@moonquake2004/dsh-security 0.1.7 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,31 +1,51 @@
1
1
  /**
2
- * SP9: Dual-Instance Guard — CLI 核心包泄漏检测
2
+ * SP9: Dual-Instance Guard — 真实重复实例检测(不是「名字出现」检测)
3
3
  *
4
4
  * 出处:#4640 + zoahdev/dsh-ecosystem 家族 4——profile 的 node_modules
5
- * 中若出现 @deepseek-ai/dsh-* 包(尤其是 dsh-tools),会导致同一进程
6
- * 两份副本 → TOOL_RUNTIME_SCHEDULER 唯一 symbol 分裂 →
7
- * undefined.prepare → 所有工具调用失败 → 会话不可恢复。
5
+ * 中若出现 @deepseek-ai/dsh-* 的**真实副本**,会导致同一进程两份模块实例
6
+ * → TOOL_RUNTIME_SCHEDULER 唯一 symbol 分裂 → undefined.prepare →
7
+ * 所有工具调用失败 → 会话不可恢复。
8
8
  *
9
- * 核心规则:profile 只承载 surfaces 和 plugins;core packages 由 CLI
10
- * 自身的依赖树提供。profile 中出现任何 @deepseek-ai/dsh-* 包即为异常。
9
+ * 2026-09 上游兼容审计 R5——旧模型(v0.1.7 及以前)的前提是**反的**:
10
+ * `$DSH_HOME/profiles/node_modules` 0.1.5 起是 **dsh 自有的符号链接镜像**,
11
+ * 由 `healProfilesModuleFallback`(dsh-app-boot/lib/index.js:660-690)写成,
12
+ * 镜像的是 CLI 安装闭包(实测 240 条 @deepseek-ai 符号链接、**0 个真实目录**),
13
+ * 其中**合法包含** dsh-tools / dsh-agent-loop 等核心运行时包。
14
+ * 旧实现用 `readdirSync().isDirectory()` 判断,符号链接恒为 false,
15
+ * 因此对同一棵树既「全盲」(镜像)又「会误报 CRITICAL」(若过滤放开)。
11
16
  *
12
- * 做法:扫描 profile/node_modules/@deepseek-ai/ 下的目录,
13
- * 检查是否存在 dsh-* 包(排除纯客户端包如 dsh-client-*,
14
- * 因为它们可能被插件合法引用;只报 dsh-tools/dsh-agent-loop
15
- * 等核心运行时包)。
17
+ * 现在的判据是**解析后的真实形态与来源**,而不是包名:
18
+ * - 真实目录(非符号链接)→ 真实副本 = 风险(核心包 CRITICAL / 其余 HIGH)
19
+ * - 符号链接目标**落在安装前缀之外**(例如指向 ~/.npm/_npx/ 残渣)→ 来源不可信 = HIGH
20
+ * - 符号链接目标落在安装前缀内 → dsh 自有镜像 = **预期形态,不报**
21
+ * - 断链 → 既不预期也不可信,**绝不静默忽略**(计入环境告警,并在 detail 中点名)
16
22
  *
17
- * Severity: CRITICAL(直接导致工具调用全灭)
23
+ * 同时扫描两处(同一 profile 的两级父目录查找,符号链接镜像通常在父级):
24
+ * - `<profileDir>/node_modules/@deepseek-ai/`(profile 自身的真实安装)
25
+ * - `<dshHome>/profiles/node_modules/@deepseek-ai/`(共享镜像,**这一级正是旧实现全盲的位置**)
26
+ *
27
+ * 环境告警(不单独产生 FAIL,但必须出现在 detail 中,逐范围如实报数):
28
+ * - 断链数量(实测:共享镜像 118 / 240 @deepseek-ai 链接全部可解析)
29
+ * - 指向 ~/.npm/_npx/ 残渣的链接数量(实测 95 条)
30
+ *
31
+ * Severity: CRITICAL(真实副本直接导致工具调用全灭)
18
32
  * Phase: POST_INSTALL
19
33
  */
20
34
 
21
- import { readFileSync, readdirSync, existsSync } from 'node:fs';
22
- import { join } from 'node:path';
35
+ import { readdirSync, lstatSync, readlinkSync, readFileSync, existsSync } from 'node:fs';
36
+ import { basename, dirname, join } from 'node:path';
23
37
  import { Severity } from '../protocol/severity.mjs';
24
38
  import { CheckPhase } from '../protocol/phase.mjs';
25
- import { pass, fail, skip } from '../protocol/check.mjs';
39
+ import { createResult, fail, skip } from '../protocol/check.mjs';
40
+ import {
41
+ resolveInstallPrefix,
42
+ resolveSharedMirrorDir,
43
+ resolveLinkTarget,
44
+ isInsidePrefix,
45
+ } from '../install-tree.mjs';
26
46
 
27
47
  /**
28
- * CLI 核心运行时包——出现在 profile 中即为异常。
48
+ * CLI 核心运行时包——出现**真实副本**即为 CRITICAL。
29
49
  * 客户端包(dsh-client-*)被插件合法引用,不在此列。
30
50
  */
31
51
  const CORE_RUNTIME_PKGS = new Set([
@@ -35,63 +55,227 @@ const CORE_RUNTIME_PKGS = new Set([
35
55
  'dsh-subprocess-local',
36
56
  ]);
37
57
 
38
- export async function run(profileDir) {
39
- const id = 'SP9';
40
- const nmDir = join(profileDir, 'node_modules');
41
- if (!existsSync(nmDir)) return pass(id, Severity.CRITICAL, '无 node_modules,跳过核心包泄漏检测');
58
+ /** npx 缓存布局——出现即代表「镜像代际腐烂」,与审计中诊断定位器的腐烂同类 */
59
+ const NPX_RESIDUE_RE = /[/\\]\.npm[/\\]_npx[/\\]/;
42
60
 
43
- const dsDir = join(nmDir, '@deepseek-ai');
44
- if (!existsSync(dsDir)) return pass(id, Severity.CRITICAL, 'profile 中无 @deepseek-ai 包,无泄漏风险');
61
+ /**
62
+ * 同一 profile 的两级父目录查找(镜像通常在 `<dshHome>/profiles/node_modules`)。
63
+ * 入参路径**按原样**保留(避免 realpath 归一后与调用方路径写法不一致造成重复扫描)。
64
+ * @returns {string[]} 待扫描/告警的 node_modules 根目录
65
+ */
66
+ function candidateNodeModulesRoots(profileDir) {
67
+ const out = [join(profileDir, 'node_modules')];
68
+ const shared = resolveSharedMirrorDir(profileDir);
69
+ out.push(shared || join(dirname(profileDir), 'node_modules'));
70
+ return [...new Set(out)];
71
+ }
72
+
73
+ /** 该 node_modules 根是否为共享镜像(`<dshHome>/profiles/node_modules`) */
74
+ function isMirrorRoot(root) {
75
+ return basename(dirname(root)) === 'profiles';
76
+ }
77
+
78
+ /**
79
+ * 符号链接代际统计:只覆盖 node_modules 的**直接包层**(depth 1–2,
80
+ * 即 `pkg` 与 `@scope/pkg`),不深入包的内部依赖子目录。
81
+ * 口径与 2026-09 审计一致:`$DSH_HOME/profiles/node_modules` = 603 条链接
82
+ * (118 断链 / 95 指向 ~/.npm/_npx/)。
83
+ */
84
+ function countMirrorLinks(root) {
85
+ let total = 0, broken = 0, npx = 0;
86
+ const leafOf = (p) => { try { return readlinkSync(p); } catch { return ''; } };
87
+ const handle = (p) => {
88
+ let st;
89
+ try { st = lstatSync(p); } catch { return false; }
90
+ if (st.isSymbolicLink()) {
91
+ total++;
92
+ const raw = leafOf(p);
93
+ if (!existsSync(p)) broken++;
94
+ if (NPX_RESIDUE_RE.test(raw)) npx++;
95
+ return true;
96
+ }
97
+ return false;
98
+ };
99
+
100
+ let level1;
101
+ try { level1 = readdirSync(root, { withFileTypes: true }); } catch { return { total, broken, npx }; }
102
+ for (const entry of level1) {
103
+ const p = join(root, entry.name);
104
+ if (!handle(p)) {
105
+ // 真实目录;scoped 包需要看第二层
106
+ if (entry.name.startsWith('@')) {
107
+ let level2;
108
+ try { level2 = readdirSync(p, { withFileTypes: true }); } catch { continue; }
109
+ for (const child of level2) handle(join(p, child.name));
110
+ }
111
+ }
112
+ }
113
+ return { total, broken, npx };
114
+ }
115
+
116
+ function classify(scopeDir, installPrefix, sourceLabel) {
117
+ const realDirs = [], outside = [], expected = [], broken = [];
118
+ let unresolved = 0;
45
119
 
46
- // 枚举 @deepseek-ai/* 下的包
47
120
  let entries;
48
- try { entries = readdirSync(dsDir, { withFileTypes: true }); } catch { entries = []; }
121
+ try { entries = readdirSync(scopeDir, { withFileTypes: true }); } catch { entries = []; }
49
122
 
50
- const leaked = [];
51
123
  for (const entry of entries) {
52
- if (!entry.isDirectory()) continue;
53
- const pkgName = `@deepseek-ai/${entry.name}`;
54
- const pjPath = join(dsDir, entry.name, 'package.json');
55
- if (!existsSync(pjPath)) continue;
56
-
57
- let version = '?';
58
- try {
59
- const pkg = JSON.parse(readFileSync(pjPath, 'utf8'));
60
- version = pkg.version || '?';
61
- } catch { /* read error */ }
62
-
63
- if (entry.name.startsWith('dsh-')) {
64
- leaked.push({ name: pkgName, version, isCore: CORE_RUNTIME_PKGS.has(entry.name) });
124
+ if (!entry.name.startsWith('dsh-')) continue;
125
+ const p = join(scopeDir, entry.name);
126
+ let st;
127
+ try { st = lstatSync(p); } catch { continue; }
128
+
129
+ if (st.isSymbolicLink()) {
130
+ let raw = '';
131
+ try { raw = readlinkSync(p); } catch { raw = ''; }
132
+ if (!existsSync(p)) { broken.push({ name: entry.name, target: raw, source: sourceLabel }); continue; }
133
+ if (!installPrefix) { unresolved++; continue; }
134
+ const resolved = resolveLinkTarget(p, raw);
135
+ if (isInsidePrefix(resolved, installPrefix)) expected.push({ name: entry.name, target: resolved, source: sourceLabel });
136
+ else outside.push({ name: entry.name, target: resolved, source: sourceLabel });
137
+ continue;
138
+ }
139
+
140
+ if (st.isDirectory()) {
141
+ let version = '?';
142
+ const pjPath = join(p, 'package.json');
143
+ if (existsSync(pjPath)) {
144
+ try { version = JSON.parse(readFileSync(pjPath, 'utf8')).version || '?'; } catch { /* 保留 ? */ }
145
+ }
146
+ realDirs.push({
147
+ name: entry.name, version, source: sourceLabel,
148
+ isCore: CORE_RUNTIME_PKGS.has(entry.name),
149
+ });
65
150
  }
66
151
  }
152
+ return { realDirs, outside, expected, broken, unresolved };
153
+ }
67
154
 
68
- if (leaked.length === 0) {
69
- return pass(id, Severity.CRITICAL, 'profile 中无 @deepseek-ai/dsh-* 泄漏,核心包仅由 CLI 提供');
155
+ export async function run(profileDir, options = {}) {
156
+ const id = 'SP9';
157
+ const nmRoots = candidateNodeModulesRoots(profileDir);
158
+ const scopeDirs = nmRoots.map(nm => join(nm, '@deepseek-ai'));
159
+ const scanned = scopeDirs.filter(d => existsSync(d));
160
+
161
+ if (scanned.length === 0) {
162
+ return skip(id, Severity.CRITICAL,
163
+ `未找到 @deepseek-ai 目录(已查找 ${scopeDirs.join(' 与 ')}),未执行重复实例检测——无法判定核心包安装形态`);
164
+ }
165
+
166
+ const installPrefix = options.installPrefix !== undefined
167
+ ? options.installPrefix
168
+ : resolveInstallPrefix(profileDir);
169
+
170
+ const realDirs = [], outside = [], expected = [], brokenPkgLinks = [];
171
+ let unresolved = 0;
172
+ for (const d of scanned) {
173
+ const label = isMirrorRoot(dirname(d)) ? '共享镜像' : 'profile';
174
+ const r = classify(d, installPrefix, label);
175
+ realDirs.push(...r.realDirs);
176
+ outside.push(...r.outside);
177
+ expected.push(...r.expected);
178
+ brokenPkgLinks.push(...r.broken);
179
+ unresolved += r.unresolved;
70
180
  }
71
181
 
72
- const coreLeaks = leaked.filter(l => l.isCore);
73
- const otherLeaks = leaked.filter(l => !l.isCore);
182
+ // 环境告警:断链 / npx 残渣(逐范围如实报数,不合并、不夸大)
183
+ // 范围 = 已扫描的 node_modules 根 + 共享镜像根本身(即便镜像的 @deepseek-ai 子目录缺失,
184
+ // 镜像里其余包的断链同样值得报——审计实测的 118 条断链即分布在整个镜像里)
185
+ const censusRoots = [...new Set([...nmRoots, resolveSharedMirrorDir(profileDir)].filter(Boolean))];
186
+ const censuses = censusRoots.map(root => ({
187
+ root,
188
+ ...(options.symlinkCensus || countMirrorLinks(root)),
189
+ }));
74
190
 
75
- // 核心运行时包泄漏 = CRITICAL(直接导致 Symbol 分裂 + 工具调用全灭)
76
- if (coreLeaks.length > 0) {
77
- const details = coreLeaks.map(l => ` ${l.name}@${l.version}`).join('\n');
78
- const otherNote = otherLeaks.length > 0
79
- ? `\n另有 ${otherLeaks.length} 个非核心 @deepseek-ai/dsh-* 包在 profile 中:${otherLeaks.map(l => l.name).join(', ')}`
80
- : '';
191
+ const envParts = censuses
192
+ .filter(c => c.broken > 0 || c.npx > 0)
193
+ .map(c => {
194
+ const bits = [];
195
+ if (c.broken > 0) bits.push(`${c.broken} 条断链`);
196
+ if (c.npx > 0) bits.push(`${c.npx} 条指向 ~/.npm/_npx/ 残渣`);
197
+ return `${c.root}: ${bits.join('、')}(共 ${c.total} 条符号链接)`;
198
+ });
199
+ const mirrorRoot = censuses.find(c => isMirrorRoot(c.root) && (c.broken > 0 || c.npx > 0));
200
+ const envFixParts = [];
201
+ if (mirrorRoot) {
202
+ envFixParts.push(`停止 dsh 后 rm -rf ${mirrorRoot.root} 再重启(healProfilesModuleFallback 会按安装闭包重建镜像),切勿删除 profile 目录本身`);
203
+ }
204
+ if (censuses.some(c => !isMirrorRoot(c.root) && (c.broken > 0 || c.npx > 0))) {
205
+ envFixParts.push(`profile 自身的断链多为包被移除后遗留的 .bin/*,用包管理器重装该 profile 即可清理(pnpm install --force)`);
206
+ }
207
+ const envWarning = envParts.length > 0
208
+ ? `\n环境告警:符号链接代际已腐烂——${envParts.join(';')}。修复: ${envFixParts.join(';')}`
209
+ : '';
210
+ const brokenPkgNote = brokenPkgLinks.length > 0
211
+ ? `\n注意: 其中 ${brokenPkgLinks.length} 条 @deepseek-ai/dsh-* 链接本身是断链(已计入告警,未静默忽略):` +
212
+ brokenPkgLinks.slice(0, 8).map(b => `${b.name}[${b.source}]`).join(', ')
213
+ : '';
214
+
215
+ const coreReal = realDirs.filter(d => d.isCore);
216
+ const otherReal = realDirs.filter(d => !d.isCore);
217
+ const fmt = (list) => list.slice(0, 10).map(x => ` ${x.name}${x.version ? `@${x.version}` : ''}[${x.source}]`).join('\n');
218
+ const prefixNote = `\n安装前缀: ${installPrefix || '未解析到'};符号链接形态的 dsh 自有镜像属预期,不计入重复。` +
219
+ `\n已扫描: ${scanned.join('、')}`;
220
+
221
+ // 风险 1:核心包真实目录副本 → CRITICAL(#4640 的直接触发条件)
222
+ if (coreReal.length > 0) {
223
+ const others = [
224
+ otherReal.length ? `另有 ${otherReal.length} 个非核心真实目录副本:${otherReal.map(d => `${d.name}[${d.source}]`).join(', ')}` : '',
225
+ outside.length ? `另有 ${outside.length} 条符号链接指向安装前缀之外:${outside.slice(0, 8).map(o => o.name).join(', ')}` : '',
226
+ ].filter(Boolean).join('\n');
81
227
  return fail(id, Severity.CRITICAL,
82
- `检测到 ${coreLeaks.length} 个 CLI 核心运行时包泄漏到 profile(#4640:双实例 symbol 分裂 → 工具调用全灭):\n${details}${otherNote}\n修复: dsh plugin --profile <name> remove <泄漏包名>(或检查安装来源是否误将 CLI 依赖写入 profile)`,
83
- '核心包(dsh-tools/dsh-agent-loop 等)不应出现在 profile node_modules 中;它们由 CLI 自身依赖树提供',
228
+ `检测到 ${coreReal.length} 个 CLI 核心运行时包的**真实副本**(真实目录,非符号链接;#4640:双实例 symbol 分裂 → 工具调用全灭):\n${fmt(coreReal)}` +
229
+ prefixNote + (others ? `\n${others}` : '') + brokenPkgNote + envWarning,
230
+ 'profile 只承载 surfaces 与 plugins,core packages 由 CLI 依赖树提供;删除这些真实目录后重装插件(符号链接镜像应保留)',
231
+ ['#4640']
232
+ );
233
+ }
234
+
235
+ // 风险 2:非核心包真实目录副本 → HIGH
236
+ if (otherReal.length > 0) {
237
+ const outsideNote = outside.length ? `\n另有 ${outside.length} 条符号链接指向安装前缀之外` : '';
238
+ return fail(id, Severity.HIGH,
239
+ `检测到 ${otherReal.length} 个 @deepseek-ai/dsh-* 的**真实目录副本**(非核心包,暂未触发 symbol 分裂,但增加未来冲突风险):\n${fmt(otherReal)}` +
240
+ prefixNote + outsideNote + brokenPkgNote + envWarning,
241
+ '清理 profile 中不需要的真实副本;若为客户端包且被插件依赖,确认无版本冲突',
242
+ ['#4640']
243
+ );
244
+ }
245
+
246
+ // 风险 3:符号链接指向安装前缀之外 → HIGH(来源不可信,可 shadow CLI 自带副本)
247
+ if (outside.length > 0) {
248
+ const details = outside.slice(0, 10).map(o => ` ${o.name} → ${o.target}[${o.source}]`).join('\n');
249
+ const outsideFix = mirrorRoot
250
+ ? `停止 dsh 后 rm -rf ${mirrorRoot.root} 再重启,让 healProfilesModuleFallback 按安装闭包重建镜像`
251
+ : '删除这些链接后重启 dsh,让其按安装闭包重建';
252
+ return fail(id, Severity.HIGH,
253
+ `检测到 ${outside.length} 条 @deepseek-ai/dsh-* 符号链接指向安装前缀之外(来源不可信,可能 shadow 掉 CLI 自带副本):\n${details}` +
254
+ prefixNote + brokenPkgNote + envWarning,
255
+ `修复: ${outsideFix};若重建后仍指向 npx 缓存,说明安装布局本身陈旧`,
84
256
  ['#4640']
85
257
  );
86
258
  }
87
259
 
88
- // 只有非核心包泄漏 = HIGH(潜在风险但不一定立即崩溃)
89
- const details = otherLeaks.map(l => ` ${l.name}@${l.version}`).join('\n');
90
- return fail(id, Severity.HIGH,
91
- `检测到 ${otherLeaks.length} @deepseek-ai/dsh-* 包泄漏到 profile(非核心包,暂未触发 symbol 分裂,但增加未来冲突风险):\n${details}`,
92
- '清理 profile 中不需要的 @deepseek-ai/dsh-* 包;若为客户端包且被插件依赖,确认无版本冲突',
93
- ['#4640']
94
- );
260
+ // 安装前缀无法解析且确有链接 有理由的 skip,而不是被动 pass
261
+ if (unresolved > 0 && expected.length === 0) {
262
+ return skip(id, Severity.CRITICAL,
263
+ `${unresolved} @deepseek-ai/dsh-* 符号链接无法判定来源(未解析到 CLI 安装前缀),未执行重复实例检测——` +
264
+ `可设置 DSH_HOME npm_node_execpath 以完成判定` + brokenPkgNote + envWarning);
265
+ }
266
+
267
+ const expectedNote = expected.length > 0
268
+ ? `${expected.length} 条符号链接指向安装前缀内(dsh 自有镜像,属预期形态,非重复)`
269
+ : '无 @deepseek-ai/dsh-* 条目';
270
+ const unresolvedNote = unresolved > 0
271
+ ? `;${unresolved} 条链接因安装前缀未解析未能判定来源(其余已判定)`
272
+ : '';
273
+ const brokenNote = brokenPkgLinks.length > 0
274
+ ? `;${brokenPkgLinks.length} 条 @deepseek-ai/dsh-* 断链未被静默忽略`
275
+ : '';
276
+
277
+ return createResult(id, true, Severity.CRITICAL,
278
+ `${expectedNote}${unresolvedNote}${brokenNote}` + prefixNote + brokenPkgNote + envWarning);
95
279
  }
96
280
 
97
281
  export const sp9Check = {
@@ -99,7 +283,7 @@ export const sp9Check = {
99
283
  name: 'dual-instance-guard',
100
284
  severity: Severity.CRITICAL,
101
285
  phase: CheckPhase.POST_INSTALL,
102
- description: 'CLI 核心包泄漏检测——profile 中不应出现 @deepseek-ai/dsh-tools 等核心包(#4640 symbol 分裂)',
286
+ description: '真实重复实例检测——profile 中出现 @deepseek-ai/dsh-* 的真实目录副本(或指向前缀外的符号链接)才是风险;dsh 自有符号链接镜像属预期形态',
103
287
  src: 'builtin',
104
288
  runner: (profileDir) => run(profileDir),
105
289
  };
@@ -16,9 +16,12 @@ import { existsSync } from 'node:fs';
16
16
  import { Severity } from '../protocol/severity.mjs';
17
17
  import { CheckPhase } from '../protocol/phase.mjs';
18
18
  import { pass, fail, skip } from '../protocol/check.mjs';
19
- import { scanSessionLines } from '../session-reader.mjs';
19
+ import { scanSessionLines, extractEvent, isShellTool } from '../session-reader.mjs';
20
20
 
21
21
  /** 已知逃逸模式(#1769 及同类) */
22
+ /** 下载即执行:curl/wget 管道进解释器 —— 与逃逸同级的高信号规则(其余系统资源规则不算发现) */
23
+ const HIGH_SIGNAL_DOWNLOAD = { name: 'curl/wget to shell', regex: /(?:curl|wget)\s+[^\n|"']{1,160}\|\s*(?:bash|sh|zsh|node)\b/gi, severity: 'critical', ref: null };
24
+
22
25
  const ESCAPE_PATTERNS = [
23
26
  { name: 'mount remount', regex: /mount\s+.*-o\s+remount.*rw/gi, severity: 'critical', ref: '#1769' },
24
27
  { name: 'chroot escape', regex: /chroot\s+\//gi, severity: 'critical', ref: null },
@@ -50,24 +53,10 @@ const WORKSPACE_ESCAPE_PATTERNS = [
50
53
  */
51
54
  function extractToolCalls(line) {
52
55
  try {
53
- const event = JSON.parse(line);
54
- if (event.type !== 'tool/call') return [];
55
-
56
- const data = event.data || {};
57
- const name = data.name || data.tool || '';
58
- const args = data.args || data.input || {};
59
- const text = typeof args === 'string' ? args : JSON.stringify(args);
60
-
61
- return [{
62
- type: event.type,
63
- name,
64
- text,
65
- seq: event.seq,
66
- turn: event.turn,
67
- }];
68
- } catch {
69
- return [];
70
- }
56
+ const e = extractEvent(JSON.parse(line));
57
+ if (e.kind === 'other') return [];
58
+ return [{ type: e.type, name: e.name || '', text: e.argsText || e.resultText || '', seq: e.seq, turn: e.turn, callId: e.callId }];
59
+ } catch { return []; }
71
60
  }
72
61
 
73
62
  /**
@@ -99,10 +88,11 @@ export async function run(sessionFile) {
99
88
  const id = 'SR1';
100
89
 
101
90
  if (!sessionFile || !existsSync(sessionFile)) {
102
- return pass(id, Severity.CRITICAL, '无会话日志,跳过运行时安全检查');
91
+ return skip(id, Severity.CRITICAL, '无会话日志,跳过运行时安全检查');
103
92
  }
104
93
 
105
94
  const findings = [];
95
+ let contextualCount = 0;
106
96
  let lineCount = 0;
107
97
 
108
98
  try {
@@ -110,11 +100,20 @@ export async function run(sessionFile) {
110
100
  if (!line.trim()) return;
111
101
  const calls = extractToolCalls(line);
112
102
  for (const call of calls) {
113
- const allPatterns = [...ESCAPE_PATTERNS, ...SYSTEM_RESOURCE_PATTERNS, ...WORKSPACE_ESCAPE_PATTERNS];
114
- const textFindings = scanPatterns(call.text, allPatterns);
115
- for (const f of textFindings) {
103
+ // 只作用于命令执行类工具(#2026-09:文档工具里"引用"路径会被误判为逃逸行为)
104
+ if (!isShellTool(call.name, call.text)) continue;
105
+ // 只把"逃逸原语"计为发现(chroot/nsenter/unshare/remount、下载即执行、管道进 shell)。
106
+ // 其余规则(读 /etc、/proc、env dump、sudo、写 /tmp 或 home、提及 child_process)
107
+ // 在日常 agent 工作里普遍出现,静默计入 contextual 而**不抬严重级**(2026-09 实测:不区分会误报 361 处)。
108
+ const decisive = [...ESCAPE_PATTERNS, HIGH_SIGNAL_DOWNLOAD];
109
+ const contextual = [...SYSTEM_RESOURCE_PATTERNS, ...WORKSPACE_ESCAPE_PATTERNS];
110
+ const seenRules = new Set(); // 同一事件内同一规则只记一次(文本里重复提及不再放大计数)
111
+ for (const f of scanPatterns(call.text, decisive)) {
112
+ if (seenRules.has(f.type)) continue;
113
+ seenRules.add(f.type);
116
114
  findings.push({ ...f, line: lineNo, tool: call.name, seq: call.seq, turn: call.turn });
117
115
  }
116
+ for (const _ of scanPatterns(call.text, contextual)) contextualCount++;
118
117
  }
119
118
  });
120
119
  } catch (e) {
@@ -16,42 +16,57 @@ import { existsSync } from 'node:fs';
16
16
  import { Severity } from '../protocol/severity.mjs';
17
17
  import { CheckPhase } from '../protocol/phase.mjs';
18
18
  import { pass, fail, skip } from '../protocol/check.mjs';
19
- import { scanSessionLines } from '../session-reader.mjs';
19
+ import { scanSessionLines, extractEvent, isShellTool } from '../session-reader.mjs';
20
20
 
21
+ // 只保留**决定性的提权原语**。裸 `sudo`/`chmod`/`chown` 在 agent 会话里极常见
22
+ // (2026-09 实测:不改会一次报 42 处,绝大多数是普通命令或文档引用),故降为 contextualCount 不入发现。
21
23
  const PRIVILEGE_PATTERNS = [
22
- { name: 'sudo usage', regex: /sudo\s+/gi, severity: 'high' },
24
+ { name: 'sudo to root shell', regex: /sudo\s+(?:-i\b|-s\b|su\b)/gi, severity: 'critical' },
23
25
  { name: 'doas usage', regex: /\bdoas\s+/gi, severity: 'high' },
24
- { name: 'chmod', regex: /chmod\s+[^/]*\s+[0-7]*7[0-7][0-7]/gi, severity: 'medium' },
26
+ { name: 'setuid/setgid bit', regex: /chmod\s+(?:[ugo]*\+s|[2467][0-7]{3})/gi, severity: 'critical' },
27
+ { name: 'setuid/setgid api', regex: /\b(?:setuid|setgid|seteuid|setegid)\s*\(/gi, severity: 'high' },
28
+ { name: 'linux capability', regex: /\bsetcap\s+|\bcap_(?:sys_admin|setuid|setgid|sys_ptrace)\b/gi, severity: 'high' },
29
+ { name: 'chown to root', regex: /chown\s+(?:-[A-Za-z]+\s+)*root[:\s]/gi, severity: 'high' },
30
+ ];
31
+
32
+ /** 常见但不足以判定提权的操作:只计数,不产生发现 */
33
+ const CONTEXTUAL_PRIVILEGE_PATTERNS = [
34
+ { name: 'sudo usage', regex: /sudo\s+/gi, severity: 'high' },
35
+ { name: 'chmod', regex: /chmod\s+/gi, severity: 'medium' },
25
36
  { name: 'chown', regex: /chown\s+/gi, severity: 'medium' },
26
- { name: 'setuid', regex: /setuid|setgid/gi, severity: 'high' },
27
- { name: 'capability', regex: /cap_|CAP_/gi, severity: 'high' },
28
37
  ];
29
38
 
30
39
  function extractToolCalls(line) {
31
40
  try {
32
- const event = JSON.parse(line);
33
- if (event.type !== 'tool/call') return [];
34
- const data = event.data || {};
35
- return [{ name: data.name || data.tool || '', text: JSON.stringify(data.args || data.input || {}), seq: event.seq }];
41
+ const e = extractEvent(JSON.parse(line));
42
+ if (e.kind === 'other') return [];
43
+ return [{ type: e.type, name: e.name || '', text: e.argsText || e.resultText || '', seq: e.seq, turn: e.turn, callId: e.callId }];
36
44
  } catch { return []; }
37
45
  }
38
46
 
39
47
  export async function run(sessionFile) {
40
48
  const id = 'SR2';
41
- if (!sessionFile || !existsSync(sessionFile)) return pass(id, Severity.HIGH, '无会话文件,跳过权限提升检测');
49
+ if (!sessionFile || !existsSync(sessionFile)) return skip(id, Severity.HIGH, '无会话文件,跳过权限提升检测');
42
50
 
43
51
  const findings = [];
52
+ let contextualCount = 0;
44
53
  let lineCount = 0;
45
54
 
46
55
  try {
47
56
  lineCount = await scanSessionLines(sessionFile, (line, lineNo) => {
48
57
  if (!line.trim()) return;
49
58
  for (const call of extractToolCalls(line)) {
59
+ const seen = new Set();
50
60
  for (const pattern of PRIVILEGE_PATTERNS) {
51
61
  for (const match of call.text.matchAll(new RegExp(pattern.regex.source, 'gi'))) {
62
+ if (seen.has(pattern.name)) break;
63
+ seen.add(pattern.name);
52
64
  findings.push({ type: pattern.name, severity: pattern.severity, line: lineNo, tool: call.name, snippet: match[0].slice(0, 40) });
53
65
  }
54
66
  }
67
+ for (const pattern of CONTEXTUAL_PRIVILEGE_PATTERNS) {
68
+ if (new RegExp(pattern.regex.source, 'i').test(call.text)) { contextualCount++; break; }
69
+ }
55
70
  }
56
71
  });
57
72
  } catch (e) {
@@ -61,7 +76,7 @@ export async function run(sessionFile) {
61
76
  throw e;
62
77
  }
63
78
 
64
- if (findings.length === 0) return pass(id, Severity.HIGH, `扫描 ${lineCount} 行,未检测到权限提升行为`);
79
+ if (findings.length === 0) return pass(id, Severity.HIGH, `扫描 ${lineCount} 行,未检测到权限提升原语${contextualCount ? `(另有 ${contextualCount} 处常见 sudo/chmod/chown 操作,未计入)` : ''}`);
65
80
 
66
81
  const details = findings.slice(0, 10).map(f => `[${f.severity}] 行${f.line} ${f.tool} — ${f.type}: ${f.snippet}`).join('\n');
67
82
  const highCount = findings.filter(f => f.severity === 'high').length;
@@ -15,7 +15,7 @@ import { existsSync } from 'node:fs';
15
15
  import { Severity } from '../protocol/severity.mjs';
16
16
  import { CheckPhase } from '../protocol/phase.mjs';
17
17
  import { pass, fail, skip } from '../protocol/check.mjs';
18
- import { scanSessionLines } from '../session-reader.mjs';
18
+ import { scanSessionLines, extractEvent, isShellTool } from '../session-reader.mjs';
19
19
 
20
20
  /** 凭据转发模式 */
21
21
  const CREDENTIAL_FORWARD_PATTERNS = [
@@ -37,18 +37,10 @@ const NETWORK_EXFIL_PATTERNS = [
37
37
  */
38
38
  function extractToolCalls(line) {
39
39
  try {
40
- const event = JSON.parse(line);
41
- if (event.type !== 'tool/call' && event.type !== 'tool/result') return [];
42
-
43
- const data = event.data || {};
44
- const name = data.name || data.tool || '';
45
- const args = data.args || data.input || data.output || data.result || data.text || {};
46
- const text = typeof args === 'string' ? args : JSON.stringify(args);
47
-
48
- return [{ type: event.type, name, text, seq: event.seq, turn: event.turn }];
49
- } catch {
50
- return [];
51
- }
40
+ const e = extractEvent(JSON.parse(line));
41
+ if (e.kind === 'other') return [];
42
+ return [{ type: e.type, name: e.name || '', text: e.argsText || e.resultText || '', seq: e.seq, turn: e.turn, callId: e.callId }];
43
+ } catch { return []; }
52
44
  }
53
45
 
54
46
  function scanPatterns(text, patterns) {
@@ -70,7 +62,7 @@ export async function run(sessionFile) {
70
62
  const id = 'SR3';
71
63
 
72
64
  if (!sessionFile || !existsSync(sessionFile)) {
73
- return pass(id, Severity.HIGH, '无会话日志,跳过数据外泄检查');
65
+ return skip(id, Severity.HIGH, '无会话日志,跳过数据外泄检查');
74
66
  }
75
67
 
76
68
  const findings = [];
@@ -13,14 +13,13 @@ import { existsSync } from 'node:fs';
13
13
  import { Severity } from '../protocol/severity.mjs';
14
14
  import { CheckPhase } from '../protocol/phase.mjs';
15
15
  import { pass, fail, skip } from '../protocol/check.mjs';
16
- import { scanSessionLines } from '../session-reader.mjs';
16
+ import { scanSessionLines, extractEvent } from '../session-reader.mjs';
17
17
 
18
18
  function extractToolCalls(line) {
19
19
  try {
20
- const event = JSON.parse(line);
21
- if (event.type !== 'tool/call') return [];
22
- const data = event.data || {};
23
- return [{ name: data.name || data.tool || '', args: data.args || data.input || {}, seq: event.seq, turn: event.turn }];
20
+ const e = extractEvent(JSON.parse(line));
21
+ if (e.kind === 'other') return [];
22
+ return [{ name: e.name || '', args: e.argsText || '', seq: e.seq, turn: e.turn }];
24
23
  } catch { return []; }
25
24
  }
26
25
 
@@ -19,9 +19,12 @@ import { scanSessionLines } from '../session-reader.mjs';
19
19
 
20
20
  const PII_PATTERNS = [
21
21
  { name: 'email', regex: /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g, severity: 'medium' },
22
- { name: 'phone', regex: /(\+?86)?1[3-9]\d{9}/g, severity: 'medium' },
22
+ // 数字边界:否则 Unix 毫秒时间戳(1788769155230)的子串会被当成手机号(2026-09 实测误报)
23
+ { name: 'phone', regex: /(?<!\d)(?:\+?86)?1[3-9]\d{9}(?!\d)/g, severity: 'medium' },
23
24
  { name: 'ID card', regex: /[1-9]\d{5}(19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]/g, severity: 'high' },
24
- { name: 'IPv4', regex: /\b(?:[0-9]{1,3}\.){3}[0-9]{1,3}\b/g, severity: 'low' },
25
+ // IPv4 informational:会话文本里 IP 无处不在(文档举例、保留网段 198.18/203.0.113、版本号形态),
26
+ // 2026-09 实测全部命中都是这类。PII 的强信号是邮箱/手机/身份证/密钥,IP 不足以判定,仅计数提示。
27
+ { name: 'IPv4', regex: /\b(?!127\.|10\.|192\.168\.|0\.|169\.254\.|100\.(?:6[4-9]|[7-9]\d|1[01]\d)\.|198\.18\.|198\.19\.|203\.0\.113\.|198\.51\.100\.|192\.0\.2\.)(?:[0-9]{1,3}\.){3}[0-9]{1,3}\b/g, severity: 'low', informational: true },
25
28
  ];
26
29
 
27
30
  /** PII 掩码:保留类型可辨识度,不回显完整个人信息 */
@@ -49,6 +52,7 @@ export async function run(sessionFile) {
49
52
  if (!sessionFile || !existsSync(sessionFile)) return pass(id, Severity.MEDIUM, '无会话文件,跳过 PII 检测');
50
53
 
51
54
  const findings = [];
55
+ let informationalCount = 0;
52
56
  let lineCount = 0;
53
57
 
54
58
  try {
@@ -57,6 +61,7 @@ export async function run(sessionFile) {
57
61
  const text = extractTextFromLine(line);
58
62
  for (const pattern of PII_PATTERNS) {
59
63
  for (const match of text.matchAll(new RegExp(pattern.regex.source, 'g'))) {
64
+ if (pattern.informational) { informationalCount++; continue; }
60
65
  findings.push({ type: pattern.name, severity: pattern.severity, line: lineNo, snippet: maskPII(match[0]) });
61
66
  }
62
67
  }
@@ -68,7 +73,7 @@ export async function run(sessionFile) {
68
73
  throw e;
69
74
  }
70
75
 
71
- if (findings.length === 0) return pass(id, Severity.MEDIUM, `扫描 ${lineCount} 行,未检测到 PII 暴露`);
76
+ if (findings.length === 0) return pass(id, Severity.MEDIUM, `扫描 ${lineCount} 行,未检测到 PII 暴露${informationalCount ? `(另有 ${informationalCount} 个裸 IP,弱信号未计入)` : ''}`);
72
77
 
73
78
  const byType = {};
74
79
  for (const f of findings) { if (!byType[f.type]) byType[f.type] = 0; byType[f.type]++; }
@@ -14,27 +14,28 @@ import { existsSync } from 'node:fs';
14
14
  import { Severity } from '../protocol/severity.mjs';
15
15
  import { CheckPhase } from '../protocol/phase.mjs';
16
16
  import { pass, fail, skip } from '../protocol/check.mjs';
17
- import { scanSessionLines } from '../session-reader.mjs';
17
+ import { scanSessionLines, extractEvent } from '../session-reader.mjs';
18
18
 
19
+ // 注意:原 'large file content'(readFileSync/cat + 500 字符)规则已移除——编码会话里
20
+ // 任何含 readFileSync 的源码都会命中(2026-09 实测:9/9 命中全是源码本身),噪声无法收敛。
19
21
  const SENSITIVE_OUTPUT_PATTERNS = [
20
22
  { name: 'env dump', regex: /(?:process\.env|ENV|env)\s*[=:]\s*\{[^}]{50,}/gi, severity: 'medium' },
21
23
  { name: 'config dump', regex: /(?:config|settings|credentials)\s*[=:]\s*\{[^}]{100,}/gi, severity: 'medium' },
22
- { name: 'large file content', regex: /(?:readFileSync|cat\s+)\S+[\s\S]{500,}/gi, severity: 'low' },
23
24
  ];
24
25
 
25
26
  function extractToolOutputs(line) {
27
+ // 注意:本检查的循环直接对返回值调用 matchAll,故这里返回**字符串数组**
26
28
  try {
27
- const event = JSON.parse(line);
28
- if (event.type !== 'tool/result') return [];
29
- const data = event.data || {};
30
- const output = data.output || data.result || data.text || '';
31
- return [typeof output === 'string' ? output : JSON.stringify(output)];
29
+ const e = extractEvent(JSON.parse(line));
30
+ if (e.kind === 'other') return [];
31
+ const t = e.resultText || e.argsText || '';
32
+ return t ? [t] : [];
32
33
  } catch { return []; }
33
34
  }
34
35
 
35
36
  export async function run(sessionFile) {
36
37
  const id = 'SS3';
37
- if (!sessionFile || !existsSync(sessionFile)) return pass(id, Severity.LOW, '无会话文件,跳过敏感输出检测');
38
+ if (!sessionFile || !existsSync(sessionFile)) return skip(id, Severity.LOW, '无会话文件,跳过敏感输出检测');
38
39
 
39
40
  const findings = [];
40
41
  let lineCount = 0;