From 385d3de57fb8ae73680cb7c3c9aa21aa78ea9fff Mon Sep 17 00:00:00 2001 From: bookworm Date: Fri, 10 Apr 2026 01:17:23 +0800 Subject: [PATCH] =?UTF-8?q?security(installer):=209=20BLOCKER=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=20=E2=80=94=20=E4=B8=89=E8=B7=AF=E5=AE=A1=E8=AE=A1?= =?UTF-8?q?=E9=AA=8C=E6=94=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 基于 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) --- auto-setup.ps1 | 177 +++++++++++++++++++++++++++++-------------------- 1 file changed, 106 insertions(+), 71 deletions(-) diff --git a/auto-setup.ps1 b/auto-setup.ps1 index 1d5ce20..d15928d 100644 --- a/auto-setup.ps1 +++ b/auto-setup.ps1 @@ -15,6 +15,15 @@ param( $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 $ScriptDir = if ($PSScriptRoot) { @@ -225,11 +234,14 @@ function Run-CmdWithUI { [int]$timeoutMs = 180000, # 默认 3 分钟 [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 - $outFile = Join-Path $env:TEMP "bw-cmd-out-$(Get-Random).tmp" - $errFile = Join-Path $env:TEMP "bw-cmd-err-$(Get-Random).tmp" + # V-04: 用 GetTempFileName (原子创建+加密随机) 替代 Get-Random + $outFile = [System.IO.Path]::GetTempFileName() + $errFile = [System.IO.Path]::GetTempFileName() try { $proc = Start-Process -FilePath $exe -ArgumentList $arguments ` -NoNewWindow -PassThru ` @@ -430,7 +442,26 @@ function Find-OpenSSL { 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 { try { $regPath = "HKCU:\Software\Bookworm\CachedEnv" @@ -443,9 +474,15 @@ function Get-CachedSecrets { $props = Get-ItemProperty $regPath -ErrorAction SilentlyContinue $loaded = 0 foreach ($p in $props.PSObject.Properties) { - if ($p.Name -match '^[A-Z_]+$') { - [System.Environment]::SetEnvironmentVariable($p.Name, $p.Value, "Process") - $loaded++ + # B9: 只加载白名单内的 Key (防止 PATH/COMSPEC 注入) + if ($CacheAllowedKeys -contains $p.Name) { + 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) @@ -456,13 +493,14 @@ function Save-SecretsToCache { try { $regPath = "HKCU:\Software\Bookworm\CachedEnv" if (-not (Test-Path $regPath)) { New-Item $regPath -Force | Out-Null } - $keys = @("ANTHROPIC_API_KEY","ANTHROPIC_BASE_URL","GITHUB_PERSONAL_ACCESS_TOKEN", - "SLACK_BOT_TOKEN","ATLASSIAN_API_TOKEN","BROWSERBASE_API_KEY","FIRECRAWL_API_KEY") - foreach ($k in $keys) { + foreach ($k in $CacheAllowedKeys) { $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 {} } @@ -586,50 +624,50 @@ if (Test-Cmd "uv") { } else { Log-Info "安装 uv (Python 包管理器, 可选)..." - # 静默执行子进程, 错误全部捕获到日志文件, 绝不冒泡到 PS2EXE + # B8: try/finally 确保 ErrorActionPreference 恢复 (防止后续 Phase 静默吞错) $prevErrPref = $ErrorActionPreference - $ErrorActionPreference = "SilentlyContinue" + try { + $ErrorActionPreference = "SilentlyContinue" - # 方案 A: winget (最可靠) - if (Test-Cmd "winget") { - try { - $null = & winget install --id=astral-sh.uv -e --silent --accept-source-agreements --accept-package-agreements 2>&1 | - 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") - # winget 装到 %LOCALAPPDATA%\Microsoft\WinGet\Packages\astral-sh.uv_* - $uvCargoBin = "$env:LOCALAPPDATA\Microsoft\WinGet\Links" - if (Test-Path $uvCargoBin) { $env:Path += ";$uvCargoBin" } - if (Test-Cmd "uv") { $uvInstalled = $true } + # 方案 A: winget (最可靠) + if (Test-Cmd "winget") { + try { + $null = & winget install --id=astral-sh.uv -e --silent --accept-source-agreements --accept-package-agreements 2>&1 | + 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") + $uvCargoBin = "$env:LOCALAPPDATA\Microsoft\WinGet\Links" + if (Test-Path $uvCargoBin) { $env:Path += ";$uvCargoBin" } + 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) { Log-OK "uv 安装成功" $installed += "uv" @@ -800,8 +838,8 @@ foreach ($t in $netTests) { # ======================================================================== Log-Phase 3 "同步 Bookworm 配置" -# 配置 git credential helper -git config --global credential.helper store 2>$null +# B3: 使用 Windows Credential Manager (DPAPI 加密) 替代明文 store +git config --global credential.helper manager 2>$null # 克隆/更新 config 仓库 (.claude/) — 使用 Run-CmdWithUI 防止 UI 冻结 if (Test-Path (Join-Path $ClaudeDir ".git")) { @@ -819,7 +857,7 @@ elseif (Test-Path $ClaudeDir) { Rename-Item $ClaudeDir $BackupDir $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 if (Test-Path (Join-Path $ClaudeDir "CLAUDE.md")) { Log-OK "配置仓库克隆成功 (旧目录已备份)" @@ -833,7 +871,7 @@ elseif (Test-Path $ClaudeDir) { else { Log-Info "首次安装, 克隆配置仓库..." $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 if (Test-Path (Join-Path $ClaudeDir "CLAUDE.md")) { Log-OK "配置仓库克隆成功" @@ -861,7 +899,7 @@ if (Test-Path (Join-Path $BootDir ".git")) { } else { Log-Info "克隆 boot 仓库 (含解密工具与凭证)..." 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 if (-not (Test-Path (Join-Path $BootDir "crypto-helper.js"))) { Log-Fail "启动工具包下载失败" @@ -885,17 +923,18 @@ if (Get-CachedSecrets) { } # 再解密 (缓存命中则跳过) if (-not $secretsDecrypted) { +# B6: Node.js 是硬性要求 (OpenSSL fallback 与 BWENC1 格式不兼容, 已移除) $cryptoHelper = Join-Path $BootDir "crypto-helper.js" -$useNode = (Test-Cmd "node") -and (Test-Path $cryptoHelper) -if (-not $useNode -and -not $opensslCmd) { - Log-Fail "无解密工具 (需要 Node.js 或 OpenSSL)" +if (-not (Test-Cmd "node") -or -not (Test-Path $cryptoHelper)) { + Log-Fail "解密需要 Node.js (Phase 1 应已安装)" + Show-MsgBox "解密凭证需要 Node.js,但未检测到。`n请确认 Phase 1 安装成功后重试。" "缺少 Node.js" "OK" "Error" } elseif ((Test-Path $SecretsEnc) -or (Get-ChildItem $BootDir -Filter "secrets-*.enc" -ErrorAction SilentlyContinue)) { $validAttempts = 0 while ($validAttempts -lt 3) { $rawCode = Show-AuthCodeDialog ($validAttempts + 1) 3 if (-not $rawCode) { - Log-Warn "用户跳过授权码输入" + Log-Warn "用户取消授权码输入" 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" continue } - $validAttempts++ - # 按 token 前8位定位 .enc 文件 (多用户独立 Key),回退 secrets.enc + # B7: 先检查文件存在, 再递增 validAttempts (文件缺失不消耗尝试次数) $fileId = $token.Substring(0, 8) $encFile = Join-Path $BootDir "secrets-$fileId.enc" if (-not (Test-Path $encFile)) { $encFile = $SecretsEnc } 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 continue } + $validAttempts++ # B7: 只有真正尝试解密才计数 + try { - if ($useNode) { - $decrypted = & node $cryptoHelper decrypt $token $encFile 2>&1 - $decExit = $LASTEXITCODE - } else { - $decrypted = & $opensslCmd enc -aes-256-cbc -d -pbkdf2 -iter 600000 -in $encFile -pass "pass:$token" 2>$null - $decExit = $LASTEXITCODE - } + $decrypted = & node $cryptoHelper decrypt $token $encFile 2>&1 + $decExit = $LASTEXITCODE $token = $null if ($decExit -eq 0 -and $decrypted -and $decrypted -notmatch 'WRONG_PASSWORD|WRONG_FORMAT|bad decrypt|bad magic') {