From 7f03b1921c449ec59643b38cc9b3bb887e4ddd26 Mon Sep 17 00:00:00 2001 From: S540d Date: Sun, 4 Oct 2026 11:52:29 +0200 Subject: [PATCH] ci: Audit-Gate mit befristeter Allowlist statt dauerrotem npm audit (#348) Co-Authored-By: Claude Sonnet 5.5 --- .audit-allowlist.json | 17 +++++++ .github/workflows/ci-cd.yml | 5 +- scripts/audit-gate.mjs | 91 +++++++++++++++++++++++++++++++++++++ 3 files changed, 111 insertions(+), 2 deletions(-) create mode 100644 .audit-allowlist.json create mode 100644 scripts/audit-gate.mjs diff --git a/.audit-allowlist.json b/.audit-allowlist.json new file mode 100644 index 0000000..66ef05f --- /dev/null +++ b/.audit-allowlist.json @@ -0,0 +1,17 @@ +{ + "_comment": "Befristete Ausnahmen für scripts/audit-gate.mjs. Nur Findings ohne Upstream-Fix im Build-Tooling (nicht im App-Bundle). Nach Ablauf schlägt das Gate fehl -> neu bewerten (DrawFromMemory#348).", + "advisories": [ + { + "id": "GHSA-86w9-cpqp-85rv", + "package": "node-forge", + "reason": "RSA PKCS#1 v1.5: kein gepatchter Release; nur via expo -> @expo/cli (Build-Tooling)", + "expires": "2026-12-31" + }, + { + "id": "GHSA-vfj7-8cjw-p6xm", + "package": "braces", + "reason": "Stack-Exhaustion DoS: kein gepatchter Release; nur via micromatch/patch-package (Dev-Tooling)", + "expires": "2026-12-31" + } + ] +} diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index 67eb937..c5cdb1a 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -203,8 +203,9 @@ jobs: with: node-version: '20' - - name: Run npm audit - run: npm audit --audit-level=high + # Gate mit befristeter Allowlist (.audit-allowlist.json) für Findings ohne Upstream-Fix (#348). + - name: Run npm audit (Gate) + run: node scripts/audit-gate.mjs --level=high - name: Check for sensitive data run: | diff --git a/scripts/audit-gate.mjs b/scripts/audit-gate.mjs new file mode 100644 index 0000000..fa6b51e --- /dev/null +++ b/scripts/audit-gate.mjs @@ -0,0 +1,91 @@ +#!/usr/bin/env node +/** + * Audit-Gate: `npm audit` mit expliziter, befristeter Allowlist. + * + * Schlägt fehl bei + * - hohen/kritischen Advisories, die NICHT in .audit-allowlist.json stehen + * - Allowlist-Einträgen, deren `expires` überschritten ist (Ausnahme muss neu bewertet werden) + * Nur Advisories (GHSA) werden verglichen, nicht Paketnamen: Ein neues Finding im selben + * Paket fällt dadurch weiterhin auf. + * + * Usage: node scripts/audit-gate.mjs [--level=high|critical] + */ +import { execFileSync } from 'node:child_process'; +import { readFileSync, existsSync } from 'node:fs'; + +const level = (process.argv.find(a => a.startsWith('--level=')) || '--level=high').split('=')[1]; +const RANK = { low: 1, moderate: 2, high: 3, critical: 4 }; +const threshold = RANK[level]; +if (!threshold) { + console.error(`Unbekanntes Level: ${level}`); + process.exit(2); +} + +const allowlist = existsSync('.audit-allowlist.json') + ? JSON.parse(readFileSync('.audit-allowlist.json', 'utf8')).advisories + : []; +const today = new Date().toISOString().slice(0, 10); + +let raw; +try { + raw = execFileSync('npm', ['audit', '--json'], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }); +} catch (e) { + // npm audit endet bei Findings mit Exit-Code 1, die JSON-Ausgabe liegt trotzdem auf stdout. + raw = e.stdout; + if (!raw) { + console.error('npm audit lieferte keine Ausgabe:', e.message); + process.exit(2); + } +} +const report = JSON.parse(raw); +if (report.error) { + console.error('npm audit Fehler:', report.error.summary || report.error); + process.exit(2); +} + +// Eindeutige Advisories sammeln (`via`-Objekte; Strings sind nur Verweise auf andere Pakete). +const advisories = new Map(); +for (const [pkg, vuln] of Object.entries(report.vulnerabilities || {})) { + for (const via of vuln.via) { + if (typeof via !== 'object') continue; + const id = (via.url || '').split('/').pop() || via.title; + if (!advisories.has(id)) + advisories.set(id, { id, pkg: via.name || pkg, title: via.title, severity: via.severity }); + } +} + +const allowed = new Map(allowlist.map(a => [a.id, a])); +const blocking = []; +const accepted = []; +for (const adv of advisories.values()) { + if (RANK[adv.severity] < threshold) continue; + const entry = allowed.get(adv.id); + (entry ? accepted : blocking).push({ ...adv, entry }); +} +const expired = allowlist.filter(a => a.expires < today); +const unused = allowlist.filter(a => !advisories.has(a.id)); + +for (const a of accepted) { + console.log(`ℹ️ akzeptiert bis ${a.entry.expires}: ${a.id} (${a.pkg}) – ${a.entry.reason}`); +} +for (const a of unused) { + console.log(`ℹ️ Allowlist-Eintrag ohne Treffer, kann entfernt werden: ${a.id} (${a.package})`); +} +let failed = false; +for (const a of blocking) { + failed = true; + console.error(`❌ ${a.severity}: ${a.id} (${a.pkg}) – ${a.title}`); +} +for (const a of expired) { + failed = true; + console.error( + `❌ Ausnahme abgelaufen (${a.expires}): ${a.id} (${a.package}) – neu bewerten oder verlängern`, + ); +} +if (failed) { + console.error('\nAudit-Gate: FEHLGESCHLAGEN'); + process.exit(1); +} +console.log( + `✅ Audit-Gate bestanden (${accepted.length} akzeptierte, ${blocking.length} offene ${level}+-Findings)`, +);