Identify exactly what is promoted
A fictional team is preparing a funds-reconciler release. Development supplies an image, security supplies analysis results and APS receives the installation request. The first coordination task is establishing whether everyone means the same content. A tag, ticket number and application name help organize work, but do not necessarily identify immutable bytes. Record the approved digest, platform, rendered manifests and tool versions that produced each item of evidence. Draw the flow from authorized code to the workload that will start: source, build, artifact publication, inventory, verification, decision and consumption. At each transition, ask which identifier is preserved and which actor can change it. If verification resolves a tag at 10:00 and deployment resolves it again at 10:02, another job may have changed its target. Promote the approved digest reference and confirm the consumed configuration. A multi-architecture index and its selected ARM64 manifest are related objects, but are not the same object. Evidence must identify which was analyzed. This discipline supports investigation without assuming that the word release always represents the same content. Ask the release owner to make this relationship explicit in acceptance criteria before the installation window starts.
Reduce components and preserve functionality
The final image needs components required for execution. A compilation stage can contain compilers, tests and development tools without sending all of them into production. In a multistage build, inspect paths copied into the final stage and inventory that result. The compiler-stage SBOM can help investigate the build, but should not be presented as proof of what was delivered to runtime. Reduction requires concrete compatibility checks. If a client starts failing HTTPS with unknown authority, investigate trust certificates and the presented chain; do not disable TLS validation to compensate for an incomplete base. Also consider dynamic libraries, users, permissions, time-zone data and writable paths required by the application. Explain which needs are real and how you observed them. A small base does not guarantee freedom from vulnerabilities. A digest-pinned reference improves repeatability but still needs an update process. Disabling cache with --no-cache does not automatically obtain the newest base. If a build used ARG for a credential, removing the current instruction neither revokes the token nor removes previous images. Investigate exposure and correct secret delivery. Acceptance should connect the reduced component set to successful business behavior, rather than treating smaller size alone as success.
Read an SBOM within its scope
An SBOM describes components observed or declared according to a tool, source and configuration. Before comparing reports, establish whether they analyzed source code, an installed directory, a build stage or the final image. Syft defaults differ between directories and images: a dependency declared in a file can appear in the repository without existing as an installed package in the result. Examine evidence before classifying the difference as an error. Retain platform, source type and executed catalogers. If you select only Python, missing operating-system packages do not prove those packages disappeared from the image. In a local exercise, docker-archive:/path/image.tar expresses the archive to read; a short reference can activate other resolution mechanisms. This lesson’s exercise generates Syft JSON and CycloneDX from a local ARM64 archive without querying an image registry. Inventory and vulnerability analysis produce different outputs. The lab does not run Grype or download an advisory database. Absence of vulnerability findings cannot be inferred from this exercise. For a real decision, also record vulnerability-data version and date, package matching, applicability and exceptions. The digest can remain unchanged while knowledge about a library changes. Use inventory to identify candidates for investigation and keep the eventual exposure assessment distinct from simple component presence.
Verify bytes, key and identity
A valid signature connects bytes to verification material under a trust model. It does not automatically grant change approval, freedom from vulnerabilities or compliance with business requirements. With organization-managed keys, the consumer must know the authorized key through a trusted channel. If a package contains a new key that validates its own signature, rotation still needs authentication before replacing the approved trust anchor. In keyless verification, expected identity and OIDC issuer form part of the decision. Accepting every organization workflow can be broader than authorizing one release workflow. Test negative matches involving another repository, workflow and reference. This lesson explains that model, but the laboratory uses disposable local keys and does not perform OIDC authentication. The exercise signs a JSON document containing hashes of an archive and an SBOM. verify-blob validates that document. To determine whether received files correspond, the consumer must still compute their hashes and compare them with authenticated values. A tar archive hash is not automatically an OCI image or index digest. Retain each object’s name and type to avoid a false association during handover. A correct cryptographic result is useful only when the team can explain precisely which bytes and trust material it covers.
Compare provenance with expectations
Useful provenance supports investigation of artifact origin and the process that built it. Verification needs consumer-defined expectations: builder identity, authorized repository, build type, accepted parameters and binding to delivered content. A signature authenticates a statement, including a statement describing an unauthorized process. Signature verification and policy evaluation are therefore separate steps. In the SLSA model, trust should not increase merely because a producer declares a high level. The verifier uses established trust configuration for the identity and builder. Unknown external parameters can change the result and require assessment before acceptance. Keep provenance format and version explicit so fields can be interpreted correctly. The lab creates its own teaching JSON format. It is not a complete SLSA provenance implementation, does not use a DSSE attestation and demonstrates no SLSA level. It shows a concrete boundary: the same laboratory key signs a document with a different builder or commit; its signature passes, but comparison with local expectations rejects it. The decision is reproducible because the approved set is defined before the candidate arrives. In production, protecting and governing that configuration is an essential part of the control. Record who may change expectations, how changes are approved and how consumers receive the approved version.
Make static analysis affect the decision
Analyze manifests actually rendered for the environment. A chart with no findings under development values can introduce a privileged sidecar through production values. Retain the resulting template, check configuration and linter version. An execution that finds no objects does not demonstrate coverage; an execution that finds problems but ends with || true can also leave the pipeline green without meeting its intended gate. The exercise selects three KubeLinter checks: privileged-container, run-as-non-root and no-read-only-root-fs. One synthetic Deployment fails all three, another corrects relevant fields, and a comment-only file is rejected because no objects exist. The second result proves only that those checks passed for that manifest. It does not establish startup, authorization, network policy, image signatures or complete Pod Security compliance. For an exceptional need, avoid globally removing a rule without assessing its reach. Associate the exemption with its resource, justification, owner, expiry and compensating controls. The tool supports checks and exceptions; risk approval belongs to the organization’s process. If an image-policy controller exists, also inspect namespace coverage and policy matching. Installing the component does not establish that every request passes through the control. Include allowed and denied requests in a separate admission exercise before relying on that boundary.
Run and interpret the local exercise
Prerequisites are Python 3, an image archive previously exported for linux/arm64, and Syft 1.54.1, Cosign 3.1.3 and KubeLinter 0.8.3 executables. Save the displayed code as run.py. Run python3 run.py --image-archive /path/image.tar --output /path/new-result.json; use --syft, --cosign and --kube-linter to specify executables outside PATH. The script refuses to overwrite an existing result, reads the archive and uses a temporary directory for its own files. In both final trials, the local archive produced 37 components and a CycloneDX document. That count belongs to the analyzed image; it is not a required result for every image. Inspect all 14 records: inventories, original signature, edited document, different key, hash binding, changed SBOM, different candidate digest, unapproved builder and commit, manifests and empty input. The different candidate digest is computed by adding a marker while hashing the bytes; the input archive is not modified. Keys are newly generated, disposable and protected by a password generated during execution. Configuration omits public services. --insecure-ignore-tlog is used only for these unpublished local blob signatures: it demonstrates no Rekor inclusion and must not be copied into a gate requiring transparency. The exercise installs no workloads. Finally, confirm temporary-file removal and unchanged input-archive integrity.
Decide the release and hand over operations
In the final case, the release.json signature passes but the received SBOM does not match its authenticated hash. Twenty-five minutes remain before the reconciliation release decision. The team should preserve files, identify the discrepancy’s origin and request a coherent package. Manually changing the document hash neither restores the original signature nor establishes approval. If binding remains unproven within the deadline, apply approved contingency and communicate business impact. Prepare a relationship for RUN between digest, platform, authorized source, inventory, analyses, versions, exceptions and decision. Include who maintains each control and how artifacts can be retrieved during an incident. A rollback runbook referencing content deleted from the registry is not executable: align retention with the recovery window and test access to required bytes and evidence. Risk reassessment also belongs to operations because advisories and exception expiry dates change after publication. Summary: identify content, observe components, authenticate evidence, compare expectations and confirm gates affect promotion. Every conclusion should state what was observed and what remains untested. Connect this lesson to identities, workload admission, change management and incident response. Banking examples are original and fictional and do not represent internal BNP Paribas procedures. Ask a colleague to explain one accepted result and one refusal using the recorded evidence before closing the handover.
#!/usr/bin/env python3
"""Local CKS exercise: actual inventory, blob signatures and static manifests.
Use an existing exported linux/arm64 Docker image archive. No registry writes,
OIDC login, production credentials, Kubernetes admission or SLSA level claim.
Temporary keys and fixtures are removed even when a check fails.
"""
import argparse, copy, datetime, hashlib, json, os, pathlib, secrets, subprocess, tempfile
p = argparse.ArgumentParser(description=__doc__)
p.add_argument('--image-archive', required=True)
p.add_argument('--syft', default='syft')
p.add_argument('--cosign', default='cosign')
p.add_argument('--kube-linter', default='kube-linter')
p.add_argument('--output', required=True)
a = p.parse_args
archive = pathlib.Path(a.image_archive).resolve(strict=True)
out = pathlib.Path(a.output).resolve
if out.exists or out == archive:
raise SystemExit('Choose a new output path; input is read only.')
def sha(path):
h = hashlib.sha256
with open(path, 'rb') as f:
for part in iter(lambda: f.read(1024 * 1024), b''):
h.update(part)
return h.hexdigest
observations = []
def observe(name, passed, **details):
observations.append(dict(name=name, passed=bool(passed), observed=details))
if not passed:
raise AssertionError(name + ': ' + str(details))
report = dict(startedAt=datetime.datetime.now(datetime.timezone.utc).isoformat,
scriptSha256=sha(__file__), observations=observations,
scope='Local image inventory, disposable-key blob signatures, explicit binding policy and selected static checks. No OCI image signature, vulnerability scan, keyless identity, transparency-log proof, live admission or SLSA certification.',
inputArchiveSha256=sha(archive), platform='linux/arm64',
fullPracticalMock=False, independentVerification=False)
try:
with tempfile.TemporaryDirectory(prefix='dr-cks-supply-') as temporary:
root = pathlib.Path(temporary)
# A new passphrase for new disposable keys; never printed or persisted.
env = dict(os.environ, COSIGN_PASSWORD=secrets.token_urlsafe(24),
COSIGN_YES='true', SYFT_CHECK_FOR_APP_UPDATE='false')
def run(tool, *args, ok=True):
result = subprocess.run([tool, *map(str, args)], cwd=root, env=env,
text=True, capture_output=True, timeout=120)
if ok and result.returncode:
raise RuntimeError(f'{pathlib.Path(tool).name} {args[0]}: {result.stderr[-1800:]}')
return result
report['versions'] = dict(syft=run(a.syft, 'version', '-o', 'json').stdout,
cosign=run(a.cosign, 'version', '--json').stdout,
kubeLinter=run(a.kube_linter, 'version').stdout.strip)
# Explicit local source and platform, no fallback to a remote registry.
run(a.syft, 'scan', 'docker-archive:' + str(archive), '--platform', 'linux/arm64',
'-o', 'syft-json=sbom.json', '-o', 'cyclonedx-json=sbom.cdx.json')
sbom = json.loads((root / 'sbom.json').read_text)
packages = sbom['artifacts']
observe('image-inventory-generated', len(packages) > 0,
packages=len(packages), sample=sorted({x['name'] for x in packages})[:8],
sourceType=sbom['source']['type'], tool=sbom['descriptor']['name'],
source=sbom['source'], catalogers=sbom['descriptor']['configuration'].get('catalogers'))
cdx = json.loads((root / 'sbom.cdx.json').read_text)
observe('cyclonedx-generated', cdx['bomFormat'] == 'CycloneDX' and len(cdx.get('components', [])) > 0,
specVersion=cdx['specVersion'], components=len(cdx.get('components', [])))
for name in ['trusted-lab', 'other-lab']:
run(a.cosign, 'generate-key-pair', '--output-key-prefix', name)
run(a.cosign, 'signing-config', 'create', '--no-default-rekor', '--no-default-fulcio',
'--no-default-oidc', '--no-default-tsa', '--out', 'local-signing.json')
# These deliberately local signatures provide NO public transparency evidence.
# The ignore flag is confined to verify-blob with a newly generated lab key.
def sign(name):
run(a.cosign, 'sign-blob', '--key', 'trusted-lab.key', '--signing-config',
'local-signing.json', '--bundle', name + '.bundle', name)
def verify(name, key='trusted-lab.pub', bundle=None):
return run(a.cosign, 'verify-blob', '--key', key, '--bundle', bundle or name + '.bundle',
'--insecure-ignore-tlog', name, ok=False)
approved = dict(builder='https://ci.example.invalid/funds/release-v2',
repository='https://git.example.invalid/funds/reconciler',
sourceCommit='0123456789abcdef0123456789abcdef01234567',
buildType='dr.pt/local-training-evidence/v1')
evidence = dict(**approved, archiveSha256=sha(archive), sbomSha256=sha(root / 'sbom.json'))
def save(name, value):
(root / name).write_text(json.dumps(value, sort_keys=True) + '\n')
save('release.json', evidence)
sign('release.json')
result = verify('release.json')
observe('original-blob-signature-valid', result.returncode == 0, exitCode=result.returncode)
(root / 'edited.json').write_text((root / 'release.json').read_text + ' ')
result = verify('edited.json', bundle='release.json.bundle')
observe('edited-blob-signature-denied', result.returncode!= 0, exitCode=result.returncode)
result = verify('release.json', key='other-lab.pub')
observe('different-key-denied', result.returncode!= 0, exitCode=result.returncode)
def policy(record, archive_hash, sbom_hash):
return (all(record.get(k) == v for k, v in approved.items)
and record.get('archiveSha256') == archive_hash
and record.get('sbomSha256') == sbom_hash)
observe('original-bindings-accepted', policy(evidence, sha(archive), sha(root / 'sbom.json')),
archiveSha256=evidence['archiveSha256'], sbomSha256=evidence['sbomSha256'])
# Editing a separate unsigned file does not change a signature on release.json.
(root / 'changed-sbom.json').write_bytes((root / 'sbom.json').read_bytes + b'\n')
observe('changed-sbom-binding-denied',
verify('release.json').returncode == 0 and not policy(evidence, sha(archive), sha(root / 'changed-sbom.json')),
signedRecordStillValid=True, separateSBOMBindingAccepted=False)
changed = hashlib.sha256
with archive.open('rb') as f:
for part in iter(lambda: f.read(1024 * 1024), b''):
changed.update(part)
changed.update(b'local-tampering-marker')
observe('different-archive-digest-denied', not policy(evidence, changed.hexdigest, sha(root / 'sbom.json')),
candidateDigest=changed.hexdigest, accepted=False)
other = dict(evidence, builder='https://ci.example.invalid/unapproved/runner')
save('other-builder.json', other)
sign('other-builder.json')
observe('other-builder-signature-valid', verify('other-builder.json').returncode == 0,
note='The approved lab key can sign a claim that policy does not approve.')
observe('other-builder-policy-denied', not policy(other, sha(archive), sha(root / 'sbom.json')),
accepted=False)
other = dict(evidence, sourceCommit='f' * 40)
save('other-commit.json', other)
sign('other-commit.json')
observe('other-commit-policy-denied', verify('other-commit.json').returncode == 0 and not policy(other, sha(archive), sha(root / 'sbom.json')),
signedRecordValid=True, sourceCommitAccepted=False)
# Original local fixtures. These are parsed, never submitted to a cluster.
bad = dict(apiVersion='apps/v1', kind='Deployment', metadata=dict(name='reconcile', namespace='training'),
spec=dict(replicas=1, selector=dict(matchLabels=dict(app='reconcile')),
template=dict(metadata=dict(labels=dict(app='reconcile')),
spec=dict(containers=[dict(name='worker', image='example.invalid/reconcile:training',
securityContext=dict(privileged=True))]))))
good = copy.deepcopy(bad)
good['spec']['template']['spec']['containers'][0]['securityContext'] = dict(
privileged=False, runAsNonRoot=True, runAsUser=10001, readOnlyRootFilesystem=True)
for name, value in [('bad.json', bad), ('good.json', good)]:
save(name, value)
checks = 'privileged-container,run-as-non-root,no-read-only-root-fs'
def lint(name):
return run(a.kube_linter, 'lint', name, '--do-not-auto-add-defaults',
'--include', checks, '--fail-if-no-objects-found', '--format', 'json', ok=False)
result = lint('bad.json')
findings = json.loads(result.stdout)
names = sorted({x['Check'] for x in findings.get('Reports', [])})
observe('insecure-manifest-findings', result.returncode!= 0 and names == sorted(checks.split(',')),
exitCode=result.returncode, checks=names)
result = lint('good.json')
observe('corrected-selected-checks-pass', result.returncode == 0, exitCode=result.returncode,
selectedChecks=checks.split(','), scope='Only these three checks; no runtime or admission evidence.')
(root / 'empty.yaml').write_text('# No Kubernetes objects\n')
result = lint('empty.yaml')
observe('empty-input-denied', result.returncode!= 0, exitCode=result.returncode)
report['cleanup'] = dict(realCredentialsUsed=False, temporaryKeysCreated=2)
report['cleanup']['temporaryMaterialRemoved'] = not root.exists
report['inputUnchanged'] = sha(archive) == report['inputArchiveSha256']
report['passed'] = all(x['passed'] for x in observations) and report['inputUnchanged']
finally:
report['finishedAt'] = datetime.datetime.now(datetime.timezone.utc).isoformat
out.write_text(json.dumps(report, indent=2) + '\n')
print(json.dumps(dict(passed=report['passed'], observations=len(observations), output=str(out))))
The document signature passes, but the delivered SBOM differs from its authenticated hash; the decision deadline requires demonstrated correction or contingency.
Common pitfalls
Confusing inventory with vulnerability analysis, signatures with approval, archive hashes with OCI digests or zero findings with complete analysis.
Related topics: Identity and trust · Workload admission · Change and recovery management
A release decision needs identified content, linked evidence and evaluated expectations with explicit scope and versions.
Reference: CKS certification and domains · Kubernetes v1.35; current six-domain CKS outline