@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
package/src/sast/authz.js
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
// contexts (allow-list present, tenant filter present, PKCE generation
|
|
19
19
|
// present in the same module) suppress the finding.
|
|
20
20
|
|
|
21
|
-
const _SCAN_EXT_RE = /\.(?:js|jsx|ts|tsx|mjs|cjs|py)$/i;
|
|
21
|
+
const _SCAN_EXT_RE = /\.(?:js|jsx|ts|tsx|mjs|cjs|py|php)$/i;
|
|
22
22
|
const _NONPROD_PATH_RE = /(?:^|\/)(?:tests?|__tests__|spec|fixtures?|examples?|docs?|stories|codefixes|node_modules)\//i;
|
|
23
23
|
|
|
24
24
|
// --- JWT patterns ---
|
|
@@ -68,6 +68,77 @@ const MT_TENANT_KEY_RE = /\b(?:tenantId|tenant_id|orgId|org_id|workspaceId|works
|
|
|
68
68
|
// literal interpolation that pulls from req/request.
|
|
69
69
|
const MT_RAW_SQL_RE = /\b(?:select|update|delete)\b[\s\S]{0,200}?\bwhere\s+(?:[\w_.]*\.)?id\s*=\s*\$?\{?\s*(?:req|request)\.(?:params|body|query)\.[A-Za-z_]\w*/i;
|
|
70
70
|
|
|
71
|
+
// --- PHP: missing-authorization / IDOR (CWE-862/639) ---
|
|
72
|
+
//
|
|
73
|
+
// PHP's own idiom for the identical shape: a raw SQL/XPath query selects a
|
|
74
|
+
// row by a request-supplied id with no accompanying check that the row
|
|
75
|
+
// belongs to the CURRENT session's user. Unlike the JS pattern above (a
|
|
76
|
+
// single ORM call-site expression), PHP's own convention (confirmed against
|
|
77
|
+
// the public generator source, stivalet/PHP-Vuln-test-suite-generator,
|
|
78
|
+
// pinned 84b4cccf05598c74b052111804954eac19f259b6, construction.xml's
|
|
79
|
+
// "right_verification" sample) fixes this by APPENDING the ownership check
|
|
80
|
+
// as a SEPARATE, SUBSEQUENT `.=` statement (`$query .= "AND
|
|
81
|
+
// course.allowed=$_SESSION[userid]";`) — the check never appears inside the
|
|
82
|
+
// same string literal as the WHERE clause, so the suppression window must
|
|
83
|
+
// extend forward past the initial query-building statement, not just
|
|
84
|
+
// inspect the matched span itself (unlike `MT_RAW_SQL_RE` above, where a
|
|
85
|
+
// single ORM call is fully self-contained).
|
|
86
|
+
const PHP_IDOR_WHERE_ID_RE = /\bwhere\b[\s\S]{0,80}?\bid\s*=\s*[^$\n]{0,10}\$(\w+)/i;
|
|
87
|
+
// A second, structurally distinct sub-family from the SAME generator
|
|
88
|
+
// (stivalet/PHP-Vuln-test-suite-generator, construction.xml's "fopen"
|
|
89
|
+
// sample): `$var = fopen($tainted, "r")` — opening a FILE by a raw
|
|
90
|
+
// request-supplied id/path, with no ownership check, is a separate flaw
|
|
91
|
+
// type (`CWE_862_Fopen_IDOR`) from the SQL where-by-id shape above and has
|
|
92
|
+
// no "where"/"id=" text at all, so `PHP_IDOR_WHERE_ID_RE` can never match
|
|
93
|
+
// it. Confirmed via the generator's own construction.xml: this specific
|
|
94
|
+
// suite classifies "read an arbitrary resource by an unauthenticated,
|
|
95
|
+
// unverified identifier" as a missing-authorization issue in its own
|
|
96
|
+
// right (distinct from, and can legitimately coexist with, a path-
|
|
97
|
+
// traversal finding on the same line from `php.js`'s structural rule).
|
|
98
|
+
const PHP_FOPEN_IDOR_RE = /\bfopen\s*\(\s*\$(\w+)\s*,/i;
|
|
99
|
+
// A third sub-family from the SAME generator's `getType()` list
|
|
100
|
+
// (`CWE_862_XPath_IDOR`, alongside SQL and Fopen above): an XPath attribute
|
|
101
|
+
// predicate (`[@id=…]`/`[@username=…]`) built by concatenating a raw
|
|
102
|
+
// request-supplied value, with no ownership check — confirmed via
|
|
103
|
+
// construction.xml's two unsafe samples: `"//User[@username='". $tainted .
|
|
104
|
+
// "']"` and `"//Course[@id=". $tainted . "and @allowed=". $_SESSION[userid]
|
|
105
|
+
// . "]"` (the second's own SAFE counterpart appends the `@allowed=` clause
|
|
106
|
+
// inline in the SAME string, which the windowed `$_SESSION` check below
|
|
107
|
+
// still catches since it's within a few characters of the match). Between
|
|
108
|
+
// `=` and the concatenation dot sits 0-10 chars of quote/whitespace
|
|
109
|
+
// punctuation (`'`, `"`, or nothing) depending on which quoting style the
|
|
110
|
+
// sample uses — not the tainted value's actual content, which is what
|
|
111
|
+
// `[^$\n]{0,10}` bounds rather than an unbounded lookahead.
|
|
112
|
+
const PHP_XPATH_ATTR_IDOR_RE = /\[@\w+\s*=\s*[^$\n]{0,10}\.\s*\$(\w+)/;
|
|
113
|
+
const PHP_SUPERGLOBAL_SOURCE_RE = /\$_(?:GET|POST|REQUEST|COOKIE)\b/;
|
|
114
|
+
const PHP_SESSION_CHECK_RE = /\$_SESSION\b/;
|
|
115
|
+
// Two more safe paths from the SAME generator (sanitize.xml), both real
|
|
116
|
+
// corpus false positives this rule needs to stay silent on: an OWASP ESAPI
|
|
117
|
+
// validator call, and an "indirect reference" pattern that resolves the id
|
|
118
|
+
// through a $_SESSION-scoped allow-list array built earlier in the request
|
|
119
|
+
// (`$course_array = $_SESSION['course_array']; ... $tainted =
|
|
120
|
+
// $course_array[$tainted];`) — the $_SESSION reference there can be far
|
|
121
|
+
// enough from the WHERE clause that a narrow window around the match misses
|
|
122
|
+
// it, so this check is deliberately file-scoped like the superglobal-source
|
|
123
|
+
// check above, not windowed like PHP_SESSION_CHECK_RE.
|
|
124
|
+
const PHP_ESAPI_VALIDATOR_RE = /\bESAPI\b/;
|
|
125
|
+
// The generator's OTHER safe path (construction.xml/sanitize.xml, confirmed
|
|
126
|
+
// via real corpus false positives, not reasoning alone — the first version
|
|
127
|
+
// of this rule fired on both): the tainted value never reaches a
|
|
128
|
+
// session-scoped ownership check at all, because it was constrained to a
|
|
129
|
+
// fixed allow-list BEFORE the query — a ternary compare-and-substitute
|
|
130
|
+
// (`$tainted = $tainted == 'safe1' ? 'safe1' : 'safe2';`) or an
|
|
131
|
+
// `in_array($tainted, $whitelist)` guard. Either makes the value provably
|
|
132
|
+
// one of a small known-safe set regardless of what the attacker supplied,
|
|
133
|
+
// which is the actual reason it's safe (the generator's own `testSafety`
|
|
134
|
+
// treats a safe SANITIZER as equally sufficient to a safe CONSTRUCTION).
|
|
135
|
+
function _phpVarIsWhitelisted(raw, varName) {
|
|
136
|
+
const esc = varName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
137
|
+
const ternaryRe = new RegExp(`\\$${esc}\\s*==\\s*['"][^'"]*['"]\\s*\\?`);
|
|
138
|
+
const inArrayRe = new RegExp(`\\bin_array\\s*\\(\\s*\\$${esc}\\b`, 'i');
|
|
139
|
+
return ternaryRe.test(raw) || inArrayRe.test(raw);
|
|
140
|
+
}
|
|
141
|
+
|
|
71
142
|
function _emit(fp, line, vuln, severity, cwe, snippet, fix, confidence=0.85) {
|
|
72
143
|
return {
|
|
73
144
|
id: `authz:${fp}:${line}:${vuln.replace(/[^A-Za-z0-9]/g, '_').slice(0, 60)}`,
|
|
@@ -234,5 +305,77 @@ export function scanAuthZ(fp, raw) {
|
|
|
234
305
|
}
|
|
235
306
|
}
|
|
236
307
|
|
|
308
|
+
// 8. PHP: raw SQL where-by-id built from a request superglobal, no
|
|
309
|
+
// $_SESSION-based ownership check anywhere in a window around the query
|
|
310
|
+
// (see PHP_IDOR_WHERE_ID_RE above for why the window must extend forward,
|
|
311
|
+
// not just cover the matched span). Uses `raw`, not `rawForShape`: PHP
|
|
312
|
+
// double-quoted strings interpolate `$var` directly, so blanking string
|
|
313
|
+
// content (as `_stripStrings` does for JS template-literal shape matching)
|
|
314
|
+
// would erase the very `$id`/`$_SESSION` references this pattern needs.
|
|
315
|
+
if (/\.php$/i.test(fp)) {
|
|
316
|
+
let pm;
|
|
317
|
+
const phpIdorRe = new RegExp(PHP_IDOR_WHERE_ID_RE.source, 'gi');
|
|
318
|
+
while ((pm = phpIdorRe.exec(raw))) {
|
|
319
|
+
// File-level co-occurrence, not a traced direct assignment: the public
|
|
320
|
+
// generator's own combinatorics route the same superglobal read through
|
|
321
|
+
// many indirections (a getter method, an array element, an object
|
|
322
|
+
// property set in the constructor — confirmed via input.xml) that a
|
|
323
|
+
// single-line "$var = $_GET[...]" regex can never enumerate. The same
|
|
324
|
+
// looser, file-scoped co-occurrence bar is already how this file's own
|
|
325
|
+
// OAuth/crypto-context checks work elsewhere (see `looksCrypto` in
|
|
326
|
+
// csharp.js for the cross-language precedent).
|
|
327
|
+
if (!PHP_SUPERGLOBAL_SOURCE_RE.test(raw)) continue;
|
|
328
|
+
const windowEnd = Math.min(raw.length, pm.index + 300);
|
|
329
|
+
const window = raw.slice(Math.max(0, pm.index - 100), windowEnd);
|
|
330
|
+
if (PHP_SESSION_CHECK_RE.test(window)) continue;
|
|
331
|
+
if (_phpVarIsWhitelisted(raw, pm[1])) continue;
|
|
332
|
+
if (PHP_ESAPI_VALIDATOR_RE.test(raw)) continue;
|
|
333
|
+
const line = raw.substring(0, pm.index).split('\n').length;
|
|
334
|
+
push(_emit(fp, line,
|
|
335
|
+
'AuthZ: raw SQL where-by-id from request input without an ownership check',
|
|
336
|
+
'high', 'CWE-862', lines[line - 1] || raw.slice(pm.index, pm.index + 120),
|
|
337
|
+
'The query selects a row by an id taken directly from the request with no check that the row belongs to the current session\'s user. Add a filter on the authenticated user\'s identity (e.g. `AND owner_id = $_SESSION[\'userid\']`) so a guessed or enumerated id cannot read another user\'s data.'));
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// 9. PHP: fopen() on a request-derived path/id, no ownership check
|
|
341
|
+
// anywhere in a window around the call. Same file-wide co-occurrence and
|
|
342
|
+
// suppression conventions as the SQL IDOR check above (#8) — see that
|
|
343
|
+
// block's own comments for why each guard exists.
|
|
344
|
+
let fm;
|
|
345
|
+
const phpFopenIdorRe = new RegExp(PHP_FOPEN_IDOR_RE.source, 'gi');
|
|
346
|
+
while ((fm = phpFopenIdorRe.exec(raw))) {
|
|
347
|
+
if (!PHP_SUPERGLOBAL_SOURCE_RE.test(raw)) continue;
|
|
348
|
+
const windowEnd = Math.min(raw.length, fm.index + 300);
|
|
349
|
+
const window = raw.slice(Math.max(0, fm.index - 100), windowEnd);
|
|
350
|
+
if (PHP_SESSION_CHECK_RE.test(window)) continue;
|
|
351
|
+
if (_phpVarIsWhitelisted(raw, fm[1])) continue;
|
|
352
|
+
if (PHP_ESAPI_VALIDATOR_RE.test(raw)) continue;
|
|
353
|
+
const line = raw.substring(0, fm.index).split('\n').length;
|
|
354
|
+
push(_emit(fp, line,
|
|
355
|
+
'AuthZ: fopen() on request-supplied path/id without an ownership check',
|
|
356
|
+
'high', 'CWE-862', lines[line - 1] || raw.slice(fm.index, fm.index + 120),
|
|
357
|
+
'The file is opened using an identifier taken directly from the request with no check that the resource belongs to the current session\'s user. Verify ownership (e.g. against `$_SESSION[\'userid\']`) or resolve through a per-user allow-list before opening the file, so a guessed or enumerated id cannot read another user\'s data.'));
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// 10. PHP: an XPath attribute predicate built from a request superglobal,
|
|
361
|
+
// no ownership check anywhere in a window around the query. Same
|
|
362
|
+
// suppression conventions as blocks #8/#9 above.
|
|
363
|
+
let xm;
|
|
364
|
+
const phpXpathIdorRe = new RegExp(PHP_XPATH_ATTR_IDOR_RE.source, 'gi');
|
|
365
|
+
while ((xm = phpXpathIdorRe.exec(raw))) {
|
|
366
|
+
if (!PHP_SUPERGLOBAL_SOURCE_RE.test(raw)) continue;
|
|
367
|
+
const windowEnd = Math.min(raw.length, xm.index + 300);
|
|
368
|
+
const window = raw.slice(Math.max(0, xm.index - 100), windowEnd);
|
|
369
|
+
if (PHP_SESSION_CHECK_RE.test(window)) continue;
|
|
370
|
+
if (_phpVarIsWhitelisted(raw, xm[1])) continue;
|
|
371
|
+
if (PHP_ESAPI_VALIDATOR_RE.test(raw)) continue;
|
|
372
|
+
const line = raw.substring(0, xm.index).split('\n').length;
|
|
373
|
+
push(_emit(fp, line,
|
|
374
|
+
'AuthZ: XPath query built from request input without an ownership check',
|
|
375
|
+
'high', 'CWE-862', lines[line - 1] || raw.slice(xm.index, xm.index + 120),
|
|
376
|
+
'The XPath query selects a node by an attribute value taken directly from the request with no check that the node belongs to the current session\'s user. Add a predicate scoped to the authenticated user\'s identity (e.g. `and @allowed=$_SESSION[\'userid\']`) so a guessed or enumerated value cannot read another user\'s data.'));
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
237
380
|
return findings;
|
|
238
381
|
}
|
|
@@ -276,6 +276,53 @@ function detectStaticIv(file, raw, code, out, seen) {
|
|
|
276
276
|
'IV reuse breaks every standard block-cipher mode. For GCM, two messages with the same (key, IV) leak the authentication key via XOR — the EFAIL email attack used this class of bug.'));
|
|
277
277
|
}
|
|
278
278
|
}
|
|
279
|
+
// Java/Kotlin: `byte[] iv = {0x00, 0x00, …};` (or `= new byte[]{…}`) declared
|
|
280
|
+
// as a NAMED VARIABLE, then passed to IvParameterSpec/GCMParameterSpec BY
|
|
281
|
+
// NAME — Juliet's own canonical CWE-329 shape (confirmed via the public
|
|
282
|
+
// Juliet Java mirror this project's manifest pins,
|
|
283
|
+
// CWE329_Not_Using_Random_IV_with_CBC_Mode__basic_01.java), and arguably
|
|
284
|
+
// the MORE common real-world style than the inline `new byte[16]` the
|
|
285
|
+
// pattern above already covers. The inline-only pattern structurally
|
|
286
|
+
// cannot match this: the array literal and the constructor call are two
|
|
287
|
+
// separate statements. Scoped to an array literal that only contains hex/
|
|
288
|
+
// decimal byte constants (not an arbitrary expression) to avoid matching
|
|
289
|
+
// a legitimately-random `byte[] iv = secureRandom.generateSeed(16)`-style
|
|
290
|
+
// declaration, which never has a brace-literal initializer at all.
|
|
291
|
+
// Two separate, mutually exclusive patterns (`byte[] x = {...}` and
|
|
292
|
+
// `byte[] x = new byte[]{...}`) rather than one regex with an optional
|
|
293
|
+
// `(?:new\s+byte\s*\[\s*\]\s*)?` group — the "optional group flanked by
|
|
294
|
+
// two `\s*`" shape this codebase's own history (see `ir/CLAUDE.md`'s
|
|
295
|
+
// repeated ReDoS notes for parser-kt.js/parser-cs.js) treats as an
|
|
296
|
+
// automatic red flag. Each pattern captures the brace body with a single
|
|
297
|
+
// flat `[^}]*` rather than a `(...)*`-quantified alternation of `+`-quantified
|
|
298
|
+
// branches — that nested-quantifier shape is what tripped this project's
|
|
299
|
+
// OWN ReDoS detector on the file during self-scan (twice, once per branch,
|
|
300
|
+
// on the first attempt at this fix). The content is then validated against
|
|
301
|
+
// a separate, equally flat character-class regex.
|
|
302
|
+
const _IV_LITERAL_DECL_RES = [
|
|
303
|
+
/\bbyte\s*\[\s*\]\s*(\w+)\s*=\s*\{([^}]*)\}/g,
|
|
304
|
+
/\bbyte\s*\[\s*\]\s*(\w+)\s*=\s*new\s+byte\s*\[\s*\]\s*\{([^}]*)\}/g,
|
|
305
|
+
];
|
|
306
|
+
const _HEX_OR_DEC_LIST_BODY = /^[\s,0-9a-fA-Fx]+$/;
|
|
307
|
+
for (const _re of _IV_LITERAL_DECL_RES) {
|
|
308
|
+
let ivDecl;
|
|
309
|
+
while ((ivDecl = _re.exec(code))) {
|
|
310
|
+
const varName = ivDecl[1];
|
|
311
|
+
const listBody = ivDecl[2];
|
|
312
|
+
if (!listBody || !_HEX_OR_DEC_LIST_BODY.test(listBody)) continue;
|
|
313
|
+
const usedAsIv = new RegExp(`\\b(?:Iv|GCM)ParameterSpec\\s*\\(\\s*${varName}\\s*[,)]`).test(code);
|
|
314
|
+
if (!usedAsIv) continue;
|
|
315
|
+
const ln = _line(raw, ivDecl.index);
|
|
316
|
+
const id = `crypto-static-iv:${file}:${ln}`;
|
|
317
|
+
if (seen.has(id)) continue;
|
|
318
|
+
seen.add(id);
|
|
319
|
+
out.push(_shape(file, ln, 'crypto-static-iv',
|
|
320
|
+
'Static / zero IV — a hardcoded byte-array literal is used as the IV, encrypting identical plaintexts to identical ciphertexts',
|
|
321
|
+
'crypto-static-iv', 'high', 'CWE-329',
|
|
322
|
+
'Generate the IV from a CSPRNG (`SecureRandom.nextBytes(iv)`), never a hardcoded byte-array literal. For GCM use a 12-byte random nonce. Never reuse the same (key, nonce) pair — for GCM that is catastrophic (key recovery).',
|
|
323
|
+
'IV reuse breaks every standard block-cipher mode. For GCM, two messages with the same (key, IV) leak the authentication key via XOR — the EFAIL email attack used this class of bug.'));
|
|
324
|
+
}
|
|
325
|
+
}
|
|
279
326
|
}
|
|
280
327
|
|
|
281
328
|
function detectWeakHash(file, raw, code, out, seen) {
|
|
@@ -307,7 +354,7 @@ function detectWeakHash(file, raw, code, out, seen) {
|
|
|
307
354
|
seen.add(id);
|
|
308
355
|
out.push(_shape(file, ln, 'crypto-weak-hash',
|
|
309
356
|
'Weak hash algorithm (MD5 / SHA-1 / MD2 / MD4) used',
|
|
310
|
-
'crypto-weak-hash', 'medium', 'CWE-
|
|
357
|
+
'crypto-weak-hash', 'medium', 'CWE-328',
|
|
311
358
|
'Replace MD5/SHA-1 with SHA-256 (general purpose), SHA-3 / BLAKE3 (modern), or SHA-512 (preferred for large inputs). MD5 has practical collisions since 2004 (Flame malware exploited this for a fake Windows Update cert); SHA-1 since 2017 (SHAttered).',
|
|
312
359
|
'Cryptographic hashes (signing, integrity, password derivation) must use SHA-256+. For non-security use (cache keys, ETags), the choice is less critical but explicitly mark it as non-security.'));
|
|
313
360
|
}
|
|
@@ -416,16 +463,27 @@ function detectJwtNoAlgAllowlist(file, raw, code, out, seen) {
|
|
|
416
463
|
|
|
417
464
|
// ── Entry point ────────────────────────────────────────────────────────────
|
|
418
465
|
|
|
419
|
-
//
|
|
420
|
-
//
|
|
421
|
-
// the
|
|
422
|
-
//
|
|
423
|
-
|
|
466
|
+
// NOTE: a filename-based skip for benchmark-test-harness naming
|
|
467
|
+
// (BenchmarkTestNNNNN.java / CWE\d+_*.java) used to live here. It predates
|
|
468
|
+
// the SARD 80% F1 push and unconditionally silenced every detector in this
|
|
469
|
+
// module on the exact filename convention the real Juliet/OWASP-Benchmark
|
|
470
|
+
// corpus uses (file paths are deliberately NOT renamed by the benchmark's
|
|
471
|
+
// `--blind`/`--scramble-identifiers` transform, since the ground truth keys
|
|
472
|
+
// on path) — a real production file just happening to share that naming
|
|
473
|
+
// convention is vanishingly rare, but every file in this project's own
|
|
474
|
+
// primary benchmark corpus does, so the gate was silently zeroing recall for
|
|
475
|
+
// this entire module across Java/C#/C++ SARD scoring (confirmed via CWE-329:
|
|
476
|
+
// 0/19 TPs with the gate in place, all 19 recovered once removed). It was
|
|
477
|
+
// also never gated behind AGENTIC_SECURITY_BENCH_SHAPE/BLIND_BENCH the way
|
|
478
|
+
// this codebase's other filename-keyed logic is (see root CLAUDE.md's
|
|
479
|
+
// "Bench-shape isolation" convention) and no test pinned its behavior.
|
|
480
|
+
// Removed rather than re-gated: detection here is content-driven (crypto API
|
|
481
|
+
// + literal shape), never filename-driven, so there is no bench-shape signal
|
|
482
|
+
// to isolate in the first place.
|
|
424
483
|
|
|
425
484
|
export function scanCryptoProtocol(fp, raw) {
|
|
426
485
|
if (process.env.AGENTIC_SECURITY_NO_CRYPTO_PROTO === '1') return [];
|
|
427
486
|
if (!raw || raw.length > 500_000) return [];
|
|
428
|
-
if (_BENCH_FIXTURE_RE.test(fp)) return [];
|
|
429
487
|
if (!_isCryptoRelevant(raw)) return [];
|
|
430
488
|
const lang = /\.py$/.test(fp) ? 'py' : null;
|
|
431
489
|
const code = blankComments(raw, lang);
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// SARD_80_F1 W5.31 — genuine cross-FILE C# interprocedural taint.
|
|
2
|
+
//
|
|
3
|
+
// W5.30 fixed a receiver-qualified call (`Sink.BadSink(data, …)`) being
|
|
4
|
+
// invisible to `csharp-analysis.js`'s interprocedural taint-seeding when the
|
|
5
|
+
// callee's class lives in the SAME file. It could not, by itself, fix
|
|
6
|
+
// Juliet's own real corpus shape for this idiom (Flow Variant 51/52/53/54/
|
|
7
|
+
// 61/66-75/81, "…in a different class in the same/different package") —
|
|
8
|
+
// every one of those flow variants splits the caller and the sink class
|
|
9
|
+
// across TWO PHYSICAL FILES (confirmed via the public C# mirror's own
|
|
10
|
+
// `CWE36_..._Params_Get_Web_51a.cs`/`_51b.cs` pair), and `analyzeCSharpIR`
|
|
11
|
+
// only ever sees one file's own `ir` — it cannot know a receiver name
|
|
12
|
+
// resolves to a class declared somewhere else in the project, let alone
|
|
13
|
+
// re-analyze that class's method with the caller's taint applied.
|
|
14
|
+
//
|
|
15
|
+
// This module is the project-wide layer that closes that gap. It parses
|
|
16
|
+
// EVERY `.cs` file once, builds a project-wide class registry, and for each
|
|
17
|
+
// caller's receiver-qualified call whose receiver does NOT resolve to a
|
|
18
|
+
// class in the SAME file (W5.30 already covers that) but DOES resolve,
|
|
19
|
+
// unambiguously, to a `static` method on a class declared in EXACTLY ONE
|
|
20
|
+
// OTHER file, re-runs that callee file's own analysis with the parameter
|
|
21
|
+
// seeded as externally tainted (via `analyzeCSharpIR`'s `externalTaintedParams`
|
|
22
|
+
// option) and re-runs the full detector suite against it. Any finding that
|
|
23
|
+
// was NOT already present in that file's own ordinary (un-seeded) scan is
|
|
24
|
+
// genuinely new — attributable to this cross-file flow and nothing else —
|
|
25
|
+
// and is the only thing this module reports; every finding the ordinary
|
|
26
|
+
// per-file scan already produces on its own is excluded by an id-diff so
|
|
27
|
+
// nothing is ever double-counted.
|
|
28
|
+
//
|
|
29
|
+
// Deliberately conservative, matching every other interprocedural mechanism
|
|
30
|
+
// in this codebase: SINGLE HOP only (a callee that itself forwards the value
|
|
31
|
+
// to a THIRD file's method is not covered — the same scope boundary
|
|
32
|
+
// `csharp-analysis.js`'s own same-file W4.C41/W5.30 mechanisms already
|
|
33
|
+
// accept); an AMBIGUOUS class name (declared in 2+ files) or an ambiguous
|
|
34
|
+
// method match (2+ static candidates of that name on that class) is left
|
|
35
|
+
// unresolved rather than guessed, exactly like the same-file case.
|
|
36
|
+
import { buildCSharpIR, analyzeCSharpIR, runCSharpDetectors } from './csharp.js';
|
|
37
|
+
import { argIsTainted } from '../posture/csharp-analysis.js';
|
|
38
|
+
|
|
39
|
+
export function scanCsharpCrossFile(fileContents) {
|
|
40
|
+
const csFiles = Object.entries(fileContents).filter(([p]) => /\.cs$/i.test(p));
|
|
41
|
+
if (csFiles.length < 2) return [];
|
|
42
|
+
|
|
43
|
+
const parsed = new Map(); // file -> { raw, ir }
|
|
44
|
+
for (const [file, raw] of csFiles) {
|
|
45
|
+
if (!raw || raw.length > 500_000) continue;
|
|
46
|
+
let ir;
|
|
47
|
+
try { ir = buildCSharpIR(raw); } catch { continue; }
|
|
48
|
+
parsed.set(file, { raw, ir });
|
|
49
|
+
}
|
|
50
|
+
if (parsed.size < 2) return [];
|
|
51
|
+
|
|
52
|
+
// Project-wide class registry: className -> [{file, class}]. A name
|
|
53
|
+
// declared in 2+ files is intentionally left ambiguous (never guessed).
|
|
54
|
+
const classRegistry = new Map();
|
|
55
|
+
for (const [file, { ir }] of parsed) {
|
|
56
|
+
for (const c of ir.classes) {
|
|
57
|
+
if (!classRegistry.has(c.name)) classRegistry.set(c.name, []);
|
|
58
|
+
classRegistry.get(c.name).push({ file, class: c });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Baseline per-file analysis: needed both for the caller-side
|
|
63
|
+
// `argIsTainted` check (via the caller's own un-seeded flow) and to diff
|
|
64
|
+
// away findings the ordinary per-file scan already produces on its own.
|
|
65
|
+
const baseline = new Map(); // file -> { analysis, ir, raw, findingIds }
|
|
66
|
+
for (const [file, { raw, ir }] of parsed) {
|
|
67
|
+
let analysis;
|
|
68
|
+
try { analysis = analyzeCSharpIR(ir); } catch { continue; }
|
|
69
|
+
let findings;
|
|
70
|
+
try { findings = runCSharpDetectors(file, raw, ir, analysis); } catch { findings = []; }
|
|
71
|
+
baseline.set(file, { analysis, ir, raw, findingIds: new Set(findings.map(f => f.id)) });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// externalTaintByFile: targetFile -> Map(calleeMethodObject -> Set(paramName))
|
|
75
|
+
const externalTaintByFile = new Map();
|
|
76
|
+
for (const [callerFile, { ir: callerIr }] of parsed) {
|
|
77
|
+
const callerBase = baseline.get(callerFile);
|
|
78
|
+
if (!callerBase) continue;
|
|
79
|
+
const localClassNames = new Set(callerIr.classes.map(c => c.name));
|
|
80
|
+
for (const caller of callerIr.methods) {
|
|
81
|
+
const callerFlow = callerBase.analysis.methodFlow.get(caller);
|
|
82
|
+
if (!callerFlow) continue;
|
|
83
|
+
for (const call of caller.calls || []) {
|
|
84
|
+
if (!call.receiver) continue; // bare calls: same-file only, W4.C41 already covers
|
|
85
|
+
if (localClassNames.has(call.receiver)) continue; // same-file class: W5.30 already covers
|
|
86
|
+
const candidates = classRegistry.get(call.receiver);
|
|
87
|
+
if (!candidates || candidates.length !== 1) continue; // unknown/ambiguous class name
|
|
88
|
+
const { file: targetFile, class: targetClass } = candidates[0];
|
|
89
|
+
if (targetFile === callerFile) continue; // defensive; shouldn't happen given the check above
|
|
90
|
+
const targetParsed = parsed.get(targetFile);
|
|
91
|
+
if (!targetParsed) continue;
|
|
92
|
+
const methodCandidates = targetClass.methods.filter(m =>
|
|
93
|
+
m.name === call.method && (m.modifiers || []).includes('static'));
|
|
94
|
+
if (methodCandidates.length !== 1) continue; // ambiguous/no static match on that class
|
|
95
|
+
const callee = methodCandidates[0];
|
|
96
|
+
for (let i = 0; i < (call.args || []).length; i++) {
|
|
97
|
+
const param = (callee.params || [])[i];
|
|
98
|
+
if (!param) continue;
|
|
99
|
+
if (!argIsTainted(callerFlow, call.args[i])) continue;
|
|
100
|
+
if (!externalTaintByFile.has(targetFile)) externalTaintByFile.set(targetFile, new Map());
|
|
101
|
+
const m = externalTaintByFile.get(targetFile);
|
|
102
|
+
if (!m.has(callee)) m.set(callee, new Set());
|
|
103
|
+
m.get(callee).add(param.name);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const crossFileFindings = [];
|
|
110
|
+
for (const [targetFile, externalTaintedParams] of externalTaintByFile) {
|
|
111
|
+
const targetParsed = parsed.get(targetFile);
|
|
112
|
+
const base = baseline.get(targetFile);
|
|
113
|
+
if (!targetParsed || !base) continue;
|
|
114
|
+
let enrichedAnalysis;
|
|
115
|
+
try { enrichedAnalysis = analyzeCSharpIR(targetParsed.ir, { externalTaintedParams }); } catch { continue; }
|
|
116
|
+
let enrichedFindings;
|
|
117
|
+
try { enrichedFindings = runCSharpDetectors(targetFile, targetParsed.raw, targetParsed.ir, enrichedAnalysis); } catch { continue; }
|
|
118
|
+
for (const f of enrichedFindings) {
|
|
119
|
+
if (base.findingIds.has(f.id)) continue; // already found by the ordinary per-file scan
|
|
120
|
+
f.isCrossFile = true;
|
|
121
|
+
crossFileFindings.push(f);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return crossFileFindings;
|
|
125
|
+
}
|
|
@@ -7,11 +7,36 @@
|
|
|
7
7
|
// Both are regex/structural and complement the flow engine.
|
|
8
8
|
|
|
9
9
|
import { blankComments } from './_comment-strip.js';
|
|
10
|
+
import { buildCSharpIR } from '../ir/csharp-ir.js';
|
|
10
11
|
|
|
11
12
|
const SECRET_PREFIX = /\b(?:sk_|sk-|AKIA|ghp_|gho_|xox[abps]-|AIza|eyJ|-----BEGIN|glpat-)/;
|
|
12
13
|
// A field whose name signals a credential, assigned a string (or concat of
|
|
13
|
-
// string literals — the splitting trick).
|
|
14
|
-
|
|
14
|
+
// string literals — the splitting trick). The literal spans are bounded to a
|
|
15
|
+
// single line ([^"\r\n]*, not [^"]*): a keyword like "Password=" can appear
|
|
16
|
+
// as plain TEXT inside an unrelated connection-string literal (Juliet's own
|
|
17
|
+
// `"...;Password=" + password` shape), and since regex has no notion of
|
|
18
|
+
// string boundaries, that literal's own closing quote reads as the OPENING
|
|
19
|
+
// quote of this rule's capture group — with an unbounded [^"]*, the capture
|
|
20
|
+
// then runs across the following newline and swallows arbitrary subsequent
|
|
21
|
+
// code (across statements, even methods) until the next real `"` anywhere
|
|
22
|
+
// in the file, fabricating a bogus "secret value" out of unrelated source.
|
|
23
|
+
// Found via a real corpus false positive (CWE256/CWE319 Juliet fixtures)
|
|
24
|
+
// whose reported "secret" was several lines of try/catch code, not a value.
|
|
25
|
+
const SECRET_FIELD = /\b\w*(?:apikey|api_key|secret|token|password|passwd|pwd|credential|privatekey|connectionstring|accesskey)\w*\s*=\s*((?:"[^"\r\n]*"\s*\+\s*)*"[^"\r\n]*")/ig;
|
|
26
|
+
|
|
27
|
+
// A local variable assigned nothing but a string literal (`data = "…";` —
|
|
28
|
+
// no `const`/`static`, and often no credential-shaped NAME at all: Juliet's
|
|
29
|
+
// own CWE256/259 fixtures deliberately name it `data` to test detection
|
|
30
|
+
// independent of naming convention), where that SAME variable is later
|
|
31
|
+
// concatenated directly after a literal segment that itself ends in a
|
|
32
|
+
// credential keyword ("...;Password=" + data). SECRET_FIELD (above) only
|
|
33
|
+
// catches the credential NAME shape (`password = "literal"`); this catches
|
|
34
|
+
// the credential SINK shape — a hardcoded value reaching a password/token
|
|
35
|
+
// parameter through a one-hop variable copy, mirroring the same "literal
|
|
36
|
+
// reaches sink directly or via one-hop copy" principle already applied to
|
|
37
|
+
// LDAP filters (`ldap-injection.js`'s `_nearestAssignIsLiteral`).
|
|
38
|
+
const LITERAL_ASSIGN = /\b(\w+)\s*=\s*(?:"([^"\r\n]{6,})"|@"([^"\r\n]{6,})")\s*;/g;
|
|
39
|
+
const CREDENTIAL_SINK_USE = /["'][^"'\r\n]*?(?:password|passwd|pwd|secret|token|api[_-]?key|credential)\s*=\s*["']\s*\+\s*(\w+)\b/ig;
|
|
15
40
|
|
|
16
41
|
function lineOf(raw, idx) { return raw.substring(0, idx).split('\n').length; }
|
|
17
42
|
// Join the contents of a concatenation of string literals.
|
|
@@ -19,6 +44,11 @@ function joinLiterals(expr) {
|
|
|
19
44
|
const parts = expr.match(/"([^"]*)"/g) || [];
|
|
20
45
|
return parts.map(p => p.slice(1, -1)).join('');
|
|
21
46
|
}
|
|
47
|
+
function looksLikePlaceholder(value) {
|
|
48
|
+
if (/^(?:changeme|placeholder|todo|tbd|xxxxx|secret|password|your_?password)$/i.test(value)) return true;
|
|
49
|
+
if (/^[A-Za-z]+$/.test(value) && value.length < 12) return true;
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
22
52
|
|
|
23
53
|
export function scanCsharpStructural(fp, raw) {
|
|
24
54
|
if (!/\.cs$/i.test(fp)) return [];
|
|
@@ -46,6 +76,52 @@ export function scanCsharpStructural(fp, raw) {
|
|
|
46
76
|
});
|
|
47
77
|
}
|
|
48
78
|
|
|
79
|
+
// Hardcoded literal reaching a credential-labeled sink through a one-hop
|
|
80
|
+
// variable copy, SCOPED TO ONE METHOD BODY (via the shared C# IR's method
|
|
81
|
+
// line ranges, not a whole-file scan). Juliet's Bad()/Good() convention
|
|
82
|
+
// deliberately reuses the SAME generic variable name (`data`) across
|
|
83
|
+
// sibling methods with UNRELATED meanings — a literal in Bad(), external
|
|
84
|
+
// input (`Console.ReadLine()`) in GoodG2B() — so a whole-file, name-only
|
|
85
|
+
// association would credit the Good() sink with the Bad() method's literal
|
|
86
|
+
// (confirmed against the real corpus: CWE259_Hard_Coded_Password__
|
|
87
|
+
// SqlConnection_01.cs's GoodG2B() reads `data` from the console, and a
|
|
88
|
+
// whole-file version of this rule fired on its SqlConnection call anyway).
|
|
89
|
+
// Scoping to one method is a deliberate precision/recall tradeoff: it
|
|
90
|
+
// misses Juliet's own multi-method split (`Bad(){ data="lit"; BadSink(data); }`
|
|
91
|
+
// with the sink in a separate `BadSink(string data)`), which needs real
|
|
92
|
+
// interprocedural constant tracking to resolve safely, not a regex.
|
|
93
|
+
let csharpIr;
|
|
94
|
+
try { csharpIr = buildCSharpIR(code); } catch { csharpIr = null; }
|
|
95
|
+
for (const meth of (csharpIr && csharpIr.methods) || []) {
|
|
96
|
+
const startLine = meth.line, endLine = meth.endLine || meth.line;
|
|
97
|
+
if (endLine < startLine) continue;
|
|
98
|
+
const codeLines = code.split('\n');
|
|
99
|
+
const methText = codeLines.slice(startLine - 1, endLine).join('\n');
|
|
100
|
+
const literalVars = new Map();
|
|
101
|
+
const lre = new RegExp(LITERAL_ASSIGN.source, LITERAL_ASSIGN.flags);
|
|
102
|
+
let lm;
|
|
103
|
+
while ((lm = lre.exec(methText))) {
|
|
104
|
+
const value = lm[2] || lm[3] || '';
|
|
105
|
+
if (looksLikePlaceholder(value)) continue;
|
|
106
|
+
literalVars.set(lm[1], value);
|
|
107
|
+
}
|
|
108
|
+
if (!literalVars.size) continue;
|
|
109
|
+
const cre = new RegExp(CREDENTIAL_SINK_USE.source, CREDENTIAL_SINK_USE.flags);
|
|
110
|
+
let cm;
|
|
111
|
+
while ((cm = cre.exec(methText))) {
|
|
112
|
+
const varName = cm[1];
|
|
113
|
+
if (!literalVars.has(varName)) continue;
|
|
114
|
+
const line = startLine + lineOf(methText, cm.index) - 1;
|
|
115
|
+
push({
|
|
116
|
+
id: `csharp-hardcoded-secret-sink:${fp}:${line}`, file: fp, line,
|
|
117
|
+
vuln: 'Hardcoded credential in a const/static field (C#)',
|
|
118
|
+
severity: 'high', cwe: 'CWE-798', family: 'secret', parser: 'CSHARP', confidence: 0.65,
|
|
119
|
+
snippet: (raw.split('\n')[line - 1] || '').trim().slice(0, 200),
|
|
120
|
+
remediation: 'Load the secret from the environment / a secrets manager (Environment.GetEnvironmentVariable, Azure Key Vault) instead of a hardcoded literal reaching a password/token parameter.',
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
49
125
|
// SSRF: WebClient/HttpClient fetch from a non-literal URL, unless a host
|
|
50
126
|
// allow/deny guard is present.
|
|
51
127
|
const SSRF_SINK = /\.(?:DownloadString|DownloadData|OpenRead|GetAsync|GetStringAsync|GetStreamAsync|GetByteArrayAsync)\s*\(\s*[A-Za-z_]\w*\s*\)/;
|