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