@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.
@@ -2,22 +2,21 @@
2
2
  * dsh-plugin-reducer 集成
3
3
  *
4
4
  * 在检测到故障时,提供最小化故障插件集的能力。
5
- * 如果 dsh-plugin-reducer 已安装,自动建议运行。
5
+ * 如果 dsh-plugin-reducer 已安装(PATH 中可执行),自动建议运行。
6
+ * 复审修复:不再用 `npx 包名` 探测/执行;执行失败返回 skip 而不是伪装通过。
6
7
  */
7
8
 
8
- import { execSync } from 'node:child_process';
9
+ import { execFileSync } from 'node:child_process';
9
10
  import { Severity } from '../protocol/severity.mjs';
10
11
  import { CheckPhase } from '../protocol/phase.mjs';
12
+ import { skip } from '../protocol/check.mjs';
11
13
 
12
14
  export function isAvailable() {
13
15
  try {
14
- execSync('which dsh-plugin-reducer', { encoding: 'utf8', stdio: 'pipe' });
16
+ execFileSync('which', ['dsh-plugin-reducer'], { encoding: 'utf8', stdio: 'pipe' });
15
17
  return true;
16
18
  } catch {
17
- try {
18
- execSync('npx dsh-plugin-reducer --version', { encoding: 'utf8', stdio: 'pipe', timeout: 10000 });
19
- return true;
20
- } catch { return false; }
19
+ return false;
21
20
  }
22
21
  }
23
22
 
@@ -25,15 +24,19 @@ export async function runReducer(profileDir, probeType = 'web') {
25
24
  const id = 'EXT-RED-1';
26
25
 
27
26
  if (!isAvailable()) {
28
- return { id, ok: true, severity: Severity.LOW, detail: 'dsh-plugin-reducer 未安装,跳过故障最小化' };
27
+ return skip(id, Severity.LOW, 'dsh-plugin-reducer 未安装,跳过故障最小化');
29
28
  }
30
29
 
31
30
  try {
32
- const output = execSync(`npx dsh-plugin-reducer --profile "${profileDir}" --probe ${probeType} --report /tmp/reducer-report.json --json`, {
33
- encoding: 'utf8',
34
- timeout: 120000,
35
- stdio: ['pipe', 'pipe', 'pipe'],
36
- });
31
+ const output = execFileSync(
32
+ 'dsh-plugin-reducer',
33
+ ['--profile', String(profileDir), '--probe', String(probeType), '--report', '/tmp/reducer-report.json', '--json'],
34
+ {
35
+ encoding: 'utf8',
36
+ timeout: 120000,
37
+ stdio: ['pipe', 'pipe', 'pipe'],
38
+ }
39
+ );
37
40
 
38
41
  const result = JSON.parse(output);
39
42
 
@@ -50,7 +53,7 @@ export async function runReducer(profileDir, probeType = 'web') {
50
53
 
51
54
  return { id, ok: true, severity: Severity.LOW, detail: 'dsh-plugin-reducer 未发现故障插件集' };
52
55
  } catch (e) {
53
- return { id, ok: true, severity: Severity.LOW, detail: `dsh-plugin-reducer 执行失败:${e.message.slice(0, 80)}` };
56
+ return skip(id, Severity.LOW, `dsh-plugin-reducer 执行失败,跳过:${e.message.slice(0, 80)}`);
54
57
  }
55
58
  }
56
59
 
@@ -1,30 +1,24 @@
1
1
  /**
2
2
  * dsh-poison-guard 集成
3
3
  *
4
- * 通过 Plugin Interface 注册投毒扫描检查。
5
- * 如果 dsh-poison-guard 已安装,自动集成到安全检查流程。
4
+ * 如果 dsh-poison-guard 已安装(PATH 中可执行),自动集成到安全检查流程。
5
+ * 复审修复:不再用 `npx 包名` 探测/执行;执行失败返回 skip 而不是伪装通过。
6
6
  */
7
7
 
8
- import { execSync } from 'node:child_process';
9
- import { existsSync } from 'node:fs';
8
+ import { execFileSync } from 'node:child_process';
10
9
  import { Severity } from '../protocol/severity.mjs';
11
10
  import { CheckPhase } from '../protocol/phase.mjs';
11
+ import { skip } from '../protocol/check.mjs';
12
12
 
13
13
  /**
14
14
  * 检测 dsh-poison-guard 是否可用
15
15
  */
16
16
  export function isAvailable() {
17
17
  try {
18
- execSync('which dsh-poison-guard', { encoding: 'utf8', stdio: 'pipe' });
18
+ execFileSync('which', ['dsh-poison-guard'], { encoding: 'utf8', stdio: 'pipe' });
19
19
  return true;
20
20
  } catch {
21
- // 也检查 npx 可用性
22
- try {
23
- execSync('npx dsh-poison-guard --version', { encoding: 'utf8', stdio: 'pipe', timeout: 10000 });
24
- return true;
25
- } catch {
26
- return false;
27
- }
21
+ return false;
28
22
  }
29
23
  }
30
24
 
@@ -37,11 +31,11 @@ export async function runScan(targetPath) {
37
31
  const id = 'EXT-PG-1';
38
32
 
39
33
  if (!isAvailable()) {
40
- return { id, ok: true, severity: Severity.HIGH, detail: 'dsh-poison-guard 未安装,跳过投毒扫描' };
34
+ return skip(id, Severity.HIGH, 'dsh-poison-guard 未安装,跳过投毒扫描');
41
35
  }
42
36
 
43
37
  try {
44
- const output = execSync(`npx dsh-poison-guard scan "${targetPath}" --json`, {
38
+ const output = execFileSync('dsh-poison-guard', ['scan', String(targetPath), '--json'], {
45
39
  encoding: 'utf8',
46
40
  timeout: 60000,
47
41
  stdio: ['pipe', 'pipe', 'pipe'],
@@ -64,7 +58,7 @@ export async function runScan(targetPath) {
64
58
  fix: '检查相关插件的源码,移除恶意代码',
65
59
  };
66
60
  } catch (e) {
67
- return { id, ok: true, severity: Severity.HIGH, detail: `dsh-poison-guard 执行失败:${e.message.slice(0, 80)}` };
61
+ return skip(id, Severity.HIGH, `dsh-poison-guard 执行失败,跳过:${e.message.slice(0, 80)}`);
68
62
  }
69
63
  }
70
64
 
@@ -1,23 +1,22 @@
1
1
  /**
2
2
  * dsh-sandbox-audit 集成
3
3
  *
4
- * 通过 Plugin Interface 注册沙箱策略审计检查。
5
- * 如果 dsh-sandbox-audit 已安装,自动集成到安全检查流程。
4
+ * 如果 dsh-sandbox-audit 已安装(PATH 中可执行),自动集成到安全检查流程。
5
+ * 复审修复:不再用 `npx 包名` 探测可用性——那会在用户机器上触发任意包的下载执行;
6
+ * 执行失败现在返回 skip 而不是伪装成通过。
6
7
  */
7
8
 
8
- import { execSync } from 'node:child_process';
9
+ import { execFileSync } from 'node:child_process';
9
10
  import { Severity } from '../protocol/severity.mjs';
10
11
  import { CheckPhase } from '../protocol/phase.mjs';
12
+ import { skip } from '../protocol/check.mjs';
11
13
 
12
14
  export function isAvailable() {
13
15
  try {
14
- execSync('which dsh-sandbox-audit', { encoding: 'utf8', stdio: 'pipe' });
16
+ execFileSync('which', ['dsh-sandbox-audit'], { encoding: 'utf8', stdio: 'pipe' });
15
17
  return true;
16
18
  } catch {
17
- try {
18
- execSync('npx dsh-sandbox-audit --version', { encoding: 'utf8', stdio: 'pipe', timeout: 10000 });
19
- return true;
20
- } catch { return false; }
19
+ return false;
21
20
  }
22
21
  }
23
22
 
@@ -25,11 +24,11 @@ export async function runAudit(profileDir) {
25
24
  const id = 'EXT-SA-1';
26
25
 
27
26
  if (!isAvailable()) {
28
- return { id, ok: true, severity: Severity.MEDIUM, detail: 'dsh-sandbox-audit 未安装,跳过沙箱策略审计' };
27
+ return skip(id, Severity.MEDIUM, 'dsh-sandbox-audit 未安装,跳过沙箱策略审计');
29
28
  }
30
29
 
31
30
  try {
32
- const output = execSync(`npx dsh-sandbox-audit "${profileDir}" --json`, {
31
+ const output = execFileSync('dsh-sandbox-audit', [String(profileDir), '--json'], {
33
32
  encoding: 'utf8',
34
33
  timeout: 60000,
35
34
  stdio: ['pipe', 'pipe', 'pipe'],
@@ -52,7 +51,7 @@ export async function runAudit(profileDir) {
52
51
  fix: '参考 dsh-sandbox-audit 文档修复沙箱配置',
53
52
  };
54
53
  } catch (e) {
55
- return { id, ok: true, severity: Severity.MEDIUM, detail: `dsh-sandbox-audit 执行失败:${e.message.slice(0, 80)}` };
54
+ return skip(id, Severity.MEDIUM, `dsh-sandbox-audit 执行失败,跳过:${e.message.slice(0, 80)}`);
56
55
  }
57
56
  }
58
57
 
@@ -47,3 +47,12 @@ export function pass(id, severity, detail) {
47
47
  export function fail(id, severity, detail, fix, references = []) {
48
48
  return createResult(id, false, severity, detail, fix, references);
49
49
  }
50
+
51
+ /**
52
+ * 跳过结果——检查因外部条件不满足而未真正执行(如网络不可达、依赖工具缺失)。
53
+ * skipped=true 的结果不计入失败统计,也不影响退出码;
54
+ * detail 必须说明原因(对齐 #1719 r5 词汇表「skip 必须带 reason」)。
55
+ */
56
+ export function skip(id, severity, detail) {
57
+ return { id, ok: true, skipped: true, severity, detail };
58
+ }
package/src/registry.mjs CHANGED
@@ -2,22 +2,41 @@
2
2
  * DSH Security Framework — Check Registry
3
3
  *
4
4
  * 管理安全检查的注册、发现和执行。
5
- * 支持内置检查和外部工具注册的检查。
5
+ * 支持内置检查、外部工具集成(自动探测)和 checks.d JSON 注册(显式启用)。
6
6
  */
7
7
 
8
8
  import { readdirSync, readFileSync, existsSync } from 'node:fs';
9
9
  import { join } from 'node:path';
10
- import { maxSeverity, severityToExitCode } from './protocol/severity.mjs';
10
+ import { maxSeverity, severityToExitCode, severityGte } from './protocol/severity.mjs';
11
+
12
+ const VALID_SEVERITIES = ['critical', 'high', 'medium', 'low', 'info'];
11
13
 
12
14
  export class SecurityCheckRegistry {
13
15
  constructor() {
14
16
  /** @type {Map<string, import('./protocol/check.mjs').SecurityCheck>} */
15
17
  this.checks = new Map();
18
+ /** @type {object|null} 可选运行配置(见 config.mjs / README 配置节) */
19
+ this.config = null;
16
20
  }
17
21
 
18
22
  register(check) { this.checks.set(check.id, check); }
19
23
  registerAll(checks) { for (const check of checks) this.register(check); }
20
24
 
25
+ /**
26
+ * 注入运行配置(~/.dsh/security.json):
27
+ * - enabled=false → 全部停用
28
+ * - checks.{ID}.enabled=false → 停用单个检查
29
+ * - severityThreshold → 低于阈值的失败降级为 skipped(不影响退出码)
30
+ */
31
+ setConfig(config) {
32
+ this.config = config && typeof config === 'object' ? config : null;
33
+ }
34
+
35
+ /**
36
+ * Plugin Interface:从 <securityDir>/checks.d/*.json 加载外部注册的检查。
37
+ * 注意:JSON 中的 command 会被执行——只应加载用户自己放置的文件,
38
+ * 且目录由调用方显式传入(框架不会默认扫描任何位置)。
39
+ */
21
40
  loadExternalChecks(securityDir) {
22
41
  const checksDir = join(securityDir, 'checks.d');
23
42
  if (!existsSync(checksDir)) return;
@@ -27,17 +46,20 @@ export class SecurityCheckRegistry {
27
46
  const reg = JSON.parse(readFileSync(join(checksDir, file), 'utf8'));
28
47
  if (reg.checks && Array.isArray(reg.checks)) {
29
48
  for (const ext of reg.checks) {
49
+ if (!ext || !ext.id || !ext.command) continue;
30
50
  this.register({
31
- id: ext.id, name: ext.name, severity: ext.severity, phase: ext.phase,
32
- description: ext.description, src: 'external', source: reg.source,
51
+ id: ext.id, name: ext.name || ext.id, severity: ext.severity || 'medium',
52
+ phase: ext.phase || 'post-install',
53
+ description: ext.description || `External check (${reg.source || file})`,
54
+ src: 'external', source: reg.source || file,
33
55
  runner: async () => {
34
56
  const { execSync } = await import('node:child_process');
35
57
  try {
36
58
  const output = execSync(ext.command, { encoding: 'utf8', timeout: 30000 });
37
59
  const result = JSON.parse(output);
38
- return { id: ext.id, ok: result.ok ?? true, severity: ext.severity, detail: result.detail || 'External check completed', fix: result.fix, references: result.references };
60
+ return { id: ext.id, ok: result.ok ?? true, severity: ext.severity || 'medium', detail: result.detail || 'External check completed', fix: result.fix, references: result.references };
39
61
  } catch (e) {
40
- return { id: ext.id, ok: false, severity: ext.severity, detail: `External check failed: ${e.message}` };
62
+ return { id: ext.id, ok: false, severity: ext.severity || 'medium', detail: `External check failed: ${e.message}` };
41
63
  }
42
64
  },
43
65
  });
@@ -56,7 +78,18 @@ export class SecurityCheckRegistry {
56
78
  }
57
79
 
58
80
  async runAll(contextFn, phase = null) {
59
- const checks = phase ? this.getByPhase(phase) : [...this.checks.values()];
81
+ let checks = phase ? this.getByPhase(phase) : [...this.checks.values()];
82
+
83
+ // 应用配置过滤(未 setConfig 时全部启用)
84
+ const cfg = this.config;
85
+ if (cfg && cfg.enabled === false) {
86
+ checks = [];
87
+ } else if (cfg && cfg.checks) {
88
+ checks = checks.filter(c => !(cfg.checks[c.id] && cfg.checks[c.id].enabled === false));
89
+ }
90
+ const threshold = cfg && cfg.severityThreshold && VALID_SEVERITIES.includes(cfg.severityThreshold)
91
+ ? cfg.severityThreshold : null;
92
+
60
93
  const results = [];
61
94
  for (const check of checks) {
62
95
  try {
@@ -67,20 +100,34 @@ export class SecurityCheckRegistry {
67
100
  results.push({ id: check.id, ok: false, severity: check.severity, detail: `Check execution failed: ${e.message}` });
68
101
  }
69
102
  }
70
- const summary = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
103
+
104
+ // severityThreshold:低于阈值的失败降级为 skip(保留原因),不进失败统计与退出码
105
+ const finalResults = results.map((r) => {
106
+ if (r.ok || r.skipped || !threshold) return r;
107
+ const sev = VALID_SEVERITIES.includes(r.severity) ? r.severity : 'medium';
108
+ if (severityGte(threshold, sev)) {
109
+ return { ...r, ok: true, skipped: true, detail: `${r.detail}\n[severityThreshold=${threshold}:低于阈值,已降级为 skip]` };
110
+ }
111
+ return r;
112
+ });
113
+
114
+ const summary = { critical: 0, high: 0, medium: 0, low: 0, info: 0, skipped: 0 };
71
115
  const failedSeverities = [];
72
- for (const r of results) {
73
- if (!r.ok && summary[r.severity] !== undefined) {
74
- summary[r.severity]++;
75
- failedSeverities.push(r.severity);
116
+ for (const r of finalResults) {
117
+ if (r.skipped) { summary.skipped++; continue; }
118
+ if (!r.ok) {
119
+ // 无效 severity 的失败按 medium 计,避免静默丢失退出码信号
120
+ const sev = VALID_SEVERITIES.includes(r.severity) ? r.severity : 'medium';
121
+ summary[sev]++;
122
+ failedSeverities.push(sev);
76
123
  }
77
124
  }
78
125
  const exitCode = failedSeverities.length > 0 ? severityToExitCode[maxSeverity(failedSeverities)] : 0;
79
- return { results, exitCode, summary };
126
+ return { results: finalResults, exitCode, summary };
80
127
  }
81
128
  }
82
129
 
83
- export async function createDefaultRegistry(loadExternal = true) {
130
+ export async function createDefaultRegistry(loadExternal = true, options = {}) {
84
131
  const registry = new SecurityCheckRegistry();
85
132
  const modules = await Promise.all([
86
133
  import('./checks/sp1-dependency-audit.mjs'),
@@ -100,6 +147,7 @@ export async function createDefaultRegistry(loadExternal = true) {
100
147
  import('./checks/ss1-credential-leak.mjs'),
101
148
  import('./checks/ss2-pii-exposure.mjs'),
102
149
  import('./checks/ss3-sensitive-output.mjs'),
150
+ import('./checks/sp7-client-syntax.mjs'),
103
151
  ]);
104
152
  for (const mod of modules) {
105
153
  for (const val of Object.values(mod)) {
@@ -109,8 +157,13 @@ export async function createDefaultRegistry(loadExternal = true) {
109
157
  }
110
158
  }
111
159
 
112
- // 加载外部集成
113
- if (loadExternal) {
160
+ // Plugin Interface:仅在调用方显式给出目录时启用(安全考虑,不默认扫描)
161
+ const extDir = typeof loadExternal === 'object' ? loadExternal.externalChecksDir : options.externalChecksDir;
162
+ if (extDir) registry.loadExternalChecks(extDir);
163
+
164
+ // 自动探测外部工具集成(dsh-poison-guard 等)
165
+ const wantIntegrations = typeof loadExternal === 'object' ? (loadExternal.integrations !== false) : loadExternal;
166
+ if (wantIntegrations) {
114
167
  try {
115
168
  const { getAvailableIntegrations } = await import('./integrations/index.mjs');
116
169
  const integrations = await getAvailableIntegrations();
@@ -0,0 +1,63 @@
1
+ /**
2
+ * DSH Security Framework — 会话日志读取
3
+ *
4
+ * SR/SS 系列检查共用:透明支持明文 .jsonl 与 zstd 压缩的 session.jsonl.zstd
5
+ * (真实 DSH 部署中会话日志为 ~/.dsh/sessions/<user>/<session>/session.jsonl.zstd)。
6
+ *
7
+ * 依赖系统 zstd 命令解压(macOS: brew install zstd;Debian: apt install zstd)。
8
+ * 二进制不可用时抛 code='ZSTD_UNAVAILABLE',检查应转为 skip 而非误报通过。
9
+ */
10
+
11
+ import { createReadStream } from 'node:fs';
12
+ import { spawn } from 'node:child_process';
13
+ import { createInterface } from 'node:readline';
14
+
15
+ export function isZstdFile(sessionFile) {
16
+ return typeof sessionFile === 'string' && (sessionFile.endsWith('.zstd') || sessionFile.endsWith('.zst'));
17
+ }
18
+
19
+ function openStream(sessionFile) {
20
+ if (isZstdFile(sessionFile)) {
21
+ const child = spawn('zstd', ['-dc', sessionFile], { stdio: ['ignore', 'pipe', 'pipe'] });
22
+ let stderrTail = '';
23
+ let spawnErr = null;
24
+ child.stderr.on('data', (d) => { stderrTail += String(d); });
25
+ const settled = new Promise((resolve) => {
26
+ child.once('error', (e) => { spawnErr = e; resolve(); });
27
+ child.once('close', () => resolve());
28
+ });
29
+ return { stream: child.stdout, settled, getErr: () => (spawnErr ? spawnErr : stderrTail.trim() || null) };
30
+ }
31
+ return { stream: createReadStream(sessionFile, { encoding: 'utf8' }), settled: Promise.resolve(), getErr: () => null };
32
+ }
33
+
34
+ /**
35
+ * 逐行扫描会话文件(自动解压 zstd)。
36
+ * @param {string} sessionFile 会话文件路径
37
+ * @param {(line: string, lineNo: number) => void|Promise<void>} onLine 每行回调
38
+ * @returns {Promise<number>} 实际读取的总行数
39
+ */
40
+ export async function scanSessionLines(sessionFile, onLine) {
41
+ const { stream, settled, getErr } = openStream(sessionFile);
42
+ const rl = createInterface({ input: stream, crlfDelay: Infinity });
43
+ let lineCount = 0;
44
+ try {
45
+ for await (const line of rl) {
46
+ lineCount++;
47
+ await onLine(line, lineCount);
48
+ }
49
+ } finally {
50
+ await settled.catch(() => {});
51
+ if (typeof stream.destroy === 'function') stream.destroy();
52
+ }
53
+ const err = getErr();
54
+ if (err) {
55
+ if (err.code === 'ENOENT') {
56
+ const e = new Error('zstd 命令不可用(PATH 中未找到),无法解压压缩会话日志');
57
+ e.code = 'ZSTD_UNAVAILABLE';
58
+ throw e;
59
+ }
60
+ throw new Error(`会话日志读取失败:${String(err).split('\n').pop().slice(0, 100)}`);
61
+ }
62
+ return lineCount;
63
+ }