@clear-capabilities/agentic-security-scanner 0.151.3 → 0.153.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/CHANGELOG.md +125 -0
  2. package/dist/1336.index.js +16010 -0
  3. package/dist/agentic-security.mjs +80 -80
  4. package/dist/agentic-security.mjs.sha256 +1 -1
  5. package/package.json +7 -6
  6. package/src/dataflow/CLAUDE.md +1 -1
  7. package/src/dataflow/backward.js +104 -7
  8. package/src/dataflow/catalog.js +802 -13
  9. package/src/dataflow/cross-service-taint.js +154 -11
  10. package/src/dataflow/engine.js +875 -90
  11. package/src/dataflow/exploit-prover.js +111 -23
  12. package/src/dataflow/incremental.js +13 -0
  13. package/src/dataflow/index.js +29 -6
  14. package/src/dataflow/path-feasibility.js +86 -9
  15. package/src/dataflow/string-domain.js +168 -0
  16. package/src/dataflow/summaries.js +69 -8
  17. package/src/engine.js +118 -9
  18. package/src/ir/CLAUDE.md +2 -1
  19. package/src/ir/balanced-call.js +33 -1
  20. package/src/ir/callgraph.js +26 -2
  21. package/src/ir/class-hierarchy.js +261 -7
  22. package/src/ir/csharp-ir.js +97 -8
  23. package/src/ir/index.js +21 -1
  24. package/src/ir/parser-cs.js +706 -24
  25. package/src/ir/parser-java.js +498 -17
  26. package/src/ir/parser-js.js +22 -0
  27. package/src/ir/parser-php.js +446 -43
  28. package/src/ir/parser-rust.js +1338 -0
  29. package/src/ir/php-include-merge.js +190 -0
  30. package/src/lineage/sink-registry.js +18 -0
  31. package/src/lineage/source-registry.js +23 -0
  32. package/src/posture/csharp-analysis.js +341 -43
  33. package/src/posture/finding-defaults.js +5 -1
  34. package/src/posture/fix-coverage.js +1 -0
  35. package/src/posture/proof-coverage.js +1 -0
  36. package/src/report/index.js +12 -0
  37. package/src/sast/CLAUDE.md +1 -1
  38. package/src/sast/authz.js +144 -1
  39. package/src/sast/crypto-protocol.js +65 -7
  40. package/src/sast/csharp-cross-file.js +125 -0
  41. package/src/sast/csharp-structural.js +78 -2
  42. package/src/sast/csharp.js +574 -42
  43. package/src/sast/java-ast-folding.js +268 -11
  44. package/src/sast/java-bench-extras.js +530 -2
  45. package/src/sast/java-deserialization.js +31 -2
  46. package/src/sast/java-structural-cross-file.js +146 -0
  47. package/src/sast/java-structural.js +499 -6
  48. package/src/sast/ldap-injection.js +133 -3
  49. package/src/sast/rust.js +57 -0
  50. package/src/sast/xss-reflected-multilang.js +252 -3
  51. package/src/dataflow/smt-feasibility.js +0 -189
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-327',
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
- // Skip well-known benchmark-test-harness naming. These files contain crypto
420
- // APIs as test scaffolding, not as deployed-app code. Avoiding them keeps
421
- // the blind benchmark regression bit-identical without losing real-world
422
- // signal.
423
- const _BENCH_FIXTURE_RE = /(?:^|\/|\\)(?:BenchmarkTest|JulietTestCase|CWE\d+_)[\w-]*\.(?:java|c|cpp|cs)$/i;
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
- const SECRET_FIELD = /\b\w*(?:apikey|api_key|secret|token|password|passwd|pwd|credential|privatekey|connectionstring|accesskey)\w*\s*=\s*((?:"[^"]*"\s*\+\s*)*"[^"]*")/ig;
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*\)/;