Skip to main content
All posts

Finding Security Vulnerabilities Earlier in Development with Trivy

Bosh9 min read

Introduction​

At work, we regularly scan our projects for vulnerabilities. My company uses the scanning built into AWS ECR to check every project's Docker images. That sounds like a pretty complete setup, but there's a catch: only DevOps and managers have access to the AWS console. As a developer, I can't even open the scan reports myself.

The process usually looks like this:

DevOps runs a scan → prepares a report → notifies the developers → we finally find out which packages need fixing

Waiting for someone to tell you what needs fixing is especially stressful when a deadline is approaching. A few times, we were almost ready to deliver when a report suddenly arrived with several high-risk vulnerabilities. Then came the last-minute package upgrades, another round of testing, another image build…

Worse, after upgrading the packages, I couldn't immediately check whether I'd actually fixed everything. The scanning tool wasn't in my hands, so I had to wait for DevOps to run it again.

A colleague later recommended Trivy, an open-source tool that scans Docker images locally. It solved the problem of having no control over when I could scan. And the best part? Trivy runs through docker run, so there's no extra software to install on your machine. Getting started is straightforward.


What Is Trivy?​

Trivy is an open-source, multipurpose security scanner for cloud-native environments. Its name comes from “trivial,” reflecting its goal of making security scanning extremely simple.

Compared with other scanners, Trivy supports a broad range of targets and scan types:

Targets

Target typeDescription
imageScan Docker / OCI container images
fs / rootfsScan a local filesystem or directory
repoScan a remote Git repository directly
sbomScan an SBOM (Software Bill of Materials) document
k8sScan a Kubernetes cluster directly

Scanners

ScannerDescription
vulnCheck for known CVE vulnerabilities
misconfigCheck for configuration mistakes in Dockerfiles, Kubernetes YAML, Terraform, and similar files
secretScan for secrets or API keys hardcoded in source code
licenseCheck package compliance with open-source license terms

Besides running it manually on your machine, you can also integrate Trivy into a CI/CD pipeline. The official documentation includes integrations for GitHub Actions, GitLab CI, Jenkins, and other common platforms. For example, with GitHub Actions, the official aquasecurity/trivy-action can scan an image when a PR is opened and send the results to GitHub's Security tab, automating the check.


How I Use It​

My usual workflow is to build the project's Docker image first, then scan that image with Trivy. Compared with scanning the filesystem, scanning the image reflects what will actually be deployed to production. It avoids the gap between “it works on my machine” and a container environment that still has vulnerabilities.

One of Trivy's most convenient features is that you can run it through Docker without installing it locally:

docker run --rm \
-v /var/run/docker.sock:/var/run/docker.sock \
-v $(pwd)/.trivycache:/root/.cache/trivy \
-v $(pwd):/output \
aquasec/trivy:latest image \
--scanners vuln \
--format json \
--output /output/raw-report.json \
your-image:latest

Here's what the volume mounts do:

  • -v /var/run/docker.sock:/var/run/docker.sock: Lets the Trivy container access the local Docker daemon and read the image you just built.
  • -v $(pwd)/.trivycache:/root/.cache/trivy: Caches the vulnerability database in a local directory so it doesn't need to be downloaded on every run.
  • -v $(pwd):/output: Mounts the current directory so the scan report can be saved to your machine.

Once the command finishes, Trivy produces an unfiltered JSON report: raw-report.json.

A Look Inside raw-report.json​

The report is very detailed, often running to tens of thousands of lines. It includes complete package dependency trees, image configuration, and more. But when all you want to know is “what's vulnerable, and which version should I upgrade to?”, most of that information isn't immediately useful:

Expand to see an example raw-report.json structure
{
"SchemaVersion": 2,
"ArtifactName": "your-image:latest",
"Metadata": {
"OS": { "Family": "alpine", "Name": "3.21.5" },
"ImageConfig": { /* ... */ }
},
"Results": [
{
"Target": "your-image:latest (alpine 3.21.5)",
"Class": "os-pkgs",
"Type": "alpine",
"Vulnerabilities": [
{
"VulnerabilityID": "CVE-2025-15467",
"PkgName": "libcrypto3",
"Severity": "CRITICAL",
"FixedVersion": "3.3.6-r0",
"Title": "...",
"Description": "..."
}
]
},
{
"Target": "Node.js",
"Class": "lang-pkgs",
"Type": "npm",
"Vulnerabilities": [
{
"VulnerabilityID": "CVE-2026-25639",
"PkgName": "axios",
"PkgPath": "usr/src/app/node_modules/axios/package.json",
"Severity": "HIGH",
"FixedVersion": "1.13.5, 0.30.3"
}
]
}
]
}

In this structure, vulnerabilities are grouped in the Vulnerabilities fields within the Results array. The Class field tells us whether they belong to OS packages or language packages.

How Do I Find Where a Vulnerability Comes From?​

First: use Class to distinguish OS packages from language packages

Trivy groups vulnerabilities into categories using the Class field:

ClassDescription
os-pkgsOperating system packages installed by an OS package manager, such as Alpine's apk or Debian's apt
lang-pkgsLanguage ecosystem packages installed through npm, pip, gem, Maven, and similar tools

Each category calls for a different approach:

  • os-pkgs: Usually, upgrade the base image, such as moving from node:22-alpine3.20 to node:22-alpine3.21, or explicitly install updated OS packages in the Dockerfile.
  • lang-pkgs: Raise the package versions in a dependency manifest such as package.json, then reinstall to apply the updates.

Next: use PkgPath to distinguish where npm packages belong

Even when the packages under lang-pkgs are all npm packages, they weren't necessarily installed by your project. In a Node.js image, you'll commonly see two kinds of paths:

  1. usr/local/lib/node_modules/npm/node_modules/...

    These are dependencies of the npm tool itself, brought in by the official Node.js image. Fixing their vulnerabilities usually means waiting for an updated Node image. Changing your project's package.json won't patch them.

  2. usr/src/app/node_modules/...

    These are packages installed for our project through npm install, and they're the ones we primarily need to upgrade ourselves.

So whenever I see a HIGH or CRITICAL vulnerability, I don't immediately reach for package.json. I check PkgPath first to see where it comes from.

Cleaning Up the Report with jq​

Now that we know which fields matter, I usually run a jq script to reduce that enormous raw-report.json to the Class categories and the key properties we need:

Expand to see the full script and jq filter
#!/bin/bash

TARGET_IMAGE="your-image:latest"

echo "🚀 Scanning $TARGET_IMAGE..."

docker run --rm \
-v /var/run/docker.sock:/var/run/docker.sock \
-v $(pwd)/.trivycache:/root/.cache/trivy \
-v $(pwd):/output \
aquasec/trivy:latest image \
--scanners vuln \
--format json \
--output /output/raw-report.json \
$TARGET_IMAGE

echo "✅ Scan complete. Cleaning up the report..."

jq 'reduce (.Results[]? | select(.Vulnerabilities)) as $res ({};
.[$res.Class] += ($res.Vulnerabilities | map({
VulnerabilityID: .VulnerabilityID,
PkgID: .PkgID,
PkgName: .PkgName,
PkgPath: .PkgPath,
Title: .Title,
Description: .Description,
Severity: .Severity,
FixedVersion: .FixedVersion
}))
)' raw-report.json > clean-report.json

echo "🎉 Report ready! See clean-report.json"

The filter first finds nodes in Results that contain Vulnerabilities, groups them by the Class we just discussed, and keeps only the key properties, such as VulnerabilityID, PkgPath, and FixedVersion, that help us locate and fix vulnerabilities quickly.


More Commands and Useful Options​

We can adjust Trivy's scanning behavior with CLI flags to suit different situations. See the official CLI reference for more options.

--download-db-only: Download the Vulnerability Database in Advance​

On its first run, Trivy downloads a vulnerability database of several hundred MB. If you want to separate that download from the scan itself, this flag downloads the database ahead of time:

docker run --rm \
-v $(pwd)/.trivycache:/root/.cache/trivy \
aquasec/trivy:latest image \
--download-db-only

This is useful when the network is unreliable or when you're optimizing stages in a CI/CD pipeline.

--skip-update: Skip the Database Update​

If you're working offline, or you've confirmed that the cache is current and don't want an update check on every scan, add --skip-update to use the local database cache directly. This can save a lot of time.

docker run --rm \
# ... Volume mounts and other basic options omitted ...
aquasec/trivy:latest image \
--scanners vuln \
--skip-update \
--format json \
--output /output/raw-report.json \
your-image:latest

--ignore-unfixed: Hide Vulnerabilities Without a Fix​

Some vulnerabilities found by a scan don't yet have a released fix. The issues still exist, but from a maintenance perspective, there's little we can do through an upgrade when no fixed version is available.

Adding --ignore-unfixed hides those entries from the report:

docker run --rm \
# ... Volume mounts and other basic options omitted ...
aquasec/trivy:latest image \
--scanners vuln \
--ignore-unfixed \
--format json \
--output /output/raw-report.json \
your-image:latest

This helps me focus on significant risks I can actually fix right now, instead of getting distracted by a long list of warnings I can't act on yet.


Wrapping Up​

Since adding Trivy to my daily workflow, I no longer have to wait for DevOps to notify me. I can find and fix vulnerabilities during development, then verify the fixes myself. That has made a real difference to how efficiently the team works and communicates.

This post focuses on finding CVE vulnerabilities in project packages earlier with Trivy. Once we have a report and know where the vulnerabilities are, the next step is fixing them. Remediating dependency vulnerabilities in a Node.js project has a few subtleties of its own. I'll share my practical approach when I have time.


Reference​