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.jsonCodeQL (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: trueGitHub'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 DjangoSeverity 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 || trueStrategy 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.txtStrategy 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: INFOStrategy 5: IAST Confirmation for Critical Findings
For any finding rated CRITICAL or HIGH, confirm it with IAST before filing a security bug:
- Run IAST in staging with your test suite
- Cross-reference: which critical SAST findings also appear in IAST output?
- SAST finding confirmed by IAST → file immediately as critical
- 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 timeMeasuring False Positive Rate
Track this metric:
False Positive Rate = (Findings suppressed as FP) / (Total findings) × 100A 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:
- Enable baseline mode — only new findings in PRs
- Tune the ruleset — remove rules irrelevant to your stack
- Set severity thresholds — only block PRs on critical/high
- Add contextual suppressions — with justification comments
- 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.