Vulnerability scanner with enrichment superpowers
Quick Start · Usage · Output Formats · CI Gating · Configuration
Wolfee picks up where other scanners stop. It wraps trivy (for container images) and cdxgen + OSV.dev (for source projects), then layers every finding with real-world context: is this CVE actively exploited? Does a public proof-of-concept exist? Is the dependency itself malicious or toxic?
wolfee --image nginx:latestOne command. No local database to warm up. Enriched results in seconds.
flowchart LR
subgraph INPUT["Input"]
A1["🐳 --image"]
A2["📁 path / --bom / --purl"]
A3["🔬 --reachable"]
end
subgraph DETECT["Detection"]
B1["trivy\n(offline DB)"]
B2["cdxgen → OSV.dev\n(30+ ecosystems)"]
B3["cdxgen → OSV.dev\n+ call-graph oracle"]
end
subgraph ENRICH["Enrichment layer"]
direction TB
C1["🔴 CISA KEV"]
C2["📊 EPSS"]
C3["💥 PoC-in-GitHub"]
C4["☠️ ossf/malicious-packages"]
C5["🧪 toxic-repos feed"]
end
subgraph OUT["Output"]
D1["📋 table"]
D2["📄 JSON"]
D3["🛡 SARIF"]
end
A1 --> B1
A2 --> B2
A3 --> B3
B1 & B2 & B3 --> ENRICH
ENRICH --> D1 & D2 & D3
| Mode | Flag | Backend | Best for |
|---|---|---|---|
| Container image | --image <ref> |
trivy (offline DB) | Docker/OCI images, registry references |
| Image vs your SBOM | --image <ref> --compare <src> |
trivy + cdxgen + govulncheck/atom | Split image packages APP (yours) vs IMAGE (not), reachability on APP |
| Project source | <path> |
cdxgen + OSV.dev | Monorepos, multi-language projects |
| Existing SBOM | --bom <file> |
OSV.dev | CycloneDX BOM files |
| Single package | --purl <purl> |
OSV.dev | Ad-hoc PURL lookups |
| Reachability (Go) | --reachable <dir> |
cdxgen + OSV.dev + govulncheck | Symbol-level call-graph for Go |
| Reachability (JS/Python/Java/PHP) | --reachable <dir> |
cdxgen + OSV.dev + atom | Package-level call-graph |
Every finding, regardless of scan mode, is enriched with:
| Feed | Source | What it adds |
|---|---|---|
| CISA KEV | cisa.gov | KEV flag — actively exploited in the wild |
| EPSS | api.first.org | Exploit probability score (0–1) |
| PoC-in-GitHub | github.com/nomi-sec/PoC-in-GitHub | Public proof-of-concept link |
| ossf/malicious-packages | github.com/ossf/malicious-packages | Day-zero malware hits by package name |
| toxic-repos | github.com/toxic-repos/toxic-repos | DDOS payloads, geo-blocks, hostile changes |
Feeds are cached to disk (TTL + conditional GET). Zero pre-warming needed.
# From source (requires Go 1.21+)
go install github.com/shinigamikiko/wolfee-cli/cmd/wolfee@latest
# Or build locally
git clone https://github.com/shinigamikiko/wolfee-cli
cd wolfee-cli
make build # → ./bin/wolfee
make tools # → ./bin/trivy + ./bin/govulncheckmake docker # builds wolfee + trivy + cdxgen + atom
docker run --rm wolfee --image nginx:latest| Mode | Requires |
|---|---|
--image |
trivy on PATH (or --trivy-bin <path>) |
path / --bom / --reachable |
cdxgen on PATH |
--reachable (Go) |
govulncheck on PATH (or --govulncheck-bin <path>) |
--reachable (JS / Python / Java / PHP) |
atom + atom-parsetools on PATH (or --atom-bin <path>) |
wolfee --image nginx:1.27
wolfee --image my-app:latest --platform linux/arm64
wolfee --image my-app:latest --save-sbom my-app.cdx.json
# Tell base-image packages apart from your own (see below)
wolfee --image my-app:latest --scout
# Pass raw trivy flags (air-gapped DB mirror, offline mode, etc.)
wolfee --image my-app:latest \
--trivy-arg=--offline-scan \
--trivy-arg=--db-repository=registry.corp/aquasecurity/trivy-dbA container image is a stack of layers: the ones from the base image you
chose (FROM …) and the ones your build added on top. A CVE in a base
layer is usually fixed by bumping the base image; a CVE in your own
layer is yours to fix. Wolfee can tell them apart so you know what to
look at first.
Each finding gets an ORIGIN column:
| Token | Meaning | Typical action |
|---|---|---|
APP |
package added by your layers | triage now — it's your dependency |
BASE |
non-OS package shipped in the base image | bump / rebuild the base image |
DEB / APK / RPM |
OS package (apt/apk/dnf) | distro-managed — apt upgrade / base update |
— |
not attributed (--scout off, no base found, or squashed layer) |
run with --scout |
# Attribute every package BASE vs APP
wolfee --image my-app:latest --scout
# Works on arbitrary third-party images too (docker scout fallback)
wolfee --image registry/some/image:tag --scoutOne flag, base detected automatically (--scout). You don't name the
base — wolfee finds it, local-first:
- OCI base-image labels (
org.opencontainers.image.base.*) — stamped by BuildKit, digest-pinned, authoritative. Free and offline. - The rootfs-import line in the image history — an image built
FROM scratch+ADD alpine-minirootfs-3.19.9-x86_64.tar.gz /carries no labels, but the distro and version are right there in the layer'screated_by, so wolfee derivesalpine:3.19.9from it. Free and offline. - Docker Scout — only when 1 and 2
find nothing, e.g. an arbitrary image with an anonymised rootfs
(
ADD file:<sha> in /) and no labels. Scout matches the image's layers against its cloud database of known base images and names the base.
The Scout fallback has real requirements: the docker scout CLI plugin, a
running Docker daemon, and docker login — and it uploads image metadata
to Docker's cloud, so it does not work air-gapped. If the plugin is
missing or you're not logged in, attribution is skipped with a warning and
the scan continues (you still get DEB/APK/RPM on OS packages). Most
labelled or distro-rootfs images never reach step 3.
Whatever the base, it is hash-verified against the layer stack (below), so
a wrong guess degrades to —, never a wrong label. Without --scout no
base is resolved at all — a plain scan never pulls a base or calls out.
How it's decided — by content hash, not heuristics. Every layer has a
DiffID (the SHA-256 of its uncompressed contents), and trivy reports
the DiffID each package was found in. Wolfee resolves the base image's
ordered DiffIDs and verifies they are an exact prefix of the scanned
image's layers (an image built FROM a base carries that base's layers,
byte-for-byte, as its leading layers). A package whose layer DiffID is in
that prefix is provably from the base; everything else is yours. If the
resolved base doesn't match the image's layer stack (wrong/stale tag,
squashed image, no layer metadata) wolfee leaves the origin unknown
rather than guess.
origin is also emitted in JSON ("origin": "app") and as a SARIF result
property, so you can gate or filter on it downstream. Resolving the base
runs trivy against it once (layers are cached); when no base is resolved,
attribution is skipped, only OS packages are tagged (DEB/APK/RPM),
and the table prints a one-line hint pointing at --scout.
An image is a mix of your application's libraries and a lot of things that
just came along for the ride — the base image, OS packages, binaries copied
in at build time. --compare <src> separates the two by comparing the image
against your application's own SBOM (cataloguing the source tree with
cdxgen), and tags every image finding in the ORIGIN column:
ORIGIN |
meaning |
|---|---|
APP |
one of your libraries, direct (in your SBOM) |
APP(T) |
one of your libraries, transitive (indirect) |
LIB(image) |
a language library in the image but not in your source (e.g. a copied CLI tool's deps) |
DEB(image)/APK(image)/RPM(image) |
OS package from the image |
DEB/APK/RPM |
OS package (plain --image, image-vs-source not computed) |
The (image) qualifier means "ships in the image, not in your source" — so
within everything that isn't yours you can still tell an OS package from a
stray bundled library.
Whenever wolfee knows your project's source languages (any source-aware scan —
--compare, --reachable, or a plain project scan) it also fills the LANG
column, flagging each library against those languages:
LANG |
meaning |
|---|---|
relevant-<lang> (green) |
the library's language matches one of your project's languages — e.g. relevant-go in a Go project |
relevant-<lang> (red) |
the library's language is foreign to your project — e.g. a relevant-php library surfacing in a Go project |
runtime/os |
an OS/runtime package, not a source library (no relevance verdict) |
In JSON output the same verdict is exposed as a boolean relevant field on each
component (true when the language matches, false when it doesn't; omitted
when there is no source language to compare against).
wolfee --image my-app:latest --compare ./srcVulnerabilities are reported from both engines: the image (trivy) and
your sources (OSV over the cdxgen SBOM). A source-declared vulnerable library
that isn't in the image is still listed (as APP); a clean source library is
not, so the inventory stays the image's. Transitive (indirect) APP
dependencies are flagged transitive from their SBOM scope, so you can
tell a direct dependency from one pulled in underneath it.
On top of that, the call-graph analyzers run over the same source tree and
mark the APP findings reachable / unreachable:
- Go →
govulncheck(symbol-level), including the application'sstdlib. - JS / Python / Java / PHP →
atom(package/flow-level).
IMAGE packages are deliberately not analyzed for reachability — "does
our code call it?" isn't a meaningful question for a foreign binary's
dependencies, so they're left unmarked rather than guessed at.
Needs the image's source tree on disk, cdxgen for the SBOM, and the
relevant analyzer on PATH (govulncheck / atom, or --govulncheck-bin /
--atom-bin). Mutually exclusive with --scout — both drive ORIGIN, one
by SBOM membership, the other by base-image layers.
wolfee ./my-app # auto-detect languages, match OSV
wolfee --reachable ./my-app # filter to reachable vulnerabilities onlywolfee --bom existing.cdx.jsonwolfee --purl pkg:npm/lodash@4.17.20
wolfee --purl pkg:pypi/discordpydebug@1.0--reachable cuts through CVE noise by asking a harder question: is this vulnerable code path actually reachable from your own code? Two separate engines handle the analysis — the right one is chosen automatically based on what's in the project.
flowchart TD
A["wolfee --reachable ./my-app"] --> B["cdxgen — catalogue all components"]
B --> C{Language?}
C -->|Go| D["govulncheck -json\n(symbol-level)"]
C -->|JS · Python · Java · PHP| E["atom reachables\n(call-graph)"]
C -->|Other| F["OSV findings kept,\nreachability = unknown"]
D --> G["called / not-called\nper CVE-ID"]
E --> H["reachable packages\nPURL list"]
G & H --> I["wolfee enrichment\n(KEV · EPSS · PoC · toxic)"]
I --> J["table / JSON / SARIF\nwith REACHABLE · UNREACHABLE · UNKNOWN labels"]
Go gets symbol-level reachability via govulncheck. It performs a full static call-graph analysis and tells you not just which package is vulnerable, but whether the specific vulnerable function is reachable from your code — and from exactly which line.
What you get per finding:
- REACHABLE — the vulnerable symbol is called (transitively) from your code
- UNREACHABLE — the vulnerable package is imported, but the unsafe code path is never invoked
- Call site:
internal/handler/serve.go:142— exact file and line in your code that reaches the vuln - Source line at the call site, trimmed for the table renderer
Works across polyglot monorepos: wolfee walks every go.mod it finds under the scan root and runs govulncheck against each module independently.
# Go project
wolfee --reachable ./my-go-service
# Monorepo with multiple Go modules
wolfee --reachable ./monorepo # scans each go.mod sub-directory
# Custom govulncheck binary (air-gapped / pinned version)
wolfee --reachable ./my-go-service --govulncheck-bin ./bin/govulncheckExample output (table):
CVE-2024-34156 net/http HIGH REACHABLE internal/proxy/forward.go:88
CVE-2023-44487 golang.org/x/net CRITICAL UNREACHABLE (imported, never called)
Requires govulncheck on PATH:
go install golang.org/x/vuln/cmd/govulncheck@latest
# or
make tools # installs into ./bin/govulncheckFor these languages wolfee uses atom to build a code-graph and extract the set of packages that appear on any reachable call flow. This is package-level (not symbol-level): a package is either on a reachable path or it isn't.
Language detection is manifest-driven — atom only runs for languages whose manifest exists in the tree:
| Language | Detected by | atom -l value |
|---|---|---|
| JavaScript / Node.js | package.json |
javascript |
| Python | requirements.txt, pyproject.toml, setup.py, Pipfile, poetry.lock |
python |
| Java | pom.xml, build.gradle, build.gradle.kts |
java |
| PHP | composer.json |
php |
Polyglot repos are supported: each language gets its own atom run, and results are merged before enrichment.
# Node.js service
wolfee --reachable ./my-node-app
# Django app
wolfee --reachable ./my-django-service
# Spring Boot
wolfee --reachable ./my-java-service
# Custom atom binary
wolfee --reachable ./my-node-app --atom-bin /opt/atom/bin/atomJava note: atom requires a JDK 21+ on PATH. The wolfee Docker image ships one. Without JDK, atom exits with a clear error and the scan continues with reachability = unknown for Java packages.
Atom failures (OOM, missing binary, parse errors) are non-fatal: affected packages keep their reachability as UNKNOWN, and the rest of the scan — CVE detection + all enrichment — completes normally.
For npm projects wolfee uses its own import-graph scanner: it walks every .js, .jsx, .ts, .tsx, .mjs, .cjs file in the project and checks for import … from 'pkg' / require('pkg') statements. If package-lock.json is present, transitive dependencies of directly-imported packages are also marked reachable.
This is deliberately lightweight — no JVM, no call-graph, no AST. The trade-off is coarse granularity:
- A package with zero
importstatements anywhere in your source is flagged not-used. - Any package that appears in at least one import is marked imported — but whether the specific vulnerable code path inside it is ever reached is unknown.
wolfee --reachable ./my-node-app # scans *.js / *.ts for import statements
wolfee --reachable ./my-ts-app # TypeScript handled the same wayPython works identically:
import foo/from foo importstatements are scanned across.pyfiles. Packages whose import name differs from the PyPI name (e.g.PIL→Pillow,cv2→opencv-python) stayUNKNOWNrather thannot-used.
No analysis. Findings are reported from OSV.dev with reachability = UNKNOWN. KEV / EPSS / PoC enrichment still runs normally.
| Language / Ecosystem | Engine | Granularity |
|---|---|---|
| Go | govulncheck | Symbol-level — called / not-called + exact call site |
| Java / Maven | atom | Package-level — call-graph (needs JDK 21+) |
| PHP / Composer | atom | Package-level — call-graph |
| JavaScript / TypeScript / npm | import-graph | Import-level — is the package referenced in source at all? |
| Python | import-graph | Import-level — is the package referenced in source at all? |
| Ruby / Rust / C / .NET / other | — | UNKNOWN |
# Go reachability
go install golang.org/x/vuln/cmd/govulncheck@latest
# JS / Python / Java / PHP reachability
# atom-parsetools ships atom's frontend parsers (astgen for JS/TS,
# phpastgen for PHP) — atom can't build a call graph without it
npm install -g @appthreat/atom @appthreat/atom-parsetools
# Or install everything in one shot
make toolsThe wolfee Docker image (make docker) ships all of these: govulncheck, atom, atom-parsetools, and a JDK 21+.
# Coloured terminal table (default)
wolfee --image nginx:1.27
# Machine-readable JSON
wolfee --image nginx:1.27 --format json --output report.json
# SARIF — plugs directly into GitHub Code Scanning, GitLab, Sonar, Azure DevOps
wolfee --image nginx:1.27 --format sarif --output report.sarif# Exit 2 if any HIGH or CRITICAL finding (or malware detected)
wolfee --image my-app:latest --fail-on high| Exit code | Meaning |
|---|---|
0 |
Clean — no findings above threshold |
1 |
Tool error (network, malformed SBOM, cdxgen failure) |
2 |
Threshold breached (--fail-on) |
- name: Wolfee scan
run: wolfee --image ${{ env.IMAGE_REF }} --format sarif --output wolfee.sarif --fail-on high
- name: Upload to Code Scanning
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: wolfee.sarif
if: always()No config file. Everything works out of the box. Optional environment variables:
| Variable | Default | Description |
|---|---|---|
WOLFEE_TOKEN |
— | Auth token for --server upload (keeps token off shell history) |
GITHUB_TOKEN |
— | Lifts GitHub anonymous rate limit from 60 → 5 000 req/hr |
NO_COLOR |
— | Disable terminal colours (universal convention) |
WOLFEE_FEEDS_CACHE_DIR |
~/.cache/wolfee/feeds |
Override feed cache directory |
WOLFEE_FEEDS_OFFLINE=1 |
— | Air-gapped mode — serve only from cache, never hit network |
WOLFEE_FEED_URL_EPSS |
api.first.org (canonical) | Mirror URL for EPSS daily CSV |
WOLFEE_FEED_URL_KEV |
cisa.gov (canonical) | Mirror URL for CISA KEV JSON |
WOLFEE_FEED_URL_DLA_LIST |
debian.org (canonical) | Mirror URL for Debian DLA list |
WOLFEE_FEED_URL_DSA_LIST |
debian.org (canonical) | Mirror URL for Debian DSA list |
WOLFEE_NVD_CACHE_FILE |
— | Override NVD cache file path |
WOLFEE_NVD_CACHE=off |
— | Disable NVD cache |
Single-file feeds (EPSS CSV, CISA KEV, Debian DLA/DSA) are cached to disk. On TTL expiry, wolfee issues a conditional GET — most upstream feeds respond 304 Not Modified, downloading nothing. Per-CVE feeds (NVD, PoC-in-GitHub) cache independently.
Extend or override the public toxic-repos feed with your own curated list:
{
"packages": {
"pkg:npm/colourama": { "category": "typosquat" },
"pkg:npm/event-stream@3.3.6": { "category": "hijacked" },
"pkg:pypi/requesocks": "abandoned"
},
"repositories": [
"github.com/sketchy-org/random-fork"
]
}Set WOLFEE_TOXIC_URL to point at your overlay file (local path or HTTP URL). Categories appear in output as TOXIC[typosquat], TOXIC[hijacked], etc.
Directory layout
wolfee-cli/
├── cmd/wolfee/main.go entry — flag dispatch only
├── go.mod 3 external deps total
└── internal/
├── cli/ per-subcommand flag parsing
│ ├── cli.go dispatcher
│ ├── scan.go scan command (image|bom|purl|path|reachable)
│ └── version.go wolfee version
├── trivy/ trivy subprocess wrapper (--image)
├── cdxgen/ cdxgen subprocess wrapper (non-image)
├── reachability/ call-graph oracle (--reachable)
│ ├── govulncheck.go Go symbol-level analysis
│ └── atom.go JS / Python / Java / PHP analysis
├── onlinescan/ OSV detect + KEV/EPSS/PoC/toxic enrichment
│ ├── enrich.go enrichment orchestrator
│ ├── kev.go CISA KEV fetcher
│ ├── epss.go EPSS score fetcher
│ ├── poc.go PoC-in-GitHub lookup
│ ├── ossf.go malicious-packages detection
│ ├── toxicrepos.go toxic-repos feed
│ └── feedcache/ persistent HTTP cache (TTL + ETag/304)
├── sbomscan/ orchestrators: ScanBOM + ScanImage
├── upload/ POST /api/v1/bom — optional server push
└── output/ table / json / sarif renderers + logger
| wolfee | trivy (standalone) | grype | |
|---|---|---|---|
| Container image scanning | ✅ (via trivy) | ✅ | ✅ |
| Source / multi-ecosystem | ✅ (cdxgen, 30+) | Partial | Partial |
| CISA KEV enrichment | ✅ | ❌ | ❌ |
| EPSS scoring | ✅ | ❌ | ❌ |
| PoC-in-GitHub links | ✅ | ❌ | ❌ |
| Malicious package detection | ✅ (ossf + OSV MAL-*) | Partial | ❌ |
| Toxic-repos feed | ✅ | ❌ | ❌ |
| Reachability analysis | ✅ (--reachable) |
❌ | ❌ |
| SARIF output | ✅ | ✅ | ✅ |
| Server push | ✅ | Via DT plugin | ❌ |
| External DB to maintain | ❌ | ✅ (trivy DB) | ✅ (grype DB) |
--imagerequires thetrivybinary on PATH; trivy manages its own DB.- Enrichment feeds require outbound HTTP to
api.first.org,cisa.gov,api.github.com,raw.githubusercontent.com. UseWOLFEE_FEEDS_OFFLINE=1+ cached feeds for air-gapped CI. - OSV-backed modes additionally need
api.osv.dev. - PoC-in-GitHub lookups are unauthenticated by default (60 req/min). Set
GITHUB_TOKENor tune--concurrencyfor large CVE sets. - Image mode produces no CycloneDX SBOM unless
--save-sbomis set, so--serverupload is skipped with a warning. --scoutattribution needs access to the base image (one cached trivy resolve) and a non-squashed image that exposes layer DiffIDs; squashed/single-layer images and unmatched bases fall back toORIGIN = —. Its docker scout fallback (used only when OCI labels and the rootfs-history line are both absent) additionally needs thedocker scoutCLI plugin, a Docker daemon, anddocker login, and uploads image metadata to Docker's cloud (not air-gapped).- CVE-specific allowlists and VEX are not implemented yet.