@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.
Files changed (51) hide show
  1. package/CHANGELOG.md +125 -0
  2. package/dist/1336.index.js +16010 -0
  3. package/dist/agentic-security.mjs +80 -80
  4. package/dist/agentic-security.mjs.sha256 +1 -1
  5. package/package.json +7 -6
  6. package/src/dataflow/CLAUDE.md +1 -1
  7. package/src/dataflow/backward.js +104 -7
  8. package/src/dataflow/catalog.js +802 -13
  9. package/src/dataflow/cross-service-taint.js +154 -11
  10. package/src/dataflow/engine.js +875 -90
  11. package/src/dataflow/exploit-prover.js +111 -23
  12. package/src/dataflow/incremental.js +13 -0
  13. package/src/dataflow/index.js +29 -6
  14. package/src/dataflow/path-feasibility.js +86 -9
  15. package/src/dataflow/string-domain.js +168 -0
  16. package/src/dataflow/summaries.js +69 -8
  17. package/src/engine.js +118 -9
  18. package/src/ir/CLAUDE.md +2 -1
  19. package/src/ir/balanced-call.js +33 -1
  20. package/src/ir/callgraph.js +26 -2
  21. package/src/ir/class-hierarchy.js +261 -7
  22. package/src/ir/csharp-ir.js +97 -8
  23. package/src/ir/index.js +21 -1
  24. package/src/ir/parser-cs.js +706 -24
  25. package/src/ir/parser-java.js +498 -17
  26. package/src/ir/parser-js.js +22 -0
  27. package/src/ir/parser-php.js +446 -43
  28. package/src/ir/parser-rust.js +1338 -0
  29. package/src/ir/php-include-merge.js +190 -0
  30. package/src/lineage/sink-registry.js +18 -0
  31. package/src/lineage/source-registry.js +23 -0
  32. package/src/posture/csharp-analysis.js +341 -43
  33. package/src/posture/finding-defaults.js +5 -1
  34. package/src/posture/fix-coverage.js +1 -0
  35. package/src/posture/proof-coverage.js +1 -0
  36. package/src/report/index.js +12 -0
  37. package/src/sast/CLAUDE.md +1 -1
  38. package/src/sast/authz.js +144 -1
  39. package/src/sast/crypto-protocol.js +65 -7
  40. package/src/sast/csharp-cross-file.js +125 -0
  41. package/src/sast/csharp-structural.js +78 -2
  42. package/src/sast/csharp.js +574 -42
  43. package/src/sast/java-ast-folding.js +268 -11
  44. package/src/sast/java-bench-extras.js +530 -2
  45. package/src/sast/java-deserialization.js +31 -2
  46. package/src/sast/java-structural-cross-file.js +146 -0
  47. package/src/sast/java-structural.js +499 -6
  48. package/src/sast/ldap-injection.js +133 -3
  49. package/src/sast/rust.js +57 -0
  50. package/src/sast/xss-reflected-multilang.js +252 -3
  51. 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
- // Walk the trace/chain for sanitizer regex constraints. The `string-domain`
94
- // produces a regex abstract value when known sanitizers are on the path;
95
- // the soft-taint table independently labels sanitizers. For v1 we check
96
- // the chain entries against the known sanitizer-output regexes.
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
- const tail = String(callee).split('.').pop();
118
- const table = {
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
  };
@@ -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 { classHierarchy = buildClassHierarchy(perFileIR); } catch { classHierarchy = null; }
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
- if (process.env.AGENTIC_SECURITY_BACKWARD_SLICE === '1') {
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 evalConst(expr) {
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
+ }