Controls & mapping
Preventive/detective/corrective, owned, mapped.
A restaurant inspector will not accept a photo of a clean refrigerator. They want the temperature log, filled in every day, for the whole quarter. Your SOC 2 Type II auditor (Service Organization Control 2, the report that judges your controls across a stretch of time rather than on one lucky morning) wants the cloud version of that log: proof that every S3 bucket (Simple Storage Service, the object storage in AWS, Amazon Web Services) and every RDS instance (Relational Database Service, Amazon's managed databases) across all twelve of your accounts had encryption at rest turned on, on every day of the audit window. A console screenshot covers one bucket at one moment. A spreadsheet somebody typed by hand covers nothing an auditor will trust. What they want is a check that runs itself and prints a verdict per resource on a schedule. Turning the sentence 'data must be encrypted at rest' into a query that returns PASS or FAIL for every real resource is the whole job of compliance as code, and you will build it end to end here with Steampipe and Powerpipe.
From a sentence in a rulebook to code that runs
A control is a sentence in a rulebook: 'data at rest is encrypted.' That same sentence shows up in SOC 2 as CC6.1, in PCI-DSS (Payment Card Industry Data Security Standard) as 3.4, and in NIST (the United States National Institute of Standards and Technology) 800-53 as SC-28. An executable check is code that reads the real state of your resources and returns a verdict for each one, ok or alarm, with a reason attached. Mapping is the bridge between the two: name the resources in scope, write the pass condition as a query, then tag the control with every framework identifier it satisfies. Steampipe is the open-source piece that makes the query easy. Its plugins present cloud, SaaS (software as a service) and Kubernetes APIs (application programming interfaces, the machine-readable front doors of those services) as read-only SQL tables (Structured Query Language, the language you already use to ask a database questions), so 'list every bucket and its encryption config' becomes a SELECT. Powerpipe is the companion that wraps those queries into named controls and benchmarks, runs them, and reports pass or fail with framework tags and an exit code you can act on. One naming change to know about: the tools split apart in 2024, with Steampipe keeping the data and SQL layer while Powerpipe took over as the runner. Older tutorials that say steampipe check control.<id> are describing what powerpipe control run <id> does today.
Get the two tools installed and talking
Four steps take you from an empty shell to a control that runs. Install Steampipe and the AWS plugin. The plugin borrows credentials you already have, an AWS profile or an SSO (single sign-on) session, and read-only permissions are enough for it. Install Powerpipe. Pull down the community compliance mod, a package that ships hundreds of ready-made controls, benchmarks and their framework tags. Then start Steampipe as a service, because it is the local Postgres database Powerpipe sends its queries to. By default Powerpipe looks for that local Steampipe instance at postgres://steampipe@localhost:9193/steampipe.
# 1. Steampipe = the data plane: cloud APIs exposed as SQL tablessudo /bin/sh -c "$(curl -fsSL https://steampipe.io/install/steampipe.sh)"steampipe plugin install aws # reuses your AWS profile / SSO# 2. Powerpipe = the control runner (split out of steampipe in 2024)sudo /bin/sh -c "$(curl -fsSL https://powerpipe.io/install/powerpipe.sh)"# 3. Pull the community mod (controls + benchmarks + framework tags)mkdir aws-compliance && cd aws-compliancepowerpipe mod initpowerpipe mod install github.com/turbot/steampipe-mod-aws-compliance# 4. Start Steampipe as the Postgres backend Powerpipe reads fromsteampipe service start
The control is the query
Open one control in the mod and the mapping stops being an idea. A control is a small object written in HCL (HashiCorp Configuration Language, the same config syntax Terraform uses) that ties three things together: a title a human can read, a SQL query, and a bag of framework tags. The query is where 'encryption at rest' turns from prose into logic. It has to hand back three columns Powerpipe knows by name: resource (what got checked), status (the verdict, either 'ok' or 'alarm'), and reason (why). Read the case expression below and you are reading the control statement itself: case when server_side_encryption_configuration is not null then 'ok' else 'alarm' end. The tags do the mapping work. Because this one control carries four framework names, a single run yields evidence toward SOC 2, PCI and NIST at the same time. Same idea as the control mapping table on your wiki, except this one executes.
control "s3_bucket_default_encryption_enabled" {title = "S3 bucket default encryption should be enabled"query = query.s3_bucket_default_encryption_enabledtags = { # one control, many frameworkssoc_2 = "true"pci_dss_v321 = "true"nist_800_53_rev_5 = "true"cis_controls_v8_ig1 = "true"}}query "s3_bucket_default_encryption_enabled" {sql = <<-EOQselectarn as resource, -- what was checkedcasewhen server_side_encryption_configuration is not null then 'ok'else 'alarm'end as status, -- the verdictcasewhen server_side_encryption_configuration is not nullthen name || ' default encryption enabled.'else name || ' default encryption disabled.'end as reason, -- whyregion,account_idfrom aws_s3_bucket; -- live S3 APIEOQ}
Point it at real infrastructure
Run the single control now. Powerpipe hands the SQL over to Steampipe, Steampipe calls the live S3 API through the plugin, every bucket in the account gets evaluated, and you get one row per resource followed by a tally of the statuses.
cd aws-compliancepowerpipe control run s3_bucket_default_encryption_enabled
S3 bucket default encryption should be enabledok arn:aws:s3:::prod-terraform-state prod-terraform-state default encryption enabled. us-east-1 123456789012ok arn:aws:s3:::prod-app-assets prod-app-assets default encryption enabled. us-east-1 123456789012alarm arn:aws:s3:::legacy-exports legacy-exports default encryption disabled. us-east-1 123456789012alarm arn:aws:s3:::analytics-scratch analytics-scratch default encryption disabled. eu-west-1 123456789012OK : 14ALARM : 2ERROR : 0SKIP : 0INFO : 0TOTAL : 16$ echo $?1
Read that output the way an auditor reads it and the way your pipeline reads it. Two buckets failed, and each failure names the resource in plain words: legacy-exports and analytics-scratch are unencrypted. Fourteen passed. The exit code is the part automation cares about, and it behaves like a severity dial rather than a count. Powerpipe returns 0 when nothing is in alarm or error, 1 when one or more controls are in alarm and none errored, and 2 when any control errors, because errors outrank alarms. Two alarms, zero errors, so $? comes back 1. A CI stage (continuous integration, the automated build that runs on every change) can fail on any non-zero code without parsing a thing. And all sixteen verdicts count as evidence toward four frameworks at once, out of a check you wrote and ran exactly once.
One check becomes a nightly gate
Controls stack into benchmarks. cis_v300, pci_dss_v321 and nist_800_53_rev_5 are each a curated tree of the same query-backed controls. In CI you run the whole tree, export a machine-readable file in whichever format your auditor accepts (JSON, CSV, ASFF for AWS Security Hub, or NUnit if you want it on a test dashboard), and gate the merge on the exit code. That exported file becomes dated, tamper-evident evidence covering the audit window instead of a screenshot somebody took once. The same pattern moves to Kubernetes: swap in the kubernetes plugin and steampipe-mod-kubernetes-compliance, and the identical control-to-query shape scores pods, RBAC (role-based access control, who is allowed to do what in the cluster) and NetworkPolicies. That benchmark tree, a pile of consensus hardening checks run as controls, is what the next lesson picks up when it turns CIS Benchmarks (Center for Internet Security, the group that publishes those consensus configuration baselines) into your control set.
steampipe service startpowerpipe benchmark run cis_v300 \--export "cis-$(date +%F).json" \--output noneecho "exit: $?" # non-zero (1=alarm, 2=error) fails the pipeline stage
exit: 1# Non-zero: one or more controls in the CIS AWS Foundations benchmark are# in alarm (none errored). Had any control errored, the code would be 2# instead — errors outrank alarms and cap the code at 2 either way.# Evidence written to cis-2026-07-14.json — the auditor-ready artifact.# Only exit 0 means every checked control passed and was actually verified.
One design choice is worth saying out loud. Steampipe controls are SQL, while OPA controls (Open Policy Agent, the subject of a later lesson) are written in Rego, its policy language. SQL suits questions about the inventory you already have running: 'which buckets lack encryption right now' reads naturally as a SELECT over a table backed by a live API. Rego suits judging one structured document before the thing it describes exists, a Terraform plan or a Kubernetes admission request. Grown-up programs run both, with Rego blocking non-compliant infrastructure at the gate and Steampipe plus Powerpipe proving the deployed fleet still complies day after day. Same control, two places to enforce it, both executable.
powerpipe benchmark run pci_dss_v321 && .... The run comes back with 40 OK, 0 ALARM and 3 ERROR (AccessDenied on the RDS Describe call). What is the right conclusion?$? is 2 and the && gate short-circuits into a failure. An errored control never evaluated the resource. Grant the missing Describe permission and run it again instead of loosening the gate.exit: 1. The summary shows 2 in ALARM and 0 in ERROR, and the export file was written. A teammate wants to mark the stage green because the evidence file exists. What do you do next?Try this
Work through “One check becomes a nightly gate” yourself on a sandbox you can throw away, following the commands above in order. Then break one step deliberately and re-run, so you have seen the failure before it finds you.
Takeaway
The trap worth remembering here: an error is not a pass. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.