Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 ""
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions docs/CONTAINER_DISTRIBUTION_SIGNING_EVIDENCE_LANE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
9 changes: 8 additions & 1 deletion docs/CONTAINER_SIGNING_DRY_RUN_VERIFICATION_LANE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:

Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion docs/LOAD_BALANCER_BUILD_OUT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
125 changes: 125 additions & 0 deletions scripts/smoke/validate-container-image-sbom.sh
Original file line number Diff line number Diff line change
@@ -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"
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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));

Expand All @@ -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) {
Expand Down
Loading