A grumpy ItSec guy walks through the office when he overhears an exchange of words.
devops0: These k8s security SaaS prices are wild.
devops1: Image scanning, policy engines, "enterprise tiers"... why are we paying so much?
ItSec (walking by): You pay for updates & support, probably, but you can do some of this yourselves with a bit of k8s hacking.
devops0: How, exactly?
Disclaimer: this is a PoC for learning, not a production-ready solution.
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".
We will write a Flask endpoint that invokes Trivy [3] for each image and denies pod creation process if HIGH or CRITICAL vuln appear.
Below is a minimal Flask service.
from flask import Flask, request, jsonify
import subprocess, json, shlex, re
app = Flask(__name__)
def is_valid_image_format(image: str) -> bool:
if not re.fullmatch(r"[A-Za-z0-9/_:.@+-]{1,300}", image):
return False
if image.startswith("-"):
return False
return True
def scan_with_trivy(image: str):
cmd = [
"trivy", "--quiet",
"--severity", "HIGH,CRITICAL",
"image", "--format", "json",
image
]
r = subprocess.run(cmd, capture_output=True, text=True)
try:
data = json.loads(r.stdout or "{}")
results = data.get("Results", [])
vulns = []
for res in results:
for v in res.get("Vulnerabilities", []) or []:
if v.get("Severity") in ("HIGH", "CRITICAL"):
vulns.append(v)
return vulns
except json.JSONDecodeError:
return None
@app.route("/scan", methods=["POST"])
def scan():
body = request.get_json(force=True, silent=True) or {}
containers = body.get("spec", {}).get("containers", [])
if not containers:
return jsonify({
"apiVersion": "imagepolicy.k8s.io/v1alpha1",
"kind": "ImageReview",
"status": {"allowed": False, "reason": "No containers provided"}
})
results = []
decision = True
for c in containers:
image = c.get("image", "")
if not is_valid_image_format(image):
results.append({"image": image, "allowed": False, "reason": "Invalid image format"})
decision = False
continue
vulns = scan_with_trivy(shlex.quote(image))
if vulns is None:
results.append({"image": image, "allowed": False, "reason": "Scanner error"})
decision = False
continue
if vulns:
results.append({"image": image, "allowed": False, "reason": "HIGH/CRITICAL vulnerabilities detected"})
decision = False
else:
results.append({"image": image, "allowed": True})
return jsonify({
"apiVersion": "imagepolicy.k8s.io/v1alpha1",
"kind": "ImageReview",
"status": {"allowed": decision, "results": results}
})
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000)
Run the service wherever Trivy is available. Tip: warm up the trivy vulns db once so the first request will not timeout.
trivy image alpine:3.22 #warm up
gunicorn -w 4 -b 0.0.0.0:5000 app:app
Test it with an ImageReview-like request. Replace the and URL and images as you wish/need.
curl -s -X POST http://127.0.0.1:5000/scan -H "Content-Type: application/json" -d '{
"apiVersion": "imagepolicy.k8s.io/v1alpha1",
"kind": "ImageReview",
"spec": {
"containers": [
{"image": "alpine:3.22"},
{"image": "nginx:latest"}
]
}
}' | jq .
Tell the API server to use ImagePolicyWebhook. The AdmissionConfiguration points at a kubeconfig for the webhook endpoint (/etc/kubernetes/admission-control-config.yaml).
apiVersion: apiserver.config.k8s.io/v1
kind: AdmissionConfiguration
plugins:
- name: ImagePolicyWebhook
configuration:
imagePolicy:
kubeConfigFile: /etc/kubernetes/webhook-kubeconfig.yaml
allowTTL: 50
denyTTL: 50
retryBackoff: 500
defaultAllow: false
The webhook kubeconfig targets your scanner's HTTP endpoint (/etc/kubernetes/webhook-kubeconfig.yaml). Edit "server" value for your case.
apiVersion: v1
kind: Config
clusters:
- name: webhook
cluster:
server: http://192.168.108.48:5000/scan
contexts:
- name: webhook
context:
cluster: webhook
user: ""
current-context: webhook
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):
---
apiVersion: v1
[...]
containers:
- command:
- kube-apiserver
[...]
- --admission-control-config-file=/etc/kubernetes/admission-control-config.yaml
- --enable-admission-plugins=NodeRestriction,ImagePolicyWebhook
[...]
volumeMounts:
[...]
- mountPath: /etc/kubernetes/admission-control-config.yaml
name: admission-control-config
readOnly: true
- mountPath: /etc/kubernetes/webhook-kubeconfig.yaml
name: webhook-kubeconfig
readOnly: true
volumes:
[...]
path: /etc/kubernetes/admission-control-config.yaml
type: FileOrCreate
- name: webhook-kubeconfig
hostPath:
path: /etc/kubernetes/webhook-kubeconfig.yaml
type: FileOrCreate
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:
kubectl run ok --image=docker.io/alpine:3.22
pod/ok created
kubectl run nope --image=docker.io/nginx:latest
Error from server (Forbidden): pods "nope" is forbidden: one or more images rejected by webhook backend
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.
[1] https://kubernetes.io/docs/reference/access-authn-authz/admission-controllers/#imagepolicywebhook
[2] https://kubernetes.io/docs/reference/access-authn-authz/admission-controllers/#request-payloads
[3] https://github.com/aquasecurity/trivy
For more grumpy stories visit:
1) https://infosec.exchange/@reynardsec/115093791930794699
2) https://infosec.exchange/@reynardsec/115048607028444198
3) https://infosec.exchange/@reynardsec/115014440095793678
4) https://infosec.exchange/@reynardsec/114912792051851956
#appsec #devops #kubernetes #programming #webdev #docker #containers #k8s #cybersecurity #infosec #cloud #hacking #sysadmin #sysops