Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions .github/scripts/fix-advisories.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// Fixes the npm advisories Dependabot reports as "No update possible": a transitive package held
// back by an exact pin in `overrides` or by its parent's version range. Dependabot never edits
// `overrides`, so this script does. Run by workflows/security-auto-fix.yml from the repo root.
import { execFileSync, spawnSync } from 'node:child_process';
import { appendFileSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';

const PACKAGES = ['front-end', 'back-end'];

const npm = (cwd, ...args) => execFileSync('npm', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'inherit'] });

// npm audit exits non-zero when it finds anything, so read its output regardless
const audit = (cwd) => JSON.parse(spawnSync('npm', ['audit', '--json'], { cwd, encoding: 'utf8' }).stdout).vulnerabilities ?? {};

// Packages with an advisory of their own, not the ones flagged only for depending on them
const advised = (vulnerabilities) => Object.values(vulnerabilities).filter((v) => v.via.some((via) => typeof via === 'object'));

const major = (version) => Number(version.split('.')[0]);

const lockedMajors = (cwd, name) => {
const { packages } = JSON.parse(readFileSync(join(cwd, 'package-lock.json'), 'utf8'));
return Object.entries(packages)
.filter(([path]) => path.endsWith(`node_modules/${name}`))
.map(([, { version }]) => major(version));
};

const fixed = [];
const crossMajor = [];
const unfixed = [];

for (const cwd of PACKAGES) {
// Lifecycle scripts stay off: this job holds a token that can write to the repo.
// Exits non-zero when it leaves anything unfixed, which the overrides below deal with.
spawnSync('npm', ['audit', 'fix', '--ignore-scripts'], { cwd, stdio: 'ignore' });

const path = join(cwd, 'package.json');
const manifest = JSON.parse(readFileSync(path, 'utf8'));
const direct = { ...manifest.dependencies, ...manifest.devDependencies };

for (const { name } of advised(audit(cwd))) {
// Dependabot updates direct dependencies, and npm refuses an override that disagrees with one
if (name in direct) continue;

const latest = npm(cwd, 'view', name, 'version').trim();
const crossesMajor = lockedMajors(cwd, name).some((m) => m < major(latest));
// A range, not an exact pin, so later patches aren't blocked the way 1.16.1 blocked 1.16.2
manifest.overrides = { ...manifest.overrides, [name]: `^${latest}` };
writeFileSync(path, `${JSON.stringify(manifest, null, 2)}\n`);
(crossesMajor ? crossMajor : fixed).push(`${cwd}: ${name} ^${latest}`);
}

npm(cwd, 'install', '--ignore-scripts');
unfixed.push(...advised(audit(cwd)).map(({ name }) => `${cwd}: ${name}`));
}

const report = [
...fixed.map((line) => `- ${line}`),
...crossMajor.map((line) => `- ${line} (new major version)`),
...unfixed.map((line) => `- ${line} (still vulnerable, no fix found)`),
].join('\n');
console.log(report || 'No overrides needed.');

if (process.env.GITHUB_OUTPUT) {
appendFileSync(process.env.GITHUB_OUTPUT, `cross-major=${crossMajor.length > 0}\nreport<<EOF\n${report}\nEOF\n`);
}
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ on:
pull_request:
push:
branches: [main]
# Started by security-auto-fix.yml, whose pushes don't trigger pull_request
workflow_dispatch:

permissions:
contents: read
Expand Down
57 changes: 57 additions & 0 deletions .github/workflows/security-auto-fix.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
name: Security auto-fix

# Covers the alerts Dependabot security updates can't: it won't change `overrides` or move a
# transitive package past its parent's range. Everything else still goes through Dependabot.
on:
schedule:
- cron: '30 5 * * *'
workflow_dispatch:
push:
branches: [test/security-auto-fix]

permissions:
contents: read

concurrency:
group: security-auto-fix

env:
BRANCH: security/auto-fix

jobs:
fix:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
actions: write # to start CI below
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version-file: .nvmrc
- id: fix
run: node .github/scripts/fix-advisories.js
- id: changes
run: echo "found=$([ -n "$(git status --porcelain)" ] && echo true || echo false)" >> "$GITHUB_OUTPUT"
- if: steps.changes.outputs.found == 'true'
run: |
git config user.name 'github-actions[bot]'
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
git switch -C "$BRANCH"
git commit -am 'Fix npm security advisories'
git push --force origin "$BRANCH"
if [ -z "$(gh pr list --head "$BRANCH" --state open --json number --jq '.[].number')" ]; then
gh pr create --head "$BRANCH" --title 'Fix npm security advisories' --body "$REPORT"
fi
# Pushes made with GITHUB_TOKEN don't trigger workflows, but a dispatch does, and its
# front-end and back-end checks are the ones the "Require CI" ruleset waits for.
gh workflow run ci.yml --ref "$BRANCH"
# Same policy as dependabot-auto-merge.yml: majors wait for a human
if [ "$CROSS_MAJOR" = 'false' ]; then
gh pr merge "$BRANCH" --auto --squash
fi
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPORT: ${{ steps.fix.outputs.report }}
CROSS_MAJOR: ${{ steps.fix.outputs.cross-major }}
Loading
Loading