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/engines.js
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/** Optional scanners run over bounded, owned snapshots; no project rules or install scripts. */
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { createHash } from 'node:crypto';
|
|
6
|
+
import { homedir, tmpdir } from 'node:os';
|
|
7
|
+
import { protectFinding, redactText } from './privacy.js';
|
|
8
|
+
import { sanitizedHtmlLines } from './syntax.js';
|
|
9
|
+
const rulePath = (file) => fileURLToPath(new URL('../rules/' + file, import.meta.url));
|
|
10
|
+
const command = (id, cfg) => cfg.enginePaths[id] ?? (id === 'osv' ? 'osv-scanner' : id);
|
|
11
|
+
const canonical = (file) => process.platform === 'win32' ? path.resolve(file).toLowerCase() : path.resolve(file);
|
|
12
|
+
const missing = (error) => /ENOENT|not found|cannot find|找不到|不存在/i.test(String(error?.code ?? '') + ' ' + String(error?.message ?? error));
|
|
13
|
+
/** File modification dates describe the cache, not the age of every advisory inside it. */
|
|
14
|
+
export async function databaseHealth(cfg) {
|
|
15
|
+
const defaultRoot = process.platform === 'win32' ? process.env.LOCALAPPDATA : process.platform === 'darwin' ? path.join(homedir(), 'Library', 'Caches') : process.env.XDG_CACHE_HOME || path.join(homedir(), '.cache');
|
|
16
|
+
const root = cfg.osvDatabaseDir || process.env.OSV_SCALIBR_LOCAL_DB_CACHE_DIRECTORY || process.env.OSV_SCANNER_LOCAL_DB_CACHE_DIRECTORY || defaultRoot || tmpdir();
|
|
17
|
+
const directory = path.join(root, 'osv-scalibr'), databases = [];
|
|
18
|
+
try {
|
|
19
|
+
for (const entry of (await fs.readdir(directory, { withFileTypes: true })).slice(0, 200)) {
|
|
20
|
+
if (!entry.isDirectory() || entry.isSymbolicLink())
|
|
21
|
+
continue;
|
|
22
|
+
try {
|
|
23
|
+
const info = await fs.lstat(path.join(directory, entry.name, 'all.zip'));
|
|
24
|
+
if (info.isFile() && !info.isSymbolicLink())
|
|
25
|
+
databases.push({ ecosystem: entry.name, fileModifiedAt: info.mtime.toISOString() });
|
|
26
|
+
}
|
|
27
|
+
catch { /* An ecosystem without an archive is unavailable. */ }
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
catch { /* A missing/unreadable cache is reported as unavailable, never an empty audit. */ }
|
|
31
|
+
return { directory, available: databases.length > 0, databases };
|
|
32
|
+
}
|
|
33
|
+
export async function probeEngine(engine, cfg, runner, signal) {
|
|
34
|
+
let owned;
|
|
35
|
+
try {
|
|
36
|
+
signal?.throwIfAborted();
|
|
37
|
+
if (engine === 'semgrep')
|
|
38
|
+
owned = await snapshot([], cfg);
|
|
39
|
+
const result = await runner.run([command(engine, cfg), engine === 'betterleaks' ? 'version' : '--version', ...(engine === 'semgrep' ? ['--metrics=off', '--disable-version-check'] : [])], { timeoutMs: Math.min(8000, cfg.engineTimeoutMs), signal,
|
|
40
|
+
...(owned ? { env: { SEMGREP_APP_TOKEN: '', SEMGREP_SETTINGS_FILE: path.join(owned.dir, '.semgrep-settings.yml'), SEMGREP_SEND_METRICS: 'off', SEMGREP_ENABLE_VERSION_CHECK: '0' } } : {}) });
|
|
41
|
+
signal?.throwIfAborted();
|
|
42
|
+
if (result.signal || result.exitCode !== 0)
|
|
43
|
+
return { engine, status: missing(result.stderr) ? 'not-installed' : 'failed', reason: 'version-check-failed' };
|
|
44
|
+
const version = /\b\d+\.\d+\.\d+\b/.exec(result.stdout)?.[0];
|
|
45
|
+
if (!version || !version.startsWith(engine === 'osv' ? '2.' : '1.'))
|
|
46
|
+
return { engine, status: 'failed', reason: 'unsupported-version' };
|
|
47
|
+
return { engine, status: 'completed', version };
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
signal?.throwIfAborted();
|
|
51
|
+
return { engine, status: missing(error) ? 'not-installed' : 'failed', reason: missing(error) ? 'executable-missing' : 'version-check-failed' };
|
|
52
|
+
}
|
|
53
|
+
finally {
|
|
54
|
+
await owned?.dispose();
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
async function snapshot(documents, cfg) {
|
|
58
|
+
const parent = path.resolve(cfg.stateDir, 'engine-tmp');
|
|
59
|
+
await fs.mkdir(parent, { recursive: true });
|
|
60
|
+
const dir = await fs.mkdtemp(path.join(parent, 'scan-')), files = new Map();
|
|
61
|
+
let bytes = 0, skipped = 0;
|
|
62
|
+
try {
|
|
63
|
+
for (const [i, doc] of documents.entries()) {
|
|
64
|
+
if (Buffer.byteLength(doc.text) > cfg.maxFileBytes || bytes + Buffer.byteLength(doc.text) > 64 * 1024 * 1024 || files.size >= cfg.maxFiles) {
|
|
65
|
+
skipped++;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
const normalized = doc.file.replace(/\\/g, '/');
|
|
69
|
+
const relative = !path.isAbsolute(normalized) && !normalized.split('/').some(p => p === '..' || p.includes(':')) ? normalized : 'external/' + i + '/' + path.basename(normalized);
|
|
70
|
+
const file = path.resolve(dir, relative);
|
|
71
|
+
if (!file.startsWith(dir + path.sep))
|
|
72
|
+
throw new Error('Invalid snapshot path');
|
|
73
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
74
|
+
await fs.writeFile(file, doc.text, { flag: 'wx', mode: 0o600 });
|
|
75
|
+
files.set(canonical(file), doc);
|
|
76
|
+
bytes += Buffer.byteLength(doc.text);
|
|
77
|
+
}
|
|
78
|
+
return { dir, files, skipped, async dispose() { if (path.dirname(dir) !== parent || !path.basename(dir).startsWith('scan-'))
|
|
79
|
+
throw new Error('Invalid owned snapshot'); await fs.rm(dir, { recursive: true, force: true }); } };
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
if (path.dirname(dir) === parent && path.basename(dir).startsWith('scan-'))
|
|
83
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
84
|
+
throw error;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
function documentFor(name, dir, files) {
|
|
88
|
+
if (typeof name !== 'string')
|
|
89
|
+
return undefined;
|
|
90
|
+
return files.get(canonical(path.isAbsolute(name) ? name : path.resolve(dir, name)));
|
|
91
|
+
}
|
|
92
|
+
function validLine(value) { return Number.isSafeInteger(value) && value >= 1; }
|
|
93
|
+
function selected(doc, start, end) {
|
|
94
|
+
return !doc.selectedLines || [...doc.selectedLines].some(line => line >= start && line <= end);
|
|
95
|
+
}
|
|
96
|
+
function normalizeFindings(engine, report, dir, files) {
|
|
97
|
+
const rows = engine === 'betterleaks' ? report : report?.results;
|
|
98
|
+
if (!Array.isArray(rows))
|
|
99
|
+
throw new Error('invalid-json-schema');
|
|
100
|
+
const findings = [];
|
|
101
|
+
let incomplete = engine === 'semgrep' && (!Array.isArray(report.errors) || report.errors.length > 0);
|
|
102
|
+
const lines = new Map([...files.values()].map(doc => [doc, doc.text.split(/\r?\n/)]));
|
|
103
|
+
const sanitized = new Map();
|
|
104
|
+
if (rows.length > 5000)
|
|
105
|
+
incomplete = true;
|
|
106
|
+
for (const row of rows.slice(0, 5000)) {
|
|
107
|
+
const doc = documentFor(engine === 'betterleaks' ? row.File : row.path, dir, files);
|
|
108
|
+
const line = engine === 'betterleaks' ? row.StartLine : row.start?.line;
|
|
109
|
+
const endLine = engine === 'betterleaks' ? row.EndLine : row.end?.line;
|
|
110
|
+
const rule = engine === 'betterleaks' ? row.RuleID : row.check_id;
|
|
111
|
+
if (!doc || !validLine(line) || !validLine(endLine) || endLine < line || endLine > lines.get(doc).length || typeof rule !== 'string' || rule.length > 300) {
|
|
112
|
+
incomplete = true;
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (!selected(doc, line, endLine))
|
|
116
|
+
continue;
|
|
117
|
+
if (engine === 'semgrep' && rule === 'dsh-js-html-input') {
|
|
118
|
+
if (!sanitized.has(doc))
|
|
119
|
+
sanitized.set(doc, sanitizedHtmlLines(doc.text));
|
|
120
|
+
if (sanitized.get(doc).has(line))
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
const snippet = lines.get(doc).slice(line - 1, endLine).join('\n').slice(0, 200);
|
|
124
|
+
const message = engine === 'betterleaks' ? row.Description : row.extra?.message;
|
|
125
|
+
const title = engine === 'betterleaks' ? '检测到平台格式的密钥' : typeof message === 'string' ? message.slice(0, 200) : rule;
|
|
126
|
+
const cwe = engine === 'betterleaks' ? 'CWE-798' : String(row.extra?.metadata?.cwe?.[0] ?? row.extra?.metadata?.cwe ?? 'CWE-20').match(/CWE-\d+/)?.[0] ?? 'CWE-20';
|
|
127
|
+
const severity = engine === 'betterleaks' ? 'high' : row.extra?.severity === 'ERROR' ? 'high' : row.extra?.severity === 'INFO' ? 'low' : 'medium';
|
|
128
|
+
const finding = { id: 'EXT-' + createHash('sha256').update(engine + '|' + rule + '|' + doc.file + '|' + line).digest('hex').slice(0, 12), ruleId: engine + ':' + rule, engine,
|
|
129
|
+
title, cwe, severity, confidence: engine === 'betterleaks' ? row.Attributes?.confidence === 'low' ? 'low' : 'high' : row.extra?.metadata?.confidence === 'HIGH' ? 'high' : 'medium',
|
|
130
|
+
evidence: engine === 'semgrep' ? 'dataflow' : 'pattern', file: doc.file, line, endLine, snippet, message: typeof message === 'string' ? message.slice(0, 2000) : title };
|
|
131
|
+
findings.push(protectFinding(finding));
|
|
132
|
+
}
|
|
133
|
+
return { findings, incomplete };
|
|
134
|
+
}
|
|
135
|
+
async function execute(engine, args, cfg, runner, dir, signal) {
|
|
136
|
+
signal?.throwIfAborted();
|
|
137
|
+
const ctl = new AbortController(), timer = setTimeout(() => ctl.abort(new Error('engine timed out')), cfg.engineTimeoutMs);
|
|
138
|
+
const databaseRoot = cfg.osvDatabaseDir || process.env.OSV_SCALIBR_LOCAL_DB_CACHE_DIRECTORY || process.env.OSV_SCANNER_LOCAL_DB_CACHE_DIRECTORY;
|
|
139
|
+
try {
|
|
140
|
+
const result = await runner.run([command(engine, cfg), ...args], { cwd: dir, timeoutMs: cfg.engineTimeoutMs, signal: signal ? AbortSignal.any([signal, ctl.signal]) : ctl.signal,
|
|
141
|
+
...(engine === 'osv' && databaseRoot ? { env: { OSV_SCANNER_LOCAL_DB_CACHE_DIRECTORY: databaseRoot, OSV_SCALIBR_LOCAL_DB_CACHE_DIRECTORY: databaseRoot } } : engine === 'semgrep' ? { env: { SEMGREP_APP_TOKEN: '', SEMGREP_SETTINGS_FILE: path.join(dir, '.semgrep-settings.yml'), SEMGREP_SEND_METRICS: 'off', SEMGREP_ENABLE_VERSION_CHECK: '0' } } : {}) });
|
|
142
|
+
signal?.throwIfAborted();
|
|
143
|
+
if (ctl.signal.aborted || result.signal)
|
|
144
|
+
return { coverage: { engine, status: 'timed-out', reason: 'execution-timeout' } };
|
|
145
|
+
if (result.exitCode !== 0 && result.exitCode !== 1)
|
|
146
|
+
return { coverage: { engine, status: 'failed', reason: engine === 'osv' && /database|cache|offline/i.test(result.stderr) ? 'database-unavailable' : 'execution-failed' } };
|
|
147
|
+
try {
|
|
148
|
+
return { report: JSON.parse(result.stdout.replace(/^\uFEFF/, '').trim()), exitCode: result.exitCode };
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
return { coverage: { engine, status: 'failed', reason: 'invalid-or-truncated-json' } };
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
catch (error) {
|
|
155
|
+
signal?.throwIfAborted();
|
|
156
|
+
return { coverage: { engine, status: missing(error) ? 'not-installed' : ctl.signal.aborted ? 'timed-out' : 'failed', reason: missing(error) ? 'executable-missing' : ctl.signal.aborted ? 'execution-timeout' : 'execution-failed' } };
|
|
157
|
+
}
|
|
158
|
+
finally {
|
|
159
|
+
clearTimeout(timer);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
export async function scanExternal(engine, documents, cfg, runner, signal) {
|
|
163
|
+
const started = Date.now(), health = await probeEngine(engine, cfg, runner, signal);
|
|
164
|
+
signal?.throwIfAborted();
|
|
165
|
+
if (health.status !== 'completed')
|
|
166
|
+
return { findings: [], coverage: health };
|
|
167
|
+
if (!documents.length)
|
|
168
|
+
return { findings: [], coverage: { ...health, status: 'not-run', reason: 'no-eligible-files' } };
|
|
169
|
+
const owned = await snapshot(documents, cfg);
|
|
170
|
+
try {
|
|
171
|
+
const args = engine === 'betterleaks' ? ['dir', owned.dir, '--config', rulePath('betterleaks.toml'), '--validation=false', '--redact=100', '--no-banner', '--no-color', '--ignore-gitleaks-allow', '--max-archive-depth=0', '--max-decode-depth=0', '--report-format=json', '--report-path=-', '--timeout', String(Math.ceil(cfg.engineTimeoutMs / 1000))]
|
|
172
|
+
: ['scan', '--config', rulePath('semgrep.yml'), '--metrics=off', '--disable-version-check', '--no-git-ignore', '--no-rewrite-rule-ids', '--json', '--quiet', '--max-target-bytes', String(cfg.maxFileBytes), '--timeout', '15', owned.dir];
|
|
173
|
+
const run = await execute(engine, args, cfg, runner, owned.dir, signal);
|
|
174
|
+
if (run.coverage)
|
|
175
|
+
return { findings: [], coverage: { ...run.coverage, version: health.version } };
|
|
176
|
+
try {
|
|
177
|
+
const normalized = normalizeFindings(engine, run.report, owned.dir, owned.files);
|
|
178
|
+
return { findings: normalized.findings, coverage: { ...health, status: normalized.incomplete || owned.skipped ? 'partial' : 'completed', ...(normalized.incomplete || owned.skipped ? { reason: 'incomplete-coverage' } : {}), findingsCount: normalized.findings.length, durationMs: Date.now() - started } };
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
return { findings: [], coverage: { ...health, status: 'failed', reason: 'invalid-json-schema' } };
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
finally {
|
|
185
|
+
await owned.dispose();
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
export function mergeFindings(findings) {
|
|
189
|
+
const grouped = new Map();
|
|
190
|
+
const rank = { critical: 4, high: 3, medium: 2, low: 1 };
|
|
191
|
+
const confidenceRank = { high: 3, medium: 2, low: 1 };
|
|
192
|
+
for (const finding of findings) {
|
|
193
|
+
const key = finding.file + '|' + finding.line + '|' + finding.cwe;
|
|
194
|
+
const previous = grouped.get(key);
|
|
195
|
+
if (!previous)
|
|
196
|
+
grouped.set(key, { ...finding, sources: [finding.engine ?? 'builtin'] });
|
|
197
|
+
else
|
|
198
|
+
grouped.set(key, { ...previous, severity: rank[finding.severity] > rank[previous.severity] ? finding.severity : previous.severity,
|
|
199
|
+
confidence: confidenceRank[finding.confidence] > confidenceRank[previous.confidence] ? finding.confidence : previous.confidence, evidence: finding.evidence === 'dataflow' ? 'dataflow' : previous.evidence,
|
|
200
|
+
sources: [...new Set([...(previous.sources ?? []), finding.engine ?? 'builtin'])] });
|
|
201
|
+
}
|
|
202
|
+
return [...grouped.values()].sort((a, b) => rank[b.severity] - rank[a.severity] || a.file.localeCompare(b.file) || a.line - b.line);
|
|
203
|
+
}
|
|
204
|
+
export async function auditDependencies(documents, mode, cfg, runner, signal) {
|
|
205
|
+
const health = await probeEngine('osv', cfg, runner, signal);
|
|
206
|
+
signal?.throwIfAborted();
|
|
207
|
+
if (health.status !== 'completed')
|
|
208
|
+
return { mode, coverage: health, packagesScanned: 0, vulnerabilities: [] };
|
|
209
|
+
if (!documents.length)
|
|
210
|
+
return { mode, coverage: { ...health, status: 'not-run', reason: 'no-lockfiles' }, packagesScanned: 0, vulnerabilities: [] };
|
|
211
|
+
const owned = await snapshot(documents, cfg);
|
|
212
|
+
try {
|
|
213
|
+
const args = ['scan', 'source', '--recursive', '--no-ignore', '--format=json', '--all-packages', '--no-resolve', '--no-call-analysis=go', '--no-call-analysis=rust', '--config', rulePath('osv.toml'), ...(mode === 'offline' ? ['--offline'] : []), owned.dir];
|
|
214
|
+
const run = await execute('osv', args, cfg, runner, owned.dir, signal);
|
|
215
|
+
if (run.coverage)
|
|
216
|
+
return { mode, coverage: { ...run.coverage, version: health.version }, packagesScanned: 0, vulnerabilities: [] };
|
|
217
|
+
if (!Array.isArray(run.report?.results))
|
|
218
|
+
return { mode, coverage: { ...health, status: 'failed', reason: 'invalid-json-schema' }, packagesScanned: 0, vulnerabilities: [] };
|
|
219
|
+
let packagesScanned = 0, incomplete = owned.skipped > 0;
|
|
220
|
+
const vulnerabilities = [];
|
|
221
|
+
for (const source of run.report.results) {
|
|
222
|
+
const doc = documentFor(source.source?.path, owned.dir, owned.files);
|
|
223
|
+
if (!doc || !Array.isArray(source.packages)) {
|
|
224
|
+
incomplete = true;
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
for (const item of source.packages) {
|
|
228
|
+
const pkg = item.package;
|
|
229
|
+
if (typeof pkg?.name !== 'string' || typeof pkg.version !== 'string') {
|
|
230
|
+
incomplete = true;
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
// OSV intentionally does not query public advisories for local packages.
|
|
234
|
+
if (typeof pkg.purl === 'string' && /[?&]source=LOCAL(?:&|$)/i.test(pkg.purl)) {
|
|
235
|
+
incomplete = true;
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
packagesScanned++;
|
|
239
|
+
for (const vuln of item.vulnerabilities ?? []) {
|
|
240
|
+
if (typeof vuln.id !== 'string') {
|
|
241
|
+
incomplete = true;
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
const fixedVersions = (vuln.affected ?? []).filter((a) => a.package?.name === pkg.name && a.package?.ecosystem === pkg.ecosystem).flatMap((a) => (a.ranges ?? []).flatMap((r) => (r.events ?? []).flatMap((e) => typeof e.fixed === 'string' ? [e.fixed] : [])));
|
|
245
|
+
vulnerabilities.push({ id: vuln.id, aliases: Array.isArray(vuln.aliases) ? vuln.aliases.filter((x) => typeof x === 'string') : [], package: redactText(pkg.name), version: redactText(pkg.version), ecosystem: pkg.ecosystem,
|
|
246
|
+
file: doc.file, summary: redactText(String(vuln.summary ?? vuln.id).slice(0, 2000)), severity: vuln.database_specific?.severity ?? vuln.ecosystem_specific?.severity ?? 'unknown', fixedVersions: [...new Set(fixedVersions)].slice(0, 50), url: 'https://osv.dev/vulnerability/' + encodeURIComponent(vuln.id) });
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return { mode, coverage: { ...health, status: incomplete ? 'partial' : packagesScanned ? 'completed' : 'not-run', ...(incomplete ? { reason: 'incomplete-coverage' } : !packagesScanned ? { reason: 'no-resolved-packages' } : {}), findingsCount: vulnerabilities.length }, packagesScanned, vulnerabilities };
|
|
251
|
+
}
|
|
252
|
+
finally {
|
|
253
|
+
await owned.dispose();
|
|
254
|
+
}
|
|
255
|
+
}
|
package/lib/extra.d.ts
CHANGED
|
@@ -5,4 +5,5 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { type ResolvedSecureConfig } from './config.js';
|
|
7
7
|
import type { SecureToolDefinition } from './tools.js';
|
|
8
|
-
|
|
8
|
+
import type { ProcessRunner } from './runner.js';
|
|
9
|
+
export declare function buildExtraTools(cfg: ResolvedSecureConfig, cwd: string, signal?: AbortSignal, runner?: ProcessRunner): SecureToolDefinition[];
|
package/lib/extra.js
CHANGED
|
@@ -7,6 +7,10 @@ import path from 'node:path';
|
|
|
7
7
|
import { optionalString, requiredString } from './config.js';
|
|
8
8
|
import { scanDeps } from './deps.js';
|
|
9
9
|
import { findingFingerprint, loadState, saveBaseline } from './state.js';
|
|
10
|
+
import { scanArguments } from './tools.js';
|
|
11
|
+
import { auditDependencies } from './engines.js';
|
|
12
|
+
import { loadPolicy } from './policy.js';
|
|
13
|
+
import fs from 'node:fs/promises';
|
|
10
14
|
function compileParameters(spec) {
|
|
11
15
|
const properties = {};
|
|
12
16
|
const required = [];
|
|
@@ -18,11 +22,13 @@ function compileParameters(spec) {
|
|
|
18
22
|
node.type = prop.type;
|
|
19
23
|
if (typeof prop?.description === 'string')
|
|
20
24
|
node.description = prop.description;
|
|
25
|
+
if (prop?.enum !== undefined)
|
|
26
|
+
node.enum = prop.enum;
|
|
21
27
|
properties[key] = node;
|
|
22
28
|
}
|
|
23
29
|
return { type: 'object', properties, ...(required.length > 0 ? { required } : {}) };
|
|
24
30
|
}
|
|
25
|
-
export function buildExtraTools(cfg, cwd, signal) {
|
|
31
|
+
export function buildExtraTools(cfg, cwd, signal, runner) {
|
|
26
32
|
const stateDir = cfg.stateDir;
|
|
27
33
|
const secureBaseline = {
|
|
28
34
|
name: 'secure_baseline',
|
|
@@ -58,9 +64,10 @@ export function buildExtraTools(cfg, cwd, signal) {
|
|
|
58
64
|
};
|
|
59
65
|
const secureDeps = {
|
|
60
66
|
name: 'secure_deps',
|
|
61
|
-
description: '
|
|
67
|
+
description: '解析依赖清单与锁文件。默认仅离线清单;audit=offline 使用本地 OSV 数据库,audit=online 明确允许向 OSV 查询包名/版本等依赖信息。不执行包管理器或安装脚本。清单检查不等于已知漏洞审计。',
|
|
62
68
|
parameters: compileParameters({
|
|
63
69
|
target: { type: 'string', description: '扫描根目录(可选,默认当前工作区)。' },
|
|
70
|
+
audit: { type: 'string', enum: ['none', 'offline', 'online'], description: '默认 none。offline 需要本地漏洞库;online 会向 OSV 查询依赖信息,不上传源码。' },
|
|
64
71
|
}),
|
|
65
72
|
output: {
|
|
66
73
|
schema: {
|
|
@@ -85,10 +92,28 @@ export function buildExtraTools(cfg, cwd, signal) {
|
|
|
85
92
|
},
|
|
86
93
|
},
|
|
87
94
|
async execute(rawArgs) {
|
|
88
|
-
const args = (rawArgs
|
|
95
|
+
const args = scanArguments(rawArgs, ['target', 'audit']);
|
|
96
|
+
const mode = args.audit ?? 'none';
|
|
97
|
+
if (!['none', 'offline', 'online'].includes(String(mode)))
|
|
98
|
+
throw new Error('audit 只支持 none、offline、online');
|
|
89
99
|
const target = optionalString(args, 'target');
|
|
90
100
|
const root = path.resolve(cwd, target ?? '.');
|
|
91
|
-
|
|
101
|
+
const documents = [];
|
|
102
|
+
let bytes = 0, truncated = false;
|
|
103
|
+
const rootDir = (await fs.lstat(root)).isDirectory() ? root : path.dirname(root);
|
|
104
|
+
const inventory = await scanDeps(root, { maxFiles: cfg.maxFiles, maxFileBytes: cfg.maxFileBytes, signal, policy: await loadPolicy(cwd),
|
|
105
|
+
...(mode === 'none' ? {} : { onDocument(doc) { if (bytes + Buffer.byteLength(doc.text) > 64 * 1024 * 1024) {
|
|
106
|
+
truncated = true;
|
|
107
|
+
return;
|
|
108
|
+
} ; bytes += Buffer.byteLength(doc.text); documents.push({ file: path.relative(cwd, path.join(rootDir, doc.file)).split(path.sep).join('/'), text: doc.text }); } }) });
|
|
109
|
+
if (mode === 'none')
|
|
110
|
+
return { ...inventory, audited: false, audit: null };
|
|
111
|
+
if (!runner)
|
|
112
|
+
throw new Error('OSV 审计需要宿主 subprocess 服务');
|
|
113
|
+
const audit = await auditDependencies(documents, mode, cfg, runner, signal);
|
|
114
|
+
if (truncated && audit.coverage.status === 'completed')
|
|
115
|
+
audit.coverage = { ...audit.coverage, status: 'partial', reason: 'snapshot-size-limit' };
|
|
116
|
+
return { ...inventory, audited: audit.coverage.status === 'completed', audit, passed: inventory.complete && audit.coverage.status === 'completed' && audit.vulnerabilities.length === 0 };
|
|
92
117
|
},
|
|
93
118
|
timeoutMs: 30000,
|
|
94
119
|
};
|
package/lib/index.d.ts
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
*
|
|
4
4
|
* 插件导出 apply(ctx, config):注册 secure_scan / secure_diff / secure_fix_verify /
|
|
5
5
|
* secure_report / secure_export / secure_baseline / secure_deps / secure_policy_show /
|
|
6
|
-
* secure_policy_set / secure_health
|
|
7
|
-
*
|
|
6
|
+
* secure_policy_set / secure_health 十个工具。内置规则与可选本地引擎共用
|
|
7
|
+
* 脱敏、覆盖状态与基线;子进程通过官方服务执行,设置面板与自动检查按需挂载。
|
|
8
8
|
*
|
|
9
9
|
* @module dsh-code-security
|
|
10
10
|
*/
|
|
@@ -23,6 +23,7 @@ export interface SecurePluginContext {
|
|
|
23
23
|
on?(event: string, listener: (...args: any[]) => unknown, options?: {
|
|
24
24
|
prepend?: boolean;
|
|
25
25
|
}): (() => void) | void;
|
|
26
|
+
inject?(services: string[], callback: (ctx: any) => void): unknown;
|
|
26
27
|
}
|
|
27
28
|
export declare function apply(ctx: SecurePluginContext, config?: SecureConfig | null): void;
|
|
28
29
|
export * from './config.js';
|
|
@@ -35,3 +36,8 @@ export * from './scanner.js';
|
|
|
35
36
|
export * from './state.js';
|
|
36
37
|
export * from './extra.js';
|
|
37
38
|
export * from './tools.js';
|
|
39
|
+
export * from './engines.js';
|
|
40
|
+
export * from './audit.js';
|
|
41
|
+
export * from './privacy.js';
|
|
42
|
+
export * from './workbench.js';
|
|
43
|
+
export * from './automatic.js';
|
package/lib/index.js
CHANGED
|
@@ -3,30 +3,65 @@
|
|
|
3
3
|
*
|
|
4
4
|
* 插件导出 apply(ctx, config):注册 secure_scan / secure_diff / secure_fix_verify /
|
|
5
5
|
* secure_report / secure_export / secure_baseline / secure_deps / secure_policy_show /
|
|
6
|
-
* secure_policy_set / secure_health
|
|
7
|
-
*
|
|
6
|
+
* secure_policy_set / secure_health 十个工具。内置规则与可选本地引擎共用
|
|
7
|
+
* 脱敏、覆盖状态与基线;子进程通过官方服务执行,设置面板与自动检查按需挂载。
|
|
8
8
|
*
|
|
9
9
|
* @module dsh-code-security
|
|
10
10
|
*/
|
|
11
11
|
import { resolveConfig } from './config.js';
|
|
12
12
|
import { createSubprocessRunner } from './runner.js';
|
|
13
13
|
import { buildSecureTools } from './tools.js';
|
|
14
|
+
import { SecurityWorkbench, installSecurityWorkbench } from './workbench.js';
|
|
15
|
+
import { AutomaticSecurity } from './automatic.js';
|
|
14
16
|
export const name = 'code-security';
|
|
15
17
|
export const inject = ['tools', 'subprocess'];
|
|
16
18
|
export function apply(ctx, config) {
|
|
17
19
|
let cfg;
|
|
20
|
+
let configurationError;
|
|
18
21
|
try {
|
|
19
22
|
cfg = resolveConfig(config);
|
|
20
23
|
}
|
|
21
24
|
catch (error) {
|
|
22
|
-
|
|
25
|
+
configurationError = error instanceof Error ? error.message : String(error);
|
|
26
|
+
console.warn('[dsh-code-security] ' + configurationError);
|
|
23
27
|
cfg = resolveConfig(null);
|
|
24
28
|
}
|
|
25
29
|
const runner = createSubprocessRunner((spec) => ctx.subprocess.spawn(spec), 10000, 60000);
|
|
26
|
-
const
|
|
30
|
+
const configuredTools = (config, root) => buildSecureTools(config, root, runner).map(definition => ({ ...definition, async execute(args, exec) {
|
|
31
|
+
if (configurationError && ['secure_scan', 'secure_diff', 'secure_fix_verify', 'secure_deps', 'secure_baseline'].includes(definition.name))
|
|
32
|
+
throw new Error('请先修复插件配置,检查未运行 / Fix the plugin configuration before scanning: ' + configurationError);
|
|
33
|
+
const value = await definition.execute(args, exec);
|
|
34
|
+
return configurationError && definition.name === 'secure_health' ? { ...value, ok: false, configurationError } : value;
|
|
35
|
+
} }));
|
|
36
|
+
const tools = configuredTools(cfg, process.cwd());
|
|
27
37
|
const disposers = [];
|
|
38
|
+
const workbench = typeof ctx.inject === 'function' ? new SecurityWorkbench(cfg, { tools: configuredTools }) : undefined;
|
|
39
|
+
if (workbench) {
|
|
40
|
+
installSecurityWorkbench(ctx, workbench);
|
|
41
|
+
const automatic = new AutomaticSecurity(workbench);
|
|
42
|
+
ctx.inject?.(['sessions', 'systemPrompt'], host => {
|
|
43
|
+
const detach = automatic.attach(host);
|
|
44
|
+
if (typeof host.effect === 'function')
|
|
45
|
+
host.effect(() => detach);
|
|
46
|
+
else
|
|
47
|
+
host.on?.('dispose', detach);
|
|
48
|
+
});
|
|
49
|
+
ctx.on?.('dispose', async () => { automatic.dispose(); await automatic.whenIdle(); await workbench.dispose(); });
|
|
50
|
+
}
|
|
28
51
|
for (const definition of tools) {
|
|
29
|
-
disposers.push(ctx.tools.register(definition)
|
|
52
|
+
disposers.push(ctx.tools.register({ ...definition, async execute(args, exec) {
|
|
53
|
+
if (!workbench)
|
|
54
|
+
return definition.execute(args, exec);
|
|
55
|
+
const context = exec;
|
|
56
|
+
const root = context?.agent?.session?.header?.cwd || process.cwd();
|
|
57
|
+
const configured = configuredTools(await workbench.configFor(root), root).find(t => t.name === definition.name);
|
|
58
|
+
const value = await configured.execute(args, exec);
|
|
59
|
+
try {
|
|
60
|
+
await workbench.remember(root, context?.agent?.session?.id);
|
|
61
|
+
}
|
|
62
|
+
catch { /* Indexing must not turn a successful check into an error. */ }
|
|
63
|
+
return value;
|
|
64
|
+
} }));
|
|
30
65
|
}
|
|
31
66
|
if (typeof ctx.on === 'function') {
|
|
32
67
|
ctx.on('tools/pre-execute', async (exec, next) => {
|
|
@@ -56,3 +91,8 @@ export * from './scanner.js';
|
|
|
56
91
|
export * from './state.js';
|
|
57
92
|
export * from './extra.js';
|
|
58
93
|
export * from './tools.js';
|
|
94
|
+
export * from './engines.js';
|
|
95
|
+
export * from './audit.js';
|
|
96
|
+
export * from './privacy.js';
|
|
97
|
+
export * from './workbench.js';
|
|
98
|
+
export * from './automatic.js';
|
package/lib/privacy.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { Finding } from './scanner.js';
|
|
2
|
+
export declare function isHighEntropyToken(value: string): boolean;
|
|
3
|
+
export declare function evidenceFingerprint(finding: Pick<Finding, 'ruleId' | 'file' | 'snippet'> & {
|
|
4
|
+
fingerprint?: string;
|
|
5
|
+
}): string;
|
|
6
|
+
export declare function redactText(text: string): string;
|
|
7
|
+
export declare function protectFinding(finding: Finding): Finding;
|
package/lib/privacy.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/** Findings crossing the scanner boundary never contain complete credentials. */
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
export function isHighEntropyToken(value) {
|
|
5
|
+
if (value.length < 24 || !/\d/.test(value) || /^[0-9]+$/.test(value))
|
|
6
|
+
return false;
|
|
7
|
+
const counts = new Map();
|
|
8
|
+
for (const char of value)
|
|
9
|
+
counts.set(char, (counts.get(char) ?? 0) + 1);
|
|
10
|
+
let entropy = 0;
|
|
11
|
+
for (const count of counts.values()) {
|
|
12
|
+
const frequency = count / value.length;
|
|
13
|
+
entropy -= frequency * Math.log2(frequency);
|
|
14
|
+
}
|
|
15
|
+
return entropy >= 3.8;
|
|
16
|
+
}
|
|
17
|
+
export function evidenceFingerprint(finding) {
|
|
18
|
+
if (/^[a-f0-9]{64}$/.test(finding.fingerprint ?? ''))
|
|
19
|
+
return finding.fingerprint;
|
|
20
|
+
const file = finding.file.split(path.sep).join('/').replace(/^(?:\.\/)+/, '');
|
|
21
|
+
return createHash('sha256').update(finding.ruleId + '|' + file + '|' + finding.snippet.trim()).digest('hex');
|
|
22
|
+
}
|
|
23
|
+
export function redactText(text) {
|
|
24
|
+
return text
|
|
25
|
+
.replace(/-----BEGIN (?:[A-Z]+ )?PRIVATE KEY-----[\s\S]*?(?:-----END (?:[A-Z]+ )?PRIVATE KEY-----|$)/g, '[REDACTED PRIVATE KEY]')
|
|
26
|
+
.replace(/((?:[\w.-]*(?:password|passwd|pwd|secret|token|api[_-]?key|access[_-]?key|authorization)[\w.-]*)["']?\s*[:=]\s*)(["'`])([^\r\n]*?)(?:\2|$)/gim, '$1$2[REDACTED]$2')
|
|
27
|
+
.replace(/((?:[\w.-]*(?:password|passwd|pwd|secret|token|api[_-]?key|access[_-]?key|authorization)[\w.-]*)["']?\s*[:=]\s*)(?!["'`\s]|\[REDACTED\b)[^\s,;}\]]+/gi, '$1[REDACTED]')
|
|
28
|
+
.replace(/(^\s*[A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|PASS)[A-Z0-9_]*\s*=\s*)([^\r\n]+)/gm, '$1[REDACTED]')
|
|
29
|
+
.replace(/\b(?:gh[pousr]_[A-Za-z0-9_]{16,}|github_pat_[A-Za-z0-9_]{20,}|sk-(?:proj-)?[A-Za-z0-9_-]{16,}|AKIA[A-Z0-9]{16})\b/g, '[REDACTED]')
|
|
30
|
+
.replace(/(\bBearer\s+)[A-Za-z0-9._~+\/-]{12,}/gi, '$1[REDACTED]')
|
|
31
|
+
.replace(/(https?:\/\/)[^\s/@]+:[^\s/@]+@/g, '$1[REDACTED]@')
|
|
32
|
+
// A credential must stay redacted when another rule reports the same source line.
|
|
33
|
+
.replace(/(['"`])([A-Za-z0-9+/=_-]{24,})(?:\1|$)/g, (match, quote, value) => isHighEntropyToken(value) ? quote + '[REDACTED]' + quote : match);
|
|
34
|
+
}
|
|
35
|
+
export function protectFinding(finding) {
|
|
36
|
+
const fingerprint = evidenceFingerprint(finding);
|
|
37
|
+
let snippet = redactText(finding.snippet);
|
|
38
|
+
if (finding.ruleId === 'SEC-203')
|
|
39
|
+
snippet = snippet.replace(/(["'])([^\r\n]*?)\1/g, '$1[REDACTED]$1');
|
|
40
|
+
// Entropy/external secret rules can identify values without an assignment.
|
|
41
|
+
if (finding.ruleId === 'SEC-304' || finding.engine === 'betterleaks')
|
|
42
|
+
snippet = '[REDACTED SECRET]';
|
|
43
|
+
if (finding.ruleId === 'SEC-303')
|
|
44
|
+
snippet = '[REDACTED PRIVATE KEY]';
|
|
45
|
+
if (finding.ruleId === 'SEC-305')
|
|
46
|
+
snippet = snippet.replace(/(=)[\s\S]*/, '$1[REDACTED]');
|
|
47
|
+
return { id: finding.id, ruleId: finding.ruleId, file: finding.file, line: finding.line,
|
|
48
|
+
severity: finding.severity, confidence: finding.confidence, cwe: finding.cwe,
|
|
49
|
+
...(finding.endLine === undefined ? {} : { endLine: finding.endLine }),
|
|
50
|
+
...(finding.engine === undefined ? {} : { engine: finding.engine }),
|
|
51
|
+
...(finding.evidence === undefined ? {} : { evidence: finding.evidence }),
|
|
52
|
+
...(finding.sources === undefined ? {} : { sources: finding.sources }),
|
|
53
|
+
fingerprint, snippet, message: redactText(finding.message), title: redactText(finding.title) };
|
|
54
|
+
}
|
package/lib/rules.js
CHANGED
|
@@ -19,7 +19,7 @@ export const RULES = Object.freeze([
|
|
|
19
19
|
{ id: 'SEC-006', title: 'Shell 管道下载执行', cwe: 'CWE-78', severity: 'critical', confidence: 'high', languages: SHELL, patterns: [/\bcurl\b[^|\n]*\|\s*(?:sudo\s+)?(?:ba|z|k|da)?sh\b/i, /\bwget\b[^|\n]*\|\s*(?:sudo\s+)?(?:ba|z|k|da)?sh\b/i], message: '下载内容直接通过管道交给 shell 执行。' },
|
|
20
20
|
{ id: 'SEC-007', title: 'JavaScript SQL 字符串拼接', cwe: 'CWE-89', severity: 'high', confidence: 'medium', languages: JS, patterns: [/['"](?:SELECT|INSERT|UPDATE|DELETE)\b[^'"]*['"]\s*\+/i, /\bquery\s*=\s*['"][^'"]*['"]\s*\+/i], message: 'SQL 语句与变量拼接,疑似可注入。' },
|
|
21
21
|
{ id: 'SEC-008', title: 'Python SQL 字符串格式化', cwe: 'CWE-89', severity: 'high', confidence: 'medium', languages: PY, patterns: [/\.execute\s*\([^)]*(?:%\s*\(|%\s*[a-z_]|\bf\s*['"]|\+\s*str\()/i], message: 'SQL 执行语句使用字符串格式化或拼接。' },
|
|
22
|
-
{ id: 'SEC-009', title: '
|
|
22
|
+
{ id: 'SEC-009', title: 'HTML 写入点需要核对输入来源', cwe: 'CWE-79', severity: 'medium', confidence: 'low', languages: JS, patterns: [/\.innerHTML\s*=/, /\bdangerouslySetInnerHTML\b/], message: '发现 HTML 写入接口。这是模式提示,尚未证明输入未经净化;已验证绑定的默认 DOMPurify.sanitize 调用不报告。' },
|
|
23
23
|
{ id: 'SEC-010', title: '开放重定向', cwe: 'CWE-601', severity: 'medium', confidence: 'medium', languages: JS, patterns: [/\bredirect\s*\(\s*(?:req|request)\.(?:query|params|body)/i, /\blocation\s*=\s*(?:req|request)\.(?:query|params|body)/i], message: '跳转目标直接取自请求参数。' },
|
|
24
24
|
{ id: 'SEC-101', title: 'Python pickle 反序列化', cwe: 'CWE-502', severity: 'critical', confidence: 'high', languages: PY, patterns: [/\bpickle\.(?:loads?)\s*\(/], message: 'pickle 反序列化不可信数据。' },
|
|
25
25
|
{ id: 'SEC-102', title: 'Python yaml.load 不安全解析', cwe: 'CWE-502', severity: 'high', confidence: 'medium', languages: PY, patterns: [/\byaml\.load\s*\(/], message: 'yaml.load 未使用 SafeLoader。' },
|
package/lib/runner.d.ts
CHANGED
|
@@ -14,6 +14,7 @@ export interface ProcessRunner {
|
|
|
14
14
|
timeoutMs?: number;
|
|
15
15
|
cwd?: string;
|
|
16
16
|
signal?: AbortSignal;
|
|
17
|
+
env?: Record<string, string>;
|
|
17
18
|
}): Promise<RunResult>;
|
|
18
19
|
}
|
|
19
20
|
export interface SubprocessHandleLike {
|
|
@@ -25,11 +26,13 @@ export interface SubprocessHandleLike {
|
|
|
25
26
|
stdout?: {
|
|
26
27
|
readFrom(offset: number): {
|
|
27
28
|
text: string;
|
|
29
|
+
lossy?: boolean;
|
|
28
30
|
};
|
|
29
31
|
};
|
|
30
32
|
stderr?: {
|
|
31
33
|
readFrom(offset: number): {
|
|
32
34
|
text: string;
|
|
35
|
+
lossy?: boolean;
|
|
33
36
|
};
|
|
34
37
|
};
|
|
35
38
|
};
|
|
@@ -50,6 +53,7 @@ export interface SubprocessSpawnLike {
|
|
|
50
53
|
};
|
|
51
54
|
graceMs: number;
|
|
52
55
|
signal?: AbortSignal;
|
|
56
|
+
env?: Record<string, string>;
|
|
53
57
|
}): SubprocessHandleLike;
|
|
54
58
|
}
|
|
55
59
|
export declare function createSubprocessRunner(spawn: SubprocessSpawnLike, graceMs: number, defaultTimeoutMs: number): ProcessRunner;
|
package/lib/runner.js
CHANGED
|
@@ -11,7 +11,7 @@ export function createSubprocessRunner(spawn, graceMs, defaultTimeoutMs) {
|
|
|
11
11
|
const controller = new AbortController();
|
|
12
12
|
const signal = options?.signal ? AbortSignal.any([options.signal, controller.signal]) : controller.signal;
|
|
13
13
|
signal.throwIfAborted();
|
|
14
|
-
const timer = setTimeout(() => controller.abort(new Error('
|
|
14
|
+
const timer = setTimeout(() => controller.abort(new Error('scanner subprocess timed out')), timeoutMs);
|
|
15
15
|
try {
|
|
16
16
|
const handle = spawn({
|
|
17
17
|
argv,
|
|
@@ -19,13 +19,18 @@ export function createSubprocessRunner(spawn, graceMs, defaultTimeoutMs) {
|
|
|
19
19
|
stdio: { stdin: 'ignore', stdout: { maxBytes: COLLECT_BYTES }, stderr: { maxBytes: COLLECT_BYTES } },
|
|
20
20
|
graceMs,
|
|
21
21
|
signal,
|
|
22
|
+
...(options?.env ? { env: options.env } : {}),
|
|
22
23
|
});
|
|
23
24
|
const outcome = await handle.done;
|
|
25
|
+
signal.throwIfAborted();
|
|
26
|
+
const stdout = handle.collected.stdout?.readFrom(0), stderr = handle.collected.stderr?.readFrom(0);
|
|
27
|
+
if (stdout?.lossy || stderr?.lossy)
|
|
28
|
+
throw new Error('scanner subprocess output exceeded 8 MiB; this check is incomplete');
|
|
24
29
|
return {
|
|
25
30
|
exitCode: outcome.exitCode,
|
|
26
31
|
signal: outcome.signal,
|
|
27
|
-
stdout:
|
|
28
|
-
stderr:
|
|
32
|
+
stdout: stdout?.text ?? '',
|
|
33
|
+
stderr: stderr?.text ?? '',
|
|
29
34
|
};
|
|
30
35
|
}
|
|
31
36
|
finally {
|
package/lib/scanner.d.ts
CHANGED
|
@@ -6,6 +6,11 @@
|
|
|
6
6
|
import { type Severity } from './config.js';
|
|
7
7
|
import { type SecurePolicy } from './policy.js';
|
|
8
8
|
export interface Finding {
|
|
9
|
+
endLine?: number;
|
|
10
|
+
fingerprint?: string;
|
|
11
|
+
engine?: 'builtin' | 'betterleaks' | 'semgrep';
|
|
12
|
+
evidence?: 'pattern' | 'dataflow';
|
|
13
|
+
sources?: string[];
|
|
9
14
|
id: string;
|
|
10
15
|
ruleId: string;
|
|
11
16
|
title: string;
|
|
@@ -18,6 +23,8 @@ export interface Finding {
|
|
|
18
23
|
message: string;
|
|
19
24
|
}
|
|
20
25
|
export interface ScanResult {
|
|
26
|
+
complete: boolean;
|
|
27
|
+
warnings: string[];
|
|
21
28
|
root: string;
|
|
22
29
|
filesScanned: number;
|
|
23
30
|
filesSkipped: number;
|
|
@@ -25,6 +32,10 @@ export interface ScanResult {
|
|
|
25
32
|
durationMs: number;
|
|
26
33
|
}
|
|
27
34
|
export interface ScanOptions {
|
|
35
|
+
onDocument?: (document: {
|
|
36
|
+
file: string;
|
|
37
|
+
text: string;
|
|
38
|
+
}) => void;
|
|
28
39
|
cwd: string;
|
|
29
40
|
target?: string;
|
|
30
41
|
maxFiles: number;
|
|
@@ -37,7 +48,7 @@ export interface LineEntry {
|
|
|
37
48
|
text: string;
|
|
38
49
|
}
|
|
39
50
|
/** 对带行号的文本行应用规则(全量扫描与 git diff 增量扫描共用)。 */
|
|
40
|
-
export declare function scanLineSet(entries: LineEntry[], rel: string, policy: SecurePolicy, selectedLines?: ReadonlySet<number>): Finding[];
|
|
51
|
+
export declare function scanLineSet(entries: LineEntry[], rel: string, policy: SecurePolicy, selectedLines?: ReadonlySet<number>, sanitized?: ReadonlySet<number>): Finding[];
|
|
41
52
|
/** 扫描单个文件内容,按语言与行应用规则。 */
|
|
42
53
|
export declare function scanText(content: string, file: string, rel: string, policy: SecurePolicy): Finding[];
|
|
43
54
|
/** Apply the same exclusions to full scans, directly selected files and diffs. */
|