diff --git a/.claude/commands/audit-security.md b/.claude/commands/audit-security.md index 5d2cc7700..433ac50ac 100644 --- a/.claude/commands/audit-security.md +++ b/.claude/commands/audit-security.md @@ -96,10 +96,11 @@ grep -rlE '/Users/|/home/[a-z]|/tmp/|ghp_|npm_[A-Za-z0-9]|BEGIN [A-Z ]*PRIVATE K /tmp/sbom-audit/ && echo "LEAK" || echo "clean" ``` -## 5. Determinism +## 5. Core determinism -Regeneration at the same commit must be byte-identical, or the reproducibility -claim in `security/SBOM.md` is false. +The dependency inventory must be byte-identical for the same release commit, +generator commit, and resolver input. Do not pass `--with-licenses` here: live +registry license and supplier metadata is point-in-time enrichment. ```bash node scripts/generate-sbom.mjs google --output-dir /tmp/sbom-audit-2 diff --git a/.github/workflows/sbom.yml b/.github/workflows/sbom.yml index f7ed2e69d..d05bcaaa0 100644 --- a/.github/workflows/sbom.yml +++ b/.github/workflows/sbom.yml @@ -15,7 +15,7 @@ on: workflow_dispatch: inputs: tag: - description: "Release tag to (re)generate an SBOM for" + description: "Release tag to generate an SBOM for" required: true type: string @@ -55,6 +55,22 @@ jobs: fetch-depth: 0 persist-credentials: false + - name: Take the generator from the default branch + id: generator + # Keep release manifests at the tag while using the current generator. + env: + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + run: | + git fetch --no-tags --depth=1 origin "$DEFAULT_BRANCH" + GENERATOR_COMMIT=$(git rev-parse FETCH_HEAD) + git checkout "$GENERATOR_COMMIT" -- \ + scripts/generate-sbom.mjs \ + scripts/sbom-dependencies.mjs \ + scripts/release-branch-policy.mjs \ + scripts/assert-release-tag.mjs + echo "commit=$GENERATOR_COMMIT" >> "$GITHUB_OUTPUT" + echo "Generator taken from $DEFAULT_BRANCH at $GENERATOR_COMMIT" + - name: Setup Node uses: actions/setup-node@v7 with: @@ -69,9 +85,7 @@ jobs: RELEASE_TAG: ${{ steps.tag.outputs.tag }} run: node scripts/generate-sbom.mjs resolve-tag "$RELEASE_TAG" - - name: Verify SBOM generator behaviour - if: ${{ steps.component.outputs.matched == 'true' }} - run: node --test scripts/generate-sbom.test.mjs + # CI tests the generator and its historical fixtures in their owning tree. - name: Generate CycloneDX SBOM id: generate @@ -80,11 +94,13 @@ jobs: # own registry. A registry outage degrades to a missing license field # rather than failing the release. env: - COMPONENT: ${{ steps.component.outputs.component }} + GENERATOR_COMMIT: ${{ steps.generator.outputs.commit }} + RELEASE_TAG: ${{ steps.tag.outputs.tag }} run: | - node scripts/generate-sbom.mjs "$COMPONENT" \ + node scripts/generate-sbom.mjs --tag "$RELEASE_TAG" \ --output-dir sbom \ --commit "$(git rev-parse HEAD)" \ + --generator-commit "$GENERATOR_COMMIT" \ --with-licenses - name: Verify the SBOM describes this release @@ -92,6 +108,7 @@ jobs: 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 @@ -105,6 +122,12 @@ jobs: .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" @@ -118,6 +141,10 @@ jobs: 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 @@ -139,7 +166,7 @@ jobs: 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 + run: gh release upload "$RELEASE_TAG" "$SBOM_FILE" - name: Report a skipped tag if: ${{ steps.component.outputs.matched != 'true' }} diff --git a/packages/docs/src/pages/docs/security/sbom.tsx b/packages/docs/src/pages/docs/security/sbom.tsx index fa7ae4332..cb7d334c8 100644 --- a/packages/docs/src/pages/docs/security/sbom.tsx +++ b/packages/docs/src/pages/docs/security/sbom.tsx @@ -132,7 +132,7 @@ const LIMITS: Limit[] = [ { title: 'Licenses and suppliers', detail: - 'resolve for every direct dependency except two structural cases: pub.dev packages, whose metadata exposes neither field, and NuGet packages whose nuspec gives only a non-SPDX license URL.', + 'come from live registries. Unavailable metadata is omitted, and pub.dev and some NuGet packages do not expose a standard value.', }, ]; @@ -143,7 +143,7 @@ function SecuritySbom() {
@@ -223,12 +223,10 @@ flutter_inapp_purchase-10.3.0.cdx.json`} { header: 'License', cell: (row) => row.license }, ]} /> - - Generation is deterministic. The document timestamp is the release - commit's timestamp, and the serial number is derived from the - release identity rather than randomly generated — so regenerating at - the released commit produces a byte-identical file. You can rebuild it - yourself and compare. + + The SBOM records both the release commit and the exact generator + commit, so you can reproduce its dependency inventory. Licenses and + suppliers come from live registries and may differ on a later run. @@ -245,6 +243,7 @@ flutter_inapp_purchase-10.3.0.cdx.json`}
  • The repository URL and the exact commit the release was built from
  • +
  • The exact generator commit used to create the SBOM
  • The release tag, so an artifact and its inventory cannot be mismatched diff --git a/scripts/fixtures/historical-releases/google-v1.3.0/openiap-versions.json b/scripts/fixtures/historical-releases/google-v1.3.0/openiap-versions.json new file mode 100644 index 000000000..86f948773 --- /dev/null +++ b/scripts/fixtures/historical-releases/google-v1.3.0/openiap-versions.json @@ -0,0 +1,6 @@ +{ + "gql": "1.2.2", + "docs": "1.2.2", + "google": "1.3.0", + "apple": "1.2.24" +} diff --git a/scripts/fixtures/historical-releases/google-v1.3.0/packages/google/gradle.properties b/scripts/fixtures/historical-releases/google-v1.3.0/packages/google/gradle.properties new file mode 100644 index 000000000..aa0628ab1 --- /dev/null +++ b/scripts/fixtures/historical-releases/google-v1.3.0/packages/google/gradle.properties @@ -0,0 +1 @@ +COMPOSE_UI_VERSION=1.6.8 diff --git a/scripts/fixtures/historical-releases/google-v1.3.0/packages/google/openiap/build.gradle.kts b/scripts/fixtures/historical-releases/google-v1.3.0/packages/google/openiap/build.gradle.kts new file mode 100644 index 000000000..23032c677 --- /dev/null +++ b/scripts/fixtures/historical-releases/google-v1.3.0/packages/google/openiap/build.gradle.kts @@ -0,0 +1,21 @@ +dependencies { + implementation("androidx.core:core-ktx:1.12.0") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0") + compileOnly("com.android.billingclient:billing-ktx:8.0.0") + add("playApi", "com.android.billingclient:billing-ktx:8.0.0") + add("autoApi", "com.android.billingclient:billing-ktx:8.0.0") + add("autoApi", "com.meta.horizon.billingclient.api:horizon-billing-compatibility:1.1.1") + add("horizonApi", "com.meta.horizon.billingclient.api:horizon-billing-compatibility:1.1.1") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0") + implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0") + implementation("com.google.code.gson:gson:2.10.1") + + val composeUiVersion = (project.findProperty("COMPOSE_UI_VERSION") as String?) ?: "1.6.8" + implementation("androidx.compose.runtime:runtime:$composeUiVersion") + implementation("androidx.compose.ui:ui:$composeUiVersion") + + testImplementation("junit:junit:4.13.2") + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0") + androidTestImplementation("androidx.test.ext:junit:1.1.5") +} diff --git a/scripts/generate-sbom.mjs b/scripts/generate-sbom.mjs index cd6cee6af..5915cde79 100644 --- a/scripts/generate-sbom.mjs +++ b/scripts/generate-sbom.mjs @@ -9,15 +9,17 @@ * 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. + * The core inventory is deterministic for a given release tag, release commit, + * generator commit, and resolver input. Registry enrichment requested with + * `--with-licenses` is point-in-time metadata and can vary between runs. * * Usage: * node scripts/generate-sbom.mjs [--output-dir DIR] * [--commit SHA] + * [--generator-commit SHA] * [--resolved FILE] + * [--tag TAG] + * [--with-licenses] * [--stdout] */ @@ -376,6 +378,8 @@ export function buildSbom({ componentId, version, commit, + generatorCommit, + releaseTag, timestamp, dependencies, vulnerabilities = [], @@ -386,7 +390,17 @@ export function buildSbom({ } const purl = definition.purl(version); - const tag = releaseTagFor(componentId, version); + const tag = releaseTag ?? releaseTagFor(componentId, version); + const resolvedTag = componentFromTag(tag); + if ( + resolvedTag?.componentId !== componentId || + resolvedTag.version !== version + ) { + throw new Error( + `Release tag '${tag}' does not match ${componentId} ${version}`, + ); + } + const resolvedGeneratorCommit = generatorCommit ?? commit; const componentRef = purl; // A VEX statement that points at a bom-ref this SBOM does not contain says @@ -426,6 +440,12 @@ export function buildSbom({ type: "application", name: GENERATOR_NAME, version: GENERATOR_VERSION, + properties: [ + { + name: "openiap:generator:commit", + value: resolvedGeneratorCommit, + }, + ], }, ], }, @@ -486,8 +506,8 @@ async function fetchText(url) { * not part of the security inventory, and a registry outage must not block a * release. * - * (name, version) pairs are immutable in every registry used here, so this - * stays reproducible in practice. + * Registry availability and metadata can change, so enriched output is not + * byte-identical across runs. */ async function lookupComponentMetadata(entry) { try { @@ -655,6 +675,8 @@ export async function generateSbom( { root = repoRoot, commit, + generatorCommit, + releaseTag, resolvedFile, withLicenses = false, runGit = defaultRunGit, @@ -669,7 +691,9 @@ export async function generateSbom( const version = readComponentVersion(componentId, root); const resolvedCommit = commit || runGit(["rev-parse", "HEAD"]); - // Commit time, not wall-clock time, keeps regeneration byte-identical. + const resolvedGeneratorCommit = + generatorCommit || runGit(["rev-parse", "HEAD"]); + // Commit time keeps the core inventory deterministic. const timestamp = new Date( runGit(["show", "-s", "--format=%cI", resolvedCommit]), ).toISOString(); @@ -688,6 +712,8 @@ export async function generateSbom( componentId, version, commit: resolvedCommit, + generatorCommit: resolvedGeneratorCommit, + releaseTag, timestamp, dependencies, vulnerabilities, @@ -713,6 +739,8 @@ function parseArguments(argv) { options.outputDir = argv[++index]; } else if (argument === "--commit") { options.commit = argv[++index]; + } else if (argument === "--generator-commit") { + options.generatorCommit = argv[++index]; } else if (argument === "--resolved") { options.resolvedFile = argv[++index]; } else if (argument === "--tag") { @@ -730,19 +758,24 @@ function parseArguments(argv) { } } - if (options.tag && !options.componentId) { + if (options.tag) { const resolved = componentFromTag(options.tag); if (!resolved) { throw new Error( `Release tag '${options.tag}' does not belong to a known SBOM component`, ); } + if (options.componentId && options.componentId !== resolved.componentId) { + throw new Error( + `Release tag '${options.tag}' belongs to ${resolved.componentId}, not ${options.componentId}`, + ); + } 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] [--with-licenses] [--stdout]`, + `Usage: generate-sbom.mjs <${listComponentIds().join("|")}|--tag TAG> [--output-dir DIR] [--commit SHA] [--generator-commit SHA] [--resolved FILE] [--with-licenses] [--stdout]`, ); } return options; @@ -769,6 +802,8 @@ async function main() { const options = parseArguments(process.argv.slice(2)); const result = await generateSbom(options.componentId, { commit: options.commit, + generatorCommit: options.generatorCommit, + releaseTag: options.tag, resolvedFile: options.resolvedFile, withLicenses: options.withLicenses, }); diff --git a/scripts/generate-sbom.test.mjs b/scripts/generate-sbom.test.mjs index 5f295d75c..a3857b47f 100644 --- a/scripts/generate-sbom.test.mjs +++ b/scripts/generate-sbom.test.mjs @@ -31,6 +31,10 @@ import { import { versionSources } from "./release-branch-policy.mjs"; const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const historicalGoogleRoot = resolve( + repoRoot, + "scripts/fixtures/historical-releases/google-v1.3.0", +); const { COMPONENTS } = generatorTesting; const { expandGradleForLoops, @@ -111,6 +115,76 @@ test("every release tag pattern resolves back to its own component", () => { assert.equal(componentFromTag(""), null); }); +test("generated SBOMs preserve accepted release tag aliases", () => { + for (const [componentId, config] of Object.entries(PACKAGE_CONFIG)) { + for (const tag of config.tags("9.9.9")) { + const document = buildSbom({ + componentId, + version: "9.9.9", + commit: stubCommit, + generatorCommit: stubCommit, + releaseTag: tag, + timestamp: "2026-01-01T00:00:00.000Z", + dependencies: [], + }); + const properties = Object.fromEntries( + document.metadata.component.properties.map((property) => [ + property.name, + property.value, + ]), + ); + assert.equal(properties["openiap:release:tag"], tag, tag); + } + } +}); + +test("current generator supports the google-v1.3.0 release tree", async () => { + const releaseCommit = "768dc142634a3f34e6a97b9eda4cdd9574d9c2ed"; + const generatorCommit = "f".repeat(40); + const { document, directCount } = await generateSbom("google", { + root: historicalGoogleRoot, + commit: releaseCommit, + generatorCommit, + releaseTag: "google-v1.3.0", + runGit: stubGit, + }); + + assert.equal(document.metadata.component.version, "1.3.0"); + assert.equal(directCount, 10); + assert.deepEqual( + document.components.map((component) => component.name), + [ + "androidx.compose.runtime:runtime", + "androidx.compose.ui:ui", + "androidx.core:core-ktx", + "androidx.lifecycle:lifecycle-runtime-ktx", + "androidx.lifecycle:lifecycle-viewmodel-ktx", + "com.android.billingclient:billing-ktx", + "com.google.code.gson:gson", + "com.meta.horizon.billingclient.api:horizon-billing-compatibility", + "org.jetbrains.kotlinx:kotlinx-coroutines-android", + "org.jetbrains.kotlinx:kotlinx-coroutines-core", + ], + ); + + const releaseProperties = Object.fromEntries( + document.metadata.component.properties.map((property) => [ + property.name, + property.value, + ]), + ); + assert.equal(releaseProperties["openiap:release:tag"], "google-v1.3.0"); + assert.equal(releaseProperties["openiap:release:commit"], releaseCommit); + + const toolProperties = Object.fromEntries( + document.metadata.tools.components[0].properties.map((property) => [ + property.name, + property.value, + ]), + ); + assert.equal(toolProperties["openiap:generator:commit"], generatorCommit); +}); + test("serial number is derived from release identity, not randomness", () => { const identity = { componentId: "expo", @@ -165,6 +239,13 @@ test("SBOM carries the metadata a release must be traceable by", () => { ); assert.equal(properties["openiap:release:commit"], stubCommit); assert.equal(properties["openiap:release:tag"], "react-native-iap-16.3.0"); + const toolProperties = Object.fromEntries( + document.metadata.tools.components[0].properties.map((property) => [ + property.name, + property.value, + ]), + ); + assert.equal(toolProperties["openiap:generator:commit"], stubCommit); }); test("generated SBOM version always matches the shipped manifest", async () => { diff --git a/security/CRA.md b/security/CRA.md index c47991c1a..b115c732f 100644 --- a/security/CRA.md +++ b/security/CRA.md @@ -102,8 +102,9 @@ 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. +Release. Generation records the release and generator commits, and the core +dependency inventory is reproducible from those inputs. Registry-sourced +license and supplier metadata is point-in-time enrichment. See [SBOM.md](SBOM.md). Practical constraint: transitive closure is complete only where an ecosystem resolver export is supplied; direct runtime @@ -143,6 +144,7 @@ through the workflows in `.github/workflows/`, and each produces a new SBOM. | 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 | +| Which generator revision was used? | `openiap:generator:commit` property on the SBOM generator component | | 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 diff --git a/security/SBOM.md b/security/SBOM.md index 72b96c137..a8281990a 100644 --- a/security/SBOM.md +++ b/security/SBOM.md @@ -114,6 +114,7 @@ table. bun run sbom # writes ./sbom/-.cdx.json bun run sbom --with-licenses # also resolve licenses from registries bun run sbom --stdout # print instead of writing +bun run sbom --tag # preserve the published tag identity bun run sbom resolve-tag # which component does this tag belong to? ``` @@ -246,9 +247,9 @@ release workflow → GitHub Release published 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. +Before upload, the workflow asserts that the SBOM's version, release tag, +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. @@ -298,15 +299,45 @@ 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. -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: +Maintainers can reproduce the core dependency inventory from the published tag +and the generator commit recorded under `openiap:generator:commit`. The release +workflow uses live registries to enrich dependencies with licenses and +suppliers, so those fields are point-in-time metadata and are not guaranteed to +be byte-identical later. ```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 +RELEASE_TAG=react-native-iap-16.3.0 +PUBLISHED_SBOM=/absolute/path/react-native-iap-16.3.0.cdx.json +GENERATOR_COMMIT=$(jq -r ' + .metadata.tools.components[] + | select(.name == "openiap-sbom-generator") + | .properties[] + | select(.name == "openiap:generator:commit") | .value +' "$PUBLISHED_SBOM") + +git fetch origin main --tags +SBOM_REPRO_DIR=$(mktemp -d) +git worktree add --detach "$SBOM_REPRO_DIR" "$RELEASE_TAG" +git -C "$SBOM_REPRO_DIR" checkout "$GENERATOR_COMMIT" -- \ + scripts/generate-sbom.mjs \ + scripts/sbom-dependencies.mjs \ + scripts/release-branch-policy.mjs \ + scripts/assert-release-tag.mjs +( + cd "$SBOM_REPRO_DIR" + node scripts/generate-sbom.mjs --tag "$RELEASE_TAG" \ + --commit "$(git rev-parse HEAD)" \ + --generator-commit "$GENERATOR_COMMIT" \ + --output-dir sbom +) + +jq '(.components[]? |= del(.licenses, .supplier))' \ + "$PUBLISHED_SBOM" > /tmp/published-core.json +jq '(.components[]? |= del(.licenses, .supplier))' \ + "$SBOM_REPRO_DIR/sbom/react-native-iap-16.3.0.cdx.json" \ + > /tmp/generated-core.json +diff /tmp/published-core.json /tmp/generated-core.json +git worktree remove --force "$SBOM_REPRO_DIR" ``` ## Update policy @@ -315,13 +346,13 @@ diff /tmp/verify/react-native-iap-16.3.0.cdx.json ./react-native-iap-16.3.0.cdx. - 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`. +- A release that predates this system and has no SBOM asset can be described + with `workflow_dispatch`. The workflow reads manifests from the release tag, + records the exact default-branch generator commit, and refuses to overwrite + an existing asset. - 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. + including a historical release-tree fixture and every accepted tag alias. + CI also fails if a releasable component has no SBOM metadata. ## Relationship to vulnerability management