diff --git a/.claude/commands/audit-security.md b/.claude/commands/audit-security.md index 433ac50ac..fb710d69b 100644 --- a/.claude/commands/audit-security.md +++ b/.claude/commands/audit-security.md @@ -25,11 +25,14 @@ component that can be released but has no SBOM definition is a release that ships without an inventory. ```bash -node --test scripts/generate-sbom.test.mjs +node --test scripts/generate-sbom.test.mjs scripts/audit-security.test.mjs +SECURITY_AUDIT_ROOT=$(mktemp -d) +export SECURITY_AUDIT_ROOT for c in $(node -e 'import("./scripts/generate-sbom.mjs").then(m=>console.log(m.listComponentIds().join(" ")))'); do printf "%-14s " "$c" - node scripts/generate-sbom.mjs "$c" --output-dir /tmp/sbom-audit || echo "FAILED" + node scripts/generate-sbom.mjs "$c" \ + --output-dir "$SECURITY_AUDIT_ROOT/core-a" || echo "FAILED" done ``` @@ -39,7 +42,7 @@ declaration shape the reader does not model — fix the reader, never silence it ## 2. Schema validity ```bash -for f in /tmp/sbom-audit/*.cdx.json; do +for f in "$SECURITY_AUDIT_ROOT"/core-a/*.cdx.json; do cyclonedx validate --input-file "$f" --input-format json \ --input-version v1_6 --fail-on-errors done @@ -54,9 +57,14 @@ are the baseline OpenSSF recommends measuring against. Check author, timestamp, and per-component name, version, purl, supplier, and dependency relationships: ```bash +for c in $(node -e 'import("./scripts/generate-sbom.mjs").then(m=>console.log(m.listComponentIds().join(" ")))'); do + node scripts/generate-sbom.mjs "$c" --with-licenses \ + --output-dir "$SECURITY_AUDIT_ROOT/enriched" +done + node -e ' const fs = require("fs"); -const dir = "/tmp/sbom-audit"; +const dir = `${process.env.SECURITY_AUDIT_ROOT}/enriched`; let tot = 0, sup = 0, lic = 0, purl = 0, auth = 0, files = 0; for (const f of fs.readdirSync(dir)) { const j = JSON.parse(fs.readFileSync(`${dir}/${f}`, "utf8")); @@ -77,7 +85,8 @@ console.log(`component license: ${lic}/${tot}`); ``` Regenerate with `--with-licenses` when auditing supplier and license coverage; -without it those fields are intentionally absent so local runs stay offline. +without it those fields are intentionally absent. Maven and NuGet inventories +still read their published dependency descriptors. Known structural gaps, which are **not** findings: pub.dev exposes neither license nor supplier in package metadata, and some NuGet packages carry only a @@ -85,7 +94,7 @@ non-SPDX license URL. Optionally score the result with [`sbomqs`](https://github.com/interlynk-io/sbomqs): -`sbomqs score /tmp/sbom-audit/*.cdx.json`. +`sbomqs score "$SECURITY_AUDIT_ROOT"/core-a/*.cdx.json`. ## 4. No leaked paths or secrets @@ -93,7 +102,7 @@ A published SBOM is a public document about a private filesystem. ```bash grep -rlE '/Users/|/home/[a-z]|/tmp/|ghp_|npm_[A-Za-z0-9]|BEGIN [A-Z ]*PRIVATE KEY' \ - /tmp/sbom-audit/ && echo "LEAK" || echo "clean" + "$SECURITY_AUDIT_ROOT/core-a" && echo "LEAK" || echo "clean" ``` ## 5. Core determinism @@ -103,8 +112,10 @@ generator commit, and resolver input. Do not pass `--with-licenses` here: live registry license and supplier metadata is point-in-time enrichment. ```bash -node scripts/generate-sbom.mjs google --output-dir /tmp/sbom-audit-2 -diff /tmp/sbom-audit/openiap-google-*.cdx.json /tmp/sbom-audit-2/openiap-google-*.cdx.json +node scripts/generate-sbom.mjs google \ + --output-dir "$SECURITY_AUDIT_ROOT/core-b" +diff "$SECURITY_AUDIT_ROOT"/core-a/openiap-google-*.cdx.json \ + "$SECURITY_AUDIT_ROOT"/core-b/openiap-google-*.cdx.json ``` ## 6. Workflow permissions and injection @@ -112,13 +123,20 @@ diff /tmp/sbom-audit/openiap-google-*.cdx.json /tmp/sbom-audit-2/openiap-google- Least privilege, and no untrusted value interpolated into a shell command: ```bash -# Any ${{ }} inside a run: block is a potential injection point -for f in .github/workflows/*.yml; do - awk '/^\s+run:/{r=1} /^\s+- name:|^\s+uses:/{r=0} r && /\$\{\{/ {print FILENAME": "$0}' "$f" -done +# Any ${{ }} inside a run: block is a potential injection point. The parser has +# fault tests, so an empty result cannot come from unsupported awk syntax. +node scripts/audit-security.mjs workflows \ + $(rg --files .github/workflows -g '*.yml') # Workflows that write must say so explicitly grep -L "^permissions:" .github/workflows/*.yml + +# Mutable action references in privileged workflow code +rg -n 'uses:\s+[^#]+@(v[0-9]+|main|master)$' .github/workflows + +# The repository dependency graph endpoint is currently unavailable (HTTP 404) +gh api repos/hyodotdev/openiap/dependency-graph/sbom || \ + echo "Dependency graph SBOM endpoint unavailable" ``` Pass values through `env:` instead of interpolating them. OpenSSF Scorecard's @@ -147,13 +165,9 @@ import("./scripts/generate-sbom.mjs").then((m) => { ' # External references must resolve -grep -rhoE "https?://[^)\" ]+" security/*.md security/vex/*.md \ - packages/docs/src/pages/docs/security/*.tsx | - sed 's/[.,)"]*$//' | sort -u | - while read -r u; do - code=$(curl -sS -o /dev/null -w "%{http_code}" -L --max-time 20 "$u") - [ "$code" = "200" ] || echo "$code $u" - done +node scripts/audit-security.mjs urls \ + $(rg --files security packages/docs/src/pages/docs/security \ + -g '*.md' -g '*.tsx') ``` Also check for **hardcoded counts** — "nine workflows", "43 of 47 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 66948a37b..35af1f76d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,6 +35,7 @@ jobs: scripts/npm-publish-authorization.test.mjs scripts/verify-npm-release-provenance.test.mjs scripts/generate-sbom.test.mjs + scripts/audit-security.test.mjs - name: Test Gradle network retry helper run: node --test scripts/ci/retry-gradle.test.mjs diff --git a/.github/workflows/release-apple.yml b/.github/workflows/release-apple.yml index da2e53957..1e436bac5 100644 --- a/.github/workflows/release-apple.yml +++ b/.github/workflows/release-apple.yml @@ -82,6 +82,9 @@ jobs: release: needs: [validate-ios] + permissions: + actions: write + contents: write runs-on: macos-15 steps: @@ -460,3 +463,10 @@ jobs: $PRERELEASE_FLAG \ --notes-file /tmp/release-notes.md fi + + - name: Dispatch SBOM + if: inputs.publish_spm == true + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_TAG: ${{ steps.version.outputs.version }} + run: gh workflow run sbom.yml --ref main -f tag="$RELEASE_TAG" diff --git a/.github/workflows/release-conformance.yml b/.github/workflows/release-conformance.yml index 89e6cac19..d86404653 100644 --- a/.github/workflows/release-conformance.yml +++ b/.github/workflows/release-conformance.yml @@ -97,6 +97,7 @@ jobs: name: Bump, tag, and release needs: [validate] permissions: + actions: write contents: write runs-on: ubuntu-latest defaults: @@ -311,6 +312,12 @@ jobs: prerelease: ${{ steps.bump.outputs.is_prerelease }} body_path: /tmp/release-notes.md + - name: Dispatch SBOM + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_TAG: openiap-conformance-${{ steps.bump.outputs.version }} + run: gh workflow run sbom.yml --ref main -f tag="$RELEASE_TAG" + # npm provenance reads the immutable workflow event GITHUB_SHA. Dispatch # this same trusted-publisher workflow on the tag so the event SHA, npm # package gitHead, and release tag all identify the same source commit. diff --git a/.github/workflows/release-expo.yml b/.github/workflows/release-expo.yml index 40f4d0d1f..06587cdd8 100644 --- a/.github/workflows/release-expo.yml +++ b/.github/workflows/release-expo.yml @@ -161,6 +161,7 @@ jobs: deploy: needs: [validate-android, validate-ios] permissions: + actions: write contents: write runs-on: ubuntu-latest defaults: @@ -429,6 +430,12 @@ jobs: prerelease: ${{ steps.bump.outputs.is_prerelease }} body_path: /tmp/release-notes.md + - name: Dispatch SBOM + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_TAG: expo-iap-${{ steps.bump.outputs.version }} + run: gh workflow run sbom.yml --ref main -f tag="$RELEASE_TAG" + # npm provenance reads the immutable workflow event GITHUB_SHA. Dispatch # this same trusted-publisher workflow on the tag so the event SHA, npm # package gitHead, and release tag all identify the same source commit. diff --git a/.github/workflows/release-flutter.yml b/.github/workflows/release-flutter.yml index 2ea210343..01ded57b8 100644 --- a/.github/workflows/release-flutter.yml +++ b/.github/workflows/release-flutter.yml @@ -141,6 +141,7 @@ jobs: deploy: needs: [validate-android, validate-ios, validate-apple-swiftpm] permissions: + actions: write contents: write runs-on: ubuntu-latest defaults: @@ -547,3 +548,9 @@ jobs: draft: false prerelease: ${{ steps.bump.outputs.is_prerelease }} body_path: /tmp/release-notes.md + + - name: Dispatch SBOM + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_TAG: flutter-iap-${{ steps.bump.outputs.version }} + run: gh workflow run sbom.yml --ref main -f tag="$RELEASE_TAG" diff --git a/.github/workflows/release-godot.yml b/.github/workflows/release-godot.yml index 0ececa77e..684dade26 100644 --- a/.github/workflows/release-godot.yml +++ b/.github/workflows/release-godot.yml @@ -119,6 +119,9 @@ jobs: deploy: needs: [validate-android, validate-ios] + permissions: + actions: write + contents: write runs-on: macos-15 defaults: run: @@ -524,3 +527,9 @@ jobs: draft: false prerelease: ${{ steps.version.outputs.is_prerelease }} body_path: /tmp/release-notes.md + + - name: Dispatch SBOM + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_TAG: godot-iap-${{ steps.version.outputs.VERSION }} + run: gh workflow run sbom.yml --ref main -f tag="$RELEASE_TAG" diff --git a/.github/workflows/release-google.yml b/.github/workflows/release-google.yml index 9c71ce8fc..ff6de90a7 100644 --- a/.github/workflows/release-google.yml +++ b/.github/workflows/release-google.yml @@ -79,6 +79,9 @@ jobs: release: needs: [validate-android] + permissions: + actions: write + contents: write runs-on: ubuntu-latest env: # Central Portal can take 10-30 minutes to finish publishing. @@ -336,11 +339,11 @@ jobs: ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.SIGNING_PASSWORD }} run: | if [ -z "$ORG_GRADLE_PROJECT_mavenCentralUsername" ]; then - echo "⚠️ Maven Central credentials not set. Skipping publish." - else - ./gradlew :openiap:publishAndReleaseToMavenCentral --no-daemon --no-parallel --stacktrace - echo "✅ Published openiap-google-horizon (Horizon flavor) to Maven Central" + echo "::error::Maven Central credentials are required to publish the Horizon flavor." + exit 1 fi + ./gradlew :openiap:publishAndReleaseToMavenCentral --no-daemon --no-parallel --stacktrace + echo "✅ Published openiap-google-horizon (Horizon flavor) to Maven Central" - name: Check if Fire OS flavor already published id: check_amazon @@ -378,11 +381,11 @@ jobs: ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.SIGNING_PASSWORD }} run: | if [ -z "$ORG_GRADLE_PROJECT_mavenCentralUsername" ]; then - echo "⚠️ Maven Central credentials not set. Skipping publish." - else - ./gradlew :openiap:publishAndReleaseToMavenCentral --no-daemon --no-parallel --stacktrace - echo "✅ Published openiap-google-amazon (Fire OS flavor) to Maven Central" + echo "::error::Maven Central credentials are required to publish the Fire OS flavor." + exit 1 fi + ./gradlew :openiap:publishAndReleaseToMavenCentral --no-daemon --no-parallel --stacktrace + echo "✅ Published openiap-google-amazon (Fire OS flavor) to Maven Central" - name: Check if Play flavor already published id: check_play @@ -420,11 +423,11 @@ jobs: ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.SIGNING_PASSWORD }} run: | if [ -z "$ORG_GRADLE_PROJECT_mavenCentralUsername" ]; then - echo "⚠️ Maven Central credentials not set. Skipping publish." - else - ./gradlew :openiap:publishAndReleaseToMavenCentral --no-daemon --no-parallel --stacktrace - echo "✅ Published openiap-google (Play flavor) to Maven Central" + echo "::error::Maven Central credentials are required to publish the Play flavor." + exit 1 fi + ./gradlew :openiap:publishAndReleaseToMavenCentral --no-daemon --no-parallel --stacktrace + echo "✅ Published openiap-google (Play flavor) to Maven Central" - name: Build release artifacts working-directory: packages/google @@ -602,3 +605,9 @@ jobs: $PRERELEASE_FLAG \ --notes-file /tmp/release-notes.md fi + + - name: Dispatch SBOM + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_TAG: google-${{ steps.version.outputs.version }} + run: gh workflow run sbom.yml --ref main -f tag="$RELEASE_TAG" diff --git a/.github/workflows/release-kmp.yml b/.github/workflows/release-kmp.yml index 3ba52cc67..2918fa5c1 100644 --- a/.github/workflows/release-kmp.yml +++ b/.github/workflows/release-kmp.yml @@ -118,6 +118,9 @@ jobs: publish: needs: [validate-android, validate-ios] + permissions: + actions: write + contents: write runs-on: macos-15 defaults: run: @@ -423,3 +426,9 @@ jobs: prerelease: ${{ steps.version.outputs.is_prerelease }} body_path: /tmp/release-notes.md files: libraries/kmp-iap/release-artifacts.zip + + - name: Dispatch SBOM + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_TAG: kmp-iap-${{ steps.version.outputs.VERSION }} + run: gh workflow run sbom.yml --ref main -f tag="$RELEASE_TAG" diff --git a/.github/workflows/release-maui.yml b/.github/workflows/release-maui.yml index e36e930fa..73d83d0e9 100644 --- a/.github/workflows/release-maui.yml +++ b/.github/workflows/release-maui.yml @@ -125,6 +125,9 @@ jobs: publish: needs: [validate, validate-multitarget] + permissions: + actions: write + contents: write # Rebuild the exact native sidecar packed into NuGet with an App Store # submission toolchain. Xcode 27 remains validation-only until Apple # accepts its SDK for App Store uploads. @@ -461,3 +464,9 @@ jobs: prerelease: ${{ steps.version.outputs.is_prerelease }} body_path: /tmp/release-notes.md files: ./nupkgs/*.nupkg + + - name: Dispatch SBOM + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_TAG: maui-iap-${{ steps.version.outputs.version }} + run: gh workflow run sbom.yml --ref main -f tag="$RELEASE_TAG" diff --git a/.github/workflows/release-react-native.yml b/.github/workflows/release-react-native.yml index 1c68ebf99..63ec72186 100644 --- a/.github/workflows/release-react-native.yml +++ b/.github/workflows/release-react-native.yml @@ -157,6 +157,7 @@ jobs: deploy: needs: [validate-android, validate-ios] permissions: + actions: write contents: write runs-on: ubuntu-latest defaults: @@ -430,6 +431,12 @@ jobs: prerelease: ${{ steps.bump.outputs.is_prerelease }} body_path: /tmp/release-notes.md + - name: Dispatch SBOM + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_TAG: react-native-iap-${{ steps.bump.outputs.version }} + run: gh workflow run sbom.yml --ref main -f tag="$RELEASE_TAG" + # npm provenance reads the immutable workflow event GITHUB_SHA. Dispatch # this same trusted-publisher workflow on the tag so the event SHA, npm # package gitHead, and release tag all identify the same source commit. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 77fccee51..961838c54 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,12 +15,12 @@ concurrency: group: ${{ github.workflow }} cancel-in-progress: false -permissions: - contents: write - jobs: release: runs-on: ubuntu-latest + permissions: + actions: write + contents: write steps: - name: Checkout repository uses: actions/checkout@v7 @@ -76,3 +76,9 @@ jobs: ### Documentation - [Documentation](https://openiap.dev) - [GitHub Repository](https://github.com/hyodotdev/openiap) + + - name: Dispatch SBOM + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_TAG: docs-${{ steps.version.outputs.version }} + run: gh workflow run sbom.yml --ref main -f tag="$RELEASE_TAG" diff --git a/.github/workflows/sbom.yml b/.github/workflows/sbom.yml index d05bcaaa0..e25e4cd09 100644 --- a/.github/workflows/sbom.yml +++ b/.github/workflows/sbom.yml @@ -4,14 +4,21 @@ name: "Security: SBOM" # belongs to, attaches it to that release, and attests that this workflow # produced it. # -# This runs *after* a release is published rather than inside each release -# workflow: the existing release workflows stay untouched, and every component — -# including any added later — is covered by the same code path. The release tag -# is the input, so the SBOM can only ever describe the commit that shipped. +# Release workflows dispatch this workflow explicitly because releases created +# with GITHUB_TOKEN do not emit another workflow run. A scheduled scan repairs +# any missed dispatch for the newest release of each component. on: release: types: [published] + push: + branches: [main] + paths: + - ".github/workflows/sbom.yml" + - "scripts/generate-sbom.mjs" + - "scripts/sbom-dependencies.mjs" + schedule: + - cron: "23 3 * * *" workflow_dispatch: inputs: tag: @@ -23,14 +30,44 @@ concurrency: group: sbom-${{ github.event.release.tag_name || inputs.tag }} cancel-in-progress: false -# Read-only by default; the publish job widens this to exactly what the -# attestation and upload steps require. permissions: contents: read jobs: + backfill: + name: Dispatch missing current SBOMs + if: github.event_name == 'push' || github.event_name == 'schedule' + runs-on: ubuntu-latest + permissions: + actions: write + contents: read + steps: + - name: Checkout default branch + uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Find and dispatch missing SBOMs + env: + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh api --paginate --slurp \ + "repos/$GITHUB_REPOSITORY/releases?per_page=100" \ + > "$RUNNER_TEMP/releases.json" + node scripts/generate-sbom.mjs missing-release-tags \ + "$RUNNER_TEMP/releases.json" > "$RUNNER_TEMP/missing-tags.txt" + + while IFS= read -r RELEASE_TAG; do + [ -n "$RELEASE_TAG" ] || continue + echo "Dispatching SBOM for $RELEASE_TAG" + gh workflow run sbom.yml --ref "$DEFAULT_BRANCH" \ + -f tag="$RELEASE_TAG" + done < "$RUNNER_TEMP/missing-tags.txt" + sbom: name: Generate and publish SBOM + if: github.event_name == 'release' || github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest permissions: contents: write # upload the SBOM as a release asset @@ -85,26 +122,78 @@ jobs: RELEASE_TAG: ${{ steps.tag.outputs.tag }} run: node scripts/generate-sbom.mjs resolve-tag "$RELEASE_TAG" + - name: Inspect the existing asset + id: existing + if: ${{ steps.component.outputs.matched == 'true' }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPAIR_DIGEST: ${{ steps.component.outputs.repair-digest }} + RELEASE_TAG: ${{ steps.tag.outputs.tag }} + SBOM_NAME: ${{ steps.component.outputs.sbom-name }} + run: | + if ! RELEASE_JSON=$(gh api \ + "repos/$GITHUB_REPOSITORY/releases/tags/$RELEASE_TAG"); then + echo "::error::Unable to inspect release assets for $RELEASE_TAG" + exit 1 + fi + ASSET=$(jq -c --arg name "$SBOM_NAME" \ + '[.assets[] | select(.name == $name)][0] // empty' \ + <<< "$RELEASE_JSON") + STAGED_ASSET=$(jq -c --arg name "$SBOM_NAME.replacement" \ + '[.assets[] | select(.name == $name)][0] // empty' \ + <<< "$RELEASE_JSON") + if [ -n "$REPAIR_DIGEST" ] && [ -n "$STAGED_ASSET" ]; then + echo "exists=false" >> "$GITHUB_OUTPUT" + echo "::notice::A staged legacy repair will be reconciled." + elif [ -z "$ASSET" ]; then + echo "exists=false" >> "$GITHUB_OUTPUT" + elif [ -n "$REPAIR_DIGEST" ] && \ + [ "$(jq -r '.digest // ""' <<< "$ASSET")" = "$REPAIR_DIGEST" ]; then + echo "exists=false" >> "$GITHUB_OUTPUT" + echo "::notice::$SBOM_NAME matches a known inaccurate legacy digest and will be replaced once." + else + echo "exists=true" >> "$GITHUB_OUTPUT" + echo "::notice::$SBOM_NAME is already attached; preserving it." + fi + # CI tests the generator and its historical fixtures in their owning tree. - name: Generate CycloneDX SBOM id: generate - if: ${{ steps.component.outputs.matched == 'true' }} - # `--with-licenses` resolves each dependency's declared license from its - # own registry. A registry outage degrades to a missing license field - # rather than failing the release. + if: ${{ steps.component.outputs.matched == 'true' && steps.existing.outputs.exists == 'false' }} + # License lookups degrade when unavailable; required POM/nuspec reads + # retry for the registry propagation window and then fail closed. env: GENERATOR_COMMIT: ${{ steps.generator.outputs.commit }} RELEASE_TAG: ${{ steps.tag.outputs.tag }} run: | - node scripts/generate-sbom.mjs --tag "$RELEASE_TAG" \ - --output-dir sbom \ - --commit "$(git rev-parse HEAD)" \ - --generator-commit "$GENERATOR_COMMIT" \ - --with-licenses + for attempt in {1..16}; do + if OUTPUT=$(node scripts/generate-sbom.mjs --tag "$RELEASE_TAG" \ + --output-dir sbom \ + --commit "$(git rev-parse HEAD)" \ + --generator-commit "$GENERATOR_COMMIT" \ + --with-licenses 2>&1); then + echo "$OUTPUT" + exit 0 + else + STATUS=$? + fi + + echo "$OUTPUT" + # generate-sbom reserves EX_TEMPFAIL (75) for registry metadata. + if [ "$STATUS" -ne 75 ]; then + exit "$STATUS" + fi + if [ "$attempt" -eq 16 ]; then + echo "::error::Published dependency metadata remained unavailable after the registry propagation retry window." + exit 1 + fi + echo "::notice::Registry metadata is still indexing; retrying in 120 seconds." + sleep 120 + done - name: Verify the SBOM describes this release - if: ${{ steps.component.outputs.matched == 'true' }} + if: ${{ steps.component.outputs.matched == 'true' && steps.existing.outputs.exists == 'false' }} env: SBOM_FILE: ${{ steps.generate.outputs.sbom-file }} EXPECTED_VERSION: ${{ steps.component.outputs.version }} @@ -155,18 +244,109 @@ jobs: echo "SBOM verified for $RELEASE_TAG at $ACTUAL_COMMIT" - name: Attest SBOM provenance - if: ${{ steps.component.outputs.matched == 'true' }} + if: ${{ steps.component.outputs.matched == 'true' && steps.existing.outputs.exists == 'false' }} uses: actions/attest-build-provenance@v4 with: subject-path: ${{ steps.generate.outputs.sbom-file }} - name: Attach SBOM to the release - if: ${{ steps.component.outputs.matched == 'true' }} + if: ${{ steps.component.outputs.matched == 'true' && steps.existing.outputs.exists == 'false' }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPAIR_DIGEST: ${{ steps.component.outputs.repair-digest }} RELEASE_TAG: ${{ steps.tag.outputs.tag }} SBOM_FILE: ${{ steps.generate.outputs.sbom-file }} - run: gh release upload "$RELEASE_TAG" "$SBOM_FILE" + SBOM_NAME: ${{ steps.component.outputs.sbom-name }} + run: | + if [ -z "$REPAIR_DIGEST" ]; then + gh release upload "$RELEASE_TAG" "$SBOM_FILE" + exit 0 + fi + + STAGED_NAME="$SBOM_NAME.replacement" + STAGED_FILE="$RUNNER_TEMP/$STAGED_NAME" + cp "$SBOM_FILE" "$STAGED_FILE" + RELEASE_JSON=$(gh api \ + "repos/$GITHUB_REPOSITORY/releases/tags/$RELEASE_TAG") + CANONICAL_ASSET=$(jq -c --arg name "$SBOM_NAME" \ + '[.assets[] | select(.name == $name)][0] // empty' \ + <<< "$RELEASE_JSON") + STAGED_ASSET=$(jq -c --arg name "$STAGED_NAME" \ + '[.assets[] | select(.name == $name)][0] // empty' \ + <<< "$RELEASE_JSON") + CANONICAL_ASSET_ID=$(jq -r '.id // ""' <<< "$CANONICAL_ASSET") + CANONICAL_DIGEST=$(jq -r '.digest // ""' <<< "$CANONICAL_ASSET") + STAGED_ASSET_ID=$(jq -r '.id' <<< "$STAGED_ASSET") + STAGED_DIGEST=$(jq -r '.digest // ""' <<< "$STAGED_ASSET") + LOCAL_DIGEST="sha256:$(sha256sum "$STAGED_FILE" | cut -d ' ' -f 1)" + + finalize_staged_asset() { + for _ in {1..5}; do + if gh api --method PATCH \ + "repos/$GITHUB_REPOSITORY/releases/assets/$STAGED_ASSET_ID" \ + -f name="$SBOM_NAME" >/dev/null; then + return 0 + fi + sleep 5 + done + echo "::error::The verified replacement remains attached as $STAGED_NAME but could not be renamed." + return 1 + } + + if [ -n "$CANONICAL_ASSET_ID" ] && [ "$CANONICAL_DIGEST" != "$REPAIR_DIGEST" ]; then + if [ -n "$STAGED_ASSET_ID" ]; then + gh api --method DELETE \ + "repos/$GITHUB_REPOSITORY/releases/assets/$STAGED_ASSET_ID" + fi + echo "::notice::$SBOM_NAME is already corrected; any staged repair was removed." + exit 0 + fi + + if [ -z "$CANONICAL_ASSET_ID" ]; then + if [ -n "$STAGED_ASSET_ID" ] && [ "$STAGED_DIGEST" = "$LOCAL_DIGEST" ]; then + finalize_staged_asset + else + if [ -n "$STAGED_ASSET_ID" ]; then + gh api --method DELETE \ + "repos/$GITHUB_REPOSITORY/releases/assets/$STAGED_ASSET_ID" + fi + gh release upload "$RELEASE_TAG" "$SBOM_FILE" + fi + exit 0 + fi + + if [ -n "$STAGED_ASSET_ID" ] && [ "$STAGED_DIGEST" != "$LOCAL_DIGEST" ]; then + gh api --method DELETE \ + "repos/$GITHUB_REPOSITORY/releases/assets/$STAGED_ASSET_ID" + STAGED_ASSET_ID="" + fi + if [ -z "$STAGED_ASSET_ID" ]; then + gh release upload "$RELEASE_TAG" "$STAGED_FILE" + RELEASE_JSON=$(gh api \ + "repos/$GITHUB_REPOSITORY/releases/tags/$RELEASE_TAG") + STAGED_ASSET=$(jq -c --arg name "$STAGED_NAME" \ + '[.assets[] | select(.name == $name)][0] // empty' \ + <<< "$RELEASE_JSON") + STAGED_ASSET_ID=$(jq -r '.id // ""' <<< "$STAGED_ASSET") + STAGED_DIGEST=$(jq -r '.digest // ""' <<< "$STAGED_ASSET") + fi + if [ -z "$STAGED_ASSET_ID" ] || [ "$STAGED_DIGEST" != "$LOCAL_DIGEST" ]; then + echo "::error::The staged replacement SBOM failed digest verification." + exit 1 + fi + + CURRENT_DIGEST=$(gh api \ + "repos/$GITHUB_REPOSITORY/releases/assets/$CANONICAL_ASSET_ID" \ + --jq '.digest // ""') + if [ "$CURRENT_DIGEST" != "$REPAIR_DIGEST" ]; then + gh api --method DELETE \ + "repos/$GITHUB_REPOSITORY/releases/assets/$STAGED_ASSET_ID" + echo "::error::The legacy SBOM asset changed during repair; refusing to replace it." + exit 1 + fi + gh api --method DELETE \ + "repos/$GITHUB_REPOSITORY/releases/assets/$CANONICAL_ASSET_ID" + finalize_staged_asset - name: Report a skipped tag if: ${{ steps.component.outputs.matched != 'true' }} diff --git a/SECURITY.md b/SECURITY.md index 347806e26..255877f83 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -77,9 +77,9 @@ deadline: | Within 72 hours of awareness | Assessment updated with severity, impact, and any mitigation available to users | | Within 14 days of a fix or mitigation being available | Final assessment: root cause, the fix or mitigation, and the affected-version list | -Affected published versions are determined from the SBOM attached to each -release, so the answer is derived from what actually shipped rather than -reconstructed from memory. Users are informed through the GitHub Security +Affected current versions are determined from their attached SBOMs. Older +releases without a backfilled asset are investigated from their immutable tag +and published descriptors. Users are informed through the GitHub Security Advisory, the release notes of the fixing release, and the repository README when the impact is broad. @@ -105,11 +105,13 @@ integrators can plan a migration rather than discover it during an incident. ## Supply Chain -Every supported component release carries a CycloneDX SBOM as a GitHub Release -asset, so you can check whether a specific version contains a given dependency: +Current component release workflows attach a CycloneDX SBOM as a GitHub Release +asset, and a daily repair job fills any missed latest-release asset. Use it to +check whether a specific version declares a given dependency: ```bash -gh release download react-native-iap-16.3.0 -p '*.cdx.json' +gh release download react-native-iap-16.3.0 \ + --repo hyodotdev/openiap -p '*.cdx.json' gh attestation verify react-native-iap-16.3.0.cdx.json --repo hyodotdev/openiap ``` diff --git a/packages/docs/src/pages/docs/security/compliance.tsx b/packages/docs/src/pages/docs/security/compliance.tsx index 7569f343b..f53e3e013 100644 --- a/packages/docs/src/pages/docs/security/compliance.tsx +++ b/packages/docs/src/pages/docs/security/compliance.tsx @@ -14,7 +14,7 @@ const DEADLINES: Deadline[] = [ { date: '11 September 2026', applies: - 'Reporting obligations. Actively exploited vulnerabilities and severe incidents must be reported to ENISA and the relevant national CSIRT — 24-hour early warning, 72-hour notification, 14-day final report', + 'Reporting obligations: 24-hour early warning and 72-hour notification. The final report is due 14 days after a vulnerability fix or mitigation becomes available, or one month after a severe-incident notification', }, { date: '11 December 2027', @@ -33,7 +33,8 @@ const PROVISIONS: Provision[] = [ need: 'Component inventory for a product you ship', provided: ( <> - The per-release SBOM, in CycloneDX 1.6 + The current-release SBOM, in CycloneDX + 1.6 ), }, @@ -220,9 +221,9 @@ function SecurityCompliance() { The assessment is published as a gap list, not a conformance claim — it records what exists, what does not, and what is out of proportion for a project of this size. Met today: the public reporting channel - with a documented response path, and the automated per-release SBOM. - Open: a single named security-assurance policy document, a maintainer - responsibility inventory, and a declared license policy. + with a documented response path, and the automated current-release + SBOM. Open: a single named security-assurance policy document, + competency and assessment records, and a declared license policy.

@@ -130,7 +131,7 @@ function SecurityOverview() {

- What every release publishes + What current release workflows publish A dependency that does not exist cannot be vulnerable. For the @@ -187,8 +189,10 @@ function SecurityOverview() { If the issue is being exploited in the wild, mark it{' '} [SECURITY][ACTIVE]. That triggers an accelerated path: an initial assessment within 24 hours, an updated assessment within 72 - hours, and a final assessment within 14 days — the windows the EU - Cyber Resilience Act sets for reporting. + hours, and a final assessment within 14 days after a fix or mitigation + is available. This is OpenIAP's internal service level; legal + reporting duties depend on the affected party's role and the + event.

Receipt validation, purchase verification, and entitlement handling @@ -224,9 +228,9 @@ function SecurityOverview() {

The important design choice: SBOMs are generated{' '} after a release is published, not on every commit. A - release tag is the only moment an inventory is meaningful, and it - means the existing release workflows did not have to change — a new - SDK added later is covered by the same path automatically. + release tag is the only moment an inventory is meaningful. Every + release lane must dispatch the shared SBOM workflow, and CI checks + that wiring.

@@ -236,24 +240,24 @@ function SecurityOverview() {

Dependabot watches IAPKit's dependency tree, the GitHub Actions - used in release workflows, and the IAPKit container image. The - published SDKs have no runtime dependency tree to watch. + used in release workflows, and the IAPKit container image. The three + published npm packages have no runtime dependency tree to watch.

Native SDK platform dependencies are pinned deliberately — several carry inline notes explaining why a newer version is not yet compatible with the supported toolchain range. They are reviewed as part of platform upgrade work rather than bumped automatically, and - each release's SBOM records exactly what shipped. + each current release's SBOM records its published direct + dependency contract. A toolchain resolver export can add transitive + entries when needed.

- GitHub's dependency graph does not parse this repository's - lockfiles — Bun lockfiles are not a supported format, and Gradle - builds are not resolved from source. Its generated inventory for this - repository is empty. Dependabot's version updates still work, - because they read manifests directly, but the platform cannot derive a - component inventory on its own. The SBOMs published here are that - inventory. + GitHub's dependency-graph SBOM endpoint currently returns HTTP + 404 for this repository, so it cannot serve as an independent + inventory. Dependabot's version updates still work because they + read manifests directly. The release SBOMs provide the + component-specific inventory here. diff --git a/packages/docs/src/pages/docs/security/sbom.tsx b/packages/docs/src/pages/docs/security/sbom.tsx index cb7d334c8..6649d10e5 100644 --- a/packages/docs/src/pages/docs/security/sbom.tsx +++ b/packages/docs/src/pages/docs/security/sbom.tsx @@ -127,13 +127,28 @@ const LIMITS: Limit[] = [ { title: 'Transitive dependencies', detail: - "are included only where the ecosystem's resolver output is available. Direct runtime dependencies are always complete.", + "are included only where the ecosystem's resolver output is available. Release SBOMs otherwise describe the documented direct-dependency source.", }, { title: 'Licenses and suppliers', detail: 'come from live registries. Unavailable metadata is omitted, and pub.dev and some NuGet packages do not expose a standard value.', }, + { + title: 'Version constraints', + detail: + 'remain constraints when a library manifest does not select one exact version. Use the consuming application lockfile for exact CVE matching.', + }, + { + title: 'KMP target scope', + detail: + 'uses the published Android Play POM so openiap-google is included. An iOS-only application should use its resolved target graph.', + }, + { + title: 'MAUI target scope', + detail: + "is the union of the published nuspec's target-framework groups. Use the consuming application's resolved graph to narrow it to Android, iOS, or Mac Catalyst.", + }, ]; function SecuritySbom() { @@ -143,19 +158,17 @@ function SecuritySbom() {

Software Bill of Materials

- Every published OpenIAP release carries a machine-readable inventory of - the third-party code it contains, attached to its GitHub Release as a{' '} - CycloneDX 1.6 JSON file. It exists to answer one - question without anyone reading our build scripts:{' '} - - does this version of this package contain the vulnerable dependency? - + Current OpenIAP release workflows attach a machine-readable inventory of + direct third-party dependencies to each GitHub Release as a{' '} + CycloneDX 1.6 JSON file. A daily repair job fills + missed latest-release assets. Exact application exposure comes from the + consumer's resolved dependency graph.

@@ -249,8 +262,8 @@ flutter_inapp_purchase-10.3.0.cdx.json`} mismatched
  • - Every direct runtime dependency, with version, purl, supplier, and - license + Every direct runtime dependency, with version or constraint, purl, + and available supplier and license data
  • @@ -305,8 +318,8 @@ flutter_inapp_purchase-10.3.0.cdx.json`} scripts/generate-sbom.mjs uses only the Node.js standard library — no npm package, no vendored code, no external binary. A tool that reports what you depend on should not quietly add dependencies of - its own. It reads package registries over HTTPS only to resolve - declared licenses and suppliers. + its own. It reads published POM and nuspec dependency descriptors, + then resolves declared licenses and suppliers from registries.

    diff --git a/scripts/audit-security.mjs b/scripts/audit-security.mjs new file mode 100644 index 000000000..c7ab2f2e3 --- /dev/null +++ b/scripts/audit-security.mjs @@ -0,0 +1,126 @@ +#!/usr/bin/env node + +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const repoRoot = resolve(fileURLToPath(new URL("..", import.meta.url))); + +export function findWorkflowRunInterpolations( + source, + filename = "workflow.yml", +) { + const lines = source.split("\n"); + const findings = []; + + for (let index = 0; index < lines.length; index += 1) { + const opener = lines[index].match(/^(\s*)(?:-\s*)?run:\s*(.*)$/u); + if (!opener) continue; + const indentation = opener[1].length; + if (opener[2].includes("${{")) { + findings.push(`${filename}:${index + 1}: ${lines[index].trim()}`); + } + if ( + !/^[|>](?:(?:[1-9][-+]?)|(?:[-+][1-9]?))?(?:\s+#.*)?\s*$/u.test(opener[2]) + ) + continue; + + for (let runIndex = index + 1; runIndex < lines.length; runIndex += 1) { + const line = lines[runIndex]; + if (line.trim() && line.match(/^\s*/u)[0].length <= indentation) break; + if (line.includes("${{")) { + findings.push(`${filename}:${runIndex + 1}: ${line.trim()}`); + } + } + } + + return findings; +} + +export function extractExternalUrls(source) { + const urls = []; + for (const match of source.matchAll(/https?:\/\/[^\s<>"'`)\]}]+/gu)) { + const nextCharacter = source[match.index + match[0].length]; + if (nextCharacter === "<" || nextCharacter === "{") continue; + urls.push(match[0].replace(/[.,;:]+$/u, "")); + } + return urls; +} + +export async function auditWorkflowFiles(paths) { + const findings = paths.flatMap((path) => + findWorkflowRunInterpolations( + readFileSync(resolve(repoRoot, path), "utf8"), + path, + ), + ); + if (findings.length === 0) { + throw new Error( + "No workflow run expressions found; refusing to report a vacuous pass", + ); + } + process.stdout.write(`${findings.join("\n")}\n`); +} + +async function auditUrls(paths) { + const urls = [ + ...new Set( + paths.flatMap((path) => + extractExternalUrls(readFileSync(resolve(repoRoot, path), "utf8")), + ), + ), + ].sort(); + if (urls.length === 0) { + throw new Error( + "No external URLs found; refusing to report a vacuous pass", + ); + } + + const failures = []; + await Promise.all( + urls.map(async (url) => { + try { + const response = await fetch(url, { + redirect: "follow", + signal: AbortSignal.timeout(20_000), + }); + await response.body?.cancel(); + if (!response.ok) failures.push(`${response.status} ${url}`); + } catch (error) { + failures.push(`ERROR ${url} (${error.message})`); + } + }), + ); + + process.stdout.write( + failures.length > 0 + ? `${failures.sort().join("\n")}\n` + : `${urls.length} external URLs resolved.\n`, + ); + if (failures.length > 0) process.exitCode = 1; +} + +async function main() { + const [command, ...paths] = process.argv.slice(2); + if (!command || paths.length === 0) { + throw new Error( + "Usage: audit-security.mjs ", + ); + } + if (command === "workflows") { + await auditWorkflowFiles(paths); + return; + } + if (command === "urls") { + await auditUrls(paths); + return; + } + throw new Error(`Unknown audit command '${command}'`); +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + main().catch((error) => { + console.error(`::error::${error.message}`); + process.exitCode = 1; + }); +} diff --git a/scripts/audit-security.test.mjs b/scripts/audit-security.test.mjs new file mode 100644 index 000000000..0df455985 --- /dev/null +++ b/scripts/audit-security.test.mjs @@ -0,0 +1,65 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; +import test from "node:test"; + +import { + auditWorkflowFiles, + extractExternalUrls, + findWorkflowRunInterpolations, +} from "./audit-security.mjs"; + +test("workflow scan detects expressions in scalar and block run steps", () => { + const workflow = `steps: + - run: echo "${"${{"} inputs.scalar }}" + - name: Block + run: | + echo "safe" + echo "${"${{"} github.event.issue.title }}" + - uses: actions/checkout@v7 +`; + assert.deepEqual(findWorkflowRunInterpolations(workflow, "fixture.yml"), [ + 'fixture.yml:2: - run: echo "${{ inputs.scalar }}"', + 'fixture.yml:6: echo "${{ github.event.issue.title }}"', + ]); +}); + +test("workflow scan recognizes YAML block-scalar header variants", () => { + for (const header of ["|2", ">-2", "|2-", ">+2", "| # note", "|2 # note"]) { + const workflow = `steps:\n - run: ${header}\n echo "${"${{"} inputs.value }}"\n`; + assert.deepEqual(findWorkflowRunInterpolations(workflow, "fixture.yml"), [ + 'fixture.yml:3: echo "${{ inputs.value }}"', + ]); + } +}); + +test("URL extraction removes JSX and Markdown delimiters", () => { + const source = + `href='https://example.com/path' [docs](https://example.org/a). ` + + `https://example.net/releases/`; + assert.deepEqual(extractExternalUrls(source), [ + "https://example.com/path", + "https://example.org/a", + ]); +}); + +test("empty workflow scans fail instead of reporting a vacuous pass", async (t) => { + const scratch = mkdtempSync(resolve(tmpdir(), "openiap-security-audit-")); + const workflow = resolve(scratch, "empty.yml"); + writeFileSync(workflow, "steps:\n - run: echo safe\n"); + t.after(() => rmSync(scratch, { recursive: true, force: true })); + + assert.deepEqual( + findWorkflowRunInterpolations("steps:\n - run: echo safe\n"), + [], + ); + await assert.rejects( + () => auditWorkflowFiles([workflow]), + /refusing to report a vacuous pass/u, + ); +}); + +test("empty URL extraction is explicit", () => { + assert.deepEqual(extractExternalUrls("no links"), []); +}); diff --git a/scripts/generate-sbom.mjs b/scripts/generate-sbom.mjs index 5915cde79..42a72bc00 100644 --- a/scripts/generate-sbom.mjs +++ b/scripts/generate-sbom.mjs @@ -34,6 +34,7 @@ import { validateVersion, versionSources } from "./release-branch-policy.mjs"; import { extractDirectDependencies, mergeResolved, + PublishedMetadataUnavailableError, } from "./sbom-dependencies.mjs"; const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); @@ -49,17 +50,25 @@ const DEFAULT_LICENSE = "MIT"; const GENERATOR_NAME = "openiap-sbom-generator"; const GENERATOR_VERSION = "1.0.0"; const SPEC_VERSION = "1.6"; +export const PUBLISHED_METADATA_UNAVAILABLE_EXIT_CODE = 75; + +const INACCURATE_SBOM_DIGESTS = new Map([ + [ + "google-3.3.0", + "sha256:7256739c147689fbbb1257a738f85e7d030bb3612fd52a903c4cc9ca72b00e66", + ], +]); /** * SBOM-specific metadata per releasable component. * * `versionSources` (release SSOT) supplies the label and version; this table * adds only what an SBOM needs on top: how the component is distributed, and - * where its runtime dependencies are declared. + * which released input describes its runtime dependencies. */ const COMPONENTS = { apple: { - sbomName: "openiap-apple", + sbomName: "openiap", type: "library", purl: (version) => `pkg:cocoapods/openiap@${version}`, distribution: (version) => `https://cocoapods.org/pods/openiap`, @@ -107,7 +116,6 @@ const COMPONENTS = { kind: "pub", manifest: "libraries/flutter_inapp_purchase/pubspec.yaml", }, - resolver: "flutter pub deps --json", }, godot: { sbomName: "godot-iap", @@ -128,7 +136,6 @@ const COMPONENTS = { }, }, }, - resolver: "gradlew :dependencies", }, google: { sbomName: "openiap-google", @@ -139,10 +146,10 @@ const COMPONENTS = { `https://central.sonatype.com/artifact/io.github.hyochan.openiap/openiap-google/${version}`, directory: "packages/google", source: { - kind: "gradle", - manifest: "packages/google/openiap/build.gradle.kts", + kind: "maven-pom", + coordinate: "io.github.hyochan.openiap:openiap-google", + repositories: ["https://repo1.maven.org/maven2"], }, - resolver: "gradlew :openiap:dependencies", }, kmp: { sbomName: "kmp-iap", @@ -155,11 +162,14 @@ const COMPONENTS = { `https://central.sonatype.com/artifact/io.github.hyochan/kmp-iap/${version}`, directory: "libraries/kmp-iap", source: { - kind: "gradle-catalog", - manifest: "libraries/kmp-iap/library/build.gradle.kts", - catalog: "libraries/kmp-iap/gradle/libs.versions.toml", + kind: "maven-pom", + coordinates: [ + "io.github.hyochan:kmp-iap-android-play", + "io.github.hyochan:kmp-iap-android", + "io.github.hyochan:kmp-iap", + ], + repositories: ["https://repo1.maven.org/maven2"], }, - resolver: "gradlew :library:dependencies", }, maui: { sbomName: "OpenIap.Maui", @@ -169,14 +179,9 @@ const COMPONENTS = { `https://www.nuget.org/packages/OpenIap.Maui/${version}`, directory: "libraries/maui-iap", source: { - kind: "nuget", - manifest: "libraries/maui-iap/src/OpenIap.Maui/OpenIap.Maui.csproj", - propertyFiles: [ - "libraries/maui-iap/Directory.Build.props", - "libraries/maui-iap/src/Directory.Build.props", - ], + kind: "nuget-nuspec", + packageId: "OpenIap.Maui", }, - resolver: "dotnet list package --include-transitive", }, "react-native": { sbomName: "react-native-iap", @@ -227,23 +232,67 @@ export function sbomFileName(componentId, version) { return `${COMPONENTS[componentId].sbomName}-${version}.cdx.json`; } -/** - * Longest-prefix first, so `google-` cannot swallow a tag that a more specific - * component owns. Apple publishes a bare semver tag and is matched last. - */ -const TAG_PREFIXES = [ - ["openiap-conformance-", "conformance"], - ["react-native-iap-", "react-native"], - ["flutter-iap-", "flutter"], - ["godot-iap-", "godot"], - ["expo-iap-", "expo"], - ["maui-iap-", "maui"], - ["kmp-iap-", "kmp"], - ["google-v", "google"], - ["google-", "google"], - ["apple-v", "apple"], - ["docs-", "docs"], -].sort((left, right) => right[0].length - left[0].length); +export function inaccurateSbomDigestForTag(tag) { + return INACCURATE_SBOM_DIGESTS.get(tag) ?? ""; +} + +/** Return newest missing releases plus every known-inaccurate legacy SBOM. */ +export function findMissingLatestSbomTags(releases) { + const seen = new Set(); + const missing = []; + const newestFirst = releases + .filter((release) => !release?.draft && release?.published_at) + .sort( + (left, right) => + Date.parse(right.published_at) - Date.parse(left.published_at), + ); + for (const release of newestFirst) { + const resolvedTag = componentFromTag(release.tag_name); + if (!resolvedTag) continue; + const expected = sbomFileName(resolvedTag.componentId, resolvedTag.version); + const assets = Array.isArray(release.assets) ? release.assets : []; + const asset = assets.find((entry) => entry?.name === expected); + const stagedAsset = assets.find( + (entry) => entry?.name === `${expected}.replacement`, + ); + const inaccurateDigest = inaccurateSbomDigestForTag(release.tag_name); + + // Legacy repairs remain eligible even after a newer component release. + if ( + inaccurateDigest && + (stagedAsset || !asset || asset.digest === inaccurateDigest) + ) { + missing.push(release.tag_name); + } + + if (seen.has(resolvedTag.componentId)) continue; + seen.add(resolvedTag.componentId); + if (!asset && !inaccurateDigest) missing.push(release.tag_name); + } + return missing; +} + +const TAG_VERSION_PLACEHOLDER = "9.8.7"; + +/** Tag aliases are derived from the same package config release validation uses. */ +const TAG_PATTERNS = [ + ...Object.entries(PACKAGE_CONFIG).flatMap(([componentId, config]) => + config.tags(TAG_VERSION_PLACEHOLDER).map((tag) => { + const index = tag.indexOf(TAG_VERSION_PLACEHOLDER); + if (index === -1) { + throw new Error( + `Release tag pattern for ${componentId} has no version`, + ); + } + return { + componentId, + prefix: tag.slice(0, index), + suffix: tag.slice(index + TAG_VERSION_PLACEHOLDER.length), + }; + }), + ), + { componentId: "docs", prefix: "docs-", suffix: "" }, +].sort((left, right) => right.prefix.length - left.prefix.length); /** * Map a published release tag back to the component that produced it. @@ -255,18 +304,18 @@ export function componentFromTag(tag) { const normalized = String(tag ?? "").trim(); if (!normalized) return null; - for (const [prefix, componentId] of TAG_PREFIXES) { - if (!normalized.startsWith(prefix)) continue; - const version = normalized.slice(prefix.length); - if (!/^\d+\.\d+\.\d+/u.test(version)) continue; + for (const { componentId, prefix, suffix } of TAG_PATTERNS) { + if (!normalized.startsWith(prefix) || !normalized.endsWith(suffix)) { + continue; + } + const version = normalized.slice( + prefix.length, + suffix ? -suffix.length : undefined, + ); + if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/u.test(version)) continue; return { componentId, version }; } - // packages/apple releases under a bare version tag. - if (/^\d+\.\d+\.\d+/u.test(normalized)) { - return { componentId: "apple", version: normalized }; - } - return null; } @@ -366,10 +415,15 @@ function dependencyComponent(entry) { if (entry.licenses?.length) { component.licenses = entry.licenses; } + const properties = [...(entry.properties ?? [])]; if (entry.transitive) { - component.properties = [ - { name: "openiap:sbom:relationship", value: "transitive" }, - ]; + properties.push({ + name: "openiap:sbom:relationship", + value: "transitive", + }); + } + if (properties.length > 0) { + component.properties = properties; } return component; } @@ -494,6 +548,29 @@ async function fetchText(url) { return response.text(); } +async function fetchPublishedText( + url, + { + fetcher = fetchText, + retryDelays = Array(5).fill(5_000), + wait = (delay) => + new Promise((resolveDelay) => setTimeout(resolveDelay, delay)), + } = {}, +) { + for (let attempt = 0; attempt <= retryDelays.length; attempt += 1) { + try { + const result = await fetcher(url); + if (result) return result; + } catch { + // Registry transport failures are retryable just like an indexing 404. + } + if (attempt < retryDelays.length) { + await wait(retryDelays[attempt]); + } + } + return null; +} + /** * Look up a dependency's declared license and supplier in its own registry. * @@ -680,6 +757,7 @@ export async function generateSbom( resolvedFile, withLicenses = false, runGit = defaultRunGit, + fetchArtifactText = fetchPublishedText, } = {}, ) { const definition = COMPONENTS[componentId]; @@ -698,7 +776,10 @@ export async function generateSbom( runGit(["show", "-s", "--format=%cI", resolvedCommit]), ).toISOString(); - const direct = extractDirectDependencies(root, definition.source); + const direct = await extractDirectDependencies(root, definition.source, { + version, + fetchText: fetchArtifactText, + }); const merged = resolvedFile ? mergeResolved(direct, readResolvedFile(resolvedFile)) : direct; @@ -784,13 +865,27 @@ function parseArguments(argv) { async function main() { const [maybeCommand] = process.argv.slice(2); + if (maybeCommand === "missing-release-tags") { + const path = process.argv[3]; + if (!path) + throw new Error("Usage: generate-sbom.mjs missing-release-tags FILE"); + const parsed = JSON.parse(readFileSync(path, "utf8")); + const releases = Array.isArray(parsed?.[0]) ? parsed.flat() : parsed; + if (!Array.isArray(releases)) { + throw new Error(`Release list must be a JSON array: ${path}`); + } + const tags = findMissingLatestSbomTags(releases); + process.stdout.write(tags.length > 0 ? `${tags.join("\n")}\n` : ""); + return; + } + // `resolve-tag` lets a workflow map a published release back to its component // without duplicating the tag conventions in YAML. if (maybeCommand === "resolve-tag") { const tag = process.argv[3]; const resolved = componentFromTag(tag); const line = resolved - ? `component=${resolved.componentId}\nversion=${resolved.version}\nmatched=true\n` + ? `component=${resolved.componentId}\nversion=${resolved.version}\nsbom-name=${sbomFileName(resolved.componentId, resolved.version)}\nrepair-digest=${inaccurateSbomDigestForTag(tag)}\nmatched=true\n` : "matched=false\n"; process.stdout.write(line); if (process.env.GITHUB_OUTPUT) { @@ -841,9 +936,17 @@ async function main() { if (process.argv[1] === fileURLToPath(import.meta.url)) { main().catch((error) => { - console.error(`::error::${error.message}`); - process.exitCode = 1; + const message = error instanceof Error ? error.message : String(error); + console.error(`::error::${message}`); + process.exitCode = + error instanceof PublishedMetadataUnavailableError + ? PUBLISHED_METADATA_UNAVAILABLE_EXIT_CODE + : 1; }); } -export const __testing = { COMPONENTS, deterministicSerialNumber }; +export const __testing = { + COMPONENTS, + deterministicSerialNumber, + fetchPublishedText, +}; diff --git a/scripts/generate-sbom.test.mjs b/scripts/generate-sbom.test.mjs index a3857b47f..a62c52625 100644 --- a/scripts/generate-sbom.test.mjs +++ b/scripts/generate-sbom.test.mjs @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import { mkdirSync, mkdtempSync, + readdirSync, readFileSync, rmSync, writeFileSync, @@ -15,9 +16,12 @@ import { __testing as generatorTesting, buildSbom, componentFromTag, + findMissingLatestSbomTags, generateSbom, + inaccurateSbomDigestForTag, listComponentIds, normalizeLicense, + PUBLISHED_METADATA_UNAVAILABLE_EXIT_CODE, readComponentVersion, readVexStatements, releaseTagFor, @@ -26,7 +30,9 @@ import { import { PACKAGE_CONFIG } from "./assert-release-tag.mjs"; import { __testing as dependencyTesting, + extractDirectDependencies, mergeResolved, + PublishedMetadataUnavailableError, } from "./sbom-dependencies.mjs"; import { versionSources } from "./release-branch-policy.mjs"; @@ -35,18 +41,112 @@ const historicalGoogleRoot = resolve( repoRoot, "scripts/fixtures/historical-releases/google-v1.3.0", ); -const { COMPONENTS } = generatorTesting; +const { COMPONENTS, fetchPublishedText } = generatorTesting; const { - expandGradleForLoops, extractGradle, - extractNuget, extractPub, isRuntimeGradleConfiguration, parseMavenCoordinate, - parseVersionCatalog, - stripTestSourceSets, + parseMavenPom, + parseNugetNuspec, } = dependencyTesting; +function mavenPom(dependencies) { + return `${dependencies + .map( + ([group, artifact, version, scope = "runtime"]) => + `${group}${artifact}` + + `${version}${scope}`, + ) + .join("")}`; +} + +const googleDependencies = [ + ["com.android.billingclient", "billing", "9.1.0", "compile"], + ["org.jetbrains.kotlin", "kotlin-stdlib", "2.2.0", "compile"], + ["androidx.core", "core", "1.18.0"], + ["androidx.lifecycle", "lifecycle-runtime", "2.10.0"], + ["org.jetbrains.kotlinx", "kotlinx-coroutines-core", "1.11.0"], + ["org.jetbrains.kotlinx", "kotlinx-coroutines-android", "1.11.0"], + ["androidx.lifecycle", "lifecycle-viewmodel", "2.10.0"], + ["com.google.code.gson", "gson", "2.14.0"], + ["androidx.compose.runtime", "runtime", "1.11.4"], + ["androidx.compose.ui", "ui", "1.11.4"], +]; + +const historicalGoogleDependencies = [ + ["com.android.billingclient", "billing-ktx", "8.0.0", "compile"], + [ + "com.meta.horizon.billingclient.api", + "horizon-billing-compatibility", + "1.1.1", + "compile", + ], + ["org.jetbrains.kotlin", "kotlin-stdlib", "2.0.21", "compile"], + ["androidx.core", "core-ktx", "1.12.0"], + ["androidx.lifecycle", "lifecycle-runtime-ktx", "2.7.0"], + ["org.jetbrains.kotlinx", "kotlinx-coroutines-core", "1.9.0"], + ["org.jetbrains.kotlinx", "kotlinx-coroutines-android", "1.9.0"], + ["androidx.lifecycle", "lifecycle-viewmodel-ktx", "2.7.0"], + ["com.google.code.gson", "gson", "2.10.1"], + ["androidx.compose.runtime", "runtime", "1.6.8"], + ["androidx.compose.ui", "ui", "1.6.8"], +]; + +const kmpDependencies = [ + ["org.jetbrains.kotlinx", "kotlinx-coroutines-core-jvm", "1.11.0", "compile"], + ["org.jetbrains.kotlin", "kotlin-stdlib", "2.4.10", "compile"], + ["io.github.hyochan.openiap", "openiap-google", "3.3.0"], + ["org.jetbrains.kotlinx", "kotlinx-datetime-jvm", "0.8.0"], + ["org.jetbrains.kotlinx", "kotlinx-serialization-json-jvm", "1.11.0"], +]; + +const mauiDependencies = [ + ["GoogleGson", "2.14.0.1"], + ["Xamarin.Android.Google.BillingClient", "9.1.0.1"], + ["Xamarin.AndroidX.Activity", "1.13.0.1"], + ["Xamarin.AndroidX.Activity.Ktx", "1.13.0.1"], + ["Xamarin.AndroidX.Collection.Ktx", "1.6.0.1"], + ["Xamarin.AndroidX.Fragment", "1.8.9.3"], + ["Xamarin.AndroidX.Fragment.Ktx", "1.8.9.4"], + ["Xamarin.AndroidX.Lifecycle.LiveData", "2.11.0.1"], + ["Xamarin.AndroidX.Lifecycle.LiveData.Core", "2.11.0.1"], + ["Xamarin.AndroidX.Lifecycle.LiveData.Core.Ktx", "2.11.0.1"], + ["Xamarin.AndroidX.Lifecycle.Process", "2.11.0.1"], + ["Xamarin.AndroidX.Lifecycle.Runtime", "2.11.0.1"], + ["Xamarin.AndroidX.Lifecycle.Runtime.Ktx", "2.11.0.1"], + ["Xamarin.AndroidX.Lifecycle.Runtime.Ktx.Android", "2.11.0.1"], + ["Xamarin.AndroidX.Lifecycle.ViewModel", "2.11.0.1"], + ["Xamarin.AndroidX.Lifecycle.ViewModel.Ktx", "2.11.0.1"], + ["Xamarin.AndroidX.SavedState", "1.5.0.1"], + ["Xamarin.AndroidX.SavedState.SavedState.Ktx", "1.5.0.1"], + ["Xamarin.Kotlin.StdLib", "2.4.0.1"], + ["Xamarin.KotlinX.Coroutines.Android", "1.11.0.1"], + ["Xamarin.KotlinX.Coroutines.Core", "1.11.0.1"], + ["Xamarin.KotlinX.Coroutines.Core.Jvm", "1.11.0.1"], +]; + +const mauiNuspec = `${mauiDependencies + .map(([name, version]) => ``) + .join("")}`; + +globalThis.fetch = async (input) => { + const url = String(input); + if (url.includes("openiap-google/1.3.0/")) { + return new Response(mavenPom(historicalGoogleDependencies)); + } + if (url.includes("openiap-google/")) { + return new Response(mavenPom(googleDependencies)); + } + if (url.includes("kmp-iap-android-play/")) { + return new Response(mavenPom(kmpDependencies)); + } + if (url.includes("openiap.maui.nuspec")) { + return new Response(mauiNuspec); + } + return new Response("", { status: 404 }); +}; + const stubCommit = "0".repeat(40); const stubGit = (args) => args[0] === "rev-parse" ? stubCommit : "2026-01-02T03:04:05+00:00"; @@ -74,6 +174,7 @@ test("SBOM file name matches the documented convention", () => { sbomFileName("conformance", "1.0.0"), "openiap-conformance-1.0.0.cdx.json", ); + assert.equal(sbomFileName("apple", "3.2.0"), "openiap-3.2.0.cdx.json"); }); test("release tags match the release-tag SSOT", () => { @@ -112,9 +213,158 @@ test("every release tag pattern resolves back to its own component", () => { assert.equal(componentFromTag("apple-v1.2.3").componentId, "apple"); assert.equal(componentFromTag("1.2.3").componentId, "apple"); assert.equal(componentFromTag("some-unrelated-tag"), null); + assert.equal(componentFromTag("google-1.2.3-not-a-version!"), null); assert.equal(componentFromTag(""), null); }); +test("backfill selects only the newest missing SBOM per component", () => { + const releases = [ + { + tag_name: "google-3.3.0", + published_at: "2026-08-11T00:00:00Z", + assets: [{ name: "openiap-google-3.3.0.cdx.json" }], + }, + { + tag_name: "kmp-iap-3.3.0", + published_at: "2026-08-11T00:00:00Z", + assets: [{ name: "release-artifacts.zip" }], + }, + { + tag_name: "kmp-iap-3.2.2", + published_at: "2026-08-10T00:00:00Z", + assets: [], + }, + { + tag_name: "draft-1.0.0", + published_at: null, + draft: true, + assets: [], + }, + ]; + assert.deepEqual(findMissingLatestSbomTags(releases), ["kmp-iap-3.3.0"]); +}); + +test("backfill repairs only the exact known inaccurate SBOM", () => { + const tag = "google-3.3.0"; + const name = "openiap-google-3.3.0.cdx.json"; + const published_at = "2026-08-11T00:00:00Z"; + const inaccurate = inaccurateSbomDigestForTag(tag); + + assert.match(inaccurate, /^sha256:[0-9a-f]{64}$/u); + assert.deepEqual( + findMissingLatestSbomTags([ + { tag_name: tag, published_at, assets: [{ name, digest: inaccurate }] }, + ]), + [tag], + ); + assert.deepEqual( + findMissingLatestSbomTags([ + { + tag_name: tag, + published_at, + assets: [ + { name, digest: `sha256:${"0".repeat(64)}` }, + { name: `${name}.replacement` }, + ], + }, + ]), + [tag], + ); + assert.deepEqual( + findMissingLatestSbomTags([ + { + tag_name: tag, + published_at, + assets: [{ name, digest: `sha256:${"0".repeat(64)}` }], + }, + ]), + [], + ); + assert.deepEqual( + findMissingLatestSbomTags([ + { + tag_name: "google-3.4.0", + published_at: "2026-08-12T00:00:00Z", + assets: [{ name: "openiap-google-3.4.0.cdx.json" }], + }, + { tag_name: tag, published_at, assets: [{ name, digest: inaccurate }] }, + ]), + [tag], + ); +}); + +test("every GitHub release workflow dispatches the SBOM workflow", () => { + const workflowDir = resolve(repoRoot, ".github/workflows"); + const workflows = readdirSync(workflowDir) + .filter((name) => name.endsWith(".yml")) + .map((name) => [name, readFileSync(resolve(workflowDir, name), "utf8")]) + .filter(([, source]) => + /softprops\/action-gh-release|gh release create/u.test(source), + ); + + assert.ok( + workflows.length > 0, + "release workflow discovery must not be empty", + ); + for (const [name, source] of workflows) { + const releaseIndex = Math.max( + source.lastIndexOf("softprops/action-gh-release"), + source.lastIndexOf("gh release create"), + ); + const dispatchIndex = source.indexOf("gh workflow run sbom.yml"); + assert.ok(dispatchIndex > releaseIndex, `${name} dispatch order`); + const dispatchCommand = source + .slice(dispatchIndex) + .match(/^gh workflow run sbom\.yml[^\n]*(?:\\\n\s+[^\n]*)*/u)?.[0]; + assert.match(dispatchCommand, /-f tag="\$RELEASE_TAG"/u, `${name} tag`); + assert.match(source, /actions: write/u, name); + } +}); + +test("SBOM publication waits for registry propagation and repairs daily", () => { + const source = readFileSync( + resolve(repoRoot, ".github/workflows/sbom.yml"), + "utf8", + ); + assert.match(source, /cron: "23 3 \* \* \*"/u); + assert.match(source, /for attempt in \{1\.\.16\}/u); + assert.match(source, /\[ "\$STATUS" -ne 75 \]/u); + assert.doesNotMatch(source, /grep.+Published/u); + assert.match(source, /A staged legacy repair will be reconciled/u); + assert.match(source, /CURRENT_DIGEST.*!=.*REPAIR_DIGEST/u); + const stagedUpload = source.indexOf( + 'gh release upload "$RELEASE_TAG" "$STAGED_FILE"', + ); + const legacyDelete = source.indexOf( + 'gh api --method DELETE \\\n "repos/$GITHUB_REPOSITORY/releases/assets/$CANONICAL_ASSET_ID"', + ); + const stagedRename = source.indexOf( + "\n finalize_staged_asset\n", + legacyDelete, + ); + assert.ok(stagedUpload >= 0, "replacement must be uploaded before deletion"); + assert.ok(legacyDelete > stagedUpload, "legacy deletion must follow staging"); + assert.ok(stagedRename > legacyDelete, "staged asset must be finalized last"); + assert.match(source, /STAGED_DIGEST.*!=.*LOCAL_DIGEST/u); + assert.match(source, /STAGED_NAME="\$SBOM_NAME\.replacement"/u); + assert.match(source, /if \[ -z "\$CANONICAL_ASSET_ID" \]/u); + assert.match(source, /STAGED_DIGEST" = "\$LOCAL_DIGEST/u); + assert.match(source, /persist-credentials: false/u); + assert.match(source, /sleep 120/u); +}); + +test("Google releases fail when an unpublished flavor lacks credentials", () => { + const source = readFileSync( + resolve(repoRoot, ".github/workflows/release-google.yml"), + "utf8", + ); + assert.equal( + source.match(/Maven Central credentials are required to publish/gu)?.length, + 3, + ); + assert.doesNotMatch(source, /Skipping publish/u); +}); + test("generated SBOMs preserve accepted release tag aliases", () => { for (const [componentId, config] of Object.entries(PACKAGE_CONFIG)) { for (const tag of config.tags("9.9.9")) { @@ -150,7 +400,7 @@ test("current generator supports the google-v1.3.0 release tree", async () => { }); assert.equal(document.metadata.component.version, "1.3.0"); - assert.equal(directCount, 10); + assert.equal(directCount, 11); assert.deepEqual( document.components.map((component) => component.name), [ @@ -162,6 +412,7 @@ test("current generator supports the google-v1.3.0 release tree", async () => { "com.android.billingclient:billing-ktx", "com.google.code.gson:gson", "com.meta.horizon.billingclient.api:horizon-billing-compatibility", + "org.jetbrains.kotlin:kotlin-stdlib", "org.jetbrains.kotlinx:kotlinx-coroutines-android", "org.jetbrains.kotlinx:kotlinx-coroutines-core", ], @@ -276,12 +527,9 @@ test("generated SBOMs never embed local filesystem paths", async () => { } }); -test("test-only Gradle configurations stay out of the inventory", () => { +test("Gradle declarations are classified or fail closed", () => { assert.equal(isRuntimeGradleConfiguration("implementation"), true); assert.equal(isRuntimeGradleConfiguration("api"), true); - assert.equal(isRuntimeGradleConfiguration("playApi"), true); - assert.equal(isRuntimeGradleConfiguration("horizonImplementation"), true); - assert.equal(isRuntimeGradleConfiguration("testImplementation"), false); assert.equal( isRuntimeGradleConfiguration("androidTestImplementation"), @@ -290,97 +538,255 @@ test("test-only Gradle configurations stay out of the inventory", () => { assert.equal(isRuntimeGradleConfiguration("compileOnly"), false); assert.equal(isRuntimeGradleConfiguration("playCompileOnly"), false); assert.equal(isRuntimeGradleConfiguration("kaptImplementation"), false); + assert.throws( + () => isRuntimeGradleConfiguration("playApi"), + /Unclassified Gradle dependency configuration/u, + ); }); -test("packages/google inventory excludes its test dependencies", () => { - const dependencies = extractGradle(repoRoot, COMPONENTS.google.source); - const names = dependencies.map((entry) => entry.name); - - assert.ok(names.includes("com.android.billingclient:billing")); - assert.ok(names.includes("com.google.code.gson:gson")); - // Declared with `testImplementation` / `androidTestImplementation`. - assert.ok(!names.includes("junit:junit")); - assert.ok(!names.includes("org.robolectric:robolectric")); - assert.ok(!names.includes("androidx.test:core")); - assert.ok(!names.includes("org.jetbrains.kotlinx:kotlinx-coroutines-test")); -}); - -test("Gradle for-loop module lists expand to real coordinates", () => { - const expanded = expandGradleForLoops( - 'for (module in listOf("a-kotlin", "b-kotlin")) {\n' + - ' add("horizonApi", "com.example:$module:1.2.3")\n' + - "}", +test("an unmodelled Gradle coordinate fails instead of silently vanishing", (t) => { + assert.throws( + () => parseMavenCoordinate("com.example:lib:$unknownVersion"), + /Unresolved Maven coordinate/u, + ); + assert.throws( + () => parseMavenCoordinate("com.example:lib:1.0.0:sources"), + /Unsupported Maven coordinate/u, ); - assert.match(expanded, /com\.example:a-kotlin:1\.2\.3/u); - assert.match(expanded, /com\.example:b-kotlin:1\.2\.3/u); - const names = extractGradle(repoRoot, COMPONENTS.google.source).map( - (entry) => entry.name, + const scratch = mkdtempSync(resolve(tmpdir(), "openiap-gradle-")); + t.after(() => rmSync(scratch, { recursive: true, force: true })); + writeFileSync( + resolve(scratch, "build.gradle.kts"), + 'dependencies { implementation(group = "com.example", name = "lib", version = "1.0.0") }\n', + ); + assert.throws( + () => extractGradle(scratch, { manifest: "build.gradle.kts" }), + /Unsupported Gradle dependency declaration/u, + ); + writeFileSync( + resolve(scratch, "build.gradle.kts"), + "dependencies { customRuntime(libs.example) }\n", + ); + assert.throws( + () => extractGradle(scratch, { manifest: "build.gradle.kts" }), + /Unsupported version-catalog dependency/u, + ); + writeFileSync( + resolve(scratch, "build.gradle.kts"), + 'dependencies { implementation(project(":unexpected")) }\n', + ); + assert.throws( + () => extractGradle(scratch, { manifest: "build.gradle.kts" }), + /Unsupported Gradle dependency declaration/u, + ); + writeFileSync( + resolve(scratch, "build.gradle.kts"), + 'dependencies { implementation(project(":openiap")) }\n', + ); + assert.throws( + () => extractGradle(scratch, { manifest: "build.gradle.kts" }), + /lacks its published fallback/u, + ); + writeFileSync( + resolve(scratch, "build.gradle.kts"), + 'dependencies { customRuntime(group = "com.example", name = "lib", version = "1.0.0") }\n', + ); + assert.throws( + () => extractGradle(scratch, { manifest: "build.gradle.kts" }), + /Unclassified Gradle dependency configuration/u, + ); + writeFileSync( + resolve(scratch, "build.gradle.kts"), + 'dependencies { if (enabled == true) customRuntime("com.example:lib:1.0.0") }\n', + ); + assert.throws( + () => extractGradle(scratch, { manifest: "build.gradle.kts" }), + /Unclassified Gradle dependency configuration/u, + ); + writeFileSync( + resolve(scratch, "build.gradle.kts"), + 'dependencies { while (enabled) { implementation("real:dependency:2.0.0") } }\n', + ); + assert.deepEqual( + extractGradle(scratch, { manifest: "build.gradle.kts" }).map( + (entry) => entry.name, + ), + ["real:dependency"], + ); + writeFileSync( + resolve(scratch, "build.gradle.kts"), + "android { compileSdk(libs.versions.compileSdk.get()) }\n" + + 'dependencies { // implementation("fake:comment:1.0.0")\n' + + ' implementation("real:dependency:2.0.0")\n}\n', + ); + assert.deepEqual(extractGradle(scratch, { manifest: "build.gradle.kts" }), [ + { + name: "real:dependency", + purl: "pkg:maven/real/dependency@2.0.0", + version: "2.0.0", + }, + ]); + writeFileSync( + resolve(scratch, "build.gradle.kts"), + 'buildscript { dependencies { classpath("build:plugin:1.0.0") } }\n' + + 'dependencies { implementation("real:dependency:2.0.0") }\n', + ); + assert.deepEqual( + extractGradle(scratch, { manifest: "build.gradle.kts" }).map( + (entry) => entry.name, + ), + ["real:dependency"], + ); + writeFileSync( + resolve(scratch, "build.gradle.kts"), + 'subprojects { dependencies { implementation("hidden:dependency:1.0.0") } }\n' + + 'dependencies { implementation("real:dependency:2.0.0") }\n', + ); + assert.throws( + () => extractGradle(scratch, { manifest: "build.gradle.kts" }), + /Unsupported nested dependencies block/u, + ); + writeFileSync( + resolve(scratch, "build.gradle.kts"), + 'dependencies { implementation("real:dependency:2.0.0") { exclude(group = "fake") } }\n', + ); + assert.deepEqual( + extractGradle(scratch, { manifest: "build.gradle.kts" }).map( + (entry) => entry.name, + ), + ["real:dependency"], + ); + writeFileSync( + resolve(scratch, "build.gradle.kts"), + 'dependencies { customContainer("value") { implementation("hidden:dependency:1.0.0") } }\n', + ); + assert.throws( + () => extractGradle(scratch, { manifest: "build.gradle.kts" }), + /Unclassified Gradle dependency configuration/u, + ); + writeFileSync( + resolve(scratch, "build.gradle.kts"), + 'dependencies { constraints { implementation("constraint:only:3.0.0") }\n' + + ' implementation("real:dependency:2.0.0")\n}\n', + ); + assert.deepEqual( + extractGradle(scratch, { manifest: "build.gradle.kts" }).map( + (entry) => entry.name, + ), + ["real:dependency"], ); - for (const module of [ - "core-kotlin", - "user-age-category-kotlin", - "iap-kotlin", - ]) { - assert.ok( - names.includes(`com.meta.horizon.platform.sdk:${module}`), - module, - ); - } }); -test("an unmodelled Gradle coordinate fails instead of silently vanishing", () => { - // A dropped dependency is worse than a failed build: the SBOM would claim - // completeness it does not have. - assert.deepEqual(parseMavenCoordinate("com.example:lib:$unknownVersion"), { - unresolved: "com.example:lib:$unknownVersion", +test("published metadata matches the consumer-visible artifacts", async () => { + const google = await generateSbom("google", { + root: repoRoot, + runGit: stubGit, }); -}); - -test("KMP test source sets are excluded", async () => { - const stripped = stripTestSourceSets( - "val commonMain by getting {\n dependencies { api(libs.a) }\n}\n" + - "val commonTest by getting {\n dependencies { implementation(libs.b) }\n}\n", - ); - assert.match(stripped, /libs\.a/u); - assert.doesNotMatch(stripped, /libs\.b/u); + const googleNames = google.document.components.map((entry) => entry.name); + assert.equal(google.directCount, 10); + assert.ok(googleNames.includes("org.jetbrains.kotlin:kotlin-stdlib")); + assert.ok(!googleNames.some((name) => name.includes("horizon"))); + assert.ok(!googleNames.some((name) => name.includes("amazon"))); const kmp = await generateSbom("kmp", { root: repoRoot, runGit: stubGit }); - const names = kmp.document.components.map((entry) => entry.name); - assert.ok(!names.includes("org.jetbrains.kotlin:kotlin-test")); - assert.ok(!names.includes("org.jetbrains.kotlinx:kotlinx-coroutines-test")); + const kmpNames = kmp.document.components.map((entry) => entry.name); + assert.equal(kmp.directCount, 5); + assert.ok(kmpNames.includes("io.github.hyochan.openiap:openiap-google")); + assert.ok(kmpNames.includes("org.jetbrains.kotlin:kotlin-stdlib")); + + const maui = await generateSbom("maui", { root: repoRoot, runGit: stubGit }); + assert.equal(maui.directCount, 22); + assert.ok( + !maui.document.components.some((entry) => + entry.name.includes("Serialization"), + ), + ); }); -test("version catalog aliases resolve through version.ref", () => { - const { versions, libraries } = parseVersionCatalog( - '[versions]\nfoo = "1.2.3"\n\n[libraries]\n' + - 'bar-baz = { module = "com.example:bar", version.ref = "foo" }\n', +test("published metadata parsers reject unsupported dependencies", () => { + assert.throws( + () => + parseMavenPom( + "g" + + "a", + { url: "fixture.pom", version: "1.0.0" }, + ), + /Incomplete runtime dependency/u, ); - assert.equal(versions.get("foo"), "1.2.3"); - assert.deepEqual(libraries.get("bar-baz"), { - module: "com.example:bar", - versionRef: "foo", - literal: undefined, + assert.throws( + () => + parseNugetNuspec( + '', + { url: "fixture.nuspec" }, + ), + /Incomplete published NuGet dependency/u, + ); + assert.throws( + () => + parseMavenPom(mavenPom([["g", "a", "1.0.0", "unclassified"]]), { + url: "fixture.pom", + version: "1.0.0", + }), + /Unsupported Maven scope/u, + ); + assert.throws( + () => + parseMavenPom( + "" + + "ga" + + "1.0.0" + + "", + { url: "fixture.pom", version: "1.0.0" }, + ), + /Unsupported profiled Maven dependency/u, + ); +}); + +test("published metadata fetches retry transport failures", async () => { + let attempts = 0; + const result = await fetchPublishedText("https://example.com/package.pom", { + fetcher: async () => { + attempts += 1; + if (attempts === 1) throw new Error("temporary DNS failure"); + return ""; + }, + retryDelays: [0], + wait: async () => {}, }); + + assert.equal(result, ""); + assert.equal(attempts, 2); }); -test("NuGet references marked PrivateAssets=all are build-only", () => { - const dependencies = extractNuget(repoRoot, COMPONENTS.maui.source); - const names = dependencies.map((entry) => entry.name); - assert.ok(names.includes("Xamarin.Android.Google.BillingClient")); - // PrivateAssets="all" is not propagated to consumers of the package. - assert.ok(!names.includes("Microsoft.Maui.Controls")); - // Every MSBuild property must have been interpolated. - for (const entry of dependencies) { - assert.doesNotMatch(entry.version, /\$\(/u, entry.name); - } +test("missing published metadata has a dedicated error type", async () => { + assert.equal(PUBLISHED_METADATA_UNAVAILABLE_EXIT_CODE, 75); + await assert.rejects( + () => + extractDirectDependencies( + repoRoot, + { + kind: "maven-pom", + coordinate: "example:package", + repositories: ["https://example.com/maven"], + }, + { version: "1.0.0", fetchText: async () => null }, + ), + (error) => error instanceof PublishedMetadataUnavailableError, + ); }); test("pub dependencies exclude the Flutter SDK itself", () => { - const names = extractPub(repoRoot, COMPONENTS.flutter.source).map( - (entry) => entry.name, + const dependencies = extractPub(repoRoot, COMPONENTS.flutter.source); + assert.deepEqual( + dependencies.map((entry) => entry.name), + ["http", "meta", "platform"], ); - assert.deepEqual(names, ["http", "meta", "platform"]); + assert.deepEqual( + dependencies.map((entry) => entry.version), + ["^1.2.0", "^1.11.0", "^3.1.4"], + ); + assert.ok(dependencies.every((entry) => entry.purl.includes("@%5E"))); }); test("npm components publish no third-party runtime dependencies", async () => { @@ -405,6 +811,10 @@ test("resolver output adds transitive entries without losing direct ones", () => assert.equal(merged.length, 2); assert.equal(merged.find((e) => e.name === "a").transitive, undefined); assert.equal(merged.find((e) => e.name === "b").transitive, true); + assert.throws( + () => mergeResolved(direct, [{ name: "missing-version" }]), + /Incomplete resolved dependency/u, + ); }); test("a registry license only becomes an SPDX id when it really is one", () => { diff --git a/scripts/sbom-dependencies.mjs b/scripts/sbom-dependencies.mjs index 0939bec08..b20386cf5 100644 --- a/scripts/sbom-dependencies.mjs +++ b/scripts/sbom-dependencies.mjs @@ -1,22 +1,18 @@ #!/usr/bin/env node /** - * Direct runtime dependency extractors, one per ecosystem this repository - * actually publishes into. + * Direct runtime dependency readers for the artifacts OpenIAP publishes. * - * Each extractor reads the same manifest the build reads, so the inventory - * cannot drift from what is shipped. Transitive closure is not resolved here — - * that needs the ecosystem's own resolver, which only the release runners have. - * `mergeResolved` folds a resolver export in when one is supplied. - * - * Test-only and build-only dependencies are excluded on purpose: they are not - * present in the published artifact, so listing them would misrepresent the - * consumer's attack surface. + * Maven and NuGet inventories come from their published POM/nuspec, which is + * the consumer-visible contract. The remaining readers use simple manifests + * and fail when a declaration shape cannot be classified. */ import { readFileSync } from "node:fs"; import { resolve } from "node:path"; +export class PublishedMetadataUnavailableError extends Error {} + function readText(root, relativePath) { return readFileSync(resolve(root, relativePath), "utf8"); } @@ -25,187 +21,379 @@ function readJson(root, relativePath) { return JSON.parse(readText(root, relativePath)); } -/** Gradle `val name = "value"` locals, used to resolve `$name` interpolation. */ -function readGradleLocals(source) { - const locals = new Map(); - for (const match of source.matchAll( - /\bval\s+([A-Za-z_][A-Za-z0-9_]*)\s*=\s*"([^"]+)"/gu, - )) { - locals.set(match[1], match[2]); - } +function decodeXml(value) { + return String(value ?? "") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll(""", '"') + .replaceAll("'", "'") + .replaceAll("&", "&"); +} - // `val x = (project.findProperty("PROP") as String?) ?: "fallback"` — the - // gradle.properties entry wins at build time, so prefer it and fall back to - // the literal only when the property is absent. - for (const match of source.matchAll( - /\bval\s+([A-Za-z_][A-Za-z0-9_]*)\s*=\s*\(\s*project\.findProperty\(\s*"([^"]+)"\s*\)[^)]*\)\s*\?:\s*"([^"]+)"/gu, - )) { - locals.set(match[1], { property: match[2], fallback: match[3] }); - } +function xmlValue(source, tag) { + const match = source.match( + new RegExp(`<${tag}\\b[^>]*>([\\s\\S]*?)<\\/${tag}>`, "u"), + ); + return match ? decodeXml(match[1].trim()) : null; +} - return locals; +function encodePurlVersion(version) { + return encodeURIComponent(version).replaceAll("%3A", ":"); } -function readGradleProperties(root, manifest) { - const properties = new Map(); - const segments = manifest.split("/"); - // Walk from the module directory up to the repository root, mirroring how - // Gradle layers project and root properties. - for (let depth = segments.length - 1; depth > 0; depth -= 1) { - const candidate = [...segments.slice(0, depth), "gradle.properties"].join( - "/", - ); - let source; - try { - source = readText(root, candidate); - } catch { +function scanGradleSource(source) { + const commentFree = [...source]; + const structural = [...source]; + let state = "code"; + let blockDepth = 0; + + const mask = (index, commentsOnly = false) => { + if (source[index] !== "\n" && source[index] !== "\r") { + structural[index] = " "; + if (!commentsOnly) return; + commentFree[index] = " "; + } + }; + + for (let index = 0; index < source.length; index += 1) { + const pair = source.slice(index, index + 2); + const triple = source.slice(index, index + 3); + + if (state === "line-comment") { + mask(index, true); + if (source[index] === "\n" || source[index] === "\r") state = "code"; + continue; + } + if (state === "block-comment") { + if (pair === "/*") blockDepth += 1; + mask(index, true); + if (pair === "*/") { + mask(index + 1, true); + blockDepth -= 1; + index += 1; + if (blockDepth === 0) state = "code"; + } continue; } - for (const line of source.split("\n")) { - const match = line.match(/^\s*([\w.-]+)\s*=\s*(.+?)\s*$/u); - if (match && !properties.has(match[1])) { - properties.set(match[1], match[2]); + if (state === "raw-string") { + if (source[index] === '"') { + let quoteCount = 1; + while (source[index + quoteCount] === '"') quoteCount += 1; + for (let offset = 0; offset < quoteCount; offset += 1) { + mask(index + offset); + } + index += quoteCount - 1; + if (quoteCount >= 3) state = "code"; + } else { + mask(index); } + continue; + } + if (state === "string" || state === "character") { + mask(index); + if (source[index] === "\\") { + mask(index + 1); + index += 1; + } else if ( + (state === "string" && source[index] === '"') || + (state === "character" && source[index] === "'") + ) { + state = "code"; + } + continue; + } + + if (pair === "//") { + mask(index, true); + mask(index + 1, true); + index += 1; + state = "line-comment"; + } else if (pair === "/*") { + mask(index, true); + mask(index + 1, true); + index += 1; + blockDepth = 1; + state = "block-comment"; + } else if (triple === '\"\"\"') { + mask(index); + mask(index + 1); + mask(index + 2); + index += 2; + state = "raw-string"; + } else if (source[index] === '"') { + mask(index); + state = "string"; + } else if (source[index] === "'") { + mask(index); + state = "character"; } } - return properties; + + if (state === "block-comment" || state === "raw-string") { + throw new Error(`Unterminated Gradle ${state.replace("-", " ")}`); + } + return { + commentFree: commentFree.join(""), + structural: structural.join(""), + }; } -/** - * Resolve locals that a sibling module owns. - * - * The Godot Android plugin computes its coordinates from `openiap-versions.json` - * and from packages/google's build script. Reading the same files keeps the - * coordinate correct without duplicating a version into this table. - */ -function readExternalLocals(root, externalLocals = {}) { - const resolved = new Map(); - for (const [name, spec] of Object.entries(externalLocals)) { - if (spec.json) { - resolved.set(name, readJson(root, spec.file)[spec.json]); - } else if (spec.gradleLocal) { - const value = readGradleLocals(readText(root, spec.file)).get( - spec.gradleLocal, - ); - if (typeof value === "string") resolved.set(name, value); +function previousGradleCodeIndex(source, from) { + let cursor = from; + while (cursor >= 0 && /\s/u.test(source[cursor])) cursor -= 1; + return cursor; +} + +function gradleIdentifierBefore(source, from) { + const end = previousGradleCodeIndex(source, from) + 1; + let start = end; + while (start > 0 && /[A-Za-z0-9_]/u.test(source[start - 1])) start -= 1; + return source.slice(start, end); +} + +function gradleCallBefore(source, close) { + let depth = 1; + let cursor = close - 1; + for (; cursor >= 0 && depth > 0; cursor -= 1) { + if (source[cursor] === ")") depth += 1; + if (source[cursor] === "(") depth -= 1; + } + if (depth !== 0) throw new Error("Unbalanced Gradle call parentheses"); + return gradleIdentifierBefore(source, cursor); +} + +function gradleBlockOwner(source, open) { + const previous = previousGradleCodeIndex(source, open - 1); + return source[previous] === ")" + ? gradleCallBefore(source, previous) + : gradleIdentifierBefore(source, previous); +} + +function gradleOwningBlocks(source, end) { + const owners = []; + for (let index = 0; index < end; index += 1) { + if (source[index] === "{") owners.push(gradleBlockOwner(source, index)); + if (source[index] === "}") owners.pop(); + } + return owners; +} + +function gradleDependencySource(source, manifest) { + const { commentFree, structural } = scanGradleSource(source); + const braceDepths = new Uint32Array(structural.length); + let braceDepth = 0; + for (let index = 0; index < structural.length; index += 1) { + braceDepths[index] = braceDepth; + if (structural[index] === "{") braceDepth += 1; + if (structural[index] === "}") { + if (braceDepth === 0) throw new Error("Unbalanced Gradle block braces"); + braceDepth -= 1; } } - return resolved; + if (braceDepth !== 0) throw new Error("Unbalanced Gradle block braces"); + + const blocks = []; + const pattern = /\bdependencies\s*\{/gu; + for ( + let match = pattern.exec(structural); + match; + match = pattern.exec(structural) + ) { + const open = match.index + match[0].lastIndexOf("{"); + if (braceDepths[match.index] !== 0) { + const owners = gradleOwningBlocks(structural, match.index); + if (owners.includes("buildscript")) continue; + throw new Error(`Unsupported nested dependencies block in ${manifest}`); + } + let depth = 1; + let cursor = open + 1; + for (; cursor < structural.length && depth > 0; cursor += 1) { + if (structural[cursor] === "{") depth += 1; + if (structural[cursor] === "}") depth -= 1; + } + if (depth !== 0) { + throw new Error(`Unterminated Gradle dependencies block in ${manifest}`); + } + blocks.push( + filterGradleDependencyBlock( + commentFree.slice(open + 1, cursor - 1), + manifest, + ), + ); + pattern.lastIndex = cursor; + } + if (blocks.length === 0) { + throw new Error(`Missing Gradle dependencies block in ${manifest}`); + } + return { commentFree, dependencies: blocks.join("\n") }; } -function flattenLocals(locals, properties) { - const flat = new Map(); - for (const [name, value] of locals) { - if (typeof value === "string") { - flat.set(name, value); - } else if (value?.property) { - flat.set(name, properties.get(value.property) ?? value.fallback); +function filterGradleDependencyBlock(source, manifest) { + const { structural } = scanGradleSource(source); + const filtered = [...source]; + const visible = [true]; + + for (let index = 0; index < structural.length; index += 1) { + if (structural[index] === "{") { + const parentVisible = visible.at(-1); + let childVisible = false; + if (parentVisible) { + const previous = previousGradleCodeIndex(structural, index - 1); + const callOwned = structural[previous] === ")"; + const owner = callOwned + ? gradleCallBefore(structural, previous) + : gradleIdentifierBefore(structural, previous); + if (["if", "for", "when", "while", "else"].includes(owner)) { + childVisible = true; + } else if (owner === "constraints" && !callOwned) { + childVisible = false; + } else if (callOwned && owner === "add") { + childVisible = false; + } else if (callOwned) { + isRuntimeGradleConfiguration(owner); + childVisible = false; + } else { + throw new Error(`Unsupported Gradle block in ${manifest}`); + } + } + visible.push(childVisible); + filtered[index] = " "; + } else if (structural[index] === "}") { + if (visible.length === 1) + throw new Error("Unbalanced Gradle block braces"); + visible.pop(); + filtered[index] = " "; + } else if ( + !visible.at(-1) && + source[index] !== "\n" && + source[index] !== "\r" + ) { + filtered[index] = " "; } } - return flat; + if (visible.length !== 1) throw new Error("Unbalanced Gradle block braces"); + return filtered.join(""); } -function interpolateGradle(coordinate, locals) { - return coordinate.replace( - /\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?/gu, - (whole, name) => locals.get(name) ?? whole, +function topLevelGradleCalls(source) { + const { structural } = scanGradleSource(source); + const depths = new Uint32Array(structural.length); + let depth = 0; + for (let index = 0; index < structural.length; index += 1) { + depths[index] = depth; + if (structural[index] === "(") depth += 1; + if (structural[index] === ")") { + if (depth === 0) throw new Error("Unbalanced Gradle call parentheses"); + depth -= 1; + } + } + if (depth !== 0) throw new Error("Unbalanced Gradle call parentheses"); + + return [...structural.matchAll(/\b([A-Za-z][A-Za-z0-9]*)\s*\(/gu)] + .filter((match) => { + if (depths[match.index] !== 0) return false; + const lineStart = structural.lastIndexOf("\n", match.index - 1) + 1; + const prefix = structural.slice(lineStart, match.index); + return !/^\s*(?:(?:val|var)\s+)?[A-Za-z_][A-Za-z0-9_]*(?:\s*:[^=]+)?\s*=\s*$/u.test( + prefix, + ); + }) + .map((match) => ({ index: match.index, name: match[1], text: match[0] })); +} + +function isVersionConstraint(version) { + return ( + version === "any" || + /[\s*^~<>=|,[\](){}]/u.test(version) || + /\bx\b/iu.test(version) ); } -function parseMavenCoordinate(coordinate) { - const parts = coordinate.split(":"); - if (parts.length !== 3) return null; - const [group, artifact, version] = parts.map((part) => part.trim()); - if (!group || !artifact || !version) return null; - // An unresolved `$name` means the build computes this coordinate in a way - // this reader did not model. Returning null here would silently drop a real - // runtime dependency, so the caller escalates it instead. - if (coordinate.includes("$")) { - return { unresolved: coordinate }; +function dependencyEntry({ name, version, purl, properties = [] }) { + if (!name || !version || !purl) { + throw new Error( + `Incomplete dependency entry: ${JSON.stringify({ name, version, purl })}`, + ); } + const versionProperties = isVersionConstraint(version) + ? [{ name: "openiap:sbom:version-constraint", value: version }] + : []; return { - name: `${group}:${artifact}`, + name, version, - purl: `pkg:maven/${group}/${artifact}@${version}`, + purl, + ...(versionProperties.length > 0 || properties.length > 0 + ? { properties: [...versionProperties, ...properties] } + : {}), }; } -/** - * Remove a balanced `val Test by getting { ... }` source-set block. - * - * Kotlin Multiplatform declares test dependencies with the same - * `implementation(...)` configuration name as production ones; only the - * enclosing source set distinguishes them. - */ -function stripTestSourceSets(source) { - const opener = - /\bval\s+[A-Za-z0-9_]*[Tt]est[A-Za-z0-9_]*\s+by\s+getting\s*\{/gu; - let result = source; - let match; +/** Gradle `val name = "value"` locals used by the Godot release manifest. */ +function readGradleLocals(source) { + const locals = new Map(); + for (const match of source.matchAll( + /\bval\s+([A-Za-z_][A-Za-z0-9_]*)\s*=\s*"([^"]+)"/gu, + )) { + locals.set(match[1], match[2]); + } + return locals; +} - while ((match = opener.exec(result)) !== null) { - let depth = 1; - let index = match.index + match[0].length; - while (index < result.length && depth > 0) { - if (result[index] === "{") depth += 1; - else if (result[index] === "}") depth -= 1; - index += 1; +function readExternalLocals(root, externalLocals = {}) { + const resolved = new Map(); + for (const [name, spec] of Object.entries(externalLocals)) { + if (spec.json) { + const value = readJson(root, spec.file)[spec.json]; + if (value == null) { + throw new Error(`Missing ${spec.json} in ${spec.file}`); + } + resolved.set(name, String(value)); + continue; } - // The counter also sees braces inside strings and comments. If it never - // returns to zero, everything after this point would be discarded and the - // SBOM would silently lose real dependencies — the one failure mode this - // module exists to prevent. - if (depth !== 0) { - throw new Error( - `Unbalanced braces while removing a test source set at offset ${match.index}; ` + - `refusing to drop the remainder of the manifest.`, + if (spec.gradleLocal) { + const value = readGradleLocals(readText(root, spec.file)).get( + spec.gradleLocal, ); + if (!value) { + throw new Error( + `Missing Gradle local ${spec.gradleLocal} in ${spec.file}`, + ); + } + resolved.set(name, value); } - result = result.slice(0, match.index) + result.slice(index); - opener.lastIndex = 0; } - - return result; + return resolved; } -/** - * Expand `for (name in listOf("a", "b")) { ... $name ... }` bodies. - * - * packages/google declares the Horizon platform SDK modules this way, so - * without expansion three real runtime dependencies would be unresolvable. - */ -function expandGradleForLoops(source) { - return source.replace( - /\bfor\s*\(\s*([A-Za-z_][A-Za-z0-9_]*)\s+in\s+listOf\(([^)]*)\)\s*\)\s*\{([^{}]*)\}/gu, - (whole, variable, rawItems, body) => { - const items = [...rawItems.matchAll(/"([^"]+)"/gu)].map( - (item) => item[1], - ); - if (items.length === 0) return whole; - return items - .map((item) => - body.replace(new RegExp(`\\$\\{?${variable}\\}?`, "gu"), item), - ) - .join("\n"); - }, +function interpolateGradle(coordinate, locals) { + return coordinate.replace( + /\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?/gu, + (whole, name) => locals.get(name) ?? whole, ); } -/** - * Gradle configurations that place a dependency on the consumer's runtime - * classpath. `compileOnly` and every test configuration are deliberately absent. - */ +function parseMavenCoordinate(coordinate) { + const parts = coordinate.split(":").map((part) => part.trim()); + if (parts.length !== 3 || parts.some((part) => !part)) { + throw new Error(`Unsupported Maven coordinate '${coordinate}'`); + } + if (coordinate.includes("$")) { + throw new Error(`Unresolved Maven coordinate '${coordinate}'`); + } + const [group, artifact, version] = parts; + return dependencyEntry({ + name: `${group}:${artifact}`, + version, + purl: `pkg:maven/${group}/${artifact}@${encodePurlVersion(version)}`, + }); +} + const GRADLE_RUNTIME_CONFIGURATIONS = new Set([ "api", "implementation", "runtimeOnly", ]); -/** - * Configuration prefixes that never reach a consumer. These must be checked - * before the flavored-configuration pattern below, because `testImplementation` - * and `androidTestImplementation` both match it. - */ const GRADLE_NON_RUNTIME_PREFIXES = [ "test", "androidTest", @@ -222,60 +410,89 @@ function isRuntimeGradleConfiguration(configuration) { if ( GRADLE_NON_RUNTIME_PREFIXES.some((prefix) => configuration.startsWith(prefix), - ) + ) || + /(?:Test|CompileOnly|AnnotationProcessor|LintChecks)/u.test(configuration) ) { return false; } - // Flavored configurations such as `playApi` / `horizonImplementation`. - return /^[a-z][A-Za-z0-9]*(Api|Implementation|RuntimeOnly)$/u.test( - configuration, + throw new Error( + `Unclassified Gradle dependency configuration '${configuration}'`, ); } function extractGradle(root, { manifest, externalLocals }) { - const rawSource = readText(root, manifest); - const locals = flattenLocals( - readGradleLocals(rawSource), - readGradleProperties(root, manifest), + const source = readText(root, manifest); + const { commentFree, dependencies } = gradleDependencySource( + source, + manifest, ); + const locals = readGradleLocals(commentFree); for (const [name, value] of readExternalLocals(root, externalLocals)) { locals.set(name, value); } - const source = expandGradleForLoops(stripTestSourceSets(rawSource)); - const found = new Map(); - const unresolved = []; + for (const match of dependencies.matchAll( + /\b([A-Za-z][A-Za-z0-9]*)\s*\(\s*libs\./gu, + )) { + if (match[1] === "alias") continue; + throw new Error( + `Unsupported version-catalog dependency in ${manifest}; use published metadata instead`, + ); + } + + const found = new Map(); + const matchedDeclarations = new Set(); + const topLevelCalls = topLevelGradleCalls(dependencies); + const topLevelCallIndices = new Set(topLevelCalls.map((call) => call.index)); + let usesLocalOpeniapProject = false; const record = (configuration, rawCoordinate) => { if (!isRuntimeGradleConfiguration(configuration)) return; const parsed = parseMavenCoordinate( interpolateGradle(rawCoordinate, locals), ); - if (!parsed) return; - if (parsed.unresolved) { - unresolved.push(parsed.unresolved); - return; - } found.set(parsed.purl, parsed); }; - // implementation("group:artifact:version") - for (const match of source.matchAll( + for (const match of dependencies.matchAll( /\b([a-zA-Z][A-Za-z0-9]*)\s*\(\s*"([^"]+:[^"]+:[^"]+)"\s*\)/gu, )) { + if (!topLevelCallIndices.has(match.index)) continue; + matchedDeclarations.add(match.index); record(match[1], match[2]); } - - // add("playApi", "group:artifact:version") - for (const match of source.matchAll( + for (const match of dependencies.matchAll( /\badd\s*\(\s*"([^"]+)"\s*,\s*"([^"]+:[^"]+:[^"]+)"\s*\)/gu, )) { + if (!topLevelCallIndices.has(match.index)) continue; + matchedDeclarations.add(match.index); record(match[1], match[2]); } - if (unresolved.length > 0) { + for (const call of topLevelCalls) { + if (["if", "for", "when", "while"].includes(call.name)) continue; + if (matchedDeclarations.has(call.index)) continue; + if (call.name === "add") { + throw new Error( + `Unsupported Gradle dependency declaration in ${manifest}`, + ); + } + if (!isRuntimeGradleConfiguration(call.name)) continue; + const argument = dependencies.slice(call.index + call.text.length); + if (/^\s*project\s*\(\s*":openiap"\s*\)/u.test(argument)) { + usesLocalOpeniapProject = true; + continue; + } + throw new Error(`Unsupported Gradle dependency declaration in ${manifest}`); + } + + if ( + usesLocalOpeniapProject && + ![...found.values()].some( + (entry) => entry.name === "io.github.hyochan.openiap:openiap-google", + ) + ) { throw new Error( - `Unresolved Gradle coordinates in ${manifest}: ${[...new Set(unresolved)].join(", ")}. ` + - `Model the declaration in sbom-dependencies.mjs, or supply a resolver export with --resolved.`, + `Local :openiap dependency in ${manifest} lacks its published fallback`, ); } @@ -284,57 +501,93 @@ function extractGradle(root, { manifest, externalLocals }) { ); } -function parseVersionCatalog(source) { - const versions = new Map(); - const libraries = new Map(); - let section = ""; +function resolveMavenValue(value, properties, context) { + let resolvedValue = value; + for ( + let attempt = 0; + attempt < 10 && resolvedValue.includes("${"); + attempt += 1 + ) { + const next = resolvedValue.replace(/\$\{([^}]+)\}/gu, (whole, name) => { + if (name === "project.version" || name === "pom.version") { + return context.version; + } + return properties.get(name) ?? whole; + }); + if (next === resolvedValue) break; + resolvedValue = next; + } + if (resolvedValue.includes("${")) { + throw new Error(`Unresolved Maven value '${value}' in ${context.url}`); + } + return resolvedValue; +} - for (const rawLine of source.split("\n")) { - const line = rawLine.trim(); - if (line.startsWith("#") || line === "") continue; - const sectionMatch = line.match(/^\[([^\]]+)\]$/u); - if (sectionMatch) { - section = sectionMatch[1]; - continue; - } - if (section === "versions") { - const match = line.match(/^([\w.-]+)\s*=\s*"([^"]+)"/u); - if (match) versions.set(match[1], match[2]); - continue; - } - if (section === "libraries") { - const match = line.match(/^([\w.-]+)\s*=\s*\{(.+)\}/u); - if (!match) continue; - const module = match[2].match(/module\s*=\s*"([^"]+)"/u)?.[1]; - const versionRef = match[2].match(/version\.ref\s*=\s*"([^"]+)"/u)?.[1]; - const literal = match[2].match(/version\s*=\s*"([^"]+)"/u)?.[1]; - if (module) libraries.set(match[1], { module, versionRef, literal }); +function parseMavenPom(source, context) { + const properties = new Map(); + const propertiesBlock = source.match( + /([\s\S]*?)<\/properties>/u, + )?.[1]; + if (propertiesBlock) { + for (const match of propertiesBlock.matchAll( + /<([A-Za-z_][\w.-]*)>([^<]+)<\/\1>/gu, + )) { + properties.set(match[1], decodeXml(match[2].trim())); } } - return { versions, libraries }; -} - -/** `libs.kotlinx.coroutines.core` -> catalog alias `kotlinx-coroutines-core`. */ -function catalogAliasFromAccessor(accessor) { - return accessor.replace(/\./gu, "-"); -} + const profiles = source.match(/([\s\S]*?)<\/profiles>/u)?.[1]; + if (profiles && /[\s\S]*?<\/dependencyManagement>/gu, "") + .replace(/[\s\S]*?<\/profiles>/gu, "") + .replace(/[\s\S]*?<\/build>/gu, ""); const found = new Map(); - for (const match of source.matchAll( - /\b([a-zA-Z][A-Za-z0-9]*)\s*\(\s*libs\.([A-Za-z0-9.]+)\s*\)/gu, + for (const match of withoutManaged.matchAll( + /([\s\S]*?)<\/dependency>/gu, )) { - if (!isRuntimeGradleConfiguration(match[1])) continue; - const entry = libraries.get(catalogAliasFromAccessor(match[2])); - if (!entry) continue; - const version = entry.literal ?? versions.get(entry.versionRef); - if (!version) continue; - const parsed = parseMavenCoordinate(`${entry.module}:${version}`); - if (parsed) found.set(parsed.purl, parsed); + const block = match[1]; + const scope = xmlValue(block, "scope") ?? "compile"; + const optional = xmlValue(block, "optional")?.toLowerCase() === "true"; + if ( + !["compile", "runtime", "test", "provided", "system", "import"].includes( + scope, + ) + ) { + throw new Error(`Unsupported Maven scope '${scope}' in ${context.url}`); + } + if (["test", "provided", "system", "import"].includes(scope) || optional) { + continue; + } + + const group = xmlValue(block, "groupId"); + const artifact = xmlValue(block, "artifactId"); + const rawVersion = xmlValue(block, "version"); + if (!group || !artifact || !rawVersion) { + throw new Error(`Incomplete runtime dependency in ${context.url}`); + } + const version = resolveMavenValue(rawVersion, properties, context); + const qualifiers = []; + const type = xmlValue(block, "type"); + const classifier = xmlValue(block, "classifier"); + if (type && type !== "jar") qualifiers.push(["type", type]); + if (classifier) qualifiers.push(["classifier", classifier]); + const qualifier = qualifiers.length + ? `?${qualifiers + .sort(([left], [right]) => left.localeCompare(right)) + .map(([name, value]) => `${name}=${encodeURIComponent(value)}`) + .join("&")}` + : ""; + const entry = dependencyEntry({ + name: `${group}:${artifact}`, + version, + purl: `pkg:maven/${group}/${artifact}@${encodePurlVersion(version)}${qualifier}`, + }); + found.set(entry.purl, entry); } return [...found.values()].sort((left, right) => @@ -342,51 +595,51 @@ function extractGradleCatalog(root, { manifest, catalog }) { ); } -function readMsBuildProperties(root, propertyFiles) { - const properties = new Map(); - for (const file of propertyFiles) { - let source; - try { - source = readText(root, file); - } catch { - continue; +async function extractMavenPom(_root, source, context) { + const coordinates = source.coordinates ?? [source.coordinate]; + const failures = []; + for (const coordinate of coordinates) { + const [group, artifact] = String(coordinate).split(":"); + if (!group || !artifact || coordinate.split(":").length !== 2) { + throw new Error(`Invalid published Maven coordinate '${coordinate}'`); } - for (const match of source.matchAll( - /<([A-Za-z_][\w.-]*)>([^<>$]+)<\/\1>/gu, - )) { - properties.set(match[1], match[2].trim()); + const path = `${group.replaceAll(".", "/")}/${artifact}/${context.version}/${artifact}-${context.version}.pom`; + for (const repository of source.repositories) { + const url = `${repository.replace(/\/$/u, "")}/${path}`; + const document = await context.fetchText(url); + if (document) return parseMavenPom(document, { ...context, url }); + failures.push(url); } } - return properties; -} - -function interpolateMsBuild(value, properties) { - return value.replace( - /\$\(([A-Za-z_][\w.-]*)\)/gu, - (whole, name) => properties.get(name) ?? whole, + throw new PublishedMetadataUnavailableError( + `Published POM not found: ${failures.join(", ")}`, ); } -function extractNuget(root, { manifest, propertyFiles = [] }) { - const source = readText(root, manifest); - const properties = readMsBuildProperties(root, [...propertyFiles, manifest]); - const found = new Map(); +function parseNugetNuspec(source, context) { + const dependenciesBlock = source.match( + /([\s\S]*?)<\/dependencies>/u, + )?.[1]; + if (!dependenciesBlock) return []; - for (const match of source.matchAll(/]*)\/?>/gu)) { + const found = new Map(); + for (const match of dependenciesBlock.matchAll( + /]*)\/?>(?:<\/dependency>)?/gu, + )) { const attributes = match[1]; - const name = attributes.match(/\bInclude\s*=\s*"([^"]+)"/u)?.[1]; - const rawVersion = attributes.match(/\bVersion\s*=\s*"([^"]+)"/u)?.[1]; - if (!name || !rawVersion) continue; - - // PrivateAssets="all" means the reference is not propagated to consumers - // of the produced package, so it is a build input rather than a runtime - // dependency of the shipped artifact. - if (/\bPrivateAssets\s*=\s*"all"/iu.test(attributes)) continue; - - const version = interpolateMsBuild(rawVersion, properties); - if (version.includes("$")) continue; - const purl = `pkg:nuget/${name}@${version}`; - found.set(purl, { name, version, purl }); + const name = attributes.match(/\bid\s*=\s*"([^"]+)"/iu)?.[1]; + const version = attributes.match(/\bversion\s*=\s*"([^"]+)"/iu)?.[1]; + if (!name || !version) { + throw new Error( + `Incomplete published NuGet dependency in ${context.url}`, + ); + } + const entry = dependencyEntry({ + name: decodeXml(name), + version: decodeXml(version), + purl: `pkg:nuget/${encodeURIComponent(decodeXml(name))}@${encodePurlVersion(decodeXml(version))}`, + }); + found.set(entry.purl.toLowerCase(), entry); } return [...found.values()].sort((left, right) => @@ -394,13 +647,28 @@ function extractNuget(root, { manifest, propertyFiles = [] }) { ); } +async function extractNugetNuspec(_root, source, context) { + const packageId = source.packageId.toLowerCase(); + const version = context.version.toLowerCase(); + const url = + `https://api.nuget.org/v3-flatcontainer/${packageId}/${version}/` + + `${packageId}.nuspec`; + const document = await context.fetchText(url); + if (!document) { + throw new PublishedMetadataUnavailableError( + `Published nuspec not found: ${url}`, + ); + } + return parseNugetNuspec(document, { ...context, url }); +} + function extractPub(root, { manifest }) { - const source = readText(root, manifest); - const lines = source.split("\n"); + const lines = readText(root, manifest).split("\n"); const found = new Map(); let inDependencies = false; - for (const line of lines) { + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index]; if (/^[A-Za-z_]+:/u.test(line)) { inDependencies = line.startsWith("dependencies:"); continue; @@ -411,16 +679,19 @@ function extractPub(root, { manifest }) { if (!match) continue; const [, name, rawConstraint] = match; const constraint = rawConstraint.trim(); - // `flutter: sdk: flutter` is the SDK itself, not a pub.dev package. - if (constraint === "") continue; - const version = constraint.replace(/^[\^~><= ]+/u, "").trim(); - if (!version) continue; - found.set(name, { + if (!constraint) { + const nested = lines[index + 1]?.trim(); + if (name === "flutter" && nested === "sdk: flutter") continue; + throw new Error( + `Unsupported nested pub dependency '${name}' in ${manifest}`, + ); + } + const entry = dependencyEntry({ name, - version, - purl: `pkg:pub/${name}@${version}`, - scope: "required", + version: constraint, + purl: `pkg:pub/${name}@${encodePurlVersion(constraint)}`, }); + found.set(entry.purl, entry); } return [...found.values()].sort((left, right) => @@ -429,84 +700,86 @@ function extractPub(root, { manifest }) { } function extractNpm(root, { manifest }) { - const packageJson = readJson(root, manifest); - const dependencies = packageJson.dependencies ?? {}; + const dependencies = readJson(root, manifest).dependencies ?? {}; return Object.entries(dependencies) - .map(([name, range]) => ({ - name, - version: String(range) - .replace(/^[\^~><= ]+/u, "") - .trim(), - purl: `pkg:npm/${name}@${String(range) - .replace(/^[\^~><= ]+/u, "") - .trim()}`, - })) + .map(([name, rawVersion]) => { + const version = String(rawVersion).trim(); + if (!version || /^(?:file|git|github|https?|workspace):/u.test(version)) { + throw new Error( + `Unsupported npm dependency '${name}@${version}' in ${manifest}`, + ); + } + const encodedName = name.startsWith("@") + ? `${encodeURIComponent(name.split("/")[0])}/${name.split("/").slice(1).join("/")}` + : name; + return dependencyEntry({ + name, + version, + purl: `pkg:npm/${encodedName}@${encodePurlVersion(version)}`, + }); + }) .sort((left, right) => left.name.localeCompare(right.name)); } function extractSwift(root, { manifest }) { const source = readText(root, manifest); const found = new Map(); + const declarations = [...source.matchAll(/\.package\s*\(/gu)].length; + let matched = 0; for (const match of source.matchAll( /\.package\s*\(\s*url:\s*"([^"]+)"[^)]*?(?:from|exact):\s*"([^"]+)"/gu, )) { + matched += 1; const url = match[1]; const version = match[2]; - const name = - url - .replace(/\.git$/u, "") - .split("/") - .pop() ?? url; - const owner = - url - .replace(/\.git$/u, "") - .split("/") - .at(-2) ?? ""; - found.set(url, { + const parts = url.replace(/\.git$/u, "").split("/"); + const name = parts.at(-1) ?? url; + const owner = parts.at(-2) ?? ""; + const entry = dependencyEntry({ name, version, - purl: `pkg:swift/github.com/${owner}/${name}@${version}`, + purl: `pkg:swift/github.com/${owner}/${name}@${encodePurlVersion(version)}`, }); + found.set(entry.purl, entry); + } + if (matched !== declarations) { + throw new Error(`Unsupported Swift package declaration in ${manifest}`); } return [...found.values()].sort((left, right) => left.name.localeCompare(right.name), ); } -/** No dependency manifest: the component ships no third-party runtime code. */ function extractNone() { return []; } const EXTRACTORS = { gradle: extractGradle, - "gradle-catalog": extractGradleCatalog, + "maven-pom": extractMavenPom, npm: extractNpm, none: extractNone, - nuget: extractNuget, + "nuget-nuspec": extractNugetNuspec, pub: extractPub, swift: extractSwift, }; -export function extractDirectDependencies(root, source) { +export async function extractDirectDependencies(root, source, context = {}) { const extractor = EXTRACTORS[source.kind]; if (!extractor) { throw new Error(`Unsupported dependency source kind: ${source.kind}`); } - return extractor(root, source); + return extractor(root, source, context); } -/** - * Fold an ecosystem resolver export into the direct dependency list. - * - * The resolver output is the only place a full transitive closure can come - * from, and only a release runner with that ecosystem's toolchain can produce - * it. Entries already present as direct dependencies keep their direct scope. - */ export function mergeResolved(direct, resolvedEntries) { const merged = new Map(direct.map((entry) => [entry.purl, { ...entry }])); for (const entry of resolvedEntries) { - if (!entry?.purl) continue; + if (!entry?.name || !entry?.version || !entry?.purl) { + throw new Error( + `Incomplete resolved dependency: ${JSON.stringify(entry)}`, + ); + } if (merged.has(entry.purl)) continue; merged.set(entry.purl, { ...entry, transitive: true }); } @@ -516,15 +789,11 @@ export function mergeResolved(direct, resolvedEntries) { } export const __testing = { - expandGradleForLoops, extractGradle, - extractGradleCatalog, extractNpm, - extractNuget, extractPub, - extractSwift, isRuntimeGradleConfiguration, parseMavenCoordinate, - parseVersionCatalog, - stripTestSourceSets, + parseMavenPom, + parseNugetNuspec, }; diff --git a/security/CRA.md b/security/CRA.md index b115c732f..3d5ec7982 100644 --- a/security/CRA.md +++ b/security/CRA.md @@ -100,9 +100,11 @@ Where this document and the regulation disagree, the regulation governs. **Expectation:** maintain a machine-readable inventory of the components a product contains. -**How OpenIAP does this:** a CycloneDX 1.6 SBOM is generated for every -published release of every releasable component and attached to its GitHub -Release. Generation records the release and generator commits, and the core +**How OpenIAP does this:** each current component release workflow creates the +GitHub Release, then dispatches `sbom.yml` because a release created with +`GITHUB_TOKEN` does not trigger another workflow. The SBOM workflow generates +and uploads the CycloneDX 1.6 asset; a daily scan repairs missed latest-release +assets. Generation records the release and generator commits, and the core dependency inventory is reproducible from those inputs. Registry-sourced license and supplier metadata is point-in-time enrichment. @@ -135,12 +137,14 @@ through the workflows in `.github/workflows/`, and each produces a new SBOM. **Expectation:** be able to reproduce and evidence how a release was produced. -**How OpenIAP does this** — for any published release, these are recoverable: +**How OpenIAP does this** — for each current release carrying an SBOM, these are +recoverable. Older releases without a backfilled asset retain their immutable +tag and published descriptors as the evidence source. | Question | Where the answer is | | ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | | What source produced this release? | Immutable release tag; `scripts/assert-release-tag.mjs` enforces that the tag matches the published version and is reachable from `main` | -| What dependencies went into it? | The `.cdx.json` SBOM asset on that release | +| What direct dependencies did it declare? | The `.cdx.json` SBOM asset on that release | | Which SBOM version corresponds to it? | SBOM filename and `metadata.component.version`; the workflow refuses to upload on a mismatch | | Which workflow generated it? | The provenance attestation on the SBOM, verifiable with `gh attestation verify` | | Which commit was it built from? | `openiap:release:commit` property inside the SBOM, and the attestation subject | diff --git a/security/README.md b/security/README.md index 377d8e847..71323c78a 100644 --- a/security/README.md +++ b/security/README.md @@ -4,13 +4,13 @@ This directory documents how OpenIAP secures what it ships. It holds policy and the reasoning behind it; the automation lives in `scripts/` and `.github/workflows/`, and no generated artifact is stored here. -| Document | Covers | -| ---------------------------------- | --------------------------------------------------------------------------- | -| [SBOM.md](SBOM.md) | Per-release dependency inventories: scope, format, generation, verification | -| [vex/](vex/README.md) | Recorded judgements on whether a known CVE actually affects a component | -| [CRA.md](CRA.md) | How these practices map to EU Cyber Resilience Act expectations | -| [openchain.md](openchain.md) | Self-assessment against ISO/IEC 18974 and 5230, with the current gap list | -| [`../SECURITY.md`](../SECURITY.md) | Vulnerability reporting, disclosure, supported versions | +| Document | Covers | +| ---------------------------------- | ------------------------------------------------------------------------------- | +| [SBOM.md](SBOM.md) | Current-release dependency inventories: scope, format, generation, verification | +| [vex/](vex/README.md) | Recorded judgements on whether a known CVE actually affects a component | +| [CRA.md](CRA.md) | How these practices map to EU Cyber Resilience Act expectations | +| [openchain.md](openchain.md) | Self-assessment against ISO/IEC 18974 and 5230, with the current gap list | +| [`../SECURITY.md`](../SECURITY.md) | Vulnerability reporting, disclosure, supported versions | Vulnerability reporting stays at the repository root, where GitHub and most contributors look for it. @@ -21,9 +21,10 @@ contributors look for it. OpenIAP source │ ▼ - dependency manifests - (package.json, gradle, - csproj, pubspec, spm) + dependency inputs + (published POM/nuspec, + package.json, pubspec, + Gradle, SwiftPM) │ ▼ CI (ci.yml) @@ -42,7 +43,7 @@ contributors look for it. package npm/registry GitHub Release provenance │ ▼ - sbom.yml (release: published) + release dispatches sbom.yml │ ┌─────────┴─────────┐ ▼ ▼ @@ -54,7 +55,7 @@ contributors look for it. | Capability | Mechanism | | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Per-release SBOM | `scripts/generate-sbom.mjs` + `.github/workflows/sbom.yml` — [CycloneDX 1.6](https://cyclonedx.org/specification/overview/) | +| Current-release SBOM | `scripts/generate-sbom.mjs` + `.github/workflows/sbom.yml` — [CycloneDX 1.6](https://cyclonedx.org/specification/overview/) | | SBOM provenance | [`actions/attest-build-provenance`](https://github.com/actions/attest-build-provenance) — [SLSA](https://slsa.dev/provenance/v1) via [Sigstore](https://www.sigstore.dev/), verifiable with `gh attestation verify` | | npm artifact provenance | `npm publish --provenance`, re-verified by `scripts/verify-npm-release-provenance.mjs` | | Release-tag integrity | `scripts/assert-release-tag.mjs` — immutable tags, version must match, reachable from `main` | @@ -81,8 +82,9 @@ Dockerfile. That is a deliberate scope, not an oversight: dependencies deliberately, often with compatibility constraints documented inline in the build files. Automated bumps there tend to break consumers' toolchain compatibility rather than help; their versions are reviewed as part - of platform upgrade work, and the SBOMs record exactly what each release - shipped. + of platform upgrade work. Each current release SBOM records its published + direct dependency contract; a toolchain resolver export can add transitive + entries for a consuming application. ## What GitHub's dependency graph does and does not see @@ -90,7 +92,7 @@ Worth stating plainly, because it explains why the SBOMs are not redundant with the platform: ```bash -gh api repos/hyodotdev/openiap/dependency-graph/sbom # → 0 packages +gh api repos/hyodotdev/openiap/dependency-graph/sbom # → HTTP 404 ``` GitHub's [dependency graph](https://docs.github.com/en/code-security/supply-chain-security/understanding-your-software-supply-chain/dependency-graph-supported-package-ecosystems) @@ -105,8 +107,9 @@ Consequences: - **Dependabot security alerts depend on the dependency graph**, so alert coverage is limited to what the graph can populate. Do not read an empty alert list as "no vulnerable dependencies". -- **The published SBOMs are the only complete inventory** of what each release - contains. +- **The published SBOMs are the release-specific inventory** of direct runtime + dependencies. A transitive closure is included only when a resolver export is + supplied. Closing this gap properly would mean submitting a snapshot through the [dependency submission API](https://docs.github.com/en/rest/dependency-graph/dependency-submission), @@ -121,9 +124,10 @@ not belong to whichever change surfaced it. | ---------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Actions are pinned by major tag, not commit SHA** | A mutable tag in a privileged publish or signing path is a supply-chain risk. Fixing it means pinning every workflow at once and reconfiguring Dependabot, not two workflows in isolation | | **Several CI workflows declare no `permissions:` block** | They inherit the repository default instead of least privilege. OpenSSF Scorecard's Token-Permissions check reports this | -| **GitHub's dependency graph is empty for this repository** | Bun lockfiles are unsupported and Gradle is not resolved from source, so Dependabot security alerts cannot cover the tree. Closing it needs the dependency submission API | +| **GitHub's dependency-graph SBOM endpoint is unavailable** | The repository endpoint returns HTTP 404, so it cannot provide an independent inventory. Closing the coverage gap requires dependency submission or another supported manifest path | -`/audit-security` re-checks each of these and prints the current state. +`/audit-security` re-checks each gap and prints its current state. Action pinning +still requires a separate repository-wide migration. ## Scanning posture @@ -132,7 +136,9 @@ OpenIAP does not run a separate vulnerability scanner [Grype](https://github.com/anchore/grype), [osv-scanner](https://github.com/google/osv-scanner)) in CI today: -- The published SDKs have no runtime dependency tree for a scanner to examine. +- The published npm SDKs have no runtime dependency tree for a scanner to + examine. Native and framework dependency inventories are carried in their + release SBOMs. - `packages/kit`'s dependencies are the meaningful surface, and Dependabot already opens update pull requests for them. - A second scanner would add alert triage and CI maintenance for a signal we @@ -150,5 +156,7 @@ the point to revisit it. `scripts/generate-sbom.test.mjs` asserts that every component in the release SSOT has SBOM metadata, so CI fails if a new component is added without it. To satisfy it, add an entry to `COMPONENTS` in `scripts/generate-sbom.mjs` -declaring the component's distribution and where its dependencies are declared. -Nothing else needs to change — `sbom.yml` picks it up from the release tag. +declaring the component's distribution and dependency source. Tag aliases are +derived from the release configuration. The generator test also requires every +workflow that creates a GitHub Release to dispatch `sbom.yml`, so a new release +lane cannot silently omit the inventory. diff --git a/security/SBOM.md b/security/SBOM.md index a8281990a..ae630953e 100644 --- a/security/SBOM.md +++ b/security/SBOM.md @@ -2,14 +2,16 @@ ## Purpose -Every OpenIAP release ships a machine-readable inventory of the third-party -code it contains. That inventory exists so a consumer — or a maintainer -responding to a new advisory — can answer one question without reading our -build scripts: _does this version of this package contain the vulnerable -dependency?_ +Current OpenIAP release workflows attach a machine-readable inventory of direct +third-party dependencies, and a daily repair job fills missed latest-release +assets. That inventory lets a consumer — or a maintainer responding to a new +advisory — identify the released dependency contract without reconstructing it +from build scripts. Exact application exposure still comes from the consumer's +resolved dependency graph. -SBOMs are generated from the same manifests the build reads. No one edits an -SBOM by hand, and none are committed to the repository. +SBOMs are generated from the released manifest or the registry descriptor that +consumers resolve. No one edits an SBOM by hand, and none are committed to the +repository. ## Scope @@ -22,7 +24,7 @@ cannot be released without also being described: | Component | SBOM name | Distribution | Release tag | | -------------- | ------------------------ | -------------------------------- | ------------------------------- | -| `apple` | `openiap-apple` | CocoaPods, Swift Package Manager | `` | +| `apple` | `openiap` | CocoaPods, Swift Package Manager | `` | | `google` | `openiap-google` | Maven Central | `google-` | | `react-native` | `react-native-iap` | npm | `react-native-iap-` | | `expo` | `expo-iap` | npm | `expo-iap-` | @@ -66,8 +68,8 @@ code, no external binary. It is plain ESM run by the Node version already pinned in CI. This is deliberate: a tool that reports what you depend on should not quietly add dependencies of its own. -**At generation time** it reads package registries over HTTPS, and only to -resolve declared licenses: +**At generation time** it reads package registries over HTTPS for the published +dependency descriptors and for declared licenses: | Registry | Used for | | ------------------------------------------------- | --------------------------- | @@ -76,8 +78,9 @@ resolve declared licenses: | [nuget.org](https://www.nuget.org/) | NuGet `.nuspec` | | [registry.npmjs.org](https://registry.npmjs.org/) | npm package metadata | -A registry failure degrades to a missing license field; it never blocks a -release. +Failure to read a published POM or nuspec blocks generation because the +dependency inventory would be incomplete. A license lookup failure degrades to +a missing license field and does not block the release. **In CI**, `.github/workflows/sbom.yml` uses these actions: @@ -88,7 +91,8 @@ release. | [`actions/attest-build-provenance`](https://github.com/actions/attest-build-provenance) | Sign SLSA provenance | MIT | | [`gh` CLI](https://cli.github.com/) | Upload the release asset | MIT | -Action versions are pinned in the workflow and kept current by Dependabot. +Action versions use reviewed major-version tags and are kept current by +Dependabot. Commit-SHA pinning remains a documented repository-wide gap. ## Naming convention @@ -104,9 +108,8 @@ openiap-conformance-1.0.0.cdx.json openiap-google-3.3.0.cdx.json ``` -The name and version always equal the published package's own name and -version, so a released artifact and its SBOM can be matched without a lookup -table. +The name and version equal the published package's own name and version, so a +released artifact and its SBOM can be matched without a lookup table. ## Generation @@ -120,13 +123,18 @@ bun run sbom resolve-tag # which component does this tag belong The generator (`scripts/generate-sbom.mjs`) reads: -| Ecosystem | Dependency source | -| --------- | -------------------------------------------------------------------- | -| npm | `package.json` (`dependencies`) | -| Gradle | `build.gradle.kts`, `gradle.properties`, `gradle/libs.versions.toml` | -| NuGet | `*.csproj`, `Directory.Build.props` | -| pub | `pubspec.yaml` | -| Swift | `Package.swift` | +| Ecosystem | Dependency source | +| -------------- | ----------------------------------------------------------- | +| npm | Released `package.json` (`dependencies`) | +| Maven / Gradle | Published POM for the selected consumer artifact | +| NuGet | Published nuspec dependency groups | +| pub | Released `pubspec.yaml`; constraints are preserved verbatim | +| Godot / Gradle | Released addon `build.gradle.kts` | +| Swift | Released `Package.swift` | + +The MAUI inventory is the union of the published nuspec's target-framework +groups. Use the consuming application's resolved graph to narrow dependencies +to Android, iOS, or Mac Catalyst. ### What is included @@ -149,12 +157,21 @@ transitive dependencies when a resolver export is supplied (see below). application's SBOM, not ours. - **Operating-system frameworks.** StoreKit is not a distributed package. +### Version constraints + +Library manifests sometimes publish a version constraint rather than one exact +resolved version. The SBOM preserves that constraint verbatim and marks it with +`openiap:sbom:version-constraint`; it never rewrites the lower bound as though +that were the version every consumer installs. An application SBOM should use +its lockfile or ecosystem resolver when exact CVE matching is required. + ### Licenses `--with-licenses` resolves each dependency's declared license from its own registry — Maven Central and Google's Maven repository for Maven coordinates, nuget.org for NuGet, registry.npmjs.org for npm. The release workflow passes -this flag; local runs default to offline. +this flag. Maven and NuGet component generation still reads the published +dependency descriptor when license enrichment is disabled. Licenses are never guessed. A registry value is emitted as an SPDX identifier only when it is a recognised one; anything else is recorded as a free-text @@ -177,9 +194,10 @@ number would go stale the next time a dependency changes. ### Transitive dependencies -Direct dependencies are read from the manifest. A complete transitive closure -requires the ecosystem's own resolver, which only a runner with that toolchain -can produce. When such an export is available it is merged in: +Direct dependencies are read from the published descriptor or released +manifest. A complete transitive closure requires the ecosystem's own resolver, +which only a runner with that toolchain can produce. When such an export is +available it is merged in: ```bash bun run sbom google --resolved gradle-dependencies.json @@ -187,54 +205,42 @@ bun run sbom google --resolved gradle-dependencies.json The file is a JSON array (or `{"components": [...]}`) of `{name, version, purl}` entries. Merged entries are marked with an `openiap:sbom:relationship` -property of `transitive`, and the component's `dependsOn` list continues to -name only its direct dependencies. +property of `transitive`; all entries remain reachable from the root dependency +graph. -Where a manifest declares a coordinate this reader cannot resolve, generation +Where an input declares a dependency this reader cannot resolve, generation **fails** rather than emitting a shorter list. An SBOM that silently omits a dependency is worse than no SBOM, because it is trusted. -#### Planned: replace manifest parsing with resolver output - -The Gradle, NuGet, and pub readers in `scripts/sbom-dependencies.mjs` parse -build manifests with regular expressions. That is a deliberate stopgap, and it -is the one part of this system expected to need maintenance: `build.gradle.kts` -is arbitrary Kotlin, so new declaration shapes will keep appearing. Two already -did — `for (module in listOf(...))` expansion and `project.findProperty(...)` -resolution. +#### Published dependency metadata -**Do not keep growing the parsers.** When transitive support is implemented, -move these ecosystems onto their own resolvers instead: +Maven and NuGet inventories are read from the POM or nuspec consumers resolve, +not reconstructed from conditional build scripts. This makes the inventory +flavor-aware and includes dependencies injected by the publishing toolchain, +such as Kotlin's standard library. Pub constraints remain constraints because a +library release does not choose the application's eventual resolved version. -| Ecosystem | Replace parser with | -| --------- | -------------------------------------------------------------- | -| Gradle | `cyclonedx-gradle-plugin`, or `gradlew ::dependencies` | -| NuGet | `dotnet list package --include-transitive --format json` | -| pub | `flutter pub deps --json` | +The KMP release spans platform variants. Its release SBOM uses the published +`io.github.hyochan:kmp-iap-android-play` POM so the Android dependency on +`openiap-google` is not omitted. An iOS-only consumer should use its resolved +application graph for target-specific dependencies. -That removes roughly 350 lines of parsing and delivers the transitive closure -in the same change — the ecosystem readers shrink rather than grow. It was not -done in the initial implementation because no JDK, Flutter, or .NET toolchain -was available to verify the result, and unverified code in a release path is -worse than a verified stopgap. - -Until then, the parsers are safe to rely on for one reason: a coordinate they -cannot resolve raises an error instead of being dropped, and the tests read the -real manifests in this repository, so drift fails CI rather than silently -shortening an inventory. +The small Godot Gradle reader remains because its GitHub release is the artifact +of record. It rejects unknown configurations, unresolved coordinates, catalog +accessors, and unsupported coordinate shapes instead of silently dropping them. ## Release integration -`.github/workflows/sbom.yml` runs on `release: published` and on manual -dispatch. It does not modify the existing release workflows; it reacts to the -releases they create, so every component — including ones added later — is -covered by the same code path. +Every component release workflow dispatches `.github/workflows/sbom.yml` after +creating its GitHub Release. This explicit dispatch is required because a +release created with `GITHUB_TOKEN` does not trigger another workflow. A scan on +SBOM changes and a daily schedule dispatch any missing newest-component asset. ```text release workflow → GitHub Release published - │ + │ explicit workflow_dispatch ▼ - sbom.yml (release: published) + sbom.yml │ ┌───────────────┼───────────────┐ ▼ ▼ ▼ @@ -252,7 +258,11 @@ release commit, and recorded generator commit match its inputs, and that no local filesystem path leaked into the document. Any mismatch fails the run. Tags that do not belong to a component are skipped with a notice rather than -failing. +failing. A duplicate dispatch preserves an existing SBOM. The repair scan +recognizes the exact digest of the inaccurate Google 3.3.0 asset produced by the +retired source-manifest reader and replaces that asset once. It uploads and +verifies the corrected document under a temporary name before removing the +legacy asset; no other existing asset is overwritten. ## Storage location @@ -273,7 +283,8 @@ Any consumer can independently verify a published SBOM: ```bash # 1. Download the SBOM from its release -gh release download react-native-iap-16.3.0 -p '*.cdx.json' +gh release download react-native-iap-16.3.0 \ + --repo hyodotdev/openiap -p '*.cdx.json' # 2. Confirm this repository's CI produced it gh attestation verify react-native-iap-16.3.0.cdx.json \ @@ -305,9 +316,13 @@ workflow uses live registries to enrich dependencies with licenses and suppliers, so those fields are point-in-time metadata and are not guaranteed to be byte-identical later. +The example below reproduces the corrected Google 3.3.0 asset from its recorded +generator commit. The one-time repair replaces the known inaccurate legacy +digest with the published-POM inventory before this procedure is used. + ```bash -RELEASE_TAG=react-native-iap-16.3.0 -PUBLISHED_SBOM=/absolute/path/react-native-iap-16.3.0.cdx.json +RELEASE_TAG=google-3.3.0 +PUBLISHED_SBOM=/absolute/path/openiap-google-3.3.0.cdx.json GENERATOR_COMMIT=$(jq -r ' .metadata.tools.components[] | select(.name == "openiap-sbom-generator") @@ -334,7 +349,7 @@ git -C "$SBOM_REPRO_DIR" checkout "$GENERATOR_COMMIT" -- \ jq '(.components[]? |= del(.licenses, .supplier))' \ "$PUBLISHED_SBOM" > /tmp/published-core.json jq '(.components[]? |= del(.licenses, .supplier))' \ - "$SBOM_REPRO_DIR/sbom/react-native-iap-16.3.0.cdx.json" \ + "$SBOM_REPRO_DIR/sbom/openiap-google-3.3.0.cdx.json" \ > /tmp/generated-core.json diff /tmp/published-core.json /tmp/generated-core.json git worktree remove --force "$SBOM_REPRO_DIR" @@ -342,14 +357,16 @@ git worktree remove --force "$SBOM_REPRO_DIR" ## Update policy -- An SBOM is produced for every published release, automatically. -- SBOMs are **immutable once published**, exactly like the release tag they - belong to. A dependency change ships as a new release with a new SBOM; a - published SBOM is never edited in place. +- Every current release workflow produces an SBOM automatically. +- SBOMs are **immutable once published**. See + [Release integration](#release-integration) for the authoritative repair + exception and overwrite rule. - A release that predates this system and has no SBOM asset can be described - with `workflow_dispatch`. The workflow reads manifests from the release tag, - records the exact default-branch generator commit, and refuses to overwrite - an existing asset. + with `workflow_dispatch`. The workflow reads released inputs, records the + exact default-branch generator commit, and refuses to overwrite an existing + asset. +- The newest release of each component is checked after SBOM changes and every + day, so a missed release-time dispatch is repaired without manual triage. - Changes to the generator are covered by `scripts/generate-sbom.test.mjs`, including a historical release-tree fixture and every accepted tag alias. CI also fails if a releasable component has no SBOM metadata. @@ -359,13 +376,13 @@ git worktree remove --force "$SBOM_REPRO_DIR" The SBOM is an input to vulnerability response, not the goal: ```text -SBOM (per released version) +SBOM (per current released version) │ ▼ dependency inventory ←── Dependabot alerts (packages/kit, GitHub Actions, Docker) │ ▼ -affected-version analysis ── "which shipped releases contain this CVE?" +affected-version analysis ── "which releases declare the affected dependency?" │ ▼ security advisory + patch @@ -375,9 +392,10 @@ new release → new SBOM ``` Its concrete value here is answering the affected-version question. Dependabot -tells us a dependency is vulnerable _today, on `main`_. The published SBOMs -tell us which already-shipped versions contain it — which is what a consumer -needs to know and what an advisory has to state. +tells us a dependency is vulnerable _today, on `main`_. Current release SBOMs +identify their direct dependency contracts; older releases without an asset are +investigated from their immutable tag and published descriptors when an +advisory needs an affected-version list. See [README.md](README.md) for the full vulnerability-management picture and [CRA.md](CRA.md) for how this maps onto Cyber Resilience Act expectations. diff --git a/security/openchain.md b/security/openchain.md index 25e8dffcf..b552371ea 100644 --- a/security/openchain.md +++ b/security/openchain.md @@ -27,13 +27,13 @@ OpenChain is a Linux Foundation project. | Req | Requirement | Status | Where / what is missing | | ----- | ----------------------------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 4.1.1 | Policy | **Partial** | [`README.md`](README.md) and [`SBOM.md`](SBOM.md) document practice, and [`../SECURITY.md`](../SECURITY.md) documents reporting. There is no single named "open source security assurance policy" document, and no procedure for communicating it to participants | -| 4.1.2 | Competence | **Missing** | No role/responsibility inventory, no `MAINTAINERS.md`, no competency definitions | +| 4.1.2 | Competence | **Partial** | [`../MAINTAINERS.md`](../MAINTAINERS.md) names the current owner and scope. There are no competency definitions or assessment records | | 4.1.3 | Awareness | **Missing** | No assessed-awareness evidence. Low value at current project size — see _Proportionality_ | | 4.1.4 | Program scope | **Partial** | Scope is defined in [`../SECURITY.md`](../SECURITY.md#scope) and per-component in [`SBOM.md`](SBOM.md#scope). No target performance metrics, no review cadence | | 4.1.5 | Standard practice (8 methods) | **Partial** | Present: vulnerability detection (Dependabot), follow-up and customer communication ([`../SECURITY.md`](../SECURITY.md)), information export (SBOM/VEX). Absent: documented threat identification, continuous security testing, and risk verification procedures | | 4.2.1 | Access | **Met** | Public reporting contact and private disclosure channel in [`../SECURITY.md`](../SECURITY.md), with a documented internal response path including the 24/72-hour timeline | | 4.2.2 | Effectively resourced | **Missing** | No documented personnel assignment or staffing/funding adequacy statement | -| 4.3.1 | Software bill of materials | **Met** | Documented procedure in [`SBOM.md`](SBOM.md); component records published per release as CycloneDX assets, generated automatically | +| 4.3.1 | Software bill of materials | **Met** | Documented procedure in [`SBOM.md`](SBOM.md); current component release records are published automatically as CycloneDX assets | | 4.3.2 | Security assurance | **Partial** | Detection via Dependabot and a per-component vulnerability record mechanism via [`vex/`](vex/README.md). No documented end-to-end detection-to-resolution procedure covering non-dependency vulnerabilities | | 4.4.1 | Completeness | **Missing** | No affirmation document; would be the output of closing the above | | 4.4.2 | Duration | **Missing** | No 18-month re-affirmation cycle defined | @@ -42,12 +42,12 @@ OpenChain is a Linux Foundation project. Only the parts touched by the SBOM work are assessed here. -| Area | Status | Notes | -| ---------------------------------------------------- | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Component license inventory | **Partial** | Published SBOMs carry license data for every direct dependency except pub.dev packages and NuGet packages with a non-SPDX license URL ([`SBOM.md`](SBOM.md#licenses)) | -| License policy (allowed/conditional/forbidden tiers) | **Missing** | No declared policy on which licenses may enter the dependency tree | -| Attribution / NOTICE generation | **Missing** | Not generated. Low urgency: the published SDKs have no runtime dependencies to attribute | -| Per-package LICENSE files | **Known gap** | Tracked separately as foundation-readiness work | +| Area | Status | Notes | +| ---------------------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Component license inventory | **Partial** | Published SBOMs carry license data for every direct dependency except pub.dev packages and NuGet packages with a non-SPDX license URL ([`SBOM.md`](SBOM.md#licenses)) | +| License policy (allowed/conditional/forbidden tiers) | **Missing** | No declared policy on which licenses may enter the dependency tree | +| Attribution / NOTICE generation | **Missing** | Not generated. Release SBOMs expose native and framework dependencies but do not produce consumer attribution bundles | +| Per-package LICENSE files | **Partial** | Every distributed component except `openiap-conformance` has a package-local license; the conformance package declares MIT but currently relies on the repository license | ## Proportionality @@ -66,16 +66,16 @@ produce something a consumer or downstream manufacturer can actually use. Ordered by value to someone consuming OpenIAP, not by requirement number. -1. **`MAINTAINERS.md`** (4.1.2) — who is responsible, and who a reporter - escalates to. Also on the foundation-readiness list, so it pays twice. -2. **Named security assurance policy** (4.1.1) — mostly assembly: the practice +1. **Named security assurance policy** (4.1.1) — mostly assembly: the practice is already documented across `security/` and `SECURITY.md`; what is missing is one document that says "this is the policy" and is reviewed on a cadence. -3. **License policy tiers** (5230) — decide what may enter the dependency tree. +2. **License policy tiers** (5230) — decide what may enter the dependency tree. Cheap to write now, expensive to retrofit once a copyleft dependency is already shipping. -4. **Threat identification and risk verification procedures** (4.1.5) — the +3. **Threat identification and risk verification procedures** (4.1.5) — the two genuinely absent practice areas. +4. **Package-local conformance license** (5230) — make the published package's + MIT declaration visible beside its source. 5. **Self-affirmation** (4.4.1, 4.4.2) — only meaningful once 1–4 exist. Nothing here blocks a release. These are programme-maturity items, and the diff --git a/security/vex/README.md b/security/vex/README.md index 16983d62a..dcf322f16 100644 --- a/security/vex/README.md +++ b/security/vex/README.md @@ -29,10 +29,10 @@ release SSOT. { "vulnerabilities": [ { - "id": "CVE-2026-12345", + "id": "CVE-2021-44228", "source": { - "name": "NVD", - "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-12345" + "name": "GitHub Advisory Database", + "url": "https://github.com/advisories/GHSA-jfh8-c2jp-5v3q" }, "affects": [{ "ref": "pkg:maven/com.example/library@1.2.3" }], "analysis": { @@ -81,10 +81,11 @@ and the file is covered by the same provenance attestation. ## Lifecycle -VEX is a per-release snapshot, like the SBOM. Editing a statement changes only -future releases; a published SBOM is never rewritten. If an assessment changes -— for example a `not_affected` becomes `exploitable` after new information — -that is a security advisory and a new release, not an edit to history. +VEX is a release-specific snapshot, like the SBOM. Editing a statement changes +only future releases; published assets follow the canonical +[`SBOM.md` update policy](../SBOM.md#update-policy). If an assessment changes — +for example a `not_affected` becomes `exploitable` after new information — that +is a security advisory and a new release, not an edit to history. See [`../SBOM.md`](../SBOM.md) for the inventory these statements annotate and [`../../SECURITY.md`](../../SECURITY.md) for the reporting process that