Guide · Security Scanning

MCP Tools for Trivy — container image scanning, multi-class results, and VEX suppression

Trivy returns a Results[] array — not a flat vulnerability list — because a container image typically contains multiple scannable components: OS packages from the base image, language packages from npm or pip lock files, embedded IaC config files, and hardcoded secrets. When you build an MCP tool that wraps Trivy — scanning a Docker image before deployment, checking local project dependencies, or auditing Kubernetes manifests — three distinctions determine correctness: the Class field (os-pkgs vs lang-pkgs vs config vs secret — each requires different remediation and different filtering logic), Severity vs SeveritySource (NVD and the OS vendor frequently disagree on severity for the same CVE — Trivy uses the vendor's score for OS packages when available, which can be lower than NVD), and Vulnerabilities is null, not an empty array, when a target class has no findings — your parser must guard against null explicitly or it will throw a TypeError on perfectly clean images.

TL;DR

Write Trivy's JSON output to a temp file rather than reading stdout — large image scans can exceed shell buffer limits. Always access result.Vulnerabilities ?? [] before iterating, never result.Vulnerabilities.forEach() — the field is null when a target has no findings. Use result.SeveritySource to understand which database provided the severity rating; do not compare Trivy's severity string against NVD CVSS thresholds directly since they come from different scoring contexts. Use VEX documents to suppress specific CVEs your application is not actually affected by, rather than globally ignoring them with --ignore-unfixed.

Running Trivy as a subprocess from Node.js

Trivy is invoked as a subprocess — it is a compiled Go binary installed on the host, not a library. The MCP tool spawns it with child_process.spawnSync or spawn, passes the target and flags, and parses the output. The most important flag combination for MCP tools is --format json --output /tmp/trivy-report.json: writing to a file rather than stdout avoids the Node.js child process pipe buffer limit, which is 1MB by default and is easily exceeded by scans of large images with hundreds of packages.

The --quiet flag suppresses progress bars and download notifications that Trivy writes to stderr by default. Without it, stderr is full of noise that can confuse error detection. The --exit-code 1 flag causes Trivy to exit with code 1 when vulnerabilities are found — useful for CI fail gates but requires your MCP tool to distinguish "exit 1 because findings" from "exit 1 because error". When running in MCP context where you always want results regardless of findings, omit --exit-code 1 or check the report file regardless of exit code.

Pre-caching the vulnerability database is critical in CI environments where Trivy might run repeatedly. The database is approximately 200MB and has a 24-hour TTL. Trivy warns but continues if the DB is stale; it does not fail. Use --download-db-only in a setup step, then --skip-db-update in scan steps to avoid redundant downloads across parallel jobs.

import { spawnSync } from 'node:child_process';
import { readFileSync, unlinkSync, existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { randomUUID } from 'node:crypto';

function runTrivy(target, options = {}) {
  const outputFile = join(tmpdir(), `trivy-${randomUUID()}.json`);

  const args = [
    'image',                   // subcommand: image | fs | config | sbom | repo
    '--format', 'json',
    '--output', outputFile,
    '--quiet',                 // suppress progress bars on stderr
  ];

  if (options.severity) {
    // e.g. 'HIGH,CRITICAL' — comma-separated, no spaces
    args.push('--severity', options.severity);
  }
  if (options.ignoreUnfixed) {
    // only report vulns that have a fix available
    args.push('--ignore-unfixed');
  }
  if (options.vexPath) {
    // suppress vulns described in this VEX document
    args.push('--vex', options.vexPath);
  }
  if (options.skipDbUpdate) {
    args.push('--skip-db-update');
  }

  // target is the image name, e.g. 'alpine:3.18' or 'ghcr.io/org/app:sha-abc123'
  args.push(target);

  const result = spawnSync('trivy', args, {
    encoding: 'utf8',
    timeout: 300_000, // 5 minutes — large images can be slow
  });

  // Exit code 2 = trivy encountered an error (not just "found vulns")
  if (result.status === 2 || result.error) {
    const stderr = result.stderr ?? result.error?.message ?? 'unknown error';
    throw new Error(`Trivy failed: ${stderr}`);
  }

  if (!existsSync(outputFile)) {
    throw new Error('Trivy did not produce output file — check target name');
  }

  try {
    const raw = readFileSync(outputFile, 'utf8');
    return JSON.parse(raw);
  } finally {
    // Clean up temp file regardless of parse outcome
    try { unlinkSync(outputFile); } catch { /* ignore */ }
  }
}

Results array structure: SchemaVersion, ArtifactType, Class

The top-level Trivy JSON output contains metadata about the scan target and a Results array. SchemaVersion identifies the output format version — check this when parsing, since Trivy has changed field names between major versions. ArtifactName is the scan target (the image name, file path, or URL). ArtifactType is one of container_image, filesystem, repository, sbom — the type determines what fields appear in Metadata.

Each entry in Results corresponds to one scannable component found within the target. A typical container image produces multiple results: one for OS packages (Alpine's apk packages, Debian's dpkg packages), one or more for language packages (a package-lock.json found inside the image), and optionally one for secrets and one for IaC configs if those scan types are enabled. The Class field identifies which scanner produced the result, and the Type field identifies the specific technology (alpine, debian, npm, pip, golang, terraform, dockerfile).

The null trap is the most common source of TypeErrors when building Trivy integrations. When a result class has no findings, Vulnerabilities is explicitly null, not an empty array. This is an intentional design choice in Trivy to distinguish "this target was scanned and found clean" from "this field is not applicable to this result type". Your parser must handle both the null case and the case where Vulnerabilities is an array. The ?? nullish coalescing operator is the correct guard — result.Vulnerabilities ?? [] handles both null and undefined safely.

function parseResults(trivyReport) {
  const results = trivyReport.Results ?? [];
  const grouped = {
    'os-pkgs': [],
    'lang-pkgs': [],
    'config': [],
    'secret': [],
    'other': [],
  };

  for (const result of results) {
    const cls = result.Class ?? 'other';
    const bucket = grouped[cls] ?? grouped['other'];

    if (cls === 'config') {
      // Config results use Misconfigurations[], not Vulnerabilities[]
      const misconfigs = result.Misconfigurations ?? [];
      bucket.push({
        target: result.Target,
        type: result.Type,
        class: cls,
        misconfigurations: misconfigs,
        misconfigCount: misconfigs.length,
      });
    } else if (cls === 'secret') {
      // Secret results use Secrets[], not Vulnerabilities[]
      const secrets = result.Secrets ?? [];
      bucket.push({
        target: result.Target,
        type: result.Type,
        class: cls,
        secrets,
        secretCount: secrets.length,
      });
    } else {
      // os-pkgs and lang-pkgs use Vulnerabilities[] — may be null
      const vulns = result.Vulnerabilities ?? [];
      bucket.push({
        target: result.Target,
        type: result.Type,
        class: cls,
        vulnerabilities: vulns,
        vulnCount: vulns.length,
      });
    }
  }

  return grouped;
}

// Summarize across all results
function summarizeReport(trivyReport) {
  const parsed = parseResults(trivyReport);
  const allVulns = [
    ...parsed['os-pkgs'].flatMap((r) => r.vulnerabilities),
    ...parsed['lang-pkgs'].flatMap((r) => r.vulnerabilities),
  ];
  return {
    artifactName: trivyReport.ArtifactName,
    artifactType: trivyReport.ArtifactType,
    schemaVersion: trivyReport.SchemaVersion,
    totalVulnerabilities: allVulns.length,
    bySeverity: {
      CRITICAL: allVulns.filter((v) => v.Severity === 'CRITICAL').length,
      HIGH: allVulns.filter((v) => v.Severity === 'HIGH').length,
      MEDIUM: allVulns.filter((v) => v.Severity === 'MEDIUM').length,
      LOW: allVulns.filter((v) => v.Severity === 'LOW').length,
      UNKNOWN: allVulns.filter((v) => v.Severity === 'UNKNOWN').length,
    },
    misconfigurations: parsed['config'].reduce((n, r) => n + r.misconfigCount, 0),
    secrets: parsed['secret'].reduce((n, r) => n + r.secretCount, 0),
  };
}

Severity vs SeveritySource: NVD vs distro scoring

Trivy's Severity field on each vulnerability is the severity you should use for filtering and reporting. It is not always the NVD severity. For OS packages, Trivy preferentially uses the distribution maintainer's security advisory when one exists — Alpine, Debian, Ubuntu, Red Hat, and others maintain their own advisory databases with severity ratings that account for the specific way their packages are built, patched, or configured. A CVE rated HIGH in NVD may be rated MEDIUM in Alpine's advisory because Alpine already backported the fix to an older version, or because the affected code path is not compiled into the Alpine package.

The SeveritySource field tells you which database Trivy used: "nvd", "redhat", "debian", "ubuntu", "alpine", and so on. For language packages (npm, pip, Go modules), SeveritySource is typically "ghsa" (GitHub Security Advisory) or "nvd". Raw CVSS scores from both NVD and distro databases are available in the CVSS object — CVSS.nvd.V3Score and CVSS.{distro}.V3Score — for cases where you need the numeric score rather than the categorical label.

A common mistake is filtering by NVD CVSS threshold directly from the CVSS.nvd.V3Score field while displaying the Trivy Severity field. These two values may disagree significantly for the same vulnerability. If you filter by CVSS.nvd.V3Score >= 7.0 but display Severity, you may show MEDIUM-labeled issues (from the distro database) or silently drop HIGH-labeled issues (where NVD rated them lower). Always use Severity for categorical filtering and CVSS.nvd.V3Score only for numeric comparisons where the source is explicitly shown.

const SEVERITY_ORDER = ['UNKNOWN', 'LOW', 'MEDIUM', 'HIGH', 'CRITICAL'];

function getSeverityRank(severity) {
  return SEVERITY_ORDER.indexOf(severity ?? 'UNKNOWN');
}

function getSeverityWithSource(vuln) {
  return {
    severityUsed: vuln.Severity,         // what Trivy decided — use this for filtering
    severitySource: vuln.SeveritySource, // which DB provided this rating
    nvdCvssScore: vuln.CVSS?.nvd?.V3Score ?? null,
    distroScore: (() => {
      const src = vuln.SeveritySource;
      if (!src || src === 'nvd') return null;
      return vuln.CVSS?.[src]?.V3Score ?? null;
    })(),
    discrepancy: (() => {
      const nvdScore = vuln.CVSS?.nvd?.V3Score;
      const src = vuln.SeveritySource;
      if (!nvdScore || !src || src === 'nvd') return false;
      const distroScore = vuln.CVSS?.[src]?.V3Score;
      if (!distroScore) return false;
      // Flag when NVD and distro scores differ by more than 2 points
      return Math.abs(nvdScore - distroScore) > 2;
    })(),
  };
}

Filtering and deduplicating vulnerability results

Raw Trivy output requires filtering before it is useful in an MCP tool response. Images built from a base like ubuntu:22.04 routinely contain 200–400 vulnerabilities at various severity levels — presenting all of them unfiltered overwhelms developers and obscures the actionable items. The two most useful filters are severity threshold (only report HIGH and CRITICAL) and fix availability (only report vulns where FixedVersion is non-empty). Together these filters typically reduce a 300-vulnerability report to 20–40 actionable findings.

Deduplication is necessary when the same package version appears in multiple image layers. Docker layers are additive: if the base image installs libssl1.1 and a later RUN apt-get install libssl1.1 reinstalls the same version, Trivy scans each layer independently and reports the same CVE twice. Deduplicate by the combination of VulnerabilityID + PkgName + InstalledVersion to get unique findings. If you want target-specific counts (to help developers understand which layer to fix), keep the Target field in the deduplicated output.

function filterVulnerabilities(results, {
  minSeverity = 'HIGH',
  onlyFixed = false,
  deduplicate = true,
} = {}) {
  const minRank = getSeverityRank(minSeverity);

  // Flatten all vulnerabilities from os-pkgs and lang-pkgs results
  const allVulns = results
    .filter((r) => r.Class === 'os-pkgs' || r.Class === 'lang-pkgs')
    .flatMap((r) => (r.Vulnerabilities ?? []).map((v) => ({
      ...v,
      _target: r.Target,
      _class: r.Class,
      _type: r.Type,
    })));

  // Apply filters
  let filtered = allVulns.filter((v) => {
    const rank = getSeverityRank(v.Severity);
    if (rank < minRank) return false;
    if (onlyFixed && !v.FixedVersion) return false;
    return true;
  });

  // Deduplicate by VulnerabilityID + PkgName + InstalledVersion
  if (deduplicate) {
    const seen = new Set();
    filtered = filtered.filter((v) => {
      const key = `${v.VulnerabilityID}::${v.PkgName}::${v.InstalledVersion}`;
      if (seen.has(key)) return false;
      seen.add(key);
      return true;
    });
  }

  // Sort: CRITICAL first, then HIGH; fixed before unfixed within same severity
  return filtered.sort((a, b) => {
    const rankDiff = getSeverityRank(b.Severity) - getSeverityRank(a.Severity);
    if (rankDiff !== 0) return rankDiff;
    // Fixed vulns sorted before unfixed at same severity
    const aFixed = a.FixedVersion ? 1 : 0;
    const bFixed = b.FixedVersion ? 1 : 0;
    return bFixed - aFixed;
  });
}

Scanning beyond images: fs, config, sbom, and repo modes

Trivy's image scanning is the most common use case but not the only one. The fs subcommand scans a local directory and finds language manifest files — package-lock.json, requirements.txt, go.sum, Gemfile.lock — without needing a container image. This is useful for pre-commit scanning or scanning a cloned repository. The output structure is identical to image scanning: Results[] with Class: "lang-pkgs" entries and the same vulnerability object shape.

The config subcommand scans for IaC misconfigurations: it checks Dockerfiles, Terraform files, Kubernetes YAML, and Helm charts for security anti-patterns. Config results use a different field than vulnerability results: instead of Vulnerabilities[], config results have Misconfigurations[] where each entry has a CheckID, Title, Description, Severity, Status (FAIL/PASS), and a CauseMetadata object pointing to the specific resource and code line that triggered the check.

The sbom subcommand scans an existing SBOM file — CycloneDX or SPDX format — rather than generating one from a scan target. This is useful when your build pipeline already generates SBOMs and you want to run vulnerability checks against them separately. The repo subcommand accepts a remote Git repository URL and clones it before scanning, which is useful for MCP tools that need to audit dependencies across repositories without local checkouts.

function scanLocalDirectory(dirPath, options = {}) {
  const outputFile = join(tmpdir(), `trivy-fs-${randomUUID()}.json`);

  const args = [
    'fs',
    '--format', 'json',
    '--output', outputFile,
    '--quiet',
  ];

  if (options.severity) args.push('--severity', options.severity);
  if (options.ignoreUnfixed) args.push('--ignore-unfixed');

  // Scan for both language packages and misconfigurations
  args.push('--scanners', 'vuln,misconfig,secret');

  args.push(dirPath);

  const result = spawnSync('trivy', args, {
    encoding: 'utf8',
    timeout: 120_000,
  });

  if (result.status === 2 || result.error) {
    throw new Error(`Trivy fs scan failed: ${result.stderr ?? result.error?.message}`);
  }

  try {
    return JSON.parse(readFileSync(outputFile, 'utf8'));
  } finally {
    try { unlinkSync(outputFile); } catch { /* ignore */ }
  }
}

function scanSBOM(sbomFilePath, options = {}) {
  const outputFile = join(tmpdir(), `trivy-sbom-${randomUUID()}.json`);
  const args = [
    'sbom',
    '--format', 'json',
    '--output', outputFile,
    '--quiet',
  ];
  if (options.severity) args.push('--severity', options.severity);
  args.push(sbomFilePath);

  const result = spawnSync('trivy', args, { encoding: 'utf8', timeout: 60_000 });
  if (result.status === 2 || result.error) {
    throw new Error(`Trivy SBOM scan failed: ${result.stderr}`);
  }
  try {
    return JSON.parse(readFileSync(outputFile, 'utf8'));
  } finally {
    try { unlinkSync(outputFile); } catch { /* ignore */ }
  }
}

VEX documents for false-positive suppression

VEX (Vulnerability Exploitability eXchange) is a standard for declaring that a specific vulnerability in a known package does not affect a specific product in its deployed configuration. The most common use case is a CVE in a library function that your application never calls — Trivy reports it because the package is installed, but the vulnerable code path is unreachable in your deployment. Rather than using --ignore-unfixed (which suppresses all unfixed vulns globally) or .trivyignore (which suppresses specific CVE IDs across all products), VEX lets you express targeted, machine-readable justifications.

Trivy supports OpenVEX format via the --vex flag. The VEX document is a JSON file listing statements that declare the vulnerability status of specific CVEs against specific products. The status values are: not_affected (the product is not affected by this CVE), affected (the product is affected and you are tracking it), fixed (the product has been fixed), and under_investigation (you are still determining impact). The justification field uses a controlled vocabulary: vulnerable_code_not_present, vulnerable_code_not_in_execute_path, vulnerable_code_cannot_be_controlled_by_adversary, inline_mitigations_already_exist.

VEX documents belong in source control alongside the code they describe, and they should be updated whenever the vulnerability status changes. They provide an audit trail that .trivyignore files do not: a reviewer can see exactly why a CVE was suppressed and when the suppression was last reviewed.

import { writeFileSync } from 'node:fs';

function generateVEX(vulnIds, productName, justification, outputPath) {
  // justification must be one of the OpenVEX controlled vocabulary values:
  // 'vulnerable_code_not_present'
  // 'vulnerable_code_not_in_execute_path'
  // 'vulnerable_code_cannot_be_controlled_by_adversary'
  // 'inline_mitigations_already_exist'
  // 'component_not_present'

  const vexDocument = {
    '@context': 'https://openvex.dev/ns/v0.2.0',
    '@id': `https://your-org.com/vex/${productName}-${Date.now()}.vex.json`,
    author: 'security-team@your-org.com',
    timestamp: new Date().toISOString(),
    version: 1,
    statements: vulnIds.map((cveId) => ({
      vulnerability: {
        name: cveId,
      },
      products: [
        {
          '@id': `pkg:oci/${productName}`,
          subcomponents: [],
        },
      ],
      status: 'not_affected',
      justification,
      // impact_statement is free text; use it to explain the justification
      impact_statement: `${productName} does not invoke the vulnerable code path in ${cveId}`,
    })),
  };

  writeFileSync(outputPath, JSON.stringify(vexDocument, null, 2), 'utf8');
  return vexDocument;
}

// Usage in MCP tool:
// 1. Generate VEX for known false positives
// generateVEX(
//   ['CVE-2023-44487', 'CVE-2023-45853'],
//   'myapp',
//   'vulnerable_code_not_in_execute_path',
//   './myapp.openvex.json'
// );
//
// 2. Pass VEX file to Trivy scan
// runTrivy('myapp:latest', { vexPath: './myapp.openvex.json' });

Frequently asked questions

Why is Vulnerabilities null instead of an empty array in Trivy results?

Trivy uses null to represent "this target class was scanned and no vulnerabilities were found" rather than an empty array. This is a deliberate design decision that distinguishes a clean scan result from a result type where the field is simply not applicable — for example, a config class result never has a Vulnerabilities field at all. In practice, both null and undefined must be guarded against. Always use result.Vulnerabilities ?? [] before iterating. Never write result.Vulnerabilities.length or result.Vulnerabilities.forEach() without the null guard — these will throw TypeErrors on images with no findings, which is the majority of production images after a proper base image update cycle.

Should I use Trivy's Severity field or NVD's CVSS score for prioritization?

Use Trivy's Severity field for categorical filtering (e.g., "show me HIGH and above"). It incorporates distro-specific knowledge that NVD does not have: Alpine may have backported a fix, or the Alpine build may not include the vulnerable component at all, making the effective severity lower than NVD's rating. If you need a numeric threshold comparison, use CVSS.nvd.V3Score and explicitly show users that the threshold is NVD-based. Never mix the two: do not compare Trivy's severity label (derived from the distro advisory) against an NVD CVSS number threshold — you will get inconsistent results where the same vulnerability is both "HIGH" (distro label) and below your 7.0 CVSS filter (NVD score), or the reverse.

How do I prevent Trivy from downloading the vulnerability database on every CI run?

Add a setup step to your CI pipeline that runs trivy image --download-db-only and caches the resulting database directory (typically ~/.cache/trivy/db). Then add --skip-db-update to all subsequent scan steps in the same workflow. The database has a 24-hour TTL: Trivy warns in stderr when the cached DB is older than 24 hours, but it continues scanning with the stale data rather than failing. For air-gapped environments, use --db-repository to point to an OCI registry mirror of the Trivy DB: trivy image --db-repository registry.internal/aquasec/trivy-db:2 myapp:latest. The mirror can be populated by pushing the official ghcr.io/aquasecurity/trivy-db OCI artifact to your internal registry.

Why does the same CVE appear in multiple Results entries?

A container image is built from layers, and each layer's filesystem is scanned independently. If a package is installed in the base image and then reinstalled or updated in a later RUN step — a common pattern when a RUN apt-get install step pins a different version than the base image includes — Trivy finds the same package version in multiple layers and reports the associated CVE once per layer. The Target field on each Result identifies which layer or path the finding came from. To get a unique vulnerability count, deduplicate by VulnerabilityID + PkgName + InstalledVersion across all Results. To understand which layer to fix, keep the Target field after deduplication and show it alongside the finding.

Further reading

Know when your MCP server is down — before users do

AliveMCP probes your server's MCP endpoint every minute, detects protocol errors and transport failures, and pages you before users notice.

Start monitoring free