Skip to content

feat(rule): add rules for unpinned images in Compose and Dockerfile - #95

Open
nozaq wants to merge 7 commits into
mainfrom
claude/reproducibility-rule-candidates-gwdw8n
Open

feat(rule): add rules for unpinned images in Compose and Dockerfile#95
nozaq wants to merge 7 commits into
mainfrom
claude/reproducibility-rule-candidates-gwdw8n

Conversation

@nozaq

@nozaq nozaq commented Aug 2, 2026

Copy link
Copy Markdown
Member

Summary

This PR adds two new linting rules to detect unpinned container images in Compose-based and Dockerfile-based devcontainer configurations, complementing the existing no-image-latest rule for direct image references.

Key Changes

  • no-compose-image-latest rule: Reports when a Compose service (the one the dev container runs in) uses an image without an explicit tag or with the "latest" tag. Reads and parses referenced Compose files to extract the service's image configuration.

  • no-dockerfile-image-latest rule: Reports when a Dockerfile that a devcontainer.json builds from has FROM instructions naming images without explicit tags or with "latest". Parses Dockerfile syntax to extract base image references.

  • Shared utilities in util.go:

    • readConfigFile(): Safely reads referenced configuration files (Compose, Dockerfile) with size limits and path validation
    • ociFeatureRefs(): Extracts OCI Feature references from configuration objects
    • isLocalFeature() and isTarballFeature(): Helpers to identify Feature references that don't need version pinning
    • unpinnedFeatureVersion(): Generates consistent messaging for unpinned Feature versions
  • dockerfile.go: New module providing Dockerfile parsing utilities:

    • dockerfileRef(): Locates the Dockerfile path from devcontainer.json (handles both top-level dockerFile and nested build.dockerfile)
    • dockerfileBaseImages(): Extracts base images from Dockerfile, filtering out stage references, scratch, and variable references
    • dockerfileBuildImages(): Combines the above to get images with their source location
  • Refactored pin_feature_version.go: Extracted common Feature reference handling logic into shared utilities in util.go to reduce duplication and support the new pin-depends-on-version and pin-feature-exact-version rules.

  • Comprehensive test coverage: Added test suites for all new rules covering edge cases like missing files, parse errors, variable references, and file merging behavior.

Implementation Details

  • Both new image rules follow the same pattern as no-image-latest: they report at the configuration property that declares the image source (the Compose file path or Dockerfile path), making the finding location clear even though the fix belongs in the referenced file.

  • Compose file handling respects the merge semantics: multiple files are read in order with later ones overriding earlier ones, matching Docker Compose's behavior.

  • Dockerfile parsing uses the buildkit parser to properly handle multi-stage builds, filtering out stage-to-stage references and variable substitutions that cannot be statically resolved.

  • File reading is bounded by maxConfigFileBytes (4 MB) to prevent issues with truncated configuration analysis, and paths are validated to prevent directory traversal.

https://claude.ai/code/session_01Y7ohBnPuPSezRvwrkzaRaA

The reproducibility rules only looked at a devcontainer.json's "image",
"features", and "customizations", so the same moving reference went
unreported wherever else it is written: a Dockerfile-based or
Compose-based configuration escaped image pinning entirely, and a
Feature's own dependencies were never checked.

- no-dockerfile-image-latest and pin-dockerfile-image-digest read the
  Dockerfile named by "build.dockerfile" (or the legacy "dockerFile")
  and judge each FROM. A reference to an earlier stage, "scratch", and
  one containing a variable are left out: none names an image the
  configuration pins.
- no-compose-image-latest reads the "image" of the Compose service the
  dev container runs in. A service that builds its own image, an image
  written as a variable, and a service no declared file defines are
  left out.
- pin-depends-on-version checks a Feature's "dependsOn", where an
  unpinned reference installs a moving dependency into every project
  using the Feature, with no way for those projects to pin it.
- pin-feature-exact-version requires a full "major.minor.patch",
  since the "major" and "major.minor" tags are reassigned on release.
  It stands to pin-feature-version as pin-image-digest stands to
  no-image-latest.

A file another configuration file names is read through the directory
the linted file was discovered in, so a path leading outside that
boundary reports nothing rather than reaching for it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y7ohBnPuPSezRvwrkzaRaA
@nozaq nozaq changed the title Add rules for unpinned images in Compose and Dockerfile feat(rule): add rules for unpinned images in Compose and Dockerfile Aug 2, 2026
claude added 6 commits August 4, 2026 21:22
Three defects found in review, each confirmed end-to-end:

- instructions.Parse panics on a nil buildkit linter, which a Dockerfile
  reaches through a "# check=..." comment: the merge of that comment's
  config dereferences the receiver. Every rule reading such a Dockerfile
  reported "rule panicked" instead of its findings. Pass a linter whose
  Warn is nil, which reports nothing without being nil itself.
- The Compose reader guarded only "${VAR}", so the bare "$VAR" form
  reached the tag check and was reported as an image with no tag.
- "build.target" was ignored, so stages the build never reaches were
  reported. Only the target stage and what it builds on and copies from
  are read now, and with no target the last stage, as "docker build"
  does.

The Compose reader also stops at "extends" and "include", which can
define or override a service from a file it does not read: it now
reports only what compose-go's full resolution (see feature's
loadComposeService) would report too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y7ohBnPuPSezRvwrkzaRaA
The Dockerfile rules read only each stage's FROM, so an image a
"COPY --from" or a "RUN --mount=from" names went unreported. BuildKit
pulls those too: a "--from" naming no stage becomes a dispatch state of
its own, whose base image is resolved with the rest. A Dockerfile whose
FROM is digest-pinned but which copies a tool from "ghcr.io/…/uv:latest"
was reported clean.

The stage lookup also matched more than BuildKit does. A FROM base is
matched against the stages declared before it, as written, so
"FROM Builder" after "AS builder" names an image; only a COPY's "--from"
accepts a stage position, while "build.target" and a RUN --mount's
"--from" are names BuildKit lower-cases first. Reporting the earlier
stage's image for "FROM 0" named an image the build never pulls, and hid
the one it does.

Also state in the Compose rule's example what is left unchecked, rather
than attributing it to the Dockerfile rules, which read a Compose
service's "build" nowhere; and cover the two branches the suite reached
by neither input: a Dockerfile of global ARGs alone, which parses to no
stage at all, and a file over the size cap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y7ohBnPuPSezRvwrkzaRaA
Several stages may be declared under one name. BuildKit only warns about
that and keeps one stage per name as it registers them in turn, so a
reference reaches the last of them; the lookup here returned the first,
which is a different stage whenever a name is repeated.

It went wrong in both directions. A "COPY --from" naming a repeated stage
read the wrong one, so a Dockerfile that copies a tool from an unpinned
"AS tools" declared second was reported clean; a "build.target" or a FROM
base naming one reported the image of a stage the build replaces. A
differential run against BuildKit disagreed on 56 of 235 valid
multi-stage Dockerfiles before, and on none after.

The two Dockerfile rules also claimed in their examples that every image
a build pulls is checked, while an image written with a "$" variable is
deliberately left out — a gap that shows up in the "ARG VARIANT" form
devcontainer templates use, and that was documented nowhere the reader
can see.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y7ohBnPuPSezRvwrkzaRaA
pin-feature-exact-version declares both properties Feature references
are written under, and a rule's paths are matched in every file type it
applies to — so it also read a "features" member of a
devcontainer-feature.json and a "dependsOn" member of a
devcontainer.json, neither of which the specification defines. A Feature
that happened to carry a "features" object was told to pin versions in
it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y7ohBnPuPSezRvwrkzaRaA
pin-feature-exact-version accepted a component with a leading zero,
which semver forbids, so a reference no Feature can be published under
passed as pinned to one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y7ohBnPuPSezRvwrkzaRaA
The stage lookup's backward scan is spelled with slices.Backward, and
the dependency walk appends its slice in one call.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y7ohBnPuPSezRvwrkzaRaA
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