
How to Audit a GCP Project for the CIS Benchmark with gcloud (Script Included) | 2026
A CIS benchmark audit of a GCP project is a set of read-only checks against IAM policies, audit logging, firewall rules, and storage configuration — this guide implements the highest-impact CIS Google Cloud Foundations controls as a single gcloud/bash script that prints PASS/FAIL per control and exits non-zero on any failure, so it runs identically on a laptop and in CI.
By Mateusz Chmielewski · Aug 17, 2026 · 14 min read
What Is a CIS Benchmark Audit on GCP?
The CIS Google Cloud Computing Platform Foundations Benchmark is a consensus-built catalog of security configuration controls — things like 'no user-managed service account keys', 'audit logging enabled for all services', 'no 0.0.0.0/0 ingress on SSH/RDP'. Auditing a project means evaluating each control against live configuration and producing evidence of pass or fail. Because every control maps to an API-readable setting, the entire audit can be automated with gcloud and bash instead of manual console clicks.
Think of it like a restaurant health inspection. The inspector does not cook or serve — they walk a fixed checklist (handwashing stations, fridge temperatures, fire exits), mark each item compliant or not, and hand over a scorecard. Your gcloud script is the inspector, the CIS benchmark is the checklist, and the project is the kitchen — and unlike a surprise visit, you can run the inspection every night.
| Concept | Explanation | When to use |
|---|---|---|
| CIS Control | A single prescriptive rule (e.g. 1.4 — no user-managed SA keys) with a defined audit and remediation procedure. | As the unit of work for the script — one check function per control. |
| Scored vs Unscored | Scored controls count toward CIS compliance tooling reports; unscored are best-practice recommendations. | Prioritize scored Level 1 controls first for maximum risk reduction per effort. |
| Level 1 vs Level 2 | Level 1 controls are broadly applicable with minimal friction; Level 2 adds defense-in-depth with operational trade-offs. | Ship Level 1 everywhere; adopt Level 2 selectively for sensitive projects. |
| Evidence Output | The per-control PASS/FAIL line plus the offending resource names the script prints. | Attach to tickets and compliance reviews — an assertion without resource names is not actionable. |
Why Script the Audit Instead of Using the Console?
Manual console reviews do not scale and do not repeat — a project that passed review in March drifts by June as engineers add firewall rules, create service account keys for a quick integration, or bind primitive roles to unblock a deploy. Point-in-time screenshots satisfy nobody in an audit, and by the time a yearly review happens, the misconfiguration has been exploitable for months.
A gcloud-based script turns the benchmark into a fast, deterministic, diff-able artifact: run it nightly, gate merges on it, and every failure prints the exact offending resource so remediation is a copy-paste. It is the read-only complement to preventive controls — pair it with [GCP Organization Policies to Enable by Default](/tutorial/gcp-organization-policies-defaults-terraform-module) to stop drift at creation time, and [Fix Over-Privileged IAM with Policy Analyzer](/tutorial/fix-overprivileged-iam-policy-analyzer-recommender) for deeper role analysis.
| Feature | thisService | altA | altB |
|---|---|---|---|
| Cost | Free (gcloud API calls) | Security Command Center Premium (~$$/mo) | Manual console review (engineer hours) |
| Frequency | Every commit / nightly cron | Continuous, built-in | Quarterly at best |
| Customization | Full — edit checks, add waivers | Fixed detector set, some tuning | Depends on reviewer memory |
| CI integration | Native exit codes | Via API export, extra glue | None |
| Coverage depth | Controls you implement | 1000+ detectors incl. threat intel | Whatever the checklist covered that day |
Prerequisites
- GCP project (or folder of projects) with billing enabled
- gcloud CLI v450.0+ installed and authenticated
- `roles/iam.securityReviewer` and `roles/viewer` on the target project (read-only — the script changes nothing)
- For organization-level controls (e.g. domain-restricted sharing): `roles/resourcemanager.organizationViewer` on the org
- bash 4+ and `jq` installed locally or in the CI runner
Step-by-Step Guide
Step 1: Scaffold the Script and Verify Read-Only Access
Create the audit script skeleton — strict bash mode, target project parameter, PASS/FAIL helper — and verify the caller's identity has the read-only roles needed, so failures in later steps are real findings, not permission errors. An audit that silently cannot read a resource reports a false PASS (or a misleading FAIL). Validating access up front makes every later line trustworthy, and strict mode (`set -euo pipefail`) prevents half-run audits from looking complete.
#!/usr/bin/env bash
# cis-audit.sh — CIS GCP Foundations spot-check audit (read-only)
set -euo pipefail
PROJECT_ID="${1:?Usage: cis-audit.sh <PROJECT_ID>}"
FAILURES=0
pass() { echo "PASS $1"; }
fail() { echo "FAIL $1"; echo " → $2"; FAILURES=$((FAILURES+1)); }
# Verify access before auditing
gcloud projects describe "$PROJECT_ID" --format='value(projectId)' >/dev/null
gcloud projects get-iam-policy "$PROJECT_ID" --format=json >/dev/null
echo "Auditing project: $PROJECT_ID"
Step 2: Audit IAM — Primitive Roles, SA Keys, and Public Bindings
Implement the highest-risk IAM controls: CIS 1.1 (no corporate-domain outsiders), 1.4/1.5 (no user-managed service account keys), 1.6 (no user bindings to Owner/Editor primitive roles), and no allUsers/allAuthenticatedUsers grants. These four checks catch the misconfigurations behind most real GCP breaches — a leaked long-lived SA key or a public bucket binding is an incident, not a finding. Primitive roles on users defeat least privilege by construction.
# CIS 1.6 — no users bound to primitive roles
POLICY=$(gcloud projects get-iam-policy "$PROJECT_ID" --format=json)
PRIMITIVE=$(echo "$POLICY" | jq -r '
.bindings[] | select(.role | IN("roles/owner","roles/editor","roles/viewer"))
| .members[] | select(startswith("user:"))' | sort -u)
if [ -z "$PRIMITIVE" ]; then
pass "1.6 no user accounts with primitive roles"
else
fail "1.6 users hold primitive roles" "$PRIMITIVE"
fi
# Public bindings — allUsers / allAuthenticatedUsers
PUBLIC=$(echo "$POLICY" | jq -r '
.bindings[].members[]
| select(. == "allUsers" or . == "allAuthenticatedUsers")' | sort -u)
if [ -z "$PUBLIC" ]; then
pass "IAM has no allUsers/allAuthenticatedUsers bindings"
else
fail "Public IAM bindings present" "$PUBLIC"
fi
# CIS 1.4/1.5 — user-managed service account keys
KEY_FINDINGS=""
for SA in $(gcloud iam service-accounts list --project="$PROJECT_ID" \
--format='value(email)'); do
KEYS=$(gcloud iam service-accounts keys list \
--iam-account="$SA" --project="$PROJECT_ID" \
--managed-by=user --format='value(name)')
[ -n "$KEYS" ] && KEY_FINDINGS="$KEY_FINDINGS $SA"
done
if [ -z "$KEY_FINDINGS" ]; then
pass "1.4/1.5 no user-managed service account keys"
else
fail "User-managed SA keys exist" "$KEY_FINDINGS"
fi
Step 3: Audit Logging — Audit Logs, Sinks, and Metric Filters
Implement CIS 2.x controls: Admin Activity audit logs enabled for all services, at least one log sink with no inclusion filter exporting to a durable destination, and log-based metrics with alerts for IAM policy and audit-config changes. Detection is the second pillar of the benchmark — without an unfiltered sink and metric filters on IAM changes, privilege escalations leave no durable trail outside the default retention window, and incident response becomes archaeology.
# CIS 2.1 — at least one empty-filter sink (exports everything)
EMPTY_SINKS=$(gcloud logging sinks list --project="$PROJECT_ID" \
--format='value(name)' --filter='filter=""')
if [ -n "$EMPTY_SINKS" ]; then
pass "2.1 unfiltered log sink configured"
else
fail "2.1 no unfiltered log sink" "create a sink with empty --log-filter"
fi
# CIS 2.4/2.5 — metric filters for IAM and audit config changes
METRICS=$(gcloud logging metrics list --project="$PROJECT_ID" \
--format='value(filter)')
if echo "$METRICS" | grep -q 'protoPayload.methodName="SetIamPolicy"'; then
pass "2.4 log metric for IAM policy changes exists"
else
fail "2.4 missing metric for SetIamPolicy" "no alert path for IAM changes"
fi
if echo "$METRICS" | grep -q 'protoPayload.serviceName="cloudaudit.googleapis.com"'; then
pass "2.5 log metric for audit config changes exists"
else
fail "2.5 missing metric for audit config changes" "audit toggles go unnoticed"
fi
Step 4: Audit Networking — Firewalls, Default Network, Flow Logs
Implement CIS 3.x controls: no default network, no 0.0.0.0/0 ingress on ports 22/3389, and VPC flow logs enabled on every subnet. An open SSH/RDP rule to the world is the single most-scanned misconfiguration on the internet — bots find it within minutes of creation. The default network ships with permissive legacy rules, which is why the benchmark wants it gone entirely.
# CIS 3.1 — default network must not exist
if gcloud compute networks describe default --project="$PROJECT_ID" \
--format='value(name)' 2>/dev/null | grep -q default; then
fail "3.1 default network exists" "delete it and use explicit VPCs"
else
pass "3.1 no default network"
fi
# CIS 3.6/3.7 — no world-open SSH (22) or RDP (3389)
OPEN=$(gcloud compute firewall-rules list --project="$PROJECT_ID" \
--format=json | jq -r '
.[] | select(.disabled != true)
| select(.direction == "INGRESS")
| select(.sourceRanges[]? == "0.0.0.0/0")
| select(.allowed[]?.ports[]? | test("^(22|3389)$|^22-|^.*-3389
chmielewski.dev | GCP Engineering & Architecture Blueprints
Insights, guides, and architectural blueprints for creators shipping on Google Cloud Platform.
quot;))
| .name' 2>/dev/null | sort -u)
if [ -z "$OPEN" ]; then
pass "3.6/3.7 no 0.0.0.0/0 ingress on 22/3389"
else
fail "Open SSH/RDP firewall rules" "$OPEN"
fi
# CIS 3.9 — flow logs on every subnet
NO_FLOW=$(gcloud compute networks subnets list --project="$PROJECT_ID" \
--format='value(name,region)' --filter='enableFlowLogs=false')
if [ -z "$NO_FLOW" ]; then
pass "3.9 VPC flow logs enabled on all subnets"
else
fail "Subnets without flow logs" "$NO_FLOW"
fi
Step 5: Audit Storage — Public Buckets and Uniform Access
Implement CIS 5.x controls: no buckets readable by allUsers/allAuthenticatedUsers and uniform bucket-level access enforced everywhere (no legacy ACLs). Public buckets are a perennial data-leak headline. Uniform bucket-level access removes the parallel ACL permission system entirely, making IAM the single source of truth and the audit deterministic.
PUBLIC_BUCKETS=""
ACL_BUCKETS=""
for B in $(gcloud storage buckets list --project="$PROJECT_ID" \
--format='value(name)'); do
IAM=$(gcloud storage buckets get-iam-policy "gs://$B" --format=json 2>/dev/null || true)
if echo "$IAM" | jq -e '.bindings[]?.members[]?
| select(. == "allUsers" or . == "allAuthenticatedUsers")' >/dev/null 2>&1; then
PUBLIC_BUCKETS="$PUBLIC_BUCKETS $B"
fi
UBLA=$(gcloud storage buckets describe "gs://$B" \
--format='value(iamConfiguration.uniformBucketLevelAccessEnabled)')
[ "$UBLA" != "True" ] && ACL_BUCKETS="$ACL_BUCKETS $B"
done
if [ -z "$PUBLIC_BUCKETS" ]; then
pass "5.1 no public buckets"
else
fail "Public buckets found" "$PUBLIC_BUCKETS"
fi
if [ -z "$ACL_BUCKETS" ]; then
pass "5.2 uniform bucket-level access everywhere"
else
fail "Buckets with legacy ACLs" "$ACL_BUCKETS"
fi
Step 6: Add the Scorecard and Exit Code
Finish the script with a summary footer — counts of passed/failed controls and a non-zero exit on any failure — so CI systems gate on it natively and humans get a one-glance scorecard. Exit codes are the contract with automation. A script that prints red text but exits 0 will be wired into a pipeline and pass forever; `exit $((FAILURES > 0))` is what makes the audit a gate instead of a decoration.
echo "----------------------------------------"
if [ "$FAILURES" -eq 0 ]; then
echo "RESULT: all audited CIS controls passed for $PROJECT_ID"
exit 0
else
echo "RESULT: $FAILURES control(s) FAILED for $PROJECT_ID"
exit 1
fi
Step 7: Run It Nightly from Cloud Scheduler or CI
Operationalize the audit — a nightly Cloud Build / GitHub Actions job running the script per project, publishing the scorecard, and failing the pipeline on regressions. The benchmark's value compounds with cadence. Nightly execution turns configuration drift into a next-morning ticket instead of a next-quarter surprise, and historical scorecards are exactly what external auditors ask for.
# .github/workflows/cis-audit.yml
name: cis-audit
on:
schedule: [{ cron: "17 3 * * *" }] # nightly
workflow_dispatch:
jobs:
audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: google-github-actions/auth@v2
with:
workload_identity_provider: ${{ vars.WIF_PROVIDER }}
service_account: ${{ vars.AUDITOR_SA }}
- run: ./cis-audit.sh ${{ vars.TARGET_PROJECT }}
Verification & Health Check
Best Practices
- Read-Only Auditor Identity
- Fail Loud with Resource Names
- Waiver Files, Not Deleted Checks
- Detect AND Prevent
Common Mistakes
- {"errorCode":"PERMISSION_DENIED (mid-audit)","symptoms":"Script aborts partway with 403 on `logging sinks list` or `service-accounts keys list`.","rootCause":"The caller has viewer but not securityReviewer (or vice versa), or the API for that service is not enabled in the project being audited.","fixCommand":"gcloud projects add-iam-policy-binding $PROJECT_ID \\\n --member=\"serviceAccount:cis-auditor@$PROJECT_ID.iam.gserviceaccount.com\" \\\n --role=\"roles/iam.securityReviewer\"\n","code":"resource \"google_project_iam_member\" \"auditor\" {\n for_each = toset([\"roles/viewer\", \"roles/iam.securityReviewer\"])\n project = var.project_id\n role = each.key\n member = \"serviceAccount:${google_service_account.cis_auditor.email}\"\n}\n","language":"hcl","filename":"fix-auditor-iam.tf","prevention":"Codify the auditor's roles in Terraform and enable required APIs project-wide before first run."}
- {"errorCode":"FALSE_POSITIVE_DEFAULT_NETWORK","symptoms":"CIS 3.1 check fails even though `gcloud compute networks list` shows no default network.","rootCause":"The `describe default` fallback grep matches a differently-cased or partially-deleted network record; the check should test the exact describe exit code.","fixCommand":"gcloud compute networks describe default --project=$PROJECT_ID; echo $?\n","code":"if gcloud compute networks describe default \\\n --project=\"$PROJECT_ID\" >/dev/null 2>&1; then\n fail \"3.1 default network exists\" \"delete it and use explicit VPCs\"\nelse\n pass \"3.1 no default network\"\nfi\n","language":"bash","filename":"fix-default-net.sh","prevention":"Test checks against both a compliant and a deliberately non-compliant project before trusting them in CI."}
- {"errorCode":"GCLOUD_COMPONENT_TOO_OLD","symptoms":"`--managed-by=user` or `gcloud storage` subcommands fail with unknown flag/unknown command.","rootCause":"Outdated gcloud installation — storage surface and key-management flags moved fast across 2023–2026 releases.","fixCommand":"gcloud components update && gcloud version\n","code":"# Pin a minimum CLI version at script start\nREQ=450.0.0\nCUR=$(gcloud version --format='value(\"Google Cloud SDK\")')\n[ \"$(printf '%s\\n' \"$REQ\" \"$CUR\" | sort -V | head -1)\" = \"$REQ\" ] \\\n || { echo \"gcloud >= $REQ required (found $CUR)\"; exit 2; }\n","language":"bash","filename":"fix-version.sh","prevention":"Version-guard the script and use a pinned gcloud image in CI instead of the runner's default."}
- {"errorCode":"AUDIT_TIMEOUT_LARGE_PROJECT","symptoms":"CI job hits its timeout while iterating hundreds of buckets or service accounts.","rootCause":"Sequential per-resource API calls scale linearly; large projects need concurrency.","fixCommand":"gcloud storage buckets list --project=$PROJECT_ID --format='value(name)' | \\\n xargs -P 8 -I{} gcloud storage buckets describe gs://{} \\\n --format='value(name,iamConfiguration.uniformBucketLevelAccessEnabled)'\n","code":"export -f check_bucket\ngcloud storage buckets list --project=\"$PROJECT_ID\" --format='value(name)' \\\n | xargs -P 8 -I{} bash -c 'check_bucket \"$@\"' _ {}\n","language":"bash","filename":"fix-parallel.sh","prevention":"Parallelize with `xargs -P` and cache API lists per run instead of re-listing inside loops."}
Cost Analysis
| Feature | metric | cost1k | cost10k | cost100k | cost1m |
|---|---|---|---|---|---|
| gcloud API read calls | $0.00 | $0.00 | $0.00 | $0.00 | |
| CI runner minutes (nightly) | $0.00 | $0.00 | $0.00 | $0.00 | |
| Security Command Center Premium (optional alternative) | — | — | subscription | subscription | |
| Log sink export storage (if enabled) | $0.01/mo | $0.05/mo | $0.50/mo | $5.00/mo |