feat(eks): assess Kubernetes network policy enforcement in the Amazon VPC CNI add-on (#12661)

This commit is contained in:
Jonathan Nguyen
2026-09-01 13:40:45 +02:00
committed by GitHub
parent b6a8af3c54
commit e5df95c259
8 changed files with 700 additions and 0 deletions
@@ -0,0 +1 @@
`eks_cluster_vpc_cni_network_policy_enforced` check for AWS provider, flagging EKS clusters whose Amazon VPC CNI managed add-on does not enable Kubernetes network policy enforcement, and reporting MANUAL where the EKS API cannot show the setting
@@ -1130,6 +1130,7 @@
"eks_cluster_not_publicly_accessible",
"eks_cluster_private_nodes_enabled",
"eks_cluster_network_policy_enabled",
"eks_cluster_vpc_cni_network_policy_enforced",
"eks_cluster_uses_a_supported_version",
"eks_control_plane_logging_all_types_enabled",
"eks_cluster_kms_cmk_encryption_in_secrets_enabled",
@@ -0,0 +1,42 @@
{
"Provider": "aws",
"CheckID": "eks_cluster_vpc_cni_network_policy_enforced",
"CheckTitle": "EKS cluster enforces Kubernetes network policies through the Amazon VPC CNI add-on",
"CheckType": [
"Software and Configuration Checks/AWS Security Best Practices/Network Reachability",
"TTPs/Lateral Movement"
],
"ServiceName": "eks",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "medium",
"ResourceType": "AwsEksCluster",
"ResourceGroup": "container",
"Description": "**Amazon EKS clusters** are evaluated for whether the **Amazon VPC CNI** managed add-on sets `enableNetworkPolicy` to `true`, which is what makes the CNI enforce Kubernetes `NetworkPolicy` resources. The policy objects themselves live in the cluster and are not exposed by the EKS API, so only this enforcement precondition is verified.",
"Risk": "Without CNI **network policy enforcement** every `NetworkPolicy` an operator authors is inert, so pods reach every other pod and service in the cluster. That unrestricted east-west path lets one compromised container move **laterally** to sidecars, tool executors and internal APIs, widening the blast radius and easing **data exfiltration**.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://docs.aws.amazon.com/eks/latest/userguide/cni-network-policy.html",
"https://docs.aws.amazon.com/eks/latest/APIReference/API_UpdateAddon.html",
"https://docs.aws.amazon.com/eks/latest/userguide/updating-an-add-on.html"
],
"Remediation": {
"Code": {
"CLI": "aws eks update-addon --cluster-name <example_cluster_name> --addon-name vpc-cni --resolve-conflicts PRESERVE --configuration-values '{\"enableNetworkPolicy\":\"true\"}'",
"NativeIaC": "```yaml\n# CloudFormation: enable network policy enforcement in the VPC CNI add-on\nResources:\n <example_resource_name>:\n Type: AWS::EKS::Addon\n Properties:\n ClusterName: <example_cluster_name>\n AddonName: vpc-cni\n ResolveConflicts: PRESERVE\n ConfigurationValues: '{\"enableNetworkPolicy\":\"true\"}' # critical: makes the CNI enforce NetworkPolicy resources\n```",
"Other": "1. Open the AWS Console and go to EKS > Clusters\n2. Select <your cluster> and open the Add-ons tab\n3. Select the Amazon VPC CNI add-on and click Edit\n4. Expand Optional configuration settings and set enableNetworkPolicy to \"true\" in the configuration values\n5. Click Save changes",
"Terraform": "```hcl\n# Enable network policy enforcement in the VPC CNI managed add-on\nresource \"aws_eks_addon\" \"<example_resource_name>\" {\n cluster_name = \"<example_cluster_name>\"\n addon_name = \"vpc-cni\"\n resolve_conflicts_on_update = \"PRESERVE\"\n\n configuration_values = jsonencode({\n enableNetworkPolicy = \"true\" # critical: makes the CNI enforce NetworkPolicy resources\n })\n}\n```"
},
"Recommendation": {
"Text": "Set `enableNetworkPolicy` to `true` on the Amazon VPC CNI add-on, then author `NetworkPolicy` resources that allow each workload only its declared dependencies, ideally with `strict` enforcement mode so traffic is denied until the policy is in place. Layer security groups for Pods to reach VPC resources such as databases.",
"Url": "https://hub.prowler.com/check/eks_cluster_vpc_cni_network_policy_enforced"
}
},
"Categories": [
"trust-boundaries",
"cluster-security"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": "The EKS API exposes only the add-on setting, not the Kubernetes NetworkPolicy resources, so a PASS is the enforcement precondition rather than proof that pod-to-pod traffic is restricted. A FAIL is a statement about the managed add-on, not about the cluster: two documented architectures enforce network policies with this setting false and neither is visible to the EKS API. A third-party policy engine -- the EKS Best Practices Guide recommends Calico and Cilium in its 'ThirdParty Network Policy Engines' section (eks/latest/best-practices/network-security.html) -- would correctly leave it false, since two enforcers are not run together. And a self-managed VPC CNI can be enabled by Helm or by the amazon-vpc-cni ConfigMap key enable-network-policy-controller with the aws-node DaemonSet, two of the three paths AWS documents (eks/latest/userguide/cni-network-policy-configure.html); only the managed add-on path is readable here. A cluster with no vpc-cni managed add-on at all reports MANUAL for the same reason. Detecting Calico or Cilium is deliberately not attempted, because any signal would be a guess presented as a measurement."
}
@@ -0,0 +1,129 @@
import json
from typing import Optional
from prowler.lib.check.models import Check, Check_Report_AWS
from prowler.providers.aws.services.eks.eks_client import eks_client
VPC_CNI_ADDON_NAME = "vpc-cni"
NETWORK_POLICY_KEY = "enableNetworkPolicy"
def parse_configuration_values(configuration_values: Optional[str]) -> Optional[dict]:
"""Decode an EKS add-on `configurationValues` blob.
Args:
configuration_values: The raw JSON string returned by DescribeAddon, which is
absent when no configuration has been supplied for the add-on.
Returns:
The decoded mapping, an empty mapping when nothing was supplied, or None when
the blob is not a readable JSON object.
"""
if not configuration_values:
return {}
try:
configuration = json.loads(configuration_values)
except ValueError:
return None
return configuration if isinstance(configuration, dict) else None
def boolean_configuration_value(value: object) -> Optional[bool]:
"""Read a VPC CNI boolean setting.
The add-on configuration schema types these as a string carrying `"format": "boolean"`,
so the API returns `"true"` rather than `true`; a JSON boolean is accepted as well
because the schema is add-on-version specific and this blob is otherwise untyped.
Args:
value: The value found in the add-on configuration, if any.
Returns:
The boolean it denotes, or None when it is absent or not a recognized boolean.
"""
if isinstance(value, bool):
return value
if isinstance(value, str) and value.strip().lower() in ("true", "false"):
return value.strip().lower() == "true"
return None
class eks_cluster_vpc_cni_network_policy_enforced(Check):
"""Ensure the Amazon VPC CNI add-on enforces Kubernetes network policies.
Kubernetes NetworkPolicy resources live in the cluster and are not exposed by the
EKS API. What the API does expose is whether the Amazon VPC CNI managed add-on has
network policy enforcement switched on, which is the precondition for any
NetworkPolicy to take effect.
- PASS: The Amazon VPC CNI add-on sets enableNetworkPolicy to true.
- FAIL: The Amazon VPC CNI add-on sets enableNetworkPolicy to false.
- MANUAL: The setting cannot be read, or the cluster does not use the Amazon VPC CNI
managed add-on.
TWO WAYS A CLUSTER CAN ENFORCE NETWORK POLICIES WITHOUT THIS SETTING BEING TRUE, so a
FAIL is a statement about the managed add-on and not about the cluster. Neither is
fixable by reading more of the AWS API: both live in in-cluster state that
DescribeAddon cannot see.
1. A third-party policy engine. The EKS Best Practices Guide recommends Calico and
Cilium for requirements the VPC CNI does not cover, such as Layer 7 and DNS
hostname rules, in its "ThirdParty Network Policy Engines" section
(https://docs.aws.amazon.com/eks/latest/best-practices/network-security.html).
A cluster enforcing through one of those would correctly leave this setting false,
because two enforcers are not run together.
2. A self-managed VPC CNI. AWS documents three ways to enable the feature and only the
first is visible here
(https://docs.aws.amazon.com/eks/latest/userguide/cni-network-policy-configure.html):
`aws eks update-addon --addon-name vpc-cni` with configurationValues; `helm upgrade
... aws-vpc-cni`; or the `amazon-vpc-cni` ConfigMap key
`enable-network-policy-controller: "true"` together with policy enforcement in the
aws-node container of the VPC CNI DaemonSet. The second and third leave the EKS
control plane with nothing to report.
Detecting either is deliberately NOT attempted. Calico and Cilium are invisible to the
AWS API, so any signal would be a guess presented as a measurement.
"""
def execute(self) -> list[Check_Report_AWS]:
"""Execute the check logic.
Returns:
A list of reports containing the result of the check.
"""
findings = []
for cluster in eks_client.clusters:
report = Check_Report_AWS(metadata=self.metadata(), resource=cluster)
report.status = "MANUAL"
addon = cluster.addons.get(VPC_CNI_ADDON_NAME)
if addon is None and cluster.addons_discovery_failed:
report.status_extended = f"EKS cluster {cluster.name} add-ons could not be listed, so Kubernetes network policy enforcement in the Amazon VPC CNI add-on cannot be determined."
elif addon is None:
report.status_extended = f"EKS cluster {cluster.name} does not use the Amazon VPC CNI managed add-on, so Kubernetes network policy enforcement cannot be determined from the EKS API. Review the self-managed CNI configuration in the cluster."
elif addon.configuration_discovery_failed:
report.status_extended = f"EKS cluster {cluster.name} Amazon VPC CNI add-on configuration could not be read, so Kubernetes network policy enforcement cannot be determined."
else:
configuration = parse_configuration_values(addon.configuration_values)
if configuration is None:
report.status_extended = f"EKS cluster {cluster.name} Amazon VPC CNI add-on configuration values are not a readable JSON object, so Kubernetes network policy enforcement cannot be determined."
else:
network_policy_enabled = boolean_configuration_value(
configuration.get(NETWORK_POLICY_KEY)
)
if network_policy_enabled is None:
report.status_extended = f"EKS cluster {cluster.name} Amazon VPC CNI add-on does not set {NETWORK_POLICY_KEY} to true or false, so Kubernetes network policy enforcement cannot be determined."
elif network_policy_enabled:
report.status = "PASS"
report.status_extended = f"EKS cluster {cluster.name} enforces Kubernetes network policies through the Amazon VPC CNI add-on. This does not confirm that NetworkPolicy resources restricting pod-to-pod traffic exist in the cluster."
else:
# States the measurement, not the inference from it. The previous wording --
# "cluster does not enforce Kubernetes network policies" -- claimed a cluster
# property from an add-on setting, and is false for a cluster enforcing
# through Calico or Cilium, or through a self-managed VPC CNI. Both are
# documented architectures rather than edge cases; see the class docstring.
report.status = "FAIL"
report.status_extended = f"EKS cluster {cluster.name} Amazon VPC CNI managed add-on does not enforce Kubernetes network policies, since it sets {NETWORK_POLICY_KEY} to false. Enforcement by a third-party policy engine or a self-managed VPC CNI is not visible to the EKS API and is not evaluated."
findings.append(report)
return findings
@@ -6,14 +6,19 @@ from prowler.lib.logger import logger
from prowler.lib.scan_filters.scan_filters import is_resource_filtered
from prowler.providers.aws.lib.service.service import AWSService
# DescribeAddon has no batch form, so only add-ons a check reads are described.
COLLECTED_ADDONS = ("vpc-cni",)
class EKS(AWSService):
def __init__(self, provider):
"""Collect the audited account's EKS clusters, their configuration and their add-ons."""
# Call AWSService's __init__
super().__init__(__class__.__name__, provider)
self.clusters = []
self.__threading_call__(self._list_clusters)
self._describe_cluster(self.regional_clients)
self.__threading_call__(self._describe_cluster_addons, self.clusters)
def _list_clusters(self, regional_client):
logger.info("EKS listing clusters...")
@@ -95,12 +100,69 @@ class EKS(AWSService):
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
def _describe_cluster_addons(self, cluster):
"""Attach the add-ons named in COLLECTED_ADDONS, with their configuration, to a cluster.
ListAddons names every add-on installed on the cluster and DescribeAddon then supplies
the ARN and the raw `configurationValues` blob for the ones checks read. A failed listing
sets `addons_discovery_failed` on the cluster and a failed describe sets
`configuration_discovery_failed` on the add-on, so a check can tell an add-on that is
absent from one whose state could not be read instead of reporting both as absent.
"""
logger.info("EKS describing cluster add-ons...")
try:
regional_client = self.regional_clients[cluster.region]
list_addons_paginator = regional_client.get_paginator("list_addons")
for page in list_addons_paginator.paginate(clusterName=cluster.name):
for addon_name in page["addons"]:
if addon_name in COLLECTED_ADDONS:
cluster.addons[addon_name] = EKSAddon(name=addon_name)
except Exception as error:
cluster.addons_discovery_failed = True
logger.error(
f"{cluster.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
return
for addon in cluster.addons.values():
try:
describe_addon = regional_client.describe_addon(
clusterName=cluster.name, addonName=addon.name
)
addon.arn = describe_addon["addon"].get("addonArn")
addon.configuration_values = describe_addon["addon"].get(
"configurationValues"
)
except Exception as error:
addon.configuration_discovery_failed = True
logger.error(
f"{cluster.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
class EKSClusterLoggingEntity(BaseModel):
types: list[str] = None
enabled: bool = None
class EKSAddon(BaseModel):
"""An EKS managed add-on, with the configuration values collected for it.
Attributes:
name: The add-on name as returned by ListAddons.
arn: The add-on ARN, absent when DescribeAddon could not be read.
configuration_values: The raw JSON blob supplied for the add-on, absent when none
was supplied or when DescribeAddon could not be read.
configuration_discovery_failed: True when DescribeAddon failed, so a check can
tell a setting that is unset from one that could not be read.
"""
name: str
arn: Optional[str] = None
configuration_values: Optional[str] = None
configuration_discovery_failed: bool = False
class EKSCluster(BaseModel):
name: str
arn: str
@@ -113,4 +175,6 @@ class EKSCluster(BaseModel):
public_access_cidrs: list[str] = []
encryptionConfig: bool = None
deletion_protection: bool = None
addons: dict[str, EKSAddon] = {}
addons_discovery_failed: bool = False
tags: Optional[list] = []
@@ -0,0 +1,307 @@
from unittest import mock
import pytest
from prowler.providers.aws.services.eks.eks_service import EKSAddon, EKSCluster
from tests.providers.aws.utils import AWS_ACCOUNT_NUMBER, AWS_REGION_EU_WEST_1
cluster_name = "cluster_test"
cluster_arn = (
f"arn:aws:eks:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:cluster/{cluster_name}"
)
addon_arn = f"arn:aws:eks:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:addon/{cluster_name}/vpc-cni/1a2b3c4d"
def build_cluster(name=cluster_name, arn=cluster_arn, **kwargs):
"""Build an EKSCluster whose add-on state the caller supplies through kwargs.
The defaults leave `addons` empty and both discovery flags false, which is the shape of a
cluster that carries no managed add-ons rather than one whose add-ons could not be read.
"""
return EKSCluster(name=name, arn=arn, region=AWS_REGION_EU_WEST_1, **kwargs)
def vpc_cni_addon(configuration_values=None, configuration_discovery_failed=False):
"""Build the `addons` mapping for a cluster carrying the vpc-cni managed add-on.
`configuration_values` is passed through as the raw JSON string DescribeAddon returns, so a
test can supply the exact blob the API would, including an absent or malformed one.
"""
return {
"vpc-cni": EKSAddon(
name="vpc-cni",
arn=addon_arn,
configuration_values=configuration_values,
configuration_discovery_failed=configuration_discovery_failed,
)
}
def run_check(clusters):
"""Execute the check against the given clusters and return its reports.
The clusters are model objects, so the reports exercise the check's own branching over
already-collected state and no EKS API call takes place.
"""
eks_client = mock.MagicMock
eks_client.clusters = clusters
with mock.patch(
"prowler.providers.aws.services.eks.eks_service.EKS",
eks_client,
):
from prowler.providers.aws.services.eks.eks_cluster_vpc_cni_network_policy_enforced.eks_cluster_vpc_cni_network_policy_enforced import (
eks_cluster_vpc_cni_network_policy_enforced,
)
return eks_cluster_vpc_cni_network_policy_enforced().execute()
class Test_eks_cluster_vpc_cni_network_policy_enforced:
def test_no_clusters(self):
"""An account with no EKS clusters must produce no reports at all."""
assert len(run_check([])) == 0
def test_addons_discovery_failed(self):
"""A cluster whose ListAddons call failed must be MANUAL, not FAIL.
The add-on mapping is empty in both this case and the no-managed-add-on case, so the
cluster-level flag is what separates "unknown" from "not installed".
"""
result = run_check([build_cluster(addons_discovery_failed=True)])
assert len(result) == 1
assert result[0].status == "MANUAL"
assert result[0].status_extended == (
f"EKS cluster {cluster_name} add-ons could not be listed, so Kubernetes "
"network policy enforcement in the Amazon VPC CNI add-on cannot be determined."
)
assert result[0].resource_id == cluster_name
assert result[0].resource_arn == cluster_arn
assert result[0].region == AWS_REGION_EU_WEST_1
def test_no_vpc_cni_addon(self):
"""A cluster with managed add-ons but no vpc-cni must be MANUAL and name the CNI.
The EKS API exposes nothing about a CNI it does not manage, so the verdict cannot be
FAIL: the cluster may well enforce network policies through a self-managed CNI.
"""
result = run_check(
[build_cluster(addons={"coredns": EKSAddon(name="coredns")})]
)
assert len(result) == 1
assert result[0].status == "MANUAL"
assert result[0].status_extended == (
f"EKS cluster {cluster_name} does not use the Amazon VPC CNI managed "
"add-on, so Kubernetes network policy enforcement cannot be determined "
"from the EKS API. Review the self-managed CNI configuration in the cluster."
)
assert result[0].resource_id == cluster_name
assert result[0].resource_arn == cluster_arn
assert result[0].region == AWS_REGION_EU_WEST_1
def test_addons_listed_but_empty(self):
"""A cluster whose add-ons listed successfully as empty must get the not-installed wording.
Same MANUAL verdict as a failed listing but a different explanation, so the report does
not tell an operator to fix permissions when the add-on is simply not there.
"""
result = run_check([build_cluster(addons={})])
assert len(result) == 1
assert result[0].status == "MANUAL"
assert result[0].status_extended == (
f"EKS cluster {cluster_name} does not use the Amazon VPC CNI managed "
"add-on, so Kubernetes network policy enforcement cannot be determined "
"from the EKS API. Review the self-managed CNI configuration in the cluster."
)
def test_addon_configuration_unreadable(self):
"""A vpc-cni add-on whose DescribeAddon call failed must be MANUAL.
The add-on is known to be installed, but its configuration was never read, so network
policy enforcement is undetermined rather than off.
"""
result = run_check(
[build_cluster(addons=vpc_cni_addon(configuration_discovery_failed=True))]
)
assert len(result) == 1
assert result[0].status == "MANUAL"
assert result[0].status_extended == (
f"EKS cluster {cluster_name} Amazon VPC CNI add-on configuration could "
"not be read, so Kubernetes network policy enforcement cannot be determined."
)
assert result[0].resource_id == cluster_name
assert result[0].resource_arn == cluster_arn
assert result[0].region == AWS_REGION_EU_WEST_1
def test_addon_configuration_unreadable_wins_over_stale_values(self):
"""A failed describe must stay MANUAL even when the model still carries a true setting.
Guards the branch order: reading `configuration_values` before checking the failure flag
would report PASS from a value the failed call did not return.
"""
result = run_check(
[
build_cluster(
addons=vpc_cni_addon(
configuration_values='{"enableNetworkPolicy":"true"}',
configuration_discovery_failed=True,
)
)
]
)
assert len(result) == 1
assert result[0].status == "MANUAL"
assert result[0].status_extended == (
f"EKS cluster {cluster_name} Amazon VPC CNI add-on configuration could "
"not be read, so Kubernetes network policy enforcement cannot be determined."
)
@pytest.mark.parametrize(
"configuration_values",
['{"enableNetworkPolicy": "true"', '["enableNetworkPolicy"]', "true", "42"],
)
def test_addon_configuration_not_a_json_object(self, configuration_values):
"""Configuration values that do not decode to a JSON object must be MANUAL.
Truncated JSON, an array, a bare boolean and a bare number each reach a different line of
the decoder, and none may raise out of the check or be read as an empty configuration.
"""
result = run_check([build_cluster(addons=vpc_cni_addon(configuration_values))])
assert len(result) == 1
assert result[0].status == "MANUAL"
assert result[0].status_extended == (
f"EKS cluster {cluster_name} Amazon VPC CNI add-on configuration values "
"are not a readable JSON object, so Kubernetes network policy enforcement "
"cannot be determined."
)
assert result[0].resource_id == cluster_name
assert result[0].resource_arn == cluster_arn
assert result[0].region == AWS_REGION_EU_WEST_1
@pytest.mark.parametrize(
"configuration_values",
[
None,
"",
"{}",
'{"enableWindowsIpam": "false"}',
'{"enableNetworkPolicy": "yes"}',
'{"enableNetworkPolicy": 1}',
'{"enableNetworkPolicy": null}',
],
)
def test_network_policy_setting_not_a_boolean(self, configuration_values):
"""A configuration carrying no recognizable boolean for the setting must be MANUAL, never FAIL.
Absent, empty, a different key, `"yes"`, `1` and `null` all mean the setting was not
stated. Reporting FAIL on any of them would assert that enforcement is off on the strength
of a value the API never returned.
"""
result = run_check([build_cluster(addons=vpc_cni_addon(configuration_values))])
assert len(result) == 1
assert result[0].status == "MANUAL"
assert result[0].status_extended == (
f"EKS cluster {cluster_name} Amazon VPC CNI add-on does not set "
"enableNetworkPolicy to true or false, so Kubernetes network policy "
"enforcement cannot be determined."
)
assert result[0].resource_id == cluster_name
assert result[0].resource_arn == cluster_arn
assert result[0].region == AWS_REGION_EU_WEST_1
@pytest.mark.parametrize(
"configuration_values",
[
'{"enableNetworkPolicy": "true"}',
'{"enableNetworkPolicy": "True"}',
'{"enableNetworkPolicy": true}',
'{"enableNetworkPolicy": "true", "enableWindowsIpam": "false"}',
],
)
def test_network_policy_enforced(self, configuration_values):
"""A cluster whose vpc-cni add-on enables the setting must PASS, string or boolean.
The add-on configuration schema types this setting as a string carrying
`"format": "boolean"`, so the API returns `"true"` rather than `true`; `"True"` and a JSON
boolean must land on the same verdict, and an unrelated sibling key must not disturb it.
"""
result = run_check([build_cluster(addons=vpc_cni_addon(configuration_values))])
assert len(result) == 1
assert result[0].status == "PASS"
assert result[0].status_extended == (
f"EKS cluster {cluster_name} enforces Kubernetes network policies through "
"the Amazon VPC CNI add-on. This does not confirm that NetworkPolicy "
"resources restricting pod-to-pod traffic exist in the cluster."
)
assert result[0].resource_id == cluster_name
assert result[0].resource_arn == cluster_arn
assert result[0].region == AWS_REGION_EU_WEST_1
@pytest.mark.parametrize(
"configuration_values",
[
'{"enableNetworkPolicy": "false"}',
'{"enableNetworkPolicy": "False"}',
'{"enableNetworkPolicy": false}',
],
)
def test_network_policy_not_enforced(self, configuration_values):
"""A cluster whose vpc-cni add-on sets the setting to false must FAIL, string or boolean.
`"false"`, `"False"` and a JSON `false` are the three forms the setting can arrive in, and
a decoder that only understood one of them would report MANUAL on a real misconfiguration.
"""
result = run_check([build_cluster(addons=vpc_cni_addon(configuration_values))])
assert len(result) == 1
assert result[0].status == "FAIL"
assert result[0].status_extended == (
f"EKS cluster {cluster_name} Amazon VPC CNI managed add-on does not enforce "
"Kubernetes network policies, since it sets enableNetworkPolicy to false. "
"Enforcement by a third-party policy engine or a self-managed VPC CNI is not "
"visible to the EKS API and is not evaluated."
)
# The finding must not claim a property of the CLUSTER from an add-on setting: a cluster
# enforcing through Calico or Cilium, or through a self-managed VPC CNI, has this setting
# false and does enforce. Both are documented architectures, so the old wording was false
# of them rather than merely imprecise.
assert "cluster does not enforce" not in result[0].status_extended
assert result[0].resource_id == cluster_name
assert result[0].resource_arn == cluster_arn
assert result[0].region == AWS_REGION_EU_WEST_1
def test_multiple_clusters(self):
"""Six clusters must yield six reports, in input order, each judged on its own add-on.
Two enforcing, three not and one with no managed add-on, so a check that carried state
between iterations or reported once per account would not produce this split.
"""
clusters = []
for index in range(6):
name = f"{cluster_name}_{index}"
arn = f"arn:aws:eks:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:cluster/{name}"
if index in (0, 1):
addons = vpc_cni_addon('{"enableNetworkPolicy": "true"}')
elif index in (2, 3, 4):
addons = vpc_cni_addon('{"enableNetworkPolicy": "false"}')
else:
addons = {}
clusters.append(build_cluster(name=name, arn=arn, addons=addons))
result = run_check(clusters)
assert len(result) == 6
statuses = [report.status for report in result]
assert statuses.count("PASS") == 2
assert statuses.count("FAIL") == 3
assert statuses.count("MANUAL") == 1
assert [report.resource_id for report in result] == [
f"{cluster_name}_{index}" for index in range(6)
]
@@ -1,5 +1,6 @@
from unittest.mock import patch
import botocore
from boto3 import client
from moto import mock_aws
@@ -7,6 +8,7 @@ from prowler.providers.aws.services.eks.eks_service import EKS
from tests.providers.aws.utils import (
AWS_ACCOUNT_NUMBER,
AWS_REGION_EU_WEST_1,
mocked_api_response,
set_mocked_aws_provider,
)
@@ -14,6 +16,77 @@ cluster_name = "test"
cidr_block_vpc = "10.0.0.0/16"
cidr_block_subnet_1 = "10.0.0.0/22"
cidr_block_subnet_2 = "10.0.4.0/22"
cluster_arn = (
f"arn:aws:eks:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:cluster/{cluster_name}"
)
vpc_cni_addon_arn = f"arn:aws:eks:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:addon/{cluster_name}/vpc-cni/1a2b3c4d"
vpc_cni_configuration_values = '{"enableNetworkPolicy":"true"}'
make_api_call = botocore.client.BaseClient._make_api_call
described_addons = []
def _addon_response(addon_name, arn, configuration_values=None):
"""Build a DescribeAddon response, omitting `configurationValues` when none is given.
The key is absent from the API response for an add-on left at its defaults, so passing None
reproduces that rather than sending an empty string.
"""
addon = {"addonName": addon_name, "addonArn": arn, "clusterName": cluster_name}
if configuration_values is not None:
addon["configurationValues"] = configuration_values
return mocked_api_response("eks", "DescribeAddon", {"addon": addon})
def mock_make_api_call_addons(self, operation_name, kwargs):
"""Serve the add-on inventory, with vpc-cni on the SECOND ListAddons page."""
if operation_name == "ListClusters":
return mocked_api_response("eks", "ListClusters", {"clusters": [cluster_name]})
if operation_name == "DescribeCluster":
return mocked_api_response(
"eks",
"DescribeCluster",
{"cluster": {"name": cluster_name, "arn": cluster_arn, "version": "1.34"}},
)
if operation_name == "ListAddons":
if kwargs.get("nextToken") is None:
return mocked_api_response(
"eks",
"ListAddons",
{"addons": ["coredns"], "nextToken": "second-page"},
)
return mocked_api_response("eks", "ListAddons", {"addons": ["vpc-cni"]})
if operation_name == "DescribeAddon":
described_addons.append(kwargs["addonName"])
if kwargs["addonName"] == "vpc-cni":
return _addon_response(
"vpc-cni", vpc_cni_addon_arn, vpc_cni_configuration_values
)
return _addon_response(
"coredns",
f"arn:aws:eks:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:addon/{cluster_name}/coredns/5e6f7a8b",
)
return make_api_call(self, operation_name, kwargs)
def mock_make_api_call_list_addons_denied(self, operation_name, kwargs):
"""Deny ListAddons and serve every other call, as a role without eks:ListAddons would."""
if operation_name == "ListAddons":
raise botocore.exceptions.ClientError(
{"Error": {"Code": "AccessDeniedException", "Message": "denied"}},
operation_name,
)
return mock_make_api_call_addons(self, operation_name, kwargs)
def mock_make_api_call_describe_vpc_cni_denied(self, operation_name, kwargs):
"""Deny DescribeAddon for vpc-cni only, so listing succeeds and the per-add-on read fails."""
if operation_name == "DescribeAddon" and kwargs["addonName"] == "vpc-cni":
raise botocore.exceptions.ClientError(
{"Error": {"Code": "AccessDeniedException", "Message": "denied"}},
operation_name,
)
return mock_make_api_call_addons(self, operation_name, kwargs)
def mock_generate_regional_clients(provider, service):
@@ -139,3 +212,86 @@ class Test_EKS_Service:
assert eks.clusters[0].public_access_cidrs == ["0.0.0.0/0"]
assert eks.clusters[0].encryptionConfig
assert eks.clusters[0].version == "1.10"
@patch(
"prowler.providers.aws.aws_provider.AwsProvider.generate_regional_clients",
new=mock_generate_regional_clients,
)
class Test_EKS_Service_Addons:
# Test EKS describe cluster add-ons
@mock_aws
@patch(
"botocore.client.BaseClient._make_api_call",
new=mock_make_api_call_addons,
)
def test__describe_cluster_addons(self):
"""vpc-cni is collected with its ARN and configuration from the SECOND ListAddons page.
Also asserts coredns costs no DescribeAddon call: it is listed on the cluster but not in
COLLECTED_ADDONS, and DescribeAddon has no batch form, so describing it would be one extra
API call per cluster for data no check reads.
"""
described_addons.clear()
aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1])
eks = EKS(aws_provider)
assert len(eks.clusters) == 1
cluster = eks.clusters[0]
assert not cluster.addons_discovery_failed
# vpc-cni is only on the second ListAddons page, so this fails without pagination
assert sorted(cluster.addons) == ["vpc-cni"]
assert cluster.addons["vpc-cni"].name == "vpc-cni"
assert cluster.addons["vpc-cni"].arn == vpc_cni_addon_arn
assert (
cluster.addons["vpc-cni"].configuration_values
== vpc_cni_configuration_values
)
assert not cluster.addons["vpc-cni"].configuration_discovery_failed
# coredns is listed but not in COLLECTED_ADDONS, so it costs no DescribeAddon call
assert described_addons == ["vpc-cni"]
# Test EKS cluster add-ons cannot be listed
@mock_aws
@patch(
"botocore.client.BaseClient._make_api_call",
new=mock_make_api_call_list_addons_denied,
)
def test__describe_cluster_addons_list_denied(self):
"""A denied ListAddons sets addons_discovery_failed and issues no DescribeAddon call.
The cluster itself must survive collection: a missing add-on permission may not cost the
scan every other EKS finding for that cluster.
"""
described_addons.clear()
aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1])
eks = EKS(aws_provider)
assert len(eks.clusters) == 1
assert eks.clusters[0].addons_discovery_failed
assert eks.clusters[0].addons == {}
assert described_addons == []
# Test EKS cluster add-on configuration cannot be described
@mock_aws
@patch(
"botocore.client.BaseClient._make_api_call",
new=mock_make_api_call_describe_vpc_cni_denied,
)
def test__describe_cluster_addons_describe_denied(self):
"""A denied DescribeAddon flags the add-on, not the cluster, and leaves its fields None.
The add-on is known to exist because ListAddons succeeded, so the failure belongs on
`configuration_discovery_failed` while `addons_discovery_failed` stays false.
"""
described_addons.clear()
aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1])
eks = EKS(aws_provider)
assert len(eks.clusters) == 1
cluster = eks.clusters[0]
assert not cluster.addons_discovery_failed
assert sorted(cluster.addons) == ["vpc-cni"]
assert cluster.addons["vpc-cni"].configuration_discovery_failed
assert cluster.addons["vpc-cni"].configuration_values is None
assert cluster.addons["vpc-cni"].arn is None