@moonquake2004/dsh-security 0.1.0 → 0.1.1

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.
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'),
@@ -109,8 +156,13 @@ export async function createDefaultRegistry(loadExternal = true) {
109
156
  }
110
157
  }
111
158
 
112
- // 加载外部集成
113
- if (loadExternal) {
159
+ // Plugin Interface:仅在调用方显式给出目录时启用(安全考虑,不默认扫描)
160
+ const extDir = typeof loadExternal === 'object' ? loadExternal.externalChecksDir : options.externalChecksDir;
161
+ if (extDir) registry.loadExternalChecks(extDir);
162
+
163
+ // 自动探测外部工具集成(dsh-poison-guard 等)
164
+ const wantIntegrations = typeof loadExternal === 'object' ? (loadExternal.integrations !== false) : loadExternal;
165
+ if (wantIntegrations) {
114
166
  try {
115
167
  const { getAvailableIntegrations } = await import('./integrations/index.mjs');
116
168
  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
+ }