Guide · Security Scanning
MCP Tools for Grype — matches array, fix state, and SBOM-first scanning with Syft
Grype structures vulnerability results as a matches[] array where each match captures three independent facts: the vulnerability (CVE ID, severity, CVSS score, fix state), the artifact where it was found (package name, version, type — apk, deb, npm, python), and the match details (how Grype linked the CVE to this package version, including whether the package is a direct or indirect dependency). When you build an MCP tool around Grype — scanning a container image before deployment, checking a local source directory, or scanning an SBOM generated by Syft — three distinctions matter for correct prioritization: vulnerability.fix.state ("fixed" vs "wont-fix" vs "unknown" — a vuln marked "wont-fix" by the OS vendor means the vendor has triaged it and decided not to backport a fix, even if upstream has released one — different risk posture than "unfixed but upstream patch exists"), matchDetails[].type ("exact-direct-match" vs "exact-indirect-match" — direct dependency vulnerabilities are immediately exploitable through your code; indirect ones depend on whether your direct dependency exposes the vulnerable code path), and Grype uses "Negligible" as a severity level between "Low" and "Unknown" — any severity comparison code that assumes ["Unknown","Low","Medium","High","Critical"] will misclassify Negligible findings and either suppress real findings or count them incorrectly.
TL;DR
Grype's top-level output is a flat matches[] array — simpler than Trivy's nested Results[].Vulnerabilities[] structure. Each match has three subobjects: vulnerability (CVE, severity, CVSS, fix.state), artifact (package name, version, type, layer), and matchDetails (how the match was found, direct vs indirect). Severity strings are capitalized first-letter: "Critical", "High", "Medium", "Low", "Negligible", "Unknown" — not all-caps. Always handle "Negligible" explicitly in severity comparisons; it appears when scanning RHEL/CentOS-based images. Use --only-fixed to pre-filter if the task is "find CVEs I can act on right now."
Running Grype: input types and JSON output
Grype accepts several input types, each specified as a URI-style prefix. The most common are registry images (no prefix or registry:), local Docker archives (docker-archive:), local directories (dir:), and SBOM files (sbom:). The dir: input scans for language package files (package-lock.json, requirements.txt, Gemfile.lock, go.sum) but does not detect OS-level packages — use image scanning for OS vulnerability coverage.
The --fail-on flag sets an exit-code threshold, not a filter. --fail-on high exits 1 if any HIGH or CRITICAL vulnerability is found, but the JSON output still contains all severity levels. An MCP tool that uses exit code to determine "is there anything to report" instead of inspecting the matches[] array will suppress MEDIUM and LOW findings even when they are relevant. Always parse the JSON regardless of exit code.
For large images, writing output to a file with --file is more reliable than capturing stdout, which can buffer large payloads unpredictably when invoked via spawnSync with a fixed maxBuffer.
import { spawnSync } from 'node:child_process';
import { readFileSync, unlinkSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { randomBytes } from 'node:crypto';
/**
* Run Grype against a target and return parsed JSON output.
* @param {string} target - e.g. 'alpine:3.18', 'docker-archive:./image.tar',
* 'dir:/path/to/source', 'sbom:./sbom.spdx.json'
* @param {object} [options]
* @param {boolean} [options.onlyFixed] - pass --only-fixed to filter to actionable CVEs
* @param {string} [options.failOn] - 'critical' | 'high' | 'medium' | 'low' (exit threshold)
* @param {boolean} [options.addCpes] - pass --add-cpes-if-none to improve match rate
* @returns {GrypeOutput}
*/
function runGrype(target, options = {}) {
const outFile = join(tmpdir(), `grype-${randomBytes(6).toString('hex')}.json`);
const args = [
target,
'-o', 'json',
'--file', outFile,
'--quiet',
];
if (options.onlyFixed) args.push('--only-fixed');
if (options.failOn) args.push('--fail-on', options.failOn);
if (options.addCpes) args.push('--add-cpes-if-none');
const result = spawnSync('grype', args, {
encoding: 'utf8',
timeout: 180_000, // image pulls can be slow
maxBuffer: 16 * 1024 * 1024,
});
// Exit code 1 with --fail-on means threshold exceeded — not a tool error.
// Exit code 2+ (or result.error) means Grype itself failed.
if (result.error) {
throw new Error(`Failed to spawn grype: ${result.error.message}`);
}
if (result.status !== null && result.status > 1 && !options.failOn) {
throw new Error(`Grype exited ${result.status}: ${result.stderr?.slice(0, 500)}`);
}
try {
const raw = readFileSync(outFile, 'utf8');
unlinkSync(outFile);
return JSON.parse(raw);
} catch (err) {
throw new Error(`Failed to parse Grype output: ${err.message}`);
}
}
The matches array: vulnerability, artifact, matchDetails
Grype's top-level JSON object contains matches[], ignoredMatches[], source (what was scanned), distro (OS distribution details if scanning an image), and descriptor (Grype version and DB version used). The flat matches[] structure is simpler than Trivy's nested Results[].Vulnerabilities[] hierarchy — there is no intermediate grouping by layer or package type.
Grype severity strings use capitalized-first-letter format: "Critical", "High", "Medium", "Low", "Negligible", "Unknown". This is different from Trivy, which uses all-caps ("HIGH", "CRITICAL"). Any utility function that normalizes severity for comparison — such as mapping to a numeric rank for sorting — must account for the capitalization difference if the same function handles output from both tools. The safest approach is to normalize to uppercase in the parsing step before the severity rank map is applied.
The artifact.locations[] array identifies where in the image the package was found — typically a path inside the container filesystem plus a layer digest. For OS packages (artifact.type === "apk" or "deb"), this is usually /lib/apk/db/installed or /var/lib/dpkg/status. For language packages, it is the path to the manifest file that introduced the package.
// Grype severity strings are capitalized first-letter, not all-caps.
// "Negligible" is a Red Hat severity level that most five-level severity
// maps omit — handle it explicitly or it will be sorted incorrectly.
const SEVERITY_ORDER = ['Critical', 'High', 'Medium', 'Low', 'Negligible', 'Unknown'];
const SEVERITY_RANK = Object.fromEntries(
SEVERITY_ORDER.map((s, i) => [s, SEVERITY_ORDER.length - i])
);
/**
* Parse and normalize Grype JSON output.
* @param {object} grypeOutput - parsed JSON from Grype
* @returns {{ matches, ignoredMatches, source, distro, dbVersion }}
*/
function parseMatches(grypeOutput) {
const matches = (grypeOutput.matches ?? []).map((m) => {
const sev = m.vulnerability?.severity ?? 'Unknown';
return {
cveId: m.vulnerability?.id,
severity: sev,
severityRank: SEVERITY_RANK[sev] ?? 0,
fixState: m.vulnerability?.fix?.state ?? 'unknown',
fixVersions: m.vulnerability?.fix?.versions ?? [],
cvssScore: extractCvssScore(m.vulnerability?.cvss),
packageName: m.artifact?.name,
packageVersion: m.artifact?.version,
packageType: m.artifact?.type, // apk, deb, npm, python, go, etc.
matchType: m.matchDetails?.[0]?.type, // exact-direct-match | exact-indirect-match | cpe-match
namespace: m.vulnerability?.namespace, // e.g. 'alpine:3.18'
dataSource: m.vulnerability?.dataSource,
// Raw objects for full access when needed
_vulnerability: m.vulnerability,
_artifact: m.artifact,
_matchDetails: m.matchDetails,
};
});
return {
matches: matches.sort((a, b) => b.severityRank - a.severityRank),
ignoredMatches: grypeOutput.ignoredMatches ?? [],
source: grypeOutput.source,
distro: grypeOutput.distro,
dbVersion: grypeOutput.descriptor?.db?.checksum,
};
}
function extractCvssScore(cvssArray) {
if (!Array.isArray(cvssArray) || cvssArray.length === 0) return null;
// Prefer CVSS v3.1 > v3.0 > v2
const v31 = cvssArray.find((c) => c.version === '3.1');
const v30 = cvssArray.find((c) => c.version === '3.0');
const v2 = cvssArray.find((c) => c.version === '2.0');
const best = v31 ?? v30 ?? v2;
return best?.metrics?.baseScore ?? null;
}
Fix state: fixed, wont-fix, unknown
The vulnerability.fix.state field is one of Grype's most useful outputs and one that most consumer tools ignore by collapsing all non-Critical findings into a flat list. The three states have different action profiles that should be surfaced distinctly to an LLM or developer:
"fixed"— a patched version exists.fix.versions[]lists the versions that contain the fix. The correct action is to upgrade the package to a version infix.versions[]or higher. Iffix.versionsis an empty array despitefix.state === "fixed", the database has confirmed a fix exists but does not have the exact version number — this is rare and usually resolves after agrype db update."wont-fix"— the OS distribution vendor has explicitly reviewed this CVE and decided not to backport a fix for the current release. This is not the same as "no fix exists" — upstream (e.g., the curl project) may have released a fix, but the Alpine or Debian maintainer has determined the vulnerable code path is not reachable in their distribution's build configuration, or the risk is acceptable for the current release cycle. Automatically suppressingwont-fixfindings is wrong; they should be documented and periodically reviewed, especially if the CVE severity is later upgraded."unknown"— no fix state information is available from the vulnerability database. The CVE may be newly published (the database hasn't yet received fix status from OS advisories), or the package is not tracked by the advisory sources Grype uses. Checkgrype db updatefreshness before treating unknown-state HIGH/CRITICAL findings as a database gap.
/**
* Categorize a match by its fix priority.
* Returns a priority level and recommended action.
* @param {object} match - normalized match from parseMatches()
* @returns {{ priority: number, tier: string, action: string }}
*/
function categorizeFix(match) {
const { severity, fixState, fixVersions } = match;
const isHighRisk = severity === 'Critical' || severity === 'High';
const isMediumRisk = severity === 'Medium';
if (fixState === 'fixed' && isHighRisk) {
return {
priority: 1,
tier: 'P1 — fix now',
action: `Upgrade to ${fixVersions[0] ?? 'latest fixed version'}`,
};
}
if (fixState === 'unknown' && isHighRisk) {
return {
priority: 2,
tier: 'P2 — monitor',
action: 'Update Grype DB; check advisory for upstream fix status',
};
}
if (fixState === 'wont-fix') {
return {
priority: 3,
tier: 'P3 — accept with documentation',
action: 'Vendor triaged and accepted risk; document acceptance decision',
};
}
if (fixState === 'fixed' && isMediumRisk) {
return {
priority: 4,
tier: 'P4 — schedule',
action: `Schedule upgrade to ${fixVersions[0] ?? 'latest fixed version'}`,
};
}
return {
priority: 5,
tier: 'P5 — backlog',
action: 'Low risk; include in next planned maintenance window',
};
}
Direct vs indirect match classification
The matchDetails[].type field reveals how Grype linked a CVE to a package. This distinction is important for prioritization: a vulnerability in a package your code directly imports is immediately exploitable through your call sites. A vulnerability in a transitive dependency can only be exploited if your direct dependency actually calls the vulnerable function — which you cannot determine from static analysis without reachability data.
"exact-direct-match"— Grype found the package listed directly in a package manifest (package-lock.json at depth 1, alpine's installed packages DB, python's site-packages). This is a direct dependency."exact-indirect-match"— the package is a transitive dependency. It does not appear directly in your manifests; another package pulls it in. The vulnerability may or may not be exploitable depending on whether the vulnerable code path is reachable."cpe-match"— Grype matched via Common Platform Enumeration rather than exact package name and version. CPE matching is less precise and has a higher false-positive rate. If a match is"cpe-match", verify manually before escalating.
Note that for OS-level packages (apk, deb, rpm), the direct/indirect distinction is less meaningful — every installed OS package is in some sense "direct" from the perspective of the container image. The distinction matters most for language ecosystem packages (npm, python, go) where the dependency graph is multi-level.
/**
* Group matches by match type: direct, indirect, and CPE-based.
* @param {object[]} matches - normalized matches from parseMatches()
* @returns {{ direct: object[], indirect: object[], cpe: object[], other: object[] }}
*/
function classifyByMatchType(matches) {
const direct = [];
const indirect = [];
const cpe = [];
const other = [];
for (const match of matches) {
switch (match.matchType) {
case 'exact-direct-match':
direct.push(match);
break;
case 'exact-indirect-match':
indirect.push(match);
break;
case 'cpe-match':
cpe.push(match);
break;
default:
other.push(match);
}
}
return { direct, indirect, cpe, other };
}
/**
* Build a prioritized report grouping by fix priority and match type.
* @param {object[]} matches - normalized matches from parseMatches()
* @returns {object}
*/
function buildGrypeReport(matches) {
const classified = classifyByMatchType(matches);
// Apply fix categorization to direct matches only for the immediate action list
const directWithPriority = classified.direct.map((m) => ({
...m,
...categorizeFix(m),
}));
const actionable = directWithPriority
.filter((m) => m.priority <= 2)
.sort((a, b) => a.priority - b.priority || b.severityRank - a.severityRank);
return {
summary: {
total: matches.length,
critical: matches.filter((m) => m.severity === 'Critical').length,
high: matches.filter((m) => m.severity === 'High').length,
medium: matches.filter((m) => m.severity === 'Medium').length,
low: matches.filter((m) => m.severity === 'Low').length,
negligible: matches.filter((m) => m.severity === 'Negligible').length,
fixed: matches.filter((m) => m.fixState === 'fixed').length,
wontFix: matches.filter((m) => m.fixState === 'wont-fix').length,
unknown: matches.filter((m) => m.fixState === 'unknown').length,
directCount: classified.direct.length,
indirectCount: classified.indirect.length,
cpeCount: classified.cpe.length,
},
actionableNow: actionable,
direct: classified.direct,
indirect: classified.indirect,
cpe: classified.cpe,
};
}
SBOM-first workflow with Syft
Syft generates Software Bill of Materials files that enumerate every package in a container image or source directory. Grype consumes those SBOMs to find vulnerabilities — either by accepting a Syft SBOM as its input target, or by running Syft internally as its first step during a direct image scan. The SBOM-first approach separates these two concerns: you generate the SBOM once during the build pipeline, then scan it for vulnerabilities at deployment time, in a different environment, or next week against an updated database.
Syft supports four output formats relevant to Grype: syft-json (Syft's native format with the most metadata), spdx-json (SPDX 2.3 JSON, the most widely supported standard), cyclonedx-json (CycloneDX 1.4 JSON), and github-json (GitHub Dependency Snapshot format). Grype accepts all four via the sbom: prefix. For MCP tools, spdx-json or syft-json are the most reliable choices — github-json strips package metadata that Grype needs for accurate matching.
The SBOM-first pattern is also the path to SLSA attestation: the SBOM can be signed with Cosign (cosign attest --predicate sbom.spdx.json), stored in an OCI registry alongside the image, and verified before deployment. The vulnerability scan result can itself be attached as a second attestation. This creates a complete, verifiable chain from build to deployment.
import { spawnSync } from 'node:child_process';
import { writeFileSync, readFileSync, unlinkSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { randomBytes } from 'node:crypto';
/**
* Generate an SBOM with Syft, then scan it with Grype.
* Separates SBOM generation from vulnerability scanning so both results
* can be stored, attested, or re-used independently.
*
* @param {string} target - image name or dir: path for Syft to scan
* @param {object} [options]
* @param {string} [options.sbomFormat] - 'spdx-json' | 'cyclonedx-json' | 'syft-json'
* @returns {{ sbomPath, grypeReport }}
*/
async function generateAndScanSBOM(target, options = {}) {
const format = options.sbomFormat ?? 'spdx-json';
const sbomPath = join(tmpdir(), `sbom-${randomBytes(6).toString('hex')}.json`);
// Step 1: generate SBOM with Syft
const syftResult = spawnSync('syft', [
target,
'-o', `${format}=${sbomPath}`,
'--quiet',
], {
encoding: 'utf8',
timeout: 120_000,
maxBuffer: 32 * 1024 * 1024,
});
if (syftResult.error) {
throw new Error(`Failed to spawn syft: ${syftResult.error.message}`);
}
if (syftResult.status !== 0) {
throw new Error(
`Syft exited ${syftResult.status}: ${syftResult.stderr?.slice(0, 500)}`
);
}
// Step 2: scan the SBOM with Grype
// grype accepts stdin: syft image -o json | grype - (use spawnSync pipe for MCP)
const grypeOutput = runGrype(`sbom:${sbomPath}`, options);
// Clean up SBOM temp file if not being persisted
if (!options.keepSbom) unlinkSync(sbomPath);
const parsed = parseMatches(grypeOutput);
const report = buildGrypeReport(parsed.matches);
return {
sbomPath: options.keepSbom ? sbomPath : null,
source: parsed.source,
distro: parsed.distro,
dbVersion: parsed.dbVersion,
...report,
};
}
Grype also accepts SBOM directly from stdin, enabling pipeline composition without temporary files: the command syft alpine:3.18 -o json | grype - pipes Syft's native JSON output directly into Grype. For MCP tools that use spawnSync, you must write the SBOM to a temporary file first (as shown above) since spawnSync does not support piping between two child processes without a shell. If you use spawn with streams, the pipe approach works directly.
Grype vs Trivy: when to use which
Grype and Trivy overlap significantly in capability — both scan container images, filesystems, and language package files for CVEs using NVD and OS vendor advisories. The choice depends on what else your MCP tool needs to do alongside vulnerability scanning.
Grype is the better choice when SBOM workflows matter. Its integration with Syft is native — Syft and Grype are both Anchore projects, and the SBOM formats are optimized for round-trip fidelity. Grype's wont-fix state granularity is also richer than Trivy's — Trivy has a WillNotFix status, but the advisory data coverage for that state differs between the two tools. Grype's flat matches[] structure is simpler to parse than Trivy's nested Results[].Vulnerabilities[] hierarchy when you only need vulnerability data.
Trivy is the better choice when you need a single tool for both vulnerability scanning and IaC misconfiguration (Terraform, Kubernetes, Dockerfile), secret scanning, or license checking. Trivy's --scanners flag lets you run vuln + config + secret in one invocation. Grype has no IaC scanning capability — for that, you need Checkov, Trivy, or tfsec.
// Severity ordering for each tool — they differ in capitalization and Negligible
// Grype: capitalized first letter, includes "Negligible"
const GRYPE_SEVERITY_ORDER = {
Critical: 6,
High: 5,
Medium: 4,
Low: 3,
Negligible: 2,
Unknown: 1,
};
// Trivy: all-caps, no Negligible level
const TRIVY_SEVERITY_ORDER = {
CRITICAL: 5,
HIGH: 4,
MEDIUM: 3,
LOW: 2,
UNKNOWN: 1,
};
/**
* Normalize a severity string to a comparable integer rank.
* Handles both Grype (capitalized) and Trivy (all-caps) formats.
* @param {string} severity
* @returns {number} higher is more severe
*/
function severityRank(severity) {
// Try Grype format first (capitalized)
if (severity in GRYPE_SEVERITY_ORDER) return GRYPE_SEVERITY_ORDER[severity];
// Try Trivy / all-caps format
const upper = severity.toUpperCase();
// Map CRITICAL->Critical for lookup in Grype table
const capitalized = upper.charAt(0) + upper.slice(1).toLowerCase();
return GRYPE_SEVERITY_ORDER[capitalized] ?? 0;
}
Frequently asked questions
What does "wont-fix" mean in Grype's fix.state and should I ignore those findings?
wont-fix means the OS distribution vendor has reviewed the CVE and decided not to backport a fix into the current release — either because the vulnerable code path is not present in their build, the risk is low for their use case, or they plan to address it in the next major release rather than the current one. You should not auto-ignore wont-fix findings. Instead, treat them differently from unfixed vulnerabilities: document the acceptance decision, note the vendor's reasoning (the vulnerability.advisories[] array may contain a link to the vendor's advisory statement), and re-evaluate if the CVE severity is later upgraded or if your deployment environment changes in a way that makes the vulnerability exploitable. A wont-fix HIGH CVE in a container you expose directly to the internet is a different risk posture than the same finding in an internal batch job with no network access.
Why does Grype use "Negligible" as a severity level?
Negligible is a Red Hat severity level below Low, used for CVEs that have minimal real-world impact or where the vulnerable code is not accessible in the distribution's build. It appears in Grype's output when scanning RHEL, CentOS, Fedora, or other Red Hat family images that include Red Hat's advisory data alongside NVD data. Red Hat assigns Negligible when they have assessed the CVE but determined it poses essentially no risk in their build. Many severity comparison functions and vulnerability tools assume only five severity levels (Unknown, Low, Medium, High, Critical) and will crash, throw a comparison error, or misclassify Negligible findings if they appear unexpectedly. Always explicitly include Negligible in your severity ordering — place it between Low and Unknown in the hierarchy, as its risk level is below Low but above completely unknown.
How does Grype differ from running Syft alone?
Syft generates an SBOM — a bill of materials listing what packages exist in an image or directory: names, versions, types (apk, deb, npm, python), locations, and checksums. Syft does not check for vulnerabilities; it does not know whether any of the packages it lists have known CVEs. Grype consumes that SBOM (or generates one internally as its first step) and cross-references the package list against its vulnerability database to find CVE matches. The two tools compose: syft image:tag -o json | grype - pipes Syft's output directly into Grype. Keeping the SBOM separately — generated by Syft, consumed by Grype — enables historical re-scanning (keep the SBOM from three months ago, re-scan against today's database to find newly disclosed CVEs), SBOM attestation (sign the SBOM with Cosign), and multi-scanner comparison (scan the same SBOM with both Grype and Trivy to compare results).
How do I keep Grype's vulnerability database up to date in a CI environment?
Run grype db update as a CI setup step before scanning. Grype checks the local database age and logs a warning if it is more than 5 days old, but continues scanning — it does not fail on a stale database. For CI environments that run many parallel jobs, cache the database directory (~/.cache/grype/db on Linux, or the path returned by grype db status) between runs using a daily or date-keyed cache key. Without caching, grype db update downloads approximately 50 MB per run. For air-gapped environments, use grype db list to find the database archive download URL, mirror the archive to an internal server on a schedule, and set the environment variable GRYPE_DB_UPDATE_URL to point to your internal mirror URL. Grype reads that variable and uses it for all database updates.
Further reading
- MCP Tools for Trivy — multi-class Results array, IaC misconfig, and VEX suppression
- MCP Tools for Snyk — REST API, vulnerability severity, and fix prioritization
- MCP Tools for Checkov — IaC security scanning, check IDs, and multi-framework output
- MCP Tools for Semgrep — SAST patterns, finding confidence, and autofix integration
- MCP Tools for Docker — container management and image inspection
- MCP Tools for Kubernetes — cluster inspection and manifest validation
- MCP Server Health Checks — liveness, readiness, and protocol probes