Skip to content

ADR-018: The Source Scan Is Aware of the Pipeline's Dependency Cache

Field Value
Date 2026-07-29
Status Accepted
Deciders Holger Zahnleiter
Supersedes
Superseded by

Context and Problem Statement

The scan job of the reference mono-repo's payment artifact failed with

FATAL  Error  remote Maven repository returned 429 Too Many Requests for
https://repo.maven.apache.org/maven2/org/springframework/data/spring-data-bom/2026.0.0/spring-data-bom-2026.0.0.pom.
Retry-After: 1800.

Every generated pipeline caches its dependencies inside $CI_PROJECT_DIR.m2/repository, .go-cache, .conan, .npm-cache — and the cache: block sits under default:, so the cache is restored into every job of the pipeline, the scan job included. None of the nine CI templates told Trivy that this directory exists. That single omission has two independent consequences, pulling in opposite directions.

The resolver never finds the cache. Trivy's pom.xml analyzer follows the parent and imported-BOM chain to determine the versions of managed dependencies. It looks for those POMs in ~/.m2/repository; the pipeline's Maven jobs resolve into $CI_PROJECT_DIR/.m2/repository via MAVEN_OPTS. Trivy finds nothing locally and fetches each POM from Maven Central instead — for a Spring Boot stack, dozens per scan. Maven Central rate-limits shared CI egress IPs, and the Retry-After: 1800 blocks every subsequent request for half an hour.

Measured on a freshly generated Java service with a populated local repository, scanning with the network cut off entirely:

Resolved packages
Without the pointer 2
With the pointer 41

The 429 was therefore never the whole story. Even when Maven Central answered, the scan was doing work it did not need to do; when it did not answer, the job failed outright.

The walk includes the cache. The reverse problem, on the same directory. Where Trivy's analyzers recognise the cache's contents, they become part of the scan. Go demonstrates it:

Scan targets Packages
Without --skip-dirs .go-cache 35 228
With --skip-dirs .go-cache 1 13

The 34 surplus targets are go.mod files below .go-cache/pkg/mod — module versions the project does not depend on, reported as if it did.

An earlier draft of this decision assumed the same held for Maven and was the second source of the 429, on the theory that Trivy queries Maven Central by SHA-1 for each unidentified jar in the cache. Measurement refuted it: trivy fs over a 214-jar local repository reports Number of language-specific files num=0, with and without network access. The filesystem analyzers match pom.xml, package-lock.json and conan.lock — file names a package cache does not contain. Go is the exception because a module cache stores real go.mod files.

Decision Drivers

  • The observed failure must stop, without trading it for a scan that reports less than it should.
  • The pre-check tier should not depend on a third-party service being reachable and unthrottled.
  • A finding that describes something outside the project's dependency graph is noise, and noise in a gating job is how gating jobs get ignored.
  • Whatever is decided has to hold for four languages and nine templates, not just for Maven.

Considered Options

  1. Point Trivy at the pipeline's Maven repository, and exclude the cache from the walk.
  2. As 1., plus --offline-scan to remove the network dependency entirely.
  3. Drop vuln from the source scan and rely on scan-image for dependency vulnerabilities.
  4. Populate ~/.m2 in the scan job before scanning.

Decision Outcome

Option 1.

Every one of the nine CI templates passes --skip-dirs for its own cache directory. The two Java templates additionally write a settings.xml in before_script:

  before_script:
    - mkdir -p ~/.m2
    - printf '<settings><localRepository>%s/.m2/repository</localRepository></settings>\n' "$CI_PROJECT_DIR" > ~/.m2/settings.xml

--skip-dirs is emitted for all four languages even though only Go needs it today. It controls the walk over the scan target; <localRepository> controls the resolver's lookups; the two do not interfere. In a mono-repo child the scan target is the artifact directory and the flag is inert — it is emitted there anyway, because an artifact is free to keep a cache of its own and a conditional would only add a way to get this wrong later.

--offline-scan is deliberately not used. It would remove the residual network dependency, but on a cold cache Trivy then drops every dependency it cannot resolve, logs one WARN, and exits 0. That is the same failure mode ADR-017 rejected for image scans: a gate that passes because it had nothing to look at. A rate-limit failure is loud and rare; a silent pass is quiet and permanent. The warm cache is the normal case — fallback_keys: [$CI_DEFAULT_BRANCH] gives even a fresh branch the default branch's cache — so the network fallback is an exception path, not the operating mode.

Positive Consequences

  • The reported failure is resolved, and on a warm cache the scan touches no external service.
  • The Java scan resolves 41 packages where it previously resolved 2. This is a security improvement in its own right, independent of the 429: the job had been reporting almost nothing.
  • Go scans stop reporting module versions the project does not depend on.
  • The pre-check tier gets measurably faster: 1 scan target instead of 35 for Go.

Negative Consequences / Risks

  • On a genuinely cold cache — a brand-new project's first pipeline — the Maven scan still reaches out to Maven Central and can still hit a 429. This is accepted as the loud failure mode; see above for why the quiet one is worse. A retry after the Retry-After window resolves it.
  • --skip-dirs is inert in six of the nine templates. It is documented as a guard, not a fix, so that nobody later reads it as load-bearing where it is not — or removes it as dead where it is.
  • The settings.xml is written into the Trivy container's $HOME, which couples the scan job to the assumption that Trivy runs as a user with a writable home. Every image used here does.

Rejected alternatives

  • Option 2 is the trap described above: it converts a visible failure into an invisible one.
  • Option 3 would move dependency scanning to scan-image, which sees the real resolved jars and is in that sense more accurate — but the java/library profile has no image stage at all, so its dependencies would go unscanned entirely. It also gives up fail-fast in the pre-check tier for the profiles that do have one.
  • Option 4 solves it by copying the cache to where Trivy expects it, doubling the disk cost of every Java job to work around a path mismatch that one line of configuration expresses directly.