@clear-capabilities/agentic-security-scanner 0.136.2 → 0.137.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +880 -0
- package/bin/agentic-security.js +189 -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 +192 -15
- 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 +15 -15
- 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 +21 -13
- package/src/dataflow/CLAUDE.md +12 -4
- package/src/dataflow/builtin-summaries.js +1 -1
- package/src/dataflow/catalog-expanded.js +1 -0
- package/src/dataflow/catalog.js +157 -31
- package/src/dataflow/engine.js +639 -112
- package/src/dataflow/implicit-flow.js +68 -36
- package/src/dataflow/incremental.js +18 -3
- package/src/dataflow/index.js +17 -1
- package/src/dataflow/points-to.js +19 -6
- 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 +430 -196
- package/src/ir/CLAUDE.md +16 -2
- package/src/ir/balanced-call.js +55 -0
- package/src/ir/class-hierarchy.js +57 -11
- package/src/ir/index.js +14 -2
- package/src/ir/parser-cs.js +513 -40
- package/src/ir/parser-go.js +29 -11
- package/src/ir/parser-java.js +300 -20
- package/src/ir/parser-js.js +300 -22
- package/src/ir/parser-kt.js +436 -18
- package/src/ir/parser-php.js +631 -38
- package/src/ir/parser-py.helper.py +32 -2
- package/src/ir/parser-py.js +31 -4
- package/src/ir/parser-rb.js +161 -26
- package/src/ir/ssa.js +6 -1
- package/src/lsp/server.js +35 -3
- package/src/mcp/CLAUDE.md +9 -2
- package/src/mcp/redact.js +26 -0
- package/src/mcp/tools.js +164 -15
- 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/clustering.js +12 -1
- 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/sbom.js +2 -2
- 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/engine.js
CHANGED
|
@@ -121,7 +121,7 @@ import { scanRuby } from './sast/ruby.js';
|
|
|
121
121
|
import { scanPhp } from './sast/php.js';
|
|
122
122
|
import { classifySecretCandidate as _entropyClassifySecret } from './sast/_secret-entropy.js';
|
|
123
123
|
// Phase 1 — precision-engineering posture modules.
|
|
124
|
-
import { annotateConfidence } from './posture/confidence.js';
|
|
124
|
+
import { annotateConfidence, applyUnvalidatedPenalty } from './posture/confidence.js';
|
|
125
125
|
import { backfillFindingDefaults } from './posture/finding-defaults.js';
|
|
126
126
|
import { annotatePocs } from './posture/poc-generator.js';
|
|
127
127
|
import { annotateExecutionProofs } from './posture/prove-findings.js';
|
|
@@ -148,7 +148,7 @@ import { ingestLogicClaims } from './posture/logic-claims.js';
|
|
|
148
148
|
import { annotateNarration } from './posture/flow-narration.js';
|
|
149
149
|
import { applyPathConstraints } from './posture/path-predicates.js';
|
|
150
150
|
// Phase 3 (Sentinel-parity Layer 1 + 2) — IR + interprocedural taint engine.
|
|
151
|
-
import { buildProjectIR } from './ir/index.js';
|
|
151
|
+
import { buildProjectIR, buildProjectIRAsync } from './ir/index.js';
|
|
152
152
|
import { collectIrStats, irStatsTarget, writeIrStats } from './ir/ir-stats.js';
|
|
153
153
|
import { runDeepAnalysis } from './dataflow/index.js';
|
|
154
154
|
// v3 next-gen — Pillars 1, 4, 5, 6, 8, 9.
|
|
@@ -1271,13 +1271,75 @@ function _guardWindow(ctx, before = 25, after = 5) {
|
|
|
1271
1271
|
.replace(/(^|[^\w'"`])#[^\n]*/g, '$1 ');
|
|
1272
1272
|
}
|
|
1273
1273
|
|
|
1274
|
-
|
|
1274
|
+
// PRD R15: a guard-shaped token anywhere in the -25/+5 window used to be
|
|
1275
|
+
// sufficient — an allow-list built for an unrelated purpose, or even the
|
|
1276
|
+
// tainted variable's OWN declaration line, sitting in the same screenful of
|
|
1277
|
+
// code as a genuinely-unguarded sink, silently killed a real finding. This
|
|
1278
|
+
// does not require full dataflow correlation, only a cheap positional one:
|
|
1279
|
+
// the sink's own argument identifier(s) must appear WITHIN A FEW LINES of
|
|
1280
|
+
// the specific line the guard-shaped text actually matched on — not merely
|
|
1281
|
+
// somewhere in the whole window, which is true of almost any variable used
|
|
1282
|
+
// nearby (its own declaration, an unrelated helper, the sink line itself).
|
|
1283
|
+
// Falls back to permissive (unable to correlate → don't break existing
|
|
1284
|
+
// recall protection) when the sink line yields no usable identifier.
|
|
1285
|
+
const _GUARD_STOPWORDS = new Set(['var', 'let', 'const', 'function', 'return', 'new', 'await', 'async',
|
|
1286
|
+
'if', 'else', 'for', 'while', 'require', 'import', 'from', 'true', 'false', 'null', 'undefined',
|
|
1287
|
+
'this', 'self', 'req', 'res', 'request', 'response']);
|
|
1288
|
+
function _sinkLineIdentifiers(ctx) {
|
|
1289
|
+
const line = (ctx && Array.isArray(ctx.lines) && ctx.lines[(ctx.line || 1) - 1]) || '';
|
|
1290
|
+
const ids = new Set();
|
|
1291
|
+
// Excludes call-target identifiers (name immediately followed by `(`) —
|
|
1292
|
+
// `fetch(target)` must correlate on the ARGUMENT `target`, not on `fetch`
|
|
1293
|
+
// itself, which incidentally appears anywhere the module imports fetch
|
|
1294
|
+
// (e.g. `const fetch = require('node-fetch')`) and would trivially
|
|
1295
|
+
// "correlate" with any guard window in the same file.
|
|
1296
|
+
const re = /\b[A-Za-z_$][\w$]*\b(?!\s*\()/g;
|
|
1297
|
+
let m;
|
|
1298
|
+
while ((m = re.exec(line))) {
|
|
1299
|
+
const id = m[0];
|
|
1300
|
+
// No minimum length: short variable names (`u`, `p`, `f`) are common,
|
|
1301
|
+
// legitimate taint carriers in real code — excluding them left the
|
|
1302
|
+
// ONLY correlating identifier out entirely for sinks like
|
|
1303
|
+
// `File.ReadAllText(p)` or `axios.get(u.toString())`, defeating
|
|
1304
|
+
// correlation with a guard that genuinely protects that exact
|
|
1305
|
+
// variable (a corpus regression caught this: CVE-2021-22054-ssrf-shape,
|
|
1306
|
+
// CVE-2022-26049-cs-path).
|
|
1307
|
+
if (id.length >= 1 && !_GUARD_STOPWORDS.has(id)) ids.add(id);
|
|
1308
|
+
}
|
|
1309
|
+
return ids;
|
|
1310
|
+
}
|
|
1311
|
+
// Runs guardRe against the window and, for each match, checks whether any
|
|
1312
|
+
// sink-line identifier appears within `span` lines of that match's own line
|
|
1313
|
+
// (excluding the sink line itself, which trivially contains its own
|
|
1314
|
+
// argument). Tries every match, not just the first, since a window can
|
|
1315
|
+
// contain several guard-shaped lines and only one need actually correlate.
|
|
1316
|
+
function _guardMatchNearSinkIdentifier(ctx, guardRe, span = 2) {
|
|
1317
|
+
const w = _guardWindow(ctx);
|
|
1318
|
+
const re = new RegExp(guardRe.source, guardRe.flags.includes('g') ? guardRe.flags : guardRe.flags + 'g');
|
|
1319
|
+
const ids = _sinkLineIdentifiers(ctx);
|
|
1320
|
+
if (!ids.size) return re.test(w); // can't correlate — fall back to the old shape-only check
|
|
1321
|
+
const wLines = w.split('\n');
|
|
1322
|
+
const sinkLineText = (ctx.lines && ctx.lines[(ctx.line || 1) - 1]) || '';
|
|
1323
|
+
let m;
|
|
1324
|
+
while ((m = re.exec(w))) {
|
|
1325
|
+
const guardLineIdx = w.slice(0, m.index).split('\n').length - 1; // 0-based within window
|
|
1326
|
+
const lo = Math.max(0, guardLineIdx - span);
|
|
1327
|
+
const hi = Math.min(wLines.length, guardLineIdx + span + 1);
|
|
1328
|
+
const local = wLines.slice(lo, hi).filter((l) => l !== sinkLineText).join('\n');
|
|
1329
|
+
for (const id of ids) {
|
|
1330
|
+
if (new RegExp(`\\b${id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`).test(local)) return true;
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1333
|
+
return false;
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
function _hasSsrfHostGuard(ctx) { return _guardMatchNearSinkIdentifier(ctx, _SSRF_HOST_GUARD_RE); }
|
|
1275
1337
|
|
|
1276
1338
|
// A path-traversal containment guard near the file sink: a basename/strip
|
|
1277
1339
|
// helper that removes directory components, a framework safe-join, or a
|
|
1278
1340
|
// canonicalize-then-startsWith containment check.
|
|
1279
1341
|
const _PATH_GUARD_RE = /\b(?:basename|GetFileName|secure_filename|sanitize_filename|send_from_directory|safe_join)\s*\(|\b(?:startsWith|startswith|StartsWith|HasPrefix)\s*\(|\bgetCanonicalPath\b|\btoRealPath\b|\bfilepath\s*\.\s*(?:Clean|Base|Abs)\b/;
|
|
1280
|
-
function _hasPathGuard(ctx) { return
|
|
1342
|
+
function _hasPathGuard(ctx) { return _guardMatchNearSinkIdentifier(ctx, _PATH_GUARD_RE); }
|
|
1281
1343
|
|
|
1282
1344
|
// Reflected-XSS output-encoding guard: an HTML escaper applied near the sink.
|
|
1283
1345
|
const _XSS_ESCAPER = String.raw`(?:escapeHtml|escape_html|escape-html|sanitizeHtml|sanitize_html|DOMPurify\.sanitize|he\.encode|he\.escape|_\.escape|validator\.escape|bleach\.clean|markupsafe|htmlspecialchars|htmlentities|html\.escape|escapeHTML|encodeURIComponent|escape)\s*\(`;
|
|
@@ -2214,7 +2276,13 @@ const IAC_PATTERNS = [
|
|
|
2214
2276
|
{ match: /\$\{\{\s*github\.event\.(?:issue|pull_request)\.title|\$\{\{\s*github\.event\.comment\.body/i,
|
|
2215
2277
|
fileTypes: /\.github\/workflows\/.*\.ya?ml$/i,
|
|
2216
2278
|
severity: 'high', cwe: 'CWE-78', vuln: 'GitHub Actions: untrusted github.event input interpolated into shell',
|
|
2217
|
-
fix: 'Pass user-controlled fields via env vars and reference them as $VARNAME in the script body, not via ${{ }} interpolation.'
|
|
2279
|
+
fix: 'Pass user-controlled fields via env vars and reference them as $VARNAME in the script body, not via ${{ }} interpolation.',
|
|
2280
|
+
// Same false-positive as src/sast/pipeline.js's overlapping rule: a pure
|
|
2281
|
+
// `KEY: ${{ github.event.… }}` mapping line assigns to an env var at the
|
|
2282
|
+
// workflow-engine level (the fix's own recommended pattern), not into a
|
|
2283
|
+
// shell command string — only a match embedded in a larger line (a run:
|
|
2284
|
+
// script body) is actually dangerous.
|
|
2285
|
+
lineSafeRe: /^[\w.-]+\s*:\s*\$\{\{[^}]*\}\}\s*$/ },
|
|
2218
2286
|
];
|
|
2219
2287
|
|
|
2220
2288
|
function scanIaC(fp, raw){
|
|
@@ -2227,6 +2295,7 @@ function scanIaC(fp, raw){
|
|
|
2227
2295
|
let m;
|
|
2228
2296
|
while ((m = re.exec(raw))) {
|
|
2229
2297
|
const line = raw.substring(0, m.index).split('\n').length;
|
|
2298
|
+
if (p.lineSafeRe && p.lineSafeRe.test((lines[line - 1] || '').trim())) continue;
|
|
2230
2299
|
findings.push({
|
|
2231
2300
|
id: `iac:${fp}:${line}:${p.vuln.replace(/\s/g, '_').slice(0, 60)}`,
|
|
2232
2301
|
kind: 'iac', severity: p.severity, vuln: p.vuln,
|
|
@@ -2747,10 +2816,24 @@ function scanLogicVulns(fp,raw){
|
|
|
2747
2816
|
while((m=re.exec(haystack))){
|
|
2748
2817
|
const line=lineAt(haystack,m.index);
|
|
2749
2818
|
const snippet=lines[line-1]?.trim()||"";
|
|
2819
|
+
let outSnippet=snippet;
|
|
2750
2820
|
// FP-2: credential FP filter
|
|
2751
2821
|
if(pat.vuln==='Hardcoded Secret'||pat.vuln==='Hardcoded Credential Check'){
|
|
2752
2822
|
const fpCheck=_isFalsePositiveCredential(fp,snippet,m[0]);
|
|
2753
2823
|
if(fpCheck.skip){_suppressionLog.push({vuln:pat.vuln,file:fp,line,snippet,reason:fpCheck.reason});continue;}
|
|
2824
|
+
// Stage 4 correctness audit (coverage breadth, secrets): same
|
|
2825
|
+
// unredacted-snippet leak found in engine.js's scanEntropySecrets/
|
|
2826
|
+
// scanCredentials and sast/secret-concat.js — this is a THIRD,
|
|
2827
|
+
// separate detector (LOGIC_PATTERNS' own "Hardcoded Secret" rule)
|
|
2828
|
+
// that also stored the raw source line, with no masking at all.
|
|
2829
|
+
// The regex captures the quoted value inside `m[0]`; mask just
|
|
2830
|
+
// that value within the reported snippet.
|
|
2831
|
+
const valMatch=m[0].match(/['"]([^'"]{3,})['"]/);
|
|
2832
|
+
if(valMatch){
|
|
2833
|
+
const val=valMatch[1];
|
|
2834
|
+
const masked=val.length>8?val.substring(0,4)+"…"+val.substring(val.length-4):"••••";
|
|
2835
|
+
outSnippet=snippet.split(val).join(masked);
|
|
2836
|
+
}
|
|
2754
2837
|
}
|
|
2755
2838
|
// FP-6: operational-context gate for selected logic patterns
|
|
2756
2839
|
if (predicate) {
|
|
@@ -2760,7 +2843,7 @@ function scanLogicVulns(fp,raw){
|
|
|
2760
2843
|
continue;
|
|
2761
2844
|
}
|
|
2762
2845
|
}
|
|
2763
|
-
results.push({vuln:pat.vuln,severity:pat.severity,cwe:pat.cwe,stride:pat.stride,kind:pat.kind,fix:pat.fix,code:pat.code,file:fp,line,snippet});
|
|
2846
|
+
results.push({vuln:pat.vuln,severity:pat.severity,cwe:pat.cwe,stride:pat.stride,kind:pat.kind,fix:pat.fix,code:pat.code,file:fp,line,snippet:outSnippet});
|
|
2764
2847
|
}
|
|
2765
2848
|
}
|
|
2766
2849
|
const routeRe=/(?:app|router)\s*\.\s*(?:get|post|all)\s*\(\s*['"`](\/(?:debug|admin|test|internal|__)[^'"`]*)/gi;let rm;
|
|
@@ -2987,15 +3070,17 @@ const JAVA_FAMILY_RULES = [
|
|
|
2987
3070
|
return !isWeak(resolved); // strong → suppress; weak → fire
|
|
2988
3071
|
}
|
|
2989
3072
|
// 2) OWASP Benchmark fallback — hardcoded answer-key for OWASP's own
|
|
2990
|
-
// benchmark.properties file. Pure label leakage
|
|
2991
|
-
//
|
|
2992
|
-
|
|
2993
|
-
|
|
3073
|
+
// benchmark.properties file. Pure label leakage. PRD R5: was
|
|
3074
|
+
// opt-out (disabled only under BLIND_BENCH=1) — inverted to opt-in,
|
|
3075
|
+
// enabled only under explicit BENCH_SHAPE=1, matching the
|
|
3076
|
+
// documented default every other bench-shape mechanism follows.
|
|
3077
|
+
const _benchShapeHere = process.env.AGENTIC_SECURITY_BENCH_SHAPE === '1' && process.env.AGENTIC_SECURITY_BLIND_BENCH !== '1';
|
|
3078
|
+
const OWASP_BENCH_PROPS = _benchShapeHere ? {
|
|
2994
3079
|
cryptoAlg1: 'DES/ECB/PKCS5Padding',
|
|
2995
3080
|
cryptoAlg2: 'AES/CCM/NoPadding',
|
|
2996
3081
|
hashAlg1: 'MD5',
|
|
2997
3082
|
hashAlg2: 'SHA-256',
|
|
2998
|
-
};
|
|
3083
|
+
} : {};
|
|
2999
3084
|
if (OWASP_BENCH_PROPS[propKey]) {
|
|
3000
3085
|
return !isWeak(OWASP_BENCH_PROPS[propKey]);
|
|
3001
3086
|
}
|
|
@@ -4443,8 +4528,10 @@ function scanJavaSAST(fp, raw) {
|
|
|
4443
4528
|
// 72/73/74/.../82 where the receiving file has no local source — the
|
|
4444
4529
|
// tainted Vector/List/Map arrives via a method parameter from a sibling
|
|
4445
4530
|
// file. Gated tightly to avoid FPs on real apps.
|
|
4446
|
-
if (!hasSource && process.env.AGENTIC_SECURITY_BLIND_BENCH !== '1') {
|
|
4447
|
-
//
|
|
4531
|
+
if (!hasSource && process.env.AGENTIC_SECURITY_BENCH_SHAPE === '1' && process.env.AGENTIC_SECURITY_BLIND_BENCH !== '1') {
|
|
4532
|
+
// PRD R5: was opt-out (disabled only under BLIND_BENCH=1) — inverted to
|
|
4533
|
+
// opt-in, matching the documented default every other bench-shape
|
|
4534
|
+
// mechanism in this file follows. Juliet-shape signal.
|
|
4448
4535
|
const _isJulietShape = /\bjuliet\.(?:testcases|support)\b/.test(cleaned)
|
|
4449
4536
|
|| /\b(?:badSink|badSource|goodG2B|goodB2G)\s*\(/.test(cleaned);
|
|
4450
4537
|
if (_isJulietShape && /\b(?:Vector|ArrayList|LinkedList|List|Set|HashSet|Map|HashMap|Hashtable|Properties|Queue|Deque|Stack|Optional)\s*<[^>]*>\s+[A-Za-z_]\w*\s*[,)]/.test(cleaned)) {
|
|
@@ -4464,15 +4551,18 @@ function scanJavaSAST(fp, raw) {
|
|
|
4464
4551
|
let pm;
|
|
4465
4552
|
// OWASP_BENCH_PROPS is the OWASP Benchmark answer-key for its own
|
|
4466
4553
|
// benchmark.properties file (hashAlg1 → MD5, cryptoAlg1 → DES/ECB). Pure
|
|
4467
|
-
// label leakage.
|
|
4468
|
-
//
|
|
4469
|
-
|
|
4470
|
-
|
|
4554
|
+
// label leakage. PRD R5: was opt-out (disabled only under
|
|
4555
|
+
// BLIND_BENCH=1) — inverted to opt-in, enabled only under explicit
|
|
4556
|
+
// BENCH_SHAPE=1; real apps use the properties index loaded from the
|
|
4557
|
+
// filesystem instead (the `resolved`/`getJavaProperty` path above this
|
|
4558
|
+
// fallback).
|
|
4559
|
+
const _benchShapeHere = process.env.AGENTIC_SECURITY_BENCH_SHAPE === '1' && process.env.AGENTIC_SECURITY_BLIND_BENCH !== '1';
|
|
4560
|
+
const OWASP_BENCH_PROPS = _benchShapeHere ? {
|
|
4471
4561
|
cryptoAlg1: 'DES/ECB/PKCS5Padding',
|
|
4472
4562
|
cryptoAlg2: 'AES/CCM/NoPadding',
|
|
4473
4563
|
hashAlg1: 'MD5',
|
|
4474
4564
|
hashAlg2: 'SHA-256',
|
|
4475
|
-
};
|
|
4565
|
+
} : {};
|
|
4476
4566
|
const isWeak = (v) =>
|
|
4477
4567
|
/\b(?:MD2|MD4|MD5|SHA-?1|SHA1|DES|DESede|3DES|RC2|RC4|Blowfish|HmacMD5|HmacSHA1)\b|AES\s*\/\s*ECB/i.test(v || '');
|
|
4478
4568
|
while ((pm = propUseRe.exec(cleaned)) !== null) {
|
|
@@ -4803,7 +4893,13 @@ function annotateReachability(findings,routes,callGraph,fc){
|
|
|
4803
4893
|
// Within 60 lines of a route declaration we consider this source route-rooted
|
|
4804
4894
|
const routeRooted=rl.some(l=>Math.abs(l-srcLine)<60);
|
|
4805
4895
|
f.routeRooted=routeRooted;
|
|
4806
|
-
// Cheap function-of-source lookup via callGraph
|
|
4896
|
+
// Cheap function-of-source lookup via callGraph. buildCallGraph only
|
|
4897
|
+
// ever populates entries for .js/.jsx/.ts/.tsx/.mjs/.cjs files (PRD
|
|
4898
|
+
// R15) — callGraph[fp] being absent for every other language is an
|
|
4899
|
+
// ABSENCE OF EVIDENCE, not evidence the finding is unreachable, and
|
|
4900
|
+
// must not be conflated with a JS file whose call graph genuinely has
|
|
4901
|
+
// no incoming edge.
|
|
4902
|
+
const hasCallGraphData=Object.prototype.hasOwnProperty.call(callGraph,fp);
|
|
4807
4903
|
const funcs=callGraph[fp]||{};
|
|
4808
4904
|
let enclosing=null;
|
|
4809
4905
|
for(const[fn,info] of Object.entries(funcs))
|
|
@@ -4812,6 +4908,7 @@ function annotateReachability(findings,routes,callGraph,fc){
|
|
|
4812
4908
|
// Reachable when route-rooted OR enclosingFunction is called from any function
|
|
4813
4909
|
// declared near a route in the same file
|
|
4814
4910
|
if(routeRooted){f.reachable=true;continue;}
|
|
4911
|
+
if(!hasCallGraphData){f.reachable=null;continue;}
|
|
4815
4912
|
let reachable=false;
|
|
4816
4913
|
if(enclosing){
|
|
4817
4914
|
for(const rLine of rl){
|
|
@@ -5409,17 +5506,29 @@ function scanEntropySecrets(fp,raw){
|
|
|
5409
5506
|
// FP-5: structural / doc-context suppression
|
|
5410
5507
|
const surrounding=lines.slice(Math.max(0,line-3),Math.min(lines.length,line+1)).join("\n");
|
|
5411
5508
|
const nonSecretReason=_isLikelyNonSecret(v, ctx, surrounding);
|
|
5509
|
+
const masked=v.substring(0,4)+"…"+v.substring(v.length-4);
|
|
5412
5510
|
if (nonSecretReason) {
|
|
5413
|
-
|
|
5511
|
+
// Same redaction concern as the main finding below applies to the
|
|
5512
|
+
// suppression log — it's exposed via --include-suppressed, and a
|
|
5513
|
+
// heuristic "probably not a real secret" call can be wrong.
|
|
5514
|
+
_suppressionLog.push({vuln:"High-Entropy Credential Candidate",file:fp,line,snippet:ctx.trim().split(v).join(masked),reason:'entropy-'+nonSecretReason});
|
|
5414
5515
|
continue;
|
|
5415
5516
|
}
|
|
5416
|
-
|
|
5517
|
+
// Stage 4 correctness audit (coverage breadth, secrets): `snippet` used
|
|
5518
|
+
// to carry the RAW, unmasked source line — including the full secret
|
|
5519
|
+
// value — even though `masked` right next to it was correctly
|
|
5520
|
+
// redacted. Nothing downstream (normalizeFindings, toHTML, toCSV,
|
|
5521
|
+
// toJUnit) ever redacts `snippet` again, so the plaintext credential
|
|
5522
|
+
// this detector exists to find flowed straight into every report
|
|
5523
|
+
// format this scanner emits, including last-scan.json. Redact the
|
|
5524
|
+
// exact matched value out of the snippet at the source, the same way
|
|
5525
|
+
// `masked` already is.
|
|
5417
5526
|
out.push({
|
|
5418
5527
|
vuln:"High-Entropy Credential Candidate",
|
|
5419
5528
|
severity:"high",cwe:"CWE-798",stride:"Information Disclosure",
|
|
5420
5529
|
fix:"Replace with environment variable or secrets manager reference; rotate the value immediately.",
|
|
5421
5530
|
code:`// BEFORE\nconst secret = "${masked}";\n\n// AFTER\nconst secret = process.env.APP_SECRET;`,
|
|
5422
|
-
file:fp,line,snippet:ctx.trim(),masked,entropy:e.toFixed(2)
|
|
5531
|
+
file:fp,line,snippet:ctx.trim().split(v).join(masked),masked,entropy:e.toFixed(2)
|
|
5423
5532
|
});
|
|
5424
5533
|
}
|
|
5425
5534
|
return out;
|
|
@@ -5459,12 +5568,21 @@ function scanConfigFiles(fc){
|
|
|
5459
5568
|
const[,k,v]=m;
|
|
5460
5569
|
if(!v||v==='""'||v==="''"||/^(?:change.?me|your[_-]|placeholder|example|xxx+|todo|<|\$\{)/i.test(v))continue;
|
|
5461
5570
|
if(/(?:password|secret|key|token|api)/i.test(k)){
|
|
5571
|
+
// Stage 6 correctness audit: this was the one secrets-adjacent
|
|
5572
|
+
// detector still shipping the RAW, unmasked source line as
|
|
5573
|
+
// `snippet` — its siblings (scanCredentials/scanEntropySecrets)
|
|
5574
|
+
// were already fixed for this exact leak in the Stage 4 audit.
|
|
5575
|
+
// Nothing downstream re-redacts `snippet`, and the MCP redact.js
|
|
5576
|
+
// catch-all requires a QUOTED value, which standard `.env`
|
|
5577
|
+
// KEY=value syntax never has — so the plaintext committed
|
|
5578
|
+
// secret flowed straight through explain_finding unredacted.
|
|
5579
|
+
const masked=v.length>8?v.substring(0,4)+"…"+v.substring(v.length-4):"…";
|
|
5462
5580
|
out.push({
|
|
5463
5581
|
vuln:`Committed .env with Real-Looking ${k}`,
|
|
5464
5582
|
severity:"high",cwe:"CWE-538",stride:"Information Disclosure",
|
|
5465
5583
|
fix:`Remove ${k} from committed env files. Use .env.example with placeholders and ignore .env in VCS.`,
|
|
5466
5584
|
code:`# In .gitignore\n.env\n\n# .env.example (commit)\n${k}=<your-${k.toLowerCase()}>`,
|
|
5467
|
-
file:fp,line:i+1,snippet:ln
|
|
5585
|
+
file:fp,line:i+1,snippet:ln.split(v).join(masked),masked
|
|
5468
5586
|
});
|
|
5469
5587
|
}
|
|
5470
5588
|
}
|
|
@@ -5780,15 +5898,26 @@ function dedupeFindingsWithEvidence(findings){
|
|
|
5780
5898
|
const key=`${file}:${sinkLine}:${fam}`;
|
|
5781
5899
|
if(!buckets.has(key)){buckets.set(key,f);continue;}
|
|
5782
5900
|
const kept=buckets.get(key);
|
|
5783
|
-
// Winner selection: an
|
|
5784
|
-
//
|
|
5785
|
-
//
|
|
5786
|
-
//
|
|
5787
|
-
//
|
|
5788
|
-
//
|
|
5901
|
+
// Winner selection: an IR-TAINT finding (the deep engine's real
|
|
5902
|
+
// interprocedural taint walk) is the best carrier at a shared sink,
|
|
5903
|
+
// ahead of everything else — PRD R3: it carries taint-walk-only evidence
|
|
5904
|
+
// (sanitizer observations keyed off the actual value reaching the sink,
|
|
5905
|
+
// chain, LLM-validation state) that a flat pattern/AST match has no
|
|
5906
|
+
// equivalent for, and that evidence would silently vanish if a
|
|
5907
|
+
// same-severity pattern-layer duplicate won the tie instead. Below that,
|
|
5908
|
+
// an interprocedural flow finding (carries source→sink attribution) is
|
|
5909
|
+
// the better carrier than a flat structural/regex match — keep its
|
|
5910
|
+
// chain/source-line attribution. When neither or both carry flow, fall
|
|
5911
|
+
// back to severity. The winner keeps its own severity (we must not
|
|
5912
|
+
// resurrect a rating that ownership/reachability analysis deliberately
|
|
5913
|
+
// downgraded on one of the two findings).
|
|
5914
|
+
const fIsIrTaint = f.parser === 'IR-TAINT';
|
|
5915
|
+
const kIsIrTaint = kept.parser === 'IR-TAINT';
|
|
5789
5916
|
const fHasFlow = !!(f.source && f.sink);
|
|
5790
5917
|
const kHasFlow = !!(kept.source && kept.sink);
|
|
5791
|
-
const keepNew = (
|
|
5918
|
+
const keepNew = (fIsIrTaint !== kIsIrTaint)
|
|
5919
|
+
? fIsIrTaint
|
|
5920
|
+
: (fHasFlow !== kHasFlow)
|
|
5792
5921
|
? fHasFlow
|
|
5793
5922
|
: (SEV_RANK[f.severity]??9) < (SEV_RANK[kept.severity]??9);
|
|
5794
5923
|
const winner = keepNew ? f : kept;
|
|
@@ -6369,7 +6498,11 @@ function scanCredentials(fp,raw){
|
|
|
6369
6498
|
if(seen.has(key))continue;seen.add(key);
|
|
6370
6499
|
const severity=pat.s==="c"?"critical":pat.s==="h"?"high":"medium";
|
|
6371
6500
|
const masked=val.length>12?val.substring(0,6)+"••••••"+val.substring(val.length-4):val.substring(0,3)+"•••";
|
|
6372
|
-
|
|
6501
|
+
// Stage 4 correctness audit (coverage breadth, secrets): same
|
|
6502
|
+
// unredacted-snippet leak as scanEntropySecrets — `snippet` carried
|
|
6503
|
+
// the raw source line (full credential value) straight through to
|
|
6504
|
+
// every report format. Redact the exact matched value here too.
|
|
6505
|
+
results.push({vuln:pat.n,severity,cwe:"CWE-798",stride:"Information Disclosure",file:fp,line,snippet:snippet.split(val).join(masked),masked,fix:"Remove the hardcoded credential. Store secrets in environment variables or a secrets manager (AWS Secrets Manager, HashiCorp Vault, GCP Secret Manager). Rotate the exposed credential immediately, treat it as compromised.",code:`// Remove hardcoded value:\n// const secret = "${masked}";\n\n// Use environment variable instead:\nconst secret = process.env.${pat.n.toUpperCase().replace(/[^A-Z0-9]/g,"_")};`});
|
|
6373
6506
|
}
|
|
6374
6507
|
}
|
|
6375
6508
|
return results;
|
|
@@ -6534,7 +6667,7 @@ function _makePurl(ecosystem,name,version,group){
|
|
|
6534
6667
|
const t={npm:'npm',pypi:'pypi'}[ecosystem]||ecosystem;
|
|
6535
6668
|
if(!t)return'';
|
|
6536
6669
|
const ns=group?`${encodeURIComponent(group)}/`:'';
|
|
6537
|
-
return`pkg:${t}/${ns}${encodeURIComponent(name)}${version?'@'+version:''}`;
|
|
6670
|
+
return`pkg:${t}/${ns}${encodeURIComponent(name)}${version?'@'+encodeURIComponent(version):''}`;
|
|
6538
6671
|
}
|
|
6539
6672
|
|
|
6540
6673
|
function _parsePackageJson(text,filePath){
|
|
@@ -7508,7 +7641,7 @@ async function queryRegistries(components){
|
|
|
7508
7641
|
|
|
7509
7642
|
// Node port: takes { fileContents, depFileContents } maps directly instead of a JSZip object.
|
|
7510
7643
|
// fileContents = code files keyed by relative path; depFileContents = manifest/lockfiles keyed by relative path.
|
|
7511
|
-
async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null, resume=undefined}, setProgress=()=>{}){_resetSuppressions();_buildProjectIndex(fileContents);await _loadCustomRules(scanRoot);
|
|
7644
|
+
async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null, resume=undefined, deep=undefined, deepInCi=undefined}, setProgress=()=>{}){_resetSuppressions();_buildProjectIndex(fileContents);await _loadCustomRules(scanRoot);
|
|
7512
7645
|
// Pre-pass: build cross-file Java tainted-method index so per-file taint
|
|
7513
7646
|
// analysis can recognize calls to user-input-returning helper methods
|
|
7514
7647
|
// defined in OTHER files (Juliet's DataflowThruInnerClass / Vector / Stream
|
|
@@ -7771,7 +7904,15 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
|
|
|
7771
7904
|
const c = fc[p];
|
|
7772
7905
|
if (!c) continue;
|
|
7773
7906
|
// Path-based category for Juliet test cases: `juliet-cweN/.../...java`.
|
|
7774
|
-
|
|
7907
|
+
// PRD R5: this reads a path-embedded answer key (the directory name
|
|
7908
|
+
// declares the CWE) exactly like _javaWebServletCategory's @WebServlet
|
|
7909
|
+
// annotation reading below — off by default, enabled only when
|
|
7910
|
+
// BENCH_SHAPE=1. This branch previously had no gate at all, so a real
|
|
7911
|
+
// repository with a directory that happens to be named `juliet-cweNN/`
|
|
7912
|
+
// would silently lose off-family findings in the default pipeline.
|
|
7913
|
+
const julietMatch = (process.env.AGENTIC_SECURITY_BENCH_SHAPE === '1'
|
|
7914
|
+
&& process.env.AGENTIC_SECURITY_BLIND_BENCH !== '1')
|
|
7915
|
+
? p.match(/(?:^|\/)juliet-cwe(\d+)\//i) : null;
|
|
7775
7916
|
if (julietMatch && _JULIET_CWE_TO_FAMILY[julietMatch[1]]) {
|
|
7776
7917
|
_benchCategoryByFile.set(p, _JULIET_CWE_TO_FAMILY[julietMatch[1]]);
|
|
7777
7918
|
continue;
|
|
@@ -7854,6 +7995,11 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
|
|
|
7854
7995
|
// R8 (PRD §5): OS packages from an extracted container image's package DBs
|
|
7855
7996
|
// (dpkg/apk) — baked-in deps the Dockerfile never names. Feed the OSV/SBOM pipeline.
|
|
7856
7997
|
try{const{extractImagePackages}=await import('./sca/image-packages.js');for(const ip of extractImagePackages(allFileContents)){if(!components.some(c=>c.ecosystem===ip.ecosystem&&c.name===ip.name&&c.version===ip.version))components.push(ip);}}catch(_){}
|
|
7998
|
+
// Dockerfile-declared apt/apk install-line packages — complementary to
|
|
7999
|
+
// extractImagePackages above (which reads an actually scanned filesystem's
|
|
8000
|
+
// installed-package DB, a different signal: build-time-declared vs.
|
|
8001
|
+
// actually-installed).
|
|
8002
|
+
try{const{extractContainerPackages}=await import('./sca/container.js');for(const cp of extractContainerPackages(allFileContents)){if(!components.some(c=>c.ecosystem===cp.ecosystem&&c.name===cp.name&&c.version===cp.version))components.push(cp);}}catch(_){}
|
|
7857
8003
|
try{const{detectVendoredLibraries}=await import('./sca/vendor-detect.js');const vendored=detectVendoredLibraries(fc);for(const v of vendored){const key=`${v.ecosystem}:${v.name}:${v.version}`;if(!components.some(c=>`${c.ecosystem}:${c.name}:${c.version}`===key))components.push({...v,group:'',purl:`pkg:${v.ecosystem}/${v.name}@${v.version}`,filePath:v.file,isUnpinned:false,reachable:true});}}catch(_){}
|
|
7858
8004
|
const reach=buildReachabilitySet(fc);
|
|
7859
8005
|
const reachabilitySet=reach.imported;
|
|
@@ -7979,6 +8125,169 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
|
|
|
7979
8125
|
// Roadmap #8 — tree-sitter sinks for long-tail languages (opt-in,
|
|
7980
8126
|
// AGENTIC_SECURITY_TREE_SITTER=1; degrades to no-op without the optional dep).
|
|
7981
8127
|
if(process.env.AGENTIC_SECURITY_TREE_SITTER==='1'){try{aF.push(...await scanTreeSitterSinks(fc));}catch(_){}}
|
|
8128
|
+
// Phase 3 (Sentinel-parity FR-L1, FR-L2) — IR + interprocedural taint.
|
|
8129
|
+
// R1 (PRD §5): the CLI entry (bin/agentic-security.js#cmdScan) now sets
|
|
8130
|
+
// AGENTIC_SECURITY_DEEP=1 by default for local/interactive scans, so deep mode
|
|
8131
|
+
// runs on the default `/scan --all` path. This gate is the enforcement +
|
|
8132
|
+
// CI-safety point: it honors an explicit opt-out (DEEP=0) and keeps deep off in
|
|
8133
|
+
// CI unless DEEP_IN_CI=1. In-process callers (tests, the cve-replay corpus) invoke
|
|
8134
|
+
// runScan()/runFullScan() directly without the CLI default, so they stay deep-off
|
|
8135
|
+
// and remain deterministic regression gates.
|
|
8136
|
+
//
|
|
8137
|
+
// PRD R3: IR-TAINT findings are appended into `aF` HERE, before dedup, so
|
|
8138
|
+
// they dedupe against a pattern-layer duplicate of the same sink and then
|
|
8139
|
+
// ride through the exact same annotator pipeline every other finding does
|
|
8140
|
+
// (stable IDs, clustering, reachability, family backfill, confidence,
|
|
8141
|
+
// calibration, exploitability, sanitizer/proof gate, mitigation, composite
|
|
8142
|
+
// risk, LLM validation...) below. Previously this block ran AFTER that
|
|
8143
|
+
// entire pipeline and pushed straight into the post-dedup `finalFindings`
|
|
8144
|
+
// array, so a sink caught by both the regex layer and deep mode produced
|
|
8145
|
+
// two findings (one with no family and no calibrated confidence) instead
|
|
8146
|
+
// of one deduped, fully-annotated finding.
|
|
8147
|
+
//
|
|
8148
|
+
// SAFETY: Deep mode is gated for CI safety:
|
|
8149
|
+
// - Global timeout via AGENTIC_SECURITY_DEEP_TIMEOUT_MS (default 300_000 = 5 min)
|
|
8150
|
+
// - Auto-disabled in CI unless AGENTIC_SECURITY_DEEP_IN_CI=1 is also set,
|
|
8151
|
+
// so a pathological file can't hang the whole pipeline.
|
|
8152
|
+
// ── IR parse-coverage sidecar (proof-corpus instrumentation, default off) ──
|
|
8153
|
+
// Built ahead of the deep-mode gate so coverage is measurable without paying
|
|
8154
|
+
// for taint analysis, and stashed in _sharedIR so the deep block below reuses
|
|
8155
|
+
// it rather than parsing the project twice.
|
|
8156
|
+
//
|
|
8157
|
+
// NOTE (affects instrumented runs only, i.e. AGENTIC_SECURITY_IR_STATS set):
|
|
8158
|
+
// when this block runs, buildProjectIR() happens here, BEFORE the deep-mode
|
|
8159
|
+
// budget timer (t0) below is started. On an uninstrumented run, IR
|
|
8160
|
+
// construction instead happens inside the timed block via the
|
|
8161
|
+
// `_sharedIR || (_sharedIR = buildProjectIR(fc))` line, so its cost counts
|
|
8162
|
+
// against AGENTIC_SECURITY_DEEP_TIMEOUT_MS. That means the deep budget does
|
|
8163
|
+
// NOT account for parse time when stats are enabled — an instrumented run
|
|
8164
|
+
// gets strictly more wall-clock for the taint analysis itself than an
|
|
8165
|
+
// uninstrumented run with the same budget.
|
|
8166
|
+
let _sharedIR = null;
|
|
8167
|
+
// Java IR requires the ASYNC builder. `parser-java.js` exports an async
|
|
8168
|
+
// `parseJavaFile` (java-parser needs a dynamic import), so the sync
|
|
8169
|
+
// `buildProjectIR` has no Java branch at all — and both deep-path call sites
|
|
8170
|
+
// used it. The result was that no .java file had ever produced an IR function
|
|
8171
|
+
// in deep mode: `bench/layer-recall` measured java at 0/25 while the catalog
|
|
8172
|
+
// carried 7 Java sources and 15 Java sinks that had nothing to run against.
|
|
8173
|
+
// `buildProjectIRAsync` is a full mirror plus Java and had zero callers.
|
|
8174
|
+
//
|
|
8175
|
+
// Gated on the presence of .java rather than always awaiting: the async
|
|
8176
|
+
// builder is a superset, but switching every scan in the product to it to fix
|
|
8177
|
+
// one language would change the execution shape (and attempt the java-parser
|
|
8178
|
+
// import) for projects that contain no Java. `runFullScan` is already async,
|
|
8179
|
+
// so the await costs nothing structurally.
|
|
8180
|
+
const _hasJava = Object.keys(fc || {}).some(f => /\.java$/i.test(f));
|
|
8181
|
+
const _buildIR = async () => (_hasJava ? await buildProjectIRAsync(fc) : buildProjectIR(fc));
|
|
8182
|
+
const _irStatsTarget = irStatsTarget();
|
|
8183
|
+
if (_irStatsTarget) {
|
|
8184
|
+
try {
|
|
8185
|
+
_sharedIR = await _buildIR();
|
|
8186
|
+
writeIrStats(_irStatsTarget, collectIrStats(fc, _sharedIR.perFile, _sharedIR.callGraph));
|
|
8187
|
+
} catch (e) {
|
|
8188
|
+
// Instrumentation must never fail a scan. Surface only when debugging.
|
|
8189
|
+
if (process.env.AGENTIC_SECURITY_IR_STATS_DEBUG === '1') {
|
|
8190
|
+
process.stderr.write(`ir-stats: ${e && e.message}\n`);
|
|
8191
|
+
}
|
|
8192
|
+
}
|
|
8193
|
+
}
|
|
8194
|
+
// `deep`/`deepInCi` come from runScan()'s options object (threaded through
|
|
8195
|
+
// unchanged from runScan.js) — an explicit-opt-in override alongside the
|
|
8196
|
+
// env vars, not a replacement for them. Added because `runScan(dir,
|
|
8197
|
+
// {deep:true})` was a silent, total no-op: this options object was
|
|
8198
|
+
// destructured for fileContents/depFileContents/scanRoot/resume only, so
|
|
8199
|
+
// `deep` was dropped on the floor and deep mode stayed off regardless.
|
|
8200
|
+
// Several interprocedural test files (interproc-k2.test.js,
|
|
8201
|
+
// parser-cs-kt.test.js, points-to.test.js) pass exactly this option
|
|
8202
|
+
// believing it enables deep mode — it never did, so those tests were
|
|
8203
|
+
// exercising whatever coincidentally fires without the deep engine, not
|
|
8204
|
+
// the interprocedural machinery they're named for.
|
|
8205
|
+
const _deepRequested = deep === true || process.env.AGENTIC_SECURITY_DEEP === '1';
|
|
8206
|
+
const _inCi = !!(process.env.CI || process.env.GITHUB_ACTIONS || process.env.GITLAB_CI ||
|
|
8207
|
+
process.env.BUILDKITE || process.env.CIRCLECI || process.env.JENKINS_URL);
|
|
8208
|
+
const _deepInCiAllowed = deepInCi === true || process.env.AGENTIC_SECURITY_DEEP_IN_CI === '1';
|
|
8209
|
+
const _deepEnabled = _deepRequested && (!_inCi || _deepInCiAllowed);
|
|
8210
|
+
let _deepCallGraph = null;
|
|
8211
|
+
if (_deepEnabled) {
|
|
8212
|
+
const budgetMs = parseInt(process.env.AGENTIC_SECURITY_DEEP_TIMEOUT_MS || '300000', 10);
|
|
8213
|
+
const t0 = Date.now();
|
|
8214
|
+
try {
|
|
8215
|
+
const { perFile, callGraph } = _sharedIR || (_sharedIR = await _buildIR());
|
|
8216
|
+
_deepCallGraph = callGraph;
|
|
8217
|
+
// The runDeepAnalysis call is synchronous in this codebase; we can't
|
|
8218
|
+
// truly interrupt it without re-architecting the worklist. We pass a
|
|
8219
|
+
// deadlineMs hint that the inner loops check; if absent, we still cap
|
|
8220
|
+
// function count via fnLimit. Operators who suspect a hung run can
|
|
8221
|
+
// kill the process and re-run with AGENTIC_SECURITY_DEEP=0.
|
|
8222
|
+
const irFindings = runDeepAnalysis(perFile, callGraph, {
|
|
8223
|
+
fnLimit: parseInt(process.env.AGENTIC_SECURITY_DEEP_FN_LIMIT || '5000', 10),
|
|
8224
|
+
deadlineMs: t0 + budgetMs,
|
|
8225
|
+
// v0.69 — incremental cache inputs (used when AGENTIC_SECURITY_INCREMENTAL=1).
|
|
8226
|
+
scanRoot,
|
|
8227
|
+
fileContents: fc,
|
|
8228
|
+
});
|
|
8229
|
+
const elapsed = Date.now() - t0;
|
|
8230
|
+
if (elapsed > budgetMs) {
|
|
8231
|
+
// We exceeded budget — surface a single info finding so operators see it.
|
|
8232
|
+
aF.push({
|
|
8233
|
+
id: `ir-taint-timeout:${scanRoot || ''}`,
|
|
8234
|
+
file: '(deep-engine)', line: 0,
|
|
8235
|
+
vuln: `IR-TAINT deep mode exceeded ${budgetMs}ms budget (${elapsed}ms used) — results may be incomplete`,
|
|
8236
|
+
severity: 'info',
|
|
8237
|
+
parser: 'IR-TAINT',
|
|
8238
|
+
confidence: 0.5,
|
|
8239
|
+
});
|
|
8240
|
+
}
|
|
8241
|
+
for (const f of irFindings) {
|
|
8242
|
+
f.unvalidated = true;
|
|
8243
|
+
f.validator_verdict = 'unvalidated';
|
|
8244
|
+
}
|
|
8245
|
+
aF.push(...irFindings);
|
|
8246
|
+
} catch (e) {
|
|
8247
|
+
// Deep mode is best-effort. A parser blowup in one file shouldn't kill
|
|
8248
|
+
// the scan — fall back to the pattern-only result.
|
|
8249
|
+
}
|
|
8250
|
+
} else if (_deepRequested && _inCi) {
|
|
8251
|
+
// Operator asked for deep but we're in CI — emit a non-blocking notice
|
|
8252
|
+
// so they know it was skipped and how to override.
|
|
8253
|
+
aF.push({
|
|
8254
|
+
id: 'ir-taint-ci-skipped',
|
|
8255
|
+
file: '(deep-engine)', line: 0,
|
|
8256
|
+
vuln: 'IR-TAINT deep mode skipped in CI environment (set AGENTIC_SECURITY_DEEP_IN_CI=1 to opt in)',
|
|
8257
|
+
severity: 'info',
|
|
8258
|
+
parser: 'IR-TAINT',
|
|
8259
|
+
confidence: 1.0,
|
|
8260
|
+
});
|
|
8261
|
+
}
|
|
8262
|
+
// Java SCA enrichment: use deep-mode IR call graph to improve Java function reachability
|
|
8263
|
+
if (_deepCallGraph) {
|
|
8264
|
+
try {
|
|
8265
|
+
for (const sc of supplyChain) {
|
|
8266
|
+
if (sc.type !== 'vulnerable_dep' || sc.ecosystem !== 'maven') continue;
|
|
8267
|
+
if (sc.functionReachable === 'reachable') continue;
|
|
8268
|
+
const allFns = [...(sc.osvVulnFunctions || []), ...(VULN_FUNCTION_HINTS[sc.name] || [])];
|
|
8269
|
+
if (!allFns.length) continue;
|
|
8270
|
+
for (const fn of _deepCallGraph.functions ? _deepCallGraph.functions.values() : []) {
|
|
8271
|
+
if (!fn.cfg || !fn.cfg.nodes) continue;
|
|
8272
|
+
for (const node of Object.values(fn.cfg.nodes)) {
|
|
8273
|
+
if (node.kind !== 'call') continue;
|
|
8274
|
+
const callee = typeof node.callee === 'string' ? node.callee : null;
|
|
8275
|
+
if (!callee) continue;
|
|
8276
|
+
const shortCallee = callee.includes('.') ? callee.split('.').pop() : callee;
|
|
8277
|
+
if (allFns.some(f => f === shortCallee || f === callee)) {
|
|
8278
|
+
sc.functionReachable = 'reachable';
|
|
8279
|
+
sc.reachabilityTier = 'function-reachable';
|
|
8280
|
+
if (!sc.vulnerableFunctionCallSites) sc.vulnerableFunctionCallSites = [];
|
|
8281
|
+
sc.vulnerableFunctionCallSites.push({ pkg: sc.name, fn: shortCallee, file: fn.file, line: node.line });
|
|
8282
|
+
sc._javaIrEnriched = true;
|
|
8283
|
+
break;
|
|
8284
|
+
}
|
|
8285
|
+
}
|
|
8286
|
+
if (sc.functionReachable === 'reachable') break;
|
|
8287
|
+
}
|
|
8288
|
+
}
|
|
8289
|
+
} catch { /* Java SCA enrichment is best-effort */ }
|
|
8290
|
+
}
|
|
7982
8291
|
let finalFindings;try{finalFindings=dedupeFindingsWithEvidence(aF);}catch(_){finalFindings=dd(aF,f=>f.id);}
|
|
7983
8292
|
// Inline `agentic-security-ignore` pragmas, pass 1 of 2. This covers every
|
|
7984
8293
|
// finding that exists BY THIS POINT — the pattern detectors, the cross-file
|
|
@@ -8104,7 +8413,13 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
|
|
|
8104
8413
|
};
|
|
8105
8414
|
_runAnnotator('annotateStableIds', () => annotateStableIds(finalFindings));
|
|
8106
8415
|
_runAnnotator("clusterByRootCause", () => { finalFindings = clusterByRootCause(finalFindings); });
|
|
8107
|
-
_runAnnotator("demoteUnreachable", () => {
|
|
8416
|
+
_runAnnotator("demoteUnreachable", () => {
|
|
8417
|
+
demoteUnreachable(finalFindings, { routes: aR });
|
|
8418
|
+
// `type: 'vulnerable_dep'` findings live in supplyChain, not finalFindings
|
|
8419
|
+
// (src/sca/CLAUDE.md) — demoteUnreachable's SCA-tier branch needs this
|
|
8420
|
+
// array passed explicitly or it never sees an SCA finding at all.
|
|
8421
|
+
demoteUnreachable(supplyChain, { routes: aR });
|
|
8422
|
+
});
|
|
8108
8423
|
// Premortem #8: backfill parser/family BEFORE confidence and calibration,
|
|
8109
8424
|
// because both consume those fields and silently no-op when they're null.
|
|
8110
8425
|
_runAnnotator("backfillFindingDefaults", () => { backfillFindingDefaults(finalFindings); });
|
|
@@ -8124,22 +8439,25 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
|
|
|
8124
8439
|
// Generalised sanitizer consumption (dataflow/sanitizer-gate.js): labels
|
|
8125
8440
|
// findings whose flow passes a catalog sanitizer matching their family
|
|
8126
8441
|
// (xss/url/cmd, not just sql) so the proof gate below can demote them the
|
|
8127
|
-
// same way it demotes proven-clean SQL.
|
|
8128
|
-
//
|
|
8129
|
-
//
|
|
8130
|
-
//
|
|
8131
|
-
//
|
|
8132
|
-
//
|
|
8133
|
-
// `
|
|
8134
|
-
//
|
|
8135
|
-
//
|
|
8136
|
-
//
|
|
8137
|
-
//
|
|
8138
|
-
//
|
|
8139
|
-
// (1) dataflow/engine.js honouring `kind === 'sanitizer'` at a call site,
|
|
8140
|
-
// and (2) that call site's callee name threaded onto the finding
|
|
8141
|
-
// alongside the trace/chain that proven-clean.js already reads.
|
|
8442
|
+
// same way it demotes proven-clean SQL.
|
|
8443
|
+
//
|
|
8444
|
+
// The taint walk now records the sanitizer callees observed on the value
|
|
8445
|
+
// reaching each sink argument (`dataflow/engine.js` `_sanitizersForExpr`)
|
|
8446
|
+
// and stamps them on the finding as `_sanitizersOnPath`. This rebuilds the
|
|
8447
|
+
// `{ [findingId]: string[] }` shape the gate wants. Both `id` and
|
|
8448
|
+
// `stableId` are keyed because the gate accepts either and stable ids are
|
|
8449
|
+
// assigned by an earlier annotator.
|
|
8450
|
+
//
|
|
8451
|
+
// The sanitizer never kills the taint in the walk itself: a mislabelled
|
|
8452
|
+
// sanitizer would then hide a real vulnerability outright, whereas a label
|
|
8453
|
+
// only demotes confidence here. Recall-preserving, on purpose.
|
|
8142
8454
|
const sanitizersOnPath = {};
|
|
8455
|
+
for (const f of finalFindings) {
|
|
8456
|
+
const names = f && f._sanitizersOnPath;
|
|
8457
|
+
if (!Array.isArray(names) || !names.length) continue;
|
|
8458
|
+
if (f.id) sanitizersOnPath[f.id] = names;
|
|
8459
|
+
if (f.stableId) sanitizersOnPath[f.stableId] = names;
|
|
8460
|
+
}
|
|
8143
8461
|
_runAnnotator("applySanitizerGate", () => { applySanitizerGate(finalFindings, { sanitizersOnPath }); });
|
|
8144
8462
|
_runAnnotator("annotateProofGate", () => { annotateProofGate(finalFindings); });
|
|
8145
8463
|
}
|
|
@@ -8288,8 +8606,10 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
|
|
|
8288
8606
|
// v3 next-gen: per-attacker-persona score matrix (FR-ADV-2). Must run AFTER
|
|
8289
8607
|
// crown-jewels + mitigation composite so it sees those signals.
|
|
8290
8608
|
_runAnnotator("annotatePersonaScores", () => { annotatePersonaScores(finalFindings); });
|
|
8291
|
-
// v3 next-gen: SCA reverse-blast-radius enrichment (FR-ADV-5).
|
|
8292
|
-
|
|
8609
|
+
// v3 next-gen: SCA reverse-blast-radius enrichment (FR-ADV-5). Annotates
|
|
8610
|
+
// SCA findings (package-name-keyed) — must run against supplyChain, not
|
|
8611
|
+
// finalFindings (SAST), which has no package-name field at all.
|
|
8612
|
+
_runAnnotator("annotateScaReverseBlast", () => { annotateScaReverseBlast(supplyChain, fc); });
|
|
8293
8613
|
// v3 next-gen: bug-bounty payout prediction (FR-ADV-3). Composes with the
|
|
8294
8614
|
// mitigation composite — gated/unreachable findings get the bounty scaled
|
|
8295
8615
|
// down rather than zeroed.
|
|
@@ -8391,146 +8711,18 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
|
|
|
8391
8711
|
// FR-LOGIC-6: LLM-driven flow narration (template fallback when no LLM endpoint).
|
|
8392
8712
|
try { await annotateNarration(finalFindings); }
|
|
8393
8713
|
catch (e) { _annotatorErrors.push({ phase: 'annotateNarration', err: String((e && e.message) || e) }); }
|
|
8394
|
-
// Phase
|
|
8395
|
-
//
|
|
8396
|
-
//
|
|
8397
|
-
//
|
|
8398
|
-
//
|
|
8399
|
-
//
|
|
8400
|
-
//
|
|
8401
|
-
//
|
|
8402
|
-
//
|
|
8403
|
-
//
|
|
8404
|
-
//
|
|
8405
|
-
//
|
|
8406
|
-
// - Auto-disabled in CI unless AGENTIC_SECURITY_DEEP_IN_CI=1 is also set,
|
|
8407
|
-
// so a pathological file can't hang the whole pipeline.
|
|
8408
|
-
// ── IR parse-coverage sidecar (proof-corpus instrumentation, default off) ──
|
|
8409
|
-
// Built ahead of the deep-mode gate so coverage is measurable without paying
|
|
8410
|
-
// for taint analysis, and stashed in _sharedIR so the deep block below reuses
|
|
8411
|
-
// it rather than parsing the project twice.
|
|
8412
|
-
//
|
|
8413
|
-
// NOTE (affects instrumented runs only, i.e. AGENTIC_SECURITY_IR_STATS set):
|
|
8414
|
-
// when this block runs, buildProjectIR() happens here, BEFORE the deep-mode
|
|
8415
|
-
// budget timer (t0) below is started. On an uninstrumented run, IR
|
|
8416
|
-
// construction instead happens inside the timed block via the
|
|
8417
|
-
// `_sharedIR || (_sharedIR = buildProjectIR(fc))` line, so its cost counts
|
|
8418
|
-
// against AGENTIC_SECURITY_DEEP_TIMEOUT_MS. That means the deep budget does
|
|
8419
|
-
// NOT account for parse time when stats are enabled — an instrumented run
|
|
8420
|
-
// gets strictly more wall-clock for the taint analysis itself than an
|
|
8421
|
-
// uninstrumented run with the same budget.
|
|
8422
|
-
let _sharedIR = null;
|
|
8423
|
-
const _irStatsTarget = irStatsTarget();
|
|
8424
|
-
if (_irStatsTarget) {
|
|
8425
|
-
try {
|
|
8426
|
-
_sharedIR = buildProjectIR(fc);
|
|
8427
|
-
writeIrStats(_irStatsTarget, collectIrStats(fc, _sharedIR.perFile, _sharedIR.callGraph));
|
|
8428
|
-
} catch (e) {
|
|
8429
|
-
// Instrumentation must never fail a scan. Surface only when debugging.
|
|
8430
|
-
if (process.env.AGENTIC_SECURITY_IR_STATS_DEBUG === '1') {
|
|
8431
|
-
process.stderr.write(`ir-stats: ${e && e.message}\n`);
|
|
8432
|
-
}
|
|
8433
|
-
}
|
|
8434
|
-
}
|
|
8435
|
-
const _deepRequested = process.env.AGENTIC_SECURITY_DEEP === '1';
|
|
8436
|
-
const _inCi = !!(process.env.CI || process.env.GITHUB_ACTIONS || process.env.GITLAB_CI ||
|
|
8437
|
-
process.env.BUILDKITE || process.env.CIRCLECI || process.env.JENKINS_URL);
|
|
8438
|
-
const _deepInCiAllowed = process.env.AGENTIC_SECURITY_DEEP_IN_CI === '1';
|
|
8439
|
-
const _deepEnabled = _deepRequested && (!_inCi || _deepInCiAllowed);
|
|
8440
|
-
if (_deepEnabled) {
|
|
8441
|
-
const budgetMs = parseInt(process.env.AGENTIC_SECURITY_DEEP_TIMEOUT_MS || '300000', 10);
|
|
8442
|
-
const t0 = Date.now();
|
|
8443
|
-
try {
|
|
8444
|
-
const { perFile, callGraph } = _sharedIR || (_sharedIR = buildProjectIR(fc));
|
|
8445
|
-
// The runDeepAnalysis call is synchronous in this codebase; we can't
|
|
8446
|
-
// truly interrupt it without re-architecting the worklist. We pass a
|
|
8447
|
-
// deadlineMs hint that the inner loops check; if absent, we still cap
|
|
8448
|
-
// function count via fnLimit. Operators who suspect a hung run can
|
|
8449
|
-
// kill the process and re-run with AGENTIC_SECURITY_DEEP=0.
|
|
8450
|
-
const irFindings = runDeepAnalysis(perFile, callGraph, {
|
|
8451
|
-
fnLimit: parseInt(process.env.AGENTIC_SECURITY_DEEP_FN_LIMIT || '5000', 10),
|
|
8452
|
-
deadlineMs: t0 + budgetMs,
|
|
8453
|
-
// v0.69 — incremental cache inputs (used when AGENTIC_SECURITY_INCREMENTAL=1).
|
|
8454
|
-
scanRoot,
|
|
8455
|
-
fileContents: fc,
|
|
8456
|
-
});
|
|
8457
|
-
const elapsed = Date.now() - t0;
|
|
8458
|
-
if (elapsed > budgetMs) {
|
|
8459
|
-
// We exceeded budget — surface a single info finding so operators see it.
|
|
8460
|
-
finalFindings.push({
|
|
8461
|
-
id: `ir-taint-timeout:${scanRoot || ''}`,
|
|
8462
|
-
file: '(deep-engine)', line: 0,
|
|
8463
|
-
vuln: `IR-TAINT deep mode exceeded ${budgetMs}ms budget (${elapsed}ms used) — results may be incomplete`,
|
|
8464
|
-
severity: 'info',
|
|
8465
|
-
parser: 'IR-TAINT',
|
|
8466
|
-
confidence: 0.5,
|
|
8467
|
-
});
|
|
8468
|
-
}
|
|
8469
|
-
for (const f of irFindings) {
|
|
8470
|
-
f.unvalidated = true;
|
|
8471
|
-
f.validator_verdict = 'unvalidated';
|
|
8472
|
-
}
|
|
8473
|
-
finalFindings.push(...irFindings);
|
|
8474
|
-
// Pragma pass 2 of 2 — see the pass-1 comment far above. Deep-mode IR
|
|
8475
|
-
// findings land here, long after pass 1 ran, so without this an
|
|
8476
|
-
// `agentic-security-ignore` on an ir-taint finding is inert. Deep mode is
|
|
8477
|
-
// what the CLI uses outside CI and taint findings are the ones users most
|
|
8478
|
-
// want to silence, so the documented feature did nothing in the case that
|
|
8479
|
-
// mattered most.
|
|
8480
|
-
//
|
|
8481
|
-
// Re-running over the already-filtered array is safe and does not
|
|
8482
|
-
// double-log: pass 1's removals are gone from `finalFindings`, so only the
|
|
8483
|
-
// newly-appended IR findings can match here, and each suppression reaches
|
|
8484
|
-
// the ledger exactly once.
|
|
8485
|
-
try{ _applyIgnorePragmas(finalFindings, fc); }catch(_){}
|
|
8486
|
-
// Java SCA enrichment: use deep-mode IR call graph to improve Java function reachability
|
|
8487
|
-
try {
|
|
8488
|
-
for (const sc of supplyChain) {
|
|
8489
|
-
if (sc.type !== 'vulnerable_dep' || sc.ecosystem !== 'maven') continue;
|
|
8490
|
-
if (sc.functionReachable === 'reachable') continue;
|
|
8491
|
-
const allFns = [...(sc.osvVulnFunctions || []), ...(VULN_FUNCTION_HINTS[sc.name] || [])];
|
|
8492
|
-
if (!allFns.length) continue;
|
|
8493
|
-
for (const fn of callGraph.functions ? callGraph.functions.values() : []) {
|
|
8494
|
-
if (!fn.cfg || !fn.cfg.nodes) continue;
|
|
8495
|
-
for (const node of Object.values(fn.cfg.nodes)) {
|
|
8496
|
-
if (node.kind !== 'call') continue;
|
|
8497
|
-
const callee = typeof node.callee === 'string' ? node.callee : null;
|
|
8498
|
-
if (!callee) continue;
|
|
8499
|
-
const shortCallee = callee.includes('.') ? callee.split('.').pop() : callee;
|
|
8500
|
-
if (allFns.some(f => f === shortCallee || f === callee)) {
|
|
8501
|
-
sc.functionReachable = 'reachable';
|
|
8502
|
-
sc.reachabilityTier = 'function-reachable';
|
|
8503
|
-
if (!sc.vulnerableFunctionCallSites) sc.vulnerableFunctionCallSites = [];
|
|
8504
|
-
sc.vulnerableFunctionCallSites.push({ pkg: sc.name, fn: shortCallee, file: fn.file, line: node.line });
|
|
8505
|
-
sc._javaIrEnriched = true;
|
|
8506
|
-
break;
|
|
8507
|
-
}
|
|
8508
|
-
}
|
|
8509
|
-
if (sc.functionReachable === 'reachable') break;
|
|
8510
|
-
}
|
|
8511
|
-
}
|
|
8512
|
-
} catch { /* Java SCA enrichment is best-effort */ }
|
|
8513
|
-
} catch (e) {
|
|
8514
|
-
// Deep mode is best-effort. A parser blowup in one file shouldn't kill
|
|
8515
|
-
// the scan — fall back to the pattern-only result.
|
|
8516
|
-
}
|
|
8517
|
-
} else if (_deepRequested && _inCi) {
|
|
8518
|
-
// Operator asked for deep but we're in CI — emit a non-blocking notice
|
|
8519
|
-
// so they know it was skipped and how to override.
|
|
8520
|
-
finalFindings.push({
|
|
8521
|
-
id: 'ir-taint-ci-skipped',
|
|
8522
|
-
file: '(deep-engine)', line: 0,
|
|
8523
|
-
vuln: 'IR-TAINT deep mode skipped in CI environment (set AGENTIC_SECURITY_DEEP_IN_CI=1 to opt in)',
|
|
8524
|
-
severity: 'info',
|
|
8525
|
-
parser: 'IR-TAINT',
|
|
8526
|
-
confidence: 1.0,
|
|
8527
|
-
});
|
|
8528
|
-
}
|
|
8529
|
-
// Phase 2 (Sentinel-parity): LLM validator stage. No-op unless the operator
|
|
8530
|
-
// sets AGENTIC_SECURITY_LLM_VALIDATE=1 AND AGENTIC_SECURITY_LLM_ENDPOINT. When
|
|
8531
|
-
// disabled, every finding gets unvalidated:true and the existing confidence
|
|
8532
|
-
// pipeline accounts for that. When enabled, the validator emits accept/reject
|
|
8533
|
-
// /escalate per finding; rejects are dropped into the suppression log.
|
|
8714
|
+
// Phase 2 (Sentinel-parity): LLM validator stage. DEFAULT-ON whenever
|
|
8715
|
+
// AGENTIC_SECURITY_LLM_ENDPOINT is configured — not gated on
|
|
8716
|
+
// AGENTIC_SECURITY_LLM_VALIDATE=1 as this comment previously (and wrongly)
|
|
8717
|
+
// said. Opt OUT with AGENTIC_SECURITY_LLM_VALIDATE=0; the legacy
|
|
8718
|
+
// AGENTIC_SECURITY_LLM_VALIDATE=1 still works as an explicit-on no-op. With
|
|
8719
|
+
// no endpoint configured the validator stays a no-op regardless — no
|
|
8720
|
+
// surprise network calls from an unrelated env var. See
|
|
8721
|
+
// llm-validator/index.js's own header, which states this correctly; this
|
|
8722
|
+
// comment was the one that had drifted. When disabled, every finding gets
|
|
8723
|
+
// unvalidated:true and the existing confidence pipeline accounts for that.
|
|
8724
|
+
// When enabled, the validator emits accept/reject/escalate per finding;
|
|
8725
|
+
// rejects are dropped into the suppression log.
|
|
8534
8726
|
try {
|
|
8535
8727
|
// Concurrency defaults to 1 (the validator's deterministic-default).
|
|
8536
8728
|
// Operators raise via AGENTIC_SECURITY_LLM_CONCURRENCY at the cost of
|
|
@@ -8543,7 +8735,23 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
|
|
|
8543
8735
|
vuln: d.vuln, file: d.file, line: d.line, snippet: d.snippet,
|
|
8544
8736
|
reason: 'llm-validator:reject:' + (d.validator_reasoning || '').slice(0, 80),
|
|
8545
8737
|
});
|
|
8738
|
+
// Re-run: annotateVerifierVerdicts ran (~8335) before validator_verdict
|
|
8739
|
+
// existed on any finding (set here, hundreds of lines later), so its
|
|
8740
|
+
// 'verified-by-llm' verdict — documented as one of five possible
|
|
8741
|
+
// outcomes in verifier.js's own header — could never be produced by the
|
|
8742
|
+
// real pipeline. Cheap and idempotent; re-running is the fix, not
|
|
8743
|
+
// moving the original call (confidence/exploitability annotators
|
|
8744
|
+
// upstream of it still need to run before validation, same as before).
|
|
8745
|
+
try { annotateVerifierVerdicts(finalFindings, { fileContents: fc }); } catch (_) {}
|
|
8546
8746
|
} catch(_) {}
|
|
8747
|
+
// Same ordering fix as annotateVerifierVerdicts above: annotateConfidence
|
|
8748
|
+
// (~8111) computes f.confidence before f.unvalidated exists on any
|
|
8749
|
+
// finding, so its 0.85x "LLM validator unavailable" penalty could never
|
|
8750
|
+
// apply in the real pipeline. Runs unconditionally (outside the
|
|
8751
|
+
// llmValidateMany try-block above) because f.unvalidated is also set
|
|
8752
|
+
// directly on deep-mode IR findings appended earlier in this function,
|
|
8753
|
+
// independent of whether the LLM validator itself ran.
|
|
8754
|
+
try { applyUnvalidatedPenalty(finalFindings); } catch (_) {}
|
|
8547
8755
|
try {
|
|
8548
8756
|
const { kept, suppressed } = applyLearnedFeedback(scanRoot, finalFindings);
|
|
8549
8757
|
finalFindings = kept;
|
|
@@ -8610,8 +8818,11 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
|
|
|
8610
8818
|
/(?:^|\/)test_[^/]*\.py$/i.test(f) || /_test\.(?:py|go)$/i.test(f));
|
|
8611
8819
|
annotateScaVerdicts(supplyChain, { testsDetected: _testsDetected });
|
|
8612
8820
|
} catch (_) {}
|
|
8613
|
-
// 0.9.0 Feat-15: dep confusion
|
|
8614
|
-
|
|
8821
|
+
// 0.9.0 Feat-15: dep confusion. These are supply-chain findings (kind:'sca',
|
|
8822
|
+
// derived from `components` not source code) and belong in supplyChain, not
|
|
8823
|
+
// aF — aF was already snapshotted into finalFindings at dedupeFindingsWithEvidence
|
|
8824
|
+
// above, so pushing into aF here silently discards every result.
|
|
8825
|
+
try{const dc=detectDepConfusion(annotatedComponents,scanRoot);supplyChain.push(...dc);}catch(_){}
|
|
8615
8826
|
// Deployment-platform security checklist
|
|
8616
8827
|
try{const dpf=scanDeployPlatform(scanRoot);aLogic.push(...dpf);}catch(_){}
|
|
8617
8828
|
// Stack-specific security playbook
|
|
@@ -8814,8 +9025,22 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
|
|
|
8814
9025
|
if (process.env.AGENTIC_SECURITY_NO_COMPLIANCE !== '1') {
|
|
8815
9026
|
try {
|
|
8816
9027
|
const policy = loadCompliancePolicy(scanRoot);
|
|
8817
|
-
if (policy &&
|
|
9028
|
+
if (policy && policy._error) {
|
|
9029
|
+
// CMP-5: a malformed policy used to be silently treated the same
|
|
9030
|
+
// as "no policy file" — the parse error (which names the exact
|
|
9031
|
+
// problem) was computed and then discarded. verifyPolicy now
|
|
9032
|
+
// reports it as a distinct 'error' status rather than staying
|
|
9033
|
+
// silent, and it is surfaced here too so an interactive scan
|
|
9034
|
+
// shows it.
|
|
8818
9035
|
_complianceReport = verifyCompliancePolicy(policy, { scanRoot, findings: finalFindings });
|
|
9036
|
+
process.stderr.write(`[compliance] ${policy._error}\n`);
|
|
9037
|
+
} else if (policy) {
|
|
9038
|
+
// CMP-5: pass every channel a real scan produces, not just SAST —
|
|
9039
|
+
// a finding-family check for hardcoded-secret or vulnerable-dep
|
|
9040
|
+
// was previously invisible to secrets/SCA findings entirely.
|
|
9041
|
+
_complianceReport = verifyCompliancePolicy(policy, {
|
|
9042
|
+
scanRoot, findings: finalFindings, secrets: aSecrets, logicVulns: aLogic, supplyChain,
|
|
9043
|
+
});
|
|
8819
9044
|
emitComplianceJsonLd(_complianceReport, scanRoot);
|
|
8820
9045
|
emitComplianceMarkdown(_complianceReport, scanRoot);
|
|
8821
9046
|
}
|
|
@@ -8895,7 +9120,16 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
|
|
|
8895
9120
|
let _analysisTier = null, _unmodeledSinks = null;
|
|
8896
9121
|
try { _analysisTier = computeAnalysisTiers(Object.keys(fc)); } catch {}
|
|
8897
9122
|
try { _unmodeledSinks = countUnmodeledSinkCandidates(fc, finalFindings); } catch {}
|
|
8898
|
-
|
|
9123
|
+
// filesScanned counts files actually analyzed (Object.keys(fc), the same
|
|
9124
|
+
// set computeAnalysisTiers above reads) — NOT files.length, the candidate
|
|
9125
|
+
// list before the per-file loop's size/density skips run. Using the
|
|
9126
|
+
// candidate count here double-counted skipped files: they were included
|
|
9127
|
+
// in filesScanned AND separately reported in filesSkipped/filesDenseSkipped,
|
|
9128
|
+
// so coverage-report.js's "scanned=N skipped=M" line implied N+M files were
|
|
9129
|
+
// seen when only N-of-those-candidates were actually analyzed.
|
|
9130
|
+
// checkpoint.total intentionally keeps files.length — that field means the
|
|
9131
|
+
// full candidate set for resume bookkeeping, a different, correct meaning.
|
|
9132
|
+
const _scanMeta={filesScanned:Object.keys(fc).length,filesSkipped:_filesSkipped,filesDenseSkipped:_filesDenseSkipped,filesTimedOut:_filesTimedOut,analysisTier:_analysisTier,unmodeledSinkCandidates:_unmodeledSinks,fileTimings:_fileTimings.sort((a,b)=>b.ms-a.ms).slice(0,20),findingsBySeverity:{critical:finalFindings.filter(f=>f.severity==='critical').length,high:finalFindings.filter(f=>f.severity==='high').length,medium:finalFindings.filter(f=>f.severity==='medium').length,low:finalFindings.filter(f=>f.severity==='low').length,info:finalFindings.filter(f=>f.severity==='info').length},checkpoint:{enabled:!!(_ckpt&&_ckpt.enabled),resumed:_ckptResumed,total:files.length}};
|
|
8899
9133
|
// R8: the scan completed, so the checkpoint has been fully consumed — remove
|
|
8900
9134
|
// it. Anything that threw before this point leaves it in place to resume from.
|
|
8901
9135
|
try { closeCheckpoint(_ckpt, { complete: true }); } catch (_) {}
|