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:
- checkov - an open-source scanner (written in Python) that understands Terraform code, Terraform plans, Dockerfiles (10.8) and other config formats (Bicep and ARM are Azure's own template languages). Each rule is a check with an ID like
CKV_AZURE_59.CKV2_...IDs are "graph" checks that look at how two resources relate. - trivy - another scanner that does the same job (
trivy config .). It swallowed an older tool, tfsec, which you still see in old pipelines. - OPA (Open Policy Agent) with Conftest, and Sentinel - not scanners with built-in rules, but languages for writing your own rules. Sentinel is HashiCorp's, used in HCP Terraform (14.28).
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:
terraform scan results:- what kind of input it scanned.Passed checks: 9, Failed checks: 3, Skipped checks: 0- the summary.- Then one block per check result: ·
Check: CKV_AZURE_59:- the check ID. It never changes, so you can search for it and refer to it. ·"Ensure that Storage accounts disallow public access"- the title, written as the requirement. It tells you the fix. ·FAILED for resource: azurerm_storage_account.logs- the verdict, and the Terraform address of the resource. ·File: /main.tf:12-20- the file and the line range of that resource block.
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
| option | means |
|---|---|
-d DIR / -f FILE | scan a directory / one file |
--framework terraform | only look at Terraform files |
-c (or --check) ID,ID | run only these checks |
--skip-check ID | run everything except these (for the whole run - see below why that is risky) |
-o json / sarif / junitxml | output formats for other tools. SARIF and JUnit XML are standard result formats that GitHub and CI servers can display |
--quiet | print only the failed checks |
--compact | do 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:
- public access / public network access - reachable from the whole internet, not just your own network.
- blobs - the files stored in a storage account; "nested items public" means individual files could be made readable by anyone with the link.
- default network access rule set to deny - block every address except the ones you list.
- purge protection - a deleted vault cannot be wiped for good during its retention period (14.2).
- content_type / expiration date - a label saying what the secret is, and a date after which it is no longer valid (so it gets rotated).
- For the cluster (14.2): logs sent to Azure's log service (CKV_AZURE_4); RBAC switched on (5); the cluster's management endpoint - the address you send admin commands to - only reachable from listed IP ranges (6) or not from the internet at all, a "private cluster" (115); firewall rules between containers (7); no shared admin account (141); the paid tier with an uptime SLA (170); an automatic upgrade channel (171).
- RDP - Remote Desktop Protocol, Windows' remote login, on port 3389.
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)
- The format is
#checkov:skip=<ID>:<reason>, and it applies to the block it sits in. - Always write the reason and a ticket reference (
ARCH-212). A skip without a reason is a silent exception nobody can review. - Prefer a per-resource skip over
--skip-checkon the command line. The first documents one justified exception; the second switches the rule off for every resource in every run.
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
terraform plan -out=tfplan- make the plan (with whatever var files the pipeline uses) and save it to the filetfplan.terraform show -json tfplan > tfplan.json- convert the saved plan (a binary file) to JSON.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
- The ID is stable; the title is the fix, phrased as a requirement. Here: set
min_tls_version = "TLS1_2"onexports, and give thejumpSSH rule a real source range. - File and line range point at the resource block (for modules, the file in the module folder).
- SKIPPED keeps the reason visible in every run - reviewers can challenge it.
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:
- Run
checkov -d .and read each result: ID, title, verdict, resource, file. - Fix findings in configuration, and suppress a justified exception with
#checkov:skip=ID:reason. - Scan a saved plan with
terraform show -json+checkov -f, and explain why that catches what a static scan misses.