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
Remediation​
Manual Steps
-
On any machine with
gcloudandkubectlaccess, list external LoadBalancer Services and candidate apps to migrate to Ingress:kubectl get svc -A -o wide | grep LoadBalancer || echo "No LoadBalancer services" -
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.
-
On any machine with
gcloud/kubectlaccess, create aManagedCertificatefor 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/v1kind: ManagedCertificatemetadata:name: example-com-certnamespace: defaultspec:domains:- example.com- www.example.comEOFkubectl apply -f managed-cert-example.yaml -
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
gcecontroller - References the
ManagedCertificatevia annotation:
cat > ingress-my-app.yaml << 'EOF'apiVersion: networking.k8s.io/v1kind: Ingressmetadata:name: my-app-ingressnamespace: defaultannotations:kubernetes.io/ingress.class: "gce"networking.gke.io/managed-certificates: "example-com-cert"spec:rules:- host: example.comhttp:paths:- path: /pathType: Prefixbackend:service:name: my-app-serviceport:number: 80# Optional HTTPS redirect via BackendConfig can be added separatelyEOFkubectl apply -f ingress-my-app.yaml
- Changing the Service to
-
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 wideCreate/modify A (and/or CNAME) records for
example.comandwww.example.comto target the Ingress IP. Wait for DNS to propagate and for the ManagedCertificate to becomeActive:kubectl -n default get managedcertificate example-com-cert -o yaml | grep -A3 status: -
Verify overall compliance from any machine with
kubectlandjq: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)>0then $f[]else "ALL_INGRESSES_USE_MANAGED_CERTS_ANDNO_PUBLIC_LB_SERVICES"endend'
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."