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.
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.
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.
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.
findings on test-app from p/typescript, p/javascript, p/security-audit
findings from about 50 lines, covering every injection class present
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.
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.
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);// GOOD: parameterized query, id travels as a bound value, never as SQL
const id = searchParams.get("id");
const rows = await sql`SELECT * FROM users WHERE id = ${id}`;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
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).
| Limit | What happens instead |
|---|---|
| Intra-file only | A 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. |
| Languages | JS/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 sources | Flask 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 sources | Spring 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 catalogue | A 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.