Skip to content

2026 July 26 update #28

2026 July 26 update

2026 July 26 update #28

Workflow file for this run

# 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