@moonquake2004/dsh-security 0.1.0 → 0.1.2

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.
@@ -15,6 +15,7 @@ import { join } from 'node:path';
15
15
  import { Severity } from '../protocol/severity.mjs';
16
16
  import { CheckPhase } from '../protocol/phase.mjs';
17
17
  import { pass, fail } from '../protocol/check.mjs';
18
+ import { maxSeverity } from '../protocol/severity.mjs';
18
19
 
19
20
  /** 已知恶意 entry 模式 */
20
21
  const MALICIOUS_PATTERNS = [
@@ -68,7 +69,9 @@ export async function run(profileDir) {
68
69
  if (allFindings.length === 0) return pass(id, Severity.HIGH, `扫描 ${patchFiles.length} 个 patch 文件,未检测到恶意 entry 模式`);
69
70
 
70
71
  const details = allFindings.slice(0, 10).map(f => `[${f.severity}] ${f.type}: ${f.snippet}`).join('\n');
71
- return fail(id, Severity.HIGH, `检测到 ${allFindings.length} 个可疑 entry 模式:\n${details}`, '检查相关插件的 cordis.patch.yml 内容');
72
+ // 复审修复:按实际命中的最高严重度定级,medium 级命中不再一律拔高成 HIGH
73
+ const overallSeverity = maxSeverity(allFindings.map(f => f.severity));
74
+ return fail(id, overallSeverity, `检测到 ${allFindings.length} 个可疑 entry 模式:\n${details}`, '检查相关插件的 cordis.patch.yml 内容', ['#2066']);
72
75
  }
73
76
 
74
77
  export const sp4Check = { id: 'SP4', name: 'entry-poison', severity: Severity.HIGH, phase: CheckPhase.POST_INSTALL, description: '恶意 entry 注入检测', src: 'builtin', runner: (d) => run(d) };
@@ -37,21 +37,39 @@ export async function run(profileDir) {
37
37
  const issues = [];
38
38
  for (const entry of readdirSync(nmDir, { withFileTypes: true })) {
39
39
  if (entry.name.startsWith('.') || entry.name === '.bin') continue;
40
- const pkgPath = join(nmDir, entry.name, 'package.json');
41
- if (!existsSync(pkgPath)) continue;
40
+ // scoped 包(@scope/pkg):复审修复——此前把 @scope 当包名拼路径,scoped 插件全部漏扫
41
+ if (entry.isDirectory() && entry.name.startsWith('@')) {
42
+ const scopeDir = join(nmDir, entry.name);
43
+ let pkgs = [];
44
+ try { pkgs = readdirSync(scopeDir, { withFileTypes: true }); } catch { continue; }
45
+ for (const pkg of pkgs) {
46
+ if (!pkg.isDirectory()) continue;
47
+ issues.push(...inspectPackage(join(scopeDir, pkg.name), join(entry.name, pkg.name)));
48
+ }
49
+ continue;
50
+ }
51
+ if (!entry.isDirectory()) continue;
52
+ issues.push(...inspectPackage(join(nmDir, entry.name), entry.name));
53
+ }
54
+
55
+ function inspectPackage(pkgDir, displayName) {
56
+ const found = [];
57
+ const pkgPath = join(pkgDir, 'package.json');
58
+ if (!existsSync(pkgPath)) return found;
42
59
  try {
43
60
  const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
44
- if (!pkg.dsh?.bundle) continue;
45
- const patchPath = join(nmDir, entry.name, 'cordis.patch.yml');
61
+ if (!pkg.dsh?.bundle) return found;
62
+ const patchPath = join(pkgDir, 'cordis.patch.yml');
46
63
  if (existsSync(patchPath)) {
47
64
  const content = readFileSync(patchPath, 'utf8');
48
- issues.push(...scanForUndeclaredCapabilities(content, pkg.name));
65
+ found.push(...scanForUndeclaredCapabilities(content, pkg.name || displayName));
49
66
  }
50
67
  } catch { /* skip */ }
68
+ return found;
51
69
  }
52
70
 
53
71
  if (issues.length === 0) return pass(id, Severity.MEDIUM, '插件权限声明一致');
54
- const details = issues.slice(0, 10).map(i => `[${i.severity}] ${i.detail}`).join('\n');
72
+ const details = issues.slice(0, 10).map(i => `[${i.severity}] ${i.type}: ${i.detail}`).join('\n');
55
73
  return fail(id, Severity.MEDIUM, `检测到 ${issues.length} 个权限声明问题:\n${details}`, '为插件显式声明所需的权限范围');
56
74
  }
57
75
 
@@ -3,8 +3,8 @@
3
3
  *
4
4
  * 通过 OSV API 查询已知漏洞(CVE/GHSA):
5
5
  * - 查询 npm 包的已知漏洞
6
- * - 过滤 critical/high 级别
7
- * - 输出受影响的包和修复版本
6
+ * - OSV 严重级别映射整体结果严重度,detail 标注 critical/high 数量
7
+ * - 输出受影响的包与漏洞编号(references)
8
8
  *
9
9
  * Severity: HIGH
10
10
  * Phase: POST_INSTALL
@@ -14,8 +14,12 @@ import { readFileSync, existsSync } from 'node:fs';
14
14
  import { join } from 'node:path';
15
15
  import { Severity, maxSeverity } from '../protocol/severity.mjs';
16
16
  import { CheckPhase } from '../protocol/phase.mjs';
17
- import { pass, fail } from '../protocol/check.mjs';
17
+ import { pass, fail, skip } from '../protocol/check.mjs';
18
18
 
19
+ /**
20
+ * 查询 OSV API。
21
+ * @returns {Promise<Array|null>} 成功返回漏洞数组(可能为空);网络失败返回 null
22
+ */
19
23
  async function queryOSV(packageName, version) {
20
24
  try {
21
25
  const response = await fetch('https://api.osv.dev/v1/query', {
@@ -24,15 +28,22 @@ async function queryOSV(packageName, version) {
24
28
  body: JSON.stringify({ package: { name: packageName, ecosystem: 'npm' }, version }),
25
29
  signal: AbortSignal.timeout(10000),
26
30
  });
27
- if (!response.ok) return [];
31
+ if (!response.ok) return null;
28
32
  const data = await response.json();
29
33
  return (data.vulns || []).map(v => ({
30
34
  id: v.id,
31
35
  summary: v.summary || v.details?.slice(0, 100) || 'No summary',
32
36
  severity: v.database_specific?.severity || v.severity?.[0]?.score || 'unknown',
33
- fixed: v.fix_versions?.[0] || 'unknown',
34
37
  }));
35
- } catch { return []; }
38
+ } catch { return null; }
39
+ }
40
+
41
+ function osvSeverityToLevel(sev) {
42
+ const s = String(sev).toUpperCase();
43
+ if (s.includes('CRITICAL')) return Severity.CRITICAL;
44
+ if (s.includes('HIGH')) return Severity.HIGH;
45
+ if (s.includes('MODERATE') || s.includes('MEDIUM')) return Severity.MEDIUM;
46
+ return Severity.LOW;
36
47
  }
37
48
 
38
49
  export async function run(profileDir) {
@@ -48,19 +59,37 @@ export async function run(profileDir) {
48
59
  if (dshPackages.length === 0) return pass(id, Severity.HIGH, '无 dsh 相关依赖,跳过漏洞匹配');
49
60
 
50
61
  const allVulns = [];
62
+ const refs = new Set();
63
+ let okQueries = 0;
64
+ let failedQueries = 0;
51
65
  for (const [name, version] of dshPackages.slice(0, 10)) {
66
+ // 只去掉首个范围前缀字符;workspace:/catalog:/file: 等协议版本无法映射,原样传给 OSV(查询无结果)
52
67
  const cleanVersion = version.replace(/^[~^>=<]/, '');
53
68
  const vulns = await queryOSV(name, cleanVersion);
69
+ if (vulns === null) { failedQueries++; continue; }
70
+ okQueries++;
54
71
  allVulns.push(...vulns.map(v => ({ package: name, ...v })));
72
+ for (const v of vulns) if (v.id) refs.add(v.id);
73
+ }
74
+
75
+ // 全部查询失败 → skip(离线时不应谎报"未发现已知漏洞")
76
+ if (okQueries === 0 && failedQueries > 0) {
77
+ return skip(id, Severity.HIGH, `OSV API 不可达(${failedQueries} 个包全部查询失败),跳过已知漏洞匹配`);
78
+ }
79
+
80
+ if (allVulns.length === 0) {
81
+ const note = failedQueries > 0 ? `(${failedQueries} 个包查询失败未覆盖)` : '';
82
+ return pass(id, Severity.HIGH, `成功查询 ${okQueries}/${dshPackages.length} 个包,未发现已知漏洞${note}`);
55
83
  }
56
84
 
57
- if (allVulns.length === 0) return pass(id, Severity.HIGH, `查询 ${dshPackages.length} 个包,未发现已知漏洞`);
85
+ const details = allVulns.slice(0, 10).map(v => `${v.package} — ${v.id}: ${v.summary}(severity: ${v.severity})`).join('\n');
86
+ const overallSeverity = maxSeverity(allVulns.map(v => osvSeverityToLevel(v.severity)));
87
+ const criticalHigh = allVulns.filter(v => ['critical', 'high'].includes(osvSeverityToLevel(v.severity))).length;
58
88
 
59
- const details = allVulns.slice(0, 10).map(v => `${v.package} — ${v.id}: ${v.summary}(fixed: ${v.fixed})`).join('\n');
60
- const fixable = allVulns.filter(v => v.fixed !== 'unknown').length;
61
- return fail(id, Severity.HIGH,
62
- `检测到 ${allVulns.length} 个已知漏洞(${fixable} 个可修复):\n${details}`,
63
- fixable > 0 ? '升级受影响的包到修复版本' : '关注上游修复进展'
89
+ return fail(id, overallSeverity,
90
+ `检测到 ${allVulns.length} 个已知漏洞(critical/high ${criticalHigh} 个):\n${details}`,
91
+ '按 OSV 建议升级受影响的包到修复版本',
92
+ [...refs].slice(0, 5)
64
93
  );
65
94
  }
66
95
 
@@ -0,0 +1,150 @@
1
+ /**
2
+ * SP7: Client Bundle Syntax — 插件 client 产物语法预检
3
+ *
4
+ * 出处:deepseek-ai/deepseek-harness discussion #2752 补充案例——插件 client.js
5
+ * 注释未闭合(语法级错误)→ 浏览器端 ReferenceError → Web UI 整页白屏,
6
+ * 服务端 HTTP 200 且日志零感知。P13 只提取 ctx.provide() 服务名,不做语法解析,
7
+ * 这类错误此前完全逃逸离线检查。
8
+ *
9
+ * 做法:对每个已装 DSH 插件包(package.json 含 dsh 字段)的 client 入口产物
10
+ * (<pkg>/client/*.js|mjs、<pkg>/lib/client.js、<pkg>/client.js)执行
11
+ * `node --check`(Node ≥22 自动探测 ESM/CJS,无需区分模块格式)。
12
+ * 解析失败 = boot 前即可断定的必白屏项 → HIGH。
13
+ *
14
+ * Severity: HIGH
15
+ * Phase: POST_INSTALL
16
+ */
17
+
18
+ import { readFileSync, readdirSync, existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
19
+ import { join, relative } from 'node:path';
20
+ import { tmpdir } from 'node:os';
21
+ import { execFileSync } from 'node:child_process';
22
+ import { Severity } from '../protocol/severity.mjs';
23
+ import { CheckPhase } from '../protocol/phase.mjs';
24
+ import { pass, fail } from '../protocol/check.mjs';
25
+
26
+ const CLIENT_EXTENSIONS = ['.js', '.mjs', '.cjs'];
27
+ const MAX_FILES_PER_PKG = 10;
28
+ const MAX_PKGS = 60; // 上限只约束"识别为 DSH 插件"的包(普通依赖不占额)
29
+ const MAX_TOTAL_FILES = 200; // 全局文件数保险,防病态安装
30
+
31
+ /** 收集一个插件包内的 client 候选产物 */
32
+ function collectClientFiles(pkgDir) {
33
+ const files = [];
34
+ const pushIfClient = (p) => {
35
+ if (files.length >= MAX_FILES_PER_PKG) return;
36
+ if (existsSync(p) && CLIENT_EXTENSIONS.some(ext => p.endsWith(ext))) files.push(p);
37
+ };
38
+ // 形态 A:<pkg>/client/ 目录(真实布局:dsh-doctor、dshmarket 等)
39
+ const clientDir = join(pkgDir, 'client');
40
+ if (existsSync(clientDir)) {
41
+ try {
42
+ for (const e of readdirSync(clientDir, { withFileTypes: true })) {
43
+ if (e.isFile()) pushIfClient(join(clientDir, e.name));
44
+ }
45
+ } catch { /* unreadable dir */ }
46
+ }
47
+ // 形态 B:lib/client.js(真实布局:dsh-better-sidebar、dsh-persist、@xmanrui/dsh-im 等)
48
+ pushIfClient(join(pkgDir, 'lib', 'client.js'));
49
+ // 形态 C:根级 client.js
50
+ pushIfClient(join(pkgDir, 'client.js'));
51
+ return files;
52
+ }
53
+
54
+ /**
55
+ * 对单个文件做语法校验。
56
+ * @returns {{ok: true} | {ok: false, message: string}}
57
+ */
58
+ function syntaxCheck(file) {
59
+ try {
60
+ execFileSync(process.execPath, ['--check', file], { timeout: 10000, stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf8' });
61
+ return { ok: true };
62
+ } catch (e) {
63
+ const errText = String(e.stderr || e.message || '');
64
+ // 提取最有信息量的一行:SyntaxError/ReferenceError/... 及其位置
65
+ const diagLine = errText.split('\n').find(l => /^(SyntaxError|ReferenceError|TypeError|RangeError|Error)\b/.test(l.trim()));
66
+ const posLine = errText.split('\n').find(l => l.startsWith(file));
67
+ const message = [posLine ? relativeProcess(posLine) : null, diagLine ? diagLine.trim().slice(0, 160) : null]
68
+ .filter(Boolean).join(' — ') || `node --check 失败(exit ${e.status ?? '?'}, ${errText.slice(0, 80)})`;
69
+ return { ok: false, message };
70
+ }
71
+ }
72
+
73
+ function relativeProcess(posLine) {
74
+ return posLine.replace(/^.*\/node_modules\//, '').slice(0, 120);
75
+ }
76
+
77
+ /** 纯文本版语法校验(供测试与无子进程环境复用):写入临时 .mjs 不需要——直接用文件路径 */
78
+ export async function run(profileDir) {
79
+ const id = 'SP7';
80
+ const nmDir = join(profileDir, 'node_modules');
81
+ if (!existsSync(nmDir)) return pass(id, Severity.HIGH, '无 node_modules,跳过 client 产物语法预检');
82
+
83
+ // 枚举已装 DSH 插件包(含 scoped;以 package.json 的 dsh 字段为门控)
84
+ const pkgs = [];
85
+ let entries = [];
86
+ try { entries = readdirSync(nmDir, { withFileTypes: true }); } catch { entries = []; }
87
+ for (const entry of entries) {
88
+ if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name === '.bin') continue;
89
+ if (entry.name.startsWith('@')) {
90
+ let subs = [];
91
+ try { subs = readdirSync(join(nmDir, entry.name), { withFileTypes: true }); } catch { continue; }
92
+ for (const sub of subs) {
93
+ if (sub.isDirectory()) pkgs.push(join(nmDir, entry.name, sub.name));
94
+ }
95
+ } else {
96
+ pkgs.push(join(nmDir, entry.name));
97
+ }
98
+ }
99
+
100
+ const findings = [];
101
+ let scannedFiles = 0;
102
+ let scannedPkgs = 0;
103
+
104
+ for (const pkgDir of pkgs) {
105
+ const pkgJsonPath = join(pkgDir, 'package.json');
106
+ if (!existsSync(pkgJsonPath)) continue;
107
+ try {
108
+ const pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf8'));
109
+ if (!pkg.dsh) continue; // 只查 DSH 插件包,避免误扫 hono/undici 等普通依赖
110
+ } catch { continue; }
111
+ if (scannedPkgs >= MAX_PKGS) break;
112
+ scannedPkgs++;
113
+
114
+ for (const file of collectClientFiles(pkgDir)) {
115
+ if (scannedFiles >= MAX_TOTAL_FILES) break;
116
+ scannedFiles++;
117
+ const result = syntaxCheck(file);
118
+ if (!result.ok) {
119
+ findings.push({
120
+ file: relative(profileDir, file),
121
+ message: result.message,
122
+ });
123
+ }
124
+ }
125
+ }
126
+
127
+ if (scannedPkgs === 0) return pass(id, Severity.HIGH, '未发现 DSH 插件包,跳过 client 产物语法预检');
128
+ if (scannedFiles === 0) return pass(id, Severity.HIGH, `扫描 ${scannedPkgs} 个插件包,未发现 client 产物,跳过语法预检`);
129
+
130
+ if (findings.length === 0) {
131
+ return pass(id, Severity.HIGH, `语法预检通过:${scannedPkgs} 个插件包 / ${scannedFiles} 个 client 产物均可正常解析`);
132
+ }
133
+
134
+ const details = findings.slice(0, 10).map(f => `${f.file}\n ${f.message}`).join('\n');
135
+ return fail(id, Severity.HIGH,
136
+ `检测到 ${findings.length} 个 client 产物语法错误(boot 后将整页白屏):\n${details}`,
137
+ '修复对应插件的 client 代码语法错误,或暂时移除该插件;此类错误在浏览器端表现为 Failed to load plugins 白屏且服务端日志无感知',
138
+ ['#2752']
139
+ );
140
+ }
141
+
142
+ export const sp7Check = {
143
+ id: 'SP7',
144
+ name: 'client-syntax',
145
+ severity: Severity.HIGH,
146
+ phase: CheckPhase.POST_INSTALL,
147
+ description: '插件 client 产物语法预检(node --check,boot 前拦截白屏源)',
148
+ src: 'builtin',
149
+ runner: (profileDir) => run(profileDir),
150
+ };
@@ -1,20 +1,22 @@
1
1
  /**
2
2
  * SR1: Sandbox Violation — 沙箱逃逸检测
3
3
  *
4
- * 分析会话日志中的工具调用,检测潜在的沙箱逃逸行为:
5
- * - 写操作超出 workspace 范围
6
- * - 访问系统级资源(/etc, /proc, 环境变量)
4
+ * 分析会话日志中的工具调用(tool/call),检测潜在的沙箱逃逸行为:
5
+ * - 访问系统级资源(/etc, /proc)
7
6
  * - 已知逃逸模式匹配(#1769 mount remount 等)
7
+ * - 管道执行远程脚本
8
+ *
9
+ * 支持明文与 zstd 压缩会话日志(session-reader)。
8
10
  *
9
11
  * Severity: CRITICAL
10
12
  * Phase: RUNTIME
11
13
  */
12
14
 
13
- import { readFileSync, existsSync, createReadStream } from 'node:fs';
14
- import { createInterface } from 'node:readline';
15
+ import { existsSync } from 'node:fs';
15
16
  import { Severity } from '../protocol/severity.mjs';
16
17
  import { CheckPhase } from '../protocol/phase.mjs';
17
- import { pass, fail } from '../protocol/check.mjs';
18
+ import { pass, fail, skip } from '../protocol/check.mjs';
19
+ import { scanSessionLines } from '../session-reader.mjs';
18
20
 
19
21
  /** 已知逃逸模式(#1769 及同类) */
20
22
  const ESCAPE_PATTERNS = [
@@ -28,7 +30,8 @@ const ESCAPE_PATTERNS = [
28
30
  const SYSTEM_RESOURCE_PATTERNS = [
29
31
  { name: '/etc access', regex: /\/etc\/(passwd|shadow|sudoers|hosts)/gi, severity: 'high' },
30
32
  { name: '/proc access', regex: /\/proc\/(self|1|environ|cmdline)/gi, severity: 'high' },
31
- { name: 'env dump', regex: /env\b|printenv|set\b.*\|/gi, severity: 'medium' },
33
+ // 复审修复:旧正则 /env\b|set\b.*\|/ 几乎命中一切文本;改为命令位置的 env/printenv 转储
34
+ { name: 'env dump', regex: /(?:^|["'|;&]\s*)(?:printenv|env)\s*(?:[|;"']|$)/gm, severity: 'medium' },
32
35
  { name: 'sudo usage', regex: /sudo\s+/gi, severity: 'high' },
33
36
  { name: 'curl/wget to unknown', regex: /curl\s+.*\|\s*(bash|sh|node)|wget\s+.*\|\s*(bash|sh|node)/gi, severity: 'critical' },
34
37
  ];
@@ -38,31 +41,30 @@ const WORKSPACE_ESCAPE_PATTERNS = [
38
41
  { name: 'write to /tmp', regex: /writeFileSync|writeFile|fs\.write|echo\s+.*>\s*\/tmp/gi, severity: 'low' },
39
42
  { name: 'write to home', regex: /writeFileSync|writeFile.*\/Users\/|\/home\//gi, severity: 'low' },
40
43
  { name: 'pipe to shell', regex: /\|\s*(bash|sh|zsh)\b/gi, severity: 'high' },
41
- { name: 'exec subprocess', regex: /child_process|execSync|spawnSync|exec\b/gi, severity: 'medium' },
44
+ // 复审修复:去掉过宽的 exec\b(命中 "execute" 等普通词),保留具体进程 API
45
+ { name: 'exec subprocess', regex: /child_process|execSync|spawnSync/gi, severity: 'medium' },
42
46
  ];
43
47
 
44
48
  /**
45
- * 从一行 JSONL 中提取工具调用信息
49
+ * 从一行 JSONL 中提取工具调用信息(只看 tool/call:result 是数据不是行为)
46
50
  */
47
51
  function extractToolCalls(line) {
48
52
  try {
49
53
  const event = JSON.parse(line);
50
- if (event.type !== 'tool/call' && event.type !== 'tool/result') return [];
54
+ if (event.type !== 'tool/call') return [];
51
55
 
52
- const calls = [];
53
56
  const data = event.data || {};
54
57
  const name = data.name || data.tool || '';
55
58
  const args = data.args || data.input || {};
56
59
  const text = typeof args === 'string' ? args : JSON.stringify(args);
57
60
 
58
- calls.push({
61
+ return [{
59
62
  type: event.type,
60
63
  name,
61
64
  text,
62
65
  seq: event.seq,
63
66
  turn: event.turn,
64
- });
65
- return calls;
67
+ }];
66
68
  } catch {
67
69
  return [];
68
70
  }
@@ -74,7 +76,8 @@ function extractToolCalls(line) {
74
76
  function scanPatterns(text, patterns) {
75
77
  const findings = [];
76
78
  for (const pattern of patterns) {
77
- const matches = text.matchAll(new RegExp(pattern.regex.source, 'gi'));
79
+ const flags = [...new Set((pattern.regex.flags + 'g').split(''))].join('');
80
+ const matches = text.matchAll(new RegExp(pattern.regex.source, flags));
78
81
  for (const match of matches) {
79
82
  findings.push({
80
83
  type: pattern.name,
@@ -89,7 +92,7 @@ function scanPatterns(text, patterns) {
89
92
 
90
93
  /**
91
94
  * SR1 检查:分析会话日志中的沙箱逃逸行为
92
- * @param {string} sessionFile - 会话日志文件路径
95
+ * @param {string} sessionFile - 会话日志文件路径(.jsonl 或 .jsonl.zstd)
93
96
  * @returns {Promise<import('../protocol/check.mjs').SecurityCheckResult>}
94
97
  */
95
98
  export async function run(sessionFile) {
@@ -99,35 +102,26 @@ export async function run(sessionFile) {
99
102
  return pass(id, Severity.CRITICAL, '无会话日志,跳过运行时安全检查');
100
103
  }
101
104
 
102
- if (sessionFile.endsWith('.zstd')) {
103
- return pass(id, Severity.CRITICAL, 'zstd 压缩的会话文件需先解压再检查');
104
- }
105
-
106
105
  const findings = [];
107
106
  let lineCount = 0;
108
107
 
109
- const fileStream = createReadStream(sessionFile, { encoding: 'utf8' });
110
- const rl = createInterface({ input: fileStream, crlfDelay: Infinity });
111
-
112
- for await (const line of rl) {
113
- lineCount++;
114
- if (!line.trim()) continue;
115
-
116
- const calls = extractToolCalls(line);
117
- for (const call of calls) {
118
- // 扫描工具参数中的危险模式
119
- const allPatterns = [...ESCAPE_PATTERNS, ...SYSTEM_RESOURCE_PATTERNS, ...WORKSPACE_ESCAPE_PATTERNS];
120
- const textFindings = scanPatterns(call.text, allPatterns);
121
- for (const f of textFindings) {
122
- findings.push({
123
- ...f,
124
- line: lineCount,
125
- tool: call.name,
126
- seq: call.seq,
127
- turn: call.turn,
128
- });
108
+ try {
109
+ lineCount = await scanSessionLines(sessionFile, (line, lineNo) => {
110
+ if (!line.trim()) return;
111
+ const calls = extractToolCalls(line);
112
+ 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) {
116
+ findings.push({ ...f, line: lineNo, tool: call.name, seq: call.seq, turn: call.turn });
117
+ }
129
118
  }
119
+ });
120
+ } catch (e) {
121
+ if (e.code === 'ZSTD_UNAVAILABLE') {
122
+ return skip(id, Severity.CRITICAL, 'zstd 命令不可用,无法解压压缩会话日志,跳过沙箱逃逸检测');
130
123
  }
124
+ throw e;
131
125
  }
132
126
 
133
127
  if (findings.length === 0) {
@@ -171,7 +165,7 @@ export const sr1Check = {
171
165
  name: 'sandbox-violation',
172
166
  severity: Severity.CRITICAL,
173
167
  phase: CheckPhase.RUNTIME,
174
- description: '沙箱逃逸行为检测(会话日志分析)',
168
+ description: '沙箱逃逸行为检测(会话日志分析,支持 zstd)',
175
169
  src: 'builtin',
176
170
  runner: (sessionFile) => run(sessionFile),
177
171
  };
@@ -1,23 +1,26 @@
1
1
  /**
2
2
  * SR2: Privilege Escalation — 权限提升检测
3
3
  *
4
- * 分析会话日志中的权限提升行为:
4
+ * 分析会话日志中的工具调用(tool/call)中的权限提升行为:
5
5
  * - sudo/doas 使用
6
6
  * - 文件权限变更(chmod/chown)
7
- * - 特权端口绑定
7
+ * - setuid/setgid、Linux capability 操作
8
+ *
9
+ * 支持明文与 zstd 压缩会话日志(session-reader)。
8
10
  *
9
11
  * Severity: HIGH
10
12
  * Phase: RUNTIME
11
13
  */
12
14
 
13
- import { existsSync, createReadStream } from 'node:fs';
14
- import { createInterface } from 'node:readline';
15
+ import { existsSync } from 'node:fs';
15
16
  import { Severity } from '../protocol/severity.mjs';
16
17
  import { CheckPhase } from '../protocol/phase.mjs';
17
- import { pass, fail } from '../protocol/check.mjs';
18
+ import { pass, fail, skip } from '../protocol/check.mjs';
19
+ import { scanSessionLines } from '../session-reader.mjs';
18
20
 
19
21
  const PRIVILEGE_PATTERNS = [
20
22
  { name: 'sudo usage', regex: /sudo\s+/gi, severity: 'high' },
23
+ { name: 'doas usage', regex: /\bdoas\s+/gi, severity: 'high' },
21
24
  { name: 'chmod', regex: /chmod\s+[^/]*\s+[0-7]*7[0-7][0-7]/gi, severity: 'medium' },
22
25
  { name: 'chown', regex: /chown\s+/gi, severity: 'medium' },
23
26
  { name: 'setuid', regex: /setuid|setgid/gi, severity: 'high' },
@@ -36,22 +39,26 @@ function extractToolCalls(line) {
36
39
  export async function run(sessionFile) {
37
40
  const id = 'SR2';
38
41
  if (!sessionFile || !existsSync(sessionFile)) return pass(id, Severity.HIGH, '无会话文件,跳过权限提升检测');
39
- if (sessionFile.endsWith('.zstd')) return pass(id, Severity.HIGH, 'zstd 文件需先解压');
40
42
 
41
43
  const findings = [];
42
44
  let lineCount = 0;
43
- const rl = createInterface({ input: createReadStream(sessionFile, { encoding: 'utf8' }), crlfDelay: Infinity });
44
45
 
45
- for await (const line of rl) {
46
- lineCount++;
47
- if (!line.trim()) continue;
48
- for (const call of extractToolCalls(line)) {
49
- for (const pattern of PRIVILEGE_PATTERNS) {
50
- for (const match of call.text.matchAll(new RegExp(pattern.regex.source, 'gi'))) {
51
- findings.push({ type: pattern.name, severity: pattern.severity, line: lineCount, tool: call.name, snippet: match[0].slice(0, 40) });
46
+ try {
47
+ lineCount = await scanSessionLines(sessionFile, (line, lineNo) => {
48
+ if (!line.trim()) return;
49
+ for (const call of extractToolCalls(line)) {
50
+ for (const pattern of PRIVILEGE_PATTERNS) {
51
+ for (const match of call.text.matchAll(new RegExp(pattern.regex.source, 'gi'))) {
52
+ findings.push({ type: pattern.name, severity: pattern.severity, line: lineNo, tool: call.name, snippet: match[0].slice(0, 40) });
53
+ }
52
54
  }
53
55
  }
56
+ });
57
+ } catch (e) {
58
+ if (e.code === 'ZSTD_UNAVAILABLE') {
59
+ return skip(id, Severity.HIGH, 'zstd 命令不可用,无法解压压缩会话日志,跳过权限提升检测');
54
60
  }
61
+ throw e;
55
62
  }
56
63
 
57
64
  if (findings.length === 0) return pass(id, Severity.HIGH, `扫描 ${lineCount} 行,未检测到权限提升行为`);
@@ -64,4 +71,4 @@ export async function run(sessionFile) {
64
71
  );
65
72
  }
66
73
 
67
- export const sr2Check = { id: 'SR2', name: 'privilege-escalation', severity: Severity.HIGH, phase: CheckPhase.RUNTIME, description: '权限提升行为检测', src: 'builtin', runner: (f) => run(f) };
74
+ export const sr2Check = { id: 'SR2', name: 'privilege-escalation', severity: Severity.HIGH, phase: CheckPhase.RUNTIME, description: '权限提升行为检测(支持 zstd)', src: 'builtin', runner: (f) => run(f) };