Skip to content

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.

TermWhat 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 provenanceA 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.”
SignatureCryptographic 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 / CVEA 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.

What each capability needs installed. The dir/zip/jar SBOM path is the only one that runs with nothing external installed.

CapabilityExternal toolTool-free?
generate-sbomdir / zip / jarnone (pure-TypeScript lockfile backend)Yes
generate-sbomimagesyft or docker buildx (#610)No
sign / attest-provenance / verifycosignNo
vuln-gategrype or trivy (auto-detected at scan time)No

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.

a config-only/producer component's build phase
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:

Terminal window
chant run --components my-lib --env local

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

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:

chant.config.ts
export default {
signing: {
oidcIssuer: "https://token.actions.githubusercontent.com",
identity: "https://github.com/my-org/my-repo/.github/workflows/release.yml@refs/heads/main",
},
};
a component's verify step
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.

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

One digest carries all three attestations — SBOM, provenance, and signature — and verify checks that digest before a deploy is allowed to proceed.

A build produces an image, publish promotes it to a registry by digest, and generate-sbom, attest-provenance, and sign all attach to that same digest. A verify step checks the signature and provenance against an identity policy before apply runs, and throws on failure. A build produces an image, publish promotes it to a registry by digest, and generate-sbom, attest-provenance, and sign all attach to that same digest. A verify step checks the signature and provenance against an identity policy before apply runs, and throws on failure.
SBOM, provenance, and signature all attach to one artifact digest. Verify gates apply on that digest.

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:

deploy-with-attestations.component.ts
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.

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:

a Gate phase, composed after Sbom
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.

  • Build Archive — how the SBOM is generated and where the digest sign/verify operate 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 VerificationFailedError halts a composition before apply runs.
  • examples/supply-chain — the runnable version of this page: npm install && npm run supply-chain produces a real SBOM + config-BOM tool-free, plus the tool-gated steps above as a separate, clearly-marked component.