trivy's flag and VEX layers carry the highest risk — 5 functions to address first

Five critical-band functions in trivy's report flag parsing, VEX filtering, Terraform IaC attribute resolution, Dockerfile build-info analysis, and Docker layer application are all in the fire quadrant — structurally complex and touched within the last 30 days.

Stephen Collins ·
Generated by hotspots · free & open source
pip
$ pip install hotspots-cli
Activity Risk14Low
Hottest FunctionToOptions

Antipatterns Detected

exit_heavy5god_function3complex_branching2long_function2deeply_nested1

Run this on your own codebase

See if your own repo has a ToOptions-style hotspot — run this in any local git repo:

pip
$ pip install hotspots-cli
npm
$ npm install -g @stephencollinstech/hotspots
Run in any repo
$ hotspots analyze .
★ Star on GitHub

Key Points

What is an exit-heavy function and why does it matter in trivy?

An exit-heavy function contains many distinct return points — in Go, that typically means multiple `return nil, err` or `return xerrors.Errorf(...)` paths scattered through the body rather than a single exit at the end. Each return path is an independent execution branch that must be reached by a test to verify the function behaves correctly there. In trivy's top 5, all five hotspot functions carry this pattern, meaning the collective test surface for these functions is significantly larger than their line counts suggest. For `ToOptions` alone, with a cyclomatic complexity of 10, there are at least 10 paths requiring coverage — and that is before accounting for the interactions between flag validation branches.

How do I reduce fan-out in Go?

Fan-out — the count of distinct functions directly called from a single function — is reduced primarily through the extract-method and introduce-parameter-object refactoring techniques. When a function calls many helpers to perform a multi-step process (like `ToOptions` with fan-out 29 coordinating validation, parsing, and struct assembly), grouping related calls behind a named intermediate function both reduces the visible fan-out and gives each concern a testable boundary. A fan-out above 15 is a strong signal to start extracting; above 25, as seen in both `ToOptions` and `ApplyLayers`, the function is almost certainly doing too many things. A concrete first step for `ApplyLayers` (fan-out 26) is to extract the per-category layer application into separate methods on a `layerMerger` type, each of which can be tested with a single-layer fixture rather than a full multi-layer stack.

Is trivy actively maintained?

Yes, the quadrant data is unambiguous on this: 1,846 functions sit in the fire quadrant, meaning they combine structural complexity with recent commit activity. Every one of the top 5 hotspot functions was touched within the last 30 days — `New` in `pkg/vex/vex.go` was touched twice in the last 30 days by 2 distinct authors and was last changed just 7 days ago; `ToOptions`, `GetRawValue`, `Analyze`, and `ApplyLayers` were each touched once, 29 days ago. The absence of any debt-quadrant functions across 5,091 functions analyzed is a strong signal that the codebase is under continuous, broad development rather than selective maintenance. High structural complexity and active development are not contradictory — they are the expected state of a fast-moving security tool — but they do mean refactoring pressure is accumulating in real time.

How do I reproduce this analysis?

Check out the trivy repository at commit `ce8cb97` with `git checkout ce8cb97`, then run `hotspots analyze . --mode snapshot --explain-patterns --force` from the repository root using the Hotspots CLI (available at github.com/hotspots-dev/hotspots). The same command works on any local git repository without additional configuration — no hotspots.json setup is required for a first run.

What does activity-weighted risk mean?

Activity-weighted risk multiplies a function's structural complexity — derived from cyclomatic complexity, nesting depth, and fan-out — by its recent commit frequency, so functions that are both hard to understand and actively changing score the highest. A function with very high complexity that hasn't been touched in two years scores much lower than one with moderate complexity touched repeatedly this month, because the dormant function's structural risk isn't currently being exercised by new changes. This prioritization focuses refactoring effort where it reduces the probability of introducing a bug right now, not simply where the code looks complicated in the abstract. In trivy's top 5, `ToOptions` scores a risk of 14.0 because it combines a fan-out of 29 and cyclomatic complexity of 10 with a recent commit 29 days ago — structural breadth in active motion.

Across 5,091 analyzed functions in aquasecurity/trivy, 478 land in the critical band — and every one of the top 5 hotspots sits in the fire quadrant, meaning they are both structurally complex and actively changing right now. The highest-ranked, ToOptions in pkg/flag/report_flags.go, carries a risk score of 14.0 and was touched 29 days ago; that is not a cleanup item for next quarter, it is a live regression surface on the current development branch. I’d treat all five as review-now candidates rather than backlog entries, because the structural complexity is compounding while the code is in motion.

The table below ranks functions by activity-weighted risk — a score that multiplies structural complexity by recent commit frequency. A function that is both hard to understand (high cyclomatic complexity) and actively changing is a higher priority than one that is complex but untouched. CC = cyclomatic complexity (independent execution paths); ND = max nesting depth; FO = fan-out (distinct callees).

Top 5 Hotspots

FunctionFileRiskCCNDFO
ToOptionspkg/flag/report_flags.go14.010429
Newpkg/vex/vex.go13.98413
GetRawValuepkg/iac/terraform/attribute.go13.711418
Analyzepkg/fanal/analyzer/buildinfo/dockerfile.go13.67512
ApplyLayerspkg/fanal/applier/docker.go13.513326

Large Repo Analysis

trivy is a large repository. To stay within memory constraints, this analysis used hybrid touch mode: structural complexity — CC, ND, FO — is measured precisely for every function. Git activity is tracked at the function level (via git log -L) only for files with 10 or more commits in the last 30 days; other files use a file-level approximation. Rankings therefore surface functions that are both structurally complex and in the most actively-changing parts of the codebase. Dormant code with high structural complexity will rank lower than it would under a full per-function analysis — to surface it, run hotspots analyze . --per-function-touches on a machine with sufficient memory.

Trivy is a widely-used open-source vulnerability and misconfiguration scanner; its codebase spans container image analysis, IaC scanning, SBOM generation, and VEX filtering. At commit ce8cb97, 1,846 functions fall into the fire quadrant — high structural complexity combined with recent commit activity — and none fall into debt or ok, which tells me the codebase is under continuous, broad development pressure.

Quadrant distribution across 5,091 analyzed functions
Fire1846Watch3245

5,091 functions analyzed

Detected Antipatterns
Exit Heavy×5Exit Heavy
Multiple return or throw paths dispersed through the body — each exit needs separate test coverage.
God Function×3God Function
Calls an unusually large number of distinct functions (high fan-out), making it the structural centre of gravity for a subsystem.
Complex Branching×2Complex Branching
High cyclomatic complexity — many independent execution paths, each a potential bug surface and required test case.
Long Function×2Long Function
Function body is too long to review in a single pass; likely contains multiple distinct responsibilities.
Deeply Nested×1Deeply Nested
Control structures nested 4+ levels deep, making it hard to reason about the full execution state at inner branches.

The antipattern picture across the top 5 is dominated by exit_heavy (all five functions) and god_function (three of five). Exit-heavy functions carry multiple return paths, each of which is an independent test case that needs to be covered. God functions accumulate broad coupling — a change in one place ripples through many callees. Those two patterns in combination, across actively-changing code, is the most direct path to an untested regression.


ToOptions — report_flags.go

ToOptions
pkg/flag/report_flags.go
14
critical
CC 10
ND 4
FO 29
touches/30d 1

ToOptions on ReportFlagGroup is responsible for translating raw CLI and config-file flag values into a validated ReportOptions struct. The source excerpt shows exactly what that means in practice: a layered sequence of cross-flag validation checks — template vs. format consistency, --list-all-pkgs format gating, --dependency-tree format gating, --table-mode config presence checks, compliance spec loading, shell-word parsing of output plugin arguments, and ignore-file existence verification — before assembling a large struct literal at the end.

The fan-out of 29 is the number I keep coming back to here. That means this single function directly calls 29 distinct functions, touching viper, shellwords, fsutils, xerrors, log, and the flag value accessors throughout. A change to any one of those dependencies, or to the validation ordering, has potential side effects that are difficult to trace without running the full option-parsing path. The god_function and long_function patterns are both tagged here for good reason — the function is doing validation, parsing, format coercion, file I/O checks, and struct construction all in one body.

With a cyclomatic complexity of 10 and nesting depth of 4, there are at least 10 independent paths through this function, each representing a distinct combination of flag states that needs test coverage. The exit_heavy tag confirms multiple early returns on error, which is idiomatic Go but means each validation branch requires its own test case to be exercised.

The external signals show no historical bug-linked commits or reverts on this file, which is a mild positive — the complexity hasn’t visibly bitten the project yet. But the function was touched 29 days ago, it is in the fire quadrant, and a fan-out of 29 means the blast radius of any subtle mistake here extends far.

My recommendation: extract each validation concern into its own named function — validateTemplateFormat, validateDependencyTreeFormat, validateTableMode, and so on — then have ToOptions orchestrate them in sequence. That reduces the fan-out visible at the call site, brings each path under isolated test coverage, and makes the validation ordering explicit and auditable.


New — vex.go

New
pkg/vex/vex.go
13.91
critical
CC 8
ND 4
FO 13
touches/30d 2

New in pkg/vex/vex.go is the constructor that builds a VEX client from a list of sources. The source excerpt makes the structure clear: it iterates over opts.Sources, and for each source type — TypeFile, TypeRepository, TypeOCI, TypeSBOMReference, and a default case — it dispatches to a different constructor (NewDocument, NewRepositorySet, NewOCI, NewSBOMReferenceSet). Each branch has its own error handling logic, and several branches use sentinel errors or nil-value checks to decide whether to continue or return.

This function has been touched twice in the last 30 days by two different authors, and was last changed 7 days ago, making it the most recently and most collaboratively active function in the top 5. That two-author activity in 30 days on a function with a nesting depth of 4 and the exit_heavy pattern is a concrete coordination risk — each author needs to reason about all four source-type branches simultaneously to avoid introducing an inconsistency in error handling between branches.

The cyclomatic complexity of 8 is moderate on its own, but the branching structure is load-bearing: the VEX filtering outcome for any given scan depends entirely on which branches execute and whether they continue, return nil, or return an error. A mistake in one branch’s nil check (the excerpt already shows lo.IsNil(v) used for OCI but v == nil used for SBOM reference) is the kind of subtle inconsistency that is easy to miss in review.

The Matches function in pkg/vex/openvex.go appears in the context data with 2 touches in the same 7-day window, suggesting the VEX subsystem broadly is under active iteration right now.

My recommendation: make the per-source-type construction logic explicit and symmetric by extracting a loadVEXSource(ctx, src, opts) (VEX, error, bool) helper that returns a consistent three-value tuple — value, error, and a skip boolean — so the loop body becomes a uniform dispatch with no hidden continue logic embedded inside each case.


GetRawValue — attribute.go

GetRawValue
pkg/iac/terraform/attribute.go
13.74
critical
CC 11
ND 4
FO 18
touches/30d 1

GetRawValue on Attribute resolves a cty-typed Terraform attribute value into a plain Go any. The source excerpt shows a large nested switch structure: the outer switch dispatches on primitive cty types (cty.String, cty.Bool, cty.Number), and the default arm switches again on collection types (tuple, list, set), then switches a third time on the element type within those collections to return []string, []float64, or []bool.

That three-level switch nesting is exactly what the cyclomatic complexity of 11 and nesting depth of 4 are measuring. There are 11 distinct paths through this function — scalar string, scalar bool, scalar number, empty collection, string collection, number collection, bool collection, unrecognised collection element type, unrecognised top-level type, plus the safeOp nil-guard — and a return of nil for anything that doesn’t match. The complex_branching and exit_heavy patterns are both present.

The fan-out of 18 reflects the breadth of cty API calls needed to interrogate values at each level. Because GetRawValue is the raw-value accessor for Terraform attribute objects, it is likely called from many places across the IaC scanning subsystem, making it a high blast-radius function. It was touched once in the last 30 days, 29 days ago, which keeps it in the fire quadrant.

The external signals show no bug-linked commits or reverts, and a single author in 90 days — low ownership spread, which is relevant for a function this structurally dense.

My recommendation: extract the collection-type resolution into a dedicated rawCollectionValue(values []cty.Value) any function. That collapses the inner two switch levels into a single tested unit, reduces the visible fan-out in GetRawValue itself, and makes it straightforward to add new collection element types without touching the outer dispatch logic.


Analyze — dockerfile.go

Analyze
pkg/fanal/analyzer/buildinfo/dockerfile.go
13.62
critical
CC 7
ND 5
FO 12
touches/30d 1

Analyze on dockerfileAnalyzer is a Red Hat build-info extractor — based on the file path (analyzer/buildinfo/dockerfile.go) and the source comment citing the Moby BuildKit conversion logic, it parses a Dockerfile found under root/buildinfo/, resolves ENV and LABEL instructions through a shell lexer with variable substitution, and extracts the com.redhat.component and architecture label values to construct a build NVR.

At CC 7 and nesting depth 5, it is the most deeply nested function in the top 5. The nesting comes from a layered structure of loops and switches: iterating stages, iterating commands per stage, switching on command type, iterating labels per LabelCommand, and switching again on the resolved label key. That five-level nesting makes the error-return logic — there are multiple xerrors.Errorf paths embedded at different depths — genuinely hard to trace. The deeply_nested and god_function patterns are both present.

The fan-out of 12 includes the parser, instruction parser, shell lexer, string utilities, and the xerrors package, meaning this function coordinates the full Dockerfile-to-build-info pipeline rather than delegating stages of it. A single touch 29 days ago keeps it in the fire quadrant.

My recommendation: extract the label-extraction walk into a separate extractBuildLabels(stages, shlex, envs) (component, arch string, err error) function. That separates the parsing orchestration from the label-resolution logic, reduces the nesting depth in each piece to something more tractable, and gives the label extraction its own test surface without requiring a full AnalysisInput fixture.


ApplyLayers — docker.go

ApplyLayers
pkg/fanal/applier/docker.go
13.54
critical
CC 13
ND 3
FO 26
touches/30d 1

ApplyLayers is the Docker image layer merging function in trivy’s artifact analysis pipeline. The source excerpt shows it iterating over a slice of BlobInfo layers, applying opaque directory deletions and whiteout file removals to a nested map, then applying seven distinct artifact categories — OS packages, language-specific packages, misconfigurations, secrets, license files, custom resources, and the OS merge — each via its own keyed nestedMap.SetByString call. After the loop, it walks the nested map in a type switch that dispatches on PackageInfo, Application, Misconfiguration, LicenseFile, and presumably others truncated in the excerpt.

The fan-out of 26 is the second-highest in this cohort, reflecting the breadth of types and helper functions this function coordinates. The cyclomatic complexity of 13 comes from the type switch in the walk closure combined with the per-layer conditional logic. The god_function, exit_heavy, and long_function patterns are all present — this function is doing layer ordering, whiteout processing, type dispatch, and result assembly in a single body.

Nesting depth of 3 is the lowest in the top 5, which is somewhat reassuring — the logic is broad rather than deeply nested. But breadth at fan-out 26 carries its own risk: any change to how a new artifact type should be handled requires editing this function, adding a new key format, and adding a new type-switch case — three coordinated edits in one place.

External signals are clean — no reverts, no bug-linked commits — so the structural risk here is primarily a maintainability concern rather than a demonstrated defect history. With a single touch 29 days ago keeping it in the fire quadrant, and a fan-out this wide, it is the function most likely to require unplanned edits when a new artifact type is introduced.

My recommendation: consider introducing a layerMerger type with per-category apply methods, and replace the monolithic loop body with a registry of merger instances. That makes each artifact category independently testable, reduces the fan-out visible in any single function, and eliminates the need to touch ApplyLayers every time a new artifact type is added.

Patterns Found

Antipatterns detected across the top functions in this snapshot:

PatternOccurrences
exit_heavy5
god_function3
complex_branching2
long_function2
deeply_nested1

These labels belong to two tiers — Tier 1 (structural): complex_branching, deeply_nested, exit_heavy, long_function, god_function. Tier 2 (relational/temporal): hub_function, cyclic_hub, middle_man, neighbor_risk, stale_complex, churn_magnet, shotgun_target, volatile_god.

See more analyses with these patterns: complex_branching, deeply_nested, exit_heavy, god_function, long_function.

Reproduce This Analysis

git clone https://github.com/aquasecurity/trivy
cd trivy
git checkout ce8cb97205c5f4022d8721387bd30912288cb218
hotspots analyze . --mode snapshot --explain-patterns --force --hybrid-touches 10

To run the same analysis on your own codebase, run hotspots analyze . --mode snapshot in any local git repo — no configuration required.

I use Hotspots to highlight structural and activity risk — not “bad code.” I treat these findings as a prioritization aid, not a bug predictor. Editorial policy →

Was this useful? Let me know →

Related Analyses