From a51ca7c2989b508a6fe07d026be1abc1859b994d Mon Sep 17 00:00:00 2001
From: hyochan
Date: Thu, 13 Aug 2026 06:13:40 +0900
Subject: [PATCH 01/10] feat(security): add per-release cyclonedx sbom
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Every published release now gets a CycloneDX 1.6 SBOM attached as a
release asset, with a provenance attestation, generated automatically
from the manifests the build already reads.
Component identity, version, and release tag come from the existing
release SSOT (release-branch-policy.mjs, assert-release-tag.mjs), so a
component cannot be released without also being describable. A test
asserts the two lists stay equal.
sbom.yml reacts to `release: published` rather than editing the nine
release workflows, so every component — including ones added later —
is covered by one code path.
Generation is deterministic: the document timestamp is the commit
timestamp and the serial number is derived from the release identity,
so regenerating at a released commit reproduces the file byte for byte.
Syft was evaluated and rejected. On this repository its directory scan
reported zero components for Gradle and NuGet modules that have real
runtime dependencies, collected react-native-iap's entire yarn.lock
dev tree, picked up CI workflow actions as Apple components, and
embedded local filesystem paths in the output.
Test and build-only dependencies are excluded, and an unresolvable
Gradle coordinate fails generation rather than silently shortening the
inventory.
Verified: 10/10 components generate, all pass CycloneDX 1.6 schema
validation, byte-identical across runs, no local paths, 19 new tests.
---
.github/workflows/ci.yml | 1 +
.github/workflows/sbom.yml | 136 ++++++++
.gitignore | 6 +
SECURITY.md | 17 +
package.json | 2 +
scripts/assert-release-tag.mjs | 2 +-
scripts/generate-sbom.mjs | 524 ++++++++++++++++++++++++++++++
scripts/generate-sbom.test.mjs | 311 ++++++++++++++++++
scripts/release-branch-policy.mjs | 2 +-
scripts/sbom-dependencies.mjs | 520 +++++++++++++++++++++++++++++
security/CRA.md | 86 +++++
security/README.md | 109 +++++++
security/SBOM.md | 242 ++++++++++++++
13 files changed, 1956 insertions(+), 2 deletions(-)
create mode 100644 .github/workflows/sbom.yml
create mode 100644 scripts/generate-sbom.mjs
create mode 100644 scripts/generate-sbom.test.mjs
create mode 100644 scripts/sbom-dependencies.mjs
create mode 100644 security/CRA.md
create mode 100644 security/README.md
create mode 100644 security/SBOM.md
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index fb0cbf3ec..1b3f42c26 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -34,6 +34,7 @@ jobs:
node --test scripts/release-branch-policy.test.mjs
scripts/npm-publish-authorization.test.mjs
scripts/verify-npm-release-provenance.test.mjs
+ scripts/generate-sbom.test.mjs
- name: Test Gradle network retry helper
run: node --test scripts/ci/retry-gradle.test.mjs
diff --git a/.github/workflows/sbom.yml b/.github/workflows/sbom.yml
new file mode 100644
index 000000000..9df8e06ae
--- /dev/null
+++ b/.github/workflows/sbom.yml
@@ -0,0 +1,136 @@
+name: "Security: SBOM"
+
+# Generates a CycloneDX SBOM for whichever component a published release
+# belongs to, attaches it to that release, and attests that this workflow
+# produced it.
+#
+# This runs *after* a release is published rather than inside each release
+# workflow: the nine release workflows stay untouched, and every component —
+# including any added later — is covered by the same code path. The release tag
+# is the input, so the SBOM can only ever describe the commit that shipped.
+
+on:
+ release:
+ types: [published]
+ workflow_dispatch:
+ inputs:
+ tag:
+ description: "Release tag to (re)generate an SBOM for"
+ required: true
+ type: string
+
+concurrency:
+ group: sbom-${{ github.event.release.tag_name || inputs.tag }}
+ cancel-in-progress: false
+
+# Read-only by default; the publish job widens this to exactly what the
+# attestation and upload steps require.
+permissions:
+ contents: read
+
+jobs:
+ sbom:
+ name: Generate and publish SBOM
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write # upload the SBOM as a release asset
+ id-token: write # request the Sigstore signing certificate
+ attestations: write # record the provenance attestation
+ steps:
+ - name: Resolve release tag
+ id: tag
+ env:
+ RELEASE_TAG: ${{ github.event.release.tag_name || inputs.tag }}
+ run: |
+ if [ -z "$RELEASE_TAG" ]; then
+ echo "::error::No release tag available"
+ exit 1
+ fi
+ echo "tag=$RELEASE_TAG" >> "$GITHUB_OUTPUT"
+
+ - name: Checkout the released commit
+ uses: actions/checkout@v7
+ with:
+ ref: ${{ steps.tag.outputs.tag }}
+ fetch-depth: 0
+ persist-credentials: false
+
+ - name: Setup Node
+ uses: actions/setup-node@v7
+ with:
+ node-version: 20
+
+ - name: Identify the released component
+ id: component
+ run: node scripts/generate-sbom.mjs resolve-tag "${{ steps.tag.outputs.tag }}"
+
+ - name: Verify SBOM generator behaviour
+ if: ${{ steps.component.outputs.matched == 'true' }}
+ run: node --test scripts/generate-sbom.test.mjs
+
+ - name: Generate CycloneDX SBOM
+ id: generate
+ if: ${{ steps.component.outputs.matched == 'true' }}
+ run: |
+ node scripts/generate-sbom.mjs "${{ steps.component.outputs.component }}" \
+ --output-dir sbom \
+ --commit "$(git rev-parse HEAD)"
+
+ - name: Verify the SBOM describes this release
+ if: ${{ steps.component.outputs.matched == 'true' }}
+ env:
+ SBOM_FILE: ${{ steps.generate.outputs.sbom-file }}
+ EXPECTED_VERSION: ${{ steps.component.outputs.version }}
+ 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")
+
+ 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
+
+ # 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"
+
+ - name: Attest SBOM provenance
+ if: ${{ steps.component.outputs.matched == 'true' }}
+ uses: actions/attest-build-provenance@v4
+ with:
+ subject-path: ${{ steps.generate.outputs.sbom-file }}
+
+ - name: Attach SBOM to the release
+ if: ${{ steps.component.outputs.matched == 'true' }}
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ RELEASE_TAG: ${{ steps.tag.outputs.tag }}
+ SBOM_FILE: ${{ steps.generate.outputs.sbom-file }}
+ run: gh release upload "$RELEASE_TAG" "$SBOM_FILE" --clobber
+
+ - name: Report a skipped tag
+ if: ${{ steps.component.outputs.matched != 'true' }}
+ run: |
+ echo "::notice::${{ steps.tag.outputs.tag }} is not a component release tag; no SBOM generated."
diff --git a/.gitignore b/.gitignore
index 578c1c52b..85463e661 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,6 +11,12 @@ dist/
build/
.turbo/
+# Generated SBOMs. These are release artifacts published to GitHub Releases,
+# never committed — a checked-in copy would drift from the release it claims
+# to describe. See security/SBOM.md.
+sbom/
+*.cdx.json
+
# Synced version files (generated from root openiap-versions.json)
packages/*/openiap-versions.json
packages/apple/Sources/openiap-versions.json
diff --git a/SECURITY.md b/SECURITY.md
index 9cd8ec3e9..71bf9c453 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -38,3 +38,20 @@ are prioritized.
Security fixes land on `main` and ship in the next release of each affected
package. The latest published version of each package is supported; older
majors receive fixes only for critical vulnerabilities, judged case by case.
+
+## Supply Chain
+
+Every published release carries a CycloneDX SBOM as a GitHub Release asset, so
+you can check whether a specific version contains a given dependency:
+
+```bash
+gh release download react-native-iap-16.3.0 -p '*.cdx.json'
+gh attestation verify react-native-iap-16.3.0.cdx.json --repo hyodotdev/openiap
+```
+
+- [`security/SBOM.md`](security/SBOM.md) — what the SBOMs cover, how they are
+ generated, and how to verify or reproduce one
+- [`security/README.md`](security/README.md) — dependency monitoring, artifact
+ provenance, and release integrity
+- [`security/CRA.md`](security/CRA.md) — how these practices map to EU Cyber
+ Resilience Act expectations
diff --git a/package.json b/package.json
index 81b53de04..299a3abff 100644
--- a/package.json
+++ b/package.json
@@ -16,6 +16,8 @@
"audit:parity": "node scripts/audit-non-godot-parity.mjs",
"audit:docs": "bun run scripts/audit-docs.ts",
"audit:release-state": "node scripts/release-branch-policy.mjs audit",
+ "sbom": "node scripts/generate-sbom.mjs",
+ "sbom:test": "node --test scripts/generate-sbom.test.mjs",
"clean": "rm -rf packages/*/node_modules node_modules",
"generate": "cd packages/gql && bun run generate",
"version:sync": "bun scripts/sync-versions.mjs",
diff --git a/scripts/assert-release-tag.mjs b/scripts/assert-release-tag.mjs
index 2b8bb7045..05ecb3f53 100644
--- a/scripts/assert-release-tag.mjs
+++ b/scripts/assert-release-tag.mjs
@@ -5,7 +5,7 @@ import { fileURLToPath } from "node:url";
import { validateVersion } from "./release-branch-policy.mjs";
-const PACKAGE_CONFIG = {
+export const PACKAGE_CONFIG = {
apple: {
path: "openiap-versions.json",
tags: (version) => [version, `apple-v${version}`],
diff --git a/scripts/generate-sbom.mjs b/scripts/generate-sbom.mjs
new file mode 100644
index 000000000..749fbfb15
--- /dev/null
+++ b/scripts/generate-sbom.mjs
@@ -0,0 +1,524 @@
+#!/usr/bin/env node
+
+/**
+ * Generate a CycloneDX SBOM for one releasable OpenIAP component.
+ *
+ * The component list, its version source, and its release tag are read from the
+ * existing release SSOT (`release-branch-policy.mjs` and
+ * `assert-release-tag.mjs`) so a component cannot be released without also
+ * being describable here, and a version can never disagree with the release
+ * that produced it.
+ *
+ * Output is deterministic for a given (component, version, commit): the
+ * document timestamp comes from the commit, and the serial number is derived
+ * from the release identity rather than randomly generated. Re-running this on
+ * the same commit reproduces the same bytes.
+ *
+ * Usage:
+ * node scripts/generate-sbom.mjs [--output-dir DIR]
+ * [--commit SHA]
+ * [--resolved FILE]
+ * [--stdout]
+ */
+
+import { execFileSync } from "node:child_process";
+import { createHash } from "node:crypto";
+import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
+import { dirname, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+
+import { PACKAGE_CONFIG } from "./assert-release-tag.mjs";
+import { validateVersion, versionSources } from "./release-branch-policy.mjs";
+import {
+ extractDirectDependencies,
+ mergeResolved,
+} from "./sbom-dependencies.mjs";
+
+const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
+
+const REPOSITORY_URL = "https://github.com/hyodotdev/openiap";
+const SUPPLIER = {
+ name: "OpenIAP",
+ url: ["https://openiap.dev"],
+};
+const GENERATOR_NAME = "openiap-sbom-generator";
+const GENERATOR_VERSION = "1.0.0";
+const SPEC_VERSION = "1.6";
+
+/**
+ * SBOM-specific metadata per releasable component.
+ *
+ * `versionSources` (release SSOT) supplies the label and version; this table
+ * adds only what an SBOM needs on top: how the component is distributed, and
+ * where its runtime dependencies are declared.
+ */
+const COMPONENTS = {
+ apple: {
+ sbomName: "openiap-apple",
+ type: "library",
+ purl: (version) => `pkg:cocoapods/openiap@${version}`,
+ distribution: (version) => `https://cocoapods.org/pods/openiap`,
+ directory: "packages/apple",
+ // Package.swift declares `dependencies: []`; StoreKit is an OS framework,
+ // not a distributed package, so it is not an SBOM component.
+ source: { kind: "swift", manifest: "packages/apple/Package.swift" },
+ },
+ conformance: {
+ sbomName: "openiap-conformance",
+ type: "library",
+ purl: (version) => `pkg:npm/openiap-conformance@${version}`,
+ distribution: (version) =>
+ `https://www.npmjs.com/package/openiap-conformance/v/${version}`,
+ directory: "packages/conformance",
+ source: { kind: "npm", manifest: "packages/conformance/package.json" },
+ },
+ docs: {
+ sbomName: "openiap-spec",
+ type: "data",
+ purl: (version) => `pkg:generic/openiap-spec@${version}`,
+ distribution: (version) => `${REPOSITORY_URL}/releases/tag/docs-${version}`,
+ directory: "packages/gql",
+ // The spec release publishes the GraphQL contract and generated types.
+ // It carries no third-party runtime code.
+ source: { kind: "none" },
+ },
+ expo: {
+ sbomName: "expo-iap",
+ type: "library",
+ purl: (version) => `pkg:npm/expo-iap@${version}`,
+ distribution: (version) =>
+ `https://www.npmjs.com/package/expo-iap/v/${version}`,
+ directory: "libraries/expo-iap",
+ source: { kind: "npm", manifest: "libraries/expo-iap/package.json" },
+ },
+ flutter: {
+ sbomName: "flutter_inapp_purchase",
+ type: "library",
+ purl: (version) => `pkg:pub/flutter_inapp_purchase@${version}`,
+ distribution: (version) =>
+ `https://pub.dev/packages/flutter_inapp_purchase/versions/${version}`,
+ directory: "libraries/flutter_inapp_purchase",
+ source: {
+ kind: "pub",
+ manifest: "libraries/flutter_inapp_purchase/pubspec.yaml",
+ },
+ resolver: "flutter pub deps --json",
+ },
+ godot: {
+ sbomName: "godot-iap",
+ type: "library",
+ purl: (version) => `pkg:generic/godot-iap@${version}`,
+ distribution: (version) =>
+ `${REPOSITORY_URL}/releases/tag/godot-iap-${version}`,
+ directory: "libraries/godot-iap",
+ source: {
+ kind: "gradle",
+ manifest: "libraries/godot-iap/android/build.gradle.kts",
+ // The plugin derives these at configuration time from sibling modules.
+ externalLocals: {
+ openiapGoogleVersion: { file: "openiap-versions.json", json: "google" },
+ googleCoroutinesVersion: {
+ file: "packages/google/openiap/build.gradle.kts",
+ gradleLocal: "coroutinesVersion",
+ },
+ },
+ },
+ resolver: "gradlew :dependencies",
+ },
+ google: {
+ sbomName: "openiap-google",
+ type: "library",
+ purl: (version) =>
+ `pkg:maven/io.github.hyochan.openiap/openiap-google@${version}`,
+ distribution: (version) =>
+ `https://central.sonatype.com/artifact/io.github.hyochan.openiap/openiap-google/${version}`,
+ directory: "packages/google",
+ source: {
+ kind: "gradle",
+ manifest: "packages/google/openiap/build.gradle.kts",
+ },
+ resolver: "gradlew :openiap:dependencies",
+ },
+ kmp: {
+ sbomName: "kmp-iap",
+ type: "library",
+ purl: (version) => `pkg:maven/io.github.hyochan/kmp-iap@${version}`,
+ distribution: (version) =>
+ `https://central.sonatype.com/artifact/io.github.hyochan/kmp-iap/${version}`,
+ directory: "libraries/kmp-iap",
+ source: {
+ kind: "gradle-catalog",
+ manifest: "libraries/kmp-iap/library/build.gradle.kts",
+ catalog: "libraries/kmp-iap/gradle/libs.versions.toml",
+ },
+ resolver: "gradlew :library:dependencies",
+ },
+ maui: {
+ sbomName: "OpenIap.Maui",
+ type: "library",
+ purl: (version) => `pkg:nuget/OpenIap.Maui@${version}`,
+ distribution: (version) =>
+ `https://www.nuget.org/packages/OpenIap.Maui/${version}`,
+ directory: "libraries/maui-iap",
+ source: {
+ kind: "nuget",
+ manifest: "libraries/maui-iap/src/OpenIap.Maui/OpenIap.Maui.csproj",
+ propertyFiles: [
+ "libraries/maui-iap/Directory.Build.props",
+ "libraries/maui-iap/src/Directory.Build.props",
+ ],
+ },
+ resolver: "dotnet list package --include-transitive",
+ },
+ "react-native": {
+ sbomName: "react-native-iap",
+ type: "library",
+ purl: (version) => `pkg:npm/react-native-iap@${version}`,
+ distribution: (version) =>
+ `https://www.npmjs.com/package/react-native-iap/v/${version}`,
+ directory: "libraries/react-native-iap",
+ source: {
+ kind: "npm",
+ manifest: "libraries/react-native-iap/package.json",
+ },
+ },
+};
+
+export function listComponentIds() {
+ return Object.keys(COMPONENTS).sort();
+}
+
+function defaultRunGit(args) {
+ return execFileSync("git", args, {
+ cwd: repoRoot,
+ encoding: "utf8",
+ stdio: ["ignore", "pipe", "pipe"],
+ }).trim();
+}
+
+export function readComponentVersion(componentId, root = repoRoot) {
+ const source = versionSources[componentId];
+ if (!source) {
+ throw new Error(`Unknown release component: ${componentId}`);
+ }
+ return validateVersion(source.read(root), source.label);
+}
+
+export function releaseTagFor(componentId, version) {
+ // `docs` releases the spec and is absent from the release-tag SSOT, which
+ // only covers packages with their own version file.
+ if (componentId === "docs") return `docs-${version}`;
+ const tags = PACKAGE_CONFIG[componentId]?.tags(version);
+ if (!tags?.length) {
+ throw new Error(`No release tag pattern for component: ${componentId}`);
+ }
+ return tags[0];
+}
+
+export function sbomFileName(componentId, version) {
+ return `${COMPONENTS[componentId].sbomName}-${version}.cdx.json`;
+}
+
+/**
+ * Longest-prefix first, so `google-` cannot swallow a tag that a more specific
+ * component owns. Apple publishes a bare semver tag and is matched last.
+ */
+const TAG_PREFIXES = [
+ ["openiap-conformance-", "conformance"],
+ ["react-native-iap-", "react-native"],
+ ["flutter-iap-", "flutter"],
+ ["godot-iap-", "godot"],
+ ["expo-iap-", "expo"],
+ ["maui-iap-", "maui"],
+ ["kmp-iap-", "kmp"],
+ ["google-v", "google"],
+ ["google-", "google"],
+ ["apple-v", "apple"],
+ ["docs-", "docs"],
+].sort((left, right) => right[0].length - left[0].length);
+
+/**
+ * Map a published release tag back to the component that produced it.
+ *
+ * Returns null for tags this repository does not release components under, so
+ * the workflow can skip them rather than fail.
+ */
+export function componentFromTag(tag) {
+ const normalized = String(tag ?? "").trim();
+ if (!normalized) return null;
+
+ for (const [prefix, componentId] of TAG_PREFIXES) {
+ if (!normalized.startsWith(prefix)) continue;
+ const version = normalized.slice(prefix.length);
+ if (!/^\d+\.\d+\.\d+/u.test(version)) continue;
+ return { componentId, version };
+ }
+
+ // packages/apple releases under a bare version tag.
+ if (/^\d+\.\d+\.\d+/u.test(normalized)) {
+ return { componentId: "apple", version: normalized };
+ }
+
+ return null;
+}
+
+/**
+ * RFC 4122 §4.3 name-based UUID (SHA-1, version 5) over the release identity,
+ * so the same release always yields the same serial number.
+ */
+function deterministicSerialNumber(identity) {
+ // DNS namespace UUID, per RFC 4122 Appendix C.
+ const namespace = "6ba7b810-9dad-11d1-80b4-00c04fd430c8";
+ const namespaceBytes = Buffer.from(namespace.replace(/-/gu, ""), "hex");
+ const hash = createHash("sha1")
+ .update(Buffer.concat([namespaceBytes, Buffer.from(identity, "utf8")]))
+ .digest();
+
+ const bytes = Buffer.from(hash.subarray(0, 16));
+ bytes[6] = (bytes[6] & 0x0f) | 0x50;
+ bytes[8] = (bytes[8] & 0x3f) | 0x80;
+
+ const hex = bytes.toString("hex");
+ return `urn:uuid:${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
+}
+
+function dependencyComponent(entry) {
+ const component = {
+ "bom-ref": entry.purl,
+ type: "library",
+ name: entry.name,
+ version: entry.version,
+ purl: entry.purl,
+ scope: "required",
+ };
+ if (entry.transitive) {
+ component.properties = [
+ { name: "openiap:sbom:relationship", value: "transitive" },
+ ];
+ }
+ return component;
+}
+
+export function buildSbom({
+ componentId,
+ version,
+ commit,
+ timestamp,
+ dependencies,
+}) {
+ const definition = COMPONENTS[componentId];
+ if (!definition) {
+ throw new Error(`Unknown SBOM component: ${componentId}`);
+ }
+
+ const purl = definition.purl(version);
+ const tag = releaseTagFor(componentId, version);
+ const componentRef = purl;
+
+ const externalReferences = [
+ { type: "vcs", url: `${REPOSITORY_URL}.git` },
+ { type: "distribution", url: definition.distribution(version) },
+ { type: "website", url: "https://openiap.dev" },
+ ];
+
+ return {
+ $schema: `http://cyclonedx.org/schema/bom-${SPEC_VERSION}.schema.json`,
+ bomFormat: "CycloneDX",
+ specVersion: SPEC_VERSION,
+ serialNumber: deterministicSerialNumber(`${purl}@${commit}`),
+ version: 1,
+ metadata: {
+ timestamp,
+ lifecycles: [{ phase: "build" }],
+ tools: {
+ components: [
+ {
+ type: "application",
+ name: GENERATOR_NAME,
+ version: GENERATOR_VERSION,
+ },
+ ],
+ },
+ component: {
+ "bom-ref": componentRef,
+ type: definition.type,
+ name: definition.sbomName,
+ version,
+ purl,
+ supplier: SUPPLIER,
+ licenses: [{ license: { id: "MIT" } }],
+ externalReferences,
+ properties: [
+ { name: "openiap:release:tag", value: tag },
+ { name: "openiap:release:commit", value: commit },
+ { name: "openiap:release:component", value: componentId },
+ ],
+ },
+ supplier: SUPPLIER,
+ },
+ components: dependencies.map(dependencyComponent),
+ dependencies: [
+ {
+ ref: componentRef,
+ dependsOn: dependencies
+ .filter((entry) => !entry.transitive)
+ .map((entry) => entry.purl),
+ },
+ ...dependencies.map((entry) => ({ ref: entry.purl, dependsOn: [] })),
+ ],
+ };
+}
+
+function readResolvedFile(path) {
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
+ const entries = Array.isArray(parsed) ? parsed : parsed.components;
+ if (!Array.isArray(entries)) {
+ throw new Error(
+ `Resolved dependency file must be an array or {"components": [...]}: ${path}`,
+ );
+ }
+ return entries;
+}
+
+export function generateSbom(
+ componentId,
+ { root = repoRoot, commit, resolvedFile, runGit = defaultRunGit } = {},
+) {
+ const definition = COMPONENTS[componentId];
+ if (!definition) {
+ throw new Error(
+ `Unknown SBOM component '${componentId}'. Known: ${listComponentIds().join(", ")}`,
+ );
+ }
+
+ const version = readComponentVersion(componentId, root);
+ const resolvedCommit = commit || runGit(["rev-parse", "HEAD"]);
+ // Commit time, not wall-clock time, keeps regeneration byte-identical.
+ const timestamp = new Date(
+ runGit(["show", "-s", "--format=%cI", resolvedCommit]),
+ ).toISOString();
+
+ const direct = extractDirectDependencies(root, definition.source);
+ const dependencies = resolvedFile
+ ? mergeResolved(direct, readResolvedFile(resolvedFile))
+ : direct;
+
+ const document = buildSbom({
+ componentId,
+ version,
+ commit: resolvedCommit,
+ timestamp,
+ dependencies,
+ });
+
+ return {
+ document,
+ version,
+ fileName: sbomFileName(componentId, version),
+ directCount: direct.length,
+ totalCount: dependencies.length,
+ };
+}
+
+function parseArguments(argv) {
+ const options = { componentId: "", outputDir: "sbom", toStdout: false };
+ for (let index = 0; index < argv.length; index += 1) {
+ const argument = argv[index];
+ if (argument === "--output-dir") {
+ options.outputDir = argv[++index];
+ } else if (argument === "--commit") {
+ options.commit = argv[++index];
+ } else if (argument === "--resolved") {
+ options.resolvedFile = argv[++index];
+ } else if (argument === "--tag") {
+ options.tag = argv[++index];
+ } else if (argument === "--stdout") {
+ options.toStdout = true;
+ } else if (argument.startsWith("--")) {
+ throw new Error(`Unknown option: ${argument}`);
+ } else if (!options.componentId) {
+ options.componentId = argument;
+ } else {
+ throw new Error(`Unexpected argument: ${argument}`);
+ }
+ }
+
+ if (options.tag && !options.componentId) {
+ const resolved = componentFromTag(options.tag);
+ if (!resolved) {
+ throw new Error(
+ `Release tag '${options.tag}' does not belong to a known SBOM component`,
+ );
+ }
+ options.componentId = resolved.componentId;
+ }
+
+ if (!options.componentId) {
+ throw new Error(
+ `Usage: generate-sbom.mjs <${listComponentIds().join("|")}|--tag TAG> [--output-dir DIR] [--commit SHA] [--resolved FILE] [--stdout]`,
+ );
+ }
+ return options;
+}
+
+function main() {
+ const [maybeCommand] = process.argv.slice(2);
+
+ // `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}\nmatched=true\n`
+ : "matched=false\n";
+ process.stdout.write(line);
+ if (process.env.GITHUB_OUTPUT) {
+ writeFileSync(process.env.GITHUB_OUTPUT, line, { flag: "a" });
+ }
+ return;
+ }
+
+ const options = parseArguments(process.argv.slice(2));
+ const result = generateSbom(options.componentId, {
+ commit: options.commit,
+ resolvedFile: options.resolvedFile,
+ });
+ const serialized = `${JSON.stringify(result.document, null, 2)}\n`;
+
+ if (options.toStdout) {
+ process.stdout.write(serialized);
+ return;
+ }
+
+ const outputDir = resolve(repoRoot, options.outputDir);
+ mkdirSync(outputDir, { recursive: true });
+ const outputPath = resolve(outputDir, result.fileName);
+ writeFileSync(outputPath, serialized);
+
+ console.log(
+ `${result.fileName}: ${result.directCount} direct` +
+ (result.totalCount !== result.directCount
+ ? `, ${result.totalCount - result.directCount} transitive`
+ : "") +
+ ` runtime dependencies`,
+ );
+ if (process.env.GITHUB_OUTPUT) {
+ writeFileSync(
+ process.env.GITHUB_OUTPUT,
+ `sbom-file=${outputPath}\nsbom-name=${result.fileName}\nversion=${result.version}\n`,
+ { flag: "a" },
+ );
+ }
+}
+
+if (process.argv[1] === fileURLToPath(import.meta.url)) {
+ try {
+ main();
+ } catch (error) {
+ console.error(`::error::${error.message}`);
+ process.exitCode = 1;
+ }
+}
+
+export const __testing = { COMPONENTS, deterministicSerialNumber };
diff --git a/scripts/generate-sbom.test.mjs b/scripts/generate-sbom.test.mjs
new file mode 100644
index 000000000..db531f5b1
--- /dev/null
+++ b/scripts/generate-sbom.test.mjs
@@ -0,0 +1,311 @@
+import assert from "node:assert/strict";
+import { dirname, resolve } from "node:path";
+import test from "node:test";
+import { fileURLToPath } from "node:url";
+
+import {
+ __testing as generatorTesting,
+ buildSbom,
+ generateSbom,
+ listComponentIds,
+ readComponentVersion,
+ releaseTagFor,
+ sbomFileName,
+} from "./generate-sbom.mjs";
+import {
+ __testing as dependencyTesting,
+ mergeResolved,
+} from "./sbom-dependencies.mjs";
+import { versionSources } from "./release-branch-policy.mjs";
+
+const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
+const { COMPONENTS } = generatorTesting;
+const {
+ expandGradleForLoops,
+ extractGradle,
+ extractNuget,
+ extractPub,
+ isRuntimeGradleConfiguration,
+ parseMavenCoordinate,
+ parseVersionCatalog,
+ stripTestSourceSets,
+} = dependencyTesting;
+
+const stubCommit = "0".repeat(40);
+const stubGit = (args) =>
+ args[0] === "rev-parse" ? stubCommit : "2026-01-02T03:04:05+00:00";
+
+test("every releasable component has SBOM metadata", () => {
+ // The release SSOT decides what ships. A component that can be released but
+ // has no SBOM definition would ship without an inventory.
+ assert.deepEqual(listComponentIds(), Object.keys(versionSources).sort());
+});
+
+test("component versions come from the release SSOT", () => {
+ for (const componentId of listComponentIds()) {
+ const fromSbom = readComponentVersion(componentId, repoRoot);
+ const fromPolicy = versionSources[componentId].read(repoRoot);
+ assert.equal(fromSbom, fromPolicy, componentId);
+ }
+});
+
+test("SBOM file name matches the documented convention", () => {
+ assert.equal(
+ sbomFileName("react-native", "16.3.0"),
+ "react-native-iap-16.3.0.cdx.json",
+ );
+ assert.equal(
+ sbomFileName("conformance", "1.0.0"),
+ "openiap-conformance-1.0.0.cdx.json",
+ );
+});
+
+test("release tags match the release-tag SSOT", () => {
+ assert.equal(
+ releaseTagFor("react-native", "16.3.0"),
+ "react-native-iap-16.3.0",
+ );
+ assert.equal(releaseTagFor("google", "3.3.0"), "google-3.3.0");
+ assert.equal(releaseTagFor("docs", "3.2.0"), "docs-3.2.0");
+});
+
+test("serial number is derived from release identity, not randomness", () => {
+ const identity = {
+ componentId: "expo",
+ version: "5.3.0",
+ commit: stubCommit,
+ };
+ const first = buildSbom({
+ ...identity,
+ timestamp: "2026-01-01T00:00:00.000Z",
+ dependencies: [],
+ });
+ const second = buildSbom({
+ ...identity,
+ timestamp: "2026-01-01T00:00:00.000Z",
+ dependencies: [],
+ });
+ assert.equal(first.serialNumber, second.serialNumber);
+ assert.match(first.serialNumber, /^urn:uuid:[0-9a-f-]{36}$/u);
+
+ const otherCommit = buildSbom({
+ ...identity,
+ commit: "1".repeat(40),
+ timestamp: "2026-01-01T00:00:00.000Z",
+ dependencies: [],
+ });
+ assert.notEqual(first.serialNumber, otherCommit.serialNumber);
+});
+
+test("SBOM carries the metadata a release must be traceable by", () => {
+ const document = buildSbom({
+ componentId: "react-native",
+ version: "16.3.0",
+ commit: stubCommit,
+ timestamp: "2026-01-01T00:00:00.000Z",
+ dependencies: [],
+ });
+
+ assert.equal(document.bomFormat, "CycloneDX");
+ assert.equal(document.specVersion, "1.6");
+
+ const component = document.metadata.component;
+ assert.equal(component.name, "react-native-iap");
+ assert.equal(component.version, "16.3.0");
+ assert.equal(component.purl, "pkg:npm/react-native-iap@16.3.0");
+
+ const referenceTypes = component.externalReferences.map((ref) => ref.type);
+ assert.ok(referenceTypes.includes("vcs"));
+ assert.ok(referenceTypes.includes("distribution"));
+
+ const properties = Object.fromEntries(
+ component.properties.map((property) => [property.name, property.value]),
+ );
+ assert.equal(properties["openiap:release:commit"], stubCommit);
+ assert.equal(properties["openiap:release:tag"], "react-native-iap-16.3.0");
+});
+
+test("generated SBOM version always matches the shipped manifest", () => {
+ for (const componentId of listComponentIds()) {
+ const result = generateSbom(componentId, {
+ root: repoRoot,
+ runGit: stubGit,
+ });
+ assert.equal(
+ result.document.metadata.component.version,
+ readComponentVersion(componentId, repoRoot),
+ componentId,
+ );
+ assert.ok(result.fileName.endsWith(".cdx.json"), componentId);
+ }
+});
+
+test("generated SBOMs never embed local filesystem paths", () => {
+ for (const componentId of listComponentIds()) {
+ const { document } = generateSbom(componentId, {
+ root: repoRoot,
+ runGit: stubGit,
+ });
+ const serialized = JSON.stringify(document);
+ assert.doesNotMatch(serialized, /\/Users\//u, componentId);
+ assert.doesNotMatch(serialized, /\/home\/[a-z]/u, componentId);
+ assert.doesNotMatch(serialized, /\/tmp\//u, componentId);
+ }
+});
+
+test("test-only Gradle configurations stay out of the inventory", () => {
+ assert.equal(isRuntimeGradleConfiguration("implementation"), true);
+ assert.equal(isRuntimeGradleConfiguration("api"), true);
+ assert.equal(isRuntimeGradleConfiguration("playApi"), true);
+ assert.equal(isRuntimeGradleConfiguration("horizonImplementation"), true);
+
+ assert.equal(isRuntimeGradleConfiguration("testImplementation"), false);
+ assert.equal(
+ isRuntimeGradleConfiguration("androidTestImplementation"),
+ false,
+ );
+ assert.equal(isRuntimeGradleConfiguration("compileOnly"), false);
+ assert.equal(isRuntimeGradleConfiguration("playCompileOnly"), false);
+ assert.equal(isRuntimeGradleConfiguration("kaptImplementation"), false);
+});
+
+test("packages/google inventory excludes its test dependencies", () => {
+ const dependencies = extractGradle(repoRoot, COMPONENTS.google.source);
+ const names = dependencies.map((entry) => entry.name);
+
+ assert.ok(names.includes("com.android.billingclient:billing"));
+ assert.ok(names.includes("com.google.code.gson:gson"));
+ // Declared with `testImplementation` / `androidTestImplementation`.
+ assert.ok(!names.includes("junit:junit"));
+ assert.ok(!names.includes("org.robolectric:robolectric"));
+ assert.ok(!names.includes("androidx.test:core"));
+ assert.ok(!names.includes("org.jetbrains.kotlinx:kotlinx-coroutines-test"));
+});
+
+test("Gradle for-loop module lists expand to real coordinates", () => {
+ const expanded = expandGradleForLoops(
+ 'for (module in listOf("a-kotlin", "b-kotlin")) {\n' +
+ ' add("horizonApi", "com.example:$module:1.2.3")\n' +
+ "}",
+ );
+ assert.match(expanded, /com\.example:a-kotlin:1\.2\.3/u);
+ assert.match(expanded, /com\.example:b-kotlin:1\.2\.3/u);
+
+ const names = extractGradle(repoRoot, COMPONENTS.google.source).map(
+ (entry) => entry.name,
+ );
+ for (const module of [
+ "core-kotlin",
+ "user-age-category-kotlin",
+ "iap-kotlin",
+ ]) {
+ assert.ok(
+ names.includes(`com.meta.horizon.platform.sdk:${module}`),
+ module,
+ );
+ }
+});
+
+test("an unmodelled Gradle coordinate fails instead of silently vanishing", () => {
+ // A dropped dependency is worse than a failed build: the SBOM would claim
+ // completeness it does not have.
+ assert.deepEqual(parseMavenCoordinate("com.example:lib:$unknownVersion"), {
+ unresolved: "com.example:lib:$unknownVersion",
+ });
+});
+
+test("KMP test source sets are excluded", () => {
+ const stripped = stripTestSourceSets(
+ "val commonMain by getting {\n dependencies { api(libs.a) }\n}\n" +
+ "val commonTest by getting {\n dependencies { implementation(libs.b) }\n}\n",
+ );
+ assert.match(stripped, /libs\.a/u);
+ assert.doesNotMatch(stripped, /libs\.b/u);
+
+ const names = generateSbom("kmp", {
+ root: repoRoot,
+ runGit: stubGit,
+ }).document.components.map((entry) => entry.name);
+ assert.ok(!names.includes("org.jetbrains.kotlin:kotlin-test"));
+ assert.ok(!names.includes("org.jetbrains.kotlinx:kotlinx-coroutines-test"));
+});
+
+test("version catalog aliases resolve through version.ref", () => {
+ const { versions, libraries } = parseVersionCatalog(
+ '[versions]\nfoo = "1.2.3"\n\n[libraries]\n' +
+ 'bar-baz = { module = "com.example:bar", version.ref = "foo" }\n',
+ );
+ assert.equal(versions.get("foo"), "1.2.3");
+ assert.deepEqual(libraries.get("bar-baz"), {
+ module: "com.example:bar",
+ versionRef: "foo",
+ literal: undefined,
+ });
+});
+
+test("NuGet references marked PrivateAssets=all are build-only", () => {
+ const dependencies = extractNuget(repoRoot, COMPONENTS.maui.source);
+ const names = dependencies.map((entry) => entry.name);
+ assert.ok(names.includes("Xamarin.Android.Google.BillingClient"));
+ // PrivateAssets="all" is not propagated to consumers of the package.
+ assert.ok(!names.includes("Microsoft.Maui.Controls"));
+ // Every MSBuild property must have been interpolated.
+ for (const entry of dependencies) {
+ assert.doesNotMatch(entry.version, /\$\(/u, entry.name);
+ }
+});
+
+test("pub dependencies exclude the Flutter SDK itself", () => {
+ const names = extractPub(repoRoot, COMPONENTS.flutter.source).map(
+ (entry) => entry.name,
+ );
+ assert.deepEqual(names, ["http", "meta", "platform"]);
+});
+
+test("npm components publish no third-party runtime dependencies", () => {
+ // These ship with an empty `dependencies` block; peer dependencies are the
+ // host app's to provide, so they are not part of this artifact's inventory.
+ for (const componentId of ["conformance", "expo", "react-native"]) {
+ const { document } = generateSbom(componentId, {
+ root: repoRoot,
+ runGit: stubGit,
+ });
+ assert.deepEqual(document.components, [], componentId);
+ }
+});
+
+test("resolver output adds transitive entries without losing direct ones", () => {
+ const direct = [{ name: "a", version: "1.0.0", purl: "pkg:maven/g/a@1.0.0" }];
+ const merged = mergeResolved(direct, [
+ { name: "a", version: "1.0.0", purl: "pkg:maven/g/a@1.0.0" },
+ { name: "b", version: "2.0.0", purl: "pkg:maven/g/b@2.0.0" },
+ ]);
+
+ assert.equal(merged.length, 2);
+ assert.equal(merged.find((e) => e.name === "a").transitive, undefined);
+ assert.equal(merged.find((e) => e.name === "b").transitive, true);
+});
+
+test("only direct dependencies are listed as the component's dependsOn", () => {
+ const document = buildSbom({
+ componentId: "google",
+ version: "3.3.0",
+ commit: stubCommit,
+ timestamp: "2026-01-01T00:00:00.000Z",
+ dependencies: [
+ { name: "a", version: "1.0.0", purl: "pkg:maven/g/a@1.0.0" },
+ {
+ name: "b",
+ version: "2.0.0",
+ purl: "pkg:maven/g/b@2.0.0",
+ transitive: true,
+ },
+ ],
+ });
+
+ const root = document.dependencies.find(
+ (entry) => entry.ref === document.metadata.component.purl,
+ );
+ assert.deepEqual(root.dependsOn, ["pkg:maven/g/a@1.0.0"]);
+ assert.equal(document.components.length, 2);
+});
diff --git a/scripts/release-branch-policy.mjs b/scripts/release-branch-policy.mjs
index 39372b4d3..62ad100fb 100644
--- a/scripts/release-branch-policy.mjs
+++ b/scripts/release-branch-policy.mjs
@@ -9,7 +9,7 @@ const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const semverPattern =
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/;
-const versionSources = {
+export const versionSources = {
apple: {
label: "openiap-apple",
read: (root) => readJson(root, "openiap-versions.json").apple,
diff --git a/scripts/sbom-dependencies.mjs b/scripts/sbom-dependencies.mjs
new file mode 100644
index 000000000..873dbcada
--- /dev/null
+++ b/scripts/sbom-dependencies.mjs
@@ -0,0 +1,520 @@
+#!/usr/bin/env node
+
+/**
+ * Direct runtime dependency extractors, one per ecosystem this repository
+ * actually publishes into.
+ *
+ * Each extractor reads the same manifest the build reads, so the inventory
+ * cannot drift from what is shipped. Transitive closure is not resolved here —
+ * that needs the ecosystem's own resolver, which only the release runners have.
+ * `mergeResolved` folds a resolver export in when one is supplied.
+ *
+ * Test-only and build-only dependencies are excluded on purpose: they are not
+ * present in the published artifact, so listing them would misrepresent the
+ * consumer's attack surface.
+ */
+
+import { readFileSync } from "node:fs";
+import { resolve } from "node:path";
+
+function readText(root, relativePath) {
+ return readFileSync(resolve(root, relativePath), "utf8");
+}
+
+function readJson(root, relativePath) {
+ return JSON.parse(readText(root, relativePath));
+}
+
+/** Gradle `val name = "value"` locals, used to resolve `$name` interpolation. */
+function readGradleLocals(source) {
+ const locals = new Map();
+ for (const match of source.matchAll(
+ /\bval\s+([A-Za-z_][A-Za-z0-9_]*)\s*=\s*"([^"]+)"/gu,
+ )) {
+ locals.set(match[1], match[2]);
+ }
+
+ // `val x = (project.findProperty("PROP") as String?) ?: "fallback"` — the
+ // gradle.properties entry wins at build time, so prefer it and fall back to
+ // the literal only when the property is absent.
+ for (const match of source.matchAll(
+ /\bval\s+([A-Za-z_][A-Za-z0-9_]*)\s*=\s*\(\s*project\.findProperty\(\s*"([^"]+)"\s*\)[^)]*\)\s*\?:\s*"([^"]+)"/gu,
+ )) {
+ locals.set(match[1], { property: match[2], fallback: match[3] });
+ }
+
+ return locals;
+}
+
+function readGradleProperties(root, manifest) {
+ const properties = new Map();
+ const segments = manifest.split("/");
+ // Walk from the module directory up to the repository root, mirroring how
+ // Gradle layers project and root properties.
+ for (let depth = segments.length - 1; depth > 0; depth -= 1) {
+ const candidate = [...segments.slice(0, depth), "gradle.properties"].join(
+ "/",
+ );
+ let source;
+ try {
+ source = readText(root, candidate);
+ } catch {
+ continue;
+ }
+ for (const line of source.split("\n")) {
+ const match = line.match(/^\s*([\w.-]+)\s*=\s*(.+?)\s*$/u);
+ if (match && !properties.has(match[1])) {
+ properties.set(match[1], match[2]);
+ }
+ }
+ }
+ return properties;
+}
+
+/**
+ * Resolve locals that a sibling module owns.
+ *
+ * The Godot Android plugin computes its coordinates from `openiap-versions.json`
+ * and from packages/google's build script. Reading the same files keeps the
+ * coordinate correct without duplicating a version into this table.
+ */
+function readExternalLocals(root, externalLocals = {}) {
+ const resolved = new Map();
+ for (const [name, spec] of Object.entries(externalLocals)) {
+ if (spec.json) {
+ resolved.set(name, readJson(root, spec.file)[spec.json]);
+ } else if (spec.gradleLocal) {
+ const value = readGradleLocals(readText(root, spec.file)).get(
+ spec.gradleLocal,
+ );
+ if (typeof value === "string") resolved.set(name, value);
+ }
+ }
+ return resolved;
+}
+
+function flattenLocals(locals, properties) {
+ const flat = new Map();
+ for (const [name, value] of locals) {
+ if (typeof value === "string") {
+ flat.set(name, value);
+ } else if (value?.property) {
+ flat.set(name, properties.get(value.property) ?? value.fallback);
+ }
+ }
+ return flat;
+}
+
+function interpolateGradle(coordinate, locals) {
+ return coordinate.replace(
+ /\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?/gu,
+ (whole, name) => locals.get(name) ?? whole,
+ );
+}
+
+function parseMavenCoordinate(coordinate) {
+ const parts = coordinate.split(":");
+ if (parts.length !== 3) return null;
+ const [group, artifact, version] = parts.map((part) => part.trim());
+ if (!group || !artifact || !version) return null;
+ // An unresolved `$name` means the build computes this coordinate in a way
+ // this reader did not model. Returning null here would silently drop a real
+ // runtime dependency, so the caller escalates it instead.
+ if (coordinate.includes("$")) {
+ return { unresolved: coordinate };
+ }
+ return {
+ name: `${group}:${artifact}`,
+ version,
+ purl: `pkg:maven/${group}/${artifact}@${version}`,
+ };
+}
+
+/**
+ * Remove a balanced `val Test by getting { ... }` source-set block.
+ *
+ * Kotlin Multiplatform declares test dependencies with the same
+ * `implementation(...)` configuration name as production ones; only the
+ * enclosing source set distinguishes them.
+ */
+function stripTestSourceSets(source) {
+ const opener =
+ /\bval\s+[A-Za-z0-9_]*[Tt]est[A-Za-z0-9_]*\s+by\s+getting\s*\{/gu;
+ let result = source;
+ let match;
+
+ while ((match = opener.exec(result)) !== null) {
+ let depth = 1;
+ let index = match.index + match[0].length;
+ while (index < result.length && depth > 0) {
+ if (result[index] === "{") depth += 1;
+ else if (result[index] === "}") depth -= 1;
+ index += 1;
+ }
+ result = result.slice(0, match.index) + result.slice(index);
+ opener.lastIndex = 0;
+ }
+
+ return result;
+}
+
+/**
+ * Expand `for (name in listOf("a", "b")) { ... $name ... }` bodies.
+ *
+ * packages/google declares the Horizon platform SDK modules this way, so
+ * without expansion three real runtime dependencies would be unresolvable.
+ */
+function expandGradleForLoops(source) {
+ return source.replace(
+ /\bfor\s*\(\s*([A-Za-z_][A-Za-z0-9_]*)\s+in\s+listOf\(([^)]*)\)\s*\)\s*\{([^{}]*)\}/gu,
+ (whole, variable, rawItems, body) => {
+ const items = [...rawItems.matchAll(/"([^"]+)"/gu)].map(
+ (item) => item[1],
+ );
+ if (items.length === 0) return whole;
+ return items
+ .map((item) =>
+ body.replace(new RegExp(`\\$\\{?${variable}\\}?`, "gu"), item),
+ )
+ .join("\n");
+ },
+ );
+}
+
+/**
+ * Gradle configurations that place a dependency on the consumer's runtime
+ * classpath. `compileOnly` and every test configuration are deliberately absent.
+ */
+const GRADLE_RUNTIME_CONFIGURATIONS = new Set([
+ "api",
+ "implementation",
+ "runtimeOnly",
+]);
+
+/**
+ * Configuration prefixes that never reach a consumer. These must be checked
+ * before the flavored-configuration pattern below, because `testImplementation`
+ * and `androidTestImplementation` both match it.
+ */
+const GRADLE_NON_RUNTIME_PREFIXES = [
+ "test",
+ "androidTest",
+ "debug",
+ "compileOnly",
+ "annotationProcessor",
+ "ksp",
+ "kapt",
+ "lintChecks",
+];
+
+function isRuntimeGradleConfiguration(configuration) {
+ if (GRADLE_RUNTIME_CONFIGURATIONS.has(configuration)) return true;
+ if (
+ GRADLE_NON_RUNTIME_PREFIXES.some((prefix) =>
+ configuration.startsWith(prefix),
+ )
+ ) {
+ return false;
+ }
+ // Flavored configurations such as `playApi` / `horizonImplementation`.
+ return /^[a-z][A-Za-z0-9]*(Api|Implementation|RuntimeOnly)$/u.test(
+ configuration,
+ );
+}
+
+function extractGradle(root, { manifest, externalLocals }) {
+ const rawSource = readText(root, manifest);
+ const locals = flattenLocals(
+ readGradleLocals(rawSource),
+ readGradleProperties(root, manifest),
+ );
+ for (const [name, value] of readExternalLocals(root, externalLocals)) {
+ locals.set(name, value);
+ }
+ const source = expandGradleForLoops(stripTestSourceSets(rawSource));
+ const found = new Map();
+ const unresolved = [];
+
+ const record = (configuration, rawCoordinate) => {
+ if (!isRuntimeGradleConfiguration(configuration)) return;
+ const parsed = parseMavenCoordinate(
+ interpolateGradle(rawCoordinate, locals),
+ );
+ if (!parsed) return;
+ if (parsed.unresolved) {
+ unresolved.push(parsed.unresolved);
+ return;
+ }
+ found.set(parsed.purl, parsed);
+ };
+
+ // implementation("group:artifact:version")
+ for (const match of source.matchAll(
+ /\b([a-zA-Z][A-Za-z0-9]*)\s*\(\s*"([^"]+:[^"]+:[^"]+)"\s*\)/gu,
+ )) {
+ record(match[1], match[2]);
+ }
+
+ // add("playApi", "group:artifact:version")
+ for (const match of source.matchAll(
+ /\badd\s*\(\s*"([^"]+)"\s*,\s*"([^"]+:[^"]+:[^"]+)"\s*\)/gu,
+ )) {
+ record(match[1], match[2]);
+ }
+
+ if (unresolved.length > 0) {
+ throw new Error(
+ `Unresolved Gradle coordinates in ${manifest}: ${[...new Set(unresolved)].join(", ")}. ` +
+ `Model the declaration in sbom-dependencies.mjs, or supply a resolver export with --resolved.`,
+ );
+ }
+
+ return [...found.values()].sort((left, right) =>
+ left.purl.localeCompare(right.purl),
+ );
+}
+
+function parseVersionCatalog(source) {
+ const versions = new Map();
+ const libraries = new Map();
+ let section = "";
+
+ for (const rawLine of source.split("\n")) {
+ const line = rawLine.trim();
+ if (line.startsWith("#") || line === "") continue;
+ const sectionMatch = line.match(/^\[([^\]]+)\]$/u);
+ if (sectionMatch) {
+ section = sectionMatch[1];
+ continue;
+ }
+ if (section === "versions") {
+ const match = line.match(/^([\w.-]+)\s*=\s*"([^"]+)"/u);
+ if (match) versions.set(match[1], match[2]);
+ continue;
+ }
+ if (section === "libraries") {
+ const match = line.match(/^([\w.-]+)\s*=\s*\{(.+)\}/u);
+ if (!match) continue;
+ const module = match[2].match(/module\s*=\s*"([^"]+)"/u)?.[1];
+ const versionRef = match[2].match(/version\.ref\s*=\s*"([^"]+)"/u)?.[1];
+ const literal = match[2].match(/version\s*=\s*"([^"]+)"/u)?.[1];
+ if (module) libraries.set(match[1], { module, versionRef, literal });
+ }
+ }
+
+ return { versions, libraries };
+}
+
+/** `libs.kotlinx.coroutines.core` -> catalog alias `kotlinx-coroutines-core`. */
+function catalogAliasFromAccessor(accessor) {
+ return accessor.replace(/\./gu, "-");
+}
+
+function extractGradleCatalog(root, { manifest, catalog }) {
+ const source = stripTestSourceSets(readText(root, manifest));
+ const { versions, libraries } = parseVersionCatalog(readText(root, catalog));
+ const found = new Map();
+
+ for (const match of source.matchAll(
+ /\b([a-zA-Z][A-Za-z0-9]*)\s*\(\s*libs\.([A-Za-z0-9.]+)\s*\)/gu,
+ )) {
+ if (!isRuntimeGradleConfiguration(match[1])) continue;
+ const entry = libraries.get(catalogAliasFromAccessor(match[2]));
+ if (!entry) continue;
+ const version = entry.literal ?? versions.get(entry.versionRef);
+ if (!version) continue;
+ const parsed = parseMavenCoordinate(`${entry.module}:${version}`);
+ if (parsed) found.set(parsed.purl, parsed);
+ }
+
+ return [...found.values()].sort((left, right) =>
+ left.purl.localeCompare(right.purl),
+ );
+}
+
+function readMsBuildProperties(root, propertyFiles) {
+ const properties = new Map();
+ for (const file of propertyFiles) {
+ let source;
+ try {
+ source = readText(root, file);
+ } catch {
+ continue;
+ }
+ for (const match of source.matchAll(
+ /<([A-Za-z_][\w.-]*)>([^<>$]+)<\/\1>/gu,
+ )) {
+ properties.set(match[1], match[2].trim());
+ }
+ }
+ return properties;
+}
+
+function interpolateMsBuild(value, properties) {
+ return value.replace(
+ /\$\(([A-Za-z_][\w.-]*)\)/gu,
+ (whole, name) => properties.get(name) ?? whole,
+ );
+}
+
+function extractNuget(root, { manifest, propertyFiles = [] }) {
+ const source = readText(root, manifest);
+ const properties = readMsBuildProperties(root, [...propertyFiles, manifest]);
+ const found = new Map();
+
+ for (const match of source.matchAll(/]*)\/?>/gu)) {
+ const attributes = match[1];
+ const name = attributes.match(/\bInclude\s*=\s*"([^"]+)"/u)?.[1];
+ const rawVersion = attributes.match(/\bVersion\s*=\s*"([^"]+)"/u)?.[1];
+ if (!name || !rawVersion) continue;
+
+ // PrivateAssets="all" means the reference is not propagated to consumers
+ // of the produced package, so it is a build input rather than a runtime
+ // dependency of the shipped artifact.
+ if (/\bPrivateAssets\s*=\s*"all"/iu.test(attributes)) continue;
+
+ const version = interpolateMsBuild(rawVersion, properties);
+ if (version.includes("$")) continue;
+ const purl = `pkg:nuget/${name}@${version}`;
+ found.set(purl, { name, version, purl });
+ }
+
+ return [...found.values()].sort((left, right) =>
+ left.purl.localeCompare(right.purl),
+ );
+}
+
+function extractPub(root, { manifest }) {
+ const source = readText(root, manifest);
+ const lines = source.split("\n");
+ const found = new Map();
+ let inDependencies = false;
+
+ for (const line of lines) {
+ if (/^[A-Za-z_]+:/u.test(line)) {
+ inDependencies = line.startsWith("dependencies:");
+ continue;
+ }
+ if (!inDependencies) continue;
+
+ const match = line.match(/^ {2}([a-z0-9_]+):\s*(.*)$/u);
+ if (!match) continue;
+ const [, name, rawConstraint] = match;
+ const constraint = rawConstraint.trim();
+ // `flutter: sdk: flutter` is the SDK itself, not a pub.dev package.
+ if (constraint === "") continue;
+ const version = constraint.replace(/^[\^~><= ]+/u, "").trim();
+ if (!version) continue;
+ found.set(name, {
+ name,
+ version,
+ purl: `pkg:pub/${name}@${version}`,
+ scope: "required",
+ });
+ }
+
+ return [...found.values()].sort((left, right) =>
+ left.name.localeCompare(right.name),
+ );
+}
+
+function extractNpm(root, { manifest }) {
+ const packageJson = readJson(root, manifest);
+ const dependencies = packageJson.dependencies ?? {};
+ return Object.entries(dependencies)
+ .map(([name, range]) => ({
+ name,
+ version: String(range)
+ .replace(/^[\^~><= ]+/u, "")
+ .trim(),
+ purl: `pkg:npm/${name}@${String(range)
+ .replace(/^[\^~><= ]+/u, "")
+ .trim()}`,
+ }))
+ .sort((left, right) => left.name.localeCompare(right.name));
+}
+
+function extractSwift(root, { manifest }) {
+ const source = readText(root, manifest);
+ const found = new Map();
+ for (const match of source.matchAll(
+ /\.package\s*\(\s*url:\s*"([^"]+)"[^)]*?(?:from|exact):\s*"([^"]+)"/gu,
+ )) {
+ const url = match[1];
+ const version = match[2];
+ const name =
+ url
+ .replace(/\.git$/u, "")
+ .split("/")
+ .pop() ?? url;
+ const owner =
+ url
+ .replace(/\.git$/u, "")
+ .split("/")
+ .at(-2) ?? "";
+ found.set(url, {
+ name,
+ version,
+ purl: `pkg:swift/github.com/${owner}/${name}@${version}`,
+ });
+ }
+ return [...found.values()].sort((left, right) =>
+ left.name.localeCompare(right.name),
+ );
+}
+
+/** No dependency manifest: the component ships no third-party runtime code. */
+function extractNone() {
+ return [];
+}
+
+const EXTRACTORS = {
+ gradle: extractGradle,
+ "gradle-catalog": extractGradleCatalog,
+ npm: extractNpm,
+ none: extractNone,
+ nuget: extractNuget,
+ pub: extractPub,
+ swift: extractSwift,
+};
+
+export function extractDirectDependencies(root, source) {
+ const extractor = EXTRACTORS[source.kind];
+ if (!extractor) {
+ throw new Error(`Unsupported dependency source kind: ${source.kind}`);
+ }
+ return extractor(root, source);
+}
+
+/**
+ * Fold an ecosystem resolver export into the direct dependency list.
+ *
+ * The resolver output is the only place a full transitive closure can come
+ * from, and only a release runner with that ecosystem's toolchain can produce
+ * it. Entries already present as direct dependencies keep their direct scope.
+ */
+export function mergeResolved(direct, resolvedEntries) {
+ const merged = new Map(direct.map((entry) => [entry.purl, { ...entry }]));
+ for (const entry of resolvedEntries) {
+ if (!entry?.purl) continue;
+ if (merged.has(entry.purl)) continue;
+ merged.set(entry.purl, { ...entry, transitive: true });
+ }
+ return [...merged.values()].sort((left, right) =>
+ left.purl.localeCompare(right.purl),
+ );
+}
+
+export const __testing = {
+ expandGradleForLoops,
+ extractGradle,
+ extractGradleCatalog,
+ extractNpm,
+ extractNuget,
+ extractPub,
+ extractSwift,
+ isRuntimeGradleConfiguration,
+ parseMavenCoordinate,
+ parseVersionCatalog,
+ stripTestSourceSets,
+};
diff --git a/security/CRA.md b/security/CRA.md
new file mode 100644
index 000000000..0a9615f6c
--- /dev/null
+++ b/security/CRA.md
@@ -0,0 +1,86 @@
+# CRA readiness
+
+This document describes the engineering practices OpenIAP maintains that
+correspond to EU Cyber Resilience Act (CRA) expectations. It is written for
+OpenIAP maintainers, not as a legal analysis, and it is **not a substitute for
+legal advice**.
+
+## Applicability
+
+Applicability of the CRA may depend on how individual OpenIAP components are
+made available or used in commercial activities. OpenIAP does not assert a
+determination here.
+
+Regardless of legal applicability, OpenIAP maintains SBOM and software-supply-chain
+security practices as part of its security governance. Nothing in this
+repository should be read as a claim of certified or guaranteed compliance.
+
+The practices below are designed to support OpenIAP's software-supply-chain
+security and its preparation for applicable CRA requirements.
+
+## What maintainers are responsible for
+
+### 1. SBOM
+
+**Expectation:** maintain a machine-readable inventory of the components a
+product contains.
+
+**How OpenIAP does this:** a CycloneDX 1.6 SBOM is generated for every
+published release of every releasable component and attached to its GitHub
+Release. Generation is automated, reads the same manifests the build reads, and
+is reproducible from the released commit.
+
+See [SBOM.md](SBOM.md). Practical constraint: transitive closure is complete
+only where an ecosystem resolver export is supplied; direct runtime
+dependencies are always present.
+
+### 2. Vulnerability handling
+
+**Expectation:** have a process to receive, assess, and act on vulnerability
+reports.
+
+**How OpenIAP does this:** private reporting and coordinated disclosure are
+defined in the repository-root [`SECURITY.md`](../SECURITY.md), including the
+reporting channel, a 72-hour acknowledgment commitment, and the prioritization
+of receipt-validation and entitlement issues. Dependency vulnerabilities are
+surfaced by Dependabot alerts.
+
+### 3. Security updates
+
+**Expectation:** define how fixes reach users, and for how long.
+
+**How OpenIAP does this:** `SECURITY.md` states the supported-version policy —
+security fixes land on `main` and ship in the next release of each affected
+package; the latest published version of each package is supported, with older
+majors fixed case by case for critical issues. Releases are cut per component
+through the workflows in `.github/workflows/`, and each produces a new SBOM.
+
+### 4. Technical evidence
+
+**Expectation:** be able to reproduce and evidence how a release was produced.
+
+**How OpenIAP does this** — for any published release, these are recoverable:
+
+| Question | Where the answer is |
+| ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
+| What source produced this release? | Immutable release tag; `scripts/assert-release-tag.mjs` enforces that the tag matches the published version and is reachable from `main` |
+| What dependencies went into it? | The `.cdx.json` SBOM asset on that release |
+| Which SBOM version corresponds to it? | SBOM filename and `metadata.component.version`; the workflow refuses to upload on a mismatch |
+| Which workflow generated it? | The provenance attestation on the SBOM, verifiable with `gh attestation verify` |
+| Which commit was it built from? | `openiap:release:commit` property inside the SBOM, and the attestation subject |
+| Was the npm artifact itself built by us? | npm provenance (`npm publish --provenance`), checked at release time by `scripts/verify-npm-release-provenance.mjs` |
+
+## Deliberate boundaries
+
+- **No compliance claim.** This repository does not state that OpenIAP is CRA
+ compliant. It documents practices.
+- **No legal interpretation.** Questions about whether a given component is in
+ scope, or who the responsible economic operator is, are out of scope here.
+- **Open-source specifics.** The CRA treats non-commercial open-source
+ development differently from commercial supply. OpenIAP does not resolve
+ that question in this repository; the practices are maintained either way.
+
+## Where this fits
+
+CRA readiness is a consequence of OpenIAP's supply-chain security work, not a
+separate program. The umbrella is described in [README.md](README.md).
diff --git a/security/README.md b/security/README.md
new file mode 100644
index 000000000..6c2aff716
--- /dev/null
+++ b/security/README.md
@@ -0,0 +1,109 @@
+# OpenIAP software supply-chain security
+
+This directory documents how OpenIAP secures what it ships. It holds policy and
+the reasoning behind it; the automation lives in `scripts/` and
+`.github/workflows/`, and no generated artifact is stored here.
+
+| Document | Covers |
+| ---------------------------------- | --------------------------------------------------------------------------- |
+| [SBOM.md](SBOM.md) | Per-release dependency inventories: scope, format, generation, verification |
+| [CRA.md](CRA.md) | How these practices map to EU Cyber Resilience Act expectations |
+| [`../SECURITY.md`](../SECURITY.md) | Vulnerability reporting, disclosure, supported versions |
+
+Vulnerability reporting stays at the repository root, where GitHub and most
+contributors look for it.
+
+## The pipeline
+
+```text
+ OpenIAP source
+ │
+ ▼
+ dependency manifests
+ (package.json, gradle,
+ csproj, pubspec, spm)
+ │
+ ▼
+ CI (ci.yml)
+ │
+ ┌───────────────┼───────────────┐
+ ▼ ▼ ▼
+ build tests audits: parity,
+ docs, lockfile,
+ release state
+ │
+ ▼
+ release workflow (per component)
+ │
+ ┌───────────────┼───────────────┐
+ ▼ ▼ ▼
+ package npm/registry GitHub Release
+ provenance │
+ ▼
+ sbom.yml (release: published)
+ │
+ ┌─────────┴─────────┐
+ ▼ ▼
+ CycloneDX SBOM provenance
+ (release asset) attestation
+```
+
+## What is in place
+
+| Capability | Mechanism |
+| ----------------------- | -------------------------------------------------------------------------------------------- |
+| Per-release SBOM | `scripts/generate-sbom.mjs` + `.github/workflows/sbom.yml` |
+| SBOM provenance | `actions/attest-build-provenance`, verifiable with `gh attestation verify` |
+| npm artifact provenance | `npm publish --provenance`, re-verified by `scripts/verify-npm-release-provenance.mjs` |
+| Release-tag integrity | `scripts/assert-release-tag.mjs` — immutable tags, version must match, reachable from `main` |
+| Publish authorization | `scripts/npm-publish-authorization.mjs` — publishing runs only from a verified release tag |
+| Dependency monitoring | Dependabot: npm (`packages/kit`), GitHub Actions, Docker |
+| Vulnerability reporting | [`../SECURITY.md`](../SECURITY.md) — private reporting, 72-hour acknowledgment |
+| Release-branch policy | `scripts/release-branch-policy.mjs` — no prerelease metadata on `main` |
+
+## Dependency monitoring coverage
+
+Dependabot is configured for `packages/kit`, GitHub Actions, and the kit
+Dockerfile. That is a deliberate scope, not an oversight:
+
+- **`packages/kit`** is a deployed service with 34 direct runtime dependencies
+ and a large transitive tree. It is the component where a vulnerable
+ dependency has the most immediate consequence, and where we control the
+ deployed version.
+- **The published SDKs** (`react-native-iap`, `expo-iap`,
+ `openiap-conformance`) declare **no runtime `dependencies`**. There is no
+ third-party runtime tree to monitor. Their peer dependencies are resolved and
+ owned by the consuming application.
+- **Native SDKs** (`packages/apple`, `packages/google`, `kmp-iap`,
+ `OpenIap.Maui`, `flutter_inapp_purchase`, `godot-iap`) pin their platform
+ dependencies deliberately, often with compatibility constraints documented
+ inline in the build files. Automated bumps there tend to break consumers'
+ toolchain compatibility rather than help; their versions are reviewed as part
+ of platform upgrade work, and the SBOMs record exactly what each release
+ shipped.
+
+## Scanning posture
+
+OpenIAP does not run a separate vulnerability scanner (Trivy, Grype, Snyk) in
+CI today. The reasoning:
+
+- GitHub's Dependabot alerts already match this repository's manifests against
+ the GitHub Advisory Database, which is the same data an OSV-backed scanner
+ would use for these ecosystems.
+- The published SDKs have no runtime dependency tree for a scanner to examine.
+- A second scanner would add alert triage and CI maintenance without new
+ signal.
+
+The published SBOMs mean this decision is reversible without rework: any
+CycloneDX-consuming scanner can be pointed at a release asset — including by
+consumers, on their own schedule — without changes here. If `packages/kit`
+grows a deployment story where image scanning matters, that is the point to
+revisit it.
+
+## Adding a releasable component
+
+`scripts/generate-sbom.test.mjs` asserts that every component in the release
+SSOT has SBOM metadata, so CI fails if a new component is added without it.
+To satisfy it, add an entry to `COMPONENTS` in `scripts/generate-sbom.mjs`
+declaring the component's distribution and where its dependencies are declared.
+Nothing else needs to change — `sbom.yml` picks it up from the release tag.
diff --git a/security/SBOM.md b/security/SBOM.md
new file mode 100644
index 000000000..7f099f4cb
--- /dev/null
+++ b/security/SBOM.md
@@ -0,0 +1,242 @@
+# Software Bill of Materials (SBOM)
+
+## Purpose
+
+Every OpenIAP release ships a machine-readable inventory of the third-party
+code it contains. That inventory exists so a consumer — or a maintainer
+responding to a new advisory — can answer one question without reading our
+build scripts: _does this version of this package contain the vulnerable
+dependency?_
+
+SBOMs are generated from the same manifests the build reads. No one edits an
+SBOM by hand, and none are committed to the repository.
+
+## Scope
+
+One SBOM per **releasable component**, not one per repository. A single
+monorepo-wide document would describe an artifact nobody installs.
+
+The component list is not maintained here. It is read from the release
+single-source-of-truth, `scripts/release-branch-policy.mjs`, so a component
+cannot be released without also being described:
+
+| Component | SBOM name | Distribution | Release tag |
+| -------------- | ------------------------ | -------------------------------- | ------------------------------- |
+| `apple` | `openiap-apple` | CocoaPods, Swift Package Manager | `` |
+| `google` | `openiap-google` | Maven Central | `google-` |
+| `react-native` | `react-native-iap` | npm | `react-native-iap-` |
+| `expo` | `expo-iap` | npm | `expo-iap-` |
+| `conformance` | `openiap-conformance` | npm | `openiap-conformance-` |
+| `flutter` | `flutter_inapp_purchase` | pub.dev | `flutter-iap-` |
+| `kmp` | `kmp-iap` | Maven Central | `kmp-iap-` |
+| `maui` | `OpenIap.Maui` | NuGet | `maui-iap-` |
+| `godot` | `godot-iap` | GitHub Release | `godot-iap-` |
+| `docs` | `openiap-spec` | GitHub Release | `docs-` |
+
+`packages/kit` (IAPKit) is deliberately outside this list. It is a deployed
+service rather than a distributed package: consumers call it over HTTPS and
+never install its dependency tree. Its dependencies are monitored through
+Dependabot instead — see [README.md](README.md).
+
+## Standards
+
+- **Format:** CycloneDX 1.6, JSON encoding.
+- **Identifiers:** Package URL (purl) for every component.
+- **Attestation:** in-toto/SLSA provenance via GitHub Artifact Attestations.
+
+CycloneDX is the primary format. It was chosen over SPDX because purl coverage
+across the six ecosystems this repository publishes into (npm, Maven, NuGet,
+pub, CocoaPods, generic) is more direct, and because vulnerability tooling in
+these ecosystems consumes CycloneDX with less translation. There is no second
+format: publishing two documents that can disagree is a liability, not a
+feature.
+
+## Naming convention
+
+```text
+-.cdx.json
+```
+
+Concretely:
+
+```text
+react-native-iap-16.3.0.cdx.json
+openiap-conformance-1.0.0.cdx.json
+openiap-google-3.3.0.cdx.json
+```
+
+The name and version always equal the published package's own name and
+version, so a released artifact and its SBOM can be matched without a lookup
+table.
+
+## Generation
+
+```bash
+bun run sbom # writes ./sbom/-.cdx.json
+bun run sbom --stdout # print instead of writing
+bun run sbom resolve-tag # which component does this tag belong to?
+```
+
+The generator (`scripts/generate-sbom.mjs`) reads:
+
+| Ecosystem | Dependency source |
+| --------- | -------------------------------------------------------------------- |
+| npm | `package.json` (`dependencies`) |
+| Gradle | `build.gradle.kts`, `gradle.properties`, `gradle/libs.versions.toml` |
+| NuGet | `*.csproj`, `Directory.Build.props` |
+| pub | `pubspec.yaml` |
+| Swift | `Package.swift` |
+
+### What is included
+
+**Runtime dependencies of the published artifact.** Direct dependencies always;
+transitive dependencies when a resolver export is supplied (see below).
+
+### What is excluded, and why
+
+- **Test and build-only dependencies.** `testImplementation`,
+ `androidTestImplementation`, `compileOnly`, annotation processors, and NuGet
+ references marked `PrivateAssets="all"` never reach a consumer. Listing them
+ would inflate the apparent attack surface of the shipped artifact with code
+ that is not in it.
+- **`devDependencies`.** Same reasoning. Note that the npm packages here
+ declare no runtime `dependencies` at all, so their SBOMs are legitimately
+ empty of third-party components — that is a property of the artifact, not a
+ gap in the tooling.
+- **`peerDependencies`.** The host application supplies and versions these
+ (React, React Native, Expo, Flutter SDK). They are part of the consuming
+ application's SBOM, not ours.
+- **Operating-system frameworks.** StoreKit is not a distributed package.
+
+### Transitive dependencies
+
+Direct dependencies are read from the manifest. A complete transitive closure
+requires the ecosystem's own resolver, which only a runner with that toolchain
+can produce. When such an export is available it is merged in:
+
+```bash
+bun run sbom google --resolved gradle-dependencies.json
+```
+
+The file is a JSON array (or `{"components": [...]}`) of `{name, version, purl}`
+entries. Merged entries are marked with an `openiap:sbom:relationship`
+property of `transitive`, and the component's `dependsOn` list continues to
+name only its direct dependencies.
+
+Where a manifest declares a coordinate this reader cannot resolve, generation
+**fails** rather than emitting a shorter list. An SBOM that silently omits a
+dependency is worse than no SBOM, because it is trusted.
+
+## Release integration
+
+`.github/workflows/sbom.yml` runs on `release: published` and on manual
+dispatch. It does not modify the nine release workflows; it reacts to the
+releases they create, so every component — including ones added later — is
+covered by the same code path.
+
+```text
+release workflow → GitHub Release published
+ │
+ ▼
+ sbom.yml (release: published)
+ │
+ ┌───────────────┼───────────────┐
+ ▼ ▼ ▼
+ resolve component generate SBOM verify identity
+ from the tag at the tagged (version, tag,
+ commit commit, no paths)
+ │
+ ┌─────────┴─────────┐
+ ▼ ▼
+ attest provenance upload as release asset
+```
+
+Before upload, the workflow asserts that the SBOM's version, release tag, and
+commit all match the release being processed, 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.
+
+## Storage location
+
+Generated SBOMs live **only** as assets on their GitHub Release:
+
+```text
+https://github.com/hyodotdev/openiap/releases/tag/
+ └── -.cdx.json
+```
+
+They are not committed. `sbom/` and `*.cdx.json` are gitignored. A checked-in
+SBOM would be a second source of truth that drifts from the release it claims
+to describe, and would add noise to every dependency-changing pull request.
+
+## Verification
+
+Any consumer can independently verify a published SBOM:
+
+```bash
+# 1. Download the SBOM from its release
+gh release download react-native-iap-16.3.0 -p '*.cdx.json'
+
+# 2. Confirm this repository's CI produced it
+gh attestation verify react-native-iap-16.3.0.cdx.json \
+ --repo hyodotdev/openiap
+
+# 3. Validate it against the CycloneDX schema
+cyclonedx validate --input-file react-native-iap-16.3.0.cdx.json \
+ --input-format json --input-version v1_6 --fail-on-errors
+```
+
+Maintainers can additionally reproduce it. Generation is deterministic for a
+given commit — the document timestamp is the commit timestamp and the serial
+number is derived from the release identity, not randomly generated — so
+regenerating at the released commit yields a byte-identical file:
+
+```bash
+git checkout react-native-iap-16.3.0
+bun run sbom react-native --output-dir /tmp/verify
+diff /tmp/verify/react-native-iap-16.3.0.cdx.json ./react-native-iap-16.3.0.cdx.json
+```
+
+## Update policy
+
+- An SBOM is produced for every published release, automatically.
+- SBOMs are **immutable once published**, exactly like the release tag they
+ belong to. A dependency change ships as a new release with a new SBOM; a
+ published SBOM is never edited in place.
+- If a release predates this system, its SBOM can be generated retroactively
+ with `workflow_dispatch` against that tag. The result describes that tag's
+ commit, not today's `main`.
+- Changes to the generator are covered by `scripts/generate-sbom.test.mjs`,
+ which runs in CI on every pull request. The test asserting that every
+ releasable component has SBOM metadata fails if a new component is added
+ without one.
+
+## Relationship to vulnerability management
+
+The SBOM is an input to vulnerability response, not the goal:
+
+```text
+SBOM (per released version)
+ │
+ ▼
+dependency inventory ←── Dependabot alerts (packages/kit, GitHub Actions, Docker)
+ │
+ ▼
+affected-version analysis ── "which shipped releases contain this CVE?"
+ │
+ ▼
+security advisory + patch
+ │
+ ▼
+new release → new SBOM
+```
+
+Its concrete value here is answering the affected-version question. Dependabot
+tells us a dependency is vulnerable _today, on `main`_. The published SBOMs
+tell us which already-shipped versions contain it — which is what a consumer
+needs to know and what an advisory has to state.
+
+See [README.md](README.md) for the full vulnerability-management picture and
+[CRA.md](CRA.md) for how this maps onto Cyber Resilience Act expectations.
From 84dee632a7abe371afe046e717b1b3eafcd478a1 Mon Sep 17 00:00:00 2001
From: hyochan
Date: Thu, 13 Aug 2026 06:56:48 +0900
Subject: [PATCH 02/10] feat(security): add cra reporting, licenses, vex, docs
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Builds on the SBOM foundation with the parts a consumer or regulator
actually asks for.
CRA Article 14 reporting path. Obligations apply from 11 September 2026:
24-hour early warning, 72-hour notification, 14-day final report, to
ENISA and the national CSIRT. SECURITY.md now documents that path,
plus a bug bar and support-lifecycle statement. CRA.md records the
manufacturer/steward/neither distinction — a steward must be a legal
person, so an unincorporated project is generally outside it. No
compliance is claimed and no attestation is issued on a downstream
manufacturer's behalf.
Dependency licenses, 0/47 to 43/47. Resolved from each dependency's own
registry, including Google's Maven repository, which Maven Central does
not mirror and which every androidx coordinate needs. Licenses are never
guessed: a registry value becomes an SPDX id only when it is one, and a
lookup failure leaves the field empty rather than failing a release.
Opt-in via --with-licenses so local runs stay offline and deterministic.
VEX. Whether a CVE is reachable is a human judgement, so this is the one
input that cannot be generated. security/vex/.json is merged
into the SBOM when present; a not_affected claim without a justification
is rejected. Absent by default — an empty vulnerabilities array would
read as "checked, none found".
OpenSSF Scorecard. Checks the repository's own posture — branch
protection, token permissions, action pinning — which neither Dependabot
nor the SBOM covers. Worth noting: GitHub's dependency graph reports
zero packages for this repository, because bun lockfiles are unsupported
and Gradle is not resolved from source. Dependabot's version updates
still work, but the published SBOMs are the only real inventory.
Docs gain a Security section: overview with a when-each-thing-runs
table, SBOM download/verify/reproduce, and compliance covering the CRA
timeline, the conformance suite as behavioral evidence, and the
OpenChain gap assessment.
Verified: 23 tests, 10/10 SBOMs pass CycloneDX 1.6 validation with
licenses attached, docs typecheck + lint + build, audit-docs clean.
---
.github/workflows/sbom.yml | 6 +-
.github/workflows/scorecard.yml | 61 ++++
SECURITY.md | 60 ++++
packages/docs/src/pages/docs/index.tsx | 18 ++
.../src/pages/docs/security/compliance.tsx | 220 +++++++++++++
.../docs/src/pages/docs/security/overview.tsx | 299 ++++++++++++++++++
.../docs/src/pages/docs/security/sbom.tsx | 163 ++++++++++
scripts/generate-sbom.mjs | 256 ++++++++++++++-
scripts/generate-sbom.test.mjs | 117 ++++++-
security/CRA.md | 47 +++
security/README.md | 2 +
security/SBOM.md | 58 +++-
security/openchain.md | 77 +++++
security/vex/README.md | 89 ++++++
14 files changed, 1446 insertions(+), 27 deletions(-)
create mode 100644 .github/workflows/scorecard.yml
create mode 100644 packages/docs/src/pages/docs/security/compliance.tsx
create mode 100644 packages/docs/src/pages/docs/security/overview.tsx
create mode 100644 packages/docs/src/pages/docs/security/sbom.tsx
create mode 100644 security/openchain.md
create mode 100644 security/vex/README.md
diff --git a/.github/workflows/sbom.yml b/.github/workflows/sbom.yml
index 9df8e06ae..3a9938769 100644
--- a/.github/workflows/sbom.yml
+++ b/.github/workflows/sbom.yml
@@ -71,10 +71,14 @@ jobs:
- name: Generate CycloneDX SBOM
id: generate
if: ${{ steps.component.outputs.matched == 'true' }}
+ # `--with-licenses` resolves each dependency's declared license from its
+ # own registry. A registry outage degrades to a missing license field
+ # rather than failing the release.
run: |
node scripts/generate-sbom.mjs "${{ steps.component.outputs.component }}" \
--output-dir sbom \
- --commit "$(git rev-parse HEAD)"
+ --commit "$(git rev-parse HEAD)" \
+ --with-licenses
- name: Verify the SBOM describes this release
if: ${{ steps.component.outputs.matched == 'true' }}
diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml
new file mode 100644
index 000000000..63a5c2df3
--- /dev/null
+++ b/.github/workflows/scorecard.yml
@@ -0,0 +1,61 @@
+name: "Security: Scorecard"
+
+# OpenSSF Scorecard checks the security posture of the repository itself —
+# branch protection, workflow token permissions, action pinning, dangerous
+# workflow patterns, release signing.
+#
+# This is the one thing neither Dependabot nor the SBOM covers. Dependabot
+# watches dependencies; the SBOM records what shipped; Scorecard checks whether
+# the process that produced it is sound. It adds no code and no dependency —
+# it reads the repository through the GitHub API.
+
+on:
+ branch_protection_rule:
+ schedule:
+ # Weekly, off the hour to avoid the scheduler's busiest minute.
+ - cron: "37 5 * * 1"
+ push:
+ branches:
+ - main
+ workflow_dispatch:
+
+permissions: read-all
+
+jobs:
+ analysis:
+ name: Scorecard analysis
+ runs-on: ubuntu-latest
+ permissions:
+ # Upload results to the code-scanning dashboard.
+ security-events: write
+ # Publish results so the score is verifiable by third parties.
+ id-token: write
+ contents: read
+ actions: read
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v7
+ with:
+ persist-credentials: false
+
+ - name: Run analysis
+ uses: ossf/scorecard-action@v2.4.4
+ with:
+ results_file: results.sarif
+ results_format: sarif
+ # Publishes the score to the OpenSSF API so it can be cited as
+ # evidence and shown as a badge. Requires the repository to be public.
+ publish_results: true
+
+ - name: Upload artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: scorecard-results
+ path: results.sarif
+ retention-days: 5
+
+ - name: Upload to code scanning
+ uses: github/codeql-action/upload-sarif@v4
+ with:
+ sarif_file: results.sarif
diff --git a/SECURITY.md b/SECURITY.md
index 71bf9c453..cbd2a44ca 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -33,12 +33,72 @@ Receipt-validation and purchase-verification logic is the highest-sensitivity
area — reports touching verification bypasses, replay, or entitlement forgery
are prioritized.
+## What Counts as a Vulnerability
+
+This bug bar keeps triage predictable and tells reporters what to expect.
+
+**In scope:**
+
+- Verification bypass — accepting a forged, replayed, or tampered receipt or
+ purchase token as valid
+- Entitlement forgery or privilege escalation in IAPKit
+- Leaking purchase tokens, receipts, credentials, or API keys through logs,
+ errors, or SDK surfaces
+- Remote code execution, injection, or dependency confusion in a published
+ artifact
+- Authentication or authorization flaws in `kit.openiap.dev`
+
+**Not a vulnerability on its own:**
+
+- Behaviour that requires a compromised device, jailbroken OS, or attacker-run
+ debugger against their own app
+- Missing hardening that is not exploitable (absent headers, verbose version
+ strings)
+- Store-side policy behaviour owned by Apple, Google, Amazon, or Meta
+- Vulnerabilities in a peer dependency the host application selects and
+ versions — report those to that project, and tell us if OpenIAP forces a
+ vulnerable range
+
+If you are unsure, report it. A borderline report is more useful than a missed
+one.
+
+## Actively Exploited Vulnerabilities
+
+If a vulnerability in an OpenIAP component is **being exploited in the wild**,
+say so explicitly in your report — put `[SECURITY][ACTIVE]` in the subject.
+That changes the response path:
+
+| When | What happens |
+| ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
+| Within 24 hours of awareness | Triage and an initial assessment: which components and published versions are affected, and whether exploitation is confirmed |
+| Within 72 hours | Assessment updated with severity, impact, and any mitigation available to users |
+| Within 14 days | Final assessment: root cause, fix or mitigation, and affected-version list |
+
+Affected published versions are determined from the SBOM attached to each
+release, so the answer is derived from what actually shipped rather than
+reconstructed from memory. Users are informed through the GitHub Security
+Advisory, the release notes of the fixing release, and the repository README
+when the impact is broad.
+
+These timelines mirror the EU Cyber Resilience Act's Article 14 reporting
+windows, which apply from 11 September 2026. Whether OpenIAP is legally
+required to report is a separate question — see
+[`security/CRA.md`](security/CRA.md) — but the process is maintained either
+way, because the first 24 hours are the part that cannot be improvised.
+
+Reporters who need to make their own regulatory notification should tell us;
+we will share the assessment on the timeline above so it can support it.
+
## Supported Versions
Security fixes land on `main` and ship in the next release of each affected
package. The latest published version of each package is supported; older
majors receive fixes only for critical vulnerabilities, judged case by case.
+There is no long-term-support branch. When a package reaches end of life, it is
+announced in its release notes and in the documentation's release history, so
+integrators can plan a migration rather than discover it during an incident.
+
## Supply Chain
Every published release carries a CycloneDX SBOM as a GitHub Release asset, so
diff --git a/packages/docs/src/pages/docs/index.tsx b/packages/docs/src/pages/docs/index.tsx
index 3b7a88295..bf0a76956 100644
--- a/packages/docs/src/pages/docs/index.tsx
+++ b/packages/docs/src/pages/docs/index.tsx
@@ -129,6 +129,9 @@ import Versions from './updates/versions';
import AIAssistants from './guides/ai-assistants';
import MCPServer from './guides/mcp-server';
import Testing from './guides/testing';
+import SecurityOverview from './security/overview';
+import SecuritySbom from './security/sbom';
+import SecurityCompliance from './security/compliance';
import FoundationGovernance from './foundation/governance';
import FoundationOnePager from './foundation/one-pager';
import FoundationSponsorship from './foundation/sponsorship';
@@ -1015,6 +1018,18 @@ function Docs() {
+ Security
+
Foundation
diff --git a/packages/docs/src/pages/docs/security/sbom.tsx b/packages/docs/src/pages/docs/security/sbom.tsx
index 92546b860..030ebbe3a 100644
--- a/packages/docs/src/pages/docs/security/sbom.tsx
+++ b/packages/docs/src/pages/docs/security/sbom.tsx
@@ -144,10 +144,10 @@ flutter_inapp_purchase-10.3.0.cdx.json`}
dependencies are always complete.
- License coverage is 43 of 47 direct dependencies.
- The gaps are pub.dev packages, which expose no standard license
- field in package metadata, and one NuGet package whose license is
- given only as a non-SPDX URL.
+ Licenses resolve for every direct dependency except
+ two structural cases: pub.dev packages, which expose no standard
+ license field in package metadata, and NuGet packages whose nuspec
+ gives only a non-SPDX license URL.
diff --git a/security/README.md b/security/README.md
index 418a2f2d7..447c46c97 100644
--- a/security/README.md
+++ b/security/README.md
@@ -68,10 +68,9 @@ contributors look for it.
Dependabot is configured for `packages/kit`, GitHub Actions, and the kit
Dockerfile. That is a deliberate scope, not an oversight:
-- **`packages/kit`** is a deployed service with 34 direct runtime dependencies
- and a large transitive tree. It is the component where a vulnerable
- dependency has the most immediate consequence, and where we control the
- deployed version.
+- **`packages/kit`** is a deployed service with a large runtime dependency
+ tree. It is the component where a vulnerable dependency has the most
+ immediate consequence, and where we control the deployed version.
- **The published SDKs** (`react-native-iap`, `expo-iap`,
`openiap-conformance`) declare **no runtime `dependencies`**. There is no
third-party runtime tree to monitor. Their peer dependencies are resolved and
diff --git a/security/SBOM.md b/security/SBOM.md
index 306077614..fbe9db6ce 100644
--- a/security/SBOM.md
+++ b/security/SBOM.md
@@ -123,13 +123,17 @@ and a confident wrong identifier is worse than an absent one. A lookup failure
leaves the field empty rather than failing the release — license data is
compliance metadata, not part of the security inventory.
-Current coverage is **43 of 47** direct dependencies. The gaps are structural,
-not bugs:
+Every direct dependency resolves a license except two structural cases, which
+are limitations of the source metadata rather than bugs:
-- **pub.dev packages** (`http`, `meta`, `platform`) — Dart declares licensing
- in a `LICENSE` file, and package metadata exposes no standard license field.
-- **`Xamarin.Android.Google.BillingClient`** — its nuspec carries only a
- license URL that does not map to an SPDX identifier.
+- **pub.dev packages** — Dart declares licensing in a `LICENSE` file, and
+ package metadata exposes no standard license field.
+- **NuGet packages whose nuspec carries only a license URL** that does not map
+ to an SPDX identifier, such as `Xamarin.Android.Google.BillingClient`.
+
+`bun run sbom --with-licenses` prints the resolved count for a
+component, so current coverage is checkable rather than quoted here — a fixed
+number would go stale the next time a dependency changes.
### Transitive dependencies
@@ -182,7 +186,7 @@ shortening an inventory.
## Release integration
`.github/workflows/sbom.yml` runs on `release: published` and on manual
-dispatch. It does not modify the nine release workflows; it reacts to the
+dispatch. It does not modify the existing release workflows; it reacts to the
releases they create, so every component — including ones added later — is
covered by the same code path.
diff --git a/security/openchain.md b/security/openchain.md
index 78151eb0c..53765ac09 100644
--- a/security/openchain.md
+++ b/security/openchain.md
@@ -36,12 +36,12 @@ OpenChain is a Linux Foundation project.
Only the parts touched by the SBOM work are assessed here.
-| Area | Status | Notes |
-| ---------------------------------------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| Component license inventory | **Partial** | 43 of 47 direct dependencies carry license data in published SBOMs; the gaps are pub.dev packages and one NuGet package with a non-SPDX license URL ([`SBOM.md`](SBOM.md#licenses)) |
-| License policy (allowed/conditional/forbidden tiers) | **Missing** | No declared policy on which licenses may enter the dependency tree |
-| Attribution / NOTICE generation | **Missing** | Not generated. Low urgency: the published SDKs have no runtime dependencies to attribute |
-| Per-package LICENSE files | **Known gap** | Tracked separately as foundation-readiness work |
+| Area | Status | Notes |
+| ---------------------------------------------------- | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| Component license inventory | **Partial** | Published SBOMs carry license data for every direct dependency except pub.dev packages and NuGet packages with a non-SPDX license URL ([`SBOM.md`](SBOM.md#licenses)) |
+| License policy (allowed/conditional/forbidden tiers) | **Missing** | No declared policy on which licenses may enter the dependency tree |
+| Attribution / NOTICE generation | **Missing** | Not generated. Low urgency: the published SDKs have no runtime dependencies to attribute |
+| Per-package LICENSE files | **Known gap** | Tracked separately as foundation-readiness work |
## Proportionality
From ff80457147e631cb3c4de387239531ec3df15847 Mon Sep 17 00:00:00 2001
From: hyochan
Date: Thu, 13 Aug 2026 07:29:23 +0900
Subject: [PATCH 04/10] docs: add PR preview recording for the security section
---
.github/pr-previews/pr-318-security-docs.webm | Bin 0 -> 1723893 bytes
1 file changed, 0 insertions(+), 0 deletions(-)
create mode 100644 .github/pr-previews/pr-318-security-docs.webm
diff --git a/.github/pr-previews/pr-318-security-docs.webm b/.github/pr-previews/pr-318-security-docs.webm
new file mode 100644
index 0000000000000000000000000000000000000000..d25191ec2de42ba00035ee0e6d5052eccd936abd
GIT binary patch
literal 1723893
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
-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