From 264178986270283950214879ba375528dbafad7c Mon Sep 17 00:00:00 2001 From: RicheyWorks <730richey730@gmail.com> Date: Fri, 21 Aug 2026 19:25:58 -0700 Subject: [PATCH] ci: add container image SBOM evidence --- .github/workflows/ci.yml | 27 ++++ ...INER_DISTRIBUTION_SIGNING_EVIDENCE_LANE.md | 2 + ...AINER_SIGNING_DRY_RUN_VERIFICATION_LANE.md | 9 +- docs/LOAD_BALANCER_BUILD_OUT.md | 3 +- .../smoke/validate-container-image-sbom.sh | 125 ++++++++++++++++++ .../api/DeploymentPackagingContractTest.java | 30 +++++ 6 files changed, 194 insertions(+), 2 deletions(-) create mode 100644 scripts/smoke/validate-container-image-sbom.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5cdca1b9..38b54718 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -608,6 +608,7 @@ jobs: echo "- Local dry-run image tag: \`${DRY_RUN_TAG}\`" echo "- Docker runtime and proxy-prod Compose smokes: passed before this evidence was captured." echo "- Trivy scans: see \`trivy-summary.txt\`, \`fixture-trivy-summary.txt\`, \`topology-ingress-trivy-summary.txt\`, and \`immutable-candidate-trivy-summary.txt\` when the scan steps complete." + echo "- Image SBOM: see \`image-sbom.cdx.json\`, \`image-sbom.cdx.sha256\`, and \`image-sbom-binding.json\` when image SBOM generation completes." echo "- Evidence directory: \`${EVIDENCE_DIR}\`" echo "- Artifact name: \`container-dry-run-evidence-no-publish-no-sign\`" echo "" @@ -633,6 +634,32 @@ jobs: ignore-unfixed: false exit-code: '1' + - name: Generate image CycloneDX SBOM + # aquasecurity/trivy-action@0.36.0 + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 + with: + image-ref: loadbalancerpro:ci-dry-run-${{ github.event.pull_request.head.sha || github.sha }} + format: cyclonedx + output: target/container-dry-run-evidence/image-sbom.cdx.json + scanners: vuln + vuln-type: os,library + severity: UNKNOWN,LOW,MEDIUM,HIGH,CRITICAL + ignore-unfixed: false + exit-code: '0' + + - name: Validate image CycloneDX SBOM evidence + shell: bash + env: + SOURCE_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + run: | + set -euo pipefail + bash -n scripts/smoke/validate-container-image-sbom.sh + bash scripts/smoke/validate-container-image-sbom.sh \ + target/container-dry-run-evidence \ + "$SOURCE_SHA" \ + "$GITHUB_SHA" \ + "loadbalancerpro:ci-dry-run-${SOURCE_SHA}" + - name: Scan proxy-prod fixture image # aquasecurity/trivy-action@0.36.0 uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 diff --git a/docs/CONTAINER_DISTRIBUTION_SIGNING_EVIDENCE_LANE.md b/docs/CONTAINER_DISTRIBUTION_SIGNING_EVIDENCE_LANE.md index 9de827a8..cb3afa81 100644 --- a/docs/CONTAINER_DISTRIBUTION_SIGNING_EVIDENCE_LANE.md +++ b/docs/CONTAINER_DISTRIBUTION_SIGNING_EVIDENCE_LANE.md @@ -34,6 +34,8 @@ The current repository supports only bounded local and CI container evidence: - [`CONTAINER_DEPLOYMENT.md`](CONTAINER_DEPLOYMENT.md) documents local-only Docker build/run recipes, loopback binding, API-key boundary checks, and no-registry-publish boundaries. - CI builds the Docker image, runs loopback-bound container health smoke, and runs Trivy for fixed high/critical findings. - CI generates CycloneDX SBOM artifacts for the Maven/JAR dependency graph. +- CI generates a CycloneDX SBOM for the exact locally built proxy image and retains a checksum plus a binding to the + local Docker content ID, source commit SHA, and workflow SHA in the no-publish/no-sign evidence artifact. - The tag-triggered Release Artifacts workflow builds deterministic JAR/SBOM/checksum assets and GitHub artifact attestations for approved semantic tags. - [`CONTAINER_SIGNING_DECISION_RECORD.md`](CONTAINER_SIGNING_DECISION_RECORD.md) and [`CONTAINER_REGISTRY_SIGNING_ROLLOUT.md`](CONTAINER_REGISTRY_SIGNING_ROLLOUT.md) keep registry publication and signing deferred. diff --git a/docs/CONTAINER_SIGNING_DRY_RUN_VERIFICATION_LANE.md b/docs/CONTAINER_SIGNING_DRY_RUN_VERIFICATION_LANE.md index 3136ed15..2ae8a801 100644 --- a/docs/CONTAINER_SIGNING_DRY_RUN_VERIFICATION_LANE.md +++ b/docs/CONTAINER_SIGNING_DRY_RUN_VERIFICATION_LANE.md @@ -99,6 +99,9 @@ Expected evidence files: - `image-entrypoint.json` - `image-cmd.json` - `image-exposed-ports.json` +- `image-sbom.cdx.json` +- `image-sbom.cdx.sha256` +- `image-sbom-binding.json` - `trivy-summary.txt` What the artifact proves: @@ -108,6 +111,8 @@ What the artifact proves: - CI captured local image identity, configuration, history, and Docker environment details. - CI recorded both the source commit SHA and the workflow SHA so pull-request merge refs are reviewable without ambiguity. - CI ran the configured Trivy image scan and stored its table output when the scan step completed. +- CI generated a CycloneDX SBOM from the exact local image, verified its structure and checksum, and bound its SHA-256 + to the local Docker content ID, source commit SHA, and workflow SHA. What the artifact does not prove: @@ -139,8 +144,10 @@ Current supported SBOM evidence: - Maven/CycloneDX SBOM generation for the JAR and dependency graph. - CI-uploaded `loadbalancerpro-sbom` workflow artifacts. +- Trivy/CycloneDX SBOM generation for the locally built proxy image, retained with a validated checksum and + image/source/workflow binding inside `container-dry-run-evidence-no-publish-no-sign`. -Optional local image evidence, pending reviewer tooling availability: +Equivalent local image evidence, when Trivy or Syft is available: ```bash trivy image --severity HIGH,CRITICAL --ignore-unfixed loadbalancerpro:dry-run diff --git a/docs/LOAD_BALANCER_BUILD_OUT.md b/docs/LOAD_BALANCER_BUILD_OUT.md index f4e33123..9cbe1d39 100644 --- a/docs/LOAD_BALANCER_BUILD_OUT.md +++ b/docs/LOAD_BALANCER_BUILD_OUT.md @@ -20,7 +20,8 @@ The repository already has these executable production surfaces: - TLS termination, verified backend TLS and optional mTLS bundles, API-key protection, and protected Actuator metrics; - hardened production Compose and Kubernetes deployment bases, a disposable live two-zone Kubernetes topology lane, and fail-closed Kubernetes staging adapter generation for deployment actions and per-replica telemetry; -- production artifact, Compose, graceful-shutdown, benchmark-smoke, SBOM, CodeQL, and image-scan CI gates; and +- production artifact, Compose, graceful-shutdown, benchmark-smoke, dependency/JAR and image-level CycloneDX SBOM, + CodeQL, and image-scan CI gates; and - a one-hour loopback soak covering steady traffic, spikes, slow upstreams, upstream loss, reload, drain, in-flight quiescence, p99 budgets, and heap-floor growth. diff --git a/scripts/smoke/validate-container-image-sbom.sh b/scripts/smoke/validate-container-image-sbom.sh new file mode 100644 index 00000000..316afb09 --- /dev/null +++ b/scripts/smoke/validate-container-image-sbom.sh @@ -0,0 +1,125 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -ne 4 ]]; then + echo "Usage: $0 EVIDENCE_DIR SOURCE_SHA WORKFLOW_SHA DRY_RUN_IMAGE_TAG" >&2 + exit 2 +fi + +evidence_dir="$1" +source_sha="$2" +workflow_sha="$3" +dry_run_tag="$4" + +for command_name in docker jq sha256sum; do + command -v "$command_name" >/dev/null 2>&1 || { + echo "$command_name is required to validate image SBOM evidence" >&2 + exit 2 + } +done + +[[ "$source_sha" =~ ^[0-9a-f]{40}$ ]] || { + echo "Source SHA must be a full lowercase Git commit ID" >&2 + exit 2 +} +[[ "$workflow_sha" =~ ^[0-9a-f]{40}$ ]] || { + echo "Workflow SHA must be a full lowercase Git commit ID" >&2 + exit 2 +} +[[ -d "$evidence_dir" && ! -L "$evidence_dir" ]] || { + echo "Evidence directory must be a non-symlink directory" >&2 + exit 2 +} + +sbom="$evidence_dir/image-sbom.cdx.json" +image_id_file="$evidence_dir/image-id.txt" +summary="$evidence_dir/dry-run-summary.md" +checksum_file="$evidence_dir/image-sbom.cdx.sha256" +binding="$evidence_dir/image-sbom-binding.json" +for evidence_file in "$sbom" "$image_id_file" "$summary"; do + [[ -s "$evidence_file" && ! -L "$evidence_file" ]] || { + echo "Required image SBOM evidence file is missing, empty, or a symlink: $evidence_file" >&2 + exit 2 + } +done +for generated_file in "$checksum_file" "$binding"; do + [[ ! -L "$generated_file" ]] || { + echo "Refusing to replace symlinked image SBOM evidence: $generated_file" >&2 + exit 2 + } +done + +jq -e ' + .bomFormat == "CycloneDX" + and (.specVersion | type == "string" and length > 0) + and (.serialNumber | type == "string" and startswith("urn:uuid:")) + and .version == 1 + and (.metadata | type == "object") + and (.components | type == "array" and length > 0) +' "$sbom" >/dev/null || { + echo "Image SBOM is not a populated CycloneDX document" >&2 + exit 1 +} + +recorded_image_id="$(tr -d '\r\n' < "$image_id_file")" +current_image_id="$(docker image inspect "$dry_run_tag" --format '{{.Id}}')" +[[ "$recorded_image_id" =~ ^sha256:[0-9a-f]{64}$ ]] || { + echo "Recorded dry-run image ID is not an immutable Docker content ID" >&2 + exit 1 +} +[[ "$current_image_id" == "$recorded_image_id" ]] || { + echo "Dry-run image identity changed before SBOM binding" >&2 + exit 1 +} + +sbom_sha256="$(sha256sum "$sbom" | awk '{print $1}')" +printf '%s %s\n' "$sbom_sha256" "$(basename "$sbom")" > "$checksum_file" +(cd "$evidence_dir" && sha256sum --check --strict "$(basename "$checksum_file")") + +jq -n \ + --arg sourceCommitSha "$source_sha" \ + --arg workflowSha "$workflow_sha" \ + --arg buildImageTag "loadbalancerpro:ci" \ + --arg dryRunImageTag "$dry_run_tag" \ + --arg imageId "$recorded_image_id" \ + --arg sbomPath "$(basename "$sbom")" \ + --arg sbomSha256 "$sbom_sha256" \ + '{schemaVersion:1, + sourceCommitSha:$sourceCommitSha, + workflowSha:$workflowSha, + buildImageTag:$buildImageTag, + dryRunImageTag:$dryRunImageTag, + imageId:$imageId, + sbom:{format:"CycloneDX", path:$sbomPath, sha256:$sbomSha256}, + published:false, + signed:false}' > "$binding" + +jq -e \ + --arg sourceCommitSha "$source_sha" \ + --arg workflowSha "$workflow_sha" \ + --arg imageId "$recorded_image_id" \ + --arg dryRunImageTag "$dry_run_tag" \ + --arg sbomSha256 "$sbom_sha256" ' + .schemaVersion == 1 + and .sourceCommitSha == $sourceCommitSha + and .workflowSha == $workflowSha + and .buildImageTag == "loadbalancerpro:ci" + and .dryRunImageTag == $dryRunImageTag + and .imageId == $imageId + and .sbom.format == "CycloneDX" + and .sbom.sha256 == $sbomSha256 + and .published == false + and .signed == false + ' "$binding" >/dev/null + +{ + echo "" + echo "## Image SBOM" + echo "" + echo "- CycloneDX image SBOM: \`$(basename "$sbom")\`" + echo "- SBOM checksum: \`$(basename "$checksum_file")\`" + echo "- Image/SBOM binding: \`$(basename "$binding")\`" + echo "- The binding records the exact local image ID, source SHA, workflow SHA, and SBOM SHA-256." +} >> "$summary" + +printf 'Validated CycloneDX image SBOM evidence for local image %s.\n' "$recorded_image_id" diff --git a/src/test/java/com/richmond423/loadbalancerpro/api/DeploymentPackagingContractTest.java b/src/test/java/com/richmond423/loadbalancerpro/api/DeploymentPackagingContractTest.java index ee194e27..ba1a0976 100644 --- a/src/test/java/com/richmond423/loadbalancerpro/api/DeploymentPackagingContractTest.java +++ b/src/test/java/com/richmond423/loadbalancerpro/api/DeploymentPackagingContractTest.java @@ -23,6 +23,7 @@ class DeploymentPackagingContractTest { private static final Path FIXTURE_DOCKERFILE = Path.of("deploy/fixture/Dockerfile"); private static final Path MANIFEST = Path.of("deploy/kubernetes-proxy-prod.yaml"); private static final Path SMOKE = Path.of("scripts/smoke/proxy-prod-compose-smoke.sh"); + private static final Path IMAGE_SBOM_VALIDATOR = Path.of("scripts/smoke/validate-container-image-sbom.sh"); private static final Path CI = Path.of(".github/workflows/ci.yml"); @Test @@ -135,6 +136,7 @@ void kubernetesBaseHasTheCanonicalLifecycleAndSecurityBoundary() throws Exceptio @Test void ciExecutesTheRuntimeSmokeAndUnsuppressedImageScans() throws Exception { String smoke = read(SMOKE); + String imageSbomValidator = read(IMAGE_SBOM_VALIDATOR); String ci = read(CI); assertEquals(1, yamlDocumentCount(ci)); @@ -155,10 +157,38 @@ void ciExecutesTheRuntimeSmokeAndUnsuppressedImageScans() throws Exception { assertTrue(ci.contains("bash scripts/bench/topology-validator-contract-test.sh")); assertTrue(ci.contains("Scan active-active ingress fixture image")); assertTrue(ci.contains("Scan immutable rollout candidate image")); + for (String expected : List.of( + "Generate image CycloneDX SBOM", + "Validate image CycloneDX SBOM evidence", + "format: cyclonedx", + "image-ref: loadbalancerpro:ci-dry-run-${{ github.event.pull_request.head.sha || github.sha }}", + "output: target/container-dry-run-evidence/image-sbom.cdx.json", + "bash -n scripts/smoke/validate-container-image-sbom.sh", + "bash scripts/smoke/validate-container-image-sbom.sh", + "image-sbom.cdx.sha256", + "image-sbom-binding.json")) { + assertTrue(ci.contains(expected), "missing image SBOM evidence boundary: " + expected); + } + for (String expected : List.of( + ".bomFormat == \"CycloneDX\"", + "sha256sum --check --strict", + "Dry-run image identity changed before SBOM binding", + ".dryRunImageTag == $dryRunImageTag", + "published:false", + "signed:false")) { + assertTrue(imageSbomValidator.contains(expected), + "missing image SBOM validator boundary: " + expected); + } + assertFalse(imageSbomValidator.contains("docker push")); + assertFalse(imageSbomValidator.contains("docker login")); + assertFalse(imageSbomValidator.contains("cosign")); assertTrue(count(ci, "ignore-unfixed: false") >= 4); assertFalse(smoke.contains("cp \"$tls_dir/certificate.pem\" \"$tls_dir/ca.pem\"")); assertFalse(ci.contains("ignore-unfixed: true")); assertFalse(ci.contains("trivyignores:")); + assertFalse(ci.contains("\n docker push ")); + assertFalse(ci.contains("\n docker login ")); + assertFalse(ci.contains("\n cosign ")); } private static long count(String content, String token) {