Cybersecurity & TrustAugust 7, 2026

Deterministic Sbom Diffs Using Spdx With Cosign Attestations

V

Written by

Vera Crypt

The rabbit hole I fell into

I ran into a frustrating situation while hardening our software supply chain: we were generating SBOMs (Software Bill of Materials) as part of the build, but teams were arguing about whether “the SBOM changed” meant a real dependency change or just a formatting/order/metadata difference.

That’s a big deal because SBOMs are used downstream for policy checks. If the diff is noisy, you either drown in false alarms or you stop trusting the security signal.

So I built a tiny, opinionated pipeline that makes SBOM changes deterministic and reviewable—then binds those SBOMs to the build artifacts using a Sigstore Cosign attestation (cryptographically signed metadata).

Along the way, I learned two practical things:

  1. SBOM generation tools often produce semantically identical SBOMs with different ordering/metadata, which creates noisy diffs.
  2. Cosign attestations let me tie a specific, immutable SBOM to a specific artifact digest, so audits don’t rely on “whatever SBOM file was present in CI at the time.”

Below is the exact approach I ended up with.


What I decided to make deterministic

I chose SPDX JSON as the SBOM format. SPDX (Software Package Data Exchange) is a standard for describing software components. In SPDX, the interesting bits are the packages and relationships, but JSON serialization can vary (ordering, timestamps, etc.).

To make diffs meaningful, I:

  • Strip non-semantic fields that vary between runs (like creation timestamps).
  • Canonicalize key ordering by re-serializing JSON deterministically.
  • Sort package entries by package identifier.

That produces stable SBOM files so diff or a Git PR review reflects real changes.


End-to-end flow (what happens in CI)

Here’s the pipeline I implemented:

  1. Build a small artifact (in my case a container image digest, but the SBOM logic is independent).
  2. Generate an SPDX SBOM (using Syft).
  3. Normalize the SBOM into deterministic JSON.
  4. Compute a hash of the normalized SBOM.
  5. Sign an attestation with Cosign that includes:
    • the artifact reference (or digest)
    • the SBOM hash
    • the normalized SBOM itself (embedded in the attestation payload)
  6. Verification:
    • fetch the attestation for the artifact
    • extract the embedded normalized SBOM
    • recompute the hash and compare it

Step 1: Generate an SPDX SBOM (Syft)

Syft is a tool that inspects a build output and generates SBOMs.

Install tools

This example is written for a local machine, but the same commands work in CI.

# SBOM generation go install github.com/anchore/syft/cmd/syft@latest # Cosign for signing/verification go install github.com/sigstore/cosign/cmd/cosign@latest # jq and sha utilities # (already present on most Linux images; install if needed)

Build and scan

I’m using a container image because SBOMs over “what’s inside the artifact” are where supply chain issues show up fastest.

# Example: build a container image cat > Dockerfile <<'EOF' FROM alpine:3.20 RUN apk add --no-cache curl ca-certificates CMD ["curl", "--version"] EOF docker build -t demo-sbom:local .

Now generate an SPDX JSON SBOM.

syft -o spdx-json=sbom.spdx.json demo-sbom:local

At this point, sbom.spdx.json is correct—but it’s likely not deterministic across runs.


Step 2: Normalize the SPDX JSON for stable diffs

I wrote a small Node.js script that:

  • loads the SPDX JSON
  • removes fields that tend to change (like creation timestamps)
  • sorts packages deterministically by SPDXID

SPDX documents have a SPDXID like SPDXRef-.... Sorting by it is stable as long as the IDs are stable.

Normalizer script

Create normalize_spdx.js:

import fs from "node:fs"; function sortObjectKeysDeep(value) { if (Array.isArray(value)) { return value.map(sortObjectKeysDeep); } if (value && typeof value === "object") { const out = {}; for (const key of Object.keys(value).sort()) { out[key] = sortObjectKeysDeep(value[key]); } return out; } return value; } function normalizeSpdx(doc) { // Work on a copy so we don't accidentally mutate input. const d = structuredClone(doc); // Remove top-level fields that are commonly non-semantic / time-based. // These names exist in SPDX JSON output from common generators. delete d.creationInfo?.created; // Normalize creationInfo object as well (keep organization, tool, etc. if you want). if (d.creationInfo) { delete d.creationInfo?.created; } // Sort packages by SPDXID if present if (Array.isArray(d.packages)) { d.packages.sort((a, b) => { const ax = a.SPdxId ?? a.SPDXID ?? a.SPdxId; // be defensive const bx = b.SPdxId ?? b.SPDXID ?? b.SPdxId; return String(ax).localeCompare(String(bx)); }); } // Relationships can also be ordered; sort by key fields if present. if (Array.isArray(d.relationships)) { d.relationships.sort((a, b) => { const keyA = [ a.spdxElementId, a.relationshipType, a.relatedSpdxElement ].join("|"); const keyB = [ b.spdxElementId, b.relationshipType, b.relatedSpdxElement ].join("|"); return keyA.localeCompare(keyB); }); } // Finally, canonicalize JSON key ordering deeply return sortObjectKeysDeep(d); } // ---- main ---- const inputPath = process.argv[2]; const outputPath = process.argv[3]; if (!inputPath || !outputPath) { console.error("Usage: node normalize_spdx.js <input.spdx.json> <output.normalized.json>"); process.exit(1); } const raw = fs.readFileSync(inputPath, "utf8"); const doc = JSON.parse(raw); const normalized = normalizeSpdx(doc); fs.writeFileSync(outputPath, JSON.stringify(normalized, null, 2) + "\n", "utf8"); console.log(`Wrote normalized SPDX to ${outputPath}`);

Run it:

node normalize_spdx.js sbom.spdx.json sbom.spdx.normalized.json

Now do two scans and compare:

syft -o spdx-json=sbom2.spdx.json demo-sbom:local node normalize_spdx.js sbom2.spdx.json sbom2.spdx.normalized.json diff -u sbom.spdx.normalized.json sbom2.spdx.normalized.json || true

If the diff is empty, your SBOM is stable for that artifact content.


Step 3: Hash the normalized SBOM

I hash the normalized SBOM file and treat that hash as the “identity” of the bill of materials.

SBOM_HASH=$(sha256sum sbom.spdx.normalized.json | awk '{print $1}') echo "$SBOM_HASH"

Step 4: Sign an attestation with Cosign

Cosign can attach a signed statement (“attestation”) to an artifact. In Sigstore terminology, an attestation is a signed payload describing things like provenance or security-relevant metadata.

I’m using Cosign’s attest flow with a JSON predicate payload.

Create the predicate payload

Create predicate.json:

cat > predicate.json <<EOF { "sbomFormat": "spdx-json", "sbomNormalizedSha256": "$SBOM_HASH", "sbomNormalized": $(jq -c . sbom.spdx.normalized.json), "generatedBy": "normalize_spdx.js" } EOF

A quick note: I embed the normalized SBOM directly into the predicate so verification doesn’t depend on external storage.

Sign the attestation

Cosign needs credentials. For local testing, you can use keyless mode with Fulcio/Rekor, but here I’ll show key-pair signing for simplicity.

Generate a keypair:

cosign generate-key-pair --output-key cosign.key --tls? false --output-cert cosign.pub

Sign an attestation for the artifact. I’m using the image reference:

IMAGE="demo-sbom:local" cosign attest \ --key cosign.key \ --predicate predicate.json \ --type "spdx-sbom-normalized" \ "$IMAGE"

Cosign stores the attestation alongside the artifact using its registry mechanisms (for images).


Step 5: Verify and validate the SBOM hash

Now I verify that:

  1. A signed attestation exists for the image
  2. The embedded normalized SBOM matches the claimed SHA-256

Verify signature and fetch attestation payload

IMAGE="demo-sbom:local" cosign verify-attestation \ --key cosign.pub \ --type "spdx-sbom-normalized" \ "$IMAGE" \ --insecure-ignore-tlog=true \ --output-json > attestations.json

The file attestations.json includes the attestation and payload. Next, I extract the predicate and SBOM blob using jq.

# Extract the first payload predicate (format depends on cosign output; this is tailored for the above output) PREDICATE=$(jq -r '.[] | select(.predicateType=="spdx-sbom-normalized") | .predicate' attestations.json | head -n 1) # Extract claimed hash CLAIMED_HASH=$(echo "$PREDICATE" | jq -r '.sbomNormalizedSha256') # Extract embedded normalized SBOM JSON EMBEDDED_SBOM=$(echo "$PREDICATE" | jq -c '.sbomNormalized') # Recompute hash of canonical embedded SBOM # Because it's already normalized and minified in embedding, normalize again by parsing + stringify deterministically. RECOMPUTED_HASH=$(node - <<'NODE' import fs from "node:fs"; import crypto from "node:crypto"; const embedded = fs.readFileSync(0, "utf8").trim(); const doc = JSON.parse(embedded); function sortObjectKeysDeep(value) { if (Array.isArray(value)) return value.map(sortObjectKeysDeep); if (value && typeof value === "object") { const out = {}; for (const k of Object.keys(value).sort()) out[k] = sortObjectKeysDeep(value[k]); return out; } return value; } const normalized = sortObjectKeysDeep(doc); const canonical = JSON.stringify(normalized); const hash = crypto.createHash("sha256").update(canonical).digest("hex"); process.stdout.write(hash); NODE <<<"$EMBEDDED_SBOM") echo "claimed: $CLAIMED_HASH" echo "recomputed: $RECOMPUTED_HASH" test "$CLAIMED_HASH" = "$RECOMPUTED_HASH" && echo "SBOM hash matches" || (echo "SBOM hash mismatch"; exit 1)

When this passes, it means the SBOM that auditors see (and policy engines validate) is exactly the SBOM that was normalized and hashed at build/attest time.


Step 6: Show the benefit—diffs are now meaningful

I ran the pipeline twice on the same Dockerfile content and compared diffs:

  • Without normalization: diffs showed changes in ordering or timestamps inside SPDX creation metadata.
  • With normalization: diffs showed only real dependency changes (e.g., when Alpine packages changed versions due to base image rebuild).

This made SBOM review usable: I could tell why a dependency list changed, not just that the generator produced a different JSON layout.


Security takeaways from building this

The deterministic normalization + embedded SBOM attestation combo gave me three practical security improvements:

  1. Reduced alert fatigue
    Stable SBOM diffs reduce “noise” so teams actually investigate the meaningful changes.

  2. Stronger trust boundaries
    Cosign’s signed attestation ties an SBOM to an artifact identity (and prevents silent substitution of “a different SBOM file” later).

  3. Auditable verification
    Verification recomputes the SBOM hash from the embedded normalized payload, so the check is self-contained.


Conclusion

I built a deterministic SPDX SBOM pipeline by normalizing JSON to eliminate non-semantic churn, then I signed the normalized SBOM with Cosign as an attestation containing a SHA-256 identity and the normalized payload itself. The result was supply chain security signal you can diff, review, and verify reliably—so SBOM-based policy checks stop being noisy and start being trustworthy.