diff --git a/dsh-plugin.json b/dsh-plugin.json index 1ed878e..4861f84 100644 --- a/dsh-plugin.json +++ b/dsh-plugin.json @@ -16,18 +16,18 @@ "permissions": [ { "name": "fs.write", - "scope": "~/.dsh/dsh-code-review/**、默认报告目录(~ 展开后的 ~/dsh-code-review-reports/**)及用户经 report_dir 配置的报告目录", - "reason": "落盘任务簿(tasks/ 任务文件)/ 插件运行配置(plugin-config.json)/ 评审报告留档" + "scope": "~/.dsh/dsh-code-review/**、默认报告目录(~ 展开后的 ~/dsh-code-review-reports/**)及用户经 report_dir 配置的报告目录(写入前校验:~ 展开为绝对路径、解析符号链接后无目录遍历逃逸,且限于用户 home 或会话工作目录下)", + "reason": "落盘任务簿(tasks/ 任务文件)/ 插件运行配置(plugin-config.json)/ 评审报告留档;report_dir 写入前经 set-config 校验绝对路径与符号链接逃逸(issue #23)" }, { "name": "process.spawn", - "scope": "插件包 bin/ 或 PATH 中的 codecheck(含用户经 engine_bin 显式配置、写入前做本地文件校验的路径);辅助命令 git / where|which / taskkill(平台子进程树终止);以及以 process.execPath 拉起的本插件后台 serve 进程", - "reason": "评审引擎与后台 serve 均为外部子进程(由 runner / task-book 以子进程方式拉起执行),辅助命令服务于待提交清单(git)、引擎路径探测(where/which)与超时终止(taskkill)" + "scope": "插件包 bin/ 或 PATH 中的 codecheck(含用户经 engine_bin 显式配置——写入前校验为绝对路径且文件存在、可执行、非 setuid/setgid 位);辅助命令 git / where|which / taskkill(平台子进程树终止);以及以 process.execPath 拉起的本插件后台 serve 进程", + "reason": "评审引擎与后台 serve 均为外部子进程(由 runner / task-book 以子进程方式拉起执行),辅助命令服务于待提交清单(git)、引擎路径探测(where/which)与超时终止(taskkill);engine_bin 写入前经 set-config 校验绝对路径/存在性/可执行/setuid 位(issue #23)" }, { "name": "credentials.read", "scope": "~/.dsh/.credentials.yaml 与环境变量中的模型凭据引用", - "reason": "调用评审 LLM 所需的 API key,复用宿主既有凭据通道" + "reason": "调用评审 LLM 所需的 API key,复用宿主既有凭据通道;凭据仅用于 API 调用,绝不写入日志或评审报告(issue #23)" } ], "contributes": { diff --git a/scripts/engine-e2e-smoke.mjs b/scripts/engine-e2e-smoke.mjs index e3dab4d..538b8b8 100644 --- a/scripts/engine-e2e-smoke.mjs +++ b/scripts/engine-e2e-smoke.mjs @@ -41,6 +41,9 @@ const BIN = join(PKG_ROOT, 'bin', engineBinName()); const IS_WIN = process.platform === 'win32'; let failures = 0; +// 条件跳过计数(issue #22):key 缺失等环境性缺失属「跳过」而非「失败」, +// 计入 skipped;末尾文案显式区分,避免无 key 机器误判为引擎失败。 +let skipped = 0; const check = (label, ok, detail = '') => { console.log(`${ok ? '✔' : '✘'} ${label}${detail ? ` —— ${detail}` : ''}`); if (!ok) failures += 1; @@ -111,6 +114,14 @@ try { // 提示不再硬编码 codecheck.exe——平台化路径由 // BIN 计算得出,提示直接引用它(Linux/macOS 上出现 .exe 字面会误导)。 check(`引擎存在(${BIN})`, false, `请先把引擎二进制放到插件包 bin/(预期 ${BIN})`); + } else if (!(typeof key === 'string' && key.length > 0)) { + // issue #22:key 解析失败(undefined)时仍构造 OCR_LLM_TOKEN: key(即成 + // 字面量 'undefined')并 spawn 引擎空等 180s 必败——短路跳过引擎调用, + // check 输出明确提示。跳过属条件形态:不计 failures(skipped 计入), + // 末尾文案显式区分,杜绝「无 key 机器误报引擎失败」。 + skipped += 1; + check('跳过引擎调用(key 缺失:未配置 DEEPSEEK_API_KEY)', true, + '条件跳过——配置 ~/.dsh/.credentials.yaml 后此步执行真实评审'); } else { const env = { ...process.env, @@ -157,8 +168,8 @@ try { // 自然退出(process.exit 在 Windows libuv 下可能触发 fetch 句柄断言崩溃)。 if (failures > 0) { - console.log(`\n✘ ${failures} 项未通过`); + console.log(`\n✘ ${failures} 项未通过${skipped > 0 ? `(另有 ${skipped} 项因 key 缺失条件跳过)` : ''}`); process.exitCode = 1; } else { - console.log('\n✔ 引擎端到端实证通过:评审模型(用户配置端点)真实审查跑通'); + console.log(`\n✔ 引擎端到端实证通过:评审模型(用户配置端点)真实审查跑通${skipped > 0 ? '(引擎调用因 key 缺失已跳过)' : ''}`); } diff --git a/scripts/sensitive-e2e-smoke.mjs b/scripts/sensitive-e2e-smoke.mjs index d55aad9..299d57c 100644 --- a/scripts/sensitive-e2e-smoke.mjs +++ b/scripts/sensitive-e2e-smoke.mjs @@ -102,21 +102,24 @@ try { }; if (existsSync(reportDir)) walk(reportDir); check('报告文件已落盘(按日期/仓库分目录)', candidates.length >= 1, candidates[0] ?? '无'); + // 内容不送审的探针串(issue #22:断言从 candidates 嵌套中提出—— + // 断言值必须与上方写入 .env 的夹具**同一串**(改夹具不改断言 = 断言恒真假绿)。 + const envProbes = ['EXAMPLE_PLACEHOLDER', 'EXAMPLE_NOT_A_REAL_KEY_12345']; if (candidates.length > 0) { reportPath = candidates[0]; const report = readFileSync(reportPath, 'utf8'); check('报告含「敏感文件点名」节', report.includes('敏感文件点名'), '命中' ); check('报告点名 .env(内容未送审的兜底人眼检查点)', report.includes('.env'), '点名项出现'); - // 内容不送审:报告正文不得出现 .env 内容里的占位值。 - // 断言值必须与上方写入 .env 的夹具**同一串**(改夹具不改断言 = 断言恒真假绿)。 - const envProbes = ['EXAMPLE_PLACEHOLDER', 'EXAMPLE_NOT_A_REAL_KEY_12345']; const leaked = envProbes.filter((s) => report.includes(s)); check('报告未含 .env 内容(密钥值不出现 = 内容未送审)', leaked.length === 0, leaked.join(',') || '无泄露'); - // 引擎评审输出同样不得含密钥值(送审对象只有 app.py diff)。 - const engineOut = text; - const leakedOut = envProbes.filter((s) => engineOut.includes(s)); - check('引擎输出未含 .env 内容(送审链路也未触碰)', leakedOut.length === 0, leakedOut.join(',') || '无泄露'); } + // 引擎输出泄露断言**不嵌套在** candidates.length > 0 内(issue #22): + // reviewOnce 静默失败(报告未落盘)时此断言也必须执行——评审失败本身由 + // 上方「评审返回非空文本」判据显式记录,杜绝「评审失败 → 整段跳过 → 假绿」。 + // text 为空/非字符串时 includes 恒 false = 无泄露,断言仍给出可观察结论。 + const engineOut = typeof text === 'string' ? text : ''; + const leakedOut = envProbes.filter((s) => engineOut.includes(s)); + check('引擎输出未含 .env 内容(送审链路也未触碰)', leakedOut.length === 0, leakedOut.join(',') || '无泄露'); } finally { rmSync(work, { recursive: true, force: true }); console.log('临时仓库/报告目录已清理。'); diff --git a/src/model-provider.js b/src/model-provider.js index ff0692c..f012483 100644 --- a/src/model-provider.js +++ b/src/model-provider.js @@ -64,8 +64,18 @@ function loadProfileLlmConfig() { let yaml; try { yaml = require('js-yaml'); - } catch { - return null; // 开发环境若 junction 未建也能继续(路由表可能为空,resolveProvider 返回 null) + } catch (error) { + // issue #21:js-yaml 依赖损坏(残损 junction / 安装缺失)此前被静默吞掉—— + // 路由表为空 → 评审 0.0s 失败且失败文案只报「端点未配置」,掩盖真实原因。 + // 这里输出可诊断信息(含真实错误原因),但仍保持原 return null 契约: + // 调用方(resolveProvider → 运行态降级链 / scripts/switch-model-e2e.mjs 的 + // check('解析出端点', !!ep) + ep?.keyEnv ?? '' 安全链)依赖「解析失败 = + // 返回 null 不抛」,绝不能改成抛异常或改变返回值契约。 + console.error( + '[dsh-code-review] loadProfileLlmConfig: 加载 js-yaml 失败,评审模型路由表将为空(解析失败返回 null,不抛异常)。真实原因:' + + String((error && error.message) || error) + ); + return null; } // 根目录统一走 resolveDshRoot:优先 DSH_HOME,回退 ~/.dsh(issue #13)。 const profilesRoot = join(resolveDshRoot(), 'profiles'); diff --git a/src/sensitive-list.js b/src/sensitive-list.js index 9f96c47..bc7059e 100644 --- a/src/sensitive-list.js +++ b/src/sensitive-list.js @@ -28,7 +28,12 @@ export const SENSITIVE_PATH_PATTERNS = [ // 下方 id_* 行覆盖、*.pem 由凭据扩展名行覆盖);.ssh 目录段单独无覆盖属 // 已知盲区(引擎侧路径保护另有 .ssh 排除),勿据此删除下方覆盖行。 /(^|\/)\.[^/]*key[^/]*$/i, - /(^|\/)\.[^/]*secret/i, + // issue #24-3:重叠归一。原两条规则「点开头段内任意位置含 secret」 + // (\.asecret / .secretfile / .secrets-cache.txt)与「词段 secrets/credentials + // + 分隔符/段尾」(secrets.yml / credentials.json / secrets/)在维护责任上 + // 重叠、易漂移;合并为一条交替正则,语义为两者并集(两侧原有命中面全部 + // 保留,单元测试「敏感命中/不命中」组逐项钉住)。 + /(^|\/)(\.[^/]*secret|(secrets?|credentials?)(\.|\/|$))/i, /(^|\/)\.env(\.[^/]+)?$/i, // .env / .env.production 等 // 凭据扩展名必须作为「独立扩展名」出现(my.key、cert.pfx、server.crt.bak 等): // 敏感词前必须由点号分隔([^/]*\.),后跟行尾或再一个点(如 .key.bak 的 .bak)。 @@ -37,7 +42,12 @@ export const SENSITIVE_PATH_PATTERNS = [ // 敏感文件,噪声稀释真正凭据命中的信号(issue #11)。修后路径段内嵌的 // key/cert/pem(sessionkey.go、keycmd.go 等)也不再命中;id_rsa/credentials/.env // 等由各自独立规则覆盖,不受此行影响。 - /(^|\/)[^/]*\.(pem|p12|pfx|keystore|jks|cert|crt|key|kdbx|otpkey)(\.|$)/i, + // issue #24-4:凭据扩展名锚定与 .env 模式对齐——原 `(\.|$)` 允许扩展名后 + // 跟任意字符(如 server.key.bak/ 目录形态、key.c 混淆名),且未锚定路径分 + // 隔符;改 .env 同款 `(\.[^/]+)?$`:敏感扩展名后只允许「再一个点 + 非斜杠 + // 后缀」或行尾,且必须到路径段结尾。my.key / cert.pfx / my.key.bak / + // key.pem 合法命中面不变。 + /(^|\/)[^/]*\.(pem|p12|pfx|keystore|jks|cert|crt|key|kdbx|otpkey)(\.[^/]+)?$/i, /(^|\/)credentials[^/]*$/i, // token 模式收紧(形态:独立词段 token(s) + **凭据类扩展名或路径结尾**)—— // 原 `.*token[^/]*$` 命中路径任意位置含 "token" 的文件(src/tokenizer.js、 @@ -45,19 +55,25 @@ export const SENSITIVE_PATH_PATTERNS = [ // 扩展名收敛到凭据类白名单(与下方 token+分隔符规则同一白名单):token.json / // api_token / my-tokens 命中;token.js(令牌类型定义模块)、tokens.ts、 // design-tokens.md 等业务源码放出——避免把「代码文件」当凭据文件点名。 - /(^|\/)(.*[.\-_])?tokens?(\.(json|yml|yaml|txt|ini|conf|cfg|config|log|bak|old|tmp|env))?$/i, + // issue #24-1:tokens? 加词边界 (?=[._-]|$)——token 词段后只允许分隔符 + // (. _ -)或路径结尾。此前缺词边界时 tokenizer-decoder.yml / + // my-api-tokenizer-decoder.yml 这类业务命名存在被误收形态(token 前缀 + + // 词尾非分隔符被前缀组吞掉);词边界保证「token 是独立词段」才命中, + // 白名单扩展名语义不变(token.json / api_token / my-tokens 仍命中)。 + /(^|\/)(.*[.\-_])?tokens?(?=[._-]|$)(\.(json|yml|yaml|txt|ini|conf|cfg|config|log|bak|old|tmp|env))?$/i, // token 后跟分隔符、且带凭据类扩展名的形态(refresh_token_backup.json、 // token_store.yaml、my_token.txt)——上一行的「token 作为独立词段 + 点号 // 扩展名或路径结尾」覆盖不到它们:token 后不是点而是下划线/横线。此处不写 // `\w*` 前缀——它会让 `subtokens.json`(sub+tokens)这类业务命名也命中, // 而词段前缀已由 `(.*[.\-_])?` 负责。 - /(^|\/)(.*[.\-_])?tokens?[._-][^/]*\.(txt|yml|yaml|json|ini|conf|cfg|config|log|bak|old|tmp)$/i, - // 补充常见凭据文件假阴性——.npmrc(含 _authToken)、 - // .netrc、.git-credentials,以及 secrets/、secrets.yml 等未带点前缀的 - // secrets/credentials 命名(原 credentials 行只覆盖含字面量 credentials 的 - // 基名、\.secret 要求点开头)。 + // issue #24-1(同为 token 词段收紧):token 后跟分隔符形态补同一个词边界 + // (?=[._-]|$)(与上一行同口径,两个 token 规则不漂移)——refresh_ + // token_backup.json / token_store.yaml / my_token.txt 命中不变。 + /(^|\/)(.*[.\-_])?tokens?(?=[._-]|$)[._-][^/]*\.(txt|yml|yaml|json|ini|conf|cfg|config|log|bak|old|tmp)$/i, + // 补充常见凭据文件假阴性——.npmrc(含 _authToken)、.netrc、.git-credentials。 + // secrets/、secrets.yml 等 secrets/credentials 词段命名已并入上方「重叠归一」 + // 行(issue #24-3),不再单独保留(避免两处语义漂移)。 /(^|\/)\.(npmrc|netrc|git-credentials)$/i, - /(^|\/)(secrets?|credentials?)(\.|\/|$)/i, // 个人/隐私信息。 /(^|\/)(.*\.)?(id_rsa|id_dsa|id_ecdsa|id_ed25519)$/i, // 常见凭据兜底(作业本/测试台账类命名)。 @@ -117,7 +133,11 @@ export function buildSensitiveBlock(pendingPaths) { // 路径未经转义直接进反引号代码块——git 文件名可含 // 反引号/换行(git 允许),会闭合代码块并向报告注入任意 markdown。拼接前 // 剔除反引号与换行(点名目的是人眼确认路径,替换为空格不影响辨识)。 - const safe = String(h.path).replace(/[`\r\n*_[\]<>|]+/g, ' '); + // issue #24-2:清洗类补 `~ # &`(删除线/标题/实体引用)——此前恶意文件 + // 名 key~~x~~.pem(删除线注入)、config#Heading(标题注入)、a b + // (实体引用)可改动报告格式;git 文件名本就允许这些字符,点名是 + // 人眼确认用途,替换为空格不影响辨识。 + const safe = String(h.path).replace(/[`\r\n*_[\]<>|~#&]+/g, ' '); return `${i + 1}. \`${safe}\``; }); return [ diff --git a/src/set-config-tool.js b/src/set-config-tool.js index 7a1a525..859edb5 100644 --- a/src/set-config-tool.js +++ b/src/set-config-tool.js @@ -22,8 +22,8 @@ * @module dsh-code-review/set-config-tool */ import { writeFileConfig, PLUGIN_CONFIG_FILE } from './file-config.js'; -import { existsSync, statSync, accessSync, constants } from 'node:fs'; -import { resolve, join } from 'node:path'; +import { existsSync, statSync, realpathSync, accessSync, constants } from 'node:fs'; +import { resolve, join, sep, dirname } from 'node:path'; import { homedir } from 'node:os'; import { cwdOfExec } from './exec-context.js'; @@ -188,6 +188,17 @@ export function createSetConfigTool(ctx, getConfig, io = {}) { } catch { engineBinRejected = `engine_bin 校验失败:\`${candidate}\` 存在但不可执行(权限不足或非可执行文件,传空串可恢复自动探测)。`; } + // issue #23:setuid/setgid 位检查——可执行文件 + 特权位是经典提权 + // 面(正常引擎二进制/脚本绝不带这些位,命中即可疑)。刻意未做符号 + // 链接校验:brew/系统包管理器的合法 symlink 安装会被误拒(校验定位 + // 是提示性防线,dsh-plugin.json 声明与实现保持同口径——未承诺非 + // 符号链接)。 + if (engineBinRejected === null) { + const mode = statSync(candidate).mode; + if ((mode & 0o4000) !== 0 || (mode & 0o2000) !== 0) { + engineBinRejected = `engine_bin 校验失败:\`${candidate}\` 带 setuid/setgid 位(0o${(mode & 0o6000).toString(8)}),拒绝写入(传空串可恢复自动探测)。`; + } + } } } catch (error) { engineBinRejected = `engine_bin 校验异常:${String(error?.message ?? error)}(该字段未写入,其余字段不受影响)`; @@ -199,12 +210,74 @@ export function createSetConfigTool(ctx, getConfig, io = {}) { patch.engine_bin = candidate; } } - // 整单只剩被拒的 engine_bin(其余字段时照写,拒绝原因进回显)。 - if (Object.keys(patch).length === 0 && engineBinRejected !== null) { + // report_dir 安全校验(issue #23):该值原样落盘后会被 report.js 作为评审 + // 报告根目录(mkdir -p 后写 .md)——模型经提示注入诱导 set-config 把 + // report_dir 指向 /etc、../../sensitive-path 等即可写预期目录之外。与 + // engine_bin 同风格做写前校验,但**不要求目录已存在**(报告目录是待建 + // 目录,与引擎二进制「必须存在」语义不同)。 + // 允许根 = 用户 home ∪ 调用方会话工作目录(用户可控位置):相对路径 + // resolve 后落在会话目录下;绝对路径必须落在两者之一,其余(/etc、系统 + // 盘根等常规投毒目标)一律拒绝。符号链接逃逸:对已存在的最长祖先做 + // realpath,解析结果越出允许根即拒绝(目录遍历逃逸检查)。 + // null(清空)跳过校验;失败只剔除该字段、其余照常落盘(与 engine_bin + // 同口径),整单只剩被拒字段时下方统一按拒绝收尾。 + let reportDirRejected = null; + if (patch.report_dir !== null && patch.report_dir !== undefined) { + let dirCandidate; + try { + if (patch.report_dir === '~') { + dirCandidate = homedir(); + } else if (patch.report_dir.startsWith('~/') || patch.report_dir.startsWith('~\\')) { + dirCandidate = join(homedir(), patch.report_dir.slice(2)); + } else { + dirCandidate = resolve(cwdOfExec(_exec) ?? process.cwd(), patch.report_dir); + } + const home = homedir(); + const base = cwdOfExec(_exec) ?? process.cwd(); + // 允许根比较:Windows 统一小写(盘符/大小写不敏感),POSIX 原样; + // 尾部统一 sep,避免 home/base 作为子串被误判(如 home='C:\\Users\\HP'、 + // 候选='C:\\Users\\HP2' 不该算 home 下)。 + const norm = (p) => (IS_WIN ? p.toLowerCase() : p); + const inAllowed = (p) => { + const pp = norm(p); + return pp === norm(home) || pp === norm(base) + || pp.startsWith(norm(home) + sep) || pp.startsWith(norm(base) + sep); + }; + if (!inAllowed(dirCandidate)) { + reportDirRejected = `report_dir 校验失败:\`${patch.report_dir}\` 解析为 \`${dirCandidate}\`,不在用户 home 或会话工作目录下(报告目录仅允许这两处及其子目录;传空串可恢复默认目录)。`; + } else { + // 目录遍历逃逸:真实存在的最高祖先经 realpath 解析符号链接后必须仍 + // 在允许根内(目录本身未创建时沿祖先链上溯到第一个存在的段)。 + let probe = dirCandidate; + while (!existsSync(probe)) { + const parent = dirname(probe); + if (parent === probe) break; + probe = parent; + } + const resolved = realpathSync(probe); + if (!inAllowed(resolved)) { + reportDirRejected = `report_dir 校验失败:\`${patch.report_dir}\` 解析为 \`${dirCandidate}\`,其存在祖先 \`${probe}\` 解析符号链接后落在 \`${resolved}\`(越出允许范围,疑似目录遍历逃逸;传空串可恢复默认目录)。`; + } + } + } catch (error) { + reportDirRejected = `report_dir 校验异常:${String(error?.message ?? error)}(该字段未写入,其余字段不受影响)`; + } + if (reportDirRejected !== null) { + delete patch.report_dir; + } else { + // 落盘规范化绝对路径(与 engine_bin 同口径)——保证消费端 + // expandHome/resolve 与校验视角一致,不存原始 `~/x`/相对形态。 + patch.report_dir = dirCandidate; + } + } + // 整单只剩被拒字段(engine_bin / report_dir)时整体拒绝写入;其余字段 + // 照常落盘、拒绝原因进回显。 + if (Object.keys(patch).length === 0 && (engineBinRejected !== null || reportDirRejected !== null)) { + const why = [engineBinRejected, reportDirRejected].filter(Boolean).join('\n- '); return { ok: false, config_path: configFile, - text: `## 代码审查插件配置(写入被拒)\n\n- ${engineBinRejected}\n- 已拒绝写入,现有配置未改动。`, + text: `## 代码审查插件配置(写入被拒)\n\n- ${why}\n- 已拒绝写入,现有配置未改动。`, }; } let wrote = false; @@ -237,6 +310,7 @@ export function createSetConfigTool(ctx, getConfig, io = {}) { // 成功回显必须带拒绝说明——「写入成功」只是其余字段成功,被拒字段 // 不可静默消失。 ...(engineBinRejected !== null ? ['', `- ⚠ ${engineBinRejected}(该字段未写入,其余字段已生效)`] : []), + ...(reportDirRejected !== null ? ['', `- ⚠ ${reportDirRejected}(该字段未写入,其余字段已生效)`] : []), '- 提示:面板「设置」里的同名字段在 0.2.0 宿主对第三方插件的回传通道修复前暂不生效,以本工具/本配置文件为准。', ]; return { ok: true, config_path: configFile, text: lines.join('\n') };