diff --git a/.claude/commands/audit-security.md b/.claude/commands/audit-security.md
index 433ac50ac..fb710d69b 100644
--- a/.claude/commands/audit-security.md
+++ b/.claude/commands/audit-security.md
@@ -25,11 +25,14 @@ component that can be released but has no SBOM definition is a release that
ships without an inventory.
```bash
-node --test scripts/generate-sbom.test.mjs
+node --test scripts/generate-sbom.test.mjs scripts/audit-security.test.mjs
+SECURITY_AUDIT_ROOT=$(mktemp -d)
+export SECURITY_AUDIT_ROOT
for c in $(node -e 'import("./scripts/generate-sbom.mjs").then(m=>console.log(m.listComponentIds().join(" ")))'); do
printf "%-14s " "$c"
- node scripts/generate-sbom.mjs "$c" --output-dir /tmp/sbom-audit || echo "FAILED"
+ node scripts/generate-sbom.mjs "$c" \
+ --output-dir "$SECURITY_AUDIT_ROOT/core-a" || echo "FAILED"
done
```
@@ -39,7 +42,7 @@ declaration shape the reader does not model — fix the reader, never silence it
## 2. Schema validity
```bash
-for f in /tmp/sbom-audit/*.cdx.json; do
+for f in "$SECURITY_AUDIT_ROOT"/core-a/*.cdx.json; do
cyclonedx validate --input-file "$f" --input-format json \
--input-version v1_6 --fail-on-errors
done
@@ -54,9 +57,14 @@ are the baseline OpenSSF recommends measuring against. Check author, timestamp,
and per-component name, version, purl, supplier, and dependency relationships:
```bash
+for c in $(node -e 'import("./scripts/generate-sbom.mjs").then(m=>console.log(m.listComponentIds().join(" ")))'); do
+ node scripts/generate-sbom.mjs "$c" --with-licenses \
+ --output-dir "$SECURITY_AUDIT_ROOT/enriched"
+done
+
node -e '
const fs = require("fs");
-const dir = "/tmp/sbom-audit";
+const dir = `${process.env.SECURITY_AUDIT_ROOT}/enriched`;
let tot = 0, sup = 0, lic = 0, purl = 0, auth = 0, files = 0;
for (const f of fs.readdirSync(dir)) {
const j = JSON.parse(fs.readFileSync(`${dir}/${f}`, "utf8"));
@@ -77,7 +85,8 @@ console.log(`component license: ${lic}/${tot}`);
```
Regenerate with `--with-licenses` when auditing supplier and license coverage;
-without it those fields are intentionally absent so local runs stay offline.
+without it those fields are intentionally absent. Maven and NuGet inventories
+still read their published dependency descriptors.
Known structural gaps, which are **not** findings: pub.dev exposes neither
license nor supplier in package metadata, and some NuGet packages carry only a
@@ -85,7 +94,7 @@ non-SPDX license URL.
Optionally score the result with
[`sbomqs`](https://github.com/interlynk-io/sbomqs):
-`sbomqs score /tmp/sbom-audit/*.cdx.json`.
+`sbomqs score "$SECURITY_AUDIT_ROOT"/core-a/*.cdx.json`.
## 4. No leaked paths or secrets
@@ -93,7 +102,7 @@ A published SBOM is a public document about a private filesystem.
```bash
grep -rlE '/Users/|/home/[a-z]|/tmp/|ghp_|npm_[A-Za-z0-9]|BEGIN [A-Z ]*PRIVATE KEY' \
- /tmp/sbom-audit/ && echo "LEAK" || echo "clean"
+ "$SECURITY_AUDIT_ROOT/core-a" && echo "LEAK" || echo "clean"
```
## 5. Core determinism
@@ -103,8 +112,10 @@ 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 /tmp/sbom-audit/openiap-google-*.cdx.json /tmp/sbom-audit-2/openiap-google-*.cdx.json
+node scripts/generate-sbom.mjs google \
+ --output-dir "$SECURITY_AUDIT_ROOT/core-b"
+diff "$SECURITY_AUDIT_ROOT"/core-a/openiap-google-*.cdx.json \
+ "$SECURITY_AUDIT_ROOT"/core-b/openiap-google-*.cdx.json
```
## 6. Workflow permissions and injection
@@ -112,13 +123,20 @@ diff /tmp/sbom-audit/openiap-google-*.cdx.json /tmp/sbom-audit-2/openiap-google-
Least privilege, and no untrusted value interpolated into a shell command:
```bash
-# Any ${{ }} inside a run: block is a potential injection point
-for f in .github/workflows/*.yml; do
- awk '/^\s+run:/{r=1} /^\s+- name:|^\s+uses:/{r=0} r && /\$\{\{/ {print FILENAME": "$0}' "$f"
-done
+# Any ${{ }} inside a run: block is a potential injection point. The parser has
+# fault tests, so an empty result cannot come from unsupported awk syntax.
+node scripts/audit-security.mjs workflows \
+ $(rg --files .github/workflows -g '*.yml')
# Workflows that write must say so explicitly
grep -L "^permissions:" .github/workflows/*.yml
+
+# Mutable action references in privileged workflow code
+rg -n 'uses:\s+[^#]+@(v[0-9]+|main|master)$' .github/workflows
+
+# The repository dependency graph endpoint is currently unavailable (HTTP 404)
+gh api repos/hyodotdev/openiap/dependency-graph/sbom || \
+ echo "Dependency graph SBOM endpoint unavailable"
```
Pass values through `env:` instead of interpolating them. OpenSSF Scorecard's
@@ -147,13 +165,9 @@ import("./scripts/generate-sbom.mjs").then((m) => {
'
# External references must resolve
-grep -rhoE "https?://[^)\" ]+" security/*.md security/vex/*.md \
- packages/docs/src/pages/docs/security/*.tsx |
- sed 's/[.,)"]*$//' | sort -u |
- while read -r u; do
- code=$(curl -sS -o /dev/null -w "%{http_code}" -L --max-time 20 "$u")
- [ "$code" = "200" ] || echo "$code $u"
- done
+node scripts/audit-security.mjs urls \
+ $(rg --files security packages/docs/src/pages/docs/security \
+ -g '*.md' -g '*.tsx')
```
Also check for **hardcoded counts** — "nine workflows", "43 of 47
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 66948a37b..35af1f76d 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -35,6 +35,7 @@ jobs:
scripts/npm-publish-authorization.test.mjs
scripts/verify-npm-release-provenance.test.mjs
scripts/generate-sbom.test.mjs
+ scripts/audit-security.test.mjs
- name: Test Gradle network retry helper
run: node --test scripts/ci/retry-gradle.test.mjs
diff --git a/.github/workflows/release-apple.yml b/.github/workflows/release-apple.yml
index da2e53957..1e436bac5 100644
--- a/.github/workflows/release-apple.yml
+++ b/.github/workflows/release-apple.yml
@@ -82,6 +82,9 @@ jobs:
release:
needs: [validate-ios]
+ permissions:
+ actions: write
+ contents: write
runs-on: macos-15
steps:
@@ -460,3 +463,10 @@ jobs:
$PRERELEASE_FLAG \
--notes-file /tmp/release-notes.md
fi
+
+ - name: Dispatch SBOM
+ if: inputs.publish_spm == true
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ RELEASE_TAG: ${{ steps.version.outputs.version }}
+ run: gh workflow run sbom.yml --ref main -f tag="$RELEASE_TAG"
diff --git a/.github/workflows/release-conformance.yml b/.github/workflows/release-conformance.yml
index 89e6cac19..d86404653 100644
--- a/.github/workflows/release-conformance.yml
+++ b/.github/workflows/release-conformance.yml
@@ -97,6 +97,7 @@ jobs:
name: Bump, tag, and release
needs: [validate]
permissions:
+ actions: write
contents: write
runs-on: ubuntu-latest
defaults:
@@ -311,6 +312,12 @@ jobs:
prerelease: ${{ steps.bump.outputs.is_prerelease }}
body_path: /tmp/release-notes.md
+ - name: Dispatch SBOM
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ RELEASE_TAG: openiap-conformance-${{ steps.bump.outputs.version }}
+ run: gh workflow run sbom.yml --ref main -f tag="$RELEASE_TAG"
+
# npm provenance reads the immutable workflow event GITHUB_SHA. Dispatch
# this same trusted-publisher workflow on the tag so the event SHA, npm
# package gitHead, and release tag all identify the same source commit.
diff --git a/.github/workflows/release-expo.yml b/.github/workflows/release-expo.yml
index 40f4d0d1f..06587cdd8 100644
--- a/.github/workflows/release-expo.yml
+++ b/.github/workflows/release-expo.yml
@@ -161,6 +161,7 @@ jobs:
deploy:
needs: [validate-android, validate-ios]
permissions:
+ actions: write
contents: write
runs-on: ubuntu-latest
defaults:
@@ -429,6 +430,12 @@ jobs:
prerelease: ${{ steps.bump.outputs.is_prerelease }}
body_path: /tmp/release-notes.md
+ - name: Dispatch SBOM
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ RELEASE_TAG: expo-iap-${{ steps.bump.outputs.version }}
+ run: gh workflow run sbom.yml --ref main -f tag="$RELEASE_TAG"
+
# npm provenance reads the immutable workflow event GITHUB_SHA. Dispatch
# this same trusted-publisher workflow on the tag so the event SHA, npm
# package gitHead, and release tag all identify the same source commit.
diff --git a/.github/workflows/release-flutter.yml b/.github/workflows/release-flutter.yml
index 2ea210343..01ded57b8 100644
--- a/.github/workflows/release-flutter.yml
+++ b/.github/workflows/release-flutter.yml
@@ -141,6 +141,7 @@ jobs:
deploy:
needs: [validate-android, validate-ios, validate-apple-swiftpm]
permissions:
+ actions: write
contents: write
runs-on: ubuntu-latest
defaults:
@@ -547,3 +548,9 @@ jobs:
draft: false
prerelease: ${{ steps.bump.outputs.is_prerelease }}
body_path: /tmp/release-notes.md
+
+ - name: Dispatch SBOM
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ RELEASE_TAG: flutter-iap-${{ steps.bump.outputs.version }}
+ run: gh workflow run sbom.yml --ref main -f tag="$RELEASE_TAG"
diff --git a/.github/workflows/release-godot.yml b/.github/workflows/release-godot.yml
index 0ececa77e..684dade26 100644
--- a/.github/workflows/release-godot.yml
+++ b/.github/workflows/release-godot.yml
@@ -119,6 +119,9 @@ jobs:
deploy:
needs: [validate-android, validate-ios]
+ permissions:
+ actions: write
+ contents: write
runs-on: macos-15
defaults:
run:
@@ -524,3 +527,9 @@ jobs:
draft: false
prerelease: ${{ steps.version.outputs.is_prerelease }}
body_path: /tmp/release-notes.md
+
+ - name: Dispatch SBOM
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ RELEASE_TAG: godot-iap-${{ steps.version.outputs.VERSION }}
+ run: gh workflow run sbom.yml --ref main -f tag="$RELEASE_TAG"
diff --git a/.github/workflows/release-google.yml b/.github/workflows/release-google.yml
index 9c71ce8fc..ff6de90a7 100644
--- a/.github/workflows/release-google.yml
+++ b/.github/workflows/release-google.yml
@@ -79,6 +79,9 @@ jobs:
release:
needs: [validate-android]
+ permissions:
+ actions: write
+ contents: write
runs-on: ubuntu-latest
env:
# Central Portal can take 10-30 minutes to finish publishing.
@@ -336,11 +339,11 @@ jobs:
ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.SIGNING_PASSWORD }}
run: |
if [ -z "$ORG_GRADLE_PROJECT_mavenCentralUsername" ]; then
- echo "⚠️ Maven Central credentials not set. Skipping publish."
- else
- ./gradlew :openiap:publishAndReleaseToMavenCentral --no-daemon --no-parallel --stacktrace
- echo "✅ Published openiap-google-horizon (Horizon flavor) to Maven Central"
+ echo "::error::Maven Central credentials are required to publish the Horizon flavor."
+ exit 1
fi
+ ./gradlew :openiap:publishAndReleaseToMavenCentral --no-daemon --no-parallel --stacktrace
+ echo "✅ Published openiap-google-horizon (Horizon flavor) to Maven Central"
- name: Check if Fire OS flavor already published
id: check_amazon
@@ -378,11 +381,11 @@ jobs:
ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.SIGNING_PASSWORD }}
run: |
if [ -z "$ORG_GRADLE_PROJECT_mavenCentralUsername" ]; then
- echo "⚠️ Maven Central credentials not set. Skipping publish."
- else
- ./gradlew :openiap:publishAndReleaseToMavenCentral --no-daemon --no-parallel --stacktrace
- echo "✅ Published openiap-google-amazon (Fire OS flavor) to Maven Central"
+ echo "::error::Maven Central credentials are required to publish the Fire OS flavor."
+ exit 1
fi
+ ./gradlew :openiap:publishAndReleaseToMavenCentral --no-daemon --no-parallel --stacktrace
+ echo "✅ Published openiap-google-amazon (Fire OS flavor) to Maven Central"
- name: Check if Play flavor already published
id: check_play
@@ -420,11 +423,11 @@ jobs:
ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.SIGNING_PASSWORD }}
run: |
if [ -z "$ORG_GRADLE_PROJECT_mavenCentralUsername" ]; then
- echo "⚠️ Maven Central credentials not set. Skipping publish."
- else
- ./gradlew :openiap:publishAndReleaseToMavenCentral --no-daemon --no-parallel --stacktrace
- echo "✅ Published openiap-google (Play flavor) to Maven Central"
+ echo "::error::Maven Central credentials are required to publish the Play flavor."
+ exit 1
fi
+ ./gradlew :openiap:publishAndReleaseToMavenCentral --no-daemon --no-parallel --stacktrace
+ echo "✅ Published openiap-google (Play flavor) to Maven Central"
- name: Build release artifacts
working-directory: packages/google
@@ -602,3 +605,9 @@ jobs:
$PRERELEASE_FLAG \
--notes-file /tmp/release-notes.md
fi
+
+ - name: Dispatch SBOM
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ RELEASE_TAG: google-${{ steps.version.outputs.version }}
+ run: gh workflow run sbom.yml --ref main -f tag="$RELEASE_TAG"
diff --git a/.github/workflows/release-kmp.yml b/.github/workflows/release-kmp.yml
index 3ba52cc67..2918fa5c1 100644
--- a/.github/workflows/release-kmp.yml
+++ b/.github/workflows/release-kmp.yml
@@ -118,6 +118,9 @@ jobs:
publish:
needs: [validate-android, validate-ios]
+ permissions:
+ actions: write
+ contents: write
runs-on: macos-15
defaults:
run:
@@ -423,3 +426,9 @@ jobs:
prerelease: ${{ steps.version.outputs.is_prerelease }}
body_path: /tmp/release-notes.md
files: libraries/kmp-iap/release-artifacts.zip
+
+ - name: Dispatch SBOM
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ RELEASE_TAG: kmp-iap-${{ steps.version.outputs.VERSION }}
+ run: gh workflow run sbom.yml --ref main -f tag="$RELEASE_TAG"
diff --git a/.github/workflows/release-maui.yml b/.github/workflows/release-maui.yml
index e36e930fa..73d83d0e9 100644
--- a/.github/workflows/release-maui.yml
+++ b/.github/workflows/release-maui.yml
@@ -125,6 +125,9 @@ jobs:
publish:
needs: [validate, validate-multitarget]
+ permissions:
+ actions: write
+ contents: write
# Rebuild the exact native sidecar packed into NuGet with an App Store
# submission toolchain. Xcode 27 remains validation-only until Apple
# accepts its SDK for App Store uploads.
@@ -461,3 +464,9 @@ jobs:
prerelease: ${{ steps.version.outputs.is_prerelease }}
body_path: /tmp/release-notes.md
files: ./nupkgs/*.nupkg
+
+ - name: Dispatch SBOM
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ RELEASE_TAG: maui-iap-${{ steps.version.outputs.version }}
+ run: gh workflow run sbom.yml --ref main -f tag="$RELEASE_TAG"
diff --git a/.github/workflows/release-react-native.yml b/.github/workflows/release-react-native.yml
index 1c68ebf99..63ec72186 100644
--- a/.github/workflows/release-react-native.yml
+++ b/.github/workflows/release-react-native.yml
@@ -157,6 +157,7 @@ jobs:
deploy:
needs: [validate-android, validate-ios]
permissions:
+ actions: write
contents: write
runs-on: ubuntu-latest
defaults:
@@ -430,6 +431,12 @@ jobs:
prerelease: ${{ steps.bump.outputs.is_prerelease }}
body_path: /tmp/release-notes.md
+ - name: Dispatch SBOM
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ RELEASE_TAG: react-native-iap-${{ steps.bump.outputs.version }}
+ run: gh workflow run sbom.yml --ref main -f tag="$RELEASE_TAG"
+
# npm provenance reads the immutable workflow event GITHUB_SHA. Dispatch
# this same trusted-publisher workflow on the tag so the event SHA, npm
# package gitHead, and release tag all identify the same source commit.
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 77fccee51..961838c54 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -15,12 +15,12 @@ concurrency:
group: ${{ github.workflow }}
cancel-in-progress: false
-permissions:
- contents: write
-
jobs:
release:
runs-on: ubuntu-latest
+ permissions:
+ actions: write
+ contents: write
steps:
- name: Checkout repository
uses: actions/checkout@v7
@@ -76,3 +76,9 @@ jobs:
### Documentation
- [Documentation](https://openiap.dev)
- [GitHub Repository](https://github.com/hyodotdev/openiap)
+
+ - name: Dispatch SBOM
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ RELEASE_TAG: docs-${{ steps.version.outputs.version }}
+ run: gh workflow run sbom.yml --ref main -f tag="$RELEASE_TAG"
diff --git a/.github/workflows/sbom.yml b/.github/workflows/sbom.yml
index d05bcaaa0..e25e4cd09 100644
--- a/.github/workflows/sbom.yml
+++ b/.github/workflows/sbom.yml
@@ -4,14 +4,21 @@ name: "Security: SBOM"
# 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 existing 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.
+# Release workflows dispatch this workflow explicitly because releases created
+# with GITHUB_TOKEN do not emit another workflow run. A scheduled scan repairs
+# any missed dispatch for the newest release of each component.
on:
release:
types: [published]
+ push:
+ branches: [main]
+ paths:
+ - ".github/workflows/sbom.yml"
+ - "scripts/generate-sbom.mjs"
+ - "scripts/sbom-dependencies.mjs"
+ schedule:
+ - cron: "23 3 * * *"
workflow_dispatch:
inputs:
tag:
@@ -23,14 +30,44 @@ 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:
+ backfill:
+ name: Dispatch missing current SBOMs
+ if: github.event_name == 'push' || github.event_name == 'schedule'
+ runs-on: ubuntu-latest
+ permissions:
+ actions: write
+ contents: read
+ steps:
+ - name: Checkout default branch
+ uses: actions/checkout@v7
+ with:
+ persist-credentials: false
+
+ - name: Find and dispatch missing SBOMs
+ env:
+ DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ gh api --paginate --slurp \
+ "repos/$GITHUB_REPOSITORY/releases?per_page=100" \
+ > "$RUNNER_TEMP/releases.json"
+ node scripts/generate-sbom.mjs missing-release-tags \
+ "$RUNNER_TEMP/releases.json" > "$RUNNER_TEMP/missing-tags.txt"
+
+ while IFS= read -r RELEASE_TAG; do
+ [ -n "$RELEASE_TAG" ] || continue
+ echo "Dispatching SBOM for $RELEASE_TAG"
+ gh workflow run sbom.yml --ref "$DEFAULT_BRANCH" \
+ -f tag="$RELEASE_TAG"
+ done < "$RUNNER_TEMP/missing-tags.txt"
+
sbom:
name: Generate and publish SBOM
+ if: github.event_name == 'release' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
permissions:
contents: write # upload the SBOM as a release asset
@@ -85,26 +122,78 @@ jobs:
RELEASE_TAG: ${{ steps.tag.outputs.tag }}
run: node scripts/generate-sbom.mjs resolve-tag "$RELEASE_TAG"
+ - name: Inspect the existing asset
+ id: existing
+ if: ${{ steps.component.outputs.matched == 'true' }}
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ REPAIR_DIGEST: ${{ steps.component.outputs.repair-digest }}
+ RELEASE_TAG: ${{ steps.tag.outputs.tag }}
+ SBOM_NAME: ${{ steps.component.outputs.sbom-name }}
+ run: |
+ if ! RELEASE_JSON=$(gh api \
+ "repos/$GITHUB_REPOSITORY/releases/tags/$RELEASE_TAG"); then
+ echo "::error::Unable to inspect release assets for $RELEASE_TAG"
+ exit 1
+ fi
+ ASSET=$(jq -c --arg name "$SBOM_NAME" \
+ '[.assets[] | select(.name == $name)][0] // empty' \
+ <<< "$RELEASE_JSON")
+ STAGED_ASSET=$(jq -c --arg name "$SBOM_NAME.replacement" \
+ '[.assets[] | select(.name == $name)][0] // empty' \
+ <<< "$RELEASE_JSON")
+ if [ -n "$REPAIR_DIGEST" ] && [ -n "$STAGED_ASSET" ]; then
+ echo "exists=false" >> "$GITHUB_OUTPUT"
+ echo "::notice::A staged legacy repair will be reconciled."
+ elif [ -z "$ASSET" ]; then
+ echo "exists=false" >> "$GITHUB_OUTPUT"
+ elif [ -n "$REPAIR_DIGEST" ] && \
+ [ "$(jq -r '.digest // ""' <<< "$ASSET")" = "$REPAIR_DIGEST" ]; then
+ echo "exists=false" >> "$GITHUB_OUTPUT"
+ echo "::notice::$SBOM_NAME matches a known inaccurate legacy digest and will be replaced once."
+ else
+ echo "exists=true" >> "$GITHUB_OUTPUT"
+ echo "::notice::$SBOM_NAME is already attached; preserving it."
+ fi
+
# CI tests the generator and its historical fixtures in their owning tree.
- 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.
+ if: ${{ steps.component.outputs.matched == 'true' && steps.existing.outputs.exists == 'false' }}
+ # License lookups degrade when unavailable; required POM/nuspec reads
+ # retry for the registry propagation window and then fail closed.
env:
GENERATOR_COMMIT: ${{ steps.generator.outputs.commit }}
RELEASE_TAG: ${{ steps.tag.outputs.tag }}
run: |
- node scripts/generate-sbom.mjs --tag "$RELEASE_TAG" \
- --output-dir sbom \
- --commit "$(git rev-parse HEAD)" \
- --generator-commit "$GENERATOR_COMMIT" \
- --with-licenses
+ for attempt in {1..16}; do
+ if OUTPUT=$(node scripts/generate-sbom.mjs --tag "$RELEASE_TAG" \
+ --output-dir sbom \
+ --commit "$(git rev-parse HEAD)" \
+ --generator-commit "$GENERATOR_COMMIT" \
+ --with-licenses 2>&1); then
+ echo "$OUTPUT"
+ exit 0
+ else
+ STATUS=$?
+ fi
+
+ echo "$OUTPUT"
+ # generate-sbom reserves EX_TEMPFAIL (75) for registry metadata.
+ if [ "$STATUS" -ne 75 ]; then
+ exit "$STATUS"
+ fi
+ if [ "$attempt" -eq 16 ]; then
+ echo "::error::Published dependency metadata remained unavailable after the registry propagation retry window."
+ exit 1
+ fi
+ echo "::notice::Registry metadata is still indexing; retrying in 120 seconds."
+ sleep 120
+ done
- name: Verify the SBOM describes this release
- if: ${{ steps.component.outputs.matched == 'true' }}
+ if: ${{ steps.component.outputs.matched == 'true' && steps.existing.outputs.exists == 'false' }}
env:
SBOM_FILE: ${{ steps.generate.outputs.sbom-file }}
EXPECTED_VERSION: ${{ steps.component.outputs.version }}
@@ -155,18 +244,109 @@ jobs:
echo "SBOM verified for $RELEASE_TAG at $ACTUAL_COMMIT"
- name: Attest SBOM provenance
- if: ${{ steps.component.outputs.matched == 'true' }}
+ if: ${{ steps.component.outputs.matched == 'true' && steps.existing.outputs.exists == 'false' }}
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' }}
+ if: ${{ steps.component.outputs.matched == 'true' && steps.existing.outputs.exists == 'false' }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ REPAIR_DIGEST: ${{ steps.component.outputs.repair-digest }}
RELEASE_TAG: ${{ steps.tag.outputs.tag }}
SBOM_FILE: ${{ steps.generate.outputs.sbom-file }}
- run: gh release upload "$RELEASE_TAG" "$SBOM_FILE"
+ SBOM_NAME: ${{ steps.component.outputs.sbom-name }}
+ run: |
+ if [ -z "$REPAIR_DIGEST" ]; then
+ gh release upload "$RELEASE_TAG" "$SBOM_FILE"
+ exit 0
+ fi
+
+ STAGED_NAME="$SBOM_NAME.replacement"
+ STAGED_FILE="$RUNNER_TEMP/$STAGED_NAME"
+ cp "$SBOM_FILE" "$STAGED_FILE"
+ RELEASE_JSON=$(gh api \
+ "repos/$GITHUB_REPOSITORY/releases/tags/$RELEASE_TAG")
+ CANONICAL_ASSET=$(jq -c --arg name "$SBOM_NAME" \
+ '[.assets[] | select(.name == $name)][0] // empty' \
+ <<< "$RELEASE_JSON")
+ STAGED_ASSET=$(jq -c --arg name "$STAGED_NAME" \
+ '[.assets[] | select(.name == $name)][0] // empty' \
+ <<< "$RELEASE_JSON")
+ CANONICAL_ASSET_ID=$(jq -r '.id // ""' <<< "$CANONICAL_ASSET")
+ CANONICAL_DIGEST=$(jq -r '.digest // ""' <<< "$CANONICAL_ASSET")
+ STAGED_ASSET_ID=$(jq -r '.id' <<< "$STAGED_ASSET")
+ STAGED_DIGEST=$(jq -r '.digest // ""' <<< "$STAGED_ASSET")
+ LOCAL_DIGEST="sha256:$(sha256sum "$STAGED_FILE" | cut -d ' ' -f 1)"
+
+ finalize_staged_asset() {
+ for _ in {1..5}; do
+ if gh api --method PATCH \
+ "repos/$GITHUB_REPOSITORY/releases/assets/$STAGED_ASSET_ID" \
+ -f name="$SBOM_NAME" >/dev/null; then
+ return 0
+ fi
+ sleep 5
+ done
+ echo "::error::The verified replacement remains attached as $STAGED_NAME but could not be renamed."
+ return 1
+ }
+
+ if [ -n "$CANONICAL_ASSET_ID" ] && [ "$CANONICAL_DIGEST" != "$REPAIR_DIGEST" ]; then
+ if [ -n "$STAGED_ASSET_ID" ]; then
+ gh api --method DELETE \
+ "repos/$GITHUB_REPOSITORY/releases/assets/$STAGED_ASSET_ID"
+ fi
+ echo "::notice::$SBOM_NAME is already corrected; any staged repair was removed."
+ exit 0
+ fi
+
+ if [ -z "$CANONICAL_ASSET_ID" ]; then
+ if [ -n "$STAGED_ASSET_ID" ] && [ "$STAGED_DIGEST" = "$LOCAL_DIGEST" ]; then
+ finalize_staged_asset
+ else
+ if [ -n "$STAGED_ASSET_ID" ]; then
+ gh api --method DELETE \
+ "repos/$GITHUB_REPOSITORY/releases/assets/$STAGED_ASSET_ID"
+ fi
+ gh release upload "$RELEASE_TAG" "$SBOM_FILE"
+ fi
+ exit 0
+ fi
+
+ if [ -n "$STAGED_ASSET_ID" ] && [ "$STAGED_DIGEST" != "$LOCAL_DIGEST" ]; then
+ gh api --method DELETE \
+ "repos/$GITHUB_REPOSITORY/releases/assets/$STAGED_ASSET_ID"
+ STAGED_ASSET_ID=""
+ fi
+ if [ -z "$STAGED_ASSET_ID" ]; then
+ gh release upload "$RELEASE_TAG" "$STAGED_FILE"
+ RELEASE_JSON=$(gh api \
+ "repos/$GITHUB_REPOSITORY/releases/tags/$RELEASE_TAG")
+ STAGED_ASSET=$(jq -c --arg name "$STAGED_NAME" \
+ '[.assets[] | select(.name == $name)][0] // empty' \
+ <<< "$RELEASE_JSON")
+ STAGED_ASSET_ID=$(jq -r '.id // ""' <<< "$STAGED_ASSET")
+ STAGED_DIGEST=$(jq -r '.digest // ""' <<< "$STAGED_ASSET")
+ fi
+ if [ -z "$STAGED_ASSET_ID" ] || [ "$STAGED_DIGEST" != "$LOCAL_DIGEST" ]; then
+ echo "::error::The staged replacement SBOM failed digest verification."
+ exit 1
+ fi
+
+ CURRENT_DIGEST=$(gh api \
+ "repos/$GITHUB_REPOSITORY/releases/assets/$CANONICAL_ASSET_ID" \
+ --jq '.digest // ""')
+ if [ "$CURRENT_DIGEST" != "$REPAIR_DIGEST" ]; then
+ gh api --method DELETE \
+ "repos/$GITHUB_REPOSITORY/releases/assets/$STAGED_ASSET_ID"
+ echo "::error::The legacy SBOM asset changed during repair; refusing to replace it."
+ exit 1
+ fi
+ gh api --method DELETE \
+ "repos/$GITHUB_REPOSITORY/releases/assets/$CANONICAL_ASSET_ID"
+ finalize_staged_asset
- name: Report a skipped tag
if: ${{ steps.component.outputs.matched != 'true' }}
diff --git a/SECURITY.md b/SECURITY.md
index 347806e26..255877f83 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -77,9 +77,9 @@ deadline:
| Within 72 hours of awareness | Assessment updated with severity, impact, and any mitigation available to users |
| Within 14 days of a fix or mitigation being available | Final assessment: root cause, the fix or mitigation, and the 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
+Affected current versions are determined from their attached SBOMs. Older
+releases without a backfilled asset are investigated from their immutable tag
+and published descriptors. Users are informed through the GitHub Security
Advisory, the release notes of the fixing release, and the repository README
when the impact is broad.
@@ -105,11 +105,13 @@ integrators can plan a migration rather than discover it during an incident.
## Supply Chain
-Every supported component release carries a CycloneDX SBOM as a GitHub Release
-asset, so you can check whether a specific version contains a given dependency:
+Current component release workflows attach a CycloneDX SBOM as a GitHub Release
+asset, and a daily repair job fills any missed latest-release asset. Use it to
+check whether a specific version declares a given dependency:
```bash
-gh release download react-native-iap-16.3.0 -p '*.cdx.json'
+gh release download react-native-iap-16.3.0 \
+ --repo hyodotdev/openiap -p '*.cdx.json'
gh attestation verify react-native-iap-16.3.0.cdx.json --repo hyodotdev/openiap
```
diff --git a/packages/docs/src/pages/docs/security/compliance.tsx b/packages/docs/src/pages/docs/security/compliance.tsx
index 7569f343b..f53e3e013 100644
--- a/packages/docs/src/pages/docs/security/compliance.tsx
+++ b/packages/docs/src/pages/docs/security/compliance.tsx
@@ -14,7 +14,7 @@ const DEADLINES: Deadline[] = [
{
date: '11 September 2026',
applies:
- 'Reporting obligations. Actively exploited vulnerabilities and severe incidents must be reported to ENISA and the relevant national CSIRT — 24-hour early warning, 72-hour notification, 14-day final report',
+ 'Reporting obligations: 24-hour early warning and 72-hour notification. The final report is due 14 days after a vulnerability fix or mitigation becomes available, or one month after a severe-incident notification',
},
{
date: '11 December 2027',
@@ -33,7 +33,8 @@ const PROVISIONS: Provision[] = [
need: 'Component inventory for a product you ship',
provided: (
<>
- The per-release SBOM, in CycloneDX 1.6
+ The current-release SBOM, in CycloneDX
+ 1.6
>
),
},
@@ -220,9 +221,9 @@ function SecurityCompliance() {
The assessment is published as a gap list, not a conformance claim —
it records what exists, what does not, and what is out of proportion
for a project of this size. Met today: the public reporting channel
- with a documented response path, and the automated per-release SBOM.
- Open: a single named security-assurance policy document, a maintainer
- responsibility inventory, and a declared license policy.
+ with a documented response path, and the automated current-release
+ SBOM. Open: a single named security-assurance policy document,
+ competency and assessment records, and a declared license policy.
@@ -130,7 +131,7 @@ function SecurityOverview() {
- What every release publishes
+ What current release workflows publish
A dependency that does not exist cannot be vulnerable. For the
@@ -187,8 +189,10 @@ function SecurityOverview() {
If the issue is being exploited in the wild, mark it{' '}
[SECURITY][ACTIVE]. That triggers an accelerated path: an
initial assessment within 24 hours, an updated assessment within 72
- hours, and a final assessment within 14 days — the windows the EU
- Cyber Resilience Act sets for reporting.
+ hours, and a final assessment within 14 days after a fix or mitigation
+ is available. This is OpenIAP's internal service level; legal
+ reporting duties depend on the affected party's role and the
+ event.
Receipt validation, purchase verification, and entitlement handling
@@ -224,9 +228,9 @@ function SecurityOverview() {
The important design choice: SBOMs are generated{' '}
after a release is published, not on every commit. A
- release tag is the only moment an inventory is meaningful, and it
- means the existing release workflows did not have to change — a new
- SDK added later is covered by the same path automatically.
+ release tag is the only moment an inventory is meaningful. Every
+ release lane must dispatch the shared SBOM workflow, and CI checks
+ that wiring.
@@ -236,24 +240,24 @@ function SecurityOverview() {
Dependabot watches IAPKit's dependency tree, the GitHub Actions
- used in release workflows, and the IAPKit container image. The
- published SDKs have no runtime dependency tree to watch.
+ used in release workflows, and the IAPKit container image. The three
+ published npm packages have no runtime dependency tree to watch.
Native SDK platform dependencies are pinned deliberately — several
carry inline notes explaining why a newer version is not yet
compatible with the supported toolchain range. They are reviewed as
part of platform upgrade work rather than bumped automatically, and
- each release's SBOM records exactly what shipped.
+ each current release's SBOM records its published direct
+ dependency contract. A toolchain resolver export can add transitive
+ entries when needed.
- GitHub's dependency graph does not parse this repository's
- lockfiles — Bun lockfiles are not a supported format, and Gradle
- builds are not resolved from source. Its generated inventory for this
- repository is empty. Dependabot's version updates still work,
- because they read manifests directly, but the platform cannot derive a
- component inventory on its own. The SBOMs published here are that
- inventory.
+ GitHub's dependency-graph SBOM endpoint currently returns HTTP
+ 404 for this repository, so it cannot serve as an independent
+ inventory. Dependabot's version updates still work because they
+ read manifests directly. The release SBOMs provide the
+ component-specific inventory here.
diff --git a/packages/docs/src/pages/docs/security/sbom.tsx b/packages/docs/src/pages/docs/security/sbom.tsx
index cb7d334c8..6649d10e5 100644
--- a/packages/docs/src/pages/docs/security/sbom.tsx
+++ b/packages/docs/src/pages/docs/security/sbom.tsx
@@ -127,13 +127,28 @@ const LIMITS: Limit[] = [
{
title: 'Transitive dependencies',
detail:
- "are included only where the ecosystem's resolver output is available. Direct runtime dependencies are always complete.",
+ "are included only where the ecosystem's resolver output is available. Release SBOMs otherwise describe the documented direct-dependency source.",
},
{
title: 'Licenses and suppliers',
detail:
'come from live registries. Unavailable metadata is omitted, and pub.dev and some NuGet packages do not expose a standard value.',
},
+ {
+ title: 'Version constraints',
+ detail:
+ 'remain constraints when a library manifest does not select one exact version. Use the consuming application lockfile for exact CVE matching.',
+ },
+ {
+ title: 'KMP target scope',
+ detail:
+ 'uses the published Android Play POM so openiap-google is included. An iOS-only application should use its resolved target graph.',
+ },
+ {
+ title: 'MAUI target scope',
+ detail:
+ "is the union of the published nuspec's target-framework groups. Use the consuming application's resolved graph to narrow it to Android, iOS, or Mac Catalyst.",
+ },
];
function SecuritySbom() {
@@ -143,19 +158,17 @@ function SecuritySbom() {
Software Bill of Materials
- Every published OpenIAP release carries a machine-readable inventory of
- the third-party code it contains, attached to its GitHub Release as a{' '}
- CycloneDX 1.6 JSON file. It exists to answer one
- question without anyone reading our build scripts:{' '}
-
- does this version of this package contain the vulnerable dependency?
-
+ Current OpenIAP release workflows attach a machine-readable inventory of
+ direct third-party dependencies to each GitHub Release as a{' '}
+ CycloneDX 1.6 JSON file. A daily repair job fills
+ missed latest-release assets. Exact application exposure comes from the
+ consumer's resolved dependency graph.
@@ -249,8 +262,8 @@ flutter_inapp_purchase-10.3.0.cdx.json`}
mismatched
- Every direct runtime dependency, with version, purl, supplier, and
- license
+ Every direct runtime dependency, with version or constraint, purl,
+ and available supplier and license data
@@ -305,8 +318,8 @@ flutter_inapp_purchase-10.3.0.cdx.json`}
scripts/generate-sbom.mjs uses only the Node.js standard
library — no npm package, no vendored code, no external binary. A tool
that reports what you depend on should not quietly add dependencies of
- its own. It reads package registries over HTTPS only to resolve
- declared licenses and suppliers.
+ its own. It reads published POM and nuspec dependency descriptors,
+ then resolves declared licenses and suppliers from registries.
diff --git a/scripts/audit-security.mjs b/scripts/audit-security.mjs
new file mode 100644
index 000000000..c7ab2f2e3
--- /dev/null
+++ b/scripts/audit-security.mjs
@@ -0,0 +1,126 @@
+#!/usr/bin/env node
+
+import { readFileSync } from "node:fs";
+import { resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const repoRoot = resolve(fileURLToPath(new URL("..", import.meta.url)));
+
+export function findWorkflowRunInterpolations(
+ source,
+ filename = "workflow.yml",
+) {
+ const lines = source.split("\n");
+ const findings = [];
+
+ for (let index = 0; index < lines.length; index += 1) {
+ const opener = lines[index].match(/^(\s*)(?:-\s*)?run:\s*(.*)$/u);
+ if (!opener) continue;
+ const indentation = opener[1].length;
+ if (opener[2].includes("${{")) {
+ findings.push(`${filename}:${index + 1}: ${lines[index].trim()}`);
+ }
+ if (
+ !/^[|>](?:(?:[1-9][-+]?)|(?:[-+][1-9]?))?(?:\s+#.*)?\s*$/u.test(opener[2])
+ )
+ continue;
+
+ for (let runIndex = index + 1; runIndex < lines.length; runIndex += 1) {
+ const line = lines[runIndex];
+ if (line.trim() && line.match(/^\s*/u)[0].length <= indentation) break;
+ if (line.includes("${{")) {
+ findings.push(`${filename}:${runIndex + 1}: ${line.trim()}`);
+ }
+ }
+ }
+
+ return findings;
+}
+
+export function extractExternalUrls(source) {
+ const urls = [];
+ for (const match of source.matchAll(/https?:\/\/[^\s<>"'`)\]}]+/gu)) {
+ const nextCharacter = source[match.index + match[0].length];
+ if (nextCharacter === "<" || nextCharacter === "{") continue;
+ urls.push(match[0].replace(/[.,;:]+$/u, ""));
+ }
+ return urls;
+}
+
+export async function auditWorkflowFiles(paths) {
+ const findings = paths.flatMap((path) =>
+ findWorkflowRunInterpolations(
+ readFileSync(resolve(repoRoot, path), "utf8"),
+ path,
+ ),
+ );
+ if (findings.length === 0) {
+ throw new Error(
+ "No workflow run expressions found; refusing to report a vacuous pass",
+ );
+ }
+ process.stdout.write(`${findings.join("\n")}\n`);
+}
+
+async function auditUrls(paths) {
+ const urls = [
+ ...new Set(
+ paths.flatMap((path) =>
+ extractExternalUrls(readFileSync(resolve(repoRoot, path), "utf8")),
+ ),
+ ),
+ ].sort();
+ if (urls.length === 0) {
+ throw new Error(
+ "No external URLs found; refusing to report a vacuous pass",
+ );
+ }
+
+ const failures = [];
+ await Promise.all(
+ urls.map(async (url) => {
+ try {
+ const response = await fetch(url, {
+ redirect: "follow",
+ signal: AbortSignal.timeout(20_000),
+ });
+ await response.body?.cancel();
+ if (!response.ok) failures.push(`${response.status} ${url}`);
+ } catch (error) {
+ failures.push(`ERROR ${url} (${error.message})`);
+ }
+ }),
+ );
+
+ process.stdout.write(
+ failures.length > 0
+ ? `${failures.sort().join("\n")}\n`
+ : `${urls.length} external URLs resolved.\n`,
+ );
+ if (failures.length > 0) process.exitCode = 1;
+}
+
+async function main() {
+ const [command, ...paths] = process.argv.slice(2);
+ if (!command || paths.length === 0) {
+ throw new Error(
+ "Usage: audit-security.mjs
",
+ );
+ }
+ if (command === "workflows") {
+ await auditWorkflowFiles(paths);
+ return;
+ }
+ if (command === "urls") {
+ await auditUrls(paths);
+ return;
+ }
+ throw new Error(`Unknown audit command '${command}'`);
+}
+
+if (process.argv[1] === fileURLToPath(import.meta.url)) {
+ main().catch((error) => {
+ console.error(`::error::${error.message}`);
+ process.exitCode = 1;
+ });
+}
diff --git a/scripts/audit-security.test.mjs b/scripts/audit-security.test.mjs
new file mode 100644
index 000000000..0df455985
--- /dev/null
+++ b/scripts/audit-security.test.mjs
@@ -0,0 +1,65 @@
+import assert from "node:assert/strict";
+import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { resolve } from "node:path";
+import test from "node:test";
+
+import {
+ auditWorkflowFiles,
+ extractExternalUrls,
+ findWorkflowRunInterpolations,
+} from "./audit-security.mjs";
+
+test("workflow scan detects expressions in scalar and block run steps", () => {
+ const workflow = `steps:
+ - run: echo "${"${{"} inputs.scalar }}"
+ - name: Block
+ run: |
+ echo "safe"
+ echo "${"${{"} github.event.issue.title }}"
+ - uses: actions/checkout@v7
+`;
+ assert.deepEqual(findWorkflowRunInterpolations(workflow, "fixture.yml"), [
+ 'fixture.yml:2: - run: echo "${{ inputs.scalar }}"',
+ 'fixture.yml:6: echo "${{ github.event.issue.title }}"',
+ ]);
+});
+
+test("workflow scan recognizes YAML block-scalar header variants", () => {
+ for (const header of ["|2", ">-2", "|2-", ">+2", "| # note", "|2 # note"]) {
+ const workflow = `steps:\n - run: ${header}\n echo "${"${{"} inputs.value }}"\n`;
+ assert.deepEqual(findWorkflowRunInterpolations(workflow, "fixture.yml"), [
+ 'fixture.yml:3: echo "${{ inputs.value }}"',
+ ]);
+ }
+});
+
+test("URL extraction removes JSX and Markdown delimiters", () => {
+ const source =
+ `href='https://example.com/path' [docs](https://example.org/a). ` +
+ `https://example.net/releases/`;
+ assert.deepEqual(extractExternalUrls(source), [
+ "https://example.com/path",
+ "https://example.org/a",
+ ]);
+});
+
+test("empty workflow scans fail instead of reporting a vacuous pass", async (t) => {
+ const scratch = mkdtempSync(resolve(tmpdir(), "openiap-security-audit-"));
+ const workflow = resolve(scratch, "empty.yml");
+ writeFileSync(workflow, "steps:\n - run: echo safe\n");
+ t.after(() => rmSync(scratch, { recursive: true, force: true }));
+
+ assert.deepEqual(
+ findWorkflowRunInterpolations("steps:\n - run: echo safe\n"),
+ [],
+ );
+ await assert.rejects(
+ () => auditWorkflowFiles([workflow]),
+ /refusing to report a vacuous pass/u,
+ );
+});
+
+test("empty URL extraction is explicit", () => {
+ assert.deepEqual(extractExternalUrls("no links"), []);
+});
diff --git a/scripts/generate-sbom.mjs b/scripts/generate-sbom.mjs
index 5915cde79..42a72bc00 100644
--- a/scripts/generate-sbom.mjs
+++ b/scripts/generate-sbom.mjs
@@ -34,6 +34,7 @@ import { validateVersion, versionSources } from "./release-branch-policy.mjs";
import {
extractDirectDependencies,
mergeResolved,
+ PublishedMetadataUnavailableError,
} from "./sbom-dependencies.mjs";
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
@@ -49,17 +50,25 @@ const DEFAULT_LICENSE = "MIT";
const GENERATOR_NAME = "openiap-sbom-generator";
const GENERATOR_VERSION = "1.0.0";
const SPEC_VERSION = "1.6";
+export const PUBLISHED_METADATA_UNAVAILABLE_EXIT_CODE = 75;
+
+const INACCURATE_SBOM_DIGESTS = new Map([
+ [
+ "google-3.3.0",
+ "sha256:7256739c147689fbbb1257a738f85e7d030bb3612fd52a903c4cc9ca72b00e66",
+ ],
+]);
/**
* 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.
+ * which released input describes its runtime dependencies.
*/
const COMPONENTS = {
apple: {
- sbomName: "openiap-apple",
+ sbomName: "openiap",
type: "library",
purl: (version) => `pkg:cocoapods/openiap@${version}`,
distribution: (version) => `https://cocoapods.org/pods/openiap`,
@@ -107,7 +116,6 @@ const COMPONENTS = {
kind: "pub",
manifest: "libraries/flutter_inapp_purchase/pubspec.yaml",
},
- resolver: "flutter pub deps --json",
},
godot: {
sbomName: "godot-iap",
@@ -128,7 +136,6 @@ const COMPONENTS = {
},
},
},
- resolver: "gradlew :dependencies",
},
google: {
sbomName: "openiap-google",
@@ -139,10 +146,10 @@ const COMPONENTS = {
`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",
+ kind: "maven-pom",
+ coordinate: "io.github.hyochan.openiap:openiap-google",
+ repositories: ["https://repo1.maven.org/maven2"],
},
- resolver: "gradlew :openiap:dependencies",
},
kmp: {
sbomName: "kmp-iap",
@@ -155,11 +162,14 @@ const COMPONENTS = {
`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",
+ kind: "maven-pom",
+ coordinates: [
+ "io.github.hyochan:kmp-iap-android-play",
+ "io.github.hyochan:kmp-iap-android",
+ "io.github.hyochan:kmp-iap",
+ ],
+ repositories: ["https://repo1.maven.org/maven2"],
},
- resolver: "gradlew :library:dependencies",
},
maui: {
sbomName: "OpenIap.Maui",
@@ -169,14 +179,9 @@ const COMPONENTS = {
`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",
- ],
+ kind: "nuget-nuspec",
+ packageId: "OpenIap.Maui",
},
- resolver: "dotnet list package --include-transitive",
},
"react-native": {
sbomName: "react-native-iap",
@@ -227,23 +232,67 @@ 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);
+export function inaccurateSbomDigestForTag(tag) {
+ return INACCURATE_SBOM_DIGESTS.get(tag) ?? "";
+}
+
+/** Return newest missing releases plus every known-inaccurate legacy SBOM. */
+export function findMissingLatestSbomTags(releases) {
+ const seen = new Set();
+ const missing = [];
+ const newestFirst = releases
+ .filter((release) => !release?.draft && release?.published_at)
+ .sort(
+ (left, right) =>
+ Date.parse(right.published_at) - Date.parse(left.published_at),
+ );
+ for (const release of newestFirst) {
+ const resolvedTag = componentFromTag(release.tag_name);
+ if (!resolvedTag) continue;
+ const expected = sbomFileName(resolvedTag.componentId, resolvedTag.version);
+ const assets = Array.isArray(release.assets) ? release.assets : [];
+ const asset = assets.find((entry) => entry?.name === expected);
+ const stagedAsset = assets.find(
+ (entry) => entry?.name === `${expected}.replacement`,
+ );
+ const inaccurateDigest = inaccurateSbomDigestForTag(release.tag_name);
+
+ // Legacy repairs remain eligible even after a newer component release.
+ if (
+ inaccurateDigest &&
+ (stagedAsset || !asset || asset.digest === inaccurateDigest)
+ ) {
+ missing.push(release.tag_name);
+ }
+
+ if (seen.has(resolvedTag.componentId)) continue;
+ seen.add(resolvedTag.componentId);
+ if (!asset && !inaccurateDigest) missing.push(release.tag_name);
+ }
+ return missing;
+}
+
+const TAG_VERSION_PLACEHOLDER = "9.8.7";
+
+/** Tag aliases are derived from the same package config release validation uses. */
+const TAG_PATTERNS = [
+ ...Object.entries(PACKAGE_CONFIG).flatMap(([componentId, config]) =>
+ config.tags(TAG_VERSION_PLACEHOLDER).map((tag) => {
+ const index = tag.indexOf(TAG_VERSION_PLACEHOLDER);
+ if (index === -1) {
+ throw new Error(
+ `Release tag pattern for ${componentId} has no version`,
+ );
+ }
+ return {
+ componentId,
+ prefix: tag.slice(0, index),
+ suffix: tag.slice(index + TAG_VERSION_PLACEHOLDER.length),
+ };
+ }),
+ ),
+ { componentId: "docs", prefix: "docs-", suffix: "" },
+].sort((left, right) => right.prefix.length - left.prefix.length);
/**
* Map a published release tag back to the component that produced it.
@@ -255,18 +304,18 @@ 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;
+ for (const { componentId, prefix, suffix } of TAG_PATTERNS) {
+ if (!normalized.startsWith(prefix) || !normalized.endsWith(suffix)) {
+ continue;
+ }
+ const version = normalized.slice(
+ prefix.length,
+ suffix ? -suffix.length : undefined,
+ );
+ if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/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;
}
@@ -366,10 +415,15 @@ function dependencyComponent(entry) {
if (entry.licenses?.length) {
component.licenses = entry.licenses;
}
+ const properties = [...(entry.properties ?? [])];
if (entry.transitive) {
- component.properties = [
- { name: "openiap:sbom:relationship", value: "transitive" },
- ];
+ properties.push({
+ name: "openiap:sbom:relationship",
+ value: "transitive",
+ });
+ }
+ if (properties.length > 0) {
+ component.properties = properties;
}
return component;
}
@@ -494,6 +548,29 @@ async function fetchText(url) {
return response.text();
}
+async function fetchPublishedText(
+ url,
+ {
+ fetcher = fetchText,
+ retryDelays = Array(5).fill(5_000),
+ wait = (delay) =>
+ new Promise((resolveDelay) => setTimeout(resolveDelay, delay)),
+ } = {},
+) {
+ for (let attempt = 0; attempt <= retryDelays.length; attempt += 1) {
+ try {
+ const result = await fetcher(url);
+ if (result) return result;
+ } catch {
+ // Registry transport failures are retryable just like an indexing 404.
+ }
+ if (attempt < retryDelays.length) {
+ await wait(retryDelays[attempt]);
+ }
+ }
+ return null;
+}
+
/**
* Look up a dependency's declared license and supplier in its own registry.
*
@@ -680,6 +757,7 @@ export async function generateSbom(
resolvedFile,
withLicenses = false,
runGit = defaultRunGit,
+ fetchArtifactText = fetchPublishedText,
} = {},
) {
const definition = COMPONENTS[componentId];
@@ -698,7 +776,10 @@ export async function generateSbom(
runGit(["show", "-s", "--format=%cI", resolvedCommit]),
).toISOString();
- const direct = extractDirectDependencies(root, definition.source);
+ const direct = await extractDirectDependencies(root, definition.source, {
+ version,
+ fetchText: fetchArtifactText,
+ });
const merged = resolvedFile
? mergeResolved(direct, readResolvedFile(resolvedFile))
: direct;
@@ -784,13 +865,27 @@ function parseArguments(argv) {
async function main() {
const [maybeCommand] = process.argv.slice(2);
+ if (maybeCommand === "missing-release-tags") {
+ const path = process.argv[3];
+ if (!path)
+ throw new Error("Usage: generate-sbom.mjs missing-release-tags FILE");
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
+ const releases = Array.isArray(parsed?.[0]) ? parsed.flat() : parsed;
+ if (!Array.isArray(releases)) {
+ throw new Error(`Release list must be a JSON array: ${path}`);
+ }
+ const tags = findMissingLatestSbomTags(releases);
+ process.stdout.write(tags.length > 0 ? `${tags.join("\n")}\n` : "");
+ return;
+ }
+
// `resolve-tag` lets a workflow map a published release back to its component
// without duplicating the tag conventions in YAML.
if (maybeCommand === "resolve-tag") {
const tag = process.argv[3];
const resolved = componentFromTag(tag);
const line = resolved
- ? `component=${resolved.componentId}\nversion=${resolved.version}\nmatched=true\n`
+ ? `component=${resolved.componentId}\nversion=${resolved.version}\nsbom-name=${sbomFileName(resolved.componentId, resolved.version)}\nrepair-digest=${inaccurateSbomDigestForTag(tag)}\nmatched=true\n`
: "matched=false\n";
process.stdout.write(line);
if (process.env.GITHUB_OUTPUT) {
@@ -841,9 +936,17 @@ async function main() {
if (process.argv[1] === fileURLToPath(import.meta.url)) {
main().catch((error) => {
- console.error(`::error::${error.message}`);
- process.exitCode = 1;
+ const message = error instanceof Error ? error.message : String(error);
+ console.error(`::error::${message}`);
+ process.exitCode =
+ error instanceof PublishedMetadataUnavailableError
+ ? PUBLISHED_METADATA_UNAVAILABLE_EXIT_CODE
+ : 1;
});
}
-export const __testing = { COMPONENTS, deterministicSerialNumber };
+export const __testing = {
+ COMPONENTS,
+ deterministicSerialNumber,
+ fetchPublishedText,
+};
diff --git a/scripts/generate-sbom.test.mjs b/scripts/generate-sbom.test.mjs
index a3857b47f..a62c52625 100644
--- a/scripts/generate-sbom.test.mjs
+++ b/scripts/generate-sbom.test.mjs
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
import {
mkdirSync,
mkdtempSync,
+ readdirSync,
readFileSync,
rmSync,
writeFileSync,
@@ -15,9 +16,12 @@ import {
__testing as generatorTesting,
buildSbom,
componentFromTag,
+ findMissingLatestSbomTags,
generateSbom,
+ inaccurateSbomDigestForTag,
listComponentIds,
normalizeLicense,
+ PUBLISHED_METADATA_UNAVAILABLE_EXIT_CODE,
readComponentVersion,
readVexStatements,
releaseTagFor,
@@ -26,7 +30,9 @@ import {
import { PACKAGE_CONFIG } from "./assert-release-tag.mjs";
import {
__testing as dependencyTesting,
+ extractDirectDependencies,
mergeResolved,
+ PublishedMetadataUnavailableError,
} from "./sbom-dependencies.mjs";
import { versionSources } from "./release-branch-policy.mjs";
@@ -35,18 +41,112 @@ const historicalGoogleRoot = resolve(
repoRoot,
"scripts/fixtures/historical-releases/google-v1.3.0",
);
-const { COMPONENTS } = generatorTesting;
+const { COMPONENTS, fetchPublishedText } = generatorTesting;
const {
- expandGradleForLoops,
extractGradle,
- extractNuget,
extractPub,
isRuntimeGradleConfiguration,
parseMavenCoordinate,
- parseVersionCatalog,
- stripTestSourceSets,
+ parseMavenPom,
+ parseNugetNuspec,
} = dependencyTesting;
+function mavenPom(dependencies) {
+ return `${dependencies
+ .map(
+ ([group, artifact, version, scope = "runtime"]) =>
+ `${group}${artifact}` +
+ `${version}${scope}`,
+ )
+ .join("")}`;
+}
+
+const googleDependencies = [
+ ["com.android.billingclient", "billing", "9.1.0", "compile"],
+ ["org.jetbrains.kotlin", "kotlin-stdlib", "2.2.0", "compile"],
+ ["androidx.core", "core", "1.18.0"],
+ ["androidx.lifecycle", "lifecycle-runtime", "2.10.0"],
+ ["org.jetbrains.kotlinx", "kotlinx-coroutines-core", "1.11.0"],
+ ["org.jetbrains.kotlinx", "kotlinx-coroutines-android", "1.11.0"],
+ ["androidx.lifecycle", "lifecycle-viewmodel", "2.10.0"],
+ ["com.google.code.gson", "gson", "2.14.0"],
+ ["androidx.compose.runtime", "runtime", "1.11.4"],
+ ["androidx.compose.ui", "ui", "1.11.4"],
+];
+
+const historicalGoogleDependencies = [
+ ["com.android.billingclient", "billing-ktx", "8.0.0", "compile"],
+ [
+ "com.meta.horizon.billingclient.api",
+ "horizon-billing-compatibility",
+ "1.1.1",
+ "compile",
+ ],
+ ["org.jetbrains.kotlin", "kotlin-stdlib", "2.0.21", "compile"],
+ ["androidx.core", "core-ktx", "1.12.0"],
+ ["androidx.lifecycle", "lifecycle-runtime-ktx", "2.7.0"],
+ ["org.jetbrains.kotlinx", "kotlinx-coroutines-core", "1.9.0"],
+ ["org.jetbrains.kotlinx", "kotlinx-coroutines-android", "1.9.0"],
+ ["androidx.lifecycle", "lifecycle-viewmodel-ktx", "2.7.0"],
+ ["com.google.code.gson", "gson", "2.10.1"],
+ ["androidx.compose.runtime", "runtime", "1.6.8"],
+ ["androidx.compose.ui", "ui", "1.6.8"],
+];
+
+const kmpDependencies = [
+ ["org.jetbrains.kotlinx", "kotlinx-coroutines-core-jvm", "1.11.0", "compile"],
+ ["org.jetbrains.kotlin", "kotlin-stdlib", "2.4.10", "compile"],
+ ["io.github.hyochan.openiap", "openiap-google", "3.3.0"],
+ ["org.jetbrains.kotlinx", "kotlinx-datetime-jvm", "0.8.0"],
+ ["org.jetbrains.kotlinx", "kotlinx-serialization-json-jvm", "1.11.0"],
+];
+
+const mauiDependencies = [
+ ["GoogleGson", "2.14.0.1"],
+ ["Xamarin.Android.Google.BillingClient", "9.1.0.1"],
+ ["Xamarin.AndroidX.Activity", "1.13.0.1"],
+ ["Xamarin.AndroidX.Activity.Ktx", "1.13.0.1"],
+ ["Xamarin.AndroidX.Collection.Ktx", "1.6.0.1"],
+ ["Xamarin.AndroidX.Fragment", "1.8.9.3"],
+ ["Xamarin.AndroidX.Fragment.Ktx", "1.8.9.4"],
+ ["Xamarin.AndroidX.Lifecycle.LiveData", "2.11.0.1"],
+ ["Xamarin.AndroidX.Lifecycle.LiveData.Core", "2.11.0.1"],
+ ["Xamarin.AndroidX.Lifecycle.LiveData.Core.Ktx", "2.11.0.1"],
+ ["Xamarin.AndroidX.Lifecycle.Process", "2.11.0.1"],
+ ["Xamarin.AndroidX.Lifecycle.Runtime", "2.11.0.1"],
+ ["Xamarin.AndroidX.Lifecycle.Runtime.Ktx", "2.11.0.1"],
+ ["Xamarin.AndroidX.Lifecycle.Runtime.Ktx.Android", "2.11.0.1"],
+ ["Xamarin.AndroidX.Lifecycle.ViewModel", "2.11.0.1"],
+ ["Xamarin.AndroidX.Lifecycle.ViewModel.Ktx", "2.11.0.1"],
+ ["Xamarin.AndroidX.SavedState", "1.5.0.1"],
+ ["Xamarin.AndroidX.SavedState.SavedState.Ktx", "1.5.0.1"],
+ ["Xamarin.Kotlin.StdLib", "2.4.0.1"],
+ ["Xamarin.KotlinX.Coroutines.Android", "1.11.0.1"],
+ ["Xamarin.KotlinX.Coroutines.Core", "1.11.0.1"],
+ ["Xamarin.KotlinX.Coroutines.Core.Jvm", "1.11.0.1"],
+];
+
+const mauiNuspec = `${mauiDependencies
+ .map(([name, version]) => ``)
+ .join("")}`;
+
+globalThis.fetch = async (input) => {
+ const url = String(input);
+ if (url.includes("openiap-google/1.3.0/")) {
+ return new Response(mavenPom(historicalGoogleDependencies));
+ }
+ if (url.includes("openiap-google/")) {
+ return new Response(mavenPom(googleDependencies));
+ }
+ if (url.includes("kmp-iap-android-play/")) {
+ return new Response(mavenPom(kmpDependencies));
+ }
+ if (url.includes("openiap.maui.nuspec")) {
+ return new Response(mauiNuspec);
+ }
+ return new Response("", { status: 404 });
+};
+
const stubCommit = "0".repeat(40);
const stubGit = (args) =>
args[0] === "rev-parse" ? stubCommit : "2026-01-02T03:04:05+00:00";
@@ -74,6 +174,7 @@ test("SBOM file name matches the documented convention", () => {
sbomFileName("conformance", "1.0.0"),
"openiap-conformance-1.0.0.cdx.json",
);
+ assert.equal(sbomFileName("apple", "3.2.0"), "openiap-3.2.0.cdx.json");
});
test("release tags match the release-tag SSOT", () => {
@@ -112,9 +213,158 @@ test("every release tag pattern resolves back to its own component", () => {
assert.equal(componentFromTag("apple-v1.2.3").componentId, "apple");
assert.equal(componentFromTag("1.2.3").componentId, "apple");
assert.equal(componentFromTag("some-unrelated-tag"), null);
+ assert.equal(componentFromTag("google-1.2.3-not-a-version!"), null);
assert.equal(componentFromTag(""), null);
});
+test("backfill selects only the newest missing SBOM per component", () => {
+ const releases = [
+ {
+ tag_name: "google-3.3.0",
+ published_at: "2026-08-11T00:00:00Z",
+ assets: [{ name: "openiap-google-3.3.0.cdx.json" }],
+ },
+ {
+ tag_name: "kmp-iap-3.3.0",
+ published_at: "2026-08-11T00:00:00Z",
+ assets: [{ name: "release-artifacts.zip" }],
+ },
+ {
+ tag_name: "kmp-iap-3.2.2",
+ published_at: "2026-08-10T00:00:00Z",
+ assets: [],
+ },
+ {
+ tag_name: "draft-1.0.0",
+ published_at: null,
+ draft: true,
+ assets: [],
+ },
+ ];
+ assert.deepEqual(findMissingLatestSbomTags(releases), ["kmp-iap-3.3.0"]);
+});
+
+test("backfill repairs only the exact known inaccurate SBOM", () => {
+ const tag = "google-3.3.0";
+ const name = "openiap-google-3.3.0.cdx.json";
+ const published_at = "2026-08-11T00:00:00Z";
+ const inaccurate = inaccurateSbomDigestForTag(tag);
+
+ assert.match(inaccurate, /^sha256:[0-9a-f]{64}$/u);
+ assert.deepEqual(
+ findMissingLatestSbomTags([
+ { tag_name: tag, published_at, assets: [{ name, digest: inaccurate }] },
+ ]),
+ [tag],
+ );
+ assert.deepEqual(
+ findMissingLatestSbomTags([
+ {
+ tag_name: tag,
+ published_at,
+ assets: [
+ { name, digest: `sha256:${"0".repeat(64)}` },
+ { name: `${name}.replacement` },
+ ],
+ },
+ ]),
+ [tag],
+ );
+ assert.deepEqual(
+ findMissingLatestSbomTags([
+ {
+ tag_name: tag,
+ published_at,
+ assets: [{ name, digest: `sha256:${"0".repeat(64)}` }],
+ },
+ ]),
+ [],
+ );
+ assert.deepEqual(
+ findMissingLatestSbomTags([
+ {
+ tag_name: "google-3.4.0",
+ published_at: "2026-08-12T00:00:00Z",
+ assets: [{ name: "openiap-google-3.4.0.cdx.json" }],
+ },
+ { tag_name: tag, published_at, assets: [{ name, digest: inaccurate }] },
+ ]),
+ [tag],
+ );
+});
+
+test("every GitHub release workflow dispatches the SBOM workflow", () => {
+ const workflowDir = resolve(repoRoot, ".github/workflows");
+ const workflows = readdirSync(workflowDir)
+ .filter((name) => name.endsWith(".yml"))
+ .map((name) => [name, readFileSync(resolve(workflowDir, name), "utf8")])
+ .filter(([, source]) =>
+ /softprops\/action-gh-release|gh release create/u.test(source),
+ );
+
+ assert.ok(
+ workflows.length > 0,
+ "release workflow discovery must not be empty",
+ );
+ for (const [name, source] of workflows) {
+ const releaseIndex = Math.max(
+ source.lastIndexOf("softprops/action-gh-release"),
+ source.lastIndexOf("gh release create"),
+ );
+ const dispatchIndex = source.indexOf("gh workflow run sbom.yml");
+ assert.ok(dispatchIndex > releaseIndex, `${name} dispatch order`);
+ const dispatchCommand = source
+ .slice(dispatchIndex)
+ .match(/^gh workflow run sbom\.yml[^\n]*(?:\\\n\s+[^\n]*)*/u)?.[0];
+ assert.match(dispatchCommand, /-f tag="\$RELEASE_TAG"/u, `${name} tag`);
+ assert.match(source, /actions: write/u, name);
+ }
+});
+
+test("SBOM publication waits for registry propagation and repairs daily", () => {
+ const source = readFileSync(
+ resolve(repoRoot, ".github/workflows/sbom.yml"),
+ "utf8",
+ );
+ assert.match(source, /cron: "23 3 \* \* \*"/u);
+ assert.match(source, /for attempt in \{1\.\.16\}/u);
+ assert.match(source, /\[ "\$STATUS" -ne 75 \]/u);
+ assert.doesNotMatch(source, /grep.+Published/u);
+ assert.match(source, /A staged legacy repair will be reconciled/u);
+ assert.match(source, /CURRENT_DIGEST.*!=.*REPAIR_DIGEST/u);
+ const stagedUpload = source.indexOf(
+ 'gh release upload "$RELEASE_TAG" "$STAGED_FILE"',
+ );
+ const legacyDelete = source.indexOf(
+ 'gh api --method DELETE \\\n "repos/$GITHUB_REPOSITORY/releases/assets/$CANONICAL_ASSET_ID"',
+ );
+ const stagedRename = source.indexOf(
+ "\n finalize_staged_asset\n",
+ legacyDelete,
+ );
+ assert.ok(stagedUpload >= 0, "replacement must be uploaded before deletion");
+ assert.ok(legacyDelete > stagedUpload, "legacy deletion must follow staging");
+ assert.ok(stagedRename > legacyDelete, "staged asset must be finalized last");
+ assert.match(source, /STAGED_DIGEST.*!=.*LOCAL_DIGEST/u);
+ assert.match(source, /STAGED_NAME="\$SBOM_NAME\.replacement"/u);
+ assert.match(source, /if \[ -z "\$CANONICAL_ASSET_ID" \]/u);
+ assert.match(source, /STAGED_DIGEST" = "\$LOCAL_DIGEST/u);
+ assert.match(source, /persist-credentials: false/u);
+ assert.match(source, /sleep 120/u);
+});
+
+test("Google releases fail when an unpublished flavor lacks credentials", () => {
+ const source = readFileSync(
+ resolve(repoRoot, ".github/workflows/release-google.yml"),
+ "utf8",
+ );
+ assert.equal(
+ source.match(/Maven Central credentials are required to publish/gu)?.length,
+ 3,
+ );
+ assert.doesNotMatch(source, /Skipping publish/u);
+});
+
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")) {
@@ -150,7 +400,7 @@ test("current generator supports the google-v1.3.0 release tree", async () => {
});
assert.equal(document.metadata.component.version, "1.3.0");
- assert.equal(directCount, 10);
+ assert.equal(directCount, 11);
assert.deepEqual(
document.components.map((component) => component.name),
[
@@ -162,6 +412,7 @@ test("current generator supports the google-v1.3.0 release tree", async () => {
"com.android.billingclient:billing-ktx",
"com.google.code.gson:gson",
"com.meta.horizon.billingclient.api:horizon-billing-compatibility",
+ "org.jetbrains.kotlin:kotlin-stdlib",
"org.jetbrains.kotlinx:kotlinx-coroutines-android",
"org.jetbrains.kotlinx:kotlinx-coroutines-core",
],
@@ -276,12 +527,9 @@ test("generated SBOMs never embed local filesystem paths", async () => {
}
});
-test("test-only Gradle configurations stay out of the inventory", () => {
+test("Gradle declarations are classified or fail closed", () => {
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"),
@@ -290,97 +538,255 @@ test("test-only Gradle configurations stay out of the inventory", () => {
assert.equal(isRuntimeGradleConfiguration("compileOnly"), false);
assert.equal(isRuntimeGradleConfiguration("playCompileOnly"), false);
assert.equal(isRuntimeGradleConfiguration("kaptImplementation"), false);
+ assert.throws(
+ () => isRuntimeGradleConfiguration("playApi"),
+ /Unclassified Gradle dependency configuration/u,
+ );
});
-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' +
- "}",
+test("an unmodelled Gradle coordinate fails instead of silently vanishing", (t) => {
+ assert.throws(
+ () => parseMavenCoordinate("com.example:lib:$unknownVersion"),
+ /Unresolved Maven coordinate/u,
+ );
+ assert.throws(
+ () => parseMavenCoordinate("com.example:lib:1.0.0:sources"),
+ /Unsupported Maven coordinate/u,
);
- 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,
+ const scratch = mkdtempSync(resolve(tmpdir(), "openiap-gradle-"));
+ t.after(() => rmSync(scratch, { recursive: true, force: true }));
+ writeFileSync(
+ resolve(scratch, "build.gradle.kts"),
+ 'dependencies { implementation(group = "com.example", name = "lib", version = "1.0.0") }\n',
+ );
+ assert.throws(
+ () => extractGradle(scratch, { manifest: "build.gradle.kts" }),
+ /Unsupported Gradle dependency declaration/u,
+ );
+ writeFileSync(
+ resolve(scratch, "build.gradle.kts"),
+ "dependencies { customRuntime(libs.example) }\n",
+ );
+ assert.throws(
+ () => extractGradle(scratch, { manifest: "build.gradle.kts" }),
+ /Unsupported version-catalog dependency/u,
+ );
+ writeFileSync(
+ resolve(scratch, "build.gradle.kts"),
+ 'dependencies { implementation(project(":unexpected")) }\n',
+ );
+ assert.throws(
+ () => extractGradle(scratch, { manifest: "build.gradle.kts" }),
+ /Unsupported Gradle dependency declaration/u,
+ );
+ writeFileSync(
+ resolve(scratch, "build.gradle.kts"),
+ 'dependencies { implementation(project(":openiap")) }\n',
+ );
+ assert.throws(
+ () => extractGradle(scratch, { manifest: "build.gradle.kts" }),
+ /lacks its published fallback/u,
+ );
+ writeFileSync(
+ resolve(scratch, "build.gradle.kts"),
+ 'dependencies { customRuntime(group = "com.example", name = "lib", version = "1.0.0") }\n',
+ );
+ assert.throws(
+ () => extractGradle(scratch, { manifest: "build.gradle.kts" }),
+ /Unclassified Gradle dependency configuration/u,
+ );
+ writeFileSync(
+ resolve(scratch, "build.gradle.kts"),
+ 'dependencies { if (enabled == true) customRuntime("com.example:lib:1.0.0") }\n',
+ );
+ assert.throws(
+ () => extractGradle(scratch, { manifest: "build.gradle.kts" }),
+ /Unclassified Gradle dependency configuration/u,
+ );
+ writeFileSync(
+ resolve(scratch, "build.gradle.kts"),
+ 'dependencies { while (enabled) { implementation("real:dependency:2.0.0") } }\n',
+ );
+ assert.deepEqual(
+ extractGradle(scratch, { manifest: "build.gradle.kts" }).map(
+ (entry) => entry.name,
+ ),
+ ["real:dependency"],
+ );
+ writeFileSync(
+ resolve(scratch, "build.gradle.kts"),
+ "android { compileSdk(libs.versions.compileSdk.get()) }\n" +
+ 'dependencies { // implementation("fake:comment:1.0.0")\n' +
+ ' implementation("real:dependency:2.0.0")\n}\n',
+ );
+ assert.deepEqual(extractGradle(scratch, { manifest: "build.gradle.kts" }), [
+ {
+ name: "real:dependency",
+ purl: "pkg:maven/real/dependency@2.0.0",
+ version: "2.0.0",
+ },
+ ]);
+ writeFileSync(
+ resolve(scratch, "build.gradle.kts"),
+ 'buildscript { dependencies { classpath("build:plugin:1.0.0") } }\n' +
+ 'dependencies { implementation("real:dependency:2.0.0") }\n',
+ );
+ assert.deepEqual(
+ extractGradle(scratch, { manifest: "build.gradle.kts" }).map(
+ (entry) => entry.name,
+ ),
+ ["real:dependency"],
+ );
+ writeFileSync(
+ resolve(scratch, "build.gradle.kts"),
+ 'subprojects { dependencies { implementation("hidden:dependency:1.0.0") } }\n' +
+ 'dependencies { implementation("real:dependency:2.0.0") }\n',
+ );
+ assert.throws(
+ () => extractGradle(scratch, { manifest: "build.gradle.kts" }),
+ /Unsupported nested dependencies block/u,
+ );
+ writeFileSync(
+ resolve(scratch, "build.gradle.kts"),
+ 'dependencies { implementation("real:dependency:2.0.0") { exclude(group = "fake") } }\n',
+ );
+ assert.deepEqual(
+ extractGradle(scratch, { manifest: "build.gradle.kts" }).map(
+ (entry) => entry.name,
+ ),
+ ["real:dependency"],
+ );
+ writeFileSync(
+ resolve(scratch, "build.gradle.kts"),
+ 'dependencies { customContainer("value") { implementation("hidden:dependency:1.0.0") } }\n',
+ );
+ assert.throws(
+ () => extractGradle(scratch, { manifest: "build.gradle.kts" }),
+ /Unclassified Gradle dependency configuration/u,
+ );
+ writeFileSync(
+ resolve(scratch, "build.gradle.kts"),
+ 'dependencies { constraints { implementation("constraint:only:3.0.0") }\n' +
+ ' implementation("real:dependency:2.0.0")\n}\n',
+ );
+ assert.deepEqual(
+ extractGradle(scratch, { manifest: "build.gradle.kts" }).map(
+ (entry) => entry.name,
+ ),
+ ["real:dependency"],
);
- 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("published metadata matches the consumer-visible artifacts", async () => {
+ const google = await generateSbom("google", {
+ root: repoRoot,
+ runGit: stubGit,
});
-});
-
-test("KMP test source sets are excluded", async () => {
- 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 googleNames = google.document.components.map((entry) => entry.name);
+ assert.equal(google.directCount, 10);
+ assert.ok(googleNames.includes("org.jetbrains.kotlin:kotlin-stdlib"));
+ assert.ok(!googleNames.some((name) => name.includes("horizon")));
+ assert.ok(!googleNames.some((name) => name.includes("amazon")));
const kmp = await generateSbom("kmp", { root: repoRoot, runGit: stubGit });
- const names = kmp.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"));
+ const kmpNames = kmp.document.components.map((entry) => entry.name);
+ assert.equal(kmp.directCount, 5);
+ assert.ok(kmpNames.includes("io.github.hyochan.openiap:openiap-google"));
+ assert.ok(kmpNames.includes("org.jetbrains.kotlin:kotlin-stdlib"));
+
+ const maui = await generateSbom("maui", { root: repoRoot, runGit: stubGit });
+ assert.equal(maui.directCount, 22);
+ assert.ok(
+ !maui.document.components.some((entry) =>
+ entry.name.includes("Serialization"),
+ ),
+ );
});
-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',
+test("published metadata parsers reject unsupported dependencies", () => {
+ assert.throws(
+ () =>
+ parseMavenPom(
+ "g" +
+ "a",
+ { url: "fixture.pom", version: "1.0.0" },
+ ),
+ /Incomplete runtime dependency/u,
);
- assert.equal(versions.get("foo"), "1.2.3");
- assert.deepEqual(libraries.get("bar-baz"), {
- module: "com.example:bar",
- versionRef: "foo",
- literal: undefined,
+ assert.throws(
+ () =>
+ parseNugetNuspec(
+ '',
+ { url: "fixture.nuspec" },
+ ),
+ /Incomplete published NuGet dependency/u,
+ );
+ assert.throws(
+ () =>
+ parseMavenPom(mavenPom([["g", "a", "1.0.0", "unclassified"]]), {
+ url: "fixture.pom",
+ version: "1.0.0",
+ }),
+ /Unsupported Maven scope/u,
+ );
+ assert.throws(
+ () =>
+ parseMavenPom(
+ "" +
+ "ga" +
+ "1.0.0" +
+ "",
+ { url: "fixture.pom", version: "1.0.0" },
+ ),
+ /Unsupported profiled Maven dependency/u,
+ );
+});
+
+test("published metadata fetches retry transport failures", async () => {
+ let attempts = 0;
+ const result = await fetchPublishedText("https://example.com/package.pom", {
+ fetcher: async () => {
+ attempts += 1;
+ if (attempts === 1) throw new Error("temporary DNS failure");
+ return "";
+ },
+ retryDelays: [0],
+ wait: async () => {},
});
+
+ assert.equal(result, "");
+ assert.equal(attempts, 2);
});
-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("missing published metadata has a dedicated error type", async () => {
+ assert.equal(PUBLISHED_METADATA_UNAVAILABLE_EXIT_CODE, 75);
+ await assert.rejects(
+ () =>
+ extractDirectDependencies(
+ repoRoot,
+ {
+ kind: "maven-pom",
+ coordinate: "example:package",
+ repositories: ["https://example.com/maven"],
+ },
+ { version: "1.0.0", fetchText: async () => null },
+ ),
+ (error) => error instanceof PublishedMetadataUnavailableError,
+ );
});
test("pub dependencies exclude the Flutter SDK itself", () => {
- const names = extractPub(repoRoot, COMPONENTS.flutter.source).map(
- (entry) => entry.name,
+ const dependencies = extractPub(repoRoot, COMPONENTS.flutter.source);
+ assert.deepEqual(
+ dependencies.map((entry) => entry.name),
+ ["http", "meta", "platform"],
);
- assert.deepEqual(names, ["http", "meta", "platform"]);
+ assert.deepEqual(
+ dependencies.map((entry) => entry.version),
+ ["^1.2.0", "^1.11.0", "^3.1.4"],
+ );
+ assert.ok(dependencies.every((entry) => entry.purl.includes("@%5E")));
});
test("npm components publish no third-party runtime dependencies", async () => {
@@ -405,6 +811,10 @@ test("resolver output adds transitive entries without losing direct ones", () =>
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);
+ assert.throws(
+ () => mergeResolved(direct, [{ name: "missing-version" }]),
+ /Incomplete resolved dependency/u,
+ );
});
test("a registry license only becomes an SPDX id when it really is one", () => {
diff --git a/scripts/sbom-dependencies.mjs b/scripts/sbom-dependencies.mjs
index 0939bec08..b20386cf5 100644
--- a/scripts/sbom-dependencies.mjs
+++ b/scripts/sbom-dependencies.mjs
@@ -1,22 +1,18 @@
#!/usr/bin/env node
/**
- * Direct runtime dependency extractors, one per ecosystem this repository
- * actually publishes into.
+ * Direct runtime dependency readers for the artifacts OpenIAP publishes.
*
- * 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.
+ * Maven and NuGet inventories come from their published POM/nuspec, which is
+ * the consumer-visible contract. The remaining readers use simple manifests
+ * and fail when a declaration shape cannot be classified.
*/
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
+export class PublishedMetadataUnavailableError extends Error {}
+
function readText(root, relativePath) {
return readFileSync(resolve(root, relativePath), "utf8");
}
@@ -25,187 +21,379 @@ 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]);
- }
+function decodeXml(value) {
+ return String(value ?? "")
+ .replaceAll("<", "<")
+ .replaceAll(">", ">")
+ .replaceAll(""", '"')
+ .replaceAll("'", "'")
+ .replaceAll("&", "&");
+}
- // `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] });
- }
+function xmlValue(source, tag) {
+ const match = source.match(
+ new RegExp(`<${tag}\\b[^>]*>([\\s\\S]*?)<\\/${tag}>`, "u"),
+ );
+ return match ? decodeXml(match[1].trim()) : null;
+}
- return locals;
+function encodePurlVersion(version) {
+ return encodeURIComponent(version).replaceAll("%3A", ":");
}
-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 {
+function scanGradleSource(source) {
+ const commentFree = [...source];
+ const structural = [...source];
+ let state = "code";
+ let blockDepth = 0;
+
+ const mask = (index, commentsOnly = false) => {
+ if (source[index] !== "\n" && source[index] !== "\r") {
+ structural[index] = " ";
+ if (!commentsOnly) return;
+ commentFree[index] = " ";
+ }
+ };
+
+ for (let index = 0; index < source.length; index += 1) {
+ const pair = source.slice(index, index + 2);
+ const triple = source.slice(index, index + 3);
+
+ if (state === "line-comment") {
+ mask(index, true);
+ if (source[index] === "\n" || source[index] === "\r") state = "code";
+ continue;
+ }
+ if (state === "block-comment") {
+ if (pair === "/*") blockDepth += 1;
+ mask(index, true);
+ if (pair === "*/") {
+ mask(index + 1, true);
+ blockDepth -= 1;
+ index += 1;
+ if (blockDepth === 0) state = "code";
+ }
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]);
+ if (state === "raw-string") {
+ if (source[index] === '"') {
+ let quoteCount = 1;
+ while (source[index + quoteCount] === '"') quoteCount += 1;
+ for (let offset = 0; offset < quoteCount; offset += 1) {
+ mask(index + offset);
+ }
+ index += quoteCount - 1;
+ if (quoteCount >= 3) state = "code";
+ } else {
+ mask(index);
}
+ continue;
+ }
+ if (state === "string" || state === "character") {
+ mask(index);
+ if (source[index] === "\\") {
+ mask(index + 1);
+ index += 1;
+ } else if (
+ (state === "string" && source[index] === '"') ||
+ (state === "character" && source[index] === "'")
+ ) {
+ state = "code";
+ }
+ continue;
+ }
+
+ if (pair === "//") {
+ mask(index, true);
+ mask(index + 1, true);
+ index += 1;
+ state = "line-comment";
+ } else if (pair === "/*") {
+ mask(index, true);
+ mask(index + 1, true);
+ index += 1;
+ blockDepth = 1;
+ state = "block-comment";
+ } else if (triple === '\"\"\"') {
+ mask(index);
+ mask(index + 1);
+ mask(index + 2);
+ index += 2;
+ state = "raw-string";
+ } else if (source[index] === '"') {
+ mask(index);
+ state = "string";
+ } else if (source[index] === "'") {
+ mask(index);
+ state = "character";
}
}
- return properties;
+
+ if (state === "block-comment" || state === "raw-string") {
+ throw new Error(`Unterminated Gradle ${state.replace("-", " ")}`);
+ }
+ return {
+ commentFree: commentFree.join(""),
+ structural: structural.join(""),
+ };
}
-/**
- * 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);
+function previousGradleCodeIndex(source, from) {
+ let cursor = from;
+ while (cursor >= 0 && /\s/u.test(source[cursor])) cursor -= 1;
+ return cursor;
+}
+
+function gradleIdentifierBefore(source, from) {
+ const end = previousGradleCodeIndex(source, from) + 1;
+ let start = end;
+ while (start > 0 && /[A-Za-z0-9_]/u.test(source[start - 1])) start -= 1;
+ return source.slice(start, end);
+}
+
+function gradleCallBefore(source, close) {
+ let depth = 1;
+ let cursor = close - 1;
+ for (; cursor >= 0 && depth > 0; cursor -= 1) {
+ if (source[cursor] === ")") depth += 1;
+ if (source[cursor] === "(") depth -= 1;
+ }
+ if (depth !== 0) throw new Error("Unbalanced Gradle call parentheses");
+ return gradleIdentifierBefore(source, cursor);
+}
+
+function gradleBlockOwner(source, open) {
+ const previous = previousGradleCodeIndex(source, open - 1);
+ return source[previous] === ")"
+ ? gradleCallBefore(source, previous)
+ : gradleIdentifierBefore(source, previous);
+}
+
+function gradleOwningBlocks(source, end) {
+ const owners = [];
+ for (let index = 0; index < end; index += 1) {
+ if (source[index] === "{") owners.push(gradleBlockOwner(source, index));
+ if (source[index] === "}") owners.pop();
+ }
+ return owners;
+}
+
+function gradleDependencySource(source, manifest) {
+ const { commentFree, structural } = scanGradleSource(source);
+ const braceDepths = new Uint32Array(structural.length);
+ let braceDepth = 0;
+ for (let index = 0; index < structural.length; index += 1) {
+ braceDepths[index] = braceDepth;
+ if (structural[index] === "{") braceDepth += 1;
+ if (structural[index] === "}") {
+ if (braceDepth === 0) throw new Error("Unbalanced Gradle block braces");
+ braceDepth -= 1;
}
}
- return resolved;
+ if (braceDepth !== 0) throw new Error("Unbalanced Gradle block braces");
+
+ const blocks = [];
+ const pattern = /\bdependencies\s*\{/gu;
+ for (
+ let match = pattern.exec(structural);
+ match;
+ match = pattern.exec(structural)
+ ) {
+ const open = match.index + match[0].lastIndexOf("{");
+ if (braceDepths[match.index] !== 0) {
+ const owners = gradleOwningBlocks(structural, match.index);
+ if (owners.includes("buildscript")) continue;
+ throw new Error(`Unsupported nested dependencies block in ${manifest}`);
+ }
+ let depth = 1;
+ let cursor = open + 1;
+ for (; cursor < structural.length && depth > 0; cursor += 1) {
+ if (structural[cursor] === "{") depth += 1;
+ if (structural[cursor] === "}") depth -= 1;
+ }
+ if (depth !== 0) {
+ throw new Error(`Unterminated Gradle dependencies block in ${manifest}`);
+ }
+ blocks.push(
+ filterGradleDependencyBlock(
+ commentFree.slice(open + 1, cursor - 1),
+ manifest,
+ ),
+ );
+ pattern.lastIndex = cursor;
+ }
+ if (blocks.length === 0) {
+ throw new Error(`Missing Gradle dependencies block in ${manifest}`);
+ }
+ return { commentFree, dependencies: blocks.join("\n") };
}
-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);
+function filterGradleDependencyBlock(source, manifest) {
+ const { structural } = scanGradleSource(source);
+ const filtered = [...source];
+ const visible = [true];
+
+ for (let index = 0; index < structural.length; index += 1) {
+ if (structural[index] === "{") {
+ const parentVisible = visible.at(-1);
+ let childVisible = false;
+ if (parentVisible) {
+ const previous = previousGradleCodeIndex(structural, index - 1);
+ const callOwned = structural[previous] === ")";
+ const owner = callOwned
+ ? gradleCallBefore(structural, previous)
+ : gradleIdentifierBefore(structural, previous);
+ if (["if", "for", "when", "while", "else"].includes(owner)) {
+ childVisible = true;
+ } else if (owner === "constraints" && !callOwned) {
+ childVisible = false;
+ } else if (callOwned && owner === "add") {
+ childVisible = false;
+ } else if (callOwned) {
+ isRuntimeGradleConfiguration(owner);
+ childVisible = false;
+ } else {
+ throw new Error(`Unsupported Gradle block in ${manifest}`);
+ }
+ }
+ visible.push(childVisible);
+ filtered[index] = " ";
+ } else if (structural[index] === "}") {
+ if (visible.length === 1)
+ throw new Error("Unbalanced Gradle block braces");
+ visible.pop();
+ filtered[index] = " ";
+ } else if (
+ !visible.at(-1) &&
+ source[index] !== "\n" &&
+ source[index] !== "\r"
+ ) {
+ filtered[index] = " ";
}
}
- return flat;
+ if (visible.length !== 1) throw new Error("Unbalanced Gradle block braces");
+ return filtered.join("");
}
-function interpolateGradle(coordinate, locals) {
- return coordinate.replace(
- /\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?/gu,
- (whole, name) => locals.get(name) ?? whole,
+function topLevelGradleCalls(source) {
+ const { structural } = scanGradleSource(source);
+ const depths = new Uint32Array(structural.length);
+ let depth = 0;
+ for (let index = 0; index < structural.length; index += 1) {
+ depths[index] = depth;
+ if (structural[index] === "(") depth += 1;
+ if (structural[index] === ")") {
+ if (depth === 0) throw new Error("Unbalanced Gradle call parentheses");
+ depth -= 1;
+ }
+ }
+ if (depth !== 0) throw new Error("Unbalanced Gradle call parentheses");
+
+ return [...structural.matchAll(/\b([A-Za-z][A-Za-z0-9]*)\s*\(/gu)]
+ .filter((match) => {
+ if (depths[match.index] !== 0) return false;
+ const lineStart = structural.lastIndexOf("\n", match.index - 1) + 1;
+ const prefix = structural.slice(lineStart, match.index);
+ return !/^\s*(?:(?:val|var)\s+)?[A-Za-z_][A-Za-z0-9_]*(?:\s*:[^=]+)?\s*=\s*$/u.test(
+ prefix,
+ );
+ })
+ .map((match) => ({ index: match.index, name: match[1], text: match[0] }));
+}
+
+function isVersionConstraint(version) {
+ return (
+ version === "any" ||
+ /[\s*^~<>=|,[\](){}]/u.test(version) ||
+ /\bx\b/iu.test(version)
);
}
-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 };
+function dependencyEntry({ name, version, purl, properties = [] }) {
+ if (!name || !version || !purl) {
+ throw new Error(
+ `Incomplete dependency entry: ${JSON.stringify({ name, version, purl })}`,
+ );
}
+ const versionProperties = isVersionConstraint(version)
+ ? [{ name: "openiap:sbom:version-constraint", value: version }]
+ : [];
return {
- name: `${group}:${artifact}`,
+ name,
version,
- purl: `pkg:maven/${group}/${artifact}@${version}`,
+ purl,
+ ...(versionProperties.length > 0 || properties.length > 0
+ ? { properties: [...versionProperties, ...properties] }
+ : {}),
};
}
-/**
- * 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;
+/** Gradle `val name = "value"` locals used by the Godot release manifest. */
+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]);
+ }
+ return locals;
+}
- 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;
+function readExternalLocals(root, externalLocals = {}) {
+ const resolved = new Map();
+ for (const [name, spec] of Object.entries(externalLocals)) {
+ if (spec.json) {
+ const value = readJson(root, spec.file)[spec.json];
+ if (value == null) {
+ throw new Error(`Missing ${spec.json} in ${spec.file}`);
+ }
+ resolved.set(name, String(value));
+ continue;
}
- // The counter also sees braces inside strings and comments. If it never
- // returns to zero, everything after this point would be discarded and the
- // SBOM would silently lose real dependencies — the one failure mode this
- // module exists to prevent.
- if (depth !== 0) {
- throw new Error(
- `Unbalanced braces while removing a test source set at offset ${match.index}; ` +
- `refusing to drop the remainder of the manifest.`,
+ if (spec.gradleLocal) {
+ const value = readGradleLocals(readText(root, spec.file)).get(
+ spec.gradleLocal,
);
+ if (!value) {
+ throw new Error(
+ `Missing Gradle local ${spec.gradleLocal} in ${spec.file}`,
+ );
+ }
+ resolved.set(name, value);
}
- result = result.slice(0, match.index) + result.slice(index);
- opener.lastIndex = 0;
}
-
- return result;
+ return resolved;
}
-/**
- * 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");
- },
+function interpolateGradle(coordinate, locals) {
+ return coordinate.replace(
+ /\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?/gu,
+ (whole, name) => locals.get(name) ?? whole,
);
}
-/**
- * Gradle configurations that place a dependency on the consumer's runtime
- * classpath. `compileOnly` and every test configuration are deliberately absent.
- */
+function parseMavenCoordinate(coordinate) {
+ const parts = coordinate.split(":").map((part) => part.trim());
+ if (parts.length !== 3 || parts.some((part) => !part)) {
+ throw new Error(`Unsupported Maven coordinate '${coordinate}'`);
+ }
+ if (coordinate.includes("$")) {
+ throw new Error(`Unresolved Maven coordinate '${coordinate}'`);
+ }
+ const [group, artifact, version] = parts;
+ return dependencyEntry({
+ name: `${group}:${artifact}`,
+ version,
+ purl: `pkg:maven/${group}/${artifact}@${encodePurlVersion(version)}`,
+ });
+}
+
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",
@@ -222,60 +410,89 @@ function isRuntimeGradleConfiguration(configuration) {
if (
GRADLE_NON_RUNTIME_PREFIXES.some((prefix) =>
configuration.startsWith(prefix),
- )
+ ) ||
+ /(?:Test|CompileOnly|AnnotationProcessor|LintChecks)/u.test(configuration)
) {
return false;
}
- // Flavored configurations such as `playApi` / `horizonImplementation`.
- return /^[a-z][A-Za-z0-9]*(Api|Implementation|RuntimeOnly)$/u.test(
- configuration,
+ throw new Error(
+ `Unclassified Gradle dependency configuration '${configuration}'`,
);
}
function extractGradle(root, { manifest, externalLocals }) {
- const rawSource = readText(root, manifest);
- const locals = flattenLocals(
- readGradleLocals(rawSource),
- readGradleProperties(root, manifest),
+ const source = readText(root, manifest);
+ const { commentFree, dependencies } = gradleDependencySource(
+ source,
+ manifest,
);
+ const locals = readGradleLocals(commentFree);
for (const [name, value] of readExternalLocals(root, externalLocals)) {
locals.set(name, value);
}
- const source = expandGradleForLoops(stripTestSourceSets(rawSource));
- const found = new Map();
- const unresolved = [];
+ for (const match of dependencies.matchAll(
+ /\b([A-Za-z][A-Za-z0-9]*)\s*\(\s*libs\./gu,
+ )) {
+ if (match[1] === "alias") continue;
+ throw new Error(
+ `Unsupported version-catalog dependency in ${manifest}; use published metadata instead`,
+ );
+ }
+
+ const found = new Map();
+ const matchedDeclarations = new Set();
+ const topLevelCalls = topLevelGradleCalls(dependencies);
+ const topLevelCallIndices = new Set(topLevelCalls.map((call) => call.index));
+ let usesLocalOpeniapProject = false;
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(
+ for (const match of dependencies.matchAll(
/\b([a-zA-Z][A-Za-z0-9]*)\s*\(\s*"([^"]+:[^"]+:[^"]+)"\s*\)/gu,
)) {
+ if (!topLevelCallIndices.has(match.index)) continue;
+ matchedDeclarations.add(match.index);
record(match[1], match[2]);
}
-
- // add("playApi", "group:artifact:version")
- for (const match of source.matchAll(
+ for (const match of dependencies.matchAll(
/\badd\s*\(\s*"([^"]+)"\s*,\s*"([^"]+:[^"]+:[^"]+)"\s*\)/gu,
)) {
+ if (!topLevelCallIndices.has(match.index)) continue;
+ matchedDeclarations.add(match.index);
record(match[1], match[2]);
}
- if (unresolved.length > 0) {
+ for (const call of topLevelCalls) {
+ if (["if", "for", "when", "while"].includes(call.name)) continue;
+ if (matchedDeclarations.has(call.index)) continue;
+ if (call.name === "add") {
+ throw new Error(
+ `Unsupported Gradle dependency declaration in ${manifest}`,
+ );
+ }
+ if (!isRuntimeGradleConfiguration(call.name)) continue;
+ const argument = dependencies.slice(call.index + call.text.length);
+ if (/^\s*project\s*\(\s*":openiap"\s*\)/u.test(argument)) {
+ usesLocalOpeniapProject = true;
+ continue;
+ }
+ throw new Error(`Unsupported Gradle dependency declaration in ${manifest}`);
+ }
+
+ if (
+ usesLocalOpeniapProject &&
+ ![...found.values()].some(
+ (entry) => entry.name === "io.github.hyochan.openiap:openiap-google",
+ )
+ ) {
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.`,
+ `Local :openiap dependency in ${manifest} lacks its published fallback`,
);
}
@@ -284,57 +501,93 @@ function extractGradle(root, { manifest, externalLocals }) {
);
}
-function parseVersionCatalog(source) {
- const versions = new Map();
- const libraries = new Map();
- let section = "";
+function resolveMavenValue(value, properties, context) {
+ let resolvedValue = value;
+ for (
+ let attempt = 0;
+ attempt < 10 && resolvedValue.includes("${");
+ attempt += 1
+ ) {
+ const next = resolvedValue.replace(/\$\{([^}]+)\}/gu, (whole, name) => {
+ if (name === "project.version" || name === "pom.version") {
+ return context.version;
+ }
+ return properties.get(name) ?? whole;
+ });
+ if (next === resolvedValue) break;
+ resolvedValue = next;
+ }
+ if (resolvedValue.includes("${")) {
+ throw new Error(`Unresolved Maven value '${value}' in ${context.url}`);
+ }
+ return resolvedValue;
+}
- 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 });
+function parseMavenPom(source, context) {
+ const properties = new Map();
+ const propertiesBlock = source.match(
+ /([\s\S]*?)<\/properties>/u,
+ )?.[1];
+ if (propertiesBlock) {
+ for (const match of propertiesBlock.matchAll(
+ /<([A-Za-z_][\w.-]*)>([^<]+)<\/\1>/gu,
+ )) {
+ properties.set(match[1], decodeXml(match[2].trim()));
}
}
- return { versions, libraries };
-}
-
-/** `libs.kotlinx.coroutines.core` -> catalog alias `kotlinx-coroutines-core`. */
-function catalogAliasFromAccessor(accessor) {
- return accessor.replace(/\./gu, "-");
-}
+ const profiles = source.match(/([\s\S]*?)<\/profiles>/u)?.[1];
+ if (profiles && /[\s\S]*?<\/dependencyManagement>/gu, "")
+ .replace(/[\s\S]*?<\/profiles>/gu, "")
+ .replace(/[\s\S]*?<\/build>/gu, "");
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,
+ for (const match of withoutManaged.matchAll(
+ /([\s\S]*?)<\/dependency>/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);
+ const block = match[1];
+ const scope = xmlValue(block, "scope") ?? "compile";
+ const optional = xmlValue(block, "optional")?.toLowerCase() === "true";
+ if (
+ !["compile", "runtime", "test", "provided", "system", "import"].includes(
+ scope,
+ )
+ ) {
+ throw new Error(`Unsupported Maven scope '${scope}' in ${context.url}`);
+ }
+ if (["test", "provided", "system", "import"].includes(scope) || optional) {
+ continue;
+ }
+
+ const group = xmlValue(block, "groupId");
+ const artifact = xmlValue(block, "artifactId");
+ const rawVersion = xmlValue(block, "version");
+ if (!group || !artifact || !rawVersion) {
+ throw new Error(`Incomplete runtime dependency in ${context.url}`);
+ }
+ const version = resolveMavenValue(rawVersion, properties, context);
+ const qualifiers = [];
+ const type = xmlValue(block, "type");
+ const classifier = xmlValue(block, "classifier");
+ if (type && type !== "jar") qualifiers.push(["type", type]);
+ if (classifier) qualifiers.push(["classifier", classifier]);
+ const qualifier = qualifiers.length
+ ? `?${qualifiers
+ .sort(([left], [right]) => left.localeCompare(right))
+ .map(([name, value]) => `${name}=${encodeURIComponent(value)}`)
+ .join("&")}`
+ : "";
+ const entry = dependencyEntry({
+ name: `${group}:${artifact}`,
+ version,
+ purl: `pkg:maven/${group}/${artifact}@${encodePurlVersion(version)}${qualifier}`,
+ });
+ found.set(entry.purl, entry);
}
return [...found.values()].sort((left, right) =>
@@ -342,51 +595,51 @@ function extractGradleCatalog(root, { manifest, catalog }) {
);
}
-function readMsBuildProperties(root, propertyFiles) {
- const properties = new Map();
- for (const file of propertyFiles) {
- let source;
- try {
- source = readText(root, file);
- } catch {
- continue;
+async function extractMavenPom(_root, source, context) {
+ const coordinates = source.coordinates ?? [source.coordinate];
+ const failures = [];
+ for (const coordinate of coordinates) {
+ const [group, artifact] = String(coordinate).split(":");
+ if (!group || !artifact || coordinate.split(":").length !== 2) {
+ throw new Error(`Invalid published Maven coordinate '${coordinate}'`);
}
- for (const match of source.matchAll(
- /<([A-Za-z_][\w.-]*)>([^<>$]+)<\/\1>/gu,
- )) {
- properties.set(match[1], match[2].trim());
+ const path = `${group.replaceAll(".", "/")}/${artifact}/${context.version}/${artifact}-${context.version}.pom`;
+ for (const repository of source.repositories) {
+ const url = `${repository.replace(/\/$/u, "")}/${path}`;
+ const document = await context.fetchText(url);
+ if (document) return parseMavenPom(document, { ...context, url });
+ failures.push(url);
}
}
- return properties;
-}
-
-function interpolateMsBuild(value, properties) {
- return value.replace(
- /\$\(([A-Za-z_][\w.-]*)\)/gu,
- (whole, name) => properties.get(name) ?? whole,
+ throw new PublishedMetadataUnavailableError(
+ `Published POM not found: ${failures.join(", ")}`,
);
}
-function extractNuget(root, { manifest, propertyFiles = [] }) {
- const source = readText(root, manifest);
- const properties = readMsBuildProperties(root, [...propertyFiles, manifest]);
- const found = new Map();
+function parseNugetNuspec(source, context) {
+ const dependenciesBlock = source.match(
+ /([\s\S]*?)<\/dependencies>/u,
+ )?.[1];
+ if (!dependenciesBlock) return [];
- for (const match of source.matchAll(/]*)\/?>/gu)) {
+ const found = new Map();
+ for (const match of dependenciesBlock.matchAll(
+ /]*)\/?>(?:<\/dependency>)?/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 });
+ const name = attributes.match(/\bid\s*=\s*"([^"]+)"/iu)?.[1];
+ const version = attributes.match(/\bversion\s*=\s*"([^"]+)"/iu)?.[1];
+ if (!name || !version) {
+ throw new Error(
+ `Incomplete published NuGet dependency in ${context.url}`,
+ );
+ }
+ const entry = dependencyEntry({
+ name: decodeXml(name),
+ version: decodeXml(version),
+ purl: `pkg:nuget/${encodeURIComponent(decodeXml(name))}@${encodePurlVersion(decodeXml(version))}`,
+ });
+ found.set(entry.purl.toLowerCase(), entry);
}
return [...found.values()].sort((left, right) =>
@@ -394,13 +647,28 @@ function extractNuget(root, { manifest, propertyFiles = [] }) {
);
}
+async function extractNugetNuspec(_root, source, context) {
+ const packageId = source.packageId.toLowerCase();
+ const version = context.version.toLowerCase();
+ const url =
+ `https://api.nuget.org/v3-flatcontainer/${packageId}/${version}/` +
+ `${packageId}.nuspec`;
+ const document = await context.fetchText(url);
+ if (!document) {
+ throw new PublishedMetadataUnavailableError(
+ `Published nuspec not found: ${url}`,
+ );
+ }
+ return parseNugetNuspec(document, { ...context, url });
+}
+
function extractPub(root, { manifest }) {
- const source = readText(root, manifest);
- const lines = source.split("\n");
+ const lines = readText(root, manifest).split("\n");
const found = new Map();
let inDependencies = false;
- for (const line of lines) {
+ for (let index = 0; index < lines.length; index += 1) {
+ const line = lines[index];
if (/^[A-Za-z_]+:/u.test(line)) {
inDependencies = line.startsWith("dependencies:");
continue;
@@ -411,16 +679,19 @@ function extractPub(root, { manifest }) {
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, {
+ if (!constraint) {
+ const nested = lines[index + 1]?.trim();
+ if (name === "flutter" && nested === "sdk: flutter") continue;
+ throw new Error(
+ `Unsupported nested pub dependency '${name}' in ${manifest}`,
+ );
+ }
+ const entry = dependencyEntry({
name,
- version,
- purl: `pkg:pub/${name}@${version}`,
- scope: "required",
+ version: constraint,
+ purl: `pkg:pub/${name}@${encodePurlVersion(constraint)}`,
});
+ found.set(entry.purl, entry);
}
return [...found.values()].sort((left, right) =>
@@ -429,84 +700,86 @@ function extractPub(root, { manifest }) {
}
function extractNpm(root, { manifest }) {
- const packageJson = readJson(root, manifest);
- const dependencies = packageJson.dependencies ?? {};
+ const dependencies = readJson(root, manifest).dependencies ?? {};
return Object.entries(dependencies)
- .map(([name, range]) => ({
- name,
- version: String(range)
- .replace(/^[\^~><= ]+/u, "")
- .trim(),
- purl: `pkg:npm/${name}@${String(range)
- .replace(/^[\^~><= ]+/u, "")
- .trim()}`,
- }))
+ .map(([name, rawVersion]) => {
+ const version = String(rawVersion).trim();
+ if (!version || /^(?:file|git|github|https?|workspace):/u.test(version)) {
+ throw new Error(
+ `Unsupported npm dependency '${name}@${version}' in ${manifest}`,
+ );
+ }
+ const encodedName = name.startsWith("@")
+ ? `${encodeURIComponent(name.split("/")[0])}/${name.split("/").slice(1).join("/")}`
+ : name;
+ return dependencyEntry({
+ name,
+ version,
+ purl: `pkg:npm/${encodedName}@${encodePurlVersion(version)}`,
+ });
+ })
.sort((left, right) => left.name.localeCompare(right.name));
}
function extractSwift(root, { manifest }) {
const source = readText(root, manifest);
const found = new Map();
+ const declarations = [...source.matchAll(/\.package\s*\(/gu)].length;
+ let matched = 0;
for (const match of source.matchAll(
/\.package\s*\(\s*url:\s*"([^"]+)"[^)]*?(?:from|exact):\s*"([^"]+)"/gu,
)) {
+ matched += 1;
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, {
+ const parts = url.replace(/\.git$/u, "").split("/");
+ const name = parts.at(-1) ?? url;
+ const owner = parts.at(-2) ?? "";
+ const entry = dependencyEntry({
name,
version,
- purl: `pkg:swift/github.com/${owner}/${name}@${version}`,
+ purl: `pkg:swift/github.com/${owner}/${name}@${encodePurlVersion(version)}`,
});
+ found.set(entry.purl, entry);
+ }
+ if (matched !== declarations) {
+ throw new Error(`Unsupported Swift package declaration in ${manifest}`);
}
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,
+ "maven-pom": extractMavenPom,
npm: extractNpm,
none: extractNone,
- nuget: extractNuget,
+ "nuget-nuspec": extractNugetNuspec,
pub: extractPub,
swift: extractSwift,
};
-export function extractDirectDependencies(root, source) {
+export async function extractDirectDependencies(root, source, context = {}) {
const extractor = EXTRACTORS[source.kind];
if (!extractor) {
throw new Error(`Unsupported dependency source kind: ${source.kind}`);
}
- return extractor(root, source);
+ return extractor(root, source, context);
}
-/**
- * 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 (!entry?.name || !entry?.version || !entry?.purl) {
+ throw new Error(
+ `Incomplete resolved dependency: ${JSON.stringify(entry)}`,
+ );
+ }
if (merged.has(entry.purl)) continue;
merged.set(entry.purl, { ...entry, transitive: true });
}
@@ -516,15 +789,11 @@ export function mergeResolved(direct, resolvedEntries) {
}
export const __testing = {
- expandGradleForLoops,
extractGradle,
- extractGradleCatalog,
extractNpm,
- extractNuget,
extractPub,
- extractSwift,
isRuntimeGradleConfiguration,
parseMavenCoordinate,
- parseVersionCatalog,
- stripTestSourceSets,
+ parseMavenPom,
+ parseNugetNuspec,
};
diff --git a/security/CRA.md b/security/CRA.md
index b115c732f..3d5ec7982 100644
--- a/security/CRA.md
+++ b/security/CRA.md
@@ -100,9 +100,11 @@ Where this document and the regulation disagree, the regulation governs.
**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 records the release and generator commits, and the core
+**How OpenIAP does this:** each current component release workflow creates the
+GitHub Release, then dispatches `sbom.yml` because a release created with
+`GITHUB_TOKEN` does not trigger another workflow. The SBOM workflow generates
+and uploads the CycloneDX 1.6 asset; a daily scan repairs missed latest-release
+assets. 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.
@@ -135,12 +137,14 @@ through the workflows in `.github/workflows/`, and each produces a new SBOM.
**Expectation:** be able to reproduce and evidence how a release was produced.
-**How OpenIAP does this** — for any published release, these are recoverable:
+**How OpenIAP does this** — for each current release carrying an SBOM, these are
+recoverable. Older releases without a backfilled asset retain their immutable
+tag and published descriptors as the evidence source.
| 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 |
+| What direct dependencies did it declare? | 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 |
diff --git a/security/README.md b/security/README.md
index 377d8e847..71323c78a 100644
--- a/security/README.md
+++ b/security/README.md
@@ -4,13 +4,13 @@ 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 |
-| [vex/](vex/README.md) | Recorded judgements on whether a known CVE actually affects a component |
-| [CRA.md](CRA.md) | How these practices map to EU Cyber Resilience Act expectations |
-| [openchain.md](openchain.md) | Self-assessment against ISO/IEC 18974 and 5230, with the current gap list |
-| [`../SECURITY.md`](../SECURITY.md) | Vulnerability reporting, disclosure, supported versions |
+| Document | Covers |
+| ---------------------------------- | ------------------------------------------------------------------------------- |
+| [SBOM.md](SBOM.md) | Current-release dependency inventories: scope, format, generation, verification |
+| [vex/](vex/README.md) | Recorded judgements on whether a known CVE actually affects a component |
+| [CRA.md](CRA.md) | How these practices map to EU Cyber Resilience Act expectations |
+| [openchain.md](openchain.md) | Self-assessment against ISO/IEC 18974 and 5230, with the current gap list |
+| [`../SECURITY.md`](../SECURITY.md) | Vulnerability reporting, disclosure, supported versions |
Vulnerability reporting stays at the repository root, where GitHub and most
contributors look for it.
@@ -21,9 +21,10 @@ contributors look for it.
OpenIAP source
│
▼
- dependency manifests
- (package.json, gradle,
- csproj, pubspec, spm)
+ dependency inputs
+ (published POM/nuspec,
+ package.json, pubspec,
+ Gradle, SwiftPM)
│
▼
CI (ci.yml)
@@ -42,7 +43,7 @@ contributors look for it.
package npm/registry GitHub Release
provenance │
▼
- sbom.yml (release: published)
+ release dispatches sbom.yml
│
┌─────────┴─────────┐
▼ ▼
@@ -54,7 +55,7 @@ contributors look for it.
| Capability | Mechanism |
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| Per-release SBOM | `scripts/generate-sbom.mjs` + `.github/workflows/sbom.yml` — [CycloneDX 1.6](https://cyclonedx.org/specification/overview/) |
+| Current-release SBOM | `scripts/generate-sbom.mjs` + `.github/workflows/sbom.yml` — [CycloneDX 1.6](https://cyclonedx.org/specification/overview/) |
| SBOM provenance | [`actions/attest-build-provenance`](https://github.com/actions/attest-build-provenance) — [SLSA](https://slsa.dev/provenance/v1) via [Sigstore](https://www.sigstore.dev/), 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` |
@@ -81,8 +82,9 @@ Dockerfile. That is a deliberate scope, not an oversight:
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.
+ of platform upgrade work. Each current release SBOM records its published
+ direct dependency contract; a toolchain resolver export can add transitive
+ entries for a consuming application.
## What GitHub's dependency graph does and does not see
@@ -90,7 +92,7 @@ Worth stating plainly, because it explains why the SBOMs are not redundant with
the platform:
```bash
-gh api repos/hyodotdev/openiap/dependency-graph/sbom # → 0 packages
+gh api repos/hyodotdev/openiap/dependency-graph/sbom # → HTTP 404
```
GitHub's [dependency graph](https://docs.github.com/en/code-security/supply-chain-security/understanding-your-software-supply-chain/dependency-graph-supported-package-ecosystems)
@@ -105,8 +107,9 @@ Consequences:
- **Dependabot security alerts depend on the dependency graph**, so alert
coverage is limited to what the graph can populate. Do not read an empty
alert list as "no vulnerable dependencies".
-- **The published SBOMs are the only complete inventory** of what each release
- contains.
+- **The published SBOMs are the release-specific inventory** of direct runtime
+ dependencies. A transitive closure is included only when a resolver export is
+ supplied.
Closing this gap properly would mean submitting a snapshot through the
[dependency submission API](https://docs.github.com/en/rest/dependency-graph/dependency-submission),
@@ -121,9 +124,10 @@ not belong to whichever change surfaced it.
| ---------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Actions are pinned by major tag, not commit SHA** | A mutable tag in a privileged publish or signing path is a supply-chain risk. Fixing it means pinning every workflow at once and reconfiguring Dependabot, not two workflows in isolation |
| **Several CI workflows declare no `permissions:` block** | They inherit the repository default instead of least privilege. OpenSSF Scorecard's Token-Permissions check reports this |
-| **GitHub's dependency graph is empty for this repository** | Bun lockfiles are unsupported and Gradle is not resolved from source, so Dependabot security alerts cannot cover the tree. Closing it needs the dependency submission API |
+| **GitHub's dependency-graph SBOM endpoint is unavailable** | The repository endpoint returns HTTP 404, so it cannot provide an independent inventory. Closing the coverage gap requires dependency submission or another supported manifest path |
-`/audit-security` re-checks each of these and prints the current state.
+`/audit-security` re-checks each gap and prints its current state. Action pinning
+still requires a separate repository-wide migration.
## Scanning posture
@@ -132,7 +136,9 @@ OpenIAP does not run a separate vulnerability scanner
[Grype](https://github.com/anchore/grype),
[osv-scanner](https://github.com/google/osv-scanner)) in CI today:
-- The published SDKs have no runtime dependency tree for a scanner to examine.
+- The published npm SDKs have no runtime dependency tree for a scanner to
+ examine. Native and framework dependency inventories are carried in their
+ release SBOMs.
- `packages/kit`'s dependencies are the meaningful surface, and Dependabot
already opens update pull requests for them.
- A second scanner would add alert triage and CI maintenance for a signal we
@@ -150,5 +156,7 @@ the point to revisit it.
`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.
+declaring the component's distribution and dependency source. Tag aliases are
+derived from the release configuration. The generator test also requires every
+workflow that creates a GitHub Release to dispatch `sbom.yml`, so a new release
+lane cannot silently omit the inventory.
diff --git a/security/SBOM.md b/security/SBOM.md
index a8281990a..ae630953e 100644
--- a/security/SBOM.md
+++ b/security/SBOM.md
@@ -2,14 +2,16 @@
## 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?_
+Current OpenIAP release workflows attach a machine-readable inventory of direct
+third-party dependencies, and a daily repair job fills missed latest-release
+assets. That inventory lets a consumer — or a maintainer responding to a new
+advisory — identify the released dependency contract without reconstructing it
+from build scripts. Exact application exposure still comes from the consumer's
+resolved dependency graph.
-SBOMs are generated from the same manifests the build reads. No one edits an
-SBOM by hand, and none are committed to the repository.
+SBOMs are generated from the released manifest or the registry descriptor that
+consumers resolve. No one edits an SBOM by hand, and none are committed to the
+repository.
## Scope
@@ -22,7 +24,7 @@ cannot be released without also being described:
| Component | SBOM name | Distribution | Release tag |
| -------------- | ------------------------ | -------------------------------- | ------------------------------- |
-| `apple` | `openiap-apple` | CocoaPods, Swift Package Manager | `` |
+| `apple` | `openiap` | 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-` |
@@ -66,8 +68,8 @@ code, no external binary. It is plain ESM run by the Node version already
pinned in CI. This is deliberate: a tool that reports what you depend on should
not quietly add dependencies of its own.
-**At generation time** it reads package registries over HTTPS, and only to
-resolve declared licenses:
+**At generation time** it reads package registries over HTTPS for the published
+dependency descriptors and for declared licenses:
| Registry | Used for |
| ------------------------------------------------- | --------------------------- |
@@ -76,8 +78,9 @@ resolve declared licenses:
| [nuget.org](https://www.nuget.org/) | NuGet `.nuspec` |
| [registry.npmjs.org](https://registry.npmjs.org/) | npm package metadata |
-A registry failure degrades to a missing license field; it never blocks a
-release.
+Failure to read a published POM or nuspec blocks generation because the
+dependency inventory would be incomplete. A license lookup failure degrades to
+a missing license field and does not block the release.
**In CI**, `.github/workflows/sbom.yml` uses these actions:
@@ -88,7 +91,8 @@ release.
| [`actions/attest-build-provenance`](https://github.com/actions/attest-build-provenance) | Sign SLSA provenance | MIT |
| [`gh` CLI](https://cli.github.com/) | Upload the release asset | MIT |
-Action versions are pinned in the workflow and kept current by Dependabot.
+Action versions use reviewed major-version tags and are kept current by
+Dependabot. Commit-SHA pinning remains a documented repository-wide gap.
## Naming convention
@@ -104,9 +108,8 @@ 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.
+The name and version equal the published package's own name and version, so a
+released artifact and its SBOM can be matched without a lookup table.
## Generation
@@ -120,13 +123,18 @@ bun run sbom resolve-tag # which component does this tag belong
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` |
+| Ecosystem | Dependency source |
+| -------------- | ----------------------------------------------------------- |
+| npm | Released `package.json` (`dependencies`) |
+| Maven / Gradle | Published POM for the selected consumer artifact |
+| NuGet | Published nuspec dependency groups |
+| pub | Released `pubspec.yaml`; constraints are preserved verbatim |
+| Godot / Gradle | Released addon `build.gradle.kts` |
+| Swift | Released `Package.swift` |
+
+The MAUI inventory is the union of the published nuspec's target-framework
+groups. Use the consuming application's resolved graph to narrow dependencies
+to Android, iOS, or Mac Catalyst.
### What is included
@@ -149,12 +157,21 @@ transitive dependencies when a resolver export is supplied (see below).
application's SBOM, not ours.
- **Operating-system frameworks.** StoreKit is not a distributed package.
+### Version constraints
+
+Library manifests sometimes publish a version constraint rather than one exact
+resolved version. The SBOM preserves that constraint verbatim and marks it with
+`openiap:sbom:version-constraint`; it never rewrites the lower bound as though
+that were the version every consumer installs. An application SBOM should use
+its lockfile or ecosystem resolver when exact CVE matching is required.
+
### Licenses
`--with-licenses` resolves each dependency's declared license from its own
registry — Maven Central and Google's Maven repository for Maven coordinates,
nuget.org for NuGet, registry.npmjs.org for npm. The release workflow passes
-this flag; local runs default to offline.
+this flag. Maven and NuGet component generation still reads the published
+dependency descriptor when license enrichment is disabled.
Licenses are never guessed. A registry value is emitted as an SPDX identifier
only when it is a recognised one; anything else is recorded as a free-text
@@ -177,9 +194,10 @@ number would go stale the next time a dependency changes.
### 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:
+Direct dependencies are read from the published descriptor or released
+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
@@ -187,54 +205,42 @@ 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.
+property of `transitive`; all entries remain reachable from the root dependency
+graph.
-Where a manifest declares a coordinate this reader cannot resolve, generation
+Where an input declares a dependency 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.
-#### Planned: replace manifest parsing with resolver output
-
-The Gradle, NuGet, and pub readers in `scripts/sbom-dependencies.mjs` parse
-build manifests with regular expressions. That is a deliberate stopgap, and it
-is the one part of this system expected to need maintenance: `build.gradle.kts`
-is arbitrary Kotlin, so new declaration shapes will keep appearing. Two already
-did — `for (module in listOf(...))` expansion and `project.findProperty(...)`
-resolution.
+#### Published dependency metadata
-**Do not keep growing the parsers.** When transitive support is implemented,
-move these ecosystems onto their own resolvers instead:
+Maven and NuGet inventories are read from the POM or nuspec consumers resolve,
+not reconstructed from conditional build scripts. This makes the inventory
+flavor-aware and includes dependencies injected by the publishing toolchain,
+such as Kotlin's standard library. Pub constraints remain constraints because a
+library release does not choose the application's eventual resolved version.
-| Ecosystem | Replace parser with |
-| --------- | -------------------------------------------------------------- |
-| Gradle | `cyclonedx-gradle-plugin`, or `gradlew ::dependencies` |
-| NuGet | `dotnet list package --include-transitive --format json` |
-| pub | `flutter pub deps --json` |
+The KMP release spans platform variants. Its release SBOM uses the published
+`io.github.hyochan:kmp-iap-android-play` POM so the Android dependency on
+`openiap-google` is not omitted. An iOS-only consumer should use its resolved
+application graph for target-specific dependencies.
-That removes roughly 350 lines of parsing and delivers the transitive closure
-in the same change — the ecosystem readers shrink rather than grow. It was not
-done in the initial implementation because no JDK, Flutter, or .NET toolchain
-was available to verify the result, and unverified code in a release path is
-worse than a verified stopgap.
-
-Until then, the parsers are safe to rely on for one reason: a coordinate they
-cannot resolve raises an error instead of being dropped, and the tests read the
-real manifests in this repository, so drift fails CI rather than silently
-shortening an inventory.
+The small Godot Gradle reader remains because its GitHub release is the artifact
+of record. It rejects unknown configurations, unresolved coordinates, catalog
+accessors, and unsupported coordinate shapes instead of silently dropping them.
## Release integration
-`.github/workflows/sbom.yml` runs on `release: published` and on manual
-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.
+Every component release workflow dispatches `.github/workflows/sbom.yml` after
+creating its GitHub Release. This explicit dispatch is required because a
+release created with `GITHUB_TOKEN` does not trigger another workflow. A scan on
+SBOM changes and a daily schedule dispatch any missing newest-component asset.
```text
release workflow → GitHub Release published
- │
+ │ explicit workflow_dispatch
▼
- sbom.yml (release: published)
+ sbom.yml
│
┌───────────────┼───────────────┐
▼ ▼ ▼
@@ -252,7 +258,11 @@ 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.
+failing. A duplicate dispatch preserves an existing SBOM. The repair scan
+recognizes the exact digest of the inaccurate Google 3.3.0 asset produced by the
+retired source-manifest reader and replaces that asset once. It uploads and
+verifies the corrected document under a temporary name before removing the
+legacy asset; no other existing asset is overwritten.
## Storage location
@@ -273,7 +283,8 @@ 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'
+gh release download react-native-iap-16.3.0 \
+ --repo hyodotdev/openiap -p '*.cdx.json'
# 2. Confirm this repository's CI produced it
gh attestation verify react-native-iap-16.3.0.cdx.json \
@@ -305,9 +316,13 @@ 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.
+The example below reproduces the corrected Google 3.3.0 asset from its recorded
+generator commit. The one-time repair replaces the known inaccurate legacy
+digest with the published-POM inventory before this procedure is used.
+
```bash
-RELEASE_TAG=react-native-iap-16.3.0
-PUBLISHED_SBOM=/absolute/path/react-native-iap-16.3.0.cdx.json
+RELEASE_TAG=google-3.3.0
+PUBLISHED_SBOM=/absolute/path/openiap-google-3.3.0.cdx.json
GENERATOR_COMMIT=$(jq -r '
.metadata.tools.components[]
| select(.name == "openiap-sbom-generator")
@@ -334,7 +349,7 @@ git -C "$SBOM_REPRO_DIR" checkout "$GENERATOR_COMMIT" -- \
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" \
+ "$SBOM_REPRO_DIR/sbom/openiap-google-3.3.0.cdx.json" \
> /tmp/generated-core.json
diff /tmp/published-core.json /tmp/generated-core.json
git worktree remove --force "$SBOM_REPRO_DIR"
@@ -342,14 +357,16 @@ git worktree remove --force "$SBOM_REPRO_DIR"
## 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.
+- Every current release workflow produces an SBOM automatically.
+- SBOMs are **immutable once published**. See
+ [Release integration](#release-integration) for the authoritative repair
+ exception and overwrite rule.
- 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.
+ with `workflow_dispatch`. The workflow reads released inputs, records the
+ exact default-branch generator commit, and refuses to overwrite an existing
+ asset.
+- The newest release of each component is checked after SBOM changes and every
+ day, so a missed release-time dispatch is repaired without manual triage.
- Changes to the generator are covered by `scripts/generate-sbom.test.mjs`,
including a historical release-tree fixture and every accepted tag alias.
CI also fails if a releasable component has no SBOM metadata.
@@ -359,13 +376,13 @@ git worktree remove --force "$SBOM_REPRO_DIR"
The SBOM is an input to vulnerability response, not the goal:
```text
-SBOM (per released version)
+SBOM (per current released version)
│
▼
dependency inventory ←── Dependabot alerts (packages/kit, GitHub Actions, Docker)
│
▼
-affected-version analysis ── "which shipped releases contain this CVE?"
+affected-version analysis ── "which releases declare the affected dependency?"
│
▼
security advisory + patch
@@ -375,9 +392,10 @@ 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.
+tells us a dependency is vulnerable _today, on `main`_. Current release SBOMs
+identify their direct dependency contracts; older releases without an asset are
+investigated from their immutable tag and published descriptors when an
+advisory needs an affected-version list.
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.
diff --git a/security/openchain.md b/security/openchain.md
index 25e8dffcf..b552371ea 100644
--- a/security/openchain.md
+++ b/security/openchain.md
@@ -27,13 +27,13 @@ OpenChain is a Linux Foundation project.
| Req | Requirement | Status | Where / what is missing |
| ----- | ----------------------------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 4.1.1 | Policy | **Partial** | [`README.md`](README.md) and [`SBOM.md`](SBOM.md) document practice, and [`../SECURITY.md`](../SECURITY.md) documents reporting. There is no single named "open source security assurance policy" document, and no procedure for communicating it to participants |
-| 4.1.2 | Competence | **Missing** | No role/responsibility inventory, no `MAINTAINERS.md`, no competency definitions |
+| 4.1.2 | Competence | **Partial** | [`../MAINTAINERS.md`](../MAINTAINERS.md) names the current owner and scope. There are no competency definitions or assessment records |
| 4.1.3 | Awareness | **Missing** | No assessed-awareness evidence. Low value at current project size — see _Proportionality_ |
| 4.1.4 | Program scope | **Partial** | Scope is defined in [`../SECURITY.md`](../SECURITY.md#scope) and per-component in [`SBOM.md`](SBOM.md#scope). No target performance metrics, no review cadence |
| 4.1.5 | Standard practice (8 methods) | **Partial** | Present: vulnerability detection (Dependabot), follow-up and customer communication ([`../SECURITY.md`](../SECURITY.md)), information export (SBOM/VEX). Absent: documented threat identification, continuous security testing, and risk verification procedures |
| 4.2.1 | Access | **Met** | Public reporting contact and private disclosure channel in [`../SECURITY.md`](../SECURITY.md), with a documented internal response path including the 24/72-hour timeline |
| 4.2.2 | Effectively resourced | **Missing** | No documented personnel assignment or staffing/funding adequacy statement |
-| 4.3.1 | Software bill of materials | **Met** | Documented procedure in [`SBOM.md`](SBOM.md); component records published per release as CycloneDX assets, generated automatically |
+| 4.3.1 | Software bill of materials | **Met** | Documented procedure in [`SBOM.md`](SBOM.md); current component release records are published automatically as CycloneDX assets |
| 4.3.2 | Security assurance | **Partial** | Detection via Dependabot and a per-component vulnerability record mechanism via [`vex/`](vex/README.md). No documented end-to-end detection-to-resolution procedure covering non-dependency vulnerabilities |
| 4.4.1 | Completeness | **Missing** | No affirmation document; would be the output of closing the above |
| 4.4.2 | Duration | **Missing** | No 18-month re-affirmation cycle defined |
@@ -42,12 +42,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** | 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 |
+| 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. Release SBOMs expose native and framework dependencies but do not produce consumer attribution bundles |
+| Per-package LICENSE files | **Partial** | Every distributed component except `openiap-conformance` has a package-local license; the conformance package declares MIT but currently relies on the repository license |
## Proportionality
@@ -66,16 +66,16 @@ produce something a consumer or downstream manufacturer can actually use.
Ordered by value to someone consuming OpenIAP, not by requirement number.
-1. **`MAINTAINERS.md`** (4.1.2) — who is responsible, and who a reporter
- escalates to. Also on the foundation-readiness list, so it pays twice.
-2. **Named security assurance policy** (4.1.1) — mostly assembly: the practice
+1. **Named security assurance policy** (4.1.1) — mostly assembly: the practice
is already documented across `security/` and `SECURITY.md`; what is missing
is one document that says "this is the policy" and is reviewed on a cadence.
-3. **License policy tiers** (5230) — decide what may enter the dependency tree.
+2. **License policy tiers** (5230) — decide what may enter the dependency tree.
Cheap to write now, expensive to retrofit once a copyleft dependency is
already shipping.
-4. **Threat identification and risk verification procedures** (4.1.5) — the
+3. **Threat identification and risk verification procedures** (4.1.5) — the
two genuinely absent practice areas.
+4. **Package-local conformance license** (5230) — make the published package's
+ MIT declaration visible beside its source.
5. **Self-affirmation** (4.4.1, 4.4.2) — only meaningful once 1–4 exist.
Nothing here blocks a release. These are programme-maturity items, and the
diff --git a/security/vex/README.md b/security/vex/README.md
index 16983d62a..dcf322f16 100644
--- a/security/vex/README.md
+++ b/security/vex/README.md
@@ -29,10 +29,10 @@ release SSOT.
{
"vulnerabilities": [
{
- "id": "CVE-2026-12345",
+ "id": "CVE-2021-44228",
"source": {
- "name": "NVD",
- "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-12345"
+ "name": "GitHub Advisory Database",
+ "url": "https://github.com/advisories/GHSA-jfh8-c2jp-5v3q"
},
"affects": [{ "ref": "pkg:maven/com.example/library@1.2.3" }],
"analysis": {
@@ -81,10 +81,11 @@ and the file is covered by the same provenance attestation.
## Lifecycle
-VEX is a per-release snapshot, like the SBOM. Editing a statement changes only
-future releases; a published SBOM is never rewritten. If an assessment changes
-— for example a `not_affected` becomes `exploitable` after new information —
-that is a security advisory and a new release, not an edit to history.
+VEX is a release-specific snapshot, like the SBOM. Editing a statement changes
+only future releases; published assets follow the canonical
+[`SBOM.md` update policy](../SBOM.md#update-policy). If an assessment changes —
+for example a `not_affected` becomes `exploitable` after new information — that
+is a security advisory and a new release, not an edit to history.
See [`../SBOM.md`](../SBOM.md) for the inventory these statements annotate and
[`../../SECURITY.md`](../../SECURITY.md) for the reporting process that