@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
@@ -243,6 +243,13 @@ export const CATALOG = [
243
243
  // do not assume it is actually gating anything yet.
244
244
  { kind: 'source', id: 'java-resultset-getstring', language: 'java', framework: 'jdbc', match: { type: 'call', callee: 'getString', receiverTypeIn: ['resultset'] }, label: 'ResultSet.getString' },
245
245
  { kind: 'source', id: 'java-resultset-getobject', language: 'java', framework: 'jdbc', match: { type: 'call', callee: 'getObject', receiverTypeIn: ['resultset'] }, label: 'ResultSet.getObject' },
246
+ // SARD_80_F1_SCANNER_PRD.md Juliet Java audit: getAttribute/getQueryString
247
+ // are as common as getParameter in the servlet source set the catalog
248
+ // already covers, and Cookie.getValue is the standard way the array
249
+ // returned by the already-cataloged getCookies() is actually read.
250
+ { kind: 'source', id: 'java-request-getAttribute', language: 'java', framework: 'servlet', match: { type: 'call', callee: 'getAttribute' }, label: 'request.getAttribute' },
251
+ { kind: 'source', id: 'java-request-getQueryString', language: 'java', framework: 'servlet', match: { type: 'call', callee: 'getQueryString' }, label: 'request.getQueryString' },
252
+ { kind: 'source', id: 'java-cookie-getvalue', language: 'java', framework: 'servlet', match: { type: 'call', callee: 'getValue' }, label: 'Cookie.getValue' },
246
253
 
247
254
  // ─── SOURCES (Annotation/Decorator-shaped) ────────────────────────────────
248
255
  // R14(a): annotation/decorator-shaped framework sources (Spring @RequestParam,
@@ -404,6 +411,28 @@ export const CATALOG = [
404
411
  { kind: 'source', id: 'php-symfony-content', language: 'php', framework: 'symfony', match: { type: 'call', callee: 'getContent' }, label: '$request->getContent() (Symfony)' },
405
412
  { kind: 'source', id: 'php-symfony-get', language: 'php', framework: 'symfony', match: { type: 'call', callee: 'get' }, label: '$request->get() (Symfony)' },
406
413
 
414
+ // SARD PHP corpus: file/stream/process-output sources. This corpus's
415
+ // dominant "source" shape is NOT a superglobal at all — it's a value read
416
+ // from a file or a subprocess's output (fgets on an fopen'd handle is the
417
+ // single most common first-source shape in the dev split, ahead of every
418
+ // superglobal combined), matching the real NIST SARD PHP Vulnerability
419
+ // Test Suite's own generator convention. Receiver-agnostic (`fgets($fh)`
420
+ // covers stdin, a socket, or an ordinary file — the taint concern is the
421
+ // same regardless of which handle) and file-content sources are real,
422
+ // legitimate untrusted input whenever the file/stream itself isn't a
423
+ // hardcoded, checked-in constant path.
424
+ { kind: 'source', id: 'php-fgetc', language: 'php', framework: 'core', match: { type: 'call', callee: 'fgetc' }, label: 'fgetc()', provenance: 'file-read' },
425
+ { kind: 'source', id: 'php-fscanf', language: 'php', framework: 'core', match: { type: 'call', callee: 'fscanf' }, label: 'fscanf()', provenance: 'file-read' },
426
+ { kind: 'source', id: 'php-file-get-contents', language: 'php', framework: 'core', match: { type: 'call', callee: 'file_get_contents' }, label: 'file_get_contents()', provenance: 'file-read' },
427
+ { kind: 'source', id: 'php-file', language: 'php', framework: 'core', match: { type: 'call', callee: 'file' }, label: 'file()', provenance: 'file-read' },
428
+ { kind: 'source', id: 'php-stream-get-contents', language: 'php', framework: 'core', match: { type: 'call', callee: 'stream_get_contents' }, label: 'stream_get_contents()', provenance: 'file-read' },
429
+ { kind: 'source', id: 'php-readline', language: 'php', framework: 'core', match: { type: 'call', callee: 'readline' }, label: 'readline()', provenance: 'stdin' },
430
+ // Assigned FROM (the return value of running a command), distinct from the
431
+ // sink use of the SAME functions (building a command from tainted input,
432
+ // see php-shell-exec/php-exec/php-system below) — both directions are real.
433
+ { kind: 'source', id: 'php-shell-exec-output', language: 'php', framework: 'core', match: { type: 'call', callee: 'shell_exec' }, label: 'shell_exec() return value', provenance: 'file-read' },
434
+ { kind: 'source', id: 'php-getenv', language: 'php', framework: 'core', match: { type: 'call', callee: 'getenv' }, label: 'getenv()', provenance: 'env' },
435
+
407
436
  // ─── SINKS (SQL — Python) ─────────────────────────────────────────────────
408
437
  { kind: 'sink', id: 'py-cursor-execute', language: 'py', framework: 'dbapi', match: { type: 'call', callee: 'execute' }, argIndex: 0,
409
438
  vuln: { name: 'SQL Injection (cursor.execute)', severity: 'critical', cwe: 'CWE-89',
@@ -432,6 +461,17 @@ export const CATALOG = [
432
461
  remediation: 'Use PreparedStatement + setX(N, value).' } },
433
462
  { kind: 'sink', id: 'java-stmt-execute', language: 'java', framework: 'jdbc', match: { type: 'call', callee: 'execute' }, argIndex: 0,
434
463
  vuln: { name: 'SQL Injection (Statement.execute)', severity: 'critical', cwe: 'CWE-89', remediation: 'Use PreparedStatement.' } },
464
+ // `Statement.addBatch(sql)` — the SQL string, not `executeBatch()` (which
465
+ // takes no arguments and just fires whatever was previously queued), is
466
+ // the actual sink: `stmt.addBatch("UPDATE ... WHERE name='" + tainted +
467
+ // "'"); ... stmt.executeBatch();`. Confirmed via the public Juliet Java
468
+ // mirror (CWE89_SQL_Injection's own executeBatch-suffixed variants, the
469
+ // single largest false-negative cluster this session found for CWE-89:
470
+ // 167 of 308 dev-split fns were this one missing sink). `argIndex: 0`
471
+ // naturally excludes `PreparedStatement.addBatch()`'s zero-arg safe form
472
+ // (queuing already-bound parameters) — there is no arg 0 to match.
473
+ { kind: 'sink', id: 'java-stmt-addbatch', language: 'java', framework: 'jdbc', match: { type: 'call', callee: 'addBatch' }, argIndex: 0,
474
+ vuln: { name: 'SQL Injection (Statement.addBatch)', severity: 'critical', cwe: 'CWE-89', remediation: 'Use PreparedStatement + setX(N, value) + the zero-arg addBatch() form. Never concatenate user input into the SQL string passed to addBatch().' } },
435
475
  { kind: 'sink', id: 'java-jdbc-prepareStatement', language: 'java', framework: 'jdbc', match: { type: 'call', callee: 'prepareStatement' }, argIndex: 0,
436
476
  vuln: { name: 'SQL Injection (PreparedStatement built via concat)', severity: 'critical', cwe: 'CWE-89',
437
477
  remediation: 'Use placeholders (?) in the SQL string; bind values via setX(N, value).' } },
@@ -453,12 +493,51 @@ export const CATALOG = [
453
493
  // response-write idiom, `HttpServletResponse.getWriter()`) is the
454
494
  // dominant shape. Receiver-scoped to the `getWriter` chain segment (both
455
495
  // `write` and `print` collide with countless unrelated APIs bare).
456
- { kind: 'sink', id: 'java-writer-write', language: 'java', framework: 'servlet', match: { type: 'call', callee: 'write', receiver: '^getWriter$' }, argIndex: 0,
496
+ //
497
+ // SARD_80_F1 W4: the chained one-liner (`response.getWriter().write(x)`)
498
+ // was the ONLY shape these three entries could ever match — Java's more
499
+ // common idiom splits it in two (`PrintWriter out = response.getWriter();
500
+ // out.println(x);`), a completely different receiver (the local variable
501
+ // NAME, not the literal `getWriter` chain segment) that was invisible.
502
+ // Fixed via `receiverTypeIn` (ADDITIVE — confirms a match a failing name
503
+ // check missed, never replaces the name check), backed by
504
+ // `class-hierarchy.js`'s `typeOfVar` now also being seeded from a local's
505
+ // DECLARED type, not just `paramTypes`/`isNew` constructor calls (see its
506
+ // own comment). Deliberately NOT done via `parser-java.js`'s
507
+ // `_localVarConstructedTypes`/`_rewriteVarTypeCallees` string-rewrite
508
+ // mechanism — a first attempt did that and broke an EXISTING, unrelated
509
+ // catalog entry keyed on a short variable-name convention (`XPath xp = …;
510
+ // xp.evaluate(...)` — the rewrite mutated `xp.evaluate` to
511
+ // `XPath.evaluate`, and the xpath sink's `receiver: '^(?:xp|xpath)$'`
512
+ // stopped matching); confirmed by direct regression in
513
+ // `test/java-taint-flow.test.js`, then reverted. `println` was also
514
+ // completely absent from this list despite being the single most common
515
+ // of the three methods in real servlet code.
516
+ { kind: 'sink', id: 'java-writer-write', language: 'java', framework: 'servlet', match: { type: 'call', callee: 'write', receiver: '^getWriter$', receiverTypeIn: ['^PrintWriter$'] }, argIndex: 0,
457
517
  vuln: { name: 'Reflected XSS (PrintWriter.write)', severity: 'high', cwe: 'CWE-79',
458
518
  remediation: 'HTML-escape user-derived content before writing to the response, or use a templating engine with auto-escaping.' } },
459
- { kind: 'sink', id: 'java-writer-print', language: 'java', framework: 'servlet', match: { type: 'call', callee: 'print', receiver: '^getWriter$' }, argIndex: 0,
519
+ { kind: 'sink', id: 'java-writer-print', language: 'java', framework: 'servlet', match: { type: 'call', callee: 'print', receiver: '^getWriter$', receiverTypeIn: ['^PrintWriter$'] }, argIndex: 0,
460
520
  vuln: { name: 'Reflected XSS (PrintWriter.print)', severity: 'high', cwe: 'CWE-79',
461
521
  remediation: 'HTML-escape user-derived content before writing to the response, or use a templating engine with auto-escaping.' } },
522
+ { kind: 'sink', id: 'java-writer-println', language: 'java', framework: 'servlet', match: { type: 'call', callee: 'println', receiver: '^getWriter$', receiverTypeIn: ['^PrintWriter$'] }, argIndex: 0,
523
+ vuln: { name: 'Reflected XSS (PrintWriter.println)', severity: 'high', cwe: 'CWE-79',
524
+ remediation: 'HTML-escape user-derived content before writing to the response, or use a templating engine with auto-escaping.' } },
525
+ // `HttpServletResponse.sendError(int, String)` — the message argument
526
+ // (argIndex 1, NOT 0) is written verbatim into the generated error page.
527
+ // Confirmed via the public Juliet Java mirror (CWE81_XSS_Error_Message's
528
+ // OWN corpus, 545 files, every one of them: `response.sendError(404,
529
+ // "<br>bad() - Parameter name has value " + data);`) that this — not a
530
+ // caught exception's `getMessage()`, which the CWE-81 name suggests and
531
+ // which a prior session-iteration built and verified working in
532
+ // isolation — is the sink Juliet's CWE-81 ("XSS in Error Message")
533
+ // actually exercises: "Error Message" names the `sendError` HTTP error
534
+ // message parameter, not an exception message. That earlier fix is not
535
+ // wrong (it is a real, separate gap for a shape this corpus just never
536
+ // happens to test) but it explains why CWE-81 showed zero real-corpus
537
+ // movement despite passing its own unit tests.
538
+ { kind: 'sink', id: 'java-response-senderror', language: 'java', framework: 'servlet', match: { type: 'call', callee: 'sendError', receiver: '^response$', receiverTypeIn: ['^HttpServletResponse$'] }, argIndex: 1,
539
+ vuln: { name: 'Reflected XSS (HttpServletResponse.sendError message)', severity: 'high', cwe: 'CWE-79',
540
+ remediation: 'HTML-escape user-derived content before passing it as the sendError message, or send a generic, static error message with no user input.' } },
462
541
 
463
542
  // ─── SINKS (SQL — Go) ──────────────────────────────────────────────────────
464
543
  { kind: 'sink', id: 'go-db-query', language: 'go', framework: 'database/sql', match: { type: 'call', callee: 'Query' }, argIndex: 0,
@@ -582,6 +661,17 @@ export const CATALOG = [
582
661
  { kind: 'sink', id: 'php-echo-xss', language: 'php', framework: 'core', match: { type: 'call', callee: '__php_echo__' }, argIndex: 'all',
583
662
  vuln: { name: 'Reflected XSS (echo)', severity: 'high', cwe: 'CWE-79',
584
663
  remediation: 'Escape output with htmlspecialchars() before echoing user-derived content.' } },
664
+ // SARD PHP corpus: the backtick shell-execution operator is a language
665
+ // construct like echo/print (parser-php.js now lowers it to a synthetic
666
+ // `__php_backtick_exec__` call, see that file for why).
667
+ { kind: 'sink', id: 'php-backtick-exec', language: 'php', framework: 'core', match: { type: 'call', callee: '__php_backtick_exec__' }, argIndex: 'all',
668
+ vuln: { name: 'OS Command Injection (backtick operator)', severity: 'critical', cwe: 'CWE-78',
669
+ remediation: 'Never build a shell command from user input. Use escapeshellarg() on every interpolated value, or avoid shell_exec()/backticks entirely in favor of a fixed argv array passed to proc_open() or symfony/process.' } },
670
+ // include/require/include_once/require_once are language constructs
671
+ // (parser-php.js now lowers them to a synthetic `__php_include__` call).
672
+ { kind: 'sink', id: 'php-include-lfi', language: 'php', framework: 'core', match: { type: 'call', callee: '__php_include__' }, argIndex: 'all',
673
+ vuln: { name: 'Local/Remote File Inclusion (include/require)', severity: 'critical', cwe: 'CWE-98',
674
+ remediation: 'Never include() / require() a user-controlled path. Build an explicit whitelist dispatch table instead: `$pages = ["home" => "home.php", ...]; include $pages[$_GET["page"]] ?? "404.php";`.' } },
585
675
 
586
676
  // ─── SINKS (XSS / template — JS/TS / browser) ─────────────────────────────
587
677
  { kind: 'sink', id: 'js-innerHTML-assign', language: 'js', framework: 'dom', match: { type: 'member', object: '_any_', prop: 'innerHTML' }, argIndex: 'rhs',
@@ -792,6 +882,26 @@ export const CATALOG = [
792
882
  { kind: 'sink', id: 'java-new-File', language: 'java', framework: 'stdlib', match: { type: 'call', callee: 'File' }, argIndex: 0,
793
883
  vuln: { name: 'Path Traversal (new File)', severity: 'high', cwe: 'CWE-22',
794
884
  remediation: 'Canonicalize with Path.normalize + startsWith(base).' } },
885
+ // Juliet's own dominant file-sink shapes: `new FileInputStream(path)`,
886
+ // `new FileReader(path)`, `new RandomAccessFile(path, mode)`. Each is a
887
+ // distinct constructor (not an alias for `new File(...)`, which some
888
+ // callers wrap it around instead), so `java-new-File`'s bare `callee:
889
+ // 'File'` never matches any of them.
890
+ { kind: 'sink', id: 'java-new-FileInputStream', language: 'java', framework: 'stdlib', match: { type: 'call', callee: 'FileInputStream' }, argIndex: 0,
891
+ vuln: { name: 'Path Traversal (new FileInputStream)', severity: 'high', cwe: 'CWE-22',
892
+ remediation: 'Canonicalize the path and verify it stays within an allow-listed base directory before opening it.' } },
893
+ { kind: 'sink', id: 'java-new-FileOutputStream', language: 'java', framework: 'stdlib', match: { type: 'call', callee: 'FileOutputStream' }, argIndex: 0,
894
+ vuln: { name: 'Path Traversal (new FileOutputStream)', severity: 'high', cwe: 'CWE-22',
895
+ remediation: 'Canonicalize the path and verify it stays within an allow-listed base directory before opening it.' } },
896
+ { kind: 'sink', id: 'java-new-FileReader', language: 'java', framework: 'stdlib', match: { type: 'call', callee: 'FileReader' }, argIndex: 0,
897
+ vuln: { name: 'Path Traversal (new FileReader)', severity: 'high', cwe: 'CWE-22',
898
+ remediation: 'Canonicalize the path and verify it stays within an allow-listed base directory before opening it.' } },
899
+ { kind: 'sink', id: 'java-new-FileWriter', language: 'java', framework: 'stdlib', match: { type: 'call', callee: 'FileWriter' }, argIndex: 0,
900
+ vuln: { name: 'Path Traversal (new FileWriter)', severity: 'high', cwe: 'CWE-22',
901
+ remediation: 'Canonicalize the path and verify it stays within an allow-listed base directory before opening it.' } },
902
+ { kind: 'sink', id: 'java-new-RandomAccessFile', language: 'java', framework: 'stdlib', match: { type: 'call', callee: 'RandomAccessFile' }, argIndex: 0,
903
+ vuln: { name: 'Path Traversal (new RandomAccessFile)', severity: 'high', cwe: 'CWE-22',
904
+ remediation: 'Canonicalize the path and verify it stays within an allow-listed base directory before opening it.' } },
795
905
  { kind: 'sink', id: 'go-os-open', language: 'go', framework: 'os', match: { type: 'call', callee: 'Open' }, argIndex: 0,
796
906
  vuln: { name: 'Path Traversal (os.Open)', severity: 'high', cwe: 'CWE-22',
797
907
  remediation: 'Use filepath.Clean + verify the path is rooted in your allow-list dir.' } },
@@ -848,7 +958,29 @@ export const CATALOG = [
848
958
  // Taint-recall PRD (80%) Tier 1: XPath.evaluate() called directly (no
849
959
  // .compile() call site to catch it via the entry above) — javax.xml.xpath's
850
960
  // other common idiom. Receiver-scoped: "evaluate" is generic elsewhere.
851
- { kind: 'sink', id: 'java-xpath-evaluate', language: 'java', framework: 'xpath', match: { type: 'call', callee: 'evaluate', receiver: '^(?:xp|xpath|expr)$' }, argIndex: 0,
961
+ //
962
+ // SARD_80_F1 W4.J32 — `xPath` (camelCase, matching the `XPath` type name
963
+ // exactly as `javax.xml.xpath.XPath`'s own Javadoc examples and the real
964
+ // public Juliet mirror's own CWE-643 files spell it — confirmed directly
965
+ // against `CWE643_Xpath_Injection__database_54e.java`'s
966
+ // `XPath xPath = XPathFactory.newInstance().newXPath();`) NEVER matched
967
+ // this entry: `_receiverAllowed` builds its regex with `new RegExp(pat)`,
968
+ // no `i` flag, so the case-sensitive `^(?:xp|xpath|expr)$` only ever
969
+ // matched the all-lowercase `xpath` or the short `xp`/`expr` — not the
970
+ // idiomatic camelCase form. This meant `IR-TAINT` could never fire on
971
+ // this sink for ANY Java file using ordinary naming conventions, in a
972
+ // single file or across any number of files — CWE-643's real corpus
973
+ // recall gap traced to cross-file/multi-hop chains (5 separate classes,
974
+ // confirmed via public-mirror `_54a`..`_54e`) was actually just the
975
+ // visible SYMPTOM: the structural/regex detectors that fired instead are
976
+ // inherently single-file, so once IR-TAINT (the only interprocedural
977
+ // mechanism) couldn't match this sink at all, every cross-file variant
978
+ // was invisible by construction. Verified directly: a same-file `xPath`
979
+ // repro fired ZERO `IR-TAINT` findings before this fix (structural/regex
980
+ // detectors covered it instead) and fired correctly after. `xp`/`xpath`/
981
+ // `expr` remain matched unchanged (existing `test/java-taint-flow.test.js`
982
+ // coverage for the `xp` short form is unaffected — purely additive).
983
+ { kind: 'sink', id: 'java-xpath-evaluate', language: 'java', framework: 'xpath', match: { type: 'call', callee: 'evaluate', receiver: '^(?:xp|xpath|xPath|expr)$' }, argIndex: 0,
852
984
  vuln: { name: 'XPath Injection (XPath.evaluate)', severity: 'high', cwe: 'CWE-643',
853
985
  remediation: 'Use XPathVariableResolver or setXPathVariableResolver; never concat user input into the expression.' } },
854
986
  { kind: 'sink', id: 'cs-xml-selectnodes', language: 'cs', framework: 'stdlib', match: { type: 'call', callee: 'SelectNodes' }, argIndex: 0,
@@ -884,6 +1016,22 @@ export const CATALOG = [
884
1016
  { kind: 'sink', id: 'php-domxpath-query', language: 'php', framework: 'stdlib', match: { type: 'call', callee: 'query', receiver: '(?:xp|xpath|dom)' }, argIndex: 0,
885
1017
  vuln: { name: 'XPath Injection (DOMXPath::query)', severity: 'high', cwe: 'CWE-643',
886
1018
  remediation: 'Never concat user input into the expression string; validate/allow-list the queried attribute value instead.' } },
1019
+ // SimpleXMLElement::xpath() — PHP's OTHER, more commonly used XPath query
1020
+ // API (distinct from DOMXPath::query above), called on a SimpleXMLElement
1021
+ // object which can be named anything (`$xml->xpath($query)` — no `xp`/
1022
+ // `xpath`/`dom`-shaped receiver name to key off, unlike DOMXPath's own
1023
+ // idiomatic naming). No `match.receiver` set deliberately: `xpath` is a
1024
+ // specific enough bare method name that gating on receiver naming would
1025
+ // only lose recall, not gain precision. Tagged CWE-91 (not CWE-643) to
1026
+ // match this specific API/CWE pairing as filed by NIST SARD's own PHP
1027
+ // Vulnerability Test Suite (Stivalet & Delaitre) — confirmed from that
1028
+ // suite's PUBLIC generator source (github.com/stivalet/
1029
+ // php-vuln-test-suite-generator, execQuery_XPath.txt: `$xml =
1030
+ // simplexml_load_file(...); $res=$xml->xpath($query);`), not from reading
1031
+ // this project's own ingested/scored corpus copy.
1032
+ { kind: 'sink', id: 'php-simplexml-xpath', language: 'php', framework: 'stdlib', match: { type: 'call', callee: 'xpath' }, argIndex: 0,
1033
+ vuln: { name: 'XPath Injection (SimpleXMLElement::xpath)', severity: 'high', cwe: 'CWE-91',
1034
+ remediation: 'Never concat user input into the expression string; validate/allow-list the queried attribute value instead.' } },
887
1035
  { kind: 'sink', id: 'go-htmlquery-find', language: 'go', framework: 'htmlquery', match: { type: 'call', callee: 'Find', receiver: 'htmlquery' }, argIndex: 1,
888
1036
  vuln: { name: 'XPath Injection (htmlquery.Find)', severity: 'high', cwe: 'CWE-643',
889
1037
  remediation: 'Never concat user input into the expression string; validate/allow-list the queried attribute value instead.' } },
@@ -974,8 +1122,60 @@ export const CATALOG = [
974
1122
  { kind: 'sanitizer', id: 'php-htmlentities', language: 'php', match: { type: 'call', callee: 'htmlentities' }, effect: 'strip', appliesTo: ['xss'] },
975
1123
  { kind: 'sanitizer', id: 'php-escapeshellarg', language: 'php', match: { type: 'call', callee: 'escapeshellarg' }, effect: 'strip', appliesTo: ['cmd'] },
976
1124
  { kind: 'sanitizer', id: 'php-escapeshellcmd', language: 'php', match: { type: 'call', callee: 'escapeshellcmd' }, effect: 'strip', appliesTo: ['cmd'] },
977
- { kind: 'sanitizer', id: 'php-intval', language: 'php', match: { type: 'call', callee: 'intval' }, effect: 'strip', appliesTo: ['*'] },
978
- { kind: 'sanitizer', id: 'php-filter-var', language: 'php', match: { type: 'call', callee: 'filter_var' }, effect: 'strip', appliesTo: ['*'] },
1125
+ // SARD PHP corpus, sanitizer-semantics precision pass. `effect: 'coerce'`
1126
+ // (distinct from `effect: 'strip'`) marks a value that becomes a genuinely
1127
+ // non-string type — an int/float can never carry a SQL/XSS/command-
1128
+ // injection payload regardless of family, which is why `appliesTo: ['*']`
1129
+ // is honest here in a way it is NOT for `htmlspecialchars`/
1130
+ // `escapeshellarg` above (those only neutralize ONE family's
1131
+ // metacharacters, appliesTo is scoped accordingly). `effect: 'coerce'`
1132
+ // pairs with dataflow/engine.js's `_isCoercionCall`, which actually KILLS
1133
+ // taint (not just demotes) for a universal-appliesTo sanitizer match.
1134
+ { kind: 'sanitizer', id: 'php-intval', language: 'php', match: { type: 'call', callee: 'intval' }, effect: 'coerce', appliesTo: ['*'] },
1135
+ { kind: 'sanitizer', id: 'php-floatval', language: 'php', match: { type: 'call', callee: 'floatval' }, effect: 'coerce', appliesTo: ['*'] },
1136
+ // filter_var($x, FILTER_VALIDATE_INT|FLOAT) genuinely coerces (returns the
1137
+ // typed value or `false`) — a real sanitizer. Every OTHER filter_var call
1138
+ // shape (no filter arg at all, defaulting to FILTER_DEFAULT which does
1139
+ // nothing; FILTER_SANITIZE_* filters, which strip characters but do not
1140
+ // guarantee the family-relevant metacharacter is gone; FILTER_VALIDATE_*
1141
+ // for anything other than INT/FLOAT) is NOT a sanitizer for any family
1142
+ // this catalog scores, and the corpus's own PHP `good` cases were found
1143
+ // (this session) to use `filter_var` with `FILTER_VALIDATE_INT`/`FLOAT`
1144
+ // specifically for its 288/288 genuinely-safe cases — a single blanket
1145
+ // `appliesTo: ['*']` entry (the previous version of this catalog) would
1146
+ // have silently marked EVERY filter_var call safe regardless of which
1147
+ // filter was actually passed, which is backwards for FILTER_SANITIZE_*
1148
+ // and simply wrong for the no-filter-arg default. `requireLiteralArg`
1149
+ // scopes the match to the two filters that are actually type coercions;
1150
+ // dataflow/engine.js's `_isCoercionCall` consults this same
1151
+ // `match.requireLiteralArg` gate (shared with the sink-side check via
1152
+ // `_literalArgSatisfied`), so a bare/wrong-filter `filter_var` call
1153
+ // correctly does not qualify as a coercion.
1154
+ { kind: 'sanitizer', id: 'php-filter-var-int', language: 'php',
1155
+ match: { type: 'call', callee: 'filter_var', requireLiteralArg: { index: 1, pattern: '^FILTER_VALIDATE_INT$' } },
1156
+ effect: 'coerce', appliesTo: ['*'] },
1157
+ { kind: 'sanitizer', id: 'php-filter-var-float', language: 'php',
1158
+ match: { type: 'call', callee: 'filter_var', requireLiteralArg: { index: 1, pattern: '^FILTER_VALIDATE_FLOAT$' } },
1159
+ effect: 'coerce', appliesTo: ['*'] },
1160
+ // settype($x, "integer"|"int"|"float"|"double") mutates $x in place to a
1161
+ // non-string type — same coercion argument as intval/floatval above, but
1162
+ // only when the TYPE argument is actually numeric (settype($x, "string")
1163
+ // or ("array") is the opposite of a sanitizer).
1164
+ { kind: 'sanitizer', id: 'php-settype-numeric', language: 'php',
1165
+ match: { type: 'call', callee: 'settype', requireLiteralArg: { index: 1, pattern: '^"(?:int|integer|float|double)"$' } },
1166
+ effect: 'coerce', appliesTo: ['*'] },
1167
+ // Escaping functions, family-scoped and NOT universal: each only
1168
+ // neutralizes ITS OWN sink's metacharacter grammar. mysql(i)_real_escape_string
1169
+ // and pg_escape_string escape SQL string-literal delimiters specifically —
1170
+ // they do nothing for command/LDAP/XSS injection, and (a real, disclosed
1171
+ // limitation, not fixed here) they are only actually safe when the escaped
1172
+ // value is placed INSIDE a quoted SQL string literal, which this catalog
1173
+ // has no mechanism to verify.
1174
+ { kind: 'sanitizer', id: 'php-mysqli-real-escape-string', language: 'php', match: { type: 'call', callee: 'real_escape_string' }, effect: 'strip', appliesTo: ['sql'] },
1175
+ { kind: 'sanitizer', id: 'php-mysql-real-escape-string', language: 'php', match: { type: 'call', callee: 'mysql_real_escape_string' }, effect: 'strip', appliesTo: ['sql'] },
1176
+ { kind: 'sanitizer', id: 'php-pg-escape-string', language: 'php', match: { type: 'call', callee: 'pg_escape_string' }, effect: 'strip', appliesTo: ['sql'] },
1177
+ { kind: 'sanitizer', id: 'php-addslashes', language: 'php', match: { type: 'call', callee: 'addslashes' }, effect: 'strip', appliesTo: ['sql'] },
1178
+ { kind: 'sanitizer', id: 'php-ldap-escape', language: 'php', match: { type: 'call', callee: 'ldap_escape' }, effect: 'strip', appliesTo: ['ldap'] },
979
1179
 
980
1180
  // ─── SANITIZERS (Ruby) ────────────────────────────────────────────────────
981
1181
  { kind: 'sanitizer', id: 'rb-rails-html-escape', language: 'rb', match: { type: 'call', callee: 'h' }, effect: 'strip', appliesTo: ['xss'] },
@@ -1192,6 +1392,35 @@ export const CATALOG = [
1192
1392
  { kind: 'sink', id: 'java-servlet-setheader', language: 'java', framework: 'servlet', match: { type: 'call', callee: 'setHeader' }, argIndex: 1,
1193
1393
  vuln: { name: 'HTTP Response Splitting / Header Injection (HttpServletResponse.setHeader)', severity: 'high', cwe: 'CWE-113',
1194
1394
  remediation: 'Strip/validate CR/LF from any user-controlled value before using it as a header value.' } },
1395
+ { kind: 'sink', id: 'java-servlet-addheader', language: 'java', framework: 'servlet', match: { type: 'call', callee: 'addHeader' }, argIndex: 1,
1396
+ vuln: { name: 'HTTP Response Splitting / Header Injection (HttpServletResponse.addHeader)', severity: 'high', cwe: 'CWE-113',
1397
+ remediation: 'Strip/validate CR/LF from any user-controlled value before using it as a header value.' } },
1398
+ // `Cookie`'s own value comes from its constructor's 2nd arg, so the sink
1399
+ // is the constructor, not addCookie() (which only ever receives the
1400
+ // already-built Cookie object).
1401
+ { kind: 'sink', id: 'java-new-Cookie', language: 'java', framework: 'servlet', match: { type: 'call', callee: 'Cookie' }, argIndex: 1,
1402
+ vuln: { name: 'HTTP Response Splitting / Header Injection (new Cookie(name, value))', severity: 'high', cwe: 'CWE-113',
1403
+ remediation: 'Strip/validate CR/LF from any user-controlled value before using it as a cookie value.' } },
1404
+ // CWE-470: unsafe reflection. Juliet's canonical shape passes a
1405
+ // user-controlled class name straight to Class.forName, then constructs
1406
+ // or invokes it — the class-name argument is the attacker's real lever.
1407
+ { kind: 'sink', id: 'java-class-forname', language: 'java', framework: 'stdlib', match: { type: 'call', callee: 'forName' }, argIndex: 0,
1408
+ vuln: { name: 'Unsafe Reflection (Class.forName)', severity: 'high', cwe: 'CWE-470',
1409
+ remediation: 'Validate the class name against an explicit allow-list before loading it reflectively.' } },
1410
+ { kind: 'sink', id: 'java-method-invoke', language: 'java', framework: 'stdlib', match: { type: 'call', callee: 'invoke', receiver: '^method$' }, argIndex: 'all',
1411
+ vuln: { name: 'Unsafe Reflection (Method.invoke)', severity: 'high', cwe: 'CWE-470',
1412
+ remediation: 'Validate the target method/class against an explicit allow-list before invoking it reflectively.' } },
1413
+ // CWE-134: uncontrolled format string. `String.format`/`Formatter.format`/
1414
+ // `PrintStream.printf` all treat arg 0 as the format string itself; a
1415
+ // tainted format string (as opposed to a tainted format ARGUMENT, which is
1416
+ // ordinary and safe) can leak memory contents via `%s`/`%n$s` or crash the
1417
+ // process.
1418
+ { kind: 'sink', id: 'java-string-format', language: 'java', framework: 'stdlib', match: { type: 'call', callee: 'format' }, argIndex: 0,
1419
+ vuln: { name: 'Uncontrolled Format String (String.format / Formatter.format)', severity: 'medium', cwe: 'CWE-134',
1420
+ remediation: 'Never pass user input as the format string itself; use a fixed format string and pass user input only as an argument.' } },
1421
+ { kind: 'sink', id: 'java-printstream-printf', language: 'java', framework: 'stdlib', match: { type: 'call', callee: 'printf' }, argIndex: 0,
1422
+ vuln: { name: 'Uncontrolled Format String (PrintStream.printf)', severity: 'medium', cwe: 'CWE-134',
1423
+ remediation: 'Never pass user input as the format string itself; use a fixed format string and pass user input only as an argument.' } },
1195
1424
  { kind: 'sink', id: 'kt-servlet-setheader', language: 'kt', framework: 'servlet', match: { type: 'call', callee: 'setHeader' }, argIndex: 1,
1196
1425
  vuln: { name: 'HTTP Response Splitting / Header Injection (HttpServletResponse.setHeader)', severity: 'high', cwe: 'CWE-113',
1197
1426
  remediation: 'Strip/validate CR/LF from any user-controlled value before using it as a header value.' } },
@@ -1207,7 +1436,18 @@ export const CATALOG = [
1207
1436
  { kind: 'sanitizer', id: 'py-urllib-quote', language: 'py', match: { type: 'call', callee: 'quote_plus' }, effect: 'strip', appliesTo: ['url'] },
1208
1437
  { kind: 'sanitizer', id: 'py-int-v2', language: 'py', match: { type: 'call', callee: 'int' }, effect: 'strip', appliesTo: ['*'] },
1209
1438
  { kind: 'sanitizer', id: 'py-float-v2', language: 'py', match: { type: 'call', callee: 'float' }, effect: 'strip', appliesTo: ['*'] },
1210
- { kind: 'sanitizer', id: 'py-ast-literal-eval', language: 'py', match: { type: 'call', callee: 'literal_eval' }, effect: 'strip', appliesTo: ['*'] },
1439
+ // NOT a type-coercion sanitizer despite the old appliesTo:['*'] tag:
1440
+ // ast.literal_eval("'; DROP TABLE users;--'") returns that exact STRING
1441
+ // unchanged — a Python string literal evaluates to itself. Its only real
1442
+ // safety property is preventing arbitrary CODE execution (the eval()
1443
+ // replacement it exists for), not neutralizing the resulting value's
1444
+ // content for any other downstream sink. `appliesTo:['*']` was safe only
1445
+ // as long as a sanitizer match merely DEMOTED confidence; now that
1446
+ // dataflow/engine.js's _isCoercionCall treats a universal tag as licence to
1447
+ // KILL taint outright, this entry would silently hide a real SQL/command
1448
+ // injection whose payload happens to parse as a Python literal. appliesTo
1449
+ // left empty (never matches any family) until this is scoped correctly.
1450
+ { kind: 'sanitizer', id: 'py-ast-literal-eval', language: 'py', match: { type: 'call', callee: 'literal_eval' }, effect: 'strip', appliesTo: [] },
1211
1451
  { kind: 'sanitizer', id: 'py-yaml-safe-load', language: 'py', match: { type: 'call', callee: 'safe_load' }, effect: 'strip', appliesTo: ['deserial'] },
1212
1452
  { kind: 'sanitizer', id: 'py-pathlib-resolve', language: 'py', match: { type: 'call', callee: 'resolve' }, effect: 'taintIf-not-pinned', appliesTo: ['path'] },
1213
1453
  { kind: 'sanitizer', id: 'py-defusedxml', language: 'py', match: { type: 'call', callee: 'fromstring' }, effect: 'strip', appliesTo: ['xxe'] }, // when called from defusedxml namespace
@@ -1219,7 +1459,61 @@ export const CATALOG = [
1219
1459
  { kind: 'source', id: 'cs-request-headers', language: 'cs', framework: 'aspnet', match: { type: 'member', object: 'Request', prop: 'Headers' }, label: 'Request.Headers', provenance: 'header' },
1220
1460
  { kind: 'source', id: 'cs-request-params', language: 'cs', framework: 'aspnet', match: { type: 'member', object: 'Request', prop: 'Params' }, label: 'Request.Params' },
1221
1461
  { kind: 'source', id: 'cs-request-body', language: 'cs', framework: 'aspnet-core', match: { type: 'member', object: 'Request', prop: 'Body' }, label: 'Request.Body', provenance: 'http-body' },
1462
+ // SARD_80_F1: the entries above only match a receiver literally named
1463
+ // `Request` (the ASP.NET-Core-controller convention, `this.Request.…`
1464
+ // stripped to its bare member). Juliet's own dominant local-variable
1465
+ // naming (`req`/`request` params, exactly as JS's `req.query`/
1466
+ // `request.body` entries already cover) never matched at all — same
1467
+ // gap, same fix, mirrored per property.
1468
+ { kind: 'source', id: 'cs-req-form', language: 'cs', framework: 'aspnet', match: { type: 'member', object: 'req', prop: 'Form' }, label: 'req.Form', provenance: 'http-body' },
1469
+ { kind: 'source', id: 'cs-req-query', language: 'cs', framework: 'aspnet', match: { type: 'member', object: 'req', prop: 'QueryString' }, label: 'req.QueryString', provenance: 'url-param' },
1470
+ { kind: 'source', id: 'cs-req-cookies', language: 'cs', framework: 'aspnet', match: { type: 'member', object: 'req', prop: 'Cookies' }, label: 'req.Cookies', provenance: 'cookie' },
1471
+ { kind: 'source', id: 'cs-req-headers', language: 'cs', framework: 'aspnet', match: { type: 'member', object: 'req', prop: 'Headers' }, label: 'req.Headers', provenance: 'header' },
1472
+ { kind: 'source', id: 'cs-req-params', language: 'cs', framework: 'aspnet', match: { type: 'member', object: 'req', prop: 'Params' }, label: 'req.Params', provenance: 'url-param' },
1473
+ { kind: 'source', id: 'cs-request-lc-form', language: 'cs', framework: 'aspnet', match: { type: 'member', object: 'request', prop: 'Form' }, label: 'request.Form', provenance: 'http-body' },
1474
+ { kind: 'source', id: 'cs-request-lc-query', language: 'cs', framework: 'aspnet', match: { type: 'member', object: 'request', prop: 'QueryString' }, label: 'request.QueryString', provenance: 'url-param' },
1475
+ { kind: 'source', id: 'cs-request-lc-cookies', language: 'cs', framework: 'aspnet', match: { type: 'member', object: 'request', prop: 'Cookies' }, label: 'request.Cookies', provenance: 'cookie' },
1476
+ { kind: 'source', id: 'cs-request-lc-headers', language: 'cs', framework: 'aspnet', match: { type: 'member', object: 'request', prop: 'Headers' }, label: 'request.Headers', provenance: 'header' },
1477
+ { kind: 'source', id: 'cs-request-lc-params', language: 'cs', framework: 'aspnet', match: { type: 'member', object: 'request', prop: 'Params' }, label: 'request.Params', provenance: 'url-param' },
1222
1478
  { kind: 'source', id: 'cs-env-var', language: 'cs', framework: 'stdlib', match: { type: 'call', callee: 'GetEnvironmentVariable' }, label: 'Environment.GetEnvironmentVariable', provenance: 'env' },
1479
+ { kind: 'source', id: 'cs-env-args', language: 'cs', framework: 'stdlib', match: { type: 'call', callee: 'GetCommandLineArgs' }, label: 'Environment.GetCommandLineArgs', provenance: 'env' },
1480
+ // Console/stream/socket console-style reads — Juliet's other dominant
1481
+ // source family (`Console.ReadLine()`, `IO.StreamReader/TextReader/
1482
+ // BinaryReader.ReadLine()/ReadToEnd()`). Bare (receiver-unscoped): the
1483
+ // last-segment index match already makes `Console.ReadLine()` and
1484
+ // `sr.ReadLine()` share this one entry, exactly the "receiver-agnostic"
1485
+ // shape `cs-directorysearcher-filter`'s own comment documents for reads.
1486
+ // No `provenance` field on the 6 entries below: 'io-read'/'database' are
1487
+ // not values src/lineage/source-registry.js's PROVENANCE_MAP recognizes
1488
+ // (found via that registry's own pinned coverage-count test after this
1489
+ // catalog change merged) — the established convention for exactly this
1490
+ // shape (a stream/console read; a JDBC/ADO result-set read) is to declare
1491
+ // NO provenance field at all and let source-registry.js's own
1492
+ // NO_PROVENANCE_OVERRIDES table classify by entry id instead, matching
1493
+ // java-io-readline (user-input) and java-resultset-getstring/-getobject
1494
+ // (database-read) exactly. Overrides added there, not invented here.
1495
+ { kind: 'source', id: 'cs-console-readline', language: 'cs', framework: 'stdlib', match: { type: 'call', callee: 'ReadLine' }, label: 'Console/TextReader.ReadLine' },
1496
+ { kind: 'source', id: 'cs-reader-readtoend', language: 'cs', framework: 'stdlib', match: { type: 'call', callee: 'ReadToEnd' }, label: 'TextReader.ReadToEnd' },
1497
+ { kind: 'source', id: 'cs-console-read', language: 'cs', framework: 'stdlib', match: { type: 'call', callee: 'Read', receiver: '^Console$' }, label: 'Console.Read' },
1498
+ { kind: 'source', id: 'cs-tcpclient-getstream', language: 'cs', framework: 'stdlib', match: { type: 'call', callee: 'GetStream' }, label: 'TcpClient/TcpListener.GetStream', provenance: 'network' },
1499
+ { kind: 'source', id: 'cs-registry-getvalue', language: 'cs', framework: 'stdlib', match: { type: 'call', callee: 'GetValue', receiver: '[Rr]egistry' }, label: 'Registry(Key).GetValue', provenance: 'env' },
1500
+ // `receiverTypeIn` (SARD_80_F1 W4.C39): additive to the `receiver` name
1501
+ // regex, never a replacement — confirms a match via CHA-resolved declared
1502
+ // type when Juliet's own idiomatic short variable name (`dr`) fails the
1503
+ // name check outright (no "reader" substring). Same
1504
+ // DATAREADER_SOURCE_TYPES list already established for the SEPARATE
1505
+ // lexical structural tracker at posture/csharp-analysis.js (W4.C34).
1506
+ { kind: 'source', id: 'cs-datareader-getstring', language: 'cs', framework: 'ado', match: { type: 'call', callee: 'GetString', receiver: '[Rr]eader', receiverTypeIn: ['^(?:Sql|OleDb|Odbc|MySql|Npgsql|Sqlite)DataReader$'] }, label: 'SqlDataReader.GetString' },
1507
+ { kind: 'source', id: 'cs-datareader-getvalue', language: 'cs', framework: 'ado', match: { type: 'call', callee: 'GetValue', receiver: '[Rr]eader', receiverTypeIn: ['^(?:Sql|OleDb|Odbc|MySql|Npgsql|Sqlite)DataReader$'] }, label: 'SqlDataReader.GetValue' },
1508
+ { kind: 'source', id: 'cs-datareader-getint32', language: 'cs', framework: 'ado', match: { type: 'call', callee: 'GetInt32', receiver: '[Rr]eader', receiverTypeIn: ['^(?:Sql|OleDb|Odbc|MySql|Npgsql|Sqlite)DataReader$'] }, label: 'SqlDataReader.GetInt32' },
1509
+ // File-read variants: the CONTENTS of a file are untrusted data (a
1510
+ // taint SOURCE) independent of whether the PATH argument to the same
1511
+ // call is itself tainted (`cs-file-readall` below still checks that,
1512
+ // on the same call, for the path-traversal family — a call can be both
1513
+ // a source and a sink argument at once, same as every other language's
1514
+ // `open(path).read()` shape in this catalog).
1515
+ { kind: 'source', id: 'cs-file-readalltext-src', language: 'cs', framework: 'stdlib', match: { type: 'call', callee: 'ReadAllText', receiver: '^File$' }, label: 'File.ReadAllText', provenance: 'file-read' },
1516
+ { kind: 'source', id: 'cs-file-readalllines-src', language: 'cs', framework: 'stdlib', match: { type: 'call', callee: 'ReadAllLines', receiver: '^File$' }, label: 'File.ReadAllLines', provenance: 'file-read' },
1223
1517
 
1224
1518
  // ─── SINKS (C#) ──────────────────────────────────────────────────────────
1225
1519
  { kind: 'sink', id: 'cs-sqlcommand', language: 'cs', framework: 'ado', match: { type: 'call', callee: 'SqlCommand' }, argIndex: 0,
@@ -1248,6 +1542,33 @@ export const CATALOG = [
1248
1542
  match: { type: 'call', callee: 'Start', requireLiteralArg: { index: 0, pattern: '^"cmd(?:\\.exe)?"$|^"(?:/bin/)?(?:sh|bash)"$' } }, argIndex: 'all',
1249
1543
  vuln: { name: 'Command Injection (Process.Start string-form)', severity: 'critical', cwe: 'CWE-78',
1250
1544
  remediation: 'Use ProcessStartInfo with separated FileName + Arguments; never pass /c with concat.' } },
1545
+ // The SINGLE-argument `Process.Start(commandString)` overload — Juliet's
1546
+ // OWN canonical C# CWE-78 shape (confirmed by reading the real, public
1547
+ // Juliet C# mirror this project's own SARD manifest pins:
1548
+ // `github.com/snyk-schmidtty/juliet-test-suite-csharp`,
1549
+ // `CWE78_OS_Command_Injection__Connect_tcp_01.cs`:
1550
+ // `Process.Start(osCommand + data)`, the interpreter-plus-arguments
1551
+ // ALREADY concatenated into ONE tainted string before the call).
1552
+ // Neither entry above can ever match this: `cs-process-start`'s
1553
+ // `requireLiteralArg` fails CLOSED because arg0 here is a CONCAT
1554
+ // expression, not a literal (there is no separate, provably-shell
1555
+ // literal to check — the whole point of this shape); `cs-process-
1556
+ // start-args` checks argIndex 1, which does not exist on a one-argument
1557
+ // call. Unlike the 2-argument array-execve form (filename, args[]),
1558
+ // there is no safe interpretation of this overload with a tainted
1559
+ // argument — the ENTIRE string IS the thing .NET treats as a shell-
1560
+ // dispatchable command line — so no `requireLiteralArg` gate applies
1561
+ // here at all. Scoped to the `Process` receiver (a real BCL class name,
1562
+ // not scrambled by --scramble-identifiers) to avoid over-matching an
1563
+ // unrelated `.Start()` call (a `Timer`/`Thread`/`Stopwatch`, none of
1564
+ // which take a string command argument at all, but bare `callee:
1565
+ // 'Start'` with no receiver constraint — as the two entries above
1566
+ // already do — would still be a real precision risk for a hypothetical
1567
+ // future scrambled receiver).
1568
+ { kind: 'sink', id: 'cs-process-start-single', language: 'cs', framework: 'stdlib',
1569
+ match: { type: 'call', callee: 'Start', receiver: '^Process$' }, argIndex: 0,
1570
+ vuln: { name: 'Command Injection (Process.Start single-argument command string)', severity: 'critical', cwe: 'CWE-78',
1571
+ remediation: 'Use ProcessStartInfo with separated FileName + ArgumentList; never build one concatenated command-line string.' } },
1251
1572
  // Companion to the entry above, for the NON-shell-literal filename case
1252
1573
  // that entry deliberately excludes (`Process.Start("ping", tainted)`).
1253
1574
  // This is a real, distinct risk, not a benchmark-only shape:
@@ -1296,10 +1617,75 @@ export const CATALOG = [
1296
1617
  { kind: 'sink', id: 'cs-localredirect', language: 'cs', framework: 'aspnet', match: { type: 'call', callee: 'LocalRedirect' }, argIndex: 0,
1297
1618
  vuln: { name: 'Open Redirect (Controller.LocalRedirect)', severity: 'medium', cwe: 'CWE-601',
1298
1619
  remediation: 'LocalRedirect rejects absolute URLs, but a crafted relative path can still redirect off-site via scheme-relative (//evil.com) input — validate the path.' } },
1299
- { kind: 'sink', id: 'cs-response-write', language: 'cs', framework: 'aspnet', match: { type: 'call', callee: 'Write', receiver: '^Response$' }, argIndex: 0,
1620
+ // SARD 80% F1 push — this project's own W4.C1 investigation logged
1621
+ // C# CWE-80/81/83 as a "total blackout... zero findings of ANY kind
1622
+ // across 1084 real files", assumed to need deep architectural work.
1623
+ // Root cause, confirmed via the public Juliet C# mirror this project's
1624
+ // manifest pins (`CWE80_XSS__CWE182_Web_Connect_tcp_01.cs`): the sink is
1625
+ // `resp.Write(...)`, where `resp` is a METHOD PARAMETER
1626
+ // (`Bad(HttpRequest req, HttpResponse resp)`) — never the literal
1627
+ // identifier `Response` this entry's `receiver: '^Response$'` required,
1628
+ // exact-match, with no alternation at all. `receiver` widened to accept
1629
+ // the overwhelmingly common real-world parameter names
1630
+ // (`resp`/`response`, either casing) and `receiverTypeIn` added as an
1631
+ // ADDITIVE fallback (same pattern as `cs-htmltextwriter-write` below —
1632
+ // `receiver` stays required, since `_receiverAllowed` matches ANY
1633
+ // receiver when neither `receiver` nor `receiverBase` is set), seeded
1634
+ // from `resp`'s declared parameter type (`fn.paramTypes`, confirmed via
1635
+ // direct IR dump to already capture `resp: "HttpResponse"` with no
1636
+ // parser changes needed).
1637
+ { kind: 'sink', id: 'cs-response-write', language: 'cs', framework: 'aspnet', match: { type: 'call', callee: 'Write', receiver: '[Rr]esp(?:onse)?', receiverTypeIn: ['^HttpResponse$'] }, argIndex: 0,
1300
1638
  vuln: { name: 'Reflected XSS (Response.Write)', severity: 'high', cwe: 'CWE-79',
1301
1639
  remediation: 'HTML-encode with HttpUtility.HtmlEncode before writing user input to the response.' } },
1302
- { kind: 'sink', id: 'cs-response-addheader', language: 'cs', framework: 'aspnet', match: { type: 'call', callee: 'AddHeader', receiver: '^Response$' }, argIndex: 1,
1640
+ // CWE-81 (XSS in Error Message) — a member-WRITE sink, not a call at
1641
+ // all: Juliet's real C# shape (confirmed via the public Juliet mirror
1642
+ // this project's manifest pins,
1643
+ // CWE81_XSS_Error_Message__Web_Connect_tcp_01.cs) sets
1644
+ // `resp.StatusDescription = "..." + data;` directly. This was a
1645
+ // genuinely MISSING sink (no catalog entry of any kind referenced
1646
+ // `StatusDescription`), not a receiver-name mismatch like
1647
+ // `cs-response-write`'s bug above — the two gaps happened to share a
1648
+ // CWE-80/81/83 investigation but have different root causes.
1649
+ // `match.object: '_any_'` required for any member-WRITE sink (see
1650
+ // dataflow/CLAUDE.md's gotcha); same receiver/receiverTypeIn shape as
1651
+ // `cs-response-write` for consistency and scramble-safety.
1652
+ { kind: 'sink', id: 'cs-response-statusdescription', language: 'cs', framework: 'aspnet', match: { type: 'member', object: '_any_', prop: 'StatusDescription', receiver: '[Rr]esp(?:onse)?', receiverTypeIn: ['^HttpResponse$'] }, argIndex: 'rhs',
1653
+ vuln: { name: 'Reflected XSS (Response.StatusDescription assigned a tainted value)', severity: 'high', cwe: 'CWE-81',
1654
+ remediation: 'HTML-encode with HttpUtility.HtmlEncode before assigning user input to StatusDescription, or avoid echoing it in a custom error page.' } },
1655
+ // SARD_80_F1 W4: `HtmlTextWriter` (ASP.NET Web Forms' `Render(HtmlTextWriter
1656
+ // writer)` override, and its declared-local-variable form
1657
+ // `HtmlTextWriter writer = ...; writer.Write(x);`) is a named W4 target
1658
+ // (495 dev cases) with NO prior catalog coverage at all — only the
1659
+ // structural `csharp.js` detector's name-based heuristic
1660
+ // (`/Writer|writer|output/i.test(call.receiver)`) could ever catch it,
1661
+ // and only when the variable happens to be named with "writer"/"output"
1662
+ // in it. `receiverTypeIn` (additive, never replaces a name check) lets
1663
+ // this ALSO fire off `class-hierarchy.js`'s `typeOfVar`, populated from
1664
+ // either a declared-type local (`parser-cs.js`'s `declaredType`) or a
1665
+ // parameter's declared type — same mechanism proven for Java's
1666
+ // `PrintWriter` two-step gap (see `java-writer-*` above).
1667
+ //
1668
+ // `receiver` is still REQUIRED here, not optional: `_receiverAllowed`
1669
+ // (catalog.js) returns `true` UNCONDITIONALLY when an entry sets neither
1670
+ // `receiver` nor `receiverBase` — so a `receiverTypeIn`-only entry would
1671
+ // match `Write`/`WriteLine` on ANY receiver whatsoever (Console, a
1672
+ // StreamWriter, a logger, literally anything), not narrow the match at
1673
+ // all. Caught before shipping by re-reading `_receiverAllowed`'s own
1674
+ // short-circuit, not by a failing test — `Write`/`WriteLine` are far too
1675
+ // generic a method name for `receiverTypeIn` to safely stand alone the
1676
+ // way the single-purpose `getString`/`fromString`-style entries
1677
+ // elsewhere in this file do. The name pattern mirrors the pre-existing
1678
+ // structural detector's own convention (`csharp.js`'s
1679
+ // `/Writer|writer|output/i`) so the two stay aligned.
1680
+ { kind: 'sink', id: 'cs-htmltextwriter-write', language: 'cs', framework: 'aspnet', match: { type: 'call', callee: 'Write', receiver: '(?:[Ww]riter|output)', receiverTypeIn: ['^HtmlTextWriter$'] }, argIndex: 0,
1681
+ vuln: { name: 'Reflected XSS (HtmlTextWriter.Write)', severity: 'high', cwe: 'CWE-79',
1682
+ remediation: 'HTML-encode with HttpUtility.HtmlEncode before writing user input to an HtmlTextWriter.' } },
1683
+ { kind: 'sink', id: 'cs-htmltextwriter-writeline', language: 'cs', framework: 'aspnet', match: { type: 'call', callee: 'WriteLine', receiver: '(?:[Ww]riter|output)', receiverTypeIn: ['^HtmlTextWriter$'] }, argIndex: 0,
1684
+ vuln: { name: 'Reflected XSS (HtmlTextWriter.WriteLine)', severity: 'high', cwe: 'CWE-79',
1685
+ remediation: 'HTML-encode with HttpUtility.HtmlEncode before writing user input to an HtmlTextWriter.' } },
1686
+ // Same `resp` (parameter name, not the literal identifier `Response`)
1687
+ // root cause as `cs-response-write` above.
1688
+ { kind: 'sink', id: 'cs-response-addheader', language: 'cs', framework: 'aspnet', match: { type: 'call', callee: 'AddHeader', receiver: '[Rr]esp(?:onse)?', receiverTypeIn: ['^HttpResponse$'] }, argIndex: 1,
1303
1689
  vuln: { name: 'HTTP Response Splitting / Header Injection (Response.AddHeader)', severity: 'high', cwe: 'CWE-113',
1304
1690
  remediation: 'Strip CR/LF from header values, or use a framework API that rejects them automatically.' } },
1305
1691
  { kind: 'sink', id: 'cs-xmldoc-load', language: 'cs', framework: 'stdlib', match: { type: 'call', callee: 'Load', receiver: '^(?:[Xx]ml[Dd]oc(?:ument)?|xmlDoc|doc)$' }, argIndex: 0,
@@ -1333,12 +1719,135 @@ export const CATALOG = [
1333
1719
  vuln: { name: 'LDAP Injection (DirectorySearcher.Filter assigned a concatenated value)', severity: 'high', cwe: 'CWE-90',
1334
1720
  remediation: 'Escape LDAP special characters in filter components, or build the filter with a parameterized helper.' } },
1335
1721
 
1722
+ // `CommandText = tainted` — Juliet's dominant real-world SQLi shape,
1723
+ // even more common than the `new SqlCommand(tainted, conn)` constructor
1724
+ // form `cs-sqlcommand` already covers: build the command with a fixed
1725
+ // (often parameterless) constructor, then assign the query text to the
1726
+ // property afterward. `match.object: '_any_'` is required for any
1727
+ // member-WRITE sink (see dataflow/CLAUDE.md's gotcha on this) — object
1728
+ // specificity is applied afterward via `receiverTypeIn`.
1729
+ { kind: 'sink', id: 'cs-commandtext-write', language: 'cs', framework: 'ado', match: { type: 'member', object: '_any_', prop: 'CommandText', receiverTypeIn: ['^(?:Sql|OleDb|Odbc|MySql|Npgsql|Sqlite)Command$'] }, argIndex: 'rhs',
1730
+ vuln: { name: 'SQL Injection (CommandText assigned a concatenated/tainted value)', severity: 'critical', cwe: 'CWE-89',
1731
+ remediation: 'Use a parameterized query: keep `CommandText` a fixed string with `@name` placeholders and bind values via `cmd.Parameters.AddWithValue(...)`.' } },
1732
+ // Juliet's canonical C# CWE-78 shape: build a `Process` object with a
1733
+ // parameterless/fixed constructor, then assign the tainted command text
1734
+ // to `.StartInfo.Arguments` (or, less commonly, `.StartInfo.FileName`)
1735
+ // afterward — the exact same "construct-then-assign" idiom
1736
+ // `cs-commandtext-write` above already models for SQL, just never
1737
+ // ported to this sink. Found via direct probing (a synthetic
1738
+ // `Process.Start()`-based Juliet-shaped fixture produced ZERO findings
1739
+ // of ANY kind before this entry existed), not assumed: the two PRE-
1740
+ // EXISTING C# CWE-78 entries (`cs-process-start`/`cs-process-start-args`)
1741
+ // both key off the static `Process.Start(...)` CALL form, which this
1742
+ // idiom never uses at all — `.Start()` is called on the instance with NO
1743
+ // arguments, so neither entry's `argIndex` had anything to check.
1744
+ // `match.object: '_any_'` required for any member-WRITE sink (see
1745
+ // dataflow/CLAUDE.md's gotcha); scoped via a name-based `receiver`
1746
+ // rather than `receiverTypeIn` since `ProcessStartInfo`'s own property
1747
+ // name (`StartInfo`) is a reliable, idiomatic .NET naming convention on
1748
+ // its own, and seeding `typeOfVar` for `new Process()` var was not yet
1749
+ // verified to work for this shape.
1750
+ { kind: 'sink', id: 'cs-processstartinfo-arguments', language: 'cs', framework: 'stdlib', match: { type: 'member', object: '_any_', prop: 'Arguments', receiver: 'StartInfo' }, argIndex: 'rhs',
1751
+ vuln: { name: 'Command Injection (ProcessStartInfo.Arguments assigned a concatenated/tainted value)', severity: 'critical', cwe: 'CWE-78',
1752
+ remediation: 'Use ProcessStartInfo.ArgumentList (never a single concatenated Arguments string), or validate/allow-list the argument content.' } },
1753
+ { kind: 'sink', id: 'cs-processstartinfo-filename', language: 'cs', framework: 'stdlib', match: { type: 'member', object: '_any_', prop: 'FileName', receiver: 'StartInfo' }, argIndex: 'rhs',
1754
+ vuln: { name: 'Command Injection (ProcessStartInfo.FileName assigned a tainted value)', severity: 'critical', cwe: 'CWE-78',
1755
+ remediation: 'Resolve the executable path against an explicit allow-list before assigning it to ProcessStartInfo.FileName.' } },
1756
+ { kind: 'sink', id: 'cs-sqldataadapter', language: 'cs', framework: 'ado', match: { type: 'call', callee: 'SqlDataAdapter' }, argIndex: 0,
1757
+ vuln: { name: 'SQL Injection (new SqlDataAdapter with concatenated query text)', severity: 'critical', cwe: 'CWE-89',
1758
+ remediation: 'Pass a parameterized SqlCommand to the adapter instead of a raw query string.' } },
1759
+ { kind: 'sink', id: 'cs-oledbcommand', language: 'cs', framework: 'ado', match: { type: 'call', callee: 'OleDbCommand' }, argIndex: 0,
1760
+ vuln: { name: 'SQL Injection (new OleDbCommand with concatenated query text)', severity: 'critical', cwe: 'CWE-89',
1761
+ remediation: 'Use parameterized OleDbCommand: bind values via `cmd.Parameters.AddWithValue(...)` rather than concatenating into the command text.' } },
1762
+ { kind: 'sink', id: 'cs-odbccommand', language: 'cs', framework: 'ado', match: { type: 'call', callee: 'OdbcCommand' }, argIndex: 0,
1763
+ vuln: { name: 'SQL Injection (new OdbcCommand with concatenated query text)', severity: 'critical', cwe: 'CWE-89',
1764
+ remediation: 'Use parameterized OdbcCommand: bind values via `cmd.Parameters.AddWithValue(...)` rather than concatenating into the command text.' } },
1765
+ // Reflection-based dynamic type loading (CWE-470, "Unsafe Reflection") —
1766
+ // resolving/instantiating a type by a tainted name lets the caller pick
1767
+ // ANY loadable type, which is exactly what CWE-502-adjacent deserializer
1768
+ // gadget chains need.
1769
+ { kind: 'sink', id: 'cs-type-gettype', language: 'cs', framework: 'stdlib', match: { type: 'call', callee: 'GetType', receiver: '^Type$' }, argIndex: 0,
1770
+ vuln: { name: 'Unsafe Reflection (Type.GetType with tainted type name)', severity: 'high', cwe: 'CWE-470',
1771
+ remediation: 'Resolve against an explicit allow-list of known-safe type names before calling Type.GetType/Activator.CreateInstance.' } },
1772
+ { kind: 'sink', id: 'cs-assembly-load', language: 'cs', framework: 'stdlib', match: { type: 'call', callee: 'Load', receiver: '^Assembly$' }, argIndex: 0,
1773
+ vuln: { name: 'Unsafe Reflection (Assembly.Load with tainted assembly name)', severity: 'high', cwe: 'CWE-470',
1774
+ remediation: 'Never load an assembly by a user-controlled name or path; resolve against an explicit allow-list.' } },
1775
+ { kind: 'sink', id: 'cs-activator-createinstance', language: 'cs', framework: 'stdlib', match: { type: 'call', callee: 'CreateInstance', receiver: '^Activator$' }, argIndex: 'all',
1776
+ vuln: { name: 'Unsafe Reflection (Activator.CreateInstance with a tainted type)', severity: 'high', cwe: 'CWE-470',
1777
+ remediation: 'Resolve the target type against an explicit allow-list before instantiating it dynamically.' } },
1778
+ // CWE-134 — an attacker-controlled FORMAT string (not just an
1779
+ // interpolated argument) can inject its own `{n}`/`%` directives.
1780
+ // Scoped to arg0 ONLY, deliberately: `String.Format`'s substitution
1781
+ // arguments (index 1+) are DATA, not format directives, so a tainted
1782
+ // substitution argument reaching an otherwise-literal format string
1783
+ // (`String.Format("Hello, {0}!", name)`) is not this vulnerability —
1784
+ // only the FORMAT STRING itself being attacker-controlled is. A bare
1785
+ // `Console.WriteLine(tainted)` single-argument sink was deliberately
1786
+ // NOT added alongside this: with only one argument there is no `{n}`
1787
+ // substitution happening at all (that overload does not treat its sole
1788
+ // argument as a format string), so it would have no real CWE-134
1789
+ // shape to detect — confirmed by a nested-call precision fixture where
1790
+ // such a sink fired on a value that was already-formatted output text
1791
+ // wrapping a merely-tainted substitution argument.
1792
+ // `^[Ss]tring$`, not `^String$`: C#'s `string` keyword is a real,
1793
+ // interchangeable alias for `System.String` (`string.Format(...)` and
1794
+ // `String.Format(...)` compile to the IDENTICAL call), and Juliet's own
1795
+ // real C# CWE-134 corpus (confirmed via the public Juliet mirror this
1796
+ // project's SARD manifest pins) uses the lowercase form exclusively
1797
+ // (`Console.Write(string.Format(data))`) — the capitalized-only pattern
1798
+ // silently missed 100% of this corpus's own CWE-134 cases, confirmed by
1799
+ // a direct probe (`String.Format` fires via IR-TAINT; the byte-identical
1800
+ // `string.Format` produces zero IR-TAINT findings before this fix).
1801
+ { kind: 'sink', id: 'cs-string-format', language: 'cs', framework: 'stdlib', match: { type: 'call', callee: 'Format', receiver: '^[Ss]tring$' }, argIndex: 0,
1802
+ vuln: { name: 'Externally-Controlled Format String (String.Format)', severity: 'medium', cwe: 'CWE-134',
1803
+ remediation: 'Never build the FORMAT string itself from user input; keep the format literal and pass user data only as substitution arguments.' } },
1804
+ // CWE-643 — XPath built from concatenated/tainted text.
1805
+ //
1806
+ // `receiverTypeIn` added: Juliet's OWN canonical C# CWE-643 shape
1807
+ // (confirmed via the public Juliet C# mirror this project's SARD
1808
+ // manifest pins, `CWE643_Xpath_Injection__Connect_tcp_01.cs`) names its
1809
+ // `XPathNavigator` variable `xPath` — matching neither `[Nn]av` nor
1810
+ // `[Nn]avigator`. Worse than a one-off naming miss: under
1811
+ // `--scramble-identifiers` (the ONLY mode this benchmark ever scores
1812
+ // from), EVERY local variable name becomes an opaque hash regardless of
1813
+ // what it was originally called, so a receiver match keyed purely on
1814
+ // NAME can never fire on this benchmark at all, independent of Juliet's
1815
+ // own naming choice. `receiverTypeIn` is additive (OR'd with the name
1816
+ // check, never replacing it — see `_receiverTypeConfirms`), seeded here
1817
+ // from the declared-type annotation on `XPathNavigator xPath =
1818
+ // inputXml.CreateNavigator();` (parser-cs.js's `declaredType` capture on
1819
+ // assign nodes, landed earlier this session for an unrelated task,
1820
+ // needed no changes to support this).
1821
+ { kind: 'sink', id: 'cs-xpathnavigator-select', language: 'cs', framework: 'stdlib', match: { type: 'call', callee: 'Select', receiver: '[Nn]av(?:igator)?', receiverTypeIn: ['^XPathNavigator$'] }, argIndex: 0,
1822
+ vuln: { name: 'XPath Injection (XPathNavigator.Select with concatenated expression)', severity: 'high', cwe: 'CWE-643',
1823
+ remediation: 'Use XPath variables (`XsltArgumentList`/`XPathExpression.SetContext`) instead of concatenating user input into the expression text.' } },
1824
+ { kind: 'sink', id: 'cs-xpathnavigator-evaluate', language: 'cs', framework: 'stdlib', match: { type: 'call', callee: 'Evaluate', receiver: '[Nn]av(?:igator)?', receiverTypeIn: ['^XPathNavigator$'] }, argIndex: 0,
1825
+ vuln: { name: 'XPath Injection (XPathNavigator.Evaluate with concatenated expression)', severity: 'high', cwe: 'CWE-643',
1826
+ remediation: 'Use XPath variables instead of concatenating user input into the expression text.' } },
1827
+ { kind: 'sink', id: 'cs-selectsinglenode', language: 'cs', framework: 'stdlib', match: { type: 'call', callee: 'SelectSingleNode' }, argIndex: 0,
1828
+ vuln: { name: 'XPath Injection (XmlNode.SelectSingleNode with concatenated expression)', severity: 'high', cwe: 'CWE-643',
1829
+ remediation: 'Use XPath variables instead of concatenating user input into the expression text.' } },
1336
1830
  // ─── SANITIZERS (C#) ─────────────────────────────────────────────────────
1337
1831
  { kind: 'sanitizer', id: 'cs-html-encode', language: 'cs', match: { type: 'call', callee: 'HtmlEncode' }, effect: 'strip', appliesTo: ['xss'] },
1338
1832
  { kind: 'sanitizer', id: 'cs-url-encode', language: 'cs', match: { type: 'call', callee: 'UrlEncode' }, effect: 'strip', appliesTo: ['url'] },
1339
1833
  { kind: 'sanitizer', id: 'cs-path-getfullpath',language: 'cs', match: { type: 'call', callee: 'GetFullPath' }, effect: 'taintIf-not-pinned', appliesTo: ['path'] },
1340
- { kind: 'sanitizer', id: 'cs-int-parse', language: 'cs', match: { type: 'call', callee: 'Parse' }, effect: 'strip', appliesTo: ['*'] },
1341
- { kind: 'sanitizer', id: 'cs-int-tryparse', language: 'cs', match: { type: 'call', callee: 'TryParse' }, effect: 'strip', appliesTo: ['*'] },
1834
+ // Receiver-scoped to numeric types (SARD_80_F1_SCANNER_PRD.md): `Parse` and
1835
+ // `TryParse` are extremely common bare method names across .NET — `XDocument
1836
+ // .Parse`, `JObject.Parse`, `DateTime.Parse`, `Uri`-adjacent parsers, and any
1837
+ // custom type's own `Parse` all share the name, and NONE of them coerce to a
1838
+ // non-string-injectable value the way a numeric parse does (a parsed XML/JSON
1839
+ // document's text content round-trips the original characters right back out
1840
+ // through `.ToString()`). Unscoped, this entry was already a precision bug
1841
+ // under the old demote-only semantics; it becomes an active false-negative
1842
+ // risk now that a universal tag can KILL taint outright (engine.js's
1843
+ // _isCoercionCall) — `var doc = XDocument.Parse(tainted); sink(doc.Value);`
1844
+ // would otherwise be reported clean.
1845
+ { kind: 'sanitizer', id: 'cs-int-parse', language: 'cs',
1846
+ match: { type: 'call', callee: 'Parse', receiver: '^[Uu]?(?:[Ii]nt(?:32|64|16)?|[Ll]ong|[Dd]ouble|[Ff]loat|[Dd]ecimal|[Ss]hort|[Bb]yte|SByte)$' },
1847
+ effect: 'strip', appliesTo: ['*'] },
1848
+ { kind: 'sanitizer', id: 'cs-int-tryparse', language: 'cs',
1849
+ match: { type: 'call', callee: 'TryParse', receiver: '^[Uu]?(?:[Ii]nt(?:32|64|16)?|[Ll]ong|[Dd]ouble|[Ff]loat|[Dd]ecimal|[Ss]hort|[Bb]yte|SByte)$' },
1850
+ effect: 'strip', appliesTo: ['*'] },
1342
1851
  { kind: 'sanitizer', id: 'cs-regex-escape', language: 'cs', match: { type: 'call', callee: 'Escape' }, effect: 'strip', appliesTo: ['regex'] },
1343
1852
  { kind: 'sanitizer', id: 'cs-addwithvalue', language: 'cs', match: { type: 'call', callee: 'AddWithValue' }, effect: 'strip', appliesTo: ['sql'] },
1344
1853
 
@@ -1353,7 +1862,25 @@ export const CATALOG = [
1353
1862
  { kind: 'sink', id: 'kt-jdbc-execute', language: 'kt', framework: 'jdbc', match: { type: 'call', callee: 'executeQuery' }, argIndex: 0,
1354
1863
  vuln: { name: 'SQL Injection (JDBC executeQuery from Kotlin)', severity: 'critical', cwe: 'CWE-89',
1355
1864
  remediation: 'Use PreparedStatement + setX(N, v) — Kotlin string templates concatenated into SQL are still injection.' } },
1356
- { kind: 'sink', id: 'kt-exposed-exec', language: 'kt', framework: 'exposed', match: { type: 'call', callee: 'exec' }, argIndex: 0,
1865
+ // SARD_80_F1 W4.J26: `kt-exposed-exec` and `kt-runtime-exec` (below) both
1866
+ // key on the SAME bare callee name `exec` with argIndex 0 — the only real
1867
+ // JVM-runtime overload of that name being ambiguous between Exposed's SQL
1868
+ // `.exec(sql)` and `Runtime.exec(cmd)`. Since `java`/`kt` share one
1869
+ // catalog-scoping family (see `_LANG_FAMILY` above), this ALSO matched
1870
+ // Java's own `Runtime.getRuntime().exec(cmd)` idiom — confirmed on real
1871
+ // corpus data (`sard-juliet-java-strict`'s CWE-78 command-injection test
1872
+ // files): 251 dev-split false positives, ALL misreported as
1873
+ // "SQL Injection (Exposed.exec)" on a plain OS-command-execution call,
1874
+ // with the correct `kt-runtime-exec`/CWE-78 finding never surviving
1875
+ // `dedupeFindingsWithEvidence`'s same-severity tie-break (first-inserted
1876
+ // wins). `receiverExclude` (see below) is the ONLY negative-match form
1877
+ // this catalog supports (`py-compile`'s own precedent) — excludes any
1878
+ // call whose receiver chain contains `Runtime`, `getRuntime`, or
1879
+ // `ProcessBuilder` (Java's `Runtime`/`ProcessBuilder` classes are always
1880
+ // capitalized; a bare, receiver-less `exec(sql)` — Exposed's more common
1881
+ // shape — is untouched, since `receiverExclude` can never match a call
1882
+ // with no receiver segments at all).
1883
+ { kind: 'sink', id: 'kt-exposed-exec', language: 'kt', framework: 'exposed', match: { type: 'call', callee: 'exec', receiverExclude: 'Runtime|ProcessBuilder' }, argIndex: 0,
1357
1884
  vuln: { name: 'SQL Injection (Exposed.exec with raw string)', severity: 'critical', cwe: 'CWE-89',
1358
1885
  remediation: 'Use Exposed DSL queries or named-parameter exec with a typed parameter list.' } },
1359
1886
  { kind: 'sink', id: 'kt-runtime-exec', language: 'kt', framework: 'stdlib', match: { type: 'call', callee: 'exec' }, argIndex: 0,
@@ -1393,6 +1920,16 @@ export const CATALOG = [
1393
1920
  { kind: 'source', id: 'cpp-read', language: 'cpp', framework: null, match: { type: 'call', callee: 'read' }, label: 'read()', provenance: 'file-read' },
1394
1921
  { kind: 'source', id: 'cpp-fread', language: 'cpp', framework: null, match: { type: 'call', callee: 'fread' }, label: 'fread()', provenance: 'file-read' },
1395
1922
  { kind: 'source', id: 'cpp-fgets', language: 'cpp', framework: null, match: { type: 'call', callee: 'fgets' }, label: 'fgets()', provenance: 'file-read' },
1923
+ // SARD PHP corpus: fgets()/fread() are receiver-agnostic in this
1924
+ // language too (`fgets($fh)` reads from stdin/a socket/a file alike).
1925
+ // Declared AFTER the cpp entries above on purpose: `matchSource`'s
1926
+ // no-file-context callers (test/cpp-integration.test.js's own
1927
+ // "non-colliding" assertion) tiebreak same-named cross-language entries by
1928
+ // declaration order when `_languageAllowed` has no file extension to
1929
+ // filter on; a real scan always has a file path, so `_languageAllowed`
1930
+ // picks the right language regardless of this order.
1931
+ { kind: 'source', id: 'php-fgets', language: 'php', framework: 'core', match: { type: 'call', callee: 'fgets' }, label: 'fgets()', provenance: 'file-read' },
1932
+ { kind: 'source', id: 'php-fread', language: 'php', framework: 'core', match: { type: 'call', callee: 'fread' }, label: 'fread()', provenance: 'file-read' },
1396
1933
  { kind: 'source', id: 'cpp-gets', language: 'cpp', framework: null, match: { type: 'call', callee: 'gets' }, label: 'gets()', provenance: 'stdin' },
1397
1934
  { kind: 'source', id: 'cpp-scanf', language: 'cpp', framework: null, match: { type: 'call', callee: 'scanf' }, label: 'scanf()', provenance: 'stdin' },
1398
1935
 
@@ -1426,6 +1963,210 @@ export const CATALOG = [
1426
1963
  // inert, but a future sanitizer consumer would silently turn this into a
1427
1964
  // false negative. realpath() stays: it genuinely canonicalises a path,
1428
1965
  // which is the property CWE-22 sinks care about.
1966
+
1967
+ // ─── SOURCES (Rust) ───────────────────────────────────────────────────────
1968
+ // Rust's web frameworks (axum, actix-web, rocket, warp) deliver request data
1969
+ // through EXTRACTOR-TYPED parameters (`Query(q): Query<P>`, `q: web::Query<P>`),
1970
+ // not through calls on a request object, so the dominant source shape is the
1971
+ // annotation channel: `parser-rust.js` emits a `paramAnnotations` entry whose
1972
+ // `decorator` is the extractor's type name (or `RouteParam` for every plain
1973
+ // parameter of a `#[get("/…/<x>")]`-attributed rocket handler). A closure
1974
+ // handler's extractor param is lowered to a synthetic `__rs_extract__.<Type>`
1975
+ // call instead, matched by the receiver-scoped call sources below.
1976
+ { kind: 'source', id: 'rs-extract-query', language: 'rs', framework: 'web', match: { type: 'annotation', name: 'Query' }, label: 'Query<T> extractor (axum/actix)', provenance: 'url-param' },
1977
+ { kind: 'source', id: 'rs-extract-path', language: 'rs', framework: 'web', match: { type: 'annotation', name: 'Path' }, label: 'Path<T> extractor (axum/actix)', provenance: 'path-param' },
1978
+ { kind: 'source', id: 'rs-extract-json', language: 'rs', framework: 'web', match: { type: 'annotation', name: 'Json' }, label: 'Json<T> extractor (axum/actix/rocket)', provenance: 'http-body' },
1979
+ { kind: 'source', id: 'rs-extract-form', language: 'rs', framework: 'web', match: { type: 'annotation', name: 'Form' }, label: 'Form<T> extractor (axum/actix/rocket)', provenance: 'http-body' },
1980
+ { kind: 'source', id: 'rs-extract-headermap', language: 'rs', framework: 'web', match: { type: 'annotation', name: 'HeaderMap' }, label: 'HeaderMap extractor (axum)', provenance: 'header' },
1981
+ { kind: 'source', id: 'rs-extract-typedheader',language: 'rs', framework: 'web', match: { type: 'annotation', name: 'TypedHeader' }, label: 'TypedHeader<T> extractor (axum)', provenance: 'header' },
1982
+ { kind: 'source', id: 'rs-extract-rawquery', language: 'rs', framework: 'web', match: { type: 'annotation', name: 'RawQuery' }, label: 'RawQuery extractor (axum)', provenance: 'url-param' },
1983
+ { kind: 'source', id: 'rs-extract-rawform', language: 'rs', framework: 'web', match: { type: 'annotation', name: 'RawForm' }, label: 'RawForm extractor (axum)', provenance: 'http-body' },
1984
+ { kind: 'source', id: 'rs-extract-multipart', language: 'rs', framework: 'web', match: { type: 'annotation', name: 'Multipart' }, label: 'Multipart extractor (axum/actix)', provenance: 'http-body' },
1985
+ { kind: 'source', id: 'rs-extract-multipartform', language: 'rs', framework: 'web', match: { type: 'annotation', name: 'MultipartForm' }, label: 'MultipartForm extractor (actix)', provenance: 'http-body' },
1986
+ { kind: 'source', id: 'rs-extract-httprequest',language: 'rs', framework: 'web', match: { type: 'annotation', name: 'HttpRequest' }, label: 'HttpRequest (actix)' },
1987
+ { kind: 'source', id: 'rs-extract-request', language: 'rs', framework: 'web', match: { type: 'annotation', name: 'Request' }, label: 'Request<Body> (axum/hyper)' },
1988
+ { kind: 'source', id: 'rs-extract-cookiejar', language: 'rs', framework: 'web', match: { type: 'annotation', name: 'CookieJar' }, label: 'CookieJar extractor', provenance: 'cookie' },
1989
+ { kind: 'source', id: 'rs-extract-cookies', language: 'rs', framework: 'web', match: { type: 'annotation', name: 'Cookies' }, label: 'Cookies extractor', provenance: 'cookie' },
1990
+ { kind: 'source', id: 'rs-extract-host', language: 'rs', framework: 'web', match: { type: 'annotation', name: 'Host' }, label: 'Host extractor (axum)', provenance: 'header' },
1991
+ { kind: 'source', id: 'rs-extract-originaluri',language: 'rs', framework: 'web', match: { type: 'annotation', name: 'OriginalUri' }, label: 'OriginalUri extractor (axum)', provenance: 'url-param' },
1992
+ { kind: 'source', id: 'rs-extract-rawpathparams', language: 'rs', framework: 'web', match: { type: 'annotation', name: 'RawPathParams' }, label: 'RawPathParams extractor (axum)', provenance: 'path-param' },
1993
+ { kind: 'source', id: 'rs-extract-bytes', language: 'rs', framework: 'web', match: { type: 'annotation', name: 'Bytes' }, label: 'Bytes body extractor (axum)', provenance: 'http-body' },
1994
+ { kind: 'source', id: 'rs-route-param', language: 'rs', framework: 'rocket', match: { type: 'annotation', name: 'RouteParam' }, label: 'route parameter of a #[get("/<x>")] handler (rocket)', provenance: 'path-param' },
1995
+ // Closure-handler extractors (`web::get().to(|q: web::Query<P>| async move { … })`).
1996
+ { kind: 'source', id: 'rs-closure-extract-query', language: 'rs', framework: 'web', match: { type: 'call', callee: 'Query', receiver: '^__rs_extract__$' }, label: 'Query<T> closure extractor', provenance: 'url-param' },
1997
+ { kind: 'source', id: 'rs-closure-extract-path', language: 'rs', framework: 'web', match: { type: 'call', callee: 'Path', receiver: '^__rs_extract__$' }, label: 'Path<T> closure extractor', provenance: 'path-param' },
1998
+ { kind: 'source', id: 'rs-closure-extract-json', language: 'rs', framework: 'web', match: { type: 'call', callee: 'Json', receiver: '^__rs_extract__$' }, label: 'Json<T> closure extractor', provenance: 'http-body' },
1999
+ { kind: 'source', id: 'rs-closure-extract-form', language: 'rs', framework: 'web', match: { type: 'call', callee: 'Form', receiver: '^__rs_extract__$' }, label: 'Form<T> closure extractor', provenance: 'http-body' },
2000
+ { kind: 'source', id: 'rs-closure-extract-headers', language: 'rs', framework: 'web', match: { type: 'call', callee: 'HeaderMap', receiver: '^__rs_extract__$' }, label: 'HeaderMap closure extractor', provenance: 'header' },
2001
+ { kind: 'source', id: 'rs-closure-extract-request', language: 'rs', framework: 'web', match: { type: 'call', callee: 'HttpRequest', receiver: '^__rs_extract__$' }, label: 'HttpRequest closure extractor' },
2002
+ // Request-object accessors (actix `HttpRequest`, hyper/axum `Request`).
2003
+ { kind: 'source', id: 'rs-req-query-string', language: 'rs', framework: 'actix', match: { type: 'call', callee: 'query_string' }, label: 'req.query_string() (actix)', provenance: 'url-param' },
2004
+ { kind: 'source', id: 'rs-req-match-info', language: 'rs', framework: 'actix', match: { type: 'call', callee: 'match_info' }, label: 'req.match_info() (actix)', provenance: 'path-param' },
2005
+ { kind: 'source', id: 'rs-req-cookie', language: 'rs', framework: 'actix', match: { type: 'call', callee: 'cookie', receiver: '^(?:req|request|jar|cookies)$' }, label: 'req.cookie() (actix)', provenance: 'cookie' },
2006
+ { kind: 'source', id: 'rs-req-headers', language: 'rs', framework: 'web', match: { type: 'call', callee: 'headers', receiver: '^(?:req|request|parts)$' }, label: 'req.headers()', provenance: 'header' },
2007
+ { kind: 'source', id: 'rs-req-uri-query', language: 'rs', framework: 'hyper', match: { type: 'call', callee: 'query', receiver: '^uri$' }, label: 'req.uri().query() (hyper/axum)', provenance: 'url-param' },
2008
+ // warp composes filters; the extracted value arrives as a closure parameter
2009
+ // bound (by parser-rust.js) to the receiver chain, so `warp.query`-style
2010
+ // MEMBER sources cover `warp::query().map(|q| …)` and the CALL forms cover
2011
+ // `let q = warp::query::<P>();`.
2012
+ { kind: 'source', id: 'rs-warp-query-member', language: 'rs', framework: 'warp', match: { type: 'member', object: 'warp', prop: 'query' }, label: 'warp::query() (warp)', provenance: 'url-param' },
2013
+ { kind: 'source', id: 'rs-warp-body-member', language: 'rs', framework: 'warp', match: { type: 'member', object: 'warp', prop: 'body' }, label: 'warp::body::* (warp)', provenance: 'http-body' },
2014
+ { kind: 'source', id: 'rs-warp-header-member', language: 'rs', framework: 'warp', match: { type: 'member', object: 'warp', prop: 'header' }, label: 'warp::header() (warp)', provenance: 'header' },
2015
+ { kind: 'source', id: 'rs-warp-path-member', language: 'rs', framework: 'warp', match: { type: 'member', object: 'warp', prop: 'path' }, label: 'warp::path::param() (warp)', provenance: 'path-param' },
2016
+ { kind: 'source', id: 'rs-warp-query', language: 'rs', framework: 'warp', match: { type: 'call', callee: 'query', receiver: '^warp$' }, label: 'warp::query() (warp)', provenance: 'url-param' },
2017
+ { kind: 'source', id: 'rs-warp-param', language: 'rs', framework: 'warp', match: { type: 'call', callee: 'param', receiver: '^(?:warp|path)$' }, label: 'warp::path::param() (warp)', provenance: 'path-param' },
2018
+ { kind: 'source', id: 'rs-warp-header', language: 'rs', framework: 'warp', match: { type: 'call', callee: 'header', receiver: '^warp$' }, label: 'warp::header() (warp)', provenance: 'header' },
2019
+ // Process environment and CLI arguments: untrusted in the same sense the
2020
+ // other languages' `getenv`/`argv` entries are.
2021
+ { kind: 'source', id: 'rs-env-var', language: 'rs', framework: 'std', match: { type: 'call', callee: 'var', receiver: '^env$' }, label: 'std::env::var()', provenance: 'env' },
2022
+ { kind: 'source', id: 'rs-env-var-os', language: 'rs', framework: 'std', match: { type: 'call', callee: 'var_os', receiver: '^env$' }, label: 'std::env::var_os()', provenance: 'env' },
2023
+ { kind: 'source', id: 'rs-env-args', language: 'rs', framework: 'std', match: { type: 'call', callee: 'args', receiver: '^env$' }, label: 'std::env::args()', provenance: 'cli' },
2024
+ { kind: 'source', id: 'rs-env-args-os',language: 'rs', framework: 'std', match: { type: 'call', callee: 'args_os', receiver: '^env$' }, label: 'std::env::args_os()', provenance: 'cli' },
2025
+
2026
+ // ─── SINKS (Rust) ─────────────────────────────────────────────────────────
2027
+ // parser-rust.js accumulates chain arguments INNERMOST-first (source order),
2028
+ // so on a builder chain like `sqlx::query(&sql).bind(x).fetch_all(pool)` the
2029
+ // SQL text is arg 0 whichever terminal method the chain ends on, and on
2030
+ // `Command::new("sh").arg("-c").arg(x).output()` the literal program name is
2031
+ // arg 0. Terminal-method entries below are receiver-scoped to the chain root.
2032
+ // `sqlx::query!(…)` keeps its `!` in the callee (`sqlx.query!`) and is never
2033
+ // matched: it is the compile-time-checked, parameterized form.
2034
+ { kind: 'sink', id: 'rs-sql-query', language: 'rs', framework: 'sqlx/postgres/mysql/rusqlite', match: { type: 'call', callee: 'query', receiverExclude: '^(?:warp|uri|url|Url|Uri)$' }, argIndex: 0,
2035
+ vuln: { name: 'SQL Injection (query with runtime-built SQL)', severity: 'critical', cwe: 'CWE-89',
2036
+ remediation: 'Keep the SQL text a literal and bind the value: `sqlx::query("SELECT * FROM t WHERE id = $1").bind(id)`, `client.query("… WHERE id = $1", &[&id])`, or `conn.query_row("… WHERE id = ?1", params![id], …)`. Never build SQL with format!/concat from request data.' } },
2037
+ { kind: 'sink', id: 'rs-sql-query-as', language: 'rs', framework: 'sqlx', match: { type: 'call', callee: 'query_as' }, argIndex: 0,
2038
+ vuln: { name: 'SQL Injection (sqlx::query_as with runtime-built SQL)', severity: 'critical', cwe: 'CWE-89',
2039
+ remediation: 'Use `sqlx::query_as::<_, T>("… WHERE id = $1").bind(id)` or the compile-checked `sqlx::query_as!(T, "… $1", id)`.' } },
2040
+ { kind: 'sink', id: 'rs-sql-query-scalar', language: 'rs', framework: 'sqlx', match: { type: 'call', callee: 'query_scalar' }, argIndex: 0,
2041
+ vuln: { name: 'SQL Injection (sqlx::query_scalar with runtime-built SQL)', severity: 'critical', cwe: 'CWE-89',
2042
+ remediation: 'Use `sqlx::query_scalar("… WHERE id = $1").bind(id)` or `sqlx::query_scalar!`.' } },
2043
+ { kind: 'sink', id: 'rs-sql-raw-sql', language: 'rs', framework: 'sqlx', match: { type: 'call', callee: 'raw_sql' }, argIndex: 0,
2044
+ vuln: { name: 'SQL Injection (sqlx::raw_sql)', severity: 'critical', cwe: 'CWE-89',
2045
+ remediation: '`raw_sql` cannot bind parameters; switch to `sqlx::query("…$1").bind(value)` for anything derived from input.' } },
2046
+ { kind: 'sink', id: 'rs-sql-diesel-sql-query', language: 'rs', framework: 'diesel', match: { type: 'call', callee: 'sql_query' }, argIndex: 0,
2047
+ vuln: { name: 'SQL Injection (diesel::sql_query with runtime-built SQL)', severity: 'critical', cwe: 'CWE-89',
2048
+ remediation: 'Bind with `diesel::sql_query("… WHERE id = $1").bind::<Integer, _>(id)`, or use the typed query DSL instead of raw SQL.' } },
2049
+ { kind: 'sink', id: 'rs-sql-diesel-sql', language: 'rs', framework: 'diesel', match: { type: 'call', callee: 'sql', receiver: '^(?:diesel|dsl)$' }, argIndex: 0,
2050
+ vuln: { name: 'SQL Injection (diesel::dsl::sql fragment from input)', severity: 'critical', cwe: 'CWE-89',
2051
+ remediation: 'A `sql()` fragment is spliced verbatim; keep it a literal and pass values via `.bind()` or the typed DSL.' } },
2052
+ { kind: 'sink', id: 'rs-sql-execute', language: 'rs', framework: 'rusqlite/postgres/mysql', match: { type: 'call', callee: 'execute', receiverExclude: '^(?:Command|cmd|command|process)$' }, argIndex: 0,
2053
+ vuln: { name: 'SQL Injection (execute with runtime-built SQL)', severity: 'critical', cwe: 'CWE-89',
2054
+ remediation: 'Keep the statement a literal and bind: `conn.execute("INSERT INTO t (a) VALUES (?1)", params![a])` / `client.execute("… $1", &[&a])`.' } },
2055
+ { kind: 'sink', id: 'rs-sql-execute-batch',language: 'rs', framework: 'rusqlite', match: { type: 'call', callee: 'execute_batch' }, argIndex: 0,
2056
+ vuln: { name: 'SQL Injection (execute_batch with runtime-built SQL)', severity: 'critical', cwe: 'CWE-89',
2057
+ remediation: '`execute_batch` runs unparameterized SQL; split into single `execute` calls with `params![…]` for any value from input.' } },
2058
+ { kind: 'sink', id: 'rs-sql-batch-execute',language: 'rs', framework: 'postgres', match: { type: 'call', callee: 'batch_execute' }, argIndex: 0,
2059
+ vuln: { name: 'SQL Injection (batch_execute with runtime-built SQL)', severity: 'critical', cwe: 'CWE-89',
2060
+ remediation: '`batch_execute` takes no parameters; use `client.execute("… $1", &[&value])` for input-derived values.' } },
2061
+ { kind: 'sink', id: 'rs-sql-prepare', language: 'rs', framework: 'rusqlite/postgres', match: { type: 'call', callee: 'prepare' }, argIndex: 0,
2062
+ vuln: { name: 'SQL Injection (prepare with runtime-built SQL)', severity: 'critical', cwe: 'CWE-89',
2063
+ remediation: 'Preparing a statement does not parameterize text that was already interpolated. Use `?1`/`$1` placeholders in a literal and pass values at execution time.' } },
2064
+ { kind: 'sink', id: 'rs-sql-prepare-cached',language: 'rs', framework: 'rusqlite', match: { type: 'call', callee: 'prepare_cached' }, argIndex: 0,
2065
+ vuln: { name: 'SQL Injection (prepare_cached with runtime-built SQL)', severity: 'critical', cwe: 'CWE-89',
2066
+ remediation: 'Use `?1` placeholders in a literal statement and pass values via `params![…]`.' } },
2067
+ { kind: 'sink', id: 'rs-sql-query-row', language: 'rs', framework: 'rusqlite', match: { type: 'call', callee: 'query_row' }, argIndex: 0,
2068
+ vuln: { name: 'SQL Injection (query_row with runtime-built SQL)', severity: 'critical', cwe: 'CWE-89',
2069
+ remediation: 'Use `conn.query_row("SELECT … WHERE id = ?1", params![id], |r| …)`.' } },
2070
+ { kind: 'sink', id: 'rs-sql-query-map', language: 'rs', framework: 'rusqlite', match: { type: 'call', callee: 'query_map', receiver: '^(?:conn|db|connection|stmt)$' }, argIndex: 0,
2071
+ vuln: { name: 'SQL Injection (query_map with runtime-built SQL)', severity: 'critical', cwe: 'CWE-89',
2072
+ remediation: 'Prepare a literal statement with `?1` placeholders and pass values via `params![…]`.' } },
2073
+ ...['fetch_all', 'fetch_one', 'fetch_optional', 'fetch', 'fetch_many', 'bind', 'persistent'].map(method => (
2074
+ { kind: 'sink', id: `rs-sqlx-chain-${method}`, language: 'rs', framework: 'sqlx', match: { type: 'call', callee: method, receiver: '^(?:query|query_as|query_scalar|raw_sql|sql_query)$' }, argIndex: 0,
2075
+ vuln: { name: `SQL Injection (sqlx query chain .${method} with runtime-built SQL)`, severity: 'critical', cwe: 'CWE-89',
2076
+ remediation: 'Keep the SQL literal and bind values: `sqlx::query("SELECT * FROM t WHERE id = $1").bind(id).fetch_all(pool)`; only the placeholder form is parameterized.' } }
2077
+ )),
2078
+ // Command injection. `Command::new(<tainted>)` is always dangerous (the
2079
+ // program itself comes from input). Everything else is only shell-interpreted
2080
+ // when the program is a literal shell (`sh -c`), gated by requireLiteralArg on
2081
+ // arg 0 (the innermost `Command::new` argument); `Command::new("ls").arg(x)`
2082
+ // never invokes a shell and does not fire.
2083
+ { kind: 'sink', id: 'rs-command-new-program', language: 'rs', framework: 'std::process', match: { type: 'call', callee: 'new', receiver: '^Command$' }, argIndex: 0,
2084
+ vuln: { name: 'Command Injection (Command::new with input-derived program)', severity: 'high', cwe: 'CWE-78',
2085
+ remediation: 'Resolve the program from a fixed allow-list (`match name { "ls" => "/bin/ls", … }`) instead of passing request data to `Command::new`.' } },
2086
+ ...['arg', 'args', 'output', 'status', 'spawn', 'env', 'current_dir', 'stdin', 'stdout', 'stderr'].map(method => (
2087
+ { kind: 'sink', id: `rs-command-shell-${method}`, language: 'rs', framework: 'std::process',
2088
+ match: { type: 'call', callee: method, receiver: '^Command$', requireLiteralArg: { index: 0, pattern: '^"(?:/bin/|/usr/bin/)?(?:sh|bash|zsh|dash|ksh)"$|^"cmd(?:\\.exe)?"$|^"powershell(?:\\.exe)?"$|^"pwsh"$' } }, argIndex: 'all',
2089
+ vuln: { name: 'Command Injection (Command::new("sh").arg("-c") with input-derived arguments)', severity: 'critical', cwe: 'CWE-78',
2090
+ remediation: 'Drop the shell: `Command::new("ping").arg("-c").arg("1").arg(&host)` passes argv directly and never interprets metacharacters. If a shell is unavoidable, allow-list the value or quote it with `shell_escape::escape`.' } }
2091
+ )),
2092
+ // Path traversal: std::fs / tokio::fs / async_std::fs free functions, and
2093
+ // File / OpenOptions constructors. `Path::new(x).join(y)` is not itself a
2094
+ // sink; the access is. A canonicalize + starts_with containment check in the
2095
+ // sink window is recognized by engine.js's dropGuardedFindings.
2096
+ ...['read', 'read_to_string', 'read_dir', 'write', 'remove_file', 'remove_dir', 'remove_dir_all', 'create_dir', 'create_dir_all', 'copy', 'rename', 'hard_link', 'symlink', 'metadata', 'read_link', 'set_permissions'].map(method => (
2097
+ { kind: 'sink', id: `rs-fs-${method}`, language: 'rs', framework: 'std::fs', match: { type: 'call', callee: method, receiver: '^fs$' }, argIndex: 'all',
2098
+ vuln: { name: `Path Traversal (fs::${method} with input-derived path)`, severity: 'high', cwe: 'CWE-22',
2099
+ remediation: 'Join onto a fixed base, canonicalize, and reject anything outside it: `let p = base.join(name).canonicalize()?; if !p.starts_with(&base) { return Err(…) }`. Reject `..` and absolute paths before use.' } }
2100
+ )),
2101
+ ...['open', 'create', 'create_new', 'options'].map(method => (
2102
+ { kind: 'sink', id: `rs-file-${method}`, language: 'rs', framework: 'std::fs', match: { type: 'call', callee: method, receiver: '^(?:File|OpenOptions)$' }, argIndex: 0,
2103
+ vuln: { name: `Path Traversal (File::${method} with input-derived path)`, severity: 'high', cwe: 'CWE-22',
2104
+ remediation: 'Canonicalize the joined path and verify `starts_with(base)` before opening; or map the input to a fixed set of known file names.' } }
2105
+ )),
2106
+ // Reflected XSS: an HTML body built from input. `HttpResponse::Ok().body(x)`
2107
+ // (actix), `Html(x)` (axum), `RawHtml(x)` (rocket), `warp::reply::html(x)`.
2108
+ { kind: 'sink', id: 'rs-actix-body', language: 'rs', framework: 'actix', match: { type: 'call', callee: 'body', receiver: '^(?:HttpResponse|HttpResponseBuilder|Response)$' }, argIndex: 'all',
2109
+ vuln: { name: 'Cross-Site Scripting (response body built from input)', severity: 'high', cwe: 'CWE-79',
2110
+ remediation: 'Escape before interpolating: `html_escape::encode_text(&name)`, or render through an auto-escaping template engine (askama, tera) instead of `format!` into the body.' } },
2111
+ { kind: 'sink', id: 'rs-axum-html', language: 'rs', framework: 'axum', match: { type: 'call', callee: 'Html', receiverExclude: '^__rs_extract__$' }, argIndex: 0,
2112
+ vuln: { name: 'Cross-Site Scripting (axum Html response built from input)', severity: 'high', cwe: 'CWE-79',
2113
+ remediation: '`Html(format!(…))` sends the string verbatim. Escape with `html_escape::encode_text` or return an askama/maud template, which escapes by default.' } },
2114
+ { kind: 'sink', id: 'rs-rocket-rawhtml', language: 'rs', framework: 'rocket', match: { type: 'call', callee: 'RawHtml' }, argIndex: 0,
2115
+ vuln: { name: 'Cross-Site Scripting (rocket RawHtml response built from input)', severity: 'high', cwe: 'CWE-79',
2116
+ remediation: 'Return a `Template::render` (auto-escaped) or escape the value with `html_escape::encode_text` before wrapping it in `RawHtml`.' } },
2117
+ { kind: 'sink', id: 'rs-warp-reply-html', language: 'rs', framework: 'warp', match: { type: 'call', callee: 'html', receiver: '^reply$' }, argIndex: 0,
2118
+ vuln: { name: 'Cross-Site Scripting (warp::reply::html built from input)', severity: 'high', cwe: 'CWE-79',
2119
+ remediation: 'Escape with `html_escape::encode_text` or render an auto-escaping template before `warp::reply::html`.' } },
2120
+ // SSRF: outbound HTTP clients given an input-derived URL.
2121
+ ...['get', 'post', 'put', 'patch', 'delete', 'head', 'request'].map(method => (
2122
+ { kind: 'sink', id: `rs-http-client-${method}`, language: 'rs', framework: 'reqwest/ureq', match: { type: 'call', callee: method, receiver: '^(?:reqwest|ureq|isahc|surf|Client|client|http_client|http)$' }, argIndex: 'all',
2123
+ vuln: { name: `Server-Side Request Forgery (HTTP client ${method} with input-derived URL)`, severity: 'high', cwe: 'CWE-918',
2124
+ remediation: 'Parse with `url::Url::parse`, then allow-list the scheme and host (and reject loopback/RFC1918/169.254.169.254) before the request is sent.' } }
2125
+ )),
2126
+ // Open redirect.
2127
+ ...['to', 'temporary', 'permanent', 'see_other', 'found', 'moved'].map(method => (
2128
+ { kind: 'sink', id: `rs-redirect-${method}`, language: 'rs', framework: 'web', match: { type: 'call', callee: method, receiver: '^Redirect$' }, argIndex: 0,
2129
+ vuln: { name: `Open Redirect (Redirect::${method} with input-derived target)`, severity: 'medium', cwe: 'CWE-601',
2130
+ remediation: 'Only redirect to relative paths you validate (`target.starts_with(\'/\') && !target.starts_with("//")`) or to hosts on an allow-list.' } }
2131
+ )),
2132
+ { kind: 'sink', id: 'rs-actix-insert-header', language: 'rs', framework: 'actix', match: { type: 'call', callee: 'insert_header', receiver: '^HttpResponse$' }, argIndex: 'all',
2133
+ vuln: { name: 'Header Injection / Open Redirect (response header set from input)', severity: 'medium', cwe: 'CWE-113',
2134
+ remediation: 'Validate the value against an allow-list and strip CR/LF; for Location headers only allow relative paths or allow-listed hosts.' } },
2135
+ // Log injection (low): request data written into structured logs unchanged.
2136
+ ...['info', 'warn', 'error', 'debug', 'trace'].map(macro => (
2137
+ { kind: 'sink', id: `rs-log-${macro}`, language: 'rs', framework: 'log/tracing', match: { type: 'call', callee: `${macro}!` }, argIndex: 0,
2138
+ vuln: { name: `Log Injection (${macro}! with input-derived content)`, severity: 'low', cwe: 'CWE-117',
2139
+ remediation: 'Log input as a structured field (`tracing::info!(user = %name, "login")`) or strip CR/LF so a request cannot forge log lines.' } }
2140
+ )),
2141
+ // Deserialization of input with format-level object construction.
2142
+ ...['from_slice', 'from_reader', 'from_str'].map(method => (
2143
+ { kind: 'sink', id: `rs-serde-pickle-${method}`, language: 'rs', framework: 'serde_pickle', match: { type: 'call', callee: method, receiver: '^serde_pickle$' }, argIndex: 0,
2144
+ vuln: { name: `Deserialization of Untrusted Data (serde_pickle::${method})`, severity: 'medium', cwe: 'CWE-502',
2145
+ remediation: 'Pickle can encode arbitrary object construction; deserialize input with serde_json/serde into a concrete typed struct instead.' } }
2146
+ )),
2147
+ { kind: 'sink', id: 'rs-bincode-deserialize', language: 'rs', framework: 'bincode', match: { type: 'call', callee: 'deserialize', receiver: '^bincode$' }, argIndex: 0,
2148
+ vuln: { name: 'Deserialization of Untrusted Data (bincode::deserialize without size limits)', severity: 'medium', cwe: 'CWE-502',
2149
+ remediation: 'Use `bincode::DefaultOptions::new().with_limit(MAX)` so a length prefix from input cannot drive a huge allocation, and deserialize into a concrete type.' } },
2150
+ // Unbounded allocation driven by input.
2151
+ { kind: 'sink', id: 'rs-with-capacity', language: 'rs', framework: 'std', match: { type: 'call', callee: 'with_capacity', receiver: '^(?:Vec|String|HashMap|HashSet|VecDeque|BTreeMap)$' }, argIndex: 0,
2152
+ vuln: { name: 'Uncontrolled Resource Consumption (with_capacity sized by input)', severity: 'medium', cwe: 'CWE-770',
2153
+ remediation: 'Clamp the requested size: `Vec::with_capacity(n.min(MAX_ITEMS))`, or grow lazily with `Vec::new()` and push.' } },
2154
+
2155
+ // ─── SANITIZERS (Rust) ────────────────────────────────────────────────────
2156
+ // `x.parse::<i64>()` is a numeric parse; `Url::parse` is not a sanitizer of
2157
+ // anything, so it is excluded by receiver.
2158
+ { kind: 'sanitizer', id: 'rs-parse-numeric', language: 'rs', match: { type: 'call', callee: 'parse', receiverExclude: '^(?:Url|Uri|url|reqwest|Path|PathBuf|serde_json|json|toml|serde_yaml)$' }, effect: 'strip', appliesTo: ['*'] },
2159
+ { kind: 'sanitizer', id: 'rs-html-escape-encode-text', language: 'rs', match: { type: 'call', callee: 'encode_text' }, effect: 'strip', appliesTo: ['xss'] },
2160
+ { kind: 'sanitizer', id: 'rs-html-escape-encode-safe', language: 'rs', match: { type: 'call', callee: 'encode_safe' }, effect: 'strip', appliesTo: ['xss'] },
2161
+ { kind: 'sanitizer', id: 'rs-html-escape-attr', language: 'rs', match: { type: 'call', callee: 'encode_double_quoted_attribute' }, effect: 'strip', appliesTo: ['xss'] },
2162
+ { kind: 'sanitizer', id: 'rs-ammonia-clean', language: 'rs', match: { type: 'call', callee: 'clean', receiver: '^ammonia$' }, effect: 'strip', appliesTo: ['xss'] },
2163
+ { kind: 'sanitizer', id: 'rs-askama-escape', language: 'rs', match: { type: 'call', callee: 'escape', receiver: '^(?:askama|v_htmlescape|html_escape|tera)$' }, effect: 'strip', appliesTo: ['xss'] },
2164
+ { kind: 'sanitizer', id: 'rs-shell-escape', language: 'rs', match: { type: 'call', callee: 'escape', receiver: '^shell_escape$' }, effect: 'strip', appliesTo: ['cmd'] },
2165
+ { kind: 'sanitizer', id: 'rs-shlex-quote', language: 'rs', match: { type: 'call', callee: 'quote', receiver: '^shlex$' }, effect: 'strip', appliesTo: ['cmd'] },
2166
+ { kind: 'sanitizer', id: 'rs-shlex-try-quote', language: 'rs', match: { type: 'call', callee: 'try_quote', receiver: '^shlex$' }, effect: 'strip', appliesTo: ['cmd'] },
2167
+ { kind: 'sanitizer', id: 'rs-sanitize-filename', language: 'rs', match: { type: 'call', callee: 'sanitize', receiver: '^sanitize_filename$' }, effect: 'strip', appliesTo: ['path'] },
2168
+ { kind: 'sanitizer', id: 'rs-path-file-name', language: 'rs', match: { type: 'call', callee: 'file_name' }, effect: 'strip', appliesTo: ['path'] },
2169
+ { kind: 'sanitizer', id: 'rs-percent-encode', language: 'rs', match: { type: 'call', callee: 'utf8_percent_encode' }, effect: 'strip', appliesTo: ['url'] },
1429
2170
  ];
1430
2171
 
1431
2172
  // ─── Expanded sanitizer catalog (v0.65.0) ────────────────────────────────
@@ -1458,6 +2199,7 @@ const _LANG_EXT = {
1458
2199
  php: /\.(?:php|phtml)$/i,
1459
2200
  rb: /\.rb$/i,
1460
2201
  java: /\.java$/i,
2202
+ rs: /\.rs$/i,
1461
2203
  };
1462
2204
 
1463
2205
  // cpp delegates to cppExtRe() rather than duplicating a literal set, so it
@@ -1778,7 +2520,25 @@ function _calleeIndexHits(calleeExpr) {
1778
2520
  return raw;
1779
2521
  }
1780
2522
 
1781
- export function matchSource(expr, file) {
2523
+ // SARD_80_F1 W4.C39 — `receiverType` (optional 3rd param) is the SOURCE-side
2524
+ // counterpart of `matchSinkOrSanitizer`'s existing receiverType param, closing
2525
+ // a gap W4.C32 found and explicitly deferred: matchSource's CALL-shaped
2526
+ // branch used to filter ONLY via `_receiverAllowed` (a name regex), with no
2527
+ // way for a `receiverTypeIn`-bearing source entry (`cs-datareader-*`) to
2528
+ // confirm a match via CHA-resolved declared type the way a sink already
2529
+ // could. W4.C32 built and verified this exact mechanism once already, then
2530
+ // reverted it — at the time, `classOfVar`'s CHA only resolved `new Foo()`
2531
+ // allocations, and `SqlDataReader` has no public constructor (always
2532
+ // obtained via `command.ExecuteReader()`, a factory method), so the
2533
+ // mechanism was correct but had no way to ever actually resolve the type it
2534
+ // needed. That blocker is gone: `class-hierarchy.js`'s `typeOfVar` now also
2535
+ // captures a local's DECLARED type off `Type x = expr;` (parser-cs.js's
2536
+ // `declaredType`, added independently for a different consumer), which
2537
+ // covers a factory-returned value exactly as well as a `new` one — verified
2538
+ // directly that `classOfVar` now resolves `dr` to `SqlDataReader` for
2539
+ // `using (SqlDataReader dr = command.ExecuteReader())`. Re-attempting the
2540
+ // SAME mechanism W4.C32 already designed is what actually unlocks it now.
2541
+ export function matchSource(expr, file, receiverType) {
1782
2542
  if (!expr) return null;
1783
2543
  // Member sources (req.query): the original path — unchanged.
1784
2544
  if (expr.kind === 'member' && expr.object?.kind === 'ident') {
@@ -1816,10 +2576,39 @@ export function matchSource(expr, file) {
1816
2576
  if (raw.length) {
1817
2577
  const hits = filterByProvenance(raw)
1818
2578
  .filter(h => _languageAllowed(h, file))
1819
- .filter(h => _receiverAllowed(h, expr.callee));
2579
+ .filter(h => _receiverAllowed(h, expr.callee) || _receiverTypeConfirms(h, receiverType));
1820
2580
  const s = hits.find(h => h.kind === 'source');
1821
2581
  if (s) return s;
1822
2582
  }
2583
+ // SARD_80_F1 W4.C27 — a value-accessor method called ON an already-
2584
+ // registered MEMBER source (`req.Params.Get("name")`,
2585
+ // `req.QueryString.Get("id")` — .NET's `NameValueCollection` read API,
2586
+ // equivalent to its own indexer `req.Params["name"]`, which already
2587
+ // matched via the branch above) was invisible here: the callee is a
2588
+ // flat dotted STRING for C#'s hand-rolled parser ("req.Params.Get"),
2589
+ // and `_calleeIndexHits` only ever consults CALLEE_INDEX (call-type
2590
+ // catalog entries) — a MEMBER-type source like `cs-req-params` lives in
2591
+ // MEMBER_INDEX under the key "req.Params" and was never looked up here
2592
+ // at all. Confirmed against the public C# mirror
2593
+ // (CWE78_OS_Command_Injection__Params_Get_Web_01.cs): the indexer form
2594
+ // already fired via IR-TAINT; the `.Get(...)` form produced zero
2595
+ // findings. Strip the callee's last dotted segment and, when it's one
2596
+ // of the small set of known NameValueCollection read accessors, look
2597
+ // that prefix up in MEMBER_INDEX exactly as the plain-member branch
2598
+ // above does.
2599
+ if (typeof expr.callee === 'string' && expr.callee.includes('.')) {
2600
+ const lastDot = expr.callee.lastIndexOf('.');
2601
+ const method = expr.callee.slice(lastDot + 1);
2602
+ if (/^(?:Get|GetValues|GetKey)$/.test(method)) {
2603
+ const prefix = expr.callee.slice(0, lastDot);
2604
+ const raw2 = MEMBER_INDEX.get(prefix);
2605
+ if (raw2) {
2606
+ const hits = filterByProvenance(raw2).filter(h => _languageAllowed(h, file));
2607
+ const s = hits.find(h => h.kind === 'source');
2608
+ if (s) return s;
2609
+ }
2610
+ }
2611
+ }
1823
2612
  }
1824
2613
  return null;
1825
2614
  }