Skip to content
ocx
install

Attach attestations and SBOMs

Attach an SBOM, provenance record or other attestation to a package, and read it back with or without verification.

A signature tells you who published a package. It does not tell you what is inside it. A CI pipeline can sign every binary with the right identity and still ship a bundled library with a known CVE.

An attestation closes that gap. It is a structured statement about a package, bound to the exact manifest digest the signature already covers. Typical statements are an SBOM that lists every component, a provenance record that names the build, or a vulnerability scan result.

ocx package attest publishes one through the same keyless pipeline signing uses. ocx package push --sbom is shorthand for the CycloneDX case. ocx package sbom reads either kind back. Under --offline, attest refuses with exit 81 (offline_attest_refused), as sign does.

The common case is a CycloneDX SBOM generated right after a build. Not every build runs where a signing identity exists. Think of a laptop, a local script or a CI job without OIDC.

The SBOM still needs to travel with the package for whatever reads it later. That may be a dependency scanner, an auditor or the next pipeline stage. Demanding a stronger floor for that metadata than for the executable beside it would be backwards.

So attach has two shapes, and OCX picks one from what it sees in the environment. You do not pass a flag to choose:

With an identity available, ocx package push --sbom <PATH> attaches the SBOM in the same step as the push. It is shorthand for attest --type cyclonedx against the digest the push just wrote:

Terminal window
cyclonedx-cli ... > sbom.json
ocx package push -p linux/amd64 --sbom sbom.json -i registry.example/pkg:1.0 pkg.tar.gz

Use ocx package attest directly for any of these cases:

  • attaching a predicate on its own
  • attaching an SPDX SBOM, because push --sbom is CycloneDX-only (use attest --type spdx or spdxjson)
  • attaching more than one predicate type to the same manifest
  • attesting something this invocation did not just publish
Terminal window
ocx package attest -p linux/amd64 --predicate sbom.json --type cyclonedx registry.example/pkg:1.0

--predicate names the file whose bytes the attestation wraps verbatim. --type is one of the cosign-compatible aliases (see Predicate types) or any absolute predicate-type URI.

With a signing identity present, the command wraps that predicate in a DSSE-enveloped in-toto Statement. The Statement names the target manifest digest as its subject. The command signs it through the keyless pipeline that sign uses. That pipeline needs an ephemeral key, a Fulcio certificate bound to your OIDC identity and a Rekor transparency-log entry. The command then pushes the bundle as a referrer.

This recording runs against a real, self-hosted Sigstore stack. It signs a CycloneDX SBOM, lists it and extracts it back out:

With no signing identity visible, attest and push --sbom still succeed. They publish the SBOM document itself as the referrer’s payload, typed by its own media type. That is application/vnd.cyclonedx+json for CycloneDX, and application/spdx+json or text/spdx for SPDX. There is no DSSE envelope, no Fulcio certificate and no Rekor entry.

This is the wire shape oras attach writes. It is also the shape cosign attach sbom writes under --registry-referrers-mode oci-1-1. An SBOM that ocx attached unsigned reads back with those tools, and one they attached reads back with ocx package sbom.

The default mode of cosign writes a sha256-<hex>.sbom tag instead of a referrer. ocx reads that shape too, though it never writes it. Neither shape is signed, so both list only under --no-verify. See Read it back.

An unsigned SBOM carries exactly the trust of the unsigned package it describes. It has the same repository, the same set of people who can push to it and the same registry access controls. It is not a weaker copy of the signed case. It is a different claim.

A signed attestation says that an identity vouches for the document. An unsigned attach says only that the document was published alongside the package. That is already true of the package’s binary layers.

The check asks one question: is a signing identity visible? It never makes a network call and never opens the interactive browser sign-in. That sign-in is a prompt for an identity, not evidence of one.

Suppose an identity is visible but acquiring the certificate then fails. Fulcio may be unreachable, or an ambient CI token may be rejected. That is a hard error with no fallback to the unsigned form. A CI job set up to sign that silently published unsigned would look identical on the wire to a job that never meant to sign. The fallback would hide the exact misconfiguration an operator needs to see.

One predicate type is refused in unsigned mode outright: SLSA provenance. Without a DSSE envelope, no builder identity is attached to the statement, so an unsigned provenance record carries no attribution at all. attest --type slsaprovenance1 with no signing identity visible exits 64 (unsigned_type_unsupported) before any network call. OCX will not publish a document that looks like provenance and proves nothing.

The same recording, run again with no signing identity present, attaches a second SBOM unsigned. It then reads both documents back with ocx package sbom --no-verify, the reading mode covered in Read it back:

Attaching and reading answer two separate questions. Attaching asks whether a signing identity is visible. If one is, OCX signs. If none is, it attaches raw (see Attach an SBOM).

Reading asks whether verification is demanded. If it is, OCX runs the full pipeline and refuses anything unsigned. If not, it runs no cryptography and reads whatever is there. ocx package sbom answers this second question.

A consumer auditing a dependency has usually configured nothing. There is no [[trust.policy]] and no certificate identity to check against. Refusing to hand them an SBOM until they set up Sigstore turns a read into a wall.

A consumer who did configure a policy, or who typed --certificate-identity expecting a check, gets a different answer. They must never receive an unsigned document dressed up as if it passed. That would make the policy decorative.

ocx package sbom resolves one of two modes per invocation and states which one it used:

  • Demand: every listed document carries a signature that passed the full pipeline. That covers referrer discovery, the Fulcio, Rekor and identity chain, and the Statement’s subject-digest binding. OCX refuses an unsigned attach and never lists it. The policy names who must have signed, and a raw attachment has no signer at all.
  • Permissive: nothing is checked and no cryptography runs. OCX extracts a signed bundle’s DSSE payload exactly as it reads a raw attachment’s bytes. It reports both as verified: false with no signer identity, because none was checked. The command is usable with no Sigstore setup at all, and the consumer above gets the SBOM either way.

The mode follows from what you asked for and what your environment already resolves to. There is never a silent default with no way to see which one you got:

You passA [[trust.policy]] covers the package?Mode
nothingnoPermissive
nothingyesDemand
--certificate-identity + --certificate-oidc-issuer(overrides policy either way)Demand
--verifyno, and no identity flags eitherError, exit 64
--verifyyes, or identity flags givenDemand
--no-verify(any)Permissive

--verify and --no-verify last-win when both are typed, like every --x/--no-x pair in ocx. Combining them is not an error.

--no-verify does conflict with the certificate flags. Supplying an identity while refusing to check it is contradictory, so that combination is a usage error in either order. --verify with nothing to verify against is also a usage error (no_identity_provided, exit 64). It never falls back silently to permissive, because an operator who typed --verify must not have it quietly ignored.

A verified entry carries verified: true plus the certificate identity, issuer and signed-at timestamp that vouch for it. An unverified entry carries verified: false and omits those three fields. OCX never emits them empty, because an empty identity would read as a verification that failed to render, not as “there was nothing to verify.”

The listing’s own summary.verification field (verified or unverified) names which mode produced the whole run. A script never has to infer it from the rows. Under Demand mode an unverified row cannot occur, and under Permissive mode nothing else can.

--output <PATH|-> extracts one predicate’s bytes, byte-exact as the publisher wrote them, from the candidate the mode’s own trust class resolved to:

Terminal window
ocx package sbom -p linux/amd64 --type cyclonedx --output sbom.json registry.example/pkg:1.0

More than one matching candidate is refused rather than guessed. The error names every colliding referrer digest and every distinct predicate type in the set, so --type has a value to narrow with.

Under Permissive mode, writing a document nothing vouched for prints one line to stderr: SBOM is unverified: no signature over referrer <digest> was checked, so nothing vouches for what it says. The bytes written to PATH (or stdout) are unaffected. A script that pipes --output - into a file still gets exactly the predicate’s bytes, and the warning stays visible separately.

For a CycloneDX predicate, --summary adds the spec version, the component count and the top-level component name to the listing. It augments the listing rather than replacing it, so you can read a document quickly without piping it through jq. --format json output also gains a serial_number field, which the plain-text form omits. --summary reads a document the same way in every mode, because whether anyone vouches for a component list says nothing about whether the list parses.

--type accepts the same short aliases cosign uses, so a predicate produced for one keyless-signing tool reads the same in the other:

AliasResolves to
cyclonedxhttps://cyclonedx.org/bom
spdx, spdxjsonthe SPDX JSON predicate type
slsaprovenanceSLSA provenance v0.2. attest refuses to publish this (exit 64, provenance_version_unsupported), so pass slsaprovenance1
slsaprovenance02SLSA provenance v0.2, explicit spelling. Refused the same way as slsaprovenance, so pass slsaprovenance1
slsaprovenance1SLSA provenance v1
link, vuln, openvexin-toto link, vulnerability-scan and OpenVEX predicates
customwraps the predicate bytes in cosign’s {Data, Timestamp} envelope before signing

Any other value is taken as an absolute predicate-type URI and stored byte-exact. Attesting a predicate type OCX has no built-in alias for costs nothing but the full URI. The attest reference lists the exit codes and the 15 MiB predicate size bound.

A larger file is refused with exit 65. A symlink is refused with exit 74, as an I/O error. The report’s predicate_type echoes the resolved URI, so cyclonedx reports the cyclonedx.org bom URI. A literal URI passes through unchanged.

Demand mode keeps the rule that automatic verification states for signatures. It cannot return an unverified result for a signed candidate, because an unverified attestation is no different from a text file an attacker wrote. Three failures each fail the read for that candidate:

  • a registry that cannot prove referrer support
  • a Rekor instance that will not confirm an inclusion proof
  • a certificate identity that does not match your policy

None of them falls back to “here is the content anyway.” An unsigned attach under Demand mode gets the same treatment. It is refused, not silently listed.

Permissive mode is not a hole in that rule. It is a floor the reader chooses, either because no identity source is on the table or explicitly with --no-verify. OCX never picks it for an operator who demanded verification. The mode table above has exactly one row that resolves to Permissive with an identity source present, and it requires typing --no-verify by hand. Every document Permissive mode returns is labeled verified: false, every time, with no path that upgrades it into looking checked.

Verification proves an attestation is authentic and unmodified as of when it was signed. It does not prove the attestation is current. A later SBOM or provenance record can exist for the same package. Nothing protects against rollback, so a registry could serve an earlier, still validly signed attestation instead.

That caveat applies inside Demand mode only. Permissive mode proves nothing about authenticity either way, so freshness was never on the table there.