dsh-code-security 0.3.8 → 0.4.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/lib/scanner.js CHANGED
@@ -9,6 +9,8 @@ import { createHash } from 'node:crypto';
9
9
  import { DEFAULT_EXCLUDE_DIRS } from './config.js';
10
10
  import { languageForFile, RULES } from './rules.js';
11
11
  import { policyExcludesDir, policyIgnores } from './policy.js';
12
+ import { protectFinding, isHighEntropyToken } from './privacy.js';
13
+ import { sanitizedHtmlLines } from './syntax.js';
12
14
  const DISABLE_NEXT = /secure-review-disable-next-line(?:\s+([\w,-]+))?/;
13
15
  const DISABLE_LINE = /secure-review-disable-line(?:\s+([\w,-]+))?/;
14
16
  function isComment(line) {
@@ -48,31 +50,12 @@ function shouldScanFile(file) {
48
50
  return true;
49
51
  return TEXT_EXTENSIONS.test(file);
50
52
  }
51
- /** Shannon 熵;只对 24 位以上且以字母数字为主的 token 判分。 */
52
- function shannon(text) {
53
- if (text.length < 16)
54
- return 0;
55
- const counts = new Map();
56
- for (const char of text)
57
- counts.set(char, (counts.get(char) ?? 0) + 1);
58
- let entropy = 0;
59
- for (const count of counts.values()) {
60
- const p = count / text.length;
61
- entropy -= p * Math.log2(p);
62
- }
63
- return entropy;
64
- }
65
53
  const TOKEN_PATTERN = /['"]([A-Za-z0-9+/=_-]{24,})['"]/g;
66
- const HIGH_ENTROPY_THRESHOLD = 3.8;
67
54
  function detectHighEntropy(line, lineNumber, rel, rule) {
68
55
  const findings = [];
69
56
  for (const match of line.matchAll(TOKEN_PATTERN)) {
70
57
  const token = match[1];
71
- if (/^[0-9]+$/.test(token))
72
- continue;
73
- if (!/\d/.test(token))
74
- continue;
75
- if (shannon(token) < HIGH_ENTROPY_THRESHOLD)
58
+ if (!isHighEntropyToken(token))
76
59
  continue;
77
60
  const id = makeId(rule.id, rel, lineNumber, token.slice(0, 32));
78
61
  findings.push({
@@ -138,7 +121,7 @@ function scanFileRule(entries, rel, rule, disabledNext, selectedLines) {
138
121
  return checkBlock();
139
122
  }
140
123
  /** 对带行号的文本行应用规则(全量扫描与 git diff 增量扫描共用)。 */
141
- export function scanLineSet(entries, rel, policy, selectedLines) {
124
+ export function scanLineSet(entries, rel, policy, selectedLines, sanitized = new Set()) {
142
125
  const language = languageForFile(rel);
143
126
  const findings = [];
144
127
  const applied = RULES.filter((rule) => (rule.languages.includes(language) || rule.languages.includes('any')) && !policyIgnores(policy, rule.id, rel));
@@ -156,6 +139,8 @@ export function scanLineSet(entries, rel, policy, selectedLines) {
156
139
  for (const rule of applied) {
157
140
  if (rule.id === 'SEC-304')
158
141
  continue;
142
+ if (rule.id === 'SEC-009' && sanitized.has(entry.line))
143
+ continue;
159
144
  if (shouldSkipLine(rule, currentDisable) || shouldSkipLine(rule, prevDisable.get(entry.line)))
160
145
  continue;
161
146
  if (rule.fileLevel === true)
@@ -188,7 +173,7 @@ export function scanLineSet(entries, rel, policy, selectedLines) {
188
173
  if (finding !== undefined)
189
174
  findings.push(finding);
190
175
  }
191
- return findings;
176
+ return findings.map(finding => protectFinding({ ...finding, engine: 'builtin', evidence: 'pattern' }));
192
177
  }
193
178
  /** 扫描单个文件内容,按语言与行应用规则。 */
194
179
  export function scanText(content, file, rel, policy) {
@@ -196,7 +181,7 @@ export function scanText(content, file, rel, policy) {
196
181
  const entries = lines.map((text, index) => ({ line: index + 1, text }));
197
182
  // Match file rules over contiguous lines, sharing locations and suppressions
198
183
  // with incremental scans without joining unrelated diff hunks.
199
- return scanLineSet(entries, rel, policy);
184
+ return scanLineSet(entries, rel, policy, undefined, languageForFile(rel) === 'javascript' ? sanitizedHtmlLines(content) : new Set());
200
185
  }
201
186
  /** Apply the same exclusions to full scans, directly selected files and diffs. */
202
187
  export function isExcludedPath(rel, policy) {
@@ -211,6 +196,10 @@ export async function scanPath(options) {
211
196
  let filesSkipped = 0;
212
197
  const findings = [];
213
198
  const relRoot = path.relative(cwd, root).split(path.sep).join('/');
199
+ let complete = true;
200
+ const warnings = [];
201
+ const incomplete = (message) => { complete = false; if (warnings.length < 50)
202
+ warnings.push(message); };
214
203
  const visit = async (absolute, rel) => {
215
204
  options.signal?.throwIfAborted();
216
205
  if (isExcludedPath(rel, policy)) {
@@ -223,11 +212,14 @@ export async function scanPath(options) {
223
212
  }
224
213
  catch {
225
214
  filesSkipped += 1;
215
+ incomplete('无法读取 ' + rel);
226
216
  return;
227
217
  }
228
218
  options.signal?.throwIfAborted();
229
219
  if (stat.isSymbolicLink()) {
230
220
  filesSkipped += 1;
221
+ if (absolute === root)
222
+ incomplete('目标为链接,未读取链接内容');
231
223
  return;
232
224
  }
233
225
  if (stat.isDirectory()) {
@@ -239,11 +231,14 @@ export async function scanPath(options) {
239
231
  }
240
232
  catch {
241
233
  filesSkipped += 1;
234
+ incomplete('无法遍历 ' + rel);
242
235
  return;
243
236
  }
244
237
  for (const entry of entries) {
245
- if (filesScanned >= maxFiles)
238
+ if (filesScanned >= maxFiles) {
239
+ incomplete('已达到文件数量上限');
246
240
  return;
241
+ }
247
242
  await visit(path.join(absolute, entry.name), rel === '' ? entry.name : rel + '/' + entry.name);
248
243
  }
249
244
  return;
@@ -254,6 +249,7 @@ export async function scanPath(options) {
254
249
  }
255
250
  if (stat.size > maxFileBytes) {
256
251
  filesSkipped += 1;
252
+ incomplete(rel + ' 超过文件大小上限');
257
253
  return;
258
254
  }
259
255
  let buffer;
@@ -262,6 +258,7 @@ export async function scanPath(options) {
262
258
  }
263
259
  catch {
264
260
  filesSkipped += 1;
261
+ incomplete('无法读取 ' + rel);
265
262
  return;
266
263
  }
267
264
  if (fileHasBinary(buffer)) {
@@ -271,11 +268,12 @@ export async function scanPath(options) {
271
268
  filesScanned += 1;
272
269
  options.signal?.throwIfAborted();
273
270
  const text = new TextDecoder('utf-8', { fatal: false }).decode(buffer);
271
+ options.onDocument?.({ file: rel, text });
274
272
  findings.push(...scanText(text, absolute, rel, policy));
275
273
  };
276
274
  await visit(root, relRoot);
277
275
  findings.sort((a, b) => severityRank(b.severity) - severityRank(a.severity) || a.file.localeCompare(b.file) || a.line - b.line);
278
- return { root, filesScanned, filesSkipped, findings, durationMs: Date.now() - started };
276
+ return { root, filesScanned, filesSkipped, findings, durationMs: Date.now() - started, complete, warnings };
279
277
  }
280
278
  function severityRank(severity) {
281
279
  return severity === 'critical' ? 4 : severity === 'high' ? 3 : severity === 'medium' ? 2 : 1;
package/lib/state.d.ts CHANGED
@@ -5,7 +5,14 @@
5
5
  * @module dsh-code-security/state
6
6
  */
7
7
  import type { Finding } from './scanner.js';
8
+ import type { SourceEngine } from './config.js';
9
+ import type { EngineCoverage } from './engines.js';
10
+ import type { ScanScope } from './audit.js';
8
11
  export interface ScanStateEntry {
12
+ complete?: boolean;
13
+ coverage?: EngineCoverage[];
14
+ engines?: SourceEngine[];
15
+ scope?: ScanScope;
9
16
  mode: string;
10
17
  target: string;
11
18
  time: string;
package/lib/state.js CHANGED
@@ -6,19 +6,33 @@
6
6
  */
7
7
  import fs from 'node:fs/promises';
8
8
  import path from 'node:path';
9
- import { createHash, randomUUID } from 'node:crypto';
9
+ import { randomUUID } from 'node:crypto';
10
10
  import lockfile from 'proper-lockfile';
11
+ import { evidenceFingerprint, protectFinding, redactText } from './privacy.js';
11
12
  const STATE_FILE = 'state.json';
12
13
  const HISTORY_LIMIT = 50;
13
14
  export function findingFingerprint(finding) {
14
- const file = finding.file.split(path.sep).join('/').replace(/^(?:\.\/)+/, '');
15
- const raw = finding.ruleId + '|' + file + '|' + finding.snippet.trim();
16
- return createHash('sha256').update(raw).digest('hex');
15
+ return evidenceFingerprint(finding);
17
16
  }
18
17
  export function statePath(stateDir) {
19
18
  return path.join(stateDir, STATE_FILE);
20
19
  }
21
20
  export async function loadState(stateDir) {
21
+ const initial = await readState(stateDir);
22
+ const protectedState = protectState(initial);
23
+ if (JSON.stringify(initial) === JSON.stringify(protectedState))
24
+ return protectedState;
25
+ const unlock = await lockfile.lock(stateDir, { retries: { retries: 30, minTimeout: 25, maxTimeout: 100 } });
26
+ try {
27
+ const state = protectState(await readState(stateDir));
28
+ await writeState(stateDir, state);
29
+ return state;
30
+ }
31
+ finally {
32
+ await unlock();
33
+ }
34
+ }
35
+ async function readState(stateDir) {
22
36
  try {
23
37
  const info = await fs.lstat(statePath(stateDir));
24
38
  if (!info.isFile() || info.isSymbolicLink() || info.size > 16 * 1024 * 1024)
@@ -39,6 +53,13 @@ export async function loadState(stateDir) {
39
53
  return { last: null, history: [], baseline: null };
40
54
  }
41
55
  }
56
+ function protectState(state) {
57
+ const clean = (entry) => {
58
+ const findings = entry.findings.map(protectFinding);
59
+ return { ...entry, ...('reason' in entry ? { reason: redactText(entry.reason) } : {}), findings, fingerprints: findings.map(findingFingerprint) };
60
+ };
61
+ return { last: state.last ? clean(state.last) : null, history: state.history.map(clean), baseline: state.baseline ? clean(state.baseline) : null };
62
+ }
42
63
  function isEntry(value) {
43
64
  if (typeof value !== 'object' || value === null)
44
65
  return false;
@@ -72,7 +93,7 @@ export async function saveState(stateDir, entry, signal) {
72
93
  const unlock = await lockfile.lock(stateDir, { retries: { retries: 30, minTimeout: 25, maxTimeout: 100 } });
73
94
  try {
74
95
  signal?.throwIfAborted();
75
- const state = await loadState(stateDir);
96
+ const state = protectState(await readState(stateDir));
76
97
  state.last = entry;
77
98
  state.history.unshift(entry);
78
99
  state.history = state.history.slice(0, HISTORY_LIMIT);
@@ -88,7 +109,7 @@ export async function saveBaseline(stateDir, baseline, signal) {
88
109
  const unlock = await lockfile.lock(stateDir, { retries: { retries: 30, minTimeout: 25, maxTimeout: 100 } });
89
110
  try {
90
111
  signal?.throwIfAborted();
91
- const state = await loadState(stateDir);
112
+ const state = protectState(await readState(stateDir));
92
113
  state.baseline = baseline;
93
114
  return await writeState(stateDir, state, signal);
94
115
  }
@@ -99,7 +120,7 @@ export async function saveBaseline(stateDir, baseline, signal) {
99
120
  async function writeState(stateDir, state, signal) {
100
121
  signal?.throwIfAborted();
101
122
  const file = statePath(stateDir);
102
- const tmp = file + '.tmp-' + randomUUID(), bytes = JSON.stringify(state, null, 2);
123
+ const tmp = file + '.tmp-' + randomUUID(), bytes = JSON.stringify(protectState(state), null, 2);
103
124
  if (Buffer.byteLength(bytes) > 16 * 1024 * 1024)
104
125
  throw new Error('安全扫描记录超过 16 MiB,本次没有保存。');
105
126
  try {
@@ -0,0 +1 @@
1
+ export declare function sanitizedHtmlLines(content: string): Set<number>;
package/lib/syntax.js ADDED
@@ -0,0 +1,43 @@
1
+ /** A narrow sanitizer check with real lexical bindings, never string-based suppression. */
2
+ import { parse } from '@babel/parser';
3
+ import traverseModule from '@babel/traverse';
4
+ const traverse = (traverseModule.default ?? traverseModule);
5
+ export function sanitizedHtmlLines(content) {
6
+ const sinks = new Map();
7
+ const add = (line, safe) => { if (line)
8
+ sinks.set(line, [...sinks.get(line) ?? [], safe]); };
9
+ function trustedSanitizer(node, at) {
10
+ if (node?.type !== 'CallExpression' || node.arguments.length !== 1 || node.callee.type !== 'MemberExpression' || node.callee.computed || node.callee.object.type !== 'Identifier' || node.callee.property.type !== 'Identifier' || node.callee.property.name !== 'sanitize')
11
+ return false;
12
+ const binding = at.scope.getBinding(node.callee.object.name);
13
+ if (!binding?.constant || !binding.path.isImportDefaultSpecifier() && !binding.path.isImportNamespaceSpecifier())
14
+ return false;
15
+ if (!binding.path.parentPath.isImportDeclaration() || binding.path.parentPath.node.source.value !== 'dompurify')
16
+ return false;
17
+ return binding.referencePaths.every(ref => {
18
+ const member = ref.parentPath, call = member?.parentPath;
19
+ return member?.isMemberExpression() && !member.node.computed && member.node.property.type === 'Identifier' && member.node.property.name === 'sanitize' && call?.isCallExpression() && call.node.callee === member.node;
20
+ });
21
+ }
22
+ try {
23
+ const ast = parse(content, { sourceType: 'unambiguous', plugins: ['typescript', 'jsx'], allowReturnOutsideFunction: true });
24
+ traverse(ast, {
25
+ AssignmentExpression(at) {
26
+ const left = at.node.left;
27
+ if (left.type === 'MemberExpression' && !left.computed && left.property.type === 'Identifier' && left.property.name === 'innerHTML')
28
+ add(left.loc?.start.line, trustedSanitizer(at.node.right, at));
29
+ },
30
+ JSXAttribute(at) {
31
+ if (at.node.name.type !== 'JSXIdentifier' || at.node.name.name !== 'dangerouslySetInnerHTML')
32
+ return;
33
+ const expression = at.node.value?.type === 'JSXExpressionContainer' ? at.node.value.expression : undefined;
34
+ const html = expression?.type === 'ObjectExpression' ? expression.properties.find(prop => prop.type === 'ObjectProperty' && !prop.computed && prop.key.type === 'Identifier' && prop.key.name === '__html') : undefined;
35
+ add(at.node.loc?.start.line, html?.type === 'ObjectProperty' && trustedSanitizer(html.value, at));
36
+ },
37
+ });
38
+ }
39
+ catch {
40
+ return new Set();
41
+ }
42
+ return new Set([...sinks].filter(([, safe]) => safe.length > 0 && safe.every(Boolean)).map(([line]) => line));
43
+ }
package/lib/tools.d.ts CHANGED
@@ -27,4 +27,6 @@ export interface SecureToolDefinition {
27
27
  execute(args: unknown, exec: unknown): Promise<unknown>;
28
28
  timeoutMs?: number;
29
29
  }
30
+ /** Reject ambiguous scopes before reading files, running git, or replacing scan state. */
31
+ export declare function scanArguments(raw: unknown, allowed?: readonly string[]): Record<string, unknown>;
30
32
  export declare function buildSecureTools(cfg: ResolvedSecureConfig, cwd: string, runner: ProcessRunner): SecureToolDefinition[];
package/lib/tools.js CHANGED
@@ -11,11 +11,11 @@ import path from 'node:path';
11
11
  import { pathToFileURL } from 'node:url';
12
12
  import { optionalString, requiredString } from './config.js';
13
13
  import { buildExtraTools } from './extra.js';
14
- import { runGitDiff, scanDiff } from './diff.js';
15
14
  import { loadPolicy, normalizePolicy, POLICY_FILE, savePolicy } from './policy.js';
16
15
  import { ruleCategory } from './rules.js';
17
- import { scanPath } from './scanner.js';
18
16
  import { compareFingerprints, countFindings, findingFingerprint, loadState, saveState, splitByBaseline } from './state.js';
17
+ import { runSourceCheck, sourceEngines } from './audit.js';
18
+ import { probeEngine, databaseHealth } from './engines.js';
19
19
  const SEVERITY_ORDER = { critical: 4, high: 3, medium: 2, low: 1 };
20
20
  const PACKAGE_VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
21
21
  function compileParameters(spec) {
@@ -31,12 +31,14 @@ function compileParameters(spec) {
31
31
  node.description = prop.description;
32
32
  if (prop?.enum !== undefined)
33
33
  node.enum = prop.enum;
34
+ if (prop?.items !== undefined)
35
+ node.items = prop.items;
34
36
  properties[key] = node;
35
37
  }
36
38
  return { type: 'object', properties, ...(required.length > 0 ? { required } : {}) };
37
39
  }
38
40
  /** Reject ambiguous scopes before reading files, running git, or replacing scan state. */
39
- function scanArguments(raw, allowed = ['target']) {
41
+ export function scanArguments(raw, allowed = ['target', 'engines']) {
40
42
  if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
41
43
  throw new Error('扫描参数必须是对象。请用 { target: "文件或目录" } 限定范围;扫描整个工作区请传 {}。');
42
44
  }
@@ -54,6 +56,8 @@ function scanArguments(raw, allowed = ['target']) {
54
56
  if (Object.hasOwn(args, 'staged') && typeof args.staged !== 'boolean') {
55
57
  throw new Error('扫描参数 staged 必须是布尔值 true 或 false。');
56
58
  }
59
+ if (Object.hasOwn(args, 'includeUntracked') && typeof args.includeUntracked !== 'boolean')
60
+ throw new Error('includeUntracked 必须是布尔值');
57
61
  return args;
58
62
  }
59
63
  const findingSchema = {
@@ -117,7 +121,7 @@ export function buildSecureTools(cfg, cwd, runner) {
117
121
  signal?.throwIfAborted();
118
122
  const workspace = context?.agent?.session?.header?.cwd || cwd;
119
123
  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 }) };
124
+ const scopedRunner = { run: (argv, options) => runner.run(argv, { ...options, cwd: options?.cwd ?? workspace, signal: options?.signal && signal ? AbortSignal.any([options.signal, signal]) : options?.signal ?? signal }) };
121
125
  const selected = buildWorkspaceTools(resolved, workspace, scopedRunner, signal).find(row => row.name === tool.name);
122
126
  return selected.execute(args, exec);
123
127
  } }));
@@ -129,6 +133,7 @@ function buildWorkspaceTools(cfg, cwd, runner, signal) {
129
133
  description: '扫描工作区(或指定文件/目录),用确定性规则检测注入、弱加密、硬编码密钥、危险配置等问题。使用 target 字符串限定范围,不接受 paths/files 等参数;{} 表示整个工作区。结果写入 .code-security 状态供 secure_fix_verify 对比。',
130
134
  parameters: { ...compileParameters({
131
135
  target: { type: 'string', description: '要扫描的文件或目录(可选,缺省扫描整个工作区)。' },
136
+ engines: { type: 'array', items: { type: 'string', enum: ['builtin', 'betterleaks', 'semgrep'] }, description: '可选引擎,默认使用配置(初始仅 builtin)。' },
132
137
  }), additionalProperties: false },
133
138
  output: { schema: scanSchema, render: renderFindings },
134
139
  async execute(rawArgs) {
@@ -136,7 +141,8 @@ function buildWorkspaceTools(cfg, cwd, runner, signal) {
136
141
  const target = optionalString(args, 'target');
137
142
  const policy = await loadPolicy(cwd);
138
143
  const failOn = policy.failOn ?? cfg.failOn;
139
- const result = await scanPath({ cwd, target, maxFiles: cfg.maxFiles, maxFileBytes: cfg.maxFileBytes, policy, signal });
144
+ const engines = sourceEngines(args.engines, cfg.engines);
145
+ const result = await runSourceCheck({ kind: 'scan', ...(target ? { target } : {}) }, cfg, cwd, runner, engines, policy, signal);
140
146
  const state = await loadState(stateDir);
141
147
  const counts = countFindings(result.findings);
142
148
  const split = splitByBaseline(result.findings, state.baseline);
@@ -148,9 +154,10 @@ function buildWorkspaceTools(cfg, cwd, runner, signal) {
148
154
  findings: result.findings,
149
155
  fingerprints: result.findings.map(findingFingerprint),
150
156
  counts,
157
+ complete: result.complete, coverage: result.coverage, engines, scope: result.scope,
151
158
  };
152
159
  const stateFile = await saveState(stateDir, entry, signal);
153
- return { ...result, counts, newFindings: split.fresh, newCounts: countFindings(split.fresh), acceptedFindings: split.accepted, baseline: state.baseline, passed: verdictOf(split.fresh, failOn), failOn, stateFile };
160
+ return { ...result, counts, newFindings: split.fresh, newCounts: countFindings(split.fresh), acceptedFindings: split.accepted, baseline: state.baseline, passed: result.complete && verdictOf(split.fresh, failOn), failOn, stateFile };
154
161
  },
155
162
  timeoutMs: 120000,
156
163
  };
@@ -161,16 +168,18 @@ function buildWorkspaceTools(cfg, cwd, runner, signal) {
161
168
  base: { type: 'string', description: 'git 基线(默认 HEAD)。' },
162
169
  target: { type: 'string', description: '限定文件路径(可选)。' },
163
170
  staged: { type: 'boolean', description: '审查已暂存改动(git diff --cached,默认 false)。' },
171
+ includeUntracked: { type: 'boolean', description: '同时检查未跟踪的新文件(默认 false,staged 模式不包含它们)。' },
172
+ engines: { type: 'array', items: { type: 'string', enum: ['builtin', 'betterleaks', 'semgrep'] }, description: '可选增强引擎;同样只报告涉及新增行的问题。' },
164
173
  }), additionalProperties: false },
165
174
  output: { schema: scanSchema, render: renderFindings },
166
175
  async execute(rawArgs) {
167
- const args = scanArguments(rawArgs, ['base', 'target', 'staged']);
176
+ const args = scanArguments(rawArgs, ['base', 'target', 'staged', 'engines', 'includeUntracked']);
168
177
  const base = optionalString(args, 'base') ?? 'HEAD';
169
178
  const target = optionalString(args, 'target');
170
179
  const policy = await loadPolicy(cwd);
171
180
  const failOn = policy.failOn ?? cfg.failOn;
172
- const diff = await runGitDiff(runner, cwd, base, target, 30000, args.staged === true);
173
- const result = scanDiff(diff, cwd, policy);
181
+ const engines = sourceEngines(args.engines, cfg.engines);
182
+ const result = await runSourceCheck({ kind: 'diff', base, staged: args.staged === true, ...(args.includeUntracked === true ? { includeUntracked: true } : {}), ...(target ? { target } : {}) }, cfg, cwd, runner, engines, policy, signal);
174
183
  const state = await loadState(stateDir);
175
184
  const counts = countFindings(result.findings);
176
185
  const split = splitByBaseline(result.findings, state.baseline);
@@ -182,17 +191,19 @@ function buildWorkspaceTools(cfg, cwd, runner, signal) {
182
191
  findings: result.findings,
183
192
  fingerprints: result.findings.map(findingFingerprint),
184
193
  counts,
194
+ complete: result.complete, coverage: result.coverage, engines, scope: result.scope,
185
195
  };
186
196
  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 };
197
+ return { ...result, counts, newFindings: split.fresh, newCounts: countFindings(split.fresh), acceptedFindings: split.accepted, baseline: state.baseline, passed: result.complete && verdictOf(split.fresh, failOn), failOn };
188
198
  },
189
199
  timeoutMs: 60000,
190
200
  };
191
201
  const secureFixVerify = {
192
202
  name: 'secure_fix_verify',
193
- description: '复扫并与上次扫描基线对比,输出已关闭、仍存在、新引入的问题指纹。用于修复后确认没有按下葫芦浮起瓢。',
203
+ description: '按上次相同范围与引擎复查,输出已关闭、仍存在、新增问题。只有完整复查才更新状态和判定关闭;规则不再命中不等同于相关测试通过。',
194
204
  parameters: { ...compileParameters({
195
205
  target: { type: 'string', description: '复扫文件或目录(可选,缺省扫描整个工作区)。' },
206
+ engines: { type: 'array', items: { type: 'string', enum: ['builtin', 'betterleaks', 'semgrep'] }, description: '默认沿用上次扫描引擎;需与上次相同,避免未执行就宣称修好。' },
196
207
  }), additionalProperties: false },
197
208
  output: {
198
209
  schema: {
@@ -215,24 +226,41 @@ function buildWorkspaceTools(cfg, cwd, runner, signal) {
215
226
  const target = optionalString(args, 'target');
216
227
  const policy = await loadPolicy(cwd);
217
228
  const state = await loadState(stateDir);
218
- const result = await scanPath({ cwd, target, maxFiles: cfg.maxFiles, maxFileBytes: cfg.maxFileBytes, policy, signal });
229
+ const engines = sourceEngines(args.engines, state.last?.engines ?? cfg.engines);
230
+ if (state.last && !state.last.scope && state.last.mode.startsWith('diff:'))
231
+ throw new Error('旧版增量记录缺少完整范围,请先重新执行 secure_diff / Rerun secure_diff to upgrade this legacy diff record before rechecking');
232
+ const scope = state.last?.scope ?? { kind: 'scan', ...(state.last?.target && state.last.target !== '.' ? { target: state.last.target } : {}) };
233
+ if (target !== undefined && path.resolve(cwd, target) !== path.resolve(cwd, scope.target ?? '.'))
234
+ throw new Error('复查范围必须与上次扫描相同;请先对新范围执行 secure_scan');
235
+ if (state.last?.engines && [...engines].sort().join() !== [...state.last.engines].sort().join())
236
+ throw new Error('复查引擎必须与上次扫描相同');
237
+ const result = await runSourceCheck(scope, cfg, cwd, runner, engines, policy, signal);
219
238
  if (state.last === null) {
220
239
  return { baselineMissing: true, baselineTime: '', closedCount: 0, remainingCount: 0, freshCount: result.findings.length, closed: [], remaining: [], fresh: result.findings, passed: false };
221
240
  }
222
241
  const current = result.findings.map(findingFingerprint);
223
242
  const cmp = compareFingerprints(state.last.findings.map(findingFingerprint), current);
224
243
  const byFingerprint = new Map(result.findings.map((finding) => [findingFingerprint(finding), finding]));
225
- const closed = cmp.closed.map((fp) => state.last.findings.find((finding) => findingFingerprint(finding) === fp)).filter((x) => x !== undefined);
244
+ const closed = result.complete ? cmp.closed.map((fp) => state.last.findings.find((finding) => findingFingerprint(finding) === fp)).filter((x) => x !== undefined) : [];
226
245
  const remaining = cmp.remaining.map((fp) => byFingerprint.get(fp)).filter((x) => x !== undefined);
227
246
  const fresh = cmp.fresh.map((fp) => byFingerprint.get(fp)).filter((x) => x !== undefined);
247
+ if (result.complete)
248
+ await saveState(stateDir, {
249
+ ...state.last, time: new Date().toISOString(), mode: 'verify:' + scope.kind,
250
+ filesScanned: result.filesScanned, findings: result.findings,
251
+ fingerprints: current, counts: countFindings(result.findings),
252
+ complete: true, coverage: result.coverage, engines, scope,
253
+ }, signal);
228
254
  return {
229
255
  baselineMissing: false,
230
256
  baselineTime: state.last.time,
231
257
  closedCount: closed.length,
232
258
  remainingCount: remaining.length,
233
259
  freshCount: fresh.length,
260
+ complete: result.complete, coverage: result.coverage,
261
+ unverified: result.complete ? [] : state.last.findings,
234
262
  closed, remaining, fresh,
235
- passed: fresh.length === 0 && remaining.length === 0,
263
+ passed: result.complete && fresh.length === 0 && remaining.length === 0,
236
264
  };
237
265
  },
238
266
  timeoutMs: 120000,
@@ -274,6 +302,7 @@ function buildWorkspaceTools(cfg, cwd, runner, signal) {
274
302
  const split = splitByBaseline(last.findings, state.baseline);
275
303
  return {
276
304
  hasScan: true,
305
+ time: last.time, scope: last.scope, engines: last.engines ?? ['builtin'],
277
306
  stateFile: stateDir,
278
307
  policy,
279
308
  counts: last.counts,
@@ -282,7 +311,9 @@ function buildWorkspaceTools(cfg, cwd, runner, signal) {
282
311
  acceptedCounts: countFindings(split.accepted),
283
312
  byRule: [...byRule.entries()].sort((a, b) => b[1] - a[1]).map(([rule, count]) => ({ rule, count, category: ruleCategory(rule) })),
284
313
  byFile: [...byFile.entries()].sort((a, b) => b[1] - a[1]).slice(0, 50).map(([file, count]) => ({ file, count })),
285
- passed: verdictOf(split.fresh, failOn),
314
+ passed: last.complete !== false && verdictOf(split.fresh, failOn),
315
+ complete: last.complete !== false, coverage: last.coverage ?? [], findings: last.findings,
316
+ newFindings: split.fresh, acceptedFindings: split.accepted,
286
317
  historyCount: state.history.length,
287
318
  };
288
319
  },
@@ -361,9 +392,10 @@ function buildWorkspaceTools(cfg, cwd, runner, signal) {
361
392
  throw new Error('尚无扫描结果,请先执行 secure_scan 或 secure_diff。');
362
393
  const text = format === 'sarif' ? buildSarif(state.last.findings, cwd) : buildMarkdown(state.last.findings, state.last.target);
363
394
  signal?.throwIfAborted();
364
- if (target !== undefined)
365
- await writeFile(target, text, 'utf8');
366
- return { format, path: target ?? '', text, findingCount: state.last.findings.length };
395
+ const destination = target === undefined ? undefined : path.resolve(cwd, target);
396
+ if (destination !== undefined)
397
+ await writeFile(destination, text, { encoding: 'utf8', mode: 0o600, signal });
398
+ return { format, path: destination ?? '', text, findingCount: state.last.findings.length };
367
399
  },
368
400
  timeoutMs: 30000,
369
401
  };
@@ -390,10 +422,11 @@ function buildWorkspaceTools(cfg, cwd, runner, signal) {
390
422
  checks.push({ name: '严重度门禁', ok: true, detail: 'failOn=' + cfg.failOn });
391
423
  checks.push({ name: '状态目录', ok: true, detail: cfg.stateDir });
392
424
  checks.push({ name: '工作目录', ok: cwd !== '', detail: cwd });
393
- return { ok: true, plugin: 'dsh-code-security', checks };
425
+ const engines = await Promise.all(['osv', 'betterleaks', 'semgrep'].map(engine => probeEngine(engine, cfg, runner)));
426
+ return { ok: true, plugin: 'dsh-code-security', checks, engines, configuredEngines: cfg.engines, database: await databaseHealth(cfg) };
394
427
  },
395
428
  };
396
- return [secureScan, secureDiff, secureFixVerify, secureReport, secureExport, ...buildExtraTools(cfg, cwd, signal), policyShow, policySet, secureHealth];
429
+ return [secureScan, secureDiff, secureFixVerify, secureReport, secureExport, ...buildExtraTools(cfg, cwd, signal, runner), policyShow, policySet, secureHealth];
397
430
  }
398
431
  function buildMarkdown(findings, target) {
399
432
  const lines = ['# 安全审查报告', '', '目标:' + target, '', '| 严重度 | 规则 | CWE | 文件:行 | 说明 |', '| :-- | :-- | :-- | :-- | :-- |'];
@@ -0,0 +1,30 @@
1
+ import { type SecureToolDefinition } from './tools.js';
2
+ import { type ResolvedSecureConfig } from './config.js';
3
+ export declare const SECURITY_WORKBENCH_ROUTE = "/api/dsh-code-security/workbench";
4
+ export declare class SecurityWorkbench {
5
+ readonly cfg: ResolvedSecureConfig;
6
+ private readonly automaticProblems;
7
+ private readonly jobs;
8
+ private readonly tools;
9
+ readonly directory: string;
10
+ onAutoChanged?: (root: string, enabled: boolean) => void;
11
+ constructor(cfg: ResolvedSecureConfig, options: {
12
+ directory?: string;
13
+ tools: (cfg: ResolvedSecureConfig, cwd: string) => SecureToolDefinition[];
14
+ });
15
+ private catalog;
16
+ private update;
17
+ remember(workspace: string, sessionId?: string): Promise<void>;
18
+ configFor(root: string): Promise<ResolvedSecureConfig>;
19
+ autoEnabled(root: string): Promise<boolean>;
20
+ automaticProblem(root: string, reason?: string): void;
21
+ private workspace;
22
+ private report;
23
+ runAutomatic(root: string, signal?: AbortSignal): Promise<any>;
24
+ private safeResult;
25
+ run(root: string, name: string, args: Record<string, unknown>, signal?: AbortSignal): Promise<any>;
26
+ action(body: Record<string, unknown>): Promise<any>;
27
+ fetch(request: Request): Promise<Response>;
28
+ dispose(): Promise<void>;
29
+ }
30
+ export declare function installSecurityWorkbench(ctx: any, workbench: SecurityWorkbench): void;