fix(security): close 5 Trivy alerts — postcss/tar bumps + drop npm from the runtime image (#878)

* fix(security): close the 5 open Trivy alerts — dep bumps + drop npm from the runtime image

Backend deps:
- postcss 8.5.10 -> 8.5.18 (CVE-2026-45623, GHSA-r28c-9q8g-f849; the pin
  exists to force sanitize-html's transitive copy onto a fixed version)
- tar pin/override >=7.5.16 -> >=7.5.21, resolves 7.5.22
  (GHSA-r292-9mhp-454m)

Runtime image:
- Remove the npm CLI from the final stage instead of upgrading it: npm's
  bundled node_modules ship tar 7.5.19 and brace-expansion 5.0.7 (no npm
  release bundles the fixed versions — checked 11.18.0 and 12.0.1), and
  npm never runs in production. wait-for-db.sh now invokes the migration
  runners via node directly. This ends the recurring npm-bundled-CVE
  alert class; the previous 'npm install -g npm@11' line was itself a
  patch for the last batch.

* fix(restore): run post-restore migrations via node — the image ships no npm

restoreService still shelled out to 'npm run migrate:safe' after a
restore; with npm removed from the runtime image that would ENOENT into
the non-fatal catch, silently leaving a restored older backup on a
schema behind the running code until the next container restart. Invoke
migrations/run-migrations-safe.js through node directly, matching
wait-for-db.sh. The PR #596 source-contract test now pins the new
invocation.
This commit is contained in:
Paul Nothaft
2026-07-27 09:54:33 +02:00
committed by GitHub
parent ea8bd9b3e9
commit 08be2b84f1
6 changed files with 43 additions and 34 deletions
+10 -8
View File
@@ -37,14 +37,16 @@ ARG CACHEBUST=1
# Upgrade all packages to fix security vulnerabilities (OpenSSL, libexpat, BusyBox CVEs) # Upgrade all packages to fix security vulnerabilities (OpenSSL, libexpat, BusyBox CVEs)
RUN echo "cachebust=${CACHEBUST}" && apk upgrade --no-cache RUN echo "cachebust=${CACHEBUST}" && apk upgrade --no-cache
# Upgrade the npm CLI in the final image so its bundled deps are patched # Remove the npm CLI from the final image. Nothing runs npm here: the
# (sigstore 4.x, tar) — closes CVE-2026-48815 and the older @sigstore/core / tar # entrypoint is node, runtime deps are COPY'd from the builder stage, and
# Trivy alerts. Safe here: only the CLI present in the image changes. Runtime # wait-for-db.sh invokes the migration runners via node directly. npm's
# dependencies come from the builder stage (COPY --from=builder node_modules # bundled node_modules kept tripping Trivy (sigstore, tar 7.5.19,
# below) and the entrypoint runs node, not npm — so npm 11's install behaviour # brace-expansion 5.0.7 — even npm 12.0.1 still ships the vulnerable
# (the reason 10.x was pinned) never executes in this stage. npm 11 needs # copies), so shipping no npm ends that alert class instead of chasing
# Node >=22.9, satisfied by node:22-alpine. # per-release patches. Note: `docker exec … npm run <script>` no longer
RUN npm install -g npm@11 # works in the container — use `node migrations/run-migrations-safe.js`
# and friends instead.
RUN rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx
# Install dumb-init for proper signal handling, postgresql-client for database # Install dumb-init for proper signal handling, postgresql-client for database
# checks, ffmpeg for video upload support, and su-exec for the root → nodejs # checks, ffmpeg for video upload support, and su-exec for the root → nodejs
@@ -183,22 +183,24 @@ describe('restoreService — PG branch scope contract (PR #596 review)', () => {
expect(window).toMatch(/was_successful:\s*true/); expect(window).toMatch(/was_successful:\s*true/);
}); });
it('npm run migrate:safe is invoked after the replay in restore()', () => { it('the safe migration runner is invoked after the replay in restore()', () => {
// Contract from PR #596 round 4: backups taken on older picpeak // Contract from PR #596 round 4: backups taken on older picpeak
// versions must restore COMPLETELY on a newer image — even if new // versions must restore COMPLETELY on a newer image — even if new
// migrations have been added since the backup was taken. The // migrations have been added since the backup was taken. The
// restore() flow shells out to `npm run migrate:safe` AFTER the // restore() flow shells out to the safe migration runner AFTER the
// operator-meta replay so the schema catches up to the running // operator-meta replay so the schema catches up to the running
// code WITHIN the restore boundary (not on the next container // code WITHIN the restore boundary (not on the next container
// restart). // restart). Invoked as `node migrations/run-migrations-safe.js` —
// the runtime image ships no npm, so the former `npm run
// migrate:safe` would ENOENT into the non-fatal catch.
// //
// Contract: // Contract:
// 1. A `migrate:safe` shell-out exists somewhere in restoreService // 1. A run-migrations-safe shell-out exists somewhere in restoreService
// 2. It sits AFTER the replay drain — verification → replay → // 2. It sits AFTER the replay drain — verification → replay →
// migrations is the documented order // migrations is the documented order
// 3. It does NOT sit inside performDatabaseRestore (must run // 3. It does NOT sit inside performDatabaseRestore (must run
// against the reinit'd pool from the parent restore()) // against the reinit'd pool from the parent restore())
const migrateLine = findFirst(/['"]migrate:safe['"]/); const migrateLine = findFirst(/run-migrations-safe\.js/);
expect(migrateLine).toBeGreaterThan(0); expect(migrateLine).toBeGreaterThan(0);
const replayLine = findLast(/this\.preservedMetaSnapshot\.length\s*>\s*0/); const replayLine = findLast(/this\.preservedMetaSnapshot\.length\s*>\s*0/);
+14 -14
View File
@@ -1,12 +1,12 @@
{ {
"name": "picpeak-backend", "name": "picpeak-backend",
"version": "3.95.1-beta.0", "version": "3.95.2-beta.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "picpeak-backend", "name": "picpeak-backend",
"version": "3.95.1-beta.0", "version": "3.95.2-beta.0",
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.850.0", "@aws-sdk/client-s3": "^3.850.0",
"@aws-sdk/lib-storage": "^3.850.0", "@aws-sdk/lib-storage": "^3.850.0",
@@ -47,7 +47,7 @@
"pdf-lib": "^1.17.1", "pdf-lib": "^1.17.1",
"pdfkit": "^0.17.2", "pdfkit": "^0.17.2",
"pg": "^8.16.3", "pg": "^8.16.3",
"postcss": "8.5.10", "postcss": "8.5.18",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"react-i18next": "^15.6.0", "react-i18next": "^15.6.0",
"sanitize-html": "^2.17.0", "sanitize-html": "^2.17.0",
@@ -56,7 +56,7 @@
"swagger-jsdoc": "^6.2.8", "swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.1", "swagger-ui-express": "^5.0.1",
"swissqrbill": "^4.3.0", "swissqrbill": "^4.3.0",
"tar": ">=7.5.16", "tar": ">=7.5.21",
"uuid": "^11.1.1", "uuid": "^11.1.1",
"winston": "^3.8.2", "winston": "^3.8.2",
"zxcvbn": "^4.4.2" "zxcvbn": "^4.4.2"
@@ -9090,9 +9090,9 @@
} }
}, },
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "3.3.11", "version": "3.3.16",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
@@ -10097,9 +10097,9 @@
} }
}, },
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.10", "version": "8.5.18",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.18.tgz",
"integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", "integrity": "sha512-xdB1oSLHbz1vRWgCDalrCqEFTWzFlhqFC5tIHLMOSUIjhm3XXQ1qrFy8S/ESr1JYRRXqM3c1QFiMZUJdUTqyMQ==",
"funding": [ "funding": [
{ {
"type": "opencollective", "type": "opencollective",
@@ -10116,7 +10116,7 @@
], ],
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"nanoid": "^3.3.11", "nanoid": "^3.3.12",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
"source-map-js": "^1.2.1" "source-map-js": "^1.2.1"
}, },
@@ -11815,9 +11815,9 @@
} }
}, },
"node_modules/tar": { "node_modules/tar": {
"version": "7.5.19", "version": "7.5.22",
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.19.tgz", "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz",
"integrity": "sha512-4LeEWl96twnS2Q7Bz4MGqgazLqO+hJN63GZxXoIqh1T3VweYD997gbU1ItNsQafqqXTXd5WFyFdReLtwvRBNiw==", "integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==",
"license": "BlueOak-1.0.0", "license": "BlueOak-1.0.0",
"dependencies": { "dependencies": {
"@isaacs/fs-minipass": "^4.0.0", "@isaacs/fs-minipass": "^4.0.0",
+3 -3
View File
@@ -57,7 +57,7 @@
"pdf-lib": "^1.17.1", "pdf-lib": "^1.17.1",
"pdfkit": "^0.17.2", "pdfkit": "^0.17.2",
"pg": "^8.16.3", "pg": "^8.16.3",
"postcss": "8.5.10", "postcss": "8.5.18",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"react-i18next": "^15.6.0", "react-i18next": "^15.6.0",
"sanitize-html": "^2.17.0", "sanitize-html": "^2.17.0",
@@ -66,7 +66,7 @@
"swagger-jsdoc": "^6.2.8", "swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.1", "swagger-ui-express": "^5.0.1",
"swissqrbill": "^4.3.0", "swissqrbill": "^4.3.0",
"tar": ">=7.5.16", "tar": ">=7.5.21",
"uuid": "^11.1.1", "uuid": "^11.1.1",
"winston": "^3.8.2", "winston": "^3.8.2",
"zxcvbn": "^4.4.2" "zxcvbn": "^4.4.2"
@@ -86,7 +86,7 @@
"js-yaml": "^4.2.0", "js-yaml": "^4.2.0",
"fast-xml-parser": ">=5.7.0", "fast-xml-parser": ">=5.7.0",
"qs": ">=6.15.2", "qs": ">=6.15.2",
"tar": ">=7.5.16", "tar": ">=7.5.21",
"brace-expansion": ">=5.0.7", "brace-expansion": ">=5.0.7",
"minimatch": ">=9.0.7", "minimatch": ">=9.0.7",
"path-to-regexp": "0.1.13", "path-to-regexp": "0.1.13",
+4 -1
View File
@@ -298,7 +298,10 @@ class RestoreService {
this.log('info', 'Applying post-restore migrations to restored database...'); this.log('info', 'Applying post-restore migrations to restored database...');
this.updateProgress('Applying any post-backup migrations...'); this.updateProgress('Applying any post-backup migrations...');
const backendRoot = path.join(__dirname, '..', '..'); const backendRoot = path.join(__dirname, '..', '..');
const { stderr } = await spawnAsync('npm', ['run', 'migrate:safe'], { // Invoked via node directly — the runtime image ships no npm
// (see Dockerfile), and an ENOENT here would be swallowed by the
// non-fatal catch below, silently skipping post-restore migrations.
const { stderr } = await spawnAsync('node', ['migrations/run-migrations-safe.js'], {
cwd: backendRoot, cwd: backendRoot,
env: { ...process.env }, env: { ...process.env },
}); });
+5 -3
View File
@@ -120,12 +120,14 @@ echo "Ensuring storage directories exist..."
STORAGE_BASE="${STORAGE_PATH:-/app/storage}" STORAGE_BASE="${STORAGE_PATH:-/app/storage}"
mkdir -p "$STORAGE_BASE/events/active" "$STORAGE_BASE/events/archived" "$STORAGE_BASE/thumbnails" 2>/dev/null || true mkdir -p "$STORAGE_BASE/events/active" "$STORAGE_BASE/events/archived" "$STORAGE_BASE/thumbnails" 2>/dev/null || true
# Run migrations (use safe runner in production) # Run migrations (use safe runner in production). Invoked via node directly —
# the runtime image no longer ships npm (see Dockerfile: its bundled deps kept
# tripping CVE scanners while npm itself never runs in production).
echo "Running database migrations..." echo "Running database migrations..."
if [ "$NODE_ENV" = "production" ]; then if [ "$NODE_ENV" = "production" ]; then
npm run migrate:safe node migrations/run-migrations-safe.js
else else
npm run migrate node migrations/run-migrations.js
fi fi
# Execute the main command # Execute the main command