f5ff53714f2fd0f02899363f984938eba59ef305
[integration.git] / test / security / check_versions / src / k8s_bin_versions_inspector.py
1 #!/usr/bin/env python3
2
3 #   COPYRIGHT NOTICE STARTS HERE
4 #
5 #   Copyright 2020 Samsung Electronics Co., Ltd.
6 #   Copyright 2023 Deutsche Telekom AG
7 #
8 #   Licensed under the Apache License, Version 2.0 (the "License");
9 #   you may not use this file except in compliance with the License.
10 #   You may obtain a copy of the License at
11 #
12 #       http://www.apache.org/licenses/LICENSE-2.0
13 #
14 #   Unless required by applicable law or agreed to in writing, software
15 #   distributed under the License is distributed on an "AS IS" BASIS,
16 #   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
17 #   See the License for the specific language governing permissions and
18 #   limitations under the License.
19 #
20 #   COPYRIGHT NOTICE ENDS HERE
21
22 """
23 k8s_bin_versions_inspector is a module for verifying versions of CPython and
24 OpenJDK binaries installed in the kubernetes cluster containers.
25 """
26
27 __title__ = "k8s_bin_versions_inspector"
28 __summary__ = (
29     "Module for verifying versions of CPython and OpenJDK binaries installed"
30     " in the kubernetes cluster containers."
31 )
32 __version__ = "0.1.0"
33 __author__ = "kkkk.k@samsung.com"
34 __license__ = "Apache-2.0"
35 __copyright__ = "Copyright 2020 Samsung Electronics Co., Ltd."
36
37 from typing import Iterable, List, Optional, Pattern, Union
38
39 import argparse
40 import dataclasses
41 import itertools
42 import json
43 import pathlib
44 import pprint
45 import re
46 import string
47 import sys
48 import tabulate
49 import yaml
50
51 import cerberus
52 import kubernetes
53
54
55 def parse_argv(argv: Optional[List[str]] = None) -> argparse.Namespace:
56     """Function for parsing command line arguments.
57
58     Args:
59         argv: Unparsed list of command line arguments.
60
61     Returns:
62         Namespace with values from parsed arguments.
63     """
64
65     epilog = (
66         f"Author: {__author__}\n"
67         f"License: {__license__}\n"
68         f"Copyright: {__copyright__}\n"
69     )
70
71     parser = argparse.ArgumentParser(
72         formatter_class=argparse.RawTextHelpFormatter,
73         prog=__title__,
74         description=__summary__,
75         epilog=epilog,
76         add_help=False,
77     )
78
79     parser.add_argument("-c", "--config-file", help="Name of the kube-config file.")
80
81     parser.add_argument(
82         "-s",
83         "--field-selector",
84         default="",
85         help="Kubernetes field selector, to filter out containers objects.",
86     )
87
88     parser.add_argument(
89         "-o",
90         "--output-file",
91         type=pathlib.Path,
92         help="Path to file, where output will be saved.",
93     )
94
95     parser.add_argument(
96         "-f",
97         "--output-format",
98         choices=("tabulate", "pprint", "json"),
99         default="tabulate",
100         help="Format of the output file (tabulate, pprint, json).",
101     )
102
103     parser.add_argument(
104         "-i",
105         "--ignore-empty",
106         action="store_true",
107         help="Ignore containers without any versions.",
108     )
109
110     parser.add_argument(
111         "-a",
112         "--acceptable",
113         type=pathlib.Path,
114         help="Path to YAML file, with list of acceptable software versions.",
115     )
116
117     parser.add_argument(
118         "-n",
119         "--namespace",
120         help="Namespace to use to list pods."
121             "If empty pods are going to be listed from all namespaces"
122     )
123
124     parser.add_argument(
125         "--check-istio-sidecar",
126         action="store_true",
127         help="Add if you want to check istio sidecars also"
128     )
129
130     parser.add_argument(
131         "--istio-sidecar-name",
132         default="istio-proxy",
133         help="Name of istio sidecar to filter out"
134     )
135
136     parser.add_argument(
137         "-d",
138         "--debug",
139         action="store_true",
140         help="Enable debugging mode in the k8s API.",
141     )
142
143     parser.add_argument(
144         "-q",
145         "--quiet",
146         action="store_true",
147         help="Suppress printing text on standard output.",
148     )
149
150     parser.add_argument(
151         "-V",
152         "--version",
153         action="version",
154         version=f"{__title__} {__version__}",
155         help="Display version information and exit.",
156     )
157
158     parser.add_argument(
159         "-h", "--help", action="help", help="Display this help text and exit."
160     )
161
162     args = parser.parse_args(argv)
163
164     return args
165
166
167 @dataclasses.dataclass
168 class ContainerExtra:
169     "Data class, to storage extra informations about container."
170
171     running: bool
172     image: str
173     identifier: str
174
175
176 @dataclasses.dataclass
177 class ContainerVersions:
178     "Data class, to storage software versions from container."
179
180     python: list
181     java: list
182
183
184 @dataclasses.dataclass
185 class ContainerInfo:
186     "Data class, to storage multiple informations about container."
187
188     namespace: str
189     pod: str
190     container: str
191     extra: ContainerExtra
192     versions: ContainerVersions = None
193
194
195 def is_container_running(
196     status: kubernetes.client.models.v1_container_status.V1ContainerStatus,
197 ) -> bool:
198     """Function to determine if k8s cluster container is in running state.
199
200     Args:
201         status: Single item from container_statuses list, that represents container status.
202
203     Returns:
204         If container is in running state.
205     """
206
207     if status.state.terminated:
208         return False
209
210     if status.state.waiting:
211         return False
212
213     if not status.state.running:
214         return False
215
216     return True
217
218
219 def list_all_containers(
220     api: kubernetes.client.api.core_v1_api.CoreV1Api,
221     field_selector: str,
222     namespace: Union[None, str],
223     check_istio_sidecars: bool,
224     istio_sidecar_name: str
225 ) -> Iterable[ContainerInfo]:
226     """Get list of all containers names.
227
228     Args:
229         api: Client of the k8s cluster API.
230         field_selector: Kubernetes field selector, to filter out containers objects.
231         namespace: Namespace to limit reading pods from
232         check_istio_sidecars: Flag to enable/disable istio sidecars check.
233             Default to False
234         istio_sidecar_name: If checking istio sidecars is disabled the name to filter
235             containers out
236
237     Yields:
238         Objects for all containers in k8s cluster.
239     """
240
241     if namespace:
242         pods = api.list_namespaced_pod(namespace, field_selector=field_selector).items
243     else:
244         pods = api.list_pod_for_all_namespaces(field_selector=field_selector).items
245
246     containers_statuses = (
247         (pod.metadata.namespace, pod.metadata.name, pod.status.container_statuses)
248         for pod in pods
249         if pod.status.container_statuses
250     )
251
252     containers_status = (
253         itertools.product([namespace], [pod], statuses)
254         for namespace, pod, statuses in containers_statuses
255     )
256
257     containers_chained = itertools.chain.from_iterable(containers_status)
258
259     containers_fields = (
260         (
261             namespace,
262             pod,
263             status.name,
264             is_container_running(status),
265             status.image,
266             status.container_id,
267         )
268         for namespace, pod, status in containers_chained
269     )
270
271     container_items = (
272         ContainerInfo(
273             namespace, pod, container, ContainerExtra(running, image, identifier)
274         )
275         for namespace, pod, container, running, image, identifier in containers_fields
276     )
277
278     if not check_istio_sidecars:
279         container_items = filter(lambda container: container.container != istio_sidecar_name, container_items)
280
281     yield from container_items
282
283
284 def sync_post_namespaced_pod_exec(
285     api: kubernetes.client.api.core_v1_api.CoreV1Api,
286     container: ContainerInfo,
287     command: Union[List[str], str],
288 ) -> dict:
289     """Function to execute command on selected container.
290
291     Args:
292         api: Client of the k8s cluster API.
293         container: Object, that represents container in k8s cluster.
294         command: Command to execute as a list of arguments or single string.
295
296     Returns:
297         Dictionary that store informations about command execution.
298         * stdout - Standard output captured from execution.
299         * stderr - Standard error captured from execution.
300         * error  - Error object that was received from kubernetes API.
301         * code   - Exit code returned by executed process
302                    or -1 if container is not running
303                    or -2 if other failure occurred.
304     """
305
306     try:
307         client = kubernetes.stream.stream(
308             api.connect_post_namespaced_pod_exec,
309             namespace=container.namespace,
310             name=container.pod,
311             container=container.container,
312             command=command,
313             stderr=True,
314             stdin=False,
315             stdout=True,
316             tty=False,
317             _request_timeout=1.0,
318             _preload_content=False,
319         )
320     except (
321         kubernetes.client.rest.ApiException,
322         kubernetes.client.exceptions.ApiException,
323     ):
324
325         if container.extra.running:
326             raise
327
328         return {
329             "stdout": "",
330             "stderr": "",
331             "error": {},
332             "code": -1,
333         }
334
335     client.run_forever(timeout=5)
336
337     stdout = client.read_stdout()
338     stderr = client.read_stderr()
339     error = yaml.safe_load(
340         client.read_channel(kubernetes.stream.ws_client.ERROR_CHANNEL)
341     )
342
343     # TODO: Is there really no better way, to check
344     # execution exit code in python k8s API client?
345     code = -2
346     try:
347         code = (
348             0
349             if error["status"] == "Success"
350             else -2
351             if error["reason"] != "NonZeroExitCode"
352             else int(error["details"]["causes"][0]["message"])
353         )
354     except:
355         pass
356
357     return {
358         "stdout": stdout,
359         "stderr": stderr,
360         "error": error,
361         "code": code,
362     }
363
364
365 def generate_python_binaries() -> List[str]:
366     """Function to generate list of names and paths for CPython binaries.
367
368     Returns:
369         List of names and paths, to CPython binaries.
370     """
371
372     dirnames = ["", "/usr/bin/", "/usr/local/bin/"]
373
374     majors_minors = [
375         f"{major}.{minor}" for major, minor in itertools.product("23", string.digits)
376     ]
377
378     suffixes = ["", "2", "3"] + majors_minors
379
380     basenames = [f"python{suffix}" for suffix in suffixes]
381
382     binaries = [f"{dir}{base}" for dir, base in itertools.product(dirnames, basenames)]
383
384     return binaries
385
386
387 def generate_java_binaries() -> List[str]:
388     """Function to generate list of names and paths for OpenJDK binaries.
389
390     Returns:
391         List of names and paths, to OpenJDK binaries.
392     """
393
394     binaries = [
395         "java",
396         "/usr/bin/java",
397         "/usr/local/bin/java",
398         "/etc/alternatives/java",
399         "/usr/java/openjdk-14/bin/java",
400     ]
401
402     return binaries
403
404
405 def determine_versions_abstraction(
406     api: kubernetes.client.api.core_v1_api.CoreV1Api,
407     container: ContainerInfo,
408     binaries: List[str],
409     extractor: Pattern,
410 ) -> List[str]:
411     """Function to determine list of software versions, that are installed in
412     given container.
413
414     Args:
415         api: Client of the k8s cluster API.
416         container: Object, that represents container in k8s cluster.
417         binaries: List of names and paths to the abstract software binaries.
418         extractor: Pattern to extract the version string from the output of the binary execution.
419
420     Returns:
421         List of installed software versions.
422     """
423
424     commands = ([binary, "--version"] for binary in binaries)
425     commands_old = ([binary, "-version"] for binary in binaries)
426     commands_all = itertools.chain(commands, commands_old)
427
428     # TODO: This list comprehension should be parallelized
429     results = (
430         sync_post_namespaced_pod_exec(api, container, command)
431         for command in commands_all
432     )
433
434     successes = (
435         f"{result['stdout']}{result['stderr']}"
436         for result in results
437         if result["code"] == 0
438     )
439
440     extractions = (extractor.search(success) for success in successes)
441
442     versions = sorted(
443         set(extraction.group(1) for extraction in extractions if extraction)
444     )
445
446     return versions
447
448
449 def determine_versions_of_python(
450     api: kubernetes.client.api.core_v1_api.CoreV1Api, container: ContainerInfo
451 ) -> List[str]:
452     """Function to determine list of CPython versions,
453     that are installed in given container.
454
455     Args:
456         api: Client of the k8s cluster API.
457         container: Object, that represents container in k8s cluster.
458
459     Returns:
460         List of installed CPython versions.
461     """
462
463     extractor = re.compile("Python ([0-9.]+)")
464
465     binaries = generate_python_binaries()
466
467     versions = determine_versions_abstraction(api, container, binaries, extractor)
468
469     return versions
470
471
472 def determine_versions_of_java(
473     api: kubernetes.client.api.core_v1_api.CoreV1Api, container: ContainerInfo
474 ) -> List[str]:
475     """Function to determine list of OpenJDK versions,
476     that are installed in given container.
477
478     Args:
479         api: Client of the k8s cluster API.
480         container: Object, that represents container in k8s cluster.
481
482     Returns:
483         List of installed OpenJDK versions.
484     """
485
486     extractor = re.compile('openjdk [version" ]*([0-9._]+)')
487
488     binaries = generate_java_binaries()
489
490     versions = determine_versions_abstraction(api, container, binaries, extractor)
491
492     return versions
493
494
495 def gather_containers_informations(
496     api: kubernetes.client.api.core_v1_api.CoreV1Api,
497     field_selector: str,
498     ignore_empty: bool,
499     namespace: Union[None, str],
500     check_istio_sidecars: bool,
501     istio_sidecar_name: str
502 ) -> List[ContainerInfo]:
503     """Get list of all containers names.
504
505     Args:
506         api: Client of the k8s cluster API.
507         field_selector: Kubernetes field selector, to filter out containers objects.
508         ignore_empty: Determines, if containers with empty versions should be ignored.
509         namespace: Namespace to limit reading pods from
510         check_istio_sidecars: Flag to enable/disable istio sidecars check.
511             Default to False
512         istio_sidecar_name: If checking istio sidecars is disabled the name to filter
513             containers out
514
515     Returns:
516         List of initialized objects for containers in k8s cluster.
517     """
518
519     containers = list(list_all_containers(api, field_selector, namespace,
520                                           check_istio_sidecars, istio_sidecar_name))
521
522     # TODO: This loop should be parallelized
523     for container in containers:
524         python_versions = determine_versions_of_python(api, container)
525         java_versions = determine_versions_of_java(api, container)
526         container.versions = ContainerVersions(python_versions, java_versions)
527
528     if ignore_empty:
529         containers = [c for c in containers if c.versions.python or c.versions.java]
530
531     return containers
532
533
534 def generate_output_tabulate(containers: Iterable[ContainerInfo]) -> str:
535     """Function for generate output string in tabulate format.
536
537     Args:
538         containers: List of items, that represents containers in k8s cluster.
539
540      Returns:
541          Output string formatted by tabulate module.
542     """
543
544     headers = [
545         "Namespace",
546         "Pod",
547         "Container",
548         "Running",
549         "CPython",
550         "OpenJDK",
551     ]
552
553     rows = [
554         [
555             container.namespace,
556             container.pod,
557             container.container,
558             container.extra.running,
559             " ".join(container.versions.python),
560             " ".join(container.versions.java),
561         ]
562         for container in containers
563     ]
564
565     output = tabulate.tabulate(rows, headers=headers)
566
567     return output
568
569
570 def generate_output_pprint(containers: Iterable[ContainerInfo]) -> str:
571     """Function for generate output string in pprint format.
572
573     Args:
574         containers: List of items, that represents containers in k8s cluster.
575
576      Returns:
577          Output string formatted by pprint module.
578     """
579
580     output = pprint.pformat(containers)
581
582     return output
583
584
585 def generate_output_json(containers: Iterable[ContainerInfo]) -> str:
586     """Function for generate output string in JSON format.
587
588     Args:
589         containers: List of items, that represents containers in k8s cluster.
590
591      Returns:
592          Output string formatted by json module.
593     """
594
595     data = [
596         {
597             "namespace": container.namespace,
598             "pod": container.pod,
599             "container": container.container,
600             "extra": {
601                 "running": container.extra.running,
602                 "image": container.extra.image,
603                 "identifier": container.extra.identifier,
604             },
605             "versions": {
606                 "python": container.versions.python,
607                 "java": container.versions.java,
608             },
609         }
610         for container in containers
611     ]
612
613     output = json.dumps(data, indent=4)
614
615     return output
616
617
618 def generate_and_handle_output(
619     containers: List[ContainerInfo],
620     output_format: str,
621     output_file: pathlib.Path,
622     quiet: bool,
623 ) -> None:
624     """Generate and handle the output of the containers software versions.
625
626     Args:
627         containers: List of items, that represents containers in k8s cluster.
628         output_format: String that will determine output format (tabulate, pprint, json).
629         output_file: Path to file, where output will be save.
630         quiet: Determines if output should be printed, to stdout.
631     """
632
633     output_generators = {
634         "tabulate": generate_output_tabulate,
635         "pprint": generate_output_pprint,
636         "json": generate_output_json,
637     }
638
639     output = output_generators[output_format](containers)
640
641     if output_file:
642         output_file.write_text(output)
643
644     if not quiet:
645         print(output)
646
647
648 def verify_versions_acceptability(
649     containers: List[ContainerInfo], acceptable: pathlib.Path, quiet: bool
650 ) -> bool:
651     """Function for verification of software versions installed in containers.
652
653     Args:
654         containers: List of items, that represents containers in k8s cluster.
655         acceptable: Path to the YAML file, with the software verification parameters.
656         quiet: Determines if output should be printed, to stdout.
657
658     Returns:
659         0 if the verification was successful or 1 otherwise.
660     """
661
662     if not acceptable:
663         return 0
664
665     if not acceptable.is_file():
666         raise FileNotFoundError(
667             "File with configuration for acceptable does not exists!"
668         )
669
670     schema = {
671         "python": {"type": "list", "schema": {"type": "string"}},
672         "java": {"type": "list", "schema": {"type": "string"}},
673     }
674
675     validator = cerberus.Validator(schema)
676
677     with open(acceptable) as stream:
678         data = yaml.safe_load(stream)
679
680     if not validator.validate(data):
681         raise cerberus.SchemaError(
682             "Schema of file with configuration for acceptable is not valid."
683         )
684
685     python_acceptable = data.get("python", [])
686     java_acceptable = data.get("java", [])
687
688     python_not_acceptable = [
689         (container, "python", version)
690         for container in containers
691         for version in container.versions.python
692         if version not in python_acceptable
693     ]
694
695     java_not_acceptable = [
696         (container, "java", version)
697         for container in containers
698         for version in container.versions.java
699         if version not in java_acceptable
700     ]
701
702     if not python_not_acceptable and not java_not_acceptable:
703         return 0
704
705     if quiet:
706         return 1
707
708     print("List of not acceptable versions")
709     pprint.pprint(python_not_acceptable)
710     pprint.pprint(java_not_acceptable)
711
712     return 1
713
714
715 def main(argv: Optional[List[str]] = None) -> str:
716     """Main entrypoint of the module for verifying versions of CPython and
717     OpenJDK installed in k8s cluster containers.
718
719     Args:
720         argv: List of command line arguments.
721     """
722
723     args = parse_argv(argv)
724
725     kubernetes.config.load_kube_config(args.config_file)
726
727     api = kubernetes.client.CoreV1Api()
728     api.api_client.configuration.debug = args.debug
729
730     containers = gather_containers_informations(
731         api, args.field_selector, args.ignore_empty, args.namespace,
732         args.check_istio_sidecar, args.istio_sidecar_name
733     )
734
735     generate_and_handle_output(
736         containers, args.output_format, args.output_file, args.quiet
737     )
738
739     code = verify_versions_acceptability(containers, args.acceptable, args.quiet)
740
741     return code
742
743
744 if __name__ == "__main__":
745     sys.exit(main())