dockerls 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dockerls/__init__.py +31 -0
- dockerls/application/__init__.py +0 -0
- dockerls/application/dto/__init__.py +3 -0
- dockerls/application/dto/analysis.py +257 -0
- dockerls/application/services/__init__.py +0 -0
- dockerls/application/services/alternatives_lookup.py +167 -0
- dockerls/application/services/composite_repository.py +106 -0
- dockerls/application/services/cross_validation.py +233 -0
- dockerls/application/services/ecosystems.py +350 -0
- dockerls/application/services/fallback_scanner.py +97 -0
- dockerls/application/services/hardening_analysis.py +174 -0
- dockerls/application/services/migration.py +297 -0
- dockerls/application/services/progress.py +56 -0
- dockerls/application/services/remediation.py +321 -0
- dockerls/application/services/scan_history_store.py +88 -0
- dockerls/application/services/scanner_factory.py +88 -0
- dockerls/application/services/source_registry.py +151 -0
- dockerls/application/services/tag_history_store.py +76 -0
- dockerls/application/services/teardown.py +50 -0
- dockerls/application/services/verdict.py +298 -0
- dockerls/application/services/version_discovery.py +108 -0
- dockerls/application/use_cases/__init__.py +0 -0
- dockerls/application/use_cases/analyze_dockerfile.py +103 -0
- dockerls/application/use_cases/analyze_image.py +173 -0
- dockerls/application/use_cases/build_image.py +1795 -0
- dockerls/application/use_cases/compare_images.py +91 -0
- dockerls/application/use_cases/fleet_scan.py +240 -0
- dockerls/application/use_cases/recommend_images.py +1078 -0
- dockerls/application/use_cases/registry_audit.py +133 -0
- dockerls/application/use_cases/search_images.py +23 -0
- dockerls/application/use_cases/upgrade_base.py +167 -0
- dockerls/cache/__init__.py +0 -0
- dockerls/cache/sqlite_cache.py +184 -0
- dockerls/cli/__init__.py +0 -0
- dockerls/cli/analysis_baseline.py +98 -0
- dockerls/cli/app.py +294 -0
- dockerls/cli/commands/__init__.py +0 -0
- dockerls/cli/commands/advisor.py +262 -0
- dockerls/cli/commands/alternatives.py +291 -0
- dockerls/cli/commands/analyze.py +429 -0
- dockerls/cli/commands/analyze_dockerfile.py +104 -0
- dockerls/cli/commands/base_cmd.py +244 -0
- dockerls/cli/commands/base_image.py +551 -0
- dockerls/cli/commands/build.py +1300 -0
- dockerls/cli/commands/cache_cmd.py +104 -0
- dockerls/cli/commands/compare.py +177 -0
- dockerls/cli/commands/controls.py +110 -0
- dockerls/cli/commands/doctor.py +566 -0
- dockerls/cli/commands/export.py +81 -0
- dockerls/cli/commands/fleet.py +159 -0
- dockerls/cli/commands/health.py +84 -0
- dockerls/cli/commands/login.py +53 -0
- dockerls/cli/commands/policy_cmd.py +111 -0
- dockerls/cli/commands/provenance_cmd.py +162 -0
- dockerls/cli/commands/recommend.py +761 -0
- dockerls/cli/commands/registry_audit_cmd.py +103 -0
- dockerls/cli/commands/sbom.py +144 -0
- dockerls/cli/commands/search.py +86 -0
- dockerls/cli/commands/verify.py +115 -0
- dockerls/cli/commands/version.py +12 -0
- dockerls/cli/commands/vex_cmd.py +117 -0
- dockerls/cli/dependencies.py +530 -0
- dockerls/cli/image_names.py +79 -0
- dockerls/cli/options.py +42 -0
- dockerls/cli/progress.py +145 -0
- dockerls/cli/publish_prompt.py +123 -0
- dockerls/cli/rendering.py +214 -0
- dockerls/cli/runtime.py +65 -0
- dockerls/cli/scan_failure.py +71 -0
- dockerls/cli/text.py +39 -0
- dockerls/cli/validators.py +35 -0
- dockerls/cli/vulnerability_view.py +154 -0
- dockerls/domain/__init__.py +0 -0
- dockerls/domain/entities/__init__.py +75 -0
- dockerls/domain/entities/declared_metadata.py +147 -0
- dockerls/domain/entities/dockerfile_analysis.py +318 -0
- dockerls/domain/entities/image.py +109 -0
- dockerls/domain/entities/image_facts.py +137 -0
- dockerls/domain/entities/recommendation.py +33 -0
- dockerls/domain/entities/scan_result.py +129 -0
- dockerls/domain/entities/vulnerability.py +232 -0
- dockerls/domain/interfaces/__init__.py +17 -0
- dockerls/domain/interfaces/cache_store.py +18 -0
- dockerls/domain/interfaces/dockerfile_validator.py +99 -0
- dockerls/domain/interfaces/eol_checker.py +11 -0
- dockerls/domain/interfaces/image_repository.py +15 -0
- dockerls/domain/interfaces/scanner.py +15 -0
- dockerls/domain/security_controls.py +362 -0
- dockerls/domain/value_objects/__init__.py +49 -0
- dockerls/domain/value_objects/attack_surface.py +198 -0
- dockerls/domain/value_objects/base_recipe.py +600 -0
- dockerls/domain/value_objects/base_upgrade.py +292 -0
- dockerls/domain/value_objects/build_labels.py +99 -0
- dockerls/domain/value_objects/build_policy.py +412 -0
- dockerls/domain/value_objects/confidence.py +156 -0
- dockerls/domain/value_objects/fleet.py +174 -0
- dockerls/domain/value_objects/gate.py +327 -0
- dockerls/domain/value_objects/hardening.py +303 -0
- dockerls/domain/value_objects/image_reference.py +90 -0
- dockerls/domain/value_objects/inheritance.py +352 -0
- dockerls/domain/value_objects/network_policy.py +280 -0
- dockerls/domain/value_objects/production_readiness.py +145 -0
- dockerls/domain/value_objects/provenance.py +211 -0
- dockerls/domain/value_objects/recipe_diff.py +188 -0
- dockerls/domain/value_objects/registry_audit.py +195 -0
- dockerls/domain/value_objects/registry_target.py +225 -0
- dockerls/domain/value_objects/remediation_score.py +62 -0
- dockerls/domain/value_objects/scan_history.py +183 -0
- dockerls/domain/value_objects/scan_plan.py +193 -0
- dockerls/domain/value_objects/scanner_db.py +143 -0
- dockerls/domain/value_objects/security_score.py +160 -0
- dockerls/domain/value_objects/security_tier.py +122 -0
- dockerls/domain/value_objects/tag_history.py +180 -0
- dockerls/domain/value_objects/tool_release.py +253 -0
- dockerls/domain/value_objects/tristate.py +47 -0
- dockerls/domain/value_objects/vex.py +249 -0
- dockerls/exit_codes.py +23 -0
- dockerls/exporters/__init__.py +0 -0
- dockerls/exporters/base.py +17 -0
- dockerls/exporters/csv_exporter.py +85 -0
- dockerls/exporters/factory.py +31 -0
- dockerls/exporters/html_exporter.py +105 -0
- dockerls/exporters/json_exporter.py +19 -0
- dockerls/exporters/markdown_exporter.py +84 -0
- dockerls/exporters/sarif_exporter.py +245 -0
- dockerls/infrastructure/__init__.py +0 -0
- dockerls/infrastructure/config/__init__.py +0 -0
- dockerls/infrastructure/config/policy_file.py +165 -0
- dockerls/infrastructure/config/settings.py +197 -0
- dockerls/infrastructure/database/__init__.py +0 -0
- dockerls/infrastructure/database/models.py +78 -0
- dockerls/infrastructure/dockerfile_validator.py +1899 -0
- dockerls/infrastructure/evidence.py +99 -0
- dockerls/infrastructure/hashing.py +165 -0
- dockerls/infrastructure/logging/__init__.py +0 -0
- dockerls/infrastructure/logging/setup.py +98 -0
- dockerls/infrastructure/network/__init__.py +0 -0
- dockerls/infrastructure/network/guarded_client.py +107 -0
- dockerls/infrastructure/network/host_guard.py +117 -0
- dockerls/infrastructure/redaction.py +135 -0
- dockerls/infrastructure/templates/hardening/alpine.dockerfile +44 -0
- dockerls/infrastructure/templates/hardening/debian.dockerfile +45 -0
- dockerls/infrastructure/templates/hardening/distroless.dockerfile +34 -0
- dockerls/infrastructure/templates/hardening/go-alpine.dockerfile +52 -0
- dockerls/infrastructure/templates/hardening/go-debian.dockerfile +54 -0
- dockerls/infrastructure/templates/hardening/go-distroless.dockerfile +43 -0
- dockerls/infrastructure/templates/hardening/go-scratch.dockerfile +48 -0
- dockerls/infrastructure/templates/hardening/go.dockerfile +50 -0
- dockerls/infrastructure/templates/hardening/gradle-alpine.dockerfile +51 -0
- dockerls/infrastructure/templates/hardening/gradle.dockerfile +52 -0
- dockerls/infrastructure/templates/hardening/java-alpine.dockerfile +50 -0
- dockerls/infrastructure/templates/hardening/java-debian.dockerfile +50 -0
- dockerls/infrastructure/templates/hardening/java-distroless.dockerfile +39 -0
- dockerls/infrastructure/templates/hardening/java-ubuntu.dockerfile +54 -0
- dockerls/infrastructure/templates/hardening/java.dockerfile +60 -0
- dockerls/infrastructure/templates/hardening/maven-alpine.dockerfile +56 -0
- dockerls/infrastructure/templates/hardening/maven.dockerfile +57 -0
- dockerls/infrastructure/templates/hardening/node-alpine.dockerfile +47 -0
- dockerls/infrastructure/templates/hardening/node-debian.dockerfile +54 -0
- dockerls/infrastructure/templates/hardening/node-distroless.dockerfile +46 -0
- dockerls/infrastructure/templates/hardening/node-ubuntu.dockerfile +63 -0
- dockerls/infrastructure/templates/hardening/node.dockerfile +61 -0
- dockerls/infrastructure/templates/hardening/php-alpine.dockerfile +45 -0
- dockerls/infrastructure/templates/hardening/php-debian.dockerfile +45 -0
- dockerls/infrastructure/templates/hardening/php-ubuntu.dockerfile +49 -0
- dockerls/infrastructure/templates/hardening/php.dockerfile +44 -0
- dockerls/infrastructure/templates/hardening/python-alpine.dockerfile +51 -0
- dockerls/infrastructure/templates/hardening/python-debian.dockerfile +54 -0
- dockerls/infrastructure/templates/hardening/python-distroless.dockerfile +51 -0
- dockerls/infrastructure/templates/hardening/python-ubuntu.dockerfile +60 -0
- dockerls/infrastructure/templates/hardening/python.dockerfile +58 -0
- dockerls/infrastructure/templates/hardening/ruby-alpine.dockerfile +48 -0
- dockerls/infrastructure/templates/hardening/ruby-debian.dockerfile +50 -0
- dockerls/infrastructure/templates/hardening/rust-alpine.dockerfile +50 -0
- dockerls/infrastructure/templates/hardening/rust-debian.dockerfile +48 -0
- dockerls/infrastructure/templates/hardening/rust-scratch.dockerfile +44 -0
- dockerls/infrastructure/templates/hardening/rust.dockerfile +54 -0
- dockerls/infrastructure/templates/hardening/ubuntu.dockerfile +49 -0
- dockerls/infrastructure/toolchain/__init__.py +0 -0
- dockerls/infrastructure/toolchain/db_metadata.py +115 -0
- dockerls/infrastructure/toolchain/installer.py +435 -0
- dockerls/integrations/__init__.py +0 -0
- dockerls/integrations/dhi/__init__.py +0 -0
- dockerls/integrations/dhi/catalog.py +457 -0
- dockerls/integrations/dhi/definition.py +151 -0
- dockerls/integrations/dhi/repository.py +238 -0
- dockerls/integrations/dockerhub/__init__.py +0 -0
- dockerls/integrations/dockerhub/client.py +318 -0
- dockerls/integrations/dockerhub/urls.py +75 -0
- dockerls/integrations/endoflife/__init__.py +0 -0
- dockerls/integrations/endoflife/checker.py +216 -0
- dockerls/integrations/engine/__init__.py +0 -0
- dockerls/integrations/engine/batch.py +197 -0
- dockerls/integrations/engine/client.py +330 -0
- dockerls/integrations/engine/locator.py +96 -0
- dockerls/integrations/exploitdb/__init__.py +0 -0
- dockerls/integrations/exploitdb/client.py +271 -0
- dockerls/integrations/grype/__init__.py +0 -0
- dockerls/integrations/grype/scanner.py +336 -0
- dockerls/integrations/registry/__init__.py +0 -0
- dockerls/integrations/registry/hardened.py +284 -0
- dockerls/integrations/registry/inspector.py +420 -0
- dockerls/integrations/registry/oci.py +259 -0
- dockerls/integrations/registry/private.py +79 -0
- dockerls/integrations/registry/urls.py +36 -0
- dockerls/integrations/scan_errors.py +67 -0
- dockerls/integrations/scan_target.py +57 -0
- dockerls/integrations/signing/__init__.py +0 -0
- dockerls/integrations/signing/cosign.py +484 -0
- dockerls/integrations/threat_intel/__init__.py +0 -0
- dockerls/integrations/threat_intel/client.py +290 -0
- dockerls/integrations/trivy/__init__.py +0 -0
- dockerls/integrations/trivy/cache_pool.py +176 -0
- dockerls/integrations/trivy/scanner.py +447 -0
- dockerls/utils/__init__.py +0 -0
- dockerls/utils/auth.py +108 -0
- dockerls/utils/executables.py +39 -0
- dockerls/utils/ignore_file.py +130 -0
- dockerls/utils/rate_limit.py +130 -0
- dockerls/utils/resources.py +183 -0
- dockerls/utils/retry.py +31 -0
- dockerls/utils/safe_yaml.py +166 -0
- dockerls/utils/subprocess_runner.py +216 -0
- dockerls/utils/validation.py +72 -0
- dockerls-1.0.0.dist-info/METADATA +563 -0
- dockerls-1.0.0.dist-info/RECORD +230 -0
- dockerls-1.0.0.dist-info/WHEEL +5 -0
- dockerls-1.0.0.dist-info/entry_points.txt +2 -0
- dockerls-1.0.0.dist-info/licenses/LICENSE +21 -0
- dockerls-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""Run an external scanner and guarantee the child process is reaped.
|
|
2
|
+
|
|
3
|
+
`asyncio.wait_for(proc.communicate(), timeout=...)` cancels the *await*, not
|
|
4
|
+
the process. On timeout the coroutine raises and the caller returns a
|
|
5
|
+
`TIMEOUT` scan result, while the `trivy` or `grype` process it started keeps
|
|
6
|
+
running: still downloading, still matching, still holding the exclusive
|
|
7
|
+
BoltDB lock on its Trivy cache directory -- which is precisely the
|
|
8
|
+
contention `TrivyCachePool` exists to eliminate. With `--workers 10` a run
|
|
9
|
+
that trips its timeout could leave ten scanners behind, outliving the CLI
|
|
10
|
+
that spawned them and slowing down the next invocation that tries to take
|
|
11
|
+
the same lock.
|
|
12
|
+
|
|
13
|
+
The same applies to cancellation: Ctrl-C unwinds the event loop, and without
|
|
14
|
+
an explicit kill the children survive it.
|
|
15
|
+
|
|
16
|
+
`run_capture` closes both holes. Every path out -- success, timeout,
|
|
17
|
+
cancellation, or an error raised inside the body -- goes through a
|
|
18
|
+
`finally` that terminates the process and *waits* for it, so the process
|
|
19
|
+
table is clean before the caller sees the outcome.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import asyncio
|
|
25
|
+
import contextlib
|
|
26
|
+
from typing import TYPE_CHECKING
|
|
27
|
+
|
|
28
|
+
from loguru import logger
|
|
29
|
+
|
|
30
|
+
if TYPE_CHECKING:
|
|
31
|
+
from collections.abc import Mapping
|
|
32
|
+
|
|
33
|
+
#: Most a scanner may write to one stream before the run is abandoned.
|
|
34
|
+
#: `communicate()` accumulates the whole output in memory, so an image with a
|
|
35
|
+
#: pathological number of findings -- or a scanner that has been tampered
|
|
36
|
+
#: with -- was read without any bound at all. 256 MiB is far above any real
|
|
37
|
+
#: Trivy or Grype JSON (a very noisy image produces single-digit MiB) and far
|
|
38
|
+
#: below what would put the host under memory pressure.
|
|
39
|
+
MAX_OUTPUT_BYTES = 256 * 1024 * 1024
|
|
40
|
+
|
|
41
|
+
#: Read granularity. Large enough that the loop is not the bottleneck, small
|
|
42
|
+
#: enough that the cap is enforced promptly.
|
|
43
|
+
_CHUNK_BYTES = 256 * 1024
|
|
44
|
+
|
|
45
|
+
#: `--version` answers instantly or not at all; a scanner that hangs on it
|
|
46
|
+
#: must not hold up a run that could still proceed without knowing which
|
|
47
|
+
#: version it is talking to.
|
|
48
|
+
VERSION_TIMEOUT_SECONDS = 10.0
|
|
49
|
+
|
|
50
|
+
# How long a signalled scanner is given to exit on its own before SIGKILL.
|
|
51
|
+
# Trivy and Grype flush and exit promptly; this only bounds the pathological
|
|
52
|
+
# case of a process ignoring SIGTERM.
|
|
53
|
+
_TERMINATE_GRACE_SECONDS = 5.0
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
#: Se dá para desligar core dumps no filho. Só POSIX tem `resource`, e a
|
|
57
|
+
#: ausência não é erro: no Windows o mecanismo simplesmente não existe.
|
|
58
|
+
try: # pragma: no cover - depende da plataforma
|
|
59
|
+
import resource
|
|
60
|
+
|
|
61
|
+
_CAN_LIMIT_CORE = True
|
|
62
|
+
except ImportError: # pragma: no cover - Windows
|
|
63
|
+
_CAN_LIMIT_CORE = False
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _no_core_dumps() -> None: # pragma: no cover - roda no processo filho
|
|
67
|
+
"""Desliga core dumps no scanner, antes do `exec`.
|
|
68
|
+
|
|
69
|
+
Um scanner que falha um pull autenticado tem, na memória, o token que
|
|
70
|
+
usou. Se ele receber um SIGSEGV com core dump ligado, esse token vai
|
|
71
|
+
para o disco num arquivo que ninguém redige e quase ninguém audita --
|
|
72
|
+
e este projeto já redige log, evidência e exportação justamente para
|
|
73
|
+
isso não acontecer. Fechar a última porta custa duas linhas.
|
|
74
|
+
|
|
75
|
+
`RLIMIT_AS` continuaria sendo a escolha errada aqui e por isso não está:
|
|
76
|
+
o Trivy é um binário Go, e o runtime do Go reserva um espaço de
|
|
77
|
+
endereçamento virtual enorme na largada. Limitar isso mata o processo na
|
|
78
|
+
inicialização, transformando uma medida de segurança numa falha de scan.
|
|
79
|
+
"""
|
|
80
|
+
resource.setrlimit(resource.RLIMIT_CORE, (0, 0))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class OutputTooLargeError(RuntimeError):
|
|
84
|
+
"""A scanner wrote more than `MAX_OUTPUT_BYTES` to one stream.
|
|
85
|
+
|
|
86
|
+
Raised rather than truncated: a JSON document cut in half does not parse,
|
|
87
|
+
and a scan whose output could not be read in full has not been measured.
|
|
88
|
+
Callers classify it as `INVALID_OUTPUT`, which is already a non-verified
|
|
89
|
+
state -- the failure mode this codebase is built around.
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
async def run_capture(
|
|
94
|
+
argv: list[str],
|
|
95
|
+
*,
|
|
96
|
+
timeout: float,
|
|
97
|
+
env: Mapping[str, str] | None = None,
|
|
98
|
+
max_output_bytes: int = MAX_OUTPUT_BYTES,
|
|
99
|
+
) -> tuple[int, bytes, bytes]:
|
|
100
|
+
"""Run `argv`, returning ``(returncode, stdout, stderr)``.
|
|
101
|
+
|
|
102
|
+
`argv[0]` must already be an absolute path (see
|
|
103
|
+
`dockerls.utils.executables.resolve_executable`); nothing here goes
|
|
104
|
+
through a shell, and the arguments are passed as a list, so no quoting
|
|
105
|
+
or escaping is involved at any point.
|
|
106
|
+
|
|
107
|
+
Raises `TimeoutError` if the process does not finish within `timeout`,
|
|
108
|
+
and `OutputTooLargeError` if either stream exceeds `max_output_bytes`.
|
|
109
|
+
In every one of those cases -- and on cancellation -- the process is
|
|
110
|
+
killed and reaped before the exception propagates.
|
|
111
|
+
"""
|
|
112
|
+
proc = await asyncio.create_subprocess_exec( # noqa: S603 -- argv list, no shell
|
|
113
|
+
*argv,
|
|
114
|
+
stdout=asyncio.subprocess.PIPE,
|
|
115
|
+
stderr=asyncio.subprocess.PIPE,
|
|
116
|
+
env=env,
|
|
117
|
+
preexec_fn=_no_core_dumps if _CAN_LIMIT_CORE else None, # noqa: PLW1509
|
|
118
|
+
)
|
|
119
|
+
try:
|
|
120
|
+
stdout, stderr = await asyncio.wait_for(
|
|
121
|
+
_communicate_capped(proc, max_output_bytes), timeout=timeout
|
|
122
|
+
)
|
|
123
|
+
finally:
|
|
124
|
+
await _reap(proc, argv[0])
|
|
125
|
+
return proc.returncode or 0, stdout, stderr
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
async def _communicate_capped(proc: asyncio.subprocess.Process, limit: int) -> tuple[bytes, bytes]:
|
|
129
|
+
"""`communicate()` with a ceiling on each stream.
|
|
130
|
+
|
|
131
|
+
Both streams are drained concurrently for the same reason the stdlib
|
|
132
|
+
does it: a child that fills the stderr pipe while the parent is reading
|
|
133
|
+
stdout deadlocks, and a scanner that has just failed writes a great deal
|
|
134
|
+
to stderr.
|
|
135
|
+
"""
|
|
136
|
+
# `getattr` rather than attribute access: the fallback exists precisely
|
|
137
|
+
# for process objects that do not model the stream readers at all, and
|
|
138
|
+
# those raise AttributeError rather than answering None.
|
|
139
|
+
stdout_reader = getattr(proc, "stdout", None)
|
|
140
|
+
stderr_reader = getattr(proc, "stderr", None)
|
|
141
|
+
if not isinstance(stdout_reader, asyncio.StreamReader) or not isinstance(
|
|
142
|
+
stderr_reader, asyncio.StreamReader
|
|
143
|
+
):
|
|
144
|
+
# A process object that does not expose stream readers -- a test
|
|
145
|
+
# double modelling the documented `communicate()` contract, or a
|
|
146
|
+
# future runner that pipes differently. Fall back rather than
|
|
147
|
+
# refuse: `communicate()` is the interface everything implements,
|
|
148
|
+
# and a real child created with PIPE above always has the readers,
|
|
149
|
+
# so the ceiling applies wherever it can actually be exceeded.
|
|
150
|
+
result: tuple[bytes, bytes] = await proc.communicate()
|
|
151
|
+
return result
|
|
152
|
+
|
|
153
|
+
stdout, stderr = await asyncio.gather(
|
|
154
|
+
_read_capped(stdout_reader, limit, "stdout"),
|
|
155
|
+
_read_capped(stderr_reader, limit, "stderr"),
|
|
156
|
+
)
|
|
157
|
+
await proc.wait()
|
|
158
|
+
return stdout, stderr
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
async def _read_capped(stream: asyncio.StreamReader, limit: int, name: str) -> bytes:
|
|
162
|
+
chunks: list[bytes] = []
|
|
163
|
+
total = 0
|
|
164
|
+
while True:
|
|
165
|
+
chunk = await stream.read(_CHUNK_BYTES)
|
|
166
|
+
if not chunk:
|
|
167
|
+
return b"".join(chunks)
|
|
168
|
+
total += len(chunk)
|
|
169
|
+
if total > limit:
|
|
170
|
+
raise OutputTooLargeError(
|
|
171
|
+
f"scanner wrote more than {limit} bytes to {name}; output discarded"
|
|
172
|
+
)
|
|
173
|
+
chunks.append(chunk)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
async def _reap(proc: asyncio.subprocess.Process, name: str) -> None:
|
|
177
|
+
"""Terminate `proc` if it is still running, then wait for it.
|
|
178
|
+
|
|
179
|
+
A no-op on the normal path: `communicate()` already returned, so
|
|
180
|
+
`returncode` is set. Shielded from cancellation so that a Ctrl-C
|
|
181
|
+
arriving *during* cleanup cannot skip the kill it is here to perform.
|
|
182
|
+
"""
|
|
183
|
+
if proc.returncode is not None:
|
|
184
|
+
return
|
|
185
|
+
logger.warning(f"Killing unfinished subprocess {name} (pid {proc.pid})")
|
|
186
|
+
with contextlib.suppress(ProcessLookupError):
|
|
187
|
+
proc.terminate()
|
|
188
|
+
try:
|
|
189
|
+
await asyncio.shield(asyncio.wait_for(proc.wait(), timeout=_TERMINATE_GRACE_SECONDS))
|
|
190
|
+
except (TimeoutError, asyncio.CancelledError):
|
|
191
|
+
with contextlib.suppress(ProcessLookupError):
|
|
192
|
+
proc.kill()
|
|
193
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
194
|
+
await asyncio.shield(proc.wait())
|
|
195
|
+
finally:
|
|
196
|
+
_close_transport(proc)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _close_transport(proc: asyncio.subprocess.Process) -> None:
|
|
200
|
+
"""Release the child's pipes now rather than at garbage-collection time.
|
|
201
|
+
|
|
202
|
+
A process killed mid-write leaves its transport holding open pipes. The
|
|
203
|
+
transport closes them in `__del__`, and if that runs after the event loop
|
|
204
|
+
is gone -- which is exactly what happens when the kill is the last thing
|
|
205
|
+
a command does -- asyncio raises "Event loop is closed" from a
|
|
206
|
+
destructor, where nothing can handle it.
|
|
207
|
+
|
|
208
|
+
The transport is not part of the public `Process` API, so it is reached
|
|
209
|
+
defensively: a Python release that renames it, or a process double that
|
|
210
|
+
never had one, simply skips this and falls back to the old behaviour.
|
|
211
|
+
"""
|
|
212
|
+
transport = getattr(proc, "_transport", None)
|
|
213
|
+
close = getattr(transport, "close", None)
|
|
214
|
+
if callable(close):
|
|
215
|
+
with contextlib.suppress(Exception):
|
|
216
|
+
close()
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
# Supports plain names ("node"), tags ("node:22-alpine"), digest
|
|
6
|
+
# references ("node@sha256:<64 hex>"), tag+digest combined, and private
|
|
7
|
+
# registry prefixes with an optional port ("ghcr.io/org/repo:tag",
|
|
8
|
+
# "registry.internal:5000/team/app@sha256:...").
|
|
9
|
+
_IMAGE_NAME_PATTERN = re.compile(
|
|
10
|
+
r"^(?:[a-zA-Z0-9.-]+(?::\d+)?/)?"
|
|
11
|
+
r"[a-zA-Z0-9._/-]+"
|
|
12
|
+
r"(?::[a-zA-Z0-9._-]+)?"
|
|
13
|
+
r"(?:@sha256:[a-fA-F0-9]{64})?$"
|
|
14
|
+
)
|
|
15
|
+
_MAX_NAME_LENGTH = 256
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def sanitize_image_name(name: str) -> str:
|
|
19
|
+
name = name.strip()
|
|
20
|
+
if not name:
|
|
21
|
+
raise ValueError("Image name cannot be empty")
|
|
22
|
+
if len(name) > _MAX_NAME_LENGTH:
|
|
23
|
+
raise ValueError(f"Image name exceeds {_MAX_NAME_LENGTH} characters")
|
|
24
|
+
if not _IMAGE_NAME_PATTERN.match(name):
|
|
25
|
+
raise ValueError(f"Invalid image name: {name}")
|
|
26
|
+
if ".." in name:
|
|
27
|
+
raise ValueError("Path traversal detected in image name")
|
|
28
|
+
_reject_option_lookalike(name)
|
|
29
|
+
return name
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _reject_option_lookalike(name: str) -> None:
|
|
33
|
+
"""Refuse references that a scanner would read as command-line options.
|
|
34
|
+
|
|
35
|
+
The reference is appended to `trivy image …` / `grype …` as the scan
|
|
36
|
+
target. Hyphen is a legal character mid-name, so strings like
|
|
37
|
+
`--ignore-unfixed` or `--offline-scan` satisfied the pattern above and
|
|
38
|
+
were handed to the scanner as *flags* rather than as an image -- turning
|
|
39
|
+
a reference that arrives from a CI variable or a config file into control
|
|
40
|
+
over how (or whether) the scan runs. Docker itself requires every path
|
|
41
|
+
component to start with an alphanumeric, so nothing legitimate is lost.
|
|
42
|
+
"""
|
|
43
|
+
for component in name.split("/"):
|
|
44
|
+
if component.startswith("-"):
|
|
45
|
+
raise ValueError(
|
|
46
|
+
f"Invalid image name: {name} (a reference component may not start with '-')"
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
_MAX_THRESHOLD = 10000
|
|
51
|
+
|
|
52
|
+
# Each worker holds a slot on an asyncio.Semaphore; 0 would deadlock the
|
|
53
|
+
# scan loop forever and anything much above this only adds contention and
|
|
54
|
+
# rate-limit pressure on Docker Hub.
|
|
55
|
+
MIN_WORKERS = 1
|
|
56
|
+
MAX_WORKERS = 50
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def validate_threshold(value: int, name: str, *, minimum: int = 0) -> int:
|
|
60
|
+
if value < minimum:
|
|
61
|
+
if minimum == 0:
|
|
62
|
+
raise ValueError(f"{name} must be non-negative")
|
|
63
|
+
raise ValueError(f"{name} must be at least {minimum}")
|
|
64
|
+
if value > _MAX_THRESHOLD:
|
|
65
|
+
raise ValueError(f"{name} exceeds maximum allowed value ({_MAX_THRESHOLD})")
|
|
66
|
+
return value
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def validate_workers(value: int, name: str = "workers") -> int:
|
|
70
|
+
if value < MIN_WORKERS or value > MAX_WORKERS:
|
|
71
|
+
raise ValueError(f"{name} must be between {MIN_WORKERS} and {MAX_WORKERS}")
|
|
72
|
+
return value
|