Analyse statique avec Semgrep : règles maison, tests et faux positifs¶
Objectifs pédagogiques¶
À la fin de ce TP, vous devez être capables de :
- lancer Semgrep en local avec les règles du registre et lire ses résultats
- écrire des règles maison, dont une règle en mode taint qui suit la donnée
- tester ses règles avec des cas positifs et négatifs
- qualifier un constat et traiter un faux positif au plus près, avec une justification
- intégrer l'analyse au pipeline en ne bloquant, en merge request, que les nouveaux constats
Prérequis¶
- Docker installé (sous Windows, travaillez dans WSL)
- Votre copie du dépôt securepay-api, celle du TP Premier pipeline de sécurité si vous l'avez fait
jqpour lire les rapports JSON (facultatif : un éditeur suffit)- Les slides du cours
Testé
Tous les ateliers ont été joués sur le dépôt SecurePay avec Semgrep 1.183.0 : les résultats décrits dans « Ce que vous devez observer » sont ceux obtenus. Le workflow GitHub Actions a été vérifié avec actionlint.
Contexte¶
Le pipeline de SecurePay lance déjà Semgrep avec les règles du registre, et ne signale qu'une seule faille : l'eval(). Pourtant, l'analyse d'architecture a montré bien plus : des secrets avec une valeur par défaut dans le code, et des routes de paiement qui ne vérifient jamais le jeton. Les règles génériques ne connaissent pas votre projet : vous allez écrire les vôtres, les tester, gérer leurs faux positifs, puis les brancher sur le pipeline.
Mise en place¶
Placez-vous à la racine du projet et créez un alias, pour lancer Semgrep par son image Docker comme s'il était installé :
cd securepay-api
alias semgrep='docker run --rm -v "$PWD:/src" -w /src semgrep/semgrep:1.183.0 semgrep'
semgrep --version
L'alias monte le dossier courant : lancez toujours les commandes depuis la racine du projet.
Atelier 1 : Premier scan avec les règles du registre¶
Travail demandé¶
- Lancez une analyse avec les règles JavaScript, puis affichez le code de sortie :
-
Relancez avec
--error, et comparez le code de sortie. -
Produisez un rapport JSON et listez les constats :
semgrep scan --config p/javascript --metrics=off --json --output registre.json .
jq -r '.results[] | "\(.check_id) \(.path):\(.start.line) [\(.extra.severity)]"' registre.json
- Relancez avec un autre jeu de règles,
p/owasp-top-ten, et comparez.
Ce que vous devez observer¶
p/javascripttrouve un seul constat,code-string-concat, à la ligne 62 desrc/routes/payments.js: une donnée de la requête arrive danseval()- Sans
--error, le code de sortie vaut 0 même s'il y a des constats : un pipeline ne bloquerait rien p/owasp-top-tenajoute la ligne 27 duDockerfile(missing-user) : le conteneur tourne en root. Chaque jeu de règles a son périmètre- Rien sur les secrets par défaut, rien sur le jeton jamais vérifié : ce sont des risques propres à SecurePay
Critère de réussite¶
Vous savez quels jeux de règles vous exécutez, ce qu'ils trouvent, et pourquoi --error est indispensable dans un pipeline.
Atelier 2 : Une première règle maison, les secrets par défaut¶
Dans auth.js et payments.js, un secret absent de l'environnement est remplacé par une valeur écrite dans le code. En production, une variable oubliée suffit pour que l'application démarre avec un secret connu de tous.
Travail demandé¶
- Créez le dossier
semgrep/et le fichiersemgrep/securepay.yml:
rules:
- id: securepay-secret-par-defaut
languages: [javascript]
severity: ERROR
message: "Le secret $VAR a une valeur par défaut dans le code : sans variable d'environnement, l'application démarre avec un secret connu de tous"
metadata:
cwe: "CWE-798: Use of Hard-coded Credentials"
patterns:
- pattern: process.env.$VAR || $DEFAUT
- metavariable-regex:
metavariable: $VAR
regex: (SECRET|KEY|TOKEN|PASSWORD)
- Lancez uniquement vos règles :
- La règle ne trouve rien. Consultez la documentation de
metavariable-regex, trouvez pourquoi, et corrigez l'expression régulière.
Ce que vous devez observer¶
- L'expression régulière est ancrée au début de la valeur :
JWT_SECRETne commence pas parSECRET. Avecregex: .*(SECRET|KEY|TOKEN|PASSWORD), la règle fonctionne - Deux constats :
src/routes/auth.jsligne 7 (JWT_SECRET) etsrc/routes/payments.jsligne 12 (STRIPE_SECRET_KEY) process.env.NODE_ENV || 'development', dansapp.js, n'est pas signalé : la métavariable filtre ce qui n'est pas un secret- L'identifiant affiché est
semgrep.securepay-secret-par-defaut: Semgrep préfixe vos règles par le nom de leur dossier
Critère de réussite¶
La règle signale les deux secrets par défaut, et rien d'autre.
Atelier 3 : Suivre la donnée avec le mode taint¶
Une règle eval(...) signalerait aussi eval('1 + 1'), qui n'est pas dangereux. Ce qui rend l'eval() de SecurePay dangereux, c'est que son argument vient de la requête.
Travail demandé¶
- Ajoutez cette règle à
semgrep/securepay.yml:
- id: securepay-eval-entree-utilisateur
languages: [javascript]
severity: ERROR
message: "Une donnée de la requête est exécutée comme du code avec eval() (CWE-95)"
metadata:
cwe: "CWE-95: Eval Injection"
mode: taint
pattern-sources:
- pattern: req.body
- pattern: req.query
- pattern: req.params
pattern-sinks:
- pattern: eval(...)
-
Relancez l'analyse. Relisez les lignes 52 à 62 de
payments.js: par quelles étapes la donnée passe-t-elle avant d'atteindreeval()? -
Testez la limite de l'outil : déplacez l'appel dans une fonction utilitaire du même fichier, puis relancez l'analyse avec vos règles et
p/javascript:
function calculer(expression) {
return eval(expression);
}
// ...
const computedTax = calculer(customFormula);
- Annulez la modification avec
git checkout src/routes/payments.js.
Ce que vous devez observer¶
- La règle signale la ligne 62 : Semgrep a suivi la donnée de
req.bodyà travers la déstructuration (const { amount, formula } = req.body) puis l'affectation (customFormula = formula || ...) - La règle
code-string-concatdu registre est elle aussi une règle en mode taint - Une fois l'
eval()déplacé dans une fonction, plus aucune règle ne le signale : la version gratuite suit la donnée à l'intérieur d'une fonction, pas d'une fonction à l'autre. Un SAST au vert ne prouve pas l'absence de faille
Critère de réussite¶
Vous savez expliquer ce qu'est une source et une destination dangereuse (sink), et citer une limite concrète de l'analyse.
Atelier 4 : Tester les règles¶
Une règle maison est du code : sans test, une modification anodine peut la rendre muette sans que personne ne s'en aperçoive.
Travail demandé¶
- Créez
semgrep/securepay.js, le fichier de test qui accompagnesecurepay.yml:
const express = require('express');
const router = express.Router();
// ruleid: securepay-secret-par-defaut
const JWT_SECRET = process.env.JWT_SECRET || 'secret-de-dev';
// ok: securepay-secret-par-defaut
const ENVIRONNEMENT = process.env.NODE_ENV || 'development';
function calculerTaxe(req) {
const { formula } = req.body;
const calcul = formula || 'amount * 0.20';
// ruleid: securepay-eval-entree-utilisateur
const taxe = eval(calcul);
// ok: securepay-eval-entree-utilisateur
const constante = eval('1 + 1');
return { taxe, constante };
}
- Lancez les tests :
-
Cassez un test : remplacez
// ok:par// ruleid:au-dessus deeval('1 + 1'), relancez, lisez le message, puis rétablissez. -
Le fichier de test contient du code vulnérable volontairement : excluez-le de l'analyse du projet, puis vérifiez que
semgrep scan --config semgrep/ --metrics=off .ne le signale plus :
Ce que vous devez observer¶
2/2: ✓ All tests passedet un code de sortie à 0- Le test cassé échoue avec
missed lines: [15]et un code de sortie à 1 : la règle n'a pas signalé une ligne qu'elle aurait dû signaler. C'est voulu,eval('1 + 1')n'a pas de source - Sans
.semgrepignore, l'analyse du projet signale aussi les lignes du fichier de test - Ne rangez pas les règles dans un dossier caché :
.semgrep/est ignoré par--test
Critère de réussite¶
Chaque règle a au moins un cas positif (ruleid) et un cas négatif (ok), et les tests passent.
Atelier 5 : Encoder un risque d'architecture, puis traiter les faux positifs¶
L'analyse d'architecture a montré que le jeton JWT n'est jamais vérifié. Vous allez en faire une règle : toute route doit passer par un middleware d'authentification.
Travail demandé¶
- Ajoutez la règle :
- id: securepay-route-sans-authentification
languages: [javascript]
severity: WARNING
message: "La route $METHODE $CHEMIN n'a aucun middleware d'authentification"
metadata:
cwe: "CWE-306: Missing Authentication for Critical Function"
patterns:
- pattern: $ROUTEUR.$METHODE($CHEMIN, $GESTIONNAIRE)
- metavariable-regex:
metavariable: $METHODE
regex: ^(get|post|put|patch|delete)$
- Lancez l'analyse et qualifiez chaque constat dans un tableau :
| Route | Fichier et ligne | Verdict | Justification |
|---|---|---|---|
get '/health' | src/app.js:19 | ... | ... |
| ... |
- Traitez le faux positif de
/healthavec une exception en ligne, justifiée, juste au-dessus de la route :
// Route publique : sonde de supervision, aucune donnée sensible
// nosemgrep: securepay-route-sans-authentification
app.get('/health', (req, res) => {
- Les routes publiques sont une propriété du projet, pas un cas isolé : retirez le commentaire
nosemgrepet déclarez-les plutôt dans la règle, à la suite demetavariable-regex:
# Routes publiques par conception, validées par l'équipe sécurité
- pattern-not: $ROUTEUR.get('/health', $GESTIONNAIRE)
- pattern-not: $ROUTEUR.get('/api/info', $GESTIONNAIRE)
- pattern-not: $ROUTEUR.post('/login', $GESTIONNAIRE)
- Complétez le fichier de test : une route sans middleware (
ruleid), une route avec middleware et une route publique (ok). Relancez les tests.
// ruleid: securepay-route-sans-authentification
router.post('/process', (req, res) => res.status(201).json({}));
// ok: securepay-route-sans-authentification
router.get('/recent', authentifier, (req, res) => res.json([]));
// ok: securepay-route-sans-authentification
router.get('/health', (req, res) => res.json({ status: 'UP' }));
- Essayez d'exclure la règle entière avec
--exclude-rule securepay-route-sans-authentification, puis avec l'identifiant affiché dans le rapport. Qu'observez-vous ?
Ce que vous devez observer¶
- Six constats avant traitement :
/healthet/api/infodansapp.js,/logindansauth.js,/process,/calculate-taxet/recentdanspayments.js - Les trois routes de paiement sont de vrais positifs : le jeton émis par
/loginn'est vérifié nulle part. La correction est un middleware qui vérifie le jeton, pas une exception /health,/api/infoet/loginsont publiques par conception : ce sont des faux positifs- Avec les
pattern-not, il reste trois constats, et la liste des routes publiques est relue à chaque modification de la règle --exclude-rulen'a d'effet qu'avec l'identifiant complet,semgrep.securepay-route-sans-authentification3/3: ✓ All tests passed
Critère de réussite¶
Chaque constat est qualifié, chaque exclusion est justifiée, et vous savez dire quand préférer pattern-not à nosemgrep.
Atelier 6 : Dans le pipeline¶
Vous faites évoluer le job d'analyse du TP Premier pipeline de sécurité : il teste les règles maison, les exécute avec celles du registre, et, en merge request, ne bloque que les constats introduits par la modification.
Travail demandé¶
- Remplacez le job d'analyse statique :
sast:
name: Code (Semgrep)
runs-on: ubuntu-latest
container:
image: semgrep/semgrep:1.183.0
permissions:
contents: read
security-events: write # publier le rapport SARIF dans l'onglet Security
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0 # l'historique complet, pour --baseline-commit
- name: Tests des règles maison
run: semgrep scan --test semgrep/
- name: Analyse statique
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
git config --global --add safe.directory "$GITHUB_WORKSPACE"
semgrep scan --config p/javascript --config semgrep/ --metrics=off --error \
--sarif-output=semgrep.sarif ${BASE_SHA:+--baseline-commit "$BASE_SHA"} .
- uses: github/codeql-action/upload-sarif@v4
if: always()
with:
sarif_file: semgrep.sarif
Ajoutez aussi le déclencheur pull_request: sous on: si votre workflow ne l'a pas. Hors pull request, BASE_SHA est vide et l'analyse porte sur tout le projet.
sast:semgrep:
stage: securite
image: semgrep/semgrep:1.183.0
variables:
GIT_DEPTH: "0" # l'historique complet, pour --baseline-commit
script:
- semgrep scan --test semgrep/
- >
semgrep scan --config p/javascript --config semgrep/ --metrics=off --error
--gitlab-sast --gitlab-sast-output=gl-sast-report.json
${CI_MERGE_REQUEST_DIFF_BASE_SHA:+--baseline-commit "$CI_MERGE_REQUEST_DIFF_BASE_SHA"} .
artifacts:
when: always
paths: [gl-sast-report.json]
reports:
sast: gl-sast-report.json
CI_MERGE_REQUEST_DIFF_BASE_SHA n'existe que dans les pipelines de merge request. Pour les activer sans doubler les pipelines, ajoutez en tête du fichier :
-
Poussez sur la branche principale : le job échoue, car les failles de SecurePay ne sont pas corrigées. C'est attendu.
-
Prouvez le mode différentiel : sur une nouvelle branche, ajoutez une route sans middleware à la fin de
payments.js, poussez et ouvrez une pull request ou une merge request :
Ce que vous devez observer¶
- Le job échoue en merge request avec un seul constat : la nouvelle route
delete '/:reference'. Les constats déjà présents sur la branche principale ne sont pas répétés - Le journal indique
Scan was limited to files changed since baseline commit - Si un test de règle échoue, le job s'arrête avant l'analyse : une règle cassée ne laisse rien passer en silence
- Sur GitHub, les constats apparaissent dans l'onglet Security, rubrique Code scanning (gratuit pour un dépôt public, payant pour un dépôt privé). Sur GitLab, leur affichage dans la merge request demande l'offre Ultimate ; sinon, le rapport reste téléchargeable
Critère de réussite¶
Les règles sont testées dans le pipeline, et une merge request n'est bloquée que par ce qu'elle introduit.
Questions de synthèse¶
- Pourquoi les règles du registre ne trouvent-elles ni les secrets par défaut, ni les routes sans authentification ?
- Qu'apporte le mode taint par rapport à une règle
eval(...)? Quelle limite avez-vous constatée ? - Pourquoi tester une règle maison, et que vérifie un cas
ok? - Dans quels cas préférer
pattern-not,nosemgrep,--exclude-ruleou.semgrepignore? - Pourquoi ne bloquer que les nouveaux constats en merge request, et comment traiter la dette existante ?
- La règle des routes signale l'absence de middleware : prouve-t-elle pour autant que le jeton est vérifié ?