dsh-code-security 0.3.8 → 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 +9 -0
- package/README.en.md +5 -4
- package/README.md +5 -4
- package/THIRD_PARTY_NOTICES.md +23 -0
- package/docs/USAGE.en.md +80 -23
- package/docs/USAGE.md +68 -56
- 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/engines.d.ts +50 -0
- package/lib/engines.js +255 -0
- package/lib/extra.d.ts +2 -1
- package/lib/extra.js +29 -4
- package/lib/index.d.ts +8 -2
- package/lib/index.js +45 -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 +12 -1
- package/lib/scanner.js +22 -5
- package/lib/state.d.ts +7 -0
- package/lib/state.js +28 -7
- package/lib/syntax.d.ts +1 -0
- package/lib/syntax.js +43 -0
- package/lib/tools.d.ts +2 -0
- package/lib/tools.js +53 -20
- 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/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 } 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) {
|
|
@@ -138,7 +140,7 @@ function scanFileRule(entries, rel, rule, disabledNext, selectedLines) {
|
|
|
138
140
|
return checkBlock();
|
|
139
141
|
}
|
|
140
142
|
/** 对带行号的文本行应用规则(全量扫描与 git diff 增量扫描共用)。 */
|
|
141
|
-
export function scanLineSet(entries, rel, policy, selectedLines) {
|
|
143
|
+
export function scanLineSet(entries, rel, policy, selectedLines, sanitized = new Set()) {
|
|
142
144
|
const language = languageForFile(rel);
|
|
143
145
|
const findings = [];
|
|
144
146
|
const applied = RULES.filter((rule) => (rule.languages.includes(language) || rule.languages.includes('any')) && !policyIgnores(policy, rule.id, rel));
|
|
@@ -156,6 +158,8 @@ export function scanLineSet(entries, rel, policy, selectedLines) {
|
|
|
156
158
|
for (const rule of applied) {
|
|
157
159
|
if (rule.id === 'SEC-304')
|
|
158
160
|
continue;
|
|
161
|
+
if (rule.id === 'SEC-009' && sanitized.has(entry.line))
|
|
162
|
+
continue;
|
|
159
163
|
if (shouldSkipLine(rule, currentDisable) || shouldSkipLine(rule, prevDisable.get(entry.line)))
|
|
160
164
|
continue;
|
|
161
165
|
if (rule.fileLevel === true)
|
|
@@ -188,7 +192,7 @@ export function scanLineSet(entries, rel, policy, selectedLines) {
|
|
|
188
192
|
if (finding !== undefined)
|
|
189
193
|
findings.push(finding);
|
|
190
194
|
}
|
|
191
|
-
return findings;
|
|
195
|
+
return findings.map(finding => protectFinding({ ...finding, engine: 'builtin', evidence: 'pattern' }));
|
|
192
196
|
}
|
|
193
197
|
/** 扫描单个文件内容,按语言与行应用规则。 */
|
|
194
198
|
export function scanText(content, file, rel, policy) {
|
|
@@ -196,7 +200,7 @@ export function scanText(content, file, rel, policy) {
|
|
|
196
200
|
const entries = lines.map((text, index) => ({ line: index + 1, text }));
|
|
197
201
|
// Match file rules over contiguous lines, sharing locations and suppressions
|
|
198
202
|
// with incremental scans without joining unrelated diff hunks.
|
|
199
|
-
return scanLineSet(entries, rel, policy);
|
|
203
|
+
return scanLineSet(entries, rel, policy, undefined, languageForFile(rel) === 'javascript' ? sanitizedHtmlLines(content) : new Set());
|
|
200
204
|
}
|
|
201
205
|
/** Apply the same exclusions to full scans, directly selected files and diffs. */
|
|
202
206
|
export function isExcludedPath(rel, policy) {
|
|
@@ -211,6 +215,10 @@ export async function scanPath(options) {
|
|
|
211
215
|
let filesSkipped = 0;
|
|
212
216
|
const findings = [];
|
|
213
217
|
const relRoot = path.relative(cwd, root).split(path.sep).join('/');
|
|
218
|
+
let complete = true;
|
|
219
|
+
const warnings = [];
|
|
220
|
+
const incomplete = (message) => { complete = false; if (warnings.length < 50)
|
|
221
|
+
warnings.push(message); };
|
|
214
222
|
const visit = async (absolute, rel) => {
|
|
215
223
|
options.signal?.throwIfAborted();
|
|
216
224
|
if (isExcludedPath(rel, policy)) {
|
|
@@ -223,11 +231,14 @@ export async function scanPath(options) {
|
|
|
223
231
|
}
|
|
224
232
|
catch {
|
|
225
233
|
filesSkipped += 1;
|
|
234
|
+
incomplete('无法读取 ' + rel);
|
|
226
235
|
return;
|
|
227
236
|
}
|
|
228
237
|
options.signal?.throwIfAborted();
|
|
229
238
|
if (stat.isSymbolicLink()) {
|
|
230
239
|
filesSkipped += 1;
|
|
240
|
+
if (absolute === root)
|
|
241
|
+
incomplete('目标为链接,未读取链接内容');
|
|
231
242
|
return;
|
|
232
243
|
}
|
|
233
244
|
if (stat.isDirectory()) {
|
|
@@ -239,11 +250,14 @@ export async function scanPath(options) {
|
|
|
239
250
|
}
|
|
240
251
|
catch {
|
|
241
252
|
filesSkipped += 1;
|
|
253
|
+
incomplete('无法遍历 ' + rel);
|
|
242
254
|
return;
|
|
243
255
|
}
|
|
244
256
|
for (const entry of entries) {
|
|
245
|
-
if (filesScanned >= maxFiles)
|
|
257
|
+
if (filesScanned >= maxFiles) {
|
|
258
|
+
incomplete('已达到文件数量上限');
|
|
246
259
|
return;
|
|
260
|
+
}
|
|
247
261
|
await visit(path.join(absolute, entry.name), rel === '' ? entry.name : rel + '/' + entry.name);
|
|
248
262
|
}
|
|
249
263
|
return;
|
|
@@ -254,6 +268,7 @@ export async function scanPath(options) {
|
|
|
254
268
|
}
|
|
255
269
|
if (stat.size > maxFileBytes) {
|
|
256
270
|
filesSkipped += 1;
|
|
271
|
+
incomplete(rel + ' 超过文件大小上限');
|
|
257
272
|
return;
|
|
258
273
|
}
|
|
259
274
|
let buffer;
|
|
@@ -262,6 +277,7 @@ export async function scanPath(options) {
|
|
|
262
277
|
}
|
|
263
278
|
catch {
|
|
264
279
|
filesSkipped += 1;
|
|
280
|
+
incomplete('无法读取 ' + rel);
|
|
265
281
|
return;
|
|
266
282
|
}
|
|
267
283
|
if (fileHasBinary(buffer)) {
|
|
@@ -271,11 +287,12 @@ export async function scanPath(options) {
|
|
|
271
287
|
filesScanned += 1;
|
|
272
288
|
options.signal?.throwIfAborted();
|
|
273
289
|
const text = new TextDecoder('utf-8', { fatal: false }).decode(buffer);
|
|
290
|
+
options.onDocument?.({ file: rel, text });
|
|
274
291
|
findings.push(...scanText(text, absolute, rel, policy));
|
|
275
292
|
};
|
|
276
293
|
await visit(root, relRoot);
|
|
277
294
|
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 };
|
|
295
|
+
return { root, filesScanned, filesSkipped, findings, durationMs: Date.now() - started, complete, warnings };
|
|
279
296
|
}
|
|
280
297
|
function severityRank(severity) {
|
|
281
298
|
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 {
|
|
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
|
-
|
|
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
|
|
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
|
|
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 {
|
package/lib/syntax.d.ts
ADDED
|
@@ -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
|
|
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
|
|
173
|
-
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);
|
|
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 {
|
|
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
|
|
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
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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
|
-
|
|
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;
|