From d3289b2167db62084f6356aec9e2289849f148e6 Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Fri, 31 Jul 2026 04:21:58 +0200 Subject: [PATCH] #202: SBOM and license report in CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The release run now generates CycloneDX 1.6 SBOMs with a pinned anchore/syft container — one per released image (scanned from the freshly built image tar, OS packages included) and one for the pnpm workspace (from the lockfile) — plus the full pnpm licenses report, and attaches everything as build artefacts BEFORE publishing the release, so a red gate stops the release. Runner constraints dictated the mechanics (documented in the workflow): the job talks to the HOST daemon, so files travel into the syft container via docker cp and images via docker save to a tar copied the same way (syft cannot read a tar from stdin — verified). scripts/check-licenses.mjs is the documented license policy: permissive allowlist, MPL-2.0/CC-BY-4.0 with recorded reasoning, per-package exception table (khroma: MIT text shipped, metadata missing). CI runs the gate on every PR (pnpm licenses:check); positive and negative case tested locally, both SBOM paths tested against real images/lockfile. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0168Ph5uBmHm8X28CSVpbpnJ --- .gitea/workflows/ci.yml | 7 +++ .gitea/workflows/release.yml | 56 +++++++++++++++++++++ docs/architecture/security.md | 20 ++++++++ docs/vs-nfd/20-massnahmenplan.md | 2 +- package.json | 1 + scripts/check-licenses.mjs | 85 ++++++++++++++++++++++++++++++++ 6 files changed, 170 insertions(+), 1 deletion(-) create mode 100644 scripts/check-licenses.mjs diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 09406f0..0b201a4 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -103,6 +103,13 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + # License allowlist gate (issue #202): fails when any dependency's + # license falls outside the documented policy in + # scripts/check-licenses.mjs (which is also where the reasoning and + # per-package exceptions live). + - name: License allowlist + run: pnpm licenses list --json | node scripts/check-licenses.mjs + # Build first: package type checks resolve @dorfteich/shared through # its built dist, and i18n:check imports the built helpers. - name: Build all packages diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 0f32860..9bb0b2d 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -38,6 +38,62 @@ jobs: docker push $IMAGE_BASE-$app:$TAG done + # Supply-chain artefacts (issue #202): one CycloneDX SBOM per release + # image, one for the pnpm workspace, plus the full license report — + # attached as build artefacts of this run BEFORE the release is + # published, so a red gate stops the release. Mechanics dictated by + # the runner (the job talks to the HOST daemon, so bind mounts of + # workspace paths resolve on the host and go nowhere): files travel + # into the pinned syft container via `docker cp` (an API stream), and + # images via `docker save` to a tar copied the same way — syft cannot + # read a tar from stdin (not seekable). + - name: Generate SBOMs + run: | + set -euo pipefail + TAG=${GITHUB_REF_NAME} + SYFT=anchore/syft:v1.33.0 + mkdir -p supply-chain sbom-src + cp pnpm-lock.yaml package.json sbom-src/ + c=$(docker create $SYFT scan dir:/src --source-name dorfteich-workspace --source-version "$TAG" -o cyclonedx-json=/out.json) + docker cp sbom-src "$c:/src" + docker start -a "$c" + docker cp "$c:/out.json" supply-chain/sbom-workspace-$TAG.cdx.json + docker rm "$c" > /dev/null + for app in web api collab backup; do + docker save $IMAGE_BASE-$app:$TAG -o image.tar + c=$(docker create $SYFT scan docker-archive:/image.tar --source-name dorfteich-$app --source-version "$TAG" -o cyclonedx-json=/out.json) + docker cp image.tar "$c:/image.tar" + docker start -a "$c" + docker cp "$c:/out.json" supply-chain/sbom-image-$app-$TAG.cdx.json + docker rm "$c" > /dev/null + rm image.tar + done + ls -l supply-chain/ + + - name: Set up pnpm + uses: pnpm/action-setup@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version-file: .node-version + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: License report and allowlist gate + run: | + set -euo pipefail + pnpm licenses list --json > supply-chain/licenses-${GITHUB_REF_NAME}.json + node scripts/check-licenses.mjs < supply-chain/licenses-${GITHUB_REF_NAME}.json + + - name: Attach supply-chain artefacts + uses: actions/upload-artifact@v3 + with: + name: supply-chain-${{ github.ref_name }} + path: supply-chain/ + - name: Generate release notes and publish the release run: | TAG=${GITHUB_REF_NAME} diff --git a/docs/architecture/security.md b/docs/architecture/security.md index 1b4ee3c..fb36f33 100644 --- a/docs/architecture/security.md +++ b/docs/architecture/security.md @@ -152,6 +152,26 @@ job (out of scope, below). - Dependencies: lockfile-pinned; monthly update batch; images pinned to digests in Prod. +## Supply chain artefacts (issue #202) + +- **SBOMs**: every release run (`release.yml`) generates CycloneDX 1.6 + SBOMs with a pinned `anchore/syft` container — one per released image + (scanned from the freshly built image tar, OS packages included) and one + for the pnpm workspace (scanned from `pnpm-lock.yaml`) — and attaches + them as build artefacts named `supply-chain-vX.Y.Z` on the release's + action run. Provenance: the workflow file records the exact syft + version; regenerate any of them with + `docker save -o image.tar && docker run … anchore/syft: +scan docker-archive:/image.tar -o cyclonedx-json` respectively + `scan dir: -o cyclonedx-json` at the release tag. +- **License policy**: `scripts/check-licenses.mjs` holds the documented + allowlist (permissive licenses, plus MPL-2.0 and CC-BY-4.0 with recorded + reasoning, plus a per-package exception table for wrong upstream + metadata). CI runs the gate on every pull request; the release run + additionally stores the full `pnpm licenses` report next to the SBOMs. + A dependency outside the allowlist fails the build — extending the + policy is a reviewed change to that script, never a build fix. + ## Logging - Application logs are pino JSON on stdout; `authorization` and `cookie` diff --git a/docs/vs-nfd/20-massnahmenplan.md b/docs/vs-nfd/20-massnahmenplan.md index 82fe633..13f8162 100644 --- a/docs/vs-nfd/20-massnahmenplan.md +++ b/docs/vs-nfd/20-massnahmenplan.md @@ -105,7 +105,7 @@ chain`_ - [x] **Papierkorb aus dem Suchindex** entfernen statt query-seitig filtern · 2 AT · #195 - [x] **Retention-Job für `audit_log`** · 1 AT · #196 - [x] **Security-Header** (helmet), CORS explizit restriktiv · 1 AT · #197 -- [ ] **SBOM in CI** (CycloneDX/syft) + Lizenzreport als Artefakt · 1–2 AT · #202 +- [x] **SBOM in CI** (CycloneDX/syft) + Lizenzreport als Artefakt · 1–2 AT · #202 - [x] `deploy/compose/.env` prüfen, Beispieldatei statt Realdatei · 0,5 AT · #198 - [ ] **Attachment-Integritätshashes** · +2–3 AT · #199 ⟵ neu aus Roadmap SHA-256-Spalte, Berechnung beim Upload, Prüfung beim Download, diff --git a/package.json b/package.json index c85b0b4..436110c 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "packageManager": "pnpm@11.9.0", "scripts": { "lint": "eslint . && prettier --check .", + "licenses:check": "pnpm licenses list --json | node scripts/check-licenses.mjs", "format": "prettier --write .", "typecheck": "pnpm -r run typecheck", "test": "pnpm -r run test", diff --git a/scripts/check-licenses.mjs b/scripts/check-licenses.mjs new file mode 100644 index 0000000..c39e601 --- /dev/null +++ b/scripts/check-licenses.mjs @@ -0,0 +1,85 @@ +// License allowlist gate (issue #202): reads `pnpm licenses list --json` +// from stdin and fails when any dependency's license is outside the +// allowlist below. The allowlist is the documented policy — extending it is +// a deliberate, reviewed act, not a build fix. +// +// Usage: pnpm licenses list --json | node scripts/check-licenses.mjs + +// Permissive licenses only, plus two consciously admitted cases: +// - MPL-2.0: file-level copyleft; we consume MPL packages (axe-core, +// dev-only) unmodified, which triggers no obligations beyond source +// availability of the (unmodified) files themselves. +// - CC-BY-4.0: attribution license used for data packages (browser +// compatibility data); attribution is satisfied by the license report. +const ALLOWED = new Set([ + 'MIT', + 'MIT-0', + 'ISC', + 'Apache-2.0', + 'BSD-2-Clause', + 'BSD-3-Clause', + '0BSD', + 'BlueOak-1.0.0', + 'CC0-1.0', + 'CC-BY-4.0', + 'Python-2.0', + 'Unlicense', + 'MPL-2.0', + 'Zlib', +]); + +// Packages whose license METADATA is missing or wrong upstream, verified by +// hand against the shipped license text. Key = package name, value = the +// verification note an auditor reads. +const EXCEPTIONS = new Map([ + [ + 'khroma', + 'MIT — upstream ships the MIT text as its `license` file but omits the package.json license field (reported as "Unknown")', + ], +]); + +/** SPDX-light evaluation, sufficient for the expressions pnpm emits today: + * parentheses stripped, OR satisfied by any allowed alternative, AND by all + * parts. Nested mixed expressions would need a real parser — they fail + * closed here, which is the safe direction. */ +function isAllowed(expression) { + const clean = expression.replace(/[()]/g, ' ').trim(); + if (/\sOR\s/.test(clean)) { + return clean.split(/\s+OR\s+/).some((part) => isAllowed(part)); + } + return clean.split(/\s+AND\s+/).every((part) => ALLOWED.has(part.trim())); +} + +const input = await new Promise((resolve, reject) => { + let data = ''; + process.stdin.setEncoding('utf8'); + process.stdin.on('data', (chunk) => (data += chunk)); + process.stdin.on('end', () => resolve(data)); + process.stdin.on('error', reject); +}); + +const byLicense = JSON.parse(input); +const violations = []; +let packages = 0; + +for (const [license, entries] of Object.entries(byLicense)) { + for (const entry of entries) { + packages += 1; + if (isAllowed(license)) continue; + if (EXCEPTIONS.has(entry.name)) continue; + violations.push({ name: entry.name, license }); + } +} + +if (violations.length > 0) { + console.error('licenses outside the documented allowlist:'); + for (const v of violations) console.error(` ${v.name}: ${v.license}`); + console.error( + 'Either the dependency goes, or the allowlist/exception table in scripts/check-licenses.mjs is extended in a reviewed change.', + ); + process.exit(1); +} + +console.log( + `license gate: ${packages} packages, ${Object.keys(byLicense).length} distinct license expressions, all within the allowlist (${EXCEPTIONS.size} documented exception${EXCEPTIONS.size === 1 ? '' : 's'})`, +);