Enforce your organization's container standards
- Advanced
- 35 min
- Published
- Verified
What you'll learn
- Write a
Dockerfile.regobuild policy that only allows base images fromdhi.iopinned by digest, and watch Buildx refuse a build before it runs - Test a build policy with
docker buildx policy test, and debug a denial with--policy log-level=debug - Tune Docker Scout's built-in policies with
--policy-config, and add your own Rego policy with--policy-dir - Use
docker scout policy --exit-codeas a pass or fail check on a built image
Your team agreed on a set of rules for container images. Base images come from Docker Hardened Images, pinned by digest. Nothing ships with a fixable critical or high vulnerability. Every image runs as a non-root user and records the repository it was built from. The rules are written down on a wiki page.
Then a pull request arrives with FROM node:22 in a new service’s Dockerfile.
It builds, the tests pass, and a reviewer approves it, because nobody reads the
wiki page while reviewing a diff. A rule that a person has to remember is a
rule that gets skipped.
In this guide, you turn those rules into checks that run at two points:
- Before the build, a Buildx build policy checks every build input: base images, downloads, and Git sources. A Dockerfile that breaks the rules never starts building.
- After the build, Docker Scout policies check the image you produced:
its packages, vulnerabilities, attestations, and configuration. An image that
breaks the rules fails the check, and
--exit-codeturns that failure into something a pipeline can act on.
The two layers check different things. A build policy sees what goes into a
build, so it can refuse a base image before a single instruction runs, but it
can’t see a vulnerable npm package that npm ci installs. Scout sees what came
out, so it catches that package, but only after you’ve spent the build.
Most teams want both.
What you’ll do
Starting from a small Node.js API whose Dockerfile already uses Docker Hardened Images, you’ll:
- Write a build policy that only allows base images from
dhi.io, pinned by digest, and watch it refuse the current Dockerfile. - Debug the denial, pin the digests, and build successfully.
- Write unit tests for the policy.
- Evaluate the built image with Docker Scout’s built-in policies, tuned to your standards.
- Add a custom Scout policy, watch the image fail it, fix the image, and pass.
The terminals in this guide are simulations. Click through them to see what each command prints, then run the same commands on your own machine.
Prerequisites
To follow along on your own machine, you need:
- Docker with Buildx 0.31.0 or later and BuildKit 0.27.0 or later, for build policies. See the build policies prerequisites.
- The Docker Scout CLI plugin. It’s included in Docker Desktop. See Install Docker Scout.
- A Docker account, signed in to
dhi.iowithdocker login dhi.ioto pull Docker Hardened Images, and to Docker Hub withdocker loginto push. See Use a Docker Hardened Image.
The examples push to myorg/my-app on Docker Hub. Replace myorg with your
own namespace.
Starting point
The project is a small Express API in ~/my-app:
my-app/
├── Dockerfile
├── index.js
├── package.json
└── package-lock.jsonIts Dockerfile is a multi-stage build on Docker Hardened Images. The -dev
variant has npm and a shell for installing dependencies. The runtime variant
has neither, and runs as a non-root user:
# syntax=docker/dockerfile:1
FROM dhi.io/node:22-alpine3.23-dev AS builder
WORKDIR /usr/src/app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
FROM dhi.io/node:22-alpine3.23
ENV PATH=/app/node_modules/.bin:$PATH
COPY --from=builder --chown=node:node /usr/src/app /app
WORKDIR /app
CMD ["node", "index.js"]If you’re not using Docker Hardened Images yet, see Build your application on a trusted foundation.
Write a build policy
Check that your Buildx version supports build policies:
~/my-app
docker buildx version
github.com/docker/buildx v0.38.0 0dbe87f36ed472fe36b6796c3f70b4210a731fc5
A build policy is a Rego file named after the Dockerfile it governs. Buildx
looks for Dockerfile.rego next to Dockerfile and loads it automatically on
every build. You don’t need a flag.
Create Dockerfile.rego in ~/my-app:
package docker
default allow := false
# The build context, including the Dockerfile itself
allow if input.local
# Docker's own build tooling: the Dockerfile frontend named by
# "# syntax=", and the SBOM scanner that --sbom=true runs
allow if {
input.image.fullRepo in {
"docker.io/docker/dockerfile",
"docker.io/docker/buildkit-syft-scanner",
}
}
# Base images: Docker Hardened Images, pinned by digest
allow if {
input.image.host == "dhi.io"
input.image.isCanonical
}
deny_msg contains msg if {
not allow
input.image
input.image.host != "dhi.io"
msg := sprintf("image %s is not from dhi.io; base images must be Docker Hardened Images", [input.image.ref])
}
deny_msg contains msg if {
not allow
input.image.host == "dhi.io"
not input.image.isCanonical
msg := sprintf("image %s must be pinned by digest (@sha256:...)", [input.image.ref])
}
decision := {"allow": allow, "deny_msg": deny_msg}Buildx evaluates the policy once for each build input, with that input’s
details in input. The policy has three parts:
default allow := falsemakes it deny by default. An input is allowed only if one of theallowrules matches it. Severalallowrules act as OR, and the conditions inside one rule act as AND.allow if input.locallets Buildx read your build context. Without it, Buildx can’t read the Dockerfile, so every build would fail.- The two image rules allow Docker’s build tooling, and allow images from
dhi.ioonly whenisCanonicalis true: the reference in the Dockerfile includes a digest. A tag can be moved to different content. A digest can’t.
The deny_msg rules don’t change the decision. They explain it, so the person
whose build fails knows what to change.
See the build refused
Build the image:
~/my-app
docker build -t myorg/my-app:1.1 .
[+] Building 0.5s (5/5) FINISHED docker:desktop-linux => ERROR loading policies Dockerfile.rego 0.5s => [internal] load build definition from Dockerfile 0.0s => => transferring dockerfile: 345B 0.0s => resolve image config for docker-image://docker.io/docker/dockerfile:1 0.4s => [auth] docker/dockerfile:pull token for registry-1.docker.io 0.0s => CACHED docker-image://docker.io/docker/dockerfile:1@sha256:4edf897a3f 0.0s => => resolve docker.io/docker/dockerfile:1@sha256:4edf897a3ffa55b89f906 0.0s ------ > loading policies Dockerfile.rego: 0.000 checking policy for source local://dockerfile 0.001 policy decision for source local://dockerfile: ALLOW 0.003 checking policy for source docker-image://docker.io/docker/dockerfile:1 0.005 policy decision for source docker-image://docker.io/docker/dockerfile:1: ALLOW 0.446 checking policy for source docker-image://docker.io/docker/dockerfile:1@sha256:4edf897a3ffa55b89f906fc8cc78afdb3f1834cc9c7083565e611a8a7d5fe99e (linux/arm64) 0.448 policy decision for source docker-image://docker.io/docker/dockerfile:1@sha256:4edf897a3ffa55b89f906fc8cc78afdb3f1834cc9c7083565e611a8a7d5fe99e (linux/arm64): ALLOW 0.486 checking policy for source local://dockerfile 0.487 policy decision for source local://dockerfile: ALLOW 0.489 checking policy for source docker-image://dhi.io/node:22-alpine3.23-dev (linux/arm64) 0.489 checking policy for source docker-image://dhi.io/node:22-alpine3.23 (linux/arm64) 0.492 policy decision for source docker-image://dhi.io/node:22-alpine3.23-dev (linux/arm64): DENY 0.492 - image dhi.io/node:22-alpine3.23-dev must be pinned by digest (@sha256:...) 0.492 policy decision for source docker-image://dhi.io/node:22-alpine3.23 (linux/arm64): DENY 0.492 - image dhi.io/node:22-alpine3.23 must be pinned by digest (@sha256:...) ------ Policy: image dhi.io/node:22-alpine3.23-dev must be pinned by digest (@sha256:...) Dockerfile:3 -------------------- 1 | # syntax=docker/dockerfile:1 2 | 3 | >>> FROM dhi.io/node:22-alpine3.23-dev AS builder 4 | WORKDIR /usr/src/app 5 | COPY package*.json ./ -------------------- ERROR: failed to build: failed to solve: could not resolve image due to policy: source "docker-image://dhi.io/node:22-alpine3.23-dev" not allowed by policy: action DENY
This failure is expected. The base images come from the right registry, but by
tag, so the policy refuses them. The log under the summary shows every source
the policy checked: the build context and the Dockerfile frontend are allowed,
and both FROM images are denied with your deny_msg. Buildx stops before
running npm ci or any other instruction, so a noncompliant Dockerfile costs
seconds, not a full build.
Debug the denial
When a denial isn’t what you expected, ask Buildx to show what the policy saw.
--progress=plain prints each policy decision, and --policy log-level=debug
adds the full input for every source. The output below is the policy step,
#1, only. The other build steps and the error summary that follow it are
left out:
~/my-app
docker buildx build --progress=plain --policy log-level=debug .
#1 loading policies Dockerfile.rego
#1 0.000 policy input: {
#1 0.000 "env": {
#1 0.000 "filename": "Dockerfile",
#1 0.000 "capsRequest": true,
#1 0.000 "depth": 0
#1 0.000 }
#1 0.000 }
#1 0.003 checking policy for source local://dockerfile
#1 0.003 policy input: {
#1 0.003 "env": {
#1 0.003 "filename": "Dockerfile",
#1 0.003 "depth": 0
#1 0.003 },
#1 0.003 "local": {
#1 0.003 "name": "dockerfile"
#1 0.003 }
#1 0.003 }
#1 0.004 policy response: &{Allow:0x462758c7866a DenyMessages:[] Caps:map[]}
#1 0.004 policy decision for source local://dockerfile: ALLOW
#1 0.007 checking policy for source docker-image://docker.io/docker/dockerfile:1
#1 0.007 policy input: {
#1 0.007 "env": {
#1 0.007 "filename": "Dockerfile",
#1 0.007 "depth": 0
#1 0.007 },
#1 0.007 "image": {
#1 0.007 "ref": "docker.io/docker/dockerfile:1",
#1 0.007 "host": "docker.io",
#1 0.007 "repo": "docker/dockerfile",
#1 0.007 "fullRepo": "docker.io/docker/dockerfile",
#1 0.007 "tag": "1",
#1 0.007 "platform": "linux/arm64",
#1 0.007 "os": "linux",
#1 0.007 "arch": "arm64"
#1 0.007 }
#1 0.007 }
#1 0.007 unknowns for policy evaluation: [image.checksum image.labels image.user image.volumes image.workingDir image.env image.hasProvenance image.provenance image.signatures]
#1 0.009 policy response: &{Allow:0x462758d901c8 DenyMessages:[] Caps:map[]}
#1 0.009 policy decision for source docker-image://docker.io/docker/dockerfile:1: ALLOW
#1 0.119 checking policy for source docker-image://docker.io/docker/dockerfile:1@sha256:4edf897a3ffa55b89f906fc8cc78afdb3f1834cc9c7083565e611a8a7d5fe99e (linux/arm64)
#1 0.119 policy input: {
#1 0.119 "env": {
#1 0.119 "filename": "Dockerfile",
#1 0.119 "depth": 0
#1 0.119 },
#1 0.119 "image": {
#1 0.119 "ref": "docker.io/docker/dockerfile:1@sha256:4edf897a3ffa55b89f906fc8cc78afdb3f1834cc9c7083565e611a8a7d5fe99e",
#1 0.119 "host": "docker.io",
#1 0.119 "repo": "docker/dockerfile",
#1 0.119 "fullRepo": "docker.io/docker/dockerfile",
#1 0.119 "tag": "1",
#1 0.119 "platform": "linux/arm64",
#1 0.119 "os": "linux",
#1 0.119 "arch": "arm64",
#1 0.119 "isCanonical": true,
#1 0.119 "checksum": "sha256:4edf897a3ffa55b89f906fc8cc78afdb3f1834cc9c7083565e611a8a7d5fe99e"
#1 0.119 }
#1 0.119 }
#1 0.119 unknowns for policy evaluation: [image.labels image.user image.volumes image.workingDir image.env image.hasProvenance image.provenance image.signatures]
#1 0.120 policy response: &{Allow:0x4627591c1a38 DenyMessages:[] Caps:map[]}
#1 0.120 policy decision for source docker-image://docker.io/docker/dockerfile:1@sha256:4edf897a3ffa55b89f906fc8cc78afdb3f1834cc9c7083565e611a8a7d5fe99e (linux/arm64): ALLOW
#1 0.158 checking policy for source local://dockerfile
#1 0.158 policy input: {
#1 0.158 "env": {
#1 0.158 "filename": "Dockerfile",
#1 0.158 "depth": 0
#1 0.158 },
#1 0.158 "local": {
#1 0.158 "name": "dockerfile"
#1 0.158 }
#1 0.158 }
#1 0.159 policy response: &{Allow:0x462758cc569a DenyMessages:[] Caps:map[]}
#1 0.159 policy decision for source local://dockerfile: ALLOW
#1 0.161 checking policy for source docker-image://dhi.io/node:22-alpine3.23-dev (linux/arm64)
#1 0.161 checking policy for source docker-image://dhi.io/node:22-alpine3.23 (linux/arm64)
#1 0.161 policy input: {
#1 0.161 "env": {
#1 0.161 "filename": "Dockerfile",
#1 0.161 "depth": 0
#1 0.161 },
#1 0.161 "image": {
#1 0.161 "ref": "dhi.io/node:22-alpine3.23",
#1 0.161 "host": "dhi.io",
#1 0.161 "repo": "dhi.io/node",
#1 0.161 "fullRepo": "dhi.io/node",
#1 0.161 "tag": "22-alpine3.23",
#1 0.161 "platform": "linux/arm64",
#1 0.161 "os": "linux",
#1 0.161 "arch": "arm64"
#1 0.161 }
#1 0.161 }
#1 0.161 unknowns for policy evaluation: [image.checksum image.labels image.user image.volumes image.workingDir image.env image.hasProvenance image.provenance image.signatures]
#1 0.162 policy input: {
#1 0.162 "env": {
#1 0.162 "filename": "Dockerfile",
#1 0.162 "depth": 0
#1 0.162 },
#1 0.162 "image": {
#1 0.162 "ref": "dhi.io/node:22-alpine3.23-dev",
#1 0.162 "host": "dhi.io",
#1 0.162 "repo": "dhi.io/node",
#1 0.162 "fullRepo": "dhi.io/node",
#1 0.162 "tag": "22-alpine3.23-dev",
#1 0.162 "platform": "linux/arm64",
#1 0.162 "os": "linux",
#1 0.162 "arch": "arm64"
#1 0.162 }
#1 0.162 }
#1 0.162 unknowns for policy evaluation: [image.checksum image.labels image.user image.volumes image.workingDir image.env image.hasProvenance image.provenance image.signatures]
#1 0.164 policy response: &{Allow:0x462759060cc0 DenyMessages:[image dhi.io/node:22-alpine3.23-dev must be pinned by digest (@sha256:...)] Caps:map[]}
#1 0.164 policy decision for source docker-image://dhi.io/node:22-alpine3.23-dev (linux/arm64): DENY
#1 0.164 - image dhi.io/node:22-alpine3.23-dev must be pinned by digest (@sha256:...)
#1 0.164 policy response: &{Allow:0x462758d90bf0 DenyMessages:[image dhi.io/node:22-alpine3.23 must be pinned by digest (@sha256:...)] Caps:map[]}
#1 0.164 policy decision for source docker-image://dhi.io/node:22-alpine3.23 (linux/arm64): DENY
#1 0.164 - image dhi.io/node:22-alpine3.23 must be pinned by digest (@sha256:...)
#1 ERROR: failed to solve: could not resolve image due to policy: source "docker-image://dhi.io/node:22-alpine3.23" not allowed by policy: action DENYTwo things to read in this output:
- The Dockerfile frontend is an input too. The
# syntax=line makes BuildKit pulldocker/dockerfile:1, and Buildx checks it against your policy like any other image. That’s why the policy allows Docker’s build tooling. A strict registry allowlist without that rule fails on the first line of the Dockerfile. refhas a tag and no digest. That’s the fieldisCanonicalis derived from, so it’s missing from thedhi.ioinputs and present on the frontend once Buildx has resolved it by digest. Theunknownsline lists fields Buildx hasn’t fetched yet, such as the image’s labels and signatures. It fetches them only when a rule needs them.
To check one source without a whole build, use docker buildx policy eval. It
prints the denial message when the policy refuses the source, and nothing when
it allows it:
~/my-app
docker buildx policy eval docker-image://node:22
ERROR: policy denied: image docker.io/library/node:22 is not from dhi.io; base images must be Docker Hardened Images
docker buildx policy eval docker-image://dhi.io/node:22-alpine3.23@sha256:920a7460ab7ac19ce6a9a38fb23b244bcfed1cf78705f7fe117195c58b307f4d
Pin the base images and build
Find the digest each tag points to now:
~/my-app
docker buildx imagetools inspect dhi.io/node:22-alpine3.23-dev --format "{{.Manifest}}"Name: dhi.io/node:22-alpine3.23-dev
MediaType: application/vnd.oci.image.index.v1+json
Digest: sha256:6721841fb09d27f6f644a5524dc75bc0e5d9114787eaa567df4a50e1dc639eec
Manifests:
Name: dhi.io/node:22-alpine3.23-dev@sha256:bc9d441c70e69a604ef734660a159d419b62d1da167b61a9616de000c371938a
MediaType: application/vnd.oci.image.manifest.v1+json
Platform: linux/amd64
Annotations:
com.docker.dhi.build.id: builder-c6540a56-d341-4456-8672-b621b4323def/linux-amd64/ifwb4kc850t237mqsmgfbs039
Name: dhi.io/node:22-alpine3.23-dev@sha256:bd52c45262ab1a61089b76b6f3266d81d46aa8f30f51d81536eb33e9d3be6861
MediaType: application/vnd.oci.image.manifest.v1+json
Platform: linux/arm64
Annotations:
com.docker.dhi.build.id: builder-a5cb8d2c-fb42-4746-ae09-99a8174bd8ea/linux-arm64/bir800rq7b6dqeuhph11zlulzdocker buildx imagetools inspect dhi.io/node:22-alpine3.23 --format "{{.Manifest}}"Name: dhi.io/node:22-alpine3.23
MediaType: application/vnd.oci.image.index.v1+json
Digest: sha256:920a7460ab7ac19ce6a9a38fb23b244bcfed1cf78705f7fe117195c58b307f4d
Manifests:
Name: dhi.io/node:22-alpine3.23@sha256:9e2168660d606eeaf1a9b6f2f1fd1a22dca8325ffc28c830de868ef5ea9a6784
MediaType: application/vnd.oci.image.manifest.v1+json
Platform: linux/arm64
Annotations:
com.docker.dhi.build.id: builder-45929e12-b78a-4b28-9f5f-eb89b2002d61/linux-arm64/vp54th8sa7zk55o9uunwq5rtb
Name: dhi.io/node:22-alpine3.23@sha256:a4b1a07ca79f72955577d2e3fb88c0a3ed8107e74660e7830cc115f3557137f7
MediaType: application/vnd.oci.image.manifest.v1+json
Platform: linux/amd64
Annotations:
com.docker.dhi.build.id: builder-d99bddca-86d8-4d26-9cbb-feb22f6cf7f3/linux-amd64/wus9hma0dwojhx3pmra43sibmUse the top-level Digest, the image index. It covers every platform, so the
same Dockerfile builds on an Intel CI runner and an Apple silicon laptop.
Update both FROM lines. Keeping the tag in front of the digest is allowed,
and it tells the next reader which version the digest is:
# syntax=docker/dockerfile:1
FROM dhi.io/node:22-alpine3.23-dev@sha256:6721841fb09d27f6f644a5524dc75bc0e5d9114787eaa567df4a50e1dc639eec AS builder
WORKDIR /usr/src/app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
FROM dhi.io/node:22-alpine3.23@sha256:920a7460ab7ac19ce6a9a38fb23b244bcfed1cf78705f7fe117195c58b307f4d
ENV PATH=/app/node_modules/.bin:$PATH
COPY --from=builder --chown=node:node /usr/src/app /app
WORKDIR /app
CMD ["node", "index.js"]Build again. This time, build with an SBOM and provenance attestations and push the result, because the Scout policies later in this guide check for them:
~/my-app
docker buildx build --sbom=true --provenance=mode=max --push -t myorg/my-app:1.1 .
[+] Building 3.4s (21/21) FINISHED docker:desktop-linux => loading policies Dockerfile.rego 0.9s => [internal] load build definition from Dockerfile 0.0s => resolve image config for docker-image://docker.io/docker/dockerfile:1 0.1s => resolve image config for docker-image://docker.io/docker/buildkit-syf 0.2s => [internal] load metadata for dhi.io/node:22-alpine3.23-dev@sha256:672 0.0s => [internal] load metadata for dhi.io/node:22-alpine3.23@sha256:920a746 0.0s => [builder 1/5] FROM dhi.io/node:22-alpine3.23-dev@sha256:6721841fb09d2 0.0s => [builder 3/5] COPY package*.json ./ 0.0s => [builder 4/5] RUN npm ci --omit=dev 0.8s => [builder 5/5] COPY . . 0.0s => [stage-1 2/3] COPY --from=builder --chown=node:node /usr/src/app /app 0.0s => [linux/arm64] generating sbom using docker.io/docker/buildkit-syft-sc 1.9s => exporting to image 0.1s => => exporting manifest list sha256:eccd859cd89da042eef9e23205321b24193 0.0s => => naming to docker.io/myorg/my-app:1.1 0.0s => => pushing layers 0.0s => => pushing manifest for docker.io/myorg/my-app:1.1@sha256:eccd859cd89 0.0s
The same policy ran again, in the loading policies step, and every input
passed: the context, the frontend, the SBOM scanner, and both pinned base
images.
Test the policy
A policy is code that decides whether your team can ship, so test it like
code. docker buildx policy test runs OPA-style unit tests against mocked
inputs, without building or contacting a registry.
Create Dockerfile_test.rego next to the policy. Test files end in
_test.rego, use the same package docker, and each test’s name starts with
test_:
package docker
test_local_context_allowed if {
decision.allow with input as {"local": {"name": "."}}
}
test_pinned_dhi_image_allowed if {
decision.allow with input as {"image": {
"ref": "dhi.io/node:22-alpine3.23@sha256:920a7460ab7ac19ce6a9a38fb23b244bcfed1cf78705f7fe117195c58b307f4d",
"host": "dhi.io",
"fullRepo": "dhi.io/node",
"isCanonical": true,
}}
}
test_unpinned_dhi_image_denied if {
result := decision with input as {"image": {
"ref": "dhi.io/node:22-alpine3.23",
"host": "dhi.io",
"fullRepo": "dhi.io/node",
"isCanonical": false,
}}
not result.allow
"image dhi.io/node:22-alpine3.23 must be pinned by digest (@sha256:...)" in result.deny_msg
}
test_docker_hub_image_denied if {
not decision.allow with input as {"image": {
"ref": "docker.io/library/node:22",
"host": "docker.io",
"fullRepo": "docker.io/library/node",
"isCanonical": false,
}}
}Each test overrides input with with input as, then asserts on the
decision. The third test checks the message as well as the decision, so a
change that breaks the explanation fails too. Run the tests. They’re reported
in alphabetical order:
~/my-app
docker buildx policy test .
test_docker_hub_image_denied: PASS (allow=false, deny_msg=image docker.io/library/node:22 is not from dhi.io; base images must be Docker Hardened Images) test_local_context_allowed: PASS (allow=true) test_pinned_dhi_image_allowed: PASS (allow=true) test_unpinned_dhi_image_denied: PASS (allow=false, deny_msg=image dhi.io/node:22-alpine3.23 must be pinned by digest (@sha256:...))
When a test fails, the output shows the input, the full decision, and any
input fields the policy needed but the test didn’t provide. To build test
inputs from real sources, use docker buildx policy eval --print. See
Test build policies.
What a build policy doesn’t do
A build policy runs on the machine doing the build, from a file in the
repository. Anyone who can edit the repository can edit or delete
Dockerfile.rego, and anyone building locally can skip it with
--policy disabled=true. It’s a guardrail for the people following the rules,
not a control against the people who aren’t.
It becomes a control when the only builds that can ship are the ones your CI
runs, and CI names the policy file instead of relying on automatic loading:
--policy filename=Dockerfile.rego,strict=true fails the build if the file is
missing or the builder can’t evaluate policies. --policy strict=true on its
own isn’t enough: with no Dockerfile.rego next to the Dockerfile, the build
runs without a policy. Building releases only in CI is covered in
Build a trusted container delivery pipeline.
Check the image with Scout policies
The build policy can’t see what npm ci installed, which user the image runs
as, or whether its attestations were attached. Docker Scout can.
docker scout policy indexes the image into an SBOM, adds vulnerability and
VEX data, and evaluates each policy in the CLI. No data is sent to the Scout
service. With no other options, it uses a set of built-in policies.
Tune those built-in policies to match your standards with a policy
configuration file. Create policies.json in ~/my-app:
{
"policies": [
{
"name": "approved-base-images",
"config": {
"allowed_base_images": ["dhi.io/*"]
}
},
{
"name": "fixable-vulnerabilities",
"config": {
"severities": ["CRITICAL", "HIGH"],
"grace_period_days": 7
}
},
{
"name": "copyleft-license",
"enabled": false
}
]
}Each entry names a policy by its stable ID. The first restricts No
unapproved base images to dhi.io, which checks the same rule as your build
policy from the other side: it reads the base image from the provenance
attestation. The second keeps the default severities for No fixable critical
or high vulnerabilities, and gives your team seven days to fix a newly
disclosed CVE before the policy fails. The third turns off No copyleft
licenses: Alpine packages in the hardened base image, such as libgcc,
libstdc++, and alpine-baselayout-data, carry GPL, LGPL, or MPL licenses,
so every image on this base fails it. Policies you don’t list run with their
defaults. The
configuration reference
lists every key.
Evaluate the image you pushed:
~/my-app
docker scout policy myorg/my-app:1.1 --policy-config policies.json
! 'docker scout policy' is experimental and its behaviour might change in the future
✓ Image stored for indexing
✓ SBOM obtained from attestation, 76 packages found
✓ Provenance obtained from attestation
✓ Pulled
✓ Policy evaluation completed
## Overview
│ Analyzed Image
────────────┼────────────────────
Target │ myorg/my-app:1.1
digest │ 78707cd79c2b
platform │ linux/arm64
## Policies
Policy status SUCCESS (6/6 policies met)
Health score A (100%)
Status │ Policy │ Results
────────┼─────────────────────────────────────────────┼─────────────────────────────
✓ │ Default non-root user │
✓ │ No fixable critical or high vulnerabilities │ 0C 0H 0M 0L
✓ │ No high-profile vulnerabilities │ 0C 0H 0M 0L
✓ │ No outdated base images │
✓ │ No unapproved base images │ 0 deviations
✓ │ Supply chain attestations │ 0 deviations
What's next:
Compare with the latest in the registry → docker scout compare --to-latest myorg/my-app:1.1Every policy that’s enabled passes: six of the seven built-in policies, with
No copyleft licenses turned off. The base image is current and from
dhi.io, the DHI runtime runs as a non-root user, and the SBOM and provenance
attestations from the build are attached. Without the copyleft-license
entry, the same image reports Copyleft licensed packages found for 7
packages, and the status is FAILED (6/7 policies met). The health score is a
weighted share of passed policies. The exact layout of this report varies
between Scout CLI versions.
Your results may differ. If No fixable critical or high vulnerabilities fails, see Find and fix vulnerabilities before you ship. If a reported CVE doesn’t affect your application, see Document vulnerabilities that don’t affect your application.
Write a custom Scout policy
The built-in policies don’t cover the last rule from the wiki page: every image records the repository it was built from, so whoever is on call can find the source of a running image. You can write that rule yourself.
A Scout policy is a Rego module in the docker.scout package. It defines a
boolean pass and a set of violation objects, and a # METADATA block tells
the CLI how to show the result. Create a policies directory with one file:
# METADATA
# title: Source repository label
# description: Images must name the myorg repository they were built from.
# custom:
# name: source-label
# result_type: generic
# weight: 10
# not_compliant_title: Missing source repository label
# details_order:
# - label
# - reason
package docker.scout
import rego.v1
default pass := false
pass if {
count(violation) == 0
}
source := object.get(input, ["source", "image", "config", "config", "Labels", "org.opencontainers.image.source"], "")
violation contains v if {
not startswith(source, "https://github.com/myorg/")
v := {
"message": "Image does not name the myorg repository it was built from",
"detail": {
"label": "org.opencontainers.image.source",
"reason": "missing, or not a github.com/myorg repository",
},
"remediation": "Add LABEL org.opencontainers.image.source=\"https://github.com/myorg/<repo>\" to the final stage",
}
}The input is the enriched SBOM, and input.source.image.config holds the
image’s configuration, including its labels. object.get returns an empty
string when the label is missing, so a missing label and a wrong one both
produce a violation. custom.name is the stable ID you’d use to configure the
policy in policies.json, not_compliant_title is the row title when the
policy fails, and details_order sets the columns of the violation table.
Evaluate the image against it. Add --exit-code so the command reports the
result the way a pipeline reads it, and print the exit code afterwards:
~/my-app
docker scout policy myorg/my-app:1.1 --policy-dir ./policies --exit-code
! 'docker scout policy' is experimental and its behaviour might change in the future
✓ SBOM of image already cached, 76 packages indexed
✓ Policy evaluation completed
## Overview
│ Analyzed Image
────────────┼────────────────────
Target │ myorg/my-app:1.1
digest │ 78707cd79c2b
platform │ linux/arm64
## Policies
Policy status FAILED (0/1 policies met)
Health score F (0%)
Status │ Policy │ Results
────────┼─────────────────────────────────┼────────────────
! │ Missing source repository label │ 1 deviation
## "Source repository label" policy evaluation results
Images must name the myorg repository they were built from.
Label │ Reason
─────────────────────────────────┼───────────────────────────────────────────────
org.opencontainers.image.source │ missing, or not a github.com/myorg repository
What's next:
View vulnerabilities → docker scout cves myorg/my-app:1.1
View base image update recommendations → docker scout recommendations myorg/my-app:1.1
Compare with the latest in the registry → docker scout compare --to-latest myorg/my-app:1.1echo $?
2
The failure is expected: nothing in the Dockerfile sets that label. The report
shows each violation’s detail, not its remediation. With
--exit-code, an unmet policy returns 2 instead of 0, so any CI system can
stop on it.
Notice that only one policy ran. When you pass --policy-file or
--policy-dir, the built-in policies aren’t loaded. You’ll combine them
at the end of this section.
Debug a custom policy
When a custom policy fails and you can’t tell why, write the raw evaluation result to a file:
~/my-app
docker scout policy myorg/my-app:1.1 --policy-dir ./policies --result-file result.json
! 'docker scout policy' is experimental and its behaviour might change in the future
✓ SBOM of image already cached, 76 packages indexed
✓ Policy evaluation completed
i Wrote policy evaluation results to result.json
## Overview
│ Analyzed Image
────────────┼────────────────────
Target │ myorg/my-app:1.1
digest │ 78707cd79c2b
platform │ linux/arm64
## Policies
Policy status FAILED (0/1 policies met)
Health score F (0%)
Status │ Policy │ Results
────────┼─────────────────────────────────┼────────────────
! │ Missing source repository label │ 1 deviation
## "Source repository label" policy evaluation results
Images must name the myorg repository they were built from.
Label │ Reason
─────────────────────────────────┼───────────────────────────────────────────────
org.opencontainers.image.source │ missing, or not a github.com/myorg repository
What's next:
View vulnerabilities → docker scout cves myorg/my-app:1.1
View base image update recommendations → docker scout recommendations myorg/my-app:1.1
Compare with the latest in the registry → docker scout compare --to-latest myorg/my-app:1.1cat result.json
{
"source-label": {
"pass": false,
"violations": [
{
"label": "org.opencontainers.image.source",
"reason": "missing, or not a github.com/myorg repository"
}
],
"bindings": {
"source": "",
"violation": [
{
"detail": {
"label": "org.opencontainers.image.source",
"reason": "missing, or not a github.com/myorg repository"
},
"message": "Image does not name the myorg repository it was built from",
"remediation": "Add LABEL org.opencontainers.image.source=\"https://github.com/myorg/\u003crepo\u003e\" to the final stage"
}
]
},
"metrics": {
"timer_rego_external_resolve_ns": 83,
"timer_rego_input_parse_ns": 1291000,
"timer_rego_query_compile_ns": 33875,
"timer_rego_query_eval_ns": 51250,
"timer_rego_query_parse_ns": 15167
}
}
}bindings holds every rule the policy computed, not only the result. Here,
source is an empty string, so the label really is missing, and the policy
logic isn’t at fault. For more detail, add print() calls to the rules and set
"debug": true in policies.json. See
Debug policies.
Fix the image and pass
Add the label to the final stage of the Dockerfile:
# syntax=docker/dockerfile:1
FROM dhi.io/node:22-alpine3.23-dev@sha256:6721841fb09d27f6f644a5524dc75bc0e5d9114787eaa567df4a50e1dc639eec AS builder
WORKDIR /usr/src/app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
FROM dhi.io/node:22-alpine3.23@sha256:920a7460ab7ac19ce6a9a38fb23b244bcfed1cf78705f7fe117195c58b307f4d
LABEL org.opencontainers.image.source="https://github.com/myorg/my-app"
ENV PATH=/app/node_modules/.bin:$PATH
COPY --from=builder --chown=node:node /usr/src/app /app
WORKDIR /app
CMD ["node", "index.js"]Build and push a new version, then evaluate it again:
~/my-app
docker buildx build --sbom=true --provenance=mode=max --push -t myorg/my-app:1.2 .
[+] Building 2.6s (20/20) FINISHED docker:desktop-linux => loading policies Dockerfile.rego 0.8s => [internal] load build definition from Dockerfile 0.0s => CACHED [builder 4/5] RUN npm ci --omit=dev 0.0s => [builder 5/5] COPY . . 0.0s => [stage-1 2/3] COPY --from=builder --chown=node:node /usr/src/app /app 0.0s => [linux/arm64] generating sbom using docker.io/docker/buildkit-syft-sc 2.0s => exporting to image 0.1s => => exporting manifest list sha256:4cdf2eb0da5cdd2a40500bb451981369bae 0.0s => => naming to docker.io/myorg/my-app:1.2 0.0s => => pushing manifest for docker.io/myorg/my-app:1.2@sha256:4cdf2eb0da5 0.0s
docker scout policy myorg/my-app:1.2 --policy-dir ./policies --exit-code
! 'docker scout policy' is experimental and its behaviour might change in the future
✓ Image stored for indexing
✓ SBOM obtained from attestation, 76 packages found
✓ Provenance obtained from attestation
✓ Pulled
✓ Policy evaluation completed
## Overview
│ Analyzed Image
────────────┼────────────────────
Target │ myorg/my-app:1.2
digest │ e499f7880fe4
platform │ linux/arm64
## Policies
Policy status SUCCESS (1/1 policies met)
Health score A (100%)
Status │ Policy │ Results
────────┼─────────────────────────┼─────────────────
✓ │ Source repository label │ 0 deviations
What's next:
Compare with the latest in the registry → docker scout compare --to-latest myorg/my-app:1.2echo $?
0
The policy passes, and the command exits 0. LABEL doesn’t add a build
step, so the build output has no line for it.
Run built-in and custom policies together
Your standards are the built-in policies and your custom one, so the check that gates a release needs both. Because local policy files replace the built-in set, publish the built-in set to a registry as an OCI policy bundle first:
~/my-app
docker scout policy publish myorg/scout-policies:1.0
✓ Published policy bundle myorg/scout-policies:1.0
digest: sha256:9ccb14e89988b3e8e087b1014ba46446b1815197f349008247c066585cfa9edd
policies: 7
- approved-base-images.rego
- copyleft-license.rego
- default-non-root-user.rego
- fixable-vulnerabilities.rego
- high-profile-vulnerabilities.rego
- no-stale-base-images.rego
- supply-chain-attestations.regodocker scout policy myorg/my-app:1.2 --policy-bundle myorg/scout-policies:1.0 --policy-dir ./policies --policy-config policies.json --exit-code
! 'docker scout policy' is experimental and its behaviour might change in the future
✓ SBOM of image already cached, 76 packages indexed
✓ Policy evaluation completed
## Overview
│ Analyzed Image
────────────┼────────────────────
Target │ myorg/my-app:1.2
digest │ e499f7880fe4
platform │ linux/arm64
## Policies
Policy status SUCCESS (7/7 policies met)
Health score A (100%)
Status │ Policy │ Results
────────┼─────────────────────────────────────────────┼─────────────────────────────
✓ │ Default non-root user │
✓ │ No fixable critical or high vulnerabilities │ 0C 0H 0M 0L
✓ │ No high-profile vulnerabilities │ 0C 0H 0M 0L
✓ │ No outdated base images │
✓ │ No unapproved base images │ 0 deviations
✓ │ Source repository label │ 0 deviations
✓ │ Supply chain attestations │ 0 deviations
What's next:
Compare with the latest in the registry → docker scout compare --to-latest myorg/my-app:1.2That last command is the whole standard in one check: the bundle, your custom policy, and your configuration, which turns off No copyleft licenses in the bundle as it did for the built-in set. A bundle also gives every repository in your organization the same version of the rules. Publish a new tag when the rules change, and each team adopts it by changing one reference.
Check yourself
- A Dockerfile starts with
FROM node:22. Both checks catch it: the build policy before the build, and No unapproved base images afterwards. - An npm package with a fixable critical CVE. Only the Scout policies. The
build policy doesn’t see packages installed by
npm ci. - A pinned base image digest that has fallen behind its tag. Only the Scout policies, through No outdated base images. The digest still satisfies the build policy.
- A laptop build with policies disabled, pushed and deployed outside CI. Neither check in this guide stops it. A cluster admission policy that requires your CI workflow’s signature does.
What you built
- A build policy,
Dockerfile.rego, that Buildx loads automatically and evaluates against every build input before the build starts. It allows the build context, Docker’s build tooling, and Docker Hardened Images pinned by digest, and explains every denial. - Unit tests for that policy, run with
docker buildx policy test. - A Scout policy configuration,
policies.json, that tunes the built-in policies to your standards. - A custom Scout policy in
policies/, and a singledocker scout policy … --exit-codecommand that evaluates the built-in bundle, your policy, and your configuration together.
Both layers check rules. Neither proves anything to someone downstream: a
registry that receives myorg/my-app:1.2 can’t tell whether these checks ran,
or whether the image it holds is the one that passed them. That takes a
signature, tied to the image’s digest.
Next steps
Continue with Prove where your container image came from, which signs an image by digest with Cosign and shows verification failing on an image that wasn’t signed.
To run these checks on every push, and gate the release on
docker scout policy --exit-code, see
Build a trusted container delivery pipeline.
Part of a learning path
This works on its own and is also included in the paths below. Open a path to see the complete route.
Learning path
Secure your software supply chain
Step 4 of 7
- Step 1: Build your application on a trusted foundation
- Step 2: Find and fix vulnerabilities before you ship
- Step 3: Document vulnerabilities that don't affect your application
- Step 4: Enforce your organization's container standards (this page)
- Step 5: Prove where your container image came from
- Step 6: Build a trusted container delivery pipeline
- Step 7: Only run trusted images in production
Table of contents