Documentation
¶
Overview ¶
Package analyze orchestrates the vexscan pipeline: prepare a target (extract an image, open a rootfs, or check out a source tree), ask each ecosystem plugin what it finds, resolve advisories for what the plugins inventory, and optionally overlay an LLM assessment on the genuinely-affected results.
The division of labour is deliberate. Plugins own the *deterministic* question — is this vulnerable code present, and can it run — and nothing else. This package owns advisory resolution and the LLM overlay, so no plugin can make the model's opinion load-bearing.
Index ¶
Constants ¶
const ( StatusNotPresent = ecosystem.StatusNotPresent StatusNotInPath = ecosystem.StatusNotInPath StatusLinked = ecosystem.StatusLinked StatusReachable = ecosystem.StatusReachable StatusUndetermined = ecosystem.StatusUndetermined )
const SchemaVersion = 2
SchemaVersion is the version of the JSON Result shape.
1 was gomod-vex: Go only, one module per run. 2 adds the ecosystem-neutral finding identity, the per-ecosystem outcome list, and OS package findings. Every version-1 field is still present and still means what it meant.
Variables ¶
This section is empty.
Functions ¶
func Validate ¶
Validate reports whether the options describe a coherent scan, touching neither the network nor the disk. It lets the caller tell a bad command line from a failed scan, and report the former before the pull rather than after.
It cannot catch everything: a bare --package name is resolved against the inventory, so whether it names anything is only knowable once the target has been read.
Types ¶
type Corrections ¶ added in v0.8.0
type Corrections struct {
Count int `json:"count"`
// Advisories are the ids set aside, sorted, so the list is stable between
// runs and diffable.
Advisories []string `json:"advisories"`
// Details spell out, per advisory, the ranges that excluded the version.
Details []string `json:"details"`
}
Corrections is what the advisory database offered and the scan did not report, because the record's own precise version ranges exclude the version it was matched against. internal/osv/customranges.go has the mechanism and the conditions.
It exists for the reason Withheld does. Setting an advisory aside is the direction this tool must never be wrong in, and a scan that quietly returned 27 fewer findings than the database offered would be indistinguishable from a cleaner image. So the count is carried out to the report and named, and a reader who disagrees with the arithmetic can check it: every dropped id is listed and every one of them is still one OSV lookup away.
type Descriptor ¶ added in v0.7.0
type Descriptor struct {
Tool string `json:"tool,omitempty"` // "vexscan"
Version string `json:"version,omitempty"` // "v0.6.2"
Started time.Time `json:"started,omitempty"` // when the command began
Duration string `json:"duration,omitempty"` // "12.4s"
// AdvisorySource is where the advisories were read from -- today always
// the live OSV API, and the one field an offline or cached database would
// change. It is recorded even when nothing was queried, because "which
// database said nothing" is the question an empty report raises.
AdvisorySource string `json:"advisory_source,omitempty"`
// AdvisoriesAsOf is when that source answered, which for a live API is how
// fresh the report is. Zero when the scan resolved no advisories at all --
// itself worth telling apart from a scan that resolved some and found
// nothing.
AdvisoriesAsOf time.Time `json:"advisories_as_of,omitempty"`
}
Descriptor records what produced a report.
A report outlives the run that made it. Six months on, the two questions a reader has are which build of the tool wrote it and how stale the advisories behind it are, and neither is recoverable from the findings: an empty report from a scan run this morning and an empty report from a build that predates the CVE are the same bytes.
The fields split by who can honestly answer them. AdvisorySource and AdvisoriesAsOf are set here, because only the resolver knows which database answered and when. Tool, Version, Started and Duration are left to the caller: the timing of a command is the command's fact, and reading a wall clock for it inside this package would make every test of it depend on one.
type Finding ¶
The finding vocabulary lives in internal/ecosystem, which is what the plugins produce. These aliases keep the existing analyze.Finding / analyze.Status spelling working for callers and keep the JSON output byte-identical.
type InventoryResult ¶
type InventoryResult struct {
Target string `json:"target"`
Mode string `json:"mode"` // "image" | "rootfs" | "rpm" | "sbom"
OS *OSInfo `json:"os,omitempty"`
Databases []pkgdb.Result `json:"databases"`
// Unreadable is the part of the tree the walks could not enter. An
// inventory that skipped a directory is a list of what is installed with
// an unknown number of omissions, which is not the same document.
Unreadable *target.Unreadable `json:"unreadable,omitempty"`
// Languages are the installed distributions of the language ecosystems
// that ship inside images: Python's site-packages, Node's node_modules.
// They are kept separate from Databases because they overlap: Debian's
// python3-yaml deb installs the same files a PyPI inventory reports under
// "pyyaml", and merging the two would hide that both advisory namespaces
// apply.
Languages []langdb.Result `json:"languages,omitempty"`
// Notes are things the reader of this list has to know that are not
// omissions: packages that were read but have no column here, and the like.
// Unreadable is for what could not be read, and conflating the two would
// make a complete inventory report as incomplete.
Notes []string `json:"notes,omitempty"`
}
InventoryResult is what a tree's package databases say is installed.
This is the raw material the OS ecosystem plugin works from, exposed on its own because it is checkable: a user who suspects a finding is wrong can see exactly which database row it came from, and the ecosystem string that will be used to query OSV before any query is made.
func Inventory ¶
func Inventory(ctx context.Context, opts Options) (*InventoryResult, error)
Inventory reads the OS package databases of an image or a rootfs.
It deliberately does not require a subject: "what is in this tree" is a question worth answering on its own, and it is the one output that can be checked against `dpkg -l` or `rpm -qa` run inside the same tree.
func (*InventoryResult) LanguagePackages ¶
func (r *InventoryResult) LanguagePackages() int
LanguagePackages counts the installed language distributions.
It is kept apart from Packages rather than added to it because the two overlap -- the same files can be one deb and one PyPI distribution -- so a single total would be a number that counts some code twice and means nothing.
func (*InventoryResult) Packages ¶
func (r *InventoryResult) Packages() int
Packages counts the OS packages the inventory found.
type OSInfo ¶
type OSInfo struct {
ID string `json:"id,omitempty"`
VersionID string `json:"version_id,omitempty"`
PrettyName string `json:"pretty_name,omitempty"`
// Ecosystem is the OSV ecosystem string, or empty with EcosystemError set.
Ecosystem string `json:"ecosystem,omitempty"`
EcosystemError string `json:"ecosystem_error,omitempty"`
}
OSInfo is the distribution identity read from /etc/os-release.
type Options ¶
type Options struct {
Image string
// RootFS is a filesystem tree already on disk -- an unpacked image, a
// mounted volume, a machine's own /. It runs the image analyzers against a
// tree nobody extracted, so it skips the pull but also arrives without an
// image config: see runTree.
RootFS string
Repo string // git repo (source mode); mutually exclusive with Image
Ref string // branch/tag/commit for Repo
Path string // module subdirectory within Repo (default ".")
// RPM are package files to scan without installing them (--rpm): a file, a
// directory of them, or a URL. Mutually exclusive with the other three.
//
// This is the one target with no filesystem behind it, and every
// difference in the report follows from that: no reachability closure can
// run, so nothing can be linked and nothing can be ruled out as
// unreachable. What a header can still answer -- would this package
// install any code at all -- it does.
RPM []string
// RPMDeep is --rpm-deep. It decompresses each package's cpio payload and
// extracts its ELF objects, so the OS plugin can read their dynamic symbol
// tables. It does not change what verdicts are reachable in kind -- there
// is still no filesystem and no entrypoint, so nothing is ever linked --
// but it lets the dynsym-absent test rule a finding out as not_present when
// the vulnerable function is exported by nothing the package ships. It only
// bites alongside --mine-advisories, which is what supplies the symbol to
// look for. See the extract half of internal/rpmsrc.
RPMDeep bool
// SBOM is a CycloneDX JSON bill of materials to scan (--sbom): a file, or
// "-" for standard input. Mutually exclusive with the other four targets.
//
// It is --rpm's weaker sibling. --rpm has no filesystem either, but an rpm
// header still lists the files the package would install, so it can rule a
// package out on the grounds that it ships no code. A CycloneDX component
// is a name, a version and a purl, so nothing here can be ruled out and
// every finding it produces is undetermined. See ecosystem.SBOMFinding.
SBOM string
// Packages are the raw --package selectors: purls, ecosystem:name
// shorthand, or bare names resolved against whatever inventory contains
// them. See ecosystem.ParseSubject.
Packages []string
// Module is the deprecated --module flag, equivalent to one
// --package golang:MODULE.
Module string
// All requests everything each plugin can inventory, rather than a named
// list of packages.
All bool
// Ecosystems restricts which plugins run (--ecosystem). Empty runs them
// all. Naming one nothing handles is an error, not an empty result.
Ecosystems []string
// Severities restricts the result to findings carrying these severity
// labels (--severity), already canonicalized through cvss.Parse by the
// caller. Empty keeps everything.
//
// Unlike Ecosystems this changes what is reported rather than what runs:
// every plugin still inventories and every advisory is still resolved,
// because a finding's severity is only knowable once its advisory is in
// hand. What it does buy is that the LLM overlay is never asked about a row
// nobody is going to read.
Severities []string
CVEs []string // optional filter; empty means "every advisory that applies"
Version string // optional override of the detected module version (image mode)
OS string
Arch string
// Roots are extra entrypoints for the reachability closures -- the OS
// plugin's shared libraries and the language plugins' import graphs -- for
// an image whose real command comes from outside its config.
Roots []string
// DlopenPolicy decides whether a reachable dlopen blocks conclusions.
DlopenPolicy elfgraph.DlopenPolicy
// DynamicPolicy decides whether a reachable import of a computed name
// blocks conclusions. It is the import graph's DlopenPolicy.
DynamicPolicy modgraph.DynamicPolicy
// OSVEcosystem overrides the OSV ecosystem derived from the image's
// os-release, for the distributions os-release does not determine. It is
// not the same knob as Ecosystems, which chooses which plugins run.
OSVEcosystem string
// VEXHubs are VEX Hub repositories to check findings against (--vexhub),
// in priority order: the first hub with a statement about a finding wins,
// so an internal hub listed ahead of a vendor's overrides it.
//
// A statement never changes a finding's status. It records that someone has
// already published an answer, which the report uses to decide what a
// reader still has to look at.
VEXHubs []string
// Triage is the EPSS/KEV loader for --triage, or nil to skip it entirely.
// It is the loader rather than a bool so a test can point it at its own
// feeds, and so the caller owns the cache location.
//
// Like VEXHubs it never changes a finding's status. Whether a vulnerability
// is being exploited elsewhere says nothing about whether the code is
// present here, which is the only question this tool answers; what it
// changes is which of the answers a reader looks at first.
Triage *triage.Loader
// GoVersion optionally pins the Go toolchain for repo-mode analysis
// (e.g. "1.24.0"). Mainly useful with --module stdlib, whose findings depend
// on the toolchain version.
GoVersion string
UseLLM bool
// LLMEndpoint, LLMModel and LLMCommand say who to ask. Each falls back to
// VEXSCAN_LLM_ENDPOINT / _MODEL / _COMMAND when empty, and exactly one of
// endpoint and command must end up set: see llm.Config. The credential is
// read from the environment only, never from here, so it cannot reach a
// command line.
LLMEndpoint string
LLMModel string
LLMCommand string
// MineAdvisories lets the model read each advisory's prose for symbol,
// soname and filename leads, which plugins then validate against the image.
// Requires UseLLM.
MineAdvisories bool
// TrustImportAbsence lets the OS plugin conclude not_in_execute_path when
// nothing the closure reaches imports the vulnerable symbol. Off by default:
// the vulnerable function is usually called from inside the same library,
// where no dynamic import records it.
TrustImportAbsence bool
// Logf receives progress messages (may be nil).
Logf func(format string, args ...any)
// contains filtered or unexported fields
}
Options configure a run. Set exactly one of Image, RootFS or Repo.
type Result ¶
type Result struct {
SchemaVersion int `json:"schema_version"`
Target string `json:"target"` // image ref, rootfs directory, or repo
Mode string `json:"mode"` // "image" | "rootfs" | "repo"
Module string `json:"module"`
Findings []Finding `json:"findings"`
// Ecosystems records how each plugin fared. It exists so a failure is
// never indistinguishable from a clean result: a plugin that found a
// package database and could not read it reports the error here and
// contributes no findings at all.
Ecosystems []ecosystem.EcosystemResult `json:"ecosystems,omitempty"`
// Unreadable is the part of the target tree the scan could not enter,
// accumulated across every plugin that walked it. It is nil in repo mode,
// which analyzes a checkout the current user just created.
//
// It is set for the same reason it is recorded at all: a directory nothing
// looked inside contributes no findings, which is exactly what a directory
// full of nothing wrong contributes. Only one of those is good news.
Unreadable *target.Unreadable `json:"unreadable,omitempty"`
// VEXHubs records what each --vexhub contributed, including one that could
// not be read. It is not part of Failed(): see vexOverlay for why a hub
// failure is not the same kind of incompleteness as an ecosystem failure.
VEXHubs []ecosystem.VEXHubResult `json:"vex_hubs,omitempty"`
// Withheld is what --severity removed from Findings, and is nil when the
// flag was not used or hid nothing. See severityFilter: a filtered result
// and a clean one are indistinguishable without it.
Withheld *Withheld `json:"withheld,omitempty"`
// Triage records what --triage contributed, and is nil when the flag was
// not used. Like VEXHubs it is not part of Failed(): see triageOverlay.
Triage *TriageResult `json:"triage,omitempty"`
// Corrections are advisories the database matched but its own precise
// ranges exclude, and is nil when there were none. See its doc comment and
// internal/osv/customranges.go.
Corrections *Corrections `json:"corrections,omitempty"`
// Descriptor records what produced this report. See its doc comment.
Descriptor *Descriptor `json:"descriptor,omitempty"`
}
Result is the full analysis output.
type Status ¶
The finding vocabulary lives in internal/ecosystem, which is what the plugins produce. These aliases keep the existing analyze.Finding / analyze.Status spelling working for callers and keep the JSON output byte-identical.
type TriageResult ¶ added in v0.5.0
type TriageResult struct {
// EPSSDate and KEVDate are the feeds' own dates, read out of the payloads.
// A cached percentile is a claim about a day, and a report read next month
// must not be able to pretend otherwise.
EPSSDate string `json:"epss_date,omitempty"`
KEVDate string `json:"kev_date,omitempty"`
// Stale means the network could not be reached and a previously downloaded
// copy was used.
EPSSStale bool `json:"epss_stale,omitempty"`
KEVStale bool `json:"kev_stale,omitempty"`
EPSSError string `json:"epss_error,omitempty"`
KEVError string `json:"kev_error,omitempty"`
// Scored is how many findings got a percentile. NoCVE is those whose
// advisory carries no CVE id at all, and NotInFeed those that had one the
// feed did not know -- almost always a CVE published in the last day or
// two. They are counted apart because the report has to explain the two
// differently, and because neither of them means "low risk".
Scored int `json:"scored"`
NoCVE int `json:"no_cve,omitempty"`
NotInFeed int `json:"not_in_feed,omitempty"`
// KnownExploited counts every finding in the catalog, whatever this scan
// decided about it -- including the ones it ruled out and the ones a vendor
// already answered. All four counters here have that population, because
// they describe the lookup and not the verdict.
//
// The text report's priority line counts the affected rows instead, since
// that is the part a reader has to act on, and says so in words when the
// two differ. They are allowed to differ. They are not allowed to differ
// silently: see writePriority.
KnownExploited int `json:"known_exploited"`
CatalogSize int `json:"catalog_size,omitempty"`
}
TriageResult records what --triage contributed, including what it could not.
It sits beside VEXHubs in Result and, like VEXHubs, is deliberately not part of Failed(). An unreachable EPSS mirror does not make the report claim a clean image it never examined -- it leaves the findings in the order they were already in. That is a different kind of wrong from an ecosystem that could not be inventoried, and only one of them may pass silently.
func (*TriageResult) Unscored ¶ added in v0.5.0
func (t *TriageResult) Unscored() int
Unscored is how many findings have no percentile, for whichever reason.
func (*TriageResult) Usable ¶ added in v0.5.0
func (t *TriageResult) Usable() bool
Usable reports whether either feed produced anything to sort by. When it is false the report keeps its severity ordering and says why.
type Withheld ¶ added in v0.4.0
type Withheld struct {
// Severities is what --severity asked to keep, so the banner can quote the
// flag back rather than making the reader remember what they typed.
Severities []string `json:"severities"`
Count int `json:"count"`
// BySeverity is what was dropped, keyed by label. UNKNOWN in here is the
// entry that matters: those findings are unrated, not unimportant.
BySeverity map[string]int `json:"by_severity"`
}
Withheld records what --severity removed from the result.
It exists because a filtered report and a clean one look identical, and that is the one confusion this tool must never cause. Every renderer prints this before the findings, so a short list is always accompanied by the reason it is short.
It is deliberately not part of Failed(). The scan completed and read everything it meant to; the reader asked for a subset of what it found. That is the opposite of an ecosystem that could not be inventoried, where the tool does not know what it missed.