Get the Zoom link
Cloud Security Office Hours Banner

Cloud Custodian policies, hands-on

Cloud Custodian reads a YAML policy, finds the cloud resources that match it and can change them. A wrong policy usually still runs cleanly and reports nothing. Learn the policy language and the ways it fails quietly, on a pretend AWS account that runs on your own computer.

Jump to the walk-through All how-to guides

·

Time: ~2 hours  ·  Difficulty: Intermediate  ·  You need: a terminal, Python 3.10.2 or newer, and some practice reading YAML. No AWS account: the lab runs on your computer, and nothing on this page talks to AWS.

How this page was checked: a script extracted every code block from this page and ran it in order, in zsh on macOS, with Cloud Custodian 0.9.53, botocore 1.43.124 and moto 5.2.5 on Python 3.12.16 and 3.13.18. Every output shown is pasted from that run; only timestamps, timings, moto's random security group IDs and the order of rows in some reports change between runs. A planted error made the same script fail, so the check can fail. What the prose says about actions and --dryrun beyond the printed output was checked by extra runs against the same lab. The Lambda function that step 8 deploys was created in the lab but never invoked, because moto does not deliver CloudTrail events, so what it does when it fires comes from Cloud Custodian's documentation. The Azure and Google Cloud notes come from the project's documentation; nothing ran against Azure, Google Cloud or a real AWS account.

Cloud Custodian for security work

Cloud Custodian is an open-source rules engine for cloud accounts. You write a policy in YAML, a plain-text format for structured data, that names a kind of resource, the conditions that make one a problem and what to do about it. The custodian command finds those resources through the provider's API (application programming interface) and acts on them, from your laptop, a CI (continuous integration) job, or a function deployed into the account. It is a CNCF (Cloud Native Computing Foundation) project, accepted in June 2020 and at the Incubating maturity level since September 2022, and the latest release when this page was checked was 0.9.53, published on 1 October 2026.

AWS (Amazon Web Services) support is in the core c7n package. Azure and Google Cloud come in separate packages, c7n-azure and c7n-gcp, with the same policy language and resource names such as azure.vm and gcp.instance. An Azure run covers one subscription, by default the one your Azure CLI login has selected, and a Google Cloud run covers the project named in GOOGLE_CLOUD_PROJECT. c7n-org runs a policy across many AWS accounts, Azure subscriptions or Google Cloud projects. This page uses AWS because a free emulator can run all of it.

Custodian is quick to start with and easy to get quietly wrong. A filter on a field the resource does not have, a misspelled key, a run that covered one Region and a dry run taken for a rehearsal all finish without an error, and most of them report fewer findings than there are. A short list of findings looks like good news.

On this page

  1. A policy is a list that filters shorten
  2. Walk-through: a pretend account, four silent failures
  3. Hands-on exercises
  4. Common mistakes
  5. Where next

A policy is a list that filters shorten

A policy has up to four parts:

The idea that makes Custodian click is what a pull run does. It lists every resource of that type the credentials can see, in one account and one Region, and each filter shortens the list. A filter that cannot evaluate a resource, because the field is missing or holds the wrong type, usually answers false, and Custodian does not report that as an error. So a broken policy and a clean account produce the same output: count:0. Before believing a policy, run it where you know something should match, and look at what it matched.

Walk-through: a pretend account, four silent failures

The lab is an AWS account inside moto, an open-source AWS emulator for Python, running on your computer. It has four S3 (Simple Storage Service) buckets with S3 Block Public Access on, off and partly on, five security groups in two Regions, and five IAM (Identity and Access Management) users with access keys of various ages. Every policy on the page has something in the lab that should match it and something that should not.

1. Install Cloud Custodian and moto

Run python3 --version first. Cloud Custodian 0.9.53 needs Python 3.10.2 or newer, and the python3 that comes with macOS is older. If yours is, install a newer Python from python.org or your package manager and use its name (for example python3.12) on the second line.

mkdir -p ~/c7n-lab && cd ~/c7n-lab
python3 -m venv .venv && . .venv/bin/activate
pip install --quiet 'c7n==0.9.53' 'botocore==1.43.124' 'moto[server]==5.2.5'
custodian version
0.9.53

The second line makes a private Python for the lab (a virtual environment) and switches to it; in a new terminal, cd ~/c7n-lab && . .venv/bin/activate switches back. Keep the pins. c7n pins its boto3 release but not botocore, the library underneath boto3 that knows which Regions each service has, so an unpinned install can give a different answer to --region all in step 6. The quotes around moto[server] stop zsh reading the square brackets as a file-name pattern.

2. Start the lab

Paste the whole block, including the final EOF line. It writes lab.py and starts it. None of it appears in the policies you write.

cat > lab.py <<'EOF'
"""lab.py: a pretend AWS account for Cloud Custodian, on your own computer.

Starts moto, an AWS emulator, at http://localhost:8116, fills it with the
buckets, security groups and IAM users this guide's policies look for, then
waits. Ctrl-C stops it, and everything in it is forgotten.
"""
import datetime, json, logging, time
import boto3
from moto.core.responses import ActionResult
from moto.core.utils import utcnow
from moto.iam.models import AccessKeyLastUsed, iam_backends
from moto.iam.responses import IamResponse
from moto.server import ThreadedMotoServer

logging.getLogger("werkzeug").setLevel(logging.ERROR)
ThreadedMotoServer(ip_address="127.0.0.1", port=8116, verbose=False).start()

# Moto dates every IAM credential report 2015, so Custodian would think it
# stale and wait for a new one on every run. Date it now instead.
IamResponse.get_credential_report = lambda self: ActionResult({
    "Content": self.backend.get_credential_report(),
    "ReportFormat": "text/csv", "GeneratedTime": utcnow()})

def client(service, region="us-east-1"):
    return boto3.client(service, region_name=region, endpoint_url="http://localhost:8116",
                        aws_access_key_id="lab", aws_secret_access_key="lab")

# Buckets: (name, Region, Block Public Access settings, versioning status).
ALL_ON = dict.fromkeys(["BlockPublicAcls", "IgnorePublicAcls",
                        "BlockPublicPolicy", "RestrictPublicBuckets"], True)
for name, region, bpa, versioning in [
        ("lab-logs", "us-east-1", ALL_ON, "Enabled"),
        ("lab-web-assets", "us-east-1", None, None),
        ("lab-build-artifacts", "us-east-1", {**ALL_ON, "BlockPublicPolicy": False}, "Suspended"),
        ("lab-eu-exports", "eu-west-1", None, None)]:
    s3 = client("s3", region)
    where = {} if region == "us-east-1" else {"CreateBucketConfiguration": {"LocationConstraint": region}}
    s3.create_bucket(Bucket=name, **where)
    if bpa:
        s3.put_public_access_block(Bucket=name, PublicAccessBlockConfiguration=bpa)
    if versioning:
        s3.put_bucket_versioning(Bucket=name, VersioningConfiguration={"Status": versioning})

# Security groups: (name, Region, port allowed in, from where).
for name, region, port, source in [
        ("debug-ssh", "us-east-1", 22, "0.0.0.0/0"), ("office-ssh", "us-east-1", 22, "203.0.113.0/24"),
        ("web-public", "us-east-1", 443, "0.0.0.0/0"), ("ipv6-ssh", "us-east-1", 22, "::/0"),
        ("legacy-rdp", "eu-west-1", 3389, "0.0.0.0/0")]:
    ec2 = client("ec2", region)
    group_id = ec2.create_security_group(GroupName=name, Description=name)["GroupId"]
    ranges = {"Ipv6Ranges": [{"CidrIpv6": source}]} if ":" in source else {"IpRanges": [{"CidrIp": source}]}
    ec2.authorize_security_group_ingress(GroupId=group_id, IpPermissions=[
        {"IpProtocol": "tcp", "FromPort": port, "ToPort": port, **ranges}])

# IAM users and their access keys. Moto cannot create an old key, so the lab
# ages them: (user, days since the key was created, days since it was last used).
iam, users = client("iam"), iam_backends["123456789012"]["global"].users
for user, created, used in [("alice", 400, 2), ("bob", 300, 120), ("carol", 5, None),
                            ("dave", 150, None), ("erin", 400, 1), ("erin", 5, None)]:
    if user not in users:
        iam.create_user(UserName=user)
    iam.create_access_key(UserName=user)
    now, key = utcnow().replace(microsecond=0), users[user].access_keys[-1]
    key.create_date = now - datetime.timedelta(days=created)
    if used is not None:
        key.last_used = AccessKeyLastUsed(now - datetime.timedelta(days=used), "iam", "us-east-1")
iam.generate_credential_report()

# The role that a deployed policy's Lambda function runs as.
iam.create_role(RoleName="custodian-lambda", AssumeRolePolicyDocument=json.dumps({
    "Version": "2012-10-17", "Statement": [{"Effect": "Allow", "Action": "sts:AssumeRole",
                                            "Principal": {"Service": "lambda.amazonaws.com"}}]}))

print("Lab running at http://localhost:8116. Leave this terminal open; Ctrl-C stops it.", flush=True)
try:
    while True:
        time.sleep(3600)
except KeyboardInterrupt:
    print("Lab stopped.")
EOF
python3 lab.py
Lab running at http://localhost:8116. Leave this terminal open; Ctrl-C stops it.

The lab listens only on your own computer, on port 8116. It changes moto in two places: moto cannot create an access key that is already old, so the lab sets the keys' dates, and moto dates every IAM credential report 2015, which would make Custodian think the report stale, ask for a new one and wait ten seconds every time it reads it.

3. Point Custodian at the lab

Leave the lab running and open a second terminal; everything from here on runs there. Custodian has no setting of its own for where AWS is. It uses boto3, the AWS SDK (software development kit) for Python, which reads AWS_ENDPOINT_URL and sends every service's requests there, so that variable is what points it at the lab. The access keys are made up and exist only in the lab, so if AWS_ENDPOINT_URL goes missing while they are set, a run sends AWS keys it never issued and fails instead of reading a real account. In a new terminal, run the first four lines again before any custodian command.

cd ~/c7n-lab && . .venv/bin/activate
unset AWS_PROFILE AWS_SESSION_TOKEN
export AWS_ENDPOINT_URL=http://localhost:8116 AWS_DEFAULT_REGION=us-east-1
export AWS_ACCESS_KEY_ID=lab AWS_SECRET_ACCESS_KEY=lab
cat > buckets.yml <<'EOF'
policies:
  - name: all-buckets
    resource: aws.s3
EOF
custodian run --cache-period 0 -s out buckets.yml
custodian report -s out buckets.yml --format simple
ls -1 out/all-buckets
2026-11-04 16:53:23,786: custodian.policy:INFO policy:all-buckets resource:aws.s3 region:us-east-1 count:4 time:0.17
Name                 CreationDate
-------------------  -------------------------
lab-logs             2026-11-05T00:53:22+00:00
lab-web-assets       2026-11-05T00:53:22+00:00
lab-build-artifacts  2026-11-05T00:53:22+00:00
lab-eu-exports       2026-11-05T00:53:22+00:00
custodian-run.log
metadata.json
resources.json

A policy with no filters matches everything, which makes it the quickest way to see what Custodian knows about a resource type. custodian run logs one line per policy and Region with the count it matched, and writes a directory per policy under -s (the output directory): resources.json holds each matched resource as JSON, which is where you find the field names filters test. custodian report only reads those files; it never calls the API. Every run on this page passes --cache-period 0, and step 7 shows why.

4. Silent failure: a key the resource does not have

A value filter tests one field. key says which, written as a JMESPath expression, so Versioning.Status reads the Status inside Versioning; value is what to compare with; op is the comparison, equality when it is left out, or ne, gt, lt, in, contains, regex and others; and value_type converts the field first, for example age to turn a date into a number of days. In the lab, versioning is on for lab-logs, suspended for lab-build-artifacts, and was never set for the other two, and S3 returns no Status at all for a bucket whose versioning was never set. Ask five ways which buckets do not have versioning on:

cat > versioning.yml <<'EOF'
policies:
  - name: status-is-suspended
    resource: aws.s3
    filters:
      - type: value
        key: Versioning.Status
        value: Suspended
  - name: status-is-not-enabled
    resource: aws.s3
    filters:
      - type: value
        key: Versioning.Status
        op: ne
        value: Enabled
  - name: status-is-absent
    resource: aws.s3
    filters:
      - type: value
        key: Versioning.Status
        value: absent
  - name: versioning-is-present
    resource: aws.s3
    filters:
      - type: value
        key: Versioning
        value: present
  - name: versioning-is-empty
    resource: aws.s3
    filters:
      - type: value
        key: Versioning
        value: empty
EOF
custodian run --cache-period 0 -s out versioning.yml
2026-11-04 16:53:24,773: custodian.policy:INFO policy:status-is-suspended resource:aws.s3 region:us-east-1 count:1 time:0.11
2026-11-04 16:53:24,824: custodian.policy:INFO policy:status-is-not-enabled resource:aws.s3 region:us-east-1 count:3 time:0.05
2026-11-04 16:53:24,874: custodian.policy:INFO policy:status-is-absent resource:aws.s3 region:us-east-1 count:2 time:0.05
2026-11-04 16:53:24,932: custodian.policy:INFO policy:versioning-is-present resource:aws.s3 region:us-east-1 count:4 time:0.06
2026-11-04 16:53:24,985: custodian.policy:INFO policy:versioning-is-empty resource:aws.s3 region:us-east-1 count:2 time:0.05

The first policy is the obvious one, and it finds only lab-build-artifacts. For the two buckets that never had versioning, the key is missing, so the comparison is between nothing and Suspended, which is false. Every comparison against a missing key is false except ne and not-in, which is why the second policy finds all three. For gt, lt and the other ordering operators, Custodian catches the type error that comparing nothing with a number raises and calls it no match.

The special values say what they test. absent matches a missing key and present a key that exists, which is why Versioning: present matches every bucket: Custodian stores an empty {} for an unversioned bucket, and an empty object is present. empty matches a key that is missing, empty or false, and not-null matches one that is none of those. Before you write a comparison, decide what a missing field means for that question, and say it in the policy.

5. Silent failure: custodian validate accepts a policy that can never match

Write the same test against security groups twice, once with the field name misspelled:

cat > sg.yml <<'EOF'
policies:
  - name: world-open-typo
    resource: aws.security-group
    filters:
      - type: value
        key: "IpPermission[].IpRanges[].CidrIp"
        op: contains
        value: 0.0.0.0/0
  - name: world-open
    resource: aws.security-group
    filters:
      - type: value
        key: "IpPermissions[].IpRanges[].CidrIp"
        op: contains
        value: 0.0.0.0/0
EOF
custodian validate sg.yml
custodian run --cache-period 0 -s out sg.yml
2026-11-04 16:53:25,359: custodian.commands:INFO Configuration valid: sg.yml
2026-11-04 16:53:25,868: custodian.policy:INFO policy:world-open-typo resource:aws.security-group region:us-east-1 count:0 time:0.08
2026-11-04 16:53:25,877: custodian.policy:INFO policy:world-open resource:aws.security-group region:us-east-1 count:2 time:0.01

validate checks a policy against Custodian's schema: the YAML, the resource type, the names of filters, actions and modes, and their options. It rejects op: includes, because that is not on its list of operators. It cannot reject IpPermission, because a key is a JMESPath expression over whatever the API returns, and a path that leads nowhere is valid JMESPath. The [] in the key flattens a list, so IpPermissions[].IpRanges[].CidrIp collects every IPv4 source from every rule. The misspelled policy runs and finds nothing; the correct one finds debug-ssh and web-public, and that positive control is the only thing on screen that tells the two apart.

6. Silent failure: one Region unless you ask for more

The world-open policy just ran in us-east-1, because a run covers one Region unless told otherwise: the --region flag, else AWS_DEFAULT_REGION, else the region in your AWS config file, in that order. The lab also has a group in eu-west-1. Ask for every Region:

custodian run --cache-period 0 -s out-all --region all -p world-open sg.yml 2> all.log
grep -c policy:world-open all.log
grep -v count:0 all.log
34
2026-11-04 16:53:29,831: custodian.policy:INFO policy:world-open resource:aws.security-group region:eu-west-1 count:1 time:0.52
2026-11-04 16:53:31,329: custodian.policy:INFO policy:world-open resource:aws.security-group region:us-east-1 count:2 time:0.64

-p picks one policy from the file. --region all asks EC2 (Elastic Compute Cloud) which Regions the account has enabled, keeps those where botocore's data says the service exists, and runs the policy once in each, writing each Region's output to its own directory under out-all. A real account has the Regions AWS enables by default, those launched before 20 March 2019, plus any opt-in Regions someone has turned on; moto treats every Region it knows as enabled, so the lab visits 34. legacy-rdp, with RDP (Remote Desktop Protocol) open to the internet, is in eu-west-1, and the first run never looked there. A global service such as IAM runs once, in us-east-1 or the first Region you name. To read the output, give custodian report the same --region flags.

S3 is the exception that cuts the other way. Listing buckets returns every bucket in the account, whichever Region you ask, so an S3 policy run with --region all reports each bucket once per Region:

custodian run --cache-period 0 -s out-all --region all buckets.yml 2>&1 | grep -c count:4
34

Four buckets, found 34 times each. Run S3 policies in one Region, and give policies for regional resources every Region the account uses.

7. Silent failure: --dryrun skips the actions and nothing else

This policy finds security groups that let the whole internet reach SSH (Secure Shell) on port 22 and removes the rule. The ingress filter is built for security group rules: Ports: [22] matches a rule whose port range includes 22, and Cidr is a value filter applied to each IPv4 source. ingress: matched tells the action to remove only the rules the filter matched.

cat > ssh-fix.yml <<'EOF'
policies:
  - name: ssh-open-to-world
    resource: aws.security-group
    filters:
      - type: ingress
        Ports: [22]
        Cidr:
          value: 0.0.0.0/0
    actions:
      - type: remove-permissions
        ingress: matched
EOF
custodian run --dryrun --cache-period 0 -s out ssh-fix.yml
2026-11-04 16:53:42,845: custodian.policy:INFO policy:ssh-open-to-world resource:aws.security-group region:us-east-1 count:1 time:0.09

One group, debug-ssh, and no action line. --dryrun skips the actions and only the actions. It still calls every list and describe API the resource needs, runs every filter, including filters that make API calls of their own (the IAM credential filter in Exercise 3 asks IAM to generate a credential report when there is no recent one), and writes resources.json. With an s3:// output directory it still uploads its files, and with -m aws it still publishes metrics to Amazon CloudWatch. A dry run is quiet, but it is not read-only. Now run it for real, then twice more without --cache-period 0:

custodian run -s out ssh-fix.yml
custodian run -s out -p world-open sg.yml
custodian run --cache-period 0 -s out -p world-open sg.yml
2026-11-04 16:53:43,428: custodian.policy:INFO policy:ssh-open-to-world resource:aws.security-group region:us-east-1 count:1 time:0.08
2026-11-04 16:53:43,435: custodian.policy:INFO policy:ssh-open-to-world action:removepermissions resources:1 execution_time:0.01
2026-11-04 16:53:43,932: custodian.policy:INFO policy:world-open resource:aws.security-group region:us-east-1 count:2 time:0.00
2026-11-04 16:53:44,471: custodian.policy:INFO policy:world-open resource:aws.security-group region:us-east-1 count:1 time:0.08

The real run removed the rule. The next run still counts two groups open to the world, because by default Custodian keeps what it listed in ~/.cache/cloud-custodian.cache and reuses it for 15 minutes, for any policy on the same resource type, account and Region. Look at time:0.00: it did not ask the API at all. A fix you just made, or a group someone opened a minute ago, does not show until the cache expires. Pass --cache-period 0 whenever you are checking a change, which is why every other run on this page does.

8. Modes: pull, or deployed into the account

Everything so far ran in pull mode. The other AWS modes deploy the policy into the account as an AWS Lambda function that runs when something happens: cloudtrail on API calls that CloudTrail records, periodic and schedule on a timer, and others on events such as AWS Config evaluations, Security Hub findings and GuardDuty findings. This version of the SSH policy runs whenever someone adds an inbound rule to a security group. role is the IAM role the function runs as, and ids is the JMESPath that finds the group in the event.

cat > ssh-realtime.yml <<'EOF'
policies:
  - name: ssh-open-realtime
    resource: aws.security-group
    mode:
      type: cloudtrail
      role: arn:aws:iam::{account_id}:role/custodian-lambda
      events:
        - source: ec2.amazonaws.com
          event: AuthorizeSecurityGroupIngress
          ids: requestParameters.groupId
    filters:
      - type: ingress
        Ports: [22]
        Cidr:
          value: 0.0.0.0/0
    actions:
      - type: remove-permissions
        ingress: matched
EOF
cat > deployed.yml <<'EOF'
policies:
  - name: functions
    resource: aws.lambda
  - name: event-rules
    resource: aws.event-rule
EOF
custodian run --dryrun --cache-period 0 -s out ssh-realtime.yml
custodian run --cache-period 0 -s out deployed.yml
2026-11-04 16:53:45,059: custodian.policy:INFO policy:ssh-open-realtime resource:aws.security-group region:us-east-1 count:0 time:0.08
2026-11-04 16:53:45,650: custodian.policy:INFO policy:functions resource:aws.lambda region:us-east-1 count:0 time:0.11
2026-11-04 16:53:45,663: custodian.policy:INFO policy:event-rules resource:aws.event-rule region:us-east-1 count:0 time:0.01

A dry run of an event-mode policy deploys nothing. It runs the policy once in pull mode against every security group as it is now, and step 7 has already closed the only match. Without --dryrun, the same command deploys:

custodian run --cache-period 0 -s out ssh-realtime.yml
custodian run --cache-period 0 -s out deployed.yml
custodian report -s out deployed.yml -p event-rules --format simple
2026-11-04 16:53:46,128: custodian.policy:INFO Provisioning policy lambda: ssh-open-realtime region: us-east-1
2026-11-04 16:53:46,225: custodian.serverless:INFO Publishing custodian policy lambda function custodian-ssh-open-realtime
2026-11-04 16:53:46,885: custodian.policy:INFO policy:functions resource:aws.lambda region:us-east-1 count:1 time:0.09
2026-11-04 16:53:46,904: custodian.policy:INFO policy:event-rules resource:aws.event-rule region:us-east-1 count:1 time:0.02
Name
---------------------------
custodian-ssh-open-realtime

Custodian packaged itself and the policy into a Lambda function named custodian-ssh-open-realtime, and created an EventBridge rule of the same name that sends matching CloudTrail events to it. Both exist only in us-east-1, the Region the command ran in; each --region you add deploys another copy. The deploy evaluated no existing group, so a rule that is already open stays open until a pull run finds it. Deploying creates resources in the account, so treat it as a change, and keep a scheduled pull run alongside it. The lab cannot fire the function, because moto does not deliver CloudTrail events.

Hands-on exercises

Write each policy in ~/c7n-lab, with the lab running and the environment from step 3, and compare your output with the answer. Rows in a report can come out in a different order from the ones shown.

Exercise 1

Find every S3 bucket that S3 Block Public Access does not fully protect, and check the account-level settings too. Custodian's S3 filter for this is check-public-block; custodian schema aws.s3.filters.check-public-block prints its help.

Show the answer
cat > bpa.yml <<'EOF'
policies:
  - name: buckets-not-fully-blocked
    resource: aws.s3
    filters:
      - type: check-public-block
  - name: buckets-blocked-backwards
    resource: aws.s3
    filters:
      - type: check-public-block
        BlockPublicAcls: true
        BlockPublicPolicy: true
  - name: account-not-fully-blocked
    resource: aws.account
    filters:
      - type: s3-public-block
        key: BlockPublicAcls && IgnorePublicAcls && BlockPublicPolicy && RestrictPublicBuckets
        op: ne
        value: true
EOF
custodian run --cache-period 0 -s out bpa.yml
custodian report -s out bpa.yml -p 'buckets-*' --format simple --no-default-fields --field Bucket=Name --field Policy=policy
2026-11-04 16:53:48,070: custodian.policy:INFO policy:buckets-not-fully-blocked resource:aws.s3 region:us-east-1 count:3 time:0.20
2026-11-04 16:53:48,218: custodian.policy:INFO policy:buckets-blocked-backwards resource:aws.s3 region:us-east-1 count:1 time:0.15
2026-11-04 16:53:48,296: custodian.policy:INFO policy:account-not-fully-blocked resource:aws.account region:us-east-1 count:1 time:0.08
Bucket               Policy
-------------------  -------------------------
lab-logs             buckets-blocked-backwards
lab-web-assets       buckets-not-fully-blocked
lab-eu-exports       buckets-not-fully-blocked
lab-build-artifacts  buckets-not-fully-blocked

With no options, check-public-block matches a bucket where any of the four settings is off or was never set, which is the first policy, and it finds the three buckets the lab left without full protection. A setting that is off does not make a bucket public; it means Block Public Access will not stop a bucket policy or ACL (access control list) that does. The second policy copies the example in the filter's own help, which lists two settings as true under a policy name ending in -Off. Options name the state to match, so it finds the one bucket where both settings are on: lab-logs, the bucket that needs nothing. To find buckets with one setting off, write that setting as false.

S3 applies the most restrictive combination of the bucket's and the account's settings, so a bucket the first policy lists is protected anyway when the account has all four on. check-public-block reads only the bucket's own settings, so check the account too. The account-level filter, s3-public-block on aws.account, is a value filter over the account's configuration, and the lab account has none, so every key is missing. The JMESPath && joins the four settings, and op: ne with value: true matches unless all four are true, a missing configuration included. Writing it as key: BlockPublicPolicy with value: false matches nothing here, which is step 4 again. This policy reaches the lab at 123456789012.localhost, which macOS sends to your own computer; on a system that does not resolve that name, it fails with a connection error rather than silently. To turn the settings on, the actions are set-public-block for buckets and set-s3-public-block for the account.

Exercise 2

Find every security group with an inbound rule open to the whole internet, on any port, in every Region.

Show the answer
cat > world-open.yml <<'EOF'
policies:
  - name: sg-world-open
    resource: aws.security-group
    filters:
      - or:
          - type: ingress
            Cidr:
              value: 0.0.0.0/0
          - type: ingress
            CidrV6:
              value: "::/0"
EOF
custodian run --cache-period 0 -s out-all --region all world-open.yml 2>&1 | grep -v count:0
custodian report -s out-all --region all world-open.yml --format simple
2026-11-04 16:53:51,922: custodian.policy:INFO policy:sg-world-open resource:aws.security-group region:eu-west-1 count:1 time:0.45
2026-11-04 16:53:53,068: custodian.policy:INFO policy:sg-world-open resource:aws.security-group region:us-east-1 count:2 time:0.58
GroupId               GroupName    Region
--------------------  -----------  ---------
sg-5b88a4b87d057a3c1  web-public   us-east-1
sg-e42eccc109dbdf89d  ipv6-ssh     us-east-1
sg-17a46f1a2e20f9e17  legacy-rdp   eu-west-1

Your group IDs will differ; moto makes new ones each time the lab starts. Cidr tests only a rule's IPv4 sources. ipv6-ssh lets in ::/0, the whole IPv6 internet, and only CidrV6 sees it, so the two tests go in an or block. Without --region all the report has no legacy-rdp. debug-ssh is gone because step 7 fixed it, and office-ssh allows one network, 203.0.113.0/24. Whether web-public, with HTTPS (port 443) open, is a finding is a question for a person; a public load balancer needs exactly that.

Cidr with a plain value is an exact match, so a rule that splits the internet into 0.0.0.0/1 and 128.0.0.0/1 slips past it. value_type: cidr_size with op: le and value: 8 catches any source at least as wide as a /8 instead. Outbound rules use the egress filter, with the same options.

Exercise 3

List every IAM user with an active access key that has not been used for 90 days, counting a key that was never used from the day it was created. AWS Security Hub uses the same 90 days in control IAM.8, and the Python and the cloud SDKs guide writes the same check as a boto3 script. Use the credential filter, which reads IAM's credential report.

Show the answer
cat > stale-keys.yml <<'EOF'
policies:
  - name: keys-idle-90-days
    resource: aws.iam-user
    filters:
      - type: credential
        key: access_keys.active
        value: true
      - type: credential
        key: access_keys.last_used_date
        value_type: age
        op: gt
        value: 90
  - name: keys-never-used-90-days
    resource: aws.iam-user
    filters:
      - type: credential
        key: access_keys.active
        value: true
      - type: credential
        key: access_keys.last_used_date
        value: absent
      - type: credential
        key: access_keys.last_rotated
        value_type: age
        op: gt
        value: 90
EOF
custodian run --cache-period 0 -s out stale-keys.yml
custodian report -s out stale-keys.yml --format simple --no-default-fields --field User=UserName --field 'Keys=count:"c7n:matched-keys"' --field Policy=policy
2026-11-04 16:53:56,866: custodian.policy:INFO policy:keys-idle-90-days resource:aws.iam-user region:us-east-1 count:1 time:0.08
2026-11-04 16:53:56,895: custodian.policy:INFO policy:keys-never-used-90-days resource:aws.iam-user region:us-east-1 count:2 time:0.03
User      Keys  Policy
------  ------  -----------------------
dave         1  keys-never-used-90-days
erin         0  keys-never-used-90-days
bob          1  keys-idle-90-days

value_type: age turns a date into days ago, so op: gt with value: 90 means more than 90 days ago. The second policy is the one a first attempt leaves out. For a key that has never been used, the credential report says N/A, Custodian turns that into a missing value, and an age comparison with a missing value is false, so the first policy alone never finds dave, whose key has sat unused since it was created 150 days ago. last_rotated is when the key was created or last changed. carol's never-used key is five days old, so she is not listed.

erin is listed with no matching key, and she is not a finding. She is part-way through rotating: her old key was used yesterday and her new one, made five days ago, has not been used yet. Each credential filter looks at all of a user's keys and is satisfied by any one of them, so the active filter matched both keys, absent matched the new key and the age test matched the old one, and no single key passed all three. In a policy made of filters like these, Custodian records per user the keys that passed every filter, in c7n:matched-keys, which is the Keys column. Check that column before acting. With no options, the remove-keys action removes every key a matched user has, so this policy would also remove erin's key that is in daily use; matched: true limits it to the keys in that list. IAM makes a new report at most every four hours, and Custodian reuses a report for up to a day by default (report_max_age), so a key used this morning can still look idle.

Common mistakes

Where next