diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 6219d88..ea231e4 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -9,6 +9,7 @@ on: - .github/workflows/ci.yaml - .github/workflows/verify.yaml - .github/workflows/release.yaml + - .github/workflows/rekor-monitor.yaml - .github/actions/** - .gitattributes - actions/** @@ -25,6 +26,7 @@ on: - .github/workflows/ci.yaml - .github/workflows/verify.yaml - .github/workflows/release.yaml + - .github/workflows/rekor-monitor.yaml - .github/actions/** - .gitattributes - actions/** @@ -59,6 +61,10 @@ jobs: run: go vet ./... - name: Test Go packages run: go test ./... + - name: Vet Rekor proof helper + run: go -C tools/rekor-entry vet ./... + - name: Test Rekor proof helper + run: go -C tools/rekor-entry test ./... shell-tests: runs-on: ubuntu-24.04 diff --git a/.github/workflows/rekor-monitor.yaml b/.github/workflows/rekor-monitor.yaml new file mode 100644 index 0000000..874d0af --- /dev/null +++ b/.github/workflows/rekor-monitor.yaml @@ -0,0 +1,138 @@ +name: Rekor identity monitor + +on: + schedule: + - cron: "23 * * * *" + workflow_dispatch: + inputs: + bootstrap: + description: "Initialize the first checkpoint (first run only; no historical coverage)." + type: boolean + default: false + +permissions: + contents: read + actions: read + +concurrency: + group: rekor-identity-monitor + cancel-in-progress: false + +jobs: + monitor: + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + timeout-minutes: 50 + env: + GOTOOLCHAIN: local + GOFLAGS: -mod=readonly + GOPROXY: https://proxy.golang.org + GOSUMDB: sum.golang.org + MONITOR_VERSION: v0.0.0-20261019132539-47017b2379cb + # Test-channel placeholder only; no webhook or production routing is configured. + ALERT_CHANNEL: "#ops-testing-new-alerts" + steps: + - name: Checkout monitor adapter + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + with: + persist-credentials: false + + - name: Enforce runner CA trust + uses: ./.github/actions/ca-trust-guard + + - name: Install locked Go toolchain + uses: ./.github/actions/locked-tools + with: + tools: go + + - name: Restore newest valid monitor state + id: restore + env: + GH_TOKEN: ${{ github.token }} + BOOTSTRAP: ${{ inputs.bootstrap || false }} + run: | + set -euo pipefail + mkdir -p monitor-state + workflow_id="$(gh api "repos/$GITHUB_REPOSITORY/actions/workflows/rekor-monitor.yaml" --jq '.id')" + candidates="$(gh api --paginate --slurp \ + "repos/$GITHUB_REPOSITORY/actions/artifacts?name=rekor-monitor-state&per_page=100" | + jq -r '[.[].artifacts[] | select(.name == "rekor-monitor-state" and .expired == false and .workflow_run.head_branch == "main")] | sort_by(.created_at) | reverse | .[].workflow_run.id')" + run_id="" + candidate_dir="" + trap 'if [ -n "$candidate_dir" ]; then rm -rf "$candidate_dir"; fi' EXIT + while IFS= read -r candidate; do + [ -n "$candidate" ] || continue + candidate_workflow="$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$candidate" --jq '.workflow_id')" + if [ "$candidate_workflow" = "$workflow_id" ]; then + candidate_dir="$(mktemp -d "./.rekor-state.XXXXXX")" + if ! gh run download "$candidate" --repo "$GITHUB_REPOSITORY" \ + --name rekor-monitor-state --dir "$candidate_dir"; then + echo "::error::download failed for run $candidate" >&2 + exit 1 + fi + if test -s "$candidate_dir/checkpoint.txt" && + test -s "$candidate_dir/reconciliation.json"; then + rmdir monitor-state + mv "$candidate_dir" monitor-state + candidate_dir="" + run_id="$candidate" + break + fi + echo "::warning::skipping incomplete state artifact from run $candidate" >&2 + rm -rf "$candidate_dir" + candidate_dir="" + fi + done <<< "$candidates" + if [ -z "$run_id" ] && [ "$BOOTSTRAP" != true ]; then + echo "::error::No monitor state exists; initialize once with the bootstrap dispatch input." + exit 1 + elif [ -z "$run_id" ]; then + echo "bootstrap=true" >> "$GITHUB_OUTPUT" + fi + + - name: Install pinned upstream monitor + run: go install "github.com/sigstore/rekor-monitor/cmd/rekor_monitor@$MONITOR_VERSION" + + - name: Build single-purpose entry proof verifier + working-directory: tools/rekor-entry + run: | + set -euo pipefail + go build -o "$RUNNER_TEMP/rekor-entry" . + + - name: Verify checkpoint and scan identities + run: | + set -euo pipefail + "$(go env GOPATH)/bin/rekor_monitor" --once=true --monitor-port=0 \ + --url https://rekor.sigstore.dev \ + --file monitor-state/checkpoint.txt --config-file tools/rekor-monitor.yaml \ + 2>&1 | tee monitor-state/diagnostics.log + touch monitor-state/identities.jsonl + + - name: Check signed digests and checkpoint progress + env: + GH_TOKEN: ${{ github.token }} + REKOR_ENTRY_HELPER: ${{ runner.temp }}/rekor-entry + MONITOR_BOOTSTRAP: ${{ steps.restore.outputs.bootstrap || false }} + run: bash scripts/rekor_digest_check.sh + + - name: Check monitor state completeness + id: state + if: always() + run: | + if [ -s monitor-state/checkpoint.txt ] && [ -s monitor-state/reconciliation.json ]; then + echo "state_complete=true" >> "$GITHUB_OUTPUT" + fi + + - name: Persist verified checkpoint and reconciliation state + if: always() && steps.state.outputs.state_complete == 'true' + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: rekor-monitor-state + path: monitor-state/ + if-no-files-found: error + retention-days: 30 + + - name: Alert delivery placeholder + if: failure() + run: | + echo '::warning::Rekor monitor needs attention. Alert delivery is not wired; intended test channel: #ops-testing-new-alerts.' diff --git a/Makefile b/Makefile index 39dbefd..b622685 100644 --- a/Makefile +++ b/Makefile @@ -16,12 +16,14 @@ test: test-go test-scripts test-go: go test ./cmd/extract-images ./cmd/record-release ./cmd/generate-manifest ./cmd/merge-manifests ./cmd/publish-public-ecr-release-tags GOTOOLCHAIN=local GOPROXY=off go test ./cmd/ca-trust-guard/... + GOTOOLCHAIN=local go -C tools/rekor-entry test ./... .PHONY: test-scripts test-scripts: bash scripts/test-derive-iam-role-name.sh bash scripts/test-normalize-release-options.sh python3 scripts/test-release-config-templates.py + python3 scripts/test-rekor-monitor.py python3 scripts/test-check-image-locks.py python3 scripts/test-windows-pre-build-script.py python3 scripts/test-safe-relative-path.py diff --git a/README.md b/README.md index 8deaded..1037ce9 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,108 @@ Use the [CA Trust Guard action](.github/actions/ca-trust-guard/README.md) as a job's first step for offline incident-chain checks, fingerprint-checked OS denial and a committed clean-bundle environment. No caller checkout is needed. +## Rekor Identity Monitor + +`.github/workflows/rekor-monitor.yaml` runs hourly on `main`, watching the three +public release-workflow SANs and the GitHub Actions issuer configured in +`tools/rekor-monitor.yaml`. It installs the upstream monitor at +`v0.0.0-20261019132539-47017b2379cb` through the Go module proxy and checksum +database, with `GOTOOLCHAIN=local` and `GOFLAGS=-mod=readonly`. +The module sum is `h1:+ICdEtSt8tWgvUR8YdwoMOavuNf2yWEqH5FXSpxVdhI=`. +The existing locked-tools action supplies the Go toolchain. + +The upstream binary verifies signed checkpoints and consistency with the prior +checkpoint; it does **not** verify matched entries' inclusion. The small isolated +`tools/rekor-entry` Go helper verifies the fetched entry's RFC 6962 inclusion, +body/UUID binding, signed timestamp, checkpoint signature and consistency with +the monitor's verified view. A proof's signed checkpoint may be newer than the +saved view, which is why simply comparing roots in Python would be insufficient. +The helper emits authenticated certificate metadata for hashedrekord entries; +the stdlib-only Python adapter performs the public-data joins. + +Fulcio chain checks use committed public roots and intermediates extracted from +the public-good TUF `trusted_root.json` after a Sigstore Go TUF refresh: + +| Material | SHA256 | +| --- | --- | +| `tools/rekor-fulcio-roots.pem` (2 roots) | `d543eb6e067c069c6b2fd2c22258452db2f923b221597303a6bce4c664209ee6` | +| `tools/rekor-fulcio-intermediates.pem` (1 intermediate) | `ac5a60ca80f473f8b7742111f9eb49bf7dbd8f03f41a5b39d58b62b6f0de4d0f` | +| Verified `trusted_root.json` source | `6494e21ea73fa7ee769f85f57d5a3e6a08725eae1e38c755fc3517c9e6bc0b66` | + +Extraction used TUF snapshot version 166, targets version 14 and root version 16. +Root changes require a reviewed refresh; this does not install a new OS trust +anchor. Non-chaining certificates are skipped by upstream before identity +matching and retained as informational chain-failure diagnostics. The entry +helper likewise reports `UNAUTHENTICATED-SAN`, never a run-identity match. + +Each supported entry has separate **run-identity-match** and +**artifact-authorized** verdicts. A real concluded GitHub run, pinned reusable +workflow reference, matching caller commit and plausible signature time prove +the former, not the latter. Certificate repository/tag extensions locate the +public catalog index, connector index and exact release manifest at +`https://dist.conductorone.com`. Known archive/checksum hashes and the hash of +the published manifest bytes are joinable signed-byte digests. Missing runs, +unexpected workflow refs and unrecorded digests on artifact-only releases are +anomalies. In-flight runs and not-yet-published digests stay `PENDING` for at most +six hours; the pending observations persist across cron runs. + +`UNKNOWN` is not success or an anomaly: hidden catalogs (including HTTP 403), +unknown storage aliases, missing certificate extensions, DSSE/intoto payloads +and non-joinable signatures are never given invented joins. A hashedrekord OCI +signature records the hash of simple-signing payload bytes, **not** the image +digest. The current manifest has no `signed` flag: the ambiguity budget is the +number of public-ECR `images` entries with `isIndex: true`; private Lambda +images do not count. At most that many distinct unrecorded hashes per release +remain `UNKNOWN` after reconciliation; the next is an anomaly. The budget +persists across runs, counts distinct hashes rather than duplicate log entries, +and must be revisited if the signed-image set or manifest schema changes. + +### Initialization, state and alert handoff + +After review/merge, dispatch the workflow once on `main` with `bootstrap: true`. +The first checkpoint establishes a baseline, **not** historical coverage. +Subsequent runs restore the newest non-expired `rekor-monitor-state` artifact +from a main-branch run of this workflow (30-day retention), preserving the +checkpoint, pending entries, digest budget and reports. Artifact-less cancelled +or early-failed runs cannot wedge restoration. Each candidate is downloaded to +an isolated temporary directory and accepted only when `checkpoint.txt` and +`reconciliation.json` are both non-empty; incomplete candidates fall through to +older artifacts with a warning. Download failures instead fail closed without +falling back, so an older budget or observation clock cannot be re-published as +the newest state. An always-run completeness check applies the same requirement +before uploading state, so partial first attempts do not perpetuate incomplete +artifacts. Missing or expired usable state fails closed rather than silently +restarting; an operator must recover the last valid artifact before resuming. +Artifacts and job logs remain the evidence; no state is committed by the bot. + +Signed checkpoints contain no timestamp. The adapter persists the first +wall-clock observation of each origin/size/root and fails if the same view is +unchanged for more than two hours. Changing only its note signature does not +reset the clock. Initial-view currency is unproven, and a quiet log cannot be +distinguished from a stalled feed at first observation. + +Alerts are **not wired**: reports and failure annotations name the test-channel +placeholder `#ops-testing-new-alerts`; no webhook or production routing is +configured. Configure and exercise delivery to that test channel before any +production alerting. No attacker tokens, live signing or self-signed canary +entries are requested by this workflow. + +Recorded low-priority follow-ups: add a scheduled dedicated-identity canary to +close the initial-view/liveness gap; compare DSSE payload hashes to payloads in +published provenance/SBOM bundles; schedule reviewed TUF root refreshes; and +index published OCI simple-signing payload hashes. Until the last item, replay +inside an image release's ambiguity budget is undetected. A replay over an +already-recorded digest is inherently indistinguishable. This is a detection +aid over published data, not a replacement for registry signature verification +or proof that a run intended particular bytes. + +Offline checks: `python3 scripts/test-rekor-monitor.py`, +`go -C tools/rekor-entry test ./...`, and `make test-scripts`. They use synthetic +fixtures, including replay, missing run, frozen checkpoint, pending/resolved +publication, image-budget exhaustion, altered proofs and untrusted certificates. +Live fleet/axiomatic release observation and alert delivery remain rollout +rehearsals, not claims established by fixture tests. + ## PR Review Workflow Runs Claude-powered PR review for same-repository pull requests. Fork PRs are skipped diff --git a/scripts/rekor_digest_check.py b/scripts/rekor_digest_check.py new file mode 100755 index 0000000..dcc816c --- /dev/null +++ b/scripts/rekor_digest_check.py @@ -0,0 +1,230 @@ +#!/usr/bin/env python3 +"""Read authenticated v1 entries; reconcile public release digests, never payload hashes.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import re +import subprocess +import sys +import time +from datetime import datetime +from urllib.error import HTTPError +from urllib.parse import quote, urlsplit +from urllib.request import HTTPRedirectHandler, Request, build_opener + +DIST = "https://dist.conductorone.com" +GITHUB = "https://api.github.com" +ISSUER = "https://token.actions.githubusercontent.com" +PINNED = { + "https://github.com/ConductorOne/github-workflows/.github/workflows/release.yaml@refs/tags/v4", + "https://github.com/ConductorOne/github-axiomatic-workflows/.github/workflows/release.yaml@refs/tags/v1", + "https://github.com/ConductorOne/multipass-workflows/.github/workflows/release-artifacts.yaml@refs/tags/v1", +} +WINDOW = 6 * 3600 +FRESHNESS = 2 * 3600 + + +class NoRedirect(HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + return None + + +def fetch(url): + if urlsplit(url).scheme != "https" or urlsplit(url).netloc not in {"dist.conductorone.com", "api.github.com"}: + raise ValueError("unexpected lookup host") + headers = {"User-Agent": "release-rekor-monitor"} + if url.startswith(GITHUB + "/") and os.environ.get("GH_TOKEN"): + headers["Authorization"] = f"Bearer {os.environ['GH_TOKEN']}" + with build_opener(NoRedirect()).open(Request(url, headers=headers), timeout=30) as response: + return response.read() + + +def checkpoint_progress(checkpoint, previous, now): + # Signed checkpoints have no timestamp. Track the first observation of the + # SAME origin/size/root; a new note signature alone is not log progress. + fields = checkpoint.replace("\\n", "\n").splitlines() + if len(fields) < 3 or not fields[1].isdigit(): + raise ValueError("malformed checkpoint") + view = hashlib.sha256("\n".join(fields[:3]).encode()).hexdigest() + first = previous.get("first_observed", now) if previous.get("view") == view else now + if first > now: + raise ValueError("checkpoint observation clock moved backwards") + return {"view": view, "first_observed": first}, now - first > FRESHNESS + + +def timestamp(value): + return datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp() + + +def run_identity(entry, get=fetch): + identity = entry.get("identity", {}) + san = identity.get("subjectAlternativeName", "") + if san not in PINNED or identity.get("issuer") != ISSUER: + return "ANOMALY", "unexpected workflow identity" + repository_uri = identity.get("sourceRepositoryURI", "") + repository = repository_uri.removeprefix("https://github.com/") + run_uri = identity.get("runInvocationURI", "") + match = re.fullmatch(r"https://github\.com/([A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)/actions/runs/([0-9]+)(?:/attempts/([0-9]+))?", run_uri) + if not repository_uri.startswith("https://github.com/") or not match or match[1] != repository or not identity.get("sourceRepositoryDigest"): + return "UNKNOWN", "missing or non-joinable certificate run extensions" + try: + endpoint = f"{GITHUB}/repos/{repository}/actions/runs/{match[2]}" + if match[3]: + endpoint += f"/attempts/{match[3]}" + run = json.loads(get(endpoint)) + except HTTPError as error: + missing_public_run = error.code == 404 and identity.get("sourceRepositoryVisibilityAtSigning") == "public" + return ("ANOMALY", "missing run") if missing_public_run else ("UNKNOWN", "run lookup unavailable") + if run.get("repository", {}).get("full_name", "").lower() != repository.lower() or run.get("head_sha") != identity["sourceRepositoryDigest"]: + return "ANOMALY", "run repository or commit differs from certificate" + if run.get("status") != "completed": + return "PENDING", "run has not concluded" + # GitHub returns a call spelling such as @v4 plus a separate canonical ref. + references = {item.get("path", "").rsplit("@", 1)[0] + "@" + item.get("ref", "") + for item in run.get("referenced_workflows", [])} + if san.removeprefix("https://github.com/") not in references: + return "ANOMALY", "run does not reference the pinned workflow" + signed_at = entry["integrated_time"] + if not timestamp(run["run_started_at"]) - 600 <= signed_at <= timestamp(run["updated_at"]) + 600: + return "ANOMALY", "signature time is not plausible for the run" + return "MATCH", "real concluded run on the pinned workflow" + + +def release_data(identity, get=fetch): + repository = identity.get("sourceRepositoryURI", "").removeprefix("https://github.com/") + ref = identity.get("sourceRepositoryRef", "") + if not re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", repository) or not ref.startswith("refs/tags/"): + return None, "UNKNOWN", "missing repository/tag or unknown storage alias" + tag = ref.removeprefix("refs/tags/") + if not tag or "/" in tag or tag in {".", ".."}: + return None, "UNKNOWN", "non-joinable release tag" + catalog = json.loads(get(DIST + "/index.json"))["catalogs"].get(repository) + if not catalog or catalog.get("hidden"): + return None, "UNKNOWN", "catalog hidden or storage alias not published" + base = f"{DIST}/releases/{repository}" + index = json.loads(get(base + "/index.json")) + release = index["semvers"].get(tag) + if not release: + return None, "PENDING", "release not published yet" + url = f"{base}/{quote(tag, safe='')}/manifest.json" + if release.get("manifest") != url: + return None, "UNKNOWN", "unexpected manifest location" + raw = get(url) + manifest = json.loads(raw) + org, name = repository.split("/") + if (manifest.get("org"), manifest.get("name"), manifest.get("semver")) != (org, name, tag): + return None, "UNKNOWN", "manifest release identity differs from index" + digests = {hashlib.sha256(raw).hexdigest()} + digests.update(asset["sha256"].lower() for asset in manifest.get("assets", {}).values() if re.fullmatch(r"[0-9a-fA-F]{64}", asset.get("sha256", ""))) + # There is no signed flag today. Only public-ECR image indexes belong to the + # current signed-image set; private Lambda images do not grant ambiguity budget. + budget = sum(image.get("isIndex") is True and image.get("uri", "").startswith("public.ecr.aws/") + for image in manifest.get("images", {}).values()) + return {"key": f"{repository}@{tag}", "digests": digests, "budget": budget}, "READY", "public release loaded" + + +def evaluate(entry, state, now, get=fetch): + identity = entry.get("identity", {}) + row = {"uuid": entry["uuid"], "log_index": entry["index"], + "san": identity.get("subjectAlternativeName"), "repository": identity.get("sourceRepositoryURI"), + "run_uri": identity.get("runInvocationURI"), "subject_digest": entry.get("digest"), + "run_identity_match": "UNKNOWN", "artifact_authorized": "UNKNOWN"} + if entry.get("error"): + row.update(status="INFORMATIONAL" if entry["error"] == "UNAUTHENTICATED-SAN" else "MONITOR-ERROR", reason=entry["error"]) + return row + if entry.get("kind") != "hashedrekord" or not entry.get("digest"): + row.update(status="UNKNOWN", reason="opaque or unsupported signed payload; never compare payloadHash to artifact digest") + return row + first = state.setdefault("first_seen", {}).setdefault(entry["uuid"], now) + expired = now - first >= WINDOW + run_status, reason = run_identity(entry, get) + row["run_identity_match"] = run_status + if run_status != "MATCH": + row.update(status="ANOMALY" if run_status == "PENDING" and expired else run_status, reason=reason) + return row + try: + release, release_status, reason = release_data(identity, get) + except HTTPError as error: + release, release_status, reason = None, "UNKNOWN", f"release lookup unavailable ({error.code})" + if release_status != "READY": + row.update(status="ANOMALY" if release_status == "PENDING" and expired else release_status, reason=reason) + return row + if entry["digest"] in release["digests"]: + row.update(status="MATCHED", artifact_authorized="AUTHORIZED", reason="signed-byte digest recorded in this public release") + return row + if not expired: + row.update(status="PENDING", reason="digest not recorded yet; bounded reconciliation window") + return row + hashes = state.setdefault("unmatched_hashes", {}).setdefault(release["key"], []) + if entry["digest"] not in hashes: + hashes.append(entry["digest"]) + if len(hashes) <= release["budget"]: + row.update(status="UNKNOWN", reason="unmatched hash within this release's opaque image-payload budget") + else: + row.update(status="ANOMALY", artifact_authorized="UNRECORDED", reason="unexpected digest exceeds signed-image ambiguity budget") + return row + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--state-dir", default="monitor-state") + parser.add_argument("--identities", default="monitor-state/identities.jsonl") + parser.add_argument("--entry-helper", required=True) + parser.add_argument("--bootstrap", action="store_true") + args = parser.parse_args() + directory = Path(args.state_dir) + state_path = directory / "reconciliation.json" + if state_path.exists(): + state = json.loads(state_path.read_text()) + elif args.bootstrap: + state = {} + else: + raise ValueError("missing reconciliation state; refusing to reset monitor history") + now = int(time.time()) + progress, stale = checkpoint_progress((directory / "checkpoint.txt").read_text(), state.get("checkpoint", {}), now) + observations = {item["UUID"]: item for item in state.get("pending", [])} + for line in Path(args.identities).read_text().splitlines(): + item = json.loads(line) + observations[item["UUID"]] = item + rows = [] + if observations: + checked = subprocess.run([args.entry_helper, "-checkpoint", str(directory / "checkpoint.txt")], + input="".join(json.dumps(item) + "\n" for item in observations.values()), + capture_output=True, text=True, check=True) + entries = [json.loads(line) for line in checked.stdout.splitlines()] + if {item["uuid"] for item in entries} != set(observations): + raise ValueError("proof helper omitted observations") + cache = {} + def cached_get(url): + if url not in cache: + cache[url] = fetch(url) + return cache[url] + rows = [evaluate(entry, state, now, cached_get) for entry in entries] + state["checkpoint"] = progress + retry = {row["uuid"] for row in rows if row["status"] in {"PENDING", "MONITOR-ERROR"} + or (row["status"] == "UNKNOWN" and "unavailable" in row["reason"] + and now - state.get("first_seen", {}).get(row["uuid"], now) < WINDOW)} + state["pending"] = [item for uuid, item in observations.items() if uuid in retry] + state["first_seen"] = {key: value for key, value in state.get("first_seen", {}).items() if key in retry} + state_path.write_text(json.dumps(state, indent=2) + "\n") + report = {"alert_channel_placeholder": "#ops-testing-new-alerts", "checkpoint_stale": stale, "entries": rows} + diagnostics = directory / "diagnostics.log" + chain_failures = diagnostics.read_text().count("Certificate chain for log entry") if diagnostics.exists() else 0 + report["unauthenticated_san_diagnostics"] = {"status": "INFORMATIONAL", "label": "UNAUTHENTICATED-SAN", + "count": chain_failures, "run_identity_match": "UNKNOWN"} + (directory / "report.json").write_text(json.dumps(report, indent=2) + "\n") + Path(args.identities).write_text("") + print(json.dumps(report, indent=2)) + if stale or any(row["status"] in {"ANOMALY", "MONITOR-ERROR"} for row in rows): + return 1 + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except (OSError, ValueError, KeyError, subprocess.CalledProcessError) as error: + print(f"monitor lookup or proof check failed: {error}", file=sys.stderr) + sys.exit(1) diff --git a/scripts/rekor_digest_check.sh b/scripts/rekor_digest_check.sh new file mode 100755 index 0000000..2f9b4cd --- /dev/null +++ b/scripts/rekor_digest_check.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +set -euo pipefail +args=() +if [ "${MONITOR_BOOTSTRAP:-false}" = true ]; then + args+=(--bootstrap) +fi +python3 scripts/rekor_digest_check.py --entry-helper "${REKOR_ENTRY_HELPER:?missing proof helper}" "${args[@]}" diff --git a/scripts/test-ci-workflow.py b/scripts/test-ci-workflow.py index 4a6e1e3..9564aba 100755 --- a/scripts/test-ci-workflow.py +++ b/scripts/test-ci-workflow.py @@ -22,6 +22,7 @@ def test_repository_events_jobs_and_read_only_permissions(self): expected_paths = {"cmd/**", ".github/actions/**", "actions/**", "scripts/**", "tools/**", "go.*", "renovate.json", "templates/**", ".github/workflows/ci.yaml", ".github/workflows/verify.yaml", ".github/workflows/release.yaml", "Makefile", ".gitattributes"} + expected_paths.add(".github/workflows/rekor-monitor.yaml") pull_paths = set(workflow["on"]["pull_request"]["paths"]) self.assertTrue(expected_paths <= pull_paths) self.assertEqual(pull_paths, set(workflow["on"]["push"]["paths"])) @@ -31,7 +32,8 @@ def test_repository_events_jobs_and_read_only_permissions(self): go_job = workflow["jobs"]["go-tests"] self.assertEqual(go_job["env"]["GOTOOLCHAIN"], "local") self.assertEqual([step["run"] for step in go_job["steps"] if "run" in step], - ["go vet ./...", "go test ./..."]) + ["go vet ./...", "go test ./...", "go -C tools/rekor-entry vet ./...", + "go -C tools/rekor-entry test ./..."]) for job in workflow["jobs"].values(): for step in job["steps"]: if "uses" in step: diff --git a/scripts/test-rekor-monitor.py b/scripts/test-rekor-monitor.py new file mode 100755 index 0000000..6c7421b --- /dev/null +++ b/scripts/test-rekor-monitor.py @@ -0,0 +1,415 @@ +#!/usr/bin/env python3 +"""Synthetic fixtures only: no Rekor, GitHub or registry requests.""" +import copy +import importlib.util +import io +import json +import os +from pathlib import Path +import re +import subprocess +import sys +import tempfile +import unittest +from contextlib import redirect_stdout +from unittest.mock import patch +from urllib.error import HTTPError + +sys.dont_write_bytecode = True +ROOT = Path(__file__).resolve().parent.parent +spec = importlib.util.spec_from_file_location("monitor", ROOT / "scripts/rekor_digest_check.py") +monitor = importlib.util.module_from_spec(spec) +spec.loader.exec_module(monitor) +SAN = "https://github.com/ConductorOne/github-workflows/.github/workflows/release.yaml@refs/tags/v4" +DIGEST = "a" * 64 +NOW = 1700000000 + + +def workflow_errors(workflow): + errors = [] + if workflow["permissions"] != {"contents": "read", "actions": "read"}: + errors.append("unexpected permissions") + job = workflow["jobs"]["monitor"] + if job["if"] != "github.ref == 'refs/heads/main'": + errors.append("must run only on main") + for step in job["steps"]: + action = step.get("uses", "") + if action and not action.startswith("./") and not re.fullmatch(r"actions/[\w-]+@[0-9a-f]{40}", action): + errors.append("mutable action") + env = job["env"] + if env["GOTOOLCHAIN"] != "local" or env["GOFLAGS"] != "-mod=readonly" or env["GOSUMDB"] != "sum.golang.org": + errors.append("unprotected tool installation") + if env["MONITOR_VERSION"] != "v0.0.0-20261019132539-47017b2379cb": + errors.append("mutable monitor") + restore = next(step for step in job["steps"] if step.get("id") == "restore") + required_selection = ("actions/artifacts?name=rekor-monitor-state&per_page=100", + ".expired == false", '.workflow_run.head_branch == "main"', + "sort_by(.created_at)", ".workflow_run.id", "--paginate", "--slurp", + "actions/workflows/rekor-monitor.yaml", ".workflow_id") + if "gh run list" in restore["run"] or any(part not in restore["run"] for part in required_selection): + errors.append("state must come from the newest valid artifact of this main-branch workflow") + state_check = next((step for step in job["steps"] if step.get("id") == "state"), None) + upload = next(step for step in job["steps"] if (step.get("with") or {}).get("name") == "rekor-monitor-state") + if state_check is None or state_check.get("if") != "always()": + errors.append("state completeness must be checked even after failure") + if upload.get("if") != "always() && steps.state.outputs.state_complete == 'true'": + errors.append("incomplete state must not be uploaded") + return errors + + +class MonitorTest(unittest.TestCase): + def setUp(self): + self.entry = {"uuid": "fixture", "index": 42, "kind": "hashedrekord", "digest": DIGEST, + "integrated_time": NOW, "identity": { + "subjectAlternativeName": SAN, "issuer": monitor.ISSUER, + "sourceRepositoryURI": "https://github.com/ConductorOne/example", + "sourceRepositoryRef": "refs/tags/v1.2.3", "sourceRepositoryDigest": "c" * 40, + "sourceRepositoryVisibilityAtSigning": "public", + "runInvocationURI": "https://github.com/ConductorOne/example/actions/runs/123/attempts/1"}} + self.run = {"repository": {"full_name": "ConductorOne/example"}, "head_sha": "c" * 40, + "status": "completed", "run_started_at": "2023-11-14T22:00:00Z", + "updated_at": "2023-11-14T22:20:00Z", + "referenced_workflows": [{"path": SAN.removeprefix("https://github.com/").replace("@refs/tags/v4", "@v4"), "ref": "refs/tags/v4"}]} + self.manifest = {"org": "ConductorOne", "name": "example", "semver": "v1.2.3", + "assets": {"archive": {"sha256": DIGEST}}, "images": {}} + self.index = {"semvers": {"v1.2.3": {"manifest": monitor.DIST + "/releases/ConductorOne/example/v1.2.3/manifest.json"}}} + self.catalog = {"catalogs": {"ConductorOne/example": {"hidden": False}}} + + def get(self, url): + if url.startswith(monitor.GITHUB): + return json.dumps(self.run).encode() + if url == monitor.DIST + "/index.json": + return json.dumps(self.catalog).encode() + if url.endswith("/example/index.json"): + return json.dumps(self.index).encode() + if url.endswith("/v1.2.3/manifest.json"): + return json.dumps(self.manifest).encode() + raise AssertionError("unexpected network boundary: " + url) + + def evaluate(self, entry=None, state=None, now=NOW): + return monitor.evaluate(entry or self.entry, state if state is not None else {}, now, self.get) + + def test_match_and_replay_are_separate_verdicts(self): + row = self.evaluate() + self.assertEqual((row["status"], row["run_identity_match"], row["artifact_authorized"]), + ("MATCHED", "MATCH", "AUTHORIZED")) + entry = dict(self.entry, digest="b" * 64) + state = {"first_seen": {"fixture": NOW - monitor.WINDOW}} + row = self.evaluate(entry, state) + self.assertEqual((row["status"], row["run_identity_match"], row["artifact_authorized"]), + ("ANOMALY", "MATCH", "UNRECORDED")) + + def test_missing_run_and_unpinned_workflow_are_anomalies(self): + def missing(url): + raise HTTPError(url, 404, "missing", {}, None) + row = monitor.evaluate(self.entry, {}, NOW, missing) + self.assertEqual(row["status"], "ANOMALY") + private = copy.deepcopy(self.entry) + private["identity"]["sourceRepositoryVisibilityAtSigning"] = "private" + self.assertEqual(monitor.evaluate(private, {}, NOW, missing)["status"], "UNKNOWN") + entry = copy.deepcopy(self.entry) + entry["identity"]["subjectAlternativeName"] = SAN.replace("refs/tags/v4", "refs/heads/untrusted") + self.assertEqual(self.evaluate(entry)["status"], "ANOMALY") + self.run["referenced_workflows"] = [] + self.assertEqual(self.evaluate()["status"], "ANOMALY") + + def test_inflight_and_unrecorded_digest_have_bounded_pending_window(self): + self.run["status"] = "in_progress" + state = {} + self.assertEqual(self.evaluate(state=state)["status"], "PENDING") + self.assertEqual(self.evaluate(state=state, now=NOW + monitor.WINDOW)["status"], "ANOMALY") + self.run["status"] = "completed" + self.assertEqual(self.evaluate(state=state, now=NOW + monitor.WINDOW)["status"], "MATCHED") + self.manifest["assets"] = {} + state = {} + self.assertEqual(self.evaluate(state=state)["status"], "PENDING") + self.manifest["assets"] = {"archive": {"sha256": DIGEST}} + self.assertEqual(self.evaluate(state=state, now=NOW + 3600)["status"], "MATCHED") + + def test_opaque_image_budget_is_per_release_and_unique_hash(self): + self.manifest["images"] = { + "public": {"uri": "public.ecr.aws/example/image@sha256:" + DIGEST, "isIndex": True}, + "lambda": {"uri": "123.dkr.ecr.us-west-2.amazonaws.com/image@sha256:" + DIGEST, "isIndex": True}} + state = {"first_seen": {"one": NOW - monitor.WINDOW, "two": NOW - monitor.WINDOW}} + one = dict(self.entry, uuid="one", digest="b" * 64) + two = dict(self.entry, uuid="two", digest="d" * 64) + self.assertEqual(self.evaluate(one, state)["status"], "UNKNOWN") + self.assertEqual(self.evaluate(one, state)["status"], "UNKNOWN", "duplicate must not consume another slot") + self.assertEqual(self.evaluate(two, state)["status"], "ANOMALY") + self.assertEqual(len(state["unmatched_hashes"]["ConductorOne/example@v1.2.3"]), 2) + + def test_no_invented_joins_or_payload_digest_aliases(self): + for kind in ("dsse", "intoto"): + row = self.evaluate(dict(self.entry, kind=kind)) + self.assertEqual((row["status"], row["artifact_authorized"]), ("UNKNOWN", "UNKNOWN")) + self.catalog["catalogs"] = {} + self.assertEqual(self.evaluate()["status"], "UNKNOWN") + self.setUp() + original = self.get + def hidden(url): + if url.endswith("/example/index.json"): + raise HTTPError(url, 403, "hidden", {}, None) + return original(url) + self.assertEqual(monitor.evaluate(self.entry, {}, NOW, hidden)["status"], "UNKNOWN") + entry = copy.deepcopy(self.entry) + entry["identity"].pop("runInvocationURI") + self.assertEqual(self.evaluate(entry)["status"], "UNKNOWN") + row = self.evaluate(dict(self.entry, error="UNAUTHENTICATED-SAN")) + self.assertEqual((row["status"], row["run_identity_match"]), ("INFORMATIONAL", "UNKNOWN")) + self.assertEqual(self.evaluate(dict(self.entry, error="invalid inclusion proof"))["status"], "MONITOR-ERROR") + + def test_manifest_bytes_and_archive_hashes_are_authorized_not_image_digests(self): + release, status, _ = monitor.release_data(self.entry["identity"], self.get) + self.assertEqual(status, "READY") + raw = json.dumps(self.manifest).encode() + self.assertIn(monitor.hashlib.sha256(raw).hexdigest(), release["digests"]) + self.assertIn(DIGEST, release["digests"]) + + def test_repeated_old_checkpoint_and_new_note_signature(self): + checkpoint = "origin\\n10\\nroot\\n\\n— signer signature" + state, stale = monitor.checkpoint_progress(checkpoint, {}, NOW) + self.assertFalse(stale) + _, stale = monitor.checkpoint_progress(checkpoint.replace("signature", "new-signature"), state, NOW + 7201) + self.assertTrue(stale) + _, stale = monitor.checkpoint_progress(checkpoint.replace("10", "11"), state, NOW + 7201) + self.assertFalse(stale) + with self.assertRaises(ValueError): + monitor.checkpoint_progress(checkpoint, state, NOW - 1) + + def test_adapter_preserves_pending_across_actual_helper_boundary(self): + with tempfile.TemporaryDirectory() as directory: + directory = Path(directory) + state = directory / "state" + state.mkdir() + (state / "checkpoint.txt").write_text("origin\\n10\\nroot\\n") + (state / "diagnostics.log").write_text("Certificate chain for log entry (UUID: fixture) could not be verified\n") + observations = state / "identities.jsonl" + observations.write_text(json.dumps({"UUID": "fixture", "Index": 42}) + "\n") + helper = directory / "helper" + helper.write_text("#!/usr/bin/env python3\nimport json,sys\nfor line in sys.stdin:\n print(" + repr(json.dumps(self.entry)) + ")\n") + helper.chmod(0o755) + argv = ["monitor", "--state-dir", str(state), "--identities", str(observations), + "--entry-helper", str(helper), "--bootstrap"] + self.run["status"] = "in_progress" + with patch.object(sys, "argv", argv), patch.object(monitor, "fetch", self.get), patch.object(monitor.time, "time", return_value=NOW), redirect_stdout(io.StringIO()): + self.assertEqual(monitor.main(), 0) + saved = json.loads((state / "reconciliation.json").read_text()) + self.assertEqual(saved["pending"][0]["UUID"], "fixture") + self.assertEqual(observations.read_text(), "") + self.run["status"] = "completed" + with patch.object(sys, "argv", argv[:-1]), patch.object(monitor, "fetch", self.get), patch.object(monitor.time, "time", return_value=NOW+3600), redirect_stdout(io.StringIO()): + self.assertEqual(monitor.main(), 0) + self.assertEqual(json.loads((state / "report.json").read_text())["entries"][0]["status"], "MATCHED") + self.assertEqual(json.loads((state / "reconciliation.json").read_text())["pending"], []) + diagnostic = json.loads((state / "report.json").read_text())["unauthenticated_san_diagnostics"] + self.assertEqual((diagnostic["label"], diagnostic["status"], diagnostic["count"]), ("UNAUTHENTICATED-SAN", "INFORMATIONAL", 1)) + # Failure after scan must retain the raw observation for the next run. + observations.write_text(json.dumps({"UUID": "fixture", "Index": 42}) + "\n") + helper.write_text("#!/bin/sh\nexit 1\n") + with patch.object(sys, "argv", argv[:-1]), self.assertRaises(subprocess.CalledProcessError): + monitor.main() + self.assertIn("fixture", observations.read_text()) + + def test_network_boundary_does_not_forward_credentials_or_follow_redirects(self): + requests = [] + class Opener: + def open(self, request, timeout): + requests.append(request) + self_test.assertEqual(timeout, 30) + return io.BytesIO(b"{}") + self_test = self + with patch.object(monitor, "build_opener", return_value=Opener()), patch.dict(os.environ, GH_TOKEN="fixture-token"): + monitor.fetch(monitor.GITHUB + "/repos/ConductorOne/example/actions/runs/123") + monitor.fetch(monitor.DIST + "/index.json") + self.assertEqual(requests[0].get_header("Authorization"), "Bearer fixture-token") + self.assertIsNone(requests[1].get_header("Authorization")) + self.assertIsNone(monitor.NoRedirect().redirect_request(None, None, 302, "", {}, "https://other.test/")) + for url in ("http://api.github.com/", "https://api.github.com.evil/"): + with self.assertRaises(ValueError): + monitor.fetch(url) + + def test_workflow_pins_identity_config_and_negative_controls(self): + workflow = json.loads(subprocess.check_output(["yq", ".", str(ROOT / ".github/workflows/rekor-monitor.yaml")], text=True)) + self.assertEqual(workflow_errors(workflow), []) + self.assertIn("schedule", workflow["on"]) + for field, value in (("GOSUMDB", "off"), ("MONITOR_VERSION", "latest")): + changed = copy.deepcopy(workflow) + changed["jobs"]["monitor"]["env"][field] = value + self.assertTrue(workflow_errors(changed)) + changed = copy.deepcopy(workflow) + changed["jobs"]["monitor"]["steps"][0]["uses"] = "actions/checkout@v5" + self.assertTrue(workflow_errors(changed)) + config = json.loads(subprocess.check_output(["yq", ".", str(ROOT / "tools/rekor-monitor.yaml")], text=True)) + for identity in monitor.PINNED: + matches = [item for item in config["monitoredValues"]["certIdentities"] if re.fullmatch(item["certSubject"], identity)] + self.assertEqual(len(matches), 1) + self.assertTrue(re.fullmatch(matches[0]["issuers"][0], monitor.ISSUER)) + self.assertFalse(re.fullmatch(matches[0]["certSubject"], identity.replace("github.com/", "github.com.evil/"))) + self.assertEqual(config["caRootsFile"], "tools/rekor-fulcio-roots.pem") + self.assertEqual(config["caIntermediatesFile"], "tools/rekor-fulcio-intermediates.pem") + restore = next(step for step in workflow["jobs"]["monitor"]["steps"] if step.get("id") == "restore") + with tempfile.TemporaryDirectory() as directory: + directory = Path(directory) + fake = directory / "gh" + fake.write_text("#!/bin/sh\nexit 0\n") + fake.chmod(0o755) + env = dict(os.environ, PATH=f"{directory}:{os.environ['PATH']}", GITHUB_REPOSITORY="ConductorOne/github-workflows", GITHUB_OUTPUT=str(directory/"output")) + for bootstrap, expected in (("false", 1), ("true", 0)): + result = subprocess.run(["bash", "-c", restore["run"]], cwd=directory, env=dict(env, BOOTSTRAP=bootstrap), capture_output=True) + self.assertEqual(result.returncode, expected) + + def test_restore_survives_artifactless_runs_and_selects_only_valid_state(self): + workflow = json.loads(subprocess.check_output(["yq", ".", str(ROOT / ".github/workflows/rekor-monitor.yaml")], text=True)) + index = next(index for index, step in enumerate(workflow["jobs"]["monitor"]["steps"]) + if step.get("id") == "restore") + restore = workflow["jobs"]["monitor"]["steps"][index]["run"] + def artifact(run, hour, branch="main", expired=False): + return {"name": "rekor-monitor-state", "expired": expired, + "created_at": f"2026-10-22T{hour}:00:00Z", + "workflow_run": {"id": run, "head_branch": branch}} + fixture = {"pages": [{"artifacts": [artifact(101, "10"), artifact(102, "12", expired=True), + artifact(103, "13", branch="feature"), artifact(104, "14")]}, + {"artifacts": [artifact(105, "11")]}], + "runs": {str(run): {"workflow_id": 999 if run == 104 else 100, + "has_artifact": run not in (102, 900, 901)} + for run in (101, 102, 103, 104, 105, 900, 901)}, + "latest_completed": 900} + with tempfile.TemporaryDirectory() as directory: + directory = Path(directory) + fake = directory / "gh" + fake.write_text("""#!/usr/bin/env python3 +import json, os, pathlib, subprocess, sys +fixture = json.loads(os.environ["RESTORE_FIXTURE"]) +args = sys.argv[1:] +if args[0] == "api": + if "--slurp" in args and "--jq" in args: + sys.exit("gh does not support combining slurp and jq") + endpoint = next(arg for arg in args if arg.startswith("repos/")) + if "/actions/artifacts?" in endpoint: + payload = fixture["pages"] if "--slurp" in args else fixture["pages"][0] + elif endpoint.endswith("/actions/workflows/rekor-monitor.yaml"): + payload = {"id": 100} + elif "/actions/runs/" in endpoint: + payload = fixture["runs"][endpoint.rsplit("/", 1)[1]] + else: + sys.exit("unexpected API boundary") + if "--jq" in args: + subprocess.run(["jq", "-r", args[args.index("--jq") + 1]], + input=json.dumps(payload), text=True, check=True) + else: + print(json.dumps(payload)) +elif args[:2] == ["run", "list"]: + print(fixture["latest_completed"]) +elif args[:2] == ["run", "download"]: + run = args[2] + with open("downloads", "a") as output: + output.write(run + "\\n") + if fixture["runs"][run].get("download_error"): + sys.exit("simulated transient download failure") + if not fixture["runs"][run]["has_artifact"]: + sys.exit("run has no usable state artifact") + destination = pathlib.Path(args[args.index("--dir") + 1]) + destination.mkdir(exist_ok=True) + if not fixture["runs"][run].get("missing_checkpoint"): + (destination / "checkpoint.txt").write_text("verified-checkpoint") + if not fixture["runs"][run].get("missing_reconciliation"): + (destination / "reconciliation.json").write_text("{}") + if fixture["runs"][run].get("missing_checkpoint") or fixture["runs"][run].get("missing_reconciliation"): + (destination / "incomplete-only.txt").write_text("must not reach monitor-state") +else: + sys.exit("unexpected CLI boundary") +""") + fake.chmod(0o755) + def execute(script, data, label, bootstrap=False): + case = directory / label + case.mkdir() + environment = dict(os.environ, PATH=f"{directory}:{os.environ['PATH']}", + GITHUB_REPOSITORY="ConductorOne/github-workflows", + GITHUB_OUTPUT=str(case / "output"), RESTORE_FIXTURE=json.dumps(data), + BOOTSTRAP=str(bootstrap).lower()) + result = subprocess.run(["bash", "-c", script], cwd=case, env=environment, + capture_output=True, text=True) + downloads = (case / "downloads").read_text().splitlines() if (case / "downloads").exists() else [] + return result, downloads + for latest in (900, 901): # Cancelled and early-failed runs have no state artifact. + fixture["latest_completed"] = latest + result, downloads = execute(restore, fixture, f"latest-{latest}") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(downloads, ["105"]) + for missing in ("missing_checkpoint", "missing_reconciliation"): + incomplete = copy.deepcopy(fixture) + incomplete["pages"][0]["artifacts"].append(artifact(106, "15")) + incomplete["runs"]["106"] = {"workflow_id": 100, "has_artifact": True, missing: True} + result, downloads = execute(restore, incomplete, missing) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(downloads, ["106", "105"]) + self.assertIn("::warning::skipping incomplete state artifact from run 106", result.stderr) + self.assertFalse((directory / missing / "monitor-state/incomplete-only.txt").exists()) + self.assertEqual(list((directory / missing).glob(".rekor-state.*")), []) + failed_download = copy.deepcopy(fixture) + failed_download["pages"][0]["artifacts"].append(artifact(106, "15")) + failed_download["runs"]["106"] = {"workflow_id": 100, "has_artifact": True, "download_error": True} + for bootstrap in (False, True): + label = f"download-error-{bootstrap}" + result, downloads = execute(restore, failed_download, label, bootstrap) + self.assertNotEqual(result.returncode, 0) + self.assertEqual(downloads, ["106"]) + self.assertIn("::error::download failed for run 106", result.stderr) + case = directory / label + self.assertEqual(list(case.glob(".rekor-state.*")), []) + check = next(step for step in workflow["jobs"]["monitor"]["steps"] if step.get("id") == "state") + output = case / "state-output" + subprocess.run(["bash", "-c", check["run"]], cwd=case, + env=dict(os.environ, GITHUB_OUTPUT=str(output)), check=True) + self.assertFalse(output.exists(), "failed downloads must not produce upload-ready state") + old_selection = '''set -euo pipefail +mkdir -p monitor-state +run_id="$(gh run list --repo "$GITHUB_REPOSITORY" --workflow rekor-monitor.yaml --branch main --status completed --limit 1 --json databaseId --jq '.[0].databaseId // empty')" +gh run download "$run_id" --repo "$GITHUB_REPOSITORY" --name rekor-monitor-state --dir monitor-state +''' + result, downloads = execute(old_selection, fixture, "old-selection") + self.assertNotEqual(result.returncode, 0) + self.assertEqual(downloads, ["901"]) + empty = copy.deepcopy(fixture) + empty["pages"] = [{"artifacts": []}] + for bootstrap in (False, True): + result, downloads = execute(restore, empty, f"empty-{bootstrap}", bootstrap) + self.assertEqual(result.returncode, 0 if bootstrap else 1) + self.assertEqual(downloads, []) + for predicate in (".expired == false", '.workflow_run.head_branch == "main"'): + changed = copy.deepcopy(workflow) + changed["jobs"]["monitor"]["steps"][index]["run"] = restore.replace(predicate, "true") + self.assertTrue(workflow_errors(changed)) + changed = copy.deepcopy(workflow) + changed["jobs"]["monitor"]["steps"][index]["run"] = old_selection + self.assertTrue(workflow_errors(changed)) + + def test_upload_requires_both_nonempty_state_files(self): + workflow = json.loads(subprocess.check_output(["yq", ".", str(ROOT / ".github/workflows/rekor-monitor.yaml")], text=True)) + steps = workflow["jobs"]["monitor"]["steps"] + check = next(step for step in steps if step.get("id") == "state") + cases = ({}, {"checkpoint.txt": "checkpoint"}, {"reconciliation.json": "{}"}, + {"checkpoint.txt": "checkpoint", "reconciliation.json": ""}, + {"checkpoint.txt": "", "reconciliation.json": "{}"}, + {"checkpoint.txt": "checkpoint", "reconciliation.json": "{}"}) + for files in cases: + with self.subTest(files=files), tempfile.TemporaryDirectory() as directory: + directory = Path(directory) + state = directory / "monitor-state" + state.mkdir() + for name, value in files.items(): + (state / name).write_text(value) + output = directory / "output" + subprocess.run(["bash", "-c", check["run"]], cwd=directory, + env=dict(os.environ, GITHUB_OUTPUT=str(output)), check=True) + complete = "state_complete=true" in output.read_text() if output.exists() else False + self.assertEqual(complete, bool(files.get("checkpoint.txt") and files.get("reconciliation.json"))) + changed = copy.deepcopy(workflow) + upload = next(step for step in changed["jobs"]["monitor"]["steps"] + if (step.get("with") or {}).get("name") == "rekor-monitor-state") + upload["if"] = "always()" + self.assertTrue(workflow_errors(changed)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/rekor-entry/go.mod b/tools/rekor-entry/go.mod new file mode 100644 index 0000000..5fe2085 --- /dev/null +++ b/tools/rekor-entry/go.mod @@ -0,0 +1,74 @@ +module github.com/ConductorOne/github-workflows/tools/rekor-entry + +go 1.27.3 + +require ( + github.com/sigstore/rekor v1.5.3 + github.com/sigstore/sigstore v1.10.9 + github.com/sigstore/sigstore-go v1.2.1 +) + +require ( + github.com/cenkalti/backoff/v5 v5.0.3 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467 // indirect + github.com/digitorus/pkcs7 v0.0.0-20230818184609-3a137a874352 // indirect + github.com/digitorus/timestamp v0.0.0-20231217203849-220c5c2851b7 // indirect + github.com/fatih/color v1.18.0 // 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.26.0 // 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.1 // indirect + github.com/go-openapi/runtime v0.33.1 // indirect + github.com/go-openapi/runtime/server-middleware v0.33.1 // 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.26.1 // indirect + github.com/go-openapi/swag/conv v0.29.1 // indirect + github.com/go-openapi/swag/fileutils v0.28.0 // indirect + github.com/go-openapi/swag/jsonname v0.26.1 // indirect + github.com/go-openapi/swag/jsonutils v0.28.0 // indirect + github.com/go-openapi/swag/loading v0.28.0 // indirect + github.com/go-openapi/swag/mangling v0.28.0 // indirect + github.com/go-openapi/swag/netutils v0.26.1 // indirect + github.com/go-openapi/swag/pools v0.28.0 // indirect + github.com/go-openapi/swag/stringutils v0.28.0 // indirect + github.com/go-openapi/swag/typeutils v0.29.1 // indirect + github.com/go-openapi/swag/yamlutils v0.28.0 // indirect + github.com/go-openapi/validate v0.26.3 // indirect + github.com/go-viper/mapstructure/v2 v2.5.0 // indirect + github.com/google/go-containerregistry v0.21.7 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/hashicorp/go-retryablehttp v0.7.8 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/oklog/ulid/v2 v2.1.2 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/secure-systems-lab/go-securesystemslib v0.11.0 // indirect + github.com/sigstore/protobuf-specs v0.5.1 // indirect + github.com/sigstore/timestamp-authority/v2 v2.1.2 // indirect + github.com/theupdateframework/go-tuf/v2 v2.4.2-0.20260407074541-7e8f69f906ef // 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.45.0 // indirect + go.opentelemetry.io/otel/metric v1.45.0 // indirect + go.opentelemetry.io/otel/trace v1.45.0 // indirect + go.yaml.in/yaml/v3 v3.0.5 // indirect + golang.org/x/crypto v0.55.0 // indirect + golang.org/x/mod v0.40.0 // indirect + golang.org/x/net v0.58.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-20260727163830-6c54dddc4772 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d // indirect + google.golang.org/grpc v1.83.2 // indirect + google.golang.org/protobuf v1.36.12 // indirect +) diff --git a/tools/rekor-entry/go.sum b/tools/rekor-entry/go.sum new file mode 100644 index 0000000..4c7b47e --- /dev/null +++ b/tools/rekor-entry/go.sum @@ -0,0 +1,305 @@ +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= +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/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/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/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/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/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.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.26.0 h1:1xECln1iMMmQnTjgcknC1vi1hA4KISt6IHpSwnqcuwI= +github.com/go-openapi/analysis v0.26.0/go.mod h1:40gERFi/2dyXA1FaqRRLxkv1IlC6X+GPDNd1xrYAjZE= +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.1 h1:toKQdIDLxlqfKLLGUUmUsiTd5/X0Chzvde9EGYQP/Ac= +github.com/go-openapi/loads v0.25.1/go.mod h1:33Hen4tsKXHL45TyYojvfD5fZUFN4O1y4r/XhsRW2zc= +github.com/go-openapi/runtime v0.33.1 h1:jCvhI+wAdsn29byy+RgcPcg+j39YT6E304QOE/WqIVk= +github.com/go-openapi/runtime v0.33.1/go.mod h1:Dl5SMVRnJz+d8bX6Y1zxy0QKpqe/ysvVeUEh1nCpEZ4= +github.com/go-openapi/runtime/server-middleware v0.33.1 h1:IAeKbwWnBnpsYTpuPVS8t73ZrPpKvRZnK2iJ2KJGUV0= +github.com/go-openapi/runtime/server-middleware v0.33.1/go.mod h1:2Gej5fDxqeJxY+w38vxXYW0BgFASfgBsJ5rXwN1Fseg= +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.26.1 h1:f2iE1ijYaJ3nuu5PaEMx3zpEhzhZFgivCJObWEObLIQ= +github.com/go-openapi/swag/cmdutils v0.26.1/go.mod h1:Sm1MVFMkF6guJJ+pQqHnQA3N0j9qALV3NxzDSv6bETM= +github.com/go-openapi/swag/conv v0.29.1 h1:AC4Eh/5c/eUDOUCzzsRC9ghmFgOSBHeRMGIngY0ZUGA= +github.com/go-openapi/swag/conv v0.29.1/go.mod h1:S1X7/ZrBEZOC0Wc8AGxjbcGS92l3WEjA7aPtpl+RaqM= +github.com/go-openapi/swag/fileutils v0.28.0 h1:Z04XWQD7R8Eq+7GnOrjovBxPPmZzsS4gt2H2GPGIViU= +github.com/go-openapi/swag/fileutils v0.28.0/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.28.0 h1:YIch6FwO7RXzeAnbO8Tu7dWBZeUEH+4nA0HXltVTnv4= +github.com/go-openapi/swag/jsonutils v0.28.0/go.mod h1:CYM3WlTUcagR2ZoHdz54di/cbBqt82tuxuXgAjxw+mg= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.28.0 h1:qV+VVUAx5Oro8WjVWpZeql7YReTKhT4smR4zhcOQZr0= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.28.0/go.mod h1:mofwUWx70wvskwESqRJ//k/9kURmCgyJl5m5Ppoh5kY= +github.com/go-openapi/swag/loading v0.28.0 h1:td8QZdZC9MIYGGSnSPKShKiK22I2tU5UQvuUhIBPRLU= +github.com/go-openapi/swag/loading v0.28.0/go.mod h1:rXB0QiQX5mMveXEA7ouM4KiiM9jVJe4K6BVbwhD1M4k= +github.com/go-openapi/swag/mangling v0.28.0 h1:pH8eyeNO9SLYsTMWJrurnNfKmDa28XrlA+HePVD53VM= +github.com/go-openapi/swag/mangling v0.28.0/go.mod h1:jtBE2+V+3pILxOR7Vgce+Cwp6A2PgZbvVqfNntbVs0w= +github.com/go-openapi/swag/netutils v0.26.1 h1:BNctoc39WTAUMxyAs355fExOPzMZtPbZ0ZZ1Am2FR5M= +github.com/go-openapi/swag/netutils v0.26.1/go.mod h1:y02vByhZhQPAVwOX+0KipXFZ/hUbk6G/Enhf5rGaOkQ= +github.com/go-openapi/swag/pools v0.28.0 h1:HPMZWSAfce3rdVTFcjFiCIBtDg9h4x2QlRrHipwhxeU= +github.com/go-openapi/swag/pools v0.28.0/go.mod h1:kVQefhSK5RWuRe7BXsL8htgBPAMpN7HDGpGEknqugeE= +github.com/go-openapi/swag/stringutils v0.28.0 h1:ixsc9iYgDPubHL/8nSkbnryEHpD2VRlBMLKpQyPXcDU= +github.com/go-openapi/swag/stringutils v0.28.0/go.mod h1:lzRN95CxXmA03XcDWHLOb6nOMcxCqR5rGY0lOgsfRoM= +github.com/go-openapi/swag/typeutils v0.29.1 h1:Nzv9nhnlLCRBPQqfOX+7lB6Guju370or8StT+lIOf6M= +github.com/go-openapi/swag/typeutils v0.29.1/go.mod h1:hxpgDZJVBkBsi/d3MIUosafoFdE5exaQRmVp0zwu3YE= +github.com/go-openapi/swag/yamlutils v0.28.0 h1:TV3JXH6DS46KUroDtMLAYHGkdWf5VDq3wVWFirmzROY= +github.com/go-openapi/swag/yamlutils v0.28.0/go.mod h1:x0q/yndZHEgk9Rx3DyDqzFUmHy55KTvIZldvF2dTJXs= +github.com/go-openapi/testify/enable/yaml/v2 v2.6.1 h1:Jm+/ze2rMtbD98yen92AhATGLGREDYXG56Xr4gMjEtE= +github.com/go-openapi/testify/enable/yaml/v2 v2.6.1/go.mod h1:YDPnwCRDu38/oJBVMBVXOUDiJ9cIeBHWvfImHaXqnv4= +github.com/go-openapi/testify/v2 v2.6.1 h1:6CNJhTjMzgaeaH8WhshcsZNPIvRemiOcFpU7seO/y7Q= +github.com/go-openapi/testify/v2 v2.6.1/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= +github.com/go-openapi/validate v0.26.3 h1:OkfZgLvLDnGP2hrRGD+42WBiPWWkoHomTJ+IVI+KaDc= +github.com/go-openapi/validate v0.26.3/go.mod h1:7DOOa4raU6NRe7A8VQSKbm3VcuUIioREYHFt+er9Sk8= +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/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/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.7 h1:/vPFuVXDjtFREsVArW+0h1CIl5urnOhzei4X2DMW9IU= +github.com/google/go-containerregistry v0.21.7/go.mod h1:kjSbt7/zMsKLWfnHrIvKvhXHUw91jbe9DNjPPJ32gXE= +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/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/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/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/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/jellydator/ttlcache/v3 v3.4.0 h1:YS4P125qQS0tNhtL6aeYkheEaB/m8HCqdMMP4mnWdTY= +github.com/jellydator/ttlcache/v3 v3.4.0/go.mod h1:Hw9EgjymziQD3yGsQdf1FqFdpp7YjFMd4Srg5EJlgD4= +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/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/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.2 h1:IEclFb9JNvzYA6MW2SCxbLzcHTVsfqm3PrqGQJH5zec= +github.com/oklog/ulid/v2 v2.1.2/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/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= +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/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/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/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.3 h1:0Tyolw3zreRgm7PUW8dccFLXGBThi08278jI8EXNSr4= +github.com/sigstore/rekor v1.5.3/go.mod h1:h3GK5dDqCcWJJZUJwdpKGSSmEV2GEjPUjJy3WTjBwzA= +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.9 h1:7Dcpt+ibnltHQZ8XhaU0dFmhHaf/T491eJfA9WDex4Y= +github.com/sigstore/sigstore v1.10.9/go.mod h1:LYW9+qH7bK8wZmLm6lPxIC5lkHtkJDCgkqjChzTAIBs= +github.com/sigstore/sigstore-go v1.2.1 h1:YWP/rDbBaEBvtbkj6xtwsSj38ZCFEhTVVadNOXjVe3A= +github.com/sigstore/sigstore-go v1.2.1/go.mod h1:I8BqVwAb/SaQJ5pBu5IDFY+ksq8O/1/kCag8XUgrsko= +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/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +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.7.0 h1:k7QnUXJ1cRDpvoy/5l1FimZqMAArRff8vjUqzi5N04o= +github.com/tink-crypto/tink-go/v2 v2.7.0/go.mod h1:cWNpQ/yAT/QHzAV0kBGMOSJzzYTKofDZdJaUqOPPWCI= +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= +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.45.0 h1:pdrWmLHofpubmArBv1LgFSv1Z0Ie/ppdZzu+kUN5EeU= +go.opentelemetry.io/otel v1.45.0/go.mod h1:XZxIqPapzEYnhNSScF5DIqXhm/rYi0FzCe2XddAwZfQ= +go.opentelemetry.io/otel/metric v1.45.0 h1:7Eg1uH7CJ5cXv9is6tnBe1FI6rj1nwUdbFypRm3br/M= +go.opentelemetry.io/otel/metric v1.45.0/go.mod h1:HAPbm1nd3p1PmFH7v2dR+6BjXxw+Lq4a2+pndMAm08s= +go.opentelemetry.io/otel/sdk v1.45.0 h1:4VVSMgQ83dUgW2aoX5f6JgLvHwIvzcuLnF9lUdCSpCw= +go.opentelemetry.io/otel/sdk v1.45.0/go.mod h1:Sr40LgXV7DsKMMJMKOhUWOgMWTfAaqvm2kF0g7ilwuA= +go.opentelemetry.io/otel/trace v1.45.0 h1:l/mP6Uv7oNO7/TblbhpbgMidxhq1uO/rPsikOyVhxag= +go.opentelemetry.io/otel/trace v1.45.0/go.mod h1:qoJJA2xNMnxRrdISU/kLtfUH2wNeQbiv+jhs/CxI8bc= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= +golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= +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= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +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.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= +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-20260727163830-6c54dddc4772 h1:4namukbyF7JY83aWHQwi9J5ugNTnDReLJ9ZcpqOpRB4= +google.golang.org/genproto/googleapis/api v0.0.0-20260727163830-6c54dddc4772/go.mod h1:1brfde68Npq6+WA75c1EHWPijZEG1kMus61ygPZfn4A= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d h1:IL4hdHzcUv2l/gcg98/Rj3FbtE6axwqslOW8SW0C+S0= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d/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.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= +google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +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= diff --git a/tools/rekor-entry/main.go b/tools/rekor-entry/main.go new file mode 100644 index 0000000..78706bb --- /dev/null +++ b/tools/rekor-entry/main.go @@ -0,0 +1,255 @@ +// Command rekor-entry authenticates v1 entry proofs before emitting digest metadata. +package main + +import ( + "bufio" + "context" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/hex" + "encoding/json" + "encoding/pem" + "errors" + "flag" + "fmt" + "os" + "strconv" + "strings" + "time" + + "github.com/sigstore/rekor/pkg/client" + "github.com/sigstore/rekor/pkg/generated/client/entries" + "github.com/sigstore/rekor/pkg/generated/models" + "github.com/sigstore/rekor/pkg/util" + "github.com/sigstore/rekor/pkg/verify" + "github.com/sigstore/sigstore-go/pkg/fulcio/certificate" + "github.com/sigstore/sigstore-go/pkg/root" + "github.com/sigstore/sigstore-go/pkg/tuf" + "github.com/sigstore/sigstore/pkg/signature" +) + +type observation struct { + UUID string + Index int64 + CertSubject string + Issuer string +} + +type result struct { + UUID string `json:"uuid"` + Index int64 `json:"index"` + Kind string `json:"kind,omitempty"` + Digest string `json:"digest,omitempty"` + IntegratedTime int64 `json:"integrated_time,omitempty"` + Identity *certificate.Summary `json:"identity,omitempty"` + Error string `json:"error,omitempty"` +} + +// checkProof binds the body, position, tree size and root to signed log data. +// The consistency callback additionally joins this checkpoint to the monitor's view. +func checkProof(ctx context.Context, observed observation, entry *models.LogEntryAnon, + verifier signature.Verifier, saved *util.SignedCheckpoint, + consistent func(context.Context, *util.SignedCheckpoint, *util.SignedCheckpoint, string) error) error { + if entry.Verification == nil || entry.Verification.InclusionProof == nil || entry.LogIndex == nil || entry.IntegratedTime == nil { + return errors.New("entry lacks inclusion or timestamp evidence") + } + proof := entry.Verification.InclusionProof + if proof.LogIndex == nil || proof.TreeSize == nil || proof.RootHash == nil || proof.Checkpoint == nil || *entry.LogIndex != observed.Index || *proof.LogIndex < 0 || *proof.TreeSize <= *proof.LogIndex { + return errors.New("entry position does not match observation") + } + body, ok := entry.Body.(string) + if !ok { + return errors.New("entry body is not base64") + } + raw, err := base64.StdEncoding.DecodeString(body) + if err != nil { + return err + } + leaf := sha256.Sum256(append([]byte{0}, raw...)) + if len(observed.UUID) != 80 || !strings.HasSuffix(observed.UUID, hex.EncodeToString(leaf[:])) { + return errors.New("entry UUID does not bind its body") + } + if err := verify.VerifyLogEntry(ctx, entry, verifier); err != nil { + return fmt.Errorf("verify log entry: %w", err) + } + checkpoint := &util.SignedCheckpoint{} + if err := checkpoint.UnmarshalText([]byte(*proof.Checkpoint)); err != nil { + return err + } + if checkpoint.Size != uint64(*proof.TreeSize) || saved.Origin != checkpoint.Origin || !saved.Verify(verifier) { + return errors.New("proof checkpoint is not the monitored tree") + } + treeID, err := strconv.ParseUint(observed.UUID[:16], 16, 64) + if err != nil { + return err + } + older, newer := saved, checkpoint + if older.Size > newer.Size { + older, newer = newer, older + } + return consistent(ctx, older, newer, strconv.FormatUint(treeID, 10)) +} + +func metadata(observed observation, entry *models.LogEntryAnon, roots, intermediates *x509.CertPool) result { + out := result{UUID: observed.UUID, Index: observed.Index, IntegratedTime: *entry.IntegratedTime} + var body struct { + Kind string `json:"kind"` + Spec struct { + Data struct { + Hash struct{ Algorithm, Value string } + } `json:"data"` + Signature struct{ PublicKey struct{ Content string } } `json:"signature"` + } `json:"spec"` + } + raw, err := base64.StdEncoding.DecodeString(entry.Body.(string)) + if err == nil { + err = json.Unmarshal(raw, &body) + } + if err != nil { + out.Error = err.Error() + return out + } + out.Kind = body.Kind + if body.Kind != "hashedrekord" { + return out + } // Opaque attestation payloads are deliberately UNKNOWN. + certPEM, err := base64.StdEncoding.DecodeString(body.Spec.Signature.PublicKey.Content) + if err != nil { + out.Error = err.Error() + return out + } + block, _ := pem.Decode(certPEM) + if block == nil || block.Type != "CERTIFICATE" { + out.Error = "entry has no certificate" + return out + } + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + out.Error = err.Error() + return out + } + _, err = cert.Verify(x509.VerifyOptions{Roots: roots, Intermediates: intermediates, + CurrentTime: time.Unix(*entry.IntegratedTime, 0), KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageCodeSigning}}) + if err != nil { + out.Error = "UNAUTHENTICATED-SAN" + return out + } + summary, err := certificate.SummarizeCertificate(cert) + if err != nil { + out.Error = err.Error() + return out + } + if summary.SubjectAlternativeName != observed.CertSubject || summary.Issuer != observed.Issuer { + out.Error = "certificate identity differs from observation" + return out + } + out.Identity = &summary + digest, err := hex.DecodeString(body.Spec.Data.Hash.Value) + if body.Spec.Data.Hash.Algorithm != "sha256" || err != nil || len(digest) != 32 { + out.Error = "unsupported signed digest" + return out + } + out.Digest = hex.EncodeToString(digest) + return out +} + +func pool(path string) (*x509.CertPool, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + pool := x509.NewCertPool() + if !pool.AppendCertsFromPEM(data) { + return nil, fmt.Errorf("no certificates in %s", path) + } + return pool, nil +} + +func run(ctx context.Context) error { + checkpointPath := flag.String("checkpoint", "monitor-state/checkpoint.txt", "verified monitor checkpoint") + rootsPath := flag.String("roots", "tools/rekor-fulcio-roots.pem", "committed Fulcio roots") + intermediatesPath := flag.String("intermediates", "tools/rekor-fulcio-intermediates.pem", "committed Fulcio intermediates") + flag.Parse() + roots, err := pool(*rootsPath) + if err != nil { + return err + } + intermediates, err := pool(*intermediatesPath) + if err != nil { + return err + } + savedText, err := os.ReadFile(*checkpointPath) + if err != nil { + return err + } + saved := &util.SignedCheckpoint{} + if err := saved.UnmarshalText([]byte(strings.ReplaceAll(strings.TrimSpace(string(savedText)), `\n`, "\n"))); err != nil { + return err + } + tufOptions := tuf.DefaultOptions() + tufOptions.Context = ctx + trusted, err := root.FetchTrustedRootWithOptions(tufOptions) + if err != nil { + return err + } + rekorClient, err := client.GetRekorClient("https://rekor.sigstore.dev") + if err != nil { + return err + } + consistent := func(ctx context.Context, older, newer *util.SignedCheckpoint, treeID string) error { + return verify.ProveConsistency(ctx, rekorClient, older, newer, treeID) + } + scanner := bufio.NewScanner(os.Stdin) + scanner.Buffer(make([]byte, 4096), 1024*1024) + encoder := json.NewEncoder(os.Stdout) + for scanner.Scan() { + var observed observation + if err := json.Unmarshal(scanner.Bytes(), &observed); err != nil { + return err + } + if len(observed.UUID) != 80 { + return errors.New("unsupported v1 entry identifier") + } + params := entries.NewGetLogEntryByUUIDParamsWithContext(ctx).WithEntryUUID(observed.UUID) + response, err := rekorClient.Entries.GetLogEntryByUUID(params) + out := result{UUID: observed.UUID, Index: observed.Index} + if err == nil { + entry, ok := response.Payload[observed.UUID] + if !ok { + err = errors.New("response omitted requested entry") + } else { + if entry.LogID == nil { + err = errors.New("entry lacks log ID") + } else if log := trusted.RekorLogs()[*entry.LogID]; log == nil { + err = errors.New("log ID is not in the TUF trusted root") + } else { + verifier, loadErr := signature.LoadVerifier(log.PublicKey, log.HashFunc) + err = loadErr + if err == nil { + err = checkProof(ctx, observed, &entry, verifier, saved, consistent) + } + if err == nil { + out = metadata(observed, &entry, roots, intermediates) + } + } + } + } + if err != nil { + out.Error = err.Error() + } + if err := encoder.Encode(out); err != nil { + return err + } + } + return scanner.Err() +} + +func main() { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) + defer cancel() + if err := run(ctx); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/tools/rekor-entry/main_test.go b/tools/rekor-entry/main_test.go new file mode 100644 index 0000000..a17701f --- /dev/null +++ b/tools/rekor-entry/main_test.go @@ -0,0 +1,159 @@ +package main + +import ( + "bytes" + "context" + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "crypto/x509" + "crypto/x509/pkix" + "encoding/asn1" + "encoding/base64" + "encoding/hex" + "encoding/json" + "encoding/pem" + "errors" + "math/big" + "net/url" + "testing" + "time" + + "github.com/sigstore/rekor/pkg/generated/models" + "github.com/sigstore/rekor/pkg/util" + "github.com/sigstore/sigstore/pkg/signature" + "github.com/sigstore/sigstore/pkg/signature/options" +) + +func fixtureProof(t *testing.T) (observation, *models.LogEntryAnon, *util.SignedCheckpoint, signature.SignerVerifier) { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + signer, err := signature.LoadECDSASignerVerifier(key, crypto.SHA256) + if err != nil { + t.Fatal(err) + } + body := base64.StdEncoding.EncodeToString([]byte(`{"kind":"fixture"}`)) + leaf := sha256.Sum256(append([]byte{0}, []byte(`{"kind":"fixture"}`)...)) + checkpoint, err := util.CreateSignedCheckpoint(util.Checkpoint{Origin: "fixture", Size: 1, Hash: leaf[:]}) + if err != nil { + t.Fatal(err) + } + if _, err := checkpoint.Sign("fixture", signer, options.WithContext(context.Background())); err != nil { + t.Fatal(err) + } + note, err := checkpoint.MarshalText() + if err != nil { + t.Fatal(err) + } + // Rekor's signed global index differs from the shard-local proof position. + index, localIndex, size, integrated := int64(100), int64(0), int64(1), int64(1700000000) + rootHash, logID, noteText := hex.EncodeToString(leaf[:]), "fixture-log", string(note) + setPayload, err := json.Marshal(map[string]any{"body": body, "integratedTime": integrated, "logID": logID, "logIndex": index}) + if err != nil { + t.Fatal(err) + } + set, err := signer.SignMessage(bytes.NewReader(setPayload)) + if err != nil { + t.Fatal(err) + } + entry := &models.LogEntryAnon{Body: body, IntegratedTime: &integrated, LogIndex: &index, LogID: &logID, + Verification: &models.LogEntryAnonVerification{SignedEntryTimestamp: set, + InclusionProof: &models.InclusionProof{Checkpoint: ¬eText, Hashes: []string{}, LogIndex: &localIndex, TreeSize: &size, RootHash: &rootHash}}} + return observation{UUID: "0000000000000001" + rootHash, Index: index}, entry, checkpoint, signer +} + +func TestProofVerification(t *testing.T) { + for _, mutation := range []string{"valid", "body", "index", "root", "checkpoint", "timestamp", "consistency", "uuid"} { + t.Run(mutation, func(t *testing.T) { + observed, entry, saved, verifier := fixtureProof(t) + switch mutation { + case "body": + entry.Body = base64.StdEncoding.EncodeToString([]byte("tampered")) + case "index": + observed.Index = 1 + case "root": + *entry.Verification.InclusionProof.RootHash = "00" + case "checkpoint": + *entry.Verification.InclusionProof.Checkpoint += "invalid" + case "timestamp": + *entry.IntegratedTime += 1 + case "uuid": + observed.UUID = "0000000000000001" + string(bytes.Repeat([]byte("a"), 64)) + } + called := false + consistent := func(context.Context, *util.SignedCheckpoint, *util.SignedCheckpoint, string) error { + called = true + if mutation == "consistency" { + return errors.New("fork") + } + return nil + } + err := checkProof(context.Background(), observed, entry, verifier, saved, consistent) + if mutation == "valid" { + if err != nil || !called { + t.Fatalf("valid proof: called=%v err=%v", called, err) + } + } else if err == nil { + t.Fatal("accepted altered proof") + } + }) + } +} + +func TestCertificateChainAndDigest(t *testing.T) { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + now := time.Unix(1700000000, 0) + ca := &x509.Certificate{SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "fixture CA"}, + NotBefore: now.Add(-time.Hour), NotAfter: now.Add(time.Hour), IsCA: true, BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign} + caDER, err := x509.CreateCertificate(rand.Reader, ca, ca, &key.PublicKey, key) + if err != nil { + t.Fatal(err) + } + ca, err = x509.ParseCertificate(caDER) + if err != nil { + t.Fatal(err) + } + san, _ := url.Parse("https://github.com/ConductorOne/github-workflows/.github/workflows/release.yaml@refs/tags/v4") + issuer := "https://token.actions.githubusercontent.com" + issuerDER, _ := asn1.Marshal(issuer) + leaf := &x509.Certificate{SerialNumber: big.NewInt(2), NotBefore: now.Add(-time.Minute), NotAfter: now.Add(time.Minute), + URIs: []*url.URL{san}, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageCodeSigning}, + ExtraExtensions: []pkix.Extension{{Id: asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 57264, 1, 8}, Value: issuerDER}}} + der, err := x509.CreateCertificate(rand.Reader, leaf, ca, &key.PublicKey, key) + if err != nil { + t.Fatal(err) + } + cert := base64.StdEncoding.EncodeToString(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})) + digest := hex.EncodeToString(bytes.Repeat([]byte{1}, 32)) + body, _ := json.Marshal(map[string]any{"kind": "hashedrekord", "spec": map[string]any{ + "data": map[string]any{"hash": map[string]string{"algorithm": "sha256", "value": digest}}, + "signature": map[string]any{"publicKey": map[string]string{"content": cert}}}}) + integrated := now.Unix() + entry := &models.LogEntryAnon{Body: base64.StdEncoding.EncodeToString(body), IntegratedTime: &integrated} + observed := observation{CertSubject: san.String(), Issuer: issuer} + roots := x509.NewCertPool() + roots.AddCert(ca) + got := metadata(observed, entry, roots, x509.NewCertPool()) + if got.Error != "" || got.Digest != digest || got.Identity == nil { + t.Fatalf("authenticated cert: %+v", got) + } + got = metadata(observed, entry, x509.NewCertPool(), x509.NewCertPool()) + if got.Error != "UNAUTHENTICATED-SAN" || got.Identity != nil { + t.Fatalf("untrusted cert: %+v", got) + } + for _, kind := range []string{"dsse", "intoto"} { + entry.Body = base64.StdEncoding.EncodeToString([]byte(`{"kind":"` + kind + `","spec":{"payloadHash":{"value":"` + digest + `"}}}`)) + got = metadata(observed, entry, roots, x509.NewCertPool()) + if got.Digest != "" || got.Identity != nil { + t.Fatal("opaque payload hash became artifact digest") + } + } +} diff --git a/tools/rekor-fulcio-intermediates.pem b/tools/rekor-fulcio-intermediates.pem new file mode 100644 index 0000000..e4832e6 --- /dev/null +++ b/tools/rekor-fulcio-intermediates.pem @@ -0,0 +1,14 @@ +-----BEGIN CERTIFICATE----- +MIICGjCCAaGgAwIBAgIUALnViVfnU0brJasmRkHrn/UnfaQwCgYIKoZIzj0EAwMw +KjEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MREwDwYDVQQDEwhzaWdzdG9yZTAeFw0y +MjA0MTMyMDA2MTVaFw0zMTEwMDUxMzU2NThaMDcxFTATBgNVBAoTDHNpZ3N0b3Jl +LmRldjEeMBwGA1UEAxMVc2lnc3RvcmUtaW50ZXJtZWRpYXRlMHYwEAYHKoZIzj0C +AQYFK4EEACIDYgAE8RVS/ysH+NOvuDZyPIZtilgUF9NlarYpAd9HP1vBBH1U5CV7 +7LSS7s0ZiH4nE7Hv7ptS6LvvR/STk798LVgMzLlJ4HeIfF3tHSaexLcYpSASr1kS +0N/RgBJz/9jWCiXno3sweTAOBgNVHQ8BAf8EBAMCAQYwEwYDVR0lBAwwCgYIKwYB +BQUHAwMwEgYDVR0TAQH/BAgwBgEB/wIBADAdBgNVHQ4EFgQU39Ppz1YkEZb5qNjp +KFWixi4YZD8wHwYDVR0jBBgwFoAUWMAeX5FFpWapesyQoZMi0CrFxfowCgYIKoZI +zj0EAwMDZwAwZAIwPCsQK4DYiZYDPIaDi5HFKnfxXx6ASSVmERfsynYBiX2X6SJR +nZU84/9DZdnFvvxmAjBOt6QpBlc4J/0DxvkTCqpclvziL6BCCPnjdlIB3Pu3BxsP +mygUY7Ii2zbdCdliiow= +-----END CERTIFICATE----- diff --git a/tools/rekor-fulcio-roots.pem b/tools/rekor-fulcio-roots.pem new file mode 100644 index 0000000..9e4f3ba --- /dev/null +++ b/tools/rekor-fulcio-roots.pem @@ -0,0 +1,26 @@ +-----BEGIN CERTIFICATE----- +MIIB+DCCAX6gAwIBAgITNVkDZoCiofPDsy7dfm6geLbuhzAKBggqhkjOPQQDAzAq +MRUwEwYDVQQKEwxzaWdzdG9yZS5kZXYxETAPBgNVBAMTCHNpZ3N0b3JlMB4XDTIx +MDMwNzAzMjAyOVoXDTMxMDIyMzAzMjAyOVowKjEVMBMGA1UEChMMc2lnc3RvcmUu +ZGV2MREwDwYDVQQDEwhzaWdzdG9yZTB2MBAGByqGSM49AgEGBSuBBAAiA2IABLSy +A7Ii5k+pNO8ZEWY0ylemWDowOkNa3kL+GZE5Z5GWehL9/A9bRNA3RbrsZ5i0Jcas +taRL7Sp5fp/jD5dxqc/UdTVnlvS16an+2Yfswe/QuLolRUCrcOE2+2iA5+tzd6Nm +MGQwDgYDVR0PAQH/BAQDAgEGMBIGA1UdEwEB/wQIMAYBAf8CAQEwHQYDVR0OBBYE +FMjFHQBBmiQpMlEk6w2uSu1KBtPsMB8GA1UdIwQYMBaAFMjFHQBBmiQpMlEk6w2u +Su1KBtPsMAoGCCqGSM49BAMDA2gAMGUCMH8liWJfMui6vXXBhjDgY4MwslmN/TJx +Ve/83WrFomwmNf056y1X48F9c4m3a3ozXAIxAKjRay5/aj/jsKKGIkmQatjI8uup +Hr/+CxFvaJWmpYqNkLDGRU+9orzh5hI2RrcuaQ== +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIB9zCCAXygAwIBAgIUALZNAPFdxHPwjeDloDwyYChAO/4wCgYIKoZIzj0EAwMw +KjEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MREwDwYDVQQDEwhzaWdzdG9yZTAeFw0y +MTEwMDcxMzU2NTlaFw0zMTEwMDUxMzU2NThaMCoxFTATBgNVBAoTDHNpZ3N0b3Jl +LmRldjERMA8GA1UEAxMIc2lnc3RvcmUwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAT7 +XeFT4rb3PQGwS4IajtLk3/OlnpgangaBclYpsYBr5i+4ynB07ceb3LP0OIOZdxex +X69c5iVuyJRQ+Hz05yi+UF3uBWAlHpiS5sh0+H2GHE7SXrk1EC5m1Tr19L9gg92j +YzBhMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBRY +wB5fkUWlZql6zJChkyLQKsXF+jAfBgNVHSMEGDAWgBRYwB5fkUWlZql6zJChkyLQ +KsXF+jAKBggqhkjOPQQDAwNpADBmAjEAj1nHeXZp+13NWBNa+EDsDP8G1WWg1tCM +WP/WHPqpaVo0jhsweNFZgSs0eE7wYI4qAjEA2WB9ot98sIkoF3vZYdd3/VtWB5b9 +TNMea7Ix/stJ5TfcLLeABLE4BNJOsQ4vnBHJ +-----END CERTIFICATE----- diff --git a/tools/rekor-monitor.yaml b/tools/rekor-monitor.yaml new file mode 100644 index 0000000..eef8fcf --- /dev/null +++ b/tools/rekor-monitor.yaml @@ -0,0 +1,13 @@ +monitoredValues: + certIdentities: + - certSubject: '^https://github\.com/ConductorOne/github-workflows/\.github/workflows/release\.yaml@.+$' + issuers: ['^https://token\.actions\.githubusercontent\.com$'] + - certSubject: '^https://github\.com/ConductorOne/github-axiomatic-workflows/\.github/workflows/release\.yaml@.+$' + issuers: ['^https://token\.actions\.githubusercontent\.com$'] + - certSubject: '^https://github\.com/ConductorOne/multipass-workflows/\.github/workflows/release-artifacts\.yaml@.+$' + issuers: ['^https://token\.actions\.githubusercontent\.com$'] +outputIdentities: monitor-state/identities.jsonl +outputIdentitiesFormat: json +identityMetadataFile: monitor-state/identity-metadata.json +caRootsFile: tools/rekor-fulcio-roots.pem +caIntermediatesFile: tools/rekor-fulcio-intermediates.pem