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`);
}
55 changes: 55 additions & 0 deletions .github/workflows/security-auto-fix.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
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
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- 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"
# A personal token, not GITHUB_TOKEN: GitHub holds CI on github-actions[bot] PRs for approval,
# so the checks the "Require CI" ruleset waits for would never run. Only this step gets it,
# so nothing npm installs can read it.
- 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 "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "$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
# 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.SECURITY_AUTOFIX_TOKEN }}
REPORT: ${{ steps.fix.outputs.report }}
CROSS_MAJOR: ${{ steps.fix.outputs.cross-major }}
Loading
Loading