Production Services exposed on a static node port
What does ZopNight detect here?
A NodePort Service opens a static port in the 30000-32767 range on every node in the cluster, reachable on any node IP regardless of where the pod runs. ZopNight suppresses this on services labelled or named dev/test, so only production-facing ones are reported.
Signal and threshold
| Field | Value |
|---|---|
| Rule IDs | RC-1722 · RC-1822 · RC-1922 |
| Category | security |
| Severity | medium |
| Metric | none — pure configuration read |
| Source | internal/rules/k8s/service_nodeport.go |
Where it applies
Every node, not just the one running the pod
This is the property that surprises people. kube-proxy programs the NodePort on all nodes, and
a request to any node IP on that port is forwarded to a backing pod wherever it lives.
So the exposure is not one host. It is the whole node fleet, on a port that stays fixed for the lifetime of the Service.
What sits in front of it: nothing
Traffic arriving on a NodePort has not passed an Ingress. That means no TLS termination, no host or path routing, no authentication annotations, no WAF, and no rate limiting. Every control typically configured at the ingress layer is bypassed, because the request never went through that layer.
If the node’s security group or firewall permits 30000-32767 from a wide range, the Service is open to that range.
The dev/test gate, and why it reads labels not tags
NodePort is a perfectly reasonable choice on a local or ad-hoc cluster, so the rule suppresses the finding where the Service carries a dev or test environment label, or its name reads as dev/test. A production environment label overrides that suppression and wins.
Worth knowing if you are wondering why a Service you expected to be exempt was reported: the
Kubernetes discoverer does not populate resource.Tags for Services. Labels are stamped into
metadata, and the environment signal is read from there. A Service whose environment lives
somewhere else entirely (an annotation, or a naming convention the matcher does not recognise)
will be reported.
Moving off it
For internal traffic, ClusterIP is the whole fix and costs nothing. For external traffic, an
Ingress gives back the TLS and routing layer NodePort skips. LoadBalancer works too but
provisions a cloud load balancer per Service, which is a real recurring bill. Be deliberate
about it rather than treating it as the obvious upgrade.
Where NodePort must stay for an infrastructure reason, narrow the node firewall to the specific port and the specific source range, and write down why.
NodePort Services and their assigned port numbers
kubectl get svc -A -o json | jq -r ' .items[] | select(.spec.type == "NodePort") | "\(.metadata.namespace)/\(.metadata.name)\t\(.spec.ports[].nodePort)"'