ReynardSec<p>A grumpy ItSec guy walks through the office when he overhears an exchange of words.</p><p>devops0: These k8s security SaaS prices are wild.<br>devops1: Image scanning, policy engines, "enterprise tiers"... why are we paying so much?</p><p>ItSec (walking by): You pay for updates & support, probably, but you can do some of this yourselves with a bit of k8s hacking.</p><p>devops0: How, exactly?</p><p>Disclaimer: this is a PoC for learning, not a production-ready solution.</p><p>Kubernetes can ask an external webhook whether a given image should be allowed via Admission Controller, in this case ImagePolicyWebhook [1]. The webhook receives an ImageReview payload [2], initiates a scan, and returns "allowed: true/false". </p><p>We will write a Flask endpoint that invokes Trivy [3] for each image and denies pod creation process if HIGH or CRITICAL vuln appear. </p><p>Below is a minimal Flask service.</p><pre><code>from flask import Flask, request, jsonify<br>import subprocess, json, shlex, re<br><br>app = Flask(__name__)<br><br>def is_valid_image_format(image: str) -> bool:<br> if not re.fullmatch(r"[A-Za-z0-9/_:.@+-]{1,300}", image):<br> return False<br> if image.startswith("-"):<br> return False<br> return True<br><br><br>def scan_with_trivy(image: str):<br> cmd = [<br> "trivy", "--quiet",<br> "--severity", "HIGH,CRITICAL",<br> "image", "--format", "json",<br> image<br> ]<br> r = subprocess.run(cmd, capture_output=True, text=True)<br> try:<br> data = json.loads(r.stdout or "{}")<br> results = data.get("Results", [])<br> vulns = []<br> for res in results:<br> for v in res.get("Vulnerabilities", []) or []:<br> if v.get("Severity") in ("HIGH", "CRITICAL"):<br> vulns.append(v)<br> return vulns<br> except json.JSONDecodeError:<br> return None<br><br>@app.route("/scan", methods=["POST"])<br>def scan():<br> body = request.get_json(force=True, silent=True) or {}<br> containers = body.get("spec", {}).get("containers", [])<br> if not containers:<br> return jsonify({<br> "apiVersion": "imagepolicy.k8s.io/v1alpha1",<br> "kind": "ImageReview",<br> "status": {"allowed": False, "reason": "No containers provided"}<br> })<br><br> results = []<br> decision = True<br> for c in containers:<br> image = c.get("image", "")<br> if not is_valid_image_format(image):<br> results.append({"image": image, "allowed": False, "reason": "Invalid image format"})<br> decision = False<br> continue<br> vulns = scan_with_trivy(shlex.quote(image))<br> if vulns is None:<br> results.append({"image": image, "allowed": False, "reason": "Scanner error"})<br> decision = False<br> continue<br> if vulns:<br> results.append({"image": image, "allowed": False, "reason": "HIGH/CRITICAL vulnerabilities detected"})<br> decision = False<br> else:<br> results.append({"image": image, "allowed": True})<br><br> return jsonify({<br> "apiVersion": "imagepolicy.k8s.io/v1alpha1",<br> "kind": "ImageReview",<br> "status": {"allowed": decision, "results": results}<br> })<br><br>if __name__ == "__main__":<br> app.run(host="0.0.0.0", port=5000)<br></code></pre><p>Run the service wherever Trivy is available. Tip: warm up the trivy vulns db once so the first request will not timeout.</p><pre><code>trivy image alpine:3.22 #warm up <br>gunicorn -w 4 -b 0.0.0.0:5000 app:app<br></code></pre><p>Test it with an ImageReview-like request. Replace the and URL and images as you wish/need.</p><pre><code>curl -s -X POST http://127.0.0.1:5000/scan -H "Content-Type: application/json" -d '{<br> "apiVersion": "imagepolicy.k8s.io/v1alpha1",<br> "kind": "ImageReview",<br> "spec": {<br> "containers": [<br> {"image": "alpine:3.22"},<br> {"image": "nginx:latest"}<br> ]<br> }<br> }' | jq .<br></code></pre><p>Tell the API server to use ImagePolicyWebhook. The AdmissionConfiguration points at a kubeconfig for the webhook endpoint (/etc/kubernetes/admission-control-config.yaml).</p><pre><code>apiVersion: apiserver.config.k8s.io/v1<br>kind: AdmissionConfiguration<br>plugins:<br>- name: ImagePolicyWebhook<br> configuration:<br> imagePolicy:<br> kubeConfigFile: /etc/kubernetes/webhook-kubeconfig.yaml<br> allowTTL: 50<br> denyTTL: 50<br> retryBackoff: 500<br> defaultAllow: false<br></code></pre><p>The webhook kubeconfig targets your scanner's HTTP endpoint (/etc/kubernetes/webhook-kubeconfig.yaml). Edit "server" value for your case.</p><pre><code>apiVersion: v1<br>kind: Config<br>clusters:<br>- name: webhook<br> cluster:<br> server: http://192.168.108.48:5000/scan<br>contexts:<br>- name: webhook<br> context:<br> cluster: webhook<br> user: ""<br>current-context: webhook<br></code></pre><p>Mount the AdmissionConfiguration and enable the plugin in the API server manifest. Add the following flags and mount the config file; adjust paths and IPs to your environment (kube-apiserver.yaml):</p><pre><code>---<br>apiVersion: v1<br>[...]<br> containers:<br> - command:<br> - kube-apiserver<br>[...]<br> - --admission-control-config-file=/etc/kubernetes/admission-control-config.yaml<br> - --enable-admission-plugins=NodeRestriction,ImagePolicyWebhook<br>[...]<br> volumeMounts:<br>[...]<br> - mountPath: /etc/kubernetes/admission-control-config.yaml<br> name: admission-control-config<br> readOnly: true<br> - mountPath: /etc/kubernetes/webhook-kubeconfig.yaml<br> name: webhook-kubeconfig<br> readOnly: true<br> volumes:<br>[...]<br> path: /etc/kubernetes/admission-control-config.yaml<br> type: FileOrCreate<br> - name: webhook-kubeconfig<br> hostPath:<br> path: /etc/kubernetes/webhook-kubeconfig.yaml<br> type: FileOrCreate<br><br></code></pre><p>After the API server restarts, the cluster will begin asking app about images during pod creation. A quick check shows an allowed image and a blocked one:</p><pre><code>kubectl run ok --image=docker.io/alpine:3.22<br>pod/ok created<br><br>kubectl run nope --image=docker.io/nginx:latest<br>Error from server (Forbidden): pods "nope" is forbidden: one or more images rejected by webhook backend<br></code></pre><p>That's the whole trick. Kubernetes asks our Flask app. App calls Trivy. If HIGH or CRITICAL vulnerabilities are present, the admission decision is deny, and the pod never starts. It's not fancy and as I wrote before, it's not meant for production, but it illustrates exactly how admission can enforce image hygiene without buying an external SaaS.</p><p>[1] <a href="https://kubernetes.io/docs/reference/access-authn-authz/admission-controllers/#imagepolicywebhook" rel="nofollow noopener" translate="no" target="_blank"><span class="invisible">https://</span><span class="ellipsis">kubernetes.io/docs/reference/a</span><span class="invisible">ccess-authn-authz/admission-controllers/#imagepolicywebhook</span></a> <br>[2] <a href="https://kubernetes.io/docs/reference/access-authn-authz/admission-controllers/#request-payloads" rel="nofollow noopener" translate="no" target="_blank"><span class="invisible">https://</span><span class="ellipsis">kubernetes.io/docs/reference/a</span><span class="invisible">ccess-authn-authz/admission-controllers/#request-payloads</span></a><br>[3] <a href="https://github.com/aquasecurity/trivy" rel="nofollow noopener" translate="no" target="_blank"><span class="invisible">https://</span><span class="">github.com/aquasecurity/trivy</span><span class="invisible"></span></a> </p><p>For more grumpy stories visit:<br>1) <a href="https://infosec.exchange/@reynardsec/115093791930794699" translate="no" rel="nofollow noopener" target="_blank"><span class="invisible">https://</span><span class="ellipsis">infosec.exchange/@reynardsec/1</span><span class="invisible">15093791930794699</span></a><br>2) <a href="https://infosec.exchange/@reynardsec/115048607028444198" translate="no" rel="nofollow noopener" target="_blank"><span class="invisible">https://</span><span class="ellipsis">infosec.exchange/@reynardsec/1</span><span class="invisible">15048607028444198</span></a><br>3) <a href="https://infosec.exchange/@reynardsec/115014440095793678" translate="no" rel="nofollow noopener" target="_blank"><span class="invisible">https://</span><span class="ellipsis">infosec.exchange/@reynardsec/1</span><span class="invisible">15014440095793678</span></a><br>4) <a href="https://infosec.exchange/@reynardsec/114912792051851956" translate="no" rel="nofollow noopener" target="_blank"><span class="invisible">https://</span><span class="ellipsis">infosec.exchange/@reynardsec/1</span><span class="invisible">14912792051851956</span></a></p><p><a href="https://infosec.exchange/tags/appsec" class="mention hashtag" rel="nofollow noopener" target="_blank">#<span>appsec</span></a> <a href="https://infosec.exchange/tags/devops" class="mention hashtag" rel="nofollow noopener" target="_blank">#<span>devops</span></a> <a href="https://infosec.exchange/tags/kubernetes" class="mention hashtag" rel="nofollow noopener" target="_blank">#<span>kubernetes</span></a> <a href="https://infosec.exchange/tags/programming" class="mention hashtag" rel="nofollow noopener" target="_blank">#<span>programming</span></a> <a href="https://infosec.exchange/tags/webdev" class="mention hashtag" rel="nofollow noopener" target="_blank">#<span>webdev</span></a> <a href="https://infosec.exchange/tags/docker" class="mention hashtag" rel="nofollow noopener" target="_blank">#<span>docker</span></a> <a href="https://infosec.exchange/tags/containers" class="mention hashtag" rel="nofollow noopener" target="_blank">#<span>containers</span></a> <a href="https://infosec.exchange/tags/k8s" class="mention hashtag" rel="nofollow noopener" target="_blank">#<span>k8s</span></a> <a href="https://infosec.exchange/tags/cybersecurity" class="mention hashtag" rel="nofollow noopener" target="_blank">#<span>cybersecurity</span></a> <a href="https://infosec.exchange/tags/infosec" class="mention hashtag" rel="nofollow noopener" target="_blank">#<span>infosec</span></a> <a href="https://infosec.exchange/tags/cloud" class="mention hashtag" rel="nofollow noopener" target="_blank">#<span>cloud</span></a> <a href="https://infosec.exchange/tags/hacking" class="mention hashtag" rel="nofollow noopener" target="_blank">#<span>hacking</span></a> <a href="https://infosec.exchange/tags/sysadmin" class="mention hashtag" rel="nofollow noopener" target="_blank">#<span>sysadmin</span></a> <a href="https://infosec.exchange/tags/sysops" class="mention hashtag" rel="nofollow noopener" target="_blank">#<span>sysops</span></a></p>