@clear-capabilities/agentic-security-scanner 0.86.0 → 0.119.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +660 -0
- package/bin/agentic-security.js +78 -1
- package/dist/178.index.js +1 -1
- package/dist/384.index.js +1 -1
- package/dist/415.index.js +90 -0
- package/dist/444.index.js +89 -0
- package/dist/503.index.js +288 -0
- package/dist/637.index.js +15 -1
- package/dist/838.index.js +1 -1
- package/dist/985.index.js +5 -1
- package/dist/agentic-security.mjs +84 -84
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +21 -10
- package/src/dataflow/CLAUDE.md +18 -6
- package/src/dataflow/catalog.js +59 -11
- package/src/dataflow/engine.js +106 -5
- package/src/dataflow/implicit-flow.js +4 -2
- package/src/dataflow/index.js +17 -4
- package/src/dataflow/proof-gate.js +100 -0
- package/src/dataflow/summaries.js +63 -6
- package/src/engine.js +292 -10
- package/src/ir/CLAUDE.md +1 -0
- package/src/ir/callgraph.js +12 -1
- package/src/ir/parser-go.js +8 -0
- package/src/ir/tree-sitter-loader.js +108 -0
- package/src/mcp/tools.js +6 -2
- package/src/posture/corpus-status.js +74 -0
- package/src/posture/coverage-report.js +113 -0
- package/src/posture/fix-history.js +24 -0
- package/src/posture/holdout-eval.js +57 -0
- package/src/posture/provenance.js +39 -0
- package/src/posture/router.js +33 -3
- package/src/posture/sca-verdict.js +130 -0
- package/src/posture/secret-history.js +72 -0
- package/src/pr-delta.js +13 -0
- package/src/report/index.js +67 -0
- package/src/sast/CLAUDE.md +9 -3
- package/src/sast/agent-untrusted-flow.js +107 -0
- package/src/sast/api-authz.js +71 -0
- package/src/sast/code-injection-multilang.js +117 -0
- package/src/sast/cross-service.js +98 -0
- package/src/sast/crypto-protocol.js +43 -1
- package/src/sast/csharp-structural.js +65 -0
- package/src/sast/csharp.js +3 -0
- package/src/sast/csrf.js +24 -3
- package/src/sast/csv-injection.js +84 -0
- package/src/sast/event-entrypoint.js +90 -0
- package/src/sast/frontend-hygiene.js +106 -0
- package/src/sast/go-structural.js +60 -0
- package/src/sast/iac-terraform.js +107 -0
- package/src/sast/java-structural.js +81 -0
- package/src/sast/js-framework-structural.js +115 -0
- package/src/sast/kotlin.js +72 -0
- package/src/sast/ldap-injection.js +73 -17
- package/src/sast/open-redirect.js +47 -5
- package/src/sast/php.js +35 -0
- package/src/sast/python-structural.js +83 -0
- package/src/sast/rbac-consistency.js +113 -0
- package/src/sast/redos-nfa.js +75 -0
- package/src/sast/response-splitting.js +48 -19
- package/src/sast/ruby.js +38 -0
- package/src/sast/secret-concat.js +60 -0
- package/src/sast/ssrf-cloud-metadata.js +13 -0
- package/src/sast/stored-taint.js +88 -0
- package/src/sast/tree-sitter-sinks.js +62 -0
- package/src/sast/weak-password-hash.js +28 -0
- package/src/sast/weak-randomness.js +46 -5
- package/src/sast/wrong-context-sanitizer.js +149 -0
- package/src/sast/xpath-injection.js +30 -6
- package/src/sast/xss-reflected-multilang.js +116 -0
- package/src/sast/xxe.js +67 -0
- package/src/sca/image-packages.js +74 -0
- package/src/sca/import-reachability.js +271 -0
- package/src/sca/install-script-analysis.js +82 -0
- package/bin/.agentic-security/findings.json +0 -1907
- package/bin/.agentic-security/last-scan.json +0 -1907
- package/bin/.agentic-security/last-scan.json.sig +0 -1
- package/bin/.agentic-security/scan-history.json +0 -166
- package/bin/.agentic-security/streak.json +0 -20
- package/src/.agentic-security/findings.json +0 -117732
- package/src/.agentic-security/last-scan.json +0 -117732
- package/src/.agentic-security/last-scan.json.sig +0 -1
- package/src/.agentic-security/scan-history.json +0 -12946
- package/src/.agentic-security/streak.json +0 -21
- package/src/dataflow/.agentic-security/findings.json +0 -6086
- package/src/dataflow/.agentic-security/last-scan.json +0 -6086
- package/src/dataflow/.agentic-security/last-scan.json.sig +0 -1
- package/src/dataflow/.agentic-security/scan-history.json +0 -250
- package/src/dataflow/.agentic-security/streak.json +0 -21
- package/src/ir/.agentic-security/findings.json +0 -4011
- package/src/ir/.agentic-security/last-scan.json +0 -4011
- package/src/ir/.agentic-security/last-scan.json.sig +0 -1
- package/src/ir/.agentic-security/scan-history.json +0 -193
- package/src/ir/.agentic-security/streak.json +0 -20
- package/src/mcp/.agentic-security/findings.json +0 -8632
- package/src/mcp/.agentic-security/last-scan.json +0 -8632
- package/src/mcp/.agentic-security/last-scan.json.sig +0 -1
- package/src/mcp/.agentic-security/scan-history.json +0 -331
- package/src/mcp/.agentic-security/streak.json +0 -20
- package/src/posture/.agentic-security/dpia.md +0 -26
- package/src/posture/.agentic-security/findings.json +0 -77181
- package/src/posture/.agentic-security/last-scan.json +0 -77181
- package/src/posture/.agentic-security/last-scan.json.sig +0 -1
- package/src/posture/.agentic-security/pqc-migration-plan.json +0 -65
- package/src/posture/.agentic-security/pqc-migration-plan.md +0 -30
- package/src/posture/.agentic-security/sbom-history/7d45b5e03804aac084b4a2b4dc8c6f10107d2005.json +0 -6
- package/src/posture/.agentic-security/scan-history.json +0 -8904
- package/src/posture/.agentic-security/streak.json +0 -21
- package/src/posture/.agentic-security/threat-model.json +0 -2038
- package/src/posture/.agentic-security/threat-model.md +0 -73
- package/src/posture/compliance-frameworks/.agentic-security/findings.json +0 -80
- package/src/posture/compliance-frameworks/.agentic-security/last-scan.json +0 -80
- package/src/posture/compliance-frameworks/.agentic-security/last-scan.json.sig +0 -1
- package/src/posture/compliance-frameworks/.agentic-security/scan-history.json +0 -90
- package/src/posture/compliance-frameworks/.agentic-security/streak.json +0 -22
- package/src/report/.agentic-security/sbom-history/7d45b5e03804aac084b4a2b4dc8c6f10107d2005.json +0 -6
- package/src/report/.agentic-security/threat-model.json +0 -7
- package/src/report/.agentic-security/threat-model.md +0 -22
- package/src/sast/.agentic-security/findings.json +0 -6154
- package/src/sast/.agentic-security/last-scan.json +0 -6154
- package/src/sast/.agentic-security/last-scan.json.sig +0 -1
- package/src/sast/.agentic-security/scan-history.json +0 -941
- package/src/sast/.agentic-security/streak.json +0 -22
- package/src/sca/.agentic-security/findings.json +0 -7460
- package/src/sca/.agentic-security/last-scan.json +0 -7460
- package/src/sca/.agentic-security/last-scan.json.sig +0 -1
- package/src/sca/.agentic-security/scan-history.json +0 -113
- package/src/sca/.agentic-security/streak.json +0 -21
|
@@ -1,9 +1,18 @@
|
|
|
1
1
|
// Function-summary cache for context-sensitive interprocedural taint.
|
|
2
2
|
//
|
|
3
|
-
// PRD §6.2: "k-CFA configurable per analysis." This module
|
|
4
|
-
//
|
|
5
|
-
// entry-taint-state, cached by hash
|
|
6
|
-
//
|
|
3
|
+
// PRD §6.2: "k-CFA configurable per analysis." This module is VALUE-context
|
|
4
|
+
// sensitive (FR-SEM-2): a function gets a distinct summary PER distinct
|
|
5
|
+
// entry-taint-state, cached by hash and computed lazily at the call sites
|
|
6
|
+
// that need it (see engine.js). So a helper that is pure when called with
|
|
7
|
+
// clean args but vulnerable when called with tainted args is modelled
|
|
8
|
+
// correctly at each call site, not collapsed to one empty-entry result.
|
|
9
|
+
//
|
|
10
|
+
// To bound the blowup that per-context computation invites, the number of
|
|
11
|
+
// distinct NON-empty contexts kept per function is capped
|
|
12
|
+
// (`_maxContextsPerFn`, env `AGENTIC_SECURITY_KCFA_MAX_CONTEXTS`, default 16;
|
|
13
|
+
// 0 = pure monovariant). Over the cap we reuse the empty-entry summary.
|
|
14
|
+
// Call-string (k>1) sensitivity is still not modelled — context is the
|
|
15
|
+
// value-abstraction (which params are tainted), not the call stack.
|
|
7
16
|
//
|
|
8
17
|
// A summary captures, given a set of tainted parameter names at function
|
|
9
18
|
// entry, what the function does:
|
|
@@ -43,12 +52,42 @@ export class SummaryCache {
|
|
|
43
52
|
this._stack = new Set(); // qids currently being analyzed (recursion guard)
|
|
44
53
|
this._iter = 0;
|
|
45
54
|
this._maxIter = 5000;
|
|
55
|
+
// FR-SEM-2 (roadmap #2): the engine computes a distinct summary per
|
|
56
|
+
// entry-taint-state (value-context sensitivity), computed lazily at call
|
|
57
|
+
// sites. To stop that from blowing up when a hot helper is reached under
|
|
58
|
+
// many distinct tainted-arg combinations, cap the number of NON-empty
|
|
59
|
+
// entry contexts kept per function. Over the cap we reuse the empty-entry
|
|
60
|
+
// (monovariant) summary — the conservative base the pre-pass computed.
|
|
61
|
+
// AGENTIC_SECURITY_KCFA_MAX_CONTEXTS=N (default 16)
|
|
62
|
+
// ...=0 disables context-sensitivity (pure monovariant).
|
|
63
|
+
this._contextsByQid = new Map(); // qid → Set<stateHash> of computed contexts
|
|
64
|
+
const envCap = Number(process.env.AGENTIC_SECURITY_KCFA_MAX_CONTEXTS);
|
|
65
|
+
this._maxContextsPerFn = Number.isFinite(envCap) && envCap >= 0 ? envCap : 16;
|
|
66
|
+
this._contextCapHits = 0;
|
|
67
|
+
// R2 (PRD §5): bounded k=1 call-string sensitivity. OPT-IN (default OFF).
|
|
68
|
+
// When on, the cache key is extended with the immediate caller's qid, so two
|
|
69
|
+
// call paths reaching a helper with the SAME tainted-arg shape get DISTINCT
|
|
70
|
+
// summaries instead of sharing one (removes the over-merge that the pure
|
|
71
|
+
// value-context cache produces). Off by default → keys are byte-identical to
|
|
72
|
+
// before, so no behavior change for the shipped engine.
|
|
73
|
+
this._callString = process.env.AGENTIC_SECURITY_KCFA_CALLSTRING === '1';
|
|
74
|
+
this._callerCtx = null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Set the immediate-caller qid for call-string keying; returns the previous
|
|
78
|
+
// value so the engine can save/restore it around nested callee analysis.
|
|
79
|
+
setCallerContext(callerQid) {
|
|
80
|
+
const prev = this._callerCtx;
|
|
81
|
+
this._callerCtx = callerQid || null;
|
|
82
|
+
return prev;
|
|
46
83
|
}
|
|
47
84
|
|
|
48
85
|
_key(qid, taintedParams, receiverType) {
|
|
49
86
|
// P1.2: when a receiver type is provided, extend the cache key with
|
|
50
87
|
// its hash. Backward-compatible: no receiverType → same key as before.
|
|
51
|
-
|
|
88
|
+
let base = `${qid}::${_hashState(taintedParams)}`;
|
|
89
|
+
// R2: append the caller qid when call-string sensitivity is enabled.
|
|
90
|
+
if (this._callString && this._callerCtx) base = `${base}@${this._callerCtx}`;
|
|
52
91
|
if (!receiverType) return base;
|
|
53
92
|
return `${base}::${hashReceiverType(receiverType)}`;
|
|
54
93
|
}
|
|
@@ -77,6 +116,24 @@ export class SummaryCache {
|
|
|
77
116
|
const cached = this._cache.get(k);
|
|
78
117
|
if (!cached._recursive) return cached;
|
|
79
118
|
}
|
|
119
|
+
// Context cap (FR-SEM-2). Empty entry is always allowed — it's the base
|
|
120
|
+
// summary the pre-pass computes for every function. For a NON-empty
|
|
121
|
+
// context that's new and over budget, reuse the empty-entry summary
|
|
122
|
+
// instead of computing a fresh one.
|
|
123
|
+
const stateHash = _hashState(taintedParams);
|
|
124
|
+
if (stateHash !== 'empty') {
|
|
125
|
+
let ctxs = this._contextsByQid.get(qid);
|
|
126
|
+
if (!ctxs) { ctxs = new Set(); this._contextsByQid.set(qid, ctxs); }
|
|
127
|
+
if (!ctxs.has(stateHash) && ctxs.size >= this._maxContextsPerFn) {
|
|
128
|
+
this._contextCapHits++;
|
|
129
|
+
const base = this._cache.get(this._key(qid, new Set()));
|
|
130
|
+
if (base && !base._recursive) return base;
|
|
131
|
+
// No base summary yet → fall through and compute this one (we need a
|
|
132
|
+
// result), but don't grow the context set.
|
|
133
|
+
} else {
|
|
134
|
+
ctxs.add(stateHash);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
80
137
|
if (this._stack.has(qid)) {
|
|
81
138
|
this._hitRecursion = true;
|
|
82
139
|
return { returnTainted: false, mutatedParams: new Set(), taintedGlobals: new Set(), findings: [], _recursive: true };
|
|
@@ -125,7 +182,7 @@ export class SummaryCache {
|
|
|
125
182
|
}
|
|
126
183
|
|
|
127
184
|
size() { return this._cache.size; }
|
|
128
|
-
clear() { this._cache.clear(); this._iter = 0; }
|
|
185
|
+
clear() { this._cache.clear(); this._iter = 0; this._contextsByQid.clear(); this._contextCapHits = 0; }
|
|
129
186
|
}
|
|
130
187
|
|
|
131
188
|
function _summaryEq(a, b) {
|
package/src/engine.js
CHANGED
|
@@ -35,6 +35,10 @@ import { scanMobileManifest } from './sast/mobile-manifest.js';
|
|
|
35
35
|
import { scanQuarkusHardening } from './sast/quarkus-hardening.js';
|
|
36
36
|
import { scanFastapiHardening } from './sast/fastapi-hardening.js';
|
|
37
37
|
import { scanAuthZ } from './sast/authz.js';
|
|
38
|
+
import { scanApiBrokenAuthz } from './sast/api-authz.js';
|
|
39
|
+
import { scanTerraform } from './sast/iac-terraform.js';
|
|
40
|
+
import { scanCrossService } from './sast/cross-service.js';
|
|
41
|
+
import { scanRbacConsistency } from './sast/rbac-consistency.js';
|
|
38
42
|
import { scanModelLoad } from './sast/model-load.js';
|
|
39
43
|
import { scanPromptTemplate } from './sast/prompt-template.js';
|
|
40
44
|
import { scanXXE } from './sast/xxe.js';
|
|
@@ -81,10 +85,27 @@ import { scanLDAPInjection } from './sast/ldap-injection.js';
|
|
|
81
85
|
import { scanXPathInjection } from './sast/xpath-injection.js';
|
|
82
86
|
import { scanSSTI } from './sast/ssti.js';
|
|
83
87
|
import { scanOpenRedirect } from './sast/open-redirect.js';
|
|
88
|
+
import { scanWrongContextSanitizer, scanSanitizerContextMismatch } from './sast/wrong-context-sanitizer.js';
|
|
89
|
+
import { scanFrontendHygiene } from './sast/frontend-hygiene.js';
|
|
90
|
+
import { scanCsvInjection } from './sast/csv-injection.js';
|
|
91
|
+
import { scanStoredTaint } from './sast/stored-taint.js';
|
|
92
|
+
import { scanTreeSitterSinks } from './sast/tree-sitter-sinks.js';
|
|
93
|
+
import { scanJavaStructural } from './sast/java-structural.js';
|
|
94
|
+
import { scanCsharpStructural } from './sast/csharp-structural.js';
|
|
95
|
+
import { scanJsFrameworkStructural } from './sast/js-framework-structural.js';
|
|
96
|
+
import { scanPythonStructural } from './sast/python-structural.js';
|
|
97
|
+
import { scanGoStructural } from './sast/go-structural.js';
|
|
98
|
+
import { scanSecretConcat } from './sast/secret-concat.js';
|
|
99
|
+
import { scanXssReflectedMultilang } from './sast/xss-reflected-multilang.js';
|
|
100
|
+
import { scanCodeInjectionMultilang } from './sast/code-injection-multilang.js';
|
|
101
|
+
import { scanRegexReDoS } from './sast/redos-nfa.js';
|
|
84
102
|
import { scanResponseSplitting } from './sast/response-splitting.js';
|
|
85
103
|
import { scanStoredPromptInjection, scanStoredPromptInjectionCrossFile } from './sast/llm-stored-prompt.js';
|
|
86
104
|
import { scanRAGPoisoning } from './sast/rag-poisoning.js';
|
|
87
105
|
import { scanAgentToolEscalation } from './sast/agent-tool-escalation.js';
|
|
106
|
+
import { scanAgentUntrustedFlow } from './sast/agent-untrusted-flow.js';
|
|
107
|
+
import { scanInstallScripts } from './sca/install-script-analysis.js';
|
|
108
|
+
import { scanEventEntrypoints } from './sast/event-entrypoint.js';
|
|
88
109
|
import { scanDbTaint, scanDbTaintCrossFile } from './sast/db-taint.js';
|
|
89
110
|
import { scanSSRFCloudMetadata } from './sast/ssrf-cloud-metadata.js';
|
|
90
111
|
import { scanMutationXSS } from './sast/mutation-xss.js';
|
|
@@ -129,6 +150,8 @@ import { annotateFeatureFlagGating } from './posture/feature-flags.js';
|
|
|
129
150
|
import { annotatePersonaScores } from './posture/persona-prioritization.js';
|
|
130
151
|
import { annotateMitigationComposite } from './posture/mitigation-composite.js';
|
|
131
152
|
import { annotateCompositeRisk } from './posture/composite-risk.js';
|
|
153
|
+
import { annotateScaVerdicts } from './posture/sca-verdict.js';
|
|
154
|
+
import { annotateFindingProvenance } from './posture/provenance.js';
|
|
132
155
|
|
|
133
156
|
// ── Integration block: world-class scaffolded modules ──────────────────────
|
|
134
157
|
// Each module is opt-in via its own env var so partial adoption is safe.
|
|
@@ -148,6 +171,8 @@ import { annotateRuntimeCorrelation } from './posture/runtime-correlation.js';
|
|
|
148
171
|
import { applyLearnedCalibration } from './posture/triage-learning.js';
|
|
149
172
|
import { annotateFormalVerification } from './dataflow/formal-verify.js';
|
|
150
173
|
import { annotatePathFeasibility } from './dataflow/smt-feasibility.js';
|
|
174
|
+
import { annotateProofGate } from './dataflow/proof-gate.js';
|
|
175
|
+
import { computeAnalysisTiers, countUnmodeledSinkCandidates } from './posture/coverage-report.js';
|
|
151
176
|
import { annotatePrivacyTaint, emitDpiaArtifact } from './dataflow/privacy-taint.js';
|
|
152
177
|
import { buildThreatModel as buildAutoThreatModel, persistThreatModel as persistAutoThreatModel } from './posture/threat-model-auto.js';
|
|
153
178
|
import { runApiContractScan } from './posture/api-contract.js';
|
|
@@ -1187,6 +1212,111 @@ function _hasPostLookupOwnershipCheck(lines, sinkLine){
|
|
|
1187
1212
|
return false;
|
|
1188
1213
|
}
|
|
1189
1214
|
|
|
1215
|
+
// An SSRF host allow/deny guard near the request sink: a hostname checked
|
|
1216
|
+
// against an allow/deny-list, an RFC1918 / loopback / link-local / metadata
|
|
1217
|
+
// prefix check, or a known SSRF-filter helper. Control-flow (not assignment),
|
|
1218
|
+
// so it isn't caught by the sanitizer-on-assignment path. Recall-safe: an
|
|
1219
|
+
// unguarded fetch still has no guard in its window.
|
|
1220
|
+
const _SSRF_HOST_GUARD_RE = /\b(?:DENY|ALLOW(?:ED)?|allow_?list|deny_?list|block_?list|allowed_?hosts?|blocked_?hosts?|ALLOWED_HOSTS)\b|\.(?:hostname|host|getHost\s*\(\s*\))\b[\s\S]{0,60}?(?:\bin\b|\.has\s*\(|\.includes\s*\(|\.contains\s*\(|[!=]=|\.startsWith\s*\(|\.startswith\s*\()|\b(?:startsWith|startswith)\s*\(\s*['"](?:10\.|192\.168\.|127\.|169\.254\.|172\.(?:1[6-9]|2\d|3[01])\.|::1|fc00|fd)|\bipaddress\b|\bis_private\b|\bis_loopback\b|getaddrinfo|gethostbyname|ssrf[-_]?req[-_]?filter|isSafeUrl|isPrivateIp|validateUrl|_is_metadata_or_private|urlparse\s*\([^)]*\)\s*\.\s*(?:hostname|netloc)/i;
|
|
1221
|
+
|
|
1222
|
+
// A comment-stripped window around the sink. Comments must not read as guards
|
|
1223
|
+
// (e.g. a "no host allow-list / 169.254.169.254" comment describing the vuln).
|
|
1224
|
+
function _guardWindow(ctx, before = 25, after = 5) {
|
|
1225
|
+
if (!ctx || !Array.isArray(ctx.lines)) return '';
|
|
1226
|
+
const line = ctx.line || 1;
|
|
1227
|
+
const lo = Math.max(0, line - before);
|
|
1228
|
+
const hi = Math.min(ctx.lines.length, line + after);
|
|
1229
|
+
return ctx.lines.slice(lo, hi).join('\n')
|
|
1230
|
+
.replace(/\/\*[\s\S]*?\*\//g, ' ')
|
|
1231
|
+
.replace(/\/\/[^\n]*/g, ' ')
|
|
1232
|
+
.replace(/(^|[^\w'"`])#[^\n]*/g, '$1 ');
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
function _hasSsrfHostGuard(ctx) { return _SSRF_HOST_GUARD_RE.test(_guardWindow(ctx)); }
|
|
1236
|
+
|
|
1237
|
+
// A path-traversal containment guard near the file sink: a basename/strip
|
|
1238
|
+
// helper that removes directory components, a framework safe-join, or a
|
|
1239
|
+
// canonicalize-then-startsWith containment check.
|
|
1240
|
+
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/;
|
|
1241
|
+
function _hasPathGuard(ctx) { return _PATH_GUARD_RE.test(_guardWindow(ctx)); }
|
|
1242
|
+
|
|
1243
|
+
// Reflected-XSS output-encoding guard: an HTML escaper applied near the sink.
|
|
1244
|
+
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*\(`;
|
|
1245
|
+
const _XSS_ESCAPE_RE = new RegExp(_XSS_ESCAPER);
|
|
1246
|
+
// An escaper whose RESULT is captured (assigned / returned) — not a bare,
|
|
1247
|
+
// return-discarded `escapeHtml(s);` call, which does nothing for the reflected
|
|
1248
|
+
// value (FP-3 in smoke). Requires `… = <escaper>(` or `return <escaper>(`.
|
|
1249
|
+
const _XSS_ASSIGNED_ESCAPE_RE = new RegExp(String.raw`(?:[:=]|\breturn\b)\s*[^=;\n]*?` + _XSS_ESCAPER);
|
|
1250
|
+
// A raw request source appearing on the sink line itself (not via a prior
|
|
1251
|
+
// escaped var). If present, the reflection is unescaped — keep the finding.
|
|
1252
|
+
const _XSS_RAW_SOURCE_RE = /\breq(?:uest)?\s*\.|\bctx\s*\.\s*(?:query|params|request|body)\b|\$_(?:GET|POST|REQUEST|COOKIE)\b|\bparams\s*\[|\.(?:query|body|params)\s*\.[A-Za-z_$]/;
|
|
1253
|
+
function _hasXssOutputEncoding(ctx) {
|
|
1254
|
+
if (!ctx || !Array.isArray(ctx.lines)) return false;
|
|
1255
|
+
const line = ctx.lines[(ctx.line || 1) - 1] || '';
|
|
1256
|
+
// The reflected-XSS finding is anchored either on the escaped-assignment line
|
|
1257
|
+
// (`const safe = escape(req.query.x)`) or on the response sink line
|
|
1258
|
+
// (`res.send('…' + safe + '…')`).
|
|
1259
|
+
// - If the finding line itself captures the escaped value (`const safe =
|
|
1260
|
+
// escape(req.query.x)`), the reflection is encoded → safe. A bare,
|
|
1261
|
+
// return-discarded `escapeHtml(s);` does NOT count.
|
|
1262
|
+
if (_XSS_ASSIGNED_ESCAPE_RE.test(line)) return true;
|
|
1263
|
+
// - A raw, unescaped request source on the finding line is the real vuln
|
|
1264
|
+
// shape (`res.send('…' + req.query.x)`) → keep it.
|
|
1265
|
+
if (_XSS_RAW_SOURCE_RE.test(line)) return false;
|
|
1266
|
+
// - Otherwise the sink reflects a variable; drop only if that variable was
|
|
1267
|
+
// escaped (and the result captured) in the surrounding window. A bare
|
|
1268
|
+
// `escapeHtml(s);` with a discarded return does NOT count.
|
|
1269
|
+
return _XSS_ASSIGNED_ESCAPE_RE.test(_guardWindow(ctx, 8, 2));
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
// Centralized guard-recognition pass (#1). SSRF (CWE-918) and path-traversal
|
|
1273
|
+
// (CWE-22) are emitted by several independent detectors (regex, structural,
|
|
1274
|
+
// per-language flow, PY-SAST, CSHARP, GO) — none of which shared a notion of
|
|
1275
|
+
// "the sink is hardened by a host allow/deny check or a path containment
|
|
1276
|
+
// guard." This single pass drops such findings uniformly, regardless of which
|
|
1277
|
+
// detector produced them, using the same comment-stripped window guards.
|
|
1278
|
+
// Recall-safe: a genuinely-unguarded sink has no guard in its window (the
|
|
1279
|
+
// cve-replay vulnerable-tier TP count is the invariant that proves this).
|
|
1280
|
+
export function dropGuardedFindings(findings, fileContents) {
|
|
1281
|
+
if (!Array.isArray(findings)) return findings;
|
|
1282
|
+
const cache = new Map();
|
|
1283
|
+
const linesOf = (file) => {
|
|
1284
|
+
if (cache.has(file)) return cache.get(file);
|
|
1285
|
+
const c = fileContents && fileContents[file];
|
|
1286
|
+
const arr = typeof c === 'string' ? c.split('\n') : null;
|
|
1287
|
+
cache.set(file, arr);
|
|
1288
|
+
return arr;
|
|
1289
|
+
};
|
|
1290
|
+
return findings.filter((f) => {
|
|
1291
|
+
if (!f || !f.file) return true;
|
|
1292
|
+
// Already-sanitized findings (isSanitized / info) are owned by the
|
|
1293
|
+
// sanitizer-dataflow pipeline, which records them in scan.suppressions.
|
|
1294
|
+
// Don't hard-drop them here or that bookkeeping is lost (smoke FP-3).
|
|
1295
|
+
if (f.isSanitized) return true;
|
|
1296
|
+
const cwe = f.cwe || '';
|
|
1297
|
+
const isXss = cwe === 'CWE-79' && /reflected xss/i.test(f.vuln || '');
|
|
1298
|
+
if (cwe !== 'CWE-918' && cwe !== 'CWE-22' && !isXss) return true;
|
|
1299
|
+
// Flow-pair findings (REGEX parser) carry the line on sink/source, not at
|
|
1300
|
+
// the top level; fall back to those before parsing it out of the id.
|
|
1301
|
+
let line = f.line;
|
|
1302
|
+
if (typeof line !== 'number') {
|
|
1303
|
+
line = (f.sink && f.sink.line) ?? (f.source && f.source.line);
|
|
1304
|
+
if (typeof line !== 'number') {
|
|
1305
|
+
const m = /:(\d+):/.exec(f.id || '');
|
|
1306
|
+
if (m) line = Number(m[1]);
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
if (typeof line !== 'number') return true;
|
|
1310
|
+
const lines = linesOf(f.file);
|
|
1311
|
+
if (!lines) return true;
|
|
1312
|
+
const ctx = { lines, line };
|
|
1313
|
+
if (cwe === 'CWE-918' && _hasSsrfHostGuard(ctx)) return false;
|
|
1314
|
+
if (cwe === 'CWE-22' && _hasPathGuard(ctx)) return false;
|
|
1315
|
+
if (isXss && _hasXssOutputEncoding(ctx)) return false;
|
|
1316
|
+
return true;
|
|
1317
|
+
});
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1190
1320
|
function _detectSafeSinkShape(vuln, args, ctx){
|
|
1191
1321
|
// 1.1: Reflected XSS / SSRF / Path Traversal / Open Redirect / Code Injection
|
|
1192
1322
|
// sinks with empty or whitespace-only args cannot reflect user input — skip
|
|
@@ -1221,6 +1351,8 @@ function _detectSafeSinkShape(vuln, args, ctx){
|
|
|
1221
1351
|
if (_isSafeExecFileCall(args)) return 'execFile-list';
|
|
1222
1352
|
}
|
|
1223
1353
|
if(/SSRF/.test(vuln) && _isStaticUrlFirstArg(args)) return 'static-url';
|
|
1354
|
+
if(/SSRF/.test(vuln) && _hasSsrfHostGuard(ctx)) return 'ssrf-host-guard';
|
|
1355
|
+
if(/Path Traversal/.test(vuln) && _hasPathGuard(ctx)) return 'path-contained';
|
|
1224
1356
|
if(/Open Redirect/.test(vuln)) {
|
|
1225
1357
|
// Static redirect targets (env var or string literal) are not user-controlled
|
|
1226
1358
|
if (/^\s*process\.env\b/.test(args)) return 'static-redirect-target';
|
|
@@ -1959,6 +2091,17 @@ function scanStructuralVulns(fp, raw) {
|
|
|
1959
2091
|
}
|
|
1960
2092
|
effectiveSeverity = s;
|
|
1961
2093
|
}
|
|
2094
|
+
// Guard recognition (precision, #1): a structural SSRF/path finding on
|
|
2095
|
+
// hardened code — an SSRF host allow/deny check, or a path
|
|
2096
|
+
// basename/containment guard near the sink — is a false positive.
|
|
2097
|
+
if (/SSRF/.test(pat.vuln) && _hasSsrfHostGuard({ lines, line })) {
|
|
2098
|
+
_suppressionLog.push({vuln:pat.vuln, file:fp, line, snippet, reason:'ssrf-host-guard'});
|
|
2099
|
+
continue;
|
|
2100
|
+
}
|
|
2101
|
+
if (/Path Traversal/.test(pat.vuln) && _hasPathGuard({ lines, line })) {
|
|
2102
|
+
_suppressionLog.push({vuln:pat.vuln, file:fp, line, snippet, reason:'path-contained'});
|
|
2103
|
+
continue;
|
|
2104
|
+
}
|
|
1962
2105
|
const id = `struct:${fp}:${line}:${pat.vuln.replace(/\s/g, '_')}`;
|
|
1963
2106
|
if (!findings.find(f => f.id === id)) {
|
|
1964
2107
|
findings.push({
|
|
@@ -5512,8 +5655,17 @@ function dedupeFindingsWithEvidence(findings){
|
|
|
5512
5655
|
const key=`${file}:${sinkLine}:${fam}`;
|
|
5513
5656
|
if(!buckets.has(key)){buckets.set(key,f);continue;}
|
|
5514
5657
|
const kept=buckets.get(key);
|
|
5515
|
-
//
|
|
5516
|
-
|
|
5658
|
+
// Winner selection: an interprocedural flow finding (carries source→sink
|
|
5659
|
+
// attribution) is the better carrier than a flat structural/regex match at
|
|
5660
|
+
// the same sink — keep its chain/source-line attribution. When neither or
|
|
5661
|
+
// both carry flow, fall back to severity. The winner keeps its own severity
|
|
5662
|
+
// (we must not resurrect a rating that ownership/reachability analysis
|
|
5663
|
+
// deliberately downgraded on one of the two findings).
|
|
5664
|
+
const fHasFlow = !!(f.source && f.sink);
|
|
5665
|
+
const kHasFlow = !!(kept.source && kept.sink);
|
|
5666
|
+
const keepNew = (fHasFlow !== kHasFlow)
|
|
5667
|
+
? fHasFlow
|
|
5668
|
+
: (SEV_RANK[f.severity]??9) < (SEV_RANK[kept.severity]??9);
|
|
5517
5669
|
const winner = keepNew ? f : kept;
|
|
5518
5670
|
const loser = keepNew ? kept : f;
|
|
5519
5671
|
if(!winner.evidence)winner.evidence=[winner.parser||"UNKNOWN"];
|
|
@@ -6548,6 +6700,42 @@ function _parseMavenDependencyTree(text, filePath){
|
|
|
6548
6700
|
return out;
|
|
6549
6701
|
}
|
|
6550
6702
|
|
|
6703
|
+
// R10 (PRD §5): Gradle transitive graph. `gradle dependencies` (or
|
|
6704
|
+
// `gradle :app:dependencies`) prints the FULL resolved tree; build.gradle alone
|
|
6705
|
+
// only declares direct deps. Canonical committed path: `gradle-dependencies.txt`.
|
|
6706
|
+
// Format (tree-drawing prefixes we strip), version may be conflict-resolved:
|
|
6707
|
+
// +--- group:artifact:1.0
|
|
6708
|
+
// | \--- group:artifact:1.0 -> 1.2 (resolved to 1.2)
|
|
6709
|
+
// Config headers ("compileClasspath - …"), project deps, and (*)/(c) markers skipped.
|
|
6710
|
+
function _parseGradleDependencies(text, filePath) {
|
|
6711
|
+
const out = [];
|
|
6712
|
+
const seen = new Set();
|
|
6713
|
+
for (const rawLine of String(text).split('\n')) {
|
|
6714
|
+
if (!rawLine.trim()) continue;
|
|
6715
|
+
const indent = rawLine.length - rawLine.replace(/^[\s|+\\-]+/, '').length;
|
|
6716
|
+
const stripped = rawLine.replace(/^[\s|+\\-]+/, '').trim();
|
|
6717
|
+
if (!stripped || stripped.startsWith('project ') || /\bproject\s+[':]/.test(stripped)) continue;
|
|
6718
|
+
// group:artifact:version, with optional `-> resolved` and trailing (*)/(c)/(n).
|
|
6719
|
+
const m = stripped.match(/^([A-Za-z0-9._-]+):([A-Za-z0-9._-]+):([^\s(]+?)(?:\s*->\s*([^\s(]+))?\s*(?:\([*cn]\))?\s*$/);
|
|
6720
|
+
if (!m) continue;
|
|
6721
|
+
const group = m[1], artifact = m[2];
|
|
6722
|
+
let ver = (m[4] || m[3] || '').replace(/[{}]/g, '');
|
|
6723
|
+
ver = ver.replace(/^(?:strictly|require|prefer)\s+/, '');
|
|
6724
|
+
if (!group || !artifact || !ver) continue;
|
|
6725
|
+
const key = `${group}:${artifact}:${ver}`;
|
|
6726
|
+
if (seen.has(key)) continue;
|
|
6727
|
+
seen.add(key);
|
|
6728
|
+
out.push({
|
|
6729
|
+
name: `${group}:${artifact}`, version: ver, group, scope: 'required',
|
|
6730
|
+
purl: _makePurl('maven', artifact, ver, group), ecosystem: 'maven', filePath,
|
|
6731
|
+
isUnpinned: false,
|
|
6732
|
+
isTransitive: indent > 6,
|
|
6733
|
+
pomSource: 'gradle-dependencies',
|
|
6734
|
+
});
|
|
6735
|
+
}
|
|
6736
|
+
return out;
|
|
6737
|
+
}
|
|
6738
|
+
|
|
6551
6739
|
function _parseBuildGradle(text,filePath){
|
|
6552
6740
|
const out=[];
|
|
6553
6741
|
const pat=/(?:implementation|api|compile|runtimeOnly|testImplementation|testCompile|compileOnly|classpath)\s*(?:\(|['"])\s*['"]?([a-zA-Z0-9._\-]+):([a-zA-Z0-9._\-]+):([^'")\s,]+)/g;
|
|
@@ -6826,6 +7014,8 @@ function parseManifests(allFileContents){
|
|
|
6826
7014
|
// `target/dependency-tree.txt` (per `mvn dependency:tree -DoutputFile=...`)
|
|
6827
7015
|
// but users sometimes commit it as `dependency-tree.txt` at repo root.
|
|
6828
7016
|
'dependency-tree.txt':_parseMavenDependencyTree,
|
|
7017
|
+
// R10: Gradle resolved transitive graph — `gradle dependencies > gradle-dependencies.txt`.
|
|
7018
|
+
'gradle-dependencies.txt':_parseGradleDependencies,
|
|
6829
7019
|
};
|
|
6830
7020
|
const out=[],seen=new Set();
|
|
6831
7021
|
for(const[fp,content]of Object.entries(allFileContents)){
|
|
@@ -7202,13 +7392,14 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null},
|
|
|
7202
7392
|
catch { _GLOBAL_JAVA_TAINTED_METHODS = new Set(); }
|
|
7203
7393
|
const _perFileTimeoutMs = parseInt(process.env.AGENTIC_SECURITY_PER_FILE_TIMEOUT_MS || '10000', 10);
|
|
7204
7394
|
const _fileTimings = [];
|
|
7205
|
-
let _filesSkipped = 0, _filesTimedOut = 0;
|
|
7206
|
-
const files=Object.keys(fileContents).filter(f=>shouldScan(f) && !_isPathIgnored(f));const fc={},pfr={};const aR=[],aF=[],aSrc=[],aSink=[],aSan=[],aLogic=[],aSupply=[],aSecrets=[],aCiphersRest=[],aCiphersTransit=[];let i=0;for(const p of files){i++;const _ft0=Date.now();setProgress({current:i,total:files.length,file:p.split("/").pop(),phase:"Scanning"});try{const c=fileContents[p];if(!c||c.length>500000){_filesSkipped++;continue;}const _avgLine=c.length/Math.max(c.split('\n').length,1);if(_avgLine>400&&c.length>10000)continue;fc[p]=c;aR.push(...scanRoutes(p,c));const ta=performAnalysis(p,c);pfr[p]=ta;aF.push(...ta.findings);aSrc.push(...ta.sources);aSink.push(...ta.sinks);aSan.push(...ta.sanitizers);aLogic.push(...scanLogicVulns(p,c));aSecrets.push(...scanCredentials(p,c));aF.push(...scanStructuralVulns(p,c));aF.push(...scanExtraStructural(p,c));aF.push(...scanAliasedSinks(p,c));aF.push(...scanJavaSAST(p,c));aF.push(...scanJavaBenchExtras(p,c));aLogic.push(...scanMiddlewareOrdering(p,c));aLogic.push(...scanReDoS(p,c));aLogic.push(...scanTodosNearSecurity(p,c));aSecrets.push(...scanEntropySecrets(p,c));const cp=scanCiphers(p,c);aCiphersRest.push(...cp.atRest);aCiphersTransit.push(...cp.inTransit);if(/\.(graphql|gql)$/i.test(p))aF.push(...scanGraphQL(p,c));aF.push(...scanIaC(p,c));
|
|
7395
|
+
let _filesSkipped = 0, _filesTimedOut = 0, _filesDenseSkipped = 0;
|
|
7396
|
+
const files=Object.keys(fileContents).filter(f=>shouldScan(f) && !_isPathIgnored(f));const fc={},pfr={};const aR=[],aF=[],aSrc=[],aSink=[],aSan=[],aLogic=[],aSupply=[],aSecrets=[],aCiphersRest=[],aCiphersTransit=[];let i=0;for(const p of files){i++;const _ft0=Date.now();setProgress({current:i,total:files.length,file:p.split("/").pop(),phase:"Scanning"});try{const c=fileContents[p];if(!c||c.length>500000){_filesSkipped++;continue;}const _avgLine=c.length/Math.max(c.split('\n').length,1);if(_avgLine>400&&c.length>10000){_filesDenseSkipped++;continue;}fc[p]=c;aR.push(...scanRoutes(p,c));const ta=performAnalysis(p,c);pfr[p]=ta;aF.push(...ta.findings);aSrc.push(...ta.sources);aSink.push(...ta.sinks);aSan.push(...ta.sanitizers);aLogic.push(...scanLogicVulns(p,c));aSecrets.push(...scanCredentials(p,c));aF.push(...scanStructuralVulns(p,c));aF.push(...scanExtraStructural(p,c));aF.push(...scanAliasedSinks(p,c));aF.push(...scanJavaSAST(p,c));aF.push(...scanJavaBenchExtras(p,c));aLogic.push(...scanMiddlewareOrdering(p,c));aLogic.push(...scanReDoS(p,c));if(/\.(?:java|cs|kt|py|php|phtml)$/i.test(p)){try{aLogic.push(...scanRegexReDoS(p,c));}catch(_){}}aLogic.push(...scanTodosNearSecurity(p,c));aSecrets.push(...scanEntropySecrets(p,c));const cp=scanCiphers(p,c);aCiphersRest.push(...cp.atRest);aCiphersTransit.push(...cp.inTransit);if(/\.(graphql|gql)$/i.test(p))aF.push(...scanGraphQL(p,c));aF.push(...scanIaC(p,c));aF.push(...scanTerraform(p,c));
|
|
7207
7397
|
aF.push(...scanLLM(p,c));
|
|
7208
7398
|
aF.push(...scanLLMOwasp(p,c));
|
|
7209
7399
|
aLogic.push(...scanBusinessLogic(p,c));
|
|
7210
7400
|
aF.push(...scanPipeline(p,c));
|
|
7211
7401
|
aF.push(...scanContainer(p,c));
|
|
7402
|
+
aF.push(...scanInstallScripts(p,c));
|
|
7212
7403
|
aF.push(...scanMCP(p,c));
|
|
7213
7404
|
aF.push(...scanClaudeSettings(p,c));
|
|
7214
7405
|
aF.push(...scanClaudeMdPromptInjection(p,c));
|
|
@@ -7265,10 +7456,25 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null},
|
|
|
7265
7456
|
aF.push(...scanXPathInjection(p,c));
|
|
7266
7457
|
aF.push(...scanSSTI(p,c));
|
|
7267
7458
|
aF.push(...scanOpenRedirect(p,c));
|
|
7459
|
+
aF.push(...scanWrongContextSanitizer(p,c));
|
|
7460
|
+
aF.push(...scanSanitizerContextMismatch(p,c));
|
|
7461
|
+
aF.push(...scanFrontendHygiene(p,c));
|
|
7462
|
+
aF.push(...scanCsvInjection(p,c));
|
|
7463
|
+
aF.push(...scanStoredTaint(p,c));
|
|
7464
|
+
aF.push(...scanJavaStructural(p,c));
|
|
7465
|
+
aF.push(...scanCsharpStructural(p,c));
|
|
7466
|
+
aF.push(...scanJsFrameworkStructural(p,c));
|
|
7467
|
+
aF.push(...scanPythonStructural(p,c));
|
|
7468
|
+
aF.push(...scanGoStructural(p,c));
|
|
7469
|
+
aF.push(...scanSecretConcat(p,c));
|
|
7470
|
+
aF.push(...scanXssReflectedMultilang(p,c));
|
|
7471
|
+
aF.push(...scanCodeInjectionMultilang(p,c));
|
|
7268
7472
|
aF.push(...scanResponseSplitting(p,c));
|
|
7269
7473
|
aF.push(...scanStoredPromptInjection(p,c));
|
|
7270
7474
|
aF.push(...scanRAGPoisoning(p,c));
|
|
7271
7475
|
aF.push(...scanAgentToolEscalation(p,c));
|
|
7476
|
+
aF.push(...scanAgentUntrustedFlow(p,c));
|
|
7477
|
+
aF.push(...scanEventEntrypoints(p,c));
|
|
7272
7478
|
aF.push(...scanDbTaint(p,c));
|
|
7273
7479
|
aF.push(...scanSSRFCloudMetadata(p,c));
|
|
7274
7480
|
aF.push(...scanMutationXSS(p,c));
|
|
@@ -7430,6 +7636,12 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null},
|
|
|
7430
7636
|
setProgress({current:i,total:files.length,file:"Stored taint...",phase:"Linking"});const storedRegistry=buildStoredTaintRegistry(fc);const stf=crossStoredTaint(fc,storedRegistry);aF.push(...stf);
|
|
7431
7637
|
setProgress({current:i,total:files.length,file:"Session taint...",phase:"Linking"});const sess=crossSessionTaint(fc);aF.push(...sess);
|
|
7432
7638
|
setProgress({current:i,total:files.length,file:"Call graph...",phase:"Linking"});const callGraph=buildCallGraph(fc);
|
|
7639
|
+
// R19 (PRD §5): cross-route BOLA/BFLA over the aggregated route inventory.
|
|
7640
|
+
try{aF.push(...scanApiBrokenAuthz(aR));}catch(_){}
|
|
7641
|
+
// R22 (PRD §5): cross-service edges inferred from code (client call → matched route).
|
|
7642
|
+
try{aF.push(...scanCrossService(aR,fc));}catch(_){}
|
|
7643
|
+
// R21 (PRD §5): RBAC role-tier consistency over the route inventory.
|
|
7644
|
+
try{aF.push(...scanRbacConsistency(aR,fc));}catch(_){}
|
|
7433
7645
|
setProgress({current:i,total:files.length,file:"Reachability + guards...",phase:"Linking"});annotateReachability(aF,aR,callGraph,fc);aF.forEach(f=>detectGuardsForFinding(f,fc));
|
|
7434
7646
|
setProgress({current:i,total:files.length,file:"Inferring sanitizers...",phase:"Linking"});const learned=inferSanitizers(fc);applyLearnedSanitizers(aF,learned,fc);
|
|
7435
7647
|
setProgress({current:i,total:files.length,file:"Sanitizer effectiveness...",phase:"Linking"});applySanitizerEffectiveness(aF);
|
|
@@ -7438,6 +7650,9 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null},
|
|
|
7438
7650
|
setProgress({current:i,total:files.length,file:"OSV vulnerability database...",phase:"SCA"});
|
|
7439
7651
|
const allFileContents={...fc, ...depFileContents};
|
|
7440
7652
|
const components=parseManifests(allFileContents);
|
|
7653
|
+
// R8 (PRD §5): OS packages from an extracted container image's package DBs
|
|
7654
|
+
// (dpkg/apk) — baked-in deps the Dockerfile never names. Feed the OSV/SBOM pipeline.
|
|
7655
|
+
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(_){}
|
|
7441
7656
|
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(_){}
|
|
7442
7657
|
const reach=buildReachabilitySet(fc);
|
|
7443
7658
|
const reachabilitySet=reach.imported;
|
|
@@ -7452,6 +7667,14 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null},
|
|
|
7452
7667
|
// 0.10.0: enrich SCA findings with CISA KEV (CISA KEV catalog)
|
|
7453
7668
|
try{supplyChain=await _enrichWithKEV(supplyChain);}catch(_){}
|
|
7454
7669
|
try{markUsedVulnFunctions(supplyChain,fc);}catch(_){}
|
|
7670
|
+
// R7 (PRD §5): import-aware call-site augmentation for npm/pypi deps. The regex
|
|
7671
|
+
// pass above misses aliased / namespace imports (import {merge as deepMerge};
|
|
7672
|
+
// deepMerge()) and over-matches bare names in files that don't import the
|
|
7673
|
+
// package. This parses each JS/TS + Python file's imports, resolves vuln-fn
|
|
7674
|
+
// calls THROUGH the alias map, and gates them to files that actually import the
|
|
7675
|
+
// package — then ADDS the recovered sites so the route-reachability classifier
|
|
7676
|
+
// below (which decides reachable/unreachable/unknown) sees them. Additive only.
|
|
7677
|
+
try{const{augmentReachabilityViaImports}=await import('./sca/import-reachability.js');augmentReachabilityViaImports(supplyChain,fc,VULN_FUNCTION_HINTS);}catch(_){}
|
|
7455
7678
|
// Python AST-based function validation (deep mode only)
|
|
7456
7679
|
if(process.env.AGENTIC_SECURITY_DEEP==='1'){try{const{validateOsvFunctionsExist}=await import('./sca/py-package-functions.js');for(const sc of supplyChain){if(sc.type!=='vulnerable_dep'||sc.ecosystem!=='pypi')continue;if(!sc.osvVulnFunctions||!sc.osvVulnFunctions.length)continue;const{validated,missing}=validateOsvFunctionsExist(sc.name,sc.osvVulnFunctions,scanRoot);if(validated.length)sc._pyAstValidated=validated;if(missing.length)sc._pyAstMissing=missing;}}catch(_){}}
|
|
7457
7680
|
// LLM-assisted function extraction for CVEs without hints (opt-in: AGENTIC_SECURITY_LLM_SCA=1)
|
|
@@ -7552,7 +7775,14 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null},
|
|
|
7552
7775
|
const annotatedComponents=components.map(c=>{const key=`${c.ecosystem}:${c.name}:${c.version}`;const vulns=vulnsByKey[key]||[];const riKey=c.ecosystem==='maven'&&c.group?`maven:${c.group}/${c.name}`:`${c.ecosystem}:${c.name}`;const ri=registryInfo.get(riKey)||{};const latestVersion=ri.latestVersion||'';const vd=(ri.versions||{})[c.version]||{};const isDeprecated=typeof vd.deprecated==='string'&&vd.deprecated.length>0;const deprecationMessage=isDeprecated?vd.deprecated:'';const isOutdated=!isDeprecated&&typeof vd.outdated==='string'&&vd.outdated.length>0;const outdatedMessage=isOutdated?vd.outdated:'';const license=ri.license||vd.license||'';return{...c,vulns,hasVulns:vulns.length>0,hasAttackPath:attackResult.flagged.has(key),attackPaths:attackResult.pathsByKey.get(key)||[],latestVersion,isDeprecated,deprecationMessage,isOutdated,outdatedMessage,license};});
|
|
7553
7776
|
try{aF.push(...scanDbTaintCrossFile(fc));}catch(_){}
|
|
7554
7777
|
try{aF.push(...scanStoredPromptInjectionCrossFile(fc));}catch(_){}
|
|
7778
|
+
// Roadmap #8 — tree-sitter sinks for long-tail languages (opt-in,
|
|
7779
|
+
// AGENTIC_SECURITY_TREE_SITTER=1; degrades to no-op without the optional dep).
|
|
7780
|
+
if(process.env.AGENTIC_SECURITY_TREE_SITTER==='1'){try{aF.push(...await scanTreeSitterSinks(fc));}catch(_){}}
|
|
7555
7781
|
let finalFindings;try{finalFindings=dedupeFindingsWithEvidence(aF);}catch(_){finalFindings=dd(aF,f=>f.id);}
|
|
7782
|
+
// #1 — centralized SSRF/path guard recognition: drop CWE-918/CWE-22 findings
|
|
7783
|
+
// on code hardened by a host allow/deny check or a path containment guard,
|
|
7784
|
+
// regardless of which detector emitted them. Opt out: AGENTIC_SECURITY_NO_GUARD_RECOGNITION=1.
|
|
7785
|
+
if(process.env.AGENTIC_SECURITY_NO_GUARD_RECOGNITION!=='1'){try{finalFindings=dropGuardedFindings(finalFindings,fc);}catch(_){}}
|
|
7556
7786
|
// 0.34.6: filter out Java FPs where a sanitizer pattern (argv-form ProcessBuilder,
|
|
7557
7787
|
// parameterized prepareStatement, constant-folded dead-branch) is present.
|
|
7558
7788
|
// applyJavaBenchSuppressions is a no-op on non-.java files.
|
|
@@ -7670,6 +7900,14 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null},
|
|
|
7670
7900
|
_runAnnotator("annotateCalibratedConfidence", () => { annotateCalibratedConfidence(finalFindings, { scanRoot }); });
|
|
7671
7901
|
const _projectCtx = (() => { try { return detectProjectContext(fc, aR); } catch { return {}; } })();
|
|
7672
7902
|
_runAnnotator("annotateExploitability", () => { annotateExploitability(finalFindings, _projectCtx); });
|
|
7903
|
+
// Roadmap #6 — proof-gate precision pass. Runs AFTER confidence +
|
|
7904
|
+
// exploitability so it can demote their tiers, and BEFORE mitigation /
|
|
7905
|
+
// composite-risk so the demotion flows into the canonical ranking. Default
|
|
7906
|
+
// on; opt out with AGENTIC_SECURITY_NO_PROOF_GATE=1. Demotes proven-clean /
|
|
7907
|
+
// proven-infeasible flows (confidence + tiers only, never severity).
|
|
7908
|
+
if (process.env.AGENTIC_SECURITY_NO_PROOF_GATE !== '1') {
|
|
7909
|
+
_runAnnotator("annotateProofGate", () => { annotateProofGate(finalFindings); });
|
|
7910
|
+
}
|
|
7673
7911
|
// v3 next-gen: production-aware context ingest (Pillar 9). Must run BEFORE
|
|
7674
7912
|
// the mitigation composite, persona prioritization, and final why-fired
|
|
7675
7913
|
// record so those see the demotion signals.
|
|
@@ -7880,10 +8118,14 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null},
|
|
|
7880
8118
|
try { await annotateNarration(finalFindings); }
|
|
7881
8119
|
catch (e) { _annotatorErrors.push({ phase: 'annotateNarration', err: String((e && e.message) || e) }); }
|
|
7882
8120
|
// Phase 3 (Sentinel-parity FR-L1, FR-L2) — IR + interprocedural taint.
|
|
7883
|
-
//
|
|
7884
|
-
//
|
|
7885
|
-
//
|
|
7886
|
-
//
|
|
8121
|
+
// R1 (PRD §5): the CLI entry (bin/agentic-security.js#cmdScan) now sets
|
|
8122
|
+
// AGENTIC_SECURITY_DEEP=1 by default for local/interactive scans, so deep mode
|
|
8123
|
+
// runs on the default `/scan --all` path. This gate is the enforcement +
|
|
8124
|
+
// CI-safety point: it honors an explicit opt-out (DEEP=0) and keeps deep off in
|
|
8125
|
+
// CI unless DEEP_IN_CI=1. In-process callers (tests, the cve-replay corpus) invoke
|
|
8126
|
+
// runScan()/runFullScan() directly without the CLI default, so they stay deep-off
|
|
8127
|
+
// and remain deterministic regression gates. Findings ride through the standard
|
|
8128
|
+
// dedup/cluster/confidence pipeline below and the LLM-validator stage that follows.
|
|
7887
8129
|
//
|
|
7888
8130
|
// SAFETY: Deep mode is gated for CI safety:
|
|
7889
8131
|
// - Global timeout via AGENTIC_SECURITY_DEEP_TIMEOUT_MS (default 300_000 = 5 min)
|
|
@@ -8025,13 +8267,29 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null},
|
|
|
8025
8267
|
// apply accept-risk / SLA / major-version-freeze rules. supplyChain
|
|
8026
8268
|
// findings get suppressed/tagged in place.
|
|
8027
8269
|
try { const sp = loadScaPolicy(scanRoot); if (sp && !sp._error) applyScaPolicy(supplyChain, sp); } catch (_) {}
|
|
8270
|
+
// R12 (PRD §5): deterministic decision-first verdict per dep, derived from the
|
|
8271
|
+
// signals enriched above (composite risk × KEV × EPSS × reachability × policy).
|
|
8272
|
+
// testsDetected is read from the UNFILTERED fileContents because shouldScan()
|
|
8273
|
+
// strips test files out of `fc`.
|
|
8274
|
+
try {
|
|
8275
|
+
const _testsDetected = Object.keys(fileContents).some(f =>
|
|
8276
|
+
/(?:^|\/)(?:tests?|spec|__tests__)\//i.test(f) ||
|
|
8277
|
+
/\.(?:test|spec)\.[cm]?[jt]sx?$/i.test(f) ||
|
|
8278
|
+
/(?:^|\/)test_[^/]*\.py$/i.test(f) || /_test\.(?:py|go)$/i.test(f));
|
|
8279
|
+
annotateScaVerdicts(supplyChain, { testsDetected: _testsDetected });
|
|
8280
|
+
} catch (_) {}
|
|
8028
8281
|
// 0.9.0 Feat-15: dep confusion
|
|
8029
8282
|
try{const dc=detectDepConfusion(annotatedComponents,scanRoot);aF.push(...dc);}catch(_){}
|
|
8030
8283
|
// Deployment-platform security checklist
|
|
8031
8284
|
try{const dpf=scanDeployPlatform(scanRoot);aLogic.push(...dpf);}catch(_){}
|
|
8032
8285
|
// Stack-specific security playbook
|
|
8033
8286
|
try{const sp=runStackPlaybook(scanRoot);if(sp&&sp.findings)aLogic.push(...sp.findings);}catch(_){}
|
|
8034
|
-
|
|
8287
|
+
// R17 (PRD §5): annotate per-finding corroboration ("one issue, many signals")
|
|
8288
|
+
// after evidence + validator verdicts are populated, then use it as a final
|
|
8289
|
+
// tiebreaker so multi-signal findings rank above single-signal ones at equal
|
|
8290
|
+
// severity. Does not alter calibrated confidence.
|
|
8291
|
+
try { annotateFindingProvenance(finalFindings); } catch (_) {}
|
|
8292
|
+
finalFindings.sort((a,b)=>(b.toxicityScore||0)-(a.toxicityScore||0)||(b.triageScore||0)-(a.triageScore||0)||({critical:0,high:1,medium:2,low:3}[a.severity]??4)-({critical:0,high:1,medium:2,low:3}[b.severity]??4)||((b.corroborationCount||1)-(a.corroborationCount||1)));
|
|
8035
8293
|
// Auto-PoC filter: tag whether a concrete payload+test can be derived. When
|
|
8036
8294
|
// AGENTIC_SECURITY_POC=1, demote ≥medium findings that fail this check.
|
|
8037
8295
|
// Used as a precision lever for users who want to ship clean reports —
|
|
@@ -8150,6 +8408,25 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null},
|
|
|
8150
8408
|
finalFindings = finalFindings.filter(_shouldKeep);
|
|
8151
8409
|
_filterInPlace(aLogic);
|
|
8152
8410
|
_filterInPlace(aSecrets);
|
|
8411
|
+
// Drop a generic High-Entropy Credential Candidate when a named/structural
|
|
8412
|
+
// secret detector (e.g. secret-concat, hardcoded-secret) already flagged the
|
|
8413
|
+
// SAME file:line — otherwise one split/hardcoded key is reported twice (the
|
|
8414
|
+
// named finding lives in `findings`, the entropy candidate in `secrets`).
|
|
8415
|
+
{
|
|
8416
|
+
const _namedSecretLines = new Set();
|
|
8417
|
+
for (const f of finalFindings) {
|
|
8418
|
+
const ln = f.line ?? f.source?.line;
|
|
8419
|
+
if (typeof ln === 'number' && (f.cwe === 'CWE-798' || /hardcoded|credential|secret/i.test(f.vuln || ''))) {
|
|
8420
|
+
_namedSecretLines.add(`${f.file}:${ln}`);
|
|
8421
|
+
}
|
|
8422
|
+
}
|
|
8423
|
+
const _kept = aSecrets.filter((s) => {
|
|
8424
|
+
if (!/High-Entropy|entropy/i.test(s.vuln || '')) return true;
|
|
8425
|
+
return !_namedSecretLines.has(`${s.file}:${s.line}`);
|
|
8426
|
+
});
|
|
8427
|
+
aSecrets.length = 0;
|
|
8428
|
+
aSecrets.push(..._kept);
|
|
8429
|
+
}
|
|
8153
8430
|
_filterInPlace(supplyChain);
|
|
8154
8431
|
classifyOrphans(aSrc,aSink,finalFindings,fc);
|
|
8155
8432
|
// v3 next-gen: capture scan-level reports (counterfactual, threat model,
|
|
@@ -8255,7 +8532,12 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null},
|
|
|
8255
8532
|
}
|
|
8256
8533
|
}
|
|
8257
8534
|
|
|
8258
|
-
|
|
8535
|
+
// Coverage-honesty report (#5 + #6): per-language analysis tier (IR-taint vs
|
|
8536
|
+
// pattern-only), dense/large/timeout skips, and unmodeled-sink candidates.
|
|
8537
|
+
let _analysisTier = null, _unmodeledSinks = null;
|
|
8538
|
+
try { _analysisTier = computeAnalysisTiers(Object.keys(fc)); } catch {}
|
|
8539
|
+
try { _unmodeledSinks = countUnmodeledSinkCandidates(fc, finalFindings); } catch {}
|
|
8540
|
+
const _scanMeta={filesScanned:files.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}};
|
|
8259
8541
|
return{routes:dd(aR,r=>`${r.method}:${r.path}:${r.file}:${r.line}`),findings:finalFindings,sources:aSrc,sinks:aSink,sanitizers:aSan,filesScanned:files.length,crossFileCount:cf.length,logicVulns:aLogic,supplyChain,components:annotatedComponents,secrets:aSecrets,ciphers:{atRest:aCiphersRest,inTransit:aCiphersTransit},pfr,fc,suppressions:_getSuppressions(),_v3,_scanMeta,_engineErrors:{cppDataflowParseErrors:_cppDataflowParseErrors.value},annotatorErrors:_annotatorErrors,threatModel:_threatModel,sbomDiff:_sbomDiff,complianceReport:_complianceReport,exploitBundles:_exploitBundles,pqcPlan:_pqcPlan,licenseGraph:_licenseGraph,attributions:_attributions,attackTaxonomy:_taxonomySummary};}
|
|
8260
8542
|
|
|
8261
8543
|
// Post-aggregation classification: every source becomes "unsafe"|"safe"; every sink becomes "confirmed"|"safe".
|
package/src/ir/CLAUDE.md
CHANGED
|
@@ -11,6 +11,7 @@ consumed by `scanner/src/dataflow/` for taint analysis.
|
|
|
11
11
|
| Python | `parser-py-cst.js` | Python 3.8+ stdlib `ast` via subprocess (default when available) |
|
|
12
12
|
| Python | `parser-py.js` | Hand-rolled regex parser (fallback when python3 missing) |
|
|
13
13
|
| Java | `parser-java.js` | `java-parser` npm package (async) |
|
|
14
|
+
| Long-tail (rust/solidity/cpp/c/go/swift/dart) | `tree-sitter-loader.js` | **Optional** `web-tree-sitter` + `tree-sitter-wasms` (ABI-pinned 0.20.8 ↔ 0.1.13), lazy + degrades when absent. Powers `sast/tree-sitter-sinks.js` (opt-in via `AGENTIC_SECURITY_TREE_SITTER=1`). Marked `--external` in the build so the committed bundle never embeds WASM. |
|
|
14
15
|
|
|
15
16
|
## Python parser — dual-path with auto fallback
|
|
16
17
|
|
package/src/ir/callgraph.js
CHANGED
|
@@ -67,8 +67,19 @@ export function buildCallGraph(perFileIR, fileContents) {
|
|
|
67
67
|
// the call graph for the callee's qid at the assign-from-call site.
|
|
68
68
|
// Same precedence as the edge resolution above (same-file ident wins,
|
|
69
69
|
// ClassName.method falls back).
|
|
70
|
-
function resolve(name) {
|
|
70
|
+
function resolve(name, callerFile) {
|
|
71
71
|
if (!name || typeof name !== 'string') return null;
|
|
72
|
+
// Roadmap #3: same-file preference. A bare name (`handler`, `save`,
|
|
73
|
+
// `query`) defined in several files would otherwise resolve to whichever
|
|
74
|
+
// file Map is iterated first, mis-targeting interprocedural taint to a
|
|
75
|
+
// same-named function in an unrelated file. When the caller's own file
|
|
76
|
+
// defines the name, that is overwhelmingly the intended callee — prefer
|
|
77
|
+
// it. Backward-compatible: with no callerFile (or no local match) the
|
|
78
|
+
// original resolution order is unchanged, so no edge is ever dropped.
|
|
79
|
+
if (callerFile) {
|
|
80
|
+
const local = byNameInFile.get(callerFile);
|
|
81
|
+
if (local && local.has(name)) return local.get(name);
|
|
82
|
+
}
|
|
72
83
|
for (const m of byNameInFile.values()) {
|
|
73
84
|
if (m.has(name)) return m.get(name);
|
|
74
85
|
}
|
package/src/ir/parser-go.js
CHANGED
|
@@ -79,6 +79,14 @@ function _lowerExpr(text) {
|
|
|
79
79
|
const parts = _splitTopLevelCommas(inner).map(_lowerExpr);
|
|
80
80
|
return { kind: 'tpl', parts };
|
|
81
81
|
}
|
|
82
|
+
// R3 (PRD §5): string concat with `+` MUST be checked before the bare-literal
|
|
83
|
+
// rule below — otherwise `"SELECT " + q` (starts with `"`) is captured whole as
|
|
84
|
+
// a single literal and taint cannot flow through the concatenation. Only fires
|
|
85
|
+
// when there's a top-level `+` (a pure literal still falls through to literal).
|
|
86
|
+
if (/["'`]/.test(s) && s.includes('+')) {
|
|
87
|
+
const plusParts = _splitTopLevelPlus(s);
|
|
88
|
+
if (plusParts.length > 1) return { kind: 'tpl', parts: plusParts.map(_lowerExpr) };
|
|
89
|
+
}
|
|
82
90
|
if (/^"/.test(s) || /^`/.test(s)) return { kind: 'literal', value: s };
|
|
83
91
|
if (/^\d/.test(s)) return { kind: 'literal', value: s };
|
|
84
92
|
if (/^(true|false|nil)\b/.test(s)) return { kind: 'literal', value: s };
|