初版功能完成
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
output_directory="${1:?usage: Collect-ServerEvidence.sh OUTPUT_DIRECTORY}"
|
||||
mkdir -p "$output_directory"
|
||||
stamp="$(date -u +%Y%m%dT%H%M%SZ)"
|
||||
|
||||
uname -a >"$output_directory/$stamp-uname.txt"
|
||||
ip -details address show >"$output_directory/$stamp-ip-address.txt"
|
||||
ip -4 route show table all >"$output_directory/$stamp-ipv4-routes.txt"
|
||||
{
|
||||
echo "[public-keys]"
|
||||
wg show all public-key
|
||||
echo "[listen-ports]"
|
||||
wg show all listen-port
|
||||
echo "[peers]"
|
||||
wg show all peers
|
||||
echo "[allowed-ips]"
|
||||
wg show all allowed-ips
|
||||
echo "[endpoints]"
|
||||
wg show all endpoints
|
||||
echo "[latest-handshakes]"
|
||||
wg show all latest-handshakes
|
||||
echo "[transfer]"
|
||||
wg show all transfer
|
||||
} >"$output_directory/$stamp-wireguard-public.txt"
|
||||
iptables-save >"$output_directory/$stamp-iptables.txt"
|
||||
cat /proc/sys/net/ipv4/ip_forward >"$output_directory/$stamp-ip-forward.txt"
|
||||
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
sha256sum "$output_directory/$stamp-"* >"$output_directory/$stamp-SHA256SUMS"
|
||||
fi
|
||||
|
||||
echo "Server evidence captured under $output_directory"
|
||||
@@ -0,0 +1,46 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidateSet("Engineer", "Site")]
|
||||
[string]$Role,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$OutputDirectory
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$resolved = [System.IO.Path]::GetFullPath($OutputDirectory)
|
||||
New-Item -ItemType Directory -Force -Path $resolved | Out-Null
|
||||
$stamp = (Get-Date).ToUniversalTime().ToString("yyyyMMddTHHmmssZ")
|
||||
$prefix = "$stamp-$($Role.ToLowerInvariant())"
|
||||
|
||||
$adapters = Get-NetAdapter -IncludeHidden | Select-Object Name, InterfaceDescription, ifIndex, Status, MacAddress, LinkSpeed
|
||||
$remlinkAdapters = @($adapters | Where-Object Name -eq "RemLink")
|
||||
$ipConfiguration = Get-NetIPConfiguration -All | Select-Object InterfaceAlias, InterfaceIndex, IPv4Address, IPv4DefaultGateway, DNSServer
|
||||
$routes = Get-NetRoute -AddressFamily IPv4 | Select-Object DestinationPrefix, NextHop, InterfaceAlias, InterfaceIndex, RouteMetric, Protocol, PolicyStore
|
||||
$nat = @(Get-NetNat -ErrorAction SilentlyContinue | Select-Object Name, InternalIPInterfaceAddressPrefix, ExternalIPInterfaceAddressPrefix, Active)
|
||||
$processes = Get-Process | Where-Object ProcessName -Match "RemLink|wireguard" | Select-Object ProcessName, Id, Path, StartTime
|
||||
|
||||
$inventory = [ordered]@{
|
||||
captured_at = (Get-Date).ToUniversalTime().ToString("o")
|
||||
computer = $env:COMPUTERNAME
|
||||
role = $Role
|
||||
elevated = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
|
||||
remlink_adapter_count = $remlinkAdapters.Count
|
||||
remlink_adapters = $remlinkAdapters
|
||||
adapters = $adapters
|
||||
ip_configuration = $ipConfiguration
|
||||
ipv4_routes = $routes
|
||||
existing_nat_read_only_snapshot = $nat
|
||||
processes = $processes
|
||||
}
|
||||
$path = Join-Path $resolved "$prefix-network.json"
|
||||
$inventory | ConvertTo-Json -Depth 8 | Set-Content -Encoding utf8 $path
|
||||
Get-FileHash -Algorithm SHA256 $path | Format-List | Out-File -Encoding utf8 (Join-Path $resolved "$prefix-network.sha256.txt")
|
||||
|
||||
if ($remlinkAdapters.Count -ne 1) {
|
||||
throw "Expected exactly one adapter named RemLink; found $($remlinkAdapters.Count). Evidence was saved to $path"
|
||||
}
|
||||
if (@($adapters | Where-Object { $_.Name -Match "WireGuard" -or $_.InterfaceDescription -Match "WireGuardNT" }).Count -ne 0) {
|
||||
throw "Unexpected independent WireGuard/WireGuardNT adapter detected. Evidence was saved to $path"
|
||||
}
|
||||
Write-Host "Windows evidence captured: $path"
|
||||
@@ -0,0 +1,45 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$OutputDirectory,
|
||||
[string]$EngineerA = "Engineer-A",
|
||||
[string]$SiteA = "Site-A",
|
||||
[string]$EngineerB = "Engineer-B",
|
||||
[string]$SiteB = "Site-B"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$resolved = [System.IO.Path]::GetFullPath($OutputDirectory)
|
||||
New-Item -ItemType Directory -Force -Path $resolved | Out-Null
|
||||
$scenarios = 1..18 | ForEach-Object {
|
||||
[ordered]@{
|
||||
id = "T{0:D2}" -f $_
|
||||
status = "NOT_RUN"
|
||||
started_at = $null
|
||||
completed_at = $null
|
||||
operator = $env:USERNAME
|
||||
evidence = @()
|
||||
notes = ""
|
||||
}
|
||||
}
|
||||
$manifest = [ordered]@{
|
||||
schema_version = 2
|
||||
created_at = (Get-Date).ToUniversalTime().ToString("o")
|
||||
topology = [ordered]@{
|
||||
engineer_a = $EngineerA
|
||||
site_a = $SiteA
|
||||
engineer_b = $EngineerB
|
||||
site_b = $SiteB
|
||||
}
|
||||
gates = @(
|
||||
[ordered]@{ id = "Gate A"; status = "NOT_RUN"; started_at = $null; completed_at = $null; operator = $env:USERNAME; evidence = @(); notes = "" },
|
||||
[ordered]@{ id = "Gate B"; status = "NOT_RUN"; started_at = $null; completed_at = $null; operator = $env:USERNAME; evidence = @(); notes = "" },
|
||||
[ordered]@{ id = "Gate C"; status = "NOT_RUN"; started_at = $null; completed_at = $null; operator = $env:USERNAME; evidence = @(); notes = "" },
|
||||
[ordered]@{ id = "Gate D"; status = "NOT_RUN"; started_at = $null; completed_at = $null; operator = $env:USERNAME; evidence = @(); notes = "" }
|
||||
)
|
||||
scenarios = $scenarios
|
||||
}
|
||||
$manifestPath = Join-Path $resolved "acceptance-run.json"
|
||||
$manifest | ConvertTo-Json -Depth 8 | Set-Content -Encoding utf8 $manifestPath
|
||||
Copy-Item (Join-Path $PSScriptRoot "..\..\docs\validation\T01-T18-runbook.md") (Join-Path $resolved "T01-T18-runbook.md")
|
||||
Write-Host "Acceptance run initialized: $manifestPath"
|
||||
@@ -0,0 +1,108 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$RunDirectory,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidatePattern('^(T(0[1-9]|1[0-8])|Gate [A-D])$')]
|
||||
[string]$ID,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidateSet("PASS", "FAIL", "NOT_RUN")]
|
||||
[string]$Status,
|
||||
[string[]]$EvidencePath = @(),
|
||||
[string]$Notes = "",
|
||||
[string]$Operator = $env:USERNAME
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$runRoot = [System.IO.Path]::GetFullPath($RunDirectory).TrimEnd([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar)
|
||||
$manifestPath = Join-Path $runRoot "acceptance-run.json"
|
||||
if (-not (Test-Path -LiteralPath $manifestPath -PathType Leaf)) {
|
||||
throw "Acceptance manifest not found: $manifestPath"
|
||||
}
|
||||
$manifest = Get-Content -Raw -LiteralPath $manifestPath | ConvertFrom-Json
|
||||
if ($manifest.schema_version -ne 2) {
|
||||
throw "Unsupported acceptance manifest schema version: $($manifest.schema_version)"
|
||||
}
|
||||
$entry = if ($ID.StartsWith("Gate ")) {
|
||||
@($manifest.gates | Where-Object id -eq $ID)
|
||||
} else {
|
||||
@($manifest.scenarios | Where-Object id -eq $ID)
|
||||
}
|
||||
if ($entry.Count -ne 1) {
|
||||
throw "Manifest must contain exactly one entry for $ID"
|
||||
}
|
||||
$entry = $entry[0]
|
||||
|
||||
$records = @()
|
||||
foreach ($rawPath in $EvidencePath) {
|
||||
$candidate = if ([System.IO.Path]::IsPathRooted($rawPath)) { $rawPath } else { Join-Path $runRoot $rawPath }
|
||||
$fullPath = [System.IO.Path]::GetFullPath($candidate)
|
||||
$rootPrefix = $runRoot + [System.IO.Path]::DirectorySeparatorChar
|
||||
if (-not $fullPath.StartsWith($rootPrefix, [System.StringComparison]::OrdinalIgnoreCase)) {
|
||||
throw "Evidence must be stored inside the acceptance run directory: $fullPath"
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $fullPath -PathType Leaf)) {
|
||||
throw "Evidence file not found: $fullPath"
|
||||
}
|
||||
if ($fullPath -eq $manifestPath) {
|
||||
throw "The acceptance manifest cannot be used as its own evidence"
|
||||
}
|
||||
$item = Get-Item -LiteralPath $fullPath
|
||||
$records += [ordered]@{
|
||||
path = [System.IO.Path]::GetRelativePath($runRoot, $fullPath).Replace('\', '/')
|
||||
sha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $fullPath).Hash.ToLowerInvariant()
|
||||
bytes = $item.Length
|
||||
}
|
||||
}
|
||||
if (($Status -eq "PASS" -or $Status -eq "FAIL") -and $records.Count -eq 0) {
|
||||
throw "$Status requires at least one evidence file"
|
||||
}
|
||||
if ($Status -eq "NOT_RUN" -and $records.Count -ne 0) {
|
||||
throw "NOT_RUN must not carry evidence; use PASS or FAIL after execution"
|
||||
}
|
||||
|
||||
if ($Status -eq "PASS" -and $ID.StartsWith("Gate ")) {
|
||||
$dependencies = switch ($ID) {
|
||||
"Gate A" { @("T01", "T02") }
|
||||
"Gate B" { @("Gate A") }
|
||||
"Gate C" { @("Gate B", "T08", "T09") }
|
||||
"Gate D" { @("Gate C", "T07", "T08", "T09") }
|
||||
}
|
||||
foreach ($dependency in $dependencies) {
|
||||
$dependencyEntry = if ($dependency.StartsWith("Gate ")) {
|
||||
@($manifest.gates | Where-Object id -eq $dependency)
|
||||
} else {
|
||||
@($manifest.scenarios | Where-Object id -eq $dependency)
|
||||
}
|
||||
if ($dependencyEntry.Count -ne 1 -or $dependencyEntry[0].status -ne "PASS") {
|
||||
throw "$ID cannot be marked PASS until $dependency is PASS"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($property in @("started_at", "completed_at", "operator", "notes")) {
|
||||
if (-not $entry.PSObject.Properties[$property]) {
|
||||
$entry | Add-Member -NotePropertyName $property -NotePropertyValue $null
|
||||
}
|
||||
}
|
||||
$now = (Get-Date).ToUniversalTime().ToString("o")
|
||||
$entry.status = $Status
|
||||
$entry.evidence = $records
|
||||
$entry.notes = $Notes
|
||||
$entry.operator = $Operator
|
||||
if ($Status -eq "NOT_RUN") {
|
||||
$entry.started_at = $null
|
||||
$entry.completed_at = $null
|
||||
} else {
|
||||
if (-not $entry.started_at) { $entry.started_at = $now }
|
||||
$entry.completed_at = $now
|
||||
}
|
||||
|
||||
$temporary = Join-Path $runRoot (".acceptance-run-" + [guid]::NewGuid().ToString("N") + ".tmp")
|
||||
try {
|
||||
$manifest | ConvertTo-Json -Depth 12 | Set-Content -Encoding utf8 -LiteralPath $temporary
|
||||
[System.IO.File]::Move($temporary, $manifestPath, $true)
|
||||
} finally {
|
||||
Remove-Item -LiteralPath $temporary -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
Write-Host "Acceptance result recorded: $ID=$Status"
|
||||
@@ -0,0 +1,80 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$RunDirectory
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$runRoot = [System.IO.Path]::GetFullPath($RunDirectory).TrimEnd([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar)
|
||||
$manifestPath = Join-Path $runRoot "acceptance-run.json"
|
||||
if (-not (Test-Path -LiteralPath $manifestPath -PathType Leaf)) {
|
||||
throw "Acceptance manifest not found: $manifestPath"
|
||||
}
|
||||
$manifest = Get-Content -Raw -LiteralPath $manifestPath | ConvertFrom-Json
|
||||
if ($manifest.schema_version -ne 2) {
|
||||
throw "Unsupported acceptance manifest schema version: $($manifest.schema_version)"
|
||||
}
|
||||
$wantedGates = @("Gate A", "Gate B", "Gate C", "Gate D")
|
||||
$wantedScenarios = 1..18 | ForEach-Object { "T{0:D2}" -f $_ }
|
||||
if (@($manifest.gates).Count -ne 4 -or @($manifest.scenarios).Count -ne 18) {
|
||||
throw "Manifest must contain four Gates and eighteen scenarios"
|
||||
}
|
||||
$actualGates = @($manifest.gates | ForEach-Object id | Sort-Object)
|
||||
$actualScenarios = @($manifest.scenarios | ForEach-Object id | Sort-Object)
|
||||
if (Compare-Object $wantedGates $actualGates -SyncWindow 0) { throw "Manifest Gate IDs are incomplete or duplicated" }
|
||||
if (Compare-Object $wantedScenarios $actualScenarios -SyncWindow 0) { throw "Manifest scenario IDs are incomplete or duplicated" }
|
||||
|
||||
$allEntries = @($manifest.gates) + @($manifest.scenarios)
|
||||
foreach ($entry in $allEntries) {
|
||||
if ($entry.status -notin @("PASS", "FAIL", "NOT_RUN")) {
|
||||
throw "$($entry.id) has invalid status $($entry.status)"
|
||||
}
|
||||
$evidence = @($entry.evidence)
|
||||
if ($entry.status -eq "NOT_RUN") {
|
||||
if ($evidence.Count -ne 0 -or $entry.completed_at) {
|
||||
throw "$($entry.id) is NOT_RUN but carries evidence or a completion time"
|
||||
}
|
||||
continue
|
||||
}
|
||||
if ($evidence.Count -eq 0 -or -not $entry.started_at -or -not $entry.completed_at) {
|
||||
throw "$($entry.id) requires evidence plus start/completion timestamps"
|
||||
}
|
||||
foreach ($record in $evidence) {
|
||||
if (-not $record.path -or $record.sha256 -notmatch '^[0-9a-f]{64}$' -or $record.bytes -lt 0) {
|
||||
throw "$($entry.id) contains an invalid evidence record"
|
||||
}
|
||||
$fullPath = [System.IO.Path]::GetFullPath((Join-Path $runRoot $record.path))
|
||||
$rootPrefix = $runRoot + [System.IO.Path]::DirectorySeparatorChar
|
||||
if (-not $fullPath.StartsWith($rootPrefix, [System.StringComparison]::OrdinalIgnoreCase) -or
|
||||
-not (Test-Path -LiteralPath $fullPath -PathType Leaf)) {
|
||||
throw "$($entry.id) evidence is missing or outside the run directory: $($record.path)"
|
||||
}
|
||||
$item = Get-Item -LiteralPath $fullPath
|
||||
$actualHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $fullPath).Hash.ToLowerInvariant()
|
||||
if ($actualHash -ne $record.sha256 -or $item.Length -ne $record.bytes) {
|
||||
throw "$($entry.id) evidence hash/size mismatch: $($record.path)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$dependenciesByGate = [ordered]@{
|
||||
"Gate A" = @("T01", "T02")
|
||||
"Gate B" = @("Gate A")
|
||||
"Gate C" = @("Gate B", "T08", "T09")
|
||||
"Gate D" = @("Gate C", "T07", "T08", "T09")
|
||||
}
|
||||
foreach ($gateID in $dependenciesByGate.Keys) {
|
||||
$gate = @($manifest.gates | Where-Object id -eq $gateID)[0]
|
||||
if ($gate.status -ne "PASS") { continue }
|
||||
foreach ($dependency in $dependenciesByGate[$gateID]) {
|
||||
$dependencyEntry = if ($dependency.StartsWith("Gate ")) {
|
||||
@($manifest.gates | Where-Object id -eq $dependency)[0]
|
||||
} else {
|
||||
@($manifest.scenarios | Where-Object id -eq $dependency)[0]
|
||||
}
|
||||
if ($dependencyEntry.status -ne "PASS") {
|
||||
throw "$gateID is PASS while dependency $dependency is not PASS"
|
||||
}
|
||||
}
|
||||
}
|
||||
Write-Host "Acceptance manifest and evidence hashes verified: $manifestPath"
|
||||
@@ -0,0 +1,40 @@
|
||||
[CmdletBinding()]
|
||||
param()
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$root = Join-Path ([System.IO.Path]::GetTempPath()) ("remlink-acceptance-tools-" + [guid]::NewGuid().ToString("N"))
|
||||
try {
|
||||
& (Join-Path $PSScriptRoot "New-AcceptanceRun.ps1") -OutputDirectory $root | Out-Null
|
||||
$missingEvidenceRejected = $false
|
||||
try {
|
||||
& (Join-Path $PSScriptRoot "Set-AcceptanceResult.ps1") -RunDirectory $root -ID T01 -Status PASS | Out-Null
|
||||
} catch {
|
||||
$missingEvidenceRejected = $true
|
||||
}
|
||||
if (-not $missingEvidenceRejected) { throw "PASS without evidence was accepted" }
|
||||
|
||||
"T01 physical evidence fixture" | Set-Content -Encoding utf8 -LiteralPath (Join-Path $root "t01.txt")
|
||||
"T02 physical evidence fixture" | Set-Content -Encoding utf8 -LiteralPath (Join-Path $root "t02.txt")
|
||||
"Gate A evidence fixture" | Set-Content -Encoding utf8 -LiteralPath (Join-Path $root "gate-a.txt")
|
||||
& (Join-Path $PSScriptRoot "Set-AcceptanceResult.ps1") -RunDirectory $root -ID T01 -Status PASS -EvidencePath t01.txt | Out-Null
|
||||
& (Join-Path $PSScriptRoot "Set-AcceptanceResult.ps1") -RunDirectory $root -ID T02 -Status PASS -EvidencePath t02.txt | Out-Null
|
||||
& (Join-Path $PSScriptRoot "Set-AcceptanceResult.ps1") -RunDirectory $root -ID "Gate A" -Status PASS -EvidencePath gate-a.txt | Out-Null
|
||||
& (Join-Path $PSScriptRoot "Test-AcceptanceRun.ps1") -RunDirectory $root | Out-Null
|
||||
|
||||
"tampered" | Add-Content -Encoding utf8 -LiteralPath (Join-Path $root "t01.txt")
|
||||
$tamperRejected = $false
|
||||
try {
|
||||
& (Join-Path $PSScriptRoot "Test-AcceptanceRun.ps1") -RunDirectory $root | Out-Null
|
||||
} catch {
|
||||
$tamperRejected = $true
|
||||
}
|
||||
if (-not $tamperRejected) { throw "Tampered acceptance evidence was accepted" }
|
||||
Write-Host "Acceptance tooling self-test passed"
|
||||
} finally {
|
||||
$resolvedTemp = [System.IO.Path]::GetFullPath([System.IO.Path]::GetTempPath()).TrimEnd([System.IO.Path]::DirectorySeparatorChar)
|
||||
$resolvedRoot = [System.IO.Path]::GetFullPath($root)
|
||||
if ($resolvedRoot.StartsWith($resolvedTemp + [System.IO.Path]::DirectorySeparatorChar, [System.StringComparison]::OrdinalIgnoreCase) -and
|
||||
[System.IO.Path]::GetFileName($resolvedRoot).StartsWith("remlink-acceptance-tools-")) {
|
||||
Remove-Item -LiteralPath $resolvedRoot -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
[CmdletBinding()]
|
||||
param()
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$repository = Split-Path -Parent (Split-Path -Parent $PSScriptRoot)
|
||||
$goFiles = Get-ChildItem -Path $repository -Recurse -File -Filter *.go | Where-Object {
|
||||
$_.FullName -notmatch '[\\/]frontend[\\/]node_modules[\\/]' -and $_.Name -notlike '*_test.go'
|
||||
}
|
||||
$violations = @()
|
||||
foreach ($file in $goFiles) {
|
||||
$relative = [System.IO.Path]::GetRelativePath($repository, $file.FullName)
|
||||
$content = Get-Content -Raw $file.FullName
|
||||
if ($relative -notlike 'internal\platform\windows\*' -and ($content -match 'powershell\.exe|Set-NetIPInterface|New-NetRoute|Remove-NetRoute')) {
|
||||
$violations += "$relative contains a Windows network command outside internal/platform/windows"
|
||||
}
|
||||
if ($content -match 'New-NetNat|Get-NetNat|Remove-NetNat|Set-NetNat|IPEnableRouter|Disable-NetFirewall|Set-NetFirewallProfile|netsh\s+advfirewall') {
|
||||
$violations += "$relative contains forbidden WinNAT, Windows forwarding, or firewall-disabling code"
|
||||
}
|
||||
if ($content -match 'S7Proxy|ModbusProxy|HTTPProxy|RDPProxy|WireGuardNT|TransitCIDR|TransitPrefix|SecondSubnetAdapter|SubnetAdapterName|\bP2P\b|\bSTUN\b|\bTURN\b|TAPDevice|EthernetFrame') {
|
||||
$violations += "$relative contains a forbidden architecture symbol"
|
||||
}
|
||||
}
|
||||
$siteConfig = Get-Content -Raw (Join-Path $repository 'internal/config/client.go')
|
||||
if ($siteConfig -match '(?i)remote[_ ]?(cidr|subnet)|transit[_ ]?(cidr|prefix)') {
|
||||
$violations += 'Site YAML configuration contains a forbidden Remote/Transit CIDR field'
|
||||
}
|
||||
$packetMux = Get-Content -Raw (Join-Path $repository 'internal/overlay/clientwg/packetmux.go')
|
||||
if ($packetMux -match 'gopacket|tcpip/header|tcprelay|udprelay|pingrelay') {
|
||||
$violations += 'PacketMux contains protocol parsing beyond IPv4 destination CIDR classification'
|
||||
}
|
||||
$serverSources = (Get-Content -Raw (Join-Path $repository 'cmd/server/main.go')) + (Get-Content -Raw (Join-Path $repository 'internal/overlay/serverwg/manager_linux.go'))
|
||||
if ($serverSources -match 'wireguard/device|internal/overlay/clientwg|NewPacketMux|NewMuxTun') {
|
||||
$violations += 'Server data plane imports client wireguard-go or PacketMux code'
|
||||
}
|
||||
$composePaths = @('deploy/docker/compose.yaml', 'deploy/docker/compose.release.yaml')
|
||||
$composes = @{}
|
||||
foreach ($composePath in $composePaths) {
|
||||
$compose = Get-Content -Raw (Join-Path $repository $composePath)
|
||||
$composes[$composePath] = $compose
|
||||
if ($compose -match '(?m)^\s*privileged\s*:') { $violations += "$composePath enables privileged mode" }
|
||||
if ($compose -notmatch 'NET_ADMIN') { $violations += "$composePath does not grant NET_ADMIN" }
|
||||
if ($compose -notmatch '/dev/net/tun') { $violations += "$composePath does not map /dev/net/tun" }
|
||||
if ($compose -match '(?m)^\s*-\s*"?(7001|6200):') { $violations += "$composePath publicly publishes an Overlay-only Control or Session port" }
|
||||
if ($compose -match '(?i)MASQUERADE|\bSNAT\b') { $violations += "$composePath configures forbidden Overlay NAT" }
|
||||
if ($compose -notmatch '\./data:/app/data') { $violations += "$composePath does not use the required ./data:/app/data persistence mount" }
|
||||
if ($compose -notmatch '\$\{REMLINK_WG_PORT:-51820\}:\$\{REMLINK_WG_PORT:-51820\}/udp') {
|
||||
$violations += "$composePath WireGuard host/container port mapping does not follow REMLINK_WG_PORT"
|
||||
}
|
||||
}
|
||||
$compose = $composes['deploy/docker/compose.yaml']
|
||||
$dockerServerConfig = Get-Content -Raw (Join-Path $repository 'deploy/docker/server.yaml')
|
||||
$dockerfile = Get-Content -Raw (Join-Path $repository 'deploy/docker/Dockerfile')
|
||||
if ($dockerServerConfig -notmatch '(?m)^\s*directory:\s*["'']?/app/data["'']?\s*$' -or $dockerfile -notmatch 'VOLUME \["/app/data"\]') {
|
||||
$violations += 'Docker Server data directory does not target the required ./data:/app/data persistence mount'
|
||||
}
|
||||
$releaseDockerfile = Get-Content -Raw (Join-Path $repository 'deploy/docker/Dockerfile.release')
|
||||
if ($releaseDockerfile -notmatch 'COPY linux-amd64/remlink-server' -or $releaseDockerfile -notmatch 'VOLUME \["/app/data"\]') {
|
||||
$violations += 'Release Dockerfile does not package the released Server binary with persistent data'
|
||||
}
|
||||
$identityPathSource = Get-Content -Raw (Join-Path $repository 'internal/identity/path_windows.go')
|
||||
$wintunRuntimeSource = Get-Content -Raw (Join-Path $repository 'internal/platform/windows/wintunruntime/runtime_windows.go')
|
||||
if (($identityPathSource + $wintunRuntimeSource) -match 'ProgramData') {
|
||||
$violations += 'Portable Windows identity or Wintun runtime still depends on ProgramData'
|
||||
}
|
||||
$releaseScript = Get-Content -Raw (Join-Path $repository 'scripts/build-release.ps1')
|
||||
foreach ($packageName in @('RemLink-Engineer-v', 'RemLink-Site-v', 'RemLink-Server-v')) {
|
||||
if ($releaseScript -notmatch [regex]::Escape($packageName)) {
|
||||
$violations += "Release build does not define the independent $packageName package"
|
||||
}
|
||||
}
|
||||
if ($releaseScript -match '\$windowsRoot') {
|
||||
$violations += 'Release build still combines Engineer and Site under one Windows package root'
|
||||
}
|
||||
if ($releaseScript -notmatch 'go build[^\r\n]+-tags\s+"desktop,production"[^\r\n]+RemLinkEngineer\.exe') {
|
||||
$violations += 'Engineer release build does not use the mandatory Wails desktop,production tags'
|
||||
}
|
||||
foreach ($dockerfileEntry in @{
|
||||
'deploy/docker/Dockerfile' = $dockerfile
|
||||
'deploy/docker/Dockerfile.release' = $releaseDockerfile
|
||||
}.GetEnumerator()) {
|
||||
if ($dockerfileEntry.Value -notmatch 'Acquire::Retries=5' -or
|
||||
$dockerfileEntry.Value -notmatch 'Acquire::http::Timeout=30' -or
|
||||
$dockerfileEntry.Value -notmatch 'APT_FORCE_IPV4' -or
|
||||
$dockerfileEntry.Value -notmatch 'APT_DEBIAN_MIRROR' -or
|
||||
$dockerfileEntry.Value -notmatch 'APT_SECURITY_MIRROR') {
|
||||
$violations += "$($dockerfileEntry.Key) does not bound apt network waits and expose the IPv4 fallback"
|
||||
}
|
||||
}
|
||||
$moduleText = Get-Content -Raw (Join-Path $repository 'go.mod')
|
||||
$packageText = Get-Content -Raw (Join-Path $repository 'frontend/package.json')
|
||||
if ($moduleText -match '@latest' -or $packageText -match '"(latest|\*)"') { $violations += 'A floating dependency version was found' }
|
||||
$serverCollector = Get-Content -Raw (Join-Path $repository 'scripts/validation/Collect-ServerEvidence.sh')
|
||||
if ($serverCollector -match '(?im)wg\s+show[^\r\n]*\bdump\b|show[^\r\n]*(private-key|preshared-key)') {
|
||||
$violations += 'Server evidence collector may export WireGuard private or preshared keys'
|
||||
}
|
||||
$preflight = Get-Content -Raw (Join-Path $repository 'deploy/docker/preflight.sh')
|
||||
if ($preflight -notmatch 'probe_interface="([^"]+)"') {
|
||||
$violations += 'Docker preflight does not define a fixed WireGuard probe interface'
|
||||
} elseif ($Matches[1].Length -gt 15) {
|
||||
$violations += "Docker preflight interface '$($Matches[1])' exceeds the Linux 15-character interface-name limit"
|
||||
}
|
||||
if ($preflight -notmatch 'ip link error') {
|
||||
$violations += 'Docker preflight suppresses the underlying ip link diagnostic'
|
||||
}
|
||||
if ($violations.Count -gt 0) {
|
||||
$violations | ForEach-Object { Write-Error $_ }
|
||||
exit 1
|
||||
}
|
||||
Write-Host "Architecture policy checks passed ($($goFiles.Count) production Go files inspected)."
|
||||
@@ -0,0 +1,48 @@
|
||||
[CmdletBinding()]
|
||||
param()
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$repository = Split-Path -Parent (Split-Path -Parent $PSScriptRoot)
|
||||
$engineerSource = Get-Content -Raw -LiteralPath (Join-Path $repository "frontend/engineer/src/api.ts")
|
||||
if ($engineerSource -notmatch 'import\.meta\.env\.DEV' -or $engineerSource -notmatch 'production Demo fallback is disabled') {
|
||||
throw "Engineer API does not fail closed when the production Native binding is absent"
|
||||
}
|
||||
if ($engineerSource -match '\\d\{1,3\}.*CIDR_INVALID') {
|
||||
throw "Engineer frontend duplicates CIDR validation instead of calling the Go Core"
|
||||
}
|
||||
$serverAppSource = Get-Content -Raw -LiteralPath (Join-Path $repository "frontend/server/src/App.vue")
|
||||
if ($serverAppSource -notmatch 'Array\.isArray' -or $serverAppSource -notmatch 'logs\.value=arrayOrEmpty') {
|
||||
throw "Server frontend does not normalize nullable legacy Admin list responses"
|
||||
}
|
||||
|
||||
$checks = @(
|
||||
[ordered]@{
|
||||
name = "Engineer"
|
||||
root = Join-Path $repository "frontend/engineer/dist"
|
||||
required = @("GetState", "CreateSession", "CheckCIDRs", "production Demo fallback is disabled")
|
||||
forbidden = @("4488624737516445881", "site-qingdao", "dev-request", "青岛现场 01")
|
||||
},
|
||||
[ordered]@{
|
||||
name = "Server"
|
||||
root = Join-Path $repository "frontend/server/dist"
|
||||
required = @("/api/v1/admin/nodes", "/api/v1/admin/sessions", "/api/v1/admin/network", "/api/v1/admin/logs")
|
||||
forbidden = @("8648912340291133", "demo-join-token-after-rotation", "节点上线,Overlay")
|
||||
}
|
||||
)
|
||||
foreach ($check in $checks) {
|
||||
if (-not (Test-Path -LiteralPath $check.root -PathType Container)) {
|
||||
throw "$($check.name) production bundle is missing; run npm build first"
|
||||
}
|
||||
$bundle = (Get-ChildItem -LiteralPath $check.root -Recurse -File | ForEach-Object { Get-Content -Raw -LiteralPath $_.FullName }) -join "`n"
|
||||
foreach ($required in $check.required) {
|
||||
if (-not $bundle.Contains($required)) {
|
||||
throw "$($check.name) production bundle is missing required runtime marker: $required"
|
||||
}
|
||||
}
|
||||
foreach ($forbidden in $check.forbidden) {
|
||||
if ($bundle.Contains($forbidden)) {
|
||||
throw "$($check.name) production bundle contains development fixture data: $forbidden"
|
||||
}
|
||||
}
|
||||
}
|
||||
Write-Host "Frontend production bundles contain live adapters and no Demo fixtures"
|
||||
@@ -0,0 +1,177 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$PackagePath,
|
||||
|
||||
[ValidateSet("Engineer", "Site", "Server")]
|
||||
[string]$Role
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$resolvedPackage = [System.IO.Path]::GetFullPath($PackagePath)
|
||||
$temporaryRoot = $null
|
||||
$packageRoot = $resolvedPackage
|
||||
try {
|
||||
if (Test-Path -LiteralPath $resolvedPackage -PathType Leaf) {
|
||||
if ([System.IO.Path]::GetExtension($resolvedPackage) -ne ".zip") {
|
||||
throw "Release package file must be a ZIP: $resolvedPackage"
|
||||
}
|
||||
$temporaryRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("remlink-release-verify-" + [guid]::NewGuid().ToString("N"))
|
||||
New-Item -ItemType Directory -Path $temporaryRoot | Out-Null
|
||||
Expand-Archive -LiteralPath $resolvedPackage -DestinationPath $temporaryRoot
|
||||
$roots = @(Get-ChildItem -LiteralPath $temporaryRoot -Directory)
|
||||
$looseFiles = @(Get-ChildItem -LiteralPath $temporaryRoot -File)
|
||||
if ($roots.Count -ne 1 -or $looseFiles.Count -ne 0) {
|
||||
throw "Release ZIP must contain exactly one top-level package directory"
|
||||
}
|
||||
$packageRoot = $roots[0].FullName
|
||||
} elseif (-not (Test-Path -LiteralPath $resolvedPackage -PathType Container)) {
|
||||
throw "Release package not found: $resolvedPackage"
|
||||
}
|
||||
|
||||
$roleMarkers = [ordered]@{
|
||||
Engineer = "RemLinkEngineer.exe"
|
||||
Site = "RemLinkSite.exe"
|
||||
Server = "linux-amd64/remlink-server"
|
||||
}
|
||||
$detectedRoles = @($roleMarkers.GetEnumerator() | Where-Object {
|
||||
Test-Path -LiteralPath (Join-Path $packageRoot $_.Value) -PathType Leaf
|
||||
} | ForEach-Object Key)
|
||||
if (-not $Role) {
|
||||
if ($detectedRoles.Count -ne 1) {
|
||||
throw "Unable to infer one package role; found markers for: $($detectedRoles -join ', ')"
|
||||
}
|
||||
$Role = $detectedRoles[0]
|
||||
}
|
||||
if ($detectedRoles.Count -ne 1 -or $detectedRoles[0] -ne $Role) {
|
||||
throw "$Role package must contain only its own executable; found markers for: $($detectedRoles -join ', ')"
|
||||
}
|
||||
$forbiddenExecutableNames = switch ($Role) {
|
||||
Engineer { @("RemLinkSite.exe", "remlink-server") }
|
||||
Site { @("RemLinkEngineer.exe", "remlink-server") }
|
||||
Server { @("RemLinkEngineer.exe", "RemLinkSite.exe") }
|
||||
}
|
||||
$foreignExecutables = @(Get-ChildItem -LiteralPath $packageRoot -Recurse -File | Where-Object { $_.Name -in $forbiddenExecutableNames })
|
||||
if ($foreignExecutables.Count -ne 0) {
|
||||
throw "$Role package contains a foreign executable: $($foreignExecutables[0].FullName)"
|
||||
}
|
||||
|
||||
$required = @(
|
||||
"README.md", "BUILD-INFO.json", "SHA256SUMS.txt", "docs/deployment-and-usage.md",
|
||||
"scripts/validation/Test-ReleasePackage.ps1"
|
||||
)
|
||||
switch ($Role) {
|
||||
Engineer {
|
||||
$required += @("RemLinkEngineer.exe", "engineer.yaml", "THIRD_PARTY_NOTICES.md")
|
||||
}
|
||||
Site {
|
||||
$required += @("RemLinkSite.exe", "site.yaml", "THIRD_PARTY_NOTICES.md")
|
||||
}
|
||||
Server {
|
||||
$required += @(
|
||||
"linux-amd64/remlink-server", "linux-amd64/server.yaml", "linux-amd64/THIRD_PARTY_NOTICES.md",
|
||||
"docker/compose.yaml", "docker/Dockerfile", "docker/compose.release.yaml", "docker/Dockerfile.release",
|
||||
"docker/.env.example", "docker/.env.china.example", "docker/preflight.sh", "docker/server.yaml", "docker/README.md",
|
||||
"docs/implementation-status.md",
|
||||
"scripts/validation/New-AcceptanceRun.ps1", "scripts/validation/Set-AcceptanceResult.ps1",
|
||||
"scripts/validation/Test-AcceptanceRun.ps1", "scripts/validation/Test-AcceptanceTools.ps1",
|
||||
"scripts/validation/Test-ReleasePackage.ps1", "scripts/validation/Collect-WindowsEvidence.ps1",
|
||||
"scripts/validation/Collect-ServerEvidence.sh", "scripts/validation/Test-RemoteTargets.ps1",
|
||||
"docs/validation/T01-T18-runbook.md", "docs/validation/requirements-evidence.md"
|
||||
)
|
||||
}
|
||||
}
|
||||
foreach ($relative in $required) {
|
||||
$target = Join-Path $packageRoot $relative
|
||||
if (-not (Test-Path -LiteralPath $target -PathType Leaf)) {
|
||||
throw "Required $Role release entry is missing: $relative"
|
||||
}
|
||||
if ((Get-Item -LiteralPath $target).Length -eq 0) {
|
||||
throw "Required $Role release entry is empty: $relative"
|
||||
}
|
||||
}
|
||||
if ($Role -in @("Engineer", "Site")) {
|
||||
$clientConfigName = if ($Role -eq "Engineer") { "engineer.yaml" } else { "site.yaml" }
|
||||
$clientConfig = Get-Content -Raw -LiteralPath (Join-Path $packageRoot $clientConfigName)
|
||||
if ($clientConfig -notmatch '(?m)^join_token:\s*') {
|
||||
throw "$Role package YAML does not expose the join_token convenience field"
|
||||
}
|
||||
if ($clientConfig -match '(?im)^\s*(node_token|wg_private_key)\s*:') {
|
||||
throw "$Role package YAML contains a durable Node secret field"
|
||||
}
|
||||
}
|
||||
|
||||
$checksumPath = Join-Path $packageRoot "SHA256SUMS.txt"
|
||||
$checksums = @{}
|
||||
foreach ($line in Get-Content -LiteralPath $checksumPath) {
|
||||
if ($line -notmatch '^([0-9a-f]{64}) (.+)$') {
|
||||
throw "Malformed checksum line: $line"
|
||||
}
|
||||
$relative = $Matches[2].Replace('\', '/')
|
||||
if ([System.IO.Path]::IsPathRooted($relative) -or $relative -match '(^|/)\.\.(/|$)' -or $relative -eq "SHA256SUMS.txt") {
|
||||
throw "Unsafe or self-referential checksum path: $relative"
|
||||
}
|
||||
if ($checksums.ContainsKey($relative)) {
|
||||
throw "Duplicate checksum path: $relative"
|
||||
}
|
||||
$checksums[$relative] = $Matches[1]
|
||||
}
|
||||
$actualFiles = @(Get-ChildItem -LiteralPath $packageRoot -Recurse -File | ForEach-Object {
|
||||
[System.IO.Path]::GetRelativePath($packageRoot, $_.FullName).Replace('\', '/')
|
||||
} | Where-Object { $_ -ne "SHA256SUMS.txt" })
|
||||
if ($checksums.Count -ne $actualFiles.Count) {
|
||||
throw "Checksum count $($checksums.Count) does not match package file count $($actualFiles.Count)"
|
||||
}
|
||||
foreach ($relative in $actualFiles) {
|
||||
if (-not $checksums.ContainsKey($relative)) {
|
||||
throw "Package file is not covered by SHA256SUMS.txt: $relative"
|
||||
}
|
||||
$actualHash = (Get-FileHash -Algorithm SHA256 -LiteralPath (Join-Path $packageRoot $relative)).Hash.ToLowerInvariant()
|
||||
if ($actualHash -ne $checksums[$relative]) {
|
||||
throw "Checksum mismatch: $relative"
|
||||
}
|
||||
}
|
||||
|
||||
$buildInfo = Get-Content -Raw -LiteralPath (Join-Path $packageRoot "BUILD-INFO.json") | ConvertFrom-Json
|
||||
if (-not $buildInfo.version -or $buildInfo.role -ne $Role -or @($buildInfo.target).Count -ne 1) {
|
||||
throw "BUILD-INFO.json does not describe one $Role target"
|
||||
}
|
||||
if ($Role -in @("Engineer", "Site") -and $buildInfo.portable_data_root -ne "executable_directory") {
|
||||
throw "$Role BUILD-INFO.json does not declare its portable executable data root"
|
||||
}
|
||||
if ($Role -eq "Engineer" -and (@($buildInfo.wails_build_tags) -join ',') -ne 'desktop,production') {
|
||||
throw "Engineer BUILD-INFO.json does not declare the mandatory Wails desktop,production tags"
|
||||
}
|
||||
|
||||
if ($Role -eq "Server") {
|
||||
$acceptanceRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("remlink-acceptance-smoke-" + [guid]::NewGuid().ToString("N"))
|
||||
try {
|
||||
& (Join-Path $packageRoot "scripts/validation/New-AcceptanceRun.ps1") -OutputDirectory $acceptanceRoot
|
||||
$manifest = Get-Content -Raw -LiteralPath (Join-Path $acceptanceRoot "acceptance-run.json") | ConvertFrom-Json
|
||||
if ($manifest.schema_version -ne 2 -or @($manifest.gates).Count -ne 4 -or @($manifest.scenarios).Count -ne 18) {
|
||||
throw "Packaged acceptance initializer produced an invalid manifest"
|
||||
}
|
||||
if (@($manifest.gates + $manifest.scenarios | Where-Object status -ne "NOT_RUN").Count -ne 0) {
|
||||
throw "Packaged acceptance initializer fabricated a completed result"
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath (Join-Path $acceptanceRoot "T01-T18-runbook.md") -PathType Leaf)) {
|
||||
throw "Packaged acceptance initializer did not copy its runbook"
|
||||
}
|
||||
} finally {
|
||||
$tempBase = [System.IO.Path]::GetFullPath([System.IO.Path]::GetTempPath())
|
||||
if ($acceptanceRoot -and $acceptanceRoot.StartsWith($tempBase, [System.StringComparison]::OrdinalIgnoreCase) -and
|
||||
[System.IO.Path]::GetFileName($acceptanceRoot).StartsWith("remlink-acceptance-smoke-")) {
|
||||
Remove-Item -LiteralPath $acceptanceRoot -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
}
|
||||
Write-Host "$Role release package verified: $packageRoot ($($checksums.Count) checksums)"
|
||||
} finally {
|
||||
if ($temporaryRoot) {
|
||||
$tempBase = [System.IO.Path]::GetFullPath([System.IO.Path]::GetTempPath())
|
||||
if ($temporaryRoot.StartsWith($tempBase, [System.StringComparison]::OrdinalIgnoreCase) -and
|
||||
[System.IO.Path]::GetFileName($temporaryRoot).StartsWith("remlink-release-verify-")) {
|
||||
Remove-Item -LiteralPath $temporaryRoot -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$TargetIPv4,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$OutputDirectory,
|
||||
[int[]]$TCPPorts = @(102, 502, 80, 3389),
|
||||
[int]$UDPPort = 0,
|
||||
[string]$UDPPayload = "RemLink-T09",
|
||||
[int]$TimeoutMilliseconds = 3000
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$address = [System.Net.IPAddress]::Parse($TargetIPv4)
|
||||
if ($address.AddressFamily -ne [System.Net.Sockets.AddressFamily]::InterNetwork) {
|
||||
throw "TargetIPv4 must be IPv4"
|
||||
}
|
||||
$resolved = [System.IO.Path]::GetFullPath($OutputDirectory)
|
||||
New-Item -ItemType Directory -Force -Path $resolved | Out-Null
|
||||
$results = [ordered]@{
|
||||
captured_at = (Get-Date).ToUniversalTime().ToString("o")
|
||||
target = $TargetIPv4
|
||||
ping = $null
|
||||
tcp = @()
|
||||
udp = $null
|
||||
}
|
||||
|
||||
$ping = Test-Connection -TargetName $TargetIPv4 -Count 4 -ErrorAction SilentlyContinue
|
||||
$results.ping = @($ping | Select-Object Address, Latency, Status)
|
||||
foreach ($port in $TCPPorts) {
|
||||
$probe = Test-NetConnection -ComputerName $TargetIPv4 -Port $port -InformationLevel Detailed -WarningAction SilentlyContinue
|
||||
$results.tcp += [ordered]@{ port = $port; success = [bool]$probe.TcpTestSucceeded; remote_address = "$($probe.RemoteAddress)" }
|
||||
}
|
||||
if ($UDPPort -gt 0) {
|
||||
$client = [System.Net.Sockets.UdpClient]::new([System.Net.Sockets.AddressFamily]::InterNetwork)
|
||||
try {
|
||||
$client.Client.ReceiveTimeout = $TimeoutMilliseconds
|
||||
$payload = [Text.Encoding]::UTF8.GetBytes($UDPPayload)
|
||||
[void]$client.Send($payload, $payload.Length, $TargetIPv4, $UDPPort)
|
||||
$remote = [System.Net.IPEndPoint]::new([System.Net.IPAddress]::Any, 0)
|
||||
$reply = $client.Receive([ref]$remote)
|
||||
$replyText = [Text.Encoding]::UTF8.GetString($reply)
|
||||
$results.udp = [ordered]@{ port = $UDPPort; success = ($replyText -eq $UDPPayload); reply = $replyText; remote = "$remote" }
|
||||
} catch {
|
||||
$results.udp = [ordered]@{ port = $UDPPort; success = $false; error = $_.Exception.Message }
|
||||
} finally {
|
||||
$client.Dispose()
|
||||
}
|
||||
}
|
||||
$path = Join-Path $resolved ((Get-Date).ToUniversalTime().ToString("yyyyMMddTHHmmssZ") + "-target-$TargetIPv4.json")
|
||||
$results | ConvertTo-Json -Depth 8 | Set-Content -Encoding utf8 $path
|
||||
Get-FileHash -Algorithm SHA256 -LiteralPath $path | Format-List | Out-File -Encoding utf8 "$path.sha256.txt"
|
||||
Write-Host "Remote target evidence: $path"
|
||||
Reference in New Issue
Block a user