Skip to content

v0.24.0 — the program that actually runs

Choose a tag to compare

@cwayne18 cwayne18 released this 25 Sep 14:17
· 5 commits to main since this release
4882364

A container rarely runs the command its image declares. A Kubernetes
command:, docker run --entrypoint, a compose service's entrypoint:, a
Nomad task's command, a systemd unit's ExecStart — each replaces the image's
ENTRYPOINT, and nothing inside the image records that it happened. A closure
built from the image alone therefore roots the declared entrypoint, which on a
hardened image is usually a shell, and every library only that shell reaches
comes back as running code.

This release is about saying what actually runs, in whatever form you have it,
and about two taints that turned out to be claiming more than the evidence
supports.

Say what runs

--images-from now recognises a Kubernetes manifest the way it already
recognised a hauler manifest, so

kubectl get daemonset -A -o yaml | vexscan --images-from -

is the whole interface. Pod, Deployment, DaemonSet, StatefulSet, ReplicaSet,
ReplicationController, Job, CronJob, PodTemplate and List are read,
initContainers included.

Kubernetes is not the only thing that replaces an entrypoint, and none of the
others ship a file this program can read, so the same claim is sayable
directly: --entrypoint and --cmd on the command line, entrypoint= and
cmd= on a fleet-list line or in a [profile]. One argv token per use, not a
comma list — --listen=1.2.3.4,5.6.7.8 is one token, and --cmd= on its own
means "this image starts with no arguments", which is a claim rather than
silence.

An override replaces the declared entrypoint rather than adding a root
beside it. --roots could not express that, and should not: naming a program
that also runs is not a claim that the declared one does not.

Measured on rancher/hardened-calico:v3.32.0-build20260511, --all --ecosystem os:

--roots /usr/bin/calico-node --exec-policy=assume-none
  45 linked, 0 not_in_execute_path, 1 not_present

the same assertion as an entrypoint, by any of the four spellings
  38 linked, 7 not_in_execute_path, 1 not_present

Seven rows move from linked to not_in_execute_path: util-linux
(SUSE-SU-2026:2485-1, 3684-1), gzip (3269-1, 3592-1), net-tools (3223-1), kbd
(4211-1) and cpio (4012-1). Identical verdicts from the manifest, the flags,
the line keys and the profile.

Four refusals came with it, because an entrypoint assertion can only ever
narrow: --entrypoint= empty is an error; --entrypoint beside a manifest is
an error, because the manifest answers per container and the flag would be
silently overridden for every image in the run; an override the image does not
contain still raises a blocking taint; and --exec-policy=assume-none remains a
separate assertion, because saying which program starts is not saying that
program starts nothing.

An assertion now travels with the conclusion it bears on

--roots and --exec-policy=assume-none reveal nothing further about an image.
They replace a question the scan cannot answer with an answer the user supplied.
Until now a not_in_execute_path reached under an assertion was byte-identical,
in the report and in the emitted OpenVEX, to one the scan derived unaided — a
reviewer had no way to know there was an assumption to disagree with.

A run that asserted something now records what: a NOTE in the report, and an
impact_statement on the statements it bears on — the execute-path claims and
no others, since presence is read out of the image and out of the package's
symbol tables and no assertion about the entrypoint moves it. An unasserted run
produces exactly the bytes it did before.

Two taints that were claiming too much

Bounded dlopen callers. A reachable object importing dlopen taints
globally, which is right for an arbitrary caller and far too blunt for
libcrypto and libpam — neither of which the workload asked for, both of which
arrive with the base userland, either of them enough to withhold every
reachability conclusion in an image. Both load plugins from a fixed set of
directories that the closure already walks, so for them the closure is not a
lower bound. The discharge is refused unless three things are shown for the
image in front of us: a strict DT_SONAME match, nothing redirecting the loader
outside its known directories (every openssl.cnf candidate path across all
three distribution layouts, every file in /etc/pam.d, and /etc/pam.conf),
and at least one of those directories actually rooted in this graph. An
unreadable config counts as a redirect and keeps the block.

Packages that install only programs. A statically linked entrypoint blocks
every OS-package conclusion in an image, because the libraries it links are
inside it rather than on disk. On hardened-calico that one taint held 33 of 45
findings whose closure reached nothing. But it is a claim about linkable code,
and cpio installs /usr/bin/cpio and nothing else — a program is not linked
into another program, so no amount of static linking can be hiding cpio's code.
The discharge needs every ELF the package installs to be a program declaring no
SONAME (glibc's libc.so.6 carries its own PT_INTERP and is a program by header
alone; what makes a library is declaring a name to be linked against), and needs
the package to ship no .a or .o, which is precisely the linkable form a
static binary consumes.

This one reads no advisory and needs no mined symbol. Running
--mine-advisories over the same image mined 195 advisories and changed no row.

Name one dlopen caller instead of all of them

--dlopen-policy=assume-none says nothing in this image loads anything that
matters — including every library nobody looked at and every one a later rebuild
adds. On hardened-calico exactly three reachable objects call dlopen.
--dlopen-assume-none names one caller, by tree-absolute path or SONAME, and is
repeatable; naming all three clears exactly what the blanket flag clears, 0
verdicts different, while the recorded assertion shrinks from one unfalsifiable
sentence to three checkable ones. A bare filename is refused, a caller the graph
already proved bounded is reported as the proof rather than the promise, and a
name that matches nothing is reported as having discharged nothing rather than
passing unmentioned. Also available as dlopen-assume-none= per line and per
profile.

Fixes

  • A [profile] asserting only an entrypoint was refused as asserting nothing —
    the most useful profile a fleet can define, rejected for carrying the one key
    it exists to carry.
  • A [profile] naming another profile parsed cleanly and the reference was
    discarded, so an author could assert exec-policy=assume-none and have the
    scan never hear it. Now an error, because a dropped assertion makes the scan
    conclude less and so has no symptom.
  • Kubernetes ignores the image's CMD when a container sets command: and no
    args:. The manifest reader passed a nil Cmd through, which here means the
    opposite — keep the image's CMD — so the scan composed a command line the
    cluster never runs. Zero verdicts change; the model is true before something
    starts reading it.
  • A source that replaced the arguments and not the program was described as
    replacing the program.
  • - renders as "standard input" rather than "-".

What this does not clear

On a default RKE2 v1.37.1-rc1+rke2r1 install — 19 images, scanned with
--all --ecosystem os — none of this changes a verdict. Ten of those images
have no OS package database at all, eight more have packages and zero
advisories, and all 73 findings are in nginx-ingress-controller, whose config
already names its own program, so there is no entrypoint to correct. Correct
roots plus --exec-policy=assume-none change nothing there; adding
--dlopen-policy=assume-none clears 19, and the remaining 52 sit behind the
static-linking taint on packages that install libraries, which the
program-only discharge deliberately does not reach.

If you are upgrading expecting a shorter report on a distroless fleet, it will
not be shorter. What changed is that the answers which do depend on what runs
are now reachable without a cluster, and say out loud what they rest on.

Companion

gen-rke2-vexscan builds a fleet
list for an RKE2 release tag — every image, with how that release actually
starts it — from the release's image lists, the Helm charts shipped inline in
rke2-runtime, and pkg/podtemplate at the tag.