kevgate-admit

What it does
It checks each container image against existing scan reports when a workload is created. Depending on the namespace policy, it denies, warns or allows.
Why it matters
The decision happens where the workload gets created. It does not scan images itself.
What was found
A review of the earlier version found five weaknesses, such as a malformed image name that was accepted. Version 0.1.0 closes all five, each with a test, a CI run or a recorded response.

Try it Read the limits

A review of the earlier version found five weaknesses: a malformed image reference accepted, an option that could drop a KEV finding, an untested outage, unauthenticated evidence and no published image. v0.1.0 closes all five, each with a test, a CI run or a recorded response. The limits it keeps are listed below.

As left: 5 of 5 closed run 43081479456 passed 2026-11-11

  • open in the reviewed version
  • closed in v0.1.0

5 open in the reviewed version (commit bfa62fe)

5 closed in v0.1.0

Try it

Pick an image and a namespace policy and read the response the real webhook returned.

Plate 1

Recorded, not live. All responses are listed here; with JavaScript on, a desk shows one at a time. Risk formula: risk = 100 * (0.40*kev + 0.25*acr/8 + 0.20*epss + 0.20*cvss/10) / 1.05.

15 recorded responses: 5 images in 3 namespace scopes (policy deploy/demo/policy.yaml)
Workload imageNamespace policyResultText the webhook returned (denial message, or warnings)
cleandemo-enforceAllowed

No rule fired

(no message, no warnings)
kev-vulnerabledemo-enforceDenied

Rule fired: denyKev, maxRisk

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)
over-thresholddemo-enforceDenied

Rule fired: maxRisk

kevgate-admit: request denied by policy (namespace "demo-enforce", scope "demo-enforce") - [maxRisk] registry.example.com/demo/over-threshold@sha256:333333333333: 1 finding(s) above max risk 40.0 (highest 47.7) CVE-2099-10004 risk 47.7 libwidget 3.2 (fixed 3.3)
tag-onlydemo-enforceDenied

Rule fired: requireDigest

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
malformed referencedemo-enforceDenied

Rule fired: imageRef

kevgate-admit: request denied by policy (namespace "demo-enforce", scope "demo-enforce") - [imageRef] ghcr.io/org/../evil: malformed image reference: invalid image reference: malformed repository
cleandemo-auditAllowed

No rule fired

(no message, no warnings)
kev-vulnerabledemo-auditAllowed with warning

Would have fired, admitted in audit mode: denyKev, maxRisk

kevgate-admit audit: would deny [denyKev] registry.example.com/demo/kev-vulnerable@sha256:222222222222: 1 finding(s) in the CISA KEV catalog kevgate-admit audit: would deny [maxRisk] registry.example.com/demo/kev-vulnerable@sha256:222222222222: 1 finding(s) above max risk 40.0 (highest 87.5)
over-thresholddemo-auditAllowed with warning

Would have fired, admitted in audit mode: maxRisk

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)
tag-onlydemo-auditAllowed with warning

Would have fired, admitted in audit mode: requireDigest

kevgate-admit audit: would deny [requireDigest] registry.example.com/demo/clean:latest: image is not pinned by digest; tags are mutable
malformed referencedemo-auditAllowed with warning

Would have fired, admitted in audit mode: imageRef

kevgate-admit audit: would deny [imageRef] ghcr.io/org/../evil: malformed image reference: invalid image reference: malformed repository
cleanany other namespaceAllowed

No rule fired

(no message, no warnings)
kev-vulnerableany other namespaceDenied

Rule fired: denyKev

kevgate-admit: request denied by policy (namespace "other", scope "_default") - [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)
over-thresholdany other namespaceAllowed

No rule fired

(no message, no warnings)
tag-onlyany other namespaceDenied

Rule fired: requireDigest

kevgate-admit: request denied by policy (namespace "other", scope "_default") - [requireDigest] registry.example.com/demo/clean:latest: image is not pinned by digest; tags are mutable
malformed referenceany other namespaceDenied

Rule fired: imageRef

kevgate-admit: request denied by policy (namespace "other", scope "_default") - [imageRef] ghcr.io/org/../evil: malformed image reference: invalid image reference: malformed repository

Signed evidence

The same webhook with requireSignedEvidence: true and an Ed25519 key. 9 recorded responses for three states of the report store.

9 recorded responses with signed evidence required
Workload imageReport storeResultText the webhook returned (denial message, or warnings)
cleanValid signatureAllowed

No rule fired

(no message, no warnings)
kev-vulnerableValid signatureDenied

Rule fired: denyKev, maxRisk

kevgate-admit: request denied by policy (namespace "demo-signed", scope "_default") - [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)
over-thresholdValid signatureDenied

Rule fired: maxRisk

kevgate-admit: request denied by policy (namespace "demo-signed", scope "_default") - [maxRisk] registry.example.com/demo/over-threshold@sha256:333333333333: 1 finding(s) above max risk 40.0 (highest 47.7) CVE-2099-10004 risk 47.7 libwidget 3.2 (fixed 3.3)
cleanReport edited after signingDenied

Rule fired: requireEvidence

kevgate-admit: request denied by policy (namespace "demo-signed", scope "_default") - [requireEvidence] registry.example.com/demo/clean@sha256:111111111111: scan report signature is invalid (signature does not verify against a trusted key); signed evidence is required
kev-vulnerableReport edited after signingDenied

Rule fired: requireEvidence

kevgate-admit: request denied by policy (namespace "demo-signed", scope "_default") - [requireEvidence] registry.example.com/demo/kev-vulnerable@sha256:222222222222: scan report signature is invalid (signature does not verify against a trusted key); signed evidence is required
over-thresholdReport edited after signingDenied

Rule fired: requireEvidence

kevgate-admit: request denied by policy (namespace "demo-signed", scope "_default") - [requireEvidence] registry.example.com/demo/over-threshold@sha256:333333333333: scan report signature is invalid (signature does not verify against a trusted key); signed evidence is required
cleanSignature missingDenied

Rule fired: requireEvidence

kevgate-admit: request denied by policy (namespace "demo-signed", scope "_default") - [requireEvidence] registry.example.com/demo/clean@sha256:111111111111: scan report is unsigned (no signature file sha256-1111111111111111111111111111111111111111111111111111111111111111.json.sig); signed evidence is required
kev-vulnerableSignature missingDenied

Rule fired: requireEvidence

kevgate-admit: request denied by policy (namespace "demo-signed", scope "_default") - [requireEvidence] registry.example.com/demo/kev-vulnerable@sha256:222222222222: scan report is unsigned (no signature file sha256-2222222222222222222222222222222222222222222222222222222222222222.json.sig); signed evidence is required
over-thresholdSignature missingDenied

Rule fired: requireEvidence

kevgate-admit: request denied by policy (namespace "demo-signed", scope "_default") - [requireEvidence] registry.example.com/demo/over-threshold@sha256:333333333333: scan report is unsigned (no signature file sha256-3333333333333333333333333333333333333333333333333333333333333333.json.sig); signed evidence is required

Scanner report

Trivy 0.76.2 report, target python:3.9-slim-bullseye (debian 11.11): 10 vulnerability entries, parsed by the webhook's own parser into 10 findings.

Findings as parsed by trivy.Parse
CVEPackageInstalledFixed inSeverityCVSSEPSSRisk
CVE-2022-3715bash5.1-2+deb11u1no fixHIGH7.8none26.8
CVE-2022-40897setuptools58.1.065.5.1HIGH7.5none26.2
CVE-2024-6345setuptools58.1.070.0.0HIGH8.8none28.7
CVE-2025-47273setuptools58.1.078.1.1HIGH8.8none28.7
CVE-2026-109518apt2.2.4no fixHIGH8.1none27.3
CVE-2026-24049wheel0.45.10.46.2HIGH7.1none25.4
CVE-2026-53613bsdutils1:2.36.1-8+deb11u2no fixHIGH7.0none25.2
CVE-2026-53615bsdutils1:2.36.1-8+deb11u2no fixHIGH8.0none27.1
CVE-2026-76642bsdutils1:2.36.1-8+deb11u2no fixHIGH7.8none26.8
CVE-2026-78408bsdutils1:2.36.1-8+deb11u2no fixHIGH7.9none27.0

risk.Score(cvss, epss, acr 4, kev false): no EPSS file, no KEV match, as under the demo policy. Fixture: internal/trivy/testdata/real-trivy-0.76.2-python-3.9-slim-bullseye.json, 6934 bytes.

The same image is denied where the namespace enforces, only warned about where it audits, and a malformed reference is rejected before any report is looked up. Recorded, not live: every result is the raw AdmissionReview response of kevgate-admit v0.1.0 (commit 161169a, go version go1.27.4 windows/amd64), started with kevgate-admit serve --self-signed --policy-file deploy/demo/policy.yaml --kev-file deploy/demo/kev-demo.json --evidence-dir deploy/demo/reports and sent one POST per pair, 2026-11-11. The page evaluates no policy; the desk only chooses which recorded response to show. Synthetic demo data and test output, not a cluster run. Regenerate with python scripts/capture_admission_bench.py; node scripts/test_bench_admission.mjs checks that the page shows exactly the stored responses.
Checked against
Python score on 1000 vectors; synthetic and real Trivy reports; kind in CI
Found, then left
As found Accepted org/../evil; ignoreUnfixed could drop a KEV finding; outage untested; unsigned evidence; no image
As left v0.1.0: 93 tests, 95.0% coverage, signed image
Limits
Does not scan images or verify image signatures; synthetic evidence in the demo Full limits
Technical description and verification record: instrument, reference standard, date, how to reproduce (self-issued, not accredited)

A Kubernetes validating admission webhook, written in Go. It denies or warns on workloads whose container images carry KEV-listed or over-threshold vulnerabilities, using the same risk model as kevgate. Release v0.1.0 adds strict image-reference parsing, optional signed scan reports and a signed image on GHCR.

Instrument
kevgate-admit v0.1.0, tag commit 161169a (released 2026-11-11)
Reference standard
kevgate's Python risk score on 1000 generated vectors; synthetic and one real Trivy report; a kind cluster in CI; distribution/reference for the image-reference grammar
Measured on
2026-11-11, Go 1.27.4, fresh clone at tag v0.1.0
Issued by
The author. Self-issued verification record, not accredited.
Reproduce
go test ./... -cover

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
The five weaknesses in commit bfa62fe
WeaknessWhat it meantEvidence on bfa62fe
A malformed image reference was acceptedimageref.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 findingWith 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 unprovenNothing 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 unauthenticatedA report file was trusted as found, so anyone who could write the report store could change a verdict.No signature option existed.
No published imageThe 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
The five changes, their evidence and the limit each keeps
ChangeEvidenceLimit that remains
Strict image-reference parsing under the OCI grammarThe 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 findingFive 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 kindThe 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-11It does not cover --fail-mode, slow responses, node drains or helm upgrade, and the demo runs one replica.
Optional signed evidenceEd25519 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 GHCRThe release workflow built, scanned, pushed, signed (cosign, keyless) and attached a CycloneDX SBOM attestation, then verified both. run 43080641309 passed 2026-11-11The 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 passed

Trimmed: 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 passed

Trimmed 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
Results
MeasureValueSource
Tests93 top-level (482 with subtests and fuzz seed runs)re-run for this page; README
Coverage95.0% of statements across packagesre-run for this page (go tool cover)
Score parity with Python1000 generated vectors plus 12 hand-computedscripts/gen_parity.py; 1000 vectors counted in the committed file
Fuzzing3 targets, 60 s each, no findingsREADME (a 5 s run of each was repeated for this page)
End-to-endallow, deny (KEV, risk, unpinned tag) and audit-warn outcomes, then the outage steprun 43081479456 passed 2026-11-11 (e2e-kind on main)
Admission desk24 recorded responses (15 decisions, 9 with signed evidence)captured from the tagged binary; scripts/capture_admission_bench.py --check
Technical details

How 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.

An admission webhook is a service the Kubernetes API server asks to approve or reject an object before it is stored.
Plate 2kevgate-admit admission pathThe API server posts an AdmissionReview over TLS. The webhook decodes it and extracts every image, parses each reference by digest, looks up a vulnerability report per digest with time and size limits, scores findings and applies the namespace policy, then allows, warns or denies.kube-apiserverAdmissionReview v1 over TLS,CREATE and UPDATEAStrict decode, extract imagescontainers, init, ephemeral; atmost 128 imagesBParse references strictlyOCI grammar; tags are neverresolvedCBounded evidence lookupTrivy report per digest,signature check optional;timeouts, concurrency capDScore and apply namespacepolicykevgate risk model; policyhot-reloadedEAllow, warn (audit mode) ordenymessage names the rule and topfindingsF
The API server posts an AdmissionReview over TLS. The webhook decodes it and extracts every image, parses each reference by digest, looks up a vulnerability report per digest with time and size limits, scores findings and applies the namespace policy, then allows, warns or denies.
Describe this diagram
  1. kube-apiserver. AdmissionReview v1 over TLS, CREATE and UPDATE.
  2. Strict decode, extract images. containers, init, ephemeral; at most 128 images.
  3. Parse references strictly. OCI grammar; tags are never resolved.
  4. Bounded evidence lookup. Trivy report per digest, signature check optional; timeouts, concurrency cap.
  5. Score and apply namespace policy. kevgate risk model; policy hot-reloaded.
  6. Allow, warn (audit mode) or deny. message names the rule and top findings.
Plate 3
  1. 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.Extract imageref.Parse Ref.Pinned
  2. Report fileThe digest becomes the file name sha256-HEX.json. The lookup is bounded by a timeout and a concurrency cap.evidence.FileName DirProvider.Lookup Handler.lookupBounded
  3. 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.ParseReader DirProvider.checkSignature Verifier.Verify
  4. ScoreEach finding gets a 0 to 100 risk from CVSS, EPSS, asset criticality and KEV membership.risk.Score evaluator.findings
  5. Policy rulesThe namespace picks the rule set. Registry list, digest, evidence, signed evidence, denyKev and maxRisk are applied in that order.Policy.RuleFor Policy.Evaluate
  6. VerdictThe decision becomes an AdmissionResponse: denied with a message, allowed, or allowed with warnings in audit mode.Handler.Review Decision.Message Decision.ResponseWarnings
From an image digest to a verdict, in six steps. The names are Go identifiers in kevgate-admit v0.1.0, each checked against the tagged source by the capture script; the packages are 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.

Tolerance and limitsWhat the numbers do not show

  • It does not verify image signatures at admission. The published image is signed, but the webhook checks nothing about the images it admits beyond their reports. Kyverno verifyImages or the Sigstore policy-controller already do that.
  • Report signatures authenticate the report store only. They show that someone holding the key signed the file; they say nothing about whether the scan was right or the image is what it claims.
  • It does not scan images. A digest with no report is "no evidence", handled by requireEvidence.
  • Evidence goes stale. maxEvidenceAge bounds it when the report carries a timestamp: a signed one when the report has CreatedAt, otherwise the unsigned file modification time.
  • The cert-manager certificate path is not exercised in CI. It is only checked by helm template and kubeconform; the end-to-end run uses a certificate generated at install time.
  • In tls.mode=helm a helm upgrade may delete the generated Secret. The README documents this; it was not tested. Use cert-manager or an existing Secret outside demos.
  • The outage test is narrow. It does not cover --fail-mode (the webhook answering but unable to judge), slow responses and timeouts at the API server, node drains with the PodDisruptionBudget, or helm upgrade. The demo runs one replica.
  • The policy and the KEV catalog are unsigned ConfigMaps, and exceptions are policy entries with an expiry, not signed attestations. Whoever can edit them controls enforcement.
  • The demo uses synthetic evidence, and ConfigMap-hosted reports are limited to 1 MiB in total. The Admission desk shows responses from a locally run binary, not from a cluster. The race detector was not run locally (it needs cgo); CI runs it.
Evidence

Where each number comes from

9 links

Chips show the state of each GitHub Actions run as captured when this page was built. The page does not call GitHub at runtime.

Release, image build, scan and signature (release, v0.1.0)
run 43080641309 passed 2026-11-11
Unit tests and lint (ci, main)
run 43081479537 passed 2026-11-11
Kind end to end (e2e-kind, main)
run 43081479456 passed 2026-11-11
Scans (security, main)
run 43081479552 passed 2026-11-11
Outage test, first passing run (e2e-kind, pull request)
run 43042286763 passed 2026-11-11
Score parity script
scripts/gen_parity.py
End-to-end transcript
README at v0.1.0
Admission desk capture script
scripts/capture_admission_bench.py
Admission desk parity test
scripts/test_bench_admission.mjs

CI evidence

Workflows on main at 0499200 (one README-only commit after the tag), and the release run; what each covers is read from the workflow files
WorkflowWhat it coversRun
civet and lint, tests with the race detector and an 85% coverage floor, 30 s fuzz runs of three targets, Helm lint, kubeconform, shellcheck, actionlintrun 43081479537 passed 2026-11-11
e2e-kindkind cluster, chart install, allow, deny and warn outcomes with exact denial text, then the outage steprun 43081479456 passed 2026-11-11
securitygovulncheck, gosec, Trivy filesystem and image scans, hadolint, gitleaksrun 43081479552 passed 2026-11-11
release (v0.1.0)image build, Trivy scan failing on HIGH or CRITICAL, SBOM, push to GHCR, keyless cosign signature, SBOM attestation, verificationrun 43080641309 passed 2026-11-11

The published image

ghcr.io/samiali38183/kevgate-admit@sha256:1023382754d9b6b7ca727d47701d9e3bfb5f245a6531e0debd1307ebf9e64195 (tag 0.1.0, linux/amd64). Checked on 2026-11-11 with the registry API and an anonymous token: the manifest is served without credentials, its SHA-256 equals that digest, tag 0.1.0 resolves to the same digest, and two Sigstore bundles are attached to it (the release signed and attested the image). cosign verify was not run for this page; the release run did, as its last step.