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.
Files changed (230) hide show
  1. dockerls/__init__.py +31 -0
  2. dockerls/application/__init__.py +0 -0
  3. dockerls/application/dto/__init__.py +3 -0
  4. dockerls/application/dto/analysis.py +257 -0
  5. dockerls/application/services/__init__.py +0 -0
  6. dockerls/application/services/alternatives_lookup.py +167 -0
  7. dockerls/application/services/composite_repository.py +106 -0
  8. dockerls/application/services/cross_validation.py +233 -0
  9. dockerls/application/services/ecosystems.py +350 -0
  10. dockerls/application/services/fallback_scanner.py +97 -0
  11. dockerls/application/services/hardening_analysis.py +174 -0
  12. dockerls/application/services/migration.py +297 -0
  13. dockerls/application/services/progress.py +56 -0
  14. dockerls/application/services/remediation.py +321 -0
  15. dockerls/application/services/scan_history_store.py +88 -0
  16. dockerls/application/services/scanner_factory.py +88 -0
  17. dockerls/application/services/source_registry.py +151 -0
  18. dockerls/application/services/tag_history_store.py +76 -0
  19. dockerls/application/services/teardown.py +50 -0
  20. dockerls/application/services/verdict.py +298 -0
  21. dockerls/application/services/version_discovery.py +108 -0
  22. dockerls/application/use_cases/__init__.py +0 -0
  23. dockerls/application/use_cases/analyze_dockerfile.py +103 -0
  24. dockerls/application/use_cases/analyze_image.py +173 -0
  25. dockerls/application/use_cases/build_image.py +1795 -0
  26. dockerls/application/use_cases/compare_images.py +91 -0
  27. dockerls/application/use_cases/fleet_scan.py +240 -0
  28. dockerls/application/use_cases/recommend_images.py +1078 -0
  29. dockerls/application/use_cases/registry_audit.py +133 -0
  30. dockerls/application/use_cases/search_images.py +23 -0
  31. dockerls/application/use_cases/upgrade_base.py +167 -0
  32. dockerls/cache/__init__.py +0 -0
  33. dockerls/cache/sqlite_cache.py +184 -0
  34. dockerls/cli/__init__.py +0 -0
  35. dockerls/cli/analysis_baseline.py +98 -0
  36. dockerls/cli/app.py +294 -0
  37. dockerls/cli/commands/__init__.py +0 -0
  38. dockerls/cli/commands/advisor.py +262 -0
  39. dockerls/cli/commands/alternatives.py +291 -0
  40. dockerls/cli/commands/analyze.py +429 -0
  41. dockerls/cli/commands/analyze_dockerfile.py +104 -0
  42. dockerls/cli/commands/base_cmd.py +244 -0
  43. dockerls/cli/commands/base_image.py +551 -0
  44. dockerls/cli/commands/build.py +1300 -0
  45. dockerls/cli/commands/cache_cmd.py +104 -0
  46. dockerls/cli/commands/compare.py +177 -0
  47. dockerls/cli/commands/controls.py +110 -0
  48. dockerls/cli/commands/doctor.py +566 -0
  49. dockerls/cli/commands/export.py +81 -0
  50. dockerls/cli/commands/fleet.py +159 -0
  51. dockerls/cli/commands/health.py +84 -0
  52. dockerls/cli/commands/login.py +53 -0
  53. dockerls/cli/commands/policy_cmd.py +111 -0
  54. dockerls/cli/commands/provenance_cmd.py +162 -0
  55. dockerls/cli/commands/recommend.py +761 -0
  56. dockerls/cli/commands/registry_audit_cmd.py +103 -0
  57. dockerls/cli/commands/sbom.py +144 -0
  58. dockerls/cli/commands/search.py +86 -0
  59. dockerls/cli/commands/verify.py +115 -0
  60. dockerls/cli/commands/version.py +12 -0
  61. dockerls/cli/commands/vex_cmd.py +117 -0
  62. dockerls/cli/dependencies.py +530 -0
  63. dockerls/cli/image_names.py +79 -0
  64. dockerls/cli/options.py +42 -0
  65. dockerls/cli/progress.py +145 -0
  66. dockerls/cli/publish_prompt.py +123 -0
  67. dockerls/cli/rendering.py +214 -0
  68. dockerls/cli/runtime.py +65 -0
  69. dockerls/cli/scan_failure.py +71 -0
  70. dockerls/cli/text.py +39 -0
  71. dockerls/cli/validators.py +35 -0
  72. dockerls/cli/vulnerability_view.py +154 -0
  73. dockerls/domain/__init__.py +0 -0
  74. dockerls/domain/entities/__init__.py +75 -0
  75. dockerls/domain/entities/declared_metadata.py +147 -0
  76. dockerls/domain/entities/dockerfile_analysis.py +318 -0
  77. dockerls/domain/entities/image.py +109 -0
  78. dockerls/domain/entities/image_facts.py +137 -0
  79. dockerls/domain/entities/recommendation.py +33 -0
  80. dockerls/domain/entities/scan_result.py +129 -0
  81. dockerls/domain/entities/vulnerability.py +232 -0
  82. dockerls/domain/interfaces/__init__.py +17 -0
  83. dockerls/domain/interfaces/cache_store.py +18 -0
  84. dockerls/domain/interfaces/dockerfile_validator.py +99 -0
  85. dockerls/domain/interfaces/eol_checker.py +11 -0
  86. dockerls/domain/interfaces/image_repository.py +15 -0
  87. dockerls/domain/interfaces/scanner.py +15 -0
  88. dockerls/domain/security_controls.py +362 -0
  89. dockerls/domain/value_objects/__init__.py +49 -0
  90. dockerls/domain/value_objects/attack_surface.py +198 -0
  91. dockerls/domain/value_objects/base_recipe.py +600 -0
  92. dockerls/domain/value_objects/base_upgrade.py +292 -0
  93. dockerls/domain/value_objects/build_labels.py +99 -0
  94. dockerls/domain/value_objects/build_policy.py +412 -0
  95. dockerls/domain/value_objects/confidence.py +156 -0
  96. dockerls/domain/value_objects/fleet.py +174 -0
  97. dockerls/domain/value_objects/gate.py +327 -0
  98. dockerls/domain/value_objects/hardening.py +303 -0
  99. dockerls/domain/value_objects/image_reference.py +90 -0
  100. dockerls/domain/value_objects/inheritance.py +352 -0
  101. dockerls/domain/value_objects/network_policy.py +280 -0
  102. dockerls/domain/value_objects/production_readiness.py +145 -0
  103. dockerls/domain/value_objects/provenance.py +211 -0
  104. dockerls/domain/value_objects/recipe_diff.py +188 -0
  105. dockerls/domain/value_objects/registry_audit.py +195 -0
  106. dockerls/domain/value_objects/registry_target.py +225 -0
  107. dockerls/domain/value_objects/remediation_score.py +62 -0
  108. dockerls/domain/value_objects/scan_history.py +183 -0
  109. dockerls/domain/value_objects/scan_plan.py +193 -0
  110. dockerls/domain/value_objects/scanner_db.py +143 -0
  111. dockerls/domain/value_objects/security_score.py +160 -0
  112. dockerls/domain/value_objects/security_tier.py +122 -0
  113. dockerls/domain/value_objects/tag_history.py +180 -0
  114. dockerls/domain/value_objects/tool_release.py +253 -0
  115. dockerls/domain/value_objects/tristate.py +47 -0
  116. dockerls/domain/value_objects/vex.py +249 -0
  117. dockerls/exit_codes.py +23 -0
  118. dockerls/exporters/__init__.py +0 -0
  119. dockerls/exporters/base.py +17 -0
  120. dockerls/exporters/csv_exporter.py +85 -0
  121. dockerls/exporters/factory.py +31 -0
  122. dockerls/exporters/html_exporter.py +105 -0
  123. dockerls/exporters/json_exporter.py +19 -0
  124. dockerls/exporters/markdown_exporter.py +84 -0
  125. dockerls/exporters/sarif_exporter.py +245 -0
  126. dockerls/infrastructure/__init__.py +0 -0
  127. dockerls/infrastructure/config/__init__.py +0 -0
  128. dockerls/infrastructure/config/policy_file.py +165 -0
  129. dockerls/infrastructure/config/settings.py +197 -0
  130. dockerls/infrastructure/database/__init__.py +0 -0
  131. dockerls/infrastructure/database/models.py +78 -0
  132. dockerls/infrastructure/dockerfile_validator.py +1899 -0
  133. dockerls/infrastructure/evidence.py +99 -0
  134. dockerls/infrastructure/hashing.py +165 -0
  135. dockerls/infrastructure/logging/__init__.py +0 -0
  136. dockerls/infrastructure/logging/setup.py +98 -0
  137. dockerls/infrastructure/network/__init__.py +0 -0
  138. dockerls/infrastructure/network/guarded_client.py +107 -0
  139. dockerls/infrastructure/network/host_guard.py +117 -0
  140. dockerls/infrastructure/redaction.py +135 -0
  141. dockerls/infrastructure/templates/hardening/alpine.dockerfile +44 -0
  142. dockerls/infrastructure/templates/hardening/debian.dockerfile +45 -0
  143. dockerls/infrastructure/templates/hardening/distroless.dockerfile +34 -0
  144. dockerls/infrastructure/templates/hardening/go-alpine.dockerfile +52 -0
  145. dockerls/infrastructure/templates/hardening/go-debian.dockerfile +54 -0
  146. dockerls/infrastructure/templates/hardening/go-distroless.dockerfile +43 -0
  147. dockerls/infrastructure/templates/hardening/go-scratch.dockerfile +48 -0
  148. dockerls/infrastructure/templates/hardening/go.dockerfile +50 -0
  149. dockerls/infrastructure/templates/hardening/gradle-alpine.dockerfile +51 -0
  150. dockerls/infrastructure/templates/hardening/gradle.dockerfile +52 -0
  151. dockerls/infrastructure/templates/hardening/java-alpine.dockerfile +50 -0
  152. dockerls/infrastructure/templates/hardening/java-debian.dockerfile +50 -0
  153. dockerls/infrastructure/templates/hardening/java-distroless.dockerfile +39 -0
  154. dockerls/infrastructure/templates/hardening/java-ubuntu.dockerfile +54 -0
  155. dockerls/infrastructure/templates/hardening/java.dockerfile +60 -0
  156. dockerls/infrastructure/templates/hardening/maven-alpine.dockerfile +56 -0
  157. dockerls/infrastructure/templates/hardening/maven.dockerfile +57 -0
  158. dockerls/infrastructure/templates/hardening/node-alpine.dockerfile +47 -0
  159. dockerls/infrastructure/templates/hardening/node-debian.dockerfile +54 -0
  160. dockerls/infrastructure/templates/hardening/node-distroless.dockerfile +46 -0
  161. dockerls/infrastructure/templates/hardening/node-ubuntu.dockerfile +63 -0
  162. dockerls/infrastructure/templates/hardening/node.dockerfile +61 -0
  163. dockerls/infrastructure/templates/hardening/php-alpine.dockerfile +45 -0
  164. dockerls/infrastructure/templates/hardening/php-debian.dockerfile +45 -0
  165. dockerls/infrastructure/templates/hardening/php-ubuntu.dockerfile +49 -0
  166. dockerls/infrastructure/templates/hardening/php.dockerfile +44 -0
  167. dockerls/infrastructure/templates/hardening/python-alpine.dockerfile +51 -0
  168. dockerls/infrastructure/templates/hardening/python-debian.dockerfile +54 -0
  169. dockerls/infrastructure/templates/hardening/python-distroless.dockerfile +51 -0
  170. dockerls/infrastructure/templates/hardening/python-ubuntu.dockerfile +60 -0
  171. dockerls/infrastructure/templates/hardening/python.dockerfile +58 -0
  172. dockerls/infrastructure/templates/hardening/ruby-alpine.dockerfile +48 -0
  173. dockerls/infrastructure/templates/hardening/ruby-debian.dockerfile +50 -0
  174. dockerls/infrastructure/templates/hardening/rust-alpine.dockerfile +50 -0
  175. dockerls/infrastructure/templates/hardening/rust-debian.dockerfile +48 -0
  176. dockerls/infrastructure/templates/hardening/rust-scratch.dockerfile +44 -0
  177. dockerls/infrastructure/templates/hardening/rust.dockerfile +54 -0
  178. dockerls/infrastructure/templates/hardening/ubuntu.dockerfile +49 -0
  179. dockerls/infrastructure/toolchain/__init__.py +0 -0
  180. dockerls/infrastructure/toolchain/db_metadata.py +115 -0
  181. dockerls/infrastructure/toolchain/installer.py +435 -0
  182. dockerls/integrations/__init__.py +0 -0
  183. dockerls/integrations/dhi/__init__.py +0 -0
  184. dockerls/integrations/dhi/catalog.py +457 -0
  185. dockerls/integrations/dhi/definition.py +151 -0
  186. dockerls/integrations/dhi/repository.py +238 -0
  187. dockerls/integrations/dockerhub/__init__.py +0 -0
  188. dockerls/integrations/dockerhub/client.py +318 -0
  189. dockerls/integrations/dockerhub/urls.py +75 -0
  190. dockerls/integrations/endoflife/__init__.py +0 -0
  191. dockerls/integrations/endoflife/checker.py +216 -0
  192. dockerls/integrations/engine/__init__.py +0 -0
  193. dockerls/integrations/engine/batch.py +197 -0
  194. dockerls/integrations/engine/client.py +330 -0
  195. dockerls/integrations/engine/locator.py +96 -0
  196. dockerls/integrations/exploitdb/__init__.py +0 -0
  197. dockerls/integrations/exploitdb/client.py +271 -0
  198. dockerls/integrations/grype/__init__.py +0 -0
  199. dockerls/integrations/grype/scanner.py +336 -0
  200. dockerls/integrations/registry/__init__.py +0 -0
  201. dockerls/integrations/registry/hardened.py +284 -0
  202. dockerls/integrations/registry/inspector.py +420 -0
  203. dockerls/integrations/registry/oci.py +259 -0
  204. dockerls/integrations/registry/private.py +79 -0
  205. dockerls/integrations/registry/urls.py +36 -0
  206. dockerls/integrations/scan_errors.py +67 -0
  207. dockerls/integrations/scan_target.py +57 -0
  208. dockerls/integrations/signing/__init__.py +0 -0
  209. dockerls/integrations/signing/cosign.py +484 -0
  210. dockerls/integrations/threat_intel/__init__.py +0 -0
  211. dockerls/integrations/threat_intel/client.py +290 -0
  212. dockerls/integrations/trivy/__init__.py +0 -0
  213. dockerls/integrations/trivy/cache_pool.py +176 -0
  214. dockerls/integrations/trivy/scanner.py +447 -0
  215. dockerls/utils/__init__.py +0 -0
  216. dockerls/utils/auth.py +108 -0
  217. dockerls/utils/executables.py +39 -0
  218. dockerls/utils/ignore_file.py +130 -0
  219. dockerls/utils/rate_limit.py +130 -0
  220. dockerls/utils/resources.py +183 -0
  221. dockerls/utils/retry.py +31 -0
  222. dockerls/utils/safe_yaml.py +166 -0
  223. dockerls/utils/subprocess_runner.py +216 -0
  224. dockerls/utils/validation.py +72 -0
  225. dockerls-1.0.0.dist-info/METADATA +563 -0
  226. dockerls-1.0.0.dist-info/RECORD +230 -0
  227. dockerls-1.0.0.dist-info/WHEEL +5 -0
  228. dockerls-1.0.0.dist-info/entry_points.txt +2 -0
  229. dockerls-1.0.0.dist-info/licenses/LICENSE +21 -0
  230. 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