Overview
Plug a SAST tool into your Jenkins pipeline, let the scan finish with zero alerts, then tell your boss “we’ve adopted DevSecOps” — I’ve seen this playbook too many times.
During a Level 2 cybersecurity protection audit for a ride-hailing project, the client’s security team required us to integrate security scanning into our CI/CD pipeline. The first version was textbook standard: SonarQube for code scanning + Trivy for image scanning, with the gate set to “block Critical.” In the first week, the pipeline went red 47 times. The development team ran 47 “fixes,” 41 of which were false positives. By the second week, someone quietly added || true to the CI config to swallow the scan results.
This is the classic “installed the tool but didn’t solve the problem” scenario. Security scanning isn’t just about plugging a tool into your pipeline — it’s a system engineering effort spanning tool selection, rule tuning, false positive management, gate strategy, and developer collaboration. This article documents our complete journey from a 70% false positive rate down to 5%, including the four-layer security defense architecture, six production-grade pitfalls, and a ready-to-use GitLab CI configuration.
If you’re working on security compliance or want to improve your team’s shift-left security capabilities, this content will save you months of trial and error.
The Four-Layer Security Defense System
Many teams only do one layer of security scanning — either just SAST or just SCA. That’s like locking your front door but leaving all the windows open. Real vulnerability interception requires layered defense. I divide this system into four layers, each addressing a different dimension.
Layer 1: SAST (Static Application Security Testing)
SAST scans the source code itself. Without running the code, it uses lexical analysis, syntax analysis, and data flow tracking to find SQL injection, XSS, hardcoded passwords, and other code-level defects.
In plain terms: SAST is like an English teacher with a red pen going through your essay line by line, circling grammar errors. It doesn’t check whether your content makes sense — only whether your writing follows the rules.
Mainstream SAST tool comparison:
| Tool | Language Coverage | False Positive Control | Rule Customization | CI Integration | Use Case |
|---|---|---|---|---|---|
| SonarQube | 40+ languages | Medium (via rule set trimming) | Java plugins, heavyweight | Scanner + Server | Multi-language teams needing quality + security in one |
| Semgrep | 30+ languages | High (AST pattern matching) | YAML rules, lightweight | CLI direct | Fast incremental scanning, heavy custom rules |
| CodeQL | Limited (mainly C/C++/Java/Python/JS) | High (data flow analysis) | CodeQL query language | GitHub Actions | GitHub ecosystem teams, deep vulnerability hunting |
My recommendation strategy:
- Teams under 50 people, multi-language projects: Semgrep for incremental scanning + SonarQube for full quality gates. Semgrep’s YAML rules are quick to pick up — a mid-level developer can write custom rules in half a day. SonarQube’s quality reports are directly usable in compliance audits.
- Heavy GitHub ecosystem users: CodeQL + GitHub Advanced Security. Zero additional infrastructure cost. CodeQL’s data flow analysis precision is the highest among open-source SAST tools.
- Large enterprises, compliance-driven: SonarQube Server + commercial SCA (Snyk or Mend). SonarQube’s compliance reports cover NIST SSDF, OWASP, CWE standards — a lifesaver during audits.
Don’t run 3 SAST tools simultaneously. I’ve seen teams run SonarQube + Semgrep + CodeQL together, resulting in 3 separate reports for the same code. The security team spent more time deduplicating than fixing vulnerabilities.
Layer 2: SCA (Software Composition Analysis)
SCA scans third-party dependencies. Your code might be secure, but an open-source library you import might carry a CVE. Modern applications are 90%+ open-source code — SCA is the health check for your dependency chain.
In plain terms: SCA is like a supermarket’s incoming goods quality check. No matter how clean your own food production is, if a supplier’s raw materials are contaminated, the final product is still unsafe. SCA checks every batch of “raw materials” (dependency libraries) for safety risks.
Core capability comparison of SCA tools:
| Dimension | OWASP Dependency-Check | Snyk | Trivy |
|---|---|---|---|
| Database | NVD + maintained CVE DB | Proprietary vulnerability DB | Trivy DB (multi-source aggregate) |
| Language Support | Java/Maven strongest, others average | 10+ languages | All languages + container images + IaC |
| License Scanning | Not supported | Supported | Supported |
| False Positive Rate | High (imprecise version matching) | Low | Low |
| CI Integration | Maven plugin / CLI | CLI + multi-CI platforms | CLI, minimal |
| Cost | Free | Commercial (per-seat pricing) | Free (open-source) / Commercial version |
Measured data: In a Java project with 327 Maven dependencies, OWASP Dependency-Check found 89 vulnerabilities while Snyk found 34. The difference comes from Dependency-Check’s coarse version matching — it matches by groupId + artifactId without distinguishing version ranges. Snyk uses precise version range matching, eliminating 55 false positives that didn’t affect the current version.
If budget is tight, use Trivy. It’s free, lightweight, broadly覆盖, and can scan both images and IaC files. For high compliance requirements (e.g., financial industry), Snyk’s vulnerability database update speed and accuracy are genuinely better, but costs add up — per-developer-seat pricing runs about 100K-150K RMB annually for a 50-person team.
Layer 3: Container Image and IaC Scanning
Container image scanning checks the security baseline of Dockerfiles and build artifacts: whether the base image has known vulnerabilities, whether secrets are left in the image, whether it runs as root.
IaC scanning checks infrastructure configuration files like Terraform and Kubernetes YAML: whether security groups are overly permissive, whether S3 buckets are publicly readable, whether K8s ServiceAccounts have excessive RBAC permissions.
This layer is often overlooked but is a key inspection area in Level 2 cybersecurity protection audits. Auditors won’t just look at your application code — they’ll check whether containers run as non-root, whether images contain known critical vulnerabilities, and whether Terraform configurations expose unnecessary ports.
For tool selection, Trivy is the all-rounder in this space:
# Scan Docker image
trivy image --severity HIGH,CRITICAL myapp:latest
# Scan IaC files (Terraform/K8s/CloudFormation/Dockerfile)
trivy config --severity HIGH,CRITICAL ./infrastructure/
# Output SARIF format (unified report format, explained later)
trivy image --format sarif -o trivy-report.sarif myapp:latest
Checkov is more specialized for Terraform scanning, checking 1000+ AWS/Azure/GCP security policies. If your infrastructure is primarily managed via Terraform, Checkov is worth using (Related: Don’t Turn Terraform Modules Into Black Boxes: IaC Layered Design Decisions and 6 Production Anti-Patterns Broken Down).
Layer 4: Secret Leak Detection
Secret scanning checks the codebase for hardcoded API keys, database passwords, private keys, and other sensitive information. This layer has the highest ROI of the four — simple to configure, immediate impact.
I’ve seen a real case: a developer hardcoded Alibaba Cloud AK/SK in a config file and pushed it to a GitLab repository. Three days later, an automated crawler found it and racked up 20,000+ RMB in cloud resource charges overnight. If secret scanning had been in place, this push would have been blocked at the CI stage.
# Gitleaks scans the entire repository
gitleaks detect --source . --report-format json -o gitleaks-report.json
# Only scan the current commit's changes
gitleaks detect --source . --report-format json -o gitleaks-report.json --log-opts HEAD~1..HEAD
Gitleaks is open-source with a rule library covering 100+ secret formats (AWS, GCP, Azure, Slack, Stripe, etc.). In CI, scanning only the increment (current commit diff) takes under 10 seconds.
Four-Layer Defense Coordination
| Layer | What It Scans | When | Typical Tools | Interception Target |
|---|---|---|---|---|
| SAST | Source code | PR stage (incremental) + daily full scan | SonarQube / Semgrep | SQL injection, XSS, hardcoded passwords |
| SCA | Dependencies | PR stage + post-build | Trivy / Snyk | Known CVE vulnerabilities |
| Container/IaC | Images + infrastructure | Post-build | Trivy / Checkov | Image vulnerabilities, config defects |
| Secret leak | Code changes | PR stage (incremental) | Gitleaks | Hardcoded secrets |
Key insight: The four layers aren’t parallel — they’re defense in depth. A single vulnerability might be caught by multiple layers. For example, a hardcoded password could be found by both SAST (rule matching) and secret scanning (key format matching). Both layers must catch it for true security.
False Positive Management: From 70% to 5% in Three Steps
False positives are the number one killer of security scanning. When the false positive rate is high, developers become desensitized to alerts — “most of them are false positives anyway, ignoring them is fine.” Once this mindset takes hold, real vulnerabilities get drowned in the noise.
At the initial stage of the ride-hailing project, our false positive rate was 70%. Meaning: only 3 out of every 10 alerts were real. After the overhaul, it dropped to 5% — only 1 in 20 alerts is a false positive. This took three steps.
Step 1: Rule Set Trimming — Don’t Enable All Rules
SonarQube ships with 6500+ rules enabled by default. Many of these are code style rules (like “methods shouldn’t exceed 50 lines”) that are security-irrelevant but trigger alerts. When we first deployed with all rules on, a 30,000-line project produced 1200 Issues — the development team was devastated.
Trimming strategy:
| Rule Category | Action | Rationale |
|---|---|---|
| Security (SQL injection, XSS, etc.) | Enable all | Directly corresponds to vulnerabilities |
| Bug (null pointer, resource leak) | Enable Critical/Blocker | High priority |
| Code Smell | Enable Major only | Minor produces too much noise |
| Security compliance (CWE/OWASP) | Enable all | Required for Level 2 audit |
| Duplication check | Enable but raise threshold (>20 lines) | Default threshold too low |
SonarQube Quality Profile configuration:
<!-- Custom Quality Profile: only keep security-related rules -->
<profile>
<name>security-focused</name>
<language>java</language>
<rules>
<!-- Enable all OWASP Top 10 related rules -->
<rule>
<repositoryKey>java</repositoryKey>
<key>S2077</key> <!-- SQL Injection -->
<priority>CRITICAL</priority>
</rule>
<rule>
<repositoryKey>java</repositoryKey>
<key>S5131</key> <!-- XSS -->
<priority>CRITICAL</priority>
</rule>
<!-- Disable noisy rules -->
<rule>
<repositoryKey>java</repositoryKey>
<key>S1186</key> <!-- Methods shouldn't have more than 7 parameters -->
<severity>0</severity> <!-- Disabled -->
</rule>
</rules>
</profile>
Semgrep’s rule trimming is more flexible. Its rule library is organized by language and security category — you can enable only specific categories:
# semgrep-config.yml — only enable security and secret detection rules
rules:
- include: "p/security-audit"
- include: "p/secret-detection"
- include: "p/java"
# Exclude code style rules
- exclude: "p/java/code-style"
Step 2: Baseline Elimination — Handle Existing Alerts
After trimming rules, the existing codebase will have a pile of historical alerts. If you directly enable the blocking gate, every PR will be blocked — because each PR’s diff might “touch” lines with historical alerts.
The solution is to establish a security baseline. Record all currently known alerts as a baseline file. The gate only checks for new alerts:
# Generate baseline snapshot (one-time operation)
sonar-scanner -Dsonar.qualitygate.wait=true \
-Dsonar.analysis.mode=preview \
-Dsonar.newCode.period=reference_branch \
-Dsonar.newCode.reference=main
In SonarQube’s Quality Gate, configure “New Code only”:
# Quality Gate conditions
New Critial Issues > 0 → FAIL
New Blocker Issues > 0 → FAIL
# Do not check Overall Code (existing)
This way, existing alerts won’t block the pipeline — only newly introduced security issues will be intercepted. Existing alerts are prioritized for remediation on a schedule, not all at once.
Semgrep also supports baseline mode:
# Only report new alerts introduced in the current commit
semgrep --config p/security-audit --diff --baseline=main .
In --diff mode, Semgrep only outputs alerts new to the baseline branch. Results are attached directly to PR comments — developers see only the issues they introduced, not historical noise.
Step 3: False Positive Marking and Suppression — Build a Feedback Loop
Steps one and two solved the “too much noise” problem. Step three solves the “real false positives that nobody manages” problem.
Scanners produce false positives — this is a fact. The key isn’t eliminating false positives but building a mechanism where they’re quickly marked, permanently suppressed, and never triggered again.
Our approach adds a “false positive marking” stage in GitLab CI. Developers mark false positives via MR comments, the security team reviews and adds them to a suppression list:
# GitLab CI — false positive marking stage
mark-false-positive:
stage: review
script:
- |
# Check if MR comments contain #false-positive marker
MR_COMMENTS=$(curl --header "PRIVATE-TOKEN: $GITLAB_TOKEN" \
"$CI_API_V4_URL/projects/$CI_PROJECT_ID/merge_requests/$CI_MERGE_REQUEST_IID/notes")
if echo "$MR_COMMENTS" | grep -q "#false-positive"; then
echo "False positive marker detected, extracting rule IDs..."
RULE_IDS=$(echo "$MR_COMMENTS" | grep -oP 'rule:(\S+)' | awk -F: '{print $2}')
for RULE_ID in $RULE_IDS; do
# Add to project-level suppression list
echo "$RULE_ID" >> .semgrep-suppressions.txt
echo "Suppressed rule: $RULE_ID"
done
# Commit suppression list
git config user.email "security-bot@company.com"
git config user.name "Security Bot"
git add .semgrep-suppressions.txt
git commit -m "chore: suppress false positive rules"
git push origin HEAD:$CI_COMMIT_REF_NAME
fi
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
Semgrep natively supports suppression files:
# Use --suppress parameter to load suppression list
semgrep --config p/security-audit --suppress .semgrep-suppressions.txt .
How this mechanism performs over time:
- Month 1: False positive rate drops from 70% to 40%. Rule trimming and baseline elimination take effect.
- Month 2: False positive rate drops from 40% to 15%. Development team starts actively marking false positives.
- Month 3: False positive rate stabilizes around 5%. Suppression list accumulates enough known false positive patterns.
Key point: The suppression list needs periodic review. We do a monthly “false positive audit” — checking whether rules in the suppression list are still valid. Some false positives may no longer be false positives after code refactoring.
Quality Gate Design: Teaching the Pipeline to Say “No”
Security scanning only finds problems. The quality gate is the checkpoint that decides “can this pass or not.” Many teams install scanning tools but don’t set gates — or set them too strict (blocking everything) or too loose (effectively decorative).
Three-Level Gate Strategy
I designed a three-level gate strategy, validated during Level 2 cybersecurity protection audit:
| Gate Level | Trigger | Blocking Rule | Notification |
|---|---|---|---|
| L1-Block | Critical vulnerability / secret leak | Immediately blocks MR merge | GitLab MR comment + Feishu alert |
| L2-Warning | High vulnerability | No block, but marks MR as “Need Review” | GitLab MR comment |
| L3-Report | Medium/Low vulnerability | No block, no mark, records to dashboard only | Security Dashboard |
Core principle: L1 catches what absolutely cannot pass. L2/L3 exist to “let developers know there’s a problem,” not to “block developers.”
Gate Script Implementation
Here’s the quality gate script we actually run in GitLab CI:
#!/bin/bash
# security-gate.sh — Security quality gate check
# Dependencies: jq (JSON parsing), scanner report files
set -euo pipefail
CRITICAL_THRESHOLD=0
HIGH_THRESHOLD=0 # L1 gate: Critical and High both block
# Collect Critical/High counts from each scanner
CRITICAL_COUNT=0
HIGH_COUNT=0
# Parse Semgrep report (JSON format)
if [ -f semgrep-report.json ]; then
CRITICAL_COUNT=$((CRITICAL_COUNT + $(jq '[.results[] | select(.extra.severity == "ERROR")] | length' semgrep-report.json)))
HIGH_COUNT=$((HIGH_COUNT + $(jq '[.results[] | select(.extra.severity == "WARNING")] | length' semgrep-report.json)))
fi
# Parse Trivy image scan report (JSON format)
if [ -f trivy-image-report.json ]; then
CRITICAL_COUNT=$((CRITICAL_COUNT + $(jq '[.Results[].Vulnerabilities[]? | select(.Severity == "CRITICAL")] | length' trivy-image-report.json)))
HIGH_COUNT=$((HIGH_COUNT + $(jq '[.Results[].Vulnerabilities[]? | select(.Severity == "HIGH")] | length' trivy-image-report.json)))
fi
# Parse Gitleaks secret leak report (JSON format)
if [ -f gitleaks-report.json ]; then
SECRET_COUNT=$(jq '[.[]] | length' gitleaks-report.json)
CRITICAL_COUNT=$((CRITICAL_COUNT + SECRET_COUNT))
fi
echo "=== Security Scan Results ==="
echo "Critical vulnerabilities: $CRITICAL_COUNT"
echo "High vulnerabilities: $HIGH_COUNT"
echo "Secret leaks: ${SECRET_COUNT:-0}"
echo "============================="
# L1 gate check
if [ $CRITICAL_COUNT -gt $CRITICAL_THRESHOLD ]; then
echo "❌ L1 gate failed: $CRITICAL_COUNT Critical issues found"
echo "Please fix and resubmit MR"
exit 1
fi
if [ $HIGH_COUNT -gt $HIGH_THRESHOLD ]; then
echo "❌ L1 gate failed: $HIGH_COUNT High issues found"
echo "Please fix and resubmit MR"
exit 1
fi
echo "✅ Security gate passed"
exit 0
Key design points:
- Multi-scanner result aggregation: Uses jq to parse JSON reports from different tools and aggregate counts. This is more flexible than setting individual gates per tool — you can adjust one threshold and affect the aggregate result across all scanners.
- Secret leaks count as Critical regardless: Whatever severity Gitleaks reports, any secret leak detection is Critical. This rule is a hard requirement in compliance audits.
- Configurable thresholds:
CRITICAL_THRESHOLDandHIGH_THRESHOLDare variables — different environments can have different thresholds. Test environments can relax to Critical-only blocking, while production must block High as well.
Gate Rollout Strategy
Don’t enable full blocking from day one. We rolled out in three phases:
Phase 1 (Weeks 1-2): Report only, no blocking All scan results are displayed via MR comments but don’t block merges. The goal is to let the development team get used to “having security feedback” while collecting false positive data.
Phase 2 (Weeks 3-4): Block secret leaks only Secret leak detection has an extremely low false positive rate (Gitleaks is under 2%). Starting here has minimal developer resistance — “it’s a real leak, I’ll fix it.”
Phase 3 (Week 5+): Full L1 gate enabled After 4 weeks of rule tuning and false positive management, full Critical + High blocking is enabled. Since the false positive rate is already under 10%, developers don’t feel the gate is “picking on them.”
GitLab CI Integration in Practice
Below is a complete GitLab CI security scanning pipeline configuration, ready to use. It integrates four scanning layers + three-level gates in a single pipeline.
# .gitlab-ci.yml — Security scanning pipeline
stages:
- test
- scan
- gate
variables:
# Lock scanner versions
SEMGREP_VERSION: "1.52.0"
TRIVY_VERSION: "0.49.1"
GITLEAKS_VERSION: "8.18.1"
# SAST scan (Semgrep incremental scan)
semgrep-sast:
stage: scan
image: returntocorp/semgrep:${SEMGREP_VERSION}
script:
# Only scan files in the MR diff; full scan goes to scheduled job
- |
if [ -n "$CI_MERGE_REQUEST_TARGET_BRANCH_NAME" ]; then
semgrep --config p/security-audit \
--config p/secret-detection \
--diff --baseline=$CI_MERGE_REQUEST_TARGET_BRANCH_NAME \
--json -o semgrep-report.json .
else
# Non-MR scenario (e.g., main branch push), full scan
semgrep --config p/security-audit \
--json -o semgrep-report.json .
fi
artifacts:
reports:
reports: semgrep-report.json
paths:
- semgrep-report.json
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == "main"
# SCA + container image scan (Trivy)
trivy-scan:
stage: scan
image: aquasec/trivy:${TRIVY_VERSION}
script:
# Build image first (simplified example; use Kaniko or Docker-in-Docker in practice)
- docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA .
# Scan image for vulnerabilities
- trivy image --format json -o trivy-image-report.json \
--severity HIGH,CRITICAL $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
# Scan IaC configuration files
- trivy config --format json -o trivy-config-report.json \
--severity HIGH,CRITICAL ./infrastructure/
artifacts:
paths:
- trivy-image-report.json
- trivy-config-report.json
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
# Secret leak detection (Gitleaks incremental scan)
gitleaks-secrets:
stage: scan
image: zricethezav/gitleaks:${GITLEAKS_VERSION}
script:
- |
if [ -n "$CI_MERGE_REQUEST_TARGET_BRANCH_NAME" ]; then
# MR incremental scan
gitleaks detect --source . \
--report-format json -o gitleaks-report.json \
--log-opts "$CI_MERGE_REQUEST_TARGET_BRANCH_NAME..HEAD"
else
# Full scan
gitleaks detect --source . \
--report-format json -o gitleaks-report.json
fi
artifacts:
paths:
- gitleaks-report.json
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == "main"
# Security quality gate
security-gate:
stage: gate
image: alpine:3.19
needs:
- job: semgrep-sast
optional: true
- job: trivy-scan
optional: true
- job: gitleaks-secrets
optional: true
before_script:
- apk add --no-cache jq bash
script:
- bash scripts/security-gate.sh
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == "main"
Key configuration notes:
optional: true: The gate stage usesneeds+optionalfor parallel waiting. If a scan job is skipped (e.g., no Dockerfile means no Trivy image scan), the gate still executes — the corresponding count is simply 0.- Incremental scanning first: MR stage only scans the diff. Full scans go to scheduled jobs or the main branch. This keeps MR scan time under 2 minutes, not affecting development rhythm. (Related: CI/CD Deployment Speed Optimization in Practice: From 90 Minutes to 5 Minutes)
- Unified JSON reports: All scanners output JSON format reports. The gate script uses jq to parse — far more reliable than parsing text logs.
SARIF: Unified Report Format for Multiple Scanners
This is a practical tip most articles don’t cover.
Different scanners output different formats: Semgrep outputs JSON, Trivy outputs JSON (but with a different structure), SonarQube outputs its own format. If you want to display all scan results in a single dashboard, you need format conversion.
SARIF (Static Analysis Results Interchange Format) is an OASIS-standardized format for exchanging static analysis results. It’s natively supported by GitHub Code Scanning, Azure DevOps, and GitLab (via plugins). Converting all scanner outputs to SARIF lets you view all security findings in a single interface.
# Semgrep natively supports SARIF output
semgrep --config p/security-audit --sarif -o semgrep.sarif .
# Trivy natively supports SARIF output
trivy image --format sarif -o trivy.sarif myapp:latest
# SonarQube exports SARIF via API
curl "$SONAR_HOST/api/issues/search?componentKeys=$PROJECT_KEY&format=sarif" \
-o sonar.sarif
In GitLab, SARIF reports render directly in the MR interface:
# GitLab CI — SARIF report rendering
semgrep-sast:
artifacts:
reports:
reports: semgrep.sarif # GitLab auto-renders in MR
Measured effect: Developers see security alerts annotated directly on code lines in the MR, click for details and fix suggestions. No jumping to external dashboards, no tool switching. This UX improvement directly boosted the development team’s willingness to fix vulnerabilities — from “passive fixing” to “fixing in passing.”
Production Pitfalls
Here are six pitfalls we encountered in actual deployment. Each one is real, not theoretical.
Pitfall 1: SonarQube Scanner OOM on Large Files
SonarScanner memory spikes and triggers OOM when scanning large files (single files >1MB, such as auto-generated protobuf code). One of our projects had 3000+ lines of auto-generated code — Scanner crashed outright.
# Solution: Exclude auto-generated files
sonar-scanner \
-Dsonar.exclusions="**/generated/**,**/*.pb.go,**/pb_*" \
-Dsonar.cpd.exclusions="**/generated/**" \
-Dsonar.coverage.exclusions="**/generated/**"
sonar.exclusions skips files entirely. sonar.cpd.exclusions only excludes duplication checks (still does security scanning). sonar.coverage.exclusions excludes coverage statistics. Choose the exclusion level based on your needs.
Pitfall 2: Trivy Database Updates in Air-Gapped Environments
In Level 2 cybersecurity compliance environments, CI servers typically can’t access the internet. Trivy’s vulnerability database needs daily updates — an outdated database in an air-gapped environment leads to missed vulnerabilities.
# Solution: Periodically sync Trivy database via internal mirror server
# Run on a machine with internet access
trivy --download-db-only
# Copy database file to CI Runner
# /root/.cache/trivy/db/trivy.db
# In CI, use --skip-db-update to skip online update
trivy image --skip-db-update --severity HIGH,CRITICAL myapp:latest
For a more automated approach, use a cron job to sync the database daily from the internet-connected machine to the internal network:
# crontab — sync Trivy database at 3 AM daily
0 3 * * * rsync -avz /root/.cache/trivy/db/ ci-internal-server:/data/trivy-db/
Pitfall 3: Semgrep Custom Rule Over-Matching
We wrote a Semgrep rule to detect exec() calls. It matched assert exec in test files, producing 47 false positives.
# Wrong rule: too broad
rules:
- id: dangerous-exec
patterns:
- pattern: exec($X)
message: "exec call detected, potential command injection risk"
severity: ERROR
# Correct rule: exclude test files and known safe calls
rules:
- id: dangerous-exec
patterns:
- pattern: exec($X)
- pattern-not-either:
- pattern: subprocess.run(...) # subprocess.run is safe
- pattern: exec(open("...").read()) # Known safe pattern
paths:
exclude:
- "tests/**"
- "*_test.go"
- "**/testdata/**"
message: "exec call detected, potential command injection risk"
severity: ERROR
Lesson: Always use paths.exclude to exclude test directories when writing custom rules. List known safe call patterns in pattern-not-either. After writing a rule, run a full local scan first and check the false positive rate before pushing to CI.
Pitfall 4: One-Size-Fits-All Gate Thresholds Blocking Test Environments
The development team introduced a debugging tool with a known CVE in the test environment (a metrics collector). Trivy scanned it as Critical and the gate blocked the build. But this tool only runs in the test environment, never in production.
Solution: Set gate thresholds by environment.
# .gitlab-ci.yml — tiered gates by environment
security-gate-test:
extends: security-gate-base
variables:
CRITICAL_THRESHOLD: 0 # Test: secret leaks still block
HIGH_THRESHOLD: 999 # High vulnerabilities don't block
rules:
- if: $CI_COMMIT_BRANCH =~ /^feature\//
- if: $CI_COMMIT_BRANCH =~ /^develop$/
security-gate-prod:
extends: security-gate-base
variables:
CRITICAL_THRESHOLD: 0
HIGH_THRESHOLD: 0 # Production: High also blocks
rules:
- if: $CI_COMMIT_BRANCH == "main"
- if: $CI_COMMIT_BRANCH =~ /^release\//
Pitfall 5: SAST Scanning Slowing Down the Pipeline
SonarQube full scan of a 100,000-line Java project takes 8 minutes. Running full scans on every MR, plus SCA + image scanning, puts the entire security stage at 15 minutes. Developers were not happy.
Optimization approach:
- Incremental scanning: Semgrep’s
--diffmode only scans changed files — 100K lines drops from 8 minutes to 45 seconds. - Parallelization: SAST, SCA, and secret scanning run as parallel jobs. Total time equals the longest job (SAST, ~1 minute).
- Async full scans: Move full scans to a daily scheduled job, with results recorded to a security dashboard. MR stage does incremental scanning only.
# Daily full scan (2 AM)
scheduled-full-scan:
stage: scan
script:
- semgrep --config p/security-audit --json -o full-scan-report.json .
- # Upload to security dashboard
- curl -X POST "$SECURITY_DASHBOARD/api/reports" \
-H "Content-Type: application/json" \
-d @full-scan-report.json
rules:
- if: $CI_PIPELINE_SOURCE == "schedule"
Pitfall 6: Secret Scanning and “Historical Leaks”
Gitleaks scans the current commit’s diff. But what if a secret was leaked into Git history 3 months ago? Even if deleted now, it’s still recoverable from git log.
# Scan entire Git history
gitleaks detect --source . --log-opts="--all" --report-format json -o history-leaks.json
# If historical leaks are found, clean with git-filter-repo
pip install git-filter-repo
git filter-repo --invert-paths --path-separator config/secrets.yml
git push --force origin main
But force push is risky. The safer approach: upon discovering a historical leak, immediately rotate the secret (notify the cloud platform to regenerate AK/SK), then delete the leaked file in the current commit. The old secret in Git history is now invalidated — even if someone digs it up, it’s useless.
SonarQube vs Semgrep: When to Use Which
These two tools don’t conflict — they can be used together. But if budget or effort is limited, you need to pick a primary tool.
| Dimension | SonarQube | Semgrep |
|---|---|---|
| Positioning | Code quality + security integrated | Pure security + code rules |
| Rule count | 6500+ (quality, security, style) | 3000+ (mainly security) |
| Rule customization | Java plugins, long dev cycle | YAML rules, minutes |
| False positive control | Via Quality Profile trimming | AST matching precision + suppression list |
| CI integration | Requires SonarQube Server | CLI, direct |
| Compliance reports | Native (OWASP/CWE/NIST) | Need to generate manually |
| Scan speed | Slow (full analysis) | Fast (incremental scanning) |
| Infrastructure cost | Requires maintaining SonarQube Server | None (CLI tool) |
My recommendation: Semgrep for MR incremental scanning (fast, accurate, lightweight), SonarQube for daily full scans and compliance report output. They complement each other without overlap.
This combination ran for 8 months in the ride-hailing project, covering the entire Level 2 cybersecurity protection audit cycle. During the audit, the security team spot-checked 3 applications — all Critical vulnerabilities had been intercepted and fixed by the CI gate 30 days before the audit, and high-risk vulnerability fix-on-discovery rate reached 100% (Related: Cloud Native Security: Container Security, Image Scanning, and Runtime Protection).
Security Scanning Performance Data
Here’s our actual performance data (100K-line Java + 3K-line Python mixed project, 16-core CI Runner):
| Scan Type | Full Scan Time | Incremental Scan Time | Report Size | False Positive Rate |
|---|---|---|---|---|
| Semgrep (SAST) | 3 min 12 sec | 45 sec | 2.1 MB (JSON) | 8% |
| SonarQube (SAST) | 8 min 37 sec | N/A (no incremental support) | 4.5 MB (JSON) | 12% |
| Trivy image scan | 1 min 28 sec | N/A | 1.8 MB (JSON) | 3% |
| Trivy SCA | 42 sec | N/A | 0.9 MB (JSON) | 5% |
| Gitleaks | 8 sec | 3 sec | 0.1 MB (JSON) | 1% |
Several observations:
- Semgrep’s incremental scan is 4x+ faster than full scan. For high-frequency MR scenarios (20+ MRs per day), the CI time savings are significant.
- SonarQube doesn’t support true incremental scanning — it needs to analyze the entire project each time to build data flow graphs. That’s why it belongs in daily scheduled jobs, not on every MR.
- Gitleaks’ incremental scan takes almost no time (3 seconds) but has the highest ROI — secret leaks have the most severe consequences. If you can only deploy one security scanner, choose Gitleaks.
Aligning with Level 2 Cybersecurity Protection Requirements
Level 2 cybersecurity protection has explicit requirements for secure development. Here’s how our four-layer scanning system maps to the requirements:
| Requirement Item | Corresponding Scan Layer | Implementation |
|---|---|---|
| Secure development management (8.1.4.3) | SAST + SCA | Code security scanning and dependency checking integrated in CI pipeline |
| Security testing (8.1.4.4) | Container image scanning | Security baseline check on build artifacts before release |
| Pre-release security review (8.1.4.5) | All four layers | Full security scan and review report before release |
| Code security management (8.1.4.2) | Secret leak detection | No plaintext secrets allowed in code repository |
Materials needed during compliance audit:
- Security scan reports (SonarQube’s compliance reports work directly)
- Vulnerability remediation records (GitLab MR comments and fix commits)
- Gate policy documentation (Quality Gate configuration screenshots)
- Periodic scan records (scheduled full scan execution logs)
Organize these materials into a “Secure Development Practices Document” and submit directly during the audit. Our experience: teams with this automated scanning pipeline achieve nearly 100% pass rate on the “secure development” portion of Level 2 audits.
Summary
Back to the opening question — “Does plugging SAST into your pipeline count as DevSecOps?”
Obviously not. The core of DevSecOps isn’t “do you have tools” but “has security checking truly integrated into the development workflow, effectively intercepted real risks, and been accepted by the development team as part of their daily work.”
We achieved this goal through a four-layer defense system + three-step false positive management + three-level gate strategy. Key metrics:
- False positive rate from 70% to 5%: Through rule trimming, baseline elimination, and false positive suppression.
- Incremental scan in 45 seconds: Semgrep’s diff mode makes security scanning no longer a CI bottleneck.
- High-risk vulnerability fix-on-discovery rate of 100%: L1 gate blocked all Critical vulnerabilities from reaching production.
- Level 2 cybersecurity audit passed on first attempt: The automated security scanning pipeline provided a complete compliance evidence chain.
Lessons learned:
- Don’t enable full blocking from day one. Run two weeks of “report only, no blocking” first. Collect false positive data, tune rules, then gradually enable the gate. Starting with blocking will trigger developer resistance that derails the entire project.
- Secret leak detection has the highest ROI. 8-second scan, 1% false positive rate, intercepting the most severe leak problems. If you can only do one layer, do this one.
- Incremental scanning is the key to developer acceptance. The difference between an 8-minute full scan and a 45-second incremental scan is night and day for developer experience.
- False positive management is ongoing work, not a one-time task. The suppression list needs monthly review. New rules need validation before going live. Treat false positive management as part of security operations, not a one-time pre-launch configuration.
This system isn’t a theoretical design — it was gradually refined over 8 months of real-world operation in a ride-hailing project. Every pitfall was encountered, every data point was measured. If you’re doing something similar, I hope these lessons help you avoid the same detours.
References & Acknowledgments
The following resources were consulted during the writing of this article. Thanks to the original authors for their contributions:
- Veracode DevSecOps Solutions — Veracode, provided the technical framework reference for SAST/SCA/DAST/IaC scanning tools
- GitLab CI Security Scanning Integration: SAST, DAST and Dependency Scanning Configuration — Tencent Cloud Developer Community, provided GitLab CI security scanning stage configuration reference
- Building an Automated Security Audit System from Scratch: SAST, SCA and CI/CD Integration in Practice — CSDN, provided the four-pillar security scanning architecture concept
- Semgrep AppSec Platform — Semgrep official documentation, provided AST pattern matching and noise filtering technical details
- SonarQube Product Documentation — SonarSource, provided Quality Profile and Quality Gate configuration specifications
- CI/CD Pipeline Quality Gates: From Code Scanning to Automated Acceptance — CSDN, provided the three-level quality gate design approach
- DevSecOps Ultimate Guide: Experience Summary from 10 Success Cases — CSDN, provided container security full-process and toolchain selection case studies
- 2026 Technology Watch: DevSecOps Enters the Security Release Phase — Tencent Cloud Developer Community, provided industry trend reference for canary interception and security release workflows