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.
Files changed (25) hide show
  1. {kubenumerate-2.1.6/kubenumerate.egg-info → kubenumerate-2.1.8}/PKG-INFO +1 -1
  2. kubenumerate-2.1.8/VERSION +1 -0
  3. {kubenumerate-2.1.6 → kubenumerate-2.1.8/kubenumerate.egg-info}/PKG-INFO +1 -1
  4. {kubenumerate-2.1.6 → kubenumerate-2.1.8}/kubenumerate.egg-info/SOURCES.txt +3 -0
  5. {kubenumerate-2.1.6 → kubenumerate-2.1.8}/kubenumerate.py +270 -44
  6. {kubenumerate-2.1.6 → kubenumerate-2.1.8}/pyproject.toml +1 -1
  7. {kubenumerate-2.1.6 → kubenumerate-2.1.8}/summary_table.py +22 -1
  8. kubenumerate-2.1.8/tests/test_container_types.py +378 -0
  9. kubenumerate-2.1.8/tests/test_security_context_v136.py +309 -0
  10. kubenumerate-2.1.8/tests/test_security_context_v136_excel.py +101 -0
  11. {kubenumerate-2.1.6 → kubenumerate-2.1.8}/tests/test_version.py +6 -4
  12. kubenumerate-2.1.6/VERSION +0 -1
  13. {kubenumerate-2.1.6 → kubenumerate-2.1.8}/ExtensiveRoleCheck.py +0 -0
  14. {kubenumerate-2.1.6 → kubenumerate-2.1.8}/LICENSE +0 -0
  15. {kubenumerate-2.1.6 → kubenumerate-2.1.8}/MANIFEST.in +0 -0
  16. {kubenumerate-2.1.6 → kubenumerate-2.1.8}/README.md +0 -0
  17. {kubenumerate-2.1.6 → kubenumerate-2.1.8}/formatter.py +0 -0
  18. {kubenumerate-2.1.6 → kubenumerate-2.1.8}/kubenumerate.egg-info/dependency_links.txt +0 -0
  19. {kubenumerate-2.1.6 → kubenumerate-2.1.8}/kubenumerate.egg-info/entry_points.txt +0 -0
  20. {kubenumerate-2.1.6 → kubenumerate-2.1.8}/kubenumerate.egg-info/requires.txt +0 -0
  21. {kubenumerate-2.1.6 → kubenumerate-2.1.8}/kubenumerate.egg-info/top_level.txt +0 -0
  22. {kubenumerate-2.1.6 → kubenumerate-2.1.8}/requirements.txt +0 -0
  23. {kubenumerate-2.1.6 → kubenumerate-2.1.8}/setup.cfg +0 -0
  24. {kubenumerate-2.1.6 → kubenumerate-2.1.8}/setup.py +0 -0
  25. {kubenumerate-2.1.6 → kubenumerate-2.1.8}/version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kubenumerate
3
- Version: 2.1.6
3
+ Version: 2.1.8
4
4
  Summary: A comprehensive Kubernetes security auditing tool
5
5
  Home-page: https://github.com/0x5ubt13/kubenumerate
6
6
  Author: Subtle
@@ -0,0 +1 @@
1
+ 2.1.8
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kubenumerate
3
- Version: 2.1.6
3
+ Version: 2.1.8
4
4
  Summary: A comprehensive Kubernetes security auditing tool
5
5
  Home-page: https://github.com/0x5ubt13/kubenumerate
6
6
  Author: Subtle
@@ -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.34.1",
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 containers:
2832
+ for container, container_type, cname_suffix in all_containers:
2665
2833
  # print(f"debug: container {container}")
2666
- cname = container.get("name", "")
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
- resources = container.get("resources", {})
2760
- limits = resources.get("limits", {})
2761
- if not limits:
2762
- findings.append(
2763
- {
2764
- "AuditResultName": "LimitsNotSet",
2765
- "ResourceNamespace": namespace,
2766
- "ResourceKind": kind,
2767
- "ResourceName": name,
2768
- "Container": cname,
2769
- "msg": "No resource limits set.",
2770
- }
2771
- )
2772
- if "cpu" not in limits:
2773
- findings.append(
2774
- {
2775
- "AuditResultName": "LimitsCPUNotSet",
2776
- "ResourceNamespace": namespace,
2777
- "ResourceKind": kind,
2778
- "ResourceName": name,
2779
- "Container": cname,
2780
- "msg": "No CPU limit set.",
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",
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "kubenumerate"
7
- version = "2.1.6"
7
+ version = "2.1.8"
8
8
  description = "A comprehensive Kubernetes security auditing tool"
9
9
  readme = "README.md"
10
10
  license = {file = "LICENSE"}
@@ -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