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(orusr/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.tgzarchive (at most four are read, up to 512 MiB each) is checked for anos-releasemember. The archive is streamed with that byte limit and a member cap; nothing is written to disk. A root filesystem archive such asdocker exportproduces is read this way. Adocker saveimage 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_IMAGEis 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:8can mean 8.0 or 8.4, so it is not flagged, whilemysql:8.0is; - 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 saveimage 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, andalpineOS IDs are matched. Another distribution, or anos-releasewithout a usable version, produces no OS finding. - Kubernetes manifests, Helm charts, Terraform, CI files,
docker buildx bakefiles, and image registries are not read. postgresandmysqlupgrades 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.