param( [string]$ServerHost = "192.168.3.90", [string]$BackendBaseUrl = "http://192.168.3.90:8001", [string]$FrontendBaseUrl = "http://192.168.3.90:3003", [string]$SshUser = "root", [string]$SshKeyPath = "$HOME\.ssh\water_audit_ed25519", [string]$RemoteProjectPath = "/opt/ai-contract-platform", [string]$ComposeFile = "docker-compose.server.yml", [string]$ArtifactRoot = "", [string]$AdminAccount = "admin@example.com", [string]$DefaultPassword = "123456" ) $ErrorActionPreference = "Stop" $repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..") $timestamp = Get-Date -Format "yyyy-MM-ddTHH-mm-ss" $safeStamp = $timestamp -replace '[^0-9A-Za-z]', '_' if ([string]::IsNullOrWhiteSpace($ArtifactRoot)) { $ArtifactRoot = Join-Path $repoRoot "project-docs\qa-artifacts\phase5-security-baseline" } if (-not [System.IO.Path]::IsPathRooted($ArtifactRoot)) { $ArtifactRoot = Join-Path $repoRoot $ArtifactRoot } $artifactDir = Join-Path $ArtifactRoot $timestamp $summaryJson = Join-Path $artifactDir "security-baseline-summary.json" $summaryMd = Join-Path $artifactDir "security-baseline-summary.md" New-Item -ItemType Directory -Force -Path $artifactDir | Out-Null if (-not (Test-Path "$HOME\.Codex\skills\gstack\bin")) { throw "gstack is required for all AI-assisted work in this repo." } if (-not (Test-Path $SshKeyPath)) { throw "SSH key not found: $SshKeyPath" } Add-Type -AssemblyName System.Net.Http $checks = New-Object System.Collections.Generic.List[object] function Add-Check { param( [string]$Key, [string]$Label, [ValidateSet("pass", "fail", "warn")] [string]$Status, [string]$Detail, [bool]$Required = $true, [hashtable]$Meta = @{} ) $checks.Add([ordered]@{ key = $Key label = $Label status = $Status required = $Required detail = $Detail meta = $Meta }) | Out-Null } function Save-Json { param( [string]$Path, [object]$Value ) $Value | ConvertTo-Json -Depth 12 | Set-Content -Encoding UTF8 -Path $Path } function Invoke-HttpProbe { param( [string]$Method, [string]$Url, [object]$Body = $null, [hashtable]$Headers = @{} ) $handler = New-Object System.Net.Http.HttpClientHandler $client = New-Object System.Net.Http.HttpClient($handler) $client.Timeout = [TimeSpan]::FromSeconds(15) try { $httpMethod = New-Object System.Net.Http.HttpMethod($Method) $request = New-Object System.Net.Http.HttpRequestMessage($httpMethod, $Url) foreach ($key in $Headers.Keys) { $request.Headers.TryAddWithoutValidation($key, [string]$Headers[$key]) | Out-Null } if ($null -ne $Body) { $json = $Body | ConvertTo-Json -Depth 8 -Compress $request.Content = New-Object System.Net.Http.StringContent($json, [System.Text.Encoding]::UTF8, "application/json") } $response = $client.SendAsync($request).GetAwaiter().GetResult() $text = $response.Content.ReadAsStringAsync().GetAwaiter().GetResult() $parsed = $null if (-not [string]::IsNullOrWhiteSpace($text)) { try { $parsed = $text | ConvertFrom-Json -ErrorAction Stop } catch { $parsed = $null } } return [ordered]@{ ok = $true url = $Url method = $Method status_code = [int]$response.StatusCode body = $parsed body_text = if ($parsed) { $null } else { $text } } } catch { return [ordered]@{ ok = $false url = $Url method = $Method status_code = $null error = $_.Exception.Message } } finally { $client.Dispose() $handler.Dispose() } } function Invoke-Remote { param( [string]$Name, [string]$Command ) $target = "${SshUser}@${ServerHost}" $logPath = Join-Path $artifactDir "$Name.log" $args = @( "-i", $SshKeyPath, "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=accept-new", "-o", "ConnectTimeout=10", $target, $Command ) $previousErrorActionPreference = $ErrorActionPreference $ErrorActionPreference = "Continue" try { $output = & ssh @args 2>&1 $exitCode = $LASTEXITCODE } finally { $ErrorActionPreference = $previousErrorActionPreference } $outputText = ($output -join [Environment]::NewLine) $outputText | Set-Content -Encoding UTF8 -Path $logPath return [ordered]@{ exit_code = $exitCode output = $outputText log = $logPath } } function Test-TcpPort { param( [string]$HostName, [int]$Port, [int]$TimeoutMs = 3000 ) $client = New-Object System.Net.Sockets.TcpClient try { $async = $client.BeginConnect($HostName, $Port, $null, $null) $connected = $async.AsyncWaitHandle.WaitOne($TimeoutMs, $false) if (-not $connected) { return $false } $client.EndConnect($async) return $client.Connected } catch { return $false } finally { $client.Close() } } $readinessPath = Join-Path $artifactDir "readiness.json" $readiness = Invoke-HttpProbe -Method "GET" -Url "$BackendBaseUrl/api/v1/system/readiness" Save-Json -Path $readinessPath -Value $readiness $readinessData = $readiness.body.data $passwordRotated = $false if ($readinessData -and $readinessData.pilot_assets -and $readinessData.pilot_assets.default_admin) { $passwordRotated = [bool]$readinessData.pilot_assets.default_admin.password_rotated } $warnings = if ($readinessData -and $readinessData.summary) { [int]$readinessData.summary.warnings } else { -1 } if ($readiness.ok -and $readiness.status_code -eq 200 -and $readiness.body.code -eq 0 -and $readinessData.status -eq "ready" -and $warnings -eq 0 -and $passwordRotated) { Add-Check -Key "readiness_gate" -Label "readiness security gate" -Status "pass" -Detail "readiness=ready, warnings=0, password_rotated=true" -Meta @{ artifact = $readinessPath } } else { Add-Check -Key "readiness_gate" -Label "readiness security gate" -Status "fail" -Detail "readiness security gate failed" -Meta @{ readiness_status = $readinessData.status warnings = $warnings password_rotated = $passwordRotated artifact = $readinessPath } } $secretCommand = "cd '$RemoteProjectPath'; s=`$(docker compose -f '$ComposeFile' exec -T backend printenv SECRET_KEY | tr -d '\r'); if [ -n `"`$s`" ] && [ `"`$s`" != 'change-me' ] && [ `"`$s`" != 'change-me-in-production' ] && [ `${#s} -ge 32 ]; then echo SECRET_KEY_OK; else echo SECRET_KEY_WEAK; fi" $secretCheck = Invoke-Remote -Name "secret-key-check" -Command $secretCommand if ($secretCheck.exit_code -eq 0 -and $secretCheck.output -match "SECRET_KEY_OK") { Add-Check -Key "secret_key" -Label "production SECRET_KEY" -Status "pass" -Detail "Production SECRET_KEY is non-default and length >= 32" -Meta @{ log = $secretCheck.log } } else { Add-Check -Key "secret_key" -Label "production SECRET_KEY" -Status "fail" -Detail "Production SECRET_KEY is missing, weak or default-like" -Meta @{ log = $secretCheck.log } } $defaultLoginPath = Join-Path $artifactDir "default-password-login.json" $defaultLogin = Invoke-HttpProbe -Method "POST" -Url "$BackendBaseUrl/api/v1/auth/login" -Body @{ account = $AdminAccount password = $DefaultPassword } Save-Json -Path $defaultLoginPath -Value $defaultLogin if ($defaultLogin.ok -and $defaultLogin.status_code -eq 401) { Add-Check -Key "default_password_rejected" -Label "default password rejected" -Status "pass" -Detail "Default admin password login returned 401" -Meta @{ artifact = $defaultLoginPath } } else { Add-Check -Key "default_password_rejected" -Label "default password rejected" -Status "fail" -Detail "Default admin password was not clearly rejected" -Meta @{ status_code = $defaultLogin.status_code artifact = $defaultLoginPath } } $memberName = "phase5-security-$safeStamp@example.com" $memberPassword = "Member123456!" $register = Invoke-HttpProbe -Method "POST" -Url "$BackendBaseUrl/api/v1/auth/register" -Body @{ username = $memberName password = $memberPassword display_name = "Phase5 Security Member" } $login = Invoke-HttpProbe -Method "POST" -Url "$BackendBaseUrl/api/v1/auth/login" -Body @{ account = $memberName password = $memberPassword } $permissionPath = Join-Path $artifactDir "member-permission.json" Save-Json -Path $permissionPath -Value ([ordered]@{ register = $register; login = $login }) $token = $null if ($login.body -and $login.body.code -eq 0) { $token = $login.body.data.access_token } if ($token) { $forbidden = Invoke-HttpProbe -Method "GET" -Url "$BackendBaseUrl/api/v1/users?page=1&page_size=1" -Headers @{ Authorization = "Bearer $token" } Save-Json -Path $permissionPath -Value ([ordered]@{ register = $register; login = $login; forbidden = $forbidden }) if ($forbidden.status_code -eq 403) { Add-Check -Key "member_permission_boundary" -Label "member permission boundary" -Status "pass" -Detail "member cannot access admin users API" -Meta @{ artifact = $permissionPath } } else { Add-Check -Key "member_permission_boundary" -Label "member permission boundary" -Status "fail" -Detail "member users API access was not rejected with 403" -Meta @{ status_code = $forbidden.status_code artifact = $permissionPath } } } else { Add-Check -Key "member_permission_boundary" -Label "member permission boundary" -Status "fail" -Detail "member registration or login failed" -Meta @{ artifact = $permissionPath } } $backendOpen = Test-TcpPort -HostName $ServerHost -Port 8001 $frontendOpen = Test-TcpPort -HostName $ServerHost -Port 3003 if ($backendOpen -and $frontendOpen) { Add-Check -Key "expected_public_ports" -Label "expected public ports" -Status "pass" -Detail "Expected frontend/backend ports are reachable" } else { Add-Check -Key "expected_public_ports" -Label "expected public ports" -Status "fail" -Detail "Expected frontend/backend ports are not both reachable" -Meta @{ backend_8001 = $backendOpen frontend_3003 = $frontendOpen } } $trackedSensitivePath = Join-Path $artifactDir "tracked-sensitive-files.txt" $trackedSensitive = git -C $repoRoot ls-files | Select-String -Pattern '(^|/)(\.env|id_rsa|id_ed25519|.*\.pem|.*\.key)$' -ErrorAction SilentlyContinue if ($trackedSensitive) { ($trackedSensitive | ForEach-Object { $_.Line }) | Set-Content -Encoding UTF8 -Path $trackedSensitivePath } else { "" | Set-Content -Encoding UTF8 -Path $trackedSensitivePath } if ($trackedSensitive) { Add-Check -Key "tracked_sensitive_files" -Label "tracked sensitive files" -Status "fail" -Detail "Git tracks sensitive-looking files" -Meta @{ artifact = $trackedSensitivePath } } else { Add-Check -Key "tracked_sensitive_files" -Label "tracked sensitive files" -Status "pass" -Detail "No tracked .env/private-key-like files found" -Meta @{ artifact = $trackedSensitivePath } } $secretScanPath = Join-Path $artifactDir "source-secret-scan.txt" $secretPatterns = @( "BEGIN (RSA|OPENSSH|PRIVATE) KEY", ("z" + "mfCSR"), ("AI_CONTRACT_ADMIN_PASSWORD" + "[[:space:]]*=[[:space:]]*['`"]?123456['`"]?") ) $secretRegex = $secretPatterns -join "|" $secretScan = git -C $repoRoot grep -n -I -E $secretRegex -- backend frontend scripts docker-compose.server.yml 2>$null if ($secretScan) { ($secretScan | ForEach-Object { $_ }) | Set-Content -Encoding UTF8 -Path $secretScanPath } else { "" | Set-Content -Encoding UTF8 -Path $secretScanPath } if ($secretScan) { Add-Check -Key "source_secret_scan" -Label "source secret scan" -Status "fail" -Detail "Potential hardcoded secret pattern found in source/config" -Meta @{ artifact = $secretScanPath } } else { Add-Check -Key "source_secret_scan" -Label "source secret scan" -Status "pass" -Detail "No configured high-signal secret patterns found" -Meta @{ artifact = $secretScanPath } } $failedRequired = @($checks | Where-Object { $_.required -and $_.status -eq "fail" }) $warningsList = @($checks | Where-Object { $_.status -eq "warn" }) $overallStatus = if ($failedRequired.Count -gt 0) { "FAIL" } elseif ($warningsList.Count -gt 0) { "WARN" } else { "PASS" } $summary = [ordered]@{ name = "phase5-security-baseline" status = $overallStatus generated_at = (Get-Date).ToUniversalTime().ToString("o") artifact_dir = $artifactDir checks = $checks totals = [ordered]@{ total = $checks.Count failed_required = $failedRequired.Count warnings = $warningsList.Count } } Save-Json -Path $summaryJson -Value $summary $markdown = New-Object System.Collections.Generic.List[string] $markdown.Add("# Phase 5 Security Baseline") | Out-Null $markdown.Add("") | Out-Null $markdown.Add("- Status: $overallStatus") | Out-Null $markdown.Add("- Generated at: $($summary.generated_at)") | Out-Null $markdown.Add("- Artifact dir: $artifactDir") | Out-Null $markdown.Add("") | Out-Null $markdown.Add("| Check | Required | Status | Detail |") | Out-Null $markdown.Add("|---|---:|---|---|") | Out-Null foreach ($check in $checks) { $markdown.Add("| $($check.label) | $($check.required) | $($check.status.ToUpperInvariant()) | $($check.detail) |") | Out-Null } $markdown.Add("") | Out-Null $markdown.Add("Local artifacts:") | Out-Null $markdown.Add("") | Out-Null $markdown.Add('- `security-baseline-summary.json`') | Out-Null $markdown.Add('- `readiness.json`') | Out-Null $markdown.Add('- `default-password-login.json`') | Out-Null $markdown.Add('- `member-permission.json`') | Out-Null $markdown.Add('- `tracked-sensitive-files.txt`') | Out-Null $markdown.Add('- `source-secret-scan.txt`') | Out-Null $markdown.Add('- `*.log`') | Out-Null $markdown | Set-Content -Encoding UTF8 -Path $summaryMd Write-Host "PHASE5_SECURITY_BASELINE_$overallStatus $artifactDir" if ($overallStatus -eq "FAIL") { throw "Phase 5 security baseline failed. See $summaryMd" }