kubenumerate 2.1.6__tar.gz → 2.1.8__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {kubenumerate-2.1.6/kubenumerate.egg-info → kubenumerate-2.1.8}/PKG-INFO +1 -1
- kubenumerate-2.1.8/VERSION +1 -0
- {kubenumerate-2.1.6 → kubenumerate-2.1.8/kubenumerate.egg-info}/PKG-INFO +1 -1
- {kubenumerate-2.1.6 → kubenumerate-2.1.8}/kubenumerate.egg-info/SOURCES.txt +3 -0
- {kubenumerate-2.1.6 → kubenumerate-2.1.8}/kubenumerate.py +270 -44
- {kubenumerate-2.1.6 → kubenumerate-2.1.8}/pyproject.toml +1 -1
- {kubenumerate-2.1.6 → kubenumerate-2.1.8}/summary_table.py +22 -1
- kubenumerate-2.1.8/tests/test_container_types.py +378 -0
- kubenumerate-2.1.8/tests/test_security_context_v136.py +309 -0
- kubenumerate-2.1.8/tests/test_security_context_v136_excel.py +101 -0
- {kubenumerate-2.1.6 → kubenumerate-2.1.8}/tests/test_version.py +6 -4
- kubenumerate-2.1.6/VERSION +0 -1
- {kubenumerate-2.1.6 → kubenumerate-2.1.8}/ExtensiveRoleCheck.py +0 -0
- {kubenumerate-2.1.6 → kubenumerate-2.1.8}/LICENSE +0 -0
- {kubenumerate-2.1.6 → kubenumerate-2.1.8}/MANIFEST.in +0 -0
- {kubenumerate-2.1.6 → kubenumerate-2.1.8}/README.md +0 -0
- {kubenumerate-2.1.6 → kubenumerate-2.1.8}/formatter.py +0 -0
- {kubenumerate-2.1.6 → kubenumerate-2.1.8}/kubenumerate.egg-info/dependency_links.txt +0 -0
- {kubenumerate-2.1.6 → kubenumerate-2.1.8}/kubenumerate.egg-info/entry_points.txt +0 -0
- {kubenumerate-2.1.6 → kubenumerate-2.1.8}/kubenumerate.egg-info/requires.txt +0 -0
- {kubenumerate-2.1.6 → kubenumerate-2.1.8}/kubenumerate.egg-info/top_level.txt +0 -0
- {kubenumerate-2.1.6 → kubenumerate-2.1.8}/requirements.txt +0 -0
- {kubenumerate-2.1.6 → kubenumerate-2.1.8}/setup.cfg +0 -0
- {kubenumerate-2.1.6 → kubenumerate-2.1.8}/setup.py +0 -0
- {kubenumerate-2.1.6 → kubenumerate-2.1.8}/version.py +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
2.1.8
|
|
@@ -16,4 +16,7 @@ kubenumerate.egg-info/dependency_links.txt
|
|
|
16
16
|
kubenumerate.egg-info/entry_points.txt
|
|
17
17
|
kubenumerate.egg-info/requires.txt
|
|
18
18
|
kubenumerate.egg-info/top_level.txt
|
|
19
|
+
tests/test_container_types.py
|
|
20
|
+
tests/test_security_context_v136.py
|
|
21
|
+
tests/test_security_context_v136_excel.py
|
|
19
22
|
tests/test_version.py
|
|
@@ -15,7 +15,7 @@ import platform
|
|
|
15
15
|
import yaml
|
|
16
16
|
import zipfile
|
|
17
17
|
from datetime import datetime
|
|
18
|
-
from packaging.version import Version
|
|
18
|
+
from packaging.version import InvalidVersion, Version
|
|
19
19
|
from pathlib import Path
|
|
20
20
|
import glob
|
|
21
21
|
from typing import Any, Dict, List, Optional, Tuple
|
|
@@ -68,7 +68,7 @@ class Kubenumerate:
|
|
|
68
68
|
kubectl_path: str = "",
|
|
69
69
|
kubectl_output_path: str = "/tmp/kubenumerate_out/kubectl_output/",
|
|
70
70
|
kubenumerate_path: str = str(Path(os.path.realpath(__file__)).parent.resolve()),
|
|
71
|
-
kube_version: str = "v1.
|
|
71
|
+
kube_version: str = "v1.36.3",
|
|
72
72
|
kubiscan_path: str = "/tmp/kubiscan/",
|
|
73
73
|
kubiscan_py: str = "",
|
|
74
74
|
limits: bool = True,
|
|
@@ -1070,6 +1070,7 @@ class Kubenumerate:
|
|
|
1070
1070
|
self.kubernetes_version_check() # TODO: make sure it works with the dry-run flag.
|
|
1071
1071
|
# Generate local kubeaudit-equivalent findings from kubectl output
|
|
1072
1072
|
kubeaudit_df = self.generate_kubeaudit_equivalent_df_from_kubectl()
|
|
1073
|
+
self.evaluate_privileged_severity(kubeaudit_df)
|
|
1073
1074
|
# with open(self.kube_bench_file, "r") as kube_bench_f:
|
|
1074
1075
|
with pd.ExcelWriter(self.excel_file, engine="xlsxwriter", mode="w") as writer:
|
|
1075
1076
|
# Run all Kubeaudit-equivalent methods
|
|
@@ -1084,8 +1085,10 @@ class Kubenumerate:
|
|
|
1084
1085
|
self.non_root(kubeaudit_df, writer)
|
|
1085
1086
|
self.privesc(kubeaudit_df, writer)
|
|
1086
1087
|
self.privileged(kubeaudit_df, writer)
|
|
1088
|
+
self.proc_mount(kubeaudit_df, writer)
|
|
1087
1089
|
self.root_fs(kubeaudit_df, writer)
|
|
1088
1090
|
self.seccomp(kubeaudit_df, writer)
|
|
1091
|
+
self.user_namespace(kubeaudit_df, writer)
|
|
1089
1092
|
if self.verbosity > 0:
|
|
1090
1093
|
print(f'{self.green_text("[+]")} {self.cyan_text("Kubeaudit-equivalent checks")} successfully parsed.')
|
|
1091
1094
|
# Run Kube-bench methods
|
|
@@ -1265,6 +1268,21 @@ class Kubenumerate:
|
|
|
1265
1268
|
|
|
1266
1269
|
return tag_name
|
|
1267
1270
|
|
|
1271
|
+
def cluster_at_least(self, minimum: str) -> bool:
|
|
1272
|
+
"""Whether the cluster runs at least `minimum` (e.g. "1.36.0").
|
|
1273
|
+
|
|
1274
|
+
Used to gate checks for features that only became generally available in a given release, so that auditing an
|
|
1275
|
+
older cluster doesn't raise recommendations its API server cannot honour. Returns False when the cluster
|
|
1276
|
+
version is unknown or unparseable, so gated checks stay silent rather than guessing.
|
|
1277
|
+
"""
|
|
1278
|
+
|
|
1279
|
+
if not self.cluster_version:
|
|
1280
|
+
return False
|
|
1281
|
+
try:
|
|
1282
|
+
return Version(self.cluster_version) >= Version(minimum)
|
|
1283
|
+
except InvalidVersion:
|
|
1284
|
+
return False
|
|
1285
|
+
|
|
1268
1286
|
@staticmethod
|
|
1269
1287
|
def extract_version(version_output: bytes) -> Optional[str]:
|
|
1270
1288
|
"""Get cluster's version with RegEx even in case it's a weird string"""
|
|
@@ -2014,6 +2032,22 @@ class Kubenumerate:
|
|
|
2014
2032
|
if self.verbosity > 1:
|
|
2015
2033
|
print(f'[{self.cyan_text("*")}] "PrivilegeEscalation - True" not detected')
|
|
2016
2034
|
|
|
2035
|
+
def evaluate_privileged_severity(self, df: pd.DataFrame) -> None:
|
|
2036
|
+
"""Raise the privilege-escalation flag only for containers a user namespace doesn't already mitigate.
|
|
2037
|
+
|
|
2038
|
+
A privileged container in a pod with hostUsers: false still holds no authority over the host, so counting it
|
|
2039
|
+
towards "Containers Allowing Privilege Escalation" would overstate the cluster's risk.
|
|
2040
|
+
"""
|
|
2041
|
+
|
|
2042
|
+
if df.empty or "AuditResultName" not in df.columns:
|
|
2043
|
+
return
|
|
2044
|
+
privileged_rows = df[df["AuditResultName"].isin(("PrivilegedNil", "PrivilegedTrue"))]
|
|
2045
|
+
if privileged_rows.empty:
|
|
2046
|
+
return
|
|
2047
|
+
mitigated = privileged_rows["UserNamespaced"].fillna(False).astype(bool)
|
|
2048
|
+
if not bool(mitigated.all()):
|
|
2049
|
+
self.privileged_flag = True
|
|
2050
|
+
|
|
2017
2051
|
def privileged(self, df: pd.DataFrame, writer: Any) -> None:
|
|
2018
2052
|
"""Privileged"""
|
|
2019
2053
|
try:
|
|
@@ -2042,7 +2076,6 @@ class Kubenumerate:
|
|
|
2042
2076
|
writer,
|
|
2043
2077
|
)
|
|
2044
2078
|
self.sus_rbac = True
|
|
2045
|
-
self.privileged_flag = True
|
|
2046
2079
|
except KeyError:
|
|
2047
2080
|
if self.verbosity > 1:
|
|
2048
2081
|
print(f'[{self.cyan_text("*")}] "Privileged - Nil" not detected')
|
|
@@ -2073,7 +2106,6 @@ class Kubenumerate:
|
|
|
2073
2106
|
writer,
|
|
2074
2107
|
)
|
|
2075
2108
|
self.sus_rbac = True
|
|
2076
|
-
self.privileged_flag = True
|
|
2077
2109
|
except KeyError:
|
|
2078
2110
|
if self.verbosity > 1:
|
|
2079
2111
|
print(f'[{self.cyan_text("*")}] "Privileged - True" not detected')
|
|
@@ -2107,6 +2139,103 @@ class Kubenumerate:
|
|
|
2107
2139
|
if self.verbosity > 1:
|
|
2108
2140
|
print(f'[{self.cyan_text("*")}] "Root FileSystem - ReadOnly Nil" not detected')
|
|
2109
2141
|
|
|
2142
|
+
def proc_mount(self, df: pd.DataFrame, writer: Any) -> None:
|
|
2143
|
+
"""procMount (KEP-4265, GA in v1.36)"""
|
|
2144
|
+
try:
|
|
2145
|
+
# procMount Unmasked
|
|
2146
|
+
df_proc_mount_unmasked = df[df["AuditResultName"] == "ProcMountUnmasked"]
|
|
2147
|
+
df_proc_mount_unmasked = df_proc_mount_unmasked[
|
|
2148
|
+
["ResourceNamespace", "ResourceKind", "ResourceName", "Container", "UserNamespaced", "msg"]
|
|
2149
|
+
]
|
|
2150
|
+
df_proc_mount_unmasked = df_proc_mount_unmasked.rename(
|
|
2151
|
+
columns={
|
|
2152
|
+
"ResourceNamespace": "Resource Namespace",
|
|
2153
|
+
"ResourceKind": "Resource Kind",
|
|
2154
|
+
"ResourceName": "Resource Name",
|
|
2155
|
+
"Container": "Affected Container",
|
|
2156
|
+
"UserNamespaced": "Mitigated by User Namespace",
|
|
2157
|
+
"msg": "Recommendation",
|
|
2158
|
+
}
|
|
2159
|
+
)
|
|
2160
|
+
self.colour_cells_and_save_to_excel(
|
|
2161
|
+
"procMount set to Unmasked in SecurityContext",
|
|
2162
|
+
"By default the container runtime masks or makes read-only a set of sensitive /proc and /sys paths "
|
|
2163
|
+
"(/proc/kcore, /proc/keys, /proc/sched_debug, /proc/timer_list, /sys/firmware and others). Setting "
|
|
2164
|
+
"procMount to Unmasked removes that protection and exposes host kernel state to the container. It "
|
|
2165
|
+
"exists for nested containers and in-pod image builds, and should not be used otherwise. Where it is "
|
|
2166
|
+
"genuinely required, it must be paired with hostUsers: false so the container's root is confined to "
|
|
2167
|
+
"a user namespace.",
|
|
2168
|
+
"Proc Mount - Unmasked",
|
|
2169
|
+
df_proc_mount_unmasked,
|
|
2170
|
+
writer,
|
|
2171
|
+
)
|
|
2172
|
+
self.hardened = False
|
|
2173
|
+
except KeyError:
|
|
2174
|
+
if self.verbosity > 1:
|
|
2175
|
+
print(f'[{self.cyan_text("*")}] "Proc Mount - Unmasked" not detected')
|
|
2176
|
+
|
|
2177
|
+
try:
|
|
2178
|
+
# procMount Unmasked without a user namespace
|
|
2179
|
+
df_proc_mount_no_userns = df[df["AuditResultName"] == "ProcMountUnmaskedWithoutUserNamespace"]
|
|
2180
|
+
df_proc_mount_no_userns = df_proc_mount_no_userns[
|
|
2181
|
+
["ResourceNamespace", "ResourceKind", "ResourceName", "Container", "msg"]
|
|
2182
|
+
]
|
|
2183
|
+
df_proc_mount_no_userns = df_proc_mount_no_userns.rename(
|
|
2184
|
+
columns={
|
|
2185
|
+
"ResourceNamespace": "Resource Namespace",
|
|
2186
|
+
"ResourceKind": "Resource Kind",
|
|
2187
|
+
"ResourceName": "Resource Name",
|
|
2188
|
+
"Container": "Affected Container",
|
|
2189
|
+
"msg": "Recommendation",
|
|
2190
|
+
}
|
|
2191
|
+
)
|
|
2192
|
+
self.colour_cells_and_save_to_excel(
|
|
2193
|
+
"procMount Unmasked without a user namespace",
|
|
2194
|
+
"Kubernetes rejects procMount: Unmasked unless the pod also sets hostUsers: false. Seeing this "
|
|
2195
|
+
"combination live means the object predates enforcement, the API server has the ProcMountType "
|
|
2196
|
+
"validation disabled, or admission control was bypassed. The container has unmasked access to host "
|
|
2197
|
+
"kernel state while running as real root on the node - treat as a container breakout risk and "
|
|
2198
|
+
"investigate how the object was admitted.",
|
|
2199
|
+
"Proc Mount - No UserNS",
|
|
2200
|
+
df_proc_mount_no_userns,
|
|
2201
|
+
writer,
|
|
2202
|
+
)
|
|
2203
|
+
self.hardened = False
|
|
2204
|
+
self.privileged_flag = True
|
|
2205
|
+
self.sus_rbac = True
|
|
2206
|
+
except KeyError:
|
|
2207
|
+
if self.verbosity > 1:
|
|
2208
|
+
print(f'[{self.cyan_text("*")}] "Proc Mount - No UserNS" not detected')
|
|
2209
|
+
|
|
2210
|
+
def user_namespace(self, df: pd.DataFrame, writer: Any) -> None:
|
|
2211
|
+
"""User namespaces (KEP-127, GA in v1.36)"""
|
|
2212
|
+
try:
|
|
2213
|
+
df_userns_not_enabled = df[df["AuditResultName"] == "UserNamespaceNotEnabled"]
|
|
2214
|
+
df_userns_not_enabled = df_userns_not_enabled[["ResourceNamespace", "ResourceKind", "ResourceName", "msg"]]
|
|
2215
|
+
df_userns_not_enabled = df_userns_not_enabled.rename(
|
|
2216
|
+
columns={
|
|
2217
|
+
"ResourceNamespace": "Resource Namespace",
|
|
2218
|
+
"ResourceKind": "Resource Kind",
|
|
2219
|
+
"ResourceName": "Resource Name",
|
|
2220
|
+
"msg": "Recommendation",
|
|
2221
|
+
}
|
|
2222
|
+
)
|
|
2223
|
+
self.colour_cells_and_save_to_excel(
|
|
2224
|
+
"User namespaces not enabled",
|
|
2225
|
+
"User namespaces became generally available in Kubernetes v1.36. Setting hostUsers: false in the pod "
|
|
2226
|
+
"spec maps the container's UID 0 to an unprivileged UID on the node, so a process can be root inside "
|
|
2227
|
+
"the container without being root on the host, and capabilities such as CAP_NET_ADMIN only apply to "
|
|
2228
|
+
"container-local resources. This significantly reduces the impact of a container breakout and "
|
|
2229
|
+
"requires no changes to container images. Workloads that must run as root should enable it.",
|
|
2230
|
+
"User Namespace - Not Enabled",
|
|
2231
|
+
df_userns_not_enabled,
|
|
2232
|
+
writer,
|
|
2233
|
+
)
|
|
2234
|
+
self.hardened = False
|
|
2235
|
+
except KeyError:
|
|
2236
|
+
if self.verbosity > 1:
|
|
2237
|
+
print(f'[{self.cyan_text("*")}] "User Namespace - Not Enabled" not detected')
|
|
2238
|
+
|
|
2110
2239
|
def seccomp(self, df: pd.DataFrame, writer: Any) -> None:
|
|
2111
2240
|
"""Seccomp profile"""
|
|
2112
2241
|
try:
|
|
@@ -2558,6 +2687,19 @@ class Kubenumerate:
|
|
|
2558
2687
|
f'\t {self.green_text("By 0x5ubt13")} {self.yellow_text(f"v{self.version}")}\n'
|
|
2559
2688
|
)
|
|
2560
2689
|
|
|
2690
|
+
@staticmethod
|
|
2691
|
+
def classify_init_container(container: Dict[str, Any]) -> Tuple[str, str]:
|
|
2692
|
+
"""Tell a sidecar apart from an ordinary init container, returning its type and display suffix.
|
|
2693
|
+
|
|
2694
|
+
An initContainer with restartPolicy: Always is restarted until the regular containers terminate, so it runs
|
|
2695
|
+
for the pod's whole lifetime (KEP-753). Its security posture is therefore closer to an app container than to
|
|
2696
|
+
a short-lived init container, and a privileged or unmasked one is exposed for far longer.
|
|
2697
|
+
"""
|
|
2698
|
+
|
|
2699
|
+
if container.get("restartPolicy") == "Always":
|
|
2700
|
+
return "sidecarContainer", " (sidecar)"
|
|
2701
|
+
return "initContainer", " (init)"
|
|
2702
|
+
|
|
2561
2703
|
def get_normalised_deprecated_apis_dict(
|
|
2562
2704
|
self,
|
|
2563
2705
|
) -> Dict[Tuple[str, str], Tuple[int, int, int, int, int | None, int | None]]:
|
|
@@ -2659,11 +2801,37 @@ class Kubenumerate:
|
|
|
2659
2801
|
pod_spec = spec.get("template", {}).get("spec", spec) # For controllers, use template.spec
|
|
2660
2802
|
pod_security_ctx = pod_spec.get("securityContext", {})
|
|
2661
2803
|
# print(f"debug: pod_spec {pod_spec}")
|
|
2804
|
+
# initContainers and ephemeralContainers accept the same securityContext as normal containers, so
|
|
2805
|
+
# a privileged initContainer or an unmasked debug container is just as much a finding. Each entry
|
|
2806
|
+
# is (container, type, display suffix); the suffix keeps them distinguishable in every report.
|
|
2662
2807
|
containers = pod_spec.get("containers", [])
|
|
2808
|
+
all_containers = (
|
|
2809
|
+
[(c, "container", "") for c in containers]
|
|
2810
|
+
+ [(c, *self.classify_init_container(c)) for c in pod_spec.get("initContainers", [])]
|
|
2811
|
+
+ [(c, "ephemeralContainer", " (ephemeral)") for c in pod_spec.get("ephemeralContainers", [])]
|
|
2812
|
+
)
|
|
2813
|
+
# User namespaces (KEP-127, GA in v1.36). hostUsers defaults to true, i.e. the pod shares the
|
|
2814
|
+
# host user namespace. When false, container UID 0 maps to an unprivileged host UID, which
|
|
2815
|
+
# mitigates every root-related finding below.
|
|
2816
|
+
userns_isolated = pod_spec.get("hostUsers", True) is False
|
|
2817
|
+
# AutomountServiceAccountToken is a pod-level property, so it is checked once per pod rather
|
|
2818
|
+
# than once per container.
|
|
2819
|
+
automount = pod_spec.get("automountServiceAccountToken", True)
|
|
2820
|
+
sa_name = pod_spec.get("serviceAccountName", "default")
|
|
2821
|
+
if all_containers and automount and sa_name == "default":
|
|
2822
|
+
findings.append(
|
|
2823
|
+
{
|
|
2824
|
+
"AuditResultName": "AutomountServiceAccountTokenTrueAndDefaultSA",
|
|
2825
|
+
"ResourceNamespace": namespace,
|
|
2826
|
+
"ResourceKind": kind,
|
|
2827
|
+
"ResourceName": name,
|
|
2828
|
+
"msg": "Pod automounts service account token and uses default SA.",
|
|
2829
|
+
}
|
|
2830
|
+
)
|
|
2663
2831
|
# AppArmor, Seccomp, ASAT, Capabilities, Limits, Mounts, Non-root, Privesc, Privileged, RootFS
|
|
2664
|
-
for container in
|
|
2832
|
+
for container, container_type, cname_suffix in all_containers:
|
|
2665
2833
|
# print(f"debug: container {container}")
|
|
2666
|
-
cname = container.get(
|
|
2834
|
+
cname = f"{container.get('name', '')}{cname_suffix}"
|
|
2667
2835
|
security_ctx = container.get("securityContext", {})
|
|
2668
2836
|
anns = item.get("metadata", {}).get("annotations", {})
|
|
2669
2837
|
# AppArmor
|
|
@@ -2684,6 +2852,7 @@ class Kubenumerate:
|
|
|
2684
2852
|
"ResourceKind": kind,
|
|
2685
2853
|
"ResourceName": name,
|
|
2686
2854
|
"Container": cname,
|
|
2855
|
+
"ContainerType": container_type,
|
|
2687
2856
|
"msg": "AppArmor profile not set.",
|
|
2688
2857
|
}
|
|
2689
2858
|
)
|
|
@@ -2702,22 +2871,10 @@ class Kubenumerate:
|
|
|
2702
2871
|
"ResourceKind": kind,
|
|
2703
2872
|
"ResourceName": name,
|
|
2704
2873
|
"Container": cname,
|
|
2874
|
+
"ContainerType": container_type,
|
|
2705
2875
|
"msg": "Seccomp profile missing.",
|
|
2706
2876
|
}
|
|
2707
2877
|
)
|
|
2708
|
-
# AutomountServiceAccountToken
|
|
2709
|
-
automount = pod_spec.get("automountServiceAccountToken", True)
|
|
2710
|
-
sa_name = pod_spec.get("serviceAccountName", "default")
|
|
2711
|
-
if automount and sa_name == "default":
|
|
2712
|
-
findings.append(
|
|
2713
|
-
{
|
|
2714
|
-
"AuditResultName": "AutomountServiceAccountTokenTrueAndDefaultSA",
|
|
2715
|
-
"ResourceNamespace": namespace,
|
|
2716
|
-
"ResourceKind": kind,
|
|
2717
|
-
"ResourceName": name,
|
|
2718
|
-
"msg": "Pod automounts service account token and uses default SA.",
|
|
2719
|
-
}
|
|
2720
|
-
)
|
|
2721
2878
|
# Capabilities
|
|
2722
2879
|
if not security_ctx or not security_ctx.get("capabilities"):
|
|
2723
2880
|
findings.append(
|
|
@@ -2727,6 +2884,7 @@ class Kubenumerate:
|
|
|
2727
2884
|
"ResourceKind": kind,
|
|
2728
2885
|
"ResourceName": name,
|
|
2729
2886
|
"Container": cname,
|
|
2887
|
+
"ContainerType": container_type,
|
|
2730
2888
|
"msg": "Missing securityContext or capabilities.",
|
|
2731
2889
|
}
|
|
2732
2890
|
)
|
|
@@ -2740,6 +2898,8 @@ class Kubenumerate:
|
|
|
2740
2898
|
"ResourceKind": kind,
|
|
2741
2899
|
"ResourceName": name,
|
|
2742
2900
|
"Container": cname,
|
|
2901
|
+
"ContainerType": container_type,
|
|
2902
|
+
"UserNamespaced": userns_isolated,
|
|
2743
2903
|
"Metadata": str(caps["add"]),
|
|
2744
2904
|
"msg": f"Capabilities added: {caps['add']}",
|
|
2745
2905
|
}
|
|
@@ -2752,34 +2912,39 @@ class Kubenumerate:
|
|
|
2752
2912
|
"ResourceKind": kind,
|
|
2753
2913
|
"ResourceName": name,
|
|
2754
2914
|
"Container": cname,
|
|
2915
|
+
"ContainerType": container_type,
|
|
2755
2916
|
"msg": "Container should drop all capabilities.",
|
|
2756
2917
|
}
|
|
2757
2918
|
)
|
|
2758
|
-
# Limits
|
|
2759
|
-
|
|
2760
|
-
|
|
2761
|
-
|
|
2762
|
-
|
|
2763
|
-
|
|
2764
|
-
|
|
2765
|
-
|
|
2766
|
-
|
|
2767
|
-
|
|
2768
|
-
|
|
2769
|
-
|
|
2770
|
-
|
|
2771
|
-
|
|
2772
|
-
|
|
2773
|
-
|
|
2774
|
-
|
|
2775
|
-
|
|
2776
|
-
|
|
2777
|
-
|
|
2778
|
-
|
|
2779
|
-
|
|
2780
|
-
|
|
2781
|
-
|
|
2782
|
-
|
|
2919
|
+
# Limits. The API forbids `resources` on ephemeral containers ("Resources are not allowed for
|
|
2920
|
+
# ephemeral containers", EphemeralContainerCommon), so flagging them would always be wrong.
|
|
2921
|
+
if container_type != "ephemeralContainer":
|
|
2922
|
+
resources = container.get("resources", {})
|
|
2923
|
+
limits = resources.get("limits", {})
|
|
2924
|
+
if not limits:
|
|
2925
|
+
findings.append(
|
|
2926
|
+
{
|
|
2927
|
+
"AuditResultName": "LimitsNotSet",
|
|
2928
|
+
"ResourceNamespace": namespace,
|
|
2929
|
+
"ResourceKind": kind,
|
|
2930
|
+
"ResourceName": name,
|
|
2931
|
+
"Container": cname,
|
|
2932
|
+
"ContainerType": container_type,
|
|
2933
|
+
"msg": "No resource limits set.",
|
|
2934
|
+
}
|
|
2935
|
+
)
|
|
2936
|
+
if "cpu" not in limits:
|
|
2937
|
+
findings.append(
|
|
2938
|
+
{
|
|
2939
|
+
"AuditResultName": "LimitsCPUNotSet",
|
|
2940
|
+
"ResourceNamespace": namespace,
|
|
2941
|
+
"ResourceKind": kind,
|
|
2942
|
+
"ResourceName": name,
|
|
2943
|
+
"Container": cname,
|
|
2944
|
+
"ContainerType": container_type,
|
|
2945
|
+
"msg": "No CPU limit set.",
|
|
2946
|
+
}
|
|
2947
|
+
)
|
|
2783
2948
|
# Mounts
|
|
2784
2949
|
for vol_mount in container.get("volumeMounts", []):
|
|
2785
2950
|
mount_path = vol_mount.get("mountPath", "")
|
|
@@ -2797,6 +2962,7 @@ class Kubenumerate:
|
|
|
2797
2962
|
"ResourceKind": kind,
|
|
2798
2963
|
"ResourceName": name,
|
|
2799
2964
|
"Container": cname,
|
|
2965
|
+
"ContainerType": container_type,
|
|
2800
2966
|
"msg": f"Sensitive path mounted: {mount_path}",
|
|
2801
2967
|
}
|
|
2802
2968
|
)
|
|
@@ -2813,6 +2979,8 @@ class Kubenumerate:
|
|
|
2813
2979
|
"ResourceKind": kind,
|
|
2814
2980
|
"ResourceName": name,
|
|
2815
2981
|
"Container": cname,
|
|
2982
|
+
"ContainerType": container_type,
|
|
2983
|
+
"UserNamespaced": userns_isolated,
|
|
2816
2984
|
"msg": "runAsNonRoot not set in Pod or Container SecurityContext.",
|
|
2817
2985
|
}
|
|
2818
2986
|
)
|
|
@@ -2824,6 +2992,8 @@ class Kubenumerate:
|
|
|
2824
2992
|
"ResourceKind": kind,
|
|
2825
2993
|
"ResourceName": name,
|
|
2826
2994
|
"Container": cname,
|
|
2995
|
+
"ContainerType": container_type,
|
|
2996
|
+
"UserNamespaced": userns_isolated,
|
|
2827
2997
|
"msg": "Container runs as UID 0.",
|
|
2828
2998
|
}
|
|
2829
2999
|
)
|
|
@@ -2835,6 +3005,8 @@ class Kubenumerate:
|
|
|
2835
3005
|
"ResourceKind": kind,
|
|
2836
3006
|
"ResourceName": name,
|
|
2837
3007
|
"Container": cname,
|
|
3008
|
+
"ContainerType": container_type,
|
|
3009
|
+
"UserNamespaced": userns_isolated,
|
|
2838
3010
|
"msg": "Pod runs as UID 0.",
|
|
2839
3011
|
}
|
|
2840
3012
|
)
|
|
@@ -2848,6 +3020,8 @@ class Kubenumerate:
|
|
|
2848
3020
|
"ResourceKind": kind,
|
|
2849
3021
|
"ResourceName": name,
|
|
2850
3022
|
"Container": cname,
|
|
3023
|
+
"ContainerType": container_type,
|
|
3024
|
+
"UserNamespaced": userns_isolated,
|
|
2851
3025
|
"msg": "allowPrivilegeEscalation not set.",
|
|
2852
3026
|
}
|
|
2853
3027
|
)
|
|
@@ -2859,6 +3033,8 @@ class Kubenumerate:
|
|
|
2859
3033
|
"ResourceKind": kind,
|
|
2860
3034
|
"ResourceName": name,
|
|
2861
3035
|
"Container": cname,
|
|
3036
|
+
"ContainerType": container_type,
|
|
3037
|
+
"UserNamespaced": userns_isolated,
|
|
2862
3038
|
"msg": "allowPrivilegeEscalation is true.",
|
|
2863
3039
|
}
|
|
2864
3040
|
)
|
|
@@ -2872,6 +3048,8 @@ class Kubenumerate:
|
|
|
2872
3048
|
"ResourceKind": kind,
|
|
2873
3049
|
"ResourceName": name,
|
|
2874
3050
|
"Container": cname,
|
|
3051
|
+
"ContainerType": container_type,
|
|
3052
|
+
"UserNamespaced": userns_isolated,
|
|
2875
3053
|
"msg": "privileged not set.",
|
|
2876
3054
|
}
|
|
2877
3055
|
)
|
|
@@ -2883,9 +3061,41 @@ class Kubenumerate:
|
|
|
2883
3061
|
"ResourceKind": kind,
|
|
2884
3062
|
"ResourceName": name,
|
|
2885
3063
|
"Container": cname,
|
|
3064
|
+
"ContainerType": container_type,
|
|
3065
|
+
"UserNamespaced": userns_isolated,
|
|
2886
3066
|
"msg": "privileged is true.",
|
|
2887
3067
|
}
|
|
2888
3068
|
)
|
|
3069
|
+
# procMount (KEP-4265, GA in v1.36). Unmasked lifts the runtime's masking of /proc/kcore,
|
|
3070
|
+
# /proc/keys, /proc/sched_debug, /sys/firmware and friends. Deliberately not version-gated:
|
|
3071
|
+
# if the field survived admission it is live, and dangerous, whatever the cluster version.
|
|
3072
|
+
if security_ctx.get("procMount") == "Unmasked":
|
|
3073
|
+
findings.append(
|
|
3074
|
+
{
|
|
3075
|
+
"AuditResultName": "ProcMountUnmasked",
|
|
3076
|
+
"ResourceNamespace": namespace,
|
|
3077
|
+
"ResourceKind": kind,
|
|
3078
|
+
"ResourceName": name,
|
|
3079
|
+
"Container": cname,
|
|
3080
|
+
"ContainerType": container_type,
|
|
3081
|
+
"UserNamespaced": userns_isolated,
|
|
3082
|
+
"msg": "procMount is Unmasked, exposing normally-masked /proc and /sys paths.",
|
|
3083
|
+
}
|
|
3084
|
+
)
|
|
3085
|
+
if not userns_isolated:
|
|
3086
|
+
findings.append(
|
|
3087
|
+
{
|
|
3088
|
+
"AuditResultName": "ProcMountUnmaskedWithoutUserNamespace",
|
|
3089
|
+
"ResourceNamespace": namespace,
|
|
3090
|
+
"ResourceKind": kind,
|
|
3091
|
+
"ResourceName": name,
|
|
3092
|
+
"Container": cname,
|
|
3093
|
+
"ContainerType": container_type,
|
|
3094
|
+
"UserNamespaced": userns_isolated,
|
|
3095
|
+
"msg": "procMount is Unmasked without hostUsers: false, which the API "
|
|
3096
|
+
"server should reject.",
|
|
3097
|
+
}
|
|
3098
|
+
)
|
|
2889
3099
|
# Root filesystem
|
|
2890
3100
|
ro_rootfs = security_ctx.get("readOnlyRootFilesystem")
|
|
2891
3101
|
if not ro_rootfs:
|
|
@@ -2896,9 +3106,23 @@ class Kubenumerate:
|
|
|
2896
3106
|
"ResourceKind": kind,
|
|
2897
3107
|
"ResourceName": name,
|
|
2898
3108
|
"Container": cname,
|
|
3109
|
+
"ContainerType": container_type,
|
|
2899
3110
|
"msg": "readOnlyRootFilesystem not set or false.",
|
|
2900
3111
|
}
|
|
2901
3112
|
)
|
|
3113
|
+
# User namespaces. Gated on v1.36+, where hostUsers went GA: on older clusters the field is
|
|
3114
|
+
# alpha/beta and may be stripped by the API server, so recommending it would be a false positive.
|
|
3115
|
+
if all_containers and not userns_isolated and self.cluster_at_least("1.36.0"):
|
|
3116
|
+
findings.append(
|
|
3117
|
+
{
|
|
3118
|
+
"AuditResultName": "UserNamespaceNotEnabled",
|
|
3119
|
+
"ResourceNamespace": namespace,
|
|
3120
|
+
"ResourceKind": kind,
|
|
3121
|
+
"ResourceName": name,
|
|
3122
|
+
"UserNamespaced": False,
|
|
3123
|
+
"msg": "hostUsers is not false, so container root is also root on the node.",
|
|
3124
|
+
}
|
|
3125
|
+
)
|
|
2902
3126
|
# Host namespace
|
|
2903
3127
|
if pod_spec.get("hostPID") is True:
|
|
2904
3128
|
findings.append(
|
|
@@ -2963,6 +3187,8 @@ class Kubenumerate:
|
|
|
2963
3187
|
"ResourceKind",
|
|
2964
3188
|
"ResourceName",
|
|
2965
3189
|
"Container",
|
|
3190
|
+
"ContainerType",
|
|
3191
|
+
"UserNamespaced",
|
|
2966
3192
|
"AnnotationValue",
|
|
2967
3193
|
"MissingAnnotation",
|
|
2968
3194
|
"Metadata",
|
|
@@ -39,8 +39,26 @@ ISSUE_NAME_MAP = {
|
|
|
39
39
|
"NamespaceHostNetworkTrue": "Uses host network namespace",
|
|
40
40
|
"MissingDefaultDenyIngressAndEgressNetworkPolicy": "Missing default deny network policy",
|
|
41
41
|
"AllowAllEgressNetworkPolicyExists": "Allows all egress traffic",
|
|
42
|
+
"ProcMountUnmasked": "procMount set to Unmasked (/proc masking disabled)",
|
|
43
|
+
"ProcMountUnmaskedWithoutUserNamespace": "procMount Unmasked without a user namespace",
|
|
44
|
+
"UserNamespaceNotEnabled": "User namespace not enabled (hostUsers not false)",
|
|
42
45
|
}
|
|
43
46
|
|
|
47
|
+
# Findings whose impact is materially reduced when the pod runs in its own user namespace
|
|
48
|
+
# (hostUsers: false), because container root no longer maps to root on the node.
|
|
49
|
+
USERNS_MITIGATED_ISSUES = {
|
|
50
|
+
"RunAsNonRootPSCNilCSCNil",
|
|
51
|
+
"RunAsUserCSCRoot",
|
|
52
|
+
"RunAsUserPSCRoot",
|
|
53
|
+
"PrivilegedNil",
|
|
54
|
+
"PrivilegedTrue",
|
|
55
|
+
"AllowPrivilegeEscalationNil",
|
|
56
|
+
"AllowPrivilegeEscalationTrue",
|
|
57
|
+
"CapabilityAdded",
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
USERNS_MITIGATION_SUFFIX = " (mitigated by user namespace)"
|
|
61
|
+
|
|
44
62
|
|
|
45
63
|
def _get_human_readable_issue_name(technical_name: str) -> str:
|
|
46
64
|
"""
|
|
@@ -113,8 +131,11 @@ def aggregate_issues_by_workload(
|
|
|
113
131
|
if key not in workload_issues:
|
|
114
132
|
workload_issues[key] = {"issues": set(), "containers": set(), "vuln_info": {}}
|
|
115
133
|
|
|
116
|
-
# Add human-readable issue
|
|
134
|
+
# Add human-readable issue, noting where a user namespace already blunts it
|
|
117
135
|
human_readable_issue = _get_human_readable_issue_name(issue_type)
|
|
136
|
+
userns_value = row.get("UserNamespaced", False)
|
|
137
|
+
if issue_type in USERNS_MITIGATED_ISSUES and pd.notna(userns_value) and bool(userns_value):
|
|
138
|
+
human_readable_issue += USERNS_MITIGATION_SUFFIX
|
|
118
139
|
workload_issues[key]["issues"].add(human_readable_issue)
|
|
119
140
|
|
|
120
141
|
# Track containers
|