|
| 1 | +name: Security Audit |
| 2 | + |
| 3 | +# Dependabot already opens PRs for advisories it can fix on its own. This job |
| 4 | +# covers the gap: advisories it cannot fix unaided (transitive pins needing an |
| 5 | +# `overrides` entry) and fixable ones whose PRs are sitting unmerged. |
| 6 | +# |
| 7 | +# Deliberately does NOT gate pull requests. A newly published advisory would |
| 8 | +# turn every PR red, including the Dependabot PR carrying the fix, which is how |
| 9 | +# vulnerabilities pile up in the first place. It reports instead of blocking. |
| 10 | + |
| 11 | +on: |
| 12 | + schedule: |
| 13 | + # Mondays 06:17 UTC, after Dependabot's weekly run so same-day fixes land first. |
| 14 | + - cron: '17 6 * * 1' |
| 15 | + workflow_dispatch: |
| 16 | + |
| 17 | +permissions: |
| 18 | + contents: read |
| 19 | + issues: write |
| 20 | + |
| 21 | +concurrency: |
| 22 | + group: security-audit-${{ github.ref }} |
| 23 | + cancel-in-progress: true |
| 24 | + |
| 25 | +jobs: |
| 26 | + audit: |
| 27 | + runs-on: ubuntu-latest |
| 28 | + steps: |
| 29 | + - uses: actions/checkout@v7 |
| 30 | + - uses: actions/setup-node@v7 |
| 31 | + with: |
| 32 | + node-version: 22 |
| 33 | + # Some repos intentionally ship without a committed lockfile; `npm ci` |
| 34 | + # requires one, so fall back to `npm install` in that case. |
| 35 | + - name: Install dependencies |
| 36 | + run: | |
| 37 | + if [ -f package-lock.json ]; then |
| 38 | + npm ci |
| 39 | + else |
| 40 | + npm install --package-lock-only |
| 41 | + fi |
| 42 | +
|
| 43 | + - name: Run npm audit |
| 44 | + id: audit |
| 45 | + run: | |
| 46 | + # npm audit exits non-zero when it finds anything; capture rather than fail. |
| 47 | + npm audit --audit-level=high --json > audit.json || true |
| 48 | + node --input-type=module <<'EOF' >> "$GITHUB_OUTPUT" |
| 49 | + import { readFileSync } from 'node:fs'; |
| 50 | +
|
| 51 | + let report; |
| 52 | + try { |
| 53 | + report = JSON.parse(readFileSync('audit.json', 'utf8')); |
| 54 | + } catch { |
| 55 | + // A malformed report means the audit itself failed. Surface that |
| 56 | + // rather than silently reporting "all clear". |
| 57 | + console.log('status=error'); |
| 58 | + console.log('count=0'); |
| 59 | + process.exit(0); |
| 60 | + } |
| 61 | +
|
| 62 | + const severities = ['high', 'critical']; |
| 63 | + const found = Object.values(report.vulnerabilities ?? {}).filter((v) => |
| 64 | + severities.includes(v.severity), |
| 65 | + ); |
| 66 | +
|
| 67 | + const lines = found |
| 68 | + .map((v) => { |
| 69 | + const advisories = (v.via ?? []) |
| 70 | + .filter((entry) => typeof entry === 'object') |
| 71 | + .map((entry) => `[${entry.title}](${entry.url})`); |
| 72 | + const fix = v.fixAvailable |
| 73 | + ? typeof v.fixAvailable === 'object' |
| 74 | + ? `\`${v.fixAvailable.name}@${v.fixAvailable.version}\`${v.fixAvailable.isSemVerMajor ? ' (semver-major)' : ''}` |
| 75 | + : 'yes' |
| 76 | + : 'none available'; |
| 77 | + return [ |
| 78 | + `- **${v.name}** (${v.severity}, ${v.isDirect ? 'direct' : 'transitive'})`, |
| 79 | + ` - fix: ${fix}`, |
| 80 | + ...advisories.map((a) => ` - ${a}`), |
| 81 | + ].join('\n'); |
| 82 | + }) |
| 83 | + .join('\n'); |
| 84 | +
|
| 85 | + console.log(`status=${found.length ? 'vulnerable' : 'clean'}`); |
| 86 | + console.log(`count=${found.length}`); |
| 87 | + console.log(`body<<AUDIT_EOF\n${lines}\nAUDIT_EOF`); |
| 88 | + EOF |
| 89 | +
|
| 90 | + - name: Open or update tracking issue |
| 91 | + if: steps.audit.outputs.status == 'vulnerable' |
| 92 | + env: |
| 93 | + GH_TOKEN: ${{ github.token }} |
| 94 | + COUNT: ${{ steps.audit.outputs.count }} |
| 95 | + DETAILS: ${{ steps.audit.outputs.body }} |
| 96 | + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} |
| 97 | + run: | |
| 98 | + TITLE="Security: $COUNT high/critical advisory(ies) in dependencies" |
| 99 | + BODY=$(printf '%s\n\n%s\n\n---\nFound by [Security Audit](%s). Updated automatically; closes itself when `npm audit --audit-level=high` is clean.\n\nIf a fix needs a transitive pin Dependabot cannot express, add an `overrides` entry to `package.json`.\n' \ |
| 100 | + "$DETAILS" "" "$RUN_URL") |
| 101 | +
|
| 102 | + # Create the label first: `gh issue list --label` errors when the |
| 103 | + # label does not exist yet, which is the state on the first run. |
| 104 | + gh label create security-audit --color B60205 \ |
| 105 | + --description "Raised by the scheduled dependency audit" 2>/dev/null || true |
| 106 | +
|
| 107 | + EXISTING=$(gh issue list --label security-audit --state open \ |
| 108 | + --json number --jq '.[0].number // empty' 2>/dev/null || true) |
| 109 | +
|
| 110 | + if [ -n "$EXISTING" ]; then |
| 111 | + gh issue edit "$EXISTING" --title "$TITLE" --body "$BODY" |
| 112 | + echo "Updated issue #$EXISTING" |
| 113 | + else |
| 114 | + gh issue create --title "$TITLE" --body "$BODY" --label security-audit |
| 115 | + fi |
| 116 | +
|
| 117 | + - name: Close tracking issue when clean |
| 118 | + if: steps.audit.outputs.status == 'clean' |
| 119 | + env: |
| 120 | + GH_TOKEN: ${{ github.token }} |
| 121 | + run: | |
| 122 | + for n in $(gh issue list --label security-audit --state open --json number --jq '.[].number' 2>/dev/null || true); do |
| 123 | + gh issue close "$n" --comment "\`npm audit --audit-level=high\` is now clean." |
| 124 | + echo "Closed issue #$n" |
| 125 | + done |
| 126 | +
|
| 127 | + - name: Fail if the audit could not be parsed |
| 128 | + if: steps.audit.outputs.status == 'error' |
| 129 | + run: | |
| 130 | + echo "::error::npm audit did not produce a parseable report" |
| 131 | + exit 1 |
0 commit comments