LLM layer (optional)
The LLM is an overlay and never a source of truth. It runs only on findings the deterministic tests could not clear, and it cannot change a status.
--llm— for CVEs whose vulnerable code is genuinely linked or reachable, a chat model gives an advisorylikely/unlikely/unknownexploitability verdict, recorded underllmon the finding. You choose which model — see Choosing a provider. Because the verdict lives in the per-finding evidence block,--llmturns on--detailsfor--format textso it is actually printed; pass--format json/sarifto get it structured instead.--mine-advisories— lets the model read an advisory's prose and extract symbols, sonames, filenames and module paths worth checking. Distro OSV records give a fixed version and nothing about what inside the package is vulnerable, so for OS packages this is often the only route to a below-package-level answer. For Python and npm the mined value is a dotted module path or a package subpath —yaml.constructor,lodash/template— and it is the only route to anot_presentfor a distribution that is installed and does ship code, since neither language eliminates dead code at build time. For Java the mined value is a class name, and this is the ecosystem that needs mining most while getting the least help with it: OSV's Maven records carry noecosystem_specificfunction data at all, unlike RustSec, so a class name can only come from prose. When one arrives it is checkable against something exact — a class is an entry in a zip.
Mined hints are contained, not trusted. A hint may only support a
not_affected-flavored status after validation: it must appear literally in
the advisory text, and it must be found in something the package actually
installs — the defined .dynsym of one of its libraries for an OS package,
its own installed file list for a Python or npm module path, an entry in the
archive for a Java class. An unvalidatable mined hint is indistinguishable from
a hallucination and is recorded as inconclusive, so a hallucinated hint is inert
rather than dangerous.
The Python and npm validations additionally defer to any blocking taint, and to a file list that had to be reconstructed rather than read. Both are cases where "the module is not here" could equally mean "we did not look in the right place".
The OS validation adds a shape gate, for the same reason the Java one below does. A namespace is shared by far more than its functions, so passing the namespace check is not evidence that the name is something a symbol table would ever have held. Two kinds get refused:
- Names that are not functions.
OSSL_CMP_CTXis a struct tag,OPENSSL_NO_COMP_ALGis a build macro,SSL_OP_NO_RX_CERTIFICATE_COMPRESSIONis an option constant. All three sit in a namespace libcrypto really exports and none of them is in any build's symbol table, so their absence is guaranteed rather than observed. C spells these in upper case, so a name with no lower-case letter is refused. The cost is that a genuinely all-caps export likeMD5can no longer be ruled out — a lost conclusion, not a wrong one. - Names the package exports under a different decoration. PCRE2 ships one
library per code-unit width and suffixes every export, so an advisory written
about
pcre2_compileis absent fromlibpcre2-8-0— which exportspcre2_compile_8— in every version ever built. Normalising the width suffix off both sides catches it.
Both were found by running a real miner against Rancher's RKE2 v1.36.0 images and auditing every rule-out it produced. Without this gate, eleven package rows across four of them were cleared on the strength of a struct tag, a build macro, an option constant, a type name or a code-unit suffix. Three CVEs left their reports entirely — the clearing hit every row they appeared on — and two more survived only because a sibling package row happened to stay open.
The Java validation adds two gates of its own. A mined name must be shaped
like a class — a dotted name whose last segment is capitalised — because there
is no doLookup.class and concluding absence from a method name's absence would
be a plain lie. And the artifact's coordinates must have been read rather
than reconstructed (tiers 1–2 above), on the same principle: an absence claim
about an artifact whose identity is a guess is two guesses stacked.
The class is then looked for under every package spelling in the archive,
not only the one the advisory wrote. That is what makes a bare JndiLookup
usable at all — GHSA-jfh8-c2jp-5v3q never writes the package — and it is
simultaneously the shading guard described above.
elf-import-absent, py-import-absent and npm-import-absent — reachable, but
nothing imports the vulnerable symbol or module — stay evidence-only unless you
pass --trust-import-absence. Absence of a direct import does not prove
unreachability, because the vulnerable code is usually called from inside the
same library or package.
Choosing a provider
There is no default. vexscan used to call GitHub
Models, which was free with a token most
users already had; it has been retired. --llm with nothing configured fails
and prints the three ways to configure it, rather than quietly not asking —
missing verdicts look exactly like findings nothing had an opinion about.
An OpenAI-compatible endpoint. Almost everything speaks this format:
export VEXSCAN_LLM_ENDPOINT=https://api.openai.com/v1/chat/completions
export VEXSCAN_LLM_TOKEN=sk-... # or just set OPENAI_API_KEY
vexscan --image myorg/app:latest --all --llm --llm-model gpt-4o
Anthropic serves the same shape at
https://api.anthropic.com/v1/chat/completions (with ANTHROPIC_API_KEY), as
do Azure AI Foundry, OpenRouter, Together, Groq and Fireworks. Set
--llm-model to whatever that provider calls the model; routers want the
vendor/model spelling.
A model on your own machine. Ollama, vLLM and llama.cpp all expose the
same endpoint, and none of them wants a token:
ollama pull llama3.1 # with `ollama serve` running
vexscan --image myorg/app:latest --all --llm \
--llm-endpoint http://localhost:11434/v1/chat/completions --llm-model llama3.1
This is the closest replacement for what GitHub Models provided — free, and
nothing about the image you are triaging leaves the machine. The work suits a
small model better than it looks: the prompts are short, the answer is one small
JSON object, and --mine-advisories is extraction from text that is supplied in
the prompt rather than recall. Expect thinner rationales; expect nothing else to
change.
A CLI you already have logged in. The prompt goes to its standard input and the reply is read from its standard output:
vexscan --image myorg/app:latest --all --llm --llm-command 'claude -p'
Anything that takes a prompt on stdin and prints a reply works, including a
wrapper script around something in-house. This is the weakest transport and the
trade is worth knowing: there is no structured-output mode to ask for, so the
reply is whatever the CLI printed; there are no rate-limit headers, so a
provider that wants you to slow down can only say so by failing; and an
unauthenticated CLI fails once per finding rather than once at startup. Note
also that --llm-model does nothing here — put the model in the command itself.
| Flag | Environment | |
|---|---|---|
| Endpoint | --llm-endpoint | VEXSCAN_LLM_ENDPOINT |
| Model | --llm-model | VEXSCAN_LLM_MODEL (default gpt-4o) |
| Credential | (none, deliberately) | VEXSCAN_LLM_TOKEN, else OPENAI_API_KEY / ANTHROPIC_API_KEY |
| Local CLI | --llm-command | VEXSCAN_LLM_COMMAND |
The credential has no flag on purpose: everything on a command line is readable in the process table by every other user on the machine. The prompt is sent to a command's stdin for the same reason, and because advisory prose is long enough to approach the argument-length limit.
Which provider you pick cannot change a conclusion. A verdict is only ever
attached to a finding that already has a status, and a mined symbol has to be
found in the artifact before it supports one. A weaker model produces vaguer
rationales and finds fewer checkable symbols. It cannot manufacture a
not_present. That is why this is a configuration option and not an
architectural decision.
Rate limits and failures
vexscan caches verdicts per CVE, so the same CVE linked into twenty binaries
costs one call. Requests are not spaced out by default — set
VEXSCAN_LLM_MIN_INTERVAL (a Go duration) for a provider that needs it.
429/5xx and connection failures are retried with backoff, honoring
Retry-After up to two minutes; a failing --llm-command is not retried,
because a CLI's transient failures were already retried inside its own client
and its other failures do not improve on the sixth attempt. A failed assessment
is non-fatal either way: the finding is still reported, just without a verdict.