@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.
@@ -1,20 +1,21 @@
1
1
  /**
2
2
  * SR3: Data Exfiltration — 数据外泄检测
3
3
  *
4
- * 分析会话日志中的工具调用,检测潜在的数据外泄行为:
5
- * - 大量文件读取后接网络请求
6
- * - 凭据转发(将密钥传递给外部工具)
7
- * - 异常的数据流模式
4
+ * 分析会话日志中的工具调用与结果,检测潜在的数据外泄行为:
5
+ * - 凭据转发(API key / token / Bearer 出现在命令或返回内容中)
6
+ * - 网络外发模式(curl POST、fetch 等)
7
+ *
8
+ * 支持明文与 zstd 压缩会话日志(session-reader)。
8
9
  *
9
10
  * Severity: HIGH
10
11
  * Phase: RUNTIME
11
12
  */
12
13
 
13
- import { existsSync, createReadStream } from 'node:fs';
14
- import { createInterface } from 'node:readline';
14
+ import { existsSync } from 'node:fs';
15
15
  import { Severity } 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
+ import { scanSessionLines } from '../session-reader.mjs';
18
19
 
19
20
  /** 凭据转发模式 */
20
21
  const CREDENTIAL_FORWARD_PATTERNS = [
@@ -31,36 +32,25 @@ const NETWORK_EXFIL_PATTERNS = [
31
32
  { name: 'HTTP request with data', regex: /http\.request|https\.request|axios\.post/gi, severity: 'low' },
32
33
  ];
33
34
 
34
- /** 大量数据读取模式 */
35
- const BULK_READ_PATTERNS = [
36
- { name: 'read entire file', regex: /readFileSync|readFile\b|cat\s+[^|]+$/gm, severity: 'low' },
37
- { name: 'directory listing', regex: /readdirSync|readdir\b|ls\s+-[la]*/gi, severity: 'low' },
38
- ];
39
-
40
35
  /**
41
- * 从一行 JSONL 中提取工具调用信息
36
+ * 从一行 JSONL 中提取文本信息(call 与 result 都扫:凭据出现在返回内容中同样是泄露)
42
37
  */
43
38
  function extractToolCalls(line) {
44
39
  try {
45
40
  const event = JSON.parse(line);
46
41
  if (event.type !== 'tool/call' && event.type !== 'tool/result') return [];
47
42
 
48
- const calls = [];
49
43
  const data = event.data || {};
50
44
  const name = data.name || data.tool || '';
51
- const args = data.args || data.input || {};
45
+ const args = data.args || data.input || data.output || data.result || data.text || {};
52
46
  const text = typeof args === 'string' ? args : JSON.stringify(args);
53
47
 
54
- calls.push({ type: event.type, name, text, seq: event.seq, turn: event.turn });
55
- return calls;
48
+ return [{ type: event.type, name, text, seq: event.seq, turn: event.turn }];
56
49
  } catch {
57
50
  return [];
58
51
  }
59
52
  }
60
53
 
61
- /**
62
- * 扫描文本中的危险模式
63
- */
64
54
  function scanPatterns(text, patterns) {
65
55
  const findings = [];
66
56
  for (const pattern of patterns) {
@@ -76,11 +66,6 @@ function scanPatterns(text, patterns) {
76
66
  return findings;
77
67
  }
78
68
 
79
- /**
80
- * SR3 检查:分析会话日志中的数据外泄行为
81
- * @param {string} sessionFile - 会话日志文件路径
82
- * @returns {Promise<import('../protocol/check.mjs').SecurityCheckResult>}
83
- */
84
69
  export async function run(sessionFile) {
85
70
  const id = 'SR3';
86
71
 
@@ -88,34 +73,26 @@ export async function run(sessionFile) {
88
73
  return pass(id, Severity.HIGH, '无会话日志,跳过数据外泄检查');
89
74
  }
90
75
 
91
- if (sessionFile.endsWith('.zstd')) {
92
- return pass(id, Severity.HIGH, 'zstd 压缩的会话文件需先解压再检查');
93
- }
94
-
95
76
  const findings = [];
96
77
  let lineCount = 0;
97
78
 
98
- const fileStream = createReadStream(sessionFile, { encoding: 'utf8' });
99
- const rl = createInterface({ input: fileStream, crlfDelay: Infinity });
100
-
101
- for await (const line of rl) {
102
- lineCount++;
103
- if (!line.trim()) continue;
104
-
105
- const calls = extractToolCalls(line);
106
- for (const call of calls) {
107
- // 扫描凭据转发
108
- const credFindings = scanPatterns(call.text, CREDENTIAL_FORWARD_PATTERNS);
109
- for (const f of credFindings) {
110
- findings.push({ ...f, line: lineCount, tool: call.name, category: 'credential-forward' });
111
- }
112
-
113
- // 扫描网络外发
114
- const netFindings = scanPatterns(call.text, NETWORK_EXFIL_PATTERNS);
115
- for (const f of netFindings) {
116
- findings.push({ ...f, line: lineCount, tool: call.name, category: 'network-exfil' });
79
+ try {
80
+ lineCount = await scanSessionLines(sessionFile, (line, lineNo) => {
81
+ if (!line.trim()) return;
82
+ for (const call of extractToolCalls(line)) {
83
+ for (const f of scanPatterns(call.text, CREDENTIAL_FORWARD_PATTERNS)) {
84
+ findings.push({ ...f, line: lineNo, tool: call.name, category: 'credential-forward' });
85
+ }
86
+ for (const f of scanPatterns(call.text, NETWORK_EXFIL_PATTERNS)) {
87
+ findings.push({ ...f, line: lineNo, tool: call.name, category: 'network-exfil' });
88
+ }
117
89
  }
90
+ });
91
+ } catch (e) {
92
+ if (e.code === 'ZSTD_UNAVAILABLE') {
93
+ return skip(id, Severity.HIGH, 'zstd 命令不可用,无法解压压缩会话日志,跳过数据外泄检查');
118
94
  }
95
+ throw e;
119
96
  }
120
97
 
121
98
  if (findings.length === 0) {
@@ -157,7 +134,7 @@ export const sr3Check = {
157
134
  name: 'data-exfiltration',
158
135
  severity: Severity.HIGH,
159
136
  phase: CheckPhase.RUNTIME,
160
- description: '数据外泄行为检测(会话日志分析)',
137
+ description: '数据外泄行为检测(会话日志分析,支持 zstd)',
161
138
  src: 'builtin',
162
139
  runner: (sessionFile) => run(sessionFile),
163
140
  };
@@ -9,11 +9,11 @@
9
9
  * Phase: RUNTIME
10
10
  */
11
11
 
12
- import { existsSync, createReadStream } from 'node:fs';
13
- import { createInterface } from 'node:readline';
12
+ import { existsSync } from 'node:fs';
14
13
  import { Severity } from '../protocol/severity.mjs';
15
14
  import { CheckPhase } from '../protocol/phase.mjs';
16
- import { pass, fail } from '../protocol/check.mjs';
15
+ import { pass, fail, skip } from '../protocol/check.mjs';
16
+ import { scanSessionLines } from '../session-reader.mjs';
17
17
 
18
18
  function extractToolCalls(line) {
19
19
  try {
@@ -27,19 +27,24 @@ function extractToolCalls(line) {
27
27
  export async function run(sessionFile) {
28
28
  const id = 'SR4';
29
29
  if (!sessionFile || !existsSync(sessionFile)) return pass(id, Severity.MEDIUM, '无会话文件,跳过隔离验证');
30
- if (sessionFile.endsWith('.zstd')) return pass(id, Severity.MEDIUM, 'zstd 文件需先解压');
31
30
 
32
31
  const toolCalls = [];
33
32
  let lineCount = 0;
34
- const rl = createInterface({ input: createReadStream(sessionFile, { encoding: 'utf8' }), crlfDelay: Infinity });
35
33
 
36
- for await (const line of rl) {
37
- lineCount++;
38
- if (!line.trim()) continue;
39
- toolCalls.push(...extractToolCalls(line));
34
+ try {
35
+ lineCount = await scanSessionLines(sessionFile, (line) => {
36
+ if (!line.trim()) return;
37
+ toolCalls.push(...extractToolCalls(line));
38
+ });
39
+ } catch (e) {
40
+ if (e.code === 'ZSTD_UNAVAILABLE') {
41
+ return skip(id, Severity.MEDIUM, 'zstd 命令不可用,无法解压压缩会话日志,跳过隔离验证');
42
+ }
43
+ throw e;
40
44
  }
41
45
 
42
46
  // 简单的隔离检查:检测同一 turn 内不同工具的数据传递
47
+ // 复审修复:正常工作流一个 turn 用 4-5 个工具很常见,阈值 3→6 降低误报
43
48
  const findings = [];
44
49
  const byTurn = {};
45
50
  for (const call of toolCalls) {
@@ -49,13 +54,13 @@ export async function run(sessionFile) {
49
54
  }
50
55
 
51
56
  for (const [turn, calls] of Object.entries(byTurn)) {
52
- const tools = new Set(calls.map(c => c.name));
53
- if (tools.size > 3) {
54
- findings.push({ type: 'multi-tool-turn', severity: 'low', detail: `turn ${turn} 使用了 ${tools.size} 个不同工具:${[...tools].join(', ')}` });
57
+ const tools = [...new Set(calls.map(c => c.name).filter(Boolean))];
58
+ if (tools.size > 6) {
59
+ findings.push({ type: 'multi-tool-turn', severity: 'low', detail: `turn ${turn} 使用了 ${tools.size} 个不同工具:${tools.slice(0, 8).join(', ')}` });
55
60
  }
56
61
  }
57
62
 
58
- if (findings.length === 0) return pass(id, Severity.MEDIUM, `分析 ${toolCalls.length} 个工具调用,未检测到隔离问题`);
63
+ if (findings.length === 0) return pass(id, Severity.MEDIUM, `分析 ${toolCalls.length} 个工具调用(${lineCount} 行),未检测到隔离问题`);
59
64
 
60
65
  const details = findings.slice(0, 10).map(f => `[${f.severity}] ${f.detail}`).join('\n');
61
66
  return fail(id, Severity.MEDIUM, `检测到 ${findings.length} 个潜在隔离问题:\n${details}`, '检查工具间的数据流是否在预期范围内');
@@ -1,19 +1,21 @@
1
1
  /**
2
2
  * SS1: Credential Leak — 会话日志凭据泄露检测
3
3
  *
4
- * 扫描 session.jsonl 中的敏感凭据(API key/token/私钥)。
5
- * 检查是否已通过 dsh-redact 脱敏。
4
+ * 扫描 session.jsonl(或 .zstd 压缩)中的敏感凭据(API key/token/私钥)。
5
+ *
6
+ * 复审修复:
7
+ * - snippet 不再回显凭据本体(旧实现输出前 20+后 4 字符,检测器自己泄露凭据)
8
+ * - 移除"前几行含 [REDACTED]/*** 即整体跳过"的弱启发(一行 markdown 分隔线就能让整个文件免检)
6
9
  *
7
10
  * Severity: CRITICAL
8
11
  * Phase: POST_INSTALL
9
12
  */
10
13
 
11
- import { readFileSync, existsSync } from 'node:fs';
12
- import { createReadStream } from 'node:fs';
13
- import { createInterface } from 'node:readline';
14
+ import { existsSync } from 'node:fs';
14
15
  import { Severity } from '../protocol/severity.mjs';
15
16
  import { CheckPhase } from '../protocol/phase.mjs';
16
- import { pass, fail } from '../protocol/check.mjs';
17
+ import { pass, fail, skip } from '../protocol/check.mjs';
18
+ import { scanSessionLines } from '../session-reader.mjs';
17
19
 
18
20
  /** 凭据模式 */
19
21
  const CREDENTIAL_PATTERNS = [
@@ -28,13 +30,18 @@ const CREDENTIAL_PATTERNS = [
28
30
  { name: 'Basic Auth', regex: /Basic\s+[a-zA-Z0-9+/=]{20,}/g },
29
31
  ];
30
32
 
33
+ /** 掩码:只保留极短前缀 + 长度信息,绝不回显凭据内容 */
34
+ function maskSecret(s) {
35
+ if (s.length <= 8) return `***(${s.length} chars)`;
36
+ return `${s.slice(0, 4)}***(${s.length} chars)`;
37
+ }
38
+
31
39
  /**
32
40
  * 从一行 JSONL 中提取文本内容
33
41
  */
34
42
  function extractTextFromLine(line) {
35
43
  try {
36
44
  const event = JSON.parse(line);
37
- // 递归提取所有字符串值
38
45
  const texts = [];
39
46
  function extract(obj) {
40
47
  if (typeof obj === 'string') {
@@ -52,9 +59,6 @@ function extractTextFromLine(line) {
52
59
  }
53
60
  }
54
61
 
55
- /**
56
- * 扫描单行中的凭据
57
- */
58
62
  function scanLine(text) {
59
63
  const findings = [];
60
64
  for (const pattern of CREDENTIAL_PATTERNS) {
@@ -62,7 +66,7 @@ function scanLine(text) {
62
66
  for (const match of matches) {
63
67
  findings.push({
64
68
  type: pattern.name,
65
- snippet: match[0].slice(0, 20) + '...' + match[0].slice(-4),
69
+ snippet: maskSecret(match[0]),
66
70
  });
67
71
  }
68
72
  }
@@ -77,49 +81,27 @@ function scanLine(text) {
77
81
  export async function run(sessionFile) {
78
82
  const id = 'SS1';
79
83
 
80
- if (!existsSync(sessionFile)) {
84
+ if (!sessionFile || !existsSync(sessionFile)) {
81
85
  return pass(id, Severity.CRITICAL, '会话文件不存在,跳过检查');
82
86
  }
83
87
 
84
- // 检查是否是 zstd 压缩的(需要先解压)
85
- if (sessionFile.endsWith('.zstd')) {
86
- return pass(id, Severity.CRITICAL, 'zstd 压缩的会话文件需先解压再检查(或使用 dsh-redact 直接处理)');
87
- }
88
-
89
- // 检查是否已通过 dsh-redact 脱敏
90
- try {
91
- const firstLines = createReadStream(sessionFile, { encoding: 'utf8' });
92
- const rl = createInterface({ input: firstLines, crlfDelay: Infinity });
93
- let alreadyRedacted = false;
94
- let checkCount = 0;
95
- for await (const line of rl) {
96
- if (checkCount++ > 5) break;
97
- if (line.includes('[REDACTED]') || line.includes('[redacted]') || line.includes('***')) {
98
- alreadyRedacted = true;
99
- break;
100
- }
101
- }
102
- firstLines.destroy();
103
- if (alreadyRedacted) {
104
- return pass(id, Severity.CRITICAL, '会话文件似乎已通过 dsh-redact 脱敏');
105
- }
106
- } catch { /* 继续正常检查 */ }
107
-
108
88
  const findings = [];
109
89
  let lineCount = 0;
110
90
 
111
- const fileStream = createReadStream(sessionFile, { encoding: 'utf8' });
112
- const rl = createInterface({ input: fileStream, crlfDelay: Infinity });
113
-
114
- for await (const line of rl) {
115
- lineCount++;
116
- if (!line.trim()) continue;
117
- const text = extractTextFromLine(line);
118
- if (!text) continue;
119
- const lineFindings = scanLine(text);
120
- for (const f of lineFindings) {
121
- findings.push({ ...f, line: lineCount });
91
+ try {
92
+ lineCount = await scanSessionLines(sessionFile, (line, lineNo) => {
93
+ if (!line.trim()) return;
94
+ const text = extractTextFromLine(line);
95
+ if (!text) return;
96
+ for (const f of scanLine(text)) {
97
+ findings.push({ ...f, line: lineNo });
98
+ }
99
+ });
100
+ } catch (e) {
101
+ if (e.code === 'ZSTD_UNAVAILABLE') {
102
+ return skip(id, Severity.CRITICAL, 'zstd 命令不可用,无法解压压缩会话日志,跳过凭据泄露检测');
122
103
  }
104
+ throw e;
123
105
  }
124
106
 
125
107
  if (findings.length === 0) {
@@ -152,7 +134,7 @@ export const ss1Check = {
152
134
  name: 'credential-leak',
153
135
  severity: Severity.CRITICAL,
154
136
  phase: CheckPhase.POST_INSTALL,
155
- description: '会话日志凭据泄露检测',
137
+ description: '会话日志凭据泄露检测(支持 zstd,结果自动掩码)',
156
138
  src: 'builtin',
157
139
  runner: (sessionFile) => run(sessionFile),
158
140
  };
@@ -11,11 +11,11 @@
11
11
  * Phase: POST_INSTALL
12
12
  */
13
13
 
14
- import { existsSync, createReadStream } from 'node:fs';
15
- import { createInterface } from 'node:readline';
14
+ import { existsSync } from 'node:fs';
16
15
  import { Severity } from '../protocol/severity.mjs';
17
16
  import { CheckPhase } from '../protocol/phase.mjs';
18
- import { pass, fail } from '../protocol/check.mjs';
17
+ import { pass, fail, skip } from '../protocol/check.mjs';
18
+ 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' },
@@ -24,6 +24,12 @@ const PII_PATTERNS = [
24
24
  { name: 'IPv4', regex: /\b(?:[0-9]{1,3}\.){3}[0-9]{1,3}\b/g, severity: 'low' },
25
25
  ];
26
26
 
27
+ /** PII 掩码:保留类型可辨识度,不回显完整个人信息 */
28
+ function maskPII(s) {
29
+ if (s.length <= 6) return `***(${s.length} chars)`;
30
+ return `${s.slice(0, 2)}***${s.slice(-2)}(${s.length} chars)`;
31
+ }
32
+
27
33
  function extractTextFromLine(line) {
28
34
  try {
29
35
  const event = JSON.parse(line);
@@ -41,21 +47,25 @@ function extractTextFromLine(line) {
41
47
  export async function run(sessionFile) {
42
48
  const id = 'SS2';
43
49
  if (!sessionFile || !existsSync(sessionFile)) return pass(id, Severity.MEDIUM, '无会话文件,跳过 PII 检测');
44
- if (sessionFile.endsWith('.zstd')) return pass(id, Severity.MEDIUM, 'zstd 文件需先解压');
45
50
 
46
51
  const findings = [];
47
52
  let lineCount = 0;
48
- const rl = createInterface({ input: createReadStream(sessionFile, { encoding: 'utf8' }), crlfDelay: Infinity });
49
53
 
50
- for await (const line of rl) {
51
- lineCount++;
52
- if (!line.trim()) continue;
53
- const text = extractTextFromLine(line);
54
- for (const pattern of PII_PATTERNS) {
55
- for (const match of text.matchAll(new RegExp(pattern.regex.source, 'g'))) {
56
- findings.push({ type: pattern.name, severity: pattern.severity, line: lineCount, snippet: match[0].slice(0, 30) });
54
+ try {
55
+ lineCount = await scanSessionLines(sessionFile, (line, lineNo) => {
56
+ if (!line.trim()) return;
57
+ const text = extractTextFromLine(line);
58
+ for (const pattern of PII_PATTERNS) {
59
+ for (const match of text.matchAll(new RegExp(pattern.regex.source, 'g'))) {
60
+ findings.push({ type: pattern.name, severity: pattern.severity, line: lineNo, snippet: maskPII(match[0]) });
61
+ }
57
62
  }
63
+ });
64
+ } catch (e) {
65
+ if (e.code === 'ZSTD_UNAVAILABLE') {
66
+ return skip(id, Severity.MEDIUM, 'zstd 命令不可用,无法解压压缩会话日志,跳过 PII 检测');
58
67
  }
68
+ throw e;
59
69
  }
60
70
 
61
71
  if (findings.length === 0) return pass(id, Severity.MEDIUM, `扫描 ${lineCount} 行,未检测到 PII 暴露`);
@@ -10,11 +10,11 @@
10
10
  * Phase: POST_INSTALL
11
11
  */
12
12
 
13
- import { existsSync, createReadStream } from 'node:fs';
14
- import { createInterface } from 'node:readline';
13
+ import { existsSync } from 'node:fs';
15
14
  import { Severity } from '../protocol/severity.mjs';
16
15
  import { CheckPhase } from '../protocol/phase.mjs';
17
- import { pass, fail } from '../protocol/check.mjs';
16
+ import { pass, fail, skip } from '../protocol/check.mjs';
17
+ import { scanSessionLines } from '../session-reader.mjs';
18
18
 
19
19
  const SENSITIVE_OUTPUT_PATTERNS = [
20
20
  { name: 'env dump', regex: /(?:process\.env|ENV|env)\s*[=:]\s*\{[^}]{50,}/gi, severity: 'medium' },
@@ -35,23 +35,26 @@ function extractToolOutputs(line) {
35
35
  export async function run(sessionFile) {
36
36
  const id = 'SS3';
37
37
  if (!sessionFile || !existsSync(sessionFile)) return pass(id, Severity.LOW, '无会话文件,跳过敏感输出检测');
38
- if (sessionFile.endsWith('.zstd')) return pass(id, Severity.LOW, 'zstd 文件需先解压');
39
38
 
40
39
  const findings = [];
41
40
  let lineCount = 0;
42
- const rl = createInterface({ input: createReadStream(sessionFile, { encoding: 'utf8' }), crlfDelay: Infinity });
43
-
44
- for await (const line of rl) {
45
- lineCount++;
46
- if (!line.trim()) continue;
47
- const outputs = extractToolOutputs(line);
48
- for (const output of outputs) {
49
- for (const pattern of SENSITIVE_OUTPUT_PATTERNS) {
50
- for (const match of output.matchAll(new RegExp(pattern.regex.source, 'g'))) {
51
- findings.push({ type: pattern.name, severity: pattern.severity, line: lineCount, snippet: match[0].slice(0, 60) });
41
+
42
+ try {
43
+ lineCount = await scanSessionLines(sessionFile, (line, lineNo) => {
44
+ if (!line.trim()) return;
45
+ for (const output of extractToolOutputs(line)) {
46
+ for (const pattern of SENSITIVE_OUTPUT_PATTERNS) {
47
+ for (const match of output.matchAll(new RegExp(pattern.regex.source, 'g'))) {
48
+ findings.push({ type: pattern.name, severity: pattern.severity, line: lineNo, snippet: match[0].slice(0, 60) });
49
+ }
52
50
  }
53
51
  }
52
+ });
53
+ } catch (e) {
54
+ if (e.code === 'ZSTD_UNAVAILABLE') {
55
+ return skip(id, Severity.LOW, 'zstd 命令不可用,无法解压压缩会话日志,跳过敏感输出检测');
54
56
  }
57
+ throw e;
55
58
  }
56
59
 
57
60
  if (findings.length === 0) return pass(id, Severity.LOW, `扫描 ${lineCount} 行,未检测到敏感输出`);
package/src/config.mjs CHANGED
@@ -2,42 +2,47 @@
2
2
  * DSH Security Framework — 配置管理
3
3
  *
4
4
  * 支持 ~/.dsh/security.json 配置文件:
5
- * - 启用/禁用特定检查
6
- * - 覆盖 severity 阈值
7
- * - 配置外部工具集成
5
+ * - enabled: false → 停用整个安全框架(缺省/文件不存在 = 全部启用)
6
+ * - checks.{ID}.enabled: false → 停用单个检查
7
+ * - severityThreshold: "low"|"medium"|"high" → 低于阈值的失败降级为 skip
8
+ *
9
+ * 注意:配置只在显式 setConfig() 注入 registry 后生效;未注入时所有检查启用。
8
10
  */
9
11
 
10
12
  import { readFileSync, existsSync } from 'node:fs';
11
13
  import { join } from 'node:path';
12
14
  import { homedir } from 'node:os';
13
15
 
14
- const DEFAULT_CONFIG = {
15
- enabled: false,
16
+ const DEFAULT_CONFIG = Object.freeze({
17
+ enabled: true,
16
18
  checks: {},
17
- external: {},
18
- severityThreshold: 'info',
19
- autoRedact: true,
20
- };
19
+ severityThreshold: null,
20
+ });
21
21
 
22
22
  export function loadConfig(dshHome) {
23
23
  const configPath = join(dshHome || join(homedir(), '.dsh'), 'security.json');
24
- if (!existsSync(configPath)) return DEFAULT_CONFIG;
24
+ if (!existsSync(configPath)) return { ...DEFAULT_CONFIG };
25
25
  try {
26
26
  const raw = JSON.parse(readFileSync(configPath, 'utf8'));
27
- return { ...DEFAULT_CONFIG, ...raw };
27
+ return {
28
+ ...DEFAULT_CONFIG,
29
+ ...raw,
30
+ checks: raw.checks && typeof raw.checks === 'object' ? raw.checks : {},
31
+ };
28
32
  } catch {
29
- return DEFAULT_CONFIG;
33
+ return { ...DEFAULT_CONFIG };
30
34
  }
31
35
  }
32
36
 
33
37
  export function isCheckEnabled(config, checkId) {
34
- if (!config.enabled) return false;
35
- const checkConfig = config.checks[checkId];
38
+ if (!config) return true;
39
+ if (config.enabled === false) return false;
40
+ const checkConfig = config.checks && config.checks[checkId];
36
41
  if (checkConfig && checkConfig.enabled === false) return false;
37
42
  return true;
38
43
  }
39
44
 
40
45
  export function getCheckSeverity(config, checkId, defaultSeverity) {
41
- const checkConfig = config.checks[checkId];
42
- return checkConfig?.severity || defaultSeverity;
46
+ const checkConfig = config && config.checks ? config.checks[checkId] : undefined;
47
+ return (checkConfig && checkConfig.severity) || defaultSeverity;
43
48
  }
package/src/index.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * DSH Security Framework — 统一安全检查框架
3
3
  *
4
- * 为 DSH 生态提供全生命周期安全检查(17 个检查项,4 层架构)。
4
+ * 为 DSH 生态提供全生命周期安全检查(18 个检查项,4 层架构)。
5
5
  *
6
6
  * @example
7
7
  * import { createDefaultRegistry } from '@moonquake2004/dsh-security';
@@ -12,7 +12,8 @@
12
12
  // Protocol
13
13
  export { Severity, severityToExitCode, severityGte, maxSeverity } from './protocol/severity.mjs';
14
14
  export { CheckPhase } from './protocol/phase.mjs';
15
- export { createResult, pass, fail } from './protocol/check.mjs';
15
+ export { createResult, pass, fail, skip } from './protocol/check.mjs';
16
+ export { isZstdFile, scanSessionLines } from './session-reader.mjs';
16
17
 
17
18
  // Layer 1: Static Checks
18
19
  export { sp1Check } from './checks/sp1-dependency-audit.mjs';
@@ -21,6 +22,7 @@ export { sp3Check } from './checks/sp3-sandbox-consistency.mjs';
21
22
  export { sp4Check } from './checks/sp4-entry-poison.mjs';
22
23
  export { sp5Check } from './checks/sp5-permission-model.mjs';
23
24
  export { sp6Check } from './checks/sp6-vuln-match.mjs';
25
+ export { sp7Check } from './checks/sp7-client-syntax.mjs';
24
26
 
25
27
  // Layer 2: Runtime Checks
26
28
  export { sr1Check } from './checks/sr1-sandbox-violation.mjs';
@@ -5,50 +5,87 @@
5
5
  * - 已知 bug 状态
6
6
  * - 发布兼容性报告
7
7
  * - 生态健康信号
8
+ *
9
+ * 复审修复:
10
+ * - 旧实现 fetch raw.githubusercontent.com 的目录 URL——raw 不提供目录列表,必然 404,
11
+ * release-compat 半边永远拿不到数据;现改走 GitHub contents API 列目录并取最新文件。
12
+ * - bug 雷达不再硬编码 weekly-2026-08-15.md,自动取 docs/ 下最新的 weekly-*.md。
13
+ * - 数据源不可用返回 skip 而不是伪装通过。
8
14
  */
9
15
 
10
16
  import { Severity } from '../protocol/severity.mjs';
11
17
  import { CheckPhase } from '../protocol/phase.mjs';
18
+ import { skip } from '../protocol/check.mjs';
19
+
20
+ const REPO_DOCS_API = 'https://api.github.com/repos/zoahdev/dsh-ecosystem/contents/docs';
21
+ const RAW_BASE = 'https://raw.githubusercontent.com/zoahdev/dsh-ecosystem/main/docs';
22
+
23
+ const GH_HEADERS = {
24
+ 'User-Agent': 'dsh-security',
25
+ 'Accept': 'application/vnd.github+json',
26
+ };
27
+
28
+ async function ghFetch(url, accept) {
29
+ const response = await fetch(url, {
30
+ headers: { ...GH_HEADERS, ...(accept ? { Accept: accept } : {}) },
31
+ signal: AbortSignal.timeout(10000),
32
+ });
33
+ if (!response.ok) return null;
34
+ return response;
35
+ }
12
36
 
13
- const ECOSYSTEM_API = 'https://raw.githubusercontent.com/zoahdev/dsh-ecosystem/main/docs';
37
+ /** docs/<sub> 目录下的 .md 文件名(GitHub contents API),按名称倒序 */
38
+ async function listMarkdownFiles(sub = '') {
39
+ try {
40
+ const response = await ghFetch(sub ? `${REPO_DOCS_API}/${sub}` : REPO_DOCS_API);
41
+ if (!response || !response.ok) return null;
42
+ const entries = await response.json();
43
+ if (!Array.isArray(entries)) return null;
44
+ return entries
45
+ .filter(e => e.type === 'file' && e.name.endsWith('.md'))
46
+ .map(e => e.name)
47
+ .sort()
48
+ .reverse();
49
+ } catch { return null; }
50
+ }
51
+
52
+ async function fetchRaw(sub, name) {
53
+ try {
54
+ const response = await ghFetch(`${RAW_BASE}/${sub ? sub + '/' : ''}${name}`);
55
+ if (!response || !response.ok) return null;
56
+ return await response.text();
57
+ } catch { return null; }
58
+ }
14
59
 
15
60
  /**
16
- * 获取发布兼容性报告
61
+ * 获取最新发布兼容性报告
17
62
  */
18
63
  async function fetchReleaseCompat() {
19
- try {
20
- const response = await fetch(`${ECOSYSTEM_API}/release-compat/`, { signal: AbortSignal.timeout(10000) });
21
- if (!response.ok) return null;
22
- // 解析目录列表找最新的兼容性报告
23
- const text = await response.text();
24
- const match = text.match(/release-compat-[\d-]+\.md/);
25
- if (!match) return null;
26
-
27
- const reportResponse = await fetch(`${ECOSYSTEM_API}/release-compat/${match[0]}`, { signal: AbortSignal.timeout(10000) });
28
- if (!reportResponse.ok) return null;
29
- return await reportResponse.text();
30
- } catch { return null; }
64
+ const names = await listMarkdownFiles('release-compat');
65
+ if (!names || names.length === 0) return null;
66
+ const latest = names.find(n => /^release-compat-\d[\d-]*\.md$/.test(n));
67
+ if (!latest) return null;
68
+ return fetchRaw('release-compat', latest);
31
69
  }
32
70
 
33
71
  /**
34
- * 获取 bug 雷达
72
+ * 获取最新一期 bug 雷达周报
35
73
  */
36
74
  async function fetchBugRadar() {
37
- try {
38
- const response = await fetch(`${ECOSYSTEM_API}/weekly-2026-08-15.md`, { signal: AbortSignal.timeout(10000) });
39
- if (!response.ok) return null;
40
- return await response.text();
41
- } catch { return null; }
75
+ const names = await listMarkdownFiles('');
76
+ if (!names) return null;
77
+ const latest = names.find(n => /^weekly-\d{4}-\d{2}-\d{2}\.md$/.test(n));
78
+ if (!latest) return null;
79
+ return fetchRaw('', latest);
42
80
  }
43
81
 
44
82
  export async function runCheck(profileDir) {
45
83
  const id = 'EXT-ECO-1';
46
84
 
47
- const releaseNotes = await fetchReleaseCompat();
48
- const bugRadar = await fetchBugRadar();
85
+ const [releaseNotes, bugRadar] = await Promise.all([fetchReleaseCompat(), fetchBugRadar()]);
49
86
 
50
87
  if (!releaseNotes && !bugRadar) {
51
- return { id, ok: true, severity: Severity.LOW, detail: 'dsh-ecosystem 数据源不可用,跳过生态兼容性检查' };
88
+ return skip(id, Severity.LOW, 'dsh-ecosystem 数据源不可达(离线或仓库无数据),跳过生态兼容性检查');
52
89
  }
53
90
 
54
91
  const issues = [];