349 lines
14 KiB
PowerShell
349 lines
14 KiB
PowerShell
param(
|
|
[string]$ServerHost = "192.168.3.90",
|
|
[string]$BackendBaseUrl = "http://192.168.3.90:8001",
|
|
[string]$FrontendBaseUrl = "https://contact.yuqei.com",
|
|
[string]$SshUser = "root",
|
|
[string]$SshKeyPath = "$HOME\.ssh\water_audit_ed25519",
|
|
[string]$RemoteProjectPath = "/opt/apps/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 $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
|
|
$frontendUri = [Uri]$FrontendBaseUrl
|
|
$frontendPort = if ($frontendUri.IsDefaultPort) { if ($frontendUri.Scheme -eq "https") { 443 } else { 80 } } else { $frontendUri.Port }
|
|
$frontendOpen = Test-TcpPort -HostName $frontendUri.Host -Port $frontendPort
|
|
if ($backendOpen -and $frontendOpen) {
|
|
Add-Check -Key "expected_public_ports" -Label "expected public ports" -Status "pass" -Detail "Expected backend and public frontend ports are reachable"
|
|
} else {
|
|
Add-Check -Key "expected_public_ports" -Label "expected public ports" -Status "fail" -Detail "Expected backend and public frontend ports are not both reachable" -Meta @{
|
|
backend_8001 = $backendOpen
|
|
frontend_host = $frontendUri.Host
|
|
frontend_port = $frontendPort
|
|
frontend_public = $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"
|
|
}
|