#202: SBOM and license report in CI
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 5m33s
CI / Build container images (pull_request) Successful in 4m38s
CI / Auth e2e pack (pull_request) Successful in 9m14s
CI / Import/export fidelity gate (pull_request) Successful in 1m12s
CD / Smoke tests against Test (push) Blocked by required conditions
CD / Deploy to Test (push) Blocked by required conditions
CD / Promote to Int (push) Blocked by required conditions
CI / Auth e2e pack (push) Blocked by required conditions
CI / Build container images (push) Blocked by required conditions
CI / Lint, typecheck, test (push) Waiting to run
CI / Import/export fidelity gate (push) Blocked by required conditions
CD / Build and push images (push) Has been cancelled

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0168Ph5uBmHm8X28CSVpbpnJ
This commit is contained in:
Claude Fable 5 2026-07-31 04:21:58 +02:00
parent 9326177534
commit d3289b2167
6 changed files with 170 additions and 1 deletions

View File

@ -103,6 +103,13 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile 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 # Build first: package type checks resolve @dorfteich/shared through
# its built dist, and i18n:check imports the built helpers. # its built dist, and i18n:check imports the built helpers.
- name: Build all packages - name: Build all packages

View File

@ -38,6 +38,62 @@ jobs:
docker push $IMAGE_BASE-$app:$TAG docker push $IMAGE_BASE-$app:$TAG
done 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 - name: Generate release notes and publish the release
run: | run: |
TAG=${GITHUB_REF_NAME} TAG=${GITHUB_REF_NAME}

View File

@ -152,6 +152,26 @@ job (out of scope, below).
- Dependencies: lockfile-pinned; monthly update batch; images pinned to - Dependencies: lockfile-pinned; monthly update batch; images pinned to
digests in Prod. 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 <image> -o image.tar && docker run … anchore/syft:<pinned>
scan docker-archive:/image.tar -o cyclonedx-json` respectively
`scan dir:<workspace> -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 ## Logging
- Application logs are pino JSON on stdout; `authorization` and `cookie` - Application logs are pino JSON on stdout; `authorization` and `cookie`

View File

@ -105,7 +105,7 @@ chain`_
- [x] **Papierkorb aus dem Suchindex** entfernen statt query-seitig filtern · 2 AT · #195 - [x] **Papierkorb aus dem Suchindex** entfernen statt query-seitig filtern · 2 AT · #195
- [x] **Retention-Job für `audit_log`** · 1 AT · #196 - [x] **Retention-Job für `audit_log`** · 1 AT · #196
- [x] **Security-Header** (helmet), CORS explizit restriktiv · 1 AT · #197 - [x] **Security-Header** (helmet), CORS explizit restriktiv · 1 AT · #197
- [ ] **SBOM in CI** (CycloneDX/syft) + Lizenzreport als Artefakt · 12 AT · #202 - [x] **SBOM in CI** (CycloneDX/syft) + Lizenzreport als Artefakt · 12 AT · #202
- [x] `deploy/compose/.env` prüfen, Beispieldatei statt Realdatei · 0,5 AT · #198 - [x] `deploy/compose/.env` prüfen, Beispieldatei statt Realdatei · 0,5 AT · #198
- [ ] **Attachment-Integritätshashes** · +23 AT · #199 ⟵ neu aus Roadmap - [ ] **Attachment-Integritätshashes** · +23 AT · #199 ⟵ neu aus Roadmap
SHA-256-Spalte, Berechnung beim Upload, Prüfung beim Download, SHA-256-Spalte, Berechnung beim Upload, Prüfung beim Download,

View File

@ -10,6 +10,7 @@
"packageManager": "pnpm@11.9.0", "packageManager": "pnpm@11.9.0",
"scripts": { "scripts": {
"lint": "eslint . && prettier --check .", "lint": "eslint . && prettier --check .",
"licenses:check": "pnpm licenses list --json | node scripts/check-licenses.mjs",
"format": "prettier --write .", "format": "prettier --write .",
"typecheck": "pnpm -r run typecheck", "typecheck": "pnpm -r run typecheck",
"test": "pnpm -r run test", "test": "pnpm -r run test",

View File

@ -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'})`,
);