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 type | Description |
|---|---|
image | Scan Docker / OCI container images |
fs / rootfs | Scan a local filesystem or directory |
repo | Scan a remote Git repository directly |
sbom | Scan an SBOM (Software Bill of Materials) document |
k8s | Scan a Kubernetes cluster directly |
Scanners
| Scanner | Description |
|---|---|
vuln | Check for known CVE vulnerabilities |
misconfig | Check for configuration mistakes in Dockerfiles, Kubernetes YAML, Terraform, and similar files |
secret | Scan for secrets or API keys hardcoded in source code |
license | Check 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:
| Class | Description |
|---|---|
os-pkgs | Operating system packages installed by an OS package manager, such as Alpine's apk or Debian's apt |
lang-pkgs | Language 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 fromnode:22-alpine3.20tonode:22-alpine3.21, or explicitly install updated OS packages in the Dockerfile.lang-pkgs: Raise the package versions in a dependency manifest such aspackage.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:
-
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.jsonwon't patch them. -
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.
