@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.
- package/CHANGELOG.md +204 -0
- package/dist/113.index.js +2 -2
- package/dist/178.index.js +1 -1
- package/dist/384.index.js +1 -1
- package/dist/435.index.js +29 -1
- package/dist/526.index.js +2 -2
- package/dist/637.index.js +1 -1
- package/dist/agentic-security.mjs +14 -14
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +10 -6
- package/src/dataflow/CLAUDE.md +30 -0
- package/src/dataflow/catalog.js +512 -14
- package/src/dataflow/engine.js +275 -27
- package/src/dataflow/summaries.js +30 -5
- package/src/engine.js +512 -120
- package/src/ir/CLAUDE.md +20 -5
- package/src/ir/balanced-call.js +11 -1
- package/src/ir/callgraph.js +34 -0
- package/src/ir/parser-cs.js +55 -6
- package/src/ir/parser-go.js +106 -2
- package/src/ir/parser-java.js +111 -10
- package/src/ir/parser-js.js +40 -0
- package/src/ir/parser-kt.js +194 -10
- package/src/ir/parser-php.js +108 -6
- package/src/ir/parser-py.helper.py +199 -10
- package/src/ir/parser-rb.js +405 -31
- package/src/mcp/tools.js +29 -1
- package/src/posture/accuracy-scorecard.js +103 -0
- package/src/runScan.js +5 -2
- package/src/sast/CLAUDE.md +1 -1
- package/src/sast/_auth-signals.js +141 -0
- package/src/sast/_comment-strip.js +80 -13
- package/src/sast/codegen-sink.js +110 -0
- package/src/sast/convention-deviation.js +235 -0
- package/src/sast/fastapi-hardening.js +45 -6
- package/src/sast/file-upload.js +29 -1
- package/src/sast/ownership-authz.js +245 -0
- package/src/sast/php.js +12 -2
- package/src/sast/rate-limit.js +2 -0
- package/src/sast/rbac-consistency.js +1 -1
- package/src/sast/redirect-toctou.js +167 -0
- package/src/sast/resource-exhaustion.js +217 -0
- package/src/sast/sibling-guard.js +176 -0
- package/src/sast/zip-slip.js +53 -2
package/src/dataflow/catalog.js
CHANGED
|
@@ -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
|
-
|
|
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'
|
|
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'
|
|
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'
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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' },
|
|
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
|
-
|
|
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
|
|