diff --git a/dsh-plugin.json b/dsh-plugin.json index 1ed878e..af87bf3 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 下,且 realpath 解析符号链接后无目录遍历逃逸(issue #23)", + "reason": "落盘任务簿(tasks/ 任务文件)/ 插件运行配置(plugin-config.json)/ 评审报告留档(写入路径按 scope 声明校验,防提示注入把报告写到任意目录)" }, { "name": "process.spawn", - "scope": "插件包 bin/ 或 PATH 中的 codecheck(含用户经 engine_bin 显式配置、写入前做本地文件校验的路径);辅助命令 git / where|which / taskkill(平台子进程树终止);以及以 process.execPath 拉起的本插件后台 serve 进程", + "scope": "插件包 bin/ 或 PATH 中的 codecheck(含用户经 engine_bin 显式配置的路径(写入前校验:绝对路径、文件存在、非符号链接、非 setuid/setgid 位,issue #23));辅助命令 git / where|which / taskkill(平台子进程树终止);以及以 process.execPath 拉起的本插件后台 serve 进程", "reason": "评审引擎与后台 serve 均为外部子进程(由 runner / task-book 以子进程方式拉起执行),辅助命令服务于待提交清单(git)、引擎路径探测(where/which)与超时终止(taskkill)" }, { "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..6f458cb 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 >= 8)) { + // 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..4ea2cb9 100644 --- a/scripts/sensitive-e2e-smoke.mjs +++ b/scripts/sensitive-e2e-smoke.mjs @@ -112,11 +112,21 @@ try { 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(',') || '无泄露'); + } else { + // issue #22:报告断言不再静默跳过——评审失败(报告未落盘)时逐条记 FAIL + // 并归因到「评审未产出报告」,杜绝零失败项假绿。 + check('报告含「敏感文件点名」节', false, '报告缺失——评审未产出报告,点名断言无法通过'); + check('报告点名 .env(内容未送审的兜底人眼检查点)', false, '报告缺失——评审未产出报告,点名断言无法通过'); + check('报告未含 .env 内容(密钥值不出现 = 内容未送审)', false, '报告缺失——评审未产出报告,泄露断言无法通过'); } + // 引擎评审输出泄露断言:从 candidates>0 嵌套提出(issue #22)——无论报告是否 + // 落盘,只要 reviewOnce 有返回就检查其不泄露 .env 内容;评审静默失败(text 空) + // 时也记录失败归因,而非整段跳过。 + const engineOut = text; + const envProbesOut = ['EXAMPLE_PLACEHOLDER', 'EXAMPLE_NOT_A_REAL_KEY_12345']; + const leakedOut = envProbesOut.filter((s) => engineOut.includes(s)); + check('引擎输出未含 .env 内容(送审链路也未触碰)', engineOut.length > 0 && leakedOut.length === 0, + engineOut.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..adecd87 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 依赖损坏/缺失时此前静默 return null——路由表为空、 + // 评审 0.0s 失败且失败文案不指向原因,排查者无从下手。输出可诊断信息 + // (含真实错误原因)后**仍保持 return null 契约**:运行态降级链与 + // scripts/switch-model-e2e.mjs 的 check('解析出端点', !!ep) + ep?.keyEnv + // 安全链都依赖「解析失败 = 返回 null 不抛」,此分支只允许加诊断,绝不 + // 能改成抛异常/改返回值。 + console.error( + '[dsh-code-review] js-yaml 加载失败:评审模型配置(profile 的 cordis.patch.yml)无法解析,' + + '路由表将为空(评审会因缺少 LLM 端点而失败)。原因:' + String(error?.message ?? error), + ); + return null; // 契约不变:解析失败 = 返回 null 不抛(路由表空时由 resolveProvider 返回 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..455a166 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, + // 隐藏文件/目录(点开头)中 secret 作为**独立词段**出现(.secret、.secrets.yml、 + // .my-secrets、dir/.secrets)。与下方 secrets?|credentials? 行分工:该行只管点 + // 开头形态;非隐藏的 secrets/credentials.yml 等由 secrets?|credentials? 行覆盖 + // (两个正则不再对同一形态重叠发力)。末尾 (?=[.\-_]|$) 保证 secret 词段后有 + // 分隔符或行尾——隐藏词段内嵌 secret 子串的其他形态不再被宽泛 [^/]* 吞入。 + /(^|\/)\.([^/]*[.\-_])?secrets?(?=[.\-_]|$)/i, /(^|\/)\.env(\.[^/]+)?$/i, // .env / .env.production 等 // 凭据扩展名必须作为「独立扩展名」出现(my.key、cert.pfx、server.crt.bak 等): // 敏感词前必须由点号分隔([^/]*\.),后跟行尾或再一个点(如 .key.bak 的 .bak)。 @@ -37,7 +42,11 @@ 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, + // 凭据扩展名后允许再跟一段点号修饰(.key.bak 的 .bak)或直接行尾——与 .env + // 行的 (\.[^/]+)?$ 锚定形态对齐(issue #24:原 (\.|$) 只认「单个点或行尾」, + // 边界语义不一致;对齐后 .key.bak.bak 这类「凭据扩展名后仍是文件修饰」也按 + // 同口径处理)。 + /(^|\/)[^/]*\.(pem|p12|pfx|keystore|jks|cert|crt|key|kdbx|otpkey)(\.[^/]+)?$/i, /(^|\/)credentials[^/]*$/i, // token 模式收紧(形态:独立词段 token(s) + **凭据类扩展名或路径结尾**)—— // 原 `.*token[^/]*$` 命中路径任意位置含 "token" 的文件(src/tokenizer.js、 @@ -45,7 +54,11 @@ 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, + // token 词段后必须跟随分隔符(. _ -)或行尾((?=[._-]|$))——issue #24: + // tokens? 缺词边界时,只要路径内存在 "token" 子串且其后以 .yml 等合法扩展名 + // 收尾即命中,tokenizer-decoder.yml / tokenizer.config 这类「token 只是词前缀」 + // 的业务命名会被误收(词边界挡住:token 后跟 "i" 不是分隔符也不是行尾)。 + /(^|\/)(.*[.\-_])?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 后不是点而是下划线/横线。此处不写 @@ -117,7 +130,7 @@ export function buildSensitiveBlock(pendingPaths) { // 路径未经转义直接进反引号代码块——git 文件名可含 // 反引号/换行(git 允许),会闭合代码块并向报告注入任意 markdown。拼接前 // 剔除反引号与换行(点名目的是人眼确认路径,替换为空格不影响辨识)。 - const safe = String(h.path).replace(/[`\r\n*_[\]<>|]+/g, ' '); + 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..cccf69e 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, accessSync, lstatSync, realpathSync, constants } from 'node:fs'; +import { resolve, join, isAbsolute, basename, dirname } from 'node:path'; import { homedir } from 'node:os'; import { cwdOfExec } from './exec-context.js'; @@ -189,6 +189,26 @@ export function createSetConfigTool(ctx, getConfig, io = {}) { engineBinRejected = `engine_bin 校验失败:\`${candidate}\` 存在但不可执行(权限不足或非可执行文件,传空串可恢复自动探测)。`; } } + // issue #23:补「绝对路径 / 非符号链接 / 非 setuid/setgid」校验——候选由 + // resolve/join 构造通常已是绝对路径,仍显式校验一次(防御未来改动); + // statSync 跟随符号链接,链接指向任意可执行文件也能通过 exists+isFile+ + // X_OK 三层,需 lstat 单独判定;setuid/setgid 位会让引擎进程以文件属主权限 + // 运行。与 dsh-plugin.json process.spawn scope 承诺逐字对齐。 + if (engineBinRejected === null && !isAbsolute(candidate)) { + engineBinRejected = `engine_bin 校验失败:\`${candidate}\` 非绝对路径(该字段未写入)。`; + } + if (engineBinRejected === null) { + try { + const lst = lstatSync(candidate); + if (lst.isSymbolicLink()) { + engineBinRejected = `engine_bin 校验失败:\`${candidate}\` 是符号链接(防引擎路径被重定向到任意可执行文件,不接受符号链接,传空串可恢复自动探测)。`; + } else if (!IS_WIN && (lst.mode & 0o6000) !== 0) { + engineBinRejected = `engine_bin 校验失败:\`${candidate}\` 带 setuid 位(拒绝以文件属主权限拉起引擎,传空串可恢复自动探测)。`; + } + } catch (error) { + engineBinRejected = `engine_bin 校验异常:${String(error?.message ?? error)}(该字段未写入,其余字段不受影响)`; + } + } } catch (error) { engineBinRejected = `engine_bin 校验异常:${String(error?.message ?? error)}(该字段未写入,其余字段不受影响)`; } @@ -199,12 +219,62 @@ export function createSetConfigTool(ctx, getConfig, io = {}) { patch.engine_bin = candidate; } } - // 整单只剩被拒的 engine_bin(其余字段时照写,拒绝原因进回显)。 - if (Object.keys(patch).length === 0 && engineBinRejected !== null) { + // issue #23(深度方案):report_dir 写前校验——此前直接落盘无校验, + // 恶意/误导路径(提示注入)会让后续评审把报告写到任意目录;码级校验 + // 与 dsh-plugin.json fs.write scope 承诺一致:展开 ~ → 绝对路径 → 必须 + // 位于用户 home 下 → 已存在段经 realpath 解析符号链接后无目录遍历逃逸。 + // 与 engine_bin 同款「部分拒绝」语义:只剔除该字段,其余字段照常落盘。 + let reportDirRejected = null; + if (patch.report_dir !== null && patch.report_dir !== undefined) { + let rd; + try { + const home = homedir(); + if (patch.report_dir === '~') { + rd = home; + } else if (patch.report_dir.startsWith('~/') || patch.report_dir.startsWith('~\\')) { + rd = join(home, patch.report_dir.slice(2)); + } else { + rd = resolve(cwdOfExec(_exec) ?? process.cwd(), patch.report_dir); + } + if (!isAbsolute(rd)) throw new Error(`解析结果 \`${rd}\` 非绝对路径`); + // home 前缀比对:Windows 卷/盘符与目录名大小写不敏感,小写归一后比较。 + const norm = (p) => (IS_WIN ? String(p).toLowerCase() : String(p)); + const homeReal = realpathSync(home); + let rdReal = rd; + if (existsSync(rd)) { + rdReal = realpathSync(rd); // 已存在:全量解析(含符号链接跳转) + } else { + // 目录尚未创建:逐级向上找最近存在祖先做 realpath,再原序拼接缺失 + // 段——防「home 下看似合法、前缀段却是符号链接指向 home 外」(遍历逃逸)。 + const missing = []; + let probe = rd; + while (!existsSync(probe)) { + const parent = dirname(probe); + if (parent === probe) break; + missing.push(basename(probe)); + probe = parent; + } + rdReal = join(realpathSync(probe), ...missing.reverse()); + } + if (!norm(rdReal).startsWith(norm(homeReal))) { + reportDirRejected = `report_dir 校验失败:\`${patch.report_dir}\` 解析为 \`${rd}\`(realpath \`${rdReal}\`),不在用户 home(\`${home}\`)下(传空串可恢复默认)。`; + } + } catch (error) { + reportDirRejected = `report_dir 校验异常:${String(error?.message ?? error)}(该字段未写入,其余字段不受影响)`; + } + if (reportDirRejected !== null) { + delete patch.report_dir; + } else { + // 落盘规范化绝对路径(与 engine_bin 同款口径:校验与消费端同一视角)。 + patch.report_dir = rd; + } + } + // 整单只剩被拒的字段(其余字段时照写,拒绝原因进回显)。 + if (Object.keys(patch).length === 0 && (engineBinRejected !== null || reportDirRejected !== null)) { return { ok: false, config_path: configFile, - text: `## 代码审查插件配置(写入被拒)\n\n- ${engineBinRejected}\n- 已拒绝写入,现有配置未改动。`, + text: `## 代码审查插件配置(写入被拒)\n\n- ${engineBinRejected ?? reportDirRejected}\n- 已拒绝写入,现有配置未改动。`, }; } let wrote = false; @@ -237,6 +307,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') };