chore: enforce production security baseline
This commit is contained in:
@@ -13,6 +13,7 @@ from app.models.user import User
|
||||
from app.services.auth_service import DEFAULT_ADMIN_PASSWORD, DEFAULT_ADMIN_USERNAME
|
||||
|
||||
router = APIRouter(prefix="/system", tags=["system"])
|
||||
WEAK_SECRET_KEYS = {"change-me", "change-me-in-production"}
|
||||
ROUTE_PERMISSION_SCOPE = "system"
|
||||
ROUTE_PERMISSION_ACTIONS = {
|
||||
"readiness": "system:readiness:get",
|
||||
@@ -38,12 +39,13 @@ def _check_database(db: Session) -> Dict[str, Any]:
|
||||
|
||||
|
||||
def _check_runtime_config() -> Dict[str, Any]:
|
||||
if settings.secret_key.strip() == "change-me":
|
||||
secret_key = settings.secret_key.strip()
|
||||
if not secret_key or secret_key in WEAK_SECRET_KEYS or len(secret_key) < 32:
|
||||
return _build_check(
|
||||
"runtime_config",
|
||||
"运行配置",
|
||||
"warning",
|
||||
"当前 SECRET_KEY 仍为默认值,试点前建议替换为环境专用密钥",
|
||||
"当前 SECRET_KEY 为空、过短或仍为默认占位值,试点前建议替换为环境专用密钥",
|
||||
required=False,
|
||||
)
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ class Settings(BaseSettings):
|
||||
api_v1_prefix: str = "/api/v1"
|
||||
secret_key: str = "change-me"
|
||||
access_token_expire_minutes: int = 120
|
||||
database_url: str = "postgresql+psycopg://postgres:zmfCSR%25816709301@192.168.3.90:5432/ai_contract_platform"
|
||||
database_url: str = "postgresql+psycopg://postgres:postgres@localhost:5432/ai_contract_platform"
|
||||
redis_url: str = "redis://localhost:6379/0"
|
||||
minio_endpoint: str = "localhost:9000"
|
||||
minio_access_key: str = "minioadmin"
|
||||
|
||||
@@ -33,7 +33,7 @@ services:
|
||||
environment:
|
||||
APP_NAME: AI Contract Platform
|
||||
APP_ENV: production
|
||||
SECRET_KEY: change-me-in-production
|
||||
SECRET_KEY: ${SECRET_KEY:?Set SECRET_KEY in .env before starting production}
|
||||
DATABASE_URL: postgresql+psycopg://postgres:postgres@postgres:5432/ai_contract_platform
|
||||
REDIS_URL: redis://redis:6379/0
|
||||
MINIO_ENDPOINT: 192.168.3.90:9000
|
||||
|
||||
@@ -90,7 +90,7 @@ npm run start -- --hostname 0.0.0.0 --port 3002
|
||||
APP_NAME=AI Contract Platform
|
||||
APP_ENV=development
|
||||
API_V1_PREFIX=/api/v1
|
||||
SECRET_KEY=change-me
|
||||
SECRET_KEY=<使用 openssl rand -hex 48 生成>
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=120
|
||||
DATABASE_URL=postgresql+psycopg://postgres:postgres@localhost:5432/ai_contract_platform
|
||||
REDIS_URL=redis://localhost:6379/0
|
||||
@@ -104,7 +104,8 @@ LOCAL_STORAGE_DIR=./storage
|
||||
说明:
|
||||
|
||||
- `DATABASE_URL` 应按实际环境覆盖,避免依赖代码内默认值。
|
||||
- 生产环境必须替换 `SECRET_KEY`。
|
||||
- 生产环境必须替换 `SECRET_KEY`,不得使用 `change-me`、`change-me-in-production` 或任何占位值。
|
||||
- 使用 `docker-compose.server.yml` 部署生产环境时,必须在服务器项目目录 `/opt/ai-contract-platform/.env` 中提供 `SECRET_KEY`,否则 compose 会拒绝启动 backend。
|
||||
|
||||
## 5. 默认账号与权限
|
||||
|
||||
|
||||
@@ -66,6 +66,34 @@ $env:AI_CONTRACT_ADMIN_PASSWORD = '<由管理员输入的实际密码>'
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts\rotate-admin-password.ps1 -ApiBase 'http://192.168.3.90:8001/api/v1'
|
||||
```
|
||||
|
||||
## 三点五、生产密钥口径
|
||||
|
||||
生产 `SECRET_KEY` 由服务器项目目录下的 `.env` 注入:
|
||||
|
||||
```text
|
||||
/opt/ai-contract-platform/.env
|
||||
```
|
||||
|
||||
要求:
|
||||
|
||||
1. `.env` 权限为 `600`。
|
||||
2. `SECRET_KEY` 不得为 `change-me`、`change-me-in-production` 或其他占位值。
|
||||
3. `SECRET_KEY` 长度不得小于 32。
|
||||
4. `.env` 不进入 Git,不复制到文档。
|
||||
5. 轮换后必须重启 backend,并重新执行生产健康巡检和安全基线检查。
|
||||
|
||||
生成新密钥示例:
|
||||
|
||||
```bash
|
||||
openssl rand -hex 48
|
||||
```
|
||||
|
||||
安全基线检查:
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts\run-security-baseline.ps1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、备份恢复口径
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
阶段 1 已完成源码闭环与生产入口闭环,生产无人 E2E `18/18` 通过。阶段 2 已完成 Review 重试、Knowledge Base 文档重试、Review -> Chat -> Contract 上下文联动、首页交付工作台和阶段 2 自动化无人闭环,本地与生产 E2E 均 `7/7` 通过。阶段 3 已完成系统 readiness、试点交付准备页、默认账号说明、演示样本数据、依赖健康门禁和阶段 3 自动化无人闭环,本地与生产 E2E 均 `5/5` 通过。
|
||||
|
||||
当前后续重点为“生产运行、安全、监控、备份与依赖治理”:默认密码轮换已完成,阶段 5 Step 1 生产健康巡检、Step 2 备份恢复演练、Step 3 依赖治理均已 PASS,下一步进入安全基线检查。
|
||||
当前后续重点为“生产运行、安全、监控、备份与依赖治理”:默认密码轮换已完成,阶段 5 Step 1 生产健康巡检、Step 2 备份恢复演练、Step 3 依赖治理、Step 4 安全基线均已 PASS,下一步进入生产回归闭环。
|
||||
|
||||
---
|
||||
|
||||
@@ -210,4 +210,4 @@ AI合同平台当前已经不是从零建设阶段。登录、合同审查、Rev
|
||||
|
||||
## 六、当前一句话结论
|
||||
|
||||
AI合同平台阶段 1、阶段 2 和阶段 3 均已完成闭环交付验证,阶段 4 已完成默认管理员密码轮换闭环,阶段 5 已启动并完成生产健康巡检、备份恢复演练和依赖治理,下一步进入安全基线检查。
|
||||
AI合同平台阶段 1、阶段 2 和阶段 3 均已完成闭环交付验证,阶段 4 已完成默认管理员密码轮换闭环,阶段 5 已启动并完成生产健康巡检、备份恢复演练、依赖治理和安全基线,下一步进入生产回归闭环。
|
||||
|
||||
@@ -13,10 +13,10 @@
|
||||
| Step 1:生产健康巡检自动化 | PASS | `scripts/run-production-health-check.ps1` 已实现并完成生产巡检 |
|
||||
| Step 2:备份与恢复演练 | PASS | `scripts/run-backup-drill.ps1` 已实现并完成生产备份/隔离恢复验证 |
|
||||
| Step 3:依赖治理专项 | PASS | Next/PostCSS moderate 漏洞已清零,前后端依赖门禁通过 |
|
||||
| Step 4:安全基线检查 | PENDING | 下一步进入,将复用 Step 1 readiness 与默认密码验证 |
|
||||
| Step 5:生产回归闭环 | PENDING | 待运维加固动作后复跑阶段 1/2/3 |
|
||||
| Step 4:安全基线检查 | PASS | 生产 `SECRET_KEY` 已改为远端 `.env` 注入,安全基线脚本 PASS |
|
||||
| Step 5:生产回归闭环 | PENDING | 下一步进入,复跑阶段 1/2/3 关键 E2E |
|
||||
|
||||
阶段 5 当前判定:Step 1、Step 2、Step 3 均已 PASS。
|
||||
阶段 5 当前判定:Step 1、Step 2、Step 3、Step 4 均已 PASS。
|
||||
|
||||
---
|
||||
|
||||
@@ -86,15 +86,15 @@ default-password-login.json
|
||||
|
||||
## 五、下一步
|
||||
|
||||
阶段 5 下一步进入 Step 4:安全基线检查。
|
||||
阶段 5 下一步进入 Step 5:生产回归闭环。
|
||||
|
||||
优先交付:
|
||||
|
||||
1. 生产密钥与默认值检查
|
||||
2. 敏感文件和私钥误提交扫描
|
||||
3. 公开端口与容器状态核对
|
||||
4. 权限边界和关键审计事件抽查
|
||||
5. 安全基线报告
|
||||
1. 阶段 1 生产关键 E2E
|
||||
2. 阶段 2 生产关键 E2E
|
||||
3. 阶段 3 生产关键 E2E
|
||||
4. 生产健康巡检
|
||||
5. 最终阶段 5 闭环报告
|
||||
|
||||
---
|
||||
|
||||
@@ -179,3 +179,54 @@ project-docs/qa-artifacts/phase5-dependency-governance/2026-05-01T08-46-29/
|
||||
| `npm run lint` | PASS | ESLint 通过 |
|
||||
| `npm run build` | PASS | Next.js 16.2.4 构建通过,18 个 app route 生成完成 |
|
||||
| `backend/.venv/Scripts/python.exe -m pip check` | PASS | No broken requirements found |
|
||||
|
||||
---
|
||||
|
||||
## 八、Step 4 安全基线检查
|
||||
|
||||
新增脚本:
|
||||
|
||||
```powershell
|
||||
scripts\run-security-baseline.ps1
|
||||
```
|
||||
|
||||
本次安全修复:
|
||||
|
||||
| 项目 | 处理 |
|
||||
|---|---|
|
||||
| 生产 `SECRET_KEY` | 从 `docker-compose.server.yml` 硬编码占位改为 `${SECRET_KEY:?Set SECRET_KEY in .env before starting production}` |
|
||||
| 远端密钥 | 在 `/opt/ai-contract-platform/.env` 生成随机强密钥,`chmod 600`,不写入仓库和文档 |
|
||||
| 后端默认配置 | `backend/app/core/config.py` 的默认 `DATABASE_URL` 改回本地开发占位,移除硬编码生产样式密码 |
|
||||
| Readiness | `change-me-in-production` 和过短密钥也进入 runtime warning 判断 |
|
||||
| 巡检脚本 | `run-production-health-check.ps1` 修复 Docker Compose warning 被 PowerShell 当异常的问题 |
|
||||
|
||||
安全基线产物:
|
||||
|
||||
```text
|
||||
project-docs/qa-artifacts/phase5-security-baseline/2026-05-01T08-55-24/
|
||||
```
|
||||
|
||||
生产健康复验产物:
|
||||
|
||||
```text
|
||||
project-docs/qa-artifacts/phase5-production-health/2026-05-01T08-56-07/
|
||||
```
|
||||
|
||||
安全基线结果:
|
||||
|
||||
| 检查项 | 结果 | 说明 |
|
||||
|---|---|---|
|
||||
| Readiness 安全门禁 | PASS | `ready`、`warnings=0`、`password_rotated=true` |
|
||||
| 生产 `SECRET_KEY` | PASS | 非默认值,长度大于等于 32 |
|
||||
| 默认密码拒绝 | PASS | 默认管理员初始密码登录返回 401 |
|
||||
| member 权限边界 | PASS | member 不能访问 `/api/v1/users` |
|
||||
| 公开端口 | PASS | `3003`、`8001` 可访问 |
|
||||
| Git 敏感文件 | PASS | 未跟踪 `.env`、私钥、pem/key 文件 |
|
||||
| 源码高信号敏感信息扫描 | PASS | 未发现私钥、管理员密码环境变量赋值、已知硬编码密码片段 |
|
||||
|
||||
本地定向验证:
|
||||
|
||||
```text
|
||||
backend/.venv/Scripts/python.exe -m pytest -q backend/tests/test_system_api.py backend/tests/test_auth_service.py backend/tests/test_permission_metadata.py
|
||||
13 passed, 3 warnings
|
||||
```
|
||||
|
||||
@@ -183,7 +183,10 @@ $env:AI_CONTRACT_ADMIN_PASSWORD = '<管理员实际密码>'
|
||||
- Step 3 已完成:`scripts/run-dependency-governance.ps1`
|
||||
- 依赖治理结果:PASS,`npm audit` 0 vulnerabilities,后端 `pip check` PASS
|
||||
- 依赖治理产物:`project-docs/qa-artifacts/phase5-dependency-governance/2026-05-01T08-46-29/`
|
||||
- 下一步:Step 4 安全基线检查
|
||||
- Step 4 已完成:`scripts/run-security-baseline.ps1`
|
||||
- 安全基线结果:PASS,生产 `SECRET_KEY` 已改为远端 `.env` 强密钥注入
|
||||
- 安全基线产物:`project-docs/qa-artifacts/phase5-security-baseline/2026-05-01T08-55-24/`
|
||||
- 下一步:Step 5 生产回归闭环
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -170,9 +170,16 @@ function Invoke-SshCommand {
|
||||
$target,
|
||||
$Command
|
||||
)
|
||||
$output = & ssh @args 2>&1
|
||||
$previousErrorActionPreference = $ErrorActionPreference
|
||||
$ErrorActionPreference = "Continue"
|
||||
try {
|
||||
$output = & ssh @args 2>&1
|
||||
$exitCode = $LASTEXITCODE
|
||||
} finally {
|
||||
$ErrorActionPreference = $previousErrorActionPreference
|
||||
}
|
||||
return [ordered]@{
|
||||
exit_code = $LASTEXITCODE
|
||||
exit_code = $exitCode
|
||||
output = ($output -join [Environment]::NewLine)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
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"
|
||||
}
|
||||
$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"
|
||||
$secretScan = git -C $repoRoot grep -n -I -E 'BEGIN (RSA|OPENSSH|PRIVATE) KEY|zmfCSR|AI_CONTRACT_ADMIN_PASSWORD[[:space:]]*=' -- 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"
|
||||
}
|
||||
Reference in New Issue
Block a user