OnCallReady

Lesson 14.10 · Terraform in Real Life & the Associate Exam · 29 min read

Security scanning: checkov on code and on plans

In plain words

A building inspector walks through a new house with a checklist: are the stair rails high enough, is there a smoke detector, is the back door lockable? The inspector does not judge whether the house is beautiful or whether the rooms are in the right place. Each item on the list has a number, and sometimes the owner gets a written exception: "no rail here, it is a ground-floor deck, approved by the council".

checkov is that inspector for Terraform. It checks each resource against hundreds of rules with IDs like CKV_AZURE_59 (storage accounts disallow public access) or CKV_AZURE_10 (no SSH from the internet), and exits 1 when something fails. A #checkov:skip=ID:reason comment is the written exception. Scanning the plan JSON, not just the code, lets it see the real values from tfvars.

The problem: valid code that is unsafe

tflint (14.6) asks "is this good Terraform?". It is perfectly happy with a storage account anyone on the internet can read, a firewall rule that opens SSH (port 22, 1.1) to the whole world, or a password that never expires. Those are not Terraform mistakes - they are security mistakes, and they are the ones that end up in the news.

Nobody can remember hundreds of such settings for every resource. So teams write the rules down as code and let a tool check every change. That is called policy as code: security rules ("storage must not be public") expressed as machine-checked rules that run automatically, instead of relying on a reviewer remembering them.

What you need to know already: TLS versions (9.15), ports and SSH (9.8, 1.1), CIDR ranges like 0.0.0.0/0 (8.3), exit codes (1.7), terraform plan -out and terraform show -json (12.24), variables and -var-file (12.5), the cluster and Key Vault from 14.2, jq (7.11).

The tools

A security scanner reads infrastructure code and reports settings that break known security rules. The common ones:

checkov     Prisma Cloud (Palo Alto), Python. Terraform, plans, Bicep, ARM, cluster YAML, Dockerfiles...
            checks named CKV_<PROVIDER>_<N>, graph checks CKV2_...
trivy       Aqua Security. Absorbed tfsec in 2023; "trivy config ." scans IaC
            (tfsec IDs like azure-storage-default-action-deny live on as AVD-AZU-... IDs)
tfsec       deprecated in favour of trivy, still seen in older pipelines
OPA/Conftest, Sentinel   write your own policies (Sentinel is HCP Terraform's)

In plain words:

The lab ships checkov (simulator: a subset of its Azure checks, with their real IDs and titles).

Running checkov

checkov -d . scans every file in the current directory (-d = directory):

# on the configuration of the checkov mission, before hardening
checkov -d . --compact
terraform scan results:

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

Check: CKV_AZURE_44: "Ensure Storage Account is using the latest version of TLS encryption"
	PASSED for resource: azurerm_storage_account.logs
	File: /main.tf:12-20

Check: CKV_AZURE_59: "Ensure that Storage accounts disallow public access"
	FAILED for resource: azurerm_storage_account.logs
	File: /main.tf:12-20

Check: CKV_AZURE_10: "Ensure that SSH access is restricted from the internet"
	FAILED for resource: azurerm_network_security_group.jump
	File: /network.tf:1-17
...
echo $?
1

How to read it:

Without --compact, a failed check also prints the offending code block.

Exit status is 1 when any check fails - which is what stops a pipeline. --soft-fail reports everything but always exits 0: the usual way to introduce a scanner without blocking everyone on day one.

Useful options:

-d DIR / -f FILE          what to scan
--framework terraform     only Terraform (skip Dockerfiles, YAML... in the same repo)
-c CKV_AZURE_59,...       run only these checks
--skip-check CKV_AZURE_35 run everything except these
-o json | sarif | junitxml   machine-readable results (SARIF for GitHub code scanning)
--quiet                   failed checks only
--compact                 no code blocks
optionmeans
-d DIR / -f FILEscan a directory / one file
--framework terraformonly look at Terraform files
-c (or --check) ID,IDrun only these checks
--skip-check IDrun everything except these (for the whole run - see below why that is risky)
-o json / sarif / junitxmloutput formats for other tools. SARIF and JUnit XML are standard result formats that GitHub and CI servers can display
--quietprint only the failed checks
--compactdo not print code blocks

The checks you will meet on Azure

storage account
  CKV_AZURE_3    Ensure that 'enable_https_traffic_only' is enabled
  CKV_AZURE_35   Ensure default network access rule for Storage Accounts is set to deny
  CKV_AZURE_44   Ensure Storage Account is using the latest version of TLS encryption
  CKV_AZURE_59   Ensure that Storage accounts disallow public access
  CKV_AZURE_190  Ensure that Storage blobs restrict public access
key vault
  CKV_AZURE_109  Ensure that key vault allows firewall rules settings
  CKV_AZURE_110  Ensure that key vault enables purge protection
  CKV_AZURE_189  Ensure that Azure Key Vault disables public network access
key vault secret
  CKV_AZURE_41   Ensure that the expiration date is set on all secrets
  CKV_AZURE_114  Ensure that key vault secrets have "content_type" set
AKS
  CKV_AZURE_4    Ensure AKS logging to Azure Monitoring is Configured
  CKV_AZURE_5    Ensure RBAC is enabled on AKS clusters
  CKV_AZURE_6    Ensure AKS has an API Server Authorized IP Ranges enabled
  CKV_AZURE_7    Ensure AKS cluster has Network Policy configured
  CKV_AZURE_115  Ensure that AKS enables private clusters
  CKV_AZURE_141  Ensure AKS local admin account is disabled
  CKV_AZURE_170  Ensure that AKS use the Paid Sku for its SLA
  CKV_AZURE_171  Ensure AKS cluster upgrade channel is chosen
network security groups
  CKV_AZURE_9    Ensure that RDP access is restricted from the internet
  CKV_AZURE_10   Ensure that SSH access is restricted from the internet
  CKV_AZURE_160  Ensure that HTTP (port 80) access is restricted from the internet

The words in those titles, in plain terms:

What each check wants, in configuration:

resource "azurerm_storage_account" "logs" {
  # ...
  min_tls_version                 = "TLS1_2"   # CKV_AZURE_44
  public_network_access_enabled   = false      # CKV_AZURE_59
  allow_nested_items_to_be_public = false      # CKV_AZURE_190
  network_rules {
    default_action = "Deny"                    # CKV_AZURE_35
    bypass         = ["AzureServices"]
  }
}

resource "azurerm_key_vault" "this" {
  # ...
  purge_protection_enabled      = true         # CKV_AZURE_110
  public_network_access_enabled = false        # CKV_AZURE_189
  network_acls {
    default_action = "Deny"                    # CKV_AZURE_109
    bypass         = "AzureServices"
  }
}

resource "azurerm_key_vault_secret" "db" {
  # ...
  content_type    = "password"                 # CKV_AZURE_114
  expiration_date = "2027-03-31T00:00:00Z"     # CKV_AZURE_41
}

(expiration_date uses the RFC 3339 date format: date, T, time, Z for UTC.)

When is an SSH rule "open to the internet"?

An NSG rule (14.2) fails CKV_AZURE_10 when it is Inbound, Allow, TCP (or * = any protocol), covers port 22, and has a source of *, Internet, 0.0.0.0/0 or similar - i.e. "any address". The fix is a real source range: the subnet of your bastion (also called a jump box: one hardened machine that admins log in to first, and hop from there), or your VPN's range. Or Azure Bastion (Azure's managed jump box) and no SSH rule at all.

Not every check is about security

Some checks are about cost and operations. CKV_AZURE_170 (the paid SLA tier) is right for prod and noise for dev - you do not pay for an uptime promise on a cluster only engineers use. That is what skips are for.

Suppressing a check - with a reason

When a finding is a deliberate, justified exception, you suppress (skip) it with a comment inside the resource block:

resource "azurerm_storage_account" "site" {
  #checkov:skip=CKV_AZURE_59:Static website, public by design (ARCH-212)
  #checkov:skip=CKV_AZURE_190:Static website, public by design (ARCH-212)
  name = "stsitepublic"
  # ...
}

checkov then reports it as skipped, with your reason:

Check: CKV_AZURE_59: "Ensure that Storage accounts disallow public access"
	SKIPPED for resource: azurerm_storage_account.site
	Suppress comment: Static website, public by design (ARCH-212)

Scanning the plan, not just the code

A scan of the directory is a static scan: it reads the code and fills in variables from their defaults and from files Terraform loads on its own (terraform.tfvars, *.auto.tfvars). If prod's pipeline passes -var-file= envs/prod.tfvars with public_access = true, a scan of the directory never sees that value.

A plan scan sees the final values, because the plan contains them:

terraform plan -out=tfplan
terraform show -json tfplan > tfplan.json
checkov -f tfplan.json
  1. terraform plan -out=tfplan - make the plan (with whatever var files the pipeline uses) and save it to the file tfplan.
  2. terraform show -json tfplan > tfplan.json - convert the saved plan (a binary file) to JSON.
  3. checkov -f tfplan.json - scan that one file (-f = file).
terraform_plan scan results:

Passed checks: 7, Failed checks: 1, Skipped checks: 0

Check: CKV_AZURE_59: "Ensure that Storage accounts disallow public access"
	FAILED for resource: azurerm_storage_account.exports

Note the first line: terraform_plan scan results - it knows it read a plan.

Pipelines typically run both: a fast static scan on every push, and a plan scan on the exact plan that will be approved.

Where scanning sits

fmt -check -> validate -> tflint -> checkov -d .     static, seconds, on every push
plan -out -> show -json -> checkov -f tfplan.json    the real values, before approval
approval -> apply tfplan

A scanner is a floor, not a design review. It knows hundreds of individual settings and nothing about your architecture. It will not tell you that a private connection is missing, or that a role assignment (14.2) is granted on the whole subscription instead of one resource - reviewers still have to read the plan.

Reading a checkov result, once more

$ checkov -d . --compact
terraform scan results:

Passed checks: 11, Failed checks: 2, Skipped checks: 1

Check: CKV_AZURE_44: "Ensure Storage Account is using the latest version of TLS encryption"
	FAILED for resource: azurerm_storage_account.exports
	File: /main.tf:9-24

Check: CKV_AZURE_10: "Ensure that SSH access is restricted from the internet"
	FAILED for resource: azurerm_network_security_group.jump
	File: /main.tf:40-58

Check: CKV_AZURE_170: "Ensure that AKS use the Paid Sku for its SLA"
	SKIPPED for resource: azurerm_kubernetes_cluster.dev
	Suppress comment: Dev cluster, no uptime SLA needed

Useful variations:

checkov -d . --quiet --compact                   # failures only
checkov -d . --check CKV_AZURE_44,CKV_AZURE_59   # only these
checkov -d . -o json | jq '.summary'             # machine-readable summary
checkov -f tfplan.json                           # the resolved plan
checkov -d . --soft-fail                         # report but exit 0 (for a rollout period)

Your own rules

Built-in checks cover each resource's security settings. Your organisation's own rules - "every storage account has an owner tag", "only the westeurope and northeurope regions" - need custom policies. checkov accepts YAML files from a folder:

# policies/owner-tag.yaml
metadata:
  id: "CKV2_ACME_1"
  name: "Storage accounts must have an owner tag"
  category: "GENERAL_SECURITY"
definition:
  cond_type: "attribute"
  resource_types:
    - "azurerm_storage_account"
  attribute: "tags.owner"
  operator: "exists"

Read it as: a check with this ID and name; for every azurerm_storage_account, the attribute tags.owner must exist. Run it with:

checkov -d . --external-checks-dir policies

--external-checks-dir adds the checks from that folder to the built-in ones.

(The lab's checkov runs a subset of the built-in Azure checks; custom policies are not modelled - simulator.) The same rules can also live in OPA's language (Rego, run by Conftest over the plan JSON), in Sentinel in HCP Terraform, or in Azure Policy - rules enforced by Azure itself on every change, however it is made. Azure Policy is the only one of these that also catches changes someone clicks in the Azure portal (the web console).

What you can now do:

Why it helps

Security scanning is a standard gate in every platform pipeline, and you will triage its failures weekly. Situations: a PR adds a storage account and fails CKV_AZURE_44 and CKV_AZURE_59; you know the fix is min_tls_version = "TLS1_2" and public_network_access_enabled = false, not a skip. A static scan passes but prod's tfvars sets public access on; only the plan scan catches it. A dev cluster fails CKV_AZURE_170 for the Free SKU, and a documented per-resource skip with a ticket reference is the right answer. Interviewers ask about policy as code, and the answer "scanners are a floor, not a design review" shows seniority.

Commands in this lesson

checkov

FAQ

Why scan the plan as well as the code?

A static scan reads code with variable defaults. If envs/prod/terraform.tfvars sets public_access = true, checkov -d . may never see it. Scanning terraform show -json tfplan with checkov -f tfplan.json sees the final resolved values for that environment. Pipelines typically run a fast static scan on every push and a plan scan on the exact plan that will be approved.

Is it OK to skip a checkov check?

Yes, when it is a justified exception, documented where it applies: #checkov:skip=CKV_AZURE_59:Static website, public by design (ARCH-212) inside the resource block. The reason stays visible in every run and reviewers can challenge it. Avoid --skip-check for the whole run, which turns the policy off for every resource, and never skip without a reason.

checkov, trivy or tfsec: which one?

checkov (Palo Alto's Prisma Cloud, Python) and trivy (Aqua Security, which absorbed tfsec in 2023) are the two common choices, both scanning Terraform, plans, Dockerfiles and other config files. tfsec is deprecated in favour of trivy config. The lab ships checkov. Teams pick one; the concepts (check IDs, skips, SARIF output, plan scanning) are the same.

What does checkov not catch?

Architecture. It knows hundreds of individual settings but nothing about your design: it will not notice a missing private endpoint, a role assignment scoped to the whole subscription, a subnet too small for the cluster, or a data flow that crosses a trust boundary. Reviewers still read the plan. Organisation rules like mandatory owner tags need custom policies (YAML for checkov, Rego, Sentinel, or Azure Policy).

How do I introduce a scanner without blocking everyone?

Start with --soft-fail: results are reported but the exit code is 0. Fix the existing findings in focused PRs, add documented skips for accepted exceptions, then remove --soft-fail so new failures block. SARIF output (-o sarif) feeds GitHub code scanning so findings appear in the PR.

In an interview Junior

What is policy as code, and how would you use it with Terraform?

Policy as code means security and organisation rules ("storage must not be public", "no SSH open to the internet") written as machine-checked rules that run on every change, instead of hoping a reviewer remembers them.

With Terraform, a scanner like checkov:

Custom rules (an owner tag on everything) go in your own policies; Sentinel or OPA do the same in other tools.

Also asked: A checkov scan fails on a storage account. How do you handle it? · Why scan the plan as well as the code? · What can a security scanner not tell you about a change?

Practise this lesson in the terminal Free, in your browser - a real Ubuntu terminal to try it in, with missions that check your work.