GCP Data Protection Hardening

Overview

This page covers Google Cloud Platform data-at-rest hardening across the surfaces that decide whether an attacker who reaches an authenticated principal — or a misconfigured bucket policy, or a leaked CI/CD service account, or a compromised partner — can read regulated data. Scope is the commercial GCP regions; GCP Sovereign Cloud (formerly Assured Workloads and the Google Cloud Air-Gapped offering) inherits the same controls but exposes a different region table, different service-availability matrices, and tenant topology constraints — re-verify region availability and the relevant cloud.google.com sovereign endpoint documentation before applying any of the IaC below to a sovereign or air-gapped deployment. CIS sub-IDs and NIST / ISO mappings throughout this page reference the CIS Google Cloud Platform Foundation Benchmark v4.0.0 — May 2025 release (accessed 2026-05) unless explicitly annotated as a post-v4.0.0 feature. The crosswalk page at compliance frameworks describes how the seven pinned framework columns relate to each other.

The GCP data plane is the product of Cloud Storage (buckets with their own IAM, uniform-bucket-level access toggle, three-layer public-access prevention model, retention policies, and per-bucket default CMEK key reference), Persistent Disks (boot and data volumes for Compute Engine VMs; encrypted at rest by Google-managed keys by default; CMEK is the upgrade for regulated workloads), Cloud SQL (managed MySQL / PostgreSQL / SQL Server with CMEK, Private IP via Service Networking, mandatory SSL, and the Cloud SQL Auth Proxy as the canonical client-connection model), BigQuery (datasets and tables with CMEK at dataset scope, plus policy-tag column-level security delivered through Dataplex Universal Catalog (formerly Data Catalog)), Secret Manager (the canonical secret store; CMEK-capable; audit-logged), Cloud KMS (key rings and crypto keys; software- or HSM-backed; rotation-policy-driven), and Sensitive Data Protection (formerly Cloud DLP — the discovery, profiling, inspection, and de-identification service that crosses every storage primitive above). The cryptographic root for everything regulated is Cloud KMS; the IAM root for Cloud KMS is the per-key (not per-key-ring) policy. The cross-cutting principles — encryption at rest, key management, data classification, data loss prevention, and retention, backup & recovery — are owned by the General Data Protection page; this page maps them to GCP primitives. Encryption in transit lives canonically at General Network — encryption in transit and is not re-authored here (Phase 4 canonical-content rule); the GCP network specifics — TLS termination at Global External HTTPS Load Balancers, Cloud SQL require_ssl = true, internal HTTPS for service-to-service — are covered alongside the GCP network surface at GCP Network Hardening.

Three anti-conflation callouts up front, because each pair gets conflated in audit reports and architecture reviews and the distinction matters for control design. First: Cloud Storage public-access prevention has THREE complementary enforcement layers, and missing any one re-opens the leak surface. (a) Organization Policy constraints/storage.publicAccessPrevention applied at organization or folder scope is a tenant-wide invariant that prevents any new bucket — including in projects that do not yet exist — from being created without public-access-prevention enforced. (b) The per-bucket public_access_prevention = "enforced" attribute pins the resource-level state and is what audit tools actually read when scanning an existing bucket. (c) uniform_bucket_level_access = true disables the legacy object-level ACL surface entirely so that even a misconfigured per-object grant cannot expose an object to allUsers / allAuthenticatedUsers. The canonical hardened posture sets all three — and that is what gcp-data-01 enforces. This is the GCP analog of the AWS Phase 6 aws-data-01 three-scope BPA pattern and the Azure Phase 7 azure-data-01 four-toggle storage pattern.

Second: CMEK at rest (gcp-data-02 / 03 / 04) and KMS IAM (gcp-data-05) form a single cryptographic chain, and compromise of the KMS admin role unwinds the chain. Cloud Storage CMEK, Persistent Disk CMEK, and Cloud SQL CMEK all reference Cloud KMS crypto keys; whichever principal holds roles/cloudkms.admin on those keys can rewrite the key IAM, grant cryptoKeyEncrypterDecrypter to a malicious service account, and read every byte the chain protects. KMS rotation (gcp-data-06) bounds the compromise window of used keying material but does not prevent compromise of the IAM surface — which is why rotation is typed MEDIUM DETECTIVE not PREVENTIVE (PITFALL B-14 application; mirrors the aws-data-06 and azure-data-06 decisions). The control at gcp-data-05 is therefore CRITICAL PREVENTIVE: it is the gating control for every CMEK-backed resource on this page.

Third: Sensitive Data Protection (formerly Cloud DLP) and Dataplex Universal Catalog (formerly Data Catalog) are two distinct services that are easy to conflate. Sensitive Data Protection is the runtime data-inspection plane — Discovery scans, Data Profiling on BigQuery datasets, Inspect Templates, and De-identification Templates with format-preserving encryption (FPE) and cryptographic hashing — and its API endpoint is still dlp.googleapis.com (which is why "Cloud DLP API" is still a current product name even though "Sensitive Data Protection" is the consolidated brand). Dataplex Universal Catalog (formerly Data Catalog) is the metadata / governance / lineage plane that hosts policy tags for BigQuery column-level security and taxonomies for data-classification labelling. BigQuery column-level security therefore depends on both: SDP scans identify sensitive columns, and Dataplex Universal Catalog policy tags gate the column reads. The two roles are covered in gcp-data-07 with policy-tag IaC examples folded into the prose; there is no standalone "BigQuery column-level security" control on this page because the pre-locked control inventory does not pre-lock one.

Order and scope matter. Controls 01–04 are foundational invariants enforced organization-wide via Org Policy and per-resource attributes: lock Cloud Storage against public access at three layers, force CMEK on every bucket / disk / Cloud SQL instance, and pair Cloud SQL with Private IP + require SSL so the storage layer is unreachable from the public internet. Control 05 hardens the IAM surface that gates the cryptographic chain. Control 06 closes the rotation loop on key material. Control 07 brings Sensitive Data Protection to bear on data already at rest (and references Dataplex Universal Catalog policy tags for BigQuery column-level security). Control 08 closes the immutability loop with Cloud Storage Bucket Lock, the GCP analog of S3 Object Lock Compliance mode and Azure Immutable Blob Storage locked mode. The VPC Service Controls identity-plane perimeter is owned by the GCP IAM page and cross-referenced from this page where relevant; do not re-author it here.

gcp-data-01-bucket-pap ! CRITICAL PREVENTIVE

Every Cloud Storage bucket in the organization has public-access prevention enforced via three independent toggles, and missing any one re-opens the leak surface. (a) Organization Policy constraints/storage.publicAccessPrevention is applied at organization or folder scope with enforce: TRUE — a tenant-wide invariant that survives project creation. (b) Each bucket carries public_access_prevention = "enforced" at the resource level. (c) Each bucket carries uniform_bucket_level_access = true so the legacy per-object ACL surface is disabled entirely (anonymous ACLs cannot be set even if a principal has storage.objects.setIamPolicy). CIS GCP v4.0.0 §5.1 and §5.2 codify the requirement (Google Cloud — Public access prevention documentation (accessed 2026-05)). CRITICAL because this is the canonical "object storage open to the internet" misconfiguration; the failure mode (a bucket inadvertently set to allUsers:objectViewer or with anonymous-readable objects) is single-step exploitable by any unauthenticated principal and is the most-cited data-leak story for the entire public-cloud era.

Remediation — gcloud CLI

# gcloud CLI (latest stable)
# Step 1: enforce public-access prevention at organization scope.
cat > pap-org.yaml <<'YAML'
name: organizations/ORG_ID/policies/storage.publicAccessPrevention
spec:
  rules:
  - enforce: true
YAML
gcloud org-policies set-policy pap-org.yaml --organization=ORG_ID

# Step 2: enforce uniform bucket-level access at organization scope.
cat > ubla-org.yaml <<'YAML'
name: organizations/ORG_ID/policies/storage.uniformBucketLevelAccess
spec:
  rules:
  - enforce: true
YAML
gcloud org-policies set-policy ubla-org.yaml --organization=ORG_ID

# Step 3: create a hardened bucket with both toggles at creation time.
gcloud storage buckets create gs://app-prod-regulated-euw1 \
  --project=svc-app-prod \
  --location=europe-west1 \
  --uniform-bucket-level-access \
  --public-access-prevention \
  --default-storage-class=STANDARD

# Step 4: remediate an existing bucket created before the org policy landed.
gcloud storage buckets update gs://legacy-bucket \
  --uniform-bucket-level-access \
  --public-access-prevention

# Step 5: audit the org for non-compliant buckets (PAP disabled or inherited).
for project in $(gcloud projects list --format='value(projectId)'); do
  gcloud storage buckets list --project="$project" \
    --format="value(name,iamConfiguration.publicAccessPrevention,iamConfiguration.uniformBucketLevelAccess.enabled)" \
    2>/dev/null
done

Remediation — Terraform

# Terraform Google provider ~> 5.0
# Source: Google Cloud docs (accessed 2026-05)
resource "google_org_policy_policy" "pap_org" {
  name   = "organizations/${var.org_id}/policies/storage.publicAccessPrevention"
  parent = "organizations/${var.org_id}"

  spec {
    rules {
      enforce = "TRUE"
    }
  }
}

resource "google_org_policy_policy" "ubla_org" {
  name   = "organizations/${var.org_id}/policies/storage.uniformBucketLevelAccess"
  parent = "organizations/${var.org_id}"

  spec {
    rules {
      enforce = "TRUE"
    }
  }
}

# Hardened bucket — all three toggles closed at creation time.
resource "google_storage_bucket" "app_prod_regulated" {
  project                     = var.svc_project_id
  name                        = "app-prod-regulated-euw1"
  location                    = "EUROPE-WEST1"
  storage_class               = "STANDARD"
  uniform_bucket_level_access = true
  public_access_prevention    = "enforced"

  # CMEK encryption block — paired with gcp-data-02 below.
  encryption {
    default_kms_key_name = google_kms_crypto_key.bucket.id
  }

  versioning {
    enabled = true
  }

  lifecycle {
    prevent_destroy = true
  }
}

Remediation — Config Connector

apiVersion: storage.cnrm.cloud.google.com/v1beta1
kind: StorageBucket
metadata:
  name: hardened-bucket
  namespace: config-control
  annotations:
    cnrm.cloud.google.com/project-id: PROJECT_ID
spec:
  location: us-central1
  uniformBucketLevelAccess: true
  publicAccessPrevention: enforced
  versioning:
    enabled: true

Remediation — Pulumi (TypeScript)

import * as gcp from "@pulumi/gcp";

// Bucket with public-access prevention enforced and uniform bucket-level access.
const hardenedBucket = new gcp.storage.Bucket("hardened-bucket", {
    name: "hardened-bucket",
    location: "US-CENTRAL1",
    uniformBucketLevelAccess: true,
    publicAccessPrevention: "enforced",
    versioning: { enabled: true },
    forceDestroy: false,
});

Compliance mapping

CIS AWS Foundations v3.0.0 CIS Microsoft Azure Foundations v3.0.0 CIS GCP Foundation v4.0.0 CIS OCI Foundation v2.0.0 NIST SP 800-53 rev5 ISO/IEC 27001:2022 ISO/IEC 27017:2015
n/an/a5.1; 5.2n/a AC-3; AC-6; SC-7A.5.10; A.8.3CLD.9.5.1

Log signals

  • Cloud Audit Logs on storage.googleapis.com for storage.setIamPermissions adding allUsers or allAuthenticatedUsers to a bucket's IAM policy.
  • Bucket-level buckets.update patches where iamConfiguration.publicAccessPrevention transitions from enforced to inherited.
  • Object ACL legacy mutations: storage.objects.update setting predefinedAcl to publicRead on individual objects bypassing the bucket-level guard.

Query

logName=~"projects/.*/logs/cloudaudit.googleapis.com%2Factivity"
          AND protoPayload.serviceName="storage.googleapis.com"
          AND ((protoPayload.methodName="storage.setIamPermissions"
                AND protoPayload.serviceData.policyDelta.bindingDeltas.member=~"allUsers|allAuthenticatedUsers")
               OR (protoPayload.methodName="storage.buckets.update"
                   AND protoPayload.request.iamConfiguration.publicAccessPrevention="inherited"))

Pin this Cloud Logging filter at organisation scope; pair with the Cloud Asset Inventory bucket inventory query (storage.googleapis.com/Bucket with iamPolicy snapshot) so the steady-state public-bucket count is visible alongside change events.

Alert threshold

  • Page on any binding that adds allUsers/allAuthenticatedUsers to a bucket; the steady-state count of public buckets is zero outside the documented static-website allow-list.
  • Page on any bucket flipping publicAccessPrevention away from enforced.

Initial response

  1. Remove the offending binding via gcloud storage buckets remove-iam-policy-binding and re-assert publicAccessPrevention=enforced.
  2. Pull the bucket's HTTP access logs (Cloud Storage usage logs) and Cloud Logging data-access logs for the exposure window; enumerate every object read by a non-authenticated requestor and treat them as exposed.
  3. If sensitive data was reachable, follow the breach-notification runbook in general/ir.html; rotate any signed-URL keys whose corresponding objects were in the exposed set.

References

Equivalent on: AWS · Azure · OCI

gcp-data-02-cmek ! HIGH PREVENTIVE

Regulated Cloud Storage buckets reference a customer-managed encryption key (CMEK) hosted in Cloud KMS via encryption.default_kms_key_name, and the organization-level constraint constraints/gcp.restrictNonCmekServices enumerates the services for which non-CMEK creation is forbidden (Cloud Storage, Compute Engine disks, Cloud SQL, BigQuery, Pub/Sub, Dataflow, Composer for the most-common regulated set). Google-managed (default) encryption remains acceptable for non-regulated data but is deprecated for any workload that needs to demonstrate cryptographic-erase, key-revocation, or per-tenant-key-separation properties (Google Cloud — Customer-managed encryption keys on Cloud Storage (accessed 2026-05)). The principle is reinforced in General Data — key management. Anti-conflation: Cloud Storage encryption is always on at the platform level with Google-managed keys; CMEK is the upgrade that puts the organization's Cloud KMS key on the cryptographic-erase path (revoke or schedule destruction of the key version → Cloud Storage reads start failing with HTTP 400 KMS key not found, even though Google still operates the storage hardware). HIGH PREVENTIVE because CMEK reduces the trust boundary from "Google's key management" to "the organization's Cloud KMS posture" — which is only an improvement if the controls in gcp-data-05 and gcp-data-06 hold.

Remediation — gcloud CLI

# gcloud CLI (latest stable)
# Step 1: enforce CMEK at org scope for the regulated service list.
cat > cmek-restrict.yaml <<'YAML'
name: organizations/ORG_ID/policies/gcp.restrictNonCmekServices
spec:
  rules:
  - values:
      allowedValues:
      - storage.googleapis.com
      - compute.googleapis.com
      - sqladmin.googleapis.com
      - bigquery.googleapis.com
YAML
gcloud org-policies set-policy cmek-restrict.yaml --organization=ORG_ID

# Step 2: create a Cloud KMS key ring + crypto key in the workload region.
gcloud kms keyrings create kr-app-prod-euw1 \
  --project=sec-keys-prod \
  --location=europe-west1

gcloud kms keys create k-bucket-app-prod \
  --project=sec-keys-prod \
  --location=europe-west1 \
  --keyring=kr-app-prod-euw1 \
  --purpose=encryption \
  --rotation-period=90d \
  --next-rotation-time=$(date -u -d '+7 days' +%Y-%m-%dT%H:%M:%SZ)

# Step 3: grant the bucket service-agent permission to use the key.
PROJECT_NUMBER=$(gcloud projects describe svc-app-prod --format='value(projectNumber)')
gcloud kms keys add-iam-policy-binding k-bucket-app-prod \
  --project=sec-keys-prod \
  --location=europe-west1 \
  --keyring=kr-app-prod-euw1 \
  --member=serviceAccount:service-${PROJECT_NUMBER}@gs-project-accounts.iam.gserviceaccount.com \
  --role=roles/cloudkms.cryptoKeyEncrypterDecrypter

# Step 4: attach the CMEK key to the bucket.
gcloud storage buckets update gs://app-prod-regulated-euw1 \
  --default-encryption-key=projects/sec-keys-prod/locations/europe-west1/keyRings/kr-app-prod-euw1/cryptoKeys/k-bucket-app-prod

Remediation — Terraform

# Terraform Google provider ~> 5.0
# Source: Google Cloud docs (accessed 2026-05)
resource "google_kms_key_ring" "app_prod_euw1" {
  project  = var.keys_project_id
  name     = "kr-app-prod-euw1"
  location = "europe-west1"
}

resource "google_kms_crypto_key" "bucket" {
  name            = "k-bucket-app-prod"
  key_ring        = google_kms_key_ring.app_prod_euw1.id
  purpose         = "ENCRYPT_DECRYPT"
  rotation_period = "7776000s" # 90 days; gcp-data-06

  version_template {
    algorithm        = "GOOGLE_SYMMETRIC_ENCRYPTION"
    protection_level = "HSM"
  }

  lifecycle {
    prevent_destroy = true
  }
}

data "google_project" "svc_app" {
  project_id = var.svc_project_id
}

resource "google_kms_crypto_key_iam_member" "bucket_sa_encrypter" {
  crypto_key_id = google_kms_crypto_key.bucket.id
  role          = "roles/cloudkms.cryptoKeyEncrypterDecrypter"
  member        = "serviceAccount:service-${data.google_project.svc_app.number}@gs-project-accounts.iam.gserviceaccount.com"
}

resource "google_org_policy_policy" "restrict_non_cmek" {
  name   = "organizations/${var.org_id}/policies/gcp.restrictNonCmekServices"
  parent = "organizations/${var.org_id}"

  spec {
    rules {
      values {
        allowed_values = [
          "storage.googleapis.com",
          "compute.googleapis.com",
          "sqladmin.googleapis.com",
          "bigquery.googleapis.com",
        ]
      }
    }
  }
}

Remediation — Config Connector

apiVersion: storage.cnrm.cloud.google.com/v1beta1
kind: StorageBucket
metadata:
  name: cmek-encrypted-bucket
  namespace: config-control
spec:
  location: us-central1
  uniformBucketLevelAccess: true
  encryption:
    defaultKmsKeyRef:
      external: "projects/PROJECT_ID/locations/us-central1/keyRings/data-kr/cryptoKeys/data-key"

Compliance mapping

CIS AWS Foundations v3.0.0 CIS Microsoft Azure Foundations v3.0.0 CIS GCP Foundation v4.0.0 CIS OCI Foundation v2.0.0 NIST SP 800-53 rev5 ISO/IEC 27001:2022 ISO/IEC 27017:2015
n/an/a1.x (verify)n/a SC-13; SC-28A.8.24; A.5.34n/a

Log signals

  • Cloud Audit Logs on storage.googleapis.com for storage.buckets.update where encryption.defaultKmsKeyName is cleared — bucket reverts to Google-managed encryption.
  • Object-level storage.objects.rewrite calls that re-write existing objects without specifying the bucket's CMEK — surfaces silent un-encryption migrations.
  • KMS IAM mutations removing roles/cloudkms.cryptoKeyEncrypterDecrypter from the Cloud Storage service identity — breaks new-object writes silently.

Query

logName=~"projects/.*/logs/cloudaudit.googleapis.com%2Factivity"
          AND ((protoPayload.serviceName="storage.googleapis.com"
                AND protoPayload.methodName="storage.buckets.update"
                AND protoPayload.request.encryption.defaultKmsKeyName="")
               OR (protoPayload.serviceName="cloudkms.googleapis.com"
                   AND protoPayload.methodName="SetIamPolicy"
                   AND resource.type="cloudkms_cryptokey"))

Run this Cloud Logging filter at project scope; pair with a Cloud Asset Inventory feed on storage.googleapis.com/Bucket so the steady-state CMEK assignment per bucket is visible as a snapshot alongside change events.

Alert threshold

  • Page on any bucket update clearing defaultKmsKeyName on a bucket previously tagged as CMEK-required.
  • Page on KMS IAM mutations affecting the Storage service identity binding for any active CMEK key.

Initial response

  1. Restore the CMEK binding on the bucket via gcloud storage buckets update --default-encryption-key; for objects written during the gap, force a re-write with the CMEK explicit via the legacy gsutil rewrite -k command (gsutil is legacy; gcloud storage cp is the current default).
  2. Re-bind roles/cloudkms.cryptoKeyEncrypterDecrypter on the KMS key for the Storage service identity; verify the next object write succeeds.
  3. Pin bucket CMEK + KMS IAM in Terraform; add a Cloud Asset Inventory feed alerting on any bucket whose encryption.defaultKmsKeyName drifts from the source-of-truth map.

References

Equivalent on: AWS · Azure · OCI

gcp-data-03-cmek-disks ! HIGH PREVENTIVE

All Compute Engine Persistent Disks — both boot and additional data disks — are encrypted with CMEK via Cloud KMS, and the organization-level constraint constraints/gcp.restrictNonCmekServices (see gcp-data-02) includes compute.googleapis.com so new disks created without a CMEK reference are rejected. For high-sensitivity workloads, use an HSM-backed key (protection_level = "HSM") so the key material is held in a Cloud HSM cluster that is FIPS 140-2 Level 3 validated and from which the private key cannot be exported in cleartext (Google Cloud — Customer-managed encryption keys on Compute Engine disks (accessed 2026-05)). The principle is reinforced in General Data — encryption at rest. Disk snapshots inherit the CMEK reference of the source disk; cross-region snapshot replication therefore requires a CMEK in the destination region (snapshot replication is not key-material replication — the snapshot ciphertext is re-encrypted with a key in the destination region).

Remediation — gcloud CLI

# gcloud CLI (latest stable)
# Step 1: create a CMEK-encrypted boot disk by referencing the KMS key at create time.
gcloud compute disks create disk-app-prod-boot \
  --project=svc-app-prod \
  --zone=europe-west1-b \
  --size=50GB \
  --type=pd-ssd \
  --image-family=debian-12 \
  --image-project=debian-cloud \
  --kms-key=projects/sec-keys-prod/locations/europe-west1/keyRings/kr-app-prod-euw1/cryptoKeys/k-disk-app-prod

# Step 2: grant the Compute Engine service agent permission to use the key.
PROJECT_NUMBER=$(gcloud projects describe svc-app-prod --format='value(projectNumber)')
gcloud kms keys add-iam-policy-binding k-disk-app-prod \
  --project=sec-keys-prod \
  --location=europe-west1 \
  --keyring=kr-app-prod-euw1 \
  --member=serviceAccount:service-${PROJECT_NUMBER}@compute-system.iam.gserviceaccount.com \
  --role=roles/cloudkms.cryptoKeyEncrypterDecrypter

# Step 3: audit existing disks for non-CMEK state.
for project in $(gcloud projects list --format='value(projectId)'); do
  gcloud compute disks list --project="$project" \
    --format="value(name,zone,diskEncryptionKey.kmsKeyName)" 2>/dev/null \
    | awk -F'\t' '$3=="" {print "NON-CMEK:", $0}'
done

Remediation — Terraform

# Terraform Google provider ~> 5.0
# Source: Google Cloud docs (accessed 2026-05)
resource "google_kms_crypto_key" "disk" {
  name            = "k-disk-app-prod"
  key_ring        = google_kms_key_ring.app_prod_euw1.id
  purpose         = "ENCRYPT_DECRYPT"
  rotation_period = "7776000s" # 90 days

  version_template {
    algorithm        = "GOOGLE_SYMMETRIC_ENCRYPTION"
    protection_level = "HSM"
  }
}

resource "google_kms_crypto_key_iam_member" "disk_compute_agent" {
  crypto_key_id = google_kms_crypto_key.disk.id
  role          = "roles/cloudkms.cryptoKeyEncrypterDecrypter"
  member        = "serviceAccount:service-${data.google_project.svc_app.number}@compute-system.iam.gserviceaccount.com"
}

resource "google_compute_disk" "app_prod_boot" {
  project = var.svc_project_id
  name    = "disk-app-prod-boot"
  zone    = "europe-west1-b"
  size    = 50
  type    = "pd-ssd"
  image   = "debian-cloud/debian-12"

  disk_encryption_key {
    kms_key_self_link = google_kms_crypto_key.disk.id
  }
}

Remediation — Config Connector

apiVersion: compute.cnrm.cloud.google.com/v1beta1
kind: ComputeDisk
metadata:
  name: cmek-encrypted-disk
  namespace: config-control
spec:
  location: us-central1-a
  sizeGb: 100
  type: pd-ssd
  diskEncryptionKey:
    kmsKeyRef:
      external: "projects/PROJECT_ID/locations/us-central1/keyRings/compute-kr/cryptoKeys/compute-key"

Compliance mapping

CIS AWS Foundations v3.0.0 CIS Microsoft Azure Foundations v3.0.0 CIS GCP Foundation v4.0.0 CIS OCI Foundation v2.0.0 NIST SP 800-53 rev5 ISO/IEC 27001:2022 ISO/IEC 27017:2015
n/an/a4.x (verify)n/a SC-28; SC-13A.8.24n/a

Log signals

  • Cloud Audit Logs on compute.googleapis.com for v1.compute.disks.insert or v1.compute.regionDisks.insert omitting the diskEncryptionKey.kmsKeyName field on production VPCs.
  • Snapshot creates that derive from a non-CMEK source disk — produces a snapshot encrypted with Google-managed keys and inherits the gap.
  • Constraint drift on constraints/gcp.restrictNonCmekServices — removal of compute.googleapis.com from the deny list weakens the perimeter.

Query

logName=~"projects/.*/logs/cloudaudit.googleapis.com%2Factivity"
          AND protoPayload.serviceName="compute.googleapis.com"
          AND protoPayload.methodName=~"v1.compute.(disks|regionDisks|snapshots).insert"
          AND NOT protoPayload.request.diskEncryptionKey.kmsKeyName=~".*"

This Cloud Logging filter catches the gap at disk-create time; pair with a Cloud Asset Inventory query on compute.googleapis.com/Disk with field-mask diskEncryptionKey to surface every existing disk without a CMEK assignment.

Alert threshold

  • Page on any disk insert lacking a CMEK on production VPCs tagged for CMEK-only storage.
  • Daily inventory cron: alert on any disk whose diskEncryptionKey is unset on a CMEK-tagged VPC.

Initial response

  1. Migrate non-CMEK disks to CMEK-encrypted replacements via snapshot-then-restore with --kms-key set; detach the original disk and delete after data integrity is verified.
  2. Re-assert the gcp.restrictNonCmekServices constraint at the organisation node; re-run a Cloud Asset Inventory sweep to confirm steady-state coverage.
  3. Pin VM templates and disk-create patterns in Terraform with kms_key_self_link set; reject deploys that omit it via a CI policy check.

References

Equivalent on: AWS · Azure · OCI

gcp-data-04-cloudsql-cmek ! HIGH PREVENTIVE

Every Cloud SQL instance (MySQL, PostgreSQL, SQL Server) is created with CMEK, Private IP only via Service Networking (no public IP, no Authorized Networks), SSL/TLS required for all client connections, and automated backups encrypted with the same CMEK as the primary instance. The canonical client connection model is the Cloud SQL Auth Proxy — which authenticates via the Cloud SQL Admin API, wraps the TCP connection in IAM-aware TLS, and removes the need to expose the database to public IP space at all. Authorized Networks (the legacy public-IP allowlist) is left empty for any new instance (Google Cloud — Configure SSL/TLS for Cloud SQL (accessed 2026-05)). HIGH PREVENTIVE because the failure mode (Cloud SQL on public IP with permissive Authorized Networks, or with SSL not required) is single-step exploitable: a leaked database password or a credential-stuffing run against an exposed Cloud SQL endpoint authenticates immediately if the network surface is reachable.

Remediation — gcloud CLI

# gcloud CLI (latest stable)
# Step 1: pre-create the Service Networking peering for Cloud SQL Private IP (one-time per VPC).
gcloud compute addresses create cloudsql-psa-range \
  --project=svc-app-prod \
  --global \
  --purpose=VPC_PEERING \
  --prefix-length=16 \
  --network=vpc-app-prod

gcloud services vpc-peerings connect \
  --service=servicenetworking.googleapis.com \
  --ranges=cloudsql-psa-range \
  --network=vpc-app-prod \
  --project=svc-app-prod

# Step 2: create a CMEK-encrypted, Private-IP-only, SSL-required Cloud SQL instance.
gcloud sql instances create sql-app-prod-euw1 \
  --project=svc-app-prod \
  --database-version=POSTGRES_15 \
  --region=europe-west1 \
  --tier=db-custom-2-7680 \
  --availability-type=REGIONAL \
  --no-assign-ip \
  --network=projects/svc-app-prod/global/networks/vpc-app-prod \
  --require-ssl \
  --backup-start-time=02:00 \
  --enable-point-in-time-recovery \
  --disk-encryption-key=projects/sec-keys-prod/locations/europe-west1/keyRings/kr-app-prod-euw1/cryptoKeys/k-sql-app-prod \
  --database-flags=cloudsql.iam_authentication=on,log_connections=on,log_disconnections=on

# Step 3: connect via Cloud SQL Auth Proxy (canonical client connection model).
./cloud-sql-proxy --auto-iam-authn svc-app-prod:europe-west1:sql-app-prod-euw1 &
psql "host=127.0.0.1 port=5432 user=app-sa@svc-app-prod.iam dbname=app sslmode=disable"

Remediation — Terraform

# Terraform Google provider ~> 5.0
# Source: Google Cloud docs (accessed 2026-05)
resource "google_kms_crypto_key" "sql" {
  name            = "k-sql-app-prod"
  key_ring        = google_kms_key_ring.app_prod_euw1.id
  purpose         = "ENCRYPT_DECRYPT"
  rotation_period = "7776000s" # 90 days

  version_template {
    algorithm        = "GOOGLE_SYMMETRIC_ENCRYPTION"
    protection_level = "HSM"
  }
}

resource "google_kms_crypto_key_iam_member" "sql_service_agent" {
  crypto_key_id = google_kms_crypto_key.sql.id
  role          = "roles/cloudkms.cryptoKeyEncrypterDecrypter"
  member        = "serviceAccount:service-${data.google_project.svc_app.number}@gcp-sa-cloud-sql.iam.gserviceaccount.com"
}

resource "google_sql_database_instance" "app_prod" {
  project             = var.svc_project_id
  name                = "sql-app-prod-euw1"
  database_version    = "POSTGRES_15"
  region              = "europe-west1"
  encryption_key_name = google_kms_crypto_key.sql.id
  deletion_protection = true

  settings {
    tier              = "db-custom-2-7680"
    availability_type = "REGIONAL"
    disk_autoresize   = true

    ip_configuration {
      ipv4_enabled    = false
      private_network = google_compute_network.vpc_app_prod.id
      require_ssl     = true
      # Authorized Networks intentionally empty — Private IP only.
    }

    backup_configuration {
      enabled                        = true
      start_time                     = "02:00"
      point_in_time_recovery_enabled = true
      transaction_log_retention_days = 7
    }

    database_flags {
      name  = "cloudsql.iam_authentication"
      value = "on"
    }
    database_flags {
      name  = "log_connections"
      value = "on"
    }
  }

  depends_on = [google_kms_crypto_key_iam_member.sql_service_agent]
}

Remediation — Config Connector

apiVersion: sql.cnrm.cloud.google.com/v1beta1
kind: SQLInstance
metadata:
  name: cmek-postgres
  namespace: config-control
spec:
  region: us-central1
  databaseVersion: POSTGRES_15
  encryptionKMSCryptoKeyRef:
    external: "projects/PROJECT_ID/locations/us-central1/keyRings/sql-kr/cryptoKeys/sql-key"
  settings:
    tier: db-custom-2-7680
    ipConfiguration:
      ipv4Enabled: false
      privateNetworkRef:
        external: "projects/PROJECT_ID/global/networks/sql-vpc"

Compliance mapping

CIS AWS Foundations v3.0.0 CIS Microsoft Azure Foundations v3.0.0 CIS GCP Foundation v4.0.0 CIS OCI Foundation v2.0.0 NIST SP 800-53 rev5 ISO/IEC 27001:2022 ISO/IEC 27017:2015
n/an/a6.x (verify)n/a SC-28; SC-13; SC-8A.8.24n/a

Log signals

  • Cloud Audit Logs on sqladmin.googleapis.com for instances.patch where ipConfiguration.ipv4Enabled transitions to true on an instance previously private-only.
  • Backup-config mutations where backupConfiguration.backupRetentionSettings.retainedBackups drops below the documented retention horizon.
  • CMEK assignment changes: diskEncryptionConfiguration.kmsKeyName cleared on an instance previously CMEK-encrypted (requires instance recreation in practice — surfaces as restore from a non-CMEK source).

Query

logName=~"projects/.*/logs/cloudaudit.googleapis.com%2Factivity"
          AND protoPayload.serviceName="sqladmin.googleapis.com"
          AND protoPayload.methodName=~".*instances.(patch|update|create)"
          AND (protoPayload.request.settings.ipConfiguration.ipv4Enabled=true
               OR protoPayload.request.settings.backupConfiguration.enabled=false)

Run this Cloud Logging filter at project scope; pair with a Cloud Asset Inventory feed on sqladmin.googleapis.com/Instance so steady-state public-IP and backup posture surface in a single dashboard pane.

Alert threshold

  • Page on any Cloud SQL instance acquiring a public IP after the documented private-only baseline.
  • Page on backup-disable or retention-shrink on production instances; backup posture is a recovery prerequisite.

Initial response

  1. Remove the public IP via gcloud sql instances patch --no-assign-ip; restore backupConfiguration from the captured baseline; verify the next backup window completes.
  2. Audit Cloud SQL connection logs (Cloud Logging resource.type="cloudsql_database") for connections from non-corporate ranges during the public-IP window; rotate the instance's database credentials.
  3. Re-assert Private Service Connect or VPC-peered private-IP path; pin instance config in Terraform with ip_configuration.ipv4_enabled=false and gate console edits via change-management.

References

Equivalent on: AWS · Azure · OCI

gcp-data-05-kms-iam ! CRITICAL PREVENTIVE

Cloud KMS IAM is bound at crypto-key scope, not key-ring scope, and the key-USE role (roles/cloudkms.cryptoKeyEncrypterDecrypter) is separated from the key-ADMIN role (roles/cloudkms.admin). Bindings at key-ring scope inherit to every key in the ring; a single over-broad binding at the ring level grants encrypt/decrypt on every key downstream — including keys created in the future. The hardened posture authors every binding with google_kms_crypto_key_iam_member (NOT google_kms_key_ring_iam_*) against a specific named key, scoped to the specific service account that needs to encrypt or decrypt for one specific resource. Org Policy constraints/iam.allowedPolicyMemberDomains blocks external principals (Cloud Identity domains other than the organization's own) from being added to any KMS IAM binding (Google Cloud — Cloud KMS IAM documentation (accessed 2026-05)). CRITICAL PREVENTIVE because compromise of roles/cloudkms.admin on a key unwinds the entire CMEK chain that gcp-data-02, gcp-data-03, and gcp-data-04 depend on — the admin can rewrite the key IAM, grant encrypt/decrypt to a malicious service account, and read every byte the chain protects.

Remediation — gcloud CLI

# gcloud CLI (latest stable)
# Step 1: bind cryptoKeyEncrypterDecrypter at KEY scope (not key-ring) to a specific SA.
gcloud kms keys add-iam-policy-binding k-bucket-app-prod \
  --project=sec-keys-prod \
  --keyring=kr-app-prod-euw1 \
  --location=europe-west1 \
  --member=serviceAccount:app-prod-bucket-encrypter@svc-app-prod.iam.gserviceaccount.com \
  --role=roles/cloudkms.cryptoKeyEncrypterDecrypter

# Step 2: separate key-admin from key-use — bind admin at KEY scope to ONE security-team SA.
gcloud kms keys add-iam-policy-binding k-bucket-app-prod \
  --project=sec-keys-prod \
  --keyring=kr-app-prod-euw1 \
  --location=europe-west1 \
  --member=serviceAccount:sec-kms-admin@sec-keys-prod.iam.gserviceaccount.com \
  --role=roles/cloudkms.admin

# Step 3: audit for any KEY-RING-scope encrypterDecrypter bindings (these should be empty).
gcloud kms keyrings get-iam-policy kr-app-prod-euw1 \
  --project=sec-keys-prod \
  --location=europe-west1 \
  --format='json' \
  | jq '.bindings[]? | select(.role | test("cryptoKey|admin"))'

# Step 4: enforce domain restriction at org scope so external principals cannot be added.
cat > domain-restrict.yaml <<'YAML'
name: organizations/ORG_ID/policies/iam.allowedPolicyMemberDomains
spec:
  rules:
  - values:
      allowedValues:
      - C0xxxxxxx  # Cloud Identity customer ID for the organization
YAML
gcloud org-policies set-policy domain-restrict.yaml --organization=ORG_ID

Remediation — Terraform

# Terraform Google provider ~> 5.0
# Source: Google Cloud docs (accessed 2026-05)
# NOTE: bindings are at KEY scope (google_kms_crypto_key_iam_member),
# NOT at KEY-RING scope (google_kms_key_ring_iam_*). Inheritance from
# the ring would leak encrypt/decrypt to every key in the ring including
# keys that do not yet exist — defeats per-resource cryptographic isolation.

resource "google_kms_crypto_key_iam_member" "bucket_encrypter" {
  crypto_key_id = google_kms_crypto_key.bucket.id
  role          = "roles/cloudkms.cryptoKeyEncrypterDecrypter"
  member        = "serviceAccount:app-prod-bucket-encrypter@${var.svc_project_id}.iam.gserviceaccount.com"
}

# Key admin: SEPARATE from key-use; bound to a single security-team SA at KEY scope.
resource "google_kms_crypto_key_iam_member" "bucket_admin" {
  crypto_key_id = google_kms_crypto_key.bucket.id
  role          = "roles/cloudkms.admin"
  member        = "serviceAccount:sec-kms-admin@${var.keys_project_id}.iam.gserviceaccount.com"
}

resource "google_org_policy_policy" "domain_restrict" {
  name   = "organizations/${var.org_id}/policies/iam.allowedPolicyMemberDomains"
  parent = "organizations/${var.org_id}"

  spec {
    rules {
      values {
        allowed_values = [var.cloud_identity_customer_id]
      }
    }
  }
}

Remediation — Config Connector

apiVersion: iam.cnrm.cloud.google.com/v1beta1
kind: IAMPolicyMember
metadata:
  name: data-key-decrypter
  namespace: config-control
spec:
  resourceRef:
    apiVersion: kms.cnrm.cloud.google.com/v1beta1
    kind: KMSCryptoKey
    name: data-key
  role: roles/cloudkms.cryptoKeyDecrypter
  member: "serviceAccount:storage-reader@PROJECT_ID.iam.gserviceaccount.com"

Remediation — Pulumi (TypeScript)

import * as gcp from "@pulumi/gcp";

// Least-privilege key access: decrypter only, no admin/encrypter on the same principal.
const dataKeyDecrypter = new gcp.kms.CryptoKeyIAMMember("data-key-decrypter", {
    cryptoKeyId: dataKey.id,
    role: "roles/cloudkms.cryptoKeyDecrypter",
    member: "serviceAccount:storage-reader@PROJECT_ID.iam.gserviceaccount.com",
});

Compliance mapping

CIS AWS Foundations v3.0.0 CIS Microsoft Azure Foundations v3.0.0 CIS GCP Foundation v4.0.0 CIS OCI Foundation v2.0.0 NIST SP 800-53 rev5 ISO/IEC 27001:2022 ISO/IEC 27017:2015
n/an/a1.x (verify)n/a AC-6; SC-12A.5.15; A.8.24n/a

Log signals

  • Cloud Audit Logs on cloudkms.googleapis.com for SetIamPolicy on a CryptoKey adding roles/cloudkms.cryptoKeyEncrypterDecrypter to a principal outside the documented consumer allow-list.
  • Key-version operations CryptoKeyVersion.destroy on a key still in use by data resources — produces an Operation that cannot be undone after the 24h scheduled-destroy window.
  • Key-purpose mutations from ENCRYPT_DECRYPT to ASYMMETRIC_SIGN or vice versa — fundamentally changes the key's permitted operation set.

Query

logName=~"projects/.*/logs/cloudaudit.googleapis.com%2Factivity"
          AND protoPayload.serviceName="cloudkms.googleapis.com"
          AND (protoPayload.methodName="SetIamPolicy"
               OR protoPayload.methodName=~".*CryptoKeyVersion.(destroy|disable)")
          AND resource.type="cloudkms_cryptokey"

Pin this Cloud Logging filter to a Cloud Monitoring alert; for the IAM half, join against the documented consumer-allow-list TSV by extracting protoPayload.serviceData.policyDelta.bindingDeltas.member in a BigQuery query of the sink dataset.

Alert threshold

  • Page on any binding addition to a CryptoKey for a principal not in the documented consumer allow-list.
  • Page on any CryptoKeyVersion.destroy; the destroy is reversible only within 24 hours and the consequence window is short.

Initial response

  1. Revoke the unauthorised binding via gcloud kms keys remove-iam-policy-binding; if a destroy was scheduled, restore the version via gcloud kms keys versions restore before the 24h window closes.
  2. Audit the principal's API calls during the binding window — any cloudkms.cryptoKeyVersions.useToDecrypt calls on the key indicate cryptographic-material exposure for the underlying data.
  3. Re-issue a key-rotation event if data was decrypted by an unsanctioned principal; affected ciphertexts must be re-encrypted under the new primary version.

References

Equivalent on: AWS · Azure · OCI

gcp-data-06-kms-rotation ! MEDIUM DETECTIVE

Every Cloud KMS symmetric key has rotation_period = "7776000s" (90 days) configured so the key is rotated automatically and the previous version remains available for decryption of legacy ciphertexts. Asymmetric and external (EKM) keys cannot be auto-rotated — they get a manual rotation runbook on the same 90-day cadence, with the runbook stored alongside the IaC that defines the key. High-sensitivity keys are HSM-backed (protection_level = "HSM") so the key material is generated and held inside a FIPS 140-2 Level 3 validated Cloud HSM cluster (Google Cloud — Rotating Cloud KMS keys documentation (accessed 2026-05)). MEDIUM DETECTIVE — not PREVENTIVE — per PITFALL B-14: rotation bounds the useful window of an already-compromised key version (an attacker who silently lifted version N can no longer decrypt new ciphertexts after version N+1 rotates in), but rotation does not prevent compromise. Mirrors the aws-data-06 and azure-data-06 typing decisions. Rotation is the compensating control for gcp-data-05 — which is the preventive control.

Remediation — gcloud CLI

# gcloud CLI (latest stable)
# Step 1: set a 90-day rotation period on an existing symmetric key.
gcloud kms keys update k-bucket-app-prod \
  --project=sec-keys-prod \
  --keyring=kr-app-prod-euw1 \
  --location=europe-west1 \
  --rotation-period=90d \
  --next-rotation-time=$(date -u -d '+7 days' +%Y-%m-%dT%H:%M:%SZ)

# Step 2: list current versions and the active primary version.
gcloud kms keys versions list \
  --project=sec-keys-prod \
  --keyring=kr-app-prod-euw1 \
  --location=europe-west1 \
  --key=k-bucket-app-prod \
  --format='table(name,state,createTime,destroyTime)'

# Step 3: enable Cloud Audit Logs Data Access for KMS at org scope (for detective coverage).
cat > kms-audit.yaml <<'YAML'
auditConfigs:
- service: cloudkms.googleapis.com
  auditLogConfigs:
  - logType: ADMIN_READ
  - logType: DATA_READ
  - logType: DATA_WRITE
YAML
# Apply via Cloud Asset / Org Policy or via gcloud organizations set-iam-policy with audit_configs.

# Step 4: audit for keys missing a rotation policy across the org.
for project in $(gcloud projects list --format='value(projectId)'); do
  for kr in $(gcloud kms keyrings list --project="$project" --location=europe-west1 --format='value(name)' 2>/dev/null); do
    gcloud kms keys list --project="$project" --location=europe-west1 --keyring="$(basename $kr)" \
      --format='value(name,rotationPeriod)' 2>/dev/null \
      | awk -F'\t' '$2=="" {print "NO-ROTATION:", $0}'
  done
done

Remediation — Terraform

# Terraform Google provider ~> 5.0
# Source: Google Cloud docs (accessed 2026-05)
# Symmetric key with 90-day auto-rotation + HSM protection.
resource "google_kms_crypto_key" "bucket_rotated" {
  name            = "k-bucket-app-prod-rotated"
  key_ring        = google_kms_key_ring.app_prod_euw1.id
  purpose         = "ENCRYPT_DECRYPT"
  rotation_period = "7776000s" # 90 days

  version_template {
    algorithm        = "GOOGLE_SYMMETRIC_ENCRYPTION"
    protection_level = "HSM"
  }

  lifecycle {
    prevent_destroy = true
  }
}

# Org-level audit config for Cloud KMS Data Access logs (detective coverage).
resource "google_organization_iam_audit_config" "kms_data_access" {
  org_id  = var.org_id
  service = "cloudkms.googleapis.com"

  audit_log_config {
    log_type = "ADMIN_READ"
  }
  audit_log_config {
    log_type = "DATA_READ"
  }
  audit_log_config {
    log_type = "DATA_WRITE"
  }
}

Remediation — Config Connector

apiVersion: kms.cnrm.cloud.google.com/v1beta1
kind: KMSCryptoKey
metadata:
  name: rotating-data-key
  namespace: config-control
spec:
  keyRingRef:
    name: data-kr
  purpose: ENCRYPT_DECRYPT
  rotationPeriod: "7776000s"  # 90 days
  versionTemplate:
    algorithm: GOOGLE_SYMMETRIC_ENCRYPTION
    protectionLevel: SOFTWARE

Compliance mapping

CIS AWS Foundations v3.0.0 CIS Microsoft Azure Foundations v3.0.0 CIS GCP Foundation v4.0.0 CIS OCI Foundation v2.0.0 NIST SP 800-53 rev5 ISO/IEC 27001:2022 ISO/IEC 27017:2015
n/an/a1.x (verify)n/a SC-12; SC-12(1)A.8.24n/a

Log signals

  • Cloud Audit Logs on cloudkms.googleapis.com for CryptoKey.update where rotationPeriod is cleared or widened beyond the documented horizon (typically 90 days).
  • Steady-state inventory: keys whose primary.createTime is older than the rotation-period setting indicate a stuck rotation cycle.
  • Manual CryptoKey.updatePrimaryVersion calls outside the documented rotation cron — operator override of the auto-rotation schedule.

Query

logName=~"projects/.*/logs/cloudaudit.googleapis.com%2Factivity"
          AND protoPayload.serviceName="cloudkms.googleapis.com"
          AND protoPayload.methodName=~".*CryptoKey.(update|updatePrimaryVersion)"
          AND (protoPayload.request.cryptoKey.rotationPeriod=""
               OR protoPayload.request.updateMask=~".*rotationPeriod.*")

Run this Cloud Logging filter alongside a Cloud Asset Inventory query computing primary.createTime ages for every cloudkms.googleapis.com/CryptoKey resource — the inventory query surfaces the steady-state stuck-rotation population in a single CSV.

Alert threshold

  • Page on any key whose rotationPeriod is cleared on the production scope; rotation is a compliance invariant under FIPS 140-3 derivation cadence.
  • Daily inventory cron: alert when any production key's primary version is older than the documented rotation horizon plus a 7-day grace window.

Initial response

  1. Restore rotationPeriod via gcloud kms keys update --rotation-period; trigger an immediate rotation via gcloud kms keys versions create --primary to close the rotation gap.
  2. Audit the principal that cleared the rotation period; if the change is unsanctioned, treat the gap window as compromised key-material and follow the key-compromise runbook (re-encrypt downstream ciphertexts).
  3. Pin rotation_period in Terraform and gate edits through change-management; the field is set-and-forget under steady state.

References

Equivalent on: AWS · Azure · OCI

gcp-data-07-dlp ! MEDIUM DETECTIVE

Sensitive Data Protection (formerly Cloud DLP) is configured org-wide to (a) discover sensitive content in Cloud Storage, BigQuery, and Cloud SQL via Discovery scans, (b) profile BigQuery datasets continuously to maintain a sensitivity inventory, (c) inspect ad-hoc payloads against reusable Inspect Templates with built-in info-type detectors (credit-card numbers, US/EU PII, OAuth tokens, GCP credentials, custom regex / dictionary detectors), and (d) de-identify exports via De-identification Templates that apply format-preserving encryption (FPE) and cryptographic hashing. The Cloud DLP API endpoint dlp.googleapis.com is still the runtime surface (the product was renamed to Sensitive Data Protection in March 2023; the API name was retained for backward compatibility) (Google Cloud — Sensitive Data Protection documentation (accessed 2026-05)). BigQuery column-level security is a separate but adjacent control: it uses policy tags hosted in Dataplex Universal Catalog (formerly Data Catalog; renamed and consolidated in 2024) to gate column reads via taxonomy hierarchy — a column tagged PII/SSN is unreadable except by principals granted roles/datacatalog.categoryFineGrainedReader on that policy tag. Sensitive Data Protection Discovery is the typical input that decides which columns get which policy tag. MEDIUM DETECTIVE because Discovery and Profiling surface unsafe state after data already exists — the preventive controls upstream (CMEK chain, bucket public-access prevention, KMS IAM) are the controls that keep the data from being exposed in the first place.

Remediation — gcloud CLI

# gcloud CLI (latest stable)
# Step 1: enable Sensitive Data Protection API in the security project.
gcloud services enable dlp.googleapis.com --project=sec-dlp-prod

# Step 2: create an Inspect Template with built-in PII info-types.
cat > inspect-template.json <<'JSON'
{
  "inspectTemplate": {
    "displayName": "pii-baseline",
    "inspectConfig": {
      "infoTypes": [
        {"name": "EMAIL_ADDRESS"},
        {"name": "CREDIT_CARD_NUMBER"},
        {"name": "US_SOCIAL_SECURITY_NUMBER"},
        {"name": "IBAN_CODE"},
        {"name": "GCP_CREDENTIALS"}
      ],
      "minLikelihood": "LIKELY",
      "includeQuote": false
    }
  }
}
JSON
gcloud dlp inspect-templates create \
  --project=sec-dlp-prod \
  --location=europe-west1 \
  --template-id=pii-baseline \
  --inspect-template-from-file=inspect-template.json

# Step 3: create a Discovery scan job trigger for an org-wide BigQuery discovery.
gcloud dlp job-triggers create \
  --project=sec-dlp-prod \
  --location=europe-west1 \
  --trigger-id=bq-discovery-daily \
  --discovery-config-from-file=discovery-config.json

# Step 4: create a Dataplex Universal Catalog (formerly Data Catalog) taxonomy + policy tag
#         for BigQuery column-level security.
gcloud data-catalog taxonomies create pii-taxonomy \
  --project=sec-dlp-prod \
  --location=europe-west1 \
  --display-name="PII Taxonomy" \
  --activated-policy-types=FINE_GRAINED_ACCESS_CONTROL

gcloud data-catalog taxonomies policy-tags create \
  --taxonomy=pii-taxonomy \
  --project=sec-dlp-prod \
  --location=europe-west1 \
  --display-name="PII/HIGH"

Remediation — Terraform

# Terraform Google provider ~> 5.0
# Source: Google Cloud docs (accessed 2026-05)
# Sensitive Data Protection (formerly Cloud DLP) — Inspect Template + Job Trigger.
resource "google_data_loss_prevention_inspect_template" "pii_baseline" {
  parent       = "projects/${var.sec_project_id}/locations/europe-west1"
  template_id  = "pii-baseline"
  display_name = "PII baseline inspect template"

  inspect_config {
    info_types {
      name = "EMAIL_ADDRESS"
    }
    info_types {
      name = "CREDIT_CARD_NUMBER"
    }
    info_types {
      name = "US_SOCIAL_SECURITY_NUMBER"
    }
    info_types {
      name = "GCP_CREDENTIALS"
    }
    min_likelihood = "LIKELY"
    include_quote  = false
  }
}

resource "google_data_loss_prevention_job_trigger" "bq_discovery_daily" {
  parent       = "projects/${var.sec_project_id}/locations/europe-west1"
  trigger_id   = "bq-discovery-daily"
  display_name = "Daily BigQuery PII discovery"

  triggers {
    schedule {
      recurrence_period_duration = "86400s"
    }
  }

  inspect_job {
    inspect_template_name = google_data_loss_prevention_inspect_template.pii_baseline.id

    storage_config {
      big_query_options {
        table_reference {
          project_id = var.svc_project_id
          dataset_id = "analytics"
          table_id   = "events"
        }
      }
    }
  }
}

# BigQuery column-level security via policy tags in Dataplex Universal Catalog (formerly Data Catalog; renamed 2024).
# Policy tag gates BigQuery column reads at taxonomy hierarchy level.
resource "google_data_catalog_taxonomy" "pii" {
  project                = var.sec_project_id
  region                 = "europe-west1"
  display_name           = "PII Taxonomy"
  activated_policy_types = ["FINE_GRAINED_ACCESS_CONTROL"]
}

resource "google_data_catalog_policy_tag" "pii_high" {
  taxonomy     = google_data_catalog_taxonomy.pii.id
  display_name = "PII/HIGH"
  description  = "Highest-sensitivity PII; SSN, biometric, government ID."
}

Remediation — Config Connector

apiVersion: dlp.cnrm.cloud.google.com/v1beta1
kind: DLPJobTrigger
metadata:
  name: pii-scan-trigger
  namespace: config-control
spec:
  projectRef:
    external: "projects/PROJECT_ID"
  status: HEALTHY
  triggers:
  - schedule:
      recurrencePeriodDuration: "86400s"  # daily
  inspectJob:
    storageConfig:
      cloudStorageOptions:
        fileSet:
          url: "gs://hardened-bucket/**"
    inspectConfig:
      infoTypes:
      - name: EMAIL_ADDRESS
      - name: CREDIT_CARD_NUMBER
      - name: US_SOCIAL_SECURITY_NUMBER

Compliance mapping

CIS AWS Foundations v3.0.0 CIS Microsoft Azure Foundations v3.0.0 CIS GCP Foundation v4.0.0 CIS OCI Foundation v2.0.0 NIST SP 800-53 rev5 ISO/IEC 27001:2022 ISO/IEC 27017:2015
n/an/a(SDP rename 2023; post-v4.0.0)n/a RA-5; SI-4A.5.12; A.5.13CLD.12.4.5

Log signals

  • Cloud Audit Logs on dlp.googleapis.com for JobTrigger.delete or JobTrigger.patch disabling the scheduled DLP scans on Cloud Storage / BigQuery sensitive datasets.
  • InspectTemplate mutations narrowing infoTypes on a template used by production triggers — silently shrinks the detection surface.
  • Findings-ingest rate against Pub/Sub: a drop in published DLP findings against rolling baseline indicates either no data or detector silence.

Query

logName=~"projects/.*/logs/cloudaudit.googleapis.com%2Factivity"
          AND protoPayload.serviceName="dlp.googleapis.com"
          AND (protoPayload.methodName=~".*JobTrigger.(delete|patch)"
               OR protoPayload.methodName=~".*InspectTemplate.(delete|patch)")

This Cloud Logging filter watches the DLP control plane; pair with a Pub/Sub publish-rate metric on the DLP-findings topic so detector silence surfaces alongside config-mutation events.

Alert threshold

  • Page on any delete of a production DLP trigger or InspectTemplate.
  • Page when DLP-findings publish rate drops below 30% of rolling baseline for more than 24 hours.

Initial response

  1. Restore the trigger / template from Cloud Asset Inventory history; force-run a one-shot scan against the affected dataset to backfill findings the schedule missed.
  2. Triage backfilled findings as if they were created during the gap window; treat the gap as a forensic window for any sensitive-data exposure not previously surfaced.
  3. Pin DLP triggers + templates in Terraform; gate edits via change-management and require a paired findings-rate alert per trigger so silent disable is impossible.

References

Equivalent on: AWS · Azure · OCI

gcp-data-08-bucket-lock ! MEDIUM PREVENTIVE

Regulated Cloud Storage buckets carry a Bucket Lock retention policy with is_locked = true and a retention period appropriate to the regulatory regime (1 year baseline; 7 years for some financial-services regimes). Once the retention policy is locked, the retention period cannot be reduced — not even by the project owner, the bucket owner, or the organization administrator. Object Versioning is enabled so overwrite and delete operations preserve the prior version, and Event-Based or Temporary Object Holds are applied for legal-discovery scenarios where individual objects must be preserved beyond the bucket-level retention window (Google Cloud — Bucket Lock documentation (accessed 2026-05)). The cross-provider equivalence is intentional: Cloud Storage Bucket Lock is the GCP analog of S3 Object Lock Compliance mode (not Governance — Governance mode allows BypassGovernanceRetention by root, which defeats the threat model) on the AWS side, and the analog of Azure Immutable Blob Storage in locked mode (unlocked mode is subscription-owner-bypassable) on the Azure side. The threat model justifies MEDIUM PREVENTIVE because the control prevents both (a) ransomware-style deletion of forensic / audit evidence by an attacker who acquired bucket-level write permissions, and (b) accidental policy weakening by an over-privileged administrator under deadline pressure.

Remediation — gcloud CLI

# gcloud CLI (latest stable)
# Step 1: create a bucket with a retention policy (not yet locked — locking is irreversible).
gcloud storage buckets create gs://forensics-prod-euw1 \
  --project=sec-forensics-prod \
  --location=europe-west1 \
  --uniform-bucket-level-access \
  --public-access-prevention \
  --default-storage-class=STANDARD \
  --retention-period=1y

# Step 2: enable Object Versioning so overwrites preserve prior versions.
gcloud storage buckets update gs://forensics-prod-euw1 --versioning

# Step 3: LOCK the retention policy. WARNING — this is irreversible.
gcloud storage buckets update gs://forensics-prod-euw1 --lock-retention-period

# Step 4: apply a Temporary Object Hold to a specific object pending legal review.
gcloud storage objects update gs://forensics-prod-euw1/incident-2026-05-23/disk-snap-1.tar.gz \
  --temporary-hold

# Step 5: verify the lock and remaining retention.
gcloud storage buckets describe gs://forensics-prod-euw1 \
  --format='value(retention_policy)'

Remediation — Terraform

# Terraform Google provider ~> 5.0
# Source: Google Cloud docs (accessed 2026-05)
# WARNING: setting retention_policy.is_locked = true is IRREVERSIBLE.
# Terraform will refuse to destroy the bucket while the policy is locked
# AND the retention period has not elapsed; this is intentional.
resource "google_storage_bucket" "forensics_prod" {
  project                     = var.sec_project_id
  name                        = "forensics-prod-euw1"
  location                    = "EUROPE-WEST1"
  storage_class               = "STANDARD"
  uniform_bucket_level_access = true
  public_access_prevention    = "enforced"

  retention_policy {
    is_locked        = true
    retention_period = 31557600 # 1 year in seconds
  }

  versioning {
    enabled = true
  }

  encryption {
    default_kms_key_name = google_kms_crypto_key.bucket.id
  }

  lifecycle {
    prevent_destroy = true
  }
}

Remediation — Config Connector

apiVersion: storage.cnrm.cloud.google.com/v1beta1
kind: StorageBucket
metadata:
  name: immutable-archive
  namespace: config-control
spec:
  location: us-central1
  uniformBucketLevelAccess: true
  retentionPolicy:
    retentionPeriod: 31536000  # 1 year
    isLocked: true

Compliance mapping

CIS AWS Foundations v3.0.0 CIS Microsoft Azure Foundations v3.0.0 CIS GCP Foundation v4.0.0 CIS OCI Foundation v2.0.0 NIST SP 800-53 rev5 ISO/IEC 27001:2022 ISO/IEC 27017:2015
n/an/a5.x (verify)n/a CP-9; SI-7A.8.13n/a

Log signals

  • Cloud Audit Logs on storage.googleapis.com for storage.buckets.update reducing or removing retentionPolicy.retentionPeriod on a bucket; if the bucket is already locked the API returns a denial, otherwise the change is silent.
  • Object-version lifecycle changes: buckets.update patches setting lifecycle.rule[].action.type="Delete" with overly short condition.age on production buckets.
  • Bucket-IAM mutations granting roles/storage.admin to a principal outside the documented bucket-owner list.

Query

logName=~"projects/.*/logs/cloudaudit.googleapis.com%2Factivity"
          AND protoPayload.serviceName="storage.googleapis.com"
          AND protoPayload.methodName="storage.buckets.update"
          AND (protoPayload.request.retentionPolicy.retentionPeriod=~"^[0-9]{1,4}$"
               OR protoPayload.request.lifecycle.rule.action.type="Delete")

Run this Cloud Logging filter against the organisation audit sink; pair with the Cloud Asset Inventory bucket-feed so steady-state retention-period + lifecycle-rule posture stays visible alongside change events.

Alert threshold

  • Page on any retention-period reduction or lifecycle-Delete-rule addition on buckets tagged as retention: locked or compliance: critical.
  • Page on any roles/storage.admin binding addition outside the documented owner list.

Initial response

  1. If the retention policy was not yet locked, restore the original retention period via gcloud storage buckets update --retention-period and apply the lock via gcloud storage buckets update --lock-retention-policy; locked policies cannot be shortened.
  2. Audit object-delete events during the gap window; restore from object versioning if the bucket has it enabled, and document the deletion ledger in the incident record.
  3. Pin bucket retention + lifecycle config in Terraform and gate edits via change-management; the lock-policy step should be a separate change ticket so the lock is intentional.

References

Equivalent on: AWS · Azure · OCI

Sources