diff --git a/src/review/ledger-anchor-rekor.ts b/src/review/ledger-anchor-rekor.ts index 6e6ae5f648..afe3e863d4 100644 --- a/src/review/ledger-anchor-rekor.ts +++ b/src/review/ledger-anchor-rekor.ts @@ -7,10 +7,18 @@ import type { SignedLedgerAnchor } from "./ledger-anchor"; import { anchorSigningInput } from "./ledger-anchor"; import { recordLedgerAnchorAttempt } from "./ledger-anchor-persistence"; -/** Rekor shards annually and the research is explicit: do not hardcode a log URL. Configurable via env, - * with a fallback to the current shard as of when this was written -- an operator updates the env var at - * the next rotation rather than this needing a code change. */ -const DEFAULT_REKOR_SHARD_BASE_URL = "https://log2026-1.rekor.sigstore.dev"; +/** Rekor shards annually as `log-.rekor.sigstore.dev` and the research is explicit: do not + * hardcode a log URL. Configurable via env, so an operator updates a variable at the next rotation rather + * than waiting on a release. + * + * This default was previously `log2026-1`, a shard Sigstore has not deployed -- so it did not resolve, and + * EVERY deployment that enabled anchoring without setting the env var recorded `fetch failed` forever and + * published no anchor at all (#9844, found on a live self-host instance). Guessing the next shard ahead of + * its deployment is worse than lagging behind it: a stale-but-real default still anchors, while a + * not-yet-existent one silently anchors nothing. + * + * So: only ever point this at a shard confirmed to be serving. When 2026-1 goes live, this moves then. */ +const DEFAULT_REKOR_SHARD_BASE_URL = "https://log2025-1.rekor.sigstore.dev"; /** The exact `hashedRekordRequestV002` body Rekor v2's `POST /api/v2/log/entries` accepts. Digest and * signature are both base64 per the API; `keyDetails` names the algorithm so Rekor can verify without @@ -155,16 +163,19 @@ export async function submitToRekor( proofR2Key: null, }); } catch (error) { - // Pass the raw caught value through, not a pre-stringified one -- #9271's persistence layer is the single - // place that normalizes an unknown error into text (Error instance vs. anything else), so this backend - // and every other one feed it the same undecided shape rather than each reimplementing that choice. + // The raw caught value still reaches the persistence layer, which stays the single place that normalizes + // an unknown error into text -- but it is WRAPPED so the recorded failure names the endpoint. Node's bare + // "fetch failed" cannot distinguish a shard hostname that does not resolve from blocked egress from a log + // that is down, and those have three different fixes. #9271 published these failures precisely so anyone + // can see anchoring is broken; a published failure that does not say what failed only half-delivers that. + // `cause` is preserved, so nothing a caller could previously inspect is lost. await recordLedgerAnchorAttempt(env, { payload: signed.payload, signature: signed.signature, keyId: signed.keyId, backend: "rekor", status: "failed", - error, + error: new Error(`Rekor submission to ${shardBaseUrl}/api/v2/log/entries failed: ${error instanceof Error ? error.message : String(error)}`, { cause: error }), }); } } diff --git a/test/unit/ledger-anchor-rekor.test.ts b/test/unit/ledger-anchor-rekor.test.ts index 449640c2dd..c41fc1cd3f 100644 --- a/test/unit/ledger-anchor-rekor.test.ts +++ b/test/unit/ledger-anchor-rekor.test.ts @@ -84,12 +84,44 @@ describe("submitToRekor (#9272)", () => { ); }); - it("uses the default shard URL when unconfigured", async () => { + it("uses the default shard URL when unconfigured -- pinned EXACTLY, because a shard that does not exist still matches a substring", async () => { + // #9844: this assertion used to be `stringContaining("rekor.sigstore.dev")`, which passed happily while + // the default pointed at log2026-1 -- a shard Sigstore never deployed. Every deployment that enabled + // anchoring without overriding the env var recorded `fetch failed` forever and published no anchor, and + // this test could not have caught it. The exact host is the thing under test, so it is asserted exactly. const env = createTestEnv(); const { signed, publicKeySpki } = await realSignedAnchor(); const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify(REKOR_RESPONSE), { status: 201 })); await submitToRekor(env, signed, publicKeySpki, fetchMock); - expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining("rekor.sigstore.dev"), expect.anything()); + expect(fetchMock).toHaveBeenCalledWith("https://log2025-1.rekor.sigstore.dev/api/v2/log/entries", expect.anything()); + }); + + it("REGRESSION (#9844): a thrown fetch error records WHICH endpoint failed, not a bare 'fetch failed'", async () => { + // Node's own message is "fetch failed" with no URL, so an operator cannot tell a shard hostname that does + // not resolve from blocked egress from a log that is down -- three different fixes. #9271 publishes these + // failures so anyone can see anchoring is broken; naming the endpoint is what makes that actionable. + const env = createTestEnv({ LOOPOVER_LEDGER_ANCHOR_REKOR_SHARD_URL: "https://log-does-not-exist.example" }); + const { signed, publicKeySpki } = await realSignedAnchor(); + const fetchMock = vi.fn().mockRejectedValue(new TypeError("fetch failed")); + + await expect(submitToRekor(env, signed, publicKeySpki, fetchMock)).resolves.toBeUndefined(); + + const { anchors } = await loadPublicLedgerAnchors(env); + expect(anchors[0]).toMatchObject({ backend: "rekor", status: "failed" }); + expect(anchors[0]!.error).toContain("https://log-does-not-exist.example/api/v2/log/entries"); + expect(anchors[0]!.error).toContain("fetch failed"); // the original cause is still legible + }); + + it("wraps a non-Error thrown value without losing it", async () => { + const env = createTestEnv(); + const { signed, publicKeySpki } = await realSignedAnchor(); + const fetchMock = vi.fn().mockRejectedValue("a bare string, not an Error"); + + await submitToRekor(env, signed, publicKeySpki, fetchMock); + + const { anchors } = await loadPublicLedgerAnchors(env); + expect(anchors[0]!.error).toContain("a bare string, not an Error"); + expect(anchors[0]!.error).toContain("log2025-1.rekor.sigstore.dev"); }); it("records status:'failed' on a non-2xx response, and does NOT throw", async () => { @@ -124,6 +156,10 @@ describe("submitToRekor (#9272)", () => { await expect(submitToRekor(env, signed, publicKeySpki, fetchMock)).resolves.toBeUndefined(); const { anchors } = await loadPublicLedgerAnchors(env); - expect(anchors[0]).toMatchObject({ status: "failed", error: "network down" }); + // #9844: the cause is still legible, now alongside the endpoint that was attempted -- "network down" on + // its own could not tell an operator which of the backends or which URL had the problem. + expect(anchors[0]).toMatchObject({ status: "failed" }); + expect(anchors[0]!.error).toContain("network down"); + expect(anchors[0]!.error).toContain("/api/v2/log/entries"); }); });