Skip to content

Commit aadeb7f

Browse files
authored
Add management command to analyze Kubernetes cluster (#1950)
Signed-off-by: tdruez <tdruez@aboutcode.org>
1 parent a894888 commit aadeb7f

File tree

8 files changed

+485
-41
lines changed

8 files changed

+485
-41
lines changed

docs/command-line-interface.rst

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ ScanPipe's own commands are listed under the ``[scanpipe]`` section::
5656
[scanpipe]
5757
add-input
5858
add-pipeline
59+
add-webhook
60+
analyze-kubernetes
5961
archive-project
6062
batch-create
6163
check-compliance
@@ -391,6 +393,119 @@ Example usage:
391393
$ scanpipe add-webhook my_project https://example.com/webhook --inactive
392394

393395

396+
.. _cli_analyze_kubernetes:
397+
398+
`$ scanpipe analyze-kubernetes <name>`
399+
--------------------------------------
400+
401+
Analyzes all Docker images from a Kubernetes cluster by extracting image references
402+
using ``kubectl`` and creating projects to scan them.
403+
404+
This command connects to your Kubernetes cluster, retrieves all container images
405+
(including init containers) from running pods, and creates projects to analyze each
406+
image for packages, dependencies, and optionally vulnerabilities.
407+
408+
Required arguments:
409+
410+
- ``name`` Project name or prefix for the created projects.
411+
412+
Optional arguments:
413+
414+
- ``--multi`` Create multiple projects (one per image) instead of a single project
415+
containing all images. When used, each project is named ``<name>: <image-reference>``.
416+
417+
- ``--find-vulnerabilities`` Run the ``find_vulnerabilities`` pipeline during the
418+
analysis to detect known security vulnerabilities in discovered packages.
419+
420+
- ``--execute`` Execute the pipelines right after project creation.
421+
422+
- ``--async`` Add the pipeline run to the tasks queue for execution by a worker instead
423+
of running in the current thread.
424+
Applies only when ``--execute`` is provided.
425+
426+
- ``--namespace NAMESPACE`` Limit the image extraction to a specific Kubernetes
427+
namespace. If not provided, images from all namespaces are collected.
428+
429+
- ``--context CONTEXT`` Use a specific Kubernetes context. If not provided, the
430+
current context is used.
431+
432+
- ``--notes NOTES`` Optional notes about the project(s).
433+
434+
- ``--label LABELS`` Optional labels for the project(s). Multiple labels can be
435+
provided by using this argument multiple times.
436+
437+
- ``--dry-run`` Do not create any projects; just print the images and projects that
438+
would be created.
439+
440+
- ``--no-global-webhook`` Skip the creation of the global webhook. This option is
441+
only useful if a global webhook is defined in the settings.
442+
443+
.. note::
444+
This command requires ``kubectl`` to be installed and configured with access to
445+
your Kubernetes cluster.
446+
447+
Example: Analyze All Cluster Images
448+
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
449+
450+
To analyze all images from all namespaces in your current Kubernetes cluster::
451+
452+
$ scanpipe analyze-kubernetes cluster-audit --multi --execute
453+
454+
This creates separate projects for each unique image found in the cluster.
455+
456+
Example: Analyze Production Namespace with Vulnerability Scanning
457+
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
458+
459+
To scan all images in the ``production`` namespace and check for vulnerabilities::
460+
461+
$ scanpipe analyze-kubernetes prod-security-scan \
462+
--namespace production \
463+
--find-vulnerabilities \
464+
--multi \
465+
--label "production" \
466+
--label "security-audit" \
467+
--execute
468+
469+
Example: Dry Run Before Creating Projects
470+
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
471+
472+
To preview which images would be analyzed without creating any projects::
473+
474+
$ scanpipe analyze-kubernetes cluster-preview \
475+
--namespace default \
476+
--dry-run
477+
478+
This displays all images that would be scanned, allowing you to verify the scope
479+
before running the actual analysis.
480+
481+
Example: Analyze Specific Cluster Context
482+
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
483+
484+
To analyze images from a specific Kubernetes cluster when you have multiple contexts
485+
configured::
486+
487+
$ scanpipe analyze-kubernetes staging-audit \
488+
--context staging-cluster \
489+
--namespace default \
490+
--multi \
491+
--execute --async
492+
493+
Example: Single Project for All Images
494+
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
495+
496+
To create one project containing all images from the cluster::
497+
498+
$ scanpipe analyze-kubernetes full-cluster-scan \
499+
--find-vulnerabilities \
500+
--execute
501+
502+
This creates a single project named ``full-cluster-scan`` that analyzes all discovered
503+
images together.
504+
505+
.. tip::
506+
Use ``--multi`` when analyzing large clusters to create separate projects per image,
507+
making it easier to track and review results for individual container images.
508+
394509
`$ scanpipe execute --project PROJECT`
395510
--------------------------------------
396511

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
# SPDX-License-Identifier: Apache-2.0
2+
#
3+
# http://nexb.com and https://github.com/aboutcode-org/scancode.io
4+
# The ScanCode.io software is licensed under the Apache License version 2.0.
5+
# Data generated with ScanCode.io is provided as-is without warranties.
6+
# ScanCode is a trademark of nexB Inc.
7+
#
8+
# You may not use this software except in compliance with the License.
9+
# You may obtain a copy of the License at: http://apache.org/licenses/LICENSE-2.0
10+
# Unless required by applicable law or agreed to in writing, software distributed
11+
# under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
12+
# CONDITIONS OF ANY KIND, either express or implied. See the License for the
13+
# specific language governing permissions and limitations under the License.
14+
#
15+
# Data Generated with ScanCode.io is provided on an "AS IS" BASIS, WITHOUT WARRANTIES
16+
# OR CONDITIONS OF ANY KIND, either express or implied. No content created from
17+
# ScanCode.io should be considered or used as legal advice. Consult an Attorney
18+
# for any legal advice.
19+
#
20+
# ScanCode.io is a free software code scanning tool from nexB Inc. and others.
21+
# Visit https://github.com/aboutcode-org/scancode.io for support and download.
22+
23+
import sys
24+
25+
from django.core.management.base import BaseCommand
26+
from django.core.management.base import CommandError
27+
from django.utils.text import slugify
28+
29+
from scanpipe.management.commands import CreateProjectCommandMixin
30+
from scanpipe.management.commands import execute_project
31+
from scanpipe.pipes.kubernetes import get_images_from_kubectl
32+
33+
34+
class Command(CreateProjectCommandMixin, BaseCommand):
35+
help = "Analyze all images of a Kubernetes cluster."
36+
37+
def add_arguments(self, parser):
38+
super().add_arguments(parser)
39+
parser.add_argument("name", help="Project name.")
40+
parser.add_argument(
41+
"--multi",
42+
action="store_true",
43+
help="Create multiple projects instead of a single one.",
44+
)
45+
parser.add_argument(
46+
"--find-vulnerabilities",
47+
action="store_true",
48+
help="Run the find_vulnerabilities pipeline during the analysis.",
49+
)
50+
parser.add_argument(
51+
"--execute",
52+
action="store_true",
53+
help="Execute the pipelines right after the project creation.",
54+
)
55+
parser.add_argument(
56+
"--dry-run",
57+
action="store_true",
58+
help=(
59+
"Do not create any projects."
60+
"Print the images and projects that would be created."
61+
),
62+
)
63+
# Additional kubectl options
64+
parser.add_argument(
65+
"--namespace",
66+
type=str,
67+
help="Kubernetes namespace to query (for --kubectl mode).",
68+
)
69+
parser.add_argument(
70+
"--context",
71+
type=str,
72+
help="Kubernetes context to use (for --kubectl mode).",
73+
)
74+
75+
def handle(self, *args, **options):
76+
self.verbosity = options["verbosity"]
77+
project_name = options["name"]
78+
pipelines = ["analyze_docker_image"]
79+
create_multiple_projects = options["multi"]
80+
execute = options["execute"]
81+
run_async = options["async"]
82+
labels = options["labels"]
83+
notes = options["notes"]
84+
created_projects = []
85+
86+
if options["find_vulnerabilities"]:
87+
pipelines.append("find_vulnerabilities")
88+
89+
images = self.get_images(**options)
90+
if not images:
91+
raise CommandError("No images found.")
92+
93+
create_project_options = {
94+
"pipelines": pipelines,
95+
"notes": notes,
96+
"labels": labels,
97+
}
98+
99+
if create_multiple_projects:
100+
labels.append(f"k8s-{slugify(project_name)}")
101+
for reference in images:
102+
project = self.create_project(
103+
**create_project_options,
104+
name=f"{project_name}: {reference}",
105+
input_urls=[f"docker://{reference}"],
106+
)
107+
created_projects.append(project)
108+
109+
else:
110+
project = self.create_project(
111+
**create_project_options,
112+
name=project_name,
113+
input_urls=[f"docker://{reference}" for reference in images],
114+
)
115+
created_projects.append(project)
116+
117+
if execute:
118+
for project in created_projects:
119+
execute_project(project=project, run_async=run_async, command=self)
120+
121+
def get_images(self, **options):
122+
namespace = options.get("namespace")
123+
context = options.get("context")
124+
dry_run = options.get("dry_run")
125+
126+
if self.verbosity >= 1:
127+
self.stdout.write(
128+
"Extracting images from Kubernetes cluster using kubectl..."
129+
)
130+
131+
try:
132+
images = get_images_from_kubectl(namespace=namespace, context=context)
133+
except Exception as e:
134+
raise CommandError(e)
135+
136+
if self.verbosity >= 1 or dry_run:
137+
self.stdout.write(
138+
self.style.SUCCESS(f"Found {len(images)} images in the cluster:"),
139+
)
140+
self.stdout.write("\n".join(images))
141+
142+
if dry_run:
143+
self.stdout.write("Dry run mode, no projects were created.")
144+
sys.exit(0)
145+
146+
return images

scanpipe/models.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1533,6 +1533,11 @@ def has_single_resource(self):
15331533
"""
15341534
return self.resource_count == 1
15351535

1536+
@property
1537+
def pipelines(self):
1538+
"""Return the list of pipeline names assigned to this Project."""
1539+
return list(self.runs.values_list("pipeline_name", flat=True))
1540+
15361541
def get_policies_dict(self):
15371542
"""
15381543
Load and return the policies from the following locations in that order:

scanpipe/pipes/__init__.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222

2323
import difflib
2424
import logging
25+
import subprocess
2526
import sys
2627
import time
2728
import uuid
@@ -574,3 +575,40 @@ def poll_until_success(check, sleep=10, **kwargs):
574575
return False
575576

576577
time.sleep(sleep)
578+
579+
580+
def run_command_safely(command_args):
581+
"""
582+
Execute the external commands following security best practices.
583+
584+
This function is using the subprocess.run function which simplifies running external
585+
commands. It provides a safer and more straightforward API compared to older methods
586+
like subprocess.Popen.
587+
588+
WARNING: Please note that the `--option=value` syntax is required for args entries,
589+
and not the `--option value` format.
590+
591+
- This does not use the Shell (shell=False) to prevent injection vulnerabilities.
592+
- The command should be provided as a list of ``command_args`` arguments.
593+
- Only full paths to executable commands should be provided to avoid any ambiguity.
594+
595+
WARNING: If you're incorporating user input into the command, make
596+
sure to sanitize and validate the input to prevent any malicious commands from
597+
being executed.
598+
599+
Raise a SubprocessError if the exit code was non-zero.
600+
"""
601+
completed_process = subprocess.run( # noqa: S603
602+
command_args,
603+
capture_output=True,
604+
text=True,
605+
)
606+
607+
if completed_process.returncode:
608+
error_msg = (
609+
f'Error while executing cmd="{completed_process.args}": '
610+
f'"{completed_process.stderr.strip()}"'
611+
)
612+
raise subprocess.SubprocessError(error_msg)
613+
614+
return completed_process.stdout

scanpipe/pipes/fetch.py

Lines changed: 2 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@
2424
import logging
2525
import os
2626
import re
27-
import subprocess
2827
import tempfile
2928
from collections import namedtuple
3029
from pathlib import Path
@@ -44,6 +43,8 @@
4443
from plugincode.location_provider import get_location
4544
from requests import auth as request_auth
4645

46+
from scanpipe.pipes import run_command_safely
47+
4748
logger = logging.getLogger("scanpipe.pipes")
4849

4950
Download = namedtuple("Download", "uri directory filename path size sha1 md5")
@@ -60,43 +61,6 @@
6061
HTTP_REQUEST_TIMEOUT = 30
6162

6263

63-
def run_command_safely(command_args):
64-
"""
65-
Execute the external commands following security best practices.
66-
67-
This function is using the subprocess.run function which simplifies running external
68-
commands. It provides a safer and more straightforward API compared to older methods
69-
like subprocess.Popen.
70-
71-
WARNING: Please note that the `--option=value` syntax is required for args entries,
72-
and not the `--option value` format.
73-
74-
- This does not use the Shell (shell=False) to prevent injection vulnerabilities.
75-
- The command should be provided as a list of ``command_args`` arguments.
76-
- Only full paths to executable commands should be provided to avoid any ambiguity.
77-
78-
WARNING: If you're incorporating user input into the command, make
79-
sure to sanitize and validate the input to prevent any malicious commands from
80-
being executed.
81-
82-
Raise a SubprocessError if the exit code was non-zero.
83-
"""
84-
completed_process = subprocess.run( # noqa: S603
85-
command_args,
86-
capture_output=True,
87-
text=True,
88-
)
89-
90-
if completed_process.returncode:
91-
error_msg = (
92-
f'Error while executing cmd="{completed_process.args}": '
93-
f'"{completed_process.stderr.strip()}"'
94-
)
95-
raise subprocess.SubprocessError(error_msg)
96-
97-
return completed_process.stdout
98-
99-
10064
def get_request_session(uri):
10165
"""Return a Requests session setup with authentication and headers."""
10266
session = requests.Session()

0 commit comments

Comments
 (0)