diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 14e4186..c22d377 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,3 +24,10 @@ jobs: - run: pnpm run lint - run: pnpm run test - run: pnpm run build + + # Generate both SBOMs and verify them against package.json, so a dependency + # change that breaks the SBOM turns the pull request red before it reaches a release. + - name: Generate and verify SBOM + run: | + pnpm run sbom + pnpm run sbom:verify diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d39a45b..d35626f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -53,6 +53,14 @@ jobs: - run: pnpm run test - run: pnpm run build + # Generate and verify the SBOMs BEFORE anything is published. A failure here aborts + # the job while the tag can still be re-cut: no release ever ships without a valid SBOM, + # and no version reaches the npm staging queue without one. + - name: Generate and verify SBOM + run: | + pnpm run sbom + pnpm run sbom:verify + # Uploads the tarball to the npm staging queue via OIDC — no NPM_TOKEN needed # (pnpm auto-detects the GitHub Actions OIDC token via id-token: write). # Provenance is generated automatically for public repos. The version does NOT @@ -62,8 +70,15 @@ jobs: run: pnpm stage publish --access public --no-git-checks # Created as a draft so the GitHub release is published by hand together with - # the npm approval — keeping both behind the same human 2FA gate. - - name: Create draft GitHub release - run: gh release create "${GITHUB_REF_NAME}" --generate-notes --verify-tag --draft + # the npm approval — keeping both behind the same human 2FA gate. Both SBOMs are + # attached as assets; the --notes text names sbom.cdx.json as the authoritative + # document so an auditor answering a supplier assessment picks the right one. + - name: Create draft GitHub release with SBOM assets + run: | + gh release create "${GITHUB_REF_NAME}" \ + --generate-notes --verify-tag --draft \ + --notes "**SBOM:** \`sbom.cdx.json\` is the authoritative CycloneDX SBOM for this release (runtime dependencies, including peer dependencies). \`sbom-dev.cdx.json\` covers the build toolchain only and is not authoritative." \ + 'sbom/sbom.cdx.json#Authoritative runtime SBOM (CycloneDX)' \ + 'sbom/sbom-dev.cdx.json#Build toolchain SBOM (non-authoritative)' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 6de27aa..338ec94 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ node_modules/ dist/ +sbom/ *.tsbuildinfo .env .claude/settings.local.json diff --git a/README.md b/README.md index 4ad3ece..8a533e4 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,33 @@ Import brand CSS in your app's stylesheet: import { de, en } from "@open-elements/ui"; ``` +## Software Bill of Materials (SBOM) + +Every release publishes two [CycloneDX](https://cyclonedx.org/) 1.7 SBOMs as assets on its +[GitHub Release](https://github.com/OpenElementsLabs/open-elements-ui/releases), so a specific +published version can be obtained without an `npm install`: + +| Asset | Contents | Authoritative? | +| --- | --- | --- | +| `sbom.cdx.json` | Runtime dependencies (transitive) plus the library's peer dependencies | **Yes** — use this for supplier assessments (Cyber Resilience Act) | +| `sbom-dev.cdx.json` | The build toolchain (`devDependencies`) | No — provided for transparency only | + +Both are generated locally with the pinned `pnpm` (`pnpm sbom`) and verified in CI on every pull +request, so a dependency change that breaks the SBOM turns the build red. A release cannot ship without +a valid SBOM. + +```bash +pnpm sbom # writes sbom/sbom.cdx.json and sbom/sbom-dev.cdx.json (gitignored) +pnpm sbom:verify # validates both against the CycloneDX 1.7 schema and package.json +``` + +**Peer dependencies.** `radix-ui`, `@base-ui/react`, `lucide-react`, `react` and `react-dom` are peer +dependencies: the consumer supplies them. `pnpm sbom` alone omits them, so they are added to +`sbom.cdx.json` and marked with a `cdx:npm:peer` property holding the declared range. The **version** +recorded for each peer is the one resolved in _this repository's_ lockfile, not the one a consumer +installs — it changes when we bump our own devDependencies, even though nothing changes for the +consumer. Read a peer's `cdx:npm:peer` range, not its pinned version, as the requirement. + ## Releasing a New Version Every release must be published to npm **and** have a corresponding Git tag and GitHub Release. diff --git a/package.json b/package.json index c09dc38..df87dfa 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,8 @@ "format": "prettier --write src/", "format:check": "prettier --check src/", "typecheck": "tsc --noEmit", + "sbom": "node scripts/generate-sbom.mjs", + "sbom:verify": "node scripts/verify-sbom.mjs", "prepublishOnly": "pnpm run build" }, "dependencies": { @@ -80,6 +82,10 @@ "react-dom": "^19.0.0", "@base-ui/react": "^1.3.0", "radix-ui": "^1.4.0", - "lucide-react": "^0.500.0" + "lucide-react": "^0.500.0", + "@cyclonedx/cyclonedx-library": "^10.2.0", + "ajv": "^8.20.0", + "ajv-formats": "^3.0.1", + "ajv-formats-draft2019": "^1.6.1" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5a689dc..3651712 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -45,6 +45,9 @@ importers: '@base-ui/react': specifier: ^1.3.0 version: 1.4.1(@date-fns/tz@1.4.1)(@types/react@19.2.14)(date-fns@4.1.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@cyclonedx/cyclonedx-library': + specifier: ^10.2.0 + version: 10.2.0(ajv-formats-draft2019@1.6.1(ajv@8.20.0))(ajv-formats@3.0.1(ajv@8.20.0))(ajv@8.20.0) '@eslint/js': specifier: ^9.0.0 version: 9.39.4 @@ -63,6 +66,15 @@ importers: '@vitejs/plugin-react': specifier: ^4.0.0 version: 4.7.0(vite@7.3.2) + ajv: + specifier: ^8.20.0 + version: 8.20.0 + ajv-formats: + specifier: ^3.0.1 + version: 3.0.1(ajv@8.20.0) + ajv-formats-draft2019: + specifier: ^1.6.1 + version: 1.6.1(ajv@8.20.0) eslint: specifier: ^9.0.0 version: 9.39.4 @@ -244,6 +256,33 @@ packages: resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} engines: {node: '>=18'} + '@cyclonedx/cyclonedx-library@10.2.0': + resolution: {integrity: sha512-hGeo1XXM0zuIeTyzJihxPxnEOeNBmgZkuPRmTR28RktlAqF9xLneonHRCzahZHLgCq/LOmtCs5vyojJlnmJ87w==} + engines: {node: '>=20.18.0'} + peerDependencies: + ajv: ^8.12.0 + ajv-formats: ^3.0.1 + ajv-formats-draft2019: ^1.6.1 + libxmljs2: ^0.35||^0.37 + packageurl-js: '*' + spdx-expression-parse: '*' + xmlbuilder2: ^3.0.2||^4.0.0 + peerDependenciesMeta: + ajv: + optional: true + ajv-formats: + optional: true + ajv-formats-draft2019: + optional: true + libxmljs2: + optional: true + packageurl-js: + optional: true + spdx-expression-parse: + optional: true + xmlbuilder2: + optional: true + '@date-fns/tz@1.4.1': resolution: {integrity: sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==} @@ -1668,9 +1707,25 @@ packages: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} + ajv-formats-draft2019@1.6.1: + resolution: {integrity: sha512-JQPvavpkWDvIsBp2Z33UkYCtXCSpW4HD3tAZ+oL4iEFOk9obQZffx0yANwECt6vzr6ET+7HN5czRyqXbnq/u0Q==} + peerDependencies: + ajv: '*' + + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + ajv@6.14.0: resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==} + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -1762,6 +1817,9 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + commander@2.20.3: + resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} + concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} @@ -1818,6 +1876,9 @@ packages: detect-node-es@1.1.0: resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + discontinuous-range@1.0.0: + resolution: {integrity: sha512-c68LpLbO+7kP/b1Hr1qs8/BJ09F5khZGTxqxZuhzxpmwJKOgRFHJWIb9/KmqnqHhLdO55aOxFH/EGBvUQbL/RQ==} + dom-accessibility-api@0.5.16: resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} @@ -1870,6 +1931,7 @@ packages: eslint@9.39.4: resolution: {integrity: sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. hasBin: true peerDependencies: jiti: '*' @@ -1904,6 +1966,9 @@ packages: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -1917,6 +1982,9 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-uri@3.1.6: + resolution: {integrity: sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -2046,6 +2114,9 @@ packages: json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} @@ -2116,6 +2187,9 @@ packages: minimatch@3.1.5: resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + moo@0.5.3: + resolution: {integrity: sha512-m2fmM2dDm7GZQsY7KK2cme8agi+AAljILjQnof7p1ZMDe6dQ4bdnSMx0cPppudoeNv5hEFQirN6u+O4fDE0IWA==} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -2127,6 +2201,10 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + nearley@2.20.1: + resolution: {integrity: sha512-+Mc8UaAebFzgV+KpI5n7DasuuQCHA89dmwm7JXw3TV43ukfNQ9DnBH3Mdb2g/I4Fdxc26pwimBWvjIw0UAILSQ==} + hasBin: true + node-releases@2.0.38: resolution: {integrity: sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==} @@ -2254,6 +2332,13 @@ packages: '@types/react-dom': optional: true + railroad-diagrams@1.0.0: + resolution: {integrity: sha512-cz93DjNeLY0idrCNOH6PviZGRN9GJhsdm9hpn1YCS879fj4W+x5IFJhhkRZcwVgMmFF7R82UA/7Oh+R8lLZg6A==} + + randexp@0.4.6: + resolution: {integrity: sha512-80WNmd9DA0tmZrw9qQa62GPPWfuXJknrmVmLcxvq4uZBdYqb1wYoKTmnlGUchvVWe0XiLupYkBoXVOxz3C8DYQ==} + engines: {node: '>=0.12'} + react-day-picker@9.14.0: resolution: {integrity: sha512-tBaoDWjPwe0M5pGrum4H0SR6Lyk+BO9oHnp9JbKpGKW2mlraNPgP9BMfsg5pWpwrssARmeqk7YBl2oXutZTaHA==} engines: {node: '>=18'} @@ -2310,6 +2395,10 @@ packages: resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} engines: {node: '>=8'} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + reselect@5.1.1: resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==} @@ -2317,6 +2406,10 @@ packages: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} + ret@0.1.15: + resolution: {integrity: sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==} + engines: {node: '>=0.12'} + rollup@4.60.2: resolution: {integrity: sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -2338,6 +2431,9 @@ packages: scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + schemes@1.4.0: + resolution: {integrity: sha512-ImFy9FbCsQlVgnE3TCWmLPCFnVzx0lHL/l+umHplDqAKd0dzFpnS6lFZIpagBlYhKwzVmlV36ec0Y1XTu8JBAQ==} + semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true @@ -2358,6 +2454,10 @@ packages: siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + smtp-address-parser@1.1.0: + resolution: {integrity: sha512-Gz11jbNU0plrReU9Sj7fmshSBxxJ9ShdD2q4ktHIHo/rpTH6lFyQoYHYKINPJtPe8aHFnsbtW46Ls0tCCBsIZg==} + engines: {node: '>=0.10'} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -2801,6 +2901,12 @@ snapshots: '@csstools/css-tokenizer@3.0.4': {} + '@cyclonedx/cyclonedx-library@10.2.0(ajv-formats-draft2019@1.6.1(ajv@8.20.0))(ajv-formats@3.0.1(ajv@8.20.0))(ajv@8.20.0)': + optionalDependencies: + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + ajv-formats-draft2019: 1.6.1(ajv@8.20.0) + '@date-fns/tz@1.4.1': {} '@esbuild/aix-ppc64@0.27.7': @@ -4225,6 +4331,18 @@ snapshots: agent-base@7.1.4: {} + ajv-formats-draft2019@1.6.1(ajv@8.20.0): + dependencies: + ajv: 8.20.0 + punycode: 2.3.1 + schemes: 1.4.0 + smtp-address-parser: 1.1.0 + uri-js: 4.4.1 + + ajv-formats@3.0.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + ajv@6.14.0: dependencies: fast-deep-equal: 3.1.3 @@ -4232,6 +4350,13 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.6 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + ansi-regex@5.0.1: {} ansi-styles@4.3.0: @@ -4310,6 +4435,8 @@ snapshots: color-name@1.1.4: {} + commander@2.20.3: {} + concat-map@0.0.1: {} convert-source-map@2.0.0: {} @@ -4352,6 +4479,8 @@ snapshots: detect-node-es@1.1.0: {} + discontinuous-range@1.0.0: {} + dom-accessibility-api@0.5.16: {} dom-accessibility-api@0.6.3: {} @@ -4471,6 +4600,8 @@ snapshots: expect-type@1.3.0: {} + extend@3.0.2: {} + fast-deep-equal@3.1.3: {} fast-equals@5.4.0: {} @@ -4479,6 +4610,8 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-uri@3.1.6: {} + fdir@6.5.0(picomatch@4.0.4): optionalDependencies: picomatch: 4.0.4 @@ -4600,6 +4733,8 @@ snapshots: json-schema-traverse@0.4.1: {} + json-schema-traverse@1.0.0: {} + json-stable-stringify-without-jsonify@1.0.1: {} json5@2.2.3: {} @@ -4666,12 +4801,21 @@ snapshots: dependencies: brace-expansion: 1.1.14 + moo@0.5.3: {} + ms@2.1.3: {} nanoid@3.3.11: {} natural-compare@1.4.0: {} + nearley@2.20.1: + dependencies: + commander: 2.20.3 + moo: 0.5.3 + railroad-diagrams: 1.0.0 + randexp: 0.4.6 + node-releases@2.0.38: {} nwsapi@2.2.23: {} @@ -4873,6 +5017,13 @@ snapshots: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) + railroad-diagrams@1.0.0: {} + + randexp@0.4.6: + dependencies: + discontinuous-range: 1.0.0 + ret: 0.1.15 + react-day-picker@9.14.0(react@19.2.5): dependencies: '@date-fns/tz': 1.4.1 @@ -4924,10 +5075,14 @@ snapshots: indent-string: 4.0.0 strip-indent: 3.0.0 + require-from-string@2.0.2: {} + reselect@5.1.1: {} resolve-from@4.0.0: {} + ret@0.1.15: {} + rollup@4.60.2: dependencies: '@types/estree': 1.0.8 @@ -4971,6 +5126,10 @@ snapshots: scheduler@0.27.0: {} + schemes@1.4.0: + dependencies: + extend: 3.0.2 + semver@6.3.1: {} semver@7.7.4: {} @@ -4983,6 +5142,10 @@ snapshots: siginfo@2.0.0: {} + smtp-address-parser@1.1.0: + dependencies: + nearley: 2.20.1 + source-map-js@1.2.1: {} stackback@0.0.2: {} diff --git a/scripts/__tests__/sbom-core.test.mjs b/scripts/__tests__/sbom-core.test.mjs new file mode 100644 index 0000000..c8a5cc8 --- /dev/null +++ b/scripts/__tests__/sbom-core.test.mjs @@ -0,0 +1,366 @@ +// Behavioral coverage for the SBOM core logic (specs/005-cyclonedx-sbom-release/behaviors.md). +// Everything runs against in-memory fixtures — no `pnpm sbom` subprocess — so the local test loop +// stays fast and deterministic (behavior: "The local test loop is unaffected"). + +import { describe, it, expect } from "vitest"; + +import { + PEER_PROPERTY, + SUPPLIER_NAME, + enrichWithPeers, + fullName, + sortBom, + verifyStructure, +} from "../sbom-core.mjs"; + +const ROOT = { name: "ui", group: "@open-elements" }; + +function ref({ name, group, version }) { + const encoded = group ? `${group.replace("@", "%40")}/${name}` : name; + return `pkg:npm/${encoded}@${version}`; +} + +function component(spec) { + const c = { type: "library", name: spec.name, version: spec.version, "bom-ref": ref(spec) }; + if (spec.group) c.group = spec.group; + if (spec.properties) c.properties = spec.properties; + return c; +} + +/** + * Build a CycloneDX-shaped document. `edges` are the root's direct dependencies; `extra` are + * additional components not linked from the root (e.g. transitive deps); `childEdges` are extra + * `dependencies[]` entries (e.g. a peer's own subtree, to prove it is not carried over). + */ +function bom({ + version = "1.0.0", + supplier = SUPPLIER_NAME, + edges = [], + extra = [], + childEdges = [], +} = {}) { + const rootComp = { ...ROOT, version }; + const rootRef = ref(rootComp); + return { + bomFormat: "CycloneDX", + specVersion: "1.7", + metadata: { + component: { + type: "library", + name: rootComp.name, + group: rootComp.group, + version, + "bom-ref": rootRef, + }, + ...(supplier === null ? {} : { supplier: { name: supplier } }), + }, + components: [...edges, ...extra].map(component), + dependencies: [{ ref: rootRef, dependsOn: edges.map(ref) }, ...childEdges], + }; +} + +function pkg(overrides = {}) { + return { + version: "1.0.0", + dependencies: {}, + peerDependencies: {}, + devDependencies: {}, + ...overrides, + }; +} + +function rootEdges(document) { + const rootRef = document.metadata.component["bom-ref"]; + return document.dependencies.find((d) => d.ref === rootRef).dependsOn; +} + +function peerComponent(document, name) { + return document.components.find((c) => fullName(c) === name); +} + +describe("fullName", () => { + it("joins scope and name for scoped packages", () => { + expect(fullName({ group: "@base-ui", name: "react" })).toBe("@base-ui/react"); + }); + + it("returns the bare name for unscoped packages", () => { + expect(fullName({ name: "radix-ui" })).toBe("radix-ui"); + }); +}); + +describe("enrichWithPeers", () => { + it("adds absent peers as components with the lockfile version, marker, and a root edge", () => { + const prod = bom({ edges: [{ name: "dep-a", version: "1.0.0" }] }); + const dev = bom({ + edges: [ + { name: "radix-ui", version: "1.4.3" }, + { name: "react", group: "@base-ui", version: "1.4.1" }, + ], + }); + const manifest = pkg({ + dependencies: { "dep-a": "^1.0.0" }, + peerDependencies: { "radix-ui": "^1.4.0", "@base-ui/react": "^1.3.0" }, + devDependencies: { "radix-ui": "^1.4.0", "@base-ui/react": "^1.3.0" }, + }); + + enrichWithPeers(prod, dev, manifest); + + const radix = peerComponent(prod, "radix-ui"); + expect(radix.version).toBe("1.4.3"); + expect(radix.properties).toContainEqual({ name: PEER_PROPERTY, value: "^1.4.0" }); + expect(rootEdges(prod)).toContain(ref({ name: "radix-ui", version: "1.4.3" })); + + const baseUi = peerComponent(prod, "@base-ui/react"); + expect(baseUi.version).toBe("1.4.1"); + expect(baseUi.properties).toContainEqual({ name: PEER_PROPERTY, value: "^1.3.0" }); + }); + + it("does not duplicate a peer already present transitively", () => { + const react = { name: "react", version: "19.2.5" }; + // react is a transitive dep (component present, but not a direct root edge). + const prod = bom({ + edges: [{ name: "react-day-picker", version: "9.14.0" }], + extra: [react], + childEdges: [ + { ref: ref({ name: "react-day-picker", version: "9.14.0" }), dependsOn: [ref(react)] }, + ], + }); + const dev = bom({ edges: [react] }); + const manifest = pkg({ + dependencies: { "react-day-picker": "^9.14.0" }, + peerDependencies: { react: "^19.0.0" }, + devDependencies: { react: "^19.0.0" }, + }); + + enrichWithPeers(prod, dev, manifest); + + const reacts = prod.components.filter((c) => fullName(c) === "react"); + expect(reacts).toHaveLength(1); + expect(reacts[0].properties).toContainEqual({ name: PEER_PROPERTY, value: "^19.0.0" }); + expect(rootEdges(prod).filter((r) => r === ref(react))).toHaveLength(1); + }); + + it("does not expand a peer's transitive subtree", () => { + const radix = { name: "radix-ui", version: "1.4.3" }; + const radixChild = { name: "react-remove-scroll", version: "2.0.0" }; + // In the dev document radix-ui depends on a child; that child must not leak into prod. + const dev = bom({ + edges: [radix], + extra: [radixChild], + childEdges: [{ ref: ref(radix), dependsOn: [ref(radixChild)] }], + }); + const prod = bom({ edges: [{ name: "dep-a", version: "1.0.0" }] }); + const manifest = pkg({ + dependencies: { "dep-a": "^1.0.0" }, + peerDependencies: { "radix-ui": "^1.4.0" }, + devDependencies: { "radix-ui": "^1.4.0" }, + }); + + enrichWithPeers(prod, dev, manifest); + + expect(peerComponent(prod, "radix-ui")).toBeDefined(); + expect(peerComponent(prod, "react-remove-scroll")).toBeUndefined(); + expect(prod.dependencies.find((d) => d.ref === ref(radix))).toBeUndefined(); + }); + + it("throws when a peerDependency is not also a devDependency", () => { + const prod = bom(); + const dev = bom(); + const manifest = pkg({ peerDependencies: { "radix-ui": "^1.4.0" }, devDependencies: {} }); + + expect(() => enrichWithPeers(prod, dev, manifest)).toThrow( + /must also be declared as a devDependency/, + ); + }); + + it("is a no-op for a library without peerDependencies", () => { + const prod = bom({ edges: [{ name: "dep-a", version: "1.0.0" }] }); + const before = structuredClone(prod); + enrichWithPeers(prod, bom(), pkg({ dependencies: { "dep-a": "^1.0.0" } })); + expect(prod).toEqual(before); + }); +}); + +describe("sortBom", () => { + it("orders components by bom-ref and is idempotent", () => { + const document = bom({ + edges: [ + { name: "zeta", version: "1.0.0" }, + { name: "alpha", version: "1.0.0" }, + { name: "mid", version: "1.0.0" }, + ], + }); + sortBom(document); + const refs = document.components.map((c) => c["bom-ref"]); + expect(refs).toEqual([...refs].sort()); + + const once = structuredClone(document); + sortBom(document); + expect(document).toEqual(once); + }); +}); + +describe("verifyStructure — accepted", () => { + function correctPair() { + const prod = bom({ + edges: [ + { name: "dep-a", version: "1.0.0" }, + { + name: "radix-ui", + version: "1.4.3", + properties: [{ name: PEER_PROPERTY, value: "^1.4.0" }], + }, + ], + }); + const dev = bom({ + edges: [ + { name: "radix-ui", version: "1.4.3" }, + { name: "typescript", version: "5.0.0" }, + ], + }); + const manifest = pkg({ + dependencies: { "dep-a": "^1.0.0" }, + peerDependencies: { "radix-ui": "^1.4.0" }, + devDependencies: { "radix-ui": "^1.4.0", typescript: "^5.0.0" }, + }); + return { prod, dev, pkg: manifest }; + } + + it("passes a correct pair", () => { + expect(verifyStructure(correctPair())).toEqual([]); + }); + + it("counts exactly one root edge per declared dependency and peer", () => { + const { prod } = correctPair(); + // 1 dependency + 1 peer = 2 edges, mirroring the spec's 11 + 5 = 16. + expect(rootEdges(prod)).toHaveLength(2); + }); +}); + +describe("verifyStructure — rejected", () => { + const dev = () => + bom({ + edges: [ + { name: "radix-ui", version: "1.4.3" }, + { name: "typescript", version: "5.0.0" }, + ], + }); + const devDeps = { "radix-ui": "^1.4.0", typescript: "^5.0.0" }; + + it("reports a version mismatch", () => { + const prod = bom({ version: "0.9.0", edges: [{ name: "dep-a", version: "1.0.0" }] }); + const errors = verifyStructure({ + prod, + dev: dev(), + pkg: pkg({ + version: "0.10.0", + dependencies: { "dep-a": "^1.0.0" }, + devDependencies: devDeps, + }), + }); + expect(errors.some((e) => /version/.test(e) && /0\.10\.0/.test(e))).toBe(true); + }); + + it("reports a missing supplier", () => { + const prod = bom({ supplier: null, edges: [{ name: "dep-a", version: "1.0.0" }] }); + const errors = verifyStructure({ + prod, + dev: dev(), + pkg: pkg({ dependencies: { "dep-a": "^1.0.0" }, devDependencies: devDeps }), + }); + expect(errors.some((e) => /supplier/.test(e))).toBe(true); + }); + + it("names a newly added dependency missing from the SBOM", () => { + const prod = bom({ edges: [{ name: "dep-a", version: "1.0.0" }] }); + const errors = verifyStructure({ + prod, + dev: dev(), + pkg: pkg({ + dependencies: { "dep-a": "^1.0.0", "dep-new": "^2.0.0" }, + devDependencies: devDeps, + }), + }); + expect(errors.some((e) => /dep-new/.test(e) && /missing/.test(e))).toBe(true); + }); + + it("names a removed dependency still present as an edge", () => { + const prod = bom({ + edges: [ + { name: "dep-a", version: "1.0.0" }, + { name: "dep-gone", version: "3.0.0" }, + ], + }); + const errors = verifyStructure({ + prod, + dev: dev(), + pkg: pkg({ dependencies: { "dep-a": "^1.0.0" }, devDependencies: devDeps }), + }); + expect(errors.some((e) => /dep-gone/.test(e) && /extra/.test(e))).toBe(true); + }); + + it("names a peer missing from the runtime SBOM", () => { + const prod = bom({ edges: [{ name: "dep-a", version: "1.0.0" }] }); + const errors = verifyStructure({ + prod, + dev: dev(), + pkg: pkg({ + dependencies: { "dep-a": "^1.0.0" }, + peerDependencies: { "lucide-react": "^0.500.0" }, + devDependencies: { ...devDeps, "lucide-react": "^0.500.0" }, + }), + }); + expect(errors.some((e) => /lucide-react/.test(e) && /missing/.test(e))).toBe(true); + }); + + it("rejects a peer without the marker property", () => { + const prod = bom({ + edges: [ + { name: "dep-a", version: "1.0.0" }, + { name: "radix-ui", version: "1.4.3" }, + ], + }); + const errors = verifyStructure({ + prod, + dev: dev(), + pkg: pkg({ + dependencies: { "dep-a": "^1.0.0" }, + peerDependencies: { "radix-ui": "^1.4.0" }, + devDependencies: devDeps, + }), + }); + expect(errors.some((e) => /radix-ui/.test(e) && new RegExp(PEER_PROPERTY).test(e))).toBe(true); + }); + + it("reports a peer that is not a devDependency with an explicit message", () => { + const prod = bom({ + edges: [ + { + name: "radix-ui", + version: "1.4.3", + properties: [{ name: PEER_PROPERTY, value: "^1.4.0" }], + }, + ], + }); + const errors = verifyStructure({ + prod, + dev: dev(), + pkg: pkg({ + peerDependencies: { "radix-ui": "^1.4.0" }, + devDependencies: { typescript: "^5.0.0" }, + }), + }); + expect(errors.some((e) => /radix-ui/.test(e) && /devDependency/.test(e))).toBe(true); + }); + + it("names a devDependency missing from the dev SBOM", () => { + const prod = bom({ edges: [{ name: "dep-a", version: "1.0.0" }] }); + const devMissing = bom({ edges: [{ name: "radix-ui", version: "1.4.3" }] }); // typescript absent + const errors = verifyStructure({ + prod, + dev: devMissing, + pkg: pkg({ dependencies: { "dep-a": "^1.0.0" }, devDependencies: devDeps }), + }); + expect(errors.some((e) => /sbom-dev/.test(e) && /typescript/.test(e))).toBe(true); + }); +}); diff --git a/scripts/__tests__/sbom-io.test.mjs b/scripts/__tests__/sbom-io.test.mjs new file mode 100644 index 0000000..be6fec2 --- /dev/null +++ b/scripts/__tests__/sbom-io.test.mjs @@ -0,0 +1,75 @@ +// Coverage for the file-reading and schema-validation helpers used by verify-sbom.mjs +// (behaviors: a missing / malformed / schema-invalid document fails). No `pnpm sbom` subprocess: +// documents are written to a temp directory as fixtures. + +import { afterAll, beforeAll, describe, it, expect } from "vitest"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { readDocument, validateAgainstSchema } from "../sbom-core.mjs"; + +let dir; + +beforeAll(() => { + dir = mkdtempSync(join(tmpdir(), "sbom-io-")); +}); + +afterAll(() => { + rmSync(dir, { recursive: true, force: true }); +}); + +describe("readDocument", () => { + it("names a document that does not exist", () => { + const path = join(dir, "does-not-exist.cdx.json"); + const { errors, bom } = readDocument(path); + expect(bom).toBeNull(); + expect(errors[0]).toContain(path); + }); + + it("reports an empty document", () => { + const path = join(dir, "empty.cdx.json"); + writeFileSync(path, " \n"); + const { errors, bom } = readDocument(path); + expect(bom).toBeNull(); + expect(errors[0]).toMatch(/empty/); + }); + + it("reports a parse failure for malformed JSON, naming the file", () => { + const path = join(dir, "malformed.cdx.json"); + writeFileSync(path, "{ not valid json "); + const { errors, bom } = readDocument(path); + expect(bom).toBeNull(); + expect(errors[0]).toContain(path); + expect(errors[0]).toMatch(/JSON/); + }); + + it("parses a well-formed document", () => { + const path = join(dir, "ok.cdx.json"); + writeFileSync(path, JSON.stringify({ bomFormat: "CycloneDX", specVersion: "1.7" })); + const { errors, bom } = readDocument(path); + expect(errors).toEqual([]); + expect(bom.bomFormat).toBe("CycloneDX"); + }); +}); + +describe("validateAgainstSchema", () => { + it("accepts a document that satisfies the CycloneDX 1.7 schema", async () => { + const valid = { + bomFormat: "CycloneDX", + specVersion: "1.7", + version: 1, + metadata: { component: { type: "library", name: "ui", version: "1.0.0" } }, + components: [], + }; + expect(await validateAgainstSchema(valid, "ok.cdx.json")).toBeNull(); + }); + + it("rejects a document that violates the schema, naming the file", async () => { + const invalid = { bomFormat: "NOT-CYCLONEDX", specVersion: "1.7" }; + const error = await validateAgainstSchema(invalid, "bad.cdx.json"); + expect(error).not.toBeNull(); + expect(error).toContain("bad.cdx.json"); + expect(error).toMatch(/CycloneDX 1\.7 schema/); + }); +}); diff --git a/scripts/generate-sbom.mjs b/scripts/generate-sbom.mjs new file mode 100644 index 0000000..0d961d2 --- /dev/null +++ b/scripts/generate-sbom.mjs @@ -0,0 +1,72 @@ +#!/usr/bin/env node +// Generate the two CycloneDX SBOM documents into sbom/ (gitignored, kept out of the npm tarball). +// +// sbom/sbom.cdx.json runtime dependencies + enriched peerDependencies (authoritative) +// sbom/sbom-dev.cdx.json dependencies + devDependencies (build toolchain) (transparency) +// +// Runs `pnpm sbom` twice (the generator is part of the pinned packageManager), enriches the runtime +// document with peers, sorts both for content stability, and writes them. + +import { execFileSync } from "node:child_process"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { + SUPPLIER_NAME, + SBOM_AUTHORS, + SPEC_VERSION, + enrichWithPeers, + sortBom, +} from "./sbom-core.mjs"; + +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), ".."); +const outDir = join(repoRoot, "sbom"); + +function runSbom(scopeFlag) { + const stdout = execFileSync( + "pnpm", + [ + "sbom", + "--sbom-format", + "cyclonedx", + scopeFlag, + "--sbom-spec-version", + SPEC_VERSION, + "--sbom-type", + "library", + "--sbom-supplier", + SUPPLIER_NAME, + "--sbom-authors", + SBOM_AUTHORS, + ], + { cwd: repoRoot, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }, + ); + return JSON.parse(stdout); +} + +function writeDocument(name, bom) { + const path = join(outDir, name); + writeFileSync(path, `${JSON.stringify(bom, null, 2)}\n`); + return path; +} + +const pkg = JSON.parse( + execFileSync("node", ["-p", "JSON.stringify(require('./package.json'))"], { + cwd: repoRoot, + encoding: "utf8", + }), +); + +mkdirSync(outDir, { recursive: true }); + +// enrichWithPeers only reads the dev document (peers are structuredClone'd), so one run feeds both. +const dev = runSbom("--dev"); +const prod = sortBom(enrichWithPeers(runSbom("--prod"), dev, pkg)); +sortBom(dev); + +const prodPath = writeDocument("sbom.cdx.json", prod); +const devPath = writeDocument("sbom-dev.cdx.json", dev); + +console.log(`Wrote ${prodPath} (${prod.components.length} components, runtime + peers).`); +console.log(`Wrote ${devPath} (${dev.components.length} components, build toolchain).`); diff --git a/scripts/sbom-core.mjs b/scripts/sbom-core.mjs new file mode 100644 index 0000000..87aeed5 --- /dev/null +++ b/scripts/sbom-core.mjs @@ -0,0 +1,240 @@ +// Core logic for CycloneDX SBOM generation and verification. +// +// Everything here is pure and free of `pnpm sbom` subprocesses so it can be unit +// tested against fixtures without the installed store (see scripts/__tests__). +// The thin CLI wrappers generate-sbom.mjs and verify-sbom.mjs supply the I/O. + +import { readFileSync } from "node:fs"; + +/** Component type for the root package and the accountable supplier (CRA supplier assessment). */ +export const SUPPLIER_NAME = "Open Elements GmbH"; +export const SBOM_AUTHORS = "Open Elements GmbH"; +/** CycloneDX spec version. pnpm's default; validated by @cyclonedx/cyclonedx-library. */ +export const SPEC_VERSION = "1.7"; +/** Marks a component that was added as a peerDependency, holding its declared range. */ +export const PEER_PROPERTY = "cdx:npm:peer"; + +/** Package identity used to match a package.json dependency name against an SBOM component. */ +export function fullName(component) { + return component.group ? `${component.group}/${component.name}` : component.name; +} + +function rootDependency(bom) { + const rootRef = bom.metadata?.component?.["bom-ref"]; + const dep = bom.dependencies?.find((d) => d.ref === rootRef); + if (!dep) { + throw new Error("SBOM has no dependency entry for its root component."); + } + return dep; +} + +/** Direct dependency components of the root (one edge per declared dependency / devDependency). */ +function directComponents(bom) { + const byRef = new Map((bom.components ?? []).map((c) => [c["bom-ref"], c])); + return rootDependency(bom) + .dependsOn.map((ref) => byRef.get(ref)) + .filter(Boolean); +} + +/** + * Enrich the runtime SBOM with the library's peerDependencies. + * + * `pnpm sbom --prod` omits peers, which makes the runtime document misleading. Each peer is added + * with the exact version resolved in this repository's lockfile (read from the dev SBOM, where every + * peer is also a devDependency), deduplicated by package identity so a peer that is already present + * transitively is not duplicated, marked with a `cdx:npm:peer` property holding the declared range, + * and linked from the root via a `dependsOn` edge. The peer's own subtree is never expanded, because + * the consumer's resolution is unknown. + * + * Throws if a peerDependency is not also a devDependency: without that, no version can be resolved. + */ +export function enrichWithPeers(prodBom, devBom, pkg) { + const peers = pkg.peerDependencies ?? {}; + const devDeps = pkg.devDependencies ?? {}; + + const notDevDeps = Object.keys(peers).filter((name) => !(name in devDeps)); + if (notDevDeps.length > 0) { + throw new Error( + `Every peerDependency must also be declared as a devDependency so its version can be resolved. ` + + `Missing from devDependencies: ${notDevDeps.join(", ")}.`, + ); + } + + const rootDep = rootDependency(prodBom); + const devResolved = directComponents(devBom); + const prodByName = new Map((prodBom.components ?? []).map((c) => [fullName(c), c])); + + for (const [name, range] of Object.entries(peers)) { + const resolved = devResolved.find((c) => fullName(c) === name); + if (!resolved) { + throw new Error( + `Cannot resolve a version for peerDependency "${name}": it is not a direct devDependency ` + + `in the dev SBOM. Install it as a devDependency and regenerate.`, + ); + } + + let component = prodByName.get(name); + if (!component) { + // Add the peer as a component, but never its transitive subtree. + component = structuredClone(resolved); + prodBom.components.push(component); + prodByName.set(name, component); + } + + component.properties = component.properties ?? []; + if (!component.properties.some((p) => p.name === PEER_PROPERTY)) { + component.properties.push({ name: PEER_PROPERTY, value: range }); + } + + if (!rootDep.dependsOn.includes(component["bom-ref"])) { + rootDep.dependsOn.push(component["bom-ref"]); + } + } + + return prodBom; +} + +/** Sort a document into a stable order (components by bom-ref) so two runs are content-stable. */ +export function sortBom(bom) { + const byRef = (a, b) => (a["bom-ref"] < b["bom-ref"] ? -1 : a["bom-ref"] > b["bom-ref"] ? 1 : 0); + bom.components?.sort(byRef); + for (const dep of bom.dependencies ?? []) { + dep.dependsOn?.sort(); + } + bom.dependencies?.sort((a, b) => (a.ref < b.ref ? -1 : a.ref > b.ref ? 1 : 0)); + return bom; +} + +/** Names of the components the root directly depends on, as package identities. */ +function rootEdgeNames(bom) { + const byRef = new Map((bom.components ?? []).map((c) => [c["bom-ref"], c])); + return rootDependency(bom).dependsOn.map((ref) => { + const component = byRef.get(ref); + return component ? fullName(component) : ref; + }); +} + +function symmetricDiff(actual, expected) { + const actualSet = new Set(actual); + const expectedSet = new Set(expected); + const missing = [...expectedSet].filter((n) => !actualSet.has(n)); + const extra = [...actualSet].filter((n) => !expectedSet.has(n)); + return { missing, extra }; +} + +/** + * Structural verification of both documents against package.json. Returns a list of human-readable + * violations (empty means valid). Schema validation and file I/O are handled by the CLI wrapper. + */ +export function verifyStructure({ prod, dev, pkg }) { + const errors = []; + const dependencies = pkg.dependencies ?? {}; + const peerDependencies = pkg.peerDependencies ?? {}; + const devDependencies = pkg.devDependencies ?? {}; + + for (const [label, bom] of [ + ["sbom.cdx.json", prod], + ["sbom-dev.cdx.json", dev], + ]) { + const version = bom.metadata?.component?.version; + if (version !== pkg.version) { + errors.push( + `${label}: metadata.component.version "${version}" does not match package.json version "${pkg.version}".`, + ); + } + const supplier = bom.metadata?.supplier?.name; + if (supplier !== SUPPLIER_NAME) { + errors.push( + `${label}: metadata.supplier.name is "${supplier ?? ""}", expected "${SUPPLIER_NAME}".`, + ); + } + } + + // Runtime SBOM: root edges cover every dependency and every peer — no more, no less. + const runtimeExpected = [...Object.keys(dependencies), ...Object.keys(peerDependencies)]; + const runtime = symmetricDiff(rootEdgeNames(prod), runtimeExpected); + for (const name of runtime.missing) { + errors.push( + `sbom.cdx.json: root component is missing a dependsOn edge for declared dependency "${name}".`, + ); + } + for (const name of runtime.extra) { + errors.push( + `sbom.cdx.json: root component has an extra dependsOn edge for "${name}" not declared in package.json.`, + ); + } + + // Every peer: also a devDependency, present as a component with an exact version and the marker. + const prodByName = new Map((prod.components ?? []).map((c) => [fullName(c), c])); + for (const [name, range] of Object.entries(peerDependencies)) { + if (!(name in devDependencies)) { + errors.push( + `package.json: peerDependency "${name}" must also be a devDependency so its version can be resolved.`, + ); + } + const component = prodByName.get(name); + if (!component) { + errors.push( + `sbom.cdx.json: peerDependency "${name}" (${range}) is missing from the runtime SBOM.`, + ); + continue; + } + if (!component.version) { + errors.push(`sbom.cdx.json: peer component "${name}" has no resolved version.`); + } + if (!component.properties?.some((p) => p.name === PEER_PROPERTY)) { + errors.push( + `sbom.cdx.json: peer component "${name}" lacks the ${PEER_PROPERTY} property; a reader could not ` + + `distinguish it from a resolved dependency.`, + ); + } + } + + // Dev SBOM (`pnpm sbom --dev`): root edges cover the devDependencies (the build toolchain). + // The runtime dependencies are covered authoritatively by the prod document, not here. + const devDiff = symmetricDiff(rootEdgeNames(dev), Object.keys(devDependencies)); + for (const name of devDiff.missing) { + errors.push(`sbom-dev.cdx.json: root component is missing a dependsOn edge for "${name}".`); + } + for (const name of devDiff.extra) { + errors.push(`sbom-dev.cdx.json: root component has an extra dependsOn edge for "${name}".`); + } + + return errors; +} + +/** + * Read and parse a single SBOM document. Returns `{ errors, bom }`; `bom` is null when unreadable, + * empty, or not JSON, with the file named in each error message. + */ +export function readDocument(path) { + let text; + try { + text = readFileSync(path, "utf8"); + } catch { + return { errors: [`${path}: file does not exist or is not readable.`], bom: null }; + } + if (text.trim().length === 0) { + return { errors: [`${path}: file is empty.`], bom: null }; + } + try { + return { errors: [], bom: JSON.parse(text) }; + } catch (err) { + return { errors: [`${path}: is not valid JSON (${err.message}).`], bom: null }; + } +} + +/** + * Validate a parsed document against the CycloneDX 1.7 JSON schema using the official + * @cyclonedx/cyclonedx-library. Returns null when valid, otherwise a human-readable error string. + * The validator (ajv) is imported lazily so consumers that only need the pure helpers stay light. + */ +export async function validateAgainstSchema(bom, path, specVersion = SPEC_VERSION) { + const { Validation } = await import("@cyclonedx/cyclonedx-library"); + const validator = new Validation.JsonValidator(specVersion); + const error = await validator.validate(JSON.stringify(bom)); + if (error === null) { + return null; + } + return `${path}: does not validate against the CycloneDX ${specVersion} schema (${JSON.stringify(error)}).`; +} diff --git a/scripts/verify-sbom.mjs b/scripts/verify-sbom.mjs new file mode 100644 index 0000000..056d113 --- /dev/null +++ b/scripts/verify-sbom.mjs @@ -0,0 +1,56 @@ +#!/usr/bin/env node +// Verify the two generated SBOM documents. Exits non-zero (naming every failure) if either document +// is missing, malformed, schema-invalid, or inconsistent with package.json. Run in ci.yml on every +// pull request and in release.yml before publishing — never as a Vitest test (see scripts/__tests__, +// which exercise the same core logic against fixtures without a `pnpm sbom` subprocess). + +import { execFileSync } from "node:child_process"; +import { join } from "node:path"; + +import { readDocument, validateAgainstSchema, verifyStructure } from "./sbom-core.mjs"; + +const repoRoot = process.cwd(); +const prodPath = join(repoRoot, "sbom", "sbom.cdx.json"); +const devPath = join(repoRoot, "sbom", "sbom-dev.cdx.json"); + +const pkg = JSON.parse( + execFileSync("node", ["-p", "JSON.stringify(require('./package.json'))"], { + cwd: repoRoot, + encoding: "utf8", + }), +); + +const errors = []; + +const prodDoc = readDocument(prodPath); +const devDoc = readDocument(devPath); +errors.push(...prodDoc.errors, ...devDoc.errors); + +for (const [doc, path] of [ + [prodDoc, prodPath], + [devDoc, devPath], +]) { + if (doc.bom) { + const schemaError = await validateAgainstSchema(doc.bom, path); + if (schemaError) { + errors.push(schemaError); + } + } +} + +// Structural checks only make sense once both documents parsed. +if (prodDoc.bom && devDoc.bom) { + errors.push(...verifyStructure({ prod: prodDoc.bom, dev: devDoc.bom, pkg })); +} + +if (errors.length > 0) { + console.error(`SBOM verification failed with ${errors.length} problem(s):`); + for (const error of errors) { + console.error(` - ${error}`); + } + process.exit(1); +} + +console.log( + "SBOM verification passed: sbom.cdx.json and sbom-dev.cdx.json are valid and consistent.", +); diff --git a/specs/005-cyclonedx-sbom-release/design.md b/specs/005-cyclonedx-sbom-release/design.md index 98118e3..0010598 100644 --- a/specs/005-cyclonedx-sbom-release/design.md +++ b/specs/005-cyclonedx-sbom-release/design.md @@ -59,7 +59,7 @@ and local development use the same version with no additional entry and no addit | File | Content | Command | Status | |------|---------|---------|--------| | `sbom.cdx.json` | `dependencies` (transitive) + enriched `peerDependencies` | `pnpm sbom --prod` | **Authoritative** for CRA requests | -| `sbom-dev.cdx.json` | `dependencies` + `devDependencies` (build toolchain) | `pnpm sbom --dev` | Transparency, not authoritative | +| `sbom-dev.cdx.json` | `devDependencies` (build toolchain) | `pnpm sbom --dev` | Transparency, not authoritative | CycloneDX spec version **1.7** (pnpm's default), `--sbom-type library`, `--sbom-supplier "Open Elements GmbH"`, `--sbom-authors "Open Elements GmbH"`. The supplier is the field @@ -122,7 +122,9 @@ library) as the schema validator: `peerDependencies` — no more, no less. 6. Every `peerDependency` is present as a component with an exact version and a `cdx:npm:peer` property. 7. Every `peerDependency` is also declared as a `devDependency`. -8. Dev SBOM: the root's `dependsOn` edges cover `dependencies` + `devDependencies`. +8. Dev SBOM: the root's `dependsOn` edges cover `devDependencies` (the build toolchain). `pnpm sbom + --dev` scopes the root to `devDependencies` only; the runtime tree is covered authoritatively by the + prod document, so it is not re-checked here. **Where it runs:** as a step in `ci.yml` on every pull request and push to `main`, and in `release.yml` before publishing. Deliberately not as a Vitest test — generating an SBOM shells out to `pnpm sbom` and @@ -163,8 +165,8 @@ sequenceDiagram ## Dependencies - `pnpm` 11.3.0 — already pinned via `packageManager`; provides `pnpm sbom`. -- `@cyclonedx/cyclonedx-library` — new devDependency, schema validation only. Runs in `ci.yml`, which - holds no publish rights. +- `@cyclonedx/cyclonedx-library` — new devDependency, schema validation only, plus its required optional + peers `ajv`, `ajv-formats` and `ajv-formats-draft2019`. Runs in `ci.yml`, which holds no publish rights. - `gh` CLI — already used by `release.yml`. ## Security considerations @@ -193,9 +195,10 @@ Next.js applications (which additionally need the application-wide SBOM feature) ## Open questions -- **Does `@cyclonedx/cyclonedx-library` ship the CycloneDX 1.7 JSON schema?** 1.7 is recent. If the - installed version validates only up to 1.6, the choice is to bump the library or to fall back to - `--sbom-spec-version 1.6`. To be resolved during implementation — it does not change the design, only - one flag. +- **Does `@cyclonedx/cyclonedx-library` ship the CycloneDX 1.7 JSON schema?** ~~1.7 is recent.~~ + **Resolved during implementation:** `@cyclonedx/cyclonedx-library` 10.2.0 validates CycloneDX 1.7 + (`Validation.JsonValidator("1.7")`). Its `JsonValidator` requires the optional peers `ajv`, + `ajv-formats` and `ajv-formats-draft2019`, which are added as devDependencies alongside it. They run + only in `ci.yml` (no publish rights), consistent with the security stance above. - **Consumer-side tolerance for 1.7.** Older scanners and Dependency-Track versions may reject an unknown spec version. Accepted for now; revisit when the Dependency-Track instance exists. diff --git a/specs/005-cyclonedx-sbom-release/steps.md b/specs/005-cyclonedx-sbom-release/steps.md new file mode 100644 index 0000000..7102371 --- /dev/null +++ b/specs/005-cyclonedx-sbom-release/steps.md @@ -0,0 +1,54 @@ +# Implementation Steps: CycloneDX SBOM for releases + +Ordered plan derived from `design.md` and `behaviors.md`. Checked off as implemented. + +## Findings from probing `pnpm sbom` (11.3.0) — grounding the design + +- Output goes to **stdout**; format flag is `--sbom-format cyclonedx` (required). Flags used: + `--prod` / `--dev`, `--sbom-spec-version 1.7` (default), `--sbom-supplier`, `--sbom-authors`, + `--sbom-type library`. +- Runtime (`--prod`) root has 11 `dependsOn` edges; `radix-ui`, `@base-ui/react`, `lucide-react` + are absent entirely. `react`/`react-dom` appear only transitively (via `@tiptap/react`) at `19.2.5`. +- The **dev** SBOM root has exactly one direct edge per peer with the exact resolved version + (`react`/`react-dom` `19.2.5`, `radix-ui` `1.4.3`, `@base-ui/react` `1.4.1`, `lucide-react` `0.500.0`). + This is the authoritative source for peer versions, per the design. +- `@cyclonedx/cyclonedx-library` 10.2.0 validates CycloneDX **1.7** (resolves the design's open + question), but its `JsonValidator` needs optional peers `ajv`, `ajv-formats`, `ajv-formats-draft2019`. + Both real documents validate clean. These validator deps run only in `ci.yml` (no publish rights). + +## Steps + +1. [ ] Add devDependencies: `@cyclonedx/cyclonedx-library`, `ajv`, `ajv-formats`, + `ajv-formats-draft2019` (validator + its required optional peers). *(done during probing)* +2. [ ] `.gitignore`: add `sbom/`. +3. [ ] `scripts/sbom-core.mjs` — pure, unit-testable core: + - `fullName(component)` — package identity (`group/name` or `name`). + - `enrichWithPeers(prodBom, devBom, pkg)` — add each `peerDependency` to the runtime doc using the + dev-resolved version; dedupe by package identity so a transitively-present peer appears once; attach + a `cdx:npm:peer` property with the declared range; add a root `dependsOn` edge; never expand the + peer's subtree; throw if a peer is not a devDependency. + - `sortBom(bom)` — sort `components` by `bom-ref` (+ `dependsOn`/`dependencies` for content stability). + - `verifyStructure({prod, dev, pkg})` — returns `string[]` of violations: version match, supplier, + runtime root edges == `dependencies` ∪ `peerDependencies` (exact), each peer present with exact + version + marker property, each peer is a devDependency, dev root edges == `dependencies` ∪ + `devDependencies`. + - File/schema helpers: `readDocument(path)`, `validateAgainstSchema(bom)`. + - Constants: `SUPPLIER_NAME`, `SPEC_VERSION`. +4. [ ] `scripts/generate-sbom.mjs` — run `pnpm sbom` twice (prod/dev) via `execFileSync`, enrich prod + with peers, `sortBom` both, write `sbom/sbom.cdx.json` and `sbom/sbom-dev.cdx.json`. +5. [ ] `scripts/verify-sbom.mjs` — read both files (exist/non-empty/parse, naming the file), schema- + validate against 1.7, run `verifyStructure`; print every violation; exit non-zero on any. +6. [ ] `package.json` scripts: `sbom` (generate) and `sbom:verify` (verify). Not wired into `test`/`build`. +7. [ ] `vitest.config.ts`: include `scripts/**/*.test.mjs` so core tests run without a `pnpm sbom` spawn. +8. [ ] Tests `scripts/__tests__/*.test.mjs` covering every scenario in `behaviors.md` against fixtures + (no `pnpm sbom` subprocess): enrichment (add/dedupe/no-subtree/stable order), accepted pair, exact + 16 root edges, and every rejection (missing/malformed/schema-invalid/version/supplier/added/removed + dep/peer missing/peer without marker/peer not devDep/dev missing devDep), plus the no-peers library. +9. [ ] `ci.yml`: dedicated step — `pnpm run sbom` then `pnpm run sbom:verify` on PR and push to `main`. +10. [ ] `release.yml`: generate + verify **before** `pnpm stage publish`; attach both files to the draft + release via `gh release create ... sbom/sbom.cdx.json sbom/sbom-dev.cdx.json`; note the authoritative + document in the release body. +11. [ ] `README.md`: document the two SBOMs, that `sbom.cdx.json` is authoritative, the peer-version + caveat (resolved from *our* lockfile, marked `cdx:npm:peer`), and how to obtain one per version. +12. [ ] Run generate + verify end-to-end; `pnpm test`, `typecheck`, `lint`, `format:check`, `build`. +13. [ ] spec-review + quality-review; iterate; set INDEX status `done`. diff --git a/specs/INDEX.md b/specs/INDEX.md index 8b374e3..59d7dd3 100644 --- a/specs/INDEX.md +++ b/specs/INDEX.md @@ -6,4 +6,4 @@ | 002 | 002-markdown-toolbar-actions | Markdown toolbar actions | frontend, api | Compose the MarkdownEditor toolbar per usage via an explicit action allowlist | — | done | | 003 | 003-markdown-view-checkboxes | Markdown view checkboxes | frontend, api | Tick task list checkboxes directly in MarkdownView with optimistic update and rollback | — | done | | 004 | 004-component-showcase | Component showcase | frontend, testing, styling, infrastructure | Storybook-based showcase with Tailwind, interaction tests, and a Coolify deployment | — | open | -| 005 | 005-cyclonedx-sbom-release | CycloneDX SBOM for releases | build, security, infrastructure, documentation | Ship a runtime and a build SBOM as GitHub release assets, generated with `pnpm sbom` and verified in CI | — | open | +| 005 | 005-cyclonedx-sbom-release | CycloneDX SBOM for releases | build, security, infrastructure, documentation | Ship a runtime and a build SBOM as GitHub release assets, generated with `pnpm sbom` and verified in CI | — | done | diff --git a/vitest.config.ts b/vitest.config.ts index 7520791..a347635 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -7,7 +7,7 @@ export default defineConfig({ test: { environment: "jsdom", setupFiles: ["./src/test/setup.ts"], - include: ["src/**/*.test.{ts,tsx}"], + include: ["src/**/*.test.{ts,tsx}", "scripts/**/*.test.mjs"], }, resolve: { alias: {