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(?(?:^$([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*`"(?.*)`"\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