From 38037547757075faa70f75531c8726a8fb93f3f7 Mon Sep 17 00:00:00 2001 From: Hyo Date: Thu, 13 Aug 2026 15:07:16 +0900 Subject: [PATCH 1/7] fix(security): repair SBOM automation and accuracy Dispatch SBOM generation explicitly from release workflows, backfill missing current assets, and read published artifact metadata for accurate dependency inventories. Fix the security audit scanners and align the documentation with verified behavior and CRA deadlines. Closes #323 --- .claude/commands/audit-security.md | 52 +- .github/workflows/ci.yml | 1 + .github/workflows/release-apple.yml | 10 + .github/workflows/release-conformance.yml | 7 + .github/workflows/release-expo.yml | 7 + .github/workflows/release-flutter.yml | 7 + .github/workflows/release-godot.yml | 9 + .github/workflows/release-google.yml | 9 + .github/workflows/release-kmp.yml | 9 + .github/workflows/release-maui.yml | 9 + .github/workflows/release-react-native.yml | 7 + .github/workflows/release.yml | 7 + .github/workflows/sbom.yml | 71 ++- SECURITY.md | 3 +- .../src/pages/docs/security/compliance.tsx | 6 +- .../docs/src/pages/docs/security/overview.tsx | 40 +- .../docs/src/pages/docs/security/sbom.tsx | 28 +- scripts/audit-security.mjs | 123 ++++ scripts/audit-security.test.mjs | 56 ++ scripts/generate-sbom.mjs | 155 +++-- scripts/generate-sbom.test.mjs | 302 +++++++--- scripts/sbom-dependencies.mjs | 565 ++++++++---------- security/CRA.md | 6 +- security/README.md | 31 +- security/SBOM.md | 139 +++-- security/openchain.md | 24 +- security/vex/README.md | 4 +- 27 files changed, 1080 insertions(+), 607 deletions(-) create mode 100644 scripts/audit-security.mjs create mode 100644 scripts/audit-security.test.mjs diff --git a/.claude/commands/audit-security.md b/.claude/commands/audit-security.md index 433ac50ac..35da5693a 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 @@ -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..d61b59150 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. @@ -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..85ab4dad5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,6 +16,7 @@ concurrency: cancel-in-progress: false permissions: + actions: write contents: write jobs: @@ -76,3 +77,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..16623783c 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 * * 0" workflow_dispatch: inputs: tag: @@ -23,14 +30,42 @@ 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. +# The backfill job can dispatch repair runs; the publish job replaces these +# permissions with exactly what attestation and upload require. permissions: + actions: write contents: read jobs: + backfill: + name: Dispatch missing current SBOMs + if: github.event_name == 'push' || github.event_name == 'schedule' + runs-on: ubuntu-latest + steps: + - name: Checkout default branch + uses: actions/checkout@v7 + + - 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,11 +120,27 @@ jobs: RELEASE_TAG: ${{ steps.tag.outputs.tag }} run: node scripts/generate-sbom.mjs resolve-tag "$RELEASE_TAG" + - name: Check for an existing immutable asset + id: existing + if: ${{ steps.component.outputs.matched == 'true' }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_TAG: ${{ steps.tag.outputs.tag }} + SBOM_NAME: ${{ steps.component.outputs.sbom-name }} + run: | + if gh release view "$RELEASE_TAG" --json assets \ + --jq '.assets[].name' | grep -Fxq "$SBOM_NAME"; then + echo "exists=true" >> "$GITHUB_OUTPUT" + echo "::notice::$SBOM_NAME is already attached; preserving it." + else + echo "exists=false" >> "$GITHUB_OUTPUT" + 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' }} + if: ${{ steps.component.outputs.matched == 'true' && steps.existing.outputs.exists == 'false' }} # `--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. @@ -104,7 +155,7 @@ jobs: --with-licenses - 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,13 +206,13 @@ 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 }} RELEASE_TAG: ${{ steps.tag.outputs.tag }} diff --git a/SECURITY.md b/SECURITY.md index 347806e26..3e85580b5 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -109,7 +109,8 @@ 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: ```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..3b37852eb 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', @@ -221,8 +221,8 @@ function SecurityCompliance() { 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. + Open: a single named security-assurance policy document, competency + and assessment records, and a declared license policy.

- What every release publishes + What current release workflows publish 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 +226,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,8 +238,8 @@ 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 @@ -247,13 +249,11 @@ function SecurityOverview() { each release's SBOM records exactly what shipped.

- 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..8b660c34e 100644 --- a/packages/docs/src/pages/docs/security/sbom.tsx +++ b/packages/docs/src/pages/docs/security/sbom.tsx @@ -134,6 +134,11 @@ const LIMITS: Limit[] = [ 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.', + }, ]; function SecuritySbom() { @@ -143,19 +148,18 @@ 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? - + Every supported OpenIAP component release carries a machine-readable + inventory of its direct third-party dependencies, attached to its GitHub + Release as a CycloneDX 1.6 JSON file. It exposes the + released dependency contract without reconstructing build scripts; exact + application exposure comes from the consumer's resolved dependency + graph.

@@ -249,8 +253,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 +309,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..d80141b72 --- /dev/null +++ b/scripts/audit-security.mjs @@ -0,0 +1,123 @@ +#!/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 (!/^[|>][-+]?\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..716740635 --- /dev/null +++ b/scripts/audit-security.test.mjs @@ -0,0 +1,56 @@ +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("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..629a3df0f 100644 --- a/scripts/generate-sbom.mjs +++ b/scripts/generate-sbom.mjs @@ -55,11 +55,11 @@ const SPEC_VERSION = "1.6"; * * `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 +107,6 @@ const COMPONENTS = { kind: "pub", manifest: "libraries/flutter_inapp_purchase/pubspec.yaml", }, - resolver: "flutter pub deps --json", }, godot: { sbomName: "godot-iap", @@ -128,7 +127,6 @@ const COMPONENTS = { }, }, }, - resolver: "gradlew :dependencies", }, google: { sbomName: "openiap-google", @@ -139,10 +137,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 +153,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 +170,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 +223,50 @@ 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); +/** Return the newest published release per component that lacks its SBOM. */ +export function findMissingLatestSbomTags(releases) { + const seen = new Set(); + const missing = []; + const newestFirst = [...releases].sort( + (left, right) => + Date.parse(right?.published_at ?? 0) - + Date.parse(left?.published_at ?? 0), + ); + for (const release of newestFirst) { + if (release?.draft || !release?.published_at) continue; + const resolvedTag = componentFromTag(release.tag_name); + if (!resolvedTag || seen.has(resolvedTag.componentId)) continue; + seen.add(resolvedTag.componentId); + const expected = sbomFileName(resolvedTag.componentId, resolvedTag.version); + const assets = Array.isArray(release.assets) ? release.assets : []; + if (!assets.some((asset) => asset?.name === expected)) { + 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 +278,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 +389,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 +522,17 @@ async function fetchText(url) { return response.text(); } +async function fetchPublishedText(url) { + for (let attempt = 0; attempt < 6; attempt += 1) { + const result = await fetchText(url); + if (result) return result; + if (attempt < 5) { + await new Promise((resolveDelay) => setTimeout(resolveDelay, 5_000)); + } + } + return null; +} + /** * Look up a dependency's declared license and supplier in its own registry. * @@ -680,6 +719,7 @@ export async function generateSbom( resolvedFile, withLicenses = false, runGit = defaultRunGit, + fetchArtifactText = fetchPublishedText, } = {}, ) { const definition = COMPONENTS[componentId]; @@ -698,7 +738,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 +827,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)}\nmatched=true\n` : "matched=false\n"; process.stdout.write(line); if (process.env.GITHUB_OUTPUT) { diff --git a/scripts/generate-sbom.test.mjs b/scripts/generate-sbom.test.mjs index a3857b47f..3861d3574 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,6 +16,7 @@ import { __testing as generatorTesting, buildSbom, componentFromTag, + findMissingLatestSbomTags, generateSbom, listComponentIds, normalizeLicense, @@ -37,16 +39,110 @@ const historicalGoogleRoot = resolve( ); const { COMPONENTS } = 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 +170,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 +209,61 @@ 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("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`); + assert.match(source, /actions: write/u, name); + } +}); + 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 +299,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 +311,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 +426,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 +437,80 @@ 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", () => { + assert.throws( + () => parseMavenCoordinate("com.example:lib:$unknownVersion"), + /Unresolved 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, + assert.throws( + () => parseMavenCoordinate("com.example:lib:1.0.0:sources"), + /Unsupported Maven coordinate/u, ); - 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")); -}); - -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', + 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"), + ), ); - assert.equal(versions.get("foo"), "1.2.3"); - assert.deepEqual(libraries.get("bar-baz"), { - module: "com.example:bar", - versionRef: "foo", - literal: undefined, - }); }); -test("NuGet references marked PrivateAssets=all are build-only", () => { - const dependencies = extractNuget(repoRoot, COMPONENTS.maui.source); - const names = dependencies.map((entry) => entry.name); - assert.ok(names.includes("Xamarin.Android.Google.BillingClient")); - // PrivateAssets="all" is not propagated to consumers of the package. - assert.ok(!names.includes("Microsoft.Maui.Controls")); - // Every MSBuild property must have been interpolated. - for (const entry of dependencies) { - assert.doesNotMatch(entry.version, /\$\(/u, entry.name); - } +test("published metadata parsers reject incomplete dependencies", () => { + assert.throws( + () => + parseMavenPom( + "g" + + "a", + { url: "fixture.pom", version: "1.0.0" }, + ), + /Incomplete runtime dependency/u, + ); + assert.throws( + () => + parseNugetNuspec( + '', + { url: "fixture.nuspec" }, + ), + /Incomplete published NuGet dependency/u, + ); }); 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 +535,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..b6d20b960 100644 --- a/scripts/sbom-dependencies.mjs +++ b/scripts/sbom-dependencies.mjs @@ -1,17 +1,11 @@ #!/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"; @@ -25,7 +19,54 @@ function readJson(root, relativePath) { return JSON.parse(readText(root, relativePath)); } -/** Gradle `val name = "value"` locals, used to resolve `$name` interpolation. */ +function decodeXml(value) { + return String(value ?? "") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll(""", '"') + .replaceAll("'", "'") + .replaceAll("&", "&"); +} + +function xmlValue(source, tag) { + const match = source.match( + new RegExp(`<${tag}\\b[^>]*>([\\s\\S]*?)<\\/${tag}>`, "u"), + ); + return match ? decodeXml(match[1].trim()) : null; +} + +function encodePurlVersion(version) { + return encodeURIComponent(version).replaceAll("%3A", ":"); +} + +function isVersionConstraint(version) { + return ( + version === "any" || + /[\s*^~<>=|,[\](){}]/u.test(version) || + /\bx\b/iu.test(version) + ); +} + +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, + version, + purl, + ...(versionProperties.length > 0 || properties.length > 0 + ? { properties: [...versionProperties, ...properties] } + : {}), + }; +} + +/** Gradle `val name = "value"` locals used by the Godot release manifest. */ function readGradleLocals(source) { const locals = new Map(); for (const match of source.matchAll( @@ -33,78 +74,35 @@ function readGradleLocals(source) { )) { locals.set(match[1], match[2]); } - - // `val x = (project.findProperty("PROP") as String?) ?: "fallback"` — the - // gradle.properties entry wins at build time, so prefer it and fall back to - // the literal only when the property is absent. - for (const match of source.matchAll( - /\bval\s+([A-Za-z_][A-Za-z0-9_]*)\s*=\s*\(\s*project\.findProperty\(\s*"([^"]+)"\s*\)[^)]*\)\s*\?:\s*"([^"]+)"/gu, - )) { - locals.set(match[1], { property: match[2], fallback: match[3] }); - } - return locals; } -function readGradleProperties(root, manifest) { - const properties = new Map(); - const segments = manifest.split("/"); - // Walk from the module directory up to the repository root, mirroring how - // Gradle layers project and root properties. - for (let depth = segments.length - 1; depth > 0; depth -= 1) { - const candidate = [...segments.slice(0, depth), "gradle.properties"].join( - "/", - ); - let source; - try { - source = readText(root, candidate); - } catch { - continue; - } - for (const line of source.split("\n")) { - const match = line.match(/^\s*([\w.-]+)\s*=\s*(.+?)\s*$/u); - if (match && !properties.has(match[1])) { - properties.set(match[1], match[2]); - } - } - } - return properties; -} - -/** - * Resolve locals that a sibling module owns. - * - * The Godot Android plugin computes its coordinates from `openiap-versions.json` - * and from packages/google's build script. Reading the same files keeps the - * coordinate correct without duplicating a version into this table. - */ function readExternalLocals(root, externalLocals = {}) { const resolved = new Map(); for (const [name, spec] of Object.entries(externalLocals)) { if (spec.json) { - resolved.set(name, readJson(root, spec.file)[spec.json]); - } else if (spec.gradleLocal) { + const value = readJson(root, spec.file)[spec.json]; + if (value == null) { + throw new Error(`Missing ${spec.json} in ${spec.file}`); + } + resolved.set(name, String(value)); + continue; + } + if (spec.gradleLocal) { const value = readGradleLocals(readText(root, spec.file)).get( spec.gradleLocal, ); - if (typeof value === "string") resolved.set(name, value); + if (!value) { + throw new Error( + `Missing Gradle local ${spec.gradleLocal} in ${spec.file}`, + ); + } + resolved.set(name, value); } } return resolved; } -function flattenLocals(locals, properties) { - const flat = new Map(); - for (const [name, value] of locals) { - if (typeof value === "string") { - flat.set(name, value); - } else if (value?.property) { - flat.set(name, properties.get(value.property) ?? value.fallback); - } - } - return flat; -} - function interpolateGradle(coordinate, locals) { return coordinate.replace( /\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?/gu, @@ -113,99 +111,27 @@ function interpolateGradle(coordinate, locals) { } 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. + 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("$")) { - return { unresolved: coordinate }; + throw new Error(`Unresolved Maven coordinate '${coordinate}'`); } - return { + const [group, artifact, version] = parts; + return dependencyEntry({ name: `${group}:${artifact}`, version, - purl: `pkg:maven/${group}/${artifact}@${version}`, - }; + purl: `pkg:maven/${group}/${artifact}@${encodePurlVersion(version)}`, + }); } -/** - * Remove a balanced `val Test by getting { ... }` source-set block. - * - * Kotlin Multiplatform declares test dependencies with the same - * `implementation(...)` configuration name as production ones; only the - * enclosing source set distinguishes them. - */ -function stripTestSourceSets(source) { - const opener = - /\bval\s+[A-Za-z0-9_]*[Tt]est[A-Za-z0-9_]*\s+by\s+getting\s*\{/gu; - let result = source; - let match; - - while ((match = opener.exec(result)) !== null) { - let depth = 1; - let index = match.index + match[0].length; - while (index < result.length && depth > 0) { - if (result[index] === "{") depth += 1; - else if (result[index] === "}") depth -= 1; - index += 1; - } - // 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.`, - ); - } - result = result.slice(0, match.index) + result.slice(index); - opener.lastIndex = 0; - } - - return result; -} - -/** - * Expand `for (name in listOf("a", "b")) { ... $name ... }` bodies. - * - * packages/google declares the Horizon platform SDK modules this way, so - * without expansion three real runtime dependencies would be unresolvable. - */ -function expandGradleForLoops(source) { - return source.replace( - /\bfor\s*\(\s*([A-Za-z_][A-Za-z0-9_]*)\s+in\s+listOf\(([^)]*)\)\s*\)\s*\{([^{}]*)\}/gu, - (whole, variable, rawItems, body) => { - const items = [...rawItems.matchAll(/"([^"]+)"/gu)].map( - (item) => item[1], - ); - if (items.length === 0) return whole; - return items - .map((item) => - body.replace(new RegExp(`\\$\\{?${variable}\\}?`, "gu"), item), - ) - .join("\n"); - }, - ); -} - -/** - * Gradle configurations that place a dependency on the consumer's runtime - * classpath. `compileOnly` and every test configuration are deliberately absent. - */ const GRADLE_RUNTIME_CONFIGURATIONS = new Set([ "api", "implementation", "runtimeOnly", ]); -/** - * Configuration prefixes that never reach a consumer. These must be checked - * before the flavored-configuration pattern below, because `testImplementation` - * and `androidTestImplementation` both match it. - */ const GRADLE_NON_RUNTIME_PREFIXES = [ "test", "androidTest", @@ -222,119 +148,128 @@ 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 locals = readGradleLocals(source); for (const [name, value] of readExternalLocals(root, externalLocals)) { locals.set(name, value); } - const source = expandGradleForLoops(stripTestSourceSets(rawSource)); - const found = new Map(); - const unresolved = []; + if (/\b(?:api|implementation|runtimeOnly)\s*\(\s*libs\./u.test(source)) { + throw new Error( + `Unsupported version-catalog dependency in ${manifest}; use published metadata instead`, + ); + } + + const found = new Map(); const record = (configuration, rawCoordinate) => { if (!isRuntimeGradleConfiguration(configuration)) return; const parsed = parseMavenCoordinate( interpolateGradle(rawCoordinate, locals), ); - if (!parsed) return; - if (parsed.unresolved) { - unresolved.push(parsed.unresolved); - return; - } found.set(parsed.purl, parsed); }; - // implementation("group:artifact:version") for (const match of source.matchAll( /\b([a-zA-Z][A-Za-z0-9]*)\s*\(\s*"([^"]+:[^"]+:[^"]+)"\s*\)/gu, )) { record(match[1], match[2]); } - - // add("playApi", "group:artifact:version") for (const match of source.matchAll( /\badd\s*\(\s*"([^"]+)"\s*,\s*"([^"]+:[^"]+:[^"]+)"\s*\)/gu, )) { record(match[1], match[2]); } - if (unresolved.length > 0) { - throw new Error( - `Unresolved Gradle coordinates in ${manifest}: ${[...new Set(unresolved)].join(", ")}. ` + - `Model the declaration in sbom-dependencies.mjs, or supply a resolver export with --resolved.`, - ); - } - return [...found.values()].sort((left, right) => left.purl.localeCompare(right.purl), ); } -function parseVersionCatalog(source) { - const versions = new Map(); - const libraries = new Map(); - let section = ""; - - for (const rawLine of source.split("\n")) { - const line = rawLine.trim(); - if (line.startsWith("#") || line === "") continue; - const sectionMatch = line.match(/^\[([^\]]+)\]$/u); - if (sectionMatch) { - section = sectionMatch[1]; - continue; - } - if (section === "versions") { - const match = line.match(/^([\w.-]+)\s*=\s*"([^"]+)"/u); - if (match) versions.set(match[1], match[2]); - continue; - } - if (section === "libraries") { - const match = line.match(/^([\w.-]+)\s*=\s*\{(.+)\}/u); - if (!match) continue; - const module = match[2].match(/module\s*=\s*"([^"]+)"/u)?.[1]; - const versionRef = match[2].match(/version\.ref\s*=\s*"([^"]+)"/u)?.[1]; - const literal = match[2].match(/version\s*=\s*"([^"]+)"/u)?.[1]; - if (module) libraries.set(match[1], { module, versionRef, literal }); - } +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; } - - return { versions, libraries }; + if (resolvedValue.includes("${")) { + throw new Error(`Unresolved Maven value '${value}' in ${context.url}`); + } + return resolvedValue; } -/** `libs.kotlinx.coroutines.core` -> catalog alias `kotlinx-coroutines-core`. */ -function catalogAliasFromAccessor(accessor) { - return accessor.replace(/\./gu, "-"); -} +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())); + } + } -function extractGradleCatalog(root, { manifest, catalog }) { - const source = stripTestSourceSets(readText(root, manifest)); - const { versions, libraries } = parseVersionCatalog(readText(root, catalog)); + const withoutManaged = source + .replace(/[\s\S]*?<\/dependencyManagement>/gu, "") + .replace(/[\s\S]*?<\/profiles>/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 (["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 +277,49 @@ 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; + throw new Error(`Published POM not found: ${failures.join(", ")}`); } -function interpolateMsBuild(value, properties) { - return value.replace( - /\$\(([A-Za-z_][\w.-]*)\)/gu, - (whole, name) => properties.get(name) ?? whole, - ); -} +function parseNugetNuspec(source, context) { + const dependenciesBlock = source.match( + /([\s\S]*?)<\/dependencies>/u, + )?.[1]; + if (!dependenciesBlock) return []; -function extractNuget(root, { manifest, propertyFiles = [] }) { - const source = readText(root, manifest); - const properties = readMsBuildProperties(root, [...propertyFiles, manifest]); const found = new Map(); - - for (const match of source.matchAll(/]*)\/?>/gu)) { + 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 +327,24 @@ 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 Error(`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 +355,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 +376,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 +465,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..4e0f181fd 100644 --- a/security/CRA.md +++ b/security/CRA.md @@ -100,9 +100,9 @@ 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:** a CycloneDX 1.6 SBOM is generated for every supported +component release and attached to its GitHub Release. Generation records the +release and generator commits, and the core dependency inventory is reproducible from those inputs. Registry-sourced license and supplier metadata is point-in-time enrichment. diff --git a/security/README.md b/security/README.md index 377d8e847..73ad0bb76 100644 --- a/security/README.md +++ b/security/README.md @@ -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 │ ┌─────────┴─────────┐ ▼ ▼ @@ -90,7 +91,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 +106,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 +123,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 +135,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 +155,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..236312d66 100644 --- a/security/SBOM.md +++ b/security/SBOM.md @@ -2,14 +2,15 @@ ## 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?_ +Every supported OpenIAP component release carries a machine-readable inventory +of its direct third-party dependencies. 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 +23,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 +67,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 +77,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: @@ -104,9 +106,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 +121,14 @@ 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 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` | ### What is included @@ -149,12 +151,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 +188,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 +199,37 @@ 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` | - -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 weekly schedule dispatch any missing newest-component asset. ```text release workflow → GitHub Release published - │ + │ explicit workflow_dispatch ▼ - sbom.yml (release: published) + sbom.yml │ ┌───────────────┼───────────────┐ ▼ ▼ ▼ @@ -252,7 +247,8 @@ 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 rather than +overwriting an immutable release asset. ## Storage location @@ -273,7 +269,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 \ @@ -306,8 +303,8 @@ suppliers, so those fields are point-in-time metadata and are not guaranteed to be byte-identical later. ```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 +331,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 +339,16 @@ git worktree remove --force "$SBOM_REPRO_DIR" ## Update policy -- An SBOM is produced for every published release, automatically. +- Every current release workflow produces an SBOM 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. - 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 + week, 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. diff --git a/security/openchain.md b/security/openchain.md index 25e8dffcf..df6a7a45b 100644 --- a/security/openchain.md +++ b/security/openchain.md @@ -27,7 +27,7 @@ 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 | @@ -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..723069c37 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" + "url": "https://github.com/advisories/GHSA-jfh8-c2jp-5v3q" }, "affects": [{ "ref": "pkg:maven/com.example/library@1.2.3" }], "analysis": { From f550afd67c33209c42f53b9a9cca3a894ef1e23b Mon Sep 17 00:00:00 2001 From: Hyo Date: Thu, 13 Aug 2026 19:26:58 +0900 Subject: [PATCH 2/7] fix(security): harden sbom recovery --- .github/workflows/sbom.yml | 33 ++++++++---- SECURITY.md | 5 +- .../docs/src/pages/docs/security/overview.tsx | 2 +- .../docs/src/pages/docs/security/sbom.tsx | 25 +++++++--- scripts/generate-sbom.mjs | 30 ++++++++--- scripts/generate-sbom.test.mjs | 50 ++++++++++++++++++- scripts/sbom-dependencies.mjs | 15 +++++- security/CRA.md | 7 +-- security/SBOM.md | 30 +++++++---- 9 files changed, 155 insertions(+), 42 deletions(-) diff --git a/.github/workflows/sbom.yml b/.github/workflows/sbom.yml index 16623783c..f212ed42d 100644 --- a/.github/workflows/sbom.yml +++ b/.github/workflows/sbom.yml @@ -18,7 +18,7 @@ on: - "scripts/generate-sbom.mjs" - "scripts/sbom-dependencies.mjs" schedule: - - cron: "23 3 * * 0" + - cron: "23 3 * * *" workflow_dispatch: inputs: tag: @@ -141,18 +141,33 @@ jobs: - name: Generate CycloneDX SBOM id: generate if: ${{ steps.component.outputs.matched == 'true' && steps.existing.outputs.exists == 'false' }} - # `--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. + # 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 + fi + + echo "$OUTPUT" + if ! grep -Eq '^::error::Published (POM|nuspec) not found:' <<< "$OUTPUT"; then + exit 1 + 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' && steps.existing.outputs.exists == 'false' }} diff --git a/SECURITY.md b/SECURITY.md index 3e85580b5..c9e6a288b 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -105,8 +105,9 @@ 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 \ diff --git a/packages/docs/src/pages/docs/security/overview.tsx b/packages/docs/src/pages/docs/security/overview.tsx index f68f6cee0..ab2f5e5f6 100644 --- a/packages/docs/src/pages/docs/security/overview.tsx +++ b/packages/docs/src/pages/docs/security/overview.tsx @@ -52,7 +52,7 @@ const AUTOMATION: Trigger[] = [ what: 'The release workflow dispatches SBOM generation for its tag. The SBOM job verifies the identity, signs provenance, and attaches the immutable asset', }, { - when: 'Weekly', + when: 'Daily', what: 'The missing-SBOM safety net runs. Dependabot and OpenSSF Scorecard also re-check the dependency and repository posture on their schedules', }, { diff --git a/packages/docs/src/pages/docs/security/sbom.tsx b/packages/docs/src/pages/docs/security/sbom.tsx index 8b660c34e..d46fb235f 100644 --- a/packages/docs/src/pages/docs/security/sbom.tsx +++ b/packages/docs/src/pages/docs/security/sbom.tsx @@ -127,7 +127,7 @@ 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', @@ -139,6 +139,16 @@ const LIMITS: Limit[] = [ 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: 'Google 3.3.0 history', + detail: + 'predates the published-POM reader and remains immutable. Use its published POM instead of that release asset for dependency matching.', + }, ]; function SecuritySbom() { @@ -148,18 +158,17 @@ function SecuritySbom() {

    Software Bill of Materials

    - Every supported OpenIAP component release carries a machine-readable - inventory of its direct third-party dependencies, attached to its GitHub - Release as a CycloneDX 1.6 JSON file. It exposes the - released dependency contract without reconstructing build scripts; exact - application exposure comes from the consumer's resolved dependency - graph. + 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.

    diff --git a/scripts/generate-sbom.mjs b/scripts/generate-sbom.mjs index 629a3df0f..007660f79 100644 --- a/scripts/generate-sbom.mjs +++ b/scripts/generate-sbom.mjs @@ -522,12 +522,24 @@ async function fetchText(url) { return response.text(); } -async function fetchPublishedText(url) { - for (let attempt = 0; attempt < 6; attempt += 1) { - const result = await fetchText(url); - if (result) return result; - if (attempt < 5) { - await new Promise((resolveDelay) => setTimeout(resolveDelay, 5_000)); +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; @@ -903,4 +915,8 @@ if (process.argv[1] === fileURLToPath(import.meta.url)) { }); } -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 3861d3574..0c807bbb4 100644 --- a/scripts/generate-sbom.test.mjs +++ b/scripts/generate-sbom.test.mjs @@ -37,7 +37,7 @@ const historicalGoogleRoot = resolve( repoRoot, "scripts/fixtures/historical-releases/google-v1.3.0", ); -const { COMPONENTS } = generatorTesting; +const { COMPONENTS, fetchPublishedText } = generatorTesting; const { extractGradle, extractPub, @@ -264,6 +264,17 @@ test("every GitHub release workflow dispatches the SBOM workflow", () => { } }); +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, /Published \(POM\|nuspec\) not found/u); + assert.match(source, /sleep 120/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")) { @@ -480,7 +491,7 @@ test("published metadata matches the consumer-visible artifacts", async () => { ); }); -test("published metadata parsers reject incomplete dependencies", () => { +test("published metadata parsers reject unsupported dependencies", () => { assert.throws( () => parseMavenPom( @@ -498,6 +509,41 @@ test("published metadata parsers reject incomplete dependencies", () => { ), /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("pub dependencies exclude the Flutter SDK itself", () => { diff --git a/scripts/sbom-dependencies.mjs b/scripts/sbom-dependencies.mjs index b6d20b960..753f5df0a 100644 --- a/scripts/sbom-dependencies.mjs +++ b/scripts/sbom-dependencies.mjs @@ -231,9 +231,15 @@ function parseMavenPom(source, context) { } } + const profiles = source.match(/([\s\S]*?)<\/profiles>/u)?.[1]; + if (profiles && /[\s\S]*?<\/dependencyManagement>/gu, "") - .replace(/[\s\S]*?<\/profiles>/gu, ""); + .replace(/[\s\S]*?<\/profiles>/gu, "") + .replace(/[\s\S]*?<\/build>/gu, ""); const found = new Map(); for (const match of withoutManaged.matchAll( @@ -242,6 +248,13 @@ function parseMavenPom(source, context) { 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; } diff --git a/security/CRA.md b/security/CRA.md index 4e0f181fd..eb371aa2d 100644 --- a/security/CRA.md +++ b/security/CRA.md @@ -100,9 +100,10 @@ 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 supported -component release and attached to its GitHub Release. Generation records the -release and generator commits, and the core +**How OpenIAP does this:** current component release workflows generate a +CycloneDX 1.6 SBOM and attach it to the GitHub Release; 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. diff --git a/security/SBOM.md b/security/SBOM.md index 236312d66..0b654dd7e 100644 --- a/security/SBOM.md +++ b/security/SBOM.md @@ -2,11 +2,12 @@ ## Purpose -Every supported OpenIAP component release carries a machine-readable inventory -of its direct third-party dependencies. 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. +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 released manifest or the registry descriptor that consumers resolve. No one edits an SBOM by hand, and none are committed to the @@ -90,7 +91,8 @@ a missing license field and does not block the 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 @@ -124,7 +126,7 @@ The generator (`scripts/generate-sbom.mjs`) reads: | Ecosystem | Dependency source | | -------------- | ----------------------------------------------------------- | | npm | Released `package.json` (`dependencies`) | -| Maven / Gradle | Published POM for the consumer artifact | +| 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` | @@ -214,6 +216,11 @@ 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. +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. + 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. @@ -223,7 +230,7 @@ accessors, and unsupported coordinate shapes instead of silently dropping them. 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 weekly schedule dispatch any missing newest-component asset. +SBOM changes and a daily schedule dispatch any missing newest-component asset. ```text release workflow → GitHub Release published @@ -302,6 +309,11 @@ 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 intentionally reproduces the immutable Google 3.3.0 asset. +That asset predates the published-POM reader and contains the older +source-manifest inventory; use the published POM, not that historical SBOM, for +Google 3.3.0 dependency matching. + ```bash RELEASE_TAG=google-3.3.0 PUBLISHED_SBOM=/absolute/path/openiap-google-3.3.0.cdx.json @@ -348,7 +360,7 @@ git worktree remove --force "$SBOM_REPRO_DIR" 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 - week, so a missed release-time dispatch is repaired without manual triage. + 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. From fc3d100c153682cd34210e5231806792ae6160d5 Mon Sep 17 00:00:00 2001 From: Hyo Date: Thu, 13 Aug 2026 19:42:05 +0900 Subject: [PATCH 3/7] fix(security): address review findings --- .claude/commands/audit-security.md | 2 +- .github/workflows/release-google.yml | 24 +++++------ .github/workflows/release.yml | 7 ++-- .github/workflows/sbom.yml | 23 +++++++---- .../docs/src/pages/docs/security/sbom.tsx | 5 +++ scripts/audit-security.mjs | 5 ++- scripts/audit-security.test.mjs | 9 ++++ scripts/generate-sbom.mjs | 22 ++++++---- scripts/generate-sbom.test.mjs | 41 ++++++++++++++++++- scripts/sbom-dependencies.mjs | 12 +++++- security/CRA.md | 9 ++-- security/SBOM.md | 4 ++ security/vex/README.md | 2 +- 13 files changed, 124 insertions(+), 41 deletions(-) diff --git a/.claude/commands/audit-security.md b/.claude/commands/audit-security.md index 35da5693a..fb710d69b 100644 --- a/.claude/commands/audit-security.md +++ b/.claude/commands/audit-security.md @@ -94,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 diff --git a/.github/workflows/release-google.yml b/.github/workflows/release-google.yml index d61b59150..ff6de90a7 100644 --- a/.github/workflows/release-google.yml +++ b/.github/workflows/release-google.yml @@ -339,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 @@ -381,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 @@ -423,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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 85ab4dad5..961838c54 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,13 +15,12 @@ concurrency: group: ${{ github.workflow }} cancel-in-progress: false -permissions: - actions: write - contents: write - jobs: release: runs-on: ubuntu-latest + permissions: + actions: write + contents: write steps: - name: Checkout repository uses: actions/checkout@v7 diff --git a/.github/workflows/sbom.yml b/.github/workflows/sbom.yml index f212ed42d..e612bc94c 100644 --- a/.github/workflows/sbom.yml +++ b/.github/workflows/sbom.yml @@ -30,10 +30,7 @@ concurrency: group: sbom-${{ github.event.release.tag_name || inputs.tag }} cancel-in-progress: false -# The backfill job can dispatch repair runs; the publish job replaces these -# permissions with exactly what attestation and upload require. permissions: - actions: write contents: read jobs: @@ -41,9 +38,14 @@ jobs: 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: @@ -128,8 +130,12 @@ jobs: RELEASE_TAG: ${{ steps.tag.outputs.tag }} SBOM_NAME: ${{ steps.component.outputs.sbom-name }} run: | - if gh release view "$RELEASE_TAG" --json assets \ - --jq '.assets[].name' | grep -Fxq "$SBOM_NAME"; then + if ! ASSET_NAMES=$(gh release view "$RELEASE_TAG" --json assets \ + --jq '.assets[].name'); then + echo "::error::Unable to inspect release assets for $RELEASE_TAG" + exit 1 + fi + if grep -Fxq "$SBOM_NAME" <<< "$ASSET_NAMES"; then echo "exists=true" >> "$GITHUB_OUTPUT" echo "::notice::$SBOM_NAME is already attached; preserving it." else @@ -155,11 +161,14 @@ jobs: --with-licenses 2>&1); then echo "$OUTPUT" exit 0 + else + STATUS=$? fi echo "$OUTPUT" - if ! grep -Eq '^::error::Published (POM|nuspec) not found:' <<< "$OUTPUT"; then - exit 1 + # 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." diff --git a/packages/docs/src/pages/docs/security/sbom.tsx b/packages/docs/src/pages/docs/security/sbom.tsx index d46fb235f..de9a0c58c 100644 --- a/packages/docs/src/pages/docs/security/sbom.tsx +++ b/packages/docs/src/pages/docs/security/sbom.tsx @@ -144,6 +144,11 @@ const LIMITS: Limit[] = [ 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.", + }, { title: 'Google 3.3.0 history', detail: diff --git a/scripts/audit-security.mjs b/scripts/audit-security.mjs index d80141b72..c7ab2f2e3 100644 --- a/scripts/audit-security.mjs +++ b/scripts/audit-security.mjs @@ -20,7 +20,10 @@ export function findWorkflowRunInterpolations( if (opener[2].includes("${{")) { findings.push(`${filename}:${index + 1}: ${lines[index].trim()}`); } - if (!/^[|>][-+]?\s*$/u.test(opener[2])) continue; + 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]; diff --git a/scripts/audit-security.test.mjs b/scripts/audit-security.test.mjs index 716740635..0df455985 100644 --- a/scripts/audit-security.test.mjs +++ b/scripts/audit-security.test.mjs @@ -25,6 +25,15 @@ test("workflow scan detects expressions in scalar and block run steps", () => { ]); }); +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). ` + diff --git a/scripts/generate-sbom.mjs b/scripts/generate-sbom.mjs index 007660f79..04c5ceccd 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,6 +50,7 @@ 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; /** * SBOM-specific metadata per releasable component. @@ -227,13 +229,13 @@ export function sbomFileName(componentId, version) { export function findMissingLatestSbomTags(releases) { const seen = new Set(); const missing = []; - const newestFirst = [...releases].sort( - (left, right) => - Date.parse(right?.published_at ?? 0) - - Date.parse(left?.published_at ?? 0), - ); + 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) { - if (release?.draft || !release?.published_at) continue; const resolvedTag = componentFromTag(release.tag_name); if (!resolvedTag || seen.has(resolvedTag.componentId)) continue; seen.add(resolvedTag.componentId); @@ -910,8 +912,12 @@ 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; }); } diff --git a/scripts/generate-sbom.test.mjs b/scripts/generate-sbom.test.mjs index 0c807bbb4..b0ecc26dc 100644 --- a/scripts/generate-sbom.test.mjs +++ b/scripts/generate-sbom.test.mjs @@ -20,6 +20,7 @@ import { generateSbom, listComponentIds, normalizeLicense, + PUBLISHED_METADATA_UNAVAILABLE_EXIT_CODE, readComponentVersion, readVexStatements, releaseTagFor, @@ -28,7 +29,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"; @@ -260,6 +263,10 @@ test("every GitHub release workflow dispatches the SBOM workflow", () => { ); 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); } }); @@ -271,10 +278,25 @@ test("SBOM publication waits for registry propagation and repairs daily", () => ); assert.match(source, /cron: "23 3 \* \* \*"/u); assert.match(source, /for attempt in \{1\.\.16\}/u); - assert.match(source, /Published \(POM\|nuspec\) not found/u); + assert.match(source, /\[ "\$STATUS" -ne 75 \]/u); + assert.doesNotMatch(source, /grep.+Published/u); + assert.match(source, /ASSET_NAMES=\$\(gh release view/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")) { @@ -546,6 +568,23 @@ test("published metadata fetches retry transport failures", async () => { assert.equal(attempts, 2); }); +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 dependencies = extractPub(repoRoot, COMPONENTS.flutter.source); assert.deepEqual( diff --git a/scripts/sbom-dependencies.mjs b/scripts/sbom-dependencies.mjs index 753f5df0a..6e294bd23 100644 --- a/scripts/sbom-dependencies.mjs +++ b/scripts/sbom-dependencies.mjs @@ -11,6 +11,8 @@ 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"); } @@ -306,7 +308,9 @@ async function extractMavenPom(_root, source, context) { failures.push(url); } } - throw new Error(`Published POM not found: ${failures.join(", ")}`); + throw new PublishedMetadataUnavailableError( + `Published POM not found: ${failures.join(", ")}`, + ); } function parseNugetNuspec(source, context) { @@ -347,7 +351,11 @@ async function extractNugetNuspec(_root, source, context) { `https://api.nuget.org/v3-flatcontainer/${packageId}/${version}/` + `${packageId}.nuspec`; const document = await context.fetchText(url); - if (!document) throw new Error(`Published nuspec not found: ${url}`); + if (!document) { + throw new PublishedMetadataUnavailableError( + `Published nuspec not found: ${url}`, + ); + } return parseNugetNuspec(document, { ...context, url }); } diff --git a/security/CRA.md b/security/CRA.md index eb371aa2d..22f99256b 100644 --- a/security/CRA.md +++ b/security/CRA.md @@ -100,10 +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:** current component release workflows generate a -CycloneDX 1.6 SBOM and attach it to the GitHub Release; a daily scan repairs -missed latest-release assets. 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. diff --git a/security/SBOM.md b/security/SBOM.md index 0b654dd7e..2e7f2157a 100644 --- a/security/SBOM.md +++ b/security/SBOM.md @@ -132,6 +132,10 @@ The generator (`scripts/generate-sbom.mjs`) reads: | 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 **Runtime dependencies of the published artifact.** Direct dependencies always; diff --git a/security/vex/README.md b/security/vex/README.md index 723069c37..bda6dad80 100644 --- a/security/vex/README.md +++ b/security/vex/README.md @@ -31,7 +31,7 @@ release SSOT. { "id": "CVE-2021-44228", "source": { - "name": "NVD", + "name": "GitHub Advisory Database", "url": "https://github.com/advisories/GHSA-jfh8-c2jp-5v3q" }, "affects": [{ "ref": "pkg:maven/com.example/library@1.2.3" }], From 17b6642423fb389a72249c8c250d3c17934906fd Mon Sep 17 00:00:00 2001 From: Hyo Date: Thu, 13 Aug 2026 20:32:26 +0900 Subject: [PATCH 4/7] fix(security): repair legacy sbom --- .github/workflows/sbom.yml | 37 +++++++++++++++---- .../docs/src/pages/docs/security/sbom.tsx | 5 --- scripts/generate-sbom.mjs | 19 ++++++++-- scripts/generate-sbom.test.mjs | 29 ++++++++++++++- security/SBOM.md | 18 +++++---- 5 files changed, 84 insertions(+), 24 deletions(-) diff --git a/.github/workflows/sbom.yml b/.github/workflows/sbom.yml index e612bc94c..ec1cd361d 100644 --- a/.github/workflows/sbom.yml +++ b/.github/workflows/sbom.yml @@ -122,24 +122,33 @@ jobs: RELEASE_TAG: ${{ steps.tag.outputs.tag }} run: node scripts/generate-sbom.mjs resolve-tag "$RELEASE_TAG" - - name: Check for an existing immutable asset + - 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 ! ASSET_NAMES=$(gh release view "$RELEASE_TAG" --json assets \ - --jq '.assets[].name'); then + 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 - if grep -Fxq "$SBOM_NAME" <<< "$ASSET_NAMES"; then + ASSET=$(jq -c --arg name "$SBOM_NAME" \ + '[.assets[] | select(.name == $name)][0] // empty' \ + <<< "$RELEASE_JSON") + if [ -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 "replace-asset-id=$(jq -r '.id' <<< "$ASSET")" >> "$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." - else - echo "exists=false" >> "$GITHUB_OUTPUT" fi # CI tests the generator and its historical fixtures in their owning tree. @@ -239,9 +248,23 @@ jobs: if: ${{ steps.component.outputs.matched == 'true' && steps.existing.outputs.exists == 'false' }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPAIR_DIGEST: ${{ steps.component.outputs.repair-digest }} + REPLACE_ASSET_ID: ${{ steps.existing.outputs.replace-asset-id }} RELEASE_TAG: ${{ steps.tag.outputs.tag }} SBOM_FILE: ${{ steps.generate.outputs.sbom-file }} - run: gh release upload "$RELEASE_TAG" "$SBOM_FILE" + run: | + if [ -n "$REPLACE_ASSET_ID" ]; then + CURRENT_DIGEST=$(gh api \ + "repos/$GITHUB_REPOSITORY/releases/assets/$REPLACE_ASSET_ID" \ + --jq '.digest // ""') + if [ "$CURRENT_DIGEST" != "$REPAIR_DIGEST" ]; then + 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/$REPLACE_ASSET_ID" + fi + gh release upload "$RELEASE_TAG" "$SBOM_FILE" - name: Report a skipped tag if: ${{ steps.component.outputs.matched != 'true' }} diff --git a/packages/docs/src/pages/docs/security/sbom.tsx b/packages/docs/src/pages/docs/security/sbom.tsx index de9a0c58c..6649d10e5 100644 --- a/packages/docs/src/pages/docs/security/sbom.tsx +++ b/packages/docs/src/pages/docs/security/sbom.tsx @@ -149,11 +149,6 @@ const LIMITS: Limit[] = [ 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.", }, - { - title: 'Google 3.3.0 history', - detail: - 'predates the published-POM reader and remains immutable. Use its published POM instead of that release asset for dependency matching.', - }, ]; function SecuritySbom() { diff --git a/scripts/generate-sbom.mjs b/scripts/generate-sbom.mjs index 04c5ceccd..64a42805c 100644 --- a/scripts/generate-sbom.mjs +++ b/scripts/generate-sbom.mjs @@ -52,6 +52,13 @@ 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. * @@ -225,7 +232,11 @@ export function sbomFileName(componentId, version) { return `${COMPONENTS[componentId].sbomName}-${version}.cdx.json`; } -/** Return the newest published release per component that lacks its SBOM. */ +export function inaccurateSbomDigestForTag(tag) { + return INACCURATE_SBOM_DIGESTS.get(tag) ?? ""; +} + +/** Return newest component releases with a missing or known-inaccurate SBOM. */ export function findMissingLatestSbomTags(releases) { const seen = new Set(); const missing = []; @@ -241,7 +252,9 @@ export function findMissingLatestSbomTags(releases) { seen.add(resolvedTag.componentId); const expected = sbomFileName(resolvedTag.componentId, resolvedTag.version); const assets = Array.isArray(release.assets) ? release.assets : []; - if (!assets.some((asset) => asset?.name === expected)) { + const asset = assets.find((entry) => entry?.name === expected); + const inaccurateDigest = inaccurateSbomDigestForTag(release.tag_name); + if (!asset || (inaccurateDigest && asset.digest === inaccurateDigest)) { missing.push(release.tag_name); } } @@ -861,7 +874,7 @@ async function main() { const tag = process.argv[3]; const resolved = componentFromTag(tag); const line = resolved - ? `component=${resolved.componentId}\nversion=${resolved.version}\nsbom-name=${sbomFileName(resolved.componentId, resolved.version)}\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) { diff --git a/scripts/generate-sbom.test.mjs b/scripts/generate-sbom.test.mjs index b0ecc26dc..bc84b8eb9 100644 --- a/scripts/generate-sbom.test.mjs +++ b/scripts/generate-sbom.test.mjs @@ -18,6 +18,7 @@ import { componentFromTag, findMissingLatestSbomTags, generateSbom, + inaccurateSbomDigestForTag, listComponentIds, normalizeLicense, PUBLISHED_METADATA_UNAVAILABLE_EXIT_CODE, @@ -243,6 +244,31 @@ test("backfill selects only the newest missing SBOM per component", () => { assert.deepEqual(findMissingLatestSbomTags(releases), ["kmp-iap-3.3.0"]); }); +test("backfill repairs only the exact known inaccurate SBOM", () => { + const tag = "google-3.3.0"; + const name = "openiap-google-3.3.0.cdx.json"; + 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)}` }], + }, + ]), + [], + ); +}); + test("every GitHub release workflow dispatches the SBOM workflow", () => { const workflowDir = resolve(repoRoot, ".github/workflows"); const workflows = readdirSync(workflowDir) @@ -280,7 +306,8 @@ test("SBOM publication waits for registry propagation and repairs daily", () => 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, /ASSET_NAMES=\$\(gh release view/u); + assert.match(source, /replace-asset-id=/u); + assert.match(source, /CURRENT_DIGEST.*!=.*REPAIR_DIGEST/u); assert.match(source, /persist-credentials: false/u); assert.match(source, /sleep 120/u); }); diff --git a/security/SBOM.md b/security/SBOM.md index 2e7f2157a..53a049ba3 100644 --- a/security/SBOM.md +++ b/security/SBOM.md @@ -258,8 +258,10 @@ release commit, and recorded generator commit match its inputs, and that no local filesystem path leaked into the document. Any mismatch fails the run. Tags that do not belong to a component are skipped with a notice rather than -failing. A duplicate dispatch preserves an existing SBOM rather than -overwriting an immutable release asset. +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; no other existing +asset is overwritten. ## Storage location @@ -313,10 +315,9 @@ 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 intentionally reproduces the immutable Google 3.3.0 asset. -That asset predates the published-POM reader and contains the older -source-manifest inventory; use the published POM, not that historical SBOM, for -Google 3.3.0 dependency matching. +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=google-3.3.0 @@ -357,8 +358,9 @@ git worktree remove --force "$SBOM_REPRO_DIR" - Every current release workflow produces an SBOM 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. + belong to. The only migration exception is the exact known-inaccurate Google + 3.3.0 digest, which the repair job replaces once. No other existing asset is + overwritten. - A release that predates this system and has no SBOM asset can be described with `workflow_dispatch`. The workflow reads released inputs, records the exact default-branch generator commit, and refuses to overwrite an existing From 19578d938bb533c3f212f4a61fe22442c0ddf9e1 Mon Sep 17 00:00:00 2001 From: Hyo Date: Thu, 13 Aug 2026 20:38:01 +0900 Subject: [PATCH 5/7] docs(security): link sbom policy --- security/SBOM.md | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/security/SBOM.md b/security/SBOM.md index 53a049ba3..f6212a703 100644 --- a/security/SBOM.md +++ b/security/SBOM.md @@ -357,10 +357,9 @@ git worktree remove --force "$SBOM_REPRO_DIR" ## Update policy - Every current release workflow produces an SBOM automatically. -- SBOMs are **immutable once published**, exactly like the release tag they - belong to. The only migration exception is the exact known-inaccurate Google - 3.3.0 digest, which the repair job replaces once. No other existing asset is - overwritten. +- 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 released inputs, records the exact default-branch generator commit, and refuses to overwrite an existing From daba0460c1b396915ed8283638116b9a1d64d96b Mon Sep 17 00:00:00 2001 From: Hyo Date: Thu, 13 Aug 2026 21:24:03 +0900 Subject: [PATCH 6/7] fix(security): close sbom recovery gaps --- .github/workflows/sbom.yml | 106 ++++++- SECURITY.md | 6 +- .../src/pages/docs/security/compliance.tsx | 9 +- .../docs/src/pages/docs/security/overview.tsx | 12 +- scripts/generate-sbom.mjs | 19 +- scripts/generate-sbom.test.mjs | 151 +++++++++- scripts/sbom-dependencies.mjs | 283 +++++++++++++++++- security/CRA.md | 6 +- security/README.md | 21 +- security/SBOM.md | 16 +- security/openchain.md | 2 +- security/vex/README.md | 9 +- 12 files changed, 583 insertions(+), 57 deletions(-) diff --git a/.github/workflows/sbom.yml b/.github/workflows/sbom.yml index ec1cd361d..e25e4cd09 100644 --- a/.github/workflows/sbom.yml +++ b/.github/workflows/sbom.yml @@ -139,12 +139,17 @@ jobs: ASSET=$(jq -c --arg name "$SBOM_NAME" \ '[.assets[] | select(.name == $name)][0] // empty' \ <<< "$RELEASE_JSON") - if [ -z "$ASSET" ]; then + 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 "replace-asset-id=$(jq -r '.id' <<< "$ASSET")" >> "$GITHUB_OUTPUT" echo "::notice::$SBOM_NAME matches a known inaccurate legacy digest and will be replaced once." else echo "exists=true" >> "$GITHUB_OUTPUT" @@ -249,22 +254,99 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} REPAIR_DIGEST: ${{ steps.component.outputs.repair-digest }} - REPLACE_ASSET_ID: ${{ steps.existing.outputs.replace-asset-id }} RELEASE_TAG: ${{ steps.tag.outputs.tag }} SBOM_FILE: ${{ steps.generate.outputs.sbom-file }} + SBOM_NAME: ${{ steps.component.outputs.sbom-name }} run: | - if [ -n "$REPLACE_ASSET_ID" ]; then - CURRENT_DIGEST=$(gh api \ - "repos/$GITHUB_REPOSITORY/releases/assets/$REPLACE_ASSET_ID" \ - --jq '.digest // ""') - if [ "$CURRENT_DIGEST" != "$REPAIR_DIGEST" ]; then - echo "::error::The legacy SBOM asset changed during repair; refusing to replace it." - exit 1 + 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/$REPLACE_ASSET_ID" + "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 release upload "$RELEASE_TAG" "$SBOM_FILE" + 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 c9e6a288b..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. diff --git a/packages/docs/src/pages/docs/security/compliance.tsx b/packages/docs/src/pages/docs/security/compliance.tsx index 3b37852eb..f53e3e013 100644 --- a/packages/docs/src/pages/docs/security/compliance.tsx +++ b/packages/docs/src/pages/docs/security/compliance.tsx @@ -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, competency - and assessment records, 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.

    @@ -163,7 +164,8 @@ function SecurityOverview() { on StoreKit, which ships with the OS. The native Android, Kotlin Multiplatform, .NET MAUI, and Flutter SDKs do depend on platform libraries (Play Billing, AndroidX, Kotlin coroutines, and so on); - those are enumerated in each release's SBOM. + their current release SBOMs enumerate the published direct dependency + contracts.

    A dependency that does not exist cannot be vulnerable. For the @@ -246,7 +248,9 @@ function SecurityOverview() { 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 SBOM endpoint currently returns HTTP diff --git a/scripts/generate-sbom.mjs b/scripts/generate-sbom.mjs index 64a42805c..42a72bc00 100644 --- a/scripts/generate-sbom.mjs +++ b/scripts/generate-sbom.mjs @@ -236,7 +236,7 @@ export function inaccurateSbomDigestForTag(tag) { return INACCURATE_SBOM_DIGESTS.get(tag) ?? ""; } -/** Return newest component releases with a missing or known-inaccurate SBOM. */ +/** Return newest missing releases plus every known-inaccurate legacy SBOM. */ export function findMissingLatestSbomTags(releases) { const seen = new Set(); const missing = []; @@ -248,15 +248,26 @@ export function findMissingLatestSbomTags(releases) { ); for (const release of newestFirst) { const resolvedTag = componentFromTag(release.tag_name); - if (!resolvedTag || seen.has(resolvedTag.componentId)) continue; - seen.add(resolvedTag.componentId); + 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); - if (!asset || (inaccurateDigest && asset.digest === inaccurateDigest)) { + + // 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; } diff --git a/scripts/generate-sbom.test.mjs b/scripts/generate-sbom.test.mjs index bc84b8eb9..d41797290 100644 --- a/scripts/generate-sbom.test.mjs +++ b/scripts/generate-sbom.test.mjs @@ -257,6 +257,19 @@ test("backfill repairs only the exact known inaccurate SBOM", () => { ]), [tag], ); + assert.deepEqual( + findMissingLatestSbomTags([ + { + tag_name: tag, + published_at, + assets: [ + { name, digest: `sha256:${"0".repeat(64)}` }, + { name: `${name}.replacement` }, + ], + }, + ]), + [tag], + ); assert.deepEqual( findMissingLatestSbomTags([ { @@ -267,6 +280,17 @@ test("backfill repairs only the exact known inaccurate SBOM", () => { ]), [], ); + assert.deepEqual( + findMissingLatestSbomTags([ + { + tag_name: "google-3.4.0", + published_at: "2026-08-12T00:00:00Z", + assets: [{ name: "openiap-google-3.4.0.cdx.json" }], + }, + { tag_name: tag, published_at, assets: [{ name, digest: inaccurate }] }, + ]), + [tag], + ); }); test("every GitHub release workflow dispatches the SBOM workflow", () => { @@ -306,8 +330,25 @@ test("SBOM publication waits for registry propagation and repairs daily", () => 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, /replace-asset-id=/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); }); @@ -503,7 +544,7 @@ test("Gradle declarations are classified or fail closed", () => { ); }); -test("an unmodelled Gradle coordinate fails instead of silently vanishing", () => { +test("an unmodelled Gradle coordinate fails instead of silently vanishing", (t) => { assert.throws( () => parseMavenCoordinate("com.example:lib:$unknownVersion"), /Unresolved Maven coordinate/u, @@ -512,6 +553,112 @@ test("an unmodelled Gradle coordinate fails instead of silently vanishing", () = () => parseMavenCoordinate("com.example:lib:1.0.0:sources"), /Unsupported Maven coordinate/u, ); + + 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"), + '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 { 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"], + ); }); test("published metadata matches the consumer-visible artifacts", async () => { diff --git a/scripts/sbom-dependencies.mjs b/scripts/sbom-dependencies.mjs index 6e294bd23..33765c21c 100644 --- a/scripts/sbom-dependencies.mjs +++ b/scripts/sbom-dependencies.mjs @@ -41,6 +41,238 @@ function encodePurlVersion(version) { return encodeURIComponent(version).replaceAll("%3A", ":"); } +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; + } + 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"; + } + } + + if (state === "block-comment" || state === "raw-string") { + throw new Error(`Unterminated Gradle ${state.replace("-", " ")}`); + } + return { + commentFree: commentFree.join(""), + structural: structural.join(""), + }; +} + +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; + } + } + 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) continue; + 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 filterGradleDependencyBlock(source, manifest) { + const { structural } = scanGradleSource(source); + const filtered = [...source]; + const visible = [true]; + const previousCodeIndex = (from) => { + let cursor = from; + while (cursor >= 0 && /\s/u.test(structural[cursor])) cursor -= 1; + return cursor; + }; + const identifierBefore = (from) => { + const end = previousCodeIndex(from) + 1; + let start = end; + while (start > 0 && /[A-Za-z0-9_]/u.test(structural[start - 1])) { + start -= 1; + } + return structural.slice(start, end); + }; + const callBefore = (close) => { + let depth = 1; + let cursor = close - 1; + for (; cursor >= 0 && depth > 0; cursor -= 1) { + if (structural[cursor] === ")") depth += 1; + if (structural[cursor] === "(") depth -= 1; + } + if (depth !== 0) throw new Error("Unbalanced Gradle call parentheses"); + return identifierBefore(cursor); + }; + + for (let index = 0; index < structural.length; index += 1) { + if (structural[index] === "{") { + const parentVisible = visible.at(-1); + let childVisible = false; + if (parentVisible) { + const previous = previousCodeIndex(index - 1); + const owner = + structural[previous] === ")" + ? callBefore(previous) + : identifierBefore(previous); + if (["if", "for", "when", "while", "else"].includes(owner)) { + childVisible = true; + } else if (owner !== "constraints" && structural[previous] !== ")") { + 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] = " "; + } + } + if (visible.length !== 1) throw new Error("Unbalanced Gradle block braces"); + return filtered.join(""); +} + +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" || @@ -162,18 +394,29 @@ function isRuntimeGradleConfiguration(configuration) { function extractGradle(root, { manifest, externalLocals }) { const source = readText(root, manifest); - const locals = readGradleLocals(source); + const { commentFree, dependencies } = gradleDependencySource( + source, + manifest, + ); + const locals = readGradleLocals(commentFree); for (const [name, value] of readExternalLocals(root, externalLocals)) { locals.set(name, value); } - if (/\b(?:api|implementation|runtimeOnly)\s*\(\s*libs\./u.test(source)) { + 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( @@ -182,17 +425,49 @@ function extractGradle(root, { manifest, externalLocals }) { found.set(parsed.purl, parsed); }; - 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]); } - 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]); } + 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( + `Local :openiap dependency in ${manifest} lacks its published fallback`, + ); + } + return [...found.values()].sort((left, right) => left.purl.localeCompare(right.purl), ); diff --git a/security/CRA.md b/security/CRA.md index 22f99256b..3d5ec7982 100644 --- a/security/CRA.md +++ b/security/CRA.md @@ -137,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 73ad0bb76..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. @@ -55,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` | @@ -82,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 diff --git a/security/SBOM.md b/security/SBOM.md index f6212a703..ae630953e 100644 --- a/security/SBOM.md +++ b/security/SBOM.md @@ -260,8 +260,9 @@ local filesystem path leaked into the document. Any mismatch fails the run. Tags that do not belong to a component are skipped with a notice rather than failing. A duplicate dispatch preserves an existing SBOM. The repair scan recognizes the exact digest of the inaccurate Google 3.3.0 asset produced by the -retired source-manifest reader and replaces that asset once; no other existing -asset is overwritten. +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 @@ -375,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 @@ -391,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 df6a7a45b..b552371ea 100644 --- a/security/openchain.md +++ b/security/openchain.md @@ -33,7 +33,7 @@ OpenChain is a Linux Foundation project. | 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 | diff --git a/security/vex/README.md b/security/vex/README.md index bda6dad80..dcf322f16 100644 --- a/security/vex/README.md +++ b/security/vex/README.md @@ -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 From 317dd71fe00841d7cdd7e45afd86e1c362d0a57d Mon Sep 17 00:00:00 2001 From: Hyo Date: Thu, 13 Aug 2026 21:35:56 +0900 Subject: [PATCH 7/7] fix(security): fail closed on gradle scopes --- scripts/generate-sbom.test.mjs | 17 +++++++ scripts/sbom-dependencies.mjs | 88 ++++++++++++++++++++++------------ 2 files changed, 75 insertions(+), 30 deletions(-) diff --git a/scripts/generate-sbom.test.mjs b/scripts/generate-sbom.test.mjs index d41797290..a62c52625 100644 --- a/scripts/generate-sbom.test.mjs +++ b/scripts/generate-sbom.test.mjs @@ -638,6 +638,15 @@ test("an unmodelled Gradle coordinate fails instead of silently vanishing", (t) ), ["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', @@ -648,6 +657,14 @@ test("an unmodelled Gradle coordinate fails instead of silently vanishing", (t) ), ["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' + diff --git a/scripts/sbom-dependencies.mjs b/scripts/sbom-dependencies.mjs index 33765c21c..b20386cf5 100644 --- a/scripts/sbom-dependencies.mjs +++ b/scripts/sbom-dependencies.mjs @@ -138,6 +138,46 @@ function scanGradleSource(source) { }; } +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); @@ -160,7 +200,11 @@ function gradleDependencySource(source, manifest) { match = pattern.exec(structural) ) { const open = match.index + match[0].lastIndexOf("{"); - if (braceDepths[match.index] !== 0) continue; + 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) { @@ -188,43 +232,27 @@ function filterGradleDependencyBlock(source, manifest) { const { structural } = scanGradleSource(source); const filtered = [...source]; const visible = [true]; - const previousCodeIndex = (from) => { - let cursor = from; - while (cursor >= 0 && /\s/u.test(structural[cursor])) cursor -= 1; - return cursor; - }; - const identifierBefore = (from) => { - const end = previousCodeIndex(from) + 1; - let start = end; - while (start > 0 && /[A-Za-z0-9_]/u.test(structural[start - 1])) { - start -= 1; - } - return structural.slice(start, end); - }; - const callBefore = (close) => { - let depth = 1; - let cursor = close - 1; - for (; cursor >= 0 && depth > 0; cursor -= 1) { - if (structural[cursor] === ")") depth += 1; - if (structural[cursor] === "(") depth -= 1; - } - if (depth !== 0) throw new Error("Unbalanced Gradle call parentheses"); - return identifierBefore(cursor); - }; for (let index = 0; index < structural.length; index += 1) { if (structural[index] === "{") { const parentVisible = visible.at(-1); let childVisible = false; if (parentVisible) { - const previous = previousCodeIndex(index - 1); - const owner = - structural[previous] === ")" - ? callBefore(previous) - : identifierBefore(previous); + 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" && structural[previous] !== ")") { + } 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}`); } }