@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
|
@@ -8,14 +8,470 @@
|
|
|
8
8
|
// guarded URLs do not match, keeping this high-precision.
|
|
9
9
|
|
|
10
10
|
import { blankComments } from './_comment-strip.js';
|
|
11
|
+
import { deadBranchRanges, isLineInDeadRange } from './java-ast-folding.js';
|
|
11
12
|
|
|
12
13
|
// Concat-into-sink families with no guard needed (parameterized form has no
|
|
13
|
-
// string-concat argument, so it auto-clears).
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
14
|
+
// string-concat argument, so it auto-clears). Each pattern additionally
|
|
15
|
+
// captures a trailing `+ identifier)` / `+ identifier;` tail — the SINGLE,
|
|
16
|
+
// LAST concatenated term, when the concatenation ends right there — so the
|
|
17
|
+
// caller can check whether that one identifier is PROVABLY a hardcoded
|
|
18
|
+
// literal (see `_trailingIdentIsLiteral` below). Juliet's own convention
|
|
19
|
+
// (confirmed against the public Java mirror's CWE-89 execute()/executeQuery()
|
|
20
|
+
// variants) keeps the IDENTICAL sink code in both bad() and goodG2B(), only
|
|
21
|
+
// swapping the source of one bare local variable (`data = System.getenv(…)`
|
|
22
|
+
// vs. `data = "foo"`) — this taint-independent detector, having no taint
|
|
23
|
+
// model of its own, previously could not tell the two apart and fired on
|
|
24
|
+
// both, a large fraction of this family's real corpus false positives.
|
|
25
|
+
// Exported for java-structural-cross-file.js (W5.41) — the SAME sink
|
|
26
|
+
// matching used to re-derive candidate findings when checking whether a
|
|
27
|
+
// sibling-file caller resolves the concatenated variable to a literal.
|
|
28
|
+
export const RE = {
|
|
29
|
+
sqlInjection: /\b(?:executeQuery|executeUpdate|execute|createQuery|createNativeQuery|prepareStatement|prepareCall)\s*\(\s*"[^"\n]*"\s*\+(?:\s*([A-Za-z_]\w*)\s*(?=(?:\s*\+\s*"[^"\n]*"\s*)?[);]))?/g,
|
|
30
|
+
cmdInjection: /\b(?:Runtime\.getRuntime\(\)\s*\.\s*exec|ProcessBuilder)\s*\(\s*(?:new\s+String\s*\[\s*\]\s*\{\s*)?"[^"\n]*"\s*\+(?:\s*([A-Za-z_]\w*)\s*(?=(?:\s*\+\s*"[^"\n]*"\s*)?[);]))?/g,
|
|
17
31
|
};
|
|
18
32
|
|
|
33
|
+
// True when `varName`'s NEAREST assignment before `beforeIdx` (source order)
|
|
34
|
+
// is a plain string-literal RHS — same "backward nearest-assignment" shape
|
|
35
|
+
// as java-bench-extras.js's `_nearestAssignIsLiteral` (CWE-259), reused here
|
|
36
|
+
// for the opposite purpose: SUPPRESSING a structural finding instead of
|
|
37
|
+
// creating one. Deliberately narrow: only fires when the regex above
|
|
38
|
+
// captured a SINGLE trailing identifier immediately before the sink call's
|
|
39
|
+
// closing `)`/`;` — a concatenation with more terms after it
|
|
40
|
+
// (`"…" + a + b`) is left alone, since a safe `a` says nothing about `b`.
|
|
41
|
+
//
|
|
42
|
+
// SARD_80_F1 W4.J35 — the SAME dead-code-blindness bug already fixed twice
|
|
43
|
+
// this session in sibling files (W4.J33's `java-bench-extras.js`
|
|
44
|
+
// `_nearestAssignIsLiteral`; W4.J34's own `deadBranchRanges` extension),
|
|
45
|
+
// found here independently while sweeping Java's largest CWE-89 fp bucket
|
|
46
|
+
// (300+ files, all `_02`..`_51b`-numbered `connect_tcp_executeUpdate_XX`
|
|
47
|
+
// variants sharing this ONE detector). Confirmed via the public Java Juliet
|
|
48
|
+
// mirror's own `CWE89_SQL_Injection__connect_tcp_executeUpdate_02.java`:
|
|
49
|
+
// `goodG2B2()` does `if (true) { data = "foo"; } else { data = null; }`
|
|
50
|
+
// immediately before the identical `executeUpdate("…"+data+"'")` sink line
|
|
51
|
+
// `bad()` also uses — the textually-LATER dead `data = null;` in the else
|
|
52
|
+
// branch made `lastAnyEnd` land there instead of on the live literal
|
|
53
|
+
// assignment, so `lastLiteralEnd === lastAnyEnd` failed and this entire
|
|
54
|
+
// flow-variant family (Juliet's own Flow Variant 02, used across dozens of
|
|
55
|
+
// CWEs per W4.C28's original C#-side discovery of the same idiom) was never
|
|
56
|
+
// recognized as literal-only. Fixed by reusing the ALREADY-EXISTING,
|
|
57
|
+
// AST-based `deadBranchRanges`/`isLineInDeadRange` (the SAME mechanism
|
|
58
|
+
// W4.J33 already wired into `java-bench-extras.js`, and which W4.J34
|
|
59
|
+
// separately extended to also resolve `private static final` field
|
|
60
|
+
// constants — this fix inherits BOTH capabilities for free) to skip an
|
|
61
|
+
// assignment whose line falls inside a provably-dead branch when computing
|
|
62
|
+
// `lastAnyEnd`.
|
|
63
|
+
// SARD_80_F1 W5.23 — ports java-bench-extras.js's W4.J37/W4.J38 cross-method
|
|
64
|
+
// literal resolution (same-file callee-return, same-method copy-chain,
|
|
65
|
+
// argument-passing via all call sites) into this file's OWN independent copy
|
|
66
|
+
// of the "nearest assignment" heuristic. Confirmed via a real-corpus sweep
|
|
67
|
+
// that CWE-89's own remaining fp bucket includes an even ~11-file share for
|
|
68
|
+
// EACH of Juliet's Flow Variant 31 (copy)/41 (argument)/42 (return) suffixes,
|
|
69
|
+
// the exact three idioms java-bench-extras.js's CWE-259/601 detectors already
|
|
70
|
+
// needed this same fix for — this file never received the port. Kept as a
|
|
71
|
+
// SEPARATE copy rather than a shared import, matching this file's own
|
|
72
|
+
// established convention (see `_trailingIdentIsLiteral`'s header comment)
|
|
73
|
+
// of independently-tuned duplicates rather than a cross-file dependency.
|
|
74
|
+
// Regexes below are copied VERBATIM in their already-ReDoS-fixed form (see
|
|
75
|
+
// java-bench-extras.js's own W4.J39 fix comments) — never reintroduce a
|
|
76
|
+
// lazy, `\s`-inclusive "return type" prefix match; neither function below
|
|
77
|
+
// ever reads that prefix, so it was never needed for correctness.
|
|
78
|
+
const _CALLEE_RETURN_LITERAL_CACHE_DEPTH = 4;
|
|
79
|
+
|
|
80
|
+
function _resolveCalleeReturnIsLiteral(content, calleeName, deadRanges, _depth) {
|
|
81
|
+
const depth = _depth || 0;
|
|
82
|
+
if (depth >= _CALLEE_RETURN_LITERAL_CACHE_DEPTH) return false;
|
|
83
|
+
const escapedCallee = calleeName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
84
|
+
const declRe = new RegExp(`\\b${escapedCallee}\\s*\\([^)]*\\)\\s*(?:throws\\s+[\\w.,\\s]+)?\\s*\\{`, 'g');
|
|
85
|
+
const dm = declRe.exec(content);
|
|
86
|
+
if (!dm) return false;
|
|
87
|
+
const bodyStart = dm.index + dm[0].length;
|
|
88
|
+
let braceDepth = 1, i = bodyStart;
|
|
89
|
+
while (i < content.length && braceDepth > 0) {
|
|
90
|
+
if (content[i] === '{') braceDepth++;
|
|
91
|
+
else if (content[i] === '}') braceDepth--;
|
|
92
|
+
i++;
|
|
93
|
+
}
|
|
94
|
+
const body = content.slice(bodyStart, i - 1);
|
|
95
|
+
const returnRe = /\breturn\s+([^;]+);/g;
|
|
96
|
+
const returns = [];
|
|
97
|
+
let rm;
|
|
98
|
+
while ((rm = returnRe.exec(body))) returns.push({ expr: rm[1].trim(), idx: bodyStart + rm.index });
|
|
99
|
+
if (returns.length !== 1) return false;
|
|
100
|
+
const { expr, idx } = returns[0];
|
|
101
|
+
if (/^"[^"]*"$/.test(expr)) return true;
|
|
102
|
+
if (/^[A-Za-z_]\w*$/.test(expr)) {
|
|
103
|
+
return _trailingIdentIsLiteral(content, expr, idx, deadRanges, depth + 1);
|
|
104
|
+
}
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function _splitTopLevelCommas(text) {
|
|
109
|
+
const parts = [];
|
|
110
|
+
let depth = 0, cur = '', inStr = null;
|
|
111
|
+
for (let i = 0; i < text.length; i++) {
|
|
112
|
+
const c = text[i];
|
|
113
|
+
if (inStr) {
|
|
114
|
+
cur += c;
|
|
115
|
+
if (c === '\\') { cur += text[++i] || ''; continue; }
|
|
116
|
+
if (c === inStr) inStr = null;
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (c === '"' || c === "'") { inStr = c; cur += c; continue; }
|
|
120
|
+
if (c === '(' || c === '[' || c === '{') depth++;
|
|
121
|
+
else if (c === ')' || c === ']' || c === '}') depth--;
|
|
122
|
+
if (c === ',' && depth === 0) { parts.push(cur); cur = ''; continue; }
|
|
123
|
+
cur += c;
|
|
124
|
+
}
|
|
125
|
+
if (cur.trim() !== '' || parts.length) parts.push(cur);
|
|
126
|
+
return parts;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// SARD_80_F1 W5.41 — Juliet's own Flow Variant 51+ ("data passed as an
|
|
130
|
+
// argument from one method to another in a DIFFERENT class, in the same or
|
|
131
|
+
// a different package") splits this idiom's caller and callee across TWO
|
|
132
|
+
// PHYSICAL FILES living in the SAME DIRECTORY (confirmed via W5.40's direct
|
|
133
|
+
// fetch of the public mirror's own `CWE89_SQL_Injection__
|
|
134
|
+
// database_executeQuery_51a.java`/`_51b.java` pair — see that ledger entry
|
|
135
|
+
// for the full investigation) — the same-file-only search below can never
|
|
136
|
+
// see a caller that lives in a sibling file, since `content` is always just
|
|
137
|
+
// the one file being scanned. `siblingFiles` (optional; an array of
|
|
138
|
+
// `{code, deadRanges}` for OTHER `.java` files in the same directory —
|
|
139
|
+
// each with its OWN correctly-computed deadRanges, since a dead-branch
|
|
140
|
+
// range computed for THIS file's line numbers would be meaningless applied
|
|
141
|
+
// to a different file's text) is a fallback search space consulted ONLY
|
|
142
|
+
// when the same-file search finds ZERO real call sites at all for this
|
|
143
|
+
// method name — a real, non-literal call site in THIS file remains
|
|
144
|
+
// authoritative and siblingFiles is never consulted, preserving this
|
|
145
|
+
// function's own pre-existing "fail closed on any real caller passing a
|
|
146
|
+
// non-literal" discipline exactly. Only `scanJavaStructural`'s own ordinary
|
|
147
|
+
// per-file call never supplies this (so its behavior is byte-identical to
|
|
148
|
+
// before this change) — it is supplied exclusively by the dedicated
|
|
149
|
+
// project-wide suppression pass, `java-structural-cross-file.js`.
|
|
150
|
+
// The declared class name of `content` itself — Juliet's own universal
|
|
151
|
+
// one-public-class-per-file convention (confirmed across every fetched
|
|
152
|
+
// corpus file this session), used ONLY to disambiguate a cross-file call
|
|
153
|
+
// site (see below); irrelevant to same-file resolution, which never needs
|
|
154
|
+
// it since a bare, unqualified call in the same file is unambiguous already.
|
|
155
|
+
function _ownClassName(content) {
|
|
156
|
+
const m = /\bclass\s+([A-Za-z_]\w*)/.exec(content);
|
|
157
|
+
return m ? m[1] : null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// The smallest method body span (character offsets, `{`..matching `}`)
|
|
161
|
+
// enclosing `idx` — used to bound a cross-file variable's own call search to
|
|
162
|
+
// its DECLARING method only (see W5.45's own call site for why: Juliet
|
|
163
|
+
// routinely reuses the same local variable name across sibling methods in
|
|
164
|
+
// one file, e.g. `bad()`/`goodG2B()`/`goodB2G()` each declaring their own
|
|
165
|
+
// `baseObject`). Returns null if no enclosing method is found or `idx` falls
|
|
166
|
+
// in a gap between methods (a same-name field/local outside any method).
|
|
167
|
+
function _enclosingMethodSpan(text, idx) {
|
|
168
|
+
const methodDeclRe = /\b(?!if\b|for\b|while\b|switch\b|catch\b|synchronized\b|do\b|else\b|return\b|new\b)([A-Za-z_]\w*)\s*\([^)]*\)\s*(?:throws\s+[\w.,\s]+)?\s*\{/g;
|
|
169
|
+
let best = null, m;
|
|
170
|
+
while ((m = methodDeclRe.exec(text)) && m.index < idx) best = m;
|
|
171
|
+
if (!best) return null;
|
|
172
|
+
const openIdx = best.index + best[0].length - 1;
|
|
173
|
+
let depth = 1, i = openIdx + 1;
|
|
174
|
+
while (i < text.length && depth > 0) {
|
|
175
|
+
if (text[i] === '{') depth++;
|
|
176
|
+
else if (text[i] === '}') depth--;
|
|
177
|
+
i++;
|
|
178
|
+
}
|
|
179
|
+
if (i <= idx) return null; // the nearest preceding method already closed before idx — not actually enclosing
|
|
180
|
+
return { start: openIdx, end: i, name: best[1] };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function _resolveParamLiteralViaAllCallSites(content, varName, beforeIdx, deadRanges, _depth, siblingFiles) {
|
|
184
|
+
const depth0 = _depth || 0;
|
|
185
|
+
if (depth0 >= _CALLEE_RETURN_LITERAL_CACHE_DEPTH) return false;
|
|
186
|
+
const escapedVar = varName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
187
|
+
const methodDeclRe = /\b(?!if\b|for\b|while\b|switch\b|catch\b|synchronized\b|do\b|else\b|return\b|new\b)([A-Za-z_]\w*)\s*\(([^)]*)\)\s*(?:throws\s+[\w.,\s]+)?\s*\{/g;
|
|
188
|
+
let enclosing = null, dm;
|
|
189
|
+
while ((dm = methodDeclRe.exec(content)) && dm.index < beforeIdx) enclosing = dm;
|
|
190
|
+
if (!enclosing) return false;
|
|
191
|
+
const methodName = enclosing[1];
|
|
192
|
+
const params = _splitTopLevelCommas(enclosing[2]).map(p => p.trim()).filter(Boolean);
|
|
193
|
+
const paramIdx = params.findIndex((p) => new RegExp(`\\b${escapedVar}$`).test(p));
|
|
194
|
+
if (paramIdx === -1) return false;
|
|
195
|
+
const escapedMethod = methodName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
196
|
+
|
|
197
|
+
// `classNameFilter` (only ever set for a SIBLING-file search — see below)
|
|
198
|
+
// requires the match to be a `(new ClassName()).method(` / `new
|
|
199
|
+
// ClassName().method(` construction naming THIS sink's own declared
|
|
200
|
+
// class, not just any call to a method sharing this method's bare name.
|
|
201
|
+
// Juliet reuses the SAME generic method names (bad/badSink/goodG2B/
|
|
202
|
+
// goodG2BSink/goodB2G/goodB2GSink/…) identically across thousands of
|
|
203
|
+
// otherwise-unrelated flow-variant files, and `--scramble-identifiers`
|
|
204
|
+
// preserves this collision BY DESIGN (the same original word always
|
|
205
|
+
// hashes to the same opaque token, everywhere) — confirmed the hard way
|
|
206
|
+
// via W5.41's own real-corpus debugging: a bare method-name search across
|
|
207
|
+
// a directory holding ~980 sibling files found hundreds of unrelated
|
|
208
|
+
// classes' own same-named methods, and picking up even ONE unrelated
|
|
209
|
+
// caller with a different arity or a genuinely different argument value
|
|
210
|
+
// wrongly failed this check closed for the ACTUAL intended pair. Requiring
|
|
211
|
+
// the class name too disambiguates correctly, since Juliet's own
|
|
212
|
+
// `CWEnnn_Descriptor__variant` → `case_<hash>` class-name scrambling is
|
|
213
|
+
// ALSO consistent per distinct original name (a DIFFERENT class name in a
|
|
214
|
+
// DIFFERENT file hashes to a DIFFERENT token). Same-file resolution never
|
|
215
|
+
// sets this — an unqualified call within the declaring file itself is
|
|
216
|
+
// already unambiguous.
|
|
217
|
+
function _checkCallAt(searchText, searchDeadRanges, matchIndex, matchEnd) {
|
|
218
|
+
const argsStart = matchEnd;
|
|
219
|
+
let braceDepth = 1, i = argsStart;
|
|
220
|
+
while (i < searchText.length && braceDepth > 0) {
|
|
221
|
+
if (searchText[i] === '(') braceDepth++;
|
|
222
|
+
else if (searchText[i] === ')') braceDepth--;
|
|
223
|
+
i++;
|
|
224
|
+
}
|
|
225
|
+
const afterClose = searchText.slice(i).match(/^\s*(\{|throws)/);
|
|
226
|
+
if (afterClose) return 'skip'; // this is the method's OWN declaration, not a call site
|
|
227
|
+
const args = _splitTopLevelCommas(searchText.slice(argsStart, i - 1)).map((a) => a.trim());
|
|
228
|
+
if (paramIdx >= args.length) return 'fail';
|
|
229
|
+
const argExpr = args[paramIdx];
|
|
230
|
+
if (/^"[^"]*"$/.test(argExpr)) return 'ok';
|
|
231
|
+
if (/^[A-Za-z_]\w*$/.test(argExpr) && _trailingIdentIsLiteral(searchText, argExpr, matchIndex, searchDeadRanges, depth0 + 1, siblingFiles)) return 'ok';
|
|
232
|
+
return 'fail';
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function scanCallSitesIn(searchText, searchDeadRanges, classNameFilter) {
|
|
236
|
+
let sawRealCallSite = false;
|
|
237
|
+
if (classNameFilter) {
|
|
238
|
+
// Inline construction: `(new ClassName()).method(` / `new ClassName().method(`.
|
|
239
|
+
const inlineRe = new RegExp(`\\bnew\\s+${classNameFilter}\\s*\\(\\s*\\)\\s*\\)?\\s*\\.\\s*${escapedMethod}\\s*\\(`, 'g');
|
|
240
|
+
let cm;
|
|
241
|
+
while ((cm = inlineRe.exec(searchText))) {
|
|
242
|
+
const r = _checkCallAt(searchText, searchDeadRanges, cm.index, cm.index + cm[0].length);
|
|
243
|
+
if (r === 'skip') continue;
|
|
244
|
+
if (r === 'fail') return 'fail';
|
|
245
|
+
sawRealCallSite = true;
|
|
246
|
+
}
|
|
247
|
+
// SARD_80_F1 W5.45 — Juliet's OWN abstract-dispatch caller convention
|
|
248
|
+
// (confirmed via a direct fetch of the public mirror's own
|
|
249
|
+
// `CWE89_SQL_Injection__Environment_executeQuery_81a.java`) is a TWO
|
|
250
|
+
// STATEMENT pattern, not the inline one above: `<BaseType> <var> = new
|
|
251
|
+
// <ConcreteClass>();` immediately followed (same or a later line,
|
|
252
|
+
// arbitrary intervening text) by `<var>.<method>(...)` — the inline
|
|
253
|
+
// regex above can never match this shape at all. Found by re-
|
|
254
|
+
// investigating a real-corpus regression this SAME session's own
|
|
255
|
+
// W5.43/44 scorer fix exposed: 30 Java files where our own detector
|
|
256
|
+
// fires on Juliet's "good source, structurally bad-shaped sink" test
|
|
257
|
+
// convention (a deliberate imprecision trap, not a real vulnerability
|
|
258
|
+
// — see buildJulietExpected's own header comment on why crediting a
|
|
259
|
+
// goodG2B() firing "rewarded exactly the imprecision Juliet is
|
|
260
|
+
// designed to expose") because the cross-file literal check couldn't
|
|
261
|
+
// see this SPECIFIC calling convention. A declared variable's type is
|
|
262
|
+
// NOT required to match `classNameFilter` (Java allows assigning a
|
|
263
|
+
// subclass instance to a base-typed variable, exactly what this
|
|
264
|
+
// idiom does — `CWE89_..._81_base baseObject = new CWE89_..._goodG2B();`)
|
|
265
|
+
// — only the CONSTRUCTOR call's own class name matters.
|
|
266
|
+
const declRe = new RegExp(`\\b[A-Za-z_]\\w*(?:<[^;()]*>)?\\s+([A-Za-z_]\\w*)\\s*=\\s*new\\s+${classNameFilter}\\s*\\(\\s*\\)\\s*;`, 'g');
|
|
267
|
+
let dm;
|
|
268
|
+
while ((dm = declRe.exec(searchText))) {
|
|
269
|
+
const varName = dm[1];
|
|
270
|
+
// Bound the variable's own call search to its DECLARING METHOD's own
|
|
271
|
+
// body span — Juliet's own `bad()`/`goodG2B()`/`goodB2G()` sibling
|
|
272
|
+
// methods routinely reuse the SAME local variable name
|
|
273
|
+
// ("baseObject") for THREE DIFFERENT concrete-class constructions,
|
|
274
|
+
// one per method; an unbounded whole-file search for
|
|
275
|
+
// `baseObject.action(` would also match the OTHER two methods' own
|
|
276
|
+
// unrelated calls (one of them genuinely tainted, in `bad()`),
|
|
277
|
+
// wrongly failing this check closed for the correct pair.
|
|
278
|
+
const span = _enclosingMethodSpan(searchText, dm.index);
|
|
279
|
+
const searchWithin = span ? searchText.slice(span.start, span.end) : searchText;
|
|
280
|
+
const offset = span ? span.start : 0;
|
|
281
|
+
const escapedVarName = varName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
282
|
+
const varCallRe = new RegExp(`\\b${escapedVarName}\\s*\\.\\s*${escapedMethod}\\s*\\(`, 'g');
|
|
283
|
+
let vm;
|
|
284
|
+
while ((vm = varCallRe.exec(searchWithin))) {
|
|
285
|
+
const r = _checkCallAt(searchText, searchDeadRanges, offset + vm.index, offset + vm.index + vm[0].length);
|
|
286
|
+
if (r === 'skip') continue;
|
|
287
|
+
if (r === 'fail') return 'fail';
|
|
288
|
+
sawRealCallSite = true;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
} else {
|
|
292
|
+
const callRe = new RegExp(`\\b${escapedMethod}\\s*\\(`, 'g');
|
|
293
|
+
let cm;
|
|
294
|
+
while ((cm = callRe.exec(searchText))) {
|
|
295
|
+
const r = _checkCallAt(searchText, searchDeadRanges, cm.index, cm.index + cm[0].length);
|
|
296
|
+
if (r === 'skip') continue;
|
|
297
|
+
if (r === 'fail') return 'fail';
|
|
298
|
+
sawRealCallSite = true;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return sawRealCallSite ? 'ok' : 'none';
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Every known real call site — same file AND every sibling — must agree
|
|
305
|
+
// the argument is a literal; a single non-literal caller ANYWHERE fails
|
|
306
|
+
// this closed, so every sibling is checked even after an earlier one
|
|
307
|
+
// already resolved 'ok' (returning early on the first 'ok' would let a
|
|
308
|
+
// LATER sibling's genuinely-tainted caller go unseen).
|
|
309
|
+
const sameFileResult = scanCallSitesIn(content, deadRanges, null);
|
|
310
|
+
if (sameFileResult === 'fail') return false;
|
|
311
|
+
let sawRealCallSite = sameFileResult === 'ok';
|
|
312
|
+
if (siblingFiles && siblingFiles.length) {
|
|
313
|
+
const ownClass = _ownClassName(content);
|
|
314
|
+
if (ownClass) {
|
|
315
|
+
const escapedClass = ownClass.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
316
|
+
for (const sib of siblingFiles) {
|
|
317
|
+
const r = scanCallSitesIn(sib.code, sib.deadRanges, escapedClass);
|
|
318
|
+
if (r === 'fail') return false;
|
|
319
|
+
if (r === 'ok') sawRealCallSite = true;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
return sawRealCallSite;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// SARD_80_F1 W5.34 — Juliet Flow Variant 45: a value passed as a private
|
|
327
|
+
// class member variable between two methods of the same class (`dataX = y;`
|
|
328
|
+
// in one method, `String data = dataX;` in another, in either textual
|
|
329
|
+
// order). See java-bench-extras.js's own header comment on the identical,
|
|
330
|
+
// canonical copy of this function for the full idiom and precision
|
|
331
|
+
// reasoning. Only resolves when `varName` is confirmed to be an actual
|
|
332
|
+
// field declaration (access-modifier-qualified — never valid on a local
|
|
333
|
+
// variable or parameter).
|
|
334
|
+
function _resolveFieldLiteralViaAllAssignments(content, varName, deadRanges, _depth) {
|
|
335
|
+
const depth0 = _depth || 0;
|
|
336
|
+
if (depth0 >= _CALLEE_RETURN_LITERAL_CACHE_DEPTH) return false;
|
|
337
|
+
const escaped = varName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
338
|
+
const fieldDeclRe = new RegExp(`\\b(?:private|protected|public)\\s+[\\w<>\\[\\],.\\s]+\\b${escaped}\\s*(?:=\\s*[^;]+)?;`);
|
|
339
|
+
if (!fieldDeclRe.test(content)) return false;
|
|
340
|
+
const ranges = deadRanges || [];
|
|
341
|
+
const assignRe = new RegExp(`\\b${escaped}\\s*=\\s*([^;]+);`, 'g');
|
|
342
|
+
const lineOf_ = (idx) => content.substring(0, idx).split('\n').length;
|
|
343
|
+
let sawAssignment = false, m;
|
|
344
|
+
while ((m = assignRe.exec(content))) {
|
|
345
|
+
if (ranges.length && isLineInDeadRange(lineOf_(m.index), ranges)) continue;
|
|
346
|
+
sawAssignment = true;
|
|
347
|
+
const rhs = m[1].trim();
|
|
348
|
+
if (/^"[^"]*"$/.test(rhs)) continue;
|
|
349
|
+
if (/^[A-Za-z_]\w*$/.test(rhs) && rhs !== varName
|
|
350
|
+
&& _trailingIdentIsLiteral(content, rhs, m.index, ranges, depth0 + 1)) continue;
|
|
351
|
+
return false;
|
|
352
|
+
}
|
|
353
|
+
return sawAssignment;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// SARD_80_F1 W5.47 — Juliet Flow Variant 65-68 ("data passed as a member
|
|
357
|
+
// variable in the 'a' class, used by a method in another class in the same
|
|
358
|
+
// package"): a PUBLIC STATIC field is declared and written in one file
|
|
359
|
+
// (`_68a.java`), then read via a QUALIFIED reference (`OtherClass.field`)
|
|
360
|
+
// in a SIBLING file's sink method (`_68b.java`) — confirmed via a direct
|
|
361
|
+
// fetch of the public mirror's own `CWE89_SQL_Injection__
|
|
362
|
+
// Environment_executeBatch_68a.java`/`_68b.java` pair. This was
|
|
363
|
+
// independently identified THREE times this session (W3.4/PHP,
|
|
364
|
+
// W4.C24/C#, W4.J27/Java) as needing "call-site-sensitive field-value
|
|
365
|
+
// tracking" — a real context-sensitivity dimension this codebase's taint
|
|
366
|
+
// engine deliberately does not model (`dataflow/CLAUDE.md`'s own
|
|
367
|
+
// documented scope). Re-deriving from the actual fetched files shows the
|
|
368
|
+
// SPECIFIC shape this idiom always takes is narrower than that framing
|
|
369
|
+
// suggested: Juliet names each sink method UNIQUELY per flow branch
|
|
370
|
+
// (`badSink`/`goodG2BSink`/`goodB2GSink`, never one shared method called
|
|
371
|
+
// from multiple places), and each has EXACTLY ONE real caller, whose own
|
|
372
|
+
// write to the field happens immediately before its own call to that
|
|
373
|
+
// EXACT sink method — the same "declare-then-call, one writer per callee"
|
|
374
|
+
// shape W5.45 already solved for method arguments, just via a qualified
|
|
375
|
+
// static field instead. `sinkMethodName` is the ENCLOSING method's own
|
|
376
|
+
// name in the file containing the read (found via `_enclosingMethodSpan`);
|
|
377
|
+
// for EACH candidate write to the field anywhere in the field-declaring
|
|
378
|
+
// sibling file, this checks whether THAT write's own enclosing method is
|
|
379
|
+
// the one that calls `sinkMethodName` — a write in an UNRELATED method
|
|
380
|
+
// (e.g. `bad()`'s own write, when resolving `goodG2BSink()`'s read) is
|
|
381
|
+
// correctly never consulted at all, not merely outvoted. Fails closed
|
|
382
|
+
// exactly like every other mechanism here: a relevant writer supplying a
|
|
383
|
+
// non-literal, or NO relevant writer found at all, both return false.
|
|
384
|
+
function _resolveStaticFieldLiteralAcrossFiles(className, fieldName, sinkMethodName, siblingFiles, _depth) {
|
|
385
|
+
const depth0 = _depth || 0;
|
|
386
|
+
if (depth0 >= _CALLEE_RETURN_LITERAL_CACHE_DEPTH) return false;
|
|
387
|
+
if (!siblingFiles || !siblingFiles.length || !sinkMethodName) return false;
|
|
388
|
+
const escapedClass = className.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
389
|
+
const escapedField = fieldName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
390
|
+
const escapedSinkMethod = sinkMethodName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
391
|
+
|
|
392
|
+
for (const sib of siblingFiles) {
|
|
393
|
+
// Cheap plain-substring pre-filter before paying for RegExp construction
|
|
394
|
+
// + execution against a whole (up to 500KB) sibling file: the vast
|
|
395
|
+
// majority of a Juliet directory's ~980 siblings never mention this
|
|
396
|
+
// class name at all, and String#includes rejects those almost for free.
|
|
397
|
+
// Never a false negative — a real match always contains the class name
|
|
398
|
+
// as a substring, so this can only skip files the regex would also skip.
|
|
399
|
+
if (!sib.code.includes(className)) continue;
|
|
400
|
+
if (!new RegExp(`\\bclass\\s+${escapedClass}\\b`).test(sib.code)) continue;
|
|
401
|
+
const assignRe = new RegExp(`\\b${escapedField}\\s*=\\s*([^;]+);`, 'g');
|
|
402
|
+
const callsSinkRe = new RegExp(`\\.\\s*${escapedSinkMethod}\\s*\\(|\\b${escapedSinkMethod}\\s*\\(`);
|
|
403
|
+
let sawRelevantWrite = false, m;
|
|
404
|
+
while ((m = assignRe.exec(sib.code))) {
|
|
405
|
+
const span = _enclosingMethodSpan(sib.code, m.index);
|
|
406
|
+
if (!span || !span.name) continue;
|
|
407
|
+
const methodBody = sib.code.slice(span.start, span.end);
|
|
408
|
+
if (!callsSinkRe.test(methodBody)) continue; // this write's own method never reaches this specific sink — irrelevant
|
|
409
|
+
sawRelevantWrite = true;
|
|
410
|
+
const rhs = m[1].trim();
|
|
411
|
+
if (/^"[^"]*"$/.test(rhs)) continue;
|
|
412
|
+
if (/^[A-Za-z_]\w*$/.test(rhs) && _trailingIdentIsLiteral(sib.code, rhs, m.index, [], depth0 + 1)) continue;
|
|
413
|
+
return false; // this writer's own value is non-literal (or unresolvable) — fail closed
|
|
414
|
+
}
|
|
415
|
+
return sawRelevantWrite;
|
|
416
|
+
}
|
|
417
|
+
return false;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// Exported for java-structural-cross-file.js (W5.41) — see
|
|
421
|
+
// `_resolveParamLiteralViaAllCallSites`'s own header comment for what
|
|
422
|
+
// `siblingFiles` is and why it's threaded through here too (the same-method
|
|
423
|
+
// copy-chain branch below must be able to recurse into a cross-file-
|
|
424
|
+
// resolved parameter if it is then copied to another local before use).
|
|
425
|
+
export function _trailingIdentIsLiteral(code, varName, beforeIdx, deadRanges, _calleeDepth, siblingFiles) {
|
|
426
|
+
if (!varName) return false;
|
|
427
|
+
if ((_calleeDepth || 0) >= _CALLEE_RETURN_LITERAL_CACHE_DEPTH) return false;
|
|
428
|
+
const ranges = deadRanges || [];
|
|
429
|
+
const escaped = varName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
430
|
+
const literalRe = new RegExp(`\\b${escaped}\\s*=\\s*"[^"]*"\\s*;`, 'g');
|
|
431
|
+
const anyAssignRe = new RegExp(`\\b${escaped}\\s*=\\s*([^;]+);`, 'g');
|
|
432
|
+
const lineOfIdx = (idx) => code.substring(0, idx).split('\n').length;
|
|
433
|
+
let lastLiteralEnd = -1, m;
|
|
434
|
+
while ((m = literalRe.exec(code)) && m.index < beforeIdx) {
|
|
435
|
+
if (ranges.length && isLineInDeadRange(lineOfIdx(m.index), ranges)) continue;
|
|
436
|
+
lastLiteralEnd = m.index + m[0].length;
|
|
437
|
+
}
|
|
438
|
+
let lastAnyEnd = -1, lastAnyRhs = null, lastAnyIdx = -1;
|
|
439
|
+
while ((m = anyAssignRe.exec(code)) && m.index < beforeIdx) {
|
|
440
|
+
if (ranges.length && isLineInDeadRange(lineOfIdx(m.index), ranges)) continue;
|
|
441
|
+
lastAnyEnd = m.index + m[0].length;
|
|
442
|
+
lastAnyRhs = m[1].trim();
|
|
443
|
+
lastAnyIdx = m.index;
|
|
444
|
+
}
|
|
445
|
+
// Same-file callee-return resolution (Flow Variant 42).
|
|
446
|
+
if (lastAnyRhs && /^[A-Za-z_]\w*\s*\([^()]*\)$/.test(lastAnyRhs)) {
|
|
447
|
+
const calleeName = lastAnyRhs.slice(0, lastAnyRhs.indexOf('(')).trim();
|
|
448
|
+
if (_resolveCalleeReturnIsLiteral(code, calleeName, ranges, _calleeDepth)) return true;
|
|
449
|
+
}
|
|
450
|
+
// Same-method copy-chain resolution (Flow Variant 31).
|
|
451
|
+
if (lastAnyRhs && lastAnyRhs !== 'null' && /^[A-Za-z_]\w*$/.test(lastAnyRhs) && lastAnyRhs !== varName) {
|
|
452
|
+
if (_trailingIdentIsLiteral(code, lastAnyRhs, lastAnyIdx, ranges, (_calleeDepth || 0) + 1, siblingFiles)) return true;
|
|
453
|
+
}
|
|
454
|
+
// Cross-file static-field resolution (Flow Variant 65-68, W5.47): the
|
|
455
|
+
// nearest assignment is a QUALIFIED `ClassName.field` reference — resolve
|
|
456
|
+
// via every write to that field in its own declaring (sibling) file whose
|
|
457
|
+
// enclosing method is the one real caller of THIS sink method.
|
|
458
|
+
if (lastAnyRhs && siblingFiles && siblingFiles.length) {
|
|
459
|
+
const fieldRefMatch = /^([A-Za-z_]\w*)\s*\.\s*([A-Za-z_]\w*)$/.exec(lastAnyRhs);
|
|
460
|
+
if (fieldRefMatch) {
|
|
461
|
+
const sinkSpan = _enclosingMethodSpan(code, beforeIdx);
|
|
462
|
+
const sinkMethodName = sinkSpan && sinkSpan.name;
|
|
463
|
+
if (_resolveStaticFieldLiteralAcrossFiles(fieldRefMatch[1], fieldRefMatch[2], sinkMethodName, siblingFiles, _calleeDepth)) return true;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
if (lastLiteralEnd === -1 || lastLiteralEnd !== lastAnyEnd) {
|
|
467
|
+
// Argument-passing resolution via all call sites (Flow Variant 41), then
|
|
468
|
+
// class-member-variable resolution via all assignments (Flow Variant 45).
|
|
469
|
+
return _resolveParamLiteralViaAllCallSites(code, varName, beforeIdx, deadRanges, _calleeDepth, siblingFiles)
|
|
470
|
+
|| _resolveFieldLiteralViaAllAssignments(code, varName, deadRanges, _calleeDepth);
|
|
471
|
+
}
|
|
472
|
+
return true;
|
|
473
|
+
}
|
|
474
|
+
|
|
19
475
|
const META = {
|
|
20
476
|
sqlInjection: {
|
|
21
477
|
vuln: 'SQL Injection — query built with string concatenation (Java)',
|
|
@@ -44,11 +500,29 @@ export function scanJavaStructural(fp, raw) {
|
|
|
44
500
|
snippet: (raw.split('\n')[line - 1] || '').trim().slice(0, 200),
|
|
45
501
|
remediation: meta.remediation, parser: 'JAVA', confidence: 0.78,
|
|
46
502
|
});
|
|
503
|
+
// See _trailingIdentIsLiteral's own header comment (W4.J35).
|
|
504
|
+
let deadRanges = [];
|
|
505
|
+
try { deadRanges = deadBranchRanges(raw); } catch { /* parse error → no AST info */ }
|
|
47
506
|
|
|
48
507
|
for (const [key, re] of Object.entries(RE)) {
|
|
49
508
|
const r = new RegExp(re.source, re.flags);
|
|
50
509
|
let m;
|
|
51
|
-
while ((m = r.exec(code)))
|
|
510
|
+
while ((m = r.exec(code))) {
|
|
511
|
+
const line = lineOf(code, m.index);
|
|
512
|
+
// SARD_80_F1 W5.35 — a sink call sitting INSIDE a provably-dead branch
|
|
513
|
+
// (Juliet's own "switch(8){case 7: <tainted sink>}" idiom, Flow
|
|
514
|
+
// Variant 15's goodB2G shapes — case 7 never matches scrutinee 8) is
|
|
515
|
+
// unreachable regardless of whether the value reaching it is tainted:
|
|
516
|
+
// `deadRanges` was already computed for the LITERAL-CHECK below, but
|
|
517
|
+
// nothing checked it against the SINK'S OWN line. Confirmed via a
|
|
518
|
+
// real-corpus fp (CWE89_SQL_Injection__*_15.java's goodB2G1/2) that
|
|
519
|
+
// `_trailingIdentIsLiteral` correctly found the tainted socket-read
|
|
520
|
+
// value (so the literal check alone could never suppress this — the
|
|
521
|
+
// value genuinely IS tainted, just unreachable) before this fix.
|
|
522
|
+
if (deadRanges.length && isLineInDeadRange(line, deadRanges)) continue;
|
|
523
|
+
if (_trailingIdentIsLiteral(code, m[1], m.index, deadRanges)) continue;
|
|
524
|
+
emit(key, line, META[key]);
|
|
525
|
+
}
|
|
52
526
|
}
|
|
53
527
|
|
|
54
528
|
// Path traversal (CWE-22): a file path built by concatenation, unless the
|
|
@@ -66,9 +540,28 @@ export function scanJavaStructural(fp, raw) {
|
|
|
66
540
|
|
|
67
541
|
// SSRF (CWE-918): new URL/URI opened from a non-literal/templated value,
|
|
68
542
|
// unless a host allow/deny guard is present.
|
|
543
|
+
//
|
|
544
|
+
// SARD_80_F1 W4.J22/W4.J24 — SSRF_SINK previously matched the mere
|
|
545
|
+
// CONSTRUCTION of a URL/URI object, with no check that it's ever actually
|
|
546
|
+
// used to open an outbound connection. `new URI(data)` is a completely
|
|
547
|
+
// ordinary way to VALIDATE a string's syntax (catch URISyntaxException)
|
|
548
|
+
// before doing something else entirely with it — e.g. Juliet's own
|
|
549
|
+
// CWE-601 (Open Redirect) test cases build a `URI` purely to reject a
|
|
550
|
+
// malformed redirect target, then call `response.sendRedirect(data)`,
|
|
551
|
+
// never opening any connection — confirmed directly against the public
|
|
552
|
+
// mirror (UnitTestBot/juliet-java-test-suite,
|
|
553
|
+
// CWE601_Open_Redirect__Servlet_File_53d.java). That construction-only
|
|
554
|
+
// shape was scoring as unrelated-CWE noise on every Juliet CWE-601 file
|
|
555
|
+
// that validates a URI this way. Now requires evidence of an outbound
|
|
556
|
+
// connection call somewhere in the file — file-scoped (like SSRF_GUARD
|
|
557
|
+
// already is) rather than proximity-scoped, since a real SSRF sink and
|
|
558
|
+
// its `new URL(...)` construction are frequently on the very same line
|
|
559
|
+
// anyway (`new URL(url).openStream()...`, this detector's own positive
|
|
560
|
+
// test fixture).
|
|
69
561
|
const SSRF_SINK = /\bnew\s+(?:URL|URI)\s*\(\s*(?:[A-Za-z_]\w*\s*\)|"[^"\n]*"\s*\+)/;
|
|
562
|
+
const SSRF_CONNECT = /\.\s*openConnection\s*\(|\.\s*openStream\s*\(|\.\s*getContent\s*\(|\.\s*connect\s*\(\s*\)|\bHttpURLConnection\b|\bHttpClient\b/;
|
|
70
563
|
const SSRF_GUARD = /169\.254\.169\.254|getHost\s*\(\s*\)|allow(?:ed)?Hosts?|isLoopback|isSiteLocal|isLinkLocal|InetAddress|\bDENY\b|deny(?:list)?|block(?:list|ed)/i;
|
|
71
|
-
if (SSRF_SINK.test(code) && !SSRF_GUARD.test(code)) {
|
|
564
|
+
if (SSRF_SINK.test(code) && SSRF_CONNECT.test(code) && !SSRF_GUARD.test(code)) {
|
|
72
565
|
const line = lineOf(code, code.search(SSRF_SINK));
|
|
73
566
|
emit('ssrf', line, {
|
|
74
567
|
vuln: 'SSRF — URL/URI opened from a non-literal value (Java)',
|