Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions .claude/commands/audit-security.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
41 changes: 34 additions & 7 deletions .github/workflows/sbom.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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"

Comment thread
coderabbitai[bot] marked this conversation as resolved.
- name: Setup Node
uses: actions/setup-node@v7
with:
Expand All @@ -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
Expand All @@ -80,18 +94,21 @@ 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
if: ${{ steps.component.outputs.matched == 'true' }}
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
Expand All @@ -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"
Expand All @@ -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
Expand All @@ -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' }}
Expand Down
15 changes: 7 additions & 8 deletions packages/docs/src/pages/docs/security/sbom.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
},
];

Expand All @@ -143,7 +143,7 @@ function SecuritySbom() {
<div className="doc-page">
<SEO
title="SBOM"
description="Every OpenIAP release ships a CycloneDX SBOM as a GitHub Release asset — how to download, verify, and reproduce it."
description="Every OpenIAP release ships a CycloneDX SBOM as a GitHub Release asset — how to download, verify, and reproduce its core inventory."
path="/docs/security/sbom"
keywords="OpenIAP SBOM, CycloneDX, software bill of materials, purl, attestation, dependency inventory, NTIA minimum elements"
/>
Expand Down Expand Up @@ -223,12 +223,10 @@ flutter_inapp_purchase-10.3.0.cdx.json`}</code>
{ header: 'License', cell: (row) => row.license },
]}
/>
<Callout kind="tip" title="Reproducible">
Generation is deterministic. The document timestamp is the release
commit&apos;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.
<Callout kind="tip" title="Reproducible core inventory">
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.
</Callout>
</section>

Expand All @@ -245,6 +243,7 @@ flutter_inapp_purchase-10.3.0.cdx.json`}</code>
<li>
The repository URL and the exact commit the release was built from
</li>
<li>The exact generator commit used to create the SBOM</li>
<li>
The release tag, so an artifact and its inventory cannot be
mismatched
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"gql": "1.2.2",
"docs": "1.2.2",
"google": "1.3.0",
"apple": "1.2.24"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
COMPOSE_UI_VERSION=1.6.8
Original file line number Diff line number Diff line change
@@ -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")
}
55 changes: 45 additions & 10 deletions scripts/generate-sbom.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <component> [--output-dir DIR]
* [--commit SHA]
* [--generator-commit SHA]
* [--resolved FILE]
* [--tag TAG]
* [--with-licenses]
* [--stdout]
*/

Expand Down Expand Up @@ -376,6 +378,8 @@ export function buildSbom({
componentId,
version,
commit,
generatorCommit,
releaseTag,
timestamp,
dependencies,
vulnerabilities = [],
Expand All @@ -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
Expand Down Expand Up @@ -426,6 +440,12 @@ export function buildSbom({
type: "application",
name: GENERATOR_NAME,
version: GENERATOR_VERSION,
properties: [
{
name: "openiap:generator:commit",
value: resolvedGeneratorCommit,
},
],
},
],
},
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -655,6 +675,8 @@ export async function generateSbom(
{
root = repoRoot,
commit,
generatorCommit,
releaseTag,
resolvedFile,
withLicenses = false,
runGit = defaultRunGit,
Expand All @@ -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();
Expand All @@ -688,6 +712,8 @@ export async function generateSbom(
componentId,
version,
commit: resolvedCommit,
generatorCommit: resolvedGeneratorCommit,
releaseTag,
timestamp,
dependencies,
vulnerabilities,
Expand All @@ -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") {
Expand All @@ -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;
Expand All @@ -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,
});
Expand Down
Loading
Loading