From c289b9e546f25bcf88f7ad462a0db033fa78b10e Mon Sep 17 00:00:00 2001 From: Eddie Knight Date: Thu, 3 Sep 2026 12:23:30 -0500 Subject: [PATCH 1/9] migration to OpenSSF Signed-off-by: Eddie Knight --- .github/actions/install/action.yml | 69 +++ .github/workflows/ci.yml | 34 ++ .github/workflows/publish-gemara.yml | 92 +++ .github/workflows/release.yml | 96 ++++ .gitignore | 33 +- .golangci.yml | 10 + CHANGELOG.md | 222 ++++++++ CLAUDE.md | 49 ++ IMPLEMENTATION.md | 122 ++++ Makefile | 46 ++ README.md | 298 +++++++++- cmd/cat.go | 153 +++++ cmd/cat_test.go | 239 ++++++++ cmd/config_test.go | 173 ++++++ cmd/fetch.go | 185 ++++++ cmd/fetch_test.go | 205 +++++++ cmd/integration_test.go | 457 +++++++++++++++ cmd/login.go | 108 ++++ cmd/logout.go | 73 +++ cmd/publish.go | 539 ++++++++++++++++++ cmd/publish_test.go | 276 +++++++++ cmd/references_test.go | 405 +++++++++++++ cmd/regtoken.go | 54 ++ cmd/root.go | 182 ++++++ cmd/unpack.go | 758 +++++++++++++++++++++++++ cmd/unpack_test.go | 47 ++ cmd/urldefault.go | 43 ++ cmd/validate.go | 162 ++++++ cmd/validate_test.go | 152 +++++ cmd/verify.go | 445 +++++++++++++++ cmd/verify_test.go | 368 ++++++++++++ cmd/versions.go | 134 +++++ cmd/versions_test.go | 273 +++++++++ examples/github-actions/publish.yml | 67 +++ go.mod | 113 ++++ go.sum | 450 +++++++++++++++ internal/cache/cache.go | 243 ++++++++ internal/cache/cache_test.go | 212 +++++++ internal/digest/digest.go | 34 ++ internal/hub/discover.go | 88 +++ internal/hub/discover_test.go | 188 ++++++ internal/hub/hub.go | 314 ++++++++++ internal/hub/hub_test.go | 207 +++++++ internal/hub/regtoken.go | 85 +++ internal/provenance/provenance.go | 250 ++++++++ internal/provenance/provenance_test.go | 80 +++ internal/refs/refs.go | 182 ++++++ internal/refs/refs_fuzz_test.go | 32 ++ internal/refs/refs_test.go | 149 +++++ internal/registry/registry.go | 471 +++++++++++++++ internal/registry/registry_test.go | 165 ++++++ internal/sign/keyless.go | 169 ++++++ internal/sign/keyless_test.go | 68 +++ internal/sign/sign.go | 330 +++++++++++ internal/sign/sign_test.go | 233 ++++++++ internal/sigverify/roundtrip_test.go | 54 ++ internal/sigverify/verify.go | 264 +++++++++ internal/sigverify/verify_test.go | 299 ++++++++++ internal/source/source.go | 171 ++++++ internal/source/source_test.go | 131 +++++ main.go | 17 + 61 files changed, 11539 insertions(+), 29 deletions(-) create mode 100644 .github/actions/install/action.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/publish-gemara.yml create mode 100644 .github/workflows/release.yml create mode 100644 .golangci.yml create mode 100644 CHANGELOG.md create mode 100644 CLAUDE.md create mode 100644 IMPLEMENTATION.md create mode 100644 Makefile create mode 100644 cmd/cat.go create mode 100644 cmd/cat_test.go create mode 100644 cmd/config_test.go create mode 100644 cmd/fetch.go create mode 100644 cmd/fetch_test.go create mode 100644 cmd/integration_test.go create mode 100644 cmd/login.go create mode 100644 cmd/logout.go create mode 100644 cmd/publish.go create mode 100644 cmd/publish_test.go create mode 100644 cmd/references_test.go create mode 100644 cmd/regtoken.go create mode 100644 cmd/root.go create mode 100644 cmd/unpack.go create mode 100644 cmd/unpack_test.go create mode 100644 cmd/urldefault.go create mode 100644 cmd/validate.go create mode 100644 cmd/validate_test.go create mode 100644 cmd/verify.go create mode 100644 cmd/verify_test.go create mode 100644 cmd/versions.go create mode 100644 cmd/versions_test.go create mode 100644 examples/github-actions/publish.yml create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/cache/cache.go create mode 100644 internal/cache/cache_test.go create mode 100644 internal/digest/digest.go create mode 100644 internal/hub/discover.go create mode 100644 internal/hub/discover_test.go create mode 100644 internal/hub/hub.go create mode 100644 internal/hub/hub_test.go create mode 100644 internal/hub/regtoken.go create mode 100644 internal/provenance/provenance.go create mode 100644 internal/provenance/provenance_test.go create mode 100644 internal/refs/refs.go create mode 100644 internal/refs/refs_fuzz_test.go create mode 100644 internal/refs/refs_test.go create mode 100644 internal/registry/registry.go create mode 100644 internal/registry/registry_test.go create mode 100644 internal/sign/keyless.go create mode 100644 internal/sign/keyless_test.go create mode 100644 internal/sign/sign.go create mode 100644 internal/sign/sign_test.go create mode 100644 internal/sigverify/roundtrip_test.go create mode 100644 internal/sigverify/verify.go create mode 100644 internal/sigverify/verify_test.go create mode 100644 internal/source/source.go create mode 100644 internal/source/source_test.go create mode 100644 main.go diff --git a/.github/actions/install/action.yml b/.github/actions/install/action.yml new file mode 100644 index 0000000..8c04593 --- /dev/null +++ b/.github/actions/install/action.yml @@ -0,0 +1,69 @@ +name: Install grcli +description: >- + Install the grcli binary from the public GHCR OCI artifact onto the + runner PATH. No token required (the package is public). + +inputs: + version: + description: >- + Version tag to install, e.g. v0.6.0, or "latest". Releases from v0.6.0 + live at ghcr.io/gemaraproj/grcli (the repo moved orgs); tags older than + that were published to ghcr.io/revanite-io/grcli and are NOT here. + required: false + default: latest + verify: + description: >- + Verify the cosign signature before installing. Requires cosign on + PATH (e.g. a prior sigstore/cosign-installer step). Default false. + required: false + default: "false" + +runs: + using: composite + steps: + # v2: https://github.com/oras-project/setup-oras/releases/tag/v2.0.0 + - uses: oras-project/setup-oras@38de303aac69abb66f3e6255b7198bff35f323e3 + with: + version: 1.3.0 + + - shell: bash + env: + GRCLI_VERSION: ${{ inputs.version }} + GRCLI_VERIFY: ${{ inputs.verify }} + run: | + set -euo pipefail + image="ghcr.io/gemaraproj/grcli" + + case "${RUNNER_OS}" in + Linux) os=linux ;; + macOS) os=darwin ;; + Windows) os=windows ;; + *) echo "grcli install: unsupported RUNNER_OS=${RUNNER_OS}" >&2; exit 1 ;; + esac + case "${RUNNER_ARCH}" in + X64) arch=amd64 ;; + ARM64) arch=arm64 ;; + *) echo "grcli install: unsupported RUNNER_ARCH=${RUNNER_ARCH}" >&2; exit 1 ;; + esac + bin=grcli; [ "$os" = "windows" ] && bin=grcli.exe + + ref="${image}:${GRCLI_VERSION}" + + if [ "${GRCLI_VERIFY}" = "true" ]; then + if ! command -v cosign >/dev/null 2>&1; then + echo "grcli install: verify=true but cosign not on PATH; add a sigstore/cosign-installer step first" >&2 + exit 1 + fi + cosign verify "$ref" \ + --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \ + --certificate-identity-regexp "^https://github.com/gemaraproj/grcli/.github/workflows/release.yml@" \ + >/dev/null + fi + + dest="${RUNNER_TEMP}/grcli-bin" + mkdir -p "$dest" + oras pull "$ref" --platform "${os}/${arch}" -o "$dest" + chmod +x "${dest}/${bin}" 2>/dev/null || true + + echo "$dest" >> "$GITHUB_PATH" + "${dest}/${bin}" --version diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d9bd63c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,34 @@ +name: ci + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + - run: make fmtcheck + - run: make vet + - run: make testcov + - uses: golangci/golangci-lint-action@v9 + with: + version: v2.12.2 + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + - run: make build diff --git a/.github/workflows/publish-gemara.yml b/.github/workflows/publish-gemara.yml new file mode 100644 index 0000000..6a28b94 --- /dev/null +++ b/.github/workflows/publish-gemara.yml @@ -0,0 +1,92 @@ +# Reusable workflow: publish Gemara artifact(s) to grc.store with grcli. +# +# The artifact TYPE (catalog, threat model, evaluation log, …) is NOT a +# parameter — grcli reads it from each file's Gemara `metadata`, so this one +# workflow publishes any Gemara type. You supply the file(s), the license, and +# optionally the hub URL. +# +# === AUTH: NO SECRET REQUIRED (ADR-0032 trusted publishing) === +# grcli uses the workflow's GitHub Actions OIDC token as its hub credential and +# for cosign keyless signing. `id-token: write` is the entire auth setup — do +# NOT add GRCLI_TOKEN, a PAT, or any `secrets.*` reference. +# +# Prerequisite (one-time, by a hub/org admin — NOT in the caller's repo): +# register the calling repository (owner/repo, optionally ref-pinned) as a +# Trusted CI publisher for the target namespace. Without that binding the hub +# returns 403; adding a GitHub secret will not fix it. +# +# --- Call it from a catalog repo --- +# name: Publish catalog +# on: +# push: +# branches: [main] +# paths: ['controls.yaml'] +# workflow_dispatch: {} +# jobs: +# publish: +# permissions: +# contents: read +# id-token: write # caller MUST grant this — it's what auth uses +# uses: revanite-io/grcli/.github/workflows/publish-gemara.yml@v0.3.0 +# with: +# files: controls.yaml +# license: Apache-2.0 + +name: Publish Gemara artifact + +on: + workflow_call: + inputs: + files: + description: 'Artifact file(s), whitespace/newline-separated. Each entry is published as one artifact; grcli reads its type + version from the YAML.' + required: true + type: string + license: + description: 'REQUIRED publication license as an SPDX expression (e.g. Apache-2.0, MIT OR Apache-2.0, or a LicenseRef-… token). Applied to every file.' + required: true + type: string + hub-url: + description: 'grc.store hub base URL — discovers the registry and is the sync target.' + required: false + type: string + default: https://hub.grc.store + grcli-version: + description: 'grcli release tag to install from ghcr.io/revanite-io/grcli.' + required: false + type: string + default: v0.3.0 + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write # hub auth + cosign keyless signing (no secret) + steps: + - uses: actions/checkout@v4 + + # grcli ships as a public, signed, multi-platform OCI artifact; pulling + # needs no token. v2: https://github.com/oras-project/setup-oras/releases/tag/v2.0.0 + - uses: oras-project/setup-oras@38de303aac69abb66f3e6255b7198bff35f323e3 + - name: Install grcli ${{ inputs.grcli-version }} + run: | + oras pull ghcr.io/revanite-io/grcli:${{ inputs.grcli-version }} --platform linux/amd64 + sudo install grcli /usr/local/bin/grcli + + # Required for keyless signing — the same OIDC identity authorizes the push. + - name: Install cosign + uses: sigstore/cosign-installer@v3 + + - name: Publish + env: + FILES: ${{ inputs.files }} + LICENSE: ${{ inputs.license }} + HUB_URL: ${{ inputs.hub-url }} + run: | + set -euo pipefail + # Word-split FILES on whitespace/newlines: one grcli publish per artifact. + for f in $FILES; do + echo "::group::grcli publish $f" + grcli publish -f "$f" --license "$LICENSE" --url "$HUB_URL" + echo "::endgroup::" + done diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..124cf1b --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,96 @@ +name: release + +# Publish grcli as a public, multi-platform OCI artifact on GHCR, signed +# keyless with cosign. Triggered by pushing a semver tag (e.g. v0.1.0). +# +# The source repo stays private; the published *package* is public (GHCR +# package visibility is independent of repo visibility). Make it public +# once, after the first run: GitHub -> repo -> Packages -> grcli -> +# Package settings -> Change visibility -> Public. After that, anyone can +# `oras pull` the binary with no token. See README "Install a pre-built +# binary". +# +# Native macOS binaries ride along because these are raw OCI artifacts, +# not container images (images can't carry darwin binaries). + +on: + push: + tags: ['v*'] + +permissions: + contents: read + packages: write # push the OCI artifact to GHCR + id-token: write # cosign keyless signing (Sigstore via GitHub OIDC) + +env: + REGISTRY: ghcr.io + IMAGE: ghcr.io/${{ github.repository }} # ghcr.io/gemaraproj/grcli (repo moved orgs after v0.5.1) + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + # oras >= 1.3 is required for --artifact-platform and + # `oras manifest index create`. + # v2: https://github.com/oras-project/setup-oras/releases/tag/v2.0.0 + - uses: oras-project/setup-oras@38de303aac69abb66f3e6255b7198bff35f323e3 + with: + version: 1.3.0 + + - uses: sigstore/cosign-installer@v3 + + - name: Log in to GHCR + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: echo "$GH_TOKEN" | oras login "$REGISTRY" -u "${{ github.actor }}" --password-stdin + + - name: Build binaries, push per-platform artifacts, assemble index + id: build + env: + VERSION: ${{ github.ref_name }} + CGO_ENABLED: "0" + run: | + set -euo pipefail + ldflags="-s -w -X github.com/revanite-io/grcli/cmd.version=${VERSION}" + artifact_type="application/vnd.revanite.grcli.binary" + platforms="linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64" + + children=() + for p in $platforms; do + os="${p%/*}"; arch="${p#*/}" + bin="grcli"; [ "$os" = "windows" ] && bin="grcli.exe" + echo "::group::build & push $p" + GOOS="$os" GOARCH="$arch" go build -trimpath -ldflags "$ldflags" -o "$bin" . + tag="${VERSION}-${os}-${arch}" + # Push the raw binary as a platform-tagged OCI artifact. The file + # is stored under its own name, so `oras pull` restores `grcli`. + oras push --artifact-type "$artifact_type" \ + --artifact-platform "$os/$arch" \ + "${IMAGE}:${tag}" \ + "${bin}:application/octet-stream" + rm -f "$bin" + children+=( "${IMAGE}:${tag}" ) + echo "::endgroup::" + done + + # Combine the per-platform artifacts into one multi-arch index. + # Platform metadata is carried from each child (set above), so a + # later `oras pull --platform os/arch` selects the right binary. + oras manifest index create "${IMAGE}:${VERSION}" "${children[@]}" + oras tag "${IMAGE}:${VERSION}" latest + + digest="$(oras resolve "${IMAGE}:${VERSION}")" + echo "digest=${digest}" >> "$GITHUB_OUTPUT" + echo "Published ${IMAGE}:${VERSION} (${digest})" + + # Sign by digest. The signature covers the index, hence every tag + # (version and latest) that points at it. + - name: Sign the index (keyless) + run: cosign sign --yes "${IMAGE}@${{ steps.build.outputs.digest }}" diff --git a/.gitignore b/.gitignore index aaadf73..25ae32a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,32 +1,9 @@ -# If you prefer the allow list template instead of the deny list, see community template: -# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore -# -# Binaries for programs and plugins -*.exe -*.exe~ -*.dll -*.so -*.dylib - -# Test binary, built with `go test -c` +bin/ +coverage.out *.test - -# Code coverage profiles and other test artifacts *.out -coverage.* -*.coverprofile -profile.cov - -# Dependency directories (remove the comment below to include it) -# vendor/ - -# Go workspace file -go.work -go.work.sum - -# env file +grcli-out/ +.grcli.yaml .env -# Editor/IDE -# .idea/ -# .vscode/ +.claude/ diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..d34b73b --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,10 @@ +version: "2" + +# golangci-lint v2. The default linter set (standard) is kept; we only +# re-enable the "std-error-handling" exclusion preset, which v1 applied by +# default but v2 makes opt-in. It excludes errcheck on fire-and-forget +# writes to stdout/stderr (fmt.Fprint*, etc.). +linters: + exclusions: + presets: + - std-error-handling diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..4bd7577 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,222 @@ +# Changelog + +Notable changes to `grcli`. This project is pre-1.0; while on `v0.x`, a breaking +change bumps the minor version. + +## [0.6.0] - 2026-08-19 + +> **Live CI smoke PASSED 2026-08-19** — the gate this release was held behind. +> A real keyless publish ran from a runner with **no cosign installed** +> (`eddie-knight/security-baseline` → preview hub), and the zero-flag verify +> resolved the signature against the hub-recorded signer identity +> `keyless:…#https://github.com/eddie-knight/security-baseline/.github/workflows/publish.yaml`. +> That exercised the Fulcio, Rekor and GitHub-OIDC legs end to end for the +> first time — none of which can be reached offline. +> +> **The repo also moved orgs after v0.5.1**: v0.6.0+ publish to +> `ghcr.io/gemaraproj/grcli`; tags ≤ v0.5.1 remain at +> `ghcr.io/revanite-io/grcli` and are not re-published. The Go module path is +> deliberately unchanged (`github.com/revanite-io/grcli`). + +### Changed + +- **Keyless publish signing runs IN-PROCESS via `sigstore-go`; `cosign` is no + longer required for CI publishing (ADR-0049).** `grcli publish` in GitHub + Actions now requests the OIDC token itself, obtains a Fulcio certificate, + signs the manifest digest (a DSSE-wrapped in-toto statement, byte-shaped like + `cosign sign --new-bundle-format`), logs it in Rekor, and attaches the bundle + as an OCI referrer — all with the library grcli already uses to *verify* + (ADR-0046), so publishing needs no external tools. This removes the cosign + version-band fragility entirely (the `--new-bundle-format` gating, the 2.6.0 + floor, and the 2.4–2.5 dead-zone that broke publishing). `cosign` remains a + prerequisite **only** for `--cosign-key` (key-based) signing and + `verify --cosign-key`. Air-gapped/private-Sigstore signing: point + `GRCLI_FULCIO_URL` / `GRCLI_REKOR_URL` at your instance. + +### Fixed + +- **In-process keyless signing could not attach its signature referrer at all.** + The referrer manifest was packed with artifactType + `https://sigstore.dev/cosign/sign/v1` — a URL, not an RFC 6838 media type — so + `oras.PackManifest` refused it before any network I/O and every keyless + publish died with `invalid artifactType format: … : invalid media type`. The + referrer is now stamped `application/vnd.dev.sigstore.bundle.v0.3+json`, which + is both the semantically correct type (grcli's signer emits a v0.3 bundle, so + it follows the bundle-by-default signer line) and the maximally compatible one + (hubs predating the both-types ingest fix accepted only that stamp). Signature + *discovery* is unchanged and still accepts both stamp variants — cosign 2.6.x + legitimately signs with the URL form; only the write side was ever broken. + (Found by the first real keyless CI publish, 2026-08-19.) + +## [0.5.0] - 2026-07-10 + +### Changed + +- **BREAKING: `unpack` verifies the artifact's signature by default and fails + closed (ADR-0048).** A remote (`--url`) unpack now discovers the Sigstore + signature and verifies it in-process BEFORE writing anything — the same check + as `grcli verify` (zero-flag against the identity the hub recorded at ingest, + or `--certificate-identity` to assert the signer yourself and bypass the hub). + An unsigned, mis-signed, or unverifiable artifact is refused and **no files are + written**. Pass `--no-verify` to write without verifying (INSECURE); a local + `--source` layout has no registry signature and is always written unverified. + - *Migration:* scripts that unpacked unsigned/legacy content now fail until they + pass `--no-verify` or the content is re-published signed (same migration class + as the earlier signature-format cutovers). + - *Offline note:* a cached unpack is no longer fully offline — verification + contacts the hub/registry even on a content cache hit. Use `--no-verify` for + the previous offline-from-cache behavior. + +### Fixed + +- **`verify` now discovers signatures attached by cosign 3.x.** The referrer + artifactType a cosign-signed catalog carries depends on the signer's cosign + major version: 2.6.x (`--new-bundle-format`) stamps + `https://sigstore.dev/cosign/sign/v1`, while 3.x (bundle by default) stamps + `application/vnd.dev.sigstore.bundle.v0.3+json` — the bundle inside is + identical. grcli filtered on the 2.6.x value only, so a cosign-3.x-signed + catalog verified as "no signature attached". Discovery now accepts both + stamp variants. (Found by the first live zero-flag verify against preview, + 2026-07-07; supersedes the protocol's "do not cross these" mediatype rule, + whose premise predates cosign 3.x.) +- **The cosign floor for `publish` signing is ≥ 2.6.0, not ≥ 2.4.0 as v0.4.1 + claimed.** cosign added `--new-bundle-format` to `verify` in 2.4.0 but to + `sign` only in **2.6.0** (confirmed against the release tags' source), so on + cosign 2.4.x–2.5.x v0.4.1 still died mid-publish on the raw + `unknown flag: --new-bundle-format` its version gate was built to prevent — + caught live by a CI publish pinned to cosign v2.5.2. The gate now fails fast + below 2.6.0 with the corrected floor in the message; cosign ≥ 3.x is + unaffected (the flag is omitted there entirely). + +## [0.4.1] - 2026-07-05 + +### Fixed + +- **`publish` signing no longer hard-codes `--new-bundle-format`, so it works + across the whole supported cosign range instead of a narrow band.** grcli now + detects the cosign version (`cosign version --json`) and selects the Sigstore + bundle-format flag accordingly: it passes `--new-bundle-format` on cosign + 2.4.0–2.x (where the flag is first-class), and omits it on cosign ≥ 3.0.0 + (where the bundle format is already the default and the flag is deprecated). + This removes the deprecation warning on every sign under cosign 3.x and makes + grcli forward-compatible with cosign removing the flag. A cosign **below + 2.4.0** now fails fast, before any bytes are pushed, with a clear "needs cosign + ≥ 2.4.0 — pin a newer cosign" message instead of surfacing cosign's raw + `unknown flag: --new-bundle-format`. The stated cosign prerequisite drops from + ≥ 3.x to **≥ 2.4.0**. The same version-gated helper backs the key-based + `verify --cosign-key` shell-out, so sign and verify stay a matched pair. + (Reported against v0.4.0 by the FINOS Common Cloud Controls release pipeline.) + +## [0.4.0] - 2026-07-03 + +### Changed + +- **Keyless `grcli verify` now verifies in-process — `cosign` is no longer a + consumer prerequisite** (ADR-0046). Both keyless paths (zero-flag + verify-by-coordinate and explicit `--certificate-identity`) verify with the + embedded `sigstore-go` library and the same pinned trust root + policy the hub + uses (Rekor inclusion, observer timestamps, SCTs required), enforcing the + expected signer identity in the verification policy. The signature is + discovered in-process as an OCI referrer of the artifact manifest, so the old + `--registry-token` subprocess plumbing is gone from the keyless paths. The + pinned Sigstore public-good `trusted_root.json` is embedded and refreshed with + each release; override it via `GRCLI_TRUSTED_ROOT` / the `trusted-root` config + key (a `trusted_root.json` path) for air-gapped or private-Sigstore + deployments. Only key-based `verify --cosign-key` still shells out to + `cosign` ≥ 3.x. Verification behavior and identity semantics are unchanged — + the same bundles that verified before verify the same way now. + +### Added + +- **`GRCLI_TRUSTED_ROOT` / `trusted-root` config key** (ADR-0046) — overrides the + embedded Sigstore trust root with a `trusted_root.json` read from disk, for + air-gapped deployments or a private Sigstore instance. Unset, keyless verify + uses grcli's pinned embedded public-good root. + +### Changed — BREAKING + +- **The per-project `./.grcli.yaml` config layer is removed (ADR-0044).** Config + now resolves from `--flag` > `GRCLI_*` env > user-global + `~/.config/grcli/config.yaml` > built-in default; the repo-local file is no + longer read. A committed config file must not be able to steer where a + publish/verify tool talks. **Migration:** move any settings from + `./.grcli.yaml` to `~/.config/grcli/config.yaml` — a lingering `./.grcli.yaml` + prints a warning until removed. + +### Added + +- **`grcli verify` gains zero-flag verify-by-coordinate** (ADR-0045). Run + `grcli verify --repository / --version ` with **no trust flags** and + grcli fetches the catalog record from the hub, reads the keyless signer + identity the hub verified and pinned at ingest, and verifies against it — so a + consumer needs no prior knowledge of the publishing workflow. The identity, and + that it came from the hub record, are printed before verification runs (trust + in the hub is visible, never silent). The ref-stripped pin is matched with an + anchored SAN regexp `'^@'`, admitting any git ref of + that exact workflow but nothing wider. If the hub has no recorded + identity (an artifact predating hub-side verification), verify fails with a + clear pointer to the explicit flags. Passing `--cosign-key` or + `--certificate-identity` bypasses the hub lookup entirely — the independent, + high-assurance path — unchanged (including ADR-0044's issuer default). +- **`grcli verify` defaults `--certificate-oidc-issuer` to + `https://token.actions.githubusercontent.com`** (ADR-0044). Keyless + verification of a GitHub-Actions-signed bundle then needs only + `--certificate-identity`. Override the issuer via the flag, the + `GRCLI_CERTIFICATE_OIDC_ISSUER` env, or the user-global config for GitHub + Enterprise, another CI provider, or an OIDC proxy. verify still checks the + issuer, so a wrong value fails closed (it rejects, never falsely accepts). + +## [0.3.0] - 2026-07-02 + +### Added + +- **`grcli cat`** — prints an artifact's Gemara content to stdout without writing + files, the read-only companion to `unpack`. Emits the artifact file(s) only + (never `bundle.json`/manifest/provenance); a single-file bundle prints verbatim, + a multi-file bundle as a `---`-separated YAML stream (`--file ` selects + one). Diagnostics go to stderr, so `grcli cat … | yq …` stays pipe-clean. +- **On-disk artifact cache for remote fetches.** A remote (`--url`) `unpack`/`cat` + of a `namespace/id/version` stores the whole bundle at `$GRCLI_CACHE` (default + `os.UserCacheDir()/grcli`); repeat fetches — and references to the same + coordinate — are served offline (immutable tags make a hit always fresh). + `--no-cache` bypasses it for one run; no eviction/GC yet. +- **User-global config file** `$XDG_CONFIG_HOME/grcli/config.yaml` + (`~/.config/grcli/config.yaml`), merged **under** the per-project `./.grcli.yaml`. + New key `cache-enabled: false` (`GRCLI_CACHE_ENABLED`) durably disables the cache. + +### Changed — BREAKING + +- **`unpack` now consults the cache for the primary artifact.** A remote `unpack` + that previously always hit the network now serves a warm coordinate from the + cache (and skips discovery entirely on a hit). Use `--no-cache` for the old + always-fresh behavior. +- **Resolved references are now written as a directory, not a flat file.** + `--with-imports`/`--with-references` previously wrote each reference as + `references///@.json` (the hub's JSON projection); it + is now a directory `references///@/` containing the + artifact's original YAML file(s) + `bundle.json`, and `references/index.json`'s + `path` points at that directory. +- **Config precedence changed and `$HOME/.grcli.yaml` is dropped.** Config is now + layered (flag > `GRCLI_*` env > project `./.grcli.yaml` > user-global + `config.yaml` > default) with the project file merged over the global one, + replacing first-match-wins. The old home-root dotfile `~/.grcli.yaml` is no + longer read — **move it to `~/.config/grcli/config.yaml`.** (The previously + *advertised-but-nonfunctional* `$XDG_CONFIG_HOME/grcli/config.yaml` now works.) + +- **Catalog signatures now use the Sigstore bundle format** — `grcli` signs (and + verifies) with cosign's `--new-bundle-format`, attaching the signature as an OCI + 1.1 referrer of the bundle, instead of the legacy tag-based `sha256-….sig`. This + converges grc.store on one signature format (the hub's plugin path already uses + it). + - **Migration:** a catalog signed by this version will **not** verify with an + older `grcli verify`, and a catalog signed by an older `grcli` will **not** + verify with this version. **Re-publish existing catalogs to re-sign them in the + bundle format.** + - To verify a catalog manually, use `cosign verify --new-bundle-format …` (not + bare `cosign verify`). + - **New requirement:** `cosign` >= 3.x on `PATH`. + +### Internal + +- Adopt the shared `github.com/revanite-io/grc-store-protocol` module for the + discovery / sync / registry-token wire types (no behavior change; wire-identical). diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..d65e615 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,49 @@ +# grcli — agent orientation + +Go CLI and **primary end-user surface** for grc.store: validates Gemara YAML, packs it into +signed OCI bundles with SLSA-shaped provenance, publishes to a hub, and verifies bundles. +Go module: `github.com/revanite-io/grcli` (unchanged on purpose). The **repo lives at +`github.com/gemaraproj/grcli`** since the org move after v0.5.1; releases v0.6.0+ publish to +`ghcr.io/gemaraproj/grcli`, older tags remain at `ghcr.io/revanite-io/grcli`. + +`README.md` covers install (via `oras`), the full usage flow, and CI/trusted-publishing; +`CHANGELOG.md` tracks the pre-1.0 breaking changes. This file is the map — point there, don't duplicate. + +> **Building new end-user tooling? Reuse this, don't fork it.** The `internal/` packages below +> are the intended reuse surface, and the wire types come from `../grc-store-protocol`. See the +> reuse map in `../CLAUDE.md`. + +## Dev loop (Makefile) +- `make build` → `bin/grcli` · `make test` (`./...`) · `make lint` (golangci-lint) · `make vet` +- `make ci-local` — fmtcheck + vet + lint + testcov (the CI gate) + +## Commands (`cmd/`) +`login`/`logout` (OIDC device flow, credential storage) · `validate` (YAML vs Gemara spec via +`cue vet`) · `publish` (pack + sign + push OCI bundle) · `verify` (cosign / Sigstore bundle; +zero-flag mode verifies against the hub-recorded signer identity, ADR-0045) · +`unpack` (verify signature fail-closed — `--no-verify`/`--source` skip — then extract to a directory +from OCI layout or registry; ADR-0048 reuses verify's policy path) · `cat` (stream Gemara content to +stdout, no files — read-only companion to `unpack`, ADR-0042) · `versions /` (list +published versions). Registered in `cmd/root.go`; one file per command (`publish.go`, `verify.go`, +…). `unpack` and `cat` share the cache-checking fetch stage in `fetch.go` (`resolveBundle`) and +differ only in the last mile. (`regtoken.go` is an internal helper — `ensureRegistryToken()` — not +a user command.) + +## Reuse surface (`internal/`) +`hub` (`/v1/bundles/sync`, `/v2/token`, discovery) · `registry` (OCI packing via `oras-go`) · +`sign` (cosign shell-out, Sigstore bundle) · `provenance` (SLSA v1.0 predicate) · `source` (load/merge/verify YAML) · `refs` · +`digest` (SHA256) · `cache` (immutable-tag disk cache — v2 stores the whole bundle: files + +`bundle.json`, ADR-0042). Imports `grc-store-protocol` (discovery, syncapi, registrytoken, spdx). +**Auth is no longer here**: `internal/auth` (device flow, credential store, token resolution, GHA +OIDC) was deleted in favour of `github.com/gemaraproj/grc-store-clientkit/auth`, shared with +privateer-sdk. `cmd.grcliApp` (`cmd/root.go`) is the per-tool identity that keeps grcli's own +credential file and login hints — pass it to every clientkit auth call. + +## Gotchas +- **External tools on PATH**: `cosign` ≥ 2.6.0 is needed **only** for key-based signing (`publish --cosign-key`) and key-based `verify --cosign-key`; `cue` for `validate`. **Keyless signing AND verifying are in-process — no cosign** (ADR-0049 sign + ADR-0046 verify), both via the embedded `sigstore-go`. Keyless `publish` (CI trusted publishing) requests the GHA OIDC token itself, hits Fulcio+Rekor, and attaches a DSSE in-toto bundle referrer (`internal/sign/keyless.go` + `registry.AttachSignatureReferrer`); override `GRCLI_FULCIO_URL`/`GRCLI_REKOR_URL` for a private Sigstore. When cosign IS used (key mode), grcli gates its bundle-format flag on the detected version (`internal/sign.BundleFormatArgs`: `--new-bundle-format` on 2.6–2.x, omitted ≥ 3.x, fail-fast below 2.6.0). The verify side mirrors the hub's `internal/sigverify`; the pinned trust root now comes from `grc-store-clientkit/trustroot` (rotate it there and re-tag the module — it is no longer vendored here), or point `GRCLI_TRUSTED_ROOT` at one. Catalog signatures are discovered as OCI referrers accepting BOTH signature stamp variants — `mediatype.CosignSignReferrer` (cosign 2.6.x) and `mediatype.SigstoreBundle` (cosign 3.x default) — since the stamp follows the signer's cosign major, not the artifact kind (the old "do not cross these" rule predated cosign 3.x; see the mediatype doc). OCI transport uses the `oras-go` **library**, not the `oras` CLI — `oras` is only needed to *install* grcli (see README), not to run it. +- **Signing is on by default**; `--no-sign` to opt out. +- **Caching (ADR-0042)**: remote `unpack`/`cat` fetches (and resolved references) are served from a global on-disk cache at `$GRCLI_CACHE` (default `os.UserCacheDir()/grcli`); a hit needs no network for the *content* (immutable tags → never stale). No GC yet. `--no-cache` per run; `cache-enabled: false` to disable durably. Caveat (ADR-0048): a default `unpack` still hits the hub/registry to *verify* even on a content cache hit — `--no-verify` restores a fully offline hit. +- Config (ADR-0043, amended by ADR-0044): flag > `GRCLI_*` env > user-global `$XDG_CONFIG_HOME/grcli/config.yaml` (→ `~/.config/grcli/config.yaml`) > default. **No per-project layer** — a repo-local `./.grcli.yaml` is not read (a committed file must not steer a publish/verify tool) and earns a migration warning. `--config ` bypasses the search. The cache toggle key is flat `cache-enabled` (not nested `cache.enabled`) because `$GRCLI_CACHE` shadows the `cache.*` viper namespace. Env prefix `GRCLI_*` (e.g. `GRCLI_REGISTRY_TOKEN`, `GRCLI_GEMARA_SPEC_DIR`). `grcli verify`'s `--certificate-oidc-issuer` defaults to GitHub Actions (ADR-0044). (Neither `./.grcli.yaml` nor `$HOME/.grcli.yaml` is read.) +- Credentials stored at `$XDG_DATA_HOME/grcli/credentials.json` (0600). +- CI publishing uses GitHub Actions OIDC (`ACTIONS_ID_TOKEN_REQUEST_URL/_TOKEN`); example at `examples/github-actions/publish.yml`. +- **grcli defaults to the *prod* hub** — for test publishing use `../publish-fixtures/` (forces preview). diff --git a/IMPLEMENTATION.md b/IMPLEMENTATION.md new file mode 100644 index 0000000..5285f8c --- /dev/null +++ b/IMPLEMENTATION.md @@ -0,0 +1,122 @@ +# Implementation plan: `grcli cat`, primary-artifact cache, and user-global config + +Tracks the work for **ADR-0042** (`cat` + cache the primary/whole bundle) and **ADR-0043** +(user-global config file), both in `../grc.store-backend/docs/adr/`. This is grcli-only — no +backend, hub, or `grc-store-protocol` change. ADRs flip `Proposed → Accepted` on merge. + +## Decisions locked in (see the ADRs for rationale) + +- **`cat` streams Gemara content only** — the artifact file(s), which carry the Gemara + `metadata:` block. No `bundle.json`/manifest/provenance via `cat` (no `--manifest` flag); + bundle information is `unpack`'s job. +- **Cache stores the complete decoded bundle** — `bundle.json` (from `bundle.Manifest`) + every + `bundle.Files` entry, each with a per-file content digest. Not the raw OCI layout, not the + cosign signature — so `verify` still hits the network (verify-on-pull is deferred, ADR-0039). +- **`cat` and `unpack` share the fetch stage, diverge only at the last mile.** One helper does + resolve-source → cache-check → fetch-on-miss → cache-put → return the in-memory bundle. + `unpack` then writes the dir (`writeBundle`); `cat` then streams `Files`. Neither does the + other's last mile. +- **References use the same full-bundle format via the registry (ADR-0042 decision 5, option + (a)).** Reference resolution moves off `hub.GetVersionBody` onto a registry pull, so each + referenced repo needs its own pull token (`ensureRegistryToken`) and a repo-path derivation + from `{ns}/{id}`. One uniform cache format; the token/plumbing cost is accepted. +- **`--no-cache`** (hyphenated, existing flag) bypasses the cache on both commands. + **`cache-enabled`** (config, default true) is the durable off switch. `$GRCLI_CACHE` location + override is retained; there is no cache-*location* config key. +- **Config precedence via viper merge, not first-match** — flag > `GRCLI_*` env > project + `./.grcli.yaml` > user-global `$XDG_CONFIG_HOME/grcli/config.yaml` > default. Fixes the phantom + `config.yaml` path (today `loadConfig` searches `.grcli.yaml` in the XDG dir). + +## Grounding (verified against current source) + +- `registry.UnpackRemote(ctx, host, repo, tag) (*bundle.Bundle, error)` → `bundle.Unpack`; the + bundle carries `Files []File`, `Manifest`, `Etag` (OCI manifest digest). `UnpackLocal` is the + `--source` twin. (`internal/registry/registry.go`) +- `writeBundle` (`cmd/unpack.go`) builds `bundle.json` via `json.MarshalIndent(b.Manifest, …)` — + reconstructed, so the cache can store the manifest and reproduce it. +- References today: `fetchReference` (`cmd/unpack.go`) uses `hub.GetVersionBody` + + `hub.GetCatalog` (for license/manifest-digest). Option (a) replaces the body fetch with a + registry pull. +- Cache today: `internal/cache/cache.go` — `Entry{ Body []byte; … }`, single `body.` + + `meta.json`, `layoutVersion = "v1"`, host-namespaced `entryDir`. Needs the multi-file change. +- Config today: `loadConfig` (`cmd/root.go`) — `SetConfigName(".grcli")` + `AddConfigPath`, + first-match-wins (`ReadInConfig`), `GRCLI` env prefix. Needs `MergeInConfig` + explicit paths. + +## Phases + +### Phase 1 — Cache `v2` multi-file entry format *(independent; land first)* +`internal/cache/cache.go`, `internal/cache/cache_test.go` +- Replace single `Body` with a bundle entry: `Files []struct{Name, Digest string; Data []byte}` + + `Manifest []byte` (the `bundle.json` bytes) + existing `ManifestDigest`/`License`/ + `SourceURL`/`Verified`. +- On disk: `meta.json` + `bundle.json` + `files/`; per-file digest computed on `Put`, + verified on `Get` (corruption → `found=false` + error, as today). +- Bump `layoutVersion` `v1` → `v2` (no migration — `v1` dirs are simply never read). +- Tests: multi-file round-trip, per-file corruption, host-namespacing preserved, `v1` ignored. + +### Phase 2 — Shared cache-checking fetch + wire `unpack` to it *(depends on P1)* +`cmd/unpack.go` (+ maybe a small helper file) +- Add `resolveBundle(ctx, v, src, url, repo, version) (*bundle.Bundle, error)`: source + resolution → `cache.Get` → `UnpackRemote`/`UnpackLocal` on miss → `cache.Put` (remote only) → + return bundle. `--source` skips cache but flows through the helper. `--no-cache` + + `cache-enabled` gate caching inside the helper. +- `runUnpack` remote branch calls `resolveBundle` instead of `UnpackRemote` directly; last mile + stays `writeBundle`. Extend `--no-cache` to cover the primary (today it only gates references). +- Tests: cache hit avoids network, `--no-cache` bypasses, `--source` uncached, corrupt entry + re-fetches. + +### Phase 3 — `grcli cat` command *(depends on P2)* +new `cmd/cat.go`, register in `cmd/root.go` +- Flags: `--source` / `--url` + `--repository` / `--version` (reuse `unpack`'s selectors and + `suppressDefaultURLIfExplicit`), `--file `, `--no-cache`. No `--output`, no `--with-*`, + no `--manifest`. Mint an anonymous pull token for `--url` reads (as `unpack` does). +- Fetch via `resolveBundle`; last mile: single file verbatim, multi-file as `---` YAML stream, + `--file` selects one. +- Tests: `cat_test.go` (single, multi-file stream, `--file`, `--source` path, cache hit) + + integration coverage à la `cmd/integration_test.go`. + +### Phase 4 — Reference resolution onto the registry/full-bundle path *(depends on P1)* +`cmd/unpack.go` (`fetchReference`, `resolveReferences`) +- Replace the `hub.GetVersionBody` fetch with a registry pull for each reference: derive the + repository from `{ns}/{id}`, `ensureRegistryToken(..., []string{"pull"})` per referenced repo, + `UnpackRemote`, store as a `v2` entry. Keep the license/manifest-digest recording and the + license-mismatch warning. Reference *output* under `references//…` is unchanged. +- Tests: reference cache hit/miss, per-repo token minted, license warning preserved. Also close + the pre-existing zero-coverage gap on `resolveReferences`/`fetchReference` surfaced in Phase 1 + QA — this path had no tests through Phase 1 and must not land Phase 4 untested. + +### Phase 5 — User-global config *(independent; can run in parallel with P1–P4)* +`cmd/root.go` (`loadConfig`), all command RunEs +- Switch first-match to explicit merge: read user-global + `$XDG_CONFIG_HOME/grcli/config.yaml` (fallback `$HOME/.config/grcli/config.yaml`), then + `MergeInConfig` the project `./.grcli.yaml` on top; `--config` still selects a single file. + Fixes the phantom `config.yaml` path. +- Bind `cache-enabled` (default true); shared `cachingEnabled(v)` helper = + `cache-enabled && !--no-cache`, consumed by `resolveBundle` and the reference path. +- Tests: precedence (flag > env > project > global > default), `cache-enabled:false` ⇒ no cache + I/O, merge (global honored when project file present). + +### Phase 6 — Docs + ADR status +- `README.md`: `cat` command, cache behavior (`$GRCLI_CACHE`, `--no-cache`, unbounded/no-GC), + config file + precedence. +- `CLAUDE.md`: add `cat` to Commands; correct the config section (`config.yaml` is the real + user-global path; document precedence). Note the cache now covers the primary. +- `CHANGELOG.md`: breaking — `unpack` now consults a cache for the primary; `.grcli.yaml` + precedence change (global file now merges under project). +- Flip ADR-0042 / ADR-0043 to `Accepted`. + +## Sequencing & PRs +- Critical path: **P1 → P2 → P3**. **P4** depends on P1. **P5** is fully independent. +- Suggested PRs: (1) cache v2, (2) shared fetch + unpack + cat, (3) reference migration, + (4) config. Or bundle 1–3 if reviewed together. +- Gate every PR on `make ci-local` (fmtcheck + vet + lint + testcov). + +## Risks / watch-items +- **Behavior breaks (accepted, ~no users):** `unpack` primary now cached; `.grcli.yaml` no + longer shadows the home file. +- **Unbounded cache** grows faster now (primary + full-bundle references). `grcli cache clean` / + eviction remains the named ADR-0039 follow-up — out of scope here but more pressing. +- **Per-reference pull tokens (Phase 4)** add hub round-trips when resolving many references; + watch latency on large dependency sets. +- **`verify` is not cache-served** — intentional; raw-OCI-layout storage is the documented + upgrade path if offline verify is ever needed. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..56a9ae8 --- /dev/null +++ b/Makefile @@ -0,0 +1,46 @@ +.PHONY: build test testcov lint vet fmt fmtcheck tidy tidycheck ci-local clean + +BIN := bin/grcli +PKG := github.com/revanite-io/grcli +VERSION ?= $(shell git describe --tags --dirty --always 2>/dev/null || echo dev) +LDFLAGS := -X $(PKG)/cmd.version=$(VERSION) + +build: + mkdir -p bin + go build -ldflags "$(LDFLAGS)" -o $(BIN) . + +test: + go test ./... + +testcov: + go test -coverprofile=coverage.out ./... + +lint: + golangci-lint run ./... + +vet: + go vet ./... + +fmt: + gofmt -s -w . + +fmtcheck: + @diff=$$(gofmt -s -d .); \ + if [ -n "$$diff" ]; then \ + echo "gofmt diff:"; echo "$$diff"; exit 1; \ + fi + +tidy: + go mod tidy + +tidycheck: + @cp go.mod go.mod.bak; cp go.sum go.sum.bak; \ + go mod tidy; \ + diff=$$(diff go.mod go.mod.bak; diff go.sum go.sum.bak); \ + mv go.mod.bak go.mod; mv go.sum.bak go.sum; \ + if [ -n "$$diff" ]; then echo "go mod tidy would change go.mod/go.sum"; exit 1; fi + +ci-local: fmtcheck vet lint testcov + +clean: + rm -rf bin coverage.out grcli-out diff --git a/README.md b/README.md index 5198f65..94d6f5d 100644 --- a/README.md +++ b/README.md @@ -1 +1,297 @@ -# grcli \ No newline at end of file +# grcli + +A command-line tool for the GRC artifact registry at +[grc.store](https://grc.store). `grcli` validates Gemara YAML against the +spec, packs it into a signed OCI bundle with SLSA-shaped provenance, +publishes it to a registry, and verifies bundles you fetch back. + +## Install or Upgrade + +Binaries are published as a public, signed, multi-platform OCI artifact +at `ghcr.io/gemaraproj/grcli` (linux, macOS, and Windows on amd64 and +arm64). Pulling needs no token. You need [`oras`](https://oras.land) ≥ +1.3 on `PATH`. + +```sh +# platforms: linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64 +oras pull ghcr.io/gemaraproj/grcli:latest --platform darwin/arm64 +chmod +x grcli && sudo mv grcli /usr/local/bin/ +``` + +In GitHub Actions: + +```yaml +# v2: https://github.com/oras-project/setup-oras/releases/tag/v2.0.0 +- uses: oras-project/setup-oras@38de303aac69abb66f3e6255b7198bff35f323e3 +- run: | + oras pull ghcr.io/gemaraproj/grcli:latest --platform linux/amd64 + sudo install grcli /usr/local/bin/grcli +``` + +Pin a release tag (`:v0.6.0`) instead of `latest` for reproducible +installs. To verify the signature before installing: + +```sh +cosign verify ghcr.io/gemaraproj/grcli:latest \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com \ + --certificate-identity-regexp '^https://github.com/gemaraproj/grcli/.github/workflows/release.yml@' +``` + +## Prerequisites + +Some commands shell out to external tools: + +- **`cosign` ≥ 2.6.0** on `PATH` — **only** for key-based signing + (`publish --cosign-key`) and key-based `verify --cosign-key`. When cosign is + used, grcli detects its version and adapts the Sigstore bundle-format flag + (`--new-bundle-format` on 2.6–2.x, omitted on 3.x). **Keyless CI publishing and + all keyless `verify`/`unpack` need no external tools** — grcli signs (ADR-0049) + and verifies (ADR-0046) in-process against Sigstore, so the common path is just + the `grcli` binary. https://docs.sigstore.dev/cosign/installation/ +- **`cue`** on `PATH` — `validate`. https://cuelang.org +- **A Gemara spec checkout** — `validate`. + `git clone https://github.com/gemaraproj/gemara` +- **A grc.store account** — `publish`. Run `grcli login` (OIDC device + flow), or use trusted publishing in CI (see below). + +If you only inspect or validate bundles, you need no account or registry +credentials. + +> **CI note (read before adding any GitHub secret):** `grcli publish` +> in GitHub Actions authenticates via the workflow's GitHub OIDC token, +> not a stored secret. You do **not** need to set `GRCLI_TOKEN`, a PAT, +> or any `secrets.*` value. The only requirements are +> `permissions: id-token: write` on the job and a one-time trusted- +> publisher binding for `owner/repo` (and optionally a specific branch) +> on the hub. See [Publishing from GitHub Actions](#publishing-from-github-actions). + +## Usage + +Run `grcli --help` for the full flag list. The typical flow is +`login → validate → publish`; consumers `verify → unpack`. + +| Command | What it does | +| --- | --- | +| `login` | Sign in to a hub via OIDC device flow; stores tokens for `publish`. | +| `validate` | Check YAML against the Gemara spec via `cue vet`. | +| `publish` | Pack an artifact + provenance into a signed OCI bundle, push it, and notify the hub. Requires `--license` (SPDX expression, ADR-0037). | +| `verify` | Verify a remote bundle's Sigstore signature (keyless: in-process, no cosign) — with no trust flags, against the signer identity the hub recorded at ingest. | +| `unpack` | Verify a remote bundle's signature (fail-closed; `--no-verify` to skip) then write its files + manifest to disk. | +| `cat` | Print an artifact's Gemara content to stdout (no files written) — for piping into `yq`. | +| `logout` | Forget locally-stored credentials. | + +```sh +# Sign in (defaults to https://hub.grc.store) +grcli login + +# Validate against a spec checkout matching your metadata.gemara-version +grcli validate -f controls.yaml --spec /path/to/gemara + +# Publish — picks up the stored login token; signs by default. +# --license is REQUIRED (ADR-0037) and takes an SPDX expression; publish +# fails before any network call without it. Use your catalog's real terms. +# Locally you must also supply signing material: --cosign-key (below) or +# --no-sign. Keyless signing is CI-only — see "Signing" further down. +grcli publish -f controls.yaml --license Apache-2.0 --cosign-key cosign.key + +# Verify a published bundle — zero-flag: uses the signer identity the hub +# recorded at ingest (prints it, and that it came from the hub, before verifying) +grcli verify --repository myorg/my-controls --version 1.0.0 + +# Or assert the identity yourself for an independent check (bypasses the hub +# lookup; issuer defaults to GitHub Actions) +grcli verify --repository myorg/my-controls --version 1.0.0 \ + --certificate-identity https://github.com/myorg/my-controls/.github/workflows/publish.yml@refs/heads/main + +# Unpack a bundle to disk +grcli unpack --repository myorg/my-controls --version 1.0.0 --output ./unpacked + +# Print an artifact's Gemara content to stdout (no files written) +grcli cat --repository myorg/my-controls --version 1.0.0 | yq '.title' +``` + +These default to the public hub at `https://hub.grc.store`; add `--url +` for a private deployment. + +### Where a publish lands: the `--repository` default + +`publish` does not ask you where to publish — **it derives the target from the +bundle's own metadata**: + +``` +/ = slugify(metadata.author.id) / slugify(metadata.id) +``` + +`slugify` replaces every run of characters outside `[a-zA-Z0-9._-]` with a +single `-`, trims leading/trailing `-`, `_`, and `.`, and lowercases the +result. So a bundle with `author.id: TAG-SC` and `id: cnsc` publishes to +`tag-sc/cnsc` — regardless of which organization you are a member of. + +**This is the usual cause of a 403 on publish.** Authorization is per +*namespace* (the part before the `/`), so you must own — or hold a trusted +publisher binding for — the namespace the metadata names, not the one you +meant. If a bundle inherits `author.id` from an upstream source, the derived +namespace belongs to that upstream. + +**The fix is to change `metadata.author.id` in the bundle** (or have the +binding registered for the namespace the metadata actually names). + +> **`--repository` is not a workaround for a 403.** It overrides only the +> **OCI push destination** — the hub still indexes the artifact under +> `slugify(metadata.author.id)`. Pointing it at a namespace that disagrees +> with the metadata splits the two apart: the blobs land in one repository +> while the index row is written under another. The publish *appears* to +> succeed, the artifact does not show up where you aimed it, and re-running +> fails with a digest conflict. Use `--repository` only when it agrees with +> what the metadata derives. + +### Reading artifacts: `unpack` vs `cat` + +`unpack` **verifies the artifact's signature before writing anything** and fails +closed — an unsigned or mis-signed artifact is refused and no files land on disk +(same check as `verify`; `--no-verify` opts out, `--source` layouts have no +signature to check). It then writes the artifact's files **and** its +`bundle.json` manifest (with provenance) into a directory. `cat` streams the +**Gemara content only** to +stdout — no manifest, no files on disk — so it pipes cleanly into `yq` (the +content is YAML; for `jq`, convert first with `yq -o=json`). A +single-file bundle prints verbatim; a multi-file bundle prints as a `---` +separated YAML stream (use `--file ` to pick one). With `--with-imports` / +`--with-references`, `unpack` also pulls the artifacts a bundle references into a +`references///@/` directory tree plus a +`references/index.json` (`cat` is primary-only). + +### Caching + +Remote (`--url`) fetches are served from an on-disk cache: the first pull of a +given `namespace/id/version` is stored (the whole bundle — files + manifest), +and later `unpack`/`cat` of the same coordinate — or references to it — are +served from the cache with **no network at all**. grc.store tags are immutable, +so a cache hit can never be stale. The cache lives at `$GRCLI_CACHE` (default +`os.UserCacheDir()/grcli`) and grows without bound (no GC yet). Note: a default +`unpack` still contacts the hub/registry to *verify* the signature even on a +content cache hit (ADR-0048); `--no-verify` restores a fully offline cache hit. + +- `--no-cache` bypasses the cache for a single run (fresh pull, nothing stored). +- Set `cache-enabled: false` in config (below) to disable it durably. +- `--source` (local layout) reads are never cached. + +### Configuration + +`grcli` reads config from, highest precedence first: a `--flag`, a `GRCLI_*` +env var, and the user-global `$XDG_CONFIG_HOME/grcli/config.yaml` (falling back +to `~/.config/grcli/config.yaml`). There is **no per-project layer**: a +repo-local `./.grcli.yaml` is deliberately not read (ADR-0044) — a committed +file must not be able to steer where a publish/verify tool talks — and a present +one prints a migration warning until removed. `--config ` selects a single +file and bypasses the search. + +Keys (env form in parentheses): + +- `cache-enabled: true|false` (`GRCLI_CACHE_ENABLED`) — durable equivalent of + `--no-cache` when `false`. Default `true`. +- `url` (`GRCLI_URL`) — the hub base URL. Config keys generally use the same + name as their flag (env form: `GRCLI_` + the name upper-snaked); see + `grcli --help` for the flag list. +- `certificate-oidc-issuer` (`GRCLI_CERTIFICATE_OIDC_ISSUER`) — the OIDC issuer + `grcli verify` expects for keyless verification. Defaults to + `https://token.actions.githubusercontent.com`; set it only for GitHub + Enterprise, another CI provider, or an OIDC proxy. +- `trusted-root` (`GRCLI_TRUSTED_ROOT`) — path to a `trusted_root.json` that + overrides the embedded Sigstore public-good trust root for keyless `verify` + (ADR-0046). For air-gapped deployments or a private Sigstore instance only; + unset, grcli uses its pinned embedded root. + +> **Registry credentials are env-only, never config keys**: set +> `GRCLI_REGISTRY_TOKEN` (or `GRCLI_REGISTRY_USERNAME` + +> `GRCLI_REGISTRY_PASSWORD`) in the environment. A `registry-token:` line in a +> config file is ignored. The cache *location* is likewise set only by +> `$GRCLI_CACHE`. (The cache toggle key is the flat `cache-enabled`, not +> `cache.enabled`, because `$GRCLI_CACHE` would otherwise shadow a nested +> `cache.*` key.) + +Signing is required by default, and `publish` fails *before* pushing when it +can't sign, so nothing unsigned reaches the registry. What counts as signing +material depends on where you run (`internal/sign.Preflight`): + +| Where | Signing material | cosign on `PATH`? | +|---|---|---| +| GitHub Actions (`GITHUB_ACTIONS=true`) | the runner's OIDC token — needs `permissions: id-token: write` | **no** — in-process, ADR-0049 | +| Anywhere else | `--cosign-key` (or `COSIGN_KEY`) | **yes**, ≥ 2.6.0 | +| Either, opting out | `--no-sign` | no | + +So a **local** `publish` with neither a key nor `--no-sign` is refused up +front — keyless signing is a CI-only path, because it depends on the +workflow's OIDC identity: + +``` +grcli: no signing material — pass --cosign-key (or COSIGN_KEY) for local +signing, run in GitHub Actions with `permissions: id-token: write` for +keyless signing, or pass --no-sign to publish without provenance +``` + +Keyless `verify` runs **in-process** against Sigstore (ADR-0046): no `cosign`, +no version-skew caveats, just the `grcli` binary. It embeds the pinned Sigstore +public-good trust root, refreshed with each grcli release; for an air-gapped or +private-Sigstore deployment, point `GRCLI_TRUSTED_ROOT` (env, or the +`trusted-root` config key) at a `trusted_root.json` on disk. Catalog signatures +use the **Sigstore bundle format** (v0.3, attached as an OCI 1.1 referrer); +artifacts signed by an older grcli (the legacy `.sig` tag format) must be +re-published to re-sign. Only key-based `verify --cosign-key` still shells out +to `cosign` ≥ 2.6.0 — a niche publisher-shared-key path. + +## Publishing from GitHub Actions + +**`grcli` in CI needs no GitHub secret, no `GRCLI_TOKEN`, no +`secrets.*` reference, no PAT.** Do not create one. Trusted publishing +(ADR-0032) means the workflow's GitHub OIDC token is the credential — +`grcli publish` mints it at runtime from the Actions OIDC endpoint +that `permissions: id-token: write` enables. The hub validates the +token's `iss` (GitHub) and `sub` (your repo/ref) against its +trusted-publisher bindings. + +**One-time setup, done on the hub — not in your repo:** an org admin +adds your repo (`owner/repo`, optionally pinned to a ref) on the hub +as a Trusted CI publisher for the namespace your bundles publish +under — which is derived from the bundle's own metadata, **not** chosen +by the workflow (see [Where a publish lands](#where-a-publish-lands-the---repository-default); +check it before registering the binding). Until that binding exists the +hub returns 403; *adding a GitHub secret will not fix it.* + +```yaml +permissions: + contents: read + id-token: write # OIDC token: hub auth + keyless signing (in-process) + # this is the ONLY auth grcli needs in CI + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + # v2: https://github.com/oras-project/setup-oras/releases/tag/v2.0.0 + - uses: oras-project/setup-oras@38de303aac69abb66f3e6255b7198bff35f323e3 + - run: | + oras pull ghcr.io/gemaraproj/grcli:latest --platform linux/amd64 + sudo install grcli /usr/local/bin/grcli + # No cosign step: grcli signs keyless in-process via sigstore-go + # (ADR-0049), using the same OIDC identity that authorizes the push. + - run: grcli publish -f controls.yaml --license Apache-2.0 + # --license is REQUIRED (ADR-0037) — set it to your catalog's real + # terms; no `env:` block, no `with: token:`, no secrets — the + # id-token: write above is what makes this work +``` + +The Fulcio certificate records the workflow URL as the signer identity +(`https://github.com///.github/workflows/publish.yml@`). +The hub verifies that signature at ingest and records the (ref-stripped) +identity, so a consumer can run `grcli verify --repository … --version …` +with **no trust flags** and grcli will verify against the recorded identity +(printing it, and that it came from the hub, first — ADR-0045). For an +independent check that does not trust the hub as the identity source, a +consumer supplies `--certificate-identity` (the workflow URL above) with the +issuer `https://token.actions.githubusercontent.com` themselves. + +## License + +Licensed under the [Apache License, Version 2.0](LICENSE). diff --git a/cmd/cat.go b/cmd/cat.go new file mode 100644 index 0000000..5d54d12 --- /dev/null +++ b/cmd/cat.go @@ -0,0 +1,153 @@ +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "fmt" + "io" + "strings" + + "github.com/gemaraproj/go-gemara/bundle" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +func newCatCmd(v *viper.Viper) *cobra.Command { + cmd := &cobra.Command{ + Use: "cat", + Short: "Print a Gemara artifact's contents to stdout", + Long: `Streams a Gemara artifact's contents to stdout without writing any files. +This is the read-only companion to 'grcli unpack' (which writes a directory): +cat is for piping into yq/jq or eyeballing an artifact. + +cat emits Gemara content ONLY — the artifact file(s), which carry the Gemara +document including its metadata block. It does NOT emit bundle information: +the bundle.json manifest and any SLSA-shaped provenance are not reachable +through cat. Use 'grcli unpack' if you want the manifest. + +The source can be a local OCI image layout (--source) or a remote registry +discovered from the hub (--url plus --repository). Exactly one must be set, +plus --version. + +A single-file bundle prints that file verbatim. A bundle with several files +prints them as a YAML multi-document stream (--- separated); use --file +to print just one. Caching behaves exactly as for 'grcli unpack' (ADR-0042): +a remote fetch is served from the on-disk cache when warm; --no-cache forces a +fresh pull. Cache diagnostics go to stderr so stdout stays pipe-clean. + +Examples: + # Print the artifact from a remote registry + grcli cat --url https://hub.grc.store \ + --repository myorg/my-controls --version 1.0.0 + + # Pipe into yq + grcli cat --url https://hub.grc.store \ + --repository myorg/my-controls --version 1.0.0 | yq '.title' + + # From a local 'publish --dry-run' output, one file out of several + grcli cat --source ./grcli-out --version 1.0.0 --file controls.yaml`, + RunE: func(cmd *cobra.Command, _ []string) error { + return runCat(cmd, v) + }, + } + + flags := cmd.Flags() + flags.String(flagSource, "", "OCI image layout directory (mutually exclusive with --url)") + flags.String(flagURL, defaultURL, "grc.store base URL (discovers the registry)") + flags.String(flagRepository, "", "repository path within the registry (requires --url)") + flags.String(flagVersion, "", "artifact version to print — the metadata.version of the published bundle (required)") + flags.String(flagFile, "", "print only the named file (for bundles carrying more than one)") + flags.Bool(flagNoCache, false, "bypass the local artifact cache for this run; set cache-enabled: false in config to disable it durably") + + // Bind at RunE time, not here — see comment in newPublishCmd. + return cmd +} + +func runCat(cmd *cobra.Command, v *viper.Viper) error { + if err := v.BindPFlags(cmd.Flags()); err != nil { + return fmt.Errorf("binding flags: %w", err) + } + suppressDefaultURLIfExplicit(cmd, v, flagSource) + ctx := cmd.Context() + + // Diagnostics to stderr; artifact content to stdout — keep stdout pipeable. + b, _, err := resolveBundle(ctx, v, cmd.ErrOrStderr()) + if err != nil { + return err + } + // Reference resolution is unpack's job (ADR-0042), and the v2 cache never + // stores Imports — but a --source layout can carry them. Never drop content + // silently: cat prints Files only, so say what was omitted (on stderr). + if len(b.Imports) > 0 { + noteCatOmittedImports(cmd.ErrOrStderr(), len(b.Imports)) + } + // Read --file from the command's OWN flags, not viper: the viper key "file" + // is publish's input-file list (bound from config/env as GRCLI_FILE / + // `file:` in .grcli.yaml), and reading it here would let publish settings + // select a bundle member the user never asked for. + fileName, err := cmd.Flags().GetString(flagFile) + if err != nil { + return err + } + return catBundle(b, fileName, cmd.OutOrStdout()) +} + +// noteCatOmittedImports warns (on the diagnostics stream, never stdout) that a +// bundle's imports are not part of cat's output. +func noteCatOmittedImports(w io.Writer, n int) { + fmt.Fprintf(w, "! bundle carries %d import(s) not included in cat output — use 'grcli unpack' to materialize them\n", n) +} + +// catBundle writes a bundle's Gemara content to out: the selected file when +// --file is given, else the sole file verbatim, else a --- separated YAML +// multi-document stream. A single file (or a --file selection) is written +// byte-for-byte; only the multi-document stream inserts separators (and a +// newline before one when a preceding doc lacks its own). It never writes +// bundle.json. +func catBundle(b *bundle.Bundle, fileName string, out io.Writer) error { + if fileName != "" { + for i := range b.Files { + if b.Files[i].Name == fileName { + _, err := out.Write(b.Files[i].Data) + return err + } + } + return fmt.Errorf("no file named %q in bundle (have: %s)", fileName, strings.Join(fileNames(b.Files), ", ")) + } + + switch len(b.Files) { + case 0: + return fmt.Errorf("bundle has no artifact files") + case 1: + _, err := out.Write(b.Files[0].Data) + return err + default: + for i := range b.Files { + if i > 0 { + if _, err := io.WriteString(out, "---\n"); err != nil { + return err + } + } + data := b.Files[i].Data + if _, err := out.Write(data); err != nil { + return err + } + // The next separator must start on its own line. + last := i == len(b.Files)-1 + if !last && (len(data) == 0 || data[len(data)-1] != '\n') { + if _, err := io.WriteString(out, "\n"); err != nil { + return err + } + } + } + return nil + } +} + +func fileNames(files []bundle.File) []string { + names := make([]string, len(files)) + for i, f := range files { + names[i] = f.Name + } + return names +} diff --git a/cmd/cat_test.go b/cmd/cat_test.go new file mode 100644 index 0000000..f2e3926 --- /dev/null +++ b/cmd/cat_test.go @@ -0,0 +1,239 @@ +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "bytes" + "context" + "io" + "os" + "path/filepath" + "testing" + + "github.com/gemaraproj/go-gemara/bundle" + "github.com/stretchr/testify/require" + "oras.land/oras-go/v2/content/oci" +) + +// TestCat_Source_SingleFile is the end-to-end happy path: publish a one-file +// bundle to a local layout, then `cat --source` it and confirm stdout is the +// artifact content byte-for-byte, with no bundle.json leaking in. +func TestCat_Source_SingleFile(t *testing.T) { + workdir := isolatedWorkdir(t) + input := writeTempFile(t, workdir, "policy.yaml", policyYAML) + layout := filepath.Join(workdir, "layout") + runRoot(t, "publish", "--dry-run", "-f", input, "--output", layout, "--license", "Apache-2.0") + + out := runRoot(t, "cat", "--source", layout, "--version", "1.0.0") + require.Equal(t, policyYAML, out, "cat must emit the artifact content verbatim") + require.NotContains(t, out, "bundle-version", "cat must not emit bundle.json content") + require.NotContains(t, out, "provenance", "cat must not emit provenance") +} + +// TestCat_CacheHit_Offline proves cat is served from the cache with no network, +// exactly like unpack — same bogus --url, pre-seeded entry. +func TestCat_CacheHit_Offline(t *testing.T) { + c := tempCache(t) + isolatedWorkdir(t) + const url = "https://hub.invalid.test" + seed := &bundle.Bundle{Files: []bundle.File{{Name: "controls.yaml", Data: []byte("id: from-cache\n")}}} + putBundle(c, hostOf(url), "acme", "controls", "1.0.0", seed, io.Discard) + + out := runRoot(t, "cat", "--url", url, "--repository", "acme/controls", "--version", "1.0.0") + require.Equal(t, "id: from-cache\n", out) +} + +// TestCat_CleanHit_ContentOnStdoutStderrQuiet asserts, with SEPARATE stdout and +// stderr buffers, that a clean cache hit puts artifact content on stdout and +// nothing on stderr — the pipe-clean guarantee cat exists for. +func TestCat_CleanHit_ContentOnStdoutStderrQuiet(t *testing.T) { + c := tempCache(t) + isolatedWorkdir(t) + const url = "https://hub.invalid.test" + seed := &bundle.Bundle{Files: []bundle.File{{Name: "controls.yaml", Data: []byte("id: from-cache\n")}}} + putBundle(c, hostOf(url), "acme", "controls", "1.0.0", seed, io.Discard) + + stdout, stderr, err := executeRootSplit("cat", "--url", url, "--repository", "acme/controls", "--version", "1.0.0") + require.NoError(t, err) + require.Equal(t, "id: from-cache\n", stdout, "content must be on stdout") + require.Empty(t, stderr, "a clean hit must not write to stderr") +} + +// TestCat_Diagnostics_GoToStderrNotStdout is the regression guard for the +// stdout/stderr split. A corrupted cache blob makes resolveBundle emit a +// re-fetch diagnostic (then fail offline). The diagnostic MUST appear on stderr +// and MUST NOT leak onto stdout. If cat's wiring ever routes diagnostics to +// stdout, this test fails. +func TestCat_Diagnostics_GoToStderrNotStdout(t *testing.T) { + c := tempCache(t) + isolatedWorkdir(t) + const url = "https://hub.invalid.test" + seed := &bundle.Bundle{Files: []bundle.File{{Name: "controls.yaml", Data: []byte("id: from-cache\n")}}} + putBundle(c, hostOf(url), "acme", "controls", "1.0.0", seed, io.Discard) + corruptOneCacheBlob(t, c.Root()) + + stdout, stderr, err := executeRootSplit("cat", "--url", url, "--repository", "acme/controls", "--version", "1.0.0") + require.Error(t, err, "corrupt entry forces a network re-fetch that must fail offline") + require.Contains(t, stderr, "cache", "the corruption/re-fetch diagnostic must be on stderr") + require.Empty(t, stdout, "no diagnostic (or content) may leak onto stdout") +} + +// corruptOneCacheBlob finds a stored file blob under the cache root and +// overwrites it so its digest no longer matches meta.json, forcing a cache +// corruption error on the next Get. +func corruptOneCacheBlob(t *testing.T, root string) { + t.Helper() + found := false + err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if found || info.IsDir() { + return nil + } + if filepath.Base(filepath.Dir(path)) == "files" { + if werr := os.WriteFile(path, []byte("tampered-bytes"), 0o644); werr != nil { + return werr + } + found = true + } + return nil + }) + require.NoError(t, err) + require.True(t, found, "expected a cached file blob to corrupt") +} + +// TestCat_PublishFileKeyDoesNotBleed guards the viper key collision: 'file' in +// project config (or GRCLI_FILE) is publish's input-file list and must NOT act +// as cat's --file member selector. +// TestCat_PublishFileKeyIgnored: a project ./.grcli.yaml is no longer read at +// all (ADR-0044), so a publish-oriented `file:` key in it cannot bleed into +// cat's --file selection. cat streams the full bundle on stdout; the ignored +// project file earns a migration warning on stderr (kept off the stdout pipe). +func TestCat_PublishFileKeyIgnored(t *testing.T) { + c := tempCache(t) + isolatedWorkdir(t) + require.NoError(t, os.WriteFile(projectConfigFile, []byte("file: policy.yaml\n"), 0o644)) + const url = "https://hub.invalid.test" + seed := &bundle.Bundle{Files: []bundle.File{{Name: "controls.yaml", Data: []byte("id: from-cache\n")}}} + putBundle(c, hostOf(url), "acme", "controls", "1.0.0", seed, io.Discard) + + stdout, stderr, err := executeRootSplit("cat", "--url", url, "--repository", "acme/controls", "--version", "1.0.0") + require.NoError(t, err) + require.Equal(t, "id: from-cache\n", stdout, "a project .grcli.yaml file: key must not select a bundle member in cat") + require.Contains(t, stderr, "ignoring config", "the ignored project config should earn a migration warning") +} + +func TestNoteCatOmittedImports(t *testing.T) { + var buf bytes.Buffer + noteCatOmittedImports(&buf, 2) + require.Contains(t, buf.String(), "2 import(s) not included") + require.Contains(t, buf.String(), "grcli unpack") +} + +// TestCat_ImportsNoteEndToEnd drives the REAL command path for the omitted- +// imports diagnostic: a --source layout whose bundle carries an Imports layer +// (packed with go-gemara directly — grcli publish never produces one) must cat +// only the artifact files on stdout and put the omission note on stderr. Guards +// the runCat call wiring, which the unit test above cannot. +func TestCat_ImportsNoteEndToEnd(t *testing.T) { + workdir := isolatedWorkdir(t) + layout := filepath.Join(workdir, "layout") + store, err := oci.New(layout) + require.NoError(t, err) + b := &bundle.Bundle{ + Manifest: bundle.Manifest{BundleVersion: "1.0", GemaraVersion: "0.5.0"}, + Files: []bundle.File{{Name: "controls.yaml", Type: "ControlCatalog", Data: []byte("id: acme\n")}}, + Imports: []bundle.File{{Name: "dep.yaml", Type: "ControlCatalog", Data: []byte("id: dep\n")}}, + } + desc, err := bundle.Pack(context.Background(), store, b) + require.NoError(t, err) + require.NoError(t, store.Tag(context.Background(), desc, "1.0.0")) + + stdout, stderr, err := executeRootSplit("cat", "--source", layout, "--version", "1.0.0") + require.NoError(t, err) + require.Equal(t, "id: acme\n", stdout, "stdout must carry the artifact files only") + require.Contains(t, stderr, "1 import(s) not included", "the omission note must land on stderr") + require.NotContains(t, stdout, "id: dep", "import content must not leak onto stdout") +} + +func TestCat_MissingVersion_Errors(t *testing.T) { + workdir := isolatedWorkdir(t) + _, err := runRootExpectErr(t, "cat", "--source", filepath.Join(workdir, "x")) + require.Error(t, err) + require.Contains(t, err.Error(), "--version is required") +} + +func TestCatBundle_SingleFileVerbatim(t *testing.T) { + var buf bytes.Buffer + b := &bundle.Bundle{Files: []bundle.File{{Name: "a.yaml", Data: []byte("id: acme")}}} // no trailing newline + require.NoError(t, catBundle(b, "", &buf)) + require.Equal(t, "id: acme", buf.String(), "single file must be byte-exact, no added newline") +} + +func TestCatBundle_MultiFileStream(t *testing.T) { + var buf bytes.Buffer + b := &bundle.Bundle{Files: []bundle.File{ + {Name: "a.yaml", Data: []byte("id: a\n")}, + {Name: "b.yaml", Data: []byte("id: b")}, // no trailing newline + }} + require.NoError(t, catBundle(b, "", &buf)) + // a ends with \n already, so no extra newline is inserted before ---. + require.Equal(t, "id: a\n---\nid: b", buf.String()) +} + +func TestCatBundle_MultiFileStream_InsertsNewlineBeforeSeparator(t *testing.T) { + var buf bytes.Buffer + b := &bundle.Bundle{Files: []bundle.File{ + {Name: "a.yaml", Data: []byte("id: a")}, // no trailing newline -> one must be added before --- + {Name: "b.yaml", Data: []byte("id: b\n")}, + }} + require.NoError(t, catBundle(b, "", &buf)) + require.Equal(t, "id: a\n---\nid: b\n", buf.String()) +} + +func TestCatBundle_ThreeFileStream(t *testing.T) { + var buf bytes.Buffer + b := &bundle.Bundle{Files: []bundle.File{ + {Name: "a.yaml", Data: []byte("id: a\n")}, + {Name: "b.yaml", Data: []byte("id: b\n")}, + {Name: "c.yaml", Data: []byte("id: c\n")}, + }} + require.NoError(t, catBundle(b, "", &buf)) + require.Equal(t, "id: a\n---\nid: b\n---\nid: c\n", buf.String()) +} + +func TestCatBundle_EmptyInteriorFile(t *testing.T) { + var buf bytes.Buffer + b := &bundle.Bundle{Files: []bundle.File{ + {Name: "a.yaml", Data: []byte("id: a\n")}, + {Name: "empty.yaml", Data: []byte{}}, // empty middle doc must not corrupt separators + {Name: "c.yaml", Data: []byte("id: c\n")}, + }} + require.NoError(t, catBundle(b, "", &buf)) + // a, then ---, then the empty doc (nothing) + a newline so the next --- is on its own line, then ---, then c. + require.Equal(t, "id: a\n---\n\n---\nid: c\n", buf.String()) +} + +func TestCatBundle_FileSelection(t *testing.T) { + var buf bytes.Buffer + b := &bundle.Bundle{Files: []bundle.File{ + {Name: "a.yaml", Data: []byte("id: a\n")}, + {Name: "b.yaml", Data: []byte("id: b\n")}, + }} + require.NoError(t, catBundle(b, "b.yaml", &buf)) + require.Equal(t, "id: b\n", buf.String(), "--file selects exactly one, verbatim") +} + +func TestCatBundle_FileSelectionUnknown(t *testing.T) { + b := &bundle.Bundle{Files: []bundle.File{{Name: "a.yaml", Data: []byte("x")}}} + err := catBundle(b, "nope.yaml", io.Discard) + require.Error(t, err) + require.Contains(t, err.Error(), "no file named") + require.Contains(t, err.Error(), "a.yaml", "error should list the available files") +} + +func TestCatBundle_EmptyBundleErrors(t *testing.T) { + err := catBundle(&bundle.Bundle{}, "", io.Discard) + require.Error(t, err) +} diff --git a/cmd/config_test.go b/cmd/config_test.go new file mode 100644 index 0000000..7623a79 --- /dev/null +++ b/cmd/config_test.go @@ -0,0 +1,173 @@ +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "bytes" + "io" + "os" + "path/filepath" + "testing" + + "github.com/gemaraproj/go-gemara/bundle" + "github.com/spf13/viper" + "github.com/stretchr/testify/require" +) + +// writeGlobalConfig writes the user-global config.yaml under the (isolated) +// XDG_CONFIG_HOME. isolatedWorkdir must have been called first. +func writeGlobalConfig(t *testing.T, content string) { + t.Helper() + dir := filepath.Join(os.Getenv("XDG_CONFIG_HOME"), "grcli") + require.NoError(t, os.MkdirAll(dir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "config.yaml"), []byte(content), 0o644)) +} + +func writeProjectConfig(t *testing.T, content string) { + t.Helper() + require.NoError(t, os.WriteFile(projectConfigFile, []byte(content), 0o644)) +} + +func loadedViper(t *testing.T) *viper.Viper { + t.Helper() + v := viper.New() + require.NoError(t, loadConfig(v, "", io.Discard)) + return v +} + +func TestConfig_DefaultCacheEnabled(t *testing.T) { + isolatedWorkdir(t) + require.True(t, cachingEnabled(loadedViper(t)), "caching is on by default with no config") +} + +func TestConfig_GlobalDisablesCache(t *testing.T) { + isolatedWorkdir(t) + writeGlobalConfig(t, "cache-enabled: false\n") + require.False(t, cachingEnabled(loadedViper(t)), "user-global cache-enabled:false must disable caching") +} + +// TestConfig_ProjectFileIgnored: a repo-local ./.grcli.yaml is no longer read +// (ADR-0044), so it cannot override the user-global file — the global setting +// stands even when a project file says otherwise. +func TestConfig_ProjectFileIgnored(t *testing.T) { + isolatedWorkdir(t) + writeGlobalConfig(t, "cache-enabled: false\n") + writeProjectConfig(t, "cache-enabled: true\n") + require.False(t, cachingEnabled(loadedViper(t)), + "a project .grcli.yaml must be ignored — user-global cache-enabled:false stands") +} + +func TestConfig_EnvOverridesGlobal(t *testing.T) { + isolatedWorkdir(t) + writeGlobalConfig(t, "cache-enabled: false\n") + t.Setenv("GRCLI_CACHE_ENABLED", "true") + require.True(t, cachingEnabled(loadedViper(t)), "GRCLI_* env must override the config file") +} + +// TestConfig_GlobalFromHomeFallbackWhenXDGUnset covers the branch most users +// actually hit: XDG_CONFIG_HOME unset, so the user-global file is read from +// ~/.config/grcli/config.yaml. Every other test goes through isolatedWorkdir, +// which always sets XDG_CONFIG_HOME and hides this path. +func TestConfig_GlobalFromHomeFallbackWhenXDGUnset(t *testing.T) { + home := t.TempDir() + t.Chdir(t.TempDir()) // empty cwd → no project file + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", "") // force the ~/.config fallback + t.Setenv("GRCLI_CACHE_ENABLED", "") + dir := filepath.Join(home, ".config", "grcli") + require.NoError(t, os.MkdirAll(dir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "config.yaml"), []byte("cache-enabled: false\n"), 0o644)) + + require.False(t, cachingEnabled(loadedViper(t)), + "must read ~/.config/grcli/config.yaml when XDG_CONFIG_HOME is unset") +} + +func TestConfig_ExplicitFileBypassesSearch(t *testing.T) { + isolatedWorkdir(t) + // The user-global file says disable; an explicit --config file says enable. + // The explicit file must win and the global file must not be read. + writeGlobalConfig(t, "cache-enabled: false\n") + explicit := filepath.Join(t.TempDir(), "custom.yaml") + require.NoError(t, os.WriteFile(explicit, []byte("cache-enabled: true\n"), 0o644)) + v := viper.New() + require.NoError(t, loadConfig(v, explicit, io.Discard)) + require.True(t, cachingEnabled(v)) +} + +// TestWarnIgnoredConfig: warn whenever a config file sits at a location grcli +// no longer reads — the per-project ./.grcli.yaml (ADR-0044) or the pre-0043 +// home/XDG dotfiles — and stay silent when there are none. Unlike the old +// legacy check, a present project file warns even when a user-global file +// exists, because the project file no longer merges over it. +func TestWarnIgnoredConfig(t *testing.T) { + warned := func(t *testing.T) string { + t.Helper() + var buf bytes.Buffer + warnIgnoredConfig(userGlobalConfigPath(), &buf) + return buf.String() + } + + t.Run("project ./.grcli.yaml present: warns even with a global file", func(t *testing.T) { + isolatedWorkdir(t) + writeGlobalConfig(t, "cache-enabled: true\n") + writeProjectConfig(t, "url: x\n") + out := warned(t) + require.Contains(t, out, "ignoring config") + require.Contains(t, out, ".grcli.yaml") + }) + + t.Run("legacy XDG dotfile present: warns", func(t *testing.T) { + isolatedWorkdir(t) + dir := filepath.Join(os.Getenv("XDG_CONFIG_HOME"), "grcli") + require.NoError(t, os.MkdirAll(dir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, ".grcli.yaml"), []byte("url: x\n"), 0o644)) + require.Contains(t, warned(t), "ignoring config") + }) + + t.Run("legacy home dotfile present: warns", func(t *testing.T) { + home := isolatedWorkdir(t) + t.Chdir(t.TempDir()) // cwd must differ from HOME, or ~/.grcli.yaml doubles as the project file + require.NoError(t, os.WriteFile(filepath.Join(home, ".grcli.yaml"), []byte("url: x\n"), 0o644)) + require.Contains(t, warned(t), "ignoring config") + }) + + t.Run("no ignored files: silent", func(t *testing.T) { + isolatedWorkdir(t) + require.Empty(t, warned(t)) + }) +} + +// TestConfig_EndToEnd_IgnoredWarningReachesStderr drives the REAL command path +// (root PersistentPreRunE → loadConfig → warnIgnoredConfig → the command's +// stderr): with a per-project ./.grcli.yaml seeded, any command run must +// surface the migration warning. Guards the call wiring, which the direct- +// helper tests above cannot (deleting the loadConfig call site would not fail +// them). +func TestConfig_EndToEnd_IgnoredWarningReachesStderr(t *testing.T) { + isolatedWorkdir(t) + writeProjectConfig(t, "url: x\n") + + // `cat` without --version fails in RunE — AFTER PersistentPreRunE has run + // loadConfig — so the warning must already be on stderr. + stdout, stderr, err := executeRootSplit("cat", "--source", "irrelevant") + require.Error(t, err) + require.Contains(t, stderr, "ignoring config", "loadConfig must emit the migration warning on the command's stderr") + require.Empty(t, stdout) +} + +// TestConfig_EndToEnd_CacheDisabledSkipsWarmEntry proves the config toggle wires +// through the command: with a warm cache entry but cache-enabled:false in the +// user-global config, unpack must bypass the cache and attempt (and fail +// offline) a network fetch. +func TestConfig_EndToEnd_CacheDisabledSkipsWarmEntry(t *testing.T) { + c := tempCache(t) // sets GRCLI_CACHE + isolatedWorkdir(t) // sets XDG_CONFIG_HOME (after GRCLI_CACHE; both live) + writeGlobalConfig(t, "cache-enabled: false\n") + const url = "https://hub.invalid.test" + seed := &bundle.Bundle{Files: []bundle.File{{Name: "controls.yaml", Data: []byte("id: from-cache\n")}}} + putBundle(c, hostOf(url), "acme", "controls", "1.0.0", seed, io.Discard) + + _, err := runRootExpectErr(t, "unpack", "--url", url, "--repository", "acme/controls", + "--version", "1.0.0", "--output", filepath.Join(t.TempDir(), "out")) + require.Error(t, err, "cache-enabled:false must bypass the warm entry and fail on the offline fetch") +} diff --git a/cmd/fetch.go b/cmd/fetch.go new file mode 100644 index 0000000..2c966e3 --- /dev/null +++ b/cmd/fetch.go @@ -0,0 +1,185 @@ +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + + "github.com/gemaraproj/go-gemara/bundle" + "github.com/spf13/viper" + + "github.com/revanite-io/grcli/internal/cache" + "github.com/revanite-io/grcli/internal/hub" + "github.com/revanite-io/grcli/internal/registry" +) + +// resolveBundle fetches the primary artifact bundle from either a local OCI +// layout (--source) or the remote registry discovered from the hub (--url + +// --repository + --version). It is the shared fetch stage for `unpack` and +// `cat` (ADR-0042 decision 4): both run this identical resolve-and-cache +// pipeline and then diverge only in how they render the returned bundle. +// +// Remote fetches consult the on-disk cache (unless caching is disabled) keyed +// by the same (host, ns, id, version) coordinate reference resolution uses, so +// a primary and a reference to the same artifact share one entry. The cache is +// checked BEFORE hub discovery, so a cache hit needs no network at all +// (ADR-0042: served offline). --source reads are local bytes and never cached. +// +// diag receives human-readable cache diagnostics (never artifact content), so a +// caller that emits machine-readable content on stdout — `cat` — must pass a +// separate stream (stderr). `unpack`, whose stdout is already a progress log, +// passes that log writer. +// +// The caller is responsible for having bound flags and suppressed the --url +// default (suppressDefaultURLIfExplicit) before calling. +func resolveBundle(ctx context.Context, v *viper.Viper, diag io.Writer) (b *bundle.Bundle, label string, err error) { + source := v.GetString(flagSource) + url := v.GetString(flagURL) + repository := v.GetString(flagRepository) + version := v.GetString(flagVersion) + + if version == "" { + return nil, "", errors.New("--version is required") + } + switch { + case source == "" && url == "": + return nil, "", errors.New("either --source or --url is required") + case source != "" && url != "": + return nil, "", errors.New("--source is mutually exclusive with --url") + } + + if source != "" { + b, err = registry.UnpackLocal(ctx, source, version) + return b, source, err + } + + if repository == "" { + return nil, "", errors.New("--repository is required when --url is set") + } + + // Cache lookup FIRST — before any network. The coordinate mirrors reference + // resolution: host from the hub --url, ns/id from the repository path. + host := hostOf(url) + ns, id := splitRepository(repository) + var c *cache.Cache + if cachingEnabled(v) && host != "" && ns != "" && id != "" { + if cc, cerr := cache.Open(); cerr != nil { + fmt.Fprintf(diag, " ! cache unavailable, fetching without it: %v\n", cerr) + } else { + c = cc + } + } + if c != nil { + if e, found, gerr := c.Get(host, ns, id, version); gerr != nil { + fmt.Fprintf(diag, " ! cache: %v (re-fetching)\n", gerr) + } else if found { + cached, berr := bundleFromEntry(e) + if berr != nil { + fmt.Fprintf(diag, " ! cache: %v (re-fetching)\n", berr) + } else { + // Served from cache; no discovery needed, so label from the + // requested coordinate rather than the (unqueried) registry host. + return cached, host + "/" + repository, nil + } + } + } + + // Cache miss: discover the registry, mint a token (ADR-0031 requires one + // even for public reads), and pull. + d, derr := hub.Discover(ctx, url) + if derr != nil { + return nil, "", fmt.Errorf("hub discovery: %w", derr) + } + // Keep the advertised scheme: registryHost is the oras dial target and + // newRemoteRepo derives PlainHTTP from it, so stripping http:// here would + // force HTTPS against a plain-HTTP zot. + registryHost := d.RegistryURL + // Label with the requested hub coordinate — the SAME label a cache hit + // prints — so repeated runs of one command read identically whether served + // from cache or the registry. Fall back to the registry host only when the + // hub host can't be parsed. + label = host + "/" + repository + if host == "" { + label = registry.NormalizeRegistryHost(registryHost) + "/" + repository + } + + if _, terr := ensureRegistryToken(ctx, url, "", repository, []string{"pull"}); terr != nil { + return nil, "", fmt.Errorf("fetching registry pull token: %w", terr) + } + b, err = registry.UnpackRemote(ctx, registryHost, repository, version) + if err != nil { + return nil, "", err + } + if c != nil { + putBundle(c, host, ns, id, version, b, diag) + } + return b, label, nil +} + +// cachingEnabled reports whether the artifact cache should be used: the durable +// cache-enabled preference (ADR-0043, default true) AND the absence of the +// per-invocation --no-cache flag. Either one off disables caching. +func cachingEnabled(v *viper.Viper) bool { + return v.GetBool(flagCacheEnabled) && !v.GetBool(flagNoCache) +} + +// bundleFromEntry reconstructs an in-memory bundle from a cache entry. The +// manifest bytes are the JSON writeBundle emits as bundle.json, so unmarshaling +// then re-marshaling reproduces byte-identical output. File.Type and the +// dormant Imports slot are not cached (neither renderer uses Type, and a bundle +// carrying Imports is never cached — see putBundle). +func bundleFromEntry(e *cache.Entry) (*bundle.Bundle, error) { + b := &bundle.Bundle{Etag: e.ManifestDigest} + for _, f := range e.Files { + b.Files = append(b.Files, bundle.File{Name: f.Name, Data: f.Data}) + } + if len(e.Manifest) > 0 { + if err := json.Unmarshal(e.Manifest, &b.Manifest); err != nil { + return nil, fmt.Errorf("decoding cached manifest: %w", err) + } + } + return b, nil +} + +// entryFromBundle builds a cache entry from a pulled bundle plus optional hub +// metadata (license, and the reference source URL). The manifest is stored as +// the exact bytes writeBundle emits as bundle.json, so a round trip reproduces +// byte-identical output. It does not persist anything. +func entryFromBundle(b *bundle.Bundle, license, sourceURL string) (cache.Entry, error) { + e := cache.Entry{ManifestDigest: b.Etag, License: license, SourceURL: sourceURL} + for _, f := range b.Files { + e.Files = append(e.Files, cache.File{Name: f.Name, Data: f.Data}) + } + if !b.Manifest.Empty() { + mb, err := json.MarshalIndent(b.Manifest, "", " ") + if err != nil { + return cache.Entry{}, fmt.Errorf("encoding manifest: %w", err) + } + e.Manifest = mb + } + return e, nil +} + +// putBundle writes a freshly-pulled primary bundle to the cache. A cache write +// failure is non-fatal (the pull already succeeded). A bundle carrying the +// dormant Imports slot is not cached: the v2 entry format stores Files + +// manifest only (ADR-0042), so caching such a bundle would silently drop the +// imports on the next hit — better to leave it uncached and re-pull. +func putBundle(c *cache.Cache, host, ns, id, version string, b *bundle.Bundle, diag io.Writer) { + if len(b.Imports) > 0 { + fmt.Fprintf(diag, " ! not caching %s/%s@%s: bundle carries imports (not stored in cache)\n", ns, id, version) + return + } + e, err := entryFromBundle(b, "", "") + if err != nil { + fmt.Fprintf(diag, " ! cache write skipped (%v)\n", err) + return + } + if err := c.Put(host, ns, id, version, e); err != nil { + fmt.Fprintf(diag, " ! cache write failed (continuing): %v\n", err) + } +} diff --git a/cmd/fetch_test.go b/cmd/fetch_test.go new file mode 100644 index 0000000..bb5bbcd --- /dev/null +++ b/cmd/fetch_test.go @@ -0,0 +1,205 @@ +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "bytes" + "encoding/json" + "io" + "os" + "path/filepath" + "testing" + + "github.com/gemaraproj/go-gemara/bundle" + "github.com/spf13/viper" + "github.com/stretchr/testify/require" + + "github.com/revanite-io/grcli/internal/cache" +) + +func tempCache(t *testing.T) *cache.Cache { + t.Helper() + t.Setenv("GRCLI_CACHE", t.TempDir()) + c, err := cache.Open() + if err != nil { + t.Fatalf("cache.Open: %v", err) + } + return c +} + +// TestPutBundleRoundtrip is the core Phase 2 guarantee: a bundle cached on a +// miss and reconstructed on a hit yields byte-identical files and a +// byte-identical bundle.json — so unpack/cat output can't drift between a +// fresh pull and a cache hit. +func TestPutBundleRoundtrip(t *testing.T) { + c := tempCache(t) + orig := &bundle.Bundle{ + Files: []bundle.File{ + {Name: "controls.yaml", Type: "ControlCatalog", Data: []byte("id: acme\n")}, + {Name: "mappings.yaml", Type: "Mapping", Data: []byte("maps: []\n")}, + }, + Manifest: bundle.Manifest{ + BundleVersion: "1", + GemaraVersion: "0.5.0", + Revision: "abc", + // Exercise the nested shapes real publishes populate — a + // map[string]any (like the SLSA provenance predicate) and an + // Artifacts slice — so the JSON decode→re-encode round trip is + // tested against the fields most likely to drift, not just strings. + Metadata: map[string]any{ + "provenance": map[string]any{ + "builder": "grcli", + "buildType": "https://example/slsa", + "count": 3, + }, + }, + Artifacts: []bundle.Artifact{ + {Name: "controls.yaml", Type: "ControlCatalog", ID: "acme", Role: "primary"}, + }, + }, + Etag: "sha256:deadbeef", + } + var out bytes.Buffer + putBundle(c, "hub.grc.store", "acme", "x", "1.0.0", orig, &out) + if out.Len() != 0 { + t.Fatalf("unexpected putBundle output: %q", out.String()) + } + + e, found, err := c.Get("hub.grc.store", "acme", "x", "1.0.0") + if err != nil || !found { + t.Fatalf("Get: found=%v err=%v", found, err) + } + got, err := bundleFromEntry(e) + if err != nil { + t.Fatalf("bundleFromEntry: %v", err) + } + + if len(got.Files) != len(orig.Files) { + t.Fatalf("got %d files, want %d", len(got.Files), len(orig.Files)) + } + for i := range orig.Files { + if got.Files[i].Name != orig.Files[i].Name || !bytes.Equal(got.Files[i].Data, orig.Files[i].Data) { + t.Errorf("file %d = {%q,%q}, want {%q,%q}", i, + got.Files[i].Name, got.Files[i].Data, orig.Files[i].Name, orig.Files[i].Data) + } + } + if got.Etag != orig.Etag { + t.Errorf("Etag = %q, want %q", got.Etag, orig.Etag) + } + // bundle.json bytes writeBundle would emit must match exactly. + wantMF, _ := json.MarshalIndent(orig.Manifest, "", " ") + gotMF, _ := json.MarshalIndent(got.Manifest, "", " ") + if !bytes.Equal(wantMF, gotMF) { + t.Errorf("manifest bytes drifted:\n got: %s\nwant: %s", gotMF, wantMF) + } +} + +func TestPutBundleSkipsWhenImportsPresent(t *testing.T) { + c := tempCache(t) + b := &bundle.Bundle{ + Files: []bundle.File{{Name: "controls.yaml", Data: []byte("id: acme\n")}}, + Imports: []bundle.File{{Name: "dep.yaml", Data: []byte("id: dep\n")}}, + } + var out bytes.Buffer + putBundle(c, "hub.grc.store", "acme", "x", "1.0.0", b, &out) + + if _, found, _ := c.Get("hub.grc.store", "acme", "x", "1.0.0"); found { + t.Error("bundle with imports should not have been cached (would drop imports on hit)") + } + if !bytes.Contains(out.Bytes(), []byte("carries imports")) { + t.Errorf("expected an imports-not-cached notice, got %q", out.String()) + } +} + +// TestResolveBundle_CacheHitSkipsNetwork proves the core Phase 2 property: a +// primary-artifact cache hit is served with NO network. The --url points at a +// non-resolvable host with no server, so if resolveBundle attempted hub +// discovery or a registry pull the command would error — a passing run proves +// the cached bytes were served offline. +func TestResolveBundle_CacheHitSkipsNetwork(t *testing.T) { + c := tempCache(t) // sets GRCLI_CACHE for both this Put and the in-process command + workdir := isolatedWorkdir(t) + const url = "https://hub.invalid.test" // no server; must never be contacted + + // Seed the cache at exactly the coordinate resolveBundle will compute. + seed := &bundle.Bundle{ + Files: []bundle.File{{Name: "controls.yaml", Data: []byte("id: from-cache\n")}}, + Manifest: bundle.Manifest{BundleVersion: "1", GemaraVersion: "0.5.0"}, + Etag: "sha256:abc", + } + putBundle(c, hostOf(url), "acme", "controls", "1.0.0", seed, io.Discard) + + unpacked := filepath.Join(workdir, "unpacked") + // --no-verify: this test isolates the cache layer's offline property. Since + // ADR-0048 a default unpack verifies, which DOES contact the hub/registry — + // so "cache hit needs no network" now holds only when verification is off. + out := runRoot(t, "unpack", "--url", url, "--repository", "acme/controls", + "--version", "1.0.0", "--no-verify", "--output", unpacked) + // The label is the hub coordinate — the SAME label a registry miss prints — + // so repeated runs read identically whether served from cache or network. + require.Contains(t, out, "unpacked hub.invalid.test/acme/controls:1.0.0") + + got, err := os.ReadFile(filepath.Join(unpacked, "controls.yaml")) + require.NoError(t, err) + require.Equal(t, "id: from-cache\n", string(got), "content must come from the cache, not the network") + // The cached manifest is materialized too. + require.FileExists(t, filepath.Join(unpacked, "bundle.json")) +} + +// TestResolveBundle_NoCacheBypassesHit confirms --no-cache ignores a warm cache +// entry: with no server reachable, the fresh-pull path must fail (proving the +// cache was skipped rather than served). +func TestResolveBundle_NoCacheBypassesHit(t *testing.T) { + c := tempCache(t) + workdir := isolatedWorkdir(t) + const url = "https://hub.invalid.test" + seed := &bundle.Bundle{Files: []bundle.File{{Name: "controls.yaml", Data: []byte("id: from-cache\n")}}} + putBundle(c, hostOf(url), "acme", "controls", "1.0.0", seed, io.Discard) + + _, err := runRootExpectErr(t, "unpack", "--url", url, "--repository", "acme/controls", + "--version", "1.0.0", "--no-cache", "--output", filepath.Join(workdir, "unpacked")) + require.Error(t, err, "--no-cache must bypass the warm entry and attempt (and fail) a network fetch") +} + +func TestBundleFromEntryNoManifest(t *testing.T) { + e := &cache.Entry{Files: []cache.File{{Name: "body.json", Data: []byte(`{"a":1}`)}}} + b, err := bundleFromEntry(e) + if err != nil { + t.Fatalf("bundleFromEntry: %v", err) + } + if !b.Manifest.Empty() { + t.Errorf("manifest should be empty, got %+v", b.Manifest) + } + if len(b.Files) != 1 || string(b.Files[0].Data) != `{"a":1}` { + t.Errorf("files = %+v", b.Files) + } +} + +func TestBundleFromEntryRejectsBadManifest(t *testing.T) { + e := &cache.Entry{ + Files: []cache.File{{Name: "controls.yaml", Data: []byte("ok")}}, + Manifest: []byte("{not json"), + } + if _, err := bundleFromEntry(e); err == nil { + t.Error("expected error decoding a corrupt cached manifest") + } +} + +func TestCachingEnabled(t *testing.T) { + v := viper.New() + v.SetDefault(flagCacheEnabled, true) + v.SetDefault(flagNoCache, false) + if !cachingEnabled(v) { + t.Error("caching should be enabled by default") + } + v.Set(flagNoCache, true) + if cachingEnabled(v) { + t.Error("--no-cache should disable caching") + } + // cache-enabled:false disables caching even without --no-cache. + v.Set(flagNoCache, false) + v.Set(flagCacheEnabled, false) + if cachingEnabled(v) { + t.Error("cache-enabled:false should disable caching") + } +} diff --git a/cmd/integration_test.go b/cmd/integration_test.go new file mode 100644 index 0000000..effbc0f --- /dev/null +++ b/cmd/integration_test.go @@ -0,0 +1,457 @@ +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "bytes" + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +// TestPublishUnpackRoundtrip exercises the full publish → unpack cycle +// via the cobra commands. It writes input YAML(s) to a temp dir, packs +// them into a local OCI layout with `publish --dry-run`, then unpacks +// that layout with `unpack` and verifies the recovered files and the +// embedded bundle manifest. + +const policyYAML = `metadata: + id: roundtrip-policy + type: Policy + version: 1.0.0 + gemara-version: 0.20.0 + author: + id: test-team + type: Human +` + +const controlsPartA = `metadata: + id: roundtrip-controls + type: ControlCatalog + version: 2.0.0 + gemara-version: 0.20.0 + author: + id: test-team + type: Human +controls: + - id: AC-1 + title: Access Control 1 +` + +const controlsPartB = `metadata: + id: roundtrip-controls + type: ControlCatalog + version: 2.0.0 + gemara-version: 0.20.0 + author: + id: test-team + type: Human +controls: + - id: AC-2 + title: Access Control 2 +` + +func TestPublishUnpackRoundtrip_SinglePolicy(t *testing.T) { + workdir := isolatedWorkdir(t) + input := writeTempFile(t, workdir, "policy.yaml", policyYAML) + layout := filepath.Join(workdir, "layout") + unpacked := filepath.Join(workdir, "unpacked") + + publishOut := runRoot(t, "publish", "--dry-run", "-f", input, "--output", layout, "--license", "Apache-2.0") + require.Contains(t, publishOut, "dry-run: wrote bundle to oci:"+layout+":1.0.0") + require.Contains(t, publishOut, "artifact: Policy/roundtrip-policy") + + unpackOut := runRoot(t, "unpack", "--source", layout, "--version", "1.0.0", "--output", unpacked) + require.Contains(t, unpackOut, "unpacked "+layout+":1.0.0") + require.Contains(t, unpackOut, "policy.yaml") + require.Contains(t, unpackOut, "bundle.json") + + got, err := os.ReadFile(filepath.Join(unpacked, "policy.yaml")) + require.NoError(t, err) + require.Equal(t, policyYAML, string(got), "policy.yaml should round-trip byte-for-byte") + + manifest := readManifest(t, filepath.Join(unpacked, "bundle.json")) + require.Equal(t, "0.20.0", manifest["gemara-version"]) + artifacts, ok := manifest["artifacts"].([]any) + require.True(t, ok, "manifest has no artifacts array") + require.Len(t, artifacts, 1) + first, _ := artifacts[0].(map[string]any) + require.Equal(t, "Policy", first["type"]) + require.Equal(t, "roundtrip-policy", first["id"]) + require.Equal(t, "policy.yaml", first["name"]) + + metadata, ok := manifest["metadata"].(map[string]any) + require.True(t, ok, "manifest has no metadata field") + provenance, ok := metadata["provenance"].(map[string]any) + require.True(t, ok, "manifest metadata.provenance is missing") + require.Contains(t, provenance, "buildDefinition") + require.Contains(t, provenance, "runDetails") +} + +// TestPublish_License_Valid_StampsCanonicalAnnotation covers ADR-0036 +// decisions 1, 2, and 4 on the happy path: a valid --license (given in +// non-canonical casing) is canonicalized and stamped as the standard OCI +// manifest annotation org.opencontainers.image.licenses. --dry-run keeps it +// off the network; we read the annotation back off the local OCI manifest. +func TestPublish_License_Valid_StampsCanonicalAnnotation(t *testing.T) { + workdir := isolatedWorkdir(t) + input := writeTempFile(t, workdir, "policy.yaml", policyYAML) + layout := filepath.Join(workdir, "layout") + + // Non-canonical input "apache-2.0" must come back canonicalized to + // "Apache-2.0" — proving the stamped value is spdx.Canonicalize's output, + // not the raw flag. + runRoot(t, "publish", "--dry-run", "-f", input, "--output", layout, "--license", "apache-2.0") + + ann := readOCIManifestAnnotations(t, layout) + require.Equal(t, "Apache-2.0", ann["org.opencontainers.image.licenses"], + "the canonical SPDX expression must be stamped as the standard OCI license annotation") +} + +// TestPublish_License_CompoundExpression confirms a compound SPDX expression +// round-trips canonicalized (operator casing normalized) into the annotation. +func TestPublish_License_CompoundExpression(t *testing.T) { + workdir := isolatedWorkdir(t) + input := writeTempFile(t, workdir, "policy.yaml", policyYAML) + layout := filepath.Join(workdir, "layout") + + // SPDX operators are case-sensitive uppercase; the leaf ids are not, so + // "mit" canonicalizes to "MIT" while "OR" must already be uppercase. + runRoot(t, "publish", "--dry-run", "-f", input, "--output", layout, "--license", "mit OR apache-2.0") + + ann := readOCIManifestAnnotations(t, layout) + require.Equal(t, "MIT OR Apache-2.0", ann["org.opencontainers.image.licenses"]) +} + +// TestPublish_License_Invalid_RejectedBeforePush covers ADR-0036 decision 4's +// strict gate: a malformed/unknown --license aborts the publish and writes NO +// OCI output, even under --dry-run (the strict check runs before pack). +func TestPublish_License_Invalid_RejectedBeforePush(t *testing.T) { + cases := []struct { + name string + license string + wantSub string + }{ + { + name: "unknown-id", + license: "Apache-9.9", // well-formed grammar, not a real SPDX id + wantSub: "unknown SPDX id", + }, + { + name: "malformed-grammar", + license: "MIT OR OR Apache-2.0", // dangling operator + wantSub: "malformed SPDX expression", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + workdir := isolatedWorkdir(t) + input := writeTempFile(t, workdir, "policy.yaml", policyYAML) + layout := filepath.Join(workdir, "layout") + + _, err := runRootExpectErr(t, "publish", "--dry-run", "-f", input, "--output", layout, "--license", tc.license) + require.Error(t, err) + require.Contains(t, err.Error(), tc.wantSub) + require.Contains(t, err.Error(), "invalid --license") + + // No OCI bytes may have been written: the layout dir must not exist. + _, statErr := os.Stat(layout) + require.True(t, os.IsNotExist(statErr), + "an invalid --license must abort before any OCI output is written") + }) + } +} + +// TestPublish_License_Omitted_RejectedBeforePush covers ADR-0037 decision 1: +// --license is now REQUIRED. Omitting it aborts the publish — before any pack +// or push, even under --dry-run — with the distinct "is required" error (NOT +// the "invalid --license" malformed-value message) and writes NO OCI output. +func TestPublish_License_Omitted_RejectedBeforePush(t *testing.T) { + workdir := isolatedWorkdir(t) + input := writeTempFile(t, workdir, "policy.yaml", policyYAML) + layout := filepath.Join(workdir, "layout") + + _, err := runRootExpectErr(t, "publish", "--dry-run", "-f", input, "--output", layout) + require.Error(t, err) + require.Contains(t, err.Error(), "a publication license is required", + "a missing --license must produce the distinct required-license error") + require.NotContains(t, err.Error(), "invalid --license", + "a missing flag and a malformed value must read differently") + + // No OCI bytes may have been written: the layout dir must not exist. + _, statErr := os.Stat(layout) + require.True(t, os.IsNotExist(statErr), + "a missing --license must abort before any OCI output is written") +} + +// TestPublish_License_Whitespace_RejectedBeforePush confirms a +// whitespace-only --license is treated as absent (the required-license +// error), not as a malformed value. +func TestPublish_License_Whitespace_RejectedBeforePush(t *testing.T) { + workdir := isolatedWorkdir(t) + input := writeTempFile(t, workdir, "policy.yaml", policyYAML) + layout := filepath.Join(workdir, "layout") + + _, err := runRootExpectErr(t, "publish", "--dry-run", "-f", input, "--output", layout, "--license", " ") + require.Error(t, err) + require.Contains(t, err.Error(), "a publication license is required") + + _, statErr := os.Stat(layout) + require.True(t, os.IsNotExist(statErr)) +} + +// TestPublish_License_LicenseRef_Accepted confirms a LicenseRef- token (the +// custom/proprietary escape hatch named in the required-license error and +// ADR-0037) is accepted and stamped verbatim. +func TestPublish_License_LicenseRef_Accepted(t *testing.T) { + workdir := isolatedWorkdir(t) + input := writeTempFile(t, workdir, "policy.yaml", policyYAML) + layout := filepath.Join(workdir, "layout") + + runRoot(t, "publish", "--dry-run", "-f", input, "--output", layout, "--license", "LicenseRef-Revanite-Proprietary") + + ann := readOCIManifestAnnotations(t, layout) + require.Equal(t, "LicenseRef-Revanite-Proprietary", ann["org.opencontainers.image.licenses"], + "a LicenseRef- token must be accepted and stamped as the OCI license annotation") +} + +func TestPublishUnpackRoundtrip_MergedControlCatalog(t *testing.T) { + workdir := isolatedWorkdir(t) + aPath := writeTempFile(t, workdir, "a.yaml", controlsPartA) + bPath := writeTempFile(t, workdir, "b.yaml", controlsPartB) + layout := filepath.Join(workdir, "layout") + unpacked := filepath.Join(workdir, "unpacked") + + runRoot(t, "publish", "--dry-run", "-f", aPath, "-f", bPath, "--output", layout, "--license", "Apache-2.0") + runRoot(t, "unpack", "--source", layout, "--version", "2.0.0", "--output", unpacked) + + // Two source files get merged into a single control-catalog.yaml + // inside the bundle. The unpacked file should contain controls from + // both inputs. + merged, err := os.ReadFile(filepath.Join(unpacked, "control-catalog.yaml")) + require.NoError(t, err) + body := string(merged) + require.Contains(t, body, "AC-1") + require.Contains(t, body, "AC-2") + + manifest := readManifest(t, filepath.Join(unpacked, "bundle.json")) + artifacts, _ := manifest["artifacts"].([]any) + require.Len(t, artifacts, 1) + first, _ := artifacts[0].(map[string]any) + require.Equal(t, "ControlCatalog", first["type"]) + require.Equal(t, "roundtrip-controls", first["id"]) + require.Equal(t, "control-catalog.yaml", first["name"]) +} + +func TestUnpack_FlagValidation(t *testing.T) { + cases := []struct { + name string + args []string + wantSub string + }{ + { + // Pass --url="" to defeat the bake-in default — otherwise + // the default would be a valid source and this test's + // premise ("no source set") wouldn't be reachable. The + // branch still exists for users who explicitly opt out of + // the default. + name: "no-source-or-url", + args: []string{"unpack", "--version", "1.0.0", "--url", ""}, + wantSub: "either --source or --url is required", + }, + { + name: "both-source-and-url", + args: []string{"unpack", "--version", "1.0.0", "--source", "/tmp/x", "--url", "https://hub.example"}, + wantSub: "mutually exclusive", + }, + { + // A bogus --url is fine: the --repository check runs before any + // hub round-trip, so this never dials the host. + name: "url-without-repository", + args: []string{"unpack", "--version", "1.0.0", "--url", "https://hub.example"}, + wantSub: "--repository is required when --url is set", + }, + { + name: "missing-version", + args: []string{"unpack", "--source", "/tmp/x"}, + wantSub: "--version is required", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + isolatedWorkdir(t) + out, err := runRootExpectErr(t, tc.args...) + require.Error(t, err, "expected error, got output: %s", out) + require.Contains(t, err.Error(), tc.wantSub) + }) + } +} + +// TestPublishPositionalFile_Roundtrip mirrors the single-policy +// roundtrip but passes the input file as a positional argument instead +// of via -f. Same assertions as the -f path — the goal is to prove the +// positional surface is wired all the way through to the bundle output, +// not to re-test the bundle internals. +func TestPublishPositionalFile_Roundtrip(t *testing.T) { + workdir := isolatedWorkdir(t) + input := writeTempFile(t, workdir, "policy.yaml", policyYAML) + layout := filepath.Join(workdir, "layout") + + publishOut := runRoot(t, "publish", "--dry-run", "--output", layout, input, "--license", "Apache-2.0") + require.Contains(t, publishOut, "dry-run: wrote bundle to oci:"+layout+":1.0.0") + require.Contains(t, publishOut, "artifact: Policy/roundtrip-policy") +} + +func TestPublish_MixingFlagAndPositional_Errors(t *testing.T) { + workdir := isolatedWorkdir(t) + input := writeTempFile(t, workdir, "policy.yaml", policyYAML) + layout := filepath.Join(workdir, "layout") + + _, err := runRootExpectErr(t, "publish", "--dry-run", "--output", layout, "-f", input, input) + require.Error(t, err) + require.Contains(t, err.Error(), "not both") +} + +// TestDefaultURL_AppliesWhenUnset locks in the user-visible behavior +// that grcli ships with hub.grc.store as the default --url target. +// Driven through the publish command's flag definition rather than +// resolveTarget directly so we catch a regression if the default is +// silently dropped from the cobra flag spec. +func TestDefaultURL_AppliesWhenUnset(t *testing.T) { + root := newRootCmd() + pub, _, err := root.Find([]string{"publish"}) + require.NoError(t, err) + urlFlag := pub.Flags().Lookup(flagURL) + require.NotNil(t, urlFlag, "publish must expose --url") + require.Equal(t, "https://hub.grc.store", urlFlag.DefValue, + "the bake-in default for --url must remain hub.grc.store until grcli has a private-hub story") +} + +// TestSuppressDefaultURLIfExplicit_SourceAlone covers the helper's +// raison d'être: a user passing only --source should NOT trip the +// "--source is mutually exclusive with --url" branch, because the --url +// they're supposedly conflicting with is just the bake-in default. +func TestSuppressDefaultURLIfExplicit_SourceAlone(t *testing.T) { + workdir := isolatedWorkdir(t) + input := writeTempFile(t, workdir, "policy.yaml", policyYAML) + layout := filepath.Join(workdir, "layout") + runRoot(t, "publish", "--dry-run", "-f", input, "--output", layout, "--license", "Apache-2.0") + + // unpack --source with no explicit --url must not error with the + // mutual-exclusion message; the helper suppresses the default --url. + out := runRoot(t, "unpack", "--source", layout, "--version", "1.0.0", "--output", filepath.Join(workdir, "unpacked")) + require.Contains(t, out, "unpacked") +} + +func TestUnpack_MissingVersion_Errors(t *testing.T) { + workdir := isolatedWorkdir(t) + input := writeTempFile(t, workdir, "policy.yaml", policyYAML) + layout := filepath.Join(workdir, "layout") + runRoot(t, "publish", "--dry-run", "-f", input, "--output", layout, "--license", "Apache-2.0") + + _, err := runRootExpectErr(t, "unpack", "--source", layout, "--version", "does-not-exist", "--output", filepath.Join(workdir, "unpacked")) + require.Error(t, err) +} + +// isolatedWorkdir chdirs into a fresh temp dir and points HOME + +// XDG_CONFIG_HOME at it so any real ~/.grcli.yaml on the dev machine +// can't influence the test's viper resolution. Also clears the +// GRCLI_URL env var: viper's AutomaticEnv would otherwise pick up a +// dev's exported value and silently override --url in tests that +// exercise the "--url is required" path. +func isolatedWorkdir(t *testing.T) string { + t.Helper() + dir := t.TempDir() + t.Chdir(dir) + t.Setenv("HOME", dir) + t.Setenv("XDG_CONFIG_HOME", dir) + t.Setenv("GRCLI_URL", "") + return dir +} + +func writeTempFile(t *testing.T, dir, name, body string) string { + t.Helper() + path := filepath.Join(dir, name) + require.NoError(t, os.WriteFile(path, []byte(body), 0o600)) + return path +} + +// runRoot builds a fresh root command (and viper instance) and runs it +// with the given args, asserting success and returning captured output. +func runRoot(t *testing.T, args ...string) string { + t.Helper() + out, err := executeRoot(args) + require.NoError(t, err, "command %v failed: %s", args, out) + return out +} + +func runRootExpectErr(t *testing.T, args ...string) (string, error) { + t.Helper() + return executeRoot(args) +} + +func executeRoot(args []string) (string, error) { + var buf bytes.Buffer + root := newRootCmd() + root.SetOut(&buf) + root.SetErr(&buf) + root.SetArgs(args) + root.SetContext(context.Background()) + err := root.Execute() + return buf.String(), err +} + +// executeRootSplit runs the root command with independent stdout and stderr +// buffers, so a test can assert that content and diagnostics land on the right +// stream (e.g. `cat` must keep stdout pipe-clean). executeRoot merges the two, +// which cannot detect a stream-separation regression. +func executeRootSplit(args ...string) (stdout, stderr string, err error) { + var out, errBuf bytes.Buffer + root := newRootCmd() + root.SetOut(&out) + root.SetErr(&errBuf) + root.SetArgs(args) + root.SetContext(context.Background()) + err = root.Execute() + return out.String(), errBuf.String(), err +} + +func readManifest(t *testing.T, path string) map[string]any { + t.Helper() + raw, err := os.ReadFile(path) + require.NoError(t, err) + var manifest map[string]any + require.NoError(t, json.Unmarshal(raw, &manifest)) + return manifest +} + +// readOCIManifestAnnotations reads the single manifest from an OCI image +// layout directory and returns its manifest-level annotations map. It walks +// index.json -> the manifest blob (addressed by digest), which is where +// go-gemara's bundle.WithAnnotations lands the publication license (ADR-0036), +// as opposed to bundle.json (the config blob) which readManifest covers. +func readOCIManifestAnnotations(t *testing.T, layoutDir string) map[string]any { + t.Helper() + index := readManifest(t, filepath.Join(layoutDir, "index.json")) + manifests, ok := index["manifests"].([]any) + require.True(t, ok, "index.json has no manifests array") + require.Len(t, manifests, 1, "expected exactly one manifest in the layout") + entry, _ := manifests[0].(map[string]any) + digest, _ := entry["digest"].(string) + require.NotEmpty(t, digest, "manifest entry has no digest") + + // "sha256:" -> blobs/sha256/ + algo, hex, ok := strings.Cut(digest, ":") + require.True(t, ok, "manifest digest %q is not algo:hex", digest) + manifest := readManifest(t, filepath.Join(layoutDir, "blobs", algo, hex)) + + if ann, ok := manifest["annotations"].(map[string]any); ok { + return ann + } + return map[string]any{} +} diff --git a/cmd/login.go b/cmd/login.go new file mode 100644 index 0000000..03accd3 --- /dev/null +++ b/cmd/login.go @@ -0,0 +1,108 @@ +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "fmt" + "io" + "time" + + "github.com/spf13/cobra" + "github.com/spf13/viper" + + "github.com/gemaraproj/grc-store-clientkit/auth" + "github.com/revanite-io/grcli/internal/hub" +) + +func newLoginCmd(v *viper.Viper) *cobra.Command { + cmd := &cobra.Command{ + Use: "login", + Short: "Sign in to grc.store via OIDC device-authorization flow", + Long: `Asks the hub at --url for its OIDC coordinates, runs the OAuth 2.0 +Device Authorization Grant (RFC 8628), and stores the resulting +access + refresh tokens at ${XDG_DATA_HOME:-~/.local/share}/grcli/credentials.json +(0600 perms). Subsequent grcli publish calls auto-pick up the stored +token — no --token / GRCLI_TOKEN needed unless you want to override. + +The login flow prints a verification URL and a short user code. Open +the URL in any browser you can reach (does NOT have to be the same +machine), enter the code, sign in, approve the request. grcli polls +the token endpoint and reports completion.`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return runLogin(cmd, v) + }, + } + flags := cmd.Flags() + flags.String(flagURL, defaultURL, "grc.store base URL") + return cmd +} + +func runLogin(cmd *cobra.Command, v *viper.Viper) error { + if err := v.BindPFlags(cmd.Flags()); err != nil { + return fmt.Errorf("binding flags: %w", err) + } + ctx := cmd.Context() + out := cmd.OutOrStdout() + + url := v.GetString(flagURL) + if url == "" { + // Defensive — defaultURL is non-empty in production, but + // belt-and-braces for any caller that hand-clears the value. + return fmt.Errorf("--url is required") + } + + fmt.Fprintf(out, "Discovering %s ...\n", url) + disc, err := hub.Discover(ctx, url) + if err != nil { + return fmt.Errorf("hub discovery: %w", err) + } + if disc.OIDCIssuer == "" || disc.OIDCCLIClientID == "" { + return fmt.Errorf("hub at %s does not advertise OIDC login — its discovery document has no oidc_issuer / oidc_cli_client_id fields, so `grcli login` has nothing to drive a device-grant flow against. Until the hub supports interactive login, you can still publish by passing a token via --token or GRCLI_TOKEN", url) + } + + meta, err := auth.FetchOIDCMetadata(ctx, disc.OIDCIssuer) + if err != nil { + return err + } + + da, err := auth.StartDeviceFlow(ctx, meta, disc.OIDCCLIClientID) + if err != nil { + return err + } + printDeviceInstructions(out, da) + + creds, err := auth.PollForToken(ctx, meta, disc.OIDCCLIClientID, da) + if err != nil { + return err + } + + store, err := auth.NewDefaultStore(grcliApp) + if err != nil { + return err + } + if err := store.Put(creds); err != nil { + return fmt.Errorf("saving credentials: %w", err) + } + fmt.Fprintf(out, "\n✓ Signed in to %s\n Token stored at %s (expires %s)\n", + disc.OIDCIssuer, store.Path, creds.ExpiresAt.Format(time.RFC3339)) + return nil +} + +// printDeviceInstructions writes the user-facing block — the +// verification URL and the user code, with both the bare URL and the +// complete URL (when Keycloak provides one) so users on machines with +// a clipboard can paste the latter and skip typing the code. +func printDeviceInstructions(out io.Writer, da *auth.DeviceAuthorization) { + fmt.Fprintln(out) + if da.VerificationURIComplete != "" { + fmt.Fprintf(out, "Open this URL in any browser to authorize:\n %s\n", da.VerificationURIComplete) + fmt.Fprintf(out, "Or visit %s and enter code: %s\n", da.VerificationURI, da.UserCode) + } else { + fmt.Fprintf(out, "Visit %s and enter code: %s\n", da.VerificationURI, da.UserCode) + } + if da.ExpiresIn > 0 { + fmt.Fprintf(out, "(code expires in %s)\n", (time.Duration(da.ExpiresIn) * time.Second).Truncate(time.Second)) + } + fmt.Fprintln(out, "Waiting for authorization...") +} diff --git a/cmd/logout.go b/cmd/logout.go new file mode 100644 index 0000000..96119d2 --- /dev/null +++ b/cmd/logout.go @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" + "github.com/spf13/viper" + + "github.com/gemaraproj/grc-store-clientkit/auth" + "github.com/revanite-io/grcli/internal/hub" +) + +func newLogoutCmd(v *viper.Viper) *cobra.Command { + cmd := &cobra.Command{ + Use: "logout", + Short: "Forget stored credentials for a grc.store hub", + Long: `Removes the stored access + refresh tokens for the hub at --url. +The hub itself is not contacted — logout is purely local. Other hubs +the user has logged into are untouched. + +Pass --issuer to remove credentials by issuer URL when you know it +directly (e.g. the hub is unreachable and you can't run discovery).`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return runLogout(cmd, v) + }, + } + flags := cmd.Flags() + flags.String(flagURL, defaultURL, "grc.store base URL (discovers the issuer)") + flags.String("issuer", "", "OIDC issuer URL to forget credentials for (alternative to --url)") + return cmd +} + +func runLogout(cmd *cobra.Command, v *viper.Viper) error { + if err := v.BindPFlags(cmd.Flags()); err != nil { + return fmt.Errorf("binding flags: %w", err) + } + suppressDefaultURLIfExplicit(cmd, v, "issuer") + out := cmd.OutOrStdout() + + issuer := v.GetString("issuer") + url := v.GetString(flagURL) + switch { + case issuer != "" && url != "": + // Can only happen when both flags are explicitly set — the + // suppress helper above clears the URL default in the --issuer- + // only case. So treating this as a real conflict is correct. + return fmt.Errorf("pass either --url or --issuer, not both") + case issuer == "" && url == "": + return fmt.Errorf("--url or --issuer is required") + case url != "": + disc, err := hub.Discover(cmd.Context(), url) + if err != nil { + return fmt.Errorf("hub discovery: %w (pass --issuer directly if the hub is unreachable)", err) + } + if disc.OIDCIssuer == "" { + return fmt.Errorf("hub at %s does not advertise an OIDC issuer; pass --issuer directly", url) + } + issuer = disc.OIDCIssuer + } + + store, err := auth.NewDefaultStore(grcliApp) + if err != nil { + return err + } + if err := store.Delete(issuer); err != nil { + return fmt.Errorf("removing stored credentials: %w", err) + } + fmt.Fprintf(out, "✓ Forgot credentials for %s\n", issuer) + return nil +} diff --git a/cmd/publish.go b/cmd/publish.go new file mode 100644 index 0000000..83339c2 --- /dev/null +++ b/cmd/publish.go @@ -0,0 +1,539 @@ +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "cmp" + "context" + "errors" + "fmt" + "io" + "os" + "regexp" + "strings" + "time" + + "github.com/spf13/cobra" + "github.com/spf13/viper" + + "github.com/revanite-io/grc-store-protocol/spdx" + + "github.com/gemaraproj/grc-store-clientkit/auth" + "github.com/revanite-io/grcli/internal/digest" + "github.com/revanite-io/grcli/internal/hub" + "github.com/revanite-io/grcli/internal/provenance" + "github.com/revanite-io/grcli/internal/registry" + "github.com/revanite-io/grcli/internal/sign" + "github.com/revanite-io/grcli/internal/source" +) + +// Flag names are declared once so the compiler catches typos at every +// viper.Get call site. publish does not expose a tag/version flag — +// the OCI tag is always metadata.version (ADR-0033). unpack and verify +// take --version (see flagVersion in unpack.go) to address a published +// bundle. +const ( + flagFile = "file" + flagURL = "url" + flagRepository = "repository" + flagToken = "token" + flagDryRun = "dry-run" + flagOutput = "output" + flagNoSign = "no-sign" + flagCosignKey = "cosign-key" + flagLicense = "license" +) + +func newPublishCmd(v *viper.Viper) *cobra.Command { + cmd := &cobra.Command{ + Use: "publish [file...]", + Short: "Bundle one Gemara artifact with provenance and push it to grc.store", + Long: `Loads the file(s) describing a single artifact, attaches a SLSA-shaped +provenance record, packs an OCI bundle, pushes it to the configured +registry, optionally signs with cosign, and notifies the hub via +POST /v1/bundles/sync. + +Files can be supplied as positional arguments (grcli publish a.yaml +b.yaml) or via -f / --file. The two forms are mutually exclusive — +mixing them is an error so neither silently wins. + +Use --dry-run to write the bundle to an OCI image layout on disk +instead of touching any network. + +Auth in GitHub Actions: no GitHub secret, no --token, no GRCLI_TOKEN — +when run inside a workflow with permissions: id-token: write, grcli +mints a GitHub Actions OIDC token and presents it as the credential +(ADR-0032 trusted publishing). The repo (owner/repo, optionally pinned +to a ref) must be registered as a trusted publisher on the hub for the +target namespace; a 403 means that binding is missing — not that you +need to set a secret.`, + Args: cobra.ArbitraryArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return runPublish(cmd, v, args) + }, + } + + flags := cmd.Flags() + flags.StringSliceP(flagFile, "f", nil, "input file(s) describing one artifact (repeatable; comma-separated also accepted)") + flags.String(flagURL, defaultURL, "grc.store base URL — discovers the registry and is the hub sync target (ADR-0026)") + flags.String(flagRepository, "", "repository path within the registry (default: /, slugified to [a-z0-9._-])") + flags.String(flagToken, "", "bearer token for the hub sync call (or GRCLI_TOKEN); leave unset in GitHub Actions — the workflow's OIDC token is used automatically (trusted publishing, no GitHub secret needed)") + flags.Bool(flagDryRun, false, "skip all network — emit OCI layout to --output instead") + flags.String(flagOutput, "grcli-out", "directory to write the OCI layout to when --dry-run") + flags.Bool(flagNoSign, false, "skip cosign signing even when material is available") + flags.String(flagCosignKey, "", "cosign key file for local signing (or COSIGN_KEY)") + flags.String(flagLicense, "", "REQUIRED: publication license as an SPDX expression (e.g. Apache-2.0, MIT OR Apache-2.0, LicenseRef-Revanite-Proprietary); stamped as the org.opencontainers.image.licenses OCI annotation. Publish fails before any network call if unset (ADR-0037)") + + // Flags are bound to viper inside RunE (see runPublish) rather than + // here at construction time. Two subcommands sharing a viper instance + // (e.g. publish + unpack both defining --output) would otherwise + // clobber each other's bindings — viper keys are global per instance. + // + // COSIGN_KEY is the conventional env name for the cosign key path; + // override the GRCLI_ prefix so existing cosign users see it picked up. + _ = v.BindEnv(flagCosignKey, "COSIGN_KEY") + + return cmd +} + +// publishTarget holds the resolved push destination after flags, config, +// and artifact metadata defaults are merged. +type publishTarget struct { + registryHost string + repository string + tag string + dryRun bool + output string +} + +func runPublish(cmd *cobra.Command, v *viper.Viper, positional []string) error { + if err := v.BindPFlags(cmd.Flags()); err != nil { + return fmt.Errorf("binding flags: %w", err) + } + ctx := cmd.Context() + startedOn := time.Now().UTC() + + flagFiles := expandCommas(v.GetStringSlice(flagFile)) + files, err := mergeFileSources(flagFiles, positional) + if err != nil { + return err + } + if len(files) == 0 { + return errors.New("no input files: pass paths positionally (grcli publish a.yaml) or via --file") + } + + loaded, err := source.Load(ctx, files) + if err != nil { + return err + } + + target, err := resolveTarget(ctx, v, loaded) + if err != nil { + return err + } + + // Strict license gate (ADR-0037 decision 1, tightening ADR-0036): grcli + // is the strict end. --license is now REQUIRED. Validate and canonicalize + // BEFORE any pack/push — including the --dry-run path — so a missing, + // malformed, or unknown SPDX expression never produces OCI bytes (locally + // or in the registry). + canonicalLicense, err := validatePublishLicense(v.GetString(flagLicense)) + if err != nil { + return err + } + + if !target.dryRun { + // Pre-flight: fail BEFORE packing/pushing if we intend to sign but + // can't — a signing misconfig must not leave unsigned bytes orphaned + // in the registry. This is a local, instant check (cosign on PATH + + // key/CI material); --no-sign is the explicit opt-out for an + // unsigned, unverifiable publish. + if err := sign.Preflight(ctx, sign.Options{ + Disabled: v.GetBool(flagNoSign), + KeyPath: v.GetString(flagCosignKey), + }); err != nil { + return err + } + // Pre-flight: versions are immutable, so halt BEFORE packing or + // pushing if the coordinate is already taken (ADR-0031). This is + // what stops a re-publish from clobbering existing bytes in the + // registry — the registry would accept the overwrite before the + // hub's sync-time guard could reject it. + if err := checkVersionAvailable(ctx, v, target.repository, target.tag); err != nil { + return err + } + // The registry rejects unauthenticated writes. Mint a repo-scoped + // push token from the hub and export it so both the oras push and + // the cosign signature push authenticate. + if err := authenticatePush(ctx, v, target.repository); err != nil { + return err + } + } + + predicate := provenance.Build(provenance.Input{ + ToolVersion: version, + StartedOn: startedOn, + ArtifactType: loaded.Type, + ArtifactID: loaded.ID, + ArtifactName: loaded.Filename, + ArtifactDigest: digest.Bytes(loaded.Body), + SourceFiles: loaded.SourceDigests, + Registry: registry.NormalizeRegistryHost(target.registryHost), + Repository: target.repository, + Tag: target.tag, + }) + + packInput := registry.PackInput{ + Filename: loaded.Filename, + ArtifactType: loaded.Type, + ArtifactID: loaded.ID, + GemaraVersion: loaded.GemaraVersion, + Body: loaded.Body, + Provenance: predicate, + License: canonicalLicense, + } + + result, err := pushBundle(ctx, target, packInput, cmd.OutOrStdout(), loaded.Type, loaded.ID) + if err != nil { + return err + } + if target.dryRun { + return nil + } + + return signAndNotify(ctx, v, signContext{ + repository: target.repository, + tag: target.tag, + reference: result.Reference, + registryHost: target.registryHost, + manifestDigest: result.ManifestDigest, + plainHTTP: strings.HasPrefix(target.registryHost, "http://"), + }) +} + +// signContext carries the push coordinates the sign + notify step needs. +type signContext struct { + repository string + tag string + reference string // /:, bare host + registryHost string // scheme-prefixed oras dial target + manifestDigest string // sha256:… of the just-pushed manifest + plainHTTP bool +} + +// resolveTarget merges --repository/--url/--dry-run with the +// metadata-derived defaults and validates the combination. --url drives +// the registry hostname via the hub's discovery endpoint (ADR-0026); +// --dry-run skips discovery since it never touches the network. +// +// The OCI tag is always metadata.version — no override. ADR-0033 (in +// grc.store-backend) made tag == metadata.version a hub-enforced +// invariant; a --tag override could only ever produce a 422 +// tag_version_mismatch from the syncer, so the flag was removed rather +// than left as a foot-gun. +func resolveTarget(ctx context.Context, v *viper.Viper, loaded *source.Loaded) (publishTarget, error) { + tag := loaded.Version + if tag == "" { + return publishTarget{}, errors.New("could not determine tag — metadata.version is required") + } + repository := cmp.Or(v.GetString(flagRepository), defaultRepository(loaded.AuthorID, loaded.ID)) + if repository == "" { + return publishTarget{}, errors.New("could not determine --repository — set it explicitly or populate metadata.author.id + metadata.id") + } + + url := v.GetString(flagURL) + dryRun := v.GetBool(flagDryRun) + + var registryHost string + if url != "" && !dryRun { + d, err := hub.Discover(ctx, url) + if err != nil { + return publishTarget{}, fmt.Errorf("hub discovery: %w", err) + } + // Keep the scheme the hub advertises (http:// for a plain-HTTP + // dev registry, https:// for prod). registryHost is the oras dial + // target, and newRemoteRepo derives PlainHTTP from that scheme — + // stripping it here would force HTTPS against a plain-HTTP zot. + // Display/provenance/cosign consumers normalize to a bare host at + // their own call sites (PushResult.Reference, provenance below). + registryHost = d.RegistryURL + } + + target := publishTarget{ + registryHost: registryHost, + repository: repository, + tag: tag, + dryRun: dryRun, + output: v.GetString(flagOutput), + } + if !target.dryRun && target.registryHost == "" { + return publishTarget{}, errors.New("--url is required (use --dry-run to skip push)") + } + return target, nil +} + +// pushBundle either writes the bundle to a local OCI layout (dry-run) +// or pushes it to the configured registry, printing a one-line summary +// in either case. +func pushBundle(ctx context.Context, target publishTarget, in registry.PackInput, out io.Writer, artifactType, artifactID string) (*registry.PushResult, error) { + if target.dryRun { + result, err := registry.PushLocal(ctx, target.output, target.tag, in) + if err != nil { + return nil, err + } + fmt.Fprintf(out, + "dry-run: wrote bundle to %s\n manifest digest: %s\n body digest: %s\n artifact: %s/%s\n", + result.Reference, result.ManifestDigest, result.BodyDigest, artifactType, artifactID) + return result, nil + } + result, err := registry.PushRemote(ctx, target.registryHost, target.repository, target.tag, in) + if err != nil { + return nil, fmt.Errorf("push: %w", err) + } + fmt.Fprintf(out, "pushed %s\n manifest digest: %s\n", result.Reference, result.ManifestDigest) + return result, nil +} + +// signAndNotify runs the optional cosign step and the hub sync call. +// Status lines go to os.Stdout rather than a passed-in writer because +// the cosign subprocess inside sign.Sign writes to os.Stdout/os.Stderr +// directly; routing grcli's own status lines through a different writer +// would create a misleading "I control the output" contract. +func signAndNotify(ctx context.Context, v *viper.Viper, sc signContext) error { + signResult, err := sign.Sign(ctx, sign.Options{ + Disabled: v.GetBool(flagNoSign), + KeyPath: v.GetString(flagCosignKey), + Reference: sc.reference, + PlainHTTP: sc.plainHTTP, + RegistryHost: sc.registryHost, + Repository: sc.repository, + ManifestDigest: sc.manifestDigest, + }) + if err != nil { + return fmt.Errorf("sign: %w", err) + } + if signResult.Mode == sign.ModeSkipped { + fmt.Fprintf(os.Stdout, "signing skipped: %s\n", signResult.Reason) + } else { + fmt.Fprintf(os.Stdout, "signed (%s)\n", signResult.Mode) + } + + hubURL := publishHubURL(v) + if hubURL == "" { + fmt.Fprintln(os.Stdout, "skipping hub sync: --url not set") + return nil + } + token, err := resolveBearerToken(ctx, v) + if err != nil { + return err + } + syncResp, err := hub.New(hubURL, token).Sync(ctx, sc.repository, sc.tag) + if err != nil { + return fmt.Errorf("hub sync: %w", err) + } + fmt.Fprintf(os.Stdout, + "hub indexed %s:%s — %d artifacts (%d new), types=%s\n", + syncResp.Repository, syncResp.Tag, + syncResp.ArtifactCount, syncResp.NewCount, + strings.Join(syncResp.Types, ","), + ) + return nil +} + +// checkVersionAvailable is the publish pre-flight. Versions are immutable +// (ADR-0031), so if the target coordinate already exists on the hub, halt +// here — before packing, before any registry write. That prevents a +// re-publish from clobbering the existing bytes in the registry (which +// accepts the overwrite before the hub's sync-time guard can reject it). +// No-op when there's no hub URL to ask (--url explicitly cleared) or when +// --repository isn't a plain / coordinate; in those cases +// the server-side sync guard remains the backstop. +func checkVersionAvailable(ctx context.Context, v *viper.Viper, repository, tag string) error { + hubBaseURL := publishHubURL(v) + if hubBaseURL == "" { + return nil + } + ns, cid, ok := strings.Cut(repository, "/") + if !ok || ns == "" || cid == "" || strings.Contains(cid, "/") { + return nil + } + status, err := hub.New(hubBaseURL, "").VersionExists(ctx, ns, cid, tag) + if err != nil { + return fmt.Errorf("checking whether %s:%s already exists: %w", repository, tag, err) + } + switch status { + case hub.VersionPresent: + return fmt.Errorf("%s:%s already exists — versions are immutable; bump the version (or yank it first)", repository, tag) + case hub.VersionTombstoned: + return fmt.Errorf("%s:%s was yanked and cannot be republished — publish a new version", repository, tag) + default: + return nil + } +} + +// ciAudience returns the audience grcli requests on its GitHub Actions +// OIDC token. The hub advertises its expected CI audience via discovery +// (ci_audience); prefer that so the token grcli mints and the value the +// hub validates can't drift (a trailing slash or a stale env var would +// otherwise produce an opaque 401). Falls back to the hub URL when +// discovery omits it (an older hub, or one with CI publishing off). +func ciAudience(ctx context.Context, v *viper.Viper) string { + if url := v.GetString(flagURL); url != "" { + if d, err := hub.Discover(ctx, url); err == nil && d.CIAudience != "" { + return d.CIAudience + } + } + return publishHubURL(v) +} + +// publishHubURL returns the hub base URL for the publish run (--url). +// Empty means --url was explicitly cleared, so there's no hub to sync +// with or mint a registry token from. +func publishHubURL(v *viper.Viper) string { + return v.GetString(flagURL) +} + +// authenticatePush exports a registry push token (GRCLI_REGISTRY_TOKEN) +// so the oras push and the cosign signature push authenticate to the +// bearer-auth registry (ADR-0031). The hub grants push only to a +// namespace owner or admin, so a push needs a hub login: when no explicit +// registry credential override is present, we resolve the login token and +// surface a clear `grcli login` hint if it's missing. No-op when there's +// no hub URL (--url explicitly cleared) or when a manual GRCLI_REGISTRY_* +// override is set. +func authenticatePush(ctx context.Context, v *viper.Viper, repository string) error { + hubBaseURL := publishHubURL(v) + if hubBaseURL == "" { + return nil + } + if os.Getenv("GRCLI_REGISTRY_TOKEN") != "" || + (os.Getenv("GRCLI_REGISTRY_USERNAME") != "" && os.Getenv("GRCLI_REGISTRY_PASSWORD") != "") { + return nil + } + login, err := resolveBearerToken(ctx, v) + if err != nil { + return fmt.Errorf("registry push needs a hub login to mint a push token: %w", err) + } + if _, err := ensureRegistryToken(ctx, hubBaseURL, login, repository, []string{"pull", "push"}); err != nil { + return fmt.Errorf("fetching registry push token: %w", err) + } + return nil +} + +// resolveBearerToken wraps auth.Resolve with the publish command's +// glue: pulls --token / GRCLI_TOKEN (merged by viper), re-runs hub +// discovery to learn the OIDC issuer + client_id when --url is set +// (cached after resolveTarget's earlier call, so this is a map lookup), +// and instantiates the default credential store. Discovery failures +// here are swallowed — the worst case is that auth.Resolve has no +// store-key to look up and falls back to ErrNoToken, which prints the +// same "run grcli login" hint a caller would already need. +func resolveBearerToken(ctx context.Context, v *viper.Viper) (string, error) { + in := auth.ResolveInput{ + App: grcliApp, + ExplicitToken: v.GetString(flagToken), + Warn: os.Stderr, + } + // Resolution order (ADR-0028): --token / GRCLI_TOKEN (captured above) + // > GitHub Actions OIDC > stored device-login creds. The CI step: + // when no explicit token is set and we're in a GHA job, fetch the + // workflow's OIDC token and present it directly — the hub validates it + // (ADR-0032) and maps the repo to its trusted-publisher namespace. No + // secret, no login. The audience comes from the hub's discovery doc + // (ci_audience), falling back to the hub URL, so it always matches the + // hub's HUB_CI_OIDC_AUDIENCE. On any failure we fall through to the + // normal stored-credential path rather than hard-failing. + if in.ExplicitToken == "" && auth.InGitHubActions() { + if tok, err := auth.FetchGitHubActionsToken(ctx, ciAudience(ctx, v)); err == nil && tok != "" { + return tok, nil + } else if err != nil { + fmt.Fprintf(os.Stderr, "warning: GitHub Actions OIDC token unavailable, falling back: %v\n", err) + } + } + if url := v.GetString(flagURL); url != "" { + if d, err := hub.Discover(ctx, url); err == nil { + in.Issuer = d.OIDCIssuer + in.ClientID = d.OIDCCLIClientID + } + } + if store, err := auth.NewDefaultStore(grcliApp); err == nil { + in.Store = store + } + return auth.Resolve(ctx, in) +} + +// validatePublishLicense runs the strict SPDX gate for --license (ADR-0037 +// decision 1, tightening ADR-0036: grcli is the strict end). The flag is now +// REQUIRED: an empty/whitespace-only value is an error — distinct from the +// invalid-value message, because a missing flag and a malformed value are +// different user mistakes. A supplied value must be a well-formed SPDX +// expression whose every leaf id is known to the bundled SPDX list; the +// returned string is the canonical SPDX spelling, used from here on. The two +// invalid-value failure modes are distinguished so the publisher knows whether +// they have a grammar error or a typo'd/unknown id. +func validatePublishLicense(raw string) (string, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return "", errors.New("a publication license is required: pass --license with an SPDX expression (e.g. Apache-2.0, MIT OR Apache-2.0; see https://spdx.org/licenses) or a LicenseRef-… token for a custom/proprietary license (ADR-0037)") + } + canonical, err := spdx.Canonicalize(raw) + if err != nil { + switch { + case errors.Is(err, spdx.ErrUnknownID): + return "", fmt.Errorf("invalid --license %q: unknown SPDX id (%w) — check https://spdx.org/licenses or use a LicenseRef- token for a custom license", raw, err) + case errors.Is(err, spdx.ErrSyntax): + return "", fmt.Errorf("invalid --license %q: malformed SPDX expression (%w)", raw, err) + default: + return "", fmt.Errorf("invalid --license %q: %w", raw, err) + } + } + return canonical, nil +} + +// mergeFileSources combines files from -f / --file with files passed as +// positional arguments. The two forms are mutually exclusive: mixing +// them silently would let one form override or shadow the other on +// scripted runs where both might be set unintentionally (e.g. a +// .grcli.yaml config sets file: while the caller also types one in). +func mergeFileSources(flagFiles, positional []string) ([]string, error) { + if len(flagFiles) > 0 && len(positional) > 0 { + return nil, errors.New("pass input files either positionally or via --file, not both") + } + if len(flagFiles) > 0 { + return flagFiles, nil + } + return expandCommas(positional), nil +} + +// expandCommas lets users write `-f a.yaml,b.yaml` in addition to +// `-f a.yaml -f b.yaml`. Cobra's StringSliceP splits commas at the +// flag layer, but viper.GetStringSlice does not when the underlying +// source is a config file, so we re-split defensively. +func expandCommas(in []string) []string { + out := make([]string, 0, len(in)) + for _, raw := range in { + for part := range strings.SplitSeq(raw, ",") { + if part = strings.TrimSpace(part); part != "" { + out = append(out, part) + } + } + } + return out +} + +// defaultRepository slugifies / for the +// registry path. Anything outside [a-zA-Z0-9._-] is collapsed to "-". +func defaultRepository(authorID, artifactID string) string { + if authorID == "" || artifactID == "" { + return "" + } + return slugify(authorID) + "/" + slugify(artifactID) +} + +var slugPattern = regexp.MustCompile(`[^a-zA-Z0-9._-]+`) + +func slugify(s string) string { + s = slugPattern.ReplaceAllString(s, "-") + s = strings.Trim(s, "-_.") + return strings.ToLower(s) +} diff --git a/cmd/publish_test.go b/cmd/publish_test.go new file mode 100644 index 0000000..d06cb89 --- /dev/null +++ b/cmd/publish_test.go @@ -0,0 +1,276 @@ +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gemaraproj/grc-store-clientkit/auth" + "github.com/spf13/viper" + "github.com/stretchr/testify/require" + + "github.com/revanite-io/grcli/internal/registry" + "github.com/revanite-io/grcli/internal/source" +) + +func TestResolveTarget(t *testing.T) { + // Mock hub discovery: --url is now the only way to a registry, so the + // happy-path cases point --url at this server, which advertises + // registry.example as the discovered host. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"registry_url":"registry.example","hub_url":"https://hub.example","api_version":"v1"}`)) + })) + defer srv.Close() + + loadedFull := &source.Loaded{ + Type: "Policy", + ID: "my-policy", + Version: "1.2.3", + AuthorID: "my-team", + } + loadedNoMetadata := &source.Loaded{ + Type: "Policy", + ID: "my-policy", + } + + tests := []struct { + name string + flags map[string]any + loaded *source.Loaded + wantErrSub string + wantTarget publishTarget + }{ + { + name: "all-defaults-from-metadata", + flags: map[string]any{ + flagURL: srv.URL, + }, + loaded: loadedFull, + wantTarget: publishTarget{ + registryHost: "registry.example", + repository: "my-team/my-policy", + tag: "1.2.3", + output: "grcli-out", + }, + }, + { + name: "flag-repository-overrides-default", + flags: map[string]any{ + flagURL: srv.URL, + flagRepository: "custom/repo", + }, + loaded: loadedFull, + wantTarget: publishTarget{ + registryHost: "registry.example", + repository: "custom/repo", + tag: "1.2.3", + output: "grcli-out", + }, + }, + { + name: "dry-run-does-not-require-registry", + flags: map[string]any{ + flagDryRun: true, + flagOutput: "/tmp/out", + }, + loaded: loadedFull, + wantTarget: publishTarget{ + registryHost: "", + repository: "my-team/my-policy", + tag: "1.2.3", + dryRun: true, + output: "/tmp/out", + }, + }, + { + name: "missing-url-when-not-dry-run", + flags: map[string]any{}, + loaded: loadedFull, + wantErrSub: "--url is required", + }, + { + name: "missing-tag", + flags: map[string]any{}, + loaded: loadedNoMetadata, + wantErrSub: "could not determine tag", + }, + { + name: "missing-repository", + flags: map[string]any{}, + loaded: &source.Loaded{ + Type: "Policy", + Version: "1.0.0", // satisfies the tag check so resolveTarget reaches the repository check + // no ID, no AuthorID — defaultRepository returns "" + }, + wantErrSub: "could not determine --repository", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + v := viper.New() + for k, val := range tc.flags { + v.Set(k, val) + } + // Output default mirrors the flag default; resolveTarget reads + // it via viper, so set it unless the test overrode it. + if _, ok := tc.flags[flagOutput]; !ok { + v.SetDefault(flagOutput, "grcli-out") + } + + got, err := resolveTarget(context.Background(), v, tc.loaded) + if tc.wantErrSub != "" { + require.Error(t, err) + require.Contains(t, err.Error(), tc.wantErrSub) + return + } + require.NoError(t, err) + require.Equal(t, tc.wantTarget, got) + }) + } +} + +// TestResolveTargetURL covers the ADR-0026 discovery hook: --url drives a +// discovery call to resolve the registry, and --dry-run skips it. Mock +// hub via httptest. +func TestResolveTargetURL(t *testing.T) { + loaded := &source.Loaded{ + Type: "Policy", + ID: "my-policy", + Version: "1.2.3", + AuthorID: "my-team", + } + + t.Run("url drives discovery, keeps the dial scheme, normalizes at composition", func(t *testing.T) { + // Adversarial response: scheme included AND trailing slash, two + // real malformations a hub operator can produce by setting + // HUB_OCI_PUBLIC_URL = "https://registry.grc.store/". + // + // registryHost is the oras dial target, so it KEEPS the advertised + // scheme — newRemoteRepo derives PlainHTTP from it, and stripping + // http:// here would force HTTPS against a plain-HTTP zot. The + // bare-host guarantee for cosign / the printed Reference / SLSA + // provenance is enforced where those are composed, via + // NormalizeRegistryHost (which also trims the trailing slash). + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"registry_url":"https://discovered.example/","hub_url":"https://hub.example","api_version":"v1"}`)) + })) + defer srv.Close() + // Discover() caches per process; unique httptest URLs per subtest + // sidestep the cache without exposing the package's reset hook. + + v := viper.New() + v.Set(flagURL, srv.URL) + v.SetDefault(flagOutput, "grcli-out") + + got, err := resolveTarget(context.Background(), v, loaded) + require.NoError(t, err) + require.Equal(t, "https://discovered.example/", got.registryHost, + "registryHost is the dial target — the advertised scheme must survive for PlainHTTP routing") + require.Equal(t, "discovered.example", registry.NormalizeRegistryHost(got.registryHost), + "normalizing the dial target yields the bare host used for cosign and OCI reference composition") + }) + + t.Run("dry-run with url does not trigger discovery", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) { + t.Error("discovery endpoint hit during dry-run — should be skipped") + })) + defer srv.Close() + + v := viper.New() + v.Set(flagURL, srv.URL) + v.Set(flagDryRun, true) + v.SetDefault(flagOutput, "grcli-out") + + got, err := resolveTarget(context.Background(), v, loaded) + require.NoError(t, err) + require.True(t, got.dryRun) + require.Equal(t, "", got.registryHost, "dry-run should not need a registry") + }) +} + +func TestCIAudience(t *testing.T) { + t.Run("prefers the hub-advertised ci_audience", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"registry_url":"https://r","hub_url":"https://h","api_version":"v1","ci_audience":"https://hub.example/ci"}`)) + })) + defer srv.Close() + + v := viper.New() + v.Set(flagURL, srv.URL) + + require.Equal(t, "https://hub.example/ci", ciAudience(context.Background(), v), + "discovery's ci_audience must win so the token audience matches HUB_CI_OIDC_AUDIENCE") + }) + + t.Run("falls back to the hub URL when ci_audience is not advertised", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"registry_url":"https://r","hub_url":"https://h","api_version":"v1"}`)) + })) + defer srv.Close() + + v := viper.New() + v.Set(flagURL, srv.URL) + + require.Equal(t, srv.URL, ciAudience(context.Background(), v), + "absent ci_audience must fall back to the publish hub URL") + }) +} + +func TestResolveBearerToken(t *testing.T) { + t.Run("uses GitHub Actions OIDC when present and no explicit token", func(t *testing.T) { + tokenSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"value":"gha.workflow.jwt"}`)) + })) + defer tokenSrv.Close() + discoSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"registry_url":"https://r","hub_url":"https://h","api_version":"v1","ci_audience":"https://hub.example/ci"}`)) + })) + defer discoSrv.Close() + + t.Setenv("GITHUB_ACTIONS", "true") + t.Setenv("ACTIONS_ID_TOKEN_REQUEST_URL", tokenSrv.URL) + t.Setenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN", "req-tok") + + v := viper.New() + v.Set(flagURL, discoSrv.URL) + + got, err := resolveBearerToken(context.Background(), v) + require.NoError(t, err) + require.Equal(t, "gha.workflow.jwt", got, + "in CI with no explicit token, the workflow OIDC token is the credential (ADR-0032)") + }) + + t.Run("explicit --token wins even inside GitHub Actions", func(t *testing.T) { + // Point the Actions endpoint at an unreachable URL: if the GHA + // path were taken it would fail, so a clean explicit return proves + // the explicit token short-circuits before the GHA branch. + t.Setenv("GITHUB_ACTIONS", "true") + t.Setenv("ACTIONS_ID_TOKEN_REQUEST_URL", "http://127.0.0.1:0/should-not-be-called") + t.Setenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN", "req-tok") + + v := viper.New() + v.Set(flagToken, "explicit-tok") + + got, err := resolveBearerToken(context.Background(), v) + require.NoError(t, err) + require.Equal(t, "explicit-tok", got) + }) +} + +// grcli registers --token, so a no-token error must name it. From +// grc-store-clientkit v0.1.1 the flag is listed only when App.TokenFlag is set, +// and dropping it would silently hide a fix the user can apply. +func TestGrcliApp_NoTokenErrorNamesTheFlag(t *testing.T) { + err := &auth.ErrNoToken{App: grcliApp, Issuer: "https://issuer", CheckedStore: true} + msg := err.Error() + for _, want := range []string{"--token", "GRCLI_TOKEN", "grcli login"} { + if !strings.Contains(msg, want) { + t.Errorf("no-token error should name %q, got: %s", want, msg) + } + } +} diff --git a/cmd/references_test.go b/cmd/references_test.go new file mode 100644 index 0000000..e998c72 --- /dev/null +++ b/cmd/references_test.go @@ -0,0 +1,405 @@ +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/gemaraproj/go-gemara/bundle" + "github.com/stretchr/testify/require" + + "github.com/revanite-io/grcli/internal/cache" +) + +// refBearingCatalogYAML is a ControlCatalog whose `imports` resolves reference +// "base" -> https://grc.store/acme/baseline@2.1.0 (the grc.store placeholder +// host rewrites to the --url target). +const refBearingCatalogYAML = `metadata: + id: my-catalog + type: ControlCatalog + gemara-version: "0.5.0" + description: a test catalog + author: + id: acme + name: Acme + mapping-references: + - id: base + title: Base Catalog + version: "2.1.0" + url: https://grc.store/acme/baseline +imports: + - reference-id: base +` + +// fakeDiscoveryHub serves the well-known discovery doc (advertising registryURL) +// and answers the primary's best-effort license lookup with a quiet 404. +// Discovery is now lazy — only a cache MISS triggers it — so an all-cache-hit +// run never calls it, and registryURL is only reached on an actual pull. +func fakeDiscoveryHub(t *testing.T, registryURL string) *httptest.Server { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/.well-known/grc-store-configuration": + _ = json.NewEncoder(w).Encode(map[string]string{"registry_url": registryURL}) + case strings.HasPrefix(r.URL.Path, "/v1/catalogs/"): + // The primary's license-baseline lookup (best-effort); a 404 just + // means "no baseline", which suppresses mismatch warnings. + http.Error(w, "not found", http.StatusNotFound) + default: + t.Errorf("unexpected hub path: %s", r.URL.Path) + http.Error(w, "not found", http.StatusNotFound) + } + })) + t.Cleanup(srv.Close) + return srv +} + +// TestUnpack_References_FromCache exercises the whole reference-resolution path +// offline: both the primary and its imported reference are pre-seeded in the +// cache, so unpack --with-imports resolves the reference with no registry pull, +// writing it as a directory (files + bundle.json) plus references/index.json. +func TestUnpack_References_FromCache(t *testing.T) { + c := tempCache(t) + workdir := isolatedWorkdir(t) + srv := fakeDiscoveryHub(t, "https://oci.invalid.test") // never pulled from on a hit + host := hostOf(srv.URL) + + // Primary: a cache hit whose content declares the import. + primary := &bundle.Bundle{ + Files: []bundle.File{{Name: "catalog.yaml", Data: []byte(refBearingCatalogYAML)}}, + Manifest: bundle.Manifest{BundleVersion: "1", GemaraVersion: "0.5.0"}, + } + putBundle(c, host, "myorg", "mycat", "1.0.0", primary, io.Discard) + + // Reference (acme/baseline@2.1.0): also a cache hit — a full bundle. + ref := &bundle.Bundle{ + Files: []bundle.File{{Name: "baseline.yaml", Data: []byte("id: baseline\n")}}, + Manifest: bundle.Manifest{BundleVersion: "1", GemaraVersion: "0.5.0"}, + Etag: "sha256:refetag", + } + refEntry, err := entryFromBundle(ref, "Apache-2.0", "https://grc.store/acme/baseline") + require.NoError(t, err) + require.NoError(t, c.Put(host, "acme", "baseline", "2.1.0", refEntry)) + + output := filepath.Join(workdir, "unpacked") + out := runRoot(t, "unpack", "--url", srv.URL, "--repository", "myorg/mycat", + "--version", "1.0.0", "--with-imports", "--no-verify", "--output", output) + require.Contains(t, out, "resolved 1 reference(s), skipped 0") + + refDir := filepath.Join(output, "references", "imports", "acme", "baseline@2.1.0") + gotFile, err := os.ReadFile(filepath.Join(refDir, "baseline.yaml")) + require.NoError(t, err) + require.Equal(t, "id: baseline\n", string(gotFile), "reference file must be materialized from cache") + require.FileExists(t, filepath.Join(refDir, "bundle.json"), "reference bundle.json must be written") + + // index.json records the reference with the directory path. + idxBytes, err := os.ReadFile(filepath.Join(output, "references", "index.json")) + require.NoError(t, err) + var idx []refIndexEntry + require.NoError(t, json.Unmarshal(idxBytes, &idx)) + require.Len(t, idx, 1) + require.Equal(t, "baseline", idx[0].CatalogID) + require.Equal(t, "acme", idx[0].Namespace) + require.Equal(t, "2.1.0", idx[0].Version) + require.Equal(t, "Apache-2.0", idx[0].License) + require.Equal(t, filepath.Join("references", "imports", "acme", "baseline@2.1.0"), idx[0].Path) + require.Equal(t, "sha256:refetag", idx[0].ManifestDigest) +} + +// TestUnpack_References_LicenseHealedOnCacheHit guards the license-heal path: a +// coordinate cached WITHOUT a license (e.g. first fetched as a primary, or +// cached during a hub outage) must have its license looked up live on a later +// reference cache hit — filling references/index.json and upgrading the cache +// entry in place — instead of staying license-less forever. +func TestUnpack_References_LicenseHealedOnCacheHit(t *testing.T) { + c := tempCache(t) + workdir := isolatedWorkdir(t) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/.well-known/grc-store-configuration": + _ = json.NewEncoder(w).Encode(map[string]string{"registry_url": "https://oci.invalid.test"}) + case r.URL.Path == "/v1/catalogs/acme/baseline": + _ = json.NewEncoder(w).Encode(map[string]any{ + "namespace": "acme", "catalog_id": "baseline", + "releases": []map[string]string{{"version": "2.1.0", "license": "GPL-3.0-only"}}, + }) + case strings.HasPrefix(r.URL.Path, "/v1/catalogs/"): + http.Error(w, "not found", http.StatusNotFound) // primary license baseline: best-effort + default: + http.Error(w, "not found", http.StatusNotFound) + } + })) + t.Cleanup(srv.Close) + host := hostOf(srv.URL) + + primary := &bundle.Bundle{Files: []bundle.File{{Name: "catalog.yaml", Data: []byte(refBearingCatalogYAML)}}} + putBundle(c, host, "myorg", "mycat", "1.0.0", primary, io.Discard) + // The reference is cached with NO license — the poisoned/outage shape. + ref := &bundle.Bundle{Files: []bundle.File{{Name: "baseline.yaml", Data: []byte("id: baseline\n")}}} + refEntry, err := entryFromBundle(ref, "", "https://grc.store/acme/baseline") + require.NoError(t, err) + require.NoError(t, c.Put(host, "acme", "baseline", "2.1.0", refEntry)) + + output := filepath.Join(workdir, "unpacked") + out := runRoot(t, "unpack", "--url", srv.URL, "--repository", "myorg/mycat", + "--version", "1.0.0", "--with-imports", "--no-verify", "--output", output) + require.Contains(t, out, "resolved 1 reference(s), skipped 0") + + // The healed license lands in index.json... + idxBytes, err := os.ReadFile(filepath.Join(output, "references", "index.json")) + require.NoError(t, err) + var idx []refIndexEntry + require.NoError(t, json.Unmarshal(idxBytes, &idx)) + require.Len(t, idx, 1) + require.Equal(t, "GPL-3.0-only", idx[0].License, "empty-license cache hit must be healed from the hub") + + // ...and the cache entry is upgraded in place, so the next hit has it. + healed, found, err := c.Get(host, "acme", "baseline", "2.1.0") + require.NoError(t, err) + require.True(t, found) + require.Equal(t, "GPL-3.0-only", healed.License) +} + +// TestUnpack_References_LicenseHealMemoized guards the heal's cost bound: when +// the hub CONFIRMS a coordinate has no license, the entry is marked checked and +// no later hit pays another hub call — the catalog endpoint must be hit exactly +// once across two runs. +func TestUnpack_References_LicenseHealMemoized(t *testing.T) { + c := tempCache(t) + workdir := isolatedWorkdir(t) + refCatalogCalls := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/.well-known/grc-store-configuration": + _ = json.NewEncoder(w).Encode(map[string]string{"registry_url": "https://oci.invalid.test"}) + case r.URL.Path == "/v1/catalogs/acme/baseline": + refCatalogCalls++ + // Lookup SUCCEEDS but the catalog genuinely records no license. + _ = json.NewEncoder(w).Encode(map[string]any{ + "namespace": "acme", "catalog_id": "baseline", + "releases": []map[string]string{{"version": "2.1.0"}}, + }) + case strings.HasPrefix(r.URL.Path, "/v1/catalogs/"): + http.Error(w, "not found", http.StatusNotFound) + default: + http.Error(w, "not found", http.StatusNotFound) + } + })) + t.Cleanup(srv.Close) + host := hostOf(srv.URL) + + primary := &bundle.Bundle{Files: []bundle.File{{Name: "catalog.yaml", Data: []byte(refBearingCatalogYAML)}}} + putBundle(c, host, "myorg", "mycat", "1.0.0", primary, io.Discard) + ref := &bundle.Bundle{Files: []bundle.File{{Name: "baseline.yaml", Data: []byte("id: baseline\n")}}} + refEntry, err := entryFromBundle(ref, "", "https://grc.store/acme/baseline") + require.NoError(t, err) + require.NoError(t, c.Put(host, "acme", "baseline", "2.1.0", refEntry)) + + for run := 1; run <= 2; run++ { + out := runRoot(t, "unpack", "--url", srv.URL, "--repository", "myorg/mycat", + "--version", "1.0.0", "--with-imports", "--no-verify", "--output", filepath.Join(workdir, fmt.Sprintf("out%d", run))) + require.Contains(t, out, "resolved 1 reference(s), skipped 0") + } + require.Equal(t, 1, refCatalogCalls, + "a confirmed license-less coordinate must be looked up exactly once, not per run") + + healed, found, err := c.Get(host, "acme", "baseline", "2.1.0") + require.NoError(t, err) + require.True(t, found) + require.True(t, healed.LicenseChecked, "the successful no-license lookup must be memoized") + require.Empty(t, healed.License) +} + +// TestWriteReference_NoPartialWriteOnRejection: a hostile name anywhere in the +// list must reject the reference BEFORE any file is written, leaving no +// orphaned content on disk. +func TestWriteReference_NoPartialWriteOnRejection(t *testing.T) { + dir := t.TempDir() + refDir := filepath.Join("references", "imports", "ns", "id@1.0.0") + e := &cache.Entry{Files: []cache.File{ + {Name: "good.yaml", Data: []byte("id: good\n")}, + {Name: "../../../../evil.yaml", Data: []byte("tampered\n")}, + }} + err := writeReference(dir, refDir, e, io.Discard) + require.Error(t, err) + require.NoFileExists(t, filepath.Join(dir, refDir, "good.yaml"), + "names are validated before anything is written — no orphaned partial output") + require.NoFileExists(t, filepath.Join(dir, "evil.yaml")) +} + +// TestUnpack_References_OfflineWhenDiscoveryDown guards the lazy-discovery fix: +// with the hub's discovery endpoint failing (503) but the primary and its +// reference both cached, unpack must still resolve the reference — discovery is +// only needed for a registry pull, which a cache hit never performs. (The +// best-effort license heal for the unchecked cached entry DOES attempt a hub +// lookup here and fails fast with a diagnostic; that must not block anything.) +func TestUnpack_References_OfflineWhenDiscoveryDown(t *testing.T) { + c := tempCache(t) + workdir := isolatedWorkdir(t) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.URL.Path, "/v1/catalogs/") { + http.Error(w, "not found", http.StatusNotFound) // primary license baseline: best-effort + return + } + http.Error(w, "discovery down", http.StatusServiceUnavailable) + })) + t.Cleanup(srv.Close) + host := hostOf(srv.URL) + + primary := &bundle.Bundle{Files: []bundle.File{{Name: "catalog.yaml", Data: []byte(refBearingCatalogYAML)}}} + putBundle(c, host, "myorg", "mycat", "1.0.0", primary, io.Discard) + ref := &bundle.Bundle{Files: []bundle.File{{Name: "baseline.yaml", Data: []byte("id: baseline\n")}}, Etag: "sha256:ref"} + refEntry, err := entryFromBundle(ref, "", "https://grc.store/acme/baseline") + require.NoError(t, err) + require.NoError(t, c.Put(host, "acme", "baseline", "2.1.0", refEntry)) + + output := filepath.Join(workdir, "unpacked") + out := runRoot(t, "unpack", "--url", srv.URL, "--repository", "myorg/mycat", + "--version", "1.0.0", "--with-imports", "--no-verify", "--output", output) + require.Contains(t, out, "resolved 1 reference(s), skipped 0", + "a cached reference must resolve even when discovery is unreachable") + require.FileExists(t, filepath.Join(output, "references", "imports", "acme", "baseline@2.1.0", "baseline.yaml")) +} + +func TestWriteReference(t *testing.T) { + dir := t.TempDir() + e := &cache.Entry{ + Files: []cache.File{{Name: "a.yaml", Data: []byte("id: a\n")}, {Name: "b.yaml", Data: []byte("id: b\n")}}, + Manifest: []byte(`{"bundle-version":"1"}`), + } + require.NoError(t, writeReference(dir, filepath.Join("references", "imports", "ns", "id@1.0.0"), e, io.Discard)) + base := filepath.Join(dir, "references", "imports", "ns", "id@1.0.0") + for name, want := range map[string]string{"a.yaml": "id: a\n", "b.yaml": "id: b\n", "bundle.json": `{"bundle-version":"1"}`} { + got, err := os.ReadFile(filepath.Join(base, name)) + require.NoError(t, err, name) + require.Equal(t, want, string(got), name) + } +} + +func TestReferenceContentDigest(t *testing.T) { + manifest := []byte(`{"bundle-version":"1"}`) + withManifest := &cache.Entry{Files: []cache.File{{Name: "a", Data: []byte("x")}}, Manifest: manifest} + require.Equal(t, cache.Digest(manifest), referenceContentDigest(withManifest), + "manifest digest identifies the whole bundle") + + single := &cache.Entry{Files: []cache.File{{Name: "a", Data: []byte("only")}}} + require.Equal(t, cache.Digest([]byte("only")), referenceContentDigest(single)) + + // Multi-file, no manifest: a deterministic combined digest, never "". + multiNoManifest := &cache.Entry{Files: []cache.File{{Name: "a", Data: []byte("x")}, {Name: "b", Data: []byte("y")}}} + d := referenceContentDigest(multiNoManifest) + require.NotEmpty(t, d, "index must always carry a content digest") + require.Equal(t, d, referenceContentDigest(multiNoManifest), "digest must be deterministic") + reordered := &cache.Entry{Files: []cache.File{{Name: "b", Data: []byte("y")}, {Name: "a", Data: []byte("x")}}} + require.NotEqual(t, d, referenceContentDigest(reordered), "digest is over the ordered file list") + + require.Equal(t, "", referenceContentDigest(&cache.Entry{}), "no files, no manifest: nothing to digest") +} + +func TestRefRelPath(t *testing.T) { + refDir := filepath.Join("references", "imports", "acme", "baseline@2.1.0") + + good, err := refRelPath(refDir, "controls.yaml") + require.NoError(t, err) + require.Equal(t, filepath.Join(refDir, "controls.yaml"), good) + + // Nested names are fine as long as they stay inside refDir. + nested, err := refRelPath(refDir, filepath.Join("sub", "extra.yaml")) + require.NoError(t, err) + require.Equal(t, filepath.Join(refDir, "sub", "extra.yaml"), nested) + + for _, hostile := range []string{ + "", + "..", + "../../../../controls.yaml", // climbs out to the output root + "../sibling.yaml", // climbs into another reference's dir + filepath.Join("sub", "..", "..", "escape.yaml"), + ".", + } { + _, err := refRelPath(refDir, hostile) + require.Error(t, err, "name %q must be rejected", hostile) + } +} + +// TestWriteReference_RejectsTraversal is the end-to-end guard for the +// remote-controlled-name traversal: a reference bundle whose file name climbs +// out of its own directory must be rejected, and nothing outside refDir +// written. +func TestWriteReference_RejectsTraversal(t *testing.T) { + dir := t.TempDir() + e := &cache.Entry{Files: []cache.File{ + {Name: "../../../../controls.yaml", Data: []byte("tampered\n")}, + }} + err := writeReference(dir, filepath.Join("references", "imports", "ns", "id@1.0.0"), e, io.Discard) + require.Error(t, err) + require.NoFileExists(t, filepath.Join(dir, "controls.yaml"), + "the traversal target must not have been written") +} + +// TestEntryFromBundle_DropsImports documents that the v2 cache entry stores +// Files + manifest only — a referenced bundle's own transitive imports are not +// represented (reference resolution is direct-only, ADR-0039). fetchReference +// warns via noteDroppedReferenceImports rather than dropping them silently. +func TestEntryFromBundle_DropsImports(t *testing.T) { + b := &bundle.Bundle{ + Files: []bundle.File{{Name: "controls.yaml", Data: []byte("id: a\n")}}, + Imports: []bundle.File{{Name: "dep.yaml", Data: []byte("id: dep\n")}}, + } + e, err := entryFromBundle(b, "", "") + require.NoError(t, err) + require.Len(t, e.Files, 1, "only the artifact files are stored") + require.Equal(t, "controls.yaml", e.Files[0].Name) +} + +func TestNoteDroppedReferenceImports(t *testing.T) { + var buf strings.Builder + noteDroppedReferenceImports(&buf, "acme", "baseline", "2.1.0", 2) + require.Contains(t, buf.String(), "acme/baseline@2.1.0") + require.Contains(t, buf.String(), "2 transitive import") + require.Contains(t, buf.String(), "not materialized") +} + +func TestUserSuppliedRegistryCredential(t *testing.T) { + t.Setenv("GRCLI_REGISTRY_TOKEN", "") + t.Setenv("GRCLI_REGISTRY_USERNAME", "") + t.Setenv("GRCLI_REGISTRY_PASSWORD", "") + require.False(t, userSuppliedRegistryCredential()) + + t.Setenv("GRCLI_REGISTRY_TOKEN", "tok") + require.True(t, userSuppliedRegistryCredential()) + + t.Setenv("GRCLI_REGISTRY_TOKEN", "") + t.Setenv("GRCLI_REGISTRY_USERNAME", "u") + require.False(t, userSuppliedRegistryCredential(), "username alone is not a complete basic-auth credential") + t.Setenv("GRCLI_REGISTRY_PASSWORD", "p") + require.True(t, userSuppliedRegistryCredential()) +} + +func TestMintRefPullToken(t *testing.T) { + // userCreds=true: the user's credential wins; we must not mint or touch env. + t.Setenv("GRCLI_REGISTRY_TOKEN", "user-token") + require.NoError(t, mintRefPullToken(context.Background(), "https://hub.invalid.test", "ns/id", true)) + require.Equal(t, "user-token", os.Getenv("GRCLI_REGISTRY_TOKEN"), "user credential must be left untouched") + + // userCreds=false: mint a fresh per-repo token from the hub and export it, + // even though GRCLI_REGISTRY_TOKEN is already set to a different repo's token. + tokenSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/v2/token", r.URL.Path) + require.Contains(t, r.URL.RawQuery, "repository%3Ans%2Fid%3Apull", "scope must target the reference repo") + _ = json.NewEncoder(w).Encode(map[string]string{"token": "fresh-ref-token"}) + })) + t.Cleanup(tokenSrv.Close) + t.Setenv("GRCLI_REGISTRY_TOKEN", "stale-primary-token") + require.NoError(t, mintRefPullToken(context.Background(), tokenSrv.URL, "ns/id", false)) + require.Equal(t, "fresh-ref-token", os.Getenv("GRCLI_REGISTRY_TOKEN"), + "a fresh per-repo token must replace the stale one") +} diff --git a/cmd/regtoken.go b/cmd/regtoken.go new file mode 100644 index 0000000..ca83694 --- /dev/null +++ b/cmd/regtoken.go @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "context" + "os" + + "github.com/revanite-io/grcli/internal/hub" +) + +// ensureRegistryToken makes grcli authenticate to the bearer-auth +// registry (ADR-0031) without the caller managing registry credentials: +// it fetches a repository-scoped Distribution token from the hub's +// /v2/token endpoint and exports it as GRCLI_REGISTRY_TOKEN, which both +// the oras push/pull path (internal/registry.dockerCredentials) and the +// cosign subprocess (internal/sign.registryCredArgs) already read. +// +// It is a no-op — returning whatever the user supplied — when: +// - a registry credential is already set explicitly (GRCLI_REGISTRY_TOKEN +// or the GRCLI_REGISTRY_USERNAME/PASSWORD pair, or a `docker login` +// the caller wants honored); manual overrides win, and +// - there is no hub base URL to ask (--url explicitly cleared), in which +// case grcli falls back to the Docker credential chain. +// +// bearer is the hub (Keycloak) access token from `grcli login`; pass "" +// for an anonymous pull token. The returned token is also handed back so +// callers that must pass it as an explicit flag (cosign verify) can. +// +// Note: the exported token is scoped to one repository. `unpack --with-*` +// resolves references from other repositories in the same invocation and mints +// a fresh per-repo token for each via mintRefPullToken (which deliberately does +// NOT reuse the already-exported token, since it is scoped to a different repo); +// this function still governs the primary artifact and the user-override rules. +func ensureRegistryToken(ctx context.Context, hubBaseURL, bearer, repository string, actions []string) (string, error) { + if tok := os.Getenv("GRCLI_REGISTRY_TOKEN"); tok != "" { + return tok, nil + } + if os.Getenv("GRCLI_REGISTRY_USERNAME") != "" && os.Getenv("GRCLI_REGISTRY_PASSWORD") != "" { + return "", nil // explicit basic-auth override; leave the chain alone + } + if hubBaseURL == "" { + return "", nil // no hub to ask; fall back to the Docker credential chain + } + + tok, err := hub.FetchRegistryToken(ctx, hubBaseURL, bearer, repository, actions) + if err != nil { + return "", err + } + if tok != "" { + _ = os.Setenv("GRCLI_REGISTRY_TOKEN", tok) + } + return tok, nil +} diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..fa65aca --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,182 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Package cmd wires the grcli cobra/viper CLI. +package cmd + +import ( + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/gemaraproj/grc-store-clientkit/auth" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +// version is overwritten at build time via -ldflags. +var version = "dev" + +// Execute is the package entry point called by main(). It builds a fresh +// command tree and viper instance for each invocation, which keeps tests +// from leaking state through package-level singletons. +func Execute() error { + return newRootCmd().Execute() +} + +// newRootCmd assembles the root command and the viper instance shared +// with its subcommands. The viper instance is populated by the root's +// PersistentPreRunE so subcommands see config + env values before their +// RunE fires. +func newRootCmd() *cobra.Command { + v := viper.New() + var cfgFile string + + cmd := &cobra.Command{ + Use: "grcli", + Short: "Validate, publish, unpack, and verify Gemara artifact bundles against grc.store", + SilenceUsage: true, + SilenceErrors: true, + Version: version, + CompletionOptions: cobra.CompletionOptions{DisableDefaultCmd: true}, + // Cobra does NOT chain PersistentPreRunE: if a subcommand defines + // its own, this one is silently skipped. If you add a subcommand + // with its own PersistentPreRunE, call loadConfig from there too + // (or refactor to a withConfig wrapper around RunE). + PersistentPreRunE: func(c *cobra.Command, _ []string) error { + return loadConfig(v, cfgFile, c.ErrOrStderr()) + }, + } + cmd.PersistentFlags().StringVar(&cfgFile, "config", "", + "config file (default: $XDG_CONFIG_HOME/grcli/config.yaml, or ~/.config/grcli/config.yaml)") + + cmd.AddCommand(newPublishCmd(v)) + cmd.AddCommand(newUnpackCmd(v)) + cmd.AddCommand(newCatCmd(v)) + cmd.AddCommand(newValidateCmd(v)) + cmd.AddCommand(newVerifyCmd(v)) + cmd.AddCommand(newVersionsCmd(v)) + cmd.AddCommand(newLoginCmd(v)) + cmd.AddCommand(newLogoutCmd(v)) + return cmd +} + +// flagCacheEnabled is the config key (ADR-0043) that durably turns the artifact +// cache off (equivalent to passing --no-cache on every command). Default true. +// It is a FLAT key, not nested `cache.enabled`, on purpose: the $GRCLI_CACHE +// location env var (ADR-0039) shadows the whole `cache.*` namespace under +// viper's AutomaticEnv, which would mask a nested key's default and file value +// whenever $GRCLI_CACHE is set. The env form is GRCLI_CACHE_ENABLED. +const flagCacheEnabled = "cache-enabled" + +// grcliApp identifies this CLI to grc-store-clientkit: it picks the credential +// file under ${XDG_DATA_HOME:-~/.local/share}/grcli and names grcli (not pvtr, +// the other consumer of that module) in every "run `grcli login`" hint. +// +// GRCLI_TOKEN is named here for those messages; viper's GRCLI env prefix +// already merges the variable into the --token flag, so it reaches Resolve as +// an explicit token before the module's own environment lookup runs. +// TokenFlag is set because grcli registers --token (see publish.go): from +// clientkit v0.1.1 the flag is named in no-token errors only when the tool +// declares it, so omitting this would drop a fix the user can actually apply. +var grcliApp = auth.App{Name: "grcli", TokenEnv: "GRCLI_TOKEN", TokenFlag: "--token"} + +// loadConfig wires the GRCLI_* env prefix and reads the single user-global +// config file (ADR-0043, amended by ADR-0044). Precedence, highest first: +// explicit flag > GRCLI_* env > user-global $XDG_CONFIG_HOME/grcli/config.yaml +// (fallback ~/.config/grcli/config.yaml) > built-in default. There is NO +// per-project layer: a repo-local ./.grcli.yaml is deliberately not read +// (ADR-0044) — a committed config file steering a publish/verify tool is a +// footgun — so a present one earns a migration warning instead. --config +// selects a single file and bypasses the search. A missing file is not +// an error; any other read error is a warning (on the command's stderr) so the +// command still runs on env + flags. +func loadConfig(v *viper.Viper, cfgFile string, warn io.Writer) error { + v.SetConfigType("yaml") + v.SetEnvPrefix("GRCLI") + v.SetEnvKeyReplacer(strings.NewReplacer("-", "_", ".", "_")) + v.AutomaticEnv() + v.SetDefault(flagCacheEnabled, true) + + if cfgFile != "" { + v.SetConfigFile(cfgFile) + if err := v.ReadInConfig(); err != nil { + fmt.Fprintln(warn, "grcli: warning: reading config:", err) + } + return nil + } + + g := userGlobalConfigPath() + if g == "" { + return nil // home dir unresolved — run on env + flags only + } + warnIgnoredConfig(g, warn) + if fileExists(g) { + v.SetConfigFile(g) + if err := v.ReadInConfig(); err != nil { + fmt.Fprintln(warn, "grcli: warning: reading user config:", err) + } + } + return nil +} + +// projectConfigFile is the repo-local config path. As of ADR-0044 grcli no +// longer reads it; the constant remains so warnIgnoredConfig can nudge anyone +// migrating from the per-project layer to the user-global file. +const projectConfigFile = ".grcli.yaml" + +// warnIgnoredConfig warns about config files sitting at locations grcli no +// longer reads, so a settings file isn't silently ignored after a layout +// change. Retired locations: the per-project ./.grcli.yaml (ADR-0044) and the +// pre-ADR-0043 dotfiles (~/.grcli.yaml and $XDG_CONFIG_HOME/grcli/.grcli.yaml). +// The only blessed location is the user-global config.yaml (globalPath). +func warnIgnoredConfig(globalPath string, w io.Writer) { + // Each candidate carries a display path (friendly, e.g. relative + // ./.grcli.yaml) and an absolute path used only for dedup — running grcli + // from $HOME makes ./.grcli.yaml and ~/.grcli.yaml the same file, which + // must warn once, not twice. + type candidate struct{ display, abs string } + candidates := []candidate{} + if abs, err := filepath.Abs(projectConfigFile); err == nil { + candidates = append(candidates, candidate{projectConfigFile, abs}) + } + // The pre-0043 search only looked inside $XDG_CONFIG_HOME/grcli when XDG + // was set; with XDG unset, ~/.config/grcli was never a search path. + if os.Getenv("XDG_CONFIG_HOME") != "" { + p := filepath.Join(filepath.Dir(globalPath), ".grcli.yaml") + candidates = append(candidates, candidate{p, p}) + } + if home, err := os.UserHomeDir(); err == nil { + p := filepath.Join(home, ".grcli.yaml") + candidates = append(candidates, candidate{p, p}) + } + seen := map[string]bool{} + for _, c := range candidates { + if seen[c.abs] || !fileExists(c.abs) { + continue + } + seen[c.abs] = true + fmt.Fprintf(w, "grcli: warning: ignoring config %s — grcli reads only %s; move your settings there\n", c.display, globalPath) + } +} + +// userGlobalConfigPath is the per-user config file: $XDG_CONFIG_HOME/grcli/ +// config.yaml, falling back to ~/.config/grcli/config.yaml. Empty if the home +// directory can't be resolved. +func userGlobalConfigPath() string { + base := os.Getenv("XDG_CONFIG_HOME") + if base == "" { + home, err := os.UserHomeDir() + if err != nil { + return "" + } + base = filepath.Join(home, ".config") + } + return filepath.Join(base, "grcli", "config.yaml") +} + +func fileExists(path string) bool { + info, err := os.Stat(path) + return err == nil && !info.IsDir() +} diff --git a/cmd/unpack.go b/cmd/unpack.go new file mode 100644 index 0000000..d17ddfe --- /dev/null +++ b/cmd/unpack.go @@ -0,0 +1,758 @@ +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + neturl "net/url" + "os" + "path/filepath" + "strings" + "time" + + "github.com/gemaraproj/go-gemara/bundle" + "github.com/spf13/cobra" + "github.com/spf13/viper" + + "github.com/revanite-io/grcli/internal/cache" + "github.com/revanite-io/grcli/internal/hub" + "github.com/revanite-io/grcli/internal/refs" + "github.com/revanite-io/grcli/internal/registry" + "github.com/revanite-io/grcli/internal/sigverify" +) + +const ( + flagSource = "source" + // flagVersion is the published artifact's metadata.version, which is + // also its OCI tag (ADR-0033 guarantees they're the same). Shared with + // verify.go. + flagVersion = "version" + + // Reference-resolution flags (ADR-0039). + flagWithImports = "with-imports" + flagWithReferences = "with-references" + flagNoCache = "no-cache" + + // flagNoVerify opts out of the default pre-unpack signature verification + // (ADR-0048). flagCertIdentity / flagCertOIDCIssuer are defined in verify.go + // and reused here so an unpack can assert an identity instead of trusting the + // hub-recorded one. + flagNoVerify = "no-verify" +) + +func newUnpackCmd(v *viper.Viper) *cobra.Command { + cmd := &cobra.Command{ + Use: "unpack", + Short: "Extract a Gemara bundle from a local OCI layout or remote registry", + Long: `Reads a Gemara bundle and writes its artifact files to a directory. +The bundle manifest, including any SLSA-shaped provenance record, is +written alongside as bundle.json. + +The source can be a local OCI image layout (--source, the shape produced +by 'grcli publish --dry-run') or a remote registry discovered from the +hub (--url plus --repository). Exactly one of --source / --url must be set. + +Verification (ADR-0048): a remote (--url) unpack VERIFIES the artifact's +Sigstore signature in-process before writing anything, and fails closed — +an unsigned, mis-signed, or unverifiable artifact is refused and no files +are written. This is the same check as 'grcli verify': zero-flag against +the identity the hub recorded at ingest, or --certificate-identity to +assert the signer yourself and bypass the hub. Pass --no-verify to write +without verifying (INSECURE). A local --source layout has no registry +signature to check, so it is always written without verification. + +Caching (ADR-0042): a remote (--url) fetch is served from a global on-disk +cache when the same namespace/id/version has been fetched before — a cache +hit for the artifact bytes needs no network. grc.store tags are immutable, +so a hit can never be stale. (Best-effort exception: resolving references +makes short-deadline hub lookups for LICENSE METADATA only — the primary's +license baseline, and a one-time lookup for any cached reference whose +license was never confirmed; failures are reported and never block.) Set +$GRCLI_CACHE to relocate the cache; pass --no-cache to force a fresh pull of +the primary artifact (and references) and persist nothing. --source reads +are local and never cached. + +Registry auth flows through the same Docker credential chain and +GRCLI_REGISTRY_USERNAME / GRCLI_REGISTRY_PASSWORD / GRCLI_REGISTRY_TOKEN +overrides as 'grcli publish'. + +Resolving references (ADR-0039): with --with-imports (the artifact's +'imports') or --with-references (every mapping reference it declares), +grcli also pulls the referenced grc.store artifacts into references/ +//@, alongside a references/index.json record. +A reference whose host is 'grc.store' resolves against your --url target +(so the same reference works against prod, staging, or a local proxy); a +reference to any other host is reported and skipped. Resolution needs a +hub target, so pass --url. Pulled artifacts are cached globally (set +$GRCLI_CACHE to override the location); --no-cache bypasses the cache. +Note: the verification above covers the PRIMARY artifact; pulled references +(--with-imports / --with-references) are NOT signature-verified yet — that +is a forthcoming follow-up. A license that differs from the primary's is +reported as a warning, not an error. + +Examples: + # From a local 'publish --dry-run' output + grcli unpack --source ./grcli-out --version 1.0.0 + + # From a remote registry (via hub discovery) + grcli unpack --url https://hub.grc.store \ + --repository myorg/my-controls --version 1.0.0 + + # Pull the artifact AND the catalogs it imports + grcli unpack --url https://hub.grc.store \ + --repository myorg/my-controls --version 1.0.0 --with-imports`, + RunE: func(cmd *cobra.Command, _ []string) error { + return runUnpack(cmd, v) + }, + } + + flags := cmd.Flags() + flags.String(flagSource, "", "OCI image layout directory (mutually exclusive with --url)") + flags.String(flagURL, defaultURL, "grc.store base URL (discovers the registry)") + flags.String(flagRepository, "", "repository path within the registry (requires --url)") + flags.String(flagVersion, "", "artifact version to unpack — the metadata.version of the published bundle (required)") + flags.String(flagOutput, "grcli-unpacked", "directory to write extracted files to") + flags.Bool(flagWithImports, false, "also resolve and pull the artifact's `imports` references from the hub (requires --url)") + flags.Bool(flagWithReferences, false, "also resolve and pull ALL of the artifact's mapping references from the hub (requires --url); superset of --with-imports") + flags.Bool(flagNoCache, false, "bypass the local artifact cache for this run (primary + references); set cache-enabled: false in config to disable it durably") + flags.Bool(flagNoVerify, false, "write without verifying the artifact's signature (INSECURE; ADR-0048) — the default verifies and fails closed") + flags.String(flagCertIdentity, "", "verify against this exact signer identity instead of the hub-recorded one (bypasses the hub lookup)") + flags.String(flagCertOIDCIssuer, "", "expected OIDC issuer for --certificate-identity (default: https://token.actions.githubusercontent.com)") + + // Bind at RunE time, not here — see comment in newPublishCmd. + return cmd +} + +func runUnpack(cmd *cobra.Command, v *viper.Viper) error { + if err := v.BindPFlags(cmd.Flags()); err != nil { + return fmt.Errorf("binding flags: %w", err) + } + // A bare `grcli unpack --source ...` would otherwise collide with the + // bake-in --url default; suppress the default so --source alone is not + // read as "both --source and --url". + suppressDefaultURLIfExplicit(cmd, v, flagSource) + ctx := cmd.Context() + + version := v.GetString(flagVersion) + output := v.GetString(flagOutput) + out := cmd.OutOrStdout() + + // Capture whether the user supplied an explicit registry credential BEFORE + // any pull mints and exports one. Reference resolution mints a fresh token + // per referenced repository (ADR-0031 tokens are per-namespace), and must + // only do so when the user hasn't provided their own credential — which is + // no longer detectable once resolveBundle has exported a primary token. + userCreds := userSuppliedRegistryCredential() + + // Shared fetch stage (cache-checking pull), identical to `cat`; unpack's + // last mile is writing the directory. + unpacked, refLabel, err := resolveBundle(ctx, v, out) + if err != nil { + return err + } + + // Verify the signature BEFORE writing anything (ADR-0048). Fail closed: + // a rejected artifact returns here, so os.MkdirAll/writeBundle never run + // and the output directory is not created. + switch planUnpackVerify(v.GetString(flagSource), v.GetBool(flagNoVerify)) { + case unpackVerify: + if err := verifyBeforeUnpack(ctx, v, out); err != nil { + return err + } + case unpackSkipSource: + fmt.Fprintln(out, " ! --source is a local OCI layout with no registry signature to verify; writing WITHOUT verification") + case unpackSkipNoVerify: + fmt.Fprintln(out, " ! WARNING: --no-verify set — writing WITHOUT signature verification; the artifact's provenance is NOT checked") + } + + if err := os.MkdirAll(output, 0o755); err != nil { + return fmt.Errorf("creating output dir: %w", err) + } + + fmt.Fprintf(out, "unpacked %s:%s → %s (%d files, %d imports)\n", + refLabel, version, output, len(unpacked.Files), len(unpacked.Imports)) + if err := writeBundle(unpacked, output, out); err != nil { + return err + } + + if mode, want := referenceMode(v); want { + return resolveReferences(ctx, v, mode, unpacked, output, userCreds, out) + } + return nil +} + +// unpackVerifyPlan is how unpack handles signature verification for one +// invocation, decided from the flags before any network work (ADR-0048). +type unpackVerifyPlan int + +const ( + unpackVerify unpackVerifyPlan = iota // verify before writing; fail closed + unpackSkipSource // --source: no registry referrer exists to verify against + unpackSkipNoVerify // --no-verify: caller opted out +) + +// planUnpackVerify decides whether unpack verifies, and if not, why. A local +// --source layout has no registry signature referrer, so it cannot be verified +// (this wins even if --no-verify is also set — the reason is just more +// specific); an explicit --no-verify opts out; otherwise unpack verifies and +// fails closed on an unsigned/mis-signed artifact. +func planUnpackVerify(source string, noVerify bool) unpackVerifyPlan { + switch { + case source != "": + return unpackSkipSource + case noVerify: + return unpackSkipNoVerify + default: + return unpackVerify + } +} + +// verifyBeforeUnpack verifies the artifact's Sigstore signature in-process +// (the ADR-0046 path) BEFORE any content is written (ADR-0048). It fails closed: +// an unsigned, mis-signed, or otherwise unverifiable artifact returns an error +// and unpack writes nothing. It reuses verify's exact policy resolution, so +// unpack and `grcli verify` apply identical trust — zero-flag against the +// hub-recorded identity, or an explicit --certificate-identity the caller +// asserts (bypassing the hub lookup). +func verifyBeforeUnpack(ctx context.Context, v *viper.Viper, out io.Writer) error { + policy, err := resolveVerifyPolicy(ctx, v) + if err != nil { + return fmt.Errorf("preparing verification: %w", err) + } + // Mint a pull token for the signature fetch. resolveBundle may have served + // the content from cache without minting one, so never assume it's exported. + policy.registryToken, err = ensureRegistryToken(ctx, v.GetString(flagURL), "", v.GetString(flagRepository), []string{"pull"}) + if err != nil { + return fmt.Errorf("fetching registry pull token: %w", err) + } + fmt.Fprintf(out, "verifying signature (%s)\n", policy.modeDescription()) + verifier, err := newSigstoreVerifier(v) + if err != nil { + return fmt.Errorf("initializing verifier: %w", err) + } + bundleJSON, artifactDigest, err := registry.FetchSignatureBundle(ctx, policy.registryHost, policy.repository, policy.version) + if err != nil { + return fmt.Errorf("discovering signature: %w", err) + } + res, err := verifier.Verify(ctx, bundleJSON, artifactDigest, policy.identityPolicy()) + if errors.Is(err, sigverify.ErrUnsigned) { + return fmt.Errorf("%s:%s has no signature in the registry — refusing to unpack unverified content "+ + "(re-run with --no-verify to override; ADR-0048)", policy.repository, policy.version) + } + if err != nil { + return fmt.Errorf("signature verification failed — refusing to unpack: %w", err) + } + fmt.Fprintf(out, "verified: %s\n", res.Identity) + return nil +} + +// referenceMode reads the --with-references / --with-imports flags. +// --with-references is the superset, so it wins when both are set. +func referenceMode(v *viper.Viper) (refs.Mode, bool) { + switch { + case v.GetBool(flagWithReferences): + return refs.AllReferences, true + case v.GetBool(flagWithImports): + return refs.ImportsOnly, true + default: + return 0, false + } +} + +// writeBundle writes the bundle's primary files, any imports (under an +// imports/ subdir to avoid collisions), and the bundle manifest as +// bundle.json. Filenames are path-cleaned and rejected if they try to +// escape the output directory. +func writeBundle(b *bundle.Bundle, dir string, out io.Writer) error { + for _, file := range b.Files { + name, err := safeWriteFile(dir, file.Name, file.Data) + if err != nil { + return err + } + fmt.Fprintf(out, " - %s\n", name) + } + if len(b.Imports) > 0 { + importsDir := filepath.Join(dir, "imports") + if err := os.MkdirAll(importsDir, 0o755); err != nil { + return fmt.Errorf("creating imports dir: %w", err) + } + for _, file := range b.Imports { + name, err := safeWriteFile(importsDir, file.Name, file.Data) + if err != nil { + return err + } + fmt.Fprintf(out, " - imports/%s\n", name) + } + } + if !b.Manifest.Empty() { + manifestBytes, err := json.MarshalIndent(b.Manifest, "", " ") + if err != nil { + return fmt.Errorf("encoding manifest: %w", err) + } + if err := os.WriteFile(filepath.Join(dir, "bundle.json"), manifestBytes, 0o644); err != nil { + return fmt.Errorf("writing manifest: %w", err) + } + fmt.Fprintln(out, " - bundle.json (bundle manifest)") + } + return nil +} + +// safeWriteFile writes data to dir/name, rejecting names that would +// escape dir via "..", absolute paths, or other traversal tricks. +// Returns the path-cleaned name (relative to dir) on success. +func safeWriteFile(dir, name string, data []byte) (string, error) { + if name == "" { + return "", errors.New("bundle file has empty name") + } + clean := filepath.Clean(name) + if filepath.IsAbs(clean) || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) { + return "", fmt.Errorf("unsafe bundle file name %q", name) + } + path := filepath.Join(dir, clean) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return "", err + } + if err := os.WriteFile(path, data, 0o644); err != nil { + return "", err + } + return clean, nil +} + +// refIndexEntry is one row of references/index.json — a record of a resolved +// reference's provenance, written so a consumer (or a later verify-on-pull +// pass) knows exactly what was pulled and from where. +type refIndexEntry struct { + Category string `json:"category"` + Namespace string `json:"namespace"` + CatalogID string `json:"catalog_id"` + Version string `json:"version"` + SourceURL string `json:"source_url"` + ManifestDigest string `json:"manifest_digest,omitempty"` + ContentDigest string `json:"content_digest"` + License string `json:"license,omitempty"` + Verified bool `json:"verified"` + Path string `json:"path"` +} + +// resolveReferences walks the unpacked artifact's mapping references and pulls +// the ones that point at the targeted hub into references// alongside +// the primary (ADR-0039). It is best-effort: an unrecognized host, a not-found, +// or a fetch error is reported and skipped, never fatal. +func resolveReferences(ctx context.Context, v *viper.Viper, mode refs.Mode, b *bundle.Bundle, output string, userCreds bool, out io.Writer) error { + url := v.GetString(flagURL) + repository := v.GetString(flagRepository) + version := v.GetString(flagVersion) + + // Gather selected references across the primary file(s). + var selected []refs.Selected + for _, f := range b.Files { + a, err := refs.Scan(f.Data) + if err != nil { + fmt.Fprintf(out, " ! could not read references in %s: %v\n", f.Name, err) + continue + } + for _, n := range a.Notes { + fmt.Fprintf(out, " note: %s\n", n) + } + selected = append(selected, a.Select(mode)...) + } + if len(selected) == 0 { + fmt.Fprintln(out, "no resolvable references declared in this artifact") + return nil + } + + // Resolution needs a hub target. The local (--source) path has no --url. + if url == "" { + fmt.Fprintf(out, "%d reference(s) declared, but resolution needs a hub target — re-run with --url\n", len(selected)) + return nil + } + targetHost := hostOf(url) + if targetHost == "" { + return fmt.Errorf("could not determine host from --url %q", url) + } + client := hub.New(url, "") + + // References pulled from the registry (ADR-0042 decision 5) need the registry + // host, discovered lazily on the FIRST cache miss so a fully-cached run stays + // offline. Memoized: at most one discovery per unpack, and a failure only + // skips the references that actually need a pull, not the cached ones. + var ( + regHost string + regErr error + regDone bool + ) + resolveRegistryHost := func() (string, error) { + if !regDone { + regDone = true + d, derr := hub.Discover(ctx, url) + if derr != nil { + regErr = fmt.Errorf("registry discovery: %w", derr) + } else { + regHost = d.RegistryURL + } + } + return regHost, regErr + } + + // The primary's own coordinate (for the self-reference guard and the + // license-mismatch baseline). Best-effort: a non-/ --repository + // just disables these niceties. + primaryNS, primaryID := splitRepository(repository) + primaryLicense := primaryLicenseBestEffort(ctx, client, primaryNS, primaryID, version) + + var c *cache.Cache + if cachingEnabled(v) { + cc, err := cache.Open() + if err != nil { + fmt.Fprintf(out, " ! cache unavailable, fetching without it: %v\n", err) + } else { + c = cc + } + } + + seen := make(map[string]bool) + var index []refIndexEntry + pulled, skipped := 0, 0 + + for _, s := range selected { + ns, id, ok, reason := refs.Recognize(s.URL, targetHost) + if !ok { + fmt.Fprintf(out, " - skip [%s] %s: %s\n", s.Category, s.URL, reason) + skipped++ + continue + } + coord := fmt.Sprintf("%s/%s@%s", ns, id, s.Version) + if seen[coord] { + continue + } + seen[coord] = true + if ns == primaryNS && id == primaryID && s.Version == version { + continue // the artifact references itself; already unpacked + } + + entry, err := fetchReference(ctx, fetchRefArgs{ + client: client, cache: c, registryHost: resolveRegistryHost, hubURL: url, + host: targetHost, ns: ns, id: id, version: s.Version, sourceURL: s.URL, + userCreds: userCreds, + }, out) + if err != nil { + fmt.Fprintf(out, " - skip [%s] %s: %v\n", s.Category, coord, err) + skipped++ + continue + } + if primaryLicense != "" && entry.License != "" && primaryLicense != entry.License { + fmt.Fprintf(out, " ! license: %s is %s but the primary is %s — review before reuse\n", + coord, entry.License, primaryLicense) + } + if len(entry.Files) == 0 { + fmt.Fprintf(out, " - skip [%s] %s: reference bundle has no files\n", s.Category, coord) + skipped++ + continue + } + + // A reference is a full bundle, written to its own directory (like the + // primary unpack): the artifact file(s) plus bundle.json (ADR-0042). + refDir := filepath.Join("references", s.Category, ns, fmt.Sprintf("%s@%s", id, s.Version)) + if err := writeReference(output, refDir, entry, out); err != nil { + fmt.Fprintf(out, " - skip [%s] %s: %v\n", s.Category, coord, err) + skipped++ + continue + } + index = append(index, refIndexEntry{ + Category: s.Category, + Namespace: ns, + CatalogID: id, + Version: s.Version, + SourceURL: s.URL, + ManifestDigest: entry.ManifestDigest, + ContentDigest: referenceContentDigest(entry), + License: entry.License, + Verified: entry.Verified, + Path: refDir, + }) + pulled++ + } + + if len(index) > 0 { + indexBytes, err := json.MarshalIndent(index, "", " ") + if err != nil { + return fmt.Errorf("encoding references index: %w", err) + } + if err := os.WriteFile(filepath.Join(output, "references", "index.json"), indexBytes, 0o644); err != nil { + return fmt.Errorf("writing references index: %w", err) + } + fmt.Fprintln(out, " - references/index.json") + } + fmt.Fprintf(out, "resolved %d reference(s), skipped %d\n", pulled, skipped) + return nil +} + +// fetchRefArgs bundles the inputs to fetchReference (a positional list would be +// error-prone at this width). +type fetchRefArgs struct { + client *hub.Client + cache *cache.Cache + // registryHost lazily resolves the OCI registry host, so a cache hit never + // triggers discovery (offline-capable). Called only on a cache miss. + registryHost func() (string, error) + hubURL string + host string // cache host key (the hub host) + ns, id string + version string + sourceURL string + userCreds bool +} + +// fetchReference returns a reference as a full bundle, from the cache when +// present and uncorrupted, otherwise by pulling the whole bundle from the +// registry (ADR-0042 decision 5) and, unless --no-cache, caching it. The +// per-version license is read from the hub for the license-mismatch warning and +// recorded on the entry. Verification is deferred (ADR-0039 amendment), so the +// entry is recorded as unverified. +func fetchReference(ctx context.Context, a fetchRefArgs, out io.Writer) (*cache.Entry, error) { + if a.cache != nil { + e, found, err := a.cache.Get(a.host, a.ns, a.id, a.version) + if err != nil { + fmt.Fprintf(out, " ! cache: %v (re-fetching)\n", err) + } else if found { + // An entry can lack a license: a primary fetch caches with license + // "" (it makes no catalog lookup), and a reference cached during a + // hub outage recorded "" too. Heal on hit — look the license up + // live and upgrade the entry in place — but at most once: a + // SUCCESSFUL lookup sets LicenseChecked even when the catalog + // genuinely records no license, so a license-less coordinate does + // not pay a hub call on every future hit. Only a FAILED lookup + // leaves LicenseChecked unset for a retry on the next run. + if e.License == "" && !e.LicenseChecked { + if license, ok := referenceLicense(ctx, a.client, a.ns, a.id, a.version, out); ok { + e.License = license + e.LicenseChecked = true + if perr := a.cache.Put(a.host, a.ns, a.id, a.version, *e); perr != nil { + fmt.Fprintf(out, " ! cache write failed (continuing): %v\n", perr) + } + } + } + return e, nil + } + } + + // License is best-effort hub metadata for the mismatch warning; a lookup + // failure doesn't block the pull (the bundle stands on its own) and does + // not poison the cache permanently — the hit path above retries unchecked + // entries (once per run) until a lookup succeeds. + license, licenseChecked := referenceLicense(ctx, a.client, a.ns, a.id, a.version, out) + + registryHost, err := a.registryHost() + if err != nil { + return nil, err + } + repo := a.ns + "/" + a.id + if err := mintRefPullToken(ctx, a.hubURL, repo, a.userCreds); err != nil { + return nil, fmt.Errorf("fetching registry pull token: %w", err) + } + b, err := registry.UnpackRemote(ctx, registryHost, repo, a.version) + if err != nil { + return nil, err + } + // Reference resolution is direct-only (ADR-0039): if the referenced bundle + // carries its own transitive imports, we neither materialize nor cache them + // (the v2 entry stores Files + manifest only). Say so rather than dropping + // them silently. + if len(b.Imports) > 0 { + noteDroppedReferenceImports(out, a.ns, a.id, a.version, len(b.Imports)) + } + e, err := entryFromBundle(b, license, a.sourceURL) + if err != nil { + return nil, err + } + e.LicenseChecked = licenseChecked + // Persist for reuse, unless the bundle carries the dormant Imports slot the + // v2 entry format can't represent (see putBundle) — then serve, don't cache. + if a.cache != nil && len(b.Imports) == 0 { + if err := a.cache.Put(a.host, a.ns, a.id, a.version, e); err != nil { + fmt.Fprintf(out, " ! cache write failed (continuing): %v\n", err) + } + } + return &e, nil +} + +// referenceLicense looks up a reference's per-version publication license from +// the hub for the license-mismatch warning and references/index.json. ok=false +// means the LOOKUP failed (license unknown — reported, since a silently-missing +// license suppresses the mismatch warning); ok=true with license "" means the +// catalog genuinely records none for that version. The call is best-effort +// metadata, so it gets a short deadline: it must never stall a resolution that +// is otherwise served from cache. +func referenceLicense(ctx context.Context, client *hub.Client, ns, id, version string, out io.Writer) (license string, ok bool) { + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + cat, err := client.GetCatalog(ctx, ns, id) + if err != nil { + fmt.Fprintf(out, " ! license lookup failed for %s/%s@%s (mismatch warning unavailable): %v\n", + ns, id, version, err) + return "", false + } + if rel := cat.ReleaseFor(version); rel != nil { + return rel.License, true + } + return "", true +} + +// noteDroppedReferenceImports warns that a referenced bundle carries its own +// transitive imports, which grcli does not materialize: reference resolution is +// direct-only (ADR-0039), and the v2 cache stores Files + manifest only. +func noteDroppedReferenceImports(out io.Writer, ns, id, version string, n int) { + fmt.Fprintf(out, " ! %s/%s@%s carries %d transitive import(s) — not materialized (direct-only resolution)\n", + ns, id, version, n) +} + +// writeReference writes a reference bundle's files (and bundle.json, when +// present) into refDir under output. +func writeReference(output, refDir string, e *cache.Entry, out io.Writer) error { + // File names come from the REMOTE bundle. safeWriteFile only guards escape + // from the output root, so a name with ../ segments could climb out of + // refDir and overwrite the primary's files. Validate EVERY name before + // writing ANY, so a hostile name later in the list can't leave earlier + // files orphaned on disk when the reference is rejected. + rels := make([]string, len(e.Files)) + for i, f := range e.Files { + rel, err := refRelPath(refDir, f.Name) + if err != nil { + return err + } + rels[i] = rel + } + for i, f := range e.Files { + written, err := safeWriteFile(output, rels[i], f.Data) + if err != nil { + return err + } + fmt.Fprintf(out, " - %s\n", written) + } + if len(e.Manifest) > 0 { + written, err := safeWriteFile(output, filepath.Join(refDir, "bundle.json"), e.Manifest) + if err != nil { + return err + } + fmt.Fprintf(out, " - %s\n", written) + } + return nil +} + +// refRelPath joins a reference bundle's file name onto the reference's own +// directory, rejecting any name whose cleaned path escapes (or resolves to) +// that directory — the name is remote-controlled, and without this check a +// ../-laden name could overwrite the primary's unpacked files elsewhere in +// the output tree. +func refRelPath(refDir, name string) (string, error) { + if name == "" { + return "", errors.New("reference file has empty name") + } + joined := filepath.Clean(filepath.Join(refDir, name)) + if joined == refDir || !strings.HasPrefix(joined, refDir+string(filepath.Separator)) { + return "", fmt.Errorf("unsafe reference file name %q", name) + } + return joined, nil +} + +// referenceContentDigest is the index's stable content identifier for a +// reference: the digest of its bundle.json manifest when present (the single +// document that captures the whole bundle), else the sole file's digest, else — +// for a multi-file bundle with no manifest — the digest of the ordered +// (name, per-file digest) list, so the index never records an empty +// content_digest (a later verify pass needs something to check against). The +// OCI identity is recorded separately as ManifestDigest. +func referenceContentDigest(e *cache.Entry) string { + switch { + case len(e.Manifest) > 0: + return cache.Digest(e.Manifest) + case len(e.Files) == 1: + return cache.Digest(e.Files[0].Data) + case len(e.Files) > 1: + var b strings.Builder + for _, f := range e.Files { + b.WriteString(f.Name) + b.WriteByte(0) + b.WriteString(cache.Digest(f.Data)) + b.WriteByte('\n') + } + return cache.Digest([]byte(b.String())) + default: + return "" + } +} + +// mintRefPullToken exports a pull token scoped to repo for the next registry +// pull. It bypasses ensureRegistryToken's "already set" short-circuit because +// GRCLI_REGISTRY_TOKEN may hold a token scoped to a DIFFERENT repo (the +// primary's, or a prior reference's) pulled earlier in this run. When the user +// supplied their own credential, that wins and we touch nothing. +func mintRefPullToken(ctx context.Context, hubURL, repo string, userCreds bool) error { + if userCreds || hubURL == "" { + return nil + } + tok, err := hub.FetchRegistryToken(ctx, hubURL, "", repo, []string{"pull"}) + if err != nil { + return err + } + if tok != "" { + _ = os.Setenv("GRCLI_REGISTRY_TOKEN", tok) + } + return nil +} + +// userSuppliedRegistryCredential reports whether the user set an explicit +// registry credential via env, captured before any pull mints its own token. +func userSuppliedRegistryCredential() bool { + if os.Getenv("GRCLI_REGISTRY_TOKEN") != "" { + return true + } + return os.Getenv("GRCLI_REGISTRY_USERNAME") != "" && os.Getenv("GRCLI_REGISTRY_PASSWORD") != "" +} + +// primaryLicenseBestEffort returns the primary artifact's publication license +// for the mismatch warning, or "" if it can't be determined (no hub baseline, +// then no warnings are emitted). Like referenceLicense, it is best-effort +// metadata on a short deadline: it runs on every reference resolution — even a +// fully cache-served one — and must never stall it. +func primaryLicenseBestEffort(ctx context.Context, client *hub.Client, ns, id, version string) string { + if ns == "" || id == "" { + return "" + } + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + cat, err := client.GetCatalog(ctx, ns, id) + if err != nil { + return "" + } + if rel := cat.ReleaseFor(version); rel != nil { + return rel.License + } + return "" +} + +// splitRepository splits an / repository path into its parts. A path +// that isn't exactly two segments yields empty strings (disabling the +// self-reference guard and license baseline rather than guessing). +func splitRepository(repository string) (ns, id string) { + parts := strings.Split(strings.Trim(repository, "/"), "/") + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return "", "" + } + return parts[0], parts[1] +} + +// hostOf returns the host of a hub base URL, tolerating a missing scheme. +func hostOf(rawURL string) string { + u, err := neturl.Parse(rawURL) + if err == nil && u.Host != "" { + return u.Host + } + // Scheme-less value (e.g. "hub.grc.store" or "hub.grc.store/x"): take the + // first path segment as the host. + return strings.Split(strings.TrimRight(rawURL, "/"), "/")[0] +} diff --git a/cmd/unpack_test.go b/cmd/unpack_test.go new file mode 100644 index 0000000..2d67455 --- /dev/null +++ b/cmd/unpack_test.go @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "testing" + + "github.com/spf13/viper" +) + +// TestPlanUnpackVerify pins the pre-network gating decision (ADR-0048): remote +// unpack verifies by default, --no-verify opts out, and a local --source layout +// can never be verified (and that reason wins even when --no-verify is also set). +func TestPlanUnpackVerify(t *testing.T) { + cases := []struct { + name string + source string + noVerify bool + want unpackVerifyPlan + }{ + {"remote default verifies", "", false, unpackVerify}, + {"no-verify opts out", "", true, unpackSkipNoVerify}, + {"source cannot be verified", "./layout", false, unpackSkipSource}, + {"source wins over no-verify", "./layout", true, unpackSkipSource}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := planUnpackVerify(tc.source, tc.noVerify); got != tc.want { + t.Fatalf("planUnpackVerify(%q, %v) = %d, want %d", tc.source, tc.noVerify, got, tc.want) + } + }) + } +} + +// TestUnpackReusesVerifyTrustFlags confirms unpack registers the keyless trust +// flags it shares with `grcli verify`, so an unpack can assert its own identity +// (bypassing the hub) exactly as verify does. A missing flag here would make +// verifyBeforeUnpack silently fall back to zero-flag mode and ignore the +// caller's asserted identity. +func TestUnpackReusesVerifyTrustFlags(t *testing.T) { + cmd := newUnpackCmd(viper.New()) + for _, name := range []string{flagNoVerify, flagCertIdentity, flagCertOIDCIssuer} { + if cmd.Flags().Lookup(name) == nil { + t.Errorf("unpack is missing the --%s flag", name) + } + } +} diff --git a/cmd/urldefault.go b/cmd/urldefault.go new file mode 100644 index 0000000..1c5f79f --- /dev/null +++ b/cmd/urldefault.go @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +// defaultURL is the bake-in target for grcli's `--url` flag. Until +// grcli grows private-hub adopters, the public grc.store API is the +// right default — most users running this tool today want it pointed +// at hub.grc.store, and asking them to remember the URL every time +// helps nobody. Override per invocation with `--url ` or per +// shell with `GRCLI_URL`. The discovery endpoint that backs `--url` +// (ADR-0026) is served by the hub at this URL, not the frontend at +// grc.store/ — the frontend Worker does not proxy /.well-known/ through. +const defaultURL = "https://hub.grc.store" + +// suppressDefaultURLIfExplicit nulls out the bake-in `--url` value in +// viper when the user has explicitly set one of the listed flags. This +// keeps a subcommand's "--url is mutually exclusive with X" branch from +// spuriously firing on the default. With this in place, `grcli unpack +// --source ./layout` keeps working unchanged — the explicit --source +// signals "local mode, ignore the default --url" and the conflict branch +// does not see two competing sources. +// +// No-op when the user passed --url explicitly: in that case both flags +// are explicit and the conflict really IS a conflict, fire as before. +// +// Pass the flag names that are mutually exclusive with --url for the +// given subcommand: unpack uses --source. +func suppressDefaultURLIfExplicit(cmd *cobra.Command, v *viper.Viper, conflictsWith ...string) { + if cmd.Flags().Changed(flagURL) { + return + } + for _, name := range conflictsWith { + if cmd.Flags().Changed(name) { + v.Set(flagURL, "") + return + } + } +} diff --git a/cmd/validate.go b/cmd/validate.go new file mode 100644 index 0000000..379f1d6 --- /dev/null +++ b/cmd/validate.go @@ -0,0 +1,162 @@ +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + + "github.com/spf13/cobra" + "github.com/spf13/viper" + "sigs.k8s.io/yaml" +) + +const ( + flagSpec = "spec" + envSpecPath = "GRCLI_GEMARA_SPEC_DIR" +) + +func newValidateCmd(v *viper.Viper) *cobra.Command { + cmd := &cobra.Command{ + Use: "validate", + Short: "Validate Gemara YAML files against the spec via cue vet", + Long: `Reads each --file input, picks its #ArtifactType from metadata.type, +and runs 'cue vet -d \"#\" ' to validate the file +against the Gemara CUE schemas. + +The spec directory must be a local checkout of the Gemara CUE module +(https://github.com/gemaraproj/gemara). Provide it via --spec or the +GRCLI_GEMARA_SPEC_DIR environment variable. For reproducible validation, +check out the tag matching your artifact's metadata.gemara-version. + +Requires the 'cue' binary on PATH (see https://cuelang.org). + +Example: + git clone --branch v1.0.0 https://github.com/gemaraproj/gemara /tmp/gemara + grcli validate -f controls.yaml --spec /tmp/gemara`, + RunE: func(cmd *cobra.Command, _ []string) error { + return runValidate(cmd, v) + }, + } + + flags := cmd.Flags() + flags.StringSliceP(flagFile, "f", nil, "input file(s) to validate (repeatable; comma-separated also accepted)") + flags.String(flagSpec, "", "path to a Gemara CUE module checkout (or set "+envSpecPath+")") + + return cmd +} + +func runValidate(cmd *cobra.Command, v *viper.Viper) error { + if err := v.BindPFlags(cmd.Flags()); err != nil { + return fmt.Errorf("binding flags: %w", err) + } + ctx := cmd.Context() + + files := expandCommas(v.GetStringSlice(flagFile)) + if len(files) == 0 { + return errors.New("at least one --file is required") + } + + specDir, err := resolveSpecDir(v) + if err != nil { + return err + } + + if _, err := exec.LookPath("cue"); err != nil { + return errors.New("cue binary not found on PATH — install from https://cuelang.org") + } + + out := cmd.OutOrStdout() + var failed []string + for _, file := range files { + artifactType, peekErr := readArtifactType(file) + if peekErr != nil { + fmt.Fprintf(out, "FAIL %s: %v\n", file, peekErr) + failed = append(failed, file) + continue + } + if validateErr := vetFile(ctx, specDir, artifactType, file, out); validateErr != nil { + fmt.Fprintf(out, "FAIL %s (#%s): %v\n", file, artifactType, validateErr) + failed = append(failed, file) + continue + } + fmt.Fprintf(out, "OK %s (#%s)\n", file, artifactType) + } + if len(failed) > 0 { + return fmt.Errorf("%d of %d file(s) failed validation", len(failed), len(files)) + } + return nil +} + +// resolveSpecDir returns the absolute path to the Gemara CUE module +// checkout. Precedence: --spec flag → GRCLI_GEMARA_SPEC_DIR env. Returns +// a typed error if neither is set or the path is not a directory. +func resolveSpecDir(v *viper.Viper) (string, error) { + dir := v.GetString(flagSpec) + if dir == "" { + dir = os.Getenv(envSpecPath) + } + if dir == "" { + return "", fmt.Errorf("spec directory is required: pass --spec or set %s", envSpecPath) + } + abs, err := filepath.Abs(dir) + if err != nil { + return "", fmt.Errorf("resolving spec path: %w", err) + } + info, err := os.Stat(abs) + if err != nil { + return "", fmt.Errorf("spec dir %s: %w", abs, err) + } + if !info.IsDir() { + return "", fmt.Errorf("spec path %s is not a directory", abs) + } + return abs, nil +} + +// readArtifactType peeks at metadata.type without loading the whole +// artifact. Mirrors source.peekedMetadata's narrow approach. +func readArtifactType(path string) (string, error) { + body, err := os.ReadFile(path) + if err != nil { + return "", err + } + var meta struct { + Metadata struct { + Type string `json:"type"` + } `json:"metadata"` + } + if err := yaml.Unmarshal(body, &meta); err != nil { + return "", fmt.Errorf("parsing %s: %w", path, err) + } + if meta.Metadata.Type == "" { + return "", errors.New("metadata.type is missing or empty") + } + return meta.Metadata.Type, nil +} + +// vetFile invokes 'cue vet -d "#" . ' with the working +// directory set to specDir. cue rejects absolute paths as package +// arguments, so we cd-and-use-"." rather than passing the spec path +// inline. The input file path is resolved to absolute first so the cd +// doesn't change which file is being vetted. Any output from cue is +// forwarded to out so users see schema violations inline. +func vetFile(ctx context.Context, specDir, artifactType, file string, out io.Writer) error { + absFile, err := filepath.Abs(file) + if err != nil { + return fmt.Errorf("resolving file path: %w", err) + } + args := []string{"vet", "-d", "#" + artifactType, ".", absFile} + cmd := exec.CommandContext(ctx, "cue", args...) + cmd.Dir = specDir + cmd.Stdout = out + cmd.Stderr = out + if err := cmd.Run(); err != nil { + return fmt.Errorf("cue vet exited with: %w", err) + } + return nil +} diff --git a/cmd/validate_test.go b/cmd/validate_test.go new file mode 100644 index 0000000..8f76507 --- /dev/null +++ b/cmd/validate_test.go @@ -0,0 +1,152 @@ +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +// minimalValidPolicy is the smallest YAML body that satisfies the +// Gemara #Policy schema today. The roundtrip tests in integration_test.go +// use a much sparser fixture (policyYAML) that exercises grcli's own +// peek + bundle logic without requiring schema validity; validate's +// tests need schema-valid input. +const minimalValidPolicy = `metadata: + id: min-policy + type: Policy + version: 1.0.0 + gemara-version: 0.20.0 + description: Minimal test policy + author: + id: tester + name: Test Author + type: Human +title: Minimal Test Policy +contacts: + responsible: + - name: Owner + accountable: + - name: Accountable +` + +// The happy-path validate tests need both `cue` on PATH and a local +// Gemara spec checkout to vet against. CI environments without either +// skip; this is the same pattern grc.store-backend's drift check uses +// (GEMARA_SPEC_DIR env or sibling checkout). +// +// On the dev workstation the spec lives at ../gemara relative to this +// repo; we also honor GRCLI_GEMARA_SPEC_DIR for explicit overrides. +func findSpecDir(t *testing.T) string { + t.Helper() + if dir := os.Getenv("GRCLI_GEMARA_SPEC_DIR"); dir != "" { + return dir + } + candidate, err := filepath.Abs("../../gemara") + if err == nil { + if info, statErr := os.Stat(filepath.Join(candidate, "cue.mod")); statErr == nil && info.IsDir() { + return candidate + } + } + t.Skip("no Gemara spec checkout available (set GRCLI_GEMARA_SPEC_DIR or check out ../gemara)") + return "" +} + +func requireCue(t *testing.T) { + t.Helper() + if _, err := exec.LookPath("cue"); err != nil { + t.Skip("cue binary not on PATH") + } +} + +func TestValidate_FlagValidation(t *testing.T) { + cases := []struct { + name string + args []string + wantSub string + }{ + { + name: "no-files", + args: []string{"validate", "--spec", "/tmp/anything"}, + wantSub: "at least one --file is required", + }, + { + name: "no-spec-no-env", + args: []string{"validate", "-f", "/tmp/x.yaml"}, + wantSub: "spec directory is required", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + isolatedWorkdir(t) + out, err := runRootExpectErr(t, tc.args...) + require.Error(t, err, "expected error, got: %s", out) + require.Contains(t, err.Error(), tc.wantSub) + }) + } +} + +func TestValidate_SpecPathNotADirectory(t *testing.T) { + workdir := isolatedWorkdir(t) + notADir := filepath.Join(workdir, "not-a-dir") + require.NoError(t, os.WriteFile(notADir, []byte("hi"), 0o600)) + + _, err := runRootExpectErr(t, "validate", "-f", "anything.yaml", "--spec", notADir) + require.Error(t, err) + require.Contains(t, err.Error(), "is not a directory") +} + +func TestValidate_HappyPath_Policy(t *testing.T) { + requireCue(t) + specDir := findSpecDir(t) + + workdir := isolatedWorkdir(t) + input := writeTempFile(t, workdir, "policy.yaml", minimalValidPolicy) + + out := runRoot(t, "validate", "-f", input, "--spec", specDir) + require.Contains(t, out, "OK") + require.Contains(t, out, "#Policy") +} + +func TestValidate_DetectsSchemaViolation(t *testing.T) { + requireCue(t) + specDir := findSpecDir(t) + + workdir := isolatedWorkdir(t) + bad := minimalValidPolicy + "not-a-real-field: oops\n" + input := writeTempFile(t, workdir, "bad.yaml", bad) + + out, err := runRootExpectErr(t, "validate", "-f", input, "--spec", specDir) + require.Error(t, err) + require.Contains(t, err.Error(), "failed validation") + require.Contains(t, out, "FAIL") + require.Contains(t, out, "not-a-real-field") +} + +func TestValidate_SpecFromEnv(t *testing.T) { + requireCue(t) + specDir := findSpecDir(t) + + workdir := isolatedWorkdir(t) + t.Setenv("GRCLI_GEMARA_SPEC_DIR", specDir) + input := writeTempFile(t, workdir, "policy.yaml", minimalValidPolicy) + + out := runRoot(t, "validate", "-f", input) + require.Contains(t, out, "OK") +} + +func TestValidate_MissingMetadataType(t *testing.T) { + requireCue(t) + specDir := findSpecDir(t) + + workdir := isolatedWorkdir(t) + input := writeTempFile(t, workdir, "noo.yaml", "metadata: {}\n") + + out, err := runRootExpectErr(t, "validate", "-f", input, "--spec", specDir) + require.Error(t, err) + require.Contains(t, out, "metadata.type is missing") +} diff --git a/cmd/verify.go b/cmd/verify.go new file mode 100644 index 0000000..27555c6 --- /dev/null +++ b/cmd/verify.go @@ -0,0 +1,445 @@ +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "os/exec" + "regexp" + "strings" + + "github.com/spf13/cobra" + "github.com/spf13/viper" + + "github.com/revanite-io/grc-store-protocol/identity" + + "github.com/revanite-io/grcli/internal/hub" + "github.com/revanite-io/grcli/internal/registry" + "github.com/revanite-io/grcli/internal/sign" + "github.com/revanite-io/grcli/internal/sigverify" +) + +// Flag names specific to verify. flagURL / flagRepository / +// flagCosignKey are declared in publish.go; flagVersion in unpack.go. +const ( + flagCertIdentity = "certificate-identity" + flagCertOIDCIssuer = "certificate-oidc-issuer" + // flagTrustedRoot overrides the embedded Sigstore public-good + // trusted_root.json with one read from disk (ADR-0046 decision 4) — for + // air-gapped deployments or a private Sigstore instance. Env form + // GRCLI_TRUSTED_ROOT; there is no --flag, only the env / config key, since + // it is an ops-level override, not a per-invocation knob. + flagTrustedRoot = "trusted-root" +) + +// defaultCertOIDCIssuer is the issuer assumed for keyless verification when +// --certificate-oidc-issuer (or the GRCLI_CERTIFICATE_OIDC_ISSUER env / +// user-global config key of the same name) is not set. Publishing to grc.store +// is a GitHub-Actions OIDC flow, so this is the issuer for ~every publisher; +// GitHub Enterprise / other CI / an OIDC proxy override it (ADR-0044). It is +// applied contextually inside keyless mode, NOT as a viper default, so it can't +// disturb key-vs-keyless detection. +const defaultCertOIDCIssuer = "https://token.actions.githubusercontent.com" + +func newVerifyCmd(v *viper.Viper) *cobra.Command { + cmd := &cobra.Command{ + Use: "verify", + Short: "Verify a remote Gemara bundle's signature", + Long: `Verifies the Sigstore signature attached to a remote Gemara bundle. +Keyless verification runs IN-PROCESS (ADR-0046) — no external tools are +required, just the grcli binary. The bundle must already be pushed to a +registry: signatures live at the registry layer as an OCI 1.1 referrer, not in +the bundle bytes, so verifying a local OCI layout from 'publish --dry-run' is +not supported. + +Signatures use the Sigstore bundle format (v0.3), attached as an OCI 1.1 +referrer. Artifacts signed by an OLDER grcli — the legacy 'sha256-….sig' tag +format — will NOT verify here; re-publish them to re-sign in the bundle format. + +The pinned Sigstore public-good trust root is embedded in grcli and refreshed +with each release. For an air-gapped deployment or a private Sigstore instance, +point GRCLI_TRUSTED_ROOT (env or config key 'trusted-root') at a +trusted_root.json on disk. + +With NO trust flags, verify runs in zero-flag mode (ADR-0045): it fetches +the catalog record from the hub, reads the keyless signer identity the hub +verified and pinned at ingest, and verifies against it — so a consumer needs +no prior knowledge of the publishing workflow. The identity it trusted, and +that it came from the hub record, are printed before verification runs. +This trusts the hub as the identity source; for an independent check, pass +--certificate-identity (or --cosign-key) yourself. + +Passing --certificate-identity (keyless verification, paired with publish's +GitHub-Actions OIDC flow) bypasses the hub lookup entirely. The identity is +typically the publishing workflow URL, e.g. +https://github.com///.github/workflows/publish.yml@refs/heads/main. +--certificate-oidc-issuer defaults to https://token.actions.githubusercontent.com +(the GitHub Actions issuer); set it — as a flag, GRCLI_CERTIFICATE_OIDC_ISSUER, +or a user-global config key — only for GitHub Enterprise, another CI provider, +or an OIDC proxy. + +Passing --cosign-key (key-based verification, paired with publish's +--cosign-key) selects the one remaining path that shells out to 'cosign' — a +niche publisher-shared-key mode. That path, and ONLY that path, still requires +cosign >= 3.x on PATH. + +Examples: + # Zero-flag: verify against the identity the hub recorded at ingest + grcli verify --url https://hub.grc.store \ + --repository myorg/my-controls --version 1.0.0 + + # Key-based (bypasses the hub lookup) + grcli verify --url https://hub.grc.store \ + --repository myorg/my-controls --version 1.0.0 \ + --cosign-key /keys/cosign.pub + + # Keyless, asserting the identity yourself (bypasses the hub lookup; + # issuer defaults to GitHub Actions) + grcli verify --url https://hub.grc.store \ + --repository myorg/my-controls --version 1.0.0 \ + --certificate-identity https://github.com/myorg/my-controls/.github/workflows/publish.yml@refs/heads/main + + # Keyless with a non-GitHub-Actions issuer + grcli verify --url https://hub.grc.store \ + --repository myorg/my-controls --version 1.0.0 \ + --certificate-identity \ + --certificate-oidc-issuer https://gitlab.example.com`, + RunE: func(cmd *cobra.Command, _ []string) error { + return runVerify(cmd, v) + }, + } + + flags := cmd.Flags() + flags.String(flagURL, defaultURL, "grc.store base URL (discovers the registry)") + flags.String(flagRepository, "", "repository path within the registry (required)") + flags.String(flagVersion, "", "artifact version to verify — the metadata.version of the published bundle (required)") + flags.String(flagCosignKey, "", "cosign public key file (mutually exclusive with keyless flags)") + flags.String(flagCertIdentity, "", "expected signer identity (e.g., a GHA workflow URL)") + flags.String(flagCertOIDCIssuer, "", "expected OIDC issuer for keyless verification (default: https://token.actions.githubusercontent.com — override for GitHub Enterprise / other CI)") + + return cmd +} + +func runVerify(cmd *cobra.Command, v *viper.Viper) error { + if err := v.BindPFlags(cmd.Flags()); err != nil { + return fmt.Errorf("binding flags: %w", err) + } + ctx := cmd.Context() + + policy, err := resolveVerifyPolicy(ctx, v) + if err != nil { + return err + } + + // ADR-0031: the signature lives in the bearer-auth registry. Mint an + // anonymous pull token from the hub (when --url is set and no override is + // present) and export it via GRCLI_REGISTRY_TOKEN. The in-process oras fetch + // reads it through the Docker credential chain (internal/registry), and + // key-mode cosign — the one remaining subprocess — gets it as an explicit + // flag, since the subprocess can't read the environment token. + policy.registryToken, err = ensureRegistryToken(ctx, v.GetString(flagURL), "", v.GetString(flagRepository), []string{"pull"}) + if err != nil { + return fmt.Errorf("fetching registry pull token: %w", err) + } + + out := cmd.OutOrStdout() + fmt.Fprintf(out, "verifying %s (%s)\n", policy.reference, policy.modeDescription()) + + // Key-based verification is the ONLY path that still shells out to cosign + // (ADR-0046 decision 5): a niche publisher-shared-key mode the hub doesn't + // pin yet. The cosign prerequisite now applies exclusively here. + if policy.keyPath != "" { + if _, err := exec.LookPath("cosign"); err != nil { + return errors.New("cosign binary not found on PATH — required only for --cosign-key (key-based) verification; " + + "install from https://docs.sigstore.dev/cosign/installation/ (keyless verification needs no external tools)") + } + args, err := policy.cosignArgs(ctx) + if err != nil { + return err + } + return runCosignVerify(ctx, args, out) + } + + // Keyless verification (explicit --certificate-identity and zero-flag + // hub-lookup) runs in-process against real Sigstore (ADR-0046) — no cosign. + return runKeylessVerify(ctx, v, policy, out) +} + +// runKeylessVerify performs in-process keyless verification (ADR-0046): it +// builds a sigstore-go verifier over the pinned (or GRCLI_TRUSTED_ROOT-override) +// trust root, discovers the signature bundle as an OCI referrer of the artifact +// manifest, and verifies it against both the artifact digest and the pinned +// signer identity (exact SAN for explicit mode, anchored SAN regexp for +// hub-lookup mode). The pre-verify announcement has already printed WHO/why is +// trusted; on success it prints the verified identity as confirmation. +func runKeylessVerify(ctx context.Context, v *viper.Viper, policy verifyPolicy, out io.Writer) error { + verifier, err := newSigstoreVerifier(v) + if err != nil { + return fmt.Errorf("initializing verifier: %w", err) + } + bundleJSON, artifactDigest, err := registry.FetchSignatureBundle(ctx, policy.registryHost, policy.repository, policy.version) + if err != nil { + return fmt.Errorf("discovering signature: %w", err) + } + res, err := verifier.Verify(ctx, bundleJSON, artifactDigest, policy.identityPolicy()) + if errors.Is(err, sigverify.ErrUnsigned) { + return fmt.Errorf("%s has no signature attached in the registry — nothing to verify (was it published with --no-sign?)", policy.reference) + } + if err != nil { + return err + } + fmt.Fprintf(out, "verified: %s\n", res.Identity) + return nil +} + +// newSigstoreVerifier builds the in-process verifier, honoring the +// GRCLI_TRUSTED_ROOT override (ADR-0046 decision 4). A zero timeout selects the +// package default. Both constructors require SCTs — the production posture is +// never relaxed off the embedded/override root. +func newSigstoreVerifier(v *viper.Viper) (*sigverify.Verifier, error) { + if path := v.GetString(flagTrustedRoot); path != "" { + return sigverify.NewVerifierFromFile(path, 0) + } + return sigverify.NewVerifier(0) +} + +// verifyPolicy bundles the resolved registry coordinates with the trust +// material used to verify the signature. +type verifyPolicy struct { + reference string // /: — bare-host, for display + cosign + // registryHost / repository / version are the split coordinates the + // in-process keyless fetch needs (internal/registry.FetchSignatureBundle + // resolves the tag, discovers the signature referrer). registryHost keeps + // any http(s):// scheme the hub advertised so plain-HTTP local registries + // propagate to oras (newRemoteRepo strips the scheme + sets PlainHTTP). + registryHost string + repository string + version string + keyPath string // populated for key-based verification + // identity is the exact keyless signer identity for --certificate-identity + // (explicit-flag keyless mode). Empty in key mode and in hub-lookup mode. + identity string + // identityRegexp is the anchored regexp for --certificate-identity-regexp, + // populated only in hub-lookup mode (the ref-stripped pin admits any git + // ref but nothing wider than the exact workflow path). Empty otherwise. + identityRegexp string + issuer string // populated for keyless verification (both modes) + // hubIdentity is the canonical identity string the hub recorded, kept for + // the pre-verify announcement so trust in the hub is visible, never silent. + // Non-empty only in hub-lookup mode (ADR-0045 decision 8). + hubIdentity string + registryToken string // Distribution pull token for the bearer-auth registry (ADR-0031) + plainHTTP bool // registry speaks plain HTTP (local dev) — pass cosign --allow-http-registry +} + +func (p verifyPolicy) modeDescription() string { + switch { + case p.keyPath != "": + return "key=" + p.keyPath + case p.identityRegexp != "": + // Hub-lookup mode: name the identity AND that the hub is its source, so + // the consumer sees exactly what they're trusting and where it came from. + return "keyless identity from hub record: " + p.hubIdentity + ", issuer " + p.issuer + default: + return "keyless identity=" + p.identity + " issuer=" + p.issuer + } +} + +// cosignArgs builds the argv for the ONLY remaining cosign shell-out: +// --cosign-key (key-based) verification (ADR-0046 decision 5). The keyless +// paths verify in-process and never reach here. grcli signs with the Sigstore +// bundle format (bundle-as-OCI-referrer), so cosign must expect it too — the +// bundle-format flags come from the SAME version-gated helper the sign side +// uses (sign.BundleFormatArgs), so sign and verify can't silently drift on +// either the format OR the cosign version band (ADR-0035). +func (p verifyPolicy) cosignArgs(ctx context.Context) ([]string, error) { + bundleArgs, err := sign.BundleFormatArgs(ctx) + if err != nil { + return nil, err + } + args := append([]string{"verify"}, bundleArgs...) + // cosign verify pulls the signature from the registry, which now + // requires a bearer token (ADR-0031). Unlike the in-process oras path, the + // cosign subprocess can't read GRCLI_REGISTRY_TOKEN, so pass it + // explicitly when we minted one. + if p.registryToken != "" { + args = append(args, "--registry-token", p.registryToken) + } + if p.plainHTTP { + args = append(args, "--allow-http-registry") + } + args = append(args, "--key", p.keyPath) + return append(args, p.reference), nil +} + +// identityPolicy translates the resolved keyless trust material into the +// in-process sigstore-go identity pin. Explicit mode carries an exact SAN +// (p.identity); hub-lookup mode carries the anchored SAN regexp +// (p.identityRegexp) — exactly one is set. The issuer is always exact. These are +// the same fields cosignArgs used to hand cosign, so the identity semantics are +// byte-identical to the old --certificate-identity / --certificate-identity-regexp +// + --certificate-oidc-issuer arguments. +func (p verifyPolicy) identityPolicy() sigverify.IdentityPolicy { + return sigverify.IdentityPolicy{ + SAN: p.identity, + SANRegexp: p.identityRegexp, + Issuer: p.issuer, + } +} + +func resolveVerifyPolicy(ctx context.Context, v *viper.Viper) (verifyPolicy, error) { + url := v.GetString(flagURL) + repository := v.GetString(flagRepository) + version := v.GetString(flagVersion) + keyPath := v.GetString(flagCosignKey) + identity := v.GetString(flagCertIdentity) + issuer := v.GetString(flagCertOIDCIssuer) + + // Validate the cheap flag combinations before the network round-trip, + // so a missing --repository/--version or bad trust material fails fast + // without a hub call. + switch { + case url == "": + return verifyPolicy{}, errors.New("--url is required") + case repository == "": + return verifyPolicy{}, errors.New("--repository is required") + case version == "": + return verifyPolicy{}, errors.New("--version is required") + } + + // Keyless mode is keyed on --certificate-identity ALONE, never the issuer: + // the issuer carries a default (defaultCertOIDCIssuer), so letting it + // trigger keyless mode would make every invocation look keyless and break + // --cosign-key detection. + keyMode := keyPath != "" + keylessMode := identity != "" + issuerSet := issuer != "" + switch { + case keyMode && (keylessMode || issuerSet): + return verifyPolicy{}, errors.New("--cosign-key is mutually exclusive with --certificate-identity / --certificate-oidc-issuer") + case issuerSet && !keylessMode: + return verifyPolicy{}, errors.New("--certificate-oidc-issuer requires --certificate-identity") + } + // With no key and no identity we're in zero-flag mode (ADR-0045 decision 8): + // the signer identity comes from the hub's catalog record, not the flags. + // (A lone --certificate-oidc-issuer is already rejected above, so this is + // exactly "no trust material at all".) + hubLookupMode := !keyMode && !keylessMode + // Keyless with no explicit issuer defaults to GitHub Actions (ADR-0044). + // This runs AFTER mode resolution, and cosign still checks issuer == this + // value, so a wrong default can only cause a false rejection, never a + // false acceptance. Hub-lookup mode carries its own issuer from the record. + if keylessMode && issuer == "" { + issuer = defaultCertOIDCIssuer + } + + d, err := hub.Discover(ctx, url) + if err != nil { + return verifyPolicy{}, fmt.Errorf("hub discovery: %w", err) + } + registryHost := d.RegistryURL + // The discovered registry value may carry an http(s):// scheme. Record + // whether it's plain HTTP (so cosign gets --allow-http-registry for a + // local dev zot), then normalize to a bare host — cosign rejects a + // reference that includes a scheme. + plainHTTP := strings.HasPrefix(registryHost, "http://") + rawRegistry := registryHost // keeps the scheme for the in-process oras fetch + registryHost = registry.NormalizeRegistryHost(registryHost) + if registryHost == "" { + return verifyPolicy{}, errors.New("hub discovery returned no registry URL") + } + + policy := verifyPolicy{ + reference: fmt.Sprintf("%s/%s:%s", registryHost, repository, version), + registryHost: rawRegistry, + repository: repository, + version: version, + keyPath: keyPath, + identity: identity, + issuer: issuer, + plainHTTP: plainHTTP, + } + + if hubLookupMode { + if err := resolveHubIdentity(ctx, url, repository, &policy); err != nil { + return verifyPolicy{}, err + } + } + return policy, nil +} + +// resolveHubIdentity fills the keyless trust material on policy from the hub's +// recorded signer identity for the catalog coordinate (ADR-0045 decision 8). +// The hub is trusted only as the *identity* source here — cosign still performs +// the Sigstore verification against it — and runVerify prints what was used and +// that it came from the hub before verifying, so the trust is never silent. +func resolveHubIdentity(ctx context.Context, url, repository string, policy *verifyPolicy) error { + ns, id, ok := strings.Cut(repository, "/") + if !ok || ns == "" || id == "" || strings.Contains(id, "/") { + return fmt.Errorf("expected --repository as /, got %q", repository) + } + + catalog, err := hub.New(url, "").GetCatalog(ctx, ns, id) + if err != nil { + return err + } + if catalog.SignerIdentity == "" { + return fmt.Errorf("hub has no recorded signer identity for %s/%s — the artifact predates hub-side signature verification, or this hub does not serve signer identity; pass --cosign-key or --certificate-identity to verify explicitly", ns, id) + } + + issuer, workflowPath, err := parseKeylessIdentity(catalog.SignerIdentity) + if err != nil { + return err + } + policy.issuer = issuer + policy.hubIdentity = catalog.SignerIdentity + // The pin is ref-stripped, so admit any git ref by matching the exact + // workflow path followed by cosign's SAN '@' suffix. QuoteMeta and the + // '^...@' anchor are load-bearing: they must never widen beyond this one + // workflow path (e.g. a longer sibling path or an org-wide match). + policy.identityRegexp = "^" + regexp.QuoteMeta(workflowPath) + "@" + return nil +} + +// parseKeylessIdentity splits a hub-recorded canonical signer identity into +// its issuer and workflow path by delegating to identity.ParseKeyless — the +// format owner's inverse of CanonicalKeylessIdentity, so producer and parser +// cannot drift — and maps its typed sentinels to actionable grcli messages. It +// rejects unknown schemes (e.g. the defined-but-unwired "key:sha256:") and +// malformed values so a garbled record fails loudly rather than producing a +// bogus verification policy. +func parseKeylessIdentity(canonical string) (issuer, workflowPath string, err error) { + issuer, workflowPath, err = identity.ParseKeyless(canonical) + switch { + case errors.Is(err, identity.ErrUnknownScheme): + if scheme, _, hasScheme := strings.Cut(canonical, ":"); hasScheme && scheme != "" { + return "", "", fmt.Errorf("hub signer identity %q uses unsupported scheme %q — only keyless identities can be verified without explicit trust flags; pass --cosign-key or --certificate-identity", canonical, scheme) + } + return "", "", fmt.Errorf("hub signer identity %q is malformed (expected \"keyless:#\")", canonical) + case errors.Is(err, identity.ErrMissingSeparator): + return "", "", fmt.Errorf("hub signer identity %q is malformed (expected \"keyless:#\")", canonical) + case err != nil: + return "", "", fmt.Errorf("hub signer identity %q: %w", canonical, err) + } + // ParseKeyless owns the format split; grcli additionally rejects empty + // halves — a pin with no issuer or no path cannot drive a cosign policy. + if issuer == "" || workflowPath == "" { + return "", "", fmt.Errorf("hub signer identity %q is malformed (expected \"keyless:#\")", canonical) + } + return issuer, workflowPath, nil +} + +func runCosignVerify(ctx context.Context, args []string, out io.Writer) error { + cosignCmd := exec.CommandContext(ctx, "cosign", args...) + cosignCmd.Stdout = out + cosignCmd.Stderr = out + cosignCmd.Stdin = os.Stdin + if err := cosignCmd.Run(); err != nil { + return fmt.Errorf("cosign verify failed: %w", err) + } + return nil +} diff --git a/cmd/verify_test.go b/cmd/verify_test.go new file mode 100644 index 0000000..dec944e --- /dev/null +++ b/cmd/verify_test.go @@ -0,0 +1,368 @@ +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "regexp" + "strings" + "testing" + + "github.com/spf13/viper" + "github.com/stretchr/testify/require" + + "github.com/revanite-io/grcli/internal/sigverify" +) + +// fakeCosignVersion puts a cosign on PATH that answers `cosign version[ --json]` +// with the given semver (and no-ops any other invocation). It lets the +// version-gated argv builder be tested deterministically, independent of +// whatever cosign the host happens to have. +func fakeCosignVersion(t *testing.T, version string) { + t.Helper() + dir := t.TempDir() + script := "#!/bin/sh\n" + + "if [ \"$1\" = version ]; then printf '{\"gitVersion\":\"" + version + "\"}\\n'; exit 0; fi\nexit 0\n" + if err := os.WriteFile(filepath.Join(dir, "cosign"), []byte(script), 0o755); err != nil { + t.Fatalf("write fake cosign: %v", err) + } + t.Setenv("PATH", dir) +} + +// Happy-path verify tests would need a real signed registry image and a +// usable cosign trust root — too much external state for a unit test +// suite. Flag-validation paths are well covered here; the cosign-shellout +// branch is one line and exercised manually. +func TestVerify_FlagValidation(t *testing.T) { + cases := []struct { + name string + args []string + wantSub string + }{ + { + // Pass --url="" to defeat the bake-in default — otherwise the + // default would supply a registry source via discovery and this + // test's "no registry source" premise wouldn't be reachable. + name: "missing-url", + args: []string{"verify", "--repository", "r", "--version", "t", "--cosign-key", "k", "--url", ""}, + wantSub: "--url is required", + }, + { + // A bogus --url is fine: flag validation runs before any hub + // round-trip, so these cases never dial the host. + name: "missing-repository", + args: []string{"verify", "--url", "https://hub.example", "--version", "t", "--cosign-key", "k"}, + wantSub: "--repository is required", + }, + { + name: "missing-version", + args: []string{"verify", "--url", "https://hub.example", "--repository", "rep", "--cosign-key", "k"}, + wantSub: "--version is required", + }, + { + name: "both-key-and-keyless", + args: []string{ + "verify", "--url", "https://hub.example", "--repository", "rep", "--version", "t", + "--cosign-key", "k", + "--certificate-identity", "id", + "--certificate-oidc-issuer", "https://example.com", + }, + wantSub: "mutually exclusive", + }, + { + // issuer without identity (and no key) is an explicit error: + // identity is the keyless trigger; a lone issuer has nothing to + // bind to (ADR-0044). Identity WITHOUT issuer is NOT here — it + // now succeeds by defaulting the issuer (see TestResolveVerifyPolicy_URL). + name: "issuer-without-identity", + args: []string{ + "verify", "--url", "https://hub.example", "--repository", "rep", "--version", "t", + "--certificate-oidc-issuer", "https://example.com", + }, + wantSub: "--certificate-oidc-issuer requires --certificate-identity", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + isolatedWorkdir(t) + out, err := runRootExpectErr(t, tc.args...) + require.Error(t, err, "expected error, got: %s", out) + require.Contains(t, err.Error(), tc.wantSub) + }) + } +} + +// TestResolveVerifyPolicy_URL covers the ADR-0026 --url path through the +// verify command. Catches the BLOCKER from the post-ship QA pass: when +// --url drives discovery, the registry_url advertised by the hub carries +// a scheme (https://...), which cosign rejects as an invalid OCI image +// reference unless grcli strips it before composing /:. +func TestResolveVerifyPolicy_URL(t *testing.T) { + t.Run("url discovery yields a bare-host cosign reference", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"registry_url":"https://discovered.example/","hub_url":"https://hub.example","api_version":"v1"}`)) + })) + defer srv.Close() + + v := viper.New() + v.Set(flagURL, srv.URL) + v.Set(flagRepository, "team/artifact") + v.Set(flagVersion, "1.0.0") + v.Set(flagCosignKey, "/keys/cosign.pub") + + policy, err := resolveVerifyPolicy(context.Background(), v) + require.NoError(t, err) + require.Equal(t, "discovered.example/team/artifact:1.0.0", policy.reference, + "cosign reference must be bare-host/repo:tag; a https:// prefix would cause cosign to reject the reference") + }) + + // discovery server shared by the issuer-default cases below. + discovery := func(t *testing.T) *httptest.Server { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"registry_url":"https://discovered.example/","hub_url":"https://hub.example","api_version":"v1"}`)) + })) + t.Cleanup(srv.Close) + return srv + } + + t.Run("keyless without issuer defaults to GitHub Actions", func(t *testing.T) { + srv := discovery(t) + v := viper.New() + v.Set(flagURL, srv.URL) + v.Set(flagRepository, "team/artifact") + v.Set(flagVersion, "1.0.0") + v.Set(flagCertIdentity, "https://github.com/team/repo/.github/workflows/publish.yml@refs/heads/main") + // flagCertOIDCIssuer deliberately unset + + policy, err := resolveVerifyPolicy(context.Background(), v) + require.NoError(t, err) + require.Equal(t, defaultCertOIDCIssuer, policy.issuer, + "keyless verify with no --certificate-oidc-issuer must default to the GitHub Actions issuer") + require.Equal(t, defaultCertOIDCIssuer, policy.identityPolicy().Issuer, + "the defaulted issuer must reach the in-process verifier") + }) + + t.Run("explicit issuer overrides the default", func(t *testing.T) { + srv := discovery(t) + v := viper.New() + v.Set(flagURL, srv.URL) + v.Set(flagRepository, "team/artifact") + v.Set(flagVersion, "1.0.0") + v.Set(flagCertIdentity, "id") + v.Set(flagCertOIDCIssuer, "https://gitlab.example.com") + + policy, err := resolveVerifyPolicy(context.Background(), v) + require.NoError(t, err) + require.Equal(t, "https://gitlab.example.com", policy.issuer, + "an explicit --certificate-oidc-issuer must override the GitHub Actions default") + }) +} + +// TestVerifyPolicy_CosignArgs now covers ONLY key-mode: keyless verification +// moved in-process (ADR-0046), so cosign is the sole remaining shell-out and it +// only ever runs with --key. The keyless trust material is carried by +// identityPolicy() instead (TestVerifyPolicy_IdentityPolicy below). +func TestVerifyPolicy_CosignArgs(t *testing.T) { + p := verifyPolicy{ + reference: "reg.example/team/artifact:1.0.0", + keyPath: "/keys/cosign.pub", + } + + t.Run("2.6.x band passes --new-bundle-format", func(t *testing.T) { + fakeCosignVersion(t, "v2.6.3") + args, err := p.cosignArgs(context.Background()) + require.NoError(t, err) + require.Equal(t, []string{ + "verify", "--new-bundle-format", + "--key", "/keys/cosign.pub", + "reg.example/team/artifact:1.0.0", + }, args) + }) + + // cosign ≥ 3.0.0 makes the bundle format the default and deprecates the + // flag; verify must omit it there to match what sign now produces. + t.Run("3.x omits the deprecated flag", func(t *testing.T) { + fakeCosignVersion(t, "v3.0.6") + args, err := p.cosignArgs(context.Background()) + require.NoError(t, err) + require.Equal(t, []string{ + "verify", + "--key", "/keys/cosign.pub", + "reg.example/team/artifact:1.0.0", + }, args) + }) + + t.Run("cosign too old fails fast", func(t *testing.T) { + fakeCosignVersion(t, "v2.2.0") + _, err := p.cosignArgs(context.Background()) + require.Error(t, err) + require.Contains(t, err.Error(), "2.6.0") + }) +} + +// TestVerifyPolicy_IdentityPolicy pins the mapping from the resolved policy to +// the in-process sigstore-go identity pin — the security-critical seam that +// replaced cosign's --certificate-identity / --certificate-identity-regexp + +// --certificate-oidc-issuer flags (ADR-0046 decision 2). The exact-vs-regexp +// choice and the issuer must carry through byte-for-byte. +func TestVerifyPolicy_IdentityPolicy(t *testing.T) { + t.Run("explicit keyless mode → exact SAN, exact issuer", func(t *testing.T) { + p := verifyPolicy{ + identity: "https://github.com/team/repo/.github/workflows/publish.yml@refs/heads/main", + issuer: "https://token.actions.githubusercontent.com", + } + require.Equal(t, sigverify.IdentityPolicy{ + SAN: "https://github.com/team/repo/.github/workflows/publish.yml@refs/heads/main", + Issuer: "https://token.actions.githubusercontent.com", + }, p.identityPolicy()) + }) + t.Run("hub-lookup mode → anchored SAN regexp, exact issuer", func(t *testing.T) { + p := verifyPolicy{ + identityRegexp: `^https://github\.com/team/repo/\.github/workflows/publish\.yml@`, + issuer: "https://token.actions.githubusercontent.com", + } + require.Equal(t, sigverify.IdentityPolicy{ + SANRegexp: `^https://github\.com/team/repo/\.github/workflows/publish\.yml@`, + Issuer: "https://token.actions.githubusercontent.com", + }, p.identityPolicy()) + }) +} + +// hubLookupServer serves both the discovery doc and a catalog detail so +// resolveVerifyPolicy's zero-flag path (discovery → GetCatalog) can run against +// httptest. signerIdentity is written into the catalog record; pass "" to omit +// the field entirely (simulating an artifact that predates hub-side +// verification). onCatalog, when non-nil, fires on each /v1/catalogs hit so a +// test can assert the catalog lookup did (or did NOT) happen. +func hubLookupServer(t *testing.T, signerIdentity string, onCatalog func()) *httptest.Server { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasPrefix(r.URL.Path, "/.well-known/"): + _, _ = w.Write([]byte(`{"registry_url":"https://discovered.example/","hub_url":"https://hub.example","api_version":"v1"}`)) + case strings.HasPrefix(r.URL.Path, "/v1/catalogs/"): + if onCatalog != nil { + onCatalog() + } + if signerIdentity == "" { + _, _ = w.Write([]byte(`{"namespace":"team","catalog_id":"artifact"}`)) + return + } + _, _ = fmt.Fprintf(w, `{"namespace":"team","catalog_id":"artifact","signer_identity":%q}`, signerIdentity) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(srv.Close) + return srv +} + +// TestResolveVerifyPolicy_HubLookup covers the zero-flag verify-by-coordinate +// path (ADR-0045 decision 8): no trust flags, so the signer identity is read +// from the hub's catalog record and turned into an anchored keyless cosign +// policy. +func TestResolveVerifyPolicy_HubLookup(t *testing.T) { + baseViper := func(url string) *viper.Viper { + v := viper.New() + v.Set(flagURL, url) + v.Set(flagRepository, "team/artifact") + v.Set(flagVersion, "1.0.0") + return v + } + + t.Run("hub identity becomes an anchored, escaped identity regexp", func(t *testing.T) { + // A workflow path carrying regexp metacharacters ('.') — the escaping is + // load-bearing, so it must survive into the compiled matcher. + const issuer = "https://token.actions.githubusercontent.com" + const workflowPath = "https://github.com/acme/repo.name/.github/workflows/publish.yml" + canonical := "keyless:" + issuer + "#" + workflowPath + + srv := hubLookupServer(t, canonical, nil) + policy, err := resolveVerifyPolicy(context.Background(), baseViper(srv.URL)) + require.NoError(t, err) + + require.Equal(t, issuer, policy.issuer) + require.Equal(t, canonical, policy.hubIdentity, "the raw hub record must be kept for the visible-trust announcement") + require.Empty(t, policy.identity, "hub-lookup mode uses the regexp field, never the exact-identity field") + + wantRegexp := "^" + regexp.QuoteMeta(workflowPath) + "@" + require.Equal(t, wantRegexp, policy.identityRegexp) + require.True(t, strings.HasPrefix(policy.identityRegexp, "^"), "must anchor at start") + require.True(t, strings.HasSuffix(policy.identityRegexp, "@"), "must require the SAN's @ boundary") + + // The anchoring + escaping must admit any ref of THIS workflow while + // refusing a wider or prefixed identity. + re := regexp.MustCompile(policy.identityRegexp) + require.True(t, re.MatchString(workflowPath+"@refs/tags/v1.0.0"), "any tag ref of the pinned workflow verifies") + require.True(t, re.MatchString(workflowPath+"@refs/heads/main"), "any branch ref of the pinned workflow verifies") + require.False(t, re.MatchString("https://evil.example/"+workflowPath+"@refs/tags/v1"), "^ anchor rejects a prefixed identity") + require.False(t, re.MatchString(workflowPath+"-sibling/.github/workflows/publish.yml@refs/tags/v1"), "the @ boundary rejects a longer sibling path") + // The escaped '.' must not act as a wildcard: a look-alike host differing + // only where a literal '.' sits must not match. + require.False(t, re.MatchString("https://github.com/acme/repoXname/.github/workflows/publish.yml@refs/tags/v1"), "escaped '.' must be a literal, not a wildcard") + + // The resolved policy feeds the in-process matcher (not cosign) — the + // anchored regexp becomes the SAN-regexp pin, issuer stays exact. + require.Equal(t, sigverify.IdentityPolicy{SANRegexp: wantRegexp, Issuer: issuer}, policy.identityPolicy()) + require.Equal(t, "keyless identity from hub record: "+canonical+", issuer "+issuer, policy.modeDescription()) + }) + + t.Run("hub record without a signer identity is a clear, actionable error", func(t *testing.T) { + srv := hubLookupServer(t, "", nil) + _, err := resolveVerifyPolicy(context.Background(), baseViper(srv.URL)) + require.Error(t, err) + require.Contains(t, err.Error(), "no recorded signer identity") + require.Contains(t, err.Error(), "--cosign-key or --certificate-identity", "the error must point at the explicit-flag escape hatch") + }) + + t.Run("malformed hub identity fails loudly", func(t *testing.T) { + for _, bad := range []string{"not-a-valid-identity", "keyless:issuer-without-hash"} { + srv := hubLookupServer(t, bad, nil) + _, err := resolveVerifyPolicy(context.Background(), baseViper(srv.URL)) + require.Error(t, err, "identity %q must be rejected", bad) + require.Contains(t, err.Error(), "malformed") + } + }) + + t.Run("unsupported key: scheme is rejected", func(t *testing.T) { + srv := hubLookupServer(t, "key:sha256:abc123", nil) + _, err := resolveVerifyPolicy(context.Background(), baseViper(srv.URL)) + require.Error(t, err) + require.Contains(t, err.Error(), "unsupported scheme") + require.Contains(t, err.Error(), `"key"`) + }) + + t.Run("explicit --certificate-identity bypasses the hub lookup entirely", func(t *testing.T) { + called := 0 + srv := hubLookupServer(t, "key:should-never-be-read", func() { called++ }) + + v := baseViper(srv.URL) + v.Set(flagCertIdentity, "https://github.com/team/repo/.github/workflows/publish.yml@refs/heads/main") + + policy, err := resolveVerifyPolicy(context.Background(), v) + require.NoError(t, err) + require.Equal(t, 0, called, "the catalog record must not be fetched when the identity is supplied explicitly") + require.Equal(t, "https://github.com/team/repo/.github/workflows/publish.yml@refs/heads/main", policy.identity) + require.Empty(t, policy.identityRegexp, "explicit keyless mode uses the exact identity, not a regexp") + require.Equal(t, defaultCertOIDCIssuer, policy.issuer, "explicit keyless still defaults the issuer (ADR-0044)") + }) + + t.Run("explicit --cosign-key bypasses the hub lookup entirely", func(t *testing.T) { + called := 0 + srv := hubLookupServer(t, "key:should-never-be-read", func() { called++ }) + + v := baseViper(srv.URL) + v.Set(flagCosignKey, "/keys/cosign.pub") + + policy, err := resolveVerifyPolicy(context.Background(), v) + require.NoError(t, err) + require.Equal(t, 0, called, "the catalog record must not be fetched when a key is supplied") + require.Equal(t, "/keys/cosign.pub", policy.keyPath) + }) +} diff --git a/cmd/versions.go b/cmd/versions.go new file mode 100644 index 0000000..bd43d79 --- /dev/null +++ b/cmd/versions.go @@ -0,0 +1,134 @@ +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "errors" + "fmt" + "io" + "strings" + "text/tabwriter" + + "github.com/spf13/cobra" + "github.com/spf13/viper" + + "github.com/revanite-io/grcli/internal/hub" +) + +const flagLatest = "latest" + +// digestDisplayLen is the count of hex chars after "sha256:" shown in +// the default table. Matches the docker/oras short-digest convention so +// the table stays readable in narrow terminals; full digests remain +// available via the hub API directly. +const digestDisplayLen = 12 + +func newVersionsCmd(v *viper.Viper) *cobra.Command { + cmd := &cobra.Command{ + Use: "versions /", + Aliases: []string{"releases"}, + Short: "List published versions of a named asset", + Long: `Looks up an asset on the hub and prints its published versions, +newest first. The asset is identified by its / +coordinate — the same shape grcli publish uses for --repository. + +By default every release is printed; pass --latest to print only the +current latest version (suitable for scripting — exits 0 with no +output if the catalog has no releases yet). + +Reads are public — no token required. + +Examples: + grcli versions finos-ccc/ccc.objstor.cn + grcli versions finos-ccc/ccc.objstor.cn --latest`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runVersions(cmd, v, args[0]) + }, + } + + flags := cmd.Flags() + flags.String(flagURL, defaultURL, "grc.store base URL") + flags.Bool(flagLatest, false, "print only the latest version") + + return cmd +} + +func runVersions(cmd *cobra.Command, v *viper.Viper, coord string) error { + if err := v.BindPFlags(cmd.Flags()); err != nil { + return fmt.Errorf("binding flags: %w", err) + } + ctx := cmd.Context() + + ns, id, ok := strings.Cut(coord, "/") + if !ok || ns == "" || id == "" || strings.Contains(id, "/") { + return fmt.Errorf("expected /, got %q", coord) + } + + // versions has no local/dry-run mode — the hub is the only source of + // truth, so an empty --url is a hard error here (unlike publish/unpack + // where --url="" is meaningful for offline flows). + url := v.GetString(flagURL) + if url == "" { + return errors.New("--url is required") + } + + catalog, err := hub.New(url, "").GetCatalog(ctx, ns, id) + if err != nil { + // Both ErrCatalogNotFound and ErrCatalogTombstoned already carry + // the namespace/id in their wrapped messages, so passing the error + // through is fine — the user sees "catalog not found: ns/id" or + // "catalog was yanked: ns/id" without the leaky hub URL. + return err + } + + out := cmd.OutOrStdout() + if v.GetBool(flagLatest) { + return writeLatest(out, catalog) + } + return writeReleases(out, catalog) +} + +// writeLatest prints the latest version on its own line. When the +// catalog exists but has no releases yet, exits silently with success +// so `grcli versions x/y --latest | xargs ...` pipelines have a clean +// no-op rather than a noisy error. +func writeLatest(out io.Writer, catalog *hub.Catalog) error { + if catalog.LatestVersion == "" { + return nil + } + fmt.Fprintln(out, catalog.LatestVersion) + return nil +} + +func writeReleases(out io.Writer, catalog *hub.Catalog) error { + if len(catalog.Releases) == 0 { + return fmt.Errorf("hub returned no releases for %s/%s", + catalog.Namespace, catalog.CatalogID) + } + tw := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0) + fmt.Fprintln(tw, "VERSION\tPUSHED\tDIGEST") + for _, r := range catalog.Releases { + marker := r.Version + if r.Version == catalog.LatestVersion { + marker += " (latest)" + } + fmt.Fprintf(tw, "%s\t%s\t%s\n", marker, r.PushedAt, shortDigest(r.ManifestDigest)) + } + return tw.Flush() +} + +// shortDigest collapses a sha256:HEX64 digest down to sha256:HEX12 for +// the default table. Anything that doesn't match the expected shape is +// returned unchanged so unrecognized digest algorithms still display. +func shortDigest(d string) string { + const prefix = "sha256:" + if !strings.HasPrefix(d, prefix) { + return d + } + hex := d[len(prefix):] + if len(hex) <= digestDisplayLen { + return d + } + return prefix + hex[:digestDisplayLen] +} diff --git a/cmd/versions_test.go b/cmd/versions_test.go new file mode 100644 index 0000000..c7fa836 --- /dev/null +++ b/cmd/versions_test.go @@ -0,0 +1,273 @@ +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/revanite-io/grcli/internal/hub" +) + +// catalogBodyTwoReleases is the happy-path JSON the hub returns for a +// catalog with two releases. Matches the live shape exercised against +// hub.grc.store during development. +const catalogBodyTwoReleases = `{ + "namespace":"finos-ccc","catalog_id":"ccc.objstor.cn", + "type":"ControlCatalog","category":"catalog", + "title":"CCC Object Storage Controls","summary":"...", + "author_name":"FINOS Common Cloud Controls", + "latest_version":"v2026.06-rc2", + "latest_manifest_digest":"sha256:a787ca997ebb5730404294dce11b1c6b987e39ba393e0f81c1400281e92e7a84", + "releases":[ + {"version":"v2026.06-rc2","manifest_digest":"sha256:a787ca997ebb5730404294dce11b1c6b987e39ba393e0f81c1400281e92e7a84","pushed_at":"2026-05-28T16:26:17.735477Z"}, + {"version":"v2026.06-rc1","manifest_digest":"sha256:951423bb3df92318ca452cc698432b703d7330275fda8023fd4babee1768824e","pushed_at":"2026-05-28T01:41:37.650829Z"} + ] +}` + +// fakeHub serves the canned response/status for /v1/catalogs/{ns}/{id} +// and rejects unexpected paths so a routing typo trips the test. +func fakeHub(t *testing.T, status int, body string) *httptest.Server { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.HasPrefix(r.URL.Path, "/v1/catalogs/") { + t.Errorf("unexpected hub path: %s", r.URL.Path) + http.Error(w, "not found", http.StatusNotFound) + return + } + w.WriteHeader(status) + if body != "" { + _, _ = w.Write([]byte(body)) + } + })) + t.Cleanup(srv.Close) + return srv +} + +func TestVersions_HappyPath(t *testing.T) { + srv := fakeHub(t, http.StatusOK, catalogBodyTwoReleases) + isolatedWorkdir(t) + + out := runRoot(t, "versions", "finos-ccc/ccc.objstor.cn", "--url", srv.URL) + + require.Contains(t, out, "VERSION") + require.Contains(t, out, "v2026.06-rc2 (latest)") + require.Contains(t, out, "v2026.06-rc1") + require.NotContains(t, out, "v2026.06-rc1 (latest)", + "only the latest_version row should carry the (latest) marker") + + // Digests are truncated to the docker/oras short form. + require.Contains(t, out, "sha256:a787ca997ebb") + require.Contains(t, out, "sha256:951423bb3df9") + require.NotContains(t, out, "a787ca997ebb5730404294dce11b1c6b987e39ba393e0f81c1400281e92e7a84", + "full sha256 digest should not appear in the default table") +} + +func TestVersions_Latest_PrintsOnlyVersion(t *testing.T) { + srv := fakeHub(t, http.StatusOK, catalogBodyTwoReleases) + isolatedWorkdir(t) + + out := runRoot(t, "versions", "finos-ccc/ccc.objstor.cn", "--url", srv.URL, "--latest") + + require.Equal(t, "v2026.06-rc2\n", out, + "--latest must emit exactly the version string plus a trailing newline for pipeline use") +} + +func TestVersions_ReleasesAlias(t *testing.T) { + srv := fakeHub(t, http.StatusOK, catalogBodyTwoReleases) + isolatedWorkdir(t) + + out := runRoot(t, "releases", "finos-ccc/ccc.objstor.cn", "--url", srv.URL) + require.Contains(t, out, "v2026.06-rc2 (latest)", + "`grcli releases` must alias to `grcli versions`") +} + +func TestVersions_NotFound(t *testing.T) { + srv := fakeHub(t, http.StatusNotFound, `{"error":"not found"}`) + isolatedWorkdir(t) + + _, err := runRootExpectErr(t, "versions", "does-not-exist/nope", "--url", srv.URL) + require.Error(t, err) + require.True(t, errors.Is(err, hub.ErrCatalogNotFound), + "404 must surface as ErrCatalogNotFound, got %v", err) + require.Contains(t, err.Error(), "does-not-exist/nope") + // Assert on the actual leak surface, not just the httptest URL: + // "http" catches both http:// and https:// hub URLs, and the + // production default URL catches a regression where the message + // embeds defaultURL even when --url overrides it. + require.NotContains(t, err.Error(), "http", + "user-facing 404 error must not embed any URL") + require.NotContains(t, err.Error(), defaultURL, + "user-facing 404 error must not embed the default hub URL") +} + +func TestVersions_Tombstoned(t *testing.T) { + srv := fakeHub(t, http.StatusGone, `{"error":"yanked"}`) + isolatedWorkdir(t) + + _, err := runRootExpectErr(t, "versions", "finos-ccc/ccc.objstor.cn", "--url", srv.URL) + require.Error(t, err) + require.True(t, errors.Is(err, hub.ErrCatalogTombstoned), + "410 must surface as ErrCatalogTombstoned, got %v", err) + require.Contains(t, err.Error(), "yanked") + require.NotContains(t, err.Error(), "http", + "user-facing 410 error must not embed any URL") + require.NotContains(t, err.Error(), defaultURL, + "user-facing 410 error must not embed the default hub URL") +} + +func TestVersions_EmptyReleases_Errors(t *testing.T) { + const body = `{"namespace":"a","catalog_id":"b","latest_version":"","releases":[]}` + srv := fakeHub(t, http.StatusOK, body) + isolatedWorkdir(t) + + _, err := runRootExpectErr(t, "versions", "a/b", "--url", srv.URL) + require.Error(t, err) + require.Contains(t, err.Error(), "no releases") +} + +func TestVersions_Latest_EmptyLatest_SilentExit(t *testing.T) { + const body = `{"namespace":"a","catalog_id":"b","latest_version":"","releases":[]}` + srv := fakeHub(t, http.StatusOK, body) + isolatedWorkdir(t) + + out := runRoot(t, "versions", "a/b", "--url", srv.URL, "--latest") + require.Equal(t, "", out, + "--latest on a catalog with no latest_version must exit 0 with empty stdout so xargs pipelines stay clean") +} + +// TestVersions_UnexpectedStatus locks in the diagnostic shape of the +// default branch in GetCatalog: a 5xx or other unmodeled status SHOULD +// embed the hub URL in the error so an operator can see which hub +// returned the unexpected status. Pairs with the 404/410 tests above, +// which assert URL ABSENCE for hub-modeled outcomes — together they +// document the contract: known statuses redact, unknown statuses leak +// for debuggability. +func TestVersions_UnexpectedStatus(t *testing.T) { + srv := fakeHub(t, http.StatusInternalServerError, `{"error":"boom"}`) + isolatedWorkdir(t) + + _, err := runRootExpectErr(t, "versions", "a/b", "--url", srv.URL) + require.Error(t, err) + require.Contains(t, err.Error(), "500") + require.Contains(t, err.Error(), srv.URL, + "unmodeled status codes should embed the hub URL for operator debugging") +} + +func TestVersions_EmptyBody200(t *testing.T) { + srv := fakeHub(t, http.StatusOK, "") + isolatedWorkdir(t) + + _, err := runRootExpectErr(t, "versions", "a/b", "--url", srv.URL) + require.Error(t, err) + require.Contains(t, err.Error(), "empty body", + "a 200 with empty body must be surfaced explicitly, not as a JSON-decode error") +} + +func TestVersions_MalformedJSON(t *testing.T) { + srv := fakeHub(t, http.StatusOK, `{ not valid json`) + isolatedWorkdir(t) + + _, err := runRootExpectErr(t, "versions", "a/b", "--url", srv.URL) + require.Error(t, err) + require.Contains(t, err.Error(), "decoding catalog response") +} + +func TestVersions_BadCoordinate(t *testing.T) { + // No HTTP server: arg parsing must reject these before any dial. + cases := []struct { + name string + coord string + }{ + {"no-slash", "finos-ccc"}, + {"empty-id", "finos-ccc/"}, + {"empty-ns", "/ccc.objstor.cn"}, + {"three-segments", "finos-ccc/ccc.objstor.cn/extra"}, + {"only-slash", "/"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + isolatedWorkdir(t) + _, err := runRootExpectErr(t, "versions", tc.coord, "--url", "https://hub.example") + require.Error(t, err) + require.Contains(t, err.Error(), "expected /") + }) + } +} + +// TestShortDigest documents the truncation contract. The sha256 case +// is exercised end-to-end by TestVersions_HappyPath; this table covers +// the boundary, fall-through, and unrecognized-algorithm branches so a +// future change to digestDisplayLen or the prefix check can't silently +// regress the non-sha256 path. +func TestShortDigest(t *testing.T) { + cases := []struct { + name string + in string + want string + }{ + { + name: "sha256-full-64-truncates-to-12", + in: "sha256:a787ca997ebb5730404294dce11b1c6b987e39ba393e0f81c1400281e92e7a84", + want: "sha256:a787ca997ebb", + }, + { + name: "sha256-exactly-12-hex-unchanged", + in: "sha256:a787ca997ebb", + want: "sha256:a787ca997ebb", + }, + { + name: "sha256-shorter-than-12-unchanged", + in: "sha256:abc", + want: "sha256:abc", + }, + { + name: "sha512-unrecognized-prefix-passes-through", + in: "sha512:b1946ac92492d2347c6235b4d2611184c1bfa4ce4eaa4d4d0a9d4f5e8c8b1234", + want: "sha512:b1946ac92492d2347c6235b4d2611184c1bfa4ce4eaa4d4d0a9d4f5e8c8b1234", + }, + { + name: "empty-string-unchanged", + in: "", + want: "", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + require.Equal(t, tc.want, shortDigest(tc.in)) + }) + } +} + +func TestVersions_MissingURL(t *testing.T) { + isolatedWorkdir(t) + _, err := runRootExpectErr(t, "versions", "a/b", "--url", "") + require.Error(t, err) + require.Contains(t, err.Error(), "--url is required") +} + +func TestVersions_MissingLatestField_OmitsLatestMarker(t *testing.T) { + // latest_version absent → no row should be marked (latest), but all + // releases must still print. Guards against a regression where the + // marker logic defaults to the first row when the field is empty. + const body = `{ + "namespace":"a","catalog_id":"b", + "releases":[ + {"version":"1.0.0","manifest_digest":"sha256:111111111111111111111111111111111111111111111111111111111111aaaa","pushed_at":"2026-01-01T00:00:00Z"}, + {"version":"0.9.0","manifest_digest":"sha256:222222222222222222222222222222222222222222222222222222222222bbbb","pushed_at":"2025-12-01T00:00:00Z"} + ] + }` + srv := fakeHub(t, http.StatusOK, body) + isolatedWorkdir(t) + + out := runRoot(t, "versions", "a/b", "--url", srv.URL) + require.Contains(t, out, "1.0.0") + require.Contains(t, out, "0.9.0") + require.NotContains(t, out, "(latest)", + "no row should be marked (latest) when the hub omits latest_version") +} diff --git a/examples/github-actions/publish.yml b/examples/github-actions/publish.yml new file mode 100644 index 0000000..f428986 --- /dev/null +++ b/examples/github-actions/publish.yml @@ -0,0 +1,67 @@ +# Sample GitHub Actions workflow: publish a grc.store catalog with grcli. +# +# === AUTH: NO GITHUB SECRET REQUIRED === +# Do NOT add `GRCLI_TOKEN`, a PAT, or any `secrets.*` reference to this +# workflow. grcli uses the workflow's GitHub Actions OIDC token as its +# credential (ADR-0032 trusted publishing). The `permissions: id-token: +# write` below is the entire auth setup — that's what lets the runtime +# mint an OIDC token grcli can present to the hub. +# +# Prerequisite (one-time, by an org admin on the hub — NOT in your repo): +# add this repository (owner/repo) as a Trusted CI publisher for the +# target namespace, optionally pinned to a single git ref. Without that +# binding the hub returns 403; adding a GitHub secret will not fix it. +# +# Copy this into .github/workflows/ in your catalog repo and adjust the +# file path, the --license, the --url, and the trigger to taste. + +name: Publish catalog + +on: + push: + branches: [main] + paths: ['controls.yaml'] # publish only when the catalog changes + workflow_dispatch: {} # allow manual runs + +permissions: + contents: read + id-token: write # issue the workflow OIDC token + # (hub auth + keyless signing) + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + # Fetch the pre-built grcli binary from the public GHCR artifact. + # No token needed (the package is public). Pin to a released tag. + # v2: https://github.com/oras-project/setup-oras/releases/tag/v2.0.0 + - uses: oras-project/setup-oras@38de303aac69abb66f3e6255b7198bff35f323e3 + - name: Install grcli + run: | + oras pull ghcr.io/gemaraproj/grcli:v0.6.0 --platform linux/amd64 + sudo install grcli /usr/local/bin/grcli + + # NO cosign step, and that is the point. As of v0.6.0 keyless publish + # signing runs IN-PROCESS via sigstore-go (ADR-0049): grcli requests the + # Actions OIDC token itself, gets a Fulcio certificate, logs to Rekor and + # attaches the signature as an OCI referrer — no external tools, no + # secrets. Verified by a live CI smoke on 2026-08-19 from a runner with + # no cosign on PATH. cosign is still needed ONLY for the `--cosign-key` + # (key-based) path, which this example does not use. + # + # Pin a tag, never `:latest`. v0.5.1 shipped a broken signing path and + # anything tracking `:latest` inherited it; the publishers that pinned + # were unaffected. Note the org move: v0.6.0+ live at + # ghcr.io/gemaraproj/grcli, tags <= v0.5.1 at ghcr.io/revanite-io/grcli. + + # --license is REQUIRED (ADR-0037): an SPDX expression naming the terms + # this catalog is published under. grcli fails before any network call + # if it is missing, and the value is stamped into the signed, immutable + # artifact — so set it to your catalog's real license, not this default. + - name: Publish + run: | + grcli publish -f controls.yaml \ + --license Apache-2.0 \ + --url https://hub.grc.store diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..ca70488 --- /dev/null +++ b/go.mod @@ -0,0 +1,113 @@ +module github.com/revanite-io/grcli + +go 1.25.0 + +require ( + github.com/gemaraproj/go-gemara v0.5.0 + github.com/opencontainers/go-digest v1.0.0 + github.com/opencontainers/image-spec v1.1.1 + github.com/revanite-io/grc-store-protocol v0.5.0 + github.com/sigstore/sigstore-go v1.1.4 + github.com/spf13/cobra v1.10.2 + github.com/spf13/viper v1.21.0 + github.com/stretchr/testify v1.11.1 + golang.org/x/mod v0.36.0 + oras.land/oras-go/v2 v2.6.0 + sigs.k8s.io/yaml v1.6.0 +) + +require ( + github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect + github.com/blang/semver v3.5.1+incompatible // indirect + github.com/cenkalti/backoff/v5 v5.0.3 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/coreos/go-oidc/v3 v3.17.0 // indirect + github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/digitorus/pkcs7 v0.0.0-20230818184609-3a137a874352 // indirect + github.com/digitorus/timestamp v0.0.0-20231217203849-220c5c2851b7 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/gemaraproj/grc-store-clientkit v0.1.1 + github.com/go-jose/go-jose/v4 v4.1.4 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-openapi/analysis v0.25.2 // indirect + github.com/go-openapi/errors v0.22.7 // indirect + github.com/go-openapi/jsonpointer v0.23.1 // indirect + github.com/go-openapi/jsonreference v0.21.6 // indirect + github.com/go-openapi/loads v0.23.3 // indirect + github.com/go-openapi/runtime v0.32.3 // indirect + github.com/go-openapi/runtime/server-middleware v0.30.0 // indirect + github.com/go-openapi/spec v0.22.5 // indirect + github.com/go-openapi/strfmt v0.26.3 // indirect + github.com/go-openapi/swag v0.26.0 // indirect + github.com/go-openapi/swag/cmdutils v0.26.0 // indirect + github.com/go-openapi/swag/conv v0.26.0 // indirect + github.com/go-openapi/swag/fileutils v0.26.0 // indirect + github.com/go-openapi/swag/jsonname v0.26.0 // indirect + github.com/go-openapi/swag/jsonutils v0.26.0 // indirect + github.com/go-openapi/swag/loading v0.26.0 // indirect + github.com/go-openapi/swag/mangling v0.26.0 // indirect + github.com/go-openapi/swag/netutils v0.26.0 // indirect + github.com/go-openapi/swag/stringutils v0.26.0 // indirect + github.com/go-openapi/swag/typeutils v0.26.0 // indirect + github.com/go-openapi/swag/yamlutils v0.26.0 // indirect + github.com/go-openapi/validate v0.25.3 // indirect + github.com/go-viper/mapstructure/v2 v2.5.0 // indirect + github.com/goccy/go-yaml v1.19.2 // indirect + github.com/google/certificate-transparency-go v1.3.3 // indirect + github.com/google/go-containerregistry v0.21.6 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/hashicorp/go-retryablehttp v0.7.8 // indirect + github.com/in-toto/attestation v1.2.0 // indirect + github.com/in-toto/in-toto-golang v0.11.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/jedisct1/go-minisign v0.0.0-20211028175153-1c139d1cc84b // indirect + github.com/letsencrypt/boulder v0.20260309.0 // indirect + github.com/oklog/ulid/v2 v2.1.1 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/sagikazarmark/locafero v0.11.0 // indirect + github.com/sassoftware/relic v7.2.1+incompatible // indirect + github.com/secure-systems-lab/go-securesystemslib v0.11.0 // indirect + github.com/shibumi/go-pathspec v1.3.0 // indirect + github.com/sigstore/protobuf-specs v0.5.1 // indirect + github.com/sigstore/rekor v1.5.2 // indirect + github.com/sigstore/rekor-tiles/v2 v2.2.2-0.20260601073857-5d098a2b6443 // indirect + github.com/sigstore/sigstore v1.10.8 // indirect + github.com/sigstore/timestamp-authority/v2 v2.1.2 // indirect + github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect + github.com/spf13/afero v1.15.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + github.com/theupdateframework/go-tuf v0.7.0 // indirect + github.com/theupdateframework/go-tuf/v2 v2.4.2-0.20260407074541-7e8f69f906ef // indirect + github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399 // indirect + github.com/transparency-dev/formats v0.1.1 // indirect + github.com/transparency-dev/merkle v0.0.2 // indirect + github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/otel v1.44.0 // indirect + go.opentelemetry.io/otel/metric v1.44.0 // indirect + go.opentelemetry.io/otel/trace v1.44.0 // indirect + go.yaml.in/yaml/v2 v2.4.4 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/crypto v0.52.0 // indirect + golang.org/x/net v0.55.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.45.0 // indirect + golang.org/x/term v0.43.0 // indirect + golang.org/x/text v0.37.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260523011958-0a33c5d7ca68 // indirect + google.golang.org/grpc v1.81.1 // indirect + google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + k8s.io/klog/v2 v2.140.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..ad15edb --- /dev/null +++ b/go.sum @@ -0,0 +1,450 @@ +cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= +cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= +cloud.google.com/go/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA= +cloud.google.com/go/auth v0.20.0/go.mod h1:942/yi/itH1SsmpyrbnTMDgGfdy2BUqIKyd0cyYLc5Q= +cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= +cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= +cloud.google.com/go/iam v1.11.0 h1:KieQ9Pb+LLPak1O3Rv3GgCxhnmkYf7Xyh0P5HfF1jFM= +cloud.google.com/go/iam v1.11.0/go.mod h1:KP+nKGugNJW4LcLx1uEZcq1ok5sQHFaQehQNl4QDgV4= +cloud.google.com/go/kms v1.31.0 h1:LS8N92OxFDgOLg5NCo3OmbvjtQAIVT5gUHVLKIDHaFE= +cloud.google.com/go/kms v1.31.0/go.mod h1:YIyXZym11R5uovJJt4oN5eUL3oPmirF3yKeIh6QAf4U= +cloud.google.com/go/longrunning v1.0.0 h1:lwzWEYD8+NkYV7dhexOz6kmlvajZA70+bW/xMhRVVdY= +cloud.google.com/go/longrunning v1.0.0/go.mod h1:8nqFBPOO1U/XkhWl0I19AMZEphrHi73VNABIpKYaTwM= +filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= +filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= +filippo.io/mldsa v0.0.0-20260215214346-43d0283efc3e h1:VsUbObBMxXlc23Eb9VeeJYE4jvTs87qa5RqSN2U5FJU= +filippo.io/mldsa v0.0.0-20260215214346-43d0283efc3e/go.mod h1:32qQ5yj3R24Eu03iWFWchdC3OB653wPvoepWejkefbY= +github.com/AdamKorcz/go-fuzz-headers-1 v0.0.0-20230919221257-8b5d3ce2d11d h1:zjqpY4C7H15HjRPEenkS4SAn3Jy2eRRjkjZbGR30TOg= +github.com/AdamKorcz/go-fuzz-headers-1 v0.0.0-20230919221257-8b5d3ce2d11d/go.mod h1:XNqJ7hv2kY++g8XEHREpi+JqZo3+0l+CH2egBVN4yqM= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1 h1:jHb/wfvRikGdxMXYV3QG/SzUOPYN9KEUUuC0Yd0/vC0= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1/go.mod h1:pzBXCYn05zvYIrwLgtK8Ap8QcjRg+0i76tMQdWN6wOk= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1/go.mod h1:IYus9qsFobWIc2YVwe/WPjcnyCkPKtnHAqUYeebc8z0= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 h1:fhqpLE3UEXi9lPaBRpQ6XuRW0nU7hgg4zlmZZa+a9q4= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0/go.mod h1:7dCRMLwisfRH3dBupKeNCioWYUZ4SS09Z14H+7i8ZoY= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.5.0 h1:MaKvxE6D0KkjOg6Wd9M00iqP5PR0kUxCfiezes4JweM= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.5.0/go.mod h1:i2h9fsTFKZorh8RdV2IcSUf/Qj98GlTkrTvUbX/s8as= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 h1:nCYfgcSyHZXJI8J0IWE5MsCGlb2xp9fJiXyxWgmOFg4= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0/go.mod h1:ucUjca2JtSZboY8IoUqyQyuuXvwbMBVwFOm0vdQPNhA= +github.com/AzureAD/microsoft-authentication-library-for-go v1.7.0 h1:4iB+IesclUXdP0ICgAabvq2FYLXrJWKx1fJQ+GxSo3Y= +github.com/AzureAD/microsoft-authentication-library-for-go v1.7.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= +github.com/alessio/shellescape v1.4.1 h1:V7yhSDDn8LP4lc4jS8pFkt0zCnzVJlG5JXy9BVKJUX0= +github.com/alessio/shellescape v1.4.1/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPpyFjDCtHLS30= +github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= +github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= +github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6tLY8= +github.com/aws/aws-sdk-go-v2 v1.41.7/go.mod h1:4LAfZOPHNVNQEckOACQx60Y8pSRjIkNZQz1w92xpMJc= +github.com/aws/aws-sdk-go-v2/config v1.32.17 h1:FpL4/758/diKwqbytU0prpuiu60fgXKUWCpDJtApclU= +github.com/aws/aws-sdk-go-v2/config v1.32.17/go.mod h1:OXqUMzgXytfoF9JaKkhrOYsyh72t9G+MJH8mMRaexOE= +github.com/aws/aws-sdk-go-v2/credentials v1.19.16 h1:r3RJBuU7X9ibt8RHbMjWE6y60QbKBiII6wSrXnapxSU= +github.com/aws/aws-sdk-go-v2/credentials v1.19.16/go.mod h1:6cx7zqDENJDbBIIWX6P8s0h6hqHC8Avbjh9Dseo27ug= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 h1:UuSfcORqNSz/ey3VPRS8TcVH2Ikf0/sC+Hdj400QI6U= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23/go.mod h1:+G/OSGiOFnSOkYloKj/9M35s74LgVAdJBSD5lsFfqKg= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 h1:GpT/TrnBYuE5gan2cZbTtvP+JlHsutdmlV2YfEyNde0= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23/go.mod h1:xYWD6BS9ywC5bS3sz9Xh04whO/hzK2plt2Zkyrp4JuA= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 h1:bpd8vxhlQi2r1hiueOw02f/duEPTMK59Q4QMAoTTtTo= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23/go.mod h1:15DfR2nw+CRHIk0tqNyifu3G1YdAOy68RftkhMDDwYk= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 h1:OQqn11BtaYv1WLUowvcA30MpzIu8Ti4pcLPIIyoKZrA= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24/go.mod h1:X5ZJyfwVrWA96GzPmUCWFQaEARPR7gCrpq2E92PJwAE= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 h1:FLudkZLt5ci0ozzgkVo8BJGwvqNaZbTWb3UcucAateA= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9/go.mod h1:w7wZ/s9qK7c8g4al+UyoF1Sp/Z45UwMGcqIzLWVQHWk= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 h1:pbrxO/kuIwgEsOPLkaHu0O+m4fNgLU8B3vxQ+72jTPw= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23/go.mod h1:/CMNUqoj46HpS3MNRDEDIwcgEnrtZlKRaHNaHxIFpNA= +github.com/aws/aws-sdk-go-v2/service/kms v1.52.0 h1:QNtg+Mtj1zmepk568+UKBD5DFfqh+ESTUUqQT27JkQc= +github.com/aws/aws-sdk-go-v2/service/kms v1.52.0/go.mod h1:Y0+uxvxz6ib4KktRdK0V4X45Vcs/JyYoz8H71pO8xeI= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 h1:TdJ+HdzOBhU8+iVAOGUTU63VXopcumCOF1paFulHWZc= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.11/go.mod h1:R82ZRExE/nheo0N+T8zHPcLRTcH8MGsnR3BiVGX0TwI= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 h1:7byT8HUWrgoRp6sXjxtZwgOKfhss5fW6SkLBtqzgRoE= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.17/go.mod h1:xNWknVi4Ezm1vg1QsB/5EWpAJURq22uqd38U8qKvOJc= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 h1:+1Kl1zx6bWi4X7cKi3VYh29h8BvsCoHQEQ6ST9X8w7w= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21/go.mod h1:4vIRDq+CJB2xFAXZ+YgGUTiEft7oAQlhIs71xcSeuVg= +github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 h1:F/M5Y9I3nwr2IEpshZgh1GeHpOItExNM9L1euNuh/fk= +github.com/aws/aws-sdk-go-v2/service/sts v1.42.1/go.mod h1:mTNxImtovCOEEuD65mKW7DCsL+2gjEH+RPEAexAzAio= +github.com/aws/smithy-go v1.25.1 h1:J8ERsGSU7d+aCmdQur5Txg6bVoYelvQJgtZehD12GkI= +github.com/aws/smithy-go v1.25.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ= +github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb h1:EDmT6Q9Zs+SbUoc7Ik9EfrFqcylYqgPZ9ANSbTAntnE= +github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb/go.mod h1:ZjrT6AXHbDs86ZSdt/osfBi5qfexBrKUdONk989Wnk4= +github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc= +github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467 h1:uX1JmpONuD549D73r6cgnxyUu18Zb7yHAy5AYU0Pm4Q= +github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467/go.mod h1:uzvlm1mxhHkdfqitSA92i7Se+S9ksOn3a3qmv/kyOCw= +github.com/danieljoos/wincred v1.2.0 h1:ozqKHaLK0W/ii4KVbbvluM91W2H3Sh0BncbUNPS7jLE= +github.com/danieljoos/wincred v1.2.0/go.mod h1:FzQLLMKBFdvu+osBrnFODiv32YGwCfx0SkRa/eYHgec= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/digitorus/pkcs7 v0.0.0-20230713084857-e76b763bdc49/go.mod h1:SKVExuS+vpu2l9IoOc0RwqE7NYnb0JlcFHFnEJkVDzc= +github.com/digitorus/pkcs7 v0.0.0-20230818184609-3a137a874352 h1:ge14PCmCvPjpMQMIAH7uKg0lrtNSOdpYsRXlwk3QbaE= +github.com/digitorus/pkcs7 v0.0.0-20230818184609-3a137a874352/go.mod h1:SKVExuS+vpu2l9IoOc0RwqE7NYnb0JlcFHFnEJkVDzc= +github.com/digitorus/timestamp v0.0.0-20231217203849-220c5c2851b7 h1:lxmTCgmHE1GUYL7P0MlNa00M67axePTq+9nBSGddR8I= +github.com/digitorus/timestamp v0.0.0-20231217203849-220c5c2851b7/go.mod h1:GvWntX9qiTlOud0WkQ6ewFm0LPy5JUR1Xo0Ngbd1w6Y= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/gemaraproj/go-gemara v0.5.0 h1:sKDj3R7fX8tdlksShWGCLUl/sLvo+tGh1xAFs1JaoHU= +github.com/gemaraproj/go-gemara v0.5.0/go.mod h1:knXQmQ4f7vB18eb6TcUCP2A3dqQpn+oZdyz74Qe1+ac= +github.com/gemaraproj/grc-store-clientkit v0.1.1 h1:q15grTze27tMbOlXaT1UAvrAhDbVPir/4C36Sq/zLus= +github.com/gemaraproj/grc-store-clientkit v0.1.1/go.mod h1:6gJ5iUOGONQ7PNKAhw+gOkkE8qBo0yatuc8uEMorpsk= +github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM= +github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= +github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-openapi/analysis v0.25.2 h1:I0vy4n3alz+DHTiN1PRhCb7QZxkK6g5YmswZKv2TKuw= +github.com/go-openapi/analysis v0.25.2/go.mod h1:Uhs1t/2XR10EnwONYILGEzw8gcfGIG5Xk5K2AxnhqDo= +github.com/go-openapi/errors v0.22.7 h1:JLFBGC0Apwdzw3484MmBqspjPbwa2SHvpDm0u5aGhUA= +github.com/go-openapi/errors v0.22.7/go.mod h1://QW6SD9OsWtH6gHllUCddOXDL0tk0ZGNYHwsw4sW3w= +github.com/go-openapi/jsonpointer v0.23.1 h1:1HBACs7XIwR2RcmItfdSFlALhGbe6S92p0ry4d1GWg4= +github.com/go-openapi/jsonpointer v0.23.1/go.mod h1:iWRmZTrGn7XwYhtPt/fvdSFj1OfNBngqRT2UG3BxSqY= +github.com/go-openapi/jsonreference v0.21.6 h1:NZ5nGfnaM1n4I43Xjm1e5/M2GjOwQwndQz22uhxwD+Y= +github.com/go-openapi/jsonreference v0.21.6/go.mod h1:xzbgtQ3ZbWxvET3AxdzCJlJt6vkovbf+IfSPJjD0tUY= +github.com/go-openapi/loads v0.23.3 h1:g5Xap1JfwKkUnZdn+S0L3SzBDpcTIYzZ5Qaag0YDkKQ= +github.com/go-openapi/loads v0.23.3/go.mod h1:NOH07zLajXo8y55hom0omlHWDVVvCwBM/S+csCK8LqA= +github.com/go-openapi/runtime v0.32.3 h1:J7Ycy5DJmhhP1By3NifhRUjnkXTrk21qbeqSULjwX8U= +github.com/go-openapi/runtime v0.32.3/go.mod h1:/WTQi0fa5DiGnnCXQKsTkSm15OzJp8Uz3H2t+67TBr4= +github.com/go-openapi/runtime/server-middleware v0.30.0 h1:8rPoJ/xv7JL8BsovaqboKETlpWBArVh8n+0L/GyePog= +github.com/go-openapi/runtime/server-middleware v0.30.0/go.mod h1:OYNT/TxNvB/VK5oe4htM2jDTwlEXuejVJmu0DVZfAMs= +github.com/go-openapi/spec v0.22.5 h1:KhO7RBlKQfonUWX2WzQCoLIXVA6AcNqDGZ3a1Dutdlo= +github.com/go-openapi/spec v0.22.5/go.mod h1:vxpOtMya5TXtENXKE5bKqv5NjocVhyhxHrlZfvKnZ74= +github.com/go-openapi/strfmt v0.26.3 h1:rzmslHarJgBbf2qfGge+X3htclQfmXqBZMm0Too0HhU= +github.com/go-openapi/strfmt v0.26.3/go.mod h1:a5nsUw0oRpQzZeOwx8bi6cKbzFZslpbCKt1LEot+KnQ= +github.com/go-openapi/swag v0.26.0 h1:GVDXCmfvhfu1BxiHo8/FA+BbKmhecHnG3varjON5/RI= +github.com/go-openapi/swag v0.26.0/go.mod h1:82g3193sZJRbocs7bNCqGfIgq8pkuwVwCfhKIRlEQF0= +github.com/go-openapi/swag/cmdutils v0.26.0 h1:iowihOcvq7y4egO8cOq0dmfohz6wfeQ63U1EnuhO2TU= +github.com/go-openapi/swag/cmdutils v0.26.0/go.mod h1:Sm1MVFMkF6guJJ+pQqHnQA3N0j9qALV3NxzDSv6bETM= +github.com/go-openapi/swag/conv v0.26.0 h1:5yGGsPYI1ZCva93U0AoKi/iZrNhaJEjr324YVsiD89I= +github.com/go-openapi/swag/conv v0.26.0/go.mod h1:tpAmIL7X58VPnHHiSO4uE3jBeRamGsFsfdDeDtb5ECE= +github.com/go-openapi/swag/fileutils v0.26.0 h1:WJoPRvsA7QRiiWluowkLJa9jaYR7FCuxmDvnCgaRRxU= +github.com/go-openapi/swag/fileutils v0.26.0/go.mod h1:0WDJ7lp67eNjPMO50wAWYlKvhOb6CQ37rzR7wrgI8Tc= +github.com/go-openapi/swag/jsonname v0.26.0 h1:gV1NFX9M8avo0YSpmWogqfQISigCmpaiNci8cGECU5w= +github.com/go-openapi/swag/jsonname v0.26.0/go.mod h1:urBBR8bZNoDYGr653ynhIx+gTeIz0ARZxHkAPktJK2M= +github.com/go-openapi/swag/jsonutils v0.26.0 h1:FawFML2iAXsPqmERscuMPIHmFsoP1tOqWkxBaKNMsnA= +github.com/go-openapi/swag/jsonutils v0.26.0/go.mod h1:2VmA0CJlyFqgawOaPI9psnjFDqzyivIqLYN34t9p91E= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.0 h1:apqeINu/ICHouqiRZbyFvuDge5jCmmLTqGQ9V95EaOM= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.0/go.mod h1:AyM6QT8uz5IdKxk5akv0y6u4QvcL9GWERt0Jx/F/R8Y= +github.com/go-openapi/swag/loading v0.26.0 h1:Apg6zaKhCJurpJer0DCxq99qwmhFddBhaMX7kilDcko= +github.com/go-openapi/swag/loading v0.26.0/go.mod h1:dBxQ/6V2uBaAQdevN18VELE6xSpJWZxLX4txe12JwDg= +github.com/go-openapi/swag/mangling v0.26.0 h1:Du2YC4YLA/Y5m/YKQd7AnY5qq0wRKSFZTTt8ktFaXcQ= +github.com/go-openapi/swag/mangling v0.26.0/go.mod h1:jifS7W9vbg+pw63bT+GI53otluMQL3CeemuyCHKwVx0= +github.com/go-openapi/swag/netutils v0.26.0 h1:CmZp+ZT7HrmFwrC3GdGsXBq2+42T1bjKBapcqVpIs3c= +github.com/go-openapi/swag/netutils v0.26.0/go.mod h1:5iK+Ok3ZohWWex1C50BFTPexi03UaPwjW4Oj8kgrpwo= +github.com/go-openapi/swag/stringutils v0.26.0 h1:qZQngLxs5s7SLijc3N2ZO+fUq2o8LjuWAASSrJuh+xg= +github.com/go-openapi/swag/stringutils v0.26.0/go.mod h1:sWn5uY+QIIspwPhvgnqJsH8xqFT2ZbYcvbcFanRyhFE= +github.com/go-openapi/swag/typeutils v0.26.0 h1:2kdEwdiNWy+JJdOvu5MA2IIg2SylWAFuuyQIKYybfq4= +github.com/go-openapi/swag/typeutils v0.26.0/go.mod h1:oovDuIUvTrEHVMqWilQzKzV4YlSKgyZmFh7AlfABNVE= +github.com/go-openapi/swag/yamlutils v0.26.0 h1:H7O8l/8NJJQ/oiReEN+oMpnGMyt8G0hl460nRZxhLMQ= +github.com/go-openapi/swag/yamlutils v0.26.0/go.mod h1:1evKEGAtP37Pkwcc7EWMF0hedX0/x3Rkvei2wtG/TbU= +github.com/go-openapi/testify/enable/yaml/v2 v2.5.1 h1:q9NtHwK4qHF7yZziBPvZyv7zWAIk8ok88Gh2mR6Jpc8= +github.com/go-openapi/testify/enable/yaml/v2 v2.5.1/go.mod h1:JW0MXIotCYps/XsgJnG3a8Q7rE5xAiBwoOD5OfaIQBk= +github.com/go-openapi/testify/v2 v2.5.1 h1:TMdhCaw8fUNraVSf3Omoob1dO/AzBfhtFAPW0an6sBo= +github.com/go-openapi/testify/v2 v2.5.1/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= +github.com/go-openapi/validate v0.25.3 h1:4nzAIavcJ7WveHK2+V1UAkZK3kWcjzxZCzjfZAfavKs= +github.com/go-openapi/validate v0.25.3/go.mod h1:GemfuGMyYpIaBoKpX3z8sLywrmxpzWVOoJ7R0VeAVuk= +github.com/go-rod/rod v0.116.2 h1:A5t2Ky2A+5eD/ZJQr1EfsQSe5rms5Xof/qj296e+ZqA= +github.com/go-rod/rod v0.116.2/go.mod h1:H+CMO9SCNc2TJ2WfrG+pKhITz57uGNYU43qYHh438Mg= +github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= +github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/certificate-transparency-go v1.3.3 h1:hq/rSxztSkXN2tx/3jQqF6Xc0O565UQPdHrOWvZwybo= +github.com/google/certificate-transparency-go v1.3.3/go.mod h1:iR17ZgSaXRzSa5qvjFl8TnVD5h8ky2JMVio+dzoKMgA= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-containerregistry v0.21.6 h1:T+yqQIlJXKrM98Om4DlW3GoWQAmhZuLMwoDOvVrtiUM= +github.com/google/go-containerregistry v0.21.6/go.mod h1:U7MMSBIJynke2MVQrQk19NP9k/uQsGz/h0amIFSHMbo= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= +github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= +github.com/google/trillian v1.7.3 h1:hziW+vo4czis48tzx2GK5xRBl/ZxBA9B0/UR5avXOro= +github.com/google/trillian v1.7.3/go.mod h1:qh8iy4x/GvnVXUBd5pK4oncuT1Y9vVYfibQVsR/WpKg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/enterprise-certificate-proxy v0.3.15 h1:xolVQTEXusUcAA5UgtyRLjelpFFHWlPQ4XfWGc7MBas= +github.com/googleapis/enterprise-certificate-proxy v0.3.15/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg= +github.com/googleapis/gax-go/v2 v2.22.0 h1:PjIWBpgGIVKGoCXuiCoP64altEJCj3/Ei+kSU5vlZD4= +github.com/googleapis/gax-go/v2 v2.22.0/go.mod h1:irWBbALSr0Sk3qlqb9SyJ1h68WjgeFuiOzI4Rqw5+aY= +github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 h1:UH//fgunKIs4JdUbpDl1VZCDaL56wXCB/5+wF6uHfaI= +github.com/grpc-ecosystem/go-grpc-middleware v1.4.0/go.mod h1:g5qyo/la0ALbONm6Vbp88Yd8NsDy6rZz+RcrMPxvld8= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= +github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48= +github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw= +github.com/hashicorp/go-rootcerts v1.0.2 h1:jzhAVGtqPKbwpyCPELlgNWhE1znq+qwJtW5Oi2viEzc= +github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8= +github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 h1:U+kC2dOhMFQctRfhK0gRctKAPTloZdMU5ZJxaesJ/VM= +github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0/go.mod h1:Ll013mhdmsVDuoIXVfBtvgGJsXDYkTw1kooNcoCXuE0= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4= +github.com/hashicorp/go-sockaddr v1.0.7 h1:G+pTkSO01HpR5qCxg7lxfsFEZaG+C0VssTy/9dbT+Fw= +github.com/hashicorp/go-sockaddr v1.0.7/go.mod h1:FZQbEYa1pxkQ7WLpyXJ6cbjpT8q0YgQaK/JakXqGyWw= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/hashicorp/hcl v1.0.1-vault-7 h1:ag5OxFVy3QYTFTJODRzTKVZ6xvdfLLCA1cy/Y6xGI0I= +github.com/hashicorp/hcl v1.0.1-vault-7/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM= +github.com/hashicorp/vault/api v1.22.0 h1:+HYFquE35/B74fHoIeXlZIP2YADVboaPjaSicHEZiH0= +github.com/hashicorp/vault/api v1.22.0/go.mod h1:IUZA2cDvr4Ok3+NtK2Oq/r+lJeXkeCrHRmqdyWfpmGM= +github.com/howeyc/gopass v0.0.0-20210920133722-c8aef6fb66ef h1:A9HsByNhogrvm9cWb28sjiS3i7tcKCkflWFEkHfuAgM= +github.com/howeyc/gopass v0.0.0-20210920133722-c8aef6fb66ef/go.mod h1:lADxMC39cJJqL93Duh1xhAs4I2Zs8mKS89XWXFGp9cs= +github.com/in-toto/attestation v1.2.0 h1:aPRUZ3azbqD7yEBD5fP3TD8Dszf+YHo284SOcpahjQk= +github.com/in-toto/attestation v1.2.0/go.mod h1:r79G45gOmzPismgObLSL+rZTFxUgZLOQJI6LofTZgXk= +github.com/in-toto/in-toto-golang v0.11.0 h1:nfidMYBFx+E0lnmX5KUnN2Pdm8zdNKal1ayjJuzzRoA= +github.com/in-toto/in-toto-golang v0.11.0/go.mod h1:u3PjTnwFKjp5a1YCcw8SJg0G+tMeKfVoWsWeFMDCMtw= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo= +github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jedisct1/go-minisign v0.0.0-20211028175153-1c139d1cc84b h1:ZGiXF8sz7PDk6RgkP+A/SFfUD0ZR/AgG6SpRNEDKZy8= +github.com/jedisct1/go-minisign v0.0.0-20211028175153-1c139d1cc84b/go.mod h1:hQmNrgofl+IY/8L+n20H6E6PWBBTokdsv+q49j0QhsU= +github.com/jellydator/ttlcache/v3 v3.4.0 h1:YS4P125qQS0tNhtL6aeYkheEaB/m8HCqdMMP4mnWdTY= +github.com/jellydator/ttlcache/v3 v3.4.0/go.mod h1:Hw9EgjymziQD3yGsQdf1FqFdpp7YjFMd4Srg5EJlgD4= +github.com/jmhodges/clock v1.2.0 h1:eq4kys+NI0PLngzaHEe7AmPT90XMGIEySD1JfV1PDIs= +github.com/jmhodges/clock v1.2.0/go.mod h1:qKjhA7x7u/lQpPB1XAqX1b1lCI/w3/fNuYpI/ZjLynI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/letsencrypt/boulder v0.20260309.0 h1:kZynrxK3QfqLGx6hhoz+Rfs3hgltJs1p9Mp+4+VwnY0= +github.com/letsencrypt/boulder v0.20260309.0/go.mod h1:yG8lj8pNPZ8taq3oNdTpfBS+eC74IaEuiewqzVpXiWE= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/natefinch/atomic v1.0.1 h1:ZPYKxkqQOx3KZ+RsbnP/YsgvxWQPGxjC0oBt2AhwV0A= +github.com/natefinch/atomic v1.0.1/go.mod h1:N/D/ELrljoqDyT3rZrsUmtsuzvHkeB/wWjHV22AZRbM= +github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s= +github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= +github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= +github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc= +github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= +github.com/revanite-io/grc-store-protocol v0.5.0 h1:u/CvDAQef6wQQpcDajGY+AhY8ETbh0Uu05wj5PeWRYw= +github.com/revanite-io/grc-store-protocol v0.5.0/go.mod h1:naeWSRGJ9dtyzndHdsA4jrENlSmSlIgLQDAKRSpPhwU= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= +github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= +github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= +github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= +github.com/sassoftware/relic v7.2.1+incompatible h1:Pwyh1F3I0r4clFJXkSI8bOyJINGqpgjJU3DYAZeI05A= +github.com/sassoftware/relic v7.2.1+incompatible/go.mod h1:CWfAxv73/iLZ17rbyhIEq3K9hs5w6FpNMdUT//qR+zk= +github.com/sassoftware/relic/v7 v7.6.2 h1:rS44Lbv9G9eXsukknS4mSjIAuuX+lMq/FnStgmZlUv4= +github.com/sassoftware/relic/v7 v7.6.2/go.mod h1:kjmP0IBVkJZ6gXeAu35/KCEfca//+PKM6vTAsyDPY+k= +github.com/secure-systems-lab/go-securesystemslib v0.11.0 h1:iuCR9kcMFD4QurdKrGvPLoKZLv9YvwPYVr0473BdtFs= +github.com/secure-systems-lab/go-securesystemslib v0.11.0/go.mod h1:+PMOTjUGwHj2vcZ+TFKlb1tXRbrdWE1LYDT5i9JC80Q= +github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= +github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= +github.com/shibumi/go-pathspec v1.3.0 h1:QUyMZhFo0Md5B8zV8x2tesohbb5kfbpTi9rBnKh5dkI= +github.com/shibumi/go-pathspec v1.3.0/go.mod h1:Xutfslp817l2I1cZvgcfeMQJG5QnU2lh5tVaaMCl3jE= +github.com/sigstore/protobuf-specs v0.5.1 h1:/5OPaNuolRJmQfeZLayJGFXMpsRJEdgC6ah1/+7Px7U= +github.com/sigstore/protobuf-specs v0.5.1/go.mod h1:DRBzpFuE+LnvQMN10/dU6nBeKwVLGEQ6o2FovN2Rats= +github.com/sigstore/rekor v1.5.2 h1:k6pX4o1zFAzAvDbXiVIp5IHj1b0wcDaxsbsbNpuRO8o= +github.com/sigstore/rekor v1.5.2/go.mod h1:WkMnITBccOFauPkT6yte74tF5gC83pefKRGZvNOsbjI= +github.com/sigstore/rekor-tiles/v2 v2.2.2-0.20260601073857-5d098a2b6443 h1:/CO8F6m3Bo/f59bZo5dv1sTIfUnQqVnepIdDV24KoDw= +github.com/sigstore/rekor-tiles/v2 v2.2.2-0.20260601073857-5d098a2b6443/go.mod h1:w1h8wF8vq9lHjmtRdwJiEaoVxhP+WHIMpj4M39pkzp0= +github.com/sigstore/sigstore v1.10.8 h1:1Mgkxvkw4AXMfIP1DOjc6kw0GkUgA8pGVpveN/EfOq4= +github.com/sigstore/sigstore v1.10.8/go.mod h1:f9+B/4iaYimvUkySyb2mvc73n3RLqNn24grHZM/ET8M= +github.com/sigstore/sigstore-go v1.1.4 h1:wTTsgCHOfqiEzVyBYA6mDczGtBkN7cM8mPpjJj5QvMg= +github.com/sigstore/sigstore-go v1.1.4/go.mod h1:2U/mQOT9cjjxrtIUeKDVhL+sHBKsnWddn8URlswdBsg= +github.com/sigstore/sigstore/pkg/signature/kms/aws v1.10.8 h1:tofVQ+UWJgad/69I5zbqxdFCN5gpIn9tRQP7iBzIpBw= +github.com/sigstore/sigstore/pkg/signature/kms/aws v1.10.8/go.mod h1:73AfJE8H6w5KGCFPBu4x/OG+i1Yxgmh0L/FtV7prd88= +github.com/sigstore/sigstore/pkg/signature/kms/azure v1.10.8 h1:8Mt7J36GcUEmbiJaiFhz2tud5ZIgkfVVCe2H/WJCHmw= +github.com/sigstore/sigstore/pkg/signature/kms/azure v1.10.8/go.mod h1:YiTpAsxoWXhF9KlLOVWCh7BckN5cYO8X01WufDq1ido= +github.com/sigstore/sigstore/pkg/signature/kms/gcp v1.10.8 h1:MxpAIMZVzn0Tpbarc9ax1I498oQBp7oYSMgoMSsOmKI= +github.com/sigstore/sigstore/pkg/signature/kms/gcp v1.10.8/go.mod h1:bnAUEkFNam6STvkVZhptVwWzWR5pS24CEtQ+lhxu7S0= +github.com/sigstore/sigstore/pkg/signature/kms/hashivault v1.10.8 h1:1DGe4/clcdOnkz5MINEczWlmEvjUtZd+AjPPT/cBhQ8= +github.com/sigstore/sigstore/pkg/signature/kms/hashivault v1.10.8/go.mod h1:6IDFhpgxtzqbnzrFkyegbj7RfWwKeRrb3/+xAD1Wp+Y= +github.com/sigstore/timestamp-authority/v2 v2.1.2 h1:7DDhnknLL4w8VwomyvW2W8qblOS9LDR8oihna+jc7Ls= +github.com/sigstore/timestamp-authority/v2 v2.1.2/go.mod h1:o6rAVZceFyejClIj/uStRNIemP16bVMZtbMmhk6pr0U= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= +github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/theupdateframework/go-tuf v0.7.0 h1:CqbQFrWo1ae3/I0UCblSbczevCCbS31Qvs5LdxRWqRI= +github.com/theupdateframework/go-tuf v0.7.0/go.mod h1:uEB7WSY+7ZIugK6R1hiBMBjQftaFzn7ZCDJcp1tCUug= +github.com/theupdateframework/go-tuf/v2 v2.4.2-0.20260407074541-7e8f69f906ef h1:jJac5InhEfD0Z46/d5RayZjoavf/se7bPZpOgg8GLrM= +github.com/theupdateframework/go-tuf/v2 v2.4.2-0.20260407074541-7e8f69f906ef/go.mod h1:cLUSJ2cgR194lNWfp+TJT4P8PX7qGleCXdudqlCMtOE= +github.com/tink-crypto/tink-go-awskms/v3 v3.0.0 h1:XSohRhCkXAVI0iaCnWB/GS05TEmpnKurQmzaY1jzt3Y= +github.com/tink-crypto/tink-go-awskms/v3 v3.0.0/go.mod h1:+7MXsShLzVbSQ6dI0Pe4JuZM52jD1jQ1itAygd/MDsA= +github.com/tink-crypto/tink-go-gcpkms/v2 v2.2.0 h1:3B9i6XBXNTRspfkTC0asN5W0K6GhOSgcujNiECNRNb0= +github.com/tink-crypto/tink-go-gcpkms/v2 v2.2.0/go.mod h1:jY5YN2BqD/KSCHM9SqZPIpJNG/u3zwfLXHgws4x2IRw= +github.com/tink-crypto/tink-go-hcvault/v2 v2.5.0 h1:eXuNqgrcYelxU1MVikOJDP3wTS5lvihM4ntoAbAMfvs= +github.com/tink-crypto/tink-go-hcvault/v2 v2.5.0/go.mod h1:3RhcxAqek6xUlRFmJifvU4CYLZN60KMQdIKqpZAZJG0= +github.com/tink-crypto/tink-go/v2 v2.6.0 h1:+KHNBHhWH33Vn+igZWcsgdEPUxKwBMEe0QC60t388v4= +github.com/tink-crypto/tink-go/v2 v2.6.0/go.mod h1:2WbBA6pfNsAfBwDCggboaHeB2X29wkU8XHtGwh2YIk8= +github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399 h1:e/5i7d4oYZ+C1wj2THlRK+oAhjeS/TRQwMfkIuet3w0= +github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399/go.mod h1:LdwHTNJT99C5fTAzDz0ud328OgXz+gierycbcIx2fRs= +github.com/transparency-dev/formats v0.1.1 h1:4bVHJc+KdBgpA1OJD1yjI+g0i5Z1graCppTMH8lWKJI= +github.com/transparency-dev/formats v0.1.1/go.mod h1:qtZ8goRuJ8FTBG9c9+Bj0rn2rUG7eG/AUTkr+Aw3jFw= +github.com/transparency-dev/merkle v0.0.2 h1:Q9nBoQcZcgPamMkGn7ghV8XiTZ/kRxn1yCG81+twTK4= +github.com/transparency-dev/merkle v0.0.2/go.mod h1:pqSy+OXefQ1EDUVmAJ8MUhHB9TXGuzVAT58PqBoHz1A= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= +github.com/ysmood/fetchup v0.2.3 h1:ulX+SonA0Vma5zUFXtv52Kzip/xe7aj4vqT5AJwQ+ZQ= +github.com/ysmood/fetchup v0.2.3/go.mod h1:xhibcRKziSvol0H1/pj33dnKrYyI2ebIvz5cOOkYGns= +github.com/ysmood/goob v0.4.0 h1:HsxXhyLBeGzWXnqVKtmT9qM7EuVs/XOgkX7T6r1o1AQ= +github.com/ysmood/goob v0.4.0/go.mod h1:u6yx7ZhS4Exf2MwciFr6nIM8knHQIE22lFpWHnfql18= +github.com/ysmood/got v0.40.0 h1:ZQk1B55zIvS7zflRrkGfPDrPG3d7+JOza1ZkNxcc74Q= +github.com/ysmood/got v0.40.0/go.mod h1:W7DdpuX6skL3NszLmAsC5hT7JAhuLZhByVzHTq874Qg= +github.com/ysmood/gson v0.7.3 h1:QFkWbTH8MxyUTKPkVWAENJhxqdBa4lYTQWqZCiLG6kE= +github.com/ysmood/gson v0.7.3/go.mod h1:3Kzs5zDl21g5F/BlLTNcuAGAYLKt2lV5G8D1zF3RNmg= +github.com/ysmood/leakless v0.9.0 h1:qxCG5VirSBvmi3uynXFkcnLMzkphdh3xx5FtrORwDCU= +github.com/ysmood/leakless v0.9.0/go.mod h1:R8iAXPRaG97QJwqxs74RdwzcRHT1SWCGTNqY8q0JvMQ= +github.com/zalando/go-keyring v0.2.3 h1:v9CUu9phlABObO4LPWycf+zwMG7nlbb3t/B5wa97yms= +github.com/zalando/go-keyring v0.2.3/go.mod h1:HL4k+OXQfJUWaMnqyuSOc0drfGPX2b51Du6K+MRgZMk= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 h1:yI1/OhfEPy7J9eoa6Sj051C7n5dvpj0QX8g4sRchg04= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0/go.mod h1:NoUCKYWK+3ecatC4HjkRktREheMeEtrXoQxrqYFeHSc= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +go.step.sm/crypto v0.77.7 h1:6azC+pD678Vjju8yXnMDHCZJ+HzFaEmL3sCryiezTIA= +go.step.sm/crypto v0.77.7/go.mod h1:OW/2sEHwTtDKq70PvSQ5B0JGy/CrLyDKOiVy3YvZMTQ= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= +go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= +go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= +go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= +golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= +golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= +golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= +golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/api v0.280.0 h1:F4OfEHZhZh6a7uTufJAXXVd/2TQ8EjM4vZH+jX/vFYk= +google.golang.org/api v0.280.0/go.mod h1:oGKmPZRDoD3vdkf6MA7F4VNkR1rxCiuaPSkhsf3EolU= +google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0= +google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I= +google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8= +google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260523011958-0a33c5d7ca68 h1:PvEgGJf9C/1u5CHkInMg7UFYYUoiaQmW2LbtH0pjB78= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260523011958-0a33c5d7ca68/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= +google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc= +k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0= +oras.land/oras-go/v2 v2.6.0 h1:X4ELRsiGkrbeox69+9tzTu492FMUu7zJQW6eJU+I2oc= +oras.land/oras-go/v2 v2.6.0/go.mod h1:magiQDfG6H1O9APp+rOsvCPcW1GD2MM7vgnKY0Y+u1o= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= +software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k= +software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= diff --git a/internal/cache/cache.go b/internal/cache/cache.go new file mode 100644 index 0000000..2fec63d --- /dev/null +++ b/internal/cache/cache.go @@ -0,0 +1,243 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Package cache is a Go-module-style on-disk cache for artifacts grcli pulls +// (ADR-0039, extended by ADR-0042). grc.store tags are immutable (ADR-0033), +// so a coordinate (host, namespace, id, version) maps to fixed bytes forever — +// a cache hit can never be stale, which is what makes this sound. Entries are +// host-namespaced so prod, staging, and self-hosted hubs stay separate. (Caveat: +// coordinate components are sanitized to single path segments, so two coordinates +// that differ only in characters sanitize() folds together — e.g. "a/b" vs "a_b" +// — would alias the same entry. A collision-resistant encoding is a tracked +// follow-up; today's coordinates don't hit it.) +// +// An entry stores the complete decoded bundle: every artifact file plus the +// bundle.json manifest (when present), enough to serve both `unpack` (write the +// dir) and `cat` (stream the content) offline. It does not store the raw OCI +// layout or the cosign signature, so `verify` still fetches from the registry. +// There is intentionally no eviction in this version; the cache grows like Go's +// module cache. +package cache + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" +) + +// layoutVersion namespaces the on-disk layout so a format change can coexist +// with old entries instead of misreading them. v2 (ADR-0042) stores a full +// bundle; v1 entries (single body, ADR-0039) are simply never read. +const layoutVersion = "v2" + +// File is one artifact file in a cached bundle. Data is held separately from the +// persisted meta.json — each file is its own blob on disk. +type File struct { + Name string + Data []byte +} + +// Entry is a cached bundle plus the metadata recorded about it. +type Entry struct { + // Files are the bundle's artifact files (bundle.Files), in order. + Files []File + // Manifest is the bundle.json bytes (the JSON-encoded OCI manifest, with any + // SLSA-shaped provenance). Nil for an entry that carries no manifest. + Manifest []byte + + // ManifestDigest is the artifact's OCI manifest digest (its identity on the + // hub — bundle.Etag), recorded for provenance. + ManifestDigest string + // License is the artifact's own publication license (canonical SPDX). + License string + // LicenseChecked records that a hub license lookup SUCCEEDED for this + // entry (even if the catalog records no license). It distinguishes + // "hub confirmed no license — stop asking" from "lookup failed or never + // attempted — retry on a later hit", so a license-less coordinate is + // healed at most once instead of paying a live hub call on every hit. + LicenseChecked bool + // SourceURL is the reference URL this entry was resolved from, if any. + SourceURL string + // Verified records whether the bytes were signature-verified. Always false + // for now — verify-on-pull is deferred (ADR-0039 amendment) — but persisted + // so a later pass can upgrade entries in place. + Verified bool +} + +// entryMeta is the persisted meta.json. File bodies and bundle.json live in +// their own files; meta.json records their names and digests. +type entryMeta struct { + Files []fileMeta `json:"files"` + Manifest *fileMeta `json:"manifest,omitempty"` + ManifestDigest string `json:"manifest_digest,omitempty"` + License string `json:"license,omitempty"` + LicenseChecked bool `json:"license_checked,omitempty"` + SourceURL string `json:"source_url,omitempty"` + Verified bool `json:"verified"` +} + +// fileMeta records one stored blob's original name and content digest. +type fileMeta struct { + Name string `json:"name"` + Digest string `json:"digest"` +} + +// manifestFile is the fixed on-disk name for the cached bundle.json. +const manifestFile = "bundle.json" + +// Cache is a handle to an on-disk cache rooted at a directory. +type Cache struct { + root string +} + +// Open returns a Cache rooted at $GRCLI_CACHE if set, else +// os.UserCacheDir()/grcli. The directory is created lazily on Put. +func Open() (*Cache, error) { + root := os.Getenv("GRCLI_CACHE") + if root == "" { + ucd, err := os.UserCacheDir() + if err != nil { + return nil, fmt.Errorf("resolving user cache dir (set $GRCLI_CACHE to override): %w", err) + } + root = filepath.Join(ucd, "grcli") + } + return &Cache{root: root}, nil +} + +// Root is the cache's base directory (for diagnostics). +func (c *Cache) Root() string { return c.root } + +// entryDir is the directory holding one coordinate's files. Components are +// sanitized so a hostile coordinate can't escape the cache root. +func (c *Cache) entryDir(host, namespace, id, version string) string { + return filepath.Join(c.root, layoutVersion, + sanitize(host), sanitize(namespace), sanitize(id), sanitize(version)) +} + +// Get returns the cached entry for a coordinate. found is false when the entry +// is absent. A present-but-corrupt entry (any blob's digest mismatches, or a +// recorded blob is missing) returns found=false with a non-nil error so the +// caller can warn and re-fetch. +func (c *Cache) Get(host, namespace, id, version string) (entry *Entry, found bool, err error) { + dir := c.entryDir(host, namespace, id, version) + metaBytes, err := os.ReadFile(filepath.Join(dir, "meta.json")) + if errors.Is(err, os.ErrNotExist) { + return nil, false, nil + } + if err != nil { + return nil, false, fmt.Errorf("reading cache metadata: %w", err) + } + var m entryMeta + if err := json.Unmarshal(metaBytes, &m); err != nil { + return nil, false, fmt.Errorf("decoding cache metadata for %s/%s@%s: %w", namespace, id, version, err) + } + + e := &Entry{ + ManifestDigest: m.ManifestDigest, + License: m.License, + LicenseChecked: m.LicenseChecked, + SourceURL: m.SourceURL, + Verified: m.Verified, + } + for i, fm := range m.Files { + data, rerr := readBlob(dir, "files", strconv.Itoa(i), fm, namespace, id, version) + if rerr != nil { + return nil, false, rerr + } + e.Files = append(e.Files, File{Name: fm.Name, Data: data}) + } + if m.Manifest != nil { + data, rerr := readBlob(dir, "", manifestFile, *m.Manifest, namespace, id, version) + if rerr != nil { + return nil, false, rerr + } + e.Manifest = data + } + return e, true, nil +} + +// readBlob reads dir/[sub/]name, verifying its digest against the recorded +// fileMeta. A missing or corrupt blob is an error so the caller re-fetches. +func readBlob(dir, sub, name string, fm fileMeta, namespace, id, version string) ([]byte, error) { + path := filepath.Join(dir, sub, name) + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("reading cached %s for %s/%s@%s: %w", fm.Name, namespace, id, version, err) + } + if got := digestOf(data); got != fm.Digest { + return nil, fmt.Errorf("cached %s for %s/%s@%s is corrupt (digest %s != recorded %s)", + fm.Name, namespace, id, version, got, fm.Digest) + } + return data, nil +} + +// Put writes an entry to the cache, computing and recording each blob's digest. +// What makes a later Get safe is the per-blob digest check on read: a blob whose +// bytes don't match the digest recorded in meta.json is rejected as corrupt. +// meta.json is written last only so a half-written brand-new entry reads as a +// clean miss (no meta.json ⇒ found=false) rather than a partial hit. +func (c *Cache) Put(host, namespace, id, version string, e Entry) error { + dir := c.entryDir(host, namespace, id, version) + filesDir := filepath.Join(dir, "files") + if err := os.MkdirAll(filesDir, 0o755); err != nil { + return fmt.Errorf("creating cache dir: %w", err) + } + + m := entryMeta{ + ManifestDigest: e.ManifestDigest, + License: e.License, + LicenseChecked: e.LicenseChecked, + SourceURL: e.SourceURL, + Verified: e.Verified, + } + for i, f := range e.Files { + if err := os.WriteFile(filepath.Join(filesDir, strconv.Itoa(i)), f.Data, 0o644); err != nil { + return fmt.Errorf("writing cache file %q: %w", f.Name, err) + } + m.Files = append(m.Files, fileMeta{Name: f.Name, Digest: digestOf(f.Data)}) + } + if e.Manifest != nil { + if err := os.WriteFile(filepath.Join(dir, manifestFile), e.Manifest, 0o644); err != nil { + return fmt.Errorf("writing cache manifest: %w", err) + } + m.Manifest = &fileMeta{Name: manifestFile, Digest: digestOf(e.Manifest)} + } + + metaBytes, err := json.MarshalIndent(m, "", " ") + if err != nil { + return fmt.Errorf("encoding cache metadata: %w", err) + } + if err := os.WriteFile(filepath.Join(dir, "meta.json"), metaBytes, 0o644); err != nil { + return fmt.Errorf("writing cache metadata: %w", err) + } + return nil +} + +// Digest returns the sha256 content digest of b in "sha256:" form — the +// same value recorded on a cache blob, exported so callers can record it for +// content that bypasses the cache (e.g. under --no-cache). +func Digest(b []byte) string { return digestOf(b) } + +func digestOf(b []byte) string { + sum := sha256.Sum256(b) + return "sha256:" + hex.EncodeToString(sum[:]) +} + +// sanitize reduces a coordinate component to a safe single path segment: +// path separators and parent-dir tokens can't survive, so the join stays +// within the cache root. +func sanitize(s string) string { + s = strings.ReplaceAll(s, "/", "_") + s = strings.ReplaceAll(s, "\\", "_") + s = strings.ReplaceAll(s, "..", "_") + s = strings.TrimSpace(s) + if s == "" { + return "_" + } + return s +} diff --git a/internal/cache/cache_test.go b/internal/cache/cache_test.go new file mode 100644 index 0000000..0f8638e --- /dev/null +++ b/internal/cache/cache_test.go @@ -0,0 +1,212 @@ +// SPDX-License-Identifier: Apache-2.0 + +package cache + +import ( + "os" + "path/filepath" + "testing" +) + +func openTemp(t *testing.T) *Cache { + t.Helper() + t.Setenv("GRCLI_CACHE", t.TempDir()) + c, err := Open() + if err != nil { + t.Fatalf("Open: %v", err) + } + return c +} + +func TestPutGetRoundtrip(t *testing.T) { + c := openTemp(t) + in := Entry{ + Files: []File{ + {Name: "controls.yaml", Data: []byte("id: acme\n")}, + {Name: "mappings.yaml", Data: []byte("maps: []\n")}, + }, + Manifest: []byte(`{"schemaVersion":2}`), + License: "Apache-2.0", + LicenseChecked: true, + ManifestDigest: "sha256:abc", + SourceURL: "https://grc.store/acme/x", + } + if err := c.Put("hub.grc.store", "acme", "x", "1.0.0", in); err != nil { + t.Fatalf("Put: %v", err) + } + + got, found, err := c.Get("hub.grc.store", "acme", "x", "1.0.0") + if err != nil || !found { + t.Fatalf("Get: found=%v err=%v", found, err) + } + if len(got.Files) != 2 { + t.Fatalf("got %d files, want 2", len(got.Files)) + } + for i, want := range in.Files { + if got.Files[i].Name != want.Name || string(got.Files[i].Data) != string(want.Data) { + t.Errorf("file %d = %+v, want %+v", i, got.Files[i], want) + } + } + if string(got.Manifest) != string(in.Manifest) { + t.Errorf("manifest = %q, want %q", got.Manifest, in.Manifest) + } + if got.License != "Apache-2.0" || got.ManifestDigest != "sha256:abc" || got.SourceURL != in.SourceURL { + t.Errorf("metadata not round-tripped: %+v", got) + } + if !got.LicenseChecked { + t.Error("LicenseChecked not round-tripped") + } + if got.Verified { + t.Error("Verified should default to false") + } +} + +func TestPutGetNoManifest(t *testing.T) { + c := openTemp(t) + in := Entry{Files: []File{{Name: "body.json", Data: []byte(`{"a":1}`)}}} + if err := c.Put("hub.grc.store", "acme", "x", "1.0.0", in); err != nil { + t.Fatalf("Put: %v", err) + } + got, found, err := c.Get("hub.grc.store", "acme", "x", "1.0.0") + if err != nil || !found { + t.Fatalf("Get: found=%v err=%v", found, err) + } + if got.Manifest != nil { + t.Errorf("Manifest = %q, want nil", got.Manifest) + } + if len(got.Files) != 1 || string(got.Files[0].Data) != `{"a":1}` { + t.Errorf("files = %+v", got.Files) + } +} + +func TestGetMissing(t *testing.T) { + c := openTemp(t) + _, found, err := c.Get("hub.grc.store", "acme", "absent", "1.0.0") + if found || err != nil { + t.Fatalf("Get on missing: found=%v err=%v, want false/nil", found, err) + } +} + +func TestGetDetectsFileCorruption(t *testing.T) { + c := openTemp(t) + if err := c.Put("hub.grc.store", "acme", "x", "1.0.0", Entry{Files: []File{{Name: "controls.yaml", Data: []byte("original")}}}); err != nil { + t.Fatalf("Put: %v", err) + } + // Tamper with the stored file so its digest no longer matches meta.json. + blob := filepath.Join(c.entryDir("hub.grc.store", "acme", "x", "1.0.0"), "files", "0") + if err := os.WriteFile(blob, []byte("tampered"), 0o644); err != nil { + t.Fatalf("tamper: %v", err) + } + _, found, err := c.Get("hub.grc.store", "acme", "x", "1.0.0") + if found { + t.Error("corrupt entry should not be reported as found") + } + if err == nil { + t.Error("corrupt entry should return an error so the caller re-fetches") + } +} + +func TestGetDetectsCorruptionAtNonZeroIndex(t *testing.T) { + c := openTemp(t) + in := Entry{Files: []File{ + {Name: "a.yaml", Data: []byte("first")}, + {Name: "b.yaml", Data: []byte("second")}, + }} + if err := c.Put("hub.grc.store", "acme", "x", "1.0.0", in); err != nil { + t.Fatalf("Put: %v", err) + } + // Tamper the SECOND file — corruption must be caught regardless of index. + blob := filepath.Join(c.entryDir("hub.grc.store", "acme", "x", "1.0.0"), "files", "1") + if err := os.WriteFile(blob, []byte("tampered"), 0o644); err != nil { + t.Fatalf("tamper: %v", err) + } + _, found, err := c.Get("hub.grc.store", "acme", "x", "1.0.0") + if found || err == nil { + t.Errorf("corrupt file at index 1: found=%v err=%v, want false/non-nil", found, err) + } +} + +// TestV1EntriesAreIgnored guards the "no migration" decision (ADR-0042 dec. 3): +// a v1-shaped entry on disk must not be read at the v2 coordinate. This is the +// invariant the whole layoutVersion bump rests on. +func TestV1EntriesAreIgnored(t *testing.T) { + c := openTemp(t) + // Hand-build a v1 entry (single body.json + flat meta.json) at the v1 path. + v1dir := filepath.Join(c.Root(), "v1", "hub.grc.store", "acme", "x", "1.0.0") + if err := os.MkdirAll(v1dir, 0o755); err != nil { + t.Fatalf("mkdir v1: %v", err) + } + if err := os.WriteFile(filepath.Join(v1dir, "body.json"), []byte(`{"legacy":true}`), 0o644); err != nil { + t.Fatalf("write v1 body: %v", err) + } + if err := os.WriteFile(filepath.Join(v1dir, "meta.json"), []byte(`{"ext":"json"}`), 0o644); err != nil { + t.Fatalf("write v1 meta: %v", err) + } + // The v2 Get must treat this coordinate as absent (clean miss, no error). + _, found, err := c.Get("hub.grc.store", "acme", "x", "1.0.0") + if found || err != nil { + t.Fatalf("v1 entry leaked into v2 read: found=%v err=%v, want false/nil", found, err) + } +} + +func TestGetDetectsManifestCorruption(t *testing.T) { + c := openTemp(t) + in := Entry{Files: []File{{Name: "controls.yaml", Data: []byte("ok")}}, Manifest: []byte(`{"schemaVersion":2}`)} + if err := c.Put("hub.grc.store", "acme", "x", "1.0.0", in); err != nil { + t.Fatalf("Put: %v", err) + } + mf := filepath.Join(c.entryDir("hub.grc.store", "acme", "x", "1.0.0"), manifestFile) + if err := os.WriteFile(mf, []byte(`{"tampered":true}`), 0o644); err != nil { + t.Fatalf("tamper: %v", err) + } + _, found, err := c.Get("hub.grc.store", "acme", "x", "1.0.0") + if found || err == nil { + t.Errorf("corrupt manifest: found=%v err=%v, want false/non-nil", found, err) + } +} + +func TestGetDetectsMissingBlob(t *testing.T) { + c := openTemp(t) + if err := c.Put("hub.grc.store", "acme", "x", "1.0.0", Entry{Files: []File{{Name: "controls.yaml", Data: []byte("ok")}}}); err != nil { + t.Fatalf("Put: %v", err) + } + blob := filepath.Join(c.entryDir("hub.grc.store", "acme", "x", "1.0.0"), "files", "0") + if err := os.Remove(blob); err != nil { + t.Fatalf("remove: %v", err) + } + _, found, err := c.Get("hub.grc.store", "acme", "x", "1.0.0") + if found || err == nil { + t.Errorf("missing blob: found=%v err=%v, want false/non-nil", found, err) + } +} + +func TestHostNamespacingPreventsCollision(t *testing.T) { + c := openTemp(t) + if err := c.Put("hub.grc.store", "acme", "x", "1.0.0", Entry{Files: []File{{Name: "b", Data: []byte("prod")}}}); err != nil { + t.Fatalf("Put prod: %v", err) + } + if err := c.Put("hub.preview.grc.store", "acme", "x", "1.0.0", Entry{Files: []File{{Name: "b", Data: []byte("staging")}}}); err != nil { + t.Fatalf("Put staging: %v", err) + } + prod, _, _ := c.Get("hub.grc.store", "acme", "x", "1.0.0") + staging, _, _ := c.Get("hub.preview.grc.store", "acme", "x", "1.0.0") + if string(prod.Files[0].Data) != "prod" || string(staging.Files[0].Data) != "staging" { + t.Errorf("hosts collided: prod=%q staging=%q", prod.Files[0].Data, staging.Files[0].Data) + } +} + +func TestSanitizeBlocksTraversal(t *testing.T) { + c := openTemp(t) + // A hostile version component must not escape the cache root. + if err := c.Put("hub.grc.store", "acme", "x", "../../etc", Entry{Files: []File{{Name: "b", Data: []byte("x")}}}); err != nil { + t.Fatalf("Put: %v", err) + } + dir := c.entryDir("hub.grc.store", "acme", "x", "../../etc") + rel, err := filepath.Rel(c.Root(), dir) + if err != nil { + t.Fatalf("Rel: %v", err) + } + if filepath.IsAbs(rel) || rel == ".." || len(rel) >= 2 && rel[0] == '.' && rel[1] == '.' { + t.Errorf("entry dir %q escaped cache root %q (rel %q)", dir, c.Root(), rel) + } +} diff --git a/internal/digest/digest.go b/internal/digest/digest.go new file mode 100644 index 0000000..c3af743 --- /dev/null +++ b/internal/digest/digest.go @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Package digest computes sha256 digests over bytes or files and returns +// them in the "sha256:" format used throughout grcli's manifests, +// provenance records, and source digests. +package digest + +import ( + "crypto/sha256" + "encoding/hex" + "io" + "os" +) + +// Bytes returns the sha256 digest of b as "sha256:". +func Bytes(b []byte) string { + sum := sha256.Sum256(b) + return "sha256:" + hex.EncodeToString(sum[:]) +} + +// File streams path through sha256 and returns the digest as +// "sha256:". The file is not loaded entirely into memory. +func File(path string) (string, error) { + file, err := os.Open(path) + if err != nil { + return "", err + } + defer file.Close() //nolint:errcheck + hasher := sha256.New() + if _, err := io.Copy(hasher, file); err != nil { + return "", err + } + return "sha256:" + hex.EncodeToString(hasher.Sum(nil)), nil +} diff --git a/internal/hub/discover.go b/internal/hub/discover.go new file mode 100644 index 0000000..353f518 --- /dev/null +++ b/internal/hub/discover.go @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: Apache-2.0 + +package hub + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "sync" + "time" + + "github.com/revanite-io/grc-store-protocol/discovery" +) + +// Discovery is the GET /.well-known/grc-store-configuration document. It is aliased to the +// shared wire-contract type (ADR-0035) — the same definition the hub serves and +// pvtr consumes — so the three can't drift. The CI-audience field is named +// CIAudience on the shared type (was CIOIDCAudience here). +type Discovery = discovery.Document + +// wellKnownPath is appended to the user-supplied hub base URL. RFC +// 8615 §3 'ext.' prefix avoids needing IANA registration. +const wellKnownPath = "/.well-known/grc-store-configuration" + +// discoveryCache holds one Discovery per normalized base URL for the +// process lifetime. No on-disk cache — discovery is cheap and we want +// the fresh value on every invocation. +var discoveryCache sync.Map // map[string]*Discovery + +// Discover fetches the well-known discovery doc from the hub at baseURL. +// Validates that registry_url is present and non-empty; on any failure +// returns an error that names the URL grcli used and what was expected +// so the user knows whether to blame the hub, the network, or the flag. +// +// The result is cached per normalized baseURL for the process lifetime. +// A second call with the same baseURL is a map lookup, no HTTP. +func Discover(ctx context.Context, baseURL string) (*Discovery, error) { + key := strings.TrimRight(baseURL, "/") + if key == "" { + return nil, errors.New("hub base URL is required") + } + if cached, ok := discoveryCache.Load(key); ok { + return cached.(*Discovery), nil + } + + url := key + wellKnownPath + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("building discovery request for %s: %w", url, err) + } + req.Header.Set("Accept", "application/json") + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("fetching %s: %w", url, err) + } + defer resp.Body.Close() //nolint:errcheck + + body, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10)) + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("hub discovery at %s returned %d: %s", url, resp.StatusCode, strings.TrimSpace(string(body))) + } + + d := &Discovery{} + if err := json.Unmarshal(body, d); err != nil { + return nil, fmt.Errorf("decoding hub discovery at %s: %w (body: %s)", url, err, strings.TrimSpace(string(body))) + } + if d.RegistryURL == "" { + return nil, fmt.Errorf("hub discovery at %s did not advertise registry_url; the hub is misconfigured (HUB_OCI_PUBLIC_URL must be set)", url) + } + + discoveryCache.Store(key, d) + return d, nil +} + +// resetDiscoveryCacheForTest is the test-only escape hatch for clearing +// the package-level cache between subtests. Not exported. +func resetDiscoveryCacheForTest() { + discoveryCache.Range(func(k, _ any) bool { + discoveryCache.Delete(k) + return true + }) +} diff --git a/internal/hub/discover_test.go b/internal/hub/discover_test.go new file mode 100644 index 0000000..f19ce46 --- /dev/null +++ b/internal/hub/discover_test.go @@ -0,0 +1,188 @@ +// SPDX-License-Identifier: Apache-2.0 + +package hub + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" +) + +func TestDiscover(t *testing.T) { + t.Run("happy path returns parsed discovery doc", func(t *testing.T) { + resetDiscoveryCacheForTest() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/.well-known/grc-store-configuration" { + t.Errorf("requested path = %q, want /.well-known/grc-store-configuration", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"registry_url":"https://registry.example","hub_url":"https://hub.example","api_version":"v1"}`)) + })) + defer srv.Close() + + d, err := Discover(context.Background(), srv.URL) + if err != nil { + t.Fatalf("Discover error: %v", err) + } + if d.RegistryURL != "https://registry.example" { + t.Errorf("RegistryURL = %q, want https://registry.example", d.RegistryURL) + } + if d.HubURL != "https://hub.example" { + t.Errorf("HubURL = %q, want https://hub.example", d.HubURL) + } + if d.APIVersion != "v1" { + t.Errorf("APIVersion = %q, want v1", d.APIVersion) + } + }) + + t.Run("cache hit avoids a second HTTP call", func(t *testing.T) { + resetDiscoveryCacheForTest() + var hits int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + atomic.AddInt32(&hits, 1) + _, _ = w.Write([]byte(`{"registry_url":"https://r","hub_url":"https://h","api_version":"v1"}`)) + })) + defer srv.Close() + + for i := 0; i < 3; i++ { + if _, err := Discover(context.Background(), srv.URL); err != nil { + t.Fatalf("call %d error: %v", i, err) + } + } + if got := atomic.LoadInt32(&hits); got != 1 { + t.Errorf("HTTP hits = %d, want 1 (cache should have served calls 2 and 3)", got) + } + }) + + t.Run("trailing slash on base URL is normalized to cache hit", func(t *testing.T) { + resetDiscoveryCacheForTest() + var hits int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + atomic.AddInt32(&hits, 1) + _, _ = w.Write([]byte(`{"registry_url":"https://r","hub_url":"https://h","api_version":"v1"}`)) + })) + defer srv.Close() + + if _, err := Discover(context.Background(), srv.URL); err != nil { + t.Fatalf("first call: %v", err) + } + if _, err := Discover(context.Background(), srv.URL+"/"); err != nil { + t.Fatalf("trailing-slash call: %v", err) + } + if got := atomic.LoadInt32(&hits); got != 1 { + t.Errorf("HTTP hits = %d, want 1 (trailing slash should hit cache)", got) + } + }) + + t.Run("missing registry_url is a loud error", func(t *testing.T) { + resetDiscoveryCacheForTest() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"hub_url":"https://h","api_version":"v1"}`)) + })) + defer srv.Close() + + _, err := Discover(context.Background(), srv.URL) + if err == nil { + t.Fatal("expected error for missing registry_url, got nil") + } + if !strings.Contains(err.Error(), "registry_url") { + t.Errorf("error = %q, want it to mention registry_url", err.Error()) + } + if !strings.Contains(err.Error(), srv.URL) { + t.Errorf("error = %q, want it to name the hub URL we called", err.Error()) + } + }) + + t.Run("malformed JSON surfaces a parse error with body excerpt", func(t *testing.T) { + resetDiscoveryCacheForTest() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`not json {{{`)) + })) + defer srv.Close() + + _, err := Discover(context.Background(), srv.URL) + if err == nil { + t.Fatal("expected error for malformed JSON, got nil") + } + if !strings.Contains(err.Error(), srv.URL) { + t.Errorf("error = %q, want it to name the hub URL", err.Error()) + } + }) + + t.Run("404 surfaces the status code and a useful message", func(t *testing.T) { + resetDiscoveryCacheForTest() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`not found`)) + })) + defer srv.Close() + + _, err := Discover(context.Background(), srv.URL) + if err == nil { + t.Fatal("expected error for 404, got nil") + } + if !strings.Contains(err.Error(), "404") { + t.Errorf("error = %q, want it to include 404", err.Error()) + } + }) + + t.Run("500 surfaces the status code", func(t *testing.T) { + resetDiscoveryCacheForTest() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"error":"registry_url_unconfigured"}`)) + })) + defer srv.Close() + + _, err := Discover(context.Background(), srv.URL) + if err == nil { + t.Fatal("expected error for 500, got nil") + } + if !strings.Contains(err.Error(), "500") { + t.Errorf("error = %q, want it to include 500", err.Error()) + } + }) + + t.Run("empty base URL fails before hitting the network", func(t *testing.T) { + resetDiscoveryCacheForTest() + _, err := Discover(context.Background(), "") + if err == nil { + t.Fatal("expected error for empty base URL, got nil") + } + }) + + t.Run("decodes ci_audience for trusted publishing", func(t *testing.T) { + resetDiscoveryCacheForTest() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"registry_url":"https://r","hub_url":"https://h","api_version":"v1","ci_audience":"https://hub.example/ci"}`)) + })) + defer srv.Close() + + d, err := Discover(context.Background(), srv.URL) + if err != nil { + t.Fatalf("Discover error: %v", err) + } + if d.CIAudience != "https://hub.example/ci" { + t.Errorf("CIAudience = %q, want https://hub.example/ci", d.CIAudience) + } + }) + + t.Run("ci_audience absent leaves the field empty", func(t *testing.T) { + resetDiscoveryCacheForTest() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"registry_url":"https://r","hub_url":"https://h","api_version":"v1"}`)) + })) + defer srv.Close() + + d, err := Discover(context.Background(), srv.URL) + if err != nil { + t.Fatalf("Discover error: %v", err) + } + if d.CIAudience != "" { + t.Errorf("CIAudience = %q, want empty when not advertised", d.CIAudience) + } + }) +} diff --git a/internal/hub/hub.go b/internal/hub/hub.go new file mode 100644 index 0000000..85f0025 --- /dev/null +++ b/internal/hub/hub.go @@ -0,0 +1,314 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Package hub calls the grc.store backend's POST /v1/bundles/sync +// endpoint so the hub indexes a bundle that grcli has already pushed +// to the OCI registry. The request body matches the handler's +// syncRequest struct (internal/server/sync.go in grc.store-backend). +package hub + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + neturl "net/url" + "strings" + "time" + + "github.com/revanite-io/grc-store-protocol/syncapi" +) + +// SyncRequest and SyncResponse are the sync request/reply, aliased to the shared +// wire-contract types (ADR-0035) so grcli and the hub can't drift on them. +type ( + SyncRequest = syncapi.Request + SyncResponse = syncapi.Response +) + +// Client is the typed wrapper around the hub's HTTP API. +type Client struct { + BaseURL string + Token string + HTTP *http.Client +} + +// New returns a Client with sensible defaults. +func New(baseURL, token string) *Client { + return &Client{ + BaseURL: strings.TrimRight(baseURL, "/"), + Token: token, + HTTP: &http.Client{Timeout: 60 * time.Second}, + } +} + +// VersionStatus reports whether a (namespace, catalogID, version) +// coordinate is already taken on the hub. +type VersionStatus int + +const ( + // VersionAbsent — the coordinate is free to publish (hub 404). + VersionAbsent VersionStatus = iota + // VersionPresent — already published at this exact coordinate (hub 200). + VersionPresent + // VersionTombstoned — previously published then yanked (hub 410). The + // coordinate stays permanently taken; versions are immutable. + VersionTombstoned +) + +// VersionExists checks whether a version coordinate is already published, +// via GET /v1/catalogs/{ns}/{id}/versions/{version}. Reads are public, so +// no token is required. Used by `grcli publish` as a pre-flight so it +// halts BEFORE packing/pushing when the version is taken (versions are +// immutable — the registry write would otherwise clobber the existing +// bytes before the hub's sync-time guard could reject it). +func (c *Client) VersionExists(ctx context.Context, namespace, catalogID, version string) (VersionStatus, error) { + if c.BaseURL == "" { + return VersionAbsent, errors.New("hub base URL is required") + } + url := fmt.Sprintf("%s/v1/catalogs/%s/%s/versions/%s", + c.BaseURL, + neturl.PathEscape(namespace), + neturl.PathEscape(catalogID), + neturl.PathEscape(version)) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return VersionAbsent, err + } + req.Header.Set("Accept", "application/json") + + resp, err := c.HTTP.Do(req) + if err != nil { + return VersionAbsent, err + } + defer resp.Body.Close() //nolint:errcheck + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return VersionAbsent, fmt.Errorf("reading version-check response from %s: %w", url, err) + } + + switch resp.StatusCode { + case http.StatusOK: + return VersionPresent, nil + case http.StatusNotFound: + return VersionAbsent, nil + case http.StatusGone: + return VersionTombstoned, nil + default: + // Same shape as GetCatalog's default branch (URL + status + body + // snippet) so an operator chasing a 5xx on either endpoint gets the + // same diagnostic surface. + return VersionAbsent, fmt.Errorf("hub version check %s returned %d: %s", + url, resp.StatusCode, strings.TrimSpace(string(body))) + } +} + +// ErrCatalogNotFound wraps a hub 404 for a catalog coordinate. +// ErrCatalogTombstoned wraps a hub 410 (the catalog was published and +// later yanked — the coordinate stays permanently taken). Both are +// exported so callers can errors.Is against them to distinguish each +// hub-modeled outcome from a transport failure. +var ( + ErrCatalogNotFound = errors.New("catalog not found") + ErrCatalogTombstoned = errors.New("catalog was yanked") +) + +// Release is one published version of a catalog, as returned in the +// releases[] array of GET /v1/catalogs/{ns}/{id}. +type Release struct { + Version string `json:"version"` + ManifestDigest string `json:"manifest_digest"` + PushedAt string `json:"pushed_at"` + // License is this version's publication license (canonical SPDX + // expression), exposed per-release by the hub. Absent when none was + // declared. Used by reference resolution to compare a dependency's + // license against the primary's (ADR-0039). + License string `json:"license,omitempty"` +} + +// ReleaseFor returns the release matching version, or nil if the catalog has +// no such version. +func (c *Catalog) ReleaseFor(version string) *Release { + for i := range c.Releases { + if c.Releases[i].Version == version { + return &c.Releases[i] + } + } + return nil +} + +// Catalog mirrors the JSON returned by GET /v1/catalogs/{ns}/{id}. +// Only the fields the CLI currently surfaces are typed; the hub may +// add more without breaking this client. +type Catalog struct { + Namespace string `json:"namespace"` + CatalogID string `json:"catalog_id"` + Type string `json:"type"` + Category string `json:"category"` + Title string `json:"title"` + Summary string `json:"summary"` + AuthorName string `json:"author_name"` + LatestVersion string `json:"latest_version"` + LatestManifestDigest string `json:"latest_manifest_digest"` + Releases []Release `json:"releases"` + // SignerIdentity is the canonical keyless signer the hub verified and + // TOFU-pinned for this coordinate at ingest — "keyless:#", + // ref-stripped (grc-store-protocol/identity, ADR-0045 decision 6). Absent when + // no signed version has been ingested (or the hub predates hub-side + // verification); grcli verify's zero-flag mode reads it to derive the cosign + // trust policy without the consumer having to know the workflow path. + SignerIdentity string `json:"signer_identity,omitempty"` +} + +// GetCatalog fetches a catalog and its releases via +// GET /v1/catalogs/{ns}/{id}. Reads are public, so no token is required. +// Returns a wrapped ErrCatalogNotFound on 404 and ErrCatalogTombstoned +// on 410 so callers can errors.Is against either to distinguish the +// "no such catalog" and "yanked" cases from a transport failure. +func (c *Client) GetCatalog(ctx context.Context, namespace, catalogID string) (*Catalog, error) { + if c.BaseURL == "" { + return nil, errors.New("hub base URL is required") + } + if namespace == "" || catalogID == "" { + return nil, errors.New("namespace and catalog id are required") + } + url := fmt.Sprintf("%s/v1/catalogs/%s/%s", + c.BaseURL, + neturl.PathEscape(namespace), + neturl.PathEscape(catalogID)) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/json") + + resp, err := c.HTTP.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() //nolint:errcheck + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return nil, fmt.Errorf("reading catalog response from %s: %w", url, err) + } + + switch resp.StatusCode { + case http.StatusOK: + if len(body) == 0 { + return nil, fmt.Errorf("hub catalog lookup %s returned 200 with empty body", url) + } + out := &Catalog{} + if err := json.Unmarshal(body, out); err != nil { + return nil, fmt.Errorf("decoding catalog response: %w", err) + } + return out, nil + case http.StatusNotFound: + return nil, fmt.Errorf("%w: %s/%s", ErrCatalogNotFound, namespace, catalogID) + case http.StatusGone: + return nil, fmt.Errorf("%w: %s/%s", ErrCatalogTombstoned, namespace, catalogID) + default: + return nil, fmt.Errorf("hub catalog lookup %s returned %d: %s", + url, resp.StatusCode, strings.TrimSpace(string(body))) + } +} + +// GetVersionBody fetches a single version's artifact body via +// GET /v1/catalogs/{ns}/{id}/versions/{version}. Reads are public, so no token +// is required. Returns the body bytes and the artifact's OCI manifest digest +// (from the X-Gemara-Manifest-Digest response header). Wraps ErrCatalogNotFound +// on 404 and ErrCatalogTombstoned on 410 (a yanked version) so callers can +// distinguish those from a transport failure. +func (c *Client) GetVersionBody(ctx context.Context, namespace, catalogID, version string) (body []byte, manifestDigest string, err error) { + if c.BaseURL == "" { + return nil, "", errors.New("hub base URL is required") + } + if namespace == "" || catalogID == "" || version == "" { + return nil, "", errors.New("namespace, catalog id, and version are required") + } + url := fmt.Sprintf("%s/v1/catalogs/%s/%s/versions/%s", + c.BaseURL, + neturl.PathEscape(namespace), + neturl.PathEscape(catalogID), + neturl.PathEscape(version)) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, "", err + } + req.Header.Set("Accept", "application/json") + + resp, err := c.HTTP.Do(req) + if err != nil { + return nil, "", err + } + defer resp.Body.Close() //nolint:errcheck + // Bodies are small Gemara artifacts; 16 MiB is a generous ceiling that + // still guards against a runaway response. + rb, err := io.ReadAll(io.LimitReader(resp.Body, 16<<20)) + if err != nil { + return nil, "", fmt.Errorf("reading version body from %s: %w", url, err) + } + + switch resp.StatusCode { + case http.StatusOK: + if len(rb) == 0 { + return nil, "", fmt.Errorf("hub version fetch %s returned 200 with empty body", url) + } + return rb, resp.Header.Get("X-Gemara-Manifest-Digest"), nil + case http.StatusNotFound: + return nil, "", fmt.Errorf("%w: %s/%s@%s", ErrCatalogNotFound, namespace, catalogID, version) + case http.StatusGone: + return nil, "", fmt.Errorf("%w: %s/%s@%s", ErrCatalogTombstoned, namespace, catalogID, version) + default: + return nil, "", fmt.Errorf("hub version fetch %s returned %d: %s", + url, resp.StatusCode, strings.TrimSpace(string(rb))) + } +} + +// Sync calls POST /v1/bundles/sync. The hub fetches the bundle from +// the registry server-side using its zot connection, so the call +// returns quickly without re-uploading any bytes from this client. +func (c *Client) Sync(ctx context.Context, repository, tag string) (*SyncResponse, error) { + if c.BaseURL == "" { + return nil, errors.New("hub base URL is required") + } + if c.Token == "" { + return nil, errors.New("hub token is required (--token or GRCLI_TOKEN)") + } + body, err := json.Marshal(SyncRequest{Repository: repository, Tag: tag}) + if err != nil { + return nil, err + } + url := c.BaseURL + "/v1/bundles/sync" + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", "Bearer "+c.Token) + + resp, err := c.HTTP.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() //nolint:errcheck + + rb, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return nil, fmt.Errorf("reading sync response from %s: %w", url, err) + } + if resp.StatusCode/100 != 2 { + // Same shape as VersionExists/GetCatalog default branches (URL + + // status + body snippet) so an operator chasing a 5xx on any hub + // endpoint sees the same diagnostic surface. + return nil, fmt.Errorf("hub sync %s returned %d: %s", + url, resp.StatusCode, strings.TrimSpace(string(rb))) + } + out := &SyncResponse{} + if err := json.Unmarshal(rb, out); err != nil { + return nil, fmt.Errorf("decoding hub response: %w", err) + } + return out, nil +} diff --git a/internal/hub/hub_test.go b/internal/hub/hub_test.go new file mode 100644 index 0000000..de3c940 --- /dev/null +++ b/internal/hub/hub_test.go @@ -0,0 +1,207 @@ +// SPDX-License-Identifier: Apache-2.0 + +package hub + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// TestVersionExists_StatusMapping covers the typed status returns — +// 200/404/410 map to the three VersionStatus values, none of them an +// error. This is the publish pre-flight's load-bearing contract. +func TestVersionExists_StatusMapping(t *testing.T) { + cases := []struct { + name string + status int + want VersionStatus + }{ + {"200 → present", http.StatusOK, VersionPresent}, + {"404 → absent", http.StatusNotFound, VersionAbsent}, + {"410 → tombstoned", http.StatusGone, VersionTombstoned}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(tc.status) + })) + defer srv.Close() + + got, err := New(srv.URL, "").VersionExists(context.Background(), "ns", "id", "v1") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tc.want { + t.Errorf("VersionExists = %v, want %v", got, tc.want) + } + }) + } +} + +// TestVersionExists_UnexpectedStatus locks in the unified diagnostic +// shape (URL + status + body snippet). Before this round, the default +// branch dropped the body — operators chasing 5xx on the publish +// pre-flight got no upstream context. +func TestVersionExists_UnexpectedStatus(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`upstream timeout from zot`)) + })) + defer srv.Close() + + got, err := New(srv.URL, "").VersionExists(context.Background(), "ns", "id", "v1") + if err == nil { + t.Fatal("expected error on 500, got nil") + } + // Pin the returned status: a future refactor must not silently flip + // 5xx to VersionPresent — that would wrongly trip the "already + // exists" branch in publish's pre-flight. + if got != VersionAbsent { + t.Errorf("VersionExists on 500 = %v, want VersionAbsent", got) + } + msg := err.Error() + for _, want := range []string{srv.URL, "500", "upstream timeout from zot"} { + if !strings.Contains(msg, want) { + t.Errorf("error %q must contain %q (URL+status+body shape)", msg, want) + } + } +} + +// TestSync_HappyPath verifies the decoded SyncResponse round-trips +// alongside the diagnostic-shape changes — guards against accidentally +// breaking the 2xx path while reshaping the error path. +func TestSync_HappyPath(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("Authorization"); got != "Bearer test-token" { + t.Errorf("Authorization header = %q, want Bearer test-token", got) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"repository":"a/b","tag":"1.0.0","manifest_etag":"etag","artifact_count":3,"new_count":1,"types":["Policy"]}`)) + })) + defer srv.Close() + + resp, err := New(srv.URL, "test-token").Sync(context.Background(), "a/b", "1.0.0") + if err != nil { + t.Fatalf("Sync error: %v", err) + } + if resp.Repository != "a/b" || resp.Tag != "1.0.0" || resp.ArtifactCount != 3 || resp.NewCount != 1 { + t.Errorf("Sync response = %+v, want repository=a/b tag=1.0.0 artifact_count=3 new_count=1", resp) + } +} + +// TestSync_UnexpectedStatus is the matching diagnostic-shape test for +// Sync: URL was previously missing from the non-2xx error message. +func TestSync_UnexpectedStatus(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadGateway) + _, _ = w.Write([]byte(`backend unavailable`)) + })) + defer srv.Close() + + _, err := New(srv.URL, "test-token").Sync(context.Background(), "a/b", "1.0.0") + if err == nil { + t.Fatal("expected error on 502, got nil") + } + msg := err.Error() + for _, want := range []string{srv.URL, "502", "backend unavailable"} { + if !strings.Contains(msg, want) { + t.Errorf("error %q must contain %q (URL+status+body shape)", msg, want) + } + } +} + +// TestGetCatalog_TypedSentinels confirms the wrapping shape: a real +// errors.Is check, not just a string match. Documents the contract +// versions.go relies on for clean user-facing error messages. +func TestGetCatalog_TypedSentinels(t *testing.T) { + cases := []struct { + name string + status int + want error + }{ + {"404 → ErrCatalogNotFound", http.StatusNotFound, ErrCatalogNotFound}, + {"410 → ErrCatalogTombstoned", http.StatusGone, ErrCatalogTombstoned}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(tc.status) + })) + defer srv.Close() + + _, err := New(srv.URL, "").GetCatalog(context.Background(), "ns", "id") + if err == nil { + t.Fatalf("expected error on %d, got nil", tc.status) + } + if !errors.Is(err, tc.want) { + t.Errorf("errors.Is(%v, %v) = false", err, tc.want) + } + }) + } +} + +// TestGetVersionBody covers the reference-resolution body fetch (ADR-0039): +// the 200 path returns the body and the manifest-digest header, and the +// typed 404/410 sentinels surface for absent/yanked versions. +func TestGetVersionBody(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasSuffix(r.URL.Path, "/versions/9.9.9"): + w.WriteHeader(http.StatusNotFound) + case strings.HasSuffix(r.URL.Path, "/versions/0.0.0"): + w.WriteHeader(http.StatusGone) + default: + w.Header().Set("X-Gemara-Manifest-Digest", "sha256:deadbeef") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"metadata":{"id":"x"}}`)) + } + })) + defer srv.Close() + c := New(srv.URL, "") + + body, digest, err := c.GetVersionBody(context.Background(), "acme", "x", "1.0.0") + if err != nil { + t.Fatalf("GetVersionBody: %v", err) + } + if string(body) != `{"metadata":{"id":"x"}}` { + t.Errorf("body = %q", body) + } + if digest != "sha256:deadbeef" { + t.Errorf("digest = %q, want sha256:deadbeef", digest) + } + + if _, _, err := c.GetVersionBody(context.Background(), "acme", "x", "9.9.9"); !errors.Is(err, ErrCatalogNotFound) { + t.Errorf("absent version: err = %v, want ErrCatalogNotFound", err) + } + if _, _, err := c.GetVersionBody(context.Background(), "acme", "x", "0.0.0"); !errors.Is(err, ErrCatalogTombstoned) { + t.Errorf("yanked version: err = %v, want ErrCatalogTombstoned", err) + } +} + +// TestReleaseFor_License confirms the per-version license decodes off +// releases[] and ReleaseFor selects the matching version. +func TestReleaseFor_License(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"namespace":"acme","catalog_id":"x","releases":[ + {"version":"1.0.0","manifest_digest":"sha256:a","license":"Apache-2.0"}, + {"version":"2.0.0","manifest_digest":"sha256:b","license":"MIT"}]}`)) + })) + defer srv.Close() + c := New(srv.URL, "") + + cat, err := c.GetCatalog(context.Background(), "acme", "x") + if err != nil { + t.Fatalf("GetCatalog: %v", err) + } + if rel := cat.ReleaseFor("2.0.0"); rel == nil || rel.License != "MIT" { + t.Errorf("ReleaseFor(2.0.0) license = %v, want MIT", rel) + } + if cat.ReleaseFor("3.0.0") != nil { + t.Error("ReleaseFor(3.0.0) should be nil") + } +} diff --git a/internal/hub/regtoken.go b/internal/hub/regtoken.go new file mode 100644 index 0000000..7cdebbe --- /dev/null +++ b/internal/hub/regtoken.go @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: Apache-2.0 + +package hub + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + neturl "net/url" + "strings" + "time" + + "github.com/revanite-io/grc-store-protocol/registrytoken" +) + +// FetchRegistryToken exchanges a hub (Keycloak) bearer token for a +// short-lived OCI Distribution token scoped to the given repository and +// actions, minted by the hub's GET /v2/token endpoint — the bearer realm +// the registry (zot) trusts (ADR-0031 on the backend). +// +// The hub grants pull to everyone and push only to a namespace owner or +// hub admin, so: +// - pass bearer="" for an anonymous pull token (public reads), and +// - pass the caller's hub access token (from `grcli login`) for a push +// token; the hub strips push from the grant if the caller doesn't +// own the repository's namespace. +// +// The returned token is presented directly to the registry as a Bearer +// credential — grcli pre-fetches rather than doing the WWW-Authenticate +// challenge dance, because the realm is gated on a Keycloak token the +// registry client can't supply on its own. +func FetchRegistryToken(ctx context.Context, hubBaseURL, bearer, repository string, actions []string) (string, error) { + base := strings.TrimRight(hubBaseURL, "/") + if base == "" { + return "", errors.New("hub base URL is required to fetch a registry token") + } + if repository == "" { + return "", errors.New("repository is required to fetch a registry token") + } + if len(actions) == 0 { + return "", errors.New("at least one action (pull/push) is required") + } + + q := neturl.Values{} + // service is informational here — the hub sets the token audience from + // its own config and the registry does not validate it — but we send + // the scope the registry challenge would ask for, spec-shaped. + q.Set("scope", "repository:"+repository+":"+strings.Join(actions, ",")) + reqURL := base + "/v2/token?" + q.Encode() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) + if err != nil { + return "", fmt.Errorf("building registry-token request for %s: %w", reqURL, err) + } + req.Header.Set("Accept", "application/json") + if bearer != "" { + req.Header.Set("Authorization", "Bearer "+bearer) + } + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("fetching registry token from %s: %w", reqURL, err) + } + defer resp.Body.Close() //nolint:errcheck + + body, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10)) + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("hub registry-token endpoint %s returned %d: %s", + reqURL, resp.StatusCode, strings.TrimSpace(string(body))) + } + + var tr registrytoken.Response + if err := json.Unmarshal(body, &tr); err != nil { + return "", fmt.Errorf("decoding registry token from %s: %w", reqURL, err) + } + tok := tr.BearerToken() // prefer token, fall back to access_token (shared helper) + if tok == "" { + return "", fmt.Errorf("hub registry-token endpoint %s returned no token", reqURL) + } + return tok, nil +} diff --git a/internal/provenance/provenance.go b/internal/provenance/provenance.go new file mode 100644 index 0000000..0d39ab7 --- /dev/null +++ b/internal/provenance/provenance.go @@ -0,0 +1,250 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Package provenance produces a SLSA v1.0-shaped JSON predicate that is +// embedded as the "provenance" key of the OCI bundle's manifest +// metadata. The shape is forward-compatible with a future cosign DSSE +// attestation flow; once the hub gains a verifier, the same predicate +// can be lifted verbatim into a signed envelope. +package provenance + +import ( + "fmt" + "os" + "os/exec" + "runtime" + "sort" + "strings" + "time" +) + +// PredicateType is the SLSA v1 provenance predicate type identifier. +const PredicateType = "https://slsa.dev/provenance/v1" + +// BuildType is grcli's own build-type URI — the schema for the +// invocation/buildConfig fields below. Unversioned so we can iterate +// the shape before there's a verifier consuming it; v1 once stable. +const BuildType = "https://grc.store/grcli/buildtype/v0" + +// Predicate is the JSON object embedded under bundle metadata.provenance. +// Field names match the SLSA v1.0 ProvenanceBuildV1 predicate so that +// consumers can validate against the public schema even before grcli +// emits signed attestations. +type Predicate struct { + BuildDefinition BuildDefinition `json:"buildDefinition"` + RunDetails RunDetails `json:"runDetails"` +} + +// BuildDefinition mirrors SLSA's BuildDefinition struct. +type BuildDefinition struct { + BuildType string `json:"buildType"` + ExternalParameters map[string]any `json:"externalParameters,omitempty"` + InternalParameters map[string]any `json:"internalParameters,omitempty"` + ResolvedDependencies []ResourceDescr `json:"resolvedDependencies,omitempty"` +} + +// RunDetails mirrors SLSA's RunDetails struct. +type RunDetails struct { + Builder Builder `json:"builder"` + Metadata Metadata `json:"metadata"` + Byproducts []ResourceDescr `json:"byproducts,omitempty"` +} + +// Builder identifies what produced the build. +type Builder struct { + ID string `json:"id"` + Version map[string]string `json:"version,omitempty"` + BuilderDependencies []ResourceDescr `json:"builderDependencies,omitempty"` +} + +// Metadata captures invocation-time info about the build run. +type Metadata struct { + InvocationID string `json:"invocationId,omitempty"` + StartedOn time.Time `json:"startedOn"` + FinishedOn time.Time `json:"finishedOn,omitempty"` +} + +// ResourceDescr is the SLSA v1 resource descriptor used for inputs, +// dependencies, and byproducts. URI + digest are the load-bearing +// fields; name is informational. +type ResourceDescr struct { + Name string `json:"name,omitempty"` + URI string `json:"uri,omitempty"` + Digest map[string]string `json:"digest,omitempty"` +} + +// Input is the data grcli's caller has, packed into a tiny struct so +// Build() doesn't grow a 12-arg signature as fields accrete. +type Input struct { + ToolVersion string + StartedOn time.Time + ArtifactType string + ArtifactID string + ArtifactName string + ArtifactDigest string // sha256: of the merged bundle body + SourceFiles map[string]string // path -> sha256: + Registry string + Repository string + Tag string +} + +// Build assembles the SLSA-shaped predicate from environment + Input. +// It never returns an error: missing fields degrade to omitted entries +// rather than failing the publish. +func Build(in Input) Predicate { + builderID, builderVer := identifyBuilder(in.ToolVersion) + + external := map[string]any{ + "artifact": map[string]string{ + "type": in.ArtifactType, + "id": in.ArtifactID, + }, + "target": map[string]string{ + "registry": in.Registry, + "repository": in.Repository, + "tag": in.Tag, + }, + } + + resolved := make([]ResourceDescr, 0, len(in.SourceFiles)+1) + for _, path := range sortedKeys(in.SourceFiles) { + digest := in.SourceFiles[path] + resolved = append(resolved, ResourceDescr{ + Name: path, + URI: "file://" + path, + Digest: digestMap(digest), + }) + } + if git := detectGit(); git != nil { + resolved = append(resolved, *git) + } + + byproducts := []ResourceDescr{} + if in.ArtifactDigest != "" { + byproducts = append(byproducts, ResourceDescr{ + Name: in.ArtifactName, + Digest: digestMap(in.ArtifactDigest), + }) + } + + return Predicate{ + BuildDefinition: BuildDefinition{ + BuildType: BuildType, + ExternalParameters: external, + InternalParameters: internalParams(), + ResolvedDependencies: resolved, + }, + RunDetails: RunDetails{ + Builder: Builder{ + ID: builderID, + Version: builderVer, + }, + Metadata: Metadata{ + InvocationID: invocationID(), + StartedOn: in.StartedOn, + FinishedOn: time.Now().UTC(), + }, + Byproducts: byproducts, + }, + } +} + +// identifyBuilder distinguishes a CI run (preferred SLSA identity) from +// a local invocation (best-effort, never asserted as trusted). +func identifyBuilder(toolVersion string) (string, map[string]string) { + ver := map[string]string{ + "grcli": toolVersion, + "go": runtime.Version(), + "go-arch": runtime.GOARCH, + "go-os": runtime.GOOS, + } + if os.Getenv("GITHUB_ACTIONS") == "true" { + server := envOr("GITHUB_SERVER_URL", "https://github.com") + return fmt.Sprintf("%s/%s/actions/runs/%s", + server, + os.Getenv("GITHUB_REPOSITORY"), + os.Getenv("GITHUB_RUN_ID")), ver + } + host, _ := os.Hostname() + user := envOr("USER", envOr("USERNAME", "unknown")) + return fmt.Sprintf("local://%s@%s", user, host), ver +} + +func internalParams() map[string]any { + out := map[string]any{} + // Allowlist only env vars that document the build environment + // without leaking secrets. Anything that looks like a token is + // excluded by design. + allow := []string{ + "GITHUB_ACTIONS", "GITHUB_WORKFLOW", "GITHUB_RUN_ID", + "GITHUB_RUN_ATTEMPT", "GITHUB_REPOSITORY", "GITHUB_REF", + "GITHUB_SHA", "GITHUB_ACTOR", "RUNNER_OS", "CI", + } + for _, k := range allow { + if v := os.Getenv(k); v != "" { + out[k] = v + } + } + return out +} + +func invocationID() string { + if v := os.Getenv("GITHUB_RUN_ID"); v != "" { + if a := os.Getenv("GITHUB_RUN_ATTEMPT"); a != "" { + return v + "-" + a + } + return v + } + return "" +} + +func detectGit() *ResourceDescr { + // Best-effort: only emit a materials entry if we're inside a git + // repo and can resolve both remote + HEAD. Anything less and we + // silently skip — provenance is informative, not authoritative. + remote, err := gitCmd("config", "--get", "remote.origin.url") + if err != nil || remote == "" { + return nil + } + sha, err := gitCmd("rev-parse", "HEAD") + if err != nil || sha == "" { + return nil + } + return &ResourceDescr{ + Name: "source", + URI: "git+" + strings.TrimSuffix(remote, ".git") + "@" + sha, + Digest: map[string]string{"gitCommit": sha}, + } +} + +func gitCmd(args ...string) (string, error) { + cmd := exec.Command("git", args...) + out, err := cmd.Output() + if err != nil { + return "", err + } + return strings.TrimSpace(string(out)), nil +} + +func envOr(k, def string) string { + if v := os.Getenv(k); v != "" { + return v + } + return def +} + +func sortedKeys(m map[string]string) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +} + +func digestMap(prefixed string) map[string]string { + idx := strings.Index(prefixed, ":") + if idx < 0 { + return map[string]string{"sha256": prefixed} + } + return map[string]string{prefixed[:idx]: prefixed[idx+1:]} +} diff --git a/internal/provenance/provenance_test.go b/internal/provenance/provenance_test.go new file mode 100644 index 0000000..b37eee1 --- /dev/null +++ b/internal/provenance/provenance_test.go @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: Apache-2.0 + +package provenance + +import ( + "encoding/json" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestBuild_BasicShape(t *testing.T) { + t.Setenv("GITHUB_ACTIONS", "") + t.Setenv("USER", "testuser") + + now := time.Date(2026, 5, 18, 10, 0, 0, 0, time.UTC) + p := Build(Input{ + ToolVersion: "1.2.3", + StartedOn: now, + ArtifactType: "ControlCatalog", + ArtifactID: "my-controls", + ArtifactName: "control-catalog.yaml", + ArtifactDigest: "sha256:abc123", + SourceFiles: map[string]string{ + "a.yaml": "sha256:aaa", + "b.yaml": "sha256:bbb", + }, + Registry: "registry.example", + Repository: "team/my-controls", + Tag: "1.0.0", + }) + + require.Equal(t, BuildType, p.BuildDefinition.BuildType) + require.Equal(t, now, p.RunDetails.Metadata.StartedOn) + require.False(t, p.RunDetails.Metadata.FinishedOn.IsZero()) + require.Equal(t, "1.2.3", p.RunDetails.Builder.Version["grcli"]) + require.Contains(t, p.RunDetails.Builder.ID, "local://") + // ExternalParameters carries the artifact + target coordinates. + ext := p.BuildDefinition.ExternalParameters + require.Equal(t, map[string]string{"type": "ControlCatalog", "id": "my-controls"}, ext["artifact"]) + require.Equal(t, map[string]string{"registry": "registry.example", "repository": "team/my-controls", "tag": "1.0.0"}, ext["target"]) + // ResolvedDependencies carries one entry per source file (sorted). + require.GreaterOrEqual(t, len(p.BuildDefinition.ResolvedDependencies), 2) + require.Equal(t, "a.yaml", p.BuildDefinition.ResolvedDependencies[0].Name) + require.Equal(t, "b.yaml", p.BuildDefinition.ResolvedDependencies[1].Name) + require.Equal(t, "aaa", p.BuildDefinition.ResolvedDependencies[0].Digest["sha256"]) + // Byproducts carries the merged body digest. + require.Len(t, p.RunDetails.Byproducts, 1) + require.Equal(t, "abc123", p.RunDetails.Byproducts[0].Digest["sha256"]) +} + +func TestBuild_GitHubActions_BuilderIDIsRunURL(t *testing.T) { + t.Setenv("GITHUB_ACTIONS", "true") + t.Setenv("GITHUB_SERVER_URL", "https://github.com") + t.Setenv("GITHUB_REPOSITORY", "revanite-io/grcli") + t.Setenv("GITHUB_RUN_ID", "42") + t.Setenv("GITHUB_RUN_ATTEMPT", "1") + + p := Build(Input{ + ToolVersion: "1.0.0", + StartedOn: time.Now().UTC(), + }) + require.Equal(t, + "https://github.com/revanite-io/grcli/actions/runs/42", + p.RunDetails.Builder.ID) + require.Equal(t, "42-1", p.RunDetails.Metadata.InvocationID) +} + +func TestBuild_SerializesAsValidJSON(t *testing.T) { + p := Build(Input{ + ToolVersion: "1.0.0", + StartedOn: time.Now().UTC(), + SourceFiles: map[string]string{"x": "sha256:1"}, + }) + b, err := json.Marshal(p) + require.NoError(t, err) + require.Contains(t, string(b), `"buildType"`) + require.Contains(t, string(b), `"resolvedDependencies"`) +} diff --git a/internal/refs/refs.go b/internal/refs/refs.go new file mode 100644 index 0000000..a8b4797 --- /dev/null +++ b/internal/refs/refs.go @@ -0,0 +1,182 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Package refs parses the mapping references out of a Gemara artifact body +// and decides which of them grcli unpack should resolve against a hub +// (ADR-0039). It is deliberately pure — no network, no filesystem — so the +// selection and host-recognition rules are unit-testable in isolation. +// +// Gemara models references in two layers (go-gemara generated_types.go): +// - metadata.mapping-references is the registry of external documents, each +// a {id, title, version, url}. The url+version live here. +// - relationship fields (extends, imports, lexicon) point INTO that registry +// by reference-id; they carry the relationship, not the locator. +// +// So --with-references resolves every entry in the metadata registry, while +// --with-imports resolves only the entries an `imports` relationship points at. +package refs + +import ( + "fmt" + neturl "net/url" + "strings" + + gemara "github.com/gemaraproj/go-gemara" + "sigs.k8s.io/yaml" +) + +// Mode selects which references to resolve. +type Mode int + +const ( + // ImportsOnly resolves only references targeted by an `imports` + // relationship (--with-imports). + ImportsOnly Mode = iota + // AllReferences resolves every mapping reference in the metadata + // registry (--with-references). + AllReferences +) + +// Reference category labels (also the materialization subdirectory names). +const ( + CategoryImports = "imports" + CategoryExtends = "extends" + CategoryLexicon = "lexicon" + CategoryReference = "reference" +) + +// Selected is one mapping reference chosen for resolution. +type Selected struct { + Category string // imports | extends | lexicon | reference + ID string // the MappingReference.id + Title string + Version string // MappingReference.version (the locator carries no version) + URL string // MappingReference.url +} + +// Artifact is the subset of a parsed Gemara artifact that matters for +// reference resolution. +type Artifact struct { + // Type is the artifact's metadata.type, for diagnostics. + Type string + // MappingRefs is the metadata registry of external documents. + MappingRefs []gemara.MappingReference + // category maps a MappingReference.id to how it is referenced. + category map[string]string + // importIDs is the set of MappingReference ids an `imports` + // relationship points at. + importIDs map[string]bool + // Notes records non-fatal parse caveats (e.g. an artifact type whose + // imports shape we don't yet walk), surfaced to the user. + Notes []string +} + +// Scan parses an artifact body (YAML) and extracts its mapping references and +// the relationships that point at them. Metadata parsing is required; failure +// to parse the relationship fields (e.g. Policy's differently-shaped `imports`) +// is recorded as a Note rather than failing — --with-references still works off +// the metadata registry alone. +func Scan(body []byte) (*Artifact, error) { + var meta struct { + Metadata gemara.Metadata `json:"metadata"` + } + if err := yaml.Unmarshal(body, &meta); err != nil { + return nil, fmt.Errorf("parsing artifact metadata: %w", err) + } + + a := &Artifact{ + Type: meta.Metadata.Type.String(), + MappingRefs: meta.Metadata.MappingReferences, + category: make(map[string]string), + importIDs: make(map[string]bool), + } + + // lexicon is a single optional relationship on the metadata block. + if lex := meta.Metadata.Lexicon; lex != nil && lex.ReferenceId != "" { + a.category[lex.ReferenceId] = CategoryLexicon + } + + // extends/imports are top-level on the catalog artifact types and share a + // uniform shape ([]ArtifactMapping / []MultiEntryMapping). Policy carries a + // structurally different `imports`, which fails this decode — caught and + // noted, not fatal. + var rel struct { + Imports []gemara.MultiEntryMapping `json:"imports"` + Extends []gemara.ArtifactMapping `json:"extends"` + } + if err := yaml.Unmarshal(body, &rel); err != nil { + a.Notes = append(a.Notes, fmt.Sprintf( + "could not read imports/extends relationships for artifact type %q (%v) — "+ + "--with-imports will resolve nothing for it; use --with-references to pull every mapping reference", + a.Type, err)) + return a, nil + } + for _, ext := range rel.Extends { + if ext.ReferenceId != "" { + a.category[ext.ReferenceId] = CategoryExtends + } + } + for _, imp := range rel.Imports { + if imp.ReferenceId != "" { + a.category[imp.ReferenceId] = CategoryImports + a.importIDs[imp.ReferenceId] = true + } + } + return a, nil +} + +// Select returns the references to resolve for the given mode. References with +// no url are skipped (nothing to retrieve). Order follows the metadata registry. +func (a *Artifact) Select(mode Mode) []Selected { + var out []Selected + for _, r := range a.MappingRefs { + if strings.TrimSpace(r.Url) == "" { + continue + } + if mode == ImportsOnly && !a.importIDs[r.Id] { + continue + } + cat := a.category[r.Id] + if cat == "" { + cat = CategoryReference + } + out = append(out, Selected{ + Category: cat, + ID: r.Id, + Title: r.Title, + Version: r.Version, + URL: r.Url, + }) + } + return out +} + +// Recognize decides whether a reference URL points at an artifact resolvable +// against the targeted hub, and if so extracts its (namespace, catalogID) from +// the URL path (ADR-0039 decision 2). The version is NOT in the URL — it lives +// in the MappingReference.version field. +// +// Rules, given the host of the --url target: +// - host "grc.store" is the canonical placeholder: it resolves against the +// target (we rewrite to the target hub implicitly by using the target client). +// - host exactly equal to targetHost resolves directly. +// - any other host is not resolvable here. +// +// ok=false carries a human reason for the skip report; it is never an error — +// an unrecognized reference is expected and benign. +func Recognize(refURL, targetHost string) (namespace, catalogID string, ok bool, reason string) { + u, err := neturl.Parse(refURL) + if err != nil { + return "", "", false, fmt.Sprintf("unparseable URL %q", refURL) + } + if u.Host == "" { + return "", "", false, fmt.Sprintf("URL %q has no host (a Gemara reference must be an absolute https URL)", refURL) + } + if u.Host != "grc.store" && u.Host != targetHost { + return "", "", false, fmt.Sprintf("host %q is neither grc.store nor the targeted hub %q", u.Host, targetHost) + } + parts := strings.Split(strings.Trim(u.Path, "/"), "/") + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return "", "", false, fmt.Sprintf("path %q is not /{namespace}/{catalog_id}", u.Path) + } + return parts[0], parts[1], true, "" +} diff --git a/internal/refs/refs_fuzz_test.go b/internal/refs/refs_fuzz_test.go new file mode 100644 index 0000000..47f7c11 --- /dev/null +++ b/internal/refs/refs_fuzz_test.go @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: Apache-2.0 + +package refs + +import "testing" + +// FuzzScan: Scan takes untrusted YAML straight off disk or a registry and +// must never panic; when it reports success it must hand back an artifact. +// Seeds are the shapes the unit tests already cover plus the degenerate ones +// (empty, scalar metadata, list root). Crashers Go saves under +// testdata/fuzz/FuzzScan/ are regression seeds — commit them. +func FuzzScan(f *testing.F) { + for _, s := range []string{ + controlCatalogYAML, + "", + "metadata: 7", + "[1, 2]", + "metadata:\n id: x\n version: v1\n", + "metadata:\n mapping-references:\n - id: a\n title: A\n version: v1\n", + "metadata:\n type: Policy\nimports: [{reference-id: a}]\n", + "metadata: {id: x}\nmapping-references: {}\n", + "\xff\xfe", + } { + f.Add([]byte(s)) + } + f.Fuzz(func(t *testing.T, body []byte) { + a, err := Scan(body) + if err == nil && a == nil { + t.Fatal("Scan returned a nil artifact without an error") + } + }) +} diff --git a/internal/refs/refs_test.go b/internal/refs/refs_test.go new file mode 100644 index 0000000..443be77 --- /dev/null +++ b/internal/refs/refs_test.go @@ -0,0 +1,149 @@ +// SPDX-License-Identifier: Apache-2.0 + +package refs + +import "testing" + +const controlCatalogYAML = ` +metadata: + id: my-catalog + type: ControlCatalog + gemara-version: "0.5.0" + description: a test catalog + author: + id: acme + name: Acme + mapping-references: + - id: base + title: Base Catalog + version: "2.1.0" + url: https://grc.store/acme/baseline + - id: extref + title: Extended Catalog + version: "3.0.0" + url: https://grc.store/acme/extended + - id: nourl + title: A reference with no retrievable URL + version: "1.0.0" +extends: + - reference-id: extref +imports: + - reference-id: base +` + +func TestScanCategorizesReferences(t *testing.T) { + a, err := Scan([]byte(controlCatalogYAML)) + if err != nil { + t.Fatalf("Scan: %v", err) + } + if a.Type != "ControlCatalog" { + t.Errorf("Type = %q, want ControlCatalog", a.Type) + } + if len(a.MappingRefs) != 3 { + t.Fatalf("MappingRefs = %d, want 3", len(a.MappingRefs)) + } + if got := a.category["base"]; got != CategoryImports { + t.Errorf("category[base] = %q, want %q", got, CategoryImports) + } + if got := a.category["extref"]; got != CategoryExtends { + t.Errorf("category[extref] = %q, want %q", got, CategoryExtends) + } + if !a.importIDs["base"] || a.importIDs["extref"] { + t.Errorf("importIDs = %v, want only base", a.importIDs) + } +} + +func TestSelectByMode(t *testing.T) { + a, err := Scan([]byte(controlCatalogYAML)) + if err != nil { + t.Fatalf("Scan: %v", err) + } + + all := a.Select(AllReferences) + // base (imports) + extref (extends); nourl has no URL so it is skipped. + if len(all) != 2 { + t.Fatalf("AllReferences selected %d, want 2: %+v", len(all), all) + } + + imports := a.Select(ImportsOnly) + if len(imports) != 1 { + t.Fatalf("ImportsOnly selected %d, want 1: %+v", len(imports), imports) + } + if imports[0].ID != "base" || imports[0].Category != CategoryImports { + t.Errorf("ImportsOnly[0] = %+v, want base/imports", imports[0]) + } + if imports[0].Version != "2.1.0" || imports[0].URL != "https://grc.store/acme/baseline" { + t.Errorf("ImportsOnly[0] locator = %q@%q, want baseline@2.1.0", imports[0].URL, imports[0].Version) + } +} + +func TestScanPolicyImportsAreNoted(t *testing.T) { + // Policy's `imports` is a map, not a list — it cannot decode into the + // catalog-shaped relationship struct. Scan must record a note, not fail, + // and metadata references must still be readable. + const policyYAML = ` +metadata: + id: my-policy + type: Policy + gemara-version: "0.5.0" + description: a test policy + author: + id: acme + name: Acme + mapping-references: + - id: cat + title: A catalog + version: "1.0.0" + url: https://grc.store/acme/catalog +imports: + catalogs: + - reference-id: cat +` + a, err := Scan([]byte(policyYAML)) + if err != nil { + t.Fatalf("Scan must not fail on Policy: %v", err) + } + if len(a.Notes) == 0 { + t.Error("expected a note about unreadable Policy imports") + } + // --with-references still works off the metadata registry. + if got := a.Select(AllReferences); len(got) != 1 { + t.Errorf("AllReferences selected %d, want 1", len(got)) + } + // --with-imports finds nothing (we don't walk Policy imports yet). + if got := a.Select(ImportsOnly); len(got) != 0 { + t.Errorf("ImportsOnly selected %d, want 0 for Policy", len(got)) + } +} + +func TestRecognize(t *testing.T) { + const target = "hub.grc.store" + cases := []struct { + name string + url string + wantOK bool + wantNS string + wantID string + }{ + {"canonical placeholder", "https://grc.store/acme/baseline", true, "acme", "baseline"}, + {"exact target host", "https://hub.grc.store/acme/baseline", true, "acme", "baseline"}, + {"other host", "https://example.com/acme/baseline", false, "", ""}, + {"schemeless", "grc.store/acme/baseline", false, "", ""}, + {"too few path segments", "https://grc.store/acme", false, "", ""}, + {"too many path segments", "https://grc.store/acme/baseline/extra", false, "", ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ns, id, ok, reason := Recognize(tc.url, target) + if ok != tc.wantOK { + t.Fatalf("ok = %v (reason %q), want %v", ok, reason, tc.wantOK) + } + if ok && (ns != tc.wantNS || id != tc.wantID) { + t.Errorf("(ns,id) = (%q,%q), want (%q,%q)", ns, id, tc.wantNS, tc.wantID) + } + if !ok && reason == "" { + t.Error("expected a non-empty skip reason") + } + }) + } +} diff --git a/internal/registry/registry.go b/internal/registry/registry.go new file mode 100644 index 0000000..4adc283 --- /dev/null +++ b/internal/registry/registry.go @@ -0,0 +1,471 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Package registry packs a Gemara bundle and writes it to an OCI target. +// The same Pack call services both the live-push path (remote.Repository) +// and the dry-run path (oci.Store on disk) — the only difference is +// which target is passed in. +package registry + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "strings" + + "github.com/gemaraproj/go-gemara/bundle" + godigest "github.com/opencontainers/go-digest" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/revanite-io/grc-store-protocol/limits" + "github.com/revanite-io/grc-store-protocol/mediatype" + "oras.land/oras-go/v2" + "oras.land/oras-go/v2/content" + "oras.land/oras-go/v2/content/oci" + "oras.land/oras-go/v2/registry" + "oras.land/oras-go/v2/registry/remote" + "oras.land/oras-go/v2/registry/remote/auth" + "oras.land/oras-go/v2/registry/remote/credentials" + "oras.land/oras-go/v2/registry/remote/retry" + + "github.com/revanite-io/grcli/internal/digest" +) + +// PackInput is the data registry.Pack needs to build the bundle. +// Body is the merged artifact YAML; Provenance is the SLSA predicate +// (typically a provenance.Predicate) embedded in the OCI config blob +// under metadata.provenance. +type PackInput struct { + Filename string + ArtifactType string + ArtifactID string + GemaraVersion string + Body []byte + Provenance any // marshaled into bundle.Manifest.Metadata + // License is the canonical SPDX publication-license expression + // (ADR-0036). When non-empty it is stamped as the standard OCI + // manifest annotation org.opencontainers.image.licenses. Empty means + // no annotation. The caller (cmd/publish.go) is the strict gate: this + // value is already validated and canonicalized via spdx.Canonicalize. + License string +} + +// PushResult reports what was published. +type PushResult struct { + ManifestDigest string + BodyDigest string + Tag string + Reference string // /: +} + +// PushRemote packs the bundle and pushes it to /:. +// Auth flows through the default Docker credential chain plus the +// $GRCLI_REGISTRY_PASSWORD / $GRCLI_REGISTRY_USERNAME env pair if set, +// matching how oras CLI resolves auth. +func PushRemote(ctx context.Context, registryHost, repository, tag string, in PackInput) (*PushResult, error) { + if tag == "" { + return nil, errors.New("--tag is required (or derivable from metadata.version)") + } + repo, err := newRemoteRepo(registryHost, repository) + if err != nil { + return nil, err + } + + desc, bodyDigest, err := pack(ctx, repo, tag, in) + if err != nil { + return nil, err + } + return &PushResult{ + ManifestDigest: desc.Digest.String(), + BodyDigest: bodyDigest, + Tag: tag, + // registryHost may carry an http(s):// scheme (it's the oras dial + // target, where the scheme drives PlainHTTP). The Reference is for + // display and cosign, which want a bare host — normalize it. + Reference: fmt.Sprintf("%s/%s:%s", NormalizeRegistryHost(registryHost), repository, tag), + }, nil +} + +// UnpackRemote pulls a Gemara bundle from /:. +// Auth uses the same chain as PushRemote. +func UnpackRemote(ctx context.Context, registryHost, repository, tag string) (*bundle.Bundle, error) { + if tag == "" { + return nil, errors.New("--tag is required") + } + repo, err := newRemoteRepo(registryHost, repository) + if err != nil { + return nil, err + } + return bundle.Unpack(ctx, repo, tag) +} + +// newRemoteRepo constructs an authenticated oras remote.Repository for +// the given host + repo path. Shared by PushRemote and UnpackRemote. +// +// registryHost may include an http:// or https:// scheme prefix — +// useful when the hub's discovery endpoint advertises a full URL via +// HUB_OCI_PUBLIC_URL (ADR-0026). When http://, the resulting client +// uses plain-HTTP for the upstream registry traffic. When https:// or +// no scheme, TLS is used (oras-go's default). +func newRemoteRepo(registryHost, repository string) (*remote.Repository, error) { + if registryHost == "" { + return nil, errors.New("registry host is required (hub discovery returned none)") + } + if repository == "" { + return nil, errors.New("--repository is required") + } + host, plainHTTP := stripScheme(registryHost) + repo, err := remote.NewRepository(host + "/" + repository) + if err != nil { + return nil, fmt.Errorf("constructing repository client: %w", err) + } + repo.PlainHTTP = plainHTTP + creds, err := dockerCredentials() + if err != nil { + return nil, fmt.Errorf("loading docker credentials: %w", err) + } + repo.Client = &auth.Client{ + Client: retry.DefaultClient, + Cache: auth.NewCache(), + Credential: creds, + } + return repo, nil +} + +// stripScheme accepts a registry hostname that may be a bare host or +// a URL with an http://[s]:// prefix. Returns the bare host (with any +// trailing slash trimmed) and a plainHTTP flag indicating whether the +// original scheme was plain HTTP. Internal entry point retained for +// the in-package call site in newRemoteRepo; external callers (cmd/) +// should use NormalizeRegistryHost which returns only the bare host. +func stripScheme(in string) (host string, plainHTTP bool) { + switch { + case strings.HasPrefix(in, "http://"): + return strings.TrimRight(strings.TrimPrefix(in, "http://"), "/"), true + case strings.HasPrefix(in, "https://"): + return strings.TrimRight(strings.TrimPrefix(in, "https://"), "/"), false + default: + return strings.TrimRight(in, "/"), false + } +} + +// NormalizeRegistryHost takes a registry value that may be a bare host +// or a full URL (typically the registry_url advertised by a hub via +// ADR-0026's discovery endpoint) and returns a bare host suitable for +// use in an OCI reference (`/:`). Strips any scheme +// and trailing slash. Exported for cmd/verify.go and cmd/publish.go, +// which need a bare-host string for cosign and for the user-printed +// reference; the push/unpack code paths inside this package go through +// newRemoteRepo and use stripScheme directly so PlainHTTP propagates +// to oras-go. +func NormalizeRegistryHost(in string) string { + host, _ := stripScheme(in) + return host +} + +// maxSignatureBlobBytes caps the referrer manifest and bundle-layer reads +// during signature discovery. Both are tiny JSON blobs; the artifact's own +// content layers are never read here. Shares the wire-contract's ingest cap so +// grcli and the hub agree on what "too big to be a signature" means. +const maxSignatureBlobBytes = limits.MaxPluginBlobBytes + +// FetchSignatureBundle resolves /: to its manifest +// and returns the raw Sigstore bundle bytes attached as an OCI referrer, plus +// the manifest digest the signature is bound to (the value the verifier's +// artifact-digest policy checks). It returns (nil, digest, nil) when no +// signature referrer is present — a nil bundle is the caller's ErrUnsigned +// signal, NOT an error; an error is reserved for a genuine transport/parse +// failure so the caller can fail closed (we cannot claim "unsigned" if we could +// not look). +// +// Discovery accepts BOTH referrer artifactTypes a cosign-signed catalog can +// carry, because the stamped type is a function of the SIGNER's cosign major +// version (field-confirmed against a live zot 2026-07-07): +// +// cosign 2.6.x `sign --new-bundle-format` → mediatype.CosignSignReferrer +// cosign 3.x `sign` (bundle by default) → mediatype.SigstoreBundle +// +// The bundle BLOB inside is the identical v0.3 bundle either way. Publishers +// control their own cosign version, so filtering on a single type silently +// treats the other cohort's signed catalogs as unsigned (the earlier +// CosignSignReferrer-only filter did exactly that for cosign-3.x publishes). +// This supersedes grc-store-protocol/mediatype's "RULE — do not cross these", +// whose premise predates cosign 3.x. +// +// Auth flows through the same credential chain as UnpackRemote (the +// GRCLI_REGISTRY_TOKEN the caller minted via ensureRegistryToken is read by +// dockerCredentials), so no token needs threading through this signature. +// AttachSignatureReferrer pushes a Sigstore signature bundle to the registry as +// an OCI 1.1 referrer of the artifact manifest identified by subjectDigest — +// the step `cosign sign` used to perform. It is the in-process publish half of +// ADR-0049 (grcli signs keyless without cosign). Auth flows through the same +// credential chain as the bundle push: the GRCLI_REGISTRY_TOKEN the publish +// flow minted and exported. +func AttachSignatureReferrer(ctx context.Context, registryHost, repository, subjectDigest string, bundleJSON []byte) error { + if subjectDigest == "" { + return errors.New("subject digest is required") + } + if len(bundleJSON) == 0 { + return errors.New("signature bundle is empty") + } + repo, err := newRemoteRepo(registryHost, repository) + if err != nil { + return err + } + // The subject descriptor the referrer attaches to. Resolve by digest so the + // size/mediaType are exactly the pushed manifest's (oras requires a full + // descriptor for Subject). + subject, err := repo.Resolve(ctx, subjectDigest) + if err != nil { + return fmt.Errorf("resolving subject %s: %w", subjectDigest, err) + } + return packSignatureReferrer(ctx, repo, subject, bundleJSON) +} + +// packSignatureReferrer is the target-agnostic half of AttachSignatureReferrer +// (split out so it is unit-testable against an in-memory oras store, mirroring +// discoverSignatureBundle on the read side). It pushes the bundle blob, then an +// OCI 1.1 referrer manifest of subject carrying it as the single layer. +// +// The referrer's artifactType is mediatype.SigstoreBundle, matching what the +// bundle-by-default signer line stamps (cosign 3.x, pvtr's plugin packer — see +// the RULE in grc-store-protocol/mediatype): grcli's in-process signer emits a +// v0.3 bundle, so that is the semantically correct stamp. It is also the +// maximally compatible one — hubs predating the both-types ingest fix accepted +// SigstoreBundle only. +// +// mediatype.CosignSignReferrer must NOT be used here: it is a URL, not an +// RFC 6838 media type, and oras.PackManifest rejects it as an artifactType +// before any network I/O ("invalid artifactType format"). Discovery still +// ACCEPTS it (see discoverSignatureBundle) — cosign 2.6.x stamps real +// signatures with it; only this write site is constrained. +func packSignatureReferrer(ctx context.Context, target oras.Target, subject ocispec.Descriptor, bundleJSON []byte) error { + bundleDesc := ocispec.Descriptor{ + MediaType: mediatype.SigstoreBundle, + Digest: godigest.FromBytes(bundleJSON), + Size: int64(len(bundleJSON)), + } + if err := target.Push(ctx, bundleDesc, bytes.NewReader(bundleJSON)); err != nil { + return fmt.Errorf("pushing signature bundle blob: %w", err) + } + if _, err := oras.PackManifest(ctx, target, oras.PackManifestVersion1_1, mediatype.SigstoreBundle, oras.PackManifestOptions{ + Subject: &subject, + Layers: []ocispec.Descriptor{bundleDesc}, + }); err != nil { + return fmt.Errorf("pushing signature referrer manifest: %w", err) + } + return nil +} + +func FetchSignatureBundle(ctx context.Context, registryHost, repository, tag string) (bundleJSON []byte, artifactDigest string, err error) { + if tag == "" { + return nil, "", errors.New("tag is required") + } + repo, err := newRemoteRepo(registryHost, repository) + if err != nil { + return nil, "", err + } + subject, err := repo.Resolve(ctx, tag) + if err != nil { + return nil, "", fmt.Errorf("resolving %s: %w", tag, err) + } + bundleJSON, err = discoverSignatureBundle(ctx, repo, subject) + if err != nil { + return nil, "", err + } + return bundleJSON, subject.Digest.String(), nil +} + +// discoverSignatureBundle is the target-agnostic half of FetchSignatureBundle +// (split out so it is unit-testable against an in-memory oras store, mirroring +// the hub's ociref.SignatureBundle). It lists ALL referrers of subject and +// keeps those whose artifactType is either signature type (cosign 2.6.x stamps +// CosignSignReferrer, cosign 3.x stamps SigstoreBundle — see +// FetchSignatureBundle), returning the raw bytes of the SigstoreBundle layer +// inside the first match, or nil when none is present (unsigned). An error is +// reserved for a genuine transport/parse failure or a malformed referrer, so +// the caller fails closed. +func discoverSignatureBundle(ctx context.Context, target oras.ReadOnlyTarget, subject ocispec.Descriptor) ([]byte, error) { + gs, ok := target.(content.ReadOnlyGraphStorage) + if !ok { + // A target that can't answer Predecessors can't have referrers + // discovered → treat as unsigned (the verifier maps nil to ErrUnsigned). + return nil, nil + } + // Empty artifactType = no server-side filter; referrer lists are tiny and + // filtering client-side is what lets one pass accept both stamp variants. + all, err := registry.Referrers(ctx, gs, subject, "") + if err != nil { + return nil, fmt.Errorf("listing signature referrers: %w", err) + } + var refs []ocispec.Descriptor + for _, r := range all { + if r.ArtifactType == mediatype.CosignSignReferrer || r.ArtifactType == mediatype.SigstoreBundle { + refs = append(refs, r) + } + } + if len(refs) == 0 { + return nil, nil // unsigned — no signature referrer attached + } + // Use the first matching referrer: fetch its manifest, then return the layer + // blob whose media type is the Sigstore bundle JSON. + manifestBytes, err := fetchCapped(ctx, target, refs[0]) + if err != nil { + return nil, fmt.Errorf("fetching signature manifest: %w", err) + } + var m ocispec.Manifest + if uerr := json.Unmarshal(manifestBytes, &m); uerr != nil { + return nil, fmt.Errorf("parsing signature manifest: %w", uerr) + } + for _, layer := range m.Layers { + if layer.MediaType == mediatype.SigstoreBundle { + blob, ferr := fetchCapped(ctx, target, layer) + if ferr != nil { + return nil, fmt.Errorf("fetching signature bundle: %w", ferr) + } + return blob, nil + } + } + // A referrer with the cosign artifactType but no bundle layer is a malformed + // signature, not "unsigned" — surface it rather than silently treating a + // present signature as absent. + return nil, fmt.Errorf("signature referrer %s carries no %s layer", refs[0].Digest, mediatype.SigstoreBundle) +} + +// fetchCapped reads a descriptor's content with a small cap. Used only for the +// referrer manifest and the bundle-JSON layer — both tiny. +func fetchCapped(ctx context.Context, target oras.ReadOnlyTarget, desc ocispec.Descriptor) ([]byte, error) { + rc, err := target.Fetch(ctx, desc) + if err != nil { + return nil, err + } + defer rc.Close() //nolint:errcheck + return io.ReadAll(io.LimitReader(rc, maxSignatureBlobBytes)) +} + +// UnpackLocal reads a Gemara bundle from an OCI image layout directory. +// It is the inverse of PushLocal: the same dir + tag round-trips the bundle. +func UnpackLocal(ctx context.Context, dir, tag string) (*bundle.Bundle, error) { + if dir == "" { + return nil, errors.New("source directory is required") + } + if tag == "" { + return nil, errors.New("tag is required") + } + store, err := oci.New(dir) + if err != nil { + return nil, fmt.Errorf("opening OCI layout: %w", err) + } + return bundle.Unpack(ctx, store, tag) +} + +// PushLocal writes the same bundle to an OCI image layout directory. +// Used by --dry-run; identical bundle shape, no network. +func PushLocal(ctx context.Context, dir, tag string, in PackInput) (*PushResult, error) { + if err := os.MkdirAll(dir, 0o755); err != nil { + return nil, fmt.Errorf("creating output dir: %w", err) + } + store, err := oci.New(dir) + if err != nil { + return nil, fmt.Errorf("opening OCI layout: %w", err) + } + desc, bodyDigest, err := pack(ctx, store, tag, in) + if err != nil { + return nil, err + } + return &PushResult{ + ManifestDigest: desc.Digest.String(), + BodyDigest: bodyDigest, + Tag: tag, + Reference: fmt.Sprintf("oci:%s:%s", dir, tag), + }, nil +} + +// pack is the shared assembly path: build the in-memory Bundle, call +// bundle.Pack against the target, then tag the resulting manifest. +func pack(ctx context.Context, target oras.Target, tag string, in PackInput) (ocispec.Descriptor, string, error) { + if len(in.Body) == 0 { + return ocispec.Descriptor{}, "", errors.New("artifact body is empty") + } + if in.Filename == "" { + return ocispec.Descriptor{}, "", errors.New("artifact filename is empty") + } + + bodyDigest := digest.Bytes(in.Body) + + manifest := bundle.Manifest{ + BundleVersion: "1.0", + GemaraVersion: in.GemaraVersion, + Metadata: map[string]any{}, + Artifacts: []bundle.Artifact{{ + Name: in.Filename, + Type: in.ArtifactType, + ID: in.ArtifactID, + Role: "artifact", + }}, + } + if in.Provenance != nil { + manifest.Metadata["provenance"] = in.Provenance + } + + b := &bundle.Bundle{ + Manifest: manifest, + Files: []bundle.File{{ + Name: in.Filename, + Type: in.ArtifactType, + Data: in.Body, + }}, + } + + var packOpts []bundle.PackOption + if in.License != "" { + // Standard OCI carrier for the publication license (ADR-0036 + // decision 2). Manifest-level annotation, set only when a license + // is declared so omitting --license leaves the manifest unchanged. + packOpts = append(packOpts, bundle.WithAnnotations(map[string]string{ + ocispec.AnnotationLicenses: in.License, + })) + } + + desc, err := bundle.Pack(ctx, target, b, packOpts...) + if err != nil { + return ocispec.Descriptor{}, "", fmt.Errorf("packing bundle: %w", err) + } + if err := target.Tag(ctx, desc, tag); err != nil { + return ocispec.Descriptor{}, "", fmt.Errorf("tagging %s: %w", tag, err) + } + return desc, bodyDigest, nil +} + +func dockerCredentials() (auth.CredentialFunc, error) { + // NewStoreFromDocker reads ~/.docker/config.json and any helpers, + // which is the same chain `docker login` writes to. CI runners + // that have already done `docker login` get auth for free. + store, err := credentials.NewStoreFromDocker(credentials.StoreOptions{}) + if err != nil { + return nil, err + } + envCreds := func(_ context.Context, _ string) (auth.Credential, error) { + // Per-registry env pair: GRCLI_REGISTRY_USERNAME + GRCLI_REGISTRY_PASSWORD + // is the simplest CI override that doesn't require docker login. + u := os.Getenv("GRCLI_REGISTRY_USERNAME") + p := os.Getenv("GRCLI_REGISTRY_PASSWORD") + if u != "" && p != "" { + return auth.Credential{Username: u, Password: p}, nil + } + // Bearer token via GRCLI_REGISTRY_TOKEN — for registries that + // take a raw bearer (e.g. some zot deployments). + if t := os.Getenv("GRCLI_REGISTRY_TOKEN"); t != "" { + return auth.Credential{AccessToken: t}, nil + } + return auth.EmptyCredential, nil + } + return func(ctx context.Context, registry string) (auth.Credential, error) { + if c, err := envCreds(ctx, registry); err == nil && c != (auth.EmptyCredential) { + return c, nil + } + return credentials.Credential(store)(ctx, registry) + }, nil +} diff --git a/internal/registry/registry_test.go b/internal/registry/registry_test.go new file mode 100644 index 0000000..1357958 --- /dev/null +++ b/internal/registry/registry_test.go @@ -0,0 +1,165 @@ +// SPDX-License-Identifier: Apache-2.0 + +package registry + +import ( + "bytes" + "context" + "encoding/json" + "testing" + + godigest "github.com/opencontainers/go-digest" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/revanite-io/grc-store-protocol/mediatype" + "github.com/stretchr/testify/require" + "oras.land/oras-go/v2/content/memory" + "oras.land/oras-go/v2/registry" + + "github.com/revanite-io/grcli/internal/digest" +) + +// pushBlob pushes raw bytes with the given media type and returns its descriptor. +func pushBlob(t *testing.T, store *memory.Store, mediaType string, data []byte) ocispec.Descriptor { + t.Helper() + desc := ocispec.Descriptor{ + MediaType: mediaType, + Digest: godigest.Digest(digest.Bytes(data)), + Size: int64(len(data)), + } + require.NoError(t, store.Push(context.Background(), desc, bytes.NewReader(data))) + return desc +} + +// pushManifest marshals and pushes an image manifest, returning its descriptor. +func pushManifest(t *testing.T, store *memory.Store, m ocispec.Manifest) ocispec.Descriptor { + t.Helper() + m.MediaType = ocispec.MediaTypeImageManifest + data, err := json.Marshal(m) + require.NoError(t, err) + desc := ocispec.Descriptor{ + MediaType: ocispec.MediaTypeImageManifest, + ArtifactType: m.ArtifactType, + Digest: godigest.Digest(digest.Bytes(data)), + Size: int64(len(data)), + } + require.NoError(t, store.Push(context.Background(), desc, bytes.NewReader(data))) + return desc +} + +// subjectManifest pushes a minimal artifact manifest to act as the signature's +// subject (the thing being verified). +func subjectManifest(t *testing.T, store *memory.Store) ocispec.Descriptor { + t.Helper() + config := pushBlob(t, store, "application/vnd.grc-store.test.config", []byte(`{}`)) + body := pushBlob(t, store, "application/vnd.grc-store.test.body", []byte("artifact-body")) + return pushManifest(t, store, ocispec.Manifest{Config: config, Layers: []ocispec.Descriptor{body}}) +} + +// attachSignature pushes a signature referrer of subject with the given +// referrer artifactType and layer media type, carrying bundleBytes. +func attachSignature(t *testing.T, store *memory.Store, subject ocispec.Descriptor, artifactType, layerMediaType string, bundleBytes []byte) { + t.Helper() + config := pushBlob(t, store, artifactType, []byte(`{}`)) + layer := pushBlob(t, store, layerMediaType, bundleBytes) + subjCopy := subject + pushManifest(t, store, ocispec.Manifest{ + ArtifactType: artifactType, + Config: config, + Layers: []ocispec.Descriptor{layer}, + Subject: &subjCopy, + }) +} + +// TestPackSignatureReferrer_StampsSigstoreBundle exercises the real pack/attach +// path. It is the regression guard for the v0.5.1 keyless-publish failure: the +// referrer was packed with artifactType mediatype.CosignSignReferrer, which is +// a URL rather than an RFC 6838 media type, so oras.PackManifest rejected it +// ("invalid artifactType format") before any network I/O — deterministically, +// on every keyless publish (eddie-knight/gemara-asset-mirror @ 2ee9a5e, +// 2026-08-19). Asserting the stamp is SigstoreBundle also pins the write side +// to the type every hub generation accepts at ingest. +func TestPackSignatureReferrer_StampsSigstoreBundle(t *testing.T) { + store := memory.New() + subject := subjectManifest(t, store) + want := []byte(`{"mediaType":"application/vnd.dev.sigstore.bundle.v0.3+json","the":"bundle"}`) + + require.NoError(t, packSignatureReferrer(context.Background(), store, subject, want)) + + refs, err := registry.Referrers(context.Background(), store, subject, "") + require.NoError(t, err) + require.Len(t, refs, 1) + require.Equal(t, mediatype.SigstoreBundle, refs[0].ArtifactType) + + // Round-trip: what we attach is what our own discovery (and the hub's + // ociref, which accepts the same pair) reads back. + got, err := discoverSignatureBundle(context.Background(), store, subject) + require.NoError(t, err) + require.Equal(t, want, got) +} + +func TestDiscoverSignatureBundle_FindsCosignReferrer(t *testing.T) { + store := memory.New() + subject := subjectManifest(t, store) + want := []byte(`{"mediaType":"application/vnd.dev.sigstore.bundle.v0.3+json","the":"bundle"}`) + // grcli's catalog signatures are attached with the cosign-sign artifactType, + // with the v0.3 bundle blob as the layer. + attachSignature(t, store, subject, mediatype.CosignSignReferrer, mediatype.SigstoreBundle, want) + + got, err := discoverSignatureBundle(context.Background(), store, subject) + require.NoError(t, err) + require.Equal(t, want, got) +} + +func TestDiscoverSignatureBundle_UnsignedReturnsNil(t *testing.T) { + store := memory.New() + subject := subjectManifest(t, store) + // No referrer attached. + got, err := discoverSignatureBundle(context.Background(), store, subject) + require.NoError(t, err) + require.Nil(t, got, "no signature referrer → nil bundle (the verifier maps nil to ErrUnsigned)") +} + +// TestDiscoverSignatureBundle_FindsSigstoreBundleArtifactType pins the cosign +// 3.x stamp variant: cosign 3.x signs with the bundle format by default and +// attaches the referrer with artifactType SigstoreBundle (not the 2.6.x-era +// CosignSignReferrer). Discovery must accept both — this exact miss (filtering +// on CosignSignReferrer only) made the first live cosign-3.x-signed catalog +// verify as "no signature attached" (2026-07-07). Supersedes the old +// "do not cross these" guard, whose premise predates cosign 3.x. +func TestDiscoverSignatureBundle_FindsSigstoreBundleArtifactType(t *testing.T) { + store := memory.New() + subject := subjectManifest(t, store) + want := []byte(`{"mediaType":"application/vnd.dev.sigstore.bundle.v0.3+json","the":"bundle"}`) + attachSignature(t, store, subject, mediatype.SigstoreBundle, mediatype.SigstoreBundle, want) + + got, err := discoverSignatureBundle(context.Background(), store, subject) + require.NoError(t, err) + require.Equal(t, want, got) +} + +// TestDiscoverSignatureBundle_IgnoresUnrelatedArtifactType: a referrer that is +// neither signature stamp variant (e.g. an SBOM attachment) must not be +// mistaken for a signature. +func TestDiscoverSignatureBundle_IgnoresUnrelatedArtifactType(t *testing.T) { + store := memory.New() + subject := subjectManifest(t, store) + attachSignature(t, store, subject, "application/spdx+json", "application/spdx+json", []byte(`{"sbom":"x"}`)) + + got, err := discoverSignatureBundle(context.Background(), store, subject) + require.NoError(t, err) + require.Nil(t, got, "a non-signature referrer must not match signature discovery") +} + +// TestDiscoverSignatureBundle_MalformedReferrerErrors confirms a cosign-typed +// referrer with no Sigstore bundle layer is a hard error (present-but-malformed), +// never silently treated as unsigned. +func TestDiscoverSignatureBundle_MalformedReferrerErrors(t *testing.T) { + store := memory.New() + subject := subjectManifest(t, store) + // Right artifactType, but the layer is some other media type — no bundle. + attachSignature(t, store, subject, mediatype.CosignSignReferrer, "application/octet-stream", []byte("not-a-bundle")) + + _, err := discoverSignatureBundle(context.Background(), store, subject) + require.Error(t, err) + require.Contains(t, err.Error(), "carries no") +} diff --git a/internal/sign/keyless.go b/internal/sign/keyless.go new file mode 100644 index 0000000..6ec367c --- /dev/null +++ b/internal/sign/keyless.go @@ -0,0 +1,169 @@ +// SPDX-License-Identifier: Apache-2.0 + +package sign + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strings" + "time" + + sgbundle "github.com/sigstore/sigstore-go/pkg/bundle" + sgsign "github.com/sigstore/sigstore-go/pkg/sign" +) + +// In-process keyless signing (ADR-0049). This is the symmetric half of the +// in-process VERIFY path (ADR-0046, internal/sigverify): grcli signs the +// pushed artifact with a short-lived Fulcio certificate obtained via the +// runner's OIDC token, logs it in Rekor, and produces a Sigstore v0.3 bundle — +// all with the sigstore-go library grcli already depends on for verification, +// so publishing no longer requires cosign on PATH. +// +// The signature is a DSSE-wrapped in-toto Statement whose single subject digest +// is the artifact's manifest digest (payloadType application/vnd.in-toto+json, +// predicateType https://sigstore.dev/cosign/sign/v1) — byte-shaped to match what +// `cosign sign --new-bundle-format` produces, so the on-registry format stays +// uniform and the hub's verifier (which mirrors internal/sigverify and checks +// the subject via WithArtifactDigest) accepts it unchanged. + +const ( + // sigstoreOIDCAudience is the audience Fulcio requires on the OIDC token. + sigstoreOIDCAudience = "sigstore" + + // inTotoPayloadType is the DSSE payloadType cosign uses for a container + // signature in the new bundle format; the verifier keys subject extraction + // on the in-toto statement shape, not this exact string, but matching it + // keeps grcli's output indistinguishable from cosign's. + inTotoPayloadType = "application/vnd.in-toto+json" + + // cosignSignPredicateType is the predicateType on that statement. + cosignSignPredicateType = "https://sigstore.dev/cosign/sign/v1" + + defaultFulcioURL = "https://fulcio.sigstore.dev" + defaultRekorURL = "https://rekor.sigstore.dev" +) + +// fulcioURL / rekorURL resolve the signing endpoints, honoring env overrides for +// a private Sigstore / air-gapped deployment (mirrors GRCLI_TRUSTED_ROOT on the +// verify side). Empty env → the public-good instances. +func fulcioURL() string { + if u := os.Getenv("GRCLI_FULCIO_URL"); u != "" { + return u + } + return defaultFulcioURL +} + +func rekorURL() string { + if u := os.Getenv("GRCLI_REKOR_URL"); u != "" { + return u + } + return defaultRekorURL +} + +// InTotoStatement builds the DSSE payload: an in-toto Statement v1 whose lone +// subject carries the artifact's manifest digest. Maps (not structs) are used so +// the empty `annotations` and `predicate` objects marshal as `{}` rather than +// `null`, matching cosign. Key order is irrelevant — the verifier re-parses. +// Exported so the sign→verify round-trip test (internal/sigverify) can prove the +// exact payload this signer emits verifies against the verifier's subject check. +func InTotoStatement(manifestDigest string) ([]byte, error) { + hexDigest := strings.TrimPrefix(manifestDigest, "sha256:") + if raw, err := hex.DecodeString(hexDigest); err != nil || len(raw) != sha256.Size { + return nil, fmt.Errorf("expected a sha256 manifest digest, got %q", manifestDigest) + } + stmt := map[string]any{ + "_type": "https://in-toto.io/Statement/v1", + "subject": []map[string]any{{ + "digest": map[string]string{"sha256": hexDigest}, + "annotations": map[string]any{}, + }}, + "predicateType": cosignSignPredicateType, + "predicate": map[string]any{}, + } + return json.Marshal(stmt) +} + +// signKeylessInProcess produces a Sigstore v0.3 bundle (JSON bytes) for the +// given manifest digest, keyless, via sigstore-go — no cosign. It obtains the +// OIDC token from the GitHub Actions runtime (the only keyless publish path), +// requests a Fulcio cert, signs the in-toto statement, and logs it in Rekor. +func signKeylessInProcess(ctx context.Context, manifestDigest string) ([]byte, error) { + token, err := githubOIDCToken(ctx, sigstoreOIDCAudience) + if err != nil { + return nil, err + } + statement, err := InTotoStatement(manifestDigest) + if err != nil { + return nil, err + } + keypair, err := sgsign.NewEphemeralKeypair(nil) + if err != nil { + return nil, fmt.Errorf("generating ephemeral keypair: %w", err) + } + content := &sgsign.DSSEData{Data: statement, PayloadType: inTotoPayloadType} + opts := sgsign.BundleOptions{ + CertificateProvider: sgsign.NewFulcio(&sgsign.FulcioOptions{BaseURL: fulcioURL(), Timeout: 30 * time.Second, Retries: 2}), + CertificateProviderOptions: &sgsign.CertificateProviderOptions{IDToken: token}, + TransparencyLogs: []sgsign.Transparency{sgsign.NewRekor(&sgsign.RekorOptions{BaseURL: rekorURL(), Timeout: 60 * time.Second, Retries: 2})}, + Context: ctx, + } + pb, err := sgsign.Bundle(content, keypair, opts) + if err != nil { + return nil, fmt.Errorf("sigstore keyless sign: %w", err) + } + b, err := sgbundle.NewBundle(pb) + if err != nil { + return nil, fmt.Errorf("assembling signature bundle: %w", err) + } + out, err := b.MarshalJSON() + if err != nil { + return nil, fmt.Errorf("serializing signature bundle: %w", err) + } + return out, nil +} + +// githubOIDCToken requests an OIDC ID token from the GitHub Actions token +// service for the given audience. Requires `permissions: id-token: write` on the +// workflow (which populates ACTIONS_ID_TOKEN_REQUEST_URL / _TOKEN). This is the +// token cosign used to read implicitly; grcli now requests it directly. +func githubOIDCToken(ctx context.Context, audience string) (string, error) { + reqURL := os.Getenv("ACTIONS_ID_TOKEN_REQUEST_URL") + reqToken := os.Getenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN") + if reqURL == "" || reqToken == "" { + return "", errors.New("GitHub Actions OIDC token unavailable " + + "(ACTIONS_ID_TOKEN_REQUEST_URL / _TOKEN unset) — add `permissions: id-token: write` " + + "to the workflow for keyless signing, or pass --no-sign") + } + u := reqURL + "&audience=" + url.QueryEscape(audience) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return "", fmt.Errorf("building OIDC token request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+reqToken) + req.Header.Set("Accept", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", fmt.Errorf("requesting GitHub Actions OIDC token: %w", err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("GitHub Actions OIDC token endpoint returned %d: %s", resp.StatusCode, bytes.TrimSpace(body)) + } + var out struct { + Value string `json:"value"` + } + if err := json.Unmarshal(body, &out); err != nil || out.Value == "" { + return "", errors.New("GitHub Actions OIDC token response had no `value`") + } + return out.Value, nil +} diff --git a/internal/sign/keyless_test.go b/internal/sign/keyless_test.go new file mode 100644 index 0000000..5855052 --- /dev/null +++ b/internal/sign/keyless_test.go @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: Apache-2.0 + +package sign + +import ( + "encoding/json" + "strings" + "testing" +) + +// TestInTotoStatement pins the DSSE payload structure the in-process signer +// emits (ADR-0049): an in-toto Statement v1 whose single subject digest is the +// manifest digest, with cosign's predicateType and empty `{}` (not null) +// annotations/predicate. The sign→verify round-trip in internal/sigverify proves +// this exact structure verifies; this pins the structure itself. +func TestInTotoStatement(t *testing.T) { + hexDigest := strings.Repeat("ab", 32) // 64 hex chars + b, err := InTotoStatement("sha256:" + hexDigest) + if err != nil { + t.Fatalf("InTotoStatement: %v", err) + } + var stmt struct { + Type string `json:"_type"` + Subject []struct { + Digest map[string]string `json:"digest"` + Annotations json.RawMessage `json:"annotations"` + } `json:"subject"` + PredicateType string `json:"predicateType"` + Predicate json.RawMessage `json:"predicate"` + } + if err := json.Unmarshal(b, &stmt); err != nil { + t.Fatalf("statement is not valid JSON: %v\n%s", err, b) + } + if stmt.Type != "https://in-toto.io/Statement/v1" { + t.Errorf("_type = %q", stmt.Type) + } + if len(stmt.Subject) != 1 || stmt.Subject[0].Digest["sha256"] != hexDigest { + t.Errorf("subject digest = %+v, want sha256=%s", stmt.Subject, hexDigest) + } + if stmt.PredicateType != cosignSignPredicateType { + t.Errorf("predicateType = %q", stmt.PredicateType) + } + // Empty objects, not null — cosign compatibility. + if string(stmt.Predicate) != "{}" { + t.Errorf("predicate = %s, want {}", stmt.Predicate) + } + if string(stmt.Subject[0].Annotations) != "{}" { + t.Errorf("annotations = %s, want {}", stmt.Subject[0].Annotations) + } +} + +func TestInTotoStatement_RejectsBadDigest(t *testing.T) { + for _, bad := range []string{ + "", + "not-a-digest", + "sha256:tooshort", + "sha256:" + strings.Repeat("zz", 32), // right length, non-hex + "sha512:" + strings.Repeat("ab", 32), // wrong algorithm prefix (not stripped) + } { + if _, err := InTotoStatement(bad); err == nil { + t.Errorf("expected error for %q", bad) + } + } + // A bare (prefix-less) 64-hex digest is accepted — the prefix is optional. + if _, err := InTotoStatement(strings.Repeat("ab", 32)); err != nil { + t.Errorf("bare 64-hex digest should be accepted, got %v", err) + } +} diff --git a/internal/sign/sign.go b/internal/sign/sign.go new file mode 100644 index 0000000..8c23c05 --- /dev/null +++ b/internal/sign/sign.go @@ -0,0 +1,330 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Package sign signs a pushed artifact as a separate step after push. +// +// Keyless signing (the CI trusted-publishing path) runs IN-PROCESS via +// sigstore-go — the same library internal/sigverify uses to verify — so +// publishing needs no cosign (ADR-0049, symmetric to ADR-0046's in-process +// verify). See keyless.go. Key-based signing (--cosign-key) still shells out to +// cosign, the one remaining path that needs it on PATH. +package sign + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "strings" + + "golang.org/x/mod/semver" + + "github.com/revanite-io/grcli/internal/registry" +) + +// Mode reports how sign() resolved its trust material. +type Mode string + +const ( + ModeKeyless Mode = "keyless" + ModeKey Mode = "key" + ModeSkipped Mode = "skipped" +) + +// FlagNewBundleFormat makes cosign store the signature as a Sigstore **bundle** +// (media type application/vnd.dev.sigstore.bundle.v0.3+json) attached as an OCI +// 1.1 referrer of the manifest, instead of the legacy tag-based `sha256-….sig`. +// This converges grc.store on one signature format across artifact types: it is +// the format the hub's plugin verifier already expects and that pvtr already +// produces (ADR-0034 dec. 7, ADR-0035). +// +// It is EXPORTED so the verify side (cmd/verify.go) references the same constant +// — a bundle-signed artifact is verified with `cosign verify --new-bundle-format` +// and does NOT verify against the legacy `.sig` path (and vice versa), so sign +// and verify MUST stay a matched pair. Sharing one constant makes that structural, +// not coincidental. +// +// The flag is NOT passed unconditionally — it only exists on a bounded band of +// cosign versions. Callers select it via BundleFormatArgs, which gates on the +// detected cosign version. See that function for the rationale. +const FlagNewBundleFormat = "--new-bundle-format" + +// minBundleFormatCosign is the oldest cosign whose SIGN command understands +// --new-bundle-format: cosign added it to `verify` in 2.4.0 but to `sign` +// only in 2.6.0 (checked against the release tags' options/sign.go — 2.4.x +// and 2.5.x abort with `unknown flag: --new-bundle-format`). Since this +// helper feeds the sign path, the floor is the sign flag's, not verify's. +const minBundleFormatCosign = "v2.6.0" + +// bundleDefaultCosign is the cosign version at which the Sigstore bundle format +// became the DEFAULT and --new-bundle-format was deprecated (cosign 3.0.0). At +// or above this the flag is redundant, prints a deprecation warning on every +// invocation, and is slated for removal — so we omit it and rely on the default. +const bundleDefaultCosign = "v3.0.0" + +// BundleFormatArgs returns the cosign CLI flags that select grc.store's Sigstore +// bundle signature format for the cosign currently on PATH, gating on its +// version so grcli works across the whole supported cosign range instead of the +// narrow 2.4.0–2.6.x band the flag was hard-coded for: +// +// cosign < 2.6.0 → error (flag doesn't exist on `sign`; fail fast with a +// clear message instead of cosign's raw `unknown flag`) +// 2.6.0 ≤ cosign < 3.0.0 → ["--new-bundle-format"] (flag is first-class here) +// cosign ≥ 3.0.0 → nil (bundle format is the default; passing the +// deprecated flag only warns and will break +// when cosign removes it) +// version undeterminable → error (fail closed — guessing wrong silently +// produces a format the verify side rejects) +// +// Both the sign path and the key-based verify shell-out call this, so a +// bundle-signed artifact is always verified as a bundle: the two stay a matched +// pair by construction, not convention. +func BundleFormatArgs(ctx context.Context) ([]string, error) { + v, err := detectCosignVersion(ctx) + if err != nil { + return nil, err + } + switch { + case semver.Compare(v, minBundleFormatCosign) < 0: + return nil, fmt.Errorf("cosign %s is too old for grc.store's Sigstore bundle "+ + "signature format, which needs cosign ≥ 2.6.0 — pin a newer cosign "+ + "(e.g. sigstore/cosign-installer with a version ≥ v2.6.0), or pass "+ + "--no-sign to publish without provenance", v) + case semver.Compare(v, bundleDefaultCosign) < 0: + return []string{FlagNewBundleFormat}, nil + default: + return nil, nil + } +} + +// detectCosignVersion returns the canonical, v-prefixed semver reported by the +// cosign on PATH. It prefers `cosign version --json` (stable since cosign 2.x) +// and falls back to scraping the `GitVersion:` line of the human-readable +// output. It fails CLOSED: an unparseable version — a source `devel` build, a +// pseudo-version, a truncated string — is an error, because selecting the wrong +// signature format silently produces a signature the verify side won't accept. +func detectCosignVersion(ctx context.Context) (string, error) { + raw, err := cosignVersionString(ctx) + if err != nil { + return "", err + } + v := raw + if !strings.HasPrefix(v, "v") { + v = "v" + v + } + if !semver.IsValid(v) { + return "", fmt.Errorf("could not determine the cosign version (got %q) — "+ + "install a released cosign ≥ 2.4.0 so grcli can select the correct "+ + "signature format, or pass --no-sign", raw) + } + return semver.Canonical(v), nil +} + +// cosignVersionString returns cosign's self-reported version string (e.g. +// "v3.0.6"), preferring the machine-readable `--json` form and falling back to +// the GitVersion: line of plain `cosign version`. +func cosignVersionString(ctx context.Context) (string, error) { + if out, err := exec.CommandContext(ctx, "cosign", "version", "--json").Output(); err == nil { + var payload struct { + GitVersion string `json:"gitVersion"` + } + if json.Unmarshal(out, &payload) == nil && payload.GitVersion != "" { + return strings.TrimSpace(payload.GitVersion), nil + } + } + out, err := exec.CommandContext(ctx, "cosign", "version").Output() + if err != nil { + return "", fmt.Errorf("running `cosign version`: %w", err) + } + for line := range strings.SplitSeq(string(out), "\n") { + if rest, ok := strings.CutPrefix(strings.TrimSpace(line), "GitVersion:"); ok { + return strings.TrimSpace(rest), nil + } + } + return "", errors.New("could not parse `cosign version` output for a GitVersion") +} + +// Result is what Sign returns to the caller for logging. +type Result struct { + Mode Mode + Reason string // populated when Mode == ModeSkipped +} + +// Options carries the user-facing knobs. +type Options struct { + // Disabled is set by --no-sign; when true we never sign. + Disabled bool + // KeyPath is the cosign key file path; equivalent to cosign sign --key. + // Selects the key-based (cosign shell-out) path. Empty in CI, where the + // keyless in-process path is used. + KeyPath string + // Reference is the full /: — used for display + // and as the cosign key-mode target. + Reference string + // PlainHTTP signals the registry speaks plain HTTP (a local dev zot). + // For key mode, cosign gets --allow-http-registry; for keyless, the + // scheme in RegistryHost drives it. + PlainHTTP bool + + // RegistryHost, Repository, and ManifestDigest are the coordinates the + // keyless in-process path (ADR-0049) needs: it signs ManifestDigest and + // attaches the bundle as an OCI referrer at RegistryHost/Repository. Unset + // for key mode (cosign resolves the reference itself). RegistryHost keeps + // any http(s):// scheme so the oras push targets the right transport. + RegistryHost string + Repository string + ManifestDigest string +} + +// Preflight reports whether a subsequent Sign call will be able to +// produce a signature — WITHOUT running cosign — so callers can fail +// before pushing rather than orphan unsigned bytes in the registry. +// +// It fails CLOSED: anything short of "we can sign" is an error, because +// an unsigned artifact has no verifiable provenance and the hub does not +// reject it on ingest. The single deliberate exception is --no-sign. +// +// --no-sign → ok (publishing unsigned is an explicit choice) +// cosign not on PATH → error +// cosign out of range → error (too old for the bundle format; see BundleFormatArgs) +// GITHUB_ACTIONS=true → ok if id-token is available, else error +// KeyPath != "" → ok +// otherwise → error (no signing material) +// +// The one thing it DOES run is `cosign version` (via BundleFormatArgs) — a +// cheap, side-effect-free probe — so an out-of-band cosign fails here, before +// any bytes are pushed, rather than after Sign shells out and cosign rejects the +// signature flag. +func Preflight(ctx context.Context, opts Options) error { + if opts.Disabled { + return nil + } + switch { + case os.Getenv("GITHUB_ACTIONS") == "true": + // Keyless in-process (ADR-0049): NO cosign needed — grcli requests the + // GHA OIDC token itself and signs via sigstore-go. Requires + // `permissions: id-token: write` (which populates the request env). + if os.Getenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN") == "" { + return errors.New("GITHUB_ACTIONS=true but ACTIONS_ID_TOKEN_REQUEST_TOKEN is unset — " + + "add `permissions: id-token: write` to the workflow for keyless signing, or pass --no-sign") + } + return nil + case opts.KeyPath != "": + // Key-based signing is the ONLY path that still shells out to cosign. + if _, err := exec.LookPath("cosign"); err != nil { + return errors.New("cosign not found on PATH — required only for --cosign-key (key-based) signing; " + + "keyless CI signing needs no external tools. Install cosign, or pass --no-sign") + } + if _, err := BundleFormatArgs(ctx); err != nil { + return err + } + return nil + default: + return errors.New("no signing material — pass --cosign-key (or COSIGN_KEY) for local signing, " + + "run in GitHub Actions with `permissions: id-token: write` for keyless signing, " + + "or pass --no-sign to publish without provenance") + } +} + +// Sign attaches a cosign signature to the pushed manifest. It fails +// CLOSED — the only path that returns ModeSkipped is --no-sign; every +// other inability to sign (no cosign, no key/CI material, cosign error) +// is an error, so a publish never silently downgrades to unsigned. +// +// Decision tree: +// +// --no-sign → ModeSkipped, no error +// GITHUB_ACTIONS=true → ModeKeyless, in-process via sigstore-go (error if id-token missing) +// KeyPath != "" → ModeKey, cosign shell-out (error if cosign absent) +// otherwise → error (no signing material) +// +// Callers should run Preflight before pushing; Sign repeats the same +// checks as a backstop because it runs after the bytes are already in +// the registry. +func Sign(ctx context.Context, opts Options) (*Result, error) { + if opts.Disabled { + return &Result{Mode: ModeSkipped, Reason: "--no-sign"}, nil + } + if opts.Reference == "" { + return nil, errors.New("sign: empty reference") + } + if err := Preflight(ctx, opts); err != nil { + return nil, fmt.Errorf("sign: %w", err) + } + + // Keyless in CI runs fully in-process (ADR-0049): sign the manifest digest + // via sigstore-go and attach the bundle as an OCI referrer — no cosign. + if os.Getenv("GITHUB_ACTIONS") == "true" { + if opts.ManifestDigest == "" || opts.RegistryHost == "" || opts.Repository == "" { + return nil, errors.New("sign: keyless signing needs the manifest digest, registry host, and repository") + } + bundleJSON, err := signKeylessInProcess(ctx, opts.ManifestDigest) + if err != nil { + return nil, fmt.Errorf("keyless sign: %w", err) + } + if err := registry.AttachSignatureReferrer(ctx, opts.RegistryHost, opts.Repository, opts.ManifestDigest, bundleJSON); err != nil { + return nil, fmt.Errorf("attaching signature to registry: %w", err) + } + return &Result{Mode: ModeKeyless}, nil + } + + // Key-based signing shells out to cosign (the one remaining cosign path). + // Select the signature-format flag for the detected cosign (empty on + // cosign ≥ 3.0.0). Preflight already validated the version. + bundleArgs, err := BundleFormatArgs(ctx) + if err != nil { + return nil, fmt.Errorf("sign: %w", err) + } + args := append([]string{"sign", "--yes"}, bundleArgs...) + args = append(args, "--key", opts.KeyPath) + args = append(args, registryFlags(opts)...) + args = append(args, opts.Reference) + if err := runCosign(ctx, args...); err != nil { + return nil, fmt.Errorf("cosign key sign: %w", err) + } + return &Result{Mode: ModeKey}, nil +} + +func runCosign(ctx context.Context, args ...string) error { + cmd := exec.CommandContext(ctx, "cosign", args...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +// registryCredArgs returns cosign registry-auth flags derived from the +// same GRCLI_REGISTRY_* env vars grcli's oras push honors (see +// internal/registry.dockerCredentials), or nil when none are set. +// +// Why this is needed: the cosign subprocess has its own credential +// chain (the Docker config) and does NOT read GRCLI_REGISTRY_*. Now +// that the registry rejects anonymous writes, an env-var-only publish +// would push the bundle and then 401 when cosign pushes the signature +// to the same repository. Forwarding the creds makes the env-var path a +// complete publish flow; `docker login` remains a valid alternative +// (cosign reads it natively, so we forward nothing and rely on the +// chain in that case). +// +// Precedence mirrors dockerCredentials: username+password first, then a +// raw bearer token. +func registryCredArgs() []string { + if u, p := os.Getenv("GRCLI_REGISTRY_USERNAME"), os.Getenv("GRCLI_REGISTRY_PASSWORD"); u != "" && p != "" { + return []string{"--registry-username", u, "--registry-password", p} + } + if t := os.Getenv("GRCLI_REGISTRY_TOKEN"); t != "" { + return []string{"--registry-token", t} + } + return nil +} + +// registryFlags is the full set of cosign registry-auth/transport flags +// for a sign run: the credential args plus --allow-http-registry when the +// target is a plain-HTTP (local dev) registry. +func registryFlags(opts Options) []string { + args := registryCredArgs() + if opts.PlainHTTP { + args = append(args, "--allow-http-registry") + } + return args +} diff --git a/internal/sign/sign_test.go b/internal/sign/sign_test.go new file mode 100644 index 0000000..0f19391 --- /dev/null +++ b/internal/sign/sign_test.go @@ -0,0 +1,233 @@ +// SPDX-License-Identifier: Apache-2.0 + +package sign + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +// fakeCosignScript builds a /bin/sh body for a fake cosign that answers +// `cosign version[ --json]` with the given semver (Preflight probes the version +// via BundleFormatArgs), and — when argsFile != "" — appends every arg of any +// OTHER invocation to argsFile so a test can assert the exact sign/verify flags. +func fakeCosignScript(version, argsFile string) string { + s := "#!/bin/sh\n" + + "if [ \"$1\" = version ]; then printf '{\"gitVersion\":\"" + version + "\"}\\n'; exit 0; fi\n" + if argsFile != "" { + s += "for a in \"$@\"; do printf '%s\\n' \"$a\" >> " + argsFile + "; done\n" + } + return s + "exit 0\n" +} + +// cosignOnPath puts a dummy cosign on PATH so the LookPath check passes and the +// version probe reports an in-band version. It doesn't record args. +func cosignOnPath(t *testing.T) { + t.Helper() + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "cosign"), []byte(fakeCosignScript("v2.6.3", "")), 0o755); err != nil { + t.Fatalf("write fake cosign: %v", err) + } + t.Setenv("PATH", dir) +} + +// cosignAbsent points PATH at an empty dir so LookPath("cosign") fails. +func cosignAbsent(t *testing.T) { + t.Helper() + t.Setenv("PATH", t.TempDir()) +} + +func TestPreflight(t *testing.T) { + t.Run("--no-sign is the one allowed skip, even with nothing available", func(t *testing.T) { + cosignAbsent(t) + t.Setenv("GITHUB_ACTIONS", "") + t.Setenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN", "") + if err := Preflight(context.Background(), Options{Disabled: true}); err != nil { + t.Fatalf("--no-sign must pass preflight, got %v", err) + } + }) + + t.Run("CI keyless needs NO cosign on PATH (ADR-0049)", func(t *testing.T) { + cosignAbsent(t) + t.Setenv("GITHUB_ACTIONS", "true") + t.Setenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN", "tok") + if err := Preflight(context.Background(), Options{}); err != nil { + t.Fatalf("keyless CI signing is in-process and must NOT require cosign, got %v", err) + } + }) + + t.Run("--cosign-key without cosign fails closed", func(t *testing.T) { + cosignAbsent(t) + t.Setenv("GITHUB_ACTIONS", "") + t.Setenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN", "") + err := Preflight(context.Background(), Options{KeyPath: "/keys/x.key"}) + if err == nil || !strings.Contains(err.Error(), "cosign") { + t.Fatalf("want a cosign-not-found error for --cosign-key, got %v", err) + } + }) + + t.Run("CI with id-token passes (keyless)", func(t *testing.T) { + cosignOnPath(t) + t.Setenv("GITHUB_ACTIONS", "true") + t.Setenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN", "tok") + if err := Preflight(context.Background(), Options{}); err != nil { + t.Fatalf("CI keyless should pass, got %v", err) + } + }) + + t.Run("CI without id-token fails closed", func(t *testing.T) { + cosignOnPath(t) + t.Setenv("GITHUB_ACTIONS", "true") + t.Setenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN", "") + err := Preflight(context.Background(), Options{}) + if err == nil || !strings.Contains(err.Error(), "id-token") { + t.Fatalf("want an id-token error, got %v", err) + } + }) + + t.Run("local with --cosign-key passes", func(t *testing.T) { + cosignOnPath(t) + t.Setenv("GITHUB_ACTIONS", "") + t.Setenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN", "") + if err := Preflight(context.Background(), Options{KeyPath: "/keys/x.key"}); err != nil { + t.Fatalf("local key should pass, got %v", err) + } + }) + + t.Run("local with no key and no CI fails closed", func(t *testing.T) { + cosignOnPath(t) + t.Setenv("GITHUB_ACTIONS", "") + t.Setenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN", "") + err := Preflight(context.Background(), Options{}) + if err == nil || !strings.Contains(err.Error(), "signing material") { + t.Fatalf("want a no-signing-material error, got %v", err) + } + }) +} + +// recordingCosign installs a fake cosign that reports the given version and +// appends the args of any non-version invocation (one per line) to a file, +// returning that file's path. Lets a test assert the exact flags grcli passes +// for a chosen cosign version, without a real registry. +func recordingCosign(t *testing.T, version string) string { + t.Helper() + dir := t.TempDir() + argsFile := filepath.Join(dir, "args") + if err := os.WriteFile(filepath.Join(dir, "cosign"), []byte(fakeCosignScript(version, argsFile)), 0o755); err != nil { + t.Fatalf("write recording cosign: %v", err) + } + t.Setenv("PATH", dir) + return argsFile +} + +// TestSignBundleFormatByCosignVersion pins that grcli selects the Sigstore +// bundle-as-referrer format (ADR-0035) correctly across the cosign range on the +// KEY-based path — the only path that still shells out to cosign (ADR-0049 moved +// keyless in-process, so it no longer invokes cosign at all). It passes +// --new-bundle-format on cosign 2.6–2.x and omits it on ≥ 3.0.0 (where the +// bundle format is the default and the flag is deprecated). +func TestSignBundleFormatByCosignVersion(t *testing.T) { + cases := []struct { + name string + version string + wantFlag bool + }{ + {"2.6.x band passes the flag", "v2.6.3", true}, + {"3.x omits the deprecated flag", "v3.0.6", false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + argsFile := recordingCosign(t, tc.version) + t.Setenv("GITHUB_ACTIONS", "") + t.Setenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN", "") + keyPath := filepath.Join(t.TempDir(), "cosign.key") + if err := os.WriteFile(keyPath, []byte("x"), 0o600); err != nil { + t.Fatalf("write key: %v", err) + } + if _, err := Sign(context.Background(), Options{Reference: "reg/repo:1", KeyPath: keyPath}); err != nil { + t.Fatalf("sign: %v", err) + } + assertBundleFlag(t, argsFile, tc.wantFlag) + }) + } +} + +func assertBundleFlag(t *testing.T, argsFile string, want bool) { + t.Helper() + got, err := os.ReadFile(argsFile) + if err != nil { + t.Fatalf("read args: %v", err) + } + if has := strings.Contains(string(got), "--new-bundle-format"); has != want { + t.Errorf("--new-bundle-format present=%v, want %v; sign args:\n%s", has, want, got) + } +} + +// TestBundleFormatArgsRejectsOutOfRangeCosign pins the fail-fast behavior: a +// cosign too old for the flag, or one whose version can't be parsed, is a clear +// grcli error rather than cosign's raw `unknown flag` surfacing after a push. +func TestBundleFormatArgsRejectsOutOfRangeCosign(t *testing.T) { + t.Run("too old names the required version", func(t *testing.T) { + recordingCosign(t, "v2.2.0") + _, err := BundleFormatArgs(context.Background()) + if err == nil || !strings.Contains(err.Error(), "2.6.0") { + t.Fatalf("want a too-old error naming cosign 2.6.0, got %v", err) + } + }) + + // Regression pin for the 2.4.x–2.5.x dead zone: those cosigns accept + // --new-bundle-format on `verify` but NOT on `sign` (the flag reached + // `sign` only in 2.6.0), so they must be rejected up front rather than + // die mid-publish on cosign's raw `unknown flag`. Caught live by a + // GitHub Actions publish pinned to cosign v2.5.2 (2026-07-07). + t.Run("2.5.x is in the sign-flag dead zone", func(t *testing.T) { + recordingCosign(t, "v2.5.2") + _, err := BundleFormatArgs(context.Background()) + if err == nil || !strings.Contains(err.Error(), "2.6.0") { + t.Fatalf("want a too-old error naming cosign 2.6.0, got %v", err) + } + }) + + t.Run("unparseable version fails closed", func(t *testing.T) { + recordingCosign(t, "devel") + _, err := BundleFormatArgs(context.Background()) + if err == nil || !strings.Contains(err.Error(), "determine the cosign version") { + t.Fatalf("want an undeterminable-version error, got %v", err) + } + }) +} + +func TestSignFailsClosed(t *testing.T) { + t.Run("--no-sign returns ModeSkipped without error", func(t *testing.T) { + cosignAbsent(t) + r, err := Sign(context.Background(), Options{Disabled: true, Reference: "reg/repo:1"}) + if err != nil { + t.Fatalf("--no-sign should not error: %v", err) + } + if r.Mode != ModeSkipped { + t.Errorf("Mode = %q, want skipped", r.Mode) + } + }) + + t.Run("empty reference errors", func(t *testing.T) { + if _, err := Sign(context.Background(), Options{}); err == nil { + t.Fatal("want error for empty reference") + } + }) + + t.Run("cannot sign is an error, never a silent unsigned publish", func(t *testing.T) { + cosignAbsent(t) + t.Setenv("GITHUB_ACTIONS", "") + t.Setenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN", "") + r, err := Sign(context.Background(), Options{Reference: "reg/repo:1"}) + if err == nil { + t.Fatalf("want error when signing material/cosign is missing, got result %+v", r) + } + if !strings.Contains(err.Error(), "cosign") { + t.Errorf("error = %v, want it to mention cosign", err) + } + }) +} diff --git a/internal/sigverify/roundtrip_test.go b/internal/sigverify/roundtrip_test.go new file mode 100644 index 0000000..779fb18 --- /dev/null +++ b/internal/sigverify/roundtrip_test.go @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: Apache-2.0 + +package sigverify + +import ( + "testing" + + "github.com/sigstore/sigstore-go/pkg/testing/ca" + "github.com/stretchr/testify/require" + + "github.com/revanite-io/grcli/internal/sign" +) + +// TestVerifyEntity_AcceptsInTotoDSSE is the ADR-0049 sign→verify round-trip: it +// proves the EXACT in-toto DSSE payload grcli's in-process signer emits +// (sign.InTotoStatement) verifies against this verifier's WithArtifactDigest +// subject check. Since the hub mirrors internal/sigverify, a bundle grcli +// produces in-process is accepted by both — so dropping cosign does not change +// what the registry considers verifiable. +func TestVerifyEntity_AcceptsInTotoDSSE(t *testing.T) { + vs, err := ca.NewVirtualSigstore() + require.NoError(t, err) + v := newTestVerifier(t, vs) + + // The digest carried in the statement's subject == what the verifier is + // asked to confirm (the pushed manifest's digest). + manifestDigest := digestOf([]byte("the-pushed-manifest-bytes")) + statement, err := sign.InTotoStatement(manifestDigest) + require.NoError(t, err) + + entity, err := vs.Attest(ghaSANRef, ghaIssuer, statement) + require.NoError(t, err) + + res, err := v.verifyEntity(entity, manifestDigest, mustCertID(t, explicitPolicy(ghaSANRef))) + require.NoError(t, err, "grcli's in-toto DSSE payload must verify against WithArtifactDigest") + require.Equal(t, "keyless:"+ghaIssuer+"#"+workflowPath, res.Identity) +} + +// A statement whose subject is a DIFFERENT digest must be rejected: the +// signature is cryptographically valid but attests a different artifact. This +// pins that WithArtifactDigest actually binds the subject, not just the cert. +func TestVerifyEntity_RejectsInTotoWrongSubject(t *testing.T) { + vs, err := ca.NewVirtualSigstore() + require.NoError(t, err) + v := newTestVerifier(t, vs) + + statement, err := sign.InTotoStatement(digestOf([]byte("artifact-A"))) + require.NoError(t, err) + entity, err := vs.Attest(ghaSANRef, ghaIssuer, statement) + require.NoError(t, err) + + _, err = v.verifyEntity(entity, digestOf([]byte("artifact-B-different")), mustCertID(t, explicitPolicy(ghaSANRef))) + require.Error(t, err, "a signature attesting a different subject digest must not verify") +} diff --git a/internal/sigverify/verify.go b/internal/sigverify/verify.go new file mode 100644 index 0000000..6dc4c3b --- /dev/null +++ b/internal/sigverify/verify.go @@ -0,0 +1,264 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Package sigverify is grcli's in-process Sigstore keyless-verification +// substrate (ADR-0046). It is a MIRROR of the hub's internal/sigverify +// (ADR-0034 decision 7 / ADR-0045) — not an import: the backend's internals +// aren't importable, and the zero-dependency grc-store-protocol rightly +// excludes sigstore-go. Keeping the two in lockstep means "verifies on the hub +// but not in grcli" (or vice versa) can only come from policy intent, never +// implementation drift. +// +// The verifier is PURE crypto: it does no registry I/O. Callers hand it the raw +// Sigstore bundle bytes (discovered as an OCI referrer of the artifact by the +// fetch layer, internal/registry) and the artifact's digest; it verifies the +// signature against a pinned trust root AND against an expected signer identity. +// +// The ONE deliberate divergence from the hub: the hub uses +// WithoutIdentitiesUnsafe because it TOFU-pins (first publish accepts any valid +// keyless cert, then the handler pins the extracted identity per coordinate). +// grcli is the consumer — it already KNOWS the identity to expect (from the +// --certificate-identity flag or the hub's recorded record, ADR-0045) — so it +// pins the SAN + issuer IN the sigstore-go policy. The cryptographic floor is +// identical; grcli additionally enforces WHO signed. +package sigverify + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "os" + "strings" + "time" + + "github.com/gemaraproj/grc-store-clientkit/trustroot" + "github.com/revanite-io/grc-store-protocol/identity" + "github.com/sigstore/sigstore-go/pkg/bundle" + "github.com/sigstore/sigstore-go/pkg/root" + "github.com/sigstore/sigstore-go/pkg/verify" +) + +// Result is what a successful Verify yields: the canonical, scheme-prefixed +// signer identity recovered from the verified certificate. It matches the hub's +// Result.Identity byte-for-byte (grc-store-protocol/identity, ADR-0035) so a +// post-verify confirmation line names the same identity the hub recorded. +type Result struct { + // Identity is the canonical keyless identity + // ("keyless:#", @refs stripped) recovered from the + // verified Fulcio certificate. + Identity string +} + +// ErrUnsigned is returned when the artifact has no signature bundle (the fetch +// layer found no referrer). It is distinct from a present-but-invalid signature +// so cmd/verify.go can print an "artifact is not signed" message rather than a +// crypto failure. +var ErrUnsigned = errors.New("artifact is not signed") + +// embeddedTrustedRoot is the pinned public-good Sigstore trust root — the SAME +// material the hub pins (ADR-0034 decision 7), which is exactly why it is no +// longer vendored here: grcli, privateer-sdk and the hub each carried a +// byte-identical copy, so rotation was three edits and three chances to miss +// one. It now comes from grc-store-clientkit, and refreshing it is one release +// of that module. Pinning (rather than fetching live via TUF) still keeps +// verify offline and deterministic, adding no network failure mode. An override +// for air-gapped / private-Sigstore deployments is NewVerifierFromFile. +var embeddedTrustedRoot = trustroot.Bytes() + +// defaultVerifyTimeout bounds the (offline, CPU-only) verification. Defense in +// depth: the work is local crypto, but a malformed bundle should never hang. +const defaultVerifyTimeout = 15 * time.Second + +// Verifier performs in-process keyless verification of an artifact signature +// using sigstore-go against a pinned trust root, enforcing an expected identity. +type Verifier struct { + verifier *verify.Verifier + timeout time.Duration + // requireSCT records whether this verifier enforces SCTs. Production + // (NewVerifier / NewVerifierFromFile) is always true; only the unexported + // test constructor sets it false, because VirtualSigstore certs carry no + // embedded SCT (see verify_test.go). Kept as a field purely so tests can + // assert the prod path never relaxes it. + requireSCT bool +} + +// IdentityPolicy pins the expected keyless signer. It mirrors, one-for-one, the +// certificate-identity material cmd/verify.go previously handed to cosign: +// +// - Explicit --certificate-identity mode: SAN set (exact), SANRegexp empty. +// - Hub-lookup mode (ADR-0045): SANRegexp set (the anchored "^QuoteMeta(path)@" +// pattern), SAN empty. The ref-stripped pin admits any git ref but nothing +// wider than the exact workflow path. +// +// Issuer is ALWAYS the exact expected OIDC issuer. Exactly one of SAN / SANRegexp +// must be non-empty; supplying neither is a programming error (rejected at build). +type IdentityPolicy struct { + SAN string // exact SAN (explicit --certificate-identity mode) + SANRegexp string // anchored SAN regexp (hub-lookup mode) + Issuer string // exact OIDC issuer +} + +// certificateIdentity turns the pinned IdentityPolicy into a sigstore-go +// certificate-identity matcher. The mapping is the security-critical seam that +// replaces cosign's flags: +// +// explicit: --certificate-identity --certificate-oidc-issuer +// → SAN exact-match, issuer exact-match +// hub-lookup: --certificate-identity-regexp --certificate-oidc-issuer +// → SAN regexp-match, issuer exact-match +// +// NewShortCertificateIdentity(issuer, issuerRegex, sanValue, sanRegex): we pass +// issuerRegex="" (exact issuer) always, and set exactly one of sanValue / +// sanRegex. sigstore-go's SubjectAlternativeNameMatcher does a byte-exact string +// compare for sanValue and an unanchored regexp match for sanRegex — the same +// semantics cosign's two flags have, so the anchoring/escaping the caller built +// (regexp.QuoteMeta + '^...@') carries through unchanged. +func (ip IdentityPolicy) certificateIdentity() (verify.CertificateIdentity, error) { + if ip.Issuer == "" { + return verify.CertificateIdentity{}, errors.New("identity policy has no issuer") + } + if (ip.SAN == "") == (ip.SANRegexp == "") { + return verify.CertificateIdentity{}, errors.New("identity policy must set exactly one of SAN or SANRegexp") + } + return verify.NewShortCertificateIdentity(ip.Issuer, "", ip.SAN, ip.SANRegexp) +} + +// NewVerifier builds a verifier over the embedded pinned trust root. It requires, +// for keyless GitHub Actions signatures, an SCT (Fulcio), a transparency-log +// entry (Rekor), and at least one observed timestamp — the production posture, +// identical to the hub's NewSigstoreVerifier. +func NewVerifier(timeout time.Duration) (*Verifier, error) { + return newVerifierFromRoot(embeddedTrustedRoot, "embedded", timeout) +} + +// NewVerifierFromFile builds a verifier over a trusted_root.json read from disk +// instead of the embedded public-good root (GRCLI_TRUSTED_ROOT, ADR-0046 +// decision 4). It serves air-gapped deployments and private Sigstore instances — +// the same posture as the hub's NewSigstoreVerifierFromFile. The SCT/Rekor/ +// timestamp policy is UNCHANGED (a private Sigstore still runs a CT log); only +// the set of trusted CAs/logs differs. An empty path is a programming error +// (callers gate on the config value being set), so it errors rather than +// silently falling back to the embedded root and masking a misconfiguration. +func NewVerifierFromFile(path string, timeout time.Duration) (*Verifier, error) { + if path == "" { + return nil, errors.New("trusted root file path is empty") + } + rootJSON, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read trusted root file %q: %w", path, err) + } + return newVerifierFromRoot(rootJSON, path, timeout) +} + +// newVerifierFromRoot parses a trusted_root.json (from any source) and builds +// the production-posture verifier over it. src is a label for error context +// ("embedded" or a file path). sctThreshold is always 1: an SCT proves the +// Fulcio cert was logged to a CT log, and the public-good root carries the CT +// keys to check it. +func newVerifierFromRoot(rootJSON []byte, src string, timeout time.Duration) (*Verifier, error) { + tm, err := root.NewTrustedRootFromJSON(rootJSON) + if err != nil { + return nil, fmt.Errorf("parse %s trusted root: %w", src, err) + } + return newVerifier(tm, timeout, 1) +} + +// newVerifier is the test-friendly constructor: it takes any root.TrustedMaterial +// (so unit tests can pass a VirtualSigstore) and an SCT threshold. Tests pass 0 +// because VirtualSigstore certs carry no embedded SCT; production always passes 1. +// It is UNEXPORTED so the production API (NewVerifier / NewVerifierFromFile) can +// only ever construct an SCT-requiring verifier. +func newVerifier(tm root.TrustedMaterial, timeout time.Duration, sctThreshold int) (*Verifier, error) { + // Transparency-log inclusion (Rekor) + at least one observed timestamp is + // sigstore-go's canonical keyless posture; SCT is additional cert-issuance + // transparency, required in production. + opts := []verify.VerifierOption{ + verify.WithTransparencyLog(1), + verify.WithObserverTimestamps(1), + } + if sctThreshold > 0 { + opts = append(opts, verify.WithSignedCertificateTimestamps(sctThreshold)) + } + v, err := verify.NewVerifier(tm, opts...) + if err != nil { + return nil, fmt.Errorf("build sigstore verifier: %w", err) + } + if timeout <= 0 { + timeout = defaultVerifyTimeout + } + return &Verifier{verifier: v, timeout: timeout, requireSCT: sctThreshold > 0}, nil +} + +// Verify parses the attached signature bundle and verifies it against the +// artifact digest AND the expected identity. Empty bundle bytes are ErrUnsigned. +// Verification is bounded by the configured timeout (defense in depth; the work +// is offline crypto). It fails CLOSED: any non-nil error means "do not trust". +func (v *Verifier) Verify(ctx context.Context, signatureBundle []byte, artifactDigest string, id IdentityPolicy) (Result, error) { + if len(signatureBundle) == 0 { + return Result{}, ErrUnsigned + } + certID, err := id.certificateIdentity() + if err != nil { + return Result{}, fmt.Errorf("build identity policy: %w", err) + } + var b bundle.Bundle + if err := b.UnmarshalJSON(signatureBundle); err != nil { + return Result{}, fmt.Errorf("parse signature bundle: %w", err) + } + + type outcome struct { + res Result + err error + } + ch := make(chan outcome, 1) + go func() { + res, err := v.verifyEntity(&b, artifactDigest, certID) + ch <- outcome{res: res, err: err} + }() + select { + case <-ctx.Done(): + return Result{}, ctx.Err() + case <-time.After(v.timeout): + return Result{}, fmt.Errorf("signature verification timed out after %s", v.timeout) + case r := <-ch: + return r.res, r.err + } +} + +// verifyEntity runs the sigstore policy check over a SignedEntity bound to the +// artifact digest AND the pinned certificate identity, then recovers the +// canonical identity from the verified cert. Split out (taking a +// verify.SignedEntity, not bundle bytes) so unit tests can drive it with a +// VirtualSigstore TestEntity and no bundle serialization. +// +// This is where grcli differs from the hub: WithCertificateIdentity(certID) +// replaces the hub's WithoutIdentitiesUnsafe() — the SAN + issuer are pinned at +// verify time, so a valid Sigstore signature by the WRONG identity is rejected. +func (v *Verifier) verifyEntity(entity verify.SignedEntity, artifactDigest string, certID verify.CertificateIdentity) (Result, error) { + digestBytes, err := hex.DecodeString(strings.TrimPrefix(artifactDigest, "sha256:")) + if err != nil || len(digestBytes) != sha256.Size { + return Result{}, fmt.Errorf("invalid artifact digest %q", artifactDigest) + } + policy := verify.NewPolicy( + verify.WithArtifactDigest("sha256", digestBytes), + verify.WithCertificateIdentity(certID), + ) + res, err := v.verifier.Verify(entity, policy) + if err != nil { + return Result{}, fmt.Errorf("signature verification failed: %w", err) + } + if res.Signature == nil || res.Signature.Certificate == nil { + return Result{}, errors.New("verified signature carries no certificate identity (key-based signing is not accepted on the keyless path)") + } + cert := res.Signature.Certificate + if cert.Issuer == "" || cert.SubjectAlternativeName == "" { + return Result{}, errors.New("verified certificate is missing OIDC issuer or SAN") + } + // The canonical signer identity comes from the shared wire-contract module + // (ADR-0035) — the SAME definition the hub uses — so grcli's confirmation + // names the identity in exactly the form the hub recorded. + return Result{ + Identity: identity.CanonicalKeylessIdentity(cert.Issuer, cert.SubjectAlternativeName), + }, nil +} diff --git a/internal/sigverify/verify_test.go b/internal/sigverify/verify_test.go new file mode 100644 index 0000000..35b7aba --- /dev/null +++ b/internal/sigverify/verify_test.go @@ -0,0 +1,299 @@ +// SPDX-License-Identifier: Apache-2.0 + +package sigverify + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "os" + "path/filepath" + "testing" + "time" + + "github.com/sigstore/sigstore-go/pkg/testing/ca" + "github.com/sigstore/sigstore-go/pkg/verify" + "github.com/stretchr/testify/require" +) + +// digestOf returns the "sha256:" coordinate for artifact bytes — the same +// value the fetch layer produces and that the policy binds to. +func digestOf(b []byte) string { + sum := sha256.Sum256(b) + return "sha256:" + hex.EncodeToString(sum[:]) +} + +// newTestVerifier builds a Verifier whose trust root IS the virtual sigstore, so +// verification is fully offline and deterministic. sctThreshold=0: VirtualSigstore +// certs carry no embedded SCT. Production (NewVerifier / NewVerifierFromFile) +// requires one — see TestProductionVerifierRequiresSCT. +func newTestVerifier(t *testing.T, vs *ca.VirtualSigstore) *Verifier { + t.Helper() + v, err := newVerifier(vs, 5*time.Second, 0) + require.NoError(t, err) + require.False(t, v.requireSCT, "test verifier must NOT require SCTs (VirtualSigstore carries none)") + return v +} + +const ( + ghaIssuer = "https://token.actions.githubusercontent.com" + // The pinned workflow path (ref-stripped) and a concrete signing ref of it. + workflowPath = "https://github.com/finos/ccc-evaluator/.github/workflows/release.yml" + ghaSANRef = workflowPath + "@refs/tags/v1.2.0" +) + +// mustCertID builds the sigstore-go matcher from an IdentityPolicy, failing the +// test on a malformed policy. It exercises the exact production path +// (IdentityPolicy.certificateIdentity) rather than a hand-rolled matcher. +func mustCertID(t *testing.T, ip IdentityPolicy) verify.CertificateIdentity { + t.Helper() + certID, err := ip.certificateIdentity() + require.NoError(t, err) + return certID +} + +// explicitPolicy pins the exact SAN (with ref) + exact issuer — the +// --certificate-identity mode. +func explicitPolicy(san string) IdentityPolicy { + return IdentityPolicy{SAN: san, Issuer: ghaIssuer} +} + +// hubLookupPolicy pins the anchored SAN regexp + exact issuer — the zero-flag +// hub-lookup mode. The pattern is byte-identical to what cmd/verify.go's +// resolveHubIdentity builds ("^" + QuoteMeta(path) + "@"). +func hubLookupPolicy(anchoredRegexp string) IdentityPolicy { + return IdentityPolicy{SANRegexp: anchoredRegexp, Issuer: ghaIssuer} +} + +func TestVerifyEntity_AcceptsMatchingExactIdentity(t *testing.T) { + vs, err := ca.NewVirtualSigstore() + require.NoError(t, err) + artifact := []byte("the-artifact-manifest-bytes") + entity, err := vs.Sign(ghaSANRef, ghaIssuer, artifact) + require.NoError(t, err) + + v := newTestVerifier(t, vs) + res, err := v.verifyEntity(entity, digestOf(artifact), mustCertID(t, explicitPolicy(ghaSANRef))) + require.NoError(t, err) + // Result identity is canonical (ref-stripped), matching the hub's record. + require.Equal(t, "keyless:"+ghaIssuer+"#"+workflowPath, res.Identity) +} + +func TestVerifyEntity_RejectsWrongDigest(t *testing.T) { + vs, err := ca.NewVirtualSigstore() + require.NoError(t, err) + entity, err := vs.Sign(ghaSANRef, ghaIssuer, []byte("artifact-A")) + require.NoError(t, err) + + v := newTestVerifier(t, vs) + _, err = v.verifyEntity(entity, digestOf([]byte("artifact-B-tampered")), mustCertID(t, explicitPolicy(ghaSANRef))) + require.Error(t, err) +} + +func TestVerifyEntity_RejectsWrongIdentity(t *testing.T) { + vs, err := ca.NewVirtualSigstore() + require.NoError(t, err) + artifact := []byte("artifact") + entity, err := vs.Sign(ghaSANRef, ghaIssuer, artifact) + require.NoError(t, err) + + v := newTestVerifier(t, vs) + // A valid signature by a DIFFERENT workflow SAN must be rejected — this is + // the whole point of pinning identity (grcli, unlike the TOFU hub, knows + // who it expects). + otherSAN := "https://github.com/evil/repo/.github/workflows/release.yml@refs/tags/v1.2.0" + _, err = v.verifyEntity(entity, digestOf(artifact), mustCertID(t, explicitPolicy(otherSAN))) + require.Error(t, err) +} + +func TestVerifyEntity_RejectsWrongIssuer(t *testing.T) { + vs, err := ca.NewVirtualSigstore() + require.NoError(t, err) + artifact := []byte("artifact") + entity, err := vs.Sign(ghaSANRef, ghaIssuer, artifact) + require.NoError(t, err) + + v := newTestVerifier(t, vs) + wrongIssuer := IdentityPolicy{SAN: ghaSANRef, Issuer: "https://gitlab.example.com"} + _, err = v.verifyEntity(entity, digestOf(artifact), mustCertID(t, wrongIssuer)) + require.Error(t, err) +} + +func TestVerifyEntity_RejectsForeignTrustRoot(t *testing.T) { + signer, err := ca.NewVirtualSigstore() + require.NoError(t, err) + artifact := []byte("artifact") + entity, err := signer.Sign(ghaSANRef, ghaIssuer, artifact) + require.NoError(t, err) + + otherRoot, err := ca.NewVirtualSigstore() + require.NoError(t, err) + v := newTestVerifier(t, otherRoot) + + _, err = v.verifyEntity(entity, digestOf(artifact), mustCertID(t, explicitPolicy(ghaSANRef))) + require.Error(t, err) +} + +func TestVerifyEntity_RejectsBadDigestFormat(t *testing.T) { + vs, err := ca.NewVirtualSigstore() + require.NoError(t, err) + entity, err := vs.Sign(ghaSANRef, ghaIssuer, []byte("x")) + require.NoError(t, err) + v := newTestVerifier(t, vs) + _, err = v.verifyEntity(entity, "not-a-sha256-digest", mustCertID(t, explicitPolicy(ghaSANRef))) + require.Error(t, err) +} + +// TestVerifyEntity_HubLookupRegexp_Adversarial reuses the ADR-0045 adversarial +// SAN cases, now asserted against the IN-PROCESS matcher (a real +// VirtualSigstore-signed cert carrying each SAN) rather than a cosign arg +// string. The anchored "^QuoteMeta(path)@" pin must admit ANY ref of the exact +// workflow while refusing a prefixed, sibling, or look-alike identity. +func TestVerifyEntity_HubLookupRegexp_Adversarial(t *testing.T) { + // Build the pin exactly as cmd/verify.go's resolveHubIdentity does. A '.' + // in the path is a regexp metacharacter, so escaping is load-bearing. + const dottedPath = "https://github.com/acme/repo.name/.github/workflows/publish.yml" + // regexp.QuoteMeta is what production uses; replicate its output here. + anchored := `^https://github\.com/acme/repo\.name/\.github/workflows/publish\.yml@` + pol := hubLookupPolicy(anchored) + + cases := []struct { + name string + san string + accept bool + }{ + {"tag ref of the pinned workflow", dottedPath + "@refs/tags/v1.0.0", true}, + {"branch ref of the pinned workflow", dottedPath + "@refs/heads/main", true}, + {"prefixed identity rejected by the ^ anchor", "https://evil.example/" + dottedPath + "@refs/tags/v1", false}, + {"longer sibling path rejected by the @ boundary", dottedPath + "-sibling/.github/workflows/publish.yml@refs/tags/v1", false}, + {"escaped '.' is a literal, not a wildcard", "https://github.com/acme/repoXname/.github/workflows/publish.yml@refs/tags/v1", false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + vs, err := ca.NewVirtualSigstore() + require.NoError(t, err) + artifact := []byte("artifact-" + tc.name) + entity, err := vs.Sign(tc.san, ghaIssuer, artifact) + require.NoError(t, err) + + v := newTestVerifier(t, vs) + _, err = v.verifyEntity(entity, digestOf(artifact), mustCertID(t, pol)) + if tc.accept { + require.NoError(t, err, "SAN %q must verify against the pinned workflow", tc.san) + } else { + require.Error(t, err, "SAN %q must be rejected by the anchored pin", tc.san) + } + }) + } +} + +// TestVerify_Unsigned confirms empty bundle bytes → ErrUnsigned (the fetch layer +// found no referrer), distinct from a present-but-invalid signature. +func TestVerify_Unsigned(t *testing.T) { + vs, err := ca.NewVirtualSigstore() + require.NoError(t, err) + v := newTestVerifier(t, vs) + _, err = v.Verify(context.Background(), nil, digestOf([]byte("x")), explicitPolicy(ghaSANRef)) + require.ErrorIs(t, err, ErrUnsigned) +} + +// TestVerify_MalformedBundle confirms garbage bundle bytes are a hard error, NOT +// ErrUnsigned — a present-but-unparseable signature must fail closed, never be +// treated as "no signature". +func TestVerify_MalformedBundle(t *testing.T) { + vs, err := ca.NewVirtualSigstore() + require.NoError(t, err) + v := newTestVerifier(t, vs) + _, err = v.Verify(context.Background(), []byte("{not a valid sigstore bundle}"), digestOf([]byte("x")), explicitPolicy(ghaSANRef)) + require.Error(t, err) + require.NotErrorIs(t, err, ErrUnsigned) +} + +// TestVerify_MalformedIdentityPolicy confirms an unusable identity pin fails the +// public Verify path loudly (a programming error), NOT as ErrUnsigned. The +// identity policy is validated before the bundle is parsed, so non-empty bytes +// with a bad policy exercise the guard without needing a real bundle. (The crypto +// + identity match is covered end-to-end against real signed entities by the +// verifyEntity tests above; the ca.TestEntity has no bundle-JSON serializer, so +// the wrapper is exercised via nil/garbage bytes here, as the hub's tests do.) +func TestVerify_MalformedIdentityPolicy(t *testing.T) { + vs, err := ca.NewVirtualSigstore() + require.NoError(t, err) + v := newTestVerifier(t, vs) + // Neither SAN nor SANRegexp set → invalid policy; the non-empty bytes get + // past the ErrUnsigned check so we reach the policy guard. + _, err = v.Verify(context.Background(), []byte("{}"), digestOf([]byte("x")), IdentityPolicy{Issuer: ghaIssuer}) + require.Error(t, err) + require.NotErrorIs(t, err, ErrUnsigned) +} + +// TestIdentityPolicy_Validation pins the exactly-one-of-SAN/SANRegexp invariant +// and the required issuer — the guardrails that stop a policy from silently +// matching nothing (or everything). +func TestIdentityPolicy_Validation(t *testing.T) { + cases := []struct { + name string + ip IdentityPolicy + ok bool + }{ + {"exact SAN + issuer", IdentityPolicy{SAN: ghaSANRef, Issuer: ghaIssuer}, true}, + {"SAN regexp + issuer", IdentityPolicy{SANRegexp: "^" + workflowPath + "@", Issuer: ghaIssuer}, true}, + {"no issuer", IdentityPolicy{SAN: ghaSANRef}, false}, + {"neither SAN nor regexp", IdentityPolicy{Issuer: ghaIssuer}, false}, + {"both SAN and regexp", IdentityPolicy{SAN: ghaSANRef, SANRegexp: "^x@", Issuer: ghaIssuer}, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := tc.ip.certificateIdentity() + if tc.ok { + require.NoError(t, err) + } else { + require.Error(t, err) + } + }) + } +} + +// TestProductionVerifierRequiresSCT guards the load-bearing invariant that the +// PRODUCTION constructors always demand an SCT — the test knob (sctThreshold=0) +// must never leak into the exported API. We can't feed a VirtualSigstore bundle +// through the SCT-requiring verifier (its certs carry no SCT), so we assert the +// posture flag the constructors set instead. +func TestProductionVerifierRequiresSCT(t *testing.T) { + v, err := NewVerifier(5 * time.Second) + require.NoError(t, err) + require.True(t, v.requireSCT, "NewVerifier must require SCTs in production") +} + +// TestEmbeddedTrustRootParses guards against a corrupt/empty trusted_root.json +// embed: the pinned production root must parse into a usable verifier. +func TestEmbeddedTrustRootParses(t *testing.T) { + v, err := NewVerifier(5 * time.Second) + require.NoError(t, err) + require.NotNil(t, v) +} + +// TestNewVerifierFromFile_ReadsRoot round-trips the embedded root through a temp +// file (GRCLI_TRUSTED_ROOT), proving the file-read + parse path is equivalent to +// the embedded path without needing a live private sigstore. +func TestNewVerifierFromFile_ReadsRoot(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "trusted_root.json") + require.NoError(t, os.WriteFile(path, embeddedTrustedRoot, 0o600)) + + v, err := NewVerifierFromFile(path, 5*time.Second) + require.NoError(t, err) + require.NotNil(t, v) + require.True(t, v.requireSCT, "the file-override path must keep the production SCT posture") +} + +// TestNewVerifierFromFile_FailsClosed confirms an empty path (programming error) +// and a missing file both fail — never silently fall back to the embedded root, +// which would mask a misconfigured override. +func TestNewVerifierFromFile_FailsClosed(t *testing.T) { + _, err := NewVerifierFromFile("", 5*time.Second) + require.Error(t, err) + + _, err = NewVerifierFromFile(filepath.Join(t.TempDir(), "does-not-exist.json"), 5*time.Second) + require.Error(t, err) +} diff --git a/internal/source/source.go b/internal/source/source.go new file mode 100644 index 0000000..afe9e1f --- /dev/null +++ b/internal/source/source.go @@ -0,0 +1,171 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Package source loads grcli input files, verifies they describe a single +// artifact, and emits the merged YAML body that goes into the bundle. +package source + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + + gemara "github.com/gemaraproj/go-gemara" + "github.com/gemaraproj/go-gemara/fetcher" + "sigs.k8s.io/yaml" + + "github.com/revanite-io/grcli/internal/digest" +) + +// Loaded is the result of merging the provided input files into a single +// in-memory artifact ready to be placed in a Gemara bundle. +type Loaded struct { + // Type is the artifact's metadata.type (e.g. "ControlCatalog"). + Type string + // ID is the artifact's metadata.id. + ID string + // Version is the artifact's metadata.version (used as the OCI tag). + Version string + // AuthorID is metadata.author.id; the hub maps this to namespace. + AuthorID string + // GemaraVersion is metadata.gemara-version, the spec the artifact targets. + GemaraVersion string + // Filename is the bundle-relative name of the merged artifact layer. + Filename string + // Body is the YAML bytes that get packed into the bundle as one layer. + Body []byte + // SourceDigests maps each input file path to its sha256:. + // Used by provenance to record what went in. + SourceDigests map[string]string +} + +// peekedMetadata is the minimal projection of metadata.* fields the loader +// needs before deciding whether to merge or pass through. sigs.k8s.io/yaml +// decodes via JSON, so only json tags are needed. +type peekedMetadata struct { + Metadata struct { + ID string `json:"id"` + Type string `json:"type"` + Version string `json:"version"` + GemaraVersion string `json:"gemara-version"` + Author struct { + ID string `json:"id"` + } `json:"author"` + } `json:"metadata"` +} + +// Load reads sources, ensures they describe one artifact (matching type +// + id), and returns the bytes that will be packed into the bundle. +// +// For ControlCatalog and GuidanceCatalog, multiple sources are merged +// via go-gemara's LoadFiles. For any other type, exactly one source is +// allowed — the file passes through unchanged. +func Load(ctx context.Context, sources []string) (*Loaded, error) { + if len(sources) == 0 { + return nil, errors.New("no source files provided") + } + + digests, err := digestAll(sources) + if err != nil { + return nil, err + } + + var first peekedMetadata + if err := readYAML(sources[0], &first); err != nil { + return nil, fmt.Errorf("reading %s: %w", sources[0], err) + } + if first.Metadata.Type == "" || first.Metadata.ID == "" { + return nil, fmt.Errorf("%s: metadata.type and metadata.id are required", sources[0]) + } + + for _, path := range sources[1:] { + var next peekedMetadata + if err := readYAML(path, &next); err != nil { + return nil, fmt.Errorf("reading %s: %w", path, err) + } + if next.Metadata.Type != first.Metadata.Type { + return nil, fmt.Errorf("%s declares type %q but %s declares %q — all inputs must describe one artifact", + path, next.Metadata.Type, sources[0], first.Metadata.Type) + } + if next.Metadata.ID != first.Metadata.ID { + return nil, fmt.Errorf("%s declares id %q but %s declares %q — all inputs must describe one artifact", + path, next.Metadata.ID, sources[0], first.Metadata.ID) + } + } + + body, name, err := mergeOrPassThrough(ctx, first.Metadata.Type, sources) + if err != nil { + return nil, err + } + + return &Loaded{ + Type: first.Metadata.Type, + ID: first.Metadata.ID, + Version: first.Metadata.Version, + AuthorID: first.Metadata.Author.ID, + GemaraVersion: first.Metadata.GemaraVersion, + Filename: name, + Body: body, + SourceDigests: digests, + }, nil +} + +// mergeOrPassThrough returns the bundle-bound YAML body and a filename. +// Multi-file inputs are merged for the two catalog types go-gemara +// supports; everything else must be a single file. +func mergeOrPassThrough(ctx context.Context, artifactType string, sources []string) ([]byte, string, error) { + if len(sources) == 1 { + body, err := os.ReadFile(sources[0]) + if err != nil { + return nil, "", err + } + return body, filepath.Base(sources[0]), nil + } + + fileFetcher := &fetcher.File{} + switch artifactType { + case "ControlCatalog": + catalog := &gemara.ControlCatalog{} + if err := catalog.LoadFiles(ctx, fileFetcher, sources); err != nil { + return nil, "", fmt.Errorf("merging control catalogs: %w", err) + } + body, err := yaml.Marshal(catalog) + if err != nil { + return nil, "", fmt.Errorf("marshaling merged control catalog: %w", err) + } + return body, "control-catalog.yaml", nil + case "GuidanceCatalog": + catalog := &gemara.GuidanceCatalog{} + if err := catalog.LoadFiles(ctx, fileFetcher, sources); err != nil { + return nil, "", fmt.Errorf("merging guidance catalogs: %w", err) + } + body, err := yaml.Marshal(catalog) + if err != nil { + return nil, "", fmt.Errorf("marshaling merged guidance catalog: %w", err) + } + return body, "guidance-catalog.yaml", nil + default: + return nil, "", fmt.Errorf("artifact type %q does not support multi-file merge — pass exactly one --file", artifactType) + } +} + +func readYAML(path string, dst any) error { + body, err := os.ReadFile(path) + if err != nil { + return err + } + return yaml.Unmarshal(body, dst) +} + +func digestAll(paths []string) (map[string]string, error) { + digests := make(map[string]string, len(paths)) + for _, path := range paths { + hashed, err := digest.File(path) + if err != nil { + return nil, fmt.Errorf("digesting %s: %w", path, err) + } + digests[path] = hashed + } + return digests, nil +} diff --git a/internal/source/source_test.go b/internal/source/source_test.go new file mode 100644 index 0000000..db29efb --- /dev/null +++ b/internal/source/source_test.go @@ -0,0 +1,131 @@ +// SPDX-License-Identifier: Apache-2.0 + +package source + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func writeFile(t *testing.T, dir, name, body string) string { + t.Helper() + p := filepath.Join(dir, name) + require.NoError(t, os.WriteFile(p, []byte(body), 0o600)) + return p +} + +const policyYAML = `metadata: + id: my-policy + type: Policy + version: 1.0.0 + gemara-version: 0.20.0 + author: + id: my-team + type: Human +` + +const controlCatalogPartA = `metadata: + id: my-controls + type: ControlCatalog + version: 1.0.0 + gemara-version: 0.20.0 + author: + id: my-team + type: Human +controls: + - id: AC-1 + title: Access Control 1 +` + +const controlCatalogPartB = `metadata: + id: my-controls + type: ControlCatalog + version: 1.0.0 + gemara-version: 0.20.0 + author: + id: my-team + type: Human +controls: + - id: AC-2 + title: Access Control 2 +` + +func TestLoad_SingleFile_PassesThrough(t *testing.T) { + d := t.TempDir() + p := writeFile(t, d, "policy.yaml", policyYAML) + + out, err := Load(context.Background(), []string{p}) + require.NoError(t, err) + require.Equal(t, "Policy", out.Type) + require.Equal(t, "my-policy", out.ID) + require.Equal(t, "1.0.0", out.Version) + require.Equal(t, "my-team", out.AuthorID) + require.Equal(t, "policy.yaml", out.Filename) + require.Equal(t, policyYAML, string(out.Body)) + require.Contains(t, out.SourceDigests, p) + require.True(t, strings.HasPrefix(out.SourceDigests[p], "sha256:")) +} + +func TestLoad_MultipleControlCatalogs_AreMerged(t *testing.T) { + d := t.TempDir() + a := writeFile(t, d, "a.yaml", controlCatalogPartA) + b := writeFile(t, d, "b.yaml", controlCatalogPartB) + + out, err := Load(context.Background(), []string{a, b}) + require.NoError(t, err) + require.Equal(t, "ControlCatalog", out.Type) + require.Equal(t, "my-controls", out.ID) + require.Equal(t, "control-catalog.yaml", out.Filename) + // Merged body must contain both controls from the two inputs. + require.Contains(t, string(out.Body), "AC-1") + require.Contains(t, string(out.Body), "AC-2") +} + +func TestLoad_MismatchedID_Errors(t *testing.T) { + d := t.TempDir() + a := writeFile(t, d, "a.yaml", controlCatalogPartA) + bad := strings.Replace(controlCatalogPartB, "my-controls", "other-id", 1) + b := writeFile(t, d, "b.yaml", bad) + + _, err := Load(context.Background(), []string{a, b}) + require.Error(t, err) + require.Contains(t, err.Error(), "must describe one artifact") +} + +func TestLoad_MismatchedType_Errors(t *testing.T) { + d := t.TempDir() + a := writeFile(t, d, "a.yaml", controlCatalogPartA) + bad := strings.Replace(controlCatalogPartB, "ControlCatalog", "GuidanceCatalog", 1) + b := writeFile(t, d, "b.yaml", bad) + + _, err := Load(context.Background(), []string{a, b}) + require.Error(t, err) + require.Contains(t, err.Error(), "must describe one artifact") +} + +func TestLoad_MissingMetadata_Errors(t *testing.T) { + d := t.TempDir() + p := writeFile(t, d, "x.yaml", "metadata: {}\n") + _, err := Load(context.Background(), []string{p}) + require.Error(t, err) + require.Contains(t, err.Error(), "metadata.type and metadata.id are required") +} + +func TestLoad_MultipleNonCatalog_Errors(t *testing.T) { + d := t.TempDir() + a := writeFile(t, d, "a.yaml", policyYAML) + b := writeFile(t, d, "b.yaml", policyYAML) + _, err := Load(context.Background(), []string{a, b}) + require.Error(t, err) + require.Contains(t, err.Error(), "does not support multi-file merge") +} + +func TestLoad_NoFiles_Errors(t *testing.T) { + _, err := Load(context.Background(), nil) + require.Error(t, err) +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..df2406b --- /dev/null +++ b/main.go @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "fmt" + "os" + + "github.com/revanite-io/grcli/cmd" +) + +func main() { + if err := cmd.Execute(); err != nil { + fmt.Fprintln(os.Stderr, "grcli:", err) + os.Exit(1) + } +} From b193170925a6b2763ae6ef655a343b0960a0a56f Mon Sep 17 00:00:00 2001 From: Eddie Knight Date: Thu, 3 Sep 2026 12:44:02 -0500 Subject: [PATCH 2/9] Address Kusari review: workflow env indirection, dependency bumps Workflows: route inputs.grcli-version and github.actor through env vars instead of interpolating them into run: blocks (command injection). Dependencies: google.golang.org/grpc v1.83.2, oras.land/oras-go/v2 v2.6.2, github.com/sigstore/sigstore-go v1.3.0, golang.org/x/crypto v0.56.0, per the flagged advisories. Signed-off-by: Eddie Knight --- .github/workflows/publish-gemara.yml | 4 +- .github/workflows/release.yml | 3 +- go.mod | 80 +++++---- go.sum | 256 +++++++++++++-------------- 4 files changed, 170 insertions(+), 173 deletions(-) diff --git a/.github/workflows/publish-gemara.yml b/.github/workflows/publish-gemara.yml index 6a28b94..59df20f 100644 --- a/.github/workflows/publish-gemara.yml +++ b/.github/workflows/publish-gemara.yml @@ -69,8 +69,10 @@ jobs: # needs no token. v2: https://github.com/oras-project/setup-oras/releases/tag/v2.0.0 - uses: oras-project/setup-oras@38de303aac69abb66f3e6255b7198bff35f323e3 - name: Install grcli ${{ inputs.grcli-version }} + env: + GRCLI_VERSION: ${{ inputs.grcli-version }} run: | - oras pull ghcr.io/revanite-io/grcli:${{ inputs.grcli-version }} --platform linux/amd64 + oras pull "ghcr.io/revanite-io/grcli:$GRCLI_VERSION" --platform linux/amd64 sudo install grcli /usr/local/bin/grcli # Required for keyless signing — the same OIDC identity authorizes the push. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 124cf1b..08474e9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -49,7 +49,8 @@ jobs: - name: Log in to GHCR env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: echo "$GH_TOKEN" | oras login "$REGISTRY" -u "${{ github.actor }}" --password-stdin + ACTOR: ${{ github.actor }} + run: echo "$GH_TOKEN" | oras login "$REGISTRY" -u "$ACTOR" --password-stdin - name: Build binaries, push per-platform artifacts, assemble index id: build diff --git a/go.mod b/go.mod index ca70488..8ed473e 100644 --- a/go.mod +++ b/go.mod @@ -1,21 +1,23 @@ module github.com/revanite-io/grcli -go 1.25.0 +go 1.26.0 require ( github.com/gemaraproj/go-gemara v0.5.0 github.com/opencontainers/go-digest v1.0.0 github.com/opencontainers/image-spec v1.1.1 github.com/revanite-io/grc-store-protocol v0.5.0 - github.com/sigstore/sigstore-go v1.1.4 + github.com/sigstore/sigstore-go v1.3.0 github.com/spf13/cobra v1.10.2 github.com/spf13/viper v1.21.0 github.com/stretchr/testify v1.11.1 - golang.org/x/mod v0.36.0 - oras.land/oras-go/v2 v2.6.0 + golang.org/x/mod v0.38.0 + oras.land/oras-go/v2 v2.6.2 sigs.k8s.io/yaml v1.6.0 ) +require github.com/go-openapi/swag/pools v0.27.3 // indirect + require ( github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect github.com/blang/semver v3.5.1+incompatible // indirect @@ -29,34 +31,34 @@ require ( github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/gemaraproj/grc-store-clientkit v0.1.1 github.com/go-jose/go-jose/v4 v4.1.4 // indirect - github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/logr v1.4.4 // indirect github.com/go-logr/stdr v1.2.2 // indirect - github.com/go-openapi/analysis v0.25.2 // indirect - github.com/go-openapi/errors v0.22.7 // indirect - github.com/go-openapi/jsonpointer v0.23.1 // indirect - github.com/go-openapi/jsonreference v0.21.6 // indirect - github.com/go-openapi/loads v0.23.3 // indirect - github.com/go-openapi/runtime v0.32.3 // indirect + github.com/go-openapi/analysis v0.25.5 // indirect + github.com/go-openapi/errors v0.22.8 // indirect + github.com/go-openapi/jsonpointer v1.0.0 // indirect + github.com/go-openapi/jsonreference v1.0.0 // indirect + github.com/go-openapi/loads v0.25.0 // indirect + github.com/go-openapi/runtime v0.33.0 // indirect github.com/go-openapi/runtime/server-middleware v0.30.0 // indirect - github.com/go-openapi/spec v0.22.5 // indirect - github.com/go-openapi/strfmt v0.26.3 // indirect - github.com/go-openapi/swag v0.26.0 // indirect - github.com/go-openapi/swag/cmdutils v0.26.0 // indirect - github.com/go-openapi/swag/conv v0.26.0 // indirect - github.com/go-openapi/swag/fileutils v0.26.0 // indirect - github.com/go-openapi/swag/jsonname v0.26.0 // indirect - github.com/go-openapi/swag/jsonutils v0.26.0 // indirect - github.com/go-openapi/swag/loading v0.26.0 // indirect - github.com/go-openapi/swag/mangling v0.26.0 // indirect - github.com/go-openapi/swag/netutils v0.26.0 // indirect - github.com/go-openapi/swag/stringutils v0.26.0 // indirect - github.com/go-openapi/swag/typeutils v0.26.0 // indirect - github.com/go-openapi/swag/yamlutils v0.26.0 // indirect - github.com/go-openapi/validate v0.25.3 // indirect + github.com/go-openapi/spec v0.22.9 // indirect + github.com/go-openapi/strfmt v0.27.0 // indirect + github.com/go-openapi/swag v0.26.1 // indirect + github.com/go-openapi/swag/cmdutils v0.27.0 // indirect + github.com/go-openapi/swag/conv v0.27.3 // indirect + github.com/go-openapi/swag/fileutils v0.27.3 // indirect + github.com/go-openapi/swag/jsonname v0.26.1 // indirect + github.com/go-openapi/swag/jsonutils v0.27.3 // indirect + github.com/go-openapi/swag/loading v0.27.3 // indirect + github.com/go-openapi/swag/mangling v0.27.3 // indirect + github.com/go-openapi/swag/netutils v0.27.0 // indirect + github.com/go-openapi/swag/stringutils v0.27.3 // indirect + github.com/go-openapi/swag/typeutils v0.27.3 // indirect + github.com/go-openapi/swag/yamlutils v0.27.3 // indirect + github.com/go-openapi/validate v0.26.1 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/goccy/go-yaml v1.19.2 // indirect github.com/google/certificate-transparency-go v1.3.3 // indirect - github.com/google/go-containerregistry v0.21.6 // indirect + github.com/google/go-containerregistry v0.21.7 // indirect github.com/google/uuid v1.6.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect @@ -76,17 +78,17 @@ require ( github.com/secure-systems-lab/go-securesystemslib v0.11.0 // indirect github.com/shibumi/go-pathspec v1.3.0 // indirect github.com/sigstore/protobuf-specs v0.5.1 // indirect - github.com/sigstore/rekor v1.5.2 // indirect - github.com/sigstore/rekor-tiles/v2 v2.2.2-0.20260601073857-5d098a2b6443 // indirect + github.com/sigstore/rekor v1.5.3 // indirect + github.com/sigstore/rekor-tiles/v2 v2.3.0 // indirect github.com/sigstore/sigstore v1.10.8 // indirect - github.com/sigstore/timestamp-authority/v2 v2.1.2 // indirect + github.com/sigstore/timestamp-authority/v2 v2.1.3 // indirect github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect github.com/spf13/afero v1.15.0 // indirect github.com/spf13/cast v1.10.0 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/theupdateframework/go-tuf v0.7.0 // indirect - github.com/theupdateframework/go-tuf/v2 v2.4.2-0.20260407074541-7e8f69f906ef // indirect + github.com/theupdateframework/go-tuf/v2 v2.4.2 // indirect github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399 // indirect github.com/transparency-dev/formats v0.1.1 // indirect github.com/transparency-dev/merkle v0.0.2 // indirect @@ -97,16 +99,16 @@ require ( go.opentelemetry.io/otel/trace v1.44.0 // indirect go.yaml.in/yaml/v2 v2.4.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.52.0 // indirect - golang.org/x/net v0.55.0 // indirect + golang.org/x/crypto v0.56.0 // indirect + golang.org/x/net v0.58.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect - golang.org/x/sync v0.20.0 // indirect - golang.org/x/sys v0.45.0 // indirect - golang.org/x/term v0.43.0 // indirect - golang.org/x/text v0.37.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/term v0.45.0 // indirect + golang.org/x/text v0.41.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260523011958-0a33c5d7ca68 // indirect - google.golang.org/grpc v1.81.1 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect + google.golang.org/grpc v1.83.2 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect k8s.io/klog/v2 v2.140.0 // indirect diff --git a/go.sum b/go.sum index ad15edb..5321b44 100644 --- a/go.sum +++ b/go.sum @@ -34,36 +34,36 @@ github.com/alessio/shellescape v1.4.1 h1:V7yhSDDn8LP4lc4jS8pFkt0zCnzVJlG5JXy9BVK github.com/alessio/shellescape v1.4.1/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPpyFjDCtHLS30= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= -github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6tLY8= -github.com/aws/aws-sdk-go-v2 v1.41.7/go.mod h1:4LAfZOPHNVNQEckOACQx60Y8pSRjIkNZQz1w92xpMJc= -github.com/aws/aws-sdk-go-v2/config v1.32.17 h1:FpL4/758/diKwqbytU0prpuiu60fgXKUWCpDJtApclU= -github.com/aws/aws-sdk-go-v2/config v1.32.17/go.mod h1:OXqUMzgXytfoF9JaKkhrOYsyh72t9G+MJH8mMRaexOE= -github.com/aws/aws-sdk-go-v2/credentials v1.19.16 h1:r3RJBuU7X9ibt8RHbMjWE6y60QbKBiII6wSrXnapxSU= -github.com/aws/aws-sdk-go-v2/credentials v1.19.16/go.mod h1:6cx7zqDENJDbBIIWX6P8s0h6hqHC8Avbjh9Dseo27ug= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 h1:UuSfcORqNSz/ey3VPRS8TcVH2Ikf0/sC+Hdj400QI6U= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23/go.mod h1:+G/OSGiOFnSOkYloKj/9M35s74LgVAdJBSD5lsFfqKg= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 h1:GpT/TrnBYuE5gan2cZbTtvP+JlHsutdmlV2YfEyNde0= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23/go.mod h1:xYWD6BS9ywC5bS3sz9Xh04whO/hzK2plt2Zkyrp4JuA= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 h1:bpd8vxhlQi2r1hiueOw02f/duEPTMK59Q4QMAoTTtTo= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23/go.mod h1:15DfR2nw+CRHIk0tqNyifu3G1YdAOy68RftkhMDDwYk= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 h1:OQqn11BtaYv1WLUowvcA30MpzIu8Ti4pcLPIIyoKZrA= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24/go.mod h1:X5ZJyfwVrWA96GzPmUCWFQaEARPR7gCrpq2E92PJwAE= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 h1:FLudkZLt5ci0ozzgkVo8BJGwvqNaZbTWb3UcucAateA= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9/go.mod h1:w7wZ/s9qK7c8g4al+UyoF1Sp/Z45UwMGcqIzLWVQHWk= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 h1:pbrxO/kuIwgEsOPLkaHu0O+m4fNgLU8B3vxQ+72jTPw= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23/go.mod h1:/CMNUqoj46HpS3MNRDEDIwcgEnrtZlKRaHNaHxIFpNA= +github.com/aws/aws-sdk-go-v2 v1.41.9 h1:/rYeyO2+HrMztAmxAq9++XJtFMqSIpSsNA0yDGALYq4= +github.com/aws/aws-sdk-go-v2 v1.41.9/go.mod h1:+HsoOEX80qAVUitj1A2DhCNTjmb3edVyuDypb6LNEeo= +github.com/aws/aws-sdk-go-v2/config v1.32.20 h1:8VMDnWc/kEzxsI/1ngGM9mG81a8IGmIHD8KLcYGwagc= +github.com/aws/aws-sdk-go-v2/config v1.32.20/go.mod h1:PuwEpciweIXGULWeOeSTXtSbH4CW9mWdWrhdCKQI1sM= +github.com/aws/aws-sdk-go-v2/credentials v1.19.19 h1:yuFzSV1U0aRNYCQGVaTY2zW2M/L93pYHnXnrJUphYhU= +github.com/aws/aws-sdk-go-v2/credentials v1.19.19/go.mod h1:7y63L1kGzeoDlJaQ3Z578KrnmfBut96JjvJUzGwR+YE= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.25 h1:0w6dCiO8iez+YKwRhRBlL1CH/E3GTfdkuzrwj1by8vo= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.25/go.mod h1:9FDWUothyr5RCRAHc45XOiVCzUR8n/IhCYX+uVqw6vk= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.25 h1:Uii3frf9ztec/ABM2/FSH9/z7PLzxfpG8h4RpkUFflQ= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.25/go.mod h1:G6kntsA2GorAxDPbap6xgB2F+amSLUF8GJTi7PUoX44= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.25 h1:r1+/l6m+WaUJF9HISEsNOLHSNj5EXYQxK8VX6Cz9NlA= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.25/go.mod h1:cKf+D+NMDK1LndD7BowHbBZPgR9V0/5HubH0PFWvA+c= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.26 h1:A1PmWU2zfkIm9EyFlJncFXL4W4phML+h8KjltUsCvNQ= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.26/go.mod h1:dY4MRzXEizrD4hqtpKvWVGPX7QleSGGVY+EBolo1RmM= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.10 h1:d5/908OJ4bXg8lyjeMPvXetEKqoDoLi5Owy1zNue3yg= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.10/go.mod h1:a57l7Hwh+FWI+we50g5NPJHYUKeJKfXbc4w8SyXu8Ig= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.25 h1:dD3dhHNglpd98gs72my22Ndqi1hqQGllFFg1F+twfxg= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.25/go.mod h1:0yAbjPfd64gG7mj85RW+fMEYdfBgCRZw8g/oWcL1pjc= github.com/aws/aws-sdk-go-v2/service/kms v1.52.0 h1:QNtg+Mtj1zmepk568+UKBD5DFfqh+ESTUUqQT27JkQc= github.com/aws/aws-sdk-go-v2/service/kms v1.52.0/go.mod h1:Y0+uxvxz6ib4KktRdK0V4X45Vcs/JyYoz8H71pO8xeI= -github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 h1:TdJ+HdzOBhU8+iVAOGUTU63VXopcumCOF1paFulHWZc= -github.com/aws/aws-sdk-go-v2/service/signin v1.0.11/go.mod h1:R82ZRExE/nheo0N+T8zHPcLRTcH8MGsnR3BiVGX0TwI= -github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 h1:7byT8HUWrgoRp6sXjxtZwgOKfhss5fW6SkLBtqzgRoE= -github.com/aws/aws-sdk-go-v2/service/sso v1.30.17/go.mod h1:xNWknVi4Ezm1vg1QsB/5EWpAJURq22uqd38U8qKvOJc= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 h1:+1Kl1zx6bWi4X7cKi3VYh29h8BvsCoHQEQ6ST9X8w7w= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21/go.mod h1:4vIRDq+CJB2xFAXZ+YgGUTiEft7oAQlhIs71xcSeuVg= -github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 h1:F/M5Y9I3nwr2IEpshZgh1GeHpOItExNM9L1euNuh/fk= -github.com/aws/aws-sdk-go-v2/service/sts v1.42.1/go.mod h1:mTNxImtovCOEEuD65mKW7DCsL+2gjEH+RPEAexAzAio= -github.com/aws/smithy-go v1.25.1 h1:J8ERsGSU7d+aCmdQur5Txg6bVoYelvQJgtZehD12GkI= -github.com/aws/smithy-go v1.25.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/aws/aws-sdk-go-v2/service/signin v1.1.1 h1:1VwbP3qMNfxUDEXWki4rCE5iA+44VA1lokTz9HasGzw= +github.com/aws/aws-sdk-go-v2/service/signin v1.1.1/go.mod h1:vUtyoSj0OPji3kjIVSc/GlKuWEiL33f/WFxl6dmpy/A= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.19 h1:N6pIsdFOW1Kd9S4KyFKXdGRBojPPxkP32+uHFWLv4Hc= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.19/go.mod h1:3gt5WJArFooNmyLONS+h/R4J+o86II8du38IgCwj9dE= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.2 h1:hc+lBYiiTr8Zk4MTzIsQ92MeDWCIDvWGmzKUWOaBcOg= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.2/go.mod h1:hU6fqB3OJA6/ePheD47LQnxvjYk6br6PtQxs+Q9ojvk= +github.com/aws/aws-sdk-go-v2/service/sts v1.42.3 h1:ErklX/7uhSbkAAeyQD/Y1OoQ9hO3SJXQNEgksORW3Js= +github.com/aws/aws-sdk-go-v2/service/sts v1.42.3/go.mod h1:ULe4HCzfKPiR6R3HEurE3b1upEkuk8AkMrOKtaOxKO8= +github.com/aws/smithy-go v1.26.0 h1:9ouqbi+NyKP7fV3Te7UElCwdAb6Y8uk7LGwPE5tVe/s= +github.com/aws/smithy-go v1.26.0/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ= @@ -107,64 +107,66 @@ github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutV github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= -github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= +github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= -github.com/go-openapi/analysis v0.25.2 h1:I0vy4n3alz+DHTiN1PRhCb7QZxkK6g5YmswZKv2TKuw= -github.com/go-openapi/analysis v0.25.2/go.mod h1:Uhs1t/2XR10EnwONYILGEzw8gcfGIG5Xk5K2AxnhqDo= -github.com/go-openapi/errors v0.22.7 h1:JLFBGC0Apwdzw3484MmBqspjPbwa2SHvpDm0u5aGhUA= -github.com/go-openapi/errors v0.22.7/go.mod h1://QW6SD9OsWtH6gHllUCddOXDL0tk0ZGNYHwsw4sW3w= -github.com/go-openapi/jsonpointer v0.23.1 h1:1HBACs7XIwR2RcmItfdSFlALhGbe6S92p0ry4d1GWg4= -github.com/go-openapi/jsonpointer v0.23.1/go.mod h1:iWRmZTrGn7XwYhtPt/fvdSFj1OfNBngqRT2UG3BxSqY= -github.com/go-openapi/jsonreference v0.21.6 h1:NZ5nGfnaM1n4I43Xjm1e5/M2GjOwQwndQz22uhxwD+Y= -github.com/go-openapi/jsonreference v0.21.6/go.mod h1:xzbgtQ3ZbWxvET3AxdzCJlJt6vkovbf+IfSPJjD0tUY= -github.com/go-openapi/loads v0.23.3 h1:g5Xap1JfwKkUnZdn+S0L3SzBDpcTIYzZ5Qaag0YDkKQ= -github.com/go-openapi/loads v0.23.3/go.mod h1:NOH07zLajXo8y55hom0omlHWDVVvCwBM/S+csCK8LqA= -github.com/go-openapi/runtime v0.32.3 h1:J7Ycy5DJmhhP1By3NifhRUjnkXTrk21qbeqSULjwX8U= -github.com/go-openapi/runtime v0.32.3/go.mod h1:/WTQi0fa5DiGnnCXQKsTkSm15OzJp8Uz3H2t+67TBr4= +github.com/go-openapi/analysis v0.25.5 h1:xPYEvTb90o1y0epuiOPAoG4QqahjP3cdp5xNlHeKJRI= +github.com/go-openapi/analysis v0.25.5/go.mod h1:d3UGtQC5uq5Kqqqis2VH09Km/v3vwsWrYkbp4gdm+Rc= +github.com/go-openapi/errors v0.22.8 h1:oP7sW7TWc3wFFjrzzj0nI83H2qMBkNjNfSd+XRejk/I= +github.com/go-openapi/errors v0.22.8/go.mod h1:BuUoHcYrU6E7V9gfj1I5wLQqgtIHnup/alXZ8KdgQ0w= +github.com/go-openapi/jsonpointer v1.0.0 h1:kR9tHqY0CtZaOPVFm622dPVNhrvYpwr4uCxgL3h1H8s= +github.com/go-openapi/jsonpointer v1.0.0/go.mod h1:Z3rw7dWu1p9IgitXCFamSlA5lmDiklEB6vkaxcNZW5Y= +github.com/go-openapi/jsonreference v1.0.0 h1:jlmTr6torcd1YgDQvSfNmRtKzYDO4FGBkrAdlAVWnpY= +github.com/go-openapi/jsonreference v1.0.0/go.mod h1:jtwdyGbJk0Xhe5Y+rwtglQP6Sb1WZST4rT32LWB+sv0= +github.com/go-openapi/loads v0.25.0 h1:74Bc2snfaVlsHzwdQj/3gsA9XJz3daXTJVs+4ZaK7jI= +github.com/go-openapi/loads v0.25.0/go.mod h1:JFBw4SIB9+PTIFHDfcXuSSy5h6aWzjtUCrPYyx3qWU8= +github.com/go-openapi/runtime v0.33.0 h1:Dd3Oj2ig+WH8ckK95l0Wn2V8a4bH/UqWPRZVT0vc8yU= +github.com/go-openapi/runtime v0.33.0/go.mod h1:+rsupH3+TFKqmFysqkmgBOTxpVJV8eV+j9myvvea2Xw= github.com/go-openapi/runtime/server-middleware v0.30.0 h1:8rPoJ/xv7JL8BsovaqboKETlpWBArVh8n+0L/GyePog= github.com/go-openapi/runtime/server-middleware v0.30.0/go.mod h1:OYNT/TxNvB/VK5oe4htM2jDTwlEXuejVJmu0DVZfAMs= -github.com/go-openapi/spec v0.22.5 h1:KhO7RBlKQfonUWX2WzQCoLIXVA6AcNqDGZ3a1Dutdlo= -github.com/go-openapi/spec v0.22.5/go.mod h1:vxpOtMya5TXtENXKE5bKqv5NjocVhyhxHrlZfvKnZ74= -github.com/go-openapi/strfmt v0.26.3 h1:rzmslHarJgBbf2qfGge+X3htclQfmXqBZMm0Too0HhU= -github.com/go-openapi/strfmt v0.26.3/go.mod h1:a5nsUw0oRpQzZeOwx8bi6cKbzFZslpbCKt1LEot+KnQ= -github.com/go-openapi/swag v0.26.0 h1:GVDXCmfvhfu1BxiHo8/FA+BbKmhecHnG3varjON5/RI= -github.com/go-openapi/swag v0.26.0/go.mod h1:82g3193sZJRbocs7bNCqGfIgq8pkuwVwCfhKIRlEQF0= -github.com/go-openapi/swag/cmdutils v0.26.0 h1:iowihOcvq7y4egO8cOq0dmfohz6wfeQ63U1EnuhO2TU= -github.com/go-openapi/swag/cmdutils v0.26.0/go.mod h1:Sm1MVFMkF6guJJ+pQqHnQA3N0j9qALV3NxzDSv6bETM= -github.com/go-openapi/swag/conv v0.26.0 h1:5yGGsPYI1ZCva93U0AoKi/iZrNhaJEjr324YVsiD89I= -github.com/go-openapi/swag/conv v0.26.0/go.mod h1:tpAmIL7X58VPnHHiSO4uE3jBeRamGsFsfdDeDtb5ECE= -github.com/go-openapi/swag/fileutils v0.26.0 h1:WJoPRvsA7QRiiWluowkLJa9jaYR7FCuxmDvnCgaRRxU= -github.com/go-openapi/swag/fileutils v0.26.0/go.mod h1:0WDJ7lp67eNjPMO50wAWYlKvhOb6CQ37rzR7wrgI8Tc= -github.com/go-openapi/swag/jsonname v0.26.0 h1:gV1NFX9M8avo0YSpmWogqfQISigCmpaiNci8cGECU5w= -github.com/go-openapi/swag/jsonname v0.26.0/go.mod h1:urBBR8bZNoDYGr653ynhIx+gTeIz0ARZxHkAPktJK2M= -github.com/go-openapi/swag/jsonutils v0.26.0 h1:FawFML2iAXsPqmERscuMPIHmFsoP1tOqWkxBaKNMsnA= -github.com/go-openapi/swag/jsonutils v0.26.0/go.mod h1:2VmA0CJlyFqgawOaPI9psnjFDqzyivIqLYN34t9p91E= -github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.0 h1:apqeINu/ICHouqiRZbyFvuDge5jCmmLTqGQ9V95EaOM= -github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.0/go.mod h1:AyM6QT8uz5IdKxk5akv0y6u4QvcL9GWERt0Jx/F/R8Y= -github.com/go-openapi/swag/loading v0.26.0 h1:Apg6zaKhCJurpJer0DCxq99qwmhFddBhaMX7kilDcko= -github.com/go-openapi/swag/loading v0.26.0/go.mod h1:dBxQ/6V2uBaAQdevN18VELE6xSpJWZxLX4txe12JwDg= -github.com/go-openapi/swag/mangling v0.26.0 h1:Du2YC4YLA/Y5m/YKQd7AnY5qq0wRKSFZTTt8ktFaXcQ= -github.com/go-openapi/swag/mangling v0.26.0/go.mod h1:jifS7W9vbg+pw63bT+GI53otluMQL3CeemuyCHKwVx0= -github.com/go-openapi/swag/netutils v0.26.0 h1:CmZp+ZT7HrmFwrC3GdGsXBq2+42T1bjKBapcqVpIs3c= -github.com/go-openapi/swag/netutils v0.26.0/go.mod h1:5iK+Ok3ZohWWex1C50BFTPexi03UaPwjW4Oj8kgrpwo= -github.com/go-openapi/swag/stringutils v0.26.0 h1:qZQngLxs5s7SLijc3N2ZO+fUq2o8LjuWAASSrJuh+xg= -github.com/go-openapi/swag/stringutils v0.26.0/go.mod h1:sWn5uY+QIIspwPhvgnqJsH8xqFT2ZbYcvbcFanRyhFE= -github.com/go-openapi/swag/typeutils v0.26.0 h1:2kdEwdiNWy+JJdOvu5MA2IIg2SylWAFuuyQIKYybfq4= -github.com/go-openapi/swag/typeutils v0.26.0/go.mod h1:oovDuIUvTrEHVMqWilQzKzV4YlSKgyZmFh7AlfABNVE= -github.com/go-openapi/swag/yamlutils v0.26.0 h1:H7O8l/8NJJQ/oiReEN+oMpnGMyt8G0hl460nRZxhLMQ= -github.com/go-openapi/swag/yamlutils v0.26.0/go.mod h1:1evKEGAtP37Pkwcc7EWMF0hedX0/x3Rkvei2wtG/TbU= -github.com/go-openapi/testify/enable/yaml/v2 v2.5.1 h1:q9NtHwK4qHF7yZziBPvZyv7zWAIk8ok88Gh2mR6Jpc8= -github.com/go-openapi/testify/enable/yaml/v2 v2.5.1/go.mod h1:JW0MXIotCYps/XsgJnG3a8Q7rE5xAiBwoOD5OfaIQBk= -github.com/go-openapi/testify/v2 v2.5.1 h1:TMdhCaw8fUNraVSf3Omoob1dO/AzBfhtFAPW0an6sBo= -github.com/go-openapi/testify/v2 v2.5.1/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= -github.com/go-openapi/validate v0.25.3 h1:4nzAIavcJ7WveHK2+V1UAkZK3kWcjzxZCzjfZAfavKs= -github.com/go-openapi/validate v0.25.3/go.mod h1:GemfuGMyYpIaBoKpX3z8sLywrmxpzWVOoJ7R0VeAVuk= +github.com/go-openapi/spec v0.22.9 h1:/vKIFDcGKp0ktZWGbym/tJEWbk6/XOEmAVU0kqKMH+w= +github.com/go-openapi/spec v0.22.9/go.mod h1:b/mNUYIOQOyIiUzUzXEE8xzyZqf93KvM9hQGP91yfl0= +github.com/go-openapi/strfmt v0.27.0 h1:kbcTeaD9TXuXD0hhMXzuYa1sdTo6+dWGvwjW93E80IM= +github.com/go-openapi/strfmt v0.27.0/go.mod h1:s/qhDqfY72irigXUGJmtgid2Rm+3tnz3k8hZaRmvWYc= +github.com/go-openapi/swag v0.26.1 h1:l5sVEyVpwj+DDYeZyo7wQI/Ebn/mKYIyGB/pFwAfGoQ= +github.com/go-openapi/swag v0.26.1/go.mod h1:yNY38BbIVthxbkDtq1UHBCGasBqjakW3lCR6ANzdBEw= +github.com/go-openapi/swag/cmdutils v0.27.0 h1:aIKiqhB29AaP+7xm8/CPg3uOpeHx2SUp6TvMpu/a31Y= +github.com/go-openapi/swag/cmdutils v0.27.0/go.mod h1:Sm1MVFMkF6guJJ+pQqHnQA3N0j9qALV3NxzDSv6bETM= +github.com/go-openapi/swag/conv v0.27.3 h1:iqJFmGEjmX3AY0lSszABFqRVqOSt99XS0LzNIMJYuhU= +github.com/go-openapi/swag/conv v0.27.3/go.mod h1:nPRmN6jgNme99hpf+nM0auDZGALWIqlwhisKPK/bQhQ= +github.com/go-openapi/swag/fileutils v0.27.3 h1:3UVoZ2RLaIs1lt+2jcKzL8RM3Yk0rmsDE9FLA/HGxFE= +github.com/go-openapi/swag/fileutils v0.27.3/go.mod h1:VvJFZLTZS0AI854gEQz5tk7dBESdLjiNUMSZ/th2ry8= +github.com/go-openapi/swag/jsonname v0.26.1 h1:VReupaV6WxlAsCn0e4DUfgV6bPmINnPpyJDLqSfNPcE= +github.com/go-openapi/swag/jsonname v0.26.1/go.mod h1:OvdW6BoWoj33pTfi7x9vFrgmT+fk7aw0BRwvCE0YOuc= +github.com/go-openapi/swag/jsonutils v0.27.3 h1:1DEz+O82frtSMBcos/7XIn1GnpNTbsD4Bru4Dc/uhRc= +github.com/go-openapi/swag/jsonutils v0.27.3/go.mod h1:qiDCoQvzkMxrV3G8FLEdIU5L+EFYc0zcDOHWT3Yofvo= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.27.3 h1:h/eT9kmGCDdFLJF29lOhzLtF0FmP1AX2MhLJWVebsb8= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.27.3/go.mod h1:mofwUWx70wvskwESqRJ//k/9kURmCgyJl5m5Ppoh5kY= +github.com/go-openapi/swag/loading v0.27.3 h1:L9nQkEgzU7QgFQL+pLEMfGUKxeM4pWwGwbET9Z3weW0= +github.com/go-openapi/swag/loading v0.27.3/go.mod h1:rJ0NeaKsF4CVPnMGjPQl7JlSHzvD0bc2DKXLss1hiuE= +github.com/go-openapi/swag/mangling v0.27.3 h1:gRzzD1PAUoLTtGMgI3KpBmCSOlTuLTFWnviLxLcTnyg= +github.com/go-openapi/swag/mangling v0.27.3/go.mod h1:jtBE2+V+3pILxOR7Vgce+Cwp6A2PgZbvVqfNntbVs0w= +github.com/go-openapi/swag/netutils v0.27.0 h1:lEUG+hHvPvLggB3A8snFk0IRKNf9uC0YKc+7WYqvAF8= +github.com/go-openapi/swag/netutils v0.27.0/go.mod h1:J+WYyFMLtvtCGqa6jLv+YNUmIKI3ZRQRrvfNDMoQoEQ= +github.com/go-openapi/swag/pools v0.27.3 h1:gXjImP3F6/56wRRcFgEPld084Y6u2gs21ikPBt8NKBk= +github.com/go-openapi/swag/pools v0.27.3/go.mod h1:kVQefhSK5RWuRe7BXsL8htgBPAMpN7HDGpGEknqugeE= +github.com/go-openapi/swag/stringutils v0.27.3 h1:Ru28hnbAvN5wycALQYy8IobHvASq+FUFMlp1QzLM0JI= +github.com/go-openapi/swag/stringutils v0.27.3/go.mod h1:lzRN95CxXmA03XcDWHLOb6nOMcxCqR5rGY0lOgsfRoM= +github.com/go-openapi/swag/typeutils v0.27.3 h1:l6SSrx5eR5/WVwrGNzN6bQ9WqL04mrxNBl9YgQ3rcJ4= +github.com/go-openapi/swag/typeutils v0.27.3/go.mod h1:Srm0xFNRZ1Y+vCxJclo5qzx8aj+1pAKda/YfFPrG0dQ= +github.com/go-openapi/swag/yamlutils v0.27.3 h1:cRFCAoYtslYn9L9T0xWryHy1t7c1MACC+DMj3CLvwvs= +github.com/go-openapi/swag/yamlutils v0.27.3/go.mod h1:6JYBGj8sw/NawMllyZY+cTA8Mzk2etS3ZBASdcyPsiU= +github.com/go-openapi/testify/enable/yaml/v2 v2.6.0 h1:gGHwAJ0R/5jU8BEGDbfRNR3hL68dAVi84WuOApp29B0= +github.com/go-openapi/testify/enable/yaml/v2 v2.6.0/go.mod h1:tY+St1SGq4NFl0QIqdTY4aEdbChAHxhyB77XQi9iJCo= +github.com/go-openapi/testify/v2 v2.6.0 h1:5PKH2HE7YJ/LuRPQGvSxBRlFXNQhSetBLlGAgUEu3ug= +github.com/go-openapi/testify/v2 v2.6.0/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= +github.com/go-openapi/validate v0.26.1 h1:pZSbvtRO8G2R2FpWTYRn3w8LrsNwbtaVhP2dWiBa0Us= +github.com/go-openapi/validate v0.26.1/go.mod h1:B8UMgXiQiwwQWIbmuROlwJZDPGlikPuh7iHV1vPX9Oo= github.com/go-rod/rod v0.116.2 h1:A5t2Ky2A+5eD/ZJQr1EfsQSe5rms5Xof/qj296e+ZqA= github.com/go-rod/rod v0.116.2/go.mod h1:H+CMO9SCNc2TJ2WfrG+pKhITz57uGNYU43qYHh438Mg= -github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= -github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= +github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= +github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= @@ -179,8 +181,8 @@ github.com/google/certificate-transparency-go v1.3.3 h1:hq/rSxztSkXN2tx/3jQqF6Xc github.com/google/certificate-transparency-go v1.3.3/go.mod h1:iR17ZgSaXRzSa5qvjFl8TnVD5h8ky2JMVio+dzoKMgA= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/go-containerregistry v0.21.6 h1:T+yqQIlJXKrM98Om4DlW3GoWQAmhZuLMwoDOvVrtiUM= -github.com/google/go-containerregistry v0.21.6/go.mod h1:U7MMSBIJynke2MVQrQk19NP9k/uQsGz/h0amIFSHMbo= +github.com/google/go-containerregistry v0.21.7 h1:/vPFuVXDjtFREsVArW+0h1CIl5urnOhzei4X2DMW9IU= +github.com/google/go-containerregistry v0.21.7/go.mod h1:kjSbt7/zMsKLWfnHrIvKvhXHUw91jbe9DNjPPJ32gXE= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= @@ -189,8 +191,8 @@ github.com/google/trillian v1.7.3 h1:hziW+vo4czis48tzx2GK5xRBl/ZxBA9B0/UR5avXOro github.com/google/trillian v1.7.3/go.mod h1:qh8iy4x/GvnVXUBd5pK4oncuT1Y9vVYfibQVsR/WpKg= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/googleapis/enterprise-certificate-proxy v0.3.15 h1:xolVQTEXusUcAA5UgtyRLjelpFFHWlPQ4XfWGc7MBas= -github.com/googleapis/enterprise-certificate-proxy v0.3.15/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg= +github.com/googleapis/enterprise-certificate-proxy v0.3.16 h1:F/VPrx0YPBdksZJQdCAp0WUsqnNmZpUZszzfYt0M5Dw= +github.com/googleapis/enterprise-certificate-proxy v0.3.16/go.mod h1:9Yb0eAkH/Xqhvv3zbeKf/+wMJqCeocWc6KIhDvEAuYE= github.com/googleapis/gax-go/v2 v2.22.0 h1:PjIWBpgGIVKGoCXuiCoP64altEJCj3/Ei+kSU5vlZD4= github.com/googleapis/gax-go/v2 v2.22.0/go.mod h1:irWBbALSr0Sk3qlqb9SyJ1h68WjgeFuiOzI4Rqw5+aY= github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 h1:UH//fgunKIs4JdUbpDl1VZCDaL56wXCB/5+wF6uHfaI= @@ -215,8 +217,6 @@ github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9 github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4= github.com/hashicorp/go-sockaddr v1.0.7 h1:G+pTkSO01HpR5qCxg7lxfsFEZaG+C0VssTy/9dbT+Fw= github.com/hashicorp/go-sockaddr v1.0.7/go.mod h1:FZQbEYa1pxkQ7WLpyXJ6cbjpT8q0YgQaK/JakXqGyWw= -github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= -github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hashicorp/hcl v1.0.1-vault-7 h1:ag5OxFVy3QYTFTJODRzTKVZ6xvdfLLCA1cy/Y6xGI0I= github.com/hashicorp/hcl v1.0.1-vault-7/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM= github.com/hashicorp/vault/api v1.22.0 h1:+HYFquE35/B74fHoIeXlZIP2YADVboaPjaSicHEZiH0= @@ -229,14 +229,6 @@ github.com/in-toto/in-toto-golang v0.11.0 h1:nfidMYBFx+E0lnmX5KUnN2Pdm8zdNKal1ay github.com/in-toto/in-toto-golang v0.11.0/go.mod h1:u3PjTnwFKjp5a1YCcw8SJg0G+tMeKfVoWsWeFMDCMtw= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= -github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= -github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= -github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= -github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo= -github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw= -github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= -github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jedisct1/go-minisign v0.0.0-20211028175153-1c139d1cc84b h1:ZGiXF8sz7PDk6RgkP+A/SFfUD0ZR/AgG6SpRNEDKZy8= github.com/jedisct1/go-minisign v0.0.0-20211028175153-1c139d1cc84b/go.mod h1:hQmNrgofl+IY/8L+n20H6E6PWBBTokdsv+q49j0QhsU= github.com/jellydator/ttlcache/v3 v3.4.0 h1:YS4P125qQS0tNhtL6aeYkheEaB/m8HCqdMMP4mnWdTY= @@ -307,14 +299,14 @@ github.com/shibumi/go-pathspec v1.3.0 h1:QUyMZhFo0Md5B8zV8x2tesohbb5kfbpTi9rBnKh github.com/shibumi/go-pathspec v1.3.0/go.mod h1:Xutfslp817l2I1cZvgcfeMQJG5QnU2lh5tVaaMCl3jE= github.com/sigstore/protobuf-specs v0.5.1 h1:/5OPaNuolRJmQfeZLayJGFXMpsRJEdgC6ah1/+7Px7U= github.com/sigstore/protobuf-specs v0.5.1/go.mod h1:DRBzpFuE+LnvQMN10/dU6nBeKwVLGEQ6o2FovN2Rats= -github.com/sigstore/rekor v1.5.2 h1:k6pX4o1zFAzAvDbXiVIp5IHj1b0wcDaxsbsbNpuRO8o= -github.com/sigstore/rekor v1.5.2/go.mod h1:WkMnITBccOFauPkT6yte74tF5gC83pefKRGZvNOsbjI= -github.com/sigstore/rekor-tiles/v2 v2.2.2-0.20260601073857-5d098a2b6443 h1:/CO8F6m3Bo/f59bZo5dv1sTIfUnQqVnepIdDV24KoDw= -github.com/sigstore/rekor-tiles/v2 v2.2.2-0.20260601073857-5d098a2b6443/go.mod h1:w1h8wF8vq9lHjmtRdwJiEaoVxhP+WHIMpj4M39pkzp0= +github.com/sigstore/rekor v1.5.3 h1:0Tyolw3zreRgm7PUW8dccFLXGBThi08278jI8EXNSr4= +github.com/sigstore/rekor v1.5.3/go.mod h1:h3GK5dDqCcWJJZUJwdpKGSSmEV2GEjPUjJy3WTjBwzA= +github.com/sigstore/rekor-tiles/v2 v2.3.0 h1:HhMgH61UP0t899V8Fjt7pz1YdgOBptbaQdnCF+79cdc= +github.com/sigstore/rekor-tiles/v2 v2.3.0/go.mod h1:DEFiKSyQ4nF75QRVNdOPaIH3cmvMkO2B6xDZjNYngPc= github.com/sigstore/sigstore v1.10.8 h1:1Mgkxvkw4AXMfIP1DOjc6kw0GkUgA8pGVpveN/EfOq4= github.com/sigstore/sigstore v1.10.8/go.mod h1:f9+B/4iaYimvUkySyb2mvc73n3RLqNn24grHZM/ET8M= -github.com/sigstore/sigstore-go v1.1.4 h1:wTTsgCHOfqiEzVyBYA6mDczGtBkN7cM8mPpjJj5QvMg= -github.com/sigstore/sigstore-go v1.1.4/go.mod h1:2U/mQOT9cjjxrtIUeKDVhL+sHBKsnWddn8URlswdBsg= +github.com/sigstore/sigstore-go v1.3.0 h1:hnIMHREyCNTYFtOE1o7ae3Axa9B5W5EjUSBJICP2NBE= +github.com/sigstore/sigstore-go v1.3.0/go.mod h1:AyRQXfpH89py1twjE3kEZxlRersng90GSYqQV9zGJE8= github.com/sigstore/sigstore/pkg/signature/kms/aws v1.10.8 h1:tofVQ+UWJgad/69I5zbqxdFCN5gpIn9tRQP7iBzIpBw= github.com/sigstore/sigstore/pkg/signature/kms/aws v1.10.8/go.mod h1:73AfJE8H6w5KGCFPBu4x/OG+i1Yxgmh0L/FtV7prd88= github.com/sigstore/sigstore/pkg/signature/kms/azure v1.10.8 h1:8Mt7J36GcUEmbiJaiFhz2tud5ZIgkfVVCe2H/WJCHmw= @@ -323,8 +315,8 @@ github.com/sigstore/sigstore/pkg/signature/kms/gcp v1.10.8 h1:MxpAIMZVzn0Tpbarc9 github.com/sigstore/sigstore/pkg/signature/kms/gcp v1.10.8/go.mod h1:bnAUEkFNam6STvkVZhptVwWzWR5pS24CEtQ+lhxu7S0= github.com/sigstore/sigstore/pkg/signature/kms/hashivault v1.10.8 h1:1DGe4/clcdOnkz5MINEczWlmEvjUtZd+AjPPT/cBhQ8= github.com/sigstore/sigstore/pkg/signature/kms/hashivault v1.10.8/go.mod h1:6IDFhpgxtzqbnzrFkyegbj7RfWwKeRrb3/+xAD1Wp+Y= -github.com/sigstore/timestamp-authority/v2 v2.1.2 h1:7DDhnknLL4w8VwomyvW2W8qblOS9LDR8oihna+jc7Ls= -github.com/sigstore/timestamp-authority/v2 v2.1.2/go.mod h1:o6rAVZceFyejClIj/uStRNIemP16bVMZtbMmhk6pr0U= +github.com/sigstore/timestamp-authority/v2 v2.1.3 h1:Fc+LjCTfik1lh3YLkaosENfkXa3R2Y1nswiUKutBdFA= +github.com/sigstore/timestamp-authority/v2 v2.1.3/go.mod h1:myoFOKJB/u5vNTFwvBBJVkG3NnOBeIJevbfjNeasLjo= github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= @@ -344,16 +336,16 @@ github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8 github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/theupdateframework/go-tuf v0.7.0 h1:CqbQFrWo1ae3/I0UCblSbczevCCbS31Qvs5LdxRWqRI= github.com/theupdateframework/go-tuf v0.7.0/go.mod h1:uEB7WSY+7ZIugK6R1hiBMBjQftaFzn7ZCDJcp1tCUug= -github.com/theupdateframework/go-tuf/v2 v2.4.2-0.20260407074541-7e8f69f906ef h1:jJac5InhEfD0Z46/d5RayZjoavf/se7bPZpOgg8GLrM= -github.com/theupdateframework/go-tuf/v2 v2.4.2-0.20260407074541-7e8f69f906ef/go.mod h1:cLUSJ2cgR194lNWfp+TJT4P8PX7qGleCXdudqlCMtOE= +github.com/theupdateframework/go-tuf/v2 v2.4.2 h1:w7976/W8uTwlsegP5nRymlpjPgrwSh+AXUf85is6nJk= +github.com/theupdateframework/go-tuf/v2 v2.4.2/go.mod h1:JqBrIUnNLAaNq/8GmBcEMFWfAFBbqp/MkJEJseXKbks= github.com/tink-crypto/tink-go-awskms/v3 v3.0.0 h1:XSohRhCkXAVI0iaCnWB/GS05TEmpnKurQmzaY1jzt3Y= github.com/tink-crypto/tink-go-awskms/v3 v3.0.0/go.mod h1:+7MXsShLzVbSQ6dI0Pe4JuZM52jD1jQ1itAygd/MDsA= -github.com/tink-crypto/tink-go-gcpkms/v2 v2.2.0 h1:3B9i6XBXNTRspfkTC0asN5W0K6GhOSgcujNiECNRNb0= -github.com/tink-crypto/tink-go-gcpkms/v2 v2.2.0/go.mod h1:jY5YN2BqD/KSCHM9SqZPIpJNG/u3zwfLXHgws4x2IRw= +github.com/tink-crypto/tink-go-gcpkms/v2 v2.3.0 h1:3s6YMgMOBZRU8qG6ybpKSF2Sau+y3sMvxR911M59SwA= +github.com/tink-crypto/tink-go-gcpkms/v2 v2.3.0/go.mod h1:X8UNvbQu2wanAGa8ixRUU/DWt1V2hUBfvPGy6s9nE2s= github.com/tink-crypto/tink-go-hcvault/v2 v2.5.0 h1:eXuNqgrcYelxU1MVikOJDP3wTS5lvihM4ntoAbAMfvs= github.com/tink-crypto/tink-go-hcvault/v2 v2.5.0/go.mod h1:3RhcxAqek6xUlRFmJifvU4CYLZN60KMQdIKqpZAZJG0= -github.com/tink-crypto/tink-go/v2 v2.6.0 h1:+KHNBHhWH33Vn+igZWcsgdEPUxKwBMEe0QC60t388v4= -github.com/tink-crypto/tink-go/v2 v2.6.0/go.mod h1:2WbBA6pfNsAfBwDCggboaHeB2X29wkU8XHtGwh2YIk8= +github.com/tink-crypto/tink-go/v2 v2.7.0 h1:k7QnUXJ1cRDpvoy/5l1FimZqMAArRff8vjUqzi5N04o= +github.com/tink-crypto/tink-go/v2 v2.7.0/go.mod h1:cWNpQ/yAT/QHzAV0kBGMOSJzzYTKofDZdJaUqOPPWCI= github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399 h1:e/5i7d4oYZ+C1wj2THlRK+oAhjeS/TRQwMfkIuet3w0= github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399/go.mod h1:LdwHTNJT99C5fTAzDz0ud328OgXz+gierycbcIx2fRs= github.com/transparency-dev/formats v0.1.1 h1:4bVHJc+KdBgpA1OJD1yjI+g0i5Z1graCppTMH8lWKJI= @@ -386,8 +378,8 @@ go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSY go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= -go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= -go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= go.step.sm/crypto v0.77.7 h1:6azC+pD678Vjju8yXnMDHCZJ+HzFaEmL3sCryiezTIA= @@ -402,37 +394,37 @@ go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= -golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= -golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= -golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= -golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= -golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y= +golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= -golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= -golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= -golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= -golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= -google.golang.org/api v0.280.0 h1:F4OfEHZhZh6a7uTufJAXXVd/2TQ8EjM4vZH+jX/vFYk= -google.golang.org/api v0.280.0/go.mod h1:oGKmPZRDoD3vdkf6MA7F4VNkR1rxCiuaPSkhsf3EolU= +google.golang.org/api v0.283.0 h1:0lkp8u0MPwJVHqRL+nJlMAoZVVzbmiXmFHXMOTmSPik= +google.golang.org/api v0.283.0/go.mod h1:6Wssta4c5n9qHq5CBhmlai5h/PUa1djdDAIhYEHyvcM= google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0= google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I= google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8= google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260523011958-0a33c5d7ca68 h1:PvEgGJf9C/1u5CHkInMg7UFYYUoiaQmW2LbtH0pjB78= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260523011958-0a33c5d7ca68/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= -google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU= +google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -442,8 +434,8 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc= k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0= -oras.land/oras-go/v2 v2.6.0 h1:X4ELRsiGkrbeox69+9tzTu492FMUu7zJQW6eJU+I2oc= -oras.land/oras-go/v2 v2.6.0/go.mod h1:magiQDfG6H1O9APp+rOsvCPcW1GD2MM7vgnKY0Y+u1o= +oras.land/oras-go/v2 v2.6.2 h1:N04RXngAp1LJKTG6ifz3xHPipasEkWr+hFmInja5YKo= +oras.land/oras-go/v2 v2.6.2/go.mod h1:PlTtg4JTDJkDe8yVHpM2wz7/YDc00GVas+i4jAW2TZ4= sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k= From 3acdf99fe9f78e3f527cee41948d87e85aa7c610 Mon Sep 17 00:00:00 2001 From: Eddie Knight Date: Thu, 3 Sep 2026 12:47:54 -0500 Subject: [PATCH 3/9] Point every reference at gemaraproj/grcli; drop revanite-io remnants This repo supersedes revanite-io/grcli entirely, so: - Go module path renamed to github.com/gemaraproj/grcli (no external importers exist; grcli is a leaf). Imports, Makefile PKG and the release ldflags follow. - Reusable publish-gemara workflow installs from ghcr.io/gemaraproj/grcli and defaults to v0.7.0 (was a v0.3.0 pin at the old registry). - Install action, example workflow, README and CLAUDE.md drop the "older tags live at revanite-io" caveats; pins move to v0.7.0. - CHANGELOG gains an Unreleased entry for the module rename. Signed-off-by: Eddie Knight --- .github/actions/install/action.yml | 5 ++--- .github/workflows/publish-gemara.yml | 8 ++++---- .github/workflows/release.yml | 4 ++-- CHANGELOG.md | 11 +++++++++-- CLAUDE.md | 6 +++--- Makefile | 2 +- README.md | 2 +- cmd/fetch.go | 6 +++--- cmd/fetch_test.go | 2 +- cmd/login.go | 2 +- cmd/logout.go | 2 +- cmd/publish.go | 12 ++++++------ cmd/publish_test.go | 4 ++-- cmd/references_test.go | 2 +- cmd/regtoken.go | 2 +- cmd/unpack.go | 10 +++++----- cmd/verify.go | 8 ++++---- cmd/verify_test.go | 2 +- cmd/versions.go | 2 +- cmd/versions_test.go | 2 +- examples/github-actions/publish.yml | 5 ++--- go.mod | 2 +- internal/provenance/provenance_test.go | 4 ++-- internal/registry/registry.go | 2 +- internal/registry/registry_test.go | 2 +- internal/sign/sign.go | 2 +- internal/sigverify/roundtrip_test.go | 2 +- internal/source/source.go | 2 +- main.go | 2 +- 29 files changed, 61 insertions(+), 56 deletions(-) diff --git a/.github/actions/install/action.yml b/.github/actions/install/action.yml index 8c04593..00b8266 100644 --- a/.github/actions/install/action.yml +++ b/.github/actions/install/action.yml @@ -6,9 +6,8 @@ description: >- inputs: version: description: >- - Version tag to install, e.g. v0.6.0, or "latest". Releases from v0.6.0 - live at ghcr.io/gemaraproj/grcli (the repo moved orgs); tags older than - that were published to ghcr.io/revanite-io/grcli and are NOT here. + Version tag to install from ghcr.io/gemaraproj/grcli, e.g. v0.7.0, + or "latest". required: false default: latest verify: diff --git a/.github/workflows/publish-gemara.yml b/.github/workflows/publish-gemara.yml index 59df20f..995bf21 100644 --- a/.github/workflows/publish-gemara.yml +++ b/.github/workflows/publish-gemara.yml @@ -27,7 +27,7 @@ # permissions: # contents: read # id-token: write # caller MUST grant this — it's what auth uses -# uses: revanite-io/grcli/.github/workflows/publish-gemara.yml@v0.3.0 +# uses: gemaraproj/grcli/.github/workflows/publish-gemara.yml@v0.7.0 # with: # files: controls.yaml # license: Apache-2.0 @@ -51,10 +51,10 @@ on: type: string default: https://hub.grc.store grcli-version: - description: 'grcli release tag to install from ghcr.io/revanite-io/grcli.' + description: 'grcli release tag to install from ghcr.io/gemaraproj/grcli.' required: false type: string - default: v0.3.0 + default: v0.7.0 jobs: publish: @@ -72,7 +72,7 @@ jobs: env: GRCLI_VERSION: ${{ inputs.grcli-version }} run: | - oras pull "ghcr.io/revanite-io/grcli:$GRCLI_VERSION" --platform linux/amd64 + oras pull "ghcr.io/gemaraproj/grcli:$GRCLI_VERSION" --platform linux/amd64 sudo install grcli /usr/local/bin/grcli # Required for keyless signing — the same OIDC identity authorizes the push. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 08474e9..50c83af 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,7 +24,7 @@ permissions: env: REGISTRY: ghcr.io - IMAGE: ghcr.io/${{ github.repository }} # ghcr.io/gemaraproj/grcli (repo moved orgs after v0.5.1) + IMAGE: ghcr.io/${{ github.repository }} # ghcr.io/gemaraproj/grcli jobs: release: @@ -59,7 +59,7 @@ jobs: CGO_ENABLED: "0" run: | set -euo pipefail - ldflags="-s -w -X github.com/revanite-io/grcli/cmd.version=${VERSION}" + ldflags="-s -w -X github.com/gemaraproj/grcli/cmd.version=${VERSION}" artifact_type="application/vnd.revanite.grcli.binary" platforms="linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64" diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bd7577..bb6c4a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,14 @@ Notable changes to `grcli`. This project is pre-1.0; while on `v0.x`, a breaking change bumps the minor version. +## [Unreleased] + +### Changed + +- **Go module path renamed to `github.com/gemaraproj/grcli`.** This repo now + supersedes `revanite-io/grcli` entirely; every workflow, example and doc + points at `github.com/gemaraproj/grcli` / `ghcr.io/gemaraproj/grcli`. + ## [0.6.0] - 2026-08-19 > **Live CI smoke PASSED 2026-08-19** — the gate this release was held behind. @@ -15,8 +23,7 @@ change bumps the minor version. > > **The repo also moved orgs after v0.5.1**: v0.6.0+ publish to > `ghcr.io/gemaraproj/grcli`; tags ≤ v0.5.1 remain at -> `ghcr.io/revanite-io/grcli` and are not re-published. The Go module path is -> deliberately unchanged (`github.com/revanite-io/grcli`). +> `ghcr.io/revanite-io/grcli` and are not re-published. ### Changed diff --git a/CLAUDE.md b/CLAUDE.md index d65e615..150195f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,9 +2,9 @@ Go CLI and **primary end-user surface** for grc.store: validates Gemara YAML, packs it into signed OCI bundles with SLSA-shaped provenance, publishes to a hub, and verifies bundles. -Go module: `github.com/revanite-io/grcli` (unchanged on purpose). The **repo lives at -`github.com/gemaraproj/grcli`** since the org move after v0.5.1; releases v0.6.0+ publish to -`ghcr.io/gemaraproj/grcli`, older tags remain at `ghcr.io/revanite-io/grcli`. +Go module: `github.com/gemaraproj/grcli`. Repo: `github.com/gemaraproj/grcli`; releases publish +to `ghcr.io/gemaraproj/grcli`. This repo supersedes the earlier `revanite-io/grcli` repo and +registry entirely. `README.md` covers install (via `oras`), the full usage flow, and CI/trusted-publishing; `CHANGELOG.md` tracks the pre-1.0 breaking changes. This file is the map — point there, don't duplicate. diff --git a/Makefile b/Makefile index 56a9ae8..b16032a 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ .PHONY: build test testcov lint vet fmt fmtcheck tidy tidycheck ci-local clean BIN := bin/grcli -PKG := github.com/revanite-io/grcli +PKG := github.com/gemaraproj/grcli VERSION ?= $(shell git describe --tags --dirty --always 2>/dev/null || echo dev) LDFLAGS := -X $(PKG)/cmd.version=$(VERSION) diff --git a/README.md b/README.md index 94d6f5d..ae79a07 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ In GitHub Actions: sudo install grcli /usr/local/bin/grcli ``` -Pin a release tag (`:v0.6.0`) instead of `latest` for reproducible +Pin a release tag (`:v0.7.0`) instead of `latest` for reproducible installs. To verify the signature before installing: ```sh diff --git a/cmd/fetch.go b/cmd/fetch.go index 2c966e3..712fab7 100644 --- a/cmd/fetch.go +++ b/cmd/fetch.go @@ -12,9 +12,9 @@ import ( "github.com/gemaraproj/go-gemara/bundle" "github.com/spf13/viper" - "github.com/revanite-io/grcli/internal/cache" - "github.com/revanite-io/grcli/internal/hub" - "github.com/revanite-io/grcli/internal/registry" + "github.com/gemaraproj/grcli/internal/cache" + "github.com/gemaraproj/grcli/internal/hub" + "github.com/gemaraproj/grcli/internal/registry" ) // resolveBundle fetches the primary artifact bundle from either a local OCI diff --git a/cmd/fetch_test.go b/cmd/fetch_test.go index bb5bbcd..6be2ff7 100644 --- a/cmd/fetch_test.go +++ b/cmd/fetch_test.go @@ -14,7 +14,7 @@ import ( "github.com/spf13/viper" "github.com/stretchr/testify/require" - "github.com/revanite-io/grcli/internal/cache" + "github.com/gemaraproj/grcli/internal/cache" ) func tempCache(t *testing.T) *cache.Cache { diff --git a/cmd/login.go b/cmd/login.go index 03accd3..6121082 100644 --- a/cmd/login.go +++ b/cmd/login.go @@ -11,7 +11,7 @@ import ( "github.com/spf13/viper" "github.com/gemaraproj/grc-store-clientkit/auth" - "github.com/revanite-io/grcli/internal/hub" + "github.com/gemaraproj/grcli/internal/hub" ) func newLoginCmd(v *viper.Viper) *cobra.Command { diff --git a/cmd/logout.go b/cmd/logout.go index 96119d2..ccd9c73 100644 --- a/cmd/logout.go +++ b/cmd/logout.go @@ -9,7 +9,7 @@ import ( "github.com/spf13/viper" "github.com/gemaraproj/grc-store-clientkit/auth" - "github.com/revanite-io/grcli/internal/hub" + "github.com/gemaraproj/grcli/internal/hub" ) func newLogoutCmd(v *viper.Viper) *cobra.Command { diff --git a/cmd/publish.go b/cmd/publish.go index 83339c2..e59a729 100644 --- a/cmd/publish.go +++ b/cmd/publish.go @@ -19,12 +19,12 @@ import ( "github.com/revanite-io/grc-store-protocol/spdx" "github.com/gemaraproj/grc-store-clientkit/auth" - "github.com/revanite-io/grcli/internal/digest" - "github.com/revanite-io/grcli/internal/hub" - "github.com/revanite-io/grcli/internal/provenance" - "github.com/revanite-io/grcli/internal/registry" - "github.com/revanite-io/grcli/internal/sign" - "github.com/revanite-io/grcli/internal/source" + "github.com/gemaraproj/grcli/internal/digest" + "github.com/gemaraproj/grcli/internal/hub" + "github.com/gemaraproj/grcli/internal/provenance" + "github.com/gemaraproj/grcli/internal/registry" + "github.com/gemaraproj/grcli/internal/sign" + "github.com/gemaraproj/grcli/internal/source" ) // Flag names are declared once so the compiler catches typos at every diff --git a/cmd/publish_test.go b/cmd/publish_test.go index d06cb89..3a43f75 100644 --- a/cmd/publish_test.go +++ b/cmd/publish_test.go @@ -13,8 +13,8 @@ import ( "github.com/spf13/viper" "github.com/stretchr/testify/require" - "github.com/revanite-io/grcli/internal/registry" - "github.com/revanite-io/grcli/internal/source" + "github.com/gemaraproj/grcli/internal/registry" + "github.com/gemaraproj/grcli/internal/source" ) func TestResolveTarget(t *testing.T) { diff --git a/cmd/references_test.go b/cmd/references_test.go index e998c72..4ab735a 100644 --- a/cmd/references_test.go +++ b/cmd/references_test.go @@ -17,7 +17,7 @@ import ( "github.com/gemaraproj/go-gemara/bundle" "github.com/stretchr/testify/require" - "github.com/revanite-io/grcli/internal/cache" + "github.com/gemaraproj/grcli/internal/cache" ) // refBearingCatalogYAML is a ControlCatalog whose `imports` resolves reference diff --git a/cmd/regtoken.go b/cmd/regtoken.go index ca83694..8c7c8b9 100644 --- a/cmd/regtoken.go +++ b/cmd/regtoken.go @@ -6,7 +6,7 @@ import ( "context" "os" - "github.com/revanite-io/grcli/internal/hub" + "github.com/gemaraproj/grcli/internal/hub" ) // ensureRegistryToken makes grcli authenticate to the bearer-auth diff --git a/cmd/unpack.go b/cmd/unpack.go index d17ddfe..e0842e2 100644 --- a/cmd/unpack.go +++ b/cmd/unpack.go @@ -18,11 +18,11 @@ import ( "github.com/spf13/cobra" "github.com/spf13/viper" - "github.com/revanite-io/grcli/internal/cache" - "github.com/revanite-io/grcli/internal/hub" - "github.com/revanite-io/grcli/internal/refs" - "github.com/revanite-io/grcli/internal/registry" - "github.com/revanite-io/grcli/internal/sigverify" + "github.com/gemaraproj/grcli/internal/cache" + "github.com/gemaraproj/grcli/internal/hub" + "github.com/gemaraproj/grcli/internal/refs" + "github.com/gemaraproj/grcli/internal/registry" + "github.com/gemaraproj/grcli/internal/sigverify" ) const ( diff --git a/cmd/verify.go b/cmd/verify.go index 27555c6..3c4ae23 100644 --- a/cmd/verify.go +++ b/cmd/verify.go @@ -17,10 +17,10 @@ import ( "github.com/revanite-io/grc-store-protocol/identity" - "github.com/revanite-io/grcli/internal/hub" - "github.com/revanite-io/grcli/internal/registry" - "github.com/revanite-io/grcli/internal/sign" - "github.com/revanite-io/grcli/internal/sigverify" + "github.com/gemaraproj/grcli/internal/hub" + "github.com/gemaraproj/grcli/internal/registry" + "github.com/gemaraproj/grcli/internal/sign" + "github.com/gemaraproj/grcli/internal/sigverify" ) // Flag names specific to verify. flagURL / flagRepository / diff --git a/cmd/verify_test.go b/cmd/verify_test.go index dec944e..403d877 100644 --- a/cmd/verify_test.go +++ b/cmd/verify_test.go @@ -16,7 +16,7 @@ import ( "github.com/spf13/viper" "github.com/stretchr/testify/require" - "github.com/revanite-io/grcli/internal/sigverify" + "github.com/gemaraproj/grcli/internal/sigverify" ) // fakeCosignVersion puts a cosign on PATH that answers `cosign version[ --json]` diff --git a/cmd/versions.go b/cmd/versions.go index bd43d79..b946ca1 100644 --- a/cmd/versions.go +++ b/cmd/versions.go @@ -12,7 +12,7 @@ import ( "github.com/spf13/cobra" "github.com/spf13/viper" - "github.com/revanite-io/grcli/internal/hub" + "github.com/gemaraproj/grcli/internal/hub" ) const flagLatest = "latest" diff --git a/cmd/versions_test.go b/cmd/versions_test.go index c7fa836..30818d8 100644 --- a/cmd/versions_test.go +++ b/cmd/versions_test.go @@ -11,7 +11,7 @@ import ( "github.com/stretchr/testify/require" - "github.com/revanite-io/grcli/internal/hub" + "github.com/gemaraproj/grcli/internal/hub" ) // catalogBodyTwoReleases is the happy-path JSON the hub returns for a diff --git a/examples/github-actions/publish.yml b/examples/github-actions/publish.yml index f428986..62adcfe 100644 --- a/examples/github-actions/publish.yml +++ b/examples/github-actions/publish.yml @@ -40,7 +40,7 @@ jobs: - uses: oras-project/setup-oras@38de303aac69abb66f3e6255b7198bff35f323e3 - name: Install grcli run: | - oras pull ghcr.io/gemaraproj/grcli:v0.6.0 --platform linux/amd64 + oras pull ghcr.io/gemaraproj/grcli:v0.7.0 --platform linux/amd64 sudo install grcli /usr/local/bin/grcli # NO cosign step, and that is the point. As of v0.6.0 keyless publish @@ -53,8 +53,7 @@ jobs: # # Pin a tag, never `:latest`. v0.5.1 shipped a broken signing path and # anything tracking `:latest` inherited it; the publishers that pinned - # were unaffected. Note the org move: v0.6.0+ live at - # ghcr.io/gemaraproj/grcli, tags <= v0.5.1 at ghcr.io/revanite-io/grcli. + # were unaffected. # --license is REQUIRED (ADR-0037): an SPDX expression naming the terms # this catalog is published under. grcli fails before any network call diff --git a/go.mod b/go.mod index 8ed473e..0bf943f 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module github.com/revanite-io/grcli +module github.com/gemaraproj/grcli go 1.26.0 diff --git a/internal/provenance/provenance_test.go b/internal/provenance/provenance_test.go index b37eee1..89fd04e 100644 --- a/internal/provenance/provenance_test.go +++ b/internal/provenance/provenance_test.go @@ -53,7 +53,7 @@ func TestBuild_BasicShape(t *testing.T) { func TestBuild_GitHubActions_BuilderIDIsRunURL(t *testing.T) { t.Setenv("GITHUB_ACTIONS", "true") t.Setenv("GITHUB_SERVER_URL", "https://github.com") - t.Setenv("GITHUB_REPOSITORY", "revanite-io/grcli") + t.Setenv("GITHUB_REPOSITORY", "gemaraproj/grcli") t.Setenv("GITHUB_RUN_ID", "42") t.Setenv("GITHUB_RUN_ATTEMPT", "1") @@ -62,7 +62,7 @@ func TestBuild_GitHubActions_BuilderIDIsRunURL(t *testing.T) { StartedOn: time.Now().UTC(), }) require.Equal(t, - "https://github.com/revanite-io/grcli/actions/runs/42", + "https://github.com/gemaraproj/grcli/actions/runs/42", p.RunDetails.Builder.ID) require.Equal(t, "42-1", p.RunDetails.Metadata.InvocationID) } diff --git a/internal/registry/registry.go b/internal/registry/registry.go index 4adc283..d86a5c0 100644 --- a/internal/registry/registry.go +++ b/internal/registry/registry.go @@ -30,7 +30,7 @@ import ( "oras.land/oras-go/v2/registry/remote/credentials" "oras.land/oras-go/v2/registry/remote/retry" - "github.com/revanite-io/grcli/internal/digest" + "github.com/gemaraproj/grcli/internal/digest" ) // PackInput is the data registry.Pack needs to build the bundle. diff --git a/internal/registry/registry_test.go b/internal/registry/registry_test.go index 1357958..f7a7af2 100644 --- a/internal/registry/registry_test.go +++ b/internal/registry/registry_test.go @@ -15,7 +15,7 @@ import ( "oras.land/oras-go/v2/content/memory" "oras.land/oras-go/v2/registry" - "github.com/revanite-io/grcli/internal/digest" + "github.com/gemaraproj/grcli/internal/digest" ) // pushBlob pushes raw bytes with the given media type and returns its descriptor. diff --git a/internal/sign/sign.go b/internal/sign/sign.go index 8c23c05..46baed8 100644 --- a/internal/sign/sign.go +++ b/internal/sign/sign.go @@ -20,7 +20,7 @@ import ( "golang.org/x/mod/semver" - "github.com/revanite-io/grcli/internal/registry" + "github.com/gemaraproj/grcli/internal/registry" ) // Mode reports how sign() resolved its trust material. diff --git a/internal/sigverify/roundtrip_test.go b/internal/sigverify/roundtrip_test.go index 779fb18..12cbf3d 100644 --- a/internal/sigverify/roundtrip_test.go +++ b/internal/sigverify/roundtrip_test.go @@ -8,7 +8,7 @@ import ( "github.com/sigstore/sigstore-go/pkg/testing/ca" "github.com/stretchr/testify/require" - "github.com/revanite-io/grcli/internal/sign" + "github.com/gemaraproj/grcli/internal/sign" ) // TestVerifyEntity_AcceptsInTotoDSSE is the ADR-0049 sign→verify round-trip: it diff --git a/internal/source/source.go b/internal/source/source.go index afe9e1f..385bb81 100644 --- a/internal/source/source.go +++ b/internal/source/source.go @@ -15,7 +15,7 @@ import ( "github.com/gemaraproj/go-gemara/fetcher" "sigs.k8s.io/yaml" - "github.com/revanite-io/grcli/internal/digest" + "github.com/gemaraproj/grcli/internal/digest" ) // Loaded is the result of merging the provided input files into a single diff --git a/main.go b/main.go index df2406b..eb1eed1 100644 --- a/main.go +++ b/main.go @@ -6,7 +6,7 @@ import ( "fmt" "os" - "github.com/revanite-io/grcli/cmd" + "github.com/gemaraproj/grcli/cmd" ) func main() { From 4ec2c39b41afecde1847dbff7a9f659f603a4858 Mon Sep 17 00:00:00 2001 From: Eddie Knight Date: Thu, 3 Sep 2026 14:51:16 -0500 Subject: [PATCH 4/9] Restart versioning at v0.1.0; drop prior-lineage history Pins, workflow defaults and docs now target v0.1.0. CHANGELOG is reset to a single 0.1.0 entry describing current capabilities, and the comments that cited earlier releases as history are reworded. Signed-off-by: Eddie Knight --- .github/actions/install/action.yml | 2 +- .github/workflows/publish-gemara.yml | 4 +- .github/workflows/release.yml | 2 +- CHANGELOG.md | 242 +++------------------------ CLAUDE.md | 4 +- README.md | 2 +- cmd/integration_test.go | 4 +- cmd/publish.go | 2 +- examples/github-actions/publish.yml | 9 +- internal/registry/registry_test.go | 5 +- 10 files changed, 37 insertions(+), 239 deletions(-) diff --git a/.github/actions/install/action.yml b/.github/actions/install/action.yml index 00b8266..5ec093b 100644 --- a/.github/actions/install/action.yml +++ b/.github/actions/install/action.yml @@ -6,7 +6,7 @@ description: >- inputs: version: description: >- - Version tag to install from ghcr.io/gemaraproj/grcli, e.g. v0.7.0, + Version tag to install from ghcr.io/gemaraproj/grcli, e.g. v0.1.0, or "latest". required: false default: latest diff --git a/.github/workflows/publish-gemara.yml b/.github/workflows/publish-gemara.yml index 995bf21..f577ea9 100644 --- a/.github/workflows/publish-gemara.yml +++ b/.github/workflows/publish-gemara.yml @@ -27,7 +27,7 @@ # permissions: # contents: read # id-token: write # caller MUST grant this — it's what auth uses -# uses: gemaraproj/grcli/.github/workflows/publish-gemara.yml@v0.7.0 +# uses: gemaraproj/grcli/.github/workflows/publish-gemara.yml@v0.1.0 # with: # files: controls.yaml # license: Apache-2.0 @@ -54,7 +54,7 @@ on: description: 'grcli release tag to install from ghcr.io/gemaraproj/grcli.' required: false type: string - default: v0.7.0 + default: v0.1.0 jobs: publish: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 50c83af..0272b99 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -60,7 +60,7 @@ jobs: run: | set -euo pipefail ldflags="-s -w -X github.com/gemaraproj/grcli/cmd.version=${VERSION}" - artifact_type="application/vnd.revanite.grcli.binary" + artifact_type="application/vnd.gemaraproj.grcli.binary" platforms="linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64" children=() diff --git a/CHANGELOG.md b/CHANGELOG.md index bb6c4a5..ea42387 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,227 +3,29 @@ Notable changes to `grcli`. This project is pre-1.0; while on `v0.x`, a breaking change bumps the minor version. -## [Unreleased] +## [0.1.0] - Unreleased -### Changed - -- **Go module path renamed to `github.com/gemaraproj/grcli`.** This repo now - supersedes `revanite-io/grcli` entirely; every workflow, example and doc - points at `github.com/gemaraproj/grcli` / `ghcr.io/gemaraproj/grcli`. - -## [0.6.0] - 2026-08-19 - -> **Live CI smoke PASSED 2026-08-19** — the gate this release was held behind. -> A real keyless publish ran from a runner with **no cosign installed** -> (`eddie-knight/security-baseline` → preview hub), and the zero-flag verify -> resolved the signature against the hub-recorded signer identity -> `keyless:…#https://github.com/eddie-knight/security-baseline/.github/workflows/publish.yaml`. -> That exercised the Fulcio, Rekor and GitHub-OIDC legs end to end for the -> first time — none of which can be reached offline. -> -> **The repo also moved orgs after v0.5.1**: v0.6.0+ publish to -> `ghcr.io/gemaraproj/grcli`; tags ≤ v0.5.1 remain at -> `ghcr.io/revanite-io/grcli` and are not re-published. - -### Changed - -- **Keyless publish signing runs IN-PROCESS via `sigstore-go`; `cosign` is no - longer required for CI publishing (ADR-0049).** `grcli publish` in GitHub - Actions now requests the OIDC token itself, obtains a Fulcio certificate, - signs the manifest digest (a DSSE-wrapped in-toto statement, byte-shaped like - `cosign sign --new-bundle-format`), logs it in Rekor, and attaches the bundle - as an OCI referrer — all with the library grcli already uses to *verify* - (ADR-0046), so publishing needs no external tools. This removes the cosign - version-band fragility entirely (the `--new-bundle-format` gating, the 2.6.0 - floor, and the 2.4–2.5 dead-zone that broke publishing). `cosign` remains a - prerequisite **only** for `--cosign-key` (key-based) signing and - `verify --cosign-key`. Air-gapped/private-Sigstore signing: point - `GRCLI_FULCIO_URL` / `GRCLI_REKOR_URL` at your instance. - -### Fixed - -- **In-process keyless signing could not attach its signature referrer at all.** - The referrer manifest was packed with artifactType - `https://sigstore.dev/cosign/sign/v1` — a URL, not an RFC 6838 media type — so - `oras.PackManifest` refused it before any network I/O and every keyless - publish died with `invalid artifactType format: … : invalid media type`. The - referrer is now stamped `application/vnd.dev.sigstore.bundle.v0.3+json`, which - is both the semantically correct type (grcli's signer emits a v0.3 bundle, so - it follows the bundle-by-default signer line) and the maximally compatible one - (hubs predating the both-types ingest fix accepted only that stamp). Signature - *discovery* is unchanged and still accepts both stamp variants — cosign 2.6.x - legitimately signs with the URL form; only the write side was ever broken. - (Found by the first real keyless CI publish, 2026-08-19.) - -## [0.5.0] - 2026-07-10 - -### Changed - -- **BREAKING: `unpack` verifies the artifact's signature by default and fails - closed (ADR-0048).** A remote (`--url`) unpack now discovers the Sigstore - signature and verifies it in-process BEFORE writing anything — the same check - as `grcli verify` (zero-flag against the identity the hub recorded at ingest, - or `--certificate-identity` to assert the signer yourself and bypass the hub). - An unsigned, mis-signed, or unverifiable artifact is refused and **no files are - written**. Pass `--no-verify` to write without verifying (INSECURE); a local - `--source` layout has no registry signature and is always written unverified. - - *Migration:* scripts that unpacked unsigned/legacy content now fail until they - pass `--no-verify` or the content is re-published signed (same migration class - as the earlier signature-format cutovers). - - *Offline note:* a cached unpack is no longer fully offline — verification - contacts the hub/registry even on a content cache hit. Use `--no-verify` for - the previous offline-from-cache behavior. - -### Fixed - -- **`verify` now discovers signatures attached by cosign 3.x.** The referrer - artifactType a cosign-signed catalog carries depends on the signer's cosign - major version: 2.6.x (`--new-bundle-format`) stamps - `https://sigstore.dev/cosign/sign/v1`, while 3.x (bundle by default) stamps - `application/vnd.dev.sigstore.bundle.v0.3+json` — the bundle inside is - identical. grcli filtered on the 2.6.x value only, so a cosign-3.x-signed - catalog verified as "no signature attached". Discovery now accepts both - stamp variants. (Found by the first live zero-flag verify against preview, - 2026-07-07; supersedes the protocol's "do not cross these" mediatype rule, - whose premise predates cosign 3.x.) -- **The cosign floor for `publish` signing is ≥ 2.6.0, not ≥ 2.4.0 as v0.4.1 - claimed.** cosign added `--new-bundle-format` to `verify` in 2.4.0 but to - `sign` only in **2.6.0** (confirmed against the release tags' source), so on - cosign 2.4.x–2.5.x v0.4.1 still died mid-publish on the raw - `unknown flag: --new-bundle-format` its version gate was built to prevent — - caught live by a CI publish pinned to cosign v2.5.2. The gate now fails fast - below 2.6.0 with the corrected floor in the message; cosign ≥ 3.x is - unaffected (the flag is omitted there entirely). - -## [0.4.1] - 2026-07-05 - -### Fixed - -- **`publish` signing no longer hard-codes `--new-bundle-format`, so it works - across the whole supported cosign range instead of a narrow band.** grcli now - detects the cosign version (`cosign version --json`) and selects the Sigstore - bundle-format flag accordingly: it passes `--new-bundle-format` on cosign - 2.4.0–2.x (where the flag is first-class), and omits it on cosign ≥ 3.0.0 - (where the bundle format is already the default and the flag is deprecated). - This removes the deprecation warning on every sign under cosign 3.x and makes - grcli forward-compatible with cosign removing the flag. A cosign **below - 2.4.0** now fails fast, before any bytes are pushed, with a clear "needs cosign - ≥ 2.4.0 — pin a newer cosign" message instead of surfacing cosign's raw - `unknown flag: --new-bundle-format`. The stated cosign prerequisite drops from - ≥ 3.x to **≥ 2.4.0**. The same version-gated helper backs the key-based - `verify --cosign-key` shell-out, so sign and verify stay a matched pair. - (Reported against v0.4.0 by the FINOS Common Cloud Controls release pipeline.) - -## [0.4.0] - 2026-07-03 - -### Changed - -- **Keyless `grcli verify` now verifies in-process — `cosign` is no longer a - consumer prerequisite** (ADR-0046). Both keyless paths (zero-flag - verify-by-coordinate and explicit `--certificate-identity`) verify with the - embedded `sigstore-go` library and the same pinned trust root + policy the hub - uses (Rekor inclusion, observer timestamps, SCTs required), enforcing the - expected signer identity in the verification policy. The signature is - discovered in-process as an OCI referrer of the artifact manifest, so the old - `--registry-token` subprocess plumbing is gone from the keyless paths. The - pinned Sigstore public-good `trusted_root.json` is embedded and refreshed with - each release; override it via `GRCLI_TRUSTED_ROOT` / the `trusted-root` config - key (a `trusted_root.json` path) for air-gapped or private-Sigstore - deployments. Only key-based `verify --cosign-key` still shells out to - `cosign` ≥ 3.x. Verification behavior and identity semantics are unchanged — - the same bundles that verified before verify the same way now. - -### Added - -- **`GRCLI_TRUSTED_ROOT` / `trusted-root` config key** (ADR-0046) — overrides the - embedded Sigstore trust root with a `trusted_root.json` read from disk, for - air-gapped deployments or a private Sigstore instance. Unset, keyless verify - uses grcli's pinned embedded public-good root. - -### Changed — BREAKING - -- **The per-project `./.grcli.yaml` config layer is removed (ADR-0044).** Config - now resolves from `--flag` > `GRCLI_*` env > user-global - `~/.config/grcli/config.yaml` > built-in default; the repo-local file is no - longer read. A committed config file must not be able to steer where a - publish/verify tool talks. **Migration:** move any settings from - `./.grcli.yaml` to `~/.config/grcli/config.yaml` — a lingering `./.grcli.yaml` - prints a warning until removed. - -### Added - -- **`grcli verify` gains zero-flag verify-by-coordinate** (ADR-0045). Run - `grcli verify --repository / --version ` with **no trust flags** and - grcli fetches the catalog record from the hub, reads the keyless signer - identity the hub verified and pinned at ingest, and verifies against it — so a - consumer needs no prior knowledge of the publishing workflow. The identity, and - that it came from the hub record, are printed before verification runs (trust - in the hub is visible, never silent). The ref-stripped pin is matched with an - anchored SAN regexp `'^@'`, admitting any git ref of - that exact workflow but nothing wider. If the hub has no recorded - identity (an artifact predating hub-side verification), verify fails with a - clear pointer to the explicit flags. Passing `--cosign-key` or - `--certificate-identity` bypasses the hub lookup entirely — the independent, - high-assurance path — unchanged (including ADR-0044's issuer default). -- **`grcli verify` defaults `--certificate-oidc-issuer` to - `https://token.actions.githubusercontent.com`** (ADR-0044). Keyless - verification of a GitHub-Actions-signed bundle then needs only - `--certificate-identity`. Override the issuer via the flag, the - `GRCLI_CERTIFICATE_OIDC_ISSUER` env, or the user-global config for GitHub - Enterprise, another CI provider, or an OIDC proxy. verify still checks the - issuer, so a wrong value fails closed (it rejects, never falsely accepts). - -## [0.3.0] - 2026-07-02 +First release of `grcli` under `github.com/gemaraproj/grcli`, published as a +signed multi-platform OCI artifact at `ghcr.io/gemaraproj/grcli`. ### Added -- **`grcli cat`** — prints an artifact's Gemara content to stdout without writing - files, the read-only companion to `unpack`. Emits the artifact file(s) only - (never `bundle.json`/manifest/provenance); a single-file bundle prints verbatim, - a multi-file bundle as a `---`-separated YAML stream (`--file ` selects - one). Diagnostics go to stderr, so `grcli cat … | yq …` stays pipe-clean. -- **On-disk artifact cache for remote fetches.** A remote (`--url`) `unpack`/`cat` - of a `namespace/id/version` stores the whole bundle at `$GRCLI_CACHE` (default - `os.UserCacheDir()/grcli`); repeat fetches — and references to the same - coordinate — are served offline (immutable tags make a hit always fresh). - `--no-cache` bypasses it for one run; no eviction/GC yet. -- **User-global config file** `$XDG_CONFIG_HOME/grcli/config.yaml` - (`~/.config/grcli/config.yaml`), merged **under** the per-project `./.grcli.yaml`. - New key `cache-enabled: false` (`GRCLI_CACHE_ENABLED`) durably disables the cache. - -### Changed — BREAKING - -- **`unpack` now consults the cache for the primary artifact.** A remote `unpack` - that previously always hit the network now serves a warm coordinate from the - cache (and skips discovery entirely on a hit). Use `--no-cache` for the old - always-fresh behavior. -- **Resolved references are now written as a directory, not a flat file.** - `--with-imports`/`--with-references` previously wrote each reference as - `references///@.json` (the hub's JSON projection); it - is now a directory `references///@/` containing the - artifact's original YAML file(s) + `bundle.json`, and `references/index.json`'s - `path` points at that directory. -- **Config precedence changed and `$HOME/.grcli.yaml` is dropped.** Config is now - layered (flag > `GRCLI_*` env > project `./.grcli.yaml` > user-global - `config.yaml` > default) with the project file merged over the global one, - replacing first-match-wins. The old home-root dotfile `~/.grcli.yaml` is no - longer read — **move it to `~/.config/grcli/config.yaml`.** (The previously - *advertised-but-nonfunctional* `$XDG_CONFIG_HOME/grcli/config.yaml` now works.) - -- **Catalog signatures now use the Sigstore bundle format** — `grcli` signs (and - verifies) with cosign's `--new-bundle-format`, attaching the signature as an OCI - 1.1 referrer of the bundle, instead of the legacy tag-based `sha256-….sig`. This - converges grc.store on one signature format (the hub's plugin path already uses - it). - - **Migration:** a catalog signed by this version will **not** verify with an - older `grcli verify`, and a catalog signed by an older `grcli` will **not** - verify with this version. **Re-publish existing catalogs to re-sign them in the - bundle format.** - - To verify a catalog manually, use `cosign verify --new-bundle-format …` (not - bare `cosign verify`). - - **New requirement:** `cosign` >= 3.x on `PATH`. - -### Internal - -- Adopt the shared `github.com/revanite-io/grc-store-protocol` module for the - discovery / sync / registry-token wire types (no behavior change; wire-identical). +- `login` / `logout` — OIDC device-flow sign-in to a hub, credentials stored + at `$XDG_DATA_HOME/grcli/credentials.json`. +- `validate` — check Gemara YAML against the spec via `cue vet`. +- `publish` — pack an artifact plus SLSA-shaped provenance into an OCI bundle, + sign it, push it, and notify the hub. `--license` (an SPDX expression) is + required. Keyless signing runs in-process via `sigstore-go` using the + GitHub Actions OIDC token; `cosign` is needed only for `--cosign-key`. +- `verify` — verify a bundle's Sigstore signature in-process; with no trust + flags, against the signer identity the hub recorded at ingest. +- `unpack` — verify (fail-closed; `--no-verify` to skip) then extract a bundle + to a directory from a registry or OCI layout. +- `cat` — stream an artifact's Gemara content to stdout without writing files. +- `versions /` — list published versions. +- On-disk cache for remote fetches at `$GRCLI_CACHE`; `--no-cache` per run, + `cache-enabled: false` to disable. +- Single user-global config at `$XDG_CONFIG_HOME/grcli/config.yaml`, with + `GRCLI_*` env overrides and `--config ` to bypass. +- Reusable GitHub Actions workflow (`.github/workflows/publish-gemara.yml`) + and install action (`.github/actions/install`). diff --git a/CLAUDE.md b/CLAUDE.md index 150195f..7965811 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,9 +2,7 @@ Go CLI and **primary end-user surface** for grc.store: validates Gemara YAML, packs it into signed OCI bundles with SLSA-shaped provenance, publishes to a hub, and verifies bundles. -Go module: `github.com/gemaraproj/grcli`. Repo: `github.com/gemaraproj/grcli`; releases publish -to `ghcr.io/gemaraproj/grcli`. This repo supersedes the earlier `revanite-io/grcli` repo and -registry entirely. +Go module and repo: `github.com/gemaraproj/grcli`; releases publish to `ghcr.io/gemaraproj/grcli`. `README.md` covers install (via `oras`), the full usage flow, and CI/trusted-publishing; `CHANGELOG.md` tracks the pre-1.0 breaking changes. This file is the map — point there, don't duplicate. diff --git a/README.md b/README.md index ae79a07..de4ba1c 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ In GitHub Actions: sudo install grcli /usr/local/bin/grcli ``` -Pin a release tag (`:v0.7.0`) instead of `latest` for reproducible +Pin a release tag (`:v0.1.0`) instead of `latest` for reproducible installs. To verify the signature before installing: ```sh diff --git a/cmd/integration_test.go b/cmd/integration_test.go index effbc0f..6b39c2b 100644 --- a/cmd/integration_test.go +++ b/cmd/integration_test.go @@ -213,10 +213,10 @@ func TestPublish_License_LicenseRef_Accepted(t *testing.T) { input := writeTempFile(t, workdir, "policy.yaml", policyYAML) layout := filepath.Join(workdir, "layout") - runRoot(t, "publish", "--dry-run", "-f", input, "--output", layout, "--license", "LicenseRef-Revanite-Proprietary") + runRoot(t, "publish", "--dry-run", "-f", input, "--output", layout, "--license", "LicenseRef-Acme-Proprietary") ann := readOCIManifestAnnotations(t, layout) - require.Equal(t, "LicenseRef-Revanite-Proprietary", ann["org.opencontainers.image.licenses"], + require.Equal(t, "LicenseRef-Acme-Proprietary", ann["org.opencontainers.image.licenses"], "a LicenseRef- token must be accepted and stamped as the OCI license annotation") } diff --git a/cmd/publish.go b/cmd/publish.go index e59a729..fa3e499 100644 --- a/cmd/publish.go +++ b/cmd/publish.go @@ -82,7 +82,7 @@ need to set a secret.`, flags.String(flagOutput, "grcli-out", "directory to write the OCI layout to when --dry-run") flags.Bool(flagNoSign, false, "skip cosign signing even when material is available") flags.String(flagCosignKey, "", "cosign key file for local signing (or COSIGN_KEY)") - flags.String(flagLicense, "", "REQUIRED: publication license as an SPDX expression (e.g. Apache-2.0, MIT OR Apache-2.0, LicenseRef-Revanite-Proprietary); stamped as the org.opencontainers.image.licenses OCI annotation. Publish fails before any network call if unset (ADR-0037)") + flags.String(flagLicense, "", "REQUIRED: publication license as an SPDX expression (e.g. Apache-2.0, MIT OR Apache-2.0, LicenseRef-Acme-Proprietary); stamped as the org.opencontainers.image.licenses OCI annotation. Publish fails before any network call if unset (ADR-0037)") // Flags are bound to viper inside RunE (see runPublish) rather than // here at construction time. Two subcommands sharing a viper instance diff --git a/examples/github-actions/publish.yml b/examples/github-actions/publish.yml index 62adcfe..1c89cba 100644 --- a/examples/github-actions/publish.yml +++ b/examples/github-actions/publish.yml @@ -40,10 +40,10 @@ jobs: - uses: oras-project/setup-oras@38de303aac69abb66f3e6255b7198bff35f323e3 - name: Install grcli run: | - oras pull ghcr.io/gemaraproj/grcli:v0.7.0 --platform linux/amd64 + oras pull ghcr.io/gemaraproj/grcli:v0.1.0 --platform linux/amd64 sudo install grcli /usr/local/bin/grcli - # NO cosign step, and that is the point. As of v0.6.0 keyless publish + # NO cosign step, and that is the point. Keyless publish # signing runs IN-PROCESS via sigstore-go (ADR-0049): grcli requests the # Actions OIDC token itself, gets a Fulcio certificate, logs to Rekor and # attaches the signature as an OCI referrer — no external tools, no @@ -51,9 +51,8 @@ jobs: # no cosign on PATH. cosign is still needed ONLY for the `--cosign-key` # (key-based) path, which this example does not use. # - # Pin a tag, never `:latest`. v0.5.1 shipped a broken signing path and - # anything tracking `:latest` inherited it; the publishers that pinned - # were unaffected. + # Pin a tag, never `:latest`, so a bad release cannot reach you + # before you choose to upgrade. # --license is REQUIRED (ADR-0037): an SPDX expression naming the terms # this catalog is published under. grcli fails before any network call diff --git a/internal/registry/registry_test.go b/internal/registry/registry_test.go index f7a7af2..73d0993 100644 --- a/internal/registry/registry_test.go +++ b/internal/registry/registry_test.go @@ -71,12 +71,11 @@ func attachSignature(t *testing.T, store *memory.Store, subject ocispec.Descript } // TestPackSignatureReferrer_StampsSigstoreBundle exercises the real pack/attach -// path. It is the regression guard for the v0.5.1 keyless-publish failure: the +// path. It is the regression guard for a keyless-publish failure where the // referrer was packed with artifactType mediatype.CosignSignReferrer, which is // a URL rather than an RFC 6838 media type, so oras.PackManifest rejected it // ("invalid artifactType format") before any network I/O — deterministically, -// on every keyless publish (eddie-knight/gemara-asset-mirror @ 2ee9a5e, -// 2026-08-19). Asserting the stamp is SigstoreBundle also pins the write side +// on every keyless publish. Asserting the stamp is SigstoreBundle also pins the write side // to the type every hub generation accepts at ingest. func TestPackSignatureReferrer_StampsSigstoreBundle(t *testing.T) { store := memory.New() From df692dc68bdb40ad244d6a1f354e23894a75cc86 Mon Sep 17 00:00:00 2001 From: Eddie Knight Date: Thu, 3 Sep 2026 15:12:49 -0500 Subject: [PATCH 5/9] remove old ADR comments Signed-off-by: Eddie Knight --- .github/workflows/publish-gemara.yml | 2 +- CLAUDE.md | 14 +-- IMPLEMENTATION.md | 122 --------------------------- README.md | 30 +++---- cmd/cat.go | 4 +- cmd/cat_test.go | 2 +- cmd/config_test.go | 6 +- cmd/fetch.go | 12 +-- cmd/fetch_test.go | 4 +- cmd/integration_test.go | 18 ++-- cmd/publish.go | 35 ++++---- cmd/publish_test.go | 4 +- cmd/references_test.go | 2 +- cmd/regtoken.go | 2 +- cmd/root.go | 16 ++-- cmd/unpack.go | 40 ++++----- cmd/unpack_test.go | 2 +- cmd/urldefault.go | 2 +- cmd/verify.go | 36 ++++---- cmd/verify_test.go | 12 +-- examples/github-actions/publish.yml | 6 +- internal/cache/cache.go | 10 +-- internal/cache/cache_test.go | 2 +- internal/hub/discover.go | 2 +- internal/hub/hub.go | 6 +- internal/hub/hub_test.go | 2 +- internal/hub/regtoken.go | 2 +- internal/refs/refs.go | 6 +- internal/registry/registry.go | 16 ++-- internal/sign/keyless.go | 4 +- internal/sign/keyless_test.go | 2 +- internal/sign/sign.go | 11 ++- internal/sign/sign_test.go | 8 +- internal/sigverify/roundtrip_test.go | 2 +- internal/sigverify/verify.go | 17 ++-- internal/sigverify/verify_test.go | 2 +- 36 files changed, 168 insertions(+), 295 deletions(-) delete mode 100644 IMPLEMENTATION.md diff --git a/.github/workflows/publish-gemara.yml b/.github/workflows/publish-gemara.yml index f577ea9..01c9758 100644 --- a/.github/workflows/publish-gemara.yml +++ b/.github/workflows/publish-gemara.yml @@ -5,7 +5,7 @@ # workflow publishes any Gemara type. You supply the file(s), the license, and # optionally the hub URL. # -# === AUTH: NO SECRET REQUIRED (ADR-0032 trusted publishing) === +# === AUTH: NO SECRET REQUIRED (trusted publishing) === # grcli uses the workflow's GitHub Actions OIDC token as its hub credential and # for cosign keyless signing. `id-token: write` is the entire auth setup — do # NOT add GRCLI_TOKEN, a PAT, or any `secrets.*` reference. diff --git a/CLAUDE.md b/CLAUDE.md index 7965811..f3b56dd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,10 +18,10 @@ Go module and repo: `github.com/gemaraproj/grcli`; releases publish to `ghcr.io/ ## Commands (`cmd/`) `login`/`logout` (OIDC device flow, credential storage) · `validate` (YAML vs Gemara spec via `cue vet`) · `publish` (pack + sign + push OCI bundle) · `verify` (cosign / Sigstore bundle; -zero-flag mode verifies against the hub-recorded signer identity, ADR-0045) · +zero-flag mode verifies against the hub-recorded signer identity) · `unpack` (verify signature fail-closed — `--no-verify`/`--source` skip — then extract to a directory -from OCI layout or registry; ADR-0048 reuses verify's policy path) · `cat` (stream Gemara content to -stdout, no files — read-only companion to `unpack`, ADR-0042) · `versions /` (list +from OCI layout or registry; reuses verify's policy path) · `cat` (stream Gemara content to +stdout, no files — read-only companion to `unpack`) · `versions /` (list published versions). Registered in `cmd/root.go`; one file per command (`publish.go`, `verify.go`, …). `unpack` and `cat` share the cache-checking fetch stage in `fetch.go` (`resolveBundle`) and differ only in the last mile. (`regtoken.go` is an internal helper — `ensureRegistryToken()` — not @@ -31,17 +31,17 @@ a user command.) `hub` (`/v1/bundles/sync`, `/v2/token`, discovery) · `registry` (OCI packing via `oras-go`) · `sign` (cosign shell-out, Sigstore bundle) · `provenance` (SLSA v1.0 predicate) · `source` (load/merge/verify YAML) · `refs` · `digest` (SHA256) · `cache` (immutable-tag disk cache — v2 stores the whole bundle: files + -`bundle.json`, ADR-0042). Imports `grc-store-protocol` (discovery, syncapi, registrytoken, spdx). +`bundle.json`). Imports `grc-store-protocol` (discovery, syncapi, registrytoken, spdx). **Auth is no longer here**: `internal/auth` (device flow, credential store, token resolution, GHA OIDC) was deleted in favour of `github.com/gemaraproj/grc-store-clientkit/auth`, shared with privateer-sdk. `cmd.grcliApp` (`cmd/root.go`) is the per-tool identity that keeps grcli's own credential file and login hints — pass it to every clientkit auth call. ## Gotchas -- **External tools on PATH**: `cosign` ≥ 2.6.0 is needed **only** for key-based signing (`publish --cosign-key`) and key-based `verify --cosign-key`; `cue` for `validate`. **Keyless signing AND verifying are in-process — no cosign** (ADR-0049 sign + ADR-0046 verify), both via the embedded `sigstore-go`. Keyless `publish` (CI trusted publishing) requests the GHA OIDC token itself, hits Fulcio+Rekor, and attaches a DSSE in-toto bundle referrer (`internal/sign/keyless.go` + `registry.AttachSignatureReferrer`); override `GRCLI_FULCIO_URL`/`GRCLI_REKOR_URL` for a private Sigstore. When cosign IS used (key mode), grcli gates its bundle-format flag on the detected version (`internal/sign.BundleFormatArgs`: `--new-bundle-format` on 2.6–2.x, omitted ≥ 3.x, fail-fast below 2.6.0). The verify side mirrors the hub's `internal/sigverify`; the pinned trust root now comes from `grc-store-clientkit/trustroot` (rotate it there and re-tag the module — it is no longer vendored here), or point `GRCLI_TRUSTED_ROOT` at one. Catalog signatures are discovered as OCI referrers accepting BOTH signature stamp variants — `mediatype.CosignSignReferrer` (cosign 2.6.x) and `mediatype.SigstoreBundle` (cosign 3.x default) — since the stamp follows the signer's cosign major, not the artifact kind (the old "do not cross these" rule predated cosign 3.x; see the mediatype doc). OCI transport uses the `oras-go` **library**, not the `oras` CLI — `oras` is only needed to *install* grcli (see README), not to run it. +- **External tools on PATH**: `cosign` ≥ 2.6.0 is needed **only** for key-based signing (`publish --cosign-key`) and key-based `verify --cosign-key`; `cue` for `validate`. **Keyless signing AND verifying are in-process — no cosign**, both via the embedded `sigstore-go`. Keyless `publish` (CI trusted publishing) requests the GHA OIDC token itself, hits Fulcio+Rekor, and attaches a DSSE in-toto bundle referrer (`internal/sign/keyless.go` + `registry.AttachSignatureReferrer`); override `GRCLI_FULCIO_URL`/`GRCLI_REKOR_URL` for a private Sigstore. When cosign IS used (key mode), grcli gates its bundle-format flag on the detected version (`internal/sign.BundleFormatArgs`: `--new-bundle-format` on 2.6–2.x, omitted ≥ 3.x, fail-fast below 2.6.0). The verify side mirrors the hub's `internal/sigverify`; the pinned trust root now comes from `grc-store-clientkit/trustroot` (rotate it there and re-tag the module — it is no longer vendored here), or point `GRCLI_TRUSTED_ROOT` at one. Catalog signatures are discovered as OCI referrers accepting BOTH signature stamp variants — `mediatype.CosignSignReferrer` (cosign 2.6.x) and `mediatype.SigstoreBundle` (cosign 3.x default) — since the stamp follows the signer's cosign major, not the artifact kind (the old "do not cross these" rule predated cosign 3.x; see the mediatype doc). OCI transport uses the `oras-go` **library**, not the `oras` CLI — `oras` is only needed to *install* grcli (see README), not to run it. - **Signing is on by default**; `--no-sign` to opt out. -- **Caching (ADR-0042)**: remote `unpack`/`cat` fetches (and resolved references) are served from a global on-disk cache at `$GRCLI_CACHE` (default `os.UserCacheDir()/grcli`); a hit needs no network for the *content* (immutable tags → never stale). No GC yet. `--no-cache` per run; `cache-enabled: false` to disable durably. Caveat (ADR-0048): a default `unpack` still hits the hub/registry to *verify* even on a content cache hit — `--no-verify` restores a fully offline hit. -- Config (ADR-0043, amended by ADR-0044): flag > `GRCLI_*` env > user-global `$XDG_CONFIG_HOME/grcli/config.yaml` (→ `~/.config/grcli/config.yaml`) > default. **No per-project layer** — a repo-local `./.grcli.yaml` is not read (a committed file must not steer a publish/verify tool) and earns a migration warning. `--config ` bypasses the search. The cache toggle key is flat `cache-enabled` (not nested `cache.enabled`) because `$GRCLI_CACHE` shadows the `cache.*` viper namespace. Env prefix `GRCLI_*` (e.g. `GRCLI_REGISTRY_TOKEN`, `GRCLI_GEMARA_SPEC_DIR`). `grcli verify`'s `--certificate-oidc-issuer` defaults to GitHub Actions (ADR-0044). (Neither `./.grcli.yaml` nor `$HOME/.grcli.yaml` is read.) +- **Caching**: remote `unpack`/`cat` fetches (and resolved references) are served from a global on-disk cache at `$GRCLI_CACHE` (default `os.UserCacheDir()/grcli`); a hit needs no network for the *content* (immutable tags → never stale). No GC yet. `--no-cache` per run; `cache-enabled: false` to disable durably. Caveat: a default `unpack` still hits the hub/registry to *verify* even on a content cache hit — `--no-verify` restores a fully offline hit. +- Config: flag > `GRCLI_*` env > user-global `$XDG_CONFIG_HOME/grcli/config.yaml` (→ `~/.config/grcli/config.yaml`) > default. **No per-project layer** — a repo-local `./.grcli.yaml` is not read (a committed file must not steer a publish/verify tool) and earns a migration warning. `--config ` bypasses the search. The cache toggle key is flat `cache-enabled` (not nested `cache.enabled`) because `$GRCLI_CACHE` shadows the `cache.*` viper namespace. Env prefix `GRCLI_*` (e.g. `GRCLI_REGISTRY_TOKEN`, `GRCLI_GEMARA_SPEC_DIR`). `grcli verify`'s `--certificate-oidc-issuer` defaults to GitHub Actions. (Neither `./.grcli.yaml` nor `$HOME/.grcli.yaml` is read.) - Credentials stored at `$XDG_DATA_HOME/grcli/credentials.json` (0600). - CI publishing uses GitHub Actions OIDC (`ACTIONS_ID_TOKEN_REQUEST_URL/_TOKEN`); example at `examples/github-actions/publish.yml`. - **grcli defaults to the *prod* hub** — for test publishing use `../publish-fixtures/` (forces preview). diff --git a/IMPLEMENTATION.md b/IMPLEMENTATION.md deleted file mode 100644 index 5285f8c..0000000 --- a/IMPLEMENTATION.md +++ /dev/null @@ -1,122 +0,0 @@ -# Implementation plan: `grcli cat`, primary-artifact cache, and user-global config - -Tracks the work for **ADR-0042** (`cat` + cache the primary/whole bundle) and **ADR-0043** -(user-global config file), both in `../grc.store-backend/docs/adr/`. This is grcli-only — no -backend, hub, or `grc-store-protocol` change. ADRs flip `Proposed → Accepted` on merge. - -## Decisions locked in (see the ADRs for rationale) - -- **`cat` streams Gemara content only** — the artifact file(s), which carry the Gemara - `metadata:` block. No `bundle.json`/manifest/provenance via `cat` (no `--manifest` flag); - bundle information is `unpack`'s job. -- **Cache stores the complete decoded bundle** — `bundle.json` (from `bundle.Manifest`) + every - `bundle.Files` entry, each with a per-file content digest. Not the raw OCI layout, not the - cosign signature — so `verify` still hits the network (verify-on-pull is deferred, ADR-0039). -- **`cat` and `unpack` share the fetch stage, diverge only at the last mile.** One helper does - resolve-source → cache-check → fetch-on-miss → cache-put → return the in-memory bundle. - `unpack` then writes the dir (`writeBundle`); `cat` then streams `Files`. Neither does the - other's last mile. -- **References use the same full-bundle format via the registry (ADR-0042 decision 5, option - (a)).** Reference resolution moves off `hub.GetVersionBody` onto a registry pull, so each - referenced repo needs its own pull token (`ensureRegistryToken`) and a repo-path derivation - from `{ns}/{id}`. One uniform cache format; the token/plumbing cost is accepted. -- **`--no-cache`** (hyphenated, existing flag) bypasses the cache on both commands. - **`cache-enabled`** (config, default true) is the durable off switch. `$GRCLI_CACHE` location - override is retained; there is no cache-*location* config key. -- **Config precedence via viper merge, not first-match** — flag > `GRCLI_*` env > project - `./.grcli.yaml` > user-global `$XDG_CONFIG_HOME/grcli/config.yaml` > default. Fixes the phantom - `config.yaml` path (today `loadConfig` searches `.grcli.yaml` in the XDG dir). - -## Grounding (verified against current source) - -- `registry.UnpackRemote(ctx, host, repo, tag) (*bundle.Bundle, error)` → `bundle.Unpack`; the - bundle carries `Files []File`, `Manifest`, `Etag` (OCI manifest digest). `UnpackLocal` is the - `--source` twin. (`internal/registry/registry.go`) -- `writeBundle` (`cmd/unpack.go`) builds `bundle.json` via `json.MarshalIndent(b.Manifest, …)` — - reconstructed, so the cache can store the manifest and reproduce it. -- References today: `fetchReference` (`cmd/unpack.go`) uses `hub.GetVersionBody` + - `hub.GetCatalog` (for license/manifest-digest). Option (a) replaces the body fetch with a - registry pull. -- Cache today: `internal/cache/cache.go` — `Entry{ Body []byte; … }`, single `body.` + - `meta.json`, `layoutVersion = "v1"`, host-namespaced `entryDir`. Needs the multi-file change. -- Config today: `loadConfig` (`cmd/root.go`) — `SetConfigName(".grcli")` + `AddConfigPath`, - first-match-wins (`ReadInConfig`), `GRCLI` env prefix. Needs `MergeInConfig` + explicit paths. - -## Phases - -### Phase 1 — Cache `v2` multi-file entry format *(independent; land first)* -`internal/cache/cache.go`, `internal/cache/cache_test.go` -- Replace single `Body` with a bundle entry: `Files []struct{Name, Digest string; Data []byte}` - + `Manifest []byte` (the `bundle.json` bytes) + existing `ManifestDigest`/`License`/ - `SourceURL`/`Verified`. -- On disk: `meta.json` + `bundle.json` + `files/`; per-file digest computed on `Put`, - verified on `Get` (corruption → `found=false` + error, as today). -- Bump `layoutVersion` `v1` → `v2` (no migration — `v1` dirs are simply never read). -- Tests: multi-file round-trip, per-file corruption, host-namespacing preserved, `v1` ignored. - -### Phase 2 — Shared cache-checking fetch + wire `unpack` to it *(depends on P1)* -`cmd/unpack.go` (+ maybe a small helper file) -- Add `resolveBundle(ctx, v, src, url, repo, version) (*bundle.Bundle, error)`: source - resolution → `cache.Get` → `UnpackRemote`/`UnpackLocal` on miss → `cache.Put` (remote only) → - return bundle. `--source` skips cache but flows through the helper. `--no-cache` + - `cache-enabled` gate caching inside the helper. -- `runUnpack` remote branch calls `resolveBundle` instead of `UnpackRemote` directly; last mile - stays `writeBundle`. Extend `--no-cache` to cover the primary (today it only gates references). -- Tests: cache hit avoids network, `--no-cache` bypasses, `--source` uncached, corrupt entry - re-fetches. - -### Phase 3 — `grcli cat` command *(depends on P2)* -new `cmd/cat.go`, register in `cmd/root.go` -- Flags: `--source` / `--url` + `--repository` / `--version` (reuse `unpack`'s selectors and - `suppressDefaultURLIfExplicit`), `--file `, `--no-cache`. No `--output`, no `--with-*`, - no `--manifest`. Mint an anonymous pull token for `--url` reads (as `unpack` does). -- Fetch via `resolveBundle`; last mile: single file verbatim, multi-file as `---` YAML stream, - `--file` selects one. -- Tests: `cat_test.go` (single, multi-file stream, `--file`, `--source` path, cache hit) + - integration coverage à la `cmd/integration_test.go`. - -### Phase 4 — Reference resolution onto the registry/full-bundle path *(depends on P1)* -`cmd/unpack.go` (`fetchReference`, `resolveReferences`) -- Replace the `hub.GetVersionBody` fetch with a registry pull for each reference: derive the - repository from `{ns}/{id}`, `ensureRegistryToken(..., []string{"pull"})` per referenced repo, - `UnpackRemote`, store as a `v2` entry. Keep the license/manifest-digest recording and the - license-mismatch warning. Reference *output* under `references//…` is unchanged. -- Tests: reference cache hit/miss, per-repo token minted, license warning preserved. Also close - the pre-existing zero-coverage gap on `resolveReferences`/`fetchReference` surfaced in Phase 1 - QA — this path had no tests through Phase 1 and must not land Phase 4 untested. - -### Phase 5 — User-global config *(independent; can run in parallel with P1–P4)* -`cmd/root.go` (`loadConfig`), all command RunEs -- Switch first-match to explicit merge: read user-global - `$XDG_CONFIG_HOME/grcli/config.yaml` (fallback `$HOME/.config/grcli/config.yaml`), then - `MergeInConfig` the project `./.grcli.yaml` on top; `--config` still selects a single file. - Fixes the phantom `config.yaml` path. -- Bind `cache-enabled` (default true); shared `cachingEnabled(v)` helper = - `cache-enabled && !--no-cache`, consumed by `resolveBundle` and the reference path. -- Tests: precedence (flag > env > project > global > default), `cache-enabled:false` ⇒ no cache - I/O, merge (global honored when project file present). - -### Phase 6 — Docs + ADR status -- `README.md`: `cat` command, cache behavior (`$GRCLI_CACHE`, `--no-cache`, unbounded/no-GC), - config file + precedence. -- `CLAUDE.md`: add `cat` to Commands; correct the config section (`config.yaml` is the real - user-global path; document precedence). Note the cache now covers the primary. -- `CHANGELOG.md`: breaking — `unpack` now consults a cache for the primary; `.grcli.yaml` - precedence change (global file now merges under project). -- Flip ADR-0042 / ADR-0043 to `Accepted`. - -## Sequencing & PRs -- Critical path: **P1 → P2 → P3**. **P4** depends on P1. **P5** is fully independent. -- Suggested PRs: (1) cache v2, (2) shared fetch + unpack + cat, (3) reference migration, - (4) config. Or bundle 1–3 if reviewed together. -- Gate every PR on `make ci-local` (fmtcheck + vet + lint + testcov). - -## Risks / watch-items -- **Behavior breaks (accepted, ~no users):** `unpack` primary now cached; `.grcli.yaml` no - longer shadows the home file. -- **Unbounded cache** grows faster now (primary + full-bundle references). `grcli cache clean` / - eviction remains the named ADR-0039 follow-up — out of scope here but more pressing. -- **Per-reference pull tokens (Phase 4)** add hub round-trips when resolving many references; - watch latency on large dependency sets. -- **`verify` is not cache-served** — intentional; raw-OCI-layout storage is the documented - upgrade path if offline verify is ever needed. diff --git a/README.md b/README.md index de4ba1c..5ee1283 100644 --- a/README.md +++ b/README.md @@ -45,8 +45,8 @@ Some commands shell out to external tools: (`publish --cosign-key`) and key-based `verify --cosign-key`. When cosign is used, grcli detects its version and adapts the Sigstore bundle-format flag (`--new-bundle-format` on 2.6–2.x, omitted on 3.x). **Keyless CI publishing and - all keyless `verify`/`unpack` need no external tools** — grcli signs (ADR-0049) - and verifies (ADR-0046) in-process against Sigstore, so the common path is just + all keyless `verify`/`unpack` need no external tools** — grcli signs + and verifies in-process against Sigstore, so the common path is just the `grcli` binary. https://docs.sigstore.dev/cosign/installation/ - **`cue`** on `PATH` — `validate`. https://cuelang.org - **A Gemara spec checkout** — `validate`. @@ -74,7 +74,7 @@ Run `grcli --help` for the full flag list. The typical flow is | --- | --- | | `login` | Sign in to a hub via OIDC device flow; stores tokens for `publish`. | | `validate` | Check YAML against the Gemara spec via `cue vet`. | -| `publish` | Pack an artifact + provenance into a signed OCI bundle, push it, and notify the hub. Requires `--license` (SPDX expression, ADR-0037). | +| `publish` | Pack an artifact + provenance into a signed OCI bundle, push it, and notify the hub. Requires `--license` (SPDX expression). | | `verify` | Verify a remote bundle's Sigstore signature (keyless: in-process, no cosign) — with no trust flags, against the signer identity the hub recorded at ingest. | | `unpack` | Verify a remote bundle's signature (fail-closed; `--no-verify` to skip) then write its files + manifest to disk. | | `cat` | Print an artifact's Gemara content to stdout (no files written) — for piping into `yq`. | @@ -88,7 +88,7 @@ grcli login grcli validate -f controls.yaml --spec /path/to/gemara # Publish — picks up the stored login token; signs by default. -# --license is REQUIRED (ADR-0037) and takes an SPDX expression; publish +# --license is REQUIRED and takes an SPDX expression; publish # fails before any network call without it. Use your catalog's real terms. # Locally you must also supply signing material: --cosign-key (below) or # --no-sign. Keyless signing is CI-only — see "Signing" further down. @@ -170,7 +170,7 @@ served from the cache with **no network at all**. grc.store tags are immutable, so a cache hit can never be stale. The cache lives at `$GRCLI_CACHE` (default `os.UserCacheDir()/grcli`) and grows without bound (no GC yet). Note: a default `unpack` still contacts the hub/registry to *verify* the signature even on a -content cache hit (ADR-0048); `--no-verify` restores a fully offline cache hit. +content cache hit; `--no-verify` restores a fully offline cache hit. - `--no-cache` bypasses the cache for a single run (fresh pull, nothing stored). - Set `cache-enabled: false` in config (below) to disable it durably. @@ -181,7 +181,7 @@ content cache hit (ADR-0048); `--no-verify` restores a fully offline cache hit. `grcli` reads config from, highest precedence first: a `--flag`, a `GRCLI_*` env var, and the user-global `$XDG_CONFIG_HOME/grcli/config.yaml` (falling back to `~/.config/grcli/config.yaml`). There is **no per-project layer**: a -repo-local `./.grcli.yaml` is deliberately not read (ADR-0044) — a committed +repo-local `./.grcli.yaml` is deliberately not read — a committed file must not be able to steer where a publish/verify tool talks — and a present one prints a migration warning until removed. `--config ` selects a single file and bypasses the search. @@ -198,8 +198,7 @@ Keys (env form in parentheses): `https://token.actions.githubusercontent.com`; set it only for GitHub Enterprise, another CI provider, or an OIDC proxy. - `trusted-root` (`GRCLI_TRUSTED_ROOT`) — path to a `trusted_root.json` that - overrides the embedded Sigstore public-good trust root for keyless `verify` - (ADR-0046). For air-gapped deployments or a private Sigstore instance only; + overrides the embedded Sigstore public-good trust root for keyless `verify`. For air-gapped deployments or a private Sigstore instance only; unset, grcli uses its pinned embedded root. > **Registry credentials are env-only, never config keys**: set @@ -216,7 +215,7 @@ material depends on where you run (`internal/sign.Preflight`): | Where | Signing material | cosign on `PATH`? | |---|---|---| -| GitHub Actions (`GITHUB_ACTIONS=true`) | the runner's OIDC token — needs `permissions: id-token: write` | **no** — in-process, ADR-0049 | +| GitHub Actions (`GITHUB_ACTIONS=true`) | the runner's OIDC token — needs `permissions: id-token: write` | **no** — in-process | | Anywhere else | `--cosign-key` (or `COSIGN_KEY`) | **yes**, ≥ 2.6.0 | | Either, opting out | `--no-sign` | no | @@ -230,7 +229,7 @@ signing, run in GitHub Actions with `permissions: id-token: write` for keyless signing, or pass --no-sign to publish without provenance ``` -Keyless `verify` runs **in-process** against Sigstore (ADR-0046): no `cosign`, +Keyless `verify` runs **in-process** against Sigstore: no `cosign`, no version-skew caveats, just the `grcli` binary. It embeds the pinned Sigstore public-good trust root, refreshed with each grcli release; for an air-gapped or private-Sigstore deployment, point `GRCLI_TRUSTED_ROOT` (env, or the @@ -243,8 +242,7 @@ to `cosign` ≥ 2.6.0 — a niche publisher-shared-key path. ## Publishing from GitHub Actions **`grcli` in CI needs no GitHub secret, no `GRCLI_TOKEN`, no -`secrets.*` reference, no PAT.** Do not create one. Trusted publishing -(ADR-0032) means the workflow's GitHub OIDC token is the credential — +`secrets.*` reference, no PAT.** Do not create one. Trusted publishing means the workflow's GitHub OIDC token is the credential — `grcli publish` mints it at runtime from the Actions OIDC endpoint that `permissions: id-token: write` enables. The hub validates the token's `iss` (GitHub) and `sub` (your repo/ref) against its @@ -274,10 +272,10 @@ jobs: - run: | oras pull ghcr.io/gemaraproj/grcli:latest --platform linux/amd64 sudo install grcli /usr/local/bin/grcli - # No cosign step: grcli signs keyless in-process via sigstore-go - # (ADR-0049), using the same OIDC identity that authorizes the push. + # No cosign step: grcli signs keyless in-process via sigstore-go, + # using the same OIDC identity that authorizes the push. - run: grcli publish -f controls.yaml --license Apache-2.0 - # --license is REQUIRED (ADR-0037) — set it to your catalog's real + # --license is REQUIRED — set it to your catalog's real # terms; no `env:` block, no `with: token:`, no secrets — the # id-token: write above is what makes this work ``` @@ -287,7 +285,7 @@ The Fulcio certificate records the workflow URL as the signer identity The hub verifies that signature at ingest and records the (ref-stripped) identity, so a consumer can run `grcli verify --repository … --version …` with **no trust flags** and grcli will verify against the recorded identity -(printing it, and that it came from the hub, first — ADR-0045). For an +(printing it, and that it came from the hub, first). For an independent check that does not trust the hub as the identity source, a consumer supplies `--certificate-identity` (the workflow URL above) with the issuer `https://token.actions.githubusercontent.com` themselves. diff --git a/cmd/cat.go b/cmd/cat.go index 5d54d12..9bf3fe1 100644 --- a/cmd/cat.go +++ b/cmd/cat.go @@ -31,7 +31,7 @@ plus --version. A single-file bundle prints that file verbatim. A bundle with several files prints them as a YAML multi-document stream (--- separated); use --file -to print just one. Caching behaves exactly as for 'grcli unpack' (ADR-0042): +to print just one. Caching behaves exactly as for 'grcli unpack': a remote fetch is served from the on-disk cache when warm; --no-cache forces a fresh pull. Cache diagnostics go to stderr so stdout stays pipe-clean. @@ -75,7 +75,7 @@ func runCat(cmd *cobra.Command, v *viper.Viper) error { if err != nil { return err } - // Reference resolution is unpack's job (ADR-0042), and the v2 cache never + // Reference resolution is unpack's job, and the v2 cache never // stores Imports — but a --source layout can carry them. Never drop content // silently: cat prints Files only, so say what was omitted (on stderr). if len(b.Imports) > 0 { diff --git a/cmd/cat_test.go b/cmd/cat_test.go index f2e3926..3682948 100644 --- a/cmd/cat_test.go +++ b/cmd/cat_test.go @@ -107,7 +107,7 @@ func corruptOneCacheBlob(t *testing.T, root string) { // project config (or GRCLI_FILE) is publish's input-file list and must NOT act // as cat's --file member selector. // TestCat_PublishFileKeyIgnored: a project ./.grcli.yaml is no longer read at -// all (ADR-0044), so a publish-oriented `file:` key in it cannot bleed into +// all, so a publish-oriented `file:` key in it cannot bleed into // cat's --file selection. cat streams the full bundle on stdout; the ignored // project file earns a migration warning on stderr (kept off the stdout pipe). func TestCat_PublishFileKeyIgnored(t *testing.T) { diff --git a/cmd/config_test.go b/cmd/config_test.go index 7623a79..3b981e7 100644 --- a/cmd/config_test.go +++ b/cmd/config_test.go @@ -46,8 +46,8 @@ func TestConfig_GlobalDisablesCache(t *testing.T) { require.False(t, cachingEnabled(loadedViper(t)), "user-global cache-enabled:false must disable caching") } -// TestConfig_ProjectFileIgnored: a repo-local ./.grcli.yaml is no longer read -// (ADR-0044), so it cannot override the user-global file — the global setting +// TestConfig_ProjectFileIgnored: a repo-local ./.grcli.yaml is no longer +// read, so it cannot override the user-global file — the global setting // stands even when a project file says otherwise. func TestConfig_ProjectFileIgnored(t *testing.T) { isolatedWorkdir(t) @@ -95,7 +95,7 @@ func TestConfig_ExplicitFileBypassesSearch(t *testing.T) { } // TestWarnIgnoredConfig: warn whenever a config file sits at a location grcli -// no longer reads — the per-project ./.grcli.yaml (ADR-0044) or the pre-0043 +// no longer reads — the per-project ./.grcli.yaml or the legacy // home/XDG dotfiles — and stay silent when there are none. Unlike the old // legacy check, a present project file warns even when a user-global file // exists, because the project file no longer merges over it. diff --git a/cmd/fetch.go b/cmd/fetch.go index 712fab7..214bb2e 100644 --- a/cmd/fetch.go +++ b/cmd/fetch.go @@ -20,14 +20,14 @@ import ( // resolveBundle fetches the primary artifact bundle from either a local OCI // layout (--source) or the remote registry discovered from the hub (--url + // --repository + --version). It is the shared fetch stage for `unpack` and -// `cat` (ADR-0042 decision 4): both run this identical resolve-and-cache +// `cat`: both run this identical resolve-and-cache // pipeline and then diverge only in how they render the returned bundle. // // Remote fetches consult the on-disk cache (unless caching is disabled) keyed // by the same (host, ns, id, version) coordinate reference resolution uses, so // a primary and a reference to the same artifact share one entry. The cache is // checked BEFORE hub discovery, so a cache hit needs no network at all -// (ADR-0042: served offline). --source reads are local bytes and never cached. +// (served offline). --source reads are local bytes and never cached. // // diag receives human-readable cache diagnostics (never artifact content), so a // caller that emits machine-readable content on stdout — `cat` — must pass a @@ -88,8 +88,8 @@ func resolveBundle(ctx context.Context, v *viper.Viper, diag io.Writer) (b *bund } } - // Cache miss: discover the registry, mint a token (ADR-0031 requires one - // even for public reads), and pull. + // Cache miss: discover the registry, mint a token (the registry requires + // one even for public reads), and pull. d, derr := hub.Discover(ctx, url) if derr != nil { return nil, "", fmt.Errorf("hub discovery: %w", derr) @@ -121,7 +121,7 @@ func resolveBundle(ctx context.Context, v *viper.Viper, diag io.Writer) (b *bund } // cachingEnabled reports whether the artifact cache should be used: the durable -// cache-enabled preference (ADR-0043, default true) AND the absence of the +// cache-enabled preference (default true) AND the absence of the // per-invocation --no-cache flag. Either one off disables caching. func cachingEnabled(v *viper.Viper) bool { return v.GetBool(flagCacheEnabled) && !v.GetBool(flagNoCache) @@ -167,7 +167,7 @@ func entryFromBundle(b *bundle.Bundle, license, sourceURL string) (cache.Entry, // putBundle writes a freshly-pulled primary bundle to the cache. A cache write // failure is non-fatal (the pull already succeeded). A bundle carrying the // dormant Imports slot is not cached: the v2 entry format stores Files + -// manifest only (ADR-0042), so caching such a bundle would silently drop the +// manifest only, so caching such a bundle would silently drop the // imports on the next hit — better to leave it uncached and re-pull. func putBundle(c *cache.Cache, host, ns, id, version string, b *bundle.Bundle, diag io.Writer) { if len(b.Imports) > 0 { diff --git a/cmd/fetch_test.go b/cmd/fetch_test.go index 6be2ff7..676313a 100644 --- a/cmd/fetch_test.go +++ b/cmd/fetch_test.go @@ -130,8 +130,8 @@ func TestResolveBundle_CacheHitSkipsNetwork(t *testing.T) { putBundle(c, hostOf(url), "acme", "controls", "1.0.0", seed, io.Discard) unpacked := filepath.Join(workdir, "unpacked") - // --no-verify: this test isolates the cache layer's offline property. Since - // ADR-0048 a default unpack verifies, which DOES contact the hub/registry — + // --no-verify: this test isolates the cache layer's offline property. A + // default unpack verifies, which DOES contact the hub/registry — // so "cache hit needs no network" now holds only when verification is off. out := runRoot(t, "unpack", "--url", url, "--repository", "acme/controls", "--version", "1.0.0", "--no-verify", "--output", unpacked) diff --git a/cmd/integration_test.go b/cmd/integration_test.go index 6b39c2b..dd37574 100644 --- a/cmd/integration_test.go +++ b/cmd/integration_test.go @@ -93,8 +93,8 @@ func TestPublishUnpackRoundtrip_SinglePolicy(t *testing.T) { require.Contains(t, provenance, "runDetails") } -// TestPublish_License_Valid_StampsCanonicalAnnotation covers ADR-0036 -// decisions 1, 2, and 4 on the happy path: a valid --license (given in +// TestPublish_License_Valid_StampsCanonicalAnnotation covers the license +// happy path: a valid --license (given in // non-canonical casing) is canonicalized and stamped as the standard OCI // manifest annotation org.opencontainers.image.licenses. --dry-run keeps it // off the network; we read the annotation back off the local OCI manifest. @@ -128,8 +128,8 @@ func TestPublish_License_CompoundExpression(t *testing.T) { require.Equal(t, "MIT OR Apache-2.0", ann["org.opencontainers.image.licenses"]) } -// TestPublish_License_Invalid_RejectedBeforePush covers ADR-0036 decision 4's -// strict gate: a malformed/unknown --license aborts the publish and writes NO +// TestPublish_License_Invalid_RejectedBeforePush covers the strict license +// gate: a malformed/unknown --license aborts the publish and writes NO // OCI output, even under --dry-run (the strict check runs before pack). func TestPublish_License_Invalid_RejectedBeforePush(t *testing.T) { cases := []struct { @@ -167,8 +167,8 @@ func TestPublish_License_Invalid_RejectedBeforePush(t *testing.T) { } } -// TestPublish_License_Omitted_RejectedBeforePush covers ADR-0037 decision 1: -// --license is now REQUIRED. Omitting it aborts the publish — before any pack +// TestPublish_License_Omitted_RejectedBeforePush covers the required-license +// rule: --license is REQUIRED. Omitting it aborts the publish — before any pack // or push, even under --dry-run — with the distinct "is required" error (NOT // the "invalid --license" malformed-value message) and writes NO OCI output. func TestPublish_License_Omitted_RejectedBeforePush(t *testing.T) { @@ -206,8 +206,8 @@ func TestPublish_License_Whitespace_RejectedBeforePush(t *testing.T) { } // TestPublish_License_LicenseRef_Accepted confirms a LicenseRef- token (the -// custom/proprietary escape hatch named in the required-license error and -// ADR-0037) is accepted and stamped verbatim. +// custom/proprietary escape hatch named in the required-license error) is +// accepted and stamped verbatim. func TestPublish_License_LicenseRef_Accepted(t *testing.T) { workdir := isolatedWorkdir(t) input := writeTempFile(t, workdir, "policy.yaml", policyYAML) @@ -433,7 +433,7 @@ func readManifest(t *testing.T, path string) map[string]any { // readOCIManifestAnnotations reads the single manifest from an OCI image // layout directory and returns its manifest-level annotations map. It walks // index.json -> the manifest blob (addressed by digest), which is where -// go-gemara's bundle.WithAnnotations lands the publication license (ADR-0036), +// go-gemara's bundle.WithAnnotations lands the publication license, // as opposed to bundle.json (the config blob) which readManifest covers. func readOCIManifestAnnotations(t *testing.T, layoutDir string) map[string]any { t.Helper() diff --git a/cmd/publish.go b/cmd/publish.go index fa3e499..3fc5112 100644 --- a/cmd/publish.go +++ b/cmd/publish.go @@ -29,7 +29,7 @@ import ( // Flag names are declared once so the compiler catches typos at every // viper.Get call site. publish does not expose a tag/version flag — -// the OCI tag is always metadata.version (ADR-0033). unpack and verify +// the OCI tag is always metadata.version. unpack and verify // take --version (see flagVersion in unpack.go) to address a published // bundle. const ( @@ -63,7 +63,7 @@ instead of touching any network. Auth in GitHub Actions: no GitHub secret, no --token, no GRCLI_TOKEN — when run inside a workflow with permissions: id-token: write, grcli mints a GitHub Actions OIDC token and presents it as the credential -(ADR-0032 trusted publishing). The repo (owner/repo, optionally pinned +(trusted publishing). The repo (owner/repo, optionally pinned to a ref) must be registered as a trusted publisher on the hub for the target namespace; a 403 means that binding is missing — not that you need to set a secret.`, @@ -75,14 +75,14 @@ need to set a secret.`, flags := cmd.Flags() flags.StringSliceP(flagFile, "f", nil, "input file(s) describing one artifact (repeatable; comma-separated also accepted)") - flags.String(flagURL, defaultURL, "grc.store base URL — discovers the registry and is the hub sync target (ADR-0026)") + flags.String(flagURL, defaultURL, "grc.store base URL — discovers the registry and is the hub sync target") flags.String(flagRepository, "", "repository path within the registry (default: /, slugified to [a-z0-9._-])") flags.String(flagToken, "", "bearer token for the hub sync call (or GRCLI_TOKEN); leave unset in GitHub Actions — the workflow's OIDC token is used automatically (trusted publishing, no GitHub secret needed)") flags.Bool(flagDryRun, false, "skip all network — emit OCI layout to --output instead") flags.String(flagOutput, "grcli-out", "directory to write the OCI layout to when --dry-run") flags.Bool(flagNoSign, false, "skip cosign signing even when material is available") flags.String(flagCosignKey, "", "cosign key file for local signing (or COSIGN_KEY)") - flags.String(flagLicense, "", "REQUIRED: publication license as an SPDX expression (e.g. Apache-2.0, MIT OR Apache-2.0, LicenseRef-Acme-Proprietary); stamped as the org.opencontainers.image.licenses OCI annotation. Publish fails before any network call if unset (ADR-0037)") + flags.String(flagLicense, "", "REQUIRED: publication license as an SPDX expression (e.g. Apache-2.0, MIT OR Apache-2.0, LicenseRef-Acme-Proprietary); stamped as the org.opencontainers.image.licenses OCI annotation. Publish fails before any network call if unset") // Flags are bound to viper inside RunE (see runPublish) rather than // here at construction time. Two subcommands sharing a viper instance @@ -132,7 +132,7 @@ func runPublish(cmd *cobra.Command, v *viper.Viper, positional []string) error { return err } - // Strict license gate (ADR-0037 decision 1, tightening ADR-0036): grcli + // Strict license gate: grcli // is the strict end. --license is now REQUIRED. Validate and canonicalize // BEFORE any pack/push — including the --dry-run path — so a missing, // malformed, or unknown SPDX expression never produces OCI bytes (locally @@ -155,7 +155,7 @@ func runPublish(cmd *cobra.Command, v *viper.Viper, positional []string) error { return err } // Pre-flight: versions are immutable, so halt BEFORE packing or - // pushing if the coordinate is already taken (ADR-0031). This is + // pushing if the coordinate is already taken. This is // what stops a re-publish from clobbering existing bytes in the // registry — the registry would accept the overwrite before the // hub's sync-time guard could reject it. @@ -223,12 +223,11 @@ type signContext struct { // resolveTarget merges --repository/--url/--dry-run with the // metadata-derived defaults and validates the combination. --url drives -// the registry hostname via the hub's discovery endpoint (ADR-0026); +// the registry hostname via the hub's discovery endpoint; // --dry-run skips discovery since it never touches the network. // -// The OCI tag is always metadata.version — no override. ADR-0033 (in -// grc.store-backend) made tag == metadata.version a hub-enforced -// invariant; a --tag override could only ever produce a 422 +// The OCI tag is always metadata.version — no override. The hub +// enforces tag == metadata.version as an invariant; a --tag override could only ever produce a 422 // tag_version_mismatch from the syncer, so the flag was removed rather // than left as a foot-gun. func resolveTarget(ctx context.Context, v *viper.Viper, loaded *source.Loaded) (publishTarget, error) { @@ -340,8 +339,8 @@ func signAndNotify(ctx context.Context, v *viper.Viper, sc signContext) error { return nil } -// checkVersionAvailable is the publish pre-flight. Versions are immutable -// (ADR-0031), so if the target coordinate already exists on the hub, halt +// checkVersionAvailable is the publish pre-flight. Versions are +// immutable, so if the target coordinate already exists on the hub, halt // here — before packing, before any registry write. That prevents a // re-publish from clobbering the existing bytes in the registry (which // accepts the overwrite before the hub's sync-time guard can reject it). @@ -395,7 +394,7 @@ func publishHubURL(v *viper.Viper) string { // authenticatePush exports a registry push token (GRCLI_REGISTRY_TOKEN) // so the oras push and the cosign signature push authenticate to the -// bearer-auth registry (ADR-0031). The hub grants push only to a +// bearer-auth registry. The hub grants push only to a // namespace owner or admin, so a push needs a hub login: when no explicit // registry credential override is present, we resolve the login token and // surface a clear `grcli login` hint if it's missing. No-op when there's @@ -434,11 +433,11 @@ func resolveBearerToken(ctx context.Context, v *viper.Viper) (string, error) { ExplicitToken: v.GetString(flagToken), Warn: os.Stderr, } - // Resolution order (ADR-0028): --token / GRCLI_TOKEN (captured above) + // Resolution order: --token / GRCLI_TOKEN (captured above) // > GitHub Actions OIDC > stored device-login creds. The CI step: // when no explicit token is set and we're in a GHA job, fetch the // workflow's OIDC token and present it directly — the hub validates it - // (ADR-0032) and maps the repo to its trusted-publisher namespace. No + // and maps the repo to its trusted-publisher namespace. No // secret, no login. The audience comes from the hub's discovery doc // (ci_audience), falling back to the hub URL, so it always matches the // hub's HUB_CI_OIDC_AUDIENCE. On any failure we fall through to the @@ -462,8 +461,8 @@ func resolveBearerToken(ctx context.Context, v *viper.Viper) (string, error) { return auth.Resolve(ctx, in) } -// validatePublishLicense runs the strict SPDX gate for --license (ADR-0037 -// decision 1, tightening ADR-0036: grcli is the strict end). The flag is now +// validatePublishLicense runs the strict SPDX gate for --license (grcli is +// the strict end; the hub is lenient). The flag is now // REQUIRED: an empty/whitespace-only value is an error — distinct from the // invalid-value message, because a missing flag and a malformed value are // different user mistakes. A supplied value must be a well-formed SPDX @@ -474,7 +473,7 @@ func resolveBearerToken(ctx context.Context, v *viper.Viper) (string, error) { func validatePublishLicense(raw string) (string, error) { raw = strings.TrimSpace(raw) if raw == "" { - return "", errors.New("a publication license is required: pass --license with an SPDX expression (e.g. Apache-2.0, MIT OR Apache-2.0; see https://spdx.org/licenses) or a LicenseRef-… token for a custom/proprietary license (ADR-0037)") + return "", errors.New("a publication license is required: pass --license with an SPDX expression (e.g. Apache-2.0, MIT OR Apache-2.0; see https://spdx.org/licenses) or a LicenseRef-… token for a custom/proprietary license") } canonical, err := spdx.Canonicalize(raw) if err != nil { diff --git a/cmd/publish_test.go b/cmd/publish_test.go index 3a43f75..b637b6e 100644 --- a/cmd/publish_test.go +++ b/cmd/publish_test.go @@ -134,7 +134,7 @@ func TestResolveTarget(t *testing.T) { } } -// TestResolveTargetURL covers the ADR-0026 discovery hook: --url drives a +// TestResolveTargetURL covers the discovery hook: --url drives a // discovery call to resolve the registry, and --dry-run skips it. Mock // hub via httptest. func TestResolveTargetURL(t *testing.T) { @@ -242,7 +242,7 @@ func TestResolveBearerToken(t *testing.T) { got, err := resolveBearerToken(context.Background(), v) require.NoError(t, err) require.Equal(t, "gha.workflow.jwt", got, - "in CI with no explicit token, the workflow OIDC token is the credential (ADR-0032)") + "in CI with no explicit token, the workflow OIDC token is the credential") }) t.Run("explicit --token wins even inside GitHub Actions", func(t *testing.T) { diff --git a/cmd/references_test.go b/cmd/references_test.go index 4ab735a..84f3f33 100644 --- a/cmd/references_test.go +++ b/cmd/references_test.go @@ -347,7 +347,7 @@ func TestWriteReference_RejectsTraversal(t *testing.T) { // TestEntryFromBundle_DropsImports documents that the v2 cache entry stores // Files + manifest only — a referenced bundle's own transitive imports are not -// represented (reference resolution is direct-only, ADR-0039). fetchReference +// represented (reference resolution is direct-only). fetchReference // warns via noteDroppedReferenceImports rather than dropping them silently. func TestEntryFromBundle_DropsImports(t *testing.T) { b := &bundle.Bundle{ diff --git a/cmd/regtoken.go b/cmd/regtoken.go index 8c7c8b9..57280c0 100644 --- a/cmd/regtoken.go +++ b/cmd/regtoken.go @@ -10,7 +10,7 @@ import ( ) // ensureRegistryToken makes grcli authenticate to the bearer-auth -// registry (ADR-0031) without the caller managing registry credentials: +// registry without the caller managing registry credentials: // it fetches a repository-scoped Distribution token from the hub's // /v2/token endpoint and exports it as GRCLI_REGISTRY_TOKEN, which both // the oras push/pull path (internal/registry.dockerCredentials) and the diff --git a/cmd/root.go b/cmd/root.go index fa65aca..1253fbb 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -62,10 +62,10 @@ func newRootCmd() *cobra.Command { return cmd } -// flagCacheEnabled is the config key (ADR-0043) that durably turns the artifact +// flagCacheEnabled is the config key that durably turns the artifact // cache off (equivalent to passing --no-cache on every command). Default true. // It is a FLAT key, not nested `cache.enabled`, on purpose: the $GRCLI_CACHE -// location env var (ADR-0039) shadows the whole `cache.*` namespace under +// location env var shadows the whole `cache.*` namespace under // viper's AutomaticEnv, which would mask a nested key's default and file value // whenever $GRCLI_CACHE is set. The env form is GRCLI_CACHE_ENABLED. const flagCacheEnabled = "cache-enabled" @@ -83,11 +83,11 @@ const flagCacheEnabled = "cache-enabled" var grcliApp = auth.App{Name: "grcli", TokenEnv: "GRCLI_TOKEN", TokenFlag: "--token"} // loadConfig wires the GRCLI_* env prefix and reads the single user-global -// config file (ADR-0043, amended by ADR-0044). Precedence, highest first: +// config file. Precedence, highest first: // explicit flag > GRCLI_* env > user-global $XDG_CONFIG_HOME/grcli/config.yaml // (fallback ~/.config/grcli/config.yaml) > built-in default. There is NO // per-project layer: a repo-local ./.grcli.yaml is deliberately not read -// (ADR-0044) — a committed config file steering a publish/verify tool is a +// — a committed config file steering a publish/verify tool is a // footgun — so a present one earns a migration warning instead. --config // selects a single file and bypasses the search. A missing file is not // an error; any other read error is a warning (on the command's stderr) so the @@ -121,15 +121,15 @@ func loadConfig(v *viper.Viper, cfgFile string, warn io.Writer) error { return nil } -// projectConfigFile is the repo-local config path. As of ADR-0044 grcli no -// longer reads it; the constant remains so warnIgnoredConfig can nudge anyone +// projectConfigFile is the repo-local config path. grcli no longer reads +// it; the constant remains so warnIgnoredConfig can nudge anyone // migrating from the per-project layer to the user-global file. const projectConfigFile = ".grcli.yaml" // warnIgnoredConfig warns about config files sitting at locations grcli no // longer reads, so a settings file isn't silently ignored after a layout -// change. Retired locations: the per-project ./.grcli.yaml (ADR-0044) and the -// pre-ADR-0043 dotfiles (~/.grcli.yaml and $XDG_CONFIG_HOME/grcli/.grcli.yaml). +// change. Retired locations: the per-project ./.grcli.yaml and the +// legacy dotfiles (~/.grcli.yaml and $XDG_CONFIG_HOME/grcli/.grcli.yaml). // The only blessed location is the user-global config.yaml (globalPath). func warnIgnoredConfig(globalPath string, w io.Writer) { // Each candidate carries a display path (friendly, e.g. relative diff --git a/cmd/unpack.go b/cmd/unpack.go index e0842e2..f645784 100644 --- a/cmd/unpack.go +++ b/cmd/unpack.go @@ -28,17 +28,17 @@ import ( const ( flagSource = "source" // flagVersion is the published artifact's metadata.version, which is - // also its OCI tag (ADR-0033 guarantees they're the same). Shared with + // also its OCI tag (the hub guarantees they're the same). Shared with // verify.go. flagVersion = "version" - // Reference-resolution flags (ADR-0039). + // Reference-resolution flags. flagWithImports = "with-imports" flagWithReferences = "with-references" flagNoCache = "no-cache" - // flagNoVerify opts out of the default pre-unpack signature verification - // (ADR-0048). flagCertIdentity / flagCertOIDCIssuer are defined in verify.go + // flagNoVerify opts out of the default pre-unpack signature + // verification. flagCertIdentity / flagCertOIDCIssuer are defined in verify.go // and reused here so an unpack can assert an identity instead of trusting the // hub-recorded one. flagNoVerify = "no-verify" @@ -56,7 +56,7 @@ The source can be a local OCI image layout (--source, the shape produced by 'grcli publish --dry-run') or a remote registry discovered from the hub (--url plus --repository). Exactly one of --source / --url must be set. -Verification (ADR-0048): a remote (--url) unpack VERIFIES the artifact's +Verification: a remote (--url) unpack VERIFIES the artifact's Sigstore signature in-process before writing anything, and fails closed — an unsigned, mis-signed, or unverifiable artifact is refused and no files are written. This is the same check as 'grcli verify': zero-flag against @@ -65,7 +65,7 @@ assert the signer yourself and bypass the hub. Pass --no-verify to write without verifying (INSECURE). A local --source layout has no registry signature to check, so it is always written without verification. -Caching (ADR-0042): a remote (--url) fetch is served from a global on-disk +Caching: a remote (--url) fetch is served from a global on-disk cache when the same namespace/id/version has been fetched before — a cache hit for the artifact bytes needs no network. grc.store tags are immutable, so a hit can never be stale. (Best-effort exception: resolving references @@ -80,7 +80,7 @@ Registry auth flows through the same Docker credential chain and GRCLI_REGISTRY_USERNAME / GRCLI_REGISTRY_PASSWORD / GRCLI_REGISTRY_TOKEN overrides as 'grcli publish'. -Resolving references (ADR-0039): with --with-imports (the artifact's +Resolving references: with --with-imports (the artifact's 'imports') or --with-references (every mapping reference it declares), grcli also pulls the referenced grc.store artifacts into references/ //@, alongside a references/index.json record. @@ -119,7 +119,7 @@ Examples: flags.Bool(flagWithImports, false, "also resolve and pull the artifact's `imports` references from the hub (requires --url)") flags.Bool(flagWithReferences, false, "also resolve and pull ALL of the artifact's mapping references from the hub (requires --url); superset of --with-imports") flags.Bool(flagNoCache, false, "bypass the local artifact cache for this run (primary + references); set cache-enabled: false in config to disable it durably") - flags.Bool(flagNoVerify, false, "write without verifying the artifact's signature (INSECURE; ADR-0048) — the default verifies and fails closed") + flags.Bool(flagNoVerify, false, "write without verifying the artifact's signature (INSECURE) — the default verifies and fails closed") flags.String(flagCertIdentity, "", "verify against this exact signer identity instead of the hub-recorded one (bypasses the hub lookup)") flags.String(flagCertOIDCIssuer, "", "expected OIDC issuer for --certificate-identity (default: https://token.actions.githubusercontent.com)") @@ -143,7 +143,7 @@ func runUnpack(cmd *cobra.Command, v *viper.Viper) error { // Capture whether the user supplied an explicit registry credential BEFORE // any pull mints and exports one. Reference resolution mints a fresh token - // per referenced repository (ADR-0031 tokens are per-namespace), and must + // per referenced repository (registry tokens are per-namespace), and must // only do so when the user hasn't provided their own credential — which is // no longer detectable once resolveBundle has exported a primary token. userCreds := userSuppliedRegistryCredential() @@ -155,7 +155,7 @@ func runUnpack(cmd *cobra.Command, v *viper.Viper) error { return err } - // Verify the signature BEFORE writing anything (ADR-0048). Fail closed: + // Verify the signature BEFORE writing anything. Fail closed: // a rejected artifact returns here, so os.MkdirAll/writeBundle never run // and the output directory is not created. switch planUnpackVerify(v.GetString(flagSource), v.GetBool(flagNoVerify)) { @@ -186,7 +186,7 @@ func runUnpack(cmd *cobra.Command, v *viper.Viper) error { } // unpackVerifyPlan is how unpack handles signature verification for one -// invocation, decided from the flags before any network work (ADR-0048). +// invocation, decided from the flags before any network work. type unpackVerifyPlan int const ( @@ -212,7 +212,7 @@ func planUnpackVerify(source string, noVerify bool) unpackVerifyPlan { } // verifyBeforeUnpack verifies the artifact's Sigstore signature in-process -// (the ADR-0046 path) BEFORE any content is written (ADR-0048). It fails closed: +// (the same path verify uses) BEFORE any content is written. It fails closed: // an unsigned, mis-signed, or otherwise unverifiable artifact returns an error // and unpack writes nothing. It reuses verify's exact policy resolution, so // unpack and `grcli verify` apply identical trust — zero-flag against the @@ -241,7 +241,7 @@ func verifyBeforeUnpack(ctx context.Context, v *viper.Viper, out io.Writer) erro res, err := verifier.Verify(ctx, bundleJSON, artifactDigest, policy.identityPolicy()) if errors.Is(err, sigverify.ErrUnsigned) { return fmt.Errorf("%s:%s has no signature in the registry — refusing to unpack unverified content "+ - "(re-run with --no-verify to override; ADR-0048)", policy.repository, policy.version) + "(re-run with --no-verify to override)", policy.repository, policy.version) } if err != nil { return fmt.Errorf("signature verification failed — refusing to unpack: %w", err) @@ -340,7 +340,7 @@ type refIndexEntry struct { // resolveReferences walks the unpacked artifact's mapping references and pulls // the ones that point at the targeted hub into references// alongside -// the primary (ADR-0039). It is best-effort: an unrecognized host, a not-found, +// the primary. It is best-effort: an unrecognized host, a not-found, // or a fetch error is reported and skipped, never fatal. func resolveReferences(ctx context.Context, v *viper.Viper, mode refs.Mode, b *bundle.Bundle, output string, userCreds bool, out io.Writer) error { url := v.GetString(flagURL) @@ -376,7 +376,7 @@ func resolveReferences(ctx context.Context, v *viper.Viper, mode refs.Mode, b *b } client := hub.New(url, "") - // References pulled from the registry (ADR-0042 decision 5) need the registry + // References pulled from the registry need the registry // host, discovered lazily on the FIRST cache miss so a fully-cached run stays // offline. Memoized: at most one discovery per unpack, and a failure only // skips the references that actually need a pull, not the cached ones. @@ -455,7 +455,7 @@ func resolveReferences(ctx context.Context, v *viper.Viper, mode refs.Mode, b *b } // A reference is a full bundle, written to its own directory (like the - // primary unpack): the artifact file(s) plus bundle.json (ADR-0042). + // primary unpack): the artifact file(s) plus bundle.json. refDir := filepath.Join("references", s.Category, ns, fmt.Sprintf("%s@%s", id, s.Version)) if err := writeReference(output, refDir, entry, out); err != nil { fmt.Fprintf(out, " - skip [%s] %s: %v\n", s.Category, coord, err) @@ -509,9 +509,9 @@ type fetchRefArgs struct { // fetchReference returns a reference as a full bundle, from the cache when // present and uncorrupted, otherwise by pulling the whole bundle from the -// registry (ADR-0042 decision 5) and, unless --no-cache, caching it. The +// registry and, unless --no-cache, caching it. The // per-version license is read from the hub for the license-mismatch warning and -// recorded on the entry. Verification is deferred (ADR-0039 amendment), so the +// recorded on the entry. Verification is deferred, so the // entry is recorded as unverified. func fetchReference(ctx context.Context, a fetchRefArgs, out io.Writer) (*cache.Entry, error) { if a.cache != nil { @@ -558,7 +558,7 @@ func fetchReference(ctx context.Context, a fetchRefArgs, out io.Writer) (*cache. if err != nil { return nil, err } - // Reference resolution is direct-only (ADR-0039): if the referenced bundle + // Reference resolution is direct-only: if the referenced bundle // carries its own transitive imports, we neither materialize nor cache them // (the v2 entry stores Files + manifest only). Say so rather than dropping // them silently. @@ -604,7 +604,7 @@ func referenceLicense(ctx context.Context, client *hub.Client, ns, id, version s // noteDroppedReferenceImports warns that a referenced bundle carries its own // transitive imports, which grcli does not materialize: reference resolution is -// direct-only (ADR-0039), and the v2 cache stores Files + manifest only. +// direct-only, and the v2 cache stores Files + manifest only. func noteDroppedReferenceImports(out io.Writer, ns, id, version string, n int) { fmt.Fprintf(out, " ! %s/%s@%s carries %d transitive import(s) — not materialized (direct-only resolution)\n", ns, id, version, n) diff --git a/cmd/unpack_test.go b/cmd/unpack_test.go index 2d67455..0d27b61 100644 --- a/cmd/unpack_test.go +++ b/cmd/unpack_test.go @@ -8,7 +8,7 @@ import ( "github.com/spf13/viper" ) -// TestPlanUnpackVerify pins the pre-network gating decision (ADR-0048): remote +// TestPlanUnpackVerify pins the pre-network gating decision: remote // unpack verifies by default, --no-verify opts out, and a local --source layout // can never be verified (and that reason wins even when --no-verify is also set). func TestPlanUnpackVerify(t *testing.T) { diff --git a/cmd/urldefault.go b/cmd/urldefault.go index 1c5f79f..d670f17 100644 --- a/cmd/urldefault.go +++ b/cmd/urldefault.go @@ -13,7 +13,7 @@ import ( // at hub.grc.store, and asking them to remember the URL every time // helps nobody. Override per invocation with `--url ` or per // shell with `GRCLI_URL`. The discovery endpoint that backs `--url` -// (ADR-0026) is served by the hub at this URL, not the frontend at +// is served by the hub at this URL, not the frontend at // grc.store/ — the frontend Worker does not proxy /.well-known/ through. const defaultURL = "https://hub.grc.store" diff --git a/cmd/verify.go b/cmd/verify.go index 3c4ae23..6ef26ed 100644 --- a/cmd/verify.go +++ b/cmd/verify.go @@ -29,7 +29,7 @@ const ( flagCertIdentity = "certificate-identity" flagCertOIDCIssuer = "certificate-oidc-issuer" // flagTrustedRoot overrides the embedded Sigstore public-good - // trusted_root.json with one read from disk (ADR-0046 decision 4) — for + // trusted_root.json with one read from disk — for // air-gapped deployments or a private Sigstore instance. Env form // GRCLI_TRUSTED_ROOT; there is no --flag, only the env / config key, since // it is an ops-level override, not a per-invocation knob. @@ -40,7 +40,7 @@ const ( // --certificate-oidc-issuer (or the GRCLI_CERTIFICATE_OIDC_ISSUER env / // user-global config key of the same name) is not set. Publishing to grc.store // is a GitHub-Actions OIDC flow, so this is the issuer for ~every publisher; -// GitHub Enterprise / other CI / an OIDC proxy override it (ADR-0044). It is +// GitHub Enterprise / other CI / an OIDC proxy override it. It is // applied contextually inside keyless mode, NOT as a viper default, so it can't // disturb key-vs-keyless detection. const defaultCertOIDCIssuer = "https://token.actions.githubusercontent.com" @@ -50,7 +50,7 @@ func newVerifyCmd(v *viper.Viper) *cobra.Command { Use: "verify", Short: "Verify a remote Gemara bundle's signature", Long: `Verifies the Sigstore signature attached to a remote Gemara bundle. -Keyless verification runs IN-PROCESS (ADR-0046) — no external tools are +Keyless verification runs IN-PROCESS — no external tools are required, just the grcli binary. The bundle must already be pushed to a registry: signatures live at the registry layer as an OCI 1.1 referrer, not in the bundle bytes, so verifying a local OCI layout from 'publish --dry-run' is @@ -65,7 +65,7 @@ with each release. For an air-gapped deployment or a private Sigstore instance, point GRCLI_TRUSTED_ROOT (env or config key 'trusted-root') at a trusted_root.json on disk. -With NO trust flags, verify runs in zero-flag mode (ADR-0045): it fetches +With NO trust flags, verify runs in zero-flag mode: it fetches the catalog record from the hub, reads the keyless signer identity the hub verified and pinned at ingest, and verifies against it — so a consumer needs no prior knowledge of the publishing workflow. The identity it trusted, and @@ -135,7 +135,7 @@ func runVerify(cmd *cobra.Command, v *viper.Viper) error { return err } - // ADR-0031: the signature lives in the bearer-auth registry. Mint an + // The signature lives in the bearer-auth registry. Mint an // anonymous pull token from the hub (when --url is set and no override is // present) and export it via GRCLI_REGISTRY_TOKEN. The in-process oras fetch // reads it through the Docker credential chain (internal/registry), and @@ -149,8 +149,8 @@ func runVerify(cmd *cobra.Command, v *viper.Viper) error { out := cmd.OutOrStdout() fmt.Fprintf(out, "verifying %s (%s)\n", policy.reference, policy.modeDescription()) - // Key-based verification is the ONLY path that still shells out to cosign - // (ADR-0046 decision 5): a niche publisher-shared-key mode the hub doesn't + // Key-based verification is the ONLY path that still shells out to + // cosign: a niche publisher-shared-key mode the hub doesn't // pin yet. The cosign prerequisite now applies exclusively here. if policy.keyPath != "" { if _, err := exec.LookPath("cosign"); err != nil { @@ -165,11 +165,11 @@ func runVerify(cmd *cobra.Command, v *viper.Viper) error { } // Keyless verification (explicit --certificate-identity and zero-flag - // hub-lookup) runs in-process against real Sigstore (ADR-0046) — no cosign. + // hub-lookup) runs in-process against real Sigstore — no cosign. return runKeylessVerify(ctx, v, policy, out) } -// runKeylessVerify performs in-process keyless verification (ADR-0046): it +// runKeylessVerify performs in-process keyless verification: it // builds a sigstore-go verifier over the pinned (or GRCLI_TRUSTED_ROOT-override) // trust root, discovers the signature bundle as an OCI referrer of the artifact // manifest, and verifies it against both the artifact digest and the pinned @@ -197,7 +197,7 @@ func runKeylessVerify(ctx context.Context, v *viper.Viper, policy verifyPolicy, } // newSigstoreVerifier builds the in-process verifier, honoring the -// GRCLI_TRUSTED_ROOT override (ADR-0046 decision 4). A zero timeout selects the +// GRCLI_TRUSTED_ROOT override. A zero timeout selects the // package default. Both constructors require SCTs — the production posture is // never relaxed off the embedded/override root. func newSigstoreVerifier(v *viper.Viper) (*sigverify.Verifier, error) { @@ -230,9 +230,9 @@ type verifyPolicy struct { issuer string // populated for keyless verification (both modes) // hubIdentity is the canonical identity string the hub recorded, kept for // the pre-verify announcement so trust in the hub is visible, never silent. - // Non-empty only in hub-lookup mode (ADR-0045 decision 8). + // Non-empty only in hub-lookup mode. hubIdentity string - registryToken string // Distribution pull token for the bearer-auth registry (ADR-0031) + registryToken string // Distribution pull token for the bearer-auth registry plainHTTP bool // registry speaks plain HTTP (local dev) — pass cosign --allow-http-registry } @@ -250,12 +250,12 @@ func (p verifyPolicy) modeDescription() string { } // cosignArgs builds the argv for the ONLY remaining cosign shell-out: -// --cosign-key (key-based) verification (ADR-0046 decision 5). The keyless +// --cosign-key (key-based) verification. The keyless // paths verify in-process and never reach here. grcli signs with the Sigstore // bundle format (bundle-as-OCI-referrer), so cosign must expect it too — the // bundle-format flags come from the SAME version-gated helper the sign side // uses (sign.BundleFormatArgs), so sign and verify can't silently drift on -// either the format OR the cosign version band (ADR-0035). +// either the format OR the cosign version band. func (p verifyPolicy) cosignArgs(ctx context.Context) ([]string, error) { bundleArgs, err := sign.BundleFormatArgs(ctx) if err != nil { @@ -263,7 +263,7 @@ func (p verifyPolicy) cosignArgs(ctx context.Context) ([]string, error) { } args := append([]string{"verify"}, bundleArgs...) // cosign verify pulls the signature from the registry, which now - // requires a bearer token (ADR-0031). Unlike the in-process oras path, the + // requires a bearer token. Unlike the in-process oras path, the // cosign subprocess can't read GRCLI_REGISTRY_TOKEN, so pass it // explicitly when we minted one. if p.registryToken != "" { @@ -324,12 +324,12 @@ func resolveVerifyPolicy(ctx context.Context, v *viper.Viper) (verifyPolicy, err case issuerSet && !keylessMode: return verifyPolicy{}, errors.New("--certificate-oidc-issuer requires --certificate-identity") } - // With no key and no identity we're in zero-flag mode (ADR-0045 decision 8): + // With no key and no identity we're in zero-flag mode: // the signer identity comes from the hub's catalog record, not the flags. // (A lone --certificate-oidc-issuer is already rejected above, so this is // exactly "no trust material at all".) hubLookupMode := !keyMode && !keylessMode - // Keyless with no explicit issuer defaults to GitHub Actions (ADR-0044). + // Keyless with no explicit issuer defaults to GitHub Actions. // This runs AFTER mode resolution, and cosign still checks issuer == this // value, so a wrong default can only cause a false rejection, never a // false acceptance. Hub-lookup mode carries its own issuer from the record. @@ -373,7 +373,7 @@ func resolveVerifyPolicy(ctx context.Context, v *viper.Viper) (verifyPolicy, err } // resolveHubIdentity fills the keyless trust material on policy from the hub's -// recorded signer identity for the catalog coordinate (ADR-0045 decision 8). +// recorded signer identity for the catalog coordinate. // The hub is trusted only as the *identity* source here — cosign still performs // the Sigstore verification against it — and runVerify prints what was used and // that it came from the hub before verifying, so the trust is never silent. diff --git a/cmd/verify_test.go b/cmd/verify_test.go index 403d877..51062b2 100644 --- a/cmd/verify_test.go +++ b/cmd/verify_test.go @@ -77,7 +77,7 @@ func TestVerify_FlagValidation(t *testing.T) { { // issuer without identity (and no key) is an explicit error: // identity is the keyless trigger; a lone issuer has nothing to - // bind to (ADR-0044). Identity WITHOUT issuer is NOT here — it + // bind to. Identity WITHOUT issuer is NOT here — it // now succeeds by defaulting the issuer (see TestResolveVerifyPolicy_URL). name: "issuer-without-identity", args: []string{ @@ -97,7 +97,7 @@ func TestVerify_FlagValidation(t *testing.T) { } } -// TestResolveVerifyPolicy_URL covers the ADR-0026 --url path through the +// TestResolveVerifyPolicy_URL covers the --url path through the // verify command. Catches the BLOCKER from the post-ship QA pass: when // --url drives discovery, the registry_url advertised by the hub carries // a scheme (https://...), which cosign rejects as an invalid OCI image @@ -165,7 +165,7 @@ func TestResolveVerifyPolicy_URL(t *testing.T) { } // TestVerifyPolicy_CosignArgs now covers ONLY key-mode: keyless verification -// moved in-process (ADR-0046), so cosign is the sole remaining shell-out and it +// moved in-process, so cosign is the sole remaining shell-out and it // only ever runs with --key. The keyless trust material is carried by // identityPolicy() instead (TestVerifyPolicy_IdentityPolicy below). func TestVerifyPolicy_CosignArgs(t *testing.T) { @@ -209,7 +209,7 @@ func TestVerifyPolicy_CosignArgs(t *testing.T) { // TestVerifyPolicy_IdentityPolicy pins the mapping from the resolved policy to // the in-process sigstore-go identity pin — the security-critical seam that // replaced cosign's --certificate-identity / --certificate-identity-regexp + -// --certificate-oidc-issuer flags (ADR-0046 decision 2). The exact-vs-regexp +// --certificate-oidc-issuer flags. The exact-vs-regexp // choice and the issuer must carry through byte-for-byte. func TestVerifyPolicy_IdentityPolicy(t *testing.T) { t.Run("explicit keyless mode → exact SAN, exact issuer", func(t *testing.T) { @@ -264,7 +264,7 @@ func hubLookupServer(t *testing.T, signerIdentity string, onCatalog func()) *htt } // TestResolveVerifyPolicy_HubLookup covers the zero-flag verify-by-coordinate -// path (ADR-0045 decision 8): no trust flags, so the signer identity is read +// path: no trust flags, so the signer identity is read // from the hub's catalog record and turned into an anchored keyless cosign // policy. func TestResolveVerifyPolicy_HubLookup(t *testing.T) { @@ -350,7 +350,7 @@ func TestResolveVerifyPolicy_HubLookup(t *testing.T) { require.Equal(t, 0, called, "the catalog record must not be fetched when the identity is supplied explicitly") require.Equal(t, "https://github.com/team/repo/.github/workflows/publish.yml@refs/heads/main", policy.identity) require.Empty(t, policy.identityRegexp, "explicit keyless mode uses the exact identity, not a regexp") - require.Equal(t, defaultCertOIDCIssuer, policy.issuer, "explicit keyless still defaults the issuer (ADR-0044)") + require.Equal(t, defaultCertOIDCIssuer, policy.issuer, "explicit keyless still defaults the issuer") }) t.Run("explicit --cosign-key bypasses the hub lookup entirely", func(t *testing.T) { diff --git a/examples/github-actions/publish.yml b/examples/github-actions/publish.yml index 1c89cba..fa374ff 100644 --- a/examples/github-actions/publish.yml +++ b/examples/github-actions/publish.yml @@ -3,7 +3,7 @@ # === AUTH: NO GITHUB SECRET REQUIRED === # Do NOT add `GRCLI_TOKEN`, a PAT, or any `secrets.*` reference to this # workflow. grcli uses the workflow's GitHub Actions OIDC token as its -# credential (ADR-0032 trusted publishing). The `permissions: id-token: +# credential (trusted publishing). The `permissions: id-token: # write` below is the entire auth setup — that's what lets the runtime # mint an OIDC token grcli can present to the hub. # @@ -44,7 +44,7 @@ jobs: sudo install grcli /usr/local/bin/grcli # NO cosign step, and that is the point. Keyless publish - # signing runs IN-PROCESS via sigstore-go (ADR-0049): grcli requests the + # signing runs IN-PROCESS via sigstore-go: grcli requests the # Actions OIDC token itself, gets a Fulcio certificate, logs to Rekor and # attaches the signature as an OCI referrer — no external tools, no # secrets. Verified by a live CI smoke on 2026-08-19 from a runner with @@ -54,7 +54,7 @@ jobs: # Pin a tag, never `:latest`, so a bad release cannot reach you # before you choose to upgrade. - # --license is REQUIRED (ADR-0037): an SPDX expression naming the terms + # --license is REQUIRED: an SPDX expression naming the terms # this catalog is published under. grcli fails before any network call # if it is missing, and the value is stamped into the signed, immutable # artifact — so set it to your catalog's real license, not this default. diff --git a/internal/cache/cache.go b/internal/cache/cache.go index 2fec63d..baed6a6 100644 --- a/internal/cache/cache.go +++ b/internal/cache/cache.go @@ -1,7 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 -// Package cache is a Go-module-style on-disk cache for artifacts grcli pulls -// (ADR-0039, extended by ADR-0042). grc.store tags are immutable (ADR-0033), +// Package cache is a Go-module-style on-disk cache for artifacts grcli +// pulls. grc.store tags are immutable, // so a coordinate (host, namespace, id, version) maps to fixed bytes forever — // a cache hit can never be stale, which is what makes this sound. Entries are // host-namespaced so prod, staging, and self-hosted hubs stay separate. (Caveat: @@ -31,8 +31,8 @@ import ( ) // layoutVersion namespaces the on-disk layout so a format change can coexist -// with old entries instead of misreading them. v2 (ADR-0042) stores a full -// bundle; v1 entries (single body, ADR-0039) are simply never read. +// with old entries instead of misreading them. v2 stores a full +// bundle; v1 entries (single body) are simply never read. const layoutVersion = "v2" // File is one artifact file in a cached bundle. Data is held separately from the @@ -64,7 +64,7 @@ type Entry struct { // SourceURL is the reference URL this entry was resolved from, if any. SourceURL string // Verified records whether the bytes were signature-verified. Always false - // for now — verify-on-pull is deferred (ADR-0039 amendment) — but persisted + // for now — verify-on-pull is deferred — but persisted // so a later pass can upgrade entries in place. Verified bool } diff --git a/internal/cache/cache_test.go b/internal/cache/cache_test.go index 0f8638e..5ebc0bb 100644 --- a/internal/cache/cache_test.go +++ b/internal/cache/cache_test.go @@ -126,7 +126,7 @@ func TestGetDetectsCorruptionAtNonZeroIndex(t *testing.T) { } } -// TestV1EntriesAreIgnored guards the "no migration" decision (ADR-0042 dec. 3): +// TestV1EntriesAreIgnored guards the "no migration" decision: // a v1-shaped entry on disk must not be read at the v2 coordinate. This is the // invariant the whole layoutVersion bump rests on. func TestV1EntriesAreIgnored(t *testing.T) { diff --git a/internal/hub/discover.go b/internal/hub/discover.go index 353f518..fc817fd 100644 --- a/internal/hub/discover.go +++ b/internal/hub/discover.go @@ -17,7 +17,7 @@ import ( ) // Discovery is the GET /.well-known/grc-store-configuration document. It is aliased to the -// shared wire-contract type (ADR-0035) — the same definition the hub serves and +// shared wire-contract type — the same definition the hub serves and // pvtr consumes — so the three can't drift. The CI-audience field is named // CIAudience on the shared type (was CIOIDCAudience here). type Discovery = discovery.Document diff --git a/internal/hub/hub.go b/internal/hub/hub.go index 85f0025..a80a24a 100644 --- a/internal/hub/hub.go +++ b/internal/hub/hub.go @@ -22,7 +22,7 @@ import ( ) // SyncRequest and SyncResponse are the sync request/reply, aliased to the shared -// wire-contract types (ADR-0035) so grcli and the hub can't drift on them. +// wire-contract types so grcli and the hub can't drift on them. type ( SyncRequest = syncapi.Request SyncResponse = syncapi.Response @@ -124,7 +124,7 @@ type Release struct { // License is this version's publication license (canonical SPDX // expression), exposed per-release by the hub. Absent when none was // declared. Used by reference resolution to compare a dependency's - // license against the primary's (ADR-0039). + // license against the primary's. License string `json:"license,omitempty"` } @@ -155,7 +155,7 @@ type Catalog struct { Releases []Release `json:"releases"` // SignerIdentity is the canonical keyless signer the hub verified and // TOFU-pinned for this coordinate at ingest — "keyless:#", - // ref-stripped (grc-store-protocol/identity, ADR-0045 decision 6). Absent when + // ref-stripped (grc-store-protocol/identity). Absent when // no signed version has been ingested (or the hub predates hub-side // verification); grcli verify's zero-flag mode reads it to derive the cosign // trust policy without the consumer having to know the workflow path. diff --git a/internal/hub/hub_test.go b/internal/hub/hub_test.go index de3c940..8f00590 100644 --- a/internal/hub/hub_test.go +++ b/internal/hub/hub_test.go @@ -144,7 +144,7 @@ func TestGetCatalog_TypedSentinels(t *testing.T) { } } -// TestGetVersionBody covers the reference-resolution body fetch (ADR-0039): +// TestGetVersionBody covers the reference-resolution body fetch: // the 200 path returns the body and the manifest-digest header, and the // typed 404/410 sentinels surface for absent/yanked versions. func TestGetVersionBody(t *testing.T) { diff --git a/internal/hub/regtoken.go b/internal/hub/regtoken.go index 7cdebbe..c05879d 100644 --- a/internal/hub/regtoken.go +++ b/internal/hub/regtoken.go @@ -19,7 +19,7 @@ import ( // FetchRegistryToken exchanges a hub (Keycloak) bearer token for a // short-lived OCI Distribution token scoped to the given repository and // actions, minted by the hub's GET /v2/token endpoint — the bearer realm -// the registry (zot) trusts (ADR-0031 on the backend). +// the registry (zot) trusts. // // The hub grants pull to everyone and push only to a namespace owner or // hub admin, so: diff --git a/internal/refs/refs.go b/internal/refs/refs.go index a8b4797..5d93a07 100644 --- a/internal/refs/refs.go +++ b/internal/refs/refs.go @@ -1,8 +1,8 @@ // SPDX-License-Identifier: Apache-2.0 // Package refs parses the mapping references out of a Gemara artifact body -// and decides which of them grcli unpack should resolve against a hub -// (ADR-0039). It is deliberately pure — no network, no filesystem — so the +// and decides which of them grcli unpack should resolve against a +// hub. It is deliberately pure — no network, no filesystem — so the // selection and host-recognition rules are unit-testable in isolation. // // Gemara models references in two layers (go-gemara generated_types.go): @@ -152,7 +152,7 @@ func (a *Artifact) Select(mode Mode) []Selected { // Recognize decides whether a reference URL points at an artifact resolvable // against the targeted hub, and if so extracts its (namespace, catalogID) from -// the URL path (ADR-0039 decision 2). The version is NOT in the URL — it lives +// the URL path. The version is NOT in the URL — it lives // in the MappingReference.version field. // // Rules, given the host of the --url target: diff --git a/internal/registry/registry.go b/internal/registry/registry.go index d86a5c0..85c098a 100644 --- a/internal/registry/registry.go +++ b/internal/registry/registry.go @@ -44,8 +44,8 @@ type PackInput struct { GemaraVersion string Body []byte Provenance any // marshaled into bundle.Manifest.Metadata - // License is the canonical SPDX publication-license expression - // (ADR-0036). When non-empty it is stamped as the standard OCI + // License is the canonical SPDX publication-license + // expression. When non-empty it is stamped as the standard OCI // manifest annotation org.opencontainers.image.licenses. Empty means // no annotation. The caller (cmd/publish.go) is the strict gate: this // value is already validated and canonicalized via spdx.Canonicalize. @@ -106,7 +106,7 @@ func UnpackRemote(ctx context.Context, registryHost, repository, tag string) (*b // // registryHost may include an http:// or https:// scheme prefix — // useful when the hub's discovery endpoint advertises a full URL via -// HUB_OCI_PUBLIC_URL (ADR-0026). When http://, the resulting client +// HUB_OCI_PUBLIC_URL. When http://, the resulting client // uses plain-HTTP for the upstream registry traffic. When https:// or // no scheme, TLS is used (oras-go's default). func newRemoteRepo(registryHost, repository string) (*remote.Repository, error) { @@ -153,7 +153,7 @@ func stripScheme(in string) (host string, plainHTTP bool) { // NormalizeRegistryHost takes a registry value that may be a bare host // or a full URL (typically the registry_url advertised by a hub via -// ADR-0026's discovery endpoint) and returns a bare host suitable for +// its discovery endpoint) and returns a bare host suitable for // use in an OCI reference (`/:`). Strips any scheme // and trailing slash. Exported for cmd/verify.go and cmd/publish.go, // which need a bare-host string for cosign and for the user-printed @@ -199,8 +199,8 @@ const maxSignatureBlobBytes = limits.MaxPluginBlobBytes // dockerCredentials), so no token needs threading through this signature. // AttachSignatureReferrer pushes a Sigstore signature bundle to the registry as // an OCI 1.1 referrer of the artifact manifest identified by subjectDigest — -// the step `cosign sign` used to perform. It is the in-process publish half of -// ADR-0049 (grcli signs keyless without cosign). Auth flows through the same +// the step `cosign sign` used to perform. It is the in-process publish half +// of keyless signing (grcli signs keyless without cosign). Auth flows through the same // credential chain as the bundle push: the GRCLI_REGISTRY_TOKEN the publish // flow minted and exported. func AttachSignatureReferrer(ctx context.Context, registryHost, repository, subjectDigest string, bundleJSON []byte) error { @@ -421,8 +421,8 @@ func pack(ctx context.Context, target oras.Target, tag string, in PackInput) (oc var packOpts []bundle.PackOption if in.License != "" { - // Standard OCI carrier for the publication license (ADR-0036 - // decision 2). Manifest-level annotation, set only when a license + // Standard OCI carrier for the publication + // license. Manifest-level annotation, set only when a license // is declared so omitting --license leaves the manifest unchanged. packOpts = append(packOpts, bundle.WithAnnotations(map[string]string{ ocispec.AnnotationLicenses: in.License, diff --git a/internal/sign/keyless.go b/internal/sign/keyless.go index 6ec367c..70a878d 100644 --- a/internal/sign/keyless.go +++ b/internal/sign/keyless.go @@ -21,8 +21,8 @@ import ( sgsign "github.com/sigstore/sigstore-go/pkg/sign" ) -// In-process keyless signing (ADR-0049). This is the symmetric half of the -// in-process VERIFY path (ADR-0046, internal/sigverify): grcli signs the +// In-process keyless signing. This is the symmetric half of the +// in-process VERIFY path (internal/sigverify): grcli signs the // pushed artifact with a short-lived Fulcio certificate obtained via the // runner's OIDC token, logs it in Rekor, and produces a Sigstore v0.3 bundle — // all with the sigstore-go library grcli already depends on for verification, diff --git a/internal/sign/keyless_test.go b/internal/sign/keyless_test.go index 5855052..efed843 100644 --- a/internal/sign/keyless_test.go +++ b/internal/sign/keyless_test.go @@ -9,7 +9,7 @@ import ( ) // TestInTotoStatement pins the DSSE payload structure the in-process signer -// emits (ADR-0049): an in-toto Statement v1 whose single subject digest is the +// emits: an in-toto Statement v1 whose single subject digest is the // manifest digest, with cosign's predicateType and empty `{}` (not null) // annotations/predicate. The sign→verify round-trip in internal/sigverify proves // this exact structure verifies; this pins the structure itself. diff --git a/internal/sign/sign.go b/internal/sign/sign.go index 46baed8..59a7604 100644 --- a/internal/sign/sign.go +++ b/internal/sign/sign.go @@ -4,8 +4,7 @@ // // Keyless signing (the CI trusted-publishing path) runs IN-PROCESS via // sigstore-go — the same library internal/sigverify uses to verify — so -// publishing needs no cosign (ADR-0049, symmetric to ADR-0046's in-process -// verify). See keyless.go. Key-based signing (--cosign-key) still shells out to +// publishing needs no cosign (symmetric to the in-process verify). See keyless.go. Key-based signing (--cosign-key) still shells out to // cosign, the one remaining path that needs it on PATH. package sign @@ -37,7 +36,7 @@ const ( // 1.1 referrer of the manifest, instead of the legacy tag-based `sha256-….sig`. // This converges grc.store on one signature format across artifact types: it is // the format the hub's plugin verifier already expects and that pvtr already -// produces (ADR-0034 dec. 7, ADR-0035). +// produces. // // It is EXPORTED so the verify side (cmd/verify.go) references the same constant // — a bundle-signed artifact is verified with `cosign verify --new-bundle-format` @@ -168,7 +167,7 @@ type Options struct { PlainHTTP bool // RegistryHost, Repository, and ManifestDigest are the coordinates the - // keyless in-process path (ADR-0049) needs: it signs ManifestDigest and + // keyless in-process path needs: it signs ManifestDigest and // attaches the bundle as an OCI referrer at RegistryHost/Repository. Unset // for key mode (cosign resolves the reference itself). RegistryHost keeps // any http(s):// scheme so the oras push targets the right transport. @@ -202,7 +201,7 @@ func Preflight(ctx context.Context, opts Options) error { } switch { case os.Getenv("GITHUB_ACTIONS") == "true": - // Keyless in-process (ADR-0049): NO cosign needed — grcli requests the + // Keyless in-process: NO cosign needed — grcli requests the // GHA OIDC token itself and signs via sigstore-go. Requires // `permissions: id-token: write` (which populates the request env). if os.Getenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN") == "" { @@ -253,7 +252,7 @@ func Sign(ctx context.Context, opts Options) (*Result, error) { return nil, fmt.Errorf("sign: %w", err) } - // Keyless in CI runs fully in-process (ADR-0049): sign the manifest digest + // Keyless in CI runs fully in-process: sign the manifest digest // via sigstore-go and attach the bundle as an OCI referrer — no cosign. if os.Getenv("GITHUB_ACTIONS") == "true" { if opts.ManifestDigest == "" || opts.RegistryHost == "" || opts.Repository == "" { diff --git a/internal/sign/sign_test.go b/internal/sign/sign_test.go index 0f19391..2331218 100644 --- a/internal/sign/sign_test.go +++ b/internal/sign/sign_test.go @@ -50,7 +50,7 @@ func TestPreflight(t *testing.T) { } }) - t.Run("CI keyless needs NO cosign on PATH (ADR-0049)", func(t *testing.T) { + t.Run("CI keyless needs NO cosign on PATH", func(t *testing.T) { cosignAbsent(t) t.Setenv("GITHUB_ACTIONS", "true") t.Setenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN", "tok") @@ -124,9 +124,9 @@ func recordingCosign(t *testing.T, version string) string { } // TestSignBundleFormatByCosignVersion pins that grcli selects the Sigstore -// bundle-as-referrer format (ADR-0035) correctly across the cosign range on the -// KEY-based path — the only path that still shells out to cosign (ADR-0049 moved -// keyless in-process, so it no longer invokes cosign at all). It passes +// bundle-as-referrer format correctly across the cosign range on the +// KEY-based path — the only path that still shells out to cosign (keyless +// moved in-process, so it no longer invokes cosign at all). It passes // --new-bundle-format on cosign 2.6–2.x and omits it on ≥ 3.0.0 (where the // bundle format is the default and the flag is deprecated). func TestSignBundleFormatByCosignVersion(t *testing.T) { diff --git a/internal/sigverify/roundtrip_test.go b/internal/sigverify/roundtrip_test.go index 12cbf3d..d5a7575 100644 --- a/internal/sigverify/roundtrip_test.go +++ b/internal/sigverify/roundtrip_test.go @@ -11,7 +11,7 @@ import ( "github.com/gemaraproj/grcli/internal/sign" ) -// TestVerifyEntity_AcceptsInTotoDSSE is the ADR-0049 sign→verify round-trip: it +// TestVerifyEntity_AcceptsInTotoDSSE is the keyless sign→verify round-trip: it // proves the EXACT in-toto DSSE payload grcli's in-process signer emits // (sign.InTotoStatement) verifies against this verifier's WithArtifactDigest // subject check. Since the hub mirrors internal/sigverify, a bundle grcli diff --git a/internal/sigverify/verify.go b/internal/sigverify/verify.go index 6dc4c3b..856aed5 100644 --- a/internal/sigverify/verify.go +++ b/internal/sigverify/verify.go @@ -1,8 +1,8 @@ // SPDX-License-Identifier: Apache-2.0 // Package sigverify is grcli's in-process Sigstore keyless-verification -// substrate (ADR-0046). It is a MIRROR of the hub's internal/sigverify -// (ADR-0034 decision 7 / ADR-0045) — not an import: the backend's internals +// substrate. It is a MIRROR of the hub's internal/sigverify — not an +// import: the backend's internals // aren't importable, and the zero-dependency grc-store-protocol rightly // excludes sigstore-go. Keeping the two in lockstep means "verifies on the hub // but not in grcli" (or vice versa) can only come from policy intent, never @@ -17,7 +17,7 @@ // WithoutIdentitiesUnsafe because it TOFU-pins (first publish accepts any valid // keyless cert, then the handler pins the extracted identity per coordinate). // grcli is the consumer — it already KNOWS the identity to expect (from the -// --certificate-identity flag or the hub's recorded record, ADR-0045) — so it +// --certificate-identity flag or the hub's recorded record) — so it // pins the SAN + issuer IN the sigstore-go policy. The cryptographic floor is // identical; grcli additionally enforces WHO signed. package sigverify @@ -41,7 +41,7 @@ import ( // Result is what a successful Verify yields: the canonical, scheme-prefixed // signer identity recovered from the verified certificate. It matches the hub's -// Result.Identity byte-for-byte (grc-store-protocol/identity, ADR-0035) so a +// Result.Identity byte-for-byte (grc-store-protocol/identity) so a // post-verify confirmation line names the same identity the hub recorded. type Result struct { // Identity is the canonical keyless identity @@ -57,7 +57,7 @@ type Result struct { var ErrUnsigned = errors.New("artifact is not signed") // embeddedTrustedRoot is the pinned public-good Sigstore trust root — the SAME -// material the hub pins (ADR-0034 decision 7), which is exactly why it is no +// material the hub pins, which is exactly why it is no // longer vendored here: grcli, privateer-sdk and the hub each carried a // byte-identical copy, so rotation was three edits and three chances to miss // one. It now comes from grc-store-clientkit, and refreshing it is one release @@ -87,7 +87,7 @@ type Verifier struct { // certificate-identity material cmd/verify.go previously handed to cosign: // // - Explicit --certificate-identity mode: SAN set (exact), SANRegexp empty. -// - Hub-lookup mode (ADR-0045): SANRegexp set (the anchored "^QuoteMeta(path)@" +// - Hub-lookup mode: SANRegexp set (the anchored "^QuoteMeta(path)@" // pattern), SAN empty. The ref-stripped pin admits any git ref but nothing // wider than the exact workflow path. // @@ -133,8 +133,7 @@ func NewVerifier(timeout time.Duration) (*Verifier, error) { } // NewVerifierFromFile builds a verifier over a trusted_root.json read from disk -// instead of the embedded public-good root (GRCLI_TRUSTED_ROOT, ADR-0046 -// decision 4). It serves air-gapped deployments and private Sigstore instances — +// instead of the embedded public-good root (GRCLI_TRUSTED_ROOT). It serves air-gapped deployments and private Sigstore instances — // the same posture as the hub's NewSigstoreVerifierFromFile. The SCT/Rekor/ // timestamp policy is UNCHANGED (a private Sigstore still runs a CT log); only // the set of trusted CAs/logs differs. An empty path is a programming error @@ -256,7 +255,7 @@ func (v *Verifier) verifyEntity(entity verify.SignedEntity, artifactDigest strin return Result{}, errors.New("verified certificate is missing OIDC issuer or SAN") } // The canonical signer identity comes from the shared wire-contract module - // (ADR-0035) — the SAME definition the hub uses — so grcli's confirmation + // — the SAME definition the hub uses — so grcli's confirmation // names the identity in exactly the form the hub recorded. return Result{ Identity: identity.CanonicalKeylessIdentity(cert.Issuer, cert.SubjectAlternativeName), diff --git a/internal/sigverify/verify_test.go b/internal/sigverify/verify_test.go index 35b7aba..35b4d92 100644 --- a/internal/sigverify/verify_test.go +++ b/internal/sigverify/verify_test.go @@ -144,7 +144,7 @@ func TestVerifyEntity_RejectsBadDigestFormat(t *testing.T) { require.Error(t, err) } -// TestVerifyEntity_HubLookupRegexp_Adversarial reuses the ADR-0045 adversarial +// TestVerifyEntity_HubLookupRegexp_Adversarial reuses the hub's adversarial // SAN cases, now asserted against the IN-PROCESS matcher (a real // VirtualSigstore-signed cert carrying each SAN) rather than a cosign arg // string. The anchored "^QuoteMeta(path)@" pin must admit ANY ref of the exact From f71cedee4d5dcc36f017db9a7a65d69c46689276 Mon Sep 17 00:00:00 2001 From: Eddie Knight Date: Thu, 3 Sep 2026 15:23:50 -0500 Subject: [PATCH 6/9] Address Kusari review: pin actions to commit SHAs, disable credential persistence - Pin actions/checkout (v4.4.0), actions/setup-go (v5.6.0), golangci-lint-action (v9.3.0) and sigstore/cosign-installer (v3.9.2) to commit SHAs across ci.yml, release.yml, publish-gemara.yml and the example workflow. - Set persist-credentials: false on every checkout step. - Bump golang.org/x/mod to v0.40.0 (GO-2026-6179 / GO-2026-6180). Signed-off-by: Eddie Knight --- .github/workflows/ci.yml | 14 +++++++++----- .github/workflows/publish-gemara.yml | 6 ++++-- .github/workflows/release.yml | 8 +++++--- examples/github-actions/publish.yml | 4 +++- go.mod | 2 +- go.sum | 4 ++-- 6 files changed, 24 insertions(+), 14 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d9bd63c..702b606 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,22 +12,26 @@ jobs: test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false + - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 with: go-version-file: go.mod cache: true - run: make fmtcheck - run: make vet - run: make testcov - - uses: golangci/golangci-lint-action@v9 + - uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0 with: version: v2.12.2 build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false + - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 with: go-version-file: go.mod cache: true diff --git a/.github/workflows/publish-gemara.yml b/.github/workflows/publish-gemara.yml index 01c9758..440f3c8 100644 --- a/.github/workflows/publish-gemara.yml +++ b/.github/workflows/publish-gemara.yml @@ -63,7 +63,9 @@ jobs: contents: read id-token: write # hub auth + cosign keyless signing (no secret) steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false # grcli ships as a public, signed, multi-platform OCI artifact; pulling # needs no token. v2: https://github.com/oras-project/setup-oras/releases/tag/v2.0.0 @@ -77,7 +79,7 @@ jobs: # Required for keyless signing — the same OIDC identity authorizes the push. - name: Install cosign - uses: sigstore/cosign-installer@v3 + uses: sigstore/cosign-installer@d58896d6a1865668819e1d91763c7751a165e159 # v3.9.2 - name: Publish env: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0272b99..6ccf7f6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -30,9 +30,11 @@ jobs: release: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false - - uses: actions/setup-go@v5 + - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 with: go-version-file: go.mod cache: true @@ -44,7 +46,7 @@ jobs: with: version: 1.3.0 - - uses: sigstore/cosign-installer@v3 + - uses: sigstore/cosign-installer@d58896d6a1865668819e1d91763c7751a165e159 # v3.9.2 - name: Log in to GHCR env: diff --git a/examples/github-actions/publish.yml b/examples/github-actions/publish.yml index fa374ff..d168543 100644 --- a/examples/github-actions/publish.yml +++ b/examples/github-actions/publish.yml @@ -32,7 +32,9 @@ jobs: publish: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false # Fetch the pre-built grcli binary from the public GHCR artifact. # No token needed (the package is public). Pin to a released tag. diff --git a/go.mod b/go.mod index 0bf943f..2a4093a 100644 --- a/go.mod +++ b/go.mod @@ -11,7 +11,7 @@ require ( github.com/spf13/cobra v1.10.2 github.com/spf13/viper v1.21.0 github.com/stretchr/testify v1.11.1 - golang.org/x/mod v0.38.0 + golang.org/x/mod v0.40.0 oras.land/oras-go/v2 v2.6.2 sigs.k8s.io/yaml v1.6.0 ) diff --git a/go.sum b/go.sum index 5321b44..ee9756b 100644 --- a/go.sum +++ b/go.sum @@ -396,8 +396,8 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y= golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I= -golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= -golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= +golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs= +golang.org/x/mod v0.40.0/go.mod h1:0/weTWkPWGBikyTWAX3dkjVztMmBA5hM0DH6BElSupE= golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= From cf73b65638489d440c9c599e8f8d7fdcfe9b3717 Mon Sep 17 00:00:00 2001 From: Eddie Knight Date: Thu, 3 Sep 2026 15:46:57 -0500 Subject: [PATCH 7/9] internal: scrub userinfo from provenance remote URL, reuse digest.Bytes, fix doc placement - provenance: strip credentials from remote.origin.url before it is stamped into signed, immutable provenance (https://user:token@host/... remotes). - cache: digestOf now calls internal/digest.Bytes instead of duplicating it. - registry: move FetchSignatureBundle's doc block onto FetchSignatureBundle; it sat above AttachSignatureReferrer. - sign: rewrap the 136-char comment line left by the ADR strip. Signed-off-by: Eddie Knight --- internal/cache/cache.go | 9 ++--- internal/provenance/provenance.go | 16 +++++++- internal/provenance/provenance_test.go | 15 ++++++++ internal/registry/registry.go | 52 +++++++++++++------------- internal/sign/sign.go | 3 +- 5 files changed, 61 insertions(+), 34 deletions(-) diff --git a/internal/cache/cache.go b/internal/cache/cache.go index baed6a6..eadd8ef 100644 --- a/internal/cache/cache.go +++ b/internal/cache/cache.go @@ -19,8 +19,6 @@ package cache import ( - "crypto/sha256" - "encoding/hex" "encoding/json" "errors" "fmt" @@ -28,6 +26,8 @@ import ( "path/filepath" "strconv" "strings" + + "github.com/gemaraproj/grcli/internal/digest" ) // layoutVersion namespaces the on-disk layout so a format change can coexist @@ -223,10 +223,7 @@ func (c *Cache) Put(host, namespace, id, version string, e Entry) error { // content that bypasses the cache (e.g. under --no-cache). func Digest(b []byte) string { return digestOf(b) } -func digestOf(b []byte) string { - sum := sha256.Sum256(b) - return "sha256:" + hex.EncodeToString(sum[:]) -} +func digestOf(b []byte) string { return digest.Bytes(b) } // sanitize reduces a coordinate component to a safe single path segment: // path separators and parent-dir tokens can't survive, so the join stays diff --git a/internal/provenance/provenance.go b/internal/provenance/provenance.go index 0d39ab7..6394c55 100644 --- a/internal/provenance/provenance.go +++ b/internal/provenance/provenance.go @@ -9,6 +9,7 @@ package provenance import ( "fmt" + "net/url" "os" "os/exec" "runtime" @@ -211,11 +212,24 @@ func detectGit() *ResourceDescr { } return &ResourceDescr{ Name: "source", - URI: "git+" + strings.TrimSuffix(remote, ".git") + "@" + sha, + URI: "git+" + strings.TrimSuffix(stripUserinfo(remote), ".git") + "@" + sha, Digest: map[string]string{"gitCommit": sha}, } } +// stripUserinfo drops credentials embedded in a remote URL +// (https://user:token@host/…) so they never land in signed, immutable +// provenance. scp-style remotes (git@host:path) have no userinfo and pass +// through unchanged. +func stripUserinfo(remote string) string { + u, err := url.Parse(remote) + if err != nil || u.Scheme == "" || u.User == nil { + return remote + } + u.User = nil + return u.String() +} + func gitCmd(args ...string) (string, error) { cmd := exec.Command("git", args...) out, err := cmd.Output() diff --git a/internal/provenance/provenance_test.go b/internal/provenance/provenance_test.go index 89fd04e..f53abeb 100644 --- a/internal/provenance/provenance_test.go +++ b/internal/provenance/provenance_test.go @@ -78,3 +78,18 @@ func TestBuild_SerializesAsValidJSON(t *testing.T) { require.Contains(t, string(b), `"buildType"`) require.Contains(t, string(b), `"resolvedDependencies"`) } + +func TestStripUserinfo(t *testing.T) { + cases := map[string]string{ + "https://user:tok3n@github.com/org/repo.git": "https://github.com/org/repo.git", + "https://x-access-token:ghs_abc@github.com/org/repo": "https://github.com/org/repo", + "https://github.com/org/repo.git": "https://github.com/org/repo.git", + "git@github.com:org/repo.git": "git@github.com:org/repo.git", + "ssh://git@github.com/org/repo.git": "ssh://github.com/org/repo.git", + } + for in, want := range cases { + if got := stripUserinfo(in); got != want { + t.Errorf("stripUserinfo(%q) = %q, want %q", in, got, want) + } + } +} diff --git a/internal/registry/registry.go b/internal/registry/registry.go index 85c098a..5f0953f 100644 --- a/internal/registry/registry.go +++ b/internal/registry/registry.go @@ -171,32 +171,6 @@ func NormalizeRegistryHost(in string) string { // grcli and the hub agree on what "too big to be a signature" means. const maxSignatureBlobBytes = limits.MaxPluginBlobBytes -// FetchSignatureBundle resolves /: to its manifest -// and returns the raw Sigstore bundle bytes attached as an OCI referrer, plus -// the manifest digest the signature is bound to (the value the verifier's -// artifact-digest policy checks). It returns (nil, digest, nil) when no -// signature referrer is present — a nil bundle is the caller's ErrUnsigned -// signal, NOT an error; an error is reserved for a genuine transport/parse -// failure so the caller can fail closed (we cannot claim "unsigned" if we could -// not look). -// -// Discovery accepts BOTH referrer artifactTypes a cosign-signed catalog can -// carry, because the stamped type is a function of the SIGNER's cosign major -// version (field-confirmed against a live zot 2026-07-07): -// -// cosign 2.6.x `sign --new-bundle-format` → mediatype.CosignSignReferrer -// cosign 3.x `sign` (bundle by default) → mediatype.SigstoreBundle -// -// The bundle BLOB inside is the identical v0.3 bundle either way. Publishers -// control their own cosign version, so filtering on a single type silently -// treats the other cohort's signed catalogs as unsigned (the earlier -// CosignSignReferrer-only filter did exactly that for cosign-3.x publishes). -// This supersedes grc-store-protocol/mediatype's "RULE — do not cross these", -// whose premise predates cosign 3.x. -// -// Auth flows through the same credential chain as UnpackRemote (the -// GRCLI_REGISTRY_TOKEN the caller minted via ensureRegistryToken is read by -// dockerCredentials), so no token needs threading through this signature. // AttachSignatureReferrer pushes a Sigstore signature bundle to the registry as // an OCI 1.1 referrer of the artifact manifest identified by subjectDigest — // the step `cosign sign` used to perform. It is the in-process publish half @@ -259,6 +233,32 @@ func packSignatureReferrer(ctx context.Context, target oras.Target, subject ocis return nil } +// FetchSignatureBundle resolves /: to its manifest +// and returns the raw Sigstore bundle bytes attached as an OCI referrer, plus +// the manifest digest the signature is bound to (the value the verifier's +// artifact-digest policy checks). It returns (nil, digest, nil) when no +// signature referrer is present — a nil bundle is the caller's ErrUnsigned +// signal, NOT an error; an error is reserved for a genuine transport/parse +// failure so the caller can fail closed (we cannot claim "unsigned" if we could +// not look). +// +// Discovery accepts BOTH referrer artifactTypes a cosign-signed catalog can +// carry, because the stamped type is a function of the SIGNER's cosign major +// version (field-confirmed against a live zot 2026-07-07): +// +// cosign 2.6.x `sign --new-bundle-format` → mediatype.CosignSignReferrer +// cosign 3.x `sign` (bundle by default) → mediatype.SigstoreBundle +// +// The bundle BLOB inside is the identical v0.3 bundle either way. Publishers +// control their own cosign version, so filtering on a single type silently +// treats the other cohort's signed catalogs as unsigned (the earlier +// CosignSignReferrer-only filter did exactly that for cosign-3.x publishes). +// This supersedes grc-store-protocol/mediatype's "RULE — do not cross these", +// whose premise predates cosign 3.x. +// +// Auth flows through the same credential chain as UnpackRemote (the +// GRCLI_REGISTRY_TOKEN the caller minted via ensureRegistryToken is read by +// dockerCredentials), so no token needs threading through this signature. func FetchSignatureBundle(ctx context.Context, registryHost, repository, tag string) (bundleJSON []byte, artifactDigest string, err error) { if tag == "" { return nil, "", errors.New("tag is required") diff --git a/internal/sign/sign.go b/internal/sign/sign.go index 59a7604..ae8f492 100644 --- a/internal/sign/sign.go +++ b/internal/sign/sign.go @@ -4,7 +4,8 @@ // // Keyless signing (the CI trusted-publishing path) runs IN-PROCESS via // sigstore-go — the same library internal/sigverify uses to verify — so -// publishing needs no cosign (symmetric to the in-process verify). See keyless.go. Key-based signing (--cosign-key) still shells out to +// publishing needs no cosign (symmetric to the in-process verify). See +// keyless.go. Key-based signing (--cosign-key) still shells out to // cosign, the one remaining path that needs it on PATH. package sign From c816821b01add6c9ec36a8ae1bd9a82f38929b86 Mon Sep 17 00:00:00 2001 From: Eddie Knight Date: Thu, 3 Sep 2026 15:46:57 -0500 Subject: [PATCH 8/9] chore: repo hygiene from panel review - publish-gemara.yml: drop the cosign-installer step; keyless signing is in-process and never looks for cosign (sign.Preflight). - install action: resolve the tag once and verify + pull by digest, so a retag between the two steps cannot install an unverified binary. - Gate tidycheck in ci-local and CI. - Add CONTRIBUTING.md (DCO sign-off, dev loop) and SECURITY.md. - release.yml: header rewritten for a public repo and package. - CLAUDE.md: replace umbrella-relative paths that resolve nowhere in a standalone clone. - .gitignore: restore go.work / go.work.sum. - examples: drop the dated smoke-test note. Signed-off-by: Eddie Knight --- .github/actions/install/action.yml | 5 +++- .github/workflows/ci.yml | 1 + .github/workflows/publish-gemara.yml | 4 --- .github/workflows/release.yml | 8 ++---- .gitignore | 2 ++ CLAUDE.md | 8 +++--- CONTRIBUTING.md | 43 ++++++++++++++++++++++++++++ Makefile | 2 +- SECURITY.md | 41 ++++++++++++++++++++++++++ examples/github-actions/publish.yml | 3 +- 10 files changed, 99 insertions(+), 18 deletions(-) create mode 100644 CONTRIBUTING.md create mode 100644 SECURITY.md diff --git a/.github/actions/install/action.yml b/.github/actions/install/action.yml index 5ec093b..06ee745 100644 --- a/.github/actions/install/action.yml +++ b/.github/actions/install/action.yml @@ -46,7 +46,10 @@ runs: esac bin=grcli; [ "$os" = "windows" ] && bin=grcli.exe - ref="${image}:${GRCLI_VERSION}" + # Resolve the tag once and use the digest for both verify and pull, so a + # retag between the two steps cannot install a binary that was never + # verified. + ref="${image}@$(oras resolve "${image}:${GRCLI_VERSION}")" if [ "${GRCLI_VERIFY}" = "true" ]; then if ! command -v cosign >/dev/null 2>&1; then diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 702b606..9d53636 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,6 +21,7 @@ jobs: cache: true - run: make fmtcheck - run: make vet + - run: make tidycheck - run: make testcov - uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0 with: diff --git a/.github/workflows/publish-gemara.yml b/.github/workflows/publish-gemara.yml index 440f3c8..c9bd4c9 100644 --- a/.github/workflows/publish-gemara.yml +++ b/.github/workflows/publish-gemara.yml @@ -77,10 +77,6 @@ jobs: oras pull "ghcr.io/gemaraproj/grcli:$GRCLI_VERSION" --platform linux/amd64 sudo install grcli /usr/local/bin/grcli - # Required for keyless signing — the same OIDC identity authorizes the push. - - name: Install cosign - uses: sigstore/cosign-installer@d58896d6a1865668819e1d91763c7751a165e159 # v3.9.2 - - name: Publish env: FILES: ${{ inputs.files }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6ccf7f6..bc0f948 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,12 +3,8 @@ name: release # Publish grcli as a public, multi-platform OCI artifact on GHCR, signed # keyless with cosign. Triggered by pushing a semver tag (e.g. v0.1.0). # -# The source repo stays private; the published *package* is public (GHCR -# package visibility is independent of repo visibility). Make it public -# once, after the first run: GitHub -> repo -> Packages -> grcli -> -# Package settings -> Change visibility -> Public. After that, anyone can -# `oras pull` the binary with no token. See README "Install a pre-built -# binary". +# The published *package* is public, so anyone can `oras pull` the binary +# with no token. See README "Install or Upgrade". # # Native macOS binaries ride along because these are raw OCI artifacts, # not container images (images can't carry darwin binaries). diff --git a/.gitignore b/.gitignore index 25ae32a..3fd5417 100644 --- a/.gitignore +++ b/.gitignore @@ -5,5 +5,7 @@ coverage.out grcli-out/ .grcli.yaml .env +go.work +go.work.sum .claude/ diff --git a/CLAUDE.md b/CLAUDE.md index f3b56dd..0974a85 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,12 +8,12 @@ Go module and repo: `github.com/gemaraproj/grcli`; releases publish to `ghcr.io/ `CHANGELOG.md` tracks the pre-1.0 breaking changes. This file is the map — point there, don't duplicate. > **Building new end-user tooling? Reuse this, don't fork it.** The `internal/` packages below -> are the intended reuse surface, and the wire types come from `../grc-store-protocol`. See the -> reuse map in `../CLAUDE.md`. +> are the intended reuse surface, and the wire types come from +> `github.com/revanite-io/grc-store-protocol`. ## Dev loop (Makefile) - `make build` → `bin/grcli` · `make test` (`./...`) · `make lint` (golangci-lint) · `make vet` -- `make ci-local` — fmtcheck + vet + lint + testcov (the CI gate) +- `make ci-local` — fmtcheck + vet + lint + tidycheck + testcov (the CI gate) ## Commands (`cmd/`) `login`/`logout` (OIDC device flow, credential storage) · `validate` (YAML vs Gemara spec via @@ -44,4 +44,4 @@ credential file and login hints — pass it to every clientkit auth call. - Config: flag > `GRCLI_*` env > user-global `$XDG_CONFIG_HOME/grcli/config.yaml` (→ `~/.config/grcli/config.yaml`) > default. **No per-project layer** — a repo-local `./.grcli.yaml` is not read (a committed file must not steer a publish/verify tool) and earns a migration warning. `--config ` bypasses the search. The cache toggle key is flat `cache-enabled` (not nested `cache.enabled`) because `$GRCLI_CACHE` shadows the `cache.*` viper namespace. Env prefix `GRCLI_*` (e.g. `GRCLI_REGISTRY_TOKEN`, `GRCLI_GEMARA_SPEC_DIR`). `grcli verify`'s `--certificate-oidc-issuer` defaults to GitHub Actions. (Neither `./.grcli.yaml` nor `$HOME/.grcli.yaml` is read.) - Credentials stored at `$XDG_DATA_HOME/grcli/credentials.json` (0600). - CI publishing uses GitHub Actions OIDC (`ACTIONS_ID_TOKEN_REQUEST_URL/_TOKEN`); example at `examples/github-actions/publish.yml`. -- **grcli defaults to the *prod* hub** — for test publishing use `../publish-fixtures/` (forces preview). +- **grcli defaults to the *prod* hub** — for test publishing pass `--url https://hub.preview.grc.store` (or set `GRCLI_URL`). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..4002355 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,43 @@ +# Contributing to grcli + +Thanks for helping out. This is a small Go CLI; the dev loop is the `Makefile`. + +## Developer Certificate of Origin + +Every commit must be signed off under the [DCO](https://developercertificate.org/). +A bot checks this on every pull request, so use `-s`: + +```sh +git commit -s -m "fix: ..." +``` + +If you forget, `git commit --amend -s` (or `git rebase --signoff main`) adds the +trailer. The sign-off name and email must match the commit author. + +## Dev loop + +```sh +make build # bin/grcli +make test # go test ./... +make ci-local # fmtcheck + vet + lint + tidycheck + testcov — what CI runs +``` + +`make lint` needs [golangci-lint](https://golangci-lint.run/) at the version +pinned in `.github/workflows/ci.yml`. `grcli validate` needs `cue` on `PATH`; +key-based signing (`--cosign-key`) needs `cosign` ≥ 2.6.0. Keyless signing and +all verification are in-process and need neither. + +## Pull requests + +- Keep PRs focused; one change per PR. +- Add or update a test for behaviour you change. Every command has a `_test.go` + next to it in `cmd/`, and `cmd/integration_test.go` covers end-to-end flows. +- Note user-visible changes in `CHANGELOG.md`. This project is pre-1.0: while + on `v0.x`, a breaking change bumps the minor version. +- Do not add secrets or tokens to workflows. Publishing uses the GitHub Actions + OIDC token (`permissions: id-token: write`) and nothing else. + +## Reporting security issues + +See [SECURITY.md](SECURITY.md). Please do not open a public issue for a +vulnerability. diff --git a/Makefile b/Makefile index b16032a..747af07 100644 --- a/Makefile +++ b/Makefile @@ -40,7 +40,7 @@ tidycheck: mv go.mod.bak go.mod; mv go.sum.bak go.sum; \ if [ -n "$$diff" ]; then echo "go mod tidy would change go.mod/go.sum"; exit 1; fi -ci-local: fmtcheck vet lint testcov +ci-local: fmtcheck vet lint tidycheck testcov clean: rm -rf bin coverage.out grcli-out diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..a9bec89 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,41 @@ +# Security Policy + +`grcli` signs, publishes and verifies compliance artifacts. A flaw in its +signing or verification path can let a tampered artifact pass as trusted, so +we take reports seriously and welcome responsible disclosure. + +## Reporting a vulnerability + +**Please do not file public GitHub issues for security vulnerabilities.** + +Open a private advisory at +https://github.com/gemaraproj/grcli/security/advisories/new. It gives us a +private space to work with you and integrates with CVE issuance. + +Please include: + +- A description of the issue and the affected path (`publish`, `verify`, + `unpack`/`cat`, the cache, a workflow under `.github/`, …). +- Reproduction steps or a proof of concept where possible. +- The release tag or commit you tested against. + +## What to expect + +- We aim to acknowledge new reports within **3 business days** and give a + triage assessment within **10 business days**. +- For accepted reports we coordinate a fix and disclosure timeline with you. + The default embargo is **90 days** from the initial report. +- We credit reporters in the published advisory unless asked not to. + +## Supported versions + +`grcli` is pre-1.0. Security fixes land on `main` and ship in the next tagged +release; only the latest release is supported. + +## Scope + +In scope: everything in this repository, including the workflows and the +reusable publish workflow / install action under `.github/`. + +Out of scope: the hub service, the OCI registry, and Sigstore infrastructure, +which are separate projects with their own policies. diff --git a/examples/github-actions/publish.yml b/examples/github-actions/publish.yml index d168543..93639f2 100644 --- a/examples/github-actions/publish.yml +++ b/examples/github-actions/publish.yml @@ -49,8 +49,7 @@ jobs: # signing runs IN-PROCESS via sigstore-go: grcli requests the # Actions OIDC token itself, gets a Fulcio certificate, logs to Rekor and # attaches the signature as an OCI referrer — no external tools, no - # secrets. Verified by a live CI smoke on 2026-08-19 from a runner with - # no cosign on PATH. cosign is still needed ONLY for the `--cosign-key` + # secrets. cosign is still needed ONLY for the `--cosign-key` # (key-based) path, which this example does not use. # # Pin a tag, never `:latest`, so a bad release cannot reach you From d1cc1a0bfcbc92a175fa088f6c573a0a0b782d0b Mon Sep 17 00:00:00 2001 From: Eddie Knight Date: Thu, 3 Sep 2026 16:28:23 -0500 Subject: [PATCH 9/9] ci: disable setup-go cache in the privileged release job Kusari flagged module caching in a job holding packages:write and id-token:write as cache-poisoning exposure for signed release artifacts. Signed-off-by: Eddie Knight --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bc0f948..29f67d7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -33,7 +33,7 @@ jobs: - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 with: go-version-file: go.mod - cache: true + cache: false # release job holds packages:write + id-token:write; no cache poisoning surface (Kusari) # oras >= 1.3 is required for --artifact-platform and # `oras manifest index create`.