<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Hardening on hippotion</title><link>https://blog.hippotion.com/tags/hardening/</link><description>Recent content in Hardening on hippotion</description><generator>Hugo</generator><language>en-us</language><lastBuildDate>Mon, 29 Jun 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://blog.hippotion.com/tags/hardening/index.xml" rel="self" type="application/rss+xml"/><item><title>🧱 An AI Audited My Homelab — and the Useful Part Was Telling It 'No'</title><link>https://blog.hippotion.com/posts/an-ai-audited-my-homelab/</link><pubDate>Mon, 29 Jun 2026 00:00:00 +0000</pubDate><guid>https://blog.hippotion.com/posts/an-ai-audited-my-homelab/</guid><description>I gave a second AI full read access to my k3s GitOps homelab. The findings were fine — the work was validating each: which to accept, which to recalibrate, and which the scanner was confidently wrong about.</description><content:encoded><![CDATA[<p>I gave OpenAI&rsquo;s Codex full read access to my k3s GitOps homelab — both repos: the <code>applications.yml</code> that generates every namespace, AppProject and NetworkPolicy; the umbrella charts; the Traefik <code>IngressRoute</code>s; the External Secrets config; the host scripts.</p>
<p>Anyone can run <em>&ldquo;AI, review my cluster&rdquo;</em> and get a list of confident findings. The work is what you do with them — which you accept, which you recalibrate, which the model got wrong. That&rsquo;s the part most &ldquo;I secured my X with AI&rdquo; posts skip, so I&rsquo;ll start there.</p>
<h2 id="what-i-rejected">What I rejected</h2>
<p>It flagged &ldquo;committed secrets&rdquo; — high-entropy strings next to keys like <code>publicAccessToken</code>. Wrong: those were <code>secretRefKey</code> <em>mappings</em> (names of keys in a Secret that External Secrets already syncs from Vault), not values. The entropy heuristic tripped on the key <em>names</em>. Real committed secrets there: zero.</p>
<p>It also said my scheduled agent runners&rsquo; blast-radius guard had the same flaw in all of them — fix identically. True for three; the other two regenerate a local index file every run, so a blanket <em>&ldquo;nothing may change outside the output dir&rdquo;</em> guard would flag their own tooling as a breach nightly. Right instinct, wrong generalization: strict single-file enforcement for the three, an <code>index/</code> allow-list for the two.</p>
<p>That&rsquo;s the loop. A reviewer with no context catches what you&rsquo;ve stopped seeing; <em>you</em> catch where that missing context makes it overconfident. Skip the second half and you got autocomplete, not an audit.</p>
<h2 id="expose-only-what-must-be-exposed">Expose only what must be exposed</h2>
<p>My n8n <code>IngressRoute</code> matched the whole host on one rule, behind only n8n&rsquo;s own login. n8n runs arbitrary code (Code nodes), reaches the internal network, stores credentials — a public editor on one password is RCE-and-credential-theft waiting on a weak password or an auth-bypass CVE.</p>
<p>The fix fell out of a quick threat model:</p>
<ul>
<li><strong>Assets:</strong> stored credentials, code execution, internal reach.</li>
<li><strong>Actors:</strong> an internet scanner; the holder of a leaked API key.</li>
<li><strong>Decisions:</strong> the editor is a <em>human</em> surface → SSO at the edge. The API auths by <em>key</em>, not a cookie → edge OAuth would only break it, so move it off the public edge (in-cluster only). Webhooks must be public but carry no privileged auth → leave exactly those path-prefixes open.</li>
</ul>
<p>One wide-open hostname → three webhook prefixes public, editor behind SSO, API reachable only in-cluster.</p>
<h2 id="stop-routing-internal-traffic-through-the-internet">Stop routing internal traffic through the internet</h2>
<p>Why it happened: the public hostname was easiest, and split-horizon DNS hid the cost — Pi-hole resolves the public name to the local ingress, so an internal caller on the public URL still <em>works</em>; it just hairpins out to the tunnel and back for nothing.</p>
<p>Fix: in-cluster producers use cluster DNS (<code>&lt;svc&gt;.&lt;ns&gt;.svc.cluster.local</code>); host-side cron scripts can&rsquo;t resolve cluster DNS, so they hit the pinned <strong>ClusterIP</strong> (this cluster runs kube-proxy, so the node routes service IPs natively). No service mesh — a <em>decision</em>: on one node, internal hops are plain HTTP inside the trust boundary, and mTLS isn&rsquo;t worth a control plane to operate. TLS at the edge, plain internal. Multi-node changes that answer.</p>
<h2 id="secrets-the-manager-is-the-control-the-secret-is-a-projection">Secrets: the manager is the control, the Secret is a projection</h2>
<p>One genuinely committed secret — rendered into a <strong>ConfigMap</strong>, the worse hiding spot because it <em>looks</em> managed. It moved to <strong>Vault</strong> (encrypted at rest, audited) → <strong>External Secrets Operator</strong> → a Kubernetes <code>Secret</code> via <code>envFrom</code>. The detail that matters: a K8s Secret is <strong>base64, not encryption</strong> — only as private as the datastore at rest. So Vault is the control; the Secret is a projection ESO keeps in sync (rotate in Vault → re-sync → restart). Not SOPS or Sealed Secrets — those keep ciphertext <em>in Git</em>; I wanted plaintext to never touch the repo, plus an access audit trail.</p>
<h2 id="three-things-it-earned">Three things it earned</h2>
<ol>
<li><strong>A rule you don&rsquo;t audit is a rule you don&rsquo;t have.</strong> I had &ldquo;no secrets in Git&rdquo; <em>and</em> a secret manager, and still leaked one into a ConfigMap.</li>
<li><strong>Grade against a named threat, not a vibe.</strong> &ldquo;Secure against a scanner,&rdquo; &ldquo;against a leaked backup,&rdquo; and &ldquo;against a shell on the box&rdquo; are three problems; a control for one can be nothing for another.</li>
<li><strong>The interesting part of an AI review is which findings you can defend rejecting.</strong> Can&rsquo;t independently validate every recommendation — false positives included? You ran autocomplete with good PR, not an audit.</li>
</ol>
]]></content:encoded></item></channel></rss>