<?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>Detection Engineering | FirewallSync</title><link>https://firewallsync.com/tags/detection-engineering/</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/detection-engineering/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></channel></rss>