@clear-capabilities/agentic-security-scanner 0.136.2 → 0.136.9
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 +236 -0
- package/bin/agentic-security.js +186 -37
- package/dist/113.index.js +13 -4
- package/dist/178.index.js +1 -1
- package/dist/207.index.js +5 -4
- package/dist/238.index.js +1 -1
- package/dist/317.index.js +36 -6
- package/dist/384.index.js +1 -1
- package/dist/435.index.js +183 -14
- package/dist/444.index.js +20 -11
- package/dist/449.index.js +8 -1
- package/dist/526.index.js +3 -3
- package/dist/637.index.js +1 -1
- package/dist/agentic-security.mjs +14 -14
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/dist/compliance-frameworks/nist-privacy-1-1.json +2 -2
- package/dist/compliance-frameworks/owasp-asvs-5.json +1 -1
- package/package.json +18 -10
- package/src/dataflow/CLAUDE.md +10 -4
- package/src/dataflow/builtin-summaries.js +1 -1
- package/src/dataflow/engine.js +324 -60
- package/src/dataflow/implicit-flow.js +68 -36
- package/src/dataflow/incremental.js +18 -3
- package/src/dataflow/index.js +2 -1
- package/src/dataflow/proven-clean.js +41 -0
- package/src/dataflow/sanitizer-gate.js +35 -9
- package/src/dataflow/sanitizer-proof.js +21 -3
- package/src/dataflow/stub-aware-filter.js +36 -13
- package/src/dataflow/summaries.js +21 -2
- package/src/engine.js +202 -42
- package/src/ir/CLAUDE.md +4 -1
- package/src/ir/balanced-call.js +55 -0
- package/src/ir/parser-cs.js +62 -9
- package/src/ir/parser-go.js +29 -11
- package/src/ir/parser-java.js +96 -19
- package/src/ir/parser-js.js +151 -20
- package/src/ir/parser-php.js +44 -9
- package/src/ir/parser-rb.js +37 -7
- package/src/ir/ssa.js +6 -1
- package/src/lsp/server.js +28 -2
- package/src/mcp/CLAUDE.md +9 -2
- package/src/mcp/redact.js +26 -0
- package/src/mcp/tools.js +155 -14
- package/src/posture/CLAUDE.md +19 -7
- package/src/posture/accuracy-scorecard.js +9 -1
- package/src/posture/aibom.js +12 -8
- package/src/posture/auditor-walkthrough.js +102 -3
- package/src/posture/autopilot.js +8 -1
- package/src/posture/calibration-drift.js +11 -5
- package/src/posture/calibration.js +24 -2
- package/src/posture/compliance-frameworks/nist-privacy-1-1.json +2 -2
- package/src/posture/compliance-frameworks/owasp-asvs-5.json +1 -1
- package/src/posture/compliance-policy.js +33 -1
- package/src/posture/confidence.js +44 -10
- package/src/posture/corpus-enroll.js +9 -5
- package/src/posture/corpus-match.js +19 -0
- package/src/posture/csharp-analysis.js +62 -3
- package/src/posture/deploy-platform.js +4 -1
- package/src/posture/drift.js +7 -1
- package/src/posture/epss.js +13 -1
- package/src/posture/evidence-bundle.js +36 -6
- package/src/posture/exploitability-probability.js +13 -1
- package/src/posture/falsification.js +23 -2
- package/src/posture/fix-metrics.js +1 -1
- package/src/posture/fix-verify-loop.js +10 -1
- package/src/posture/iac-reachability.js +14 -8
- package/src/posture/integrity.js +25 -7
- package/src/posture/model-rescan.js +65 -0
- package/src/posture/mttr.js +5 -0
- package/src/posture/poc-inprocess.js +27 -8
- package/src/posture/regression-test-gen.js +23 -8
- package/src/posture/reverse-blast-radius.js +5 -1
- package/src/posture/risk-dollars.js +18 -1
- package/src/posture/secret-history.js +20 -11
- package/src/posture/security-trend.js +7 -1
- package/src/posture/stack-playbook.js +22 -1
- package/src/posture/threat-model-grounding.js +2 -2
- package/src/posture/validator-metrics.js +10 -3
- package/src/posture/verifier.js +32 -57
- package/src/report/index.js +183 -14
- package/src/runScan.js +1 -1
- package/src/sast/_comment-strip.js +15 -4
- package/src/sast/_secret-entropy.js +1 -1
- package/src/sast/authz.js +6 -4
- package/src/sast/bench-shape/index.js +2 -7
- package/src/sast/claude-md-prompt-injection.js +14 -3
- package/src/sast/cloud-iam.js +60 -7
- package/src/sast/cpp-bench-extras.js +1 -1
- package/src/sast/csrf.js +7 -5
- package/src/sast/env-hygiene.js +5 -2
- package/src/sast/iac-terraform.js +25 -0
- package/src/sast/java-bench-extras.js +1 -1
- package/src/sast/java-constant-fold.js +5 -5
- package/src/sast/llm-owasp.js +4 -2
- package/src/sast/mcp-audit.js +7 -0
- package/src/sast/pipeline.js +8 -0
- package/src/sast/prompt-template.js +8 -6
- package/src/sast/prototype-pollution.js +6 -2
- package/src/sast/redos-nfa.js +6 -6
- package/src/sast/secret-concat.js +13 -2
- package/src/sast/ssrf-cloud-metadata.js +6 -3
- package/src/sast/xss-reflected-multilang.js +1 -1
- package/src/sast/xxe.js +1 -1
- package/src/sca/CLAUDE.md +3 -4
- package/src/sca/container.js +35 -3
- package/src/sca/dep-confusion.js +7 -0
- package/src/sca/sarif-ingest.js +0 -187
package/src/posture/aibom.js
CHANGED
|
@@ -156,12 +156,16 @@ function _extractPromptFile(fp, content) {
|
|
|
156
156
|
};
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
+
// A package's role isn't mutually exclusive in reality — `openai` and
|
|
160
|
+
// `@anthropic-ai/sdk` are both a general inference framework AND an
|
|
161
|
+
// embedding-provider SDK. Returns every matching class, not just the first.
|
|
159
162
|
function _classifyFramework(c) {
|
|
160
163
|
const name = (c.name || '').toLowerCase();
|
|
161
|
-
|
|
162
|
-
if (
|
|
163
|
-
if (
|
|
164
|
-
|
|
164
|
+
const classes = [];
|
|
165
|
+
if (FRAMEWORK_PACKAGES.has(name) || FRAMEWORK_PACKAGES.has(c.name)) classes.push('inference-framework');
|
|
166
|
+
if (VECTOR_STORE_PACKAGES.has(name) || VECTOR_STORE_PACKAGES.has(c.name)) classes.push('vector-store');
|
|
167
|
+
if (EMBEDDING_PACKAGES.has(name) || EMBEDDING_PACKAGES.has(c.name)) classes.push('embedding-provider');
|
|
168
|
+
return classes;
|
|
165
169
|
}
|
|
166
170
|
|
|
167
171
|
// Public: build the AI-BOM from already-scanned data.
|
|
@@ -189,10 +193,10 @@ export function buildAIBOM(scan, fileContents = {}, meta = {}) {
|
|
|
189
193
|
const vectorStores = [];
|
|
190
194
|
const embeddings = [];
|
|
191
195
|
for (const c of (scan.components || [])) {
|
|
192
|
-
const
|
|
193
|
-
if (
|
|
194
|
-
|
|
195
|
-
|
|
196
|
+
const classes = _classifyFramework(c);
|
|
197
|
+
if (classes.includes('inference-framework')) frameworks.push({ ecosystem: c.ecosystem, name: c.name, version: c.version, license: c.license || null });
|
|
198
|
+
if (classes.includes('vector-store')) vectorStores.push({ ecosystem: c.ecosystem, name: c.name, version: c.version });
|
|
199
|
+
if (classes.includes('embedding-provider')) embeddings.push({ ecosystem: c.ecosystem, name: c.name, version: c.version });
|
|
196
200
|
}
|
|
197
201
|
return {
|
|
198
202
|
aibomFormat: 'agentic-security AI-BOM',
|
|
@@ -98,11 +98,69 @@ export function loadFramework(scanRoot, id) {
|
|
|
98
98
|
* 'absent' — no signal / open critical findings on every mapsTo family
|
|
99
99
|
* 'manual' — control has no mapsTo (requires manual attestation)
|
|
100
100
|
*/
|
|
101
|
+
// Stage 6 correctness audit: several compliance frameworks map controls to
|
|
102
|
+
// `family:auth-missing` / `family:authz`, but no detector in this codebase
|
|
103
|
+
// ever emits those literal family strings — the real missing-auth/authz
|
|
104
|
+
// detectors use `broken-access-control` (generic), `fastapi-missing-auth`,
|
|
105
|
+
// `springboot-missing-authz`, `laravel-missing-auth`, `quarkus-missing-authz`
|
|
106
|
+
// (framework-specific). Without this alias, a control mapped to
|
|
107
|
+
// auth-missing/authz read "present" (vacuously — the family bucket was
|
|
108
|
+
// always empty) even with a critical, unauthenticated route open. Listed
|
|
109
|
+
// under both compliance-side names since `broken-access-control` covers
|
|
110
|
+
// both "nobody checked" (missing auth) and "checked wrong" (broken authz)
|
|
111
|
+
// and it is strictly safer to over-count a real finding against both than
|
|
112
|
+
// to keep silently excluding it from either.
|
|
113
|
+
// CMP-1 (Stage 6 follow-up): grown incrementally by cross-referencing every
|
|
114
|
+
// `family:` string the bundled compliance-frameworks/*.json files reference
|
|
115
|
+
// against what src/sast + src/posture actually emit (a small, closed
|
|
116
|
+
// problem — only the strings a control actually maps to, not a universal
|
|
117
|
+
// vocabulary registry). k8s-admission.js's real rule id is
|
|
118
|
+
// `k8s-pod-privileged`; `nist-csf-2.json`/`hipaa-security-rule.json` map to
|
|
119
|
+
// the compliance-side spelling `k8s-pod-security-privileged`, which no
|
|
120
|
+
// detector ever emitted.
|
|
121
|
+
const COMPLIANCE_FAMILY_ALIAS = {
|
|
122
|
+
'auth-missing': ['broken-access-control', 'fastapi-missing-auth', 'springboot-missing-authz', 'laravel-missing-auth', 'quarkus-missing-authz'],
|
|
123
|
+
'authz': ['broken-access-control', 'idor', 'springboot-missing-authz', 'quarkus-missing-authz'],
|
|
124
|
+
'k8s-pod-security-privileged': ['k8s-pod-privileged'],
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
// CMP-1 audit trail: every `family:` string referenced by the bundled
|
|
128
|
+
// compliance-frameworks/*.json files was cross-checked against real
|
|
129
|
+
// detector output; entries above are the confirmed naming mismatches with a
|
|
130
|
+
// real detector to alias, and `mcp-audit.js`/`sca/dep-confusion.js` were
|
|
131
|
+
// fixed at the SOURCE (they now set `family` explicitly) rather than
|
|
132
|
+
// aliased, since the finding constructors themselves were the root cause.
|
|
133
|
+
// Four references have no matching detector at all — not a naming
|
|
134
|
+
// mismatch, a genuine coverage gap that would need a new rule, out of
|
|
135
|
+
// scope for an alias table: `crypto-tls-version` (crypto-protocol.js checks
|
|
136
|
+
// verify-disabled, not minimum-TLS-version — the file's own header comment
|
|
137
|
+
// even names the never-implemented `crypto-tls-min-version` rule id),
|
|
138
|
+
// `nosql-injection` (referenced by cross-lang-meta.js's chain-detection
|
|
139
|
+
// list, but no dedicated NoSQL-injection detector exists), `pii-exposure`
|
|
140
|
+
// and `data-exposure` (both referenced only by consumers — threat-model-
|
|
141
|
+
// auto.js's classifier and a Juliet-benchmark answer-key label
|
|
142
|
+
// respectively — with no producer). Every control mapped to one of these
|
|
143
|
+
// four reads `manual`/`engine-gap` rather than a false `present`, which is
|
|
144
|
+
// the safe failure mode this whole mechanism exists to guarantee.
|
|
101
145
|
export function evaluateFramework(scanRoot, fw, scan) {
|
|
146
|
+
// CMP-2: last-scan.json (what this is actually handed in production) carries
|
|
147
|
+
// findings across four separate channels — SAST (`findings`), secrets,
|
|
148
|
+
// business-logic, and SCA (`supplyChain`) — because report/index.js's
|
|
149
|
+
// normalizeFindings keeps them apart too. Reading only `scan.findings` made
|
|
150
|
+
// every secrets/logic/SCA finding invisible to every framework's family:
|
|
151
|
+
// mappings, e.g. a critical hardcoded secret never counted against a
|
|
152
|
+
// control mapped to family:hardcoded-secret. Each channel's family default
|
|
153
|
+
// mirrors normalizeFindings' own fallback for that channel so the two stay
|
|
154
|
+
// in agreement about what family an untagged finding belongs to.
|
|
102
155
|
const findings = (scan && Array.isArray(scan.findings)) ? scan.findings : [];
|
|
156
|
+
const secrets = ((scan && Array.isArray(scan.secrets)) ? scan.secrets : [])
|
|
157
|
+
.map(s => ({ ...s, family: s.family || 'hardcoded-secret' }));
|
|
158
|
+
const logicVulns = (scan && Array.isArray(scan.logicVulns)) ? scan.logicVulns : [];
|
|
159
|
+
const supplyChain = ((scan && Array.isArray(scan.supplyChain)) ? scan.supplyChain : [])
|
|
160
|
+
.map(sc => ({ ...sc, family: sc.family || 'vulnerable-dep' }));
|
|
103
161
|
const components = (scan && Array.isArray(scan.components)) ? scan.components : [];
|
|
104
162
|
const families = new Map();
|
|
105
|
-
for (const f of findings) {
|
|
163
|
+
for (const f of [...findings, ...secrets, ...logicVulns, ...supplyChain]) {
|
|
106
164
|
const k = f.family || 'unknown';
|
|
107
165
|
if (!families.has(k)) families.set(k, []);
|
|
108
166
|
families.get(k).push(f);
|
|
@@ -122,15 +180,43 @@ export function evaluateFramework(scanRoot, fw, scan) {
|
|
|
122
180
|
|
|
123
181
|
let allCleared = true;
|
|
124
182
|
let anySignal = false;
|
|
183
|
+
// Tracks whether ANY family:/module: mapping in this control actually
|
|
184
|
+
// passed (distinct from allCleared, which asks whether EVERY one did).
|
|
185
|
+
// Feeds the 'absent' vs 'partial' distinction below: a control where
|
|
186
|
+
// nothing at all checks out is a materially different auditor story
|
|
187
|
+
// ("no evidence") than one that's mostly clean with one open gap
|
|
188
|
+
// ("evidence with a gap") — both used to render as the same 'partial'.
|
|
189
|
+
let anyCleared = false;
|
|
190
|
+
// CMP-2: a rule: mapping's own observation says "verify manually" — it
|
|
191
|
+
// is deliberately not code-checked. It used to set anySignal=true and
|
|
192
|
+
// leave allCleared untouched, so a control whose ONLY mapping was rule:
|
|
193
|
+
// resolved to 'present' (fully evidenced), the same status as a control
|
|
194
|
+
// with real, checked evidence. Any rule: mapping present caps the
|
|
195
|
+
// control at 'partial' — never 'present' — regardless of what the
|
|
196
|
+
// family:/module: mappings in the same control found.
|
|
197
|
+
let hasUnverifiableMapping = false;
|
|
125
198
|
for (const m of maps) {
|
|
126
199
|
if (m.startsWith('family:')) {
|
|
127
|
-
|
|
128
|
-
|
|
200
|
+
// `family:X` and the subfamily-qualified `family:X:Y` (used by
|
|
201
|
+
// owasp-llm-top-10 for LLM02/LLM06/LLM07) both used to collapse to
|
|
202
|
+
// just `X` here — the `:Y` qualifier was parsed and then silently
|
|
203
|
+
// dropped, so any finding of family X counted against every control
|
|
204
|
+
// mapped to X regardless of which subfamily the control actually
|
|
205
|
+
// named (four LLM controls flagged off one credential-in-prompt
|
|
206
|
+
// finding). Detectors that emit a subfamily always set it, so
|
|
207
|
+
// filtering is safe; findings with no subfamily set still count
|
|
208
|
+
// (recall-preserving default — same precedent as relevance.js).
|
|
209
|
+
const [fam, subfam] = m.slice('family:'.length).split(':');
|
|
210
|
+
const aliasFams = COMPLIANCE_FAMILY_ALIAS[fam] || [];
|
|
211
|
+
const candidates = [fam, ...aliasFams].flatMap(k => families.get(k) || []);
|
|
212
|
+
const scoped = subfam ? candidates.filter(f => !f.subfamily || f.subfamily === subfam) : candidates;
|
|
213
|
+
const open = scoped.filter(f => !f.intentSuppressed && !f.pastDecision && (f.severity === 'critical' || f.severity === 'high'));
|
|
129
214
|
if (open.length) {
|
|
130
215
|
allCleared = false;
|
|
131
216
|
obs.push(`${open.length} open ${fam} finding(s) at high/critical.`);
|
|
132
217
|
} else {
|
|
133
218
|
obs.push(`✓ ${fam}: no open critical/high findings.`);
|
|
219
|
+
anyCleared = true;
|
|
134
220
|
}
|
|
135
221
|
anySignal = true;
|
|
136
222
|
} else if (m.startsWith('module:')) {
|
|
@@ -174,6 +260,7 @@ export function evaluateFramework(scanRoot, fw, scan) {
|
|
|
174
260
|
if (resolved && fs.existsSync(resolved)) {
|
|
175
261
|
obs.push(`✓ ${mod}: ${label} present.`);
|
|
176
262
|
anySignal = true;
|
|
263
|
+
anyCleared = true;
|
|
177
264
|
} else {
|
|
178
265
|
obs.push(`✗ ${mod}: expected ${label} not present.`);
|
|
179
266
|
allCleared = false;
|
|
@@ -182,11 +269,23 @@ export function evaluateFramework(scanRoot, fw, scan) {
|
|
|
182
269
|
// Could check whether a custom rule fires zero — leave a hint for now.
|
|
183
270
|
obs.push(`(rule mapping) ${m} — verify manually that the bodyguard rule is enabled.`);
|
|
184
271
|
anySignal = true;
|
|
272
|
+
hasUnverifiableMapping = true;
|
|
185
273
|
}
|
|
186
274
|
}
|
|
187
275
|
|
|
276
|
+
// 'absent' was documented (see the docstring above) as a fourth,
|
|
277
|
+
// distinct status — a control where NOTHING passed at all — but the
|
|
278
|
+
// assignment below never produced it; every non-present, non-manual
|
|
279
|
+
// control rendered as 'partial' regardless of whether it was "mostly
|
|
280
|
+
// clean with one gap" or "completely unevidenced". Only introduced for
|
|
281
|
+
// the fully-automated case (no rule: mapping) to avoid reclassifying
|
|
282
|
+
// any control that already reads 'partial' because of an inherently
|
|
283
|
+
// unverifiable rule: mapping — that precedent (rule: caps at 'partial',
|
|
284
|
+
// never reaching 'present' OR 'absent') is unchanged.
|
|
188
285
|
if (!anySignal) status = 'manual';
|
|
286
|
+
else if (hasUnverifiableMapping) status = 'partial';
|
|
189
287
|
else if (allCleared) status = 'present';
|
|
288
|
+
else if (!anyCleared) status = 'absent';
|
|
190
289
|
else status = 'partial';
|
|
191
290
|
|
|
192
291
|
results.push({ control: c, status, observations: obs });
|
package/src/posture/autopilot.js
CHANGED
|
@@ -110,7 +110,14 @@ export async function runAutopilot({
|
|
|
110
110
|
for (const f of inScope) {
|
|
111
111
|
const key = f.stableId || `${f.file}:${f.line}:${f.vuln}`;
|
|
112
112
|
const prior = resume ? state.findings[key] : null;
|
|
113
|
-
|
|
113
|
+
// A cached VERIFIED_FIXED whose patch was gated (not written) on a prior
|
|
114
|
+
// apply:false run is not a terminal outcome once the caller asks for
|
|
115
|
+
// apply:true — replaying it verbatim would silently never call
|
|
116
|
+
// applyFix on the exact two-step workflow (preview, then --apply) this
|
|
117
|
+
// gate exists to support. Everything else cached IS terminal and is
|
|
118
|
+
// replayed as before.
|
|
119
|
+
const gatedOnlyByApply = prior?.outcome === 'VERIFIED_FIXED' && prior.applied !== true && apply === true;
|
|
120
|
+
if (prior?.outcome && !gatedOnlyByApply) { results.push(prior); continue; }
|
|
114
121
|
|
|
115
122
|
const rec = { key, file: f.file, line: f.line, vuln: f.vuln, severity: f.severity };
|
|
116
123
|
|
|
@@ -57,12 +57,18 @@ export function computeDrift(scanRoot, opts = {}) {
|
|
|
57
57
|
const fb = loadTriageFeedback(scanRoot);
|
|
58
58
|
if (!fb.length) return { alarms: [], note: 'no-feedback-data' };
|
|
59
59
|
|
|
60
|
-
// Group by family. Each entry should carry: family, verdict ('tp'|'fp'|'
|
|
61
|
-
// reportedConfidence (0..1),
|
|
60
|
+
// Group by family. Each entry should carry: family, verdict ('tp'|'fp'|'wontfix'),
|
|
61
|
+
// reportedConfidence (0..1), at.
|
|
62
|
+
// Field names/verdict values here previously didn't match the real
|
|
63
|
+
// producer (commands/triage.md's Step 2): it writes `at`, not `ts`, and
|
|
64
|
+
// never wrote `reportedConfidence` at all (fixed there alongside this —
|
|
65
|
+
// Stage 2 measurement-completeness audit) or a `'wai'` verdict (it only
|
|
66
|
+
// ever writes 'tp'|'fp'|'wontfix'), so this alarm could never fire against
|
|
67
|
+
// real triage data regardless of how badly calibration had drifted.
|
|
62
68
|
const byFamily = new Map();
|
|
63
69
|
for (const e of fb) {
|
|
64
|
-
if (!e || !e.family || !inWindow(e.
|
|
65
|
-
if (!['tp', 'fp', '
|
|
70
|
+
if (!e || !e.family || !inWindow(e.at, window)) continue;
|
|
71
|
+
if (!['tp', 'fp', 'wontfix'].includes(e.verdict)) continue;
|
|
66
72
|
if (typeof e.reportedConfidence !== 'number') continue;
|
|
67
73
|
if (!byFamily.has(e.family)) byFamily.set(e.family, []);
|
|
68
74
|
byFamily.get(e.family).push(e);
|
|
@@ -75,7 +81,7 @@ export function computeDrift(scanRoot, opts = {}) {
|
|
|
75
81
|
const reportedAcc = entries.reduce((acc, e) => acc + e.reportedConfidence, 0) / entries.length;
|
|
76
82
|
const divergence = Math.abs(reportedAcc - realizedAcc);
|
|
77
83
|
if (divergence < threshold) continue;
|
|
78
|
-
const firstTs = entries.map(e => e.
|
|
84
|
+
const firstTs = entries.map(e => e.at).filter(Boolean).sort()[0];
|
|
79
85
|
alarms.push({
|
|
80
86
|
alarm: true,
|
|
81
87
|
since: firstTs,
|
|
@@ -121,15 +121,37 @@ export function loadCalibrationHistory(scanRoot) {
|
|
|
121
121
|
};
|
|
122
122
|
if (seed) merge(seed);
|
|
123
123
|
if (customer) merge(customer);
|
|
124
|
-
// Merge triage-derived TP/FP counts (auto-feedback loop)
|
|
124
|
+
// Merge triage-derived TP/FP counts (auto-feedback loop).
|
|
125
|
+
//
|
|
126
|
+
// EA-01: only a state a human actually chose is a real signal. triage.js's
|
|
127
|
+
// syncWithScan sets every new finding to 'open' with no human involved, and
|
|
128
|
+
// auto-closes any finding the scanner stops seeing to 'fixed' with
|
|
129
|
+
// `automatic:true` on the transition — neither reflects a judgment that the
|
|
130
|
+
// finding was real. Counting them as TP made the "true positive" column
|
|
131
|
+
// entirely machine-generated while the FP column required an explicit human
|
|
132
|
+
// false-positive mark, so calibrated confidence trended toward 1.0 for any
|
|
133
|
+
// family with enough scan volume regardless of actual accuracy. Only a
|
|
134
|
+
// manual 'fixed' transition (confirmed real, now resolved) counts as TP;
|
|
135
|
+
// only 'false-positive' counts as FP. 'open', 'in-progress', 'wont-fix',
|
|
136
|
+
// and an automatically-reached 'fixed' contribute to neither bucket.
|
|
125
137
|
try {
|
|
126
138
|
const triage = _readJsonMaybe(statePath(scanRoot, 'triage.json'));
|
|
127
139
|
if (triage && triage.findings) {
|
|
140
|
+
const lastFixedIsAutomatic = new Map();
|
|
141
|
+
for (const t of triage.transitions || []) {
|
|
142
|
+
if (t && t.to === 'fixed') lastFixedIsAutomatic.set(t.id, t.automatic === true);
|
|
143
|
+
}
|
|
128
144
|
const triageFams = {};
|
|
129
145
|
for (const f of Object.values(triage.findings)) {
|
|
130
146
|
const fam = f.family || 'unknown';
|
|
131
147
|
if (!triageFams[fam]) triageFams[fam] = { tp: 0, fp: 0 };
|
|
132
|
-
|
|
148
|
+
// Require POSITIVE proof of a manual transition (=== false), not
|
|
149
|
+
// merely the absence of proof of an automatic one (!== true) — the
|
|
150
|
+
// latter fails OPEN: a finding with no transition record at all
|
|
151
|
+
// (lastFixedIsAutomatic.get returns undefined) counted as a manual
|
|
152
|
+
// TP by default, exactly the machine-generated-signal failure mode
|
|
153
|
+
// this whole block's comment states it exists to prevent.
|
|
154
|
+
if (f.state === 'fixed' && lastFixedIsAutomatic.get(f.id) === false) triageFams[fam].tp++;
|
|
133
155
|
else if (f.state === 'false-positive') triageFams[fam].fp++;
|
|
134
156
|
}
|
|
135
157
|
merge({ families: triageFams });
|
|
@@ -781,7 +781,7 @@
|
|
|
781
781
|
"summary": "Software is maintained, replaced, and removed commensurate with risk.",
|
|
782
782
|
"codeTestable": "partial",
|
|
783
783
|
"mapsTo": [
|
|
784
|
-
"family:vulnerable-
|
|
784
|
+
"family:vulnerable-dep",
|
|
785
785
|
"family:dependency-drift"
|
|
786
786
|
]
|
|
787
787
|
},
|
|
@@ -800,7 +800,7 @@
|
|
|
800
800
|
"codeTestable": "yes",
|
|
801
801
|
"mapsTo": [
|
|
802
802
|
"family:dependency-confusion",
|
|
803
|
-
"family:vulnerable-
|
|
803
|
+
"family:vulnerable-dep"
|
|
804
804
|
]
|
|
805
805
|
},
|
|
806
806
|
{
|
|
@@ -66,7 +66,7 @@
|
|
|
66
66
|
"category": "Malicious Code",
|
|
67
67
|
"summary": "Verify the application does not include known-malicious or compromised dependencies.",
|
|
68
68
|
"evidence": [".agentic-security/sbom-history snapshots clean.", "No dependency-confusion or dependency-drift findings."],
|
|
69
|
-
"mapsTo": ["family:vulnerable-
|
|
69
|
+
"mapsTo": ["family:vulnerable-dep", "family:dependency-confusion", "family:dependency-drift"]
|
|
70
70
|
},
|
|
71
71
|
{
|
|
72
72
|
"id": "V14.1",
|
|
@@ -41,6 +41,7 @@ import * as fs from 'node:fs';
|
|
|
41
41
|
import * as path from 'node:path';
|
|
42
42
|
import * as yaml from '../util/yaml.js';
|
|
43
43
|
import { statePath, safeWriteState } from './state-dir.js';
|
|
44
|
+
import { SCANNER_VERSION } from './version.js';
|
|
44
45
|
|
|
45
46
|
const POLICY_FILE = 'compliance.policy.yml';
|
|
46
47
|
|
|
@@ -128,7 +129,29 @@ function _runCheck(check, ctx) {
|
|
|
128
129
|
* Run all controls in the policy and emit a verification report.
|
|
129
130
|
*/
|
|
130
131
|
export function verifyPolicy(policy, ctx) {
|
|
132
|
+
// CMP-5: loadPolicy() reports a parse failure as { _error }, which has no
|
|
133
|
+
// `.controls` — the same shape as "no policy file at all". Distinguishing
|
|
134
|
+
// them matters: a customer who typo'd their YAML deserves a loud error,
|
|
135
|
+
// not a report that silently treats their (unparsed) policy as having no
|
|
136
|
+
// controls to check.
|
|
137
|
+
if (policy && policy._error) return { controls: [], status: 'error', error: policy._error };
|
|
131
138
|
if (!policy || !policy.controls) return { controls: [], status: 'no-policy' };
|
|
139
|
+
// CMP-5: a finding-family check must see every channel a real scan
|
|
140
|
+
// produces (findings=SAST, secrets, logicVulns, supplyChain=SCA) — the
|
|
141
|
+
// caller (engine.js) hands last-scan.json's own channel split through
|
|
142
|
+
// ctx, so a control checking family:hardcoded-secret or
|
|
143
|
+
// family:vulnerable-dep isn't blind to 3 of the engine's 4 finding types.
|
|
144
|
+
// Family defaults mirror report/index.js's normalizeFindings so the two
|
|
145
|
+
// modules can't drift apart on what family an untagged finding belongs to.
|
|
146
|
+
ctx = {
|
|
147
|
+
...ctx,
|
|
148
|
+
findings: [
|
|
149
|
+
...(ctx.findings || []),
|
|
150
|
+
...(ctx.secrets || []).map(s => ({ ...s, family: s.family || 'hardcoded-secret' })),
|
|
151
|
+
...(ctx.logicVulns || []),
|
|
152
|
+
...(ctx.supplyChain || []).map(sc => ({ ...sc, family: sc.family || 'vulnerable-dep' })),
|
|
153
|
+
],
|
|
154
|
+
};
|
|
132
155
|
const results = [];
|
|
133
156
|
for (const control of policy.controls) {
|
|
134
157
|
if (control.not_applicable) {
|
|
@@ -166,6 +189,13 @@ export function emitEvidenceJsonLd(report, scanRoot) {
|
|
|
166
189
|
framework: report.framework,
|
|
167
190
|
version: report.version,
|
|
168
191
|
generatedAt: new Date().toISOString(),
|
|
192
|
+
// CMP-5: this artifact is fed to GRC tooling (Vanta/Drata/SecureFrame) and
|
|
193
|
+
// auditors largely unread by a human — the same reason
|
|
194
|
+
// auditor-walkthrough.js's narrative carries this disclaimer verbatim.
|
|
195
|
+
// Without it here, a machine-consumed "ComplianceEvidence" document reads
|
|
196
|
+
// as an attestation, not a scanner's automated observation.
|
|
197
|
+
disclaimer: 'This artifact organizes automated scanner evidence into a structured report. It does not certify compliance. A licensed assessor (CPA / auditor / DPO) is responsible for the final attestation.',
|
|
198
|
+
provenance: { engineVersion: SCANNER_VERSION },
|
|
169
199
|
summary: report.summary,
|
|
170
200
|
controls: report.controls.map(c => ({
|
|
171
201
|
'@type': 'Control',
|
|
@@ -192,7 +222,9 @@ export function emitEvidenceMarkdown(report, scanRoot) {
|
|
|
192
222
|
const lines = [];
|
|
193
223
|
lines.push(`# Compliance evidence — ${report.framework}`);
|
|
194
224
|
lines.push('');
|
|
195
|
-
lines.push(`Generated by agentic-security on ${new Date().toISOString().slice(0,10)}.`);
|
|
225
|
+
lines.push(`Generated by agentic-security (engine ${SCANNER_VERSION}) on ${new Date().toISOString().slice(0,10)}.`);
|
|
226
|
+
lines.push('');
|
|
227
|
+
lines.push('> This document organizes automated scanner evidence into a structured report. It does not certify compliance. A licensed assessor (CPA / auditor / DPO) is responsible for the final attestation.');
|
|
196
228
|
lines.push('');
|
|
197
229
|
lines.push(`Compliant: **${report.summary.compliant}** / Non-compliant: **${report.summary.nonCompliant}** / Not applicable: **${report.summary.notApplicable}** of ${report.summary.total} controls.`);
|
|
198
230
|
lines.push('');
|
|
@@ -47,19 +47,53 @@ export function annotateConfidence(findings) {
|
|
|
47
47
|
if (f.routeRooted) conf = Math.min(1, conf + 0.05);
|
|
48
48
|
if (f.guards && f.guards.length) conf *= 0.80;
|
|
49
49
|
if (f.reachable === false) conf *= 0.55;
|
|
50
|
+
// f.unvalidated is set later in the pipeline (llm-validator/index.js,
|
|
51
|
+
// invoked well after this first confidence pass), so it's never true
|
|
52
|
+
// here on a finding's first computation — see applyUnvalidatedPenalty
|
|
53
|
+
// below, the real enforcement point, run after validation. Kept here
|
|
54
|
+
// too so a caller that builds a synthetic finding with
|
|
55
|
+
// unvalidated:true pre-set still gets it applied in one pass.
|
|
50
56
|
if (f.unvalidated) conf *= 0.85; // LLM validator unavailable
|
|
51
|
-
|
|
57
|
+
// f.llmOnly: currently unreachable — no producer anywhere in this
|
|
58
|
+
// codebase ever sets it (grepped). Documented as a no-op rather than
|
|
59
|
+
// silently deleted, since the intent ("LLM-only finding, no Layer-2
|
|
60
|
+
// path") is a real, plausible signal that just isn't wired yet.
|
|
61
|
+
if (f.llmOnly) conf *= 0.70;
|
|
52
62
|
}
|
|
53
63
|
conf = Math.max(0, Math.min(1, conf));
|
|
54
64
|
f.confidence = Math.round(conf * 1000) / 1000;
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
65
|
+
f.confidenceTier = _tierFor(f.confidence);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Premortem 3R-15: derive tier from the 2-decimal display value so a
|
|
70
|
+
// finding reported as "0.75" never lands in two tiers depending on the
|
|
71
|
+
// viewer's rounding (3-decimal raw 0.745 → 2-decimal 0.75 → high).
|
|
72
|
+
function _tierFor(confidence) {
|
|
73
|
+
const display = Math.round(confidence * 100) / 100;
|
|
74
|
+
if (display >= 0.75) return 'high';
|
|
75
|
+
if (display >= 0.50) return 'medium';
|
|
76
|
+
if (display >= 0.25) return 'low';
|
|
77
|
+
return 'very-low';
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Retroactively applies the unvalidated penalty to findings whose
|
|
81
|
+
// confidence was already computed by annotateConfidence BEFORE
|
|
82
|
+
// f.unvalidated could be known — annotateConfidence only computes from
|
|
83
|
+
// scratch when f.confidence is still null (so hand-tuned detector
|
|
84
|
+
// confidences survive untouched), and f.unvalidated is set by
|
|
85
|
+
// llm-validator/index.js, which runs well after the pipeline's first
|
|
86
|
+
// annotateConfidence pass. Call this once, immediately after LLM
|
|
87
|
+
// validation runs (or is skipped). Idempotent: a finding is only
|
|
88
|
+
// adjusted once, tracked via f._unvalidatedPenaltyApplied.
|
|
89
|
+
export function applyUnvalidatedPenalty(findings) {
|
|
90
|
+
if (!Array.isArray(findings)) return;
|
|
91
|
+
for (const f of findings) {
|
|
92
|
+
if (!f || typeof f !== 'object') continue;
|
|
93
|
+
if (f._unvalidatedPenaltyApplied) continue;
|
|
94
|
+
f._unvalidatedPenaltyApplied = true;
|
|
95
|
+
if (!f.unvalidated || typeof f.confidence !== 'number') continue;
|
|
96
|
+
f.confidence = Math.round(Math.max(0, Math.min(1, f.confidence * 0.85)) * 1000) / 1000;
|
|
97
|
+
f.confidenceTier = _tierFor(f.confidence);
|
|
64
98
|
}
|
|
65
99
|
}
|
|
@@ -296,8 +296,12 @@ function _stripState(dir) {
|
|
|
296
296
|
}
|
|
297
297
|
}
|
|
298
298
|
|
|
299
|
-
// `scoreCandidate` is deliberately NOT exported
|
|
300
|
-
//
|
|
301
|
-
//
|
|
302
|
-
//
|
|
303
|
-
|
|
299
|
+
// `scoreCandidate` is deliberately NOT exported — not even via _internals,
|
|
300
|
+
// the test-only-helpers convention used elsewhere in this codebase: an
|
|
301
|
+
// external caller could score a candidate and then write it by some other
|
|
302
|
+
// route, which is exactly the unscored-write path this module exists to
|
|
303
|
+
// make unavailable. Enrolment scores and writes as one operation or not at
|
|
304
|
+
// all. Keep it out of this object even though every other _internals
|
|
305
|
+
// export in this codebase is a harmless pure helper — this one specific
|
|
306
|
+
// function is the security boundary, not test plumbing.
|
|
307
|
+
export const _internals = { DEFAULT_TIER, _languageOf, _escapeRegex, _stripState };
|
|
@@ -54,6 +54,25 @@ function _matches(f, manifest, matcher) {
|
|
|
54
54
|
(manifest?.cwe ? f.cwe === manifest.cwe || matcher.test(f.cwe || '') : true);
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Every finding, across every channel, that scores this entry.
|
|
59
|
+
*
|
|
60
|
+
* Third caller of the ONE predicate (gate, enrollment, and now the per-layer
|
|
61
|
+
* recall instrument in `bench/layer-recall/`). That instrument needs to know
|
|
62
|
+
* WHICH findings matched so it can attribute them to the analysis layer that
|
|
63
|
+
* produced them; re-deriving the predicate there is precisely the drift this
|
|
64
|
+
* module was extracted to prevent, so it reuses `_matches` instead.
|
|
65
|
+
*/
|
|
66
|
+
export function matchingFindings(scan, manifest, matcher = matcherFor(manifest)) {
|
|
67
|
+
const out = [];
|
|
68
|
+
for (const channel of CHANNELS) {
|
|
69
|
+
const arr = scan?.[channel];
|
|
70
|
+
if (!Array.isArray(arr)) continue;
|
|
71
|
+
for (const f of arr) if (_matches(f, manifest, matcher)) out.push(f);
|
|
72
|
+
}
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
|
|
57
76
|
/** Did the vulnerable (`pre/`) tree produce a matching finding? */
|
|
58
77
|
export function preHit(scan, manifest, matcher = matcherFor(manifest)) {
|
|
59
78
|
return _any(scan, f => _matches(f, manifest, matcher));
|
|
@@ -101,6 +101,49 @@ function isSanitizedExpr(text) {
|
|
|
101
101
|
return false;
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
+
// For each SANITIZER_PATTERNS match in `text`, find the nearest `(...)` call
|
|
105
|
+
// span after the match and return its [start, end) bounds. Used to tell
|
|
106
|
+
// "the sanitizer call actually wraps the tainted value" (HtmlEncode(x)) apart
|
|
107
|
+
// from "the sanitizer pattern matched something unrelated elsewhere in a
|
|
108
|
+
// compound expression" (comment + (IsNullOrEmpty(flag) ? ... : ...)) — a
|
|
109
|
+
// common .NET idiom that combines a tainted value with an unrelated validity
|
|
110
|
+
// check in the same expression.
|
|
111
|
+
function _sanitizerCallSpans(text) {
|
|
112
|
+
const spans = [];
|
|
113
|
+
for (const re of SANITIZER_PATTERNS) {
|
|
114
|
+
const g = new RegExp(re.source, re.flags.includes('g') ? re.flags : re.flags + 'g');
|
|
115
|
+
let m;
|
|
116
|
+
while ((m = g.exec(text))) {
|
|
117
|
+
const openIdx = text.indexOf('(', g.lastIndex - 1);
|
|
118
|
+
if (openIdx !== -1 && openIdx - g.lastIndex < 3) {
|
|
119
|
+
let depth = 1, j = openIdx + 1;
|
|
120
|
+
while (j < text.length && depth > 0) {
|
|
121
|
+
if (text[j] === '(') depth++;
|
|
122
|
+
else if (text[j] === ')') depth--;
|
|
123
|
+
j++;
|
|
124
|
+
}
|
|
125
|
+
spans.push([openIdx, j]);
|
|
126
|
+
}
|
|
127
|
+
if (g.lastIndex === m.index) g.lastIndex++; // avoid infinite loop on zero-width matches
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return spans;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Is `ref` tainted-and-NOT-neutralized by an enclosing sanitizer call in
|
|
134
|
+
// `text`? True when every occurrence of `ref` as a whole word sits outside
|
|
135
|
+
// every sanitizer call span (i.e. no sanitizer actually wraps it).
|
|
136
|
+
function _refEscapesSanitizers(text, ref, spans) {
|
|
137
|
+
if (!spans.length) return true;
|
|
138
|
+
const re = new RegExp(`\\b${ref.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'g');
|
|
139
|
+
let m, sawAny = false;
|
|
140
|
+
while ((m = re.exec(text))) {
|
|
141
|
+
sawAny = true;
|
|
142
|
+
if (!spans.some(([s, e]) => m.index >= s && m.index < e)) return true;
|
|
143
|
+
}
|
|
144
|
+
return !sawAny;
|
|
145
|
+
}
|
|
146
|
+
|
|
104
147
|
// Walk a single method's body and compute per-variable type + taint.
|
|
105
148
|
// Returns { typeMap, taintMap, sourceLines } where sourceLines records the
|
|
106
149
|
// declaration line at which each variable first became tainted.
|
|
@@ -296,12 +339,22 @@ export function receiverIsType(method, flow, receiver, typePattern) {
|
|
|
296
339
|
// for short expressions but unsafe for arbitrary string-containing text.
|
|
297
340
|
export function expressionIsTainted(flow, text, idents = null) {
|
|
298
341
|
if (!text && !idents) return false;
|
|
342
|
+
// Check known-tainted variable references that ESCAPE every sanitizer
|
|
343
|
+
// call span first — a whole-expression sanitizer-pattern match (below)
|
|
344
|
+
// must not clear a tainted variable the sanitizer doesn't actually wrap.
|
|
345
|
+
// `HtmlEncode(x)` genuinely neutralizes `x` (inside the call's parens);
|
|
346
|
+
// `comment + (string.IsNullOrEmpty(flag) ? ... : ...)` does not neutralize
|
|
347
|
+
// `comment` just because an unrelated sanitizer pattern matched `flag`
|
|
348
|
+
// elsewhere in the same compound expression.
|
|
349
|
+
const refs = idents || (text ? text.match(/\b[A-Za-z_]\w*\b/g) || [] : []);
|
|
350
|
+
const spans = text ? _sanitizerCallSpans(text) : [];
|
|
351
|
+
for (const r of refs) {
|
|
352
|
+
if (flow.taintMap.get(r) && (!text || _refEscapesSanitizers(text, r, spans))) return true;
|
|
353
|
+
}
|
|
299
354
|
if (text) {
|
|
300
355
|
if (isSourceExpr(text) && !isSanitizedExpr(text)) return true;
|
|
301
356
|
if (isSanitizedExpr(text)) return false;
|
|
302
357
|
}
|
|
303
|
-
const refs = idents || (text ? text.match(/\b[A-Za-z_]\w*\b/g) || [] : []);
|
|
304
|
-
for (const r of refs) if (flow.taintMap.get(r)) return true;
|
|
305
358
|
return false;
|
|
306
359
|
}
|
|
307
360
|
|
|
@@ -311,9 +364,15 @@ export function expressionIsTainted(flow, text, idents = null) {
|
|
|
311
364
|
// not treated as code identifiers.
|
|
312
365
|
export function argIsTainted(flow, arg) {
|
|
313
366
|
if (!arg) return false;
|
|
367
|
+
// Same span-aware fix as expressionIsTainted: a tainted identifier that
|
|
368
|
+
// escapes every sanitizer call span wins over a whole-argument
|
|
369
|
+
// sanitizer-pattern match.
|
|
370
|
+
const spans = arg.text ? _sanitizerCallSpans(arg.text) : [];
|
|
371
|
+
for (const id of arg.idents || []) {
|
|
372
|
+
if (flow.taintMap.get(id) && (!arg.text || _refEscapesSanitizers(arg.text, id, spans))) return true;
|
|
373
|
+
}
|
|
314
374
|
if (arg.text && isSanitizedExpr(arg.text)) return false;
|
|
315
375
|
if (arg.text && isSourceExpr(arg.text)) return true;
|
|
316
|
-
for (const id of arg.idents || []) if (flow.taintMap.get(id)) return true;
|
|
317
376
|
return false;
|
|
318
377
|
}
|
|
319
378
|
|
|
@@ -4,7 +4,10 @@
|
|
|
4
4
|
// returns platform-specific security findings: missing headers, public previews,
|
|
5
5
|
// no health checks, unsafe infra settings.
|
|
6
6
|
//
|
|
7
|
-
// Platforms: Vercel, Railway, Fly.io,
|
|
7
|
+
// Platforms: Vercel, Railway, Fly.io, Netlify, Cloudflare.
|
|
8
|
+
// NOT implemented despite being previously listed here: Render, AWS Amplify
|
|
9
|
+
// (found via Stage-0 doc audit, 2026 — grepped for any render/amplify config
|
|
10
|
+
// path or platform key in this file; zero hits for either).
|
|
8
11
|
|
|
9
12
|
import * as fs from 'node:fs';
|
|
10
13
|
import * as path from 'node:path';
|
package/src/posture/drift.js
CHANGED
|
@@ -8,7 +8,13 @@
|
|
|
8
8
|
|
|
9
9
|
function _routeKey(r) { return `${r.method || 'ANY'} ${r.path || '(file)'} @ ${r.file}:${r.line}`; }
|
|
10
10
|
function _depKey(c) { return `${c.ecosystem}:${c.name}@${c.version}`; }
|
|
11
|
-
|
|
11
|
+
// Prefer stableId (posture/stable-id.js) — it omits the exact source line by
|
|
12
|
+
// design, so an unrelated edit that shifts a still-unfixed finding's line
|
|
13
|
+
// number doesn't register as one "removed" + one "added" finding, inflating
|
|
14
|
+
// drift tier and falsely flagging a PR as introducing/fixing something it
|
|
15
|
+
// didn't touch. Falls back to the line-sensitive key only for finding shapes
|
|
16
|
+
// that never got a stableId annotated.
|
|
17
|
+
function _findingKey(f) { return f.stableId || `${f.kind}:${f.file}:${f.line}:${(f.vuln||'').slice(0,80)}`; }
|
|
12
18
|
|
|
13
19
|
function _toMap(arr, keyFn) {
|
|
14
20
|
const m = new Map();
|
package/src/posture/epss.js
CHANGED
|
@@ -6,10 +6,19 @@
|
|
|
6
6
|
// from those attackers are actively weaponizing.
|
|
7
7
|
//
|
|
8
8
|
// Decoration shape (added to each SCA finding with a CVE):
|
|
9
|
-
//
|
|
9
|
+
// epssScore: 0.92345 (this comment previously said `epss:`; the code has
|
|
10
|
+
// always written `epssScore` — the comment was wrong,
|
|
11
|
+
// not the code)
|
|
10
12
|
// epssPercentile: 0.987
|
|
11
13
|
// exploitedNow: true ← percentile >= 0.95
|
|
12
14
|
//
|
|
15
|
+
// ⚠ engine.js ALSO sets f.epssScore/f.epssPercentile directly (around line
|
|
16
|
+
// 6451), independently of this module's fetchEPSS/decorate path — two
|
|
17
|
+
// implementations of the same decoration. This module's own `fetchEPSS` has
|
|
18
|
+
// no in-tree caller (allowlisted in no-dead-modules.test.js as future-public
|
|
19
|
+
// API); the live path is the one in engine.js. Worth consolidating, not
|
|
20
|
+
// resolved here.
|
|
21
|
+
//
|
|
13
22
|
// Source: https://api.first.org/data/v1/epss?cve=CVE-...,CVE-...
|
|
14
23
|
// Cached on disk: ~/.claude/agentic-security/epss-cache/<sha256>.json
|
|
15
24
|
// 24-hour TTL. Falls back gracefully when offline.
|
|
@@ -93,6 +102,9 @@ function cvesIn(finding) {
|
|
|
93
102
|
const found = new Set();
|
|
94
103
|
if (typeof finding.cve === 'string') found.add(finding.cve.toUpperCase());
|
|
95
104
|
if (Array.isArray(finding.cves)) for (const c of finding.cves) found.add(String(c).toUpperCase());
|
|
105
|
+
// The actual field name every SCA finding in this codebase uses (src/sca/
|
|
106
|
+
// CLAUDE.md's documented shape; carried through by normalizeFindings).
|
|
107
|
+
if (Array.isArray(finding.cveAliases)) for (const c of finding.cveAliases) found.add(String(c).toUpperCase());
|
|
96
108
|
if (Array.isArray(finding.vulnerabilities)) {
|
|
97
109
|
for (const v of finding.vulnerabilities) {
|
|
98
110
|
if (typeof v.id === 'string' && v.id.startsWith('CVE-')) found.add(v.id.toUpperCase());
|