Attestation Policy
salt verify answers one question: is this attestation genuine? The signature checks out, the certificate is good, and the digests match the artifact in hand.
That is not the same as the artifact being fit to promote. An artifact can carry a perfectly valid attestation and still be missing the SBOM your compliance team requires, or carry a vulnerability scan from a scanner nobody approved, or have been built from a branch that is not main.
An attestation policy answers the second question: are these attestations enough? It is a YAML document stating what must be attested, who must have signed it, where the artifact came from, and what the attestations must say.
$ salt -nb verify \
--file=dist/app.jar --name=app.jar \
--attestation=attestation.jsonl \
--trust-root=sigstore \
--policy=./release-policy.ymlThe two verdicts are kept apart, including in the exit code:
0
The attestations are genuine, and they satisfy the policy
13
The attestations do not verify — a bad signature, a rejected certificate, a digest that does not match
17
The attestations are genuine, and they are not enough — the policy is not satisfied
A pipeline can act differently on each. Exit 13 says something may be wrong with the artifact or the attestation; exit 17 says the build did not produce everything your organization requires, which is usually a pipeline to fix rather than an artifact to distrust.
A first policy
schemaVersion: 1
name: release-promotion
description: What must hold before a build is promoted to production.
require:
- id: provenance
description: Built by our own release pipeline, from main.
predicate: Provenance
signer:
trustRoot: sigstore
uris: ["https://github.com/acme/build/.github/workflows/release.yml@refs/heads/main"]
source:
repositories: ["https://github.com/acme/widget"]
branches: [main]
- id: sbom
description: Everything we ship has a bill of materials, with no copyleft.
predicate: [CycloneDx, Spdx]
signer:
trustRoot: sigstore
uris: ["https://github.com/acme/build/.github/workflows/release.yml@refs/heads/main"]
sbom:
denyLicenseKinds: [Copyleft, WeakCopyleft]
requireLicenseForEveryComponent: trueEvery requirement must hold, for every subject named on the command line. There is no partial credit: a gate either opens or it does not.
Check a policy before committing it — this needs no network and no credentials, so it suits a pre-commit hook:
What a requirement can ask
A requirement is satisfied when some attestation is verified, is signed, is about the artifact you named, and meets everything the requirement asks. Four kinds of thing can be asked, and they answer different questions.
By whom
The strongest evidence available, because it is the only part a certificate authority vouched for rather than the build asserting about itself.
commonName, organizations, emails, uris, dnsNames
Certificate attributes the signer must carry
trustRoot
The authority the signer's certificate must chain to
publicKey
A published key, for a signature made without a certificate
anyOf
Several whole identities, any one of which is accepted
uris is the line that matters most. For keyless CI signing, Fulcio writes the workload identity there — repository, workflow and ref, bound into the certificate by the CA:
Every requirement must name a signer. "Attested" without "by whom" is not a control: an attestation is a claim, and a gate exists to ask whose claim it is. Where one signer covers the whole policy, say it once in defaults:
Two suppliers means anyOf, not two entries in organizations and two in emails. Parallel lists on one identity accept every crossing of those lists — including one supplier's organization paired with the other's address, an identity nobody authorized.
How it was built
From the provenance: builders, buildTypes, requireReproducible, materialsFrom.
Where it came from
From the provenance or a git attestation: repositories, repositoryTypes, branches, tags, commits, runIds, triggerTypes, triggeredBy, commitAuthors, requireSignedCommit.
What the attestations found
Assertions on the content itself, one block per predicate family.
vulns
maxCvss, maxSeverity, maxCount, scanners, allowVulnerabilities, requireDbLastUpdate
sbom
allowedLicenses, denyLicenses, allowedLicenseKinds, denyLicenseKinds, requireLicenseForEveryComponent, minComponents, allowEmpty
vsa
verificationResult, requiredLevels, verifiers, resourceUri, timeVerifiedWithin
tests
result, maxFailed, maxWarned, requiredTests
Licences are compared after resolution through Xygeni's SPDX catalogue, so an SBOM writing Apache License 2.0 matches a policy writing Apache-2.0. A component that declares no licence at all fails an allowlist — it is the dependency most likely to be carrying whatever the list was written to keep out.
Freshness
signedWithin, signedAfter and signedBefore sit on the requirement and bound when the attestation was signed. scanFinishedWithin and dbUpdatedWithin sit in the vulns block and bound how recent the scan, and the vulnerability database behind it, were — a clean result from a stale database means very little.
Drafting a policy from a build
The hard part of writing a policy is the blank page. salt policy infer reads an attestation your pipeline has just produced and drafts the policy it would satisfy:
It reads the signer identity out of the certificate — including the workload-identity URI, which almost nobody writes out correctly by hand — and the repository, branch and trigger out of the provenance.
What it declines to infer is the point. A policy pinning this build's commit or run id would pass exactly once and then refuse everything after it. A maxCvss read off today's worst finding would write today's technical debt in as tomorrow's permanent ceiling — so bounds are suggested as comments, with the reason, rather than written in.
The output is a starting point for a person to read, edit and cut down, and it says so in the file.
Shared policies
A policy that lives beside the code it guards is the right shape for a policy that belongs to one project. An organization holding twenty repositories to the same standard wants the opposite: the gate defined once, and changed in one place.
Store the policy on the Xygeni platform under a name, and reference it by that name from any pipeline:
Why keep policies centrally
One definition, not twenty copies. Twenty files drift. One is edited for a good reason, the rest are not, and a year later nobody can say which repository is held to what. A reference cannot drift: every pipeline naming xygeni:release-promotion is checked against the same requirements.
Tighten the gate without twenty pull requests. Adding a requirement — a stricter signer, a new predicate, a lower CVSS ceiling — is one push. Every pipeline picks it up on its next run. There is no window in which half the estate enforces the new standard and half still enforces the old one.
The requirement is not in the repository being checked. Storing the policy on the platform separates who defines the standard from who builds against it. A pipeline referencing xygeni:release-promotion fetches the requirements at verification time, so there is no policy file in that repository for a change to the build to quietly relax along with it.
An answer to "what was enforced, and since when". salt policy list shows each stored policy with the date it last changed, and salt policy show prints it exactly as the gate will read it. When an auditor asks what a release was actually held to, the answer is one command rather than a search through pipeline history for the version of a file that was checked out that day.
Keeping the policy centrally does not make it any less checkable: it is still a file you write, review and version wherever you keep your source. push is what publishes it, and pull brings it back.
The commands
salt policy list
The policies stored for your account: name, description and when each changed
salt policy show <name>
Print one policy as stored
salt policy pull <name> -o policy.yml
Write one to a local file
salt policy push ./policy.yml --name <name>
Create or replace one. The file is validated before it is sent
salt policy delete <name>
Remove one. Confirms first, unless --force
salt policy validate ./policy.yml
Check a local file. Offline, no credentials
salt policy infer <attestation>
Draft a policy from an attestation
push validates the file before it sends it. Refusing a broken policy at the person who wrote it is the difference between one author seeing an error and every pipeline referencing that name failing at once.
A stored policy is validated again by exactly the same loader as a local file. One that would be refused on disk is refused as a download: the platform is where a policy is kept, not a reason to stop checking it.
Two people editing the gate that guards production should not silently overwrite each other, so push and delete accept --etag to make the change conditional on the version you edited. Without it, a push replaces whatever is stored.
Why a policy refuses
A refusal names the requirement and says what was wrong with it, because a gate that closes without saying why is a gate somebody will work around:
The verdict travels inside the verification result as well, so -o result.json carries both halves — what was verified and what was required — in one document. An auditor asking what was enforced at the gate needs the requirements that ran beside the verification they ran against.
Rules worth knowing
Only positive requirements. A policy says what must be present. There is no way to write one satisfied by the absence of an attestation, because an in-toto bundle is not signed as a whole and entries can be dropped from it — a gate that could be satisfied by removing something would be no gate at all.
Nothing passes vacuously. A requirement whose predicate matches no attestation fails; existence is implied by asking for anything at all. An SBOM listing no components fails unless you say allowEmpty: true. A condition that cannot be measured — a missing value, a severity in a vocabulary this version does not know — fails rather than passes.
Unknown settings are refused. A misspelled signor: would silently drop an identity pin while still reporting a pass, so the policy is rejected when it loads, where an author is watching, rather than at the gate, where nobody is.
A statement must be about your artifact. Sharing a filename is not enough: the digests must have been compared and agreed. Two artifacts are routinely called app.jar.
Last updated

