DedSec Application Security ·
The pull request adds a NetworkPolicy called default-deny to the payments namespace. It selects every pod and contains no rules. The reviewer reads the name, sees that nothing is allowed, and approves it. A week later a dependency in one of those pods is compromised, and the attacker's first connection, to the cloud provider's instance metadata address, succeeds. So does the second, to an unrestricted database in another namespace.
The policy did what it says. It just says less than its name. With no policyTypes field it applies only to ingress, so egress from every pod in the namespace was never restricted. That gap, between what a set of NetworkPolicy objects appears to say and what the cluster actually lets through, is the subject of this post: where it opens, which manifest shapes cause it, how to review for it in the repository, and what a review of the repository cannot tell you at all.
Kubernetes behaviour below is taken from the Kubernetes documentation: the Network Policies concept page, the NetworkPolicy API reference (the field descriptions of NetworkPolicySpec, NetworkPolicyPeer, NetworkPolicyPort and IPBlock), the well-known labels reference for kubernetes.io/metadata.name, and the DNS debugging page for how cluster DNS is labelled. Where a statement is our own inference from that behaviour, or general practice rather than documented behaviour (the cloud metadata address, for example), the text says so.
NetworkPolicy isolation is per pod and per direction, and the default is no isolation. The concept page states it for both directions: by default a pod is non-isolated for egress, and all outbound connections are allowed; by default a pod is non-isolated for ingress, and all inbound connections are allowed. A pod becomes isolated for ingress only when at least one NetworkPolicy both selects it and has Ingress in its policyTypes, and isolated for egress only when at least one policy both selects it and has Egress.
Three consequences matter for review:
The concept page is explicit: network policies do not conflict, they are additive. When several policies apply to a pod in one direction, what is allowed is the union of what each allows, so the order of evaluation does not matter. The API has allow rules only. "The ability to explicitly deny policies" is on the documentation's list of things NetworkPolicy cannot do.
This is what makes a policy-by-policy review misleading. A careful default deny and a tight allow rule for the frontend can sit in the same namespace as a policy shipped by another chart that allows all ingress to every pod, for example to reach a metrics port. The documentation's own allow-all-ingress example carries the warning: with that policy in place, no additional policy or policies can cause any incoming connection to those pods to be denied. Nothing in the tight policies is wrong, and the namespace is open.
Additive also means that whoever can create a NetworkPolicy in a namespace can widen access for every pod in it, without touching the policies the security team reviewed. create on networkpolicies belongs on the list of grants to check in a namespace's RBAC, alongside the ones in the post on RBAC permissions that add up to cluster-admin.
The API reference describes the default exactly. If policyTypes is not specified, ingress is always assumed, and egress is assumed only if the policy contains an egress section. It then spells out the trap: to write a policy that allows no egress, you must list Egress in policyTypes, since a policy with no egress section would otherwise default to Ingress only.
So the policy from the opening, {podSelector: {}} with nothing else, behaves as default deny for ingress and does not touch egress. The documentation's default-deny-ingress example is exactly that shape, with policyTypes: [Ingress] written out, and the text under it says the policy does not affect isolation for egress from any pod. Its default-deny-all example is {podSelector: {}, policyTypes: [Ingress, Egress]}.
The review rule follows: read policyTypes, not the policy's name. A policy that omits it is an ingress policy, whatever it is called. To be isolated in both directions, a pod needs to be selected by a policy listing Ingress and by a policy listing Egress, whether that is one policy or two.
Most of the YAML that widens access is short. These are the shapes, with what the API reference says each one means.
ingress: [{}], which is the documentation's allow-all-ingress example, is a rule with no peers and no ports, so it matches every source on every port. egress: [{}] is the same for egress.{from: [{podSelector: {matchLabels: {app: frontend}}}]} lets the frontend reach every port on the selected pods, not only the one the service listens on.One shape that looks like it belongs on the list does not: a peer with no fields at all. The API validation rejects a from or to entry that sets none of podSelector, namespaceSelector and ipBlock, and it rejects an ipBlock combined with either selector in the same entry. The allow-all form is the empty rule, not the empty peer.
The concept page calls this out with a warning to be careful with YAML syntax, and it is an easy way for a reviewed policy to allow more than its author meant.
A single entry in from that sets both namespaceSelector and podSelector selects pods matching the pod selector, inside namespaces matching the namespace selector. In flow style, that is from: [{namespaceSelector: {matchLabels: {user: alice}}, podSelector: {matchLabels: {role: client}}}], one list element with two keys. The documentation's reading: connections from pods labelled role=client in namespaces labelled user=alice.
Add one dash in block YAML, so that podSelector starts a new list item, and the meaning changes to from: [{namespaceSelector: {matchLabels: {user: alice}}}, {podSelector: {matchLabels: {role: client}}}]. That is two elements, combined with OR: pods labelled role=client in the policy's own namespace, or any pod at all in any namespace labelled user=alice. The difference in block YAML is a single hyphen and two spaces of indentation, and both versions are valid.
The documentation's advice is to use kubectl describe to see how Kubernetes has interpreted the policy. In review, the equivalent is to stop reading indentation and normalise each peer: for each element of from or to, write down which of podSelector, namespaceSelector and ipBlock it sets. A list with a namespaceSelector-only element is allowing whole namespaces.
NetworkPolicy has no field for a namespace's name, a service account or a workload identity. The concept page notes that namespaces cannot be named directly, only selected by label, and lists targeting services by name among the things the API cannot do. Every peer is a label selector or an address range. So the question for each peer is who can make something match it.
namespaceSelector: {matchLabels: {team: payments}} admits every pod in any namespace that carries that label, including one created next month by someone with namespace-create rights, or an existing namespace someone relabels. Namespaces are cluster-scoped, so this is a cluster-level permission, not a namespace one.{kubernetes.io/metadata.name: monitoring}, not a label someone can attach to another namespace.podSelector: {matchLabels: {app: frontend}} admits any pod anyone in that namespace labels app=frontend. That is a reason to treat workload-create rights as part of the network boundary as well as the RBAC one.The same applies to the policy's own spec.podSelector, in the other direction. If the policy selects pods by a label the chart's pod template does not carry, because of a typo or a label renamed in a chart upgrade, the policy selects nothing. The API server accepts a policy whose selector matches no pods, and neither it nor kubectl warns about it. If that was the only policy covering those pods, they are simply non-isolated.
Egress is where default deny is most often missing, and where adding it breaks things first.
DNS. A default deny-all egress policy also blocks DNS, so workloads that resolve names need a separate policy allowing egress to the cluster's DNS pods. Three details make that rule work:
{namespaceSelector: {matchLabels: {kubernetes.io/metadata.name: kube-system}}, podSelector: {matchLabels: {k8s-app: kube-dns}}} means those pods. Split into two elements, it means every pod in kube-system.ports: [{port: 53}] allows TCP only, and ordinary UDP lookups fail. The upstream DNS Service exposes 53/UDP and 53/TCP and the upstream CoreDNS pods listen on 53, but the policy matches the pod's port, so check the Service's targetPort on your distribution.The cloud metadata address. This part is general cloud practice, not Kubernetes documentation: the major cloud providers serve instance metadata, and on many platforms instance credentials, at the link-local address 169.254.169.254. An egress rule allowing {ipBlock: {cidr: 0.0.0.0/0}} allows it. The usual pattern is {ipBlock: {cidr: 0.0.0.0/0, except: [169.254.169.254/32]}}, and the API reference confirms except entries must sit inside the cidr. Two limits apply. On dual-stack clusters an IPv4 block says nothing about IPv6 destinations, and some providers also expose metadata over IPv6. And some managed platforms intercept metadata traffic on the node, which brings in the rewriting caveat above. Some providers use a different address entirely. Check your provider's documentation. The post on out-of-band testing and blind SSRF covers why a server-side request to that address is worth preventing.
ipBlock is for outside the cluster. The concept page says ipBlock should select cluster-external IPs, since pod IPs are ephemeral and unpredictable. An ipBlock covering the pod network is a policy whose meaning changes every time a pod is rescheduled. For ingress, the source address the policy sees may be the original client, a load balancer or a node, depending on the plugin, the cloud provider and the Service implementation. An office allowlist written as an ipBlock may therefore be compared against an address that is not the client's.
The node is always allowed. The documentation lists this as an exception: traffic to and from the node where a pod is running is always allowed, regardless of the pod's or node's address, and the API reference's description of the ingress field likewise allows traffic whose source is the pod's local node. An ingress default deny therefore never cuts a pod off from its own node, and the "What you can't do" section confirms that pods cannot block localhost access or access from their resident node.
hostNetwork pods are mostly outside the model. The documentation calls NetworkPolicy behaviour for hostNetwork pods undefined, limited to two possibilities, and says the most common is that the plugin ignores hostNetwork pods when matching podSelector and namespaceSelector and treats their traffic as node traffic. A hostNetwork pod selected by a deny policy may be restricted by nothing. Our inference from those two statements: a hostNetwork pod on the same node as a target is likely to be treated as node traffic to that target, and node traffic is always allowed.
This is the point a manifest review can never settle. The concept page's prerequisites section says network policies are implemented by the network plugin, and that creating a NetworkPolicy resource without a controller that implements it will have no effect. The note under its first example says the same: posting the policy to the API server will have no effect unless your networking solution supports network policy.
The API server validates the object's syntax and stores it. kubectl get networkpolicy lists it. Nothing in the API reports whether any component acted on it. A cluster whose plugin does not implement NetworkPolicy has policies that are documentation only. The post on why "the scan passed" is not "the scan ran" describes the same shape of problem from the scanner side: a control that reports present is not a control that ran.
Timing is the smaller version of the same gap. The pod lifecycle section says a new policy may take time for the plugin to handle, that a pod created in that window may start unprotected, and that there is no way to tell from the Kubernetes API when handling happens. When a policy tightens access, whether existing connections are closed is implementation-defined.
The concept page describes NetworkPolicy as control at the IP address or port level, OSI layer 3 or 4, and its "What you can't do" section lists the rest. That rules out several things it is often expected to do:
Render first. NetworkPolicy in Helm charts is often behind a values flag, and selectors are built from the same label helpers as the pod templates. Values.yaml shows inputs, not results. Run helm template with each environment's values files, or kustomize build for each overlay, and review that output. The Terraform post covers the same gap between configuration in the repository and configuration applied, for a different tool.
Inventory three things from the rendered output: the namespaces, every pod template with its labels and hostNetwork setting, and every NetworkPolicy.
Compute isolation per workload, not per policy. For each pod template, find every policy in its namespace whose spec.podSelector matches its labels, including matchExpressions. Then ask:
Flag these as findings:
Gate on the change. A policy is cheapest to fix in the pull request that introduces it, while the author still knows which connection they were trying to allow. The post on catching vulnerabilities in the pull request covers the gate pattern, and the post on stale baselines covers how a gate can block the wrong change.
Confirm in a live cluster where you can. kubectl describe networkpolicy shows how the API server read each policy, which is what the documentation recommends for the selector question. Then test from a short-lived pod carrying the labels of the workload in question: an allowed connection, a connection that should be denied, a DNS lookup over UDP and TCP, and a request to the metadata address. If the denied connection succeeds, the plugin is not enforcing what the manifests say, and nothing upstream of that test would have shown it.
Review of rendered manifests can show that the repository intends isolation. It cannot show:
The honest output of a static review is "intended isolation, with these assumptions", and the assumptions belong in the finding.
AppSecD runs Terraform, CloudFormation, Helm, Ansible and Pulumi rules, and analyses Kubernetes RBAC, network policy and pod security context. Those results land in the same deduplicated finding model as the platform's other scanning, and pull requests and commits can be gated by policy. The review method in this post (rendering first, computing isolation per workload from the union of policies, and reading selectors as statements about who controls labels) is general practice, not a description of how AppSecD's network policy analysis is implemented. Nothing here claims that AppSecD renders charts with your values, computes per-workload isolation or observes what a network plugin enforces.
A NetworkPolicy object is a request to the network plugin, expressed in labels. Whether a pod is isolated depends on every policy that selects it, who controls the labels those policies match, and whether anything in the cluster is listening.