Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions dsh-plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,18 +16,18 @@
"permissions": [
{
"name": "fs.write",
"scope": "~/.dsh/dsh-code-review/**、默认报告目录(~ 展开后的 ~/dsh-code-review-reports/**)及用户经 report_dir 配置的报告目录",
"scope": "~/.dsh/dsh-code-review/**、默认报告目录(~ 展开后的 ~/dsh-code-review-reports/**)及用户经 report_dir 配置的报告目录(写入前校验:目标须为 home 下绝对路径、解析符号链接后无目录遍历逃逸)",
"reason": "落盘任务簿(tasks/ 任务文件)/ 插件运行配置(plugin-config.json)/ 评审报告留档"
},
{
"name": "process.spawn",
"scope": "插件包 bin/ 或 PATH 中的 codecheck(含用户经 engine_bin 显式配置、写入前做本地文件校验的路径);辅助命令 git / where|which / taskkill(平台子进程树终止);以及以 process.execPath 拉起的本插件后台 serve 进程",
"scope": "插件包 bin/ 或 PATH 中的 codecheck(用户经 engine_bin 显式配置时:写入前校验 engine_bin 须为绝对路径、文件存在、非符号链接、非 setuid 位);辅助命令 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 调用,绝不写入日志或评审报告"
}
],
"contributes": {
Expand Down
6 changes: 6 additions & 0 deletions scripts/engine-e2e-smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,12 @@ 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 必败。key 缺失 = 无真实端点
// 凭证,引擎评审无从发起——短路跳过引擎调用,明确输出跳过原因(无
// DEEPSEEK_API_KEY 的开发机/CI 不应以「空等超时」形态误报失败)。
check('跳过引擎调用(key 缺失)', true, '未解析到 DEEPSEEK_API_KEY(真实端点 token),引擎无法评审;配置 ~/.dsh/.credentials.yaml 后重跑');
} else {
const env = {
...process.env,
Expand Down
24 changes: 15 additions & 9 deletions scripts/sensitive-e2e-smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -102,21 +102,27 @@ try {
};
if (existsSync(reportDir)) walk(reportDir);
check('报告文件已落盘(按日期/仓库分目录)', candidates.length >= 1, candidates[0] ?? '无');
// issue #22:此前这些断言整体嵌套在 if (candidates.length > 0) 内——评审
// 静默失败(未落盘/落盘目录异常)时整段跳过、零失败项假绿。把「报告存在」
// 抽成独立判据后,泄露检测断言无条件执行:评审失败本身也被记录,杜绝静默跳过。
check('报告文件已生成(评审链路未静默失败)', candidates.length > 0, candidates[0] ?? '无报告落盘');
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=0)也照常执行——泄露检测不依赖报告存在,评审失败被上一判据记录。
// 断言值必须与上方写入 .env 的夹具**同一串**(改夹具不改断言 = 断言恒真假绿)。
const envProbes = ['EXAMPLE_PLACEHOLDER', 'EXAMPLE_NOT_A_REAL_KEY_12345'];
const leaked = reportPath && existsSync(reportPath)
? envProbes.filter((s) => readFileSync(reportPath, 'utf8').includes(s))
: [];
check('报告未含 .env 内容(密钥值不出现 = 内容未送审)', leaked.length === 0, leaked.join(',') || '无泄露');
const engineOut = 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('临时仓库/报告目录已清理。');
Expand Down
10 changes: 8 additions & 2 deletions src/model-provider.js
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,14 @@ function loadProfileLlmConfig() {
let yaml;
try {
yaml = require('js-yaml');
} catch {
return null; // 开发环境若 junction 未建也能继续(路由表可能为空,resolveProvider 返回 null)
} catch (error) {
// issue #21:依赖损坏/缺失被静默吞掉(catch 空 → 路由表为空、评审 0.0s
// 失败且失败文案不指向原因)。这里输出真实错误原因供诊断,但**保持原返回
// 契约不变**:解析失败 = 返回 null 不抛(scripts/switch-model-e2e.mjs 走
// check('解析出端点', !!ep) 安全链,运行态降级链同样依赖「解析失败=返回
// null 不抛」)。js-yaml 本体不涉及敏感信息,可安全打印错误消息。
console.error('[dsh-code-review] 加载 js-yaml 失败,评审模型路由表将为空(解析失败返回 null 语义不变):', error?.message ?? error);
return null;
}
// 根目录统一走 resolveDshRoot:优先 DSH_HOME,回退 ~/.dsh(issue #13)。
const profilesRoot = join(resolveDshRoot(), 'profiles');
Expand Down
21 changes: 17 additions & 4 deletions src/sensitive-list.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)。
Expand All @@ -37,15 +42,23 @@ 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、
// token-utils/index.js 等业务文件也被点名),噪声淹没「逐个人眼确认」信号。
// 扩展名收敛到凭据类白名单(与下方 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 后不是点而是下划线/横线。此处不写
Expand Down Expand Up @@ -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 [
Expand Down
72 changes: 65 additions & 7 deletions src/set-config-tool.js
Original file line number Diff line number Diff line change
Expand Up @@ -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, realpathSync, constants } from 'node:fs';
import { resolve, join, dirname, sep } from 'node:path';
import { homedir } from 'node:os';
import { cwdOfExec } from './exec-context.js';

Expand Down Expand Up @@ -68,7 +68,7 @@ export function createSetConfigTool(ctx, getConfig, io = {}) {
additionalProperties: false,
properties: {
review_model: { type: ['string', 'null'], description: '评审模型标识。省略/空串/null = 沿用会话模型;其他值 = 显式另选的模型。' },
report_dir: { type: ['string', 'null'], description: '报告目录(支持 ~)。空串/null 恢复默认。' },
report_dir: { type: ['string', 'null'], description: '报告目录(支持 ~,须为 home 下绝对路径,写入前校验防符号链接逃逸)。空串/null 恢复默认。' },
engine_bin: { type: ['string', 'null'], description: '引擎二进制路径。空串/null 恢复自动探测。传路径前请先确认文件存在——不存在/目录会被拒绝写入。' },
},
required: [],
Expand Down Expand Up @@ -126,6 +126,59 @@ export function createSetConfigTool(ctx, getConfig, io = {}) {
...(args?.report_dir !== undefined ? { report_dir: normalizeValue(args.report_dir) } : {}),
...(args?.engine_bin !== undefined ? { engine_bin: normalizeValue(args.engine_bin) } : {}),
};
// report_dir 安全校验(issue #23 代码级兑现):dsh-plugin.json 的
// fs.write 声明已承诺「写入前校验目标为 home 下绝对路径、解析符号链接后
// 无目录遍历逃逸」——此处把承诺变成代码。与 engine_bin 校验的差异:报告
// 目录消费端(report.js writeReport)会自动 mkdir,故「目标目录尚不存在」
// 是合法输入(对最近存在的祖先做 realpath 防符号链接逃逸即可);
// null/空串(清空恢复默认)跳过校验。有意收紧:报告目录仅允许落在用户
// home 之内(默认 ~/dsh-code-review-reports 即在此),保证 manifest
// 声明的权限面与实际写盘点一致。
let reportDirRejected = null;
if (patch.report_dir !== null && patch.report_dir !== undefined) {
let candidate;
try {
if (patch.report_dir === '~') {
candidate = homedir();
} else if (patch.report_dir.startsWith('~/') || patch.report_dir.startsWith('~\\')) {
candidate = join(homedir(), patch.report_dir.slice(2));
} else {
// 相对路径以**调用方会话目录**为基准解析(与 engine_bin 同一基准)。
candidate = resolve(cwdOfExec(_exec) ?? process.cwd(), patch.report_dir);
}
// 承诺一:home 下绝对路径。home 前缀比较必须带分隔符(sep)——
// C:/Users/HP 与 C:/Users/HP2 不混淆;Windows 上大小写不敏感,
// 统一 resolve 规范化后再比。
const home = resolve(homedir());
if (candidate !== home && !candidate.startsWith(home + sep)) {
reportDirRejected = `report_dir 校验失败:\`${patch.report_dir}\` 解析为 \`${candidate}\`,不在用户 home 目录(\`${home}\`)之下(报告目录仅允许落在 home 内,传空串可恢复默认)。`;
} else {
// 承诺二:解析符号链接后无目录遍历逃逸。目标可能尚未创建
//(writeReport 自动 mkdir),逐级上溯到最近存在的祖先做 realpath。
let probe = candidate;
while (!existsSync(probe)) {
const parent = dirname(probe);
if (parent === probe) break;
probe = parent;
}
const real = realpathSync(probe);
if (real !== home && !real.startsWith(home + sep)) {
reportDirRejected = `report_dir 校验失败:\`${patch.report_dir}\` 经符号链接解析后指向 \`${real}\`,逃逸出用户 home 目录(\`${home}\`)。`;
} else if (existsSync(candidate) && !statSync(candidate).isDirectory()) {
reportDirRejected = `report_dir 校验失败:\`${candidate}\` 存在但不是目录(报告目录必须是目录)。`;
}
}
} catch (error) {
reportDirRejected = `report_dir 校验异常:${String(error?.message ?? error)}(该字段未写入,其余字段不受影响)`;
}
if (reportDirRejected !== null) {
delete patch.report_dir;
} else {
// 落盘规范化绝对路径——消费端 expandHome 对绝对路径幂等(report.js),
// 保存展开结果避免「校验看到的路径 ≠ 运行时路径」错位。
patch.report_dir = candidate;
}
}
// engine_bin 安全校验:该值原样落盘后会被引擎定位
// 逻辑作为 spawn 候选二进制(cfg.engine_bin 优先级最高),且支持相对
// 路径/`~`——恶意输入(提示注入写 /tmp/evil、../../foo)即可把后续评审
Expand Down Expand Up @@ -199,12 +252,16 @@ export function createSetConfigTool(ctx, getConfig, io = {}) {
patch.engine_bin = candidate;
}
}
// 整单只剩被拒的 engine_bin(其余字段时照写,拒绝原因进回显)。
if (Object.keys(patch).length === 0 && engineBinRejected !== null) {
// 整单只剩被拒的 engine_bin / report_dir(其余字段时照写,拒绝原因进回显)。
if (Object.keys(patch).length === 0 && (engineBinRejected !== null || reportDirRejected !== null)) {
const reasons = [engineBinRejected, reportDirRejected]
.filter((x) => x !== null)
.map((x) => `- ${x}`)
.join('\n');
return {
ok: false,
config_path: configFile,
text: `## 代码审查插件配置(写入被拒)\n\n- ${engineBinRejected}\n- 已拒绝写入,现有配置未改动。`,
text: `## 代码审查插件配置(写入被拒)\n\n${reasons}\n- 已拒绝写入,现有配置未改动。`,
};
}
let wrote = false;
Expand Down Expand Up @@ -233,10 +290,11 @@ export function createSetConfigTool(ctx, getConfig, io = {}) {
`- 评审模型:\`${active.review_model}\``,
`- 报告目录:\`${active.report_dir}\``,
`- 引擎路径:\`${active.engine_bin || '(自动探测)'}\``,
// 部分字段被拒(engine_bin 校验失败)时
// 部分字段被拒(engine_bin / report_dir 校验失败)时
// 成功回显必须带拒绝说明——「写入成功」只是其余字段成功,被拒字段
// 不可静默消失。
...(engineBinRejected !== null ? ['', `- ⚠ ${engineBinRejected}(该字段未写入,其余字段已生效)`] : []),
...(reportDirRejected !== null ? ['', `- ⚠ ${reportDirRejected}(该字段未写入,其余字段已生效)`] : []),
'- 提示:面板「设置」里的同名字段在 0.2.0 宿主对第三方插件的回传通道修复前暂不生效,以本工具/本配置文件为准。',
];
return { ok: true, config_path: configFile, text: lines.join('\n') };
Expand Down
Loading