Code Security (SAST)

SAST False Positives and Other Scan Problems: A Troubleshooting Guide

Most failed SAST rollouts don't fail because the scanner is bad. They fail because the first scan returns a wall of findings, nobody owns triage, and within two sprints the job is either muted or ignored. Almost every symptom traces back to one of five causes: no baseline, unstable suppressions, an untaught taint model, an incomplete scan, or a gate scoped wrong.

This guide works symptom by symptom. Match what you're seeing, read the likely cause, apply the fix. It assumes you already know roughly what static application security testing does — if not, the static analysis guide maps how SAST differs from a linter or a type checker.

Symptom: the first scan returns hundreds of findings

What you see. You enable the scanner on an existing repo and it reports a backlog large enough that no one reads it.

Cause. This is expected behaviour, not a malfunction. The scanner is evaluating your entire history of accumulated code against every rule it has, including informational severities, test fixtures, generated files, and vendored dependencies. There is no such thing as a clean first scan on a mature codebase.

Fix — baseline, then gate forward.

  1. Run the full scan once and record the result as a baseline. Most scanners support a baseline file or a "compare to ref" mode; commit whatever artefact yours produces.
  2. Gate only on new findings introduced by the change under review. The backlog becomes debt to schedule, not a blocker on unrelated work.
  3. Scope the rule set. Start with high-confidence, high-severity categories — injection, hardcoded secrets, unsafe deserialization — and switch on the rest deliberately.
  4. Exclude what isn't yours: vendored directories, generated code, third-party bundles. Dependency vulnerabilities are a different technique (software composition analysis) and shouldn't be triaged in the same queue.

Symptom: the same finding keeps coming back after you suppress it

What you see. A developer marks a finding as a false positive, and it reappears on the next PR — sometimes several times, as if it were new.

Cause. Findings are identified by a fingerprint. If that fingerprint includes the line number or surrounding whitespace, any edit above the code re-issues the finding as a fresh one. The other common cause is suppression state living somewhere disposable — a scanner dashboard that resets, or a baseline file that isn't committed.

Fix. Put suppressions in the source, next to the code, with a reason, and keep any baseline artefact under version control so it moves with the branch. Every scanner has its own comment syntax; the shape that survives review is the same everywhere:

# Reviewed: `table` is not user input - it is chosen from a fixed allowlist
# above, and the id is parameterized. Ticket SEC-412.

Two rules keep suppressions from rotting into blind spots: an anonymous suppression is never approved, and suppressions are reviewed on a schedule, since the code they justified will change.

Symptom: the scanner flags code you are certain is safe

What you see. A genuine false positive — the tool reports untrusted data reaching a dangerous sink, but you can see the input is validated.

Cause. SAST works by tracing data from a source (request parameter, form field, file read) to a sink (SQL query, shell command, HTML response) and asking whether anything on the path sanitizes it. The tool only recognizes sanitizers it knows about. Wrap your escaping in a helper function, hide the flow behind a framework abstraction, pass the value through a dictionary or a dynamically dispatched call, and the analysis loses the thread. It then errs the way a security tool should: it reports.

Fix — teach the tool, then suppress what's left. Take this classic finding:

def get_user(request, conn):
    user_id = request.args.get("id")
    query = f"SELECT * FROM users WHERE id = {user_id}"
    return conn.execute(query).fetchall()

That one is a true positive — string interpolation into SQL. Parameterizing it removes the finding and the vulnerability at once:

def get_user(request, conn):
    user_id = request.args.get("id")
    return conn.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchall()

The false-positive version is the same code with your own safe_id() wrapper in the middle. The durable fix is not a suppression on every call site — it is to declare the wrapper as a sanitizer in your scanner's configuration, so the whole class of findings resolves at once. Most SAST engines expose some form of custom rule or taint-model configuration for exactly this; check what yours calls it before you write your fiftieth inline suppression.

Before you file anything as a false positive, run the triage question in order: Is the source genuinely untrusted? Does the value actually reach the sink on some path? Does anything on that path neutralize it for this sink? HTML escaping does not make a value safe for a shell command.

Symptom: the scanner missed something you know is there

What you see. A vulnerability you planted or discovered later never appeared in a scan.

Cause. False negatives are usually mechanical rather than analytical. The files were excluded by a path filter. The language or framework isn't fully supported. For compiled languages, the scanner needs a working build to resolve symbols, and the build silently failed inside the scan step. Or the analysis hit a parse error and skipped the file.

Fix. Read the scan log, not just the findings list. Confirm the file and line counts analyzed match roughly what's in the repo, and treat parse errors and skipped files as build failures rather than noise. Then check your exclusion patterns for over-reach — a broad **/test/** filter also hides production code in a directory that happens to be named test.

Set expectations honestly while you're here: no static analyzer finds every vulnerability. Deciding non-trivial runtime properties from source alone is impossible in general, so every tool trades false positives against false negatives. SAST is a layer, not a guarantee.

Symptom: the scan is too slow for CI

What you see. The security job doubles pipeline time, or times out on a monorepo.

Cause. Deep interprocedural analysis is expensive, and the default is often a full scan of everything on every push.

Fix — split the schedule. Scan the diff on pull requests for fast feedback, and run the full analysis on a nightly job where a long runtime is free. Cache whatever your scanner caches between runs, and use path filters so a docs-only change doesn't trigger a deep scan.

name: security-scan
on:
  pull_request:
  schedule:
    - cron: "0 3 * * *"

jobs:
  sast:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - name: Scan changed code on pull requests
        if: github.event_name == 'pull_request'
        run: your-scanner scan --baseline-ref "origin/${{ github.base_ref }}"
      - name: Full scan nightly
        if: github.event_name == 'schedule'
        run: your-scanner scan --all

Flag names differ per tool — check your scanner's documentation for its diff and baseline options. fetch-depth: 0 matters: diff scanning needs the base commit present, and the default shallow checkout doesn't have it. The same layering principles that keep linting fast apply here, and the linting in CI guide covers the pipeline mechanics in full.

Symptom: the gate blocks everything, so people bypass it

What you see. Merges stall on findings unrelated to the change, and the team learns to click the override.

Cause. The gate is scoped to the codebase rather than to the diff, or it fails on every severity including informational.

Fix. Fail the build on new findings at or above a stated severity; report everything else as a warning or a dashboard item. Route findings to the code owner automatically instead of a shared queue nobody owns, and give the team a documented path to dispute one. A gate people can satisfy is enforced; a gate people route around is theatre. Choosing which checks deserve blocking status at all is the wider question covered in code quality tools.

FAQ

Why does my SAST scanner report so many false positives?

Because it approximates. Taint analysis has to guess how data moves through abstractions it can't fully resolve, and a security tool is designed to err toward reporting. Custom sanitizer wrappers, framework magic, and dynamic dispatch are the usual triggers — teaching the tool your sanitizers removes whole classes of them at once.

How do I tell a false positive from a real vulnerability?

Follow the data path the tool reports. Confirm the source is genuinely untrusted, that the value reaches the sink on some real path, and that nothing neutralizes it for that specific sink. If all three hold, it's a true positive regardless of how unlikely the path feels.

Should I suppress a finding or fix the code?

Fix it when the fix is cheap and safe — parameterizing a query is faster than arguing about it. Suppress only when you've confirmed the finding is wrong, and always with a written justification and a ticket reference. Anonymous suppressions become permanent blind spots.

Is it safe to ignore low-severity SAST findings?

Ignoring and deprioritizing are different things. Keep low-severity findings visible in a backlog rather than deleting the rules, so a category that later matters isn't invisible. Just don't gate merges on them.

Do I still need SAST if I already run a linter?

They catch different things. A linter matches local patterns within a file; SAST traces data flow across functions and files to find whether untrusted input can reach a dangerous operation. Neither substitutes for the other.


Once your scanner is producing findings people act on, the remaining question is whether it's the right scanner for your languages, your pipeline, and your budget. Decide that on stated criteria rather than defaults: see how the leading SAST scanners stack up on Lintense, where every comparison shows exactly what's being judged.

Comments are disabled for this article.