From 247e9206260c0d2e5246a17c0591403e6af1a12d Mon Sep 17 00:00:00 2001 From: Hyo Date: Fri, 14 Aug 2026 01:31:45 +0900 Subject: [PATCH] fix(security): close SBOM verification gaps Repair the React Native 16.3.0 SBOM only when its exact legacy digest is present. Verify existing assets against release identity, the GitHub digest, and exact main-workflow provenance before preserving them. Keep staged repair markers across races and align the public verification guidance. --- .claude/commands/audit-security.md | 54 +++- .github/workflows/sbom.yml | 107 +++---- SECURITY.md | 6 +- .../docs/src/pages/docs/security/sbom.tsx | 7 +- scripts/audit-security.test.mjs | 19 +- scripts/generate-sbom.mjs | 295 +++++++++++++++++- scripts/generate-sbom.test.mjs | 217 +++++++++++-- security/SBOM.md | 27 +- 8 files changed, 612 insertions(+), 120 deletions(-) diff --git a/.claude/commands/audit-security.md b/.claude/commands/audit-security.md index fb710d69b..c4ad32010 100644 --- a/.claude/commands/audit-security.md +++ b/.claude/commands/audit-security.md @@ -118,7 +118,51 @@ diff "$SECURITY_AUDIT_ROOT"/core-a/openiap-google-*.cdx.json \ "$SECURITY_AUDIT_ROOT"/core-b/openiap-google-*.cdx.json ``` -## 6. Workflow permissions and injection +## 6. Published current-release assets + +Fresh local output does not prove that the public release asset is complete. +Download every newest component SBOM, bind it to the release tag and API digest, +validate its schema, and verify its exact signing workflow: + +```bash +set -euo pipefail +git fetch origin main --tags +gh api --paginate --slurp \ + "repos/hyodotdev/openiap/releases?per_page=100" \ + > "$SECURITY_AUDIT_ROOT/releases.json" + +node scripts/generate-sbom.mjs missing-release-tags \ + "$SECURITY_AUDIT_ROOT/releases.json" \ + > "$SECURITY_AUDIT_ROOT/missing-tags.txt" +if [ -s "$SECURITY_AUDIT_ROOT/missing-tags.txt" ]; then + cat "$SECURITY_AUDIT_ROOT/missing-tags.txt" + exit 1 +fi + +node scripts/generate-sbom.mjs latest-release-assets \ + "$SECURITY_AUDIT_ROOT/releases.json" \ + > "$SECURITY_AUDIT_ROOT/latest-assets.tsv" +mkdir -p "$SECURITY_AUDIT_ROOT/published" +while IFS=$'\t' read -r tag name digest; do + gh release download "$tag" --repo hyodotdev/openiap \ + -p "$name" -D "$SECURITY_AUDIT_ROOT/published" || exit 1 + file="$SECURITY_AUDIT_ROOT/published/$name" + node scripts/generate-sbom.mjs verify-file "$file" \ + --tag "$tag" --digest "$digest" || exit 1 + cyclonedx validate --input-file "$file" --input-format json \ + --input-version v1_6 --fail-on-errors || exit 1 + cert_identity=https://github.com/hyodotdev/openiap + cert_identity="$cert_identity/.github/workflows/sbom.yml@refs/heads/main" + gh attestation verify "$file" --repo hyodotdev/openiap \ + --cert-identity "$cert_identity" \ + --deny-self-hosted-runners || exit 1 +done < "$SECURITY_AUDIT_ROOT/latest-assets.tsv" +``` + +An existing asset without the required generator commit is a failure even when +its dependency list, schema, and attestation are otherwise valid. + +## 7. Workflow permissions and injection Least privilege, and no untrusted value interpolated into a shell command: @@ -142,14 +186,14 @@ gh api repos/hyodotdev/openiap/dependency-graph/sbom || \ Pass values through `env:` instead of interpolating them. OpenSSF Scorecard's Dangerous-Workflow check flags the same pattern. -## 7. Generated SBOMs stay out of git +## 8. Generated SBOMs stay out of git ```bash git check-ignore -v sbom/ && echo "ignored" || echo "GAP: sbom/ is committable" git ls-files '*.cdx.json' | head # must be empty ``` -## 8. Documentation matches the code +## 9. Documentation matches the code Documentation drift is the most common finding, because prose has no compiler. @@ -174,7 +218,7 @@ Also check for **hardcoded counts** — "nine workflows", "43 of 47 dependencies". They are true on the day they are written and wrong later. Prefer a described property or a command that prints the live number. -## 9. Release integrity still holds +## 10. Release integrity still holds ```bash node --test scripts/release-branch-policy.test.mjs \ @@ -183,7 +227,7 @@ node --test scripts/release-branch-policy.test.mjs \ node scripts/release-branch-policy.mjs audit ``` -## 10. Report +## 11. Report State each check as pass, gap, or not-applicable with the command output that justifies it. For every gap, either fix it in the same pass or record why it is diff --git a/.github/workflows/sbom.yml b/.github/workflows/sbom.yml index e25e4cd09..3b67015f1 100644 --- a/.github/workflows/sbom.yml +++ b/.github/workflows/sbom.yml @@ -9,8 +9,6 @@ name: "Security: SBOM" # any missed dispatch for the newest release of each component. on: - release: - types: [published] push: branches: [main] paths: @@ -27,7 +25,7 @@ on: type: string concurrency: - group: sbom-${{ github.event.release.tag_name || inputs.tag }} + group: sbom-${{ inputs.tag || github.ref }} cancel-in-progress: false permissions: @@ -67,7 +65,9 @@ jobs: sbom: name: Generate and publish SBOM - if: github.event_name == 'release' || github.event_name == 'workflow_dispatch' + if: >- + github.event_name == 'workflow_dispatch' && + github.ref == format('refs/heads/{0}', github.event.repository.default_branch) runs-on: ubuntu-latest permissions: contents: write # upload the SBOM as a release asset @@ -77,7 +77,7 @@ jobs: - name: Resolve release tag id: tag env: - RELEASE_TAG: ${{ github.event.release.tag_name || inputs.tag }} + RELEASE_TAG: ${{ inputs.tag }} run: | if [ -z "$RELEASE_TAG" ]; then echo "::error::No release tag available" @@ -126,6 +126,7 @@ jobs: id: existing if: ${{ steps.component.outputs.matched == 'true' }} env: + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} REPAIR_DIGEST: ${{ steps.component.outputs.repair-digest }} RELEASE_TAG: ${{ steps.tag.outputs.tag }} @@ -142,18 +143,36 @@ jobs: STAGED_ASSET=$(jq -c --arg name "$SBOM_NAME.replacement" \ '[.assets[] | select(.name == $name)][0] // empty' \ <<< "$RELEASE_JSON") - if [ -n "$REPAIR_DIGEST" ] && [ -n "$STAGED_ASSET" ]; then + ASSET_DIGEST=$(jq -r '.digest // ""' <<< "$ASSET") + if [ -n "$ASSET" ] && \ + { [ -z "$REPAIR_DIGEST" ] || [ "$ASSET_DIGEST" != "$REPAIR_DIGEST" ]; }; then + ASSET_ID=$(jq -r '.id' <<< "$ASSET") + EXISTING_FILE="$RUNNER_TEMP/$SBOM_NAME" + gh api "repos/$GITHUB_REPOSITORY/releases/assets/$ASSET_ID" \ + -H "Accept: application/octet-stream" > "$EXISTING_FILE" + node scripts/generate-sbom.mjs verify-file "$EXISTING_FILE" \ + --tag "$RELEASE_TAG" --digest "$ASSET_DIGEST" + CERT_IDENTITY="https://github.com/$GITHUB_REPOSITORY/.github/workflows/sbom.yml@refs/heads/$DEFAULT_BRANCH" + gh attestation verify "$EXISTING_FILE" \ + --repo "$GITHUB_REPOSITORY" \ + --cert-identity "$CERT_IDENTITY" \ + --deny-self-hosted-runners + if [ -n "$REPAIR_DIGEST" ] && [ -n "$STAGED_ASSET" ]; then + STAGED_ASSET_ID=$(jq -r '.id' <<< "$STAGED_ASSET") + gh api --method DELETE \ + "repos/$GITHUB_REPOSITORY/releases/assets/$STAGED_ASSET_ID" + echo "::notice::Removed a stale staged repair after verifying $SBOM_NAME." + fi + echo "exists=true" >> "$GITHUB_OUTPUT" + echo "::notice::$SBOM_NAME is verified and will be preserved." + elif [ -n "$REPAIR_DIGEST" ] && [ -n "$STAGED_ASSET" ]; then echo "exists=false" >> "$GITHUB_OUTPUT" echo "::notice::A staged legacy repair will be reconciled." elif [ -z "$ASSET" ]; then echo "exists=false" >> "$GITHUB_OUTPUT" - elif [ -n "$REPAIR_DIGEST" ] && \ - [ "$(jq -r '.digest // ""' <<< "$ASSET")" = "$REPAIR_DIGEST" ]; then - echo "exists=false" >> "$GITHUB_OUTPUT" - echo "::notice::$SBOM_NAME matches a known inaccurate legacy digest and will be replaced once." else - echo "exists=true" >> "$GITHUB_OUTPUT" - echo "::notice::$SBOM_NAME is already attached; preserving it." + echo "exists=false" >> "$GITHUB_OUTPUT" + echo "::notice::$SBOM_NAME matches a guarded legacy digest and will be replaced once." fi # CI tests the generator and its historical fixtures in their owning tree. @@ -196,52 +215,12 @@ jobs: if: ${{ steps.component.outputs.matched == 'true' && steps.existing.outputs.exists == 'false' }} env: SBOM_FILE: ${{ steps.generate.outputs.sbom-file }} - EXPECTED_VERSION: ${{ steps.component.outputs.version }} EXPECTED_GENERATOR_COMMIT: ${{ steps.generator.outputs.commit }} RELEASE_TAG: ${{ steps.tag.outputs.tag }} run: | - # A version mismatch means the tag and the manifest disagree, which - # would attach a misleading inventory to a real release. - ACTUAL_VERSION=$(jq -r '.metadata.component.version' "$SBOM_FILE") - ACTUAL_TAG=$(jq -r ' - .metadata.component.properties[] - | select(.name == "openiap:release:tag") | .value - ' "$SBOM_FILE") - ACTUAL_COMMIT=$(jq -r ' - .metadata.component.properties[] - | select(.name == "openiap:release:commit") | .value - ' "$SBOM_FILE") - ACTUAL_GENERATOR_COMMIT=$(jq -r ' - .metadata.tools.components[] - | select(.name == "openiap-sbom-generator") - | .properties[] - | select(.name == "openiap:generator:commit") | .value - ' "$SBOM_FILE") - - if [ "$ACTUAL_VERSION" != "$EXPECTED_VERSION" ]; then - echo "::error::SBOM version $ACTUAL_VERSION does not match tag version $EXPECTED_VERSION" - exit 1 - fi - if [ "$ACTUAL_TAG" != "$RELEASE_TAG" ]; then - echo "::error::SBOM tag $ACTUAL_TAG does not match release tag $RELEASE_TAG" - exit 1 - fi - if [ "$ACTUAL_COMMIT" != "$(git rev-parse HEAD)" ]; then - echo "::error::SBOM commit $ACTUAL_COMMIT does not match the released commit" - exit 1 - fi - if [ "$ACTUAL_GENERATOR_COMMIT" != "$EXPECTED_GENERATOR_COMMIT" ]; then - echo "::error::SBOM generator $ACTUAL_GENERATOR_COMMIT does not match $EXPECTED_GENERATOR_COMMIT" - exit 1 - fi - - # Fail closed if a local path ever reaches a published document. - if grep -qE '/Users/|/home/[a-z]|/tmp/' "$SBOM_FILE"; then - echo "::error::SBOM contains a local filesystem path" - exit 1 - fi - - echo "SBOM verified for $RELEASE_TAG at $ACTUAL_COMMIT" + node scripts/generate-sbom.mjs verify-file "$SBOM_FILE" \ + --tag "$RELEASE_TAG" \ + --generator-commit "$EXPECTED_GENERATOR_COMMIT" - name: Attest SBOM provenance if: ${{ steps.component.outputs.matched == 'true' && steps.existing.outputs.exists == 'false' }} @@ -293,15 +272,6 @@ jobs: return 1 } - if [ -n "$CANONICAL_ASSET_ID" ] && [ "$CANONICAL_DIGEST" != "$REPAIR_DIGEST" ]; then - if [ -n "$STAGED_ASSET_ID" ]; then - gh api --method DELETE \ - "repos/$GITHUB_REPOSITORY/releases/assets/$STAGED_ASSET_ID" - fi - echo "::notice::$SBOM_NAME is already corrected; any staged repair was removed." - exit 0 - fi - if [ -z "$CANONICAL_ASSET_ID" ]; then if [ -n "$STAGED_ASSET_ID" ] && [ "$STAGED_DIGEST" = "$LOCAL_DIGEST" ]; then finalize_staged_asset @@ -335,13 +305,16 @@ jobs: exit 1 fi + if [ "$CANONICAL_DIGEST" != "$REPAIR_DIGEST" ]; then + echo "::error::$SBOM_NAME changed during repair; leaving the staged asset for verified reconciliation." + exit 1 + fi + CURRENT_DIGEST=$(gh api \ "repos/$GITHUB_REPOSITORY/releases/assets/$CANONICAL_ASSET_ID" \ --jq '.digest // ""') if [ "$CURRENT_DIGEST" != "$REPAIR_DIGEST" ]; then - gh api --method DELETE \ - "repos/$GITHUB_REPOSITORY/releases/assets/$STAGED_ASSET_ID" - echo "::error::The legacy SBOM asset changed during repair; refusing to replace it." + echo "::error::The legacy SBOM asset changed during repair; leaving the staged asset for verified reconciliation." exit 1 fi gh api --method DELETE \ diff --git a/SECURITY.md b/SECURITY.md index 255877f83..0e9f744c2 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -112,7 +112,11 @@ check whether a specific version declares a given dependency: ```bash gh release download react-native-iap-16.3.0 \ --repo hyodotdev/openiap -p '*.cdx.json' -gh attestation verify react-native-iap-16.3.0.cdx.json --repo hyodotdev/openiap +CERT_IDENTITY=https://github.com/hyodotdev/openiap +CERT_IDENTITY="$CERT_IDENTITY/.github/workflows/sbom.yml@refs/heads/main" +gh attestation verify react-native-iap-16.3.0.cdx.json \ + --repo hyodotdev/openiap --cert-identity "$CERT_IDENTITY" \ + --deny-self-hosted-runners ``` - [`security/SBOM.md`](security/SBOM.md) — what the SBOMs cover, how they are diff --git a/packages/docs/src/pages/docs/security/sbom.tsx b/packages/docs/src/pages/docs/security/sbom.tsx index 6649d10e5..90074fad0 100644 --- a/packages/docs/src/pages/docs/security/sbom.tsx +++ b/packages/docs/src/pages/docs/security/sbom.tsx @@ -208,8 +208,11 @@ flutter_inapp_purchase-10.3.0.cdx.json`} OpenIAP's CI produced it rather than trusting the file on sight:

-          {`gh attestation verify react-native-iap-16.3.0.cdx.json \\
-  --repo hyodotdev/openiap`}
+          {`CERT_IDENTITY=https://github.com/hyodotdev/openiap
+CERT_IDENTITY="$CERT_IDENTITY/.github/workflows/sbom.yml@refs/heads/main"
+gh attestation verify react-native-iap-16.3.0.cdx.json \\
+  --repo hyodotdev/openiap --cert-identity "$CERT_IDENTITY" \\
+  --deny-self-hosted-runners`}
         

And validate it against the CycloneDX schema:

diff --git a/scripts/audit-security.test.mjs b/scripts/audit-security.test.mjs
index 0df455985..d9f6eb26e 100644
--- a/scripts/audit-security.test.mjs
+++ b/scripts/audit-security.test.mjs
@@ -1,5 +1,5 @@
 import assert from "node:assert/strict";
-import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
+import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
 import { tmpdir } from "node:os";
 import { resolve } from "node:path";
 import test from "node:test";
@@ -63,3 +63,20 @@ test("empty workflow scans fail instead of reporting a vacuous pass", async (t)
 test("empty URL extraction is explicit", () => {
   assert.deepEqual(extractExternalUrls("no links"), []);
 });
+
+test("published SBOM audit fails fast and trusts only main", () => {
+  const source = readFileSync(
+    new URL("../.claude/commands/audit-security.md", import.meta.url),
+    "utf8",
+  );
+  const block = source.match(
+    /## 6\. Published current-release assets[\s\S]*?```bash\n([\s\S]*?)```/u,
+  )?.[1];
+
+  assert.ok(block, "published asset audit command is missing");
+  assert.match(block, /^set -euo pipefail$/mu);
+  assert.match(block, /@refs\/heads\/main/u);
+  assert.match(block, /--cert-identity "\$cert_identity"/u);
+  assert.match(block, /--deny-self-hosted-runners \|\| exit 1/u);
+  assert.doesNotMatch(block, /--signer-workflow/u);
+});
diff --git a/scripts/generate-sbom.mjs b/scripts/generate-sbom.mjs
index 42a72bc00..11929d72d 100644
--- a/scripts/generate-sbom.mjs
+++ b/scripts/generate-sbom.mjs
@@ -26,7 +26,7 @@
 import { execFileSync } from "node:child_process";
 import { createHash } from "node:crypto";
 import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
-import { dirname, resolve } from "node:path";
+import { basename, dirname, resolve } from "node:path";
 import { fileURLToPath } from "node:url";
 
 import { PACKAGE_CONFIG } from "./assert-release-tag.mjs";
@@ -52,11 +52,15 @@ const GENERATOR_VERSION = "1.0.0";
 const SPEC_VERSION = "1.6";
 export const PUBLISHED_METADATA_UNAVAILABLE_EXIT_CODE = 75;
 
-const INACCURATE_SBOM_DIGESTS = new Map([
+const LEGACY_SBOM_REPAIR_DIGESTS = new Map([
   [
     "google-3.3.0",
     "sha256:7256739c147689fbbb1257a738f85e7d030bb3612fd52a903c4cc9ca72b00e66",
   ],
+  [
+    "react-native-iap-16.3.0",
+    "sha256:f93f56530a9042d8c31289bfac765d295d6549701e0fbcedce395c55c0191a1d",
+  ],
 ]);
 
 /**
@@ -232,11 +236,11 @@ export function sbomFileName(componentId, version) {
   return `${COMPONENTS[componentId].sbomName}-${version}.cdx.json`;
 }
 
-export function inaccurateSbomDigestForTag(tag) {
-  return INACCURATE_SBOM_DIGESTS.get(tag) ?? "";
+export function repairSbomDigestForTag(tag) {
+  return LEGACY_SBOM_REPAIR_DIGESTS.get(tag) ?? "";
 }
 
-/** Return newest missing releases plus every known-inaccurate legacy SBOM. */
+/** Return newest missing releases plus every approved legacy repair. */
 export function findMissingLatestSbomTags(releases) {
   const seen = new Set();
   const missing = [];
@@ -255,23 +259,59 @@ export function findMissingLatestSbomTags(releases) {
     const stagedAsset = assets.find(
       (entry) => entry?.name === `${expected}.replacement`,
     );
-    const inaccurateDigest = inaccurateSbomDigestForTag(release.tag_name);
+    const repairDigest = repairSbomDigestForTag(release.tag_name);
 
     // Legacy repairs remain eligible even after a newer component release.
     if (
-      inaccurateDigest &&
-      (stagedAsset || !asset || asset.digest === inaccurateDigest)
+      repairDigest &&
+      (stagedAsset || !asset || asset.digest === repairDigest)
     ) {
       missing.push(release.tag_name);
     }
 
     if (seen.has(resolvedTag.componentId)) continue;
     seen.add(resolvedTag.componentId);
-    if (!asset && !inaccurateDigest) missing.push(release.tag_name);
+    if (!asset && !repairDigest) missing.push(release.tag_name);
   }
   return missing;
 }
 
+/** Return the expected SBOM asset for the newest release of each component. */
+export function latestSbomAssets(releases) {
+  const seen = new Set();
+  const assets = [];
+  const newestFirst = releases
+    .filter((release) => !release?.draft && release?.published_at)
+    .sort(
+      (left, right) =>
+        Date.parse(right.published_at) - Date.parse(left.published_at),
+    );
+
+  for (const release of newestFirst) {
+    const resolvedTag = componentFromTag(release.tag_name);
+    if (!resolvedTag || seen.has(resolvedTag.componentId)) continue;
+    seen.add(resolvedTag.componentId);
+    const fileName = sbomFileName(resolvedTag.componentId, resolvedTag.version);
+    const asset = (release.assets ?? []).find(
+      (entry) => entry?.name === fileName,
+    );
+    if (!asset?.digest) {
+      throw new Error(`Missing published SBOM asset for ${release.tag_name}`);
+    }
+    assets.push({
+      componentId: resolvedTag.componentId,
+      tag: release.tag_name,
+      fileName,
+      digest: asset.digest,
+    });
+  }
+
+  if (assets.length === 0) {
+    throw new Error("No published component releases found");
+  }
+  return assets;
+}
+
 const TAG_VERSION_PLACEHOLDER = "9.8.7";
 
 /** Tag aliases are derived from the same package config release validation uses. */
@@ -539,6 +579,179 @@ export function buildSbom({
   };
 }
 
+function requiredProperty(properties, name, context) {
+  const values = (properties ?? [])
+    .filter((property) => property?.name === name)
+    .map((property) => property.value);
+  if (values.length !== 1 || !values[0]) {
+    throw new Error(`${context} must contain exactly one ${name} property`);
+  }
+  return values[0];
+}
+
+export function verifyPublishedSbom(
+  serialized,
+  { fileName, releaseTag, releaseCommit, generatorCommit, digest } = {},
+) {
+  const content = Buffer.isBuffer(serialized)
+    ? serialized
+    : Buffer.from(String(serialized), "utf8");
+  if (digest !== undefined) {
+    if (!/^sha256:[0-9a-f]{64}$/u.test(digest)) {
+      throw new Error(`Invalid published SBOM digest '${digest}'`);
+    }
+    const actual = `sha256:${createHash("sha256").update(content).digest("hex")}`;
+    if (actual !== digest) {
+      throw new Error(
+        `Published SBOM digest ${actual} does not match ${digest}`,
+      );
+    }
+  }
+
+  const document = JSON.parse(content.toString("utf8"));
+  const resolvedTag = componentFromTag(releaseTag);
+  if (!resolvedTag) {
+    throw new Error(`Unknown published SBOM release tag '${releaseTag}'`);
+  }
+  const definition = COMPONENTS[resolvedTag.componentId];
+  const expectedFileName = sbomFileName(
+    resolvedTag.componentId,
+    resolvedTag.version,
+  );
+  if (fileName && basename(fileName) !== expectedFileName) {
+    throw new Error(`Published SBOM file must be named ${expectedFileName}`);
+  }
+  if (!/^[0-9a-f]{40}$/u.test(releaseCommit ?? "")) {
+    throw new Error(`Invalid release commit '${releaseCommit ?? ""}'`);
+  }
+  if (
+    document.bomFormat !== "CycloneDX" ||
+    document.specVersion !== SPEC_VERSION
+  ) {
+    throw new Error(`Published SBOM must use CycloneDX ${SPEC_VERSION}`);
+  }
+  if (!document.metadata?.timestamp || !document.metadata?.authors?.length) {
+    throw new Error("Published SBOM must include a timestamp and author");
+  }
+
+  const root = document.metadata.component;
+  const expectedPurl = definition.purl(resolvedTag.version);
+  if (
+    root?.name !== definition.sbomName ||
+    root?.version !== resolvedTag.version ||
+    root?.purl !== expectedPurl ||
+    root?.["bom-ref"] !== expectedPurl
+  ) {
+    throw new Error(`Published SBOM identity does not match ${releaseTag}`);
+  }
+  const rootProperties = root.properties;
+  if (
+    requiredProperty(
+      rootProperties,
+      "openiap:release:tag",
+      "Published SBOM root",
+    ) !== releaseTag ||
+    requiredProperty(
+      rootProperties,
+      "openiap:release:commit",
+      "Published SBOM root",
+    ) !== releaseCommit ||
+    requiredProperty(
+      rootProperties,
+      "openiap:release:component",
+      "Published SBOM root",
+    ) !== resolvedTag.componentId
+  ) {
+    throw new Error(`Published SBOM properties do not match ${releaseTag}`);
+  }
+
+  const generators = (document.metadata.tools?.components ?? []).filter(
+    (component) => component?.name === GENERATOR_NAME,
+  );
+  if (generators.length !== 1) {
+    throw new Error(`Published SBOM must identify one ${GENERATOR_NAME}`);
+  }
+  const recordedGeneratorCommit = requiredProperty(
+    generators[0].properties,
+    "openiap:generator:commit",
+    "Published SBOM generator",
+  );
+  if (!/^[0-9a-f]{40}$/u.test(recordedGeneratorCommit)) {
+    throw new Error(
+      `Invalid SBOM generator commit '${recordedGeneratorCommit}'`,
+    );
+  }
+  if (
+    generatorCommit !== undefined &&
+    recordedGeneratorCommit !== generatorCommit
+  ) {
+    throw new Error(
+      `Published SBOM generator ${recordedGeneratorCommit} does not match ${generatorCommit}`,
+    );
+  }
+
+  if (!Array.isArray(document.components)) {
+    throw new Error("Published SBOM must contain a components array");
+  }
+  const componentRefs = new Set();
+  for (const component of document.components) {
+    if (
+      !component?.name ||
+      !component.version ||
+      !component.purl ||
+      component["bom-ref"] !== component.purl
+    ) {
+      throw new Error("Published SBOM contains an incomplete component");
+    }
+    if (componentRefs.has(component.purl)) {
+      throw new Error(`Duplicate published SBOM component ${component.purl}`);
+    }
+    componentRefs.add(component.purl);
+  }
+  const dependencyRows = document.dependencies ?? [];
+  const rootRows = dependencyRows.filter((row) => row?.ref === expectedPurl);
+  if (rootRows.length !== 1) {
+    throw new Error("Published SBOM must contain one root dependency row");
+  }
+  const rootDependencies = rootRows[0].dependsOn ?? [];
+  const rootDependencyRefs = new Set(rootDependencies);
+  if (
+    rootDependencies.length !== rootDependencyRefs.size ||
+    rootDependencyRefs.size !== componentRefs.size ||
+    [...rootDependencyRefs].some((ref) => !componentRefs.has(ref))
+  ) {
+    throw new Error("Published SBOM dependency graph is incomplete");
+  }
+  const knownRefs = new Set([expectedPurl, ...componentRefs]);
+  for (const row of dependencyRows) {
+    if (!knownRefs.has(row?.ref)) {
+      throw new Error(`Unknown published SBOM dependency row ${row?.ref}`);
+    }
+    if ((row.dependsOn ?? []).some((ref) => !knownRefs.has(ref))) {
+      throw new Error(`Unknown published SBOM dependency target in ${row.ref}`);
+    }
+  }
+  for (const ref of componentRefs) {
+    if (dependencyRows.filter((row) => row?.ref === ref).length !== 1) {
+      throw new Error(`Published SBOM dependency row is missing for ${ref}`);
+    }
+  }
+
+  if (
+    /\/Users\/|\/home\/[a-z]|\/tmp\/|ghp_|npm_[A-Za-z0-9]|BEGIN [A-Z ]*PRIVATE KEY/u.test(
+      content.toString("utf8"),
+    )
+  ) {
+    throw new Error("Published SBOM contains a local path or secret");
+  }
+
+  return {
+    componentId: resolvedTag.componentId,
+    version: resolvedTag.version,
+    generatorCommit: recordedGeneratorCommit,
+  };
+}
+
 async function fetchText(url) {
   const response = await fetch(url, {
     headers: { "user-agent": `${GENERATOR_NAME}/${GENERATOR_VERSION}` },
@@ -865,27 +1078,81 @@ function parseArguments(argv) {
 async function main() {
   const [maybeCommand] = process.argv.slice(2);
 
-  if (maybeCommand === "missing-release-tags") {
-    const path = process.argv[3];
-    if (!path)
-      throw new Error("Usage: generate-sbom.mjs missing-release-tags FILE");
+  const readReleaseList = (path) => {
+    if (!path) throw new Error(`Usage: generate-sbom.mjs ${maybeCommand} FILE`);
     const parsed = JSON.parse(readFileSync(path, "utf8"));
     const releases = Array.isArray(parsed?.[0]) ? parsed.flat() : parsed;
     if (!Array.isArray(releases)) {
       throw new Error(`Release list must be a JSON array: ${path}`);
     }
+    return releases;
+  };
+
+  if (maybeCommand === "missing-release-tags") {
+    const path = process.argv[3];
+    const releases = readReleaseList(path);
     const tags = findMissingLatestSbomTags(releases);
     process.stdout.write(tags.length > 0 ? `${tags.join("\n")}\n` : "");
     return;
   }
 
+  if (maybeCommand === "latest-release-assets") {
+    const assets = latestSbomAssets(readReleaseList(process.argv[3]));
+    process.stdout.write(
+      assets
+        .map((asset) => [asset.tag, asset.fileName, asset.digest].join("\t"))
+        .join("\n") + (assets.length > 0 ? "\n" : ""),
+    );
+    return;
+  }
+
+  if (maybeCommand === "verify-file") {
+    const path = process.argv[3];
+    if (!path) {
+      throw new Error(
+        "Usage: generate-sbom.mjs verify-file FILE --tag TAG [--digest SHA256] [--generator-commit SHA]",
+      );
+    }
+    const options = {};
+    const args = process.argv.slice(4);
+    for (let index = 0; index < args.length; index += 1) {
+      const argument = args[index];
+      if (!["--tag", "--digest", "--generator-commit"].includes(argument)) {
+        throw new Error(`Unknown verify-file option '${argument}'`);
+      }
+      const value = args[++index];
+      if (!value) throw new Error(`${argument} requires a value`);
+      if (argument === "--tag") options.tag = value;
+      if (argument === "--digest") options.digest = value;
+      if (argument === "--generator-commit") options.generatorCommit = value;
+    }
+    if (!options.tag) {
+      throw new Error("verify-file requires --tag");
+    }
+    const releaseCommit = defaultRunGit([
+      "rev-parse",
+      `${options.tag}^{commit}`,
+    ]);
+    const verified = verifyPublishedSbom(readFileSync(path), {
+      fileName: path,
+      releaseTag: options.tag,
+      releaseCommit,
+      generatorCommit: options.generatorCommit,
+      digest: options.digest,
+    });
+    console.log(
+      `Verified ${basename(path)} for ${options.tag} with generator ${verified.generatorCommit}`,
+    );
+    return;
+  }
+
   // `resolve-tag` lets a workflow map a published release back to its component
   // without duplicating the tag conventions in YAML.
   if (maybeCommand === "resolve-tag") {
     const tag = process.argv[3];
     const resolved = componentFromTag(tag);
     const line = resolved
-      ? `component=${resolved.componentId}\nversion=${resolved.version}\nsbom-name=${sbomFileName(resolved.componentId, resolved.version)}\nrepair-digest=${inaccurateSbomDigestForTag(tag)}\nmatched=true\n`
+      ? `component=${resolved.componentId}\nversion=${resolved.version}\nsbom-name=${sbomFileName(resolved.componentId, resolved.version)}\nrepair-digest=${repairSbomDigestForTag(tag)}\nmatched=true\n`
       : "matched=false\n";
     process.stdout.write(line);
     if (process.env.GITHUB_OUTPUT) {
diff --git a/scripts/generate-sbom.test.mjs b/scripts/generate-sbom.test.mjs
index a62c52625..1806517e4 100644
--- a/scripts/generate-sbom.test.mjs
+++ b/scripts/generate-sbom.test.mjs
@@ -1,4 +1,5 @@
 import assert from "node:assert/strict";
+import { createHash } from "node:crypto";
 import {
   mkdirSync,
   mkdtempSync,
@@ -18,14 +19,16 @@ import {
   componentFromTag,
   findMissingLatestSbomTags,
   generateSbom,
-  inaccurateSbomDigestForTag,
+  latestSbomAssets,
   listComponentIds,
   normalizeLicense,
   PUBLISHED_METADATA_UNAVAILABLE_EXIT_CODE,
   readComponentVersion,
   readVexStatements,
   releaseTagFor,
+  repairSbomDigestForTag,
   sbomFileName,
+  verifyPublishedSbom,
 } from "./generate-sbom.mjs";
 import { PACKAGE_CONFIG } from "./assert-release-tag.mjs";
 import {
@@ -244,19 +247,48 @@ test("backfill selects only the newest missing SBOM per component", () => {
   assert.deepEqual(findMissingLatestSbomTags(releases), ["kmp-iap-3.3.0"]);
 });
 
-test("backfill repairs only the exact known inaccurate SBOM", () => {
-  const tag = "google-3.3.0";
-  const name = "openiap-google-3.3.0.cdx.json";
+test("backfill repairs only exact guarded legacy SBOMs", () => {
   const published_at = "2026-08-11T00:00:00Z";
-  const inaccurate = inaccurateSbomDigestForTag(tag);
+  const repairs = [
+    [
+      "google-3.3.0",
+      "openiap-google-3.3.0.cdx.json",
+      "sha256:7256739c147689fbbb1257a738f85e7d030bb3612fd52a903c4cc9ca72b00e66",
+    ],
+    [
+      "react-native-iap-16.3.0",
+      "react-native-iap-16.3.0.cdx.json",
+      "sha256:f93f56530a9042d8c31289bfac765d295d6549701e0fbcedce395c55c0191a1d",
+    ],
+  ];
 
-  assert.match(inaccurate, /^sha256:[0-9a-f]{64}$/u);
-  assert.deepEqual(
-    findMissingLatestSbomTags([
-      { tag_name: tag, published_at, assets: [{ name, digest: inaccurate }] },
-    ]),
-    [tag],
-  );
+  for (const [tag, name, expectedDigest] of repairs) {
+    const repairDigest = repairSbomDigestForTag(tag);
+    assert.equal(repairDigest, expectedDigest);
+    assert.deepEqual(
+      findMissingLatestSbomTags([
+        {
+          tag_name: tag,
+          published_at,
+          assets: [{ name, digest: repairDigest }],
+        },
+      ]),
+      [tag],
+    );
+    assert.deepEqual(
+      findMissingLatestSbomTags([
+        {
+          tag_name: tag,
+          published_at,
+          assets: [{ name, digest: `sha256:${"0".repeat(64)}` }],
+        },
+      ]),
+      [],
+    );
+  }
+
+  const [tag, name] = repairs[0];
+  const repairDigest = repairSbomDigestForTag(tag);
   assert.deepEqual(
     findMissingLatestSbomTags([
       {
@@ -272,25 +304,54 @@ test("backfill repairs only the exact known inaccurate SBOM", () => {
   );
   assert.deepEqual(
     findMissingLatestSbomTags([
+      {
+        tag_name: "google-3.4.0",
+        published_at: "2026-08-12T00:00:00Z",
+        assets: [{ name: "openiap-google-3.4.0.cdx.json" }],
+      },
       {
         tag_name: tag,
         published_at,
-        assets: [{ name, digest: `sha256:${"0".repeat(64)}` }],
+        assets: [{ name, digest: repairDigest }],
       },
     ]),
-    [],
+    [tag],
   );
+});
+
+test("latest release inventory fails closed when an asset is missing", () => {
+  const published_at = "2026-08-11T00:00:00Z";
+  const digest = `sha256:${"1".repeat(64)}`;
   assert.deepEqual(
-    findMissingLatestSbomTags([
+    latestSbomAssets([
       {
-        tag_name: "google-3.4.0",
-        published_at: "2026-08-12T00:00:00Z",
-        assets: [{ name: "openiap-google-3.4.0.cdx.json" }],
+        tag_name: "google-3.3.0",
+        published_at,
+        assets: [
+          {
+            name: "openiap-google-3.3.0.cdx.json",
+            digest,
+          },
+        ],
       },
-      { tag_name: tag, published_at, assets: [{ name, digest: inaccurate }] },
     ]),
-    [tag],
+    [
+      {
+        componentId: "google",
+        tag: "google-3.3.0",
+        fileName: "openiap-google-3.3.0.cdx.json",
+        digest,
+      },
+    ],
+  );
+  assert.throws(
+    () =>
+      latestSbomAssets([
+        { tag_name: "google-3.3.0", published_at, assets: [] },
+      ]),
+    /Missing published SBOM asset/u,
   );
+  assert.throws(() => latestSbomAssets([]), /No published component releases/u);
 });
 
 test("every GitHub release workflow dispatches the SBOM workflow", () => {
@@ -316,6 +377,7 @@ test("every GitHub release workflow dispatches the SBOM workflow", () => {
     const dispatchCommand = source
       .slice(dispatchIndex)
       .match(/^gh workflow run sbom\.yml[^\n]*(?:\\\n\s+[^\n]*)*/u)?.[0];
+    assert.match(dispatchCommand, /--ref main/u, `${name} workflow ref`);
     assert.match(dispatchCommand, /-f tag="\$RELEASE_TAG"/u, `${name} tag`);
     assert.match(source, /actions: write/u, name);
   }
@@ -326,6 +388,11 @@ test("SBOM publication waits for registry propagation and repairs daily", () =>
     resolve(repoRoot, ".github/workflows/sbom.yml"),
     "utf8",
   );
+  assert.doesNotMatch(source, /^  release:$/mu);
+  assert.match(
+    source,
+    /github\.ref == format\('refs\/heads\/\{0\}', github\.event\.repository\.default_branch\)/u,
+  );
   assert.match(source, /cron: "23 3 \* \* \*"/u);
   assert.match(source, /for attempt in \{1\.\.16\}/u);
   assert.match(source, /\[ "\$STATUS" -ne 75 \]/u);
@@ -349,6 +416,38 @@ test("SBOM publication waits for registry propagation and repairs daily", () =>
   assert.match(source, /STAGED_NAME="\$SBOM_NAME\.replacement"/u);
   assert.match(source, /if \[ -z "\$CANONICAL_ASSET_ID" \]/u);
   assert.match(source, /STAGED_DIGEST" = "\$LOCAL_DIGEST/u);
+  assert.match(source, /verify-file "\$EXISTING_FILE"/u);
+  assert.match(source, /--digest "\$ASSET_DIGEST"/u);
+  assert.match(source, /verify-file "\$SBOM_FILE"/u);
+  assert.match(source, /gh attestation verify "\$EXISTING_FILE"/u);
+  assert.match(source, /--cert-identity "\$CERT_IDENTITY"/u);
+  assert.match(source, /refs\/heads\/\$DEFAULT_BRANCH/u);
+  assert.doesNotMatch(source, /--signer-workflow/u);
+  assert.match(source, /--deny-self-hosted-runners/u);
+  assert.ok(
+    source.indexOf('verify-file "$EXISTING_FILE"') <
+      source.indexOf("A staged legacy repair will be reconciled"),
+    "canonical verification must precede staged repair reconciliation",
+  );
+  assert.match(source, /Removed a stale staged repair after verifying/u);
+  const changedDuringRepair = source.indexOf(
+    "changed during repair; leaving the staged asset",
+  );
+  assert.ok(
+    changedDuringRepair > stagedUpload,
+    "a repair race must leave a staged marker for the next verified retry",
+  );
+  const finalRaceBlock = source.match(
+    /if \[ "\$CURRENT_DIGEST" != "\$REPAIR_DIGEST" \]; then\n([\s\S]*?)\n\s+fi/u,
+  )?.[1];
+  assert.ok(finalRaceBlock, "final live-digest race guard is missing");
+  assert.match(finalRaceBlock, /leaving the staged asset/u);
+  assert.match(finalRaceBlock, /^\s+exit 1$/mu);
+  assert.doesNotMatch(finalRaceBlock, /--method DELETE|STAGED_ASSET_ID/u);
+  assert.doesNotMatch(
+    source,
+    /already corrected; any staged repair was removed/u,
+  );
   assert.match(source, /persist-credentials: false/u);
   assert.match(source, /sleep 120/u);
 });
@@ -499,6 +598,84 @@ test("SBOM carries the metadata a release must be traceable by", () => {
   assert.equal(toolProperties["openiap:generator:commit"], stubCommit);
 });
 
+test("published SBOM verification requires reproducible release evidence", () => {
+  const generatorCommit = "1".repeat(40);
+  const document = buildSbom({
+    componentId: "react-native",
+    version: "16.3.0",
+    commit: stubCommit,
+    generatorCommit,
+    timestamp: "2026-01-01T00:00:00.000Z",
+    dependencies: [],
+  });
+  const serialized = `${JSON.stringify(document, null, 2)}\n`;
+  const digest = `sha256:${createHash("sha256").update(serialized).digest("hex")}`;
+
+  assert.deepEqual(
+    verifyPublishedSbom(serialized, {
+      fileName: "/tmp/react-native-iap-16.3.0.cdx.json",
+      releaseTag: "react-native-iap-16.3.0",
+      releaseCommit: stubCommit,
+      generatorCommit,
+      digest,
+    }),
+    { componentId: "react-native", version: "16.3.0", generatorCommit },
+  );
+
+  const legacy = structuredClone(document);
+  delete legacy.metadata.tools.components[0].properties;
+  assert.throws(
+    () =>
+      verifyPublishedSbom(JSON.stringify(legacy), {
+        fileName: "react-native-iap-16.3.0.cdx.json",
+        releaseTag: "react-native-iap-16.3.0",
+        releaseCommit: stubCommit,
+      }),
+    /openiap:generator:commit/u,
+  );
+  assert.throws(
+    () =>
+      verifyPublishedSbom(serialized, {
+        fileName: "react-native-iap-16.3.0.cdx.json",
+        releaseTag: "react-native-iap-16.3.0",
+        releaseCommit: "2".repeat(40),
+      }),
+    /properties do not match/u,
+  );
+  assert.throws(
+    () =>
+      verifyPublishedSbom(serialized, {
+        fileName: "react-native-iap-16.3.0.cdx.json",
+        releaseTag: "react-native-iap-16.3.0",
+        releaseCommit: stubCommit,
+        digest: `sha256:${"0".repeat(64)}`,
+      }),
+    /digest/u,
+  );
+  assert.throws(
+    () =>
+      verifyPublishedSbom(serialized, {
+        fileName: "react-native-iap-16.3.0.cdx.json",
+        releaseTag: "react-native-iap-16.3.0",
+        releaseCommit: stubCommit,
+        digest: "",
+      }),
+    /Invalid published SBOM digest/u,
+  );
+
+  const incomplete = structuredClone(document);
+  delete incomplete.components;
+  assert.throws(
+    () =>
+      verifyPublishedSbom(JSON.stringify(incomplete), {
+        fileName: "react-native-iap-16.3.0.cdx.json",
+        releaseTag: "react-native-iap-16.3.0",
+        releaseCommit: stubCommit,
+      }),
+    /components array/u,
+  );
+});
+
 test("generated SBOM version always matches the shipped manifest", async () => {
   for (const componentId of listComponentIds()) {
     const result = await generateSbom(componentId, {
diff --git a/security/SBOM.md b/security/SBOM.md
index ae630953e..f6bbf7d4c 100644
--- a/security/SBOM.md
+++ b/security/SBOM.md
@@ -258,11 +258,13 @@ release commit, and recorded generator commit match its inputs, and that no
 local filesystem path leaked into the document. Any mismatch fails the run.
 
 Tags that do not belong to a component are skipped with a notice rather than
-failing. A duplicate dispatch preserves an existing SBOM. The repair scan
-recognizes the exact digest of the inaccurate Google 3.3.0 asset produced by the
-retired source-manifest reader and replaces that asset once. It uploads and
-verifies the corrected document under a temporary name before removing the
-legacy asset; no other existing asset is overwritten.
+failing. A duplicate dispatch verifies an existing SBOM's identity, generator
+commit, GitHub digest, and provenance before preserving it. The repair scan
+recognizes only the exact digests of approved legacy assets: the inaccurate
+Google 3.3.0 inventory and the React Native 16.3.0 document that predates the
+generator-commit field. It uploads and verifies a corrected document under a
+temporary name before removing the legacy asset; no other existing asset is
+overwritten.
 
 ## Storage location
 
@@ -286,9 +288,12 @@ Any consumer can independently verify a published SBOM:
 gh release download react-native-iap-16.3.0 \
   --repo hyodotdev/openiap -p '*.cdx.json'
 
-# 2. Confirm this repository's CI produced it
+# 2. Confirm the main-branch workflow produced it on a GitHub-hosted runner
+CERT_IDENTITY=https://github.com/hyodotdev/openiap
+CERT_IDENTITY="$CERT_IDENTITY/.github/workflows/sbom.yml@refs/heads/main"
 gh attestation verify react-native-iap-16.3.0.cdx.json \
-  --repo hyodotdev/openiap
+  --repo hyodotdev/openiap --cert-identity "$CERT_IDENTITY" \
+  --deny-self-hosted-runners
 
 # 3. Validate it against the CycloneDX schema
 cyclonedx validate --input-file react-native-iap-16.3.0.cdx.json \
@@ -306,9 +311,11 @@ require trusting our tooling:
 | [`osv-scanner`](https://github.com/google/osv-scanner)                         | Match components against the OSV database    | Apache-2.0 |
 | [`grype`](https://github.com/anchore/grype)                                    | Match components against vulnerability feeds | Apache-2.0 |
 
-OpenIAP runs none of these in CI — see [README.md](README.md#scanning-posture)
-for why — but each accepts a CycloneDX 1.6 document directly, so a consumer can
-point their own scanner at a release asset on their own schedule.
+OpenIAP runs `gh attestation verify` in CI as a provenance gate. It does not run
+the listed quality or vulnerability scanners in CI — see
+[README.md](README.md#scanning-posture) for why. Those scanners accept a
+CycloneDX 1.6 document directly, so a consumer can inspect a release asset on
+their own schedule.
 
 Maintainers can reproduce the core dependency inventory from the published tag
 and the generator commit recorded under `openiap:generator:commit`. The release