📍 Guide principal du sujet : Pipeline SAST DAST SCA 2026 : architecture, outils et intégration CI/CD
Ce tutoriel donne une méthode disciplinée pour traiter les faux positifs SAST, condition nécessaire à la pérennité d’un programme DevSecOps.
Pourquoi les faux positifs sont l’ennemi numéro un d’un pipeline SAST
Les retours d’expérience publics convergent : les outils SAST génèrent typiquement entre 30 et 60 % de faux positifs sur du code applicatif réel selon la maturité du tuning et la stack ciblée. Cette friction explique l’observation maintes fois confirmée sur le terrain : la première année, l’équipe s’investit, trie, corrige ; à partir de la deuxième année, les alertes sont systématiquement ignorées et l’outil tourne dans le vide. Le coût d’un faux positif n’est pas seulement le temps perdu à le trier — il est surtout le crédit confiance entamé. Quand un développeur reçoit dix alertes dont sept sont fausses, il finit par ignorer les trois vraies aussi.
Réduire les faux positifs n’est pas une opération ponctuelle de configuration mais un processus continu. Cinq leviers cohabitent : tuner les règles existantes, écrire des règles plus spécifiques au code de l’équipe, supprimer les findings qui ne s’appliquent pas avec une justification tracée, mesurer le ratio FP par règle pour cibler les pires, et rejouer périodiquement la baseline pour vérifier que les suppressions restent valables. Ce tutoriel développe chaque levier avec des exemples concrets sur Semgrep, SonarQube et Trivy.
Prérequis
- Au moins un outil SAST déjà en production sur votre dépôt depuis quelques semaines.
- Une plateforme d’agrégation des findings (DefectDojo OSS, Faraday ou GitLab Vulnerability Report).
- Accès en écriture aux fichiers de configuration des outils et au repo de règles.
- Niveau attendu : intermédiaire DevSecOps, capacité à lire un rapport SARIF.
- Temps estimé : 90 minutes pour traiter une session de tri sur 50 findings, l’investissement initial pour mettre en place les compteurs prend une journée.
Étape 1 — Trier les findings au lieu de les éliminer aveuglément
Le premier réflexe d’une équipe submergée est de désactiver les règles bruyantes. C’est l’erreur fondatrice : derrière une règle qui produit beaucoup de bruit se cachent souvent quelques vrais findings critiques noyés dans la masse. La discipline commence par trier finding par finding, en attribuant chacune à l’une de quatre catégories.
| Catégorie | Action |
|---|---|
| True positive exploitable | Créer un ticket, assigner à une squad, tag security-debt |
| True positive non exploitable (vulnérabilité présente mais surface bloquée par un middleware ou une auth) | Documenter la mitigation existante, marquer not-exploitable avec lien vers le code de mitigation |
| False positive — règle trop large | Affiner la règle (étape 3), ne pas suppress un par un |
| False positive — cas légitime spécifique | Suppression inline avec justification (étape 4) |
Cette grille évite l’écueil de tout étiqueter false positive. La distinction entre true positive non exploitable et false positive est particulièrement importante : la première reste un risque latent à surveiller, la seconde n’a jamais été un risque. Mélanger les deux conduit à perdre la trace des mitigations et à accumuler de la dette implicite.
Étape 2 — Mesurer le taux de faux positifs par règle
Sans mesure, impossible de cibler les règles à travailler. La métrique pivot est le ratio FP par règle : pour chaque rule-id, combien de findings ont été marqués false positive sur le total émis. DefectDojo expose ce calcul nativement via son API, mais on peut le reproduire avec un script Python en quelques lignes.
import os
import requests
from collections import defaultdict
DOJO = "https://defectdojo.example.com"
TOKEN = os.environ["DOJO_API_KEY"]
HEADERS = {"Authorization": f"Token {TOKEN}"}
stats = defaultdict(lambda: {"total": 0, "fp": 0})
url = f"{DOJO}/api/v2/findings/?limit=1000&active=true"
while url:
r = requests.get(url, headers=HEADERS).json()
for f in r["results"]:
rule = f.get("vuln_id_from_tool", "unknown")
stats[rule]["total"] += 1
if f.get("false_p"):
stats[rule]["fp"] += 1
url = r["next"]
ranked = sorted(
((r, s["fp"] / s["total"], s["total"]) for r, s in stats.items() if s["total"] >= 5),
key=lambda x: x[1], reverse=True
)
for rule, ratio, total in ranked[:20]:
print(f"{ratio:5.1%} {total:4d} {rule}")
La sortie classe les 20 règles les plus bruyantes. Une règle au-dessus de 70 % de FP avec 30+ occurrences est candidate prioritaire à la révision : soit la règle elle-même est à raffiner, soit le code de l’équipe contient un pattern légitime que la règle ne sait pas reconnaître. Le seuil total >= 5 évite de classer en tête une règle qui a été déclenchée deux fois et marquée FP par hasard.
Étape 3 — Raffiner une règle Semgrep trop large
Imaginons que la règle python.lang.security.audit.dangerous-system-call matche tous les os.system(...) et génère 80 % de FP parce que votre équipe utilise os.system() pour des commandes hard-codées dans des scripts d’admin. La solution n’est pas de désactiver la règle mais d’ajouter un pattern-not qui exclut les cas légitimes.
rules:
- id: dangerous-system-call-refined
languages: [python]
severity: HIGH
message: |
os.system() avec une chaîne dynamique permet une command injection.
Utiliser subprocess.run([...]) avec une liste d'arguments.
pattern: os.system($CMD)
pattern-not:
- pattern: os.system("...")
- pattern: os.system(f"...")
- pattern: os.system(CONST_CMD)
metavariable-regex:
metavariable: $CMD
regex: ^[a-zA-Z_]\w*$|.*\+.*|.*\.format\(.*
L’idée centrale : le risque vient de la concaténation et du formatage de chaîne, pas des littéraux ou des constantes globales. Le pattern-not exclut les chaînes littérales (toujours sûres puisque hard-codées) et les os.system(CONST_CMD) où CONST_CMD est une constante. Le metavariable-regex resserre encore en exigeant que la métavariable $CMD soit suspecte — concaténation ou formatage. Sur le terrain, ce raffinage ramène le taux de FP de 80 % à moins de 10 %.
Étape 4 — Suppressions inline avec justification et expiration
Pour les cas où la règle est juste mais le contexte spécifique justifie l’exception, l’annotation inline est la voie propre. Trois règles cardinales : la justification est obligatoire, la date d’expiration est explicite, le commentaire est revu en code review.
# nosemgrep: dangerous-system-call - Le chemin est validé par validate_safe_path() ligne 42.
# Expire 2026-12-31 - revoir la nécessité de subprocess vs os.system.
os.system(f"backup --target={validate_safe_path(target)}")
L’annotation Semgrep nosemgrep: sur la ligne précédant l’occurrence désactive la règle pour cette ligne uniquement. La justification est en clair, lisible. La date d’expiration est moralement contraignante : un script de revue trimestrielle peut grep nosemgrep: et signaler les expirations dépassées.
grep -rn 'Expire \(2026\)' --include='*.py' . | \
awk -F'Expire ' '{print $2}' | \
awk -F' ' '{if ($1 < "'"$(date +%F)"'") print}'
Ce one-liner liste les annotations dont la date d’expiration est passée. À intégrer dans un job CI hebdomadaire qui ouvre une issue automatique si une expiration n’a pas été traitée. Sans ce mécanisme, les annotations s’accumulent et les exceptions deviennent permanentes par défaut.
Étape 5 — SonarQube Issue Status et tag suppression
SonarQube expose un workflow d’issue intégré qui distingue cinq statuts : Open, Confirmed, Resolved (avec sous-statut Fixed, False Positive, Won’t Fix), Reopened, Closed. Le statut False Positive retire l’issue du Quality Gate sans la supprimer définitivement. Le passage au statut nécessite un commentaire ; SonarQube refuse de marquer une issue FP sans justification.
Au-delà du statut, les tags servent à documenter la nature de l’exception. Convention recommandée pour l’équipe :
| Tag | Usage |
|---|---|
fp-pattern-trop-large |
FP dû à une règle imprécise — à remonter dans Sonar Issue Tracker |
fp-context-specifique |
FP justifié par un contexte que la règle ne peut pas voir |
not-exploitable-mitigated |
True positive avec mitigation effective ailleurs dans le code |
accepted-risk-2026 |
Risque accepté par décision RSSI, expire à la fin de l’année |
L’export hebdomadaire des issues taggées accepted-risk-* via GET /api/issues/search?tags=accepted-risk-2026 alimente un comité de revue trimestriel avec le RSSI. Cette traçabilité fait la différence entre une équipe sécurité crédible et une équipe qui accumule des risques sous le tapis.
Étape 6 — Trivy avec ignorefile structuré et VEX
Trivy lit un fichier .trivyignore à la racine du dépôt ou via --ignorefile. Le format simple ignore une CVE par ligne, mais Trivy 0.50+ accepte aussi le format VEX (Vulnerability Exploitability eXchange) qui structure la justification au standard CycloneDX 1.7.
# trivy-vex.yaml
metadata:
component:
bom-ref: "pkg:image/myapp@sha256:abc123"
type: "container"
vulnerabilities:
- id: CVE-2024-12345
analysis:
state: "not_affected"
justification: "vulnerable_code_not_in_execute_path"
detail: "La fonction vulnérable openssl_sign_old() n'est pas invoquée par notre code."
response: ["will_not_fix"]
affects:
- ref: "pkg:image/myapp@sha256:abc123"
Cinq justifications sont normalisées : code_not_present, code_not_reachable, requires_configuration, requires_dependency, requires_environment, protected_by_compiler, protected_at_runtime, protected_at_perimeter, protected_by_mitigating_control. Choisir la plus précise possible : un auditeur ou un futur mainteneur comprendra immédiatement pourquoi la CVE a été acceptée. Pour appliquer le VEX au scan : trivy image --vex trivy-vex.yaml myimage.
Étape 7 — Boucle de feedback avec les développeurs
Une règle bruyante l’est rarement par hasard : elle correspond à une situation où le code de l’équipe diverge des hypothèses standard de l’outil. Les développeurs les plus exposés au bruit sont les meilleurs sources d’amélioration. Trois mécanismes structurent cette boucle.
Le premier est le canal Slack ou MM dédié où les développeurs signalent les FP en deux clics : un bouton dans l’issue tracker SonarQube, un menu contextuel dans VS Code via l’extension Semgrep, un workflow GitHub Actions qui crée une issue de tracking. Le second est la revue mensuelle des règles bruyantes, pendant laquelle l’équipe sécurité présente le top 10 des règles à plus haut FP et discute du raffinage avec deux ou trois développeurs représentatifs des squads concernées. Le troisième est la responsabilisation écrite : chaque règle a un mainteneur identifié dans le repo de règles, qui répond aux issues sous 48 h.
Étape 8 — Rejouer la baseline régulièrement
Une suppression posée il y a 18 mois peut avoir perdu sa pertinence : la fonction de mitigation a été supprimée, la CVE marquée not-affected est devenue exploitable par un nouveau chemin de code, le développeur qui a justifié l’exception a quitté l’équipe. Le rejeu trimestriel de la baseline est une pratique salutaire.
#!/usr/bin/env bash
# baseline-review.sh - à lancer trimestriellement
set -euo pipefail
# 1. Lister les nosemgrep et leurs lignes
grep -rn 'nosemgrep:' --include='*.py' --include='*.js' --include='*.go' . > current-suppressions.txt
# 2. Lister celles dont la date d'expiration est passée
awk '/Expire / { match($0, /Expire ([0-9]{4}-[0-9]{2}-[0-9]{2})/, a); if (a[1] < "'"$(date +%F)"'") print }' current-suppressions.txt
# 3. Comparer aux suppressions de la version précédente
git show HEAD~90:suppressions.txt 2>/dev/null | comm -23 - current-suppressions.txt
Le rapport trimestriel récapitule trois informations : combien d’annotations ont expiré et n’ont pas été renouvelées, combien de nouvelles annotations sont apparues sans revue, et le ratio global suppressions/findings actifs. Ces trois chiffres sont l’indicateur de santé du programme. Une augmentation rapide des suppressions sans baisse correspondante du nombre de FP signale une dérive : l’équipe escamote au lieu de corriger.
Erreurs fréquentes
| Symptôme | Cause | Solution |
|---|---|---|
| L’équipe désactive les règles bruyantes en bloc | Pas de processus de tri par finding | Imposer la grille des 4 catégories dès la première semaine, mesurer le ratio FP par règle |
Le fichier .trivyignore contient 50 entrées sans justification |
Pas de discipline d’écriture initiale | Reformer un fichier propre depuis zéro, refuser les commits qui ajoutent une ligne sans commentaire |
Les annotations nosemgrep n’ont jamais d’expiration |
Absence de modèle imposé en code review | Mettre en place un linter qui rejette les annotations sans date |
| Les développeurs râlent que SonarQube affiche encore des FP fermés | Confusion entre filtre Open/All | Former à filtrer sur status=Open dans la vue par défaut |
| Le ratio FP global ne descend pas malgré l’investissement | Pas de boucle de feedback sur les règles | Revue mensuelle structurée des 10 règles les plus bruyantes |
| VEX mal interprété par les outils en aval | Justification non standard | Utiliser exclusivement les valeurs normalisées CycloneDX 1.7 |
FAQ
Faut-il viser zéro faux positif ? Non, c’est irréaliste avec n’importe quel outil statique. Un programme mature vit avec 5 à 15 % de FP, sous deux conditions : ils sont systématiquement triés et tracés, et le ratio reste stable ou décroissant. Au-dessus de 30 %, l’outil perd sa valeur opérationnelle.
Quelle différence entre not-exploitable et false positive en VEX ? Un FP est une erreur de l’outil — la vulnérabilité n’a jamais existé. Un not-exploitable reconnaît la présence du code vulnérable mais affirme que la surface est protégée par un facteur externe (auth, configuration, mitigation runtime). Le second mérite une revue trimestrielle ; le premier peut être oublié sereinement.
Comment éviter qu’un développeur abuse des annotations nosemgrep ? Code review obligatoire avec règle d’or : toute annotation doit être validée par un security champion ou un reviewer senior. Métriques sur le nombre d’annotations par contributeur — un dev avec 50 annotations en six mois mérite une discussion.
Le tri des findings doit-il être centralisé ou réparti ? Centralisé pour la cohérence des règles et la mesure du ratio FP, réparti par squad pour le triage opérationnel. Le pattern qui marche : un security champion par squad fait le premier tri, puis remonte les règles à raffiner à l’équipe sécurité centrale.
Combien de temps allouer au tri par sprint ? Pour une squad de 5 à 8 développeurs, deux à quatre heures par sprint suffisent en régime stable. La première année demande 2 à 3 fois plus pour traiter la dette historique.
Tutoriels associés
- Bloquer les PR sur les nouvelles vulnérabilités sans freiner l’équipe — politique fail-on-new qui rend le tri des FP soutenable.
- Écrire ses règles Semgrep custom : pattern, taint analysis et autofix — base technique pour le raffinage des règles bruyantes.
- 🔝 Retour au guide principal : Pipeline SAST DAST SCA 2026 : architecture, outils et intégration CI/CD