Skip to content

Commit 669b9e1

Browse files
committed
ci: add scheduled security audit workflow
Weekly npm audit for high/critical advisories, opening and auto-closing a tracking issue. Reports rather than gating PRs, so a newly published advisory cannot block the Dependabot PR that fixes it.
1 parent 1807e6a commit 669b9e1

1 file changed

Lines changed: 131 additions & 0 deletions

File tree

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
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

Comments
 (0)