diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f8d5ed8 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,28 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + test: + name: Test composite actions + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - name: Harden Runner + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + with: + egress-policy: audit + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - name: Run action tests + # A bare directory arg worked on Node 20 but fails on Node 24 + # (MODULE_NOT_FOUND); runners now default to 24. Use an explicit glob. + run: node --test "actions/**/test/*.test.js" diff --git a/actions/probe-publish-token/action.yml b/actions/probe-publish-token/action.yml new file mode 100644 index 0000000..689065c --- /dev/null +++ b/actions/probe-publish-token/action.yml @@ -0,0 +1,79 @@ +name: Probe publish token +description: >- + Liveness probe for a marketplace publish token using the vendor CLI's own + verify-pat command. The token is passed only via environment — never argv — + and neither the token nor any response body is logged. + +inputs: + tool: + description: "'vsce' (VS Code Marketplace) or 'ovsx' (Open VSX)" + required: true + namespace: + description: "Publisher / namespace to verify against, e.g. nimbus-agent" + required: true + +outputs: + status: + description: "ok | dead | not-configured | indeterminate" + value: ${{ steps.probe.outputs.status }} + +runs: + using: composite + steps: + - id: probe + shell: bash + # PUBLISH_TOKEN is mapped by the caller from the appropriate secret. + # It is exported into the vendor CLI's expected env var and never echoed. + # Output is discarded wholesale: error messages from these CLIs can echo + # request context, and this runs in a public repository's logs. + # + # CLI versions are pinned EXACTLY, not by range: this hands a live publish + # credential to the resolved package, so `@latest` (or a `^` range, which + # still floats) would put a third-party release in the credential's trust + # boundary. Bumping these is a deliberate, reviewed change. + run: | + set +e + if [ -z "${PUBLISH_TOKEN}" ]; then + # An unset secret cannot be assessed as live or revoked: this branch + # never contacts the vendor CLI and never runs the reachability + # check below, so it has no evidence the token was rejected. + # `dead` is reserved for a confirmed rejection by a reachable + # service — reporting it here would be a false revocation alarm. + # Whether an unconfigured token is a hard failure is the caller's + # call; this probe's job is only to report the signal honestly. + echo "status=not-configured" >> "$GITHUB_OUTPUT" + echo "probe ${TOOL}: secret not configured (no token to check)" + exit 0 + fi + if [ "${TOOL}" = "vsce" ]; then + VSCE_PAT="${PUBLISH_TOKEN}" npx --yes @vscode/vsce@3.9.2 verify-pat "${NAMESPACE}" >/dev/null 2>&1 + code=$? + reach_url="https://marketplace.visualstudio.com/" + else + OVSX_PAT="${PUBLISH_TOKEN}" npx --yes ovsx@1.0.2 verify-pat "${NAMESPACE}" >/dev/null 2>&1 + code=$? + reach_url="https://open-vsx.org/api/-/search?size=1" + fi + + if [ $code -eq 0 ]; then + echo "status=ok" >> "$GITHUB_OUTPUT" + echo "probe ${TOOL} (${NAMESPACE}): ok" + exit 0 + fi + + # A non-zero exit alone does NOT mean the token is revoked — registry + # downtime, a timeout or rate-limiting look identical. Only call it dead + # if the service is actually reachable; otherwise report indeterminate + # so the caller warns instead of raising a false revocation alarm. + http="$(curl -sS -o /dev/null -w '%{http_code}' --max-time 20 "$reach_url" 2>/dev/null)" + if [ "$http" = "200" ]; then + echo "status=dead" >> "$GITHUB_OUTPUT" + echo "probe ${TOOL} (${NAMESPACE}): failed (exit=${code}) and service reachable -> dead" + else + echo "status=indeterminate" >> "$GITHUB_OUTPUT" + echo "probe ${TOOL} (${NAMESPACE}): failed (exit=${code}) and service unreachable (http=${http}) -> indeterminate" + fi + exit 0 + env: + TOOL: ${{ inputs.tool }} + NAMESPACE: ${{ inputs.namespace }} diff --git a/actions/verify-npm-provenance/action.yml b/actions/verify-npm-provenance/action.yml new file mode 100644 index 0000000..518864f --- /dev/null +++ b/actions/verify-npm-provenance/action.yml @@ -0,0 +1,50 @@ +name: Verify npm provenance +description: >- + Assert a published npm version carries both the npm publish attestation and a + SLSA provenance predicate, and that the provenance names the expected source + repo/workflow/commit. Checks claims, not cryptography — pair with + `npm audit signatures` for signature verification. + +inputs: + package: + description: "npm package name, e.g. @nimbus-dev/sdk" + required: true + version: + description: "Published version to verify" + required: true + expected-repo: + description: "owner/repo that must appear in the provenance source claim" + required: true + expected-workflow: + description: "Workflow path that must have produced it, e.g. .github/workflows/release.yml" + required: false + expected-sha: + description: "Commit SHA the build must have come from" + required: false + severity: + description: "'gate' (default) fails the job on any non-ok status; 'monitor' always exits 0" + required: false + default: gate + +outputs: + status: + description: "ok | missing-provenance | source-mismatch | indeterminate" + value: ${{ steps.verify.outputs.status }} + detail: + description: "Short human-readable reason" + value: ${{ steps.verify.outputs.detail }} + +runs: + using: composite + steps: + - id: verify + shell: bash + # Node 20+ is preinstalled on GitHub runners; no setup-node, no install. + run: node "${{ github.action_path }}/src/main.js" + env: + INPUT_PACKAGE: ${{ inputs.package }} + INPUT_VERSION: ${{ inputs.version }} + INPUT_EXPECTED_REPO: ${{ inputs.expected-repo }} + INPUT_EXPECTED_WORKFLOW: ${{ inputs.expected-workflow }} + INPUT_EXPECTED_SHA: ${{ inputs.expected-sha }} + INPUT_SEVERITY: ${{ inputs.severity }} diff --git a/actions/verify-npm-provenance/src/classify.js b/actions/verify-npm-provenance/src/classify.js new file mode 100644 index 0000000..2f222da --- /dev/null +++ b/actions/verify-npm-provenance/src/classify.js @@ -0,0 +1,77 @@ +export const PUBLISH_PREDICATE = "https://github.com/npm/attestation/tree/main/specs/publish/v0.1"; +export const SLSA_PREDICATE = "https://slsa.dev/provenance/v1"; + +/** + * Decode the DSSE payloads out of a registry attestation response. + * + * The attestation content lives in `bundle.dsseEnvelope.payload` (base64 JSON), + * NOT at the top level. We read predicateType from the decoded statement rather + * than the unsigned outer wrapper. + * + * Returns null on any unrecognised shape — the caller maps that to + * "indeterminate", never a false "ok". + */ +export function decodeStatements(body) { + if (body === null || typeof body !== "object") return null; + const list = body.attestations; + if (!Array.isArray(list)) return null; + const statements = []; + for (const att of list) { + const payload = att?.bundle?.dsseEnvelope?.payload; + if (typeof payload !== "string") return null; + let decoded; + try { + decoded = JSON.parse(Buffer.from(payload, "base64").toString("utf8")); + } catch { + return null; + } + if (decoded === null || typeof decoded !== "object") return null; + statements.push(decoded); + } + return statements; +} + +function fail(status, detail) { + return { status, detail }; +} + +/** + * Assert the attestation set is complete AND attested to the expected source. + * "An attestation exists" is a weaker claim than "attested to us". + */ +export function classifyProvenance(statements, expected) { + if (statements === null) return fail("indeterminate", "unparseable attestation response"); + if (statements.length === 0) return fail("missing-provenance", "no attestations published"); + + const types = new Set(statements.map((s) => s?.predicateType)); + if (!types.has(PUBLISH_PREDICATE)) { + return fail("missing-provenance", "no npm publish attestation"); + } + const slsa = statements.find((s) => s?.predicateType === SLSA_PREDICATE); + if (slsa === undefined) { + return fail("missing-provenance", "no SLSA provenance predicate — publish degraded"); + } + + const wf = slsa?.predicate?.buildDefinition?.externalParameters?.workflow; + if (wf === null || typeof wf !== "object") { + return fail("indeterminate", "provenance carries no workflow claim"); + } + + const wantRepo = `https://github.com/${expected.repo}`; + if (wf.repository !== wantRepo) { + return fail("source-mismatch", `repository ${String(wf.repository)} != ${wantRepo}`); + } + if (expected.workflow !== undefined && wf.path !== expected.workflow) { + return fail("source-mismatch", `workflow ${String(wf.path)} != ${expected.workflow}`); + } + if (expected.sha !== undefined) { + const deps = slsa?.predicate?.buildDefinition?.resolvedDependencies; + const commit = Array.isArray(deps) + ? deps.find((d) => typeof d?.digest?.gitCommit === "string")?.digest?.gitCommit + : undefined; + if (commit !== expected.sha) { + return fail("source-mismatch", `commit ${String(commit)} != ${expected.sha}`); + } + } + return { status: "ok", detail: `${String(wf.path)} @ ${String(wf.repository)}` }; +} diff --git a/actions/verify-npm-provenance/src/fetch-attestations.js b/actions/verify-npm-provenance/src/fetch-attestations.js new file mode 100644 index 0000000..083bb1a --- /dev/null +++ b/actions/verify-npm-provenance/src/fetch-attestations.js @@ -0,0 +1,63 @@ +/** + * Deterministic retry schedule: 5s doubling to a 30s cap, ~2.5 min total. + * + * Jitter is deliberately omitted. Jitter decorrelates a FLEET of clients + * retrying in lockstep; here exactly one client retries per publish, so it + * would only add nondeterminism to these tests. Do not re-add it. + */ +export const BACKOFF_MS = Object.freeze([5000, 10000, 20000, 30000, 30000, 30000, 30000]); + +const REGISTRY = "https://registry.npmjs.org/-/npm/v1/attestations"; + +/** + * All three encodings of a scoped name (raw, fully percent-encoded, mixed) + * return HTTP 200 — verified against the live registry 2026-07-19. The raw + * form is canonical here; no encoding is required. + */ +export function attestationUrl(pkg, version) { + return `${REGISTRY}/${pkg}@${version}`; +} + +/** + * Attestations can trail a publish, and the registry is CDN-fronted, so a 404 + * is only conclusive once the backoff schedule is exhausted. + * + * Distinguishes "absent" (404 throughout — the package genuinely has no + * attestation) from "error" (5xx / network / unparseable). Callers map those + * to different severities. + */ +export async function fetchAttestations(pkg, version, deps) { + const backoff = deps.backoff ?? BACKOFF_MS; + const url = attestationUrl(pkg, version); + let lastDetail = "no attempt made"; + + for (let attempt = 0; attempt <= backoff.length; attempt += 1) { + if (attempt > 0) await deps.sleep(backoff[attempt - 1]); + try { + const res = await deps.fetchFn(url, { headers: { accept: "application/json" } }); + if (res.status === 200) { + try { + return { outcome: "body", body: await res.json(), detail: "200" }; + } catch { + // A 200 carrying unparseable bytes is transient, not authoritative: + // a CDN/proxy error page or a truncated body both look like this. + // Retry rather than failing a release on one bad edge response. + lastDetail = "200 with unparseable JSON body"; + continue; + } + } + if (res.status === 404) { + lastDetail = "404 after full backoff"; + continue; + } + lastDetail = `HTTP ${res.status}`; + } catch { + // Never surface the thrown error object: fetch errors can embed request + // headers, and this action runs in public logs. + lastDetail = "network error"; + } + } + return lastDetail === "404 after full backoff" + ? { outcome: "absent", detail: lastDetail } + : { outcome: "error", detail: lastDetail }; +} diff --git a/actions/verify-npm-provenance/src/main.js b/actions/verify-npm-provenance/src/main.js new file mode 100644 index 0000000..2834b95 --- /dev/null +++ b/actions/verify-npm-provenance/src/main.js @@ -0,0 +1,68 @@ +import { appendFileSync } from "node:fs"; +import { classifyProvenance, decodeStatements } from "./classify.js"; +import { fetchAttestations } from "./fetch-attestations.js"; + +/** Map a fetch outcome plus the classifier into a single status. */ +export function decide(fetched, expected) { + if (fetched.outcome === "absent") { + return { status: "missing-provenance", detail: fetched.detail }; + } + if (fetched.outcome === "error") { + // A registry problem is NOT evidence the publish was bad. + return { status: "indeterminate", detail: fetched.detail }; + } + return classifyProvenance(decodeStatements(fetched.body), expected); +} + +/** + * The gate must not let a possibly-degraded publish through; the monitor must + * not turn a registry hiccup into issue spam. Same status, different severity. + */ +export function exitCodeFor(status, severity) { + if (severity === "monitor") return 0; + return status === "ok" ? 0 : 1; +} + +function runbook(pkg, version, status, detail) { + return [ + "::error::npm provenance verification FAILED", + `::error::package=${pkg}@${version} status=${status} detail=${detail}`, + "::error::RUNBOOK:", + "::error:: 1. This version is already on the registry. npm allows unpublish", + "::error:: only within 72h of publish — check the publish timestamp now.", + "::error:: 2. Within 72h: `npm unpublish @`, fix the cause, republish.", + '::error:: 3. After 72h: `npm deprecate @ "no provenance; use "`', + "::error:: then publish a patch version with provenance.", + "::error:: 4. Common causes: `id-token: write` missing from the publish job;", + "::error:: npm older than 11.5.1; trusted-publisher binding removed on npmjs.com.", + ].join("\n"); +} + +if (process.env["NODE_ENV"] !== "test" && process.argv[1]?.endsWith("main.js")) { + const pkg = process.env["INPUT_PACKAGE"] ?? ""; + const version = process.env["INPUT_VERSION"] ?? ""; + const severity = process.env["INPUT_SEVERITY"] === "monitor" ? "monitor" : "gate"; + const expected = { repo: process.env["INPUT_EXPECTED_REPO"] ?? "" }; + const wf = process.env["INPUT_EXPECTED_WORKFLOW"]; + const sha = process.env["INPUT_EXPECTED_SHA"]; + if (wf) expected.workflow = wf; + if (sha) expected.sha = sha; + + const fetched = await fetchAttestations(pkg, version, { + fetchFn: fetch, + sleep: (ms) => new Promise((r) => setTimeout(r, ms)), + }); + const { status, detail } = decide(fetched, expected); + + const out = process.env["GITHUB_OUTPUT"]; + if (out) appendFileSync(out, `status=${status}\ndetail=${detail}\n`); + console.log(`npm provenance: ${pkg}@${version} -> ${status} (${detail})`); + if (status !== "ok" && severity === "gate") console.log(runbook(pkg, version, status, detail)); + // Set exitCode instead of calling process.exit(): exit() tears the process + // down immediately, which can race undici's keep-alive sockets/timers + // (observed as a libuv UV_HANDLE_CLOSING assertion crash on Windows/Node 24) + // and can truncate buffered stdout / the GITHUB_OUTPUT write. Assigning + // process.exitCode lets Node exit naturally once the event loop drains, + // preserving the same exit-code contract without the early teardown. + process.exitCode = exitCodeFor(status, severity); +} diff --git a/actions/verify-npm-provenance/test/classify.test.js b/actions/verify-npm-provenance/test/classify.test.js new file mode 100644 index 0000000..6f6ed4c --- /dev/null +++ b/actions/verify-npm-provenance/test/classify.test.js @@ -0,0 +1,105 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { test } from "node:test"; +import { classifyProvenance, decodeStatements } from "../src/classify.js"; + +const real = JSON.parse( + readFileSync(new URL("./fixtures/sdk-1.3.0.json", import.meta.url), "utf8"), +); +const EXPECTED = { repo: "nimbus-agent/nimbus-sdk", workflow: ".github/workflows/release.yml" }; + +function statementsOf(body) { + const s = decodeStatements(body); + assert.notEqual(s, null, "fixture should decode"); + return s; +} + +test("real published package classifies ok", () => { + const r = classifyProvenance(statementsOf(real), EXPECTED); + assert.equal(r.status, "ok"); +}); + +test("missing SLSA predicate is missing-provenance, not ok", () => { + const only = { + attestations: real.attestations.filter( + (a) => a.predicateType === "https://github.com/npm/attestation/tree/main/specs/publish/v0.1", + ), + }; + const r = classifyProvenance(statementsOf(only), EXPECTED); + assert.equal(r.status, "missing-provenance"); +}); + +test("missing npm publish predicate is missing-provenance", () => { + const only = { + attestations: real.attestations.filter( + (a) => a.predicateType === "https://slsa.dev/provenance/v1", + ), + }; + const r = classifyProvenance(statementsOf(only), EXPECTED); + assert.equal(r.status, "missing-provenance"); +}); + +test("wrong repo is source-mismatch", () => { + const r = classifyProvenance(statementsOf(real), { repo: "attacker/evil" }); + assert.equal(r.status, "source-mismatch"); +}); + +test("wrong workflow path is source-mismatch", () => { + const r = classifyProvenance(statementsOf(real), { + repo: "nimbus-agent/nimbus-sdk", + workflow: ".github/workflows/attacker.yml", + }); + assert.equal(r.status, "source-mismatch"); +}); + +test("wrong commit sha is source-mismatch", () => { + const r = classifyProvenance(statementsOf(real), { + repo: "nimbus-agent/nimbus-sdk", + sha: "0000000000000000000000000000000000000000", + }); + assert.equal(r.status, "source-mismatch"); +}); + +test("correct commit sha is ok", () => { + const r = classifyProvenance(statementsOf(real), { + repo: "nimbus-agent/nimbus-sdk", + sha: "7e5a45f325d588a0b21eb5e1718a31c4ccb306cb", + }); + assert.equal(r.status, "ok"); +}); + +test("empty attestation list is missing-provenance", () => { + const r = classifyProvenance(statementsOf({ attestations: [] }), EXPECTED); + assert.equal(r.status, "missing-provenance"); +}); + +test("null statements is indeterminate, never ok", () => { + const r = classifyProvenance(null, EXPECTED); + assert.equal(r.status, "indeterminate"); +}); + +test("malformed body decodes to null", () => { + assert.equal(decodeStatements(null), null); + assert.equal(decodeStatements({}), null); + assert.equal(decodeStatements({ attestations: "nope" }), null); + assert.equal(decodeStatements({ attestations: [{ bundle: {} }] }), null); +}); + +test("truncated base64 payload decodes to null", () => { + const broken = { + attestations: [{ bundle: { dsseEnvelope: { payload: "bm90IGpzb24=" } } }], + }; + assert.equal(decodeStatements(broken), null); +}); + +test("detail is a short summary, never the raw statement blob", () => { + const r = classifyProvenance(statementsOf(real), EXPECTED); + // Assert the actual content, not merely that it is short: a length bound + // would pass on a truncated blob just as happily as on a real summary. + assert.equal( + r.detail, + ".github/workflows/release.yml @ https://github.com/nimbus-agent/nimbus-sdk", + ); + assert.ok(!/[A-Za-z0-9+/]{80,}={0,2}/.test(r.detail), "no base64 payload in detail"); + assert.ok(!r.detail.includes("dsseEnvelope"), "no envelope internals in detail"); +}); diff --git a/actions/verify-npm-provenance/test/fetch-attestations.test.js b/actions/verify-npm-provenance/test/fetch-attestations.test.js new file mode 100644 index 0000000..c61ddf6 --- /dev/null +++ b/actions/verify-npm-provenance/test/fetch-attestations.test.js @@ -0,0 +1,127 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { attestationUrl, BACKOFF_MS, fetchAttestations } from "../src/fetch-attestations.js"; + +const noSleep = async () => {}; + +test("url uses the raw scoped name (all forms verified 200; raw is canonical)", () => { + assert.equal( + attestationUrl("@nimbus-dev/sdk", "1.3.0"), + "https://registry.npmjs.org/-/npm/v1/attestations/@nimbus-dev/sdk@1.3.0", + ); +}); + +test("backoff is deterministic, ~2.5 min, capped at 30s (no jitter)", () => { + assert.deepEqual([...BACKOFF_MS], [5000, 10000, 20000, 30000, 30000, 30000, 30000]); + const total = BACKOFF_MS.reduce((a, b) => a + b, 0); + assert.equal(total, 155000); + assert.ok(Math.max(...BACKOFF_MS) === 30000); +}); + +test("200 on first attempt returns body without sleeping", async () => { + let slept = 0; + const r = await fetchAttestations("@x/y", "1.0.0", { + fetchFn: async () => new Response(JSON.stringify({ attestations: [] }), { status: 200 }), + sleep: async () => { + slept += 1; + }, + }); + assert.equal(r.outcome, "body"); + assert.deepEqual(r.body, { attestations: [] }); + assert.equal(slept, 0); +}); + +test("404 retries the full schedule then reports absent", async () => { + let calls = 0; + const slept = []; + const r = await fetchAttestations("@x/y", "1.0.0", { + fetchFn: async () => { + calls += 1; + return new Response("", { status: 404 }); + }, + sleep: async (ms) => { + slept.push(ms); + }, + }); + assert.equal(r.outcome, "absent"); + assert.equal(calls, BACKOFF_MS.length + 1, "initial attempt plus one per backoff step"); + assert.deepEqual(slept, [...BACKOFF_MS]); +}); + +test("404 then 200 succeeds without exhausting the schedule", async () => { + let calls = 0; + const r = await fetchAttestations("@x/y", "1.0.0", { + fetchFn: async () => { + calls += 1; + return calls < 3 + ? new Response("", { status: 404 }) + : new Response(JSON.stringify({ attestations: [1] }), { status: 200 }); + }, + sleep: noSleep, + }); + assert.equal(r.outcome, "body"); + assert.equal(calls, 3); +}); + +test("5xx exhausts retries and reports error, not absent", async () => { + let calls = 0; + const r = await fetchAttestations("@x/y", "1.0.0", { + fetchFn: async () => { + calls += 1; + return new Response("", { status: 503 }); + }, + sleep: noSleep, + }); + assert.equal(r.outcome, "error"); + assert.match(r.detail, /503/); + assert.equal( + calls, + BACKOFF_MS.length + 1, + "5xx is retryable; must exhaust the full schedule before reporting error", + ); +}); + +test("network throw is error, never absent", async () => { + let calls = 0; + const r = await fetchAttestations("@x/y", "1.0.0", { + fetchFn: async () => { + calls += 1; + throw new Error("ECONNRESET"); + }, + sleep: noSleep, + }); + assert.equal(r.outcome, "error"); + assert.equal( + calls, + BACKOFF_MS.length + 1, + "network errors are transient; must exhaust the full schedule before reporting error", + ); +}); + +test("invalid JSON body retries, then reports error — never a false absent", async () => { + let calls = 0; + const r = await fetchAttestations("@x/y", "1.0.0", { + fetchFn: async () => { + calls += 1; + return new Response("502", { status: 200 }); + }, + sleep: noSleep, + }); + assert.equal(r.outcome, "error"); + assert.equal(calls, BACKOFF_MS.length + 1, "a proxy error page is transient — must retry"); +}); + +test("transient bad body then good body succeeds", async () => { + let calls = 0; + const r = await fetchAttestations("@x/y", "1.0.0", { + fetchFn: async () => { + calls += 1; + return calls === 1 + ? new Response("502", { status: 200 }) + : new Response(JSON.stringify({ attestations: [1] }), { status: 200 }); + }, + sleep: noSleep, + }); + assert.equal(r.outcome, "body"); + assert.equal(calls, 2); +}); diff --git a/actions/verify-npm-provenance/test/fixtures/sdk-1.3.0.json b/actions/verify-npm-provenance/test/fixtures/sdk-1.3.0.json new file mode 100644 index 0000000..ef68fba --- /dev/null +++ b/actions/verify-npm-provenance/test/fixtures/sdk-1.3.0.json @@ -0,0 +1 @@ +{"attestations":[{"predicateType":"https://github.com/npm/attestation/tree/main/specs/publish/v0.1","bundle":{"mediaType":"application/vnd.dev.sigstore.bundle+json;version=0.2","verificationMaterial":{"publicKey":{"hint":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"},"tlogEntries":[{"logIndex":"2174440815","logId":{"keyId":"wNI9atQGlz+VWfO6LRygH4QUfY/8W4RFwiT5i5WRgB0="},"kindVersion":{"kind":"dsse","version":"0.0.1"},"integratedTime":"1784139919","inclusionPromise":{"signedEntryTimestamp":"MEUCIGHZbzNns9PE5sVUJUdXB68XhEpuNVNIXxQ6r6J6QuceAiEAqi1hNoXXy5Jh8MsdP/0lMVXkJXtAHvA/WE/1GVf/JPM="},"inclusionProof":{"logIndex":"2052536553","rootHash":"yL+TRLRdhtHFAcHCytvcHye6Rd6nP9oJAo1OqU9nZWs=","treeSize":"2052536575","hashes":["FZXhrjiY5jcT13jVYl4rf4cQLGZjW96dTX9i1dDILUY=","MVbXDoC/tnR/KrZrQJ6wVTRLyhTkWBdqzSini6dUM80=","JWvgLa/HiwRs/FsFMXSIykjqiIPD9z95xy1xOFYOiNE=","/GX5bBHKRwVRUTTr6jCZGW6W90qT27sERJSEUq1xKO8=","rP9OlxbbaNJdeDHIXzfMeLXxeTS8K5JwTYKWX8i1860=","GPZJqnUwn5YB+4qqZAWLuVBTHyeVPn8iRAs2kDEGTiw=","Ed15z/W1sazGqW/zLdrsszKONd8JkLtifTJFk1xJ72Y=","42PBMp3l+V9rMZBHG+jGQakOQ0Wn5AHhuoD3rgwi7B0=","IvA7UQs53Ln8qrPlEWI8zVuyFCNS9RK+7LTMjz2RnKo=","p3S4UTybZAW/8s8WB5pXtO/XmP37ru1S/8CmW/PfWNQ=","SAqx0ABc2341+WZLPhL/W/clh+1Gqsm3Am7YgoJePxM=","7DNmN9Kp7s8EhOYbPcS5C2S1lzh37kF2upSUm47AiPg=","0e0cusdtuJdStXdndoksQRZNkGenJJrEhxgBoeTO0v0=","KW/rCfu94dcvlBs5OTrgAxq/fgYIem0cYETLCQKPamA=","8hfVRO8nWPdV6Nl3qHDoKdy7OIQnE34anBoI2QpMEyo=","ytvIYdlfyTi5f6Z9mptDOMQUc3XaaJx5SeS7h/9lX/k=","A7J53Hp6UjtklJa/6DOOwBFIBojnOs0IWvgZtejKFMY=","Rq/A2aTC9e54ldNjcpsJ26rX/h8JlN6ZHxgw7yEa6C0=","+/VZ56MsIPxMiyLAodzKXo5TEWdQp36z89qLhpzloAo=","daxmZaajRpZV+JxHiOYZhJBiSKN5ucqjh2WnGbHhirw=","DOCeoSMovIvLExkhIvisow9AuNXgeWs4ECkyR6EcqYU="],"checkpoint":{"envelope":"rekor.sigstore.dev - 1193050959916656506\n2052536575\nyL+TRLRdhtHFAcHCytvcHye6Rd6nP9oJAo1OqU9nZWs=\n\n— rekor.sigstore.dev wNI9ajBFAiEAkTPLThvF0sw1qCqGm5r92gNRDd6c3X/4HI3i+IGIIZUCIH8H1w/RwU8QWwIt4fAP5SVsyTjOB8Q/ehbIGqA5TfxG\n"}},"canonicalizedBody":"eyJhcGlWZXJzaW9uIjoiMC4wLjEiLCJraW5kIjoiZHNzZSIsInNwZWMiOnsiZW52ZWxvcGVIYXNoIjp7ImFsZ29yaXRobSI6InNoYTI1NiIsInZhbHVlIjoiNmQxYTJjOWQ3MzJkZGI3NWMwNzQyODMzZDQxOTU2Y2U3NDg5ZTZiMzhiYmM4NTVlNTdmMTVhMWZjNTdlOGYwOSJ9LCJwYXlsb2FkSGFzaCI6eyJhbGdvcml0aG0iOiJzaGEyNTYiLCJ2YWx1ZSI6ImIzYjYxN2NmY2M1YWJkOTZiYTQwMTE0ODAzMWUyOTAwZDBiOWQyMjI5NTI3N2E2MTY0MzMyODgxYzE4OWQ1MjYifSwic2lnbmF0dXJlcyI6W3sic2lnbmF0dXJlIjoiTUVVQ0lRRHA2c2RhS3pUeTBDeWliR29SMlNUc2ExeVgwaE5xYzE5ZXllTnFJandGZFFJZ1JOd2RacEFpem9KS3ZwdVM2TFV3bW95U3Q4MWp5dEo3NXFaNVY0UXI4Zjg9IiwidmVyaWZpZXIiOiJMUzB0TFMxQ1JVZEpUaUJRVlVKTVNVTWdTMFZaTFMwdExTMEtUVVpyZDBWM1dVaExiMXBKZW1vd1EwRlJXVWxMYjFwSmVtb3dSRUZSWTBSUlowRkZXVFpaWVRkWEt5czNZVlZRZW5aTlZISmxla2cyV1dONE0yTXJTQXBQUzFsRFkwNUhlV0pLV2xORFNuRXZabVEzVVdFNGRYVkJTM1JrU1d0VlVYUlJhVVZMUlZKb1FXMUZOV3hOVFVwb1VEaFBhMFJQWVRKblBUMEtMUzB0TFMxRlRrUWdVRlZDVEVsRElFdEZXUzB0TFMwdENnPT0ifV19fQ=="}],"timestampVerificationData":{"rfc3161Timestamps":[]}},"dsseEnvelope":{"payload":"eyJfdHlwZSI6Imh0dHBzOi8vaW4tdG90by5pby9TdGF0ZW1lbnQvdjAuMSIsInN1YmplY3QiOlt7Im5hbWUiOiJwa2c6bnBtLyU0MG5pbWJ1cy1kZXYvc2RrQDEuMy4wIiwiZGlnZXN0Ijp7InNoYTUxMiI6Ijk4MjU2NTMwYjZkODUyOGMxMTJlY2MzNTc0MDcwM2E3YWQ0MGNhODJkMjViYTg2YjdkYjc2YjI2MjliYjg2OGRlNDgzYmYyYjI2NTRiNDBkOGFjYWE2YTQzMTZlNzgzYTEzY2RjZGIxYzI2NDUwNzk0ODE1YTlmMWE3NDg2OWU2In19XSwicHJlZGljYXRlVHlwZSI6Imh0dHBzOi8vZ2l0aHViLmNvbS9ucG0vYXR0ZXN0YXRpb24vdHJlZS9tYWluL3NwZWNzL3B1Ymxpc2gvdjAuMSIsInByZWRpY2F0ZSI6eyJuYW1lIjoiQG5pbWJ1cy1kZXYvc2RrIiwidmVyc2lvbiI6IjEuMy4wIiwicmVnaXN0cnkiOiJodHRwczovL3JlZ2lzdHJ5Lm5wbWpzLm9yZyJ9fQ==","payloadType":"application/vnd.in-toto+json","signatures":[{"sig":"MEUCIQDp6sdaKzTy0CyibGoR2STsa1yX0hNqc19eyeNqIjwFdQIgRNwdZpAizoJKvpuS6LUwmoySt81jytJ75qZ5V4Qr8f8=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}]}},"signedAccessSignatureUrl":""},{"predicateType":"https://slsa.dev/provenance/v1","bundle":{"mediaType":"application/vnd.dev.sigstore.bundle.v0.3+json","verificationMaterial":{"certificate":{"rawBytes":"MIIHJTCCBqqgAwIBAgIUF6aPqHZjoxZWCiCwf51vCE0ibU0wCgYIKoZIzj0EAwMwNzEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MR4wHAYDVQQDExVzaWdzdG9yZS1pbnRlcm1lZGlhdGUwHhcNMjYwNzE1MTgyNTE2WhcNMjYwNzE1MTgzNTE2WjAAMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEnhffbKSJ7vnrcmB8IaSxBelusSLzBHlfb+R52Jwo9v1+kvFz0EXFhFAPFI4+1f8TbYLNJgO6bsduUlQZp5wi9qOCBckwggXFMA4GA1UdDwEB/wQEAwIHgDATBgNVHSUEDDAKBggrBgEFBQcDAzAdBgNVHQ4EFgQUq2Jb2uFeTO25jlOElQ971pQdiS8wHwYDVR0jBBgwFoAU39Ppz1YkEZb5qNjpKFWixi4YZD8wZgYDVR0RAQH/BFwwWoZYaHR0cHM6Ly9naXRodWIuY29tL25pbWJ1cy1hZ2VudC9uaW1idXMtc2RrLy5naXRodWIvd29ya2Zsb3dzL3JlbGVhc2UueW1sQHJlZnMvaGVhZHMvbWFpbjA5BgorBgEEAYO/MAEBBCtodHRwczovL3Rva2VuLmFjdGlvbnMuZ2l0aHVidXNlcmNvbnRlbnQuY29tMBIGCisGAQQBg78wAQIEBHB1c2gwNgYKKwYBBAGDvzABAwQoN2U1YTQ1ZjMyNWQ1ODhhMGIyMWViNWUxNzE4YTMxYzRjY2IzMDZjYjAVBgorBgEEAYO/MAEEBAdSZWxlYXNlMCUGCisGAQQBg78wAQUEF25pbWJ1cy1hZ2VudC9uaW1idXMtc2RrMB0GCisGAQQBg78wAQYED3JlZnMvaGVhZHMvbWFpbjA7BgorBgEEAYO/MAEIBC0MK2h0dHBzOi8vdG9rZW4uYWN0aW9ucy5naXRodWJ1c2VyY29udGVudC5jb20waAYKKwYBBAGDvzABCQRaDFhodHRwczovL2dpdGh1Yi5jb20vbmltYnVzLWFnZW50L25pbWJ1cy1zZGsvLmdpdGh1Yi93b3JrZmxvd3MvcmVsZWFzZS55bWxAcmVmcy9oZWFkcy9tYWluMDgGCisGAQQBg78wAQoEKgwoN2U1YTQ1ZjMyNWQ1ODhhMGIyMWViNWUxNzE4YTMxYzRjY2IzMDZjYjAdBgorBgEEAYO/MAELBA8MDWdpdGh1Yi1ob3N0ZWQwOgYKKwYBBAGDvzABDAQsDCpodHRwczovL2dpdGh1Yi5jb20vbmltYnVzLWFnZW50L25pbWJ1cy1zZGswOAYKKwYBBAGDvzABDQQqDCg3ZTVhNDVmMzI1ZDU4OGEwYjIxZWI1ZTE3MThhMzFjNGNjYjMwNmNiMB8GCisGAQQBg78wAQ4EEQwPcmVmcy9oZWFkcy9tYWluMBoGCisGAQQBg78wAQ8EDAwKMTMwMTg5MTI2MjAvBgorBgEEAYO/MAEQBCEMH2h0dHBzOi8vZ2l0aHViLmNvbS9uaW1idXMtYWdlbnQwGQYKKwYBBAGDvzABEQQLDAkyODEzODYxMjUwaAYKKwYBBAGDvzABEgRaDFhodHRwczovL2dpdGh1Yi5jb20vbmltYnVzLWFnZW50L25pbWJ1cy1zZGsvLmdpdGh1Yi93b3JrZmxvd3MvcmVsZWFzZS55bWxAcmVmcy9oZWFkcy9tYWluMDgGCisGAQQBg78wARMEKgwoN2U1YTQ1ZjMyNWQ1ODhhMGIyMWViNWUxNzE4YTMxYzRjY2IzMDZjYjAUBgorBgEEAYO/MAEUBAYMBHB1c2gwXgYKKwYBBAGDvzABFQRQDE5odHRwczovL2dpdGh1Yi5jb20vbmltYnVzLWFnZW50L25pbWJ1cy1zZGsvYWN0aW9ucy9ydW5zLzI5NDM5ODI4NTQ1L2F0dGVtcHRzLzIwFgYKKwYBBAGDvzABFgQIDAZwdWJsaWMwVQYKKwYBBAGDvzABGARHDEVyZXBvOm5pbWJ1cy1hZ2VudEAyODEzODYxMjUvbmltYnVzLXNka0AxMzAxODkxMjYyOnJlZjpyZWZzL2hlYWRzL21haW4wgYkGCisGAQQB1nkCBAIEewR5AHcAdQDdPTBqxscRMmMZHhyZZzcCokpeuN48rf+HinKALynujgAAAZ9nBqXOAAAEAwBGMEQCICuWsq+xeAqzzdTAoJsgR3rwrGbgMAjXu6SQqWd7xur9AiB3WhS5Rbs6RKr0o/V7NguOd6UL6nHzpLZfn8LPyPrCLDAKBggqhkjOPQQDAwNpADBmAjEAyutkCrlij7nGT7pEJOOe+jKejMUPbPOTB0lML0WVos65eEE4mWpRXWnp6VRA6oSvAjEA1BX36xkmBKAwpjIZyLhost42RB+PQnOfwpt1m8GDzbPGEbLb95aFdjNiyCSn2nhF"},"tlogEntries":[{"logIndex":"2174440351","logId":{"keyId":"wNI9atQGlz+VWfO6LRygH4QUfY/8W4RFwiT5i5WRgB0="},"kindVersion":{"kind":"dsse","version":"0.0.1"},"integratedTime":"1784139916","inclusionPromise":{"signedEntryTimestamp":"MEUCIQCF8A9YZGQ7XzR/XDfP4gRhvQqBScHJrrUi+wEP+vDumAIgJYD1VOhe3nCvMaiq7tB/kpUSVq5jiPnHBT+PFXPcFoU="},"inclusionProof":{"logIndex":"2052536089","rootHash":"+1JnRLlRPgoowupKoykATWmS1xhTWnmGSqoOZLgVFKY=","treeSize":"2052536102","hashes":["gADou3lWtuYHfC9Mszk+NQqCYXjyvuua2JFBj/hVc6s=","zijjzWAmr1g5GeJkyFrHDQJZsD/Gzssio8vOCiCHRRM=","h/jRO+Ism7TyLloTyJdD4I0CO6lV60v4EnmNDA+CdIY=","qQxTQ8ywskCIcUJ3jOWtZqDTXqpKmU5NQNRB8FQRmkw=","MPyYXFNCOWo9pJ893DFb0zmWMNVNdUxy3KETXiyYG7g=","n+dweAzplVwlYDyIQPbSjnvuksYWonW3QB78LZbwlhM=","cp7/Pxh5kPSnHycX5TOXrKQQyMR/GDw9Pq+DIvWtDeI=","OPw8yWA9XGmZpdChx4j+Ry7mVbt2iV5ASSl6zHJARQk=","JGZZb0JZrshIUf21zMMVn0+z6PuKNWiAki/VhfHdiCY=","p3S4UTybZAW/8s8WB5pXtO/XmP37ru1S/8CmW/PfWNQ=","SAqx0ABc2341+WZLPhL/W/clh+1Gqsm3Am7YgoJePxM=","7DNmN9Kp7s8EhOYbPcS5C2S1lzh37kF2upSUm47AiPg=","0e0cusdtuJdStXdndoksQRZNkGenJJrEhxgBoeTO0v0=","KW/rCfu94dcvlBs5OTrgAxq/fgYIem0cYETLCQKPamA=","8hfVRO8nWPdV6Nl3qHDoKdy7OIQnE34anBoI2QpMEyo=","ytvIYdlfyTi5f6Z9mptDOMQUc3XaaJx5SeS7h/9lX/k=","A7J53Hp6UjtklJa/6DOOwBFIBojnOs0IWvgZtejKFMY=","Rq/A2aTC9e54ldNjcpsJ26rX/h8JlN6ZHxgw7yEa6C0=","+/VZ56MsIPxMiyLAodzKXo5TEWdQp36z89qLhpzloAo=","daxmZaajRpZV+JxHiOYZhJBiSKN5ucqjh2WnGbHhirw=","DOCeoSMovIvLExkhIvisow9AuNXgeWs4ECkyR6EcqYU="],"checkpoint":{"envelope":"rekor.sigstore.dev - 1193050959916656506\n2052536102\n+1JnRLlRPgoowupKoykATWmS1xhTWnmGSqoOZLgVFKY=\n\n— rekor.sigstore.dev wNI9ajBEAiApKOXQSFTcJ2U+P9Ip6jx8TR+yJd7WqI+jpRW5+16FuAIgK+G6Wu3g4SJAIK2iNxwtmuELPIf42Vsyuf7+j5ryamY=\n"}},"canonicalizedBody":"eyJhcGlWZXJzaW9uIjoiMC4wLjEiLCJraW5kIjoiZHNzZSIsInNwZWMiOnsiZW52ZWxvcGVIYXNoIjp7ImFsZ29yaXRobSI6InNoYTI1NiIsInZhbHVlIjoiNGZiMmFlYTBhMzZiZTMxYzgzODM2YjNiZjc1ZjBmOTA2MjdjMDM1OTYzYWQ0Y2Q5YmNjY2ZmNjc1ODRmOTkzNCJ9LCJwYXlsb2FkSGFzaCI6eyJhbGdvcml0aG0iOiJzaGEyNTYiLCJ2YWx1ZSI6Ijg5OWQzMmEyMWU4YzJmNjlhNjgxNDE5MDU1NWUwMzMyNTY5Y2UyYTZkMzI3NTM3NmJmZTg5YWYzNGNjYmY3MjgifSwic2lnbmF0dXJlcyI6W3sic2lnbmF0dXJlIjoiTUVVQ0lRRFVLUU5Fam9mUTI1QUNNTEdRQVJrUE5XRzJBTDJibEY3Tk9jL1g3RnNHUHdJZ0RDT0ZEenVKRlFPbVJzT1VydzM0d3VzS2VUS09WTnpmbklZMm1udVFmSm89IiwidmVyaWZpZXIiOiJMUzB0TFMxQ1JVZEpUaUJEUlZKVVNVWkpRMEZVUlMwdExTMHRDazFKU1VoS1ZFTkRRbkZ4WjBGM1NVSkJaMGxWUmpaaFVIRklXbXB2ZUZwWFEybERkMlkxTVhaRFJUQnBZbFV3ZDBObldVbExiMXBKZW1vd1JVRjNUWGNLVG5wRlZrMUNUVWRCTVZWRlEyaE5UV015Ykc1ak0xSjJZMjFWZFZwSFZqSk5ValIzU0VGWlJGWlJVVVJGZUZaNllWZGtlbVJIT1hsYVV6RndZbTVTYkFwamJURnNXa2RzYUdSSFZYZElhR05PVFdwWmQwNTZSVEZOVkdkNVRsUkZNbGRvWTA1TmFsbDNUbnBGTVUxVVozcE9WRVV5VjJwQlFVMUdhM2RGZDFsSUNrdHZXa2w2YWpCRFFWRlpTVXR2V2tsNmFqQkVRVkZqUkZGblFVVnVhR1ptWWt0VFNqZDJibkpqYlVJNFNXRlRlRUpsYkhWelUweDZRa2hzWm1JclVqVUtNa3AzYnpsMk1TdHJka1o2TUVWWVJtaEdRVkJHU1RRck1XWTRWR0paVEU1S1owODJZbk5rZFZWc1VWcHdOWGRwT1hGUFEwSmphM2RuWjFoR1RVRTBSd3BCTVZWa1JIZEZRaTkzVVVWQmQwbElaMFJCVkVKblRsWklVMVZGUkVSQlMwSm5aM0pDWjBWR1FsRmpSRUY2UVdSQ1owNVdTRkUwUlVablVWVnhNa3BpQ2pKMVJtVlVUekkxYW14UFJXeFJPVGN4Y0ZGa2FWTTRkMGgzV1VSV1VqQnFRa0puZDBadlFWVXpPVkJ3ZWpGWmEwVmFZalZ4VG1wd1MwWlhhWGhwTkZrS1drUTRkMXBuV1VSV1VqQlNRVkZJTDBKR2QzZFhiMXBaWVVoU01HTklUVFpNZVRsdVlWaFNiMlJYU1hWWk1qbDBUREkxY0dKWFNqRmplVEZvV2pKV2RRcGtRemwxWVZjeGFXUllUWFJqTWxKeVRIazFibUZZVW05a1YwbDJaREk1ZVdFeVduTmlNMlI2VEROS2JHSkhWbWhqTWxWMVpWY3hjMUZJU214YWJrMTJDbUZIVm1oYVNFMTJZbGRHY0dKcVFUVkNaMjl5UW1kRlJVRlpUeTlOUVVWQ1FrTjBiMlJJVW5kamVtOTJURE5TZG1FeVZuVk1iVVpxWkVkc2RtSnVUWFVLV2pKc01HRklWbWxrV0U1c1kyMU9kbUp1VW14aWJsRjFXVEk1ZEUxQ1NVZERhWE5IUVZGUlFtYzNPSGRCVVVsRlFraENNV015WjNkT1oxbExTM2RaUWdwQ1FVZEVkbnBCUWtGM1VXOU9NbFV4V1ZSUk1WcHFUWGxPVjFFeFQwUm9hRTFIU1hsTlYxWnBUbGRWZUU1NlJUUlpWRTE0V1hwU2Fsa3lTWHBOUkZwcUNsbHFRVlpDWjI5eVFtZEZSVUZaVHk5TlFVVkZRa0ZrVTFwWGVHeFpXRTVzVFVOVlIwTnBjMGRCVVZGQ1p6YzRkMEZSVlVWR01qVndZbGRLTVdONU1XZ0tXakpXZFdSRE9YVmhWekZwWkZoTmRHTXlVbkpOUWpCSFEybHpSMEZSVVVKbk56aDNRVkZaUlVRelNteGFiazEyWVVkV2FGcElUWFppVjBad1ltcEJOd3BDWjI5eVFtZEZSVUZaVHk5TlFVVkpRa013VFVzeWFEQmtTRUo2VDJrNGRtUkhPWEphVnpSMVdWZE9NR0ZYT1hWamVUVnVZVmhTYjJSWFNqRmpNbFo1Q2xreU9YVmtSMVoxWkVNMWFtSXlNSGRoUVZsTFMzZFpRa0pCUjBSMmVrRkNRMUZTWVVSR2FHOWtTRkozWTNwdmRrd3laSEJrUjJneFdXazFhbUl5TUhZS1ltMXNkRmx1Vm5wTVYwWnVXbGMxTUV3eU5YQmlWMG94WTNreGVscEhjM1pNYldSd1pFZG9NVmxwT1ROaU0wcHlXbTE0ZG1RelRYWmpiVlp6V2xkR2VncGFVelUxWWxkNFFXTnRWbTFqZVRsdldsZEdhMk41T1hSWlYyeDFUVVJuUjBOcGMwZEJVVkZDWnpjNGQwRlJiMFZMWjNkdlRqSlZNVmxVVVRGYWFrMTVDazVYVVRGUFJHaG9UVWRKZVUxWFZtbE9WMVY0VG5wRk5GbFVUWGhaZWxKcVdUSkplazFFV21wWmFrRmtRbWR2Y2tKblJVVkJXVTh2VFVGRlRFSkJPRTBLUkZka2NHUkhhREZaYVRGdllqTk9NRnBYVVhkUFoxbExTM2RaUWtKQlIwUjJla0ZDUkVGUmMwUkRjRzlrU0ZKM1kzcHZka3d5WkhCa1IyZ3hXV2sxYWdwaU1qQjJZbTFzZEZsdVZucE1WMFp1V2xjMU1Fd3lOWEJpVjBveFkza3hlbHBIYzNkUFFWbExTM2RaUWtKQlIwUjJla0ZDUkZGUmNVUkRaek5hVkZab0NrNUVWbTFOZWtreFdrUlZORTlIUlhkWmFrbDRXbGRKTVZwVVJUTk5WR2hvVFhwR2FrNUhUbXBaYWsxM1RtMU9hVTFDT0VkRGFYTkhRVkZSUW1jM09IY0tRVkUwUlVWUmQxQmpiVlp0WTNrNWIxcFhSbXRqZVRsMFdWZHNkVTFDYjBkRGFYTkhRVkZSUW1jM09IZEJVVGhGUkVGM1MwMVVUWGROVkdjMVRWUkpNZ3BOYWtGMlFtZHZja0puUlVWQldVOHZUVUZGVVVKRFJVMUlNbWd3WkVoQ2VrOXBPSFphTW13d1lVaFdhVXh0VG5aaVV6bDFZVmN4YVdSWVRYUlpWMlJzQ21KdVVYZEhVVmxMUzNkWlFrSkJSMFIyZWtGQ1JWRlJURVJCYTNsUFJFVjZUMFJaZUUxcVZYZGhRVmxMUzNkWlFrSkJSMFIyZWtGQ1JXZFNZVVJHYUc4S1pFaFNkMk42YjNaTU1tUndaRWRvTVZscE5XcGlNakIyWW0xc2RGbHVWbnBNVjBadVdsYzFNRXd5TlhCaVYwb3hZM2t4ZWxwSGMzWk1iV1J3WkVkb01RcFphVGt6WWpOS2NscHRlSFprTTAxMlkyMVdjMXBYUm5wYVV6VTFZbGQ0UVdOdFZtMWplVGx2V2xkR2EyTjVPWFJaVjJ4MVRVUm5SME5wYzBkQlVWRkNDbWMzT0hkQlVrMUZTMmQzYjA0eVZURlpWRkV4V21wTmVVNVhVVEZQUkdob1RVZEplVTFYVm1sT1YxVjRUbnBGTkZsVVRYaFplbEpxV1RKSmVrMUVXbW9LV1dwQlZVSm5iM0pDWjBWRlFWbFBMMDFCUlZWQ1FWbE5Ra2hDTVdNeVozZFlaMWxMUzNkWlFrSkJSMFIyZWtGQ1JsRlNVVVJGTlc5a1NGSjNZM3B2ZGdwTU1tUndaRWRvTVZscE5XcGlNakIyWW0xc2RGbHVWbnBNVjBadVdsYzFNRXd5TlhCaVYwb3hZM2t4ZWxwSGMzWlpWMDR3WVZjNWRXTjVPWGxrVnpWNkNreDZTVFZPUkUwMVQwUkpORTVVVVRGTU1rWXdaRWRXZEdOSVVucE1la2wzUm1kWlMwdDNXVUpDUVVkRWRucEJRa1puVVVsRVFWcDNaRmRLYzJGWFRYY0tWbEZaUzB0M1dVSkNRVWRFZG5wQlFrZEJVa2hFUlZaNVdsaENkazl0TlhCaVYwb3hZM2t4YUZveVZuVmtSVUY1VDBSRmVrOUVXWGhOYWxWMlltMXNkQXBaYmxaNlRGaE9hMkV3UVhoTmVrRjRUMFJyZUUxcVdYbFBia3BzV21wd2VWcFhXbnBNTW1oc1dWZFNla3d5TVdoaFZ6UjNaMWxyUjBOcGMwZEJVVkZDQ2pGdWEwTkNRVWxGWlhkU05VRklZMEZrVVVSa1VGUkNjWGh6WTFKTmJVMWFTR2g1V2xwNlkwTnZhM0JsZFU0ME9ISm1LMGhwYmt0QlRIbHVkV3BuUVVFS1FWbzVia0p4V0U5QlFVRkZRWGRDUjAxRlVVTkpRM1ZYYzNFcmVHVkJjWHA2WkZSQmIwcHpaMUl6Y25keVIySm5UVUZxV0hVMlUxRnhWMlEzZUhWeU9RcEJhVUl6VjJoVE5WSmljelpTUzNJd2J5OVdOMDVuZFU5a05sVk1ObTVJZW5CTVdtWnVPRXhRZVZCeVEweEVRVXRDWjJkeGFHdHFUMUJSVVVSQmQwNXdDa0ZFUW0xQmFrVkJlWFYwYTBOeWJHbHFOMjVIVkRkd1JVcFBUMlVyYWt0bGFrMVZVR0pRVDFSQ01HeE5UREJYVm05ek5qVmxSVVUwYlZkd1VsaFhibkFLTmxaU1FUWnZVM1pCYWtWQk1VSllNelo0YTIxQ1MwRjNjR3BKV25sTWFHOXpkRFF5VWtJclVGRnVUMlozY0hReGJUaEhSSHBpVUVkRllreGlPVFZoUmdwa2FrNXBlVU5UYmpKdWFFWUtMUzB0TFMxRlRrUWdRMFZTVkVsR1NVTkJWRVV0TFMwdExRbz0ifV19fQ=="}],"timestampVerificationData":{"rfc3161Timestamps":[]}},"dsseEnvelope":{"payload":"eyJfdHlwZSI6Imh0dHBzOi8vaW4tdG90by5pby9TdGF0ZW1lbnQvdjEiLCJzdWJqZWN0IjpbeyJuYW1lIjoicGtnOm5wbS8lNDBuaW1idXMtZGV2L3Nka0AxLjMuMCIsImRpZ2VzdCI6eyJzaGE1MTIiOiI5ODI1NjUzMGI2ZDg1MjhjMTEyZWNjMzU3NDA3MDNhN2FkNDBjYTgyZDI1YmE4NmI3ZGI3NmIyNjI5YmI4NjhkZTQ4M2JmMmIyNjU0YjQwZDhhY2FhNmE0MzE2ZTc4M2ExM2NkY2RiMWMyNjQ1MDc5NDgxNWE5ZjFhNzQ4NjllNiJ9fV0sInByZWRpY2F0ZVR5cGUiOiJodHRwczovL3Nsc2EuZGV2L3Byb3ZlbmFuY2UvdjEiLCJwcmVkaWNhdGUiOnsiYnVpbGREZWZpbml0aW9uIjp7ImJ1aWxkVHlwZSI6Imh0dHBzOi8vc2xzYS1mcmFtZXdvcmsuZ2l0aHViLmlvL2dpdGh1Yi1hY3Rpb25zLWJ1aWxkdHlwZXMvd29ya2Zsb3cvdjEiLCJleHRlcm5hbFBhcmFtZXRlcnMiOnsid29ya2Zsb3ciOnsicmVmIjoicmVmcy9oZWFkcy9tYWluIiwicmVwb3NpdG9yeSI6Imh0dHBzOi8vZ2l0aHViLmNvbS9uaW1idXMtYWdlbnQvbmltYnVzLXNkayIsInBhdGgiOiIuZ2l0aHViL3dvcmtmbG93cy9yZWxlYXNlLnltbCJ9fSwiaW50ZXJuYWxQYXJhbWV0ZXJzIjp7ImdpdGh1YiI6eyJldmVudF9uYW1lIjoicHVzaCIsInJlcG9zaXRvcnlfaWQiOiIxMzAxODkxMjYyIiwicmVwb3NpdG9yeV9vd25lcl9pZCI6IjI4MTM4NjEyNSJ9fSwicmVzb2x2ZWREZXBlbmRlbmNpZXMiOlt7InVyaSI6ImdpdCtodHRwczovL2dpdGh1Yi5jb20vbmltYnVzLWFnZW50L25pbWJ1cy1zZGtAcmVmcy9oZWFkcy9tYWluIiwiZGlnZXN0Ijp7ImdpdENvbW1pdCI6IjdlNWE0NWYzMjVkNTg4YTBiMjFlYjVlMTcxOGEzMWM0Y2NiMzA2Y2IifX1dfSwicnVuRGV0YWlscyI6eyJidWlsZGVyIjp7ImlkIjoiaHR0cHM6Ly9naXRodWIuY29tL2FjdGlvbnMvcnVubmVyL2dpdGh1Yi1ob3N0ZWQifSwibWV0YWRhdGEiOnsiaW52b2NhdGlvbklkIjoiaHR0cHM6Ly9naXRodWIuY29tL25pbWJ1cy1hZ2VudC9uaW1idXMtc2RrL2FjdGlvbnMvcnVucy8yOTQzOTgyODU0NS9hdHRlbXB0cy8yIn19fX0=","payloadType":"application/vnd.in-toto+json","signatures":[{"sig":"MEUCIQDUKQNEjofQ25ACMLGQARkPNWG2AL2blF7NOc/X7FsGPwIgDCOFDzuJFQOmRsOUrw34wusKeTKOVNzfnIY2mnuQfJo=","keyid":""}]}},"signedAccessSignatureUrl":""}]} \ No newline at end of file diff --git a/actions/verify-npm-provenance/test/main.test.js b/actions/verify-npm-provenance/test/main.test.js new file mode 100644 index 0000000..14727ef --- /dev/null +++ b/actions/verify-npm-provenance/test/main.test.js @@ -0,0 +1,33 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { decide, exitCodeFor } from "../src/main.js"; + +test("absent attestation is missing-provenance", () => { + const r = decide({ outcome: "absent", detail: "404 after full backoff" }, { repo: "a/b" }); + assert.equal(r.status, "missing-provenance"); +}); + +test("registry error is indeterminate, not a false failure claim", () => { + const r = decide({ outcome: "error", detail: "HTTP 503" }, { repo: "a/b" }); + assert.equal(r.status, "indeterminate"); +}); + +test("gate severity fails on anything other than ok", () => { + assert.equal(exitCodeFor("ok", "gate"), 0); + assert.equal(exitCodeFor("missing-provenance", "gate"), 1); + assert.equal(exitCodeFor("source-mismatch", "gate"), 1); + assert.equal(exitCodeFor("indeterminate", "gate"), 1); +}); + +test("monitor severity never fails the job — the caller classifies", () => { + assert.equal(exitCodeFor("ok", "monitor"), 0); + assert.equal(exitCodeFor("missing-provenance", "monitor"), 0); + assert.equal(exitCodeFor("indeterminate", "monitor"), 0); +}); + +test("same input yields different severity, same status", () => { + const input = { outcome: "error", detail: "HTTP 503" }; + const status = decide(input, { repo: "a/b" }).status; + assert.equal(exitCodeFor(status, "gate"), 1); + assert.equal(exitCodeFor(status, "monitor"), 0); +});