background-shape
Supply Chain Security for AI Models, Signing and SBOM
September 24, 2025 · 9 min read · by Muhammad Amal programming

TL;DR — Treat models like containers; sign them with cosign 2.5 to Sigstore, attach an SBOM describing weights, training data, and dependencies, and verify both at runtime with Kyverno admission policies. The cryptographic story is solved; what’s hard is the SBOM schema and the operational discipline to actually verify.

In 2024 a research group quietly demonstrated that you could upload a backdoored model to Hugging Face, give it a plausible name, and have it get pulled by a non-trivial number of production deployments within 48 hours. Nobody noticed for weeks. The same year, an open-source ML library shipped with a typo-squatted dependency that was published by an attacker. ML supply chain security stopped being a thought experiment.

The good news is that the tools to fix this matured fast. Cosign 2.5 (April 2025) handles model signing natively. Sigstore’s transparency log gives you a public, verifiable record of every signing event. CycloneDX 1.6 added an ML-BOM section that actually represents what’s in a model artifact. Kyverno 1.13 verifies signatures at admission. The puzzle pieces are all there.

This tutorial wires them together for a realistic model lifecycle: train, sign, attest, distribute, verify. I’m going to assume you’ve read the DevSecOps post earlier in this series since the CI patterns there are what produce signed models in the first place.

1. The Threat Surface

What can go wrong in an AI model supply chain.

+----------+    +---------+    +---------+    +----------+    +--------+
| training | -> | model   | -> | model   | -> | registry | -> | runtime|
| data     |    | weights |    | bundle  |    |          |    |        |
+----+-----+    +----+----+    +----+----+    +----+-----+    +---+----+
     |               |              |               |              |
     v               v              v               v              v
  poisoning      backdoor       tampering      account         loading
  unauthorized   embedded       in transit     compromise      wrong
  inclusion      in weights                    typo-squat      model

Five distinct threats. We can address (2) through (5) with cryptographic signing and verification. (1) is data provenance, which we touched on in the DevSecOps post and which sits underneath signing.

2. Step 1, Signing Model Artifacts

Cosign 2.5 signs blobs, OCI artifacts, or git tags. For models we’ll use blob signing because most teams aren’t yet pushing models to OCI registries.

# Keyless signing via Sigstore (works in CI with OIDC)
cosign sign-blob \
  --bundle model-v42.bundle \
  --yes \
  ./checkpoints/model-v42.safetensors

The output is a Sigstore bundle: a JSON file containing the signature, the certificate (issued by Fulcio against your OIDC identity), and the inclusion proof in the Rekor transparency log. Store the bundle next to the model.

checkpoints/
├── model-v42.safetensors      <- the artifact
├── model-v42.bundle           <- Sigstore bundle (signature + cert + log proof)
└── model-v42.attest.bundle    <- attestation bundle (see next section)

2.1 Why keyless

Traditional signing means managing keys: rotating them, securing them in an HSM, recovering when they leak. Keyless signing replaces that with OIDC: the signer authenticates to Sigstore as a workflow identity (GitHub Actions, GitLab CI, etc.), Fulcio issues a short-lived certificate bound to that identity, the signing happens with that cert, the proof goes into Rekor.

Result: no long-lived keys, and the signature includes the OIDC identity that produced it. Verifiers check both “is the signature cryptographically valid” and “was this identity authorized to sign this artifact.”

3. Step 2, Attestations Beyond Signature

A signature says “this model came from this workflow.” An attestation says “this model came from this workflow, was trained on data version X, achieved evaluation score Y, used base model Z, and was signed off by reviewers A and B.” Cosign supports in-toto attestations natively.

# Build the attestation predicate
cat > attestation.json <<EOF
{
  "training": {
    "workflow": "$GITHUB_WORKFLOW",
    "run_id": "$GITHUB_RUN_ID",
    "commit": "$GITHUB_SHA"
  },
  "data": {
    "version": "v1.5.0",
    "hash": "$(sha256sum data/train.parquet | cut -d' ' -f1)",
    "license": "CC-BY-4.0",
    "row_count": 1240000
  },
  "model": {
    "base": "meta-llama/Llama-3.2-3B",
    "params": "3.21B",
    "hash": "$(sha256sum checkpoints/model-v42.safetensors | cut -d' ' -f1)"
  },
  "eval": {
    "suite_version": "v0.4",
    "accuracy": 0.918,
    "safety_pass_rate": 0.997
  },
  "reviewers": ["alice@example.com", "bob@example.com"]
}
EOF

cosign attest-blob \
  --predicate attestation.json \
  --type custom \
  --bundle model-v42.attest.bundle \
  ./checkpoints/model-v42.safetensors

Now the model has two cryptographic artifacts: a signature and an attestation. Both are tied to the model hash; neither can be transferred to a different model without re-signing.

3.1 Reading attestations

cosign verify-blob-attestation \
  --bundle model-v42.attest.bundle \
  --certificate-identity 'https://github.com/myorg/ml-pipeline/.github/workflows/train.yml@refs/heads/main' \
  --certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \
  --type custom \
  ./checkpoints/model-v42.safetensors \
  | jq -r '.payload' | base64 -d | jq .

The payload comes back as the JSON we put in. Programmatic consumers can extract any field for further verification (refuse to load a model with accuracy below 0.9, refuse if data license is “research-only,” etc.).

4. Step 3, ML SBOM with CycloneDX 1.6

CycloneDX 1.6 added an mlBom field that captures ML-specific bill-of-materials data. The schema covers model architecture, training datasets, frameworks, and downstream relationships.

{
  "bomFormat": "CycloneDX",
  "specVersion": "1.6",
  "serialNumber": "urn:uuid:9c5b6a3e-...",
  "version": 1,
  "metadata": {
    "timestamp": "2025-09-20T14:00:00Z",
    "tools": [{ "name": "ml-sbom-gen", "version": "0.4" }]
  },
  "components": [
    {
      "type": "machine-learning-model",
      "bom-ref": "model-v42",
      "name": "support-classifier",
      "version": "42.0",
      "modelCard": {
        "modelParameters": {
          "approach": { "type": "supervised" },
          "task": "classification",
          "architectureFamily": "transformer",
          "modelArchitecture": "Llama-3.2-3B-fine-tuned",
          "datasets": [
            {
              "ref": "dataset:internal-support-2025-08"
            }
          ]
        },
        "considerations": {
          "users": ["support team"],
          "useCases": ["ticket triage"],
          "ethicalConsiderations": [
            {"name": "PII", "description": "trained on redacted PII"}
          ]
        }
      }
    },
    {
      "type": "data",
      "bom-ref": "dataset:internal-support-2025-08",
      "name": "internal-support-tickets",
      "version": "2025-08",
      "hashes": [{"alg": "SHA-256", "content": "8c2f5e..."}],
      "licenses": [{"license": {"id": "Proprietary"}}]
    },
    {
      "type": "library",
      "name": "torch",
      "version": "2.4.1",
      "purl": "pkg:pypi/torch@2.4.1"
    }
  ]
}

Attach the SBOM as a second attestation:

cosign attest-blob \
  --predicate model-v42.cdx.json \
  --type cyclonedx \
  --bundle model-v42.sbom.bundle \
  ./checkpoints/model-v42.safetensors

Now the model carries: signature, custom attestation, CycloneDX SBOM. All cryptographically bound to the artifact hash.

4.1 Generating SBOMs

For Python-based training, pip-audit and cyclonedx-bom produce the library section automatically:

pip install cyclonedx-bom==4.5
cyclonedx-py poetry --output-format json --output-file libs.cdx.json

You’ll need to write a small script that merges this library SBOM with the model-specific section from your training pipeline metadata. There’s no single tool that does both cleanly as of late 2025.

5. Step 4, Verification at Runtime

Signing without verifying is theater. The runtime gate is Kyverno on Kubernetes; for non-K8s environments, do it in your model loader.

5.1 Kyverno admission policy

apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: verify-models
spec:
  validationFailureAction: Enforce
  rules:
    - name: verify-model-signature
      match:
        resources:
          kinds: [Pod]
          namespaces: [inference]
      verifyImages:
        - imageReferences: ["registry.internal/models/*"]
          attestors:
            - entries:
                - keyless:
                    issuer: "https://token.actions.githubusercontent.com"
                    subject: "https://github.com/myorg/ml-pipeline/.github/workflows/train.yml@refs/heads/main"
          mutateDigest: true
          required: true
          attestations:
            - type: "custom"
              attestors:
                - entries:
                    - keyless:
                        issuer: "https://token.actions.githubusercontent.com"
                        subject: "https://github.com/myorg/ml-pipeline/.github/workflows/train.yml@refs/heads/main"
              conditions:
                - all:
                    - key: "{{ predicate.eval.accuracy }}"
                      operator: GreaterThanOrEquals
                      value: 0.85
                    - key: "{{ predicate.eval.safety_pass_rate }}"
                      operator: GreaterThanOrEquals
                      value: 0.99

Two things happen: signature verification (the keyless attestor block) and predicate verification (the conditions block reads fields from the custom attestation and refuses if accuracy or safety fall below thresholds).

5.2 In-process verification for blob models

When the model is a file pulled from S3, do the verification in the loader:

import subprocess
import json
from pathlib import Path

def verify_model(model_path: Path, bundle_path: Path, attest_path: Path):
    # Signature
    r = subprocess.run([
        "cosign", "verify-blob",
        "--bundle", str(bundle_path),
        "--certificate-identity-regexp",
        "https://github.com/myorg/ml-pipeline/.github/workflows/train.yml@refs/.*",
        "--certificate-oidc-issuer",
        "https://token.actions.githubusercontent.com",
        str(model_path),
    ], check=True, capture_output=True)

    # Attestation
    r = subprocess.run([
        "cosign", "verify-blob-attestation",
        "--bundle", str(attest_path),
        "--certificate-identity-regexp",
        "https://github.com/myorg/ml-pipeline/.github/workflows/train.yml@refs/.*",
        "--certificate-oidc-issuer",
        "https://token.actions.githubusercontent.com",
        "--type", "custom",
        str(model_path),
    ], check=True, capture_output=True, text=True)

    payload = json.loads(
        bytes.fromhex(json.loads(r.stdout)["payload"]).decode()
    )
    if payload["eval"]["safety_pass_rate"] < 0.99:
        raise RuntimeError("safety threshold not met")
    return payload

def load_model(path: Path):
    bundle = path.with_suffix(path.suffix + ".bundle")
    attest = path.with_suffix(path.suffix + ".attest.bundle")
    verify_model(path, bundle, attest)
    return safetensors.torch.load_file(str(path))

load_model refuses to load anything that doesn’t pass verification. The first time someone tries to drop a hand-trained model into production, they get a hard fail rather than a silent success.

6. The Full Flow

   training pipeline
         |
         v
   model artifact (safetensors)
         |
         +---> sha256 hash
         |
         v
   cosign sign-blob ----> Sigstore (Fulcio cert + Rekor log)
         |
         +---> model.bundle
         |
         v
   cosign attest-blob (custom predicate)
         |
         +---> model.attest.bundle
         |
         v
   cosign attest-blob (cyclonedx SBOM)
         |
         +---> model.sbom.bundle
         |
         v
   upload to S3 / OCI registry
         |
         v
   runtime: verify-blob + verify-attestation + load

Five files: the model, the signature bundle, the attestation bundle, the SBOM bundle, and (optionally) a metadata.json that points to all of them. Treat that quintet as the atomic unit of model delivery.

7. Common Pitfalls

Four mistakes I see often.

7.1 Signing in CI without verifying anywhere

Adopting cosign because someone said you should and then never verifying the signatures. The signature is metadata until the verifier exists. Build the verifier first.

7.2 Pinning the wrong workflow identity

Cosign verification checks the workflow path. If you move the workflow to a new file path or rename the branch, every existing signature becomes unverifiable. Use --certificate-identity-regexp for some flexibility, but don’t make it so loose that anyone can publish from any branch and pass.

7.3 Attesting without verifying predicate values

Adding an attestation that says “accuracy 0.5” doesn’t help if nobody checks the value. Wire predicate conditions into Kyverno or your in-process verifier. Otherwise the attestation is decoration.

7.4 SBOM without licenses

A CycloneDX SBOM that omits the dataset license fields is half-useful. The licensing of training data is one of the things you most want to track. Make license fields required in your SBOM generation tooling and reject SBOMs missing them.

8. Troubleshooting

Three common failure modes.

8.1 Verification fails on identical model

A model that verified yesterday fails today. Most often: Sigstore’s certificate is short-lived and the Rekor log entry’s inclusion proof requires a fresh timestamp. Make sure your verifier connects to Rekor (--rekor-url if you’re not using the public instance) and your environment’s CA bundle is up to date.

8.2 Kyverno policy admits unsigned models

The policy validationFailureAction: Audit only logs violations rather than blocking. Make sure your production environment uses Enforce. Common mistake when copying a policy from staging.

8.3 SBOM generation OOMs

A model with millions of parameters and a multi-GB training set can produce a CycloneDX SBOM that takes hundreds of MB to generate, mostly from naive serialization. Use streaming JSON writers and aggregate dataset components instead of listing every file.

9. Wrapping Up

ML supply chain security in 2025 has the same shape as software supply chain security circa 2022: the tools are there, the patterns are there, and what’s missing is operational discipline. Sign every artifact, attach an SBOM, verify at every consumer, and refuse to run anything that doesn’t pass. The hardest part is organizational, not technical.

The signature-and-attestation pattern composes nicely with the other security layers in this series. Combined with the DevSecOps pipeline for producing signed artifacts and Kyverno policies for enforcement, you have a coherent story for “this model is the one we think it is, trained on data we authorized, with quality and safety we measured.”

For more reading, the Sigstore documentation covers signing and verification in depth, the CycloneDX ML-BOM spec describes the SBOM schema, and the in-toto attestation spec is the underlying standard. This closes out the September security series; the next series picks up with hands-on agent engineering.