As foundFive weaknesses in the reviewed version
This page described commit bfa62fe, before any release. A review of that version found five weaknesses, listed in the repository's changelog under "Hardening since the first review". The first two were reproduced for this page by running the current tests against bfa62fe; the other three are absences, read from the changelog and from that version's own limits list.
- open in the reviewed version
- closed in v0.1.0
5 open in the reviewed version (commit bfa62fe)
5 closed in v0.1.0
Table, 5 rows: The five weaknesses in commit bfa62fe
| Weakness | What it meant | Evidence on bfa62fe |
|---|---|---|
| A malformed image reference was accepted | imageref.Parse("ghcr.io/org/../evil") returned registry ghcr.io and repository org/../evil. An allow-list entry ghcr.io/org matched it as text. ::0 was accepted too. The design notes add that a runtime would probably have refused such an image, but an allow-list should not depend on that. | Reproduced: both calls returned no error. |
ignoreUnfixed could drop a KEV finding | With ignoreUnfixed: true, an exploited CVE that has no fixed version passed denyKev and maxRisk. | Reproduced: three of the current policy tests fail on that commit (outcome allow, want deny). |
| Availability was unproven | Nothing showed what the API server does while the webhook is down, or that kube-system and the webhook's own namespace stay ungated. | No outage step in the end-to-end run; the changelog says it "now also" covers one. |
| Evidence was unauthenticated | A report file was trusted as found, so anyone who could write the report store could change a verdict. | No signature option existed. |
| No published image | The chart's default image tag did not exist and the release workflow had never run. | The earlier version of this page said there was no release and that the release workflow had never run. The README now says the chart's default image "now exists". |
As leftWhat v0.1.0 changed, and what it still does not do
What v0.1.0 changed
Release v0.1.0 (tag commit 161169a, 2026-11-11). Each row has its evidence and the limit that remains.
Table, 5 rows: The five changes, their evidence and the limit each keeps
| Change | Evidence | Limit that remains |
|---|---|---|
| Strict image-reference parsing under the OCI grammar | The webhook now answers ghcr.io/org/../evil with an imageRef denial (recorded on Plate 1). imageref has 97.4% coverage and a fuzz target. The README records a differential run against distribution/reference: 14,907,530 executions, no input accepted here that the library rejects. | The differential harness lives in a scratch module outside the repository, so it was not re-run. The parser is deliberately stricter than the library in a few documented places. |
ignoreUnfixed never drops a KEV finding | Five policy test cases cover it, including a finding the report itself marks as KEV; three of them fail on bfa62fe. Only an explicit CVE exception with an expiry date can waive one. | Exceptions are policy entries, not signed attestations; whoever can edit the policy can add one. |
| Outage behaviour proven on kind | The end-to-end run now scales the webhook to zero. Under failurePolicy: Fail the API server rejects the Pod with "failed calling webhook"; under Ignore it admits a Pod the policy would deny; kube-system and kevgate-system stay ungated; decisions resume after recovery. run 43081479456 passed 2026-11-11 | It does not cover --fail-mode, slow responses, node drains or helm upgrade, and the demo runs one replica. |
| Optional signed evidence | Ed25519 or ECDSA P-256 detached signatures bind a report to its digest. A report that is unsigned, or edited after signing, is never scored and counts as missing evidence. Plate 1 shows the three states. | The signature authenticates the report store only: not the scanner, and not the image. The policy and KEV files are still unsigned ConfigMaps. |
| A signed image on GHCR | The release workflow built, scanned, pushed, signed (cosign, keyless) and attached a CycloneDX SBOM attestation, then verified both. run 43080641309 passed 2026-11-11 | The webhook does not verify image signatures at admission; the signature proves which workflow built the image, nothing more. |
Tests, re-run locally
Run on 2026-11-11 from a fresh clone at tag v0.1.0 (commit 161169a), Go 1.27.4, on Windows.
Output, 12 lines: $ go test ./... -cover
$ go test ./... -cover
ok .../internal/admission coverage: 98.0% of statements
ok .../internal/app coverage: 96.4% of statements
ok .../internal/evidence coverage: 92.8% of statements
ok .../internal/evsign coverage: 93.8% of statements
ok .../internal/imageref coverage: 97.4% of statements
ok .../internal/policy coverage: 98.0% of statements
ok .../internal/risk coverage: 87.5% of statements
ok .../internal/trivy coverage: 98.9% of statements
...
$ go test ./... -coverprofile=c.out; go tool cover -func=c.out | tail -1
total: (statements) 95.0%Trimmed: 8 of 15 packages shown and the elapsed times removed. Counting passes in go test -json gave 93 top-level tests and 482 including subtests and fuzz seed runs, none failing.
A real scanner report
Every other fixture is hand-written. One test parses a real report from Trivy 0.76.2 for python:3.9-slim-bullseye, trimmed to 10 vulnerabilities, with the image history removed after a public GPG key id tripped the secret scanner. The description of the pull request that added it (#2) says that a full report gave 72 findings, equal to the 72 unique CVE ids among 171 raw entries. The full report was not committed, so that figure is the pull request's own and is unverified here. The committed file was: 10 entries, 10 findings, listed in the "Scanner report" view of Plate 1.
Fuzzing
Three fuzz targets: the image-reference parser, the Trivy parser and the AdmissionReview decoder. The README records a 60 second run of each with no findings (17,574,522, 3,552,242 and 1,758,884 executions). CI runs each for 30 seconds. A 5 second run of each on 2026-11-11 also found nothing; a longer run was not repeated.
The kind end-to-end run
The e2e-kind workflow builds the image, creates a kind cluster, installs the Helm chart and applies demo workloads against synthetic evidence. It fails the job unless every outcome and denial text matches. This is the transcript from the repository README (the output of a passing CI run; the runner's file paths are shortened with ...):
Output, 23 lines: End-to-end transcript (README)
== clean in demo-enforce (expect: allowed)
deployment.apps/clean created
RESULT clean: PASS
== kev-vulnerable in demo-enforce (expect: denied)
Error from server (Forbidden): error when creating ".../kev-vulnerable.yaml": admission webhook "validate.kevgate-admit.samiali38183.github.io" denied the request: kevgate-admit: request denied by policy (namespace "demo-enforce", scope "demo-enforce")
- [denyKev] registry.example.com/demo/kev-vulnerable@sha256:222222222222: 1 finding(s) in the CISA KEV catalog
CVE-2021-44228 KEV risk 87.5 log4j-core 2.14.1 (fixed 2.17.1)
- [maxRisk] registry.example.com/demo/kev-vulnerable@sha256:222222222222: 1 finding(s) above max risk 40.0 (highest 87.5)
CVE-2021-44228 KEV risk 87.5 log4j-core 2.14.1 (fixed 2.17.1)
RESULT kev-vulnerable: PASS
== over-threshold-audit in demo-audit (expect: warned)
Warning: kevgate-admit audit: would deny [maxRisk] registry.example.com/demo/over-threshold@sha256:333333333333: 1 finding(s) above max risk 40.0 (highest 47.7)
deployment.apps/over-threshold created
RESULT over-threshold-audit: PASS
== tag-only in demo-enforce (expect: denied)
Error from server (Forbidden): ... denied the request: kevgate-admit: request denied by policy (namespace "demo-enforce", scope "demo-enforce")
- [requireDigest] registry.example.com/demo/clean:latest: image is not pinned by digest; tags are mutable
RESULT tag-only: PASS
demo finished: all checks passedTrimmed: namespace setup, Helm install output, the over-threshold enforce case and the metrics block removed; the rest is unedited. The images are synthetic (registry.example.com) and the CVE-2099 entries are invented.
The outage step
The same workflow then runs deploy/demo/outage.sh. This excerpt from the README shows the part that matters (the exact reason after "failed calling webhook" depends on the Kubernetes version and kube-proxy):
Output, 15 lines: Outage transcript (README)
== failurePolicy Fail: a Pod create is rejected by the API server
Error from server (InternalError): error when creating "STDIN": Internal error occurred: failed calling webhook "validate.kevgate-admit.samiali38183.github.io": failed to call webhook: Post "https://kevgate-admit.kevgate-system.svc:443/validate?timeout=10s": dial tcp 10.96.183.14:443: connect: connection refused
RESULT fail-policy-rejects: PASS
RESULT fail-policy-no-pod: PASS
== failurePolicy Fail: excluded namespaces still admit during the outage
pod/excluded-kube-system created (server dry run)
RESULT outage-excluded-kube-system: PASS
== failurePolicy Ignore: the same outage admits the Pod, even one the policy would deny
validatingwebhookconfiguration.admissionregistration.k8s.io/kevgate-admit patched
pod/outage-ignore created
RESULT ignore-policy-admits-tag-only-pod: PASS
outage test finished: all checks passedTrimmed to the Fail and Ignore cases; the baseline, restore and resume steps are in the README. The run the README cites for this step is run 43042286763 passed 2026-11-11.
Table, 6 rows: Results
| Measure | Value | Source |
|---|---|---|
| Tests | 93 top-level (482 with subtests and fuzz seed runs) | re-run for this page; README |
| Coverage | 95.0% of statements across packages | re-run for this page (go tool cover) |
| Score parity with Python | 1000 generated vectors plus 12 hand-computed | scripts/gen_parity.py; 1000 vectors counted in the committed file |
| Fuzzing | 3 targets, 60 s each, no findings | README (a 5 s run of each was repeated for this page) |
| End-to-end | allow, deny (KEV, risk, unpinned tag) and audit-warn outcomes, then the outage step | run 43081479456 passed 2026-11-11 (e2e-kind on main) |
| Admission desk | 24 recorded responses (15 decisions, 9 with signed evidence) | captured from the tagged binary; scripts/capture_admission_bench.py --check |
Technical detailsHow it works
design, implementation points and diagram
The problem
Vulnerability scans usually run in a pipeline, and nothing stops a workload with a known-exploited vulnerability from reaching the cluster anyway. A tag is also not evidence: nginx:1.25 can point at different bytes tomorrow, so a report about a tag says little about what a node will pull.
What it does
For CREATE and UPDATE of Pods, Deployments, StatefulSets, DaemonSets, Jobs and CronJobs, it extracts every container, init container and ephemeral container image, parses each reference strictly, looks up a Trivy report for each image digest, optionally checks the report's signature, scores each finding, and applies the namespace's policy. The answer is allow, allow with warnings, or deny with a message naming the rule and the top findings.
Policy is per namespace: denyKev, maxRisk, requireDigest, requireEvidence, requireSignedEvidence, allowed registries, exceptions with an expiry date, and an audit mode that admits but warns. It does not scan images itself; it enforces over reports a scan pipeline already produced.
Describe this diagram
- kube-apiserver. AdmissionReview v1 over TLS, CREATE and UPDATE.
- Strict decode, extract images. containers, init, ephemeral; at most 128 images.
- Parse references strictly. OCI grammar; tags are never resolved.
- Bounded evidence lookup. Trivy report per digest, signature check optional; timeouts, concurrency cap.
- Score and apply namespace policy. kevgate risk model; policy hot-reloaded.
- Allow, warn (audit mode) or deny. message names the rule and top findings.
- Image digestThe reference is parsed under the OCI grammar and never resolved. A tag has no digest, so the lookup stops there and requireDigest fires.
admission.Extractimageref.ParseRef.Pinned - Report fileThe digest becomes the file name sha256-HEX.json. The lookup is bounded by a timeout and a concurrency cap.
evidence.FileNameDirProvider.LookupHandler.lookupBounded - Parse and checkFindings are de-duplicated by CVE id and validated. A report naming another digest is refused. With signed evidence the signature is verified over digest plus bytes.
trivy.ParseReaderDirProvider.checkSignatureVerifier.Verify - ScoreEach finding gets a 0 to 100 risk from CVSS, EPSS, asset criticality and KEV membership.
risk.Scoreevaluator.findings - Policy rulesThe namespace picks the rule set. Registry list, digest, evidence, signed evidence, denyKev and maxRisk are applied in that order.
Policy.RuleForPolicy.Evaluate - VerdictThe decision becomes an AdmissionResponse: denied with a message, allowed, or allowed with warnings in audit mode.
Handler.ReviewDecision.MessageDecision.ResponseWarnings
internal/admission, imageref, evidence, evsign, trivy, risk and policy. Formula: risk = 100 * (0.40*kev + 0.25*acr/8 + 0.20*epss + 0.20*cvss/10) / 1.05, rounded to one decimal.Four implementation points
The Go score must equal the Python score
Two services that rank the same finding differently would be a bug nobody notices until an argument about a denial. Go can differ from Python in the last bit: constant expressions are evaluated with arbitrary precision, and the compiler may fuse multiply-adds on some architectures. internal/risk keeps the weights in variables so the arithmetic runs in float64 at run time, forces explicit conversions to prevent fusing, and rounds to one decimal through the decimal expansion of the value, which is what Python's round(x, 1) does. scripts/gen_parity.py calls kevgate's own risk_score to generate 1000 reference vectors (a grid plus a seeded random sample) and a test compares every one, next to 12 hand-computed cases.
Only a well-formed digest names an image
internal/imageref never resolves a tag, and it parses a reference under the OCI distribution grammar before anything else uses it: dot segments, upper-case repositories, bad hosts, bad ports and non-ASCII bytes are rejected as imageRef, and allow-list entries are validated with the same rules and match whole path components. A malformed digest is an error rather than "unpinned", so an image that cannot be pulled is not confused with one that merely lacks a pin. Evidence is keyed by sha256-<hex>.json, and a report whose own metadata names a different digest is rejected as an evidence error, so a file renamed to another image's digest cannot vouch for it.
Bounded work and last-good state
Admission sits on the critical path of every deploy, so the lookup is bounded. Each request has a time budget (8 s by default, to stay under the webhook timeout), each evidence lookup has its own (3 s), a semaphore caps concurrent lookups, a request may name at most 128 distinct images, and report files and HTTP bodies have size caps. If the evidence source fails, --fail-mode closed (the default) denies instead of waiting. Policy, KEV catalog, EPSS data and the TLS pair are hot reloaded by a generic File[T] that swaps a parsed copy atomically; a file that fails to parse never replaces the last good one, and a broken version is remembered by modification time and size so it is reported once per change, not on every tick.
A signature binds a report to its digest
internal/evsign signs a domain-separation string, the image digest and the report bytes together, with Ed25519 or ECDSA P-256 from the standard library and a detached .sig file. Moving a signed report to another digest, or editing it, breaks verification. The directory provider reads the report once and uses the same bytes for the signature check and the parse, so what was verified is what is judged. A report that fails the check is not scored: it counts as missing, and requireEvidence decides.