CoursesCheckov & IaC scanningSuppressions done right

Suppressions done right

Skip with a reason, not blindly.

Intermediate14 min · lesson 5 of 12

A suppression is a doctor's note. The class still happened without you, but there is a signed reason on file and anyone can ask to see it. Checkov, the open-source scanner that reads infrastructure code and flags misconfigured settings, will throw findings that do not fit your situation: false alarms, risks you have looked at and accepted, checks aimed at something they were never meant for. Skipping those is healthy. What separates a well-run repository from a rubber-stamped one is whether every skip carries a reason a reviewer can push back on, or whether somebody made the red go away and said nothing.

No attacker needs a clever exploit against a repository wallpapered with unexplained #checkov:skip comments left over from old firefights. Each unexplained skip is a hole drilled during one bad afternoon and never filled. Treat a skip the way you treat an exception granted during an incident: written down, owned by a person, revisited on a date. A skip is not a mute button.

There are three places to record one. Inline on the resource, as a Kubernetes annotation (a key and value bolted onto a manifest's metadata section), or repo-wide in a .checkov.yaml config file. They differ in who can see them and how far they reach. Inline is the default choice. Config is for directories you do not own and for waivers agreed across a whole organisation. One trap is worth flagging early: scanning a Terraform plan throws inline comments away completely, and the warn block below spells out what that does to your merge gate.

Inline skips in Terraform

Terraform source files keep their comments all the way into Checkov's parser, so the note goes straight inside the block: #checkov:skip=<CHECK_ID>:<reason>. Rescan and the finding prints as SKIPPED with your reason beside it. The good property here is that the excuse travels with the thing it excuses. Delete the bucket and the skip goes with it. Write one line per check ID. Wildcards are rejected inline and work only in config.

terminal
$ checkov -f main.tf --compact
output
Check: CKV_AWS_20: "S3 Bucket has public access block"
SKIPPED for resource: aws_s3_bucket.assets
Suppress comment: Static-asset bucket is public by design (SEC-482)

Kubernetes annotations

Kubernetes manifests and Helm charts (Helm being the package manager that renders templates into those manifests) reach Checkov through a parser that never looks at comments in YAML, the indented text format those files are written in. So you hand it metadata instead: checkov.io/skip1: CKV_K8S_8=Liveness probe meaningless for one-shot Job. The number on the end of the key is what lets you stack more than one waiver on the same resource, with skip1, skip2 and onward.

terminal
$ checkov -f job.yaml --framework kubernetes --compact
output
Check: CKV_K8S_8: "Liveness Probe Should be Configured"
SKIPPED for resource: Job.default.db-migrate
Suppress comment: Liveness probe is meaningless for a one-shot Job

Repo-wide skips in config

Some exclusions are policy for a whole repository rather than a judgement about one bucket. skip-check turns a check off everywhere and accepts wildcards. skip-path lifts entire directories such as vendor/ or .terraform/ out of the scan. Commit .checkov.yaml and point at it with --config-file so your laptop and the build server test the same rules. The price is visibility. Nothing at the resource tells a reader that a check was waived, so keep this surface for code outside your control.

terminal
$ checkov -d . --config-file .checkov.yaml --compact --quiet
output
Passed checks: 812, Failed checks: 37, Skipped checks: 24
# skip-path entries do not appear as SKIPPED lines — those files are never scanned
Exception path for a failed check
1Check fails
Triage (cv-results)
2Fixable?
Fix the config, do not skip
3Accepted risk
Inline skip + ticket
4Audit
Grep skips on cadence
Skipping is the exception. Every skip needs a reason a reviewer can veto.

Inventory your suppressions

Checkov accepts a skip with no reason at all and passes it in silence, so the only thing standing between you and a wall of blank waivers is habit. Put a ticket ID inside every reason, so the skip points at a conversation somebody can go and read. Then use grep, the command line search that walks a directory tree, to pull every skip in the repository into one list you can actually look at. Organisation-wide governance of that process is cv-program. It starts here, with one grep.

terminal
$ grep -rnE "checkov:skip|checkov\.io/skip" --include="*.tf" --include="*.yaml" .
output
./main.tf:14: # checkov:skip=CKV_AWS_20:SEC-482 public static assets by design
./k8s/job.yaml:6: checkov.io/skip1: CKV_K8S_8=one-shot Job
Inline skips vanish when you scan a plan
#checkov:skip comments live only in your .tf source. Scan the JSON (JavaScript Object Notation, a machine-readable data format) that terraform plan produces (cv-plan) and Checkov never sees those comments, so a finding you accepted in code comes back and blocks the plan gate. Put the same waivers into skip-check inside .checkov.yaml, and keep the two surfaces saying the same thing.

Wildcard skips in config only

Patterns such as CKV2_AWS_* work in the skip-check list inside .checkov.yaml and behind --skip-check on the command line. They never work inline, where Checkov wants one explicit check ID. A wildcard is a blunt instrument: it covers every check matching the pattern, including ones you have never read. Reach for explicit IDs unless you genuinely mean to waive a whole family of graph checks, and get architecture sign-off when you do.

terminal
# .checkov.yaml
# skip-check:
# - CKV2_AWS_*
$ checkov -d . --config-file .checkov.yaml --compact | head -20
output
Passed checks: 812, Failed checks: 29, Skipped checks: 32
# CKV2_ checks show SKIPPED when matched by wildcard

Save repo-wide skip-check for categories that genuinely do not apply to you: multi-region checks in a business that runs one region on purpose, or a framework you are actively migrating off. It is not a quiet dumping ground for findings nobody wanted to fix. Give config skips the same ticket discipline as inline ones, and be stricter about it if anything, because an engineer reading the Terraform sees no sign the check was ever there.

In review, treat a new #checkov:skip line the way you treat a new security group rule that opens a port. Ask why. Check that the ticket exists and says something real. Ask when it expires and what closes it. A suppression slipped in to unblock a Friday deploy, with no owner signing off, is how a scanner quietly turns into decoration. cv-program covers audit cadence across an organisation. At repository level, a CODEOWNERS entry (the file that assigns reviewers to paths) on the directories that collect the most skips pulls the right people into the review automatically.

Typing --skip-check on your own laptop helps you for one afternoon and helps nobody else, because the next engineer and the build server never see it. Config committed to git is the shared contract. Shared module repositories raise the stakes again: a skip written inside a module travels out to every team consuming it, and none of them asked for it. Fix the module, or write down plainly why the check does not make sense for that abstraction. Suppressing something in shared code is a product decision, not a personal convenience.

Waivers that have to hold at plan time belong in .checkov.yaml, because terraform show -json strips your comments out of the picture entirely. If you run a source gate and a plan gate together, keep a short table in the repository docs mapping each inline skip to its config entry. When the two lists drift apart you get the phantom failures described in cv-plan, where a scan is green on your branch and red in the pipeline for reasons nobody can locate.

Make every skip reviewable

A suppression is a security exception, so give it the lifespan of one. An empty #checkov:skip=CKV_AWS_20 sails through without a word of complaint, which means the convention has to come from you: a ticket ID in the reason, every time. Then start the clock. A risk that read as reasonable in January can be indefensible by June, once the bucket that was internal has a public route in front of it and three teams reading from it. Re-reading skips on a schedule is what turns a pile of old notes back into decisions.

terminal
$ grep -rnE "checkov:skip=CKV[0-9A-Z_]+\s*$" .
$ grep -rnE "checkov:skip|checkov\.io/skip" --include="*.tf" --include="*.yaml" . | wc -l
output
./legacy/main.tf:88: # checkov:skip=CKV_AWS_18
47

Making the pipeline itself fail on bad suppressions is cv-cicd territory, and that gate is only ever as trustworthy as the skips you let merge. One cheap guard covers most of the problem: a grep step in continuous integration (the automated build that runs on every push) that fails the job when checkov:skip=CKV reaches the end of a line with nothing written after the ID.

Notice the difference between code you cannot fix and code you would rather not fix. skip-path over vendor/ and .terraform/ excludes third-party files you did not write and have no way to change, which is an honest exclusion. Turning off CKV_AWS_18 across the repository because access logging is inconvenient on buckets you own is a different act, even though both live in the same config file. Say which of the two you are doing, in the ticket, in words.

Put an audit on the calendar every quarter and give it three jobs. Chase the tickets that have expired. Clear out skips pointing at resources nobody deploys any more. Question every global skip that nobody in the room remembers adding. An inline skip that outlived its resource is untidy and little else. A forgotten CKV ID sitting in skip-check is the dangerous one, because it keeps swallowing findings in code written months after anyone remembers why.

Pick the narrowest surface that matches the risk. One resource, one accepted risk, code you own: inline #checkov:skip=CKV_AWS_20:SEC-482 with the reason attached, visible in review and gone when the resource goes. A Kubernetes Job where comments never reach the parser: checkov.io/skip1 in the annotations. A whole family of checks the business has decided against, with architecture sign-off behind it: skip-check with CKV2_AWS_* in config. Files you have no ability to edit: skip-path vendor/.

Keeping the two gates in step is manual work, because nothing does it for you. When you add an inline skip for something that also fails at plan time, add the .checkov.yaml entry in the same pull request instead of waiting for the pipeline to complain. And when a scan passes on your branch while the plan job blocks the merge on a check you know you skipped, that gap between the two surfaces is the first place to look.

Try this

Do this: add one inline skip with a reason shaped like a real ticket, rescan, and watch the check move into Skipped. Then run the grep that hunts for empty reasons, so you have seen what an audit actually looks like on your own repository before you owe someone one.

terminal
# After adding: #checkov:skip=CKV_AWS_18:SEC-482 temporary until logging module lands
$ checkov -d . --compact --quiet
$ grep -rn "checkov:skip" --include="*.tf" . | head
$ grep -rn "checkov:skip=CKV" --include="*.tf" . | grep -E "skip=CKV_[A-Z0-9_]+$" || echo "no empty-reason skips found"
output
Passed checks: 812, Failed checks: 36, Skipped checks: 1
./modules/logging/main.tf:14: #checkov:skip=CKV_AWS_18:SEC-482 temporary until logging module lands
no empty-reason skips found

Takeaway

Remember: a suppression counts as governance when it carries a reason, a ticket and a habit of expiring. Inline skips surface in code review and die with the resource. A global skip-check silences the check everywhere, including in code nobody has written yet. Choose the smallest scope that covers the risk you actually accepted, and no more.

Plan scans keep ignoring your inline comments, so mirror the long-lived waivers into .checkov.yaml whenever terraform_plan is what guards the merge. Next up: when the built-in catalogue has no check for a rule your organisation cares about, such as "every bucket carries an Owner tag", write a custom check (cv-custom) rather than suppressing the nearest CKV ID forever.

Quick check
01Where should a risk you have accepted on one single resource be recorded?
Incorrect — That flag turns the check off for every resource in the repository, not the one you looked at and accepted.
Correct — Scoped to the resource, visible to anyone reading the code review, and it disappears the day the resource does.
Incorrect — That changes real infrastructure to quiet a scanner, and the risk you accepted ends up written down nowhere.
Incorrect — A baseline is for bulk legacy debt, and it carries no reason for this one exception.
02Why do Kubernetes skips use annotations rather than comments?
Incorrect — YAML has # comments. Checkov's Kubernetes parser is the part that does not read them as skip directives.
Correct — Different syntax, same intent as an inline Terraform skip.
Incorrect — An annotation is plain text sitting in the manifest for anyone to read.
Incorrect — Comments are valid YAML and apply fine. Checkov only ignores them when it goes looking for skips.
03Your source scan is green, but the terraform plan gate blocks the merge on CKV_AWS_20, the exact check you skipped inline weeks ago. What do you do next?
Incorrect — The plan JSON carries no comments at all, so the second one is as invisible to the plan scan as the first.
Correct — Config is the only suppression surface a plan scan can see, which is why the two gates have to be kept in sync.
Incorrect — You would be changing infrastructure to quiet a gate, and the accepted risk stops being documented anywhere.
Incorrect — That mechanism belongs to Kubernetes manifests, not to Terraform source or its plan output.

Related