6 minutes read

Container base images

The container detector reports base images whose release line has reached a vendor-published end of life. It also reads a built image's filesystem, when one is scanned, and reports the operating system and runtimes that actually ship inside it. It is stable: it is scored on the public corpus and held to the precision and recall floors of decision 0013. The reasoning is in Decision 0007.

What it reads

File Declaration
Dockerfile, Dockerfile.*, *.Dockerfile, Containerfile, Containerfile.* FROM <image>[:<tag>][@<digest>], with --platform and AS <stage> accepted and line continuations joined
compose.yaml, compose.yml, compose.*.yaml, compose.*.yml, docker-compose.yaml, docker-compose.yml, docker-compose.*.yaml, docker-compose.*.yml services.<name>.image (services built from a local Dockerfile via build: are skipped)
devcontainer.json, .devcontainer/devcontainer.json image (top-level string; JSONC comments are accepted)

It walks the repository up to eight directories deep (at most 2,000 matching files) and does not follow symbolic links. It skips .git, node_modules, vendor, dist, build, target, .venv, venv, fixtures, __fixtures__, and testdata.

Built images and root filesystems

The detector reads a container filesystem directly, without pulling an image, resolving a tag, extracting an archive, or executing anything in it:

  • Unpacked root filesystem. When the scanned directory contains etc/os-release (or usr/lib/os-release), it is read as a root filesystem alongside any Dockerfiles and Compose files in the repository.
  • Local root filesystem archive. A directory that holds a top-level .tar, .tar.gz, or .tgz archive (at most four are read, up to 512 MiB each) is checked for an os-release member. The archive is streamed with that byte limit and a member cap; nothing is written to disk. A root filesystem archive such as docker export produces is read this way. A docker save image archive is not a root filesystem (it holds layers), so unpack it first.

The operating system comes from ID, VERSION_ID, and VERSION_CODENAME in /etc/os-release (or /usr/lib/os-release), and is judged with the same Ubuntu, Debian, and Alpine rules as a Docker Hub base image. When VERSION_ID is absent, a Debian or Ubuntu codename is used.

Runtimes come from these well-known paths:

Runtime Evidence
Node.js usr/local/include/node/node_version.h or usr/include/node/node_version.h (NODE_MAJOR_VERSION, NODE_MINOR_VERSION, NODE_PATCH_VERSION)
Go usr/local/go/VERSION or usr/lib/go-<version>/VERSION
Java release under usr/lib/jvm/*, opt/java/openjdk, or opt/java/* (JAVA_VERSION)
Python usr/lib/pythonX.Y, usr/local/lib/pythonX.Y, or pythonX.Y in usr/bin or usr/local/bin
Ruby usr/lib/ruby/X.Y.Z, usr/local/lib/ruby/X.Y.Z, or rubyX.Y in usr/bin or usr/local/bin
PHP phpX.Y or phpXY in usr/bin or usr/local/bin
.NET usr/share/dotnet/shared/Microsoft.NETCore.App/X.Y.Z or usr/lib/dotnet/shared/Microsoft.NETCore.App/X.Y.Z

Each runtime version uses the same rules as a Dockerfile RUN pin. Installed release lines are reported, not individual patches: an image that keeps two Python release lines gets a finding for each one a rule covers, while Java reports one finding per major, .NET one per major.minor line, and Ruby's 3.1 and 3.1.0 are the same line. A runtime with no end-of-life rule (for example Python 3.12) produces nothing.

For an archive, a finding's location names the member, such as image.tar!etc/os-release or image.tar!usr/local/go/VERSION. For an unpacked directory it is the path itself, such as etc/os-release.

What it matches

Docker Hub official images: node, python, postgres, mysql, and ruby (node, library/node, and docker.io/library/node are the same image). Images from other registries or organizations (ghcr.io/acme/node, myorg/node) are never matched, with one exception.

Microsoft .NET images. mcr.microsoft.com/dotnet/sdk, aspnet, runtime, and runtime-deps (and dotnet/core/*, where the same images lived before .NET 5) are matched, because their tags name a .NET release line: sdk:6.0 is .NET 6. They are judged with the dotnet runtime rules (dotnet.runtime.end-of-support-*, with Microsoft's support policy and releases index as sources), not with a copy of them, so a rule changes in one place. A tag X.Y or X.Y.Z is the line X.Y; the variant suffixes Microsoft publishes (-alpine3.19, -bookworm-slim, -jammy-chiseled, -cbl-mariner2.0, -azurelinux3.0, -amd64, -nanoserver-1809) are accepted. A variable, latest, no tag, or an unrecognized suffix is unclassified. dotnet/framework/*, dotnet/nightly/*, and dotnet/monitor are never matched, and no other registry is.

A tag is read as a release line when it starts with a version and carries only a known variant suffix (alpine, slim, bookworm, bullseye, jammy, and similar). node:18, node:18.20.4-alpine, and postgres:11.5-bullseye are classified. A finding is reported only when every version the tag can stand for is inside the rule's range.

What it intentionally does not judge

These are reported as unclassified references (with file and line), never as findings and never guessed:

  • a tag that is a build argument or variable, such as node:${NODE_VERSION} (Offing does not expand it; $BASE_IMAGE is skipped entirely because the image is unknown);
  • latest, no tag, or a digest with no tag;
  • a floating tag that spans supported and unsupported versions: mysql:8 can mean 8.0 or 8.4, so it is not flagged, while mysql:8.0 is;
  • a tag with a suffix Offing does not recognize, such as node:20-custom-build.

Nothing is reported for scratch, a FROM that names an earlier stage, comments, RUN strings, Compose services built from a local Dockerfile (build:), Compose keys other than services.*.image (container_name, labels, extension fields), YAML aliases, or invalid files.

Known limits and false-positive boundaries

  • An image kept on an old release line on purpose (for example a compatibility test against mysql:5) is still reported. The rule states a vendor fact; whether the old version is intended is for the repository to decide, for example with a baseline.
  • A base image's tag does not reveal the operating system inside it. The OS is judged only when the image filesystem itself is scanned.
  • A docker save image archive is not a root filesystem, so it is not read; export or unpack the image first. Package databases (dpkg, apk, rpm) are not read, so a runtime installed only as a distro package without a versioned path is not detected.
  • Only the ubuntu, debian, and alpine OS IDs are matched. Another distribution, or an os-release without a usable version, produces no OS finding.
  • Kubernetes manifests, Helm charts, Terraform, CI files, docker buildx bake files, and image registries are not read.
  • postgres and mysql upgrades need a data migration; the rule's migration guidance says so, but Offing cannot check the data.

Data

The rules are in registry/container.json and ship in the data bundle. The Node.js and Python rules are drafted by the container-official-images importer from the vendors' own schedules; the PostgreSQL, MySQL, and Ruby rules are hand-written from each vendor's lifecycle page. The Docker Official Images repository is never used to decide support status. Each rule links its vendor source and carries migration steps and verification commands (docker build ., docker run --rm IMAGE ... --version).

Operating-system and runtime findings reuse the existing container and runtime registries (registry/container.json, registry/nodejs.json, registry/python.json, registry/ruby.json, registry/php.json, registry/java.json, registry/go.json, and registry/dotnet.json). Scanning a root filesystem adds no network access and no new lifecycle data.

Validation

42 public repositories at pinned commits are in the corpus, 37 of them selected blind (88%): 35 have expected findings of some detector, and seven have none. Their expected findings were reviewed by hand against the files at that commit, and where the maintainers later moved off the image the note links their commit. The selection rule is in Ecosystem corpora.

The .NET images are checked against SharpSocks, whose SharpSocksServer/Dockerfile uses mcr.microsoft.com/dotnet/runtime:5.0 and dotnet/sdk:5.0; both lines are expected findings in the corpus.

The root filesystem path has a fixture at tests/fixtures/container-rootfs: an unpacked Debian 11 tree with Node.js 16, Go 1.20, Java 13, PHP 8.1, Python 3.9, Ruby 3.1, and .NET 6, plus a supported Python 3.12. tests/container-rootfs.test.ts asserts the OS and runtime findings, then packs the same tree into a tar, a gzip-compressed tar, and a tar with one top-level directory and asserts the same findings with archive member locations.

View Markdown

Use with an agent
View Markdown

Open in