diff --git a/.travis.yml b/.travis.yml index 39358c7..1499316 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,16 +2,13 @@ group: travis_latest language: python cache: pip matrix: - allow_failures: - # Python 3 tests are failing due to an import error - - python: 3.6 include: - python: 2.7 #- python: 3.4 #- python: 3.5 - python: 3.6 - #- python: 3.7 - # dist: xenial # required for Python 3.7 (travis-ci/travis-ci#9069) + - python: 3.7 + dist: xenial # required for Python 3.7 (travis-ci/travis-ci#9069) # sudo: required # required for Python 3.7 (travis-ci/travis-ci#9069) install: - pip install -r requirements.txt diff --git a/Dockerfile b/Dockerfile index 8bbc3fc..e0f3ccb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:2.7.15-alpine3.8 +FROM python:3.7.2-alpine3.9 RUN apk add --update \ linux-headers \ diff --git a/README.md b/README.md index 864e303..73f93df 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ ![kube-hunter](https://github.com/aquasecurity/kube-hunter/blob/master/kube-hunter.png) +[![Build Status](https://travis-ci.org/aquasecurity/kube-hunter.svg?branch=master)](https://travis-ci.org/aquasecurity/kube-hunter) + Kube-hunter hunts for security weaknesses in Kubernetes clusters. The tool was developed to increase awareness and visibility for security issues in Kubernetes environments. **You should NOT run kube-hunter on a Kubernetes cluster you don't own!** **Run kube-hunter**: kube-hunter is available as a container (aquasec/kube-hunter), and we also offer a web site at [kube-hunter.aquasec.com](https://kube-hunter.aquasec.com) where you can register online to receive a token allowing you see and share the results online. You can also run the Python code yourself as described below. @@ -72,7 +74,7 @@ You can run the kube-hunter python code directly on your machine. #### Prerequisites You will need the following installed: -* python 2.7 +* python 2.7 or python 3.x * pip Clone the repository: @@ -84,9 +86,8 @@ Install module dependencies: ~~~ cd ./kube-hunter pip install -r requirements.txt - -In the case where you have python 3.x in the path as your default, and python2 refers to a python 2.7 executable, use "python2 -m pip install -r requirements.txt" ~~~ + Run: `./kube-hunter.py` diff --git a/requirements.txt b/requirements.txt index 5cc2a79..20c49a4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,3 +7,4 @@ PrettyTable urllib3 ruamel.yaml requests_mock +future diff --git a/src/__init__.py b/src/__init__.py index d96bb8d..e34aa65 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -1,2 +1,2 @@ -import core -import modules \ No newline at end of file +from . import core +from . import modules diff --git a/src/core/__init__.py b/src/core/__init__.py index d516ffa..48f5bcf 100644 --- a/src/core/__init__.py +++ b/src/core/__init__.py @@ -1,2 +1,2 @@ -import types -import events \ No newline at end of file +from . import types +from . import events diff --git a/src/core/events/__init__.py b/src/core/events/__init__.py index 1dbbdff..5f76db6 100644 --- a/src/core/events/__init__.py +++ b/src/core/events/__init__.py @@ -1,2 +1,2 @@ -from handler import * -import types \ No newline at end of file +from .handler import * +from . import types diff --git a/src/core/events/handler.py b/src/core/events/handler.py index 652a257..0ca0e72 100644 --- a/src/core/events/handler.py +++ b/src/core/events/handler.py @@ -2,7 +2,7 @@ import logging import time from abc import ABCMeta from collections import defaultdict -from Queue import Queue +from queue import Queue from threading import Lock, Thread from __main__ import config diff --git a/src/core/events/types/__init__.py b/src/core/events/types/__init__.py index 7be8215..004dbd3 100644 --- a/src/core/events/types/__init__.py +++ b/src/core/events/types/__init__.py @@ -1,10 +1,10 @@ from os.path import dirname, basename, isfile import glob -from common import * +from .common import * # dynamically importing all modules in folder files = glob.glob(dirname(__file__)+"/*.py") for module_name in (basename(f)[:-3] for f in files if isfile(f) and not f.endswith('__init__.py')): if module_name != "handler": - exec('from {} import *'.format(module_name)) \ No newline at end of file + exec('from .{} import *'.format(module_name)) diff --git a/src/core/events/types/common.py b/src/core/events/types/common.py index ffe60a4..2ec8acf 100644 --- a/src/core/events/types/common.py +++ b/src/core/events/types/common.py @@ -16,6 +16,17 @@ class Event(object): if name in event.__dict__: return event.__dict__[name] + # Event's logical location to be used mainly for reports. + # If event don't implement it check previous event + # This is because events are composed (previous -> previous ...) + # and not inheritted + def location(self): + location = None + if self.previous: + location = self.previous.location() + + return location + # returns the event history ordered from newest to oldest @property def history(self): @@ -85,7 +96,10 @@ class NewHostEvent(Event): def __str__(self): return str(self.host) - + + # Event's logical location to be used mainly for reports. + def location(self): + return str(self.host) class OpenPortEvent(Event): def __init__(self, port): @@ -93,7 +107,14 @@ class OpenPortEvent(Event): def __str__(self): return str(self.port) - + + # Event's logical location to be used mainly for reports. + def location(self): + if self.host: + location = str(self.host) + ":" + str(self.port) + else: + location = str(self.port) + return location class HuntFinished(Event): pass diff --git a/src/core/types.py b/src/core/types.py index 9a404fd..85e16f1 100644 --- a/src/core/types.py +++ b/src/core/types.py @@ -48,5 +48,7 @@ class AccessRisk(object): class PrivilegeEscalation(KubernetesCluster): name = "Privilege Escalation" +class DenialOfService(object): + name = "Denial of Service" -from events import handler # import is in the bottom to break import loops \ No newline at end of file +from .events import handler # import is in the bottom to break import loops diff --git a/src/modules/__init__.py b/src/modules/__init__.py index a35d22b..9586599 100644 --- a/src/modules/__init__.py +++ b/src/modules/__init__.py @@ -1,3 +1,3 @@ -import report -import discovery -import hunting \ No newline at end of file +from . import report +from . import discovery +from . import hunting diff --git a/src/modules/discovery/__init__.py b/src/modules/discovery/__init__.py index e1e1462..3a5ad78 100644 --- a/src/modules/discovery/__init__.py +++ b/src/modules/discovery/__init__.py @@ -4,4 +4,5 @@ import glob # dynamically importing all modules in folder files = glob.glob(dirname(__file__)+"/*.py") for module_name in (basename(f)[:-3] for f in files if isfile(f) and not f.endswith('__init__.py')): - exec('from {} import *'.format(module_name)) \ No newline at end of file + if not module_name.startswith('test_'): + exec('from .{} import *'.format(module_name)) diff --git a/src/modules/discovery/hosts.py b/src/modules/discovery/hosts.py index 21e9f4c..e36298b 100644 --- a/src/modules/discovery/hosts.py +++ b/src/modules/discovery/hosts.py @@ -23,6 +23,14 @@ class RunningAsPodEvent(Event): self.client_cert = self.get_service_account_file("ca.crt") self.namespace = self.get_service_account_file("namespace") + # Event's logical location to be used mainly for reports. + def location(self): + location = "Local to Pod" + if 'HOSTNAME' in os.environ: + location += "(" + os.environ['HOSTNAME'] + ")" + + return location + def get_service_account_file(self, file): try: with open("/var/run/secrets/kubernetes.io/serviceaccount/{file}".format(file=file)) as f: diff --git a/src/modules/hunting/CVE_2018_1002105.py b/src/modules/hunting/CVE_2018_1002105.py deleted file mode 100644 index ae2b4b1..0000000 --- a/src/modules/hunting/CVE_2018_1002105.py +++ /dev/null @@ -1,72 +0,0 @@ -import logging -import json -import requests -import uuid -import ast - -from ...core.events import handler -from ...core.events.types import Vulnerability, Event -from ..discovery.apiserver import ApiServer -from ...core.types import Hunter, ActiveHunter, KubernetesCluster, RemoteCodeExec, AccessRisk, InformationDisclosure, PrivilegeEscalation - -""" Vulnerabilities """ -class ServerApiVersionEndPointAccess(Vulnerability, Event): - """ Node is vulnerable to critical CVE-2018-1002105 """ - - def __init__(self, evidence): - Vulnerability.__init__(self, KubernetesCluster, name="Critical Privilege Escalation CVE", category=PrivilegeEscalation) - self.evidence = evidence - -# Passive Hunter -@handler.subscribe(ApiServer) -class IsVulnerableToCVEAttack(Hunter): - """ Node is running a Kubernetes version vulnerable to critical CVE-2018-1002105 """ - - def __init__(self, event): - self.event = event - self.headers = dict() - self.path = "https://{}:{}".format(self.event.host, self.event.port) - self.service_account_token_evidence = '' - self.api_server_evidence = '' - self.k8sVersion = '' - - def access_api_server_version_end_point(self): - logging.debug(self.event.host) - logging.debug('Passive Hunter is attempting to access the API server /version end point using the pod\'s service account token') - try: - res = requests.get("{path}/version".format(path=self.path), - headers=self.headers, verify=False) - self.api_server_evidence = res.content - resDict = ast.literal_eval(res.content) - version = resDict["gitVersion"].split('.') - first_two_minor_digists = eval(version[1]) - last_two_minor_digists = eval(version[2]) - - if first_two_minor_digists == 10 and last_two_minor_digists < 11: - return True - elif first_two_minor_digists == 11 and last_two_minor_digists < 5: - return True - elif first_two_minor_digists == 12 and last_two_minor_digists < 3: - return True - elif first_two_minor_digists < 10: - return True - except (requests.exceptions.ConnectionError, KeyError): - return False - - def get_service_account_token(self): - logging.debug(self.event.host) - logging.debug('Passive Hunter is attempting to access pod\'s service account token') - try: - with open('/var/run/secrets/kubernetes.io/serviceaccount/token', 'r') as token: - data = token.read() - self.service_account_token_evidence = data - self.headers = {'Authorization': 'Bearer ' + self.service_account_token_evidence} - return True - except IOError: # Couldn't read file - return False - - def execute(self): - self.get_service_account_token() # From within a Pod we may have extra credentials - if self.access_api_server_version_end_point(): - self.publish_event(ServerApiVersionEndPointAccess(self.api_server_evidence)) - diff --git a/src/modules/hunting/__init__.py b/src/modules/hunting/__init__.py index e1e1462..6c5952c 100644 --- a/src/modules/hunting/__init__.py +++ b/src/modules/hunting/__init__.py @@ -4,4 +4,4 @@ import glob # dynamically importing all modules in folder files = glob.glob(dirname(__file__)+"/*.py") for module_name in (basename(f)[:-3] for f in files if isfile(f) and not f.endswith('__init__.py')): - exec('from {} import *'.format(module_name)) \ No newline at end of file + exec('from .{} import *'.format(module_name)) diff --git a/src/modules/hunting/aks.py b/src/modules/hunting/aks.py index 6090295..527a2eb 100644 --- a/src/modules/hunting/aks.py +++ b/src/modules/hunting/aks.py @@ -3,7 +3,7 @@ import logging import requests -from kubelet import ExposedRunHandler +from .kubelet import ExposedRunHandler from ...core.events import handler from ...core.events.types import Event, Vulnerability diff --git a/src/modules/hunting/cvehunter.py b/src/modules/hunting/cvehunter.py new file mode 100644 index 0000000..a3f7c34 --- /dev/null +++ b/src/modules/hunting/cvehunter.py @@ -0,0 +1,114 @@ +import logging +import json +import requests +import uuid +import ast + +from ...core.events import handler +from ...core.events.types import Vulnerability, Event +from ..discovery.apiserver import ApiServer +from ...core.types import Hunter, ActiveHunter, KubernetesCluster, RemoteCodeExec, AccessRisk, InformationDisclosure, \ + PrivilegeEscalation, DenialOfService + +""" Vulnerabilities """ + + +class ServerApiVersionEndPointAccessPE(Vulnerability, Event): + """Node is vulnerable to critical CVE-2018-1002105""" + + def __init__(self, evidence): + Vulnerability.__init__(self, KubernetesCluster, name="Critical Privilege Escalation CVE", category=PrivilegeEscalation) + self.evidence = evidence + + +class ServerApiVersionEndPointAccessDos(Vulnerability, Event): + """Node not patched for CVE-2019-1002100. Depending on your RBAC settings, a crafted json-patch could cause a Denial of Service.""" + + def __init__(self, evidence): + Vulnerability.__init__(self, KubernetesCluster, name="Denial of Service to Kubernetes API Server", category=DenialOfService) + self.evidence = evidence + + +# Passive Hunter +@handler.subscribe(ApiServer) +class IsVulnerableToCVEAttack(Hunter): + """ Node is running a Kubernetes version vulnerable to critical CVE-2018-1002105 """ + + def __init__(self, event): + self.event = event + self.headers = dict() + # From within a Pod we may have extra credentials + if self.event.auth_token: + self.headers = {'Authorization': 'Bearer ' + self.event.auth_token} + self.path = "https://{}:{}".format(self.event.host, self.event.port) + self.api_server_evidence = '' + self.k8sVersion = '' + + def get_api_server_version_end_point(self): + logging.debug(self.event.host) + if 'Authorization' in self.headers: + logging.debug('Passive Hunter is attempting to access the API server version end point using the pod\'s service account token: \t%s', str(self.headers)) + else: + logging.debug('Passive Hunter is attempting to access the API server version end point anonymously') + try: + res = requests.get("{path}/version".format(path=self.path), + headers=self.headers, verify=False) + self.api_server_evidence = res.content + resDict = ast.literal_eval(res.content) + version = resDict["gitVersion"].split('.') + first_two_minor_digits = eval(version[1]) + last_two_minor_digits = eval(version[2]) + logging.debug('Passive Hunter got version from the API server version end point: %d.%d', first_two_minor_digits, last_two_minor_digits) + return [first_two_minor_digits, last_two_minor_digits] + + except (requests.exceptions.ConnectionError, KeyError): + return None + + def check_cve_2018_1002105(self, api_version): + first_two_minor_digists = api_version[0] + last_two_minor_digists = api_version[1] + + if first_two_minor_digists == 10 and last_two_minor_digists < 11: + return True + elif first_two_minor_digists == 11 and last_two_minor_digists < 5: + return True + elif first_two_minor_digists == 12 and last_two_minor_digists < 3: + return True + elif first_two_minor_digists < 10: + return True + + return False + + def check_cve_2019_1002100(self, api_version): + """ + Kubernetes v1.0.x-1.10.x + Kubernetes v1.11.0-1.11.7 (fixed in v1.11.8) + Kubernetes v1.12.0-1.12.5 (fixed in v1.12.6) + Kubernetes v1.13.0-1.13.3 (fixed in v1.13.4) + """ + + first_two_minor_digists = api_version[0] + last_two_minor_digists = api_version[1] + + if first_two_minor_digists == 11 and last_two_minor_digists < 8: + return True + elif first_two_minor_digists == 12 and last_two_minor_digists < 6: + return True + elif first_two_minor_digists == 13 and last_two_minor_digists < 4: + return True + elif first_two_minor_digists < 11: + return True + + return False + + def execute(self): + api_version = self.get_api_server_version_end_point() + + if api_version: + if self.check_cve_2018_1002105(api_version): + self.publish_event(ServerApiVersionEndPointAccessPE(self.api_server_evidence)) + + if self.check_cve_2019_1002100(api_version): + self.publish_event(ServerApiVersionEndPointAccessDos(self.api_server_evidence)) + + diff --git a/src/modules/report/__init__.py b/src/modules/report/__init__.py index e1e1462..6c5952c 100644 --- a/src/modules/report/__init__.py +++ b/src/modules/report/__init__.py @@ -4,4 +4,4 @@ import glob # dynamically importing all modules in folder files = glob.glob(dirname(__file__)+"/*.py") for module_name in (basename(f)[:-3] for f in files if isfile(f) and not f.endswith('__init__.py')): - exec('from {} import *'.format(module_name)) \ No newline at end of file + exec('from .{} import *'.format(module_name)) diff --git a/src/modules/report/base.py b/src/modules/report/base.py index b8986a0..786cf26 100644 --- a/src/modules/report/base.py +++ b/src/modules/report/base.py @@ -1,4 +1,4 @@ -from collector import services, vulnerabilities, services_lock, vulnerabilities_lock +from .collector import services, vulnerabilities, services_lock, vulnerabilities_lock class BaseReporter(object): def get_nodes(self): @@ -24,7 +24,7 @@ class BaseReporter(object): def get_vulnerabilities(self): vulnerabilities_lock.acquire() - vulnerabilities_data = [{"location": "{}:{}".format(vuln.host, vuln.port) if vuln.host else "", + vulnerabilities_data = [{"location": vuln.location(), "category": vuln.category.name, "vulnerability": vuln.get_name(), "description": vuln.explain(), diff --git a/src/modules/report/json_reporter.py b/src/modules/report/json_reporter.py index 356d7c9..0917188 100644 --- a/src/modules/report/json_reporter.py +++ b/src/modules/report/json_reporter.py @@ -1,5 +1,5 @@ import json -from base import BaseReporter +from .base import BaseReporter class JSONReporter(BaseReporter): def get_report(self): diff --git a/src/modules/report/plain.py b/src/modules/report/plain.py index 4171f33..70b88eb 100644 --- a/src/modules/report/plain.py +++ b/src/modules/report/plain.py @@ -3,7 +3,7 @@ from __future__ import print_function from prettytable import ALL, PrettyTable from __main__ import config -from collector import services, vulnerabilities, services_lock, vulnerabilities_lock +from .collector import services, vulnerabilities, services_lock, vulnerabilities_lock EVIDENCE_PREVIEW = 40 MAX_TABLE_WIDTH = 20 @@ -82,7 +82,7 @@ class PlainReporter(object): vulnerabilities_lock.acquire() for vuln in vulnerabilities: - row = ["{}:{}".format(vuln.host, vuln.port) if vuln.host else "", vuln.category.name, vuln.get_name(), vuln.explain()] + row = [vuln.location(), vuln.category.name, vuln.get_name(), vuln.explain()] evidence = str(vuln.evidence)[:EVIDENCE_PREVIEW] + "..." if len(str(vuln.evidence)) > EVIDENCE_PREVIEW else str(vuln.evidence) row.append(evidence) vuln_table.add_row(row) diff --git a/src/modules/report/yaml.py b/src/modules/report/yaml.py index 2060fed..07abd94 100644 --- a/src/modules/report/yaml.py +++ b/src/modules/report/yaml.py @@ -1,7 +1,6 @@ -import StringIO - +from io import StringIO from ruamel.yaml import YAML -from base import BaseReporter +from .base import BaseReporter class YAMLReporter(BaseReporter): def get_report(self): @@ -13,4 +12,4 @@ class YAMLReporter(BaseReporter): } output = StringIO.StringIO() yaml.dump(report, output) - return output.getvalue() \ No newline at end of file + return output.getvalue() diff --git a/tests/discovery/test_hosts.py b/tests/discovery/test_hosts.py index 57b3ad4..d136ef9 100644 --- a/tests/discovery/test_hosts.py +++ b/tests/discovery/test_hosts.py @@ -1,6 +1,6 @@ import requests_mock import time -from Queue import Empty +from queue import Empty from src.modules.discovery.hosts import FromPodHostDiscovery, RunningAsPodEvent, HostScanEvent, AzureMetadataApi from src.core.events.types import Event, NewHostEvent @@ -59,4 +59,4 @@ class testHostDiscoveryEvent(object): @handler.subscribe(AzureMetadataApi) class testAzureMetadataApi(object): def __init__(self, event): - assert config.azure \ No newline at end of file + assert config.azure