287 lines
9.3 KiB
PowerShell
287 lines
9.3 KiB
PowerShell
param(
|
|
[string]$SourceDb = '',
|
|
[int]$ProbePort = 18088,
|
|
[string]$EvidenceDate = (Get-Date -Format 'yyyy-MM-dd')
|
|
)
|
|
|
|
$ErrorActionPreference = 'Stop'
|
|
|
|
$projectRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
|
|
if ([string]::IsNullOrWhiteSpace($SourceDb)) {
|
|
$SourceDb = Join-Path $projectRoot 'data\user_management.db'
|
|
}
|
|
|
|
$evidenceRoot = Join-Path $projectRoot "docs\evidence\ops\$EvidenceDate\secret-boundary"
|
|
$goCacheRoot = Join-Path $projectRoot '.cache'
|
|
$goBuildCache = Join-Path $goCacheRoot 'go-build'
|
|
$goModCache = Join-Path $goCacheRoot 'gomod'
|
|
$goPath = Join-Path $goCacheRoot 'gopath'
|
|
$timestamp = Get-Date -Format 'yyyyMMdd-HHmmss'
|
|
$drillRoot = Join-Path $evidenceRoot $timestamp
|
|
$isolatedDb = Join-Path $drillRoot 'user_management.secret-boundary.db'
|
|
$isolatedConfig = Join-Path $drillRoot 'config.secret-boundary.yaml'
|
|
$serverExe = Join-Path $drillRoot 'server-secret-boundary.exe'
|
|
$serverStdOut = Join-Path $drillRoot 'server.stdout.log'
|
|
$serverStdErr = Join-Path $drillRoot 'server.stderr.log'
|
|
$capabilitiesPath = Join-Path $drillRoot 'capabilities.json'
|
|
$reportPath = Join-Path $drillRoot 'SECRET_BOUNDARY_DRILL.md'
|
|
$syntheticJWTSecret = 'secret-boundary-drill-0123456789abcdef-UVWXYZ'
|
|
|
|
New-Item -ItemType Directory -Force $evidenceRoot, $drillRoot, $goBuildCache, $goModCache, $goPath | Out-Null
|
|
|
|
function Test-UrlReady {
|
|
param(
|
|
[Parameter(Mandatory = $true)][string]$Url
|
|
)
|
|
|
|
try {
|
|
$response = Invoke-WebRequest $Url -UseBasicParsing -TimeoutSec 2
|
|
return $response.StatusCode -ge 200 -and $response.StatusCode -lt 500
|
|
} catch {
|
|
return $false
|
|
}
|
|
}
|
|
|
|
function Wait-UrlReady {
|
|
param(
|
|
[Parameter(Mandatory = $true)][string]$Url,
|
|
[Parameter(Mandatory = $true)][string]$Label,
|
|
[int]$RetryCount = 120,
|
|
[int]$DelayMs = 500
|
|
)
|
|
|
|
for ($i = 0; $i -lt $RetryCount; $i++) {
|
|
if (Test-UrlReady -Url $Url) {
|
|
return
|
|
}
|
|
Start-Sleep -Milliseconds $DelayMs
|
|
}
|
|
|
|
throw "$Label did not become ready: $Url"
|
|
}
|
|
|
|
function Stop-TreeProcess {
|
|
param(
|
|
[Parameter(Mandatory = $false)]$Process
|
|
)
|
|
|
|
if (-not $Process) {
|
|
return
|
|
}
|
|
|
|
if (-not $Process.HasExited) {
|
|
try {
|
|
taskkill /PID $Process.Id /T /F *> $null
|
|
} catch {
|
|
Stop-Process -Id $Process.Id -Force -ErrorAction SilentlyContinue
|
|
}
|
|
}
|
|
}
|
|
|
|
function Build-IsolatedConfig {
|
|
param(
|
|
[Parameter(Mandatory = $true)][string]$TemplatePath,
|
|
[Parameter(Mandatory = $true)][string]$OutputPath,
|
|
[Parameter(Mandatory = $true)][string]$DbPath,
|
|
[Parameter(Mandatory = $true)][int]$Port
|
|
)
|
|
|
|
$content = Get-Content $TemplatePath -Raw -Encoding UTF8
|
|
$dbPathForYaml = ($DbPath -replace '\\', '/')
|
|
$content = $content -replace '(?m)^ port: \d+$', " port: $Port"
|
|
$content = [regex]::Replace(
|
|
$content,
|
|
'(?ms)(sqlite:\s*\r?\n\s*path:\s*).+?(\r?\n)',
|
|
"`$1`"$dbPathForYaml`"`$2"
|
|
)
|
|
Set-Content -Path $OutputPath -Value $content -Encoding UTF8
|
|
}
|
|
|
|
function Get-ConfigBlock {
|
|
param(
|
|
[Parameter(Mandatory = $true)][string]$Content,
|
|
[Parameter(Mandatory = $true)][string]$Name,
|
|
[int]$Indent = 0
|
|
)
|
|
|
|
$currentIndent = ' ' * $Indent
|
|
$childIndent = ' ' * ($Indent + 2)
|
|
$pattern = "(?ms)^$([regex]::Escape($currentIndent))$([regex]::Escape($Name)):\s*\r?\n(?<body>(?:^$([regex]::Escape($childIndent)).*\r?\n)*)"
|
|
$match = [regex]::Match($Content, $pattern)
|
|
if (-not $match.Success) {
|
|
throw "config block not found: $Name"
|
|
}
|
|
|
|
return $match.Groups['body'].Value
|
|
}
|
|
|
|
function Get-QuotedFieldValue {
|
|
param(
|
|
[Parameter(Mandatory = $true)][string]$Content,
|
|
[Parameter(Mandatory = $true)][string]$Field
|
|
)
|
|
|
|
$match = [regex]::Match($Content, "(?m)^\s*$([regex]::Escape($Field)):\s*`"(?<value>.*)`"\s*$")
|
|
if (-not $match.Success) {
|
|
throw "quoted field not found: $Field"
|
|
}
|
|
|
|
return $match.Groups['value'].Value
|
|
}
|
|
|
|
if (-not (Test-Path $SourceDb)) {
|
|
throw "source db not found: $SourceDb"
|
|
}
|
|
|
|
$configPath = Join-Path $projectRoot 'configs\config.yaml'
|
|
$configContent = Get-Content $configPath -Raw -Encoding UTF8
|
|
$gitignorePath = Join-Path $projectRoot '.gitignore'
|
|
$gitignoreContent = Get-Content $gitignorePath -Raw -Encoding UTF8
|
|
|
|
$jwtBlock = Get-ConfigBlock -Content $configContent -Name 'jwt'
|
|
$databaseBlock = Get-ConfigBlock -Content $configContent -Name 'database'
|
|
$postgresBlock = Get-ConfigBlock -Content $databaseBlock -Name 'postgresql' -Indent 2
|
|
$mysqlBlock = Get-ConfigBlock -Content $databaseBlock -Name 'mysql' -Indent 2
|
|
|
|
$jwtSecretTemplateValue = Get-QuotedFieldValue -Content $jwtBlock -Field 'secret'
|
|
$postgresPasswordValue = Get-QuotedFieldValue -Content $postgresBlock -Field 'password'
|
|
$mysqlPasswordValue = Get-QuotedFieldValue -Content $mysqlBlock -Field 'password'
|
|
|
|
if ($jwtSecretTemplateValue -ne '') {
|
|
throw "expected jwt.secret in config template to be blank, got: $jwtSecretTemplateValue"
|
|
}
|
|
if ($postgresPasswordValue -ne '') {
|
|
throw 'expected postgresql.password in config template to be blank'
|
|
}
|
|
if ($mysqlPasswordValue -ne '') {
|
|
throw 'expected mysql.password in config template to be blank'
|
|
}
|
|
|
|
foreach ($forbiddenToken in @(
|
|
'your-secret-key-change-in-production',
|
|
'replace-with-secret'
|
|
)) {
|
|
if ($configContent -match [regex]::Escape($forbiddenToken)) {
|
|
throw "forbidden placeholder still present in config template: $forbiddenToken"
|
|
}
|
|
}
|
|
|
|
if ($gitignoreContent -notmatch '(?m)^data/jwt/\*\.pem\r?$') {
|
|
throw '.gitignore is missing data/jwt/*.pem'
|
|
}
|
|
if ($gitignoreContent -notmatch '(?m)^\.env\r?$') {
|
|
throw '.gitignore is missing .env'
|
|
}
|
|
if ($gitignoreContent -notmatch '(?m)^\.env\.local\r?$') {
|
|
throw '.gitignore is missing .env.local'
|
|
}
|
|
|
|
Copy-Item $SourceDb $isolatedDb -Force
|
|
Build-IsolatedConfig `
|
|
-TemplatePath $configPath `
|
|
-OutputPath $isolatedConfig `
|
|
-DbPath $isolatedDb `
|
|
-Port $ProbePort
|
|
|
|
Push-Location $projectRoot
|
|
try {
|
|
$env:GOCACHE = $goBuildCache
|
|
$env:GOMODCACHE = $goModCache
|
|
$env:GOPATH = $goPath
|
|
& go build -o $serverExe .\cmd\server
|
|
if ($LASTEXITCODE -ne 0) {
|
|
throw 'build secret boundary server failed'
|
|
}
|
|
} finally {
|
|
Pop-Location
|
|
Remove-Item Env:GOCACHE, Env:GOMODCACHE, Env:GOPATH -ErrorAction SilentlyContinue
|
|
}
|
|
|
|
$previousConfigPath = $env:UMS_CONFIG_PATH
|
|
$previousJWTAlgorithm = $env:UMS_JWT_ALGORITHM
|
|
$previousJWTSecret = $env:UMS_JWT_SECRET
|
|
$serverProcess = $null
|
|
|
|
try {
|
|
$env:UMS_CONFIG_PATH = $isolatedConfig
|
|
$env:UMS_JWT_ALGORITHM = 'HS256'
|
|
$env:UMS_JWT_SECRET = $syntheticJWTSecret
|
|
|
|
Remove-Item $serverStdOut, $serverStdErr -Force -ErrorAction SilentlyContinue
|
|
$serverProcess = Start-Process `
|
|
-FilePath $serverExe `
|
|
-WorkingDirectory $projectRoot `
|
|
-PassThru `
|
|
-WindowStyle Hidden `
|
|
-RedirectStandardOutput $serverStdOut `
|
|
-RedirectStandardError $serverStdErr
|
|
|
|
Wait-UrlReady -Url "http://127.0.0.1:$ProbePort/health" -Label 'secret boundary health endpoint'
|
|
Wait-UrlReady -Url "http://127.0.0.1:$ProbePort/health/ready" -Label 'secret boundary readiness endpoint'
|
|
$capabilities = Invoke-RestMethod "http://127.0.0.1:$ProbePort/api/v1/auth/capabilities" -TimeoutSec 5
|
|
Set-Content -Path $capabilitiesPath -Value (($capabilities.data | ConvertTo-Json -Depth 6) + [Environment]::NewLine) -Encoding UTF8
|
|
} finally {
|
|
Stop-TreeProcess $serverProcess
|
|
|
|
if ([string]::IsNullOrWhiteSpace($previousConfigPath)) {
|
|
Remove-Item Env:UMS_CONFIG_PATH -ErrorAction SilentlyContinue
|
|
} else {
|
|
$env:UMS_CONFIG_PATH = $previousConfigPath
|
|
}
|
|
|
|
if ([string]::IsNullOrWhiteSpace($previousJWTAlgorithm)) {
|
|
Remove-Item Env:UMS_JWT_ALGORITHM -ErrorAction SilentlyContinue
|
|
} else {
|
|
$env:UMS_JWT_ALGORITHM = $previousJWTAlgorithm
|
|
}
|
|
|
|
if ([string]::IsNullOrWhiteSpace($previousJWTSecret)) {
|
|
Remove-Item Env:UMS_JWT_SECRET -ErrorAction SilentlyContinue
|
|
} else {
|
|
$env:UMS_JWT_SECRET = $previousJWTSecret
|
|
}
|
|
}
|
|
|
|
$reportLines = @(
|
|
'# Secret Boundary Drill',
|
|
'',
|
|
"- Generated at: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss zzz')",
|
|
"- Source DB: $SourceDb",
|
|
"- Isolated DB: $isolatedDb",
|
|
"- Isolated config: $isolatedConfig",
|
|
'',
|
|
'## Template Validation',
|
|
'',
|
|
"- config template jwt.secret blank: $($jwtSecretTemplateValue -eq '')",
|
|
"- config template postgresql.password blank: $($postgresPasswordValue -eq '')",
|
|
"- config template mysql.password blank: $($mysqlPasswordValue -eq '')",
|
|
'- forbidden placeholders removed from configs/config.yaml: True',
|
|
"- .gitignore protects local JWT key files: $($gitignoreContent -match '(?m)^data/jwt/\*\.pem\r?$')",
|
|
"- .gitignore protects .env files: $($gitignoreContent -match '(?m)^\.env\r?$' -and $gitignoreContent -match '(?m)^\.env\.local\r?$')",
|
|
'',
|
|
'## Runtime Injection Validation',
|
|
'',
|
|
'- Startup path: UMS_CONFIG_PATH + UMS_JWT_ALGORITHM + UMS_JWT_SECRET',
|
|
"- Synthetic JWT algorithm injected: HS256",
|
|
"- Synthetic JWT secret length: $($syntheticJWTSecret.Length)",
|
|
'- GET /health: pass',
|
|
'- GET /health/ready: pass',
|
|
"- GET /api/v1/auth/capabilities: $(($capabilities.data | ConvertTo-Json -Compress))",
|
|
'',
|
|
'## Scope Note',
|
|
'',
|
|
'- This drill proves the repo-level secret boundary and environment injection path are executable locally.',
|
|
'- It does not prove external secrets manager, KMS rotation, or CI/CD environment delivery evidence.',
|
|
'',
|
|
'## Evidence Files',
|
|
'',
|
|
"- $(Split-Path $serverStdOut -Leaf)",
|
|
"- $(Split-Path $serverStdErr -Leaf)",
|
|
"- $(Split-Path $capabilitiesPath -Leaf)",
|
|
"- $(Split-Path $isolatedConfig -Leaf)",
|
|
''
|
|
)
|
|
|
|
Set-Content -Path $reportPath -Value ($reportLines -join [Environment]::NewLine) -Encoding UTF8
|
|
Get-Content $reportPath
|