Files
git.stella-ops.org/docs/product-advisories/06-Dec-2025 - How to Build a Verifiable SBOM→VEX Chain.md
2025-12-09 20:23:50 +02:00

17 KiB
Raw Blame History

Heres a tight, stepthrough recipe for making every VEX statement verifiably tied to build evidence—using CycloneDX (SBOM), deterministic identifiers, and attestations (intoto/DSSE).


1) Build time: mint stable, contentaddressed IDs

  • For every artifact (source, module, package, container layer), compute:

    • sha256 of canonical bytes
    • a deterministic component ID: pkg:<ecosystem>/<name>@<version>?sha256=<digest> (CycloneDX supports bom-ref; use this value as the bom-ref).
  • Emit SBOM (CycloneDX 1.6) with:

    • metadata.component = the top artifact
    • each components[].bom-ref = the deterministic ID
    • properties[] for extras: build system run ID, git commit, tool versions.

Example (SBOM fragment):

{
  "bomFormat": "CycloneDX",
  "specVersion": "1.6",
  "serialNumber": "urn:uuid:7b4f3f64-8f0b-4a7d-9b3f-7a0a2b6cf6a9",
  "version": 1,
  "metadata": {
    "component": {
      "type": "container",
      "name": "stellaops/scanner",
      "version": "1.2.3",
      "bom-ref": "pkg:docker/stellaops/scanner@1.2.3?sha256=7e1a...b9"
    }
  },
  "components": [
    {
      "type": "library",
      "name": "openssl",
      "version": "3.2.1",
      "purl": "pkg:apk/alpine/openssl@3.2.1-r0",
      "bom-ref": "pkg:apk/alpine/openssl@3.2.1-r0?sha256=2c0f...54e",
      "properties": [
        {"name": "build.git", "value": "ef3d9b4"},
        {"name": "build.run", "value": "gha-61241"}
      ]
    }
  ]
}

2) Sign the SBOM as evidence

  • Wrap the SBOM in DSSE and sign it (cosign or intoto).
  • Record to Rekor (or your offline mirror). Store the log index/UUID.

Provenance note: keep { sbomDigest, dsseSignature, rekorLogID }.


3) Normalize vulnerability findings to the same IDs

  • Your scanner should output findings where affected.bom-ref equals the components deterministic ID.
  • If using CVE/OSV, keep both the upstream ID and your local bom-ref.

Finding (internal record):

{
  "vulnId": "CVE-2024-12345",
  "affected": "pkg:apk/alpine/openssl@3.2.1-r0?sha256=2c0f...54e",
  "source": "grype@0.79.0",
  "introducedBy": "stellaops/scanner@1.2.3",
  "evidence": {"scanDigest": "sha256:aa1b..."}
}

4) Issue VEX with deterministic targets

  • Create a CycloneDX VEX doc where each vulnerabilities[].affects[].ref equals the SBOM bom-ref.
  • Use analysis.justification and analysis.state (not_affected, affected, fixed, under_investigation).
  • Add tight reasons (reachability, config, platform) and a link back to evidence via properties.

VEX (CycloneDX) minimal:

{
  "bomFormat": "CycloneDX",
  "specVersion": "1.6",
  "version": 1,
  "vulnerabilities": [
    {
      "id": "CVE-2024-12345",
      "source": {"name": "NVD"},
      "analysis": {
        "state": "not_affected",
        "justification": "vulnerable_code_not_present",
        "response": ["will_not_fix"],
        "detail": "Linked OpenSSL feature set excludes the vulnerable cipher."
      },
      "affects": [
        {"ref": "pkg:apk/alpine/openssl@3.2.1-r0?sha256=2c0f...54e"}
      ],
      "properties": [
        {"name": "evidence.sbomDigest", "value": "sha256:91f2...9a"},
        {"name": "evidence.rekorLogID", "value": "425c1d1e..."},
        {"name": "reachability.report", "value": "sha256:reacha..."},
        {"name": "policy.decision", "value": "TrustGate#R-17.2"}
      ]
    }
  ]
}

5) Sign the VEX and anchor it

  • Wrap the VEX in DSSE, sign, and (optionally) publish to Rekor (or your ProofMarket mirror).
  • Now you can verify: component digest ↔ SBOM bomref ↔ VEX affects.ref ↔ signatures/log.

6) Verifier flow (what your UI/CLI should do)

  1. Load VEX → verify DSSE signature → (optional) Rekor inclusion.
  2. For each affects.ref, check there exists an SBOM component with the exact same value.
  3. Verify the SBOM signature and Rekor entry (hash of SBOM equals what VEX references in properties.evidence.sbomDigest).
  4. Crosscheck the running artifact/container digest matches the SBOM metadata.component.bom-ref (or OCI manifest digest).
  5. Render the decision with explainable evidence (links to proofs, reachability report hash, policy rule ID).

7) Attestation shapes (quick starters)

DSSE envelope (JSON) around SBOM or VEX payload:

{
  "payloadType": "application/vnd.cyclonedx+json;version=1.6",
  "payload": "BASE64(SBOM_OR_VEX_JSON)",
  "signatures": [
    {"keyid": "SHA256-PUBKEY", "sig": "BASE64(SIG)"}
  ]
}

intoto Statement for provenance → attach SBOM hash:

{
  "_type": "https://in-toto.io/Statement/v1",
  "predicateType": "https://slsa.dev/provenance/v1",
  "subject": [{"name": "stellaops/scanner", "digest": {"sha256": "7e1a...b9"}}],
  "predicate": {
    "buildType": "stellaops/ci",
    "materials": [{"uri": "git+https://...#ef3d9b4"}],
    "metadata": {"buildInvocationID": "gha-61241"},
    "externalParameters": {"sbomDigest": "sha256:91f2...9a"}
  }
}

8) Practical guardrails (so it stays deterministic)

  • Never generate bom-ref from mutable fields (like file paths). Use content digests + stable PURL.
  • Pin toolchains and normalize JSON (UTF8, sorted keys if you posthash).
  • Store { toolVersions, feed snapshots, policy set hash } to replay decisions.
  • For containers, prefer bom-ref = pkg:oci/<repo>@<digest> PLUS layer evidence in components[].

9) “Helloworld” verification script (pseudo)

# 1) Verify SBOM sig -> get sbomDigest
cosign verify-blob --signature sbom.sig sbom.json

# 2) Verify VEX sig
cosign verify-blob --signature vex.sig vex.json

# 3) Check that every VEX affects.ref exists in SBOM
jq -r '.vulnerabilities[].affects[].ref' vex.json | while read ref; do
  jq -e --arg r "$ref" '.components[] | select(.["bom-ref"]==$r)' sbom.json >/dev/null
done

# 4) Compare running image digest to SBOM metadata.component.bom-ref

Where this fits in StellaOps (quick wiring)

  • Sbomer: emits CycloneDX with deterministic bom-refs + DSSE sig.
  • Scanner: normalizes findings to bom-ref.
  • Vexer: produces/signed VEX; includes properties back to SBOM/reachability/policy.
  • Authority/Verifier: one click “Prove it” view → checks DSSE, Rekor, and ref equality.
  • Proof Graph: edge types: produces(SBOM), affects(VEX↔component), signedBy, recordedAt(Rekor).

If you want, I can turn this into:

  • a .NET 10 helper lib for stable bom-ref generation,
  • a CLI that takes sbom.json + vex.json and runs the full verification,
  • or fixtures (golden SBOM/VEX/DSSE triplets) for your CI. Below is a developer-oriented blueprint you can hand to engineers as “How we build a verifiable SBOM→VEX chain”.

1. Objectives and Trust Model

Goal: Any VEX statement about a component must be:

  1. Precisely scoped to one or more concrete artifacts.
  2. Cryptographically linked to the SBOM that defined those artifacts.
  3. Replayable: a third party can re-run verification and reach the same conclusion.
  4. Auditable: every step is backed by signatures and immutable logs (e.g., Rekor or internal ledger).

Questions you must be able to answer deterministically:

  • “Which exact artifact does this VEX statement apply to?”
  • “Show me the SBOM where this artifact is defined, and prove it was not tampered with.”
  • “Prove that the VEX document I am looking at was authored and/or approved by the expected party.”

2. Canonical Identifiers: Non-Negotiable Foundation

You cannot build a verifiable chain without stable, content-addressed IDs.

2.1 Component IDs

For every component, choose a deterministic scheme:

  • Base: PURL or URN, e.g., pkg:maven/org.apache.commons/commons-lang3@3.14.0
  • Extend with content hash: pkg:maven/org.apache.commons/commons-lang3@3.14.0?sha256=<digest>
  • Use this value as the CycloneDX bom-ref.

Developer rule:

  • bom-ref must be:

    • Stable across SBOM regenerations for identical content.
    • Independent of local, ephemeral data (paths, build numbers).
    • Derived from canonical bytes (normalized archive/layer, not “whatever we saw on disk”).

2.2 Top-Level Artifact IDs

For images, archives, etc.:

  • Prefer OCI-style naming: pkg:oci/<repo>@sha256:<manifestDigest>
  • Set this as metadata.component.bom-ref in the SBOM.

3. SBOM Generation Guidelines

3.1 Required Properties

When emitting a CycloneDX SBOM (1.5/1.6):

  • metadata.component:

    • name, version, bom-ref.
  • components[]:

    • name, version, purl (if available), bom-ref.
    • hashes[]: include at least SHA-256.
  • properties[]:

    • Build metadata:

      • build.gitCommit
      • build.pipelineRunId
      • build.toolchain (e.g., dotnet-10.0.100, maven-3.9.9)
    • Optional:

      • provenance.statementDigest
      • scm.url

Minimal JSON fragment:

{
  "bomFormat": "CycloneDX",
  "specVersion": "1.6",
  "metadata": {
    "component": {
      "type": "container",
      "name": "example/api-gateway",
      "version": "1.0.5",
      "bom-ref": "pkg:oci/example/api-gateway@sha256:abcd..."
    }
  },
  "components": [
    {
      "type": "library",
      "name": "openssl",
      "version": "3.2.1",
      "purl": "pkg:apk/alpine/openssl@3.2.1-r0",
      "bom-ref": "pkg:apk/alpine/openssl@3.2.1-r0?sha256:1234...",
      "hashes": [
        { "alg": "SHA-256", "content": "1234..." }
      ]
    }
  ]
}

3.2 SBOM Normalization

Developer directions:

  • Normalize JSON before hashing/signing:

    • Sorted keys, UTF-8, consistent whitespace.
  • Ensure SBOM generation is deterministic given the same:

    • Inputs (image, source tree)
    • Tool versions
    • Settings/flags

4. Signing and Publishing the SBOM

4.1 DSSE Envelope

Wrap the raw SBOM bytes in a DSSE envelope and sign:

{
  "payloadType": "application/vnd.cyclonedx+json;version=1.6",
  "payload": "BASE64(SBOM_JSON)",
  "signatures": [
    {
      "keyid": "<KID>",
      "sig": "BASE64(SIGNATURE)"
    }
  ]
}

Guidelines:

  • Use a dedicated signing identity (keypair or KMS key) for SBOMs.

  • Publish signature and payload hash to:

    • Rekor or
    • Your internal immutable log / ledger.

Persist:

  • sbomDigest = sha256(SBOM_JSON).
  • sbomLogId (Rekor UUID or internal ledger ID).

5. Vulnerability Findings → Normalized Targets

Your scanners (or imports from external scanners) must map findings onto the same IDs used in the SBOM.

5.1 Mapping Rule

For each finding:

  • vulnId: CVE, GHSA, OSV ID, etc.
  • affectedRef: exact bom-ref from SBOM.
  • Optional: secondary keys (file path, package manager coordinates).

Example internal record:

{
  "vulnId": "CVE-2025-0001",
  "affectedRef": "pkg:apk/alpine/openssl@3.2.1-r0?sha256:1234...",
  "scanner": "grype@0.79.0",
  "sourceSbomDigest": "sha256:91f2...",
  "foundAt": "2025-12-09T12:34:56Z"
}

Developer directions:

  • Build a component index keyed by bom-ref when ingesting SBOMs.

  • Any finding that cannot be mapped to a known bom-ref must be flagged:

    • status = "unlinked" and either:

      • dropped from VEX scope, or
      • fixed by improving normalization rules.

6. VEX Authoring Guidelines

Use CycloneDX VEX (or OpenVEX) with a strict mapping to SBOM bom-refs.

6.1 Minimal VEX Structure

{
  "bomFormat": "CycloneDX",
  "specVersion": "1.6",
  "version": 1,
  "vulnerabilities": [
    {
      "id": "CVE-2025-0001",
      "source": { "name": "NVD" },
      "analysis": {
        "state": "not_affected",
        "justification": "vulnerable_code_not_in_execute_path",
        "response": ["will_not_fix"],
        "detail": "The vulnerable function is not reachable in this configuration."
      },
      "affects": [
        { "ref": "pkg:apk/alpine/openssl@3.2.1-r0?sha256:1234..." }
      ],
      "properties": [
        { "name": "evidence.sbomDigest", "value": "sha256:91f2..." },
        { "name": "evidence.sbomLogId", "value": "rekor:abcd-..." },
        { "name": "policy.decisionId", "value": "TRUST-ALG-001#rule-7" }
      ]
    }
  ]
}

6.2 Required Analysis Discipline

For each (vulnId, affectedRef):

  • state ∈ { not_affected, affected, fixed, under_investigation }.

  • justification:

    • vulnerable_code_not_present
    • vulnerable_code_not_in_execute_path
    • vulnerable_code_not_configured
    • vulnerable_code_cannot_be_controlled_by_adversary
    • etc.
  • detail: concrete explanation, not generic text.

  • Reference back to SBOM and other proofs via properties.

Developer rules:

  • Every affects.ref must match exactly a bom-ref in at least one SBOM.
  • VEX generator must fail if it cannot confirm this mapping.

7. Cryptographic Linking: SBOM ↔ VEX

To make the chain verifiable:

  1. Compute sbomDigest = sha256(SBOM_JSON).

  2. Inside each VEX vulnerability (or at top-level), include:

    • properties.evidence.sbomDigest = sbomDigest
    • properties.evidence.sbomLogId if a transparency log is used.
  3. Sign the VEX document with DSSE:

    • Separate key from SBOM key, or the same with different usage metadata.
  4. Optionally publish VEX DSSE to Rekor (or equivalent).

Resulting verification chain:

  • Artifact digest → matches SBOM metadata.component.bom-ref.
  • SBOM bom-refs → referenced by vulnerabilities[].affects[].ref.
  • VEX references SBOM by hash/log ID.
  • Both SBOM and VEX have valid signatures and log inclusion proofs.

8. Verifier Implementation Guidelines

You should implement a verifier library and then thin wrappers:

  • CLI
  • API endpoint
  • UI “Prove it” button

8.1 Verification Steps (Algorithm)

Given: artifact digest, SBOM, VEX, signatures, logs.

  1. Verify SBOM DSSE signature.

  2. Verify VEX DSSE signature.

  3. If using Rekor/log:

    • Verify SBOM and VEX entries:

      • log inclusion proof
      • payload hashes match local files.
  4. Confirm that:

    • artifactDigest matches metadata.component.bom-ref or the indicated digest.
  5. Build a map of bom-ref from SBOM.

  6. For each VEX affects.ref:

    • Ensure it exists in SBOM components.
    • Ensure properties.evidence.sbomDigest == sbomDigest.
  7. Compile per-component decisions:

For each component:

  • List associated VEX records.
  • Derive effective state using a policy (e.g., most recent, highest priority source).

Verifier output should be structured (not just logs), e.g.:

{
  "artifact": "pkg:oci/example/api-gateway@sha256:abcd...",
  "sbomVerified": true,
  "vexVerified": true,
  "components": [
    {
      "bomRef": "pkg:apk/alpine/openssl@3.2.1-r0?sha256:1234...",
      "vulnerabilities": [
        {
          "id": "CVE-2025-0001",
          "state": "not_affected",
          "justification": "vulnerable_code_not_in_execute_path"
        }
      ]
    }
  ]
}

9. Data Model and Storage

A minimal relational / document model:

  • Artifacts

    • id
    • purl
    • digest
    • bomRef (top level)
  • Sboms

    • id
    • digest
    • dsseSignature
    • logId
    • rawJson
  • SbomComponents

    • id
    • sbomId
    • bomRef (unique per SBOM)
    • purl
    • hash
  • VexDocuments

    • id
    • digest
    • dsseSignature
    • logId
    • rawJson
  • VexEntries

    • id
    • vexId
    • vulnId
    • affectedBomRef
    • state
    • justification
    • evidenceSbomDigest
    • policyDecisionId

Guideline: store raw JSON plus an indexed view for efficient queries.


10. Testing: Golden Chains

Developers should maintain golden fixtures where:

  • A known image or package → SBOM (JSON) → VEX (JSON) → DSSE envelopes → log entries.

  • For each fixture:

    • A test harness runs the verifier.

    • Asserts:

      • All signatures valid.
      • All affects.ref map to a SBOM bom-ref.
      • The final summarized decision for specific (vulnId, bomRef) pairs matches expectations.

Include negative tests:

  • VEX referencing unknown bom-ref → verification error.
  • Mismatching evidence.sbomDigest → verification error.
  • Tampered SBOM or VEX → signature/log verification failure.

11. Operational Practices and Guardrails

Developer-facing rules of thumb:

  1. Never generate bom-ref from mutable fields (paths, timestamps).

  2. Treat tool versions and feed snapshots as part of the “scan config”:

    • Include hashes/versions in SBOM/VEX properties.
  3. Enforce strict types in code (e.g., enums for VEX states/justifications).

  4. Keep keys and signing policies separate per role:

    • Build pipeline SBOM signer.
    • Security team VEX signer.
  5. Offer a single, stable API:

    • POST /verify:

      • Inputs: artifact digest (or image reference), SBOM+VEX or references.
      • Outputs: structured verification report.

If you want, next step I can do is sketch a small reference implementation outline (e.g., .NET 10 service with DTOs and verification pipeline) that you can drop directly into your codebase.