@clear-capabilities/agentic-security-scanner 0.151.3 → 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 +125 -0
- package/dist/1336.index.js +16010 -0
- package/dist/agentic-security.mjs +80 -80
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +7 -6
- package/src/dataflow/CLAUDE.md +1 -1
- package/src/dataflow/backward.js +104 -7
- package/src/dataflow/catalog.js +802 -13
- package/src/dataflow/cross-service-taint.js +154 -11
- package/src/dataflow/engine.js +875 -90
- package/src/dataflow/exploit-prover.js +111 -23
- package/src/dataflow/incremental.js +13 -0
- package/src/dataflow/index.js +29 -6
- package/src/dataflow/path-feasibility.js +86 -9
- package/src/dataflow/string-domain.js +168 -0
- package/src/dataflow/summaries.js +69 -8
- package/src/engine.js +118 -9
- package/src/ir/CLAUDE.md +2 -1
- package/src/ir/balanced-call.js +33 -1
- package/src/ir/callgraph.js +26 -2
- package/src/ir/class-hierarchy.js +261 -7
- package/src/ir/csharp-ir.js +97 -8
- package/src/ir/index.js +21 -1
- package/src/ir/parser-cs.js +706 -24
- package/src/ir/parser-java.js +498 -17
- package/src/ir/parser-js.js +22 -0
- package/src/ir/parser-php.js +446 -43
- package/src/ir/parser-rust.js +1338 -0
- package/src/ir/php-include-merge.js +190 -0
- package/src/lineage/sink-registry.js +18 -0
- package/src/lineage/source-registry.js +23 -0
- package/src/posture/csharp-analysis.js +341 -43
- package/src/posture/finding-defaults.js +5 -1
- package/src/posture/fix-coverage.js +1 -0
- package/src/posture/proof-coverage.js +1 -0
- package/src/report/index.js +12 -0
- package/src/sast/CLAUDE.md +1 -1
- package/src/sast/authz.js +144 -1
- package/src/sast/crypto-protocol.js +65 -7
- package/src/sast/csharp-cross-file.js +125 -0
- package/src/sast/csharp-structural.js +78 -2
- package/src/sast/csharp.js +574 -42
- package/src/sast/java-ast-folding.js +268 -11
- package/src/sast/java-bench-extras.js +530 -2
- package/src/sast/java-deserialization.js +31 -2
- package/src/sast/java-structural-cross-file.js +146 -0
- package/src/sast/java-structural.js +499 -6
- package/src/sast/ldap-injection.js +133 -3
- package/src/sast/rust.js +57 -0
- package/src/sast/xss-reflected-multilang.js +252 -3
- 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.
|
|
@@ -149,6 +149,18 @@ export function seedSummaryCache(summaryCache, persisted, reusableQids) {
|
|
|
149
149
|
const summary = {
|
|
150
150
|
returnTainted: !!s.returnTainted,
|
|
151
151
|
mutatedParams: new Set(s.mutatedParams || []),
|
|
152
|
+
// W5.53: mutatedThisFields is a THIRD Set-valued summary field
|
|
153
|
+
// (object-instance field taint via method calls), added after this
|
|
154
|
+
// module's own serialize/reseed pair was written — omitting it here
|
|
155
|
+
// wouldn't crash (every consumer already null-guards with `&&`), but
|
|
156
|
+
// would silently drop the capability on any incremental/warm-cache
|
|
157
|
+
// scan, exactly the class of gap `_summaryEq`'s own header comment in
|
|
158
|
+
// summaries.js warns about for a different (but structurally
|
|
159
|
+
// identical) reason: a summary field that exists on some code paths
|
|
160
|
+
// and not others must be threaded through EVERY path that copies the
|
|
161
|
+
// summary shape, not just the ones exercised by this session's own
|
|
162
|
+
// tests.
|
|
163
|
+
mutatedThisFields: new Set(s.mutatedThisFields || []),
|
|
152
164
|
taintedGlobals: new Set(s.taintedGlobals || []),
|
|
153
165
|
findings: Array.isArray(s.findings) ? s.findings : [],
|
|
154
166
|
};
|
|
@@ -195,6 +207,7 @@ export function serializeSummaries(summaryCache) {
|
|
|
195
207
|
out[qid] = {
|
|
196
208
|
returnTainted: !!summary.returnTainted,
|
|
197
209
|
mutatedParams: [...(summary.mutatedParams || [])],
|
|
210
|
+
mutatedThisFields: [...(summary.mutatedThisFields || [])],
|
|
198
211
|
taintedGlobals: [...(summary.taintedGlobals || [])],
|
|
199
212
|
findings: Array.isArray(summary.findings) ? summary.findings.slice(0, 50) : [],
|
|
200
213
|
};
|
package/src/dataflow/index.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { runTaintEngine } from './engine.js';
|
|
3
3
|
import { annotateProvenClean } from './proven-clean.js';
|
|
4
4
|
import { CATALOG, matchSource, matchSinkOrSanitizer, _catalogSize } from './catalog.js';
|
|
5
|
-
import { applyPathFeasibility } from './path-feasibility.js';
|
|
5
|
+
import { applyPathFeasibility, buildConstantFnMap } from './path-feasibility.js';
|
|
6
6
|
import { SummaryCache, entryStateFromCall } from './summaries.js';
|
|
7
7
|
import { rhsReachableFunctions, shouldAnalyzeUnderRhs } from './tabulation.js';
|
|
8
8
|
import { annotateBackwardSlices } from './backward.js';
|
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
serializeSummaries, commitIncrementalState,
|
|
13
13
|
} from './incremental.js';
|
|
14
14
|
import { buildPointsTo } from './points-to.js';
|
|
15
|
-
import { buildClassHierarchy } from '../ir/class-hierarchy.js';
|
|
15
|
+
import { buildClassHierarchy, annotateRTA } from '../ir/class-hierarchy.js';
|
|
16
16
|
import { annotateSoftTaint } from './soft-taint.js';
|
|
17
17
|
import { runIfdsTaintEngine } from './ifds.js';
|
|
18
18
|
import { proveExploits } from './exploit-prover.js';
|
|
@@ -22,9 +22,13 @@ import { runPrivacyTaintEngine } from './privacy-deep-walker.js';
|
|
|
22
22
|
|
|
23
23
|
export function runDeepAnalysis(perFileIR, callGraph, opts = {}) {
|
|
24
24
|
// Path-feasibility pass over every function before the taint walk.
|
|
25
|
+
// W2.1: build the trivially-constant-helper map ONCE, from every function
|
|
26
|
+
// in the scan, before folding any single function's conditions — a
|
|
27
|
+
// constant helper in file A must resolve when called from file B.
|
|
28
|
+
const constFns = buildConstantFnMap(callGraph.functions.values());
|
|
25
29
|
let totalPruned = 0;
|
|
26
30
|
for (const fn of callGraph.functions.values()) {
|
|
27
|
-
const r = applyPathFeasibility(fn);
|
|
31
|
+
const r = applyPathFeasibility(fn, constFns);
|
|
28
32
|
totalPruned += r.pruned;
|
|
29
33
|
}
|
|
30
34
|
// P2.1 — RHS-lite reachability slice. When AGENTIC_SECURITY_RHS=1 the
|
|
@@ -93,7 +97,16 @@ export function runDeepAnalysis(perFileIR, callGraph, opts = {}) {
|
|
|
93
97
|
// (a single walk of the already-parsed IR, no fixed-point iteration) and
|
|
94
98
|
// every consumer degrades to today's behavior when it finds no useful type.
|
|
95
99
|
let classHierarchy = null;
|
|
96
|
-
try {
|
|
100
|
+
try {
|
|
101
|
+
classHierarchy = buildClassHierarchy(perFileIR, callGraph);
|
|
102
|
+
// PRD W2.5 (SARD_80_F1_EXECUTION_PRD.md) — RTA-annotate so
|
|
103
|
+
// `engine.js`'s `_resolveMemberCalleeViaCHA` can resolve a
|
|
104
|
+
// parameter-typed (or otherwise non-`new`-tracked) receiver against
|
|
105
|
+
// whichever subclasses are ACTUALLY instantiated somewhere in this
|
|
106
|
+
// scan, not just the receiver's own declared/abstract type (which
|
|
107
|
+
// typically has no method BODY of its own to resolve to at all).
|
|
108
|
+
if (classHierarchy) annotateRTA(classHierarchy, perFileIR);
|
|
109
|
+
} catch { classHierarchy = null; }
|
|
97
110
|
// v0.70 #2 — Steensgaard points-to / alias analysis. Built once before
|
|
98
111
|
// the worklist, passed via opts so the engine can resolve aliased
|
|
99
112
|
// mutations (`let a = obj; a.x = tainted; sink(obj.x)`).
|
|
@@ -150,7 +163,17 @@ export function runDeepAnalysis(perFileIR, callGraph, opts = {}) {
|
|
|
150
163
|
});
|
|
151
164
|
}
|
|
152
165
|
// P1.4 — backward slice (opt-in via AGENTIC_SECURITY_BACKWARD_SLICE=1).
|
|
153
|
-
|
|
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') {
|
|
154
177
|
findings = annotateBackwardSlices(findings, perFileIR, callGraph);
|
|
155
178
|
}
|
|
156
179
|
// Roadmap #6 — flow-proof: prove SQL sinks reached only through a
|
|
@@ -237,4 +260,4 @@ export function runDeepAnalysis(perFileIR, callGraph, opts = {}) {
|
|
|
237
260
|
return findings;
|
|
238
261
|
}
|
|
239
262
|
|
|
240
|
-
export { runTaintEngine, CATALOG, matchSource, matchSinkOrSanitizer, _catalogSize, applyPathFeasibility, SummaryCache, entryStateFromCall, rhsReachableFunctions, shouldAnalyzeUnderRhs, annotateBackwardSlices };
|
|
263
|
+
export { runTaintEngine, CATALOG, matchSource, matchSinkOrSanitizer, _catalogSize, applyPathFeasibility, buildConstantFnMap, SummaryCache, entryStateFromCall, rhsReachableFunctions, shouldAnalyzeUnderRhs, annotateBackwardSlices };
|
|
@@ -14,21 +14,84 @@
|
|
|
14
14
|
//
|
|
15
15
|
// Patterns deliberately deferred (would need SMT or symbolic execution):
|
|
16
16
|
// - Comparisons of unrelated variables
|
|
17
|
-
// - Comparisons involving function call return values
|
|
18
17
|
// - Aliasing-aware constraint propagation
|
|
18
|
+
// - Static/instance final-field constants (PRD W2.1's other half — needs
|
|
19
|
+
// parser changes to capture field initializer values across all IR
|
|
20
|
+
// languages, not attempted here)
|
|
21
|
+
//
|
|
22
|
+
// W2.1: trivially-constant helper returns. Juliet's control-flow-gating
|
|
23
|
+
// variants (02-22) commonly gate through a helper — `if (privateReturnsTrue())`,
|
|
24
|
+
// `if (IO.staticReturnsTrueOrFalse())` — rather than an inline literal. This
|
|
25
|
+
// is NOT a Juliet-specific special case: `buildConstantFnMap` (below, called
|
|
26
|
+
// once per scan in dataflow/index.js) generically finds any function whose
|
|
27
|
+
// ENTIRE body is `return <literal>;` and nothing else, for ANY codebase,
|
|
28
|
+
// then this module resolves a call to such a function the same way it
|
|
29
|
+
// already resolves `if (true)`. A function with real logic (even one extra
|
|
30
|
+
// statement) is never treated as constant — this reads the function's own
|
|
31
|
+
// semantics, not an answer-key label.
|
|
19
32
|
//
|
|
20
33
|
// Output: mutates the CFG node's `succ` array to drop unreachable edges. The
|
|
21
34
|
// existing taint engine then never walks them. Logs the prune count on each
|
|
22
35
|
// function so we can count how many FPs path-feasibility avoided.
|
|
23
36
|
|
|
24
|
-
function
|
|
37
|
+
// A function is "trivially constant" iff its CFG, after stripping entry/exit/
|
|
38
|
+
// noop bookkeeping nodes, is exactly one `return <literal>;` node. Anything
|
|
39
|
+
// else (a branch, an assignment, a call, a non-literal return) disqualifies
|
|
40
|
+
// it — conservative by design, since a false resolution here silently prunes
|
|
41
|
+
// a REAL branch.
|
|
42
|
+
function triviallyConstantValue(fn) {
|
|
43
|
+
if (!fn || !fn.cfg || !fn.cfg.nodes) return undefined;
|
|
44
|
+
const real = Object.values(fn.cfg.nodes).filter(n => n && n.kind !== 'entry' && n.kind !== 'exit' && n.kind !== 'noop');
|
|
45
|
+
if (real.length !== 1) return undefined;
|
|
46
|
+
const only = real[0];
|
|
47
|
+
if (only.kind !== 'return') return undefined;
|
|
48
|
+
if (!only.value || only.value.kind !== 'literal') return undefined;
|
|
49
|
+
return only.value.value;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Builds { byQid: Map<qid, value>, byBareName: Map<lastSegment, value|AMBIGUOUS> }
|
|
53
|
+
// from every function in the scan. Bare-name resolution is how the hand-rolled
|
|
54
|
+
// parsers' flat dotted-string callees (`"IO.staticReturnsTrue"`,
|
|
55
|
+
// `"privateReturnsTrue"`) get matched — same collision-refusal precedent as
|
|
56
|
+
// `callgraph.js`'s `~bare~` key: two differently-valued same-named functions
|
|
57
|
+
// refuse to resolve via bare name rather than guessing.
|
|
58
|
+
const AMBIGUOUS = Symbol('ambiguous-constant-fn');
|
|
59
|
+
|
|
60
|
+
export function buildConstantFnMap(functions) {
|
|
61
|
+
const byQid = new Map();
|
|
62
|
+
const byBareName = new Map();
|
|
63
|
+
for (const fn of functions) {
|
|
64
|
+
const val = triviallyConstantValue(fn);
|
|
65
|
+
if (val === undefined) continue;
|
|
66
|
+
if (fn.qid) byQid.set(fn.qid, val);
|
|
67
|
+
const bare = (fn.name || '').split('.').pop();
|
|
68
|
+
if (!bare) continue;
|
|
69
|
+
if (byBareName.has(bare)) {
|
|
70
|
+
const existing = byBareName.get(bare);
|
|
71
|
+
if (existing !== AMBIGUOUS && existing !== val) byBareName.set(bare, AMBIGUOUS);
|
|
72
|
+
} else {
|
|
73
|
+
byBareName.set(bare, val);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return { byQid, byBareName };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function _bareCalleeName(callee) {
|
|
80
|
+
if (!callee) return null;
|
|
81
|
+
if (typeof callee === 'string') return callee.split('.').pop() || null;
|
|
82
|
+
if (callee.kind === 'ident') return callee.name || null;
|
|
83
|
+
if (callee.kind === 'member') return callee.prop || null;
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function evalConst(expr, constFns) {
|
|
25
88
|
if (!expr) return undefined;
|
|
26
89
|
switch (expr.kind) {
|
|
27
90
|
case 'literal': return expr.value;
|
|
28
91
|
case 'unknown': return undefined;
|
|
29
92
|
case 'binary': {
|
|
30
|
-
const l = evalConst(expr.left);
|
|
31
|
-
const r = evalConst(expr.right);
|
|
93
|
+
const l = evalConst(expr.left, constFns);
|
|
94
|
+
const r = evalConst(expr.right, constFns);
|
|
32
95
|
if (l === undefined || r === undefined) return undefined;
|
|
33
96
|
switch (expr.op) {
|
|
34
97
|
case '===': return l === r;
|
|
@@ -47,10 +110,10 @@ function evalConst(expr) {
|
|
|
47
110
|
return undefined;
|
|
48
111
|
}
|
|
49
112
|
case 'logical': {
|
|
50
|
-
const l = evalConst(expr.left);
|
|
113
|
+
const l = evalConst(expr.left, constFns);
|
|
51
114
|
if (l === undefined) return undefined;
|
|
52
|
-
if (expr.op === '&&') return l ? evalConst(expr.right) : l;
|
|
53
|
-
if (expr.op === '||') return l ? l : evalConst(expr.right);
|
|
115
|
+
if (expr.op === '&&') return l ? evalConst(expr.right, constFns) : l;
|
|
116
|
+
if (expr.op === '||') return l ? l : evalConst(expr.right, constFns);
|
|
54
117
|
return undefined;
|
|
55
118
|
}
|
|
56
119
|
case 'ident': {
|
|
@@ -62,6 +125,20 @@ function evalConst(expr) {
|
|
|
62
125
|
// x === x style: the engine can't fold this without symbolic equality.
|
|
63
126
|
return undefined;
|
|
64
127
|
}
|
|
128
|
+
case 'call': {
|
|
129
|
+
// W2.1: a call to a function whose entire body is `return <literal>;`
|
|
130
|
+
// resolves to that literal — see `buildConstantFnMap` above. Prefer an
|
|
131
|
+
// exact qid match (rare for hand-rolled parsers' flat callee strings,
|
|
132
|
+
// but free to check), fall back to the collision-refused bare-name map.
|
|
133
|
+
if (!constFns) return undefined;
|
|
134
|
+
if (typeof expr.callee === 'string' && constFns.byQid.has(expr.callee)) {
|
|
135
|
+
return constFns.byQid.get(expr.callee);
|
|
136
|
+
}
|
|
137
|
+
const bare = _bareCalleeName(expr.callee);
|
|
138
|
+
if (!bare) return undefined;
|
|
139
|
+
const v = constFns.byBareName.get(bare);
|
|
140
|
+
return v === AMBIGUOUS ? undefined : v;
|
|
141
|
+
}
|
|
65
142
|
}
|
|
66
143
|
return undefined;
|
|
67
144
|
}
|
|
@@ -77,7 +154,7 @@ function syntacticallyEqual(a, b) {
|
|
|
77
154
|
return false;
|
|
78
155
|
}
|
|
79
156
|
|
|
80
|
-
export function applyPathFeasibility(fn) {
|
|
157
|
+
export function applyPathFeasibility(fn, constFns) {
|
|
81
158
|
if (!fn || !fn.cfg || !fn.cfg.nodes) return { pruned: 0 };
|
|
82
159
|
let pruned = 0;
|
|
83
160
|
for (const id of Object.keys(fn.cfg.nodes)) {
|
|
@@ -86,7 +163,7 @@ export function applyPathFeasibility(fn) {
|
|
|
86
163
|
const cond = node.cond;
|
|
87
164
|
if (!cond) continue;
|
|
88
165
|
// Constant cond?
|
|
89
|
-
const val = evalConst(cond);
|
|
166
|
+
const val = evalConst(cond, constFns);
|
|
90
167
|
if (val === true) {
|
|
91
168
|
// Drop the second successor (the false branch).
|
|
92
169
|
if (node.succ.length > 1) {
|
|
@@ -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
|
*
|
|
@@ -232,3 +268,135 @@ export function isSafeRedirectTarget(absVal, allowedHosts) {
|
|
|
232
268
|
export function hashAbstract(absVal) {
|
|
233
269
|
return render(absVal);
|
|
234
270
|
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Next-gen taint capability #2 — regex-VALIDATION guard safety.
|
|
274
|
+
*
|
|
275
|
+
* A DIFFERENT question from `provablyMatches` above (which asks "does this
|
|
276
|
+
* already-known abstract value fit a safety regex"): here we're asked "is
|
|
277
|
+
* this REGEX PATTERN, used as a `pattern.test(x)`-style validation guard,
|
|
278
|
+
* restrictive enough that observing it MATCH on the true branch proves `x`
|
|
279
|
+
* cannot carry injection metacharacters for ANY family?" This is the JS/TS
|
|
280
|
+
* analog of PHP's `if (preg_match($re, $x) == 1) { ... }` idiom — found to
|
|
281
|
+
* be a genuine, real-world-common pattern with NO recognition anywhere in
|
|
282
|
+
* this engine (neither the name-based `GUARD_PREDICATES` in engine.js, nor
|
|
283
|
+
* this file's own `abstract()`/`SANITIZER_OUTPUT_REGEX`, which characterizes
|
|
284
|
+
* a SANITIZER CALL's output, not a validation-guard's CONDITION).
|
|
285
|
+
*
|
|
286
|
+
* Deliberately a conservative ALLOWLIST parser, not a full regex-engine
|
|
287
|
+
* subset check (undecidable in general): walks the pattern character by
|
|
288
|
+
* character, permitting only literal safe characters (letters, digits, and
|
|
289
|
+
* a short vetted punctuation set), `\d`/`\w` shorthand classes, and
|
|
290
|
+
* bracket character classes built from the same safe set — with
|
|
291
|
+
* quantifiers on any of the above. Anything it cannot fully account for
|
|
292
|
+
* (alternation, groups, wildcards, negated classes `[^...]`, negated
|
|
293
|
+
* shorthand `\D`/`\W`/`\s`/`\S`, backreferences, lookaround, unicode
|
|
294
|
+
* property escapes, an unescaped stray metacharacter) REJECTS rather than
|
|
295
|
+
* guesses — a false "safe" here would silently suppress a real,
|
|
296
|
+
* exploitable finding, which is a much worse failure mode than missing a
|
|
297
|
+
* genuinely-safe-but-unrecognized pattern (the existing, unchanged taint
|
|
298
|
+
* flow still fires in that case, exactly as it did before this capability
|
|
299
|
+
* existed).
|
|
300
|
+
*
|
|
301
|
+
* Also requires full anchoring (`^...$`) and rejects the `m` (multiline)
|
|
302
|
+
* flag: with `m` set, `^`/`$` match at LINE boundaries rather than string
|
|
303
|
+
* boundaries, so a multi-line input could have a safe first line and an
|
|
304
|
+
* unsafe second line while still matching `^safe$` against line one —
|
|
305
|
+
* silently defeating the "matched, therefore the WHOLE value is safe"
|
|
306
|
+
* guarantee this function exists to provide.
|
|
307
|
+
*/
|
|
308
|
+
export function isSafeValidationPattern(pattern) {
|
|
309
|
+
if (!(pattern instanceof RegExp)) return false;
|
|
310
|
+
if (pattern.flags && pattern.flags.includes('m')) return false;
|
|
311
|
+
const source = pattern.source;
|
|
312
|
+
if (!source.startsWith('^') || !source.endsWith('$')) return false;
|
|
313
|
+
const body = source.slice(1, -1);
|
|
314
|
+
if (!body) return false; // `^$` (empty-string-only) is degenerate, not a useful guard
|
|
315
|
+
// Two DISTINCT allowlists, deliberately not merged into one — conflating
|
|
316
|
+
// them is exactly the bug this comment now documents (caught by this
|
|
317
|
+
// function's own adversarial unit tests before ever reaching production):
|
|
318
|
+
//
|
|
319
|
+
// `isSafeBareChar` — characters with NO special regex meaning when they
|
|
320
|
+
// appear UNESCAPED. Critically, `.` (the wildcard metacharacter — matches
|
|
321
|
+
// ANY character) is NOT in this set: an earlier version of this function
|
|
322
|
+
// included `.` here on the (wrong) assumption that it's "just punctuation,"
|
|
323
|
+
// which caused `/^.*$/` — the real PHP generator's OWN "no_filtering"
|
|
324
|
+
// unsafe sample, matching literally everything — to be accepted as a safe
|
|
325
|
+
// guard. A bare `.` in a pattern is ALWAYS the wildcard; a literal dot
|
|
326
|
+
// must be escaped (`\.`) or appear inside a character class (`[.]`),
|
|
327
|
+
// both handled by the escaped/class-content paths below.
|
|
328
|
+
//
|
|
329
|
+
// `isSafeEscapedPunct` — punctuation that, when ESCAPED with a backslash,
|
|
330
|
+
// unambiguously means "match this literal character." Deliberately
|
|
331
|
+
// EXCLUDES every letter and digit: an escaped letter in regex syntax is
|
|
332
|
+
// Two DELIBERATELY SEPARATE allowlists, keyed by context, not merged into
|
|
333
|
+
// one — conflating them is the exact bug class this function's own
|
|
334
|
+
// adversarial unit tests caught twice while writing it, both fixed here:
|
|
335
|
+
//
|
|
336
|
+
// `isSafeBareChar` (governs an UNESCAPED character, bare or inside a
|
|
337
|
+
// class) — letters, digits, underscore, hyphen, at-sign: characters with
|
|
338
|
+
// NO special regex meaning at all when unescaped. Crucially this does
|
|
339
|
+
// NOT include `.` — a bare `.` outside a class is the wildcard
|
|
340
|
+
// metacharacter (matches ANY character), not punctuation. An earlier
|
|
341
|
+
// version of this allowlist included `.`, which let `/^.*$/` — the real
|
|
342
|
+
// PHP generator's own "no_filtering" UNSAFE sample, matching literally
|
|
343
|
+
// everything — pass as a safe guard.
|
|
344
|
+
//
|
|
345
|
+
// `isSafeEscapedChar` (governs a character immediately AFTER a
|
|
346
|
+
// backslash) — punctuation ONLY, deliberately excluding every letter and
|
|
347
|
+
// digit. An escaped letter in regex syntax is NEVER a plain literal:
|
|
348
|
+
// `\d`/`\D`/`\w`/`\W`/`\s`/`\S`/`\b`/`\B` are shorthand/boundary
|
|
349
|
+
// constructs, `\1`/`\2`/… are backreferences, and any other escaped
|
|
350
|
+
// letter could be a unicode property escape (`\p{...}`) or named
|
|
351
|
+
// backreference this parser has never heard of. An earlier version of
|
|
352
|
+
// this function reused `isSafeBareChar` (which includes letters) for the
|
|
353
|
+
// escaped-character check too, so `\D` (negated digit — matches almost
|
|
354
|
+
// anything) was accepted as though it meant a literal capital D.
|
|
355
|
+
const isSafeBareChar = (ch) => /^[a-zA-Z0-9_\-@]$/.test(ch);
|
|
356
|
+
const isSafeEscapedChar = (ch) => /^[_\-@.]$/.test(ch);
|
|
357
|
+
let i = 0;
|
|
358
|
+
const consumeQuantifier = () => {
|
|
359
|
+
if (i >= body.length) return;
|
|
360
|
+
const ch = body[i];
|
|
361
|
+
if (ch === '*' || ch === '+' || ch === '?') { i++; return; }
|
|
362
|
+
if (ch === '{') {
|
|
363
|
+
const close = body.indexOf('}', i);
|
|
364
|
+
if (close === -1) throw new Error('unterminated');
|
|
365
|
+
if (!/^\d+(,\d*)?$/.test(body.slice(i + 1, close))) throw new Error('malformed');
|
|
366
|
+
i = close + 1;
|
|
367
|
+
}
|
|
368
|
+
};
|
|
369
|
+
try {
|
|
370
|
+
while (i < body.length) {
|
|
371
|
+
const ch = body[i];
|
|
372
|
+
if (ch === '\\') {
|
|
373
|
+
const next = body[i + 1];
|
|
374
|
+
if (next === 'd' || next === 'w') { i += 2; consumeQuantifier(); continue; }
|
|
375
|
+
if (next && isSafeEscapedChar(next)) { i += 2; consumeQuantifier(); continue; }
|
|
376
|
+
return false; // \D, \W, \s, \S, \b, an escaped letter/digit, unicode escapes, or anything unrecognized
|
|
377
|
+
}
|
|
378
|
+
if (ch === '[') {
|
|
379
|
+
const close = body.indexOf(']', i);
|
|
380
|
+
if (close === -1) return false;
|
|
381
|
+
const cls = body.slice(i + 1, close);
|
|
382
|
+
if (cls.startsWith('^')) return false; // negated class — "anything except", unsafe by construction
|
|
383
|
+
let j = 0;
|
|
384
|
+
while (j < cls.length) {
|
|
385
|
+
if (cls[j] === '\\' && (cls[j + 1] === 'd' || cls[j + 1] === 'w')) { j += 2; continue; }
|
|
386
|
+
if (cls[j] === '\\' && isSafeEscapedChar(cls[j + 1])) { j += 2; continue; }
|
|
387
|
+
// Inside a character class, `.` loses its wildcard meaning and IS
|
|
388
|
+
// a literal dot even when bare — no escaping needed here, unlike
|
|
389
|
+
// outside a class.
|
|
390
|
+
if (isSafeBareChar(cls[j]) || cls[j] === '-' || cls[j] === '.') { j++; continue; }
|
|
391
|
+
return false;
|
|
392
|
+
}
|
|
393
|
+
i = close + 1;
|
|
394
|
+
consumeQuantifier();
|
|
395
|
+
continue;
|
|
396
|
+
}
|
|
397
|
+
if (isSafeBareChar(ch)) { i++; consumeQuantifier(); continue; }
|
|
398
|
+
return false; // `.`, `|`, `(`, `)`, a bare mid-pattern `^`/`$`, or any other metacharacter
|
|
399
|
+
}
|
|
400
|
+
} catch { return false; }
|
|
401
|
+
return true;
|
|
402
|
+
}
|