@clear-capabilities/agentic-security-scanner 0.152.0 → 0.153.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 +54 -0
- package/dist/1336.index.js +16010 -0
- package/dist/agentic-security.mjs +3 -3
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +4 -3
- package/src/dataflow/backward.js +104 -7
- package/src/dataflow/cross-service-taint.js +154 -11
- package/src/dataflow/engine.js +23 -1
- package/src/dataflow/exploit-prover.js +111 -23
- package/src/dataflow/index.js +12 -2
- package/src/dataflow/string-domain.js +36 -0
- package/src/engine.js +10 -6
- package/src/ir/class-hierarchy.js +160 -1
- package/src/report/index.js +12 -0
- package/src/dataflow/smt-feasibility.js +0 -189
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
//
|
|
23
23
|
// We do NOT attempt to prove ARBITRARY satisfiability — the v2 use case.
|
|
24
24
|
|
|
25
|
-
import { provablyMatches } from './string-domain.js';
|
|
25
|
+
import { provablyMatches, safeSanitizerOutputRegex } from './string-domain.js';
|
|
26
26
|
|
|
27
27
|
// CWE → canonical exploit input. Conservative payloads safe to display.
|
|
28
28
|
const EXPLOIT_INPUTS = {
|
|
@@ -62,6 +62,31 @@ const FAMILY_METACHARS = {
|
|
|
62
62
|
|
|
63
63
|
// Try to load z3-solver. Returns null if not installed — that's the common
|
|
64
64
|
// case (we don't bundle the WASM blob).
|
|
65
|
+
//
|
|
66
|
+
// SCOPE NOTE (next-gen taint capability #5 rebuild): loading z3-solver here
|
|
67
|
+
// is real — it is a genuine `optionalDependencies` entry in package.json,
|
|
68
|
+
// and `_maybeLoadZ3` performs a real dynamic `import()`, verified against
|
|
69
|
+
// an actual installed copy. What it does NOT yet do is discharge any query
|
|
70
|
+
// TO Z3: `proveExploits` below still routes every feasibility verdict
|
|
71
|
+
// through `smtLiteInfeasibilityCheck`'s JS-native regex check regardless of
|
|
72
|
+
// whether z3 loaded; a present z3 module only labels `_exploitInputSource`
|
|
73
|
+
// on the FEASIBLE branch. Making Z3 do real work here would mean
|
|
74
|
+
// translating each sanitizer's `outputRegexSource` (a JS RegExp source —
|
|
75
|
+
// anchors, character classes, quantifiers, alternation, optional groups;
|
|
76
|
+
// see `string-domain.js`'s `SANITIZER_OUTPUT_REGEX`) into Z3's own
|
|
77
|
+
// string/regex theory and asking it "does any string matching this pattern
|
|
78
|
+
// also contain this metacharacter?" That translator is a real, standalone
|
|
79
|
+
// undertaking, and a translation bug in it is uniquely dangerous for this
|
|
80
|
+
// specific mechanism: it would manufacture a false `feasible: false`
|
|
81
|
+
// (a false safety proof) rather than merely a missed detection. The
|
|
82
|
+
// existing JS-native check (`_backwardSliceExcludesMetachars`) is already
|
|
83
|
+
// SOUND for every regex currently in the hand-vetted catalog (all of them
|
|
84
|
+
// are anchored, closed-under-repetition character classes or fixed-format
|
|
85
|
+
// literals — verified by direct reading of `SANITIZER_OUTPUT_REGEX`), so
|
|
86
|
+
// there is no live soundness gap this would close today. Left as a
|
|
87
|
+
// deliberate, documented boundary rather than an oversight: build the
|
|
88
|
+
// translator as its own reviewed capability if/when the sanitizer catalog
|
|
89
|
+
// grows regex shapes the JS probe can no longer safely reason about.
|
|
65
90
|
let _z3 = null;
|
|
66
91
|
let _z3Loaded = false;
|
|
67
92
|
async function _maybeLoadZ3() {
|
|
@@ -90,20 +115,30 @@ export function smtLiteInfeasibilityCheck(finding) {
|
|
|
90
115
|
if (!metacharsNeeded || metacharsNeeded.length === 0) {
|
|
91
116
|
return { feasible: 'unknown', reason: 'no-metachar-model' };
|
|
92
117
|
}
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
//
|
|
118
|
+
// Next-gen taint capability #5 (SMT path-feasibility rebuild): prefer
|
|
119
|
+
// `finding.backwardSlice` (backward.js's REAL, ordered source-to-sink
|
|
120
|
+
// path, opt-in via AGENTIC_SECURITY_BACKWARD_SLICE=1) over the
|
|
121
|
+
// trace/chain fallback below. Confirmed live, during this rebuild, that
|
|
122
|
+
// `finding.trace`/`finding.chain` are NOT a sequential path at all — they
|
|
123
|
+
// are the list of SOURCES reaching the sink (see dataflow/engine.js's own
|
|
124
|
+
// `_sourcesReachingExpr`), so a `.callee` never actually names a
|
|
125
|
+
// sanitizer in real production data; this function's own pre-rebuild
|
|
126
|
+
// unit tests only ever exercised a HAND-FABRICATED `chain` shape
|
|
127
|
+
// (`[{callee: 'htmlspecialchars'}]`) that the real engine never produces.
|
|
128
|
+
const slicePath = _backwardSliceExcludesMetachars(finding.backwardSlice, metacharsNeeded);
|
|
129
|
+
if (slicePath) return slicePath;
|
|
130
|
+
// Fallback for callers that never ran the (opt-in) backward-slice pass —
|
|
131
|
+
// kept for backward compatibility, though per the finding above it can
|
|
132
|
+
// realistically only ever return 'unknown' against real finding.trace/
|
|
133
|
+
// chain data; a caller that wants a real feasibility answer needs
|
|
134
|
+
// AGENTIC_SECURITY_BACKWARD_SLICE=1 set.
|
|
97
135
|
const trace = Array.isArray(finding.trace) ? finding.trace : [];
|
|
98
136
|
const chain = Array.isArray(finding.chain) ? finding.chain : [];
|
|
99
137
|
const all = [...trace, ...chain];
|
|
100
138
|
for (const step of all) {
|
|
101
139
|
const callee = step.callee || step.label || '';
|
|
102
|
-
// Heuristic: an encodeURIComponent / parseInt / quote_plus on the path
|
|
103
|
-
// produces output that cannot contain the family's metacharacters.
|
|
104
140
|
const regex = _sanitizerRegexFor(callee);
|
|
105
141
|
if (!regex) continue;
|
|
106
|
-
// Test if ALL required metacharacters are excluded by this regex.
|
|
107
142
|
const excludesAll = metacharsNeeded.every(mc => !regex.test(mc.repeat(8)));
|
|
108
143
|
if (excludesAll) {
|
|
109
144
|
return { feasible: false, reason: `sanitizer-excludes-metacharacters:${callee}` };
|
|
@@ -112,22 +147,75 @@ export function smtLiteInfeasibilityCheck(finding) {
|
|
|
112
147
|
return { feasible: 'unknown', reason: 'no-sanitizer-on-path' };
|
|
113
148
|
}
|
|
114
149
|
|
|
150
|
+
// Parameterized-query API calls: a DIFFERENT kind of "safe" than a
|
|
151
|
+
// regex-constrained output — the value is never concatenated into the
|
|
152
|
+
// query TEXT at all, so no charset restriction on the value itself makes
|
|
153
|
+
// it safe regardless of content. Kept as its own small, explicit,
|
|
154
|
+
// unambiguous list (real database-driver method names, not generic
|
|
155
|
+
// English words) rather than folded into `safeSanitizerOutputRegex`,
|
|
156
|
+
// which models a genuinely different property (output charset).
|
|
157
|
+
const _PARAMETERIZED_CALL_NAMES = new Set(['setString', 'AddWithValue', 'bindParam', 'parameterize']);
|
|
158
|
+
|
|
159
|
+
// Walk `backwardSlice` (backward.js's source-first ordered path) from the
|
|
160
|
+
// SINK end backward, looking for the CLOSEST sanitize/parameterize step —
|
|
161
|
+
// stopping (refusing to conclude anything) the moment it crosses an OPAQUE
|
|
162
|
+
// 'call' step it cannot reason about, since such a call could have undone
|
|
163
|
+
// or bypassed whatever a sanitizer further upstream proved. Only a
|
|
164
|
+
// sanitizer/parameterization with NOTHING but pure 'assign' aliasing
|
|
165
|
+
// between it and the sink is trusted — this is the conservative,
|
|
166
|
+
// sound-by-construction direction: a missed proof (returning 'unknown')
|
|
167
|
+
// costs nothing but a demotion opportunity, while a WRONG proof would
|
|
168
|
+
// manufacture a false safety claim for a real vulnerability.
|
|
169
|
+
function _backwardSliceExcludesMetachars(backwardSlice, metacharsNeeded) {
|
|
170
|
+
if (!Array.isArray(backwardSlice) || !backwardSlice.length) return null;
|
|
171
|
+
// Reverse to walk sink-first (the array itself is stored source-first).
|
|
172
|
+
for (const step of [...backwardSlice].reverse()) {
|
|
173
|
+
if (step.kind === 'sink') continue;
|
|
174
|
+
if (step.kind === 'assign') continue; // pure alias — keep looking upstream
|
|
175
|
+
if (step.kind === 'sanitize') {
|
|
176
|
+
if (_PARAMETERIZED_CALL_NAMES.has(step.callee)) {
|
|
177
|
+
return { feasible: false, reason: `parameterized-call:${step.callee}` };
|
|
178
|
+
}
|
|
179
|
+
if (step.outputRegexSource) {
|
|
180
|
+
// `outputRegexSource` is never attacker-influenced despite flowing
|
|
181
|
+
// through a `backwardSlice` step object: `backward.js`'s own
|
|
182
|
+
// `_safeSanitizerOutputRegex` is its ONLY producer, and that
|
|
183
|
+
// function returns either `null` or one of `string-domain.js`'s
|
|
184
|
+
// SMALL, HARDCODED `SANITIZER_OUTPUT_REGEX` pattern literals
|
|
185
|
+
// (encodeURIComponent, parseInt, htmlspecialchars, …) — confirmed
|
|
186
|
+
// by direct code reading, never a value derived from scanned
|
|
187
|
+
// source text, a finding, or any other runtime input. This
|
|
188
|
+
// project's own self-scan correctly cannot see that far through
|
|
189
|
+
// the call chain and flags this defensively — a genuine false
|
|
190
|
+
// positive, not a suppressed real risk.
|
|
191
|
+
const regex = new RegExp(step.outputRegexSource, step.outputRegexFlags || ''); // agentic-security-ignore: CWE-1333
|
|
192
|
+
const excludesAll = metacharsNeeded.every((mc) => !regex.test(mc.repeat(8)));
|
|
193
|
+
if (excludesAll) {
|
|
194
|
+
return { feasible: false, reason: `sanitizer-excludes-metacharacters:${step.callee}` };
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
// A recognized-by-catalog-name sanitizer with no PROVEN output
|
|
198
|
+
// regex (e.g. a project-local function merely NAMED like a known
|
|
199
|
+
// sanitizer) cannot be trusted for a sound proof — refuse rather
|
|
200
|
+
// than guess, matching backward.js's own `outputRegexSource`
|
|
201
|
+
// header comment on exactly this hazard.
|
|
202
|
+
return { feasible: 'unknown', reason: `sanitizer-without-proven-output-shape:${step.callee || 'unknown'}` };
|
|
203
|
+
}
|
|
204
|
+
if (step.kind === 'call') {
|
|
205
|
+
// An opaque, unmodeled call between here and the sink — refuse. We
|
|
206
|
+
// cannot know whether it further transforms (or un-does a real
|
|
207
|
+
// sanitizer's effect on) the value.
|
|
208
|
+
return { feasible: 'unknown', reason: `opaque-call-on-path:${step.callee || 'unknown'}` };
|
|
209
|
+
}
|
|
210
|
+
if (step.kind === 'source') break; // reached the origin with no sanitizer found
|
|
211
|
+
}
|
|
212
|
+
return { feasible: 'unknown', reason: 'no-sanitizer-on-path' };
|
|
213
|
+
}
|
|
214
|
+
|
|
115
215
|
function _sanitizerRegexFor(callee) {
|
|
116
216
|
if (!callee) return null;
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
encodeURIComponent: /^[A-Za-z0-9\-_.!~*'()%]*$/,
|
|
120
|
-
parseInt: /^-?\d+$/,
|
|
121
|
-
parseFloat: /^-?\d+(?:\.\d+)?$/,
|
|
122
|
-
digest: /^[0-9a-f]+$/,
|
|
123
|
-
htmlspecialchars: /^[^<>&"']*$/,
|
|
124
|
-
escapeHtml: /^[^<>&"']*$/,
|
|
125
|
-
setString: /^.*$/, // parameterized → infeasible regardless of content
|
|
126
|
-
AddWithValue: /^.*$/,
|
|
127
|
-
bindParam: /^.*$/,
|
|
128
|
-
parameterize: /^.*$/,
|
|
129
|
-
};
|
|
130
|
-
return table[tail] || null;
|
|
217
|
+
if (_PARAMETERIZED_CALL_NAMES.has(String(callee).split('.').pop())) return /^.*$/;
|
|
218
|
+
return safeSanitizerOutputRegex(callee);
|
|
131
219
|
}
|
|
132
220
|
|
|
133
221
|
/**
|
|
@@ -180,7 +268,7 @@ export async function proveExploits(findings, opts = {}) {
|
|
|
180
268
|
|
|
181
269
|
export const _internal = {
|
|
182
270
|
EXPLOIT_INPUTS, FAMILY_METACHARS, _sanitizerRegexFor, smtLiteInfeasibilityCheck,
|
|
183
|
-
_maybeLoadZ3,
|
|
271
|
+
_maybeLoadZ3, _backwardSliceExcludesMetachars,
|
|
184
272
|
};
|
|
185
273
|
|
|
186
274
|
// Re-export provablyMatches so callers don't have to thread imports.
|
package/src/dataflow/index.js
CHANGED
|
@@ -98,7 +98,7 @@ export function runDeepAnalysis(perFileIR, callGraph, opts = {}) {
|
|
|
98
98
|
// every consumer degrades to today's behavior when it finds no useful type.
|
|
99
99
|
let classHierarchy = null;
|
|
100
100
|
try {
|
|
101
|
-
classHierarchy = buildClassHierarchy(perFileIR);
|
|
101
|
+
classHierarchy = buildClassHierarchy(perFileIR, callGraph);
|
|
102
102
|
// PRD W2.5 (SARD_80_F1_EXECUTION_PRD.md) — RTA-annotate so
|
|
103
103
|
// `engine.js`'s `_resolveMemberCalleeViaCHA` can resolve a
|
|
104
104
|
// parameter-typed (or otherwise non-`new`-tracked) receiver against
|
|
@@ -163,7 +163,17 @@ export function runDeepAnalysis(perFileIR, callGraph, opts = {}) {
|
|
|
163
163
|
});
|
|
164
164
|
}
|
|
165
165
|
// P1.4 — backward slice (opt-in via AGENTIC_SECURITY_BACKWARD_SLICE=1).
|
|
166
|
-
|
|
166
|
+
// Next-gen taint capability #5 (SMT path-feasibility rebuild): ALSO run
|
|
167
|
+
// this when AGENTIC_SECURITY_SYMEXEC=1 is set, even if the operator never
|
|
168
|
+
// separately set AGENTIC_SECURITY_BACKWARD_SLICE — `proveExploits`'s own
|
|
169
|
+
// infeasibility check now depends on `finding.backwardSlice` for a sound
|
|
170
|
+
// verdict (see exploit-prover.js's own header), and requiring an operator
|
|
171
|
+
// to know to independently set a SECOND, differently-named flag just to
|
|
172
|
+
// get a real answer out of the first is exactly the kind of silent,
|
|
173
|
+
// undiscoverable degradation this codebase's own verification discipline
|
|
174
|
+
// warns against — SYMEXEC's own soundness must not be a hidden function
|
|
175
|
+
// of an unrelated flag.
|
|
176
|
+
if (process.env.AGENTIC_SECURITY_BACKWARD_SLICE === '1' || process.env.AGENTIC_SECURITY_SYMEXEC === '1') {
|
|
167
177
|
findings = annotateBackwardSlices(findings, perFileIR, callGraph);
|
|
168
178
|
}
|
|
169
179
|
// Roadmap #6 — flow-proof: prove SQL sinks reached only through a
|
|
@@ -146,8 +146,44 @@ const SANITIZER_OUTPUT_REGEX = {
|
|
|
146
146
|
// (We can't distinguish overloads from regex name alone; conservative listing.)
|
|
147
147
|
// PHP htmlspecialchars / htmlentities — HTML-entity escape.
|
|
148
148
|
htmlspecialchars: /^[^<>&"']*(?:&(?:lt|gt|amp|quot|#039);)*[^<>&"']*$/,
|
|
149
|
+
// JS-ecosystem HTML escapers (he/escape-html/lodash.escape and similar
|
|
150
|
+
// libraries all converge on the same five-entity escape set) — same
|
|
151
|
+
// shape as htmlspecialchars above, different ecosystem name. Added
|
|
152
|
+
// during the SMT path-feasibility rebuild: this exact name was already
|
|
153
|
+
// trusted by exploit-prover.js's own (now-superseded) duplicate table,
|
|
154
|
+
// so omitting it here would have been a real regression, not a
|
|
155
|
+
// deliberate narrowing.
|
|
156
|
+
escapeHtml: /^[^<>&"']*(?:&(?:lt|gt|amp|quot|#039);)*[^<>&"']*$/,
|
|
149
157
|
};
|
|
150
158
|
|
|
159
|
+
// Next-gen taint capability #5 (SMT path-feasibility rebuild) — a SAFE,
|
|
160
|
+
// bare-tail lookup against the SAME catalog `abstract()` above already
|
|
161
|
+
// uses, exported so a soundness-critical consumer (the SMT/exploit-prover
|
|
162
|
+
// infeasibility check) can reuse this exact table rather than maintaining
|
|
163
|
+
// its own, inevitably-drifting copy — confirmed live that THREE separate,
|
|
164
|
+
// slightly different sanitizer-regex tables existed across this codebase
|
|
165
|
+
// before this fix (this one, exploit-prover.js's own `_sanitizerRegexFor`,
|
|
166
|
+
// and smt-feasibility.js's now-superseded encoder), each with subtly
|
|
167
|
+
// different entries.
|
|
168
|
+
//
|
|
169
|
+
// Deliberately EXCLUDES `toString` from what this function will return:
|
|
170
|
+
// `abstract()`'s own use of the FULL table above is fine with the
|
|
171
|
+
// over-approximation (worst case, a coarser Regex/Unknown classification —
|
|
172
|
+
// never a false SAFETY claim on its own, since nothing downstream of
|
|
173
|
+
// `abstract()` treats its answer as license to PROVE a finding infeasible).
|
|
174
|
+
// A soundness-critical consumer is different in kind: treating "this
|
|
175
|
+
// callee's output matches the regex" as grounds to demote a finding to
|
|
176
|
+
// provably-unreachable must not trust a name as ambiguous as `toString`
|
|
177
|
+
// (matches ANY object's own stringifier, not just a Buffer's base64
|
|
178
|
+
// encoding specifically) — an incorrect match there would manufacture a
|
|
179
|
+
// false "provably safe" verdict for a genuinely exploitable finding.
|
|
180
|
+
export function safeSanitizerOutputRegex(calleeName) {
|
|
181
|
+
if (!calleeName) return null;
|
|
182
|
+
const tail = String(calleeName).split('.').pop();
|
|
183
|
+
if (tail === 'toString') return null;
|
|
184
|
+
return SANITIZER_OUTPUT_REGEX[tail] || null;
|
|
185
|
+
}
|
|
186
|
+
|
|
151
187
|
/**
|
|
152
188
|
* SAFE-CHARSET PROOF — does the abstract value provably fit the given regex?
|
|
153
189
|
*
|
package/src/engine.js
CHANGED
|
@@ -239,7 +239,6 @@ import { runCrossServiceTaint } from './dataflow/cross-service-taint.js';
|
|
|
239
239
|
import { annotateRuntimeCorrelation } from './posture/runtime-correlation.js';
|
|
240
240
|
import { applyLearnedCalibration } from './posture/triage-learning.js';
|
|
241
241
|
import { annotateFormalVerification } from './dataflow/formal-verify.js';
|
|
242
|
-
import { annotatePathFeasibility } from './dataflow/smt-feasibility.js';
|
|
243
242
|
import { annotateProofGate } from './dataflow/proof-gate.js';
|
|
244
243
|
import { applySanitizerGate } from './dataflow/sanitizer-gate.js';
|
|
245
244
|
import { annotateFalsification } from './posture/falsification.js';
|
|
@@ -9839,11 +9838,16 @@ function _deterministicFileTimings(timings) {
|
|
|
9839
9838
|
if (process.env.AGENTIC_SECURITY_FORMAL === '1') {
|
|
9840
9839
|
await _runAnnotator("annotateFormalVerification", async () => { await annotateFormalVerification(finalFindings, fc, {}); });
|
|
9841
9840
|
}
|
|
9842
|
-
// SMT path feasibility
|
|
9843
|
-
//
|
|
9844
|
-
|
|
9845
|
-
|
|
9846
|
-
|
|
9841
|
+
// SMT path feasibility (`AGENTIC_SECURITY_SMT_FEASIBILITY`) was retired:
|
|
9842
|
+
// its predicate encoder read `f.chain`/`f.taintPath` as a sequential
|
|
9843
|
+
// source-to-sink path, but those fields are actually a flat list of
|
|
9844
|
+
// SOURCES reaching the sink (`_sourcesReachingExpr`), never a path —
|
|
9845
|
+
// confirmed by direct code reading, never fixed by real data. It also
|
|
9846
|
+
// mutated `f.severity` directly on an 'unsat' verdict, violating this
|
|
9847
|
+
// codebase's own recall-preserving-demotion convention that every other
|
|
9848
|
+
// proof gate here follows (see `dataflow/CLAUDE.md`). Superseded by the
|
|
9849
|
+
// real backward-slice-driven mechanism: `dataflow/exploit-prover.js`'s
|
|
9850
|
+
// `proveExploits`, opt-in via `AGENTIC_SECURITY_SYMEXEC=1`.
|
|
9847
9851
|
// Privacy / PII taint: emits pii-exposure findings + DPIA artifact.
|
|
9848
9852
|
// FR-405 (assurance-hardening PRD): _privacyIrBacked is declared at
|
|
9849
9853
|
// function scope above the AGENTIC_SECURITY_NO_INTEGRATION block, not
|
|
@@ -39,8 +39,15 @@ const _AST_CACHE = new WeakMap();
|
|
|
39
39
|
* Build the CHA over a perFileIR map (file → parsed IR with raw AST attached
|
|
40
40
|
* under `_ast`). When AST isn't attached, fall back to the IR's own
|
|
41
41
|
* structural hints (class names appearing in qids).
|
|
42
|
+
*
|
|
43
|
+
* `callGraph` (optional, from `../ir/callgraph.js#buildCallGraph`) enables
|
|
44
|
+
* one additional, otherwise-skipped pass: cross-call-site parameter type
|
|
45
|
+
* inference from the argument a caller actually passes (see that pass's own
|
|
46
|
+
* header comment below for the full rationale). Every existing caller that
|
|
47
|
+
* only ever passed `perFileIR` keeps working unchanged — the pass simply
|
|
48
|
+
* doesn't run without it.
|
|
42
49
|
*/
|
|
43
|
-
export function buildClassHierarchy(perFileIR) {
|
|
50
|
+
export function buildClassHierarchy(perFileIR, callGraph) {
|
|
44
51
|
const classes = new Map(); // className -> { file, line, methods, extends }
|
|
45
52
|
const methodOwners = new Map(); // qid -> className
|
|
46
53
|
const typeOfVar = new Map(); // file::scope::var -> className
|
|
@@ -261,6 +268,158 @@ export function buildClassHierarchy(perFileIR) {
|
|
|
261
268
|
}
|
|
262
269
|
}
|
|
263
270
|
|
|
271
|
+
// Next-gen taint capability #4b — factory-function RETURN-type inference,
|
|
272
|
+
// the sibling gap this file's OWN header comment names and explicitly
|
|
273
|
+
// marks unimplemented ("`function buildFoo(): Foo { ... }` typed-return
|
|
274
|
+
// inference — NOT implemented. `const x = buildFoo()` is untyped
|
|
275
|
+
// (correctly: it is a plain call, not a `new`)"). Confirmed still real via
|
|
276
|
+
// a direct reproduction: `function makeHelper() { return new Helper(); }
|
|
277
|
+
// const h = makeHelper(); h.getValue(req);` misses the vulnerability
|
|
278
|
+
// entirely, the identical failure shape 4a (above) closes for the
|
|
279
|
+
// parameter case. `_inferredFactoryReturnClass` is deliberately
|
|
280
|
+
// conservative, same "refuse rather than guess" discipline as every other
|
|
281
|
+
// pass in this file: a function qualifies ONLY when every one of its own
|
|
282
|
+
// `return` statements is a `new ClassName(...)` literal of the SAME
|
|
283
|
+
// class — a function with no return, a non-constructor return on any
|
|
284
|
+
// path, or disagreeing classes across returns all correctly infer
|
|
285
|
+
// nothing, rather than picking one arbitrarily.
|
|
286
|
+
function _inferredFactoryReturnClass(fn) {
|
|
287
|
+
if (!fn || !fn.cfg || !fn.cfg.nodes) return null;
|
|
288
|
+
let className = null;
|
|
289
|
+
for (const node of Object.values(fn.cfg.nodes)) {
|
|
290
|
+
if (node.kind !== 'return' || !node.value) continue;
|
|
291
|
+
const v = node.value;
|
|
292
|
+
if (v.kind !== 'call' || !v.isNew) return null;
|
|
293
|
+
const callee = v.callee;
|
|
294
|
+
const cn = callee?.kind === 'ident' ? callee.name
|
|
295
|
+
: typeof callee === 'string' ? (callee.includes('.') ? callee.slice(callee.lastIndexOf('.') + 1) : callee)
|
|
296
|
+
: null;
|
|
297
|
+
if (!cn || !(classes.has(cn) || /^[A-Z]/.test(cn))) return null;
|
|
298
|
+
if (className === null) className = cn;
|
|
299
|
+
else if (className !== cn) return null;
|
|
300
|
+
}
|
|
301
|
+
return className;
|
|
302
|
+
}
|
|
303
|
+
if (callGraph && typeof callGraph.resolveKnownCallee === 'function' && callGraph.functions) {
|
|
304
|
+
const factoryReturnCache = new Map(); // qid -> className|null, memoized
|
|
305
|
+
const factoryReturnClassOf = (qid, fn) => {
|
|
306
|
+
if (factoryReturnCache.has(qid)) return factoryReturnCache.get(qid);
|
|
307
|
+
const cn = _inferredFactoryReturnClass(fn);
|
|
308
|
+
factoryReturnCache.set(qid, cn);
|
|
309
|
+
return cn;
|
|
310
|
+
};
|
|
311
|
+
for (const [callerFile, ir] of Object.entries(perFileIR)) {
|
|
312
|
+
if (!ir || !Array.isArray(ir.functions)) continue;
|
|
313
|
+
for (const fn of ir.functions) {
|
|
314
|
+
if (!fn.cfg || !fn.cfg.nodes) continue;
|
|
315
|
+
for (const node of Object.values(fn.cfg.nodes)) {
|
|
316
|
+
if (node.kind !== 'assign' || typeof node.target !== 'string' || node.target.includes('.')) continue;
|
|
317
|
+
const src = node.source;
|
|
318
|
+
// A plain call (isNew must be FALSY — a real `new Foo()` is
|
|
319
|
+
// already handled by the constructor-assignment pass above, and
|
|
320
|
+
// conflating the two would just redundantly re-derive the same
|
|
321
|
+
// answer through a slower path).
|
|
322
|
+
if (!src || src.kind !== 'call' || src.isNew) continue;
|
|
323
|
+
const calleeName = typeof src.callee === 'string' ? src.callee
|
|
324
|
+
: src.callee && src.callee.kind === 'ident' ? src.callee.name : null;
|
|
325
|
+
if (!calleeName) continue;
|
|
326
|
+
const calleeQid = callGraph.resolveKnownCallee(calleeName, callerFile);
|
|
327
|
+
if (!calleeQid) continue;
|
|
328
|
+
const calleeFn = callGraph.functions.get(calleeQid);
|
|
329
|
+
if (!calleeFn) continue;
|
|
330
|
+
const className = factoryReturnClassOf(calleeQid, calleeFn);
|
|
331
|
+
if (!className) continue;
|
|
332
|
+
const key = `${callerFile}::${fn.qid}::${node.target}`;
|
|
333
|
+
if (ambiguousVarKeys.has(key)) continue;
|
|
334
|
+
if (typeOfVar.has(key) && typeOfVar.get(key) !== className) {
|
|
335
|
+
ambiguousVarKeys.add(key);
|
|
336
|
+
typeOfVar.delete(key);
|
|
337
|
+
} else {
|
|
338
|
+
typeOfVar.set(key, className);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Next-gen taint capability #4 (a narrow, call-site-scoped slice of
|
|
346
|
+
// "Andersen-style points-to", not the full thing): cross-call-site
|
|
347
|
+
// PARAMETER type inference from the ARGUMENT a caller actually passes, for
|
|
348
|
+
// JS/TS — which has no declared-type mechanism at all (`fn.paramTypes`
|
|
349
|
+
// just above only ever fires for Java/C#). Juliet's own dominant
|
|
350
|
+
// "driver constructs the concrete instance, passes it as a parameter to a
|
|
351
|
+
// helper that invokes the virtual method" idiom (already solved for
|
|
352
|
+
// Java/C# via fn.paramTypes) has no JS/TS equivalent: `function
|
|
353
|
+
// wrapper(h, req) { return h.getValue(req); }`, called only as `const h =
|
|
354
|
+
// new Helper(); wrapper(h, req);`, leaves `h` permanently untyped inside
|
|
355
|
+
// wrapper's own body, so `h.getValue()` can never resolve via CHA —
|
|
356
|
+
// confirmed via a direct reproduction (cold-cache AND warm-incremental-
|
|
357
|
+
// cache both miss it identically, ruling out a caching-specific cause)
|
|
358
|
+
// showing a real cross-file taint-recall false negative for exactly this
|
|
359
|
+
// shape. Requires `callGraph` (optional, backward-compatible — every
|
|
360
|
+
// existing caller that only ever passed `perFileIR` simply skips this
|
|
361
|
+
// pass, matching this file's own established graceful-degradation
|
|
362
|
+
// convention). Deliberately runs as a LAST, separate pass over every file
|
|
363
|
+
// (not merged into the per-file loop above) because it needs BOTH a
|
|
364
|
+
// cross-file callee lookup (`callGraph.resolveKnownCallee`) AND the
|
|
365
|
+
// CALLER's own already-inferred `typeOfVar` entries (an identifier
|
|
366
|
+
// argument's type, established by the constructor-assignment pass above)
|
|
367
|
+
// — neither is available mid-way through that loop for an arbitrary file
|
|
368
|
+
// ordering.
|
|
369
|
+
if (callGraph && typeof callGraph.resolveKnownCallee === 'function' && callGraph.functions) {
|
|
370
|
+
for (const [callerFile, ir] of Object.entries(perFileIR)) {
|
|
371
|
+
if (!ir || !Array.isArray(ir.functions)) continue;
|
|
372
|
+
for (const callerFn of ir.functions) {
|
|
373
|
+
if (!Array.isArray(callerFn.calls)) continue;
|
|
374
|
+
for (const call of callerFn.calls) {
|
|
375
|
+
const calleeName = typeof call.callee === 'string' ? call.callee
|
|
376
|
+
: call.callee && call.callee.kind === 'ident' ? call.callee.name : null;
|
|
377
|
+
if (!calleeName) continue;
|
|
378
|
+
const calleeQid = callGraph.resolveKnownCallee(calleeName, callerFile);
|
|
379
|
+
if (!calleeQid) continue;
|
|
380
|
+
const calleeFn = callGraph.functions.get(calleeQid);
|
|
381
|
+
if (!calleeFn || !Array.isArray(calleeFn.params) || !calleeFn.params.length || !calleeFn.file) continue;
|
|
382
|
+
const args = Array.isArray(call.args) ? call.args : [];
|
|
383
|
+
for (let i = 0; i < args.length && i < calleeFn.params.length; i++) {
|
|
384
|
+
const arg = args[i];
|
|
385
|
+
let className = null;
|
|
386
|
+
if (arg && arg.kind === 'call' && arg.isNew) {
|
|
387
|
+
// A literal `new ClassName(...)` passed directly as the argument.
|
|
388
|
+
const argCallee = arg.callee;
|
|
389
|
+
className = argCallee?.kind === 'ident' ? argCallee.name
|
|
390
|
+
: typeof argCallee === 'string' ? (argCallee.includes('.') ? argCallee.slice(argCallee.lastIndexOf('.') + 1) : argCallee)
|
|
391
|
+
: null;
|
|
392
|
+
} else if (arg && arg.kind === 'ident' && arg.name) {
|
|
393
|
+
// Juliet's own dominant idiom: `const h = new Helper(); f(h,
|
|
394
|
+
// ...)`. `h`'s type was already established by the
|
|
395
|
+
// constructor-assignment pass above (it runs first, in the
|
|
396
|
+
// SAME outer file loop) — reuse it directly rather than
|
|
397
|
+
// re-deriving, so this stays in sync with that pass's own
|
|
398
|
+
// ambiguity handling by construction.
|
|
399
|
+
className = typeOfVar.get(`${callerFile}::${callerFn.qid}::${arg.name}`) || null;
|
|
400
|
+
}
|
|
401
|
+
if (!className || !(classes.has(className) || /^[A-Z]/.test(className))) continue;
|
|
402
|
+
const paramName = calleeFn.params[i];
|
|
403
|
+
if (typeof paramName !== 'string' || !paramName) continue;
|
|
404
|
+
// Same collision-refusal discipline as every other typeOfVar-
|
|
405
|
+
// populating pass in this file: a wrong "confidently resolved"
|
|
406
|
+
// type is worse than staying unknown, so a SECOND call site
|
|
407
|
+
// disagreeing with an already-recorded type for this exact
|
|
408
|
+
// parameter permanently refuses it, never picks a side.
|
|
409
|
+
const key = `${calleeFn.file}::${calleeQid}::${paramName}`;
|
|
410
|
+
if (ambiguousVarKeys.has(key)) continue;
|
|
411
|
+
if (typeOfVar.has(key) && typeOfVar.get(key) !== className) {
|
|
412
|
+
ambiguousVarKeys.add(key);
|
|
413
|
+
typeOfVar.delete(key);
|
|
414
|
+
} else {
|
|
415
|
+
typeOfVar.set(key, className);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
264
423
|
return { classes, methodOwners, typeOfVar };
|
|
265
424
|
}
|
|
266
425
|
|
package/src/report/index.js
CHANGED
|
@@ -228,6 +228,18 @@ export function normalizeFindings(scan){
|
|
|
228
228
|
dataClasses: f.dataClasses || [],
|
|
229
229
|
chain: Array.isArray(f.chain) ? f.chain : null,
|
|
230
230
|
sourceProvenance: f.sourceProvenance || null,
|
|
231
|
+
// Next-gen taint capability #7 (cross-service schema-driven taint):
|
|
232
|
+
// dataflow/cross-service-taint.js SETS crossService/_severityBumpReason
|
|
233
|
+
// on findings whose source crosses a declared services.yml trust
|
|
234
|
+
// boundary, but this allowlist never named either field — the same
|
|
235
|
+
// "silently dropped field" bug class engine.js's own _funcQid/callee/
|
|
236
|
+
// argIndex comments already document twice for the raw finding
|
|
237
|
+
// object, this time at the report layer. Without this, an operator
|
|
238
|
+
// reading a report saw a bumped severity with no visible reason at
|
|
239
|
+
// all: `severity` itself IS in this allowlist (so the bump survived),
|
|
240
|
+
// but its only explanation was silently discarded.
|
|
241
|
+
crossService: f.crossService || null,
|
|
242
|
+
_severityBumpReason: f._severityBumpReason || null,
|
|
231
243
|
confidence: typeof f.confidence === 'number' ? f.confidence : null,
|
|
232
244
|
// R17: corroboration ("one issue, many signals") — count of independent
|
|
233
245
|
// analyses that agreed, and which ones.
|