I built a multi-cloud CSPM workflow that audits AWS and Azure against CIS benchmarks with Prowler and ScoutSuite, then remediates drift with Terraform. In my lab run, the first audit surfaced 47 misconfigurations, and the public S3 bucket exposures were closed within one hour of starting remediation. Your counts and timing will differ; treat these as one run, not a benchmark.

The workflow has three properties I cared about: the scanners authenticate with read-only roles so the audit itself cannot become an attack path, every fix is expressed as infrastructure code so it can be reviewed and rolled back, and the scans run on a schedule so posture becomes a trend line instead of a one-off snapshot. This page walks the whole loop end to end: standing up least-privilege credentials, running Prowler against AWS and ScoutSuite against Azure, remediating the worst findings with Terraform and Azure Policy, and wiring up recurring scans that catch drift before an auditor, or an attacker, does. Finding counts and timings below come from the reference run for this lab; your environment will report different numbers, the workflow is what transfers.

Learning Objectives

  • Run Prowler against AWS and ScoutSuite against Azure using least-privilege read-only identities.
  • Map findings to CIS benchmark controls and compute a defensible compliance score.
  • Remediate high-severity drift with Terraform and Azure Policy.
  • Schedule recurring scans so posture becomes a trend instead of a snapshot.

Context & Standards Alignment

Cloud misconfiguration, not exploitation, drives most cloud breaches. This lab continuously measures posture against CIS AWS Foundations and CIS Azure benchmarks and enforces guardrails with policy-as-code.

CIS benchmarks are prescriptive, community-reviewed configuration guides published per platform, organized into Level 1 (baseline hygiene every environment should meet) and Level 2 (defense-in-depth for sensitive workloads). I benchmarked against Level 1 first: failing Level 1 controls like public object storage, a root account without MFA, or missing multi-region audit logging are the exact misconfigurations behind most real-world cloud breaches. Mapping each finding family to MITRE ATT&CK techniques keeps the work threat-informed instead of checklist-driven, it answers "why does this control matter" with a concrete attack path:

  • T1530 Data from Cloud Storage Object, detection of public buckets/blobs.
  • T1078.004 Cloud Accounts, IAM privilege-escalation path scanning.

T1530 is why public S3 buckets and anonymous blob access sit at the top of the remediation queue: any internet client can enumerate and read the data. T1078.004 is why IAM findings (root without MFA, wildcard policies, access keys older than 90 days) come next, a stolen or guessed valid cloud credential converts directly into control-plane access without exploiting anything.

Concretely, the audit targets CIS AWS Foundations Benchmark v2.0 and CIS Microsoft Azure Foundations Benchmark v2.1. Compliance percentage is computed per benchmark as PASS / (PASS + FAIL), deliberately ignoring INFO and MANUAL results, so the number is never inflated by checks the scanner could not evaluate. Every stored finding keeps its CIS control ID, severity, resource ARN/ID, and region, that metadata is what makes triage and trend tracking possible later.

Environment & Prerequisites

Prerequisites

  • A Linux bastion host (a small VM is fine) with Python 3.11+, Terraform 1.7, Azure CLI, and AWS CLI v2.
  • AWS account with a read-only SecurityAudit role; Azure subscription with Reader plus Security Reader.
  • Assumed skills: basic Terraform, comfort reading CIS benchmark controls, and familiarity with IAM concepts.
  • AWS account with read-only SecurityAudit role; Azure subscription with Reader + Security Reader.
  • Prowler 5.x (prowler-cloud on PyPI), ScoutSuite, Terraform 1.7 or newer, Azure CLI, AWS CLI v2.
  • CI runner with scheduled scans; findings stored for trend analysis.

Everything runs from one Linux bastion host (a small VM is fine). Install the toolchain once in a virtual environment and confirm versions before auditing, Prowler 3.x and ScoutSuite both pin specific SDK versions, so mixing pip and distro packages causes subtle breakage:

INSTALL, audit toolchain on the bastion host

python3 -m venv ~/cspm-venv && source ~/cspm-venv/bin/activate
pip install prowler-cloud scoutsuite
terraform -version   # Terraform v1.7.x
aws --version        # aws-cli/2.x
az version           # azure-cli 2.x

On AWS, the scan identity is a dedicated role holding the managed SecurityAudit and ViewOnlyAccess policies, read-only by design, so a leaked scanner credential still cannot change anything:

AWS, create the read-only audit role (run once with admin credentials)

aws iam create-role --role-name ProwlerAuditRole \
  --assume-role-policy-document file://trust-policy.json
aws iam attach-role-policy --role-name ProwlerAuditRole \
  --policy-arn arn:aws:iam::aws:policy/SecurityAudit
aws iam attach-role-policy --role-name ProwlerAuditRole \
  --policy-arn arn:aws:iam::aws:policy/job-function/ViewOnlyAccess
aws sts get-caller-identity   # confirm who you are before scanning

Expected output (illustrative)

{
    "UserId": "AROAXAMPLE:cspm-scan-session",
    "Account": "123456789012",
    "Arn": "arn:aws:sts::123456789012:assumed-role/ProwlerAuditRole/cspm-scan-session"
}

On Azure, sign in with the CLI and confirm the session lands on the intended subscription. The scanning identity needs Reader plus Security Reader at subscription scope, without them, Microsoft Defender for Cloud data and several policy states come back empty, which looks exactly like "no findings":

AZURE, log in and confirm subscription context

az login
az account set --subscription "Pay-As-You-Go-Lab"
az account show --query "{name:name, id:id, state:state}" -o table
az role assignment list --assignee $(az ad signed-in-user show --query id -o tsv) \
  --query "[].{role:roleDefinitionName, scope:scope}" -o table

Findings are written to a dated directory layout so any scan day can be reproduced, diffed, or handed to an auditor later:

LAYOUT, dated findings store on the bastion

/var/lib/cspm/findings/2026-06-14/
  aws/prowler-output-123456789012.csv
  aws/prowler-output-123456789012.json
  azure/scoutsuite-results/
/var/lib/cspm/compliance-trend.txt   # one line per scan day, per cloud

Common mistake: scanning the wrong subscription or AWS account because of stale CLI context. Always run az account show and aws sts get-caller-identity first and record the account IDs at the top of the findings report, otherwise you cannot later prove which environment a finding belonged to.

Step-by-Step Execution

The workflow runs as two loops. The audit loop scans both clouds against CIS and writes findings to disk; the remediation loop fixes findings as infrastructure code, applies the change, and re-scans the same checks to prove they pass. Nothing is fixed by clicking in a console, console fixes cannot be reviewed, repeated, or rolled back.

1. Run a Prowler CIS audit against AWS

What: Prowler executes several hundred checks mapped to the CIS AWS Foundations Benchmark v2.0 and writes machine-readable output, CSV for spreadsheets, JSON for dashboards. Why the compliance flag: scoping the run to cis_2.0_aws keeps the first audit focused on an agreed baseline instead of an unmanageable wall of findings from every framework at once.

prowler aws --compliance cis_2.0_aws -M csv json-ocsf -o ./out
FAIL s3_bucket_public_access: 3 buckets public
FAIL iam_root_mfa_enabled: root MFA disabled
PASS cloudtrail_multi_region_enabled

Expect the first full run to take 10-20 minutes on a fresh account. When it finishes, build a severity picture from the CSV before diving into individual findings:

TRIAGE, count failures and rank the noisiest checks

cd ./out
grep -c ",FAIL," prowler-output-*.csv
grep ",FAIL," prowler-output-*.csv | cut -d',' -f4 | sort | uniq -c | sort -rn | head

Expected output (illustrative)

47
     12 s3_bucket_public_access
      8 ec2_securitygroup_allow_ingress_from_any_ip_to_any_port
      6 iam_access_key_rotated_90_days
      4 iam_root_mfa_enabled

How to verify: generate the HTML summary too (prowler aws -M html) and confirm its FAIL count matches the CSV. Common mistake: running Prowler with credentials missing a service's read permissions, those checks silently report as MANUAL or get skipped, so a "clean" result can actually mean "could not look". Check the run summary line for skipped checks before trusting the score.

By default Prowler scans every enabled region, which matters: forgotten regions are exactly where attackers stage resources. While iterating on fixes, scope the loop with --region ca-central-1 or --service s3 for speed, but always run the final, recorded audit unscoped so no region is invisible.

2. Audit Azure with ScoutSuite

What: ScoutSuite pulls the Azure management-plane configuration through the authenticated CLI session and renders an offline HTML report with findings grouped by service and CIS control. Why ScoutSuite here: its Azure coverage (Entra ID, Storage, Networking, SQL Database) complements Prowler, whose Azure module was still maturing at the time of this lab.

scout azure --cli --report-dir ./azure-out

The run drops a self-contained report under ./azure-out/scoutsuite-results/ (an HTML report plus a raw results file). To triage from the terminal instead of clicking through the report, rank findings by their flagged-item counts:

TRIAGE, noisiest Azure findings from the raw results

grep -rho '"flagged_items": [0-9]*' ./azure-out/scoutsuite-results/ | sort -t: -k2 -rn | head

Expected output (illustrative)

"flagged_items": 6
"flagged_items": 4
"flagged_items": 2

On large subscriptions, re-scan services individually, it isolates throttling failures and lets you re-run one service without repeating the whole audit:

PER-SERVICE, re-run only the noisy services

scout azure --cli --services storageaccounts --report-dir ./azure-out/storage
scout azure --cli --services network --report-dir ./azure-out/network

In this environment the top counts were storage accounts not requiring secure transfer (6), NSG rules allowing any:any inbound (4), and SQL databases with auditing disabled (2). How to verify: spot-check two flagged resources directly with az, for example az storage account show -n <acct> --query enableHttpsTrafficOnly, and confirm the report matches reality. Common mistake: treating every flagged item as equally urgent. ScoutSuite flags informational gaps too; cross-reference each item against its CIS Azure Foundations control ID before queueing it for remediation.

3. Remediate a public bucket with Terraform

What: the highest-severity AWS findings were three publicly readable S3 buckets (ATT&CK T1530). The fix is an aws_s3_bucket_public_access_block resource per bucket, expressed in Terraform so the change is code-reviewed, versioned, and repeatable. Why all four flags: ACLs and bucket policies are independent exposure paths, blocking only one leaves the other open.

main.tf, block public access on the offending buckets

resource "aws_s3_bucket_public_access_block" "fix" {
  for_each = toset(["legacy-logs-2023", "team-assets", "backup-export-tmp"])

  bucket                  = each.key
  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

Per-bucket blocks fix today's leak; an account-level Public Access Block stops the next bucket from ever going public, even if someone creates it with a permissive ACL or policy:

main.tf, account-level Public Access Block (defense in depth)

resource "aws_s3_account_public_access_block" "account" {
  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

Preview before applying, the plan must show only the access-block resources being created, with nothing modified or destroyed:

PLAN, review the exact change set first

terraform init
terraform plan -out=fix.tfplan
terraform apply fix.tfplan
aws_s3_bucket_public_access_block.fix["legacy-logs-2023"]: Creation complete
aws_s3_bucket_public_access_block.fix["team-assets"]: Creation complete
aws_s3_bucket_public_access_block.fix["backup-export-tmp"]: Creation complete
aws_s3_account_public_access_block.account: Creation complete
Apply complete! Resources: 4 added, 0 changed, 0 destroyed.

Why the saved plan, not -target: you just reviewed fix.tfplan, applying that exact file is what makes the review meaningful. A -target run applies a different change set than the one you approved, and it would have left the account-level block behind. Targeted applies are for emergencies, not for the remediation you already planned.

How to verify: confirm the setting from the AWS API itself rather than trusting Terraform state:

VERIFY, the cloud API agrees the bucket is blocked

aws s3api get-public-access-block --bucket legacy-logs-2023 \
  --query 'PublicAccessBlockConfiguration.BlockPublicAcls'

Expected output

true

Common mistake: fixing the bucket ACL but leaving a permissive bucket policy in place. restrict_public_buckets = true neutralizes public policies, but still audit the policy with aws s3api get-bucket-policy --bucket <name>, an overly broad principal is a finding even when it is not currently public.

4. Close the root-MFA finding

What: iam_root_mfa_enabled failed, meaning the account's most powerful identity had single-factor protection. Why manual: root MFA enrollment is deliberately a console-only human action, it cannot (and should not) be automated through the API. After enrolling a hardware or virtual MFA device on the root account, confirm it programmatically:

VERIFY, account summary must report MFA on root

aws iam get-account-summary --query 'SummaryMap.AccountMFAEnabled'

Expected output

1

While working in the root account, also confirm root has no access keys, an existing root key is a critical finding even if it has never been used:

VERIFY, root must have zero access keys

aws iam get-account-summary --query 'SummaryMap.AccountAccessKeysPresent'

Expected output

0

How to verify: 1 means enabled; re-run the single check with prowler aws -c iam_root_mfa_enabled and it flips to PASS. Common mistake: enabling MFA on the everyday IAM admin user and assuming the root finding is closed, they are different identities, and the check only looks at root.

5. Enforce guardrails with Azure Policy

What: Azure remediation is split in two: fix the currently non-compliant resources, then assign a deny policy so the misconfiguration cannot return. Why policy-as-code: a one-off fix drifts within weeks; an Azure Policy assignment evaluates every new resource at creation time.

FIX NOW, require HTTPS on every storage account

az storage account list --query "[?enableHttpsTrafficOnly==\`false\`].name" -o tsv | \
  while read -r acct; do
    az storage account update --name "$acct" --https-only true
  done

The same pattern closes the NSG findings, replace any:any inbound rules with a deny and add scoped allows only where the application actually needs them:

FIX NOW, replace the any:any inbound rule with a deny

az network nsg rule update -g rg-prod --nsg-name nsg-web -n allow-any-inbound \
  --access Deny --source-address-prefixes "*" --destination-port-ranges "*" --priority 100
az network nsg rule create -g rg-prod --nsg-name nsg-web -n allow-https-from-appgw \
  --access Allow --source-address-prefixes "10.0.1.0/24" --destination-port-ranges 443 --priority 110

GUARDRAIL, deny future non-compliant storage accounts

az policy assignment create \
  --name "deny-storage-no-https" \
  --display-name "Storage accounts must use secure transfer" \
  --policy "404c3081-a854-4457-ae30-26a93ef643f9" \
  --params '{"effect":{"value":"Deny"}}' \
  --scope "/subscriptions/$(az account show --query id -o tsv)"

Expected output (trimmed)

{
  "name": "deny-storage-no-https",
  "enforcementMode": "Default",
  "policyDefinitionId": ".../providers/Microsoft.Authorization/policyDefinitions/404c3081-a854-4457-ae30-26a93ef643f9"
}

How to verify: deploy a throwaway storage account with --https-only false in a test resource group, the deployment must fail with a RequestDisallowedByPolicy error. Delete the test group afterwards. Common mistake: assigning the policy at resource-group scope and assuming the subscription is covered, scope decides coverage, so assign at the subscription (or management group) level.

6. Schedule recurring scans for drift detection

What: a posture snapshot is stale the day it is taken, so the scanners run nightly from the CI runner on a cron schedule, with findings uploaded as artifacts and appended to the trend store. Why OIDC: the pipeline assumes the read-only audit role through workload identity federation, there are no long-lived cloud keys sitting in CI secrets.

.github/workflows/cspm-scan.yml, scheduled nightly audit

name: cspm-scan
on:
  schedule:
    - cron: "30 2 * * *"   # 02:30 UTC nightly
  workflow_dispatch:

permissions:
  id-token: write
  contents: read

jobs:
  audit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with: { python-version: "3.12" }
      - run: pip install prowler-cloud
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/ProwlerAuditRole
          aws-region: ca-central-1
      - run: prowler aws --compliance cis_2.0_aws -M csv json-ocsf --output-directory findings/
      - uses: actions/upload-artifact@v4
        with: { name: prowler-findings, path: findings/ }

The same workflow audits Azure in a second job, authenticating with a federated workload identity (no stored client secret):

AZURE JOB, same workflow file, second job

  audit-azure:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/setup-python@v5
        with: { python-version: "3.12" }
      - run: pip install scoutsuite
      - uses: azure/login@v2
        with:
          client-id: ${{ vars.AZURE_CLIENT_ID }}
          tenant-id: ${{ vars.AZURE_TENANT_ID }}
          subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}
      - run: scout azure --cli --report-dir azure-out/
      - uses: actions/upload-artifact@v4
        with: { name: scout-findings, path: azure-out/ }

ROLLUP, one compliance percentage per scan day, from the artifacts

#!/bin/bash
# compliance = PASS / (PASS + FAIL) across the day's CSV
f=findings/prowler-output-*.csv
pass=$(grep -c ";PASS;" $f); fail=$(grep -c ";FAIL;" $f)
echo "$(date +%F) $(( 100 * pass / (pass + fail) ))" >> compliance-trend.txt

Expected trend output (illustrative)

2026-06-14 61
2026-06-15 74
2026-06-16 92

How to verify: after the first scheduled run, the Actions tab shows a green nightly job and compliance-trend.txt gains one line per day. Common mistake: changing scan flags between runs (CIS-only one day, full scan the next), the trend line then jumps for reasons unrelated to real posture. Keep the scan scope identical between runs.

7. Publish the posture dashboard

What: both clouds' findings land in a single store, and a small static dashboard renders compliance percentage per benchmark over time. Why: a number that only moves when someone remembers to look is not a control, the trend line is what catches drift between audits, and it is the artifact that answers management's "are we getting better?" question.

The dashboard reads a flat JSON feed that the rollup script appends to after every scan, one object per cloud per day:

posture.json, feed the dashboard renders

[
  { "date": "2026-06-14", "cloud": "aws",   "benchmark": "cis_2.0", "compliance": 61 },
  { "date": "2026-06-15", "cloud": "aws",   "benchmark": "cis_2.0", "compliance": 74 },
  { "date": "2026-06-16", "cloud": "aws",   "benchmark": "cis_2.0", "compliance": 92 },
  { "date": "2026-06-16", "cloud": "azure", "benchmark": "cis_2.1", "compliance": 88 }
]

How to verify: the percentage computed for a re-scan day must equal the number shown in Prowler's own HTML summary for that same run. If they disagree, the rollup script is parsing the wrong delimiter or reading stale artifacts. Common mistake: mixing AWS and Azure findings into one percentage, the two benchmarks have different control counts and severities, so track one trend line per cloud and per framework.

Step Flow Map

Each box is one step in the lab; work them in order, since later steps build on earlier state.

How It Works

CSPM scanners are read-only API enumerators. Prowler assumes an AWS role and calls Describe and List APIs across every region, evaluating each response against a check mapped to a CIS control; ScoutSuite does the equivalent against Azure Resource Manager and Microsoft Graph. Because the scan identity holds no write permissions, the audit cannot become an attack path even if its credentials leak, which is why this lab creates dedicated read-only roles first.

CIS benchmarks turn secure configuration from an opinion into a checklist: each control has an ID, a rationale, and an audit procedure. The compliance percentage used here is PASS divided by PASS plus FAIL, deliberately excluding INFO and MANUAL results so unevaluated checks never inflate the score. Every finding keeps its control ID, severity, resource identifier, and region, which is the metadata that makes triage and trend tracking possible later.

The loop only matters if it repeats. One scan is a snapshot; scheduled scans turn posture into a trend line, and expressing every fix as Terraform or policy means remediation is reviewed, versioned, and reversible. When the next scan runs, the previously failing control should flip to PASS, which is the only proof that counts: the scanner re-measuring reality, not a human marking a ticket done.

Validation & Testing

Re-run Prowler scoped to the S3 checks and confirm the previously failing controls now PASS. The posture dashboard should reflect the improved CIS compliance percentage (alt text: before/after CIS compliance bar chart; my run reached 92% post-remediation, yours will vary).

Verification is always done against the cloud API, never against Terraform state or the scanner's cache. Three independent checks closed the loop:

RE-SCAN, only the previously failing checks

prowler aws -c s3_bucket_public_access iam_root_mfa_enabled

Expected output

PASS s3_bucket_public_access: 0 buckets public
PASS iam_root_mfa_enabled: root MFA enabled

NEGATIVE TEST, anonymous request against the fixed bucket

curl -s -o /dev/null -w "%{http_code}\n" https://legacy-logs-2023.s3.amazonaws.com/

Expected output

403

A 403 from an unauthenticated client proves the bucket is closed to the internet; 200 would mean the fix did not hold. On the Azure side, re-running ScoutSuite and diffing flagged-item counts against the first report confirmed the storage and networking findings dropped to zero, and the deny policy rejected a deliberately non-compliant test deployment exactly as expected. Only after all three signals agreed did I mark the findings closed in the tracker.

Troubleshooting

Quick-reference for the failures you are most likely to hit in this lab.

SymptomLikely causeFix
Prowler exits with an auth errorThe scan is using the wrong role or expired temporary credentialsRun aws sts get-caller-identity first; the assumed role ARN should match the audit role.
Azure scan returns almost no findingsThe identity lacks Security Reader, so Defender and policy states come back emptyAssign Reader plus Security Reader at subscription scope and re-run; empty is not the same as clean.
Scan takes hoursEvery region and every check is enabledScope --region to the regions you actually use and run the full sweep weekly instead of daily.
Findings you know are fixed still show FAILBenchmark version drift between runs, or the fix has not propagatedPin the benchmark version in the scan config and re-run after the platform propagation delay.
Scheduled scan produces no outputThe CI secret or role trust expired silentlyRotate the credential and add a canary check that fails loudly when output is empty.
Advanced: Troubleshooting
  • Prowler AccessDenied: attach the managed SecurityAudit + ViewOnlyAccess policies. Then re-run with prowler aws --log-level ERROR to see exactly which API calls are denied, the error names the missing action, which tells you which managed policy to add. Never "fix" this by widening to AdministratorAccess; that defeats the point of a read-only auditor.
  • ScoutSuite throttling: add --max-workers 5 and run per-service. On large subscriptions the Azure management API returns HTTP 429 under aggressive scanning; lowering the request rate and scanning one service at a time (storage, then network, then SQL) completes reliably without tripping the limits.
  • Terraform drift returns: enforce the guardrail with an SCP or Azure Policy denyAssignment. If terraform plan shows the same public-access fix reappearing every week, someone is changing it outside the pipeline, find the actor first with aws cloudtrail lookup-events --lookup-attributes AttributeKey=EventName,AttributeValue=PutBucketAcl, then enforce.
  • Zero findings on a known-bad account: the scanner is probably looking at the wrong account or region set. Confirm aws sts get-caller-identity / az account show, and check the run summary for skipped services, a scanner that cannot see a service reports silence, not failure.
  • Azure Policy compliance stuck at "Not started": evaluation is not instant after an assignment. Trigger a scan on demand with az policy state trigger-scan and query results with az policy state list --policy-assignment deny-storage-no-https instead of waiting hours for the scheduled evaluation cycle.

Key Results

Numbers from the first full audit-remediate-verify cycle across both clouds:

  • Identified 47 misconfigurations across AWS and Azure in the first audit.
  • Remediated 100% of public S3 bucket exposures within 1 hour.
  • My run reached 92% compliance against CIS benchmarks after remediation; treat the number as one data point, not a guarantee.
  • Scheduled recurring scans giving continuous drift detection on both clouds.

Key Takeaways

  • Scan read-only, fix in code: auditors run under SecurityAudit/Reader roles, and every fix lands as Terraform or Azure Policy, no console-only changes.
  • Scope scans to one CIS benchmark per run so results stay comparable day over day and the trend line means something.
  • Verify against the cloud API, and with anonymous negative tests, not against scanner output or Terraform state.
  • Per-resource fixes stop today's exposure; account-level public-access blocks and deny policies stop tomorrow's.
  • Map findings to ATT&CK techniques (T1530, T1078.004) so remediation order follows real attack paths, not checklist order.
  • Posture is a trend, not a snapshot: schedule the scans, keep the history, and alert when the FAIL count moves the wrong way.

Practice Challenges

Challenge 1: Write a custom Prowler check

Goal: Add a company-specific check, for example flagging storage accounts without your mandatory tag, and see it in the findings.

Hint: Copy an existing check's structure; the metadata JSON is where the CIS mapping lives.

Challenge 2: Trend the findings

Goal: Build a simple dashboard from the dated findings directories showing PASS rate per benchmark over the last 30 days.

Hint: The CSV output already has control ID, status, and region; a small script plus any charting tool is enough.

Challenge 3: Remediate and re-prove

Goal: Fix public blob access with Terraform, re-run the scan, and show the control flipping from FAIL to PASS.

Hint: The rescan is the deliverable; a fix without a re-measurement is just a claim.