├── LICENSE ├── NOTICE ├── README.md ├── globals.py ├── nshound.py └── requirements.txt /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /NOTICE: -------------------------------------------------------------------------------- 1 | NamespaceHound 2 | Copyright 2024 Wiz Inc. 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Overview - Kubernetes Multi-Tenancy 2 | Kubernetes users usually share clusters to meet the demands of multiple teams and multiple customers. As the number of users grow, the need in multi-tenant solution increases as well. There are two ways to handle multi-tenancy - either through dedicated cluster per team or by managing the shared access to the same cluster. In the latter case Kubernetes offers three mechanisms to achieve control plane isolation - through usage of namespaces, RBAC and quotas, with namespace isolation being a driving factor. 3 | 4 | ## Problem 5 | In the multi-tenant cluster scenario namespaces become a security isolation controls. For example, two teams sharing the same cluster with access to workloads with varying degree of sensitivity. Or company running SaaS service allocating container / pod for every customer. 6 | However, there is no native mechanism to monitor the logical crossings of namespaces. There is also no way to detect the attack paths / vectors for potential violations. This is what NamespaceHound is for. Cluster operators can use NamespaceHound to assess the risk of cross-tenant violations in their environment. 7 | 8 | ## Usage 9 | NamespaceHound is the tool for detecting the risk of potential **namespace crossing violations** in multi-tenant clusters. Given the cluster, NamespaceHound will run analysis and determine all the possible ways to cross the security boundaries between the namespaces. In addition, the tool is inspecting the cluster config for anonymous access opportunities. If given a specific namespace (*-n namespace* parameter), it will focus on this namespace plus anonymous access to find all the possible ways to reach / interfere with the resources from another namespace. 10 | 11 | Another instance where NamespaceHound is useful is in helping red-teamers and security researchers to find **lateral movement paths** once they are past the point of initial access into the cluster. Our [2023 Kubernetes Security Report](https://www.wiz.io/blog/key-takeaways-from-the-wiz-2023-kubernetes-security-report) revealed that assuming the successful initial access, the opportunities for lateral movement are abundant and thus should be assessed rigorously. For example, in the cluster with the classic frontend - business logic - database architecture, the most obvious lateral movement direction would be from a frontend pod to a namespace containing pods with data access. 12 | 13 | ``` 14 | >python3 nshound.py -h 15 | usage: nshound.py [-h] [--kubeconfig KUBECONFIG] [-n NAMESPACE | -c] [-o {table,json,csv,html}] [-v] 16 | 17 | NamespaceHound is a tool that detects various ways to cross the namespace boundaries within the Kubernetes cluster. 18 | 19 | options: 20 | -h, --help show this help message and exit 21 | --kubeconfig KUBECONFIG 22 | .kubeconfig file containing the cluster access credentials 23 | -n NAMESPACE, --namespace NAMESPACE 24 | look for escape paths from this namespace only 25 | -c, --controlplane show issues from kube-system 26 | -o {table,json,csv,html}, --output {table,json,csv,html} 27 | output format - json, csv or table 28 | -v, --verbose increase output verbosity 29 | 30 | Run it with an existing kubeconfig file while (optionally) supplying the namespace name you are interested in. For the detailed explanation of the detected risks go to the repo README. 31 | ``` 32 | If you don't supply the specific namespace, the tool by default will hide the results from kube-system namespace to reduce the noise. Run the tool with *-c / --controlplane* parameter if you do want to see kube-system issues 33 | 34 | Sample run: 35 | 36 | ``` 37 | >python3 nshound.py --kubeconfig configs/config.eks.wizard-maker-cluster -n argocd -o table 38 | +----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ 39 | | Findings Table | 40 | +-----------+----------+------------+-----------------------------------+-----------+------+---------------------------+----------------------------------------------------+------------+ 41 | | Namespace | Severity | Confidence | Principals | Container | Pod | Type | Description | Neighbours | 42 | +-----------+----------+------------+-----------------------------------+-----------+------+---------------------------+----------------------------------------------------+------------+ 43 | | argocd | LOW | MEDIUM | None | None | None | DOS_NO_QUOTA | There is no resource limits on this namespace - if | None | 44 | | | | | | | | | attacker controls resource creation it can cause | | 45 | | | | | | | | | DoS in other namespaces. | | 46 | | argocd | HIGH | HIGH | ['argocd-application-controller'] | None | None | RBAC_SECRETS_STEALING | Principals can read secrets from another | None | 47 | | | | | | | | | namespace. | | 48 | | argocd | HIGH | HIGH | ['argocd-application-controller'] | None | None | RBAC_WORKLOAD_CREATION | Principals can create workloads in another | None | 49 | | | | | | | | | namespace. | | 50 | | argocd | HIGH | HIGH | ['argocd-application-controller'] | None | None | RBAC_POD_EXECUTION | Principals can exec/attach to pods in another | None | 51 | | | | | | | | | namespace. | | 52 | | argocd | MEDIUM | HIGH | ['argocd-application-controller'] | None | None | RBAC_CONFIGMAP_SMASHING | Principals can update configmap in another | None | 53 | | | | | | | | | namespace. | | 54 | | argocd | HIGH | HIGH | ['argocd-application-controller'] | None | None | RBAC_WEBHOOK_MANIPULATION | Principals can update | None | 55 | | | | | | | | | mutatingwebhookconfigurations and potentially | | 56 | | | | | | | | | inject a sidecar container. | | 57 | | argocd | HIGH | HIGH | ['argocd-application-controller'] | None | None | RBAC_SECRETS_SMASHING | Principals can update | None | 58 | | | | | | | | | validatingwebhookconfigurations and potentially | | 59 | | | | | | | | | steal secrets. | | 60 | | argocd | MEDIUM | HIGH | ['argocd-application-controller'] | None | None | RBAC_WORKLOAD_DELETION | Principals can delete workloads in another | None | 61 | | | | | | | | | namespace. | | 62 | | argocd | MEDIUM | HIGH | ['argocd-application-controller'] | None | None | RBAC_SHARED_URLS | Principals can access {'*'} - URLs that | None | 63 | | | | | | | | | potentially contain information from other | | 64 | | | | | | | | | namespaces. | | 65 | | argocd | HIGH | HIGH | ['argocd-server'] | None | None | RBAC_SECRETS_STEALING | Principals can read secrets from another | None | 66 | | | | | | | | | namespace. | | 67 | | argocd | HIGH | HIGH | ['argocd-server'] | None | None | RBAC_WORKLOAD_CREATION | Principals can create workloads in another | None | 68 | | | | | | | | | namespace. | | 69 | | argocd | MEDIUM | HIGH | ['argocd-server'] | None | None | RBAC_CONFIGMAP_SMASHING | Principals can update configmap in another | None | 70 | | | | | | | | | namespace. | | 71 | | argocd | HIGH | HIGH | ['argocd-server'] | None | None | RBAC_WEBHOOK_MANIPULATION | Principals can update | None | 72 | | | | | | | | | mutatingwebhookconfigurations and potentially | | 73 | | | | | | | | | inject a sidecar container. | | 74 | | argocd | HIGH | HIGH | ['argocd-server'] | None | None | RBAC_SECRETS_SMASHING | Principals can update | None | 75 | | | | | | | | | validatingwebhookconfigurations and potentially | | 76 | | | | | | | | | steal secrets. | | 77 | | argocd | MEDIUM | HIGH | ['argocd-server'] | None | None | RBAC_WORKLOAD_DELETION | Principals can delete workloads in another | None | 78 | | | | | | | | | namespace. | | 79 | +-----------+----------+------------+-----------------------------------+-----------+------+---------------------------+----------------------------------------------------+------------+ 80 | ``` 81 | ### Security and Privacy 82 | To function properly, NamespaceHound requires K8s API read permissions on all of the resource types. That is the minimal set. Of course principals mapped to *admin*, *cluster-admin* roles and *system:masters* group will work as well. 83 | 84 | NamespaceHound does not save any data about the target cluster locally. It does not build graph and does not save object material - upon every run, NamespaceHound establishes a new connection with the cluster and performes API server querying in the same capacity. 85 | 86 | ## Library - Types of Namespace Crossings 87 | 88 | | Name | Severity | Confidence | Description | Method | 89 | | -------- | ------- | -------- | ------- | ------- | 90 | | DOS_NO_QUOTA | LOW | MEDIUM | No resource quota on this namespace. Over-resourced workloads can take up other namespaces' resources. | Querying API for resource quotas | 91 | | RBAC_POD_EVICTION | LOW | HIGH | A service account from this namespace can evict pods in another namespace. | Querying RBAC API | 92 | | RBAC_ANONYMOUS_ACCESS_TO_RESOURCES | MEDIUM | HIGH | Anonymous user has access to resources. Applies to any namespace. | Querying RBAC API | 93 | | RBAC_SHARED_URLS | MEDIUM | HIGH | A service account from this namespace has access to the non-trivial URLs that potentially include other namespaces data. | Querying RBAC API | 94 | | RBAC_SECRETS_STEALING | HIGH | HIGH | A service account from this namespace has access to secrets in another namespace. | Querying RBAC API | 95 | | RBAC_CONFIGMAP_SMASHING | MEDIUM | HIGH | A service account from this namespace can manipulate a configmap from another namespace, which may result in secret stealing, data exfiltration and execution in the context of another namespace. | Querying RBAC API | 96 | | RBAC_LOG_EXFILTRATION | HIGH | HIGH | A service account from this namespace can redirect and control fluentbit logs and executions in another namespace, which results in secret stealing, data exfiltration and execution in the context of another namespace. | Querying RBAC API | 97 | | RBAC_WORKLOAD_CREATION | HIGH | HIGH | A service account from this namespace can create workloads in another namespace. | Querying RBAC API | 98 | | RBAC_WORKLOAD_DELETION | MEDIUM | HIGH | A service account from this namespace can delete workloads in another namespace. | Querying RBAC API | 99 | | RBAC_POD_EXECUTION | HIGH | HIGH | A service account from this namespace can exec/attach to pods in another namespace. | Querying RBAC API | 100 | | RBAC_WEBHOOK_MANIPULATION | HIGH | MEDIUM | A service account from this namespace can manipulate the global mutating webhook, which may result in security control compromise, secret stealing, data exfiltration and execution in the context of another namespace. | Querying RBAC API | 101 | | RBAC_SECRETS_SMASHING | HIGH | MEDIUM | A service account from this namespace can manipulate the global validating webhook, which may result in security control compromise, secret stealing and data exfiltration. | Querying RBAC API | 102 | | POD_ACCESS_TO_NPD_CONFIG | HIGH | HIGH | Pod has RW access to the node problem detector (NPD) config, which is equal to cluster admin due to powerful NPD execution. | Inspecting pod's host mounts. | 103 | | POD_ESCAPE_CORE_PATTERN | HIGH | HIGH | Pod can escape to host / has writable access to host through sensitive volume mount. | Inspecting pod's host mounts. | 104 | | POD_ACCESS_TO_LOGS | HIGH | HIGH | Pod has access to other pods logs through volume mount. | Inspecting pod's host mounts. | 105 | | POD_ACCESS_TO_HOST | HIGH | MEDIUM | Pod has access to host through sensitive volume mount. | Inspecting pod's host mounts. | 106 | | CONTAINER_PRIVILEGED_ACCESS_TO_HOST | HIGH | HIGH | Container is privileged and thus can escape to worker node and access shared secrets. | Inspecting container's capabilities and namespace sharing. | 107 | | CONTAINER_POWERFUL_CAPABILITIES | HIGH | MEDIUM | Container has powerful capabilities and thus can escape to worker node and access shared secrets. | Inspecting container's capabilities and namespace sharing. | 108 | | CONTAINER_PTRACE_CAPABILITY | HIGH | HIGH | Container has SYS_PTRACE capability allowing control of other namespace processes running on the same worker node. | Inspecting container's capabilities and namespace sharing. | 109 | | CONTAINER_BPF_CAPABILITY | HIGH | HIGH | Container has SYS_BPF capability allowing kernel-level access to other process resources, (f.e. packet capture and secret stealing). | Inspecting container's capabilities and namespace sharing. | 110 | | CONTAINER_IPC_CAPABILITY | HIGH | HIGH | Container has IPC_OWNER capability allowing control of other namespace processes running on the same worker node. | Inspecting container's capabilities and namespace sharing. | 111 | 112 | ## References 113 | - https://www.cncf.io/blog/2022/11/09/multi-tenancy-in-kubernetes-implementation-and-optimization/ 114 | - https://kubernetes.io/docs/concepts/overview/working-with-objects/namespaces/ 115 | 116 | ## License 117 | This project is licensed under the Apache-2.0 License. 118 | -------------------------------------------------------------------------------- /globals.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | import json 3 | 4 | 5 | class SetEncoder(json.JSONEncoder): 6 | def default(self, obj): 7 | if isinstance(obj, set): 8 | return list(obj) 9 | return json.JSONEncoder.default(self, obj) 10 | 11 | 12 | # global structures 13 | namespaces = {} 14 | findings = dict(allnamespaces=[]) 15 | node_residency_table = dict() 16 | 17 | cluster_flavor = {} 18 | 19 | 20 | class ClusterFlavor(str, Enum): 21 | AKS = "AKS" 22 | EKS = "EKS" 23 | GKE = "GKE" 24 | OTHER = "OTHER" 25 | 26 | 27 | class OutputFormat(Enum): 28 | table = 'table' 29 | json = 'json' 30 | csv = 'csv' 31 | html = 'html' 32 | 33 | def __str__(self): 34 | return self.value 35 | 36 | 37 | class Finding(str, Enum): 38 | DOS_NO_QUOTA = "DOS_NO_QUOTA" 39 | RBAC_ANONYMOUS_ACCESS_TO_RESOURCES = "RBAC_ANONYMOUS_ACCESS_TO_RESOURCES" 40 | RBAC_GOOGLE_USER_ACCESS_TO_RESOURCES = "RBAC_GOOGLE_USER_ACCESS_TO_RESOURCES" 41 | RBAC_SHARED_URLS = "RBAC_SHARED_URLS" 42 | RBAC_SECRETS_STEALING = "RBAC_SECRETS_STEALING" 43 | RBAC_CONFIGMAP_SMASHING = "RBAC_CONFIGMAP_SMASHING" 44 | RBAC_LOG_EXFILTRATION = "RBAC_LOG_EXFILTRATION" 45 | RBAC_WORKLOAD_CREATION = "RBAC_WORKLOAD_CREATION" 46 | RBAC_WORKLOAD_DELETION = "RBAC_WORKLOAD_DELETION" 47 | RBAC_POD_EXECUTION = "RBAC_POD_EXECUTION" 48 | RBAC_POD_EVICTION = "RBAC_POD_EVICTION" 49 | RBAC_WEBHOOK_MANIPULATION = "RBAC_WEBHOOK_MANIPULATION" 50 | RBAC_SECRETS_SMASHING = "RBAC_SECRETS_SMASHING" 51 | POD_ACCESS_TO_NPD_CONFIG = "POD_ACCESS_TO_NPD_CONFIG" 52 | POD_ESCAPE_CORE_PATTERN = "POD_ESCAPE_CORE_PATTERN" 53 | POD_ACCESS_TO_LOGS = "POD_ACCESS_TO_LOGS" 54 | POD_ACCESS_TO_HOST = "POD_ACCESS_TO_HOST" 55 | CONTAINER_PRIVILEGED_ACCESS_TO_HOST = "CONTAINER_PRIVILEGED_ACCESS_TO_HOST" 56 | CONTAINER_POWERFUL_CAPABILITIES = "CONTAINER_POWERFUL_CAPABILITIES" 57 | CONTAINER_PTRACE_CAPABILITY = "CONTAINER_PTRACE_CAPABILITY" 58 | CONTAINER_BPF_CAPABILITY = "CONTAINER_BPF_CAPABILITY" 59 | CONTAINER_IPC_CAPABILITY = "CONTAINER_IPC_CAPABILITY" 60 | 61 | 62 | # constants 63 | help_string = "Run it with an existing kubeconfig file while (optionally) supplying the namespace name you are interested in. For the detailed explanation of the detected risks go to the repo README." 64 | sensitive_volumes = {"/", "/boot", "/boot/", "/dev", "/dev/", "/etc", "/etc/", "/home", "/home/", "/proc", "/proc/", 65 | "/lib", "/lib/", "/root", "/root/", "/run", "/run/", "/seLinux", "/seLinux/", "/srv", "/srv/", 66 | "/var", "/var/", "/var/lib", "/var/lib/", "/var/lib/kubelet", "/var/lib/kubelet/"} 67 | core_pattern_escape_volumes = {"/", "/proc", "/proc/", "/proc/sys", "/proc/sys/","/proc/sys/kernel", "/proc/sys/kernel/"} 68 | log_volumes = {"/var/log", "/var/log/"} 69 | sensitive_npd_volumes_on_gke = {"/", "/home", "/home/", "/home/kubernetes", "/home/kubernetes/", 70 | "/home/kubernetes/node-problem-detector", 71 | "/home/kubernetes/node-problem-detector/", 72 | "/home/kubernetes/node-problem-detector/config", 73 | "/home/kubernetes/node-problem-detector/config/"} 74 | sensitive_npd_volumes_on_aks = {"/", "/etc", "/etc/", "/etc/node-problem-detector.d", "/etc/node-problem-detector.d/", 75 | "/etc/node-problem-detector.d/custom-plugin-monitor", 76 | "/etc/node-problem-detector.d/custom-plugin-monitor/", 77 | "/etc/node-problem-detector.d/system-stats-monitor", 78 | "/etc/node-problem-detector.d/system-stats-monitor/", 79 | "/etc/node-problem-detector.d/system-log-monitor", 80 | "/etc/node-problem-detector.d/system-log-monitor/"} 81 | powerful_standalone_capabilities = {"SYS_ADMIN", "SYS_RAWIO", "DAC_READ_SEARCH", "DAC_OVERRIDE", "SYS_BOOT", 82 | "SETUID", "SETGID", "KILL", "SYS_MODULE"} 83 | ptrace_capability = "SYS_PTRACE" 84 | bpf_capability = "SYS_BPF" 85 | ipc_capability = "IPC_OWNER" 86 | 87 | read_verbs = {"*", "watch", "get", "list"} 88 | secret_resources = {"*", "secrets"} 89 | create_verbs = {"*", "create", "update", "patch"} 90 | delete_verbs = {"*", "delete", "deletecollection"} 91 | workload_resources = {"*", "pods", "daemonsets", "deployments", "replicasets", "jobs", "cronjobs", "replicationcontrollers", "statefulsets"} -------------------------------------------------------------------------------- /nshound.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import argparse 3 | import logging 4 | import sys 5 | import json 6 | import csv 7 | from prettytable import prettytable 8 | import globals 9 | from kubernetes import client, config 10 | 11 | # TODOs 12 | # 1. Make it into Krew plugin 13 | # 2. Add cgroupsv1 escape 14 | # 3. Add sysfs escape 15 | 16 | 17 | # file the finding - both to the output and to the findings array 18 | def fileFinding(type, namespace_name, description, container=None, 19 | pod=None, principals=None, severity="HIGH", confidence="HIGH", neighbours=None): 20 | line = f"{type} - " 21 | if container is not None: 22 | line += f"container {container} in " 23 | if pod is not None: 24 | line += f"pod {pod}" 25 | if principals is not None: 26 | line += f"{principals}" 27 | line += f" in {namespace_name}" 28 | line += f" - {description}" 29 | if neighbours is not None: 30 | line += f" Pods from following namespaces reside on the same worker node: {neighbours}" 31 | logging.warning("FINDING: " + line) 32 | finding = {} 33 | finding['type'] = type 34 | finding['severity'] = severity 35 | finding['confidence'] = confidence 36 | finding['principals'] = principals 37 | finding['container'] = container 38 | finding['pod'] = pod 39 | finding['description'] = description 40 | finding['neighbours'] = neighbours 41 | globals.findings[namespace_name].append(finding) 42 | 43 | 44 | def inspectContainer(p, c, ns): 45 | 46 | # privileged container is a game over 47 | if c.security_context and c.security_context.privileged is True: 48 | fileFinding(type=globals.Finding.CONTAINER_PRIVILEGED_ACCESS_TO_HOST, 49 | severity="HIGH", 50 | confidence="HIGH", 51 | container=c.name, 52 | pod=p.metadata.name, 53 | namespace_name=ns.metadata.name, 54 | neighbours=globals.node_residency_table[p.spec.node_name] - {ns.metadata.name}, 55 | description="Container is privileged and thus can escape to worker node and access shared secrets.") 56 | 57 | if c.security_context and c.security_context.capabilities is not None and c.security_context.capabilities.add is not None: 58 | 59 | # capabilities that dont need additional conditions to escape 60 | if globals.powerful_standalone_capabilities & set(c.security_context.capabilities.add): 61 | fileFinding(type=globals.Finding.CONTAINER_POWERFUL_CAPABILITIES, 62 | severity="HIGH", 63 | confidence="MEDIUM", 64 | container=c.name, 65 | pod=p.metadata.name, 66 | namespace_name=ns.metadata.name, 67 | neighbours=globals.node_residency_table[p.spec.node_name] - {ns.metadata.name}, 68 | description="Container has powerful capabilities and thus can escape to worker node and access shared secrets.") 69 | 70 | # bpf capability means kernel-level access 71 | if globals.bpf_capability in c.security_context.capabilities.add: 72 | fileFinding(type=globals.Finding.CONTAINER_BPF_CAPABILITY, 73 | severity="HIGH", 74 | confidence="HIGH", 75 | container=c.name, 76 | pod=p.metadata.name, 77 | namespace_name=ns.metadata.name, 78 | neighbours=globals.node_residency_table[p.spec.node_name] - {ns.metadata.name}, 79 | description="Container has SYS_BPF capability allowing kernel-level access to other process resources, (f.e. packet capture and secret stealing).") 80 | 81 | # ptrace requires shared pid namespace 82 | if globals.ptrace_capability in c.security_context.capabilities.add and p.spec.host_pid == True: 83 | fileFinding(type=globals.Finding.CONTAINER_PTRACE_CAPABILITY, 84 | severity="HIGH", 85 | confidence="HIGH", 86 | container=c.name, 87 | pod=p.metadata.name, 88 | namespace_name=ns.metadata.name, 89 | neighbours=globals.node_residency_table[p.spec.node_name] - {ns.metadata.name}, 90 | description="Container has SYS_PTRACE capability allowing control of other namespace processes running on the same worker node.") 91 | 92 | # ipc requires shared ipc namespace 93 | if globals.ipc_capability in c.security_context.capabilities.add and p.spec.host_ipc is True: 94 | fileFinding(type=globals.Finding.CONTAINER_IPC_CAPABILITY, 95 | severity="HIGH", 96 | confidence="HIGH", 97 | container=c.name, 98 | pod=p.metadata.name, 99 | namespace_name=ns.metadata.name, 100 | neighbours=globals.node_residency_table[p.spec.node_name] - {ns.metadata.name}, 101 | description="Container has IPC_OWNER capability allowing control of other namespace processes running on the same worker node.") 102 | 103 | 104 | def inspectPod(pod, ns): 105 | logging.info(f"\tInspecting pod {pod.metadata.name}") 106 | # prep pod volume mounts 107 | host_volumes = [v for v in pod.spec.volumes if v.host_path is not None] 108 | 109 | # on the other side prep all container mouns 110 | container_mounts = [v for c in pod.spec.containers if c.volume_mounts is not None for v in c.volume_mounts] 111 | if pod.spec.init_containers: 112 | container_mounts += [v for c in pod.spec.init_containers if c.volume_mounts is not None for v in c.volume_mounts] 113 | 114 | # privileged pod can escape immediately 115 | for c in pod.spec.containers: 116 | inspectContainer(pod, c, ns) 117 | if pod.spec.init_containers: 118 | for c in pod.spec.init_containers: 119 | inspectContainer(pod, c, ns) 120 | # not going to go over ephemeral cotainers as its unclear whether attacker can reliably use them 121 | 122 | # look for interesting volumes 123 | for volume in host_volumes: 124 | 125 | # look for NPD compromise on GKE - must be RW 126 | if cluster_flavor == globals.ClusterFlavor.GKE and volume.host_path.path in globals.sensitive_npd_volumes_on_gke: 127 | for vm in container_mounts: 128 | if vm.name == volume.name and (vm.read_only is False or vm.read_only is None): 129 | fileFinding(type=globals.Finding.POD_ACCESS_TO_NPD_CONFIG, 130 | severity="HIGH", 131 | confidence="HIGH", 132 | pod=pod.metadata.name, 133 | namespace_name=ns.metadata.name, 134 | neighbours=globals.node_residency_table[pod.spec.node_name] - {ns.metadata.name}, 135 | description="Pod has RW access to the node problem detector config which is equal to cluster admin.") 136 | 137 | # look for NPD compromise on GKE - must be RW 138 | if cluster_flavor == globals.ClusterFlavor.AKS and volume.host_path.path in globals.sensitive_npd_volumes_on_aks: 139 | for vm in container_mounts: 140 | if vm.name == volume.name and (vm.read_only is False or vm.read_only is None): 141 | fileFinding(type=globals.Finding.POD_ACCESS_TO_NPD_CONFIG, 142 | severity="HIGH", 143 | confidence="HIGH", 144 | pod=pod.metadata.name, 145 | namespace_name=ns.metadata.name, 146 | neighbours=globals.node_residency_table[pod.spec.node_name] - {ns.metadata.name}, 147 | description="Pod has RW access to the node problem detector config which is equal to cluster admin.") 148 | 149 | # look for writable mount allowing core pattern container escape 150 | if volume.host_path.path in globals.core_pattern_escape_volumes: 151 | for vm in container_mounts: 152 | if vm.name == volume.name and (vm.read_only is False or vm.read_only is None): 153 | fileFinding(type=globals.Finding.POD_ESCAPE_CORE_PATTERN, 154 | severity="HIGH", 155 | confidence="HIGH", 156 | pod=pod.metadata.name, 157 | namespace_name=ns.metadata.name, 158 | neighbours=globals.node_residency_table[pod.spec.node_name] - {ns.metadata.name}, 159 | description=f"Pod has access to host through sensitive volume mount {volume.host_path.path}.") 160 | 161 | # look for readable log mount allowing reading other pod logs 162 | if volume.host_path.path in globals.log_volumes: 163 | for vm in container_mounts: 164 | if vm.name == volume.name: 165 | fileFinding(type=globals.Finding.POD_ACCESS_TO_LOGS, 166 | severity="HIGH", 167 | confidence="HIGH", 168 | pod=pod.metadata.name, 169 | namespace_name=ns.metadata.name, 170 | neighbours=globals.node_residency_table[pod.spec.node_name] - {ns.metadata.name}, 171 | description=f"Pod has access to other pods logs through volume mount {volume.host_path.path}.") 172 | 173 | # look for general sensitive mounts allowing either escape or token/secret lookup 174 | if volume.host_path.path in globals.sensitive_volumes: 175 | fileFinding(type=globals.Finding.POD_ACCESS_TO_HOST, 176 | severity="HIGH", 177 | confidence="MEDIUM", 178 | pod=pod.metadata.name, 179 | namespace_name=ns.metadata.name, 180 | neighbours=globals.node_residency_table[pod.spec.node_name] - {ns.metadata.name}, 181 | description=f"Pod has access to host through sensitive volume mount {volume.host_path.path}.") 182 | 183 | 184 | if __name__ == '__main__': 185 | # Argument parsing 186 | parser = argparse.ArgumentParser( 187 | description='NamespaceHound is a tool that detects various ways to cross the namespace boundaries within the Kubernetes cluster.', 188 | formatter_class=argparse.RawTextHelpFormatter, 189 | epilog=globals.help_string) 190 | parser.add_argument("--kubeconfig", help=".kubeconfig file containing the cluster access credentials") 191 | group = parser.add_mutually_exclusive_group() 192 | group.add_argument("-n", "--namespace", help="look for escape paths from this namespace only") 193 | group.add_argument("-c", "--controlplane", help="show issues from kube-system", action='store_true') 194 | parser.add_argument("-o", "--output", help="output format - json, csv or table", type=globals.OutputFormat, choices=list(globals.OutputFormat)) 195 | parser.add_argument("-v", "--verbose", action="store_true", help="increase output verbosity") 196 | 197 | args = parser.parse_args() 198 | 199 | # deal with verbosity print until the end of the program 200 | if args.verbose: 201 | level = logging.INFO 202 | else: 203 | level = logging.ERROR 204 | logging.basicConfig(format="%(message)s", level=level) 205 | 206 | try: 207 | # Configs can be set in Configuration class directly or using helper utility 208 | if args.kubeconfig: 209 | config.load_kube_config(config_file=args.kubeconfig) 210 | else: 211 | config.load_kube_config() 212 | 213 | # Create an instance of the Kubernetes API client 214 | api_client = client.ApiClient() 215 | 216 | # Create a CoreV1Api instance to interact with pods 217 | v1 = client.CoreV1Api(api_client) 218 | rbac_v1_api = client.RbacAuthorizationV1Api(api_client) 219 | version = client.VersionApi() 220 | 221 | # General 222 | logging.info(f"Cluster version: \n\t{version.get_code()}") 223 | 224 | # ---------------------------------- 225 | # Cluster flavor for future 226 | # ---------------------------------- 227 | try: 228 | nodes = v1.list_node() 229 | kubelet_version = nodes.items[0].status.node_info.kubelet_version 230 | kernel_version = nodes.items[0].status.node_info.kernel_version 231 | if "gke" in kubelet_version: 232 | cluster_flavor = globals.ClusterFlavor.GKE 233 | elif "eks" in kubelet_version: 234 | cluster_flavor = globals.ClusterFlavor.EKS 235 | elif "aks" in kernel_version: 236 | cluster_flavor = globals.ClusterFlavor.AKS 237 | else: 238 | cluster_flavor = globals.ClusterFlavor.OTHER 239 | except Exception: 240 | cluster_flavor = globals.ClusterFlavor.OTHER 241 | logging.info(f"Cluster flavor: {cluster_flavor}") 242 | 243 | # ---------------------------------- 244 | # Prepare the node residency table 245 | # ---------------------------------- 246 | all_pods = v1.list_pod_for_all_namespaces() 247 | for node in nodes.items: 248 | globals.node_residency_table[node.metadata.name] = set() 249 | for pod in all_pods.items: 250 | if pod.spec.node_name == node.metadata.name: 251 | globals.node_residency_table[node.metadata.name].add(pod.metadata.namespace) 252 | 253 | # ---------------------------------- 254 | # Check global anonymous access first 255 | # ---------------------------------- 256 | try: 257 | # first real connection 258 | cluster_role_bindings = rbac_v1_api.list_cluster_role_binding() 259 | except client.ApiException: 260 | logging.error("Found configfile, but can't connect. No permissions?") 261 | sys.exit(1) 262 | 263 | for cluster_role_binding in cluster_role_bindings.items: 264 | subjects = cluster_role_binding.subjects 265 | if subjects is None: 266 | continue 267 | user_names = [u.name for u in subjects if u.kind == "User"] 268 | group_names = [g.name for g in subjects if g.kind == "Group"] 269 | 270 | # evaluate dangerous conditions 271 | anonymous_condition = "system:anonymous" in user_names or "system:unauthenticated" in group_names 272 | authenticated_condition = "system:authenticated" in group_names and cluster_flavor == globals.ClusterFlavor.GKE 273 | 274 | if not anonymous_condition and not authenticated_condition: 275 | continue 276 | 277 | role_name = cluster_role_binding.role_ref.name 278 | # now find out whether this role can do something non-trivial 279 | try: 280 | role = rbac_v1_api.read_cluster_role(name=role_name) 281 | except Exception as e: 282 | logging.info(f"Error: {str(e)}") 283 | continue 284 | 285 | role_rules = role.rules 286 | for rule in role_rules: 287 | rule_non_resource_urls = None if rule.non_resource_ur_ls is None else set(rule.non_resource_ur_ls) 288 | rule_verbs = None if rule.verbs is None else set(rule.verbs) 289 | rule_resources = None if rule.resources is None else set(rule.resources) 290 | 291 | # check for meaningful url access 292 | if rule_non_resource_urls is not None: 293 | meaningful_urls = rule_non_resource_urls - {"/configz", "/healthz", "readiness", "/version", "/livez", "/readyz", "/version/"} 294 | if len(meaningful_urls) > 0: 295 | if anonymous_condition: 296 | fileFinding(type=globals.Finding.RBAC_ANONYMOUS_ACCESS_TO_RESOURCES, 297 | severity="MEDIUM", 298 | confidence="LOW", 299 | namespace_name="allnamespaces", 300 | description=f"Anonymous user can {rule_verbs} {meaningful_urls} that potentially contain information from other namespaces.") 301 | if authenticated_condition: 302 | fileFinding(type=globals.Finding.RBAC_GOOGLE_USER_ACCESS_TO_RESOURCES, 303 | severity="MEDIUM", 304 | confidence="LOW", 305 | namespace_name="allnamespaces", 306 | description=f"Any Google user can {rule_verbs} {meaningful_urls} that potentially contain information from other namespaces.") 307 | continue 308 | 309 | # check for reading secrets from another namespace 310 | if globals.read_verbs & rule_verbs and globals.secret_resources & rule_resources: 311 | if anonymous_condition: 312 | fileFinding(type=globals.Finding.RBAC_ANONYMOUS_ACCESS_TO_RESOURCES, 313 | severity="HIGH", 314 | confidence="HIGH", 315 | namespace_name="allnamespaces", 316 | description="Anonymous user can read secrets from any namespace.") 317 | if authenticated_condition: 318 | fileFinding(type=globals.Finding.RBAC_GOOGLE_USER_ACCESS_TO_RESOURCES, 319 | severity="HIGH", 320 | confidence="HIGH", 321 | namespace_name="allnamespaces", 322 | description="Any Google user can read secrets from any namespace.") 323 | 324 | # check for creation of workloads in another namespace 325 | if globals.create_verbs & rule_verbs and globals.workload_resources & rule_resources: 326 | if anonymous_condition: 327 | fileFinding(type=globals.Finding.RBAC_ANONYMOUS_ACCESS_TO_RESOURCES, 328 | severity="HIGH", 329 | confidence="HIGH", 330 | namespace_name="allnamespaces", 331 | description="Anonymous user can create workloads in any namespace.") 332 | if authenticated_condition: 333 | fileFinding(type=globals.Finding.RBAC_GOOGLE_USER_ACCESS_TO_RESOURCES, 334 | severity="HIGH", 335 | confidence="HIGH", 336 | namespace_name="allnamespaces", 337 | description="Any Google user can create workloads in any namespace.") 338 | 339 | # check for execution into pods from other namespaces 340 | if {"*", "create"} & rule_verbs and {"*", "pods/exec", "pods/attach"} & rule_resources: 341 | if anonymous_condition: 342 | fileFinding(type=globals.Finding.RBAC_ANONYMOUS_ACCESS_TO_RESOURCES, 343 | severity="HIGH", 344 | confidence="HIGH", 345 | namespace_name="allnamespaces", 346 | description="Anonymous user can exec/attach to pods in any namespace.") 347 | if authenticated_condition: 348 | fileFinding(type=globals.Finding.RBAC_GOOGLE_USER_ACCESS_TO_RESOURCES, 349 | severity="HIGH", 350 | confidence="HIGH", 351 | namespace_name="allnamespaces", 352 | description="Any Google user can exec/attach to pods in any namespace.") 353 | 354 | # rest of the operations - less severe 355 | if rule_resources is not None: 356 | if anonymous_condition: 357 | fileFinding(type=globals.Finding.RBAC_ANONYMOUS_ACCESS_TO_RESOURCES, 358 | severity="MEDIUM", 359 | confidence="HIGH", 360 | namespace_name="allnamespaces", 361 | description=f"Anonymous user can {rule_verbs} {rule_resources} in any namespace.") 362 | if authenticated_condition: 363 | fileFinding(type=globals.Finding.RBAC_GOOGLE_USER_ACCESS_TO_RESOURCES, 364 | severity="MEDIUM", 365 | confidence="HIGH", 366 | namespace_name="allnamespaces", 367 | description=f"Any Google user can {rule_verbs} {rule_resources} in any namespace.") 368 | 369 | # ---------------------------------- 370 | # Separate check for fluentbit 371 | # ---------------------------------- 372 | configmaps = v1.list_config_map_for_all_namespaces() 373 | fluentbit_configmaps = [c.metadata.name for c in configmaps.items if ("fluentbit" in c.metadata.name or "ama-logs" in c.metadata.name or "fluent-bit" in c.metadata.name or "fluentd" in c.metadata.name)] 374 | 375 | # Check for namespace existence 376 | try: 377 | if args.namespace: 378 | v1.read_namespace(name=args.namespace) 379 | except client.ApiException: 380 | logging.error(f"No such namespace {args.namespace}") 381 | 382 | # ---------------------------------- 383 | # Main namespaces loop 384 | # ---------------------------------- 385 | namespaces = v1.list_namespace() 386 | for ns in namespaces.items: 387 | 388 | # If namespace specified - skip all others 389 | if args.namespace and ns.metadata.name != args.namespace: 390 | continue 391 | 392 | # If namespace specified - skip all others 393 | if not args.controlplane and ns.metadata.name == "kube-system": 394 | continue 395 | 396 | logging.info(f"\nInspecting namespace {ns.metadata.name}") 397 | globals.findings[ns.metadata.name] = [] 398 | 399 | # First check for resource quotas against DOS 400 | resource_quotas = v1.list_namespaced_resource_quota(namespace=ns.metadata.name) 401 | quota_exists = False 402 | for quota in resource_quotas.items: 403 | if quota.spec.hard: 404 | quota_exists = True 405 | break 406 | # assumption here the lack of any hard quota equals problem 407 | if not quota_exists: 408 | fileFinding(type=globals.Finding.DOS_NO_QUOTA, 409 | severity="LOW", 410 | confidence="MEDIUM", 411 | namespace_name=ns.metadata.name, 412 | description="There is no resource limits on this namespace - if attacker controls resource creation it can cause DoS in other namespaces.") 413 | 414 | # Check the anonymous access to this namespace resources 415 | role_bindings = rbac_v1_api.list_namespaced_role_binding(namespace=ns.metadata.name) 416 | for role_binding in role_bindings.items: 417 | subjects = role_binding.subjects 418 | role_name = role_binding.role_ref.name 419 | if subjects is None: 420 | continue 421 | user_names = [u.name for u in subjects if u.kind == "User"] 422 | group_names = [g.name for g in subjects if g.kind == "Group"] 423 | 424 | if "system:anonymous" not in user_names and "system:unauthenticated" not in group_names: 425 | continue 426 | 427 | # now find out whether this role can do something non-trivial 428 | try: 429 | role = rbac_v1_api.read_namespaced_role(name=role_name, namespace=ns.metadata.name) 430 | except Exception as e: 431 | logging.info(f"Error: {str(e)}") 432 | continue 433 | 434 | role_rules = role.rules 435 | for rule in role_rules: 436 | rule_verbs = None if rule.verbs is None else set(rule.verbs) 437 | rule_resources = None if rule.resources is None else set(rule.resources) 438 | 439 | # check for reading secrets from another namespace 440 | if globals.read_verbs & rule_verbs and globals.secret_resources & rule_resources: 441 | fileFinding(type=globals.Finding.RBAC_ANONYMOUS_ACCESS_TO_RESOURCES, 442 | severity="HIGH", 443 | confidence="HIGH", 444 | namespace_name=ns.metadata.name, 445 | principals=active_principals, 446 | description=f"Anonymous user can read secrets from {ns.metadata.name} namespace.") 447 | 448 | # check for creation of workloads in another namespace 449 | if globals.create_verbs & rule_verbs and globals.workload_resources & rule_resources: 450 | fileFinding(type=globals.Finding.RBAC_ANONYMOUS_ACCESS_TO_RESOURCES, 451 | severity="HIGH", 452 | confidence="HIGH", 453 | principals=active_principals, 454 | namespace_name=ns.metadata.name, 455 | description=f"Anonymous user can create workloads in {ns.metadata.name} namespace.") 456 | 457 | # check for execution into pods from other namespaces 458 | if {"*", "create"} & rule_verbs and {"*", "pods/exec", "pods/attach"} & rule_resources: 459 | fileFinding(type=globals.Finding.RBAC_ANONYMOUS_ACCESS_TO_RESOURCES, 460 | severity="HIGH", 461 | confidence="HIGH", 462 | principals=active_principals, 463 | namespace_name=ns.metadata.name, 464 | description=f"Anonymous user can exec/attach to pods in {ns.metadata.name} namespace.") 465 | 466 | # rest of the operations - less severe 467 | if rule_resources is not None: 468 | fileFinding(type=globals.Finding.RBAC_ANONYMOUS_ACCESS_TO_RESOURCES, 469 | severity="MEDIUM", 470 | confidence="HIGH", 471 | namespace_name=ns.metadata.name, 472 | description=f"Anonymous user can {rule_verbs} {rule_resources} in {ns.metadata.name} namespace.") 473 | 474 | # Roles 475 | # role_bindings = rbac_v1_api.list_namespaced_role_binding(namespace=ns.metadata.name) 476 | namespace_service_accounts = v1.list_namespaced_service_account(namespace=ns.metadata.name) 477 | 478 | # Find the relavant bindings for this namespace 479 | for sa in namespace_service_accounts.items: 480 | for cluster_role_binding in cluster_role_bindings.items: 481 | subjects = cluster_role_binding.subjects 482 | role_name = cluster_role_binding.role_ref.name 483 | if subjects is None: 484 | continue 485 | service_account_names = [sa.name for sa in subjects if sa.kind == "ServiceAccount"] 486 | user_names = [u.name for u in subjects if u.kind == "User"] 487 | group_names = [g.name for g in subjects if g.kind == "Group"] 488 | 489 | # skip the irrelevant clusterrolebindings 490 | if sa.metadata.name not in service_account_names: 491 | continue 492 | 493 | active_principals = [] 494 | if service_account_names: 495 | logging.info(f"\t\tClusterRole: {role_name}") 496 | logging.info(f"\t\tService Accounts: {', '.join(service_account_names)}") 497 | active_principals.extend(service_account_names) 498 | if user_names: 499 | logging.info(f"\t\tClusterRole: {role_name}") 500 | logging.info(f"\t\tUsers: {', '.join(user_names)}") 501 | active_principals.extend(user_names) 502 | if group_names: 503 | logging.info(f"\t\tClusterRole: {role_name}") 504 | logging.info(f"\t\tGroups: {', '.join(group_names)}") 505 | active_principals.extend(group_names) 506 | 507 | # now find out what this role can do 508 | try: 509 | role = rbac_v1_api.read_cluster_role(name=role_name) 510 | except Exception as e: 511 | logging.info(f"Error: {str(e)}") 512 | continue 513 | 514 | role_rules = role.rules 515 | for rule in role_rules: 516 | if rule.resource_names is not None: 517 | logging.info(f"\t\t\tThis role can {rule.verbs} {rule.resources}, but only on {rule.resource_names} ") 518 | elif rule.non_resource_ur_ls is not None: 519 | logging.info(f"\t\t\tThis role can {rule.verbs} {rule.non_resource_ur_ls} URLs") 520 | else: 521 | logging.info(f"\t\t\tThis role can {rule.verbs} {rule.resources}") 522 | 523 | # prepare all the permission sets for analysis 524 | rule_verbs = None if rule.verbs is None else set(rule.verbs) 525 | rule_resources = None if rule.resources is None else set(rule.resources) 526 | rule_non_resource_urls = None if rule.non_resource_ur_ls is None else set(rule.non_resource_ur_ls) 527 | rule_resource_names = None if rule.resource_names is None else set(rule.resource_names) 528 | 529 | # check for the existence of "interesting" URLs, not just /healthz etc. 530 | if rule_non_resource_urls: 531 | if len(rule_non_resource_urls - {"/configz", "/healthz", "/readiness", "/version", "/livez", "/readyz", "/version/"}) > 0: 532 | fileFinding(type=globals.Finding.RBAC_SHARED_URLS, 533 | severity="MEDIUM", 534 | confidence="HIGH", 535 | namespace_name=ns.metadata.name, 536 | principals=active_principals, 537 | description=f"Principals can access {rule_non_resource_urls} - URLs that potentially contain information from other namespaces.") 538 | continue 539 | 540 | # check for reading secrets from another namespace 541 | if globals.read_verbs & rule_verbs and globals.secret_resources & rule_resources: 542 | fileFinding(type=globals.Finding.RBAC_SECRETS_STEALING, 543 | severity="HIGH", 544 | confidence="HIGH", 545 | namespace_name=ns.metadata.name, 546 | principals=active_principals, 547 | description="Principals can read secrets from another namespace.") 548 | 549 | # check for creation of workloads in another namespace 550 | if globals.create_verbs & rule_verbs and globals.workload_resources & rule_resources: 551 | fileFinding(type=globals.Finding.RBAC_WORKLOAD_CREATION, 552 | severity="HIGH", 553 | confidence="HIGH", 554 | principals=active_principals, 555 | namespace_name=ns.metadata.name, 556 | description="Principals can create workloads in another namespace.") 557 | 558 | # check for execution into pods from other namespaces 559 | if {"*", "create"} & rule_verbs and {"*", "pods/exec", "pods/attach"} & rule_resources: 560 | fileFinding(type=globals.Finding.RBAC_POD_EXECUTION, 561 | severity="HIGH", 562 | confidence="HIGH", 563 | principals=active_principals, 564 | namespace_name=ns.metadata.name, 565 | description="Principals can exec/attach to pods in another namespace.") 566 | 567 | # check for execution into pods from other namespaces 568 | if {"create"} & rule_verbs and {"pods/eviction"} & rule_resources: 569 | fileFinding(type=globals.Finding.RBAC_POD_EVICTION, 570 | severity="LOW", 571 | confidence="HIGH", 572 | principals=active_principals, 573 | namespace_name=ns.metadata.name, 574 | description="Principals can evict pods in another namespace.") 575 | 576 | # check for creation / update of configmap in another namespace 577 | if globals.create_verbs & rule_verbs and {"*", "configmaps"} & rule_resources: 578 | # if the rule is constrained on the specific CM names we need to see whether they belong to this NS or another 579 | if rule_resource_names: 580 | for name in rule_resource_names: 581 | try: 582 | v1.read_namespaced_config_map(namespace=ns.metadata.name, name=name) 583 | except Exception: 584 | # this means the configmap is owned by a different namespace 585 | fileFinding(type=globals.Finding.RBAC_CONFIGMAP_SMASHING, 586 | severity="MEDIUM", 587 | confidence="HIGH", 588 | principals=active_principals, 589 | namespace_name=ns.metadata.name, 590 | description=f"Principals can create / update configmap in another namespace, but only on {rule_resource_names}.") 591 | continue 592 | if rule_resource_names & fluentbit_configmaps and cluster_flavor != globals.ClusterFlavor.GKE: 593 | fileFinding(type=globals.Finding.RBAC_LOG_EXFILTRATION, 594 | severity="HIGH", 595 | confidence="HIGH", 596 | principals=active_principals, 597 | namespace_name=ns.metadata.name, 598 | description="Principals can redirect and control fluentbit logs and executions in another namespace.") 599 | else: 600 | fileFinding(type=globals.Finding.RBAC_CONFIGMAP_SMASHING, 601 | severity="MEDIUM", 602 | confidence="HIGH", 603 | principals=active_principals, 604 | namespace_name=ns.metadata.name, 605 | description="Principals can update configmap in another namespace.") 606 | if len(fluentbit_configmaps) > 0 and cluster_flavor != globals.ClusterFlavor.GKE: 607 | fileFinding(type=globals.Finding.RBAC_LOG_EXFILTRATION, 608 | severity="HIGH", 609 | confidence="HIGH", 610 | principals=active_principals, 611 | namespace_name=ns.metadata.name, 612 | description="Principals can redirect and control fluentbit logs and executions in another namespace.") 613 | 614 | # check for creation / update of mutatingwebhookconfigurations as a possible sidecar injection attack 615 | if globals.create_verbs & rule_verbs and {"*", "mutatingwebhookconfigurations"} & rule_resources: 616 | if rule_resource_names: 617 | fileFinding(type=globals.Finding.RBAC_WEBHOOK_MANIPULATION, 618 | severity="HIGH", 619 | confidence="MEDIUM", 620 | principals=active_principals, 621 | namespace_name=ns.metadata.name, 622 | description=f"Principals can update mutatingwebhookconfigurations and potentially inject a sidecar container, but only on {rule_resource_names}.") 623 | else: 624 | fileFinding(type=globals.Finding.RBAC_WEBHOOK_MANIPULATION, 625 | severity="HIGH", 626 | confidence="HIGH", 627 | principals=active_principals, 628 | namespace_name=ns.metadata.name, 629 | description="Principals can update mutatingwebhookconfigurations and potentially inject a sidecar container.") 630 | 631 | # check for creation / update of validatingwebhookconfigurations as a possible sidecar secret exfiltration attack 632 | if globals.create_verbs & rule_verbs and {"*", "validatingwebhookconfigurations"} & rule_resources: 633 | if rule_resource_names: 634 | fileFinding(type=globals.Finding.RBAC_SECRETS_SMASHING, 635 | severity="HIGH", 636 | confidence="MEDIUM", 637 | principals=active_principals, 638 | namespace_name=ns.metadata.name, 639 | description=f"Principals can update validatingwebhookconfigurations and potentially steal secrets, but only on {rule_resource_names}.") 640 | else: 641 | fileFinding(type=globals.Finding.RBAC_SECRETS_SMASHING, 642 | severity="HIGH", 643 | confidence="HIGH", 644 | principals=active_principals, 645 | namespace_name=ns.metadata.name, 646 | description="Principals can update validatingwebhookconfigurations and potentially steal secrets.") 647 | 648 | # check for deletion of workloads 649 | if globals.delete_verbs & rule_verbs and globals.workload_resources & rule_resources: 650 | if rule_resource_names: 651 | fileFinding(type=globals.Finding.RBAC_WORKLOAD_DELETION, 652 | severity="MEDIUM", 653 | confidence="HIGH", 654 | principals=active_principals, 655 | namespace_name=ns.metadata.name, 656 | description=f"Principals can delete workloads in another namespace but only {rule_resource_names}.") 657 | else: 658 | fileFinding(type=globals.Finding.RBAC_WORKLOAD_DELETION, 659 | severity="MEDIUM", 660 | confidence="HIGH", 661 | principals=active_principals, 662 | namespace_name=ns.metadata.name, 663 | description="Principals can delete workloads in another namespace.") 664 | 665 | # ---------------------------------- 666 | # Main pods loop 667 | # ---------------------------------- 668 | pods = v1.list_namespaced_pod(namespace=ns.metadata.name) 669 | for pod in pods.items: 670 | inspectPod(pod, ns) 671 | logging.info("") 672 | 673 | # ---------------------------------- 674 | # Deduplication - its better to go through once than check every time before filing 675 | # ---------------------------------- 676 | for ns in globals.findings.keys(): 677 | new_list = [] 678 | for finding in globals.findings[ns]: 679 | if finding not in new_list: 680 | new_list.append(finding) 681 | globals.findings[ns] = new_list 682 | 683 | if args.output == globals.OutputFormat.table or args.output == globals.OutputFormat.html: 684 | headers = ['Namespace', 'Severity', 'Confidence', 'Principals', 'Container', 'Pod', 'Type', 'Description', 'Neighbours'] 685 | t = prettytable.PrettyTable(headers) 686 | t.title = 'Findings Table' 687 | t.align = 'l' 688 | t.valign = 't' 689 | t.max_width = 50 690 | for ns in globals.findings.keys(): 691 | for finding in globals.findings[ns]: 692 | t.add_row(row=[ns, finding['severity'], finding['confidence'], finding['principals'], finding['container'], finding['pod'], finding['type'].value, finding['description'], finding['neighbours']]) 693 | if args.output == globals.OutputFormat.html: 694 | to_save = t.get_html_string(format=True) 695 | print(to_save) 696 | else: 697 | print(t) 698 | elif args.output == globals.OutputFormat.csv: 699 | w = csv.writer(sys.stdout) 700 | w.writerow(['Namespace', 'Severity', 'Confidence', 'Principals', 'Container', 'Pod', 'Type', 'Description', 'Neighbours']) 701 | for ns in globals.findings.keys(): 702 | for finding in globals.findings[ns]: 703 | w.writerow([ns, finding['severity'], finding['confidence'], finding['principals'], finding['container'], finding['pod'], finding['type'].value, finding['description'], finding['neighbours']]) 704 | else: 705 | print(json.dumps(globals.findings, indent=4, cls=globals.SetEncoder)) 706 | 707 | except Exception as e: 708 | logging.error(f"Error: {str(e)}") 709 | sys.exit(1) 710 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wiz-sec-public/namespacehound/acecb2917288c354cabde837a34ed5521523efc0/requirements.txt --------------------------------------------------------------------------------