Analyse statique avec Semgrep : règles maison, tests et faux positifs
Objectifs pédagogiques
- Lancer une analyse statique sans serveur, avec les règles du registre Semgrep
- Écrire des règles maison qui encodent les risques propres à un projet
- Suivre une donnée de son entrée à son usage dangereux avec le mode taint
- Tester ses règles comme du code
- Traiter un faux positif sans perdre la trace de la décision
- Intégrer l'analyse au pipeline, en ne bloquant que les nouveaux constats
Le SAST avec Semgrep
Rappel : l'analyse statique
- Le SAST lit le code sans l'exécuter et désigne la ligne en cause
- Il trouve les erreurs d'implémentation : injection,
eval(), secret codé en dur, cryptographie faible - Il ne voit pas ce qui dépend de l'exécution ou de la configuration : c'est le rôle du DAST
- Il ne voit pas les failles de conception : une vérification absente n'a pas de ligne à signaler
- Il produit des faux positifs : chaque constat se qualifie avant de se corriger
Semgrep : un SAST sans serveur
- Un outil en ligne de commande, distribué en image Docker : rien à installer, rien à héberger
- Plus de 30 langages, dont les Dockerfile, le YAML et le Terraform
- Des règles écrites en YAML, dont les motifs ressemblent au code qu'ils recherchent
- Un registre de règles prêtes à l'emploi :
p/javascript, p/owasp-top-ten, p/dockerfile - Des résultats en texte, JSON ou SARIF, et un code de sortie exploitable par le pipeline
docker run --rm -v "$PWD:/src" -w /src semgrep/semgrep:1.183.0 \
semgrep scan --config p/javascript --metrics=off --error .
--metrics=off coupe la télémétrie envoyée par défaut, --error fait échouer la commande dès le premier constat
Semgrep ou SonarQube ?
| Semgrep CE | SonarQube Community Build |
| Mise en place | une commande | un serveur et sa base de données |
| Résultats | fichier, SARIF, code de sortie | interface web, historique |
| Qualité du code | non | oui : dette, duplications, couverture |
| Règles maison | YAML, en quelques minutes | API de plugin en Java |
| Point fort | encoder les risques du projet | suivi de la qualité dans la durée |
- Les deux se complètent : SonarQube pour la qualité, Semgrep pour les règles de sécurité propres au projet
Licences et écosystème
- Moteur Semgrep CE : LGPL-2.1, une nouvelle version toutes les une à deux semaines
- Règles du registre : Semgrep Rules License depuis décembre 2024, usage interne autorisé, reprise dans un produit concurrent interdite
- L'analyse entre plusieurs fichiers et certaines règles sont réservées à la version payante
- Opengrep : fork LGPL du moteur créé en janvier 2025, maintenu par un consortium d'éditeurs, même syntaxe de règles
- ast-grep : recherche et réécriture structurelles de code (MIT), sans analyse de flux de données
| Semgrep CE | Opengrep | ast-grep |
| Moteur maintenu | oui | oui | oui |
| Catalogue de règles de sécurité maintenu | oui | figé depuis janvier 2025 | quelques règles |
| Mode taint | oui | oui | non |
| Image Docker officielle | oui | non | non |
Écrire des règles
Anatomie d'une règle
rules:
- id: securepay-eval-interdit
languages: [javascript]
severity: ERROR # ERROR, WARNING ou INFO
message: "eval() exécute une chaîne comme du code (CWE-95)"
metadata:
cwe: "CWE-95: Eval Injection"
pattern: eval(...)
id : identifiant stable, cité dans les exceptions et les rapports message : ce qu'un développeur doit comprendre sans ouvrir la documentation metadata : CWE, OWASP, lien vers la règle interne, pour trier et relier aux standards - Le Playground permet d'écrire et d'essayer une règle dans le navigateur
La syntaxe des motifs
| Motif | Signification | Exemple |
... | n'importe quels arguments ou instructions | eval(...) |
$X | métavariable : capture une expression | $X == $X |
"..." | n'importe quelle chaîne littérale | jwt.sign($P, "...") |
$F(..., $ARG, ...) | un argument quelque part dans l'appel | exec(..., req.body, ...) |
- Le motif est comparé à l'arbre syntaxique : espaces, retours à la ligne et commentaires sont ignorés
"a" + b et `a${b}` sont des formes différentes : un motif ne couvre pas toutes les écritures
Combiner les motifs
| Opérateur | Effet |
patterns | ET : toutes les conditions doivent être vraies |
pattern-either | OU : une seule suffit |
pattern-not | exclut une forme précise |
pattern-inside | ne cherche qu'à l'intérieur d'un bloc |
pattern-not-inside | exclut ce qui se trouve dans un bloc |
metavariable-regex | filtre une métavariable par une expression régulière |
patterns:
- pattern: process.env.$VAR || $DEFAUT
- metavariable-regex:
metavariable: $VAR
regex: .*(SECRET|KEY|TOKEN|PASSWORD)
- L'expression de
metavariable-regex est ancrée au début : sans .*, JWT_SECRET ne correspond pas
Suivre la donnée : le mode taint
- id: securepay-eval-entree-utilisateur
mode: taint
pattern-sources: # d'où vient la donnée non fiable
- pattern: req.body
- pattern: req.query
pattern-sinks: # où elle devient dangereuse
- pattern: eval(...)
pattern-sanitizers: # ce qui la rend sûre (facultatif)
- pattern: Number(...)
- Semgrep suit la donnée à travers les affectations et la déstructuration :
const { formula } = req.body eval(formula) est signalé, eval('1 + 1') ne l'est pas : moins de bruit qu'un simple eval(...) - La version gratuite suit la donnée à l'intérieur d'une fonction :
eval() caché dans une fonction utilitaire lui échappe, le suivi entre fonctions et entre fichiers est payant
Tester ses règles comme du code
// ruleid: securepay-eval-entree-utilisateur
const taxe = eval(calcul);
// ok: securepay-eval-entree-utilisateur
const constante = eval('1 + 1');
semgrep scan --test semgrep/
3/3: ✓ All tests passed
- Le fichier de test porte le même nom que la règle :
securepay.yml et securepay.js ruleid : la ligne suivante doit être signalée, ok : elle ne doit pas l'être - Un échec affiche les lignes manquées ou signalées à tort, et le code de sortie vaut 1
- Les tests s'exécutent dans le pipeline, comme les tests unitaires
- Le dossier des règles ne doit pas être caché : un dossier comme
.semgrep/ est ignoré par --test
Encoder un risque du projet
- Les règles du registre trouvent les failles génériques, pas les règles de votre architecture
- Une décision d'architecture devient une règle : « toute route passe par le middleware d'authentification »
- id: securepay-route-sans-authentification
severity: WARNING
message: "La route $METHODE $CHEMIN n'a aucun middleware d'authentification"
patterns:
- pattern: $ROUTEUR.$METHODE($CHEMIN, $GESTIONNAIRE)
- metavariable-regex:
metavariable: $METHODE
regex: ^(get|post|put|patch|delete)$
- Deux arguments seulement : le chemin et le gestionnaire, aucun middleware entre les deux
- La règle reste une heuristique : elle vérifie une forme d'écriture, pas que le jeton est réellement contrôlé
Les faux positifs
Qualifier avant de traiter
| Verdict | Exemple sur SecurePay | Action |
| Vrai positif | eval() sur une formule envoyée par l'utilisateur | corriger |
| Faux positif | /health sans authentification, route publique par conception | exclure et justifier |
| Risque accepté | une faille réelle, mais une correction planifiée | ticket, échéance, responsable |
- Un faux positif ne se masque pas en silence : la justification doit survivre au départ de son auteur
- Désactiver l'outil parce qu'il fait du bruit, c'est perdre aussi tous les vrais positifs
Quatre façons d'exclure, de la plus précise à la plus large
| Moyen | Portée | Quand l'utiliser |
pattern-not dans la règle | toutes les occurrences d'une forme | la règle est à vous et l'exception est une propriété du projet |
// nosemgrep: <id> | une ligne | un cas isolé, justifié par un commentaire |
--exclude-rule <id> | une règle entière | la règle ne s'applique pas au projet |
.semgrepignore | des fichiers ou dossiers | code généré, fichiers de test des règles |
// Route publique : sonde de supervision, aucune donnée sensible
// nosemgrep: securepay-route-sans-authentification
app.get('/health', (req, res) => res.json({ status: 'UP' }));
nosemgrep sans identifiant masque toutes les règles de la ligne : à proscrire --exclude-rule attend l'identifiant complet affiché dans le rapport, préfixe compris
Dans le pipeline
La commande du pipeline
semgrep scan \
--config p/javascript --config semgrep/ \
--metrics=off --error \
--sarif-output=semgrep.sarif \
${BASE_SHA:+--baseline-commit "$BASE_SHA"} .
- Règles du registre et règles maison dans le même passage
--severity ERROR limite l'analyse aux règles ERROR : utile pour un job bloquant, à côté d'un rapport complet qui ne bloque pas - SARIF : le format standard des rapports d'analyse, lu par GitHub et par les IDE
- GitLab lit son propre format :
--gitlab-sast --gitlab-sast-output=gl-sast-report.json
Ne bloquer que les nouveaux constats
- Sur un projet existant, la première analyse remonte des dizaines de constats : bloquer tout le monde est contre-productif
--baseline-commit <sha> n'analyse que les fichiers modifiés et ne signale que les constats introduits depuis ce commit - En merge request :
CI_MERGE_REQUEST_DIFF_BASE_SHA sur GitLab, github.event.pull_request.base.sha sur GitHub - Le clone doit contenir ce commit :
fetch-depth: 0 sur GitHub, GIT_DEPTH: 0 sur GitLab - La dette existante se traite à part : un ticket par constat, une échéance, et une analyse complète régulière sur la branche principale
Anti-patterns à proscrire
- Activer tous les jeux de règles d'un coup et laisser l'équipe se noyer dans le bruit
nosemgrep sans identifiant ni justification - Désactiver la règle qui bloque la mise en production plutôt que de qualifier le constat
- Des règles maison sans tests, qui ne détectent plus rien après une modification
- Une image
semgrep/semgrep:latest : un outil qui change sans prévenir change les résultats - Croire qu'un SAST au vert signifie une application sûre
À retenir
- Semgrep analyse le code sans serveur, avec des règles qui ressemblent au code recherché
- Les règles du registre couvrent les failles génériques, les règles maison encodent les risques du projet
- Le mode taint suit la donnée de sa source à son usage dangereux et réduit le bruit
- Une règle se teste comme du code, avec
ruleid et ok - Un faux positif s'exclut au plus près, avec l'identifiant de la règle et une justification
- En merge request,
--baseline-commit ne bloque que ce que la modification introduit
Pour aller plus loin