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
11 changes: 11 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Comment on lines +202 to +210

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Make the anonymous GHCR gate genuinely fail closed.

The workflow ignores logout failure, so a private image can pass manifest inspection using the prior login credentials; both policy suites currently accept that implementation.

  • .github/workflows/release.yml#L202-L210: make logout fatal and inspect through an empty, isolated DOCKER_CONFIG.
  • tests/scripts/release_hub_image_policy_test.sh#L105-L109: assert that logout failure is not ignored and credential isolation is present.
  • tools/internal/releasepack/policy_test.go#L80-L95: enforce the same fail-closed and credential-isolation requirements.
📍 Affects 3 files
  • .github/workflows/release.yml#L202-L210 (this comment)
  • tests/scripts/release_hub_image_policy_test.sh#L105-L109
  • tools/internal/releasepack/policy_test.go#L80-L95
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/release.yml around lines 202 - 210, Make the “Verify
public hub image distribution” step fail closed by making docker logout failure
fatal and running docker manifest inspect with an empty isolated DOCKER_CONFIG.
Update the assertions in tests/scripts/release_hub_image_policy_test.sh (lines
105-109) and tools/internal/releasepack/policy_test.go (lines 80-95) to require
both non-ignored logout failure and credential isolation; the workflow site in
.github/workflows/release.yml (lines 202-210) requires the implementation
change.

- name: Generate SLSA build provenance
id: provenance
uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4.1.1
Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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_<version>_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_<version>_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
Expand Down
12 changes: 9 additions & 3 deletions docs/RELEASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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}"
Expand Down
58 changes: 58 additions & 0 deletions sessions/2026-07-15-e9-hub-package-visibility.md
Original file line number Diff line number Diff line change
@@ -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.
49 changes: 36 additions & 13 deletions tests/scripts/release_hub_image_policy_test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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" '
Expand Down Expand Up @@ -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 \
Expand All @@ -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
Expand All @@ -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)"
Expand Down
25 changes: 25 additions & 0 deletions tools/internal/releasepack/policy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
} {
Expand Down Expand Up @@ -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)
Expand Down