diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6e2c008..9b096da 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,23 @@ All notable changes to Shasta are documented here. Format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## Unreleased
+
+### Added
+- Add GCP compliance scanning coverage across IAM, networking, storage,
+ encryption, logging, compute, and Cloud Run, including CIS GCP mappings,
+ Google provider Terraform remediation templates, GCP dependencies, and
+ GCP smoke/functional tests.
+
+### Fixed
+- Surface GCP domain, project, IAM, and GCS per-resource API failures as
+ `NOT_ASSESSED` findings instead of silently dropping checks or reporting
+ false `PASS` results.
+- Use Cloud Resource Manager's `projects/NUMBER` resource name when recording
+ GCP project numbers.
+- Render GCP provider labels and CIS GCP controls in Markdown, HTML,
+ consolidated reports, and dashboard finding details.
+
## [1.9.0] — 2026-05-05 — Voice console (opt-in)
### Added
diff --git a/README.md b/README.md
index ebba00f..797eaa1 100644
--- a/README.md
+++ b/README.md
@@ -5,9 +5,9 @@
**Multi-Cloud Compliance Automation, Claude-Native**
AI-native compliance toolkit — SOC 2, ISO 27001, HIPAA, ISO 42001, and EU AI Act
-across AWS and Azure. Through conversation, not dashboards — including an opt-in
-**voice console** (`python -m shasta.voice`) that lets you talk to your compliance
-posture hands-free. From the team at [Transilience.ai](https://www.transilience.ai).
+across AWS, Azure, and GCP. Through conversation, not dashboards — including an
+opt-in **voice console** (`python -m shasta.voice`) that lets you talk to your
+compliance posture hands-free. From the team at [Transilience.ai](https://www.transilience.ai).
[](https://www.transilience.ai)
[](https://github.com/transilienceai/shasta/actions/workflows/integrity.yml)
@@ -24,7 +24,7 @@ posture hands-free. From the team at [Transilience.ai](https://www.transilience.
> 📹 **See it in action:** [60-second walkthrough of the voice console](./docs/media/shasta-voice-demo.mp4) — talk to your compliance posture, drill into findings, manage the risk register hands-free. Also attached as a release asset on the [v1.9.0 GitHub Release](https://github.com/transilienceai/shasta/releases/tag/v1.9.0).
-Shasta scans your cloud infrastructure for SOC 2, ISO 27001, HIPAA, ISO 42001, EU AI Act, OWASP LLM Top 10 and more. It covers 13 compliance frameworks, 221 automated checks, and 199 security questionnaire answers — with a web dashboard, an opt-in voice console, 112 Terraform remediation templates, and auditor-grade evidence. Application source-code AI scanning lives in the separate [Whitney](https://github.com/transilienceai/whitney) project (`pip install whitney`). Built for founders running <50 employee companies who need compliance without the $30K/year Vanta bill.
+Shasta scans your cloud infrastructure for SOC 2, ISO 27001, HIPAA, ISO 42001, EU AI Act, OWASP LLM Top 10 and more. It covers 13 compliance frameworks, 267 automated checks, and 199 security questionnaire answers — with a web dashboard, an opt-in voice console, 132 Terraform remediation templates, and auditor-grade evidence. Application source-code AI scanning lives in the separate [Whitney](https://github.com/transilienceai/whitney) project (`pip install whitney`). Built for founders running <50 employee companies who need compliance without the $30K/year Vanta bill.
> **Three load-bearing artifacts at the repo root, in order of what to read:**
> [`README.md`](./README.md) (this file — what it does) →
@@ -56,7 +56,7 @@ For application source-code AI security scanning — prompt injection detection,
## Platform Capabilities
-### 1. Multi-Cloud Security Scanning (5 Domains, 174+ Checks)
+### 1. Multi-Cloud Security Scanning (5 Domains, 267+ Checks)
#### AWS Checks (40+)
@@ -184,7 +184,7 @@ Attack surface analysis that produces auditor-grade pen test evidence:
### 11. Risk Register (SOC 2 CC3.1)
Automated risk management workflow required for SOC 2 Risk Assessment:
-- **Auto-seeds from scan findings** — failing checks automatically create risk items with pre-mapped likelihood, impact, and treatment plans (34 check-to-risk mappings across AWS + Azure)
+- **Auto-seeds from scan findings** — failing checks automatically create risk items with pre-mapped likelihood, impact, and treatment plans (34 check-to-risk mappings across AWS, Azure, and GCP)
- **Risk scoring** — 3x3 likelihood/impact matrix (1-9 score, low/medium/high levels)
- **Treatment tracking** — mitigate, accept, transfer, or avoid with documented plans
- **Status workflow** — open → in_progress → accepted/resolved
@@ -254,7 +254,8 @@ git clone https://github.com/kkmookhey/shasta.git
cd shasta
pip install -e ".[dev]" # Core + dev tools
pip install -e ".[azure]" # Add Azure support (optional)
-pip install -e ".[dev,azure]" # Everything
+pip install -e ".[gcp]" # Add GCP support (optional)
+pip install -e ".[dev,azure,gcp]" # Everything
# 2a. Configure AWS (read-only access)
aws configure --profile shasta
@@ -264,10 +265,15 @@ aws configure --profile shasta
az login
az account show # Note your subscription_id and tenant_id
+# 2c. Configure GCP (read access via Application Default Credentials)
+gcloud auth application-default login
+gcloud config set project YOUR_PROJECT_ID
+
# 3. Open Claude Code and run
/connect-aws # Validate AWS credentials, discover services
/connect-azure # Validate Azure credentials, discover services
-/scan # Full SOC 2 compliance scan (AWS, Azure, or both)
+/connect-gcp # Validate GCP credentials, discover project and services
+/scan # Full SOC 2 compliance scan (AWS, Azure, GCP, or any combination)
/gap-analysis # Interactive gap analysis with AI guidance
/report # Generate PDF/HTML/MD reports
/remediate # Get Terraform fixes for findings
@@ -302,7 +308,8 @@ Skills are the building blocks Claude uses behind the scenes. You can invoke the
|-------|-------------|--------|
| `/connect-aws` | Validate AWS credentials, discover account topology and services | Account info, service list |
| `/connect-azure` | Validate Azure credentials, discover subscription and services | Subscription info, service list |
-| `/scan` | Run all compliance checks across AWS and/or Azure (IAM, network, storage, encryption, monitoring) | Findings with AI explanations |
+| `/connect-gcp` | Validate GCP credentials, discover project and enabled services | Project info, service list |
+| `/scan` | Run all compliance checks across AWS, Azure, and/or GCP (IAM, network, storage, encryption, monitoring) | Findings with AI explanations |
| `/gap-analysis` | Interactive SOC 2 gap analysis with control-by-control walkthrough | Gap analysis report |
| `/report` | Generate compliance reports in all formats | MD, HTML, PDF files |
| `/remediate` | Interactive remediation with Terraform code and step-by-step instructions | Terraform bundle + guidance |
@@ -330,14 +337,14 @@ For a **<50 employee startup** pursuing compliance:
| Category | Coverage | Method |
|----------|----------|--------|
-| Technical cloud controls | ~90% | 190+ automated checks across AWS and Azure (full CIS AWS v3.0 + CIS Azure v3.0 coverage, including EC2/EKS/ECS hardening, KMS posture, CIS 4.x CloudWatch alarms, CloudFront, Redshift, ElastiCache, Neptune, Lambda Function URL auth, S3 Object Ownership, AWS Backup cross-region copy + access policy) |
+| Technical cloud controls | ~90% | 267+ automated checks across AWS, Azure, and GCP (full CIS AWS v3.0 + CIS Azure v3.0 coverage plus CIS GCP mappings, including EC2/EKS/ECS hardening, KMS posture, CIS 4.x CloudWatch alarms, CloudFront, Redshift, ElastiCache, Neptune, Lambda Function URL auth, S3 Object Ownership, AWS Backup cross-region copy + access policy) |
| Policy/process controls | ~80% | 8 generated policy documents |
| Continuous monitoring | ~90% | 12 Config Rules + 6 EventBridge rules + GuardDuty + Inspector + Azure Defender (per-plan) + Azure Policy + CIS 5.2.x Activity Log alerts |
| Audit evidence | ~85% | Control tests, evidence snapshots (AWS + Azure), access reviews, reports |
| Vulnerability management | ~85% | Inspector + SBOM + OSV.dev + CISA KEV |
| Supply chain security | ~80% | SBOM discovery + known-compromised DB + live scanning |
| Change management | ~80% | GitHub integration + CloudTrail + Config + Azure Activity Log |
-| Remediation guidance | ~90% | 112 Terraform templates (81 AWS + 31 Azure azurerm) covering CloudTrail/KMS/Object Lock, Security Hub, Access Analyzer, EC2 IMDSv2 + instance profiles, EKS private endpoint + audit logging + secrets KMS, ECS task hardening, KMS rotation + key policy + scheduled deletion, IAM policy wildcards + role trust + unused roles, CIS 4.x CloudWatch alarms, Config conformance packs, CloudFront HTTPS+TLS+WAF+OAC, Redshift encryption+public-access+audit+SSL, ElastiCache TLS+at-rest+AUTH, Neptune encryption, RDS force_ssl + log_settings + min TLS, Lambda Function URL auth + layer origin, API Gateway client cert + authorizer + throttling + request validation, S3 Object Ownership + access logging + KMS-CMK, AWS Backup cross-region copy + access policy, EFS/SNS/SQS/Secrets/ACM, ELB v2 TLS+logs+headers, RDS deep+IAM auth+PITR, Lambda runtime+CMK+DLQ, API Gateway WAF+logging, AWS Backup vault lock, VPC endpoints, CloudWatch Logs KMS+retention, AWS Org SCPs+tag policies — plus the full Azure set |
+| Remediation guidance | ~90% | 132 Terraform templates (81 AWS + 31 Azure azurerm + 20 GCP google) covering CloudTrail/KMS/Object Lock, Security Hub, Access Analyzer, EC2 IMDSv2 + instance profiles, EKS private endpoint + audit logging + secrets KMS, ECS task hardening, KMS rotation + key policy + scheduled deletion, IAM policy wildcards + role trust + unused roles, CIS 4.x CloudWatch alarms, Config conformance packs, CloudFront HTTPS+TLS+WAF+OAC, Redshift encryption+public-access+audit+SSL, ElastiCache TLS+at-rest+AUTH, Neptune encryption, RDS force_ssl + log_settings + min TLS, Lambda Function URL auth + layer origin, API Gateway client cert + authorizer + throttling + request validation, S3 Object Ownership + access logging + KMS-CMK, AWS Backup cross-region copy + access policy, EFS/SNS/SQS/Secrets/ACM, ELB v2 TLS+logs+headers, RDS deep+IAM auth+PITR, Lambda runtime+CMK+DLQ, API Gateway WAF+logging, AWS Backup vault lock, VPC endpoints, CloudWatch Logs KMS+retention, AWS Org SCPs+tag policies — plus the full Azure and GCP sets |
| Security questionnaires | ~70% | 199 questions auto-filled from scan evidence (SIG Lite, CAIQ, Enterprise) |
| AI governance | ~85% | Cloud AI checks (Bedrock + SageMaker + Azure OpenAI + Azure ML) + AI SBOM, 7 frameworks (ISO 42001, EU AI Act, NIST AI RMF, NIST AI 600-1, OWASP LLM Top 10, OWASP Agentic Top 10, MITRE ATLAS); application source-code prompt-injection scanning lives in the standalone Whitney scanner (separate repo) |
| Visual dashboard | Yes | FastAPI + Tailwind + Chart.js at localhost:8080 |
@@ -659,7 +666,7 @@ Tier 2 and 3 used 4 parallel agents in isolated worktrees for maximum throughput
| Terraform templates | 14 | 36 | **36** |
| Unit tests | 9 | 100 | **100** |
| Compliance frameworks | 1 (SOC 2) | 2 (SOC 2 + ISO 27001) | **5** (+ HIPAA, ISO 42001, EU AI Act) |
-| Cloud providers | 1 (AWS) | 2 (AWS + Azure) | **2** |
+| Cloud providers | 1 (AWS) | 2 (AWS + Azure) | **3** (AWS + Azure + GCP) |
### Token Consumption Estimate
@@ -717,7 +724,8 @@ shasta/
├── .claude/skills/ # Claude Code skills (auto-discovered)
│ ├── connect-aws/SKILL.md # AWS connection and validation
│ ├── connect-azure/SKILL.md # Azure connection and validation
-│ ├── scan/SKILL.md # Full compliance scan (AWS + Azure)
+│ ├── connect-gcp/SKILL.md # GCP connection and validation
+│ ├── scan/SKILL.md # Full compliance scan (AWS + Azure + GCP)
│ ├── gap-analysis.md # Interactive gap analysis
│ ├── report.md # Report generation (MD/HTML/PDF)
│ ├── remediate.md # Terraform remediation guidance
@@ -790,7 +798,7 @@ shasta/
│ └── azure-test-env/ # Azure test environment
│ └── main.tf # Azure test resources (compliant + non-compliant)
│
-├── tests/ # pytest test suite (500+ tests)
+├── tests/ # pytest test suite (720+ tests)
│ ├── conftest.py
│ ├── test_aws/
│ │ ├── test_client.py # AWS client tests (moto)
@@ -864,7 +872,7 @@ shasta/
- [ ] Network ACL checks (AWS)
### Medium Term
-- [ ] GCP scanning modules
+- [x] ~~GCP scanning modules~~ — IAM, networking, storage, encryption, monitoring, compute, and Cloud Run checks with CIS GCP mappings
- [ ] Okta integration (identity provider checks)
- [ ] Google Workspace integration
- [ ] Trust center page generation
diff --git a/TRUST.md b/TRUST.md
index c0148e2..829a1c2 100644
--- a/TRUST.md
+++ b/TRUST.md
@@ -10,7 +10,7 @@ forward-looking discipline. Principles say what we promise to do.
Trust says how you check that we did it.
For the Whitney AI scanner's specific validation story (vulnerability
-fixtures, live cloud validation against AWS + Azure, dual-engine
+fixtures, live cloud validation across AWS, Azure, and GCP, dual-engine
Semgrep architecture), see the standalone Whitney repo at
[github.com/transilienceai/whitney](https://github.com/transilienceai/whitney).
(`src/whitney/TRUST.md` was retired in the 2026-04-13 Whitney/Shasta split.)
@@ -21,9 +21,9 @@ Semgrep architecture), see the standalone Whitney repo at
Shasta and Whitney together ship the following, all integrity-tested:
-- **221 check functions** (221 cloud compliance + 0 AI governance — Whitney now ships as a separate repo at [github.com/transilienceai/whitney](https://github.com/transilienceai/whitney); install with `pip install whitney` for source-code scanning)
-- **112 Terraform remediation templates** (81 AWS + 31 Azure)
-- **706 tests** that all pass on every commit
+- **267 check functions** (267 cloud compliance + 0 AI governance — Whitney now ships as a separate repo at [github.com/transilienceai/whitney](https://github.com/transilienceai/whitney); install with `pip install whitney` for source-code scanning)
+- **132 Terraform remediation templates** (81 AWS + 31 Azure + 20 GCP)
+- **814 tests** that all pass on every commit
None of the claims in this README are written by hand and hoped-for —
every numeric claim is AST-counted from source by an integrity test
@@ -58,7 +58,7 @@ Every finding is produced by:
| Mechanism | Used in |
|---|---|
-| `boto3` / `azure.mgmt.*` SDK calls | All AWS / Azure cloud checks |
+| `boto3` / `azure.mgmt.*` / Google Cloud SDK calls | AWS, Azure, and GCP cloud checks |
| Semgrep AST-based pattern matching (with regex fallback) | Whitney code scanning |
| Dictionary lookups | Framework control mapping |
| Arithmetic | Compliance scoring |
diff --git a/pyproject.toml b/pyproject.toml
index b2458fa..3dc46c3 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -62,6 +62,12 @@ azure = [
"azure-mgmt-security>=6.0.0",
"msgraph-sdk>=1.5.0",
]
+gcp = [
+ "google-auth>=2.29.0",
+ "google-api-python-client>=2.128.0",
+ "google-cloud-storage>=2.16.0",
+ "google-auth-httplib2>=0.2.0",
+]
dashboard = [
"fastapi>=0.115.0",
"uvicorn>=0.32.0",
diff --git a/src/shasta/dashboard/routes.py b/src/shasta/dashboard/routes.py
index 7154b88..188741d 100644
--- a/src/shasta/dashboard/routes.py
+++ b/src/shasta/dashboard/routes.py
@@ -31,6 +31,7 @@ def _enrich_all_frameworks(findings):
enrich_findings_with_hipaa(findings)
return findings
+
router = APIRouter()
@@ -195,6 +196,7 @@ async def finding_detail(request: Request, finding_id: str):
# Get mapped controls
soc2_controls = finding.soc2_controls
+ cis_gcp_controls = finding.cis_gcp_controls
iso_controls = finding.details.get("iso27001_controls", [])
hipaa_controls = finding.details.get("hipaa_controls", [])
@@ -204,6 +206,7 @@ async def finding_detail(request: Request, finding_id: str):
"request": request,
"finding": finding,
"soc2_controls": soc2_controls,
+ "cis_gcp_controls": cis_gcp_controls,
"iso_controls": iso_controls,
"hipaa_controls": hipaa_controls,
"details_json": json.dumps(finding.details, indent=2, default=str),
diff --git a/src/shasta/dashboard/templates/finding_detail.html b/src/shasta/dashboard/templates/finding_detail.html
index c02828a..9b4b220 100644
--- a/src/shasta/dashboard/templates/finding_detail.html
+++ b/src/shasta/dashboard/templates/finding_detail.html
@@ -102,7 +102,7 @@
Re
Mapped Controls
-
+
{% if soc2_controls %}
SOC 2 Controls
@@ -113,6 +113,16 @@ SOC 2 Controls
{% endif %}
+ {% if cis_gcp_controls %}
+
+
CIS GCP Controls
+
+ {% for ctrl in cis_gcp_controls %}
+ {{ ctrl }}
+ {% endfor %}
+
+
+ {% endif %}
{% if iso_controls %}
ISO 27001 Controls
@@ -133,8 +143,8 @@ HIPAA Controls
{% endif %}
- {% if not soc2_controls and not iso_controls and not hipaa_controls %}
-
No control mappings available for this finding.
+ {% if not soc2_controls and not cis_gcp_controls and not iso_controls and not hipaa_controls %}
+
No control mappings available for this finding.
{% endif %}
diff --git a/src/shasta/evidence/models.py b/src/shasta/evidence/models.py
index 4218910..ac211ac 100644
--- a/src/shasta/evidence/models.py
+++ b/src/shasta/evidence/models.py
@@ -35,6 +35,7 @@ class CloudProvider(str, Enum):
AWS = "aws"
AZURE = "azure"
+ GCP = "gcp"
class CheckDomain(str, Enum):
@@ -106,6 +107,7 @@ def not_assessed(
soc2_controls: list[str] = Field(default_factory=list) # e.g., ["CC6.1", "CC6.2"]
cis_aws_controls: list[str] = Field(default_factory=list) # e.g., ["1.4", "3.1"]
cis_azure_controls: list[str] = Field(default_factory=list) # e.g., ["1.1.4", "5.2.1"]
+ cis_gcp_controls: list[str] = Field(default_factory=list) # e.g., ["1.4", "3.6", "5.1"]
mcsb_controls: list[str] = Field(default_factory=list) # e.g., ["IM-6", "DP-5"]
iso27001_controls: list[str] = Field(default_factory=list) # e.g., ["A.8.5", "A.5.15"]
hipaa_controls: list[str] = Field(default_factory=list) # e.g., ["164.312(a)(1)", "164.312(e)(1)"]
diff --git a/src/shasta/gcp/__init__.py b/src/shasta/gcp/__init__.py
new file mode 100644
index 0000000..d432f6d
--- /dev/null
+++ b/src/shasta/gcp/__init__.py
@@ -0,0 +1 @@
+"""GCP compliance scanning for Shasta."""
diff --git a/src/shasta/gcp/client.py b/src/shasta/gcp/client.py
new file mode 100644
index 0000000..ed216b4
--- /dev/null
+++ b/src/shasta/gcp/client.py
@@ -0,0 +1,324 @@
+"""GCP client for Shasta compliance scanning.
+
+Wraps the Google Cloud API client to provide validated access to GCP services.
+Mirrors the AWSClient / AzureClient interface for consistency.
+
+Supports all standard Application Default Credentials (ADC) sources:
+- gcloud CLI (gcloud auth application-default login)
+- Service account JSON key (GOOGLE_APPLICATION_CREDENTIALS env var)
+- Workload Identity (running on GCP: GCE, GKE, Cloud Run, etc.)
+- IAM service account impersonation
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from typing import Any
+
+
+class GCPClientError(Exception):
+ """Raised when GCP client operations fail."""
+
+
+@dataclass
+class GCPProjectInfo:
+ """Information about the connected GCP project."""
+
+ project_id: str
+ project_number: str
+ project_name: str
+ principal: str
+ region: str
+ services_in_use: list[str] = field(default_factory=list)
+
+
+# Default region used when none is specified
+_DEFAULT_REGION = "us-central1"
+
+# Fallback region list when the Compute API is unavailable
+_FALLBACK_REGIONS = [
+ "us-central1",
+ "us-east1",
+ "us-west1",
+ "europe-west1",
+ "asia-east1",
+]
+
+
+class GCPClient:
+ """Manages GCP API credentials and provides validated access to GCP services.
+
+ Uses Application Default Credentials (ADC), which supports:
+ - gcloud CLI: ``gcloud auth application-default login``
+ - Service Account: ``GOOGLE_APPLICATION_CREDENTIALS=/path/to/key.json``
+ - Workload Identity: automatically picked up when running on GCP
+ - Explicit credentials passed to the constructor
+ """
+
+ def __init__(
+ self,
+ project_id: str | None = None,
+ credentials: Any = None,
+ region: str | None = None,
+ ):
+ self._project_id = project_id
+ self._credentials = credentials
+ self._region = region or _DEFAULT_REGION
+ self._account_info: GCPProjectInfo | None = None
+ self._service_cache: dict[str, Any] = {}
+
+ @property
+ def credentials(self) -> Any:
+ """Lazy-initialize Application Default Credentials."""
+ if self._credentials is None:
+ try:
+ import google.auth
+
+ self._credentials, project_id = google.auth.default(
+ scopes=["https://www.googleapis.com/auth/cloud-platform"]
+ )
+ if not self._project_id and project_id:
+ self._project_id = project_id
+ except ImportError:
+ raise GCPClientError("GCP SDK not installed. Run: pip install -e '.[gcp]'")
+ except Exception as e:
+ raise GCPClientError(f"Failed to obtain GCP credentials: {e}")
+ return self._credentials
+
+ @property
+ def project_id(self) -> str:
+ if self._account_info:
+ return self._account_info.project_id
+ if self._project_id:
+ return self._project_id
+ raise GCPClientError(
+ "No GCP project ID available. Run validate_credentials() first or "
+ "set GOOGLE_CLOUD_PROJECT environment variable."
+ )
+
+ @property
+ def account_info(self) -> GCPProjectInfo | None:
+ return self._account_info
+
+ def service(self, service_name: str, version: str) -> Any:
+ """Return a cached googleapiclient discovery service.
+
+ Usage:
+ iam = client.service("iam", "v1")
+ response = iam.projects().serviceAccounts().list(name=...).execute()
+ """
+ key = f"{service_name}/{version}"
+ if key not in self._service_cache:
+ try:
+ from googleapiclient import discovery
+
+ self._service_cache[key] = discovery.build(
+ service_name,
+ version,
+ credentials=self.credentials,
+ cache_discovery=False,
+ )
+ except ImportError:
+ raise GCPClientError(
+ "google-api-python-client not installed. Run: pip install -e '.[gcp]'"
+ )
+ return self._service_cache[key]
+
+ def storage_client(self) -> Any:
+ """Return a google.cloud.storage.Client for GCS operations."""
+ key = "_storage_client"
+ if key not in self._service_cache:
+ try:
+ from google.cloud import storage
+
+ self._service_cache[key] = storage.Client(
+ project=self._project_id,
+ credentials=self.credentials,
+ )
+ except ImportError:
+ raise GCPClientError(
+ "google-cloud-storage not installed. Run: pip install -e '.[gcp]'"
+ )
+ return self._service_cache[key]
+
+ def validate_credentials(self) -> GCPProjectInfo:
+ """Validate GCP credentials and discover project information.
+
+ Returns GCPProjectInfo with project ID, number, and principal details.
+ """
+ try:
+ crm = self.service("cloudresourcemanager", "v3")
+ if not self._project_id:
+ # Try to infer project from credentials scope
+ try:
+ import google.auth.transport.requests
+
+ request = google.auth.transport.requests.Request()
+ self.credentials.refresh(request)
+ # For service accounts the quota_project_id may be set
+ self._project_id = getattr(
+ self.credentials, "quota_project_id", None
+ ) or getattr(self.credentials, "project_id", None)
+ except Exception:
+ pass
+
+ if not self._project_id:
+ raise GCPClientError(
+ "No GCP project ID found. Set GOOGLE_CLOUD_PROJECT or pass "
+ "project_id to GCPClient()."
+ )
+
+ project = crm.projects().get(name=f"projects/{self._project_id}").execute()
+ principal = self._get_principal()
+
+ self._account_info = GCPProjectInfo(
+ project_id=self._project_id,
+ project_number=project.get("name", "").replace("projects/", "")
+ or project.get("projectNumber", "")
+ or self._project_id,
+ project_name=project.get("displayName", self._project_id),
+ principal=principal,
+ region=self._region,
+ )
+ return self._account_info
+
+ except GCPClientError:
+ raise
+ except Exception as e:
+ raise GCPClientError(f"Failed to validate GCP credentials: {e}")
+
+ def _get_principal(self) -> str:
+ """Extract the principal (email/service-account) from credentials."""
+ try:
+ creds = self.credentials
+ # Service account credentials have a service_account_email attribute
+ if hasattr(creds, "service_account_email"):
+ return creds.service_account_email
+ # OAuth user credentials
+ if hasattr(creds, "token"):
+ import base64
+ import json
+
+ # Attempt to decode the token
+ token = getattr(creds, "id_token", None) or getattr(creds, "token", None)
+ if token and "." in str(token):
+ payload = str(token).split(".")[1]
+ payload += "=" * (4 - len(payload) % 4)
+ claims = json.loads(base64.b64decode(payload))
+ return claims.get("email") or claims.get("sub") or "unknown"
+ except Exception:
+ pass
+ return "unknown"
+
+ def list_projects(self) -> list[dict[str, str]]:
+ """List all GCP projects accessible to this credential.
+
+ Returns a list of {project_id, display_name, project_number, state} dicts.
+ Best-effort; returns the current single project on failure.
+ """
+ try:
+ crm = self.service("cloudresourcemanager", "v3")
+ out: list[dict[str, str]] = []
+ page_token = None
+ while True:
+ kwargs: dict[str, Any] = {}
+ if page_token:
+ kwargs["pageToken"] = page_token
+ response = crm.projects().list(**kwargs).execute()
+ for p in response.get("projects", []):
+ if p.get("state") != "ACTIVE":
+ continue
+ out.append(
+ {
+ "project_id": p.get("projectId", ""),
+ "display_name": p.get("displayName", ""),
+ "project_number": p.get("name", "").replace("projects/", ""),
+ "state": p.get("state", ""),
+ }
+ )
+ page_token = response.get("nextPageToken")
+ if not page_token:
+ break
+ return out
+ except Exception:
+ pid = self._project_id or "unknown"
+ return [
+ {"project_id": pid, "display_name": "", "project_number": "", "state": "ACTIVE"}
+ ]
+
+ def for_project(self, project_id: str) -> "GCPClient":
+ """Return a sibling GCPClient bound to a different project.
+
+ Reuses the same credentials and clears the service cache so each project
+ gets fresh API clients.
+ """
+ sibling = GCPClient(
+ project_id=project_id,
+ credentials=self._credentials,
+ region=self._region,
+ )
+ return sibling
+
+ def for_region(self, region: str) -> "GCPClient":
+ """Return a sibling GCPClient scoped to a specific region.
+
+ The region is stored for use by regional API calls (subnets, instances).
+ Credentials and project are shared.
+ """
+ sibling = GCPClient(
+ project_id=self._project_id,
+ credentials=self._credentials,
+ region=region,
+ )
+ sibling._account_info = self._account_info
+ return sibling
+
+ def get_enabled_regions(self) -> list[str]:
+ """Return all available GCP compute regions for this project."""
+ try:
+ compute = self.service("compute", "v1")
+ response = compute.regions().list(project=self.project_id).execute()
+ return sorted(r["name"] for r in response.get("items", []) if r.get("status") == "UP")
+ except Exception:
+ return list(_FALLBACK_REGIONS)
+
+ def discover_services(self) -> list[str]:
+ """Discover which GCP services are in use in the project.
+
+ Queries the Service Usage API for enabled APIs.
+ Best-effort; returns an empty list on failure.
+ """
+ services: list[str] = []
+ try:
+ su = self.service("serviceusage", "v1")
+ name = f"projects/{self.project_id}"
+ page_token = None
+ while True:
+ kwargs: dict[str, Any] = {"parent": name, "filter": "state:ENABLED"}
+ if page_token:
+ kwargs["pageToken"] = page_token
+ response = su.services().list(**kwargs).execute()
+ for svc in response.get("services", []):
+ svc_name = svc.get("name", "").split("/")[-1]
+ services.append(svc_name)
+ page_token = response.get("nextPageToken")
+ if not page_token:
+ break
+
+ if self._account_info:
+ self._account_info.services_in_use = services
+ except Exception:
+ pass
+ return services
+
+ def to_dict(self) -> dict[str, Any]:
+ """Serialize connection info for reporting."""
+ if not self._account_info:
+ return {"status": "not_connected"}
+ return {
+ "project_id": self._account_info.project_id,
+ "project_name": self._account_info.project_name,
+ "principal": self._account_info.principal,
+ "region": self._account_info.region,
+ "services_in_use": self._account_info.services_in_use,
+ }
diff --git a/src/shasta/gcp/cloud_run.py b/src/shasta/gcp/cloud_run.py
new file mode 100644
index 0000000..5c51214
--- /dev/null
+++ b/src/shasta/gcp/cloud_run.py
@@ -0,0 +1,556 @@
+"""GCP Cloud Run security checks for SOC 2 and CIS GCP Benchmark v2.0.
+
+Covers:
+ CC6.1 — Authentication and access control (unauthenticated invocation)
+ CC6.2 — Service account least privilege
+ CC6.6 — System boundaries (ingress controls, binary authorization)
+ CC6.7 — Data protection (secrets management, environment variables)
+
+Cloud Run v2 API is used via ``client.service("run", "v2")``.
+Each service is inspected per region (Cloud Run is regional).
+Engineering Principle #3: regional checks iterate get_enabled_regions().
+"""
+
+from __future__ import annotations
+
+import re
+from typing import Any
+
+from shasta.gcp.client import GCPClient
+from shasta.evidence.models import (
+ CheckDomain,
+ CloudProvider,
+ ComplianceStatus,
+ Finding,
+ Severity,
+)
+
+IS_GLOBAL = False # Cloud Run services are regional
+
+# Ingress settings that restrict access — anything other than "INGRESS_TRAFFIC_ALL"
+_RESTRICTED_INGRESS = {
+ "INGRESS_TRAFFIC_INTERNAL_ONLY",
+ "INGRESS_TRAFFIC_INTERNAL_LOAD_BALANCER",
+}
+
+# Default Compute Engine SA — should not be used by Cloud Run services
+_DEFAULT_COMPUTE_SA_PATTERN = re.compile(r"^\d+-compute@developer\.gserviceaccount\.com$")
+
+# Environment variable name patterns that suggest embedded secrets
+_SECRET_PATTERNS = re.compile(
+ r"(password|passwd|secret|api_?key|auth_?token|private_?key|credential|access_?token)",
+ re.IGNORECASE,
+)
+
+
+def run_all_gcp_cloud_run_checks(client: GCPClient) -> list[Finding]:
+ """Run all Cloud Run compliance checks across all enabled regions."""
+ project_id = client.project_id if client.account_info else (client._project_id or "unknown")
+
+ findings: list[Finding] = []
+ for region in client.get_enabled_regions():
+ regional_client = client.for_region(region)
+ findings.extend(check_cloud_run_no_unauthenticated_access(regional_client, project_id, region))
+ findings.extend(check_cloud_run_no_default_service_account(regional_client, project_id, region))
+ findings.extend(check_cloud_run_ingress_restricted(regional_client, project_id, region))
+ findings.extend(check_cloud_run_binary_authorization(regional_client, project_id, region))
+ findings.extend(check_cloud_run_no_plaintext_secrets(regional_client, project_id, region))
+
+ return findings
+
+
+def _list_cloud_run_services(client: GCPClient, project_id: str, region: str) -> list[dict[str, Any]]:
+ """Return all Cloud Run v2 services in the given region."""
+ run = client.service("run", "v2")
+ parent = f"projects/{project_id}/locations/{region}"
+ response = run.projects().locations().services().list(parent=parent).execute()
+ services = response.get("services", [])
+
+ # Paginate
+ next_page = response.get("nextPageToken")
+ while next_page:
+ response = (
+ run.projects()
+ .locations()
+ .services()
+ .list(parent=parent, pageToken=next_page)
+ .execute()
+ )
+ services.extend(response.get("services", []))
+ next_page = response.get("nextPageToken")
+
+ return services
+
+
+def check_cloud_run_no_unauthenticated_access(
+ client: GCPClient, project_id: str, region: str
+) -> list[Finding]:
+ """Check that Cloud Run services do not allow unauthenticated invocations.
+
+ A Cloud Run service is publicly invocable without any credentials when its
+ IAM policy grants ``roles/run.invoker`` to ``allUsers`` or
+ ``allAuthenticatedUsers``. Unless the service is intentionally public
+ (e.g. a static site), this exposes it to SSRF, data exfiltration, and
+ compute abuse.
+ """
+ try:
+ services = _list_cloud_run_services(client, project_id, region)
+ except Exception as e:
+ return [
+ Finding.not_assessed(
+ check_id="gcp-cloudrun-no-unauth-access",
+ title=f"Unable to list Cloud Run services in {region}",
+ description=f"API call failed: {e}",
+ domain=CheckDomain.COMPUTE,
+ resource_type="GCP::Run::Service",
+ account_id=project_id,
+ region=region,
+ cloud_provider=CloudProvider.GCP,
+ )
+ ]
+
+ if not services:
+ return []
+
+ run = client.service("run", "v2")
+ offenders: list[dict[str, str]] = []
+
+ for svc in services:
+ svc_name = svc.get("name", "")
+ try:
+ policy = (
+ run.projects()
+ .locations()
+ .services()
+ .getIamPolicy(resource=svc_name)
+ .execute()
+ )
+ for binding in policy.get("bindings", []):
+ members = binding.get("members", [])
+ if "allUsers" in members or "allAuthenticatedUsers" in members:
+ offenders.append(
+ {
+ "service": svc_name.split("/")[-1],
+ "role": binding.get("role", ""),
+ }
+ )
+ break
+ except Exception:
+ continue
+
+ if not offenders:
+ return [
+ Finding(
+ check_id="gcp-cloudrun-no-unauth-access",
+ title=f"No Cloud Run services in {region} allow unauthenticated access",
+ description=f"All {len(services)} Cloud Run service(s) in {region} require authentication.",
+ severity=Severity.INFO,
+ status=ComplianceStatus.PASS,
+ domain=CheckDomain.COMPUTE,
+ resource_type="GCP::Run::Service",
+ resource_id=f"projects/{project_id}/locations/{region}/services",
+ region=region,
+ account_id=project_id,
+ cloud_provider=CloudProvider.GCP,
+ soc2_controls=["CC6.1", "CC6.6"],
+ cis_gcp_controls=["1.2"],
+ )
+ ]
+
+ return [
+ Finding(
+ check_id="gcp-cloudrun-no-unauth-access",
+ title=f"{len(offenders)} Cloud Run service(s) in {region} allow unauthenticated invocation",
+ description=(
+ f"{len(offenders)} Cloud Run service(s) in {region} grant "
+ "roles/run.invoker to allUsers or allAuthenticatedUsers. These services "
+ "are callable from the public internet without any credentials."
+ ),
+ severity=Severity.HIGH,
+ status=ComplianceStatus.FAIL,
+ domain=CheckDomain.COMPUTE,
+ resource_type="GCP::Run::Service",
+ resource_id=f"projects/{project_id}/locations/{region}/services",
+ region=region,
+ account_id=project_id,
+ cloud_provider=CloudProvider.GCP,
+ remediation=(
+ "Remove the allUsers IAM binding: "
+ "`gcloud run services remove-iam-policy-binding SERVICE "
+ "--region=REGION --member=allUsers --role=roles/run.invoker`. "
+ "Enable Cloud IAP or require an OIDC token for service-to-service calls."
+ ),
+ soc2_controls=["CC6.1", "CC6.6"],
+ cis_gcp_controls=["1.2"],
+ iso27001_controls=["A.9.1.2"],
+ details={"services_with_public_access": offenders[:20]},
+ )
+ ]
+
+
+def check_cloud_run_no_default_service_account(
+ client: GCPClient, project_id: str, region: str
+) -> list[Finding]:
+ """Check that Cloud Run services do not use the default Compute Engine service account.
+
+ The default Compute Engine SA (PROJECT_NUMBER-compute@developer.gserviceaccount.com)
+ has the Editor role by default — far too broad for a Cloud Run service. Services
+ should use a dedicated, narrowly scoped SA.
+ """
+ try:
+ services = _list_cloud_run_services(client, project_id, region)
+ except Exception as e:
+ return [
+ Finding.not_assessed(
+ check_id="gcp-cloudrun-no-default-sa",
+ title=f"Unable to list Cloud Run services in {region}",
+ description=f"API call failed: {e}",
+ domain=CheckDomain.COMPUTE,
+ resource_type="GCP::Run::Service",
+ account_id=project_id,
+ region=region,
+ cloud_provider=CloudProvider.GCP,
+ )
+ ]
+
+ if not services:
+ return []
+
+ offenders: list[dict[str, str]] = []
+ for svc in services:
+ template = svc.get("template", {})
+ sa = template.get("serviceAccount", "")
+ is_default_sa = not sa or bool(_DEFAULT_COMPUTE_SA_PATTERN.search(sa))
+ if is_default_sa:
+ offenders.append(
+ {
+ "service": svc.get("name", "").split("/")[-1],
+ "service_account": sa or "(none — uses project default)",
+ }
+ )
+
+ if not offenders:
+ return [
+ Finding(
+ check_id="gcp-cloudrun-no-default-sa",
+ title=f"All Cloud Run services in {region} use dedicated service accounts",
+ description=f"No Cloud Run services in {region} use the default Compute Engine service account.",
+ severity=Severity.INFO,
+ status=ComplianceStatus.PASS,
+ domain=CheckDomain.COMPUTE,
+ resource_type="GCP::Run::Service",
+ resource_id=f"projects/{project_id}/locations/{region}/services",
+ region=region,
+ account_id=project_id,
+ cloud_provider=CloudProvider.GCP,
+ soc2_controls=["CC6.2"],
+ cis_gcp_controls=["1.5"],
+ )
+ ]
+
+ return [
+ Finding(
+ check_id="gcp-cloudrun-no-default-sa",
+ title=f"{len(offenders)} Cloud Run service(s) in {region} use the default service account",
+ description=(
+ f"{len(offenders)} Cloud Run service(s) in {region} use the default "
+ "Compute Engine service account, which carries the broad Editor role by default. "
+ "A compromised service gains project-wide write access."
+ ),
+ severity=Severity.HIGH,
+ status=ComplianceStatus.FAIL,
+ domain=CheckDomain.COMPUTE,
+ resource_type="GCP::Run::Service",
+ resource_id=f"projects/{project_id}/locations/{region}/services",
+ region=region,
+ account_id=project_id,
+ cloud_provider=CloudProvider.GCP,
+ remediation=(
+ "Create a dedicated SA with only the required roles and set it on the service: "
+ "`gcloud run services update SERVICE --service-account=SA_EMAIL --region=REGION`."
+ ),
+ soc2_controls=["CC6.2"],
+ cis_gcp_controls=["1.5"],
+ iso27001_controls=["A.9.1.2", "A.8.2"],
+ details={"services_using_default_sa": offenders[:20]},
+ )
+ ]
+
+
+def check_cloud_run_ingress_restricted(
+ client: GCPClient, project_id: str, region: str
+) -> list[Finding]:
+ """Check that Cloud Run services restrict ingress to internal or load-balancer traffic.
+
+ ``INGRESS_TRAFFIC_ALL`` (the default) allows the service to be reached by
+ direct public URL without going through a load balancer, bypassing WAF,
+ Cloud Armor, and CDN policies. Internal-only or load-balancer-only ingress
+ forces all traffic through the controlled entry point.
+ """
+ try:
+ services = _list_cloud_run_services(client, project_id, region)
+ except Exception as e:
+ return [
+ Finding.not_assessed(
+ check_id="gcp-cloudrun-ingress-restricted",
+ title=f"Unable to list Cloud Run services in {region}",
+ description=f"API call failed: {e}",
+ domain=CheckDomain.NETWORKING,
+ resource_type="GCP::Run::Service",
+ account_id=project_id,
+ region=region,
+ cloud_provider=CloudProvider.GCP,
+ )
+ ]
+
+ if not services:
+ return []
+
+ offenders: list[dict[str, str]] = []
+ for svc in services:
+ ingress = svc.get("ingress", "INGRESS_TRAFFIC_ALL")
+ if ingress not in _RESTRICTED_INGRESS:
+ offenders.append(
+ {
+ "service": svc.get("name", "").split("/")[-1],
+ "ingress": ingress,
+ }
+ )
+
+ if not offenders:
+ return [
+ Finding(
+ check_id="gcp-cloudrun-ingress-restricted",
+ title=f"All Cloud Run services in {region} restrict ingress traffic",
+ description=f"All {len(services)} service(s) in {region} use internal or load-balancer-only ingress.",
+ severity=Severity.INFO,
+ status=ComplianceStatus.PASS,
+ domain=CheckDomain.NETWORKING,
+ resource_type="GCP::Run::Service",
+ resource_id=f"projects/{project_id}/locations/{region}/services",
+ region=region,
+ account_id=project_id,
+ cloud_provider=CloudProvider.GCP,
+ soc2_controls=["CC6.6"],
+ cis_gcp_controls=["3.1"],
+ )
+ ]
+
+ return [
+ Finding(
+ check_id="gcp-cloudrun-ingress-restricted",
+ title=f"{len(offenders)} Cloud Run service(s) in {region} accept all ingress traffic",
+ description=(
+ f"{len(offenders)} Cloud Run service(s) in {region} have "
+ "INGRESS_TRAFFIC_ALL — they can be reached directly via the "
+ "*.run.app URL, bypassing any Cloud Armor WAF or load balancer policy."
+ ),
+ severity=Severity.MEDIUM,
+ status=ComplianceStatus.FAIL,
+ domain=CheckDomain.NETWORKING,
+ resource_type="GCP::Run::Service",
+ resource_id=f"projects/{project_id}/locations/{region}/services",
+ region=region,
+ account_id=project_id,
+ cloud_provider=CloudProvider.GCP,
+ remediation=(
+ "Restrict ingress: "
+ "`gcloud run services update SERVICE --ingress=internal-and-cloud-load-balancing "
+ "--region=REGION`. For internal microservices use `--ingress=internal`."
+ ),
+ soc2_controls=["CC6.6"],
+ cis_gcp_controls=["3.1"],
+ iso27001_controls=["A.13.1.1"],
+ details={"services_with_open_ingress": offenders[:20]},
+ )
+ ]
+
+
+def check_cloud_run_binary_authorization(
+ client: GCPClient, project_id: str, region: str
+) -> list[Finding]:
+ """Check that Cloud Run services have Binary Authorization enforced.
+
+ Binary Authorization verifies the container image's provenance at deploy time,
+ ensuring only trusted images built by your CI/CD pipeline can run. Without it,
+ any container image (including attacker-substituted ones) can be deployed.
+ """
+ try:
+ services = _list_cloud_run_services(client, project_id, region)
+ except Exception as e:
+ return [
+ Finding.not_assessed(
+ check_id="gcp-cloudrun-binary-authorization",
+ title=f"Unable to list Cloud Run services in {region}",
+ description=f"API call failed: {e}",
+ domain=CheckDomain.COMPUTE,
+ resource_type="GCP::Run::Service",
+ account_id=project_id,
+ region=region,
+ cloud_provider=CloudProvider.GCP,
+ )
+ ]
+
+ if not services:
+ return []
+
+ missing_binauthz: list[str] = []
+ for svc in services:
+ binauthz = svc.get("binaryAuthorization", {})
+ # useDefault=True means "use the project-level policy" — acceptable
+ # policy=non-empty means a custom policy is set — acceptable
+ use_default = binauthz.get("useDefault", False)
+ policy = binauthz.get("policy", "")
+ if not use_default and not policy:
+ missing_binauthz.append(svc.get("name", "").split("/")[-1])
+
+ if not missing_binauthz:
+ return [
+ Finding(
+ check_id="gcp-cloudrun-binary-authorization",
+ title=f"All Cloud Run services in {region} have Binary Authorization configured",
+ description=f"All {len(services)} service(s) in {region} use Binary Authorization.",
+ severity=Severity.INFO,
+ status=ComplianceStatus.PASS,
+ domain=CheckDomain.COMPUTE,
+ resource_type="GCP::Run::Service",
+ resource_id=f"projects/{project_id}/locations/{region}/services",
+ region=region,
+ account_id=project_id,
+ cloud_provider=CloudProvider.GCP,
+ soc2_controls=["CC6.6"],
+ cis_gcp_controls=["5.7"],
+ )
+ ]
+
+ return [
+ Finding(
+ check_id="gcp-cloudrun-binary-authorization",
+ title=f"{len(missing_binauthz)} Cloud Run service(s) in {region} lack Binary Authorization",
+ description=(
+ f"{len(missing_binauthz)} Cloud Run service(s) in {region} do not have "
+ "Binary Authorization enabled. Without it, any container image can be deployed "
+ "without provenance verification, enabling supply-chain attacks."
+ ),
+ severity=Severity.MEDIUM,
+ status=ComplianceStatus.FAIL,
+ domain=CheckDomain.COMPUTE,
+ resource_type="GCP::Run::Service",
+ resource_id=f"projects/{project_id}/locations/{region}/services",
+ region=region,
+ account_id=project_id,
+ cloud_provider=CloudProvider.GCP,
+ remediation=(
+ "Enable Binary Authorization: "
+ "`gcloud run services update SERVICE --binary-authorization=default "
+ "--region=REGION`. Set up a project policy that requires attestation "
+ "from your CI/CD pipeline."
+ ),
+ soc2_controls=["CC6.6"],
+ cis_gcp_controls=["5.7"],
+ iso27001_controls=["A.12.5.1"],
+ details={"services_without_binauthz": missing_binauthz[:20]},
+ )
+ ]
+
+
+def check_cloud_run_no_plaintext_secrets(
+ client: GCPClient, project_id: str, region: str
+) -> list[Finding]:
+ """Check that Cloud Run services do not embed secrets in environment variables.
+
+ Environment variables on Cloud Run services appear in plain text in the API
+ response, the Cloud Console, and Cloud Audit Logs. Secrets (API keys, passwords,
+ tokens) should be stored in Secret Manager and mounted as volumes or env vars
+ via the secretKeyRef mechanism — not hardcoded in the service spec.
+ """
+ try:
+ services = _list_cloud_run_services(client, project_id, region)
+ except Exception as e:
+ return [
+ Finding.not_assessed(
+ check_id="gcp-cloudrun-no-plaintext-secrets",
+ title=f"Unable to list Cloud Run services in {region}",
+ description=f"API call failed: {e}",
+ domain=CheckDomain.ENCRYPTION,
+ resource_type="GCP::Run::Service",
+ account_id=project_id,
+ region=region,
+ cloud_provider=CloudProvider.GCP,
+ )
+ ]
+
+ if not services:
+ return []
+
+ suspicious: list[dict[str, str]] = []
+ for svc in services:
+ svc_short_name = svc.get("name", "").split("/")[-1]
+ template = svc.get("template", {})
+ containers = template.get("containers", [])
+ for container in containers:
+ for env in container.get("env", []):
+ name = env.get("name", "")
+ # Flag if the env var name suggests a secret AND it has a literal value
+ # (not a secretKeyRef — those have "valueSource" instead of "value")
+ has_literal_value = "value" in env and "valueSource" not in env
+ if has_literal_value and _SECRET_PATTERNS.search(name):
+ suspicious.append(
+ {
+ "service": svc_short_name,
+ "env_var": name,
+ }
+ )
+ break
+
+ if not suspicious:
+ return [
+ Finding(
+ check_id="gcp-cloudrun-no-plaintext-secrets",
+ title=f"No Cloud Run services in {region} have suspicious plaintext env vars",
+ description=(
+ f"No Cloud Run services in {region} have environment variables "
+ "whose names suggest embedded secrets."
+ ),
+ severity=Severity.INFO,
+ status=ComplianceStatus.PASS,
+ domain=CheckDomain.ENCRYPTION,
+ resource_type="GCP::Run::Service",
+ resource_id=f"projects/{project_id}/locations/{region}/services",
+ region=region,
+ account_id=project_id,
+ cloud_provider=CloudProvider.GCP,
+ soc2_controls=["CC6.7"],
+ cis_gcp_controls=["5.3"],
+ )
+ ]
+
+ return [
+ Finding(
+ check_id="gcp-cloudrun-no-plaintext-secrets",
+ title=f"{len(suspicious)} Cloud Run service(s) in {region} may embed secrets in env vars",
+ description=(
+ f"{len(suspicious)} Cloud Run service(s) in {region} have environment "
+ "variables whose names suggest secrets (password, api_key, token, etc.) "
+ "with literal string values rather than Secret Manager references. "
+ "These values appear in plain text in the API response and audit logs."
+ ),
+ severity=Severity.HIGH,
+ status=ComplianceStatus.FAIL,
+ domain=CheckDomain.ENCRYPTION,
+ resource_type="GCP::Run::Service",
+ resource_id=f"projects/{project_id}/locations/{region}/services",
+ region=region,
+ account_id=project_id,
+ cloud_provider=CloudProvider.GCP,
+ remediation=(
+ "Store secrets in Secret Manager and reference them via secretKeyRef: "
+ "`gcloud run services update SERVICE --update-secrets=ENV_VAR=SECRET_NAME:latest "
+ "--region=REGION`. Remove the plaintext env var after migrating."
+ ),
+ soc2_controls=["CC6.7"],
+ cis_gcp_controls=["5.3"],
+ iso27001_controls=["A.10.1.1", "A.13.2.1"],
+ details={"suspicious_env_vars": suspicious[:20]},
+ )
+ ]
diff --git a/src/shasta/gcp/compute.py b/src/shasta/gcp/compute.py
new file mode 100644
index 0000000..c74b7d1
--- /dev/null
+++ b/src/shasta/gcp/compute.py
@@ -0,0 +1,923 @@
+"""GCP Compute Engine and GKE security checks for SOC 2 and CIS GCP Benchmark v2.0.
+
+Covers:
+ CC6.6 — System boundaries (compute instance hardening, GKE security)
+
+CIS GCP v2.0 Section 4 (Compute Engine) and CIS GKE Benchmark.
+Regional resources (instances, GKE clusters) use get_enabled_regions() + for_region()
+per Engineering Principle #3.
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+from shasta.gcp.client import GCPClient
+from shasta.evidence.models import (
+ CheckDomain,
+ CloudProvider,
+ ComplianceStatus,
+ Finding,
+ Severity,
+)
+
+IS_GLOBAL = False # Compute instance and GKE cluster checks are per-region
+
+
+def run_all_gcp_compute_checks(client: GCPClient) -> list[Finding]:
+ """Run all GCP Compute Engine and GKE compliance checks.
+
+ Project-level metadata checks run once.
+ Per-region instance and GKE cluster checks iterate enabled regions.
+ """
+ project_id = client.project_id if client.account_info else (client._project_id or "unknown")
+
+ findings: list[Finding] = []
+
+ # Project-level checks (not per-region)
+ findings.extend(check_os_login_project_enabled(client, project_id))
+ findings.extend(check_serial_port_disabled_project(client, project_id))
+
+ # Regional checks
+ for region in client.get_enabled_regions():
+ regional_client = client.for_region(region)
+ findings.extend(check_instance_no_external_ip(regional_client, project_id, region))
+ findings.extend(check_instance_no_default_service_account_full_scope(regional_client, project_id, region))
+ findings.extend(check_instance_shielded_vm(regional_client, project_id, region))
+ findings.extend(check_gke_private_cluster(regional_client, project_id, region))
+ findings.extend(check_gke_workload_identity(regional_client, project_id, region))
+ findings.extend(check_gke_network_policy(regional_client, project_id, region))
+ findings.extend(check_gke_node_auto_upgrade(regional_client, project_id, region))
+
+ return findings
+
+
+def check_os_login_project_enabled(
+ client: GCPClient, project_id: str
+) -> list[Finding]:
+ """[CIS 4.4] OS Login should be enabled at the project level.
+
+ OS Login ties SSH access to Google Account credentials and supports
+ IAM-based authorization for SSH. Without it, instance SSH access is controlled
+ by manually managed SSH keys stored in metadata — easy to misconfigure and
+ hard to revoke project-wide.
+ """
+ region = "global"
+ try:
+ compute = client.service("compute", "v1")
+ project = compute.projects().get(project=project_id).execute()
+ metadata = project.get("commonInstanceMetadata", {})
+ items = metadata.get("items", [])
+ except Exception as e:
+ return [
+ Finding.not_assessed(
+ check_id="gcp-compute-os-login",
+ title="Unable to read project metadata for OS Login check",
+ description=f"API call failed: {e}",
+ domain=CheckDomain.COMPUTE,
+ resource_type="GCP::Compute::Project",
+ account_id=project_id,
+ region=region,
+ cloud_provider=CloudProvider.GCP,
+ )
+ ]
+
+ os_login_enabled = any(
+ item.get("key") == "enable-oslogin" and item.get("value", "").lower() == "true"
+ for item in items
+ )
+
+ if os_login_enabled:
+ return [
+ Finding(
+ check_id="gcp-compute-os-login",
+ title="OS Login is enabled at the project level",
+ description=(
+ "Project metadata has `enable-oslogin=true`. All instances inherit OS Login "
+ "unless overridden at the instance level."
+ ),
+ severity=Severity.INFO,
+ status=ComplianceStatus.PASS,
+ domain=CheckDomain.COMPUTE,
+ resource_type="GCP::Compute::Project",
+ resource_id=f"projects/{project_id}",
+ region=region,
+ account_id=project_id,
+ cloud_provider=CloudProvider.GCP,
+ soc2_controls=["CC6.1"],
+ cis_gcp_controls=["4.4"],
+ )
+ ]
+
+ return [
+ Finding(
+ check_id="gcp-compute-os-login",
+ title="OS Login is NOT enabled at the project level",
+ description=(
+ "Project metadata does not set `enable-oslogin=true`. SSH access to instances "
+ "is controlled by manually managed public keys in metadata, which are hard to "
+ "audit and revoke. OS Login provides IAM-controlled SSH with audit trails."
+ ),
+ severity=Severity.MEDIUM,
+ status=ComplianceStatus.FAIL,
+ domain=CheckDomain.COMPUTE,
+ resource_type="GCP::Compute::Project",
+ resource_id=f"projects/{project_id}",
+ region=region,
+ account_id=project_id,
+ cloud_provider=CloudProvider.GCP,
+ remediation=(
+ "Enable OS Login: `gcloud compute project-info add-metadata "
+ f"--metadata=enable-oslogin=TRUE --project={project_id}`. "
+ "Grant users `roles/compute.osLogin` (non-admin) or "
+ "`roles/compute.osAdminLogin` (with sudo) to control access."
+ ),
+ soc2_controls=["CC6.1"],
+ cis_gcp_controls=["4.4"],
+ iso27001_controls=["A.8.5"],
+ )
+ ]
+
+
+def check_serial_port_disabled_project(
+ client: GCPClient, project_id: str
+) -> list[Finding]:
+ """[CIS 4.5] Serial port access should be disabled at the project level.
+
+ The serial console provides interactive access to a VM without SSH and bypasses
+ firewall rules. It should be disabled at the project level to prevent abuse.
+ """
+ region = "global"
+ try:
+ compute = client.service("compute", "v1")
+ project = compute.projects().get(project=project_id).execute()
+ metadata = project.get("commonInstanceMetadata", {})
+ items = metadata.get("items", [])
+ except Exception as e:
+ return [
+ Finding.not_assessed(
+ check_id="gcp-compute-serial-port",
+ title="Unable to read project metadata for serial port check",
+ description=f"API call failed: {e}",
+ domain=CheckDomain.COMPUTE,
+ resource_type="GCP::Compute::Project",
+ account_id=project_id,
+ region=region,
+ cloud_provider=CloudProvider.GCP,
+ )
+ ]
+
+ serial_enabled = any(
+ item.get("key") == "serial-port-enable" and item.get("value", "").lower() in ("true", "1")
+ for item in items
+ )
+
+ if not serial_enabled:
+ return [
+ Finding(
+ check_id="gcp-compute-serial-port",
+ title="Serial port access is disabled at the project level",
+ description="Project metadata does not enable serial port access. Instances cannot access the interactive serial console.",
+ severity=Severity.INFO,
+ status=ComplianceStatus.PASS,
+ domain=CheckDomain.COMPUTE,
+ resource_type="GCP::Compute::Project",
+ resource_id=f"projects/{project_id}",
+ region=region,
+ account_id=project_id,
+ cloud_provider=CloudProvider.GCP,
+ soc2_controls=["CC6.6"],
+ cis_gcp_controls=["4.5"],
+ )
+ ]
+
+ return [
+ Finding(
+ check_id="gcp-compute-serial-port",
+ title="Serial port access is ENABLED at the project level",
+ description=(
+ "Project metadata sets `serial-port-enable=true`. All instances allow "
+ "serial console access by default, bypassing SSH and firewall controls."
+ ),
+ severity=Severity.HIGH,
+ status=ComplianceStatus.FAIL,
+ domain=CheckDomain.COMPUTE,
+ resource_type="GCP::Compute::Project",
+ resource_id=f"projects/{project_id}",
+ region=region,
+ account_id=project_id,
+ cloud_provider=CloudProvider.GCP,
+ remediation=(
+ "Disable serial port access: `gcloud compute project-info add-metadata "
+ f"--metadata=serial-port-enable=false --project={project_id}`. "
+ "Also disable it per-instance if it's enabled at the instance level."
+ ),
+ soc2_controls=["CC6.6"],
+ cis_gcp_controls=["4.5"],
+ iso27001_controls=["A.8.9"],
+ )
+ ]
+
+
+def check_instance_no_external_ip(
+ client: GCPClient, project_id: str, region: str
+) -> list[Finding]:
+ """[CIS 4.6] Compute Engine instances should not have external IP addresses.
+
+ Instances with public IPs are directly reachable from the internet. Use
+ Cloud NAT for outbound traffic and IAP for inbound administrative access
+ to eliminate the need for public IPs on most workloads.
+ """
+ try:
+ compute = client.service("compute", "v1")
+ response = (
+ compute.instances()
+ .aggregatedList(
+ project=project_id,
+ filter=f"region eq .*/regions/{region}$",
+ )
+ .execute()
+ )
+ items = response.get("items", {})
+ instances = []
+ for zone_data in items.values():
+ instances.extend(zone_data.get("instances", []))
+ except Exception as e:
+ return [
+ Finding.not_assessed(
+ check_id="gcp-compute-no-external-ip",
+ title=f"Unable to list compute instances in {region}",
+ description=f"API call failed: {e}",
+ domain=CheckDomain.COMPUTE,
+ resource_type="GCP::Compute::Instance",
+ account_id=project_id,
+ region=region,
+ cloud_provider=CloudProvider.GCP,
+ )
+ ]
+
+ if not instances:
+ return []
+
+ with_external: list[dict[str, str]] = []
+ for inst in instances:
+ if inst.get("status") not in ("RUNNING", "STAGING"):
+ continue
+ for nic in inst.get("networkInterfaces", []):
+ access_configs = nic.get("accessConfigs", [])
+ has_external = any(ac.get("natIP") for ac in access_configs)
+ if has_external:
+ with_external.append(
+ {
+ "instance": inst.get("name", ""),
+ "zone": inst.get("zone", "").split("/")[-1],
+ "external_ip": next(
+ (ac.get("natIP") for ac in access_configs if ac.get("natIP")), ""
+ ),
+ }
+ )
+ break
+
+ if not with_external:
+ return [
+ Finding(
+ check_id="gcp-compute-no-external-ip",
+ title=f"No instances in {region} have external IP addresses",
+ description=f"All running instances in {region} use only private IP addresses.",
+ severity=Severity.INFO,
+ status=ComplianceStatus.PASS,
+ domain=CheckDomain.COMPUTE,
+ resource_type="GCP::Compute::Instance",
+ resource_id=f"projects/{project_id}/regions/{region}/instances",
+ region=region,
+ account_id=project_id,
+ cloud_provider=CloudProvider.GCP,
+ soc2_controls=["CC6.6"],
+ cis_gcp_controls=["4.6"],
+ )
+ ]
+
+ return [
+ Finding(
+ check_id="gcp-compute-no-external-ip",
+ title=f"{len(with_external)} instance(s) in {region} have external IP addresses",
+ description=(
+ f"{len(with_external)} running instance(s) in {region} have public IP addresses. "
+ "These instances are directly reachable from the internet."
+ ),
+ severity=Severity.MEDIUM,
+ status=ComplianceStatus.FAIL,
+ domain=CheckDomain.COMPUTE,
+ resource_type="GCP::Compute::Instance",
+ resource_id=f"projects/{project_id}/regions/{region}/instances",
+ region=region,
+ account_id=project_id,
+ cloud_provider=CloudProvider.GCP,
+ remediation=(
+ "Remove external IPs: `gcloud compute instances delete-access-config INSTANCE_NAME "
+ f"--access-config-name='External NAT' --zone=ZONE --project={project_id}`. "
+ "Configure Cloud NAT for outbound internet access and IAP for SSH/RDP."
+ ),
+ soc2_controls=["CC6.6"],
+ cis_gcp_controls=["4.6"],
+ iso27001_controls=["A.8.20"],
+ details={"instances_with_external_ip": with_external[:20]},
+ )
+ ]
+
+
+def check_instance_no_default_service_account_full_scope(
+ client: GCPClient, project_id: str, region: str
+) -> list[Finding]:
+ """[CIS 4.1] Instances should not use the default service account with full API scope.
+
+ The default compute service account has the `editor` role, and running instances
+ with it + the `cloud-platform` (full) scope grants the instance full project access.
+ This violates least privilege and means any code running on the instance has
+ full GCP project control.
+ """
+ FULL_SCOPE = "https://www.googleapis.com/auth/cloud-platform"
+ DEFAULT_SA_SUFFIX = "-compute@developer.gserviceaccount.com"
+
+ try:
+ compute = client.service("compute", "v1")
+ response = (
+ compute.instances()
+ .aggregatedList(
+ project=project_id,
+ filter=f"region eq .*/regions/{region}$",
+ )
+ .execute()
+ )
+ items = response.get("items", {})
+ instances = []
+ for zone_data in items.values():
+ instances.extend(zone_data.get("instances", []))
+ except Exception as e:
+ return [
+ Finding.not_assessed(
+ check_id="gcp-compute-default-sa-full-scope",
+ title=f"Unable to list compute instances in {region} for SA scope check",
+ description=f"API call failed: {e}",
+ domain=CheckDomain.COMPUTE,
+ resource_type="GCP::Compute::Instance",
+ account_id=project_id,
+ region=region,
+ cloud_provider=CloudProvider.GCP,
+ )
+ ]
+
+ if not instances:
+ return []
+
+ offenders: list[dict[str, str]] = []
+ for inst in instances:
+ if inst.get("status") not in ("RUNNING", "STAGING"):
+ continue
+ for sa in inst.get("serviceAccounts", []):
+ email = sa.get("email", "")
+ scopes = sa.get("scopes", [])
+ is_default = email.endswith(DEFAULT_SA_SUFFIX)
+ has_full_scope = FULL_SCOPE in scopes
+ if is_default and has_full_scope:
+ offenders.append(
+ {
+ "instance": inst.get("name", ""),
+ "zone": inst.get("zone", "").split("/")[-1],
+ "service_account": email,
+ }
+ )
+
+ if not offenders:
+ return [
+ Finding(
+ check_id="gcp-compute-default-sa-full-scope",
+ title=f"No instances in {region} use default SA with full cloud-platform scope",
+ description=f"No running instances in {region} combine the default compute SA with full API scope.",
+ severity=Severity.INFO,
+ status=ComplianceStatus.PASS,
+ domain=CheckDomain.COMPUTE,
+ resource_type="GCP::Compute::Instance",
+ resource_id=f"projects/{project_id}/regions/{region}/instances",
+ region=region,
+ account_id=project_id,
+ cloud_provider=CloudProvider.GCP,
+ soc2_controls=["CC6.1", "CC6.2"],
+ cis_gcp_controls=["4.1"],
+ )
+ ]
+
+ return [
+ Finding(
+ check_id="gcp-compute-default-sa-full-scope",
+ title=f"{len(offenders)} instance(s) in {region} use default SA with full API scope",
+ description=(
+ f"{len(offenders)} instance(s) in {region} run with the default compute "
+ "service account and the `cloud-platform` scope. Any code running on these "
+ "instances effectively has full GCP project access."
+ ),
+ severity=Severity.HIGH,
+ status=ComplianceStatus.FAIL,
+ domain=CheckDomain.COMPUTE,
+ resource_type="GCP::Compute::Instance",
+ resource_id=f"projects/{project_id}/regions/{region}/instances",
+ region=region,
+ account_id=project_id,
+ cloud_provider=CloudProvider.GCP,
+ remediation=(
+ "Create a purpose-specific service account with minimal IAM roles, "
+ "and update the instance: `gcloud compute instances set-service-account "
+ "INSTANCE_NAME --service-account=CUSTOM_SA_EMAIL "
+ "--scopes=cloud-platform --zone=ZONE`. "
+ "Remove the default SA's editor role from the project."
+ ),
+ soc2_controls=["CC6.1", "CC6.2"],
+ cis_gcp_controls=["4.1"],
+ iso27001_controls=["A.5.15"],
+ details={"offending_instances": offenders[:20]},
+ )
+ ]
+
+
+def check_instance_shielded_vm(
+ client: GCPClient, project_id: str, region: str
+) -> list[Finding]:
+ """[CIS 4.8] Compute instances should have Shielded VM enabled.
+
+ Shielded VM provides verifiable integrity of Compute Engine VM instances using
+ Secure Boot, vTPM, and Integrity Monitoring. This hardens against boot-level
+ malware and rootkits.
+ """
+ try:
+ compute = client.service("compute", "v1")
+ response = (
+ compute.instances()
+ .aggregatedList(
+ project=project_id,
+ filter=f"region eq .*/regions/{region}$",
+ )
+ .execute()
+ )
+ items = response.get("items", {})
+ instances = []
+ for zone_data in items.values():
+ instances.extend(zone_data.get("instances", []))
+ except Exception as e:
+ return [
+ Finding.not_assessed(
+ check_id="gcp-compute-shielded-vm",
+ title=f"Unable to list compute instances in {region} for Shielded VM check",
+ description=f"API call failed: {e}",
+ domain=CheckDomain.COMPUTE,
+ resource_type="GCP::Compute::Instance",
+ account_id=project_id,
+ region=region,
+ cloud_provider=CloudProvider.GCP,
+ )
+ ]
+
+ if not instances:
+ return []
+
+ not_shielded: list[dict[str, str]] = []
+ for inst in instances:
+ if inst.get("status") not in ("RUNNING", "STAGING"):
+ continue
+ shielded = inst.get("shieldedInstanceConfig", {})
+ if not shielded.get("enableSecureBoot") or not shielded.get("enableVtpm"):
+ not_shielded.append(
+ {
+ "instance": inst.get("name", ""),
+ "zone": inst.get("zone", "").split("/")[-1],
+ "secure_boot": str(shielded.get("enableSecureBoot", False)),
+ "vtpm": str(shielded.get("enableVtpm", False)),
+ }
+ )
+
+ if not not_shielded:
+ return [
+ Finding(
+ check_id="gcp-compute-shielded-vm",
+ title=f"All instances in {region} have Shielded VM (Secure Boot + vTPM) enabled",
+ description=f"All running instances in {region} have Shielded VM configuration.",
+ severity=Severity.INFO,
+ status=ComplianceStatus.PASS,
+ domain=CheckDomain.COMPUTE,
+ resource_type="GCP::Compute::Instance",
+ resource_id=f"projects/{project_id}/regions/{region}/instances",
+ region=region,
+ account_id=project_id,
+ cloud_provider=CloudProvider.GCP,
+ soc2_controls=["CC6.6"],
+ cis_gcp_controls=["4.8"],
+ )
+ ]
+
+ return [
+ Finding(
+ check_id="gcp-compute-shielded-vm",
+ title=f"{len(not_shielded)} instance(s) in {region} lack full Shielded VM protection",
+ description=(
+ f"{len(not_shielded)} running instance(s) in {region} do not have both Secure "
+ "Boot and vTPM enabled. Shielded VM provides cryptographic guarantees about the "
+ "integrity of boot firmware, kernel, and loaded drivers."
+ ),
+ severity=Severity.MEDIUM,
+ status=ComplianceStatus.FAIL,
+ domain=CheckDomain.COMPUTE,
+ resource_type="GCP::Compute::Instance",
+ resource_id=f"projects/{project_id}/regions/{region}/instances",
+ region=region,
+ account_id=project_id,
+ cloud_provider=CloudProvider.GCP,
+ remediation=(
+ "Enable Shielded VM (requires stopping the instance): "
+ "`gcloud compute instances update INSTANCE_NAME "
+ "--shielded-secure-boot --shielded-vtpm --shielded-integrity-monitoring "
+ f"--zone=ZONE --project={project_id}`."
+ ),
+ soc2_controls=["CC6.6"],
+ cis_gcp_controls=["4.8"],
+ iso27001_controls=["A.8.9"],
+ details={"instances_without_shielded_vm": not_shielded[:20]},
+ )
+ ]
+
+
+def check_gke_private_cluster(
+ client: GCPClient, project_id: str, region: str
+) -> list[Finding]:
+ """[CIS 7.1] GKE clusters should use private nodes (no public IP on nodes).
+
+ Private clusters ensure worker nodes have no external IP addresses, preventing
+ direct internet access to nodes. The control plane remains reachable via
+ authorized networks and Private Google Access.
+ """
+ try:
+ container = client.service("container", "v1")
+ response = (
+ container.projects()
+ .locations()
+ .clusters()
+ .list(parent=f"projects/{project_id}/locations/{region}")
+ .execute()
+ )
+ clusters = response.get("clusters", [])
+ except Exception as e:
+ return [
+ Finding.not_assessed(
+ check_id="gcp-gke-private-cluster",
+ title=f"Unable to list GKE clusters in {region}",
+ description=f"API call failed: {e}",
+ domain=CheckDomain.COMPUTE,
+ resource_type="GCP::GKE::Cluster",
+ account_id=project_id,
+ region=region,
+ cloud_provider=CloudProvider.GCP,
+ )
+ ]
+
+ if not clusters:
+ return []
+
+ non_private: list[str] = []
+ for cluster in clusters:
+ private_cfg = cluster.get("privateClusterConfig") or {}
+ if not private_cfg.get("enablePrivateNodes", False):
+ non_private.append(cluster.get("name", "unknown"))
+
+ if not non_private:
+ return [
+ Finding(
+ check_id="gcp-gke-private-cluster",
+ title=f"All {len(clusters)} GKE cluster(s) in {region} use private nodes",
+ description=f"All GKE clusters in {region} have private nodes enabled.",
+ severity=Severity.INFO,
+ status=ComplianceStatus.PASS,
+ domain=CheckDomain.COMPUTE,
+ resource_type="GCP::GKE::Cluster",
+ resource_id=f"projects/{project_id}/locations/{region}/clusters",
+ region=region,
+ account_id=project_id,
+ cloud_provider=CloudProvider.GCP,
+ soc2_controls=["CC6.6"],
+ cis_gcp_controls=["7.1"],
+ )
+ ]
+
+ return [
+ Finding(
+ check_id="gcp-gke-private-cluster",
+ title=f"{len(non_private)} GKE cluster(s) in {region} have public nodes",
+ description=(
+ f"{len(non_private)} GKE cluster(s) in {region} do not use private nodes: "
+ f"{', '.join(non_private[:5])}. Worker nodes with public IPs are directly "
+ "reachable from the internet if firewall rules are misconfigured."
+ ),
+ severity=Severity.HIGH,
+ status=ComplianceStatus.FAIL,
+ domain=CheckDomain.COMPUTE,
+ resource_type="GCP::GKE::Cluster",
+ resource_id=f"projects/{project_id}/locations/{region}/clusters",
+ region=region,
+ account_id=project_id,
+ cloud_provider=CloudProvider.GCP,
+ remediation=(
+ "Private nodes cannot be enabled on existing clusters — create a new cluster "
+ "with `--enable-private-nodes --master-ipv4-cidr=172.16.0.0/28`. "
+ "Migrate workloads to the new cluster and delete the non-private cluster."
+ ),
+ soc2_controls=["CC6.6"],
+ cis_gcp_controls=["7.1"],
+ iso27001_controls=["A.8.20"],
+ details={"clusters_without_private_nodes": non_private},
+ )
+ ]
+
+
+def check_gke_workload_identity(
+ client: GCPClient, project_id: str, region: str
+) -> list[Finding]:
+ """[CIS 7.4] GKE clusters should use Workload Identity for GCP API access.
+
+ Without Workload Identity, pods must use mounted service account key files or
+ the node's service account (which may have broad permissions). Workload Identity
+ binds a Kubernetes service account to a GCP service account, scoping access
+ to exactly what each pod needs.
+ """
+ try:
+ container = client.service("container", "v1")
+ response = (
+ container.projects()
+ .locations()
+ .clusters()
+ .list(parent=f"projects/{project_id}/locations/{region}")
+ .execute()
+ )
+ clusters = response.get("clusters", [])
+ except Exception as e:
+ return [
+ Finding.not_assessed(
+ check_id="gcp-gke-workload-identity",
+ title=f"Unable to list GKE clusters in {region} for Workload Identity check",
+ description=f"API call failed: {e}",
+ domain=CheckDomain.COMPUTE,
+ resource_type="GCP::GKE::Cluster",
+ account_id=project_id,
+ region=region,
+ cloud_provider=CloudProvider.GCP,
+ )
+ ]
+
+ if not clusters:
+ return []
+
+ without_wi: list[str] = []
+ for cluster in clusters:
+ wi_config = cluster.get("workloadIdentityConfig") or {}
+ workload_pool = wi_config.get("workloadPool", "")
+ if not workload_pool:
+ without_wi.append(cluster.get("name", "unknown"))
+
+ if not without_wi:
+ return [
+ Finding(
+ check_id="gcp-gke-workload-identity",
+ title=f"All {len(clusters)} GKE cluster(s) in {region} have Workload Identity",
+ description=f"Workload Identity is enabled on all GKE clusters in {region}.",
+ severity=Severity.INFO,
+ status=ComplianceStatus.PASS,
+ domain=CheckDomain.COMPUTE,
+ resource_type="GCP::GKE::Cluster",
+ resource_id=f"projects/{project_id}/locations/{region}/clusters",
+ region=region,
+ account_id=project_id,
+ cloud_provider=CloudProvider.GCP,
+ soc2_controls=["CC6.2"],
+ cis_gcp_controls=["7.4"],
+ )
+ ]
+
+ return [
+ Finding(
+ check_id="gcp-gke-workload-identity",
+ title=f"{len(without_wi)} GKE cluster(s) in {region} lack Workload Identity",
+ description=(
+ f"{len(without_wi)} GKE cluster(s) in {region} do not have Workload Identity "
+ f"enabled: {', '.join(without_wi[:5])}. Pods must use node SA or mounted key "
+ "files, granting broader-than-needed GCP API access."
+ ),
+ severity=Severity.HIGH,
+ status=ComplianceStatus.FAIL,
+ domain=CheckDomain.COMPUTE,
+ resource_type="GCP::GKE::Cluster",
+ resource_id=f"projects/{project_id}/locations/{region}/clusters",
+ region=region,
+ account_id=project_id,
+ cloud_provider=CloudProvider.GCP,
+ remediation=(
+ "Enable Workload Identity: `gcloud container clusters update CLUSTER_NAME "
+ f"--region={region} --workload-pool={project_id}.svc.id.goog --project={project_id}`. "
+ "Then update node pools: `gcloud container node-pools update POOL_NAME "
+ "--cluster=CLUSTER_NAME --workload-metadata=GKE_METADATA`."
+ ),
+ soc2_controls=["CC6.2"],
+ cis_gcp_controls=["7.4"],
+ iso27001_controls=["A.5.15"],
+ details={"clusters_without_workload_identity": without_wi},
+ )
+ ]
+
+
+def check_gke_network_policy(
+ client: GCPClient, project_id: str, region: str
+) -> list[Finding]:
+ """[CIS 7.5] GKE clusters should have a Network Policy enabled.
+
+ Without a network policy, all pods can communicate with all other pods across
+ all namespaces. Network policies implement micro-segmentation at the pod level,
+ preventing lateral movement if a pod is compromised.
+ """
+ try:
+ container = client.service("container", "v1")
+ response = (
+ container.projects()
+ .locations()
+ .clusters()
+ .list(parent=f"projects/{project_id}/locations/{region}")
+ .execute()
+ )
+ clusters = response.get("clusters", [])
+ except Exception as e:
+ return [
+ Finding.not_assessed(
+ check_id="gcp-gke-network-policy",
+ title=f"Unable to list GKE clusters in {region} for Network Policy check",
+ description=f"API call failed: {e}",
+ domain=CheckDomain.COMPUTE,
+ resource_type="GCP::GKE::Cluster",
+ account_id=project_id,
+ region=region,
+ cloud_provider=CloudProvider.GCP,
+ )
+ ]
+
+ if not clusters:
+ return []
+
+ without_np: list[str] = []
+ for cluster in clusters:
+ # addonsConfig.networkPolicyConfig.disabled == False means Calico is installed
+ # networkPolicy.enabled must also be True for the policy engine to be active
+ addons = cluster.get("addonsConfig", {})
+ np_config = addons.get("networkPolicyConfig", {})
+ np = cluster.get("networkPolicy", {})
+ calico_installed = not np_config.get("disabled", True)
+ np_enabled = np.get("enabled", False)
+ if not (calico_installed and np_enabled):
+ without_np.append(cluster.get("name", "unknown"))
+
+ if not without_np:
+ return [
+ Finding(
+ check_id="gcp-gke-network-policy",
+ title=f"All {len(clusters)} GKE cluster(s) in {region} have Network Policy enabled",
+ description=f"Network Policy (Calico) is enabled on all GKE clusters in {region}.",
+ severity=Severity.INFO,
+ status=ComplianceStatus.PASS,
+ domain=CheckDomain.COMPUTE,
+ resource_type="GCP::GKE::Cluster",
+ resource_id=f"projects/{project_id}/locations/{region}/clusters",
+ region=region,
+ account_id=project_id,
+ cloud_provider=CloudProvider.GCP,
+ soc2_controls=["CC6.6"],
+ cis_gcp_controls=["7.5"],
+ )
+ ]
+
+ return [
+ Finding(
+ check_id="gcp-gke-network-policy",
+ title=f"{len(without_np)} GKE cluster(s) in {region} have no Network Policy",
+ description=(
+ f"{len(without_np)} GKE cluster(s) in {region} do not enforce Network Policy: "
+ f"{', '.join(without_np[:5])}. All pods communicate freely across namespaces, "
+ "enabling lateral movement after a pod compromise."
+ ),
+ severity=Severity.MEDIUM,
+ status=ComplianceStatus.FAIL,
+ domain=CheckDomain.COMPUTE,
+ resource_type="GCP::GKE::Cluster",
+ resource_id=f"projects/{project_id}/locations/{region}/clusters",
+ region=region,
+ account_id=project_id,
+ cloud_provider=CloudProvider.GCP,
+ remediation=(
+ "Enable Network Policy: `gcloud container clusters update CLUSTER_NAME "
+ f"--enable-network-policy --zone=ZONE --project={project_id}`. "
+ "Deploy NetworkPolicy objects to restrict pod-to-pod communication. "
+ "Alternatively, use GKE Dataplane V2 which has NetworkPolicy built in."
+ ),
+ soc2_controls=["CC6.6"],
+ cis_gcp_controls=["7.5"],
+ iso27001_controls=["A.8.20"],
+ details={"clusters_without_network_policy": without_np},
+ )
+ ]
+
+
+def check_gke_node_auto_upgrade(
+ client: GCPClient, project_id: str, region: str
+) -> list[Finding]:
+ """GKE node pools should have automatic node upgrades enabled.
+
+ Auto-upgrade keeps node pools on the latest patched Kubernetes minor version,
+ reducing exposure to known CVEs. Manual upgrade processes are often delayed,
+ leaving nodes on vulnerable versions for months.
+ """
+ try:
+ container = client.service("container", "v1")
+ response = (
+ container.projects()
+ .locations()
+ .clusters()
+ .list(parent=f"projects/{project_id}/locations/{region}")
+ .execute()
+ )
+ clusters = response.get("clusters", [])
+ except Exception as e:
+ return [
+ Finding.not_assessed(
+ check_id="gcp-gke-node-auto-upgrade",
+ title=f"Unable to list GKE clusters in {region} for auto-upgrade check",
+ description=f"API call failed: {e}",
+ domain=CheckDomain.COMPUTE,
+ resource_type="GCP::GKE::Cluster",
+ account_id=project_id,
+ region=region,
+ cloud_provider=CloudProvider.GCP,
+ )
+ ]
+
+ if not clusters:
+ return []
+
+ pools_without_upgrade: list[dict[str, str]] = []
+ for cluster in clusters:
+ cluster_name = cluster.get("name", "")
+ for pool in cluster.get("nodePools", []):
+ mgmt = pool.get("management", {})
+ if not mgmt.get("autoUpgrade", False):
+ pools_without_upgrade.append(
+ {
+ "cluster": cluster_name,
+ "pool": pool.get("name", ""),
+ }
+ )
+
+ if not pools_without_upgrade:
+ return [
+ Finding(
+ check_id="gcp-gke-node-auto-upgrade",
+ title=f"All GKE node pools in {region} have auto-upgrade enabled",
+ description=f"Automatic node upgrades are enabled on all node pools in {region}.",
+ severity=Severity.INFO,
+ status=ComplianceStatus.PASS,
+ domain=CheckDomain.COMPUTE,
+ resource_type="GCP::GKE::Cluster",
+ resource_id=f"projects/{project_id}/locations/{region}/clusters",
+ region=region,
+ account_id=project_id,
+ cloud_provider=CloudProvider.GCP,
+ soc2_controls=["CC6.6"],
+ cis_gcp_controls=["7.9"],
+ )
+ ]
+
+ return [
+ Finding(
+ check_id="gcp-gke-node-auto-upgrade",
+ title=f"{len(pools_without_upgrade)} node pool(s) in {region} have auto-upgrade disabled",
+ description=(
+ f"{len(pools_without_upgrade)} node pool(s) in {region} do not have auto-upgrade "
+ "enabled. Nodes on outdated Kubernetes versions may be exposed to known CVEs."
+ ),
+ severity=Severity.MEDIUM,
+ status=ComplianceStatus.FAIL,
+ domain=CheckDomain.COMPUTE,
+ resource_type="GCP::GKE::Cluster",
+ resource_id=f"projects/{project_id}/locations/{region}/clusters",
+ region=region,
+ account_id=project_id,
+ cloud_provider=CloudProvider.GCP,
+ remediation=(
+ "Enable auto-upgrade: `gcloud container node-pools update POOL_NAME "
+ "--cluster=CLUSTER_NAME --enable-autoupgrade "
+ f"--region={region} --project={project_id}`."
+ ),
+ soc2_controls=["CC6.6"],
+ cis_gcp_controls=["7.9"],
+ details={"pools_without_auto_upgrade": pools_without_upgrade[:20]},
+ )
+ ]
diff --git a/src/shasta/gcp/encryption.py b/src/shasta/gcp/encryption.py
new file mode 100644
index 0000000..0308486
--- /dev/null
+++ b/src/shasta/gcp/encryption.py
@@ -0,0 +1,696 @@
+"""GCP encryption security checks for SOC 2 and CIS GCP Benchmark v2.0.
+
+Covers:
+ CC6.7 — Data protection at rest and in transit
+
+CIS GCP v2.0 Sections 1 (KMS), 6 (Cloud SQL), 7 (BigQuery).
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+from shasta.gcp.client import GCPClient
+from shasta.evidence.models import (
+ CheckDomain,
+ CloudProvider,
+ ComplianceStatus,
+ Finding,
+ Severity,
+)
+
+IS_GLOBAL = True # Uses global/project-wide listing endpoints for KMS, SQL, BigQuery
+
+# KMS key rotation period must be ≤ this many seconds (90 days)
+_MAX_ROTATION_PERIOD_SECONDS = 90 * 24 * 3600
+
+
+def run_all_gcp_encryption_checks(client: GCPClient) -> list[Finding]:
+ """Run all GCP encryption compliance checks."""
+ project_id = client.project_id if client.account_info else (client._project_id or "unknown")
+
+ findings: list[Finding] = []
+ findings.extend(check_kms_key_rotation_period(client, project_id))
+ findings.extend(check_sql_require_ssl(client, project_id))
+ findings.extend(check_sql_no_public_ip(client, project_id))
+ findings.extend(check_sql_data_backup_enabled(client, project_id))
+ findings.extend(check_bigquery_no_public_access(client, project_id))
+ findings.extend(check_bigquery_cmek_configured(client, project_id))
+
+ return findings
+
+
+def check_kms_key_rotation_period(
+ client: GCPClient, project_id: str
+) -> list[Finding]:
+ """[CIS 1.10] Cloud KMS key rotation period should be ≤90 days.
+
+ Key rotation limits the amount of data encrypted under a single key version
+ and the window of exposure if a key is compromised. The CIS benchmark requires
+ an automatic rotation period of 90 days or less.
+ """
+ region = "global"
+ try:
+ kms = client.service("cloudkms", "v1")
+ # KMS doesn't accept "-" as a wildcard location — enumerate supported
+ # locations first, then list key rings per location.
+ locations_resp = (
+ kms.projects().locations().list(name=f"projects/{project_id}").execute()
+ )
+ locations = [loc.get("locationId") for loc in locations_resp.get("locations", [])]
+ key_rings: list[dict[str, Any]] = []
+ for location in locations:
+ try:
+ parent = f"projects/{project_id}/locations/{location}"
+ resp = (
+ kms.projects().locations().keyRings().list(parent=parent).execute()
+ )
+ key_rings.extend(resp.get("keyRings", []))
+ except Exception:
+ # A single failed location should not abort the whole check
+ continue
+ except Exception as e:
+ return [
+ Finding.not_assessed(
+ check_id="gcp-kms-key-rotation",
+ title="Unable to list KMS key rings",
+ description=f"API call failed: {e}",
+ domain=CheckDomain.ENCRYPTION,
+ resource_type="GCP::KMS::CryptoKey",
+ account_id=project_id,
+ region=region,
+ cloud_provider=CloudProvider.GCP,
+ )
+ ]
+
+ if not key_rings:
+ return [
+ Finding(
+ check_id="gcp-kms-key-rotation",
+ title="No Cloud KMS key rings found in project",
+ description="No KMS key rings exist — rotation check not applicable.",
+ severity=Severity.INFO,
+ status=ComplianceStatus.NOT_APPLICABLE,
+ domain=CheckDomain.ENCRYPTION,
+ resource_type="GCP::KMS::CryptoKey",
+ resource_id=f"projects/{project_id}/locations/-/keyRings",
+ region=region,
+ account_id=project_id,
+ cloud_provider=CloudProvider.GCP,
+ soc2_controls=["CC6.7"],
+ cis_gcp_controls=["1.10"],
+ )
+ ]
+
+ offenders: list[dict[str, Any]] = []
+ for ring in key_rings:
+ ring_name = ring.get("name", "")
+ try:
+ keys_resp = (
+ kms.projects()
+ .locations()
+ .keyRings()
+ .cryptoKeys()
+ .list(parent=ring_name)
+ .execute()
+ )
+ keys = keys_resp.get("cryptoKeys", [])
+ except Exception:
+ continue
+
+ for key in keys:
+ purpose = key.get("purpose", "")
+ if purpose != "ENCRYPT_DECRYPT":
+ continue # Only symmetric encryption keys can be rotated
+ rotation_period = key.get("rotationPeriod")
+ next_rotation = key.get("nextRotationTime")
+
+ if not rotation_period:
+ offenders.append(
+ {
+ "key": key.get("name", ""),
+ "issue": "no rotation period set",
+ }
+ )
+ else:
+ # rotationPeriod is like "7776000s" (90 days in seconds)
+ try:
+ period_secs = int(rotation_period.rstrip("s"))
+ if period_secs > _MAX_ROTATION_PERIOD_SECONDS:
+ offenders.append(
+ {
+ "key": key.get("name", ""),
+ "issue": f"rotation period {period_secs // 86400} days (>{_MAX_ROTATION_PERIOD_SECONDS // 86400} day limit)",
+ }
+ )
+ except (ValueError, AttributeError):
+ pass
+
+ if not offenders:
+ return [
+ Finding(
+ check_id="gcp-kms-key-rotation",
+ title="All KMS ENCRYPT_DECRYPT keys have rotation ≤90 days",
+ description="All Cloud KMS symmetric keys are configured to rotate within 90 days.",
+ severity=Severity.INFO,
+ status=ComplianceStatus.PASS,
+ domain=CheckDomain.ENCRYPTION,
+ resource_type="GCP::KMS::CryptoKey",
+ resource_id=f"projects/{project_id}/locations/-/keyRings/-/cryptoKeys",
+ region=region,
+ account_id=project_id,
+ cloud_provider=CloudProvider.GCP,
+ soc2_controls=["CC6.7"],
+ cis_gcp_controls=["1.10"],
+ )
+ ]
+
+ return [
+ Finding(
+ check_id="gcp-kms-key-rotation",
+ title=f"{len(offenders)} KMS key(s) have insufficient rotation configuration",
+ description=(
+ f"{len(offenders)} Cloud KMS symmetric key(s) either have no automatic rotation "
+ "period or a period exceeding 90 days. Long-lived keys expand the breach window."
+ ),
+ severity=Severity.MEDIUM,
+ status=ComplianceStatus.FAIL,
+ domain=CheckDomain.ENCRYPTION,
+ resource_type="GCP::KMS::CryptoKey",
+ resource_id=f"projects/{project_id}/locations/-/keyRings/-/cryptoKeys",
+ region=region,
+ account_id=project_id,
+ cloud_provider=CloudProvider.GCP,
+ remediation=(
+ "Set automatic rotation via the Console or: "
+ "`gcloud kms keys update KEY_NAME --keyring=KEYRING --location=LOCATION "
+ "--rotation-period=7776000s --next-rotation-time=TOMORROW_ISO8601`. "
+ "For new keys, always set --rotation-period at creation time."
+ ),
+ soc2_controls=["CC6.7"],
+ cis_gcp_controls=["1.10"],
+ iso27001_controls=["A.8.24"],
+ details={"keys_with_issues": offenders[:20]},
+ )
+ ]
+
+
+def check_sql_require_ssl(
+ client: GCPClient, project_id: str
+) -> list[Finding]:
+ """[CIS 6.3] Cloud SQL instances should require SSL for all connections.
+
+ Without enforced SSL, database clients can connect over unencrypted channels,
+ exposing credentials and data in transit to network eavesdropping.
+ """
+ region = "global"
+ try:
+ sqladmin = client.service("sqladmin", "v1beta4")
+ response = sqladmin.instances().list(project=project_id).execute()
+ instances = response.get("items", [])
+ except Exception as e:
+ return [
+ Finding.not_assessed(
+ check_id="gcp-sql-require-ssl",
+ title="Unable to list Cloud SQL instances",
+ description=f"API call failed: {e}",
+ domain=CheckDomain.ENCRYPTION,
+ resource_type="GCP::SQL::Instance",
+ account_id=project_id,
+ region=region,
+ cloud_provider=CloudProvider.GCP,
+ )
+ ]
+
+ if not instances:
+ return [
+ Finding(
+ check_id="gcp-sql-require-ssl",
+ title="No Cloud SQL instances found",
+ description="No Cloud SQL instances exist — SSL check not applicable.",
+ severity=Severity.INFO,
+ status=ComplianceStatus.NOT_APPLICABLE,
+ domain=CheckDomain.ENCRYPTION,
+ resource_type="GCP::SQL::Instance",
+ resource_id=f"projects/{project_id}/instances",
+ region=region,
+ account_id=project_id,
+ cloud_provider=CloudProvider.GCP,
+ soc2_controls=["CC6.7"],
+ cis_gcp_controls=["6.3"],
+ )
+ ]
+
+ offenders: list[dict[str, str]] = []
+ for inst in instances:
+ settings = inst.get("settings", {})
+ ip_config = settings.get("ipConfiguration", {})
+ if not ip_config.get("requireSsl", False):
+ offenders.append(
+ {
+ "instance": inst.get("name", ""),
+ "region": inst.get("region", ""),
+ "database_version": inst.get("databaseVersion", ""),
+ }
+ )
+
+ if not offenders:
+ return [
+ Finding(
+ check_id="gcp-sql-require-ssl",
+ title=f"All {len(instances)} Cloud SQL instance(s) require SSL",
+ description="SSL is enforced on all Cloud SQL instances.",
+ severity=Severity.INFO,
+ status=ComplianceStatus.PASS,
+ domain=CheckDomain.ENCRYPTION,
+ resource_type="GCP::SQL::Instance",
+ resource_id=f"projects/{project_id}/instances",
+ region=region,
+ account_id=project_id,
+ cloud_provider=CloudProvider.GCP,
+ soc2_controls=["CC6.7"],
+ cis_gcp_controls=["6.3"],
+ )
+ ]
+
+ return [
+ Finding(
+ check_id="gcp-sql-require-ssl",
+ title=f"{len(offenders)} Cloud SQL instance(s) do not require SSL",
+ description=(
+ f"{len(offenders)} Cloud SQL instance(s) allow non-SSL connections: "
+ + ", ".join(o["instance"] for o in offenders[:5])
+ + (f" and {len(offenders) - 5} more" if len(offenders) > 5 else "")
+ + ". This allows database credentials and query results to be captured in transit."
+ ),
+ severity=Severity.HIGH,
+ status=ComplianceStatus.FAIL,
+ domain=CheckDomain.ENCRYPTION,
+ resource_type="GCP::SQL::Instance",
+ resource_id=f"projects/{project_id}/instances",
+ region=region,
+ account_id=project_id,
+ cloud_provider=CloudProvider.GCP,
+ remediation=(
+ "Enable SSL requirement: `gcloud sql instances patch INSTANCE_NAME "
+ "--require-ssl --project=PROJECT_ID`. "
+ "Also create client SSL certificates and update application connection strings."
+ ),
+ soc2_controls=["CC6.7"],
+ cis_gcp_controls=["6.3"],
+ iso27001_controls=["A.8.24"],
+ hipaa_controls=["164.312(e)(1)"],
+ details={"instances_without_ssl": offenders[:20]},
+ )
+ ]
+
+
+def check_sql_no_public_ip(
+ client: GCPClient, project_id: str
+) -> list[Finding]:
+ """[CIS 6.2] Cloud SQL instances should not have a public IP address.
+
+ A public IP makes the database endpoint directly reachable from the internet.
+ Use Private IP with VPC Service Controls or Cloud SQL Auth Proxy instead.
+ """
+ region = "global"
+ try:
+ sqladmin = client.service("sqladmin", "v1beta4")
+ response = sqladmin.instances().list(project=project_id).execute()
+ instances = response.get("items", [])
+ except Exception as e:
+ return [
+ Finding.not_assessed(
+ check_id="gcp-sql-no-public-ip",
+ title="Unable to list Cloud SQL instances for public IP check",
+ description=f"API call failed: {e}",
+ domain=CheckDomain.ENCRYPTION,
+ resource_type="GCP::SQL::Instance",
+ account_id=project_id,
+ region=region,
+ cloud_provider=CloudProvider.GCP,
+ )
+ ]
+
+ if not instances:
+ return []
+
+ offenders: list[dict[str, str]] = []
+ for inst in instances:
+ ip_addresses = inst.get("ipAddresses", [])
+ public_ips = [ip for ip in ip_addresses if ip.get("type") == "PRIMARY"]
+ if public_ips:
+ offenders.append(
+ {
+ "instance": inst.get("name", ""),
+ "public_ip": public_ips[0].get("ipAddress", ""),
+ "region": inst.get("region", ""),
+ }
+ )
+
+ if not offenders:
+ return [
+ Finding(
+ check_id="gcp-sql-no-public-ip",
+ title=f"All {len(instances)} Cloud SQL instance(s) use private IP only",
+ description="No Cloud SQL instances have a public IP address.",
+ severity=Severity.INFO,
+ status=ComplianceStatus.PASS,
+ domain=CheckDomain.ENCRYPTION,
+ resource_type="GCP::SQL::Instance",
+ resource_id=f"projects/{project_id}/instances",
+ region=region,
+ account_id=project_id,
+ cloud_provider=CloudProvider.GCP,
+ soc2_controls=["CC6.6"],
+ cis_gcp_controls=["6.2"],
+ )
+ ]
+
+ return [
+ Finding(
+ check_id="gcp-sql-no-public-ip",
+ title=f"{len(offenders)} Cloud SQL instance(s) have a public IP",
+ description=(
+ f"{len(offenders)} Cloud SQL instance(s) have a public IP address: "
+ + ", ".join(o["instance"] for o in offenders[:5])
+ + ". Public IPs make the database endpoint reachable from the internet."
+ ),
+ severity=Severity.HIGH,
+ status=ComplianceStatus.FAIL,
+ domain=CheckDomain.ENCRYPTION,
+ resource_type="GCP::SQL::Instance",
+ resource_id=f"projects/{project_id}/instances",
+ region=region,
+ account_id=project_id,
+ cloud_provider=CloudProvider.GCP,
+ remediation=(
+ "Migrate to Private IP: `gcloud sql instances patch INSTANCE_NAME "
+ "--no-assign-ip --network=VPC_NETWORK --project=PROJECT_ID`. "
+ "Use Cloud SQL Auth Proxy for secure connections from application code."
+ ),
+ soc2_controls=["CC6.6"],
+ cis_gcp_controls=["6.2"],
+ iso27001_controls=["A.8.20"],
+ details={"instances_with_public_ip": offenders[:20]},
+ )
+ ]
+
+
+def check_sql_data_backup_enabled(
+ client: GCPClient, project_id: str
+) -> list[Finding]:
+ """[CIS 6.7] Cloud SQL instances should have automated backups enabled.
+
+ Automated backups are a last resort recovery mechanism. Without them, data
+ loss from accidental deletion, corruption, or a ransom attack is unrecoverable.
+ """
+ region = "global"
+ try:
+ sqladmin = client.service("sqladmin", "v1beta4")
+ response = sqladmin.instances().list(project=project_id).execute()
+ instances = response.get("items", [])
+ except Exception as e:
+ return [
+ Finding.not_assessed(
+ check_id="gcp-sql-backups",
+ title="Unable to list Cloud SQL instances for backup check",
+ description=f"API call failed: {e}",
+ domain=CheckDomain.ENCRYPTION,
+ resource_type="GCP::SQL::Instance",
+ account_id=project_id,
+ region=region,
+ cloud_provider=CloudProvider.GCP,
+ )
+ ]
+
+ if not instances:
+ return []
+
+ missing: list[dict[str, str]] = []
+ for inst in instances:
+ settings = inst.get("settings", {})
+ backup_config = settings.get("backupConfiguration", {})
+ if not backup_config.get("enabled", False):
+ missing.append(
+ {
+ "instance": inst.get("name", ""),
+ "region": inst.get("region", ""),
+ }
+ )
+
+ if not missing:
+ return [
+ Finding(
+ check_id="gcp-sql-backups",
+ title=f"All {len(instances)} Cloud SQL instance(s) have automated backups",
+ description="Automated backups are enabled on all Cloud SQL instances.",
+ severity=Severity.INFO,
+ status=ComplianceStatus.PASS,
+ domain=CheckDomain.ENCRYPTION,
+ resource_type="GCP::SQL::Instance",
+ resource_id=f"projects/{project_id}/instances",
+ region=region,
+ account_id=project_id,
+ cloud_provider=CloudProvider.GCP,
+ soc2_controls=["CC6.7"],
+ cis_gcp_controls=["6.7"],
+ )
+ ]
+
+ return [
+ Finding(
+ check_id="gcp-sql-backups",
+ title=f"{len(missing)} Cloud SQL instance(s) have automated backups disabled",
+ description=(
+ f"{len(missing)} Cloud SQL instance(s) do not have automated backups: "
+ + ", ".join(m["instance"] for m in missing[:5])
+ + ". Without backups, data loss from corruption or accidental deletion is permanent."
+ ),
+ severity=Severity.HIGH,
+ status=ComplianceStatus.FAIL,
+ domain=CheckDomain.ENCRYPTION,
+ resource_type="GCP::SQL::Instance",
+ resource_id=f"projects/{project_id}/instances",
+ region=region,
+ account_id=project_id,
+ cloud_provider=CloudProvider.GCP,
+ remediation=(
+ "Enable backups: `gcloud sql instances patch INSTANCE_NAME "
+ "--backup-start-time=HH:MM --project=PROJECT_ID`. "
+ "Also enable point-in-time recovery for MySQL/PostgreSQL instances."
+ ),
+ soc2_controls=["CC6.7"],
+ cis_gcp_controls=["6.7"],
+ hipaa_controls=["164.308(a)(7)"],
+ details={"instances_without_backups": missing[:20]},
+ )
+ ]
+
+
+def check_bigquery_no_public_access(
+ client: GCPClient, project_id: str
+) -> list[Finding]:
+ """[CIS 7.1] BigQuery datasets should not be publicly accessible.
+
+ Public datasets expose all tables within them to the internet, bypassing
+ any row-level security or table-level IAM policies.
+ """
+ region = "global"
+ try:
+ bq = client.service("bigquery", "v2")
+ response = bq.datasets().list(projectId=project_id, all=False).execute()
+ datasets = response.get("datasets", [])
+ except Exception as e:
+ return [
+ Finding.not_assessed(
+ check_id="gcp-bigquery-public-access",
+ title="Unable to list BigQuery datasets",
+ description=f"API call failed: {e}",
+ domain=CheckDomain.ENCRYPTION,
+ resource_type="GCP::BigQuery::Dataset",
+ account_id=project_id,
+ region=region,
+ cloud_provider=CloudProvider.GCP,
+ )
+ ]
+
+ if not datasets:
+ return [
+ Finding(
+ check_id="gcp-bigquery-public-access",
+ title="No BigQuery datasets found in project",
+ description="No BigQuery datasets exist — public access check not applicable.",
+ severity=Severity.INFO,
+ status=ComplianceStatus.NOT_APPLICABLE,
+ domain=CheckDomain.ENCRYPTION,
+ resource_type="GCP::BigQuery::Dataset",
+ resource_id=f"projects/{project_id}/datasets",
+ region=region,
+ account_id=project_id,
+ cloud_provider=CloudProvider.GCP,
+ soc2_controls=["CC6.7"],
+ cis_gcp_controls=["7.1"],
+ )
+ ]
+
+ public_datasets: list[dict[str, str]] = []
+ for ds_ref in datasets:
+ dataset_id = ds_ref.get("datasetReference", {}).get("datasetId", "")
+ try:
+ dataset = (
+ bq.datasets()
+ .get(projectId=project_id, datasetId=dataset_id)
+ .execute()
+ )
+ for entry in dataset.get("access", []):
+ special = entry.get("specialGroup", "")
+ if special in ("allUsers", "allAuthenticatedUsers"):
+ public_datasets.append(
+ {"dataset": dataset_id, "special_group": special}
+ )
+ break
+ except Exception:
+ continue
+
+ if not public_datasets:
+ return [
+ Finding(
+ check_id="gcp-bigquery-public-access",
+ title=f"All {len(datasets)} BigQuery dataset(s) are not publicly accessible",
+ description="No BigQuery datasets grant access to allUsers or allAuthenticatedUsers.",
+ severity=Severity.INFO,
+ status=ComplianceStatus.PASS,
+ domain=CheckDomain.ENCRYPTION,
+ resource_type="GCP::BigQuery::Dataset",
+ resource_id=f"projects/{project_id}/datasets",
+ region=region,
+ account_id=project_id,
+ cloud_provider=CloudProvider.GCP,
+ soc2_controls=["CC6.7"],
+ cis_gcp_controls=["7.1"],
+ )
+ ]
+
+ return [
+ Finding(
+ check_id="gcp-bigquery-public-access",
+ title=f"{len(public_datasets)} BigQuery dataset(s) are publicly accessible",
+ description=(
+ f"{len(public_datasets)} BigQuery dataset(s) grant access to allUsers or "
+ "allAuthenticatedUsers, exposing all tables within those datasets to the internet."
+ ),
+ severity=Severity.CRITICAL,
+ status=ComplianceStatus.FAIL,
+ domain=CheckDomain.ENCRYPTION,
+ resource_type="GCP::BigQuery::Dataset",
+ resource_id=f"projects/{project_id}/datasets",
+ region=region,
+ account_id=project_id,
+ cloud_provider=CloudProvider.GCP,
+ remediation=(
+ "Remove public access via Console (BigQuery > dataset > Sharing > Permissions) "
+ "or: `bq update --dataset PROJECT_ID:DATASET_ID` after patching the JSON policy "
+ "to remove allUsers/allAuthenticatedUsers entries."
+ ),
+ soc2_controls=["CC6.7"],
+ cis_gcp_controls=["7.1"],
+ iso27001_controls=["A.8.3"],
+ details={"public_datasets": public_datasets},
+ )
+ ]
+
+
+def check_bigquery_cmek_configured(
+ client: GCPClient, project_id: str
+) -> list[Finding]:
+ """[CIS 7.2] BigQuery datasets with sensitive data should use Customer-Managed Encryption Keys.
+
+ By default BigQuery uses Google-managed keys. CMEK gives you control over key
+ lifecycle, rotation, and the ability to disable access to all data in the dataset
+ by disabling/destroying the KMS key.
+ """
+ region = "global"
+ try:
+ bq = client.service("bigquery", "v2")
+ response = bq.datasets().list(projectId=project_id, all=False).execute()
+ datasets = response.get("datasets", [])
+ except Exception as e:
+ return [
+ Finding.not_assessed(
+ check_id="gcp-bigquery-cmek",
+ title="Unable to list BigQuery datasets for CMEK check",
+ description=f"API call failed: {e}",
+ domain=CheckDomain.ENCRYPTION,
+ resource_type="GCP::BigQuery::Dataset",
+ account_id=project_id,
+ region=region,
+ cloud_provider=CloudProvider.GCP,
+ )
+ ]
+
+ if not datasets:
+ return []
+
+ missing_cmek: list[str] = []
+ for ds_ref in datasets:
+ dataset_id = ds_ref.get("datasetReference", {}).get("datasetId", "")
+ try:
+ dataset = (
+ bq.datasets()
+ .get(projectId=project_id, datasetId=dataset_id)
+ .execute()
+ )
+ # defaultEncryptionConfiguration is only set when CMEK is configured
+ if not dataset.get("defaultEncryptionConfiguration", {}).get("kmsKeyName"):
+ missing_cmek.append(dataset_id)
+ except Exception:
+ continue
+
+ if not missing_cmek:
+ return [
+ Finding(
+ check_id="gcp-bigquery-cmek",
+ title=f"All {len(datasets)} BigQuery dataset(s) use CMEK",
+ description="Customer-managed encryption keys are configured on all BigQuery datasets.",
+ severity=Severity.INFO,
+ status=ComplianceStatus.PASS,
+ domain=CheckDomain.ENCRYPTION,
+ resource_type="GCP::BigQuery::Dataset",
+ resource_id=f"projects/{project_id}/datasets",
+ region=region,
+ account_id=project_id,
+ cloud_provider=CloudProvider.GCP,
+ soc2_controls=["CC6.7"],
+ cis_gcp_controls=["7.2"],
+ )
+ ]
+
+ return [
+ Finding(
+ check_id="gcp-bigquery-cmek",
+ title=f"{len(missing_cmek)} BigQuery dataset(s) use Google-managed encryption (not CMEK)",
+ description=(
+ f"{len(missing_cmek)} BigQuery dataset(s) do not use Customer-Managed Encryption "
+ "Keys. For datasets containing sensitive or regulated data, CMEK gives you "
+ "direct control over key lifecycle and cryptographic access."
+ ),
+ severity=Severity.LOW,
+ status=ComplianceStatus.PARTIAL,
+ domain=CheckDomain.ENCRYPTION,
+ resource_type="GCP::BigQuery::Dataset",
+ resource_id=f"projects/{project_id}/datasets",
+ region=region,
+ account_id=project_id,
+ cloud_provider=CloudProvider.GCP,
+ remediation=(
+ "New datasets can be configured with CMEK at creation time. For existing datasets, "
+ "create a new dataset with CMEK configured, copy data with `bq cp`, and delete the "
+ "old dataset. The KMS key must be in the same region as the dataset."
+ ),
+ soc2_controls=["CC6.7"],
+ cis_gcp_controls=["7.2"],
+ iso27001_controls=["A.8.24"],
+ details={"datasets_without_cmek": missing_cmek[:20]},
+ )
+ ]
diff --git a/src/shasta/gcp/iam.py b/src/shasta/gcp/iam.py
new file mode 100644
index 0000000..1584a1e
--- /dev/null
+++ b/src/shasta/gcp/iam.py
@@ -0,0 +1,694 @@
+"""GCP IAM security checks for SOC 2 and CIS GCP Benchmark v2.0.
+
+Covers:
+ CC6.1 — Logical access security (MFA, admin privileges, service accounts)
+ CC6.2 — Access provisioning (least privilege, primitive roles)
+ CC6.3 — Access removal (stale SA keys, unused accounts)
+
+CIS GCP v2.0 Section 1 (Identity and Access Management).
+"""
+
+from __future__ import annotations
+
+from datetime import datetime, timezone, timedelta
+from typing import Any
+
+from shasta.gcp.client import GCPClient
+from shasta.evidence.models import (
+ CheckDomain,
+ CloudProvider,
+ ComplianceStatus,
+ Finding,
+ Severity,
+)
+
+IS_GLOBAL = True # IAM is project-level — no per-region iteration
+
+SA_KEY_MAX_AGE_DAYS = 90
+
+# Primitive roles that should never be assigned at the project level for users
+PRIMITIVE_ROLES = {"roles/owner", "roles/editor"}
+
+# Roles that should not be granted to service accounts at project level
+SA_ADMIN_ROLES = {"roles/owner", "roles/editor", "roles/iam.serviceAccountAdmin"}
+
+
+def run_all_gcp_iam_checks(client: GCPClient) -> list[Finding]:
+ """Run all GCP IAM compliance checks and return findings."""
+ project_id = client.project_id if client.account_info else (client._project_id or "unknown")
+ region = client.account_info.region if client.account_info else _DEFAULT_REGION
+
+ findings: list[Finding] = []
+ findings.extend(check_service_account_key_rotation(client, project_id, region))
+ findings.extend(check_service_account_not_admin(client, project_id, region))
+ findings.extend(check_primitive_roles_not_used(client, project_id, region))
+ findings.extend(check_iam_no_allusers_access(client, project_id, region))
+ findings.extend(check_iam_service_account_token_creator(client, project_id, region))
+ findings.extend(check_iam_workload_identity_preferred(client, project_id, region))
+
+ return findings
+
+
+_DEFAULT_REGION = "global"
+
+
+def check_service_account_key_rotation(
+ client: GCPClient, project_id: str, region: str
+) -> list[Finding]:
+ """[CIS 1.4] User-managed service account keys should not be older than 90 days.
+
+ Keys that are never rotated become long-lived credentials — if leaked they
+ remain valid indefinitely. Enforcing rotation bounds the window of exposure.
+ """
+ try:
+ iam = client.service("iam", "v1")
+ sa_response = iam.projects().serviceAccounts().list(name=f"projects/{project_id}").execute()
+ accounts = sa_response.get("accounts", [])
+ except Exception as e:
+ return [
+ Finding.not_assessed(
+ check_id="gcp-iam-sa-key-rotation",
+ title="Unable to list service accounts",
+ description=f"API call failed: {e}",
+ domain=CheckDomain.IAM,
+ resource_type="GCP::IAM::ServiceAccount",
+ account_id=project_id,
+ region=region,
+ cloud_provider=CloudProvider.GCP,
+ )
+ ]
+
+ if not accounts:
+ return [
+ Finding(
+ check_id="gcp-iam-sa-key-rotation",
+ title="No service accounts found in project",
+ description="No service accounts exist — no key rotation check required.",
+ severity=Severity.INFO,
+ status=ComplianceStatus.NOT_APPLICABLE,
+ domain=CheckDomain.IAM,
+ resource_type="GCP::IAM::ServiceAccount",
+ resource_id=f"projects/{project_id}/serviceAccounts",
+ region=region,
+ account_id=project_id,
+ cloud_provider=CloudProvider.GCP,
+ soc2_controls=["CC6.3"],
+ cis_gcp_controls=["1.4"],
+ )
+ ]
+
+ now = datetime.now(timezone.utc)
+ threshold = now - timedelta(days=SA_KEY_MAX_AGE_DAYS)
+ stale: list[dict[str, Any]] = []
+ access_errors: list[dict[str, str]] = []
+ findings: list[Finding] = []
+
+ for sa in accounts:
+ sa_email = sa.get("email", "")
+ sa_name = sa.get("name", "")
+ # Skip Google-managed service accounts (default SAs)
+ if sa_email.endswith(".gserviceaccount.com") and "iam.gserviceaccount.com" not in sa_email:
+ continue
+
+ try:
+ keys_resp = (
+ iam.projects()
+ .serviceAccounts()
+ .keys()
+ .list(name=sa_name, keyTypes=["USER_MANAGED"])
+ .execute()
+ )
+ keys = keys_resp.get("keys", [])
+ except Exception as e:
+ access_errors.append({"service_account": sa_email or sa_name, "error": str(e)})
+ continue
+
+ for key in keys:
+ created_str = key.get("validAfterTime", "")
+ if not created_str:
+ continue
+ try:
+ created = datetime.fromisoformat(created_str.replace("Z", "+00:00"))
+ except (ValueError, TypeError):
+ continue
+
+ age_days = (now - created).days
+ if created < threshold:
+ stale.append(
+ {
+ "sa_email": sa_email,
+ "key_name": key.get("name", ""),
+ "age_days": age_days,
+ "created": created_str,
+ }
+ )
+
+ if stale:
+ findings.append(
+ Finding(
+ check_id="gcp-iam-sa-key-rotation",
+ title=f"{len(stale)} user-managed SA key(s) are >{SA_KEY_MAX_AGE_DAYS} days old",
+ description=(
+ f"{len(stale)} user-managed service account key(s) have not been rotated within "
+ f"{SA_KEY_MAX_AGE_DAYS} days. Long-lived keys expand the breach window if stolen. "
+ "Prefer Workload Identity Federation over SA keys for GCE/GKE workloads."
+ ),
+ severity=Severity.HIGH,
+ status=ComplianceStatus.FAIL,
+ domain=CheckDomain.IAM,
+ resource_type="GCP::IAM::ServiceAccountKey",
+ resource_id=f"projects/{project_id}/serviceAccounts",
+ region=region,
+ account_id=project_id,
+ cloud_provider=CloudProvider.GCP,
+ remediation=(
+ "Rotate or delete stale keys: "
+ "`gcloud iam service-accounts keys delete KEY_ID --iam-account=SA_EMAIL`. "
+ "For GCE/GKE workloads, migrate to Workload Identity Federation to eliminate "
+ "key management entirely."
+ ),
+ soc2_controls=["CC6.3"],
+ cis_gcp_controls=["1.4"],
+ iso27001_controls=["A.8.3"],
+ details={"stale_keys": stale[:20]},
+ )
+ )
+
+ if access_errors:
+ finding = Finding.not_assessed(
+ check_id="gcp-iam-sa-key-rotation",
+ title="Some service account keys could not be assessed",
+ description=(
+ f"Unable to list user-managed keys for {len(access_errors)} service account(s). "
+ "The scan cannot prove all keys meet the rotation policy."
+ ),
+ domain=CheckDomain.IAM,
+ resource_type="GCP::IAM::ServiceAccountKey",
+ account_id=project_id,
+ region=region,
+ cloud_provider=CloudProvider.GCP,
+ )
+ finding.details["access_errors"] = access_errors[:20]
+ findings.append(finding)
+
+ if findings:
+ return findings
+
+ return [
+ Finding(
+ check_id="gcp-iam-sa-key-rotation",
+ title=f"All user-managed SA keys are ≤{SA_KEY_MAX_AGE_DAYS} days old",
+ description=f"No stale user-managed service account keys found across {len(accounts)} accounts.",
+ severity=Severity.INFO,
+ status=ComplianceStatus.PASS,
+ domain=CheckDomain.IAM,
+ resource_type="GCP::IAM::ServiceAccountKey",
+ resource_id=f"projects/{project_id}/serviceAccounts",
+ region=region,
+ account_id=project_id,
+ cloud_provider=CloudProvider.GCP,
+ soc2_controls=["CC6.3"],
+ cis_gcp_controls=["1.4"],
+ )
+ ]
+
+
+def check_service_account_not_admin(
+ client: GCPClient, project_id: str, region: str
+) -> list[Finding]:
+ """[CIS 1.5] Service accounts should not have admin-level roles at project scope.
+
+ Assigning Owner, Editor, or ServiceAccountAdmin to a service account
+ creates an escalation path: any compromised workload using that SA
+ can pivot to full project control.
+ """
+ try:
+ crm = client.service("cloudresourcemanager", "v1")
+ policy = crm.projects().getIamPolicy(resource=project_id, body={}).execute()
+ bindings = policy.get("bindings", [])
+ except Exception as e:
+ return [
+ Finding.not_assessed(
+ check_id="gcp-iam-sa-not-admin",
+ title="Unable to read project IAM policy",
+ description=f"API call failed: {e}",
+ domain=CheckDomain.IAM,
+ resource_type="GCP::IAM::Policy",
+ account_id=project_id,
+ region=region,
+ cloud_provider=CloudProvider.GCP,
+ )
+ ]
+
+ offenders: list[dict[str, str]] = []
+ for binding in bindings:
+ role = binding.get("role", "")
+ if role not in SA_ADMIN_ROLES:
+ continue
+ for member in binding.get("members", []):
+ if member.startswith("serviceAccount:"):
+ offenders.append({"member": member, "role": role})
+
+ if not offenders:
+ return [
+ Finding(
+ check_id="gcp-iam-sa-not-admin",
+ title="No service accounts with admin roles at project scope",
+ description="No service accounts hold Owner, Editor, or ServiceAccountAdmin at the project level.",
+ severity=Severity.INFO,
+ status=ComplianceStatus.PASS,
+ domain=CheckDomain.IAM,
+ resource_type="GCP::IAM::Policy",
+ resource_id=f"projects/{project_id}",
+ region=region,
+ account_id=project_id,
+ cloud_provider=CloudProvider.GCP,
+ soc2_controls=["CC6.1", "CC6.2"],
+ cis_gcp_controls=["1.5"],
+ )
+ ]
+
+ return [
+ Finding(
+ check_id="gcp-iam-sa-not-admin",
+ title=f"{len(offenders)} service account(s) hold admin roles at project scope",
+ description=(
+ f"{len(offenders)} service account binding(s) grant Owner, Editor, or "
+ "ServiceAccountAdmin at the project level. If any workload using these SAs is "
+ "compromised, an attacker gains full project control."
+ ),
+ severity=Severity.HIGH,
+ status=ComplianceStatus.FAIL,
+ domain=CheckDomain.IAM,
+ resource_type="GCP::IAM::Policy",
+ resource_id=f"projects/{project_id}",
+ region=region,
+ account_id=project_id,
+ cloud_provider=CloudProvider.GCP,
+ remediation=(
+ "Replace broad roles with purpose-built predefined roles "
+ "(e.g., `roles/storage.objectCreator` instead of `roles/editor`). "
+ "Use `gcloud projects remove-iam-policy-binding PROJECT_ID "
+ "--member=serviceAccount:SA_EMAIL --role=roles/editor`."
+ ),
+ soc2_controls=["CC6.1", "CC6.2"],
+ cis_gcp_controls=["1.5"],
+ iso27001_controls=["A.5.15", "A.8.2"],
+ details={"offenders": offenders[:20]},
+ )
+ ]
+
+
+def check_primitive_roles_not_used(
+ client: GCPClient, project_id: str, region: str
+) -> list[Finding]:
+ """[CIS 1.1] Primitive roles (owner/editor/viewer) should not be used for users.
+
+ Primitive roles pre-date IAM and grant permissions across ALL GCP services.
+ They violate least privilege and make access reviews nearly impossible because
+ the exact permissions implied by 'Editor' span thousands of API methods.
+ """
+ try:
+ crm = client.service("cloudresourcemanager", "v1")
+ policy = crm.projects().getIamPolicy(resource=project_id, body={}).execute()
+ bindings = policy.get("bindings", [])
+ except Exception as e:
+ return [
+ Finding.not_assessed(
+ check_id="gcp-iam-primitive-roles",
+ title="Unable to read project IAM policy",
+ description=f"API call failed: {e}",
+ domain=CheckDomain.IAM,
+ resource_type="GCP::IAM::Policy",
+ account_id=project_id,
+ region=region,
+ cloud_provider=CloudProvider.GCP,
+ )
+ ]
+
+ offenders: list[dict[str, str]] = []
+ for binding in bindings:
+ role = binding.get("role", "")
+ if role not in PRIMITIVE_ROLES:
+ continue
+ for member in binding.get("members", []):
+ # Exclude service accounts and groups — focus on human users and allUsers
+ if member.startswith(("user:", "allUsers", "allAuthenticatedUsers")):
+ offenders.append({"member": member, "role": role})
+
+ if not offenders:
+ return [
+ Finding(
+ check_id="gcp-iam-primitive-roles",
+ title="No users have primitive Owner/Editor roles at project scope",
+ description="Human user accounts do not hold primitive Owner or Editor roles at the project level.",
+ severity=Severity.INFO,
+ status=ComplianceStatus.PASS,
+ domain=CheckDomain.IAM,
+ resource_type="GCP::IAM::Policy",
+ resource_id=f"projects/{project_id}",
+ region=region,
+ account_id=project_id,
+ cloud_provider=CloudProvider.GCP,
+ soc2_controls=["CC6.1", "CC6.2"],
+ cis_gcp_controls=["1.1"],
+ )
+ ]
+
+ return [
+ Finding(
+ check_id="gcp-iam-primitive-roles",
+ title=f"{len(offenders)} user(s) hold primitive Owner/Editor roles",
+ description=(
+ f"{len(offenders)} user binding(s) use primitive Owner or Editor roles. "
+ "Primitive roles grant permissions across ALL GCP services, violating the "
+ "principle of least privilege. Use purpose-specific predefined or custom roles."
+ ),
+ severity=Severity.HIGH,
+ status=ComplianceStatus.FAIL,
+ domain=CheckDomain.IAM,
+ resource_type="GCP::IAM::Policy",
+ resource_id=f"projects/{project_id}",
+ region=region,
+ account_id=project_id,
+ cloud_provider=CloudProvider.GCP,
+ remediation=(
+ "Replace primitive roles with predefined roles scoped to the services each user "
+ "actually needs. Use `gcloud projects get-iam-policy PROJECT_ID` to audit, then "
+ "`gcloud projects remove-iam-policy-binding` to remove and add scoped replacements."
+ ),
+ soc2_controls=["CC6.1", "CC6.2"],
+ cis_gcp_controls=["1.1"],
+ iso27001_controls=["A.5.15"],
+ details={"offenders": offenders[:20]},
+ )
+ ]
+
+
+def check_iam_no_allusers_access(client: GCPClient, project_id: str, region: str) -> list[Finding]:
+ """[CIS 1.2] allUsers and allAuthenticatedUsers should not appear in IAM policies.
+
+ These special members grant access to the public internet. A project-level
+ binding to allUsers is almost never intentional and exposes all resources
+ in the project to unauthenticated traffic.
+ """
+ try:
+ crm = client.service("cloudresourcemanager", "v1")
+ policy = crm.projects().getIamPolicy(resource=project_id, body={}).execute()
+ bindings = policy.get("bindings", [])
+ except Exception as e:
+ return [
+ Finding.not_assessed(
+ check_id="gcp-iam-no-allusers",
+ title="Unable to read project IAM policy",
+ description=f"API call failed: {e}",
+ domain=CheckDomain.IAM,
+ resource_type="GCP::IAM::Policy",
+ account_id=project_id,
+ region=region,
+ cloud_provider=CloudProvider.GCP,
+ )
+ ]
+
+ public_bindings: list[dict[str, str]] = []
+ for binding in bindings:
+ role = binding.get("role", "")
+ for member in binding.get("members", []):
+ if member in ("allUsers", "allAuthenticatedUsers"):
+ public_bindings.append({"member": member, "role": role})
+
+ if not public_bindings:
+ return [
+ Finding(
+ check_id="gcp-iam-no-allusers",
+ title="No public (allUsers/allAuthenticatedUsers) IAM bindings at project level",
+ description="The project IAM policy does not grant access to allUsers or allAuthenticatedUsers.",
+ severity=Severity.INFO,
+ status=ComplianceStatus.PASS,
+ domain=CheckDomain.IAM,
+ resource_type="GCP::IAM::Policy",
+ resource_id=f"projects/{project_id}",
+ region=region,
+ account_id=project_id,
+ cloud_provider=CloudProvider.GCP,
+ soc2_controls=["CC6.1"],
+ cis_gcp_controls=["1.2"],
+ )
+ ]
+
+ return [
+ Finding(
+ check_id="gcp-iam-no-allusers",
+ title=f"{len(public_bindings)} public IAM binding(s) found at project level",
+ description=(
+ f"{len(public_bindings)} IAM binding(s) grant access to allUsers or "
+ "allAuthenticatedUsers at the project level. This exposes all resources in the "
+ "project to unauthenticated or any-authenticated traffic on the internet."
+ ),
+ severity=Severity.CRITICAL,
+ status=ComplianceStatus.FAIL,
+ domain=CheckDomain.IAM,
+ resource_type="GCP::IAM::Policy",
+ resource_id=f"projects/{project_id}",
+ region=region,
+ account_id=project_id,
+ cloud_provider=CloudProvider.GCP,
+ remediation=(
+ "Remove public bindings immediately: "
+ "`gcloud projects remove-iam-policy-binding PROJECT_ID "
+ "--member=allUsers --role=ROLE`. Audit whether any individual resource "
+ "(GCS bucket, Cloud Run service) legitimately needs public access and scope "
+ "the permission there instead of at the project level."
+ ),
+ soc2_controls=["CC6.1", "CC6.6"],
+ cis_gcp_controls=["1.2"],
+ iso27001_controls=["A.5.15", "A.8.3"],
+ details={"public_bindings": public_bindings},
+ )
+ ]
+
+
+def check_iam_service_account_token_creator(
+ client: GCPClient, project_id: str, region: str
+) -> list[Finding]:
+ """[CIS 1.6] Service Account Token Creator / Account User roles create impersonation chains.
+
+ Granting roles/iam.serviceAccountTokenCreator to any principal at the project level
+ lets that principal impersonate any service account in the project, effectively granting
+ the combined permissions of all SAs. The role should exist only in tightly scoped
+ resource-level bindings, not at the project level — regardless of principal type.
+ A compromised service account holder has the same blast radius as a compromised user.
+ """
+ IMPERSONATION_ROLES = {
+ "roles/iam.serviceAccountTokenCreator",
+ "roles/iam.serviceAccountUser",
+ }
+
+ try:
+ crm = client.service("cloudresourcemanager", "v1")
+ policy = crm.projects().getIamPolicy(resource=project_id, body={}).execute()
+ bindings = policy.get("bindings", [])
+ except Exception as e:
+ return [
+ Finding.not_assessed(
+ check_id="gcp-iam-sa-token-creator",
+ title="Unable to read project IAM policy",
+ description=f"API call failed: {e}",
+ domain=CheckDomain.IAM,
+ resource_type="GCP::IAM::Policy",
+ account_id=project_id,
+ region=region,
+ cloud_provider=CloudProvider.GCP,
+ )
+ ]
+
+ offenders: list[dict[str, Any]] = []
+ for binding in bindings:
+ role = binding.get("role", "")
+ if role not in IMPERSONATION_ROLES:
+ continue
+ members = binding.get("members", [])
+ if members:
+ offenders.append({"role": role, "members": members})
+
+ if not offenders:
+ return [
+ Finding(
+ check_id="gcp-iam-sa-token-creator",
+ title="No project-level ServiceAccountTokenCreator/User bindings",
+ description=(
+ "No principal — user, group, or service account — holds "
+ "ServiceAccountTokenCreator or ServiceAccountUser at the project level."
+ ),
+ severity=Severity.INFO,
+ status=ComplianceStatus.PASS,
+ domain=CheckDomain.IAM,
+ resource_type="GCP::IAM::Policy",
+ resource_id=f"projects/{project_id}",
+ region=region,
+ account_id=project_id,
+ cloud_provider=CloudProvider.GCP,
+ soc2_controls=["CC6.1", "CC6.2"],
+ cis_gcp_controls=["1.6"],
+ )
+ ]
+
+ return [
+ Finding(
+ check_id="gcp-iam-sa-token-creator",
+ title=f"{len(offenders)} project-level ServiceAccountTokenCreator/User binding(s)",
+ description=(
+ f"{len(offenders)} binding(s) grant ServiceAccountTokenCreator or "
+ "ServiceAccountUser at the project level. Any holder of these roles — "
+ "user, group, or service account — can generate tokens for, and "
+ "impersonate, any service account in the project. This produces the same "
+ "blast radius regardless of principal type."
+ ),
+ severity=Severity.HIGH,
+ status=ComplianceStatus.FAIL,
+ domain=CheckDomain.IAM,
+ resource_type="GCP::IAM::Policy",
+ resource_id=f"projects/{project_id}",
+ region=region,
+ account_id=project_id,
+ cloud_provider=CloudProvider.GCP,
+ remediation=(
+ "Remove project-level TokenCreator/User bindings and re-create them as "
+ "resource-level bindings on the specific service accounts that need to be "
+ "impersonated. This limits the blast radius to a single SA rather than all "
+ "SAs in the project."
+ ),
+ soc2_controls=["CC6.1", "CC6.2"],
+ cis_gcp_controls=["1.6"],
+ iso27001_controls=["A.5.15"],
+ details={"impersonation_bindings": offenders},
+ )
+ ]
+
+
+def check_iam_workload_identity_preferred(
+ client: GCPClient, project_id: str, region: str
+) -> list[Finding]:
+ """[CIS 1.4] Prefer Workload Identity over user-managed service account keys.
+
+ User-managed keys require manual rotation and are a persistent credential that
+ can be leaked. Workload Identity Federation eliminates keys entirely for
+ workloads running on GCE, GKE, Cloud Run, and external OIDC providers.
+ This check is advisory — it flags projects with many user-managed keys as
+ candidates for Workload Identity migration.
+ """
+ try:
+ iam = client.service("iam", "v1")
+ sa_response = iam.projects().serviceAccounts().list(name=f"projects/{project_id}").execute()
+ accounts = sa_response.get("accounts", [])
+ except Exception as e:
+ return [
+ Finding.not_assessed(
+ check_id="gcp-iam-workload-identity",
+ title="Unable to list service accounts for Workload Identity check",
+ description=f"API call failed: {e}",
+ domain=CheckDomain.IAM,
+ resource_type="GCP::IAM::ServiceAccount",
+ account_id=project_id,
+ region=region,
+ cloud_provider=CloudProvider.GCP,
+ )
+ ]
+
+ total_user_keys = 0
+ sa_with_keys: list[str] = []
+ access_errors: list[dict[str, str]] = []
+ for sa in accounts:
+ sa_name = sa.get("name", "")
+ try:
+ keys_resp = (
+ iam.projects()
+ .serviceAccounts()
+ .keys()
+ .list(name=sa_name, keyTypes=["USER_MANAGED"])
+ .execute()
+ )
+ count = len(keys_resp.get("keys", []))
+ if count > 0:
+ total_user_keys += count
+ sa_with_keys.append(sa.get("email", sa_name))
+ except Exception as e:
+ access_errors.append({"service_account": sa.get("email", sa_name), "error": str(e)})
+ continue
+
+ if access_errors:
+ finding = Finding.not_assessed(
+ check_id="gcp-iam-workload-identity",
+ title="Some service account keys could not be assessed",
+ description=(
+ f"Unable to list user-managed keys for {len(access_errors)} service account(s). "
+ "The scan cannot prove Workload Identity is used everywhere."
+ ),
+ domain=CheckDomain.IAM,
+ resource_type="GCP::IAM::ServiceAccount",
+ account_id=project_id,
+ region=region,
+ cloud_provider=CloudProvider.GCP,
+ )
+ finding.details["access_errors"] = access_errors[:20]
+ if total_user_keys == 0:
+ return [finding]
+
+ if total_user_keys == 0:
+ return [
+ Finding(
+ check_id="gcp-iam-workload-identity",
+ title="No user-managed SA keys found — Workload Identity likely in use",
+ description=(
+ "No user-managed service account keys found in the project. "
+ "This is the desired state — workloads should use Workload Identity "
+ "Federation instead of static keys."
+ ),
+ severity=Severity.INFO,
+ status=ComplianceStatus.PASS,
+ domain=CheckDomain.IAM,
+ resource_type="GCP::IAM::ServiceAccount",
+ resource_id=f"projects/{project_id}/serviceAccounts",
+ region=region,
+ account_id=project_id,
+ cloud_provider=CloudProvider.GCP,
+ soc2_controls=["CC6.3"],
+ cis_gcp_controls=["1.4"],
+ )
+ ]
+
+ findings = [
+ Finding(
+ check_id="gcp-iam-workload-identity",
+ title=f"{total_user_keys} user-managed SA key(s) across {len(sa_with_keys)} account(s)",
+ description=(
+ f"{total_user_keys} user-managed key(s) exist across {len(sa_with_keys)} "
+ "service account(s). Each key is a long-lived credential that requires manual "
+ "rotation. Consider migrating these workloads to Workload Identity Federation."
+ ),
+ severity=Severity.MEDIUM,
+ status=ComplianceStatus.PARTIAL,
+ domain=CheckDomain.IAM,
+ resource_type="GCP::IAM::ServiceAccount",
+ resource_id=f"projects/{project_id}/serviceAccounts",
+ region=region,
+ account_id=project_id,
+ cloud_provider=CloudProvider.GCP,
+ remediation=(
+ "For GCE instances: enable the Metadata Service identity (no key needed). "
+ "For GKE: enable Workload Identity on the cluster and node pool. "
+ "For external workloads: configure Workload Identity Federation with your "
+ "identity provider. Then delete user-managed keys."
+ ),
+ soc2_controls=["CC6.3"],
+ cis_gcp_controls=["1.4"],
+ iso27001_controls=["A.8.3"],
+ details={
+ "total_user_managed_keys": total_user_keys,
+ "service_accounts_with_keys": sa_with_keys[:20],
+ },
+ )
+ ]
+ if access_errors:
+ findings.append(finding)
+ return findings
diff --git a/src/shasta/gcp/logging_checks.py b/src/shasta/gcp/logging_checks.py
new file mode 100644
index 0000000..5b469f4
--- /dev/null
+++ b/src/shasta/gcp/logging_checks.py
@@ -0,0 +1,615 @@
+"""GCP Cloud Logging and audit configuration checks for SOC 2 and CIS GCP Benchmark v2.0.
+
+Covers:
+ CC7.1 — Detection and monitoring (audit logs, log-based metrics)
+ CC7.2 — Anomaly monitoring (alerts on security-relevant events)
+
+CIS GCP v2.0 Section 2 (Logging and Monitoring).
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+from shasta.gcp.client import GCPClient
+from shasta.evidence.models import (
+ CheckDomain,
+ CloudProvider,
+ ComplianceStatus,
+ Finding,
+ Severity,
+)
+
+IS_GLOBAL = True # Audit log configuration is project-wide
+
+# CIS 2.x log-based metric filters (log filter, description, cis_id)
+# Each tuple: (filter_fragment, human_description, cis_id)
+_CIS_LOG_METRICS: list[tuple[str, str, str]] = [
+ ("protoPayload.methodName:SetIamPolicy", "IAM policy changes", "2.2"),
+ ("resource.type=audited_resource AND protoPayload.serviceName=cloudresourcemanager", "project ownership changes", "2.4"),
+ ("resource.type=gce_firewall_rule", "VPC firewall rule changes", "2.10"),
+ ("resource.type=gce_network", "VPC network changes", "2.9"),
+ ("resource.type=gce_route", "VPC route changes", "2.8"),
+ ("resource.type=iam_role AND protoPayload.methodName:(roles.create OR roles.update OR roles.delete)", "custom IAM role changes", "2.3"),
+ ("protoPayload.methodName=google.logging.v2.ConfigServiceV2.UpdateSink", "audit logging sink changes", "2.5"),
+ ("resource.type=bigquery_dataset", "BigQuery IAM changes", "2.13"),
+]
+
+
+def run_all_gcp_logging_checks(client: GCPClient) -> list[Finding]:
+ """Run all GCP Cloud Logging and monitoring compliance checks."""
+ project_id = client.project_id if client.account_info else (client._project_id or "unknown")
+
+ findings: list[Finding] = []
+ findings.extend(check_audit_config_data_access(client, project_id))
+ findings.extend(check_log_sink_configured(client, project_id))
+ findings.extend(check_log_metrics_and_alerts(client, project_id))
+ findings.extend(check_log_retention_period(client, project_id))
+ findings.extend(check_log_metric_vpc_network_changes(client, project_id))
+ findings.extend(check_log_metric_firewall_changes(client, project_id))
+ findings.extend(check_log_metric_custom_role_changes(client, project_id))
+ findings.extend(check_log_metric_project_ownership(client, project_id))
+
+ return findings
+
+
+def check_audit_config_data_access(
+ client: GCPClient, project_id: str
+) -> list[Finding]:
+ """[CIS 2.1] Data Access audit logs should be configured for all services.
+
+ By default, GCP only writes Admin Activity logs. Data Access logs (DATA_READ,
+ DATA_WRITE) must be explicitly enabled per service. Without them you cannot
+ audit who read or wrote data in Cloud Storage, BigQuery, Cloud SQL, etc.
+ """
+ region = "global"
+ try:
+ crm = client.service("cloudresourcemanager", "v1")
+ policy = crm.projects().getIamPolicy(resource=project_id, body={}).execute()
+ audit_configs = policy.get("auditConfigs", [])
+ except Exception as e:
+ return [
+ Finding.not_assessed(
+ check_id="gcp-audit-data-access",
+ title="Unable to read project IAM policy for audit config",
+ description=f"API call failed: {e}",
+ domain=CheckDomain.MONITORING,
+ resource_type="GCP::IAM::AuditConfig",
+ account_id=project_id,
+ region=region,
+ cloud_provider=CloudProvider.GCP,
+ )
+ ]
+
+ # Check whether allServices has DATA_READ and DATA_WRITE enabled
+ all_services_config: dict[str, set[str]] = {}
+ for config in audit_configs:
+ service = config.get("service", "")
+ for log_config in config.get("auditLogConfigs", []):
+ log_type = log_config.get("logType", "")
+ if service not in all_services_config:
+ all_services_config[service] = set()
+ all_services_config[service].add(log_type)
+
+ required_log_types = {"DATA_READ", "DATA_WRITE"}
+ all_services_types = all_services_config.get("allServices", set())
+ missing_types = required_log_types - all_services_types
+
+ if not missing_types:
+ return [
+ Finding(
+ check_id="gcp-audit-data-access",
+ title="Data Access audit logs (DATA_READ + DATA_WRITE) enabled for allServices",
+ description=(
+ "The project audit config enables DATA_READ and DATA_WRITE logs for "
+ "allServices. All API data access operations are captured in Cloud Audit Logs."
+ ),
+ severity=Severity.INFO,
+ status=ComplianceStatus.PASS,
+ domain=CheckDomain.MONITORING,
+ resource_type="GCP::IAM::AuditConfig",
+ resource_id=f"projects/{project_id}",
+ region=region,
+ account_id=project_id,
+ cloud_provider=CloudProvider.GCP,
+ soc2_controls=["CC7.1", "CC8.1"],
+ cis_gcp_controls=["2.1"],
+ )
+ ]
+
+ return [
+ Finding(
+ check_id="gcp-audit-data-access",
+ title=f"Data Access audit logs missing for allServices: {', '.join(sorted(missing_types))}",
+ description=(
+ f"The project audit config does not enable {', '.join(sorted(missing_types))} "
+ "for allServices. Without these log types, read and write operations to GCP "
+ "data services (Cloud Storage, BigQuery, Cloud SQL, etc.) are not captured "
+ "in Cloud Audit Logs, leaving a blind spot in your audit trail."
+ ),
+ severity=Severity.HIGH,
+ status=ComplianceStatus.FAIL,
+ domain=CheckDomain.MONITORING,
+ resource_type="GCP::IAM::AuditConfig",
+ resource_id=f"projects/{project_id}",
+ region=region,
+ account_id=project_id,
+ cloud_provider=CloudProvider.GCP,
+ remediation=(
+ "Enable Data Access logs in the GCP Console: IAM & Admin > Audit Logs, "
+ "select 'All Google Cloud Services', and check DATA_READ and DATA_WRITE. "
+ "Note: Data Access logs significantly increase logging volume — budget accordingly."
+ ),
+ soc2_controls=["CC7.1", "CC8.1"],
+ cis_gcp_controls=["2.1"],
+ iso27001_controls=["A.8.15"],
+ hipaa_controls=["164.312(b)"],
+ details={"missing_log_types": sorted(missing_types), "current_audit_configs": list(all_services_types)},
+ )
+ ]
+
+
+def check_log_sink_configured(
+ client: GCPClient, project_id: str
+) -> list[Finding]:
+ """[CIS 2.2] At least one log sink should export project logs to an external destination.
+
+ Log sinks export Cloud Logging entries to Cloud Storage, BigQuery, or Pub/Sub.
+ Without a sink, logs are only retained in Cloud Logging for 30 days (Admin Activity)
+ or 30 days (Data Access by default). A sink provides long-term retention and
+ immutable audit trail outside of Cloud Logging.
+ """
+ region = "global"
+ try:
+ logging = client.service("logging", "v2")
+ response = logging.projects().sinks().list(parent=f"projects/{project_id}").execute()
+ sinks = response.get("sinks", [])
+ except Exception as e:
+ return [
+ Finding.not_assessed(
+ check_id="gcp-log-sink",
+ title="Unable to list log sinks",
+ description=f"API call failed: {e}",
+ domain=CheckDomain.MONITORING,
+ resource_type="GCP::Logging::LogSink",
+ account_id=project_id,
+ region=region,
+ cloud_provider=CloudProvider.GCP,
+ )
+ ]
+
+ # Filter to non-_Default sinks that have a real destination
+ active_export_sinks = [
+ s for s in sinks
+ if s.get("name", "").split("/")[-1] not in ("_Default", "_Required")
+ and s.get("destination")
+ ]
+
+ if active_export_sinks:
+ return [
+ Finding(
+ check_id="gcp-log-sink",
+ title=f"{len(active_export_sinks)} log export sink(s) configured",
+ description=(
+ f"{len(active_export_sinks)} log sink(s) export logs to external "
+ "destinations: "
+ + ", ".join(s.get("destination", "")[:40] for s in active_export_sinks[:3])
+ + "."
+ ),
+ severity=Severity.INFO,
+ status=ComplianceStatus.PASS,
+ domain=CheckDomain.MONITORING,
+ resource_type="GCP::Logging::LogSink",
+ resource_id=f"projects/{project_id}/sinks",
+ region=region,
+ account_id=project_id,
+ cloud_provider=CloudProvider.GCP,
+ soc2_controls=["CC7.1"],
+ cis_gcp_controls=["2.2"],
+ )
+ ]
+
+ return [
+ Finding(
+ check_id="gcp-log-sink",
+ title="No log export sinks configured — logs retained only in Cloud Logging",
+ description=(
+ "No log sinks export project logs to an external destination (Cloud Storage, "
+ "BigQuery, or Pub/Sub). Logs retained only in Cloud Logging may be deleted "
+ "within 30-365 days and are not immutably archived."
+ ),
+ severity=Severity.MEDIUM,
+ status=ComplianceStatus.FAIL,
+ domain=CheckDomain.MONITORING,
+ resource_type="GCP::Logging::LogSink",
+ resource_id=f"projects/{project_id}/sinks",
+ region=region,
+ account_id=project_id,
+ cloud_provider=CloudProvider.GCP,
+ remediation=(
+ "Create an export sink: `gcloud logging sinks create audit-export-sink "
+ "storage.googleapis.com/BUCKET_NAME --log-filter='logName:cloudaudit.googleapis.com' "
+ f"--project={project_id}`. Then grant the sink's service account "
+ "`storage.objectCreator` on the destination bucket."
+ ),
+ soc2_controls=["CC7.1"],
+ cis_gcp_controls=["2.2"],
+ iso27001_controls=["A.8.15"],
+ )
+ ]
+
+
+def check_log_metrics_and_alerts(
+ client: GCPClient, project_id: str
+) -> list[Finding]:
+ """[CIS 2.2–2.13] Log-based metrics and alert policies should exist for key audit events.
+
+ CIS GCP requires log-based metrics + Alerting policies for IAM changes, custom role
+ changes, project ownership changes, VPC network changes, firewall changes, etc.
+ This check reports which events are covered and which are missing.
+ """
+ region = "global"
+ try:
+ logging = client.service("logging", "v2")
+ monitoring = client.service("monitoring", "v3")
+
+ metrics_resp = (
+ logging.projects()
+ .metrics()
+ .list(parent=f"projects/{project_id}")
+ .execute()
+ )
+ metrics = metrics_resp.get("metrics", [])
+
+ policies_resp = (
+ monitoring.projects()
+ .alertPolicies()
+ .list(name=f"projects/{project_id}")
+ .execute()
+ )
+ policies = policies_resp.get("alertPolicies", [])
+ except Exception as e:
+ return [
+ Finding.not_assessed(
+ check_id="gcp-log-metric-alerts",
+ title="Unable to list log metrics or alert policies",
+ description=f"API call failed: {e}",
+ domain=CheckDomain.MONITORING,
+ resource_type="GCP::Logging::LogMetric",
+ account_id=project_id,
+ region=region,
+ cloud_provider=CloudProvider.GCP,
+ )
+ ]
+
+ # Build set of filter fragments seen in existing metrics
+ existing_filters = {m.get("filter", "") for m in metrics}
+ # Build set of metric names referenced in alert policies
+ alerted_metrics: set[str] = set()
+ for policy in policies:
+ for condition in policy.get("conditions", []):
+ threshold = condition.get("conditionThreshold", {})
+ for agg in threshold.get("aggregations", []):
+ pass
+ # Check the filter/metric for the condition
+ filter_str = threshold.get("filter", "")
+ if "logging.googleapis.com/user/" in filter_str:
+ metric_name = filter_str.split("logging.googleapis.com/user/")[-1].split('"')[0]
+ alerted_metrics.add(metric_name)
+
+ covered: list[str] = []
+ missing: list[dict[str, str]] = []
+
+ for filter_frag, description, cis_id in _CIS_LOG_METRICS:
+ has_metric = any(filter_frag in f for f in existing_filters)
+ if has_metric:
+ covered.append(f"CIS {cis_id}: {description}")
+ else:
+ missing.append({"cis_id": cis_id, "description": description})
+
+ if not missing:
+ return [
+ Finding(
+ check_id="gcp-log-metric-alerts",
+ title=f"All {len(covered)} required CIS log-based metrics are configured",
+ description="Log-based metrics exist for all required CIS 2.x audit events.",
+ severity=Severity.INFO,
+ status=ComplianceStatus.PASS,
+ domain=CheckDomain.MONITORING,
+ resource_type="GCP::Logging::LogMetric",
+ resource_id=f"projects/{project_id}/metrics",
+ region=region,
+ account_id=project_id,
+ cloud_provider=CloudProvider.GCP,
+ soc2_controls=["CC7.1", "CC7.2"],
+ cis_gcp_controls=["2.2", "2.3", "2.4", "2.5", "2.8", "2.9", "2.10", "2.13"],
+ )
+ ]
+
+ return [
+ Finding(
+ check_id="gcp-log-metric-alerts",
+ title=f"{len(missing)} required CIS log-based metric(s) are missing",
+ description=(
+ f"{len(missing)} CIS-required log-based metrics do not exist. Missing: "
+ + "; ".join(f"CIS {m['cis_id']} ({m['description']})" for m in missing[:5])
+ + (f" and {len(missing)-5} more" if len(missing) > 5 else "")
+ + ". Without these metrics, security events like IAM changes and firewall "
+ "modifications go undetected in real time."
+ ),
+ severity=Severity.MEDIUM,
+ status=ComplianceStatus.FAIL,
+ domain=CheckDomain.MONITORING,
+ resource_type="GCP::Logging::LogMetric",
+ resource_id=f"projects/{project_id}/metrics",
+ region=region,
+ account_id=project_id,
+ cloud_provider=CloudProvider.GCP,
+ remediation=(
+ "Create log-based metrics via the Console (Logging > Log-based Metrics) "
+ "and pair each with an Alerting policy. The CIS GCP benchmark v2.0 Section 2 "
+ "provides the exact filter strings for each required metric."
+ ),
+ soc2_controls=["CC7.1", "CC7.2"],
+ cis_gcp_controls=["2.2", "2.3", "2.4", "2.5", "2.8", "2.9", "2.10", "2.13"],
+ iso27001_controls=["A.8.15", "A.8.16"],
+ details={"missing_metrics": missing, "covered_metrics": covered},
+ )
+ ]
+
+
+def check_log_retention_period(
+ client: GCPClient, project_id: str
+) -> list[Finding]:
+ """Log buckets should retain logs for at least 365 days.
+
+ Cloud Logging default retention is 30 days for most log types. For SOC 2 and
+ HIPAA compliance, 1 year of log retention is standard. This check verifies the
+ _Default and _Required log buckets have sufficient retention configured.
+ """
+ region = "global"
+ MIN_RETENTION_DAYS = 365
+
+ try:
+ logging = client.service("logging", "v2")
+ response = (
+ logging.projects()
+ .locations()
+ .buckets()
+ .list(parent=f"projects/{project_id}/locations/-")
+ .execute()
+ )
+ buckets = response.get("buckets", [])
+ except Exception as e:
+ return [
+ Finding.not_assessed(
+ check_id="gcp-log-retention",
+ title="Unable to list Cloud Logging buckets",
+ description=f"API call failed: {e}",
+ domain=CheckDomain.MONITORING,
+ resource_type="GCP::Logging::LogBucket",
+ account_id=project_id,
+ region=region,
+ cloud_provider=CloudProvider.GCP,
+ )
+ ]
+
+ if not buckets:
+ return []
+
+ insufficient: list[dict[str, Any]] = []
+ for bucket in buckets:
+ bucket_name = bucket.get("name", "").split("/")[-1]
+ retention_days = bucket.get("retentionDays", 30)
+ if retention_days < MIN_RETENTION_DAYS:
+ insufficient.append(
+ {
+ "bucket": bucket_name,
+ "retention_days": retention_days,
+ "required_days": MIN_RETENTION_DAYS,
+ }
+ )
+
+ if not insufficient:
+ return [
+ Finding(
+ check_id="gcp-log-retention",
+ title=f"All Cloud Logging buckets retain logs ≥{MIN_RETENTION_DAYS} days",
+ description=f"All Cloud Logging buckets have retention ≥{MIN_RETENTION_DAYS} days configured.",
+ severity=Severity.INFO,
+ status=ComplianceStatus.PASS,
+ domain=CheckDomain.MONITORING,
+ resource_type="GCP::Logging::LogBucket",
+ resource_id=f"projects/{project_id}/locations/-/buckets",
+ region=region,
+ account_id=project_id,
+ cloud_provider=CloudProvider.GCP,
+ soc2_controls=["CC7.1"],
+ cis_gcp_controls=["2.1"],
+ )
+ ]
+
+ return [
+ Finding(
+ check_id="gcp-log-retention",
+ title=f"{len(insufficient)} Cloud Logging bucket(s) retain logs <{MIN_RETENTION_DAYS} days",
+ description=(
+ f"{len(insufficient)} logging bucket(s) have retention below {MIN_RETENTION_DAYS} "
+ "days. SOC 2 and HIPAA require at least 1 year of audit log retention."
+ ),
+ severity=Severity.MEDIUM,
+ status=ComplianceStatus.FAIL,
+ domain=CheckDomain.MONITORING,
+ resource_type="GCP::Logging::LogBucket",
+ resource_id=f"projects/{project_id}/locations/-/buckets",
+ region=region,
+ account_id=project_id,
+ cloud_provider=CloudProvider.GCP,
+ remediation=(
+ "Update retention: `gcloud logging buckets update BUCKET_NAME "
+ f"--location=LOCATION --retention-days={MIN_RETENTION_DAYS} "
+ f"--project={project_id}`."
+ ),
+ soc2_controls=["CC7.1"],
+ cis_gcp_controls=["2.1"],
+ iso27001_controls=["A.8.15"],
+ hipaa_controls=["164.312(b)"],
+ details={"buckets_with_short_retention": insufficient},
+ )
+ ]
+
+
+def check_log_metric_vpc_network_changes(
+ client: GCPClient, project_id: str
+) -> list[Finding]:
+ """[CIS 2.9] A log-based metric should exist for VPC network configuration changes.
+
+ VPC network changes (creating/deleting networks, routes, peering) are significant
+ security events that should trigger real-time alerts.
+ """
+ return _check_single_log_metric(
+ client=client,
+ project_id=project_id,
+ check_id="gcp-log-metric-vpc-changes",
+ filter_fragment="resource.type=gce_network",
+ description="VPC network configuration changes",
+ cis_id="2.9",
+ )
+
+
+def check_log_metric_firewall_changes(
+ client: GCPClient, project_id: str
+) -> list[Finding]:
+ """[CIS 2.10] A log-based metric should exist for VPC firewall rule changes.
+
+ Unauthorized firewall changes can open the network perimeter. Real-time
+ alerting on firewall mutations is essential for detecting privilege escalation.
+ """
+ return _check_single_log_metric(
+ client=client,
+ project_id=project_id,
+ check_id="gcp-log-metric-firewall-changes",
+ filter_fragment="resource.type=gce_firewall_rule",
+ description="VPC firewall rule changes",
+ cis_id="2.10",
+ )
+
+
+def check_log_metric_custom_role_changes(
+ client: GCPClient, project_id: str
+) -> list[Finding]:
+ """[CIS 2.3] A log-based metric should exist for custom IAM role changes.
+
+ Custom role mutations (adding permissions, creating new roles) are a common
+ privilege escalation technique that should trigger immediate alerts.
+ """
+ return _check_single_log_metric(
+ client=client,
+ project_id=project_id,
+ check_id="gcp-log-metric-custom-role-changes",
+ filter_fragment="resource.type=iam_role",
+ description="custom IAM role changes",
+ cis_id="2.3",
+ )
+
+
+def check_log_metric_project_ownership(
+ client: GCPClient, project_id: str
+) -> list[Finding]:
+ """[CIS 2.4] A log-based metric should exist for project ownership (IAM policy) changes.
+
+ Changes to project-level IAM policy are the most impactful security events —
+ any escalation to Owner gives full project control. These must be alerted on immediately.
+ """
+ return _check_single_log_metric(
+ client=client,
+ project_id=project_id,
+ check_id="gcp-log-metric-project-ownership",
+ filter_fragment="protoPayload.methodName:SetIamPolicy",
+ description="project IAM policy (ownership) changes",
+ cis_id="2.4",
+ )
+
+
+def _check_single_log_metric(
+ client: GCPClient,
+ project_id: str,
+ check_id: str,
+ filter_fragment: str,
+ description: str,
+ cis_id: str,
+) -> list[Finding]:
+ """Shared implementation for single-metric CIS log checks."""
+ region = "global"
+ try:
+ logging = client.service("logging", "v2")
+ response = (
+ logging.projects()
+ .metrics()
+ .list(parent=f"projects/{project_id}")
+ .execute()
+ )
+ metrics = response.get("metrics", [])
+ except Exception as e:
+ return [
+ Finding.not_assessed(
+ check_id=check_id,
+ title=f"Unable to list log metrics for CIS {cis_id} check",
+ description=f"API call failed: {e}",
+ domain=CheckDomain.MONITORING,
+ resource_type="GCP::Logging::LogMetric",
+ account_id=project_id,
+ region=region,
+ cloud_provider=CloudProvider.GCP,
+ )
+ ]
+
+ existing_filters = {m.get("filter", "") for m in metrics}
+ has_metric = any(filter_fragment in f for f in existing_filters)
+
+ if has_metric:
+ return [
+ Finding(
+ check_id=check_id,
+ title=f"Log-based metric for {description} exists [CIS {cis_id}]",
+ description=f"A log-based metric covering {description} is configured.",
+ severity=Severity.INFO,
+ status=ComplianceStatus.PASS,
+ domain=CheckDomain.MONITORING,
+ resource_type="GCP::Logging::LogMetric",
+ resource_id=f"projects/{project_id}/metrics",
+ region=region,
+ account_id=project_id,
+ cloud_provider=CloudProvider.GCP,
+ soc2_controls=["CC7.1", "CC7.2"],
+ cis_gcp_controls=[cis_id],
+ )
+ ]
+
+ return [
+ Finding(
+ check_id=check_id,
+ title=f"No log-based metric for {description} [CIS {cis_id}]",
+ description=(
+ f"No log-based metric covering {description} was found. "
+ f"CIS GCP {cis_id} requires a metric+alert for this event type to "
+ "detect unauthorized changes in real time."
+ ),
+ severity=Severity.MEDIUM,
+ status=ComplianceStatus.FAIL,
+ domain=CheckDomain.MONITORING,
+ resource_type="GCP::Logging::LogMetric",
+ resource_id=f"projects/{project_id}/metrics",
+ region=region,
+ account_id=project_id,
+ cloud_provider=CloudProvider.GCP,
+ remediation=(
+ f"Create a log-based metric with filter containing `{filter_fragment}` "
+ "and pair it with an Alerting policy notification channel. "
+ "See Cloud Logging > Log-based Metrics in the Console."
+ ),
+ soc2_controls=["CC7.1", "CC7.2"],
+ cis_gcp_controls=[cis_id],
+ iso27001_controls=["A.8.15", "A.8.16"],
+ )
+ ]
diff --git a/src/shasta/gcp/networking.py b/src/shasta/gcp/networking.py
new file mode 100644
index 0000000..782344f
--- /dev/null
+++ b/src/shasta/gcp/networking.py
@@ -0,0 +1,643 @@
+"""GCP networking security checks for SOC 2 and CIS GCP Benchmark v2.0.
+
+Covers:
+ CC6.6 — System boundaries (firewall rules, VPC flow logs, network exposure)
+
+CIS GCP v2.0 Section 3 (Networking).
+Regional resources (subnets) use get_enabled_regions() + for_region() per
+Engineering Principle #3.
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+from shasta.gcp.client import GCPClient
+from shasta.evidence.models import (
+ CheckDomain,
+ CloudProvider,
+ ComplianceStatus,
+ Finding,
+ Severity,
+)
+
+IS_GLOBAL = False # Subnet flow-log and Private Google Access checks are per-region
+
+# Ports that should never be open to 0.0.0.0/0 or ::/0 on the internet
+DANGEROUS_PORTS: dict[str, str] = {
+ "22": "SSH",
+ "3389": "RDP",
+ "3306": "MySQL",
+ "5432": "PostgreSQL",
+ "1433": "MSSQL",
+ "27017": "MongoDB",
+ "6379": "Redis",
+ "11211": "Memcached",
+ "9200": "Elasticsearch",
+ "445": "SMB",
+ "23": "Telnet",
+ "21": "FTP",
+}
+
+_INTERNET_RANGES = {"0.0.0.0/0", "::/0"}
+
+
+def run_all_gcp_networking_checks(client: GCPClient) -> list[Finding]:
+ """Run all GCP networking compliance checks.
+
+ Global checks (firewall rules, default network, DNS) run once.
+ Per-region checks (subnet flow logs, Private Google Access) iterate regions.
+ """
+ project_id = client.project_id if client.account_info else (client._project_id or "unknown")
+
+ findings: list[Finding] = []
+
+ # Global checks
+ findings.extend(check_default_network_not_created(client, project_id))
+ findings.extend(check_firewall_no_unrestricted_ssh(client, project_id))
+ findings.extend(check_firewall_no_unrestricted_rdp(client, project_id))
+ findings.extend(check_firewall_no_unrestricted_admin_ports(client, project_id))
+ findings.extend(check_dns_logging_enabled(client, project_id))
+
+ # Regional checks
+ for region in client.get_enabled_regions():
+ regional_client = client.for_region(region)
+ findings.extend(check_subnet_flow_logs_enabled(regional_client, project_id, region))
+ findings.extend(check_private_google_access_enabled(regional_client, project_id, region))
+
+ return findings
+
+
+def check_default_network_not_created(
+ client: GCPClient, project_id: str
+) -> list[Finding]:
+ """[CIS 3.1] The default VPC network should be deleted from all projects.
+
+ The default network comes pre-configured with permissive firewall rules (allow SSH, RDP,
+ ICMP from anywhere) and is shared across all regions. Production workloads should run
+ in custom VPCs with tightly scoped firewall rules.
+ """
+ region = "global"
+ try:
+ compute = client.service("compute", "v1")
+ response = compute.networks().list(project=project_id).execute()
+ networks = response.get("items", [])
+ except Exception as e:
+ return [
+ Finding.not_assessed(
+ check_id="gcp-network-default-not-created",
+ title="Unable to list VPC networks",
+ description=f"API call failed: {e}",
+ domain=CheckDomain.NETWORKING,
+ resource_type="GCP::Compute::Network",
+ account_id=project_id,
+ region=region,
+ cloud_provider=CloudProvider.GCP,
+ )
+ ]
+
+ default_net = next((n for n in networks if n.get("name") == "default"), None)
+
+ if default_net is None:
+ return [
+ Finding(
+ check_id="gcp-network-default-not-created",
+ title="Default VPC network has been deleted",
+ description="The default VPC network does not exist in this project. Custom VPCs are in use.",
+ severity=Severity.INFO,
+ status=ComplianceStatus.PASS,
+ domain=CheckDomain.NETWORKING,
+ resource_type="GCP::Compute::Network",
+ resource_id=f"projects/{project_id}/global/networks",
+ region=region,
+ account_id=project_id,
+ cloud_provider=CloudProvider.GCP,
+ soc2_controls=["CC6.6"],
+ cis_gcp_controls=["3.1"],
+ )
+ ]
+
+ return [
+ Finding(
+ check_id="gcp-network-default-not-created",
+ title="Default VPC network exists in project",
+ description=(
+ "The default VPC network still exists. It ships with permissive firewall rules "
+ "(allow-internal, allow-ssh, allow-rdp, allow-icmp) that apply to all VMs "
+ "in the default network. Production workloads should use custom VPCs."
+ ),
+ severity=Severity.MEDIUM,
+ status=ComplianceStatus.FAIL,
+ domain=CheckDomain.NETWORKING,
+ resource_type="GCP::Compute::Network",
+ resource_id=default_net.get("selfLink", f"projects/{project_id}/global/networks/default"),
+ region=region,
+ account_id=project_id,
+ cloud_provider=CloudProvider.GCP,
+ remediation=(
+ "Delete the default network: `gcloud compute networks delete default --project=PROJECT_ID`. "
+ "Ensure all workloads use custom VPCs with explicit, minimal firewall rules."
+ ),
+ soc2_controls=["CC6.6"],
+ cis_gcp_controls=["3.1"],
+ iso27001_controls=["A.8.20"],
+ )
+ ]
+
+
+def check_firewall_no_unrestricted_ssh(
+ client: GCPClient, project_id: str
+) -> list[Finding]:
+ """[CIS 3.6] No firewall rule should allow SSH (port 22) from 0.0.0.0/0 or ::/0."""
+ return _check_unrestricted_port(client, project_id, "22", "SSH", "gcp-firewall-unrestricted-ssh", "3.6")
+
+
+def check_firewall_no_unrestricted_rdp(
+ client: GCPClient, project_id: str
+) -> list[Finding]:
+ """[CIS 3.7] No firewall rule should allow RDP (port 3389) from 0.0.0.0/0 or ::/0."""
+ return _check_unrestricted_port(client, project_id, "3389", "RDP", "gcp-firewall-unrestricted-rdp", "3.7")
+
+
+def _check_unrestricted_port(
+ client: GCPClient,
+ project_id: str,
+ port: str,
+ service_name: str,
+ check_id: str,
+ cis_id: str,
+) -> list[Finding]:
+ """Shared implementation for unrestricted-port firewall checks."""
+ region = "global"
+ try:
+ compute = client.service("compute", "v1")
+ response = compute.firewalls().list(project=project_id).execute()
+ rules = response.get("items", [])
+ except Exception as e:
+ return [
+ Finding.not_assessed(
+ check_id=check_id,
+ title=f"Unable to list firewall rules for {service_name} check",
+ description=f"API call failed: {e}",
+ domain=CheckDomain.NETWORKING,
+ resource_type="GCP::Compute::Firewall",
+ account_id=project_id,
+ region=region,
+ cloud_provider=CloudProvider.GCP,
+ )
+ ]
+
+ offenders: list[dict[str, Any]] = []
+ for rule in rules:
+ if rule.get("direction") != "INGRESS":
+ continue
+ if rule.get("disabled", False):
+ continue
+ src_ranges = set(rule.get("sourceRanges", []))
+ if not src_ranges.intersection(_INTERNET_RANGES):
+ continue
+ for allowed in rule.get("allowed", []):
+ proto = allowed.get("IPProtocol", "")
+ ports = allowed.get("ports", [])
+ if proto in ("all", "tcp"):
+ if not ports or port in ports or any(
+ "-" in p and int(p.split("-")[0]) <= int(port) <= int(p.split("-")[1])
+ for p in ports
+ if "-" in p
+ ):
+ offenders.append(
+ {
+ "name": rule.get("name"),
+ "source_ranges": list(src_ranges.intersection(_INTERNET_RANGES)),
+ "network": rule.get("network", "").split("/")[-1],
+ }
+ )
+
+ if not offenders:
+ return [
+ Finding(
+ check_id=check_id,
+ title=f"No firewall rules allow unrestricted {service_name} (port {port}) access",
+ description=f"No VPC firewall rules allow {service_name} from 0.0.0.0/0 or ::/0.",
+ severity=Severity.INFO,
+ status=ComplianceStatus.PASS,
+ domain=CheckDomain.NETWORKING,
+ resource_type="GCP::Compute::Firewall",
+ resource_id=f"projects/{project_id}/global/firewalls",
+ region=region,
+ account_id=project_id,
+ cloud_provider=CloudProvider.GCP,
+ soc2_controls=["CC6.6"],
+ cis_gcp_controls=[cis_id],
+ )
+ ]
+
+ return [
+ Finding(
+ check_id=check_id,
+ title=f"{len(offenders)} firewall rule(s) allow unrestricted {service_name} access",
+ description=(
+ f"{len(offenders)} firewall rule(s) allow {service_name} (port {port}) from "
+ f"0.0.0.0/0 or ::/0. This exposes instances to brute-force attacks from "
+ "the entire internet."
+ ),
+ severity=Severity.HIGH,
+ status=ComplianceStatus.FAIL,
+ domain=CheckDomain.NETWORKING,
+ resource_type="GCP::Compute::Firewall",
+ resource_id=f"projects/{project_id}/global/firewalls",
+ region=region,
+ account_id=project_id,
+ cloud_provider=CloudProvider.GCP,
+ remediation=(
+ f"Restrict {service_name} access to known IP ranges using Cloud Identity-Aware "
+ f"Proxy (IAP) for administrative access instead of opening {port} to the internet. "
+ f"To update: `gcloud compute firewall-rules update RULE_NAME "
+ f"--source-ranges=YOUR_IP/32 --project={project_id}`."
+ ),
+ soc2_controls=["CC6.6"],
+ cis_gcp_controls=[cis_id],
+ iso27001_controls=["A.8.20"],
+ details={"offending_rules": offenders[:20]},
+ )
+ ]
+
+
+def check_firewall_no_unrestricted_admin_ports(
+ client: GCPClient, project_id: str
+) -> list[Finding]:
+ """No firewall rule should allow common database/admin ports from 0.0.0.0/0.
+
+ Beyond SSH and RDP, exposing database ports (MySQL 3306, PostgreSQL 5432,
+ MongoDB 27017, etc.) to the internet is a common misconfiguration that
+ leads to data breaches.
+ """
+ region = "global"
+ try:
+ compute = client.service("compute", "v1")
+ response = compute.firewalls().list(project=project_id).execute()
+ rules = response.get("items", [])
+ except Exception as e:
+ return [
+ Finding.not_assessed(
+ check_id="gcp-firewall-admin-ports",
+ title="Unable to list firewall rules for admin-port check",
+ description=f"API call failed: {e}",
+ domain=CheckDomain.NETWORKING,
+ resource_type="GCP::Compute::Firewall",
+ account_id=project_id,
+ region=region,
+ cloud_provider=CloudProvider.GCP,
+ )
+ ]
+
+ offenders: list[dict[str, Any]] = []
+ for rule in rules:
+ if rule.get("direction") != "INGRESS":
+ continue
+ if rule.get("disabled", False):
+ continue
+ src_ranges = set(rule.get("sourceRanges", []))
+ if not src_ranges.intersection(_INTERNET_RANGES):
+ continue
+ for allowed in rule.get("allowed", []):
+ proto = allowed.get("IPProtocol", "")
+ ports = allowed.get("ports", [])
+ if proto not in ("all", "tcp"):
+ continue
+ for danger_port, service in DANGEROUS_PORTS.items():
+ if danger_port in ("22", "3389"):
+ continue # Covered by dedicated checks above
+ if not ports or danger_port in ports or any(
+ "-" in p and int(p.split("-")[0]) <= int(danger_port) <= int(p.split("-")[1])
+ for p in ports
+ if "-" in p
+ ):
+ offenders.append(
+ {
+ "name": rule.get("name"),
+ "port": danger_port,
+ "service": service,
+ "network": rule.get("network", "").split("/")[-1],
+ }
+ )
+
+ if not offenders:
+ return [
+ Finding(
+ check_id="gcp-firewall-admin-ports",
+ title="No firewall rules expose admin/database ports to the internet",
+ description="No VPC firewall rules open database or admin ports to 0.0.0.0/0.",
+ severity=Severity.INFO,
+ status=ComplianceStatus.PASS,
+ domain=CheckDomain.NETWORKING,
+ resource_type="GCP::Compute::Firewall",
+ resource_id=f"projects/{project_id}/global/firewalls",
+ region=region,
+ account_id=project_id,
+ cloud_provider=CloudProvider.GCP,
+ soc2_controls=["CC6.6"],
+ cis_gcp_controls=["3.6"],
+ )
+ ]
+
+ return [
+ Finding(
+ check_id="gcp-firewall-admin-ports",
+ title=f"{len(offenders)} firewall rule(s) expose admin/database ports to internet",
+ description=(
+ f"{len(offenders)} rule(s) expose admin or database ports to 0.0.0.0/0. "
+ "Exposed services: " + ", ".join({o['service'] for o in offenders}) + ". "
+ "These ports should be behind VPN or Cloud IAP, not exposed to the internet."
+ ),
+ severity=Severity.CRITICAL,
+ status=ComplianceStatus.FAIL,
+ domain=CheckDomain.NETWORKING,
+ resource_type="GCP::Compute::Firewall",
+ resource_id=f"projects/{project_id}/global/firewalls",
+ region=region,
+ account_id=project_id,
+ cloud_provider=CloudProvider.GCP,
+ remediation=(
+ "Remove internet-facing firewall rules for database and admin ports. "
+ "Use Cloud VPN or Cloud Interconnect for private connectivity, or Cloud IAP "
+ "for TCP forwarding to internal services."
+ ),
+ soc2_controls=["CC6.6"],
+ cis_gcp_controls=["3.6"],
+ iso27001_controls=["A.8.20"],
+ details={"offending_rules": offenders[:20]},
+ )
+ ]
+
+
+def check_subnet_flow_logs_enabled(
+ client: GCPClient, project_id: str, region: str
+) -> list[Finding]:
+ """[CIS 3.8] VPC subnet flow logs should be enabled for all subnets.
+
+ Flow logs capture metadata about network flows (source, destination, protocol,
+ bytes) and are essential for security investigations, anomaly detection,
+ and compliance audit trails.
+ """
+ try:
+ compute = client.service("compute", "v1")
+ response = compute.subnetworks().list(project=project_id, region=region).execute()
+ subnets = response.get("items", [])
+ except Exception as e:
+ return [
+ Finding.not_assessed(
+ check_id="gcp-subnet-flow-logs",
+ title=f"Unable to list subnets in {region}",
+ description=f"API call failed: {e}",
+ domain=CheckDomain.NETWORKING,
+ resource_type="GCP::Compute::Subnetwork",
+ account_id=project_id,
+ region=region,
+ cloud_provider=CloudProvider.GCP,
+ )
+ ]
+
+ if not subnets:
+ return [] # No subnets in this region — NOT_APPLICABLE at region level is noise
+
+ findings: list[Finding] = []
+ for subnet in subnets:
+ log_config = subnet.get("logConfig") or {}
+ enabled = log_config.get("enable", False)
+ subnet_name = subnet.get("name", "unknown")
+ resource_id = subnet.get("selfLink") or f"projects/{project_id}/regions/{region}/subnetworks/{subnet_name}"
+
+ if enabled:
+ findings.append(
+ Finding(
+ check_id="gcp-subnet-flow-logs",
+ title=f"Subnet {subnet_name} ({region}) has flow logs enabled",
+ description="VPC flow logs are enabled on this subnet.",
+ severity=Severity.INFO,
+ status=ComplianceStatus.PASS,
+ domain=CheckDomain.NETWORKING,
+ resource_type="GCP::Compute::Subnetwork",
+ resource_id=resource_id,
+ region=region,
+ account_id=project_id,
+ cloud_provider=CloudProvider.GCP,
+ soc2_controls=["CC6.6", "CC7.1"],
+ cis_gcp_controls=["3.8"],
+ )
+ )
+ else:
+ findings.append(
+ Finding(
+ check_id="gcp-subnet-flow-logs",
+ title=f"Subnet {subnet_name} ({region}) has flow logs disabled",
+ description=(
+ f"VPC flow logs are not enabled on subnet {subnet_name} in {region}. "
+ "Without flow logs, you cannot investigate network security incidents, "
+ "detect lateral movement, or audit traffic patterns."
+ ),
+ severity=Severity.MEDIUM,
+ status=ComplianceStatus.FAIL,
+ domain=CheckDomain.NETWORKING,
+ resource_type="GCP::Compute::Subnetwork",
+ resource_id=resource_id,
+ region=region,
+ account_id=project_id,
+ cloud_provider=CloudProvider.GCP,
+ remediation=(
+ f"Enable flow logs: `gcloud compute networks subnets update {subnet_name} "
+ f"--region={region} --enable-flow-logs --project={project_id}`. "
+ "Set an appropriate aggregation interval and flow sampling rate (0.5 recommended)."
+ ),
+ soc2_controls=["CC6.6", "CC7.1"],
+ cis_gcp_controls=["3.8"],
+ iso27001_controls=["A.8.15"],
+ )
+ )
+
+ return findings
+
+
+def check_private_google_access_enabled(
+ client: GCPClient, project_id: str, region: str
+) -> list[Finding]:
+ """[CIS 3.9] Private Google Access should be enabled on all subnets.
+
+ Private Google Access lets VMs without external IPs reach Google APIs and
+ services (Cloud Storage, BigQuery, etc.) through the internal Google network
+ rather than over the internet. Without it, VMs need public IPs to use GCP
+ services, expanding your attack surface.
+ """
+ try:
+ compute = client.service("compute", "v1")
+ response = compute.subnetworks().list(project=project_id, region=region).execute()
+ subnets = response.get("items", [])
+ except Exception as e:
+ return [
+ Finding.not_assessed(
+ check_id="gcp-subnet-private-google-access",
+ title=f"Unable to list subnets in {region} for Private Google Access check",
+ description=f"API call failed: {e}",
+ domain=CheckDomain.NETWORKING,
+ resource_type="GCP::Compute::Subnetwork",
+ account_id=project_id,
+ region=region,
+ cloud_provider=CloudProvider.GCP,
+ )
+ ]
+
+ if not subnets:
+ return []
+
+ findings: list[Finding] = []
+ for subnet in subnets:
+ enabled = subnet.get("privateIpGoogleAccess", False)
+ subnet_name = subnet.get("name", "unknown")
+ resource_id = subnet.get("selfLink") or f"projects/{project_id}/regions/{region}/subnetworks/{subnet_name}"
+
+ if enabled:
+ findings.append(
+ Finding(
+ check_id="gcp-subnet-private-google-access",
+ title=f"Subnet {subnet_name} ({region}) has Private Google Access",
+ description="Private Google Access is enabled on this subnet.",
+ severity=Severity.INFO,
+ status=ComplianceStatus.PASS,
+ domain=CheckDomain.NETWORKING,
+ resource_type="GCP::Compute::Subnetwork",
+ resource_id=resource_id,
+ region=region,
+ account_id=project_id,
+ cloud_provider=CloudProvider.GCP,
+ soc2_controls=["CC6.6"],
+ cis_gcp_controls=["3.9"],
+ )
+ )
+ else:
+ findings.append(
+ Finding(
+ check_id="gcp-subnet-private-google-access",
+ title=f"Subnet {subnet_name} ({region}) lacks Private Google Access",
+ description=(
+ f"Private Google Access is not enabled on subnet {subnet_name} in {region}. "
+ "VMs in this subnet need public IPs to reach GCP APIs, expanding "
+ "your network attack surface."
+ ),
+ severity=Severity.LOW,
+ status=ComplianceStatus.FAIL,
+ domain=CheckDomain.NETWORKING,
+ resource_type="GCP::Compute::Subnetwork",
+ resource_id=resource_id,
+ region=region,
+ account_id=project_id,
+ cloud_provider=CloudProvider.GCP,
+ remediation=(
+ f"Enable Private Google Access: `gcloud compute networks subnets update {subnet_name} "
+ f"--region={region} --enable-private-ip-google-access --project={project_id}`."
+ ),
+ soc2_controls=["CC6.6"],
+ cis_gcp_controls=["3.9"],
+ )
+ )
+
+ return findings
+
+
+def check_dns_logging_enabled(
+ client: GCPClient, project_id: str
+) -> list[Finding]:
+ """[CIS 3.10] Cloud DNS logging should be enabled for all managed zones.
+
+ DNS query logs reveal which domains workloads resolve, enabling detection
+ of data exfiltration via DNS tunneling and command-and-control domains.
+ """
+ region = "global"
+ try:
+ dns = client.service("dns", "v1")
+ response = dns.managedZones().list(project=project_id).execute()
+ zones = response.get("managedZones", [])
+ except Exception as e:
+ return [
+ Finding.not_assessed(
+ check_id="gcp-dns-logging",
+ title="Unable to list Cloud DNS managed zones",
+ description=f"API call failed: {e}",
+ domain=CheckDomain.NETWORKING,
+ resource_type="GCP::DNS::ManagedZone",
+ account_id=project_id,
+ region=region,
+ cloud_provider=CloudProvider.GCP,
+ )
+ ]
+
+ if not zones:
+ return [
+ Finding(
+ check_id="gcp-dns-logging",
+ title="No Cloud DNS managed zones in project",
+ description="No Cloud DNS managed zones found — DNS logging check not applicable.",
+ severity=Severity.INFO,
+ status=ComplianceStatus.NOT_APPLICABLE,
+ domain=CheckDomain.NETWORKING,
+ resource_type="GCP::DNS::ManagedZone",
+ resource_id=f"projects/{project_id}/managedZones",
+ region=region,
+ account_id=project_id,
+ cloud_provider=CloudProvider.GCP,
+ soc2_controls=["CC7.1"],
+ cis_gcp_controls=["3.10"],
+ )
+ ]
+
+ missing: list[str] = []
+ for zone in zones:
+ logging_config = zone.get("cloudLoggingConfig") or {}
+ if not logging_config.get("enableLogging", False):
+ missing.append(zone.get("name", "unknown"))
+
+ if not missing:
+ return [
+ Finding(
+ check_id="gcp-dns-logging",
+ title=f"DNS logging enabled on all {len(zones)} managed zone(s)",
+ description="Cloud DNS query logging is enabled on all managed zones.",
+ severity=Severity.INFO,
+ status=ComplianceStatus.PASS,
+ domain=CheckDomain.NETWORKING,
+ resource_type="GCP::DNS::ManagedZone",
+ resource_id=f"projects/{project_id}/managedZones",
+ region=region,
+ account_id=project_id,
+ cloud_provider=CloudProvider.GCP,
+ soc2_controls=["CC7.1"],
+ cis_gcp_controls=["3.10"],
+ )
+ ]
+
+ return [
+ Finding(
+ check_id="gcp-dns-logging",
+ title=f"DNS logging disabled on {len(missing)} managed zone(s)",
+ description=(
+ f"DNS logging is not enabled on {len(missing)} managed zone(s): "
+ f"{', '.join(missing[:10])}. Without DNS logs, DNS tunneling and "
+ "C2 communication to known-malicious domains go undetected."
+ ),
+ severity=Severity.MEDIUM,
+ status=ComplianceStatus.FAIL,
+ domain=CheckDomain.NETWORKING,
+ resource_type="GCP::DNS::ManagedZone",
+ resource_id=f"projects/{project_id}/managedZones",
+ region=region,
+ account_id=project_id,
+ cloud_provider=CloudProvider.GCP,
+ remediation=(
+ "Enable DNS logging: `gcloud dns managed-zones update ZONE_NAME "
+ f"--log-dns-queries --project={project_id}`."
+ ),
+ soc2_controls=["CC7.1"],
+ cis_gcp_controls=["3.10"],
+ iso27001_controls=["A.8.15"],
+ details={"zones_without_logging": missing},
+ )
+ ]
diff --git a/src/shasta/gcp/storage.py b/src/shasta/gcp/storage.py
new file mode 100644
index 0000000..0eef35d
--- /dev/null
+++ b/src/shasta/gcp/storage.py
@@ -0,0 +1,588 @@
+"""GCP Cloud Storage (GCS) security checks for SOC 2 and CIS GCP Benchmark v2.0.
+
+Covers:
+ CC6.7 — Data protection at rest and in transit (bucket access, encryption)
+
+CIS GCP v2.0 Section 5 (Cloud Storage).
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+from shasta.gcp.client import GCPClient
+from shasta.evidence.models import (
+ CheckDomain,
+ CloudProvider,
+ ComplianceStatus,
+ Finding,
+ Severity,
+)
+
+IS_GLOBAL = True # GCS buckets are listed project-wide (not per-region)
+
+
+def run_all_gcp_storage_checks(client: GCPClient) -> list[Finding]:
+ """Run all GCP Cloud Storage compliance checks."""
+ project_id = client.project_id if client.account_info else (client._project_id or "unknown")
+
+ findings: list[Finding] = []
+ findings.extend(check_bucket_no_public_access(client, project_id))
+ findings.extend(check_bucket_uniform_access_enabled(client, project_id))
+ findings.extend(check_bucket_versioning_enabled(client, project_id))
+ findings.extend(check_bucket_access_logging_enabled(client, project_id))
+ findings.extend(check_bucket_retention_policy(client, project_id))
+
+ return findings
+
+
+def _list_buckets(client: GCPClient, project_id: str) -> list[Any]:
+ """Return all GCS buckets in the project, or raise on error."""
+ storage = client.storage_client()
+ return list(storage.list_buckets(project=project_id))
+
+
+def check_bucket_no_public_access(client: GCPClient, project_id: str) -> list[Finding]:
+ """[CIS 5.1] GCS buckets should not allow allUsers or allAuthenticatedUsers.
+
+ Public buckets expose all objects to the internet. Even buckets without sensitive
+ data should not be public unless you are explicitly serving public content from them,
+ because they become attack vectors for storing malicious content.
+ """
+ region = "global"
+ try:
+ buckets = _list_buckets(client, project_id)
+ except Exception as e:
+ return [
+ Finding.not_assessed(
+ check_id="gcp-storage-bucket-public-access",
+ title="Unable to list GCS buckets",
+ description=f"API call failed: {e}",
+ domain=CheckDomain.STORAGE,
+ resource_type="GCP::Storage::Bucket",
+ account_id=project_id,
+ region=region,
+ cloud_provider=CloudProvider.GCP,
+ )
+ ]
+
+ if not buckets:
+ return [
+ Finding(
+ check_id="gcp-storage-bucket-public-access",
+ title="No GCS buckets found in project",
+ description="No Cloud Storage buckets found — check not applicable.",
+ severity=Severity.INFO,
+ status=ComplianceStatus.NOT_APPLICABLE,
+ domain=CheckDomain.STORAGE,
+ resource_type="GCP::Storage::Bucket",
+ resource_id=f"projects/{project_id}",
+ region=region,
+ account_id=project_id,
+ cloud_provider=CloudProvider.GCP,
+ soc2_controls=["CC6.7"],
+ cis_gcp_controls=["5.1"],
+ )
+ ]
+
+ public_buckets: list[dict[str, str]] = []
+ access_errors: list[dict[str, str]] = []
+ for bucket in buckets:
+ try:
+ policy = bucket.get_iam_policy(requested_policy_version=3)
+ for binding in policy.bindings:
+ members = list(binding.get("members", []))
+ if "allUsers" in members or "allAuthenticatedUsers" in members:
+ public_buckets.append({"bucket": bucket.name, "role": binding.get("role", "")})
+ break
+ except Exception as e:
+ access_errors.append({"bucket": getattr(bucket, "name", "unknown"), "error": str(e)})
+ continue
+
+ if not public_buckets and not access_errors:
+ return [
+ Finding(
+ check_id="gcp-storage-bucket-public-access",
+ title=f"All {len(buckets)} GCS bucket(s) are not publicly accessible",
+ description="No GCS buckets grant access to allUsers or allAuthenticatedUsers.",
+ severity=Severity.INFO,
+ status=ComplianceStatus.PASS,
+ domain=CheckDomain.STORAGE,
+ resource_type="GCP::Storage::Bucket",
+ resource_id=f"projects/{project_id}",
+ region=region,
+ account_id=project_id,
+ cloud_provider=CloudProvider.GCP,
+ soc2_controls=["CC6.7"],
+ cis_gcp_controls=["5.1"],
+ )
+ ]
+
+ findings: list[Finding] = []
+ if public_buckets:
+ findings.append(
+ Finding(
+ check_id="gcp-storage-bucket-public-access",
+ title=f"{len(public_buckets)} GCS bucket(s) are publicly accessible",
+ description=(
+ f"{len(public_buckets)} bucket(s) grant access to allUsers or "
+ "allAuthenticatedUsers. These buckets are readable from the internet."
+ ),
+ severity=Severity.CRITICAL,
+ status=ComplianceStatus.FAIL,
+ domain=CheckDomain.STORAGE,
+ resource_type="GCP::Storage::Bucket",
+ resource_id=f"projects/{project_id}",
+ region=region,
+ account_id=project_id,
+ cloud_provider=CloudProvider.GCP,
+ remediation=(
+ "Remove public access: `gcloud storage buckets remove-iam-policy-binding "
+ "gs://BUCKET_NAME --member=allUsers --role=ROLE`. "
+ "Enable 'Public Access Prevention' at the org or project level to block "
+ "future public grants: `gcloud resource-manager org-policies enable-enforce "
+ "constraints/storage.publicAccessPrevention --project=PROJECT_ID`."
+ ),
+ soc2_controls=["CC6.7"],
+ cis_gcp_controls=["5.1"],
+ iso27001_controls=["A.8.3"],
+ hipaa_controls=["164.312(c)(1)"],
+ details={"public_buckets": public_buckets[:20]},
+ )
+ )
+ if access_errors:
+ finding = Finding.not_assessed(
+ check_id="gcp-storage-bucket-public-access",
+ title="Some GCS bucket IAM policies could not be assessed",
+ description=(
+ f"Unable to read IAM policy for {len(access_errors)} bucket(s). "
+ "The scan cannot prove every bucket is non-public."
+ ),
+ domain=CheckDomain.STORAGE,
+ resource_type="GCP::Storage::Bucket",
+ account_id=project_id,
+ region=region,
+ cloud_provider=CloudProvider.GCP,
+ )
+ finding.details["access_errors"] = access_errors[:20]
+ findings.append(finding)
+ return findings
+
+
+def check_bucket_uniform_access_enabled(client: GCPClient, project_id: str) -> list[Finding]:
+ """[CIS 5.2] Uniform bucket-level access should be enabled on all GCS buckets.
+
+ When uniform access is disabled, individual objects can have ACLs that override
+ the bucket-level IAM policy. This creates a complex permission model that is
+ difficult to audit and easy to misconfigure, leaving objects publicly readable
+ without the bucket policy showing it.
+ """
+ region = "global"
+ try:
+ buckets = _list_buckets(client, project_id)
+ except Exception as e:
+ return [
+ Finding.not_assessed(
+ check_id="gcp-storage-uniform-access",
+ title="Unable to list GCS buckets for uniform access check",
+ description=f"API call failed: {e}",
+ domain=CheckDomain.STORAGE,
+ resource_type="GCP::Storage::Bucket",
+ account_id=project_id,
+ region=region,
+ cloud_provider=CloudProvider.GCP,
+ )
+ ]
+
+ if not buckets:
+ return []
+
+ missing: list[str] = []
+ access_errors: list[dict[str, str]] = []
+ for bucket in buckets:
+ try:
+ iam_config = bucket.iam_configuration
+ if not getattr(iam_config, "uniform_bucket_level_access_enabled", False):
+ missing.append(bucket.name)
+ except Exception as e:
+ access_errors.append({"bucket": getattr(bucket, "name", "unknown"), "error": str(e)})
+ continue
+
+ if not missing and not access_errors:
+ return [
+ Finding(
+ check_id="gcp-storage-uniform-access",
+ title=f"All {len(buckets)} GCS bucket(s) have uniform bucket-level access",
+ description="Uniform bucket-level access is enabled on all GCS buckets.",
+ severity=Severity.INFO,
+ status=ComplianceStatus.PASS,
+ domain=CheckDomain.STORAGE,
+ resource_type="GCP::Storage::Bucket",
+ resource_id=f"projects/{project_id}",
+ region=region,
+ account_id=project_id,
+ cloud_provider=CloudProvider.GCP,
+ soc2_controls=["CC6.7"],
+ cis_gcp_controls=["5.2"],
+ )
+ ]
+
+ findings: list[Finding] = []
+ if missing:
+ findings.append(
+ Finding(
+ check_id="gcp-storage-uniform-access",
+ title=f"{len(missing)} GCS bucket(s) have ACL-based (non-uniform) access",
+ description=(
+ f"{len(missing)} bucket(s) do not have uniform bucket-level access enabled: "
+ f"{', '.join(missing[:10])}{'...' if len(missing) > 10 else ''}. "
+ "Object-level ACLs can bypass bucket IAM policies, making access control "
+ "hard to audit."
+ ),
+ severity=Severity.MEDIUM,
+ status=ComplianceStatus.FAIL,
+ domain=CheckDomain.STORAGE,
+ resource_type="GCP::Storage::Bucket",
+ resource_id=f"projects/{project_id}",
+ region=region,
+ account_id=project_id,
+ cloud_provider=CloudProvider.GCP,
+ remediation=(
+ "Enable uniform access: `gcloud storage buckets update gs://BUCKET_NAME "
+ "--uniform-bucket-level-access`. Note: once enabled for 90+ days, this "
+ "cannot be reverted."
+ ),
+ soc2_controls=["CC6.7"],
+ cis_gcp_controls=["5.2"],
+ details={"buckets_without_uniform_access": missing},
+ )
+ )
+ if access_errors:
+ finding = Finding.not_assessed(
+ check_id="gcp-storage-uniform-access",
+ title="Some GCS bucket access modes could not be assessed",
+ description=(
+ f"Unable to read uniform bucket-level access settings for "
+ f"{len(access_errors)} bucket(s)."
+ ),
+ domain=CheckDomain.STORAGE,
+ resource_type="GCP::Storage::Bucket",
+ account_id=project_id,
+ region=region,
+ cloud_provider=CloudProvider.GCP,
+ )
+ finding.details["access_errors"] = access_errors[:20]
+ findings.append(finding)
+ return findings
+
+
+def check_bucket_versioning_enabled(client: GCPClient, project_id: str) -> list[Finding]:
+ """GCS buckets storing important data should have versioning enabled.
+
+ Versioning protects against accidental or malicious deletion and overwrites.
+ Without versioning, a ransomware attack or accidental `gsutil rm` can destroy
+ data permanently.
+ """
+ region = "global"
+ try:
+ buckets = _list_buckets(client, project_id)
+ except Exception as e:
+ return [
+ Finding.not_assessed(
+ check_id="gcp-storage-versioning",
+ title="Unable to list GCS buckets for versioning check",
+ description=f"API call failed: {e}",
+ domain=CheckDomain.STORAGE,
+ resource_type="GCP::Storage::Bucket",
+ account_id=project_id,
+ region=region,
+ cloud_provider=CloudProvider.GCP,
+ )
+ ]
+
+ if not buckets:
+ return []
+
+ missing: list[str] = []
+ access_errors: list[dict[str, str]] = []
+ for bucket in buckets:
+ try:
+ if not bucket.versioning_enabled:
+ missing.append(bucket.name)
+ except Exception as e:
+ access_errors.append({"bucket": getattr(bucket, "name", "unknown"), "error": str(e)})
+ continue
+
+ if not missing and not access_errors:
+ return [
+ Finding(
+ check_id="gcp-storage-versioning",
+ title=f"All {len(buckets)} GCS bucket(s) have versioning enabled",
+ description="Object versioning is enabled on all GCS buckets.",
+ severity=Severity.INFO,
+ status=ComplianceStatus.PASS,
+ domain=CheckDomain.STORAGE,
+ resource_type="GCP::Storage::Bucket",
+ resource_id=f"projects/{project_id}",
+ region=region,
+ account_id=project_id,
+ cloud_provider=CloudProvider.GCP,
+ soc2_controls=["CC6.7"],
+ cis_gcp_controls=["5.3"],
+ )
+ ]
+
+ findings: list[Finding] = []
+ if missing:
+ findings.append(
+ Finding(
+ check_id="gcp-storage-versioning",
+ title=f"{len(missing)} GCS bucket(s) do not have versioning enabled",
+ description=(
+ f"{len(missing)} bucket(s) do not have object versioning: "
+ f"{', '.join(missing[:10])}{'...' if len(missing) > 10 else ''}. "
+ "Without versioning, deleted or overwritten objects cannot be recovered."
+ ),
+ severity=Severity.MEDIUM,
+ status=ComplianceStatus.FAIL,
+ domain=CheckDomain.STORAGE,
+ resource_type="GCP::Storage::Bucket",
+ resource_id=f"projects/{project_id}",
+ region=region,
+ account_id=project_id,
+ cloud_provider=CloudProvider.GCP,
+ remediation=(
+ "Enable versioning: `gcloud storage buckets update gs://BUCKET_NAME "
+ "--versioning`. Add a lifecycle rule to expire old versions after N days "
+ "to control storage costs."
+ ),
+ soc2_controls=["CC6.7"],
+ cis_gcp_controls=["5.3"],
+ details={"buckets_without_versioning": missing},
+ )
+ )
+ if access_errors:
+ finding = Finding.not_assessed(
+ check_id="gcp-storage-versioning",
+ title="Some GCS bucket versioning settings could not be assessed",
+ description=f"Unable to read versioning settings for {len(access_errors)} bucket(s).",
+ domain=CheckDomain.STORAGE,
+ resource_type="GCP::Storage::Bucket",
+ account_id=project_id,
+ region=region,
+ cloud_provider=CloudProvider.GCP,
+ )
+ finding.details["access_errors"] = access_errors[:20]
+ findings.append(finding)
+ return findings
+
+
+def check_bucket_access_logging_enabled(client: GCPClient, project_id: str) -> list[Finding]:
+ """GCS buckets should have access logging enabled.
+
+ Access logs record every GET/PUT/DELETE against the bucket, providing an
+ audit trail required for SOC 2 CC7.1 and ISO 27001 A.8.15 (logging of events).
+ """
+ region = "global"
+ try:
+ buckets = _list_buckets(client, project_id)
+ except Exception as e:
+ return [
+ Finding.not_assessed(
+ check_id="gcp-storage-access-logging",
+ title="Unable to list GCS buckets for access logging check",
+ description=f"API call failed: {e}",
+ domain=CheckDomain.STORAGE,
+ resource_type="GCP::Storage::Bucket",
+ account_id=project_id,
+ region=region,
+ cloud_provider=CloudProvider.GCP,
+ )
+ ]
+
+ if not buckets:
+ return []
+
+ missing: list[str] = []
+ access_errors: list[dict[str, str]] = []
+ for bucket in buckets:
+ try:
+ logging_cfg = (
+ bucket.get_logging_config()
+ if callable(getattr(bucket, "get_logging_config", None))
+ else None
+ )
+ if not logging_cfg:
+ # Fallback: check via reload
+ bucket.reload()
+ logging_cfg = bucket._properties.get("logging") # type: ignore[attr-defined]
+ if not logging_cfg:
+ missing.append(bucket.name)
+ except Exception as e:
+ access_errors.append({"bucket": getattr(bucket, "name", "unknown"), "error": str(e)})
+
+ if not missing and not access_errors:
+ return [
+ Finding(
+ check_id="gcp-storage-access-logging",
+ title=f"All {len(buckets)} GCS bucket(s) have access logging configured",
+ description="Access logging is configured on all GCS buckets.",
+ severity=Severity.INFO,
+ status=ComplianceStatus.PASS,
+ domain=CheckDomain.STORAGE,
+ resource_type="GCP::Storage::Bucket",
+ resource_id=f"projects/{project_id}",
+ region=region,
+ account_id=project_id,
+ cloud_provider=CloudProvider.GCP,
+ soc2_controls=["CC7.1"],
+ cis_gcp_controls=["5.4"],
+ )
+ ]
+
+ findings: list[Finding] = []
+ if missing:
+ findings.append(
+ Finding(
+ check_id="gcp-storage-access-logging",
+ title=f"{len(missing)} GCS bucket(s) lack access logging",
+ description=(
+ f"{len(missing)} bucket(s) do not have access logging enabled: "
+ f"{', '.join(missing[:10])}{'...' if len(missing) > 10 else ''}. "
+ "Without access logs, you cannot audit who accessed, modified, or deleted objects."
+ ),
+ severity=Severity.MEDIUM,
+ status=ComplianceStatus.FAIL,
+ domain=CheckDomain.STORAGE,
+ resource_type="GCP::Storage::Bucket",
+ resource_id=f"projects/{project_id}",
+ region=region,
+ account_id=project_id,
+ cloud_provider=CloudProvider.GCP,
+ remediation=(
+ "Enable access logging by pointing the bucket at a dedicated log bucket: "
+ "`gcloud storage buckets update gs://BUCKET_NAME "
+ "--log-bucket=LOG_BUCKET_NAME --log-object-prefix=PREFIX`."
+ ),
+ soc2_controls=["CC7.1"],
+ cis_gcp_controls=["5.4"],
+ iso27001_controls=["A.8.15"],
+ details={"buckets_without_access_logging": missing},
+ )
+ )
+ if access_errors:
+ finding = Finding.not_assessed(
+ check_id="gcp-storage-access-logging",
+ title="Some GCS bucket access logging settings could not be assessed",
+ description=f"Unable to read access logging settings for {len(access_errors)} bucket(s).",
+ domain=CheckDomain.STORAGE,
+ resource_type="GCP::Storage::Bucket",
+ account_id=project_id,
+ region=region,
+ cloud_provider=CloudProvider.GCP,
+ )
+ finding.details["access_errors"] = access_errors[:20]
+ findings.append(finding)
+ return findings
+
+
+def check_bucket_retention_policy(client: GCPClient, project_id: str) -> list[Finding]:
+ """GCS buckets that store audit logs or compliance data should have a retention policy.
+
+ A retention policy prevents objects from being deleted or overwritten before a
+ minimum retention period expires. A locked retention policy makes this immutable —
+ not even a storage admin can delete objects before the policy expires.
+ """
+ region = "global"
+ try:
+ buckets = _list_buckets(client, project_id)
+ except Exception as e:
+ return [
+ Finding.not_assessed(
+ check_id="gcp-storage-retention-policy",
+ title="Unable to list GCS buckets for retention policy check",
+ description=f"API call failed: {e}",
+ domain=CheckDomain.STORAGE,
+ resource_type="GCP::Storage::Bucket",
+ account_id=project_id,
+ region=region,
+ cloud_provider=CloudProvider.GCP,
+ )
+ ]
+
+ if not buckets:
+ return []
+
+ missing: list[str] = []
+ access_errors: list[dict[str, str]] = []
+ for bucket in buckets:
+ try:
+ retention = bucket.retention_policy
+ if not retention or not getattr(retention, "retention_period", None):
+ missing.append(bucket.name)
+ except Exception as e:
+ access_errors.append({"bucket": getattr(bucket, "name", "unknown"), "error": str(e)})
+
+ if not missing and not access_errors:
+ return [
+ Finding(
+ check_id="gcp-storage-retention-policy",
+ title=f"All {len(buckets)} GCS bucket(s) have a retention policy set",
+ description="Retention policies are configured on all GCS buckets.",
+ severity=Severity.INFO,
+ status=ComplianceStatus.PASS,
+ domain=CheckDomain.STORAGE,
+ resource_type="GCP::Storage::Bucket",
+ resource_id=f"projects/{project_id}",
+ region=region,
+ account_id=project_id,
+ cloud_provider=CloudProvider.GCP,
+ soc2_controls=["CC6.7", "CC7.1"],
+ cis_gcp_controls=["5.5"],
+ )
+ ]
+
+ findings: list[Finding] = []
+ if missing:
+ findings.append(
+ Finding(
+ check_id="gcp-storage-retention-policy",
+ title=f"{len(missing)} GCS bucket(s) have no retention policy",
+ description=(
+ f"{len(missing)} bucket(s) lack a retention policy: "
+ f"{', '.join(missing[:10])}{'...' if len(missing) > 10 else ''}. "
+ "Buckets storing audit logs or compliance artifacts should have a minimum "
+ "retention period to prevent premature deletion."
+ ),
+ severity=Severity.LOW,
+ status=ComplianceStatus.PARTIAL,
+ domain=CheckDomain.STORAGE,
+ resource_type="GCP::Storage::Bucket",
+ resource_id=f"projects/{project_id}",
+ region=region,
+ account_id=project_id,
+ cloud_provider=CloudProvider.GCP,
+ remediation=(
+ "Set a retention policy: `gcloud storage buckets update gs://BUCKET_NAME "
+ "--retention-period=2678400s` (31 days). For audit log buckets, use a "
+ "longer period (e.g., 365 days) and consider locking the policy."
+ ),
+ soc2_controls=["CC6.7", "CC7.1"],
+ cis_gcp_controls=["5.5"],
+ details={"buckets_without_retention": missing},
+ )
+ )
+ if access_errors:
+ finding = Finding.not_assessed(
+ check_id="gcp-storage-retention-policy",
+ title="Some GCS bucket retention policies could not be assessed",
+ description=f"Unable to read retention policy settings for {len(access_errors)} bucket(s).",
+ domain=CheckDomain.STORAGE,
+ resource_type="GCP::Storage::Bucket",
+ account_id=project_id,
+ region=region,
+ cloud_provider=CloudProvider.GCP,
+ )
+ finding.details["access_errors"] = access_errors[:20]
+ findings.append(finding)
+ return findings
diff --git a/src/shasta/remediation/engine.py b/src/shasta/remediation/engine.py
index 0769a21..962a157 100644
--- a/src/shasta/remediation/engine.py
+++ b/src/shasta/remediation/engine.py
@@ -2447,6 +2447,728 @@ def _tf_az_security_initiative(f: Finding) -> str:
}'''
+# ---------------------------------------------------------------------------
+# GCP Terraform templates (google provider)
+# ---------------------------------------------------------------------------
+
+
+@_tf("gcp-iam-primitive-roles")
+def _tf_gcp_primitive_roles(f: Finding) -> str:
+ project = f.account_id or "PROJECT_ID"
+ return f'''\
+# Remove primitive Owner/Editor roles and replace with purpose-specific roles.
+# First audit current bindings:
+# gcloud projects get-iam-policy {project} --format=json | jq '.bindings[] | select(.role | test("roles/(owner|editor)"))'
+
+data "google_project" "project" {{
+ project_id = "{project}"
+}}
+
+# Example: grant a scoped role to a specific user instead of Editor
+resource "google_project_iam_member" "scoped_role" {{
+ project = "{project}"
+ role = "roles/storage.objectViewer" # Replace with the minimum needed role
+ member = "user:REPLACE_WITH_USER_EMAIL"
+}}
+
+# Remove the primitive role binding (use google_project_iam_binding to manage
+# the full membership list, or google_project_iam_member with lifecycle remove):
+resource "google_project_iam_binding" "remove_editor" {{
+ project = "{project}"
+ role = "roles/editor"
+ members = [] # Empty = remove all editor bindings
+
+ lifecycle {{
+ prevent_destroy = false
+ }}
+}}
+# IMPORTANT: Review and test before applying — removing Editor may break services
+# that rely on broad permissions. Use IAM Recommender to generate least-privilege replacements.'''
+
+
+@_tf("gcp-iam-service-account-not-admin")
+def _tf_gcp_sa_not_admin(f: Finding) -> str:
+ project = f.account_id or "PROJECT_ID"
+ offenders = f.details.get("offenders", [])
+ sa_email = offenders[0]["member"].replace("serviceAccount:", "") if offenders else "SA_EMAIL@PROJECT.iam.gserviceaccount.com"
+ role = offenders[0].get("role", "roles/editor") if offenders else "roles/editor"
+ return f'''\
+# Remove admin role from service account and replace with scoped role.
+
+# Step 1: Remove the broad role
+resource "google_project_iam_member" "remove_sa_admin" {{
+ project = "{project}"
+ role = "{role}"
+ member = "serviceAccount:{sa_email}"
+
+ lifecycle {{
+ prevent_destroy = false
+ }}
+}}
+
+# Step 2: Grant only what the SA actually needs (example for Cloud Storage access):
+resource "google_project_iam_member" "sa_scoped" {{
+ project = "{project}"
+ role = "roles/storage.objectAdmin" # Customize to minimum required role
+ member = "serviceAccount:{sa_email}"
+}}
+
+# Run IAM Recommender to get suggestions:
+# gcloud recommender recommendations list \\
+# --project={project} --recommender=google.iam.policy.Recommender \\
+# --location=global --format=json'''
+
+
+@_tf("gcp-firewall-unrestricted-ssh")
+def _tf_gcp_firewall_ssh(f: Finding) -> str:
+ project = f.account_id or "PROJECT_ID"
+ offenders = f.details.get("offending_rules", [])
+ rule_name = offenders[0]["name"] if offenders else "default-allow-ssh"
+ network = offenders[0].get("network", "default") if offenders else "default"
+ return f'''\
+# Replace unrestricted SSH rule with IAP-based access.
+# IAP TCP forwarding eliminates the need to open port 22 to the internet.
+
+# Step 1: Remove the unrestricted rule
+resource "google_compute_firewall" "deny_ssh_world" {{
+ name = "{rule_name}-restricted"
+ network = "{network}"
+ project = "{project}"
+
+ deny {{
+ protocol = "tcp"
+ ports = ["22"]
+ }}
+
+ source_ranges = ["0.0.0.0/0"]
+ priority = 900
+}}
+
+# Step 2: Allow SSH only from IAP (Google's managed proxy range)
+resource "google_compute_firewall" "allow_ssh_iap" {{
+ name = "allow-ssh-from-iap"
+ network = "{network}"
+ project = "{project}"
+
+ allow {{
+ protocol = "tcp"
+ ports = ["22"]
+ }}
+
+ # IAP's source IP range — only allow SSH from IAP tunnel
+ source_ranges = ["35.235.240.0/20"]
+ target_tags = ["allow-iap-ssh"] # Apply to specific VMs
+ priority = 1000
+}}
+
+# Use IAP to SSH: gcloud compute ssh INSTANCE_NAME --tunnel-through-iap --project={project}'''
+
+
+@_tf("gcp-firewall-unrestricted-rdp")
+def _tf_gcp_firewall_rdp(f: Finding) -> str:
+ project = f.account_id or "PROJECT_ID"
+ offenders = f.details.get("offending_rules", [])
+ rule_name = offenders[0]["name"] if offenders else "default-allow-rdp"
+ network = offenders[0].get("network", "default") if offenders else "default"
+ return f'''\
+# Replace unrestricted RDP rule with IAP-based access.
+
+resource "google_compute_firewall" "deny_rdp_world" {{
+ name = "{rule_name}-restricted"
+ network = "{network}"
+ project = "{project}"
+
+ deny {{
+ protocol = "tcp"
+ ports = ["3389"]
+ }}
+
+ source_ranges = ["0.0.0.0/0"]
+ priority = 900
+}}
+
+# Allow RDP only through IAP TCP forwarding
+resource "google_compute_firewall" "allow_rdp_iap" {{
+ name = "allow-rdp-from-iap"
+ network = "{network}"
+ project = "{project}"
+
+ allow {{
+ protocol = "tcp"
+ ports = ["3389"]
+ }}
+
+ source_ranges = ["35.235.240.0/20"] # IAP range
+ target_tags = ["allow-iap-rdp"]
+ priority = 1000
+}}
+
+# Connect via IAP: gcloud compute start-iap-tunnel INSTANCE_NAME 3389 --local-host-port=localhost:3389'''
+
+
+@_tf("gcp-vpc-flow-logs")
+def _tf_gcp_flow_logs(f: Finding) -> str:
+ project = f.account_id or "PROJECT_ID"
+ region = f.region or "us-central1"
+ missing = f.details.get("subnets_without_flow_logs", ["SUBNET_NAME"])
+ subnet = missing[0] if missing else "SUBNET_NAME"
+ network = "default"
+ return f'''\
+# Enable VPC flow logs on subnets in {region}.
+
+resource "google_compute_subnetwork" "{subnet.replace("-", "_")}_flow_logs" {{
+ name = "{subnet}"
+ ip_cidr_range = "10.0.0.0/24" # Keep existing CIDR — only updating log_config
+ region = "{region}"
+ network = "projects/{project}/global/networks/{network}"
+ project = "{project}"
+
+ log_config {{
+ aggregation_interval = "INTERVAL_5_SEC"
+ flow_sampling = 0.5
+ metadata = "INCLUDE_ALL_METADATA"
+ }}
+}}
+
+# For all subnets without flow logs, use gcloud:
+# for subnet in {' '.join(missing[:5])}; do
+# gcloud compute networks subnets update $subnet \\
+# --region={region} --enable-flow-logs \\
+# --logging-aggregation-interval=interval-5-sec \\
+# --logging-flow-sampling=0.5 --project={project}
+# done'''
+
+
+@_tf("gcp-kms-key-rotation")
+def _tf_gcp_kms_rotation(f: Finding) -> str:
+ project = f.account_id or "PROJECT_ID"
+ keys_info = f.details.get("keys_with_issues", [])
+ key_name = keys_info[0]["key"].split("/")[-1] if keys_info else "my-key"
+ keyring = keys_info[0]["key"].split("/keyRings/")[1].split("/")[0] if keys_info and "/keyRings/" in keys_info[0].get("key", "") else "my-keyring"
+ location = keys_info[0]["key"].split("/locations/")[1].split("/")[0] if keys_info and "/locations/" in keys_info[0].get("key", "") else "global"
+ return f'''\
+# Configure KMS key rotation period to ≤90 days (7,776,000 seconds).
+
+resource "google_kms_key_ring" "keyring" {{
+ name = "{keyring}"
+ location = "{location}"
+ project = "{project}"
+}}
+
+resource "google_kms_crypto_key" "{key_name.replace("-", "_")}" {{
+ name = "{key_name}"
+ key_ring = google_kms_key_ring.keyring.id
+ rotation_period = "7776000s" # 90 days
+
+ lifecycle {{
+ prevent_destroy = true
+ }}
+}}
+
+# For existing keys, set rotation via gcloud:
+# gcloud kms keys update {key_name} \\
+# --keyring={keyring} --location={location} \\
+# --rotation-period=7776000s \\
+# --next-rotation-time=$(date -d "+1 day" --iso-8601=seconds) \\
+# --project={project}'''
+
+
+@_tf("gcp-storage-bucket-public-access")
+def _tf_gcp_bucket_public(f: Finding) -> str:
+ project = f.account_id or "PROJECT_ID"
+ pub_buckets = f.details.get("public_buckets", [])
+ bucket_name = pub_buckets[0]["bucket"] if pub_buckets else "my-bucket"
+ return f'''\
+# Remove public access from GCS bucket and enable Public Access Prevention.
+
+resource "google_storage_bucket_iam_binding" "remove_public_{bucket_name.replace("-", "_")}" {{
+ bucket = "{bucket_name}"
+ role = "roles/storage.objectViewer"
+ members = [] # Empty list removes all members from this role binding
+}}
+
+# Enable Public Access Prevention at the project level (prevents future public grants):
+resource "google_project_organization_policy" "public_access_prevention" {{
+ project = "{project}"
+ constraint = "constraints/storage.publicAccessPrevention"
+
+ boolean_policy {{
+ enforced = true
+ }}
+}}
+
+# Remove public access via gcloud:
+# gcloud storage buckets remove-iam-policy-binding gs://{bucket_name} \\
+# --member=allUsers --role=roles/storage.objectViewer --project={project}'''
+
+
+@_tf("gcp-storage-uniform-access")
+def _tf_gcp_bucket_uniform(f: Finding) -> str:
+ missing = f.details.get("buckets_without_uniform_access", ["my-bucket"])
+ bucket = missing[0] if missing else "my-bucket"
+ return f'''\
+# Enable uniform bucket-level access on GCS bucket.
+# Note: once enabled for 90+ days, this setting cannot be reverted.
+
+resource "google_storage_bucket" "{bucket.replace("-", "_")}_uniform" {{
+ name = "{bucket}"
+ location = "US" # Keep existing location
+
+ uniform_bucket_level_access = true
+}}
+
+# Via gcloud:
+# gcloud storage buckets update gs://{bucket} --uniform-bucket-level-access'''
+
+
+@_tf("gcp-logging-audit-config")
+def _tf_gcp_audit_config(f: Finding) -> str:
+ project = f.account_id or "PROJECT_ID"
+ missing_types = f.details.get("missing_log_types", ["DATA_READ", "DATA_WRITE"])
+ return f'''\
+# Enable Data Access audit logs (DATA_READ + DATA_WRITE) for all GCP services.
+# WARNING: This significantly increases log volume. Budget ~$X/GB for Cloud Logging.
+
+resource "google_project_iam_audit_config" "all_services" {{
+ project = "{project}"
+ service = "allServices"
+
+ audit_log_config {{
+ log_type = "ADMIN_READ"
+ }}
+
+ audit_log_config {{
+ log_type = "DATA_READ"
+ }}
+
+ audit_log_config {{
+ log_type = "DATA_WRITE"
+ }}
+}}
+
+# Missing log types: {", ".join(missing_types)}
+# Note: DATA_READ generates high volume — consider enabling per-service
+# for high-sensitivity services (Cloud Storage, BigQuery, Cloud SQL) first.'''
+
+
+@_tf("gcp-logging-metric-alerting")
+def _tf_gcp_log_metric(f: Finding) -> str:
+ project = f.account_id or "PROJECT_ID"
+ missing = f.details.get("missing_metrics", [])
+ cis_id = missing[0]["cis_id"] if missing else "2.x"
+ desc = missing[0]["description"] if missing else "security events"
+ return f'''\
+# Create log-based metric and alert policy for {desc} (CIS {cis_id}).
+
+resource "google_logging_metric" "security_metric_{cis_id.replace(".", "_")}" {{
+ name = "cis-{cis_id.replace(".", "-")}-{desc.replace(" ", "-")[:30]}"
+ filter = "resource.type=gce_firewall_rule" # Update with correct CIS filter
+ description = "CIS {cis_id}: {desc}"
+ project = "{project}"
+
+ metric_descriptor {{
+ metric_kind = "DELTA"
+ value_type = "INT64"
+ display_name = "CIS {cis_id} {desc}"
+ }}
+}}
+
+resource "google_monitoring_notification_channel" "email_channel" {{
+ display_name = "Security Alerts Email"
+ type = "email"
+ project = "{project}"
+
+ labels = {{
+ email_address = "security@YOURDOMAIN.com" # Update with your email
+ }}
+}}
+
+resource "google_monitoring_alert_policy" "cis_{cis_id.replace(".", "_")}_alert" {{
+ display_name = "CIS {cis_id}: {desc}"
+ combiner = "OR"
+ project = "{project}"
+
+ conditions {{
+ display_name = "{desc}"
+
+ condition_threshold {{
+ filter = "metric.type=\\"logging.googleapis.com/user/cis-{cis_id.replace(".", "-")}\\" AND resource.type=\\"global\\""
+ duration = "0s"
+ comparison = "COMPARISON_GT"
+ threshold_value = 0
+
+ aggregations {{
+ alignment_period = "60s"
+ per_series_aligner = "ALIGN_COUNT"
+ }}
+ }}
+ }}
+
+ notification_channels = [google_monitoring_notification_channel.email_channel.name]
+}}'''
+
+
+@_tf("gcp-compute-os-login")
+def _tf_gcp_os_login(f: Finding) -> str:
+ project = f.account_id or "PROJECT_ID"
+ return f'''\
+# Enable OS Login at the project level.
+
+resource "google_compute_project_metadata_item" "os_login" {{
+ project = "{project}"
+ key = "enable-oslogin"
+ value = "TRUE"
+}}
+
+# Grant users SSH access via IAM roles:
+# Non-admin SSH: roles/compute.osLogin
+# Admin SSH (sudo): roles/compute.osAdminLogin
+
+resource "google_project_iam_member" "os_login_user" {{
+ project = "{project}"
+ role = "roles/compute.osLogin"
+ member = "user:REPLACE_WITH_USER_EMAIL"
+}}
+
+# Disable SSH key-based access for existing instances by removing
+# project-level SSH keys from project metadata (if any are set).'''
+
+
+@_tf("gcp-compute-serial-port")
+def _tf_gcp_serial_port(f: Finding) -> str:
+ project = f.account_id or "PROJECT_ID"
+ return f'''\
+# Disable serial port access at the project level.
+
+resource "google_compute_project_metadata_item" "disable_serial_port" {{
+ project = "{project}"
+ key = "serial-port-enable"
+ value = "false"
+}}
+
+# Also enforce via Organization Policy (prevents instance-level override):
+resource "google_project_organization_policy" "no_serial_port" {{
+ project = "{project}"
+ constraint = "constraints/compute.disableSerialPortAccess"
+
+ boolean_policy {{
+ enforced = true
+ }}
+}}'''
+
+
+@_tf("gcp-sql-require-ssl")
+def _tf_gcp_sql_ssl(f: Finding) -> str:
+ project = f.account_id or "PROJECT_ID"
+ instances = f.details.get("instances_without_ssl", [])
+ inst_name = instances[0]["instance"] if instances else "my-db-instance"
+ return f'''\
+# Enforce SSL on Cloud SQL instance.
+
+resource "google_sql_database_instance" "{inst_name.replace("-", "_")}" {{
+ name = "{inst_name}"
+ project = "{project}"
+ # Keep existing region/version — only updating settings
+
+ settings {{
+ tier = "db-f1-micro" # Keep existing tier
+
+ ip_configuration {{
+ require_ssl = true
+ }}
+ }}
+
+ deletion_protection = true
+}}
+
+# Create client SSL certificate:
+resource "google_sql_ssl_cert" "client_cert" {{
+ common_name = "client-cert"
+ instance = "{inst_name}"
+ project = "{project}"
+}}
+
+# Update connection string in your app to use SSL:
+# Host={inst_name} SSLMode=verify-ca SSLCert=client.crt SSLKey=client.key SSLRootCert=server-ca.pem'''
+
+
+@_tf("gcp-gke-workload-identity")
+def _tf_gcp_gke_workload_identity(f: Finding) -> str:
+ project = f.account_id or "PROJECT_ID"
+ region = f.region or "us-central1"
+ clusters = f.details.get("clusters_without_workload_identity", ["my-cluster"])
+ cluster = clusters[0] if clusters else "my-cluster"
+ return f'''\
+# Enable Workload Identity on GKE cluster.
+# This allows pods to authenticate to GCP APIs without service account key files.
+
+resource "google_container_cluster" "{cluster.replace("-", "_")}_wi" {{
+ name = "{cluster}"
+ location = "{region}"
+ project = "{project}"
+
+ workload_identity_config {{
+ workload_pool = "{project}.svc.id.goog"
+ }}
+}}
+
+resource "google_container_node_pool" "{cluster.replace("-", "_")}_nodes" {{
+ name = "default-pool"
+ cluster = "{cluster}"
+ location = "{region}"
+ project = "{project}"
+
+ node_config {{
+ workload_metadata_config {{
+ mode = "GKE_METADATA"
+ }}
+ }}
+}}
+
+# After enabling, bind a Kubernetes SA to a GCP SA:
+# kubectl create serviceaccount KSA_NAME --namespace=NAMESPACE
+# gcloud iam service-accounts add-iam-policy-binding GSA_EMAIL \\
+# --role=roles/iam.workloadIdentityUser \\
+# --member="serviceAccount:{project}.svc.id.goog[NAMESPACE/KSA_NAME]"'''
+
+
+@_tf("gcp-gke-private-cluster")
+def _tf_gcp_gke_private(f: Finding) -> str:
+ project = f.account_id or "PROJECT_ID"
+ region = f.region or "us-central1"
+ clusters = f.details.get("clusters_without_private_nodes", ["my-cluster"])
+ old_cluster = clusters[0] if clusters else "my-cluster"
+ return f'''\
+# Create a new private GKE cluster to replace the non-private one.
+# Note: Private nodes cannot be enabled on existing clusters — migration required.
+
+resource "google_container_cluster" "{old_cluster.replace("-", "_")}_private" {{
+ name = "{old_cluster}-private"
+ location = "{region}"
+ project = "{project}"
+
+ # Remove default node pool immediately and manage via google_container_node_pool
+ remove_default_node_pool = true
+ initial_node_count = 1
+
+ network = "projects/{project}/global/networks/default"
+ subnetwork = "projects/{project}/regions/{region}/subnetworks/default"
+
+ private_cluster_config {{
+ enable_private_nodes = true
+ enable_private_endpoint = false # Set true if you want private control plane
+ master_ipv4_cidr_block = "172.16.0.0/28"
+ }}
+
+ master_authorized_networks_config {{
+ cidr_blocks {{
+ cidr_block = "10.0.0.0/8" # Restrict to your VPN/internal range
+ display_name = "internal"
+ }}
+ }}
+
+ workload_identity_config {{
+ workload_pool = "{project}.svc.id.goog"
+ }}
+}}
+
+# After migration: drain old cluster, update DNS/LB targets, delete old cluster.'''
+
+
+@_tf("gcp-cloudrun-no-unauth-access")
+def _tf_gcp_cloudrun_unauth(f: Finding) -> str:
+ project = f.account_id or "PROJECT_ID"
+ region = f.region or "us-central1"
+ services = f.details.get("services_with_public_access", [{"service": "my-service"}])
+ svc_name = services[0].get("service", "my-service") if services else "my-service"
+ return f'''\
+# Remove the unauthenticated invoker binding from the Cloud Run service.
+# Replace with a specific member list for authorized callers.
+
+resource "google_cloud_run_v2_service_iam_binding" "{svc_name.replace("-", "_")}_invoker" {{
+ project = "{project}"
+ location = "{region}"
+ name = "{svc_name}"
+ role = "roles/run.invoker"
+
+ # List only the specific service accounts or users that need to invoke this service.
+ # Remove "allUsers" and "allAuthenticatedUsers" from this list.
+ members = [
+ "serviceAccount:caller-sa@{project}.iam.gserviceaccount.com",
+ ]
+}}'''
+
+
+@_tf("gcp-cloudrun-no-default-sa")
+def _tf_gcp_cloudrun_default_sa(f: Finding) -> str:
+ project = f.account_id or "PROJECT_ID"
+ region = f.region or "us-central1"
+ services = f.details.get("services_using_default_sa", [{"service": "my-service"}])
+ svc_name = services[0].get("service", "my-service") if services else "my-service"
+ sa_id = svc_name.replace("-", "")[:28]
+ return f'''\
+# Create a dedicated service account with minimal permissions,
+# then update the Cloud Run service to use it.
+
+resource "google_service_account" "{sa_id}_sa" {{
+ account_id = "{svc_name}-sa"
+ display_name = "Cloud Run SA for {svc_name}"
+ project = "{project}"
+}}
+
+# Grant only the permissions this service actually needs (example: Cloud SQL client)
+resource "google_project_iam_member" "{sa_id}_sa_roles" {{
+ project = "{project}"
+ role = "roles/cloudsql.client"
+ member = "serviceAccount:${{google_service_account.{sa_id}_sa.email}}"
+}}
+
+resource "google_cloud_run_v2_service" "{svc_name.replace("-", "_")}" {{
+ name = "{svc_name}"
+ location = "{region}"
+ project = "{project}"
+
+ template {{
+ service_account = google_service_account.{sa_id}_sa.email
+
+ containers {{
+ image = "IMAGE_URI" # Replace with your container image URI
+ }}
+ }}
+}}'''
+
+
+@_tf("gcp-cloudrun-ingress-restricted")
+def _tf_gcp_cloudrun_ingress(f: Finding) -> str:
+ project = f.account_id or "PROJECT_ID"
+ region = f.region or "us-central1"
+ services = f.details.get("services_with_open_ingress", [{"service": "my-service"}])
+ svc_name = services[0].get("service", "my-service") if services else "my-service"
+ return f'''\
+# Restrict Cloud Run ingress to internal + Cloud Load Balancing only.
+# This prevents direct access to the *.run.app URL, forcing all traffic through
+# your Cloud Armor / load balancer policy.
+
+resource "google_cloud_run_v2_service" "{svc_name.replace("-", "_")}" {{
+ name = "{svc_name}"
+ location = "{region}"
+ project = "{project}"
+
+ ingress = "INGRESS_TRAFFIC_INTERNAL_LOAD_BALANCER"
+ # Use "INGRESS_TRAFFIC_INTERNAL_ONLY" if this is a pure internal microservice.
+
+ template {{
+ containers {{
+ image = "IMAGE_URI" # Replace with your container image URI
+ }}
+ }}
+}}'''
+
+
+@_tf("gcp-cloudrun-binary-authorization")
+def _tf_gcp_cloudrun_binauthz(f: Finding) -> str:
+ project = f.account_id or "PROJECT_ID"
+ region = f.region or "us-central1"
+ services = f.details.get("services_without_binauthz", ["my-service"])
+ svc_name = services[0] if services else "my-service"
+ return f'''\
+# Enable Binary Authorization on the Cloud Run service.
+# Requires a project-level Binary Authorization policy to be configured first.
+
+# Step 1: Configure the project Binary Authorization policy
+resource "google_binary_authorization_policy" "policy" {{
+ project = "{project}"
+
+ default_admission_rule {{
+ evaluation_mode = "REQUIRE_ATTESTATION"
+ enforcement_mode = "ENFORCED_BLOCK_AND_AUDIT_LOG"
+ require_attestations_by = [
+ # google_binary_authorization_attestor.my_attestor.name
+ ]
+ }}
+
+ # Allow Google-managed images (Cloud Run base images)
+ global_policy_evaluation_mode = "ENABLE"
+}}
+
+# Step 2: Enable Binary Authorization on the Cloud Run service
+resource "google_cloud_run_v2_service" "{svc_name.replace("-", "_")}" {{
+ name = "{svc_name}"
+ location = "{region}"
+ project = "{project}"
+
+ binary_authorization {{
+ use_default = true
+ breakglass_justification = null # Remove to enforce strictly
+ }}
+
+ template {{
+ containers {{
+ image = "IMAGE_URI" # Replace with your container image URI
+ }}
+ }}
+}}'''
+
+
+@_tf("gcp-cloudrun-no-plaintext-secrets")
+def _tf_gcp_cloudrun_secrets(f: Finding) -> str:
+ project = f.account_id or "PROJECT_ID"
+ region = f.region or "us-central1"
+ suspicious = f.details.get("suspicious_env_vars", [{"service": "my-service", "env_var": "API_KEY"}])
+ svc_name = suspicious[0].get("service", "my-service") if suspicious else "my-service"
+ env_var = suspicious[0].get("env_var", "API_KEY") if suspicious else "API_KEY"
+ secret_id = env_var.lower().replace("_", "-")
+ return f'''\
+# Migrate the plaintext secret environment variable to Secret Manager.
+
+# Step 1: Create the secret in Secret Manager
+resource "google_secret_manager_secret" "{secret_id}" {{
+ secret_id = "{secret_id}"
+ project = "{project}"
+
+ replication {{
+ auto {{}}
+ }}
+}}
+
+# Step 2: Add the secret value (manage via CI/CD pipeline, not Terraform)
+# resource "google_secret_manager_secret_version" "{secret_id}_v1" {{
+# secret = google_secret_manager_secret.{secret_id}.id
+# secret_data = var.{secret_id} # Passed in via -var flag, never hardcoded
+# }}
+
+# Step 3: Grant the Cloud Run service account access to the secret
+resource "google_secret_manager_secret_iam_member" "{secret_id}_accessor" {{
+ secret_id = google_secret_manager_secret.{secret_id}.secret_id
+ project = "{project}"
+ role = "roles/secretmanager.secretAccessor"
+ member = "serviceAccount:{svc_name}-sa@{project}.iam.gserviceaccount.com"
+}}
+
+# Step 4: Update the Cloud Run service to use the secret reference
+resource "google_cloud_run_v2_service" "{svc_name.replace("-", "_")}" {{
+ name = "{svc_name}"
+ location = "{region}"
+ project = "{project}"
+
+ template {{
+ containers {{
+ image = "IMAGE_URI"
+
+ env {{
+ name = "{env_var}"
+ value_source {{
+ secret_key_ref {{
+ secret = google_secret_manager_secret.{secret_id}.secret_id
+ version = "latest"
+ }}
+ }}
+ }}
+ }}
+ }}
+}}'''
+
+
# ---------------------------------------------------------------------------
# Explanation and steps registry
# ---------------------------------------------------------------------------
@@ -3593,6 +4315,208 @@ def _tf_az_security_initiative(f: Finding) -> str:
],
"effort": "quick",
},
+ # ── GCP ──────────────────────────────────────────────────────────────────
+ "gcp-iam-primitive-roles": {
+ "explanation": "GCP primitive roles (Owner, Editor, Viewer) grant broad project-wide permissions that violate least-privilege. CIS GCP 1.1 requires replacing them with predefined or custom IAM roles that grant only the specific permissions each principal needs.",
+ "steps": [
+ "Run `gcloud projects get-iam-policy PROJECT_ID --format=json` to list all bindings",
+ "Identify all principals with roles/owner or roles/editor",
+ "Replace each with a predefined role (e.g., roles/compute.instanceAdmin, roles/storage.objectAdmin)",
+ "Remove the primitive role binding after granting the replacement",
+ "Use the Terraform template to manage bindings as code going forward",
+ ],
+ "effort": "moderate",
+ },
+ "gcp-iam-service-account-not-admin": {
+ "explanation": "Service accounts should never hold roles/owner or roles/editor at the project level. CIS GCP 1.5 requires granting service accounts only the specific roles needed for their workload — not blanket admin access that persists after the service is decommissioned.",
+ "steps": [
+ "List project IAM bindings: `gcloud projects get-iam-policy PROJECT_ID`",
+ "Identify service account members with owner or editor roles",
+ "Grant narrowly scoped predefined roles that match the service account's actual workload",
+ "Remove the overly permissive role bindings",
+ ],
+ "effort": "moderate",
+ },
+ "gcp-firewall-unrestricted-ssh": {
+ "explanation": "Firewall rules allowing SSH (port 22) from 0.0.0.0/0 or ::/0 expose all instances in the VPC to brute-force and credential-stuffing attacks. CIS GCP 3.6 requires restricting SSH ingress to specific IP ranges or routing through IAP tunnels.",
+ "steps": [
+ "Identify the firewall rule: `gcloud compute firewall-rules list --filter='allowed.ports=22 AND direction=INGRESS'`",
+ "Enable OS Login or use IAP for SSH: `gcloud compute instances add-metadata INSTANCE --metadata enable-oslogin=TRUE`",
+ "If direct SSH is required, restrict source ranges to your corporate CIDR",
+ "Delete or update the 0.0.0.0/0 rule: `gcloud compute firewall-rules update RULE --source-ranges CIDR`",
+ ],
+ "effort": "quick",
+ },
+ "gcp-firewall-unrestricted-rdp": {
+ "explanation": "Firewall rules allowing RDP (port 3389) from 0.0.0.0/0 or ::/0 expose Windows instances to credential attacks and ransomware delivery. CIS GCP 3.7 requires restricting RDP to specific source ranges or eliminating it in favor of IAP Desktop.",
+ "steps": [
+ "List rules: `gcloud compute firewall-rules list --filter='allowed.ports=3389'`",
+ "Enable IAP for Windows: use Google IAP TCP forwarding instead of public RDP",
+ "If direct RDP is required, restrict source-ranges to the corporate IP range",
+ "Remove the unrestricted rule via Console or `gcloud compute firewall-rules delete RULE`",
+ ],
+ "effort": "quick",
+ },
+ "gcp-vpc-flow-logs": {
+ "explanation": "VPC Flow Logs capture network flow metadata (source/destination IP, protocol, bytes) for subnets. CIS GCP 3.8 requires enabling flow logs on all subnets — they are essential for incident response, traffic anomaly detection, and network forensics.",
+ "steps": [
+ "List subnets missing flow logs: `gcloud compute networks subnets list --format='table(name,region,enableFlowLogs)'`",
+ "Enable flow logs per subnet: `gcloud compute networks subnets update SUBNET --region REGION --enable-flow-logs`",
+ "Configure sampling rate and aggregation interval as needed",
+ "Use the Terraform template to enforce flow logs on all future subnets",
+ ],
+ "effort": "moderate",
+ },
+ "gcp-kms-key-rotation": {
+ "explanation": "Cloud KMS symmetric keys should rotate automatically at least every 90 days (CIS GCP 1.10). Stale keys increase the blast radius of a key compromise and may violate compliance mandates that require fresh cryptographic material.",
+ "steps": [
+ "List keys without rotation: `gcloud kms keys list --keyring=KEYRING --location=global`",
+ "Set a rotation schedule: `gcloud kms keys update KEY --keyring=KEYRING --location=LOCATION --rotation-period=7776000s --next-rotation-time=TIMESTAMP`",
+ "For existing keys past rotation deadline, manually rotate: `gcloud kms keys versions create KEY --keyring=KEYRING --location=LOCATION --primary`",
+ "Use the Terraform `rotation_period` argument to enforce this for all new keys",
+ ],
+ "effort": "moderate",
+ },
+ "gcp-storage-bucket-public-access": {
+ "explanation": "GCS buckets with allUsers or allAuthenticatedUsers IAM bindings expose data to the public internet. CIS GCP 5.1 requires removing public access and enabling the uniform bucket-level access policy to prevent ACL-level public grants.",
+ "steps": [
+ "Identify public buckets: `gsutil iam get gs://BUCKET_NAME`",
+ "Remove public members: `gsutil iam ch -d allUsers gs://BUCKET_NAME`",
+ "Enable Public Access Prevention: `gcloud storage buckets update gs://BUCKET_NAME --public-access-prevention=enforced`",
+ "Set uniform bucket-level access to block legacy ACLs",
+ ],
+ "effort": "quick",
+ },
+ "gcp-storage-uniform-access": {
+ "explanation": "Uniform bucket-level access disables legacy object ACLs and enforces IAM-only access control. CIS GCP 5.2 requires enabling it to prevent inconsistent per-object permissions that are hard to audit and can inadvertently expose objects.",
+ "steps": [
+ "Enable for each bucket: `gsutil uniformbucketlevelaccess set on gs://BUCKET_NAME`",
+ "Once enabled, existing ACLs are no longer evaluated",
+ "Verify with: `gsutil uniformbucketlevelaccess get gs://BUCKET_NAME`",
+ "Use the Terraform `uniform_bucket_level_access = true` argument for all new buckets",
+ ],
+ "effort": "quick",
+ },
+ "gcp-logging-audit-config": {
+ "explanation": "GCP audit logs record who did what to which resource and when. CIS GCP 2.1 requires enabling Data Access audit logs (DATA_READ, DATA_WRITE) for all services — by default only admin activity is logged, leaving data-plane operations invisible to forensic investigation.",
+ "steps": [
+ "Check current audit config: `gcloud projects get-iam-policy PROJECT_ID --format=json | jq '.auditConfigs'`",
+ "Enable Data Access logging for all services via Console: IAM > Audit Logs > select all services > enable DATA_READ + DATA_WRITE",
+ "Or set via `gcloud projects set-iam-policy` with an updated policy JSON including auditConfigs",
+ "Use the Terraform `google_project_iam_audit_config` resource to manage this as code",
+ ],
+ "effort": "moderate",
+ },
+ "gcp-logging-metric-alerting": {
+ "explanation": "CIS GCP 2.2–2.13 requires log-based metrics and alerting policies for critical changes: project ownership, IAM changes, custom roles, VPC/firewall/route modifications, and SQL instance changes. Without these, security-relevant events go undetected until a post-incident review.",
+ "steps": [
+ "Create log-based metrics in Cloud Monitoring for each CIS control filter",
+ "Create alerting policies that fire when the metric exceeds threshold (usually 0)",
+ "Configure notification channels (email, PagerDuty, Slack) for each alert",
+ "Use the Terraform template to manage all metrics and alerts as code",
+ ],
+ "effort": "high",
+ },
+ "gcp-compute-os-login": {
+ "explanation": "OS Login binds SSH access to Google identities and enforces 2FA, replacing SSH key management with centralized IAM. CIS GCP 4.4 requires enabling it at the project level so all new instances inherit the setting by default.",
+ "steps": [
+ "Enable at project level: `gcloud compute project-info add-metadata --metadata enable-oslogin=TRUE`",
+ "Grant the `roles/compute.osLogin` or `roles/compute.osAdminLogin` role to users who need SSH",
+ "Ensure 2FA is enforced for all Google accounts in the organization",
+ "Verify on a test instance: `gcloud compute ssh INSTANCE --zone ZONE`",
+ ],
+ "effort": "moderate",
+ },
+ "gcp-compute-serial-port": {
+ "explanation": "The compute instance serial port provides low-level diagnostic access that bypasses normal authentication. CIS GCP 4.5 requires disabling it at the project level — leaving it enabled allows anyone with project-level IAM to access instance consoles interactively.",
+ "steps": [
+ "Disable at project level: `gcloud compute project-info add-metadata --metadata serial-port-enable=false`",
+ "Verify no instances have the override metadata `serial-port-enable=1`",
+ "Use an org policy to enforce: `gcloud resource-manager org-policies enable-enforce --project PROJECT compute.disableSerialPortAccess`",
+ ],
+ "effort": "quick",
+ },
+ "gcp-sql-require-ssl": {
+ "explanation": "Cloud SQL instances should require SSL/TLS for all connections to prevent credential interception in transit. CIS GCP 6.3 requires enabling `requireSsl` on all Cloud SQL instances — without it, applications may connect over plaintext.",
+ "steps": [
+ "Enable SSL requirement: `gcloud sql instances patch INSTANCE --require-ssl`",
+ "Create server CA certificates if not present",
+ "Update application connection strings to include SSL client certificates",
+ "For PostgreSQL, ensure `ssl=require` or `sslmode=require` in connection strings",
+ ],
+ "effort": "moderate",
+ },
+ "gcp-gke-workload-identity": {
+ "explanation": "Workload Identity Federation replaces long-lived service account key files with short-lived tokens issued per workload. CIS GCP 7.4 requires enabling Workload Identity on GKE clusters — key files are the primary credential theft vector in container environments.",
+ "steps": [
+ "Enable Workload Identity on the cluster: `gcloud container clusters update CLUSTER --workload-pool=PROJECT.svc.id.goog`",
+ "Annotate Kubernetes service accounts with the GCP service account: `kubectl annotate serviceaccount KSA iam.gke.io/gcp-service-account=GSA@PROJECT.iam.gserviceaccount.com`",
+ "Grant `roles/iam.workloadIdentityUser` to the Kubernetes SA on the GCP SA",
+ "Remove any key files previously mounted into pods",
+ ],
+ "effort": "high",
+ },
+ "gcp-gke-private-cluster": {
+ "explanation": "Private GKE clusters assign only internal IP addresses to nodes and restrict access to the control plane to authorised networks. CIS GCP 7.1 requires private clusters — public node IPs expose the Kubernetes API and nodes directly to the internet.",
+ "steps": [
+ "Private clusters must be configured at creation time — plan to recreate the cluster",
+ "Create with: `gcloud container clusters create CLUSTER --enable-private-nodes --enable-private-endpoint --master-ipv4-cidr 172.16.0.0/28`",
+ "Authorise control-plane access: `--master-authorized-networks CIDR`",
+ "Migrate workloads to the new private cluster using blue/green deployment",
+ ],
+ "effort": "high",
+ },
+ # ── GCP Cloud Run ─────────────────────────────────────────────────────────
+ "gcp-cloudrun-no-unauth-access": {
+ "explanation": "Cloud Run services that grant roles/run.invoker to allUsers or allAuthenticatedUsers can be called by anyone on the internet without credentials. Unless the service is explicitly a public endpoint (e.g. a landing page), this exposes application logic, data APIs, and compute to anonymous abuse.",
+ "steps": [
+ "List IAM policy: `gcloud run services get-iam-policy SERVICE --region=REGION`",
+ "Remove allUsers: `gcloud run services remove-iam-policy-binding SERVICE --region=REGION --member=allUsers --role=roles/run.invoker`",
+ "For service-to-service calls, use a dedicated invoker SA with `gcloud run services add-iam-policy-binding`",
+ "For user-facing traffic, front the service with Identity-Aware Proxy or a load balancer with authentication",
+ ],
+ "effort": "quick",
+ },
+ "gcp-cloudrun-no-default-sa": {
+ "explanation": "The default Compute Engine service account carries the Editor role at the project level, meaning any Cloud Run service using it can read/write most GCP resources in the project. A dedicated SA with only the required roles reduces the blast radius if the service is compromised.",
+ "steps": [
+ "Create a dedicated SA: `gcloud iam service-accounts create SERVICE-sa --display-name='Cloud Run SA for SERVICE'`",
+ "Grant only the roles the service needs (e.g., `roles/cloudsql.client`, `roles/secretmanager.secretAccessor`)",
+ "Update the service: `gcloud run services update SERVICE --service-account=SERVICE-sa@PROJECT.iam.gserviceaccount.com --region=REGION`",
+ ],
+ "effort": "moderate",
+ },
+ "gcp-cloudrun-ingress-restricted": {
+ "explanation": "Cloud Run services with INGRESS_TRAFFIC_ALL can be reached directly via the *.run.app URL, bypassing any Cloud Armor WAF policy, rate limiting, or authentication headers enforced by the load balancer. Setting ingress to INTERNAL_AND_CLOUD_LOAD_BALANCER forces all traffic through the controlled entry point.",
+ "steps": [
+ "Update ingress: `gcloud run services update SERVICE --ingress=internal-and-cloud-load-balancing --region=REGION`",
+ "For internal-only microservices: `--ingress=internal`",
+ "Set up a Cloud Load Balancer + Cloud Armor policy to be the sole public entry point",
+ "Verify the *.run.app URL is no longer publicly reachable after the change",
+ ],
+ "effort": "moderate",
+ },
+ "gcp-cloudrun-binary-authorization": {
+ "explanation": "Binary Authorization verifies that container images were built by your trusted CI/CD pipeline before allowing deployment to Cloud Run. Without it, any image (including attacker-substituted ones in a supply-chain attack) can be deployed. CIS GCP 5.7 recommends enabling it for all container workloads.",
+ "steps": [
+ "Configure a project-level Binary Authorization policy in the Console: Security > Binary Authorization",
+ "Set up an attestor tied to your CI/CD pipeline (Cloud Build or SLSA attestation)",
+ "Enable on the service: `gcloud run services update SERVICE --binary-authorization=default --region=REGION`",
+ "Test with a non-attested image to confirm it is blocked",
+ ],
+ "effort": "high",
+ },
+ "gcp-cloudrun-no-plaintext-secrets": {
+ "explanation": "Literal secret values in Cloud Run environment variables are stored in plain text in the service spec, visible in the Cloud Console, the API, and Cloud Audit Logs. They also appear in `gcloud run services describe` output. Secret Manager with secretKeyRef mounts secrets at runtime and ensures they are never stored in the service configuration.",
+ "steps": [
+ "Create the secret: `gcloud secrets create SECRET_NAME --replication-policy=automatic`",
+ "Add the secret value: `echo -n 'VALUE' | gcloud secrets versions add SECRET_NAME --data-file=-`",
+ "Grant the service SA access: `gcloud secrets add-iam-policy-binding SECRET_NAME --member=serviceAccount:SA_EMAIL --role=roles/secretmanager.secretAccessor`",
+ "Update the service: `gcloud run services update SERVICE --update-secrets=ENV_VAR=SECRET_NAME:latest --region=REGION`",
+ "Remove the plaintext env var: `gcloud run services update SERVICE --remove-env-vars=ENV_VAR --region=REGION`",
+ ],
+ "effort": "moderate",
+ },
}
diff --git a/src/shasta/reports/generator.py b/src/shasta/reports/generator.py
index ff9cd65..4a25247 100644
--- a/src/shasta/reports/generator.py
+++ b/src/shasta/reports/generator.py
@@ -15,7 +15,7 @@
from shasta.compliance.mapper import get_control_summary
from shasta.compliance.scorer import calculate_score
-from shasta.evidence.models import CloudProvider, Finding, ScanResult
+from shasta.evidence.models import CloudProvider, ScanResult
# Provider-aware labels for reports and templates
PROVIDER_LABELS: dict[str, dict[str, str]] = {
@@ -37,6 +37,15 @@
"inspector_service": "Defender for Cloud",
"product_tagline": "Cloud Compliance Automation",
},
+ "gcp": {
+ "account_label": "GCP Project",
+ "console": "Google Cloud console",
+ "config_service": "Security Command Center",
+ "logging_service": "Cloud Audit Logs",
+ "threat_service": "Security Command Center",
+ "inspector_service": "Security Command Center",
+ "product_tagline": "Cloud Compliance Automation",
+ },
}
@@ -44,11 +53,19 @@ def _provider_labels(provider: CloudProvider) -> dict[str, str]:
"""Return display labels for the given cloud provider."""
return PROVIDER_LABELS.get(provider.value, PROVIDER_LABELS["aws"])
+
# Keys in Finding.details that are framework mappings (already shown elsewhere)
-_FRAMEWORK_KEYS = frozenset({
- "iso27001_controls", "hipaa_controls", "soc2_controls",
- "cis_azure_controls", "mcsb_controls", "cis_aws_controls",
-})
+_FRAMEWORK_KEYS = frozenset(
+ {
+ "iso27001_controls",
+ "hipaa_controls",
+ "soc2_controls",
+ "cis_azure_controls",
+ "mcsb_controls",
+ "cis_aws_controls",
+ "cis_gcp_controls",
+ }
+)
def _render_details_html(details: dict) -> str:
@@ -69,7 +86,9 @@ def _render_details_html(details: dict) -> str:
if isinstance(value[0], dict):
# List of dicts → table
headers = list(value[0].keys())
- header_row = "".join(f"{esc(h.replace('_', ' ').title())} | " for h in headers)
+ header_row = "".join(
+ f"{esc(h.replace('_', ' ').title())} | " for h in headers
+ )
body_rows = []
for row in value:
cells = "".join(
@@ -112,6 +131,7 @@ def _render_details_html(details: dict) -> str:
return ""
return f"
{''.join(parts)}
"
+
MARKDOWN_TEMPLATE = """\
# SOC 2 Compliance Gap Analysis Report
@@ -171,6 +191,9 @@ def _render_details_html(details: dict) -> str:
- **Severity:** {{ f.severity.value | upper }}
- **SOC 2 Control(s):** {{ f.soc2_controls | join(', ') }}
+{% if f.cis_gcp_controls -%}
+- **CIS GCP Control(s):** {{ f.cis_gcp_controls | join(', ') }}
+{% endif -%}
- **Resource:** `{{ f.resource_id }}`
- **Description:** {{ f.description }}
{% if f.remediation -%}
@@ -191,6 +214,9 @@ def _render_details_html(details: dict) -> str:
### {{ f.title }}
- **SOC 2 Control(s):** {{ f.soc2_controls | join(', ') }}
+{% if f.cis_gcp_controls -%}
+- **CIS GCP Control(s):** {{ f.cis_gcp_controls | join(', ') }}
+{% endif -%}
- **Resource:** `{{ f.resource_id }}`
- **Description:** {{ f.description }}
{% if f.remediation -%}
@@ -342,7 +368,7 @@ def _render_details_html(details: dict) -> str:
{{ f.severity.value }}
{{ f.title }}
- SOC 2: {{ f.soc2_controls | join(', ') }} | Resource: {{ f.resource_id }}
+ SOC 2: {{ f.soc2_controls | join(', ') }}{% if f.cis_gcp_controls %} | CIS GCP: {{ f.cis_gcp_controls | join(', ') }}{% endif %} | Resource: {{ f.resource_id }}
{{ f.description }}
{% if f.remediation %}
Fix: {{ f.remediation }}
@@ -361,7 +387,7 @@ def _render_details_html(details: dict) -> str:
medium
{{ f.title }}
- SOC 2: {{ f.soc2_controls | join(', ') }} | Resource: {{ f.resource_id }}
+ SOC 2: {{ f.soc2_controls | join(', ') }}{% if f.cis_gcp_controls %} | CIS GCP: {{ f.cis_gcp_controls | join(', ') }}{% endif %} | Resource: {{ f.resource_id }}
{{ f.description }}
{% if f.remediation %}
Fix: {{ f.remediation }}
diff --git a/src/shasta/reports/multi_framework_html.py b/src/shasta/reports/multi_framework_html.py
index b65e117..a82e4f7 100644
--- a/src/shasta/reports/multi_framework_html.py
+++ b/src/shasta/reports/multi_framework_html.py
@@ -14,7 +14,6 @@
from shasta.compliance.hipaa_scorer import calculate_hipaa_score
from shasta.compliance.iso27001_mapper import get_iso27001_control_summary
from shasta.compliance.iso27001_scorer import calculate_iso27001_score
-from shasta.compliance.mapper import get_control_summary
from shasta.compliance.scorer import calculate_score
from shasta.evidence.models import Finding, ScanResult
from shasta.reports.generator import _render_details_html
@@ -113,7 +112,7 @@ def _wrap(title: str, body: str) -> str:
{body}
-
+