> For the complete documentation index, see [llms.txt](https://docs.xygeni.io/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.xygeni.io/xygeni-products/build-security/attestation-policy.md).

# 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.

```console
$ salt -nb verify \
       --file=dist/app.jar --name=app.jar \
       --attestation=attestation.jsonl \
       --trust-root=sigstore \
       --policy=./release-policy.yml
```

The two verdicts are kept apart, including in the exit code:

| Exit code | Meaning                                                                                                |
| --------- | ------------------------------------------------------------------------------------------------------ |
| `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

```yaml
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: true
```

Every 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:

```console
$ salt policy validate ./release-policy.yml
```

## 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.

| Key                                                         | Meaning                                                     |
| ----------------------------------------------------------- | ----------------------------------------------------------- |
| `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:

```
https://github.com/acme/build/.github/workflows/release.yml@refs/heads/main
```

**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`:

```yaml
defaults:
  signer:
    trustRoot: sigstore
    uris: ["https://github.com/acme/build/.github/workflows/release.yml@refs/heads/main"]
```

{% hint style="warning" %}
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.
{% endhint %}

### 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.

| Family  | Asks about                                                                                                                                     |
| ------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| `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.

{% hint style="info" %}
A freshness bound needs a signature time that rests on evidence, which means a transparency log entry: pass `--rekor-uuid` or `--rekor-entry`. Measuring against the current clock instead would let an attestation of any age satisfy any window.
{% endhint %}

## 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:

```console
$ salt policy infer attestation.jsonl -o draft-policy.yml
```

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:

```console
$ salt policy push ./release-policy.yml --name release-promotion
Stored policy 'release-promotion' (4 requirement(s)). Reference it as --policy xygeni:release-promotion

$ salt verify -a attestation.jsonl --file dist/app.jar --name app.jar \
              --policy xygeni:release-promotion
```

### 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

| Command                                       | Does                                                                          |
| --------------------------------------------- | ----------------------------------------------------------------------------- |
| `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.

{% hint style="info" %}
Referencing a stored policy needs network access. An air-gapped run should fetch the policy on a connected host with `salt policy pull`, commit the file, and reference it by path.
{% endhint %}

## 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:

```console
$ salt -nb verify -a attestation.jsonl --file dist/app.jar --name app.jar \
                  --trust-root sigstore --policy ./release-policy.yml

WARN Salt - Attestations are valid but do not satisfy the policy:
policy release-promotion not satisfied:
  - sbom: app.jar: log4j-core is Copyleft licensed (GPL-3.0-only), which the policy refuses
```

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`.


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://docs.xygeni.io/xygeni-products/build-security/attestation-policy.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
