diff --git a/.github/scripts/audit-check.mjs b/.github/scripts/audit-check.mjs new file mode 100644 index 0000000..c0a92d9 --- /dev/null +++ b/.github/scripts/audit-check.mjs @@ -0,0 +1,51 @@ +// Weekly dependency audit (Dependabot alerts are off). Reads `npm audit --json` and the lockfile and +// prints, as Markdown for an issue, the high and critical findings a production install can reach. +// Findings reachable only through a package named in IGNORE (comma-separated) are left out: @x402/paywall +// bundles browser wallet libraries (WalletConnect, Reown) that the server never runs. +// Usage: node audit-check.mjs audit.json package-lock.json > report.md (empty output = nothing to report) +import { readFileSync } from "node:fs"; + +const [auditPath, lockPath] = process.argv.slice(2); +const audit = JSON.parse(readFileSync(auditPath, "utf8")); +if (audit.error) throw new Error(`npm audit failed: ${audit.error.summary || JSON.stringify(audit.error)}`); +const pkgs = JSON.parse(readFileSync(lockPath, "utf8")).packages || {}; +const ignore = new Set((process.env.IGNORE || "").split(",").map((s) => s.trim()).filter(Boolean)); + +// Where Node finds `name` when `from` requires it: the nearest node_modules on the way up to the root. +const resolve = (from, name) => { + for (let dir = from; ; ) { + const path = (dir ? `${dir}/` : "") + `node_modules/${name}`; + if (pkgs[path]) return path; + if (!dir) return null; + const i = dir.lastIndexOf("/node_modules/"); + dir = i === -1 ? "" : dir.slice(0, i); + } +}; + +// Every installed path a production install reaches from the root, skipping ignored packages. +const reached = new Set(); +const root = pkgs[""] || {}; +const queue = Object.keys({ ...root.dependencies, ...root.optionalDependencies }).map((n) => [n, ""]); +while (queue.length) { + const [name, from] = queue.pop(); + if (ignore.has(name)) continue; + const path = resolve(from, name); + if (!path || reached.has(path)) continue; + reached.add(path); + const e = pkgs[path]; + for (const dep of Object.keys({ ...e.dependencies, ...e.optionalDependencies, ...e.peerDependencies })) queue.push([dep, path]); +} + +const rank = { critical: 0, high: 1 }; +const found = Object.values(audit.vulnerabilities || {}) + .filter((v) => v.severity in rank && (v.nodes || []).some((n) => reached.has(n))) + .sort((a, b) => rank[a.severity] - rank[b.severity] || a.name.localeCompare(b.name)); +if (found.length) { + const fix = (f) => (f === true ? "update available" : f && f.name ? `fix needs ${f.name}@${f.version}${f.isSemVerMajor ? " (major)" : ""}` : "no fix yet"); + const lines = found.map((v) => { + const advisories = [...new Map(v.via.filter((x) => typeof x === "object").map((x) => [x.url, `[${x.title}](${x.url})`])).values()]; + const through = v.via.filter((x) => typeof x === "string"); + return `- **${v.name}** ${v.severity}, ${v.range}: ${advisories.join("; ") || `through ${through.join(", ")}`}. ${fix(v.fixAvailable)}.`; + }); + console.log(`The weekly \`npm audit\` found ${found.length} high or critical finding(s) in production dependencies${ignore.size ? ` (not counting what only ${[...ignore].join(", ")} pulls in)` : ""}:\n\n${lines.join("\n")}\n\nFix with \`npm install @\` or \`npm audit fix --omit=dev\`, run the tests, and open a PR. This issue closes itself after a clean weekly run.`); +} diff --git a/.github/workflows/audit.yml b/.github/workflows/audit.yml new file mode 100644 index 0000000..bba1f3b --- /dev/null +++ b/.github/workflows/audit.yml @@ -0,0 +1,43 @@ +name: Dependency audit +# Every Monday: npm audit on PlainText's production dependencies (Dependabot alerts are off on this repo). +# A high or critical finding the server can reach opens one issue labelled dependency-audit (GitHub +# emails the owner), or adds the new list to the open one; the next clean run closes it. +on: + schedule: + - cron: "17 5 * * 1" + workflow_dispatch: {} + push: + branches: ['claude/**'] + paths: ['.github/scripts/audit-check.mjs', '.github/workflows/audit.yml'] +permissions: + contents: read + issues: write +jobs: + audit: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v5 + with: + node-version: 22 + # npm audit exits non-zero whenever it finds anything; the next step decides what counts. + - run: npm audit --omit=dev --package-lock-only --json > "$RUNNER_TEMP/audit.json" || true + - name: High and critical findings the server can reach + run: node "$GITHUB_WORKSPACE/.github/scripts/audit-check.mjs" "$RUNNER_TEMP/audit.json" package-lock.json > "$RUNNER_TEMP/report.md" + - name: Keep one issue open while findings remain + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + run: | + report="$RUNNER_TEMP/report.md" + num=$(gh issue list --label dependency-audit --state open --json number --jq '.[0].number // empty') + if [ -s "$report" ]; then + cat "$report" >> "$GITHUB_STEP_SUMMARY" + if [ -n "$num" ]; then gh issue comment "$num" --body-file "$report"; exit 0; fi + gh label create dependency-audit --color B60205 --description "Weekly npm audit found a high or critical advisory" 2>/dev/null || true + gh issue create --title "Dependency audit: high or critical advisory" --label dependency-audit --body-file "$report" + else + echo "No high or critical findings the server can reach." >> "$GITHUB_STEP_SUMMARY" + if [ -n "$num" ]; then gh issue close "$num" --comment "The weekly audit is clean again."; fi + fi