Guide · Security Scanning
MCP Tools for Checkov — IaC security scanning, failed_checks parsing, and multi-framework output
Checkov scans Infrastructure as Code files for security misconfigurations — open security groups, unencrypted S3 buckets, missing pod security contexts, containers running as root, Docker images with no USER instruction. Unlike CVE scanners that report severity from a vulnerability database, Checkov reports PASSED or FAILED for each check on each resource — there are no built-in severity levels in the standard JSON output. When you build an MCP tool that integrates Checkov — scanning a Terraform plan before apply, auditing Kubernetes manifests in a CI/CD pipeline, or checking a Dockerfile against CIS benchmarks — three distinctions determine correctness: the JSON output format changes depending on how many frameworks are detected (single-framework produces one JSON object; multi-framework without --compact produces newline-delimited JSON objects that must be parsed differently), check IDs encode both framework and category (CKV_AWS_* for Terraform AWS, CKV_K8S_* for Kubernetes, CKV2_* for graph-based checks that span multiple resources and require cross-file analysis), and the resource field in failed_checks is the Terraform resource address or Kubernetes resource name, not a file path — you need file_path + file_line_range together to locate the failing code.
TL;DR
Always pass --compact when invoking Checkov from an MCP tool — it collapses all framework results into a single JSON object with a predictable top-level structure (summary, passed_checks, failed_checks, skipped_checks, parsing_errors). Check parsing_errors[] before reporting results — a Terraform file that fails to parse generates no check results at all, which looks like a clean scan. Severity is not in Checkov's output; maintain a SEVERITY_MAP keyed on check IDs. The resource field is the Terraform resource address (e.g., aws_security_group.allow_all), not a file path; use file_path + file_line_range to locate the failing block.
Running Checkov: flags, frameworks, and output modes
Checkov is invoked as a CLI process. The most important flag for MCP tool integration is --compact, which merges all framework results into a single JSON object. Without it, scanning a directory that contains both Terraform and Kubernetes manifests produces newline-delimited JSON — one object per detected framework — and naively parsing the full stdout as JSON will fail.
The exit code carries status: 0 means all checks passed, 1 means at least one check failed (expected behavior when findings exist — not a process error), and 2 means Checkov itself encountered an error such as a missing dependency or invalid flag. An MCP tool must distinguish exit code 1 (findings, process succeeded) from exit code 2 (tool failure) before attempting to parse the output.
Writing results to a file with --output-file is preferred over capturing stdout when scanning large codebases — Checkov can produce hundreds of kilobytes of JSON for a single Terraform monorepo, and piping large stdout through a child process can trigger buffering issues in Node.js's spawnSync.
import { spawnSync } from 'node:child_process';
import { readFileSync, writeFileSync, unlinkSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { randomBytes } from 'node:crypto';
/**
* Run Checkov against an IaC directory and return parsed JSON results.
* @param {string} dir - Absolute path to the directory to scan
* @param {object} options
* @param {string[]} [options.frameworks] - e.g. ['terraform', 'kubernetes', 'dockerfile']
* @param {string[]} [options.skipChecks] - check IDs to skip globally
* @returns {{ summary, passed_checks, failed_checks, skipped_checks, parsing_errors }}
*/
function runCheckov(dir, options = {}) {
const outFile = join(tmpdir(), `checkov-${randomBytes(6).toString('hex')}.json`);
const args = [
'-d', dir,
'--compact', // merge all frameworks into a single JSON object
'--output', 'json',
'--output-file', outFile,
'--quiet', // suppress progress bars on stderr
];
if (options.frameworks?.length) {
args.push('--framework', options.frameworks.join(','));
}
if (options.skipChecks?.length) {
args.push('--skip-check', options.skipChecks.join(','));
}
const result = spawnSync('checkov', args, {
encoding: 'utf8',
timeout: 120_000, // 2-minute timeout; large repos can be slow
maxBuffer: 64 * 1024 * 1024,
});
// Exit code 2 is a Checkov error (not a finding); throw so the MCP tool
// surfaces a proper error rather than returning an empty result set.
if (result.status === 2) {
throw new Error(
`Checkov process error (exit 2): ${result.stderr?.slice(0, 500) ?? 'unknown'}`
);
}
if (result.error) {
throw new Error(`Failed to spawn checkov: ${result.error.message}`);
}
try {
const raw = readFileSync(outFile, 'utf8');
unlinkSync(outFile);
return JSON.parse(raw);
} catch (err) {
throw new Error(`Failed to parse Checkov output file: ${err.message}`);
}
}
JSON output structure: summary, failed_checks, skipped_checks
With --compact, Checkov writes a single JSON object. The top-level keys are summary, passed_checks, failed_checks, skipped_checks, and parsing_errors. The summary object contains aggregate counts plus the Checkov version that ran the scan — useful for debugging version-specific check behavior.
Each entry in failed_checks contains the check ID and name, the check result (always "FAILED" in this array), the file path relative to the scanned directory, the line range of the offending resource block, the Terraform resource address or Kubernetes object name in resource, and an evaluations field that is non-null for checks that evaluate variable values (most checks leave it null). The check_result.evaluated_keys array names the specific attribute keys that caused the failure — for example, ["ingress/[0]/cidr_blocks/[0]"] for an open security group check.
Always check parsing_errors[] before interpreting results. A file that Checkov cannot parse (invalid HCL syntax, truncated YAML, unsupported Kubernetes API version) silently produces zero check results for all resources in that file. If parsing errors are present and you report a clean scan to an LLM without surfacing them, the agent may incorrectly conclude the IaC is safe when an entire module was never evaluated.
/**
* Parse Checkov results, handling both compact (single object) and
* non-compact (NDJSON or array) output formats.
* Always use --compact when possible; this handles legacy or misconfigured calls.
* @param {string} rawOutput - raw stdout or file contents from Checkov
* @returns {{ summary, failed_checks, skipped_checks, parsing_errors }}
*/
function parseCheckovResults(rawOutput) {
const trimmed = rawOutput.trim();
// Compact output: a single JSON object starting with '{'
if (trimmed.startsWith('{')) {
return JSON.parse(trimmed);
}
// Array output: some Checkov versions wrap multi-framework in an array
if (trimmed.startsWith('[')) {
const arr = JSON.parse(trimmed);
return mergeFrameworkResults(arr);
}
// NDJSON: one JSON object per line (one per framework, no --compact)
const lines = trimmed.split('\n').filter((l) => l.trim().startsWith('{'));
if (lines.length === 0) {
throw new Error('Checkov produced no parseable JSON output');
}
const parsed = lines.map((line) => JSON.parse(line));
return mergeFrameworkResults(parsed);
}
function mergeFrameworkResults(results) {
const merged = {
summary: { passed: 0, failed: 0, skipped: 0, parsing_error: 0, resource_count: 0 },
passed_checks: [],
failed_checks: [],
skipped_checks: [],
parsing_errors: [],
};
for (const r of results) {
merged.summary.passed += r.summary?.passed ?? 0;
merged.summary.failed += r.summary?.failed ?? 0;
merged.summary.skipped += r.summary?.skipped ?? 0;
merged.summary.parsing_error += r.summary?.parsing_error ?? 0;
merged.summary.resource_count += r.summary?.resource_count ?? 0;
merged.passed_checks.push(...(r.passed_checks ?? []));
merged.failed_checks.push(...(r.failed_checks ?? []));
merged.skipped_checks.push(...(r.skipped_checks ?? []));
merged.parsing_errors.push(...(r.parsing_errors ?? []));
}
return merged;
}
Check ID taxonomy: CKV_AWS, CKV_K8S, CKV_GIT, CKV2
Checkov's check ID prefixes encode the framework and provider. This structure lets you determine at a glance which tool evaluated a given check and write targeted severity mappings, skip lists, and fix guidance without fetching external metadata.
CKV_AWS_*— Terraform AWS resource checks: security groups, IAM policies, S3 bucket ACLs, RDS encryption, EKS cluster config, CloudTrail loggingCKV_K8S_*— Kubernetes manifest checks: pod security contexts, RBAC bindings, network policies, resource limits, image pull policiesCKV_GCP_*— Terraform Google Cloud resource checks: firewall rules, GKE cluster security, Cloud SQL encryptionCKV_AZURE_*— Terraform Azure resource checks: NSG rules, Key Vault policies, AKS node configCKV_DOCKER_*— Dockerfile checks: no root USER, noADDwith remote URLs, nolatesttag pinningCKV_GIT_*— GitHub Actions and GitLab CI checks: pinned action versions, restricted workflow permissions, secrets not loggedCKV2_AWS_*,CKV2_K8S_*— Graph-based checks that analyze relationships between multiple resources, requiring cross-file state to evaluate
Graph-based CKV2_* checks are meaningfully different from single-resource checks. For example, CKV2_AWS_5 checks whether a security group is actually attached to an EC2 instance or RDS database — a security group with no attachments isn't a risk. This relationship cannot be determined by examining a single resource block; Checkov must build a resource graph from the full Terraform directory. CKV2_* checks require scanning with -d (directory) rather than -f (single file) and will not run against a single .tf file in isolation.
/**
* Derive the framework from a Checkov check ID.
* @param {string} checkId - e.g. 'CKV_AWS_24', 'CKV2_K8S_6', 'CKV_DOCKER_3'
* @returns {string} framework name
*/
function getCheckFramework(checkId) {
if (checkId.startsWith('CKV2_AWS')) return 'terraform-aws-graph';
if (checkId.startsWith('CKV2_K8S')) return 'kubernetes-graph';
if (checkId.startsWith('CKV2_')) return 'graph';
if (checkId.startsWith('CKV_AWS')) return 'terraform-aws';
if (checkId.startsWith('CKV_K8S')) return 'kubernetes';
if (checkId.startsWith('CKV_GCP')) return 'terraform-gcp';
if (checkId.startsWith('CKV_AZU')) return 'terraform-azure';
if (checkId.startsWith('CKV_DOC')) return 'dockerfile';
if (checkId.startsWith('CKV_GIT')) return 'github-actions';
return 'unknown';
}
// A representative severity mapping for high-impact check IDs.
// Checkov's open-source CLI has no built-in severity — this is your team's policy.
const CRITICAL_CHECKS = {
CKV_AWS_24: 'CRITICAL', // Security group allows ingress from 0.0.0.0/0
CKV_AWS_53: 'CRITICAL', // S3 block public access disabled
CKV_AWS_25: 'CRITICAL', // Security group allows egress to 0.0.0.0/0
CKV_K8S_8: 'CRITICAL', // Container runs in privileged mode
CKV_K8S_6: 'CRITICAL', // Container runs as root (runAsNonRoot not set)
CKV_AWS_7: 'HIGH', // KMS key rotation disabled
CKV_AWS_18: 'HIGH', // S3 access logging disabled
CKV_K8S_30: 'HIGH', // Container runAsUser is root (UID 0)
CKV_AWS_57: 'MEDIUM', // S3 bucket has static website enabled
CKV_K8S_21: 'MEDIUM', // No resource limits set on container
CKV_DOCKER_3: 'HIGH', // Dockerfile has no USER instruction (runs as root)
CKV_DOCKER_7: 'MEDIUM', // Dockerfile uses ADD with remote URL instead of curl+COPY
};
Multi-framework scanning: parsing combined JSON
When Checkov scans a directory containing Terraform files, Kubernetes YAML, and a Dockerfile simultaneously without --compact, it emits one complete JSON object per framework on separate lines — newline-delimited JSON (NDJSON). Each line is valid JSON on its own, but the combined stdout is not valid JSON if treated as a single document. The character on the first line of each frame is {, so naively calling JSON.parse(stdout) succeeds for the first framework and silently discards the rest.
Some Checkov versions (particularly 2.x) wrap multi-framework output in a JSON array ([{...}, {...}]) rather than NDJSON. Both formats appear in the wild depending on the installed version. The detection heuristic is simple: if the first non-whitespace character is [, parse as an array; if it is {, check whether the output contains multiple root-level objects by splitting on newlines; otherwise assume compact single-object output.
The recommendation for MCP tools is unambiguous: always pass --compact. The merged output is stable across Checkov versions from 2.x onward and eliminates all parsing ambiguity. The examples above in parseCheckovResults handle the fallback cases for compatibility with legacy invocations or wrapped calls that cannot control Checkov's flags directly.
/**
* Robust multi-framework output parser for Checkov stdout.
* Handles: compact single object, JSON array, and NDJSON.
* Recommendation: always pass --compact to avoid needing this.
* @param {string} rawOutput
* @returns {ReturnType<typeof mergeFrameworkResults>}
*/
function parseMultiFrameworkOutput(rawOutput) {
const trimmed = rawOutput.trim();
if (!trimmed) throw new Error('Checkov produced empty output');
const firstChar = trimmed[0];
if (firstChar === '{') {
// Could be compact single object OR the first line of NDJSON
const newlinePos = trimmed.indexOf('\n');
if (newlinePos === -1) {
// Single object: compact or single-framework
return JSON.parse(trimmed);
}
// Check if second non-whitespace line also starts a JSON object
const rest = trimmed.slice(newlinePos).trimStart();
if (rest.startsWith('{')) {
// NDJSON: parse line by line
const lines = trimmed
.split('\n')
.map((l) => l.trim())
.filter((l) => l.startsWith('{'));
return mergeFrameworkResults(lines.map((l) => JSON.parse(l)));
}
// Single compact object that happens to have newlines in its JSON
return JSON.parse(trimmed);
}
if (firstChar === '[') {
// JSON array of framework result objects
return mergeFrameworkResults(JSON.parse(trimmed));
}
throw new Error(`Unexpected Checkov output format: starts with '${firstChar}'`);
}
Inline suppression comments and severity mapping
Checkov supports inline suppression via specially formatted comments placed immediately before the resource block they apply to. These are the only supported suppression mechanism in the open-source CLI — there is no centralized suppression file. The comment format varies by framework:
- Terraform:
#checkov:skip=CKV_AWS_24:This security group is intentionally permissive for ALB - Kubernetes:
# checkov:skip=CKV_K8S_30:requires privileged for eBPF - Dockerfile:
# checkov:skip=CKV_DOCKER_3:running as root required for legacy compatibility
The text after the colon following the check ID is the suppression comment — it appears verbatim in the suppress_comment field of the corresponding skipped_checks entry. An MCP tool should surface suppressed checks alongside failed ones: a suppressed CRITICAL check that has a comment from three years ago ("temporary workaround for launch") deserves review. Suppressed checks with no comment (the comment text is empty) are a code smell worth flagging to the LLM.
Severity mapping is entirely your responsibility. The open-source Checkov CLI does not output severity levels. The Prisma Cloud (Checkov SaaS) platform adds severity to its database of checks, but those severity values are not available in the CLI's JSON output. You must maintain a mapping keyed on check IDs for the checks your team cares about, and assign a default level (typically LOW or MEDIUM) to unmapped checks.
// Representative severity map — extend this for your organization's IaC stack.
// Checkov open-source CLI has no built-in severity; this is your team's policy.
const SEVERITY_MAP = {
// CRITICAL: direct attack surface or immediate data exposure
CKV_AWS_24: 'CRITICAL', // Security group open to 0.0.0.0/0 on all ports
CKV_AWS_53: 'CRITICAL', // S3 block public access disabled
CKV_K8S_8: 'CRITICAL', // Container privileged mode
CKV2_AWS_5: 'CRITICAL', // Security group not attached to any resource
// HIGH: significant risk, fix in current sprint
CKV_AWS_7: 'HIGH',
CKV_AWS_18: 'HIGH', // S3 access logging disabled
CKV_K8S_30: 'HIGH', // Container runs as UID 0
CKV_DOCKER_3: 'HIGH', // No USER instruction in Dockerfile
CKV_GIT_4: 'HIGH', // GitHub Actions token has write-all permissions
// MEDIUM: important best practice, schedule next sprint
CKV_AWS_57: 'MEDIUM',
CKV_K8S_21: 'MEDIUM',
CKV_AWS_144: 'MEDIUM', // S3 versioning disabled
};
const SEVERITY_ORDER = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW'];
/**
* Annotate each failed check with a severity level from SEVERITY_MAP.
* Checks not in the map default to LOW.
* @param {object[]} failedChecks - from Checkov's failed_checks array
* @returns {object[]} sorted by severity descending
*/
function addSeverity(failedChecks) {
const withSeverity = failedChecks.map((check) => ({
...check,
severity: SEVERITY_MAP[check.check_id] ?? 'LOW',
framework: getCheckFramework(check.check_id),
}));
return withSeverity.sort(
(a, b) =>
SEVERITY_ORDER.indexOf(a.severity) - SEVERITY_ORDER.indexOf(b.severity)
);
}
/**
* Surface suppressed checks that may warrant review.
* @param {object[]} skippedChecks - from Checkov's skipped_checks array
* @returns {object[]} suppressed checks missing a comment or suppressing CRITICAL checks
*/
function auditSuppressions(skippedChecks) {
return skippedChecks
.map((check) => ({
...check,
severity: SEVERITY_MAP[check.check_id] ?? 'LOW',
missingComment: !check.suppress_comment?.trim(),
}))
.filter(
(check) =>
check.missingComment ||
check.severity === 'CRITICAL' ||
check.severity === 'HIGH'
);
}
Building an actionable Checkov MCP tool
The complete MCP tool function wraps the Checkov invocation, output parsing, severity annotation, and result grouping into a single call that returns structured data an LLM can reason about. The key design decisions: group results by resource address (so the agent sees all failing checks for aws_security_group.allow_all together, not scattered across check-first ordering), always surface parsing_errors before the findings summary, and provide a fix hint by deriving the check's CIS benchmark reference from the check ID range.
The file_path in each failed_check is relative to the scanned directory unless Checkov is invoked with an absolute path. When generating actionable output, prefix relative paths with the scanned directory so the agent can provide an exact edit location.
/**
* MCP tool: scan an IaC directory with Checkov and return structured findings.
* @param {string} dir - directory to scan
* @param {{ frameworks?: string[], skipChecks?: string[] }} [options]
* @returns {{ critical, high, medium, low, suppressedAlerts, parsingErrors, summary }}
*/
function scanInfrastructure(dir, options = {}) {
// Run Checkov with --compact for predictable output format
const raw = runCheckov(dir, options);
// Parsing errors must surface before findings — a parse error means
// an entire file's resources were silently skipped.
const parsingErrors = raw.parsing_errors ?? [];
// Annotate failures with severity and sort
const annotated = addSeverity(raw.failed_checks ?? []);
// Group by severity bucket
const critical = annotated.filter((c) => c.severity === 'CRITICAL');
const high = annotated.filter((c) => c.severity === 'HIGH');
const medium = annotated.filter((c) => c.severity === 'MEDIUM');
const low = annotated.filter((c) => c.severity === 'LOW');
// Group by resource: { resourceAddress -> [checks] }
const byResource = new Map();
for (const check of annotated) {
const key = `${check.file_path}::${check.resource}`;
if (!byResource.has(key)) byResource.set(key, []);
byResource.get(key).push(check);
}
// Surface suppressed CRITICAL/HIGH checks and those missing a comment
const suppressedAlerts = auditSuppressions(raw.skipped_checks ?? []);
return {
summary: {
...raw.summary,
criticalCount: critical.length,
highCount: high.length,
mediumCount: medium.length,
lowCount: low.length,
parsingErrorCount: parsingErrors.length,
suppressedAlertCount: suppressedAlerts.length,
},
critical,
high,
medium,
low,
byResource: Object.fromEntries(byResource),
suppressedAlerts,
parsingErrors,
};
}
Register your IaC repository's CI endpoint with AliveMCP to detect drift between scheduled Checkov scans. A Checkov scan that runs cleanly in CI but encounters a parsing error 48 hours later — because a developer committed invalid HCL — is invisible until the next CI run. AliveMCP's continuous probing catches protocol-level failures on your infrastructure management endpoints, closing the gap between periodic CI scans.
Frequently asked questions
Why doesn't Checkov's JSON output include severity levels?
Checkov is check-pass/fail based — it tests whether a resource meets a specific security best practice. The severity of violating that practice is a policy decision for your team, not a universal fact baked into the tool. The same open security group is CRITICAL for a production database and perhaps MEDIUM for a developer sandbox environment behind a VPN. The Checkov SaaS platform (Prisma Cloud) does add severity levels to its check database, and you can sometimes observe severity in the Prisma Cloud-connected output, but the open-source CLI does not include this data. You must maintain your own SEVERITY_MAP keyed on the check IDs your team cares about, set based on your specific architecture and risk tolerance.
What is a CKV2 check and why is it different from CKV checks?
CKV2 checks are graph-based — they analyze relationships between multiple resources rather than checking a single resource in isolation. For example, CKV2_AWS_5 checks whether a security group is actually attached to an EC2 instance or RDS database; a security group with no attachments is not a risk regardless of its inbound rules, but a single-resource CKV check has no way to know whether a security group is attached anywhere. Graph-based checks require the full Terraform directory (or plan graph) to run correctly — they will not execute against a single .tf file. If you scan with -f single-file.tf instead of -d directory/, all CKV2 checks are skipped silently. Always use -d for production scans.
How do I scan a Terraform plan file instead of raw .tf files?
Use checkov -f tfplan.json --file-type json where tfplan.json is the output of terraform show -json tfplan.bin. This scans the resolved plan rather than the template files, catching variable substitution issues that raw .tf file scanning misses entirely. For example, if a security group CIDR is passed as a Terraform variable set to 0.0.0.0/0 at plan time, raw file scanning may not detect the open rule because the variable value is not in the .tf file. Plan-file scanning resolves all variables and sees the concrete values. The check results in plan mode reference planned_resource_type.resource_name rather than the raw Terraform addresses, and file_path will point to the plan JSON rather than individual .tf files.
How do I handle Checkov's output when it scans both Terraform and Kubernetes in the same directory?
Always pass --compact to merge framework results. Without it, Checkov emits newline-delimited JSON — one JSON object per detected framework — and the raw stdout looks like invalid JSON when treated as a single document. With --compact, you get a single merged summary object with passed_checks, failed_checks, and skipped_checks combining all frameworks. The check_id prefix on each entry tells you which framework generated it (CKV_AWS_* for Terraform AWS, CKV_K8S_* for Kubernetes). Alternatively, pass --framework terraform,kubernetes explicitly to control which frameworks run, which also stabilizes the output structure by preventing Checkov from auto-detecting and running unexpected frameworks like github_actions if a .github/workflows/ directory is present.
Further reading
- MCP Tools for Trivy — container image scanning, IaC misconfig, and VEX suppression
- MCP Tools for Snyk — REST API, vulnerability severity, and fix prioritization
- MCP Tools for Semgrep — SAST patterns, finding confidence, and autofix integration
- MCP Tools for Grype — SBOM-first vulnerability scanning, fix state, and Syft integration
- MCP Tools for Terraform — plan parsing, state inspection, and drift detection
- MCP Tools for Kubernetes — cluster inspection and manifest validation
- MCP Server Health Checks — liveness, readiness, and protocol probes