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
8 changes: 4 additions & 4 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 配置的报告目录",
"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 位,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": {
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 === 0) {
// issue #22:key 未解析(无 .credentials.yaml / 未配置 DEEPSEEK_API_KEY)时,
// 此前仍把 undefined 塞进 OCR_LLM_TOKEN → spawn 成字面量 'undefined',引擎
// 拿它空等 180s 必败且无明确归因。显式短路跳过引擎调用,并输出明确 check
// (无 key 环境下跑 e2e 是「预期跳过」形态,不是挂死也不是假失败)。
check('codecheck review(需 DEEPSEEK_API_KEY)', false, '跳过引擎调用(key 缺失)——本机未配置评审凭据,引擎实证无法执行');
} else {
const env = {
...process.env,
Expand Down
18 changes: 14 additions & 4 deletions scripts/sensitive-e2e-smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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('临时仓库/报告目录已清理。');
Expand Down
8 changes: 7 additions & 1 deletion src/model-provider.js
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,13 @@ function loadProfileLlmConfig() {
let yaml;
try {
yaml = require('js-yaml');
} catch {
} catch (error) {
// issue #21:js-yaml 依赖损坏(缺包/版本不兼容/加载抛错)此前被静默吞掉——
// 路由表为空 → 评审 0.0s 失败且失败文案不指向原因。这里只输出可诊断信息
// (真实错误原因),绝不抛出、绝不改变返回值契约:scripts/switch-model-e2e.mjs
// 的 check('解析出端点', !!ep) + ep?.keyEnv ?? '' 安全链与运行态降级链都
// 依赖「解析失败 = 返回 null 不抛」。
console.error('[dsh-code-review] 警告:加载 js-yaml 失败(依赖损坏),模型路由表将为空,评审可能失败。原因:', String(error?.message ?? error));
return null; // 开发环境若 junction 未建也能继续(路由表可能为空,resolveProvider 返回 null)
}
// 根目录统一走 resolveDshRoot:优先 DSH_HOME,回退 ~/.dsh(issue #13)。
Expand Down
20 changes: 15 additions & 5 deletions src/sensitive-list.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,10 @@ export const SENSITIVE_PATH_PATTERNS = [
// 下方 id_* 行覆盖、*.pem 由凭据扩展名行覆盖);.ssh 目录段单独无覆盖属
// 已知盲区(引擎侧路径保护另有 .ssh 排除),勿据此删除下方覆盖行。
/(^|\/)\.[^/]*key[^/]*$/i,
/(^|\/)\.[^/]*secret/i,
// issue #24:加尾锚 ([.\-/]|$) —— secret 只认「隐藏文件名词段结尾」形态
// (.secret / .my.secret / .a-secret.bak),不再命中 .secretive 这类词干扩展;
// 独立词段 secrets/secret+x 形态统一由下方 (secrets?|credentials?) 负责,两规则不重叠。
/(^|\/)\.[^/]*secret([.\-/]|$)/i,
/(^|\/)\.env(\.[^/]+)?$/i, // .env / .env.production 等
// 凭据扩展名必须作为「独立扩展名」出现(my.key、cert.pfx、server.crt.bak 等):
// 敏感词前必须由点号分隔([^/]*\.),后跟行尾或再一个点(如 .key.bak 的 .bak)。
Expand All @@ -38,14 +41,16 @@ export const SENSITIVE_PATH_PATTERNS = [
// key/cert/pem(sessionkey.go、keycmd.go 等)也不再命中;id_rsa/credentials/.env
// 等由各自独立规则覆盖,不受此行影响。
/(^|\/)[^/]*\.(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,
// issue #24:tokens? 补词边界 (?=[._-]|$) —— token 后必须是分隔符或行尾,
// 否则 tokenizer-decoder.yml、tokenization.js 这类「token 作词干」的普通
// 命名会被误收(原正则部分匹配 token 词首即可命中)。
/(^|\/)(.*[.\-_])?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 All @@ -57,7 +62,10 @@ export const SENSITIVE_PATH_PATTERNS = [
// secrets/credentials 命名(原 credentials 行只覆盖含字面量 credentials 的
// 基名、\.secret 要求点开头)。
/(^|\/)\.(npmrc|netrc|git-credentials)$/i,
/(^|\/)(secrets?|credentials?)(\.|\/|$)/i,
// issue #24:凭据扩展名锚定与 .env 模式((\.[^/]+)?$)对齐——词段后只认
// 「点扩展名 + 行尾」或「目录斜杠」,扩展名白名单不在这里重复判定(.env 同款
// 口径:任何点后缀都视为凭据文件候选,误报面由词段本身收窄)。
/(^|\/)(secrets?|credentials?)(\.[^/]*)?($|\/)/i,
// 个人/隐私信息。
/(^|\/)(.*\.)?(id_rsa|id_dsa|id_ecdsa|id_ed25519)$/i,
// 常见凭据兜底(作业本/测试台账类命名)。
Expand Down Expand Up @@ -117,7 +125,9 @@ export function buildSensitiveBlock(pendingPaths) {
// 路径未经转义直接进反引号代码块——git 文件名可含
// 反引号/换行(git 允许),会闭合代码块并向报告注入任意 markdown。拼接前
// 剔除反引号与换行(点名目的是人眼确认路径,替换为空格不影响辨识)。
const safe = String(h.path).replace(/[`\r\n*_[\]<>|]+/g, ' ');
// issue #24:清洗字符类补 ~ # &——GitHub 文件名可含这些字符,落到报告
// markdown 里 # 会截断标题、& 触发 HTML 实体、~ 与删除线/下标语法冲突。
const safe = String(h.path).replace(/[`\r\n*_[\]<>|~#&]+/g, ' ');
return `${i + 1}. \`${safe}\``;
});
return [
Expand Down
81 changes: 76 additions & 5 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, 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';

Expand Down Expand Up @@ -189,6 +189,26 @@ export function createSetConfigTool(ctx, getConfig, io = {}) {
engineBinRejected = `engine_bin 校验失败:\`${candidate}\` 存在但不可执行(权限不足或非可执行文件,传空串可恢复自动探测)。`;
}
}
// issue #23:补「绝对路径 / 非符号链接 / 非 setuid」校验——候选由
// resolve/join 构造通常已是绝对路径,仍显式校验一次(防御未来改动);
// statSync 跟随符号链接,链接指向任意可执行文件也能通过 exists+isFile+
// X_OK 三层,需 lstat 单独判定;setuid 位会让引擎进程以文件属主权限
// 运行。与 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 & 0o4000) !== 0) {
engineBinRejected = `engine_bin 校验失败:\`${candidate}\` 带 setuid 位(拒绝以文件属主权限拉起引擎,传空串可恢复自动探测)。`;
}
} catch (error) {
engineBinRejected = `engine_bin 校验异常:${String(error?.message ?? error)}(该字段未写入,其余字段不受影响)`;
}
}
} catch (error) {
engineBinRejected = `engine_bin 校验异常:${String(error?.message ?? error)}(该字段未写入,其余字段不受影响)`;
}
Expand All @@ -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;
Expand Down Expand Up @@ -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') };
Expand Down
Loading