Attestkeep docs

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

KindMatchesExample
digestOne 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
cveEvery image whose last scan reports the advisory id, whether or not a fix exists.CVE-2021-44228, GHSA-jfh8-c2jp-5v3q, DLA-4726-1
packageEvery image whose last scan lists the package in the version range.maven org.apache.logging.log4j:log4j-core < 2.17.1
binaryEvery image carrying one executable, by the sha256 of its bytes, whatever its name or path.binary sha256 d697e81fb96e
binary_pathEvery 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 bitnamiEvery 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.

EcosystemPackage nameVersion format
apkthe package, or its originversion-rN, such as 3.3.2-r0
cargothe crateSemVer, such as 1.0.188
composervendor/packagea release version, such as 7.4.2
debthe binary package, or its source package[epoch:]upstream[-revision], such as 3.0.11-1~deb12u2
gothe module patha module version, such as v0.17.0
mavengroupId:artifactIda Maven version starting with a digit, such as 2.17.1
npmthe packageSemVer, such as 4.17.21
nugetthe package ida package version, such as 13.0.1
pypithe project (stored in its PEP 503 form, so PyYAML and pyyaml are one entry)PEP 440, such as 2.31.0 or 1.0rc1
rpmthe binary package, or its source package[epoch:]version-release starting with a digit
rubygemsthe gema 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.

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:

ValueWhat 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.
auditAdmits the pod with the warning would be denied by deny list entry …, and records the hit.
offThe 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

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:

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:

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:

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:

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

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.