Skip to content

Commit 4a5d101

Browse files
committed
ci: gate container releases on high vulnerabilities and build daily
1 parent 08bf291 commit 4a5d101

3 files changed

Lines changed: 192 additions & 4 deletions

File tree

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
name: Container security gate
2+
description: Generate a Syft SBOM and fail on any High or Critical Grype finding for every image platform.
3+
inputs:
4+
image:
5+
description: Immutable registry digest or local OCI archive to scan
6+
required: true
7+
platforms:
8+
description: Comma-separated image platforms
9+
required: true
10+
report-directory:
11+
description: Job-local directory for SBOMs and vulnerability reports
12+
required: true
13+
artifact-name:
14+
description: Unique artifact name for this job and build
15+
required: true
16+
runs:
17+
using: composite
18+
steps:
19+
- name: Install Syft
20+
id: syft
21+
uses: anchore/sbom-action/download-syft@66cbf4bc1f1c0d2edc94016e65bc221b6bb0ad6c # v0.24.3
22+
with:
23+
syft-version: 1.54.0
24+
- name: Install Grype
25+
id: grype
26+
uses: anchore/scan-action/download-grype@27805bf3b4e84b4a5c980df22ed233c00390a439 # v7.4.2
27+
with:
28+
grype-version: 0.118.0
29+
cache-db: false
30+
- name: Scan every platform and enforce High/Critical gate
31+
shell: bash
32+
env:
33+
SECURITY_IMAGE: ${{ inputs.image }}
34+
SECURITY_PLATFORMS: ${{ inputs.platforms }}
35+
SECURITY_REPORT_DIRECTORY: ${{ inputs.report-directory }}
36+
SYFT_CMD: ${{ steps.syft.outputs.cmd }}
37+
GRYPE_CMD: ${{ steps.grype.outputs.cmd }}
38+
SECURITY_ACTION_PATH: ${{ github.action_path }}
39+
run: bash "$SECURITY_ACTION_PATH/container-security.sh" scan
40+
- name: Retain SBOMs and vulnerability reports
41+
if: ${{ always() }}
42+
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
43+
with:
44+
name: ${{ inputs.artifact-name }}
45+
path: ${{ inputs.report-directory }}/
46+
retention-days: 14
47+
if-no-files-found: warn
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
# prepare runs before Buildx; scan runs in the composite action; publish runs
5+
# only after the scan action succeeds. Credentials remain in Docker's config.
6+
case "${1:-}" in
7+
prepare)
8+
: "${SECURITY_REPORT_DIRECTORY:?}" "${SECURITY_PUBLISH:?}" "${GITHUB_OUTPUT:?}"
9+
mkdir -p "$SECURITY_REPORT_DIRECTORY"
10+
archive="$SECURITY_REPORT_DIRECTORY.oci.tar"
11+
repository="container-security-local"
12+
: > "$SECURITY_REPORT_DIRECTORY/release-tags.txt"
13+
if [[ "$SECURITY_PUBLISH" == true ]]; then
14+
: "${SECURITY_RELEASE_TAGS:?}"
15+
repository=""
16+
while IFS= read -r tag; do
17+
[[ -z "$tag" ]] && continue
18+
[[ "$tag" =~ ^ghcr\.io/[a-zA-Z0-9._/-]+:[a-zA-Z0-9_.-]+$ ]] || {
19+
echo "Unsupported release tag: $tag" >&2; exit 2;
20+
}
21+
candidate="${tag%:*}"
22+
if [[ -n "$repository" && "$candidate" != "$repository" ]]; then
23+
echo "One build must publish to one image repository" >&2; exit 2
24+
fi
25+
repository="$candidate"
26+
printf '%s\n' "$tag" >> "$SECURITY_REPORT_DIRECTORY/release-tags.txt"
27+
done <<< "$SECURITY_RELEASE_TAGS"
28+
[[ -s "$SECURITY_REPORT_DIRECTORY/release-tags.txt" ]] || exit 2
29+
exporter='type=image,push-by-digest=true,name-canonical=true,push=true'
30+
elif [[ "$SECURITY_PUBLISH" == false ]]; then
31+
exporter="type=oci,dest=$archive"
32+
else
33+
echo "SECURITY_PUBLISH must be true or false" >&2; exit 2
34+
fi
35+
printf 'repository=%s\nexporter=%s\narchive=%s\n' "$repository" "$exporter" "$archive" >> "$GITHUB_OUTPUT"
36+
;;
37+
scan)
38+
: "${SECURITY_IMAGE:?}" "${SECURITY_PLATFORMS:?}" "${SECURITY_REPORT_DIRECTORY:?}" "${SYFT_CMD:?}" "${GRYPE_CMD:?}"
39+
mkdir -p "$SECURITY_REPORT_DIRECTORY"
40+
printf '%s\n' "$SECURITY_IMAGE" > "$SECURITY_REPORT_DIRECTORY/scanned-image.txt"
41+
printf '%s\n' "$SECURITY_PLATFORMS" > "$SECURITY_REPORT_DIRECTORY/scanned-platforms.txt"
42+
: > "$SECURITY_REPORT_DIRECTORY/gate.txt"
43+
# Explicit empty configurations prevent project ignore rules from silently
44+
# excluding findings or turning this into an only-fixed scan.
45+
printf '{}\n' > "$SECURITY_REPORT_DIRECTORY/syft-config.yaml"
46+
printf '{}\n' > "$SECURITY_REPORT_DIRECTORY/grype-config.yaml"
47+
export SYFT_CHECK_FOR_APP_UPDATE=false GRYPE_CHECK_FOR_APP_UPDATE=false
48+
export GRYPE_DB_AUTO_UPDATE=true GRYPE_DB_VALIDATE_AGE=true
49+
failed=0
50+
count=0
51+
IFS=',' read -r -a platforms <<< "${SECURITY_PLATFORMS//$'\n'/,}"
52+
for platform in "${platforms[@]}"; do
53+
platform="${platform//[[:space:]]/}"
54+
[[ "$platform" =~ ^linux/(amd64|arm64)(/v[0-9]+)?$ ]] || {
55+
echo "Unsupported or empty image platform: $platform" >&2; exit 2;
56+
}
57+
count=$((count + 1))
58+
suffix="${platform//\//-}"
59+
sbom="$SECURITY_REPORT_DIRECTORY/$suffix.syft.json"
60+
report="$SECURITY_REPORT_DIRECTORY/$suffix.grype.json"
61+
echo "Generating SBOM and scanning $platform"
62+
if ! "$SYFT_CMD" scan "$SECURITY_IMAGE" --platform "$platform" \
63+
--config "$SECURITY_REPORT_DIRECTORY/syft-config.yaml" \
64+
--output "syft-json=$sbom"; then
65+
echo "Syft failed for $platform" >&2
66+
printf '%s FAIL syft\n' "$platform" >> "$SECURITY_REPORT_DIRECTORY/gate.txt"
67+
failed=1
68+
continue
69+
fi
70+
if "$GRYPE_CMD" "sbom:$sbom" --config "$SECURITY_REPORT_DIRECTORY/grype-config.yaml" \
71+
--fail-on high --output json --file "$report"; then
72+
printf '%s PASS\n' "$platform" >> "$SECURITY_REPORT_DIRECTORY/gate.txt"
73+
else
74+
status=$?
75+
printf '%s FAIL exit=%s\n' "$platform" "$status" >> "$SECURITY_REPORT_DIRECTORY/gate.txt"
76+
echo "Grype failed for $platform (exit $status)" >&2
77+
failed=1
78+
fi
79+
done
80+
[[ "$count" -gt 0 ]] || exit 2
81+
[[ "$failed" == 0 ]] || exit 1
82+
;;
83+
publish)
84+
: "${SECURITY_IMAGE:?}" "${SECURITY_REPORT_DIRECTORY:?}"
85+
[[ "$SECURITY_IMAGE" =~ ^ghcr\.io/[a-zA-Z0-9._/-]+@sha256:[a-f0-9]{64}$ ]] || {
86+
echo "Publication requires an immutable GHCR digest" >&2; exit 2;
87+
}
88+
[[ -s "$SECURITY_REPORT_DIRECTORY/gate.txt" ]] || {
89+
echo "Missing successful security gate evidence" >&2; exit 2;
90+
}
91+
if grep -q ' FAIL' "$SECURITY_REPORT_DIRECTORY/gate.txt"; then
92+
echo "Failed security gate prevents release tagging" >&2; exit 2
93+
fi
94+
[[ "$(cat "$SECURITY_REPORT_DIRECTORY/scanned-image.txt")" == "registry:$SECURITY_IMAGE" ]] || {
95+
echo "Gate evidence belongs to a different image" >&2; exit 2;
96+
}
97+
args=(docker buildx imagetools create --prefer-index=false)
98+
while IFS= read -r tag; do
99+
[[ -z "$tag" ]] && continue
100+
args+=(--tag "$tag")
101+
done < "$SECURITY_REPORT_DIRECTORY/release-tags.txt"
102+
[[ "${#args[@]}" -gt 5 ]] || exit 2
103+
"${args[@]}" "$SECURITY_IMAGE"
104+
digest="${SECURITY_IMAGE##*@}"
105+
while IFS= read -r tag; do
106+
[[ -z "$tag" ]] && continue
107+
actual="$(docker buildx imagetools inspect "$tag" --format '{{.Manifest.Digest}}')"
108+
[[ "$actual" == "$digest" ]] || {
109+
echo "Published digest differs from scanned image for $tag" >&2; exit 3;
110+
}
111+
printf '%s %s\n' "$tag" "$actual" >> "$SECURITY_REPORT_DIRECTORY/published-digests.txt"
112+
done < "$SECURITY_REPORT_DIRECTORY/release-tags.txt"
113+
;;
114+
*) echo "Usage: $0 prepare|scan|publish" >&2; exit 2 ;;
115+
esac

‎.github/workflows/publish-image.yml‎

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
name: Build and publish image
22

33
on:
4+
schedule:
5+
- cron: '5 4 * * *'
6+
timezone: America/Detroit
47
push:
58
branches: [main]
69
workflow_dispatch:
@@ -46,18 +49,41 @@ jobs:
4649
- name: Prepare disk build cache
4750
id: ci_disk_cache_1
4851
run: ci-cache "routelisttotesla-linux-amd64"
52+
- name: Prepare immutable image and release tags
53+
id: container_security_1
54+
shell: bash
55+
env:
56+
SECURITY_PUBLISH: "true"
57+
SECURITY_RELEASE_TAGS: |
58+
ghcr.io/javadevjt/routelisttotesla:latest
59+
ghcr.io/javadevjt/routelisttotesla:sha-${{ github.sha }}
60+
SECURITY_REPORT_DIRECTORY: ${{ runner.temp }}/container-security/build-1
61+
run: bash .github/actions/container-security/container-security.sh prepare
4962
- name: Build, test, and publish
63+
id: container_build_1
5064
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
5165
with:
66+
outputs: ${{ steps.container_security_1.outputs.exporter }}
5267
cache-from: |
5368
type=local,src=${{ steps.ci_disk_cache_1.outputs.from }}
5469
type=gha,version=2,scope=routelisttotesla-linux-amd64
5570
cache-to: ${{ steps.ci_disk_cache_1.outputs.to && format('type=local,dest={0},mode=max,ignore-error=true', steps.ci_disk_cache_1.outputs.to) || '' }}
5671
context: .
5772
platforms: linux/amd64
58-
push: true
73+
push: false # Push-by-digest is configured in the exporter; tags follow the scan.
5974
build-args: |
6075
VCS_REF=${{ github.sha }}
61-
tags: |
62-
ghcr.io/javadevjt/routelisttotesla:latest
63-
ghcr.io/javadevjt/routelisttotesla:sha-${{ github.sha }}
76+
tags: ${{ steps.container_security_1.outputs.repository }}
77+
- name: Generate SBOM and enforce High/Critical vulnerability gate
78+
uses: ./.github/actions/container-security
79+
with:
80+
image: registry:${{ steps.container_security_1.outputs.repository }}@${{ steps.container_build_1.outputs.digest }}
81+
platforms: "linux/amd64"
82+
report-directory: ${{ runner.temp }}/container-security/build-1
83+
artifact-name: container-security-${{ github.job }}-${{ strategy.job-index || 0 }}-build-1
84+
- name: Publish release tags only after the vulnerability gate passes
85+
shell: bash
86+
env:
87+
SECURITY_IMAGE: ${{ steps.container_security_1.outputs.repository }}@${{ steps.container_build_1.outputs.digest }}
88+
SECURITY_REPORT_DIRECTORY: ${{ runner.temp }}/container-security/build-1
89+
run: bash .github/actions/container-security/container-security.sh publish

0 commit comments

Comments
 (0)