CONTAINERS-POLICY.JSON(5)

okay bestie, welcome to containers-policy.json(5) β€” Miloslav Trmač, September 2016. This is the manual for the signature verification policy file, and we’re gonna get into it. πŸ’…

πŸŽ€ NAME πŸŽ€

containers-policy.json β€” syntax for the signature verification policy file. That’s the whole vibe: it’s the file that says what counts as trusted. ✨

πŸ’– DESCRIPTION πŸ’–

Signature verification policy files are used to specify policy, e.g. trusted keys, applicable when deciding whether to accept an image, or individual signatures of that image, as valid.

By default, the policy is read from $XDG_CONFIG_HOME/containers/policy.json (or from $HOME/.config/containers/policy.json if $XDG_CONFIG_HOME is unset), if it exists; otherwise from /etc/containers/policy.json; otherwise from /usr/share/containers/policy.json. Applications performing verification may allow using a different policy instead.

If CONTAINERS_POLICY_JSON is set, it specifies the policy file to use, unless overridden by application-specific configuration. environment variable said β€œuse this one,” bestie.

✨ FORMAT ✨

The signature verification policy file, usually called policy.json, uses a JSON format. Unlike some other JSON files, its parsing is fairly strict: unrecognized, duplicated or otherwise invalid fields cause the entire file, and usually the entire operation, to be rejected. no β€œeh, close enough” energy here β€” strict strict. 😭

The purpose of the policy file is to define a set of policy requirements for a container image, usually depending on its location (where it is being pulled from) or otherwise defined identity.

Policy requirements can be defined for:

  • An individual scope in a transport. The transport values are the same as the transport prefixes when pushing/pulling images (e.g. docker:, atomic:), and scope values are defined by each transport; see below for more details.

Usually, a scope can be defined to match a single image, and various prefixes of such a most specific scope define namespaces of matching images.

  • A default policy for a single transport, expressed using an empty string as a scope
  • A global default policy.

If multiple policy requirements match a given image, only the requirements from the most specific match apply, the more general policy requirements definitions are ignored. most specific match is the main character, periodt.

This is expressed in JSON using the top-level syntax

{
    "default": [/* policy requirements: global default */]
    "transports": {
        transport_name: {
            "": [/* policy requirements: default for transport $transport_name */],
            scope_1: [/* policy requirements: default for $scope_1 in $transport_name */],
            scope_2: [/*…*/]
            /*…*/
        },
        transport_name_2: {/*…*/}
        /*…*/
    }
}

The global default set of policy requirements is mandatory; all of the other fields (transports itself, any specific transport, the transport-specific default, etc.) are optional.

🚚 Supported transports and their scopes 🚚

See containers-transports(5) for general documentation about the transports and their reference syntax. 🫢

atomic:

The deprecated atomic: transport refers to images in an Atomic Registry. yes, deprecated β€” note it and move on, bestie.

Supported scopes use the form hostname[:port][/namespace[/imagestream[:tag]]], i.e. either specifying a complete name of a tagged image, or prefix denoting a host/namespace/image stream, or a wildcarded expression starting with *. for matching all subdomains. For wildcarded subdomain matching, *.example.com is a valid case, but example*.*.com is not.

Note: The hostname and port refer to the container registry host and port (the one used e.g. for docker pull), not to the OpenShift API host and port.

containers-storage:

Supported scopes have the form [storage-specifier]image-scope.

[storage-specifier] is usually [graph-driver-name@graph-root], e.g. [overlay@/var/lib/containers/storage].

image-scope matching the individual image is:

  • a named Docker reference in the fully expanded form, either using a tag or digest. For example, docker.io/library/busybox:latest (not busybox:latest)
  • and/or (depending on which one the user’s input provides) @image-id

More general scopes are prefixes of individual-image scopes, and specify a less-precisely-specified image, or a repository (by omitting first the image ID, if any; then the digest, if any; and finally a tag, if any), a repository namespace, or a registry host (by only specifying the host name and possibly a port number).

Finally, two full-store specifiers matching all images in the store are valid scopes:

  • [graph-driver-name@graph-root]
  • [graph-root]
Note: Some tools like Podman and Buildah hard-code overrides of the signature verification policy for β€œpush” operations, allowing these operations regardless of configuration in policy.json. push goes brrr, policy.json says nothing.

dir:

The dir: transport refers to images stored in local directories.

Supported scopes are paths of directories (either containing a single image or subdirectories possibly containing images).

Note: - The paths must be absolute and contain no symlinks. Paths violating these requirements may be silently ignored. - The top-level scope / is forbidden; use the transport default scope "", for consistency with other transports. do not use /, bestie β€” that’s not the move.

docker:

The docker: transport refers to images in a registry implementing the β€œDocker Registry HTTP API V2”.

Scopes matching individual images are named Docker references in the fully expanded form, either using a tag or digest. For example, docker.io/library/busybox:latest (not busybox:latest).

More general scopes are prefixes of individual-image scopes, and specify a repository (by omitting the tag or digest), a repository namespace, or a registry host (by only specifying the host name and possibly a port number) or a wildcarded expression starting with *., for matching all subdomains (not including a port number). For wildcarded subdomain matching, *.example.com is a valid case, but example*.*.com is not.

docker-archive:

Only the default "" scope is supported.

docker-daemon:

For references using the algo:digest format (referring to an image ID), only the default "" scope is used.

For images using a named reference, scopes matching individual images are in the fully expanded form, either using a tag or digest. For example, docker.io/library/busybox:latest (not busybox:latest).

More general named scopes are prefixes of individual-image scopes, and specify a repository (by omitting the tag or digest), a repository namespace, or a registry host (by only specifying the host name and possibly a port number) or a wildcarded expression starting with *., for matching all subdomains (not including a port number). For wildcarded subdomain matching, *.example.com is a valid case, but example*.*.com is not.

oci:

The oci: transport refers to images in directories compliant with β€œOpen Container Image Layout Specification”.

Supported scopes are paths to directories (either containing an OCI layout, or subdirectories possibly containing OCI layout directories). The reference annotation value, if any, is not used.

Note: - The paths must be absolute and contain no symlinks. Paths violating these requirements may be silently ignored. - The top-level scope / is forbidden; use the transport default scope "", for consistency with other transports.

oci-archive:

Supported scopes are paths to OCI archives, and their parent directories (either containing a single archive, or subdirectories possibly containing archives). The reference annotation value, if any, is not used.

Note: - The paths must be absolute and contain no symlinks. Paths violating these requirements may be silently ignored. - The top-level scope / is forbidden; use the transport default scope "", for consistency with other transports.

sif:

Supported scopes are paths to Singularity images, and their parent directories (either containing images, or subdirectories possibly containing images).

Note: - The paths must be absolute and contain no symlinks. Paths violating these requirements may be silently ignored. - The top-level scope / is forbidden; use the transport default scope "", for consistency with other transports.

tarball:

The tarball: transport is an implementation detail of some import workflows. Only the default "" scope is supported. 🫢

✨ Policy Requirements ✨

okay so bestie, using all those mechanisms up above πŸ‘† a whole set of policy requirements gets looked up. And here's the vibe: those requirements show up as a JSON array of individual requirement objects, and for an image to get the yes πŸ’–, all of the requirements have to be satisfied at the same time. no skipping, no picking favorites.

The same policy requirements can also be used to decide whether an individual signature is accepted (like, is it signed by a recognized key of a known author?). In that case some requirements might only apply to some signatures β€” but each signature has to be accepted by at least one requirement object. periodt. πŸ’…

These are the requirement objects we support:

insecureAcceptAnything

the chill one. simple syntax, no drama:

{"type":"insecureAcceptAnything"}

It accepts any image (but lowkey remember: the other requirements in the array still apply, so don't get too excited 🌸).

When it's deciding whether to accept an individual signature, this requirement basically does nothing β€” it does not cause the signature to be accepted, bestie. it's just standing there being pretty.

So what's it for? mainly policy scopes where no signature verification is required πŸŽ€ because the array of policy requirements must not be empty, so this requirement exists purely to say "yep, no requirements here" explicitly.

reject

the hater. simple syntax:

{"type":"reject"}

It rejects every image and every signature. that's it. that's the whole personality πŸ’€

signedBy

This one requires an image to be signed using "simple signing" with an expected identity β€” or, when we're judging a single signature, it accepts that signature if it's using an expected identity and key. ✨

{
    "type":    "signedBy",
    "keyType": "GPGKeys", /* The only currently supported value */
    "keyPath": "/path/to/local/keyring/file",
    "keyPaths": ["/path/to/local/keyring/file1","/path/to/local/keyring/file2"…],
    "keyData": "base64-encoded-keyring-data",
    "signedIdentity": identity_requirement
}

Exactly one of keyPath, keyPaths and keyData must be present, and it holds a GPG keyring of one or more public keys. Only signatures made by these keys are accepted πŸ”‘

The signedIdentity field β€” that's a JSON object, btw β€” says what image identity the signature is claiming about the image. You've got these alternatives:

  • The identity in the signature must exactly match the image identity. Heads up though: with this one, referencing an image by digest (with a signature claiming a repository:tag identity) will fail 😭
{"type":"matchExact"}
  • Tag? exact match. Digest reference? same repository. If the image identity carries a tag, the identity in the signature must exactly match; if the image identity uses a digest reference, the identity in the signature must be in the same repository as the image identity (using any tag).

(Note that with images identified using digest references, the digest from the reference is validated even before signature verification starts πŸ‘€)

{"type":"matchRepoDigestOrExact"}
  • Same repository, no cap. The identity in the signature must be in the same repository as the image identity. This is useful, like, to pull an image using the :latest tag when the image is signed with a tag specifying an exact image version. iconic behavior πŸŽ€
{"type":"matchRepository"}
  • Exact match to a specified identity. The identity in the signature must exactly match a specified identity. Useful e.g. when locally mirroring images signed using their public identity πŸ¦‹
{
    "type": "exactReference",
    "dockerReference": docker_reference_value
}
  • Same repository as a specified identity. The identity in the signature must be in the same repository as a specified identity. This one combines the properties of matchRepository and exactReference ✨
{
    "type": "exactRepository",
    "dockerRepository": docker_repository_value
}
  • Prefix remapping: If the image identity matches the specified prefix, that prefix is replaced by the specified "signed prefix" (otherwise it is used as unchanged and no remapping takes place); matching then follows the matchRepoDigestOrExact semantics documented above (i.e. if the image identity carries a tag, the identity in the signature must exactly match, if it uses a digest reference, the repository must match).

The prefix and signedPrefix values can be either host[:port] values (matching exactly the same host[:port], string), repository namespaces, or repositories (i.e. they must not contain tags/digests), and match as prefixes of the fully expanded form. For example, docker.io/library/busybox (not busybox) to specify that single repository, or docker.io/library (not an empty string) to specify the parent namespace of docker.io/library/busybox==busybox).

The prefix value is usually the same as the scope containing the parent signedBy requirement πŸ‘‘

{
    "type": "remapIdentity",
    "prefix": prefix,
    "signedPrefix": prefix,
}

If the signedIdentity field is missing, it gets treated as matchRepoDigestOrExact 🫢

⚠️ Note: matchExact, matchRepoDigestOrExact and matchRepository can be only used if a Docker-like image identity is provided by the transport. In particular, the dir: and oci: transports can be only used with exactReference or exactRepository. no exceptions bestie 😭

sigstoreSigned

This requirement requires an image to be signed using a sigstore signature with an expected identity and key πŸ”₯

{
    "type":    "sigstoreSigned",
    "keyPath": "/path/to/local/public/key/file",
    "keyPaths": ["/path/to/first/public/key/one", "/path/to/first/public/key/two"],
    "keyData": "base64-encoded-public-key-data",
    "keyDatas": ["base64-encoded-public-key-one-data", "base64-encoded-public-key-two-data"]
    "fulcio": {
        "caPath": "/path/to/local/CA/file",
        "caData": "base64-encoded-CA-data",
        "oidcIssuer": "https://expected.OIDC.issuer/",
        "subjectEmail", "expected-signing-user@example.com",
    },
    "pki": {
        "caRootsPath": "/path/to/local/CARoots/file",
        "caRootsData": "base64-encoded-CARoots-data",
        "caIntermediatesPath": "/path/to/local/CAIntermediates/file",
        "caIntermediatesData": "base64-encoded-CAIntermediates-data",
        "subjectHostname": "expected-signing-hostname.example.com",
        "subjectEmail": "expected-signing-user@example.com"
    },
    "rekorPublicKeyPath": "/path/to/local/public/key/file",
    "rekorPublicKeyPaths": ["/path/to/local/public/key/one","/path/to/local/public/key/two"],
    "rekorPublicKeyData": "base64-encoded-public-key-data",
    "rekorPublicKeyDatas": ["base64-encoded-public-key-one-data","base64-encoded-public-key-two-data"],
    "signedIdentity": identity_requirement
}

Exactly one of keyPath, keyPaths, keyData, keyDatas, fulcio and pki must be present. pick one, bestie πŸ’…

  • If keyPath or keyData is present, it contains a sigstore public key. Only signatures made by this key are accepted.
  • If keyPaths or keyDatas is present, it contains sigstore public keys. Only signatures made by any key in the list are accepted. 🌈
  • If fulcio is present, the signature must be based on a Fulcio-issued certificate. One of caPath and caData must be specified, containing the public key of the Fulcio instance. Both oidcIssuer and subjectEmail are mandatory, exactly specifying the expected identity provider and the identity of the user obtaining the Fulcio certificate πŸŽ€
  • If pki is present, the signature must be based on a non-Fulcio X.509 certificate. One of caRootsPath and caRootsData must be specified, containing certificates of the CAs. Only one of caIntermediatesPath and caIntermediatesData can be present, containing certificates of the intermediate CAs. One of subjectEmail and subjectHostname must be specified, exactly specifying the expected identity to which the certificate was issued πŸ”‘

At most one of rekorPublicKeyPath, rekorPublicKeyPaths, rekorPublicKeyData and rekorPublicKeyDatas can be present; and it's mandatory if fulcio is specified πŸ‘€ If a Rekor public key is specified, the signature must have been uploaded to a Rekor server and the signature must contain an (offline-verifiable) "signed entry timestamp" proving the existence of the Rekor log record, signed by one of the provided public keys.

The signedIdentity field has the same semantics as in the signedBy requirement described above 🫢 Note that cosign-created signatures only contain a repository, so only matchRepository and exactRepository can be used to accept them (and, ngl, that does not protect against substitution of a signed image with an unexpected tag 😭).

To use this with images hosted on image registries, the use-sigstore-attachments option needs to be enabled for the relevant registry or repository in the client's containers-registries.d(5) ✨

✨ EXAMPLES ✨

Okay bestie, this is the part you actually came for πŸ’… because honestly reading about default and transports in the abstract is giving "syllabus week" energy, and we want receipts. So here's the whole thing in action.

It is strongly recommended to set the default policy to reject, and then selectively allow individual transports and scopes as desired. Like, start with the door locked πŸ”’ and hand out keys one at a time. That's the whole vibe. That's the assignment. 🫢


πŸŽ€ A reasonably locked-down system πŸŽ€

(Note that the /*…*/ comments are not valid in JSON, and must not be used in real policies.) β€” yes queen, this is a manpage, not a JSON parser, so we're allowed to annotate. Copy this into a real file and those comments will get you absolutely cooked 😭
{
    "default": [{"type": "reject"}], /* Reject anything not explicitly allowed */
    "transports": {
        "docker": {
            /* Allow installing images from a specific repository namespace, without cryptographic verification.
               This namespace includes images like openshift/hello-openshift and openshift/origin. */
            "docker.io/openshift": [{"type": "insecureAcceptAnything"}],
            /* Similarly, allow installing the β€œofficial” busybox images.  Note how the fully expanded
               form, with the explicit /library/, must be used. */
            "docker.io/library/busybox": [{"type": "insecureAcceptAnything"}],
            /* Allow installing images from all subdomains */
            "*.temporary-project.example.com": [{"type": "insecureAcceptAnything"}],
            /* A sigstore-signed repository */
            "hostname:5000/myns/sigstore-signed-with-full-references": [
                {
                    "type": "sigstoreSigned",
                    "keyPath": "/path/to/sigstore-pubkey.pub"
                }
            ],
            /* A sigstore-signed repository using the community Fulcio+Rekor servers.

               The community servers’ public keys can be obtained from
               https://github.com/sigstore/sigstore/tree/main/pkg/tuf/repository/targets .  */
            "hostname:5000/myns/sigstore-signed-fulcio-rekor": [
                {
                    "type": "sigstoreSigned",
                    "fulcio": {
                        "caPath": "/path/to/fulcio_v1.crt.pem",
                        "oidcIssuer": "https://github.com/login/oauth",
                        "subjectEmail": "test-user@example.com"
                    },
                    "rekorPublicKeyPath": "/path/to/rekor.pub",
                }
            ],
            /* A Sigstore-signed repository using a certificate generated by a custom public-key infrastructure.*/
            "hostname:5000/myns/sigstore-signed-byopki": [
                {
                    "type": "sigstoreSigned",
                    "pki": {
                        "caRootsPath": "/path/to/pki_root_crts.pem",
                        "caIntermediatesPath": "/path/to/pki_intermediate_crts.pem",
                        "subjectHostname": "test-user.example.com"
                        "subjectEmail": "test-user@example.com"
                    }
                }
            ],
            /* A sigstore-signed repository, accepts signatures by /usr/bin/cosign */
            "hostname:5000/myns/sigstore-signed-allows-malicious-tag-substitution": [
                {
                    "type": "sigstoreSigned",
                    "keyPath": "/path/to/sigstore-pubkey.pub",
                    "signedIdentity": {"type": "matchRepository"}
                }
            ],
            /* A sigstore-signed repository using the community Fulcio+Rekor servers,
               accepts signatures by /usr/bin/cosign.

               The community servers’ public keys can be obtained from
               https://github.com/sigstore/sigstore/tree/main/pkg/tuf/repository/targets .  */
            "hostname:5000/myns/sigstore-signed-fulcio-rekor- allows-malicious-tag-substitution": [
                {
                    "type": "sigstoreSigned",
                    "fulcio": {
                        "caPath": "/path/to/fulcio_v1.crt.pem",
                        "oidcIssuer": "https://github.com/login/oauth",
                        "subjectEmail": "test-user@example.com"
                    },
                    "rekorPublicKeyPath": "/path/to/rekor.pub",
                    "signedIdentity": { "type": "matchRepository" }
                }
            ]
              /* Other docker: images use the global default policy and are rejected */
        },
        "dir": {
            "": [{"type": "insecureAcceptAnything"}] /* Allow any images originating in local directories */
        },
        "atomic": {
            /* The common case: using a known key for a repository or set of repositories */
            "hostname:5000/myns/official": [
                {
                    "type": "signedBy",
                    "keyType": "GPGKeys",
                    "keyPath": "/path/to/official-pubkey.gpg"
                }
            ],
            /* A more complex example, for a repository which contains a mirror of a third-party product,
               which must be signed-off by local IT */
            "hostname:5000/vendor/product": [
                { /* Require the image to be signed by the original vendor, using the vendor's repository location. */
                    "type": "signedBy",
                    "keyType": "GPGKeys",
                    "keyPath": "/path/to/vendor-pubkey.gpg",
                    "signedIdentity": {
                        "type": "exactRepository",
                        "dockerRepository": "vendor-hostname/product/repository"
                    }
                },
                { /* Require the image to _also_ be signed by a local reviewer. */
                    "type": "signedBy",
                    "keyType": "GPGKeys",
                    "keyPath": "/path/to/reviewer-pubkey.gpg"
                }
            ],
            /* A way to mirror many repositories from a single vendor */
            "private-mirror:5000/vendor-mirror": [
                { /* Require the image to be signed by the original vendor, using the vendor's repository location.
                     For example, private-mirror:5000/vendor-mirror/productA/image1:latest needs to be signed as
                     vendor.example/productA/image1:latest . */
                    "type": "signedBy",
                    "keyType": "GPGKeys",
                    "keyPath": "/path/to/vendor-pubkey.gpg",
                    "signedIdentity": {
                        "type": "remapIdentity",
                        "prefix": "private-mirror:5000/vendor-mirror",
                        "signedPrefix": "vendor.example.com"
                    }
                }
            ]
        }
    }
}

A few things to clock πŸ‘€ β€” the docker section lets you in on docker.io/openshift, on the fully-expanded docker.io/library/busybox (yes, /library/ is mandatory, don't fight it), on everything under *.temporary-project.example.com, and it walks you through all four flavors of sigstore signing: plain key, community Fulcio+Rekor, custom public-key infrastructure (that's the pki block with caRootsPath, caIntermediatesPath, subjectHostname and subjectEmail), and the matchRepository variants β€” which are literally named ...-allows-malicious-tag-substitution because that is what matchRepository lets happen, and you should know that going in πŸ’€. The dir section with the empty-string scope "" waves through anything from local directories. And the atomic block serves up the classic signedBy/GPGKeys triple: one key for hostname:5000/myns/official, a two-signature setup for hostname:5000/vendor/product (vendor signs at vendor-hostname/product/repository via exactRepository, local reviewer signs too), and a remapIdentity mirror where prefix is private-mirror:5000/vendor-mirror and signedPrefix is vendor.example.com.

And notice the last comment in the docker section β€” other docker: images fall back to the global default policy and get rejected. Bye πŸ‘‹


πŸ’€ Completely disable security, allow all images, do not trust any signatures πŸ’€

You asked for it, so here it is:

{
    "default": [{"type": "insecureAcceptAnything"}]
}

That's it. That's the whole policy. No verification, no signatures, no thoughts, head empty πŸ’… β€” obviously only do this if you fully understand what you're giving up, bestie, because you are giving up everything.


🌈 SEE ALSO 🌈

  • atomic(1)

🌸 HISTORY 🌸

  • August 2018 β€” Rename to containers-policy.json(5) by Valentin Rothberg vrothberg@suse.com ⟨mailto:vrothberg@suse.com⟩
  • September 2016 β€” Originally compiled by Miloslav Trmač mitr@redhat.com ⟨mailto:mitr@redhat.com⟩

And that's the whole page, babe πŸŽ€ this one lives in the Man collection as policy.json, section 5 of CONTAINERS-POLICY.JSON(5) β€” that's the source and the version right there, go off πŸ“–βœ¨ mwah πŸ’‹

πŸ“¦ where this page comes from