2026 July 26 update #28
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # Release-time `pnpm audit` automation. | |
| # | |
| # Implements the three-channel CVE surfacing scheme described in | |
| # docs/security-audit-public-2026-04.md §11 limitation 4: | |
| # | |
| # 1. **Build-fail on release branch** — every push or PR to `main` | |
| # runs `pnpm install --frozen-lockfile && pnpm audit --json` and | |
| # fails the workflow on any High/Critical advisory not on the | |
| # ignore list (scripts/audit/ignore-list.json), or on any | |
| # ignore-list entry whose re-eval date is in the past. This | |
| # prevents merging or releasing while a critical CVE is open. | |
| # | |
| # 2. **Daily scheduled scan opens an issue** — the same parser runs | |
| # every day at 13:17 UTC against the `main` lockfile. Findings | |
| # are de-duplicated by (cve + package + installedVersion) | |
| # triple via scripts/audit/sync-issues.mjs: a new triple opens | |
| # an issue, an existing triple gets a comment, a triple that has | |
| # disappeared (dep bumped, advisory withdrawn) auto-closes its | |
| # issue. CVEs are disclosed daily; this path catches them even | |
| # when no commits are landing. | |
| # | |
| # 3. **Webhook / notification post** — when AUDIT_WEBHOOK_URL is | |
| # configured as a repository secret, the scheduled run POSTs a | |
| # JSON summary so the maintainer is paged without polling the | |
| # issue tracker. | |
| # | |
| # # Pinning | |
| # | |
| # - The pnpm major version is pinned via `package-manager: pnpm@10.26.1` | |
| # below. `pnpm audit --json` output shape changed between pnpm 8 and | |
| # pnpm 9 (object-keyed advisories rather than an array); the parser | |
| # in scripts/audit/parse-audit.mjs detects the legacy shape and fails | |
| # loudly, so a future pnpm major bump in the workflow that we don't | |
| # notice will break the parser visibly rather than silently. | |
| # - Node 22 is the active LTS at audit time and matches what the rest | |
| # of the workspace tooling expects; pinning it here keeps the audit | |
| # output reproducible across runner image upgrades. | |
| # | |
| # # Dependabot division of labour | |
| # | |
| # This workflow does NOT duplicate Dependabot's role: | |
| # | |
| # - **Dependabot owns:** opening PRs that bump the affected dependency | |
| # to a patched version. If `path-to-regexp` ships 8.4.0, Dependabot | |
| # files the PR; this workflow's job is to keep the issue open until | |
| # that PR lands and to make sure the maintainer is paged in the | |
| # meantime. | |
| # - **This workflow owns:** release-time enforcement (block the merge), | |
| # daily lockfile-resident scanning against the audit's accepted- | |
| # residual ledger, and the re-evaluation-date discipline that turns | |
| # one-time risk-acceptance decisions into recurring obligations. | |
| # Dependabot has no concept of "ignore until 2026-08-31, then force | |
| # re-justification"; this workflow does, and that is the load-bearing | |
| # bit relative to the audit's confidence-labels appendix. | |
| # | |
| # As of 2026-05-02 Dependabot configuration is not present in the repo | |
| # (no `.github/dependabot.yml`); enabling it is operator discretion. If | |
| # Dependabot is later turned on the two systems do not collide because | |
| # they touch different surfaces (Dependabot opens PRs against the | |
| # lockfile; this workflow reads the lockfile and writes issues). | |
| name: pnpm audit | |
| on: | |
| # Release-branch enforcement: NO `paths:` filter on purpose. The task | |
| # requires the build-fail check on EVERY push to the release branch | |
| # so a code-only push cannot bypass the audit by leaving the lockfile | |
| # untouched. The audit is cheap (one frozen install + one parser run) | |
| # so unconditional execution is the right tradeoff vs. the bypass risk. | |
| push: | |
| branches: [main] | |
| pull_request: | |
| branches: [main] | |
| schedule: | |
| # 13:17 UTC daily. Off-the-hour to avoid GHA scheduler congestion. | |
| - cron: "17 13 * * *" | |
| workflow_dispatch: | |
| permissions: | |
| contents: read | |
| issues: write | |
| jobs: | |
| audit: | |
| name: pnpm audit (${{ github.event_name }}) | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| steps: | |
| - name: Checkout repository | |
| uses: actions/checkout@v4 | |
| - name: Set up pnpm (pinned) | |
| uses: pnpm/action-setup@v4 | |
| with: | |
| run_install: false | |
| - name: Set up Node.js | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version: 22 | |
| cache: pnpm | |
| - name: Cross-check ignore-list ↔ audit doc (drift test) | |
| # Catches drift between scripts/audit/ignore-list.json and the | |
| # §R-0 / §R-N rows in docs/security-audit-public-2026-04.md. | |
| # Runs before `pnpm install` because it only reads two static | |
| # files and has no node_modules dependency — fails fast and on | |
| # every event (push, PR, schedule, dispatch) so a one-sided | |
| # edit cannot land silently. | |
| run: node scripts/audit/ignore-list.drift.test.mjs | |
| - name: Install dependencies (frozen lockfile) | |
| # We don't need to build — but `pnpm audit` reads the lockfile | |
| # and the workspace package.json files, and a frozen-lockfile | |
| # install is the cheapest way to materialize the same | |
| # resolution set the parser will report on. | |
| run: pnpm install --frozen-lockfile --prefer-offline | |
| - name: Run pnpm audit and capture JSON | |
| id: audit | |
| # `pnpm audit --json` exits non-zero in two distinct cases: | |
| # (a) advisories were found (the parser decides disposition) | |
| # (b) the audit itself failed (registry/network/auth/tool | |
| # error, no JSON written to stdout) | |
| # We need to fail CLOSED on case (b) — otherwise an audit | |
| # infrastructure failure would be misclassified as "no | |
| # vulnerabilities found" and the release-time enforcement | |
| # would silently degrade. Distinguishing the cases: | |
| # - exit 0 with empty stdout → clean lockfile (rare; pnpm | |
| # usually writes `{}` or an `advisories` block) | |
| # - exit 0 with non-empty stdout → clean OR no findings | |
| # above the default threshold; let the parser decide | |
| # - non-zero exit with non-empty *valid* JSON → advisories | |
| # found; let the parser decide | |
| # - non-zero exit with empty stdout → infrastructure | |
| # failure; FAIL THE STEP. (`[ -s file ]` is true iff the | |
| # file exists and is non-empty.) | |
| # - non-zero exit with non-empty stdout that is not valid | |
| # JSON → infrastructure failure (e.g. `pnpm audit` printed | |
| # a human-readable error). FAIL THE STEP via `jq` parse. | |
| run: | | |
| set +e | |
| pnpm audit --json > /tmp/pnpm-audit.json 2> /tmp/pnpm-audit.stderr | |
| AUDIT_EXIT=$? | |
| set -e | |
| echo "audit_exit=$AUDIT_EXIT" >> "$GITHUB_OUTPUT" | |
| echo "Audit raw exit code: $AUDIT_EXIT" | |
| if [ "$AUDIT_EXIT" -ne 0 ] && [ ! -s /tmp/pnpm-audit.json ]; then | |
| echo "::error::pnpm audit failed (exit $AUDIT_EXIT) and produced no JSON output — treating as infrastructure failure, not a clean lockfile." | |
| echo "::group::pnpm audit stderr" | |
| cat /tmp/pnpm-audit.stderr || true | |
| echo "::endgroup::" | |
| exit 1 | |
| fi | |
| if [ -s /tmp/pnpm-audit.json ] && ! jq -e . /tmp/pnpm-audit.json >/dev/null 2>&1; then | |
| echo "::error::pnpm audit stdout is not valid JSON — treating as infrastructure failure." | |
| head -c 1024 /tmp/pnpm-audit.json | |
| echo | |
| cat /tmp/pnpm-audit.stderr || true | |
| exit 1 | |
| fi | |
| - name: Parse audit (fail mode — push / PR / dispatch) | |
| if: github.event_name != 'schedule' | |
| run: | | |
| node scripts/audit/parse-audit.mjs --mode=fail < /tmp/pnpm-audit.json | |
| - name: Parse audit (report mode — scheduled) | |
| if: github.event_name == 'schedule' | |
| run: | | |
| node scripts/audit/parse-audit.mjs --mode=report --json < /tmp/pnpm-audit.json > /tmp/audit-report.json | |
| cat /tmp/audit-report.json | head -200 | |
| - name: Sync GitHub issues (scheduled) | |
| if: github.event_name == 'schedule' | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: | | |
| node scripts/audit/sync-issues.mjs \ | |
| --repo "${GITHUB_REPOSITORY}" \ | |
| --new-issues-out /tmp/audit-new-issues.json \ | |
| < /tmp/audit-report.json | |
| - name: Check whether the optional webhook is configured | |
| id: webhook_configured | |
| if: github.event_name == 'schedule' | |
| # `if:` evaluates expressions before any step `env:` block is in | |
| # scope, so we cannot gate the POST step on `env.AUDIT_WEBHOOK_URL` | |
| # directly. Materialize the secret once into a step output so the | |
| # next step can gate on the literal "yes" / "no" string. | |
| env: | |
| AUDIT_WEBHOOK_URL: ${{ secrets.AUDIT_WEBHOOK_URL }} | |
| run: | | |
| if [ -n "${AUDIT_WEBHOOK_URL}" ]; then | |
| echo "configured=yes" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "configured=no" >> "$GITHUB_OUTPUT" | |
| fi | |
| - name: POST webhook (scheduled, optional) | |
| if: github.event_name == 'schedule' && steps.webhook_configured.outputs.configured == 'yes' | |
| env: | |
| AUDIT_WEBHOOK_URL: ${{ secrets.AUDIT_WEBHOOK_URL }} | |
| run: | | |
| # Trim the report to the surfaced + expired sections so the | |
| # webhook payload stays small. Maintainer can pull the full | |
| # report from the workflow run if they want detail. | |
| jq '{generatedAt, totals, surface, ignoredExpired, orphanExpired}' /tmp/audit-report.json > /tmp/audit-webhook.json | |
| curl --fail-with-body -sS -X POST \ | |
| -H "Content-Type: application/json" \ | |
| --data @/tmp/audit-webhook.json \ | |
| "$AUDIT_WEBHOOK_URL" | |
| # ── ntfy operator alert (Task #274) ────────────────────────────────── | |
| # | |
| # Route the "new High/Critical CVE" signal to the SAME ntfy topic the | |
| # api-server uses for its three runtime signals (CSP wave, Lightning | |
| # shape drift, payment slowness). This workflow runs OUTSIDE the | |
| # api-server process, so it posts to ntfy directly with curl rather than | |
| # through the shared publisher. The message format is kept consistent | |
| # with the in-process alerts: a "VOID: …" title and a one-line body. | |
| # | |
| # We fire ONLY when sync-issues opened at least one NEW surface issue | |
| # this run (i.e. a genuinely new High/Critical advisory). An advisory | |
| # that is already tracked produces no new issue and therefore no ntfy | |
| # alert — that is the dedupe that stops a daily re-page for the same CVE. | |
| - name: Check whether ntfy is configured | |
| id: ntfy_configured | |
| if: github.event_name == 'schedule' | |
| # Same trick as the webhook gate above: `if:` cannot read a step `env:` | |
| # block, so materialize the secret into a step output first. | |
| env: | |
| NTFY_TOPIC: ${{ secrets.NTFY_TOPIC }} | |
| run: | | |
| if [ -n "${NTFY_TOPIC}" ]; then | |
| echo "configured=yes" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "configured=no" >> "$GITHUB_OUTPUT" | |
| fi | |
| - name: POST ntfy CVE alert (scheduled, optional) | |
| if: github.event_name == 'schedule' && steps.ntfy_configured.outputs.configured == 'yes' | |
| env: | |
| NTFY_TOPIC: ${{ secrets.NTFY_TOPIC }} | |
| NTFY_SERVER: ${{ secrets.NTFY_SERVER }} | |
| NTFY_TOKEN: ${{ secrets.NTFY_TOKEN }} | |
| run: | | |
| NEW_FILE=/tmp/audit-new-issues.json | |
| # No file → sync-issues did not run or wrote nothing; treat as none. | |
| if [ ! -s "$NEW_FILE" ]; then | |
| echo "No new-issues file — nothing to alert." | |
| exit 0 | |
| fi | |
| COUNT=$(jq 'length' "$NEW_FILE") | |
| if [ "$COUNT" -eq 0 ]; then | |
| echo "No newly-opened High/Critical advisories this run — no ntfy alert." | |
| exit 0 | |
| fi | |
| SERVER="${NTFY_SERVER:-https://ntfy.sh}" | |
| SERVER="${SERVER%/}" | |
| # One-line summary of the new advisories: "pkg@ver (CVE) [severity]". | |
| SUMMARY=$(jq -r '.[] | "\(.package)@\(.installedVersion) (\(.cve // "no-cve")) [\(.severity)]"' "$NEW_FILE" | paste -sd "; " -) | |
| BODY="${COUNT} new High/Critical advisory(ies) in the daily dependency scan: ${SUMMARY}. A tracking issue was opened. See the pnpm-audit workflow run for detail." | |
| AUTH_ARGS=() | |
| if [ -n "${NTFY_TOKEN}" ]; then | |
| AUTH_ARGS=(-H "Authorization: Bearer ${NTFY_TOKEN}") | |
| fi | |
| curl --fail-with-body -sS -X POST \ | |
| -H "Title: VOID: New High/Critical CVE" \ | |
| -H "Priority: high" \ | |
| -H "Tags: lock,warning" \ | |
| "${AUTH_ARGS[@]}" \ | |
| --data "${BODY}" \ | |
| "${SERVER}/${NTFY_TOPIC}" | |
| - name: Upload audit JSON as artifact | |
| if: always() | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: pnpm-audit-${{ github.run_id }} | |
| path: | | |
| /tmp/pnpm-audit.json | |
| /tmp/audit-report.json | |
| if-no-files-found: ignore | |
| retention-days: 30 |