diff --git a/.gitignore b/.gitignore index 4606c69..e634865 100644 --- a/.gitignore +++ b/.gitignore @@ -38,4 +38,7 @@ plan # transient log files *.log *.list -*.json \ No newline at end of file +*.json + +# macOS +.DS_Store diff --git a/README.md b/README.md index 8e56be9..9ea3abb 100644 --- a/README.md +++ b/README.md @@ -19,9 +19,10 @@ flowchart TB subgraph GCP["GCP Project"] CGW["Connect Gateway
(IAM-auth, no public endpoint)"] - subgraph VPC["VPC (private, egress: TCP 443 only)"] + subgraph VPC["VPC (private, egress: TCP 443 + Redis 6378)"] GW["GKE Gateway
Google-managed TLS"] PODS["Socket Firewall pods
ClusterIP :80, replicas 2"] + REDIS["Memorystore Redis
verdict cache (TLS)"] CP["Private control plane
172.16.0.0/28"] NAT["Cloud NAT"] end @@ -33,6 +34,7 @@ flowchart TB CGW -.->|proxied| CP CLIENTS -->|HTTPS| GW GW --> PODS + PODS --> REDIS PODS --> NAT NAT --> UPSTREAM SECRETS --> PODS @@ -65,7 +67,7 @@ flowchart LR CGW -.->|proxied| MASTER ``` -Egress from GKE nodes is **deny-by-default** at the VPC firewall layer. Only **TCP 443** is permitted outbound (HTTPS to Socket.dev and upstream registries, and the fleet Connect agent's outbound channel to Google). Port 80 is intentionally blocked. +Egress from GKE nodes is **deny-by-default** at the VPC firewall layer. Only **TCP 443** is permitted outbound to the internet (HTTPS to Socket.dev and upstream registries, and the fleet Connect agent's outbound channel to Google), plus **TCP 6378** to the Memorystore Redis host for the shared verdict cache. Port 80 is intentionally blocked. ## Data flow @@ -74,25 +76,49 @@ sequenceDiagram participant Dev as Developer / CI participant GW as GKE Gateway participant FW as Socket Firewall Pod + participant Redis as Memorystore Redis participant Up as Socket.dev API / Registry Dev->>GW: GET https://sfw.example.com/npm/... GW->>FW: HTTP :80 (public TLS terminated at gateway) - FW->>Up: Scan (Socket.dev) + proxy download (registry) - Up-->>FW: Verdict + package artifact + FW->>Redis: Lookup cached verdict + alt Fresh / stale cache hit on API failure + Redis-->>FW: Last known-good verdict + else Cache miss or revalidation + FW->>Up: Scan (Socket.dev) + proxy download (registry) + Up-->>FW: Verdict + package artifact + FW->>Redis: Store verdict (TTL 24h stale window) + end FW-->>Dev: Scanned / allowed response ``` +## Fail-open and circuit breaker + +When the Socket API is degraded, the firewall's circuit breaker trips. With the previous `fail_open: false` and no shared cache, that defaulted to **blocking** packages. + +Current behavior (Helm values in [`helm.tf`](terraform/helm.tf)): + +| Setting | Value | Effect | +|---------|-------|--------| +| `socket.failOpen` | `true` | On API/breaker failure, allow when no better signal exists | +| `socket.failOpenUnscanned` | `false` | Unscanned/unknown packages stay blocked | +| `socket.cacheTtl` | `600` | Fresh verdict window (10 minutes) | +| `redis.enabled` / `redis.ttl` | `true` / `86400` | Shared Memorystore cache; stale window 24 hours | +| `extraConfig.resilience.circuit_breaker.enabled` | `true` | Prefer cached verdicts when the breaker opens | + +Stale-while-revalidate: fresh hits return immediately; between `cacheTtl` and `redis.ttl` the firewall revalidates with Socket and falls back to the stale verdict if the API (or breaker) fails. After `redis.ttl` the key expires and the next request must fetch fresh (or fail-open if still down). + ## Components | Layer | Resource | Purpose | |-------|----------|---------| | **Network** | VPC + subnet + secondary ranges | Isolated network; subnet has VPC flow logs | -| **Egress** | Deny-all + allow TCP 443 + Cloud NAT | Default-deny egress; HTTPS-only outbound via NAT scoped to the subnet | +| **Egress** | Deny-all + allow TCP 443 + Redis 6378 + Cloud NAT | Default-deny egress; HTTPS to internet via NAT; TLS Redis to Memorystore | | **Compute** | Private GKE cluster + node pool | Shielded nodes, deletion protection, Calico NetworkPolicy, Binary Authorization | | **Encryption** | Cloud KMS key ring (3 keys) | CMEK for etcd secrets, node boot disks, and Secret Manager | | **Access** | Fleet membership + Connect Gateway | IAM-authenticated proxy to the private control plane; no public endpoint, bastion, or IAP tunnel | -| **App** | Helm `socket-firewall` | Package firewall with path-based routing, HPA, pod anti-affinity, PDB | +| **App** | Helm `socket-firewall` | Package firewall with path-based routing, HPA, pod anti-affinity, PDB; Redis-backed verdict cache with fail-open on breaker trip | +| **Cache** | Memorystore Redis (AUTH + TLS) | Shared stale-while-revalidate cache across replicas so a tripped Socket API circuit breaker serves last known-good verdicts | | **Exposure** | GKE Gateway (`gke-l7-global-external-managed`) | External HTTPS when `firewall_domain` is set; `LoadBalancer` Service fallback when domain is unset | | **Secrets** | Secret Manager (CMEK) → K8s secret | `SOCKET_SECURITY_API_TOKEN` for Socket.dev | | **TLS** | Certificate Manager + GKE Gateway + SSL policy | Google-managed cert; HTTPS terminates at the LB (TLS 1.2 minimum) | @@ -116,12 +142,13 @@ sequenceDiagram |---------|----------------| | **Encryption at rest** | CMEK for GKE etcd, node disks, and Secret Manager (90-day key rotation) | | **Encryption in transit** | GCP-managed TLS at the Gateway; SSL policy enforces RESTRICTED cipher suites and TLS 1.2+ | -| **Network egress** | VPC firewall deny-all with explicit TCP 443 allow; Kubernetes egress governed by VPC rules | +| **Network egress** | VPC firewall deny-all with explicit TCP 443 (internet) and TCP 6378 (Memorystore) allows; Kubernetes egress governed by VPC rules | | **Network ingress** | Calico NetworkPolicies default-deny ingress in the firewall namespace | | **Image admission** | Binary Authorization (`PROJECT_SINGLETON_POLICY_ENFORCE`) | | **Node hardening** | Shielded VMs, dedicated node SA (no `cloud-platform` scope), Workload Identity, legacy metadata endpoints disabled | | **Control plane** | Private endpoint only — no public API server; reachable solely via Connect Gateway (IAM-authenticated) | | **Availability** | HPA, pod anti-affinity, PodDisruptionBudget (`minAvailable: 1`), 2-node minimum. **Zonal cluster** (`us-central1-a`) — these protect against single-node loss, not a full-zone outage, and the control plane has no HA SLA | +| **Verdict cache** | Memorystore Redis (AUTH + in-transit TLS); fresh TTL 10m, stale window 24h; `fail_open: true` so a tripped Socket API circuit breaker serves cached verdicts instead of blocking | | **IAM least privilege** | Custom Terraform roles replace `container.admin`/`secretmanager.secretAdmin`/`cloudkms.admin` (no project-wide secret payload access); read-only plan SA distinct from write apply SA; fleet write access is bootstrap-only | | **Audit** | VPC flow logs and firewall rule logging with full metadata | @@ -163,7 +190,8 @@ The Gateway terminates the public, browser-trusted certificate and forwards to t | [`fleet.tf`](terraform/fleet.tf) | Fleet (GKE Hub) membership enabling Connect Gateway access to the private control plane | | [`iam.tf`](terraform/iam.tf) | GKE node SA, custom least-privilege Terraform roles (apply + plan SA), IAM bindings | | [`secrets.tf`](terraform/secrets.tf) | CMEK-encrypted Secret Manager secret for the Socket API token | -| [`helm.tf`](terraform/helm.tf) | Namespace, K8s secret, Helm release (incl. pod `securityContext`/`fsGroup` so nginx can read the generated cert key) | +| [`helm.tf`](terraform/helm.tf) | Namespace, K8s secret, Helm release (fail-open + Redis stale cache + circuit breaker; pod `securityContext`/`fsGroup` so nginx can read the generated cert key) | +| [`redis.tf`](terraform/redis.tf) | Memorystore Redis (PSA, AUTH, TLS CA) + K8s secrets for the firewall verdict cache | | [`tls.tf`](terraform/tls.tf) | Certificate Manager, SSL policy, GKE Gateway, GCPGatewayPolicy, HTTPRoute, HealthCheckPolicy | | [`variables.tf`](terraform/variables.tf) | Input variable declarations (chart/image versions are required, no default) | | [`terraform.tfvars`](terraform/terraform.tfvars) | Concrete pinned values Terraform auto-loads (project, SA emails, node counts, `firewall_domain`, chart/image versions) | @@ -196,6 +224,8 @@ Roles Terraform grants to the **apply SA**: | Custom `socketFirewallTfSecretManager` | Manage Secret Manager resources (no project-wide payload read) | | `roles/secretmanager.secretAccessor` | Read the Socket API token (resource-scoped) | | Custom `socketFirewallTfKmsManager` | CMEK key ring / key management (no key or version destruction) | +| `roles/redis.admin` | Memorystore Redis instance for the shared verdict cache | +| `roles/servicenetworking.networksAdmin` | Private Service Access peering for Memorystore | | `roles/certificatemanager.editor` | Certificate Manager certs and DNS authorizations | | `roles/iam.serviceAccountUser` | Attach the node SA to node-pool VMs | | `roles/gkehub.gatewayEditor` + `roles/gkehub.viewer` | Reach the control plane via Connect Gateway; refresh the fleet membership | diff --git a/terraform/apis.tf b/terraform/apis.tf index 5538579..4df4ed6 100644 --- a/terraform/apis.tf +++ b/terraform/apis.tf @@ -21,6 +21,7 @@ locals { "binaryauthorization.googleapis.com", # image admission policy enforcement "gkehub.googleapis.com", # fleet membership for the cluster "connectgateway.googleapis.com", # Connect Gateway proxy to the private control plane + "redis.googleapis.com", # Memorystore Redis for shared verdict cache ]) } diff --git a/terraform/helm.tf b/terraform/helm.tf index 2478ba9..f212feb 100644 --- a/terraform/helm.tf +++ b/terraform/helm.tf @@ -43,11 +43,44 @@ locals { } } + # failOpen=true: when the Socket API circuit breaker trips (or the API is + # unreachable), allow packages that have a cached verdict and fall back to + # allow for cache misses. Pair with Redis stale-while-revalidate below so + # the common case serves the last known-good decision instead of blocking. + # failOpenUnscanned stays false — unknown/unscanned packages are still blocked. socket = { existingSecret = kubernetes_secret.socket_api_token.metadata[0].name existingSecretKey = "SOCKET_SECURITY_API_TOKEN" - failOpen = false + failOpen = true failOpenUnscanned = false + # Fresh window for Socket API verdicts (seconds). After this, entries are + # stale and revalidated; Redis retains them until redis.ttl. + cacheTtl = 600 + } + + # Shared Redis cache (Memorystore). Fresh for cacheTtl, then stale until + # redis.ttl — on API/breaker failure the firewall serves the stale verdict. + redis = { + enabled = true + host = google_redis_instance.verdict_cache.host + port = 6378 # Memorystore TLS port + ttl = 86400 + existingSecret = kubernetes_secret.redis_auth.metadata[0].name + existingSecretKey = "REDIS_PASSWORD" + ssl = true + sslVerify = true + sslServerName = google_redis_instance.verdict_cache.host + sslCaCertExistingSecret = kubernetes_secret.redis_ca.metadata[0].name + } + + # Circuit breaker is not a first-class chart value yet; pass via the + # raw-config escape hatch (top-level socket.yml section). + extraConfig = { + resilience = { + circuit_breaker = { + enabled = true + } + } } pathRouting = local.firewall_domain != "" ? { @@ -148,6 +181,9 @@ resource "helm_release" "socket_firewall" { depends_on = [ kubernetes_namespace.socket_firewall, kubernetes_secret.socket_api_token, + kubernetes_secret.redis_auth, + kubernetes_secret.redis_ca, + google_compute_firewall.allow_redis_egress, ] } diff --git a/terraform/iam.tf b/terraform/iam.tf index 3e8f277..f30af27 100644 --- a/terraform/iam.tf +++ b/terraform/iam.tf @@ -246,6 +246,22 @@ resource "google_project_iam_member" "tf_sa_certificate_manager_editor" { member = local.tf_sa_member } +# Memorystore Redis instance lifecycle (verdict cache for the firewall) +resource "google_project_iam_member" "tf_sa_redis_admin" { + project = var.project_id + role = "roles/redis.admin" + member = local.tf_sa_member +} + +# Private Service Access for Memorystore (allocate peering range + connection). +# compute.networkAdmin covers most of this; servicenetworking.networksAdmin is +# required for google_service_networking_connection. +resource "google_project_iam_member" "tf_sa_servicenetworking" { + project = var.project_id + role = "roles/servicenetworking.networksAdmin" + member = local.tf_sa_member +} + # --------------------------------------------------------------------------- # Fleet Connect Gateway access for the apply SA # --------------------------------------------------------------------------- @@ -353,6 +369,11 @@ resource "google_project_iam_custom_role" "tf_plan_reader" { # Fleet / GKE Hub "gkehub.memberships.get", "gkehub.memberships.list", + # Memorystore Redis + "redis.instances.get", + "redis.instances.list", + # Private Service Access connection used by Memorystore + "servicenetworking.services.get", ] } diff --git a/terraform/network.tf b/terraform/network.tf index 2b80100..ce7ab69 100644 --- a/terraform/network.tf +++ b/terraform/network.tf @@ -71,6 +71,28 @@ resource "google_compute_firewall" "allow_egress" { } } +# Memorystore Redis with in-transit encryption listens on 6378 (not 6379). +# Scoped to the instance host so the broader deny-all egress still applies +# everywhere else. +resource "google_compute_firewall" "allow_redis_egress" { + name = "${var.cluster_name}-allow-redis-egress" + network = google_compute_network.main.id + direction = "EGRESS" + priority = 1000 + + allow { + protocol = "tcp" + ports = ["6378"] + } + + destination_ranges = ["${google_redis_instance.verdict_cache.host}/32"] + target_tags = ["gke-${var.cluster_name}"] + + log_config { + metadata = "INCLUDE_ALL_METADATA" + } +} + # Cloud NAT so private nodes can reach the internet resource "google_compute_router" "main" { name = "${var.cluster_name}-router" diff --git a/terraform/outputs.tf b/terraform/outputs.tf index b0f71e1..f64b1e0 100644 --- a/terraform/outputs.tf +++ b/terraform/outputs.tf @@ -58,3 +58,13 @@ output "firewall_domain" { description = "Normalized domain used for path-based routing" value = local.firewall_domain != "" ? local.firewall_domain : null } + +output "redis_host" { + description = "Memorystore Redis host for the shared Socket API verdict cache" + value = google_redis_instance.verdict_cache.host +} + +output "redis_port" { + description = "Memorystore Redis TLS port (in-transit encryption)" + value = 6378 +} diff --git a/terraform/redis.tf b/terraform/redis.tf new file mode 100644 index 0000000..741c537 --- /dev/null +++ b/terraform/redis.tf @@ -0,0 +1,98 @@ +# --------------------------------------------------------------------------- +# Memorystore Redis — shared Socket API verdict cache +# --------------------------------------------------------------------------- +# Multi-replica firewall pods need a shared cache so a tripped circuit breaker +# can serve the last known-good verdict (stale-while-revalidate) instead of +# blocking. In-cluster Redis would require Binary Authorization allowlisting +# of a Redis image; Memorystore avoids that and keeps Redis off the pod +# security boundary. +# +# Connectivity: PRIVATE_SERVICE_ACCESS on the firewall VPC. AUTH + in-transit +# TLS (server authentication) so the AUTH string and CA are mounted into the +# firewall pods via Kubernetes secrets. +# --------------------------------------------------------------------------- + +resource "google_compute_global_address" "redis_psa" { + name = "${var.cluster_name}-redis-psa" + purpose = "VPC_PEERING" + address_type = "INTERNAL" + prefix_length = 24 + network = google_compute_network.main.id + project = var.project_id + + depends_on = [google_project_service.required] +} + +resource "google_service_networking_connection" "redis_psa" { + network = google_compute_network.main.id + service = "servicenetworking.googleapis.com" + reserved_peering_ranges = [google_compute_global_address.redis_psa.name] + + depends_on = [google_project_service.required] +} + +resource "google_redis_instance" "verdict_cache" { + name = "${var.cluster_name}-verdict-cache" + display_name = "Socket Firewall verdict cache" + tier = "BASIC" + memory_size_gb = var.redis_memory_size_gb + region = var.region + location_id = var.zone + redis_version = "REDIS_7_0" + authorized_network = google_compute_network.main.id + connect_mode = "PRIVATE_SERVICE_ACCESS" + auth_enabled = true + # SERVER_AUTHENTICATION enables TLS on port 6378 and exposes a per-instance + # CA that the firewall mounts via redis.sslCaCertExistingSecret. + transit_encryption_mode = "SERVER_AUTHENTICATION" + + labels = { + app = "socket-firewall" + env = "prod" + team = "team-security-2" + } + + depends_on = [ + google_service_networking_connection.redis_psa, + google_project_service.required, + ] +} + +# AUTH string for the Helm chart (REDIS_PASSWORD env → redis-password secret). +resource "kubernetes_secret" "redis_auth" { + metadata { + name = "socket-firewall-redis-auth" + namespace = kubernetes_namespace.socket_firewall.metadata[0].name + } + + data = { + REDIS_PASSWORD = google_redis_instance.verdict_cache.auth_string + } + + type = "Opaque" + + depends_on = [ + kubernetes_namespace.socket_firewall, + google_redis_instance.verdict_cache, + ] +} + +# Memorystore in-transit encryption CA (private per-instance CA, not in the +# system trust store). Mounted by the chart at /etc/nginx/redis-tls/ca/ca.crt. +resource "kubernetes_secret" "redis_ca" { + metadata { + name = "socket-firewall-redis-ca" + namespace = kubernetes_namespace.socket_firewall.metadata[0].name + } + + data = { + "ca.crt" = google_redis_instance.verdict_cache.server_ca_certs[0].cert + } + + type = "Opaque" + + depends_on = [ + kubernetes_namespace.socket_firewall, + google_redis_instance.verdict_cache, + ] +} diff --git a/terraform/variables.tf b/terraform/variables.tf index 507e98e..624db43 100644 --- a/terraform/variables.tf +++ b/terraform/variables.tf @@ -162,3 +162,9 @@ variable "path_routing_routes" { }, ] } + +variable "redis_memory_size_gb" { + description = "Memorystore Redis memory size in GiB for the shared Socket API verdict cache" + type = number + default = 1 +}