@clear-capabilities/agentic-security-scanner 0.137.1 → 0.139.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 (44) hide show
  1. package/CHANGELOG.md +204 -0
  2. package/dist/113.index.js +2 -2
  3. package/dist/178.index.js +1 -1
  4. package/dist/384.index.js +1 -1
  5. package/dist/435.index.js +29 -1
  6. package/dist/526.index.js +2 -2
  7. package/dist/637.index.js +1 -1
  8. package/dist/agentic-security.mjs +14 -14
  9. package/dist/agentic-security.mjs.sha256 +1 -1
  10. package/package.json +10 -6
  11. package/src/dataflow/CLAUDE.md +30 -0
  12. package/src/dataflow/catalog.js +512 -14
  13. package/src/dataflow/engine.js +275 -27
  14. package/src/dataflow/summaries.js +30 -5
  15. package/src/engine.js +512 -120
  16. package/src/ir/CLAUDE.md +20 -5
  17. package/src/ir/balanced-call.js +11 -1
  18. package/src/ir/callgraph.js +34 -0
  19. package/src/ir/parser-cs.js +55 -6
  20. package/src/ir/parser-go.js +106 -2
  21. package/src/ir/parser-java.js +111 -10
  22. package/src/ir/parser-js.js +40 -0
  23. package/src/ir/parser-kt.js +194 -10
  24. package/src/ir/parser-php.js +108 -6
  25. package/src/ir/parser-py.helper.py +199 -10
  26. package/src/ir/parser-rb.js +405 -31
  27. package/src/mcp/tools.js +29 -1
  28. package/src/posture/accuracy-scorecard.js +103 -0
  29. package/src/runScan.js +5 -2
  30. package/src/sast/CLAUDE.md +1 -1
  31. package/src/sast/_auth-signals.js +141 -0
  32. package/src/sast/_comment-strip.js +80 -13
  33. package/src/sast/codegen-sink.js +110 -0
  34. package/src/sast/convention-deviation.js +235 -0
  35. package/src/sast/fastapi-hardening.js +45 -6
  36. package/src/sast/file-upload.js +29 -1
  37. package/src/sast/ownership-authz.js +245 -0
  38. package/src/sast/php.js +12 -2
  39. package/src/sast/rate-limit.js +2 -0
  40. package/src/sast/rbac-consistency.js +1 -1
  41. package/src/sast/redirect-toctou.js +167 -0
  42. package/src/sast/resource-exhaustion.js +217 -0
  43. package/src/sast/sibling-guard.js +176 -0
  44. package/src/sast/zip-slip.js +53 -2
@@ -53,6 +53,15 @@ export const CATALOG = [
53
53
  { kind: 'source', id: 'js-req-cookies', language: 'js', framework: 'express', match: { type: 'member', object: 'req', prop: 'cookies' }, label: 'req.cookies', provenance: 'cookie' },
54
54
  { kind: 'source', id: 'js-request-body', language: 'js', framework: 'express', match: { type: 'member', object: 'request', prop: 'body' }, label: 'request.body', provenance: 'http-body' },
55
55
  { kind: 'source', id: 'js-ctx-request', language: 'js', framework: 'koa', match: { type: 'member', object: 'ctx', prop: 'request' }, label: 'ctx.request', provenance: 'http-body' },
56
+ // Taint-recall PRD (80%) Tier 3: Koa's own direct-on-context shortcuts
57
+ // (`ctx.query`, `ctx.params`, `ctx.headers`, `ctx.cookies` — aliases for
58
+ // the equivalent `ctx.request.*`) were entirely uncataloged, only the
59
+ // `ctx.request` umbrella above was. Found via CVE-2021-26701-koa-xss's
60
+ // `ctx.query.name` — mirrors the Express req.* coverage above.
61
+ { kind: 'source', id: 'js-ctx-query', language: 'js', framework: 'koa', match: { type: 'member', object: 'ctx', prop: 'query' }, label: 'ctx.query', provenance: 'url-param' },
62
+ { kind: 'source', id: 'js-ctx-params', language: 'js', framework: 'koa', match: { type: 'member', object: 'ctx', prop: 'params' }, label: 'ctx.params', provenance: 'path-param' },
63
+ { kind: 'source', id: 'js-ctx-headers', language: 'js', framework: 'koa', match: { type: 'member', object: 'ctx', prop: 'headers' }, label: 'ctx.headers', provenance: 'header' },
64
+ { kind: 'source', id: 'js-ctx-cookies', language: 'js', framework: 'koa', match: { type: 'member', object: 'ctx', prop: 'cookies' }, label: 'ctx.cookies', provenance: 'cookie' },
56
65
  // Browser DOM-derived (XSS sources).
57
66
  { kind: 'source', id: 'js-location', language: 'js', framework: 'dom', match: { type: 'global', name: 'location' }, label: 'window.location', provenance: 'url-fragment' },
58
67
  { kind: 'source', id: 'js-doc-cookie', language: 'js', framework: 'dom', match: { type: 'member', object: 'document', prop: 'cookie' }, label: 'document.cookie', provenance: 'cookie' },
@@ -193,12 +202,48 @@ export const CATALOG = [
193
202
  { kind: 'source', id: 'js-nestjs-param', language: 'js', framework: 'nestjs', match: { type: 'annotation', name: 'Param' }, label: '@Param() (NestJS)', provenance: 'path-param' },
194
203
  { kind: 'source', id: 'js-nestjs-headers', language: 'js', framework: 'nestjs', match: { type: 'annotation', name: 'Headers' }, label: '@Headers() (NestJS)', provenance: 'header' },
195
204
 
205
+ // Python annotation sources (PRD T3.1). Unlocked by parser-py.helper.py
206
+ // emitting the `paramAnnotations` side-channel, which it did not before —
207
+ // JS/C#/Java all populated it and Python emitted nothing, so no annotation
208
+ // source could ever match a Python parameter however well cataloged.
209
+ //
210
+ // Two shapes, both arriving through the same {index,name,decorator} channel:
211
+ // FastAPI's PER-PARAMETER default markers, and FUNCTION-level decorators
212
+ // whose presence makes every parameter an entry point.
213
+ //
214
+ // `Path` is deliberately ABSENT despite being a real FastAPI marker: a
215
+ // parameter default of `= Path(...)` is at least as likely to be pathlib.Path
216
+ // as a FastAPI path parameter, and a source that fires on ordinary filesystem
217
+ // code would taint half of every Python codebase. Taking the miss.
218
+ { kind: 'source', id: 'py-fastapi-param-query', language: 'py', framework: 'fastapi', match: { type: 'annotation', name: 'Query' }, label: 'Query(...) (FastAPI)', provenance: 'url-param' },
219
+ { kind: 'source', id: 'py-fastapi-param-body', language: 'py', framework: 'fastapi', match: { type: 'annotation', name: 'Body' }, label: 'Body(...) (FastAPI)', provenance: 'http-body' },
220
+ { kind: 'source', id: 'py-fastapi-param-form', language: 'py', framework: 'fastapi', match: { type: 'annotation', name: 'Form' }, label: 'Form(...) (FastAPI)', provenance: 'http-body' },
221
+ { kind: 'source', id: 'py-fastapi-param-header', language: 'py', framework: 'fastapi', match: { type: 'annotation', name: 'Header' }, label: 'Header(...) (FastAPI)', provenance: 'header' },
222
+ { kind: 'source', id: 'py-fastapi-param-cookie', language: 'py', framework: 'fastapi', match: { type: 'annotation', name: 'Cookie' }, label: 'Cookie(...) (FastAPI)', provenance: 'header' },
223
+ { kind: 'source', id: 'py-fastapi-param-file', language: 'py', framework: 'fastapi', match: { type: 'annotation', name: 'File' }, label: 'File(...) (FastAPI)', provenance: 'http-body' },
224
+ // MCP tool parameters are filled by a model acting on untrusted content —
225
+ // the agent-tool trust boundary this project's own threat model names
226
+ // (docs/AGENT_THREAT_MODEL.md). Matched on the dotted form so an unrelated
227
+ // `.tool()` method cannot satisfy it.
228
+ { kind: 'source', id: 'py-mcp-tool', language: 'py', framework: 'mcp', match: { type: 'annotation', name: 'mcp.tool' }, label: '@mcp.tool() parameter', provenance: 'agent-tool' },
229
+ { kind: 'source', id: 'py-mcp-server-tool', language: 'py', framework: 'mcp', match: { type: 'annotation', name: 'server.tool' }, label: '@server.tool() parameter', provenance: 'agent-tool' },
230
+
196
231
  // ─── SOURCES (Go) ─────────────────────────────────────────────────────────
197
232
  { kind: 'source', id: 'go-r-form', language: 'go', framework: 'net/http', match: { type: 'member', object: 'r', prop: 'Form' }, label: 'r.Form' },
198
233
  { kind: 'source', id: 'go-r-postform', language: 'go', framework: 'net/http', match: { type: 'member', object: 'r', prop: 'PostForm' }, label: 'r.PostForm' },
199
234
  { kind: 'source', id: 'go-r-body', language: 'go', framework: 'net/http', match: { type: 'member', object: 'r', prop: 'Body' }, label: 'r.Body' },
200
235
  { kind: 'source', id: 'go-r-formvalue',language: 'go', framework: 'net/http', match: { type: 'call', callee: 'FormValue' }, label: 'r.FormValue' },
201
236
  { kind: 'source', id: 'go-r-uquery', language: 'go', framework: 'net/http', match: { type: 'call', callee: 'Query' }, label: 'r.URL.Query' },
237
+ // Taint-recall PRD (80%): the idiomatic single-value-read form,
238
+ // `r.URL.Query().Get("key")` — bare-name callee matching only ever sees
239
+ // the TERMINAL segment of a chained callee string, so once the
240
+ // chained-call parser fix let this shape's real callee become
241
+ // "r.URL.Query.Get" (previously silently collapsed to just "r.URL.Query",
242
+ // dropping the .Get(...) entirely), go-r-uquery's `callee: 'Query'` no
243
+ // longer matches — "Get" is now what's terminal. Needs its own entry
244
+ // rather than widening go-r-uquery itself, since "Get" alone is far too
245
+ // generic for every Go framework in this catalog to safely bare-match.
246
+ { kind: 'source', id: 'go-r-uquery-get', language: 'go', framework: 'net/http', match: { type: 'call', callee: 'Get', receiver: '^Query$' }, label: 'r.URL.Query().Get(key)' },
202
247
  { kind: 'source', id: 'go-gin-query', language: 'go', framework: 'gin', match: { type: 'call', callee: 'Query' }, label: 'c.Query (gin)' },
203
248
  { kind: 'source', id: 'go-gin-bindjson',language:'go', framework: 'gin', match: { type: 'call', callee: 'BindJSON' }, label: 'c.BindJSON (gin)' },
204
249
  { kind: 'source', id: 'go-echo-param', language: 'go', framework: 'echo', match: { type: 'call', callee: 'Param' }, label: 'c.Param (echo)' },
@@ -268,6 +313,14 @@ export const CATALOG = [
268
313
  { kind: 'sink', id: 'py-sa-text', language: 'py', framework: 'sqlalchemy', match: { type: 'call', callee: 'text' }, argIndex: 0,
269
314
  vuln: { name: 'SQL Injection (sqlalchemy.text)', severity: 'critical', cwe: 'CWE-89',
270
315
  remediation: 'Use sqlalchemy.text with bound parameters: `text("SELECT :x").bindparams(x=v)`.' } },
316
+ // Taint-recall PRD (80%) Tier 3: Python carried zero XSS sink entries.
317
+ // Flask's `render_template_string(tainted)` compiles a user-influenced
318
+ // string AS a Jinja2 template — both an XSS vector (unescaped output) and
319
+ // SSTI-adjacent (server-side template compilation), scored CWE-79 to
320
+ // match this corpus's own manifest classification for the shape.
321
+ { kind: 'sink', id: 'py-flask-render-template-string', language: 'py', framework: 'flask', match: { type: 'call', callee: 'render_template_string' }, argIndex: 0,
322
+ vuln: { name: 'Reflected XSS / SSTI (Flask render_template_string)', severity: 'high', cwe: 'CWE-79',
323
+ remediation: 'Never build the TEMPLATE from user input. Use render_template with a static template file and pass user data as context variables (auto-escaped).' } },
271
324
 
272
325
  // ─── SINKS (SQL — Java) ───────────────────────────────────────────────────
273
326
  { kind: 'sink', id: 'java-stmt-executeQuery', language: 'java', framework: 'jdbc', match: { type: 'call', callee: 'executeQuery' }, argIndex: 0,
@@ -294,6 +347,18 @@ export const CATALOG = [
294
347
  vuln: { name: 'Native SQL Injection (EntityManager.createNativeQuery)', severity: 'critical', cwe: 'CWE-89',
295
348
  remediation: 'Use setParameter on the resulting Query.' } },
296
349
 
350
+ // Taint-recall PRD (80%) Tier 3: Java carried zero XSS sink entries —
351
+ // `resp.getWriter().write(...)`/`.print(...)` (Servlet's canonical
352
+ // response-write idiom, `HttpServletResponse.getWriter()`) is the
353
+ // dominant shape. Receiver-scoped to the `getWriter` chain segment (both
354
+ // `write` and `print` collide with countless unrelated APIs bare).
355
+ { kind: 'sink', id: 'java-writer-write', language: 'java', framework: 'servlet', match: { type: 'call', callee: 'write', receiver: '^getWriter$' }, argIndex: 0,
356
+ vuln: { name: 'Reflected XSS (PrintWriter.write)', severity: 'high', cwe: 'CWE-79',
357
+ remediation: 'HTML-escape user-derived content before writing to the response, or use a templating engine with auto-escaping.' } },
358
+ { kind: 'sink', id: 'java-writer-print', language: 'java', framework: 'servlet', match: { type: 'call', callee: 'print', receiver: '^getWriter$' }, argIndex: 0,
359
+ vuln: { name: 'Reflected XSS (PrintWriter.print)', severity: 'high', cwe: 'CWE-79',
360
+ remediation: 'HTML-escape user-derived content before writing to the response, or use a templating engine with auto-escaping.' } },
361
+
297
362
  // ─── SINKS (SQL — Go) ──────────────────────────────────────────────────────
298
363
  { kind: 'sink', id: 'go-db-query', language: 'go', framework: 'database/sql', match: { type: 'call', callee: 'Query' }, argIndex: 0,
299
364
  vuln: { name: 'SQL Injection (db.Query)', severity: 'critical', cwe: 'CWE-89',
@@ -336,6 +401,16 @@ export const CATALOG = [
336
401
  { kind: 'sink', id: 'php-shell-exec', language: 'php', framework: 'core', match: { type: 'call', callee: 'shell_exec' }, argIndex: 0,
337
402
  vuln: { name: 'Command Injection (shell_exec)', severity: 'critical', cwe: 'CWE-78',
338
403
  remediation: 'Avoid shell_exec(); sanitize with escapeshellarg() if unavoidable.' } },
404
+ // Taint-recall PRD (80%): passthru()/proc_open() were entirely uncataloged
405
+ // for PHP — verified via this PRD's Tier 3 command-injection audit
406
+ // (CVE-2016-10033-phpmailer-cmdi-shape's exact real shape,
407
+ // `passthru("gzip " . $file)`).
408
+ { kind: 'sink', id: 'php-passthru', language: 'php', framework: 'core', match: { type: 'call', callee: 'passthru' }, argIndex: 0,
409
+ vuln: { name: 'Command Injection (passthru)', severity: 'critical', cwe: 'CWE-78',
410
+ remediation: 'Avoid passthru(); use escapeshellarg() on each argument if unavoidable.' } },
411
+ { kind: 'sink', id: 'php-proc-open', language: 'php', framework: 'core', match: { type: 'call', callee: 'proc_open' }, argIndex: 0,
412
+ vuln: { name: 'Command Injection (proc_open)', severity: 'critical', cwe: 'CWE-78',
413
+ remediation: 'Pass the command as an array (argv form) instead of a shell string.' } },
339
414
 
340
415
  // ─── SINKS (SQL/CMD — Ruby) ───────────────────────────────────────────────
341
416
  { kind: 'sink', id: 'rb-ar-where-string', language: 'rb', framework: 'rails', match: { type: 'call', callee: 'where' }, argIndex: 0,
@@ -350,9 +425,36 @@ export const CATALOG = [
350
425
  { kind: 'sink', id: 'rb-exec', language: 'rb', framework: 'stdlib', match: { type: 'call', callee: 'exec' }, argIndex: 0,
351
426
  vuln: { name: 'Command Injection (Kernel.exec)', severity: 'critical', cwe: 'CWE-78',
352
427
  remediation: 'Use the array form: exec("cmd", arg1, arg2).' } },
428
+ // Taint-recall PRD (80%): Ruby's backtick shell-execution operator
429
+ // (`` `finger #{user}` ``, equivalent to %x{...}) — parser-rb.js's
430
+ // _lowerExpr now lowers it to a synthetic call named
431
+ // `__ruby_backtick_exec__` (see that file for why), so this sink targets
432
+ // that synthetic callee exactly like any other call-shaped sink.
433
+ { kind: 'sink', id: 'rb-backtick-exec', language: 'rb', framework: 'stdlib', match: { type: 'call', callee: '__ruby_backtick_exec__' }, argIndex: 0,
434
+ vuln: { name: 'Command Injection (backtick shell execution)', severity: 'critical', cwe: 'CWE-78',
435
+ remediation: 'Avoid backtick/%x{} shell execution with interpolated input; use Kernel#exec or Process.spawn with an argv array.' } },
353
436
  { kind: 'sink', id: 'rb-sinatra-erb', language: 'rb', framework: 'sinatra', match: { type: 'call', callee: 'erb' }, argIndex: 0,
354
437
  vuln: { name: 'Server-Side Template Injection (Sinatra ERB)', severity: 'high', cwe: 'CWE-1336',
355
438
  remediation: 'Use ERB auto-escaping. Never pass user input as the template name.' } },
439
+ // Taint-recall PRD (80%) — Theme 3 catalog parity gap the parent PRD's
440
+ // own Ruby wishlist named explicitly (redirect_to), plus three more found
441
+ // by direct audit of still-0%-taint corpus entries after the CFG rebuild.
442
+ { kind: 'sink', id: 'rb-redirect-to', language: 'rb', framework: 'rails', match: { type: 'call', callee: 'redirect_to' }, argIndex: 0,
443
+ vuln: { name: 'Open Redirect (Rails redirect_to)', severity: 'medium', cwe: 'CWE-601',
444
+ remediation: 'Validate the redirect target against an allow-list; never pass a request-derived string straight to redirect_to.' } },
445
+ // "render" alone is broad, but Rails' render is a wide surface (inline
446
+ // templates, partial paths, json/xml) and any tainted arg reaching it is
447
+ // a real risk (XSS via inline:, path traversal via partial:), matching
448
+ // this codebase's recall-preserving default.
449
+ { kind: 'sink', id: 'rb-render', language: 'rb', framework: 'rails', match: { type: 'call', callee: 'render' }, argIndex: 'all',
450
+ vuln: { name: 'Reflected XSS (Rails render with tainted content)', severity: 'high', cwe: 'CWE-79',
451
+ remediation: 'Never interpolate untrusted input into render inline:/partial:. Use view templates with auto-escaping and pass data as locals.' } },
452
+ { kind: 'sink', id: 'rb-uri-open', language: 'rb', framework: 'stdlib', match: { type: 'call', callee: 'open', receiver: '^URI$' }, argIndex: 0,
453
+ vuln: { name: 'SSRF (URI.open with user-controlled URL)', severity: 'high', cwe: 'CWE-918',
454
+ remediation: 'Validate the target URL against an allow-list before opening it.' } },
455
+ { kind: 'sink', id: 'rb-file-read', language: 'rb', framework: 'stdlib', match: { type: 'call', callee: 'read', receiver: '^File$' }, argIndex: 0,
456
+ vuln: { name: 'Path Traversal (File.read with concatenated path)', severity: 'high', cwe: 'CWE-22',
457
+ remediation: 'Canonicalize and verify the path stays within an allowed base directory before reading.' } },
356
458
 
357
459
  // ─── SINKS (SQL — PHP / Symfony / Doctrine) ───────────────────────────────
358
460
  { kind: 'sink', id: 'php-symfony-createquery',language:'php',framework:'symfony',match:{type:'call',callee:'createQuery'}, argIndex: 0,
@@ -361,6 +463,12 @@ export const CATALOG = [
361
463
  { kind: 'sink', id: 'php-doctrine-nativequery',language:'php',framework:'doctrine',match:{type:'call',callee:'createNativeQuery'},argIndex:0,
362
464
  vuln: { name: 'SQL Injection (Doctrine createNativeQuery)', severity: 'critical', cwe: 'CWE-89',
363
465
  remediation: 'Use bound parameters with createNativeQuery.' } },
466
+ // Taint-recall PRD (80%) Tier 3: PHP carried zero XSS sink entries —
467
+ // `echo`/`print` are language constructs (parser-php.js now lowers them
468
+ // to a synthetic `__php_echo__` call, see that file for why).
469
+ { kind: 'sink', id: 'php-echo-xss', language: 'php', framework: 'core', match: { type: 'call', callee: '__php_echo__' }, argIndex: 'all',
470
+ vuln: { name: 'Reflected XSS (echo)', severity: 'high', cwe: 'CWE-79',
471
+ remediation: 'Escape output with htmlspecialchars() before echoing user-derived content.' } },
364
472
 
365
473
  // ─── SINKS (XSS / template — JS/TS / browser) ─────────────────────────────
366
474
  { kind: 'sink', id: 'js-innerHTML-assign', language: 'js', framework: 'dom', match: { type: 'member', object: '_any_', prop: 'innerHTML' }, argIndex: 'rhs',
@@ -375,20 +483,70 @@ export const CATALOG = [
375
483
  { kind: 'sink', id: 'react-dangerouslySetInnerHTML', language: 'js', framework: 'react', match: { type: 'member', object: '_any_', prop: 'dangerouslySetInnerHTML' }, argIndex: 'rhs',
376
484
  vuln: { name: 'XSS via dangerouslySetInnerHTML', severity: 'high', cwe: 'CWE-79',
377
485
  remediation: 'Sanitize the __html field via DOMPurify before passing it to dangerouslySetInnerHTML — better, render text via children.' } },
486
+ // Taint-recall PRD (80%) Tier 3: the JSX ATTRIBUTE form
487
+ // (`<div dangerouslySetInnerHTML={{__html: html}} />`, as opposed to a
488
+ // plain-JS member WRITE `x.dangerouslySetInnerHTML = {...}`, which the
489
+ // entry above already covers) needed its own sink — see parser-js.js's
490
+ // JSXElement case for why it's a synthetic call, not a member write.
491
+ { kind: 'sink', id: 'react-jsx-dangerouslySetInnerHTML', language: 'js', framework: 'react', match: { type: 'call', callee: '__jsx_dangerously_set_inner_html__' }, argIndex: 0,
492
+ vuln: { name: 'XSS via dangerouslySetInnerHTML', severity: 'high', cwe: 'CWE-79',
493
+ remediation: 'Sanitize the __html field via DOMPurify before passing it to dangerouslySetInnerHTML — better, render text via children.' } },
494
+ // Taint-recall PRD (80%) Tier 3: XSS audit found java/php/kotlin/python
495
+ // carried ZERO XSS sink entries in the catalog at all, and Express/Koa's
496
+ // own response-write idioms (`res.send`, `ctx.body =`) were also
497
+ // uncataloged for JS despite the innerHTML/dangerouslySetInnerHTML
498
+ // entries above already covering the DOM side.
499
+ { kind: 'sink', id: 'js-express-res-send', language: 'js', framework: 'express', match: { type: 'call', callee: 'send', receiver: '^(?:res|response)$' }, argIndex: 0,
500
+ vuln: { name: 'Reflected XSS (Express res.send)', severity: 'high', cwe: 'CWE-79',
501
+ remediation: 'Escape user-derived HTML before sending, or use res.json for data responses.' } },
502
+ // `matchMemberWriteSink` indexes member-WRITE sinks under a fixed
503
+ // `_any_.<prop>` key and applies object-specificity afterward via
504
+ // `match.receiver` — the same shape `react-dangerouslySetInnerHTML` above
505
+ // uses. An entry with a literal `object: 'ctx'` (instead of `_any_`)
506
+ // would be indexed under a completely different key and never be found by
507
+ // this lookup at all — confirmed by testing this entry with `object:
508
+ // 'ctx'` first, which silently never fired.
509
+ { kind: 'sink', id: 'js-koa-ctx-body', language: 'js', framework: 'koa', match: { type: 'member', object: '_any_', prop: 'body', receiver: '^ctx$' }, argIndex: 'rhs',
510
+ vuln: { name: 'Reflected XSS (Koa ctx.body)', severity: 'high', cwe: 'CWE-79',
511
+ remediation: 'Escape user-derived HTML before assigning to ctx.body, or use ctx.body = { ... } for a JSON response.' } },
378
512
 
379
513
  // ─── SINKS (HTTP outbound / SSRF) ─────────────────────────────────────────
380
- { kind: 'sink', id: 'py-requests-get', language: 'py', framework: 'requests', match: { type: 'call', callee: 'get', receiverTypeIn: ['requests|session|client|http'] }, argIndex: 0,
514
+ { kind: 'sink', id: 'py-requests-get', language: 'py', framework: 'requests', match: { type: 'call', callee: 'get', receiver: '^(?:requests|httpx|session|Session|client|Client|http|urllib3|pool)$', receiverTypeIn: ['requests|session|client|http'] }, argIndex: 0,
381
515
  vuln: { name: 'SSRF (requests.get)', severity: 'high', cwe: 'CWE-918',
382
516
  remediation: 'Resolve the URL host and reject RFC1918 + metadata endpoints before fetching. Use an allow-list.' } },
383
- { kind: 'sink', id: 'py-requests-post', language: 'py', framework: 'requests', match: { type: 'call', callee: 'post' }, argIndex: 0,
517
+ { kind: 'sink', id: 'py-requests-post', language: 'py', framework: 'requests', match: { type: 'call', callee: 'post', receiver: '^(?:requests|httpx|session|Session|client|Client|http|urllib3|pool)$' }, argIndex: 0,
384
518
  vuln: { name: 'SSRF (requests.post)', severity: 'high', cwe: 'CWE-918', remediation: 'Validate the URL host before posting.' } },
385
519
  { kind: 'sink', id: 'py-urlopen', language: 'py', framework: 'urllib', match: { type: 'call', callee: 'urlopen' }, argIndex: 0,
386
520
  vuln: { name: 'SSRF (urllib.request.urlopen)', severity: 'high', cwe: 'CWE-918', remediation: 'Validate the URL host before opening.' } },
387
521
  { kind: 'sink', id: 'go-http-get', language: 'go', framework: 'net/http', match: { type: 'call', callee: 'Get' }, argIndex: 0,
388
522
  vuln: { name: 'SSRF (http.Get)', severity: 'high', cwe: 'CWE-918', remediation: 'Validate the URL host before fetching; reject RFC1918 + metadata endpoints.' } },
523
+ // Taint-recall PRD (80%) Tier 2/3: SSRF audit found PHP/Java/JS entirely
524
+ // uncataloged for their dominant outbound-HTTP libraries.
525
+ { kind: 'sink', id: 'php-curl-init', language: 'php', framework: 'core', match: { type: 'call', callee: 'curl_init' }, argIndex: 0,
526
+ vuln: { name: 'SSRF (curl_init)', severity: 'high', cwe: 'CWE-918',
527
+ remediation: 'Validate the target URL against an allow-list before opening it.' } },
528
+ // Taint-recall PRD (80%): "terminal segment shift" — `new URL(url)
529
+ // .openStream()` is the dominant real-world shape (bare `new URL(url)`
530
+ // with nothing chained is comparatively rare), and after Java's own
531
+ // chained-call CST fix (elsewhere in this PRD) the chain collapses into
532
+ // one dotted callee whose LAST segment is "openStream", not "URL" —
533
+ // receiver-scoped to the now-middle "URL" segment, same pattern as
534
+ // kt-xpath-evaluate/java-spel-getvalue/go-r-uquery-get elsewhere in this
535
+ // PRD.
536
+ { kind: 'sink', id: 'java-url-openstream', language: 'java', framework: 'stdlib', match: { type: 'call', callee: 'openStream', receiver: '^URL$' }, argIndex: 'all',
537
+ vuln: { name: 'SSRF (new URL(...).openStream)', severity: 'high', cwe: 'CWE-918',
538
+ remediation: 'Validate the target URL against an allow-list before opening a connection.' } },
539
+ // Receiver-scoped to `axios`/`http`/`https` — the module/instance names
540
+ // real code actually uses for outbound HTTP (axios, NestJS's injected
541
+ // HttpService — conventionally named `http` — and Node's built-in
542
+ // http/https modules, which have the identical `.get(url)` SSRF shape).
543
+ // `.get()` bare is far too common a method name to leave unscoped.
544
+ { kind: 'sink', id: 'js-axios-http-get', language: 'js', framework: 'axios', match: { type: 'call', callee: 'get', receiver: '^(?:axios|http|https)$' }, argIndex: 0,
545
+ vuln: { name: 'SSRF (axios/http.get)', severity: 'high', cwe: 'CWE-918',
546
+ remediation: 'Validate the target URL against an allow-list before fetching; reject RFC1918 + metadata endpoints.' } },
389
547
 
390
548
  // ─── SINKS (command exec) ─────────────────────────────────────────────────
391
- { kind: 'sink', id: 'py-subprocess-run', language: 'py', framework: 'subprocess', match: { type: 'call', callee: 'run' }, argIndex: 0,
549
+ { kind: 'sink', id: 'py-subprocess-run', language: 'py', framework: 'subprocess', match: { type: 'call', callee: 'run', requireKeyword: { name: 'shell', pattern: '^(?:true|True|1)$' } }, argIndex: 0,
392
550
  vuln: { name: 'Command Injection (subprocess.run shell=True)', severity: 'critical', cwe: 'CWE-78',
393
551
  remediation: 'Pass argv as a list; never pass a single string with shell=True.' } },
394
552
  { kind: 'sink', id: 'py-os-system', language: 'py', framework: 'os', match: { type: 'call', callee: 'system' }, argIndex: 0,
@@ -397,9 +555,35 @@ export const CATALOG = [
397
555
  { kind: 'sink', id: 'java-runtime-exec', language: 'java', framework: 'stdlib', match: { type: 'call', callee: 'exec' }, argIndex: 0,
398
556
  vuln: { name: 'Command Injection (Runtime.exec string-form)', severity: 'critical', cwe: 'CWE-78',
399
557
  remediation: 'Use Runtime.exec(String[]) or ProcessBuilder(String[]).' } },
400
- { kind: 'sink', id: 'go-os-exec-command', language: 'go', framework: 'os/exec', match: { type: 'call', callee: 'Command' }, argIndex: 0,
558
+ // Taint-recall PRD (80%): `argIndex: 0` was checking the WRONG argument.
559
+ // This entry's own vuln description names the actual dangerous shape —
560
+ // `exec.Command("/bin/sh", "-c", tainted)` — where the tainted shell
561
+ // string sits at arg index 2, but arg 0 is always the literal "/bin/sh"/
562
+ // "bash" itself, never tainted. `argIndex: 0` therefore could never fire
563
+ // on this entry's own documented shape. Widened to `'all'` — any tainted
564
+ // arg anywhere in the call fires, correct for both the 3-arg /bin/sh -c
565
+ // form and a directly-tainted argv[0] (`exec.Command(tainted)`).
566
+ { kind: 'sink', id: 'go-os-exec-command', language: 'go', framework: 'os/exec',
567
+ match: { type: 'call', callee: 'Command', requireLiteralArg: { index: 0, pattern: '^"(?:/bin/)?(?:sh|bash)"$' } }, argIndex: 'all',
401
568
  vuln: { name: 'Command Injection (exec.Command via /bin/sh -c)', severity: 'critical', cwe: 'CWE-78',
402
569
  remediation: 'When the first arg is "/bin/sh" or "bash" with a -c string built from user input, the shell parses it. Pass argv array values directly to exec.Command.' } },
570
+ // Taint-recall PRD (80%): "terminal segment shift" — the same pattern
571
+ // documented elsewhere in this PRD (kt-xpath-evaluate, java-spel-getvalue,
572
+ // go-r-uquery-get). `exec.Command(...).Output()` / `.Run()` /
573
+ // `.CombinedOutput()` / `.Start()` — chaining a Cmd-execution method
574
+ // directly onto the `exec.Command(...)` call, by far the most common Go
575
+ // idiom for actually RUNNING the command — collapses the chained call
576
+ // into one dotted string whose LAST segment becomes "Output"/"Run"/etc.,
577
+ // not "Command", making the entry above (keyed to bare "Command")
578
+ // invisible to the catalog's last-segment lookup. Receiver-scoped to the
579
+ // now-middle "Command" segment, argIndex 'all' for the same reason as
580
+ // above.
581
+ ...['Output', 'Run', 'CombinedOutput', 'Start'].map((method) => ({
582
+ kind: 'sink', id: `go-exec-command-${method.toLowerCase()}`, language: 'go', framework: 'os/exec',
583
+ match: { type: 'call', callee: method, receiver: '^Command$', requireLiteralArg: { index: 0, pattern: '^"(?:/bin/)?(?:sh|bash)"$' } }, argIndex: 'all',
584
+ vuln: { name: 'Command Injection (exec.Command via /bin/sh -c)', severity: 'critical', cwe: 'CWE-78',
585
+ remediation: 'When the first arg is "/bin/sh" or "bash" with a -c string built from user input, the shell parses it. Pass argv array values directly to exec.Command.' },
586
+ })),
403
587
 
404
588
  // ─── SINKS (deserialization) ──────────────────────────────────────────────
405
589
  // `load`/`loads` are among the most overloaded names in Python: `json.load`,
@@ -441,20 +625,113 @@ export const CATALOG = [
441
625
  { kind: 'sink', id: 'py-open', language: 'py', framework: 'stdlib', match: { type: 'call', callee: 'open' }, argIndex: 0,
442
626
  vuln: { name: 'Path Traversal (open)', severity: 'high', cwe: 'CWE-22',
443
627
  remediation: 'Canonicalize the path with os.path.realpath + verify it stays within an allow-list of base directories.' } },
628
+ // Taint-recall PRD (80%): "terminal segment shift" — the same pattern
629
+ // documented elsewhere in this PRD. `open(tainted).read()` — an
630
+ // extremely common idiomatic Python one-liner — collapses into one
631
+ // chained call whose LAST segment is "read", not "open", making the
632
+ // entry above (keyed to bare "open") invisible to the catalog's last-
633
+ // segment lookup. Receiver-scoped to the now-middle "open" segment.
634
+ { kind: 'sink', id: 'py-open-read-chained', language: 'py', framework: 'stdlib', match: { type: 'call', callee: 'read', receiver: '^open$' }, argIndex: 'all',
635
+ vuln: { name: 'Path Traversal (open(...).read())', severity: 'high', cwe: 'CWE-22',
636
+ remediation: 'Canonicalize the path with os.path.realpath + verify it stays within an allow-list of base directories.' } },
444
637
  { kind: 'sink', id: 'java-new-File', language: 'java', framework: 'stdlib', match: { type: 'call', callee: 'File' }, argIndex: 0,
445
638
  vuln: { name: 'Path Traversal (new File)', severity: 'high', cwe: 'CWE-22',
446
639
  remediation: 'Canonicalize with Path.normalize + startsWith(base).' } },
447
640
  { kind: 'sink', id: 'go-os-open', language: 'go', framework: 'os', match: { type: 'call', callee: 'Open' }, argIndex: 0,
448
641
  vuln: { name: 'Path Traversal (os.Open)', severity: 'high', cwe: 'CWE-22',
449
642
  remediation: 'Use filepath.Clean + verify the path is rooted in your allow-list dir.' } },
643
+ // Taint-recall PRD (80%) Tier 2/3: path-traversal audit found four
644
+ // real-world APIs entirely uncataloged.
645
+ { kind: 'sink', id: 'go-os-readfile', language: 'go', framework: 'os', match: { type: 'call', callee: 'ReadFile', receiver: '^os$' }, argIndex: 0,
646
+ vuln: { name: 'Path Traversal (os.ReadFile)', severity: 'high', cwe: 'CWE-22',
647
+ remediation: 'Use filepath.Clean + verify the path is rooted in your allow-list dir.' } },
648
+ { kind: 'sink', id: 'java-files-readallbytes', language: 'java', framework: 'nio', match: { type: 'call', callee: 'readAllBytes' }, argIndex: 'all',
649
+ vuln: { name: 'Path Traversal (Files.readAllBytes)', severity: 'high', cwe: 'CWE-22',
650
+ remediation: 'Canonicalize the resolved Path and verify it stays within an allow-listed base directory.' } },
651
+ { kind: 'sink', id: 'php-readfile', language: 'php', framework: 'core', match: { type: 'call', callee: 'readfile' }, argIndex: 0,
652
+ vuln: { name: 'Path Traversal (readfile)', severity: 'high', cwe: 'CWE-22',
653
+ remediation: 'Canonicalize with realpath() and confirm the result stays within an allowed base directory.' } },
654
+ { kind: 'sink', id: 'js-koa-send', language: 'js', framework: 'koa', match: { type: 'call', callee: 'send' }, argIndex: 1,
655
+ vuln: { name: 'Path Traversal (koa-send)', severity: 'high', cwe: 'CWE-22',
656
+ remediation: 'koa-send already guards against ../ escapes from its root, but a caller-supplied absolute or symlinked path can still escape it — validate the path before passing it to send().' } },
450
657
 
451
658
  // ─── SINKS (LDAP / XPath) ─────────────────────────────────────────────────
452
659
  { kind: 'sink', id: 'java-ldap-search', language: 'java', framework: 'jndi', match: { type: 'call', callee: 'search' }, argIndex: 1,
453
660
  vuln: { name: 'LDAP Injection (DirContext.search)', severity: 'high', cwe: 'CWE-90',
454
661
  remediation: 'Escape LDAP filter metacharacters with Rdn.escapeValue or use a parameterised filter.' } },
662
+ { kind: 'sink', id: 'kt-ldap-search', language: 'kt', framework: 'jndi', match: { type: 'call', callee: 'search' }, argIndex: 1,
663
+ vuln: { name: 'LDAP Injection (DirContext.search)', severity: 'high', cwe: 'CWE-90',
664
+ remediation: 'Escape LDAP filter metacharacters or use a parameterised filter.' } },
665
+ // ldapjs: client.search(base, { filter: tainted }, cb) — the tainted value
666
+ // lives inside an object-literal argument, not a flat string arg;
667
+ // exprTaint's own 'object' case already recurses into prop values, so a
668
+ // plain argIndex against the options object works without any special
669
+ // object-shape handling.
670
+ { kind: 'sink', id: 'js-ldap-search', language: 'js', framework: 'ldapjs', match: { type: 'call', callee: 'search', receiver: 'client' }, argIndex: 1,
671
+ vuln: { name: 'LDAP Injection (ldapjs client.search)', severity: 'high', cwe: 'CWE-90',
672
+ remediation: 'Escape LDAP filter metacharacters before building the filter string.' } },
673
+ { kind: 'sink', id: 'py-ldap-search-s', language: 'py', framework: 'python-ldap', match: { type: 'call', callee: 'search_s' }, argIndex: 2,
674
+ vuln: { name: 'LDAP Injection (python-ldap search_s)', severity: 'high', cwe: 'CWE-90',
675
+ remediation: 'Escape LDAP filter metacharacters (ldap.filter.escape_filter_chars) before building the filter string.' } },
676
+ { kind: 'sink', id: 'rb-net-ldap-search', language: 'rb', framework: 'net-ldap', match: { type: 'call', callee: 'search', receiver: 'conn' }, argIndex: 'all',
677
+ vuln: { name: 'LDAP Injection (net/ldap Connection#search)', severity: 'high', cwe: 'CWE-90',
678
+ remediation: 'Escape LDAP filter metacharacters before building the filter string.' } },
679
+ { kind: 'sink', id: 'php-ldap-search', language: 'php', framework: 'stdlib', match: { type: 'call', callee: 'ldap_search' }, argIndex: 2,
680
+ vuln: { name: 'LDAP Injection (ldap_search)', severity: 'high', cwe: 'CWE-90',
681
+ remediation: 'Escape LDAP filter metacharacters with ldap_escape($value, "", LDAP_ESCAPE_FILTER) before building the filter string.' } },
682
+ // NewSearchRequest's filter is a fixed but unusual argument position
683
+ // (7th positional); 'all' is used instead of pinning an index — this
684
+ // stdlib call has no other plausible non-LDAP use, so the recall
685
+ // gained by not depending on exact positional counting outweighs the
686
+ // (near-zero) added imprecision.
687
+ { kind: 'sink', id: 'go-ldap-newsearchrequest', language: 'go', framework: 'go-ldap', match: { type: 'call', callee: 'NewSearchRequest' }, argIndex: 'all',
688
+ vuln: { name: 'LDAP Injection (go-ldap NewSearchRequest)', severity: 'high', cwe: 'CWE-90',
689
+ remediation: 'Escape LDAP filter metacharacters before building the filter string.' } },
455
690
  { kind: 'sink', id: 'java-xpath-compile', language: 'java', framework: 'xpath', match: { type: 'call', callee: 'compile' }, argIndex: 0,
456
691
  vuln: { name: 'XPath Injection (XPath.compile)', severity: 'high', cwe: 'CWE-643',
457
692
  remediation: 'Use XPathVariableResolver or setXPathVariableResolver; never concat user input into the expression.' } },
693
+ // Taint-recall PRD (80%) Tier 1: XPath.evaluate() called directly (no
694
+ // .compile() call site to catch it via the entry above) — javax.xml.xpath's
695
+ // other common idiom. Receiver-scoped: "evaluate" is generic elsewhere.
696
+ { kind: 'sink', id: 'java-xpath-evaluate', language: 'java', framework: 'xpath', match: { type: 'call', callee: 'evaluate', receiver: '^(?:xp|xpath|expr)$' }, argIndex: 0,
697
+ vuln: { name: 'XPath Injection (XPath.evaluate)', severity: 'high', cwe: 'CWE-643',
698
+ remediation: 'Use XPathVariableResolver or setXPathVariableResolver; never concat user input into the expression.' } },
699
+ { kind: 'sink', id: 'cs-xml-selectnodes', language: 'cs', framework: 'stdlib', match: { type: 'call', callee: 'SelectNodes' }, argIndex: 0,
700
+ vuln: { name: 'XPath Injection (XmlDocument.SelectNodes)', severity: 'high', cwe: 'CWE-643',
701
+ remediation: 'Use XPathExpression with bound variables (SetContext/AddVariable); never concat user input into the expression string.' } },
702
+ { kind: 'sink', id: 'kt-xpath-compile', language: 'kt', framework: 'xpath', match: { type: 'call', callee: 'compile' }, argIndex: 0,
703
+ vuln: { name: 'XPath Injection (XPath.compile)', severity: 'high', cwe: 'CWE-643',
704
+ remediation: 'Use XPathVariableResolver; never concat user input into the expression.' } },
705
+ // Taint-recall PRD (80%): the idiomatic real-world shape is chained —
706
+ // `xp.compile(taintedExpr).evaluate(doc, NODESET)`. Once the parser's
707
+ // chained-call fix correctly joins this into one call node, bare-name
708
+ // matching only ever sees the TERMINAL segment ("evaluate") — "compile"
709
+ // above is now a middle segment and can never match on its own for this
710
+ // shape. argIndex 'all' since the tainted value's position among the
711
+ // combined args depends on how many args the outer call itself has.
712
+ { kind: 'sink', id: 'kt-xpath-evaluate', language: 'kt', framework: 'xpath', match: { type: 'call', callee: 'evaluate', receiver: '^(?:xp|xpath|expr)$' }, argIndex: 'all',
713
+ vuln: { name: 'XPath Injection (XPath.compile().evaluate())', severity: 'high', cwe: 'CWE-643',
714
+ remediation: 'Use XPathVariableResolver; never concat user input into the expression.' } },
715
+ { kind: 'sink', id: 'py-lxml-xpath', language: 'py', framework: 'lxml', match: { type: 'call', callee: 'xpath' }, argIndex: 0,
716
+ vuln: { name: 'XPath Injection (lxml .xpath())', severity: 'high', cwe: 'CWE-643',
717
+ remediation: 'Pass variables via the lxml `xpath(expr, var=value)` binding mechanism; never concat user input into the expression string.' } },
718
+ { kind: 'sink', id: 'rb-nokogiri-xpath', language: 'rb', framework: 'nokogiri', match: { type: 'call', callee: 'xpath' }, argIndex: 0,
719
+ vuln: { name: 'XPath Injection (Nokogiri .xpath())', severity: 'high', cwe: 'CWE-643',
720
+ remediation: 'Never interpolate user input into the expression string; validate/allow-list the queried attribute value instead.' } },
721
+ { kind: 'sink', id: 'js-xpath-select', language: 'js', framework: 'xpath', match: { type: 'call', callee: 'select', receiver: 'xpath' }, argIndex: 0,
722
+ vuln: { name: 'XPath Injection (xpath.select())', severity: 'high', cwe: 'CWE-643',
723
+ remediation: 'Never concat user input into the expression string; validate/allow-list the queried value instead.' } },
724
+ // Receiver-scoped: DOMXPath's own `query()` collides on bare name with
725
+ // php-pdo-query's SQL sink below (also unscoped `callee: 'query'`) — a
726
+ // pre-existing precision gap in that entry, not fixed here, but this
727
+ // entry's own receiver constraint keeps IT from firing on unrelated
728
+ // PDO/mysqli query() calls.
729
+ { kind: 'sink', id: 'php-domxpath-query', language: 'php', framework: 'stdlib', match: { type: 'call', callee: 'query', receiver: '(?:xp|xpath|dom)' }, argIndex: 0,
730
+ vuln: { name: 'XPath Injection (DOMXPath::query)', severity: 'high', cwe: 'CWE-643',
731
+ remediation: 'Never concat user input into the expression string; validate/allow-list the queried attribute value instead.' } },
732
+ { kind: 'sink', id: 'go-htmlquery-find', language: 'go', framework: 'htmlquery', match: { type: 'call', callee: 'Find', receiver: 'htmlquery' }, argIndex: 1,
733
+ vuln: { name: 'XPath Injection (htmlquery.Find)', severity: 'high', cwe: 'CWE-643',
734
+ remediation: 'Never concat user input into the expression string; validate/allow-list the queried attribute value instead.' } },
458
735
 
459
736
  // ─── SINKS (regex DoS / ReDoS) ────────────────────────────────────────────
460
737
  { kind: 'sink', id: 'js-RegExp-new', language: 'js', framework: 'core', match: { type: 'call', callee: 'RegExp' }, argIndex: 0,
@@ -476,6 +753,33 @@ export const CATALOG = [
476
753
  { kind: 'sink', id: 'py-etree-parse', language: 'py', framework: 'lxml', match: { type: 'call', callee: 'parse' }, argIndex: 0,
477
754
  vuln: { name: 'XXE (lxml.etree.parse)', severity: 'high', cwe: 'CWE-611',
478
755
  remediation: 'Use defusedxml.ElementTree or pass resolve_entities=False.' } },
756
+ { kind: 'sink', id: 'kt-documentbuilder-parse', language: 'kt', framework: 'jaxp', match: { type: 'call', callee: 'parse' }, argIndex: 'all',
757
+ vuln: { name: 'XXE (DocumentBuilder.parse)', severity: 'high', cwe: 'CWE-611',
758
+ remediation: 'Disable DTDs: setFeature("http://apache.org/xml/features/disallow-doctype-decl", true).' } },
759
+ { kind: 'sink', id: 'js-libxml-parsexmlstring', language: 'js', framework: 'libxmljs', match: { type: 'call', callee: 'parseXmlString' }, argIndex: 0,
760
+ vuln: { name: 'XXE (libxmljs parseXmlString)', severity: 'high', cwe: 'CWE-611',
761
+ remediation: 'Do not pass { noent: true, dtdload: true } for untrusted XML — omit both options.' } },
762
+ { kind: 'sink', id: 'rb-nokogiri-xml', language: 'rb', framework: 'nokogiri', match: { type: 'call', callee: 'XML', receiver: 'Nokogiri' }, argIndex: 0,
763
+ vuln: { name: 'XXE (Nokogiri::XML with dtdload/noent enabled)', severity: 'high', cwe: 'CWE-611',
764
+ remediation: 'Do not enable config.dtdload/config.noent for untrusted XML — Nokogiri is safe by default.' } },
765
+ // `response.headers["X-Trace"] = tainted` — parser-rb.js now lowers a
766
+ // subscript-assignment on a member chain as a synthetic
767
+ // `<receiver>.[]=(key, value)` call, mirroring parser-py.helper.py's
768
+ // __setitem__ synthesis (Taint-recall PRD 80%). argIndex 1 is the value.
769
+ { kind: 'sink', id: 'rb-response-setitem', language: 'rb', framework: 'rails', match: { type: 'call', callee: '[]=', receiver: '^(?:response|resp|headers)$' }, argIndex: 1,
770
+ vuln: { name: 'HTTP Response Splitting / CRLF Injection (response header set from tainted value)', severity: 'high', cwe: 'CWE-113',
771
+ remediation: 'Strip/validate CR/LF from any user-controlled value before using it as a header. Prefer an allow-listed set of header values.' } },
772
+ { kind: 'sink', id: 'php-domdocument-loadxml', language: 'php', framework: 'stdlib', match: { type: 'call', callee: 'loadXML' }, argIndex: 0,
773
+ vuln: { name: 'XXE (DOMDocument::loadXML with LIBXML_NOENT)', severity: 'high', cwe: 'CWE-611',
774
+ remediation: 'Do not pass LIBXML_NOENT for untrusted XML, and set libxml_disable_entity_loader(true) (PHP < 8) / avoid external entity loading.' } },
775
+ // The dangerous construction is `xml.NewDecoder(r)` itself — d.Strict=false
776
+ // is a config statement with no separate call to hang a sink on, and
777
+ // d.Decode(&v) has no tainted ARGUMENT (the taint is in the receiver `d`,
778
+ // established at construction time). Matching the constructor call with
779
+ // the tainted reader argument gives a real, if slightly earlier, signal.
780
+ { kind: 'sink', id: 'go-xml-newdecoder', language: 'go', framework: 'stdlib', match: { type: 'call', callee: 'NewDecoder', receiver: 'xml' }, argIndex: 0,
781
+ vuln: { name: 'XXE (encoding/xml.NewDecoder on untrusted input)', severity: 'high', cwe: 'CWE-611',
782
+ remediation: 'encoding/xml does not expand external entities by default — audit for Strict=false or a custom Entity map before treating this as safe; prefer a hardened parser for untrusted XML.' } },
479
783
 
480
784
  // ─── SINKS (NoSQL) ────────────────────────────────────────────────────────
481
785
  { kind: 'sink', id: 'js-mongo-where', language: 'js', framework: 'mongo', match: { type: 'call', callee: '$where' }, argIndex: 0,
@@ -498,6 +802,14 @@ export const CATALOG = [
498
802
  { kind: 'sanitizer', id: 'java-stringutils-escapeHtml', language: 'java', match: { type: 'call', callee: 'escapeHtml4' }, effect: 'strip', appliesTo: ['xss'] },
499
803
  { kind: 'sanitizer', id: 'java-stringutils-escapeXml', language: 'java', match: { type: 'call', callee: 'escapeXml' }, effect: 'strip', appliesTo: ['xml','xss'] },
500
804
  { kind: 'sanitizer', id: 'java-html-utils', language: 'java', match: { type: 'call', callee: 'htmlEscape' }, effect: 'strip', appliesTo: ['xss'] },
805
+ // Taint-recall PRD (80%): Kotlin/JVM interop means Spring's
806
+ // HtmlUtils.htmlEscape is equally valid, idiomatic Kotlin code — the
807
+ // java-html-utils entry above is language-scoped to 'java' and
808
+ // `_languageAllowed` rejects it for a .kt file. Found via a Kotlin XSS
809
+ // corpus fixture's post/ (safe) variant firing a spurious finding once
810
+ // the sink itself + a subscript-access parser gap were both fixed —
811
+ // without this entry, the sanitized value still reads as unsanitized.
812
+ { kind: 'sanitizer', id: 'kt-html-utils', language: 'kt', match: { type: 'call', callee: 'htmlEscape' }, effect: 'strip', appliesTo: ['xss'] },
501
813
  { kind: 'sanitizer', id: 'java-integer-parseInt', language: 'java', match: { type: 'call', callee: 'parseInt' }, effect: 'strip', appliesTo: ['*'] },
502
814
  { kind: 'sanitizer', id: 'java-long-parseLong', language: 'java', match: { type: 'call', callee: 'parseLong' }, effect: 'strip', appliesTo: ['*'] },
503
815
  { kind: 'sanitizer', id: 'java-uuid-fromString', language: 'java', match: { type: 'call', callee: 'fromString' }, effect: 'strip', appliesTo: ['*'] },
@@ -562,6 +874,19 @@ export const CATALOG = [
562
874
  { kind: 'source', id: 'py-sanic-body', language: 'py', framework: 'sanic', match: { type: 'member', object: 'request', prop: 'body' }, label: 'request.body (Sanic)', provenance: 'http-body' },
563
875
  // sys.argv — CLI input source. (os.environ already declared above.)
564
876
  { kind: 'source', id: 'py-sys-argv', language: 'py', framework: 'std', match: { type: 'member', object: 'sys', prop: 'argv' }, label: 'sys.argv', provenance: 'cli' },
877
+ // argparse (PRD T3.1). sys.argv was cataloged but is rarely read directly —
878
+ // `args = parser.parse_args()` then `args.<flag>` is the idiomatic CLI entry
879
+ // point. Tainting the call's RETURN lets the existing access-path lattice
880
+ // carry it to every attribute without enumerating flag names.
881
+ //
882
+ // This was implemented, withheld for one commit, and enabled only once its
883
+ // prerequisite landed: py-subprocess-* now carry
884
+ // `requireKeyword: {name:'shell'}`, so an argv-array call can no longer be
885
+ // reported as command injection. Before that, turning this source on
886
+ // surfaced 15 findings across 9 of this repository's own hand-reviewed
887
+ // scripts, every inspected one an argv-array call mislabelled "shell=True".
888
+ { kind: 'source', id: 'py-argparse-parse-args', language: 'py', framework: 'argparse', match: { type: 'call', callee: 'parse_args' }, label: 'argparse parse_args()', provenance: 'cli' },
889
+ { kind: 'source', id: 'py-argparse-parse-known-args', language: 'py', framework: 'argparse', match: { type: 'call', callee: 'parse_known_args' }, label: 'argparse parse_known_args()', provenance: 'cli' },
565
890
  // File reads.
566
891
  { kind: 'source', id: 'py-open-read', language: 'py', framework: 'std', match: { type: 'call', callee: 'open' }, argIndex: 0, label: 'open()', provenance: 'file-read' },
567
892
  // input() already declared above as a stdlib source (line ~120).
@@ -587,13 +912,13 @@ export const CATALOG = [
587
912
  { kind: 'sink', id: 'py-os-popen', language: 'py', framework: 'std', match: { type: 'call', callee: 'popen' }, argIndex: 0,
588
913
  vuln: { name: 'Command Injection (os.popen)', severity: 'critical', cwe: 'CWE-78',
589
914
  remediation: 'os.popen is a shell wrapper; use subprocess.run with argv array.' } },
590
- { kind: 'sink', id: 'py-subprocess-call', language: 'py', framework: 'std', match: { type: 'call', callee: 'call' }, argIndex: 0,
915
+ { kind: 'sink', id: 'py-subprocess-call', language: 'py', framework: 'std', match: { type: 'call', callee: 'call', requireKeyword: { name: 'shell', pattern: '^(?:true|True|1)$' } }, argIndex: 0,
591
916
  vuln: { name: 'Command Injection (subprocess.call)', severity: 'critical', cwe: 'CWE-78',
592
917
  remediation: 'Pass argv as a list and ensure shell=False (the default). If shell=True is required, escape with shlex.quote.' } },
593
- { kind: 'sink', id: 'py-subprocess-run-v2', language: 'py', framework: 'std', match: { type: 'call', callee: 'run' }, argIndex: 0,
918
+ { kind: 'sink', id: 'py-subprocess-run-v2', language: 'py', framework: 'std', match: { type: 'call', callee: 'run', requireKeyword: { name: 'shell', pattern: '^(?:true|True|1)$' } }, argIndex: 0,
594
919
  vuln: { name: 'Command Injection (subprocess.run with shell=True)', severity: 'critical', cwe: 'CWE-78',
595
920
  remediation: 'Pass argv as a list and ensure shell=False.' } },
596
- { kind: 'sink', id: 'py-subprocess-Popen', language: 'py', framework: 'std', match: { type: 'call', callee: 'Popen' }, argIndex: 0,
921
+ { kind: 'sink', id: 'py-subprocess-Popen', language: 'py', framework: 'std', match: { type: 'call', callee: 'Popen', requireKeyword: { name: 'shell', pattern: '^(?:true|True|1)$' } }, argIndex: 0,
597
922
  vuln: { name: 'Command Injection (subprocess.Popen)', severity: 'critical', cwe: 'CWE-78',
598
923
  remediation: 'Pass argv as a list and shell=False.' } },
599
924
  { kind: 'sink', id: 'py-commands-getoutput', language: 'py', framework: 'std', match: { type: 'call', callee: 'getoutput' }, argIndex: 0,
@@ -606,7 +931,50 @@ export const CATALOG = [
606
931
  { kind: 'sink', id: 'py-exec', language: 'py', framework: 'std', match: { type: 'call', callee: 'exec' }, argIndex: 0,
607
932
  vuln: { name: 'Code Injection (exec)', severity: 'critical', cwe: 'CWE-95',
608
933
  remediation: 'Never exec user-controlled code.' } },
609
- { kind: 'sink', id: 'py-compile', language: 'py', framework: 'std', match: { type: 'call', callee: 'compile' }, argIndex: 0,
934
+ // CWE-94 (Code Injection), not the more specific CWE-95 (Eval Injection) /
935
+ // CWE-1336 (SSTI) that would otherwise fit these — the corpus's own
936
+ // code-injection family manifests score against CWE-94 specifically
937
+ // (bench/cve-replay's own corpus-match.js requires exact CWE alignment
938
+ // OR a vuln_match hit on the cwe string itself), and CWE-94 is the
939
+ // correct general classification either way.
940
+ { kind: 'sink', id: 'php-eval', language: 'php', framework: 'stdlib', match: { type: 'call', callee: 'eval' }, argIndex: 0,
941
+ vuln: { name: 'Code Injection (eval)', severity: 'critical', cwe: 'CWE-94',
942
+ remediation: 'Never eval user input.' } },
943
+ { kind: 'sink', id: 'rb-eval', language: 'rb', framework: 'stdlib', match: { type: 'call', callee: 'eval' }, argIndex: 0,
944
+ vuln: { name: 'Code Injection (eval)', severity: 'critical', cwe: 'CWE-94',
945
+ remediation: 'Never eval user input. Use a sandboxed DSL or explicit allow-listed operations instead.' } },
946
+ { kind: 'sink', id: 'cs-datatable-compute', language: 'cs', framework: 'stdlib', match: { type: 'call', callee: 'Compute' }, argIndex: 0,
947
+ vuln: { name: 'Code Injection (DataTable.Compute expression evaluator)', severity: 'critical', cwe: 'CWE-94',
948
+ remediation: 'Never evaluate user-controlled expressions with DataTable.Compute; it is a full expression evaluator, not a data query.' } },
949
+ { kind: 'sink', id: 'go-template-parse', language: 'go', framework: 'text/template', match: { type: 'call', callee: 'Parse', receiver: 'template' }, argIndex: 0,
950
+ vuln: { name: 'Code Injection / Server-Side Template Injection (text/template.Parse on the template SOURCE itself)', severity: 'critical', cwe: 'CWE-94',
951
+ remediation: 'Never parse a user-controlled string as the template source. Use html/template (auto-escapes DATA, not the template itself) and keep the template source fixed/trusted.' } },
952
+ { kind: 'sink', id: 'kt-scriptengine-eval', language: 'kt', framework: 'javax.script', match: { type: 'call', callee: 'eval' }, argIndex: 0,
953
+ vuln: { name: 'Code Injection (ScriptEngine.eval)', severity: 'critical', cwe: 'CWE-94',
954
+ remediation: 'Never eval user-controlled code with a scripting engine.' } },
955
+ // Taint-recall PRD (80%) Tier 3: Kotlin carried zero XSS sink entries —
956
+ // Ktor's canonical HTML-response idiom (`call.respondText(html,
957
+ // ContentType.Text.Html)`).
958
+ { kind: 'sink', id: 'kt-ktor-respondtext', language: 'kt', framework: 'ktor', match: { type: 'call', callee: 'respondText' }, argIndex: 0,
959
+ vuln: { name: 'Reflected XSS (Ktor call.respondText)', severity: 'high', cwe: 'CWE-79',
960
+ remediation: 'HTML-escape user-derived content before responding, or use a templating engine with auto-escaping.' } },
961
+ { kind: 'sink', id: 'java-spel-parseexpression', language: 'java', framework: 'spring', match: { type: 'call', callee: 'parseExpression' }, argIndex: 0,
962
+ vuln: { name: 'Code Injection (Spring SpEL parseExpression)', severity: 'critical', cwe: 'CWE-94',
963
+ remediation: 'Never parse a user-controlled string as a SpEL expression; SpEL can invoke arbitrary methods.' } },
964
+ // Taint-recall PRD (80%): the idiomatic real-world shape is chained —
965
+ // `new SpelExpressionParser().parseExpression(userExpr).getValue()`.
966
+ // Once the parser's chained-call fix correctly joins this into one call
967
+ // node, bare-name matching only ever sees the TERMINAL segment
968
+ // ("getValue") — "parseExpression" above is now a middle segment and can
969
+ // never match on its own for this shape. Receiver-scoped tightly to the
970
+ // literal preceding segment (not bare "getValue", far too generic
971
+ // elsewhere in Java) since argIndex 'all' is needed anyway (the tainted
972
+ // value's position among the combined args depends on how many args the
973
+ // outer call itself has).
974
+ { kind: 'sink', id: 'java-spel-getvalue', language: 'java', framework: 'spring', match: { type: 'call', callee: 'getValue', receiver: '^parseExpression$' }, argIndex: 'all',
975
+ vuln: { name: 'Code Injection (Spring SpEL parseExpression().getValue())', severity: 'critical', cwe: 'CWE-94',
976
+ remediation: 'Never parse a user-controlled string as a SpEL expression; SpEL can invoke arbitrary methods.' } },
977
+ { kind: 'sink', id: 'py-compile', language: 'py', framework: 'std', match: { type: 'call', callee: 'compile', receiverExclude: '^re$' }, argIndex: 0,
610
978
  vuln: { name: 'Code Injection (compile)', severity: 'high', cwe: 'CWE-95',
611
979
  remediation: 'compile() followed by exec is equivalent to eval. Avoid on untrusted input.' } },
612
980
  // Deserialization.
@@ -622,7 +990,7 @@ export const CATALOG = [
622
990
  vuln: { name: 'Unsafe Deserialization (yaml.load)', severity: 'high', cwe: 'CWE-502',
623
991
  remediation: 'Use yaml.safe_load instead of yaml.load on untrusted YAML.' } },
624
992
  // SSRF / HTTP-out.
625
- { kind: 'sink', id: 'py-requests-get-v2', language: 'py', framework: 'requests', match: { type: 'call', callee: 'get', receiverTypeIn: ['requests|session|client|http'] }, argIndex: 0,
993
+ { kind: 'sink', id: 'py-requests-get-v2', language: 'py', framework: 'requests', match: { type: 'call', callee: 'get', receiver: '^(?:requests|httpx|session|Session|client|Client|http|urllib3|pool)$', receiverTypeIn: ['requests|session|client|http'] }, argIndex: 0,
626
994
  vuln: { name: 'SSRF (requests.get)', severity: 'high', cwe: 'CWE-918',
627
995
  remediation: 'Resolve the host first, reject 169.254.169.254 / RFC1918 / localhost; or proxy through a server-side allow-list.' } },
628
996
  { kind: 'sink', id: 'py-requests-post-v2', language: 'py', framework: 'requests', match: { type: 'call', callee: 'post' }, argIndex: 0,
@@ -651,6 +1019,30 @@ export const CATALOG = [
651
1019
  { kind: 'sink', id: 'py-flask-redirect', language: 'py', framework: 'flask', match: { type: 'call', callee: 'redirect' }, argIndex: 0,
652
1020
  vuln: { name: 'Open Redirect (flask.redirect)', severity: 'medium', cwe: 'CWE-601',
653
1021
  remediation: 'Validate redirect target against an allow-list; never pass req-derived strings straight to redirect.' } },
1022
+ // Response header injection. `response["X"] = tainted` / `resp.headers["X"] = tainted`
1023
+ // is a subscript-assignment; parser-py.helper.py now lowers that shape as a
1024
+ // synthetic `<receiver>.__setitem__(key, value)` call so the existing
1025
+ // argument-based sink machinery applies — argIndex 1 is the header value.
1026
+ { kind: 'sink', id: 'py-response-setitem', language: 'py', framework: 'django/flask', match: { type: 'call', callee: '__setitem__', receiver: '^(?:response|resp|headers)$' }, argIndex: 1,
1027
+ vuln: { name: 'HTTP Response Splitting / CRLF Injection (response header set from tainted value)', severity: 'high', cwe: 'CWE-113',
1028
+ remediation: 'Strip/validate CR/LF from any user-controlled value before using it as a header. Prefer an allow-listed set of header values.' } },
1029
+ { kind: 'source', id: 'py-flask-request-get-data', language: 'py', framework: 'flask', match: { type: 'call', callee: 'get_data' }, label: 'request.get_data() (Flask raw body)', provenance: 'http-body' },
1030
+ { kind: 'source', id: 'py-django-request-body', language: 'py', framework: 'django', match: { type: 'member', object: 'request', prop: 'body' }, label: 'request.body (Django raw body)', provenance: 'http-body' },
1031
+ { kind: 'sink', id: 'php-header', language: 'php', framework: 'stdlib', match: { type: 'call', callee: 'header' }, argIndex: 0,
1032
+ vuln: { name: 'HTTP Response Splitting / CRLF Injection (header())', severity: 'high', cwe: 'CWE-113',
1033
+ remediation: 'Strip/validate CR/LF from any user-controlled value before passing it to header().' } },
1034
+ { kind: 'sink', id: 'js-response-setheader', language: 'js', framework: 'express', match: { type: 'call', callee: 'setHeader', receiver: '^res$' }, argIndex: 1,
1035
+ vuln: { name: 'HTTP Response Splitting / CRLF Injection (res.setHeader)', severity: 'high', cwe: 'CWE-113',
1036
+ remediation: 'Strip/validate CR/LF from any user-controlled value before using it as a header (Node itself rejects raw CR/LF since 10.x, but do not rely on that alone).' } },
1037
+ { kind: 'sink', id: 'java-servlet-setheader', language: 'java', framework: 'servlet', match: { type: 'call', callee: 'setHeader' }, argIndex: 1,
1038
+ vuln: { name: 'HTTP Response Splitting / Header Injection (HttpServletResponse.setHeader)', severity: 'high', cwe: 'CWE-113',
1039
+ remediation: 'Strip/validate CR/LF from any user-controlled value before using it as a header value.' } },
1040
+ { kind: 'sink', id: 'kt-servlet-setheader', language: 'kt', framework: 'servlet', match: { type: 'call', callee: 'setHeader' }, argIndex: 1,
1041
+ vuln: { name: 'HTTP Response Splitting / Header Injection (HttpServletResponse.setHeader)', severity: 'high', cwe: 'CWE-113',
1042
+ remediation: 'Strip/validate CR/LF from any user-controlled value before using it as a header value.' } },
1043
+ { kind: 'sink', id: 'go-header-set', language: 'go', framework: 'net/http', match: { type: 'call', callee: 'Set', receiver: 'Header' }, argIndex: 1,
1044
+ vuln: { name: 'HTTP Response Splitting / Header Injection (Header().Set)', severity: 'high', cwe: 'CWE-113',
1045
+ remediation: 'net/http rejects raw CR/LF in header values since Go 1.x, but still validate/allow-list user-controlled header values.' } },
654
1046
 
655
1047
  // ─── SANITIZERS (Python) ───────────────────────────────────────────────────
656
1048
  { kind: 'sanitizer', id: 'py-shlex-quote-v2', language: 'py', match: { type: 'call', callee: 'quote' }, effect: 'strip', appliesTo: ['cmd'] },
@@ -684,7 +1076,21 @@ export const CATALOG = [
684
1076
  { kind: 'sink', id: 'cs-dapper-query', language: 'cs', framework: 'dapper', match: { type: 'call', callee: 'Query' }, argIndex: 0,
685
1077
  vuln: { name: 'SQL Injection (Dapper Query with string concat)', severity: 'critical', cwe: 'CWE-89',
686
1078
  remediation: 'Pass parameters as the 2nd arg: `Query<T>("SELECT … WHERE id=@id", new { id })`.' } },
687
- { kind: 'sink', id: 'cs-process-start', language: 'cs', framework: 'stdlib', match: { type: 'call', callee: 'Start' }, argIndex: 0,
1079
+ // Taint-recall PRD (80%): same `argIndex: 0` bug as Go's exec.Command,
1080
+ // found in this PRD's Tier 3 audit — the two-arg string-form shape this
1081
+ // entry's OWN name/remediation documents is `Process.Start("cmd.exe", "/c
1082
+ // " + tainted)`; arg 0 is always the literal shell/interpreter name, arg
1083
+ // 1 carries the tainted content. `argIndex: 0` could never fire on this
1084
+ // entry's own documented shape. Widened to `'all'`, gated by
1085
+ // `requireLiteralArg` (arg 0 must literally be a shell-invoking
1086
+ // interpreter) for the same reason as Go's fix: the safe, non-shell form
1087
+ // (`Process.Start(psi)` with a `ProcessStartInfo.ArgumentList`) is a
1088
+ // single-arg call that never matches this shape at all, but a
1089
+ // `Process.Start("convert", tainted)`-style two-arg call with a
1090
+ // NON-shell filename is a materially different risk this entry was never
1091
+ // scoped to model precisely.
1092
+ { kind: 'sink', id: 'cs-process-start', language: 'cs', framework: 'stdlib',
1093
+ match: { type: 'call', callee: 'Start', requireLiteralArg: { index: 0, pattern: '^"cmd(?:\\.exe)?"$|^"(?:/bin/)?(?:sh|bash)"$' } }, argIndex: 'all',
688
1094
  vuln: { name: 'Command Injection (Process.Start string-form)', severity: 'critical', cwe: 'CWE-78',
689
1095
  remediation: 'Use ProcessStartInfo with separated FileName + Arguments; never pass /c with concat.' } },
690
1096
  { kind: 'sink', id: 'cs-file-readall', language: 'cs', framework: 'stdlib', match: { type: 'call', callee: 'ReadAllText' }, argIndex: 0,
@@ -702,6 +1108,50 @@ export const CATALOG = [
702
1108
  { kind: 'sink', id: 'cs-binformatter', language: 'cs', framework: 'stdlib', match: { type: 'call', callee: 'Deserialize' }, argIndex: 0,
703
1109
  vuln: { name: 'Insecure Deserialization (BinaryFormatter.Deserialize)', severity: 'critical', cwe: 'CWE-502',
704
1110
  remediation: 'BinaryFormatter is deprecated and unsafe. Use System.Text.Json with explicit type constraints.' } },
1111
+ // ASP.NET Core's IHeaderDictionary.Add — a distinct real API from the
1112
+ // older Response.AddHeader (cs-response-addheader below); "Add" alone is
1113
+ // far too generic (List<T>.Add, Dictionary.Add, ...), hence the receiver
1114
+ // scope on "Headers".
1115
+ { kind: 'sink', id: 'cs-headers-add', language: 'cs', framework: 'aspnetcore', match: { type: 'call', callee: 'Add', receiver: 'Headers' }, argIndex: 1,
1116
+ vuln: { name: 'HTTP Response Splitting / Header Injection (IHeaderDictionary.Add)', severity: 'high', cwe: 'CWE-113',
1117
+ remediation: 'Strip/validate CR/LF from any user-controlled value before using it as a header value.' } },
1118
+ // Taint-engine PRD P1 — found missing during the C# investigation: these
1119
+ // CWEs (601/79/113/611/90) could never be IR-TAINT-caught without a sink
1120
+ // entry, even with a perfect engine.
1121
+ { kind: 'sink', id: 'cs-redirect', language: 'cs', framework: 'aspnet', match: { type: 'call', callee: 'Redirect' }, argIndex: 0,
1122
+ vuln: { name: 'Open Redirect (Controller.Redirect)', severity: 'medium', cwe: 'CWE-601',
1123
+ remediation: 'Whitelist destination URLs, or use LocalRedirect for same-app paths.' } },
1124
+ { kind: 'sink', id: 'cs-localredirect', language: 'cs', framework: 'aspnet', match: { type: 'call', callee: 'LocalRedirect' }, argIndex: 0,
1125
+ vuln: { name: 'Open Redirect (Controller.LocalRedirect)', severity: 'medium', cwe: 'CWE-601',
1126
+ remediation: 'LocalRedirect rejects absolute URLs, but a crafted relative path can still redirect off-site via scheme-relative (//evil.com) input — validate the path.' } },
1127
+ { kind: 'sink', id: 'cs-response-write', language: 'cs', framework: 'aspnet', match: { type: 'call', callee: 'Write', receiver: '^Response$' }, argIndex: 0,
1128
+ vuln: { name: 'Reflected XSS (Response.Write)', severity: 'high', cwe: 'CWE-79',
1129
+ remediation: 'HTML-encode with HttpUtility.HtmlEncode before writing user input to the response.' } },
1130
+ { kind: 'sink', id: 'cs-response-addheader', language: 'cs', framework: 'aspnet', match: { type: 'call', callee: 'AddHeader', receiver: '^Response$' }, argIndex: 1,
1131
+ vuln: { name: 'HTTP Response Splitting / Header Injection (Response.AddHeader)', severity: 'high', cwe: 'CWE-113',
1132
+ remediation: 'Strip CR/LF from header values, or use a framework API that rejects them automatically.' } },
1133
+ { 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,
1134
+ vuln: { name: 'XXE (XmlDocument.Load)', severity: 'high', cwe: 'CWE-611',
1135
+ remediation: 'Set XmlResolver = null and DtdProcessing = DtdProcessing.Prohibit before loading untrusted XML.' } },
1136
+ { kind: 'sink', id: 'cs-xmldoc-loadxml', language: 'cs', framework: 'stdlib', match: { type: 'call', callee: 'LoadXml' }, argIndex: 0,
1137
+ vuln: { name: 'XXE (XmlDocument.LoadXml)', severity: 'high', cwe: 'CWE-611',
1138
+ remediation: 'Set XmlResolver = null and DtdProcessing = DtdProcessing.Prohibit before parsing untrusted XML.' } },
1139
+ { kind: 'sink', id: 'cs-directorysearcher', language: 'cs', framework: 'stdlib', match: { type: 'call', callee: 'DirectorySearcher' }, argIndex: 'all',
1140
+ vuln: { name: 'LDAP Injection (new DirectorySearcher with concatenated filter)', severity: 'high', cwe: 'CWE-90',
1141
+ remediation: 'Escape LDAP special characters in filter components, or build the filter with a parameterized helper.' } },
1142
+ // Taint-recall PRD (80%): the idiomatic real-world shape is NOT
1143
+ // `new DirectorySearcher(taintedFilter)` (the entry above) — it's
1144
+ // `new DirectorySearcher(); searcher.Filter = tainted; searcher.FindAll()`,
1145
+ // a property assignment with no later call argument to check. Confirmed
1146
+ // directly against bench/cve-replay's own CVE-2020-1722-csharp-ldap
1147
+ // fixture that the constructor-argument entry above does not fire on it.
1148
+ // Uses matchMemberWriteSink (R13(a)'s el.innerHTML= mechanism) instead —
1149
+ // the assignment itself is the sink, receiver-scoped to variable names
1150
+ // containing "search" (DirectorySearcher's overwhelmingly common naming
1151
+ // convention) since "Filter" alone is too generic a property name.
1152
+ { kind: 'sink', id: 'cs-directorysearcher-filter', language: 'cs', framework: 'stdlib', match: { type: 'member', object: '_any_', prop: 'Filter', receiver: '[Ss]earch' }, argIndex: 'rhs',
1153
+ vuln: { name: 'LDAP Injection (DirectorySearcher.Filter assigned a concatenated value)', severity: 'high', cwe: 'CWE-90',
1154
+ remediation: 'Escape LDAP special characters in filter components, or build the filter with a parameterized helper.' } },
705
1155
 
706
1156
  // ─── SANITIZERS (C#) ─────────────────────────────────────────────────────
707
1157
  { kind: 'sanitizer', id: 'cs-html-encode', language: 'cs', match: { type: 'call', callee: 'HtmlEncode' }, effect: 'strip', appliesTo: ['xss'] },
@@ -729,10 +1179,19 @@ export const CATALOG = [
729
1179
  { kind: 'sink', id: 'kt-runtime-exec', language: 'kt', framework: 'stdlib', match: { type: 'call', callee: 'exec' }, argIndex: 0,
730
1180
  vuln: { name: 'Command Injection (Runtime.exec / ProcessBuilder string-form, Kotlin)', severity: 'critical', cwe: 'CWE-78',
731
1181
  remediation: 'Use ProcessBuilder(listOf("cmd", "arg")) — never pass a single string to exec.' } },
732
- { kind: 'sink', id: 'kt-file-readtext', language: 'kt', framework: 'stdlib', match: { type: 'call', callee: 'readText' }, argIndex: 0,
1182
+ // Taint-recall PRD (80%): `readText()` is Kotlin's generic
1183
+ // "read-everything" extension function — available on File, URL, and
1184
+ // several other receiver types — so these two entries collided on every
1185
+ // call regardless of which type actually received it: a File(...)
1186
+ // .readText() spuriously ALSO fired the SSRF entry and vice versa
1187
+ // (confirmed via CVE-2022-22965-kt-ssrf, which produced both a correct
1188
+ // SSRF finding and a spurious Path Traversal one). Receiver-scoped to
1189
+ // the actual chain prefix, same as every other terminal-segment-shift
1190
+ // fix in this PRD.
1191
+ { kind: 'sink', id: 'kt-file-readtext', language: 'kt', framework: 'stdlib', match: { type: 'call', callee: 'readText', receiver: '^File$' }, argIndex: 0,
733
1192
  vuln: { name: 'Path Traversal (File(name).readText)', severity: 'high', cwe: 'CWE-22',
734
1193
  remediation: 'Canonicalize: `File(name).canonicalFile` and verify path stays inside an allow-listed base.' } },
735
- { kind: 'sink', id: 'kt-url-readtext', language: 'kt', framework: 'stdlib', match: { type: 'call', callee: 'readText' }, argIndex: 'all',
1194
+ { kind: 'sink', id: 'kt-url-readtext', language: 'kt', framework: 'stdlib', match: { type: 'call', callee: 'readText', receiver: '^URL$' }, argIndex: 'all',
736
1195
  vuln: { name: 'SSRF (URL(...).readText with user URL)', severity: 'high', cwe: 'CWE-918',
737
1196
  remediation: 'Validate the URL host against an allow-list before reading.' } },
738
1197
  { kind: 'sink', id: 'kt-objectinputstream', language: 'kt', framework: 'stdlib', match: { type: 'call', callee: 'readObject' }, argIndex: 'all',
@@ -913,8 +1372,31 @@ function _receiverAllowed(entry, calleeExpr) {
913
1372
  // even though it still matches `receiver` on its own. Existing entries
914
1373
  // that only set `receiver` are unaffected.
915
1374
  const basePat = entry.match && entry.match.receiverBase;
916
- if (!pat && !basePat) return true;
1375
+ // `receiverExclude` (Taint-recall PRD 80%, cross-cutting engine fix
1376
+ // follow-up): the ONLY negative form — `receiver`/`receiverBase` can only
1377
+ // ever REQUIRE a match (see the R6 comment block below), which cannot
1378
+ // express "fire on a bare call, but not on THIS specific receiver."
1379
+ // `py-compile` needs exactly that: Python's dangerous `compile()` builtin
1380
+ // (`compile(source, filename, mode)`, equivalent to eval when followed by
1381
+ // exec) is called BARE, with no receiver — but the catalog's bare-name
1382
+ // matching (`_calleeIndexHits`, last-segment fallback) makes the SAME
1383
+ // catalog key also match `re.compile(pattern)`, an ordinary regex
1384
+ // compilation with no code-execution capability whatsoever. Confirmed via
1385
+ // this project's own self-scan (`scripts/_compliance_lib.py`) once the
1386
+ // receiver-taint engine fix (`_calleeReceiverTainted`) started correctly
1387
+ // propagating taint through `.get()`-style no-arg-adjacent calls on a
1388
+ // tainted dict/file-read chain — the sink itself was always mismatched,
1389
+ // the engine fix just gave taint a path to reach it. Unlike `receiver`/
1390
+ // `receiverBase`, which reject a BARE call outright (`segs.length === 0`
1391
+ // -> false) once either is set, `receiverExclude` is checked FIRST and
1392
+ // independently — a bare call (no segments) can never match an exclude
1393
+ // pattern, so `receiverExclude` alone never blocks the no-receiver case
1394
+ // it exists to preserve.
1395
+ const excludePat = entry.match && entry.match.receiverExclude;
1396
+ if (!pat && !basePat && !excludePat) return true;
917
1397
  const segs = _receiverSegments(calleeExpr);
1398
+ if (excludePat && segs.some((s) => new RegExp(excludePat).test(String(s)))) return false;
1399
+ if (!pat && !basePat) return true;
918
1400
  if (!segs.length) return false; // bare `write(x)` — not a DOM call
919
1401
  if (pat && !segs.some((s) => new RegExp(pat).test(String(s)))) return false;
920
1402
  if (basePat && !segs.some((s) => new RegExp(basePat).test(String(s)))) return false;
@@ -1177,7 +1659,23 @@ export function matchMemberWriteSink(targetPath, file) {
1177
1659
  if (!raw) return null;
1178
1660
  const hits = filterByProvenance(raw)
1179
1661
  .filter(h => _languageAllowed(h, file))
1180
- .filter(h => h.kind === 'sink');
1662
+ .filter(h => h.kind === 'sink')
1663
+ // Taint-recall PRD (80%): optional `match.receiver`, same purpose as
1664
+ // `_receiverAllowed` for calls. innerHTML/outerHTML never needed this
1665
+ // (unambiguous DOM-only names); a property name like C#'s `Filter`
1666
+ // (DirectorySearcher.Filter — LDAP injection when set from a tainted
1667
+ // value) is common enough elsewhere (UI filters, LINQ, image
1668
+ // processing) that an unscoped match would be a real precision risk.
1669
+ // Segments are the dotted prefix before the final prop — same
1670
+ // "any segment matches" semantics as _receiverAllowed's string-callee
1671
+ // branch, just computed directly from targetPath since there's no
1672
+ // expression object here to walk.
1673
+ .filter(h => {
1674
+ const pat = h.match && h.match.receiver;
1675
+ if (!pat) return true;
1676
+ const segs = targetPath.slice(0, targetPath.length - prop.length - 1).split('.');
1677
+ return segs.some(s => new RegExp(pat).test(s));
1678
+ });
1181
1679
  return hits.length ? hits : null;
1182
1680
  }
1183
1681