19. Gestion des secrets#
Les secrets — mots de passe, clés API, certificats, tokens — sont le maillon le plus souvent négligé de la sécurité applicative. Un secret en clair dans un dépôt git, dans un log ou dans une image Docker représente une fuite permanente : même supprimé, il reste dans l’historique. Ce chapitre couvre les stratégies modernes de gestion des secrets, du stockage centralisé avec HashiCorp Vault aux secrets dynamiques, en passant par le chiffrement de fichiers avec SOPS et l’intégration Kubernetes avec External Secrets Operator.
Le problème : secrets en clair#
Les vecteurs d’exposition involontaire des secrets sont nombreux :
Dans le code source : un mot de passe hardcodé dans un fichier de configuration ou un script. Même supprimé dans un commit ultérieur, le secret reste visible dans l’historique git. Des outils comme git-secrets, trufflehog ou gitleaks scannent l’historique pour détecter ces fuites.
Dans les images Docker : un ARG ou ENV passé lors du docker build apparaît dans les métadonnées de l’image (docker inspect) et dans chaque layer. Les multi-stage builds et les build secrets (--mount=type=secret) permettent d’éviter cet écueil.
Dans les logs : un framework qui loggue les variables d’environnement au démarrage, ou une stacktrace qui inclut une URL de connexion, expose les secrets dans les systèmes d’agrégation.
Dans les variables d’environnement : meilleur que le hardcoding, mais les variables d’environnement sont lisibles par tous les processus du même espace de noms, et souvent exposées par les endpoints de diagnostic (Spring Boot Actuator, etc.).
Les principes fondamentaux d’une bonne gestion des secrets :
Rotation régulière : un secret qui ne tourne pas est un secret qui sera compromis un jour
TTL court : un secret éphémère réduit la fenêtre d’exploitation
Audit complet : chaque accès à un secret doit être tracé (qui, quand, depuis quel service)
Least privilege : chaque service n’accède qu’aux secrets dont il a besoin
Secrets dynamiques : idéalement, les credentials sont générés à la demande et révoqués après usage
HashiCorp Vault : architecture#
Vault est le gestionnaire de secrets de référence dans l’écosystème cloud-native. Son architecture se compose de plusieurs couches :
Core : le moteur central qui gère le chiffrement (AES-256-GCM), le scellement/déscellement (seal/unseal), le token store et les politiques.
Storage backend : Vault ne stocke que des données chiffrées dans son backend. En production : Integrated Storage (Raft, recommandé depuis Vault 1.4), Consul, ou PostgreSQL. En développement : fichier local ou mémoire.
Auth methods : comment s’authentifient les clients ?
Token : le plus simple, utilisé en interne et pour les tests
AppRole : role_id + secret_id, adapté aux services automatisés
Kubernetes : utilise le ServiceAccount token JWT — le pod prouve son identité via l’API Kubernetes
AWS IAM : le service prouve son identité via la signature IAM, sans secret statique
Secret engines : comment Vault génère-t-il les secrets ?
KV v2 : stockage clé-valeur versionnée (secrets statiques avec historique)
Database : génère des credentials de base de données temporaires à la demande
PKI : autorité de certification intégrée, émet des certificats x509 avec TTL court
Transit : chiffrement/déchiffrement à la demande (Vault comme HSM logiciel)
Leases, renouvellement et révocation#
Chaque secret dynamique est associé à un lease : un identifiant unique, une durée de vie (lease_duration) et un flag renewable. Le client doit renouveler le lease avant son expiration via vault lease renew <lease_id>. À l’expiration ou lors d’une révocation explicite (vault lease revoke), Vault révoque le secret côté backend (suppression du compte DB, invalidation du token, etc.).
Politique Vault (HCL) pour un service applicatif :
# policy: app-backend.hcl
path "secret/data/production/app-backend/*" {
capabilities = ["read", "list"]
}
path "database/creds/app-backend-role" {
capabilities = ["read"]
}
path "pki/issue/app-backend" {
capabilities = ["create", "update"]
}
# Renouvellement des leases
path "sys/leases/renew" {
capabilities = ["update"]
}
# Révocation de ses propres tokens
path "auth/token/revoke-self" {
capabilities = ["update"]
}
Authentification Kubernetes#
# Vault Agent Injector — annotation sur le Pod
apiVersion: v1
kind: Pod
metadata:
annotations:
vault.hashicorp.com/agent-inject: "true"
vault.hashicorp.com/role: "app-backend"
vault.hashicorp.com/agent-inject-secret-db: "database/creds/app-backend-role"
vault.hashicorp.com/agent-inject-template-db: |
{{- with secret "database/creds/app-backend-role" -}}
DB_USER={{ .Data.username }}
DB_PASSWORD={{ .Data.password }}
{{- end }}
spec:
serviceAccountName: app-backend
Le Vault Agent sidecar s’authentifie auprès de Vault en utilisant le ServiceAccount JWT du pod, récupère les credentials et les écrit dans un fichier monté dans le pod principal.
SOPS : chiffrement de fichiers de secrets#
SOPS (Secrets OPerationS, de Mozilla) permet de versionner des fichiers de secrets chiffrés dans git. Contrairement à Vault, SOPS n’est pas un service centralisé — c’est un outil de chiffrement de fichiers.
SOPS chiffre les valeurs des fichiers YAML/JSON/ENV tout en laissant les clés en clair, ce qui permet de voir la structure du fichier et de faire des diffs lisibles.
# Avant chiffrement (secrets.yaml)
database:
password: "super_secret_password"
host: "db.prod.internal"
api_key: "sk-prod-abc123xyz"
# Après chiffrement par SOPS
database:
password: ENC[AES256_GCM,data:xyz...,tag:abc...,type:str]
host: db.prod.internal # non chiffré (pas sensible)
api_key: ENC[AES256_GCM,data:abc...,tag:xyz...,type:str]
sops:
kms:
- arn: arn:aws:kms:eu-west-1:123456789:key/mrk-abc
created_at: '2024-03-15T10:00:00Z'
age:
- recipient: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p
version: 3.8.1
Configuration .sops.yaml (à la racine du dépôt) pour gérer les clés par environnement :
creation_rules:
# Environnement de production : chiffré avec KMS AWS
- path_regex: secrets/production/.*\.yaml$
kms: arn:aws:kms:eu-west-1:123456789:key/mrk-prod-abc
age: age1prod...
# Environnement de staging : chiffré avec clé age de l'équipe
- path_regex: secrets/staging/.*\.yaml$
age: >-
age1staging1...,
age1staging2...
# Développement local : clé age individuelle
- path_regex: secrets/dev/.*\.yaml$
age: age1dev...
Intégration CI/CD : le pipeline CI s’authentifie via OIDC auprès d’AWS pour obtenir les droits KMS, puis déchiffre les secrets avec sops --decrypt.
External Secrets Operator (Kubernetes)#
External Secrets Operator (ESO) est un opérateur Kubernetes qui synchronise les secrets depuis des sources externes (Vault, AWS Secrets Manager, GCP Secret Manager, Azure Key Vault) vers des Secret Kubernetes natifs.
L’avantage est double : les pods restent compatibles avec l’API Kubernetes standard (envFrom, volumeMount), et la source de vérité des secrets reste dans le gestionnaire centralisé.
# ExternalSecret : synchronise un secret Vault vers Kubernetes
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
name: app-backend-secrets
namespace: production
spec:
refreshInterval: 1h
secretStoreRef:
name: vault-backend
kind: ClusterSecretStore
target:
name: app-backend-secrets # nom du Secret Kubernetes créé
creationPolicy: Owner
data:
- secretKey: DB_PASSWORD # clé dans le Secret Kubernetes
remoteRef:
key: secret/production/app-backend
property: db_password # champ dans Vault KV
- secretKey: API_KEY
remoteRef:
key: secret/production/app-backend
property: api_key
# ClusterSecretStore : configuration de connexion à Vault
apiVersion: external-secrets.io/v1beta1
kind: ClusterSecretStore
metadata:
name: vault-backend
spec:
provider:
vault:
server: "https://vault.internal:8200"
path: "secret"
version: "v2"
auth:
kubernetes:
mountPath: "kubernetes"
role: "external-secrets"
serviceAccountRef:
name: "external-secrets"
namespace: "external-secrets"
Autres gestionnaires de secrets cloud#
AWS Secrets Manager : stockage et rotation automatique nativement intégrés avec RDS, Redshift, DocumentDB. La rotation est gérée par des Lambda functions managées. Supporte le versionning et les politiques IAM.
GCP Secret Manager : simple et économique (0,06 $/10 000 accès). Intégration native avec Cloud Run, GKE Workload Identity. Audit via Cloud Audit Logs.
Azure Key Vault : stockage de secrets, clés et certificats. Intégration native avec Azure AD, App Service, AKS. HSM-backed pour les clés de chiffrement.
La comparaison principale porte sur le coût, la fonctionnalité de secrets dynamiques (uniquement Vault), et la profondeur de l’intégration avec l’écosystème cloud respectif.
Bonnes pratiques CI : OIDC pour éviter les secrets statiques#
La pratique moderne dans les pipelines CI/CD est d’éliminer les secrets statiques au profit de l”authentification OIDC (OpenID Connect). Au lieu de stocker un AWS_ACCESS_KEY_ID dans les secrets GitHub, le job CI obtient un token JWT signé par GitHub et l’échange contre des credentials AWS temporaires via AssumeRoleWithWebIdentity.
# GitHub Actions avec OIDC AWS
jobs:
deploy:
permissions:
id-token: write # Nécessaire pour demander le JWT OIDC
contents: read
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789:role/github-ci-deploy
aws-region: eu-west-1
# Pas de secrets statiques — authentification OIDC
Le rôle IAM github-ci-deploy autorise uniquement les repos et branches spécifiques via une condition sur le claim sub du token JWT.
Visualisations#
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
from matplotlib.patches import FancyBboxPatch
import seaborn as sns
import numpy as np
import pandas as pd
import networkx as nx
sns.set_theme(style="whitegrid", palette="muted", font_scale=1.1)
Modèle de risque : probabilité de compromission selon la stratégie#
sns.set_theme(style="whitegrid", palette="muted", font_scale=1.1)
strategies = [
"Hardcodé\ndans le code",
"Variable\nd'environnement",
"Fichier .env\nnon chiffré",
"SOPS\n(git chiffré)",
"Vault KV\n(statique)",
"Vault DB\n(dynamique)",
]
# Score de risque (0-100) : probabilité estimée de compromission sur 1 an
risk_scores = [95, 55, 65, 20, 12, 4]
# Composantes du risque
risk_components = {
"Exposition code source": [35, 0, 5, 0, 0, 0],
"Exposition logs/images": [25, 20, 20, 5, 3, 1],
"Durée d'exposition": [20, 20, 25, 8, 5, 1],
"Absence d'audit": [15, 15, 15, 7, 4, 2],
}
fig, axes = plt.subplots(1, 2, figsize=(14, 6))
# Graphique 1 : score global
colors_risk = ["#e06c75" if s > 50 else "#C4AD66" if s > 20 else "#6ACC65"
for s in risk_scores]
bars = axes[0].barh(strategies, risk_scores, color=colors_risk, edgecolor="white",
height=0.6)
axes[0].set_xlabel("Score de risque (0–100)")
axes[0].set_title("Risque de compromission selon la stratégie\n(estimation sur 1 an)")
axes[0].set_xlim(0, 105)
for bar, score in zip(bars, risk_scores):
axes[0].text(score + 1, bar.get_y() + bar.get_height()/2,
f"{score}", va="center", fontsize=10, fontweight="bold")
# Graphique 2 : décomposition empilée
component_names = list(risk_components.keys())
component_colors = ["#e06c75", "#C4AD66", "#B47CC7", "#4878CF"]
bottoms = np.zeros(len(strategies))
for comp_name, comp_color in zip(component_names, component_colors):
vals = risk_components[comp_name]
axes[1].barh(strategies, vals, left=bottoms, label=comp_name,
color=comp_color, alpha=0.85, height=0.6, edgecolor="white")
bottoms += np.array(vals)
axes[1].set_xlabel("Score de risque composé")
axes[1].set_title("Décomposition du risque par composante")
axes[1].legend(fontsize=8, loc="lower right")
axes[1].set_xlim(0, 105)
fig.suptitle("Modèle de risque — gestion des secrets", fontsize=13, y=1.02)
plt.show()
Cycle de vie d’un secret statique vs dynamique Vault#
sns.set_theme(style="whitegrid", palette="muted", font_scale=1.1)
fig, ax = plt.subplots(figsize=(13, 6))
ax.set_xlim(0, 400)
ax.set_ylim(-1, 5)
ax.axis("off")
ax.set_title("Cycle de vie d'un secret : statique vs dynamique Vault", fontsize=13, pad=12)
# --- Secret statique (ligne du haut) ---
y_static = 3.5
# Phase : création (semaine 0 à semaine 52+)
phases_static = [
(0, 60, "#e06c75", "Création\n(manuel)"),
(60, 200, "#c678dd", "Utilisation\nen production\n(6-12 mois)"),
(200, 260, "#C4AD66", "Compromission\npotentielle\n(inconnue)"),
(260, 340, "#e06c75", "Découverte\n& rotation\n(manuel)"),
(340, 380, "#6ACC65", "Nouveau\nsecret"),
]
for x_start, x_end, color, label in phases_static:
width = x_end - x_start
rect = FancyBboxPatch((x_start + 2, y_static - 0.35), width - 4, 0.7,
boxstyle="round,pad=0.05", facecolor=color, alpha=0.8,
edgecolor="white", linewidth=1.5)
ax.add_patch(rect)
ax.text((x_start + x_end) / 2, y_static, label,
ha="center", va="center", fontsize=8, color="white", fontweight="bold")
ax.text(-5, y_static, "Secret\nstatique", ha="right", va="center",
fontsize=10, fontweight="bold", color="#e06c75")
# Indicateur de fenêtre d'exposition
ax.annotate("", xy=(260, y_static + 0.6), xytext=(60, y_static + 0.6),
arrowprops=dict(arrowstyle="<->", color="#e06c75", lw=1.5))
ax.text(160, y_static + 0.85, "Fenêtre d'exposition : 6-12 mois",
ha="center", fontsize=9, color="#e06c75")
# --- Secret dynamique Vault (lignes du bas) ---
y_dynamic_base = 1.5
ttl_minutes = [0, 60, 120, 180, 240, 300, 360] # TTL = 1h, 6 cycles sur 360 min
cycle_colors = ["#4878CF", "#6ACC65", "#B47CC7", "#4878CF", "#6ACC65", "#B47CC7"]
cycle_width = 60 # 1h par cycle
for i, (x_start_raw, color) in enumerate(zip(ttl_minutes[:-1], cycle_colors)):
x_start = x_start_raw / 360 * 340
x_end = (x_start_raw + cycle_width) / 360 * 340
rect = FancyBboxPatch((x_start + 2, y_dynamic_base - 0.35), x_end - x_start - 4, 0.7,
boxstyle="round,pad=0.05", facecolor=color, alpha=0.85,
edgecolor="white", linewidth=1.5)
ax.add_patch(rect)
ax.text((x_start + x_end) / 2, y_dynamic_base,
f"Cred\n#{i+1}", ha="center", va="center",
fontsize=8, color="white", fontweight="bold")
# Flèche de révocation
if i < len(cycle_colors) - 1:
ax.annotate("", xy=(x_end, y_dynamic_base),
xytext=(x_end, y_dynamic_base - 0.6),
arrowprops=dict(arrowstyle="-|>", color="#aaaaaa", lw=1))
ax.text(x_end, y_dynamic_base - 0.8, "révoqué",
ha="center", fontsize=7, color="#888888")
ax.text(-5, y_dynamic_base, "Vault\ndynamique\n(TTL 1h)", ha="right", va="center",
fontsize=10, fontweight="bold", color="#4878CF")
ax.annotate("", xy=(340, y_dynamic_base + 0.6), xytext=(0, y_dynamic_base + 0.6),
arrowprops=dict(arrowstyle="<->", color="#4878CF", lw=1.5))
ax.text(170, y_dynamic_base + 0.85, "Fenêtre d'exposition max : 1 heure par credential",
ha="center", fontsize=9, color="#4878CF")
ax.axhline(y=2.5, xmin=0.02, xmax=0.98, color="#dddddd", linewidth=1, linestyle="--")
plt.show()
Graphe de flux External Secrets Operator#
sns.set_theme(style="whitegrid", palette="muted", font_scale=1.1)
G = nx.DiGraph()
nodes = {
"Vault\n(KV/Database)": {"color": "#e06c75", "pos": (0, 2)},
"ClusterSecretStore": {"color": "#C4AD66", "pos": (2, 2)},
"ExternalSecret\n(CRD)": {"color": "#B47CC7", "pos": (2, 0)},
"ESO Controller": {"color": "#4878CF", "pos": (4, 1)},
"Kubernetes\nSecret": {"color": "#6ACC65", "pos": (6, 2)},
"Pod\n(App)": {"color": "#4878CF", "pos": (8, 2)},
"Service Account\n(JWT)":{"color": "#C4AD66", "pos": (4, 3)},
}
for node, attrs in nodes.items():
G.add_node(node, **attrs)
edges = [
("ClusterSecretStore", "Vault\n(KV/Database)", "auth (K8s JWT)"),
("ExternalSecret\n(CRD)", "ESO Controller", "watch"),
("ClusterSecretStore", "ESO Controller", "config"),
("ESO Controller", "Vault\n(KV/Database)", "fetch secret"),
("ESO Controller", "Kubernetes\nSecret", "create/update"),
("Pod\n(App)", "Kubernetes\nSecret", "envFrom / mount"),
("Service Account\n(JWT)","ESO Controller", "RBAC"),
]
for src, dst, label in edges:
G.add_edge(src, dst, label=label)
pos = {node: attrs["pos"] for node, attrs in nodes.items()}
node_colors = [attrs["color"] for attrs in nodes.values()]
fig, ax = plt.subplots(figsize=(14, 7))
ax.set_title("Flux External Secrets Operator\n(Vault → ESO → Kubernetes Secret → Pod)",
fontsize=13, pad=12)
nx.draw_networkx_nodes(G, pos, ax=ax, node_color=node_colors,
node_size=3000, alpha=0.9)
nx.draw_networkx_labels(G, pos, ax=ax, font_size=8, font_color="white",
font_weight="bold")
edge_labels = {(src, dst): label for src, dst, label in edges}
nx.draw_networkx_edges(G, pos, ax=ax, edge_color="#888888",
arrows=True, arrowsize=20,
connectionstyle="arc3,rad=0.1",
width=1.5, min_source_margin=30, min_target_margin=30)
nx.draw_networkx_edge_labels(G, pos, edge_labels=edge_labels, ax=ax,
font_size=7, font_color="#444444",
bbox=dict(boxstyle="round,pad=0.2",
fc="white", alpha=0.7))
ax.axis("off")
plt.show()
Résumé#
Les secrets hardcodés dans le code source restent dans l’historique git même après suppression — scanner l’historique avec
gitleaksoutrufflehogest indispensable avant toute migration.HashiCorp Vault est la solution de référence : son architecture modulaire (auth methods, secret engines, policies) permet d’adapter la gestion des secrets à chaque contexte d’exécution.
L’authentification Kubernetes via ServiceAccount JWT élimine tout secret statique pour l’accès de Vault depuis les pods — c’est le pattern recommandé en production.
Les secrets dynamiques (Vault Database engine) réduisent la fenêtre d’exposition de plusieurs mois à quelques heures, avec révocation automatique à l’expiration du lease.
SOPS permet de versionner des fichiers de secrets chiffrés dans git tout en conservant la lisibilité de la structure — la clé AWS KMS ou
agesert de racine de confiance par environnement.External Secrets Operator découple la gestion des secrets (Vault, AWS SM) de leur consommation (Kubernetes Secret natif), sans modifier les applications existantes.
L’audit trail est non négociable : chaque accès à un secret doit générer un événement traçable (Vault Audit Log, AWS CloudTrail) pour la conformité et la détection d’anomalies.
L’OIDC dans les pipelines CI/CD élimine les secrets statiques de service (AWS keys, tokens) en remplaçant l’authentification par des tokens JWT temporaires émis par le provider CI.
La rotation automatique (Vault dynamic secrets, AWS Secrets Manager Lambda rotation) supprime le risque humain lié à la rotation manuelle tardive ou oubliée.
Le principe de least privilege appliqué aux secrets — chaque service n’accède qu’à ses propres chemins Vault avec les seules capabilities nécessaires — limite le rayon de blast en cas de compromission.