├── lib ├── version.py ├── helpers │ ├── __init__.py │ ├── banner.py │ ├── logger.py │ └── helpers.py └── platform │ ├── k8s.py │ └── platform.py ├── .gitignore ├── requirements.txt ├── resources └── k8s │ ├── rel_serviceaccount_to_secret.cypher │ ├── query_system_cluster_role_control.yml │ ├── query_aggregate_perms.cypher │ ├── rel_resource_to_owner.cypher │ ├── query_roles_clusterroles_use_psps.cypher │ ├── rel_endpoint_to_target.cypher │ ├── query_entities_use_psps.cypher │ ├── query_read_privileged_sa_secret.cypher │ ├── query_crb_to_role_pods_exec.cypher │ ├── query_entities_exec_pods.cypher │ ├── query_service_accounts_bind.cypher │ ├── rel_aggregate_roles.cypher │ ├── query_bad_pods_4.cypher │ ├── query_use_psps_create_patch_pods.cypher │ ├── query_bad_pods_1.cypher │ ├── rel_serviceAccountName_relationships.cypher │ ├── query_crb_to_role_pods_create.cypher │ ├── node_pods.cypher │ ├── node_generic.cypher │ ├── standard_subresources.json │ ├── rel_role_bindings.cypher │ ├── rel_clusterroles.cypher │ ├── rel_roles.cypher │ └── config.yml ├── Dockerfile ├── tools ├── k8s-enum.sh └── convertYaml2Json.py ├── demo ├── binding.yml ├── security-reader.yml ├── impersonate.yml └── node-proxy.yml ├── konstellation.py ├── CODE_OF_CONDUCT.md ├── README.md └── LICENSE /lib/version.py: -------------------------------------------------------------------------------- 1 | """Konstellation version""" 2 | 3 | __version__ = 1.0 4 | -------------------------------------------------------------------------------- /lib/helpers/__init__.py: -------------------------------------------------------------------------------- 1 | """Helper functions used throughout the application.""" 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | aws/snapshots 2 | venv 3 | results/* 4 | *.pyc 5 | .vscode/* 6 | *-enum/ 7 | *-results/ 8 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Jinja2 2 | kubernetes 3 | logutils 4 | neo4j 5 | pyyaml 6 | tqdm 7 | colorama 8 | termcolor 9 | -------------------------------------------------------------------------------- /resources/k8s/rel_serviceaccount_to_secret.cypher: -------------------------------------------------------------------------------- 1 | MATCH (s:Secret) 2 | WITH s 3 | MATCH (x {uid: s.`metadata.annotations.kubernetes.io/service-account.uid`}) 4 | MERGE (x)-[:SERVICE_ACCOUNT_TOKEN]->(s) 5 | RETURN * -------------------------------------------------------------------------------- /resources/k8s/query_system_cluster_role_control.yml: -------------------------------------------------------------------------------- 1 | MATCH (n)-[:FULL_CONTROL]->(r:ClusterRole) 2 | WHERE r.name =~ "^system:.*$" and not n.name contains "system" and not n.name = "cluster-admin" 3 | RETURN DISTINCT n.name -------------------------------------------------------------------------------- /resources/k8s/query_aggregate_perms.cypher: -------------------------------------------------------------------------------- 1 | MATCH (n)-[r1]->(m) 2 | WHERE r1.aggregated = "true" 3 | AND NOT EXISTS { 4 | MATCH (n)-[r2]->(m) 5 | WHERE type(r1) = type(r2) AND r2.aggregated IS NULL 6 | } 7 | RETURN n, r1, m -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.11 2 | 3 | # Set the working directory to /app 4 | WORKDIR /app 5 | 6 | COPY * /app 7 | 8 | RUN pip install -r /app/requirements.txt 9 | 10 | ENTRYPOINT ["python3", "konstellation.py"] 11 | 12 | -------------------------------------------------------------------------------- /resources/k8s/rel_resource_to_owner.cypher: -------------------------------------------------------------------------------- 1 | MATCH (r) WHERE r.`metadata.ownerReferences` IS NOT NULL 2 | WITH apoc.convert.getJsonProperty(r, 'metadata.ownerReferences') as owners, r 3 | UNWIND owners as owner 4 | WITH * 5 | MATCH (x {uid: owner.uid}) 6 | MERGE (x)-[:OWNER]->(r) 7 | RETURN * -------------------------------------------------------------------------------- /resources/k8s/query_roles_clusterroles_use_psps.cypher: -------------------------------------------------------------------------------- 1 | // get cluster-roles that can use PSPs 2 | MATCH (r:ClusterRole) 3 | WITH apoc.convert.getJsonProperty(r, 'rules') as rules, r 4 | UNWIND rules as rule 5 | MATCH (r) WHERE rule.verbs = ["use"] AND rule.resources = ["podsecuritypolicies"] return r -------------------------------------------------------------------------------- /resources/k8s/rel_endpoint_to_target.cypher: -------------------------------------------------------------------------------- 1 | MATCH (e:Endpoints) 2 | WITH apoc.convert.getJsonProperty(e, 'subsets') as subsets, e 3 | UNWIND subsets as subset 4 | UNWIND subset.addresses as address 5 | MATCH (x) WHERE x.`metadata.uid` = address.targetRef.uid and x.`metadata.uid` IS NOT NULL 6 | MERGE (e)-[:TARGET]->(x) 7 | RETURN * -------------------------------------------------------------------------------- /resources/k8s/query_entities_use_psps.cypher: -------------------------------------------------------------------------------- 1 | // get entities that bound to roles/cluster-roles that use PSPs 2 | MATCH (r where r.kind in ["role","clusterrole"])<-[rb:ROLE_BINDING]-(x) 3 | WITH apoc.convert.getJsonProperty(r, 'rules') as rules,r,x 4 | UNWIND rules as rule 5 | MATCH (r) WHERE rule.verbs = ["use"] AND rule.resources = ["podsecuritypolicies"] return x.name,x.kind,x.namespace -------------------------------------------------------------------------------- /resources/k8s/query_read_privileged_sa_secret.cypher: -------------------------------------------------------------------------------- 1 | MATCH (y)-[z:ROLE_BINDING]->(x)-[a:GET|LIST]->(s:Secret)<-[b:SERVICE_ACCOUNT_TOKEN]-(sa:ServiceAccount)-[r:ROLE_BINDING]->(role:ClusterRole) 2 | WHERE x.kind in ["clusterrole", "role"] and not y.name = sa.name and role.name in ["admin", "cluster-admin"] 3 | RETURN role.name as ClusterRole, sa.name as ServiceAccount, s.name as Secret, collect(distinct y.name) as readers -------------------------------------------------------------------------------- /resources/k8s/query_crb_to_role_pods_exec.cypher: -------------------------------------------------------------------------------- 1 | MATCH (sa:ServiceAccount)-[rb:ROLE_BINDING]->(r) WHERE r.kind in ["clusterrole","role"] 2 | WITH apoc.convert.getJsonProperty(r, 'rules') as rules,r,sa,rb 3 | UNWIND rules as rule 4 | WITH rule,r,sa,rb 5 | WHERE (("*" IN rule.resources OR "pods/exec" IN rule.resources) AND ("*" IN rule.verbs OR "create" in rule.verbs OR "get" in rule.verbs) AND NOT ("kube-system" IN sa.`metadata.namespace`)) 6 | MATCH (r) 7 | RETURN DISTINCT(sa.name), sa.`metadata.namespace`, r.name -------------------------------------------------------------------------------- /resources/k8s/query_entities_exec_pods.cypher: -------------------------------------------------------------------------------- 1 | // get entities (and their roles) that have exec permissions into pods within their namespace/cluster 2 | MATCH (x)-[rb:ROLE_BINDING]->(r where r.kind in ["role","clusterrole"]) with apoc.convert.getJsonProperty(r,'rules') as rules,r,x 3 | unwind rules as rule 4 | match (r) where ("create" in rule.verbs or "*" in rule.verbs) and ("pods/exec" in rule.resources or "*" in rule.resources) 5 | RETURN r.name,r.kind,collect(DISTINCT x.name),r.namespace -------------------------------------------------------------------------------- /tools/k8s-enum.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # This standalone bash script to enumerate kubernetes resources. 4 | # This script requires jq and zip in $PATH. 5 | 6 | OUTPUT="k8s-enum" 7 | CWD=$(pwd) 8 | 9 | mkdir $OUTPUT 10 | cd $OUTPUT 11 | 12 | for i in $(kubectl api-resources -o name); do kubectl get $i -o json > "${i}.json"; done 13 | 14 | # remove the raw secret data 15 | jq '.items | map(del(.data))'< secrets.json > secrets2.json 16 | mv secrets2.json secrets.json 17 | cd $CWD 18 | 19 | zip -r ${OUTPUT}.zip $OUTPUT 20 | -------------------------------------------------------------------------------- /resources/k8s/query_service_accounts_bind.cypher: -------------------------------------------------------------------------------- 1 | // find service accounts that can use the "bind" verb (https://raesene.github.io/blog/2021/01/16/Getting-Into-A-Bind-with-Kubernetes/) 2 | MATCH (sa:ServiceAccount)-[rb:ROLE_BINDING]->(r) WHERE r.kind in ["clusterrole","role"] 3 | WITH apoc.convert.getJsonProperty(r, 'rules') as rules,r,sa,rb 4 | UNWIND rules as rule 5 | WITH rule,r,sa,rb 6 | WHERE ("bind" in rule.verbs) AND NOT ("kube-system" IN sa.`metadata.namespace`) 7 | MATCH (r) 8 | RETURN DISTINCT(sa.name), sa.`metadata.namespace`, r.name, rb.name, rb.kind, rb.`metadata.namespace` -------------------------------------------------------------------------------- /tools/convertYaml2Json.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import json 4 | import yaml 5 | import os 6 | import sys 7 | 8 | d = sys.argv[1] 9 | files = os.listdir(d) 10 | for f in files: 11 | full = os.path.join(d, f) 12 | ext = os.path.splitext(full)[1] 13 | if ext in ['.yml', '.yaml']: 14 | print(f'Converting {full}') 15 | y = yaml.safe_load(open(full, 'r').read()) 16 | j = json.dumps(y, indent=2) 17 | with open(full.replace(ext, '.json'), 'w') as fout: 18 | fout.write(j) 19 | else: 20 | continue 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /lib/helpers/banner.py: -------------------------------------------------------------------------------- 1 | """Create the ASCII art banner""" 2 | 3 | from lib.version import __version__ 4 | 5 | def banner() -> str: 6 | b = f""" 7 | 8 | _ __ _ _ _ _ _ 9 | | | / / | | | | | | | (_) 10 | | |/ / ___ _ __ ___| |_ ___| | | __ _| |_ _ ___ _ __ 11 | | \\ / _ \\| '_ \\/ __| __/ _ \\ | |/ _` | __| |/ _ \\| '_ \\ 12 | | |\\ \\ (_) | | | \\__ \\ || __/ | | (_| | |_| | (_) | | | | 13 | \\_| \\_/\\___/|_| |_|___/\\__\\___|_|_|\\__,_|\\__|_|\\___/|_| |_| 14 | 15 | v{__version__} 16 | """ 17 | return b 18 | -------------------------------------------------------------------------------- /resources/k8s/rel_aggregate_roles.cypher: -------------------------------------------------------------------------------- 1 | match (z) WHERE z.`aggregationRule.clusterRoleSelectors` IS NOT NULL 2 | WITH apoc.convert.getJsonProperty(z, 'aggregationRule.clusterRoleSelectors') as agg, z 3 | UNWIND agg as rule 4 | WITH z, keys(rule.matchLabels) as keys 5 | UNWIND keys as k 6 | CALL apoc.cypher.run('MATCH (x {`metadata.labels.' + k + '`: "true"}) RETURN x', {}) YIELD value 7 | WITH z, value.x as x 8 | MATCH (x)-[r1]-(y) 9 | WITH *, collect(r1) as relationships, collect(y) as nodes 10 | UNWIND relationships as r 11 | CALL apoc.create.relationship(z, type(r), {aggregated: true, aggregationRule: k + ':' +}, endNode(r)) YIELD rel 12 | RETURN rel 13 | -------------------------------------------------------------------------------- /resources/k8s/query_bad_pods_4.cypher: -------------------------------------------------------------------------------- 1 | // Based on https://bishopfox.com/blog/kubernetes-pod-privilege-escalation Bad Pod #4 2 | MATCH (p:Pod) 3 | WITH apoc.convert.getJsonProperty(p, 'spec.volumes') as volumes, p 4 | UNWIND volumes as volume 5 | MATCH (p) WHERE volume.hostPath IS NOT NULL 6 | WITH p, volume 7 | MATCH (p)-[:POD]->(c:Container) 8 | WITH apoc.convert.getJsonProperty(c, 'volumeMounts') as volumeMounts, p, c, volume 9 | UNWIND volumeMounts as volumeMount 10 | MATCH (p)-[:POD]->(c:Container) WHERE volume.name = volumeMount.name 11 | RETURN p.name as pod, p.namespace as namespace, volume.name, volume.hostPath.path, c.name as container, volumeMount -------------------------------------------------------------------------------- /demo/binding.yml: -------------------------------------------------------------------------------- 1 | // Copied from https://raesene.github.io/blog/2021/01/16/Getting-Into-A-Bind-with-Kubernetes/ 2 | 3 | apiVersion: rbac.authorization.k8s.io/v1 4 | kind: ClusterRole 5 | metadata: 6 | name: rbac-binder 7 | rules: 8 | - apiGroups: 9 | - rbac.authorization.k8s.io 10 | resources: 11 | - clusterroles 12 | verbs: 13 | - bind 14 | - apiGroups: 15 | - rbac.authorization.k8s.io 16 | resources: 17 | - clusterrolebindings 18 | verbs: 19 | - create 20 | 21 | --- 22 | apiVersion: rbac.authorization.k8s.io/v1 23 | kind: ClusterRole 24 | metadata: 25 | name: not-rbac-binder 26 | rules: 27 | - apiGroups: 28 | - rbac.authorization.k8s.io 29 | resources: 30 | - clusterrolebindings 31 | verbs: 32 | - create 33 | -------------------------------------------------------------------------------- /konstellation.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """Konstellation main entrypoint.""" 3 | 4 | from lib.helpers.banner import banner 5 | from lib.helpers.helpers import parse_args, Modes 6 | from lib.helpers.logger import get_logger 7 | from lib.platform.k8s import K8s 8 | 9 | def main(config): 10 | """Main method that calls the appropriate platform feature.""" 11 | platform = K8s(config) 12 | 13 | if config.mode == Modes.enum.name: 14 | platform.enum() 15 | elif config.mode == Modes.push.name: 16 | platform.push() 17 | elif config.mode == Modes.query.name: 18 | platform.query() 19 | 20 | if __name__ == '__main__': 21 | print(banner()) 22 | conf = parse_args() 23 | get_logger(conf) 24 | main(conf) 25 | -------------------------------------------------------------------------------- /resources/k8s/query_use_psps_create_patch_pods.cypher: -------------------------------------------------------------------------------- 1 | // get roles/cluster-roles which use PSPs and can create or patch pods 2 | MATCH (sa:ServiceAccount)-[:ROLE_BINDING]->(r) WHERE r.kind in ["role","clusterrole"] 3 | WITH apoc.convert.getJsonProperty(r, 'rules') as rules, r, sa 4 | UNWIND rules as rule 5 | WITH rule, r, sa 6 | WHERE (("*" IN rule.resources OR "pods" IN rule.resources) AND ("*" IN rule.verbs OR "patch" IN rule.verbs OR "create" in rule.verbs)) 7 | MATCH (r) 8 | WITH apoc.convert.getJsonProperty(r, 'rules') as new_rules, r, sa 9 | UNWIND new_rules as new_rule 10 | WITH new_rules, r, sa 11 | WHERE (("podsecuritypolicies" IN new_rule.resources OR "*" IN new_rule.resources) AND ("use" IN new_rule.verbs OR "*" IN new_rule.verbs)) 12 | RETURN sa.name -------------------------------------------------------------------------------- /resources/k8s/query_bad_pods_1.cypher: -------------------------------------------------------------------------------- 1 | // Based on https://bishopfox.com/blog/kubernetes-pod-privilege-escalation Bad Pod #1 2 | MATCH (p:Pod {`spec.hostPID`: "true", `spec.hostIPC`: "true", `spec.hostNetwork`: "true"}) 3 | WITH apoc.convert.getJsonProperty(p, 'spec.volumes') as volumes, p 4 | UNWIND volumes as volume 5 | MATCH (p) WHERE volume.hostPath IS NOT NULL 6 | WITH p, volume 7 | MATCH (p)-[:POD]->(c:Container {`securityContext.privileged`: "true"}) 8 | WITH apoc.convert.getJsonProperty(c, 'volumeMounts') as volumeMounts, p, c, volume 9 | UNWIND volumeMounts as volumeMount 10 | MATCH (p)-[:POD]->(c:Container) WHERE volume.name = volumeMount.name 11 | RETURN p.name, volume.name, volume.hostPath.path, c.name, volumeMount -------------------------------------------------------------------------------- /resources/k8s/rel_serviceAccountName_relationships.cypher: -------------------------------------------------------------------------------- 1 | MATCH (node1) 2 | WITH node1 3 | // ServiceAccounts are namespaced, constrain on match 4 | MATCH (node2:ServiceAccount {`metadata.namespace`: node1.`metadata.namespace`}) 5 | WHERE node1.`spec.serviceAccountName` IS NOT NULL AND node1.`spec.serviceAccountName`= node2.name OR 6 | node1.`spec.template.spec.serviceAccountName` IS NOT NULL AND node1.`spec.template.spec.serviceAccountName`= node2.name OR 7 | node1.`spec.podSpec.serviceAccountName` IS NOT NULL AND node1.`spec.podSpec.serviceAccountName`= node2.name OR 8 | node1.`spec.jobTemplate.spec.template.spec.serviceAccountName` IS NOT NULL AND node1.`spec.jobTemplate.spec.template.spec.serviceAccountName` = node2.name 9 | MERGE (node1)-[r:RUN_AS]->(node2) 10 | RETURN node1.name, node2.name, node1.`metadata.namespace` -------------------------------------------------------------------------------- /demo/security-reader.yml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Namespace 3 | metadata: 4 | creationTimestamp: null 5 | name: mgmt 6 | spec: {} 7 | status: {} 8 | 9 | --- 10 | 11 | apiVersion: rbac.authorization.k8s.io/v1 12 | kind: Role 13 | metadata: 14 | name: security-reader 15 | namespace: mgmt 16 | rules: 17 | - apiGroups: [""] 18 | resources: ["*"] 19 | verbs: ["get"] 20 | 21 | --- 22 | 23 | apiVersion: v1 24 | kind: ServiceAccount 25 | metadata: 26 | creationTimestamp: null 27 | name: security-reader 28 | namespace: mgmt 29 | 30 | --- 31 | 32 | apiVersion: rbac.authorization.k8s.io/v1 33 | kind: RoleBinding 34 | metadata: 35 | creationTimestamp: null 36 | name: security-reader-rb 37 | namespace: mgmt 38 | roleRef: 39 | apiGroup: rbac.authorization.k8s.io 40 | kind: Role 41 | name: security-reader 42 | subjects: 43 | - kind: ServiceAccount 44 | name: security-reader 45 | namespace: mgmt 46 | -------------------------------------------------------------------------------- /demo/impersonate.yml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Namespace 3 | metadata: 4 | name: devops 5 | spec: {} 6 | status: {} 7 | 8 | --- 9 | 10 | apiVersion: v1 11 | kind: ServiceAccount 12 | metadata: 13 | creationTimestamp: null 14 | name: asmith 15 | namespace: devops 16 | 17 | --- 18 | 19 | apiVersion: rbac.authorization.k8s.io/v1 20 | kind: Role 21 | metadata: 22 | name: security-reader-impersonator 23 | namespace: mgmt 24 | rules: 25 | - apiGroups: [""] 26 | resources: ["serviceaccounts"] 27 | verbs: ["impersonate"] 28 | resourceNames: ["security-reader"] 29 | 30 | --- 31 | 32 | apiVersion: rbac.authorization.k8s.io/v1 33 | kind: RoleBinding 34 | metadata: 35 | name: devops-reader 36 | namespace: mgmt 37 | roleRef: 38 | apiGroup: rbac.authorization.k8s.io 39 | kind: Role 40 | name: security-reader-impersonator 41 | subjects: 42 | - kind: ServiceAccount 43 | name: asmith 44 | namespace: devops 45 | -------------------------------------------------------------------------------- /resources/k8s/query_crb_to_role_pods_create.cypher: -------------------------------------------------------------------------------- 1 | // service accounts outside of `kube-system` with clusterrolebindings or rolebindings to roles or clusterroles that allow them full control or create/patch access to pods 2 | MATCH (sa:ServiceAccount)-[rb:ROLE_BINDING]->(r) WHERE r.kind in ["clusterrole","role"] 3 | WITH apoc.convert.getJsonProperty(r, 'rules') as rules, r,sa,rb 4 | UNWIND rules as rule 5 | WITH rule, r,sa,rb 6 | WHERE (("*" IN rule.resources OR "pods" IN rule.resources) AND ("*" IN rule.verbs OR "create" in rule.verbs OR "patch" in rule.verbs) AND NOT ("kube-system" IN sa.`metadata.namespace`)) 7 | MATCH (r) 8 | WITH apoc.convert.getJsonProperty(r, 'rules') as new_rules,r,sa,rb 9 | UNWIND new_rules as new_rule 10 | WITH new_rules,r,sa,rb 11 | WHERE (("podsecuritypolicies" IN new_rule.resources OR "*" IN new_rule.resources) AND ("use" IN new_rule.verbs OR "*" IN new_rule.verbs)) 12 | RETURN DISTINCT(sa.name), sa.`metadata.namespace`, sa.kind, r.name, rb.name, rb.kind, rb.`metadata.namespace` -------------------------------------------------------------------------------- /demo/node-proxy.yml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Namespace 3 | metadata: 4 | creationTimestamp: null 5 | name: devops 6 | spec: {} 7 | status: {} 8 | 9 | --- 10 | 11 | apiVersion: rbac.authorization.k8s.io/v1 12 | kind: ClusterRole 13 | metadata: 14 | name: node-proxy 15 | rules: 16 | - apiGroups: 17 | - "" 18 | resources: 19 | - nodes/proxy 20 | verbs: 21 | - get 22 | - create 23 | 24 | --- 25 | 26 | apiVersion: v1 27 | kind: ServiceAccount 28 | metadata: 29 | name: node-proxy 30 | namespace: mgmt 31 | 32 | --- 33 | 34 | apiVersion: rbac.authorization.k8s.io/v1 35 | kind: ClusterRoleBinding 36 | metadata: 37 | name: node-proxy-rb 38 | roleRef: 39 | apiGroup: rbac.authorization.k8s.io 40 | kind: ClusterRole 41 | name: node-proxy 42 | subjects: 43 | - kind: ServiceAccount 44 | name: node-proxy 45 | namespace: mgmt 46 | 47 | --- 48 | 49 | apiVersion: v1 50 | kind: Secret 51 | type: kubernetes.io/service-account-token 52 | metadata: 53 | name: node-proxy-sat 54 | namespace: mgmt 55 | annotations: 56 | kubernetes.io/service-account.name: "node-proxy" 57 | -------------------------------------------------------------------------------- /resources/k8s/node_pods.cypher: -------------------------------------------------------------------------------- 1 | CALL apoc.load.json('{{ path }}', '{{jsonPath}}') YIELD value as pods 2 | 3 | // create the generic pod resource 4 | WITH pods 5 | CALL apoc.merge.node(['Pod'], {kind: 'pod', name: 'pod', type: 'resource', `spec.group`: apoc.text.split(pods.apiVersion, "/")[0]}) YIELD node 6 | 7 | WITH pods 8 | UNWIND pods as pod 9 | MERGE (p:Pod {name: pod.metadata.name, uid: pod.metadata.uid, namespace: pod.metadata.namespace}) 10 | WITH p ,pod, apoc.map.flatten(pod) as flat 11 | WITH p, pod, flat, keys(flat) as keys 12 | CALL apoc.create.setProperties(p,[k in keys |k], [k in keys | apoc.text.regreplace(apoc.text.regreplace(apoc.convert.toJson(flat[k]), '^"', ''), '"$', '')]) YIELD node as n 13 | WITH * 14 | SET p.kind = 'pod' //gets overwritten in the apoc call above 15 | 16 | WITH pod, p 17 | UNWIND pod.spec as spec 18 | UNWIND spec.containers as container 19 | WITH container, p, pod 20 | CALL apoc.merge.node(['Container'], {name: container.name, id: apoc.create.uuid(), kind: 'container'}, {namespace: pod.metadata.namespace}) YIELD node as c 21 | WITH c ,container, apoc.map.flatten(container) as flat, p 22 | WITH c, container, flat, keys(flat) as keys, p 23 | CALL apoc.create.setProperties(c,[k in keys |k], [k in keys | apoc.text.regreplace(apoc.text.regreplace(apoc.convert.toJson(flat[k]), '^"', ''), '"$', '')]) YIELD node as n 24 | WITH p, n 25 | MERGE (p)-[:POD]->(n) 26 | RETURN * 27 | -------------------------------------------------------------------------------- /lib/helpers/logger.py: -------------------------------------------------------------------------------- 1 | """Centralized logging for Konstellation""" 2 | 3 | import logging 4 | from logging import Handler 5 | from logutils import colorize 6 | from tqdm import tqdm 7 | 8 | 9 | class TqdmLoggingHandler(Handler): 10 | def emit(self, record): 11 | try: 12 | msg = self.format(record) 13 | tqdm.write(msg) 14 | self.flush() 15 | except Exception: 16 | self.handleError(record) 17 | 18 | 19 | class TqdmHandler(colorize.ColorizingStreamHandler): 20 | """A logging handler to cleanly handle logging with the progress bar.""" 21 | def emit(self, record): 22 | try: 23 | message = self.format(record) 24 | stream = self.stream 25 | 26 | if not self.is_tty: 27 | stream.write(message) 28 | else: 29 | self.output_colorized(message) 30 | tqdm.write(getattr(self, 'terminator', '\n')) 31 | self.flush() 32 | except (KeyboardInterrupt, SystemExit) as e: 33 | raise e 34 | except Exception: 35 | self.handleError(record) 36 | 37 | def get_logger(config) -> logging.Logger: 38 | logger = logging.getLogger('konstellation') 39 | if config.debug: 40 | logger.setLevel(logging.DEBUG) 41 | else: 42 | logger.setLevel(logging.INFO) 43 | 44 | handler = TqdmLoggingHandler() 45 | if config.debug: 46 | formatter = logging.Formatter( 47 | '[%(asctime)s][%(module)s][%(funcName)s] %(message)s', 48 | datefmt='%H:%M:%S') 49 | else: 50 | formatter = logging.Formatter( 51 | '[%(asctime)s] %(message)s', datefmt='%H:%M:%S') 52 | 53 | handler.setFormatter(formatter) 54 | logger.addHandler(handler) 55 | 56 | return logger 57 | -------------------------------------------------------------------------------- /resources/k8s/node_generic.cypher: -------------------------------------------------------------------------------- 1 | CALL apoc.load.json('{{ path }}', '{{ jsonPath }}') YIELD value as items 2 | 3 | WITH items, items.kind as kind, items.metadata as m, apoc.text.split(items.apiVersion, "/")[0] as specGroup 4 | // create a resource node that will catch wildcard permissions 5 | CALL apoc.merge.node([kind], {kind: toLower(kind), name: toLower(kind), type: 'resource', `spec.group`: specGroup}) YIELD node 6 | 7 | WITH items, kind, m, specGroup 8 | //build node dynamically 9 | //CALL apoc.merge.node([{{labelField}}], {kind: toLower(kind), name: {{nameField}}, uid: m.uid, `spec.group`: specGroup}) YIELD node as n 10 | CALL apoc.do.case([ 11 | m.uid IS NULL, 12 | 'CALL apoc.merge.node([items.kind], {kind: toLower(kind), name: items.metadata.name, `spec.group`: specGroup}) YIELD node as n RETURN n' 13 | ], 14 | 'CALL apoc.merge.node([items.kind], {kind: toLower(kind), name: items.metadata.name, uid: m.uid, `spec.group`: specGroup}) YIELD node as n RETURN n', 15 | {items: items, specGroup: specGroup, m: m, kind: kind} 16 | ) YIELD value 17 | 18 | //set metadata props 19 | WITH items, value.n as n, m, keys(m) as keys, kind 20 | // kind of ugly, but we're converting the values to json, so they can be converted back to a map later. 21 | // However, it wraps bare values in double quotes, so two apoc.text.regreplace calls exist to remove those 22 | // since backreferences aren't supported. 23 | CALL apoc.create.setProperties(n,[k in keys |k], [k in keys | apoc.text.regreplace(apoc.text.regreplace(apoc.convert.toJson(m[k]), '^"', ''), '"$', '')]) YIELD node as updated 24 | 25 | // flatten and save props 26 | WITH updated, apoc.map.removeKeys(apoc.map.flatten(items), ["metadata"]) as flat, kind 27 | WITH updated, keys(flat) as keys, flat, kind 28 | CALL apoc.create.setProperties(updated,[k in keys |k], [k in keys | apoc.text.regreplace(apoc.text.regreplace(apoc.convert.toJson(flat[k]), '^"', ''), '"$', '')]) YIELD node as n2 29 | WITH n2, kind 30 | CALL apoc.create.setProperty(n2, "kind", toLower(kind)) YIELD node as n3 31 | RETURN n3 32 | -------------------------------------------------------------------------------- /resources/k8s/standard_subresources.json: -------------------------------------------------------------------------------- 1 | {"apiservices": {"status": ["get", "patch", "update"]}, 2 | "certificatesigningrequests": {"approval": ["get", "patch", "update"], 3 | "status": ["get", "patch", "update"]}, 4 | "cronjobs": {"status": ["get", "patch", "update"]}, 5 | "customresourcedefinitions": {"status": ["get", "patch", "update"]}, 6 | "daemonsets": {"status": ["get", "patch", "update"]}, 7 | "deployments": {"scale": ["get", "patch", "update"], 8 | "status": ["get", "patch", "update"]}, 9 | "flowschemas": {"status": ["get", "patch", "update"]}, 10 | "horizontalpodautoscalers": {"status": ["get", "patch", "update"]}, 11 | "ingresses": {"status": ["get", "patch", "update"]}, 12 | "jobs": {"status": ["get", "patch", "update"]}, 13 | "namespaces": {"finalize": ["update"], "status": ["get", "patch", "update"]}, 14 | "networkpolicies": {"status": ["get", "patch", "update"]}, 15 | "nodes": {"proxy": ["create", "delete", "get", "patch", "update"], 16 | "status": ["get", "patch", "update"]}, 17 | "persistentvolumeclaims": {"status": ["get", "patch", "update"]}, 18 | "persistentvolumes": {"status": ["get", "patch", "update"]}, 19 | "poddisruptionbudgets": {"status": ["get", "patch", "update"]}, 20 | "pods": {"attach": ["create", "get"], 21 | "binding": ["create"], 22 | "ephemeralcontainers": ["get", "patch", "update"], 23 | "eviction": ["create"], 24 | "exec": ["create", "get"], 25 | "log": ["get"], 26 | "portforward": ["create", "get"], 27 | "proxy": ["create", "delete", "get", "patch", "update"], 28 | "status": ["get", "patch", "update"]}, 29 | "prioritylevelconfigurations": {"status": ["get", "patch", "update"]}, 30 | "replicasets": {"scale": ["get", "patch", "update"], 31 | "status": ["get", "patch", "update"]}, 32 | "replicationcontrollers": {"scale": ["get", "patch", "update"], 33 | "status": ["get", "patch", "update"]}, 34 | "resourcequotas": {"status": ["get", "patch", "update"]}, 35 | "serviceaccounts": {"token": ["create"]}, 36 | "services": {"proxy": ["create", "delete", "get", "patch", "update"], 37 | "status": ["get", "patch", "update"]}, 38 | "statefulsets": {"scale": ["get", "patch", "update"], 39 | "status": ["get", "patch", "update"]}, 40 | "volumeattachments": {"status": ["get", "patch", "update"]}} -------------------------------------------------------------------------------- /resources/k8s/rel_role_bindings.cypher: -------------------------------------------------------------------------------- 1 | CALL apoc.load.json('{{ path }}', '$.items[*]') YIELD value as rbs 2 | WITH rbs.metadata.name as rbname, rbs.kind as rbkind, rbs.roleRef.name as rname, rbs.roleRef.kind as rkind, rbs.subjects as subjects, rbs.namespace as namespace 3 | UNWIND subjects as s 4 | WITH *, s.kind as kind, apoc.text.split(s.name, ":") as user 5 | 6 | // If the node doesn't exist, create it with a a `defined: false` property 7 | OPTIONAL MATCH (sub {name: s.name, kind: toLower(s.kind), `spec.group`: s.apiGroup}) 8 | WITH * 9 | CALL apoc.do.case([ 10 | sub IS NULL, 11 | 'CALL apoc.merge.node([s.kind], {name: s.name, kind: toLower(s.kind)}, {`spec.group`: s.apiGroup, defined: false}) YIELD node RETURN node' 12 | ], 13 | 'RETURN NULL', 14 | {s:s} 15 | ) YIELD value 16 | 17 | WITH * 18 | CALL apoc.do.case( 19 | [ 20 | // system:serviceaccount:foo:admin-deployer 21 | kind = "User" AND user[3] IS NOT NULL, 22 | 'MATCH (subject:ServiceAccount {name: user[3], namespace: user[2]}) RETURN subject', 23 | 24 | // system:kube-proxy 25 | kind = "User" and size(user) = 2, 26 | 'MATCH (subject:ServiceAccount {name: user[1]}) RETURN subject' 27 | ], 28 | 'MATCH (subject {name: name}) WHERE subject.kind = toLower(kind) return subject', 29 | {kind: toLower(s.kind), name: s.name, user: apoc.text.split(s.name, ":"), apiGroup: s.apiGroup} 30 | ) YIELD value as subject 31 | WITH subject.subject as subject, rkind, rname, rbname, rbkind, namespace 32 | 33 | // get role 34 | WITH * 35 | CALL apoc.merge.node([rkind], {name: rname}) YIELD node as role 36 | 37 | // create role binding relationship between subject and role 38 | WITH * 39 | CALL apoc.do.case( 40 | [ 41 | // roles have a namespace 42 | toLower(role.kind) = "role", 43 | "MERGE (subject)-[r:ROLE_BINDING {name: rbname, kind: rbkind, namespace: role.namespace}]->(role)" 44 | ], 45 | // clusterroles do not have namespace 46 | "MERGE (subject)-[r:ROLE_BINDING {name: rbname, kind: rbkind}]->(role)", 47 | {subject: subject, rbname: rbname, rbkind: rbkind, role: role} 48 | ) YIELD value 49 | //MERGE (subject)-[r:ROLE_BINDING {name: rbname, kind: rbkind, namespace: namespace}]->(role) 50 | 51 | 52 | // link the binding 53 | WITH role, subject, rbname, rbkind 54 | MATCH (binding {name: rbname, kind: rbkind}) 55 | 56 | WITH * 57 | MERGE (role)<-[:ROLE]-(binding)-[:SUBJECT]->(subject) -------------------------------------------------------------------------------- /lib/helpers/helpers.py: -------------------------------------------------------------------------------- 1 | """Global constants and argument parsing.""" 2 | 3 | import argparse 4 | from argparse import Namespace 5 | from enum import Enum 6 | from lib.version import __version__ 7 | import sys 8 | 9 | # Define mode and platform as enums for easy refernce 10 | # Platform names need to match the class names 11 | # TODO: figure out how to tightly couple this relation 12 | Platforms = Enum('Platform', ['k8s']) 13 | Modes = Enum('Mode', ['enum', 'push', 'query']) 14 | 15 | def parse_args() -> Namespace: 16 | ap = argparse.ArgumentParser(description=f'Konstellation v{__version__}') 17 | ap.add_argument('platform', 18 | help='Platform to enumerate', 19 | choices=[member.name for member in Platforms]) 20 | ap.add_argument('mode', 21 | help='Operation mode', 22 | choices=[member.name for member in Modes]) 23 | ap.add_argument('--debug', 24 | '-d', 25 | action='store_true', 26 | help='Debug output') 27 | ap.add_argument('--enum', 28 | '-e', 29 | type=str, 30 | help='Enum platform output directory', 31 | default=None) 32 | ap.add_argument('--verbose', 33 | '-v', 34 | action='store_true', 35 | help='Verbose output') 36 | ap.add_argument('--kubeconfig', 37 | '-k', 38 | type=str, 39 | help='Kubeconfig file', 40 | default=None) 41 | ap.add_argument('--incluster', 42 | '-l', 43 | type=str, 44 | help='Incluster kubernetes', 45 | default=None) 46 | ap.add_argument('--name', 47 | '-n', 48 | type=str, 49 | help='Name of query to run') 50 | ap.add_argument('--print', 51 | '-p', 52 | action='store_true', 53 | help='Print query results to stdout') 54 | ap.add_argument('--results', 55 | '-r', 56 | type=str, 57 | help='Query results directory', 58 | default=None) 59 | ap.add_argument('--relationships', 60 | action='store_true', 61 | help='Only run relationship mappings', 62 | default=False) 63 | ap.add_argument('--relationship-name', 64 | type=str, 65 | help='Run the named relationship name mapping', 66 | default=False) 67 | ap.add_argument('--neo4juri', 68 | type=str, 69 | help='neo4j URI', 70 | default='bolt://localhost:7687') 71 | ap.add_argument('--neo4juser', 72 | type=str, 73 | help='neo4j user', 74 | default='neo4j') 75 | ap.add_argument('--neo4jpass', 76 | type=str, 77 | help='neo4j password', 78 | default='neo4j') 79 | 80 | args = ap.parse_args() 81 | 82 | if args.mode in [Modes.enum.name, Modes.push.name] and args.enum is None: 83 | args.enum = f'{args.platform}-enum' 84 | elif args.mode == Modes.query.name and args.results is None: 85 | args.results = f'{args.platform}-results' 86 | 87 | if (args.relationships or args.relationship_name) and ( 88 | not args.mode == Modes.push.name): 89 | 90 | sys.exit('--relationships can only be used with push') 91 | 92 | return args 93 | -------------------------------------------------------------------------------- /resources/k8s/rel_clusterroles.cypher: -------------------------------------------------------------------------------- 1 | //ClusterRoles 2 | CALL apoc.load.json('{{ path }}', '$.items[*]') YIELD value as items 3 | WITH items, items.kind as kind, items.metadata as m 4 | 5 | // Get the role node 6 | MATCH (role {name: m.name, uid: m.uid}) 7 | WITH role, items, kind, m 8 | 9 | // Iterate over each rule 10 | UNWIND items.rules as rules 11 | // 12 | WITH rules.resources as resources, rules.verbs as verbs, apoc.convert.toList(rules.resourceNames) as resourceNames, m, kind, role, rules.apiGroups as groups 13 | UNWIND resources as resource 14 | UNWIND verbs as verb 15 | UNWIND groups as group 16 | // resource - lowercase, strip the trailing s, and replace the wildcard * with .* to convert it to a regex 17 | // and split the resource by / to get the subresource 18 | WITH *, toLower(apoc.text.replace(apoc.text.replace(apoc.text.replace(resource, '/.*$', ''), '\*', '.*'), 's$', '')) as resource, apoc.text.split(resource, '/')[1] as subresource 19 | // convert group wildcard to regex wildcard 20 | WITH *, apoc.text.replace(group, '\*', '.*') as group 21 | // If a subresource value exists set the relationship name to VERB_SUBRESOURCE 22 | // otherwise set the verb as the relationship name 23 | // In both cases, a wildcard verb is replaced by FULL_CONTROL 24 | CALL apoc.do.when( 25 | subresource IS NOT NULL, 26 | "RETURN toUpper(apoc.text.replace(verb, '\*', 'FULL_CONTROL') + '_' + subresource) as relname", 27 | "RETURN toUpper(apoc.text.replace(verb, '\*', 'FULL_CONTROL')) as relname", 28 | {subresource: subresource, verb: verb} 29 | ) YIELD value as relname 30 | 31 | WITH role, resource, relname.relname as relname, resourceNames, group 32 | CALL apoc.do.case( 33 | [ 34 | // API group and resource names present 35 | // Seems as though the apiGroups key is always present so it produces an empty list, but 36 | // resourceNames is only present when there are values. So an empty list check is requied groups 37 | // and resourceNames has to be compared to null. 38 | // 39 | // resource is always treated as a regex 40 | NOT isEmpty(group) AND NOT group = "" and resourceNames IS NOT NULL, 41 | 'MATCH (res) WHERE res.kind =~ resource and res.name in resourceNames and res.`spec.group` =~ group RETURN res', 42 | 43 | // API group present 44 | NOT isEmpty(group) AND NOT group = "", 45 | 'MATCH (res) WHERE res.kind =~ resource and res.`spec.group` =~ group RETURN res', 46 | 47 | // resourceNames present 48 | resourceNames IS NOT NULL, 49 | 'MATCH (res) WHERE res.kind =~ resource and res.name in resourceNames RETURN res' 50 | ], 51 | 52 | // default 53 | 'MATCH (res) WHERE res.kind =~ resource RETURN res', 54 | {resource: resource, resourceNames: resourceNames, group: group} 55 | ) YIELD value as res 56 | 57 | WITH res, role, relname 58 | UNWIND res as resNode 59 | // merge the relationship between the role and matched resource with the relname 60 | CALL apoc.merge.relationship(role, relname, {}, {}, resNode.res, {}) YIELD rel as rel 61 | RETURN rel -------------------------------------------------------------------------------- /resources/k8s/rel_roles.cypher: -------------------------------------------------------------------------------- 1 | //Roles 2 | CALL apoc.load.json('{{ path }}', '$.items[*]') YIELD value as items 3 | WITH items, items.kind as kind, items.metadata as m, items.metadata.namespace as namespace 4 | 5 | // Get the role node 6 | MATCH (role {name: m.name, uid: m.uid}) 7 | WITH role, items, kind, namespace 8 | 9 | // Iterate over each rule 10 | UNWIND items.rules as rules 11 | // 12 | WITH rules.resources as resources, rules.verbs as verbs, apoc.convert.toList(rules.resourceNames) as resourceNames, namespace, kind, role, rules.apiGroups as groups 13 | UNWIND resources as resource 14 | UNWIND verbs as verb 15 | UNWIND groups as group 16 | // resource - lowercase, strip the trailing s, and replace the wildcard * with .* to convert it to a regex 17 | // and split the resource by / to get the subresource 18 | WITH *, toLower(apoc.text.replace(apoc.text.replace(apoc.text.replace(resource, '/.*$', ''), '\*', '.*'), 's$', '')) as resource, apoc.text.split(resource, '/')[1] as subresource 19 | // convert group wildcard to regex wildcard 20 | WITH *, apoc.text.replace(group, '\*', '.*') as group 21 | // If a subresource value exists set the relationship name to VERB_SUBRESOURCE 22 | // otherwise set the verb as the relationship name 23 | // In both cases, a wildcard verb is replaced by FULL_CONTROL 24 | CALL apoc.do.when( 25 | subresource IS NOT NULL, 26 | "RETURN toUpper(apoc.text.replace(verb, '\*', 'FULL_CONTROL') + '_' + subresource) as relname", 27 | "RETURN toUpper(apoc.text.replace(verb, '\*', 'FULL_CONTROL')) as relname", 28 | {subresource: subresource, verb: verb} 29 | ) YIELD value as relname 30 | 31 | WITH role, resource, relname.relname as relname, resourceNames, group, namespace 32 | // When resource names have been specified, add them to the WHERE clause 33 | // otherwise regex match only on the resource 34 | CALL apoc.do.case( 35 | [ 36 | // API group and resource names present 37 | // Seems as though the apiGroups key is always present and it produces a list with an empty string, but 38 | // resourceNames is only present when there are values. So an empty list and empty string check for the 39 | // first element is requied groups and resourceNames has to be compared to null. 40 | // 41 | // resource is always treated as a regex 42 | NOT isEmpty(group) AND NOT group = "" and resourceNames IS NOT NULL, 43 | 'MATCH (res {namespace: namespace}) WHERE res.kind =~ resource and res.name in resourceNames and res.`spec.group` =~ group RETURN res', 44 | 45 | // API group present 46 | NOT isEmpty(group) AND NOT group = "", 47 | 'MATCH (res {namespace: namespace}) WHERE res.kind =~ resource and res.`spec.group` =~ group RETURN res', 48 | 49 | // resourceNames present 50 | resourceNames IS NOT NULL, 51 | 'MATCH (res {namespace: namespace}) WHERE res.kind =~ resource and res.name in resourceNames RETURN res' 52 | ], 53 | 54 | // default 55 | 'MATCH (res {namespace: namespace}) WHERE res.kind =~ resource RETURN res', 56 | {resource: resource, resourceNames: resourceNames, group: group, namespace: namespace} 57 | ) YIELD value as res 58 | 59 | WITH res, role, relname 60 | UNWIND res as resNode 61 | // merge the relationship between the role and matched resource with the relname 62 | CALL apoc.merge.relationship(role, relname, {}, {}, resNode.res, {}) YIELD rel as rel 63 | RETURN rel -------------------------------------------------------------------------------- /lib/platform/k8s.py: -------------------------------------------------------------------------------- 1 | """Kubernetes Platform implementation""" 2 | 3 | import json 4 | from lib.helpers.helpers import Modes 5 | from lib.platform.platform import Platform 6 | import logging 7 | from tqdm import tqdm 8 | import os 9 | 10 | 11 | 12 | from kubernetes import config as k8s_config 13 | from kubernetes.dynamic import DynamicClient 14 | from kubernetes.dynamic.exceptions import NotFoundError, MethodNotAllowedError, ServiceUnavailableError 15 | from kubernetes.client import api_client 16 | from kubernetes.dynamic.resource import Resource 17 | 18 | 19 | logger = logging.getLogger('konstellation') 20 | 21 | # Using k8s for internal class names to avoid collision with the sdk 22 | class K8s(Platform): 23 | """Kubernetes Platform implementation.""" 24 | def __init__(self, config, **kwargs): 25 | super().__init__(config, **kwargs) 26 | 27 | if config.mode == Modes.enum.name: 28 | # Load kubeconfig 29 | if config.kubeconfig: 30 | k8s_config.load_kube_config(config_file=config.kubeconfig) 31 | elif config.incluster: 32 | k8s_config.load_incluster_config() 33 | else: 34 | k8s_config.load_kube_config() 35 | 36 | # load stanard subresources for comparison when enumerating 37 | subresources_path = os.path.join('resources', 38 | self.name, 39 | 'standard_subresources.json') 40 | 41 | with open(subresources_path, 'r', encoding='ascii') as fin: 42 | self.subresources = json.loads(fin.read()) 43 | 44 | def _remove_secret_data(self, items): 45 | """ Remove secret details """ 46 | 47 | for i in items['items']: 48 | i['data'] = {} 49 | 50 | return items 51 | 52 | def enum(self) -> None: 53 | self._make_dir_if_not_exist(self.config.enum) 54 | 55 | resources_count = 0 56 | resources_retrieved = 0 57 | client = DynamicClient(api_client.ApiClient()) 58 | 59 | for res_list in tqdm(client.resources): # enumerated resource types 60 | for r in res_list: # each type is a list with a single member 61 | 62 | # does not work with isinstance, 63 | # using type() instead 64 | if type(r) == Resource: 65 | logger.debug(r.to_dict()) 66 | resources_count += 1 67 | 68 | if r.group: 69 | rname = f'{r.name}.{r.group}' 70 | else: 71 | rname = f'{r.name}' 72 | 73 | logger.debug(rname) 74 | 75 | try: 76 | items = r.get().to_dict() 77 | except NotFoundError: 78 | logger.error('Failed to fetch %s', rname) 79 | continue 80 | except MethodNotAllowedError: 81 | logger.error('List method not allowed on %s', rname) 82 | continue 83 | except ServiceUnavailableError as e: 84 | logger.error('Error requesting %s, ' \ 85 | 'Service Unavailable, %s', 86 | rname, e) 87 | 88 | # Remove the secret data 89 | if rname == 'secrets': 90 | items = self._remove_secret_data(items) 91 | 92 | filename = os.path.join(self.config.enum, rname + '.json') 93 | with open(filename, 'w', encoding='ascii') as fout: 94 | fout.write(json.dumps(items, indent=2)) 95 | 96 | 97 | if r.subresources: 98 | logger.debug(str(r.subresources)) 99 | for sub in r.subresources.keys(): 100 | if not r.name in self.subresources.keys(): 101 | logger.warning( 102 | 'Non-standard subresource found: %s - %s', 103 | r.name, sub) 104 | else: 105 | for verb in r.subresources[sub].verbs: 106 | if verb not in self.subresources[r.name].get(sub, []): 107 | logger.warning( 108 | 'Non-standard subresource verb found: %s - %s - %s', 109 | r.name, sub, verb) 110 | 111 | 112 | resources_retrieved += 1 113 | 114 | logger.warning('Found %s resource types.', resources_count) 115 | logger.warning('Retrieved %s resource types.', resources_retrieved) 116 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | 2 | # Contributor Covenant Code of Conduct 3 | 4 | ## Our Pledge 5 | 6 | We as members, contributors, and leaders pledge to make participation in our 7 | community a harassment-free experience for everyone, regardless of age, body 8 | size, visible or invisible disability, ethnicity, sex characteristics, gender 9 | identity and expression, level of experience, education, socio-economic status, 10 | nationality, personal appearance, race, caste, color, religion, or sexual identity 11 | and orientation. 12 | 13 | We pledge to act and interact in ways that contribute to an open, welcoming, 14 | diverse, inclusive, and healthy community. 15 | 16 | ## Our Standards 17 | 18 | Examples of behavior that contributes to a positive environment for our 19 | community include: 20 | 21 | * Demonstrating empathy and kindness toward other people 22 | * Being respectful of differing opinions, viewpoints, and experiences 23 | * Giving and gracefully accepting constructive feedback 24 | * Accepting responsibility and apologizing to those affected by our mistakes, 25 | and learning from the experience 26 | * Focusing on what is best not just for us as individuals, but for the 27 | overall community 28 | 29 | Examples of unacceptable behavior include: 30 | 31 | * The use of sexualized language or imagery, and sexual attention or 32 | advances of any kind 33 | * Trolling, insulting or derogatory comments, and personal or political attacks 34 | * Public or private harassment 35 | * Publishing others' private information, such as a physical or email 36 | address, without their explicit permission 37 | * Other conduct which could reasonably be considered inappropriate in a 38 | professional setting 39 | 40 | ## Enforcement Responsibilities 41 | 42 | Community leaders are responsible for clarifying and enforcing our standards of 43 | acceptable behavior and will take appropriate and fair corrective action in 44 | response to any behavior that they deem inappropriate, threatening, offensive, 45 | or harmful. 46 | 47 | Community leaders have the right and responsibility to remove, edit, or reject 48 | comments, commits, code, wiki edits, issues, and other contributions that are 49 | not aligned to this Code of Conduct, and will communicate reasons for moderation 50 | decisions when appropriate. 51 | 52 | ## Scope 53 | 54 | This Code of Conduct applies within all community spaces, and also applies when 55 | an individual is officially representing the community in public spaces. 56 | Examples of representing our community include using an official e-mail address, 57 | posting via an official social media account, or acting as an appointed 58 | representative at an online or offline event. 59 | 60 | ## Enforcement 61 | 62 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 63 | reported to the community leaders responsible for enforcement at 64 | opensource@praetorian.com. 65 | All complaints will be reviewed and investigated promptly and fairly. 66 | 67 | All community leaders are obligated to respect the privacy and security of the 68 | reporter of any incident. 69 | 70 | ## Enforcement Guidelines 71 | 72 | Community leaders will follow these Community Impact Guidelines in determining 73 | the consequences for any action they deem in violation of this Code of Conduct: 74 | 75 | ### 1. Correction 76 | 77 | **Community Impact**: Use of inappropriate language or other behavior deemed 78 | unprofessional or unwelcome in the community. 79 | 80 | **Consequence**: A private, written warning from community leaders, providing 81 | clarity around the nature of the violation and an explanation of why the 82 | behavior was inappropriate. A public apology may be requested. 83 | 84 | ### 2. Warning 85 | 86 | **Community Impact**: A violation through a single incident or series 87 | of actions. 88 | 89 | **Consequence**: A warning with consequences for continued behavior. No 90 | interaction with the people involved, including unsolicited interaction with 91 | those enforcing the Code of Conduct, for a specified period of time. This 92 | includes avoiding interactions in community spaces as well as external channels 93 | like social media. Violating these terms may lead to a temporary or 94 | permanent ban. 95 | 96 | ### 3. Temporary Ban 97 | 98 | **Community Impact**: A serious violation of community standards, including 99 | sustained inappropriate behavior. 100 | 101 | **Consequence**: A temporary ban from any sort of interaction or public 102 | communication with the community for a specified period of time. No public or 103 | private interaction with the people involved, including unsolicited interaction 104 | with those enforcing the Code of Conduct, is allowed during this period. 105 | Violating these terms may lead to a permanent ban. 106 | 107 | ### 4. Permanent Ban 108 | 109 | **Community Impact**: Demonstrating a pattern of violation of community 110 | standards, including sustained inappropriate behavior, harassment of an 111 | individual, or aggression toward or disparagement of classes of individuals. 112 | 113 | **Consequence**: A permanent ban from any sort of public interaction within 114 | the community. 115 | 116 | ## Attribution 117 | 118 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 119 | version 2.1, available at 120 | [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. 121 | 122 | Community Impact Guidelines were inspired by 123 | [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. 124 | 125 | For answers to common questions about this code of conduct, see the FAQ at 126 | [https://www.contributor-covenant.org/faq][FAQ]. Translations are available 127 | at [https://www.contributor-covenant.org/translations][translations]. 128 | 129 | [homepage]: https://www.contributor-covenant.org 130 | [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html 131 | [Mozilla CoC]: https://github.com/mozilla/diversity 132 | [FAQ]: https://www.contributor-covenant.org/faq 133 | [translations]: https://www.contributor-covenant.org/translations 134 | 135 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Konstellation 2 | 3 | Konstellation is a configuration-driven CLI tool to enumerate cloud resources and store the data into neo4j. 4 | 5 | # Installation 6 | 7 | ## Python 8 | Konstellation is a Python3 application and can have its dependencies installed using the following commands. 9 | 10 | ```bash 11 | python3 -m venv venv 12 | source venv/bin/activate 13 | pip install -r requirements.txt 14 | ``` 15 | 16 | ## Neo4j 17 | 18 | Konstellation uses Neo4j as its backend database. [Neo4j Desktop](https://neo4j.com/download/) is the preferred installation method for Neo4j. Installation instructions are [here](https://neo4j.com/docs/desktop-manual/current/installation/download-installation/). 19 | 20 | After installing Neo4j Desktop, create a new project for Konstellation to house the database and configuration settings. When [creating a new database](https://neo4j.com/developer/neo4j-desktop/#desktop-create-DBMS), use a 4.x version that is greater than or equal to 4.4. 21 | 22 | After creating the DBMS, enable the [APOC]() library according to these [instructions](https://neo4j.com/labs/apoc/4.1/installation/#neo4j-desktop). Konstellation uses APOC to enable the direct processing and conversion of JSON to nodes and relationships. 23 | 24 | Using the [DBMS settings](https://neo4j.com/labs/apoc/4.1/installation/#neo4j-desktop), add the following configuration directives to provide Konstellation sufficient privileges. 25 | 26 | ```ini 27 | dbms.security.procedures.allowlist=apoc.convert.getJsonProperty,apoc.convert.toJson,apoc.convert.toList,apoc.create.setProperties,apoc.create.setProperty,apoc.create.uuid,apoc.do.case,apoc.do.when,apoc.load.json,apoc.map.flatten,apoc.map.removeKeys,apoc.merge.node,apoc.merge.relationship,apoc.nodes.get,apoc.text.regreplace,apoc.text.replace,apoc.text.split 28 | apoc.import.file.enabled=true 29 | apoc.import.file.use_neo4j_config=false 30 | 31 | dbms.memory.heap.max_size=16G 32 | ``` 33 | 34 | Note: The `push` operation can require a large amount of memory when processing large datasets. Praetorian has experienced heap sizes of 10G when processing large Kubernetes clusters. Setting an appropriate `dbms.memory.heap.max_size` will keep the import process from crashing. 35 | 36 | Finally, after setting all of the configuration options, restart the DBMS if it is currently running so all setup tasks are loaded into the running DBMS. 37 | 38 | # Usage 39 | 40 | ## Neo4j Authentication 41 | Konstellation requires a Neo4j database to perform the `push` and `query` functions. By default, it will look for the database at `bolt://localhost:7687` with `neo4j` as the username and password. The user may specify alternate configurations with the `--neo4juri`, `--neo4juser`, and `--neo4jpass` options. 42 | 43 | Example: 44 | 45 | ```bash 46 | python3 konstellation.py k8s enum --neo4juri bolt://1.2.3.4:7687 --neo4juser konstellation --neo4jpass konstellation 47 | ``` 48 | 49 | 50 | ## Enumerating resources (`enum`) 51 | 52 | The `enum` command will enumerate the specified platform with the provided credentials. Results are written to `-enum` unless an alternate directory is specified with `--enum`. 53 | 54 | Examples: 55 | 56 | ```bash 57 | python3 konstellation.py k8s enum 58 | ``` 59 | 60 | ``` 61 | python3 konstellation.py k8s enum --enum foo/bar 62 | ``` 63 | 64 | ## Loading data (`push`) 65 | The `push` command loads the enumerated data and stores it in the Neo4j database. 66 | 67 | Examples: 68 | 69 | Loading data with default enum directory (`k8s-enum`). 70 | ```bash 71 | python3 konstellation.py k8s push 72 | ``` 73 | 74 | Pushing with a custom enum directory. 75 | ``` 76 | python3 konstellation.py k8s push --enum foo/bar 77 | ``` 78 | 79 | Re-running all relationship mapping. 80 | ``` 81 | python3 konstellation.py k8s push --relationships 82 | ``` 83 | 84 | Run a single relationship mapping. 85 | ``` 86 | python3 konstellation.py k8s push --relationships --relationship-name "Cluster Role Bindings" 87 | ``` 88 | 89 | 90 | ## Querying data (`query`) 91 | Konstellation's query operation performs the specified queries on the `push`ed data. It writes the `query` results to the `-results` directory unless otherwise specified with the `--results/-r` option. By default, Konstellation runs all queries defined for a platform, but a user may perform single queries using the `--name` option. 92 | 93 | Examples: 94 | 95 | Run all queries for the k8s platform. 96 | ``` 97 | python3 ./konstellation.py k8s query 98 | ``` 99 | 100 | Print results to stdout as well as write to files. 101 | ``` 102 | python3 ./konstellation.py k8s query --print 103 | ``` 104 | 105 | Run a single query. 106 | ``` 107 | python3 ./konstellation.py k8s query --name "Resources that can create or modify role bindings" 108 | ``` 109 | 110 | Write `query` results to a non-default directory. 111 | ``` 112 | python3 ./konstellation.py k8s query --results custom/dir 113 | ``` 114 | 115 | # Schema 116 | The structured output of the source JSON drives the schema. Konstellation parses the raw json files obtained during enumeration and transforms them into nodes and relationships based on the `resources//config.yml`. Using the data to drive the schema allows for rapid development and default handling of new data types. 117 | 118 | ### K8s/Kubernetes 119 | Kubernetes has two notable deviations to the raw enumeration data structure in regards to RoleBindings and subresources. 120 | 121 | RoleBindings are present; however, in addition to the `(role)-[:ROLEREF]->(rolebinding)-[:SUBJECT]->(subject)` mapping, developers implemented more concise representation. A `ROLE_BINDING` relationship between the subject and role (`(subject)-[:ROLE_BINDING]->(role)`) replaces the more verbose node structure. This approach simplifies the graph structure and complexity, and queries. 122 | 123 | Subresources are map as relationships to the parent node where the relationship name is verb + subresource. For example, a role with the `get` verb on `pod/exec` would have the `GET_EXEC` relationship mapped to the appropriate pod: `(role)-[:GET_EXEC]->(p:Pod)` 124 | 125 | #### Meta Resource Nodes 126 | 127 | A special "meta" node exists for each resource type to represent the resource itself and map wildcard permissions. The nodes have the name and kind set as the resource type, and a `type` property set to `resource`. These meta-resource types are useful for finding excessive privileges. An example is below looking for non-`kube-system` namespaced service accounts that can read all secrets. 128 | 129 | ```cypher 130 | MATCH (sa:ServiceAccount)-[:ROLE_BINDING]->(x)-[:GET]->(s:Secret {type: 'resource'}) WHERE NOT sa.namespace = 'kube-system' RETURN * 131 | ``` -------------------------------------------------------------------------------- /lib/platform/platform.py: -------------------------------------------------------------------------------- 1 | """Parent class for all Platforms. 2 | 3 | The Platform class implements push and query, but leaves enum 4 | for the individual platforms to implement. 5 | """ 6 | 7 | from argparse import Namespace 8 | from colorama import init 9 | from jinja2 import Environment, FileSystemLoader 10 | import json 11 | import logging 12 | from neo4j import GraphDatabase, Query 13 | from neo4j.exceptions import AuthError, ClientError, TransientError, ServiceUnavailable 14 | import os 15 | import re 16 | from lib.helpers.helpers import Modes 17 | import sys 18 | from tqdm import tqdm 19 | from termcolor import colored 20 | import yaml 21 | 22 | logger = logging.getLogger('konstellation') 23 | 24 | # init colorama 25 | init() 26 | 27 | class Platform(object): 28 | """Platform base class.""" 29 | def __init__(self, config: Namespace, **kwargs) -> None: 30 | self.config = config 31 | 32 | # assing all kwargs as class members 33 | for key, value in kwargs.items(): 34 | setattr(self, key, value) 35 | 36 | if self.config.mode in [Modes.push.name, Modes.query.name]: 37 | # Load and validate mappings 38 | platform_config_file = os.path.join('resources', 39 | self.name, 'config.yml') 40 | if not os.path.exists(platform_config_file): 41 | raise FileNotFoundError(f'{self.name} ' \ 42 | f'{platform_config_file} ' \ 43 | 'config file not found.') 44 | 45 | logger.debug('Loading %s', platform_config_file) 46 | with open(platform_config_file, 'r', encoding='ascii') as fin: 47 | platform_config = yaml.safe_load(fin.read()) 48 | self.mappings = platform_config['mappings'] 49 | self.order = platform_config.get('order', {}) 50 | self.relationships = platform_config['relationships'] 51 | self.queries = platform_config['queries'] 52 | 53 | if not 'default' in self.mappings: 54 | raise KeyError(f'{platform_config_file} is missing' \ 55 | ' a "default" template mapping entry') 56 | 57 | 58 | # Set up Jinja2 59 | self.templates = Environment( 60 | loader=FileSystemLoader( 61 | os.path.join('resources', self.name))) 62 | 63 | # Create neo4j driver if we're going to perform a push 64 | self.neo4j = GraphDatabase.driver(self.config.neo4juri, 65 | auth=(self.config.neo4juser, 66 | self.config.neo4jpass)) 67 | try: 68 | self.neo4j.verify_connectivity() 69 | except (ServiceUnavailable, ConnectionRefusedError): 70 | logger.error('Neo4j unavailable at %s', self.config.neo4juri) 71 | sys.exit() 72 | except AuthError as e: 73 | logger.error('Neo4j AuthError: %s', e) 74 | sys.exit() 75 | 76 | if self.config.mode == Modes.query.name: 77 | self._make_dir_if_not_exist(self.config.results) 78 | 79 | @property 80 | def name(self) -> str: 81 | return self.__class__.__name__ 82 | 83 | def enum(self) -> None: 84 | raise NotImplementedError(f'Enum for {self.name} has not' \ 85 | f'been implemented yet.') 86 | 87 | def push(self) -> None: 88 | try: 89 | results = os.listdir(self.config.enum) 90 | except FileNotFoundError: 91 | logger.error('%s not found. Run `%s enum` first,' \ 92 | ' or specify an alternative directory with `--enum`', 93 | self.config.enum, self.name) 94 | sys.exit() 95 | 96 | if not self.config.relationships: 97 | logger.warning('Pushing %s data from %s into %s.', 98 | self.name, 99 | os.path.abspath(self.config.enum), 100 | self.config.neo4juri) 101 | ordered = self._order_items(results) 102 | for filename in tqdm(ordered): 103 | # render abs path for result file so neo4j can locate it 104 | path = os.path.join(os.path.abspath(self.config.enum), filename) 105 | template_name = self._get_template_name(filename) 106 | template_file_name = self._get_template_file_name(template_name) 107 | 108 | logger.info('Importing %s using template %s.', 109 | filename, template_name) 110 | if self.mappings[template_name].get('labelField'): 111 | label = self.mappings[template_name].get('labelField') 112 | else: 113 | label = f"'{self.mappings[template_name].get('label')}'" 114 | 115 | data = { 116 | 'path': path, 117 | 'jsonPath': self.mappings[template_name].get('jsonPath', '$'), 118 | 'label': label, 119 | 'labelField': self.mappings[template_name].get('labelField', None), 120 | 'nameField': self.mappings[template_name].get('nameField', '$') 121 | } 122 | 123 | logger.debug('data: %s', data) 124 | cypher = self._render_template(template_file_name, **data) 125 | logger.debug(cypher) 126 | try: 127 | self._neo4j_query(cypher) 128 | except ClientError as e: 129 | logger.error('Error importing %s', filename) 130 | logger.debug(e) 131 | except TransientError as e: 132 | logger.error(e) 133 | 134 | self._apply_relationship_mappings() 135 | 136 | def query(self) -> None: 137 | for q in self.queries: 138 | if q.get('template', None): 139 | query = self._render_template(q.get('template')) 140 | else: 141 | query = q.get('query') 142 | 143 | if self.config.name not in [None ,q.get('name')]: 144 | continue 145 | 146 | logger.warning('Executing query: %s', q.get('name')) 147 | res = self._neo4j_query(query) 148 | 149 | filename = self._make_filename(q.get('name'), 'json') 150 | path = os.path.join(self.config.results, filename) 151 | 152 | data = None 153 | try: 154 | data = json.dumps(res, indent=2) 155 | except TypeError: 156 | # only fields were returned 157 | data = str(res) 158 | 159 | if self.config.print: 160 | for i in res: 161 | tqdm.write(str(i)) 162 | 163 | if len(res) > 0: 164 | logger.critical(colored(f'Found {len(res)} results', 'green')) 165 | logger.warning('Writing %s "%s" results to %s.', 166 | len(res), q.get('name'), path) 167 | with open(os.path.abspath(path), 'w', encoding='ascii') as fout: 168 | fout.write(data) 169 | else: 170 | logger.warning('Found 0 results') 171 | 172 | def _make_filename(self, value: str, extension: str) -> str: 173 | """Convert query description to a reasonable file name.""" 174 | value = value.lower() 175 | value = re.sub(r'[^\w\s-]', '', value) 176 | value = re.sub(r'[-\s]+', '-', value) 177 | return f'{value}.{extension}' 178 | 179 | def _neo4j_query(self, cypher: str) -> list: 180 | """Execute the cypher query.""" 181 | with self.neo4j.session() as session: 182 | logger.debug(cypher) 183 | q = Query('') # workaround for LiteralString error 184 | q.text = cypher 185 | 186 | res = [] 187 | try: 188 | res = session.run(q) 189 | except ClientError as e: 190 | logger.error('Error executing cypher query: %s', e) 191 | return [] 192 | 193 | # Results belong to the session, 194 | # so create a list of values to return 195 | return [ dict(i) for i in res] 196 | 197 | def _get_template_name(self, filename: str) -> str: 198 | if self.mappings.get(filename, None): 199 | template = filename 200 | else: 201 | template = 'default' 202 | 203 | logger.debug('filename: %s, template: %s', filename, template) 204 | return template 205 | 206 | def _get_template_file_name(self, template_name: str) -> str: 207 | template = self.mappings.get(template_name).get('template', 'generic.cypher') 208 | 209 | logger.debug('template_name: %s, template: %s', template_name, template) 210 | return template 211 | 212 | def _make_dir_if_not_exist(self, dir_: str) -> None: 213 | if not os.path.exists(dir_): 214 | os.makedirs(dir_) 215 | 216 | def _render_template(self, template_name: str, **kwargs) -> str: 217 | template = self.templates.get_template(template_name) 218 | rendered = template.render(**kwargs) 219 | return rendered 220 | 221 | def _order_items(self, items: list) -> list: 222 | 223 | if self.order.get('last', None): 224 | items = [item for item in items if item not in self.order['last']] 225 | items = items + self.order['last'] 226 | return items 227 | 228 | def _apply_relationship_mappings(self) -> None: 229 | logger.info('Applying relationships, this may take a while.') 230 | query = '' 231 | relationships = [] 232 | 233 | if self.config.relationship_name: 234 | for r in self.relationships: 235 | if self.config.relationship_name == r['name']: 236 | relationships.append(r) 237 | break 238 | if len(relationships) != 1: 239 | logger.error('Relationship %s not found.', self.config.relationship_name) 240 | sys.exit() 241 | else: 242 | relationships = self.relationships 243 | 244 | for r in tqdm(relationships): 245 | data = {} 246 | if r.get('results_file'): 247 | path = os.path.join(os.path.abspath(self.config.enum), r['results_file']) 248 | data = {'path': path} 249 | 250 | query = self._render_template(r['template'], **data) 251 | logger.warning('Applying %s - %s', r['name'], r.get('description', '')) 252 | self._neo4j_query(query) 253 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /resources/k8s/config.yml: -------------------------------------------------------------------------------- 1 | mappings: 2 | default: 3 | template: node_generic.cypher 4 | jsonPath: $.items[*] 5 | labelField: items.kind 6 | label: null 7 | nameField: items.metadata.name 8 | pods.json: 9 | template: node_pods.cypher 10 | jsonPath: $.items[*] 11 | labelField: null 12 | label: Pod 13 | nameField: items.metadata.name 14 | order: 15 | last: 16 | - namespaces.json 17 | - roles.rbac.authorization.k8s.io.json 18 | - clusterroles.rbac.authorization.k8s.io.json 19 | - rolebindings.rbac.authorization.k8s.io.json 20 | - clusterrolebindings.rbac.authorization.k8s.io.json 21 | relationships: 22 | - name: Node to ServiceAccount 23 | description: Maps nodes with with a serviceAccountName in its spec to the corresponding ServiceAccount node. 24 | template: rel_serviceAccountName_relationships.cypher 25 | - name: Map SERVICE_ACCOUNT_TOKENS 26 | description: Map ServiceAccounts to their corresponding secrets 27 | template: rel_serviceaccount_to_secret.cypher 28 | - name: Cluster Role Bindings 29 | description: Map ClusterRoleBinding subjects to the roleRef 30 | template: rel_role_bindings.cypher 31 | results_file: clusterrolebindings.rbac.authorization.k8s.io.json 32 | - name: Role Bindings 33 | description: Map RoleBinding subjects to the roleRef 34 | template: rel_role_bindings.cypher 35 | results_file: rolebindings.rbac.authorization.k8s.io.json 36 | - name: Cluster Role Privileges 37 | description: Map privileges from ClusterRoles to the corresponding resources 38 | template: rel_clusterroles.cypher 39 | results_file: clusterroles.rbac.authorization.k8s.io.json 40 | - name: Role Privileges 41 | description: Map privileges from Roles to the corresponding resources 42 | template: rel_roles.cypher 43 | results_file: roles.rbac.authorization.k8s.io.json 44 | - name: Resource to Owner 45 | description: Map Resources to their Owners 46 | template: rel_resource_to_owner.cypher 47 | - name: Endpoint to Target 48 | description: Map Endpoints to the targets defined in the subsets property. 49 | template: rel_endpoint_to_target.cypher 50 | 51 | queries: 52 | - name: Inbound relationships to admin or cluster-admin 53 | query: MATCH (x)-[r]->(y:ClusterRole) WHERE y.name = "admin" OR y.name = "cluster-admin" RETURN x.name, collect(TYPE(r)) as verb, y.name 54 | - name: Principals with full contol over nodes 55 | query: "MATCH (x)-[:FULL_CONTROL]->(y:Node) RETURN DISTINCT x.name" 56 | - name: SA can read service account token of SA bound to privileged role 57 | template: query_read_privileged_sa_secret.cypher 58 | - name: Resource can read secret for Service Account 59 | query: "MATCH (sa:ServiceAccount)-[r1:SERVICE_ACCOUNT_TOKEN]->(s:Secret)<-[r2:FULL_CONTROL|GET|LIST]-(x) WHERE x <> sa and x.namespace <> 'kube-system' RETURN sa.name as vulnSA, type(r1) as type, s.name as vulnSaSecret, type(r2) as verb, x.name as serviceaccount" 60 | - name: SA that can read all secrets and is not in the kube-system namespace 61 | query: "MATCH (sa:ServiceAccount)-[r1:ROLE_BINDING]->(x)-[r2:GET|LIST]->(s:Secret {type: 'resource'}) WHERE NOT sa.namespace = 'kube-system' RETURN sa.name, type(r1), x.name, type(r2)" 62 | - name: Roles bound to cluster admin 63 | query: "MATCH (x)-[r1:ROLE_BINDING]->(cr:ClusterRole) WHERE cr.name in ['admin', 'clsuter-admin'] RETURN x.name, type(r1), cr.name" 64 | - name: Pods that can read service account tokens of other roles. 65 | query: MATCH (p:Pod)-[r1:RUN_AS]->(y)-[r2:ROLE_BINDING]->(x)-[r3:GET|LIST|FULL_CONTROL]->(s:Secret)<-[r4:SERVICE_ACCOUNT_TOKEN]-(sa:ServiceAccount)-[r5:ROLE_BINDING]->(role) WHERE x <> role RETURN p.name, type(r1), y.name, type(r2), x.name, type(r3), s.name, type(r4), sa.name, type(r5), role.name 66 | - name: Service accounts that can read all secrets 67 | query: "MATCH (sa:ServiceAccount)-[r1:ROLE_BINDING]->(role WHERE role.kind in ['role', 'clusterrole'])-[r2:GET|LIST]->(s:Secret {type: 'resource'}) RETURN sa.name, role.name" 68 | - name: Non-kube-system service accounts that can read kube-system secrets 69 | query: "MATCH (sa:ServiceAccount)-[r1:ROLE_BINDING]->(role)-[r2:GET|LIST]->(s:Secret {namespace: 'kube-system'}) WHERE NOT sa.namespace = 'kube-system' RETURN sa.name, type(r1), role.name, type(r2), s.name" 70 | - name: Pods that can exec or have full control over all pods 71 | query: "MATCH path = (p:Pod)-[r1:RUN_AS]->(sa:ServiceAccount)-[r2:ROLE_BINDING]->(role)-[r3:CREATE|GET_EXEC|FULL_CONTROL]->(p2:Pod {type: 'resource'}) RETURN collect(p.name) as pods, sa.name, role.name, type(r3)" 72 | - name: Role bindings for the system:unauthenticated group 73 | query: "MATCH (g:Group {name: 'system:unauthenticated'})-[r:ROLE_BINDING]->(role) RETURN g.name, type(r), role.name" 74 | - name: Role bindings for system:anonymous user 75 | query: "MATCH (u:User {name: 'system:anonymous'})-[rb:ROLE_BINDING]->(role) RETURN u.name, type(rb), role.name" 76 | - name: Resources that can create or modify role bindings 77 | query: "MATCH path = (x)-[:ROLE_BINDING]->(role)-[r1:CREATE|FULL_CONTROL|UPDATE]->(rb:RoleBinding {type: 'resource'}) RETURN path" 78 | - name: Privileged containers 79 | query: "MATCH (c:Container) WHERE c.`securityContext.privileged` = 'true' RETURN c.name" 80 | - name: Pods with hostPath mounts 81 | query: "MATCH (pod:Pod) WHERE pod.`spec.volumes` IS NOT NULL AND pod.`spec.volumes` CONTAINS 'hostPath' AND pod.namespace <> 'kube-system' RETURN pod.name, pod.`spec.volumes`" 82 | - name: Pods with allowPrivilegeEscalation as true 83 | query: "MATCH (p)-[:POD]->(c:Container {`securityContext.allowPrivilegeEscalation`: \"true\"}) RETURN DISTINCT p.name" 84 | - name: Pods that run as user id 0 85 | query: "MATCH (p)-[:POD]->(c:Container {`securityContext.runAsUser`: \"0\"}) RETURN DISTINCT p.name" 86 | - name: Get service accounts outside of `kube-system` with rolebindings or clusterrolebindings to roles or clusterroles that allow them list, get or full control access to `kube-system` secrets 87 | query: "MATCH (sa:ServiceAccount)<-[r1:ROLE_BINDING]->(role where role.kind in ['role','clusterrole'])-[r2:LIST|GET|FULL_CONTROL]->(s:Secret {namespace: 'kube-system'}) WHERE NOT sa.namespace = 'kube-system' RETURN DISTINCT(sa.name),r1.name,r1.namespace,r1.kind,sa.`metadata.namespace`" 88 | - name: Get service accounts outside of the `kube-system` namespace with clusterrolebindings or rolebindings to roles or clusterroles that grant the `escalate` verb 89 | query: "MATCH (sa:ServiceAccount)<-[r1:ROLE_BINDING]->(role where role.kind in ['role','clusterrole'])-[r2:`ESCALATE`]->(y) WHERE NOT sa.namespace = 'kube-system' RETURN DISTINCT(sa.name),r1.name,r1.kind,r1.namespace,sa.`metadata.namespace`" 90 | reference: https://raesene.github.io/blog/2020/12/12/Escalating_Away/ 91 | - name: Get service accounts outside of the `kube-system` namespace with clusterrolebindings or rolebindings to roles or clusterroles that grant the `bind` verb 92 | template: query_service_accounts_bind.cypher 93 | reference: https://raesene.github.io/blog/2021/01/16/Getting-Into-A-Bind-with-Kubernetes/ 94 | - name: Get service accounts outside of the `kube-system` namespace with clusterrolebindings or rolebindings to roles or clusterroles that grant the `impersonate` verb 95 | query: "MATCH (sa:ServiceAccount)-[r1:ROLE_BINDING]->(role)-[r2:`IMPERSONATE`]->(y) WHERE NOT sa.`metadata.namespace`='kube-system' RETURN DISTINCT sa.name, sa.`metadata.namespace`, collect(y.name)" 96 | reference: https://blog.lightspin.io/kubernetes-pod-privilege-escalation 97 | - name: Get entities that can exec into privileged pods 98 | query: "MATCH (x)-[r:GET_EXEC|CREATE_EXEC]->(p)-[pod:POD]->(c:Container) WHERE c.`securityContext.privileged`='true' RETURN x.name,collect(p.name)" 99 | - name: Get all groups and the roles or cluster roles that they are bound to 100 | query: "match (r)<-[rb:ROLE_BINDING]-(x:Group) where r.kind in [\"role\",\"clusterrole\"] return DISTINCT x.name,x.namespace,collect(DISTINCT r.name)" 101 | - name: Get pods and the roles for given service account that pods can run as (REPLACE THE SERVICE ACCOUNT NAME BELOW; MEANT FOR COPY/PASTE) 102 | query: "match (p:Pod)-[:RUN_AS]->(sa:ServiceAccount)-[rb:ROLE_BINDING]->(r where r.kind in [\"role\",\"clusterrole\"]) where sa.name = \"\" return DISTINCT sa.name,p.name" 103 | - name: Get rules for a given role/cluster-role (REPLACE THE ROLE NAME BELOW; MEANT FOR COPY/PASTE) 104 | query: "MATCH (r where r.kind in ['role','clusterrole']) with apoc.convert.getJsonProperty(r,'rules') as rules,r unwind rules as rule match (r) where r.name=\"\" return rule" 105 | - name: Get all service accounts bound to a role/cluster-role (REPLACE THE ROLE NAME BELOW; MEANT FOR COPY/PASTE) 106 | query: "MATCH (sa:ServiceAccount)-[rb:ROLE_BINDING]->(role where role.kind in ['role','clusterrole']) where role.name=\"\" return sa.name,sa.namespace,role.name,role.kind" 107 | - name: Get cluster-roles that can use PSPs 108 | template: query_roles_clusterroles_use_psps.cypher 109 | - name: Get entities bound to roles or cluster-roles that can use PSPs 110 | template: query_entities_use_psps.cypher 111 | - name: Roles or Cluster Roles that use PSPs and can create or patch pods 112 | template: query_use_psps_create_patch_pods.cypher 113 | - name: Get entities that can exec into pods within their namespace or in their cluster 114 | template: query_entities_exec_pods.cypher 115 | - name: Get service accounts outside of `kube-system` with clusterrolebindings or rolebindings to roles or clusterroles that allow them full control or create/patch access to pods 116 | template: query_crb_to_role_pods_create.cypher 117 | - name: Get service accounts outside of `kube-system` with clusterrolebindings or rolebindings to roles or clusterroles that allow them pods/exec access 118 | template: query_crb_to_role_pods_exec.cypher 119 | - name: "Bad Pods #1 - privileged, hostPID, hostIPC, hostNetwork, and hostPath" 120 | template: query_bad_pods_1.cypher 121 | description: Pods with privileged security context, hostPID, hostIPC, hostNetwork, and hostPaths 122 | reference: https://bishopfox.com/blog/kubernetes-pod-privilege-escalation 123 | - name: "Bad Pods #2 - privileged and hostPID" 124 | query: "MATCH (p:Pod {`spec.hostPID`: \"true\"})-[r:POD]->(c:Container {`securityContext.privileged`: \"true\"}) RETURN p.name, collect(c.name)" 125 | description: Pods with privileged securty context and hostPID 126 | reference: https://bishopfox.com/blog/kubernetes-pod-privilege-escalation 127 | - name: "Bad Pods #3 - privileged pods only" 128 | query: "MATCH (p:Pod)-[r:POD]->(c:Container {`securityContext.privileged`: \"true\"}) RETURN p.name, collect(c.name)" 129 | description: Pods with privileged securty context 130 | reference: https://bishopfox.com/blog/kubernetes-pod-privilege-escalation 131 | - name: "Bad Pods #4 - hostPath" 132 | template: query_bad_pods_4.cypher 133 | description: Pods with mounted hostPaths 134 | reference: https://bishopfox.com/blog/kubernetes-pod-privilege-escalation 135 | - name: "Bad Pods #4 - hostPath query 2" 136 | query: "MATCH (p:Pod) WITH apoc.convert.getJsonProperty(p, 'spec.volumes') as volumes, p UNWIND volumes as volume MATCH (p) WHERE volume.hostPath IS NOT NULL AND volume.hostPath.path = \"/\" RETURN DISTINCT p.name, p.namespace" 137 | description: Pods with mounted / hostPaths only 138 | reference: https://bishopfox.com/blog/kubernetes-pod-privilege-escalation 139 | - name: "Bad Pods #5 - hostPID" 140 | query: "MATCH (p:Pod {`spec.hostPID`: \"true\"}) RETURN p.name" 141 | description: Pods with hostPID 142 | reference: https://bishopfox.com/blog/kubernetes-pod-privilege-escalation 143 | - name: "Bad Pods #6 - hostNetwork" 144 | query: "MATCH (p:Pod {`spec.hostNetwork`: \"true\"}) RETURN p.name" 145 | description: Pods with hostNetwork 146 | reference: https://bishopfox.com/blog/kubernetes-pod-privilege-escalation 147 | - name: "Bad Pods #7 - hostIPC" 148 | query: "MATCH (p:Pod {`spec.hostIPC`: \"true\"}) RETURN p.name" 149 | description: Pods with hostIPC 150 | reference: https://bishopfox.com/blog/kubernetes-pod-privilege-escalation 151 | - name: Nodes with control over `system:` cluster roles. 152 | template: query_system_cluster_role_control.yml 153 | - name: Nodes with control over cluster-admin 154 | query: "MATCH (n)-[r:FULL_CONTROL]->(cr:ClusterRole {name: 'cluster-admin'}) WHERE not cr.name = 'cluster-admin' RETURN DISTINCT n.name" 155 | - name: Role can create service account tokens 156 | query: match (x)-[b:ROLE_BINDING]->(role)-[r:CREATE_TOKEN]->(s:ServiceAccount) WHERE not role.name in ["system:kube-controller-manager", "system:node"] RETURN x.name, x.kind, role.name, collect(s.name) 157 | - name: non-privileged role proxy pod or node 158 | query: MATCH (x)-[b:ROLE_BINDING]->(role)-[r:CREATE_PROXY|GET_PROXY]->(y) WHERE y.kind in ["pod", "node"] and not role.name in ["admin", "edit", "cluster-admin", "system:aggregate-to-edit"] RETURN x.name, x.kind, role.name, type(r) as verb, collect(y.name) 159 | - name: non-privileged role can exec pod or node 160 | query: MATCH (x)-[b:ROLE_BINDING]->(role)-[r:CREATE_EXEC|GET_EXEC]->(p:Pod) WHERE p.kind = "pod" and not role.name in ["admin", "edit", "cluster-admin", "system:aggregate-to-edit"] RETURN x.name, x.kind, role.name, type(r) as verb, p.kind, collect(p.name) 161 | - name: Role has ESCALATE privilege 162 | query: MATCH (role)-[r:ESCALATE]->(y) WHERE role.name <> "system:controller:clusterrole-aggregation-controller" return r.name, y.name 163 | - name: Role has IMPERSONATE privilege 164 | query: MATCH (x)-[b:ROLE_BINDING]->(role)-[r:`IMPERSONATE`]->(y) WHERE not role.name in ["admin", "edit", "cluster-admin"] return x.name, role.name, y.name 165 | - name: Role can modify secrets 166 | query: MATCH (x)-[b:ROLE_BINDING]->(role)-[r:`CREATE`|`UPDATE`|PATCH|FULL_CONTROL]->(s:Secret) WHERE not role.name in ["admin", "edit", "cluster-admin"] AND s.namespace = "kube-system" return x.name, x.kind, role.name, collect(s.name) 167 | - name: Role can read privileged secrets 168 | query: "MATCH (x)-[b:ROLE_BINDING {kind: 'ClusterRoleBinding'}]->(role)-[r:GET|LIST|FULL_CONTROL]->(s:Secret {namespace: 'kube-system'}) RETURN x.name, x.kind, role.name, collect(s.name)" 169 | - name: Role can modify EKS aws-auth config map 170 | query: "MATCH (x)-[b:ROLE_BINDING]->(role)-[r:`UPDATE`|PATCH|FULL_CONTROL]->(c:ConfigMap {name: \"aws-auth\", namespace: \"kube-system\"}) return x.name, role.name" 171 | - name: Role can modify webhooks 172 | query: "MATCH (x)-[b:ROLE_BINDING]->(role)-[r:`CREATE`|`UPDATE`|PATCH|FULL_CONTROL]->(y) WHERE y.kind in [\"validatingwebhookconfiguration\", \"mutatingwebhookconfiguration\"] RETURN x.name, role.name, y.name" 173 | - name: Role has cross-namespace access 174 | query: "MATCH (x)-[r {kind: \"RoleBinding\"}]->(y) WHERE x.namespace <> y.namespace RETURN x.name, x.namespace, y.name, y.namespace" 175 | - name: Subject bound to privileged role 176 | query: "MATCH (subject)-[r:ROLE_BINDING]->(role:ClusterRole) WHERE role.name IN [\"cluster-admin\", \"admin\", \"edit\"] RETURN subject.name, role.name" --------------------------------------------------------------------------------