From 99376f2f1bd05112081b3df0f187c9d27afc5a77 Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Thu, 2 Jul 2026 04:36:21 +0100 Subject: [PATCH 1/2] feat(slsa): verify GitHub native build-provenance attestations (#56) Consumer-side counterpart to the estate's 2026-06-25 producer rollout of actions/attest-build-provenance. New Opsm.Slsa.GithubAttestation module: - fetch_attestations/3: GET /repos/{owner}/{repo}/attestations/{digest} via Verified.Http (SSRF-guarded) with GITHUB_TOKEN when present. Returns attestation records; the inline bundle is used when present (bundle_url blobs are snappy-compressed, so verification backends fetch bundles themselves). - verify_subject/4: cryptographic bundle verification via checky-monkey's new POST /verify/github-attestation endpoint (wraps gh attestation verify), falling back to the local gh CLI via SafeExec. Fail-open: unreachable verifiers degrade to :unverified, never block. - Provenance.verify_github_attestation/2 + verification-gated builder trust: the GitHub Actions builder identity (https://github.com///.github/workflows/@) is trusted ONLY when the Sigstore bundle verified - the builder string alone never confers trust (check_builder/3 gate). - Trust pipeline: new parallel :github_attestation check. Missing attestations skip; unreachable verifier is info; only a cryptographically REJECTED attestation is an error. - checky-monkey (Rust): sync /verify/github-attestation endpoint, svalinn-style tokio::process wrapper around gh, camelCase wire format, operational-vs-rejection error split, owner/repo arg validation. - SafeExec: fix latent bug - :allowlist opt was forwarded to System.cmd which rejects unknown options; nobody had passed the documented opt. - Opsm.Http.post_json_get_json/3: POST that returns the parsed body (post_json/3 discards 2xx bodies), sharing get_json's validation. - mix.lock: re-pin verisim_client to live verisimdb HEAD (old pinned SHA vanished from the remote; deps.get was broken for fresh clones). Verified end-to-end against a live attested artifact (gh 2.92.0 release, cli/cli): fetch finds the record, gh backend verifies the bundle, builder id extracts + matches, SLSA verify grants builder trust only with bundle_verified: true. Estate images at :latest carry no bundles yet (nothing rebuilt since the producer rollout) - live test defaults to the immutable cli/cli artifact until an estate artifact is attested. Tests: 18 new unit tests (builder matcher, DSSE extraction, gh output decoding, trust gating, pipeline integration) + tagged live contract tests; 3 new Rust tests. Full suite: 950 tests, 1 pre-existing environmental failure (snap query hangs in WSL, unrelated). Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 1 + opsm_ex/lib/opsm/clients/checky_monkey.ex | 36 ++ opsm_ex/lib/opsm/http.ex | 57 ++- opsm_ex/lib/opsm/safe_exec.ex | 3 +- opsm_ex/lib/opsm/slsa/github_attestation.ex | 365 ++++++++++++++++++ opsm_ex/lib/opsm/slsa/provenance.ex | 46 ++- opsm_ex/lib/opsm/trust/pipeline.ex | 36 ++ opsm_ex/lib/opsm/types.ex | 15 + opsm_ex/mix.lock | 2 +- .../live/github_attestation_live_test.exs | 77 ++++ .../opsm/slsa/github_attestation_test.exs | 258 +++++++++++++ services/checky-monkey/src/main.rs | 222 +++++++++++ 12 files changed, 1093 insertions(+), 25 deletions(-) create mode 100644 opsm_ex/lib/opsm/slsa/github_attestation.ex create mode 100644 opsm_ex/test/opsm/live/github_attestation_live_test.exs create mode 100644 opsm_ex/test/opsm/slsa/github_attestation_test.exs diff --git a/CHANGELOG.md b/CHANGELOG.md index 97b03f38..f90bda06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ this project aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- feat(slsa): GitHub native build-provenance attestation verification (#56) — `Opsm.Slsa.GithubAttestation` fetches Sigstore attestation records from the GitHub attestations API, verification is delegated to checky-monkey's new `POST /verify/github-attestation` endpoint (wrapping `gh attestation verify`) with a local-CLI SafeExec fallback, and `Opsm.Slsa.Provenance.verify_github_attestation/2` trusts the GitHub Actions builder identity only after the bundle cryptographically verifies. Wired into the trust pipeline as the fail-open `:github_attestation` check. - feat(storage): S3/IPFS tarball cache backends replacing /tmp-only caching - feat(security): CVE/OSV scanning + typosquat detection - feat(tui): wire opsm tui dispatch to ratatui binary diff --git a/opsm_ex/lib/opsm/clients/checky_monkey.ex b/opsm_ex/lib/opsm/clients/checky_monkey.ex index fe541d47..979ecebd 100644 --- a/opsm_ex/lib/opsm/clients/checky_monkey.ex +++ b/opsm_ex/lib/opsm/clients/checky_monkey.ex @@ -11,6 +11,7 @@ defmodule Opsm.Clients.CheckyMonkey do HttpConfig, CheckyMonkeyRequest, CheckyMonkeyResponse, + GithubAttestationVerification, VerificationResult, OikosHealthResponse } @@ -79,6 +80,41 @@ defmodule Opsm.Clients.CheckyMonkey do end end + @doc """ + Verify a GitHub native build-provenance attestation (Sigstore bundle) + for an artifact. The service wraps `gh attestation verify`. + + Request map keys: `:owner_repo` (required, `"owner/repo"`), plus one + subject — `:oci_uri` (`"oci://..."`) or `:artifact_path` (a path the + service can read). Returns `{:ok, %GithubAttestationVerification{}}` + or `{:error, reason}` (service unreachable / bad request). + """ + def verify_github_attestation(%__MODULE__{client: client}, request) when is_map(request) do + body = %{ + "ownerRepo" => Map.fetch!(request, :owner_repo), + "ociUri" => Map.get(request, :oci_uri), + "artifactPath" => Map.get(request, :artifact_path) + } + + case Http.post_json_get_json(client, "/verify/github-attestation", body) do + {:ok, json} when is_map(json) -> + {:ok, + %GithubAttestationVerification{ + verified: json["verified"] == true, + builder_id: json["builderId"], + predicate_type: json["predicateType"], + message: json["message"] || "", + details: json["details"] + }} + + {:ok, _other} -> + {:error, "Unexpected response shape from checky-monkey"} + + {:error, reason} -> + {:error, reason} + end + end + @doc """ Check service health. """ diff --git a/opsm_ex/lib/opsm/http.ex b/opsm_ex/lib/opsm/http.ex index e2df9592..e0e5f422 100644 --- a/opsm_ex/lib/opsm/http.ex +++ b/opsm_ex/lib/opsm/http.ex @@ -78,23 +78,7 @@ defmodule Opsm.Http do def get_json(client, path) do case Req.get(client, url: path) do {:ok, %Req.Response{status: status, body: body}} when status in 200..299 -> - # Validate JSON response if it's a string - case body do - body when is_binary(body) -> - case Json.decode(body) do - {:ok, parsed} -> {:ok, parsed} - {:error, reason} -> {:error, "JSON validation failed: #{inspect(reason)}"} - end - - body when is_map(body) or is_list(body) -> - # Already parsed by Req, return as-is - # Note: We trust Req's JSON decoder for now, but in v2.0 - # we should validate all JSON regardless of source - {:ok, body} - - _other -> - {:error, "Invalid response body type"} - end + validate_json_body(body) {:ok, %Req.Response{status: status, body: body}} -> {:error, "HTTP error: #{status} - #{inspect(body)}"} @@ -104,6 +88,45 @@ defmodule Opsm.Http do end end + @doc """ + POST JSON to a path and return the parsed JSON response body. + + Unlike `post_json/3` (which discards 2xx bodies), this is for endpoints + whose response payload matters. Validated via Verified.Json like `get_json/2`. + """ + def post_json_get_json(client, path, body) do + case Req.post(client, url: path, json: body) do + {:ok, %Req.Response{status: status, body: rbody}} when status in 200..299 -> + validate_json_body(rbody) + + {:ok, %Req.Response{status: status, body: rbody}} -> + {:error, "HTTP error: #{status} - #{inspect(rbody)}"} + + {:error, reason} -> + {:error, "Request failed: #{inspect(reason)}"} + end + end + + # Validate a response body: strings go through Verified.Json (size/depth + # limits); Req-parsed maps/lists pass through. + defp validate_json_body(body) do + case body do + body when is_binary(body) -> + case Json.decode(body) do + {:ok, parsed} -> {:ok, parsed} + {:error, reason} -> {:error, "JSON validation failed: #{inspect(reason)}"} + end + + body when is_map(body) or is_list(body) -> + # Already parsed by Req. Note: we trust Req's JSON decoder for now, + # but in v2.0 we should validate all JSON regardless of source. + {:ok, body} + + _other -> + {:error, "Invalid response body type"} + end + end + @doc """ Build URL by joining base and path. """ diff --git a/opsm_ex/lib/opsm/safe_exec.ex b/opsm_ex/lib/opsm/safe_exec.ex index dbdb98fe..a686eceb 100644 --- a/opsm_ex/lib/opsm/safe_exec.ex +++ b/opsm_ex/lib/opsm/safe_exec.ex @@ -69,7 +69,8 @@ defmodule Opsm.SafeExec do with :ok <- validate_command(command, allowlist), :ok <- validate_args(args) do - System.cmd(command, args, opts) + # :allowlist is ours, not System.cmd's — it rejects unknown options + System.cmd(command, args, Keyword.delete(opts, :allowlist)) else {:error, reason} -> {"safe-exec blocked: #{reason}", 1} end diff --git a/opsm_ex/lib/opsm/slsa/github_attestation.ex b/opsm_ex/lib/opsm/slsa/github_attestation.ex new file mode 100644 index 00000000..a98d59f6 --- /dev/null +++ b/opsm_ex/lib/opsm/slsa/github_attestation.ex @@ -0,0 +1,365 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) Jonathan D.A. Jewell +defmodule Opsm.Slsa.GithubAttestation do + @moduledoc """ + Fetch and verify GitHub native build-provenance attestations. + + GitHub's `actions/attest-build-provenance` produces Sigstore bundles + (DSSE envelope + Fulcio certificate + Rekor/TSA proof) that GitHub stores + against the artifact digest and serves from + `GET /repos/{owner}/{repo}/attestations/{subject-digest}`. + + Trust model (issue #56): the GitHub Actions builder identity + (`https://github.com///.github/workflows/@`) is + trusted ONLY after the Sigstore bundle has been cryptographically + verified — never from the builder string alone. Bundle verification is + delegated to checky-monkey's `/verify/github-attestation` endpoint + (which wraps `gh attestation verify`), with a local `gh` CLI fallback + via SafeExec when the service is unreachable. + """ + + alias Opsm.Verified.Http, as: VerifiedHttp + alias Opsm.Verified.Json + alias Opsm.Clients.CheckyMonkey + alias Opsm.SafeExec + alias Opsm.Types.GithubAttestationVerification + + @api_base "https://api.github.com" + @api_version "2022-11-28" + + # GitHub Actions workflow builder identity, e.g. + # https://github.com/hyperpolymath/proven/.github/workflows/release.yml@refs/heads/main + @github_actions_builder ~r{\Ahttps://github\.com/[^/\s]+/[^/\s]+/\.github/workflows/[^@\s]+@\S+\z} + + @owner_repo_pattern ~r{\A[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+\z} + @sha256_hex ~r/\A[0-9a-f]{64}\z/ + + # ========================================================================== + # Builder identity + # ========================================================================== + + @doc """ + True when the builder ID has the GitHub Actions native-builder shape. + + This predicate alone confers no trust — callers must combine it with a + successful bundle verification (see `Opsm.Slsa.Provenance.verify_github_attestation/2`). + """ + def github_actions_builder?(builder_id) when is_binary(builder_id) do + Regex.match?(@github_actions_builder, builder_id) + end + + def github_actions_builder?(_), do: false + + # ========================================================================== + # Fetch (GitHub attestations API) + # ========================================================================== + + @doc """ + Fetch the attestation records GitHub holds for an artifact digest. + + `owner_repo` is `"owner/repo"`; `digest` is `"sha256:<64-hex>"` or bare hex. + Returns `{:ok, [attestation_record]}` (possibly empty) or `{:error, reason}`. + + Each record may carry the Sigstore bundle inline under `"bundle"` or only + as a `"bundle_url"` (a snappy-compressed blob — NOT fetched here). This + call establishes existence; cryptographic verification and statement + extraction happen in `verify_subject/4`, whose `gh attestation verify` + backend fetches and decompresses bundles itself. + """ + def fetch_attestations(owner_repo, digest, opts \\ []) do + with {:ok, owner_repo} <- validate_owner_repo(owner_repo), + {:ok, digest} <- normalize_digest(digest) do + api_base = Keyword.get(opts, :api_base, @api_base) + url = "#{api_base}/repos/#{owner_repo}/attestations/#{digest}" + timeout = Keyword.get(opts, :timeout, 10_000) + + case VerifiedHttp.get_json(url, headers: github_headers(), timeout: timeout) do + {:ok, %{"attestations" => attestations}} when is_list(attestations) -> + {:ok, attestations} + + {:ok, _other} -> + {:ok, []} + + # GitHub returns 404 when no attestations exist for the digest + {:error, {:http_error, 404}} -> + {:ok, []} + + {:error, reason} -> + {:error, reason} + end + end + end + + @doc """ + The Sigstore bundle carried inline by an attestation record, when present + (a map, or a JSON-encoded string). Records that only carry a `bundle_url` + (snappy-compressed blob) yield `nil`. + """ + def bundle_from_record(%{"bundle" => bundle}) when is_map(bundle), do: bundle + + def bundle_from_record(%{"bundle" => bundle}) when is_binary(bundle) do + case Json.decode(bundle) do + {:ok, decoded} when is_map(decoded) -> decoded + _ -> nil + end + end + + def bundle_from_record(_record), do: nil + + @doc """ + Decode the in-toto statement out of a Sigstore bundle's DSSE envelope. + """ + def extract_statement(%{"dsseEnvelope" => %{"payloadType" => ptype, "payload" => payload}}) + when is_binary(payload) do + if ptype == "application/vnd.in-toto+json" do + with {:ok, raw} <- decode_base64(payload), + {:ok, statement} <- Json.decode(raw) do + {:ok, statement} + end + else + {:error, "Unsupported DSSE payload type: #{ptype}"} + end + end + + def extract_statement(_bundle), do: {:error, "Bundle has no DSSE envelope"} + + # ========================================================================== + # Verify (checky-monkey service, local gh fallback) + # ========================================================================== + + @doc """ + Fetch and verify GitHub attestations for a resolved package. + + Returns: + - `{:ok, %GithubAttestationVerification{}}` — bundle checked (see `.verified`) + - `{:unverified, msg}` — attestations exist but no verifier was reachable + - `{:none, msg}` — nothing to check (no GitHub repo / digest / attestations) + - `{:error, reason}` — the attestation lookup itself failed + """ + def verify_package(package, config, opts \\ []) do + with {:ok, owner_repo} <- github_owner_repo(package), + {:ok, digest} <- artifact_digest(package) do + case fetch_attestations(owner_repo, digest, opts) do + {:ok, []} -> + {:none, "No GitHub attestations found for #{owner_repo}@#{short_digest(digest)}"} + + {:ok, bundles} -> + subject = verification_subject(package, opts) + verify_subject(subject, owner_repo, config, Keyword.put(opts, :bundle_count, length(bundles))) + + {:error, reason} -> + {:error, reason} + end + end + end + + @doc """ + Verify a subject (`oci://` URI, `{:artifact, path}`, or `nil`) against the + repo's GitHub attestations. Tries checky-monkey first, then a local + `gh attestation verify` via SafeExec. With no subject available the bundle + cannot be cryptographically checked — returns `{:unverified, msg}`. + """ + def verify_subject(nil, owner_repo, _config, opts) do + count = Keyword.get(opts, :bundle_count, 1) + + {:unverified, + "#{count} GitHub attestation(s) found for #{owner_repo} " <> + "(no artifact subject available for cryptographic verification)"} + end + + def verify_subject(subject, owner_repo, config, opts) do + with {:ok, owner_repo} <- validate_owner_repo(owner_repo) do + case verify_via_service(subject, owner_repo, config) do + {:ok, verification} -> + {:ok, verification} + + {:error, _service_reason} -> + case verify_via_local_gh(subject, owner_repo, opts) do + {:ok, verification} -> + {:ok, verification} + + {:error, reason} -> + count = Keyword.get(opts, :bundle_count, 1) + + {:unverified, + "#{count} GitHub attestation(s) found for #{owner_repo} " <> + "but no verifier available (#{reason})"} + end + end + end + end + + defp verify_via_service(subject, owner_repo, config) do + client = CheckyMonkey.new(config.checky_monkey, config.http) + + request = + case subject do + {:artifact, path} -> %{owner_repo: owner_repo, artifact_path: path} + oci when is_binary(oci) -> %{owner_repo: owner_repo, oci_uri: oci} + end + + CheckyMonkey.verify_github_attestation(client, request) + end + + defp verify_via_local_gh(subject, owner_repo, opts) do + positional = + case subject do + {:artifact, path} -> path + oci when is_binary(oci) -> oci + end + + args = ["attestation", "verify", positional, "--repo", owner_repo, "--format", "json"] + allowlist = Keyword.get(opts, :exec_allowlist, ["gh"]) + + case SafeExec.cmd("gh", args, allowlist: allowlist) do + {output, 0} -> + case Json.decode(output) do + {:ok, results} -> {:ok, decode_gh_output(results)} + {:error, _} -> {:error, "gh produced unparseable output"} + end + + {output, _nonzero} -> + message = output |> to_string() |> String.trim() |> String.slice(0, 300) + + if String.contains?(message, "safe-exec blocked") or + String.contains?(message, "not found") do + {:error, "gh CLI unavailable: #{message}"} + else + # gh ran and rejected the attestation — that is a definitive failure + {:ok, + %GithubAttestationVerification{ + verified: false, + message: "gh attestation verify failed: #{message}" + }} + end + end + end + + @doc """ + Map `gh attestation verify --format json` output (a list of verification + results) to a `GithubAttestationVerification`. + """ + def decode_gh_output(results) when is_list(results) and results != [] do + statement = + results + |> Enum.map(&get_in_any(&1, [["verificationResult", "statement"], ["statement"]])) + |> Enum.find(&is_map/1) + + builder_id = + case statement do + %{"predicate" => %{"runDetails" => %{"builder" => %{"id" => id}}}} -> id + _ -> nil + end + + %GithubAttestationVerification{ + verified: true, + builder_id: builder_id, + predicate_type: statement && statement["predicateType"], + message: "Verified via gh attestation verify (#{length(results)} attestation(s))", + details: %{statement: statement} + } + end + + def decode_gh_output(_), + do: %GithubAttestationVerification{verified: false, message: "gh returned no verification results"} + + # ========================================================================== + # Package helpers + # ========================================================================== + + @doc """ + Extract `"owner/repo"` from a package's manifest repository URL. + """ + def github_owner_repo(%{manifest: %{repository: repo}}) when is_binary(repo) do + case Regex.run(~r{\Ahttps?://github\.com/([A-Za-z0-9_.-]+)/([A-Za-z0-9_.-]+?)(?:\.git)?/?\z}, repo) do + [_, owner, name] -> {:ok, "#{owner}/#{name}"} + _ -> {:none, "Package repository is not a GitHub URL"} + end + end + + def github_owner_repo(_package), do: {:none, "Package has no repository URL"} + + @doc """ + The `sha256:` artifact digest for attestation lookup, from the + package checksum. Only sha256 checksums are usable. + """ + def artifact_digest(%{checksum: checksum, checksum_algo: algo}) + when is_binary(checksum) and algo in [:sha256, "sha256"] do + case normalize_digest(checksum) do + {:ok, digest} -> {:ok, digest} + {:error, _} -> {:none, "Package checksum is not a sha256 digest"} + end + end + + def artifact_digest(_package), do: {:none, "Package has no sha256 checksum for attestation lookup"} + + defp verification_subject(package, opts) do + cond do + Keyword.has_key?(opts, :artifact_path) -> + {:artifact, Keyword.fetch!(opts, :artifact_path)} + + is_binary(package.tarball_url) and String.starts_with?(package.tarball_url, "oci://") -> + package.tarball_url + + true -> + nil + end + end + + # ========================================================================== + # Validation / small helpers + # ========================================================================== + + defp validate_owner_repo(owner_repo) when is_binary(owner_repo) do + if Regex.match?(@owner_repo_pattern, owner_repo) do + {:ok, owner_repo} + else + {:error, "Invalid owner/repo: #{inspect(owner_repo)}"} + end + end + + defp validate_owner_repo(other), do: {:error, "Invalid owner/repo: #{inspect(other)}"} + + defp normalize_digest("sha256:" <> hex), do: normalize_digest(hex) + + defp normalize_digest(hex) when is_binary(hex) do + normalized = String.downcase(hex) + + if Regex.match?(@sha256_hex, normalized) do + {:ok, "sha256:#{normalized}"} + else + {:error, "Not a sha256 digest: #{inspect(hex)}"} + end + end + + defp normalize_digest(other), do: {:error, "Not a sha256 digest: #{inspect(other)}"} + + defp short_digest("sha256:" <> hex), do: "sha256:#{String.slice(hex, 0, 12)}…" + defp short_digest(other), do: other + + defp decode_base64(payload) do + case Base.decode64(payload) do + {:ok, raw} -> {:ok, raw} + :error -> {:error, "DSSE payload is not valid base64"} + end + end + + defp get_in_any(map, paths) do + Enum.find_value(paths, fn path -> get_in(map, path) end) + end + + defp github_headers do + token = System.get_env("GITHUB_TOKEN") || Application.get_env(:opsm, :github_token, nil) + + base = [ + {"Accept", "application/vnd.github+json"}, + {"X-GitHub-Api-Version", @api_version} + ] + + if token do + [{"Authorization", "Bearer #{token}"} | base] + else + base + end + end +end diff --git a/opsm_ex/lib/opsm/slsa/provenance.ex b/opsm_ex/lib/opsm/slsa/provenance.ex index c5f4b161..f18f2f71 100644 --- a/opsm_ex/lib/opsm/slsa/provenance.ex +++ b/opsm_ex/lib/opsm/slsa/provenance.ex @@ -131,7 +131,7 @@ defmodule Opsm.Slsa.Provenance do result = %SlsaVerificationResult{} # Check builder trust - {builder_trusted, result} = check_builder(provenance, result) + {builder_trusted, result} = check_builder(provenance, result, opts) # Check materials consistency {materials_match, result} = check_materials(provenance, package, result) @@ -217,6 +217,32 @@ defmodule Opsm.Slsa.Provenance do def from_envelope(_), do: {:error, "Invalid envelope format"} + @doc """ + Verify a GitHub-native build-provenance statement (issue #56). + + Takes the in-toto statement decoded from a GitHub attestation's DSSE + envelope plus a verification context. The GitHub Actions builder identity + (`https://github.com///.github/workflows/@`) is + treated as trusted ONLY when `:bundle_verified` is true — i.e. the + Sigstore bundle passed cryptographic verification (`gh attestation + verify` via checky-monkey or the local CLI). The builder string alone + never confers trust. + + ## Options + - `:package` (required) — the ResolvedPackage the statement should cover + - `:bundle_verified` — result of the Sigstore bundle verification + + Returns `{:ok, %SlsaVerificationResult{}}` or `{:error, reason}`. + """ + def verify_github_attestation(statement, opts) when is_map(statement) do + package = Keyword.fetch!(opts, :package) + bundle_verified = Keyword.get(opts, :bundle_verified, false) + + with {:ok, provenance} <- from_envelope(statement) do + verify(provenance, package, github_builder_verified: bundle_verified) + end + end + # ========================================================================== # Private # ========================================================================== @@ -290,11 +316,19 @@ defmodule Opsm.Slsa.Provenance do end end - defp check_builder(provenance, result) do - if provenance.builder_id in @trusted_builders do - {true, result} - else - {false, add_warning(result, "Builder '#{provenance.builder_id}' is not in trusted list")} + defp check_builder(provenance, result, opts) do + cond do + provenance.builder_id in @trusted_builders -> + {true, result} + + # GitHub Actions native builder: trusted only when the enclosing + # Sigstore bundle already passed cryptographic verification. + Keyword.get(opts, :github_builder_verified, false) and + Opsm.Slsa.GithubAttestation.github_actions_builder?(provenance.builder_id) -> + {true, result} + + true -> + {false, add_warning(result, "Builder '#{provenance.builder_id}' is not in trusted list")} end end diff --git a/opsm_ex/lib/opsm/trust/pipeline.ex b/opsm_ex/lib/opsm/trust/pipeline.ex index 771b355e..4debd4e3 100644 --- a/opsm_ex/lib/opsm/trust/pipeline.ex +++ b/opsm_ex/lib/opsm/trust/pipeline.ex @@ -62,6 +62,12 @@ defmodule Opsm.Trust.Pipeline do tasks end + tasks = if :github_attestation not in skip_checks do + [Task.async(fn -> {:github_attestation, check_github_attestation(package, config)} end) | tasks] + else + tasks + end + # Collect results safely (D2: use yield_many to handle task crashes/timeouts) # 3s timeout — trust checks are advisory, install should not stall on unreachable services check_results = @@ -212,6 +218,36 @@ defmodule Opsm.Trust.Pipeline do end end + # GitHub native build-provenance attestations (issue #56). Fail-open: + # a missing attestation or unreachable verifier never blocks install — + # only a cryptographically REJECTED attestation is an error. + defp check_github_attestation(package, config) do + try do + case Opsm.Slsa.GithubAttestation.verify_package(package, config) do + {:ok, %{verified: true, builder_id: builder_id}} -> + if Opsm.Slsa.GithubAttestation.github_actions_builder?(builder_id) do + {:ok, "GitHub build provenance verified (builder: #{builder_id})"} + else + {:ok, "GitHub attestation verified (builder: #{builder_id || "unknown"})"} + end + + {:ok, %{verified: false, message: message}} -> + {:error, "GitHub attestation failed verification: #{message}"} + + {:unverified, message} -> + {:info, message} + + {:none, message} -> + {:skipped, message} + + {:error, reason} -> + {:skipped, "GitHub attestation lookup unavailable: #{inspect(reason)}"} + end + rescue + e -> {:skipped, "GitHub attestation check failed: #{Exception.message(e)}"} + end + end + defp check_sustainability(package, config) do # Fetch a real sustainability score from oikos for this package. # Falls back to heuristic scoring when the oikos service is unreachable, diff --git a/opsm_ex/lib/opsm/types.ex b/opsm_ex/lib/opsm/types.ex index 2878b4e6..de1ddc13 100644 --- a/opsm_ex/lib/opsm/types.ex +++ b/opsm_ex/lib/opsm/types.ex @@ -123,6 +123,21 @@ defmodule Opsm.Types do defstruct [:attestation_type, :uri, :digest] end + defmodule GithubAttestationVerification do + @moduledoc """ + Result of verifying a GitHub native build-provenance attestation + (a Sigstore bundle) — via checky-monkey or the local gh CLI. + """ + @type t :: %__MODULE__{ + verified: boolean(), + builder_id: String.t() | nil, + predicate_type: String.t() | nil, + message: String.t(), + details: map() | nil + } + defstruct verified: false, builder_id: nil, predicate_type: nil, message: "", details: nil + end + defmodule PackageMetadata do @type t :: %__MODULE__{ name: String.t(), diff --git a/opsm_ex/mix.lock b/opsm_ex/mix.lock index 159b17a4..afc1f129 100644 --- a/opsm_ex/mix.lock +++ b/opsm_ex/mix.lock @@ -32,6 +32,6 @@ "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, "thousand_island": {:hex, :thousand_island, "1.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"}, "toml": {:hex, :toml, "0.7.0", "fbcd773caa937d0c7a02c301a1feea25612720ac3fa1ccb8bfd9d30d822911de", [:mix], [], "hexpm", "0690246a2478c1defd100b0c9b89b4ea280a22be9a7b313a8a058a2408a2fa70"}, - "verisim_client": {:git, "https://github.com/hyperpolymath/verisimdb.git", "27c10472dd06e12e5894be6c5fb20721825a8bc4", [sparse: "connectors/clients/elixir"]}, + "verisim_client": {:git, "https://github.com/hyperpolymath/verisimdb.git", "9e4a21c713febde4c85f65c2f5e142f43b70e062", [sparse: "connectors/clients/elixir"]}, "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, } diff --git a/opsm_ex/test/opsm/live/github_attestation_live_test.exs b/opsm_ex/test/opsm/live/github_attestation_live_test.exs new file mode 100644 index 00000000..f7eb9156 --- /dev/null +++ b/opsm_ex/test/opsm/live/github_attestation_live_test.exs @@ -0,0 +1,77 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) Jonathan D.A. Jewell +defmodule Opsm.Live.GithubAttestationLiveTest do + @moduledoc """ + Live contract tests for GitHub native build-provenance attestations + (issue #56). Excluded from the default run. + + The stable subject is a released GitHub CLI artifact: release assets are + immutable, so its digest and attestation are permanent fixtures. Estate + artifacts (e.g. ghcr.io/hyperpolymath/*) should replace it once any + estate image/binary is rebuilt after the 2026-06-25 producer rollout — + as of 2026-07-01 none of the published :latest artifacts carry bundles. + + Override the subject via OPSM_ATTESTED_REPO / OPSM_ATTESTED_DIGEST. + """ + use ExUnit.Case, async: false + + alias Opsm.Slsa.GithubAttestation + + @moduletag :external_api + + # gh v2.92.0 linux amd64 tarball — released artifact, digest is permanent + @default_repo "cli/cli" + @default_digest "b57848131bdf0c229cd35e1f2a51aa718199858b2e728410b37e89a428943ec4" + + defp attested_repo, do: System.get_env("OPSM_ATTESTED_REPO", @default_repo) + defp attested_digest, do: System.get_env("OPSM_ATTESTED_DIGEST", @default_digest) + + test "attestations API returns records for an attested artifact digest" do + assert {:ok, records} = GithubAttestation.fetch_attestations(attested_repo(), attested_digest()) + assert records != [], "expected at least one attestation record" + assert Enum.all?(records, &is_map/1) + end + + test "unknown digests yield an empty record list, not an error" do + absent = String.duplicate("0", 63) <> "1" + assert {:ok, []} = GithubAttestation.fetch_attestations(attested_repo(), absent) + end + + @tag :live_download + test "full chain: fetch, verify via gh, gate builder trust on verification" do + # ~12MB download; needs `gh` on PATH with auth. Mirrors the manual + # acceptance run for issue #56. + tmp = Path.join(System.tmp_dir!(), "opsm-attest-live-#{System.unique_integer([:positive])}.tar.gz") + + try do + url = "https://github.com/cli/cli/releases/download/v2.92.0/gh_2.92.0_linux_amd64.tar.gz" + {:ok, %{status: 200, body: body}} = Opsm.Verified.Http.get(url, timeout: 120_000) + File.write!(tmp, body) + + config = Opsm.Config.load_config_or_example() + + assert {:ok, verification} = + GithubAttestation.verify_subject({:artifact, tmp}, attested_repo(), config, + bundle_count: 1 + ) + + assert verification.verified + assert GithubAttestation.github_actions_builder?(verification.builder_id) + + statement = verification.details[:statement] + assert is_map(statement) + + {:ok, slsa} = + Opsm.Slsa.Provenance.verify_github_attestation(statement, + package: %{tarball_url: nil}, + bundle_verified: verification.verified + ) + + assert slsa.builder_trusted + assert slsa.slsa_level >= 2 + assert slsa.verified + after + File.rm(tmp) + end + end +end diff --git a/opsm_ex/test/opsm/slsa/github_attestation_test.exs b/opsm_ex/test/opsm/slsa/github_attestation_test.exs new file mode 100644 index 00000000..857099ed --- /dev/null +++ b/opsm_ex/test/opsm/slsa/github_attestation_test.exs @@ -0,0 +1,258 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) Jonathan D.A. Jewell +defmodule Opsm.Slsa.GithubAttestationTest do + use ExUnit.Case, async: true + + alias Opsm.Slsa.GithubAttestation + alias Opsm.Slsa.Provenance + alias Opsm.Types.GithubAttestationVerification + + @gh_builder "https://github.com/hyperpolymath/proven/.github/workflows/release.yml@refs/heads/main" + + defp statement(builder_id) do + %{ + "_type" => "https://in-toto.io/Statement/v1", + "predicateType" => "https://slsa.dev/provenance/v1", + "subject" => [ + %{"name" => "proven", "digest" => %{"sha256" => String.duplicate("ab", 32)}} + ], + "predicate" => %{ + "buildDefinition" => %{ + "buildType" => "https://actions.github.io/buildtypes/workflow/v1", + "externalParameters" => %{}, + "resolvedDependencies" => [ + %{ + "uri" => "git+https://github.com/hyperpolymath/proven@refs/heads/main", + "digest" => %{"gitCommit" => String.duplicate("c", 40)} + } + ] + }, + "runDetails" => %{"builder" => %{"id" => builder_id}, "metadata" => %{}} + } + } + end + + defp bundle(statement) do + %{ + "mediaType" => "application/vnd.dev.sigstore.bundle.v0.3+json", + "verificationMaterial" => %{"certificate" => %{"rawBytes" => "..."}}, + "dsseEnvelope" => %{ + "payloadType" => "application/vnd.in-toto+json", + "payload" => Base.encode64(Jason.encode!(statement)) + } + } + end + + describe "github_actions_builder?/1" do + test "matches the GitHub Actions workflow builder identity" do + assert GithubAttestation.github_actions_builder?(@gh_builder) + + assert GithubAttestation.github_actions_builder?( + "https://github.com/o/r/.github/workflows/w.yml@refs/tags/v1.0.0" + ) + end + + test "rejects non-builder identities" do + refute GithubAttestation.github_actions_builder?("https://github.com/o/r") + refute GithubAttestation.github_actions_builder?("https://evil.com/o/r/.github/workflows/w@x") + refute GithubAttestation.github_actions_builder?("https://github.com/o/r/.github/workflows/w.yml") + refute GithubAttestation.github_actions_builder?("https://opsm.dev/builders/elixir-mix") + refute GithubAttestation.github_actions_builder?(nil) + refute GithubAttestation.github_actions_builder?(42) + end + end + + describe "github_owner_repo/1" do + test "extracts owner/repo from GitHub repository URLs" do + for url <- [ + "https://github.com/hyperpolymath/proven", + "https://github.com/hyperpolymath/proven.git", + "https://github.com/hyperpolymath/proven/", + "http://github.com/hyperpolymath/proven" + ] do + assert {:ok, "hyperpolymath/proven"} = + GithubAttestation.github_owner_repo(%{manifest: %{repository: url}}) + end + end + + test "returns :none for non-GitHub or missing repositories" do + assert {:none, _} = + GithubAttestation.github_owner_repo(%{manifest: %{repository: "https://gitlab.com/a/b"}}) + + assert {:none, _} = + GithubAttestation.github_owner_repo(%{manifest: %{repository: nil}}) + + assert {:none, _} = GithubAttestation.github_owner_repo(%{manifest: %{}}) + end + end + + describe "artifact_digest/1" do + test "normalizes a sha256 checksum" do + hex = String.duplicate("AB", 32) + + assert {:ok, "sha256:" <> normalized} = + GithubAttestation.artifact_digest(%{checksum: hex, checksum_algo: :sha256}) + + assert normalized == String.downcase(hex) + end + + test "returns :none for missing or non-sha256 checksums" do + assert {:none, _} = GithubAttestation.artifact_digest(%{checksum: nil, checksum_algo: :sha256}) + assert {:none, _} = GithubAttestation.artifact_digest(%{checksum: "abc", checksum_algo: :sha512}) + assert {:none, _} = GithubAttestation.artifact_digest(%{checksum: "not-hex", checksum_algo: :sha256}) + end + end + + describe "fetch_attestations/3 input validation" do + test "rejects malformed owner/repo without touching the network" do + assert {:error, _} = GithubAttestation.fetch_attestations("owner repo; rm", "sha256:" <> String.duplicate("a", 64)) + assert {:error, _} = GithubAttestation.fetch_attestations("noslash", "sha256:" <> String.duplicate("a", 64)) + end + + test "rejects malformed digests without touching the network" do + assert {:error, _} = GithubAttestation.fetch_attestations("o/r", "sha256:short") + assert {:error, _} = GithubAttestation.fetch_attestations("o/r", "md5:" <> String.duplicate("a", 64)) + end + end + + describe "extract_statement/1" do + test "decodes the in-toto statement from a DSSE envelope" do + s = statement(@gh_builder) + assert {:ok, decoded} = GithubAttestation.extract_statement(bundle(s)) + assert decoded == s + end + + test "rejects wrong payload types, bad base64, and missing envelopes" do + s = statement(@gh_builder) + wrong_type = put_in(bundle(s), ["dsseEnvelope", "payloadType"], "application/json") + assert {:error, _} = GithubAttestation.extract_statement(wrong_type) + + bad_b64 = put_in(bundle(s), ["dsseEnvelope", "payload"], "!!not-base64!!") + assert {:error, _} = GithubAttestation.extract_statement(bad_b64) + + assert {:error, _} = GithubAttestation.extract_statement(%{"no" => "envelope"}) + end + end + + describe "decode_gh_output/1" do + test "extracts builder identity from gh verify JSON" do + gh_output = [ + %{ + "verificationResult" => %{ + "statement" => statement(@gh_builder) + } + } + ] + + assert %GithubAttestationVerification{ + verified: true, + builder_id: @gh_builder, + predicate_type: "https://slsa.dev/provenance/v1" + } = GithubAttestation.decode_gh_output(gh_output) + end + + test "empty output is not a verification" do + assert %GithubAttestationVerification{verified: false} = GithubAttestation.decode_gh_output([]) + assert %GithubAttestationVerification{verified: false} = GithubAttestation.decode_gh_output(%{}) + end + end + + describe "Provenance.verify_github_attestation/2 — verification-gated builder trust" do + test "GitHub Actions builder is trusted when the bundle verified" do + package = %{tarball_url: nil} + + assert {:ok, result} = + Provenance.verify_github_attestation(statement(@gh_builder), + package: package, + bundle_verified: true + ) + + assert result.builder_trusted + assert result.materials_match + # Unsigned in-toto statement (signature lives in the Sigstore bundle, + # already checked) → level 2 via trusted builder + materials. + assert result.slsa_level == 2 + assert result.verified + end + + test "the builder string alone confers no trust" do + package = %{tarball_url: nil} + + assert {:ok, result} = + Provenance.verify_github_attestation(statement(@gh_builder), + package: package, + bundle_verified: false + ) + + refute result.builder_trusted + assert result.slsa_level == 1 + assert Enum.any?(result.warnings, &String.contains?(&1, "not in trusted list")) + end + + test "a non-GitHub builder is not trusted even with a verified bundle" do + package = %{tarball_url: nil} + + assert {:ok, result} = + Provenance.verify_github_attestation(statement("https://evil.example/builder"), + package: package, + bundle_verified: true + ) + + refute result.builder_trusted + end + + test "exact-match trusted builders keep working without the flag" do + package = %{tarball_url: nil} + + assert {:ok, result} = + Provenance.verify_github_attestation( + statement("https://github.com/slsa-framework/slsa-github-generator"), + package: package, + bundle_verified: false + ) + + assert result.builder_trusted + end + end + + describe "trust pipeline integration (no network)" do + test "package without a GitHub repository skips the check" do + package = %Opsm.Types.ResolvedPackage{ + package: "local-pkg", + version: "1.0.0", + forth: :elixir, + registry_url: "https://example.invalid", + tarball_url: nil, + checksum: nil, + manifest: %{repository: nil, license: "MIT", dependencies: %{}} + } + + {:ok, results} = + Opsm.Trust.Pipeline.verify(package, + skip_checks: [:attestation, :license, :sustainability, :slsa] + ) + + assert {:skipped, _msg} = results.checks[:github_attestation] + end + + test "package with GitHub repo but no sha256 checksum skips before any lookup" do + package = %Opsm.Types.ResolvedPackage{ + package: "proven", + version: "1.0.0", + forth: :elixir, + registry_url: "https://example.invalid", + tarball_url: nil, + checksum: nil, + manifest: %{repository: "https://github.com/hyperpolymath/proven", license: "MPL-2.0", dependencies: %{}} + } + + {:ok, results} = + Opsm.Trust.Pipeline.verify(package, + skip_checks: [:attestation, :license, :sustainability, :slsa] + ) + + assert {:skipped, msg} = results.checks[:github_attestation] + assert msg =~ "sha256" + end + end +end diff --git a/services/checky-monkey/src/main.rs b/services/checky-monkey/src/main.rs index 727d12ef..17e632b5 100644 --- a/services/checky-monkey/src/main.rs +++ b/services/checky-monkey/src/main.rs @@ -110,6 +110,27 @@ struct HealthResponse { queue_length: usize, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct GithubAttestationRequest { + /// "owner/repo" — passed to `gh attestation verify --repo` + owner_repo: String, + /// Verification subject: an oci:// image URI ... + oci_uri: Option, + /// ... or a filesystem path readable by this service. + artifact_path: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct GithubAttestationResponse { + verified: bool, + builder_id: Option, + predicate_type: Option, + message: String, + details: Option, +} + // ============================================================================ // Handlers // ============================================================================ @@ -273,6 +294,160 @@ async fn list_verification_types() -> Json> { ]) } +/// Verify a GitHub native build-provenance attestation (Sigstore bundle) by +/// wrapping `gh attestation verify` (issue opsm#56). Synchronous: gh does the +/// fetch + Fulcio/Rekor verification and prints JSON we relay back. +async fn verify_github_attestation( + Json(request): Json, +) -> Result, (StatusCode, String)> { + if !valid_owner_repo(&request.owner_repo) { + return Err(( + StatusCode::UNPROCESSABLE_ENTITY, + format!("Invalid ownerRepo: {}", request.owner_repo), + )); + } + + let subject = match (&request.oci_uri, &request.artifact_path) { + (Some(oci), _) if oci.starts_with("oci://") => oci.clone(), + (Some(oci), _) => { + return Err(( + StatusCode::UNPROCESSABLE_ENTITY, + format!("ociUri must start with oci://, got: {}", oci), + )) + } + (None, Some(path)) => { + if !std::path::Path::new(path).is_file() { + return Err(( + StatusCode::UNPROCESSABLE_ENTITY, + format!("artifactPath does not exist: {}", path), + )); + } + path.clone() + } + (None, None) => { + return Err(( + StatusCode::UNPROCESSABLE_ENTITY, + "A subject is required: ociUri or artifactPath".to_string(), + )) + } + }; + + info!( + "GitHub attestation verify: {} against {}", + subject, request.owner_repo + ); + + let command = tokio::process::Command::new("gh") + .args([ + "attestation", + "verify", + &subject, + "--repo", + &request.owner_repo, + "--format", + "json", + ]) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output(); + + let output = match tokio::time::timeout(tokio::time::Duration::from_secs(120), command).await { + Err(_elapsed) => { + return Err(( + StatusCode::GATEWAY_TIMEOUT, + "gh attestation verify timed out".to_string(), + )) + } + Ok(Err(e)) if e.kind() == std::io::ErrorKind::NotFound => { + return Err(( + StatusCode::SERVICE_UNAVAILABLE, + "gh CLI not available on this service".to_string(), + )) + } + Ok(Err(e)) => { + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to run gh: {}", e), + )) + } + Ok(Ok(output)) => output, + }; + + if output.status.success() { + let parsed: serde_json::Value = serde_json::from_slice(&output.stdout) + .map_err(|e| { + ( + StatusCode::BAD_GATEWAY, + format!("gh produced unparseable JSON: {}", e), + ) + })?; + + let statement = extract_statement(&parsed); + let builder_id = statement + .and_then(|s| s.pointer("/predicate/runDetails/builder/id")) + .and_then(|v| v.as_str()) + .map(String::from); + let predicate_type = statement + .and_then(|s| s.get("predicateType")) + .and_then(|v| v.as_str()) + .map(String::from); + + Ok(Json(GithubAttestationResponse { + verified: true, + builder_id, + predicate_type, + message: "Verified via gh attestation verify".to_string(), + details: Some(parsed), + })) + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + let trimmed: String = stderr.trim().chars().take(300).collect(); + + // gh exits non-zero for both "attestation rejected" and operational + // errors (auth, network). Only report a definitive rejection as + // verified=false; surface operational errors as a gateway problem so + // the caller can fall back instead of recording a false negative. + let definitive = trimmed.contains("attestation") + && (trimmed.contains("verify") || trimmed.contains("not found") || trimmed.contains("failed")); + + if definitive { + Ok(Json(GithubAttestationResponse { + verified: false, + builder_id: None, + predicate_type: None, + message: format!("gh attestation verify rejected: {}", trimmed), + details: None, + })) + } else { + Err((StatusCode::BAD_GATEWAY, format!("gh error: {}", trimmed))) + } + } +} + +/// The verify JSON is a list of verification results; the in-toto statement +/// lives at .verificationResult.statement (or .statement in older shapes). +fn extract_statement(parsed: &serde_json::Value) -> Option<&serde_json::Value> { + parsed.as_array()?.iter().find_map(|entry| { + entry + .pointer("/verificationResult/statement") + .or_else(|| entry.get("statement")) + .filter(|v| v.is_object()) + }) +} + +/// "owner/repo" with both segments limited to GitHub-legal name characters — +/// also guarantees the value is inert as a CLI argument. +fn valid_owner_repo(owner_repo: &str) -> bool { + let parts: Vec<&str> = owner_repo.split('/').collect(); + parts.len() == 2 + && parts.iter().all(|part| { + !part.is_empty() + && part + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.') + }) +} + // ============================================================================ // Background verification runner // ============================================================================ @@ -392,6 +567,7 @@ async fn main() -> anyhow::Result<()> { let app = Router::new() .route("/health", get(health)) .route("/verify", post(submit_verification)) + .route("/verify/github-attestation", post(verify_github_attestation)) .route("/verify/status/{sha}", get(get_verification_status)) .route("/verify/{request_id}", get(get_verification_detail)) .route("/verify/{request_id}/cancel", post(cancel_verification)) @@ -409,3 +585,49 @@ async fn main() -> anyhow::Result<()> { Ok(()) } + +// ============================================================================ +// Tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn owner_repo_validation() { + assert!(valid_owner_repo("hyperpolymath/proven")); + assert!(valid_owner_repo("a-b/c.d_e")); + assert!(!valid_owner_repo("noslash")); + assert!(!valid_owner_repo("too/many/parts")); + assert!(!valid_owner_repo("owner/")); + assert!(!valid_owner_repo("owner/repo; rm -rf /")); + assert!(!valid_owner_repo("owner/repo --flag")); + } + + #[test] + fn statement_extraction_modern_shape() { + let parsed = serde_json::json!([{ + "verificationResult": { + "statement": { + "predicateType": "https://slsa.dev/provenance/v1", + "predicate": {"runDetails": {"builder": {"id": + "https://github.com/hyperpolymath/proven/.github/workflows/release.yml@refs/heads/main"}}} + } + } + }]); + let statement = extract_statement(&parsed).expect("statement"); + assert_eq!( + statement.pointer("/predicate/runDetails/builder/id").and_then(|v| v.as_str()), + Some("https://github.com/hyperpolymath/proven/.github/workflows/release.yml@refs/heads/main") + ); + } + + #[test] + fn statement_extraction_flat_shape_and_miss() { + let flat = serde_json::json!([{"statement": {"predicateType": "https://slsa.dev/provenance/v1"}}]); + assert!(extract_statement(&flat).is_some()); + assert!(extract_statement(&serde_json::json!([])).is_none()); + assert!(extract_statement(&serde_json::json!({"not": "an array"})).is_none()); + } +} From a247d285ace5907116850d9a6dccfec39320a7cb Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Thu, 2 Jul 2026 05:05:09 +0100 Subject: [PATCH 2/2] fix(slsa): harden attestation verification per adversarial review Fixes from the multi-lens review of the feature diff: - Local gh fallback: capture stderr (gh writes errors there; without it every operational failure classified as empty-message rejection) and carve the JSON payload out of the merged stream on success (gh also writes "Loaded digest..." diagnostics to stderr). Conservative failure classification: only clear verification failures are definitive rejections; everything else degrades fail-open to unavailable. Same classifier mirrored in the Rust service. - Subject-digest binding: GitHub-native provenance binds the artifact via statement.subject, not materials; verify_github_attestation/2 now compares subject sha256 digests to the package checksum and that comparison governs the materials check (mismatch warns and fails the binding even with a verified bundle). - Trust pipeline: pass timeout: 2_500 so the attestation lookup fits the 3s advisory yield_many budget instead of always timing out. - verisimdb.ex: VeriSimClient.Vql -> .Vcl (module renamed upstream; the re-locked dep otherwise adds a compile warning). - Rust service: SSRF guard - oci:// subjects restricted to allowlisted registries (default ghcr.io, CHECKY_MONKEY_OCI_REGISTRIES override); artifactPath must be absolute and traversal-free; kill_on_drop reaps the gh child if the 120s timeout fires; details."statement" mirrors the Elixir backend shape so callers read one key from either verifier. Re-verified live against the attested cli/cli artifact after all fixes: fetch + gh verify + builder gate + subject binding all pass (SLSA L2, builder trusted, materials bound via checksum). Co-Authored-By: Claude Fable 5 --- opsm_ex/lib/opsm/slsa/github_attestation.ex | 58 +++++++++---- opsm_ex/lib/opsm/slsa/provenance.ex | 57 +++++++++---- opsm_ex/lib/opsm/trust/pipeline.ex | 4 +- opsm_ex/lib/opsm/verisimdb.ex | 2 +- .../live/github_attestation_live_test.exs | 2 +- .../opsm/slsa/github_attestation_test.exs | 28 +++++++ services/checky-monkey/src/main.rs | 83 +++++++++++++++++-- 7 files changed, 196 insertions(+), 38 deletions(-) diff --git a/opsm_ex/lib/opsm/slsa/github_attestation.ex b/opsm_ex/lib/opsm/slsa/github_attestation.ex index a98d59f6..a1b192ef 100644 --- a/opsm_ex/lib/opsm/slsa/github_attestation.ex +++ b/opsm_ex/lib/opsm/slsa/github_attestation.ex @@ -211,27 +211,56 @@ defmodule Opsm.Slsa.GithubAttestation do args = ["attestation", "verify", positional, "--repo", owner_repo, "--format", "json"] allowlist = Keyword.get(opts, :exec_allowlist, ["gh"]) - case SafeExec.cmd("gh", args, allowlist: allowlist) do + # stderr_to_stdout: gh writes errors to stderr; without it every failure + # arrives as an empty string and cannot be classified. On success gh also + # writes diagnostics ("Loaded digest ...") to stderr, so the JSON must be + # carved out of the merged stream before parsing. + case SafeExec.cmd("gh", args, allowlist: allowlist, stderr_to_stdout: true) do {output, 0} -> - case Json.decode(output) do + case Json.decode(json_portion(output)) do {:ok, results} -> {:ok, decode_gh_output(results)} {:error, _} -> {:error, "gh produced unparseable output"} end {output, _nonzero} -> message = output |> to_string() |> String.trim() |> String.slice(0, 300) + classify_gh_failure(message) + end + end - if String.contains?(message, "safe-exec blocked") or - String.contains?(message, "not found") do - {:error, "gh CLI unavailable: #{message}"} - else - # gh ran and rejected the attestation — that is a definitive failure - {:ok, - %GithubAttestationVerification{ - verified: false, - message: "gh attestation verify failed: #{message}" - }} - end + # gh interleaves stderr diagnostics with the stdout JSON when merged; + # the JSON payload starts at the first line beginning with [ or {. + defp json_portion(output) do + output + |> String.split("\n") + |> Enum.drop_while(fn line -> + trimmed = String.trim_leading(line) + not (String.starts_with?(trimmed, "[") or String.starts_with?(trimmed, "{")) + end) + |> Enum.join("\n") + end + + # gh exits non-zero for both cryptographic rejections and operational + # errors (auth, network). Only a clear verification failure is reported + # as a definitive rejection; everything else is "verifier unavailable" + # so the caller degrades fail-open instead of raising a false alarm. + defp classify_gh_failure(message) do + down = String.downcase(message) + + cond do + String.contains?(down, "safe-exec blocked") -> + {:error, "gh CLI unavailable: #{message}"} + + String.contains?(down, "verif") and + (String.contains?(down, "fail") or String.contains?(down, "none of the")) -> + {:ok, + %GithubAttestationVerification{ + verified: false, + message: "gh attestation verify rejected: #{message}" + }} + + true -> + {:error, "gh error: #{message}"} end end @@ -256,7 +285,8 @@ defmodule Opsm.Slsa.GithubAttestation do builder_id: builder_id, predicate_type: statement && statement["predicateType"], message: "Verified via gh attestation verify (#{length(results)} attestation(s))", - details: %{statement: statement} + # string key — matches the checky-monkey backend's details shape + details: %{"statement" => statement} } end diff --git a/opsm_ex/lib/opsm/slsa/provenance.ex b/opsm_ex/lib/opsm/slsa/provenance.ex index f18f2f71..53c24f96 100644 --- a/opsm_ex/lib/opsm/slsa/provenance.ex +++ b/opsm_ex/lib/opsm/slsa/provenance.ex @@ -134,7 +134,7 @@ defmodule Opsm.Slsa.Provenance do {builder_trusted, result} = check_builder(provenance, result, opts) # Check materials consistency - {materials_match, result} = check_materials(provenance, package, result) + {materials_match, result} = check_materials(provenance, package, result, opts) # Check signature {sig_valid, result} = if provenance.signature && public_key do @@ -239,10 +239,27 @@ defmodule Opsm.Slsa.Provenance do bundle_verified = Keyword.get(opts, :bundle_verified, false) with {:ok, provenance} <- from_envelope(statement) do - verify(provenance, package, github_builder_verified: bundle_verified) + verify(provenance, package, + github_builder_verified: bundle_verified, + subject_match: subject_matches_package?(statement, package) + ) end end + # true/false when the statement subject and package checksum are both + # comparable sha256 digests; nil (fall back to materials) otherwise. + defp subject_matches_package?(%{"subject" => subjects}, %{checksum: checksum}) + when is_list(subjects) and subjects != [] and is_binary(checksum) do + hex = checksum |> String.replace_prefix("sha256:", "") |> String.downcase() + + Enum.any?(subjects, fn + %{"digest" => %{"sha256" => subject_hex}} -> String.downcase(subject_hex) == hex + _ -> false + end) + end + + defp subject_matches_package?(_statement, _package), do: nil + # ========================================================================== # Private # ========================================================================== @@ -332,19 +349,31 @@ defmodule Opsm.Slsa.Provenance do end end - defp check_materials(provenance, package, result) do - # Check if package tarball appears in materials - has_tarball = Enum.any?(provenance.materials, fn - %BuildMaterial{uri: uri} -> uri == package.tarball_url - %{uri: uri} -> uri == package.tarball_url - %{"uri" => uri} -> uri == package.tarball_url - _ -> false - end) + defp check_materials(provenance, package, result, opts) do + # GitHub-native provenance binds the artifact via the statement subject + # (digest), not via materials — when the caller already compared the + # subject to the package checksum, that comparison governs. + case Keyword.get(opts, :subject_match) do + true -> + {true, result} - if has_tarball or is_nil(package.tarball_url) do - {true, result} - else - {false, add_warning(result, "Package tarball not found in provenance materials")} + false -> + {false, add_warning(result, "Statement subject digest does not match package checksum")} + + nil -> + # Check if package tarball appears in materials + has_tarball = Enum.any?(provenance.materials, fn + %BuildMaterial{uri: uri} -> uri == package.tarball_url + %{uri: uri} -> uri == package.tarball_url + %{"uri" => uri} -> uri == package.tarball_url + _ -> false + end) + + if has_tarball or is_nil(package.tarball_url) do + {true, result} + else + {false, add_warning(result, "Package tarball not found in provenance materials")} + end end end diff --git a/opsm_ex/lib/opsm/trust/pipeline.ex b/opsm_ex/lib/opsm/trust/pipeline.ex index 4debd4e3..c885eaab 100644 --- a/opsm_ex/lib/opsm/trust/pipeline.ex +++ b/opsm_ex/lib/opsm/trust/pipeline.ex @@ -223,7 +223,9 @@ defmodule Opsm.Trust.Pipeline do # only a cryptographically REJECTED attestation is an error. defp check_github_attestation(package, config) do try do - case Opsm.Slsa.GithubAttestation.verify_package(package, config) do + # timeout must fit the pipeline's 3s advisory yield_many budget — + # a slower lookup degrades to the timed-out-check path, not a stall + case Opsm.Slsa.GithubAttestation.verify_package(package, config, timeout: 2_500) do {:ok, %{verified: true, builder_id: builder_id}} -> if Opsm.Slsa.GithubAttestation.github_actions_builder?(builder_id) do {:ok, "GitHub build provenance verified (builder: #{builder_id})"} diff --git a/opsm_ex/lib/opsm/verisimdb.ex b/opsm_ex/lib/opsm/verisimdb.ex index 3327dbda..46496e21 100644 --- a/opsm_ex/lib/opsm/verisimdb.ex +++ b/opsm_ex/lib/opsm/verisimdb.ex @@ -165,7 +165,7 @@ defmodule Opsm.VeriSimDB do end def handle_call({:query, vql_query}, _from, %{client: client} = state) do - case VeriSimClient.Vql.execute(client, vql_query) do + case VeriSimClient.Vcl.execute(client, vql_query) do {:ok, result} -> {:reply, {:ok, result}, state} diff --git a/opsm_ex/test/opsm/live/github_attestation_live_test.exs b/opsm_ex/test/opsm/live/github_attestation_live_test.exs index f7eb9156..963023a7 100644 --- a/opsm_ex/test/opsm/live/github_attestation_live_test.exs +++ b/opsm_ex/test/opsm/live/github_attestation_live_test.exs @@ -58,7 +58,7 @@ defmodule Opsm.Live.GithubAttestationLiveTest do assert verification.verified assert GithubAttestation.github_actions_builder?(verification.builder_id) - statement = verification.details[:statement] + statement = verification.details["statement"] assert is_map(statement) {:ok, slsa} = diff --git a/opsm_ex/test/opsm/slsa/github_attestation_test.exs b/opsm_ex/test/opsm/slsa/github_attestation_test.exs index 857099ed..47c95018 100644 --- a/opsm_ex/test/opsm/slsa/github_attestation_test.exs +++ b/opsm_ex/test/opsm/slsa/github_attestation_test.exs @@ -213,6 +213,34 @@ defmodule Opsm.Slsa.GithubAttestationTest do assert result.builder_trusted end + + test "statement subject digest binds the artifact when it matches the package checksum" do + # subject digest in statement/1 is "abab...": tarball_url would fail the + # materials check, but the matching subject digest governs instead + package = %{tarball_url: "https://example.com/not-in-materials.tgz", checksum: String.duplicate("ab", 32)} + + assert {:ok, result} = + Provenance.verify_github_attestation(statement(@gh_builder), + package: package, + bundle_verified: true + ) + + assert result.materials_match + assert result.slsa_level == 2 + end + + test "a mismatched subject digest fails the binding even with a verified bundle" do + package = %{tarball_url: nil, checksum: String.duplicate("ff", 32)} + + assert {:ok, result} = + Provenance.verify_github_attestation(statement(@gh_builder), + package: package, + bundle_verified: true + ) + + refute result.materials_match + assert Enum.any?(result.warnings, &String.contains?(&1, "subject digest does not match")) + end end describe "trust pipeline integration (no network)" do diff --git a/services/checky-monkey/src/main.rs b/services/checky-monkey/src/main.rs index 17e632b5..a25e8484 100644 --- a/services/checky-monkey/src/main.rs +++ b/services/checky-monkey/src/main.rs @@ -308,7 +308,21 @@ async fn verify_github_attestation( } let subject = match (&request.oci_uri, &request.artifact_path) { - (Some(oci), _) if oci.starts_with("oci://") => oci.clone(), + (Some(oci), _) if oci.starts_with("oci://") => { + // SSRF guard: gh will connect to whatever registry host the URI + // names, so only allowlisted registries may be verified. + if !oci_registry_allowed(oci) { + return Err(( + StatusCode::UNPROCESSABLE_ENTITY, + format!( + "ociUri registry not in allowlist ({}): {}", + oci_registry_allowlist().join(","), + oci + ), + )); + } + oci.clone() + } (Some(oci), _) => { return Err(( StatusCode::UNPROCESSABLE_ENTITY, @@ -316,6 +330,14 @@ async fn verify_github_attestation( )) } (None, Some(path)) => { + // absolute, traversal-free paths only — this endpoint must not + // become a relative-path probe of the service working directory + if !path.starts_with('/') || path.contains("..") { + return Err(( + StatusCode::UNPROCESSABLE_ENTITY, + "artifactPath must be an absolute path without ..".to_string(), + )); + } if !std::path::Path::new(path).is_file() { return Err(( StatusCode::UNPROCESSABLE_ENTITY, @@ -337,7 +359,8 @@ async fn verify_github_attestation( subject, request.owner_repo ); - let command = tokio::process::Command::new("gh") + let mut command = tokio::process::Command::new("gh"); + command .args([ "attestation", "verify", @@ -349,9 +372,10 @@ async fn verify_github_attestation( ]) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) - .output(); + // reap the child if the timeout below drops the future + .kill_on_drop(true); - let output = match tokio::time::timeout(tokio::time::Duration::from_secs(120), command).await { + let output = match tokio::time::timeout(tokio::time::Duration::from_secs(120), command.output()).await { Err(_elapsed) => { return Err(( StatusCode::GATEWAY_TIMEOUT, @@ -392,12 +416,19 @@ async fn verify_github_attestation( .and_then(|v| v.as_str()) .map(String::from); + // "statement" mirrors the Elixir local-gh backend's details shape so + // callers read one key regardless of which verifier answered + let details = serde_json::json!({ + "statement": statement.cloned().unwrap_or(serde_json::Value::Null), + "raw": parsed, + }); + Ok(Json(GithubAttestationResponse { verified: true, builder_id, predicate_type, message: "Verified via gh attestation verify".to_string(), - details: Some(parsed), + details: Some(details), })) } else { let stderr = String::from_utf8_lossy(&output.stderr); @@ -407,8 +438,9 @@ async fn verify_github_attestation( // errors (auth, network). Only report a definitive rejection as // verified=false; surface operational errors as a gateway problem so // the caller can fall back instead of recording a false negative. - let definitive = trimmed.contains("attestation") - && (trimmed.contains("verify") || trimmed.contains("not found") || trimmed.contains("failed")); + let lower = trimmed.to_lowercase(); + let definitive = + lower.contains("verif") && (lower.contains("fail") || lower.contains("none of the")); if definitive { Ok(Json(GithubAttestationResponse { @@ -435,6 +467,33 @@ fn extract_statement(parsed: &serde_json::Value) -> Option<&serde_json::Value> { }) } +/// Registries the service will let gh connect to for oci:// subjects. +/// Comma-separated override via CHECKY_MONKEY_OCI_REGISTRIES. +fn oci_registry_allowlist() -> Vec { + std::env::var("CHECKY_MONKEY_OCI_REGISTRIES") + .unwrap_or_else(|_| "ghcr.io".to_string()) + .split(',') + .map(|s| s.trim().to_lowercase()) + .filter(|s| !s.is_empty()) + .collect() +} + +fn oci_registry_allowed(oci_uri: &str) -> bool { + let rest = match oci_uri.strip_prefix("oci://") { + Some(rest) => rest, + None => return false, + }; + let host = rest + .split(['/', '@']) + .next() + .unwrap_or("") + .split(':') + .next() + .unwrap_or("") + .to_lowercase(); + !host.is_empty() && oci_registry_allowlist().iter().any(|allowed| allowed == &host) +} + /// "owner/repo" with both segments limited to GitHub-legal name characters — /// also guarantees the value is inert as a CLI argument. fn valid_owner_repo(owner_repo: &str) -> bool { @@ -630,4 +689,14 @@ mod tests { assert!(extract_statement(&serde_json::json!([])).is_none()); assert!(extract_statement(&serde_json::json!({"not": "an array"})).is_none()); } + + #[test] + fn oci_registry_guard() { + assert!(oci_registry_allowed("oci://ghcr.io/hyperpolymath/proven:latest")); + assert!(oci_registry_allowed("oci://GHCR.IO/x/y@sha256:abc")); + assert!(!oci_registry_allowed("oci://evil.example.com/x/y")); + assert!(!oci_registry_allowed("oci://ghcr.io.evil.com/x")); + assert!(!oci_registry_allowed("https://ghcr.io/x/y")); + assert!(!oci_registry_allowed("oci://")); + } }