From 928be2a4cef724ed4e08b5891b783740980c504f Mon Sep 17 00:00:00 2001 From: Hannes Baum Date: Tue, 20 Jun 2023 15:53:29 +0200 Subject: [PATCH] Add test for k8s version recency (#288) * add initial test script * check k8s release for releases * check cluster with kubeconfig for version * check version against each other * if not matching, check for cadence time Signed-off-by: Hannes Baum --- .../scs-0210-v1-k8s-new-version-policy.md | 4 +- Tests/kaas/.gitkeep | 0 .../k8s-version-recency/config.yaml.template | 24 ++ .../k8s-version-recency-check.py | 369 ++++++++++++++++++ Tests/requirements.txt | 3 +- 5 files changed, 398 insertions(+), 2 deletions(-) delete mode 100644 Tests/kaas/.gitkeep create mode 100644 Tests/kaas/k8s-version-recency/config.yaml.template create mode 100644 Tests/kaas/k8s-version-recency/k8s-version-recency-check.py diff --git a/Standards/scs-0210-v1-k8s-new-version-policy.md b/Standards/scs-0210-v1-k8s-new-version-policy.md index bfb1d0cc5..4fb9b9b47 100644 --- a/Standards/scs-0210-v1-k8s-new-version-policy.md +++ b/Standards/scs-0210-v1-k8s-new-version-policy.md @@ -49,4 +49,6 @@ All important documents regarding versioning, releases, etc. for the official Ku ## Conformance Tests -TBD +The conformance test is written in the 'k8s-version-recency-check.py' script. The script requires the path to a valid +kubeconfig file, which should describe the k8s cluster under test. This can either be done by creating a config from +the also provided 'config.yaml.template' or by calling the test script with its cli arguments. diff --git a/Tests/kaas/.gitkeep b/Tests/kaas/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/Tests/kaas/k8s-version-recency/config.yaml.template b/Tests/kaas/k8s-version-recency/config.yaml.template new file mode 100644 index 000000000..e472b7ec7 --- /dev/null +++ b/Tests/kaas/k8s-version-recency/config.yaml.template @@ -0,0 +1,24 @@ +## Configuration file for the K8s Version Recency Test + +log: + level: INFO + version: 1 + disable_existing_loggers: False + formatters: + k8s-version-recency-check: + format: "%(levelname)s: %(message)s" + handlers: + console: + class: logging.StreamHandler + formatter: k8s-version-recency-check + stream: ext://sys.stdout + file: + class: logging.handlers.WatchedFileHandler + formatter: k8s-version-recency-check + filename: MY-LOG-FILE-NAME.log + root: # Configuring the default (root) logger is highly recommended + handlers: [console] + loggers: + k8s-version-recency-check: + handlers: [console, file] + propagate: no \ No newline at end of file diff --git a/Tests/kaas/k8s-version-recency/k8s-version-recency-check.py b/Tests/kaas/k8s-version-recency/k8s-version-recency-check.py new file mode 100644 index 000000000..af6da762f --- /dev/null +++ b/Tests/kaas/k8s-version-recency/k8s-version-recency-check.py @@ -0,0 +1,369 @@ +#!/usr/bin/env python3 +# vim: set ts=4 sw=4 et: +# +""" +K8s Version Recency Checker +https://github.com/SovereignCloudStack/standards + +Return codes: +0: Version is inside the recency window +1: Error during script execution +2: Version isn't inside the recency windows anymore +3: Version used contains a critical CVE + +One or more K8s clusters are checked by providing their kubeconfigs. +It is determined, if the version on these clusters is still inside +the recency window, which is determined by the Standard to be 4 months +for minor versions and 1 week for patch versions. An exception are +versions with critical CVEs, which should be replaced on a shorter notice. + +(c) Hannes Baum , 6/2023 +License: CC-BY-SA 4.0 +""" + +import aiohttp +import asyncio +import datetime +import getopt +import kubernetes_asyncio +import logging +import logging.config +import math +import re +import requests +import sys +import yaml + + +MAJOR_VERSION_CADENCE = None +MINOR_VERSION_CADENCE = 4 # months +PATCH_VERSION_CADENCE = 1 # week +CVE_VERSION_CADENCE = 3 # days +CVE_SEVERITY = 8 # CRITICAL + + +logger = logging.getLogger("k8s-version-recency-check") + + +class ConfigException(BaseException): + """Exception raised in a configuration error occurs + """ + + +class HelpException(BaseException): + """Exception raised if the help functionality is called + """ + + +class Config: + kubeconfig = None + + log = None + + +def print_usage(): + print(""" +K8s Version Recency Compliance Check + +Usage: k8s-version-recency-check.py [-h] [-t|--token GITHUB_TOKEN] [-k|--kubeconfig PATH/TO/KUBECONFIG] + +The K8s version recency check returns 0 if the version of the tested cluster is still acceptable, otherwise +it returns 1 for an out-of-date minor version, 2 for an out-of-date patch level version or 3 if the currently +used version should be updated due to a highly critical CVE. + + -t/--token GITHUB_TOKEN - Github token used to check the Github API for the latest K8s releases + -k/--kubeconfig PATH/TO/KUBECONFIG - Path to the kubeconfig of the server we want to check + -h - Output help + """) + return + + +def parse_arguments(argv): + """Parse cli arguments from the script call + """ + config = Config() + config_path = "./config.yaml" + + try: + opts, args = getopt.gnu_getopt(argv, "t:k:h", ["token", "kubeconfig", "help"]) + except getopt.GetoptError: + print_usage() + raise ConfigException + + for opt in opts: + if opt[0] == "-h" or opt[0] == "--help": + print_usage() + raise HelpException + if opt[0] == "-c" or opt[0] == "--config": + config_path = opt[1] + if opt[0] == "-k" or opt[0] == "--kubeconfig": + config.kubeconfig = opt[1] + + return config, config_path + + +def setup_logging(config_log): + + logging.config.dictConfig(config_log) + loggers = [ + logging.getLogger(name) + for name in logging.root.manager.loggerDict + if not logging.getLogger(name).level + ] + + for log in loggers: + log.setLevel(config_log['level']) + + +def initialize_config(config, config_path): + """Initialize the configuration for the test script + """ + + config_file = None + try: + with open(config_path, "r") as fd: + config_file = yaml.safe_load(fd) + except OSError: + pass + + if config_file: + # Setup logging if the config file with the relevant information could be loaded before + setup_logging(config_file['log']) + else: + # Otherwise, we initialize basic logging + logging.basicConfig(format='%(levelname)s: %(message)s', level=logging.INFO) + + if not config.kubeconfig: + print("A kubeconfig needs to be set in order to test a k8s cluster version.") + raise ConfigException + + return config + + +class K8sVersionInfo: + """Class that contains a k8s version info. + + Attributes: + major (int): Major version of the k8s version + minor (int): Minor version of the k8s version + patch (int): Patch version of the k8s version + date (datetime): release date of the k8s version + """ + def __init__(self, major=0, minor=0, patch=0): + self.major = major + self.minor = minor + self.patch = patch + + self.date = None + + def __eq__(self, other): + if not isinstance(other, K8sVersionInfo): + return False + return self.major == other.major and self.minor == other.minor and self.patch == other.patch + + @classmethod + def extract_version(cls, string, separator="."): + components = string.strip().split(separator) + return cls(int(components[0]), int(components[1]), int(components[2])) + + def check_for_version(self, major=None, minor=None, patch=None): + return (major is None or self.major == major) and \ + (minor is None or self.minor == minor) and \ + (patch is None or self.patch == patch) + + def __str__(self): + return f"{self.major}.{self.minor}.{self.patch}" + + +def diff_months(date1, date2): + return abs((date1.year - date2.year) * 12 + date1.month - date2.month) + + +def diff_weeks(date1, date2): + day1 = (date1 - datetime.timedelta(days=date1.weekday())) + day2 = (date2 - datetime.timedelta(days=date2.weekday())) + diff = day2 - day1 + return abs((diff.days / 7) + math.ceil(diff.seconds / 86400)) + + +def diff_days(date1, date2): + delta = date1 - date2 + return abs(delta.days) + + +async def request_cve_data(session: aiohttp.ClientSession, cveid: str) -> dict: + """Request for a single CVE data item. + """ + resp = await session.request('GET', f"https://cveawg.mitre.org/api/cve/{cveid}", + headers={"Accept": "application/json"}) + return await resp.json() + + +async def collect_cve_versions(): + """Get all relevant CVE versions, that are relevant for the test according to the severity + dictated by the Standard. + """ + + # CVE fix versions + cfvs = list() + + # Request latest version + cve_list = requests.get("https://kubernetes.io/docs/reference/issues-security/official-cve-feed/index.json", + headers={"Accept": "application/json"}).json() + + async with aiohttp.ClientSession() as session: + tasks = [] + for cve in cve_list['items']: + cveid = cve['external_url'].split("=")[-1] + tasks.append(request_cve_data(session=session, cveid=cveid)) + # asyncio.gather() will wait on the entire task set to be + # completed. If you want to process results greedily as they come in, + # loop over asyncio.as_completed() + cve_data_list = await asyncio.gather(*tasks, return_exceptions=True) + + for cve_data in cve_data_list: + try: + cve_cna = cve_data['containers']['cna'] + cve_metrics = cve_cna['metrics'] + # This data is extracted like this due to the location of the relevant information in the schema. + # mitre.org uses CVE schema 5.0, which is described in the link below + # https://github.com/CVEProject/cve-schema/tree/master/schema/v5.0 + # The containers -> cna path contains vulnerability information like severity, which is documented + # under the metrics list. + except KeyError as e: + logger.debug(f"They key {e} couldn't be found in the CVE json data for CVE {cveid}.") + continue + for i, cve_metric in enumerate(cve_metrics): + try: + cve_affected_versions = cve_cna['affected'][i]['versions'] + except KeyError as e: + logger.debug(f"They key {e} couldn't be found in the CVE json data for CVE {cveid}.") + continue + + for key in cve_metric: + if re.search(r'[cC][vV][sS]{1,2}V\d', key) and \ + cve_cna['affected'][i]['product'] == "Kubernetes" and \ + cve_metric[key]['baseScore'] >= CVE_SEVERITY: + + for v in cve_affected_versions: + vi = K8sVersionInfo() + if 'lessThanOrEqual' in v: + vi = vi.extract_version(v['lessThanOrEqual'].strip("v")) + vi.patch += 1 + elif 'lessThan' in v: + vi = vi.extract_version(v['lessThan'].strip("v")) + if vi not in cfvs: + cfvs.append(vi) + break + + return cfvs + + +async def get_k8s_cluster_version(kubeconfig): + """Get the k8s version of the cluster under test. + """ + cluster_config = await kubernetes_asyncio.config.load_kube_config(kubeconfig) + + async with kubernetes_asyncio.client.ApiClient() as api: + version_api = kubernetes_asyncio.client.VersionApi(api) + ret = await version_api.get_code() + + version = K8sVersionInfo.extract_version(ret.git_version.strip("v")) + version.date = datetime.datetime.strptime(ret.build_date, '%Y-%m-%dT%H:%M:%SZ') + + return version, cluster_config.current_context['name'] + + +def is_current_version(version, k8s_version_list, cve_version_list=[]): + """Filter the versions depending on the usage times set by the Standard and + the times set for CVE versions. + """ + try: + if diff_months(version.date, datetime.datetime.now()) >= MINOR_VERSION_CADENCE: + return False + else: + for kv in k8s_version_list: + if version.check_for_version(major=kv.major, minor=kv.minor) and \ + version.patch < kv.patch: + + if diff_weeks(datetime.datetime.now(), kv.date) >= PATCH_VERSION_CADENCE: + return False + + if kv in cve_version_list and \ + diff_days(datetime.datetime.now(), kv.date) >= CVE_VERSION_CADENCE: + return False + except (KeyError, IndexError, TypeError) as e: + logger.debug(f"An error occurred during version filtering: {e}") + return False + else: + return True + + +def collect_accepted_k8s_versions(cve_version_list=[]): + """Collect a list of k8s versions that comply to the cadence time set by the standard + """ + + k8s_versions = [] + + github_headers = { + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28" + } + + # Request latest version + resp = requests.get("https://api.github.com/repos/kubernetes/kubernetes/releases/latest", + headers=github_headers).json() + + # Latest version + lv = K8sVersionInfo.extract_version(resp['tag_name'].strip("v")) + lv.date = datetime.datetime.strptime(resp['published_at'], '%Y-%m-%dT%H:%M:%SZ') + + # Request the latest 100 version (the next are not needed, since these versions are too old) + response = requests.get("https://api.github.com/repos/kubernetes/kubernetes/releases?per_page=100", + headers=github_headers).json() + + # Iterate all versions until the first patch of the previous latest version + for r in response: + v = K8sVersionInfo.extract_version(r['tag_name'].split("-")[0].strip("v")) + v.date = datetime.datetime.strptime(r['published_at'], '%Y-%m-%dT%H:%M:%SZ') + + if not r['draft'] and not r['prerelease']: + k8s_versions.append(v) + + # Stop adding new version if the version we added is the previous latest minor versions first patch, + # since it is most of the time around 3-6 months until a new version comes out or an old one goes into EOL + if v.check_for_version(lv.major, (lv.minor-1), 0): + break + + return [v for v in k8s_versions if is_current_version(v, k8s_versions, cve_version_list)] + + +async def main(argv): + try: + config, config_path = parse_arguments(argv) + config = initialize_config(config, config_path) + except (OSError, ConfigException, HelpException): + return 1 + + cve_versions = await collect_cve_versions() + k8s_versions = collect_accepted_k8s_versions(cve_versions) + cluster_version, cluster_name = await get_k8s_cluster_version(config.kubeconfig) + + for k8sv in k8s_versions: + if k8sv == cluster_version: + logger.info("The K8s cluster version %s of cluster '%s' is still in the recency time window." % + (str(cluster_version), cluster_name)) + return 0 + if cluster_version in cve_versions: + logger.error("The K8s cluster version %s of cluster '%s' is an outdated version with a CRITICAL CVE." % + (str(cluster_version), cluster_name)) + return 3 + else: + logger.error("The K8s cluster version %s of cluster '%s' is outdated according to the Standard." % + (str(cluster_version), cluster_name)) + return 2 + + +if __name__ == "__main__": + return_code = asyncio.run(main(sys.argv[1:])) + sys.exit(return_code) diff --git a/Tests/requirements.txt b/Tests/requirements.txt index d20582fea..3325441e4 100644 --- a/Tests/requirements.txt +++ b/Tests/requirements.txt @@ -1,3 +1,4 @@ +fabric +kubernetes_asyncio PyYAML==6.0 openstacksdk==0.103.0 -fabric