Skip to main content

Ensure Use Of Google-Managed SSL Certificates

More Info:​

Use Google-managed SSL certificates on Ingress resources so TLS certificates are automatically provisioned and rotated. This avoids expired or mismanaged manual certificates and exposed LoadBalancer services.

Risk Level​

Medium

Address​

Security

Compliance Standards​

  • CIS GKE

Triage and Remediation​

Remediation​

Manual Steps
  1. On any machine with gcloud and kubectl access, list external LoadBalancer Services and candidate apps to migrate to Ingress:

    kubectl get svc -A -o wide | grep LoadBalancer || echo "No LoadBalancer services"
  2. For each HTTP/HTTPS LoadBalancer Service you want to protect with managed certs, create or reuse a DNS name that points to the GKE Ingress IP (will be known after step 5). Ensure you control the DNS zone in Cloud DNS or another DNS provider.

  3. On any machine with gcloud/kubectl access, create a ManagedCertificate for each domain you want Google to manage (replace values with your real domain and namespace):

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

    kubectl apply -f managed-cert-example.yaml
  4. Migrate the existing external Service to Ingress by:

    • Changing the Service to ClusterIP (so it is only internal to the cluster), for example:
      kubectl -n default patch service my-app-service \
      -p '{"spec":{"type":"ClusterIP","externalIPs":null,"loadBalancerIP":null}}'
    • Creating an Ingress that:
      • Routes to that Service
      • Uses the Google HTTP(S) Load Balancer gce controller
      • References the ManagedCertificate via annotation:
      cat > ingress-my-app.yaml << 'EOF'
      apiVersion: networking.k8s.io/v1
      kind: Ingress
      metadata:
      name: my-app-ingress
      namespace: default
      annotations:
      kubernetes.io/ingress.class: "gce"
      networking.gke.io/managed-certificates: "example-com-cert"
      spec:
      rules:
      - host: example.com
      http:
      paths:
      - path: /
      pathType: Prefix
      backend:
      service:
      name: my-app-service
      port:
      number: 80
      # Optional HTTPS redirect via BackendConfig can be added separately
      EOF

      kubectl apply -f ingress-my-app.yaml
  5. After the Ingress is created, obtain its external IP and point your DNS records at it (run on any machine with kubectl):

    kubectl -n default get ingress my-app-ingress -o wide

    Create/modify A (and/or CNAME) records for example.com and www.example.com to target the Ingress IP. Wait for DNS to propagate and for the ManagedCertificate to become Active:

    kubectl -n default get managedcertificate example-com-cert -o yaml | grep -A3 status:
  6. Verify overall compliance from any machine with kubectl and jq:

    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_ANDNO_PUBLIC_LB_SERVICES"
    end
    end
    '
Using kubectl

kubectl cannot remediate this finding because it requires configuring Google-managed SSL certificates and Ingress behavior at the GKE / cloud provider configuration and IaC layer. Make the changes through the Google Cloud Console, gcloud, or your IaC (per the Manual Steps section), then re-run the audit command from any machine with kubectl access to confirm the issue is resolved.

Automation
#!/usr/bin/env bash
#
# automate-gke-managed-certs.sh
#
# Idempotently:
# - Detects public Services of type LoadBalancer
# - Creates corresponding Ingress objects that use Google-managed SSL certificates
# - Creates ManagedCertificate resources per service
# - Leaves existing Ingresses/ManagedCertificates untouched
# - Verifies using the benchmark audit logic
#
# REQUIREMENTS (run on any machine with kubectl and jq configured for the cluster):
# - kubectl
# - jq
#
# LIMITATIONS:
# - This cannot change GKE control-plane settings; it only applies Kubernetes
# resources via the API.
# - It assumes HTTP applications on port 80 behind the Service. You may need to
# adjust hostnames and paths for production use.

set -euo pipefail

# ------------- CONFIGURABLE SETTINGS ----------------

# Default DNS suffix to use when fabricating hostnames for demo purposes.
# In real environments, set this to a domain you control, or replace logic
# below to read proper hostnames from an inventory.
DEFAULT_DOMAIN="example.com"

# Label selector to filter which public LoadBalancer Services to convert.
# Leave empty ("") to target all.
SVC_LABEL_SELECTOR=""

# ------------- HELPER FUNCTIONS ---------------------

log() {
printf '[%s] %s\n' "$(date -u +'%Y-%m-%dT%H:%M:%SZ')" "$*" >&2
}

kubectl_json() {
# Wrapper to avoid repetitive 2>/dev/null.
kubectl "$@" -o json 2>/dev/null
}

# Idempotently apply a manifest from stdin
kapply() {
kubectl apply -f -
}

# ------------- DISCOVERY ----------------------------

log "Discovering Services, Ingresses and ManagedCertificates..."

svc_json="$(kubectl_json get svc -A || echo '{"items":[],"__err":"SVC_FORBIDDEN"}')"
ing_json="$(kubectl_json get ingress -A || echo '{"items":[],"__err":"INGRESS_FORBIDDEN"}')"
mc_json="$(kubectl_json get managedcertificates -A || echo '{"items":[],"__err":"MC_FORBIDDEN"}')"

svcs_err="$(echo "$svc_json" | jq -r '.__err // empty')"
ings_err="$(echo "$ing_json" | jq -r '.__err // empty')"
mcs_err="$(echo "$mc_json" | jq -r '.__err // empty')"

if [[ -n "$svcs_err" || -n "$ings_err" || -n "$mcs_err" ]]; then
log "ERROR: Missing permissions for kubectl list: svc_err='$svcs_err' ing_err='$ings_err' mc_err='$mcs_err'"
log "Grant list access to services, ingresses, and managedcertificates and re-run."
exit 1
fi

# ------------- SELECT PUBLIC LOADBALANCER SERVICES ----------------

log "Selecting candidate Services of type LoadBalancer..."

svc_filter_args=()
if [[ -n "$SVC_LABEL_SELECTOR" ]]; then
svc_filter_args=( -l "$SVC_LABEL_SELECTOR" )
fi

svc_lb_json="$(kubectl get svc -A "${svc_filter_args[@]}" -o json \
| jq '.items // [] | map(select(.spec.type=="LoadBalancer"))')"

svc_count="$(echo "$svc_lb_json" | jq 'length')"

if [[ "$svc_count" -eq 0 ]]; then
log "No public LoadBalancer Services found (after label filtering). Nothing to convert."
else
log "Found $svc_count public LoadBalancer Service(s) to inspect."
fi

# ------------- CREATE MANAGEDCERT + INGRESS PER SERVICE -----------

log "Ensuring Google-managed SSL certificates and Ingress resources exist..."

for idx in $(seq 0 $((svc_count - 1))); do
svc="$(echo "$svc_lb_json" | jq ".[$idx]")"
ns="$(echo "$svc" | jq -r '.metadata.namespace')"
name="$(echo "$svc" | jq -r '.metadata.name')"

# Derive resource names
mc_name="${name}-managed-cert"
ing_name="${name}-ing"

# Choose a hostname. Adjust to real DNS in production.
host="${name}.${ns}.${DEFAULT_DOMAIN}"

log "Processing Service: ${ns}/${name} -> Ingress: ${ns}/${ing_name}, ManagedCertificate: ${ns}/${mc_name}, host: ${host}"

# Check for existing ManagedCertificate
mc_exists="false"
if kubectl get managedcertificate "$mc_name" -n "$ns" >/dev/null 2>&1; then
mc_exists="true"
log " ManagedCertificate ${ns}/${mc_name} already exists; leaving as-is."
fi

# Check for Ingress already annotated to use this ManagedCertificate
ing_exists="false"
if kubectl get ingress "$ing_name" -n "$ns" >/dev/null 2>&1; then
ing_exists="true"
log " Ingress ${ns}/${ing_name} already exists; leaving as-is (idempotent apply will reconcile)."
fi

# Create/Update ManagedCertificate (idempotent)
if [[ "$mc_exists" == "false" ]]; then
log " Creating ManagedCertificate ${ns}/${mc_name}..."
else
log " Updating ManagedCertificate ${ns}/${mc_name} (idempotent)..."
fi

cat <<EOF | kapply
apiVersion: networking.gke.io/v1
kind: ManagedCertificate
metadata:
name: ${mc_name}
namespace: ${ns}
spec:
domains:
- ${host}
EOF

# Build basic HTTP Ingress referencing the Service and ManagedCertificate
# NOTE: This assumes Service exposes port 80. Adjust as needed.
log " Creating/Updating Ingress ${ns}/${ing_name} with managed certificate annotation..."

cat <<EOF | kapply
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: ${ing_name}
namespace: ${ns}
annotations:
kubernetes.io/ingress.class: "gce"
networking.gke.io/managed-certificates: "${mc_name}"
spec:
rules:
- host: ${host}
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: ${name}
port:
number: 80
tls:
- hosts:
- ${host}
EOF

done

# ------------- VERIFICATION (BENCHMARK-AWARE) ---------------------

log "Running verification using the benchmark audit logic..."

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

printf '%s\n%s\n%s\n' "$verify_svc_json" "$verify_ing_json" "$verify_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
' | while read -r line; do
log "VERIFY: $line"
done

log "Script completed. Review verification output above. For any remaining FOUND_PUBLIC_LB_SERVICE entries, decide case-by-case whether they should remain as LoadBalancer or be refactored into Ingress + ManagedCertificate."