dsh-code-security 0.3.6 → 0.3.8

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/lib/state.js CHANGED
@@ -6,11 +6,13 @@
6
6
  */
7
7
  import fs from 'node:fs/promises';
8
8
  import path from 'node:path';
9
- import { createHash } from 'node:crypto';
9
+ import { createHash, randomUUID } from 'node:crypto';
10
+ import lockfile from 'proper-lockfile';
10
11
  const STATE_FILE = 'state.json';
11
12
  const HISTORY_LIMIT = 50;
12
13
  export function findingFingerprint(finding) {
13
- const raw = finding.ruleId + '|' + finding.file + '|' + finding.snippet.trim();
14
+ const file = finding.file.split(path.sep).join('/').replace(/^(?:\.\/)+/, '');
15
+ const raw = finding.ruleId + '|' + file + '|' + finding.snippet.trim();
14
16
  return createHash('sha256').update(raw).digest('hex');
15
17
  }
16
18
  export function statePath(stateDir) {
@@ -18,43 +20,96 @@ export function statePath(stateDir) {
18
20
  }
19
21
  export async function loadState(stateDir) {
20
22
  try {
23
+ const info = await fs.lstat(statePath(stateDir));
24
+ if (!info.isFile() || info.isSymbolicLink() || info.size > 16 * 1024 * 1024)
25
+ throw new Error('记录不是普通文件或超过 16 MiB');
21
26
  const text = await fs.readFile(statePath(stateDir), 'utf8');
22
27
  const parsed = JSON.parse(text);
23
28
  const obj = (parsed ?? {});
29
+ if (!Array.isArray(obj.history) || obj.history.some(entry => !isEntry(entry)) || obj.last !== null && !isEntry(obj.last) || obj.baseline !== undefined && obj.baseline !== null && !isBaseline(obj.baseline))
30
+ throw new Error('记录结构无效');
24
31
  const last = isEntry(obj.last) ? obj.last : null;
25
32
  const history = Array.isArray(obj.history) ? obj.history.filter(isEntry) : [];
26
33
  const baseline = isBaseline(obj.baseline) ? obj.baseline : null;
27
34
  return { last, history, baseline };
28
35
  }
29
- catch {
36
+ catch (error) {
37
+ if (error.code !== 'ENOENT')
38
+ throw new Error('安全扫描记录无法读取,原文件已保留:' + (error instanceof Error ? error.message : String(error)));
30
39
  return { last: null, history: [], baseline: null };
31
40
  }
32
41
  }
33
42
  function isEntry(value) {
34
- return typeof value === 'object' && value !== null && Array.isArray(value.findings);
43
+ if (typeof value !== 'object' || value === null)
44
+ return false;
45
+ const row = value;
46
+ return typeof row.mode === 'string' && typeof row.target === 'string' && typeof row.time === 'string'
47
+ && Number.isInteger(row.filesScanned) && row.filesScanned >= 0 && validFindings(row.findings)
48
+ && validFingerprints(row.fingerprints) && validCounts(row.counts);
35
49
  }
36
50
  function isBaseline(value) {
37
- return typeof value === 'object' && value !== null && Array.isArray(value.findings) && Array.isArray(value.fingerprints);
51
+ if (typeof value !== 'object' || value === null)
52
+ return false;
53
+ const row = value;
54
+ return typeof row.time === 'string' && typeof row.target === 'string' && typeof row.reason === 'string'
55
+ && validFindings(row.findings) && validFingerprints(row.fingerprints) && validCounts(row.counts);
38
56
  }
39
- export async function saveState(stateDir, entry) {
57
+ function validFingerprints(value) { return Array.isArray(value) && value.every(item => typeof item === 'string'); }
58
+ function validCounts(value) {
59
+ if (typeof value !== 'object' || value === null)
60
+ return false;
61
+ const counts = value;
62
+ return ['critical', 'high', 'medium', 'low'].every(key => typeof counts[key] === 'number' && Number.isInteger(counts[key]) && counts[key] >= 0);
63
+ }
64
+ function validFindings(value) {
65
+ return Array.isArray(value) && value.every(item => typeof item === 'object' && item !== null
66
+ && typeof item.ruleId === 'string' && typeof item.file === 'string' && typeof item.snippet === 'string'
67
+ && Number.isInteger(item.line) && item.line >= 1 && ['critical', 'high', 'medium', 'low'].includes(item.severity));
68
+ }
69
+ export async function saveState(stateDir, entry, signal) {
70
+ signal?.throwIfAborted();
40
71
  await fs.mkdir(stateDir, { recursive: true });
41
- const state = await loadState(stateDir);
42
- state.last = entry;
43
- state.history.unshift(entry);
44
- state.history = state.history.slice(0, HISTORY_LIMIT);
45
- return writeState(stateDir, state);
72
+ const unlock = await lockfile.lock(stateDir, { retries: { retries: 30, minTimeout: 25, maxTimeout: 100 } });
73
+ try {
74
+ signal?.throwIfAborted();
75
+ const state = await loadState(stateDir);
76
+ state.last = entry;
77
+ state.history.unshift(entry);
78
+ state.history = state.history.slice(0, HISTORY_LIMIT);
79
+ return await writeState(stateDir, state, signal);
80
+ }
81
+ finally {
82
+ await unlock();
83
+ }
46
84
  }
47
- export async function saveBaseline(stateDir, baseline) {
85
+ export async function saveBaseline(stateDir, baseline, signal) {
86
+ signal?.throwIfAborted();
48
87
  await fs.mkdir(stateDir, { recursive: true });
49
- const state = await loadState(stateDir);
50
- state.baseline = baseline;
51
- return writeState(stateDir, state);
88
+ const unlock = await lockfile.lock(stateDir, { retries: { retries: 30, minTimeout: 25, maxTimeout: 100 } });
89
+ try {
90
+ signal?.throwIfAborted();
91
+ const state = await loadState(stateDir);
92
+ state.baseline = baseline;
93
+ return await writeState(stateDir, state, signal);
94
+ }
95
+ finally {
96
+ await unlock();
97
+ }
52
98
  }
53
- async function writeState(stateDir, state) {
99
+ async function writeState(stateDir, state, signal) {
100
+ signal?.throwIfAborted();
54
101
  const file = statePath(stateDir);
55
- const tmp = file + '.tmp-' + process.pid;
56
- await fs.writeFile(tmp, JSON.stringify(state, null, 2), 'utf8');
57
- await fs.rename(tmp, file);
102
+ const tmp = file + '.tmp-' + randomUUID(), bytes = JSON.stringify(state, null, 2);
103
+ if (Buffer.byteLength(bytes) > 16 * 1024 * 1024)
104
+ throw new Error('安全扫描记录超过 16 MiB,本次没有保存。');
105
+ try {
106
+ await fs.writeFile(tmp, bytes, { flag: 'wx', mode: 0o600, signal });
107
+ signal?.throwIfAborted();
108
+ await fs.rename(tmp, file);
109
+ }
110
+ finally {
111
+ await fs.unlink(tmp).catch(() => { });
112
+ }
58
113
  return file;
59
114
  }
60
115
  export function countFindings(findings) {
@@ -76,7 +131,8 @@ export function compareFingerprints(baseline, current) {
76
131
  export function splitByBaseline(findings, baseline) {
77
132
  if (baseline === null)
78
133
  return { fresh: findings, accepted: [] };
79
- const acceptedSet = new Set(baseline.fingerprints);
134
+ // Recompute from saved evidence so older "./file" fingerprints remain usable.
135
+ const acceptedSet = new Set(baseline.findings.map(findingFingerprint));
80
136
  const fresh = [];
81
137
  const accepted = [];
82
138
  for (const finding of findings) {
package/lib/tools.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
- * 九个面向模型的安全审查工具:
2
+ * 十个面向模型的安全审查工具:
3
3
  * secure_scan / secure_diff / secure_fix_verify / secure_report / secure_export /
4
- * secure_baseline / secure_deps / secure_policy_show / secure_policy_set。
4
+ * secure_baseline / secure_deps / secure_policy_show / secure_policy_set / secure_health。
5
5
  *
6
6
  * @module dsh-code-security/tools
7
7
  */
package/lib/tools.js CHANGED
@@ -1,11 +1,12 @@
1
1
  /**
2
- * 九个面向模型的安全审查工具:
2
+ * 十个面向模型的安全审查工具:
3
3
  * secure_scan / secure_diff / secure_fix_verify / secure_report / secure_export /
4
- * secure_baseline / secure_deps / secure_policy_show / secure_policy_set。
4
+ * secure_baseline / secure_deps / secure_policy_show / secure_policy_set / secure_health。
5
5
  *
6
6
  * @module dsh-code-security/tools
7
7
  */
8
8
  import { writeFile } from 'node:fs/promises';
9
+ import { readFileSync } from 'node:fs';
9
10
  import path from 'node:path';
10
11
  import { pathToFileURL } from 'node:url';
11
12
  import { optionalString, requiredString } from './config.js';
@@ -16,6 +17,7 @@ import { ruleCategory } from './rules.js';
16
17
  import { scanPath } from './scanner.js';
17
18
  import { compareFingerprints, countFindings, findingFingerprint, loadState, saveState, splitByBaseline } from './state.js';
18
19
  const SEVERITY_ORDER = { critical: 4, high: 3, medium: 2, low: 1 };
20
+ const PACKAGE_VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
19
21
  function compileParameters(spec) {
20
22
  const properties = {};
21
23
  const required = [];
@@ -46,6 +48,12 @@ function scanArguments(raw, allowed = ['target']) {
46
48
  if (Object.hasOwn(args, 'target') && (typeof args.target !== 'string' || args.target.trim() === '')) {
47
49
  throw new Error('扫描参数 target 必须是非空的文件或目录字符串。扫描整个工作区请省略 target 或使用 "."。');
48
50
  }
51
+ if (Object.hasOwn(args, 'base') && (typeof args.base !== 'string' || args.base.trim() === '' || args.base.trim().startsWith('-') || args.base.includes('\0'))) {
52
+ throw new Error('扫描参数 base 必须是非空的 Git ref,不能是 Git 命令选项。');
53
+ }
54
+ if (Object.hasOwn(args, 'staged') && typeof args.staged !== 'boolean') {
55
+ throw new Error('扫描参数 staged 必须是布尔值 true 或 false。');
56
+ }
49
57
  return args;
50
58
  }
51
59
  const findingSchema = {
@@ -66,6 +74,7 @@ const scanSchema = {
66
74
  type: 'object',
67
75
  properties: {
68
76
  root: { type: 'string' }, filesScanned: { type: 'integer' }, filesSkipped: { type: 'integer' },
77
+ filesChanged: { type: 'integer' }, addedLines: { type: 'integer' },
69
78
  findings: { type: 'array', items: findingSchema }, counts: countsSchema, passed: { type: 'boolean' },
70
79
  failOn: { type: 'string' }, durationMs: { type: 'integer' },
71
80
  newFindings: { type: 'array', items: findingSchema }, newCounts: countsSchema,
@@ -94,12 +103,26 @@ function renderFindings(_args, value) {
94
103
  '安全审查:' + (rec.passed === true ? '通过' : '未通过'),
95
104
  '文件 ' + String(rec.filesScanned ?? 0) + ' 个,发现 ' + findings.length + ' 个问题(critical ' + String(counts?.critical ?? 0) + ' / high ' + String(counts?.high ?? 0) + ' / medium ' + String(counts?.medium ?? 0) + ' / low ' + String(counts?.low ?? 0) + ');基线外新增 ' + split.fresh.length + ' 个。',
96
105
  ];
106
+ if (typeof rec.filesSkipped === 'number' && rec.filesSkipped > 0)
107
+ lines.push('已跳过 ' + rec.filesSkipped + ' 项;跳过项不计入已扫描文件数。');
97
108
  for (const finding of findings) {
98
109
  lines.push('- ' + finding.file + ':' + finding.line + ' [' + finding.severity + '] ' + finding.ruleId + ' ' + finding.title);
99
110
  }
100
111
  return [{ type: 'text', text: lines.join('\n') }];
101
112
  }
102
113
  export function buildSecureTools(cfg, cwd, runner) {
114
+ return buildWorkspaceTools(cfg, cwd, runner).map(tool => ({ ...tool, async execute(args, exec) {
115
+ const context = exec;
116
+ const signal = context?.signal;
117
+ signal?.throwIfAborted();
118
+ const workspace = context?.agent?.session?.header?.cwd || cwd;
119
+ const resolved = { ...cfg, stateDir: cfg.stateDirRelative ? path.resolve(workspace, cfg.stateDirRelative) : cfg.stateDir };
120
+ const scopedRunner = { run: (argv, options) => runner.run(argv, { ...options, cwd: workspace, signal }) };
121
+ const selected = buildWorkspaceTools(resolved, workspace, scopedRunner, signal).find(row => row.name === tool.name);
122
+ return selected.execute(args, exec);
123
+ } }));
124
+ }
125
+ function buildWorkspaceTools(cfg, cwd, runner, signal) {
103
126
  const stateDir = cfg.stateDir;
104
127
  const secureScan = {
105
128
  name: 'secure_scan',
@@ -113,7 +136,7 @@ export function buildSecureTools(cfg, cwd, runner) {
113
136
  const target = optionalString(args, 'target');
114
137
  const policy = await loadPolicy(cwd);
115
138
  const failOn = policy.failOn ?? cfg.failOn;
116
- const result = await scanPath({ cwd, target, maxFiles: cfg.maxFiles, maxFileBytes: cfg.maxFileBytes, policy });
139
+ const result = await scanPath({ cwd, target, maxFiles: cfg.maxFiles, maxFileBytes: cfg.maxFileBytes, policy, signal });
117
140
  const state = await loadState(stateDir);
118
141
  const counts = countFindings(result.findings);
119
142
  const split = splitByBaseline(result.findings, state.baseline);
@@ -126,14 +149,14 @@ export function buildSecureTools(cfg, cwd, runner) {
126
149
  fingerprints: result.findings.map(findingFingerprint),
127
150
  counts,
128
151
  };
129
- const stateFile = await saveState(stateDir, entry);
152
+ const stateFile = await saveState(stateDir, entry, signal);
130
153
  return { ...result, counts, newFindings: split.fresh, newCounts: countFindings(split.fresh), acceptedFindings: split.accepted, baseline: state.baseline, passed: verdictOf(split.fresh, failOn), failOn, stateFile };
131
154
  },
132
155
  timeoutMs: 120000,
133
156
  };
134
157
  const secureDiff = {
135
158
  name: 'secure_diff',
136
- description: '只审查 git diff 的新增行(默认 HEAD,即未提交改动)。base 可用任意 git ref;staged=true 审查已暂存改动;target 可限定文件。结果同样写入状态。',
159
+ description: '结合少量上下文审查 git diff,只报告涉及新增行的问题(默认 HEAD,即未提交改动)。base 可用 Git ref;staged=true 审查已暂存改动;target 可限定文件。范围与路径相对当前会话工作区,遵守项目排除策略。结果同样写入状态。',
137
160
  parameters: { ...compileParameters({
138
161
  base: { type: 'string', description: 'git 基线(默认 HEAD)。' },
139
162
  target: { type: 'string', description: '限定文件路径(可选)。' },
@@ -155,13 +178,13 @@ export function buildSecureTools(cfg, cwd, runner) {
155
178
  mode: 'diff:' + base,
156
179
  target: target ?? '.',
157
180
  time: new Date().toISOString(),
158
- filesScanned: result.filesChanged,
181
+ filesScanned: result.filesScanned,
159
182
  findings: result.findings,
160
183
  fingerprints: result.findings.map(findingFingerprint),
161
184
  counts,
162
185
  };
163
- await saveState(stateDir, entry);
164
- return { root: base, filesScanned: result.filesChanged, filesSkipped: 0, addedLines: result.addedLines, findings: result.findings, counts, newFindings: split.fresh, newCounts: countFindings(split.fresh), acceptedFindings: split.accepted, baseline: state.baseline, passed: verdictOf(split.fresh, failOn), failOn, durationMs: 0 };
186
+ await saveState(stateDir, entry, signal);
187
+ return { root: base, filesChanged: result.filesChanged, filesScanned: result.filesScanned, filesSkipped: result.filesSkipped, addedLines: result.addedLines, findings: result.findings, counts, newFindings: split.fresh, newCounts: countFindings(split.fresh), acceptedFindings: split.accepted, baseline: state.baseline, passed: verdictOf(split.fresh, failOn), failOn, durationMs: 0 };
165
188
  },
166
189
  timeoutMs: 60000,
167
190
  };
@@ -192,12 +215,12 @@ export function buildSecureTools(cfg, cwd, runner) {
192
215
  const target = optionalString(args, 'target');
193
216
  const policy = await loadPolicy(cwd);
194
217
  const state = await loadState(stateDir);
195
- const result = await scanPath({ cwd, target, maxFiles: cfg.maxFiles, maxFileBytes: cfg.maxFileBytes, policy });
218
+ const result = await scanPath({ cwd, target, maxFiles: cfg.maxFiles, maxFileBytes: cfg.maxFileBytes, policy, signal });
196
219
  if (state.last === null) {
197
220
  return { baselineMissing: true, baselineTime: '', closedCount: 0, remainingCount: 0, freshCount: result.findings.length, closed: [], remaining: [], fresh: result.findings, passed: false };
198
221
  }
199
222
  const current = result.findings.map(findingFingerprint);
200
- const cmp = compareFingerprints(state.last.fingerprints, current);
223
+ const cmp = compareFingerprints(state.last.findings.map(findingFingerprint), current);
201
224
  const byFingerprint = new Map(result.findings.map((finding) => [findingFingerprint(finding), finding]));
202
225
  const closed = cmp.closed.map((fp) => state.last.findings.find((finding) => findingFingerprint(finding) === fp)).filter((x) => x !== undefined);
203
226
  const remaining = cmp.remaining.map((fp) => byFingerprint.get(fp)).filter((x) => x !== undefined);
@@ -306,7 +329,7 @@ export function buildSecureTools(cfg, cwd, runner) {
306
329
  throw new Error('policy 参数不是合法 JSON。');
307
330
  }
308
331
  const policy = normalizePolicy((parsed ?? {}));
309
- const file = await savePolicy(cwd, policy);
332
+ const file = await savePolicy(cwd, policy, signal);
310
333
  return { file, policy };
311
334
  },
312
335
  timeoutMs: 10000,
@@ -337,6 +360,7 @@ export function buildSecureTools(cfg, cwd, runner) {
337
360
  if (state.last === null)
338
361
  throw new Error('尚无扫描结果,请先执行 secure_scan 或 secure_diff。');
339
362
  const text = format === 'sarif' ? buildSarif(state.last.findings, cwd) : buildMarkdown(state.last.findings, state.last.target);
363
+ signal?.throwIfAborted();
340
364
  if (target !== undefined)
341
365
  await writeFile(target, text, 'utf8');
342
366
  return { format, path: target ?? '', text, findingCount: state.last.findings.length };
@@ -369,7 +393,7 @@ export function buildSecureTools(cfg, cwd, runner) {
369
393
  return { ok: true, plugin: 'dsh-code-security', checks };
370
394
  },
371
395
  };
372
- return [secureScan, secureDiff, secureFixVerify, secureReport, secureExport, ...buildExtraTools(cfg, cwd), policyShow, policySet, secureHealth];
396
+ return [secureScan, secureDiff, secureFixVerify, secureReport, secureExport, ...buildExtraTools(cfg, cwd, signal), policyShow, policySet, secureHealth];
373
397
  }
374
398
  function buildMarkdown(findings, target) {
375
399
  const lines = ['# 安全审查报告', '', '目标:' + target, '', '| 严重度 | 规则 | CWE | 文件:行 | 说明 |', '| :-- | :-- | :-- | :-- | :-- |'];
@@ -394,7 +418,7 @@ function buildSarif(findings, cwd) {
394
418
  version: '2.1.0',
395
419
  $schema: 'https://json.schemastore.org/sarif-2.1.0.json',
396
420
  runs: [{
397
- tool: { driver: { name: 'dsh-code-security', version: '0.1.0', informationUri: 'https://github.com/STARDUSTLC666/dsh-code-security', rules: [...rules.values()] } },
421
+ tool: { driver: { name: 'dsh-code-security', version: PACKAGE_VERSION, informationUri: 'https://github.com/STARDUSTLC666/dsh-code-security', rules: [...rules.values()] } },
398
422
  originalUriBaseIds: { ROOTPATH: { uri: pathToFileURL(path.resolve(cwd) + path.sep).href } },
399
423
  results,
400
424
  }],
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-code-security",
3
- "version": "0.3.6",
4
- "description": "DSH 代码安全审查插件:40+ 确定性规则与密钥熵检测,支持 git diff/staged 审查、SARIF 导出、基线接受与 SBOM-lite。",
3
+ "version": "0.3.8",
4
+ "description": "对代码与 Git 改动进行本地静态安全检查,并追踪修复结果。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/index.d.ts",
@@ -17,7 +17,9 @@
17
17
  "lib",
18
18
  "cordis.patch.yml",
19
19
  "README.md",
20
- "README.en.md"
20
+ "README.en.md",
21
+ "docs",
22
+ "CHANGELOG.md"
21
23
  ],
22
24
  "scripts": {
23
25
  "build": "tsc -p tsconfig.json",
@@ -46,6 +48,7 @@
46
48
  },
47
49
  "devDependencies": {
48
50
  "@types/node": "^24.0.0",
51
+ "@types/proper-lockfile": "4.1.4",
49
52
  "typescript": "^5.6.0"
50
53
  },
51
54
  "repository": {
@@ -57,5 +60,8 @@
57
60
  },
58
61
  "homepage": "https://github.com/STARDUSTLC666/dsh-code-security#readme",
59
62
  "author": "stardustlc",
60
- "packageManager": "pnpm@11.7.0"
63
+ "packageManager": "pnpm@11.7.0",
64
+ "dependencies": {
65
+ "proper-lockfile": "4.1.2"
66
+ }
61
67
  }