security(installer): 9 BLOCKER 修复 — 三路审计验收

基于 code-reviewer + red-team-attacker + red-team-logic 三路并行审计:

B1: 日志脱敏 — Run-CmdWithUI 的 Bw-Log 对 URL 做 ://***@ 替换
B2: DPAPI 加密注册表 — Save/Get-CachedSecrets 用 ProtectedData.Protect/Unprotect
B3: credential.helper store → manager (Windows Credential Manager, DPAPI)
B4: 单实例 Mutex — Global\BookwormPortableSetup, 重复启动弹提示退出
B5: URL-encode git 凭证 — EscapeDataString 处理 @/#/% 等特殊字符
B6: 移除 OpenSSL fallback — BWENC1 格式与 openssl enc 不兼容, Node.js 为硬性要求
B7: validAttempts++ 后移 — 文件不存在不消耗尝试次数, 避免用户误锁
B8: ErrorActionPreference try/finally — 防止 uv 安装异常后全局静默吞错
B9: Registry 加载白名单 — CacheAllowedKeys 防止 PATH/COMSPEC 注入
V-04: Get-Random → GetTempFileName (原子创建+加密随机, 防并发碰撞)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
bookworm 2026-04-10 01:17:23 +08:00
parent 8a6611d96e
commit 385d3de57f

View File

@ -15,6 +15,15 @@ param(
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
# ─── B4: 单实例保护 (防止双击两次导致竞态) ─────────
$mutexCreated = $false
$global:BWMutex = [System.Threading.Mutex]::new($true, "Global\BookwormPortableSetup", [ref]$mutexCreated)
if (-not $mutexCreated) {
Add-Type -AssemblyName System.Windows.Forms
[System.Windows.Forms.MessageBox]::Show("Bookworm 安装器已在运行中。`n请勿重复启动。", "提示", "OK", "Information") | Out-Null
exit 0
}
# ─── 路径定义 ──────────────────────────────────────── # ─── 路径定义 ────────────────────────────────────────
# PS2EXE 兼容: $MyInvocation.MyCommand.Path 在 EXE 启动时为空,需用 Process MainModule # PS2EXE 兼容: $MyInvocation.MyCommand.Path 在 EXE 启动时为空,需用 Process MainModule
$ScriptDir = if ($PSScriptRoot) { $ScriptDir = if ($PSScriptRoot) {
@ -225,11 +234,14 @@ function Run-CmdWithUI {
[int]$timeoutMs = 180000, # 默认 3 分钟 [int]$timeoutMs = 180000, # 默认 3 分钟
[switch]$captureOutput # 返回 stdout 内容 [switch]$captureOutput # 返回 stdout 内容
) )
Bw-Log "CMD" "$exe $($arguments -join ' ')" # B1: 脱敏日志 (去除 URL 内嵌凭证 user:pass@)
$sanitizedArgs = ($arguments -join ' ') -replace '://[^@]+@', '://***@'
Bw-Log "CMD" "$exe $sanitizedArgs"
Update-Progress-SubStatus $label Update-Progress-SubStatus $label
$outFile = Join-Path $env:TEMP "bw-cmd-out-$(Get-Random).tmp" # V-04: 用 GetTempFileName (原子创建+加密随机) 替代 Get-Random
$errFile = Join-Path $env:TEMP "bw-cmd-err-$(Get-Random).tmp" $outFile = [System.IO.Path]::GetTempFileName()
$errFile = [System.IO.Path]::GetTempFileName()
try { try {
$proc = Start-Process -FilePath $exe -ArgumentList $arguments ` $proc = Start-Process -FilePath $exe -ArgumentList $arguments `
-NoNewWindow -PassThru ` -NoNewWindow -PassThru `
@ -430,7 +442,26 @@ function Find-OpenSSL {
return $paths | Where-Object { Test-Path $_ } | Select-Object -First 1 return $paths | Where-Object { Test-Path $_ } | Select-Object -First 1
} }
# ─── 凭证缓存 (Windows Credential Manager) ───────── # ─── 凭证缓存 (DPAPI 加密, 绑定当前 Windows 用户) ──
# B2: 不再明文存注册表, 使用 ProtectedData 加密
# B9: 读取时使用白名单, 不加载任意 KEY
Add-Type -AssemblyName System.Security
$CacheAllowedKeys = @("ANTHROPIC_API_KEY","ANTHROPIC_BASE_URL","GITHUB_PERSONAL_ACCESS_TOKEN",
"SLACK_BOT_TOKEN","ATLASSIAN_API_TOKEN","BROWSERBASE_API_KEY","FIRECRAWL_API_KEY","GEMINI_API_KEY")
function Protect-String([string]$plain) {
$bytes = [System.Text.Encoding]::UTF8.GetBytes($plain)
$enc = [System.Security.Cryptography.ProtectedData]::Protect($bytes, $null, "CurrentUser")
return [Convert]::ToBase64String($enc)
}
function Unprotect-String([string]$b64) {
$enc = [Convert]::FromBase64String($b64)
$bytes = [System.Security.Cryptography.ProtectedData]::Unprotect($enc, $null, "CurrentUser")
return [System.Text.Encoding]::UTF8.GetString($bytes)
}
function Get-CachedSecrets { function Get-CachedSecrets {
try { try {
$regPath = "HKCU:\Software\Bookworm\CachedEnv" $regPath = "HKCU:\Software\Bookworm\CachedEnv"
@ -443,9 +474,15 @@ function Get-CachedSecrets {
$props = Get-ItemProperty $regPath -ErrorAction SilentlyContinue $props = Get-ItemProperty $regPath -ErrorAction SilentlyContinue
$loaded = 0 $loaded = 0
foreach ($p in $props.PSObject.Properties) { foreach ($p in $props.PSObject.Properties) {
if ($p.Name -match '^[A-Z_]+$') { # B9: 只加载白名单内的 Key (防止 PATH/COMSPEC 注入)
[System.Environment]::SetEnvironmentVariable($p.Name, $p.Value, "Process") if ($CacheAllowedKeys -contains $p.Name) {
$loaded++ try {
$val = Unprotect-String $p.Value
[System.Environment]::SetEnvironmentVariable($p.Name, $val, "Process")
$loaded++
} catch {
Bw-Log "WARN" "缓存解密失败: $($p.Name)"
}
} }
} }
return ($loaded -gt 0 -and $env:ANTHROPIC_API_KEY) return ($loaded -gt 0 -and $env:ANTHROPIC_API_KEY)
@ -456,13 +493,14 @@ function Save-SecretsToCache {
try { try {
$regPath = "HKCU:\Software\Bookworm\CachedEnv" $regPath = "HKCU:\Software\Bookworm\CachedEnv"
if (-not (Test-Path $regPath)) { New-Item $regPath -Force | Out-Null } if (-not (Test-Path $regPath)) { New-Item $regPath -Force | Out-Null }
$keys = @("ANTHROPIC_API_KEY","ANTHROPIC_BASE_URL","GITHUB_PERSONAL_ACCESS_TOKEN", foreach ($k in $CacheAllowedKeys) {
"SLACK_BOT_TOKEN","ATLASSIAN_API_TOKEN","BROWSERBASE_API_KEY","FIRECRAWL_API_KEY")
foreach ($k in $keys) {
$v = [System.Environment]::GetEnvironmentVariable($k, "Process") $v = [System.Environment]::GetEnvironmentVariable($k, "Process")
if ($v) { Set-ItemProperty $regPath -Name $k -Value $v -Force } if ($v) {
$encrypted = Protect-String $v
Set-ItemProperty $regPath -Name $k -Value $encrypted -Force
}
} }
Set-ItemProperty $regPath -Name "_expiry" -Value (Get-Date).Date.AddDays(1).ToString("o") -Force Set-ItemProperty $regPath -Name "_expiry" -Value (Get-Date).Date.AddDays(1).ToUniversalTime().ToString("o") -Force
} catch {} } catch {}
} }
@ -586,50 +624,50 @@ if (Test-Cmd "uv") {
} else { } else {
Log-Info "安装 uv (Python 包管理器, 可选)..." Log-Info "安装 uv (Python 包管理器, 可选)..."
# 静默执行子进程, 错误全部捕获到日志文件, 绝不冒泡到 PS2EXE # B8: try/finally 确保 ErrorActionPreference 恢复 (防止后续 Phase 静默吞错)
$prevErrPref = $ErrorActionPreference $prevErrPref = $ErrorActionPreference
$ErrorActionPreference = "SilentlyContinue" try {
$ErrorActionPreference = "SilentlyContinue"
# 方案 A: winget (最可靠) # 方案 A: winget (最可靠)
if (Test-Cmd "winget") { if (Test-Cmd "winget") {
try { try {
$null = & winget install --id=astral-sh.uv -e --silent --accept-source-agreements --accept-package-agreements 2>&1 | $null = & winget install --id=astral-sh.uv -e --silent --accept-source-agreements --accept-package-agreements 2>&1 |
Out-File -FilePath $uvLogFile -Encoding utf8 -Append Out-File -FilePath $uvLogFile -Encoding utf8 -Append
} catch { "[winget] $_" | Out-File -FilePath $uvLogFile -Encoding utf8 -Append } } catch { "[winget] $_" | Out-File -FilePath $uvLogFile -Encoding utf8 -Append }
$env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path","User") $env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path","User")
# winget 装到 %LOCALAPPDATA%\Microsoft\WinGet\Packages\astral-sh.uv_* $uvCargoBin = "$env:LOCALAPPDATA\Microsoft\WinGet\Links"
$uvCargoBin = "$env:LOCALAPPDATA\Microsoft\WinGet\Links" if (Test-Path $uvCargoBin) { $env:Path += ";$uvCargoBin" }
if (Test-Path $uvCargoBin) { $env:Path += ";$uvCargoBin" } if (Test-Cmd "uv") { $uvInstalled = $true }
if (Test-Cmd "uv") { $uvInstalled = $true } }
# 方案 B: Astral 官方一行脚本
if (-not $uvInstalled) {
try {
$null = & powershell -NoProfile -ExecutionPolicy Bypass -Command "irm https://astral.sh/uv/install.ps1 | iex" 2>&1 |
Out-File -FilePath $uvLogFile -Encoding utf8 -Append
} catch { "[astral] $_" | Out-File -FilePath $uvLogFile -Encoding utf8 -Append }
$localBin = Join-Path $env:USERPROFILE ".local\bin"
if (Test-Path $localBin) { $env:Path += ";$localBin" }
if (Test-Cmd "uv") { $uvInstalled = $true }
}
# 方案 C: pip fallback
if (-not $uvInstalled -and (Test-Cmd "python")) {
try {
$null = & python -m pip install --quiet uv 2>&1 |
Out-File -FilePath $uvLogFile -Encoding utf8 -Append
} catch { "[pip] $_" | Out-File -FilePath $uvLogFile -Encoding utf8 -Append }
try {
$pyScripts = Join-Path (Split-Path (& python -c "import sys; print(sys.executable)") -Parent) "Scripts"
if (Test-Path $pyScripts) { $env:Path += ";$pyScripts" }
} catch {}
if (Test-Cmd "uv") { $uvInstalled = $true }
}
} finally {
$ErrorActionPreference = $prevErrPref
} }
# 方案 B: Astral 官方一行脚本 (走 https://astral.sh/uv/install.ps1)
if (-not $uvInstalled) {
try {
$null = & powershell -NoProfile -ExecutionPolicy Bypass -Command "irm https://astral.sh/uv/install.ps1 | iex" 2>&1 |
Out-File -FilePath $uvLogFile -Encoding utf8 -Append
} catch { "[astral] $_" | Out-File -FilePath $uvLogFile -Encoding utf8 -Append }
# Astral 脚本默认装到 %USERPROFILE%\.local\bin
$localBin = Join-Path $env:USERPROFILE ".local\bin"
if (Test-Path $localBin) { $env:Path += ";$localBin" }
if (Test-Cmd "uv") { $uvInstalled = $true }
}
# 方案 C: pip fallback (Python 已装且前两个都失败)
if (-not $uvInstalled -and (Test-Cmd "python")) {
try {
$null = & python -m pip install --quiet uv 2>&1 |
Out-File -FilePath $uvLogFile -Encoding utf8 -Append
} catch { "[pip] $_" | Out-File -FilePath $uvLogFile -Encoding utf8 -Append }
try {
$pyScripts = Join-Path (Split-Path (& python -c "import sys; print(sys.executable)") -Parent) "Scripts"
if (Test-Path $pyScripts) { $env:Path += ";$pyScripts" }
} catch {}
if (Test-Cmd "uv") { $uvInstalled = $true }
}
$ErrorActionPreference = $prevErrPref
if ($uvInstalled) { if ($uvInstalled) {
Log-OK "uv 安装成功" Log-OK "uv 安装成功"
$installed += "uv" $installed += "uv"
@ -800,8 +838,8 @@ foreach ($t in $netTests) {
# ======================================================================== # ========================================================================
Log-Phase 3 "同步 Bookworm 配置" Log-Phase 3 "同步 Bookworm 配置"
# 配置 git credential helper # B3: 使用 Windows Credential Manager (DPAPI 加密) 替代明文 store
git config --global credential.helper store 2>$null git config --global credential.helper manager 2>$null
# 克隆/更新 config 仓库 (.claude/) — 使用 Run-CmdWithUI 防止 UI 冻结 # 克隆/更新 config 仓库 (.claude/) — 使用 Run-CmdWithUI 防止 UI 冻结
if (Test-Path (Join-Path $ClaudeDir ".git")) { if (Test-Path (Join-Path $ClaudeDir ".git")) {
@ -819,7 +857,7 @@ elseif (Test-Path $ClaudeDir) {
Rename-Item $ClaudeDir $BackupDir Rename-Item $ClaudeDir $BackupDir
$cred = Show-GiteaCredentialDialog $cred = Show-GiteaCredentialDialog
$cloneUrl = if ($cred) { $GitUrl -replace '://', "://$($cred.User):$($cred.Pass)@" } else { $GitUrl } $cloneUrl = if ($cred) { $GitUrl -replace '://', "://$([System.Uri]::EscapeDataString($cred.User)):$([System.Uri]::EscapeDataString($cred.Pass))@" } else { $GitUrl }
$r = Run-CmdWithUI "git" @("clone", "--depth", "1", $cloneUrl, $ClaudeDir) "克隆配置仓库" 180000 $r = Run-CmdWithUI "git" @("clone", "--depth", "1", $cloneUrl, $ClaudeDir) "克隆配置仓库" 180000
if (Test-Path (Join-Path $ClaudeDir "CLAUDE.md")) { if (Test-Path (Join-Path $ClaudeDir "CLAUDE.md")) {
Log-OK "配置仓库克隆成功 (旧目录已备份)" Log-OK "配置仓库克隆成功 (旧目录已备份)"
@ -833,7 +871,7 @@ elseif (Test-Path $ClaudeDir) {
else { else {
Log-Info "首次安装, 克隆配置仓库..." Log-Info "首次安装, 克隆配置仓库..."
$cred = Show-GiteaCredentialDialog $cred = Show-GiteaCredentialDialog
$cloneUrl = if ($cred) { $GitUrl -replace '://', "://$($cred.User):$($cred.Pass)@" } else { $GitUrl } $cloneUrl = if ($cred) { $GitUrl -replace '://', "://$([System.Uri]::EscapeDataString($cred.User)):$([System.Uri]::EscapeDataString($cred.Pass))@" } else { $GitUrl }
$r = Run-CmdWithUI "git" @("clone", "--depth", "1", $cloneUrl, $ClaudeDir) "克隆配置仓库" 180000 $r = Run-CmdWithUI "git" @("clone", "--depth", "1", $cloneUrl, $ClaudeDir) "克隆配置仓库" 180000
if (Test-Path (Join-Path $ClaudeDir "CLAUDE.md")) { if (Test-Path (Join-Path $ClaudeDir "CLAUDE.md")) {
Log-OK "配置仓库克隆成功" Log-OK "配置仓库克隆成功"
@ -861,7 +899,7 @@ if (Test-Path (Join-Path $BootDir ".git")) {
} else { } else {
Log-Info "克隆 boot 仓库 (含解密工具与凭证)..." Log-Info "克隆 boot 仓库 (含解密工具与凭证)..."
if (-not $cred) { $cred = Show-GiteaCredentialDialog } if (-not $cred) { $cred = Show-GiteaCredentialDialog }
$bootCloneUrl = if ($cred) { $BootUrl -replace '://', "://$($cred.User):$($cred.Pass)@" } else { $BootUrl } $bootCloneUrl = if ($cred) { $BootUrl -replace '://', "://$([System.Uri]::EscapeDataString($cred.User)):$([System.Uri]::EscapeDataString($cred.Pass))@" } else { $BootUrl }
$r = Run-CmdWithUI "git" @("clone", "--depth", "1", $bootCloneUrl, $BootDir) "克隆 boot 仓库" 180000 $r = Run-CmdWithUI "git" @("clone", "--depth", "1", $bootCloneUrl, $BootDir) "克隆 boot 仓库" 180000
if (-not (Test-Path (Join-Path $BootDir "crypto-helper.js"))) { if (-not (Test-Path (Join-Path $BootDir "crypto-helper.js"))) {
Log-Fail "启动工具包下载失败" Log-Fail "启动工具包下载失败"
@ -885,17 +923,18 @@ if (Get-CachedSecrets) {
} }
# 再解密 (缓存命中则跳过) # 再解密 (缓存命中则跳过)
if (-not $secretsDecrypted) { if (-not $secretsDecrypted) {
# B6: Node.js 是硬性要求 (OpenSSL fallback 与 BWENC1 格式不兼容, 已移除)
$cryptoHelper = Join-Path $BootDir "crypto-helper.js" $cryptoHelper = Join-Path $BootDir "crypto-helper.js"
$useNode = (Test-Cmd "node") -and (Test-Path $cryptoHelper) if (-not (Test-Cmd "node") -or -not (Test-Path $cryptoHelper)) {
if (-not $useNode -and -not $opensslCmd) { Log-Fail "解密需要 Node.js (Phase 1 应已安装)"
Log-Fail "无解密工具 (需要 Node.js 或 OpenSSL)" Show-MsgBox "解密凭证需要 Node.js但未检测到。`n请确认 Phase 1 安装成功后重试。" "缺少 Node.js" "OK" "Error"
} }
elseif ((Test-Path $SecretsEnc) -or (Get-ChildItem $BootDir -Filter "secrets-*.enc" -ErrorAction SilentlyContinue)) { elseif ((Test-Path $SecretsEnc) -or (Get-ChildItem $BootDir -Filter "secrets-*.enc" -ErrorAction SilentlyContinue)) {
$validAttempts = 0 $validAttempts = 0
while ($validAttempts -lt 3) { while ($validAttempts -lt 3) {
$rawCode = Show-AuthCodeDialog ($validAttempts + 1) 3 $rawCode = Show-AuthCodeDialog ($validAttempts + 1) 3
if (-not $rawCode) { if (-not $rawCode) {
Log-Warn "用户跳过授权码输入" Log-Warn "用户取消授权码输入"
break break
} }
@ -908,26 +947,22 @@ elseif ((Test-Path $SecretsEnc) -or (Get-ChildItem $BootDir -Filter "secrets-*.e
Show-MsgBox "格式错误。`n正确格式: BW-YYYYMMDD-XXXXXXXXXXXXXXXXXXXXXXXX`n`n请检查后重新粘贴。" "格式错误" "OK" "Warning" Show-MsgBox "格式错误。`n正确格式: BW-YYYYMMDD-XXXXXXXXXXXXXXXXXXXXXXXX`n`n请检查后重新粘贴。" "格式错误" "OK" "Warning"
continue continue
} }
$validAttempts++
# 按 token 前8位定位 .enc 文件 (多用户独立 Key),回退 secrets.enc # B7: 先检查文件存在, 再递增 validAttempts (文件缺失不消耗尝试次数)
$fileId = $token.Substring(0, 8) $fileId = $token.Substring(0, 8)
$encFile = Join-Path $BootDir "secrets-$fileId.enc" $encFile = Join-Path $BootDir "secrets-$fileId.enc"
if (-not (Test-Path $encFile)) { $encFile = $SecretsEnc } if (-not (Test-Path $encFile)) { $encFile = $SecretsEnc }
if (-not (Test-Path $encFile)) { if (-not (Test-Path $encFile)) {
Show-MsgBox "未找到对应凭证文件。`n请确认管理员已推送 secrets-$fileId.enc 到 Gitea`n并重新运行安装器(会自动拉取)。" "文件未找到" "OK" "Warning" Show-MsgBox "未找到对应凭证文件。`n请确认管理员已推送 secrets-$fileId.enc 到 Gitea`n并重新运行安装器(会自动拉取)。`n`n(此次不计为失败尝试)" "文件未找到" "OK" "Warning"
$token = $null $token = $null
continue continue
} }
$validAttempts++ # B7: 只有真正尝试解密才计数
try { try {
if ($useNode) { $decrypted = & node $cryptoHelper decrypt $token $encFile 2>&1
$decrypted = & node $cryptoHelper decrypt $token $encFile 2>&1 $decExit = $LASTEXITCODE
$decExit = $LASTEXITCODE
} else {
$decrypted = & $opensslCmd enc -aes-256-cbc -d -pbkdf2 -iter 600000 -in $encFile -pass "pass:$token" 2>$null
$decExit = $LASTEXITCODE
}
$token = $null $token = $null
if ($decExit -eq 0 -and $decrypted -and $decrypted -notmatch 'WRONG_PASSWORD|WRONG_FORMAT|bad decrypt|bad magic') { if ($decExit -eq 0 -and $decrypted -and $decrypted -notmatch 'WRONG_PASSWORD|WRONG_FORMAT|bad decrypt|bad magic') {