diff --git a/.claude/commands/audit-security.md b/.claude/commands/audit-security.md new file mode 100644 index 000000000..5d2cc7700 --- /dev/null +++ b/.claude/commands/audit-security.md @@ -0,0 +1,176 @@ +--- +name: audit-security +description: Audit OpenIAP's supply-chain security posture — SBOM correctness and NTIA completeness, release provenance, workflow permissions, and documentation drift — then fix what it finds. Use when the user asks to audit security, check SBOM quality, verify supply-chain posture, or before a release train. +--- + +# Audit Security Posture + +Check what OpenIAP publishes about itself against what it actually does. Every +step below produces evidence, not an opinion. + +Run this before a release train, after changing anything under `security/`, +`scripts/generate-sbom*`, or `.github/workflows/`, and whenever a new +releasable component is added. + +The canonical policy this audits lives in +[`security/README.md`](../../security/README.md), +[`security/SBOM.md`](../../security/SBOM.md), and +[`security/CRA.md`](../../security/CRA.md). Read them before reporting a gap — +several apparent gaps are documented deliberate choices. + +## 1. SBOM generates for every releasable component + +The component list is owned by the release SSOT, not by the SBOM code. A +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 + +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" +done +``` + +A failure here is the intended behaviour when a build manifest gained a +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 + cyclonedx validate --input-file "$f" --input-format json \ + --input-version v1_6 --fail-on-errors +done +``` + +Install with `brew install cyclonedx-cli` if absent. + +## 3. NTIA minimum elements + +The [NTIA minimum elements](https://www.ntia.gov/report/2021/minimum-elements-software-bill-materials-sbom) +are the baseline OpenSSF recommends measuring against. Check author, timestamp, +and per-component name, version, purl, supplier, and dependency relationships: + +```bash +node -e ' +const fs = require("fs"); +const dir = "/tmp/sbom-audit"; +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")); + files++; + if (j.metadata?.authors?.length) auth++; + for (const c of j.components ?? []) { + tot++; + if (c.supplier?.name) sup++; + if (c.licenses?.length) lic++; + if (c.purl) purl++; + } +} +console.log(`SBOM author: ${auth}/${files}`); +console.log(`component purl: ${purl}/${tot}`); +console.log(`component supplier: ${sup}/${tot}`); +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. + +Known structural gaps, which are **not** findings: pub.dev exposes neither +license nor supplier in package metadata, and some NuGet packages carry only a +non-SPDX license URL. + +Optionally score the result with +[`sbomqs`](https://github.com/interlynk-io/sbomqs): +`sbomqs score /tmp/sbom-audit/*.cdx.json`. + +## 4. No leaked paths or secrets + +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" +``` + +## 5. Determinism + +Regeneration at the same commit must be byte-identical, or the reproducibility +claim in `security/SBOM.md` is false. + +```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 +``` + +## 6. Workflow permissions and injection + +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 + +# Workflows that write must say so explicitly +grep -L "^permissions:" .github/workflows/*.yml +``` + +Pass values through `env:` instead of interpolating them. OpenSSF Scorecard's +Dangerous-Workflow check flags the same pattern. + +## 7. Generated SBOMs stay out of git + +```bash +git check-ignore -v sbom/ && echo "ignored" || echo "GAP: sbom/ is committable" +git ls-files '*.cdx.json' | head # must be empty +``` + +## 8. Documentation matches the code + +Documentation drift is the most common finding, because prose has no compiler. + +```bash +# Component table in security/SBOM.md vs the release SSOT +node -e ' +import("./scripts/generate-sbom.mjs").then((m) => { + const doc = require("fs").readFileSync("security/SBOM.md", "utf8"); + for (const id of m.listComponentIds()) { + if (!doc.includes(`\`${id}\``)) console.log(`MISSING from SBOM.md: ${id}`); + } +}); +' + +# 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 +``` + +Also check for **hardcoded counts** — "nine workflows", "43 of 47 +dependencies". They are true on the day they are written and wrong later. +Prefer a described property or a command that prints the live number. + +## 9. Release integrity still holds + +```bash +node --test scripts/release-branch-policy.test.mjs \ + scripts/npm-publish-authorization.test.mjs \ + scripts/verify-npm-release-provenance.test.mjs +node scripts/release-branch-policy.mjs audit +``` + +## 10. Report + +State each check as pass, gap, or not-applicable with the command output that +justifies it. For every gap, either fix it in the same pass or record why it is +deliberate. Do not report a check as passing when its tool was unavailable — +report it as unrun and say which tool is missing. diff --git a/.claude/skills/loop-review/SKILL.md b/.claude/skills/loop-review/SKILL.md index f1cdc5b77..e99300252 100644 --- a/.claude/skills/loop-review/SKILL.md +++ b/.claude/skills/loop-review/SKILL.md @@ -13,6 +13,9 @@ Use Claude Code's matching skills or commands for each delegated phase: - `/review-self` for pre-PR stabilization and exact-head fallback review. - `/commit --all --pr` for commit, push, PR, labels, and preview. - `/review-pr ` for review threads, CodeRabbit, CI polling, and cleanup. +- `/e2e-tests` for the device-regression gate — hand back to the user to run it + rather than merging, since it needs real devices and store accounts. - `ScheduleWakeup` for every five-minute re-entry; never use a shell sleep loop. -Do not merge until the canonical exact-head clean gate is satisfied. +Do not merge until the canonical exact-head clean gate is satisfied, and stop +without merging when the canonical device-regression gate applies. diff --git a/.codex/skills/loop-review/SKILL.md b/.codex/skills/loop-review/SKILL.md index d0c42ea31..619f54bb8 100644 --- a/.codex/skills/loop-review/SKILL.md +++ b/.codex/skills/loop-review/SKILL.md @@ -104,7 +104,43 @@ Clean means all of the following hold for the same head SHA: - the PR is mergeable and the branch contains every required update from main; - the worktree is clean and the final diff has been reread. -## 6. Merge And Close The Loop +## 6. Gate Device Regression Before Merging + +Device-backed regression needs real hardware, store accounts, and sandbox +purchases, so this loop cannot run it unattended. Decide whether the change +requires it **before** merging, not after. + +Require `$e2e-tests` when the diff touches any of: + +- `packages/apple/`, `packages/google/`, or `packages/kit/`; +- any `libraries//` implementation, example app, or podspec/gradle/csproj + manifest; +- `packages/gql/src/*.graphql` or the generated types synced from it; +- native build configuration, dependency placement, config plugins, or store + metadata for any of the above. + +When it is required, **stop without merging even if the PR is otherwise +clean**. Report the exact regression-matrix rows the diff implicates and hand +back to the user. The loop does not merge such a change on its own authority. + +Exactly two things clear the gate, and both are recorded on the PR before any +merge: + +1. A `$e2e-tests` run covering the implicated rows, with its result posted. +2. An explicit written waiver from the user in this conversation, naming the + rows waived and the reason. Record it verbatim on the PR. Absence of an + objection is not a waiver, and the loop must never grant one to itself. + +A clean CI run is not a substitute: CI does not exercise purchase dialogs, +store accounts, or device wiring. + +When it is not required, say so explicitly in the final report and name the +paths that justify it. Silence here reads as an untested merge. + +A change confined to documentation, repository automation, agent workflows, or +release/security tooling does not need device regression. + +## 7. Merge And Close The Loop Immediately before merging, refetch the PR and confirm its head still equals the clean reviewed SHA. Use the repository-supported merge method, defaulting to a @@ -125,5 +161,6 @@ After merge: Stop without merging when a required choice lacks authority, the same finding survives two fix attempts, an access blocker repeats under the source workflow's -threshold, or the exact head cannot satisfy the clean gate. Report the concrete -blocker; never describe a pending or partially reviewed PR as clean. +threshold, the change requires device regression that has not been run, or the +exact head cannot satisfy the clean gate. Report the concrete blocker; never +describe a pending or partially reviewed PR as clean. diff --git a/.codex/skills/openiap-workflows/SKILL.md b/.codex/skills/openiap-workflows/SKILL.md index 2b12ee44a..79d7e8aee 100644 --- a/.codex/skills/openiap-workflows/SKILL.md +++ b/.codex/skills/openiap-workflows/SKILL.md @@ -37,6 +37,8 @@ natural-language requests, execute the matching workflow: exactly as defined by the command workflow. - Audit code, check latest APIs, or "audit-code": read `.claude/commands/audit-code.md`. +- Audit SBOM quality, release provenance, workflow permissions, or + supply-chain/security posture: read `.claude/commands/audit-security.md`. - Compile knowledge or rebuild AI context: read `.claude/commands/compile-knowledge.md`. - Resolve a GitHub issue: read `.claude/commands/resolve-issue.md`. diff --git a/.github/pr-previews/pr-318-security-docs.webm b/.github/pr-previews/pr-318-security-docs.webm new file mode 100644 index 000000000..d25191ec2 Binary files /dev/null and b/.github/pr-previews/pr-318-security-docs.webm differ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fb0cbf3ec..1b3f42c26 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,6 +34,7 @@ jobs: node --test scripts/release-branch-policy.test.mjs scripts/npm-publish-authorization.test.mjs scripts/verify-npm-release-provenance.test.mjs + scripts/generate-sbom.test.mjs - name: Test Gradle network retry helper run: node --test scripts/ci/retry-gradle.test.mjs diff --git a/.github/workflows/sbom.yml b/.github/workflows/sbom.yml new file mode 100644 index 000000000..f7ed2e69d --- /dev/null +++ b/.github/workflows/sbom.yml @@ -0,0 +1,149 @@ +name: "Security: SBOM" + +# Generates a CycloneDX SBOM for whichever component a published release +# 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. + +on: + release: + types: [published] + workflow_dispatch: + inputs: + tag: + description: "Release tag to (re)generate an SBOM for" + required: true + type: string + +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: + sbom: + name: Generate and publish SBOM + runs-on: ubuntu-latest + permissions: + contents: write # upload the SBOM as a release asset + id-token: write # request the Sigstore signing certificate + attestations: write # record the provenance attestation + steps: + - name: Resolve release tag + id: tag + env: + RELEASE_TAG: ${{ github.event.release.tag_name || inputs.tag }} + run: | + if [ -z "$RELEASE_TAG" ]; then + echo "::error::No release tag available" + exit 1 + fi + echo "tag=$RELEASE_TAG" >> "$GITHUB_OUTPUT" + + - name: Checkout the released commit + uses: actions/checkout@v7 + with: + ref: ${{ steps.tag.outputs.tag }} + fetch-depth: 0 + persist-credentials: false + + - name: Setup Node + uses: actions/setup-node@v7 + with: + node-version: 20 + + - name: Identify the released component + id: component + # Pass the tag through the environment rather than interpolating it + # into the shell command; a ref name must never be able to extend the + # command it is an argument to. + env: + RELEASE_TAG: ${{ steps.tag.outputs.tag }} + run: node scripts/generate-sbom.mjs resolve-tag "$RELEASE_TAG" + + - name: Verify SBOM generator behaviour + if: ${{ steps.component.outputs.matched == 'true' }} + run: node --test scripts/generate-sbom.test.mjs + + - 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. + env: + COMPONENT: ${{ steps.component.outputs.component }} + run: | + node scripts/generate-sbom.mjs "$COMPONENT" \ + --output-dir sbom \ + --commit "$(git rev-parse HEAD)" \ + --with-licenses + + - name: Verify the SBOM describes this release + if: ${{ steps.component.outputs.matched == 'true' }} + env: + SBOM_FILE: ${{ steps.generate.outputs.sbom-file }} + EXPECTED_VERSION: ${{ steps.component.outputs.version }} + RELEASE_TAG: ${{ steps.tag.outputs.tag }} + run: | + # A version mismatch means the tag and the manifest disagree, which + # would attach a misleading inventory to a real release. + ACTUAL_VERSION=$(jq -r '.metadata.component.version' "$SBOM_FILE") + ACTUAL_TAG=$(jq -r ' + .metadata.component.properties[] + | select(.name == "openiap:release:tag") | .value + ' "$SBOM_FILE") + ACTUAL_COMMIT=$(jq -r ' + .metadata.component.properties[] + | select(.name == "openiap:release:commit") | .value + ' "$SBOM_FILE") + + if [ "$ACTUAL_VERSION" != "$EXPECTED_VERSION" ]; then + echo "::error::SBOM version $ACTUAL_VERSION does not match tag version $EXPECTED_VERSION" + exit 1 + fi + if [ "$ACTUAL_TAG" != "$RELEASE_TAG" ]; then + echo "::error::SBOM tag $ACTUAL_TAG does not match release tag $RELEASE_TAG" + exit 1 + fi + if [ "$ACTUAL_COMMIT" != "$(git rev-parse HEAD)" ]; then + echo "::error::SBOM commit $ACTUAL_COMMIT does not match the released commit" + exit 1 + fi + + # Fail closed if a local path ever reaches a published document. + if grep -qE '/Users/|/home/[a-z]|/tmp/' "$SBOM_FILE"; then + echo "::error::SBOM contains a local filesystem path" + exit 1 + fi + + echo "SBOM verified for $RELEASE_TAG at $ACTUAL_COMMIT" + + - name: Attest SBOM provenance + if: ${{ steps.component.outputs.matched == 'true' }} + 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' }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_TAG: ${{ steps.tag.outputs.tag }} + SBOM_FILE: ${{ steps.generate.outputs.sbom-file }} + run: gh release upload "$RELEASE_TAG" "$SBOM_FILE" --clobber + + - name: Report a skipped tag + if: ${{ steps.component.outputs.matched != 'true' }} + env: + RELEASE_TAG: ${{ steps.tag.outputs.tag }} + run: | + echo "::notice::$RELEASE_TAG is not a component release tag; no SBOM generated." diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml new file mode 100644 index 000000000..63a5c2df3 --- /dev/null +++ b/.github/workflows/scorecard.yml @@ -0,0 +1,61 @@ +name: "Security: Scorecard" + +# OpenSSF Scorecard checks the security posture of the repository itself — +# branch protection, workflow token permissions, action pinning, dangerous +# workflow patterns, release signing. +# +# This is the one thing neither Dependabot nor the SBOM covers. Dependabot +# watches dependencies; the SBOM records what shipped; Scorecard checks whether +# the process that produced it is sound. It adds no code and no dependency — +# it reads the repository through the GitHub API. + +on: + branch_protection_rule: + schedule: + # Weekly, off the hour to avoid the scheduler's busiest minute. + - cron: "37 5 * * 1" + push: + branches: + - main + workflow_dispatch: + +permissions: read-all + +jobs: + analysis: + name: Scorecard analysis + runs-on: ubuntu-latest + permissions: + # Upload results to the code-scanning dashboard. + security-events: write + # Publish results so the score is verifiable by third parties. + id-token: write + contents: read + actions: read + + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Run analysis + uses: ossf/scorecard-action@v2.4.4 + with: + results_file: results.sarif + results_format: sarif + # Publishes the score to the OpenSSF API so it can be cited as + # evidence and shown as a badge. Requires the repository to be public. + publish_results: true + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: scorecard-results + path: results.sarif + retention-days: 5 + + - name: Upload to code scanning + uses: github/codeql-action/upload-sarif@v4 + with: + sarif_file: results.sarif diff --git a/.gitignore b/.gitignore index 578c1c52b..85463e661 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,12 @@ dist/ build/ .turbo/ +# Generated SBOMs. These are release artifacts published to GitHub Releases, +# never committed — a checked-in copy would drift from the release it claims +# to describe. See security/SBOM.md. +sbom/ +*.cdx.json + # Synced version files (generated from root openiap-versions.json) packages/*/openiap-versions.json packages/apple/Sources/openiap-versions.json diff --git a/AGENTS.md b/AGENTS.md index 6592df69e..4df3f355d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -276,6 +276,7 @@ Cursor-specific files. | `$rebase-main` | Pull main and safely rebase the current branch | `$rebase-main` | | `/review-pr` | Review PR comments, fix issues, resolve threads | `/review-pr 65` or `/review-pr ` | | `/audit-code` | Audit code against knowledge rules and latest APIs | `/audit-code` | +| `/audit-security` | Audit SBOM, provenance, and supply-chain posture | `/audit-security` | | `/compile-knowledge` | Compile knowledge base for Claude context | `/compile-knowledge` | | `/resolve-issue` | Analyze an issue, label it, and fix/comment | `/resolve-issue 88` | | `/verify-all` | Run the full monorepo health check | `/verify-all` | diff --git a/SECURITY.md b/SECURITY.md index 9cd8ec3e9..347806e26 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -33,8 +33,89 @@ Receipt-validation and purchase-verification logic is the highest-sensitivity area — reports touching verification bypasses, replay, or entitlement forgery are prioritized. +## What Counts as a Vulnerability + +This bug bar keeps triage predictable and tells reporters what to expect. + +**In scope:** + +- Verification bypass — accepting a forged, replayed, or tampered receipt or + purchase token as valid +- Entitlement forgery or privilege escalation in IAPKit +- Leaking purchase tokens, receipts, credentials, or API keys through logs, + errors, or SDK surfaces +- Remote code execution, injection, or dependency confusion in a published + artifact +- Authentication or authorization flaws in `kit.openiap.dev` + +**Not a vulnerability on its own:** + +- Behaviour that requires a compromised device, jailbroken OS, or attacker-run + debugger against their own app +- Missing hardening that is not exploitable (absent headers, verbose version + strings) +- Store-side policy behaviour owned by Apple, Google, Amazon, or Meta +- Vulnerabilities in a peer dependency the host application selects and + versions — report those to that project, and tell us if OpenIAP forces a + vulnerable range + +If you are unsure, report it. A borderline report is more useful than a missed +one. + +## Actively Exploited Vulnerabilities + +If a vulnerability in an OpenIAP component is **being exploited in the wild**, +say so explicitly in your report — put `[SECURITY][ACTIVE]` in the subject. +That changes the response path: + +This is an **internal service level**, not a restatement of any statutory +deadline: + +| When | What happens | +| ----------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | +| Within 24 hours of awareness | Triage and an initial assessment: which components and published versions are affected, and whether exploitation is confirmed | +| 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 +Advisory, the release notes of the fixing release, and the repository README +when the impact is broad. + +These windows are modelled on the EU Cyber Resilience Act's Article 14 staging, +which applies from 11 September 2026, but they are not the statutory deadlines +themselves and do not discharge anyone's reporting duty. Whether OpenIAP is +legally required to report is a separate question — see +[`security/CRA.md`](security/CRA.md) — but the process is maintained either +way, because the first 24 hours are the part that cannot be improvised. + +Reporters who need to make their own regulatory notification should tell us; +we will share the assessment on the timeline above so it can support it. + ## Supported Versions Security fixes land on `main` and ship in the next release of each affected package. The latest published version of each package is supported; older majors receive fixes only for critical vulnerabilities, judged case by case. + +There is no long-term-support branch. When a package reaches end of life, it is +announced in its release notes and in the documentation's release history, so +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: + +```bash +gh release download react-native-iap-16.3.0 -p '*.cdx.json' +gh attestation verify react-native-iap-16.3.0.cdx.json --repo hyodotdev/openiap +``` + +- [`security/SBOM.md`](security/SBOM.md) — what the SBOMs cover, how they are + generated, and how to verify or reproduce one +- [`security/README.md`](security/README.md) — dependency monitoring, artifact + provenance, and release integrity +- [`security/CRA.md`](security/CRA.md) — how these practices map to EU Cyber + Resilience Act expectations diff --git a/knowledge/_claude-context/context.md b/knowledge/_claude-context/context.md index d2b83c698..6d1798bd6 100644 --- a/knowledge/_claude-context/context.md +++ b/knowledge/_claude-context/context.md @@ -1,7 +1,7 @@ # OpenIAP Project Context > **Auto-generated for Claude Code** -> Last updated: 2026-08-12T21:14:33.370Z +> Last updated: 2026-08-12T23:08:31.332Z > > Usage: `claude --context knowledge/_claude-context/context.md` @@ -1677,6 +1677,58 @@ Use `MenuDropdown` for collapsible parent-child navigation: --- +## Separate Content Data From Rendering + +> **Priority: MANDATORY** + +Repeated page content — table rows, link lists, card grids, comparison +matrices — is **data**. Declare it as a typed module-level constant and render +it with `.map()`. Do not hand-write repeated JSX blocks that differ only in +their text. + +```tsx +interface Standard { + concern: string; + standard: ReactNode; +} + +const STANDARDS: Standard[] = [ + { concern: "Document format", standard: CycloneDX 1.6 }, + { concern: "Component identity", standard: purl }, +]; + +// …then render it + row.concern} + columns={[ + { header: "Concern", cell: (row) => row.concern }, + { header: "Standard", cell: (row) => row.standard }, + ]} +/>; +``` + +Why this is mandatory rather than stylistic: + +- Editing a fact means editing one object, not hunting through `` markup. +- A reviewer can read the content of a page without stepping through JSX. +- Adding a row cannot accidentally break table structure. +- Content becomes greppable and, when needed, exportable to another surface. + +Rules: + +- Use `src/components/DataTable.tsx` for tabular content rather than + hand-writing ``; pass `columns` and `rows`. +- Name the constant in `SCREAMING_SNAKE_CASE`, type it with an `interface`, and + place it above the component. +- `rowKey` must be a stable field, never the array index. +- Prose paragraphs stay inline as JSX. This rule is about **repeated + structures**, not about extracting every sentence into a variable. +- A one-off two-row table is not worth a constant; use judgement, and extract + once the structure repeats or grows. + +--- + ## React Component Organization ### Component Structure diff --git a/knowledge/internal/05-docs-patterns.md b/knowledge/internal/05-docs-patterns.md index 8f780a667..682e1751b 100644 --- a/knowledge/internal/05-docs-patterns.md +++ b/knowledge/internal/05-docs-patterns.md @@ -140,6 +140,58 @@ Use `MenuDropdown` for collapsible parent-child navigation: --- +## Separate Content Data From Rendering + +> **Priority: MANDATORY** + +Repeated page content — table rows, link lists, card grids, comparison +matrices — is **data**. Declare it as a typed module-level constant and render +it with `.map()`. Do not hand-write repeated JSX blocks that differ only in +their text. + +```tsx +interface Standard { + concern: string; + standard: ReactNode; +} + +const STANDARDS: Standard[] = [ + { concern: "Document format", standard: CycloneDX 1.6 }, + { concern: "Component identity", standard: purl }, +]; + +// …then render it + row.concern} + columns={[ + { header: "Concern", cell: (row) => row.concern }, + { header: "Standard", cell: (row) => row.standard }, + ]} +/>; +``` + +Why this is mandatory rather than stylistic: + +- Editing a fact means editing one object, not hunting through `` markup. +- A reviewer can read the content of a page without stepping through JSX. +- Adding a row cannot accidentally break table structure. +- Content becomes greppable and, when needed, exportable to another surface. + +Rules: + +- Use `src/components/DataTable.tsx` for tabular content rather than + hand-writing `
`; pass `columns` and `rows`. +- Name the constant in `SCREAMING_SNAKE_CASE`, type it with an `interface`, and + place it above the component. +- `rowKey` must be a stable field, never the array index. +- Prose paragraphs stay inline as JSX. This rule is about **repeated + structures**, not about extracting every sentence into a variable. +- A one-off two-row table is not worth a constant; use judgement, and extract + once the structure repeats or grows. + +--- + ## React Component Organization ### Component Structure diff --git a/package.json b/package.json index 81b53de04..299a3abff 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,8 @@ "audit:parity": "node scripts/audit-non-godot-parity.mjs", "audit:docs": "bun run scripts/audit-docs.ts", "audit:release-state": "node scripts/release-branch-policy.mjs audit", + "sbom": "node scripts/generate-sbom.mjs", + "sbom:test": "node --test scripts/generate-sbom.test.mjs", "clean": "rm -rf packages/*/node_modules node_modules", "generate": "cd packages/gql && bun run generate", "version:sync": "bun scripts/sync-versions.mjs", diff --git a/packages/docs/src/components/DataTable.tsx b/packages/docs/src/components/DataTable.tsx new file mode 100644 index 000000000..66bb925bf --- /dev/null +++ b/packages/docs/src/components/DataTable.tsx @@ -0,0 +1,44 @@ +import type { ReactNode } from 'react'; + +/** + * Renders a table from a data array so page content stays data, not markup. + * + * Declare the rows as a typed module-level constant and pass them here. Editing + * a fact then means editing one object, and a reviewer can read the content + * without stepping through JSX. + */ +export interface DataTableColumn { + header: string; + cell: (row: Row) => ReactNode; +} + +interface DataTableProps { + columns: DataTableColumn[]; + rows: Row[]; + rowKey: (row: Row) => string; +} + +function DataTable({ columns, rows, rowKey }: DataTableProps) { + return ( +
+ + + {columns.map((column) => ( + + ))} + + + + {rows.map((row) => ( + + {columns.map((column) => ( + + ))} + + ))} + +
{column.header}
{column.cell(row)}
+ ); +} + +export default DataTable; diff --git a/packages/docs/src/pages/docs/index.tsx b/packages/docs/src/pages/docs/index.tsx index 3b7a88295..bf0a76956 100644 --- a/packages/docs/src/pages/docs/index.tsx +++ b/packages/docs/src/pages/docs/index.tsx @@ -129,6 +129,9 @@ import Versions from './updates/versions'; import AIAssistants from './guides/ai-assistants'; import MCPServer from './guides/mcp-server'; import Testing from './guides/testing'; +import SecurityOverview from './security/overview'; +import SecuritySbom from './security/sbom'; +import SecurityCompliance from './security/compliance'; import FoundationGovernance from './foundation/governance'; import FoundationOnePager from './foundation/one-pager'; import FoundationSponsorship from './foundation/sponsorship'; @@ -1015,6 +1018,18 @@ function Docs() { +

Security

+
    + +

Foundation

    } /> } /> } /> + } /> + } /> + } /> } /> + The per-release SBOM, in CycloneDX 1.6 + + ), + }, + { + need: 'Evidence of where a release came from', + provided: + 'Provenance attestation on the SBOM, npm provenance on npm packages, and immutable release tags', + }, + { + need: 'A channel to report a vulnerability you found', + provided: ( + <> + Private reporting with a documented response timeline — see{' '} + Overview + + ), + }, + { + need: 'Whether a flagged CVE actually affects you', + provided: 'VEX statements carried in the SBOM, where an analysis exists', + }, + { + need: 'Evidence the SDK behaves to specification', + provided: 'The conformance suite, below', + }, +]; + +interface Source { + href: string; + label: string; + note?: string; +} + +const SOURCES: Source[] = [ + { + href: 'https://eur-lex.europa.eu/eli/reg/2024/2847/oj', + label: 'Regulation (EU) 2024/2847', + note: 'the Cyber Resilience Act itself', + }, + { + href: 'https://digital-strategy.ec.europa.eu/en/policies/cra-open-source', + label: 'European Commission — CRA and open source', + }, + { + href: 'https://policy.openssf.org/CRA/stewards-playbook.html', + label: 'OpenSSF CRA Stewards Playbook', + note: 'the practical checklist behind the obligations above', + }, + { + href: 'https://cra.orcwg.org/faq/stewards/', + label: 'Open Regulatory Compliance WG — steward FAQ', + }, + { + href: 'https://openchainproject.org/security-assurance', + label: 'OpenChain ISO/IEC 18974', + note: 'the security-assurance standard the gap assessment uses', + }, + { + href: 'https://openchainproject.org/license-compliance', + label: 'OpenChain ISO/IEC 5230', + note: 'its license-compliance counterpart', + }, +]; + +function SecurityCompliance() { + useScrollToHash(); + + return ( +
    + +

    Compliance

    +

    + If you integrate OpenIAP into a product placed on the EU market, you may + carry obligations under the Cyber Resilience Act. This page describes + what OpenIAP publishes to support that work. +

    + + + OpenIAP does not claim CRA compliance, and nothing here is legal advice + or a compliance attestation on your behalf. Whether the CRA applies to + your product, and who the responsible economic operator is, are + questions for you and your counsel. + + +
    + + The dates that matter + + row.date} + columns={[ + { header: 'Date', cell: (row) => {row.date} }, + { header: 'What applies', cell: (row) => row.applies }, + ]} + /> +

    + Reporting starts more than a year before the product requirements do, + which is why the reporting path is the part worth having ready first. +

    +
    + +
    + + What OpenIAP provides + + row.need} + columns={[ + { header: 'Your need', cell: (row) => row.need }, + { header: 'What to use', cell: (row) => row.provided }, + ]} + /> +

    + What OpenIAP will not issue is a compliance + attestation or warranty on behalf of a downstream manufacturer. That + would move legal responsibility upstream, which the CRA's + guidance for open-source stewards explicitly warns against. +

    +
    + +
    + + Behavioral conformance + +

    + A type-level contract proves an SDK declares an API. It does + not prove the API does anything. An implementation could + declare restorePurchases and return immediately while + passing every type check. +

    +

    + The openiap-conformance suite closes that gap with + versioned behavioral tests: permanent behavior ids, RFC-2119 levels, + and capability gates derived from a store capability matrix rather + than self-declared by the adapter — so an implementation cannot excuse + itself from its own store's requirements. A missing{' '} + MUST behavior is reported as a failure, not skipped. +

    +

    + It matters for compliance because it produces reproducible evidence + that a released SDK behaves as the specification says — including + around entitlement correctness, where the suite's first run + surfaced real defects such as a pending subscription being treated as + an active entitlement. +

    + +
    + +
    + + OpenChain self-assessment + +

    + OpenIAP maintains an internal gap assessment against{' '} + ISO/IEC 18974 (open source security assurance) and{' '} + ISO/IEC 5230 (license compliance). These are Linux + Foundation standards, they are the closest existing ones to what the + CRA expects of a software supplier, and they are expressed as + verifiable materials rather than legal language. +

    +

    + 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. +

    +

    + + Read the current gap assessment + +

    +
    + +
    + + A note on roles + +

    + The CRA distinguishes manufacturers,{' '} + open-source stewards, and everyone else. A steward + must be a legal person that systematically supports + open-source software intended for commercial activities; individual + maintainers and unincorporated projects fall outside that definition. +

    +

    + OpenIAP does not assert a determination about its own status here. The + practices are maintained either way, because the value of an accurate + inventory and a working reporting path does not depend on which label + applies. +

    +
    + +
    + + Sources + +

    + This page is a reading of public material, not legal advice. Where it + and the regulation disagree, the regulation governs. +

    +
      + {SOURCES.map((source) => ( +
    • + + {source.label} + + {source.note ? ` — ${source.note}` : null} +
    • + ))} +
    +
    +
    + ); +} + +export default SecurityCompliance; diff --git a/packages/docs/src/pages/docs/security/overview.tsx b/packages/docs/src/pages/docs/security/overview.tsx new file mode 100644 index 000000000..26f8f0e1a --- /dev/null +++ b/packages/docs/src/pages/docs/security/overview.tsx @@ -0,0 +1,300 @@ +import type { ReactNode } from 'react'; +import SEO from '../../../components/SEO'; +import AnchorLink from '../../../components/AnchorLink'; +import Callout from '../../../components/Callout'; +import DataTable from '../../../components/DataTable'; +import { useScrollToHash } from '../../../hooks/useScrollToHash'; + +interface Artifact { + name: string; + answers: string; +} + +const RELEASE_ARTIFACTS: Artifact[] = [ + { + name: 'CycloneDX SBOM', + answers: + 'Exactly which third-party components this version contains, with versions, package URLs, suppliers, and licenses', + }, + { + name: 'Provenance attestation', + answers: + "Cryptographic proof that the SBOM was produced by OpenIAP's CI from a specific commit, not written by hand", + }, + { + name: 'npm provenance', + answers: + 'For npm packages, that the published tarball was built from this repository', + }, + { + name: 'Immutable release tag', + answers: + 'Which source commit produced the release, verified at publish time', + }, +]; + +interface Trigger { + when: string; + what: string; +} + +const AUTOMATION: Trigger[] = [ + { + when: 'Every pull request', + what: "The SBOM generator's own tests run. They read the real dependency manifests in the repository, so if a build file changes shape and the inventory would go stale, CI fails there rather than at release time", + }, + { + when: 'Merge to main', + what: 'Same checks, plus release-state audits that keep versions and tags consistent', + }, + { + when: 'A release is published', + what: 'The SBOM workflow identifies which component the tag belongs to, generates its SBOM at that exact commit, resolves licenses and suppliers, verifies the version/tag/commit all agree, signs a provenance attestation, and attaches the file to the release', + }, + { + when: 'Weekly', + what: "Dependabot opens update pull requests for IAPKit's dependencies, the GitHub Actions used in workflows, and the IAPKit container image. OpenSSF Scorecard re-checks the repository's own security posture", + }, + { + when: 'A vulnerability is reported', + what: 'The response path above — accelerated if it is being actively exploited', + }, +]; + +interface Layer { + name: string; + question: ReactNode; +} + +const POSTURE_LAYERS: Layer[] = [ + { + name: 'Dependabot', + question: + 'are the dependencies we use current and free of known vulnerabilities?', + }, + { + name: 'SBOM', + question: 'what exactly did each published version contain?', + }, + { + name: 'OpenSSF Scorecard', + question: + 'is the process that produced it sound? It checks branch protection, workflow token permissions, action pinning, and dangerous workflow patterns, and publishes a score anyone can verify', + }, +]; + +interface FurtherReading { + href: string; + label: string; + note: string; + external?: boolean; +} + +const FURTHER_READING: FurtherReading[] = [ + { + href: '/docs/security/sbom', + label: 'SBOM', + note: 'download, verify, and reproduce a release inventory', + }, + { + href: '/docs/security/compliance', + label: 'Compliance', + note: 'CRA readiness, OpenChain self-assessment, and the behavioral conformance suite', + }, + { + href: 'https://github.com/hyodotdev/openiap/tree/main/security', + label: 'security/', + note: 'the maintainer-facing policy documents', + external: true, + }, +]; + +function SecurityOverview() { + useScrollToHash(); + + return ( +
    + +

    Supply Chain Security

    +

    + If you ship an app built on OpenIAP, its dependencies become part of + your product. This section covers what OpenIAP publishes so you can + answer questions about that — for a security review, a customer + questionnaire, or a regulator. +

    + +
    + + What every release publishes + + row.name} + columns={[ + { header: 'Artifact', cell: (row) => {row.name} }, + { header: 'What it answers', cell: (row) => row.answers }, + ]} + /> +

    + See SBOM for how to download and + verify one. +

    +
    + +
    + + Dependency posture + +

    + The published JavaScript SDKs declare{' '} + no runtime dependencies. Installing{' '} + react-native-iap, expo-iap, or{' '} + openiap-conformance adds no third-party runtime code to + your app. React, React Native, and Expo are peer dependencies your + application already owns and versions. +

    +

    + The Apple SDK has no external package dependencies either — it builds + on StoreKit, which ships with the OS. The native Android, Kotlin + Multiplatform, .NET MAUI, and Flutter SDKs do depend on platform + libraries (Play Billing, AndroidX, Kotlin coroutines, and so on); + those are enumerated in each release's SBOM. +

    + + A dependency that does not exist cannot be vulnerable. For the + JavaScript SDKs, the honest answer to "what is OpenIAP's + transitive dependency risk?" is: none at runtime — and the SBOM + is the evidence, not the claim. + +
    + +
    + + Reporting a vulnerability + +

    + Report privately — do not open a public issue. Email{' '} + hyo@hyo.dev with the subject prefix{' '} + [SECURITY], or use GitHub's private vulnerability + reporting on the repository. +

    +

    + 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. +

    +

    + Receipt validation, purchase verification, and entitlement handling + are the highest-sensitivity areas. The full policy, including what + does and does not count as a vulnerability, is in{' '} + + SECURITY.md + + . +

    +
    + +
    + + When each thing runs + +

    + All of it is automated. Nothing in this section requires a maintainer + to remember a step. +

    + row.when} + columns={[ + { header: 'Trigger', cell: (row) => {row.when} }, + { header: 'What happens automatically', cell: (row) => row.what }, + ]} + /> +

    + 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. +

    +
    + +
    + + How dependencies are monitored + +

    + 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. +

    +

    + 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. +

    + + 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. + +
    + +
    + + Repository posture + +

    + Three layers cover different things, and none substitutes for another: +

    +
      + {POSTURE_LAYERS.map((layer) => ( +
    • + {layer.name} — {layer.question} +
    • + ))} +
    +
    + +
    + + Further reading + +
      + {FURTHER_READING.map((item) => ( +
    • + + {item.label} + {' '} + — {item.note} +
    • + ))} +
    +
    +
    + ); +} + +export default SecurityOverview; diff --git a/packages/docs/src/pages/docs/security/sbom.tsx b/packages/docs/src/pages/docs/security/sbom.tsx new file mode 100644 index 000000000..fa7ae4332 --- /dev/null +++ b/packages/docs/src/pages/docs/security/sbom.tsx @@ -0,0 +1,336 @@ +import type { ReactNode } from 'react'; +import SEO from '../../../components/SEO'; +import AnchorLink from '../../../components/AnchorLink'; +import Callout from '../../../components/Callout'; +import DataTable from '../../../components/DataTable'; +import { useScrollToHash } from '../../../hooks/useScrollToHash'; + +function ExternalLink({ + href, + children, +}: { + href: string; + children: ReactNode; +}) { + return ( + + {children} + + ); +} + +interface Standard { + concern: string; + standard: ReactNode; +} + +const STANDARDS: Standard[] = [ + { + concern: 'Document format', + standard: ( + + CycloneDX 1.6 + + ), + }, + { + concern: 'Component identity', + standard: ( + + Package URL (purl) + + ), + }, + { + concern: 'License identity', + standard: ( + + SPDX identifiers + + ), + }, + { + concern: 'Required data fields', + standard: ( + + NTIA minimum elements + + ), + }, + { + concern: 'Vulnerability analysis', + standard: ( + + CycloneDX VEX + + ), + }, + { + concern: 'Provenance', + standard: ( + <> + SLSA{' '} + in in-toto{' '} + statements, signed via{' '} + Sigstore + + ), + }, +]; + +interface VerifyTool { + href: string; + name: string; + role: string; + license: string; +} + +const VERIFY_TOOLS: VerifyTool[] = [ + { + href: 'https://cli.github.com/manual/gh_attestation_verify', + name: 'gh attestation verify', + role: 'Confirm CI provenance against Sigstore', + license: 'MIT', + }, + { + href: 'https://github.com/CycloneDX/cyclonedx-cli', + name: 'cyclonedx-cli', + role: 'Validate against the published schema', + license: 'Apache-2.0', + }, + { + href: 'https://github.com/interlynk-io/sbomqs', + name: 'sbomqs', + role: 'Score quality and NTIA compliance', + license: 'Apache-2.0', + }, + { + href: 'https://github.com/google/osv-scanner', + name: 'osv-scanner', + role: 'Match components against the OSV database', + license: 'Apache-2.0', + }, + { + href: 'https://github.com/anchore/grype', + name: 'grype', + role: 'Match components against vulnerability feeds', + license: 'Apache-2.0', + }, +]; + +interface Limit { + title: string; + detail: string; +} + +const LIMITS: Limit[] = [ + { + title: 'Transitive dependencies', + detail: + "are included only where the ecosystem's resolver output is available. Direct runtime dependencies are always complete.", + }, + { + title: 'Licenses and suppliers', + detail: + 'resolve for every direct dependency except two structural cases: pub.dev packages, whose metadata exposes neither field, and NuGet packages whose nuspec gives only a non-SPDX license URL.', + }, +]; + +function SecuritySbom() { + useScrollToHash(); + + return ( +
    + +

    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? + +

    + +
    + + Finding one + +

    + SBOMs are release assets, named after the package and version they + describe: +

    +
    +          {`-.cdx.json
    +
    +react-native-iap-16.3.0.cdx.json
    +openiap-google-3.3.0.cdx.json
    +flutter_inapp_purchase-10.3.0.cdx.json`}
    +        
    +

    Download one from its release:

    +
    +          {`gh release download react-native-iap-16.3.0 \\
    +  --repo hyodotdev/openiap -p '*.cdx.json'`}
    +        
    +

    + One SBOM is published per releasable component — the SDKs, the native + packages, and the specification — rather than one for the whole + monorepo, because a repository-wide document would describe something + nobody installs. +

    +
    + +
    + + Verifying it + +

    + The SBOM carries a build provenance attestation, so you can confirm + OpenIAP's CI produced it rather than trusting the file on sight: +

    +
    +          {`gh attestation verify react-native-iap-16.3.0.cdx.json \\
    +  --repo hyodotdev/openiap`}
    +        
    +

    And validate it against the CycloneDX schema:

    +
    +          {`cyclonedx validate --input-file react-native-iap-16.3.0.cdx.json \\
    +  --input-format json --input-version v1_6 --fail-on-errors`}
    +        
    +

    + Every tool below is independent of this repository, so verification + never requires trusting our tooling: +

    + row.name} + columns={[ + { + header: 'Tool', + cell: (row) => ( + + {row.name} + + ), + }, + { header: 'Role', cell: (row) => row.role }, + { header: 'License', cell: (row) => row.license }, + ]} + /> + + Generation is deterministic. The document timestamp is the release + commit's timestamp, and the serial number is derived from the + release identity rather than randomly generated — so regenerating at + the released commit produces a byte-identical file. You can rebuild it + yourself and compare. + +
    + +
    + + What is inside + +

    Each document records:

    +
      +
    • + The component's name, version, and{' '} + package URL (purl), matching the published package +
    • +
    • + The repository URL and the exact commit the release was built from +
    • +
    • + The release tag, so an artifact and its inventory cannot be + mismatched +
    • +
    • + Every direct runtime dependency, with version, purl, supplier, and + license +
    • +
    +

    + Deliberately excluded: test and build-only dependencies, and peer + dependencies. None of them are present in the artifact you install — + listing them would overstate what is actually running in your app. + Operating-system frameworks such as StoreKit are not distributed + packages and are likewise absent. +

    + + The JavaScript SDKs' SBOMs contain no components, because those + packages declare no runtime dependencies. That is a property of the + artifact, not a gap in the tooling. + +
    + +
    + + Vulnerability analysis (VEX) + +

    + When a scanner flags a CVE against a dependency, the useful question + is whether it is reachable through OpenIAP's use of that + dependency. Where that has been analysed, the judgement travels in the + same SBOM as a CycloneDX vulnerabilities entry, with a + state such as not_affected and a required justification. +

    +

    + If a release's SBOM has no vulnerabilities section, + it means no CVE has been analysed for that release — not that a scan + was run and came back clean. +

    +
    + +
    + + Standards and tooling + +

    + Everything here is an open standard, most of it maintained under the + Linux Foundation or OWASP. +

    + row.concern} + columns={[ + { header: 'Concern', cell: (row) => row.concern }, + { header: 'Standard', cell: (row) => row.standard }, + ]} + /> + + 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. + +
    + +
    + + Current limits + +

    Stated plainly, so you can judge the evidence:

    +
      + {LIMITS.map((limit) => ( +
    • + {limit.title} {limit.detail} +
    • + ))} +
    +

    + Where a dependency cannot be resolved, generation fails rather than + emitting a shorter list — an inventory that silently omits something + is worse than none, because it is trusted. +

    +
    +
    + ); +} + +export default SecuritySbom; diff --git a/scripts/assert-release-tag.mjs b/scripts/assert-release-tag.mjs index 2b8bb7045..05ecb3f53 100644 --- a/scripts/assert-release-tag.mjs +++ b/scripts/assert-release-tag.mjs @@ -5,7 +5,7 @@ import { fileURLToPath } from "node:url"; import { validateVersion } from "./release-branch-policy.mjs"; -const PACKAGE_CONFIG = { +export const PACKAGE_CONFIG = { apple: { path: "openiap-versions.json", tags: (version) => [version, `apple-v${version}`], diff --git a/scripts/generate-sbom.mjs b/scripts/generate-sbom.mjs new file mode 100644 index 000000000..cd6cee6af --- /dev/null +++ b/scripts/generate-sbom.mjs @@ -0,0 +1,814 @@ +#!/usr/bin/env node + +/** + * Generate a CycloneDX SBOM for one releasable OpenIAP component. + * + * The component list, its version source, and its release tag are read from the + * existing release SSOT (`release-branch-policy.mjs` and + * `assert-release-tag.mjs`) so a component cannot be released without also + * being describable here, and a version can never disagree with the release + * that produced it. + * + * Output is deterministic for a given (component, version, commit): the + * document timestamp comes from the commit, and the serial number is derived + * from the release identity rather than randomly generated. Re-running this on + * the same commit reproduces the same bytes. + * + * Usage: + * node scripts/generate-sbom.mjs [--output-dir DIR] + * [--commit SHA] + * [--resolved FILE] + * [--stdout] + */ + +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { PACKAGE_CONFIG } from "./assert-release-tag.mjs"; +import { validateVersion, versionSources } from "./release-branch-policy.mjs"; +import { + extractDirectDependencies, + mergeResolved, +} from "./sbom-dependencies.mjs"; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + +const REPOSITORY_URL = "https://github.com/hyodotdev/openiap"; +const SUPPLIER = { + name: "OpenIAP", + url: ["https://openiap.dev"], +}; +// The repository LICENSE. A component that publishes under different terms +// must override this, or its SBOM would assert a licence it does not ship. +const DEFAULT_LICENSE = "MIT"; +const GENERATOR_NAME = "openiap-sbom-generator"; +const GENERATOR_VERSION = "1.0.0"; +const SPEC_VERSION = "1.6"; + +/** + * 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. + */ +const COMPONENTS = { + apple: { + sbomName: "openiap-apple", + type: "library", + purl: (version) => `pkg:cocoapods/openiap@${version}`, + distribution: (version) => `https://cocoapods.org/pods/openiap`, + directory: "packages/apple", + // Package.swift declares `dependencies: []`; StoreKit is an OS framework, + // not a distributed package, so it is not an SBOM component. + source: { kind: "swift", manifest: "packages/apple/Package.swift" }, + }, + conformance: { + sbomName: "openiap-conformance", + type: "library", + purl: (version) => `pkg:npm/openiap-conformance@${version}`, + distribution: (version) => + `https://www.npmjs.com/package/openiap-conformance/v/${version}`, + directory: "packages/conformance", + source: { kind: "npm", manifest: "packages/conformance/package.json" }, + }, + docs: { + sbomName: "openiap-spec", + type: "data", + purl: (version) => `pkg:generic/openiap-spec@${version}`, + distribution: (version) => `${REPOSITORY_URL}/releases/tag/docs-${version}`, + directory: "packages/gql", + // The spec release publishes the GraphQL contract and generated types. + // It carries no third-party runtime code. + source: { kind: "none" }, + }, + expo: { + sbomName: "expo-iap", + type: "library", + purl: (version) => `pkg:npm/expo-iap@${version}`, + distribution: (version) => + `https://www.npmjs.com/package/expo-iap/v/${version}`, + directory: "libraries/expo-iap", + source: { kind: "npm", manifest: "libraries/expo-iap/package.json" }, + }, + flutter: { + sbomName: "flutter_inapp_purchase", + type: "library", + purl: (version) => `pkg:pub/flutter_inapp_purchase@${version}`, + distribution: (version) => + `https://pub.dev/packages/flutter_inapp_purchase/versions/${version}`, + directory: "libraries/flutter_inapp_purchase", + source: { + kind: "pub", + manifest: "libraries/flutter_inapp_purchase/pubspec.yaml", + }, + resolver: "flutter pub deps --json", + }, + godot: { + sbomName: "godot-iap", + type: "library", + purl: (version) => `pkg:generic/godot-iap@${version}`, + distribution: (version) => + `${REPOSITORY_URL}/releases/tag/godot-iap-${version}`, + directory: "libraries/godot-iap", + source: { + kind: "gradle", + manifest: "libraries/godot-iap/android/build.gradle.kts", + // The plugin derives these at configuration time from sibling modules. + externalLocals: { + openiapGoogleVersion: { file: "openiap-versions.json", json: "google" }, + googleCoroutinesVersion: { + file: "packages/google/openiap/build.gradle.kts", + gradleLocal: "coroutinesVersion", + }, + }, + }, + resolver: "gradlew :dependencies", + }, + google: { + sbomName: "openiap-google", + type: "library", + purl: (version) => + `pkg:maven/io.github.hyochan.openiap/openiap-google@${version}`, + distribution: (version) => + `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", + }, + resolver: "gradlew :openiap:dependencies", + }, + kmp: { + sbomName: "kmp-iap", + type: "library", + // Published under Apache-2.0, unlike the rest of the repository. + // See the POM licence block in libraries/kmp-iap/library/build.gradle.kts. + license: "Apache-2.0", + purl: (version) => `pkg:maven/io.github.hyochan/kmp-iap@${version}`, + distribution: (version) => + `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", + }, + resolver: "gradlew :library:dependencies", + }, + maui: { + sbomName: "OpenIap.Maui", + type: "library", + purl: (version) => `pkg:nuget/OpenIap.Maui@${version}`, + distribution: (version) => + `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", + ], + }, + resolver: "dotnet list package --include-transitive", + }, + "react-native": { + sbomName: "react-native-iap", + type: "library", + purl: (version) => `pkg:npm/react-native-iap@${version}`, + distribution: (version) => + `https://www.npmjs.com/package/react-native-iap/v/${version}`, + directory: "libraries/react-native-iap", + source: { + kind: "npm", + manifest: "libraries/react-native-iap/package.json", + }, + }, +}; + +export function listComponentIds() { + return Object.keys(COMPONENTS).sort(); +} + +function defaultRunGit(args) { + return execFileSync("git", args, { + cwd: repoRoot, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }).trim(); +} + +export function readComponentVersion(componentId, root = repoRoot) { + const source = versionSources[componentId]; + if (!source) { + throw new Error(`Unknown release component: ${componentId}`); + } + return validateVersion(source.read(root), source.label); +} + +export function releaseTagFor(componentId, version) { + // `docs` releases the spec and is absent from the release-tag SSOT, which + // only covers packages with their own version file. + if (componentId === "docs") return `docs-${version}`; + const tags = PACKAGE_CONFIG[componentId]?.tags(version); + if (!tags?.length) { + throw new Error(`No release tag pattern for component: ${componentId}`); + } + return tags[0]; +} + +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); + +/** + * Map a published release tag back to the component that produced it. + * + * Returns null for tags this repository does not release components under, so + * the workflow can skip them rather than fail. + */ +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; + 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; +} + +/** + * RFC 4122 §4.3 name-based UUID (SHA-1, version 5) over the release identity, + * so the same release always yields the same serial number. + */ +function deterministicSerialNumber(identity) { + // DNS namespace UUID, per RFC 4122 Appendix C. + const namespace = "6ba7b810-9dad-11d1-80b4-00c04fd430c8"; + const namespaceBytes = Buffer.from(namespace.replace(/-/gu, ""), "hex"); + const hash = createHash("sha1") + .update(Buffer.concat([namespaceBytes, Buffer.from(identity, "utf8")])) + .digest(); + + const bytes = Buffer.from(hash.subarray(0, 16)); + bytes[6] = (bytes[6] & 0x0f) | 0x50; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + + const hex = bytes.toString("hex"); + return `urn:uuid:${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; +} + +/** + * SPDX identifiers seen in this repository's dependency tree. A registry value + * outside this set is recorded as a free-text license name rather than being + * asserted as an SPDX id, because a wrong identifier is worse than an absent + * one — downstream tooling treats ids as authoritative. + */ +const KNOWN_SPDX_IDS = new Set([ + "0BSD", + "Apache-2.0", + "BSD-2-Clause", + "BSD-3-Clause", + "CC0-1.0", + "EPL-1.0", + "EPL-2.0", + "GPL-2.0-only", + "GPL-2.0-with-classpath-exception", + "ISC", + "LGPL-2.1-only", + "MIT", + "MPL-2.0", + "Unlicense", +]); + +/** Common registry spellings that are unambiguous but not SPDX-formatted. */ +const LICENSE_ALIASES = new Map([ + ["apache license 2.0", "Apache-2.0"], + ["apache license, version 2.0", "Apache-2.0"], + ["apache-2.0", "Apache-2.0"], + ["apache 2.0", "Apache-2.0"], + ["the apache license, version 2.0", "Apache-2.0"], + ["the apache software license, version 2.0", "Apache-2.0"], + ["mit", "MIT"], + ["mit license", "MIT"], + ["the mit license", "MIT"], + ["bsd-3-clause", "BSD-3-Clause"], + ["eclipse public license 1.0", "EPL-1.0"], + ["eclipse public license - v 2.0", "EPL-2.0"], +]); + +export function normalizeLicense(raw) { + const value = String(raw ?? "").trim(); + if (!value) return null; + + if (KNOWN_SPDX_IDS.has(value)) return { license: { id: value } }; + + const alias = LICENSE_ALIASES.get(value.toLowerCase()); + if (alias) return { license: { id: alias } }; + + // A compound expression such as "MIT AND Apache-2.0" is valid CycloneDX only + // in the `expression` form, and only if every operand is a known id. + if (/\s(AND|OR|WITH)\s/u.test(value)) { + const operands = value.split(/\s(?:AND|OR|WITH)\s/u).map((p) => p.trim()); + if (operands.every((operand) => KNOWN_SPDX_IDS.has(operand))) { + return { expression: value }; + } + } + + return { license: { name: value } }; +} + +function dependencyComponent(entry) { + const component = { + "bom-ref": entry.purl, + type: "library", + name: entry.name, + version: entry.version, + purl: entry.purl, + scope: "required", + }; + // NTIA minimum elements name the supplier as required data. + if (entry.supplier) { + component.supplier = { name: entry.supplier }; + } + if (entry.licenses?.length) { + component.licenses = entry.licenses; + } + if (entry.transitive) { + component.properties = [ + { name: "openiap:sbom:relationship", value: "transitive" }, + ]; + } + return component; +} + +export function buildSbom({ + componentId, + version, + commit, + timestamp, + dependencies, + vulnerabilities = [], +}) { + const definition = COMPONENTS[componentId]; + if (!definition) { + throw new Error(`Unknown SBOM component: ${componentId}`); + } + + const purl = definition.purl(version); + const tag = releaseTagFor(componentId, version); + const componentRef = purl; + + // A VEX statement that points at a bom-ref this SBOM does not contain says + // nothing a consumer's scanner can act on. Catch it here rather than + // shipping an analysis nobody can match to a component. + const knownRefs = new Set([componentRef, ...dependencies.map((d) => d.purl)]); + for (const statement of vulnerabilities) { + for (const affected of statement.affects ?? []) { + if (!knownRefs.has(affected.ref)) { + throw new Error( + `VEX statement ${statement.id} affects '${affected.ref}', which is not a component of ${definition.sbomName}@${version}`, + ); + } + } + } + + const externalReferences = [ + { type: "vcs", url: `${REPOSITORY_URL}.git` }, + { type: "distribution", url: definition.distribution(version) }, + { type: "website", url: "https://openiap.dev" }, + ]; + + return { + $schema: `http://cyclonedx.org/schema/bom-${SPEC_VERSION}.schema.json`, + bomFormat: "CycloneDX", + specVersion: SPEC_VERSION, + serialNumber: deterministicSerialNumber(`${purl}@${commit}`), + version: 1, + metadata: { + timestamp, + lifecycles: [{ phase: "build" }], + // NTIA minimum elements require an author distinct from the supplier. + authors: [{ name: SUPPLIER.name }], + tools: { + components: [ + { + type: "application", + name: GENERATOR_NAME, + version: GENERATOR_VERSION, + }, + ], + }, + component: { + "bom-ref": componentRef, + type: definition.type, + name: definition.sbomName, + version, + purl, + supplier: SUPPLIER, + licenses: [{ license: { id: definition.license ?? DEFAULT_LICENSE } }], + externalReferences, + properties: [ + { name: "openiap:release:tag", value: tag }, + { name: "openiap:release:commit", value: commit }, + { name: "openiap:release:component", value: componentId }, + ], + }, + supplier: SUPPLIER, + }, + components: dependencies.map(dependencyComponent), + dependencies: [ + { + // Every component is reachable from the root. A transitive entry left + // out of `dependsOn` would appear in `components` with no inbound edge, + // so a consumer walking the graph would never reach it. The + // openiap:sbom:relationship property, not the graph, is what marks an + // entry as transitive. + ref: componentRef, + dependsOn: dependencies.map((entry) => entry.purl), + }, + ...dependencies.map((entry) => ({ ref: entry.purl, dependsOn: [] })), + ], + // Omitted entirely when there is nothing analysed, rather than emitted as + // an empty array that reads like "we checked and found none". + ...(vulnerabilities.length > 0 ? { vulnerabilities } : {}), + }; +} + +async function fetchText(url) { + const response = await fetch(url, { + headers: { "user-agent": `${GENERATOR_NAME}/${GENERATOR_VERSION}` }, + signal: AbortSignal.timeout(20_000), + }); + if (!response.ok) return null; + return response.text(); +} + +/** + * Look up a dependency's declared license and supplier in its own registry. + * + * Both are NTIA minimum elements, and both come from the same document, so + * they are fetched together rather than in two passes. + * + * The registry is the only authoritative source; guessing from a name would + * produce confident, wrong compliance data. A lookup that fails leaves the + * field empty rather than failing the build — this is compliance metadata, + * not part of the security inventory, and a registry outage must not block a + * release. + * + * (name, version) pairs are immutable in every registry used here, so this + * stays reproducible in practice. + */ +async function lookupComponentMetadata(entry) { + try { + if (entry.purl.startsWith("pkg:maven/")) { + const [, coordinates] = entry.purl.split("pkg:maven/"); + const [group, rest] = coordinates.split("/"); + const [artifact] = rest.split("@"); + const groupPath = group.replace(/\./gu, "/"); + const path = `${groupPath}/${artifact}/${entry.version}/${artifact}-${entry.version}.pom`; + + // androidx, com.android.*, and com.google.android.* publish to Google's + // Maven repository, not Maven Central. + for (const base of [ + "https://repo1.maven.org/maven2", + "https://dl.google.com/dl/android/maven2", + ]) { + const pom = await fetchText(`${base}/${path}`); + if (!pom) continue; + const license = pom.match( + /[\s\S]*?([^<]+)<\/name>/u, + )?.[1]; + // The publishing organisation, falling back to the group id, which is + // the coordinate's own namespace claim. + const supplier = + pom.match(/[\s\S]*?([^<]+)<\/name>/u)?.[1] ?? + group; + if (license || supplier) { + return { license: normalizeLicense(license), supplier }; + } + } + return null; + } + + if (entry.purl.startsWith("pkg:nuget/")) { + const id = entry.name.toLowerCase(); + const nuspec = await fetchText( + `https://api.nuget.org/v3-flatcontainer/${id}/${entry.version}/${id}.nuspec`, + ); + if (!nuspec) return null; + const expression = nuspec.match( + /([^<]+)<\/license>/u, + )?.[1]; + const url = nuspec.match(/([^<]+)<\/licenseUrl>/u)?.[1]; + const fromUrl = url?.match(/licenses\.nuget\.org\/(.+)$/u)?.[1]; + const raw = expression ?? (fromUrl && decodeURIComponent(fromUrl)); + return { + license: normalizeLicense(raw), + supplier: nuspec.match(/([^<]+)<\/authors>/u)?.[1]?.trim(), + }; + } + + if (entry.purl.startsWith("pkg:npm/")) { + // A scoped name contains a slash, which would otherwise be read as a + // path separator and 404. + const raw = await fetchText( + `https://registry.npmjs.org/${encodeURIComponent(entry.name)}/${entry.version}`, + ); + if (!raw) return null; + const metadata = JSON.parse(raw); + const license = metadata.license; + const author = metadata.author; + return { + license: normalizeLicense( + typeof license === "string" ? license : license?.type, + ), + supplier: typeof author === "string" ? author : author?.name, + }; + } + } catch { + // Network failure, timeout, or malformed registry response. + return null; + } + + // pub.dev has no standard license field in package metadata; Dart packages + // declare licensing in a LICENSE file that the API does not expose. + return null; +} + +async function attachRegistryMetadata(dependencies) { + return Promise.all( + dependencies.map(async (entry) => { + const found = await lookupComponentMetadata(entry); + if (!found) return entry; + return { + ...entry, + ...(found.license ? { licenses: [found.license] } : {}), + ...(found.supplier ? { supplier: found.supplier } : {}), + }; + }), + ); +} + +/** CycloneDX 1.6 analysis states, in the order a finding moves through them. */ +const VEX_STATES = new Set([ + "in_triage", + "exploitable", + "resolved", + "resolved_with_pedigree", + "false_positive", + "not_affected", +]); + +/** + * Load recorded VEX statements for a component, if any exist. + * + * Unlike the dependency inventory, VEX cannot be generated: whether a CVE + * actually affects this product is a human judgement. What automation can do + * is make sure a recorded judgement travels with the release it applies to, + * and refuse a malformed one. + * + * No file means no analysed vulnerabilities, which is the normal state. + */ +export function readVexStatements(root, componentId) { + const path = resolve(root, "security/vex", `${componentId}.json`); + if (!existsSync(path)) return []; + + const parsed = JSON.parse(readFileSync(path, "utf8")); + const statements = Array.isArray(parsed) ? parsed : parsed.vulnerabilities; + if (!Array.isArray(statements)) { + throw new Error( + `VEX file must be an array or {"vulnerabilities": [...]}: ${path}`, + ); + } + + for (const statement of statements) { + if (!statement?.id) { + throw new Error(`VEX statement without an id in ${path}`); + } + const state = statement.analysis?.state; + if (!VEX_STATES.has(state)) { + throw new Error( + `VEX statement ${statement.id} has state '${state ?? "(missing)"}'; ` + + `expected one of ${[...VEX_STATES].join(", ")}`, + ); + } + // An unaffected claim without a reason is not reviewable, and reviewers + // are the whole point of publishing one. + if ( + (state === "not_affected" || state === "false_positive") && + !statement.analysis.justification && + !statement.analysis.detail + ) { + throw new Error( + `VEX statement ${statement.id} claims '${state}' without a justification or detail`, + ); + } + } + + return statements; +} + +function readResolvedFile(path) { + const parsed = JSON.parse(readFileSync(path, "utf8")); + const entries = Array.isArray(parsed) ? parsed : parsed.components; + if (!Array.isArray(entries)) { + throw new Error( + `Resolved dependency file must be an array or {"components": [...]}: ${path}`, + ); + } + return entries; +} + +export async function generateSbom( + componentId, + { + root = repoRoot, + commit, + resolvedFile, + withLicenses = false, + runGit = defaultRunGit, + } = {}, +) { + const definition = COMPONENTS[componentId]; + if (!definition) { + throw new Error( + `Unknown SBOM component '${componentId}'. Known: ${listComponentIds().join(", ")}`, + ); + } + + const version = readComponentVersion(componentId, root); + const resolvedCommit = commit || runGit(["rev-parse", "HEAD"]); + // Commit time, not wall-clock time, keeps regeneration byte-identical. + const timestamp = new Date( + runGit(["show", "-s", "--format=%cI", resolvedCommit]), + ).toISOString(); + + const direct = extractDirectDependencies(root, definition.source); + const merged = resolvedFile + ? mergeResolved(direct, readResolvedFile(resolvedFile)) + : direct; + const dependencies = withLicenses + ? await attachRegistryMetadata(merged) + : merged; + + const vulnerabilities = readVexStatements(root, componentId); + + const document = buildSbom({ + componentId, + version, + commit: resolvedCommit, + timestamp, + dependencies, + vulnerabilities, + }); + + return { + document, + version, + fileName: sbomFileName(componentId, version), + directCount: direct.length, + totalCount: dependencies.length, + licensedCount: dependencies.filter((entry) => entry.licenses?.length) + .length, + vexCount: vulnerabilities.length, + }; +} + +function parseArguments(argv) { + const options = { componentId: "", outputDir: "sbom", toStdout: false }; + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + if (argument === "--output-dir") { + options.outputDir = argv[++index]; + } else if (argument === "--commit") { + options.commit = argv[++index]; + } else if (argument === "--resolved") { + options.resolvedFile = argv[++index]; + } else if (argument === "--tag") { + options.tag = argv[++index]; + } else if (argument === "--stdout") { + options.toStdout = true; + } else if (argument === "--with-licenses") { + options.withLicenses = true; + } else if (argument.startsWith("--")) { + throw new Error(`Unknown option: ${argument}`); + } else if (!options.componentId) { + options.componentId = argument; + } else { + throw new Error(`Unexpected argument: ${argument}`); + } + } + + if (options.tag && !options.componentId) { + const resolved = componentFromTag(options.tag); + if (!resolved) { + throw new Error( + `Release tag '${options.tag}' does not belong to a known SBOM component`, + ); + } + options.componentId = resolved.componentId; + } + + if (!options.componentId) { + throw new Error( + `Usage: generate-sbom.mjs <${listComponentIds().join("|")}|--tag TAG> [--output-dir DIR] [--commit SHA] [--resolved FILE] [--with-licenses] [--stdout]`, + ); + } + return options; +} + +async function main() { + const [maybeCommand] = process.argv.slice(2); + + // `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` + : "matched=false\n"; + process.stdout.write(line); + if (process.env.GITHUB_OUTPUT) { + writeFileSync(process.env.GITHUB_OUTPUT, line, { flag: "a" }); + } + return; + } + + const options = parseArguments(process.argv.slice(2)); + const result = await generateSbom(options.componentId, { + commit: options.commit, + resolvedFile: options.resolvedFile, + withLicenses: options.withLicenses, + }); + const serialized = `${JSON.stringify(result.document, null, 2)}\n`; + + if (options.toStdout) { + process.stdout.write(serialized); + return; + } + + const outputDir = resolve(repoRoot, options.outputDir); + mkdirSync(outputDir, { recursive: true }); + const outputPath = resolve(outputDir, result.fileName); + writeFileSync(outputPath, serialized); + + console.log( + `${result.fileName}: ${result.directCount} direct` + + (result.totalCount !== result.directCount + ? `, ${result.totalCount - result.directCount} transitive` + : "") + + ` runtime dependencies` + + (options.withLicenses + ? `, ${result.licensedCount}/${result.totalCount} with license data` + : "") + + (result.vexCount > 0 ? `, ${result.vexCount} VEX statements` : ""), + ); + if (process.env.GITHUB_OUTPUT) { + writeFileSync( + process.env.GITHUB_OUTPUT, + `sbom-file=${outputPath}\nsbom-name=${result.fileName}\nversion=${result.version}\n`, + { flag: "a" }, + ); + } +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + main().catch((error) => { + console.error(`::error::${error.message}`); + process.exitCode = 1; + }); +} + +export const __testing = { COMPONENTS, deterministicSerialNumber }; diff --git a/scripts/generate-sbom.test.mjs b/scripts/generate-sbom.test.mjs new file mode 100644 index 000000000..5f295d75c --- /dev/null +++ b/scripts/generate-sbom.test.mjs @@ -0,0 +1,531 @@ +import assert from "node:assert/strict"; +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, resolve } from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +import { + __testing as generatorTesting, + buildSbom, + componentFromTag, + generateSbom, + listComponentIds, + normalizeLicense, + readComponentVersion, + readVexStatements, + releaseTagFor, + sbomFileName, +} from "./generate-sbom.mjs"; +import { PACKAGE_CONFIG } from "./assert-release-tag.mjs"; +import { + __testing as dependencyTesting, + mergeResolved, +} from "./sbom-dependencies.mjs"; +import { versionSources } from "./release-branch-policy.mjs"; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const { COMPONENTS } = generatorTesting; +const { + expandGradleForLoops, + extractGradle, + extractNuget, + extractPub, + isRuntimeGradleConfiguration, + parseMavenCoordinate, + parseVersionCatalog, + stripTestSourceSets, +} = dependencyTesting; + +const stubCommit = "0".repeat(40); +const stubGit = (args) => + args[0] === "rev-parse" ? stubCommit : "2026-01-02T03:04:05+00:00"; + +test("every releasable component has SBOM metadata", () => { + // The release SSOT decides what ships. A component that can be released but + // has no SBOM definition would ship without an inventory. + assert.deepEqual(listComponentIds(), Object.keys(versionSources).sort()); +}); + +test("component versions come from the release SSOT", () => { + for (const componentId of listComponentIds()) { + const fromSbom = readComponentVersion(componentId, repoRoot); + const fromPolicy = versionSources[componentId].read(repoRoot); + assert.equal(fromSbom, fromPolicy, componentId); + } +}); + +test("SBOM file name matches the documented convention", () => { + assert.equal( + sbomFileName("react-native", "16.3.0"), + "react-native-iap-16.3.0.cdx.json", + ); + assert.equal( + sbomFileName("conformance", "1.0.0"), + "openiap-conformance-1.0.0.cdx.json", + ); +}); + +test("release tags match the release-tag SSOT", () => { + assert.equal( + releaseTagFor("react-native", "16.3.0"), + "react-native-iap-16.3.0", + ); + assert.equal(releaseTagFor("google", "3.3.0"), "google-3.3.0"); + assert.equal(releaseTagFor("docs", "3.2.0"), "docs-3.2.0"); +}); + +test("every release tag pattern resolves back to its own component", () => { + // `sbom.yml` identifies the component from the published tag alone. If a tag + // pattern is added to the release SSOT and TAG_PREFIXES does not learn it, + // that release is silently skipped and ships with no SBOM. This iterates the + // SSOT so the divergence fails here rather than at release time. + for (const [componentId, config] of Object.entries(PACKAGE_CONFIG)) { + for (const tag of config.tags("9.9.9")) { + assert.deepEqual( + componentFromTag(tag), + { componentId, version: "9.9.9" }, + `${componentId} tag ${tag}`, + ); + } + } + + // `docs` releases the spec and is absent from PACKAGE_CONFIG. + assert.deepEqual(componentFromTag("docs-9.9.9"), { + componentId: "docs", + version: "9.9.9", + }); + + // A prefix must not swallow a longer one, and a tag we do not own is skipped + // rather than misattributed. + assert.equal(componentFromTag("google-v1.2.3").componentId, "google"); + 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(""), null); +}); + +test("serial number is derived from release identity, not randomness", () => { + const identity = { + componentId: "expo", + version: "5.3.0", + commit: stubCommit, + }; + const first = buildSbom({ + ...identity, + timestamp: "2026-01-01T00:00:00.000Z", + dependencies: [], + }); + const second = buildSbom({ + ...identity, + timestamp: "2026-01-01T00:00:00.000Z", + dependencies: [], + }); + assert.equal(first.serialNumber, second.serialNumber); + assert.match(first.serialNumber, /^urn:uuid:[0-9a-f-]{36}$/u); + + const otherCommit = buildSbom({ + ...identity, + commit: "1".repeat(40), + timestamp: "2026-01-01T00:00:00.000Z", + dependencies: [], + }); + assert.notEqual(first.serialNumber, otherCommit.serialNumber); +}); + +test("SBOM carries the metadata a release must be traceable by", () => { + const document = buildSbom({ + componentId: "react-native", + version: "16.3.0", + commit: stubCommit, + timestamp: "2026-01-01T00:00:00.000Z", + dependencies: [], + }); + + assert.equal(document.bomFormat, "CycloneDX"); + assert.equal(document.specVersion, "1.6"); + + const component = document.metadata.component; + assert.equal(component.name, "react-native-iap"); + assert.equal(component.version, "16.3.0"); + assert.equal(component.purl, "pkg:npm/react-native-iap@16.3.0"); + + const referenceTypes = component.externalReferences.map((ref) => ref.type); + assert.ok(referenceTypes.includes("vcs")); + assert.ok(referenceTypes.includes("distribution")); + + const properties = Object.fromEntries( + component.properties.map((property) => [property.name, property.value]), + ); + assert.equal(properties["openiap:release:commit"], stubCommit); + assert.equal(properties["openiap:release:tag"], "react-native-iap-16.3.0"); +}); + +test("generated SBOM version always matches the shipped manifest", async () => { + for (const componentId of listComponentIds()) { + const result = await generateSbom(componentId, { + root: repoRoot, + runGit: stubGit, + }); + assert.equal( + result.document.metadata.component.version, + readComponentVersion(componentId, repoRoot), + componentId, + ); + assert.ok(result.fileName.endsWith(".cdx.json"), componentId); + } +}); + +test("generated SBOMs never embed local filesystem paths", async () => { + for (const componentId of listComponentIds()) { + const { document } = await generateSbom(componentId, { + root: repoRoot, + runGit: stubGit, + }); + const serialized = JSON.stringify(document); + assert.doesNotMatch(serialized, /\/Users\//u, componentId); + assert.doesNotMatch(serialized, /\/home\/[a-z]/u, componentId); + assert.doesNotMatch(serialized, /\/tmp\//u, componentId); + } +}); + +test("test-only Gradle configurations stay out of the inventory", () => { + 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"), + false, + ); + assert.equal(isRuntimeGradleConfiguration("compileOnly"), false); + assert.equal(isRuntimeGradleConfiguration("playCompileOnly"), false); + assert.equal(isRuntimeGradleConfiguration("kaptImplementation"), false); +}); + +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' + + "}", + ); + 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, + ); + 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("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 kmp = await generateSbom("kmp", { root: repoRoot, runGit: stubGit }); + const names = kmp.document.components.map((entry) => entry.name); + assert.ok(!names.includes("org.jetbrains.kotlin:kotlin-test")); + assert.ok(!names.includes("org.jetbrains.kotlinx:kotlinx-coroutines-test")); +}); + +test("version catalog aliases resolve through version.ref", () => { + const { versions, libraries } = parseVersionCatalog( + '[versions]\nfoo = "1.2.3"\n\n[libraries]\n' + + 'bar-baz = { module = "com.example:bar", version.ref = "foo" }\n', + ); + assert.equal(versions.get("foo"), "1.2.3"); + assert.deepEqual(libraries.get("bar-baz"), { + module: "com.example:bar", + versionRef: "foo", + literal: undefined, + }); +}); + +test("NuGet references marked PrivateAssets=all are build-only", () => { + const dependencies = extractNuget(repoRoot, COMPONENTS.maui.source); + const names = dependencies.map((entry) => entry.name); + assert.ok(names.includes("Xamarin.Android.Google.BillingClient")); + // PrivateAssets="all" is not propagated to consumers of the package. + assert.ok(!names.includes("Microsoft.Maui.Controls")); + // Every MSBuild property must have been interpolated. + for (const entry of dependencies) { + assert.doesNotMatch(entry.version, /\$\(/u, entry.name); + } +}); + +test("pub dependencies exclude the Flutter SDK itself", () => { + const names = extractPub(repoRoot, COMPONENTS.flutter.source).map( + (entry) => entry.name, + ); + assert.deepEqual(names, ["http", "meta", "platform"]); +}); + +test("npm components publish no third-party runtime dependencies", async () => { + // These ship with an empty `dependencies` block; peer dependencies are the + // host app's to provide, so they are not part of this artifact's inventory. + for (const componentId of ["conformance", "expo", "react-native"]) { + const { document } = await generateSbom(componentId, { + root: repoRoot, + runGit: stubGit, + }); + assert.deepEqual(document.components, [], componentId); + } +}); + +test("resolver output adds transitive entries without losing direct ones", () => { + const direct = [{ name: "a", version: "1.0.0", purl: "pkg:maven/g/a@1.0.0" }]; + const merged = mergeResolved(direct, [ + { name: "a", version: "1.0.0", purl: "pkg:maven/g/a@1.0.0" }, + { name: "b", version: "2.0.0", purl: "pkg:maven/g/b@2.0.0" }, + ]); + + 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); +}); + +test("a registry license only becomes an SPDX id when it really is one", () => { + // Downstream tooling treats `license.id` as authoritative, so an unrecognised + // string must degrade to a free-text name rather than be asserted as SPDX. + assert.deepEqual(normalizeLicense("MIT"), { license: { id: "MIT" } }); + assert.deepEqual( + normalizeLicense("The Apache Software License, Version 2.0"), + { + license: { id: "Apache-2.0" }, + }, + ); + assert.deepEqual(normalizeLicense("MIT AND Apache-2.0"), { + expression: "MIT AND Apache-2.0", + }); + assert.deepEqual( + normalizeLicense("Android Software Development Kit License"), + { + license: { name: "Android Software Development Kit License" }, + }, + ); + // A compound expression with an unknown operand is not a valid SPDX + // expression, so it stays free text. + assert.deepEqual(normalizeLicense("MIT AND Some-Proprietary-Thing"), { + license: { name: "MIT AND Some-Proprietary-Thing" }, + }); + assert.equal(normalizeLicense(""), null); + assert.equal(normalizeLicense(undefined), null); +}); + +test("license lookup is opt-in so generation stays offline by default", async () => { + const { document } = await generateSbom("google", { + root: repoRoot, + runGit: stubGit, + }); + assert.ok(document.components.length > 0); + for (const component of document.components) { + assert.equal(component.licenses, undefined, component.name); + } +}); + +test("VEX statements are validated, and absent by default", (t) => { + const scratch = mkdtempSync(resolve(tmpdir(), "openiap-vex-")); + const vexDir = resolve(scratch, "security/vex"); + mkdirSync(vexDir, { recursive: true }); + t.after(() => rmSync(scratch, { recursive: true, force: true })); + + // No file is the normal state and must not be an error. + assert.deepEqual(readVexStatements(scratch, "google"), []); + + const write = (body) => + writeFileSync(resolve(vexDir, "google.json"), JSON.stringify(body)); + + write({ + vulnerabilities: [ + { + id: "CVE-2026-0001", + affects: [{ ref: "pkg:maven/g/a@1.0.0" }], + analysis: { state: "not_affected", justification: "code_not_present" }, + }, + ], + }); + assert.equal(readVexStatements(scratch, "google").length, 1); + + write({ vulnerabilities: [{ id: "CVE-2026-0002", analysis: {} }] }); + assert.throws(() => readVexStatements(scratch, "google"), /expected one of/u); + + // "not affected" with no stated reason is not reviewable. + write({ + vulnerabilities: [ + { id: "CVE-2026-0003", analysis: { state: "not_affected" } }, + ], + }); + assert.throws( + () => readVexStatements(scratch, "google"), + /without a justification or detail/u, + ); + + write({ vulnerabilities: [{ analysis: { state: "in_triage" } }] }); + assert.throws(() => readVexStatements(scratch, "google"), /without an id/u); +}); + +test("each component's declared licence matches what it publishes", async () => { + // A wrong licence is worse than a missing one: it is confident and it is + // consumed by compliance tooling. kmp-iap ships Apache-2.0 while the rest of + // the repository is MIT, so the SBOM must not blanket-assert MIT. + const declared = async (componentId) => { + const { document } = await generateSbom(componentId, { + root: repoRoot, + runGit: stubGit, + }); + return document.metadata.component.licenses[0].license.id; + }; + + assert.equal(await declared("kmp"), "Apache-2.0"); + for (const componentId of ["apple", "expo", "react-native", "conformance"]) { + assert.equal(await declared(componentId), "MIT", componentId); + } + + // The npm components state it in their own manifest; keep the two in step. + for (const [componentId, manifest] of [ + ["expo", "libraries/expo-iap/package.json"], + ["react-native", "libraries/react-native-iap/package.json"], + ["conformance", "packages/conformance/package.json"], + ]) { + const pkg = JSON.parse(readFileSync(resolve(repoRoot, manifest), "utf8")); + assert.equal(await declared(componentId), pkg.license, componentId); + } + + // kmp declares Apache-2.0 in its POM block; fail if that ever diverges. + const kmpBuild = readFileSync( + resolve(repoRoot, "libraries/kmp-iap/library/build.gradle.kts"), + "utf8", + ); + assert.match(kmpBuild, /Apache-2\.0|Apache License 2\.0/u); +}); + +test("a VEX statement must point at a component this SBOM contains", () => { + const dependencies = [ + { name: "a", version: "1.0.0", purl: "pkg:maven/g/a@1.0.0" }, + ]; + const base = { + componentId: "google", + version: "3.3.0", + commit: stubCommit, + timestamp: "2026-01-01T00:00:00.000Z", + dependencies, + }; + + const good = buildSbom({ + ...base, + vulnerabilities: [ + { + id: "CVE-2026-0001", + affects: [{ ref: "pkg:maven/g/a@1.0.0" }], + analysis: { state: "not_affected", justification: "code_not_present" }, + }, + ], + }); + assert.equal(good.vulnerabilities.length, 1); + + assert.throws( + () => + buildSbom({ + ...base, + vulnerabilities: [ + { + id: "CVE-2026-0002", + affects: [{ ref: "pkg:maven/g/typo@9.9.9" }], + analysis: { state: "exploitable" }, + }, + ], + }), + /is not a component of/u, + ); +}); + +test("an SBOM with no analysed vulnerabilities omits the section", () => { + const document = buildSbom({ + componentId: "google", + version: "3.3.0", + commit: stubCommit, + timestamp: "2026-01-01T00:00:00.000Z", + dependencies: [], + }); + // An empty array would read as "checked, none found", which is a stronger + // claim than the absence of analysis. + assert.equal("vulnerabilities" in document, false); +}); + +test("every component is reachable from the root of the dependency graph", () => { + // A transitive entry missing from `dependsOn` would still appear under + // `components`, but a consumer walking the graph from the root would never + // reach it. Standard CycloneDX tooling uses the graph, not our property. + const document = buildSbom({ + componentId: "google", + version: "3.3.0", + commit: stubCommit, + timestamp: "2026-01-01T00:00:00.000Z", + dependencies: [ + { name: "a", version: "1.0.0", purl: "pkg:maven/g/a@1.0.0" }, + { + name: "b", + version: "2.0.0", + purl: "pkg:maven/g/b@2.0.0", + transitive: true, + }, + ], + }); + + const root = document.dependencies.find( + (entry) => entry.ref === document.metadata.component.purl, + ); + assert.deepEqual(root.dependsOn, [ + "pkg:maven/g/a@1.0.0", + "pkg:maven/g/b@2.0.0", + ]); + assert.equal(document.components.length, 2); + + // The transitive marker stays a property, so the distinction is not lost. + const transitive = document.components.find((c) => c.name === "b"); + assert.deepEqual(transitive.properties, [ + { name: "openiap:sbom:relationship", value: "transitive" }, + ]); +}); diff --git a/scripts/release-branch-policy.mjs b/scripts/release-branch-policy.mjs index 39372b4d3..62ad100fb 100644 --- a/scripts/release-branch-policy.mjs +++ b/scripts/release-branch-policy.mjs @@ -9,7 +9,7 @@ const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const semverPattern = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/; -const versionSources = { +export const versionSources = { apple: { label: "openiap-apple", read: (root) => readJson(root, "openiap-versions.json").apple, diff --git a/scripts/sbom-dependencies.mjs b/scripts/sbom-dependencies.mjs new file mode 100644 index 000000000..0939bec08 --- /dev/null +++ b/scripts/sbom-dependencies.mjs @@ -0,0 +1,530 @@ +#!/usr/bin/env node + +/** + * Direct runtime dependency extractors, one per ecosystem this repository + * actually publishes into. + * + * 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. + */ + +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +function readText(root, relativePath) { + return readFileSync(resolve(root, relativePath), "utf8"); +} + +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]); + } + + // `val x = (project.findProperty("PROP") as String?) ?: "fallback"` — the + // gradle.properties entry wins at build time, so prefer it and fall back to + // the literal only when the property is absent. + for (const match of source.matchAll( + /\bval\s+([A-Za-z_][A-Za-z0-9_]*)\s*=\s*\(\s*project\.findProperty\(\s*"([^"]+)"\s*\)[^)]*\)\s*\?:\s*"([^"]+)"/gu, + )) { + locals.set(match[1], { property: match[2], fallback: match[3] }); + } + + return locals; +} + +function readGradleProperties(root, manifest) { + const properties = new Map(); + const segments = manifest.split("/"); + // Walk from the module directory up to the repository root, mirroring how + // Gradle layers project and root properties. + for (let depth = segments.length - 1; depth > 0; depth -= 1) { + const candidate = [...segments.slice(0, depth), "gradle.properties"].join( + "/", + ); + let source; + try { + source = readText(root, candidate); + } catch { + continue; + } + for (const line of source.split("\n")) { + const match = line.match(/^\s*([\w.-]+)\s*=\s*(.+?)\s*$/u); + if (match && !properties.has(match[1])) { + properties.set(match[1], match[2]); + } + } + } + return properties; +} + +/** + * Resolve locals that a sibling module owns. + * + * The Godot Android plugin computes its coordinates from `openiap-versions.json` + * and from packages/google's build script. Reading the same files keeps the + * coordinate correct without duplicating a version into this table. + */ +function readExternalLocals(root, externalLocals = {}) { + const resolved = new Map(); + for (const [name, spec] of Object.entries(externalLocals)) { + if (spec.json) { + resolved.set(name, readJson(root, spec.file)[spec.json]); + } else if (spec.gradleLocal) { + const value = readGradleLocals(readText(root, spec.file)).get( + spec.gradleLocal, + ); + if (typeof value === "string") resolved.set(name, value); + } + } + return resolved; +} + +function flattenLocals(locals, properties) { + const flat = new Map(); + for (const [name, value] of locals) { + if (typeof value === "string") { + flat.set(name, value); + } else if (value?.property) { + flat.set(name, properties.get(value.property) ?? value.fallback); + } + } + return flat; +} + +function interpolateGradle(coordinate, locals) { + return coordinate.replace( + /\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?/gu, + (whole, name) => locals.get(name) ?? whole, + ); +} + +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 }; + } + return { + name: `${group}:${artifact}`, + version, + purl: `pkg:maven/${group}/${artifact}@${version}`, + }; +} + +/** + * Remove a balanced `val Test by getting { ... }` source-set block. + * + * Kotlin Multiplatform declares test dependencies with the same + * `implementation(...)` configuration name as production ones; only the + * enclosing source set distinguishes them. + */ +function stripTestSourceSets(source) { + const opener = + /\bval\s+[A-Za-z0-9_]*[Tt]est[A-Za-z0-9_]*\s+by\s+getting\s*\{/gu; + let result = source; + let match; + + while ((match = opener.exec(result)) !== null) { + let depth = 1; + let index = match.index + match[0].length; + while (index < result.length && depth > 0) { + if (result[index] === "{") depth += 1; + else if (result[index] === "}") depth -= 1; + index += 1; + } + // The counter also sees braces inside strings and comments. If it never + // returns to zero, everything after this point would be discarded and the + // SBOM would silently lose real dependencies — the one failure mode this + // module exists to prevent. + if (depth !== 0) { + throw new Error( + `Unbalanced braces while removing a test source set at offset ${match.index}; ` + + `refusing to drop the remainder of the manifest.`, + ); + } + result = result.slice(0, match.index) + result.slice(index); + opener.lastIndex = 0; + } + + return result; +} + +/** + * Expand `for (name in listOf("a", "b")) { ... $name ... }` bodies. + * + * packages/google declares the Horizon platform SDK modules this way, so + * without expansion three real runtime dependencies would be unresolvable. + */ +function expandGradleForLoops(source) { + return source.replace( + /\bfor\s*\(\s*([A-Za-z_][A-Za-z0-9_]*)\s+in\s+listOf\(([^)]*)\)\s*\)\s*\{([^{}]*)\}/gu, + (whole, variable, rawItems, body) => { + const items = [...rawItems.matchAll(/"([^"]+)"/gu)].map( + (item) => item[1], + ); + if (items.length === 0) return whole; + return items + .map((item) => + body.replace(new RegExp(`\\$\\{?${variable}\\}?`, "gu"), item), + ) + .join("\n"); + }, + ); +} + +/** + * Gradle configurations that place a dependency on the consumer's runtime + * classpath. `compileOnly` and every test configuration are deliberately absent. + */ +const GRADLE_RUNTIME_CONFIGURATIONS = new Set([ + "api", + "implementation", + "runtimeOnly", +]); + +/** + * Configuration prefixes that never reach a consumer. These must be checked + * before the flavored-configuration pattern below, because `testImplementation` + * and `androidTestImplementation` both match it. + */ +const GRADLE_NON_RUNTIME_PREFIXES = [ + "test", + "androidTest", + "debug", + "compileOnly", + "annotationProcessor", + "ksp", + "kapt", + "lintChecks", +]; + +function isRuntimeGradleConfiguration(configuration) { + if (GRADLE_RUNTIME_CONFIGURATIONS.has(configuration)) return true; + if ( + GRADLE_NON_RUNTIME_PREFIXES.some((prefix) => + configuration.startsWith(prefix), + ) + ) { + return false; + } + // Flavored configurations such as `playApi` / `horizonImplementation`. + return /^[a-z][A-Za-z0-9]*(Api|Implementation|RuntimeOnly)$/u.test( + configuration, + ); +} + +function extractGradle(root, { manifest, externalLocals }) { + const rawSource = readText(root, manifest); + const locals = flattenLocals( + readGradleLocals(rawSource), + readGradleProperties(root, manifest), + ); + for (const [name, value] of readExternalLocals(root, externalLocals)) { + locals.set(name, value); + } + const source = expandGradleForLoops(stripTestSourceSets(rawSource)); + const found = new Map(); + const unresolved = []; + + const record = (configuration, rawCoordinate) => { + if (!isRuntimeGradleConfiguration(configuration)) return; + const parsed = parseMavenCoordinate( + interpolateGradle(rawCoordinate, locals), + ); + if (!parsed) return; + if (parsed.unresolved) { + unresolved.push(parsed.unresolved); + return; + } + found.set(parsed.purl, parsed); + }; + + // implementation("group:artifact:version") + for (const match of source.matchAll( + /\b([a-zA-Z][A-Za-z0-9]*)\s*\(\s*"([^"]+:[^"]+:[^"]+)"\s*\)/gu, + )) { + record(match[1], match[2]); + } + + // add("playApi", "group:artifact:version") + for (const match of source.matchAll( + /\badd\s*\(\s*"([^"]+)"\s*,\s*"([^"]+:[^"]+:[^"]+)"\s*\)/gu, + )) { + record(match[1], match[2]); + } + + if (unresolved.length > 0) { + throw new Error( + `Unresolved Gradle coordinates in ${manifest}: ${[...new Set(unresolved)].join(", ")}. ` + + `Model the declaration in sbom-dependencies.mjs, or supply a resolver export with --resolved.`, + ); + } + + return [...found.values()].sort((left, right) => + left.purl.localeCompare(right.purl), + ); +} + +function parseVersionCatalog(source) { + const versions = new Map(); + const libraries = new Map(); + let section = ""; + + for (const rawLine of source.split("\n")) { + const line = rawLine.trim(); + if (line.startsWith("#") || line === "") continue; + const sectionMatch = line.match(/^\[([^\]]+)\]$/u); + if (sectionMatch) { + section = sectionMatch[1]; + continue; + } + if (section === "versions") { + const match = line.match(/^([\w.-]+)\s*=\s*"([^"]+)"/u); + if (match) versions.set(match[1], match[2]); + continue; + } + if (section === "libraries") { + const match = line.match(/^([\w.-]+)\s*=\s*\{(.+)\}/u); + if (!match) continue; + const module = match[2].match(/module\s*=\s*"([^"]+)"/u)?.[1]; + const versionRef = match[2].match(/version\.ref\s*=\s*"([^"]+)"/u)?.[1]; + const literal = match[2].match(/version\s*=\s*"([^"]+)"/u)?.[1]; + if (module) libraries.set(match[1], { module, versionRef, literal }); + } + } + + return { versions, libraries }; +} + +/** `libs.kotlinx.coroutines.core` -> catalog alias `kotlinx-coroutines-core`. */ +function catalogAliasFromAccessor(accessor) { + return accessor.replace(/\./gu, "-"); +} + +function extractGradleCatalog(root, { manifest, catalog }) { + const source = stripTestSourceSets(readText(root, manifest)); + const { versions, libraries } = parseVersionCatalog(readText(root, catalog)); + 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, + )) { + 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); + } + + return [...found.values()].sort((left, right) => + left.purl.localeCompare(right.purl), + ); +} + +function readMsBuildProperties(root, propertyFiles) { + const properties = new Map(); + for (const file of propertyFiles) { + let source; + try { + source = readText(root, file); + } catch { + continue; + } + for (const match of source.matchAll( + /<([A-Za-z_][\w.-]*)>([^<>$]+)<\/\1>/gu, + )) { + properties.set(match[1], match[2].trim()); + } + } + return properties; +} + +function interpolateMsBuild(value, properties) { + return value.replace( + /\$\(([A-Za-z_][\w.-]*)\)/gu, + (whole, name) => properties.get(name) ?? whole, + ); +} + +function extractNuget(root, { manifest, propertyFiles = [] }) { + const source = readText(root, manifest); + const properties = readMsBuildProperties(root, [...propertyFiles, manifest]); + const found = new Map(); + + for (const match of source.matchAll(/]*)\/?>/gu)) { + 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 }); + } + + return [...found.values()].sort((left, right) => + left.purl.localeCompare(right.purl), + ); +} + +function extractPub(root, { manifest }) { + const source = readText(root, manifest); + const lines = source.split("\n"); + const found = new Map(); + let inDependencies = false; + + for (const line of lines) { + if (/^[A-Za-z_]+:/u.test(line)) { + inDependencies = line.startsWith("dependencies:"); + continue; + } + if (!inDependencies) continue; + + const match = line.match(/^ {2}([a-z0-9_]+):\s*(.*)$/u); + 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, { + name, + version, + purl: `pkg:pub/${name}@${version}`, + scope: "required", + }); + } + + return [...found.values()].sort((left, right) => + left.name.localeCompare(right.name), + ); +} + +function extractNpm(root, { manifest }) { + const packageJson = readJson(root, manifest); + const dependencies = packageJson.dependencies ?? {}; + return Object.entries(dependencies) + .map(([name, range]) => ({ + name, + version: String(range) + .replace(/^[\^~><= ]+/u, "") + .trim(), + purl: `pkg:npm/${name}@${String(range) + .replace(/^[\^~><= ]+/u, "") + .trim()}`, + })) + .sort((left, right) => left.name.localeCompare(right.name)); +} + +function extractSwift(root, { manifest }) { + const source = readText(root, manifest); + const found = new Map(); + for (const match of source.matchAll( + /\.package\s*\(\s*url:\s*"([^"]+)"[^)]*?(?:from|exact):\s*"([^"]+)"/gu, + )) { + 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, { + name, + version, + purl: `pkg:swift/github.com/${owner}/${name}@${version}`, + }); + } + 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, + npm: extractNpm, + none: extractNone, + nuget: extractNuget, + pub: extractPub, + swift: extractSwift, +}; + +export function extractDirectDependencies(root, source) { + const extractor = EXTRACTORS[source.kind]; + if (!extractor) { + throw new Error(`Unsupported dependency source kind: ${source.kind}`); + } + return extractor(root, source); +} + +/** + * 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 (merged.has(entry.purl)) continue; + merged.set(entry.purl, { ...entry, transitive: true }); + } + return [...merged.values()].sort((left, right) => + left.purl.localeCompare(right.purl), + ); +} + +export const __testing = { + expandGradleForLoops, + extractGradle, + extractGradleCatalog, + extractNpm, + extractNuget, + extractPub, + extractSwift, + isRuntimeGradleConfiguration, + parseMavenCoordinate, + parseVersionCatalog, + stripTestSourceSets, +}; diff --git a/security/CRA.md b/security/CRA.md new file mode 100644 index 000000000..c47991c1a --- /dev/null +++ b/security/CRA.md @@ -0,0 +1,161 @@ +# CRA readiness + +This document describes the engineering practices OpenIAP maintains that +correspond to EU Cyber Resilience Act (CRA) expectations. It is written for +OpenIAP maintainers, not as a legal analysis, and it is **not a substitute for +legal advice**. + +## Applicability + +Applicability of the CRA may depend on how individual OpenIAP components are +made available or used in commercial activities. OpenIAP does not assert a +determination here. + +Regardless of legal applicability, OpenIAP maintains SBOM and software-supply-chain +security practices as part of its security governance. Nothing in this +repository should be read as a claim of certified or guaranteed compliance. + +The practices below are designed to support OpenIAP's software-supply-chain +security and its preparation for applicable CRA requirements. + +### Roles the CRA defines + +Three roles carry different obligations. The distinction matters because it +decides whether the reporting duties below are mandatory or voluntary. + +| Role | Who it covers | +| ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | +| **Manufacturer** | Places a product with digital elements on the EU market under its own name, commercially | +| **Open-source steward** | A **legal person** that systematically provides sustained support for FOSS intended for commercial activities, without directly monetising it | +| **Neither** | Individual maintainers and unincorporated projects — they lack legal personhood and fall outside the steward definition | + +Two consequences follow for this repository as it stands today: + +- The steward role requires a **legal person**. A project maintained by + individuals, without a foundation or company behind it, is generally + outside that definition. If OpenIAP is later hosted by a foundation, that + foundation would be the candidate steward, not the repository. +- `packages/kit` is operated as a hosted service and is a separate question + from the distributed SDKs. It is not covered by the SDK analysis here. + +Article 64(10) exempts open-source software stewards from the administrative +fines set out in Article 64(3) to (9). That is a scoped exemption, not a +blanket one — other provisions, including Article 64(2), and the obligations +Article 24(3) applies to stewards, are outside it. Treat the precise liability +position as a question for legal advice rather than something this repository +settles. + +Separately, stewards must never issue compliance attestations or warranties on +behalf of downstream manufacturers — that would improperly move legal +responsibility upstream. OpenIAP issues no such attestation. + +## Reporting timeline + +Article 14 reporting obligations apply from **11 September 2026**, ahead of the +main product requirements on 11 December 2027. Reports go to the relevant +national CSIRT and ENISA through ENISA's Single Reporting Platform (SRP). + +Each stage starts from a different event, which is easy to get wrong: + +| Trigger | Early warning | Notification | Final report | +| ------------------------------------------------- | ------------------- | ------------------- | ------------------------------------------------------------- | +| Actively exploited vulnerability | 24 h from awareness | 72 h from awareness | 14 days after a corrective or mitigating measure is available | +| Severe incident affecting operated infrastructure | 24 h from awareness | 72 h from awareness | 1 month after the 72-hour notification | + +Only the first two clocks run from **becoming aware**. The final report for a +vulnerability runs from the availability of a fix or mitigation, and the final +report for an incident runs from the notification. + +The operational procedure OpenIAP follows on becoming aware of an actively +exploited vulnerability is in +[`SECURITY.md`](../SECURITY.md#actively-exploited-vulnerabilities). That is an +**internal service level**, not a restatement of the statutory deadlines: it is +maintained whether or not the obligation is legally binding here, because the +first 24 hours are the part that cannot be improvised. + +Article 15 additionally allows **voluntary** reporting of vulnerabilities, +incidents, and near misses by any party, including those with no mandatory +duty. + +## Sources + +This document is a reading of public material, not legal advice. The primary +sources are: + +| Source | What it provides | +| -------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ | +| [Regulation (EU) 2024/2847](https://eur-lex.europa.eu/eli/reg/2024/2847/oj) | The regulation itself, including Articles 14, 15, 24, and 64 | +| [European Commission — CRA implementation](https://digital-strategy.ec.europa.eu/en/policies/cra-summary) | Official summary and implementation guidance | +| [European Commission — CRA and open source](https://digital-strategy.ec.europa.eu/en/policies/cra-open-source) | Open-source-specific position | +| [OpenSSF CRA Stewards Playbook](https://policy.openssf.org/CRA/stewards-playbook.html) | Practical checklist behind the obligations described here | +| [Open Regulatory Compliance WG](https://orcwg.org/cra/) and its [FAQ](https://cra.orcwg.org/faq/stewards/) | Community reading of steward vs manufacturer scope | +| [ENISA](https://www.enisa.europa.eu/) | Operates the Single Reporting Platform reports are filed to | + +Where this document and the regulation disagree, the regulation governs. + +## What maintainers are responsible for + +### 1. SBOM + +**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 is automated, reads the same manifests the build reads, and +is reproducible from the released commit. + +See [SBOM.md](SBOM.md). Practical constraint: transitive closure is complete +only where an ecosystem resolver export is supplied; direct runtime +dependencies are always present. + +### 2. Vulnerability handling + +**Expectation:** have a process to receive, assess, and act on vulnerability +reports. + +**How OpenIAP does this:** private reporting and coordinated disclosure are +defined in the repository-root [`SECURITY.md`](../SECURITY.md), including the +reporting channel, a 72-hour acknowledgment commitment, and the prioritization +of receipt-validation and entitlement issues. Dependency vulnerabilities are +surfaced by Dependabot alerts. + +### 3. Security updates + +**Expectation:** define how fixes reach users, and for how long. + +**How OpenIAP does this:** `SECURITY.md` states the supported-version policy — +security fixes land on `main` and ship in the next release of each affected +package; the latest published version of each package is supported, with older +majors fixed case by case for critical issues. Releases are cut per component +through the workflows in `.github/workflows/`, and each produces a new SBOM. + +### 4. Technical evidence + +**Expectation:** be able to reproduce and evidence how a release was produced. + +**How OpenIAP does this** — for any published release, these are recoverable: + +| 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 | +| 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 | +| Was the npm artifact itself built by us? | npm provenance (`npm publish --provenance`), checked at release time by `scripts/verify-npm-release-provenance.mjs` | + +## Deliberate boundaries + +- **No compliance claim.** This repository does not state that OpenIAP is CRA + compliant. It documents practices. +- **No legal interpretation.** Questions about whether a given component is in + scope, or who the responsible economic operator is, are out of scope here. +- **Open-source specifics.** The CRA treats non-commercial open-source + development differently from commercial supply. OpenIAP does not resolve + that question in this repository; the practices are maintained either way. + +## Where this fits + +CRA readiness is a consequence of OpenIAP's supply-chain security work, not a +separate program. The umbrella is described in [README.md](README.md). diff --git a/security/README.md b/security/README.md new file mode 100644 index 000000000..377d8e847 --- /dev/null +++ b/security/README.md @@ -0,0 +1,154 @@ +# OpenIAP software supply-chain security + +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 | + +Vulnerability reporting stays at the repository root, where GitHub and most +contributors look for it. + +## The pipeline + +```text + OpenIAP source + │ + ▼ + dependency manifests + (package.json, gradle, + csproj, pubspec, spm) + │ + ▼ + CI (ci.yml) + │ + ┌───────────────┼───────────────┐ + ▼ ▼ ▼ + build tests audits: parity, + docs, lockfile, + release state + │ + ▼ + release workflow (per component) + │ + ┌───────────────┼───────────────┐ + ▼ ▼ ▼ + package npm/registry GitHub Release + provenance │ + ▼ + sbom.yml (release: published) + │ + ┌─────────┴─────────┐ + ▼ ▼ + CycloneDX SBOM provenance + (release asset) attestation +``` + +## What is in place + +| Capability | Mechanism | +| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Per-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` | +| Publish authorization | `scripts/npm-publish-authorization.mjs` — publishing runs only from a verified release tag | +| Dependency monitoring | [Dependabot](https://docs.github.com/en/code-security/dependabot): npm (`packages/kit`), GitHub Actions, Docker | +| Repository posture | [`ossf/scorecard-action`](https://github.com/ossf/scorecard-action) — [OpenSSF Scorecard](https://scorecard.dev/), results in code scanning | +| Vulnerability reporting | [`../SECURITY.md`](../SECURITY.md) — private reporting, 72-hour acknowledgment | +| Release-branch policy | `scripts/release-branch-policy.mjs` — no prerelease metadata on `main` | + +## Dependency monitoring coverage + +Dependabot is configured for `packages/kit`, GitHub Actions, and the kit +Dockerfile. That is a deliberate scope, not an oversight: + +- **`packages/kit`** is a deployed service with a large runtime dependency + tree. It is the component where a vulnerable dependency has the most + immediate consequence, and where we control the deployed version. +- **The published SDKs** (`react-native-iap`, `expo-iap`, + `openiap-conformance`) declare **no runtime `dependencies`**. There is no + third-party runtime tree to monitor. Their peer dependencies are resolved and + owned by the consuming application. +- **Native SDKs** (`packages/apple`, `packages/google`, `kmp-iap`, + `OpenIap.Maui`, `flutter_inapp_purchase`, `godot-iap`) pin their platform + 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. + +## What GitHub's dependency graph does and does not see + +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 +``` + +GitHub's [dependency graph](https://docs.github.com/en/code-security/supply-chain-security/understanding-your-software-supply-chain/dependency-graph-supported-package-ecosystems) +does not parse this repository's dependency state: Bun lockfiles are not a +supported format, Gradle is not resolved from source, and this repository does +not commit `pubspec.lock` or `Package.resolved` because those are libraries. + +Consequences: + +- **Dependabot version updates work.** They read manifests directly, and open + pull requests for `packages/kit`, GitHub Actions, and the kit Dockerfile. +- **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. + +Closing this gap properly would mean submitting a snapshot through the +[dependency submission API](https://docs.github.com/en/rest/dependency-graph/dependency-submission), +which is tracked as future work rather than done here. + +## Known gaps + +Recorded rather than silently carried. Each is repository-wide work that does +not belong to whichever change surfaced it. + +| Gap | Why it is open | +| ---------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **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 | + +`/audit-security` re-checks each of these and prints the current state. + +## Scanning posture + +OpenIAP does not run a separate vulnerability scanner +([Trivy](https://github.com/aquasecurity/trivy), +[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. +- `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 + cannot yet act on better than the update stream. + +The published SBOMs make this reversible without rework: any CycloneDX-consuming +scanner can be pointed at a release asset — including by consumers, on their own +schedule — without changes here. See +[SBOM.md](SBOM.md#verification) for the tools that accept one. If +`packages/kit` grows a deployment story where image scanning matters, that is +the point to revisit it. + +## Adding a releasable component + +`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. diff --git a/security/SBOM.md b/security/SBOM.md new file mode 100644 index 000000000..72b96c137 --- /dev/null +++ b/security/SBOM.md @@ -0,0 +1,352 @@ +# Software Bill of Materials (SBOM) + +## 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?_ + +SBOMs are generated from the same manifests the build reads. No one edits an +SBOM by hand, and none are committed to the repository. + +## Scope + +One SBOM per **releasable component**, not one per repository. A single +monorepo-wide document would describe an artifact nobody installs. + +The component list is not maintained here. It is read from the release +single-source-of-truth, `scripts/release-branch-policy.mjs`, so a component +cannot be released without also being described: + +| Component | SBOM name | Distribution | Release tag | +| -------------- | ------------------------ | -------------------------------- | ------------------------------- | +| `apple` | `openiap-apple` | 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-` | +| `conformance` | `openiap-conformance` | npm | `openiap-conformance-` | +| `flutter` | `flutter_inapp_purchase` | pub.dev | `flutter-iap-` | +| `kmp` | `kmp-iap` | Maven Central | `kmp-iap-` | +| `maui` | `OpenIap.Maui` | NuGet | `maui-iap-` | +| `godot` | `godot-iap` | GitHub Release | `godot-iap-` | +| `docs` | `openiap-spec` | GitHub Release | `docs-` | + +`packages/kit` (IAPKit) is deliberately outside this list. It is a deployed +service rather than a distributed package: consumers call it over HTTPS and +never install its dependency tree. Its dependencies are monitored through +Dependabot instead — see [README.md](README.md). + +## Standards + +| Concern | Standard | +| ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Document format | [CycloneDX 1.6](https://cyclonedx.org/specification/overview/), JSON encoding ([schema](https://github.com/CycloneDX/specification)) | +| Component identity | [Package URL (purl)](https://github.com/package-url/purl-spec) | +| License identity | [SPDX license identifiers](https://spdx.org/licenses/) | +| Vulnerability data | [CycloneDX VEX](https://cyclonedx.org/capabilities/vex/) | +| Attestation | [in-toto](https://in-toto.io/) statements carrying [SLSA provenance](https://slsa.dev/provenance/v1), signed through [Sigstore](https://www.sigstore.dev/) | + +CycloneDX is the primary format. It was chosen over SPDX because purl coverage +across the six ecosystems this repository publishes into (npm, Maven, NuGet, +pub, CocoaPods, generic) is more direct, and because vulnerability tooling in +these ecosystems consumes CycloneDX with less translation. There is no second +format: publishing two documents that can disagree is a liability, not a +feature. + +## What this system depends on + +An SBOM pipeline is itself a supply-chain surface, so its own inputs are listed +here rather than left implicit. + +**The generator** (`scripts/generate-sbom.mjs`, `scripts/sbom-dependencies.mjs`) +uses **only the Node.js standard library** — no npm dependency, no vendored +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: + +| Registry | Used for | +| ------------------------------------------------- | --------------------------- | +| [Maven Central](https://repo1.maven.org/maven2/) | Maven coordinate POMs | +| [Google Maven](https://maven.google.com/) | androidx / com.android POMs | +| [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. + +**In CI**, `.github/workflows/sbom.yml` uses these actions: + +| Action | Purpose | License | +| --------------------------------------------------------------------------------------- | -------------------------- | ------- | +| [`actions/checkout`](https://github.com/actions/checkout) | Check out the released tag | MIT | +| [`actions/setup-node`](https://github.com/actions/setup-node) | Provide the Node runtime | MIT | +| [`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. + +## Naming convention + +```text +-.cdx.json +``` + +Concretely: + +```text +react-native-iap-16.3.0.cdx.json +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. + +## Generation + +```bash +bun run sbom # writes ./sbom/-.cdx.json +bun run sbom --with-licenses # also resolve licenses from registries +bun run sbom --stdout # print instead of writing +bun run sbom resolve-tag # which component does this tag belong to? +``` + +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` | + +### What is included + +**Runtime dependencies of the published artifact.** Direct dependencies always; +transitive dependencies when a resolver export is supplied (see below). + +### What is excluded, and why + +- **Test and build-only dependencies.** `testImplementation`, + `androidTestImplementation`, `compileOnly`, annotation processors, and NuGet + references marked `PrivateAssets="all"` never reach a consumer. Listing them + would inflate the apparent attack surface of the shipped artifact with code + that is not in it. +- **`devDependencies`.** Same reasoning. Note that the npm packages here + declare no runtime `dependencies` at all, so their SBOMs are legitimately + empty of third-party components — that is a property of the artifact, not a + gap in the tooling. +- **`peerDependencies`.** The host application supplies and versions these + (React, React Native, Expo, Flutter SDK). They are part of the consuming + application's SBOM, not ours. +- **Operating-system frameworks.** StoreKit is not a distributed package. + +### 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. + +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 +license name, because downstream tooling treats `license.id` as authoritative +and a confident wrong identifier is worse than an absent one. A lookup failure +leaves the field empty rather than failing the release — license data is +compliance metadata, not part of the security inventory. + +Every direct dependency resolves a license except two structural cases, which +are limitations of the source metadata rather than bugs: + +- **pub.dev packages** — Dart declares licensing in a `LICENSE` file, and + package metadata exposes no standard license field. +- **NuGet packages whose nuspec carries only a license URL** that does not map + to an SPDX identifier, such as `Xamarin.Android.Google.BillingClient`. + +`bun run sbom --with-licenses` prints the resolved count for a +component, so current coverage is checkable rather than quoted here — a fixed +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: + +```bash +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. + +Where a manifest declares a coordinate 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. + +**Do not keep growing the parsers.** When transitive support is implemented, +move these ecosystems onto their own resolvers instead: + +| Ecosystem | Replace parser with | +| --------- | -------------------------------------------------------------- | +| Gradle | `cyclonedx-gradle-plugin`, or `gradlew ::dependencies` | +| NuGet | `dotnet list package --include-transitive --format json` | +| pub | `flutter pub deps --json` | + +That removes roughly 350 lines of parsing and delivers the transitive closure +in the same change — the ecosystem readers shrink rather than grow. It was not +done in the initial implementation because no JDK, Flutter, or .NET toolchain +was available to verify the result, and unverified code in a release path is +worse than a verified stopgap. + +Until then, the parsers are safe to rely on for one reason: a coordinate they +cannot resolve raises an error instead of being dropped, and the tests read the +real manifests in this repository, so drift fails CI rather than silently +shortening an inventory. + +## 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. + +```text +release workflow → GitHub Release published + │ + ▼ + sbom.yml (release: published) + │ + ┌───────────────┼───────────────┐ + ▼ ▼ ▼ + resolve component generate SBOM verify identity + from the tag at the tagged (version, tag, + commit commit, no paths) + │ + ┌─────────┴─────────┐ + ▼ ▼ + attest provenance upload as release asset +``` + +Before upload, the workflow asserts that the SBOM's version, release tag, and +commit all match the release being processed, and that no local filesystem +path leaked into the document. Any mismatch fails the run. + +Tags that do not belong to a component are skipped with a notice rather than +failing. + +## Storage location + +Generated SBOMs live **only** as assets on their GitHub Release: + +```text +https://github.com/hyodotdev/openiap/releases/tag/ + └── -.cdx.json +``` + +They are not committed. `sbom/` and `*.cdx.json` are gitignored. A checked-in +SBOM would be a second source of truth that drifts from the release it claims +to describe, and would add noise to every dependency-changing pull request. + +## Verification + +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' + +# 2. Confirm this repository's CI produced it +gh attestation verify react-native-iap-16.3.0.cdx.json \ + --repo hyodotdev/openiap + +# 3. Validate it against the CycloneDX schema +cyclonedx validate --input-file react-native-iap-16.3.0.cdx.json \ + --input-format json --input-version v1_6 --fail-on-errors +``` + +Every tool above is independent of this repository, so verification does not +require trusting our tooling: + +| Tool | Role | License | +| ------------------------------------------------------------------------------ | -------------------------------------------- | ---------- | +| [`gh attestation verify`](https://cli.github.com/manual/gh_attestation_verify) | Confirm CI provenance against Sigstore | MIT | +| [`cyclonedx-cli`](https://github.com/CycloneDX/cyclonedx-cli) | Validate against the published schema | Apache-2.0 | +| [`bomlens`](https://github.com/sktelecom/bomlens) | Local-first SBOM validation and risk report | Apache-2.0 | +| [`osv-scanner`](https://github.com/google/osv-scanner) | Match components against the OSV database | Apache-2.0 | +| [`grype`](https://github.com/anchore/grype) | Match components against vulnerability feeds | Apache-2.0 | + +OpenIAP runs none of these in CI — see [README.md](README.md#scanning-posture) +for why — but each accepts a CycloneDX 1.6 document directly, so a consumer can +point their own scanner at a release asset on their own schedule. + +Maintainers can additionally reproduce it. Generation is deterministic for a +given commit — the document timestamp is the commit timestamp and the serial +number is derived from the release identity, not randomly generated — so +regenerating at the released commit yields a byte-identical file: + +```bash +git checkout react-native-iap-16.3.0 +bun run sbom react-native --output-dir /tmp/verify +diff /tmp/verify/react-native-iap-16.3.0.cdx.json ./react-native-iap-16.3.0.cdx.json +``` + +## 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. +- If a release predates this system, its SBOM can be generated retroactively + with `workflow_dispatch` against that tag. The result describes that tag's + commit, not today's `main`. +- Changes to the generator are covered by `scripts/generate-sbom.test.mjs`, + which runs in CI on every pull request. The test asserting that every + releasable component has SBOM metadata fails if a new component is added + without one. + +## Relationship to vulnerability management + +The SBOM is an input to vulnerability response, not the goal: + +```text +SBOM (per released version) + │ + ▼ +dependency inventory ←── Dependabot alerts (packages/kit, GitHub Actions, Docker) + │ + ▼ +affected-version analysis ── "which shipped releases contain this CVE?" + │ + ▼ +security advisory + patch + │ + ▼ +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. + +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 new file mode 100644 index 000000000..25e8dffcf --- /dev/null +++ b/security/openchain.md @@ -0,0 +1,83 @@ +# OpenChain gap assessment + +A self-assessment of OpenIAP against **ISO/IEC 18974** (OpenChain Security +Assurance) and, where relevant, **ISO/IEC 5230** (OpenChain License +Compliance). It records what exists, what does not, and what is deliberately +out of scope. + +This is an internal gap list, **not a conformance claim**. OpenIAP has not +submitted a self-certification. + +Reference material, all openly licensed: + +| Source | Use | +| --------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- | +| [OpenChain ISO/IEC 18974](https://openchainproject.org/security-assurance) ([spec text](https://github.com/OpenChain-Project/Security-Assurance-Specification)) | Security-assurance requirements assessed below | +| [OpenChain ISO/IEC 5230](https://openchainproject.org/license-compliance) | License-compliance requirements | +| [Trusted OSS](https://trustedoss.github.io/en) | Self-study guide mapping the standards to concrete evidence | + +Why bother: 18974 is the closest existing standard to what the CRA expects of +software suppliers, and it is expressed as verifiable materials rather than +legal language. Closing these gaps improves the security programme regardless +of which regulation applies. It also aligns with the foundation track, since +OpenChain is a Linux Foundation project. + +## ISO/IEC 18974 — Security Assurance + +| 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.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.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 | + +## ISO/IEC 5230 — License Compliance (partial view) + +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 | + +## Proportionality + +Several 18974 requirements — competence assessment, awareness training, +staffing adequacy — assume an organisation with employees. OpenIAP is a +small-maintainer open-source project. Producing HR-shaped evidence for a +project this size would be paperwork that no one reads and that no consumer +benefits from. + +The honest position: these are marked **Missing** rather than +_Not applicable_, because they would become real if OpenIAP moves under a +foundation. Until then, the requirements worth closing are the ones that +produce something a consumer or downstream manufacturer can actually use. + +## Priority + +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 + 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. + 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 + two genuinely absent practice areas. +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 +technical evidence chain — SBOM, provenance, release-tag integrity — is +already in place. diff --git a/security/vex/README.md b/security/vex/README.md new file mode 100644 index 000000000..16983d62a --- /dev/null +++ b/security/vex/README.md @@ -0,0 +1,91 @@ +# VEX statements + +A VEX (Vulnerability Exploitability eXchange) statement records **whether a +known vulnerability actually affects an OpenIAP component**. Its purpose is to +answer the question a consumer's scanner raises: their tool matched a CVE +against a dependency in our SBOM, and they need to know whether it is +reachable in our product. + +## Why this is not generated + +Everything else in `security/` is produced automatically from the manifests the +build reads. VEX is the deliberate exception. Whether a CVE is exploitable +through OpenIAP's use of a dependency is an engineering judgement — no tool can +derive it. What automation does here is narrower: make sure a recorded +judgement ships with the release it applies to, and reject a malformed one. + +**There is normally no file in this directory.** A file appears only when a CVE +has been analysed against a released component. An empty analysis is not +published, because an empty `vulnerabilities` array would read as "we checked +and found nothing" — a stronger claim than silence. + +## Format + +One file per component, named after its component id (`google.json`, +`react-native.json`, …). Use the ids from `bun run sbom` — the same ids as the +release SSOT. + +```json +{ + "vulnerabilities": [ + { + "id": "CVE-2026-12345", + "source": { + "name": "NVD", + "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-12345" + }, + "affects": [{ "ref": "pkg:maven/com.example/library@1.2.3" }], + "analysis": { + "state": "not_affected", + "justification": "code_not_reachable", + "detail": "The vulnerable XML parser is only invoked by the library's servlet entry point, which OpenIAP does not use. OpenIAP calls only the billing result mapper." + } + } + ] +} +``` + +The `affects[].ref` must match a `bom-ref` in the component's SBOM — that is +the purl of the dependency, exactly as generated. + +## States + +CycloneDX defines six analysis states +([VEX capability](https://cyclonedx.org/capabilities/vex/), +[`vulnerabilities` schema](https://cyclonedx.org/docs/1.6/json/#vulnerabilities)): + +| State | Meaning | +| ------------------------ | --------------------------------------------------------- | +| `in_triage` | Received, assessment under way | +| `exploitable` | Confirmed to affect this component | +| `not_affected` | Present in the dependency tree, but not exploitable here | +| `false_positive` | The matcher is wrong — the vulnerable code is not present | +| `resolved` | Fixed in this version | +| `resolved_with_pedigree` | Fixed, with the modification recorded in pedigree | + +`not_affected` and `false_positive` **require** a `justification` or `detail`. +A bare "not affected" is not reviewable, and reviewers are the entire point of +publishing one. Generation fails without it. + +Standard justifications include `code_not_present`, `code_not_reachable`, +`requires_configuration`, `requires_dependency`, `requires_environment`, +`protected_by_compiler`, `protected_at_runtime`, `protected_at_perimeter`, +and `protected_by_mitigating_control`. + +## How it reaches consumers + +Statements are merged into the component's SBOM at release time and published +as part of the same `.cdx.json` asset. There is no separate VEX document to +find or verify — the SBOM a consumer already downloaded carries the analysis, +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. + +See [`../SBOM.md`](../SBOM.md) for the inventory these statements annotate and +[`../../SECURITY.md`](../../SECURITY.md) for the reporting process that +produces them.