Linting Infrastructure as Code: A Deep Dive into Checkov

Sep 17, 2026 min read

You spend Tuesday afternoon running a vulnerability scan against your app’s container image. Zero critical CVEs, two mediums with patches already queued up. You feel good enough about it to merge the PR and let the pipeline ship it to production. Thursday morning, someone on the platform team pings you: “hey, why is your pod running as root with a privileged security context?” You pull up the deployment YAML. There it is, three commits back, left over from when you were debugging a permissions error at 11pm and never cleaned it up. Nobody caught it — not the scanner, not code review, not the pipeline. It just shipped.

Your scanner passed. Your architecture didn’t.

That gap is the whole problem with treating “scanned” as synonymous with “safe.” A vulnerability scanner like Trivy checks the software inside your container — the OS packages, the language dependencies — against databases of known CVEs. It has no opinion on how you built the container or how you’re deploying it. It won’t tell you that your Dockerfile never drops root, that your Kubernetes manifest exposes port 22, or that your pod spec is missing resource limits. Those aren’t vulnerabilities in someone else’s code. They’re decisions you made in your own configuration files, and they need a different kind of scanner entirely.

Configuration Mistakes Are Not the Same as Vulnerabilities

A CVE is a flaw in software someone else wrote — a buffer overflow in OpenSSL, a deserialization bug in a logging library. You didn’t create it, and you can’t fix it directly; you can only patch or replace the dependency. A misconfiguration is different. It’s a flaw in the architecture you designed: running a container as root, skipping a HEALTHCHECK, mounting the Docker socket into a pod that doesn’t need it. Nobody upstream can patch that for you, because it isn’t a bug — it’s a choice, usually made under deadline pressure, that nobody circled back to fix.

This is where Checkov comes in. Checkov is a static analysis tool, written in Python, that parses your Dockerfiles, Kubernetes manifests, and Terraform code into a graph and checks it against a library of security policies — many of them aligned with CIS Benchmarks. It isn’t looking for known-bad software. It’s looking for known-bad patterns: a USER instruction that never runs, a Deployment spec with allowPrivilegeEscalation left at its default, a container with no HEALTHCHECK at all. Each policy has a stable ID — CKV_DOCKER_8, CKV_K8S_20, and so on — so a failure in a CI log points to exactly one rule, not a vague “security issue found.”

Think of it this way: Trivy tells you if the house has termites. Checkov tells you if you left the front door unlocked. You need both, and they catch entirely different classes of problems.

The Dockerfile Mistakes Checkov Catches

Most of what Checkov flags in a Dockerfile falls into a handful of repeat offenders. None of them are exotic — they’re the kind of thing that happens because a Dockerfile got written fast, worked, and nobody touched it again.

Running as Root

Docker containers run as root by default unless you explicitly tell them not to. That’s convenient during development — no permission errors, no fiddling with UID/GID — and it’s exactly why so many Dockerfiles never bother to change it. The problem shows up the moment that container is compromised. A process running as root inside the container is one kernel exploit or misconfigured volume mount away from root on the host. Checkov flags this with CKV_DOCKER_8 — “Ensure the last USER is not root” — and its companion, CKV_DOCKER_3 — “Ensure that a user for the container has been created.”

A scan against a Dockerfile that never sets a user looks like this:

Check: CKV_DOCKER_3: "Ensure that a user for the container has been created"
	FAILED for resource: Dockerfile
	File: /Dockerfile:1-9

Check: CKV_DOCKER_8: "Ensure the last USER is not root"
	FAILED for resource: Dockerfile
	File: /Dockerfile:1-9

Two failures for one missing line. That’s the point — Checkov doesn’t just tell you something’s wrong, it tells you which specific rule you broke and gives you a documentation link to go read about it.

Exposed Ports and Missing Healthchecks

Two more habits Checkov calls out constantly: exposing port 22 in a container image, and skipping HEALTHCHECK entirely. SSH inside a container is almost never intentional — it’s usually copy-pasted from an old VM-based deployment script and never removed, and it gives an attacker who lands in the container a persistent way back in. Checkov catches it as CKV_DOCKER_1. A missing HEALTHCHECK is a quieter problem: without it, your orchestrator only knows a container is “running,” not whether the process inside it has actually hung. Checkov flags that gap as CKV_DOCKER_2.

Neither failure is dramatic on its own. Together, they describe a container that nobody hardened past “it builds.”

Running Checkov Against Your Dockerfile

Enough theory — here’s what it looks like to actually run this. Checkov ships as a Python package, so installation is one command:

pip install checkov

Point it at a directory and it will auto-detect every framework it finds — Dockerfiles, Kubernetes manifests, Terraform, CloudFormation, whatever’s there. To scan just the Dockerfile misconfigurations in your project:

checkov -d . --framework dockerfile

Against a bare-bones Node.js Dockerfile with no USER, no HEALTHCHECK, and no non-root setup, the output looks something like this:

       _               _
   ___| |__   ___  ___| | _______   __
  / __| '_ \ / _ \/ __| |/ / _ \ \ / /
 | (__| | | |  __/ (__|   < (_) \ V /
  \___|_| |_|\___|\___|_|\_\___/ \_/

By Prisma Cloud | version: 3.2.x

dockerfile scan results:

Passed checks: 12, Failed checks: 3, Skipped checks: 0

Check: CKV_DOCKER_2: "Ensure that HEALTHCHECK instructions have been added to container images"
	FAILED for resource: Dockerfile
	File: /Dockerfile:1-9
	Guide: https://docs.prismacloud.io/en/enterprise-edition/policy-reference/docker-policies/docker-policies

Check: CKV_DOCKER_3: "Ensure that a user for the container has been created"
	FAILED for resource: Dockerfile
	File: /Dockerfile:1-9

Check: CKV_DOCKER_8: "Ensure the last USER is not root"
	FAILED for resource: Dockerfile
	File: /Dockerfile:1-9

That’s the whole loop: passed/failed counts up top, then every failure named by check ID with a file location and a link to the fix. No guessing which line broke, no digging through a wiki to figure out what “insecure configuration” means this time.

Here’s the fix — a before-and-after for the exact Dockerfile that produced that output:

Before:

FROM node:20-slim

WORKDIR /app
COPY . .
RUN npm install

EXPOSE 3000
CMD ["node", "server.js"]

After:

FROM node:20-slim

# Create a dedicated, unprivileged user instead of inheriting root from the base image
RUN groupadd -r appgroup && useradd -r -g appgroup appuser

WORKDIR /app
COPY --chown=appuser:appgroup . .
RUN npm install

# Let the orchestrator detect a hung process, not just a dead one
HEALTHCHECK --interval=30s --timeout=3s CMD curl -f http://localhost:3000/health || exit 1

EXPOSE 3000

# Every instruction after this line — including CMD — runs as appuser, not root
USER appuser

CMD ["node", "server.js"]

That’s CKV_DOCKER_3, CKV_DOCKER_8, and CKV_DOCKER_2 all resolved with four added lines. None of it changes what the app does. It just closes the gap between “runs” and “runs safely.”

Wiring Checkov into GitHub Actions

Running Checkov locally catches problems before you commit. Running it in CI catches the problems you forgot to run it for. Bridgecrew (the company behind Checkov) maintains an official GitHub Action, so wiring it in doesn’t require writing your own Python invocation step:

name: IaC Security Scan

on:
  pull_request:
    branches: [main]

jobs:
  checkov:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Run Checkov
        uses: bridgecrewio/checkov-action@v12
        with:
          directory: .
          framework: dockerfile,kubernetes
          soft_fail: false   # a failed check fails the build — that's the entire point
          output_format: cli

Notice the use of a pinned version tag @v12 instead of @master. Pinning ensures upstream breaking changes don’t take your CI pipeline down on a Friday afternoon. Set soft_fail: false and a failing check blocks the merge, the same way a failing unit test does. That’s a deliberate choice, not a default to leave in place blindly — if your team isn’t ready to gate merges on it yet, start with soft_fail: true to see the noise level before you turn it into a hard stop.

Checkov CI/CD Integration

This diagram illustrates how Checkov gates pull requests by scanning Infrastructure as Code (IaC) before it is merged.

flowchart LR A[Pull Request] --> B[GitHub Actions] B --> C[Checkout Code] C --> D[Run Checkov] subgraph Checkov Analysis D --> E{Policies Evaluated} E -->|CKV_DOCKER_8| F[Root User Check] E -->|CKV_DOCKER_2| G[Healthcheck] end F & G --> H{Any Failures?} H -->|Yes| I[Block PR\nReport Inline] H -->|No| J[Allow Merge]

Visual Notes:

  • A failure explicitly points to the violated CIS benchmark/policy ID.
  • The process is entirely static and requires no built image to analyze.

Best Practices: When (and How) to Skip a Check

Not every failure is a real problem. Maybe you’re building a debugging image that legitimately needs SSH access for a support tool, or a base image that intentionally runs a step as root before dropping privileges later. Checkov supports inline skips for exactly this:

# checkov:skip=CKV_DOCKER_1: SSH required for the legacy support tunnel on this internal-only image
EXPOSE 22

The reason after the colon isn’t optional in practice, even though Checkov won’t enforce it — a skip with no explanation is a landmine for whoever reads this Dockerfile in eight months. Treat every skip as a documented risk acceptance, not a way to make a red X turn green.

Do:

  • Enforce Checkov on every Dockerfile and Kubernetes manifest change before merge.
  • Use scoped, inline skips with a reason instead of disabling a check globally in a config file.
  • Review skipped checks periodically — “temporary” exceptions have a way of outliving the ticket that justified them.

Don’t:

  • Disable CKV_DOCKER_8 project-wide because one image needs root. Skip it on that one image, not every image your team ships.
  • Merge a failing scan “just this once.” That’s how the root-user pod from the opening of this article got to production in the first place.

Troubleshooting Checkov in the Real World

Checkov fails the build on a configuration you did on purpose. This is the most common friction point, and it’s solved above: use an inline # checkov:skip=CKV_DOCKER_1: reason comment scoped to the specific line, not a blanket exclusion in .checkov.yaml. A scoped skip stays visible in code review. A global exclusion quietly disables the check for every Dockerfile in the repo, forever, without anyone noticing.

Checkov reports false positives against Helm charts. This one catches people off guard. Checkov parses Kubernetes YAML, not Helm templating syntax — a manifest full of {{ .Values.replicaCount }} isn’t valid YAML until Helm renders it. Run helm template ./chart -f values.yaml > rendered.yaml first (ensuring you pass the required values files), then point Checkov at the rendered output, not the chart’s source templates. Scanning raw Helm templates will produce parse errors that look like security failures but are really just Checkov choking on Go template syntax.

Checkov flags dozens of pre-existing issues the moment you turn it on. Enabling a scanner against a codebase that’s never had one is going to be loud — that’s not a bug, it’s the scanner doing its job on years of accumulated defaults. Rather than fixing (or skipping) everything in one PR, run checkov -d . --create-baseline to snapshot the current failures into a baseline file. Future scans only fail on new violations, so you can gate the pipeline today and clean up the backlog on its own timeline instead of blocking every PR on a wall of unrelated red.

Where This Leaves You

A clean vulnerability scan tells you the software inside your container isn’t carrying a known CVE. It says nothing about whether you built that container to run as root, left SSH exposed, or forgot a healthcheck — and those are decisions Checkov catches that a CVE scanner structurally can’t. Add it to your local workflow with checkov -d ., wire the bridgecrewio/checkov-action into your pull request checks, and use inline skips — with reasons — for the exceptions that are actually intentional rather than just unexamined.

Run checkov -d . against whatever repo you have open right now. You’ll probably find at least one thing you forgot about.

This article is part of The Ultimate Guide to Shift-Left Security for Docker Containers, which lays out the full pipeline this series builds toward. If you haven’t set up local linting yet, start with Catching Mistakes Early: Setting Up Pre-Commit Hooks for Docker so these checks run before you even commit. Pair Checkov’s configuration scanning with Integrating Trivy Vulnerability Scanning into GitHub Actions for CVE coverage, and once both are producing findings, Beating Alert Fatigue: How to Filter and Prioritize Vulnerabilities covers how to keep that output from turning into noise nobody reads. For the Dockerfile itself, Hardening from the Ground Up: Using Distroless Base Images takes the non-root pattern from this article a step further by removing the shell and package manager an attacker would want in the first place.

Sources