SAST Fundamentals
A scanner does not read your code the way you do. It builds a model of it — syntax tree, types, call graph, data flow — and then asks questions of that model. Everything a SAST tool can find, and everything it silently cannot, follows from what the model represents.
What this page is for
What SAST actually does
Static analysis parses the source into an abstract syntax tree, resolves names and types, then builds a control-flow graph per function and a call graph across them. A rule is a query over that model — "a call to this API whose argument is derived from this parameter" — not a regular expression over text. Nothing is executed, so a finding is a claim that a path exists, not proof that it runs.
That distinction predicts which classes the tool is good at. Injection, unsafe deserialization and weak crypto are expressible as "untrusted data reached this API", so a dataflow engine models them well. Broken access control, multi-tenant isolation and business-logic flaws are usually a missing check — and no rule can match an absence. The other blind spot is deployment: the scanner reads the repository, so a patched dependency in the container image, a WAF rule in front of the service and a runtime feature flag are all invisible to it.
Treat the output as a ranked reading list, not a defect count. The engine is good at finding the file; you are still the one who decides whether the path is real.
Taint analysis
Taint analysis has three parts and one direction. A source introduces untrusted data — an HTTP parameter, a header, a message body, a file upload. A sink is an API that gives the value meaning: a query executor, a shell, an HTML parser, a deserializer. Between them the value propagates through assignments, returns, collection inserts and string concatenation. A sanitizer is a call the engine knows removes the danger — a numeric parse, an allowlist check, a bind.
Two properties decide how much a given engine can see. Intraprocedural analysis follows the value inside one function; interprocedural analysis follows it across the call graph. Semgrep CE is single-file by design, so a value that enters in a controller and reaches a sink in a repository class is invisible to it unless the rule is written for the intermediate shape. CodeQL builds a database with cross-function dataflow, which is why it costs a compile and a query run.
// ReportController.java — request parameter is the source
String name = request.getParameter("name");
String sql = "SELECT id, email FROM users WHERE name = '" + name + "'";
ResultSet rs = stmt.executeQuery(sql);// ReportController.java — request parameter is the source
String name = request.getParameter("name");
String sql = "SELECT id, email FROM users WHERE name = '" + name + "'";
ResultSet rs = stmt.executeQuery(sql);// ReportController.java — the value is bound, never parsed as syntax
String sql = "SELECT id, email FROM users WHERE name = ?";
try (PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setString(1, name);
return ps.executeQuery();
}// ReportController.java — the value is bound, never parsed as syntax
String sql = "SELECT id, email FROM users WHERE name = ?";
try (PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setString(1, name);
return ps.executeQuery();
}The same path written as a taint rule. Note the sanitizer entry: without it, every numeric parse in the codebase looks like a bypass, and the rule's precision collapses.
rules:
- id: java-sql-string-concat
languages: [java]
message: Request parameter reaches a SQL string built by concatenation.
severity: ERROR
mode: taint
pattern-sources:
- pattern: (HttpServletRequest $R).getParameter(...)
pattern-sanitizers:
- pattern: Integer.parseInt(...)
pattern-sinks:
- pattern: (Statement $S).executeQuery(...)Taint analysis - source, propagation, sink, sanitizer
Scroll sideways to see the whole diagram
Top-down and bottom-up
Top-down
Start at the entry points the framework knows about — controllers, route tables, message consumers, scheduled jobs, CLI commands — and follow each input into the application.
Finds the paths the authors intended, including the ones that cross three layers and a queue. Also the fastest way to audit authentication and authorisation, because the entry point is where the check should be.
Bottom-up
Start at the sinks — the greps below — and walk backwards to ask who can reach this call, and with what.
Finds the helper nobody wired to a route yet, the sink reached through reflection or a template, and the second caller added last quarter. Cheap, mechanical, and it scales to a repository you have never seen.
Run both. A sink that bottom-up finds and top-down cannot reach is usually dead code or a job with no authentication — worth knowing either way. A path that both reach is a finding you can file without further argument. In practice, spend the first hour bottom-up to build the sink inventory and the rest of the day top-down through the entry points that matter, because that is where access control lives.
Dangerous sinks
These are the bottom-up searches. Three flags do the work: -r recurses, -n prints line numbers so a finding is addressable, and -E switches to extended regular expressions so | means alternation. The trailing . is the path operand — without it GNU grep waits on stdin and BSD grep errors, so a search that "finds nothing" is often a search that never ran.
# SQL — CWE-89. -E so | is alternation, -n for line numbers, . is the path operand.
grep -rnE "executeQuery|executeUpdate|createNativeQuery|prepareCall" --include="*.java" .
grep -rnE "cursor\.execute|\.raw\(|RawSQL|extra\(" --include="*.py" .
grep -rnE "\.query\(|\.raw\(|sequelize\.query" --include="*.js" --include="*.ts" .
grep -rnE "mysqli_query|->query\(|PDO::query" --include="*.php" .
# Android SQLite — rawQuery/execSQL are the platform API, not JDBC. Same defect, different driver.
grep -rnE "rawQuery|execSQL" --include="*.java" --include="*.kt" .# Command execution — CWE-78
grep -rnE "getRuntime|ProcessBuilder" --include="*.java" .
grep -rnE "os\.system|subprocess\.(run|call|Popen)|os\.popen" --include="*.py" .
grep -rnE "child_process|execSync|spawnSync" --include="*.js" --include="*.ts" .
grep -rnE "shell_exec|passthru|proc_open|system\(" --include="*.php" .
# XSS — CWE-79. The sink is where the value is handed to an HTML parser.
grep -rnE "innerHTML|outerHTML|document\.write|insertAdjacentHTML" --include="*.js" --include="*.ts" .
grep -rnE "dangerouslySetInnerHTML|v-html" --include="*.jsx" --include="*.tsx" --include="*.vue" .
grep -rnE "\|safe|Markup\(" --include="*.html" --include="*.jinja" .# Deserialization — CWE-502
grep -rnE "ObjectInputStream|readObject|XMLDecoder|XStream" --include="*.java" .
grep -rnE "pickle\.loads|yaml\.load" --include="*.py" .
grep -rnE "unserialize" --include="*.php" .
grep -rnE "BinaryFormatter|TypeNameHandling" --include="*.cs" .
# Path traversal — CWE-22
grep -rnE "new File\(|Paths\.get|FileInputStream|Files\.copy" --include="*.java" .
grep -rnE "path\.join|fs\.(readFile|createReadStream|writeFile)" --include="*.js" --include="*.ts" .
grep -rnE "send_file|os\.path\.join|open\(" --include="*.py" .A hit is a lead, not a finding. The table is what each sink actually means, so the next step — read the call site, check the argument, find the route — starts from the right question.
| Sink | CWE | Language | What it means |
|---|---|---|---|
| Statement.executeQuery with a concatenated string | CWE-89 | Java / JDBC | The value is parsed as SQL syntax, so quotes and comments change the query. |
| SQLiteDatabase.rawQuery | CWE-89 | Android, Java or Kotlin | Platform SQLite API on the device. Not JDBC — same concatenation defect, different driver and different escaping rules. |
| Runtime.getRuntime().exec or ProcessBuilder with one string | CWE-78 | Java | A single command string is split by a shell, so separators in the value become commands. |
| innerHTML and dangerouslySetInnerHTML | CWE-79 | JavaScript, React | The value is handed to the HTML parser. Encoding has to happen at the output context, not at input. |
| pickle.loads and ObjectInputStream.readObject | CWE-502 | Python, Java | The payload names the class, so the attacker chooses which code path runs. |
| yaml.load with an explicit Loader | CWE-502 | Python | PyYAML 6.0 made Loader mandatory, so the finding is the loader choice, not the call itself. |
| path.join, Paths.get or new File on request data | CWE-22 | Node, Java | Dot-dot segments escape the intended root unless the result is resolved and prefix-checked. |
| fs.readFile or send_file with a request path | CWE-22 | Node, Python | Reads whatever the process account can read, which is often the config and the keys. |
The escaping bug that made these greps useless
"a\|b" inside a JavaScript template literal loses the backslash, because \| is not an escape sequence — the command that runs is grep -rn "a|b". In basic regex a bare pipe is a literal character, so the search looks for one string containing a pipe and matches nothing, quietly. Use -E with a bare |, and double any other backslash the pattern needs.
Triage and prioritisation
The first useful act on a scan report is to collapse it. Group by rule and file: a rule that fires 200 times in a generated client is one decision, not 200 findings, and a rule that fires twice in an authentication filter is two. Read the grouped view before you open a single result.
Then ask three questions of every group. Is it reachable from a route, job or consumer an attacker can touch? Is there an authorisation check in front of that path, and does it constrain the object or only the route? What is the data class at the sink — public content, tenant data, credentials? The answers sort the report into prove now, ask the owner, and close as unreachable with a reason recorded.
# Group findings by rule and file before reading any of them.
jq -r '.runs[].results[] | [.ruleId, (.locations[0].physicalLocation.artifactLocation.uri // "?")] | @tsv' results.sarif | sort | uniq -c | sort -rn | head -40| Check | Expected | Where to look |
|---|---|---|
| The scan covers every first-party language in the repo | One rule pack per language present, with vendored, generated and test paths excluded in config rather than by hand | CI job definition and the scan log |
| Each high finding names a route and a sink | The handler that reaches the sink is linked in the finding; a sink with no route is marked unreachable, never closed silently | The triage record |
| A sample of findings was reproduced by reading the path | Three to five findings confirmed by hand, quoting the source line and the sink line with file and line number | Review notes attached to the ticket |
| Precision was measured, not assumed | A labelled sample or an OWASP Benchmark run recorded per rule class, with the false-positive count stated alongside the true positives | The metrics file the programme keeps |
The limits of SAST
Three techniques sit behind the reports you will read, and they fail differently. Pattern and semantic matching looks for known shapes — a call, an annotation, an assignment — and is fast and deterministic, but it has no reach across functions and no idea what happens to the value later. Taint and dataflow analysis follows the value, which is why it finds real chains; it depends entirely on models for your frameworks, so a wrapper class or a custom ORM becomes a blind spot. AI-assisted review is the 2026 addition: useful for "this looks wrong" on logic the rules cannot express, non-deterministic between runs, and with recall nobody has convincingly measured.
This is also why no honest false-positive figure exists for SAST as a category. Precision varies by rule class: a hardcoded-secret rule and a taint rule aimed at a custom data layer have nothing in common, and vendor numbers are self-reported on their own benchmarks. Measure it yourself — run the tool against OWASP Benchmark for a comparable score, then label fifty findings from your own repository and count. That second number is the one that tells you how much of the report to trust.
# Two engines, one tree: the diff between the SARIF files is the interesting part.
semgrep --config p/java --sarif --output semgrep.sarif src/
codeql database analyze db codeql/java-queries:codeql-suites/java-security-extended.qls --format=sarif-latest --output codeql.sarifA clean scan is not a clean codebase
Every engine has rule gaps: an internal wrapper, a language it does not parse, a framework the models do not know. A zero-finding report on a service with forty routes and no authorisation tests is evidence about the rules, not about the service. Record which languages and frameworks the scan actually covered before you report a clean result.
Reachability is the hard part
Most false positives are not wrong about the code — they are wrong about who can reach it. A sink behind an admin-only route, a disabled feature flag or a test fixture is a real sink with no attacker. That is a triage decision to record, not a scanner bug to argue about.
Evidence Collection
A SAST finding is a claim about a path. Capture enough to let someone else walk it:
The path, quoted: Source line, every assignment that carries the value, and the sink line — each with file, line number and the commit SHA the scan ran against.
The rule and its version:
Rule id plus pack version (java-sql-string-concat at 1.4.0, not "Semgrep said so"). A rule changes between releases, and the finding has to be reproducible against the version you ran.
Why the value is attacker-controlled: The route, the parameter name and the authentication level required. A source that only an administrator can reach is a different severity from one on a public endpoint.
False Positive Identification
- The sanitizer was on the path: Taint stops at a modelled sanitizer. Before filing, check the value did not pass through a parser, an allowlist check or a parameter-binding call between source and sink.
- The source is not attacker-controlled: Configuration values, constants and database columns the user cannot write are not sources. A finding built on one is a hypothesis, not a vulnerability.
- The code is unreachable: Dead handlers, disabled feature flags and test-only routes are common in long-lived repositories. Prove reachability or downgrade the finding — do not close it without a reason recorded.
SAST Programme Checklist
Coverage
- ☐ Every first-party language has a rule pack; the gaps are written down rather than discovered later.
- ☐ Generated code, vendored dependencies and fixtures are excluded by configuration, not by filtering the results afterwards.
- ☐ The scan runs on the commit that deploys, and on the diff for pull requests.
Rule quality
- ☐ Custom rules carry a test file with a positive and a negative case.
- ☐ Every rule that fires regularly has an owner and a documented triage decision.
- ☐ A rule with a high false-positive rate is tuned or retired — it is never left to erode trust in the report.
Triage
- ☐ Findings are deduplicated by sink instance, so one root cause is one ticket.
- ☐ Severity accounts for reachability and authentication, not just the CWE name.
- ☐ A baseline is recorded so the next scan reports new findings rather than the same backlog.
Remediation & Defense
Fix the path, then prove the path is gone — a rule suppression is not a fix.
Break the path, not the string
- • Parameterise the query, drop the shell, encode at the output context. Escaping a value while still concatenating it into an interpreter is a mitigation that fails on the next encoding.
Suppress with a reason
- • An inline suppression needs a comment naming the control that makes it safe and a date. Unattributed suppressions accumulate into a blind spot.
Regression test the sink
- • A test that sends the payload to the handler and asserts the safe behaviour — not a test that asserts the scanner no longer reports it.
SAST Practice Targets
Deliberately vulnerable applications with published ground truth, so you can compare what a scanner reports against what is actually there.
Related Source Code Review Guides
SAST and Scanning Tools
Semgrep, CodeQL, SonarQube and secrets scanning — editions, licences and CI wiring.
Triage and Remediation
What a reviewer hands back: severity, evidence, developer handoff and regression tests.
Java and Spring Security
JDBC injection, deserialization filters and the Spring-specific sink list.
SQL Injection
The black-box view of the class the SQL sink search is looking for.
Secure Architecture
Where trust boundaries belong, so a review knows which crossings to audit.