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