SAST Tools Compared: SonarQube vs Semgrep for Modern DevSecOps
Static Application Security Testing (SAST) catches vulnerabilities before they ever run. By analyzing source code at rest, SAST tools identify injection flaws, insecure configurations, hardcoded secrets, and dozens of other issues during development — not after a breach. Two tools dominate modern DevSecOps pipelines: SonarQube and Semgrep. They take fundamentally different approaches, and understanding those differences is the key to choosing the right tool (or combining both).
What SAST Actually Does
SAST tools parse your source code into an abstract syntax tree (AST) or a control-flow graph and run rule sets against that representation. Unlike dynamic testing, SAST requires no running application — it works on pull requests, in pre-commit hooks, or as a CI gate.
The tradeoff: SAST can't see runtime behavior. It won't catch a vulnerability that only appears when a specific API call returns a malformed response. But it will catch the SQL query built with string concatenation before any user ever sends a request.
A well-tuned SAST setup catches:
- SQL injection, XSS, path traversal, command injection
- Insecure cryptography (MD5, SHA1, weak key sizes)
- Hardcoded credentials (though dedicated secrets scanners do this better)
- Unsafe deserialization
- Missing authorization checks in common patterns
SonarQube: Enterprise-Grade Static Analysis
SonarQube is the incumbent. It supports 30+ languages, integrates with every major CI system, and ships with thousands of built-in rules covering security, reliability, and maintainability.
Setting Up SonarQube
The fastest path to a working SonarQube instance is Docker:
docker run -d --name sonarqube \
-p 9000:9000 \
-e SONAR_ES_BOOTSTRAP_CHECKS_DISABLE=true \
sonarqube:communityDefault credentials: admin / admin. Change them immediately.
For production, use the official Helm chart:
helm repo add sonarqube https://SonarSource.github.io/helm-chart-sonarqube
helm repo update
helm install sonarqube sonarqube/sonarqube \
--set postgresql.enabled=true \
--set postgresql.auth.password=your-db-passwordConfiguring a Project
Create a sonar-project.properties file in your repo root:
sonar.projectKey=my-app
sonar.projectName=My Application
sonar.sources=src
sonar.tests=tests
sonar.exclusions=**/*.test.js,**/vendor/**,**/node_modules/**
sonar.coverage.exclusions=**/*.test.js
# For JavaScript/TypeScript
sonar.javascript.lcov.reportPaths=coverage/lcov.info
# Security hotspot categories to track
sonar.security.sources.jaas.loginModules=Run the scanner:
sonar-scanner \
-Dsonar.host.url=https://sonarqube.your-org.com \
-Dsonar.token=$SONAR_TOKENUnderstanding Quality Gates
Quality Gates are where SonarQube becomes a real CI gate. The default "Sonar way" gate fails if:
- Coverage on new code drops below 80%
- New code has a reliability rating below A
- New code has a security rating below A
- New code has a maintainability rating below A
For security-focused teams, tighten the security conditions:
Quality Gate: Security Strict
- New Security Hotspots Reviewed: >= 100%
- New Vulnerabilities: = 0
- New Security Rating: = AConfigure this via the SonarQube UI under Quality Gates, or via the API:
curl -u $SONAR_TOKEN: -X POST \
"https://sonarqube.your-org.com/api/qualitygates/create" \
-d "name=Security+Strict"
curl -u $SONAR_TOKEN: -X POST \
"https://sonarqube.your-org.com/api/qualitygates/create_condition" \
-d "gateId=2&metric=new_vulnerabilities&op=GT&error=0"CI/CD Integration
GitHub Actions:
name: SonarQube Analysis
on:
push:
branches: [main]
pull_request:
jobs:
sonarqube:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # Full history for blame data
- name: SonarQube Scan
uses: SonarSource/sonarqube-scan-action@master
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }}
- name: SonarQube Quality Gate
uses: SonarSource/sonarqube-quality-gate-action@master
timeout-minutes: 5
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}GitLab CI:
sonarqube-check:
image: sonarsource/sonar-scanner-cli:latest
stage: security
variables:
SONAR_USER_HOME: "${CI_PROJECT_DIR}/.sonar"
GIT_DEPTH: "0"
cache:
key: "${CI_JOB_NAME}"
paths:
- .sonar/cache
script:
- sonar-scanner
allow_failure: false
only:
- merge_requests
- mainSemgrep: Lightweight, Fast, Extensible
Semgrep takes a different approach. Instead of building a full semantic model of your code, it uses pattern matching on a simplified AST. This makes it dramatically faster and makes writing custom rules accessible to developers who aren't compiler engineers.
Installing Semgrep
# pip
pip install semgrep
# Homebrew
brew install semgrep
# Docker
docker pull semgrep/semgrepRunning Your First Scan
# Scan with the security-focused rule pack
semgrep --config=p/security-audit .
# OWASP Top 10 coverage
semgrep --config=p/owasp-top-ten .
# CI-friendly output
semgrep --config=p/ci --json --output=semgrep-results.json .The p/ prefix pulls from the Semgrep Registry — a public collection of community and Semgrep-maintained rules. For production use, pin specific rule sets rather than pulling latest every run.
Writing Custom Semgrep Rules
This is where Semgrep shines. Rule files are YAML with a pattern DSL that feels approachable:
rules:
- id: hardcoded-jwt-secret
patterns:
- pattern: jwt.sign($PAYLOAD, "...")
- pattern-not: jwt.sign($PAYLOAD, process.env.$ENV_VAR)
message: >
Hardcoded JWT secret detected. Use environment variables or a secrets
manager instead. Found in $FILE at line $LINE.
languages: [javascript, typescript]
severity: ERROR
metadata:
category: security
cwe: CWE-798
owasp: A07:2021
- id: unsafe-sql-query
pattern: |
$DB.query("..." + $USER_INPUT)
message: Possible SQL injection via string concatenation
languages: [javascript, typescript, python]
severity: ERROR
- id: missing-rate-limit
patterns:
- pattern: |
app.post($ROUTE, $HANDLER)
- pattern-not: |
app.post($ROUTE, rateLimit(...), $HANDLER)
message: POST endpoint $ROUTE is missing rate limiting middleware
languages: [javascript]
severity: WARNING
paths:
include:
- "src/routes/**"Run your custom rules:
semgrep --config=./security-rules/ --error .Taint Analysis in Semgrep
Semgrep Pro supports dataflow / taint analysis — tracking user-controlled data from source to sink:
rules:
- id: taint-sql-injection
mode: taint
pattern-sources:
- pattern: req.body.$FIELD
- pattern: req.query.$FIELD
- pattern: req.params.$FIELD
pattern-sinks:
- pattern: $DB.query(...)
- pattern: $DB.execute(...)
pattern-sanitizers:
- pattern: $DB.escape(...)
- pattern: parameterized($QUERY, ...)
message: User input flows to SQL query without sanitization
languages: [javascript, typescript]
severity: ERRORHead-to-Head Comparison
| Dimension | SonarQube | Semgrep |
|---|---|---|
| Languages | 30+ | 30+ |
| Rule count (built-in) | 5,000+ | 3,000+ (registry) |
| Custom rule complexity | Java/XML-based | YAML pattern DSL |
| Scan speed (10k LOC) | ~2-5 min | ~10-30 sec |
| False positive rate | Medium-High | Low-Medium |
| Self-hosted cost | Free (Community) | Free (OSS) |
| Taint analysis | Yes (paid) | Yes (Pro) |
| PR decoration | Yes | Yes |
| Historical tracking | Yes | Limited |
| Code coverage integration | Yes | No |
Managing False Positives
False positives are the main reason SAST adoption fails. Developers learn to ignore the scanner when every result is noise.
SonarQube suppression:
// Within code — use sparingly
@SuppressWarnings("java:S2077") // Suppresses SQL injection check
public List<User> search(String query) {
// This is safe because query is validated by the InputValidator
return db.execute("SELECT * FROM users WHERE " + validatedQuery);
}Or mark as "Won't Fix" / "False Positive" in the SonarQube UI, which persists across scans.
Semgrep suppression:
// nosemgrep: hardcoded-jwt-secret
const testSecret = "test-secret-for-unit-tests-only";Or use .semgrepignore:
# Ignore test fixtures
tests/fixtures/
tests/mocks/
# Ignore generated code
generated/
dist/Systematic false positive reduction:
- Track your false-positive rate per rule. If a rule fires > 50% false positives, tune or disable it.
- Create rule exceptions for test directories globally — test code has different security requirements.
- Review suppression comments in PRs to catch misuse.
- Re-evaluate suppressed findings quarterly.
The Recommended Stack
Use both. They complement each other:
- Semgrep in pre-commit and PR checks — fast, low friction, catches obvious issues before review
- SonarQube as the authoritative gate — deeper analysis, historical trending, code coverage correlation
In your CI pipeline, run Semgrep first (it's faster). If Semgrep passes, SonarQube runs its deeper analysis. This keeps feedback loops tight while maintaining thorough coverage.
For monitoring the full security testing picture across your CI runs — including SAST gate pass rates over time — HelpMeTest can track your pipeline health and alert when security checks start degrading, giving you visibility beyond what a single scanner dashboard provides.
Practical Rule Management
For teams starting out, avoid the temptation to enable every rule. Start with:
SonarQube: Enable the "Sonar way" security profile, then add your top 3 language-specific OWASP rules.
Semgrep: Start with p/owasp-top-ten and p/secrets. Add custom rules only when you identify a gap.
Review your active rules quarterly. Rules that have never fired in 90 days are either already-mitigated risk (good) or dead rules that generate false confidence (investigate).
# Semgrep: list rules that fired in last scan
semgrep --config=p/security-audit --json . | \
jq '[.results[] | .check_id] | unique | sort'
# Compare against your enabled rule list to find never-firing rulesSAST is not a one-time setup. It's an ongoing practice: tuning rules as your codebase evolves, reviewing suppressions, and treating the quality gate as a real gate — not a checkbox that's always bypassed. The teams that get value from SAST are the ones that have a process for acting on its findings, not just running it.