Skip to content

fix(image): the production image ships npm, and npm is where its CVEs are - #349

Merged
0xmanhnv merged 1 commit into
developfrom
fix/production-image-ships-npm
Aug 2, 2026
Merged

fix(image): the production image ships npm, and npm is where its CVEs are#349
0xmanhnv merged 1 commit into
developfrom
fix/production-image-ships-npm

Conversation

@0xmanhnv

@0xmanhnv 0xmanhnv commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

What found this

ui#347 made docker-scan run on a release PR rather than only after the tag
that publishes the image. On its first run it earned its keep: the v0.4.0
candidate image reported five vulnerabilities that were not on main's baseline.

All five are tar 7.5.16. tar is not in package-lock.json:

$ python3 -c "import json; d=json.load(open('package-lock.json'))['packages']; \
  print([k for k in d if k.endswith('/tar')])"
[]

It comes from npm's own bundled dependency tree inside node:26-alpine.

The production stage does not use npm

FROM base AS production
...
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
CMD ["node", "server.js"]

Next standalone output, started with node. No npm, no npx, at build time
or runtime, in this stage. The package manager is dead weight in the artifact we
publish — and it is where nearly all of that artifact's reported risk lives.

Measured, on real builds of both

Trivy --severity HIGH,CRITICAL against the two images:

HIGH/CRITICAL
current Dockerfile 6
with npm removed 1

Cleared: CVE-2026-59873, CVE-2026-59874 (tar), CVE-2026-13149,
CVE-2026-14257 (brace-expansion), CVE-2026-12151 (undici). Newly
introduced: none.

The remaining one is sharp 0.34.5, which is pre-existing on main, unreachable
today (no images: block in next.config.ts, next/image imported in zero
files, /public holds five SVGs which bypass sharp), and needs an overrides
entry rather than a Dockerfile change — see ui#344.

It still runs

docker run -d -p 39876:3000 ui:hardened
/            -> 200
/login       -> 200
/api/health  -> 200
✓ Ready

and npm / npx are gone from the image:

$ docker run --rm --entrypoint sh ui:baseline -c 'cat .../npm/node_modules/tar/package.json | grep version'
"version": "7.5.16"
$ docker run --rm --entrypoint sh ui:hardened -c 'ls /usr/local/lib/node_modules/npm'
npm REMOVED

The other thing that scan found

Two CRITICAL "Google (GCP) Service-account" secret detections in the shipped
JS chunks:

Match: (P.Textarea,{placeholder:'{*************************, ...}'

It is one textarea placeholder in edit-credential-dialog.tsx
{"type": "service_account", ...} — telling the user what shape of JSON to
paste. No secret material, and it has been alerting since 2026-06-08.

Replaced with Paste the full service-account key JSON. That is better copy
anyway (an instruction beats a truncated sample), and it means the scanner does
not need a suppression for our own UI hint. A secret alert that is always
false is the one nobody reads on the day it is true
— which matters more here
than in most codebases.

Checked there is no second copy: grep -rn '"type": *"service_account"' src/
returns nothing else.

Verified

Check Result
Trivy HIGH/CRITICAL on the built image 6 → 1
hardened image serves /, /login, /api/health 200
npm, npx present in image no
tsc --noEmit exit 0
vitest run 862 tests / 49 files, pass
prettier, palette gate clean

… are

ui#347 made docker-scan run on a release PR instead of only after the tag. Its
first run on the v0.4.0 candidate reported five vulnerabilities that were not on
main's baseline — all five in `tar 7.5.16`, which is not in package-lock.json at
all. It comes from npm's bundled dependency tree inside the node:26-alpine base
image.

The production stage runs `node server.js` on the Next standalone output and
never invokes npm or npx. So the package manager is pure attack surface in the
artifact we publish, and it is also the artifact's largest source of scan noise:
Trivy on the built image goes from 6 HIGH/CRITICAL to 1 when npm is removed. The
five cleared are tar x2, brace-expansion x2 and undici — none reachable from
anything this image executes, all of them counted against us.

The one that remains is sharp, which is pre-existing on main, unreachable today
(no images: block in next.config.ts, next/image imported in zero files, /public
holds five SVGs which bypass sharp entirely) and needs an overrides entry rather
than a Dockerfile change.

Also: the same scan flags two CRITICAL "Google (GCP) Service-account" secrets in
the shipped JS chunks. Both are one Textarea placeholder,
`{"type": "service_account", ...}`, telling the user what shape to paste.
Replaced with an instruction rather than a truncated JSON sample — clearer copy,
and a secret scanner should not have to be taught to ignore our own UI hints. A
secret alert that is always false is one nobody reads the day it is true.

Verified against a real build: baseline image has npm's tar 7.5.16, hardened
image has no npm and no npx on PATH, and it serves — /, /login and /api/health
all 200 from a container run off the hardened image.
@0xmanhnv
0xmanhnv merged commit c20b4be into develop Aug 2, 2026
13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant