Reducing SAST False Positives: A Practical Guide

Reducing SAST False Positives: A Practical Guide

SAST tools commonly produce 30–70% false positive rates out of the box. Teams that don't manage this noise end up ignoring all SAST output. The solution is a combination of: baseline suppression (ignore existing issues, only alert on new ones), rule tuning (disable rules not relevant to your tech stack), custom suppression for known-safe patterns, and IAST confirmation for high-priority findings. Done well, you can cut actionable triage time by 60–80% without missing real vulnerabilities.

Key Takeaways

False positives kill SAST programs. When developers see 400 findings and know 90% are noise, they stop looking. The goal isn't zero findings—it's only showing findings that warrant action.

Baseline mode is your first tool. Configure SAST to only report findings introduced in the current PR. Existing findings in the codebase are legacy noise until you schedule time to address them.

Tune for your stack, not the default ruleset. A Java ruleset applied to a Python codebase generates irrelevant findings. A Node.js API with no server-side HTML rendering doesn't need DOM XSS rules. Delete rules that don't apply.

Inline suppressions should require justification. // nosec or # noqa without a comment is technical debt. Require developers to explain why they're suppressing.

Confirm high-severity findings with IAST or manual review before filing them. A critical SQL injection finding in a method that only accepts integer inputs is a false positive. Five minutes of manual review prevents a fire drill.

Why SAST Has So Many False Positives

SAST tools analyze code without running it. They look for patterns that could be vulnerable—but patterns aren't the same as vulnerabilities.

Pattern detection limitations:

// SAST flags this as potential SQL injection
String query = "SELECT * FROM users WHERE id = " + userId;
Statement stmt = conn.createStatement();
stmt.executeQuery(query);

But if userId is declared as int three lines up and comes from a path parameter parsed as integer, there's no SQL injection. SAST doesn't always trace types well enough to know this.

Sanitization blindness: SAST may not recognize custom validation or sanitization logic. If your framework sanitizes inputs before they reach your code, SAST may still flag the downstream usage.

Dead code: SAST scans all code, including unreachable legacy methods that will never execute in production.

Context-free rules: Rules written for maximum recall (catch everything) sacrifice precision (avoid false positives).

Strategy 1: Baseline Suppression

The most impactful change you can make. Instead of running SAST and reporting all findings, only report findings that are new compared to the baseline (typically main/master branch).

Semgrep

# Scan only new findings compared to baseline
semgrep scan \
  --config=p/owasp-top-ten \
  --baseline-commit=$(git merge-base HEAD origin/main) \
  --json \
  > findings.json

CodeQL (GitHub Actions)

- name: Initialize CodeQL
  uses: github/codeql-action/init@v3
  with:
    languages: java

- name: Analyze
  uses: github/codeql-action/analyze@v3
  with:
    # Only fail on new results vs default branch
    upload: true

GitHub's code scanning automatically surfaces only new findings in PRs by comparing against the default branch scan.

Bandit (Python)

# Save baseline
bandit -r src/ -f json > bandit-baseline.json

# Future runs: only show new findings
bandit -r src/ -f json | \
  python3 -c "
import json, sys
current = json.load(sys.stdin)['results']
with open('bandit-baseline.json') as f:
    baseline = {r['filename'] + str(r['line_number']): r for r in json.load(f)['results']}
new = [r for r in current if r['filename'] + str(r['line_number']) not in baseline]
print(json.dumps(new, indent=2))
"

Strategy 2: Rule Selection and Tuning

Start with fewer rules, all of which apply to your stack.

Remove Irrelevant Rules

# .semgrep.yml — curated ruleset for a Node.js/Express app
rules:
  - id: nosql-injection
    patterns:
      - pattern: db.collection($X).find({$FIELD: req.params.$PARAM, ...})
    message: Potential NoSQL injection
    severity: ERROR
    languages: [javascript, typescript]

# Explicitly disable categories that don't apply
exclude-rules:
  - java.*          # We don't use Java
  - php.*           # We don't use PHP
  - python.django.* # We don't use Django

Severity Thresholds

In CI, only fail on high/critical findings. Review medium/low findings in weekly triage, not on every PR:

# Fail CI only on high and critical
semgrep scan --config=auto --severity=ERROR --severity=WARNING \
  | jq '[.results[] | select(.extra.severity == "ERROR" or .extra.severity == "WARNING")] | length' \
  | xargs -I{} test {} -eq 0

# Log but don't fail on low severity
semgrep scan --config=auto --severity=INFO > low-severity.log || true

Strategy 3: Contextual Suppression

When a finding is a known false positive, suppress it with context.

Semgrep Inline Suppression

# Bad: suppression without justification
result = db.execute(query)  # nosec

# Good: suppression with justification
# nosec: B608 - query is constructed from integer ID, not user string input
result = db.execute(f"SELECT * FROM users WHERE id = {user_id}")

CodeQL Suppression

// lgtm[java/sql-injection] - userId is validated as integer by Spring @PathVariable
String query = "SELECT * FROM users WHERE id = " + userId;

Suppression Audit

Periodically review suppressions to ensure they're still valid:

# List all suppressions in the codebase
grep -rn "nosec\|noqa\|lgtm\|# type: ignore" src/ | \
  grep -v ":#" | \
  sort > suppressions-audit.txt

Strategy 4: Custom Rules for Your Codebase

Generic rules miss context-specific vulnerabilities and generate noise on context-specific patterns. Write rules for your actual code.

# Example: flag use of our legacy unsafe helper that was deprecated
rules:
  - id: legacy-unsafe-query
    pattern: LegacyDB.rawQuery($ARGS)
    message: "Use SafeDB.query() instead. LegacyDB.rawQuery() doesn't sanitize inputs."
    severity: ERROR
    languages: [java]

  - id: safe-db-pattern
    # Document the approved pattern so SAST recognizes it as safe
    pattern: SafeDB.query($PREPARED_STMT, $PARAMS)
    # This rule is informational — used to confirm safe usage
    severity: INFO

Strategy 5: IAST Confirmation for Critical Findings

For any finding rated CRITICAL or HIGH, confirm it with IAST before filing a security bug:

  1. Run IAST in staging with your test suite
  2. Cross-reference: which critical SAST findings also appear in IAST output?
  3. SAST finding confirmed by IAST → file immediately as critical
  4. SAST finding not in IAST → manual code review before filing

This prevents security teams from chasing false positives while ensuring genuine vulnerabilities get immediate attention.

Workflow: Tiered SAST Response

PR Opened
    │
    ▼
SAST Scan (new findings only, vs baseline)
    │
    ├── CRITICAL/HIGH new findings → Block PR, require security review
    │
    ├── MEDIUM new findings → Comment on PR, don't block
    │
    └── LOW/INFO new findings → Log to dashboard, weekly triage
    │
    ▼
Weekly Triage
    ├── Review medium findings accumulated in the week
    ├── Promote any confirmed medium → high
    └── Add suppressions for confirmed false positives
    │
    ▼
Sprint Cleanup
    ├── Select 5 legacy baseline findings to address per sprint
    └── Reduce baseline debt over time

Measuring False Positive Rate

Track this metric:

False Positive Rate = (Findings suppressed as FP) / (Total findings) × 100

A healthy SAST program should be below 30% false positive rate after tuning. Track this weekly:

# Count total findings
TOTAL=$(semgrep scan --config=auto --json | jq '.results | length')

# Count suppressed findings (findings that were manually dismissed)
SUPPRESSED=$(cat .semgrep-suppressions.json | jq '. | length')

echo "False positive rate: $(echo "scale=1; $SUPPRESSED * 100 / $TOTAL" | bc)%"

Common Sources of False Positives by Tool

Semgrep

Pattern Typical Cause Fix
SQL injection Integer parameters incorrectly typed as string Add type annotations or use typed queries
Path traversal File paths assembled from constants, not user input Suppress with justification
Hardcoded credentials Test fixtures or example values Move to test data files, or suppress

Bandit (Python)

Check Typical Cause Fix
B105 (hardcoded password) Default values in config schemas Rename config key or suppress
B311 (random) Uses of random for non-security purposes Suppress with note explaining non-security use
B404 (subprocess import) Build tools, not user-input processing Suppress with note

CodeQL

Query Typical Cause Fix
java/sql-injection ORM queries that look like concatenation Use type-safe query builders; add suppression
js/xss Template literals in test files Exclude test directories from scan scope

Summary

Managing SAST false positives is an ongoing process, not a one-time configuration. The highest-ROI changes in order:

  1. Enable baseline mode — only new findings in PRs
  2. Tune the ruleset — remove rules irrelevant to your stack
  3. Set severity thresholds — only block PRs on critical/high
  4. Add contextual suppressions — with justification comments
  5. Confirm critical findings with IAST before filing security bugs

Teams that implement these five steps consistently report 60–80% reductions in SAST triage time while maintaining or improving their actual security posture.

Read more

Start now free