Automatisation Powershell
Secrets Next.js : 3 garde-fous PowerShell
Hook pre-commit, scan du bundle après next build et contrôle en CI : trois garde-fous PowerShell pour qu'aucun secret ne quitte plus votre projet Next.js.
13 octobre 2026
Un projet Next.js perd ses secrets par deux chemins que ni le typage, ni le linter, ni le build ne surveillent. Le premier est Git : une clé collée dans un fichier de test, un .env emporté par un git add ., et la valeur reste dans l'historique même si le commit suivant la supprime. Le second est le bundle : une valeur que le serveur devait garder pour lui part dans les fichiers servis à chaque visiteur.
Cet article met en place trois garde-fous, écrits en PowerShell pour tourner sur un poste Windows comme dans GitHub Actions : un hook pre-commit, un scan du bundle après next build, puis les deux contrôles rejoués en CI. Les deux scripts ont été testés sous Windows PowerShell 5.1, sur un dépôt jetable puis sur un faux build, et n'utilisent que des commandes communes à PowerShell 7.
Deux fuites, deux mécanismes
Dans Git, tout ce qui est committé est conservé. Supprimer la clé dans un second commit ne retire rien : elle reste lisible dans l'historique, dans chaque clone et dans chaque fork. Sur un dépôt public, des robots parcourent les nouveaux commits en continu. Sur un dépôt privé, elle est à la portée de toute personne et de toute intégration qui a un accès en lecture.
Dans le bundle, Next.js applique une règle simple : une variable préfixée NEXT_PUBLIC_ est copiée en clair dans le JavaScript au moment du build, les autres ne sont lisibles que par le serveur. Une clé d'API atteint donc le navigateur de quatre façons :
- elle a reçu le préfixe
NEXT_PUBLIC_« pour que ça marche » dans un composant client ; - elle figure dans la clé
envdenext.config.js, qui la rend lisible par le code client quel que soit son nom ; - un Server Component la passe en prop à un composant client, et elle est sérialisée dans la page ;
- elle est écrite en dur dans un module importé des deux côtés.
Le premier mécanisme se contrôle au moment du commit, le second après le build.
1. Bloquer le commit avec un hook PowerShell
Un hook pre-commit s'exécute avant l'enregistrement du commit et l'annule s'il se termine par un code de sortie non nul. Le script ci-dessous lit le diff de l'index et ne contrôle que les lignes ajoutées : un secret déjà présent dans le dépôt ne bloque pas le travail de l'équipe, il relève du scan d'historique abordé plus bas.
#Requires -Version 5.1
<#
.SYNOPSIS
Bloque les secrets dans les lignes en attente de commit.
.EXAMPLE
.\scripts\Test-GitSecrets.ps1 # index : hook pre-commit
.\scripts\Test-GitSecrets.ps1 -Range 'origin/main...HEAD' # plage de commits : CI
#>
[CmdletBinding()]
param(
[string]$Range
)
# Formats publics des fournisseurs, puis une règle générique.
$rules = [ordered]@{
'bloc PRIVATE KEY' = '-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY'
'identifiant AWS' = '\b(?:AKIA|ASIA)[0-9A-Z]{16}\b'
'jeton GitHub' = '\b(?:gh[pousr]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{22,})'
'secret Stripe live' = '\b[sr]k_live_[A-Za-z0-9]{24,}'
'secret Anthropic ou OpenAI' = '\bsk-(?:ant|proj)-[A-Za-z0-9_-]{20,}'
'jeton Slack' = '\bxox[abprs]-[A-Za-z0-9-]{10,}'
'secret en dur' = '(?i)(?:api_?key|secret|token|password)\w*["'']?\s*[:=]\s*["''](?!https?://)[^"''\s]{16,}["'']'
}
# .env, .env.local, .env.production... mais pas .env.example.
$envFile = '(^|/)\.env(\.[^/]*)?$'
$envTemplate = '\.(example|sample|template)$'
$scope = if ($Range) { $Range } else { '--cached' }
$diff = git diff $scope --unified=0 --no-color --diff-filter=ACMR
if ($LASTEXITCODE -ne 0) {
Write-Warning "Erreur de git diff ($scope) : scan impossible."
exit 2
}
$findings = @()
$file = ''
$line = 0
$inHunk = $false
foreach ($text in $diff) {
if ($text -match '^diff --git ') { $inHunk = $false; continue }
if ($text -match '^@@ -\S+ \+(\d+)') { $inHunk = $true; $line = [int]$Matches[1]; continue }
if (-not $inHunk) {
# En-tête du fichier : son nom précède le premier bloc @@.
if ($text -match '^\+\+\+ "?b/(.+?)"?$') {
$file = $Matches[1]
if ($file -match $envFile -and $file -notmatch $envTemplate) {
$findings += "${file} : fichier .env"
}
}
continue
}
if ($text -notmatch '^\+') { continue }
if ($text -notmatch 'secret-scan:allow') {
foreach ($rule in $rules.GetEnumerator()) {
if ($text -cmatch $rule.Value) { $findings += "${file}:${line} : $($rule.Key)" }
}
}
$line++
}
if ($findings.Count -gt 0) {
$findings | ForEach-Object { Write-Warning $_ }
Write-Warning 'Secret dans le diff. Retirez-le, ou signalez un faux positif avec secret-scan:allow.'
exit 1
}
Trois choix à connaître avant de l'adopter :
-cmatch, pas-match. L'opérateur-matchignore la casse, alors que les formats des fournisseurs y sont sensibles : une comparaison insensible multiplie les faux positifs.- Le rapport ne contient jamais la valeur. Fichier, ligne, règle : c'est suffisant pour corriger, et le journal de CI ne devient pas une seconde fuite.
- Les messages sont sans accents. Windows PowerShell 5.1 lit en ANSI un fichier UTF-8 sans BOM, et les caractères accentués d'un message sortiraient déformés.
La règle générique (« secret en dur ») fait du bruit par nature. Un faux positif se lève en ajoutant le commentaire secret-scan:allow sur la ligne, ce qui laisse une trace relisible en revue de code. À l'inverse, aucune liste de formats n'est complète : ajoutez ceux de vos fournisseurs.
Git ne versionne pas .git/hooks. Le hook vit donc dans un dossier du dépôt, .githooks/pre-commit :
#!/bin/sh
# PowerShell 7 s'il est installé (Windows, Linux, macOS), sinon Windows PowerShell 5.1.
if command -v pwsh >/dev/null 2>&1; then ps=pwsh; else ps=powershell.exe; fi
exec "$ps" -NoProfile -NonInteractive -File scripts/Test-GitSecrets.ps1
À sa création, une commande enregistre son bit d'exécution dans l'index : Windows s'en passe, mais Linux et macOS ignorent un hook qui ne l'a pas. Ensuite, chaque clone active le dossier une fois.
git add --chmod=+x .githooks/pre-commit # une fois, par l'auteur du hook
git config core.hooksPath .githooks # une fois par clone
Si la politique d'exécution du poste interdit les scripts, le hook échoue et le commit avec lui. RemoteSigned suffit pour un script cloné ; sous AllSigned, il doit être signé comme n'importe quel script d'entreprise, ce que détaille notre guide sur la signature des scripts PowerShell.
2. Vérifier ce que le navigateur reçoit
Le hook ne voit pas le second chemin. Une variable mal préfixée vit dans un .env.local que Git ignore, et c'est le build qui la publie. Le contrôle porte donc sur la sortie du build, et il compare des valeurs : pour chaque variable sensible du serveur, le script cherche sa valeur exacte dans les fichiers que Next.js enverra au navigateur.
#Requires -Version 5.1
<#
.SYNOPSIS
Cherche les secrets du serveur dans les fichiers servis au navigateur.
.EXAMPLE
npm run build; .\scripts\Test-BundleSecrets.ps1
#>
[CmdletBinding()]
param(
[string]$BuildDir = '.next',
[string]$Sensitive = 'KEY|SECRET|TOKEN|PASSWORD|DATABASE_URL'
)
if (-not (Test-Path -LiteralPath "$BuildDir/static")) {
Write-Warning "Aucun build dans $BuildDir : lancez next build avant le scan."
exit 2
}
# Variables du processus (CI), puis des fichiers .env* du poste, hors modèles.
$assignment = '^\s*(?:export\s+)?([A-Za-z_]\w*)\s*=\s*(?:"([^"]*)"|''([^'']*)''|([^#\s]*))'
$vars = @(
Get-ChildItem Env:
Get-ChildItem -Path . -Filter '.env*' -File -Force |
Where-Object { $_.Name -notmatch '\.(example|sample|template)$' } |
Get-Content |
ForEach-Object {
if ($_ -match $assignment) {
[pscustomobject]@{ Name = $Matches[1]; Value = "$($Matches[2])$($Matches[3])$($Matches[4])" }
}
}
)
$public = @($vars | Where-Object { $_.Name -like 'NEXT_PUBLIC_*' })
$secrets = @($vars |
Where-Object { $_.Name -notlike 'NEXT_PUBLIC_*' -and $_.Name -match $Sensitive -and $_.Value.Length -ge 16 } |
Sort-Object Name, Value -Unique)
if ($secrets.Count -eq 0) {
Write-Warning 'Aucune variable sensible en vue : scan sans objet.'
exit 2
}
# Ce que le navigateur reçoit : chunks JS et CSS, puis HTML et charges RSC des pages prérendues.
$served = @(
Get-ChildItem -Path "$BuildDir/static" -Recurse -File
Get-ChildItem -Path "$BuildDir/server/app" -Recurse -File -ErrorAction SilentlyContinue |
Where-Object { $_.Extension -in '.html', '.rsc', '.body' }
)
$leaks = 0
foreach ($var in $public) {
if ($var.Name -match 'SECRET|PASSWORD|PRIVATE') {
Write-Warning "$($var.Name) : variable publique au nom de secret"
$leaks++
}
}
foreach ($secret in $secrets) {
$hit = $served |
Select-String -SimpleMatch -CaseSensitive -Pattern $secret.Value -List |
Select-Object -First 1
if ($hit) {
Write-Warning "$($secret.Name) : valeur servie au navigateur ($($hit.Path))"
$leaks++
}
}
if ($leaks -gt 0) { exit 1 }
Write-Output "$($secrets.Count) valeurs sensibles, $($served.Count) fichiers servis : aucune fuite."
La comparaison par valeur ne s'intéresse pas à la cause. Une clé recopiée dans une variable publique, inscrite dans next.config.js, passée en prop ou écrite en dur : dans tous les cas, la valeur d'une variable du serveur se retrouve dans un fichier servi, et le script la signale sans jamais l'afficher. S'il ne trouve aucune variable à comparer, il sort en erreur : un contrôle qui ne compare rien ne doit pas passer pour un contrôle réussi.
Il reste un angle mort. Un secret qui n'existe que sous un nom public anodin, NEXT_PUBLIC_OPENAI_API_KEY par exemple, ne ressemble à rien de suspect pour le script. Toute nouvelle variable NEXT_PUBLIC_ mérite donc une question en revue de code : cette valeur peut-elle être lue par n'importe qui ?
En amont, un module réservé au serveur évite la plupart des accidents. Le paquet server-only (npm install server-only) fait échouer le build dès qu'un composant client importe le fichier :
// lib/env.server.ts
import "server-only";
function required(name: string): string {
const value = process.env[name];
if (!value) throw new Error(`Variable d'environnement manquante : ${name}`);
return value;
}
/** Secrets du serveur, lus à l'usage et jamais au chargement du module. */
export const serverEnv = {
get stripeSecretKey() {
return required("STRIPE_SECRET_KEY");
},
get resendApiKey() {
return required("RESEND_API_KEY");
},
};
Les accesseurs diffèrent la lecture : une variable absente lève une erreur à la première utilisation, avec son nom, plutôt qu'un undefined qui voyage jusqu'à l'appel d'API.
3. Rejouer les deux contrôles en CI
Un hook est local, facultatif et contournable : git commit --no-verify l'ignore, et un clone neuf ne l'a pas encore activé. Il réduit les accidents, il ne garantit rien. La garantie vient du pipeline, qui relance les mêmes scripts sur chaque pull request :
name: Secrets
on:
pull_request:
push:
branches: [main]
permissions:
contents: read
jobs:
scan:
runs-on: ubuntu-latest
# Valeurs canari, vues par le build comme par le scan : aucun secret réel.
env:
STRIPE_SECRET_KEY: canari-stripe-secret-key-0001
RESEND_API_KEY: canari-resend-api-key-0002
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Secrets dans les commits
shell: pwsh
env:
BASE: ${{ github.event.pull_request.base.sha || github.event.before }}
run: ./scripts/Test-GitSecrets.ps1 -Range "${env:BASE}...HEAD"
- uses: actions/setup-node@v5
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run build
- name: Secrets dans le bundle
shell: pwsh
run: ./scripts/Test-BundleSecrets.ps1
Quatre détails font la différence :
- Des valeurs canari, pas les vrais secrets. Le scan compare des valeurs : il lui suffit que chaque variable du serveur en porte une, reconnaissable. Le job fonctionne donc aussi sur la pull request d'un fork, à laquelle GitHub ne transmet aucun secret du dépôt. Si votre build interroge une base ou une API, seules ces variables-là restent de vrais secrets.
- La base de comparaison passe par
env. Une expression de contexte écrite directement dans un blocrunest collée dans le script avant son exécution : c'est le vecteur classique d'injection dans un workflow. permissions: contents: read. Le job lit le dépôt et rien d'autre.fetch-depth: 0. Sans historique, la base de comparaison n'existe pas etgit difféchoue. Le script sort alors en code 2 au lieu de déclarer le diff propre.
Ces réglages relèvent d'un principe plus large, celui d'un pipeline qui reste court et dont chaque étape a les droits minimaux : nous le détaillons dans notre note sur le pipeline CI/CD minimal.
Si le secret est déjà parti
Les deux scripts regardent ce qui arrive, pas ce qui est déjà là. Pour l'historique existant, passez un détecteur de secrets dédié (gitleaks, TruffleHog) sur le dépôt complet, et activez la push protection de GitHub, gratuite sur les dépôts publics. C'est l'une des cinq familles d'outils de notre trousse d'audit de sécurité.
Quand un secret a été poussé, l'ordre des opérations compte :
- Révoquer et remplacer la clé chez le fournisseur, tout de suite. C'est la seule action qui annule la fuite.
- Lire les journaux du fournisseur sur la période d'exposition, pour savoir si la clé a servi.
- Nettoyer l'historique ensuite, si nécessaire, avec
git filter-repo. C'est de l'hygiène, pas une remédiation : les clones, les forks et les caches gardent l'ancienne version.
Ce qu'il faut retenir
- Un secret fuit par Git ou par le bundle, et chaque chemin a son contrôle : le diff au commit, les fichiers servis après le build.
- Le hook pre-commit ne lit que les lignes ajoutées, ne révèle jamais la valeur et se contourne : il évite l'accident, pas la négligence.
- Le scan du bundle compare des valeurs, ce qui couvre le préfixe recopié,
next.config.js, les props et le code en dur. - La CI rejoue les deux scripts avec des valeurs canari, sans secret réel et avec des droits en lecture seule.
- Une clé poussée se révoque d'abord. L'historique se nettoie après.
Un site ou une application Next.js à concevoir, à refondre ou à optimiser ? Découvrez les services de Securium Agency, ou présentez-nous votre projet.
Article rédigé avec l'aide d'une IA, relu et vérifié par l'auteur.
Prêt à déployer automatisation powershell chez vous ?
Discutons de votre cas d'usage et voyons comment le déployer en quelques jours, pas en trimestres.