dsh-code-security 0.3.8 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -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 +18 -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 +7 -0
- package/lib/privacy.js +54 -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 +23 -25
- 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, isHighEntropyToken } from './privacy.js';
|
|
13
|
+
import { sanitizedHtmlLines } from './syntax.js';
|
|
12
14
|
const DISABLE_NEXT = /secure-review-disable-next-line(?:\s+([\w,-]+))?/;
|
|
13
15
|
const DISABLE_LINE = /secure-review-disable-line(?:\s+([\w,-]+))?/;
|
|
14
16
|
function isComment(line) {
|
|
@@ -48,31 +50,12 @@ function shouldScanFile(file) {
|
|
|
48
50
|
return true;
|
|
49
51
|
return TEXT_EXTENSIONS.test(file);
|
|
50
52
|
}
|
|
51
|
-
/** Shannon 熵;只对 24 位以上且以字母数字为主的 token 判分。 */
|
|
52
|
-
function shannon(text) {
|
|
53
|
-
if (text.length < 16)
|
|
54
|
-
return 0;
|
|
55
|
-
const counts = new Map();
|
|
56
|
-
for (const char of text)
|
|
57
|
-
counts.set(char, (counts.get(char) ?? 0) + 1);
|
|
58
|
-
let entropy = 0;
|
|
59
|
-
for (const count of counts.values()) {
|
|
60
|
-
const p = count / text.length;
|
|
61
|
-
entropy -= p * Math.log2(p);
|
|
62
|
-
}
|
|
63
|
-
return entropy;
|
|
64
|
-
}
|
|
65
53
|
const TOKEN_PATTERN = /['"]([A-Za-z0-9+/=_-]{24,})['"]/g;
|
|
66
|
-
const HIGH_ENTROPY_THRESHOLD = 3.8;
|
|
67
54
|
function detectHighEntropy(line, lineNumber, rel, rule) {
|
|
68
55
|
const findings = [];
|
|
69
56
|
for (const match of line.matchAll(TOKEN_PATTERN)) {
|
|
70
57
|
const token = match[1];
|
|
71
|
-
if (
|
|
72
|
-
continue;
|
|
73
|
-
if (!/\d/.test(token))
|
|
74
|
-
continue;
|
|
75
|
-
if (shannon(token) < HIGH_ENTROPY_THRESHOLD)
|
|
58
|
+
if (!isHighEntropyToken(token))
|
|
76
59
|
continue;
|
|
77
60
|
const id = makeId(rule.id, rel, lineNumber, token.slice(0, 32));
|
|
78
61
|
findings.push({
|
|
@@ -138,7 +121,7 @@ function scanFileRule(entries, rel, rule, disabledNext, selectedLines) {
|
|
|
138
121
|
return checkBlock();
|
|
139
122
|
}
|
|
140
123
|
/** 对带行号的文本行应用规则(全量扫描与 git diff 增量扫描共用)。 */
|
|
141
|
-
export function scanLineSet(entries, rel, policy, selectedLines) {
|
|
124
|
+
export function scanLineSet(entries, rel, policy, selectedLines, sanitized = new Set()) {
|
|
142
125
|
const language = languageForFile(rel);
|
|
143
126
|
const findings = [];
|
|
144
127
|
const applied = RULES.filter((rule) => (rule.languages.includes(language) || rule.languages.includes('any')) && !policyIgnores(policy, rule.id, rel));
|
|
@@ -156,6 +139,8 @@ export function scanLineSet(entries, rel, policy, selectedLines) {
|
|
|
156
139
|
for (const rule of applied) {
|
|
157
140
|
if (rule.id === 'SEC-304')
|
|
158
141
|
continue;
|
|
142
|
+
if (rule.id === 'SEC-009' && sanitized.has(entry.line))
|
|
143
|
+
continue;
|
|
159
144
|
if (shouldSkipLine(rule, currentDisable) || shouldSkipLine(rule, prevDisable.get(entry.line)))
|
|
160
145
|
continue;
|
|
161
146
|
if (rule.fileLevel === true)
|
|
@@ -188,7 +173,7 @@ export function scanLineSet(entries, rel, policy, selectedLines) {
|
|
|
188
173
|
if (finding !== undefined)
|
|
189
174
|
findings.push(finding);
|
|
190
175
|
}
|
|
191
|
-
return findings;
|
|
176
|
+
return findings.map(finding => protectFinding({ ...finding, engine: 'builtin', evidence: 'pattern' }));
|
|
192
177
|
}
|
|
193
178
|
/** 扫描单个文件内容,按语言与行应用规则。 */
|
|
194
179
|
export function scanText(content, file, rel, policy) {
|
|
@@ -196,7 +181,7 @@ export function scanText(content, file, rel, policy) {
|
|
|
196
181
|
const entries = lines.map((text, index) => ({ line: index + 1, text }));
|
|
197
182
|
// Match file rules over contiguous lines, sharing locations and suppressions
|
|
198
183
|
// with incremental scans without joining unrelated diff hunks.
|
|
199
|
-
return scanLineSet(entries, rel, policy);
|
|
184
|
+
return scanLineSet(entries, rel, policy, undefined, languageForFile(rel) === 'javascript' ? sanitizedHtmlLines(content) : new Set());
|
|
200
185
|
}
|
|
201
186
|
/** Apply the same exclusions to full scans, directly selected files and diffs. */
|
|
202
187
|
export function isExcludedPath(rel, policy) {
|
|
@@ -211,6 +196,10 @@ export async function scanPath(options) {
|
|
|
211
196
|
let filesSkipped = 0;
|
|
212
197
|
const findings = [];
|
|
213
198
|
const relRoot = path.relative(cwd, root).split(path.sep).join('/');
|
|
199
|
+
let complete = true;
|
|
200
|
+
const warnings = [];
|
|
201
|
+
const incomplete = (message) => { complete = false; if (warnings.length < 50)
|
|
202
|
+
warnings.push(message); };
|
|
214
203
|
const visit = async (absolute, rel) => {
|
|
215
204
|
options.signal?.throwIfAborted();
|
|
216
205
|
if (isExcludedPath(rel, policy)) {
|
|
@@ -223,11 +212,14 @@ export async function scanPath(options) {
|
|
|
223
212
|
}
|
|
224
213
|
catch {
|
|
225
214
|
filesSkipped += 1;
|
|
215
|
+
incomplete('无法读取 ' + rel);
|
|
226
216
|
return;
|
|
227
217
|
}
|
|
228
218
|
options.signal?.throwIfAborted();
|
|
229
219
|
if (stat.isSymbolicLink()) {
|
|
230
220
|
filesSkipped += 1;
|
|
221
|
+
if (absolute === root)
|
|
222
|
+
incomplete('目标为链接,未读取链接内容');
|
|
231
223
|
return;
|
|
232
224
|
}
|
|
233
225
|
if (stat.isDirectory()) {
|
|
@@ -239,11 +231,14 @@ export async function scanPath(options) {
|
|
|
239
231
|
}
|
|
240
232
|
catch {
|
|
241
233
|
filesSkipped += 1;
|
|
234
|
+
incomplete('无法遍历 ' + rel);
|
|
242
235
|
return;
|
|
243
236
|
}
|
|
244
237
|
for (const entry of entries) {
|
|
245
|
-
if (filesScanned >= maxFiles)
|
|
238
|
+
if (filesScanned >= maxFiles) {
|
|
239
|
+
incomplete('已达到文件数量上限');
|
|
246
240
|
return;
|
|
241
|
+
}
|
|
247
242
|
await visit(path.join(absolute, entry.name), rel === '' ? entry.name : rel + '/' + entry.name);
|
|
248
243
|
}
|
|
249
244
|
return;
|
|
@@ -254,6 +249,7 @@ export async function scanPath(options) {
|
|
|
254
249
|
}
|
|
255
250
|
if (stat.size > maxFileBytes) {
|
|
256
251
|
filesSkipped += 1;
|
|
252
|
+
incomplete(rel + ' 超过文件大小上限');
|
|
257
253
|
return;
|
|
258
254
|
}
|
|
259
255
|
let buffer;
|
|
@@ -262,6 +258,7 @@ export async function scanPath(options) {
|
|
|
262
258
|
}
|
|
263
259
|
catch {
|
|
264
260
|
filesSkipped += 1;
|
|
261
|
+
incomplete('无法读取 ' + rel);
|
|
265
262
|
return;
|
|
266
263
|
}
|
|
267
264
|
if (fileHasBinary(buffer)) {
|
|
@@ -271,11 +268,12 @@ export async function scanPath(options) {
|
|
|
271
268
|
filesScanned += 1;
|
|
272
269
|
options.signal?.throwIfAborted();
|
|
273
270
|
const text = new TextDecoder('utf-8', { fatal: false }).decode(buffer);
|
|
271
|
+
options.onDocument?.({ file: rel, text });
|
|
274
272
|
findings.push(...scanText(text, absolute, rel, policy));
|
|
275
273
|
};
|
|
276
274
|
await visit(root, relRoot);
|
|
277
275
|
findings.sort((a, b) => severityRank(b.severity) - severityRank(a.severity) || a.file.localeCompare(b.file) || a.line - b.line);
|
|
278
|
-
return { root, filesScanned, filesSkipped, findings, durationMs: Date.now() - started };
|
|
276
|
+
return { root, filesScanned, filesSkipped, findings, durationMs: Date.now() - started, complete, warnings };
|
|
279
277
|
}
|
|
280
278
|
function severityRank(severity) {
|
|
281
279
|
return severity === 'critical' ? 4 : severity === 'high' ? 3 : severity === 'medium' ? 2 : 1;
|
package/lib/state.d.ts
CHANGED
|
@@ -5,7 +5,14 @@
|
|
|
5
5
|
* @module dsh-code-security/state
|
|
6
6
|
*/
|
|
7
7
|
import type { Finding } from './scanner.js';
|
|
8
|
+
import type { SourceEngine } from './config.js';
|
|
9
|
+
import type { EngineCoverage } from './engines.js';
|
|
10
|
+
import type { ScanScope } from './audit.js';
|
|
8
11
|
export interface ScanStateEntry {
|
|
12
|
+
complete?: boolean;
|
|
13
|
+
coverage?: EngineCoverage[];
|
|
14
|
+
engines?: SourceEngine[];
|
|
15
|
+
scope?: ScanScope;
|
|
9
16
|
mode: string;
|
|
10
17
|
target: string;
|
|
11
18
|
time: string;
|
package/lib/state.js
CHANGED
|
@@ -6,19 +6,33 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import fs from 'node:fs/promises';
|
|
8
8
|
import path from 'node:path';
|
|
9
|
-
import {
|
|
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;
|