Aller au contenu

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