Skip to main content

Ensure Use Google Managed Ssl Certificates

More Info:

Encrypt traffic to HTTPS load balancers using TLS certificates.

Risk Level

Low

Address

Security

Compliance Standards

  • CIS GKE

Triage and Remediation

Remediation

Manual Steps
  1. On any machine with gcloud and kubectl access, list current LoadBalancer Services and Ingresses to decide what to migrate and secure:

    kubectl get svc -A | grep LoadBalancer || echo "No LoadBalancer Services"
    kubectl get ingress -A
  2. For each public LoadBalancer Service you want to front with HTTPS, create or reuse an Ingress and backend Service/ports (GCE HTTP(S) Load Balancer only works with NodePort/NEG backends). Example (edit names/namespaces/ports as needed, run from any machine with kubectl access):

    cat <<'EOF' > ingress-backend-svc.yaml
    apiVersion: v1
    kind: Service
    metadata:
    name: my-app
    namespace: default
    spec:
    type: NodePort
    selector:
    app: my-app
    ports:
    - port: 80
    targetPort: 8080
    ---
    apiVersion: networking.k8s.io/v1
    kind: Ingress
    metadata:
    name: my-app-ing
    namespace: default
    annotations:
    kubernetes.io/ingress.class: "gce"
    spec:
    rules:
    - host: myapp.example.com
    http:
    paths:
    - path: /
    pathType: Prefix
    backend:
    service:
    name: my-app
    port:
    number: 80
    EOF

    kubectl apply -f ingress-backend-svc.yaml
  3. Create a Google-managed SSL certificate resource in the same namespace as the Ingress (any machine with kubectl access). Use DNS names that point to the load balancer IP you will get:

    cat <<'EOF' > managed-cert.yaml
    apiVersion: networking.gke.io/v1
    kind: ManagedCertificate
    metadata:
    name: my-app-cert
    namespace: default
    spec:
    domains:
    - myapp.example.com
    EOF

    kubectl apply -f managed-cert.yaml
  4. Attach the ManagedCertificate to the Ingress via annotation (any machine with kubectl access):

    kubectl annotate ingress -n default my-app-ing \
    "networking.gke.io/managed-certificates=my-app-cert" \
    --overwrite
  5. Wait for the managed certificate and Ingress to become active, then decommission old public LoadBalancer Services once traffic is confirmed on HTTPS (any machine with kubectl access):

    # Watch certificate status
    kubectl describe managedcertificate -n default my-app-cert | egrep 'Status:|Domain Status'

    # Watch Ingress IP and provisioning
    kubectl get ingress -n default my-app-ing -w

    # When stable and tested over HTTPS, delete obsolete LoadBalancer Services
    kubectl get svc -A | grep LoadBalancer
    # Example:
    kubectl delete svc -n default old-lb-service-name
  6. Verify compliance from any machine with kubectl and jq installed:

    svc_json="$(kubectl get svc -A -o json 2>/dev/null || echo '{"items":[],"__err":"SVC_FORBIDDEN"}')"
    ing_json="$(kubectl get ingress -A -o json 2>/dev/null || echo '{"items":[],"__err":"INGRESS_FORBIDDEN"}')"
    mc_json="$(kubectl get managedcertificates -A -o json 2>/dev/null || echo '{"items":[],"__err":"MC_FORBIDDEN"}')"

    printf '%s\n%s\n%s\n' "$svc_json" "$ing_json" "$mc_json" \
    | jq -rs '
    (.[0] // {}) as $svcsRaw |
    (.[1] // {}) as $ingsRaw |
    (.[2] // {}) as $mcsRaw |
    if ($svcsRaw.__err or $ingsRaw.__err or $mcsRaw.__err) then
    "ERROR_KUBECTL_LIST:" +
    ([
    ($svcsRaw.__err // empty),
    ($ingsRaw.__err // empty),
    ($mcsRaw.__err // empty)
    ] | join(","))
    else
    ($svcsRaw.items // []) as $svcs |
    ($ingsRaw.items // []) as $ings |
    ($mcsRaw.items // []) as $mcs |
    def trim: gsub("^\\s+|\\s+$";"");
    def hasmc($ns;$name): any($mcs[]?; .metadata.namespace==$ns and .metadata.name==$name);
    ([
    $svcs[]? | select(.spec.type=="LoadBalancer")
    | "FOUND_PUBLIC_LB_SERVICE:\(.metadata.namespace // "default"):\(.metadata.name)"
    ] + [
    $ings[]? as $i
    | ($i.metadata.annotations."networking.gke.io/managed-certificates" // "") as $ann
    | select($ann=="")
    | "FOUND_INGRESS_WITHOUT_MANAGED_CERT:\($i.metadata.namespace // "default"):\($i.metadata.name)"
    ] + [
    $ings[]? as $i
    | ($i.metadata.annotations."networking.gke.io/managed-certificates" // "") as $ann
    | select($ann!="")
    | ($i.metadata.namespace // "default") as $ns
    | ($ann | split(",") | map(trim) | map(select(length>0)) | .[]) as $mc
    | select(hasmc($ns;$mc) | not)
    | "FOUND_MISSING_MANAGED_CERT_RESOURCE:\($ns):\($i.metadata.name):cert=\($mc)"
    ]) as $f
    | if ($f|length)>0
    then $f[]
    else "ALL_INGRESSES_USE_MANAGED_CERTS_AND_NO_PUBLIC_LB_SERVICES"
    end
    end
    '
Using kubectl

kubectl cannot configure Google-managed SSL Certificates or convert LoadBalancer Services into Ingress resources; those changes must be made in the GCP console, gcloud CLI, or your IaC (GKE/Cloud Load Balancing configuration). Refer to the Manual Steps section for the exact cloud-side changes to replace public LoadBalancer Services with Ingress objects that use Google-managed certificates.

Automation
#!/usr/bin/env bash
#
# automate_managed_ssl_gke.sh
#
# Scope: run on any machine with:
# - gcloud CLI configured for the target project
# - kubectl with access to the target GKE cluster/context
#
# What it does (idempotent):
# - Detects Services of type LoadBalancer (does NOT auto-convert them; prints guidance)
# - For each Ingress that:
# * has no managed-certificates annotation, OR
# * has an annotation but some referenced ManagedCertificates are missing
# it:
# * creates/keeps one Google ManagedCertificate per Ingress
# named "<ingress-name>-mc" in the same namespace
# * annotates the Ingress with that single managed-cert reference
# - At the end, re-runs the benchmark audit logic to show remaining findings.
#
# IMPORTANT: You MUST provide a domain for each Ingress.
# This script expects a mapping file to avoid guessing domains.
#
# DOMAIN MAPPING FILE FORMAT (YAML-ish, parsed via grep/sed, keep it simple):
# # lines starting with # are comments
# namespace/ingress-name: example.com
# namespace/another-ing: app.example.org
# default/web: web.example.com
#
# Example usage:
# PROJECT_ID="my-proj" CLUSTER="my-cluster" LOCATION="us-central1" \
# DOMAIN_MAP="ingress_domains.map" ./automate_managed_ssl_gke.sh
#

set -euo pipefail

PROJECT_ID="${PROJECT_ID:-}"
CLUSTER="${CLUSTER:-}"
LOCATION="${LOCATION:-}"
DOMAIN_MAP="${DOMAIN_MAP:-ingress_domains.map}"

if [[ -z "$PROJECT_ID" || -z "$CLUSTER" || -z "$LOCATION" ]]; then
echo "ERROR: Set PROJECT_ID, CLUSTER, and LOCATION environment variables."
exit 1
fi

if [[ ! -f "$DOMAIN_MAP" ]]; then
echo "ERROR: DOMAIN_MAP file '$DOMAIN_MAP' not found."
echo "Create it with lines like: namespace/ingress-name: example.com"
exit 1
fi

# Ensure kubectl context is set to the target GKE cluster
echo "Configuring kubectl for cluster '${CLUSTER}' in '${LOCATION}' (project '${PROJECT_ID}')..."
gcloud container clusters get-credentials "$CLUSTER" --zone "$LOCATION" --project "$PROJECT_ID"

# Helper: trim whitespace
trim() { sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//'; }

# Helper: lookup domain for namespace/ingress from DOMAIN_MAP
lookup_domain() {
local ns="$1"
local ing="$2"
local key="${ns}/${ing}"
# Match "ns/ing:" at line start; ignore comments and blank lines
local line
line="$(grep -E "^[[:space:]]*${key}:" "$DOMAIN_MAP" 2>/dev/null || true)"
if [[ -z "$line" ]]; then
return 1
fi
# Extract substring after first colon
local dom
dom="$(printf '%s\n' "$line" | sed 's/^[^:]*:[[:space:]]*//')"
dom="$(printf '%s\n' "$dom" | trim)"
if [[ -z "$dom" ]]; then
return 1
fi
printf '%s\n' "$dom"
}

# 1) Detect public LoadBalancer Services (read-only; no auto-conversion)
echo "Scanning for Services of type LoadBalancer..."
lb_svcs_json="$(kubectl get svc -A -o json 2>/dev/null || echo '{"items":[]}')"
lb_count="$(printf '%s\n' "$lb_svcs_json" | jq '[.items[]? | select(.spec.type=="LoadBalancer")] | length')"

if [[ "$lb_count" -gt 0 ]]; then
echo "WARNING: Found $lb_count Service(s) of type LoadBalancer."
printf '%s\n' "$lb_svcs_json" \
| jq -r '.items[]? | select(.spec.type=="LoadBalancer") | " - " + (.metadata.namespace//"default") + "/" + .metadata.name'
echo "Per CIS GKE 5.6.8: consider replacing these with Ingress resources."
else
echo "No Services of type LoadBalancer found."
fi

# 2) Process Ingresses and ManagedCertificate resources
echo "Scanning Ingresses and ManagedCertificates..."

ings_json="$(kubectl get ingress -A -o json 2>/dev/null || echo '{"items":[],"__err":"INGRESS_FORBIDDEN"}')"
mcs_json="$(kubectl get managedcertificates -A -o json 2>/dev/null || echo '{"items":[]}')"

if echo "$ings_json" | jq -e 'has("__err")' >/dev/null 2>&1; then
echo "ERROR: Unable to list Ingresses with kubectl. Check permissions."
exit 1
fi

# Get array of Ingresses as lines "ns ing annotations_json"
mapfile -t INGS < <(
printf '%s\n' "$ings_json" | jq -r '
.items[]? |
(.metadata.namespace // "default") + " " +
.metadata.name + " " +
( (.metadata.annotations // {}) | @json )
'
)

# Existing ManagedCertificates: "ns name"
mapfile -t EXISTING_MC < <(
printf '%s\n' "$mcs_json" | jq -r '.items[]? | (.metadata.namespace // "default") + " " + .metadata.name'
)

has_mc() {
local ns="$1" name="$2"
for l in "${EXISTING_MC[@]}"; do
if [[ "$l" == "$ns $name" ]]; then
return 0
fi
done
return 1
}

# For each ingress, ensure it uses a single ManagedCertificate "<ingress-name>-mc"
for line in "${INGS[@]}"; do
ns="$(printf '%s\n' "$line" | awk '{print $1}')"
ing="$(printf '%s\n' "$line" | awk '{print $2}')"
ann_json="$(printf '%s\n' "$line" | cut -d' ' -f3-)"

mc_name="${ing}-mc"

# Desired annotation value: single entry "<ingress-name>-mc"
desired_ann="$mc_name"

# Parse current annotation value (if any)
current_ann="$(printf '%s\n' "$ann_json" | jq -r '."networking.gke.io/managed-certificates" // ""')"

# Determine if current annotation already references our mc_name and mc exists
need_update="true"
if [[ -n "$current_ann" ]]; then
# Split by comma, trim, and check if our mc_name is present and exists
if printf '%s\n' "$current_ann" \
| jq -r --arg mc "$mc_name" '
split(",")
| map(gsub("^\\s+|\\s+$";""))
| map(select(length>0))
| any(. == $mc)
' | grep -qx "true"; then
if has_mc "$ns" "$mc_name"; then
# Already using our desired ManagedCertificate
need_update="false"
fi
fi
fi

# Ensure ManagedCertificate resource exists
if ! has_mc "$ns" "$mc_name"; then
domain="$(lookup_domain "$ns" "$ing" || true)"
if [[ -z "$domain" ]]; then
echo "SKIP: Ingress ${ns}/${ing} has no domain mapping in ${DOMAIN_MAP}; cannot create ManagedCertificate."
continue
fi

echo "Creating/refreshing ManagedCertificate ${ns}/${mc_name} for domain ${domain}..."
cat <<EOF | kubectl apply -n "$ns" -f -
apiVersion: networking.gke.io/v1
kind: ManagedCertificate
metadata:
name: ${mc_name}
spec:
domains:
- ${domain}
EOF

# Refresh EXISTING_MC cache entry for idempotence in same run
EXISTING_MC+=("$ns $mc_name")
fi

if [[ "$need_update" == "true" ]]; then
echo "Annotating Ingress ${ns}/${ing} with networking.gke.io/managed-certificates=${desired_ann}..."
kubectl annotate ingress "${ing}" \
-n "${ns}" \
"networking.gke.io/managed-certificates=${desired_ann}" \
--overwrite
else
echo "Ingress ${ns}/${ing} already references existing ManagedCertificate ${mc_name}; no change."
fi
done

echo
echo "Waiting briefly for Kubernetes API to reflect changes..."
sleep 5

# 3) Verification: re-run the benchmark audit logic
echo
echo "=== Verification (CIS GKE 5.6.8 check) ==="
svc_json="$(kubectl get svc -A -o json 2>/dev/null || echo '{"items":[],"__err":"SVC_FORBIDDEN"}')"
ing_json="$(kubectl get ingress -A -o json 2>/dev/null || echo '{"items":[],"__err":"INGRESS_FORBIDDEN"}')"
mc_json="$(kubectl get managedcertificates -A -o json 2>/dev/null || echo '{"items":[],"__err":"MC_FORBIDDEN"}')"

printf '%s\n%s\n%s\n' "$svc_json" "$ing_json" "$mc_json" \
| jq -rs '
(.[0] // {}) as $svcsRaw |
(.[1] // {}) as $ingsRaw |
(.[2] // {}) as $mcsRaw |

if ($svcsRaw.__err or $ingsRaw.__err or $mcsRaw.__err) then
"ERROR_KUBECTL_LIST:" +
([
($svcsRaw.__err // empty),
($ingsRaw.__err // empty),
($mcsRaw.__err // empty)
] | join(","))
else
($svcsRaw.items // []) as $svcs |
($ingsRaw.items // []) as $ings |
($mcsRaw.items // []) as $mcs |

def trim: gsub("^\\s+|\\s+$";"");
def hasmc($ns;$name): any($mcs[]?; .metadata.namespace==$ns and .metadata.name==$name);

([
$svcs[]? | select(.spec.type=="LoadBalancer")
| "FOUND_PUBLIC_LB_SERVICE:\(.metadata.namespace // "default"):\(.metadata.name)"
] + [
$ings[]? as $i
| ($i.metadata.annotations."networking.gke.io/managed-certificates" // "") as $ann
| select($ann=="")
| "FOUND_INGRESS_WITHOUT_MANAGED_CERT:\($i.metadata.namespace // "default"):\($i.metadata.name)"
] + [
$ings[]? as $i
| ($i.metadata.annotations."networking.gke.io/managed-certificates" // "") as $ann
| select($ann!="")
| ($i.metadata.namespace // "default") as $ns
| ($ann | split(",") | map(trim) | map(select(length>0)) | .[]) as $mc
| select(hasmc($ns;$mc) | not)
| "FOUND_MISSING_MANAGED_CERT_RESOURCE:\($ns):\($i.metadata.name):cert=\($mc)"
]) as $f
| if ($f|length)>0
then $f[]
else "ALL_INGRESSES_USE_MANAGED_CERTS_AND_NO_PUBLIC_LB_SERVICES"
end
end
'

echo "=== End of verification ==="

Additional Reading: