From caa69fcdfaedd5596e447be7eb2995a2848c8944 Mon Sep 17 00:00:00 2001 From: Gnani Rahul Date: Tue, 14 Jul 2026 23:45:56 -0500 Subject: [PATCH] fix(e9): verify public hub image distribution GSTACK-Checkpoint: 2026-07-15/e9-hub-package-visibility#1 Signed-off-by: Gnani Rahul --- .github/workflows/release.yml | 11 ++++ README.md | 6 +- docs/RELEASE.md | 12 +++- .../2026-07-15-e9-hub-package-visibility.md | 58 +++++++++++++++++++ .../scripts/release_hub_image_policy_test.sh | 49 +++++++++++----- tools/internal/releasepack/policy_test.go | 25 ++++++++ 6 files changed, 143 insertions(+), 18 deletions(-) create mode 100644 sessions/2026-07-15-e9-hub-package-visibility.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3cd2f16..84dc383 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -150,6 +150,8 @@ jobs: platforms: linux/amd64,linux/arm64 push: true tags: ${{ env.HUB_IMAGE }}:${{ github.ref_name }} + labels: | + org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }} provenance: false sbom: false @@ -197,6 +199,15 @@ jobs: sbom-path: dist/sith_${{ env.VERSION }}_hub.spdx.json push-to-registry: true + - name: Verify public hub image distribution + env: + HUB_DIGEST: ${{ steps.hub_image.outputs.digest }} + run: | + set -euo pipefail + test -n "$HUB_DIGEST" + docker logout ghcr.io >/dev/null 2>&1 || true + docker manifest inspect "${HUB_IMAGE}@${HUB_DIGEST}" >/dev/null + - name: Generate SLSA build provenance id: provenance uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4.1.1 diff --git a/README.md b/README.md index a7f05ed..80d23b0 100644 --- a/README.md +++ b/README.md @@ -270,8 +270,10 @@ The no-network setting applies only to those isolated image checks. A deployed h allowlisted egress to its configured runtime dependencies, including its database and, when enabled, the pinned OIDC discovery and JWKS endpoints. -Hub OCI images are published only by a completed, signed release tag. The release attaches the -exact immutable digest as `sith__hub.image`; follow the +Hub OCI images are published only by a completed, signed release tag. An organization package admin +must make the `sith-hub` Container package public once before the first Hub release; each completed +release then proves anonymous access to its release-bound digest and attaches it as +`sith__hub.image`; follow the [release verification guide](docs/RELEASE.md#verify-a-hub-oci-image) before supplying it to the fail-closed [`charts/sith-hub`](charts/sith-hub) chart. The chart requires an explicit `repository@sha256:...` image reference and refuses tags, especially `latest`; it invokes `sith hub diff --git a/docs/RELEASE.md b/docs/RELEASE.md index ce409aa..de6f666 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -6,7 +6,9 @@ builds four archives with GoReleaser, emits an SPDX 2.3 SBOM for each archive wi archives, SBOMs, and checksum manifest with keyless Cosign, and creates GitHub SLSA provenance plus one SBOM attestation per platform. It also publishes the tag's multi-architecture hub image by its manifest digest, signs it with keyless Cosign, and creates separate provenance and SPDX SBOM -attestations for that digest. The draft becomes public only after every step succeeds. +attestations for that digest. The workflow then removes its registry credentials and proves that the +exact digest is anonymously pullable; the release draft becomes public only after every step +succeeds. The workflow follows the primary guidance for [GitHub artifact attestations](https://docs.github.com/en/actions/how-tos/secure-your-work/use-artifact-attestations/use-artifact-attestations), [GoReleaser reproducible Go builds](https://goreleaser.com/customization/builds/builders/go/#reproducible-builds), @@ -48,8 +50,12 @@ case "$image" in ghcr.io/ardurai/sith-hub@sha256:*) ;; *) exit 1 ;; esac ``` Verify the keyless image signature against the exact tag workflow identity, then verify GitHub -provenance and the SPDX SBOM attestation. These commands require registry access; the later air-gap -workflow consumes mirrored, pre-verified material rather than weakening this verification boundary. +provenance and the SPDX SBOM attestation. Before the first Hub-image release, an organization +package admin must make the `sith-hub` Container package public in its GitHub Package settings; +GitHub makes that choice irreversible. A completed Hub-image release is then anonymously pullable +by its release-bound digest only after these trust records are created and the workflow's anonymous +pull check passes. The later air-gap workflow consumes mirrored, pre-verified material rather than +weakening this verification boundary. ```bash identity="https://github.com/ArdurAI/sith/.github/workflows/release.yml@refs/tags/${tag}" diff --git a/sessions/2026-07-15-e9-hub-package-visibility.md b/sessions/2026-07-15-e9-hub-package-visibility.md new file mode 100644 index 0000000..b9b258b --- /dev/null +++ b/sessions/2026-07-15-e9-hub-package-visibility.md @@ -0,0 +1,58 @@ +# Session — 2026-07-15 — e9-hub-package-visibility + +**Builder:** Gnani Rahul · **Branch:** `gnanirahulnutakki/fix/e9-hub-package-visibility` +**Slice(s):** E9 / [#172](https://github.com/ArdurAI/sith/issues/172) · **Status:** in-progress + +--- + +[G] Goal: require organization-admin public package configuration before release, then ensure every +completed release exposes its already verified Hub OCI manifest for normal anonymous, digest-pinned +consumption. + +[S] Scope: the one-time organization container-package visibility bootstrap, the release workflow's +anonymous distribution check, static policy tests, and user-facing release documentation. No Hub +runtime behavior, image content, registry credential, Helm values, mutable tag, or new long-lived +secret is introduced. + +[A] Action: post-release verification of `v0.3.0-beta.4` proved the signed multi-architecture +manifest, its Cosign bundle, GitHub provenance, SPDX SBOM attestation, and release assets exist, +but an unauthenticated `docker manifest inspect` was denied because the newly created GHCR package +was private. Filed #172 instead of claiming the distribution path was complete. + +[A] Action: the strict review correctly identified that GitHub exposes no REST endpoint for package +visibility changes. After keyless image signing and both image attestations succeed, the workflow +now removes its GHCR credentials and requires an anonymous manifest read of the exact digest before +release attachment. The image carries the OCI source-repository label for package linkage. Package +visibility remains the documented one-time organization-admin setting that GitHub requires. + +[T] Test: `make ci` passed, including race-enabled unit/integration suites, Go formatting, lint, +vet, vulnerability scan, E2E coverage, the release workflow static policy suite, and the UI latency +gate. `make release-check` passed, including reproducible archives, SBOMs, and a two-platform Hub +OCI layout assembled from the released Linux archives. The targeted shell policy suite and +race-enabled Go release-policy suite also passed. The required real multi-cluster gate +`make e2e-kind KIND=/Volumes/EXTENDED/MacData/tools/bin/kind` passed in 155.576 seconds. + +[R] Review: CodeRabbit initially identified the nonexistent package-visibility REST mutation; that +finding was accepted and removed. A later review correctly identified a policy-test gap: the tests +proved the logout and anonymous inspection existed but not that logout preceded inspection. Both +the shell and Go policy suites now enforce that ordering, and the full CI/release validation was +rerun successfully. Two journal-only suggestions were rejected after verification because they +named an unrelated `ardur-proxy` image and incorrectly claimed this not-yet-created PR was merged. +The implementation relies only on the documented package-admin setting plus the executable +anonymous-read gate. The final amended-diff review against `6fe30e0` found zero findings across +the workflow, documentation, policy tests, and this journal. + +[S] Security queue: immediately before commit, GitHub reported zero open Dependabot alerts, zero +open code-scanning alerts, and zero open secret-scanning alerts for `ArdurAI/sith`. + +[S] Security: GitHub documentation confirms that public Container access is a package-admin setting; +making a public package private again is irreversible. An organization administrator must configure +the package public before a tag is cut. No unsupported REST call or personal token is added. The +local GitHub token lacks optional `read:packages`, and browser session state was not used as an +authentication bypass. The workflow retains only its existing `packages: write` publication +permission and blocks release attachment when its anonymous digest check fails. + +[C] Checkpoint #2: local validation and red-team review are complete. Next: record GitHub security +queue evidence, create the signed DCO/GSTACK commit, land the smallest fix PR into `dev`, then +verify exact post-merge CI before requesting the one-time organization-admin visibility bootstrap +and cutting a fresh release tag. diff --git a/tests/scripts/release_hub_image_policy_test.sh b/tests/scripts/release_hub_image_policy_test.sh index 91d78d7..6afe6fe 100644 --- a/tests/scripts/release_hub_image_policy_test.sh +++ b/tests/scripts/release_hub_image_policy_test.sh @@ -21,15 +21,6 @@ assert_contains() { printf '[release-hub-image] PASS: %s\n' "$description" } -workflow_step() { - local name="$1" - awk -v name="$name" ' - $0 == " - name: " name { inside = 1 } - inside && $0 ~ /^ - name: / && $0 != " - name: " name { exit } - inside { print } - ' "$workflow" -} - workflow_job() { local name="$1" awk -v name="$name" ' @@ -61,6 +52,15 @@ assert_text_contains() { } release_job="$(workflow_job 'release')" +release_job_step() { + local name="$1" + awk -v name="$name" ' + $0 == " - name: " name { inside = 1 } + inside && $0 ~ /^ - name: / && $0 != " - name: " name { exit } + inside { print } + ' <<<"$release_job" +} + assert_contains "$workflow_contents" 'HUB_IMAGE: ghcr.io/ardurai/sith-hub' 'uses one fixed GHCR hub image name' assert_text_contains "$release_job" 'packages: write' 'grants package publication permission to the release job' for assertion in \ @@ -78,7 +78,7 @@ for assertion in \ assert_contains "$release_job" "$needle" "$description" done -publish_step="$(workflow_step 'Publish immutable multi-platform hub image')" +publish_step="$(release_job_step 'Publish immutable multi-platform hub image')" platforms="$(awk '/^[[:space:]]+platforms:/{ print $2 }' <<<"$publish_step")" if [[ "$platforms" != 'linux/amd64,linux/arm64' ]]; then printf '[release-hub-image] FAIL: publish step platforms = %q, want linux/amd64,linux/arm64\n' "$platforms" >&2 @@ -87,21 +87,44 @@ fi printf '[release-hub-image] PASS: publishes exactly the two supported Linux platforms\n' assert_contains "$publish_step" 'push: true' 'publishes the release image before release publication' assert_contains "$publish_step" 'tags: ${{ env.HUB_IMAGE }}:${{ github.ref_name }}' 'uses the exact release tag without a latest tag' +assert_contains "$publish_step" 'org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}' 'links the image package to its source repository' assert_contains "$publish_step" 'provenance: false' 'uses the explicit GitHub provenance attestation path' assert_contains "$publish_step" 'sbom: false' 'uses the explicit SPDX SBOM attestation path' -signing_step="$(workflow_step 'Sign and verify published hub image')" +signing_step="$(release_job_step 'Sign and verify published hub image')" assert_text_contains "$signing_step" 'HUB_DIGEST: ${{ steps.hub_image.outputs.digest }}' 'derives the signing digest from the pushed manifest' assert_text_contains "$signing_step" 'image="${HUB_IMAGE}@${HUB_DIGEST}"' 'constructs the signed image from the pushed manifest digest' assert_text_contains "$signing_step" 'cosign sign --yes "$image"' 'keylessly signs that manifest digest' for attestation in 'Attest hub image build provenance' 'Attest hub image SBOM'; do - attestation_step="$(workflow_step "$attestation")" + attestation_step="$(release_job_step "$attestation")" assert_text_contains "$attestation_step" 'subject-name: ${{ env.HUB_IMAGE }}' "$attestation uses the tag-free image name" assert_text_contains "$attestation_step" 'subject-digest: ${{ steps.hub_image.outputs.digest }}' "$attestation uses the pushed manifest digest" done -guard_step="$(workflow_step 'Guard hub image tag against overwrite')" +distribution_step="$(release_job_step 'Verify public hub image distribution')" +assert_text_contains "$distribution_step" 'HUB_DIGEST: ${{ steps.hub_image.outputs.digest }}' 'checks public access to the pushed manifest digest' +assert_text_contains "$distribution_step" 'set -euo pipefail' 'fails closed when the anonymous distribution check errors' +assert_text_contains "$distribution_step" 'docker logout ghcr.io' 'removes registry credentials before public access verification' +assert_text_contains "$distribution_step" 'docker manifest inspect "${HUB_IMAGE}@${HUB_DIGEST}"' 'fails closed unless the immutable hub digest is anonymously pullable' +logout_line="$(grep -n -F 'docker logout ghcr.io' <<<"$distribution_step" | head -n 1 | cut -d: -f1)" +inspect_line="$(grep -n -F 'docker manifest inspect "${HUB_IMAGE}@${HUB_DIGEST}"' <<<"$distribution_step" | head -n 1 | cut -d: -f1)" +if [[ -z "$logout_line" || -z "$inspect_line" || "$logout_line" -ge "$inspect_line" ]]; then + printf '[release-hub-image] FAIL: must log out of ghcr.io before the anonymous manifest inspection\n' >&2 + exit 1 +fi +printf '[release-hub-image] PASS: logs out of ghcr.io before the anonymous manifest inspection\n' +hub_provenance_line="$(grep -n -F ' - name: Attest hub image build provenance' <<<"$release_job" | head -n 1 | cut -d: -f1)" +hub_sbom_line="$(grep -n -F ' - name: Attest hub image SBOM' <<<"$release_job" | head -n 1 | cut -d: -f1)" +distribution_line="$(grep -n -F ' - name: Verify public hub image distribution' <<<"$release_job" | head -n 1 | cut -d: -f1)" +attach_line="$(grep -n -F ' - name: Attach attestations and Homebrew formula' <<<"$release_job" | head -n 1 | cut -d: -f1)" +if [[ -z "$hub_provenance_line" || -z "$hub_sbom_line" || -z "$distribution_line" || -z "$attach_line" || "$hub_provenance_line" -ge "$distribution_line" || "$hub_sbom_line" -ge "$distribution_line" || "$distribution_line" -ge "$attach_line" ]]; then + printf '[release-hub-image] FAIL: public digest verification must follow both image attestations and precede release attachment\n' >&2 + exit 1 +fi +printf '[release-hub-image] PASS: public digest verification follows image attestations before release attachment\n' + +guard_step="$(release_job_step 'Guard hub image tag against overwrite')" assert_text_contains "$guard_step" 'docker manifest inspect "$HUB_TAG"' 'checks the exact release tag before publication' assert_text_contains "$guard_step" 'could not establish whether the hub image tag exists' 'fails closed on registry inspection errors' guard_line="$(grep -n -F ' - name: Guard hub image tag against overwrite' "$workflow" | head -n 1 | cut -d: -f1)" diff --git a/tools/internal/releasepack/policy_test.go b/tools/internal/releasepack/policy_test.go index 29d4b2d..ae4c3b2 100644 --- a/tools/internal/releasepack/policy_test.go +++ b/tools/internal/releasepack/policy_test.go @@ -28,6 +28,7 @@ func TestReleasePolicyIsFailClosed(t *testing.T) { "cosign sign-blob --yes --bundle=dist/sith.rb.sigstore.json dist/sith.rb", "HUB_IMAGE: ghcr.io/ardurai/sith-hub", "tags: ${{ env.HUB_IMAGE }}:${{ github.ref_name }}", + "org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}", "cosign sign --yes \"$image\"", `gh release edit "$GITHUB_REF_NAME" --draft=false --latest`, } { @@ -76,6 +77,30 @@ func TestReleasePolicyIsFailClosed(t *testing.T) { } } } + distributionStep := releaseWorkflowStep(releaseJob, "Verify public hub image distribution") + for _, want := range []string{ + "HUB_DIGEST: ${{ steps.hub_image.outputs.digest }}", + "set -euo pipefail", + "docker logout ghcr.io", + `docker manifest inspect "${HUB_IMAGE}@${HUB_DIGEST}"`, + } { + if !strings.Contains(distributionStep, want) { + t.Errorf("hub public distribution step does not enforce %q", want) + } + } + logoutIndex := strings.Index(distributionStep, "docker logout ghcr.io") + inspectIndex := strings.Index(distributionStep, `docker manifest inspect "${HUB_IMAGE}@${HUB_DIGEST}"`) + if logoutIndex < 0 || inspectIndex < 0 || logoutIndex >= inspectIndex { + t.Error("hub public distribution step must log out of ghcr.io before inspecting the manifest anonymously") + } + for _, name := range []string{"Attest hub image build provenance", "Attest hub image SBOM"} { + if strings.Index(releaseJob, "- name: "+name) >= strings.Index(releaseJob, "- name: Verify public hub image distribution") { + t.Errorf("%s must precede public hub digest verification", name) + } + } + if strings.Index(releaseJob, "- name: Verify public hub image distribution") >= strings.Index(releaseJob, "- name: Attach attestations and Homebrew formula") { + t.Error("public hub digest verification must precede release attachment") + } for _, forbidden := range []string{"pull_request_target:", "workflow_run:", "HOMEBREW_TAP_TOKEN", "PERSONAL_AUTH_TOKEN"} { if strings.Contains(release, forbidden) { t.Errorf("release workflow contains forbidden trust expansion %q", forbidden)