Skip to content

Commit 1d9915c

Browse files
fix(slsa): close the rejection fail-open — match gh's real rejection signatures (#65)
Follow-up to #62, from the adversarial review's **confirmed** high finding (empirically reproduced by the verifier agent against cli/cli's attested artifact with gh 2.92.0): Genuine cryptographic rejections — certificate-identity mismatch, subject-digest mismatch, signer-repo mismatch — all exit 1 with stderr exactly `Error: verifying with issuer "sigstore.dev"`, which the shipped `verif`+`fail` heuristic classified as *operational*. Result: a rejected/forged artifact degraded to an advisory note instead of a hard `{:error}` — the `verified: false` branch was effectively dead code on current gh. **Fix**: both classifiers (Elixir local-gh fallback and checky-monkey's Rust endpoint) now match the empirically observed rejection signatures (`verifying with issuer`, `no attestations found`) ahead of the generic heuristic, with comments pinning them to the gh version for future upgrades. Trust is still only ever *granted* on gh exit 0. Additionally, "attestations exist but no verifier could check them" now surfaces as a pipeline **warning** rather than info — the package claims provenance nothing could confirm; still non-blocking per the advisory doctrine. 5 new signature tests (rejections definitive; operational/auth/empty degrade fail-open; blocked CLI is unavailable). 23/23 Elixir + 4/4 Rust tests green. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 7706b09 commit 1d9915c

4 files changed

Lines changed: 55 additions & 6 deletions

File tree

opsm_ex/lib/opsm/slsa/github_attestation.ex

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -244,15 +244,24 @@ defmodule Opsm.Slsa.GithubAttestation do
244244
# errors (auth, network). Only a clear verification failure is reported
245245
# as a definitive rejection; everything else is "verifier unavailable"
246246
# so the caller degrades fail-open instead of raising a false alarm.
247-
defp classify_gh_failure(message) do
247+
#
248+
# Empirical rejection signatures on gh 2.92.0 (cert-identity mismatch,
249+
# digest mismatch, signer-repo mismatch all print the first form):
250+
# Error: verifying with issuer "sigstore.dev"
251+
# Error: no attestations found ...
252+
# These are gh-version-coupled; revisit on gh upgrades (issue #56 review).
253+
@doc false
254+
def classify_gh_failure(message) do
248255
down = String.downcase(message)
249256

250257
cond do
251258
String.contains?(down, "safe-exec blocked") ->
252259
{:error, "gh CLI unavailable: #{message}"}
253260

254-
String.contains?(down, "verif") and
255-
(String.contains?(down, "fail") or String.contains?(down, "none of the")) ->
261+
String.contains?(down, "verifying with issuer") or
262+
String.contains?(down, "no attestations found") or
263+
(String.contains?(down, "verif") and
264+
(String.contains?(down, "fail") or String.contains?(down, "none of the"))) ->
256265
{:ok,
257266
%GithubAttestationVerification{
258267
verified: false,

opsm_ex/lib/opsm/trust/pipeline.ex

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -236,8 +236,11 @@ defmodule Opsm.Trust.Pipeline do
236236
{:ok, %{verified: false, message: message}} ->
237237
{:error, "GitHub attestation failed verification: #{message}"}
238238

239+
# Attestations exist but none of the verifiers could check them —
240+
# louder than info (the package claims provenance we cannot confirm),
241+
# still non-blocking.
239242
{:unverified, message} ->
240-
{:info, message}
243+
{:warning, message}
241244

242245
{:none, message} ->
243246
{:skipped, message}

opsm_ex/test/opsm/slsa/github_attestation_test.exs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,36 @@ defmodule Opsm.Slsa.GithubAttestationTest do
134134
end
135135
end
136136

137+
describe "classify_gh_failure/1 — empirical gh 2.92.0 signatures" do
138+
test "rejection signatures are definitive verified: false" do
139+
# cert-identity / digest / signer-repo mismatches all print this
140+
assert {:ok, %GithubAttestationVerification{verified: false}} =
141+
GithubAttestation.classify_gh_failure(~s(Error: verifying with issuer "sigstore.dev"))
142+
143+
assert {:ok, %GithubAttestationVerification{verified: false}} =
144+
GithubAttestation.classify_gh_failure("Error: no attestations found")
145+
146+
assert {:ok, %GithubAttestationVerification{verified: false}} =
147+
GithubAttestation.classify_gh_failure("X verification failed: subject mismatch")
148+
end
149+
150+
test "operational errors degrade fail-open, never as rejection" do
151+
for msg <- [
152+
"Error: the provided token was denied access to the requested resource",
153+
"Error: HTTP 503: Service Unavailable",
154+
"connection refused",
155+
""
156+
] do
157+
assert {:error, _} = GithubAttestation.classify_gh_failure(msg)
158+
end
159+
end
160+
161+
test "a blocked or missing gh CLI is unavailable" do
162+
assert {:error, "gh CLI unavailable:" <> _} =
163+
GithubAttestation.classify_gh_failure("safe-exec blocked: command not found: gh")
164+
end
165+
end
166+
137167
describe "decode_gh_output/1" do
138168
test "extracts builder identity from gh verify JSON" do
139169
gh_output = [

services/checky-monkey/src/main.rs

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -438,9 +438,16 @@ async fn verify_github_attestation(
438438
// errors (auth, network). Only report a definitive rejection as
439439
// verified=false; surface operational errors as a gateway problem so
440440
// the caller can fall back instead of recording a false negative.
441+
// Empirical rejection signatures on gh 2.92.0: cert-identity,
442+
// digest and signer-repo mismatches all print
443+
// Error: verifying with issuer "sigstore.dev"
444+
// and missing subjects print "no attestations found". Version-coupled;
445+
// revisit on gh upgrades.
441446
let lower = trimmed.to_lowercase();
442-
let definitive =
443-
lower.contains("verif") && (lower.contains("fail") || lower.contains("none of the"));
447+
let definitive = lower.contains("verifying with issuer")
448+
|| lower.contains("no attestations found")
449+
|| (lower.contains("verif")
450+
&& (lower.contains("fail") || lower.contains("none of the")));
444451

445452
if definitive {
446453
Ok(Json(GithubAttestationResponse {

0 commit comments

Comments
 (0)