ADR-032: Container image signing + SBOM (cosign + CycloneDX)¶
Status¶
Accepted — 2026-04-25 (Sprint 46 Stream A1)
Context¶
The CTO audit dated 2026-04-25 flagged "no image signing = supply chain attack possible" as a top-tier risk. AKKO publishes 14 custom images per release (harbor.akko-ai.com/akko/<image>:2026.04). They are pulled by every cluster — k3d dev, customer Netcup, future EKS / GKE — and the only thing that today proves authenticity is "we trust Harbor". A registry compromise, a stolen Harbor robot account, or a single malicious PR squash-merging into main would let an attacker ship code to every operator without anyone noticing.
Banking and healthcare prospects (the two pilot verticals tracked in akko-confidential/sales-playbook/) explicitly require:
- DORA Article 6 — ICT supply-chain integrity controls.
- NIS2 Annex II — software bill of materials for "essential entities".
- HDS (Hébergeur de Données de Santé) Section 6.7 — provenance verification of every container deployed in the production data plane.
We need:
- A way to sign every image so operators can prove "this artefact was published by AKKO and was not tampered with after build".
- A way to enumerate every dependency (SBOM) so a CVE in
libssl3.0.7 surfaces in 14 builds without re-scanning each image. - A way to verify both signals at pull time — eventually as an admission policy, today as a documented operator step.
Constraints:
- 100 % open source, Apache-2.0-or-equivalent license. No Docker Scout, no Snyk Container, no Aqua. Sovereign-friendly.
- Foundation governance preferred (ADR-029 rule). Single-vendor projects are excluded for load-bearing supply-chain bricks.
- Must run inside the existing Woodpecker pipeline. Build runners have no daemon, no privileged access beyond what Buildah already requires (ADR-025).
- Toggleable for air-gapped / offline customers. Not every operator can reach
fulcio.sigstore.devfrom inside their build network. - Idempotent. A Woodpecker re-run on the same digest must not duplicate signatures or fail.
Considered options¶
Image signing¶
| Option | License | Governance | Verdict |
|---|---|---|---|
| cosign (Sigstore) | Apache 2.0 | Linux Foundation (OpenSSF) | Selected |
GPG / docker trust |
GPL | Docker Inc. (single-vendor) | Workflow predates SLSA, key management is operator-owned, no transparency log. Rejected. |
| Notary v1 | Apache 2.0 | CNCF (deprecated) | Replaced by Notary v2 / cosign. Rejected. |
Notary v2 / notation |
Apache 2.0 | CNCF | Viable alternative. Smaller community than cosign, weaker tooling around SBOM attestation. Held in reserve as a second source. |
| Docker Scout | Commercial (Docker Inc.) | Single-vendor | Violates the open-source constraint. Rejected. |
Why cosign: Linux Foundation governance (rule from ADR-029), first-class CycloneDX + SPDX attestation support, mature ecosystem (Kyverno, Gatekeeper, Tekton Chains, ArgoCD all consume cosign signatures natively), and the keyless mode (Fulcio + Rekor) gives us a forward path to zero long-lived signing keys.
SBOM format¶
| Option | Maintainer | License | 2025+ industry default? |
|---|---|---|---|
| CycloneDX 1.5 | OWASP | Apache 2.0 | Yes — adopted by NIST, OWASP, Anchore, Snyk, Dependency-Track |
| SPDX 2.3 | Linux Foundation | CC-BY-3.0 | Common in Linux distro / SBOM-as-policy work, weaker tooling for container SBOMs |
| Syft native | Anchore | Apache 2.0 | Internal only, not consumed downstream |
Why CycloneDX: OWASP standard, Apache 2.0, the format every commercial SBOM scanner (Anchore Enterprise, Snyk, JFrog Xray, Dependency-Track) ingests natively. SPDX remains a strong second choice, but CycloneDX is the 2025+ default for container-centric SBOMs. We emit CycloneDX 1.5 JSON; we can add SPDX as a second predicate later (cosign supports both side-by-side on the same digest).
SBOM generator¶
syft (Anchore, Apache 2.0) — broadest ecosystem support (Alpine, Debian, RPM, Python, Java, Node, Go, Rust). The single-vendor Anchore caveat is mitigated by the BYOS pattern from ADR-029: SBOM generation is a pluggable build step, not load-bearing. Trivy can also emit CycloneDX SBOMs and is already in pipeline 06-trivy.yml — we keep it as a fallback.
Signing identity (keypair vs keyless Fulcio)¶
We ship keypair as the day-1 default and plan keyless Fulcio as the long-term default.
- Keypair: deterministic, runs in air-gapped clusters, no external dependency. Downside: a long-lived private key in a K8s Secret is a juicy target. Rotation procedure is documented in
helm/scripts/cosign-init.sh --rotate. - Keyless Fulcio: short-lived OIDC-bound certificates, transparency log via Rekor, no private key to leak. Requires the build runner to obtain an OIDC token bound to the pipeline. Woodpecker v3.4 lacks first-class OIDC issuer integration (tracked upstream:
woodpecker-ci/woodpecker#3210); migration deferred until that ships. Toggle today viaAKKO_SIGN_KEYLESS=truefor operators whose build environment already has the OIDC plumbing.
Decision¶
- Sign every image published to
harbor.akko-ai.com/akko/*:2026.04with cosign. - Attach a CycloneDX 1.5 SBOM as an in-toto attestation on the same digest (predicate type
cyclonedx). - Pipeline: new
.woodpecker/10-image-sign.ymlruns after the build steps in08-deploy-netcup.ymlsucceed. - Bootstrap:
helm/scripts/cosign-init.shgenerates the keypair and stores it asSecret/akko-cosign-keysin theharbornamespace. The public key is committed athelm/akko/charts/akko-cockpit/files/cosign.pub. - Verification today (Sprint 46): documented operator command + a
cosign verifystep appended to06-trivy.ymlimage-scanso a missing signature blocks merges tomain. - Verification tomorrow (Sprint 47): Kyverno or OPA Gatekeeper policy at admission time refuses any pod whose image lacks a valid AKKO signature.
- Kill switch:
AKKO_SIGN_IMAGES=false(Woodpecker secret) skips the entire signing pipeline. Default istrue.
Verification commands (operators)¶
# Pull the AKKO public key (committed in the repo, mirror-friendly)
PUBKEY="https://harbor.akko-ai.com/api/v2.0/projects/akko/repositories/cockpit/artifacts/2026.04/additions/cosign.pub"
# Verify signature
cosign verify --key cosign.pub harbor.akko-ai.com/akko/cockpit:2026.04
# Pull and validate the CycloneDX SBOM
cosign download attestation harbor.akko-ai.com/akko/cockpit:2026.04 \
| jq -r .payload | base64 -d | jq '.predicate' > sbom-cockpit.cdx.json
# Forward to your SBOM scanner
curl -X POST -H "Content-Type: application/vnd.cyclonedx+json" \
--data-binary @sbom-cockpit.cdx.json \
https://your-dependency-track/api/v1/bom
Future ImagePolicyWebhook (Sprint 47)¶
A Kyverno ClusterPolicy (or OPA Gatekeeper ConstraintTemplate) will enforce, at admission time, that every pod whose image starts with harbor.akko-ai.com/akko/ carries:
- A valid cosign signature against
cosign.pubmounted fromSecret/akko-cosign-keys. - A
cyclonedxattestation on the same digest, generated within the last 90 days.
# helm/akko/charts/akko-opa/templates/clusterpolicy-image-signing.yaml (Sprint 47)
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: akko-require-signed-images
spec:
validationFailureAction: Enforce
rules:
- name: verify-signature
match:
any:
- resources:
kinds: [Pod]
verifyImages:
- imageReferences:
- "harbor.akko-ai.com/akko/*"
attestors:
- entries:
- keys:
publicKeys: |-
-----BEGIN PUBLIC KEY-----
...
-----END PUBLIC KEY-----
attestations:
- type: cyclonedx
attestors:
- entries:
- keys:
publicKeys: |-
-----BEGIN PUBLIC KEY-----
...
-----END PUBLIC KEY-----
This decouples signing (CI) from verification (cluster) so a future helm upgrade --set akko-opa.imagePolicy.enforce=true flip is a single value change, not a code change.
Consequences¶
Positive¶
- DORA / NIS2 / HDS supply-chain checkbox addressed end-to-end.
- Banking + healthcare prospects can audit AKKO's provenance trail with industry-standard tooling (no proprietary verifier required).
- Future-proof: keyless Fulcio migration is one env flag away when Woodpecker OIDC lands.
- Idempotent — re-running the pipeline costs ~2 s/image after the first run.
Negative / mitigations¶
- Pipeline runtime +30-60 s per image on the first run (sign + syft scan + attest). Cached on subsequent runs. Acceptable.
- Long-lived signing key in K8s Secret. Mitigated by (a) tight RBAC on the
harbornamespace, (b) rotation procedure, (c) keyless migration plan. - Fulcio reachability in air-gapped builds. Mitigated by the
AKKO_SIGN_IMAGES=falsekill switch. - One more thing to maintain. Mitigated by the modular Woodpecker file structure — disabling the pipeline is a 1-line change.
References¶
- Sigstore project: https://www.sigstore.dev (Linux Foundation, OpenSSF)
- cosign GitHub: https://github.com/sigstore/cosign (Apache 2.0)
- syft GitHub: https://github.com/anchore/syft (Apache 2.0)
- CycloneDX spec: https://cyclonedx.org (OWASP)
- SLSA framework: https://slsa.dev (Linux Foundation, OpenSSF)
- Kyverno verifyImages: https://kyverno.io/docs/writing-policies/verify-images/
- DORA Article 6: https://eur-lex.europa.eu/eli/reg/2022/2554/oj
- NIS2 Annex II: https://eur-lex.europa.eu/eli/dir/2022/2555/oj
- ADR-022: CI strategy
- ADR-025: Buildah privileged build steps
- ADR-029: Governance over license — tool selection rule
Migration / rollout plan¶
| Step | When | Owner | Status |
|---|---|---|---|
| Bootstrap key, K8s Secret, public key in repo | Sprint 46 W1 | Stream A1 | Done (this ADR + cosign-init.sh) |
New pipeline .woodpecker/10-image-sign.yml |
Sprint 46 W1 | Stream A1 | Done |
cosign verify enforcement in 06-trivy.yml |
Sprint 46 W1 | Stream A1 | Done |
Operator docs (docs/docs/admin/security.md) |
Sprint 46 W1 | Stream A1 | Done |
| Kyverno policy in audit mode | Sprint 47 W1 | Stream A2 | Backlog |
| Kyverno policy in enforce mode | Sprint 47 W2 | Stream A2 | Backlog |
| Keyless Fulcio migration | Once Woodpecker #3210 lands | Stream A1 | Backlog |