CoursesCheckov & IaC scanningReading & triaging findings

Reading & triaging findings

Checks, severity, guides.

Intermediate12 min · lesson 3 of 12

A Checkov run does not hand you a to-do list. It hands you a lab report. Hundreds of lines, green and red, each one rule checked against one piece of infrastructure, most of them harmless and a few genuinely dangerous. A doctor reading your bloodwork does not panic at every value outside the reference range. She reads the whole page, separates the incidental flags from the ones that need treating this week, and notes what to watch. Doing that to scanner output is called triage.

Every finding comes with three things: a stable check ID (the rule's permanent name, like CKV_AWS_18), the resource that tripped it, and a file and line number. Most also print a Guide link to a page describing the fix, but a check with no guideline mapped to it prints no Guide line at all. The ID is the part you carry everywhere else, into skip comments, baselines and your CI (continuous integration) config. This lesson walks the whole loop. Read one finding properly, cut the noise, group by how often a rule fires and how much damage it could do, then close each row out as fixed or as a written-down skip with a reason attached.

Read a single finding

Learn one record before you drown in five hundred. The first line gives you the check ID, CKV_AWS_18, then a one-sentence name and either PASSED or FAILED. The lines below it name the resource that failed (aws_s3_bucket.logs, a storage bucket), the file and line range (/s3.tf:1-4), and the Guide URL. The prefix carries meaning. A CKV_ check looks at one resource on its own. A CKV2_ check is a graph check: it reasons about how resources connect to each other. Read the ID before you read the name. The name is a summary you will forget by lunchtime. The ID is the word you type into every suppression and baseline file you will ever write.

terminal
$ checkov -d . --compact --quiet
output
terraform scan results:
Passed checks: 12, Failed checks: 3, Skipped checks: 0
Check: CKV_AWS_18: "Ensure the S3 bucket has access logging enabled"
FAILED for resource: aws_s3_bucket.logs
File: /s3.tf:1-4
Guide: https://docs.prismacloud.io/.../s3_13
Check: CKV_AWS_21: "Ensure all data stored in the S3 bucket have versioning enabled"
FAILED for resource: aws_s3_bucket.logs
File: /s3.tf:1-4

Narrow the noise

A first scan on a real repository throws hundreds of failures at you, so triage starts by making the list shorter. --quiet hides everything that passed. --compact drops the code snippets. --check keeps only the IDs you name, and --skip-check drops the ones you have already ruled on. The heavy lifting happens with -o json, which prints results as JSON (JavaScript Object Notation, a machine-readable format), piped into jq, a small command-line tool for slicing JSON apart. Group by check_id and the picture changes shape: "CKV_AWS_18 fires forty times, and all forty come out of one module." Sort by frequency and by how exposed the resource is, never by the order things scrolled past.

terminal
$ checkov -d . --framework terraform -o json \
| jq -r '.results.failed_checks[].check_id' \
| sort | uniq -c | sort -rn | head
output
40 CKV_AWS_18
12 CKV_AWS_21
3 CKV_AWS_79
1 CKV2_AWS_6
Triaging a scan
1Full scan
checkov -d . --quiet
2Group by check
json + jq, count per CKV id
3Rank by risk
exposure x blast radius
4Fix or defer
remediate, baseline, or skip
Triage turns a flat dump into a ranked worklist. The scanner counts; you weigh.

Pull remediation from JSON

Most failed checks in the JSON output carry a guideline field, the same advice that sits behind the Guide link. Working through a batch of forty, you want that text on screen beside the resource name rather than opening one browser tab at a time. Where a check has no guideline mapped to it the field comes back null, and the filter prints the word null on that line instead of advice. Scope the run to a single framework before you write the filter. One framework gives you a single object with .results at the top. Ask for several and Checkov hands back a list of those objects instead, and your jq path quietly stops matching anything.

terminal
$ checkov -d . --framework terraform -o json \
| jq -r '.results.failed_checks[]
| select(.check_id == "CKV_AWS_18")
| "\(.resource) \(.check_name)\n \(.guideline)"'
output
aws_s3_bucket.logs Ensure the S3 bucket has access logging enabled
S3 bucket should have access logging enabled. Add aws_s3_bucket_logging...

From finding to fix

Once the list is ranked, close the loop. When the finding prints a Guide link, open it before you edit anything, because some checks want one named attribute set and will keep failing if you tighten something nearby instead. Often the check name plus the resource address tells you the fix outright. When a finding is real but cannot be fixed yet, write a documented suppression with a reason (cv-suppress). Deleting the failing line to turn the output green does not count as a fix. Someone reads that file in six months and believes it.

terminal
$ checkov -d . --check CKV_AWS_18,CKV_AWS_21 --compact --quiet
output
Passed checks: 0, Failed checks: 2, Skipped checks: 0
Check: CKV_AWS_18: "Ensure the S3 bucket has access logging enabled"
FAILED for resource: aws_s3_bucket.logs
Open-source Checkov cannot gate on severity
HIGH and CRITICAL bands only show up when Checkov is connected to Prisma Cloud, the paid platform sold by the company that maintains Checkov, using --bc-api-key. Without that key the severity field comes back null in JSON, and --check and --skip-check match on check IDs and nothing else. Build a triage rule or a CI gate around that severity column and it will wave everything through.

CKV vs CKV2 graph checks

A house alarm that only complains when the back door is unlocked and the side gate is open at the same time is not reporting on a door. It is reporting on a combination. CKV2_ graph checks work the same way: an instance attached to a wide-open security group, a bucket wired to a public access control list (ACL, the rules saying who may read a stored object). So fixing the one resource named in the output may not clear the row. Read the Guide slowly on these, because the fix often lives in two files. Counting CKV2_ failures per module still pays, though; it points you at the templates whose wiring needs changing rather than one attribute.

terminal
$ checkov -d . --framework terraform -o json | jq -r '.results.failed_checks[] | select(.check_id | startswith("CKV2_")) | .check_id' | sort | uniq -c
output
8 CKV2_AWS_6
3 CKV2_AWS_12

Rank by exposure

How do you rank a list with no severity column to sort on? On exposure, or blast radius: how far the damage spreads if a stranger finds that row before you do. A CKV_AWS_20 finding about a publicly readable S3 bucket outranks a missing description tag every single time, whatever order they printed in. Count still matters, just second: those forty CKV_AWS_18 rows are one module edit, an afternoon rather than a sprint. One production database security group open to the whole internet beats fifty cosmetic findings.

If you are running this as a programme rather than a one-off cleanup, keep the JSON from every scan. Failed counts per check_id plotted across commits show whether remediation is landing or whether you are treading water while new code adds rows at the same rate. The metrics side of that is cv-program. This lesson is the part where you decide what gets fixed this sprint, what goes into a baseline, and what gets a skip.

Watch someone new to Checkov read a failing scan and you will see them scroll until their own resource appears. Wrong sort order. Forty CKV_AWS_18 rows caused by one shared module that forgot logging is one fix, not forty. A lone CKV2_AWS_6 on a public-facing bucket, sitting at the very bottom of the output, might be the only line that matters this week. Counting by check_id in JSON is how experienced engineers read a wall of red: count, cluster, rank by exposure, then open the Guide for the top three. Not for every line.

The skipped tally deserves a look on every scan too. A skip count that creeps up release after release means suppressions are piling on faster than fixes. SKIPPED with a ticket reference beside it is healthy; someone made that call on purpose and wrote it down. SKIPPED with an empty reason is debt hiding in plain sight. When the number jumps after a noisy release, run the grep audit from cv-suppress before you trust the rest of the report.

Export for dashboards

Once triage has named the top offenders, get the results out of your terminal. JSON feeds scripts and ticket automation. SARIF (Static Analysis Results Interchange Format, the shared file format code-scanning dashboards read) feeds the GitHub Security tab or Azure DevOps. One run can write both, with --output json --output sarif --output-file-path results. Archive the summary for each commit and cv-program turns those files into trend lines. Triage is the human step between scanner output and a ticket backlog. Skip it and every FAILED line looks equally urgent, which is the same as none of them being urgent.

terminal
$ checkov -d . --framework terraform -o json --output-file-path results
$ jq '.results.failed_checks | length' results/results_json.json
output
37

When findings leave your team, send the four fields that travel well: check_id, resource, file_line and check_name. A raw JSON dump full of build-runner paths means nothing to a product manager, and it buries the one line you wanted read. The ID is the stable key that survives the trip through Slack, into JIRA, and back into a skip comment months later.

Try this

Do this: run a compact quiet scan, count the failures by check ID with jq, then re-run scoped to the two IDs at the top of that count. That is the whole triage loop in three commands. Keep --framework terraform on the JSON run, because a repo that also holds a Dockerfile hands jq a list and .results matches nothing. Group before you scroll, open one Guide, pick the fix that clears the most rows.

terminal
$ checkov -d . --compact --quiet
$ checkov -d . --framework terraform -o json | jq -r '.results.failed_checks[].check_id' | sort | uniq -c | sort -nr | head
$ checkov -d . --check CKV_AWS_18,CKV_AWS_21 --compact --quiet
output
Passed checks: 812, Failed checks: 37, Skipped checks: 0
14 CKV_AWS_18
9 CKV_AWS_21
4 CKV_AWS_79
2 CKV2_AWS_6
Check: CKV_AWS_18: "Ensure the S3 bucket has access logging enabled"
FAILED for resource: aws_s3_bucket.logs

Takeaway

Remember: triage is what turns a scanner into a backlog you can actually work. Rank by exposure and by how many rows one edit clears, because in open-source mode there is no severity column to sort on. Fix the module fanning out fourteen identical CKV_AWS_18 hits before you chase a one-off edge case, and never turn a finding green by deleting the line that failed.

When a finding is real but not fixable today, write the skip with a ticket behind it (cv-suppress), or park legacy bulk debt in a baseline (cv-baseline). Next up: point Checkov at the frameworks you actually own (cv-frameworks), so third-party Helm charts nobody on your team can edit never reach this worklist in the first place.

Quick check
01A repo holds Terraform files and a Dockerfile. checkov -d . --compact --quiet reports 37 failed checks, but checkov -d . -o json | jq -r '.results.failed_checks[].check_id' prints nothing at all. What fixes the pipeline?
Incorrect — --quiet only hides passing results in the terminal report, and your filter already asks for failed_checks by name. Volume was never the issue, since jq printed nothing at all rather than too much.
Incorrect — --compact trims the code snippets out of the human readable report. The JSON gets assembled the same way with or without it, so the shape jq walks does not change.
Correct — The Dockerfile pulls a second framework into the scan, and Checkov then returns a list with one object per framework. Your path looks for .results at the top level, so it matches nothing until you scope the run back to terraform.
Incorrect — results/results_json.json holds exactly what the pipe held. Saving it first helps you archive a scan per commit, but a list on disk still defeats the same path.
02Your count comes back 14 CKV_AWS_18, 9 CKV_AWS_21, 4 CKV_AWS_79, 2 CKV2_AWS_6. All fourteen CKV_AWS_18 rows trace to one shared module used by internal buckets. One CKV2_AWS_6 row sits on a bucket serving public downloads. Where do you start?
Correct — A publicly reachable bucket is the row a stranger can use today, so it goes first even though it printed last. Take the fourteen row module edit straight after it.
Incorrect — That edit is the best value on the list and it is the right second move. Frequency tells you what clears fastest, not what costs you most if someone finds it first.
Incorrect — sort -rn ordered that list by how often each rule fired and nothing else. It has no idea which of those resources is reachable from outside your network.
Incorrect — Graph checks often do need edits in two places, but effort is not a ranking signal. The wiring a CKV2_ row describes is frequently what makes a resource exposed in the first place.
03Your team agrees on a triage rule: anything CRITICAL gets fixed this sprint and everything else waits. You are running open-source Checkov with no --bc-api-key. What happens to the 37 findings?
Incorrect — The CKV2_ prefix tells you a check reasons about how resources connect to each other. It says nothing about how bad the finding is, and those rows carry the same empty severity as the rest.
Correct — Severity comes back null without --bc-api-key, so no row ever reaches the CRITICAL branch and the rule parks the whole report. Gate on check IDs instead, since --check and --skip-check read nothing else.
Incorrect — The field is present and empty rather than missing, so your rule reads null and moves on without complaining. A gate that never fires looks exactly like a clean repo.
Incorrect — JSON is where the null is easiest to see, which is how most people find this. Output format changes how results get written down, not what the open-source scanner was able to work out.

Related