meles, part 1: a malware scanner that digs through the layers

meles, part 1: a malware scanner that digs through the layers

October 29, 2026
meles logo: a badger's head

Container image scanners have become part of every pipeline: they tell you
which packages have known CVEs, which configuration is risky and whether
someone left a secret in a layer. Malware is a different question. A
cryptominer dropped into /usr/local/bin has no CVE, and a webshell in a
web root is not a misconfiguration.

meles is my attempt to answer that question for my own clusters, and later
for anyone who wants it. It is a free, open source malware scanner for
container images that works the way image scanners usually do: one command
against an image reference, no Docker daemon, output for humans and for CI.
It is meant to run next to a vulnerability scanner, not to replace one. The
name comes from Meles meles, the European badger. It digs through the
layers.

This is the first part of a series. It covers what meles does and how its
parts fit together. Later parts go into the scanning details and into
running it across a fleet.

ℹ️
The code is on GitHub:
github.com/movray/meles.

What already exists, and what is missing

I looked at what is out there (as of October 2026). Malware scanning of
container images exists, but in two forms: as a feature of commercial
platforms (Anchore Enterprise, Prisma Cloud, Aqua, Trend Micro’s Artifact
Scanner), or for a vendor’s own images: Docker scans every layer of its
Hardened Images with ClamAV and attaches the result as a signed
attestation. Trivy, a widely used free scanner,
lists malware scanning as a feature of Aqua’s commercial product, not of
Trivy itself.

The free projects I found cover a part each. Deepfence YaraHunter scans
images, running containers and filesystems with YARA rules. Dagda scans
Docker images with ClamAV; it needs the Docker daemon, and its last commit
is from May 2023. Kubescape and a ClamAV operator scan the nodes of a
cluster, not the images. Harbor, the registry many of us run ourselves,
has pluggable scanner adapters; none of those in its compatibility list is
a malware scanner.

So the niche is narrow, and in it I found nothing comparable that is
freely available: a malware scanner for container images that is free,
daemonless, layer-aware and CLI-first
, with ClamAV and YARA, a
Kubernetes operator, a Harbor adapter, and output that a CI pipeline can
act on. That is where meles sits.

This is not a theoretical problem: in December 2025 Flare described a
cluster of 64 cryptomining images on Docker Hub, pulled about 6.5 million
times. In the images they examined, the miner was hidden in a directory
named after a real tool such as Gradle.

Why layers matter

A container image is a stack of tar archives. When a later layer deletes a
file, it does not remove it from the earlier layer. It adds a whiteout
entry instead:

  • .wh.<name> hides a single file or directory from the layers below.
  • .wh..wh..opq (an opaque whiteout) hides the whole content of a
    directory from the layers below.

The running container does not see the deleted file. The image still ships
it, and anyone who pulls the image can extract it. A scanner that only looks
at the final filesystem never sees it.

meles scans every layer on its own and keeps track of the whiteouts. Every
finding carries the digest of its layer, the Dockerfile instruction from the
image history where available, and a status:

  • present: visible in the final filesystem.
  • deleted: removed in a later layer, but still shipped.

The same approach makes caching cheap. In the server and the operator, a
result belongs to a layer digest and a signature database version, so the
layers of a common base image are scanned once and then skipped. A single
CLI run keeps no cache between runs: the signature database changes
several times a day, so a cache between CI jobs would rarely hit.

The scan pipeline

Every part of meles that scans, the CLI, the server and the operator, runs
the same pipeline:

image source --> puller --> layer extractor --> layer cache --> clamd --> reporter
(registry,    (go-container- (per layer,        (digest +      (INSTREAM) (table/json/
 OCI layout,   registry)      whiteouts, limits) DB version)               sarif,
 docker save)                                                              exit code)

A few decisions shape it:

  • No Docker daemon. Images come straight from the registry through
    go-containerregistry, with credentials from the usual Docker config. meles
    runs rootless in any CI job. An image that was just built and not pushed
    yet can be scanned from an OCI layout or a docker save archive.
  • clamd over the network, not libclamav. meles talks to a running clamd
    over its INSTREAM protocol. Linking against libclamav would make meles
    GPLv2; this way meles stays Apache-2.0, and the signature database is
    loaded into memory once, by clamd, instead of once per scan.
  • One file per stream. Sending a whole layer as one stream would be
    simpler, but clamd then only answers “stream: <signature> FOUND”, without
    a file name, and runs into its stream size limit. meles sends each file on
    its own, over a pool of parallel sessions.
  • All platforms by default. For a multi-arch image meles scans every
    platform in the index. Otherwise an amd64 runner could call an image clean
    while its arm64 variant is infected. Layers shared between platforms are
    scanned once.
  • Incomplete is not clean. Every size limit can be bypassed by making a
    file bigger. A file that meles or clamd skips makes the report incomplete,
    the output never says “No malware found”, and the exit code is the same as
    for a finding unless you change it.

The exit codes follow from that: 0 clean, 1 malware found or files
skipped, 2 an error. A finding fails the pipeline without extra flags,
and an error never passes as clean. Results come as a table, as JSON or as
SARIF, for example for GitHub code scanning.

Running it in a cluster

In Kubernetes, meles is a Helm chart with several roles. Each one can be
switched off on its own.

Cisco Talos CDN --> DB mirror (cvdupdate + nginx, one per site)
                        | freshclam (PrivateMirror)
                        v
                    clamd workers (signatures in RAM) + clamd-proxy (TLS, token)
                        ^              ^                 ^
                        | INSTREAM     | INSTREAM        | INSTREAM
                  meles server    meles CLI         meles operator
                  (web UI,        (CI jobs,         (watches pods,
                   schedules,      selftest)         MalwareReport CRDs,
                   SQLite)                           scan gate)
  • DB mirror: the only component that talks to Cisco Talos, at most once
    an hour, to respect their rate limits.
  • clamd workers: long-running, because a full signature database needs
    well over a gigabyte of RAM, and almost twice that while it reloads.
  • clamd-proxy: clamd has no authentication, and any client could send
    SHUTDOWN or ask it to scan a path on its own disk. A small proxy in the
    clamd pod adds TLS and a token and lets only the commands meles needs
    through. clamd itself only listens on localhost inside the pod.
  • meles server: a web UI with scheduled scans and their history in
    SQLite. Server-rendered pages, no JavaScript framework, no CDN, a strict
    Content Security Policy. It has no login of its own; that belongs in front
    of it, for example oauth2-proxy with Keycloak.
  • meles operator: scans what actually runs in the cluster.
  • Harbor adapter: registers meles as a scanner in Harbor, chained with
    Harbor’s Trivy, so one report shows CVEs and malware.
meles dashboard: a quick scan form, counters for tracked and infected images, an infected EICAR test image under Needs attention, and recent runs meles dashboard: a quick scan form, counters for tracked and infected images, an infected EICAR test image under Needs attention, and recent runs
The dashboard of meles server: one infected image (the public EICAR test image), clean runs, and the signature version with the hash of the YARA rules.

The operator

The operator watches all pods and writes one MalwareReport per workload
and container into the workload’s namespace, owned by the workload. Teams
see their own results with kubectl get malwarereports, and deleting a
workload cleans up its reports.

$ kubectl -n shop get malwarereports
NAME                                   WORKLOAD     CONTAINER              IMAGE                                   STATUS   FINDINGS   AGE
cluster-shop-db-bootstrap-controller   Cluster      bootstrap-controller   ghcr.io/cloudnative-pg/cloudnative-pg   Clean    0          5d12h
cluster-shop-db-postgres               Cluster      postgres               ghcr.io/cloudnative-pg/postgresql       Clean    0          5d12h
replicaset-web-7cb58b7fff-api-server   ReplicaSet   api-server             docker.io/library/python                Clean    0          5d12h
replicaset-web-7cb58b7fff-worker       ReplicaSet   worker                 docker.io/library/python                Clean    0          5d12h
replicaset-web-7cb58b7fff-init-db      ReplicaSet   init-db                docker.io/library/postgres              Clean    0          5d12h
replicaset-web-7cb58b7fff-nginx        ReplicaSet   nginx                  docker.io/library/nginx                 Clean    0          5d12h

Init containers get their own report, and a workload does not have to be a
Deployment: here the pods of a CloudNativePG Cluster are reported under
that custom resource.

A few things keep it cheap:

  • Every digest is scanned once. Ten Deployments running the same
    nginx digest share one scan; the result is copied into all reports.
  • Reconciles do not scan. Reconciling a pod only reads from the informer
    cache and either writes a known result or queues a scan. Scans run in
    their own queue, so a ten-minute scan of a large image blocks nothing
    else.
  • Rescans only when something changed: after a signature update, at most
    once a day, and failed scans are retried later.

False positives quickly make a scanner more annoying than useful, so
exceptions are first-class: MalwareIgnore for a namespace and
ClusterMalwareIgnore for the whole cluster. Every rule needs a reason and
an expiry date, and ignored findings stay visible in the report. If the
admin allows it, a rule can also be created from a finding in the web UI;
it is stored as the same kind of Kubernetes object, with the name of the
person who created it.

The optional scan gate turns reporting into enforcement. In namespaces
labelled meles.io/gate=enforce, a webhook adds a Kubernetes scheduling
gate to every new pod. The operator resolves the image tag, scans the image,
pins the pod to the digest it scanned and removes the gate. A pod with an
infected image never starts. Scans take minutes, admission webhooks get at
most 30 seconds, so holding the pod is the only way that works. The gate is
off by default and fails open, so an outage of meles does not stop your
cluster.

Where state lives

Nothing critical lives only in memory:

Where What
RAM layer cache, scan queue, recent results
Kubernetes API (etcd) MalwareReports, ignore rules: the source of truth
SQLite (server, one PVC) scheduled scans, their runs and findings, report history

When the operator restarts, running scans start over and the reports are
still there.

What meles is not

meles reports what is in an image. It does not replace image signatures and
SBOMs for supply-chain security, and it does not replace runtime detection
such as Falco or Tetragon: it never sees what a container downloads after it
starts. It never changes or deletes image content either; with the scan gate
enabled it holds pods, nothing more.

Its detection is only as good as its signatures. ClamAV’s are broad but
not tuned for container threats; meles adds a curated set of about 140
YARA rules for Linux malware, cryptominers and webshells, taken from
sources with a clear license, each match naming the rule’s author.
meles selftest --yara checks that clamd has actually loaded them. A
“clean” from meles means no known malware was found, not that the image is
safe.

Next in this series

  • What a CVE scanner does not look for: a demo with an EICAR file hidden
    behind a whiteout.
  • The launch: install, quickstart, a pipeline example, honest limits.
  • A deep dive into layer scanning, whiteouts and the digest cache.
  • Later: the operator across a fleet of clusters.

Links

Last updated on