@clear-capabilities/agentic-security-scanner 0.86.0 → 0.119.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 +660 -0
- package/bin/agentic-security.js +78 -1
- package/dist/178.index.js +1 -1
- package/dist/384.index.js +1 -1
- package/dist/415.index.js +90 -0
- package/dist/444.index.js +89 -0
- package/dist/503.index.js +288 -0
- package/dist/637.index.js +15 -1
- package/dist/838.index.js +1 -1
- package/dist/985.index.js +5 -1
- package/dist/agentic-security.mjs +84 -84
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +21 -10
- package/src/dataflow/CLAUDE.md +18 -6
- package/src/dataflow/catalog.js +59 -11
- package/src/dataflow/engine.js +106 -5
- package/src/dataflow/implicit-flow.js +4 -2
- package/src/dataflow/index.js +17 -4
- package/src/dataflow/proof-gate.js +100 -0
- package/src/dataflow/summaries.js +63 -6
- package/src/engine.js +292 -10
- package/src/ir/CLAUDE.md +1 -0
- package/src/ir/callgraph.js +12 -1
- package/src/ir/parser-go.js +8 -0
- package/src/ir/tree-sitter-loader.js +108 -0
- package/src/mcp/tools.js +6 -2
- package/src/posture/corpus-status.js +74 -0
- package/src/posture/coverage-report.js +113 -0
- package/src/posture/fix-history.js +24 -0
- package/src/posture/holdout-eval.js +57 -0
- package/src/posture/provenance.js +39 -0
- package/src/posture/router.js +33 -3
- package/src/posture/sca-verdict.js +130 -0
- package/src/posture/secret-history.js +72 -0
- package/src/pr-delta.js +13 -0
- package/src/report/index.js +67 -0
- package/src/sast/CLAUDE.md +9 -3
- package/src/sast/agent-untrusted-flow.js +107 -0
- package/src/sast/api-authz.js +71 -0
- package/src/sast/code-injection-multilang.js +117 -0
- package/src/sast/cross-service.js +98 -0
- package/src/sast/crypto-protocol.js +43 -1
- package/src/sast/csharp-structural.js +65 -0
- package/src/sast/csharp.js +3 -0
- package/src/sast/csrf.js +24 -3
- package/src/sast/csv-injection.js +84 -0
- package/src/sast/event-entrypoint.js +90 -0
- package/src/sast/frontend-hygiene.js +106 -0
- package/src/sast/go-structural.js +60 -0
- package/src/sast/iac-terraform.js +107 -0
- package/src/sast/java-structural.js +81 -0
- package/src/sast/js-framework-structural.js +115 -0
- package/src/sast/kotlin.js +72 -0
- package/src/sast/ldap-injection.js +73 -17
- package/src/sast/open-redirect.js +47 -5
- package/src/sast/php.js +35 -0
- package/src/sast/python-structural.js +83 -0
- package/src/sast/rbac-consistency.js +113 -0
- package/src/sast/redos-nfa.js +75 -0
- package/src/sast/response-splitting.js +48 -19
- package/src/sast/ruby.js +38 -0
- package/src/sast/secret-concat.js +60 -0
- package/src/sast/ssrf-cloud-metadata.js +13 -0
- package/src/sast/stored-taint.js +88 -0
- package/src/sast/tree-sitter-sinks.js +62 -0
- package/src/sast/weak-password-hash.js +28 -0
- package/src/sast/weak-randomness.js +46 -5
- package/src/sast/wrong-context-sanitizer.js +149 -0
- package/src/sast/xpath-injection.js +30 -6
- package/src/sast/xss-reflected-multilang.js +116 -0
- package/src/sast/xxe.js +67 -0
- package/src/sca/image-packages.js +74 -0
- package/src/sca/import-reachability.js +271 -0
- package/src/sca/install-script-analysis.js +82 -0
- package/bin/.agentic-security/findings.json +0 -1907
- package/bin/.agentic-security/last-scan.json +0 -1907
- package/bin/.agentic-security/last-scan.json.sig +0 -1
- package/bin/.agentic-security/scan-history.json +0 -166
- package/bin/.agentic-security/streak.json +0 -20
- package/src/.agentic-security/findings.json +0 -117732
- package/src/.agentic-security/last-scan.json +0 -117732
- package/src/.agentic-security/last-scan.json.sig +0 -1
- package/src/.agentic-security/scan-history.json +0 -12946
- package/src/.agentic-security/streak.json +0 -21
- package/src/dataflow/.agentic-security/findings.json +0 -6086
- package/src/dataflow/.agentic-security/last-scan.json +0 -6086
- package/src/dataflow/.agentic-security/last-scan.json.sig +0 -1
- package/src/dataflow/.agentic-security/scan-history.json +0 -250
- package/src/dataflow/.agentic-security/streak.json +0 -21
- package/src/ir/.agentic-security/findings.json +0 -4011
- package/src/ir/.agentic-security/last-scan.json +0 -4011
- package/src/ir/.agentic-security/last-scan.json.sig +0 -1
- package/src/ir/.agentic-security/scan-history.json +0 -193
- package/src/ir/.agentic-security/streak.json +0 -20
- package/src/mcp/.agentic-security/findings.json +0 -8632
- package/src/mcp/.agentic-security/last-scan.json +0 -8632
- package/src/mcp/.agentic-security/last-scan.json.sig +0 -1
- package/src/mcp/.agentic-security/scan-history.json +0 -331
- package/src/mcp/.agentic-security/streak.json +0 -20
- package/src/posture/.agentic-security/dpia.md +0 -26
- package/src/posture/.agentic-security/findings.json +0 -77181
- package/src/posture/.agentic-security/last-scan.json +0 -77181
- package/src/posture/.agentic-security/last-scan.json.sig +0 -1
- package/src/posture/.agentic-security/pqc-migration-plan.json +0 -65
- package/src/posture/.agentic-security/pqc-migration-plan.md +0 -30
- package/src/posture/.agentic-security/sbom-history/7d45b5e03804aac084b4a2b4dc8c6f10107d2005.json +0 -6
- package/src/posture/.agentic-security/scan-history.json +0 -8904
- package/src/posture/.agentic-security/streak.json +0 -21
- package/src/posture/.agentic-security/threat-model.json +0 -2038
- package/src/posture/.agentic-security/threat-model.md +0 -73
- package/src/posture/compliance-frameworks/.agentic-security/findings.json +0 -80
- package/src/posture/compliance-frameworks/.agentic-security/last-scan.json +0 -80
- package/src/posture/compliance-frameworks/.agentic-security/last-scan.json.sig +0 -1
- package/src/posture/compliance-frameworks/.agentic-security/scan-history.json +0 -90
- package/src/posture/compliance-frameworks/.agentic-security/streak.json +0 -22
- package/src/report/.agentic-security/sbom-history/7d45b5e03804aac084b4a2b4dc8c6f10107d2005.json +0 -6
- package/src/report/.agentic-security/threat-model.json +0 -7
- package/src/report/.agentic-security/threat-model.md +0 -22
- package/src/sast/.agentic-security/findings.json +0 -6154
- package/src/sast/.agentic-security/last-scan.json +0 -6154
- package/src/sast/.agentic-security/last-scan.json.sig +0 -1
- package/src/sast/.agentic-security/scan-history.json +0 -941
- package/src/sast/.agentic-security/streak.json +0 -22
- package/src/sca/.agentic-security/findings.json +0 -7460
- package/src/sca/.agentic-security/last-scan.json +0 -7460
- package/src/sca/.agentic-security/last-scan.json.sig +0 -1
- package/src/sca/.agentic-security/scan-history.json +0 -113
- package/src/sca/.agentic-security/streak.json +0 -21
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// Tree-sitter parser loader (roadmap #8) — OPTIONAL, runtime-lazy, degrades.
|
|
2
|
+
//
|
|
3
|
+
// web-tree-sitter + tree-sitter-wasms are OPTIONAL dependencies (pinned to an
|
|
4
|
+
// ABI-matched pair: web-tree-sitter 0.20.8 ↔ tree-sitter-wasms 0.1.13). They
|
|
5
|
+
// are NOT bundled into dist (the build marks them `--external`), so the
|
|
6
|
+
// committed bundle stays self-contained and small; this loader requires them
|
|
7
|
+
// lazily at runtime and returns null when they're absent. That keeps the
|
|
8
|
+
// scanner fully bootable offline / without the optional deps — long-tail
|
|
9
|
+
// languages simply fall back to the existing pattern detectors.
|
|
10
|
+
//
|
|
11
|
+
// Long-tail languages this unlocks (no first-class IR parser today):
|
|
12
|
+
// rust, solidity, cpp, c, go, swift, dart. (js/ts/py/java/etc. already have
|
|
13
|
+
// dedicated IR parsers and don't need this.)
|
|
14
|
+
|
|
15
|
+
import { createRequire } from 'node:module';
|
|
16
|
+
|
|
17
|
+
const require = createRequire(import.meta.url);
|
|
18
|
+
|
|
19
|
+
// language → prebuilt grammar wasm shipped by tree-sitter-wasms.
|
|
20
|
+
const GRAMMAR_WASM = {
|
|
21
|
+
rust: 'tree-sitter-rust.wasm',
|
|
22
|
+
solidity: 'tree-sitter-solidity.wasm',
|
|
23
|
+
cpp: 'tree-sitter-cpp.wasm',
|
|
24
|
+
c: 'tree-sitter-c.wasm',
|
|
25
|
+
go: 'tree-sitter-go.wasm',
|
|
26
|
+
swift: 'tree-sitter-swift.wasm',
|
|
27
|
+
dart: 'tree-sitter-dart.wasm',
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const EXT_TO_LANG = {
|
|
31
|
+
rs: 'rust', sol: 'solidity', cpp: 'cpp', cc: 'cpp', cxx: 'cpp', hpp: 'cpp',
|
|
32
|
+
c: 'c', h: 'c', go: 'go', swift: 'swift', dart: 'dart',
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export function treeSitterLangOf(file) {
|
|
36
|
+
if (typeof file !== 'string') return null;
|
|
37
|
+
const ext = file.split('.').pop().toLowerCase();
|
|
38
|
+
return EXT_TO_LANG[ext] || null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let _Parser; // undefined = not tried, null = unavailable, fn = the class
|
|
42
|
+
let _initPromise = null;
|
|
43
|
+
const _langCache = new Map(); // language → Language | null
|
|
44
|
+
const _parserCache = new Map(); // language → Parser | null
|
|
45
|
+
|
|
46
|
+
async function _ensureRuntime() {
|
|
47
|
+
if (_Parser === null) return null;
|
|
48
|
+
if (_Parser) return _Parser;
|
|
49
|
+
try {
|
|
50
|
+
// 0.20 API: the module's default export IS the Parser class.
|
|
51
|
+
const mod = require('web-tree-sitter');
|
|
52
|
+
_Parser = mod && (mod.Parser || mod.default || mod);
|
|
53
|
+
if (!_initPromise) _initPromise = _Parser.init();
|
|
54
|
+
await _initPromise;
|
|
55
|
+
return _Parser;
|
|
56
|
+
} catch {
|
|
57
|
+
_Parser = null; // optional dep absent or failed to init → degrade.
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function _grammarPath(language) {
|
|
63
|
+
const file = GRAMMAR_WASM[language];
|
|
64
|
+
if (!file) return null;
|
|
65
|
+
try { return require.resolve('tree-sitter-wasms/out/' + file); } catch { return null; }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Returns a ready-to-use Parser for `language`, or null if tree-sitter or the
|
|
69
|
+
// grammar isn't available. Cached per language. `parser.parse(src)` is then
|
|
70
|
+
// synchronous, so callers can parse inside a sync loop after awaiting this.
|
|
71
|
+
export async function getParserFor(language) {
|
|
72
|
+
if (_parserCache.has(language)) return _parserCache.get(language);
|
|
73
|
+
const Parser = await _ensureRuntime();
|
|
74
|
+
if (!Parser) { _parserCache.set(language, null); return null; }
|
|
75
|
+
try {
|
|
76
|
+
let Lang = _langCache.get(language);
|
|
77
|
+
if (Lang === undefined) {
|
|
78
|
+
const wasm = _grammarPath(language);
|
|
79
|
+
Lang = wasm ? await Parser.Language.load(wasm) : null;
|
|
80
|
+
_langCache.set(language, Lang);
|
|
81
|
+
}
|
|
82
|
+
if (!Lang) { _parserCache.set(language, null); return null; }
|
|
83
|
+
const p = new Parser();
|
|
84
|
+
p.setLanguage(Lang);
|
|
85
|
+
_parserCache.set(language, p);
|
|
86
|
+
return p;
|
|
87
|
+
} catch {
|
|
88
|
+
_parserCache.set(language, null);
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function isTreeSitterAvailable() {
|
|
94
|
+
return (await _ensureRuntime()) != null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Walk every named node, invoking cb(node). Iterative to avoid deep recursion.
|
|
98
|
+
export function walkNamed(root, cb) {
|
|
99
|
+
if (!root) return;
|
|
100
|
+
const stack = [root];
|
|
101
|
+
while (stack.length) {
|
|
102
|
+
const n = stack.pop();
|
|
103
|
+
cb(n);
|
|
104
|
+
for (let i = n.namedChildCount - 1; i >= 0; i--) stack.push(n.namedChild(i));
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export const _internals = { GRAMMAR_WASM, EXT_TO_LANG, _grammarPath };
|
package/src/mcp/tools.js
CHANGED
|
@@ -16,7 +16,7 @@ import * as fs from 'node:fs';
|
|
|
16
16
|
import * as fsp from 'node:fs/promises';
|
|
17
17
|
import * as path from 'node:path';
|
|
18
18
|
import * as crypto from 'node:crypto';
|
|
19
|
-
import { applyFix as applyFixHistory } from '../posture/fix-history.js';
|
|
19
|
+
import { applyFix as applyFixHistory, fixAcceptanceRate } from '../posture/fix-history.js';
|
|
20
20
|
import { verifyLastScan } from '../posture/integrity.js';
|
|
21
21
|
import { redactString, redactFinding } from './redact.js';
|
|
22
22
|
|
|
@@ -578,7 +578,11 @@ export const apply_fix = {
|
|
|
578
578
|
}
|
|
579
579
|
throw e;
|
|
580
580
|
}
|
|
581
|
-
|
|
581
|
+
// R25 (PRD §5): surface the running auto-fix acceptance rate after each
|
|
582
|
+
// applied fix, so the closed loop reports its own success metric.
|
|
583
|
+
let acceptance = null;
|
|
584
|
+
try { acceptance = fixAcceptanceRate(ctx.sessionRoot); } catch { /* metric is best-effort */ }
|
|
585
|
+
return { _meta: META, applied: true, historyId: entry.id, file: f.file, backupPath: entry.backupPath, integrity: status, attemptOrdinal: entry.attemptOrdinal, acceptance };
|
|
582
586
|
},
|
|
583
587
|
};
|
|
584
588
|
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Corpus coverage / gap reporter (roadmap #10).
|
|
2
|
+
//
|
|
3
|
+
// "Scale the ground-truth corpus" is a data-acquisition effort, not a code one
|
|
4
|
+
// — but you can't prioritize what you can't see. This turns the CVE-replay
|
|
5
|
+
// corpus into an actionable coverage map: progress toward the target size, and
|
|
6
|
+
// exactly which CWE×language cells have zero ground-truth entries. It measures
|
|
7
|
+
// the existing corpus only; it never invents entries.
|
|
8
|
+
|
|
9
|
+
// The corpus's declared target families/languages (mirrors bench manifest).
|
|
10
|
+
export const TARGET_CWES = [
|
|
11
|
+
'CWE-22', 'CWE-78', 'CWE-79', 'CWE-89', 'CWE-90', 'CWE-94', 'CWE-113',
|
|
12
|
+
'CWE-327', 'CWE-329', 'CWE-338', 'CWE-352', 'CWE-502', 'CWE-601', 'CWE-611',
|
|
13
|
+
'CWE-643', 'CWE-798', 'CWE-916', 'CWE-918', 'CWE-1321', 'CWE-1333',
|
|
14
|
+
];
|
|
15
|
+
export const TARGET_LANGUAGES = [
|
|
16
|
+
'javascript', 'python', 'java', 'go', 'ruby', 'php', 'csharp', 'kotlin',
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
// Pure analyzer. `entries` = [{ cwe, language, family, cve, tier? }, …].
|
|
20
|
+
export function analyzeCorpus(entries, opts = {}) {
|
|
21
|
+
const target = opts.target || 500;
|
|
22
|
+
const cwes = opts.cweFamilies || TARGET_CWES;
|
|
23
|
+
const langs = opts.languages || TARGET_LANGUAGES;
|
|
24
|
+
const list = Array.isArray(entries) ? entries : [];
|
|
25
|
+
|
|
26
|
+
const byLanguage = {}, byCwe = {}, matrix = {};
|
|
27
|
+
for (const e of list) {
|
|
28
|
+
const lang = (e && e.language) || 'unknown';
|
|
29
|
+
const cwe = (e && e.cwe) || 'unknown';
|
|
30
|
+
byLanguage[lang] = (byLanguage[lang] || 0) + 1;
|
|
31
|
+
byCwe[cwe] = (byCwe[cwe] || 0) + 1;
|
|
32
|
+
(matrix[cwe] || (matrix[cwe] = {}))[lang] = (matrix[cwe][lang] || 0) + 1;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Empty cells in the target CWE×language matrix = prioritized gaps.
|
|
36
|
+
const gaps = [];
|
|
37
|
+
for (const cwe of cwes) {
|
|
38
|
+
for (const lang of langs) {
|
|
39
|
+
if (!(matrix[cwe] && matrix[cwe][lang])) gaps.push({ cwe, language: lang });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const cellsTotal = cwes.length * langs.length;
|
|
43
|
+
return {
|
|
44
|
+
total: list.length,
|
|
45
|
+
target,
|
|
46
|
+
progressPct: Math.round((list.length / target) * 100),
|
|
47
|
+
remainingToTarget: Math.max(0, target - list.length),
|
|
48
|
+
byLanguage,
|
|
49
|
+
byCwe,
|
|
50
|
+
matrix,
|
|
51
|
+
cellsTotal,
|
|
52
|
+
cellsCovered: cellsTotal - gaps.length,
|
|
53
|
+
gapCount: gaps.length,
|
|
54
|
+
gaps,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function summarizeCorpusStatus(r) {
|
|
59
|
+
if (!r) return 'corpus: (no data)';
|
|
60
|
+
const lines = [
|
|
61
|
+
`corpus: ${r.total}/${r.target} entries (${r.progressPct}%, ${r.remainingToTarget} to target)`,
|
|
62
|
+
`matrix: ${r.cellsCovered}/${r.cellsTotal} CWE×language cells covered (${r.gapCount} gaps)`,
|
|
63
|
+
];
|
|
64
|
+
// Top under-covered languages (fewest entries among target languages).
|
|
65
|
+
const langCounts = Object.entries(r.byLanguage).sort((a, b) => a[1] - b[1]);
|
|
66
|
+
if (langCounts.length) {
|
|
67
|
+
lines.push('thinnest languages: ' + langCounts.slice(0, 3).map(([l, n]) => `${l}=${n}`).join(', '));
|
|
68
|
+
}
|
|
69
|
+
// A few example gaps to act on first.
|
|
70
|
+
if (r.gaps.length) {
|
|
71
|
+
lines.push('example gaps: ' + r.gaps.slice(0, 6).map(g => `${g.cwe}/${g.language}`).join(', '));
|
|
72
|
+
}
|
|
73
|
+
return lines.join('\n');
|
|
74
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// Analysis-coverage honesty report (roadmap #5 + #6).
|
|
2
|
+
//
|
|
3
|
+
// A "perfect" SAST scanner must publish its blind spots, not hide them. This
|
|
4
|
+
// module turns three things the engine already knows internally into an
|
|
5
|
+
// explicit, user-facing coverage summary:
|
|
6
|
+
//
|
|
7
|
+
// 1. Per-language ANALYSIS TIER — which languages in this scan got the full
|
|
8
|
+
// IR + taint engine vs. only pattern (regex) detectors. The taint engine
|
|
9
|
+
// is wired for js/ts/py/java/cs/kt/go/php/rb; everything else
|
|
10
|
+
// (c/c++/rust/swift/solidity/dart/…) is pattern-only today.
|
|
11
|
+
// 2. Files SKIPPED and why (too large, too dense, timed out).
|
|
12
|
+
// 3. UNMODELED-SINK candidates (#5) — calls that match a dangerous shape but
|
|
13
|
+
// have NO finding at their line, i.e. likely recall blind spots to verify.
|
|
14
|
+
//
|
|
15
|
+
// All of this is informational; it never changes a finding's severity.
|
|
16
|
+
|
|
17
|
+
// Languages that flow through the Layer-1 IR + Layer-2 taint engine.
|
|
18
|
+
export const IR_TAINT_LANGS = new Set(['js', 'ts', 'py', 'java', 'cs', 'kt', 'go', 'php', 'rb']);
|
|
19
|
+
|
|
20
|
+
const EXT_TO_LANG = {
|
|
21
|
+
js: 'js', jsx: 'js', mjs: 'js', cjs: 'js', ts: 'ts', tsx: 'ts',
|
|
22
|
+
py: 'py', pyw: 'py', java: 'java', go: 'go', rb: 'rb', php: 'php',
|
|
23
|
+
cs: 'cs', kt: 'kt', kts: 'kt', swift: 'swift', rs: 'rs',
|
|
24
|
+
c: 'c', h: 'c', cpp: 'cpp', cc: 'cpp', cxx: 'cpp', hpp: 'cpp',
|
|
25
|
+
sol: 'sol', dart: 'dart', scala: 'scala', m: 'objc',
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export function langOfFile(file) {
|
|
29
|
+
if (typeof file !== 'string') return null;
|
|
30
|
+
const ext = file.split('.').pop().toLowerCase();
|
|
31
|
+
return EXT_TO_LANG[ext] || null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Given the list of scanned files, report which languages got IR-taint vs.
|
|
35
|
+
// pattern-only analysis, with a per-language file count.
|
|
36
|
+
export function computeAnalysisTiers(fileList) {
|
|
37
|
+
const counts = {};
|
|
38
|
+
for (const f of fileList || []) {
|
|
39
|
+
const l = langOfFile(f);
|
|
40
|
+
if (!l) continue;
|
|
41
|
+
counts[l] = (counts[l] || 0) + 1;
|
|
42
|
+
}
|
|
43
|
+
const irTaint = {}, patternOnly = {};
|
|
44
|
+
for (const [lang, n] of Object.entries(counts)) {
|
|
45
|
+
(IR_TAINT_LANGS.has(lang) ? irTaint : patternOnly)[lang] = n;
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
irTaint, // { js: 12, py: 4 }
|
|
49
|
+
patternOnly, // { rs: 3, sol: 1 } → flow analysis NOT applied
|
|
50
|
+
languages: Object.keys(counts).sort(),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Dangerous-call shapes worth surfacing when no finding lands on their line.
|
|
55
|
+
// Deliberately conservative + cross-language; this is a "did we miss a sink?"
|
|
56
|
+
// prompt for the user, emitted at info level only.
|
|
57
|
+
const DANGER_TOKENS = [
|
|
58
|
+
[/\beval\s*\(/, 'eval'],
|
|
59
|
+
[/\bnew\s+Function\s*\(/, 'Function-constructor'],
|
|
60
|
+
[/\bvm\.\s*runIn\w+\s*\(/, 'vm.runIn*'],
|
|
61
|
+
[/\bchild_process\b|\bexecSync?\s*\(|\bspawnSync?\s*\(/, 'child_process'],
|
|
62
|
+
[/\bdeserialize\s*\(|readObject\s*\(|ObjectInputStream\b/, 'deserialize'],
|
|
63
|
+
[/\byaml\.\s*load\s*\(|\bsafe_load\b/, 'yaml.load'],
|
|
64
|
+
[/\bpickle\.\s*loads?\s*\(/, 'pickle.load'],
|
|
65
|
+
[/\bMarshal\.\s*load\b|\bunserialize\s*\(/, 'unserialize'],
|
|
66
|
+
[/\bRuntime\b[\s\S]{0,40}\.exec\s*\(|\bProcessBuilder\b/, 'Runtime.exec/ProcessBuilder'],
|
|
67
|
+
[/\bos\.\s*system\s*\(|\bsubprocess\.\s*(?:call|run|Popen)\s*\(/, 'os.system/subprocess'],
|
|
68
|
+
[/\.innerHTML\s*=|\bdangerouslySetInnerHTML\b/, 'innerHTML'],
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
// Scan file contents for danger tokens that have NO finding on their line.
|
|
72
|
+
// findings: array with {file,line}. fc: { path: content }. Returns a capped
|
|
73
|
+
// example list + total count. Lines are 1-based to match finding lines.
|
|
74
|
+
export function countUnmodeledSinkCandidates(fc, findings, opts = {}) {
|
|
75
|
+
const cap = opts.cap || 12;
|
|
76
|
+
const covered = new Set();
|
|
77
|
+
for (const f of findings || []) {
|
|
78
|
+
if (f && f.file && typeof f.line === 'number') covered.add(`${f.file}:${f.line}`);
|
|
79
|
+
}
|
|
80
|
+
const examples = [];
|
|
81
|
+
let count = 0;
|
|
82
|
+
for (const [file, content] of Object.entries(fc || {})) {
|
|
83
|
+
if (typeof content !== 'string') continue;
|
|
84
|
+
const lines = content.split('\n');
|
|
85
|
+
for (let i = 0; i < lines.length; i++) {
|
|
86
|
+
for (const [re, token] of DANGER_TOKENS) {
|
|
87
|
+
if (!re.test(lines[i])) continue;
|
|
88
|
+
if (covered.has(`${file}:${i + 1}`)) continue; // already flagged → modelled
|
|
89
|
+
count++;
|
|
90
|
+
if (examples.length < cap) examples.push({ file, line: i + 1, token });
|
|
91
|
+
break; // one token per line is enough
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return { count, examples };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// One-line CLI summary of an analysis-coverage object.
|
|
99
|
+
export function summarizeCoverage(meta) {
|
|
100
|
+
if (!meta) return 'coverage: (none)';
|
|
101
|
+
const t = meta.analysisTier || {};
|
|
102
|
+
const irLangs = Object.keys(t.irTaint || {});
|
|
103
|
+
const patLangs = Object.keys(t.patternOnly || {});
|
|
104
|
+
const parts = [
|
|
105
|
+
`scanned=${meta.filesScanned ?? '?'}`,
|
|
106
|
+
`skipped=${(meta.filesSkipped || 0) + (meta.filesDenseSkipped || 0)}`,
|
|
107
|
+
`timedOut=${meta.filesTimedOut || 0}`,
|
|
108
|
+
`ir-taint=[${irLangs.join(',')}]`,
|
|
109
|
+
];
|
|
110
|
+
if (patLangs.length) parts.push(`pattern-only=[${patLangs.join(',')}]`);
|
|
111
|
+
if (meta.unmodeledSinkCandidates?.count) parts.push(`unmodeled-sink-candidates=${meta.unmodeledSinkCandidates.count}`);
|
|
112
|
+
return parts.join(' · ');
|
|
113
|
+
}
|
|
@@ -31,6 +31,30 @@ export function readLog(scanRoot) {
|
|
|
31
31
|
if (!fs.existsSync(fp)) return [];
|
|
32
32
|
try { return JSON.parse(fs.readFileSync(fp, 'utf8')); } catch { return []; }
|
|
33
33
|
}
|
|
34
|
+
// R25 (PRD §5): auto-fix acceptance rate. The closed loop (apply → verify →
|
|
35
|
+
// re-scan for SAST; dry-run + test-gate + rollback for SCA upgrades) already
|
|
36
|
+
// exists; this surfaces the OUTCOME metric the PRD asks to track — the fraction
|
|
37
|
+
// of RESOLVED fix attempts that landed and stuck. Pure aggregation over the
|
|
38
|
+
// fix-history log; still-'pending' entries are excluded from the denominator.
|
|
39
|
+
export function acceptanceFromEntries(entries) {
|
|
40
|
+
const counts = { applied: 0, pending: 0, reverted: 0, failed: 0, other: 0 };
|
|
41
|
+
for (const e of (Array.isArray(entries) ? entries : [])) {
|
|
42
|
+
const s = (e && e.status) || 'other';
|
|
43
|
+
if (counts[s] != null) counts[s]++; else counts.other++;
|
|
44
|
+
}
|
|
45
|
+
const accepted = counts.applied;
|
|
46
|
+
const resolved = counts.applied + counts.reverted + counts.failed;
|
|
47
|
+
return {
|
|
48
|
+
acceptanceRate: resolved > 0 ? Number((accepted / resolved).toFixed(4)) : null,
|
|
49
|
+
accepted, resolved, pending: counts.pending,
|
|
50
|
+
total: Array.isArray(entries) ? entries.length : 0, counts,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function fixAcceptanceRate(scanRoot) {
|
|
55
|
+
return acceptanceFromEntries(readLog(scanRoot));
|
|
56
|
+
}
|
|
57
|
+
|
|
34
58
|
function writeLog(scanRoot, log) {
|
|
35
59
|
ensure(scanRoot);
|
|
36
60
|
fs.writeFileSync(logPath(scanRoot), JSON.stringify(log, null, 2));
|
|
@@ -23,6 +23,25 @@ import { brierScore, computeBrierOnHeldOut, wilsonInterval } from './calibration
|
|
|
23
23
|
|
|
24
24
|
const ECE_BINS_DEFAULT = 10;
|
|
25
25
|
|
|
26
|
+
// Map a labeled sample to a source language. Prefer an explicit `language`
|
|
27
|
+
// field; otherwise derive it from the `file` extension. Keeps per-language
|
|
28
|
+
// metrics meaningful even for older corpora that only carry a file path.
|
|
29
|
+
const EXT_TO_LANG = {
|
|
30
|
+
js: 'js', jsx: 'js', mjs: 'js', cjs: 'js', ts: 'ts', tsx: 'ts',
|
|
31
|
+
py: 'py', java: 'java', go: 'go', rb: 'rb', php: 'php',
|
|
32
|
+
cs: 'cs', kt: 'kt', kts: 'kt', swift: 'swift', rs: 'rs',
|
|
33
|
+
c: 'c', h: 'c', cpp: 'cpp', cc: 'cpp', cxx: 'cpp', hpp: 'cpp',
|
|
34
|
+
sol: 'sol', dart: 'dart',
|
|
35
|
+
};
|
|
36
|
+
export function languageOf(o) {
|
|
37
|
+
if (o && typeof o.language === 'string' && o.language) return o.language;
|
|
38
|
+
if (o && typeof o.file === 'string') {
|
|
39
|
+
const ext = o.file.split('.').pop().toLowerCase();
|
|
40
|
+
if (EXT_TO_LANG[ext]) return EXT_TO_LANG[ext];
|
|
41
|
+
}
|
|
42
|
+
return 'unknown';
|
|
43
|
+
}
|
|
44
|
+
|
|
26
45
|
export function parseLabeledJsonl(text) {
|
|
27
46
|
if (typeof text !== 'string' || !text.length) return [];
|
|
28
47
|
const out = [];
|
|
@@ -39,6 +58,7 @@ export function parseLabeledJsonl(text) {
|
|
|
39
58
|
if (p === null || a === null) continue;
|
|
40
59
|
out.push({
|
|
41
60
|
family: typeof o.family === 'string' ? o.family : 'unknown',
|
|
61
|
+
language: languageOf(o),
|
|
42
62
|
stableId: typeof o.stableId === 'string' ? o.stableId : null,
|
|
43
63
|
predicted: Math.max(0, Math.min(1, p)),
|
|
44
64
|
actual: a,
|
|
@@ -98,6 +118,27 @@ export function perFamily(samples) {
|
|
|
98
118
|
return fams;
|
|
99
119
|
}
|
|
100
120
|
|
|
121
|
+
// Per-language precision/recall breakdown (roadmap #9). The "perfect
|
|
122
|
+
// multi-language SAST" claim is only defensible with per-language ground
|
|
123
|
+
// truth — a corpus that's 90% JS can hide poor Ruby precision behind a
|
|
124
|
+
// healthy aggregate. Mirrors perFamily, keyed by sample.language, and adds
|
|
125
|
+
// a per-language precision so a regression in one language is visible.
|
|
126
|
+
export function perLanguage(samples) {
|
|
127
|
+
const langs = {};
|
|
128
|
+
for (const s of samples) {
|
|
129
|
+
const l = s.language || 'unknown';
|
|
130
|
+
if (!langs[l]) langs[l] = { tp: 0, fp: 0, n: 0, precision: 0 };
|
|
131
|
+
langs[l].n++;
|
|
132
|
+
if (s.actual === 1) langs[l].tp++;
|
|
133
|
+
else if (s.actual === 0) langs[l].fp++;
|
|
134
|
+
}
|
|
135
|
+
for (const l of Object.keys(langs)) {
|
|
136
|
+
const { tp, fp } = langs[l];
|
|
137
|
+
langs[l].precision = (tp + fp) > 0 ? Number((tp / (tp + fp)).toFixed(4)) : 0;
|
|
138
|
+
}
|
|
139
|
+
return langs;
|
|
140
|
+
}
|
|
141
|
+
|
|
101
142
|
// One-shot evaluation: Brier + ECE + per-family TP/FP + overall precision.
|
|
102
143
|
// Returns null only when there's truly no data; never returns a tautological
|
|
103
144
|
// zero.
|
|
@@ -124,14 +165,30 @@ export function evaluateHeldOut(samples) {
|
|
|
124
165
|
precision,
|
|
125
166
|
precisionCi95: ci,
|
|
126
167
|
perFamily: fams,
|
|
168
|
+
perLanguage: perLanguage(samples),
|
|
127
169
|
notes: [
|
|
128
170
|
...(samples.length < 100 ? ['n<100: Brier and ECE have wide confidence; treat as directional, not decision-grade.'] : []),
|
|
129
171
|
...(brierR.brier !== null && brierR.brier > 0.10 ? [`brier=${brierR.brier.toFixed(3)} exceeds PRD target 0.10`] : []),
|
|
130
172
|
...(ece && ece.ece > 0.05 ? [`ece=${ece.ece.toFixed(3)} exceeds 0.05 calibration target`] : []),
|
|
173
|
+
// Per-language regression surfacing: a language with enough samples
|
|
174
|
+
// whose precision trails the aggregate by >0.15 is the failure the
|
|
175
|
+
// aggregate would otherwise mask.
|
|
176
|
+
...Object.entries(perLanguage(samples))
|
|
177
|
+
.filter(([, m]) => m.n >= 20 && precision - m.precision > 0.15)
|
|
178
|
+
.map(([lang, m]) => `language '${lang}' precision=${m.precision.toFixed(3)} (n=${m.n}) trails aggregate ${precision.toFixed(3)}`),
|
|
131
179
|
],
|
|
132
180
|
};
|
|
133
181
|
}
|
|
134
182
|
|
|
183
|
+
// Per-language dashboard line: one row per language, sorted by sample count.
|
|
184
|
+
export function summarizePerLanguage(result) {
|
|
185
|
+
if (!result || !result.ok || !result.perLanguage) return 'per-language: no data';
|
|
186
|
+
return Object.entries(result.perLanguage)
|
|
187
|
+
.sort((a, b) => b[1].n - a[1].n)
|
|
188
|
+
.map(([lang, m]) => `${lang}: precision=${m.precision.toFixed(3)} (tp=${m.tp} fp=${m.fp} n=${m.n})`)
|
|
189
|
+
.join('\n');
|
|
190
|
+
}
|
|
191
|
+
|
|
135
192
|
// CLI-friendly summary line.
|
|
136
193
|
export function summarize(result) {
|
|
137
194
|
if (!result || !result.ok) return `held-out: ${result?.reason || 'unknown error'}`;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// R17 (PRD §5) — finding provenance: "one issue, many signals".
|
|
2
|
+
//
|
|
3
|
+
// dedupeFindingsWithEvidence() already collapses findings that share a
|
|
4
|
+
// (file, sink-line, family) into ONE finding and accumulates the parsers that
|
|
5
|
+
// fired into `evidence`. This annotator turns that into an explicit, queryable
|
|
6
|
+
// corroboration signal on EVERY finding (not just merged ones):
|
|
7
|
+
//
|
|
8
|
+
// finding.corroborationCount : number of DISTINCT independent analyses that
|
|
9
|
+
// flagged this exact issue (≥1).
|
|
10
|
+
// finding.corroboration.by : sorted list of those signal sources.
|
|
11
|
+
// finding.multiSignal : true when ≥2 independent sources agree.
|
|
12
|
+
//
|
|
13
|
+
// Independent confirmation lowers the odds of a false positive, so the engine
|
|
14
|
+
// ranks multi-signal findings above single-signal ones at equal severity. It
|
|
15
|
+
// deliberately does NOT touch the calibrated `confidence` field — calibration
|
|
16
|
+
// is fit/measured held-out (see posture/CLAUDE.md) and must not be perturbed by
|
|
17
|
+
// a ranking heuristic.
|
|
18
|
+
|
|
19
|
+
export function annotateFindingProvenance(findings) {
|
|
20
|
+
if (!Array.isArray(findings)) return findings;
|
|
21
|
+
for (const f of findings) {
|
|
22
|
+
if (!f || typeof f !== 'object') continue;
|
|
23
|
+
const sources = new Set();
|
|
24
|
+
if (f.parser) sources.add(f.parser);
|
|
25
|
+
// Parsers merged in by the dedup pass.
|
|
26
|
+
if (Array.isArray(f.evidence)) for (const e of f.evidence) if (e) sources.add(e);
|
|
27
|
+
// The Layer-3 LLM validator accepting a finding is an independent signal.
|
|
28
|
+
if (f.validator_verdict === 'accept') sources.add('llm-validator');
|
|
29
|
+
// A dynamically-confirmed finding (reserved for the DAST loop) is the
|
|
30
|
+
// strongest independent signal.
|
|
31
|
+
if (f.dynamicallyConfirmed === true) sources.add('dynamic');
|
|
32
|
+
if (sources.size === 0) sources.add(f.parser || 'UNKNOWN');
|
|
33
|
+
const by = [...sources].sort();
|
|
34
|
+
f.corroboration = { count: by.length, by };
|
|
35
|
+
f.corroborationCount = by.length;
|
|
36
|
+
if (by.length >= 2) f.multiSignal = true;
|
|
37
|
+
}
|
|
38
|
+
return findings;
|
|
39
|
+
}
|
package/src/posture/router.js
CHANGED
|
@@ -27,7 +27,34 @@ function ageHours(fp) {
|
|
|
27
27
|
return (Date.now() - fs.statSync(fp).mtimeMs) / 3_600_000;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
|
|
30
|
+
// Compare the two most-recent scan-history entries to tell the user whether
|
|
31
|
+
// they're trending safer or more exposed. Returns {} when there isn't enough
|
|
32
|
+
// history to say anything honest. (premortem: never invent a trend from one
|
|
33
|
+
// data point.)
|
|
34
|
+
export function computeScanTrend(scanRoot) {
|
|
35
|
+
const hist = readJson(path.join(scanRoot, '.agentic-security', 'scan-history.json'));
|
|
36
|
+
if (!Array.isArray(hist) || hist.length < 2) return {};
|
|
37
|
+
const cur = hist[hist.length - 1];
|
|
38
|
+
const prev = hist[hist.length - 2];
|
|
39
|
+
const sevOf = (e) => (e?.critical || 0) + (e?.high || 0);
|
|
40
|
+
const delta = sevOf(cur) - sevOf(prev);
|
|
41
|
+
let trend = 'flat';
|
|
42
|
+
if (delta < 0) trend = 'improving';
|
|
43
|
+
else if (delta > 0) trend = 'regressing';
|
|
44
|
+
const n = Math.abs(delta);
|
|
45
|
+
const whatChanged =
|
|
46
|
+
trend === 'improving' ? `${n} fewer critical+high than last scan — keep going.`
|
|
47
|
+
: trend === 'regressing' ? `${n} more critical+high than last scan — new risk crept in.`
|
|
48
|
+
: 'No change in critical+high since last scan.';
|
|
49
|
+
return { trend, whatChanged };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function decide(opts) {
|
|
53
|
+
const base = baseDecision(opts);
|
|
54
|
+
return { ...base, ...computeScanTrend(opts.scanRoot) };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function baseDecision({ scanRoot, intent }) {
|
|
31
58
|
const stateDir = path.join(scanRoot, '.agentic-security');
|
|
32
59
|
const lastScan = readJson(path.join(stateDir, 'last-scan.json'));
|
|
33
60
|
const scanAge = ageHours(path.join(stateDir, 'last-scan.json'));
|
|
@@ -102,8 +129,11 @@ export function explain(decision) {
|
|
|
102
129
|
``,
|
|
103
130
|
` Action: ${decision.action}`,
|
|
104
131
|
` Why: ${decision.reason}`,
|
|
105
|
-
` Run: ${decision.command}`,
|
|
106
|
-
``,
|
|
107
132
|
];
|
|
133
|
+
if (decision.trend) {
|
|
134
|
+
const arrow = decision.trend === 'improving' ? '↓' : decision.trend === 'regressing' ? '↑' : '→';
|
|
135
|
+
lines.push(` Trend: ${arrow} ${decision.whatChanged}`);
|
|
136
|
+
}
|
|
137
|
+
lines.push(` Run: ${decision.command}`, ``);
|
|
108
138
|
return lines.join('\n');
|
|
109
139
|
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// R12 (PRD §5) — deterministic SCA decision-first verdict.
|
|
2
|
+
//
|
|
3
|
+
// Turns the enrichment signals already on each `vulnerable_dep` finding
|
|
4
|
+
// (compositeRiskTier, kev, epss, reachabilityTier, mitigationVerdict,
|
|
5
|
+
// fixedVersions, policy suppression/freeze) into ONE verdict from a closed enum,
|
|
6
|
+
// so the default SCA output is a decision ("fix these 5, auto-merge these 40,
|
|
7
|
+
// ignore these 255 — here's why") rather than a CVE dump.
|
|
8
|
+
//
|
|
9
|
+
// This codifies the ordered procedure documented for the `sca-triager` agent so
|
|
10
|
+
// it runs without an LLM. The agent remains for nuanced/uncertain cases; this
|
|
11
|
+
// gives every dep a defensible default verdict deterministically.
|
|
12
|
+
//
|
|
13
|
+
// Verdict enum (must match the agent + downstream /fix --sca):
|
|
14
|
+
// AUTO_MERGE_PATCH · WAIT_FOR_PATCH · MANUAL_REVIEW · ACCEPT_RISK · WONT_FIX
|
|
15
|
+
|
|
16
|
+
export const SCA_VERDICTS = ['AUTO_MERGE_PATCH', 'WAIT_FOR_PATCH', 'MANUAL_REVIEW', 'ACCEPT_RISK', 'WONT_FIX'];
|
|
17
|
+
|
|
18
|
+
const REACHABLE_TIERS = new Set(['route-reachable-via-function', 'function-reachable', 'import-reachable']);
|
|
19
|
+
const UNREACHABLE_TIERS = new Set(['unreachable', 'build-only', 'manifest-only', 'transitive-only']);
|
|
20
|
+
|
|
21
|
+
// Parse a version into {major,minor,patch}. Tolerates leading 'v', ranges, and
|
|
22
|
+
// pre-release/build suffixes. Returns null when no leading numeric core exists.
|
|
23
|
+
export function parseVersion(v) {
|
|
24
|
+
if (v == null) return null;
|
|
25
|
+
const m = String(v).match(/(\d+)(?:\.(\d+))?(?:\.(\d+))?/);
|
|
26
|
+
if (!m) return null;
|
|
27
|
+
return { major: +m[1], minor: m[2] != null ? +m[2] : 0, patch: m[3] != null ? +m[3] : 0 };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Classify the smallest bump from `current` to `fixed`: 'patch' | 'minor' |
|
|
31
|
+
// 'major' | 'none' (fixed <= current) | 'unknown' (unparseable).
|
|
32
|
+
export function bumpKind(current, fixed) {
|
|
33
|
+
const c = parseVersion(current);
|
|
34
|
+
const f = parseVersion(fixed);
|
|
35
|
+
if (!c || !f) return 'unknown';
|
|
36
|
+
if (f.major > c.major) return 'major';
|
|
37
|
+
if (f.major < c.major) return 'none';
|
|
38
|
+
if (f.minor > c.minor) return 'minor';
|
|
39
|
+
if (f.minor < c.minor) return 'none';
|
|
40
|
+
if (f.patch > c.patch) return 'patch';
|
|
41
|
+
return 'none';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function isReachable(sc) {
|
|
45
|
+
// Default to "reachable" when the tier is unknown — fail toward review, not
|
|
46
|
+
// toward auto-accept (never silently ACCEPT_RISK something we couldn't place).
|
|
47
|
+
if (sc.reachabilityTier == null) return true;
|
|
48
|
+
return REACHABLE_TIERS.has(sc.reachabilityTier);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Compute the verdict for one vulnerable_dep finding. Pure. Returns
|
|
53
|
+
* { verdict, reason, expiryDays? }. Follows the documented ordered procedure;
|
|
54
|
+
* the first matching rule wins.
|
|
55
|
+
*/
|
|
56
|
+
export function computeScaVerdict(sc, opts = {}) {
|
|
57
|
+
const testsDetected = !!opts.testsDetected;
|
|
58
|
+
const tier = sc.reachabilityTier;
|
|
59
|
+
const fixed = Array.isArray(sc.fixedVersions) ? sc.fixedVersions.filter(Boolean) : [];
|
|
60
|
+
const riskTier = sc.compositeRiskTier;
|
|
61
|
+
const bump = fixed.length ? bumpKind(sc.version, fixed[0]) : null;
|
|
62
|
+
|
|
63
|
+
// 1. Already suppressed by policy (accept-risk match) → pass-through.
|
|
64
|
+
if (sc.suppressed || sc.suppressionReason) {
|
|
65
|
+
return { verdict: 'ACCEPT_RISK', reason: sc.suppressionReason || 'matched existing accept-risk in sca-policy.yml' };
|
|
66
|
+
}
|
|
67
|
+
// 2. No fixed version exists → nothing to upgrade yet.
|
|
68
|
+
if (!fixed.length) {
|
|
69
|
+
return { verdict: 'WAIT_FOR_PATCH', reason: `no fixed version published${sc.osvId ? ` for ${sc.osvId}` : ''}` };
|
|
70
|
+
}
|
|
71
|
+
// 3. KEV-listed AND reachable → urgent. Same-major fix auto-merges; else review.
|
|
72
|
+
if ((sc.kev || sc.kevListed) && isReachable(sc)) {
|
|
73
|
+
if (bump === 'patch' || bump === 'minor') {
|
|
74
|
+
return { verdict: 'AUTO_MERGE_PATCH', reason: `KEV-listed and reachable; ${bump} bump to ${fixed[0]}` };
|
|
75
|
+
}
|
|
76
|
+
return { verdict: 'MANUAL_REVIEW', reason: 'KEV-listed but fix requires a major-version bump' };
|
|
77
|
+
}
|
|
78
|
+
// 4. Environment-mitigated in prod → accept with re-evaluation window.
|
|
79
|
+
if (sc.mitigationVerdict === 'mitigated-in-prod') {
|
|
80
|
+
return { verdict: 'ACCEPT_RISK', reason: 'mitigated in production (WAF / network policy / auth gate)', expiryDays: 90 };
|
|
81
|
+
}
|
|
82
|
+
// 5. Unreachable (in prod or by tier) → accept; low real exposure.
|
|
83
|
+
if (sc.mitigationVerdict === 'unreachable-in-prod' || (tier && UNREACHABLE_TIERS.has(tier))) {
|
|
84
|
+
return { verdict: 'ACCEPT_RISK', reason: `not reachable (${sc.mitigationVerdict || tier})`, expiryDays: 180 };
|
|
85
|
+
}
|
|
86
|
+
// 6. Major-version freeze policy → human decision.
|
|
87
|
+
if (sc.majorVersionFrozen) {
|
|
88
|
+
return { verdict: 'MANUAL_REVIEW', reason: 'package is on the major-version-freeze list' };
|
|
89
|
+
}
|
|
90
|
+
// 7. Fix requires a major bump → breaking-change review.
|
|
91
|
+
if (bump === 'major') {
|
|
92
|
+
return { verdict: 'MANUAL_REVIEW', reason: `fix requires a major-version bump (${sc.version} → ${fixed[0]}); review breaking changes` };
|
|
93
|
+
}
|
|
94
|
+
// 8. Patch-only bump at high/critical risk → safe auto-merge.
|
|
95
|
+
if (bump === 'patch' && (riskTier === 'high' || riskTier === 'critical')) {
|
|
96
|
+
return { verdict: 'AUTO_MERGE_PATCH', reason: `patch bump to ${fixed[0]} at ${riskTier} risk` };
|
|
97
|
+
}
|
|
98
|
+
// 9. EPSS says exploited-now + reachable + same-major → urgency overrides minor/patch distinction.
|
|
99
|
+
if ((sc.exploitedNow || (typeof sc.epssPercentile === 'number' && sc.epssPercentile >= 0.95)) && isReachable(sc) && (bump === 'patch' || bump === 'minor')) {
|
|
100
|
+
return { verdict: 'AUTO_MERGE_PATCH', reason: `high EPSS (exploited in the wild) and reachable; ${bump} bump to ${fixed[0]}` };
|
|
101
|
+
}
|
|
102
|
+
// 10. Minor bump at critical risk WITH tests present → auto-merge (tests catch breakage).
|
|
103
|
+
if (bump === 'minor' && riskTier === 'critical' && testsDetected) {
|
|
104
|
+
return { verdict: 'AUTO_MERGE_PATCH', reason: `minor bump to ${fixed[0]} at critical risk; project has tests` };
|
|
105
|
+
}
|
|
106
|
+
// 11. Anything else (minor bump at non-critical risk, 'none', unclear) → human.
|
|
107
|
+
return { verdict: 'MANUAL_REVIEW', reason: bump === 'none' ? 'no upgrade path resolves the advisory; review manually' : `${bump || 'unclear'} bump — defer to human triage` };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Annotate every vulnerable_dep in supplyChain with `scaVerdict` +
|
|
112
|
+
* `scaVerdictReason` (+ `scaVerdictExpiryDays` when applicable). Mutates and
|
|
113
|
+
* returns supplyChain. Returns a per-verdict count map on `supplyChain._scaVerdictCounts`.
|
|
114
|
+
*/
|
|
115
|
+
export function annotateScaVerdicts(supplyChain, opts = {}) {
|
|
116
|
+
if (!Array.isArray(supplyChain)) return supplyChain;
|
|
117
|
+
const counts = { AUTO_MERGE_PATCH: 0, WAIT_FOR_PATCH: 0, MANUAL_REVIEW: 0, ACCEPT_RISK: 0, WONT_FIX: 0 };
|
|
118
|
+
for (const sc of supplyChain) {
|
|
119
|
+
if (!sc || sc.type !== 'vulnerable_dep') continue;
|
|
120
|
+
const { verdict, reason, expiryDays } = computeScaVerdict(sc, opts);
|
|
121
|
+
sc.scaVerdict = verdict;
|
|
122
|
+
sc.scaVerdictReason = reason;
|
|
123
|
+
if (expiryDays) sc.scaVerdictExpiryDays = expiryDays;
|
|
124
|
+
if (counts[verdict] != null) counts[verdict]++;
|
|
125
|
+
}
|
|
126
|
+
try {
|
|
127
|
+
Object.defineProperty(supplyChain, '_scaVerdictCounts', { value: counts, enumerable: false, configurable: true });
|
|
128
|
+
} catch { /* frozen array — ignore */ }
|
|
129
|
+
return supplyChain;
|
|
130
|
+
}
|