Skip to content

harden(docker): pin the image by hash — base digest + npm ci from a lockfile#91

Merged
askalf merged 1 commit into
masterfrom
harden/pin-image-deps
Jul 22, 2026
Merged

harden(docker): pin the image by hash — base digest + npm ci from a lockfile#91
askalf merged 1 commit into
masterfrom
harden/pin-image-deps

Conversation

@askalf

@askalf askalf commented Jul 22, 2026

Copy link
Copy Markdown
Owner

Closes the three agent-actionable open code-scanning alerts. The fourth (#21, CII-Best-Practices) needs a bestpractices.dev registration, not a code change — left open.

Scorecard #25Dockerfile:11, containerImage not pinned by hash

FROM node:22-slim is a mutable tag. Pinned to the digest it resolves to today (sha256:6c74791e…, verified against the registry, matches the remediation tip).

Scorecard #26Dockerfile:23, npmCommand not pinned by hash

npm i -g @askalf/truecopy@0.9.0 @modelcontextprotocol/server-everything@2026.7.4 pins a label, not bytes — a version tag can be republished, and nothing in the transitive tree was pinned at all. The image now installs from a lockfile:

  • new docker/package.json + docker/package-lock.json — the image's own dependency set, separate from truecopy's
  • RUN npm ci --omit=dev — all 108 packages carry an integrity hash; the git-sourced redstamp dependency is pinned to an exact commit

A tool whose entire purpose is making agents pin their supply chain should not install its own unpinned.

Two consequences worth review attention:

  1. The CLIs move from a global install to /app/node_modules/.bin. ENV PATH puts that first, which covers the build steps, the ENTRYPOINT, and the downstream server pin-everything.mjs spawns.
  2. git transport. npm writes hosted-git resolved URLs as git+ssh:// whenever the lock is generated on a machine with a GitHub SSH remote, and there is no key in the build. git config url."https://github.com/".insteadOf ssh://git@github.com/ makes the transport irrelevant — the commit SHA is what pins the dependency — so regenerating the lock on another machine can't break the build later. (Same trap as ci(fuzz): root .dockerignore from #72 blanks ClusterFuzzLite's build context — scope it to the MCP image #76's ClusterFuzzLite build.)

Pinning by hash means nothing moves the pins on its own, so Dependabot now watches the base image (docker) and the image dependency set (/docker npm) alongside the existing entries.

Verification: npm ci --omit=dev re-run in a clean directory with an empty npm cache and GIT_SSH_COMMAND=false — resolves and installs 108 packages over HTTPS, truecopy / truecopy-mcp / mcp-server-everything all present in node_modules/.bin. The full image build + live MCP handshake is what docker.yml asserts on this PR (it triggers on Dockerfile, Dockerfile.dockerignore, docker/**), including the empty-tools/list regression and the non-root check.

CodeQL #27support/evidence.test.mjs:39, unused variable

mismatches was destructured and never used. Asserted rather than deleted: zero unverifiable hits on a real scan is the guarantee evidenceOf exists to make, and the integration test was the one place not checking it. 166 tests pass.

…ockfile

Closes the three agent-actionable code-scanning alerts.

Scorecard #25 (Dockerfile:11) — `FROM node:22-slim` is a mutable tag; pinned to
the digest it currently resolves to. Scorecard #26 (Dockerfile:23) — `npm i -g
<pkg>@<version>` pins a label, not bytes: the version tag can be republished and
nothing in the transitive tree was pinned at all. The image now installs from
docker/package-lock.json with `npm ci`, so all 108 packages carry an integrity
hash and redstamp is pinned to an exact commit. A tool whose entire purpose is
making agents pin their supply chain should not install its own unpinned.

Two consequences worth naming:

- The CLIs move from a global install to /app/node_modules/.bin, so that goes on
  PATH for the build steps, the ENTRYPOINT, and the downstream server the gate
  spawns.
- npm writes hosted-git `resolved` URLs as git+ssh:// whenever the lock is
  generated on a machine with a GitHub SSH remote, and there is no key in the
  build. `git config url.https://github.com/.insteadOf` makes the transport
  irrelevant — the commit SHA is what pins the dependency — so regenerating the
  lock elsewhere can't break the build later.

Pinning by hash means nothing moves the pins on its own, so Dependabot now
watches both the base image (`docker`) and the image's dependency set
(`/docker` npm). Verified `npm ci` resolves clean-cache with SSH disabled.

CodeQL #27 (support/evidence.test.mjs:39) — `mismatches` was destructured and
never used. Asserted instead of deleted: zero unverifiable hits on a real scan
is the guarantee evidenceOf exists to make, and the integration test was the one
place not checking it.

Alert #21 (CII-Best-Practices) is unaffected — it needs the bestpractices.dev
registration, not a code change.

@sprayberry-reviewer sprayberry-reviewer left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated review from the Sprayberry Labs fleet code reviewer.

Verdict: No blocking issues found — clean, well-documented supply-chain hardening change. Approving.

What I checked

  • gh pr diff 91 (1475 lines) — Dockerfile, Dockerfile.dockerignore, dependabot.yml, new docker/package.json + docker/package-lock.json, and the support/evidence.test.mjs assertion addition.
  • CI check-rollup: all 10 checks green, including build-and-smoke (the docker workflow that actually builds the image, asserts non-root uid, and does a live stdio MCP handshake to confirm tools/list isn't empty — exactly the failure mode a lockfile/PATH change could cause).
  • Cross-referenced docker/pin-everything.mjs and .github/workflows/docker.yml against the Dockerfile's new ENV PATH=/app/node_modules/.bin:$PATH (Dockerfile:38 in the new file) to confirm the local-bin CLIs (truecopy-mcp, mcp-server-everything) are actually resolvable at the point they're invoked (pin-everything.mjs, ENTRYPOINT) — they are, since the PATH change lands before both.
  • Confirmed docker/package-lock.json records an integrity hash for every registry-resolved package and a pinned commit SHA (resolved: git+https://github.com/askalf/redstamp.git#3da60b9d...) for the git-sourced dependency, matching the PR's stated intent.
  • .github/dependabot.yml diff adds ecosystems for both the new /docker npm manifest and the docker base image, consistent with the new pin points introduced.

What's good

  • The base-image digest and npm lockfile close the two Scorecard hash-pinning alerts described in the PR body, and the CodeQL unused-variable fix is paired with an actual assertion (mismatches === 0) rather than just silencing the lint.
  • The git config --global url."https://github.com/".insteadOf ssh://git@github.com/ line is a good catch — prevents a future lockfile regen on an SSH-configured machine from silently breaking the build.
  • build-and-smoke CI directly exercises the two riskiest consequences of this change (root-owned-but-readable node_modules, and PATH resolution for the relocated CLIs) rather than just asserting the image builds.

No correctness, security, or test-coverage concerns in the diff. I did not find anything to request changes on.

@askalf
askalf merged commit d06fa2f into master Jul 22, 2026
10 checks passed
@askalf
askalf deleted the harden/pin-image-deps branch July 22, 2026 01:13
askalf added a commit that referenced this pull request Jul 22, 2026
…frvp-7c67-39w9)

Scorecard opened a high-severity Vulnerabilities alert (#28) minutes after #91
landed. The dependency is not new — `npm i -g` was installing the same tree
before — but nothing in the repo described it, so nothing could see it. The
lockfile that #91 added is what made it visible, which is the point of having
one.

The advisory (published 2026-07-21, hours before that merge) is a path traversal
in @hono/node-server's serve-static, patched in 2.0.5. It reaches this image
transitively: server-everything -> @modelcontextprotocol/sdk -> @hono/node-server
"^1.19.9". That caret range can never resolve to 2.0.5, the SDK is already at its
latest (1.29.0), and server-everything is already at its latest (2026.7.4) — so
there is no upstream bump to wait for. An npm override is the only way off it
today.

Overriding a transitive major the SDK was not written against is the real risk
here, so it is tested rather than assumed: with the override in place, `npm ci`
resolves @hono/node-server 2.0.11 and the reference server still completes a full
MCP handshake over stdio, returning all 13 tools. The image only ever runs the
downstream over stdio, so it never constructs a Hono HTTP server at all — the
vulnerable serve-static path was unreachable here either way. This is about not
shipping a flagged dependency in a supply-chain tool's own image, not about a
live exploit.

Drop the override once the SDK widens its range; Dependabot watches /docker
(added in #91) and will offer that bump.

Verified: clean-cache `npm ci --omit=dev` with SSH disabled resolves 108 packages
and pins @hono/node-server to the patched 2.0.11; live stdio handshake against
that exact tree returns 13 tools. The full image build + non-root check + live
handshake is what docker.yml asserts on this PR.
askalf added a commit that referenced this pull request Jul 22, 2026
…frvp-7c67-39w9) (#96)

Scorecard opened a high-severity Vulnerabilities alert (#28) minutes after #91
landed. The dependency is not new — `npm i -g` was installing the same tree
before — but nothing in the repo described it, so nothing could see it. The
lockfile that #91 added is what made it visible, which is the point of having
one.

The advisory (published 2026-07-21, hours before that merge) is a path traversal
in @hono/node-server's serve-static, patched in 2.0.5. It reaches this image
transitively: server-everything -> @modelcontextprotocol/sdk -> @hono/node-server
"^1.19.9". That caret range can never resolve to 2.0.5, the SDK is already at its
latest (1.29.0), and server-everything is already at its latest (2026.7.4) — so
there is no upstream bump to wait for. An npm override is the only way off it
today.

Overriding a transitive major the SDK was not written against is the real risk
here, so it is tested rather than assumed: with the override in place, `npm ci`
resolves @hono/node-server 2.0.11 and the reference server still completes a full
MCP handshake over stdio, returning all 13 tools. The image only ever runs the
downstream over stdio, so it never constructs a Hono HTTP server at all — the
vulnerable serve-static path was unreachable here either way. This is about not
shipping a flagged dependency in a supply-chain tool's own image, not about a
live exploit.

Drop the override once the SDK widens its range; Dependabot watches /docker
(added in #91) and will offer that bump.

Verified: clean-cache `npm ci --omit=dev` with SSH disabled resolves 108 packages
and pins @hono/node-server to the patched 2.0.11; live stdio handshake against
that exact tree returns 13 tools. The full image build + non-root check + live
handshake is what docker.yml asserts on this PR.
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.

2 participants