Enforcing in CI/CD

Gate without alert fatigue.

Advanced14 min · lesson 11 of 12

A smoke alarm with the battery taken out is a plastic disc on your ceiling. It looks like protection. It does nothing. Checkov has the same problem the moment it only ever runs on somebody's laptop. The job of the tool is to stand between a change and production: scan every pull request (the proposed code change a teammate reviews before it merges), stop the merge when something genuinely risky shows up, and put the result where developers already look. Everything hard about this sits in the word risky. Block on new problems and on a short list of checks you named on purpose. Report the rest. Never dump three hundred mixed findings on someone who fixed a typo.

This lesson wires Checkov into CI/CD (continuous integration and continuous delivery, the automated pipeline that builds, tests and ships your code). You get a GitHub Actions job, a SARIF upload (Static Analysis Results Interchange Format, the standard file scanners write so a code host can show findings inline), baselines, config files, and the tuning that keeps a gate switched on instead of quietly deleted. Settle one thing up front: gating on severity words with hard-fail-on HIGH,CRITICAL only works when Checkov is talking to Prisma Cloud. Open-source pipelines gate on check IDs and baselines instead.

Wiring the scan into GitHub Actions

bridgecrewio/checkov-action is a thin wrapper around the same CLI (command line interface, the checkov command you have been typing) you already know. Hand it directory, config_file, baseline and output_format sarif, tell it where to write with output_file_path, and it does what your terminal did. Pin the action to a version. Then upload the SARIF file so findings land in GitHub's code scanning tab, next to the diff, where a reviewer will actually read them instead of scrolling a log.

terminal
# .github/workflows/iac.yml excerpt
- uses: bridgecrewio/checkov-action@v12
with:
directory: .
config_file: .checkov.yaml
baseline: .checkov.baseline
output_format: sarif
output_file_path: results
- uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: results/results_sarif.sarif
output
Checkov scan completed
Passed: 812 Failed: 2 Skipped: 24
Uploading results
results/results_sarif.sarif
# PR Security tab shows CKV_AWS_21 on aws_s3_bucket.new_uploads

The gate people keep: a baseline plus named check IDs

Open-source Checkov decides pass or fail from two things: which checks failed, and which check IDs you listed under hard-fail-on in .checkov.yaml. There are no built-in severity bands to gate on unless you pass --bc-api-key, the API key (application programming interface credential) that links the scanner to the paid Prisma Cloud service. So your list of check IDs is your severity system. Pair --baseline with hard-fail-on for the handful of checks that must never reach the main branch, and soft-fail-on for the ones you are still rolling out. --compact squeezes each finding down to a couple of lines so the log stays readable at five o'clock on a Friday.

terminal
$ checkov -d . --config-file .checkov.yaml \
--baseline .checkov.baseline \
--compact --quiet
output
Passed checks: 812, Failed checks: 2, Skipped checks: 24
Check: CKV_AWS_20: "S3 Bucket has public access block"
FAILED for resource: aws_s3_bucket.new_uploads
# exit=1 — merge blocked; pre-existing baselined failures silent
Severity-aware CI gate
1Scan every PR
checkov-action or CLI
2New vs baseline
--baseline diffs debt
3hard-fail-on IDs
block merge on listed checks
4Upload SARIF
findings in Security tab
Block on new findings and the check IDs you named; report everything else without stopping the merge.

The plan scan in the merge queue

Run the scan at two speeds. A source scan on every push, because it is fast and developers want an answer inside a minute. A plan scan before merge or apply, because that one sees the real values Terraform is about to use. The pipeline runs terraform plan, converts it to tfplan.json (the same trick from cv-plan), scans it with the terraform_plan framework and uploads SARIF. One catch is worth writing on the wall: inline skip comments live in your Terraform source, so a plan file contains none of them. Keep the skip-check list in .checkov.yaml lined up with what you expect the plan gate to let through.

terminal
$ terraform plan -out=tfplan.bin -input=false
$ terraform show -json tfplan.bin > tfplan.json
$ checkov -f tfplan.json --framework terraform_plan \
--baseline .checkov.baseline -o sarif --output-file-path results
output
terraform_plan scan results:
Passed checks: 156, Failed checks: 1
Wrote SARIF output to results/results_sarif.sarif

Why gates get switched off

The way this dies is boring and predictable. The gate makes so much noise that people stop reading it, and then someone removes it. Your counters are all about signal. Baseline the legacy debt so it stops shouting. Put check IDs in tiers so blocking actually means something. Keep suppressions narrow and attached to a ticket. Make sure the SARIF entry carries the remediation link so a developer can fix the thing in ten minutes. One blocking finding that is real and fixable buys you goodwill. Three hundred mixed findings on a routine change buys you a pull request that deletes the step.

terminal
$ checkov -d . --baseline .checkov.baseline --soft-fail
$ echo exit=$?
output
Failed checks: 2
# reported but exit=0 during grace period
exit=0
A gate that blocks everything gets switched off
Most scanning programs die in the week the gate starts blocking ordinary work. Tune first, tighten later: baseline the old debt, name the check IDs that block, make every SARIF finding actionable, then raise the bar as the signal gets cleaner. A slightly loose gate teams keep enabled protects far more than a strict one they disable on a bad Tuesday.

The same gate in GitLab CI

Outside GitHub nothing about the scan changes. Install a pinned checkov with pip, run checkov -d . --config-file .checkov.yaml --baseline .checkov.baseline, and pick -o junitxml so GitLab renders failures in its test report panel, or SARIF where the platform supports it. The gate logic is identical. The only part you rewrite is the YAML (YAML Ain't Markup Language, the indented config format both platforms use) that decides what a non-zero exit code does to the pipeline.

terminal
$ checkov -d . --baseline .checkov.baseline \
--output junitxml --output-file-path results
$ echo exit=$?
output
Wrote JUNIT XML output to results/results_junitxml.xml
exit=1

Artefacts per commit

Upload results_json.json and results_sarif.sarif as build artefacts, keyed by the commit SHA (the long fingerprint Git gives every commit). Months later, when a security champion or the metrics job in cv-program wants the failed count over time, they read the stored files instead of re-running scans nobody has the compute for. One shape gotcha: scan several frameworks at once and the JSON (JavaScript Object Notation, the machine-readable output format) comes back as a list of reports rather than one object, so pipe it through jq 'if type=="array" then .[].summary else .summary end' before archiving.

The action takes the same flags as the CLI, with underscores where the command line has dashes: config_file, baseline, framework, soft_fail_on, hard_fail_on. That last one accepts check IDs, or severities if you have the platform key. Pin it to @v12 or to a digest. A floating @master lets the action change under you overnight, which breaks reproducibility the same way an unpinned pip install does.

Split the work across two jobs. The source scan runs on every push and stays fast, so pull request feedback arrives while the author is still looking at the screen. The plan scan runs on the merge queue or nightly, costs more, and is the authoritative check before anything is applied. Fail the merge queue job on plan findings even when the push job only warned. Developers learn very quickly which stage is cosmetic and which one stops them. If your dashboard deduplicates by commit, upload SARIF from both.

Branch protection is part of the design, not an afterthought. The required check in GitHub has to name the checkov job. If that job is optional, or allowed to fail, you have built a report generator with a red icon on it. Nothing is being gated.

In a monorepo, path filters keep the job off pull requests that never touch infrastructure: run Checkov when infra/** changes. Cheaper, quieter, and one trap. The filter has to include modules/ and org-policies/ as well, or the day someone edits the shared policy pack is the day the scan meant to enforce it never runs. Put CODEOWNERS (the GitHub file naming which team must approve changes to a path) on .github/workflows/iac.yml so nobody can quietly delete the step on a Friday afternoon.

Output format is chosen by whoever reads it, not by the scan. SARIF goes to the GitHub Security tab. JUnit XML (Extensible Markup Language, the format test runners emit) feeds the test panels in GitLab and Jenkins. JSON feeds your own dashboard. Same binary, same flags, three different readers.

Pin checkov==3.2.451 in the pipeline and ask people to install that same version locally. When the runner has a newer Checkov than the laptop, the runner knows about checks the laptop has never heard of, and you get the works-locally support ticket that eats an afternoon of somebody's week.

A workflow you can copy

Nothing exotic in it. Check out the repository, install a pinned Checkov, run the scan with your config file and baseline, upload the SARIF with if: always(), and let the exit code decide whether the merge is allowed. The required check in branch protection points at this job by its id. A bot that comments failed CKV IDs on the pull request is a pleasant extra. The Security tab is the part you cannot skip, because people fix what they can see beside their own code.

terminal
jobs:
checkov:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: pip install checkov==3.2.451
- run: |
checkov -d . --config-file .checkov.yaml \
--baseline .checkov.baseline \
-o sarif --output-file-path results
- uses: github/codeql-action/upload-sarif@v3
if: always()
with:
sarif_file: results/results_sarif.sarif
output
Checkov scan completed — Failed: 2
Uploaded SARIF — 2 findings in Security tab
Job exit code: 1 — merge blocked

Turn it on in this order and you will not have to argue with anyone. Run with --soft-fail first and watch the failed count for a week. Baseline what the team agrees to carry as debt. Add the small hard-fail-on list of checks that must never merge. Only then make the job a required check. Each step buys a bit more strictness with evidence instead of opinion.

A pipeline understands exactly one thing from a scanner: the exit code. Zero means carry on, anything else means stop. That is why every example here ends with echo exit=$?, and why --soft-fail turns a failing scan into a passing job without hiding a single finding. It is also why the upload step carries if: always(). Without that, a failing scan ends the job early and the SARIF never reaches the Security tab, so the one run with something to say is the run nobody can read.

Check that the two paths agree. --output-file-path results tells Checkov to write results/results_sarif.sarif, and sarif_file: results/results_sarif.sarif tells the upload action where to look. Change one and forget the other and the upload step has no file to send, so the Security tab stays empty while everyone assumes reporting is working.

When the gate blocks a change you believe is safe, you have three honest moves: fix the resource, add a narrow suppression with a stated reason and a ticket, or record the finding in the baseline as debt with an owner. Deleting the step is not on the list, and CODEOWNERS is what turns that idea into a conversation in review rather than a commit nobody noticed.

Try this

Do this: run the gate on your own machine with the exact flags the pipeline will use, config file, baseline, SARIF output and all, then read the exit code yourself. Run it once more with --soft-fail so you can feel the difference between a finding that reports and a finding that blocks, before you make the job required for everyone else.

terminal
$ checkov -d . --config-file .checkov.yaml --baseline .checkov.baseline --compact --quiet; echo exit=$?
$ checkov -d . --baseline .checkov.baseline --soft-fail; echo soft_exit=$?
$ checkov -d . --baseline .checkov.baseline -o sarif --output-file-path results
$ ls results/results_sarif.sarif
output
Passed checks: 812, Failed checks: 2, Skipped checks: 24
exit=1
Failed checks: 2
soft_exit=0
Wrote SARIF output to results/results_sarif.sarif

Takeaway

Remember: a gate keeps its welcome by blocking new, fixable risk and staying quiet about debt the team already agreed to carry. Three pieces do that work: the baseline, an explicit hard-fail-on list of check IDs, and SARIF in the pull request Security tab. Severity-only lists like HIGH,CRITICAL fail open in open-source mode with no platform key, so gate on the CKV IDs you chose deliberately.

Keep the fast source scan on every push and the plan scan on the merge queue doing different jobs, and put CODEOWNERS on the workflow file so the step survives a bad release week. Next (cv-program) you take this same config, the same check ID tiers and the same archived metrics across every repository, so a green pipeline means the same thing in every team.

Quick check
01Your Checkov step runs in GitHub Actions and the job goes red. What actually caused that?
Incorrect — No. The upload only reports; the scan's own exit code is what turns the job red.
Correct — Yes. The exit code is the entire gate signal, and --soft-fail is how you opt out of it.
Incorrect — No. Severity bands need the platform key, and without one any failed check already exits non-zero.
Incorrect — No. GitHub has no opinion about CKV IDs; your workflow and branch protection decide.
02You already fail the job on findings. Why bother uploading SARIF as well?
Incorrect — No. SARIF is an output format; parsing happens whatever format you ask it to print.
Correct — Yes. The exit code blocks the merge, and the SARIF tells the developer which line to fix.
Incorrect — No. The baseline decides what is allowed to fail; SARIF decides where findings are displayed.
Incorrect — No. It is a reporting format, not a filter.
03A pull request that only edits a README goes red: Failed checks: 2, exit=1. Both failures are on legacy buckets nobody has touched in two years, and the team is asking you to delete the step. What do you do next?
Incorrect — No. A blanket skip switches those checks off everywhere, including on the new resource somebody adds next month.
Correct — Yes. The baseline silences accepted legacy debt while anything new still stops the merge.
Incorrect — No. Soft-fail is a grace period while you tune. Left on forever it is a report generator, not a gate.
Incorrect — No. A job that is optional or allowed to fail gates nothing, which is the exact failure this lesson is about.

Related