This page is generated from devops-agent/eks-ingress-migration/references/ingress-discovery.md. Edit the source, not this page.
Ingress Discovery
Rating model: Express every finding as Impact 0–5 using the Impact Indicator rubric.
Presence is decided by estate state; severity by priority order. Classify the estate first, then rate only what exists:
- Absent controller / empty estate / orphaned dead config = Non-event (0) — nothing to migrate (an unbuilt or abandoned road); it never inflates a finding. Carve-out — control-plane exposure survives zero routes: a reachable, running controller on a known-CVE/EOL version is a security finding even with zero Ingress objects. Example: ingress-nginx's validating admission webhook (CVE-2025-1974, CVSS 9.8, fixed v1.11.5 / v1.12.1) is exploitable from the pod network with no Ingress configured and the webhook is on by default. "Zero routes" bounds data-plane exposure, never control-plane exposure.
- Present-but-broken controller (CrashLoopBackOff / unreachable) splits by whether it has bound routes — bound routes = Ingress objects whose
spec.ingressClassName(or the deprecatedkubernetes.io/ingress.classannotation) matches this controller's class, i.e. routes this controller is responsible for serving:
- With bound routes → suspected active outage. For an in-pod data plane (ingress-nginx) the pod is the data plane — all replicas down means those routes are down now. Flag it urgently and separately, outside the migration score (migration difficulty is the wrong lens for a live outage). Do not fold it into a silent tech-debt 1.
- With zero bound routes → tech debt (1) + a mandatory cleanup note (deployed then abandoned; the owner must fix or remove it).
- Healthy controller serving live traffic anchors the business and security dimensions at full weight. A healthy migration-target controller (e.g. AWS LB Controller) with nothing bound to migrate is 0 effort — do not deduct.
Verify before you downgrade (conservative default). "Serves no live traffic" must be evidenced (read-only), not inferred from pod status: for ingress-nginx confirm all replicas are down (one crashlooping replica while others serve is still live); for the AWS Load Balancer Controller the data plane is the ALB/NLB, which keeps serving registered targets while the controller pod is down or even uninstalled — check load-balancer/target-group state (
aws elbv2 describe-load-balancers,aws elbv2 describe-target-health --target-group-arn <arn>forhealthytargets, and CloudWatchRequestCount/ActiveConnectionCounton the LB), not the pod. If you cannot verify zero traffic, treat the estate as live. Where read-only evidence points to a state, say so in the report.Priority order (sets severity and breaks ties — it does not gate presence): (1) business logic / revenue — the live traffic at stake · (2) security / reputation · (3) effort. Effort is NOT a severity driver — how hard a fix is depends on who implements it (trivial for an expert, hard for a novice), so never raise or lower Impact by remediation effort; if effort is mentioned, label it an operator note. Security findings anchor on exposure / blast radius, business findings on live traffic; priority order breaks ties, it does not zero out a real security exposure just because the business traffic behind it is small.
Three independent dimensions stack (they do not override each other): a single controller/route can carry (a) a migration-difficulty deduction (config complexity), (b) a tech-debt deduction (present-but-broken with zero bound routes = +1; a broken controller with bound routes is instead a suspected active outage flagged outside the score — not a stacked deduction), and (c) a security deduction (CVE/EOL). Priority order ranks the dimensions within one finding; stacking means each of the three gets its own row in the Score Breakdown and they add up.
Band mapping is a starting point — 🟢 0 / 🟡 1–2 / 🟠 3–4 / 🔴 5 — but the Impact Indicator criteria set the final score (e.g. an easy-to-deploy prerequisite stays 🟡 low even if it blocks a path). All checks are read-only (Kubernetes API
get/list, AWSDescribe/List).
Purpose
Discover all ingress controllers, IngressClass resources, and Ingress objects in the cluster.
Checks to Execute
1.1 — Ingress Controllers Installed
What to check:
- Deployments/DaemonSets running ingress controllers
- Common controllers: nginx-ingress, AWS LB Controller, Traefik, HAProxy, Istio, Contour, Kong
- ⚠️ Disambiguate the two NGINX controllers. This skill's annotation mappings, CVE bands and retirement guidance are all for the community
ingress-nginxcontroller (annotation prefixnginx.ingress.kubernetes.io/, imageregistry.k8s.io/ingress-nginx/controller). F5's commercial NGINX Ingress Controller (nginx-ingress, annotation prefixnginx.org/, imagenginx/nginx-ingress) is a different product on its own support lifecycle — it is out of scope here. If you findnginx.org/*annotations or the F5 image, say so explicitly and do not apply this skill's ingress-nginx findings to it.
- ⚠️ Disambiguate the two NGINX controllers. This skill's annotation mappings, CVE bands and retirement guidance are all for the community
How to check:
- List Deployments across all namespaces → filter for ingress-related names
- List DaemonSets across all namespaces → filter for ingress-related names
- Check namespaces:
ingress-nginx,kube-system,aws-load-balancer-controller - List pods with labels:
app.kubernetes.io/name=ingress-nginx,app.kubernetes.io/name=aws-load-balancer-controller
Impact (per Impact Indicator — mind presence vs. absence, and verify before downgrading):
- 🟢 0 (Non-event): No controller found / absent / empty estate — nothing to migrate (unbuilt road). Contributes 0; never rate this as a finding. If Ingress objects exist without any controller, see the orphaned-config handling in
report-generation.mdStep 1 (still 0, but emit the Migration Crew Alert note). - ⚠️ Active outage (NOT a migration-score item): controller present but broken (
CrashLoopBackOff/ImagePullBackOff/ unreachable) AND Ingress objects are bound to it. For an in-pod data plane (ingress-nginx) those routes are down now — surface it as an urgent flag, separately and outside the 0–100 score (a live-outage question, not a migration-difficulty one). Verify first: for ingress-nginx confirm all replicas are down (a multi-replica controller can serve while one pod crashloops); for the AWS LB Controller check the ALB/target-group state (the ALB keeps serving while the pod is down). If you cannot verify zero traffic, treat it as live. - 🟡 1 (Tech debt): Controller present but broken with zero bound routes — deployed then abandoned. Deduct 1 and emit a mandatory cleanup note: it carries no live traffic but can mis-align/mis-configure other systems, so the cluster owner must fix or remove it. This is not a migration-difficulty rating; if it also has complex config, that complexity is scored separately under its own categories.
- 🟢 0 / 🟡 1–2 (Low): Single healthy modern controller. A healthy migration-target controller (AWS LB Controller v2.x) with nothing bound to migrate is 0 effort — do not deduct; a healthy controller you are migrating from (e.g. nginx) that serves live routes is 🟡 1–2.
- 🟠 3–4 (Medium): Multiple controllers, or a legacy controller (nginx-ingress, ALB Ingress Controller v1). A broken controller still counts as a present controller for the "multiple controllers" assessment; its brokenness is scored by the rows above, not double-counted here.
- Security carve-out: a reachable known-CVE/EOL controller is a security finding even with zero routes (control-plane exposure) — rate it under §1.4 (Controller Currency, EOL & CVE Exposure), not here.
- ⬜ Unknown: Cannot determine controller health — state what to check and why.
1.2 — IngressClass Resources
What to check:
- IngressClass resources defined in the cluster
- Default IngressClass annotation (
ingressclass.kubernetes.io/is-default-class: "true") - Whether Ingress resources reference a specific IngressClass
How to check:
- List IngressClass resources (networking.k8s.io/v1)
- Check for default class annotation
- Cross-reference with Ingress resources'
spec.ingressClassName
Impact (per Impact Indicator — mind presence vs. absence; the behaviors below are version-specific — state them correctly):
- 🟢 0 (Non-event): No controller installed — IngressClass findings are moot (nothing reconciles them, whether or not Ingress objects exist); route through the
report-generation.md§1.0 short-circuit. Also 0 for an empty estate (no IngressClass, no controller, no Ingress). - 🟡 1–2 (Low): IngressClass defined, a default set, and Ingress resources reference it explicitly via
ingressClassName. - 🟠 3–4 (Medium): IngressClass exists but Ingress resources use the deprecated
kubernetes.io/ingress.classannotation instead ofingressClassName; or Ingress resources exist with no class and no default IngressClass set. Spec-compliant controllers are permitted to ignore Ingresses without a class (IngressClass API: "implementations may choose to ignore Ingresses without a class specified"); ingress-nginx serves them only if started with--watch-ingress-without-class=true(defaultfalse) or if an IngressClass is marked default (ingressclass.kubernetes.io/is-default-class: "true"). The real risk is silent non-reconciliation — routes quietly not served — not an "ambiguous/implicit default". - 🔴 5 (High): Multiple IngressClasses marked default. On all EKS-supported Kubernetes versions (v1.25+) the
DefaultIngressClassadmission plugin silently assigns the newest default (bycreationTimestamp, alphabetically-lowest name as tiebreak) to a classless Ingress — admission succeeds, it does not reject. The real risk is therefore silent misrouting / cutover ambiguity: a classless Ingress quietly binds to a class nobody intended, so traffic can land on the wrong controller during a cutover. This is the source-of-truth behavior, verified in theDefaultIngressClassadmission-plugin code across tags v1.25 → v1.36 and its merge PR kubernetes/kubernetes#110974. (Historical note — Kubernetes ≤ v1.24 only: the plugin rejected creation of a classless Ingress while multiple defaults existed; that behavior was removed in v1.25.0 by the PR above. The kubernetes.io Default IngressClass concept page still describes the old rejection behavior — it is stale for the versions this skill targets and must not be read as a live EKS outcome.) Either way the remediation is identical: keep exactly one default IngressClass. - ⬜ Unknown: Cannot determine IngressClass usage.
1.3 — Ingress Resource Inventory
What to check:
- Total Ingress resources across all namespaces
- Which namespaces have Ingress resources
- Ingress resources without an IngressClass (will use default)
How to check:
- List all Ingress resources (networking.k8s.io/v1) across all namespaces
- Count per namespace
- Check each for
spec.ingressClassNameorkubernetes.io/ingress.classannotation
Impact (per Impact Indicator):
- 🟡 1–2 (Low): All Ingress resources have explicit IngressClass, manageable count (<50)
- 🟠 3–4 (Medium): Some Ingress resources missing IngressClass, or high count (50-200)
- 🔴 5 (High): >200 Ingress resources, or many without IngressClass assignment
- ⬜ Unknown: Cannot list Ingress resources
1.3-A — TCP/UDP Service Exposure (L4 flows with no Ingress object)
Why this is its own check: ingress-nginx also proxies raw TCP/UDP via the --tcp-services-configmap / --udp-services-configmap flags. Those flows are declared in a ConfigMap plus a controller Service port — there is no Ingress object anywhere. §1.3's inventory therefore cannot see them, and neither can the Routing Topology table. Missing them silently drops working production traffic at cutover.
What to check (read-only):
- Controller Deployment
argsfor--tcp-services-configmap=<ns>/<name>and--udp-services-configmap=<ns>/<name> - The referenced ConfigMaps — each entry is
<external-port>: <namespace>/<service>:<service-port> - The controller Service's ports other than 80/443 — an L4 entry only reaches the cluster if the Service publishes its port, so an entry with no matching Service port is dead config (record it as such, don't count it as a live route)
How to check:
- Read the controller Deployment's container args; extract the two ConfigMap references
- Get each referenced ConfigMap and enumerate its
dataentries - List the controller Service and record every non-80/443 port, matching it to the ConfigMap entries
Migration consequence (state it in the report): TCP/UDP flows map to TCPRoute / UDPRoute on an NLB Gateway — a separate Gateway from the L7 ALB one, because LBC does not support mixing protocol layers on a single Gateway. They also need the L4 CRDs, and from Gateway API v1.6.0 TCPRoute/UDPRoute are in the standard channel (below v1.6.0 they require the experimental install). Plan a second Gateway, not an extra listener.
Impact (per Impact Indicator):
- 🟢 0: no
--tcp/udp-services-configmaparg and no non-80/443 controller Service port - 🟡 1–2 (Low): a small number of L4 entries, all mapping cleanly to TCPRoute/UDPRoute
- 🟠 3–4 (Medium): L4 entries present and carrying live business traffic — a second (NLB) Gateway plus its own cutover is now in scope
- ⬜ Unknown: fail closed. If the controller args or the ConfigMaps cannot be read, record L4 exposure as Unverified and state that the route inventory is a lower bound. Never infer "no L4 services" from a failed or denied read.
1.4 — Controller Currency, EOL & CVE Exposure
What to check (read-only):
- The container image tag/version of each ingress controller.
- Whether that version is end-of-life / unsupported or carries known CVEs.
- For ingress-nginx specifically: whether snippet annotations are enabled (injection surface).
How to check (read-only):
- Via Kubernetes API — read each controller
Deployment(apps/v1) found in 1.1 and take the version tag fromspec.template.spec.containers[0].image. - Compare each version against the project's supported-versions table — but read it as a frozen historical artifact, not a live support matrix: for ingress-nginx that table stopped moving when the project was archived (see "Retirement status" below), so "appears in the table" does not mean "supported today".
- For ingress-nginx, read the controller
ConfigMap(core/v1) in the controller's namespace and check theallow-snippet-annotationsandannotations-risk-levelkeys. - Admission-webhook exposure (required for the control-plane CVE band below): confirm whether the validating admission webhook is actually present and reachable — do NOT assume from the version alone. List
ValidatingWebhookConfiguration(admissionregistration.k8s.io/v1) and look for the ingress-nginx entry (default nameingress-nginx-admission); cross-check the controller Deployment args (spec.template.spec.containers[0].args) and theingress-nginx-controller-admissionService (core/v1).Runtime caveat — expect this read to be denied.
admissionregistration.k8s.iois outside the built-in groups an access entry is likely to grant, soValidatingWebhookConfigurationcommonly returns403 Forbiddenunless a supplementary read-only ClusterRole was bound (seereferences/supplementary-rbac.md; the managed policy's exact rules are not published, so do not assume either way). A denied read is not evidence of absence: it lands in the Unverified (fail closed) branch below, which is scored as exposed. Reading the controller Deployment args (appsgroup, which is covered) is therefore the primary signal here — the VWC read is corroboration. These object names are built from the chart's fullname, not the bare release name: it is the release name when that already contains the chart name, otherwise<release>-ingress-nginx. So the VWC isingress-nginx-admissionfor a release namedingress-nginx, but<release>-ingress-nginx-admissionotherwise (and the Service likewise…-controller-admission) — match by the controller's owner references / labels, not just the literal default names. The webhook ships enabled by default in Helm installs. Decide exposure as follows and record the exact state (webhook: exposed | not-exposed | unverified) in the finding — do not collapse it to a bare present/absent flag:- Exposed — the controller Deployment is started with a non-empty
--validating-webhook=<address>arg (e.g.--validating-webhook=:8443, the Helm default), so the webhook server is actually listening on the controller pod (pod-IP:8443). This is the CVE-2025-1974 attack surface: the exploit reaches the pod's webhook server directly over the pod network, so on a< v1.11.5 / < v1.12.1controller the control-plane path is live regardless of route count (🔴 5 band). A presentingress-nginx-admissionVWC and a backedingress-nginx-controller-admissionService corroborate that the webhook is also wired into the API server, but it is the listening server that makes it exploitable. - Not exposed — the necessary condition is that the webhook server is not listening: the controller Deployment has no (or an empty)
--validating-webhook=<address>arg. That flag takes a service-address string, not a boolean — there is no--validating-webhook=falseform; when the value is absent/empty the server is simply never started. An absent VWC and/or an absent admission Service are corroborating signals (a Helmcontroller.admissionWebhooks.enabled=falseinstall drops the VWC, the Service and the arg together) but do NOT, on their own, close the path: the VWC and admission Service are API-server-side plumbing, whereas the exploit targets the pod's listening webhook server at pod-IP:8443 directly. Only a confirmed server-not-listening (arg absent/empty) drops the 🔴 5 band; then note it and fall back to the data-plane assessment. - Unverified (fail closed) — if you cannot confirm the server is not listening — the controller Deployment args are
Forbidden/unreadable, or only a partial read succeeded (e.g. you could read the VWC or the Service but not the args) — you have not verified the necessary condition, so default to exposed for a< v1.11.5 / < v1.12.1controller (the webhook is on by default; a missing read must not silently drop the 🔴 5 band). This takes precedence over any VWC/Service-absent observation: an absent VWC with unreadable controller args is still Unverified → exposed, because the absence of the API-server plumbing does not prove the pod's webhook server is down. Score it as exposed and flag "webhook state unverified — assumed exposed; re-check the controller args with cluster-admin."
- Exposed — the controller Deployment is started with a non-empty
Deterministic version facts (cite in the finding):
- ingress-nginx
< v1.9.0is affected by CVE-2023-5043 / CVE-2023-5044 (configuration-snippet / permanent-redirect annotation injection → arbitrary command execution / privilege escalation). Treat any controller< v1.9.0as a security finding. - Retirement status — ingress-nginx is retired and permanently unpatched (checked 2026-09-13). The
kubernetes/ingress-nginxrepository was archived by its owners on 2026-03-24 and is now read-only. Upstream's own retirement notice states that after best-effort maintenance ended in March 2026 there are "no further releases, no bugfixes, and no updates to resolve any security vulnerabilities"; the supported-versions table is frozen at v1.15.1. Existing deployments keep running and published images/charts remain downloadable, so a cluster will not break on its own — but every ingress-nginx version, including the newest, is now permanently unpatched against any CVE discovered from here on. Cite the retirement announcement. State this as a fact in the report; never imply a "current"/"patched" ingress-nginx exists or that migration is optional maintenance. - Since v1.9.1,
allow-snippet-annotationsdefaults tofalse— v1.9.1 is where the default flipped, not where the setting appeared.allow-snippet-annotationsdoes exist in v1.9.0, where it defaults totrue, so a v1.9.0 controller allows snippets by default (verified ininternal/ingress/controller/config/config.goat tagcontroller-v1.9.0: the field is present andNewDefault()sets ittrue). What v1.9.0 lacks isannotations-risk-level, which landed in v1.9.1 (2023-10-03). Note thatannotations-risk-leveldefaults toHigh, which admits High-risk annotations and blocks onlyCritical— a default-configured controller is not "hardened to Low". If a cluster setsallow-snippet-annotations: "true", it re-opens the injection surface — flag it. - AWS Load Balancer Controller: no separately documented minimum for the plain ALB Ingress path (use a currently supported release — the latest line is v3.5.0; currency is dated in
gateway-api.md) — but v2.14.1+ iftransformsURI rewrites are needed, and ≥ v3.0.0 for Gateway API (the production floor; L4/L7 reconciliation began at v2.13.3 / v2.14.0 but upstream flagged the whole v2.x line as not for production).
Impact (per Impact Indicator — anchor on EXPOSURE / blast-radius for security, and on live traffic for business; never on patch effort):
A CVE/EOL finding's severity comes from what it exposes, not how hard the upgrade is. Two exposure surfaces exist and are independent:
- Data-plane exposure scales with the live traffic the controller serves (business-critical routes > internal > none).
- Control-plane exposure is the controller's own attack surface (e.g. ingress-nginx's validating admission webhook) and exists whenever the controller process is running and reachable — regardless of how many routes it serves. "Zero routes" does NOT imply "not exploitable."
Floor — a running ingress-nginx controller is never Currency-clean (minimum 🟠 3). Because the project is archived and permanently unpatched (see "Retirement status" above), there is no ingress-nginx version that can be called current. Any running ingress-nginx controller therefore scores at least 🟠 3 on this category, even at the newest release (v1.15.1) with the webhook confirmed not-exposed and snippet hardening intact. Rationale: the estate carries a standing, unfixable security-patch gap, so a "clean" Currency row would tell the reader migration is optional when it is not. This floor raises a band, never lowers one — the 🔴 5 conditions below still govern where they apply, and 🟢 0 remains correct only for an absent or fully-down controller (nothing is running, so nothing is exposed). It applies to ingress-nginx specifically; other controllers are still judged against their own live support matrices.
- 🟢 0 (Non-event): The controller is absent, or fully down — all replicas
CrashLoopBackOff/unreachable, so neither the data plane nor the admission webhook is serving. Record as an informational note, deduct 0. (A broken-with-zero-routes controller still earns its separate §1.1 tech-debt point — that is not a CVE deduction; a broken-with-bound-routes controller is an active outage, handled in §1.1.) - 🟡 1–2 (Low): EOL/CVE controller serving only non-critical / internal / low-traffic routes, with no known control-plane RCE; snippet hardening intact.
- 🟠 3–4 (Medium): A controller is behind/approaching EOL, or
allow-snippet-annotations=trueis set on a current controller (injection surface re-opened), serving routes of moderate business importance. - 🔴 5 (High): Either (a) an EOL/unsupported controller with known CVEs (e.g. ingress-nginx
< v1.9.0) actively serving business-critical / revenue / public-facing live traffic; or (b) a running controller exposing a known control-plane RCE regardless of route count — e.g. ingress-nginx< v1.11.5 / < v1.12.1with the validating admission webhook exposed (or unverified — see the tri-state in "How to check" step 4; unverified defaults to exposed) (CVE-2025-1974, CVSS 9.8): exploitable from the pod network with zero Ingress objects, leading to cluster-wide Secret disclosure / takeover. A healthy, zero-route vulnerable controller is a critical finding here — not a non-event. - ⬜ Unknown: Cannot read controller image/version — state what to check.
Every controller version found MUST appear in the report (Current Configuration + Ingress Discovery), with EOL/CVE status called out — do not roll multiple controllers into one line.
Remediation sequencing (SAFETY — do not get this wrong): setting
allow-snippet-annotations: falseis a breaking change for any Ingress currently using snippet annotations — the controller drops those routes and can cause immediate downtime. If snippet-using ingresses exist (cross-check §3.1), you MUST NOT recommend disabling it as an "urgent / Day-1 / immediate" action. Sequence it after those routes are migrated or redesigned. The recommendation wording must read "re-disable snippet annotations after migrating the snippet routes", never "urgent: set false now". The same applies to retiring an EOL controller that still serves live routes — migrate first, retire last.
1.5 — EKS Auto Mode Detection
What to check (read-only):
- Whether the cluster runs EKS Auto Mode (changes how load balancing is provided).
How to check (read-only):
- EKS
DescribeCluster— readcluster.computeConfig; Auto Mode is enabled whencomputeConfig.enabled = true(with managednodePools). - Recognize Auto Mode's managed load-balancing IngressClass:
spec.controller: eks.amazonaws.com/alb(parametersapiGroup: eks.amazonaws.com,kind: IngressClassParams); NLB vialoadBalancerClass: eks.amazonaws.com/nlb. This is distinct from the self-managed LBC (ingress.k8s.aws/alb).
Why it matters: on Auto Mode the ALB Ingress path needs no self-managed LBC install (it's built in); a eks.amazonaws.com/alb IngressClass is a managed controller, not a missing one. Gateway API is not part of Auto Mode's built-in load balancing (Ingress + Service type: LoadBalancer only, as of 2026-09-01), so a Gateway API target still requires a self-managed LBC at ≥ v3.0.0.
Impact (per Impact Indicator): informational — record Auto Mode status in Current Configuration; it does not by itself carry a migration impact, but it changes the Migration Options guidance.