Deny list
A policy answers "does this image meet the rules", automatically. A deny-list entry answers a different question: "did somebody decide this image must not run". The deny list is where that decision is written down, and it is checked before anything else.
There is one list per cluster, in Security › Deny list. Each policy's denyList field decides what its namespaces do about a match. An entry is never deleted: removing it lifts it, and the row stays with who removed it, when and why.
An empty list changes nothing. With no active entry, admission resolves no extra digest and refuses nothing, so upgrading to 1.4.0 changes no decision until somebody adds an entry.
Kinds of entry
| Kind | Matches | Example |
|---|---|---|
digest | One exact image build: sha256: and 64 hex characters, compared with the digest admission resolved for the pod, and with the image index or platform manifests it is known to belong to. | sha256:9f2a…d4e5 |
cve | Every image whose last scan reports the advisory id, whether or not a fix exists. | CVE-2021-44228, GHSA-jfh8-c2jp-5v3q, DLA-4726-1 |
package | Every image whose last scan lists the package in the version range. | maven org.apache.logging.log4j:log4j-core < 2.17.1 |
binary | Every image carrying one executable, by the sha256 of its bytes, whatever its name or path. | binary sha256 d697e81fb96e |
binary_path | Every image with an executable at a path, or with a file name anywhere. A path rule also hits base images: start it where the policy has denyList: audit. A pattern of wildcards only is refused. | /bin/sh, /usr/local/bin/*, nc |
package, ecosystem bitnami | Every image with a runtime whose version, read out of the executable itself, is in the range; never a distribution package. | bitnami php < 8.2.35 |
Advisory ids are the ones scanners report findings under, because for some findings that id is the only name they have. Accepted schemes: CVE, GHSA, GO, PYSEC, RUSTSEC, OSV, MAL, GSD, DLA, DSA, ELA, USN, RHSA, RHBA, ALSA, ALBA, ELSA, CGA, BIT, TEMP, SUSE-SU and openSUSE-SU. Anything else — NOT-A-CVE, a typo ending in a dash — is refused when the entry is added, since an id no scanner reports would match nothing. Ids are stored upper-case (a GHSA body lower-case, as GitHub prints it) and matched case-insensitively.
Package entries: operators and versions
A package entry names an ecosystem, a package and one operator: lt (<), le (<=), eq (=), ge (>=), gt (>), between (lower bound included, upper bound excluded) or any. There is no free-text range; two ranges are two entries. Every operator works in every ecosystem, and versions are compared with the comparator the scanner itself uses for that ecosystem. A version the comparator cannot read is refused when the entry is added, rather than compared as a string, where 10.0 would sort below 9.0.
| Ecosystem | Package name | Version format |
|---|---|---|
apk | the package, or its origin | version-rN, such as 3.3.2-r0 |
cargo | the crate | SemVer, such as 1.0.188 |
composer | vendor/package | a release version, such as 7.4.2 |
deb | the binary package, or its source package | [epoch:]upstream[-revision], such as 3.0.11-1~deb12u2 |
go | the module path | a module version, such as v0.17.0 |
maven | groupId:artifactId | a Maven version starting with a digit, such as 2.17.1 |
npm | the package | SemVer, such as 4.17.21 |
nuget | the package id | a package version, such as 13.0.1 |
pypi | the project (stored in its PEP 503 form, so PyYAML and pyyaml are one entry) | PEP 440, such as 2.31.0 or 1.0rc1 |
rpm | the binary package, or its source package | [epoch:]version-release starting with a digit |
rubygems | the gem | a gem version, such as 7.0.4 |
An entry with gt or ge also covers every version released after it is added; the what-if says so. The console lists the same table from GET /api/v1/deny-list/ecosystems.
Executables
Trivy reads what package managers recorded. A runtime compiled from source and copied into an image has no such record, and Trivy lists no package for it: a PHP 8.2.12 built from the php.net tarball, in an image without labels, shows 91 Debian packages and no php, in Trivy 0.74.0 and 0.76.1 alike. The binary, binary_path and bitnami entries are answered by the binary check instead, a second tool in the operator image that reads each image's executables. It runs only when such an entry is on the list; with none, no image is pulled a second time.
- Only bytes. The image is pulled by digest with the workload's own pull secrets, its layers read top-down with whiteouts applied, and every ELF file hashed. The image's name, labels, environment and base image are never read. A path is what a path rule matches, not evidence of a product.
- Versions from the file itself. A runtime's version is read from the executable's read-only data and confirmed by a second string in the same file, so a configuration file or a program that only links a library is not that product: nginx carries the OpenSSL it was built against, which is not the OpenSSL installed. Products: php, node, python, ruby, nginx, apache, postgresql, curl, openssl. Another is refused when the entry is added, and so is a range the signatures cannot read (they read ruby 3.x, python 3.x, postgresql 10 to 19 and openssl 1.1.1 and 3.x).
- Stripped builds are seen.
stripremoves symbols and debug sections, not read-only data: a stripped PHP 8.2.12 carries its version string at the same offset as the unstripped one. - Three answers. Matched (with the file, its sha256 and the version found), not matched, or not checked with a reason (credentials, registry, image gone, limit, tool error, no source). Not checked is never counted as clean. The reason no check yet means no answer is recorded for the image's digest against the entry yet. An admission that meets it queues a check, and its warning or refusal says so ("(no check yet); a check has been queued"). In Affected and What if a running image with no recorded answer reads not checked (no check yet) when nothing is queued for it; one whose check is queued or whose scan failed is counted as no answer yet.
- When it runs. An added entry (an advisory id included) is checked against the images of running pods first, then the digests scanned in the last 90 days; a new image is checked in its scan; running images an entry has not reached yet are checked hourly. What if and Affected say "checking n of m" until every answer is in; an administrator can check an image again, which reads it again under the caps in force. A pod whose image has no answer yet for an executable or
bitnamientry is decided as an unscanned image is:DenyUntilScannedrefuses until the check has run,AllowAndScanadmits with a warning. For an advisory entry a missing answer never refuses: Trivy's findings decide at once, and a match the check finds later only adds to them. - Advisory ids reach these runtimes too. With a
cveentry on the list, the runtimes found are matched against the Bitnami vulnerability ranges in the scanner's database:CVE-2026-109618catches a source-built PHP 8.2.12 or 8.3.0, not 8.2.35. A runtime those ranges do not cover, such as curl or openssl, is "no vulnerability data in this source", never "not matched".
Not seen: an executable whose version string was removed or patched, one whose section headers were removed (shown as not checked), a statically linked and stripped executable of a product confirmed by a symbol name, packed executables (a packed or damaged executable anywhere in an image leaves that image's bitnami answers not checked, naming the file, and under DenyUntilScanned the image is refused until the entry is removed or the admission timing is changed), runtimes downloaded when the container starts, versions written outside executables, interpreted application code, and images that cannot be pulled (shown as not checked). The caps — an ELF file over 256 MiB is hashed but not read for versions, at most 4,000 executables per image, 60 seconds per walk — are the chart value binscanLimits (configuration). Before downloading, the check adds up the image's layer sizes; an image larger than its disk budget (the scanner's /tmp limit, scan.tmpSizeLimit, shared by its workers, or scan.binscanDiskBudget when set) is not downloaded and reads not checked: "image larger than the check's disk budget".
Where it sits in the decision
The order is deny list, then break-glass, then the policy's rules. A listed image is refused even when it is clean, and even while break-glass is open: the entry is a deliberate decision, and it outranks the emergency door. The refusal reads:
denied by deny list entry 7: package maven org.apache.logging.log4j:log4j-core < 2.17.1
The rules that need no registry call — the allowed-registry list, blockLatestTag, a required digest — are checked first. Under an enforcing policy their refusal stands on its own; under an Audit policy the review goes on to the deny list, so a listed image is refused there too.
The policy's denyList field:
| Value | What a match does in this policy's namespaces |
|---|---|
enforce (default) | Refuses the pod, even when the policy's enforcement is Audit. An empty or absent field reads as enforce. |
audit | Admits the pod with the warning would be denied by deny list entry …, and records the hit. |
off | The list is not consulted. |
Write off quoted in YAML (denyList: "off"): a bare off is a boolean to a YAML 1.1 reader. The CRD schema and the chart quote it for you.
Which requests it reviews
- Pod creation. Every container, init container and ephemeral container of a new pod.
kubectl debug. The ephemeral container the request adds, and only that one: the pod's running containers are not decided again.- Not other updates. A label, an annotation, a finalizer or an ownership change of an existing pod is neither refused nor counted.
- A dry run is answered like a real request but records no hit.
Nothing running is stopped. An entry refuses a workload the next time one of its pods is created; the workload's controller keeps retrying, with its own back-off, until its image changes.
When a digest cannot be resolved
While the list holds an entry, admission resolves the pod's digest before break-glass. If the registry does not answer:
- under an enforcing policy with
denyList: enforce, the pod is refused withdeny list could not be evaluated: digest unresolved; - under a policy in
Audit, or withdenyList: audit, it is admitted with that warning.
One consequence: an image referenced by tag that only the nodes can pull — through node credentials such as a kubelet credential provider, rather than a pull secret the pod or its service account references — cannot be resolved by the operator, and is refused in enforcing namespaces while the list is not empty. Pin it by digest, or reference a pull secret from the pod (see private registries). If the operator's database cannot be read, the list is not applied and the pod carries the warning deny list unavailable; it was not applied to this image, as the rest of admission behaves during a database outage.
What if
What if tries an entry before anyone has to find out at admission — from its row, or in the Add dialog before saving. It answers two things side by side:
- Running workloads it covers, read from the cluster at that moment: each container's running image digest, grouped by the workload the pod belongs to: the Deployment above its ReplicaSet (the ReplicaSet shown under it), the CronJob above its Job, a StatefulSet, a DaemonSet, or the pod itself. Namespaces whose policy has the list off are counted apart; namespaces the admission webhook does not intercept are left out. The read stops after 20 seconds, 20,000 pods or 2,000 distinct images, whichever comes first, and then the answer says it is partial and how many pods it inspected.
- Past admissions it would have refused, from the last 30 days by default (up to 90): the ledger's rows decided again under each namespace's policy as it stands now, with this entry as the whole list — so the answer is about this entry, not about the ones already in force. Admissions let in under break-glass are decided too and counted with the rest, since break-glass never passes the deny list; the answer says how many of them it would have refused.
Nothing is stopped and nothing is saved. A run made by a deployer or an administrator is kept in Security › What-if history with the type Deny list, ninety days like every run and at most the latest five hundred deny-list runs, counted apart from policy runs, and downloads as PDF or JSON — a projection, not evidence, never part of an evidence package. See trying a policy first for the history itself.
Hits
Every refusal and every audit warning an entry causes is counted against the workload — the Deployment, not its ReplicaSet, so a rollout does not start a new row (rows recorded before 1.4.0 keep the ReplicaSet; after the upgrade the workload's next refusal starts its Deployment row, without a new notification): one row per entry, namespace, workload and digest, with the first and last time it was seen and how many times. The deny-list page shows the latest hits of every entry under the list, and Hits on a row shows that entry's, filtered by namespace or workload. The admission ledger row of each refusal names the entry that decided it.
Affected workloads and take down
An entry refuses new pods. It never stops what is already running: no policy mode touches a running workload on its own. Affected on an entry's row lists, live from the cluster, the workloads it catches now and the ones it refused, each with a status:
- Running — pods running a listed image are up.
- Pods down, recreate denied — none is up and the controller's new pods are refused. A refused
kubectl debugcontainer is not counted here: a workload the entry caught only that way runs, is listed as Running with "debug container refused", and offers no take down. - Scaled to zero — a take down set it to zero replicas and it is still there.
- Restored externally — it was scaled to zero here and asks for replicas again: somebody, or a GitOps tool, put them back. It is shown and left alone.
An administrator can Take down a running workload, with a reason. It acts once and nothing ever repeats it. The take down is recorded before the cluster is touched, and its outcome (done, blocked by a budget, or failed with the error) after. The call to the cluster runs to its own 30-second limit even if the browser goes away. A take down whose outcome was not recorded (the operator stopped mid-call) is read back from the workload after a minute and when the controller starts: at zero replicas it is done, still at its old count it failed. The panel and the hits show a pending or failed take down with its error and age, and Restore works on a failed or pending scale to zero whose workload is at zero:
- Delete pods (the default) evicts the pods running a listed image, once. The desired state is not touched: the controller creates replacements, admission refuses them while the entry stands, and the workload shows as degraded until its image changes or the entry is removed — then it heals by itself.
- Scale to zero, for a Deployment, StatefulSet or ReplicaSet, sets the replicas to 0 through the scale subresource, refusing (409) if the count is no longer the one the dialog showed, and keeps the previous count. Restore puts it back. Removing the entry does not: nothing scales a workload without a click. A DaemonSet, a Job, a CronJob or a bare pod is taken down by deleting its pods.
PodDisruptionBudgets are honoured. Pods are evicted through the eviction API, which refuses with 429 while a budget would be broken; such a pod keeps running, the dialog says "PDB blocked, N pods remain", and nothing forces or retries it.
GitOps. A workload Argo CD tracks (the argocd.argoproj.io/tracking-id annotation, or an argocd.argoproj.io/instance tracking label) or Flux applied (kustomize.toolkit.fluxcd.io/name or helm.toolkit.fluxcd.io/name) is marked in the panel, and the dialog warns that the tool will put a scale to zero back. When it does, the row reads restored externally; Attestkeep never scales it down again. The permanent fix is in the source: change the image, or lower the replicas.
Enabling it. Take down needs rbac.denyList.takedown: true in the chart (default false). It grants the operator pods/eviction create and get/update on deployments/scale, statefulsets/scale and replicasets/scale, cluster-wide. Without it the button is shown disabled and the API answers 409. Reading the owner chain (get and list on Deployments, ReplicaSets, StatefulSets, DaemonSets, Jobs and CronJobs) is granted in every installation: it is read-only. A namespace admission does not review — the operator's own, one the webhook's namespace selector leaves out (the bypass label) or one in webhook.bypassNamespaces — is never taken down: its recreated pods would not be checked, so a take down would only restart the same image. The panel shows such a row with that note and no button; the API answers 409.
Notifications and events
A channel subscribed to Deny list hit (on by default for new channels) is told the first time an entry catches a workload, with the entry, its reason, the workload and the way out: "Change the image or lower the replicas; the gate will keep refusing recreated pods." After that the refusals are counted and sent once a day per entry as a digest. A deny-list refusal is not also sent as Admission blocked: that event is for refusals by the policy's own rules. Deny list entry added and removed are there to subscribe to, off by default.
A refusal also leaves a Kubernetes Warning event, reason DenyListRefused, naming the entry, on the workload (the Deployment, StatefulSet, DaemonSet, CronJob or Job), so kubectl describe says why its pods do not start; for a pod nobody owns, on its namespace. At most one per workload and entry every five minutes. The ImageSecurityReport of a refused image carries status.denyList with the entry id and the refusal.
Evidence
The evidence package has a Deny list section: the entries in force at the end of the period and the ones added or removed in it (who, when, why), the admissions the entries refused and let in, the exceptions — matches let in because a namespace audits the list — and every take down and restore with its reason. The policy hash does not cover the list; the entry that decided an admission is named in that admission record. See verifying reports.
Limits
- Advisory and package entries need a scan. They are matched against an image's last scan record; an image that has never been scanned cannot be matched until it is. Digest entries need no scan.
- Index or platform digest. An entry naming an image index digest catches a pod that arrives with one of its platform digests, and the other way round, once the operator has seen the relation — which it learns when it resolves a tag or scans the image.
- Packages a package manager installed. Package entries other than
bitnamisee what the scanner's package inventory lists: OS packages and language packages with their manifests. A binary built from source and copied into an image is not in that inventory; the executable entries are for it. - After upgrading, each policy records one new revision, because
denyListis part of the policy content the ledger hashes.
Who can do what
Reading the list, its hits, its affected workloads and running What if need the same authority as reading a policy. Adding and removing an entry, taking a workload down and restoring it are administrator actions, and all but restore need a reason. The audit log files them as deny_list_add, deny_list_remove, deny_list_takedown, deny_list_restore and, for an administrator's check again, deny_list_check_again (the target is namespace/Kind/name); a what-if run is filed as policy_whatif, naming the run. The SIEM export sends a take down as denylist.takedown and a restore as denylist.restored.
GET /api/v1/deny-list[?removed=true]
POST /api/v1/deny-list {"kind":"package","ecosystem":"maven","package_name":"org.apache.logging.log4j:log4j-core","version_op":"lt","version":"2.17.1","reason":"…"}
POST /api/v1/deny-list/{id}/remove {"reason":"…"}
GET /api/v1/deny-list/{id}/hits[?namespace=&workload=]
GET /api/v1/deny-list/hits[?namespace=&workload=]
GET /api/v1/deny-list/ecosystems
POST /api/v1/deny-list/whatif {"entry_id":7,"period_days":30} or {"entry":{…},"period_days":30}
GET /api/v1/deny-list/{id}/affected
POST /api/v1/deny-list/{id}/takedown {"namespace":"prod","kind":"Deployment","name":"web","mechanism":"scale_zero","reason":"…","expected_replicas":3}
POST /api/v1/deny-list/{id}/restore {"takedown_id":4}
GET /api/v1/deny-list/{id}/checks
POST /api/v1/deny-list/{id}/checks/again {"digest":"sha256:…"}
GET /api/v1/deny-list/whatif/runs/{id}
A field the server refuses comes back as 400 with {"error":"<field>: …","field":"<field>"}; a second active entry with the same value is 409.