Appendix D
Security Checklist
Scope. Every security control this book teaches, merged across all thirty-seven chapters, deduplicated, and re-sorted by the resource it applies to. Each row carries a verification command and the section that explains it. Prerequisites. None. This appendix is a lookup artifact and is meant to be opened out of order. Verified against. Google Cloud console and API surface as of 2026-09, Cloud SDK 583.0.0; every verification command extracted from its source chapter and re-resolved with
--help; see sources at end.
Every chapter ends with a checklist ordered by topic, because that is how the chapter was ordered. That is the wrong order for the only moments anyone reads a checklist — an audit, a review, a handover — when the question is what do I check on this resource, not what did Chapter 12 say.
This appendix is that reordering. The 518 rows written across the thirty-seven chapter checklists were extracted mechanically, merged where the same control appeared in several chapters, and cut where a control was specific to the one chapter that raised it. 190 rows survive. The verification commands are copied from the chapters, not retyped, because a retyped command is a command that can be subtly wrong; where several chapters verified the same control differently, the most specific command survived.
Read the § column as the authority: the row is a reminder, and the section is the explanation.
D.1 Organization §
The organization node and the policy that every project below it inherits. These are the controls that cannot be retrofitted cheaply: an estate without an organization resource has nowhere to put any of them.
| Control | Why it matters | How to verify | § |
|---|---|---|---|
| Organization resource exists and owns all projects | Without an org node there is no place to enforce inherited policy | gcloud organizations list; Console → IAM & Admin → Settings | §1.7 |
| Domain-restricted sharing enforced | Blocks IAM grants to identities outside your organization | gcloud org-policies describe iam.allowedPolicyMemberDomains --organization=ORG_ID --effective; Console → IAM & Admin → Organization policies | §2.1 |
| Default org-wide Project Creator / Billing Creator grants removed | Otherwise any employee can create ungoverned, funded projects | gcloud organizations get-iam-policy ORG_ID --format="value(bindings.role,bindings.members)"; Console → IAM & Admin → IAM (org scope) | §2.5 |
| Super admins separated from Organization Administrators | Prevents one identity from holding both control planes | Admin console → Account → Admin roles; gcloud organizations get-iam-policy ORG_ID | §2.4 |
| Essential Contacts set for the Security category | Google's compromise and suspension notices must reach someone | gcloud essential-contacts list --organization=ORG_ID; Console → IAM & Admin → Essential Contacts | §2.14 |
| Default network suppressed on new projects | The default VPC ships permissive rules including SSH from 0.0.0.0/0 | gcloud compute networks list --project=PROJECT_ID --format="value(name)"; org policy constraints/compute.skipDefaultNetworkCreation (§2.30) | §1.12 |
| Automatic role grants to default service accounts disabled | Otherwise default SAs receive a basic role granting broad project write | gcloud org-policies describe iam.automaticIamGrantsForDefaultServiceAccounts --organization=ORG_ID --effective | §1.10 |
| Resource locations constrained | Data residency must be enforced, not requested | gcloud org-policies describe constraints/gcp.resourceLocations --organization=ORG_ID --effective | §1.6 |
| Every new org policy went dry-run before enforcement | Enforcing untested constraints causes outages and rollback pressure | gcloud org-policies describe CONSTRAINT --organization=ORG_ID --format="value(dryRunSpec)" | §2.30 |
roles/orgpolicy.policyAdmin granted only at the organization | A project-scoped org policy admin can relax inherited guardrails | gcloud asset search-all-iam-policies --scope="organizations/ORG_ID" --query="policy:roles/orgpolicy.policyAdmin" | §2.34 |
| Automatic baseline status determined | Seven constraints may already be enforced, or none | gcloud org-policies list --organization=ORG_ID --show-unset | §31.1 |
| Baseline stored as code and applied by pipeline | A hand-applied baseline cannot be diffed or reviewed | google_org_policy_policy resources in the repository | §31.1 |
| Service usage restricted to an allowlist | Prevents unreviewed services from holding data | gcloud org-policies describe gcp.restrictServiceUsage --organization=ORG_ID --effective | §2.30 |
| Shared VPC host projects constrained to an allowlist | Stops workloads attaching to an unapproved network | gcloud org-policies describe compute.restrictSharedVpcHostProjects --organization=ORG_ID --effective | §2.30 |
| Effective policy checked at the resource | An override below the root is invisible at the root | gcloud org-policies describe CONSTRAINT --project=PROJECT_ID --effective | §31.1 |
D.2 Projects §
Project-level hygiene — the boundary, its lifecycle, and the metadata that makes it governable. A project is the unit of blast radius, so most of what follows is about knowing which one you are looking at and who can create another.
| Control | Why it matters | How to verify | § |
|---|---|---|---|
| Every project carries the mandatory label set | Attribution, detection scoping, and ownership all key on labels | gcloud asset search-all-resources --scope="organizations/ORG_ID" --asset-types="cloudresourcemanager.googleapis.com/Project" --query="NOT labels.data-class:*" | §2.6 |
| Environment tag bound at the environment folders | Tags, unlike labels, drive IAM conditions and org policy | gcloud resource-manager tags bindings list --parent=//cloudresourcemanager.googleapis.com/projects/PROJECT_NUMBER | §2.13 |
| Production projects have deletion protection | Deletion has only a 30-day window and no support override afterward | gcloud projects describe PROJECT_ID --format="value(lifecycleState)"; Terraform deletion_policy = "PREVENT" | §2.29 |
| Budget alerts routed to security | Spend anomalies are early compromise indicators | gcloud billing budgets list --billing-account=BILLING_ACCOUNT_ID; Console → Billing → Budgets & alerts | §1.24 |
| Perimeter membership is created by the project factory | Membership is per project and is never inherited | Terraform: the project module adds the project number to the perimeter | §20.2 |
| Public assets isolated to their own folder | Makes "what is public" enumerable | Folder structure and its policy overrides | §31.7 |
D.3 IAM §
Identity and access. The largest single source of real findings in the book, and the section an auditor opens first. Basic roles, service account keys, and public principals account for most of it.
| Control | Why it matters | How to verify | § |
|---|---|---|---|
| No legacy basic role grants outside the sandbox folder | They are unbounded and cannot be narrowed with conditions | gcloud asset search-all-iam-policies --scope=organizations/ORG_ID --query='policy:(owner OR editor)' | §3.10 |
No allUsers or allAuthenticatedUsers bindings | Both make a resource effectively public and cannot carry conditions | gcloud asset search-all-iam-policies --scope="organizations/ORG_ID" --query="policy:(allUsers OR allAuthenticatedUsers)" | §3.32 |
| Human roles bound to groups, not users | User bindings survive departures and team moves | gcloud asset search-all-iam-policies --scope="organizations/ORG_ID" --query="policy:\"user:\"" | §3.3 |
| No user-managed service account keys | Non-expiring bearer credentials with no context binding | gcloud org-policies describe iam.managed.disableServiceAccountKeyCreation --organization=ORG_ID --effective | §3.25 |
| Deny policy protecting production project deletion | Only deny subtracts; org policy cannot express this | gcloud iam policies list --attachment-point="cloudresourcemanager.googleapis.com/organizations/ORG_ID" --kind=denypolicies | §3.17 |
| Org-node bindings match an approved allowlist | Org grants apply to every current and future resource | gcloud organizations get-iam-policy ORG_ID --format="table(bindings.role,bindings.members)" | §3.8 |
| Separation-of-duties pairs have no overlapping principal | A single principal completing a sensitive action end to end | gcloud asset analyze-iam-policy --organization=ORG_ID --permissions=PERMISSION --analyze-service-account-impersonation | §3.22 |
| No impersonation path from low-privilege to high-privilege accounts | Chains grant privileges nothing else reveals | same command, run per privileged account | §3.5 |
| Pipeline identity federated, not keyed, and impersonating per-environment accounts | The CI identity is the highest-value target in the org | gcloud iam service-accounts get-iam-policy SA_EMAIL --format=json; Console → IAM & Admin → Workload Identity Federation | §3.34 |
Runtime service accounts hold no setIamPolicy on anything | Any setIamPolicy is a self-escalation path | gcloud iam roles describe ROLE --format="value(includedPermissions)" for each granted role | §3.9 |
| PAM entitlements exist for every privileged role, with approvers distinct from requesters | Standing privilege is the risk JIT removes | gcloud pam entitlements list --organization=ORG_ID --location=global | §3.27 |
| Access Approval enabled for all supported services | Converts "Google can access our data" into a signed, per-case record | gcloud access-approval settings get --organization=ORG_ID; Console → Security → Access Approval | §3.29 |
| IAM recommendations reviewed on a schedule | Unused privilege is the cheapest privilege to remove | gcloud recommender recommendations list --project=PROJECT_ID --location=global --recommender=google.iam.policy.Recommender | §3.31 |
| Every workload identity pool provider has an attribute condition | Without one, the provider trusts every identity the issuer will ever sign for | gcloud iam workload-identity-pools providers describe PROVIDER_ID --workload-identity-pool=POOL_ID --location=global --format="value(attributeCondition)"; Console → IAM & Admin → Workload Identity Federation | §4.4 |
No role bound to a whole pool (.../POOL_ID/*) | Grants access to every identity the provider accepts | gcloud asset search-all-iam-policies --scope="organizations/ORG_ID" --query="policy:workloadIdentityPools" | §4.3 |
| No instance uses the Compute Engine default service account | A shared, historically over-privileged identity is not on the audit trail for any workload | gcloud asset search-all-iam-policies --scope=organizations/ORG_ID --query='policy:compute@developer' | §8.29 |
| One service account per workload | A shared account destroys attribution | Asset inventory joined to workload list | §35.4 |
roles/iam.serviceAccountUser granted narrowly | Otherwise anyone who can deploy can run as any account | gcloud iam service-accounts get-iam-policy SA | §35.4 |
| Break-glass path exists and is tested | The first execution should not be during an incident | gcloud pam entitlements list --location=global --organization=ORG_ID | §34.1 |
| Credential lifetime constraint checked | It decides whether the residual window is 1 hour or 12 | gcloud org-policies describe iam.allowServiceAccountCredentialLifetimeExtension --organization=ORG_ID --effective | §34.5 |
D.4 Networking §
Network reachability and its evidence. Firewall policy, private access, perimeters, and the logs that show whether any of it holds.
| Control | Why it matters | How to verify | § |
|---|---|---|---|
| All production networks are custom subnet mode | Auto mode adds subnets for new regions without review | gcloud compute networks describe NETWORK --format="value(autoCreateSubnetworks)" | §5.2 |
| Egress deny-default rule exists on every production VPC | The implied egress rule is allow, so exfiltration is open by default | gcloud compute firewall-rules list --filter="direction=EGRESS AND denied:*" --format="table(name,priority,destinationRanges.list())" | §5.13 |
No firewall rule allows 22 or 3389 from 0.0.0.0/0 | Direct administrative exposure to the internet | gcloud compute firewall-rules list --filter="allowed.ports:22 AND sourceRanges:0.0.0.0/0" | §5.13 |
Hierarchical policy allows IAP 35.235.240.0/20 and health checks 130.211.0.0/22, 35.191.0.0/16 | These paths must work regardless of project configuration | gcloud compute firewall-policies describe FIREWALL_POLICY_ID --organization=ORG_ID | §5.14 |
| Firewall targets use secure tags or service accounts, not network tags | Network tags are ungoverned strings any VM editor can add | gcloud compute firewall-rules list --format="table(name,targetTags.list(),targetServiceAccounts.list())" | §5.16 |
| Private Google Access enabled on every workload subnet | VMs without external IPs otherwise cannot reach Google APIs | gcloud compute networks subnets list --filter="privateIpGoogleAccess=false" --format="table(name,region,network)" | §5.17 |
restricted.googleapis.com used inside VPC Service Controls perimeters | It refuses APIs outside perimeter support, closing an exfiltration path | inspect the private googleapis.com zone records with gcloud dns record-sets list | §5.17 |
| Cloud NAT scoped to named subnets with reserved static IPs | A VPC-wide NAT grants future subnets egress by default | gcloud compute routers nats describe NAT --router=ROUTER --region=REGION --format="value(sourceSubnetworkIpRangesToNat,natIps)" | §5.20 |
| VPC Flow Logs enabled on production subnets | The only record of who talked to whom | gcloud compute networks subnets list --filter="enableFlowLogs=false" | §5.4 |
| DNS query logging enabled on private zones and DNS policy | Highest-signal evidence of command-and-control attempts | gcloud dns managed-zones describe ZONE --format="value(cloudLoggingConfig.enableLogging)" | §5.22 |
| DNSSEC enabled on public zones and CAA records present | DNS control is certificate-issuance control | gcloud dns managed-zones describe ZONE --format="value(dnssecConfig.state)" | §5.25 |
roles/compute.networkUser granted on subnets, never at the host project | A project-level grant covers every future subnet | gcloud projects get-iam-policy HOST_PROJECT --format=json and per-subnet gcloud compute networks subnets get-iam-policy | §5.28 |
No custom 0.0.0.0/0 route in production without review | A route is a complete, quiet traffic redirect | gcloud compute routes list --filter="destRange=0.0.0.0/0" --format="table(name,network,nextHopIlb,nextHopInstance,priority)" | §5.9 |
| No public DNS record points at a deleted resource | Dangling records enable subdomain takeover | reconcile gcloud dns record-sets list --zone=ZONE against live resources | §5.25 |
| HA VPN uses both gateway interfaces to two peer devices or two peer interfaces | A single active interface carries no availability SLA | gcloud compute vpn-tunnels list --format="table(name,vpnGateway,vpnGatewayInterface,peerExternalGateway,status)"; Console → Hybrid Connectivity → VPN | §6.3 |
| BGP sessions have MD5 authentication configured | Prevents session hijack by anything reaching the link-local address | gcloud compute routers describe ROUTER --region=REGION --format="value(bgpPeers[].md5AuthenticationKeyName)" (the key value itself is never returned) | §6.8 |
| Firewall Insights reviewed for shadowed and unused rules | A shadowed rule is a control that does not exist | gcloud recommender insights list --insight-type=google.compute.firewall.Insight --location=global | §6.20 |
| Backends have no external IPs | An external IP bypasses every load balancer control | gcloud compute instances list --filter="networkInterfaces[].accessConfigs[].natIP:*"; org policy constraints/compute.managed.vmExternalIpAccess | §7.19 |
Backend ingress permits only 130.211.0.0/22 and 35.191.0.0/16 (or the proxy-only subnet) | Anything wider is a direct path around the load balancer | gcloud compute firewall-rules list --format="table(name,sourceRanges.list(),targetServiceAccounts.list())" | §7.20 |
| Cloud Armor policy attached to every internet-facing backend service | An unattached policy protects nothing and warns no one | gcloud compute backend-services describe BES --global --format="value(securityPolicy)"; Console → Network Security → Cloud Armor | §7.13 |
WAF rules were run in --preview before enforcement | Enforcing untuned CRS signatures blocks legitimate traffic | gcloud compute security-policies describe POLICY --format="value(rules[].preview)" | §7.14 |
| SSL policy attached to every target HTTPS proxy, minimum TLS 1.2 | Without one the load balancer negotiates the COMPATIBLE profile | gcloud compute target-https-proxies describe PROXY --global --format="value(sslPolicy)" | §7.10 |
| Load balancer logging enabled with a non-zero sample rate | The only record of what reached the application and what Armor did | gcloud compute backend-services describe BES --global --format="value(logConfig.enable,logConfig.sampleRate)" | §7.3 |
| IAP-protected applications verify the signed JWT | An alternate path would otherwise accept a forged identity | code review; confirm issuer and audience checks | §7.17 |
| A perimeter exists around every project holding regulated data | IAM alone cannot stop an authorized copy out | gcloud access-context-manager perimeters list --policy=POLICY_ID | §20.1 |
| Restricted services include compute, KMS, and Secret Manager | Restricting only storage leaves a VM-mediated path open | gcloud access-context-manager perimeters describe sp-prod-data --policy=POLICY_ID | §20.3 |
No egress rule names resources: ["*"] | That is an unrestricted exfiltration permission | Review the egress policy file in version control | §20.6 |
| A dry-run configuration is maintained ahead of the enforced one | Makes perimeter change routine rather than exceptional | gcloud access-context-manager perimeters dry-run describe sp-prod-data --policy=POLICY_ID | §20.8 |
| Perimeter enforced, not dry-run | A dry-run perimeter is §33.20's theater | Perimeter status | §33.20 |
D.5 Compute §
Compute Engine instances: boot integrity, login path, disks, and the metadata surface.
| Control | Why it matters | How to verify | § |
|---|---|---|---|
constraints/compute.requireShieldedVm enforced | Blocks any instance without Secure Boot, vTPM, and Integrity Monitoring | gcloud org-policies describe constraints/compute.requireShieldedVm --organization=123456789012 | §8.16 |
constraints/compute.requireOsLogin enforced | Prevents metadata SSH keys from bypassing IAM-governed access | gcloud org-policies describe compute.managed.requireOsLogin --organization=ORG_ID --effective | §8.20 |
constraints/compute.vmExternalIpAccess denies by default | Removes the internet as a reachable path to workload VMs | gcloud compute instances list --project=PROJECT_ID --filter="networkInterfaces[].accessConfigs[].natIP:*" | §8.22 |
constraints/compute.trustedImageProjects restricted | Forces every instance through the golden-image pipeline | gcloud org-policies describe constraints/compute.trustedImageProjects --organization=123456789012 | §8.35 |
constraints/compute.disableSerialPortAccess enforced | Closes the interactive serial console as an OS Login bypass | gcloud org-policies describe constraints/compute.disableSerialPortAccess --organization=123456789012 | §8.35 |
| Boot disks and images use customer-managed keys where required | Meets data-classification requirements without depending on Google-managed key rotation | gcloud compute instances describe NAME --zone=ZONE --format='value(disks.diskEncryptionKey)' | §8.8 |
| OS Config agent enrolled fleet-wide | Ensures patch management and vulnerability signal cover every instance | gcloud compute os-config inventories list --location=ZONE returns the full fleet | §8.32 |
| Local accounts removed where OS Login applies | A local account with a key is a credential outside IAM | ansible all -m ansible.builtin.command -a 'getent passwd' | §28.10 |
| Every managed instance group and Cloud Run service has a maximum | Autoscaling converts an availability attack into a cost attack | gcloud run services describe SERVICE --region=REGION --format="value(spec.template.metadata.annotations)" | §7.16 |
| Instance metadata scanned for credentials | It is invisible without a full read mask | gcloud asset search-all-resources --asset-types='compute.googleapis.com/Instance' --read-mask='*' | §33.6 |
D.6 GKE §
GKE clusters and the workloads inside them. The recurring split is between the cluster as a Google Cloud resource and the Kubernetes objects it hosts — both need checking.
| Control | Why it matters | How to verify | § |
|---|---|---|---|
| Private nodes enabled on every cluster | Removes external IP exposure on node VMs | gcloud container clusters describe CLUSTER --region=REGION --format="value(privateClusterConfig.enablePrivateNodes)"; constraints/container.managed.enablePrivateNodes | §9.9 |
| DNS-based control plane endpoint with authorized networks enforced | Current recommended model; closes the public-endpoint path | gcloud container clusters describe CLUSTER --region=REGION --format="value(controlPlaneEndpointsConfig)" | §9.4 |
GKE_METADATA set on every node pool | A legacy-mode pool exposes the node identity to every pod on it | gcloud container clusters describe CLUSTER --region=REGION --format="value(workloadIdentityConfig.workloadPool)" | §9.11 |
| Shielded Nodes with Secure Boot on every node pool | Verifies node authenticity and boot integrity | gcloud container node-pools describe POOL ... and read config.shieldedInstanceConfig | §9.31 |
Node service account is sa-gke-node-<cluster>, never default Compute Engine SA | Bounds blast radius of a compromised node | gcloud container node-pools describe POOL --cluster=CLUSTER --region=REGION --format="value(config.serviceAccount)"; constraints/container.managed.disallowDefaultComputeServiceAccount | §9.11 |
Pod Security Admission at restricted on every namespace without a documented exception | Blocks root, host namespaces, and unconfined capabilities by default | kubectl get ns -o custom-columns=NAME:.metadata.name,ENFORCE:.metadata.labels.pod-security\.kubernetes\.io/enforce | §9.14 |
Default-deny NetworkPolicy per namespace | Without one, every pod reaches every other pod | kubectl get networkpolicy -A; Dataplane V2 enabled makes enforcement always-on | §9.15 |
| Application-layer Secret encryption with a Cloud KMS key | Protects Secret contents in an etcd snapshot | gcloud container clusters describe CLUSTER --region=REGION --format="value(databaseEncryption)" | §9.17 |
| Node auto-upgrade and a release channel set | Unpatched nodes accumulate known CVEs | gcloud container clusters describe CLUSTER --region=REGION --format="value(releaseChannel)" | §9.34 |
hostNetwork denied by admission policy in Workload Identity clusters | Host-networked pods bypass Workload Identity and get the node identity | Policy Controller / admission policy review; Console → Kubernetes Engine → Security Posture | §4.12 |
D.7 Cloud Run §
Cloud Run services and jobs. Ingress, invoker authorization, and the identity the revision runs as.
| Control | Why it matters | How to verify | § |
|---|---|---|---|
No allUsers binding on roles/run.invoker outside a service explicitly meant to be public | Removes authentication for the entire service | gcloud run services get-iam-policy SERVICE --region=REGION | §10.12 |
constraints/run.managed.requireInvokerIam applied where no service should ever be public | Prevents allUsers and --no-invoker-iam-check estate-wide | gcloud org-policies describe constraints/run.managed.requireInvokerIam --project=PROJECT | §10.14 |
Ingress restricted to internal-and-cloud-load-balancing for every load-balanced service | Otherwise the *.run.app URL bypasses the load balancer and Cloud Armor entirely | gcloud run services describe SERVICE --region=REGION --format="value(spec.template.metadata.annotations)"; test the default URL | §10.18 |
Dedicated sa-run-<service> account per service, never the default compute service account | Limits blast radius of a compromised container to that service's own permissions | gcloud run services describe SERVICE --region=REGION --format="value(spec.template.spec.serviceAccountName)" | §10.18 |
constraints/run.allowedIngress restricts all where not justified | Prevents a service owner from widening ingress without platform review | gcloud org-policies describe constraints/run.allowedIngress --project=PROJECT | §10.11 |
Cloud SQL connections use roles/cloudsql.client scoped to the service account, not a broader IAM grant | Limits which identities can open a proxied database connection | gcloud projects get-iam-policy PROJECT --flatten="bindings[].members" --filter="bindings.role:roles/cloudsql.client" | §10.23 |
Scheduler and other automation accounts hold only roles/run.jobsExecutor on the specific job, not roles/run.developer | A compromised trigger credential cannot alter the job it triggers | gcloud projects get-iam-policy PROJECT --flatten="bindings[].members" --filter="bindings.role:roles/run.jobsExecutor" | §10.28 |
D.8 Databases §
Cloud SQL, AlloyDB, Spanner, and BigQuery. Connection path, authentication, and encryption at rest.
| Control | Why it matters | How to verify | § |
|---|---|---|---|
| No Cloud SQL instance has a public IP without a documented exception | Public IP is the largest single exposure a managed database can have | gcloud sql instances list --format="table(name,settings.ipConfiguration.ipv4Enabled)" | §12.13 |
constraints/sql.restrictPublicIp (or the managed variant) enforced at project or org | Backstops the private-networking default against a one-off create | gcloud org-policies describe constraints/sql.restrictPublicIp --project=PROJECT_ID --effective (§2.30) | §2.30 |
| IAM database authentication enabled for every PostgreSQL/MySQL instance | Removes the shared password from the connection path | gcloud sql instances describe INSTANCE --format="value(settings.databaseFlags)" for cloudsql.iam_authentication=on | §12.7 |
--ssl-mode=ENCRYPTED_ONLY or stricter on every instance | Closes unencrypted direct-protocol connections that bypass the proxy | gcloud sql instances describe INSTANCE --format="value(settings.settingsVersion,ipConfiguration.sslMode)" | §12.18 |
| CMEK configured at creation for instances holding regulated data | Default encryption is adequate but not independently auditable or revocable | gcloud sql instances describe INSTANCE --format="value(diskEncryptionConfiguration)" (§12.14, §14.8) | §12.14 |
cloudsql.enable_pgaudit on for PostgreSQL instances holding sensitive data | Built-in logging flags are coarser and can log password-bearing statements | gcloud sql instances describe INSTANCE --format="value(settings.databaseFlags)" | §12.2 |
--deletion-protection set on every production instance | Prevents an accidental or malicious single-command deletion | gcloud sql instances describe INSTANCE --format="value(settings.deletionProtectionEnabled)" | §12.13 |
| Cloud SQL Auth Proxy or a Language Connector used for every out-of-VPC connection | Removes reliance on network allowlists and long-lived passwords | Review application configuration and --connector-enforcement on the instance | §12.17 |
| BigQuery consumers granted views, not tables | The view's SQL is the boundary | bq show --format=prettyjson DATASET | §32.6 |
| BigQuery datasets carry a default table expiration | Data that no longer exists cannot be exfiltrated | bq show --format=prettyjson DATASET | §32.6 |
D.9 Storage §
Cloud Storage buckets and objects: public reachability, access uniformity, retention, and encryption.
| Control | Why it matters | How to verify | § |
|---|---|---|---|
constraints/storage.uniformBucketLevelAccess enforced org-wide | Removes ACLs as a parallel, unaudited access path | gcloud org-policies describe constraints/storage.uniformBucketLevelAccess --organization=123456789012 --effective | §11.9 |
constraints/storage.publicAccessPrevention enforced org-wide | Blocks any accidental public binding regardless of mechanism | gcloud org-policies describe constraints/storage.publicAccessPrevention --organization=123456789012 --effective | §11.10 |
| No bucket relies on legacy bucket/object roles | They bypass the IAM condition and audit model | gcloud storage buckets get-iam-policy gs://BUCKET --format="value(bindings.role)" and check for legacy | §11.20 |
| Signed URL duration matches task duration, not the 7-day maximum | A long-lived signed URL is a long-lived bearer credential | review application code issuing gcloud storage sign-url / client-library equivalents | §11.11 |
| Signing uses service account impersonation, never a downloaded key | Removes a static credential capable of signing arbitrarily | gcloud iam service-accounts keys list --iam-account=SA_EMAIL --managed-by=user returns nothing | §11.11 |
| Quarantine bucket is distinct from the bucket applications read from | Prevents unscanned content from reaching production consumers | review bucket inventory against the upload pipeline design (§11.22) | §11.22 |
| Soft delete duration meets or exceeds the organization floor | Protects against accidental overwrite or deletion | gcloud storage buckets describe gs://BUCKET --format="value(softDeletePolicy)" | §11.17 |
| Retention policy locked (Bucket Lock) on every compliance-archival bucket | An unlocked policy can be shortened or removed by an administrator | gcloud storage buckets describe gs://BUCKET --format="value(retentionPolicy)" | §11.16 |
D.10 Secrets §
Secret Manager and the paths that route around it. The controls that matter are per-secret IAM, rotation, and whether access is logged.
| Control | Why it matters | How to verify | § |
|---|---|---|---|
Secret-level IAM bindings, not project-level secretAccessor | Project-level access reaches every secret, present and future | gcloud secrets get-iam-policy SECRET_ID per secret vs. gcloud projects get-iam-policy | §13.4 |
| Rotation topics have a deployed, subscribed handler | --rotation-period alone never changes the credential | confirm a Pub/Sub subscription and recent handler invocations for each topic | §13.5 |
Workloads pin a version number, not latest | latest moves at access time and hides which credential is live | gcloud run services describe SERVICE --region=REGION --format="value(spec.template.spec.containers[].env)" | §13.8 |
Data Access DATA_READ logging enabled for Secret Manager | AccessSecretVersion is unrecorded otherwise | gcloud projects get-iam-policy PROJECT_ID --format=json | grep -A2 secretmanager | §13.12 |
| Version destroy TTL set on secrets holding production credentials | Gives an investigation window before an irreversible destroy | gcloud secrets describe SECRET_ID --format='value(versionDestroyTtl)' | §13.2 |
CMEK'd secrets use user-managed replication | automatic replication cannot pair with a customer-managed key | gcloud secrets describe SECRET_ID --format=json | §13.11 |
| Pre-commit and CI secret scanning both active | The hook catches locally; CI catches what the hook missed | pipeline configuration review; hook install in onboarding docs | §13.14 |
| Any secret found in Git history is rotated, not just deleted | A committed secret is compromised regardless of history rewriting | incident log entry for the rotation, dated before any history rewrite | §13.14 |
| No credential appears in a log line or a query string | Logs have wider readership than the credential store | Log query for Bearer and for key-shaped strings | §21.13 |
D.11 Encryption §
Customer-managed encryption keys, their provenance, their protection level, and the destruction delay that makes a mistake recoverable.
| Control | Why it matters | How to verify | § |
|---|---|---|---|
Key rings and keys live only in rc-saas-shared-sec-01, never a workload project | Enforces independent administrative control (§2.21, §14.8) | gcloud kms keyrings list --location=us-central1 --project=rc-saas-shared-sec-01 | §2.21 |
| No global key ring backs data-at-rest CMEK | A global dependency for a regional workload with no locality benefit | gcloud kms keyrings list --location=global --project=rc-saas-shared-sec-01 | §14.2 |
Service agents hold only roles/cloudkms.cryptoKeyEncrypterDecrypter, never roles/cloudkms.admin | Separates use from administration | gcloud kms keys get-iam-policy KEY --keyring=RING --location=REGION | §14.1 |
constraints/gcp.restrictNonCmekServices set for regulated data services | Blocks Google-managed-key-only resource creation | gcloud org-policies describe constraints/gcp.restrictNonCmekServices --organization=123456789012 --effective | §14.8 |
constraints/gcp.restrictCmekCryptoKeyProjects allows only the security project | Prevents CMEK keys sourced from an unapproved project | gcloud org-policies describe gcp.restrictCmekCryptoKeyProjects --organization=ORG_ID | §14.8 |
constraints/cloudkms.minimumDestroyScheduledDuration floors the destroy window | Stops an effectively-immediate key destruction | same command against that constraint | §14.18 |
| Per-tenant keys exist wherever crypto-shredding is a designed erasure path | A shared key makes destruction all-or-nothing | review key-to-tenant mapping against §14.13's separation axes | §14.13 |
| Old key versions disabled or destroyed once no ciphertext depends on them | Unused enabled versions are both an attack surface and a cost | gcloud kms keys versions list --key=KEY --keyring=RING --location=REGION | §14.12 |
D.12 Logging §
Audit and platform logs: what is collected, where it lands, how long it survives, and who can read the sensitive parts.
| Control | Why it matters | How to verify | § |
|---|---|---|---|
| Aggregated org-level log sink to a separate logging project | Logs must survive deletion of the audited project | gcloud logging sinks describe org-audit-sink --organization=ORG_ID | §1.20 |
| Sink writer identities granted on every destination | An ungranted sink routes nothing and reports no error | gcloud logging sinks describe SINK --format="value(writerIdentity)" | §17.9 |
| Data Access audit logs enabled for data-bearing services | Admin Activity alone cannot answer "who read this" | gcloud projects get-iam-policy PROJECT_ID --format="value(auditConfigs)"; Console → IAM & Admin → Audit Logs | §1.8 |
constraints/iam.disableAuditLoggingExemption enforced | An exempted principal's reads are invisible | gcloud org-policies describe constraints/iam.disableAuditLoggingExemption --organization=ORG_ID | §17.3 |
constraints/gcp.disableCloudLogging never allowed | Disables the entire trail for a project | gcloud org-policies describe constraints/gcp.disableCloudLogging --organization=ORG_ID | §17.10 |
| Audit bucket retention set and locked | Locking prevents shortening retention or deleting the bucket | gcloud logging buckets describe lb-audit-7y --location=us-central1 --project=rc-saas-shared-log-01 | §17.14 |
| Second copy in Cloud Storage with Bucket Lock | The only copy outside the logging project's blast radius | gcloud storage buckets describe gs://rc-saas-shared-log-archive-01 | §17.12 |
No workload team holds roles/logging.configWriter at the organization | Sink creation is exfiltration; sink deletion is evidence destruction | gcloud organizations get-iam-policy ORG_ID --flatten="bindings[].members" | §17.12 |
| Log views scoped per audience | Central storage must not mean universal read | gcloud logging views list --bucket=lb-audit-7y --location=us-central1 | §17.8 |
| Asset export scheduled at least monthly | CAI history covers only 35 days | Evidence bucket object listing by date | §29.4 |
D.13 Monitoring §
Detection and alerting. Security Command Center, notification routing, and the coverage checks that show a control is actually running.
| Control | Why it matters | How to verify | § |
|---|---|---|---|
| SCC activated at the organization | Project-scoped activation produces no attack paths and no cross-project correlation | gcloud scc manage services list --organization=organizations/ORG_ID; Security Command Center → Settings | §16.6 |
| Threat detectors enabled on production | Misconfiguration scanning without threat detection sees nothing happening now | gcloud scc manage services describe event-threat-detection --organization=organizations/ORG_ID | §16.6 |
| Notification config exists and is filtered | An unfiltered stream is unusable and expensive; no stream means no alerting | gcloud scc notifications list --organization=ORG_ID | §16.14 |
| BigQuery export running alongside Pub/Sub | Pub/Sub retention is bounded; the export is the durable copy | gcloud scc bqexports list --organization=ORG_ID | §16.14 |
| Mute configs are time-bounded | A permanent static mute hides both the finding and the failed control | gcloud scc muteconfigs list --organization=ORG_ID | §16.3 |
| No workload service account holds an SCC role | Findings are read by pipelines, not applications | gcloud organizations get-iam-policy ORG_ID --flatten="bindings[].members" | §16.1 |
| Detection service enablement is alerted on | Disabling a detector produces no finding | Alert on gcloud scc manage services update in Admin Activity | §34.2 |
| Security alerts exist for the §18.15 event list | These are the events that indicate a control was removed | gcloud monitoring policies list --project=rc-saas-shared-sec-01 | §18.15 |
| Security alerts are built on Admin Activity logs | Data Access-based alerts can be silenced by editing an audit config | gcloud logging metrics describe METRIC --project=rc-saas-shared-sec-01 | §18.15 |
| Two independent notification channels for critical pages | PagerDuty, Slack, webhooks, and mobile share one failure domain | gcloud monitoring policies describe POLICY_ID | §18.17 |
| Log sink deletion alerted on | Locking the bucket does not protect the sink | Monitoring → Alerting, policy list | §17.20 |
D.14 CI/CD §
The pipeline as a production system: its identity, its state, its artifacts, and the approval that separates a plan from an apply.
| Control | Why it matters | How to verify | § |
|---|---|---|---|
| Default branch requires a pull request and blocks direct push | Every other control here is advisory without it | Branch protection settings; attempt a direct push | §22.6 |
| Tags protected and deploy sources are protected references | A tag-triggered deploy routes around branch protection | Tag protection rules; pipeline source configuration | §22.6 |
| Fork pull requests build without credentials | Otherwise a stranger's code runs with your permissions | gcloud builds triggers describe TRIGGER --region=REGION | §23.2 |
| Signed commits required where production artifacts are built | Authorship survives repository account compromise | Host verification status on recent commits | §22.8 |
Every trigger sets --service-account | The default identity is shared and over-granted | gcloud builds triggers list --region=REGION --format="table(name,serviceAccount)" | §23.2 |
| Build service accounts are per pipeline | A shared account holds the union of every pipeline's grants | IAM policy review in the CI/CD project | §23.6 |
| No build account holds deploy permissions | Build and deploy identities must differ | gcloud asset search-all-iam-policies --query='policy:cloudbuild.gserviceaccount.com' | §25.13 |
constraints/cloudbuild.disableCreateDefaultServiceAccount enforced | Removes the shared default before it accumulates grants | gcloud org-policies describe constraints/cloudbuild.disableCreateDefaultServiceAccount --organization=ORG_ID | §23.6 |
Production pools set --no-public-egress | The only constraint on exfiltration from a build step | gcloud builds worker-pools describe POOL --region=REGION | §23.5 |
| Builder images pinned by digest | A tag executes whatever the publisher pushed today | Grep cloudbuild.yaml for name: values without @sha256: | §23.3 |
Build config comes from the repository, not --inline-config | Inline configs are edited without review | gcloud builds triggers describe TRIGGER --region=REGION | §23.15 |
| Images referenced downstream by digest | A tag can be repointed after verification | gcloud deploy releases describe RELEASE --delivery-pipeline=PIPELINE --region=REGION | §23.10 |
| Separate repositories per environment | Promotion is IAM-enforced only if the repositories differ | gcloud artifacts repositories list --location=us-central1 | §24.1 |
| IAM granted at repository scope, not project | A project grant erases the environment boundary | gcloud artifacts repositories get-iam-policy REPO --location=REGION | §24.4 |
Production repository has --immutable-tags | Prevents a verified tag being repointed | gcloud artifacts repositories describe REPO --location=REGION --format="value(dockerConfig.immutableTags)" | §24.5 |
| Vulnerability scanning enabled on every repository | Continuous re-evaluation is the only current view | Repository vulnerabilityScanningConfig | §24.6 |
| Production cleanup policy has an explicit Keep for release tags | Age-based deletion otherwise removes a running release | gcloud artifacts repositories describe REPO --location=REGION | §24.12 |
Every production target sets requireApproval: true | The field defaults to false | gcloud deploy targets describe prod --region=REGION | §25.4 |
| Deploy identity holds no Artifact Registry write | Deployment must not be able to publish | gcloud artifacts repositories get-iam-policy prod-docker --location=REGION | §25.13 |
| Binary Authorization enforced, not dry-run, in production | Dry-run logs and admits | gcloud container clusters describe CLUSTER --region=REGION --format="value(binaryAuthorization.evaluationMode)" | §25.10 |
| Attesting identity is not the build identity | Otherwise the attestation proves only that a build ran | gcloud kms keys get-iam-policy KEY --keyring=RING --location=REGION | §25.11 |
| State bucket has uniform bucket-level access and public access prevention | Removes per-object ACLs and makes §33.1 impossible here | gcloud storage buckets describe gs://BUCKET | §33.1 |
| State readable only by the pipeline account | The one control that actually prevents disclosure | gcloud storage buckets get-iam-policy gs://BUCKET | §26.14 |
| Apply consumes the approved plan artifact | Otherwise the approval refers to a different change | Pipeline definition: apply tfplan, no -auto-approve | §26.24 |
Provider pinned ~> 8.0 and lock file committed | Reproducible provider binary; a supply chain control | .terraform.lock.hcl present and in the diff | §26.1 |
| Drift detection runs nightly and alerts on exit code 2 | Undetected drift is an unrecorded change or an attacker | Scheduled job and its alert policy (§18.15) | §18.15 |
D.15 Disaster Recovery §
Backup, restore, and the tests that distinguish a backup from a hope.
| Control | Why it matters | How to verify | § |
|---|---|---|---|
| Automated backups and point-in-time recovery enabled with adequate retention | Bounds data-loss exposure from both operator error and attack | gcloud sql instances describe INSTANCE --format="value(settings.backupConfiguration)" | §12.10 |
| Backup coverage reconciled against the asset inventory | Finds unprotected production data | Asset export minus backup plan associations | §29.9 |
| A dated restore test exists | An untested restore is a plan, not a capability | The restore test record and its verification step | §29.9 |
| Backup vault has enforced retention | Without it a vault is a bucket with a nicer name | gcloud backup-dr backup-vaults describe VAULT --location=REGION | §29.9 |
| Backups separated from the source's project and key | A compromised project must not reach its own backups | gcloud backup-dr backup-vaults list --location=REGION | §33.19 |
| Backups reach back further than detection latency | You must restore from before the compromise began | Backup retention versus measured detection latency | §34.14 |
| Rollback tested on a schedule | An untested rollback is a plan, not a capability | Rollout history shows a rehearsed rollback | §25.9 |
| Previous production artifacts retained | A cleanup policy can delete the rollback target | gcloud artifacts docker images list for the prior release tag | §25.9 |
| GKE Backup plan scope reviewed after every namespace change | A stale scope silently stops covering new namespaces | review the google_gke_backup_backup_plan.backup_config state against the cluster's current namespace list | §9.40 |
| Runbook snapshots before stopping | Stopping destroys memory and serial output | Runbook step order | §34.7 |
| State bucket has object versioning | The only recovery from a corrupt state write | gcloud storage buckets describe gs://BUCKET --format='value(versioning)' | §26.14 |
Sources §
Every row in this appendix is merged from the Security Checklist of the chapter named in its § column, and each of those chapters carries its own sources. This appendix introduces no claim of its own and therefore cites no external page; the extraction method and the 518-to-190 merge are recorded in .build/verify/appD.md.