Compliance Evidence Bundle Pipeline

Build a contract-enforced compliance bundle a reviewer can interrogate cold.

Introduction

30 Second Summary

When someone asks "prove your software is secure," the evidence is usually scattered across different folders, chat threads, and someone's memory. A reviewer who did not build the software has no reliable way to check whether the artifacts are complete or authentic.

In this project, you will build a compliance evidence bundle that packages security artifacts into a single, contract-enforced package keyed by its content digest. You will define the contract before any artifact exists, prove it rejects incomplete bundles, and hand the result to a reviewer who answers control questions from it cold.

What You'll Build

Picture handing a single bundle to a reviewer who has never seen your code and watching them answer three security control questions by citing values straight from inside it.

By the end of this project, you'll have:

  • A contract-first pipeline where you write three control questions from NIST SP 800-53 Rev 5 and a six-slot JSON Schema before any artifact exists, then run the validator and watch it reject incomplete bundles by name.
  • Four real security artifacts from a single build run (a CycloneDX SBOM, a vulnerability scan, a provenance signature, and a build-environment snapshot) assembled into a manifest named by its SHA-256 content digest.
  • A one-command retrieve that pulls the bundle from a local S3-compatible store by its digest and re-computes the hash to verify nothing was substituted.
  • Secret Mission: an optional challenge to push your skills further.

Are there any prerequisites?

This project assumes you have Python 3.10+, Git, and a code editor (Cursor or VS Code) installed on a Windows workstation. You also need a GitHub account and comfort with command-line tools and JSON.

Before We Start

Before any tools are installed or artifacts generated, this is the moment to lock in what you are building and why it matters.

The compliance evidence bundle you create only proves something if the person reviewing it did not build it. That independence is the whole point.

Install the Toolchain

The bundle is only as real as the tools that produce its artifacts. A scan result from a scanner you cannot identify is itself unidentified. Each tool must be present and version-verified before any artifact is generated.

In this step, you will install four specialized tools: Syft for generating software bills of materials, Grype for vulnerability scanning, cosign for cryptographic signing, and SeaweedFS for S3-compatible local storage. You will also set up a Python environment and create the signing key pair that anchors provenance.

In this step, get ready to:
  • Install Syft, Grype, cosign, and SeaweedFS and verify each with its version command.
  • Create a Python virtual environment and install project dependencies.
  • Generate a cosign key pair for provenance signing and initialize a Git repository.
Install the scanning, signing, and storage tools

You need four tools on your machine before you can generate any artifacts. Two install through winget and two require a manual download from GitHub.

  • Press the Windows key to open your search bar.
  • Type PowerShell and press Enter to open it.
  • Install Syft by running this command:
winget install Anchore.Syft

What is Syft?

Syft scans a directory or container image and produces a software bill of materials (SBOM). Your bundle uses the SBOM to answer the question: what components are in this build?

  • Confirm Syft installed by running:
syft version

✔️ I see a version number

Syft is ready. Move on to installing Grype.

ⓧ Command not found

After a winget install, the current PowerShell session does not pick up the new PATH entry automatically.

  • Refresh your PATH by running:
$env:Path = [System.Environment]::GetEnvironmentVariable('Path','Machine') + ';' + [System.Environment]::GetEnvironmentVariable('Path','User')
  • Run syft version again.
  • If it still fails, close PowerShell entirely and reopen it, then try the version command once more.
  • Install Grype by running this command:
winget install Anchore.Grype

What is Grype?

Grype takes an SBOM as input and checks every component against known vulnerability databases. Your bundle uses it to answer: does this build contain any known vulnerabilities?

  • Confirm Grype installed by running:
grype version

✔️ I see a version number

Grype is ready. Next you will install cosign and SeaweedFS manually.

ⓧ Command not found

  • Refresh your PATH by running:
$env:Path = [System.Environment]::GetEnvironmentVariable('Path','Machine') + ';' + [System.Environment]::GetEnvironmentVariable('Path','User')
  • Run grype version again.
  • If it still fails, close and reopen PowerShell, then retry.

cosign and SeaweedFS are not available through winget. You will download each binary from its GitHub releases page and place both in a shared tools directory on your PATH.

  • Create a tools directory by running:
mkdir C:\tools -Force
  • Open the cosign v3.1.3 release page in your browser:

https://github.com/sigstore/cosign/releases/tag/v3.1.3

  • Scroll down to the Assets section.
  • Click cosign-windows-amd64.exe to download it.
  • Move and rename the downloaded file to C:\tools by running:
Move-Item ~\Downloads\cosign-windows-amd64.exe C:\tools\cosign.exe
  • Confirm cosign.exe is in place by running:
ls C:\tools\cosign.exe

You should see the file listed with its size (around 190 MB).

  • Open the SeaweedFS releases page in your browser:

https://github.com/seaweedfs/seaweedfs/releases/tag/4.42

  • Scroll down to the Assets section.
  • Click windows_amd64.zip to download it.
  • Extract weed.exe from the zip and move it into C:\tools by running:
Expand-Archive ~\Downloads\windows_amd64.zip -DestinationPath ~\Downloads\seaweedfs -Force
Move-Item ~\Downloads\seaweedfs\weed.exe C:\tools\weed.exe

Move-Item says the file already exists?

If you downloaded the file more than once, Windows may have renamed it (for example windows_amd64 (1).zip). Check your Downloads folder for the actual filename and adjust the command.

If weed.exe already exists in C:\tools from a previous attempt, add -Force to the Move-Item command to overwrite it.

help me move weed.exe into C:\tools on Windows

  • Add C:\tools to your PATH permanently and update the current session by running:
[Environment]::SetEnvironmentVariable('Path', $env:Path + ';C:\tools', 'User')
$env:Path += ';C:\tools'

What do these two lines do?

The first line writes C:\tools into your permanent user PATH so every future terminal session can find the binaries.

The second line updates the current session immediately so you do not have to close and reopen PowerShell.

  • Verify cosign is available by running:
cosign version

✔️ I see a version string

cosign is ready.

ⓧ Command not found

  • Confirm cosign.exe exists in C:\tools by running ls C:\tools.
  • If the file is missing, re-download from the releases page and repeat the Move-Item command.
  • If the file is present, confirm your PATH includes C:\tools by running $env:Path -split ';' and looking for the entry.
  • Verify SeaweedFS is available by running:
weed version

✔️ I see a version string

All four tools are installed and on your PATH.

ⓧ Command not found

  • Confirm weed.exe exists in C:\tools by running ls C:\tools.
  • If the zip extracted into a subfolder, look inside ~\Downloads\seaweedfs for a nested folder containing weed.exe and move it to C:\tools.
  • If the file is present but the command still fails, confirm your PATH includes C:\tools by running $env:Path -split ';'.
Set up the Python environment

Your project needs two Python packages: jsonschema for validating the bundle contract and boto3 for uploading to SeaweedFS. A virtual environment keeps these isolated from your system Python.

  • Move to your Desktop by running this command:
cd ~\Desktop
  • Create your project folder and move into it by running these commands:
mkdir evidence-bundle
cd evidence-bundle
  • Create a Python virtual environment by running:
python -m venv .venv
  • Activate the virtual environment by running:
.venv\Scripts\activate

Your prompt now shows (.venv) at the beginning. Every pip install from here goes into this isolated environment.

Don't see (.venv) in your prompt?

PowerShell's execution policy may block the activation script. Run this command to allow it for the current session, then retry the activation command:

Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass

help me activate a Python virtual environment on Windows PowerShell

  • Open the evidence-bundle folder in your code editor. If you are using VS Code, run:
code .

Using Cursor?

Run cursor . instead. Both editors work the same way for this project.

  • In your code editor, create a new file called requirements.txt in the root of your evidence-bundle folder with this content:
jsonschema==4.26.0
boto3==1.43.75
  • Save requirements.txt.
  • Install the dependencies by running this command in PowerShell:
pip install -r requirements.txt

What are these packages?

jsonschema validates JSON documents against a JSON Schema. You will use it to enforce the bundle contract: every slot must be present and every READY slot must have a file and hash.

boto3 is the AWS SDK for Python. SeaweedFS speaks the S3 protocol, so boto3 can upload and download bundles from it as if it were Amazon S3.

  • Confirm the packages installed by running:
python -c "import jsonschema; print(jsonschema.__version__)"

You should see 4.26.0 printed.

Seeing a ModuleNotFoundError?

Make sure your prompt shows (.venv). If it does not, run .venv\Scripts\activate again and then retry pip install -r requirements.txt.

help me install Python packages into a virtual environment on Windows

✔️ Awesome, I've got everything!

Great. Double check you have saved requirements.txt.

ⓧ I'd like to double check the full code

Your requirements.txt should contain exactly this:

jsonschema==4.26.0
boto3==1.43.75
Generate a cosign key pair and initialize a Git repository

The provenance slot in your bundle holds a cryptographic signature over the manifest. That signature comes from a cosign key pair you generate now. The public key gets committed to the repository so anyone retrieving the bundle can verify the signature without asking the author.

  • Generate a cosign key pair by running these commands:
$env:COSIGN_PASSWORD = ""
cosign generate-key-pair

What does this create?

Setting COSIGN_PASSWORD to an empty string tells cosign to create a passwordless key pair for local development. cosign writes two files: cosign.key (the private key, stays local) and cosign.pub (the public key, safe to share).

  • Confirm both files exist by running:
ls cosign.*

You should see cosign.key and cosign.pub listed.

  • Initialize a Git repository and commit the public key by running:
git init
git add cosign.pub
git commit -m "Add cosign public key"

Why only the public key?

cosign.key is your private signing key. Committing it to a repository would let anyone forge signatures. Only cosign.pub goes into version control so verifiers can check signatures without access to the private key.

You should see a message confirming the commit with one file changed.

Git asking for your name and email?

If Git shows an error about identity, configure it for this repository by running:

git config user.name "Your Name"
git config user.email "your@email.com"

Then retry the git commit command.

help me configure Git identity for a new repository on Windows

Before you run the final check, predict: how many tools should return a version string without errors?

  • Run all five version checks in sequence:
syft version
grype version
cosign version
weed version
python -c "import jsonschema; print(jsonschema.__version__)"

All five commands return version strings without errors. Your toolchain is verified and ready to produce artifacts.

One of the commands failed?

If syft or grype fails, refresh your PATH as shown in the earlier outcome tabs: $env:Path = [System.Environment]::GetEnvironmentVariable('Path','Machine') + ';' + [System.Environment]::GetEnvironmentVariable('Path','User').

If cosign or weed fails, confirm C:\tools is on your PATH with $env:Path -split ';' and that both .exe files are in that directory.

help me get all five tools (syft, grype, cosign, weed, jsonschema) to return version strings in PowerShell

Your toolchain is locked in. Every tool that will produce an artifact for the bundle is installed and version-verified. Next up, you will write the contract that defines what the bundle must contain before any artifact exists.

Write the Contract Before the Artifacts

Your toolchain is installed and verified. Every tool that produces artifacts is ready to run. Now the critical move: the contract must exist before any artifact does.

Control questions written after artifacts can be shaped to fit what the bundle happens to contain. Committing the questions and the schema first means the Git history corroborates the ordering. It does not prove it, because a git history can be amended. But shaping questions to fit artifacts that do not yet exist is harder than shaping them to fit artifacts you can see.

In this step, get ready to:
  • Write three control questions from NIST SP 800-53 Rev 5 and a six-slot JSON Schema with conditional validation.
  • Validate the schema rejects an empty manifest and a STUBBED slot missing its successor.
  • Record five design decisions as markdown files and commit the entire design checkpoint.
Write the control questions and evidence bundle schema

The three questions are drawn from NIST SP 800-53 Rev 5, a published catalog the author does not control. That sourcing bounds the gaming.

  • Create a new file called control_questions.md in your project directory and paste the following content:
# Control Questions

Three questions drawn from NIST SP 800-53 Rev 5 before any artifact exists.
Committed before build. Sourced from a published catalog the author does not control.
That sourcing bounds the gaming; the commit order corroborates it and does not prove it,
because a git history can be amended.

## Q1: RA-5 Vulnerability Monitoring and Scanning

Does the `vuln-scan` slot contain scan results dated within this release window,
and what is the highest-severity finding identifier?

Answerable from: `vuln-scan` slot artifact (`vuln-scan.json`)

## Q2: CM-8 System Component Inventory

Does the `sbom` slot enumerate every component in the build,
and what is the version of a named dependency?

Answerable from: `sbom` slot artifact (`sbom.cdx.json`)

## Q3: SI-7 Software, Firmware, and Information Integrity

Does the `provenance` slot contain a verifiable signature over the manifest,
and what is the digest of the signed manifest?

Answerable from: `provenance` slot artifact (`provenance.sigstore.json`)
and the unsigned manifest (`manifest-unsigned.json`)

What do these questions test?

Each question maps to a NIST control that applies at Low, Moderate, and High baselines.

  • Q1 (RA-5): Can the reviewer find a specific vulnerability finding in the scan results?
  • Q2 (CM-8): Can the reviewer name the version of a specific dependency from the SBOM?
  • Q3 (SI-7): Can the reviewer verify the bundle's provenance signature and identify the signed digest?
  • Save control_questions.md.
  • Confirm the file exists by running:
ls control_questions.md

You should see control_questions.md listed in the output.

Now for the schema. The JSON Schema declares six named slots and uses additionalProperties: false so a slot dropped from the schema cannot pass unseen.

  • Create a new file called bundle_schema.json and paste the top-level structure as the beginning of the file:
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "title": "Evidence Bundle Manifest",
  "description": "Contract for a compliance evidence bundle with six named slots.",
  "type": "object",
  "required": ["bundle_id", "release", "created_at", "slots"],
  "properties": {
    "bundle_id": {
      "type": "string",
      "pattern": "^sha256:[a-f0-9]{64}$",
      "description": "SHA-256 content digest of this manifest (excluding this field)."
    },
    "release": { "type": "string" },
    "created_at": { "type": "string" },
    "slots": {
      "type": "object",
      "required": ["sbom", "vuln-scan", "hardening-check", "provenance", "build-env", "control-map"],
      "additionalProperties": false,
      "properties": {
        "sbom": { "$ref": "#/$defs/slot" },
        "vuln-scan": { "$ref": "#/$defs/slot" },
        "hardening-check": { "$ref": "#/$defs/slot" },
        "provenance": { "$ref": "#/$defs/slot" },
        "build-env": { "$ref": "#/$defs/slot" },
        "control-map": { "$ref": "#/$defs/slot" }
      }
    }
  },

What does this top-level structure define?

  • The bundle_id field requires a SHA-256 digest pattern. This becomes the bundle's address.
  • The slots object lists all six required slot names. Because additionalProperties is false, a slot that is not one of these six causes validation to fail.
  • Each slot references a shared definition at #/$defs/slot that you will paste next.
  • Directly after the trailing comma on the last line, paste the slot definition that completes the schema:
  "$defs": {
    "slot": {
      "type": "object",
      "required": ["state"],
      "properties": {
        "state": { "type": "string", "enum": ["READY", "STUBBED"] },
        "file": { "type": "string" },
        "content_sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" },
        "successor_build": { "type": "string", "minLength": 1 }
      },
      "allOf": [
        {
          "if": { "properties": { "state": { "const": "READY" } }, "required": ["state"] },
          "then": { "required": ["state", "file", "content_sha256"] }
        },
        {
          "if": { "properties": { "state": { "const": "STUBBED" } }, "required": ["state"] },
          "then": { "required": ["state", "successor_build"] }
        }
      ]
    }
  }
}

What do the if/then rules enforce?

The allOf array contains two conditional rules inside each slot.

  • When state is READY, the slot must have a file name and a content_sha256 hash.
  • When state is STUBBED, the slot must name a successor_build with at least one character (minLength: 1 prevents empty strings).
  • Save bundle_schema.json.
  • Confirm both contract files exist by running:
ls control_questions.md bundle_schema.json

You should see both files listed.

Seeing a JSON parse error?

  • Open bundle_schema.json and check that the closing } from the first block and the "$defs" from the second block meet with a comma between them, not two commas or no comma.
  • Verify the file ends with exactly two closing braces: one for $defs and one for the root object.
  • help me debug a JSON parse error in my bundle_schema.json

✔️ Awesome, I've got everything!

Great. Make sure you have saved bundle_schema.json.

ⓧ I'd like to double check the full code

The complete bundle_schema.json is built from the two chunks above. Scroll up and confirm:

  • The first chunk (top-level structure with properties and slots) is at the top of the file, ending with a comma.
  • The second chunk ($defs with the slot definition and allOf rules) follows immediately, ending with }.
  • No duplicate commas or missing commas at the join point.
  • Commit both contract files to git by running these commands:
git add control_questions.md bundle_schema.json
git commit -m "Add control questions and bundle schema before any artifact"

Why commit before building?

The commit timestamp proves these files existed before any artifact was generated. A reviewer can check git log to see that the questions were not written to fit the scan results.

This does not prove the ordering, because a git history can be amended. It corroborates it.

Test the schema against incomplete manifests

A schema that accepts everything is decorative. You need to see the validator reject two specific failure modes before you trust the contract.

  • Create a new file called test_schema.py and paste the first test, which validates an empty manifest:
import json
import jsonschema

# Load the evidence bundle schema
with open("bundle_schema.json") as f:
    schema = json.load(f)

validator = jsonschema.Draft202012Validator(schema)

# Test 1: Empty slots object should name all six missing slots
print("--- Test 1: Empty slots ---")
empty = {
    "bundle_id": "sha256:" + "a" * 64,
    "release": "test",
    "created_at": "2026-01-01T00:00:00Z",
    "slots": {}
}
for err in sorted(validator.iter_errors(empty), key=lambda e: e.message):
    print(f"  {err.message}")

What does this test do?

  • It creates a manifest with a valid top-level shape but an empty slots object.
  • The validator should reject it six times, once for each missing slot (sbom, vuln-scan, hardening-check, provenance, build-env, control-map).
  • Save test_schema.py and run the first test:
python test_schema.py

You should see six error messages, each naming one of the required slots as missing from the slots object.

  • Return to test_schema.py and add the second test below the existing code. This one checks that a STUBBED slot without a successor is rejected:
# Test 2: STUBBED slot without successor_build should fail
print("\n--- Test 2: STUBBED without successor ---")
stubbed_slots = {
    "sbom": {"state": "READY", "file": "s.json", "content_sha256": "c" * 64},
    "vuln-scan": {"state": "READY", "file": "v.json", "content_sha256": "d" * 64},
    "hardening-check": {"state": "STUBBED"},
    "provenance": {"state": "READY", "file": "p.json", "content_sha256": "e" * 64},
    "build-env": {"state": "READY", "file": "e.json", "content_sha256": "f" * 64},
    "control-map": {"state": "STUBBED", "successor_build": "future-001"}
}
stubbed = {**empty, "slots": stubbed_slots}
stubbed["bundle_id"] = "sha256:" + "b" * 64
for err in validator.iter_errors(stubbed):
    print(f"  {err.message}")

What does this test check?

  • The hardening-check slot is set to STUBBED but has no successor_build field.
  • The control-map slot is also STUBBED but names its successor correctly. Only hardening-check should fail.

Before you run the tests, predict: how many errors will Test 2 produce, and which slot will it name?

  • Save test_schema.py and run both tests:
python test_schema.py

Test 1 lists all six missing slots. Test 2 names hardening-check as the slot that is STUBBED without a successor_build. The schema catches both failure modes.

Not seeing any errors from the validator?

  • Make sure your virtual environment is activated (you should see (.venv) in your terminal prompt). Run .venv\Scripts\activate if not.
  • Verify bundle_schema.json is valid JSON by running python -c "import json; json.load(open('bundle_schema.json'))" in your terminal. A JSONDecodeError means the two schema chunks did not join correctly.
  • my schema validation test is not producing errors when it should
Record the design decisions

Five decisions shaped this bundle's design. Each one names the alternative it beat, the tradeoff, the consequence, and the condition under which you would reverse it.

  • Create a file called create_decisions.py to generate all five decision records at once:
from pathlib import Path

decisions_dir = Path("decisions")
decisions_dir.mkdir(exist_ok=True)

# Each record: (id, title, alternative, tradeoff, consequence, reversal)
records = [
    ("DR-001", "Fixed slots over opportunistic collection",
     "Collect whatever artifacts the build produces",
     "Missed artifacts are invisible vs. new slots require a schema change",
     "Every slot is named in advance; a missing one fails validation",
     "If the slot set changes faster than the schema can be updated"),
    ("DR-002", "Declared stub over silent omission",
     "Omit the slot entirely when the artifact is unavailable",
     "Gaps are honest and trackable vs. every stub needs a successor name",
     "A STUBBED slot proves the author knew the gap existed",
     "If stubs outnumber ready slots consistently"),
    ("DR-003", "Digest naming with verification over movable name",
     "Use a human-readable name or tag as the bundle identifier",
     "Substitution is detectable vs. digest must travel out-of-band",
     "Retrieval re-computes the digest; a mismatch proves tampering",
     "If bundles need human-readable names for regulatory filing"),
    ("DR-004", "Person as acceptance test rather than schema",
     "Rely on automated schema validation alone",
     "Catches questions schema cannot ask vs. requires a second person",
     "A reviewer who did not build the bundle answers control questions cold",
     "If bundles ship faster than people can review them"),
    ("DR-005", "Questions fixed in advance from a published catalog",
     "Write questions after seeing the artifacts",
     "Questions cannot be shaped to fit vs. may miss bundle-specific issues",
     "Control questions are committed before any artifact is generated",
     "If the published catalog does not cover the domain"),
]

Why decision records?

Each record documents a deliberate choice. When a future maintainer asks "why fixed slots instead of collecting whatever we find?", DR-001 answers without needing the original author in the room.

  • Add the file-writing loop below the records list in create_decisions.py:
# Write each decision record as a markdown file
for dr_id, title, alt, tradeoff, consequence, reversal in records:
    content = f"""# {dr_id}: {title}

**Alternative considered:** {alt}

**Tradeoff:** {tradeoff}

**Consequence:** {consequence}

**Reversal trigger:** {reversal}
"""
    (decisions_dir / f"{dr_id}.md").write_text(content)
    print(f"  Created {dr_id}.md")

print(f"\nWrote {len(records)} decision records to {decisions_dir}/")
  • Save create_decisions.py and run it:
python create_decisions.py

You should see five lines confirming each DR file was created, followed by a summary.

  • Verify the decision records exist by running:
ls decisions/

You should see DR-001.md through DR-005.md listed.

  • Commit the entire design checkpoint by running these commands:
git add control_questions.md bundle_schema.json decisions/
git commit -m "Design checkpoint: questions, schema, and decision records"

Git says nothing to commit?

  • If you already committed control_questions.md and bundle_schema.json earlier, only the decisions/ directory is new. Run git add decisions/ and commit with just the decision records.
  • git says nothing to commit after I tried to add my design files

Your contract is locked in. Three questions from a published catalog, a six-slot schema with teeth, and five documented decisions. Next up, you will build the artifacts that fill the contract and prove the publish gate works.

Build the Bundle

Your schema and control questions are committed, and the validator already proved it can reject bad manifests. Now the contract needs real artifacts behind it.

This step produces four real artifacts from one build run, assembles them into a manifest with a SHA-256 content digest, and publishes the complete bundle to SeaweedFS. A complete bundle must publish successfully first, because a publisher that refuses everything makes both refusals in the next step look like passes.

In this step, get ready to:
  • Write and run the assembler to produce a CycloneDX SBOM, vulnerability scan, build-environment snapshot, and cosign provenance signature, assembled into a manifest with a content digest.
  • Write and run the publisher to validate the manifest against the schema, verify every slot, and upload the bundle to SeaweedFS.
Write the assembler script

The assembler orchestrates the entire artifact pipeline: it runs Syft to generate the SBOM, feeds the SBOM to Grype for a vulnerability scan, collects build-environment metadata, writes a pre-sign manifest, signs it with cosign, and computes the content digest.

  • Create a new file called assemble.py in your project directory and paste the following imports, constants, and helper functions:
import hashlib
import json
import os
import subprocess
import sys
from datetime import datetime, timezone
from pathlib import Path


ARTIFACTS_DIR = Path("artifacts")
MANIFEST_PATH = ARTIFACTS_DIR / "manifest.json"
PRE_SIGN_PATH = ARTIFACTS_DIR / "manifest-unsigned.json"


def file_sha256(path):
    h = hashlib.sha256()
    with open(path, "rb") as f:
        for chunk in iter(lambda: f.read(8192), b""):
            h.update(chunk)
    return h.hexdigest()


def compute_manifest_digest(manifest_data):
    data_copy = {k: v for k, v in manifest_data.items() if k != "bundle_id"}
    serialized = json.dumps(data_copy, sort_keys=True, separators=(",", ":"))
    return hashlib.sha256(serialized.encode("utf-8")).hexdigest()

What do these helpers do?

file_sha256 reads a file in chunks and returns its SHA-256 hex digest. This is how each READY slot proves its artifact has not been tampered with.

compute_manifest_digest serializes the entire manifest (excluding the bundle_id field itself) into compact, key-sorted JSON, then hashes those bytes. The resulting digest becomes the bundle's name.

  • Save assemble.py.
  • Below compute_manifest_digest, add the build-environment collector function:
def collect_build_env():
    env_data = {"timestamp": datetime.now(timezone.utc).isoformat(), "tools": {}}
    for tool, cmd in [
        ("syft", ["syft", "version", "-o", "text"]),
        ("grype", ["grype", "version"]),
        ("cosign", ["cosign", "version"]),
    ]:
        try:
            result = subprocess.run(cmd, capture_output=True, text=True, timeout=15)
            env_data["tools"][tool] = result.stdout.strip()[:200]
        except (FileNotFoundError, subprocess.TimeoutExpired):
            env_data["tools"][tool] = "not found"
    try:
        result = subprocess.run(
            ["git", "rev-parse", "HEAD"], capture_output=True, text=True
        )
        env_data["git_commit"] = result.stdout.strip()
    except FileNotFoundError:
        env_data["git_commit"] = "unknown"
    return env_data

What does collect_build_env do?

This function captures the exact tool versions and git commit hash at build time. Each value is recorded so a reviewer can see which scanner versions produced the results. If a tool is missing, it records not found instead of crashing.

  • Save assemble.py.
  • Test the build-environment collector by running:
python -c "from assemble import collect_build_env; import json; print(json.dumps(collect_build_env(), indent=2))"

You should see a JSON object with your git commit hash, a UTC timestamp, and version strings for Syft, Grype, and cosign.

Seeing 'not found' for a tool?

If any tool shows not found, that tool is not on your PATH. Return to Step 1 and verify the tool's installation. The assembler will fail later if a required tool is missing.

help me debug why a tool is not found on my PATH after installing it in Step 1

  • Now add the first part of the main function, which generates the three non-provenance artifacts, below collect_build_env:
def main():
    scan_target = sys.argv[1] if len(sys.argv) > 1 else "."
    release_id = sys.argv[2] if len(sys.argv) > 2 else "dev-local"

    ARTIFACTS_DIR.mkdir(exist_ok=True)

    sbom_path = ARTIFACTS_DIR / "sbom.cdx.json"
    print("--- Generating SBOM with Syft ---")
    subprocess.run(
        ["syft", f"dir:{scan_target}", "-o", "cyclonedx-json", f"--file={sbom_path}"],
        check=True,
    )

    vuln_path = ARTIFACTS_DIR / "vuln-scan.json"
    print("--- Running vulnerability scan with Grype ---")
    subprocess.run(
        ["grype", f"sbom:{sbom_path}", "-o", "json", f"--file={vuln_path}"],
        check=True,
    )

    print("--- Collecting build environment ---")
    build_env = collect_build_env()
    build_env_path = ARTIFACTS_DIR / "build-env.json"
    with open(build_env_path, "w") as f:
        json.dump(build_env, f, indent=2)

What does this code do?

The main function creates the artifacts/ directory, then runs three artifact generators in sequence. Syft scans the project directory (dir:.) and writes a CycloneDX SBOM. Grype reads that SBOM and writes vulnerability findings. Finally, the build-environment collector writes tool versions and the git commit.

  • Save assemble.py.
  • Directly below the build-environment write, add the manifest dictionary that maps all six slots:
    manifest = {
        "release": release_id,
        "created_at": datetime.now(timezone.utc).isoformat(),
        "slots": {
            "sbom": {
                "state": "READY",
                "file": "sbom.cdx.json",
                "content_sha256": file_sha256(sbom_path),
            },
            "vuln-scan": {
                "state": "READY",
                "file": "vuln-scan.json",
                "content_sha256": file_sha256(vuln_path),
            },
            "hardening-check": {
                "state": "STUBBED",
                "successor_build": "linux-ci-pipeline-run-001",
            },
            "build-env": {
                "state": "READY",
                "file": "build-env.json",
                "content_sha256": file_sha256(build_env_path),
            },
            "control-map": {
                "state": "STUBBED",
                "successor_build": "control-map-population-001",
            },
        },
    }

Why are two slots STUBBED?

The hardening-check slot is STUBBED because OpenSCAP requires Linux and this workstation runs Windows. The gap is scheduled, not hidden: successor_build names linux-ci-pipeline-run-001 as the build that will fill it.

The control-map slot declares which NIST 800-53 controls these artifacts serve. That mapping is a separate design activity. A bundle that silently dropped this slot would pass every check and would be worse than one admitting the gap.

  • Save assemble.py.
  • Verify the module still parses correctly by running:
python -c "import assemble; print('Module parsed successfully')"

You should see Module parsed successfully with no errors.

  • Below the manifest dictionary, add the code that writes the pre-sign manifest and signs it with cosign:
    print("--- Writing pre-sign manifest ---")
    with open(PRE_SIGN_PATH, "w") as f:
        json.dump(manifest, f, indent=2, sort_keys=True)

    print("--- Signing manifest with cosign ---")
    provenance_path = ARTIFACTS_DIR / "provenance.sigstore.json"
    env = {**os.environ, "COSIGN_PASSWORD": ""}
    subprocess.run(
        [
            "cosign",
            "sign-blob",
            "--key",
            "cosign.key",
            "--bundle",
            str(provenance_path),
            "--tlog-upload=false",
            "--yes",
            str(PRE_SIGN_PATH),
        ],
        check=True,
        env=env,
    )

What does the signing step do?

The pre-sign manifest is written first so cosign has a stable file to sign. The --bundle flag writes the Sigstore bundle (signature plus verification material) to provenance.sigstore.json. The --tlog-upload=false flag skips the public transparency log since this is local-key signing.

  • Save assemble.py.
  • Finally, add the code that inserts the provenance slot, computes the content digest, writes the final manifest, and prints the summary:
    manifest["slots"]["provenance"] = {
        "state": "READY",
        "file": "provenance.sigstore.json",
        "content_sha256": file_sha256(provenance_path),
    }

    digest = compute_manifest_digest(manifest)
    manifest["bundle_id"] = f"sha256:{digest}"

    with open(MANIFEST_PATH, "w") as f:
        json.dump(manifest, f, indent=2, sort_keys=True)

    ready = sum(1 for s in manifest["slots"].values() if s["state"] == "READY")
    stubbed = sum(1 for s in manifest["slots"].values() if s["state"] == "STUBBED")
    print(f"\nBundle assembled.")
    print(f"  Bundle ID: {manifest['bundle_id']}")
    print(f"  Manifest:  {MANIFEST_PATH}")
    print(f"  Slots:     {len(manifest['slots'])} ({ready} READY, {stubbed} STUBBED)")


if __name__ == "__main__":
    main()

Why is provenance added after signing?

The provenance slot cannot exist in the pre-sign manifest because the file it references (provenance.sigstore.json) does not exist until cosign finishes signing. The slot is added after signing, and then the full manifest (with all six slots) gets its content digest computed and written as bundle_id.

  • Save assemble.py.

✔️ Awesome, I've got everything!

Double check you have saved assemble.py.

ⓧ I'd like to double check the full code

Scroll up through your file and confirm you have all six sections in this order:

  • Imports and constants (ARTIFACTS_DIR, MANIFEST_PATH, PRE_SIGN_PATH).
  • file_sha256 and compute_manifest_digest helper functions.
  • collect_build_env function.
  • main function: artifact generation (Syft, Grype, build env).
  • main continued: manifest dict, pre-sign write, cosign signing, provenance slot, digest, final write, summary print.
  • if __name__ == "__main__": guard at the bottom.
Run the assembler and inspect the output

The script is ready. Before you run it, think about what you expect to see. The assembler runs Syft, Grype, and cosign in sequence.

  • Predict: do you expect to see 4 or 6 READY slots? Why?
  • Run the assembler:
python assemble.py

You should see output for each stage: Syft generating the SBOM, Grype running the vulnerability scan, cosign signing the manifest, and a final summary reporting 4 READY and 2 STUBBED slots.

What happened during the run?

Syft scanned the project directory and wrote a CycloneDX SBOM to artifacts/sbom.cdx.json. Grype consumed that SBOM and wrote vulnerability findings to artifacts/vuln-scan.json.

Cosign signed the pre-sign manifest with your local key and wrote the Sigstore bundle to artifacts/provenance.sigstore.json. The script then added the provenance slot, computed the SHA-256 digest over the complete manifest, and wrote the final artifacts/manifest.json with bundle_id set to sha256:<64-hex-chars>.

  • Confirm the artifact files exist by running:
ls artifacts

You should see six files: build-env.json, manifest-unsigned.json, manifest.json, provenance.sigstore.json, sbom.cdx.json, and vuln-scan.json.

Assembler failed with an error?

  • If Syft or Grype fails with a "command not found" error, the tool is not on your PATH. Recheck Step 1.
  • If cosign fails, make sure cosign.key exists in your project directory (created in Step 1).
  • If you see an indentation error, compare your code against the chunks above. Python is whitespace-sensitive.

help me debug why my assemble.py failed to run

Start SeaweedFS, write the publisher, and publish the bundle

The assembled bundle exists on disk, but a bundle that sits in a directory is just a collection of files. Publishing it to an S3-compatible store keyed by its content digest makes it addressable. The publisher also enforces the contract one more time before uploading.

  • Open a second terminal window. Press the Windows key to open your search bar, type cmd or PowerShell, and press Enter.
  • Navigate to your project directory in the new terminal.
  • Start SeaweedFS by running:
weed mini -dir=.\seaweedfs-data

SeaweedFS prints a welcome banner listing the services it started. The S3 endpoint is at http://localhost:8333. Leave this terminal running and switch back to your original terminal.

SeaweedFS did not start?

  • If you see a port-in-use error, another process is using that port. Stop the other process or check if SeaweedFS is already running.
  • If you see a "command not found" error, weed.exe is not on your PATH. Recheck Step 1.

help me troubleshoot why SeaweedFS weed mini did not start on my Windows machine

  • In your original terminal (with the virtual environment active), create a new file called publish.py and paste the imports, constants, slot list, and file-hash helper:
import hashlib
import json
import sys
from pathlib import Path

import boto3
import jsonschema

ARTIFACTS_DIR = Path("artifacts")
SCHEMA_PATH = Path("bundle_schema.json")
MANIFEST_PATH = ARTIFACTS_DIR / "manifest.json"

REQUIRED_SLOTS = [
    "sbom",
    "vuln-scan",
    "hardening-check",
    "provenance",
    "build-env",
    "control-map",
]


def file_sha256(path):
    h = hashlib.sha256()
    with open(path, "rb") as f:
        for chunk in iter(lambda: f.read(8192), b""):
            h.update(chunk)
    return h.hexdigest()

Why is REQUIRED_SLOTS a frozen list?

The publisher checks the manifest against this hardcoded list, not against whatever the schema happens to require. If someone edits the schema to drop a slot, the publisher still catches the discrepancy. Defense in depth.

  • Save publish.py.
  • Below file_sha256, add the digest helper and the first part of validate_and_publish, which loads the schema and manifest, validates, and checks slot presence:
def compute_manifest_digest(manifest_data):
    data_copy = {k: v for k, v in manifest_data.items() if k != "bundle_id"}
    serialized = json.dumps(data_copy, sort_keys=True, separators=(",", ":"))
    return hashlib.sha256(serialized.encode("utf-8")).hexdigest()


def validate_and_publish(
    endpoint_url="http://localhost:8333", bucket="evidence-bundles"
):
    with open(SCHEMA_PATH) as f:
        schema = json.load(f)

    with open(MANIFEST_PATH) as f:
        manifest = json.load(f)

    try:
        jsonschema.validate(manifest, schema)
        print("[PASS] Schema validation passed.")
    except jsonschema.ValidationError as e:
        print(f"[FAIL] Schema validation failed: {e.message}")
        sys.exit(1)

    manifest_slots = set(manifest.get("slots", {}).keys())
    missing = set(REQUIRED_SLOTS) - manifest_slots
    if missing:
        print(
            f"[FAIL] Required slots missing from manifest: {', '.join(sorted(missing))}"
        )
        sys.exit(1)
    print(f"[PASS] All {len(REQUIRED_SLOTS)} required slots present.")

What does this validation do?

The publisher first validates the manifest against bundle_schema.json using the jsonschema library. Then it checks the manifest's slot keys against REQUIRED_SLOTS to ensure no slot was silently dropped.

  • Save publish.py.
  • Test that the helpers import correctly by running:
python -c "from publish import file_sha256; print(file_sha256('bundle_schema.json')[:16])"

You should see a 16-character hex prefix of the schema file's hash.

  • Continue validate_and_publish by adding the READY and STUBBED slot verification loops:
    for name, slot in manifest["slots"].items():
        if slot["state"] == "READY":
            artifact_path = ARTIFACTS_DIR / slot["file"]
            if not artifact_path.exists():
                print(
                    f"[FAIL] Slot '{name}' is READY but file '{slot['file']}' is missing."
                )
                sys.exit(1)
            actual_hash = file_sha256(artifact_path)
            if actual_hash != slot["content_sha256"]:
                print(
                    f"[FAIL] Slot '{name}': hash mismatch. "
                    f"Expected {slot['content_sha256']}, got {actual_hash}."
                )
                sys.exit(1)
            print(f"[PASS] Slot '{name}': READY, file present, hash verified.")

    for name, slot in manifest["slots"].items():
        if slot["state"] == "STUBBED":
            successor = slot.get("successor_build", "")
            if not successor:
                print(
                    f"[FAIL] Slot '{name}' is STUBBED but does not name a successor build."
                )
                sys.exit(1)
            print(f"[PASS] Slot '{name}': STUBBED, successor is '{successor}'.")

What do these loops verify?

For each READY slot, the publisher checks that the artifact file exists on disk and that its SHA-256 hash matches what the manifest claims. For each STUBBED slot, it checks that successor_build is not empty. A STUBBED slot without a successor is a gap nobody scheduled to close.

  • Save publish.py.
  • Below the STUBBED loop, add the digest re-verification and S3 client setup:
    expected_digest = f"sha256:{compute_manifest_digest(manifest)}"
    if manifest.get("bundle_id") != expected_digest:
        print(
            f"[FAIL] Digest mismatch. "
            f"Manifest says {manifest.get('bundle_id')}, computed {expected_digest}."
        )
        sys.exit(1)
    print(f"[PASS] Content digest verified: {expected_digest}")

    digest = manifest["bundle_id"]
    s3 = boto3.client(
        "s3",
        endpoint_url=endpoint_url,
        aws_access_key_id="admin",
        aws_secret_access_key="secret",
    )

    try:
        s3.head_bucket(Bucket=bucket)
    except Exception:
        s3.create_bucket(Bucket=bucket)

What does this code do?

The publisher re-computes the content digest from the downloaded manifest and compares it to the bundle_id stored inside. If they differ, the manifest was modified after assembly. Then it creates a boto3 S3 client pointing at the local SeaweedFS endpoint and ensures the evidence-bundles bucket exists.

  • Save publish.py.
  • Verify the module still parses by running:
python -c "import publish; print('Module parsed')"

You should see Module parsed with no errors.

  • Add the final section of validate_and_publish that uploads every file in the artifacts directory and the module guard:
    prefix = digest.replace(":", "/")
    for file_path in ARTIFACTS_DIR.iterdir():
        if file_path.is_file():
            key = f"{prefix}/{file_path.name}"
            s3.upload_file(str(file_path), bucket, key)
            print(f"  Uploaded: {key}")

    print(f"\n[PUBLISHED] Bundle {digest}")
    print(f"  Retrieve with: python retrieve.py {digest}")
    return digest


if __name__ == "__main__":
    validate_and_publish()

How is the bundle keyed?

Each file is uploaded under a prefix derived from the digest: sha256/<hex>/filename. The colon in sha256:<hex> is replaced with a slash to form a valid S3 key. This means the bundle is addressable by its content. A different digest yields a different prefix.

  • Save publish.py.

✔️ Awesome, I've got everything!

Double check you have saved publish.py.

ⓧ I'd like to double check the full code

Scroll up through your file and confirm you have all five sections in this order:

  • Imports, constants (ARTIFACTS_DIR, SCHEMA_PATH, MANIFEST_PATH), REQUIRED_SLOTS list, and file_sha256 function.
  • compute_manifest_digest function and validate_and_publish through the slot presence check.
  • READY verification loop and STUBBED verification loop.
  • Digest verification, S3 client setup, and bucket creation.
  • Upload loop, published message, return, and if __name__ == "__main__": guard.

Before you run the publisher, make a prediction. The assembler reported 4 READY and 2 STUBBED. Do you expect the publisher to pass or fail? Why?

  • Run the publisher:
python publish.py

You should see [PASS] for schema validation, all six slot checks (four READY with file and hash verified, two STUBBED with named successors), and digest verification. Then [PUBLISHED] with the bundle digest and an upload line for each file.

Publisher failed?

  • If you see a connection error mentioning localhost:8333, SeaweedFS is not running. Switch to your second terminal and confirm weed mini is still active.
  • If schema validation failed, make sure bundle_schema.json is in your project directory (not inside artifacts/).
  • If a hash mismatch is reported, the manifest may have been edited after assembly. Re-run python assemble.py to regenerate the artifacts, then run python publish.py again.

help me debug why publish.py failed to validate or upload my bundle

Your bundle is assembled, validated, and published. The contract held. Next, you will prove the gate actually refuses when a slot is missing or a stub has no successor.

Prove the Gate Refuses

Your bundle published successfully in the last step, with all six slots validated and a content digest computed. That positive result proves the pipeline runs.

A gate that never refuses is not a gate. The contract proves itself by what it rejects, not by what it accepts. In this step, you run two negative tests to confirm the publisher catches specific violations and names them in its output. You read each output by hand, because a publish that fails for the wrong reason looks exactly like one that works.

In this step, get ready to:
  • Delete a READY slot's artifact file and confirm the publisher refuses, naming the missing slot.
  • Empty a STUBBED slot's successor field and confirm the publisher refuses, naming the issue.
  • Re-run the publisher on the restored bundle to confirm the positive case still passes.
Remove a READY artifact and confirm the gate refuses

The vuln-scan slot is marked READY in the manifest, which means publish.py expects to find the actual file on disk with a matching hash. If that file disappears, the publisher should refuse to publish and tell you exactly which slot is broken.

  • Rename vuln-scan.json to a backup so the publisher can no longer find it, by running this command:
ren artifacts\vuln-scan.json vuln-scan.json.bak
  • Confirm the file is gone by running:
dir artifacts\vuln-scan.json

You should see a "File Not Found" message. The artifact is renamed, not deleted, so you can restore it in a moment.

Before you run the publisher, predict what will happen. The manifest still lists vuln-scan as READY with a file path and hash. Will the publisher succeed, or will it catch the missing file?

  • Run the publisher by running this command:
python publish.py

The publisher exits with a [FAIL] message. Read the output line by line. You should see [PASS] for schema validation and the slot-presence check, then [PASS] for the other READY slots, and then a failure line that names vuln-scan and says the file is missing.

Why does this matter?

A gate that only accepts is decoration. This failure proves the publisher walks the slots and checks that every READY artifact actually exists on disk with a matching hash. It names the specific slot that failed, so a human reading the output knows exactly what to fix.

The error names vuln-scan by name. A generic "file not found" without the slot name would force the reader to guess which artifact is broken. That ambiguity is the difference between a gate and a log line.

  • Restore the artifact by running this command:
ren artifacts\vuln-scan.json.bak vuln-scan.json
  • Confirm it is back by running:
dir artifacts\vuln-scan.json

You should see vuln-scan.json listed with its file size. The artifact is restored.

Still seeing [PUBLISHED] instead of [FAIL]?

The rename may not have taken effect. Run dir artifacts\ to list all files in the directory. If you still see vuln-scan.json (not the .bak version), the rename failed. Re-run the ren command from the step above.

Help me debug why my publish gate isn't catching the missing artifact

Empty a STUBBED slot's successor and confirm the gate refuses

A STUBBED slot is an honest gap. It declares the build that will fill it later. If that declaration is empty, the stub is no longer honest. It is a gap with no plan, which is the thing the STUBBED mechanism was designed to prevent.

  • Back up the manifest before editing by running this command:
copy artifacts\manifest.json artifacts\manifest.json.bak
  • Open artifacts\manifest.json in your editor.
  • Find the hardening-check slot. It looks like this:
"hardening-check": {
    "state": "STUBBED",
    "successor_build": "linux-ci-pipeline-run-001"
}
  • Change the value of successor_build from "linux-ci-pipeline-run-001" to an empty string "", so the slot now looks like this:
"hardening-check": {
    "state": "STUBBED",
    "successor_build": ""
}
  • Save the file.

Before you run the publisher, predict what will happen. The manifest still has all six slots. The hardening-check slot is STUBBED but its successor_build is now an empty string. Will the publisher catch this, or will it slip through?

  • Run the publisher by running this command:
python publish.py

The publisher exits with a [FAIL] message. Read the output line by line. The validation catches the empty successor string and refuses to publish.

What did you just prove?

The schema enforces minLength: 1 on the successor_build field. An empty string violates that constraint before publish.py's own programmatic check even runs. The contract has two layers of defense: the JSON Schema validation and the explicit successor check in the publisher code.

A STUBBED slot without a named successor is a gap with no plan. The contract catches it and names it, because a gap that is hidden is worse than a gap that is declared.

  • Restore the manifest from the backup by running these commands:
copy artifacts\manifest.json.bak artifacts\manifest.json
del artifacts\manifest.json.bak

Seeing [PUBLISHED] instead of [FAIL]?

Open artifacts\manifest.json and confirm the hardening-check slot's successor_build value is actually an empty string "" and not the original value. Make sure you saved the file before running publish.py.

Help me debug why my publish gate isn't catching the empty successor_build

Re-confirm the positive case still passes

Two negative tests are only half the proof. If restoring the files broke something, both refusals could have been the gate rejecting everything, not the gate catching the specific violation. The third run must succeed.

Before you run this, predict: the artifact is restored and the manifest is restored from backup. Should the publisher accept the bundle and print [PUBLISHED], or will something else go wrong?

  • Run the publisher one more time by running this command:
python publish.py

You should see [PASS] for every check and [PUBLISHED] at the end. The positive case is intact.

Why the third run matters

Three results together make the proof. The first run fails and names vuln-scan. The second run fails and catches the empty successor. The third run succeeds. A gate that refuses specific violations and then accepts the correct bundle is a gate that works. All three outputs are read by hand, not by a test harness, because a publish that fails for the wrong reason looks exactly like one that works.

🙋‍♀️ Getting [FAIL] on the re-confirmation?

The backup may not have restored cleanly. Check that vuln-scan.json is present in the artifacts directory by running dir artifacts\. Also open artifacts\manifest.json and confirm the hardening-check slot has successor_build set to "linux-ci-pipeline-run-001", not an empty string.

If the manifest is corrupted, re-run python assemble.py to regenerate all artifacts and the manifest from scratch. Then try python publish.py again.

Help me debug why my restored bundle is still failing to publish

Your gate catches both failure modes by name and still accepts the correct bundle. The contract is not decorative. Next up, you will store the bundle in SeaweedFS keyed by its content digest and prove you can retrieve it in one command with digest re-verification.

Store, Retrieve, and Verify the Digest

Your bundle is published and the gate works. There is still a gap: if the only way to find the bundle is to ask the person who published it, the retrieval problem is the original problem wearing a new hat.

In this step, you will publish a second bundle to prove the store handles multiple digests, then write a retriever that downloads a bundle by its content address and re-computes the hash on arrival. The digest IS the address, and re-computing it IS the integrity check.

In this step, get ready to:
  • Publish a second bundle with a different content digest to SeaweedFS.
  • Write retrieve.py to download and verify a bundle by its digest.
  • Spot-check end-to-end integrity of a retrieved artifact.
Publish a second bundle to SeaweedFS

SeaweedFS is still running from the previous step with the first bundle already stored. To prove the store handles multiple bundles keyed by different digests, you need a second bundle with different content.

  • Record the first bundle's digest by running this command:
python -c "import json; print(json.load(open('artifacts/manifest.json'))['bundle_id'])"

This prints sha256:<your-first-digest>. Copy that full string somewhere safe. You will need it to test retrieval later.

  • Create a marker file so Syft produces a different SBOM by running:
echo second-build-marker > marker.txt
  • Re-run the assembler and publisher to produce and store a second bundle by running:
python assemble.py
python publish.py

The assembler reports a new bundle_id with a different digest. The publisher prints [PUBLISHED] with the new digest.

Why does a marker file change the digest?

Syft scans the entire project directory. A new file means a different SBOM, which means a different hash in the sbom slot, which means a different manifest, which means a different SHA-256 content digest. One changed byte cascades through every layer.

  • Copy the second bundle's digest from the [PUBLISHED] output. You now have two digests recorded.

Seeing a schema validation failure?

If publish.py fails, confirm you restored the manifest and artifact files fully at the end of Step 4. Run python assemble.py again first to regenerate a clean bundle, then re-run python publish.py.

Help me debug a publish failure after running assemble.py with a new marker file

Write the retriever

The retriever takes a digest as its only argument, downloads the bundle from SeaweedFS, and re-computes the digest from the downloaded manifest. If the recomputed digest matches the key, the bundle is intact.

  • Create a new file called retrieve.py in your project folder. Add the imports and the digest function by pasting this code:
import hashlib
import json
import sys
from pathlib import Path

import boto3

# Same digest algorithm used in publish.py
def compute_manifest_digest(manifest_data):
    # Exclude bundle_id so the digest is self-consistent
    data_copy = {k: v for k, v in manifest_data.items() if k != "bundle_id"}
    serialized = json.dumps(data_copy, sort_keys=True, separators=(",", ":"))
    return hashlib.sha256(serialized.encode("utf-8")).hexdigest()

What does this code do?

The imports pull in the same libraries used by publish.py: hashlib for SHA-256, json for serialization, and boto3 for the S3 API.

The compute_manifest_digest function is an exact copy of the one in publish.py. The retriever must use the same algorithm the publisher used, or the comparison is meaningless.

  • Save retrieve.py and confirm the digest function works by running:
python -c "from retrieve import compute_manifest_digest; print(compute_manifest_digest({'test': True}))"

You should see a 64-character hex string. That confirms the function computes a SHA-256 hash correctly.

  • Return to retrieve.py in your editor. Add the retrieve function below compute_manifest_digest by pasting this code:

def retrieve(digest, endpoint_url="http://localhost:8333", bucket="evidence-bundles"):
    # Name output directory after the digest
    output_dir = Path(f"retrieved-{digest.replace(':', '-')}")
    output_dir.mkdir(exist_ok=True)

    # Connect to the local SeaweedFS S3 endpoint
    s3 = boto3.client(
        "s3",
        endpoint_url=endpoint_url,
        aws_access_key_id="admin",
        aws_secret_access_key="secret",
    )

    # Convert digest to S3 prefix and list all files
    prefix = digest.replace(":", "/")
    response = s3.list_objects_v2(Bucket=bucket, Prefix=prefix)

    if "Contents" not in response:
        print(f"[FAIL] No bundle found for digest: {digest}")
        sys.exit(1)

    # Download every file in the bundle
    for obj in response["Contents"]:
        filename = obj["Key"].split("/")[-1]
        local_path = output_dir / filename
        s3.download_file(bucket, obj["Key"], str(local_path))
        print(f"  Downloaded: {filename}")

What does this code do?

The retrieve function converts the digest (sha256:abc...) into an S3 prefix (sha256/abc...) and lists all objects stored under it. This is the key design: the digest is the storage key, so you can find the bundle if and only if you know its digest.

Every file under that prefix gets downloaded into a local directory named after the digest. The directory name replaces : with - so the path is valid on Windows.

  • Still in retrieve.py, add the digest verification and the entry point below the download loop by pasting this code:

    # Verify the manifest exists in retrieved files
    manifest_path = output_dir / "manifest.json"
    if not manifest_path.exists():
        print("[FAIL] Retrieved bundle has no manifest.json.")
        sys.exit(1)

    with open(manifest_path) as f:
        manifest = json.load(f)

    # Re-compute digest from the downloaded manifest
    recomputed = f"sha256:{compute_manifest_digest(manifest)}"
    if recomputed == digest:
        print(f"\n[VERIFIED] Digest matches: {digest}")
    else:
        print(f"\n[FAIL] Digest mismatch!")
        print(f"  Published:  {digest}")
        print(f"  Recomputed: {recomputed}")
        sys.exit(1)

    return output_dir


if __name__ == "__main__":
    if len(sys.argv) != 2:
        print("Usage: python retrieve.py sha256:<digest>")
        sys.exit(1)
    retrieve(sys.argv[1])

What does this code do?

After downloading, the retriever re-computes the SHA-256 digest from the manifest it just downloaded (excluding bundle_id) and compares it to the digest the caller provided. A match is strong evidence of integrity. A mismatch proves substitution.

The __main__ block takes the digest as a command-line argument, so the retrieval is a single command.

Before you run the retriever, think: you are retrieving the FIRST bundle, not the one you just published. Why is that the stronger claim?

  • Save retrieve.py.
  • Run the retriever against the FIRST bundle's digest (the one you copied earlier) by running:
python retrieve.py [[FIRST_DIGEST="sha256:your-first-digest-here"]]

You should see each file downloaded, followed by [VERIFIED] Digest matches. The retriever downloaded the bundle, re-computed the hash, and confirmed it matches the key. If you see [FAIL], the bundle was altered after publishing.

Seeing a connection error or no bundle found?

If you see a connection refused error, confirm SeaweedFS is still running in its terminal window. If it was stopped, restart it by running weed mini -dir=.\seaweedfs-data in a separate terminal.

If you see [FAIL] No bundle found for digest, double-check that you pasted the full digest string including the sha256: prefix. The digest must match exactly.

Help me troubleshoot a retrieve.py failure when connecting to SeaweedFS

Spot-check end-to-end integrity

The digest verification proves the manifest was not altered. To verify end-to-end integrity down to individual artifacts, you can spot-check a slot's content_sha256 against the actual file.

  • Open the retrieved manifest to inspect the slot hashes by running:
python -c "import json; m=json.load(open('retrieved-sha256-[[FIRST_DIGEST_HEX="your-first-64-char-hex-digest"]]/manifest.json')); print(m['slots']['sbom']['content_sha256'])"

This prints the expected SHA-256 hash of the SBOM file as recorded in the manifest.

  • Compute the actual hash of the retrieved SBOM file by running:
certutil -hashfile retrieved-sha256-[[FIRST_DIGEST_HEX="your-first-64-char-hex-digest"]]\sbom.cdx.json SHA256

Compare the two values. They should match character for character. If they do, the artifact stored in SeaweedFS is identical to the one that was published. The manifest's hash is a promise. The file's hash is the proof.

What does a matching hash prove?

The manifest records the hash of each READY artifact at publish time. If the hash of the retrieved file matches, the file has not been altered since the manifest was written. Combined with the digest verification on the manifest itself, this chain proves integrity from the store back to the build.

Content addressing is a property of digests, not a benefit of this build. Two obligations carry that property: the reference digest must reach the reader through a channel they trust, and the retriever must actually re-compute. A name or a tag offers neither.

🙋‍♀️ Hashes do not match?

If the hashes differ, the artifact was modified after publishing. Re-run python assemble.py and python publish.py to produce a fresh bundle, then retrieve it again with the new digest.

Help me debug a content hash mismatch between the retrieved manifest and artifact file

✔️ Awesome, I've got everything!

Great. Double check that you have saved retrieve.py and that the retrieved-sha256-... directory contains the downloaded bundle files.

ⓧ I'd like to double check the full code

Your retrieve.py is built from three code blocks above. Scroll up and confirm each one is present in order:

  • Block 1: Imports (hashlib, json, sys, Path, boto3) and the compute_manifest_digest function.
  • Block 2: The retrieve function definition through the download loop (ending with print(f" Downloaded: {filename}")).
  • Block 3: The manifest verification, digest comparison, return output_dir, and the if __name__ == "__main__" entry point.

Confirm there are no gaps between blocks. Block 2 continues the retrieve function started in Block 2's header line, and Block 3 continues the same function body before closing it with return output_dir.

Two bundles are in the store, keyed by their content digests. The retriever downloads by address and re-verifies on arrival. Next, you will hand this bundle to someone who did not build it and see whether the control questions can be answered cold.

Secret mission

Hand the Bundle to a Reader

The bundle has passed every automated gate. Now hand it to someone who did not build it and find out whether they can answer three security control questions by reading the artifacts cold. This is the test no schema can replace.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • SeaweedFS process and data directory (seaweedfs-data/).
  • Python virtual environment (.venv/).
  • cosign key pair (cosign.key and cosign.pub).
  • Project files (artifacts, scripts, decision records, schemas, retrieved bundle directory).

Keep everything running

No action needed. Choose this if you are still actively experimenting with additional bundles or planning to extend the pipeline.

  • SeaweedFS keeps running and data persists in seaweedfs-data/.
  • Your cosign private key (cosign.key) is in your project directory. Do not commit it to a shared repository.
  • Syft, Grype, cosign, and SeaweedFS remain installed on your machine.

Pause - I'll come back to this later

Stop the running process to free up resources. Your data and files stay on disk so you can pick up where you left off.

  • In the terminal where SeaweedFS is running, press Ctrl+C to stop the process.
  • Your bundle data persists in seaweedfs-data/. When you are ready to resume, restart with the same command:
weed mini -dir=.\seaweedfs-data
  • All project files, the virtual environment, and the cosign key pair remain intact.

Delete - I don't want to use this again

Remove all project resources and start fresh. This deletes your bundles, artifacts, keys, and virtual environment.

  • If SeaweedFS is still running, press Ctrl+C in its terminal to stop it.
  • Deactivate the Python virtual environment by running:
deactivate
  • Delete the SeaweedFS data directory and the Python virtual environment by running:
rmdir /s /q seaweedfs-data
rmdir /s /q .venv
  • On macOS or Linux, use this instead:
rm -rf seaweedfs-data
rm -rf .venv
  • To remove the entire project directory, navigate up one level and delete it. On Windows:
cd ..
rmdir /s /q evidence-bundle-pipeline
  • On macOS or Linux:
cd ..
rm -rf evidence-bundle-pipeline
  • Uninstall Syft and Grype by running:
winget uninstall Anchore.Syft
winget uninstall Anchore.Grype
  • Delete the cosign.exe and weed.exe files from the directory you added to your PATH in Step 1.

Nice Work!

Nice Work!

Nice work! You've built a compliance evidence bundle system that turns scattered security artifacts into an addressable, contract-enforced package a reviewer can interrogate without its author.

You've learned how to:

  • Define a JSON Schema contract with six named slots and conditional validation, commit three NIST SP 800-53 Rev 5 control questions before any artifact existed, and prove the publish gate rejects a missing READY artifact by name and rejects a STUBBED slot without a successor.
  • Produce four real artifacts from one build run (CycloneDX SBOM, vulnerability scan, cosign provenance signature, build-environment snapshot) alongside two honestly STUBBED slots, assembled into a manifest with a SHA-256 content digest.
  • Publish the bundle to a local SeaweedFS store keyed by its digest, retrieve it in one command with digest re-verification, and hand it to a reader who answered the control questions cold by citing values from inside the slots.
  • Secret Mission: Wrote the release checklist with the digest-in-release-notes rule and the honest limits, recorded the acceptance test, and tagged the release with the bundle digest.

Ready to quiz yourself?