Supply-Chain Attestations
This page is for the first time you’re asked to sign an artifact and you’re not sure what any of the acronyms mean. It covers four things chant attaches to a build, in plain language, then walks through doing it for real with the sign and verify capabilities.
Glossary
Section titled “Glossary”| Term | What it actually is |
|---|---|
| SBOM (Software Bill of Materials) | A list of every package/library in the artifact — name, version, license. Answers “what’s in this thing,” the same way a food label answers “what’s in this can.” |
| SLSA provenance | A signed statement of where the artifact came from and how it was built — source commit, builder identity, build timestamp. Answers “who built this and from what,” not “what’s in it.” |
| Signature | Cryptographic proof that a specific identity (a person or a CI workflow) produced this exact artifact, plus a public record that the signature happened. Answers “who vouches for these exact bytes.” |
| VEX / CVE | A CVE is a known vulnerability in some package. VEX is a statement about whether a given CVE actually affects this artifact the way it’s used. Answers “of everything the SBOM lists, what actually matters.” |
An SBOM tells you what’s inside. Provenance tells you where it came from. A signature tells you who’s vouching for it. None of the three answers the other two — that’s why chant attaches all three separately, to the same artifact, rather than treating any one as a stand-in for the others.
Prerequisites
Section titled “Prerequisites”What each capability needs installed. The dir/zip/jar SBOM path is the only one that runs with nothing external installed.
| Capability | External tool | Tool-free? |
|---|---|---|
generate-sbom — dir / zip / jar | none (pure-TypeScript lockfile backend) | Yes |
generate-sbom — image | syft or docker buildx (#610) | No |
sign / attest-provenance / verify | cosign | No |
vuln-gate | grype or trivy (auto-detected at scan time) | No |
Generating an SBOM tool-free
Section titled “Generating an SBOM tool-free”Before any of the signing/provenance material below, there’s a step that needs no external tool at all: generate-sbom. Every artifact type it supports (dir, zip, jar — not image, see the aside below) defaults to lockfileSbomGenerator (packages/core/src/components/verbs/lockfile-sbom-generator.ts), a pure-TypeScript backend that parses a package-lock.json/pom.xml already on disk and emits a real SPDX/CycloneDX document. No syft, no network, no Docker.
import { phase, type Component } from "@intentius/chant/components/component";
export const myLib: Component = { name: "my-lib", archetype: "producer-library", dependsOn: [], deploy: [ phase("Sbom", [ { kind: "generate-sbom", artifactType: "dir", path: ".", format: "spdx" }, ]), ],};Run it:
chant run --components my-lib --env localThat writes sbom.spdx.json next to the scanned package-lock.json (lockfileSbomGenerator’s default outDir) and folds an sbom-kind entry into the component’s build-archive manifest. examples/supply-chain is this exact pattern, plus a second, structural BOM over a synthesized IaC template via extract-config-bom — clone it and run npm install && npm run supply-chain to see both artifacts land on disk.
Why keyless signing
Section titled “Why keyless signing”The obvious way to sign something is a private key: generate a keypair, sign with the private half, publish the public half for verification. That works, but it hands a beginner a new hazard — a key file that can leak, that can be lost (taking your ability to sign with it), and that someone has to remember to rotate.
Keyless signing removes the key file entirely. Instead of a long-lived secret, cosign asks an OIDC identity provider you already use — your CI system’s built-in token, or a browser login — “prove who you are,” and gets back a short-lived certificate good for a few minutes. It signs with that, and logs the signature to a public transparency log (Rekor) so anyone can later confirm a signature really was issued at that time for that identity. The certificate expires almost immediately; there’s nothing left to steal afterward.
This is why chant’s sign capability defaults to keyless with no configuration: there’s no key to generate before your first signature, and nothing to rotate afterward. Key-based signing still exists as an explicit opt-in (key config) for teams with an existing KMS policy, but it is never what a new project reaches for first.
The one thing you must set: an OIDC identity
Section titled “The one thing you must set: an OIDC identity”Signing needs no setup — cosign detects ambient OIDC automatically in GitHub Actions, GitLab CI, and most CI providers, and falls back to an interactive browser login locally. Verifying does need one thing from you: telling verify which identity is allowed to have signed.
Without this, “verify” would only mean “some signature exists” — which any keyless signer, including an attacker who can also complete an OIDC login, can produce. Naming the expected issuer and identity is what turns “a signature exists” into “the identity I trust signed this.”
Set it in two places that must agree:
export default { signing: { oidcIssuer: "https://token.actions.githubusercontent.com", identity: "https://github.com/my-org/my-repo/.github/workflows/release.yml@refs/heads/main", },};import { verify } from "@intentius/chant/components/verbs/verify";
await verify.run(ctx, { imageRef: "@Sign.imageRef", policy: { expectedIssuer: "https://token.actions.githubusercontent.com", expectedIdentity: "https://github.com/my-org/my-repo/.github/workflows/release.yml@refs/heads/main", },});resolveSigningDefaults() (packages/core/src/config.ts) reads the signing block from chant.config.ts so a component’s verify step doesn’t have to repeat the issuer/identity literally — it resolves project-wide defaults a per-call policy can still override.
For GitHub Actions and GitLab CI, oidcIssuer is the provider’s fixed token endpoint and identity is your own workflow’s ref — both values you can read straight off the provider’s own OIDC docs, not something chant invents. Locally, signing falls back to an interactive Sigstore login (a browser tab), and identity becomes the email cosign’s certificate records for that login.
identityIsRegexp: true treats identity as a pattern instead of a literal match, for teams whose workflow ref varies by branch or tag and don’t want to enumerate every one.
Why digest, not tag
Section titled “Why digest, not tag”A tag like myapp:latest is a pointer that can move — the next push retargets it to different bytes without changing the tag. Signing a tag would only ever mean “something with this name was signed at some point,” which says nothing about what a docker pull actually resolves to by the time someone runs it.
A digest (myapp@sha256:...) is the artifact’s own content hash. It cannot be reassigned to different bytes; a signature on a digest is a signature on those exact bytes, forever.
sign and attest-provenance enforce this structurally: both throw SignTargetNotDigestError before ever invoking cosign if the reference isn’t digest-qualified. In practice you never construct that digest by hand — it comes from a prior publish step’s output ("@Publish.uri", itself repo@sha256:...; see Build Archive).
First-signing walkthrough
Section titled “First-signing walkthrough”One digest carries all three attestations — SBOM, provenance, and signature — and verify checks that digest before a deploy is allowed to proceed.
The composition below is the real authoring shape — a Component built from phase()/Step (packages/core/src/components/component.ts), the exact shape chant run --components dispatches, not a paraphrase. It’s written here as { kind: … } literals to keep every field visible; each step also has a typed builder (sign({ … }), verify({ … })) — see Component Contract:
import { phase, type Component } from "@intentius/chant/components/component";
export const webService: Component = { name: "web-service", archetype: "service", dependsOn: [], build: { kind: "docker-build", context: "." }, deploy: [ phase("Build", [ { kind: "docker-build", context: ".", into: "web-service.tar", sourceRef: "$env.GIT_SHA" }, ]), phase("Publish", [ // "@Build.digest" reads the prior "Build" phase's docker-build output — // publish-image's own output, "@Publish.uri", is what every step below wires from. { kind: "publish-image", from: "archive:web-service.tar", to: "123.dkr.ecr.us-east-1.amazonaws.com/web-service" }, ]), phase("Sbom", [ { kind: "generate-sbom", artifactType: "image", path: "archive:web-service.tar", digest: "@Build.digest" }, ]), phase("Attest", [ { kind: "attest-provenance", imageRef: "@Publish.uri", // { sourceRef, artifactDigest } — the same link docker-build folded into the archive manifest (#614) provenance: { sourceRef: "$env.GIT_SHA", artifactDigest: "@Build.digest" }, builderId: "https://github.com/actions/runner", }, ]), phase("Sign", [ { kind: "sign", imageRef: "@Publish.uri" }, // keyless — no key config needed ]), phase("Verify", [ { kind: "verify", imageRef: "@Publish.uri", policy: { expectedIssuer: "https://token.actions.githubusercontent.com", expectedIdentity: "https://github.com/my-org/my-repo/.github/workflows/release.yml@refs/heads/main", }, }, ]), phase("Apply", [ { kind: "cfn-deploy", template: "archive:web-service.template.json" }, // only runs if Verify passed ]), ],};Run it: chant run --components web-service --env prod. Every step from Sbom onward takes the same "@Publish.uri" digest (or "@Build.digest", threaded from the Build phase) — build once, attest three times, verify once, all against bytes that never change underneath you.
“Done right” here means: the digest deployed is the digest that was signed, and verify ran and passed before apply — not signed-and-forgotten, and not a signature that gets checked manually after the fact if someone remembers. Put verify before the Apply phase in the composition and a failed check throws, halting the deploy the same way any other thrown capability error does (see Orchestration) — an unsigned or wrongly-signed artifact never reaches apply.
VEX/CVE gating with vuln-gate
Section titled “VEX/CVE gating with vuln-gate”vuln-gate scans an SBOM for known CVEs, suppresses the ones a VEX document says don’t actually apply, checks a license policy, and throws VulnGateFailedError on a violation — the same throw-to-halt mechanism as verify:
phase("VulnGate", [ { kind: "vuln-gate", sbom: "@Sbom.sbom", // wired from the generate-sbom step's output, same phase-output convention as "@Publish.uri" policy: {}, // filled from chant.config.ts's vulnPolicy section (#629) when left empty },]),The default policy (DEFAULT_VULN_POLICY, packages/core/src/components/verbs/vuln-gate.ts) is beginner-safe: block only critical + fixable + not-VEX-suppressed findings; warn on high; license findings are report-only unless failOnLicense: true.
Read next
Section titled “Read next”- Build Archive — how the SBOM is generated and where the digest
sign/verifyoperate on comes from. - Observability — the build ledger’s referrer discovery, which can surface a digest’s signature/provenance/SBOM referrers after the fact.
- Orchestration — how a thrown
VerificationFailedErrorhalts a composition beforeapplyruns. - examples/supply-chain — the runnable version of this page:
npm install && npm run supply-chainproduces a real SBOM + config-BOM tool-free, plus the tool-gated steps above as a separate, clearly-marked component.