Home / Security

How to Audit a GCP Project for the CIS Benchmark with gcloud (Script Included) | 2026

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.

ConceptExplanationWhen to use
CIS ControlA 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 UnscoredScored 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 2Level 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 OutputThe 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.

FeaturethisServicealtAaltB
CostFree (gcloud API calls)Security Command Center Premium (~$$/mo)Manual console review (engineer hours)
FrequencyEvery commit / nightly cronContinuous, built-inQuarterly at best
CustomizationFull — edit checks, add waiversFixed detector set, some tuningDepends on reviewer memory
CI integrationNative exit codesVia API export, extra glueNone
Coverage depthControls you implement1000+ detectors incl. threat intelWhatever 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

Featuremetriccost1kcost10kcost100kcost1m
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)subscriptionsubscription
Log sink export storage (if enabled)$0.01/mo$0.05/mo$0.50/mo$5.00/mo

References

Browse all tutorials