🏫 The School›🛡️ Security›✍️ धडा 14 — Signing, SBOM आणि provenance: तुम्ही काय पाठवता आणि ते कुठून आले हे सिद्ध करा
🖼️ See the drawing + lab 🏠 Course home 🌿 Branch on GitHub ✏️ View source
🖼️ आकृती आणि labThe drawing + lab पूर्ण पानावर उघडा ↗Open full page ↗

✍️ धडा 14 — Signing, SBOM आणि provenance: तुम्ही काय पाठवता आणि ते कुठून आले हे सिद्ध करा

📍 तुम्ही इथे आहात: 16 पैकी धडा 14 · मागील: lesson-13-dependencies · पुढील: lesson-15-pipeline-threats


📦 या ब्रँचमध्ये काय आहे

धडे 01–13, आणि प्रत्येक build सोबत जाणारा पुरावा: एक SBOM (आत असलेल्या प्रत्येक गोष्टीची यादी), image digest वर एक signature (CI ने बनवल्यापासून त्यात बदल झालेला नाही), आणि provenance (कोणत्या repo ने, कोणत्या workflow ने आणि कोणत्या review झालेल्या commit ने तो build केला). तुम्ही SPDX आणि CycloneDX, Sigstore / cosign (keyless signing सह), सोप्या शब्दांत SLSA levels, आणि cluster फक्त signed images admit कसे करतो हे शिकता.

🧒 5 वर्षांच्या मुलाला समजावल्यासारखे

शाळेचे स्वयंपाकघर प्रत्येक वर्गात एक डबा पाठवते. कतरिना, एक शिक्षिका, असा एक डबा घेते. वर्ग जेवण्याआधी ती तीन गोष्टी तपासते:

  1. 📃 डब्यावर चिकटवलेली सामानाची यादी: "bread (bakery A, batch 3.0), jam (5.4), juice (2.32)". बातमीत "jam batch 5.3 खराब आहे" असे आले, तर तिच्या डब्यात ते आहे का हे ती एका सेकंदात पाहू शकते. हे आहे SBOM.
  2. 🔏 सील. स्वयंपाकघर डबा सील करते आणि त्यावर शिक्का मारते. वाटेत कोणी तो उघडला — अगदी एक sandwich बदलण्यासाठी जरी — तर सील तुटलेले असते. हे आहे signature.
  3. 🚚 वितरण चिठ्ठी. "आपल्या शाळेच्या स्वयंपाकघरात, आपल्या स्वयंपाकीने, मुख्य स्वयंपाकीने मंजूर केलेल्या पाककृतीतून बनवले." दुसऱ्या स्वयंपाकघरातून आलेला डबा, किंवा कोणीही न तपासलेल्या पाककृतीतून बनलेला डबा, परत जातो. हे आहे provenance.

आणि वर्गाच्या दारावर एक नियम आहे: सील नाही, वितरण चिठ्ठी नाही — तर जेवण नाही. हेच म्हणजे cluster फक्त चांगल्या provenance असलेल्या signed images admit करतो.

🗺️ आकृती

flowchart LR
    c["✅ reviewed commit<br/>BaluRaut/school-app"] --> ci["🏗️ CI: release.yml"]
    ci --> img["📦 image<br/>digest sha256:…"]
    ci --> sb["📃 SBOM<br/>SPDX / CycloneDX"]
    ci --> sig["🔏 cosign signature<br/>keyless: workflow identity"]
    ci --> prov["🚚 provenance<br/>repo · workflow · commit"]
    img --> reg["🗄️ registry"]
    reg --> adm{"🛂 admission<br/>signature ✓ provenance ✓"}
    adm -->|"fork / other workflow / tampered"| no["❌ rejected"]
    adm -->|"all good"| run["☸️ runs"]

🗺️ काढलेली आवृत्ती + एक lab: https://school-edh.pages.dev/security/lesson-diagrams.html#l14

❓ काय

🤔 का

कारण "आम्ही review केलेला code" आणि "चालणारा container" यांच्या मध्ये अनेक हात असतात: CI runners, caches, registries, mirrors. Registry वर push करू शकणारा, fork मधून build करू शकणारा, किंवा बाजूचा workflow चालवू शकणारा हल्लेखोर योग्य दिसणाऱ्या नावाखाली कोणीही review न केलेला code पाठवू शकतो. दारावर तपासलेली, आपल्या repo शी आणि आपल्या release workflow शी जोडलेली signature ही फट बंद करते. आणि पुढची मोठी library advisory आली, की SBOMs असलेली team "आपण ती कुठे चालवतो?" याचे उत्तर मिनिटांत देते.

🔧 कसे (या repo मध्ये)

sec/supply.py मध्ये:

🧪 करून पाहा

python3 sec/demo.py artifacts
python3 - <<'EOF'
import sys; sys.path.insert(0, "sec"); from supply import sbom, digest, sign, verify, provenance_ok
image = "school/results:1.4.2 layers=3f9c,77ab,e1d0"; key = b"release-signing-key"
s = sign(image, key)
print(f"digest {digest(image)[:16]}… · signature {s}")
for label, art, k in (("same image, same key", image, key), ("one layer changed", image.replace("e1d0", "e1d1"), key),
                      ("same image, other key", image, b"someone-elses-key")):
    print(f"verify: {label:<22} → {verify(art, s, k)}")
before = {c["name"]: c for c in sbom([("flask", "3.0.0"), ("requests", "2.32.3")])}
after  = {c["name"]: c for c in sbom([("flask", "3.0.0"), ("requests", "2.32.4"), ("pyyaml", "6.0.2")])}
print("SBOM diff:", {n: (before.get(n, {}).get("version"), after[n]["version"]) for n in after if before.get(n) != after[n]})
for att in (dict(repo="BaluRaut/school-app", workflow=".github/workflows/release.yml", reviewed=True),
            dict(repo="BaluRaut/school-app", workflow=".github/workflows/debug.yml", reviewed=True),
            dict(repo="BaluRaut/school-app", workflow=".github/workflows/release.yml", reviewed=False)):
    print(f"provenance {att['workflow']:<30} reviewed={att['reviewed']!s:<5} → {provenance_ok(att)}")
EOF
python3 sec/test_sec.py

✅ तपासा — तुम्हाला काय दिसायला हवे

artifacts हे print करते:

═══ artifacts ═══
── SBOM: [{'name': 'flask', 'version': '3.0.0', 'sha256': 'af4113b6a8b4'}, {'name': 'jinja2', 'version': '3.1.4', 'sha256': 'c85d52da9cf8'}, {'name': 'requests', 'version': '2.32.3', 'sha256': '426739742007'}]
── signature on the image digest: 529a0de3b5e2a1c6 · verify original → True · verify tampered → False
── provenance BaluRaut/school-app    → (True, [])
── provenance someone/fork           → (False, ['built from someone/fork', 'commit was not reviewed'])
   sign in CI (cosign / Sigstore), keep an SBOM per image, and let the cluster admit only signed images with good provenance

✅ done — every door checked

तुमचा snippet हे print करतो:

digest c940ffbe5d5f7d3f… · signature 529a0de3b5e2a1c6
verify: same image, same key   → True
verify: one layer changed      → False
verify: same image, other key  → False
SBOM diff: {'requests': ('2.32.3', '2.32.4'), 'pyyaml': (None, '6.0.2')}
provenance .github/workflows/release.yml  reviewed=True  → (True, [])
provenance .github/workflows/debug.yml    reviewed=True  → (False, ['built by .github/workflows/debug.yml'])
provenance .github/workflows/release.yml  reviewed=False → (False, ['commit was not reviewed'])

Tests मध्ये ✅ L14 a tampered artifact fails verification; a fork fails provenance येते आणि शेवटी 16/16 passed येते.

🏁 तुम्ही आत्ताच काय सिद्ध केले

एकच layer id बदलल्याने (e1d0 → e1d1) signature तुटली, आणि योग्य image चुकीच्या key ने तपासली तरी fail झाली — signature "बदल झालेला नाही" आणि "याच sign करणाऱ्याने sign केले" या दोन्ही गोष्टी सिद्ध करते. दोन SBOMs ची तुलना केल्याने एका release ने नेमके काय बदलले ते दिसले: एक upgrade आणि एक नवे package, आणि पाहायला हवे ते तेच. आणि जे signature एकटी पकडू शकत नाही ते provenance पकडते: आपल्या repo ने build केलेली, पण बाजूच्या workflow ने किंवा review न झालेल्या commit मधून बनलेली image तरीही नाकारली जाते.

⚠️ नेहमीच्या चुका

🏭 प्रत्यक्ष वापरात

खऱ्या account वर — release workflow: build, push, SBOM बनवा, keyless sign करा, आणि signed build provenance जोडा (.github/workflows/release.yml):

name: release
on:
  push:
    branches: [main]
permissions:
  contents: read
  packages: write
  id-token: write          # for keyless signing and provenance
  attestations: write
jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: sigstore/cosign-installer@v3
      - uses: anchore/sbom-action/download-syft@v0
      - name: build and push
        id: build
        run: |
          echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin
          docker build -t ghcr.io/baluraut/school-results:${{ github.sha }} .
          docker push ghcr.io/baluraut/school-results:${{ github.sha }}
          echo "digest=$(docker inspect --format='{{index .RepoDigests 0}}' ghcr.io/baluraut/school-results:${{ github.sha }} | cut -d@ -f2)" >> "$GITHUB_OUTPUT"
      - name: SBOM (SPDX and CycloneDX)
        run: |
          syft ghcr.io/baluraut/school-results@${{ steps.build.outputs.digest }} -o spdx-json=sbom.spdx.json -o cyclonedx-json=sbom.cdx.json
      - name: sign and attach the SBOM
        run: |
          cosign sign --yes ghcr.io/baluraut/school-results@${{ steps.build.outputs.digest }}
          cosign attest --yes --type spdxjson --predicate sbom.spdx.json ghcr.io/baluraut/school-results@${{ steps.build.outputs.digest }}
      - name: build provenance
        uses: actions/attest-build-provenance@v2
        with:
          subject-name: ghcr.io/baluraut/school-results
          subject-digest: ${{ steps.build.outputs.digest }}
          push-to-registry: true

कुठूनही verify करा — signature main वरच्या याच workflow कडून आलेली, आणि GitHub च्या OIDC ने दिलेली असायला हवी:

cosign verify ghcr.io/baluraut/school-results@sha256:<digest> \
    --certificate-identity "https://github.com/BaluRaut/school-app/.github/workflows/release.yml@refs/heads/main" \
    --certificate-oidc-issuer "https://token.actions.githubusercontent.com"
gh attestation verify oci://ghcr.io/baluraut/school-results@sha256:<digest> --repo BaluRaut/school-app
grype sbom:./sbom.spdx.json          # "are we affected?" from the stored SBOM

Admission — आपल्या registry मधली image आपल्या release workflow ने signed नसेल, तर Kyverno तो pod नाकारतो (आणि tags ना digests मध्ये बदलतो):

apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata: { name: verify-school-images }
spec:
  webhookTimeoutSeconds: 30
  rules:
    - name: signed-by-release-workflow
      match: { any: [{ resources: { kinds: [Pod] } }] }
      verifyImages:
        - imageReferences: ["ghcr.io/baluraut/*"]
          failureAction: Enforce
          mutateDigest: true
          attestors:
            - entries:
                - keyless:
                    subject: "https://github.com/BaluRaut/school-app/.github/workflows/release.yml@refs/heads/main"
                    issuer: "https://token.actions.githubusercontent.com"
                    rekor: { url: "https://rekor.sigstore.dev" }

🏭 Production मध्ये हे का महत्त्वाचे: तुमच्या registry मधून न आलेल्या images साठी दुसरा नियम जोडा (त्या block करा, किंवा review केलेल्या छोट्या यादीलाच परवानगी द्या), नाहीतर signature check फक्त त्याच images ना लागू होतो ज्या तशाही ठीकच असणार होत्या.

⏭️ पुढे

आता प्रत्येक खिडकीचा स्वतःचा रक्षक आहे. पुढे आपण developer च्या laptop पासून production पर्यंत संपूर्ण delivery line वरून चालतो, प्रत्येक टप्प्यावरचे धोके STRIDE ने ओळखतो — आणि secret leak झाल्यानंतरच्या पहिल्या तासाचा सराव करतो.

git checkout lesson-15-pipeline-threats

✍️ Lesson 14 — Signing, SBOM & provenance: prove what you ship and where it came from

📍 You are here: Lesson 14 of 16 · Previous: lesson-13-dependencies · Next: lesson-15-pipeline-threats


📦 What's in this branch

Lessons 01–13, plus the proof that goes with every build: an SBOM (the list of everything inside), a signature on the image digest (it has not changed since CI made it), and provenance (which repo, which workflow and which reviewed commit built it). You learn SPDX and CycloneDX, Sigstore / cosign (including keyless signing), the SLSA levels in plain words, and how the cluster admits only signed images.

🧒 Explain like I'm 5

The school kitchen sends a lunch box to every classroom. Katrina, a teacher, receives one. Before the class eats, she checks three things:

  1. 📃 The packing list taped on the box: "bread (bakery A, batch 3.0), jam (5.4), juice (2.32)". If the news says "jam batch 5.3 is bad", she can see in one second whether her box has it. That is the SBOM.
  2. 🔏 The seal. The kitchen seals the box and stamps it. If anyone opened it on the way — even to change one sandwich — the seal is broken. That is the signature.
  3. 🚚 The delivery note. "Made in our school kitchen, by our cook, from a recipe the head cook approved." A box from a different kitchen, or from a recipe nobody checked, goes back. That is provenance.

And at the classroom door there is a rule: no seal, no delivery note — no lunch. That is the cluster admitting only signed images with good provenance.

🗺️ Diagram

flowchart LR
    c["✅ reviewed commit<br/>BaluRaut/school-app"] --> ci["🏗️ CI: release.yml"]
    ci --> img["📦 image<br/>digest sha256:…"]
    ci --> sb["📃 SBOM<br/>SPDX / CycloneDX"]
    ci --> sig["🔏 cosign signature<br/>keyless: workflow identity"]
    ci --> prov["🚚 provenance<br/>repo · workflow · commit"]
    img --> reg["🗄️ registry"]
    reg --> adm{"🛂 admission<br/>signature ✓ provenance ✓"}
    adm -->|"fork / other workflow / tampered"| no["❌ rejected"]
    adm -->|"all good"| run["☸️ runs"]

🗺️ Drawn version + a lab: https://school-edh.pages.dev/security/lesson-diagrams.html#l14

❓ What

🤔 Why

Because between "the code we reviewed" and "the container that runs" there are many hands: CI runners, caches, registries, mirrors. An attacker who can push to the registry, or build from a fork, or run a side workflow, can ship code nobody reviewed under a name that looks right. A signature checked at the door, tied to our repo and our release workflow, closes that gap. And when the next big library advisory lands, the team with SBOMs answers "where do we run it?" in minutes.

🔧 How (in this repo)

In sec/supply.py:

🧪 Try it

python3 sec/demo.py artifacts
python3 - <<'EOF'
import sys; sys.path.insert(0, "sec"); from supply import sbom, digest, sign, verify, provenance_ok
image = "school/results:1.4.2 layers=3f9c,77ab,e1d0"; key = b"release-signing-key"
s = sign(image, key)
print(f"digest {digest(image)[:16]}… · signature {s}")
for label, art, k in (("same image, same key", image, key), ("one layer changed", image.replace("e1d0", "e1d1"), key),
                      ("same image, other key", image, b"someone-elses-key")):
    print(f"verify: {label:<22} → {verify(art, s, k)}")
before = {c["name"]: c for c in sbom([("flask", "3.0.0"), ("requests", "2.32.3")])}
after  = {c["name"]: c for c in sbom([("flask", "3.0.0"), ("requests", "2.32.4"), ("pyyaml", "6.0.2")])}
print("SBOM diff:", {n: (before.get(n, {}).get("version"), after[n]["version"]) for n in after if before.get(n) != after[n]})
for att in (dict(repo="BaluRaut/school-app", workflow=".github/workflows/release.yml", reviewed=True),
            dict(repo="BaluRaut/school-app", workflow=".github/workflows/debug.yml", reviewed=True),
            dict(repo="BaluRaut/school-app", workflow=".github/workflows/release.yml", reviewed=False)):
    print(f"provenance {att['workflow']:<30} reviewed={att['reviewed']!s:<5} → {provenance_ok(att)}")
EOF
python3 sec/test_sec.py

✅ Verify — what you should see

artifacts prints:

═══ artifacts ═══
── SBOM: [{'name': 'flask', 'version': '3.0.0', 'sha256': 'af4113b6a8b4'}, {'name': 'jinja2', 'version': '3.1.4', 'sha256': 'c85d52da9cf8'}, {'name': 'requests', 'version': '2.32.3', 'sha256': '426739742007'}]
── signature on the image digest: 529a0de3b5e2a1c6 · verify original → True · verify tampered → False
── provenance BaluRaut/school-app    → (True, [])
── provenance someone/fork           → (False, ['built from someone/fork', 'commit was not reviewed'])
   sign in CI (cosign / Sigstore), keep an SBOM per image, and let the cluster admit only signed images with good provenance

✅ done — every door checked

Your snippet prints:

digest c940ffbe5d5f7d3f… · signature 529a0de3b5e2a1c6
verify: same image, same key   → True
verify: one layer changed      → False
verify: same image, other key  → False
SBOM diff: {'requests': ('2.32.3', '2.32.4'), 'pyyaml': (None, '6.0.2')}
provenance .github/workflows/release.yml  reviewed=True  → (True, [])
provenance .github/workflows/debug.yml    reviewed=True  → (False, ['built by .github/workflows/debug.yml'])
provenance .github/workflows/release.yml  reviewed=False → (False, ['commit was not reviewed'])

The tests include ✅ L14 a tampered artifact fails verification; a fork fails provenance and end with 16/16 passed.

🏁 What you just proved

Changing one layer id (e1d0 → e1d1) broke the signature, and the right image checked with the wrong key failed too — a signature proves both "unchanged" and "signed by this signer". Comparing two SBOMs showed exactly what a release changed: one upgrade and one new package, which is the one to look at. And provenance catches what a signature alone cannot: an image built by our repo, but by a side workflow or from an unreviewed commit, is still refused.

⚠️ Common mistakes

🏭 In production

On a real account — the release workflow: build, push, make an SBOM, sign keylessly, and attach signed build provenance (.github/workflows/release.yml):

name: release
on:
  push:
    branches: [main]
permissions:
  contents: read
  packages: write
  id-token: write          # for keyless signing and provenance
  attestations: write
jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: sigstore/cosign-installer@v3
      - uses: anchore/sbom-action/download-syft@v0
      - name: build and push
        id: build
        run: |
          echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin
          docker build -t ghcr.io/baluraut/school-results:${{ github.sha }} .
          docker push ghcr.io/baluraut/school-results:${{ github.sha }}
          echo "digest=$(docker inspect --format='{{index .RepoDigests 0}}' ghcr.io/baluraut/school-results:${{ github.sha }} | cut -d@ -f2)" >> "$GITHUB_OUTPUT"
      - name: SBOM (SPDX and CycloneDX)
        run: |
          syft ghcr.io/baluraut/school-results@${{ steps.build.outputs.digest }} -o spdx-json=sbom.spdx.json -o cyclonedx-json=sbom.cdx.json
      - name: sign and attach the SBOM
        run: |
          cosign sign --yes ghcr.io/baluraut/school-results@${{ steps.build.outputs.digest }}
          cosign attest --yes --type spdxjson --predicate sbom.spdx.json ghcr.io/baluraut/school-results@${{ steps.build.outputs.digest }}
      - name: build provenance
        uses: actions/attest-build-provenance@v2
        with:
          subject-name: ghcr.io/baluraut/school-results
          subject-digest: ${{ steps.build.outputs.digest }}
          push-to-registry: true

Verify from anywhere — the signature must come from this workflow on main, issued by GitHub's OIDC:

cosign verify ghcr.io/baluraut/school-results@sha256:<digest> \
    --certificate-identity "https://github.com/BaluRaut/school-app/.github/workflows/release.yml@refs/heads/main" \
    --certificate-oidc-issuer "https://token.actions.githubusercontent.com"
gh attestation verify oci://ghcr.io/baluraut/school-results@sha256:<digest> --repo BaluRaut/school-app
grype sbom:./sbom.spdx.json          # "are we affected?" from the stored SBOM

Admission — Kyverno refuses any pod whose image from our registry is not signed by our release workflow (and rewrites tags to digests):

apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata: { name: verify-school-images }
spec:
  webhookTimeoutSeconds: 30
  rules:
    - name: signed-by-release-workflow
      match: { any: [{ resources: { kinds: [Pod] } }] }
      verifyImages:
        - imageReferences: ["ghcr.io/baluraut/*"]
          failureAction: Enforce
          mutateDigest: true
          attestors:
            - entries:
                - keyless:
                    subject: "https://github.com/BaluRaut/school-app/.github/workflows/release.yml@refs/heads/main"
                    issuer: "https://token.actions.githubusercontent.com"
                    rekor: { url: "https://rekor.sigstore.dev" }

🏭 Why this matters in production: add a second rule for images not from your registry (block them, or allow a short reviewed list), or the signature check only covers the images that were going to be fine anyway.

⏭️ Next

Each desk now has its own guard. Next we walk the whole delivery line from a developer's laptop to production, name the threats at every stage with STRIDE — and practise the first hour after a secret leaks.

git checkout lesson-15-pipeline-threats
← PreviousdependenciesNext →pipeline threats

This page is the lesson's README from the lesson-14-artifacts branch, shown here so the whole School stays on one site. Code files open on GitHub at the same branch.