#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
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:
parent
9326177534
commit
d3289b2167
@ -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
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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`
|
||||||
|
|||||||
@ -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 · 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
|
- [x] `deploy/compose/.env` prüfen, Beispieldatei statt Realdatei · 0,5 AT · #198
|
||||||
- [ ] **Attachment-Integritätshashes** · +2–3 AT · #199 ⟵ neu aus Roadmap
|
- [ ] **Attachment-Integritätshashes** · +2–3 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,
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
85
scripts/check-licenses.mjs
Normal file
85
scripts/check-licenses.mjs
Normal 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'})`,
|
||||||
|
);
|
||||||
Loading…
Reference in New Issue
Block a user