From 247e9206260c0d2e5246a17c0591403e6af1a12d Mon Sep 17 00:00:00 2001
From: Hyo
-{`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