CSPM & continuous compliance
Config rules, CIS benchmarks, and drift as a security signal.
A yearly compliance audit is a photograph of a river. It captures the water on one day and tells you nothing about the current for the other 364. Your cloud estate is that river: thousands of resources changing every day, and any single change can carve a channel straight to your data. Cloud Security Posture Management (CSPM), which means watching the configuration of every resource around the clock and flagging the unsafe ones, swaps the photograph for a gauge fixed to the bank. A public storage bucket or a wide-open firewall shows up minutes after someone creates it, not at next year's review.
The loop every CSPM tool runs
Underneath the branding, every CSPM tool runs the same short loop. You assert a rule, which is one yes-or-no question you can ask of a resource. The tool evaluates every matching resource against it. Then it surfaces the failures as findings, and a finding is something a person or a script now has to close. The rules come from four places: the cloud provider's own best practices, industry benchmarks like the CIS (Center for Internet Security) Foundations Benchmarks, regulatory and audit frameworks such as PCI-DSS (the Payment Card Industry Data Security Standard), NIST 800-53 (a US government catalog of security controls), and SOC 2 (a common trust and security audit report), and your own house policy. Each rule is deliberately small. Storage buckets must block public reads. Virtual machines must not carry a public IP address. Key policies must not grant access to everyone. Small rules are easy to test, easy to defend in a review, and easy to run in more than one place, which turns out to be the whole game.
A lock on the door versus a camera in the hall
Two kinds of control enforce those rules, and the difference decides how much damage a slip can do. A preventive control is a lock on the door. It refuses the bad configuration before it can exist: an AWS service control policy (SCP, an organization-wide rule that no account beneath it can escape, admins included), a Google Cloud organization policy constraint, an Azure Policy 'deny' effect, or a scan in your build pipeline (CI, short for continuous integration, the automated checks that run on every change) that fails the merge. A detective control is a camera in the hallway. It watches resources that already exist and raises a finding after the fact. Neither one replaces the other. The gate blocks the obvious mistakes cheaply, and continuous evaluation at runtime catches everything that still gets past it: drift, someone clicking around in the console, or a class of resource your pipeline never scanned. Write the same intent in both places and you are covered coming and going.
Block it in the pipeline
Start on the cheap side, before anything is deployed. Checkov, an open-source scanner that reads your infrastructure-as-code (the text files that define your cloud, kept in version control) and checks it against hundreds of built-in rules, can read the very same Terraform that builds all three clouds and fail the build when a resource is wrong. One tool, one command, every provider. Each rule has an identifier. CKV_AWS_53 checks that an S3 bucket blocks public access control lists (ACLs, the older per-object permission model). CKV_GCP_29 checks that a Google Cloud Storage bucket has uniform bucket-level access turned on. CKV_AZURE_59 checks that an Azure storage account disallows public access. One idea, three spellings.
# One scanner, all three clouds' Terraform, before anything is applied.checkov -d ./infra --compact --quiet \--check CKV_AWS_53,CKV_GCP_29,CKV_AZURE_59echo "exit=$?"
_ ____| |__ ___ ___| | _______ __/ __| '_ \ / _ \/ __| |/ / _ \ \ / /| (__| | | | __/ (__| < (_) \ V /\___|_| |_|\___|\___|_|\_\___/ \_/by Prisma Cloud | version: 3.2.334Passed checks: 41, Failed checks: 2, Skipped checks: 0Check: CKV_AWS_53: "Ensure S3 bucket has block public ACLs enabled"FAILED for resource: aws_s3_bucket.exportsFile: /storage.tf:12-19Check: CKV_AZURE_59: "Ensure that Storage accounts disallow public access"FAILED for resource: azurerm_storage_account.logsFile: /azure_storage.tf:4-14exit=1 # non-zero exit fails the merge request
CKV_GCP_29 passed, so it is not in the list. The two failures are, and the exit code 1 is the only part the pipeline actually reads: non-zero, so the merge request stops here. Nobody had to spot the public bucket in a review, because the machine refused to let it land.
The same question on three control planes
Once resources are live, each cloud runs its own CSPM engine, and in a multi-cloud shop you operate all three. AWS pairs AWS Config, which records every configuration change as a dated item and evaluates Config rules against it (change-triggered or on a schedule), with Security Hub, which collects the findings and scores them against packaged standards. Google Cloud's Security Command Center (SCC) runs Security Health Analytics (SHA), a built-in detector set that scans on a fixed cadence and writes findings you can query for the whole organization, a folder, or one project. Azure's Microsoft Defender for Cloud grades resources against Azure Policy initiatives and rolls the result into a Secure Score. Different nouns, identical loop. Here is each one answering the same question: which resources are non-compliant right now?
# AWS: a managed Config rule flags any bucket that turns public.aws configservice put-config-rule --config-rule '{"ConfigRuleName": "s3-bucket-public-read-prohibited","Source": { "Owner": "AWS","SourceIdentifier": "S3_BUCKET_PUBLIC_READ_PROHIBITED" }}'# Which buckets fail right now? (put-config-rule prints nothing on success)aws configservice get-compliance-details-by-config-rule \--config-rule-name s3-bucket-public-read-prohibited \--compliance-types NON_COMPLIANT \--query 'EvaluationResults[].EvaluationResultIdentifier.EvaluationResultQualifier.ResourceId' \--output json
["prod-analytics-exports","legacy-cf-logs-2019"]
# Google Cloud: Security Health Analytics writes findings into SCC on its own.# List active HIGH-severity public-bucket findings across the whole org.gcloud scc findings list organizations/846251066572 \--source=- \--filter='state="ACTIVE" AND severity="HIGH" AND category="PUBLIC_BUCKET_ACL"' \--format='table(category, resourceName.basename(), eventTime)'
CATEGORY RESOURCE_NAME EVENT_TIMEPUBLIC_BUCKET_ACL prod-analytics-exports 2026-07-14T09:12:03.774ZPUBLIC_BUCKET_ACL ml-datasets-public 2026-07-19T22:41:10.208Z
# Azure: the subscription's Secure Score, then the Policy compliance rollup.az security secure-scores show --name ascScore \--query '{current:properties.score.current, max:properties.score.max, pct:properties.score.percentage}'az policy state summarize --management-group mg-prod \--query 'value[0].results.{policies:nonCompliantPolicies, resources:nonCompliantResources}'
{"current": 38.0,"max": 58.0,"pct": 0.6552}{"policies": 9,"resources": 47}
Read the three outputs side by side and the trade-off jumps out. AWS names the exact buckets that fail one rule. Google Cloud hands you the same failure as a finding with a timestamp, so you know when the exposure started. Azure gives you the aggregate instead: a Secure Score of 38 out of 58, about 66 percent, and 47 resources failing 9 policies. Same reality, three shapes. Pulling those shapes back together is a real cost, and we will come back to it.
A benchmark is a checklist a whole industry agreed on
A CIS Benchmark is a health-inspection checklist for a cloud that a whole industry agreed on, so you are not inventing your own rules or defending them in a meeting. The AWS version, the CIS AWS Foundations Benchmark, spells out concrete controls: '[S3.8] S3 general purpose buckets should block public access', '[EC2.2] VPC default security groups should not allow inbound or outbound traffic', '[IAM.4] IAM root user access key should not exist', '[KMS.4] AWS KMS key rotation should be enabled'. You subscribe to the benchmark, the cloud scores every control, and you watch the percentage move over time the way you watch a credit score. That percentage, tracked continuously instead of once a year, is what 'continuous compliance' actually means.
Every cloud packages the benchmark, but the command line tells a different story on each. On AWS you turn the standard on and the findings start flowing. On Azure the CLI hands you the pass and fail counts directly. On Google Cloud the single percentage lives in the console, while the CLI gives you the failing controls one category at a time, because in SCC each Security Health Analytics detector is a CIS control.
# AWS: subscribe the account to CIS AWS Foundations Benchmark v5.0.0# (the current version AWS recommends).aws securityhub batch-enable-standards \--standards-subscription-requests \'StandardsArn=arn:aws:securityhub:us-east-1::standards/cis-aws-foundations-benchmark/v/5.0.0'# Confirm which standards are enabled, and their status.aws securityhub get-enabled-standards
{"StandardsSubscriptions": [{"StandardsSubscriptionArn": "arn:aws:securityhub:us-east-1:123456789012:subscription/aws-foundational-security-best-practices/v/1.0.0","StandardsArn": "arn:aws:securityhub:us-east-1::standards/aws-foundational-security-best-practices/v/1.0.0","StandardsInput": {},"StandardsStatus": "READY"},{"StandardsSubscriptionArn": "arn:aws:securityhub:us-east-1:123456789012:subscription/cis-aws-foundations-benchmark/v/5.0.0","StandardsArn": "arn:aws:securityhub:us-east-1::standards/cis-aws-foundations-benchmark/v/5.0.0","StandardsInput": {},"StandardsStatus": "PENDING"}]}
# Azure: compliance state per regulatory standard in Defender for Cloud.az security regulatory-compliance-standards list \--query '[].{Standard:name, State:properties.state, Pass:properties.passedControls, Fail:properties.failedControls, Skip:properties.skippedControls}' \-o table
Standard State Pass Fail Skip--------------- ------- ------ ------ ------Azure-CIS-1.1.0 Failed 41 12 7Azure-CIS-1.3.0 Failed 58 19 9PCI-DSS-3.2.1 Failed 33 16 5ISO-27001 Failed 12 8 2SOC-TSP Failed 9 6 3
# Google Cloud: no single CIS percentage from the CLI, so count the failing# detectors by category. Each category maps to a CIS control.gcloud scc findings list organizations/846251066572 --source=- \--filter='state="ACTIVE"' \--format='value(category)' | sort | uniq -c | sort -rn | head
9 BUCKET_POLICY_ONLY_DISABLED6 MFA_NOT_ENFORCED5 OPEN_FIREWALL3 NON_ORG_IAM_MEMBER2 PUBLIC_BUCKET_ACL2 KMS_PROJECT_HAS_OWNER
Look at the AWS status. It reads PENDING because you turned the standard on moments ago. Security Hub can take up to 18 hours to generate findings for a control that shares an underlying Config rule with a standard you already had enabled (Foundational Security Best Practices, or FSBP, the one already showing READY), so a fresh benchmark is not a same-minute answer. Two more things bite. The control identifiers never line up across clouds: the public-bucket rule is '[S3.8]' on AWS, 'PUBLIC_BUCKET_ACL' on Google Cloud, and a policy named 'Storage accounts should disallow public access' on Azure. And CIS ships new versions over time, so v1.2.0, v1.4.0, v3.0.0, and v5.0.0 all sit side by side in Security Hub. v5.0.0 is the current one, which is why you enabled it above. When the next version lands, turn it on before you retire the old one, or you leave a gap in your checks during the switch.
Drift is the signal, not the noise
A tamper-evident seal on a medicine bottle does not stop anyone opening it. It tells you that someone did. Drift is that seal for your cloud. When the live configuration stops matching the approved, version-controlled baseline, that gap is itself a security event. Maybe a teammate loosened a control by hand under a deadline. Maybe an attacker who already has a foothold is quietly widening one: a security group opened to the internet, flow logs switched off, a Key Management Service (KMS) key policy broadened, public-access-prevention peeled off a bucket. You cannot tell which from a number on a dashboard, so you treat every out-of-band change to a security-relevant resource as something to investigate, not a metric to admire.
The cheapest drift detector is the infrastructure-as-code tool you already run. Keep the desired state in Terraform, and a plan that wants to change something you did not change means the world moved underneath you.
# The baseline lives in Terraform. Does the live estate still match it?# -detailed-exitcode returns: 0 = in sync, 1 = error, 2 = drift found.terraform plan -detailed-exitcodeecho "exit=$?"
Note: Objects have changed outside of TerraformTerraform detected the following changes made outside of Terraform since thelast "terraform apply" which may have affected this plan:# aws_security_group.db has changed~ resource "aws_security_group" "db" {id = "sg-0ab12cd34ef56789a"name = "db-tier"~ ingress = [+ {+ cidr_blocks = [+ "0.0.0.0/0",]+ description = ""+ from_port = 5432+ ipv6_cidr_blocks = []+ prefix_list_ids = []+ protocol = "tcp"+ security_groups = []+ self = false+ to_port = 5432},# (1 unchanged element hidden)]# (8 unchanged attributes hidden)}Unless you have made equivalent changes to your configuration, or ignored therelevant attributes using ignore_changes, the following plan may includeactions to undo or respond to these changes.Terraform will perform the following actions:# aws_security_group.db will be updated in-place~ resource "aws_security_group" "db" {id = "sg-0ab12cd34ef56789a"~ ingress = [- {- cidr_blocks = [- "0.0.0.0/0",]- description = ""- from_port = 5432- ipv6_cidr_blocks = []- prefix_list_ids = []- protocol = "tcp"- security_groups = []- self = false- to_port = 5432},# (1 unchanged element hidden)]# (8 unchanged attributes hidden)}Plan: 0 to add, 1 to change, 0 to destroy.exit=2
Someone opened the database security group to 0.0.0.0/0 on port 5432, the PostgreSQL port, straight to the open internet. Terraform did not do that, so it reports the object changed outside Terraform and offers to put it back by removing the rule. The exit code 2 is the machine-readable half of the signal, and it is what you wire into a scheduled job: exit 2 means open a ticket and page whoever is on call, then decide whether the change was legitimate (in which case it belongs in the code) or hostile (in which case it belongs in your incident queue).
The honest costs: latency, money, parity
Detective CSPM buys you time-to-detection and charges for it in three currencies. The first is latency. Evaluation is periodic, not instant. AWS change-triggered Config rules fire within minutes, but Security Health Analytics batches its scans on a multi-hour cadence and Defender re-assesses on its own clock. Every one of those windows is time the exposure is live and unwatched, which is the entire argument for the preventive controls that shrink the window to zero.
The second currency is money, and it grows with the estate. AWS Config bills per configuration item it records and per rule evaluation. Security Hub bills per finding and per check. Defender for Cloud bills per protected resource per hour. Security Command Center's paid tiers carry a subscription cost of their own. Across thousands of resources in dozens of accounts, projects, and subscriptions, this becomes a real line item, and a 'record everything, everywhere' default can cost more than the workloads it is watching. Scope what you record on purpose.
The third is parity, the problem you already saw in those three outputs. One 'public storage' fact carries a different identifier and severity in every cloud. Teams either normalize inside one provider (Security Hub's AWS Security Finding Format, or ASFF, gives every finding a common shape) or buy a third-party cloud-native application protection platform (CNAPP) such as Wiz, Prisma Cloud, or Orca that pulls all three into a single posture view. Whichever you choose, aggregate at the top of the org tree: a delegated administrator account in AWS, organization-level SCC in Google Cloud, a management-group Azure Policy assignment in Azure. Do it there and no single account can quietly switch its own evaluation off.
terraform plan -detailed-exitcode against production. Tonight it prints 'aws_security_group.db has changed' with a new 0.0.0.0/0 ingress on port 5432 and exits 2, though nobody merged a change. What should the job do?The next lesson keeps this continuous-evaluation habit and moves the target inward. Instead of the whole estate, it looks inside the workloads: for containers and serverless functions, posture stops being about bucket permissions and becomes image provenance, runtime privileges, and exactly what a function is allowed to call. Same loop, a different rule library, evaluated one layer down the stack.
Try this
Run echo "exit=$?" on a scratch host or disposable cluster and read the output against what this lesson described. Then change one input so it fails, and re-run: the error you get is the one you will meet in production.
Takeaway
The trap worth remembering here: auto-remediation can fight your own code. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.