dsh-code-security 0.3.7 → 0.4.0

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/tools.js 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
  */
@@ -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
  }
@@ -48,6 +50,14 @@ function scanArguments(raw, allowed = ['target']) {
48
50
  if (Object.hasOwn(args, 'target') && (typeof args.target !== 'string' || args.target.trim() === '')) {
49
51
  throw new Error('扫描参数 target 必须是非空的文件或目录字符串。扫描整个工作区请省略 target 或使用 "."。');
50
52
  }
53
+ if (Object.hasOwn(args, 'base') && (typeof args.base !== 'string' || args.base.trim() === '' || args.base.trim().startsWith('-') || args.base.includes('\0'))) {
54
+ throw new Error('扫描参数 base 必须是非空的 Git ref,不能是 Git 命令选项。');
55
+ }
56
+ if (Object.hasOwn(args, 'staged') && typeof args.staged !== 'boolean') {
57
+ throw new Error('扫描参数 staged 必须是布尔值 true 或 false。');
58
+ }
59
+ if (Object.hasOwn(args, 'includeUntracked') && typeof args.includeUntracked !== 'boolean')
60
+ throw new Error('includeUntracked 必须是布尔值');
51
61
  return args;
52
62
  }
53
63
  const findingSchema = {
@@ -68,6 +78,7 @@ const scanSchema = {
68
78
  type: 'object',
69
79
  properties: {
70
80
  root: { type: 'string' }, filesScanned: { type: 'integer' }, filesSkipped: { type: 'integer' },
81
+ filesChanged: { type: 'integer' }, addedLines: { type: 'integer' },
71
82
  findings: { type: 'array', items: findingSchema }, counts: countsSchema, passed: { type: 'boolean' },
72
83
  failOn: { type: 'string' }, durationMs: { type: 'integer' },
73
84
  newFindings: { type: 'array', items: findingSchema }, newCounts: countsSchema,
@@ -96,6 +107,8 @@ function renderFindings(_args, value) {
96
107
  '安全审查:' + (rec.passed === true ? '通过' : '未通过'),
97
108
  '文件 ' + 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 + ' 个。',
98
109
  ];
110
+ if (typeof rec.filesSkipped === 'number' && rec.filesSkipped > 0)
111
+ lines.push('已跳过 ' + rec.filesSkipped + ' 项;跳过项不计入已扫描文件数。');
99
112
  for (const finding of findings) {
100
113
  lines.push('- ' + finding.file + ':' + finding.line + ' [' + finding.severity + '] ' + finding.ruleId + ' ' + finding.title);
101
114
  }
@@ -108,7 +121,7 @@ export function buildSecureTools(cfg, cwd, runner) {
108
121
  signal?.throwIfAborted();
109
122
  const workspace = context?.agent?.session?.header?.cwd || cwd;
110
123
  const resolved = { ...cfg, stateDir: cfg.stateDirRelative ? path.resolve(workspace, cfg.stateDirRelative) : cfg.stateDir };
111
- 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 }) };
112
125
  const selected = buildWorkspaceTools(resolved, workspace, scopedRunner, signal).find(row => row.name === tool.name);
113
126
  return selected.execute(args, exec);
114
127
  } }));
@@ -120,6 +133,7 @@ function buildWorkspaceTools(cfg, cwd, runner, signal) {
120
133
  description: '扫描工作区(或指定文件/目录),用确定性规则检测注入、弱加密、硬编码密钥、危险配置等问题。使用 target 字符串限定范围,不接受 paths/files 等参数;{} 表示整个工作区。结果写入 .code-security 状态供 secure_fix_verify 对比。',
121
134
  parameters: { ...compileParameters({
122
135
  target: { type: 'string', description: '要扫描的文件或目录(可选,缺省扫描整个工作区)。' },
136
+ engines: { type: 'array', items: { type: 'string', enum: ['builtin', 'betterleaks', 'semgrep'] }, description: '可选引擎,默认使用配置(初始仅 builtin)。' },
123
137
  }), additionalProperties: false },
124
138
  output: { schema: scanSchema, render: renderFindings },
125
139
  async execute(rawArgs) {
@@ -127,7 +141,8 @@ function buildWorkspaceTools(cfg, cwd, runner, signal) {
127
141
  const target = optionalString(args, 'target');
128
142
  const policy = await loadPolicy(cwd);
129
143
  const failOn = policy.failOn ?? cfg.failOn;
130
- 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);
131
146
  const state = await loadState(stateDir);
132
147
  const counts = countFindings(result.findings);
133
148
  const split = splitByBaseline(result.findings, state.baseline);
@@ -139,29 +154,32 @@ function buildWorkspaceTools(cfg, cwd, runner, signal) {
139
154
  findings: result.findings,
140
155
  fingerprints: result.findings.map(findingFingerprint),
141
156
  counts,
157
+ complete: result.complete, coverage: result.coverage, engines, scope: result.scope,
142
158
  };
143
159
  const stateFile = await saveState(stateDir, entry, signal);
144
- 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 };
145
161
  },
146
162
  timeoutMs: 120000,
147
163
  };
148
164
  const secureDiff = {
149
165
  name: 'secure_diff',
150
- description: '只审查 git diff 的新增行(默认 HEAD,即未提交改动)。base 可用任意 git ref;staged=true 审查已暂存改动;target 可限定文件。结果同样写入状态。',
166
+ description: '结合少量上下文审查 git diff,只报告涉及新增行的问题(默认 HEAD,即未提交改动)。base 可用 Git ref;staged=true 审查已暂存改动;target 可限定文件。范围与路径相对当前会话工作区,遵守项目排除策略。结果同样写入状态。',
151
167
  parameters: { ...compileParameters({
152
168
  base: { type: 'string', description: 'git 基线(默认 HEAD)。' },
153
169
  target: { type: 'string', description: '限定文件路径(可选)。' },
154
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: '可选增强引擎;同样只报告涉及新增行的问题。' },
155
173
  }), additionalProperties: false },
156
174
  output: { schema: scanSchema, render: renderFindings },
157
175
  async execute(rawArgs) {
158
- const args = scanArguments(rawArgs, ['base', 'target', 'staged']);
176
+ const args = scanArguments(rawArgs, ['base', 'target', 'staged', 'engines', 'includeUntracked']);
159
177
  const base = optionalString(args, 'base') ?? 'HEAD';
160
178
  const target = optionalString(args, 'target');
161
179
  const policy = await loadPolicy(cwd);
162
180
  const failOn = policy.failOn ?? cfg.failOn;
163
- const diff = await runGitDiff(runner, cwd, base, target, 30000, args.staged === true);
164
- 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);
165
183
  const state = await loadState(stateDir);
166
184
  const counts = countFindings(result.findings);
167
185
  const split = splitByBaseline(result.findings, state.baseline);
@@ -169,21 +187,23 @@ function buildWorkspaceTools(cfg, cwd, runner, signal) {
169
187
  mode: 'diff:' + base,
170
188
  target: target ?? '.',
171
189
  time: new Date().toISOString(),
172
- filesScanned: result.filesChanged,
190
+ filesScanned: result.filesScanned,
173
191
  findings: result.findings,
174
192
  fingerprints: result.findings.map(findingFingerprint),
175
193
  counts,
194
+ complete: result.complete, coverage: result.coverage, engines, scope: result.scope,
176
195
  };
177
196
  await saveState(stateDir, entry, signal);
178
- 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 };
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 };
179
198
  },
180
199
  timeoutMs: 60000,
181
200
  };
182
201
  const secureFixVerify = {
183
202
  name: 'secure_fix_verify',
184
- description: '复扫并与上次扫描基线对比,输出已关闭、仍存在、新引入的问题指纹。用于修复后确认没有按下葫芦浮起瓢。',
203
+ description: '按上次相同范围与引擎复查,输出已关闭、仍存在、新增问题。只有完整复查才更新状态和判定关闭;规则不再命中不等同于相关测试通过。',
185
204
  parameters: { ...compileParameters({
186
205
  target: { type: 'string', description: '复扫文件或目录(可选,缺省扫描整个工作区)。' },
206
+ engines: { type: 'array', items: { type: 'string', enum: ['builtin', 'betterleaks', 'semgrep'] }, description: '默认沿用上次扫描引擎;需与上次相同,避免未执行就宣称修好。' },
187
207
  }), additionalProperties: false },
188
208
  output: {
189
209
  schema: {
@@ -206,24 +226,41 @@ function buildWorkspaceTools(cfg, cwd, runner, signal) {
206
226
  const target = optionalString(args, 'target');
207
227
  const policy = await loadPolicy(cwd);
208
228
  const state = await loadState(stateDir);
209
- 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);
210
238
  if (state.last === null) {
211
239
  return { baselineMissing: true, baselineTime: '', closedCount: 0, remainingCount: 0, freshCount: result.findings.length, closed: [], remaining: [], fresh: result.findings, passed: false };
212
240
  }
213
241
  const current = result.findings.map(findingFingerprint);
214
- const cmp = compareFingerprints(state.last.fingerprints, current);
242
+ const cmp = compareFingerprints(state.last.findings.map(findingFingerprint), current);
215
243
  const byFingerprint = new Map(result.findings.map((finding) => [findingFingerprint(finding), finding]));
216
- 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) : [];
217
245
  const remaining = cmp.remaining.map((fp) => byFingerprint.get(fp)).filter((x) => x !== undefined);
218
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);
219
254
  return {
220
255
  baselineMissing: false,
221
256
  baselineTime: state.last.time,
222
257
  closedCount: closed.length,
223
258
  remainingCount: remaining.length,
224
259
  freshCount: fresh.length,
260
+ complete: result.complete, coverage: result.coverage,
261
+ unverified: result.complete ? [] : state.last.findings,
225
262
  closed, remaining, fresh,
226
- passed: fresh.length === 0 && remaining.length === 0,
263
+ passed: result.complete && fresh.length === 0 && remaining.length === 0,
227
264
  };
228
265
  },
229
266
  timeoutMs: 120000,
@@ -265,6 +302,7 @@ function buildWorkspaceTools(cfg, cwd, runner, signal) {
265
302
  const split = splitByBaseline(last.findings, state.baseline);
266
303
  return {
267
304
  hasScan: true,
305
+ time: last.time, scope: last.scope, engines: last.engines ?? ['builtin'],
268
306
  stateFile: stateDir,
269
307
  policy,
270
308
  counts: last.counts,
@@ -273,7 +311,9 @@ function buildWorkspaceTools(cfg, cwd, runner, signal) {
273
311
  acceptedCounts: countFindings(split.accepted),
274
312
  byRule: [...byRule.entries()].sort((a, b) => b[1] - a[1]).map(([rule, count]) => ({ rule, count, category: ruleCategory(rule) })),
275
313
  byFile: [...byFile.entries()].sort((a, b) => b[1] - a[1]).slice(0, 50).map(([file, count]) => ({ file, count })),
276
- 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,
277
317
  historyCount: state.history.length,
278
318
  };
279
319
  },
@@ -352,9 +392,10 @@ function buildWorkspaceTools(cfg, cwd, runner, signal) {
352
392
  throw new Error('尚无扫描结果,请先执行 secure_scan 或 secure_diff。');
353
393
  const text = format === 'sarif' ? buildSarif(state.last.findings, cwd) : buildMarkdown(state.last.findings, state.last.target);
354
394
  signal?.throwIfAborted();
355
- if (target !== undefined)
356
- await writeFile(target, text, 'utf8');
357
- 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 };
358
399
  },
359
400
  timeoutMs: 30000,
360
401
  };
@@ -381,10 +422,11 @@ function buildWorkspaceTools(cfg, cwd, runner, signal) {
381
422
  checks.push({ name: '严重度门禁', ok: true, detail: 'failOn=' + cfg.failOn });
382
423
  checks.push({ name: '状态目录', ok: true, detail: cfg.stateDir });
383
424
  checks.push({ name: '工作目录', ok: cwd !== '', detail: cwd });
384
- 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) };
385
427
  },
386
428
  };
387
- 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];
388
430
  }
389
431
  function buildMarkdown(findings, target) {
390
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;
@@ -0,0 +1,242 @@
1
+ /** Authenticated operator UI: workspaces come from host sessions/tools, never browser paths. */
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { homedir } from 'node:os';
5
+ import { createHash, randomUUID } from 'node:crypto';
6
+ import lockfile from 'proper-lockfile';
7
+ import { resolveConfig } from './config.js';
8
+ import { loadState } from './state.js';
9
+ import { splitByBaseline } from './state.js';
10
+ import { redactText } from './privacy.js';
11
+ export const SECURITY_WORKBENCH_ROUTE = '/api/dsh-code-security/workbench';
12
+ const hash = (text) => createHash('sha256').update(text).digest('hex');
13
+ const json = (status, value) => new Response(JSON.stringify(value), { status, headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' } });
14
+ export class SecurityWorkbench {
15
+ cfg;
16
+ automaticProblems = new Map();
17
+ jobs = new Map();
18
+ tools;
19
+ directory;
20
+ onAutoChanged;
21
+ constructor(cfg, options) {
22
+ this.cfg = cfg;
23
+ this.directory = options.directory ?? path.join(process.env.DSH_HOME || path.join(homedir(), '.dsh'), 'plugins', 'dsh-code-security');
24
+ this.tools = options.tools;
25
+ }
26
+ async catalog() {
27
+ try {
28
+ const file = path.join(this.directory, 'workspaces.json'), stat = await fs.lstat(file);
29
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 2 * 1024 * 1024)
30
+ throw new Error('invalid index');
31
+ const data = JSON.parse(await fs.readFile(file, 'utf8'));
32
+ if (data.version !== 1 || !Array.isArray(data.workspaces) || data.workspaces.length > 100 || data.workspaces.some((w) => !w || typeof w.root !== 'string' || !path.isAbsolute(w.root) || w.id !== hash(w.root) || typeof w.label !== 'string' || typeof w.autoCheck !== 'boolean' || w.sessionId !== undefined && typeof w.sessionId !== 'string') || !data.settings || !Array.isArray(data.settings.engines) || !data.settings.enginePaths)
33
+ throw new Error('invalid index');
34
+ resolveConfig(data.settings);
35
+ return data;
36
+ }
37
+ catch (error) {
38
+ if (error.code !== 'ENOENT')
39
+ throw new Error('工作区索引损坏,原文件已保留 / Workspace index is invalid; the original was preserved');
40
+ return { version: 1, workspaces: [], settings: { engines: this.cfg.engines, enginePaths: { ...this.cfg.enginePaths }, ...(this.cfg.osvDatabaseDir ? { osvDatabaseDir: this.cfg.osvDatabaseDir } : {}) } };
41
+ }
42
+ }
43
+ async update(change) {
44
+ await fs.mkdir(this.directory, { recursive: true });
45
+ const unlock = await lockfile.lock(this.directory, { retries: { retries: 30, minTimeout: 25, maxTimeout: 100 } });
46
+ try {
47
+ const data = await this.catalog();
48
+ change(data);
49
+ const file = path.join(this.directory, 'workspaces.json'), temp = file + '.tmp-' + randomUUID();
50
+ try {
51
+ await fs.writeFile(temp, JSON.stringify(data), { flag: 'wx', mode: 0o600 });
52
+ await fs.rename(temp, file);
53
+ }
54
+ finally {
55
+ await fs.unlink(temp).catch(() => { });
56
+ }
57
+ }
58
+ finally {
59
+ await unlock();
60
+ }
61
+ }
62
+ async remember(workspace, sessionId) {
63
+ if (!path.isAbsolute(workspace))
64
+ return;
65
+ const root = await fs.realpath(workspace);
66
+ if (!(await fs.lstat(root)).isDirectory())
67
+ return;
68
+ await this.update(data => {
69
+ const previous = data.workspaces.find(w => w.id === hash(root));
70
+ data.workspaces = [{ id: hash(root), root, label: path.basename(root) || root, autoCheck: previous?.autoCheck ?? false, ...(sessionId ? { sessionId } : previous?.sessionId ? { sessionId: previous.sessionId } : {}) }, ...data.workspaces.filter(w => w.id !== hash(root))].slice(0, 100);
71
+ });
72
+ }
73
+ async configFor(root) {
74
+ const { settings } = await this.catalog();
75
+ return { ...this.cfg, engines: [...settings.engines], enginePaths: { ...this.cfg.enginePaths, ...settings.enginePaths }, osvDatabaseDir: settings.osvDatabaseDir ?? this.cfg.osvDatabaseDir, stateDir: this.cfg.stateDirRelative ? path.resolve(root, this.cfg.stateDirRelative) : this.cfg.stateDir };
76
+ }
77
+ async autoEnabled(root) { return (await this.catalog()).workspaces.find(w => w.id === hash(root))?.autoCheck === true; }
78
+ automaticProblem(root, reason) { if (reason)
79
+ this.automaticProblems.set(root, reason);
80
+ else
81
+ this.automaticProblems.delete(root); }
82
+ async workspace(id) {
83
+ const row = (await this.catalog()).workspaces.find(w => typeof id === 'string' && w.id === id);
84
+ if (!row || await fs.realpath(row.root) !== row.root)
85
+ throw new Error('请选择已登记的工作区 / Choose a registered workspace');
86
+ return row;
87
+ }
88
+ async report(row) {
89
+ const tools = this.tools(await this.configFor(row.root), row.root);
90
+ return tools.find(t => t.name === 'secure_report').execute({}, {});
91
+ }
92
+ async runAutomatic(root, signal) {
93
+ const cfg = await this.configFor(root);
94
+ const autoCfg = { ...cfg, stateDir: path.join(cfg.stateDir, 'automatic'), stateDirRelative: undefined, engines: ['builtin'] };
95
+ const result = await this.tools(autoCfg, root).find(t => t.name === 'secure_diff').execute({ includeUntracked: true, engines: ['builtin'] }, { signal });
96
+ const manual = await loadState(cfg.stateDir);
97
+ return { ...result, newFindings: splitByBaseline(result.findings, manual.baseline).fresh };
98
+ }
99
+ safeResult(result) {
100
+ if (!result || typeof result !== 'object')
101
+ return result;
102
+ const value = { ...result };
103
+ delete value.baseline;
104
+ for (const name of ['findings', 'newFindings', 'acceptedFindings', 'closed', 'remaining', 'fresh', 'unverified', 'dependencies'])
105
+ if (Array.isArray(value[name])) {
106
+ value[name + 'Total'] = value[name].length;
107
+ value[name] = value[name].slice(0, 200);
108
+ }
109
+ if (value.audit)
110
+ value.audit = { ...value.audit, vulnerabilitiesTotal: value.audit.vulnerabilities.length, vulnerabilities: value.audit.vulnerabilities.slice(0, 200) };
111
+ return value;
112
+ }
113
+ async run(root, name, args, signal) {
114
+ const tools = this.tools(await this.configFor(root), root), selected = tools.find(t => t.name === name);
115
+ if (!selected)
116
+ throw new Error('Unknown security operation');
117
+ return selected.execute(args, { signal });
118
+ }
119
+ async action(body) {
120
+ if (body.operation === 'list')
121
+ return { ok: true, ...await this.catalog() };
122
+ const row = await this.workspace(body.id);
123
+ if (body.operation === 'open') {
124
+ const cfg = await this.configFor(row.root);
125
+ let automatic;
126
+ try {
127
+ const state = await loadState(path.join(cfg.stateDir, 'automatic'));
128
+ automatic = state.last ? { time: state.last.time, complete: state.last.complete !== false, highRisk: state.last.findings.filter(f => f.severity === 'high' || f.severity === 'critical').length } : null;
129
+ }
130
+ catch {
131
+ automatic = { problem: 'automatic-state-invalid' };
132
+ }
133
+ if (this.automaticProblems.has(row.root))
134
+ automatic = { ...automatic, problem: this.automaticProblems.get(row.root) };
135
+ const running = [...this.jobs.values()].find(j => j.workspace === row.id && j.status === 'running');
136
+ return { ok: true, workspace: row, report: this.safeResult(await this.report(row)), settings: (await this.catalog()).settings, automatic, ...(running ? { activeJob: { id: running.id, action: running.action, status: running.status } } : {}) };
137
+ }
138
+ if (body.operation === 'settings') {
139
+ if (typeof body.autoCheck !== 'boolean')
140
+ throw new Error('autoCheck must be boolean');
141
+ const checked = resolveConfig({ engines: body.engines, enginePaths: body.enginePaths, ...(body.osvDatabaseDir === undefined ? {} : { osvDatabaseDir: body.osvDatabaseDir }) });
142
+ await this.update(data => { data.settings = { engines: checked.engines, enginePaths: checked.enginePaths, ...(checked.osvDatabaseDir ? { osvDatabaseDir: checked.osvDatabaseDir } : {}) }; data.workspaces.find(w => w.id === row.id).autoCheck = body.autoCheck; });
143
+ this.onAutoChanged?.(row.root, body.autoCheck);
144
+ return { ok: true, ...await this.action({ operation: 'open', id: row.id }) };
145
+ }
146
+ if (body.operation === 'location') {
147
+ const cfg = await this.configFor(row.root), state = await loadState(cfg.stateDir), finding = state.last?.findings.find(f => f.id === body.finding);
148
+ if (!finding)
149
+ throw new Error('问题已变化,请刷新 / Finding changed; refresh the report');
150
+ const file = path.resolve(row.root, finding.file), rel = path.relative(row.root, file);
151
+ if (rel.startsWith('..') || path.isAbsolute(rel) || await fs.realpath(file) !== file || !(await fs.lstat(file)).isFile())
152
+ throw new Error('不能打开工作区外或已移动的文件 / File is outside the workspace or has moved');
153
+ return { ok: true, path: file, line: finding.line, sessionId: row.sessionId };
154
+ }
155
+ if (body.operation === 'job' || body.operation === 'cancel') {
156
+ const job = this.jobs.get(String(body.job));
157
+ if (!job || job.workspace !== row.id)
158
+ throw new Error('任务已结束或已重启 / Job expired or DSH restarted');
159
+ if (body.operation === 'cancel')
160
+ job.controller.abort(new Error('cancelled by operator'));
161
+ return { ok: true, job: { id: job.id, action: job.action, status: job.status, ...(job.result ? { result: this.safeResult(job.result) } : {}), ...(job.error ? { error: job.error } : {}) } };
162
+ }
163
+ if (body.operation === 'export') {
164
+ if (body.format !== 'sarif' && body.format !== 'markdown')
165
+ throw new Error('Invalid export format');
166
+ const result = await this.run(row.root, 'secure_export', { format: body.format });
167
+ return { ok: true, text: result.text, filename: body.format === 'sarif' ? 'security-report.sarif' : 'security-report.md' };
168
+ }
169
+ if (body.operation === 'start') {
170
+ const actions = { scan: 'secure_scan', diff: 'secure_diff', verify: 'secure_fix_verify', deps: 'secure_deps', health: 'secure_health' };
171
+ const action = String(body.action), tool = actions[action];
172
+ if (!tool)
173
+ throw new Error('不支持此操作 / Unsupported operation');
174
+ if ([...this.jobs.values()].some(j => j.workspace === row.id && j.status === 'running'))
175
+ throw new Error('已有检查正在运行 / A check is already running');
176
+ if (action === 'deps' && !['none', 'offline', 'online'].includes(String(body.audit ?? 'none')))
177
+ throw new Error('Invalid audit mode');
178
+ const args = action === 'deps' ? { audit: body.audit ?? 'none' } : action === 'diff' ? { includeUntracked: true } : {};
179
+ const job = { id: randomUUID(), workspace: row.id, action, status: 'running', controller: new AbortController() };
180
+ this.jobs.set(job.id, job);
181
+ job.work = this.run(row.root, tool, args, job.controller.signal).then(result => { job.result = result; job.status = 'completed'; }, error => { job.status = job.controller.signal.aborted ? 'cancelled' : 'failed'; job.error = job.controller.signal.aborted ? '已取消 / Cancelled' : redactText(error instanceof Error ? error.message : String(error)).slice(0, 2000); });
182
+ if (this.jobs.size > 100)
183
+ for (const [id, old] of this.jobs) {
184
+ if (old.status !== 'running')
185
+ this.jobs.delete(id);
186
+ if (this.jobs.size <= 80)
187
+ break;
188
+ }
189
+ return { ok: true, job: { id: job.id, action, status: job.status } };
190
+ }
191
+ throw new Error('不支持此操作 / Unsupported operation');
192
+ }
193
+ async fetch(request) {
194
+ if (request.method !== 'POST')
195
+ return json(405, { ok: false, message: 'POST required' });
196
+ const origin = request.headers.get('origin'), site = request.headers.get('sec-fetch-site');
197
+ if (request.headers.get('x-dsh-security-action') !== '1' || request.headers.get('content-type')?.split(';')[0].trim() !== 'application/json' || site && !['same-origin', 'none'].includes(site))
198
+ return json(403, { ok: false, message: '请从安全检查面板操作 / Use the security panel' });
199
+ try {
200
+ if (origin) {
201
+ // DSH's connection adapter uses an internal Request URL; Host retains the browser authority.
202
+ const source = new URL(origin), internal = new URL(request.url), authority = request.headers.get('host') || internal.host;
203
+ if (!['http:', 'https:'].includes(source.protocol) || source.host !== authority || authority === internal.host && source.protocol !== internal.protocol)
204
+ return json(403, { ok: false, message: '拒绝跨源操作 / Cross-origin request refused' });
205
+ }
206
+ const reader = request.body?.getReader();
207
+ if (!reader)
208
+ throw new Error('Request body missing');
209
+ let size = 0;
210
+ const chunks = [];
211
+ try {
212
+ for (;;) {
213
+ const part = await reader.read();
214
+ if (part.done)
215
+ break;
216
+ size += part.value.byteLength;
217
+ if (size > 16384) {
218
+ await reader.cancel();
219
+ return json(413, { ok: false, message: 'Request too large' });
220
+ }
221
+ ;
222
+ chunks.push(part.value);
223
+ }
224
+ }
225
+ finally {
226
+ reader.releaseLock();
227
+ }
228
+ const body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
229
+ if (!body || typeof body !== 'object' || Array.isArray(body))
230
+ throw new Error('Invalid operation');
231
+ return json(200, await this.action(body));
232
+ }
233
+ catch (error) {
234
+ return json(400, { ok: false, message: redactText(error instanceof Error ? error.message : String(error)) });
235
+ }
236
+ }
237
+ async dispose() { for (const job of this.jobs.values())
238
+ job.controller.abort(new Error('DSH disposed')); await Promise.allSettled([...this.jobs.values()].map(job => job.work)); }
239
+ }
240
+ export function installSecurityWorkbench(ctx, workbench) {
241
+ ctx.inject?.(['connection'], (host) => host.connection.fetch.register({ path: SECURITY_WORKBENCH_ROUTE, methods: ['POST'], requestBody: 'buffered', fetch: (request) => workbench.fetch(request) }));
242
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-code-security",
3
- "version": "0.3.7",
3
+ "version": "0.4.0",
4
4
  "description": "对代码与 Git 改动进行本地静态安全检查,并追踪修复结果。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -10,16 +10,19 @@
10
10
  "types": "./lib/index.d.ts",
11
11
  "default": "./lib/index.js"
12
12
  },
13
+ "./client": "./lib/client.js",
13
14
  "./cordis.patch.yml": "./cordis.patch.yml",
14
15
  "./package.json": "./package.json"
15
16
  },
16
17
  "files": [
17
18
  "lib",
19
+ "rules",
18
20
  "cordis.patch.yml",
19
21
  "README.md",
20
22
  "README.en.md",
21
23
  "docs",
22
- "CHANGELOG.md"
24
+ "CHANGELOG.md",
25
+ "THIRD_PARTY_NOTICES.md"
23
26
  ],
24
27
  "scripts": {
25
28
  "build": "tsc -p tsconfig.json",
@@ -30,6 +33,10 @@
30
33
  "dsh": {
31
34
  "bundle": {
32
35
  "patch": "./cordis.patch.yml"
36
+ },
37
+ "client": {
38
+ "inject": ["@deepseek-ai/dsh-client-ui-settings"],
39
+ "platform": "web"
33
40
  }
34
41
  },
35
42
  "keywords": [
@@ -47,8 +54,10 @@
47
54
  "node": "^22.19.0 || >=24.0.0"
48
55
  },
49
56
  "devDependencies": {
57
+ "@types/babel__traverse": "^7.28.0",
50
58
  "@types/node": "^24.0.0",
51
59
  "@types/proper-lockfile": "4.1.4",
60
+ "fflate": "0.8.3",
52
61
  "typescript": "^5.6.0"
53
62
  },
54
63
  "repository": {
@@ -62,6 +71,10 @@
62
71
  "author": "stardustlc",
63
72
  "packageManager": "pnpm@11.7.0",
64
73
  "dependencies": {
65
- "proper-lockfile": "4.1.2"
74
+ "@babel/parser": "7.29.9",
75
+ "@babel/traverse": "7.29.10",
76
+ "@babel/types": "7.29.8",
77
+ "proper-lockfile": "4.1.2",
78
+ "smol-toml": "1.9.1"
66
79
  }
67
80
  }
@@ -0,0 +1,3 @@
1
+ # Use the installed engine's reviewed default rules, never a project's executable rules.
2
+ [extend]
3
+ useDefault = true
package/rules/osv.toml ADDED
@@ -0,0 +1 @@
1
+ # Explicit empty scanner configuration; project overrides are not executed or imported.