SAST scanner

Taint Analysis

One question, asked deterministically: can attacker-controlled data reach a dangerous operation without passing through a sanitizer? Semgrep answers it with isitsecure's own rule packs, instantly and for free, and the LLM reviewer covers what the rules cannot name.

SASTHigh – CriticalInjection risksemgrep_taint · confidence 0.85

Source to sink, with and without a sanitizer

Three moving parts. A source is where attacker-controlled data enters (searchParams.get, req.query, location.hash). A sink is an operation that must never see raw input (sql.unsafe, innerHTML, fetch, exec). A sanitizer is what makes the data safe on the way (a parameterized query, an escaper, an allow-list). Taint analysis follows the data line by line and reports only when it arrives at a sink untouched.

app/api/users/route.ts 1 const id = searchParams.get("id") 2 const q = `SELECT * FROM users WHERE id = ${id}` 3 const rows = await sql.unsafe(q) SOURCE SINK taint: id → q (template literal keeps it tainted) the same route, parameterized 1 const id = searchParams.get("id") 2 const rows = await sql`SELECT * FROM users WHERE id = ${id}` SOURCE SANITIZER sql`…${id}` sends id as a bound parameter, never as SQL text. Taint ends here. FINDING · injection_risk · critical semgrep_taint · confidence 0.85 · route.ts:3 sql.unsafe(q) NOT FLAGGED the rule names the parameterized query as a sanitizer sources searchParams.get · req.query · location.hash sinks sql.unsafe · innerHTML · fetch · writeFile · exec sanitizers parameterized query · escaper · allow-list

1The rule knows searchParams.get() is a Next.js request value, so id is tainted: the caller chose it.

2Line 2 builds q from id. Interpolating a tainted value into a string keeps the result tainted, so the taint moves with it.

3sql.unsafe(q) is a catalogued sink for the postgres library. Tainted data arrived with nothing in between, so a critical finding is raised at line 3, deterministic and the same on every run.

4The fix keeps the same source. The tagged template sql`…${id}` is a parameterized query, which the rule recognises as a sanitizer: the flow stops there and nothing is flagged.

5Every rule is this triple. Sources come from the framework, sinks from the library, sanitizers from the library too, which is why a rule written once applies to every app on that stack.

A deterministic floor beneath the LLM

Before this layer, every injection verdict (SQLi, XSS, SSRF, path traversal, command injection) came from the LLM code reviewer alone: approximate, non-deterministic, paid per token. A spike on test-app showed what a small set of tuned rules could take off its plate.

LLM code reviewer reads the route, reasons about data flow in its head carries ALL injection detection findings per run: 6 · 9 · 7 non-deterministic · tokens · seconds to minutes business-logic flaws libraries no rule has catalogued yet the nuanced long tail Semgrep taint floor · semgrep_taint shells out to the semgrep binary with our rule packs mechanical source → sink injections SQLi · XSS · SSRF · path · cmdi · SSTI deterministic · instant · $0 · same every run dedup: a finding both layers raise counts once Spike: Semgrep on test-app (27 files) p/typescript · p/javascript · p/security-audit 0 findings ~50 lines of stack-tuned rules 11 findings · every class 6 SQLi · DOM XSS · reflected XSS · SSRF · path traversal cmdi: 0 (none in the app, no false positive) Benchmark gate · sast-injection fixtures python benchmarks/run_benchmarks.py sast-injection 46/46 recall · 0 FP JS/TS · Python · Java · Kotlin fixtures with known source→sink bugs

1The starting point: regexes flag injection-looking files and the LLM reviewer decides. It works, but the count wobbles from run to run, every verdict costs tokens, and it is sometimes wrong in both directions.

2Off-the-shelf Semgrep packs found nothing. They scanned all 27 files cleanly and missed every blatant injection, because stock packs do not know this stack's sinks, such as the postgres library's .unsafe().

3About 50 lines of tuned rules found 11. Six SQLi in db.ts, DOM XSS from location.hash, reflected XSS and SSRF from searchParams.get, and a path-traversal upload. The command-injection rule fired nowhere, correctly. One spot was double-labelled (SSRF and reflected XSS), a real bug counted twice.

4So the design keeps the LLM and puts a floor under it. Semgrep takes the mechanical source-to-sink cases on catalogued stacks; the LLM keeps business logic, uncatalogued libraries and nuance. Dedup collapses anything both raise.

5Detection changes ship only through the benchmark. On the sast-injection fixture set the layer scores 46/46 recall with 0 false positives across JS/TS, Python, Java and Kotlin.

stock packs
0

findings on test-app from p/typescript, p/javascript, p/security-audit

tuned rules
11

findings from about 50 lines, covering every injection class present

benchmark
46/46

recall, 0 false positives, on the sast-injection fixtures

Rule packs are per stack, not per app

A rule names a library API, never an app's own code. searchParams.get() is Next.js, sql.unsafe() is the postgres library, writeFile is Node's fs. Written once, the rule catches the bug in every app on that stack. The analyzer picks the packs to load from the languages it finds in the clone; there is no configuration.

Cloned repo extensions seen on disk .ts .tsx .py no .java .kt .go _RULE_PACKS registry JS/TS injection-js.yaml Python injection-python.yaml Java injection-java.yaml Kotlin injection-kotlin.yaml Go injection-go.yaml What each pack catches JS/TS SQLi · reflected XSS · DOM XSS · SSRF · path · cmdi Python (Flask / Django sources) SQLi · cmdi · SSRF · path · SSTI Java · Kotlin (Spring MVC / servlet sources) SQLi · cmdi · SSRF · path Go SQLi · template XSS · cmdi · SSRF · path · redirect A rule names the library, so it fits every app that uses it searchParams.get() → Next.js source sql.unsafe() → postgres library sink writeFile(path.join)→ Node fs sink req.query / req.body → Express source $queryRawUnsafe → Prisma sink cursor.execute(f"…") → DB-API sink App on a library with no rule yet some-orm.rawQuery(`… ${id}`) → no sink catalogued LLM reviewer backstop falls through; this is the point of the layering

1The analyzer walks the clone (skipping node_modules and .git) and notes which file extensions exist. Here: TypeScript and Python.

2Packs are selected by language present. The JS/TS and Python packs load; Java, Kotlin and Go are skipped because nothing on disk would match them. A JS-only repo would load one pack.

3Each pack covers the injection classes that stack actually exposes. Sources are the framework's request objects; sinks are the library calls that take raw strings.

4Rules name library APIs, never application code. A rule for the postgres.unsafe sink catches injection in any app that uses that library, which is how Semgrep, CodeQL and Snyk packs scale too.

5An app built on a library no pack has catalogued gets no deterministic finding for it. That case falls through to the LLM reviewer, which is why the LLM layer stays.

The intra-file limit, and why sink rules recover most of it

OSS Semgrep tracks taint within one file. A route that passes user input to a helper in another file, where the helper reaches the sink, is a path it cannot follow (inter-file taint is Semgrep Pro). In practice the sink itself usually gives the game away.

app/api/search/route.ts 1 const q = searchParams.get("q") 2 const rows = await findUsers(q) SOURCE q leaves the file through a call file boundary: OSS taint stops lib/db.ts 7 export async function findUsers(q) { 8 return sql.unsafe(`… name LIKE '${q}'`) SINK sink rule: unsafe() with interpolation FINDING · db.ts:8 · needs no taint path an interpolated sql.unsafe() is essentially always wrong The cross-file path itself → LLM reviewer route → helper → sink is only tracked by Semgrep Pro

1The route reads q from the request and hands it to findUsers(), which lives in another file.

2The taint rule follows q to the call and stops at the file boundary. OSS Semgrep does not cross files, so no source-to-sink flow is reported from this side.

3The packs also carry sink-focused rules that need no source. sql.unsafe() with an interpolated string is flagged on its own in db.ts, so the bug is still reported, at the line that must change.

4What is lost is the cross-file path itself, which matters when a helper reaches a sink in a shape no sink rule names. That case falls back to the LLM reviewer; it is the spike's lesson that flagging the sink recovers most of the value without Semgrep Pro.

What the rule sees

// BAD: user input interpolated into a raw SQL query (flagged: critical)
const id = searchParams.get("id");
const rows = await sql.unsafe(`SELECT * FROM users WHERE id = ${id}`);

// BAD: user-controlled URL fetched server-side (flagged: high, SSRF)
const target = searchParams.get("url");
const res = await fetch(target);

Findings carry scanner_name = "semgrep_taint", confidence = 0.85 and category injection_risk, then pass through the same triage, dedup, plain-English and grading pipeline as every other scanner's.

Graceful degradation and known limits

Best-effort and self-contained

The analyzer needs the semgrep binary from the optional extra (pip install -e ".[taint]", or an isolated pipx install semgrep; only the binary on PATH matters). Without it the layer no-ops and returns nothing, and the LLM reviewer runs exactly as before. A Semgrep crash or timeout never fails the scan: it logs and returns no findings, and the subprocess is always killed and reaped even if the scan is cancelled. It runs only when the repo holds files a pack covers, ships its own rules, and makes no network calls (--metrics off).

LimitWhat happens instead
Intra-file onlyA route → helper → sink path across files is not tracked; the sink rule in the helper file usually still fires, and the path falls to the LLM reviewer.
LanguagesJS/TS, Python, Java, Kotlin and Go packs ship; other stacks (Ruby, Rust, ...) have no taint pack. Python, Java and Kotlin server-side XSS (template autoescaping nuance) is not yet covered, and Go covers only the template.HTML-style safe-string cast.
Python sourcesFlask and Django request.*. FastAPI endpoint parameters are not tracked, so FastAPI cmdi/SSRF/path/SSTI fall to the LLM; string-built SQL is still caught on any stack.
Java and Kotlin sourcesSpring MVC and servlet (@RequestParam, @PathVariable, getParameter). JAX-RS and @RequestBody fields fall to the LLM. Concatenated SQL on unambiguous DB verbs is caught source-free; generic verbs need a recognised Spring source.
Per-stack catalogueA library with no rule produces no deterministic finding; the LLM backstops it.

No configuration. The packs live in isitsecure/engine/code_analysis/semgrep_rules/; a new one is registered in _RULE_PACKS in semgrep_analyzer.py.