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/CHANGELOG.md +17 -0
- package/README.en.md +7 -4
- package/README.md +7 -4
- package/THIRD_PARTY_NOTICES.md +23 -0
- package/docs/ITERATION-2026-10-12.md +53 -0
- package/docs/USAGE.en.md +83 -20
- package/docs/USAGE.md +72 -52
- package/docs/VALIDATION.md +14 -0
- package/lib/audit.d.ts +27 -0
- package/lib/audit.js +109 -0
- package/lib/automatic.d.ts +18 -0
- package/lib/automatic.js +103 -0
- package/lib/client.js +63 -0
- package/lib/config.d.ts +9 -0
- package/lib/config.js +12 -1
- package/lib/deps.d.ts +14 -8
- package/lib/deps.js +170 -143
- package/lib/diff.d.ts +3 -0
- package/lib/diff.js +111 -33
- package/lib/engines.d.ts +50 -0
- package/lib/engines.js +255 -0
- package/lib/extra.d.ts +2 -1
- package/lib/extra.js +31 -6
- package/lib/index.d.ts +9 -2
- package/lib/index.js +46 -5
- package/lib/privacy.d.ts +6 -0
- package/lib/privacy.js +39 -0
- package/lib/rules.js +1 -1
- package/lib/runner.d.ts +4 -0
- package/lib/runner.js +8 -3
- package/lib/scanner.d.ts +14 -1
- package/lib/scanner.js +90 -36
- package/lib/state.d.ts +7 -0
- package/lib/state.js +30 -7
- package/lib/syntax.d.ts +1 -0
- package/lib/syntax.js +43 -0
- package/lib/tools.d.ts +4 -2
- package/lib/tools.js +67 -25
- package/lib/workbench.d.ts +30 -0
- package/lib/workbench.js +242 -0
- package/package.json +16 -3
- package/rules/betterleaks.toml +3 -0
- package/rules/osv.toml +1 -0
- package/rules/semgrep.yml +72 -0
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
|
|
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: '
|
|
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
|
|
164
|
-
const result =
|
|
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.
|
|
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 {
|
|
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
|
|
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.
|
|
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
|
-
|
|
356
|
-
|
|
357
|
-
|
|
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
|
-
|
|
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;
|
package/lib/workbench.js
ADDED
|
@@ -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
|
+
"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
|
-
"
|
|
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
|
}
|
package/rules/osv.toml
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Explicit empty scanner configuration; project overrides are not executed or imported.
|