Format des logs d'audit Kubernetes : anatomie d'un event
Le format des logs d'audit Kubernetes champ par champ : stages, niveaux, user, sourceIPs, objectRef, responseStatus, annotations, utiles en forensique.
TL;DR. Un événement d'audit Kubernetes est un objet JSON de kind Event (audit.k8s.io/v1) qui décrit une requête à un stage donné. Les champs utiles dans toute enquête sont user, impersonatedUser, sourceIPs, userAgent, verb, objectRef, requestURI, responseStatus.code, les annotations authorization.k8s.io/decision et reason et, quand le niveau d'audit le permet, requestObject. Dédupliquez les stages sur auditID, ne faites confiance qu'à la dernière IP source, et souvenez-vous qu'au niveau Metadata il n'y a pas de corps.
La plupart des gens découvrent le format des logs d'audit en plein incident, dans une fenêtre CloudWatch ou Log Analytics, face à des milliers de lignes presque identiques. Savoir quels champs portent la preuve et lesquels sont du bruit fait gagner des heures.
Un événement complet
Voici un événement abrégé pour un kubectl exec dans un pod, tel que l'API server l'écrit avec le backend log (un objet JSON par ligne). Les noms et adresses sont fictifs.
{
"kind": "Event",
"apiVersion": "audit.k8s.io/v1",
"level": "Metadata",
"auditID": "4f1e0a52-8c1b-4d0e-9d3e-2b7a5c9e1f00",
"stage": "ResponseStarted",
"requestURI": "/api/v1/namespaces/shop/pods/api-6c9f7d8b5-q2w8x/exec?command=sh&container=api&stdin=true&stdout=true&tty=true",
"verb": "create",
"user": {
"username": "alice@example.com",
"groups": ["platform-admins", "system:authenticated"]
},
"sourceIPs": ["198.51.100.23"],
"userAgent": "kubectl/v1.30.2 (linux/amd64) kubernetes/3968350",
"objectRef": {
"resource": "pods",
"namespace": "shop",
"name": "api-6c9f7d8b5-q2w8x",
"apiVersion": "v1",
"subresource": "exec"
},
"responseStatus": { "metadata": {}, "code": 101 },
"requestReceivedTimestamp": "2026-09-14T09:12:03.412000Z",
"stageTimestamp": "2026-09-14T09:12:03.448000Z",
"annotations": {
"authorization.k8s.io/decision": "allow",
"authorization.k8s.io/reason": "RBAC: allowed by ClusterRoleBinding \"platform-admins\" of ClusterRole \"cluster-admin\" to Group \"platform-admins\""
}
}
Le schéma complet est dans la référence de configuration d'audit de kube-apiserver. Voici ce que vaut chaque champ pour un enquêteur.
Stages : une requête, plusieurs événements
Une requête peut produire jusqu'à quatre événements, un par stage :
| Stage | Quand il est émis | Valeur forensique |
|---|---|---|
RequestReceived | Dès que le handler reçoit la requête | Doublon de l'événement final, sans réponse |
ResponseStarted | En-têtes envoyés, corps pas encore (requêtes longues : watch, exec, attach, port-forward) | Le seul événement pour une session exec encore ouverte |
ResponseComplete | Réponse terminée | La ligne « une requête » normale |
Panic | Le handler a paniqué | Rare ; à regarder si vous en voyez |
Tous les événements d'une même requête partagent le même auditID. Beaucoup de politiques (dont celles, managées, d'EKS et de GKE) omettent RequestReceived. Pour compter, regroupez les stages sur auditID, sinon chaque exec sera compté deux fois. C'est ce que fait l'analyseur de la page d'accueil : il garde un événement par requête et, pour exec, attach et port-forward, garde l'événement ResponseStarted.
Niveaux : quelle part de la requête vous obtenez
La politique d'audit attribue un des quatre niveaux à chaque requête :
None: rien n'est journalisé.Metadata: utilisateur, source, verbe, objet, code de réponse. Pas de corps.Request: métadonnées plus le corps de la requête (requestObject).RequestResponse: plus le corps de la réponse (responseObject).
Le niveau figure dans le champ level de l'événement : vous pouvez donc savoir, à partir des données elles-mêmes, si un corps absent signifie « non envoyé » ou « non journalisé ». C'est important : un create sur daemonsets au niveau Metadata vous dit qu'un DaemonSet a été créé, pas qu'il était privilégié. L'article sur la politique d'audit montre comment obtenir les corps là où ils comptent.
Qui : user, groups, impersonation
user.username est l'identité authentifiée. Sa forme en dit long :
system:serviceaccount:<namespace>:<name>: un token de compte de service.system:node:<node-name>: un kubelet.system:anonymous: une requête non authentifiée qui a été acceptée (accès anonyme).- Un e-mail, un mapping d'ARN IAM ou un objet Entra ID : un humain ou une identité cloud, selon la plateforme.
user.groups explique beaucoup de décisions d'autorisation. user.extra peut porter des identifiants utiles : pour les tokens de compte de service, les versions récentes ajoutent un identifiant de credential (le JTI du token), qui permet de distinguer deux tokens d'un même compte de service, comme le décrit la documentation des comptes de service.
Quand l'appelant utilise des en-têtes d'impersonation, impersonatedUser contient l'identité sous laquelle la requête a été autorisée. Lisez toujours les deux champs ensemble.
D'où : sourceIPs et userAgent
sourceIPs est construit à partir de X-Forwarded-For, puis de X-Real-Ip, puis de l'adresse distante de la connexion. La référence est explicite : toutes les IP sauf la dernière peuvent être fixées par le client. Utilisez la dernière, et sachez ce qui se trouve devant votre API server (un load balancer cloud, un saut Konnectivity ou VPN) avant de conclure.
userAgent est déclaré par le client et trivial à falsifier, mais les attaquants s'en donnent rarement la peine. L'agent habituel d'un contrôleur ressemble à kube-controller-manager/v1.30.4 … ; un workload qui utilise la bibliothèque cliente affiche le nom de son binaire ; une personne affiche kubectl/…. Un compte de service qui parle soudain kubectl/ ou curl/ est un signal fort de token rejoué à la main.
Quoi : verb, objectRef, requestURI
verb est le verbe Kubernetes (get, list, watch, create, update, patch, delete, deletecollection), pas la méthode HTTP. objectRef contient resource, namespace, name, apiGroup, apiVersion et subresource.
Trois détails piègent souvent :
- Un
listou unwatchsansobjectRef.namespacecouvre tout le cluster. Sursecrets, cela renvoie tous les secrets du cluster. - Sur un
create,objectRef.namepeut manquer, car le nom est dans le corps. Au niveauMetadata, vous ne verrez parfois que la collection. - Les sous-ressources portent l'action.
pods/exec,pods/attach,pods/portforward,pods/log,serviceaccounts/token,nodes/proxysont « la partie dangereuse » d'une ressource par ailleurs banale.
requestURI conserve la query string. Pour un exec, cela inclut chaque argument command=, même au niveau Metadata. Cela signifie aussi que des secrets passés en ligne de commande d'un exec finissent dans le log d'audit, un point soulevé dans l'issue kubernetes/kubernetes #97795.
Résultat : responseStatus et annotations
responseStatus.code est le code HTTP : 200/201 succès, 101 changement de protocole pour le streaming (exec, attach, port-forward), 401 non authentifié, 403 interdit, 404 introuvable, 409 conflit. Une rafale de 403 pour une même identité, c'est quelqu'un qui cartographie ses permissions.
Les annotations authorization.k8s.io/decision (allow / forbid) et authorization.k8s.io/reason nomment le binding RBAC qui a accordé l'accès. Le guide des bonnes pratiques EKS recommande de s'en servir pour comprendre pourquoi un appel a été autorisé. En incident, la raison pointe directement le binding à supprimer. Pod Security Admission écrit aussi des annotations ici lorsqu'un pod viole une politique en mode audit.
Horodatages
requestReceivedTimestamp est l'arrivée de la requête ; stageTimestamp le moment où ce stage a été atteint. Les deux sont en UTC, à la microseconde. Pour une session exec, l'écart entre ResponseStarted et ResponseComplete donne la durée de la session quand les deux sont journalisés.
Les enveloppes cloud changent l'emballage, pas l'événement
Sur EKS, l'événement est le message d'un enregistrement CloudWatch Logs. Sur AKS, c'est une chaîne JSON dans properties.log (compte de stockage, Event Hubs) ou dans la colonne log_s (AzureDiagnostics), ou éclatée en colonnes dans la table AKSAudit. Sur GKE, il est réécrit en entrée Cloud Audit Logs : protoPayload.methodName comme io.k8s.core.v1.pods.exec.create, protoPayload.resourceName, authenticationInfo.principalEmail, requestMetadata.callerIp. Le guide d'export couvre chacun. L'analyseur les ramène tous aux mêmes champs : le reste de cette série s'applique quelle que soit la source.