<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:media="http://search.yahoo.com/mrss/"><channel><title>Audit Logging | FirewallSync</title><link>https://firewallsync.com/tags/audit-logging/</link><description>Practical cybersecurity writing for engineering teams that ship fast.</description><language>en-us</language><image><url>https://firewallsync.com/icon-512.png</url><title>FirewallSync</title><link>https://firewallsync.com/</link></image><lastBuildDate>Mon, 26 Oct 2026 04:45:00 +0000</lastBuildDate><atom:link href="https://firewallsync.com/tags/audit-logging/index.xml" rel="self" type="application/rss+xml"/><item><title>Kubernetes Audit Log Detections: 8 Rules We Tested</title><link>https://firewallsync.com/posts/kubernetes-audit-log-detections-8-rules-tested/</link><guid>https://firewallsync.com/posts/kubernetes-audit-log-detections-8-rules-tested/</guid><pubDate>Mon, 26 Oct 2026 04:45:00 +0000</pubDate><dc:creator>Craig Leslie</dc:creator><category>Cloud Security</category><description>Eight detection rules for the Kubernetes audit log, written as jq filters and mapped to MITRE ATT&amp;CK: exec and debug sessions, pods asking for host access, cluster-admin bindings, Secret sweeps, minted tokens, impersonation, permission discovery and anonymous calls. We ran them against a scripted attack on kube-apiserver v1.34.1 and report what each caught, what it missed and how to tune it.</description><enclosure url="https://firewallsync.com/posts/kubernetes-audit-log-detections-8-rules-tested/cover_hu578911609021610890.jpg" type="image/jpeg" length="0"/><media:content url="https://firewallsync.com/posts/kubernetes-audit-log-detections-8-rules-tested/cover_hu578911609021610890.jpg" medium="image" type="image/jpeg" width="1200" height="630"/><content:encoded><![CDATA[<p><img src="https://firewallsync.com/posts/kubernetes-audit-log-detections-8-rules-tested/cover_hu578911609021610890.jpg" alt="A single white surveillance camera on a short bracket, mounted on a plain pale grey textured wall crossed by a diagonal band of light" width="1200" height="630"></p><p>Kubernetes audit log detections work because most attack techniques against a cluster have to go through kube-apiserver. Getting a shell in a Pod, starting a container that mounts the node&rsquo;s filesystem, granting yourself cluster-admin, sweeping Secrets, minting a token: each is an API request, and each leaves an audit event with the identity, the verb, the resource and the result. The eight rules below turn those events into alerts.</p>
<p>We wrote them as <code>jq</code> filters so you can run them against a log file today, then ran them against kube-apiserver v1.34.1 while we played out a small attack: a stolen CI credential probing what it can do, a compromised administrator setting up persistence, and an anonymous caller. This article shows what each rule caught, what it would miss, and how to tune it before you port it to a SIEM.</p>
<p>The rules assume an audit policy that logs everything at <code>Metadata</code> or above and keeps request bodies for writes. If you do not have one yet, start with our <a href="/posts/kubernetes-audit-policy-what-to-log-and-at-what-level/">Kubernetes audit policy guide</a>; we used the same six-rule policy here.</p>
<h2 id="the-eight-rules">The eight rules</h2>
<table>
  <thead>
      <tr>
          <th>Rule</th>
          <th>What it looks for</th>
          <th>MITRE ATT&amp;CK</th>
          <th>Level needed</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>D1</td>
          <td><code>exec</code>, <code>attach</code> and <code>ephemeralcontainers</code> on Pods</td>
          <td>T1609 Container Administration Command</td>
          <td>Metadata (Request to see debug images)</td>
      </tr>
      <tr>
          <td>D2</td>
          <td>Pod creates asking for <code>privileged</code>, <code>hostPID</code>, <code>hostNetwork</code>, <code>hostIPC</code> or a <code>hostPath</code> volume, allowed or rejected</td>
          <td>T1610 Deploy Container, T1611 Escape to Host</td>
          <td>Request</td>
      </tr>
      <tr>
          <td>D3</td>
          <td>Bindings whose <code>roleRef</code> is <code>cluster-admin</code></td>
          <td>T1098.006 Additional Container Cluster Roles</td>
          <td>Request</td>
      </tr>
      <tr>
          <td>D4</td>
          <td>Secret <code>get</code>, <code>list</code> or <code>watch</code> across all namespaces or in <code>kube-system</code></td>
          <td>T1552.007 Container API</td>
          <td>Metadata</td>
      </tr>
      <tr>
          <td>D5</td>
          <td><code>serviceaccounts/token</code> creates by anything other than a kubelet</td>
          <td>T1528 Steal Application Access Token</td>
          <td>Metadata</td>
      </tr>
      <tr>
          <td>D6</td>
          <td>Any request carrying <code>impersonatedUser</code></td>
          <td>Context for every other rule</td>
          <td>Metadata</td>
      </tr>
      <tr>
          <td>D7</td>
          <td><code>selfsubjectrulesreviews</code> (<code>kubectl auth can-i --list</code>) and bursts of <code>forbid</code> decisions</td>
          <td>T1613 Container and Resource Discovery</td>
          <td>Metadata</td>
      </tr>
      <tr>
          <td>D8</td>
          <td>Requests from <code>system:anonymous</code></td>
          <td>Context: exposure check</td>
          <td>Metadata</td>
      </tr>
  </tbody>
</table>
<p>The ATT&amp;CK IDs and names are from MITRE&rsquo;s STIX data (enterprise collection version 20.0). The mapping is ours: ATT&amp;CK describes techniques, not audit fields.</p>
<h2 id="the-test">The test</h2>
<p>We ran kube-apiserver v1.34.1 and etcd 3.6.4 (the controller-runtime envtest binaries) with RBAC authorization, static bearer tokens for three users and the audit policy linked above, writing JSON lines to a file. There were no nodes, kubelets, controller manager or scheduler, so Pods were never scheduled. That keeps the log readable, but a real cluster&rsquo;s log is far noisier; see the limits section.</p>
<p>The scenario, in order:</p>
<ol>
<li><strong>Benign:</strong> <code>dev-bob</code>, in the <code>developers</code> group, creates a Pod in <code>dev</code>, reads a Secret in his own namespace and runs <code>kubectl exec</code>.</li>
<li><strong>Stolen CI token:</strong> <code>ci-deployer</code>, which can create Pods and exec in <code>prod</code>, runs <code>kubectl auth can-i --list</code>, tries to list Secrets, nodes, namespaces, ClusterRoles, ConfigMaps and ServiceAccounts cluster-wide, tries Secrets in <code>kube-system</code>, then tries to create a privileged Pod with <code>hostPID</code>, <code>hostNetwork</code> and a <code>hostPath</code> mount of <code>/</code>. The API server rejected that one (it was running with the default <code>--allow-privileged=false</code>), so the attacker dropped the privileged flags and created a Pod that only mounts <code>/</code> from the host. That one succeeded.</li>
<li><strong>Compromised admin:</strong> <code>alice</code> binds <code>cluster-admin</code> to the <code>prod/default</code> service account, mints a 24-hour token for it with <code>kubectl create token</code>, reads <code>kube-system</code> Secrets while impersonating it, and attaches a debug container to a running Pod with <code>kubectl debug</code>.</li>
<li><strong>Anonymous:</strong> an unauthenticated <code>curl</code> to <code>/api/v1/namespaces/kube-system/secrets</code>.</li>
</ol>
<p>The resulting log had 574 events. 532 came from <code>system:apiserver</code> itself; the other 42 were ours (alice 18, ci-deployer 15, dev-bob 8, anonymous 1).</p>
<h2 id="the-rules-as-a-script">The rules as a script</h2>
<p>Save this as <code>detections.sh</code> and run <code>sh detections.sh audit.log</code>. It needs jq 1.6 or later for <code>IN</code>; we ran it with jq 1.7.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span><span style="color:#75715e">#!/bin/sh
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#75715e"># Kubernetes audit log detections. Usage: ./detections.sh audit.log</span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Input: kube-apiserver JSON-lines audit log (audit.k8s.io/v1 Event objects).</span>
</span></span><span style="display:flex;"><span>LOG<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;</span>$1<span style="color:#e6db74">&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Control-plane identities to ignore. Extend this for your distribution.</span>
</span></span><span style="display:flex;"><span>NOT_CP<span style="color:#f92672">=</span><span style="color:#e6db74">&#39;(.user.username | IN(&#34;system:apiserver&#34;,&#34;system:kube-controller-manager&#34;,&#34;system:kube-scheduler&#34;) | not)&#39;</span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Count each request once: long-running calls such as exec also emit ResponseStarted.</span>
</span></span><span style="display:flex;"><span>DONE<span style="color:#f92672">=</span><span style="color:#e6db74">&#39;.stage==&#34;ResponseComplete&#34;&#39;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>echo <span style="color:#e6db74">&#34;D1 exec, attach and ephemeral containers&#34;</span>
</span></span><span style="display:flex;"><span>jq -c <span style="color:#e6db74">&#34;select(</span>$DONE<span style="color:#e6db74">)&#34;</span><span style="color:#e6db74">&#39; | select(.objectRef.resource==&#34;pods&#34;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">      and (.objectRef.subresource | IN(&#34;exec&#34;,&#34;attach&#34;,&#34;ephemeralcontainers&#34;)))
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">  | {user:.user.username, sub:.objectRef.subresource, verb, ns:.objectRef.namespace,
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">     pod:.objectRef.name, code:.responseStatus.code, uri:.requestURI}&#39;</span> <span style="color:#e6db74">&#34;</span>$LOG<span style="color:#e6db74">&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>echo <span style="color:#e6db74">&#34;D2 pods requesting host access (allowed or rejected)&#34;</span>
</span></span><span style="display:flex;"><span>jq -c <span style="color:#e6db74">&#39;select(.objectRef.resource==&#34;pods&#34; and .verb==&#34;create&#34; and .objectRef.subresource==null)
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">  | select(.requestObject.spec.hostPID or .requestObject.spec.hostNetwork or .requestObject.spec.hostIPC
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">      or any(.requestObject.spec.volumes[]?; .hostPath)
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">      or any(.requestObject.spec.containers[]?; .securityContext.privileged))
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">  | {user:.user.username, ns:.objectRef.namespace, pod:.objectRef.name, code:.responseStatus.code,
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">     hostPaths:[.requestObject.spec.volumes[]?.hostPath.path | select(.)]}&#39;</span> <span style="color:#e6db74">&#34;</span>$LOG<span style="color:#e6db74">&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>echo <span style="color:#e6db74">&#34;D3 bindings that grant cluster-admin&#34;</span>
</span></span><span style="display:flex;"><span>jq -c <span style="color:#e6db74">&#34;select(</span>$NOT_CP<span style="color:#e6db74">)&#34;</span><span style="color:#e6db74">&#39; | select(.objectRef.apiGroup==&#34;rbac.authorization.k8s.io&#34;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">      and (.objectRef.resource | IN(&#34;clusterrolebindings&#34;,&#34;rolebindings&#34;))
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">      and (.verb | IN(&#34;create&#34;,&#34;update&#34;,&#34;patch&#34;))
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">      and .requestObject.roleRef.name==&#34;cluster-admin&#34;)
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">  | {user:.user.username, binding:.objectRef.name, subjects:.requestObject.subjects,
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">     code:.responseStatus.code}&#39;</span> <span style="color:#e6db74">&#34;</span>$LOG<span style="color:#e6db74">&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>echo <span style="color:#e6db74">&#34;D4 Secret reads across all namespaces or in kube-system&#34;</span>
</span></span><span style="display:flex;"><span>jq -c <span style="color:#e6db74">&#34;select(</span>$NOT_CP<span style="color:#e6db74">)&#34;</span><span style="color:#e6db74">&#39; | select(.objectRef.resource==&#34;secrets&#34; and (.verb | IN(&#34;get&#34;,&#34;list&#34;,&#34;watch&#34;)))
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">  | select(.objectRef.namespace==null or .objectRef.namespace==&#34;kube-system&#34;)
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">  | {user:.user.username, as:.impersonatedUser.username, verb, ns:.objectRef.namespace,
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">     code:.responseStatus.code}&#39;</span> <span style="color:#e6db74">&#34;</span>$LOG<span style="color:#e6db74">&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>echo <span style="color:#e6db74">&#34;D5 service account tokens minted outside the kubelet&#34;</span>
</span></span><span style="display:flex;"><span>jq -c <span style="color:#e6db74">&#34;select(</span>$NOT_CP<span style="color:#e6db74">)&#34;</span><span style="color:#e6db74">&#39; | select(.objectRef.resource==&#34;serviceaccounts&#34;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">      and .objectRef.subresource==&#34;token&#34; and .verb==&#34;create&#34;)
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">  | select(.user.username | startswith(&#34;system:node:&#34;) | not)
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">  | {user:.user.username, sa:(.objectRef.namespace + &#34;/&#34; + .objectRef.name),
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">     code:.responseStatus.code}&#39;</span> <span style="color:#e6db74">&#34;</span>$LOG<span style="color:#e6db74">&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>echo <span style="color:#e6db74">&#34;D6 impersonated requests&#34;</span>
</span></span><span style="display:flex;"><span>jq -c <span style="color:#e6db74">&#39;select(.impersonatedUser)
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">  | {user:.user.username, as:.impersonatedUser.username, verb, res:.objectRef.resource,
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">     ns:.objectRef.namespace, code:.responseStatus.code}&#39;</span> <span style="color:#e6db74">&#34;</span>$LOG<span style="color:#e6db74">&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>echo <span style="color:#e6db74">&#34;D7a permission listing (kubectl auth can-i --list)&#34;</span>
</span></span><span style="display:flex;"><span>jq -c <span style="color:#e6db74">&#39;select(.objectRef.resource==&#34;selfsubjectrulesreviews&#34;)
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">  | {user:.user.username, ns:.objectRef.namespace, ip:.sourceIPs[0]}&#39;</span> <span style="color:#e6db74">&#34;</span>$LOG<span style="color:#e6db74">&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>echo <span style="color:#e6db74">&#34;D7b identities with 5 or more denied requests in the file&#34;</span>
</span></span><span style="display:flex;"><span>jq -sc <span style="color:#e6db74">&#39;[.[] | select(.annotations[&#34;authorization.k8s.io/decision&#34;]==&#34;forbid&#34;)]
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">  | group_by(.user.username)
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">  | map({user:.[0].user.username, denied:length,
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">         resources:([.[].objectRef.resource] | unique)})
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">  | map(select(.denied &gt;= 5))&#39;</span> <span style="color:#e6db74">&#34;</span>$LOG<span style="color:#e6db74">&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>echo <span style="color:#e6db74">&#34;D8 anonymous requests&#34;</span>
</span></span><span style="display:flex;"><span>jq -c <span style="color:#e6db74">&#39;select(.user.username==&#34;system:anonymous&#34;)
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">  | {uri:.requestURI, ip:.sourceIPs[0], code:.responseStatus.code}&#39;</span> <span style="color:#e6db74">&#34;</span>$LOG<span style="color:#e6db74">&#34;</span>
</span></span></code></pre></div><h2 id="what-each-rule-caught">What each rule caught</h2>
<p>All 15 hits from the run, condensed:</p>
<table>
  <thead>
      <tr>
          <th>Rule</th>
          <th>Hits</th>
          <th>What fired</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>D1</td>
          <td>3</td>
          <td>dev-bob&rsquo;s exec (one <code>get</code>, one <code>create</code>, both 400 because the Pod had no node); alice&rsquo;s <code>patch</code> to <code>pods/ephemeralcontainers</code> (200)</td>
      </tr>
      <tr>
          <td>D2</td>
          <td>2</td>
          <td>ci-deployer&rsquo;s privileged Pod, rejected with 422; ci-deployer&rsquo;s hostPath Pod, created with 201. Both show <code>hostPaths: [&quot;/&quot;]</code></td>
      </tr>
      <tr>
          <td>D3</td>
          <td>1</td>
          <td>alice&rsquo;s <code>backup-sync</code> ClusterRoleBinding of <code>cluster-admin</code> to <code>prod/default</code></td>
      </tr>
      <tr>
          <td>D4</td>
          <td>4</td>
          <td>ci-deployer&rsquo;s cluster-wide list and <code>kube-system</code> list (both 403); alice&rsquo;s <code>kube-system</code> list as <code>prod/default</code> (200); the anonymous call (403)</td>
      </tr>
      <tr>
          <td>D5</td>
          <td>1</td>
          <td>alice minting a token for <code>prod/default</code></td>
      </tr>
      <tr>
          <td>D6</td>
          <td>1</td>
          <td>alice listing <code>kube-system</code> Secrets as <code>system:serviceaccount:prod:default</code> (200)</td>
      </tr>
      <tr>
          <td>D7</td>
          <td>2</td>
          <td>ci-deployer&rsquo;s <code>selfsubjectrulesreviews</code>; ci-deployer with 8 denials across 6 resource types</td>
      </tr>
      <tr>
          <td>D8</td>
          <td>1</td>
          <td>the anonymous request (403)</td>
      </tr>
  </tbody>
</table>
<p>Every step of both attack paths fired at least one rule. The only benign activity that fired was dev-bob&rsquo;s exec, which is the false positive you would expect: D1 is a &ldquo;who is in our workloads&rdquo; feed, not a high-severity alert on its own.</p>
<h3 id="d1-exec-attach-and-debug-sessions">D1: exec, attach and debug sessions</h3>
<p>Two details matter. First, <code>exec</code> is a long-running request, and the Kubernetes docs say <code>ResponseStarted</code> &ldquo;is only generated for long-running requests&rdquo;. Our first version of the rule returned every exec event twice, once per stage; filtering on <code>ResponseComplete</code> fixed it. Second, one <code>kubectl exec</code> produced both a <code>get</code> and a <code>create</code> on <code>pods/exec</code>, so count sessions by <code>auditID</code> or by time window, not by event.</p>
<p><code>kubectl debug</code> does not use <code>exec</code> at all. It sends a <code>patch</code> to the <code>pods/ephemeralcontainers</code> subresource, which is why the rule matches that subresource explicitly. Because it is a write, our policy logged it at <code>Request</code> level, and the event carried the debug container&rsquo;s image (<code>busybox:1.36</code>). A debug container running an image you have never seen in the cluster is worth a look.</p>
<p>To tune: alert on exec into <code>kube-system</code> and other platform namespaces, exec by service accounts (people run exec, workloads rarely should), and exec whose <code>requestURI</code> contains shells or tools you do not expect. Route the rest to a lower-severity feed.</p>
<h3 id="d2-pods-asking-for-the-host">D2: Pods asking for the host</h3>
<p>The fields come from the Pod Security Standards&rsquo; Baseline profile, which disallows host namespaces (<code>spec.hostNetwork</code>, <code>spec.hostPID</code>, <code>spec.hostIPC</code>), privileged containers and hostPath volumes. If you enforce those with <a href="/posts/kubernetes-pod-security-admission-namespace-labels-modes-and-a-safe-rollout-from-warn-to-enforce/">Pod Security Admission</a>, D2 tells you who keeps trying.</p>
<p>The useful finding from our run is that the rejected request was logged in full. The privileged Pod failed validation with HTTP 422 (&ldquo;disallowed by cluster policy&rdquo;), and the audit event still held the complete <code>requestObject</code>, including the <code>hostPath</code> of <code>/</code>. That is why D2 does not filter on success. An attacker&rsquo;s first attempt is often the one that fails.</p>
<p>D2 needs <code>Request</code> level on Pod creates; at <code>Metadata</code> the spec is not there. It also only sees Pods. Deployments, DaemonSets, Jobs and CronJobs create Pods through controllers, so the Pod create event&rsquo;s user is the controller&rsquo;s identity. Add the same <code>select</code> on <code>.requestObject.spec.template.spec</code> for workload resources to see the person who submitted the template.</p>
<h3 id="d3-bindings-to-cluster-admin">D3: bindings to cluster-admin</h3>
<p>Without the control-plane exclusion, this rule fired on startup: <code>system:apiserver</code> creates the <code>cluster-admin</code> ClusterRoleBinding to <code>system:masters</code> when it bootstraps RBAC. After excluding it, the only hit was the persistence binding.</p>
<p>The binding name <code>backup-sync</code> is deliberately bland; do not rely on names. The rule matches <code>roleRef.name</code>, which our policy logs because RBAC writes are kept at <code>RequestResponse</code>. Extend it with the other roles that are as good as cluster-admin in your cluster, and with writes to ClusterRoles that add <code>*</code> verbs or <code>escalate</code>, <code>bind</code> or <code>impersonate</code>. Our <a href="/posts/kubernetes-rbac-audit-11-permissions-that-escalate-access/">RBAC permission audit</a> lists them.</p>
<h3 id="d4-secret-sweeps">D4: Secret sweeps</h3>
<p>A cluster-wide list has no namespace in <code>objectRef</code>, which is how D4 finds it. Our policy logs Secrets at <code>Metadata</code>, so none of these events contain Secret values, and the rule does not need them: who, which verb, which scope and the response code are enough.</p>
<p>The 403s are as interesting as the 200. Two of the four hits were the stolen CI token being denied, which tells you someone is trying the credential well outside its job. Do not exclude every <code>system:</code> user here. <code>system:serviceaccount:...</code> identities are <code>system:</code> users too: had the attacker used the minted token directly instead of impersonating, the read would have been logged as <code>system:serviceaccount:prod:default</code> and a blanket filter would have dropped it. That is why the script excludes control-plane components by exact name. Some distributions run controllers under <code>kube-system</code> service accounts; add those by name after you have looked at your own log.</p>
<h3 id="d5-minted-tokens">D5: minted tokens</h3>
<p><code>kubectl create token</code> is a <code>create</code> on the <code>serviceaccounts/token</code> subresource. Our policy logs that subresource at <code>Metadata</code>, so the event recorded who asked and for which service account, but not the token. Keep it that way: at <code>RequestResponse</code> the response would contain a usable token.</p>
<p>The kubelet requests tokens on behalf of Pods that use projected service account tokens, so in a real cluster most of these events come from <code>system:node:&lt;name&gt;</code> identities. The rule drops those. What is left, a person or a workload minting a token for another service account, is rare and worth reviewing. Our <a href="/posts/kubernetes-service-account-tokens-bound-vs-legacy-secrets/">service account token guide</a> covers why bound tokens are still worth watching.</p>
<h3 id="d6-impersonation">D6: impersonation</h3>
<p>When a request uses impersonation, the audit event keeps the real caller in <code>user</code> and adds <code>impersonatedUser</code>. Here alice read <code>kube-system</code> Secrets as the service account she had just made cluster-admin. D4 fired on that read too, but only D6 tells you the request ran with the service account&rsquo;s permissions, which is what links the read back to the binding created minutes earlier. Treat D6 as context: join it to every other rule so each alert shows both the real caller and the identity whose permissions were used.</p>
<h3 id="d7-permission-discovery">D7: permission discovery</h3>
<p><code>kubectl auth can-i --list</code> sends a <code>SelfSubjectRulesReview</code>, which is logged as a create on <code>selfsubjectrulesreviews</code>. Developers run it too, so it is weak alone, but it is a strong signal from a CI or workload identity: our stolen CI token ran it first. The burst rule then found 8 denials for ci-deployer across 6 resource types.</p>
<p>The threshold of 5 per file is only for this demo. In a SIEM, count <code>forbid</code> decisions per identity over a window such as 10 minutes, and weight by the number of distinct resources: one identity hitting one forbidden resource repeatedly is usually a broken deployment; one identity hitting many is usually someone exploring.</p>
<h3 id="d8-anonymous-requests">D8: anonymous requests</h3>
<p>kube-apiserver accepts anonymous requests by default (<code>--anonymous-auth</code> defaults to true; they arrive as <code>system:anonymous</code> in group <code>system:unauthenticated</code>). RBAC denied our anonymous call, but the event is still useful: anonymous requests for resource paths from outside your network mean the API server is reachable by people scanning for it. Any anonymous request that gets a 2xx on a resource path is an incident.</p>
<h2 id="porting-the-rules-to-a-siem">Porting the rules to a SIEM</h2>
<p>The fields used here (<code>user.username</code>, <code>impersonatedUser</code>, <code>objectRef.*</code>, <code>verb</code>, <code>stage</code>, <code>responseStatus.code</code>, <code>annotations</code>, <code>requestObject</code>, <code>requestURI</code>, <code>sourceIPs</code>) are fields of the <code>audit.k8s.io/v1</code> Event object, so the logic carries over to any backend that stores the events. Managed services may wrap each event in their own envelope and may run their own audit policy, which decides whether <code>requestObject</code> is present at all. Check one raw event from your provider before you trust a rule that depends on request bodies.</p>
<p>Three practical points:</p>
<ul>
<li><strong>Deduplicate on <code>auditID</code>.</strong> It is the same across a request&rsquo;s stages.</li>
<li><strong>Alert on attempts.</strong> Keep 403 and 422 results in every rule unless you have a reason to drop them.</li>
<li><strong>Join with identity context.</strong> Mapping <code>user.username</code> and <code>sourceIPs</code> to a person, a pipeline or a node is what turns these rules from noise into findings. Our <a href="/posts/logging-for-incidents-not-for-dashboards/">incident logging guide</a> covers what to keep alongside the audit log.</li>
</ul>
<h2 id="limits-of-this-article">Limits of this article</h2>
<p>We tested on a single kube-apiserver with no nodes, controllers or scheduler, so no Pod ever ran and exec returned 400 instead of opening a session. A production log has far more control-plane and controller traffic, and your exclusion list will need tuning against it. We used static tokens; OIDC, certificates and cloud IAM identities change what <code>user.username</code> looks like. Managed Kubernetes providers control their own audit policies, and some do not log request bodies, which disables D2, D3 and the image detail in D1. These rules detect API activity only: they do not see what happens inside a container or on a node, which needs runtime or host telemetry.</p>
<h2 id="sources-and-verification">Sources and verification</h2>
<table>
  <thead>
      <tr>
          <th>Important claim</th>
          <th>Source</th>
          <th>Status</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Audit stages; <code>ResponseStarted</code> only for long-running requests</td>
          <td><a href="https://kubernetes.io/docs/tasks/debug/debug-cluster/audit/">Kubernetes docs: Auditing</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td><code>impersonatedUser</code> and other Event fields</td>
          <td><a href="https://github.com/kubernetes/apiserver/blob/master/pkg/apis/audit/v1/types.go">kubernetes/apiserver audit/v1 types.go</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>Host namespaces, privileged and hostPath restrictions</td>
          <td><a href="https://kubernetes.io/docs/concepts/security/pod-security-standards/">Pod Security Standards</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>Kubelet requests projected tokens on behalf of Pods</td>
          <td><a href="https://kubernetes.io/docs/tasks/configure-pod-container/configure-service-account/">Configure Service Accounts for Pods</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td><code>--anonymous-auth</code> and <code>--allow-privileged</code> defaults</td>
          <td><code>kube-apiserver --help</code>, v1.34.1</td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>ATT&amp;CK technique IDs and names</td>
          <td><a href="https://github.com/mitre-attack/attack-stix-data">MITRE ATT&amp;CK STIX data</a>, enterprise collection 20.0</td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>All hits, counts and observed behaviour (exec stages, 422 logged with body, debug as ephemeralcontainers patch, bootstrap binding)</td>
          <td>Our run: kube-apiserver v1.34.1, etcd 3.6.4, jq 1.7, October 26, 2026</td>
          <td>Verified (single test environment)</td>
      </tr>
  </tbody>
</table>
]]></content:encoded></item><item><title>Kubernetes Audit Policy: What to Log, at What Level</title><link>https://firewallsync.com/posts/kubernetes-audit-policy-what-to-log-and-at-what-level/</link><guid>https://firewallsync.com/posts/kubernetes-audit-policy-what-to-log-and-at-what-level/</guid><pubDate>Sun, 25 Oct 2026 04:45:00 +0000</pubDate><dc:creator>Craig Leslie</dc:creator><category>Cloud Security</category><description>A Kubernetes audit policy decides which API requests kube-apiserver records and how much of each it keeps. The first matching rule wins, so one misplaced rule can write Secret values into your logs. Here is a six-rule policy we ran against kube-apiserver v1.34.1, what each rule captured, and the jq queries that turn the log into answers.</description><enclosure url="https://firewallsync.com/posts/kubernetes-audit-policy-what-to-log-and-at-what-level/cover_hu8208247904918711124.jpg" type="image/jpeg" length="0"/><media:content url="https://firewallsync.com/posts/kubernetes-audit-policy-what-to-log-and-at-what-level/cover_hu8208247904918711124.jpg" medium="image" type="image/jpeg" width="1200" height="630"/><content:encoded><![CDATA[<p><img src="https://firewallsync.com/posts/kubernetes-audit-policy-what-to-log-and-at-what-level/cover_hu8208247904918711124.jpg" alt="A worn, yellowed ledger lying open on green baize, its ruled pages covered in faded handwriting, with a white quill pen resting across them" width="1200" height="630"></p><p>A Kubernetes audit policy is the file that tells kube-apiserver which API requests to record and how much of each to keep. Without one, upstream kube-apiserver records nothing. A good policy logs everything at <code>Metadata</code> at minimum, keeps request bodies for changes that alter access, and never logs the contents of Secrets. Rule order is what makes that work: the first matching rule wins, and a broad rule placed too high will quietly write Secret values into your logs. We demonstrate that below.</p>
<p>This guide gives a six-rule policy we ran against kube-apiserver v1.34.1, shows what each rule captured, and ends with queries you can run against the log during an incident.</p>
<h2 id="how-kube-apiserver-evaluates-a-policy">How kube-apiserver evaluates a policy</h2>
<p>Every request produces audit events at one or more <strong>stages</strong>:</p>
<table>
  <thead>
      <tr>
          <th>Stage</th>
          <th>When</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>RequestReceived</code></td>
          <td>As soon as the audit handler receives the request</td>
      </tr>
      <tr>
          <td><code>ResponseStarted</code></td>
          <td>After response headers are sent, for long-running requests such as watch</td>
      </tr>
      <tr>
          <td><code>ResponseComplete</code></td>
          <td>When the response body is finished</td>
      </tr>
      <tr>
          <td><code>Panic</code></td>
          <td>When a panic occurred</td>
      </tr>
  </tbody>
</table>
<p>The policy is a list of rules. kube-apiserver compares each event with the rules <strong>in order</strong>, and the first rule that matches sets the event&rsquo;s <strong>level</strong>:</p>
<table>
  <thead>
      <tr>
          <th>Level</th>
          <th>What gets recorded</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>None</code></td>
          <td>Nothing</td>
      </tr>
      <tr>
          <td><code>Metadata</code></td>
          <td>User, groups, source IPs, user agent, verb, resource, namespace, name, response status, timestamps, annotations</td>
      </tr>
      <tr>
          <td><code>Request</code></td>
          <td>Metadata plus the request body</td>
      </tr>
      <tr>
          <td><code>RequestResponse</code></td>
          <td>Metadata plus request and response bodies</td>
      </tr>
  </tbody>
</table>
<p><code>Request</code> and <code>RequestResponse</code> don&rsquo;t apply to non-resource URLs such as <code>/healthz</code>. A rule can match on <code>users</code>, <code>userGroups</code>, <code>verbs</code>, <code>resources</code> (group, resource, subresource and <code>resourceNames</code>), <code>namespaces</code> and <code>nonResourceURLs</code>. A rule with no match fields matches everything, which is what you want for the last rule and exactly what you don&rsquo;t want near the top.</p>
<p>The policy file is passed with <code>--audit-policy-file</code>. If the flag is omitted, no events are logged, and a policy with no rules is rejected.</p>
<h2 id="a-policy-that-answers-incident-questions">A policy that answers incident questions</h2>
<p>The policy below is built around the questions you get asked during an incident: who read this Secret, who exec&rsquo;d into that Pod, who granted this binding, and was it allowed. It is a starting point, not a complete production policy; see the limits at the end.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">apiVersion</span>: <span style="color:#ae81ff">audit.k8s.io/v1</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">kind</span>: <span style="color:#ae81ff">Policy</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">omitStages</span>:
</span></span><span style="display:flex;"><span>  - <span style="color:#e6db74">&#34;RequestReceived&#34;</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">rules</span>:
</span></span><span style="display:flex;"><span>  <span style="color:#75715e"># 1. Drop health and version probes.</span>
</span></span><span style="display:flex;"><span>  - <span style="color:#f92672">level</span>: <span style="color:#ae81ff">None</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">nonResourceURLs</span>: [<span style="color:#e6db74">&#34;/healthz*&#34;</span>, <span style="color:#e6db74">&#34;/livez*&#34;</span>, <span style="color:#e6db74">&#34;/readyz*&#34;</span>, <span style="color:#e6db74">&#34;/version&#34;</span>]
</span></span><span style="display:flex;"><span>  <span style="color:#75715e"># 2. Sensitive payloads: who touched them, never the contents.</span>
</span></span><span style="display:flex;"><span>  - <span style="color:#f92672">level</span>: <span style="color:#ae81ff">Metadata</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">resources</span>:
</span></span><span style="display:flex;"><span>      - <span style="color:#f92672">group</span>: <span style="color:#e6db74">&#34;&#34;</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">resources</span>: [<span style="color:#e6db74">&#34;secrets&#34;</span>, <span style="color:#e6db74">&#34;configmaps&#34;</span>, <span style="color:#e6db74">&#34;serviceaccounts/token&#34;</span>]
</span></span><span style="display:flex;"><span>      - <span style="color:#f92672">group</span>: <span style="color:#e6db74">&#34;authentication.k8s.io&#34;</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">resources</span>: [<span style="color:#e6db74">&#34;tokenreviews&#34;</span>]
</span></span><span style="display:flex;"><span>  <span style="color:#75715e"># 3. Interactive access into running workloads.</span>
</span></span><span style="display:flex;"><span>  - <span style="color:#f92672">level</span>: <span style="color:#ae81ff">Metadata</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">resources</span>:
</span></span><span style="display:flex;"><span>      - <span style="color:#f92672">group</span>: <span style="color:#e6db74">&#34;&#34;</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">resources</span>: [<span style="color:#e6db74">&#34;pods/exec&#34;</span>, <span style="color:#e6db74">&#34;pods/attach&#34;</span>, <span style="color:#e6db74">&#34;pods/portforward&#34;</span>, <span style="color:#e6db74">&#34;pods/proxy&#34;</span>, <span style="color:#e6db74">&#34;nodes/proxy&#34;</span>]
</span></span><span style="display:flex;"><span>  <span style="color:#75715e"># 4. Changes to who can do what, and to admission control.</span>
</span></span><span style="display:flex;"><span>  - <span style="color:#f92672">level</span>: <span style="color:#ae81ff">RequestResponse</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">verbs</span>: [<span style="color:#e6db74">&#34;create&#34;</span>, <span style="color:#e6db74">&#34;update&#34;</span>, <span style="color:#e6db74">&#34;patch&#34;</span>, <span style="color:#e6db74">&#34;delete&#34;</span>, <span style="color:#e6db74">&#34;deletecollection&#34;</span>]
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">resources</span>:
</span></span><span style="display:flex;"><span>      - <span style="color:#f92672">group</span>: <span style="color:#e6db74">&#34;rbac.authorization.k8s.io&#34;</span>
</span></span><span style="display:flex;"><span>      - <span style="color:#f92672">group</span>: <span style="color:#e6db74">&#34;admissionregistration.k8s.io&#34;</span>
</span></span><span style="display:flex;"><span>  <span style="color:#75715e"># 5. Other writes: keep the submitted object.</span>
</span></span><span style="display:flex;"><span>  - <span style="color:#f92672">level</span>: <span style="color:#ae81ff">Request</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">verbs</span>: [<span style="color:#e6db74">&#34;create&#34;</span>, <span style="color:#e6db74">&#34;update&#34;</span>, <span style="color:#e6db74">&#34;patch&#34;</span>, <span style="color:#e6db74">&#34;delete&#34;</span>, <span style="color:#e6db74">&#34;deletecollection&#34;</span>]
</span></span><span style="display:flex;"><span>  <span style="color:#75715e"># 6. Everything else (reads, watches, non-resource URLs).</span>
</span></span><span style="display:flex;"><span>  - <span style="color:#f92672">level</span>: <span style="color:#ae81ff">Metadata</span>
</span></span></code></pre></div><p>Why each rule is where it is:</p>
<table>
  <thead>
      <tr>
          <th>Rule</th>
          <th>Level</th>
          <th>Reason</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>1. Probes</td>
          <td>None</td>
          <td>High volume, no investigative value. Leaving out <code>RequestReceived</code> globally also halves events for most requests, since <code>ResponseComplete</code> carries the same metadata plus the result.</td>
      </tr>
      <tr>
          <td>2. Secrets, ConfigMaps, TokenRequest, TokenReview</td>
          <td>Metadata</td>
          <td>Their bodies hold credentials and tokens. The upstream GCE policy treats them the same way, noting they &ldquo;can contain sensitive &amp; binary data&rdquo;. It must come before any rule that logs bodies.</td>
      </tr>
      <tr>
          <td>3. exec, attach, port-forward, proxy</td>
          <td>Metadata</td>
          <td>These are how people get inside workloads. The body adds nothing, but you always want the event.</td>
      </tr>
      <tr>
          <td>4. RBAC and admission configuration</td>
          <td>RequestResponse</td>
          <td>You need the exact subjects, <code>roleRef</code>, rules and webhook targets that were written, and the response confirms what was stored. Listing a group with no <code>resources</code> matches every resource in it.</td>
      </tr>
      <tr>
          <td>5. Other writes</td>
          <td>Request</td>
          <td>Keeps the object that was submitted, such as a Pod spec with a privileged container or a hostPath mount.</td>
      </tr>
      <tr>
          <td>6. Catch-all</td>
          <td>Metadata</td>
          <td>Every read and watch is still attributable to someone.</td>
      </tr>
  </tbody>
</table>
<p>Pod Security Admission and our <a href="/posts/kubernetes-rbac-audit-11-permissions-that-escalate-access/">RBAC permission audit</a> tell you what <em>could</em> happen. The audit log is how you find out what did.</p>
<h2 id="what-it-recorded">What it recorded</h2>
<p>We ran the policy on kube-apiserver v1.34.1 (the envtest build from the kubernetes-sigs/controller-tools releases) with etcd and RBAC authorization, then made requests as a <code>system:masters</code> user and as an unprivileged user <code>alice</code>. There were no nodes, so no Pod actually ran. Results:</p>
<table>
  <thead>
      <tr>
          <th>Action</th>
          <th>Level logged</th>
          <th>What the event held</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>kubectl create secret generic db-creds --from-literal=password=...</code></td>
          <td>Metadata</td>
          <td>User, namespace, name, <code>201</code>. No request body; the value appeared nowhere in the log.</td>
      </tr>
      <tr>
          <td><code>kubectl create rolebinding alice-view --clusterrole=view --user=alice</code></td>
          <td>RequestResponse</td>
          <td>Full <code>requestObject</code> and <code>responseObject</code>, including the subject <code>alice</code> and <code>roleRef</code> <code>view</code>.</td>
      </tr>
      <tr>
          <td><code>alice</code> lists Secrets in <code>kube-system</code></td>
          <td>Metadata</td>
          <td><code>403</code>, annotation <code>authorization.k8s.io/decision: forbid</code></td>
      </tr>
      <tr>
          <td>Admin runs <code>kubectl --as alice get pods</code></td>
          <td>Metadata</td>
          <td><code>user.username: admin</code> and <code>impersonatedUser.username: alice</code> in the same event</td>
      </tr>
      <tr>
          <td><code>kubectl create deployment web --image=nginx:1.27</code></td>
          <td>Request</td>
          <td><code>requestObject</code> with the Deployment spec, no response body</td>
      </tr>
      <tr>
          <td><code>kubectl create token ci-bot</code></td>
          <td>Metadata</td>
          <td>Resource <code>serviceaccounts</code>, subresource <code>token</code>, name <code>ci-bot</code>, no token in the log</td>
      </tr>
      <tr>
          <td><code>kubectl exec shell -- cat /etc/shadow</code></td>
          <td>Metadata</td>
          <td><code>requestURI</code> of <code>/api/v1/namespaces/default/pods/shell/exec?command=cat&amp;command=%2Fetc%2Fshadow&amp;...</code></td>
      </tr>
  </tbody>
</table>
<p>Two details from the test are worth building detections around:</p>
<ul>
<li><strong>Exec shows up under more than one verb.</strong> That single <code>kubectl exec</code> produced events with verb <code>get</code> and verb <code>create</code> for the <code>exec</code> subresource. Filter on <code>objectRef.subresource == &quot;exec&quot;</code>, not on the verb.</li>
<li><strong>Impersonation keeps both identities.</strong> The authenticated user is in <code>user</code> and the identity they acted as is in <code>impersonatedUser</code>. If you only alert on <code>user.username</code>, impersonated actions look like they came from the admin&rsquo;s own account.</li>
</ul>
<p>Also keep in mind what the API reference warns about two fields: <code>userAgent</code> is reported by the client &ldquo;and must not be trusted&rdquo;, and in <code>sourceIPs</code> all entries except the last &ldquo;can be arbitrarily set by the client&rdquo;.</p>
<h2 id="the-rule-order-mistake-that-leaks-secrets">The rule-order mistake that leaks Secrets</h2>
<p>We then moved rule 5 (Request for all writes) above rule 2, restarted kube-apiserver with that policy, and created a Secret with the value <code>S3cretValue-456</code>. The audit log now contained:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>&#34;data&#34;:{&#34;key&#34;:&#34;UzNjcmV0VmFsdWUtNDU2&#34;}
</span></span></code></pre></div><p>That is the base64 encoding of <code>S3cretValue-456</code>. The broad write rule matched first, logged the request body at <code>Request</code> level, and the Metadata rule for Secrets was never reached. With the correct order, the equivalent test (a Secret holding <code>S3cretValue-123</code>) left no trace of either the plain or the encoded value in the log.</p>
<p>This is the most common way an audit log becomes a credential store. Your audit pipeline usually has wider read access than the cluster&rsquo;s Secrets do, so treat a body-logging rule above your Secrets rule as a bug. Check it in code review with a simple test: create a dummy Secret in a test cluster and search the log for its encoded value.</p>
<p>If you already store <a href="/posts/kubernetes-secrets-encryption-at-rest-what-it-protects/">Secrets encrypted at rest</a>, remember that encryption protects etcd, not the audit log. And a <a href="/posts/kubernetes-service-account-tokens-bound-vs-legacy-secrets/">Secret-based service account token</a> leaked this way never expires.</p>
<h2 id="backends-and-volume">Backends and volume</h2>
<p>There are two backends: a log file and a webhook.</p>
<ul>
<li><strong>Log backend:</strong> <code>--audit-log-path</code> sets the file (<code>-</code> means stdout). Rotation is controlled by <code>--audit-log-maxage</code> (days), <code>--audit-log-maxbackup</code> (files) and <code>--audit-log-maxsize</code> (megabytes). Batching is off by default for the log backend.</li>
<li><strong>Webhook backend:</strong> <code>--audit-webhook-config-file</code> points at a kubeconfig-format file for the remote endpoint. Batching is on by default, with a maximum of 400 events per batch and a 30-second maximum wait.</li>
<li><strong>Truncation</strong> of oversized events is off by default for both backends and has to be enabled explicitly.</li>
</ul>
<p>The Kubernetes docs also note that auditing increases API server memory use, because some context is kept for each request, and that the increase depends on the policy. <code>RequestResponse</code> on a busy resource is where cost grows: the GCE reference policy, for instance, logs <code>get</code>, <code>list</code> and <code>watch</code> at <code>Request</code> level rather than <code>RequestResponse</code> because &ldquo;Get responses can be large&rdquo;.</p>
<p>Ship the log off the control plane. An attacker with control-plane access can edit a local file, and an <a href="/posts/logging-for-incidents-not-for-dashboards/">incident log is only useful</a> if it survives the incident.</p>
<h2 id="managed-kubernetes">Managed Kubernetes</h2>
<p>On EKS, AKS and similar services the provider runs kube-apiserver, so you generally choose whether audit events are exported, not what the policy contains. Check your provider&rsquo;s documentation for what its policy logs.</p>
<ul>
<li><strong>Amazon EKS:</strong> control plane logs are not sent to CloudWatch Logs by default. Enable the <code>audit</code> log type per cluster, for example with <code>aws eks update-cluster-config --logging</code>. The audit stream is named <code>kube-apiserver-audit-&lt;id&gt;</code> in the <code>/aws/eks/&lt;cluster&gt;/cluster</code> log group. AWS notes delivery is best effort and standard CloudWatch Logs charges apply.</li>
<li><strong>Azure AKS:</strong> resource logs are not collected until you create a diagnostic setting. Microsoft&rsquo;s guidance warns that <code>kube-audit</code> can be costly and suggests <code>kube-audit-admin</code>, which leaves out <code>get</code> and <code>list</code> events. That saves money but drops exactly the read events, such as Secret reads, that matter in a credential-theft investigation. Decide that trade-off deliberately.</li>
</ul>
<h2 id="queries-to-keep-ready">Queries to keep ready</h2>
<p>These run against a JSON-lines audit log with <code>jq</code>. We ran each one against the log from the test above.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span><span style="color:#75715e"># Who read or changed Secrets (excluding the API server itself)</span>
</span></span><span style="display:flex;"><span>jq -c <span style="color:#e6db74">&#39;select(.objectRef.resource==&#34;secrets&#34; and .user.username!=&#34;system:apiserver&#34;)
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">  | {ts:.requestReceivedTimestamp, user:.user.username, verb, ns:.objectRef.namespace,
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">     name:.objectRef.name, code:.responseStatus.code}&#39;</span> audit.log
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Every exec into a Pod, with the command line</span>
</span></span><span style="display:flex;"><span>jq -c <span style="color:#e6db74">&#39;select(.objectRef.subresource==&#34;exec&#34;)
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">  | {ts:.requestReceivedTimestamp, user:.user.username, ns:.objectRef.namespace,
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">     pod:.objectRef.name, uri:.requestURI}&#39;</span> audit.log
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Denied requests, including impersonated ones</span>
</span></span><span style="display:flex;"><span>jq -c <span style="color:#e6db74">&#39;select(.annotations[&#34;authorization.k8s.io/decision&#34;]==&#34;forbid&#34;)
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">  | {user:.user.username, as:.impersonatedUser.username, verb,
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">     res:.objectRef.resource, ns:.objectRef.namespace}&#39;</span> audit.log
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># RBAC writes by people rather than the control plane, with what they granted</span>
</span></span><span style="display:flex;"><span>jq -c <span style="color:#e6db74">&#39;select(.objectRef.apiGroup==&#34;rbac.authorization.k8s.io&#34;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">       and (.verb|test(&#34;create|update|patch|delete&#34;))
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">       and (.user.username|startswith(&#34;system:&#34;)|not))
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">  | {user:.user.username, verb, res:.objectRef.resource, name:.objectRef.name,
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">     subjects:.requestObject.subjects, role:.requestObject.roleRef.name}&#39;</span> audit.log
</span></span></code></pre></div><p>A burst of <code>forbid</code> events from one identity across many resources can be a sign that someone is enumerating what a stolen credential can do.</p>
<h2 id="limits-of-this-article">Limits of this article</h2>
<p>We tested on a single kube-apiserver v1.34.1 with no nodes, no controller manager and static token authentication, so no workload actually ran and no kubelet or controller traffic was present. A real cluster produces far more events, and you will want extra <code>None</code> or <code>Metadata</code> rules for high-volume system traffic (the GCE policy is a good reference). The policy does not cover custom resources that hold credentials; add those to rule 2. <code>Request</code> level on Pod writes will log any credentials placed directly in environment variables. We did not test the webhook backend or any managed service.</p>
<h2 id="sources-and-verification">Sources and verification</h2>
<table>
  <thead>
      <tr>
          <th>Important claim</th>
          <th>Source</th>
          <th>Status</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Stages, levels, first-match evaluation, no logging without <code>--audit-policy-file</code>, zero-rule policy rejected, log and webhook flags, batching defaults, truncation off by default, memory note</td>
          <td><a href="https://kubernetes.io/docs/tasks/debug/debug-cluster/audit/">Kubernetes docs: Auditing</a> (<a href="https://github.com/kubernetes/website/blob/main/content/en/docs/tasks/debug/debug-cluster/audit.md">source</a>)</td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>Policy fields, <code>sourceIPs</code> and <code>userAgent</code> trust warnings, empty <code>resources</code> matches whole group</td>
          <td><a href="https://github.com/kubernetes/apiserver/blob/master/pkg/apis/audit/v1/types.go">audit.k8s.io/v1 types</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>Secrets, ConfigMaps, TokenRequest and TokenReviews logged at Metadata; &ldquo;Get responses can be large&rdquo;</td>
          <td><a href="https://github.com/kubernetes/kubernetes/blob/master/cluster/gce/gci/configure-helper.sh">GCE configure-helper.sh audit policy</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td><code>authorization.k8s.io/decision</code> annotation</td>
          <td><a href="https://github.com/kubernetes/kubernetes/blob/master/staging/src/k8s.io/apiserver/pkg/endpoints/filters/authorization.go">kube-apiserver authorization filter</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>Test results: Metadata Secret events without values, RequestResponse RBAC bodies, forbid annotation, impersonatedUser, exec URI and verbs, Secret value leaked with wrong rule order</td>
          <td>Our test on kube-apiserver v1.34.1 (envtest build)</td>
          <td>Verified (single test environment)</td>
      </tr>
      <tr>
          <td>EKS audit log off by default, stream naming, best-effort delivery, CloudWatch charges</td>
          <td><a href="https://docs.aws.amazon.com/eks/latest/userguide/control-plane-logs.html">Amazon EKS: Send control plane logs to CloudWatch Logs</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>AKS <code>kube-audit-admin</code> excludes get and list; logs need a diagnostic setting</td>
          <td><a href="https://docs.azure.cn/en-us/aks/monitor-apiserver">Microsoft: Monitor the AKS API server</a> (Azure China documentation mirror)</td>
          <td>Qualified</td>
      </tr>
      <tr>
          <td>Managed providers control the audit policy</td>
          <td>General provider architecture; not stated in one primary source</td>
          <td>Qualified</td>
      </tr>
  </tbody>
</table>
]]></content:encoded></item></channel></rss>