Skip to content
← Blog

Technical explainer

How CI caching affects supply chain security

2 min read

CI caching is essential for build performance. A cold npm ci on a project with 200 dependencies takes 60–120 seconds. With a properly configured cache, the same install takes 5–10 seconds. For teams running CI hundreds of times per day, caching is not optional.

But caching interacts with supply chain security in non-obvious ways. The same cache that speeds up your build can also preserve malicious packages longer than they would otherwise persist.

The three things you can cache for npm

The npm download cache (~/.npm): npm caches downloaded tarballs locally before installing them. Caching ~/.npm means tarballs don't need to be re-downloaded from the registry on subsequent builds. The package manager still re-extracts and re-installs from the cached tarballs.

The installed packages (node_modules): Caching the extracted node_modules directory skips both the download and the extraction step. This is the fastest approach and also the most risky.

The Veln result cache (~/.veln/ci-cache.json): Caching Veln's result cache means that packages Veln has already verified don't need to be re-analyzed on subsequent builds.

Why caching node_modules is dangerous

When you cache node_modules and restore it on a subsequent build, you're running code from packages that were installed in a previous build — potentially days or weeks ago. You're also bypassing the integrity verification that npm ci would perform if it re-installed from scratch.

Scenario: your CI cache includes node_modules with some-package@1.2.3. That version is later identified as malicious. You update your package-lock.json to use some-package@1.2.4. But your cache still has the old node_modules with 1.2.3. If the cache restore key doesn't change (because only the lockfile changed, not the broader cache key), your build restores the malicious node_modules.

# Dangerous — caches node_modules
- uses: actions/cache@v4
  with:
    path: node_modules  # don't cache this
    key: ${{ runner.os }}-node-${{ hashFiles('package-lock.json') }}

Even with hashFiles('package-lock.json') in the key, there are edge cases where the lockfile hash doesn't change but the installed packages should:

  • Security patches at the same version number
  • Registry-side tarball updates
  • Any case where the lockfile is identical but the intended packages are different

Safe caching: the npm download cache

Caching ~/.npm (the download cache, not the installed packages) is significantly safer:

- uses: actions/cache@v4
  with:
    path: ~/.npm
    key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
    restore-keys: |
      ${{ runner.os }}-npm-

# npm ci re-installs from the cached tarballs, re-running integrity checks
- run: npm ci

With this configuration, npm ci re-installs all packages from cached tarballs, re-running the integrity hash checks against the lockfile. If a tarball in ~/.npm has been tampered with (unlikely but possible), the hash check would fail.

Safe caching: the Veln result cache

Veln's result cache is specifically designed to be safe to cache:

- uses: actions/cache@v4
  with:
    path: ~/.veln/ci-cache.json
    key: veln-${{ runner.os }}-${{ hashFiles('**/package-lock.json', '**/requirements.txt') }}
    restore-keys: veln-${{ runner.os }}-

The Veln cache records (ecosystem, name, version, sha256) → verdict. A cached entry is only valid if the sha256 matches the downloaded package. If a tarball changes — even a malicious tarball replacing a previously-verified one — the sha256 mismatch invalidates the cache entry and forces a full re-analysis.

This means: even if an attacker compromised the npm CDN and started serving a different tarball for a locked version, the Veln cache would not accept the cached ALLOW verdict for the new hash. It would re-run the full pipeline.

Safe caching: Python

For Python, the same principles apply:

# Safe: cache the pip download cache (not the venv)
- uses: actions/cache@v4
  with:
    path: ~/.cache/pip
    key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }}

# Safe: cache the uv download cache
- uses: actions/cache@v4
  with:
    path: ~/.cache/uv
    key: ${{ runner.os }}-uv-${{ hashFiles('uv.lock') }}

# Risky: caching the venv directory directly
# (same problems as node_modules)

Cache the download cache, not the installed packages. Cache Veln's result cache for fast re-verification.