@clear-capabilities/agentic-security-scanner 0.151.3 → 0.152.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 +71 -0
- package/dist/agentic-security.mjs +80 -80
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +5 -5
- package/src/dataflow/CLAUDE.md +1 -1
- package/src/dataflow/catalog.js +802 -13
- package/src/dataflow/engine.js +852 -89
- package/src/dataflow/incremental.js +13 -0
- package/src/dataflow/index.js +18 -5
- package/src/dataflow/path-feasibility.js +86 -9
- package/src/dataflow/string-domain.js +132 -0
- package/src/dataflow/summaries.js +69 -8
- package/src/engine.js +108 -3
- 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 +101 -6
- 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/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
|
@@ -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);
|
|
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)`).
|
|
@@ -237,4 +250,4 @@ export function runDeepAnalysis(perFileIR, callGraph, opts = {}) {
|
|
|
237
250
|
return findings;
|
|
238
251
|
}
|
|
239
252
|
|
|
240
|
-
export { runTaintEngine, CATALOG, matchSource, matchSinkOrSanitizer, _catalogSize, applyPathFeasibility, SummaryCache, entryStateFromCall, rhsReachableFunctions, shouldAnalyzeUnderRhs, annotateBackwardSlices };
|
|
253
|
+
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) {
|
|
@@ -232,3 +232,135 @@ export function isSafeRedirectTarget(absVal, allowedHosts) {
|
|
|
232
232
|
export function hashAbstract(absVal) {
|
|
233
233
|
return render(absVal);
|
|
234
234
|
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Next-gen taint capability #2 — regex-VALIDATION guard safety.
|
|
238
|
+
*
|
|
239
|
+
* A DIFFERENT question from `provablyMatches` above (which asks "does this
|
|
240
|
+
* already-known abstract value fit a safety regex"): here we're asked "is
|
|
241
|
+
* this REGEX PATTERN, used as a `pattern.test(x)`-style validation guard,
|
|
242
|
+
* restrictive enough that observing it MATCH on the true branch proves `x`
|
|
243
|
+
* cannot carry injection metacharacters for ANY family?" This is the JS/TS
|
|
244
|
+
* analog of PHP's `if (preg_match($re, $x) == 1) { ... }` idiom — found to
|
|
245
|
+
* be a genuine, real-world-common pattern with NO recognition anywhere in
|
|
246
|
+
* this engine (neither the name-based `GUARD_PREDICATES` in engine.js, nor
|
|
247
|
+
* this file's own `abstract()`/`SANITIZER_OUTPUT_REGEX`, which characterizes
|
|
248
|
+
* a SANITIZER CALL's output, not a validation-guard's CONDITION).
|
|
249
|
+
*
|
|
250
|
+
* Deliberately a conservative ALLOWLIST parser, not a full regex-engine
|
|
251
|
+
* subset check (undecidable in general): walks the pattern character by
|
|
252
|
+
* character, permitting only literal safe characters (letters, digits, and
|
|
253
|
+
* a short vetted punctuation set), `\d`/`\w` shorthand classes, and
|
|
254
|
+
* bracket character classes built from the same safe set — with
|
|
255
|
+
* quantifiers on any of the above. Anything it cannot fully account for
|
|
256
|
+
* (alternation, groups, wildcards, negated classes `[^...]`, negated
|
|
257
|
+
* shorthand `\D`/`\W`/`\s`/`\S`, backreferences, lookaround, unicode
|
|
258
|
+
* property escapes, an unescaped stray metacharacter) REJECTS rather than
|
|
259
|
+
* guesses — a false "safe" here would silently suppress a real,
|
|
260
|
+
* exploitable finding, which is a much worse failure mode than missing a
|
|
261
|
+
* genuinely-safe-but-unrecognized pattern (the existing, unchanged taint
|
|
262
|
+
* flow still fires in that case, exactly as it did before this capability
|
|
263
|
+
* existed).
|
|
264
|
+
*
|
|
265
|
+
* Also requires full anchoring (`^...$`) and rejects the `m` (multiline)
|
|
266
|
+
* flag: with `m` set, `^`/`$` match at LINE boundaries rather than string
|
|
267
|
+
* boundaries, so a multi-line input could have a safe first line and an
|
|
268
|
+
* unsafe second line while still matching `^safe$` against line one —
|
|
269
|
+
* silently defeating the "matched, therefore the WHOLE value is safe"
|
|
270
|
+
* guarantee this function exists to provide.
|
|
271
|
+
*/
|
|
272
|
+
export function isSafeValidationPattern(pattern) {
|
|
273
|
+
if (!(pattern instanceof RegExp)) return false;
|
|
274
|
+
if (pattern.flags && pattern.flags.includes('m')) return false;
|
|
275
|
+
const source = pattern.source;
|
|
276
|
+
if (!source.startsWith('^') || !source.endsWith('$')) return false;
|
|
277
|
+
const body = source.slice(1, -1);
|
|
278
|
+
if (!body) return false; // `^$` (empty-string-only) is degenerate, not a useful guard
|
|
279
|
+
// Two DISTINCT allowlists, deliberately not merged into one — conflating
|
|
280
|
+
// them is exactly the bug this comment now documents (caught by this
|
|
281
|
+
// function's own adversarial unit tests before ever reaching production):
|
|
282
|
+
//
|
|
283
|
+
// `isSafeBareChar` — characters with NO special regex meaning when they
|
|
284
|
+
// appear UNESCAPED. Critically, `.` (the wildcard metacharacter — matches
|
|
285
|
+
// ANY character) is NOT in this set: an earlier version of this function
|
|
286
|
+
// included `.` here on the (wrong) assumption that it's "just punctuation,"
|
|
287
|
+
// which caused `/^.*$/` — the real PHP generator's OWN "no_filtering"
|
|
288
|
+
// unsafe sample, matching literally everything — to be accepted as a safe
|
|
289
|
+
// guard. A bare `.` in a pattern is ALWAYS the wildcard; a literal dot
|
|
290
|
+
// must be escaped (`\.`) or appear inside a character class (`[.]`),
|
|
291
|
+
// both handled by the escaped/class-content paths below.
|
|
292
|
+
//
|
|
293
|
+
// `isSafeEscapedPunct` — punctuation that, when ESCAPED with a backslash,
|
|
294
|
+
// unambiguously means "match this literal character." Deliberately
|
|
295
|
+
// EXCLUDES every letter and digit: an escaped letter in regex syntax is
|
|
296
|
+
// Two DELIBERATELY SEPARATE allowlists, keyed by context, not merged into
|
|
297
|
+
// one — conflating them is the exact bug class this function's own
|
|
298
|
+
// adversarial unit tests caught twice while writing it, both fixed here:
|
|
299
|
+
//
|
|
300
|
+
// `isSafeBareChar` (governs an UNESCAPED character, bare or inside a
|
|
301
|
+
// class) — letters, digits, underscore, hyphen, at-sign: characters with
|
|
302
|
+
// NO special regex meaning at all when unescaped. Crucially this does
|
|
303
|
+
// NOT include `.` — a bare `.` outside a class is the wildcard
|
|
304
|
+
// metacharacter (matches ANY character), not punctuation. An earlier
|
|
305
|
+
// version of this allowlist included `.`, which let `/^.*$/` — the real
|
|
306
|
+
// PHP generator's own "no_filtering" UNSAFE sample, matching literally
|
|
307
|
+
// everything — pass as a safe guard.
|
|
308
|
+
//
|
|
309
|
+
// `isSafeEscapedChar` (governs a character immediately AFTER a
|
|
310
|
+
// backslash) — punctuation ONLY, deliberately excluding every letter and
|
|
311
|
+
// digit. An escaped letter in regex syntax is NEVER a plain literal:
|
|
312
|
+
// `\d`/`\D`/`\w`/`\W`/`\s`/`\S`/`\b`/`\B` are shorthand/boundary
|
|
313
|
+
// constructs, `\1`/`\2`/… are backreferences, and any other escaped
|
|
314
|
+
// letter could be a unicode property escape (`\p{...}`) or named
|
|
315
|
+
// backreference this parser has never heard of. An earlier version of
|
|
316
|
+
// this function reused `isSafeBareChar` (which includes letters) for the
|
|
317
|
+
// escaped-character check too, so `\D` (negated digit — matches almost
|
|
318
|
+
// anything) was accepted as though it meant a literal capital D.
|
|
319
|
+
const isSafeBareChar = (ch) => /^[a-zA-Z0-9_\-@]$/.test(ch);
|
|
320
|
+
const isSafeEscapedChar = (ch) => /^[_\-@.]$/.test(ch);
|
|
321
|
+
let i = 0;
|
|
322
|
+
const consumeQuantifier = () => {
|
|
323
|
+
if (i >= body.length) return;
|
|
324
|
+
const ch = body[i];
|
|
325
|
+
if (ch === '*' || ch === '+' || ch === '?') { i++; return; }
|
|
326
|
+
if (ch === '{') {
|
|
327
|
+
const close = body.indexOf('}', i);
|
|
328
|
+
if (close === -1) throw new Error('unterminated');
|
|
329
|
+
if (!/^\d+(,\d*)?$/.test(body.slice(i + 1, close))) throw new Error('malformed');
|
|
330
|
+
i = close + 1;
|
|
331
|
+
}
|
|
332
|
+
};
|
|
333
|
+
try {
|
|
334
|
+
while (i < body.length) {
|
|
335
|
+
const ch = body[i];
|
|
336
|
+
if (ch === '\\') {
|
|
337
|
+
const next = body[i + 1];
|
|
338
|
+
if (next === 'd' || next === 'w') { i += 2; consumeQuantifier(); continue; }
|
|
339
|
+
if (next && isSafeEscapedChar(next)) { i += 2; consumeQuantifier(); continue; }
|
|
340
|
+
return false; // \D, \W, \s, \S, \b, an escaped letter/digit, unicode escapes, or anything unrecognized
|
|
341
|
+
}
|
|
342
|
+
if (ch === '[') {
|
|
343
|
+
const close = body.indexOf(']', i);
|
|
344
|
+
if (close === -1) return false;
|
|
345
|
+
const cls = body.slice(i + 1, close);
|
|
346
|
+
if (cls.startsWith('^')) return false; // negated class — "anything except", unsafe by construction
|
|
347
|
+
let j = 0;
|
|
348
|
+
while (j < cls.length) {
|
|
349
|
+
if (cls[j] === '\\' && (cls[j + 1] === 'd' || cls[j + 1] === 'w')) { j += 2; continue; }
|
|
350
|
+
if (cls[j] === '\\' && isSafeEscapedChar(cls[j + 1])) { j += 2; continue; }
|
|
351
|
+
// Inside a character class, `.` loses its wildcard meaning and IS
|
|
352
|
+
// a literal dot even when bare — no escaping needed here, unlike
|
|
353
|
+
// outside a class.
|
|
354
|
+
if (isSafeBareChar(cls[j]) || cls[j] === '-' || cls[j] === '.') { j++; continue; }
|
|
355
|
+
return false;
|
|
356
|
+
}
|
|
357
|
+
i = close + 1;
|
|
358
|
+
consumeQuantifier();
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
if (isSafeBareChar(ch)) { i++; consumeQuantifier(); continue; }
|
|
362
|
+
return false; // `.`, `|`, `(`, `)`, a bare mid-pattern `^`/`$`, or any other metacharacter
|
|
363
|
+
}
|
|
364
|
+
} catch { return false; }
|
|
365
|
+
return true;
|
|
366
|
+
}
|
|
@@ -91,9 +91,26 @@ export class SummaryCache {
|
|
|
91
91
|
_key(qid, taintedParams, receiverType) {
|
|
92
92
|
// P1.2: when a receiver type is provided, extend the cache key with
|
|
93
93
|
// its hash. Backward-compatible: no receiverType → same key as before.
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
94
|
+
const stateHash = _hashState(taintedParams);
|
|
95
|
+
let base = `${qid}::${stateHash}`;
|
|
96
|
+
// R2: append the caller qid when call-string sensitivity is enabled —
|
|
97
|
+
// but ONLY for a NON-EMPTY entry state. The EMPTY-entry baseline is the
|
|
98
|
+
// one summary `engine.js`'s outer fixed-point pre-pass loop shares and
|
|
99
|
+
// progressively refines across BOTH multiple passes AND every call path
|
|
100
|
+
// that reaches a function with no tainted args — splitting it by caller
|
|
101
|
+
// breaks that sharing outright: a 3-hop empty-entry mutation chain
|
|
102
|
+
// (leaf mutates its own param unconditionally, mid calls leaf, top calls
|
|
103
|
+
// mid) stopped propagating entirely once enabled, confirmed via direct
|
|
104
|
+
// instrumentation, not a hypothetical concern — the pre-pass loop's own
|
|
105
|
+
// `bMid`/`cLeaf` iterations write to a caller-LESS key nothing else ever
|
|
106
|
+
// reads once a NESTED call from a different caller starts writing to a
|
|
107
|
+
// caller-SUFFIXED key instead. A non-empty entry state has no such
|
|
108
|
+
// shared-convergence dependency to begin with — the value-context cache
|
|
109
|
+
// (`AGENTIC_SECURITY_KCFA_MAX_CONTEXTS`) already computes each distinct
|
|
110
|
+
// tainted-arg shape independently, lazily, per call site, so adding a
|
|
111
|
+
// caller dimension there only ever narrows an already-isolated lookup,
|
|
112
|
+
// never disconnects a shared one.
|
|
113
|
+
if (this._callString && this._callerCtx && stateHash !== 'empty') base = `${base}@${this._callerCtx}`;
|
|
97
114
|
if (!receiverType) return base;
|
|
98
115
|
return `${base}::${hashReceiverType(receiverType)}`;
|
|
99
116
|
}
|
|
@@ -103,7 +120,8 @@ export class SummaryCache {
|
|
|
103
120
|
}
|
|
104
121
|
|
|
105
122
|
set(qid, taintedParams, summary, receiverType) {
|
|
106
|
-
this.
|
|
123
|
+
const k = this._key(qid, taintedParams, receiverType);
|
|
124
|
+
this._cache.set(k, summary);
|
|
107
125
|
}
|
|
108
126
|
|
|
109
127
|
has(qid, taintedParams, receiverType) {
|
|
@@ -172,8 +190,16 @@ export class SummaryCache {
|
|
|
172
190
|
// Helper: apply a summary to a caller's taint state given the call site's
|
|
173
191
|
// argument bindings. Returns { calleeReturnTainted, mutated: Set of caller-side
|
|
174
192
|
// var names that should become tainted because the callee mutated them }.
|
|
175
|
-
|
|
176
|
-
|
|
193
|
+
// `receiverPath` (optional, next-gen taint capability #3): the caller-side
|
|
194
|
+
// access path of a receiver-qualified call's OWN receiver (`bad` for
|
|
195
|
+
// `bad.setData(x)`). When present and `summary.mutatedThisFields` is
|
|
196
|
+
// non-empty, also returns `mutatedThisPaths` — the receiver's own field
|
|
197
|
+
// paths (`bad.data`) to taint in the caller, mirroring `mutated` above but
|
|
198
|
+
// for the implicit `this` parameter instead of a declared one. Omitting
|
|
199
|
+
// `receiverPath` (every pre-existing call site) leaves `mutatedThisPaths`
|
|
200
|
+
// empty, so this is purely additive for callers that don't pass it.
|
|
201
|
+
applyAtCallSite(summary, paramNames, callArgs, callerTaintedVars, receiverPath) {
|
|
202
|
+
if (!summary) return { returnTainted: false, mutated: new Set(), mutatedThisPaths: new Set() };
|
|
177
203
|
const mutated = new Set();
|
|
178
204
|
if (summary.mutatedParams && summary.mutatedParams.size) {
|
|
179
205
|
// Map each mutated parameter position back to the caller-side argument name.
|
|
@@ -184,7 +210,11 @@ export class SummaryCache {
|
|
|
184
210
|
if (arg && arg.kind === 'ident') mutated.add(arg.name);
|
|
185
211
|
}
|
|
186
212
|
}
|
|
187
|
-
|
|
213
|
+
const mutatedThisPaths = new Set();
|
|
214
|
+
if (receiverPath && summary.mutatedThisFields && summary.mutatedThisFields.size) {
|
|
215
|
+
for (const field of summary.mutatedThisFields) mutatedThisPaths.add(`${receiverPath}.${field}`);
|
|
216
|
+
}
|
|
217
|
+
return { returnTainted: !!summary.returnTainted, mutated, mutatedThisPaths };
|
|
188
218
|
}
|
|
189
219
|
|
|
190
220
|
size() { return this._cache.size; }
|
|
@@ -208,6 +238,16 @@ function _summaryEq(a, b) {
|
|
|
208
238
|
const bm = b.mutatedParams || new Set();
|
|
209
239
|
if (am.size !== bm.size) return false;
|
|
210
240
|
for (const p of am) if (!bm.has(p)) return false;
|
|
241
|
+
// Next-gen taint capability #3's `mutatedThisFields` is a THIRD summary
|
|
242
|
+
// field alongside `returnTainted`/`mutatedParams` — deliberately compared
|
|
243
|
+
// here too, not left out, per this function's own header comment above:
|
|
244
|
+
// omitting a field from this equality check is exactly how a fresher,
|
|
245
|
+
// more-refined summary can be silently discarded by the fixed-point loop
|
|
246
|
+
// while still being returned to its immediate caller.
|
|
247
|
+
const atf = a.mutatedThisFields || new Set();
|
|
248
|
+
const btf = b.mutatedThisFields || new Set();
|
|
249
|
+
if (atf.size !== btf.size) return false;
|
|
250
|
+
for (const f of atf) if (!btf.has(f)) return false;
|
|
211
251
|
return true;
|
|
212
252
|
}
|
|
213
253
|
|
|
@@ -244,10 +284,31 @@ export function entryStateFromCall(paramNames, callArgs, callerTaintedVars, isAr
|
|
|
244
284
|
// ASSIGNED locals, not raw globals) took the member branch, found
|
|
245
285
|
// nothing, and — being an else-if — never reached isArgTaintedExpr at
|
|
246
286
|
// all. Confirmed via a real corpus fixture.
|
|
287
|
+
// PRD W2 (SARD_80_F1_EXECUTION_PRD.md) — an object argument whose FIELD
|
|
288
|
+
// was tainted before the call (`c.field = tainted; step(c);`) is a
|
|
289
|
+
// documented scope gap: entry-state granularity here is param-level,
|
|
290
|
+
// not per-access-path, so `c` itself was never marked tainted even
|
|
291
|
+
// though `c.field` genuinely is in the caller's field-sensitive lattice
|
|
292
|
+
// — the callee then reads `c.field` back out with no provenance at all.
|
|
293
|
+
// Found sampling real Juliet variant 67 false negatives (a tainted
|
|
294
|
+
// value stored into a `Container` object's field, the object passed
|
|
295
|
+
// whole to another function, the field read back out there). Widens
|
|
296
|
+
// the SAME direction this codebase already takes for "unknown-key
|
|
297
|
+
// container writes" and collection taint (dataflow/CLAUDE.md's own
|
|
298
|
+
// documented precedent): if ANY access path prefixed by this
|
|
299
|
+
// identifier is tainted, treat the whole object as tainted when it
|
|
300
|
+
// crosses a call boundary — over-approximating (a clean sibling field
|
|
301
|
+
// read downstream may falsely inherit taint) is the accepted direction
|
|
302
|
+
// for a security scanner, the same tradeoff every other "widen to the
|
|
303
|
+
// container" rule in this codebase already makes.
|
|
247
304
|
let tainted = false;
|
|
248
305
|
if (arg.kind === 'ident' && callerTaintedVars.has(arg.name)) {
|
|
249
306
|
tainted = true;
|
|
250
|
-
} else if (arg.kind === '
|
|
307
|
+
} else if (arg.kind === 'ident') {
|
|
308
|
+
const prefix = `${arg.name}.`;
|
|
309
|
+
for (const v of callerTaintedVars) { if (v.startsWith(prefix)) { tainted = true; break; } }
|
|
310
|
+
}
|
|
311
|
+
if (!tainted && arg.kind === 'member' && arg.object?.kind === 'ident') {
|
|
251
312
|
const base = arg.object.name;
|
|
252
313
|
if (callerTaintedVars.has(base) || callerTaintedVars.has(`${base}.${arg.prop}`)) tainted = true;
|
|
253
314
|
}
|