@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
|
@@ -46,7 +46,20 @@ const FILTER_INLINE_RE = {
|
|
|
46
46
|
go: new RegExp(String.raw`\b(?:NewSearchRequest|SearchRequest|Search)\s*\([^)]*"\(\s*` + ATTR + String.raw`\s*=[^"]*"\s*\+\s*[A-Za-z_][\w.]*(?![\w.]*\s*\()`, 'g'),
|
|
47
47
|
// C#: DirectorySearcher.Filter assigned a concat ("(uid=" + u) or an
|
|
48
48
|
// interpolated string ($"(uid={u})").
|
|
49
|
-
|
|
49
|
+
//
|
|
50
|
+
// SARD_80_F1 W4.C13 — this is the shape C#'s REAL Juliet corpus actually
|
|
51
|
+
// uses (`search.Filter = "(...) " + data + "))";`, a property ASSIGNMENT,
|
|
52
|
+
// not a call argument), confirmed via the public mirror
|
|
53
|
+
// (CWE90_LDAP_Injection__Connect_tcp_01.cs) — meaning it's THIS regex,
|
|
54
|
+
// not FILTER_VAR_RE.cs below, that needs the literal-suppression capture
|
|
55
|
+
// group: the identical filter line appears verbatim in bad() and
|
|
56
|
+
// GoodG2B(), only `data`'s source differs, same convention as every
|
|
57
|
+
// other language's Path B fix. Split into two top-level alternatives
|
|
58
|
+
// (concat form with a capturing group + trailing lookahead, vs.
|
|
59
|
+
// interpolation form uncaptured) instead of the previous single regex
|
|
60
|
+
// with an internal `"\s*\+|\{` alternation, since only the concat form's
|
|
61
|
+
// trailing identifier is safely checkable this way.
|
|
62
|
+
cs: new RegExp(String.raw`\bFilter\s*=\s*\$?@?"[^"]*\(\s*` + ATTR + String.raw`\s*=[^"]*"\s*\+\s*([A-Za-z_][\w.]*)(?![\w.]*\s*\()(?=\s*(?:\+\s*["'][^"'\n]*["']\s*)?[);,])|\bFilter\s*=\s*\$?@?"[^"]*\(\s*` + ATTR + String.raw`\s*=[^"]*\{`, 'g'),
|
|
50
63
|
// Ruby net-ldap: a filter built with #{} interpolation inside a search/
|
|
51
64
|
// construct/filter call.
|
|
52
65
|
rb: new RegExp(String.raw`\.(?:search|filter|construct|equals)\s*\([^)]*\(\s*` + ATTR + String.raw`\s*=[^)]*#\{`, 'g'),
|
|
@@ -62,11 +75,30 @@ const FILTER_INLINE_RE = {
|
|
|
62
75
|
// which is the *escaped* (safe) form — must not match.
|
|
63
76
|
const FILTER_VAR_RE = {
|
|
64
77
|
js: new RegExp(String.raw`["'` + '`' + String.raw`]\s*\(\s*` + ATTR + String.raw`\s*=\s*["'` + '`' + String.raw`]?\s*(?:\+|\$\{)\s*[A-Za-z_$][\w.]*(?![\w.]*\s*\()`, 'g'),
|
|
65
|
-
|
|
78
|
+
// Captures the trailing identifier (group 1) so scanLdapInjection can check
|
|
79
|
+
// whether it's PROVABLY a hardcoded literal — Juliet's own convention
|
|
80
|
+
// (confirmed via the public mirror, CWE90_LDAP_Injection__Environment_01
|
|
81
|
+
// .java) keeps the IDENTICAL `"(cn=" + data + ")"` filter line in both
|
|
82
|
+
// bad() and goodG2B(), only swapping `data`'s source. The trailing
|
|
83
|
+
// lookahead mirrors java-structural.js's CWE-89 fix: only a SINGLE
|
|
84
|
+
// trailing term (optionally followed by one more literal segment, e.g.
|
|
85
|
+
// the closing `+ ")"`) is eligible for suppression — a second variable
|
|
86
|
+
// after it is left alone.
|
|
87
|
+
java: new RegExp(String.raw`["']\s*\(\s*` + ATTR + String.raw`\s*=\s*["']?\s*\+\s*([A-Za-z_][\w.]*)(?![\w.]*\s*\()(?=\s*(?:\+\s*["'][^"'\n]*["']\s*)?[);,])`, 'g'),
|
|
66
88
|
py: new RegExp(String.raw`["']\s*\(\s*` + ATTR + String.raw`\s*=\s*["']?\s*\+\s*[A-Za-z_][\w.]*(?![\w.]*\s*\()|[fF]["']\s*\(\s*` + ATTR + String.raw`\s*=\s*\{`, 'g'),
|
|
67
89
|
php: new RegExp(String.raw`["']\s*\(\s*` + ATTR + String.raw`\s*=\s*["']?\s*\.\s*\$[A-Za-z_]\w*`, 'g'),
|
|
68
90
|
go: new RegExp(String.raw`["']\s*\(\s*` + ATTR + String.raw`\s*=\s*["']?\s*\+\s*[A-Za-z_][\w.]*(?![\w.]*\s*\()`, 'g'),
|
|
69
|
-
|
|
91
|
+
// SARD_80_F1 W4.C13 — capture group added to the concat alternative
|
|
92
|
+
// (mirroring `java`'s) so scanLDAPInjection can run the same
|
|
93
|
+
// hardcoded-literal check; the C# public mirror confirms Juliet's
|
|
94
|
+
// identical-line convention holds here too (CWE90_LDAP_Injection__
|
|
95
|
+
// Connect_tcp_01.cs keeps `search.Filter = "(...employeename=" + data +
|
|
96
|
+
// "))";` verbatim in both bad() and GoodG2B(), only data's source
|
|
97
|
+
// differs). The interpolation alternative (`$"...{`) is left uncaptured
|
|
98
|
+
// (m[1] undefined there is a no-op for `_nearestAssignIsLiteral`, which
|
|
99
|
+
// returns false on a falsy varName) since Juliet's own corpus uses the
|
|
100
|
+
// concat form exclusively for this shape.
|
|
101
|
+
cs: new RegExp(String.raw`["']\s*\(\s*` + ATTR + String.raw`\s*=\s*["']?\s*\+\s*([A-Za-z_][\w.]*)(?![\w.]*\s*\()(?=\s*(?:\+\s*["'][^"'\n]*["']\s*)?[);,])|\$"[^"]*\(\s*` + ATTR + String.raw`\s*=\s*\{`, 'g'),
|
|
70
102
|
rb: new RegExp(String.raw`["']\s*\(\s*` + ATTR + String.raw`\s*=[^"']*#\{`, 'g'),
|
|
71
103
|
kt: new RegExp(String.raw`["']\s*\(\s*` + ATTR + String.raw`\s*=\s*["']?\s*(?:\+|\$\{)\s*[A-Za-z_$][\w.]*(?![\w.]*\s*\()`, 'g'),
|
|
72
104
|
};
|
|
@@ -88,6 +120,99 @@ const LDAP_ESCAPE_RE =
|
|
|
88
120
|
/\b(?:ldap_escape|EscapeFilter|escape_filter_chars|escapeForLDAP|encodeForLDAP|escapeLDAPSearchFilter|LDAP_ESCAPE_FILTER)\b|\bNet::LDAP::Filter\b|\bEqualityFilter\b|\bfilters\.\w+\b/;
|
|
89
121
|
|
|
90
122
|
function lineOf(raw, idx) { return raw.substring(0, idx).split('\n').length; }
|
|
123
|
+
|
|
124
|
+
// True when EVERY assignment to `varName` before `beforeIdx` (source order)
|
|
125
|
+
// is a plain string-literal RHS, and at least one such assignment exists.
|
|
126
|
+
// Same "backward assignment scan" shape as java-structural.js's
|
|
127
|
+
// `_trailingIdentIsLiteral` (itself modeled on java-bench-extras.js's
|
|
128
|
+
// CWE-259 check) — duplicated rather than imported, matching this
|
|
129
|
+
// codebase's established per-module convention for this small a helper.
|
|
130
|
+
//
|
|
131
|
+
// SARD_80_F1 W4.C13 — this USED to check only the TEXTUALLY NEAREST
|
|
132
|
+
// assignment, which is wrong for an if/else where each branch assigns the
|
|
133
|
+
// same variable: `if (cond) data = Environment.GetEnvironmentVariable(...);
|
|
134
|
+
// else data = "foo";` has its literal assignment (the else branch) textually
|
|
135
|
+
// LAST, so the old "nearest" check wrongly concluded `data` was provably a
|
|
136
|
+
// literal and suppressed a REAL vulnerability — found via a real corpus
|
|
137
|
+
// regression during THIS EXACT fix's own verification (C# CWE90_LDAP_
|
|
138
|
+
// Injection__Environment_12.cs's Bad() lost its true positive). Checking
|
|
139
|
+
// EVERY preceding assignment (failing closed the moment any one of them is
|
|
140
|
+
// non-literal) fixes that — but naively scanning the WHOLE FILE for prior
|
|
141
|
+
// assignments introduced a SECOND regression the same verification pass
|
|
142
|
+
// caught: Juliet's universal convention re-declares the source variable
|
|
143
|
+
// FRESH in every method (`string data;` in Bad(), a completely separate
|
|
144
|
+
// `string data;` in GoodG2B()), so a same-named variable in an EARLIER
|
|
145
|
+
// method (e.g. Bad()'s own non-literal source assignment) would incorrectly
|
|
146
|
+
// poison GoodG2B()'s own all-literal check. Scoping the scan to start at
|
|
147
|
+
// the variable's most recent DECLARATION before `beforeIdx` (a `Type
|
|
148
|
+
// varName;` or `Type varName = …;` statement) fixes both at once: within
|
|
149
|
+
// one method's scope, every assignment must be literal (catches the
|
|
150
|
+
// if/else case); across method boundaries, each method's own `data` is
|
|
151
|
+
// scanned independently (catches the cross-method case). Falls back to
|
|
152
|
+
// scanning from file-start when no declaration is found — strictly SAFER
|
|
153
|
+
// than before (fewer assignments end up in scope, never more).
|
|
154
|
+
// SARD_80_F1 W4.C17 — a bare-identifier RHS (`data = dataCopy;`) previously
|
|
155
|
+
// failed this check outright, even when `dataCopy` was itself provably a
|
|
156
|
+
// literal at that point — confirmed on the real corpus (C# Juliet's own
|
|
157
|
+
// "make a copy of data within the same method" flow variant,
|
|
158
|
+
// CWE90_LDAP_Injection__Environment_31.cs: GoodG2B() sets `data = "foo"` in
|
|
159
|
+
// one block, copies it to `dataCopy`, then re-declares `data = dataCopy` in
|
|
160
|
+
// a SECOND block before the sink — the one-hop copy defeated the
|
|
161
|
+
// literal-RHS check, which only recognized a DIRECT `"literal"` string).
|
|
162
|
+
// Now recurses ONE LEVEL (capped, cycle-guarded via `rhs !== varName`) into
|
|
163
|
+
// a bare-identifier RHS, applying the exact same fail-closed "every
|
|
164
|
+
// assignment must be literal" policy to the copied-from variable — this can
|
|
165
|
+
// only ADD suppression where the old code returned false, never introduce a
|
|
166
|
+
// new false negative the old code didn't already risk (a genuinely tainted
|
|
167
|
+
// `dataCopy` still fails its own scan and propagates the non-literal verdict
|
|
168
|
+
// back up).
|
|
169
|
+
function _nearestAssignIsLiteral(code, varName, beforeIdx, _depth) {
|
|
170
|
+
if (!varName || varName.includes('.')) return false;
|
|
171
|
+
const depth = _depth || 0;
|
|
172
|
+
const escaped = varName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
173
|
+
const declRe = new RegExp(`\\b[\\w<>[\\],.?]+\\s+${escaped}\\s*(?:=|;)`, 'g');
|
|
174
|
+
let scopeStart = 0, dm;
|
|
175
|
+
while ((dm = declRe.exec(code)) && dm.index < beforeIdx) scopeStart = dm.index;
|
|
176
|
+
const anyAssignRe = new RegExp(`\\b${escaped}\\s*=\\s*([^;]+);`, 'g');
|
|
177
|
+
anyAssignRe.lastIndex = scopeStart;
|
|
178
|
+
const literalRhsRe = /^"[^"]*"$/;
|
|
179
|
+
const bareIdentRe = /^[A-Za-z_]\w*$/;
|
|
180
|
+
let sawAny = false, m;
|
|
181
|
+
while ((m = anyAssignRe.exec(code)) && m.index < beforeIdx) {
|
|
182
|
+
sawAny = true;
|
|
183
|
+
const rhs = m[1].trim();
|
|
184
|
+
if (literalRhsRe.test(rhs)) continue;
|
|
185
|
+
// SARD_80_F1 W4.C37 — `null` is never attacker-controlled data (it is
|
|
186
|
+
// the ABSENCE of a value, not a value), so an assignment of `data =
|
|
187
|
+
// null;` can never make a later sink call injectable — treating it as a
|
|
188
|
+
// disqualifying non-literal was always wrong, independent of any dead-
|
|
189
|
+
// code/control-flow question. This exact shape is Juliet's own "if
|
|
190
|
+
// (CONST) { data = <literal-or-source> } else { data = null; }" dead-
|
|
191
|
+
// code idiom (confirmed via the public C# Juliet mirror's own
|
|
192
|
+
// CWE90_LDAP_Injection__Connect_tcp_04.cs), but the fix is sound as a
|
|
193
|
+
// general rule, not merely a benchmark-shape accommodation: BEFORE
|
|
194
|
+
// this fix, `_nearestAssignIsLiteral` recursed into "null" as if it
|
|
195
|
+
// might be a traceable bare-identifier variable (`bareIdentRe` matches
|
|
196
|
+
// it — "null" is alphabetic), found no declaration or assignment for a
|
|
197
|
+
// variable literally named `null`, and returned `false` from that dead
|
|
198
|
+
// end — which then disqualified the WHOLE candidate as "not provably
|
|
199
|
+
// literal" even when every OTHER real assignment was a hardcoded
|
|
200
|
+
// literal. This bug PRE-DATES and is INDEPENDENT of this session's
|
|
201
|
+
// parser-cs.js CFG work (confirmed via a direct, standalone call to
|
|
202
|
+
// `scanLDAPInjection` bypassing runScan/parser-cs.js entirely) — it was
|
|
203
|
+
// simply never exercised on a real corpus file until an unrelated CFG
|
|
204
|
+
// fix elsewhere changed which findings survive `engine.js`'s own
|
|
205
|
+
// downstream dedup, surfacing it. Skipping (not disqualifying, not
|
|
206
|
+
// affirming) a `null` RHS can only ever ADD suppression where the old
|
|
207
|
+
// code returned false, never remove a genuine tainted-source detection
|
|
208
|
+
// — a real source assignment (`data = sr.ReadLine();`, `data =
|
|
209
|
+
// Environment.GetEnvironmentVariable(...)`) is never itself `null`.
|
|
210
|
+
if (rhs === 'null') continue;
|
|
211
|
+
if (depth < 3 && rhs !== varName && bareIdentRe.test(rhs) && _nearestAssignIsLiteral(code, rhs, m.index, depth + 1)) continue;
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
214
|
+
return sawAny;
|
|
215
|
+
}
|
|
91
216
|
function _lang(fp) {
|
|
92
217
|
if (/\.(?:js|jsx|ts|tsx|mjs|cjs)$/i.test(fp)) return 'js';
|
|
93
218
|
if (/\.java$/i.test(fp)) return 'java';
|
|
@@ -140,6 +265,10 @@ export function scanLDAPInjection(fp, raw) {
|
|
|
140
265
|
const re = new RegExp(FILTER_INLINE_RE[lang].source, FILTER_INLINE_RE[lang].flags);
|
|
141
266
|
let m;
|
|
142
267
|
while ((m = re.exec(code))) {
|
|
268
|
+
// SARD_80_F1 W4.C13 — C#'s DirectorySearcher.Filter shape goes
|
|
269
|
+
// through THIS path (a property assignment), not FILTER_VAR_RE, so
|
|
270
|
+
// the hardcoded-literal check belongs here for `cs`.
|
|
271
|
+
if (lang === 'cs' && _nearestAssignIsLiteral(code, m[1], m.index)) continue;
|
|
143
272
|
const line = lineOf(raw, m.index);
|
|
144
273
|
const key = `inline:${line}`;
|
|
145
274
|
if (seen.has(key)) continue;
|
|
@@ -154,6 +283,7 @@ export function scanLDAPInjection(fp, raw) {
|
|
|
154
283
|
const re = new RegExp(FILTER_VAR_RE[lang].source, FILTER_VAR_RE[lang].flags);
|
|
155
284
|
let m;
|
|
156
285
|
while ((m = re.exec(code))) {
|
|
286
|
+
if ((lang === 'java' || lang === 'cs') && _nearestAssignIsLiteral(code, m[1], m.index)) continue;
|
|
157
287
|
const line = lineOf(raw, m.index);
|
|
158
288
|
const key = `var:${line}`;
|
|
159
289
|
if (seen.has(key)) continue;
|
package/src/sast/rust.js
CHANGED
|
@@ -63,6 +63,59 @@ const FINDINGS = [
|
|
|
63
63
|
remediation: 'Define a typed struct with serde for the extractor: `#[derive(Deserialize)] struct UserPath { id: i64 }`. Typed extractors reject malformed input at the framework boundary instead of bubbling raw strings into your handlers.',
|
|
64
64
|
infoOnly: true,
|
|
65
65
|
},
|
|
66
|
+
{
|
|
67
|
+
id: 'rust-tls-verify-disabled', severity: 'high', cwe: 'CWE-295', family: 'tls-verification',
|
|
68
|
+
// reqwest::ClientBuilder::danger_accept_invalid_certs(true) /
|
|
69
|
+
// danger_accept_invalid_hostnames(true) — the method name itself says
|
|
70
|
+
// "danger"; this only fires on the literal `true` argument, since a
|
|
71
|
+
// variable/computed argument might legitimately gate it off in prod.
|
|
72
|
+
re: /\bdanger_accept_invalid_(?:certs|hostnames)\s*\(\s*true\s*\)/g,
|
|
73
|
+
vuln: 'TLS certificate/hostname verification disabled',
|
|
74
|
+
remediation: 'Remove `danger_accept_invalid_certs`/`danger_accept_invalid_hostnames` (or pass a variable that is `false` in production). If a private CA is the real problem, add its root with `Certificate::from_pem` + `.add_root_certificate(...)` instead of disabling verification.',
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
id: 'rust-weak-hash-md5', severity: 'medium', cwe: 'CWE-327', family: 'weak-crypto',
|
|
78
|
+
re: /\b(?:md5::compute|Md5::new|Md5::default)\s*\(/g,
|
|
79
|
+
vuln: 'Weak cryptographic hash — MD5',
|
|
80
|
+
remediation: 'MD5 is broken for any security purpose (collision-resistance, password storage, integrity). Use `sha2::Sha256` for integrity/fingerprinting or `argon2`/`bcrypt` for password hashing.',
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
id: 'rust-weak-hash-sha1', severity: 'medium', cwe: 'CWE-327', family: 'weak-crypto',
|
|
84
|
+
re: /\b(?:Sha1::new|Sha1::default|sha1::Sha1)\s*[:(]/g,
|
|
85
|
+
vuln: 'Weak cryptographic hash — SHA-1',
|
|
86
|
+
remediation: 'SHA-1 has practical collision attacks. Use `sha2::Sha256`/`Sha512` (or `blake3`) for anything security-relevant.',
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
id: 'rust-weak-cipher', severity: 'high', cwe: 'CWE-327', family: 'weak-crypto',
|
|
90
|
+
// des::Des / des::TdesEde3 (DES/3DES) and rc4::Rc4 — broken/deprecated
|
|
91
|
+
// ciphers still published as crates.
|
|
92
|
+
re: /\b(?:des::Des\b|des::TdesEde[23]\b|Rc4::new)\s*[:(<]/g,
|
|
93
|
+
vuln: 'Weak/broken cipher — DES/3DES/RC4',
|
|
94
|
+
remediation: 'Use an AEAD cipher from the `aes-gcm` or `chacha20poly1305` crates (`Aes256Gcm`, `ChaCha20Poly1305`). DES, 3DES and RC4 are all broken or deprecated for new use.',
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
id: 'rust-weak-rng-token', severity: 'high', cwe: 'CWE-338', family: 'weak-rng',
|
|
98
|
+
// rand::random()/thread_rng() feeding an identifier that names a
|
|
99
|
+
// token/secret/key/session — `rand`'s default RNG is NOT a CSPRNG-grade
|
|
100
|
+
// guarantee for security tokens the way `getrandom`/`OsRng` are treated
|
|
101
|
+
// in the ecosystem's own guidance; flag the security-sensitive naming
|
|
102
|
+
// context, not `rand::random()` in general (rolling dice is fine).
|
|
103
|
+
re: /\b(?:let|const)\s+(?:mut\s+)?\w*(?:token|secret|session|api_?key|password|nonce|salt|csrf)\w*\s*(?::[^=]+)?=\s*(?:rand::random|rand::thread_rng\s*\(\s*\)\.gen)\b/gi,
|
|
104
|
+
vuln: 'Weak randomness for a security-sensitive value (rand::random/thread_rng)',
|
|
105
|
+
remediation: 'Use `rand::rngs::OsRng` (or the `getrandom` crate) to seed tokens/session ids/keys — `rand::thread_rng()`/`rand::random()` are not documented as cryptographically secure. `let mut buf = [0u8; 32]; OsRng.fill_bytes(&mut buf);`',
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
id: 'rust-unsafe-raw-memory', severity: 'medium', cwe: 'CWE-119', family: 'memory-safety',
|
|
109
|
+
// transmute / from_raw_parts(_mut) / get_unchecked(_mut) — the classic
|
|
110
|
+
// unsafe memory-reinterpretation and bounds-skipping primitives. Scoped
|
|
111
|
+
// to occurrences that are themselves already inside an `unsafe` block
|
|
112
|
+
// in practice (transmute/from_raw_parts require it to compile at all;
|
|
113
|
+
// get_unchecked does not, so it's included for the same reason: it
|
|
114
|
+
// skips the bounds check the safe `[]` indexing performs).
|
|
115
|
+
re: /\b(?:std::mem::transmute|mem::transmute|std::slice::from_raw_parts(?:_mut)?|slice::from_raw_parts(?:_mut)?)\s*[:(<]|\.get_unchecked(?:_mut)?\s*\(/g,
|
|
116
|
+
vuln: 'Unsafe raw-memory operation (transmute/from_raw_parts/get_unchecked)',
|
|
117
|
+
remediation: 'Prefer safe alternatives where possible: `as` casts or `bytemuck`/`zerocopy` instead of `transmute`; `<[T]>::get(i)`/checked slicing instead of `get_unchecked`. If unsafe is unavoidable, document the exact invariant (length, alignment, lifetime) that makes it sound at this call site.',
|
|
118
|
+
},
|
|
66
119
|
];
|
|
67
120
|
|
|
68
121
|
function lineOf(raw, idx) { return raw.substring(0, idx).split('\n').length; }
|
|
@@ -93,7 +146,11 @@ export function scanRust(fp, raw) {
|
|
|
93
146
|
stride: rule.family === 'sql-injection' ? 'Tampering'
|
|
94
147
|
: rule.family === 'command-injection' ? 'Elevation of Privilege'
|
|
95
148
|
: rule.family === 'weak-rng' ? 'Spoofing'
|
|
149
|
+
: rule.family === 'weak-crypto' ? 'Information Disclosure'
|
|
150
|
+
: rule.family === 'tls-verification' ? 'Spoofing'
|
|
151
|
+
: rule.family === 'memory-safety' ? 'Denial of Service'
|
|
96
152
|
: 'Information Disclosure',
|
|
153
|
+
family: rule.family,
|
|
97
154
|
snippet: (raw.split('\n')[line - 1] || '').trim().slice(0, 200),
|
|
98
155
|
remediation: rule.remediation,
|
|
99
156
|
confidence: 0.85,
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
// content-type, or a raw-superglobal echo) so plain-text responses don't fire.
|
|
13
13
|
|
|
14
14
|
import { blankComments } from './_comment-strip.js';
|
|
15
|
+
import { deadBranchRanges, isLineInDeadRange } from './java-ast-folding.js';
|
|
15
16
|
|
|
16
17
|
const lineOf = (raw, idx) => raw.substring(0, idx).split('\n').length;
|
|
17
18
|
|
|
@@ -77,14 +78,242 @@ const LANGS = {
|
|
|
77
78
|
// Servlet: response.getWriter().write/print/println(...) or a PrintWriter
|
|
78
79
|
// `out`, building an HTML string ("<…") by concatenation. The concat (`"<…"
|
|
79
80
|
// +`) is the reflected-XSS shape — a static literal has no `+`.
|
|
81
|
+
// Each pattern additionally captures a trailing `+ identifier)`/`+
|
|
82
|
+
// identifier;` tail — the SINGLE, LAST concatenated term, when the
|
|
83
|
+
// concatenation ends right there — mirroring java-structural.js's own
|
|
84
|
+
// SQL/cmd-injection capture shape, so `_nearestAssignIsLiteral` (below)
|
|
85
|
+
// can tell a real tainted variable from a hardcoded one. The capture
|
|
86
|
+
// also tolerates ONE simple method call chained directly onto that
|
|
87
|
+
// identifier (`data.replaceAll(...)`) — Juliet's own real corpus shape
|
|
88
|
+
// (confirmed via the public Java mirror,
|
|
89
|
+
// CWE80_XSS__CWE182_Servlet_File_01.java) is exactly this: `data =
|
|
90
|
+
// readerBuffered.readLine()` (tainted) vs `data = "foo"` (literal) in
|
|
91
|
+
// bad()/goodG2B() respectively, both then passed through the IDENTICAL
|
|
92
|
+
// `data.replaceAll("(<script>)", "")` before the sink — a call on a
|
|
93
|
+
// provably-literal receiver is still provably literal. The inner
|
|
94
|
+
// `(?:"[^"]*"|[^()])*` is quote-aware specifically so a literal paren
|
|
95
|
+
// INSIDE a string argument (like `"(<script>)"` above) doesn't
|
|
96
|
+
// terminate the match early — this taint-independent detector, having
|
|
97
|
+
// no taint model of its own, previously fired on both bad() and
|
|
98
|
+
// goodG2B() identically.
|
|
80
99
|
sinks: [
|
|
81
|
-
/\.\s*(?:getWriter\s*\(\s*\)\s*\.\s*)?(?:write|print|println|append|format|printf)\s*\(\s*"<[^"\n]*"\s
|
|
82
|
-
/\b(?:out|writer|pw|w)\s*\.\s*(?:write|print|println|append)\s*\(\s*"<[^"\n]*"\s
|
|
100
|
+
/\.\s*(?:getWriter\s*\(\s*\)\s*\.\s*)?(?:write|print|println|append|format|printf)\s*\(\s*"<[^"\n]*"\s*\+(?:\s*([A-Za-z_]\w*)(?:\s*\.\s*[A-Za-z_]\w*\s*\((?:"[^"]*"|[^()])*\))?\s*(?=(?:\s*\+\s*"[^"\n]*"\s*)?[);]))?/,
|
|
101
|
+
/\b(?:out|writer|pw|w)\s*\.\s*(?:write|print|println|append)\s*\(\s*"<[^"\n]*"\s*\+(?:\s*([A-Za-z_]\w*)(?:\s*\.\s*[A-Za-z_]\w*\s*\((?:"[^"]*"|[^()])*\))?\s*(?=(?:\s*\+\s*"[^"\n]*"\s*)?[);]))?/,
|
|
83
102
|
],
|
|
84
103
|
escaper: /\b(?:encodeForHTML|forHtml|forHtmlContent|htmlEscape|escapeHtml4?|StringEscapeUtils|HtmlUtils\.htmlEscape|Encode\.forHtml|ESAPI|OWASP)\b/,
|
|
85
104
|
},
|
|
86
105
|
};
|
|
87
106
|
|
|
107
|
+
// SARD_80_F1 W4.J28 — Juliet's own "Control flow: if(true) and if(false)"
|
|
108
|
+
// flow variant (its own file header names this explicitly) writes
|
|
109
|
+
// `if (true) { data = "foo"; } else { /* CWE 561 Dead Code */ data = null; }`
|
|
110
|
+
// (or the mirror image for `if (false)`) — the ELSE branch is PROVABLY
|
|
111
|
+
// unreachable (a constant-condition, not a genuine runtime-dependent
|
|
112
|
+
// branch), but a plain backward-scan just finds
|
|
113
|
+
// "the textually nearest assignment", which is the DEAD branch's `data =
|
|
114
|
+
// null;` here (textually last), so it never recognizes `data` as the
|
|
115
|
+
// literal it always actually is.
|
|
116
|
+
//
|
|
117
|
+
// SARD_80_F1 W5.32 — originally patched with this file's OWN narrow regex
|
|
118
|
+
// (matching only `if(true)`/`if(false)`, then widened to also match
|
|
119
|
+
// `if(5==5)`/`if(5!=5)`) — found investigating a NEW fp this exact gap
|
|
120
|
+
// exposed once an unrelated fix (a Java parser bug, W5.32's own
|
|
121
|
+
// StringTokenizer fix) let the deep taint engine correctly detect `bad()`'s
|
|
122
|
+
// real vulnerability for the first time: the resulting dedup/clustering
|
|
123
|
+
// reshuffle stopped accidentally folding this ALREADY-PRESENT, independent
|
|
124
|
+
// XSS-ML false positive into the genuine finding, exposing a real,
|
|
125
|
+
// pre-existing gap rather than introducing one. That narrow regex approach
|
|
126
|
+
// only ever covered 2 of the many dead-code idioms this real corpus uses
|
|
127
|
+
// (checked directly: 04-14/21/22a/31/41/42/45/51b/52c/54e/61a/66b-74b all
|
|
128
|
+
// remained as fps) — rather than keep extending a second, independent
|
|
129
|
+
// regex-based dead-branch detector, this now consumes `java-ast-folding.js`'s
|
|
130
|
+
// existing `deadBranchRanges`/`isLineInDeadRange` — the SAME shared,
|
|
131
|
+
// AST-based (not regex-based) mechanism `java-bench-extras.js` and
|
|
132
|
+
// `java-structural.js` already use, which ALREADY resolves literal
|
|
133
|
+
// true/false, private-static-final fields, effectively-final fields,
|
|
134
|
+
// literal-vs-literal int comparisons, and zero-arg/single-return same-class
|
|
135
|
+
// helper calls (W4.J33-36/W5.27's own incrementally-built capability) — one
|
|
136
|
+
// shared mechanism, extended once, every consumer benefits, matching this
|
|
137
|
+
// whole session's established pattern. The backward-scan now simply SKIPS any assignment whose line falls in a dead
|
|
138
|
+
// range, rather than relying on a pre-blanked copy of the source text.
|
|
139
|
+
// Cross-file variants (the lettered-suffix families) remain correctly
|
|
140
|
+
// out of scope — `deadBranchRanges` is single-file, same boundary as
|
|
141
|
+
// every other interprocedural mechanism in this codebase.
|
|
142
|
+
|
|
143
|
+
// SARD_80_F1 W5.33 — this file's OWN backward-only "nearest assignment"
|
|
144
|
+
// check (formerly `_trailingIdentIsLiteral`) had none of the interprocedural
|
|
145
|
+
// resolution `java-bench-extras.js` already built for the identical Juliet
|
|
146
|
+
// idioms (W4.J37-39): a value returned from a same-file helper
|
|
147
|
+
// (`data = goodG2BSource();`), a same-method copy chain (`dataCopy = data;`
|
|
148
|
+
// … `data = dataCopy;`), or an argument passed to a helper that receives it
|
|
149
|
+
// as a PARAMETER rather than a local assignment. Confirmed via a real-corpus
|
|
150
|
+
// sweep of this file's own remaining CWE-80 fp list (Flow Variants 31/41/42/
|
|
151
|
+
// 45 — no lettered suffix, genuinely same-file) that these are exactly the
|
|
152
|
+
// gap. Rather than re-derive a THIRD independent copy of this logic (a
|
|
153
|
+
// second copy, in java-structural.js, was already ported once at W5.23),
|
|
154
|
+
// this ports the same three functions verbatim in their already-ReDoS-fixed
|
|
155
|
+
// form (W4.J39 — the "return type prefix" lazy `\s`-inclusive character
|
|
156
|
+
// class that caused a confirmed hang was dropped entirely in the source
|
|
157
|
+
// this was copied from; reintroducing it here would reintroduce the exact
|
|
158
|
+
// same hang against Juliet's own large blanked-comment header blocks).
|
|
159
|
+
// `_nearestAssignIsLiteral` is the direct, same-signature replacement for
|
|
160
|
+
// this file's own former `_trailingIdentIsLiteral` at the one call site
|
|
161
|
+
// below. Same-file only, matching every other interprocedural mechanism in
|
|
162
|
+
// this codebase — the lettered-suffix (cross-file) variants remain
|
|
163
|
+
// correctly out of scope.
|
|
164
|
+
const _CALLEE_RETURN_LITERAL_CACHE_DEPTH = 4;
|
|
165
|
+
function _resolveCalleeReturnIsLiteral(content, calleeName, deadRanges, _depth) {
|
|
166
|
+
const depth = _depth || 0;
|
|
167
|
+
if (depth >= _CALLEE_RETURN_LITERAL_CACHE_DEPTH) return false;
|
|
168
|
+
const escapedCallee = calleeName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
169
|
+
const declRe = new RegExp(`\\b${escapedCallee}\\s*\\([^)]*\\)\\s*(?:throws\\s+[\\w.,\\s]+)?\\s*\\{`, 'g');
|
|
170
|
+
const dm = declRe.exec(content);
|
|
171
|
+
if (!dm) return false;
|
|
172
|
+
const bodyStart = dm.index + dm[0].length;
|
|
173
|
+
let braceDepth = 1, i = bodyStart;
|
|
174
|
+
while (i < content.length && braceDepth > 0) {
|
|
175
|
+
if (content[i] === '{') braceDepth++;
|
|
176
|
+
else if (content[i] === '}') braceDepth--;
|
|
177
|
+
i++;
|
|
178
|
+
}
|
|
179
|
+
const body = content.slice(bodyStart, i - 1);
|
|
180
|
+
const returnRe = /\breturn\s+([^;]+);/g;
|
|
181
|
+
const returns = [];
|
|
182
|
+
let rm;
|
|
183
|
+
while ((rm = returnRe.exec(body))) returns.push({ expr: rm[1].trim(), idx: bodyStart + rm.index });
|
|
184
|
+
if (returns.length !== 1) return false; // ambiguous (0 or 2+ returns) — fail closed
|
|
185
|
+
const { expr, idx } = returns[0];
|
|
186
|
+
if (/^"[^"]*"$/.test(expr)) return true;
|
|
187
|
+
if (/^[A-Za-z_]\w*$/.test(expr)) {
|
|
188
|
+
return _nearestAssignIsLiteral(content, expr, idx, deadRanges, depth + 1);
|
|
189
|
+
}
|
|
190
|
+
return false;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Balanced top-level comma split (paren/bracket/brace-aware, quote-aware).
|
|
194
|
+
function _splitTopLevelCommas(text) {
|
|
195
|
+
const parts = [];
|
|
196
|
+
let depth = 0, cur = '', inStr = null;
|
|
197
|
+
for (let i = 0; i < text.length; i++) {
|
|
198
|
+
const c = text[i];
|
|
199
|
+
if (inStr) {
|
|
200
|
+
cur += c;
|
|
201
|
+
if (c === '\\') { cur += text[++i] || ''; continue; }
|
|
202
|
+
if (c === inStr) inStr = null;
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
if (c === '"' || c === "'") { inStr = c; cur += c; continue; }
|
|
206
|
+
if (c === '(' || c === '[' || c === '{') depth++;
|
|
207
|
+
else if (c === ')' || c === ']' || c === '}') depth--;
|
|
208
|
+
if (c === ',' && depth === 0) { parts.push(cur); cur = ''; continue; }
|
|
209
|
+
cur += c;
|
|
210
|
+
}
|
|
211
|
+
if (cur.trim() !== '' || parts.length) parts.push(cur);
|
|
212
|
+
return parts;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function _resolveParamLiteralViaAllCallSites(content, varName, beforeIdx, deadRanges, _depth) {
|
|
216
|
+
const depth0 = _depth || 0;
|
|
217
|
+
if (depth0 >= _CALLEE_RETURN_LITERAL_CACHE_DEPTH) return false;
|
|
218
|
+
const escapedVar = varName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
219
|
+
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;
|
|
220
|
+
let enclosing = null, dm;
|
|
221
|
+
while ((dm = methodDeclRe.exec(content)) && dm.index < beforeIdx) enclosing = dm;
|
|
222
|
+
if (!enclosing) return false;
|
|
223
|
+
const methodName = enclosing[1];
|
|
224
|
+
const params = _splitTopLevelCommas(enclosing[2]).map(p => p.trim()).filter(Boolean);
|
|
225
|
+
const paramIdx = params.findIndex((p) => new RegExp(`\\b${escapedVar}$`).test(p));
|
|
226
|
+
if (paramIdx === -1) return false; // varName isn't actually a param of the enclosing method
|
|
227
|
+
const escapedMethod = methodName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
228
|
+
const callRe = new RegExp(`\\b${escapedMethod}\\s*\\(`, 'g');
|
|
229
|
+
let cm, sawRealCallSite = false;
|
|
230
|
+
while ((cm = callRe.exec(content))) {
|
|
231
|
+
const argsStart = cm.index + cm[0].length;
|
|
232
|
+
let braceDepth = 1, i = argsStart;
|
|
233
|
+
while (i < content.length && braceDepth > 0) {
|
|
234
|
+
if (content[i] === '(') braceDepth++;
|
|
235
|
+
else if (content[i] === ')') braceDepth--;
|
|
236
|
+
i++;
|
|
237
|
+
}
|
|
238
|
+
const afterClose = content.slice(i).match(/^\s*(\{|throws)/);
|
|
239
|
+
if (afterClose) continue; // this is the method's OWN declaration, not a call site
|
|
240
|
+
const args = _splitTopLevelCommas(content.slice(argsStart, i - 1)).map((a) => a.trim());
|
|
241
|
+
if (paramIdx >= args.length) return false; // arity mismatch — bail conservatively
|
|
242
|
+
sawRealCallSite = true;
|
|
243
|
+
const argExpr = args[paramIdx];
|
|
244
|
+
if (/^"[^"]*"$/.test(argExpr)) continue; // this caller's own argument is a direct literal
|
|
245
|
+
if (/^[A-Za-z_]\w*$/.test(argExpr) && _nearestAssignIsLiteral(content, argExpr, cm.index, deadRanges, depth0 + 1)) continue;
|
|
246
|
+
return false; // a non-literal (or unresolvable) caller exists
|
|
247
|
+
}
|
|
248
|
+
return sawRealCallSite;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// SARD_80_F1 W5.34 — Juliet Flow Variant 45: a value passed as a private
|
|
252
|
+
// class member variable between two methods of the same class. See
|
|
253
|
+
// java-bench-extras.js's own header comment on the identical, canonical
|
|
254
|
+
// copy of this function for the full idiom and precision reasoning. Only
|
|
255
|
+
// resolves when `varName` is confirmed to be an actual field declaration
|
|
256
|
+
// (access-modifier-qualified — never valid on a local variable or
|
|
257
|
+
// parameter).
|
|
258
|
+
function _resolveFieldLiteralViaAllAssignments(content, varName, deadRanges, _depth) {
|
|
259
|
+
const depth0 = _depth || 0;
|
|
260
|
+
if (depth0 >= _CALLEE_RETURN_LITERAL_CACHE_DEPTH) return false;
|
|
261
|
+
const escaped = varName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
262
|
+
const fieldDeclRe = new RegExp(`\\b(?:private|protected|public)\\s+[\\w<>\\[\\],.\\s]+\\b${escaped}\\s*(?:=\\s*[^;]+)?;`);
|
|
263
|
+
if (!fieldDeclRe.test(content)) return false;
|
|
264
|
+
const ranges = deadRanges || [];
|
|
265
|
+
const assignRe = new RegExp(`\\b${escaped}\\s*=\\s*([^;]+);`, 'g');
|
|
266
|
+
const lineOf_ = (idx) => content.substring(0, idx).split('\n').length;
|
|
267
|
+
let sawAssignment = false, m;
|
|
268
|
+
while ((m = assignRe.exec(content))) {
|
|
269
|
+
if (ranges.length && isLineInDeadRange(lineOf_(m.index), ranges)) continue;
|
|
270
|
+
sawAssignment = true;
|
|
271
|
+
const rhs = m[1].trim();
|
|
272
|
+
if (/^"[^"]*"$/.test(rhs)) continue;
|
|
273
|
+
if (/^[A-Za-z_]\w*$/.test(rhs) && rhs !== varName
|
|
274
|
+
&& _nearestAssignIsLiteral(content, rhs, m.index, ranges, depth0 + 1)) continue;
|
|
275
|
+
return false;
|
|
276
|
+
}
|
|
277
|
+
return sawAssignment;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function _nearestAssignIsLiteral(content, varName, beforeIdx, deadRanges, _calleeDepth) {
|
|
281
|
+
if (!varName) return false;
|
|
282
|
+
if ((_calleeDepth || 0) >= _CALLEE_RETURN_LITERAL_CACHE_DEPTH) return false;
|
|
283
|
+
const ranges = deadRanges || [];
|
|
284
|
+
const escaped = varName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
285
|
+
const literalRe = new RegExp(`\\b${escaped}\\s*=\\s*"[^"]*"\\s*;`, 'g');
|
|
286
|
+
const anyAssignRe = new RegExp(`\\b${escaped}\\s*=\\s*([^;]+);`, 'g');
|
|
287
|
+
const lineOf = (idx) => content.substring(0, idx).split('\n').length;
|
|
288
|
+
let lastLiteralEnd = -1, m;
|
|
289
|
+
while ((m = literalRe.exec(content)) && m.index < beforeIdx) {
|
|
290
|
+
if (ranges.length && isLineInDeadRange(lineOf(m.index), ranges)) continue;
|
|
291
|
+
lastLiteralEnd = m.index + m[0].length;
|
|
292
|
+
}
|
|
293
|
+
let lastAnyEnd = -1, lastAnyRhs = null, lastAnyIdx = -1;
|
|
294
|
+
while ((m = anyAssignRe.exec(content)) && m.index < beforeIdx) {
|
|
295
|
+
if (ranges.length && isLineInDeadRange(lineOf(m.index), ranges)) continue;
|
|
296
|
+
lastAnyEnd = m.index + m[0].length;
|
|
297
|
+
lastAnyRhs = m[1].trim();
|
|
298
|
+
lastAnyIdx = m.index;
|
|
299
|
+
}
|
|
300
|
+
if (lastAnyRhs && /^[A-Za-z_]\w*\s*\([^()]*\)$/.test(lastAnyRhs)) {
|
|
301
|
+
const calleeName = lastAnyRhs.slice(0, lastAnyRhs.indexOf('(')).trim();
|
|
302
|
+
if (_resolveCalleeReturnIsLiteral(content, calleeName, ranges, _calleeDepth)) return true;
|
|
303
|
+
}
|
|
304
|
+
if (lastAnyRhs && lastAnyRhs !== 'null' && /^[A-Za-z_]\w*$/.test(lastAnyRhs) && lastAnyRhs !== varName) {
|
|
305
|
+
if (_nearestAssignIsLiteral(content, lastAnyRhs, lastAnyIdx, ranges, (_calleeDepth || 0) + 1)) return true;
|
|
306
|
+
}
|
|
307
|
+
if (lastLiteralEnd === -1 || lastLiteralEnd !== lastAnyEnd) {
|
|
308
|
+
return _resolveParamLiteralViaAllCallSites(content, varName, beforeIdx, deadRanges, _calleeDepth)
|
|
309
|
+
|| _resolveFieldLiteralViaAllAssignments(content, varName, deadRanges, _calleeDepth);
|
|
310
|
+
}
|
|
311
|
+
const paramRe = new RegExp(`\\([^()]*\\b[\\w.<>\\[\\]]+\\s+${escaped}\\s*[,)]`, 'g');
|
|
312
|
+
let lastParamEnd = -1;
|
|
313
|
+
while ((m = paramRe.exec(content)) && m.index < beforeIdx) lastParamEnd = m.index + m[0].length;
|
|
314
|
+
return lastParamEnd <= lastLiteralEnd;
|
|
315
|
+
}
|
|
316
|
+
|
|
88
317
|
export function scanXssReflectedMultilang(fp, raw) {
|
|
89
318
|
if (!raw || raw.length > 500_000) return [];
|
|
90
319
|
let lang = null;
|
|
@@ -93,13 +322,33 @@ export function scanXssReflectedMultilang(fp, raw) {
|
|
|
93
322
|
|
|
94
323
|
const code = blankComments(raw, /\.rb$/i.test(fp) ? 'py' : (/\.(?:php|phtml)$/i.test(fp) ? 'php' : undefined));
|
|
95
324
|
const lines = code.split('\n');
|
|
325
|
+
let deadRanges = [];
|
|
326
|
+
if (lang === LANGS.java) { try { deadRanges = deadBranchRanges(code); } catch { deadRanges = []; } }
|
|
96
327
|
const findings = [];
|
|
97
328
|
const seen = new Set();
|
|
98
329
|
|
|
330
|
+
let lineStartOffset = 0;
|
|
99
331
|
for (let i = 0; i < lines.length; i++) {
|
|
100
332
|
const line = lines[i];
|
|
333
|
+
const thisLineStart = lineStartOffset;
|
|
334
|
+
lineStartOffset += line.length + 1; // +1 for the '\n' _splitStatements-style join consumed
|
|
101
335
|
if (lang.escaper.test(line)) continue;
|
|
102
|
-
|
|
336
|
+
let sinkMatch = null;
|
|
337
|
+
for (const re of lang.sinks) {
|
|
338
|
+
const m = line.match(re);
|
|
339
|
+
if (m) { sinkMatch = m; break; }
|
|
340
|
+
}
|
|
341
|
+
if (!sinkMatch) continue;
|
|
342
|
+
// SARD_80_F1 W5.35 — a sink call sitting INSIDE a provably-dead branch
|
|
343
|
+
// (Juliet's "switch(8){case 7: <tainted sink>}" idiom) is unreachable
|
|
344
|
+
// regardless of whether the value reaching it is tainted — the
|
|
345
|
+
// literal-check below can never suppress this shape, since the value
|
|
346
|
+
// genuinely IS tainted, just unreachable.
|
|
347
|
+
if (lang === LANGS.java && deadRanges.length && isLineInDeadRange(i + 1, deadRanges)) continue;
|
|
348
|
+
// SARD_80_F1 W3.x — a captured trailing identifier (Java's sink patterns
|
|
349
|
+
// only) that's provably a hardcoded literal at this point is not a real
|
|
350
|
+
// XSS flow; see `_nearestAssignIsLiteral`'s header comment.
|
|
351
|
+
if (lang === LANGS.java && sinkMatch[1] && _nearestAssignIsLiteral(code, sinkMatch[1], thisLineStart + sinkMatch.index, deadRanges)) continue;
|
|
103
352
|
const ln = i + 1;
|
|
104
353
|
const id = `xss-reflected:${fp}:${ln}`;
|
|
105
354
|
if (seen.has(id)) continue;
|