Skip to content

Commit 85bb5fc

Browse files
javahippieclaude
andauthored
feat: Attest a CycloneDX SBOM for each published image (#44)
* feat: Attest a CycloneDX SBOM for each published image The SBOM shipped inside the image only covers the Operaton distribution. Each platform image now gets a trivy SBOM including OS packages and the JRE, attached as a keyless cosign attestation and verified after publishing. Closes #43 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: Retry SBOM verification while Docker Hub catches up Docker Hub lists a new attestation only a few seconds after it was pushed, so verifying right away failed. Also stop one failing publish job from cancelling the others mid-push. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent f98d775 commit 85bb5fc

4 files changed

Lines changed: 246 additions & 4 deletions

File tree

‎.github/workflows/build-test-and-publish.yml‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,13 @@ jobs:
9696
publish:
9797
runs-on: ubuntu-latest
9898
needs: build-and-test
99+
permissions:
100+
contents: read
101+
# Keyless signing of the SBOM attestations
102+
id-token: write
99103
strategy:
104+
# A failing distro must not cancel the others in the middle of a push
105+
fail-fast: false
100106
matrix:
101107
DISTRO: [run, tomcat, wildfly]
102108
JAVA_VERSION: ${{ fromJson(github.event.inputs.java_version || '["17", "21", "25"]') }}
@@ -119,11 +125,27 @@ jobs:
119125
core.exportVariable(key, process.env[key]);
120126
}
121127
});
128+
- name: Install cosign
129+
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
130+
with:
131+
cosign-release: v3.1.3
132+
- name: Install trivy
133+
env:
134+
TRIVY_VERSION: 0.75.0
135+
TRIVY_SHA256: c6e65abddb348e25f10549df887045629cf28cc72453cd1c63acb717316b3f3f
136+
run: |
137+
mkdir -p "$RUNNER_TEMP/trivy"
138+
curl -fsSLo "$RUNNER_TEMP/trivy.tar.gz" \
139+
"https://github.com/aquasecurity/trivy/releases/download/v${TRIVY_VERSION}/trivy_${TRIVY_VERSION}_Linux-64bit.tar.gz"
140+
echo "${TRIVY_SHA256} $RUNNER_TEMP/trivy.tar.gz" | sha256sum -c -
141+
tar -xzf "$RUNNER_TEMP/trivy.tar.gz" -C "$RUNNER_TEMP/trivy" trivy
142+
echo "$RUNNER_TEMP/trivy" >> "$GITHUB_PATH"
122143
- name: Publish multi-platform images
123144
run: ./release.sh
124145
env:
125146
DISTRO: ${{ matrix.DISTRO }}
126147
PLATFORMS: linux/arm64,linux/amd64
148+
SBOM_DIR: sbom
127149
JAVA_VERSION: ${{ matrix.JAVA_VERSION }}
128150
VERSION: ${{ github.event.inputs.version || '' }}
129151
SNAPSHOT: ${{ github.event.inputs.snapshot || '' }}
@@ -133,3 +155,10 @@ jobs:
133155
IMAGE_REPO_OPERATON: ${{ vars.IMAGE_REPO_OPERATON }}
134156
IMAGE_REPO_TOMCAT: ${{ vars.IMAGE_REPO_TOMCAT }}
135157
IMAGE_REPO_WILDFLY: ${{ vars.IMAGE_REPO_WILDFLY }}
158+
- name: Upload SBOMs
159+
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
160+
if: ${{ !cancelled() }}
161+
with:
162+
name: sbom-${{ matrix.DISTRO }}-jdk${{ matrix.JAVA_VERSION }}
163+
path: sbom/
164+
if-no-files-found: ignore

‎README.md‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,29 @@ Each of the repositories follows the tag schema below:
6868
- `SNAPSHOT`, `${VERSION}-SNAPSHOT`: A nightly build of the latest or a specific revision of Operaton. These are not officially released versions.
6969
- `${VERSION}`: A specific, officially released version of Operaton.
7070

71+
### SBOM
72+
73+
Every published platform image carries a signed [CycloneDX](https://cyclonedx.org/) SBOM attestation, generated with [trivy](https://trivy.dev/). Unlike `/operaton/sbom.cdx.json` inside the image, which only describes the Operaton distribution, it also covers the OS packages and the JRE of the base image.
74+
75+
SBOM attestations are only available for images published after their introduction. They are attached to the platform specific digests, so look these up first:
76+
77+
```shell
78+
docker buildx imagetools inspect operaton/operaton:latest
79+
```
80+
81+
Then verify the attestation and extract the SBOM with [cosign](https://docs.sigstore.dev/cosign/):
82+
83+
```shell
84+
cosign verify-attestation \
85+
--type cyclonedx \
86+
--certificate-identity-regexp '^https://github\.com/operaton/operaton-docker/\.github/workflows/build-test-and-publish\.yml@' \
87+
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
88+
operaton/operaton@sha256:<platform digest> \
89+
| jq -r '.payload' | base64 -d | jq '.predicate' > sbom.cdx.json
90+
```
91+
92+
The SBOMs are also available as artifacts of the publishing workflow run.
93+
7194
## Operaton configuration
7295

7396
You can find the complete Operaton documentation at https://docs.operaton.org/.

‎release.sh‎

Lines changed: 86 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,10 @@ elif [ "$DISTRO" == "wildfly" ]; then
3030
IMAGE=${IMAGE_REPO_WILDFLY}
3131
fi
3232

33+
SBOM_DIR=${SBOM_DIR:-sbom}
34+
3335
function build_and_push {
34-
local tag tags=()
36+
local tag tags=() metadata_file index_digest
3537
for tag in "$@"; do
3638
tags+=("${tag}${JAVA_TAG_SUFFIX}")
3739
# The default Java version is also published with an explicit suffix
@@ -40,6 +42,7 @@ function build_and_push {
4042
fi
4143
done
4244
printf -v tag_arguments -- "--tag $IMAGE:%s " "${tags[@]}"
45+
metadata_file="$(mktemp)"
4346
docker buildx build . \
4447
$tag_arguments \
4548
--build-arg DISTRO=${DISTRO} \
@@ -48,10 +51,88 @@ function build_and_push {
4851
--build-arg JAVA_VERSION=${JAVA_VERSION} \
4952
--cache-from type=gha,scope="$GITHUB_REF_NAME-$DISTRO-jdk$JAVA_VERSION-image" \
5053
--platform $PLATFORMS \
54+
--metadata-file "${metadata_file}" \
5155
--push
5256

5357
echo "Tags released:" >> $GITHUB_STEP_SUMMARY
5458
printf -- "- $IMAGE:%s\n" "${tags[@]}" >> $GITHUB_STEP_SUMMARY
59+
60+
# Attest the pushed digest rather than a tag, which could have moved meanwhile
61+
index_digest="$(python3 -c 'import json, sys
62+
print(json.load(open(sys.argv[1]))["containerimage.digest"])' "${metadata_file}")"
63+
attest_sboms "${IMAGE}@${index_digest}" "${tags[0]}"
64+
}
65+
66+
# Prints "<digest> <os>/<arch>" for each platform image of a multi-platform
67+
# image, skipping the provenance manifests buildx adds as "unknown/unknown"
68+
function list_platform_images {
69+
docker buildx imagetools inspect --raw "$1" | python3 -c 'import json, sys
70+
for manifest in json.load(sys.stdin).get("manifests", []):
71+
platform = manifest.get("platform", {})
72+
if platform.get("os", "unknown") != "unknown":
73+
print(manifest["digest"], platform["os"] + "/" + platform["architecture"])'
74+
}
75+
76+
# Verifies that the image has a CycloneDX attestation signed by this workflow
77+
function verify_sbom_attestation {
78+
local workflow="https://github.com/${GITHUB_WORKFLOW_REF%@*}@"
79+
cosign verify-attestation \
80+
--type cyclonedx \
81+
--certificate-identity-regexp "^${workflow//./\\.}" \
82+
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
83+
"$1" > /dev/null
84+
}
85+
86+
# Generates a CycloneDX SBOM for each platform image with trivy, covering the
87+
# OS packages and the JRE of the base image as well as the Operaton libraries,
88+
# and publishes it as a keyless signed attestation next to the image
89+
function attest_sboms {
90+
local image_ref="$1" label="$2" platform_images line digest platform sbom attempt
91+
platform_images="$(list_platform_images "${image_ref}")"
92+
if [ -z "${platform_images}" ]; then
93+
echo "No platform images found for ${image_ref}" >&2
94+
exit 1
95+
fi
96+
97+
mkdir -p "${SBOM_DIR}"
98+
echo "SBOM attestations:" >> $GITHUB_STEP_SUMMARY
99+
mapfile -t platform_images <<< "${platform_images}"
100+
for line in "${platform_images[@]}"; do
101+
read -r digest platform <<< "${line}"
102+
103+
# Re-runs of the workflow must not stack up duplicate attestations
104+
if verify_sbom_attestation "${IMAGE}@${digest}" 2> /dev/null; then
105+
echo "SBOM attestation for ${IMAGE}@${digest} already exists"
106+
echo "- ${platform}: \`${IMAGE}@${digest}\` (already attested)" >> $GITHUB_STEP_SUMMARY
107+
continue
108+
fi
109+
110+
sbom="${SBOM_DIR}/${IMAGE//\//-}-${label}-${platform//\//-}.cdx.json"
111+
# No "--scanners vuln": like the SBOMs of operaton/operaton, this is an
112+
# inventory only. Trivy still writes an empty vulnerabilities section,
113+
# which is dropped as well.
114+
trivy image --quiet --image-src remote --format cyclonedx \
115+
--output "${sbom}" "${IMAGE}@${digest}"
116+
python3 -c 'import json, sys
117+
with open(sys.argv[1]) as file:
118+
sbom = json.load(file)
119+
sbom.pop("vulnerabilities", None)
120+
with open(sys.argv[1], "w") as file:
121+
json.dump(sbom, file, indent=2)' "${sbom}"
122+
123+
cosign attest --yes --type cyclonedx --predicate "${sbom}" "${IMAGE}@${digest}"
124+
# Docker Hub lists new referrers with a short delay
125+
for attempt in 1 2 3 4 5 6; do
126+
if verify_sbom_attestation "${IMAGE}@${digest}"; then
127+
break
128+
elif [ "${attempt}" -eq 6 ]; then
129+
echo "SBOM attestation for ${IMAGE}@${digest} could not be verified" >&2
130+
exit 1
131+
fi
132+
sleep "${ATTESTATION_RETRY_DELAY:-10}"
133+
done
134+
echo "- ${platform}: \`${IMAGE}@${digest}\`" >> $GITHUB_STEP_SUMMARY
135+
done
55136
}
56137

57138
function get_highest_release_version {
@@ -112,14 +193,16 @@ function should_tag_latest {
112193
[ "$(printf '%s\n%s\n' "${highest_version}" "${VERSION}" | sort -V | tail -n1)" = "${VERSION}" ] && [ "${VERSION}" != "${highest_version}" ]
113194
}
114195

196+
echo "$DOCKERHUB_PASSWORD" | docker login -u "${DOCKERHUB_USERNAME}" --password-stdin
197+
115198
# check whether the image for distro was already released and exit in that case
116199
if [ $(docker manifest inspect $IMAGE:${VERSION}${JAVA_TAG_SUFFIX} > /dev/null ; echo $?) == '0' ]; then
117200
echo "Not pushing already released image"
201+
# A previous run may have pushed the image but failed to attest its SBOM
202+
attest_sboms "$IMAGE:${VERSION}${JAVA_TAG_SUFFIX}" "${VERSION}${JAVA_TAG_SUFFIX}"
118203
exit 0
119204
fi
120205

121-
echo "$DOCKERHUB_PASSWORD" | docker login -u "${DOCKERHUB_USERNAME}" --password-stdin
122-
123206
tags=()
124207

125208
if [ "${SNAPSHOT}" = "true" ]; then

‎test/test-release-tags.sh‎

Lines changed: 108 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,68 @@ cat > "${MOCK_DIR}/docker" <<'EOF'
1111
set -euo pipefail
1212
echo "$*" >> "${MOCK_DOCKER_CALLS}"
1313
if [ "$1" = "manifest" ] && [ "$2" = "inspect" ]; then
14-
exit 1
14+
[ "${MOCK_RELEASED:-false}" = "true" ]
15+
exit
16+
fi
17+
if [ "$1" = "buildx" ] && [ "$2" = "build" ]; then
18+
while [ "$#" -gt 0 ]; do
19+
if [ "$1" = "--metadata-file" ]; then
20+
echo '{"containerimage.digest":"sha256:index"}' > "$2"
21+
fi
22+
shift
23+
done
24+
exit 0
25+
fi
26+
if [ "$1" = "buildx" ] && [ "$2" = "imagetools" ]; then
27+
cat <<'JSON'
28+
{"manifests":[
29+
{"digest":"sha256:arm","platform":{"architecture":"arm64","os":"linux"}},
30+
{"digest":"sha256:amd","platform":{"architecture":"amd64","os":"linux"}},
31+
{"digest":"sha256:att","platform":{"architecture":"unknown","os":"unknown"}}
32+
]}
33+
JSON
34+
exit 0
1535
fi
1636
exit 0
1737
EOF
1838
chmod +x "${MOCK_DIR}/docker"
1939

40+
cat > "${MOCK_DIR}/trivy" <<'EOF'
41+
#!/usr/bin/env bash
42+
set -euo pipefail
43+
echo "$*" >> "${MOCK_SBOM_CALLS}"
44+
while [ "$#" -gt 0 ]; do
45+
if [ "$1" = "--output" ]; then
46+
echo '{"bomFormat":"CycloneDX","components":[],"vulnerabilities":[]}' > "$2"
47+
fi
48+
shift
49+
done
50+
EOF
51+
chmod +x "${MOCK_DIR}/trivy"
52+
53+
# Attestations are remembered per image reference, so that verify-attestation
54+
# only succeeds for images that were attested before
55+
cat > "${MOCK_DIR}/cosign" <<'EOF'
56+
#!/usr/bin/env bash
57+
set -euo pipefail
58+
echo "cosign $*" >> "${MOCK_SBOM_CALLS}"
59+
if [ "$1" = "attest" ]; then
60+
echo "${!#}" >> "${MOCK_ATTESTED}"
61+
# Like Docker Hub, list the new attestation only on a later lookup
62+
if [ "${MOCK_REFERRER_LAG:-false}" = "true" ]; then
63+
echo "${!#}" >> "${MOCK_ATTESTED}.lag"
64+
fi
65+
elif [ "$1" = "verify-attestation" ]; then
66+
if grep -qxF -- "${!#}" "${MOCK_ATTESTED}.lag" 2> /dev/null; then
67+
grep -vxF -- "${!#}" "${MOCK_ATTESTED}.lag" > "${MOCK_ATTESTED}.tmp" || true
68+
mv "${MOCK_ATTESTED}.tmp" "${MOCK_ATTESTED}.lag"
69+
exit 1
70+
fi
71+
grep -qxF -- "${!#}" "${MOCK_ATTESTED}"
72+
fi
73+
EOF
74+
chmod +x "${MOCK_DIR}/cosign"
75+
2076
cat > "${MOCK_DIR}/curl" <<'EOF'
2177
#!/usr/bin/env bash
2278
set -euo pipefail
@@ -40,11 +96,18 @@ run_release() {
4096
local java_version="${4:-17}"
4197
: > "${MOCK_DOCKER_CALLS}"
4298
: > "${MOCK_CURL_CALLS}"
99+
: > "${MOCK_SBOM_CALLS}"
100+
rm -rf "${MOCK_DIR}/sbom"
43101
PATH="${MOCK_DIR}:${PATH}" \
44102
MOCK_DOCKER_CALLS="${MOCK_DOCKER_CALLS}" \
45103
MOCK_CURL_CALLS="${MOCK_CURL_CALLS}" \
46104
MOCK_CURL_PAGE_1="${MOCK_CURL_PAGE_1}" \
47105
MOCK_CURL_PAGE_2="${MOCK_CURL_PAGE_2}" \
106+
MOCK_SBOM_CALLS="${MOCK_SBOM_CALLS}" \
107+
MOCK_ATTESTED="${MOCK_ATTESTED}" \
108+
SBOM_DIR="${MOCK_DIR}/sbom" \
109+
ATTESTATION_RETRY_DELAY=0 \
110+
GITHUB_WORKFLOW_REF="operaton/operaton-docker/.github/workflows/build-test-and-publish.yml@refs/heads/main" \
48111
DISTRO="run" \
49112
VERSION="${version}" \
50113
SNAPSHOT="${snapshot}" \
@@ -58,6 +121,9 @@ run_release() {
58121
}
59122

60123
MOCK_CURL_CALLS="${MOCK_DIR}/curl-calls.log"
124+
MOCK_SBOM_CALLS="${MOCK_DIR}/sbom-calls.log"
125+
MOCK_ATTESTED="${MOCK_DIR}/attested.log"
126+
: > "${MOCK_ATTESTED}"
61127
MOCK_CURL_PAGE_1="${MOCK_DIR}/curl-page-1.json"
62128
MOCK_CURL_PAGE_2="${MOCK_DIR}/curl-page-2.json"
63129

@@ -132,3 +198,44 @@ if grep -q -- "--tag operaton/operaton:latest-jdk21" "${MOCK_DOCKER_CALLS}"; the
132198
echo "latest-jdk21 must not be added when a higher jdk21 release already exists"
133199
exit 1
134200
fi
201+
202+
# Each platform image gets an SBOM attestation, the provenance manifest does not
203+
: > "${MOCK_ATTESTED}"
204+
run_release "main" "2.3.0" "false"
205+
grep -q -- "--metadata-file" "${MOCK_DOCKER_CALLS}"
206+
grep -q -- "buildx imagetools inspect --raw operaton/operaton@sha256:index" "${MOCK_DOCKER_CALLS}"
207+
grep -q -- "--format cyclonedx --output ${MOCK_DIR}/sbom/operaton-operaton-2.3.0-linux-amd64.cdx.json operaton/operaton@sha256:amd" "${MOCK_SBOM_CALLS}"
208+
grep -q -- "--format cyclonedx --output ${MOCK_DIR}/sbom/operaton-operaton-2.3.0-linux-arm64.cdx.json operaton/operaton@sha256:arm" "${MOCK_SBOM_CALLS}"
209+
grep -q -- "cosign attest --yes --type cyclonedx --predicate ${MOCK_DIR}/sbom/operaton-operaton-2.3.0-linux-amd64.cdx.json operaton/operaton@sha256:amd" "${MOCK_SBOM_CALLS}"
210+
grep -q -- "cosign attest --yes --type cyclonedx --predicate ${MOCK_DIR}/sbom/operaton-operaton-2.3.0-linux-arm64.cdx.json operaton/operaton@sha256:arm" "${MOCK_SBOM_CALLS}"
211+
grep -qF -- "--certificate-identity-regexp ^https://github\\.com/operaton/operaton-docker/\\.github/workflows/build-test-and-publish\\.yml@" "${MOCK_SBOM_CALLS}"
212+
if grep -q -- "sha256:att" "${MOCK_SBOM_CALLS}"; then
213+
echo "Provenance manifests must not get an SBOM attestation"
214+
exit 1
215+
fi
216+
if grep -q -- "vulnerabilities" "${MOCK_DIR}/sbom/operaton-operaton-2.3.0-linux-amd64.cdx.json"; then
217+
echo "The vulnerabilities section must be removed from the SBOM"
218+
exit 1
219+
fi
220+
221+
# A re-run for an already released image adds missing attestations only
222+
echo "operaton/operaton@sha256:amd" > "${MOCK_ATTESTED}"
223+
MOCK_RELEASED=true run_release "main" "2.3.0" "false"
224+
if grep -q -- "buildx build" "${MOCK_DOCKER_CALLS}"; then
225+
echo "Already released images must not be rebuilt"
226+
exit 1
227+
fi
228+
grep -q -- "buildx imagetools inspect --raw operaton/operaton:2.3.0" "${MOCK_DOCKER_CALLS}"
229+
grep -q -- "cosign attest .* operaton/operaton@sha256:arm" "${MOCK_SBOM_CALLS}"
230+
if grep -q -- "cosign attest .* operaton/operaton@sha256:amd" "${MOCK_SBOM_CALLS}"; then
231+
echo "Existing attestations must not be duplicated"
232+
exit 1
233+
fi
234+
235+
# Attestations that the registry lists with a delay are verified again
236+
: > "${MOCK_ATTESTED}"
237+
MOCK_REFERRER_LAG=true run_release "main" "2.4.0" "false"
238+
if [ "$(grep -c -- "cosign verify-attestation .* operaton/operaton@sha256:amd" "${MOCK_SBOM_CALLS}")" -ne 3 ]; then
239+
echo "A lagging attestation must be verified again"
240+
exit 1
241+
fi

0 commit comments

Comments
 (0)