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,530 @@
1
+ from __future__ import annotations
2
+
3
+ from functools import lru_cache
4
+ from typing import TYPE_CHECKING
5
+
6
+ from loguru import logger
7
+
8
+ from dockerls.application.services.composite_repository import CompositeImageRepository
9
+ from dockerls.application.services.cross_validation import CrossValidator
10
+ from dockerls.application.services.hardening_analysis import HardeningAnalyzer
11
+ from dockerls.application.services.scanner_factory import ScannerFactory
12
+ from dockerls.application.services.source_registry import SourceRegistry, SourceSpec
13
+ from dockerls.application.use_cases.analyze_image import AnalyzeImageUseCase
14
+ from dockerls.application.use_cases.compare_images import CompareImagesUseCase
15
+ from dockerls.application.use_cases.recommend_images import RecommendImagesUseCase
16
+ from dockerls.application.use_cases.search_images import SearchImagesUseCase
17
+ from dockerls.cli.runtime import (
18
+ _settings,
19
+ configure_logging,
20
+ current_log_file,
21
+ enable_console_logging,
22
+ )
23
+ from dockerls.domain.entities.image import DOCKER_HUB
24
+ from dockerls.domain.value_objects.network_policy import NetworkPolicy
25
+ from dockerls.infrastructure.evidence import EvidenceStore
26
+ from dockerls.infrastructure.network.host_guard import HostGuard
27
+ from dockerls.integrations.dhi.catalog import DHICatalogClient
28
+ from dockerls.integrations.dhi.repository import DHI, DHIRepository
29
+ from dockerls.integrations.dockerhub.client import DockerHubClient
30
+ from dockerls.integrations.endoflife.checker import EndOfLifeChecker
31
+ from dockerls.integrations.exploitdb.client import ExploitDBClient
32
+ from dockerls.integrations.registry.hardened import (
33
+ CHAINGUARD,
34
+ DISTROLESS,
35
+ ChainguardRepository,
36
+ DistrolessRepository,
37
+ )
38
+ from dockerls.integrations.registry.inspector import RegistryInspector
39
+ from dockerls.integrations.registry.private import PRIVATE_REGISTRY, PrivateRegistryRepository
40
+ from dockerls.integrations.threat_intel.client import ThreatIntelClient
41
+ from dockerls.utils.auth import load_credentials
42
+ from dockerls.utils.resources import describe_capacity, recommended_workers
43
+ from dockerls.utils.validation import validate_threshold, validate_workers
44
+
45
+ if TYPE_CHECKING:
46
+ from collections.abc import Callable, Sequence
47
+
48
+ from dockerls.application.services.progress import ScanObserver
49
+ from dockerls.application.services.source_registry import SourceBuilder
50
+ from dockerls.cache.sqlite_cache import SQLiteCache
51
+ from dockerls.domain.interfaces.image_repository import ImageRepositoryInterface
52
+
53
+ # As Settings e o logging moram em `cli/runtime.py`, que não arrasta este
54
+ # módulo junto: o callback de bootstrap precisa deles antes de todo
55
+ # subcomando, e importar o contêiner inteiro para configurar um sink era o
56
+ # que fazia `dockerls version` custar o mesmo que `dockerls advisor`.
57
+ # Reexportados aqui porque todo chamador -- e todo teste -- já os importa
58
+ # deste módulo.
59
+ __all__ = [
60
+ "_settings",
61
+ "configure_logging",
62
+ "current_log_file",
63
+ "enable_console_logging",
64
+ ]
65
+
66
+
67
+ def resolve_workers(requested: int | None = None) -> int:
68
+ """How many scanner processes this run may hold at once.
69
+
70
+ Precedence: the command line, then the configured value, then the
71
+ machine. `0` in either of the first two means "ask the machine", which
72
+ is the default -- a scanner process is not a coroutine, and ten of them
73
+ on a two-core runner is slower than four, not faster.
74
+
75
+ An explicit value above what the machine can carry is honoured and
76
+ logged. Refusing it would be presumptuous: an operator scanning tiny
77
+ images, or one who has measured their own runner, is entitled to
78
+ overcommit. Doing it silently is what must not happen.
79
+ """
80
+ s = _settings()
81
+ configured = requested if requested is not None else s.workers
82
+ recommended = recommended_workers()
83
+
84
+ if not configured:
85
+ logger.info(
86
+ f"Using {recommended} scanner worker(s) for this machine "
87
+ f"({describe_capacity()}); set --workers to override"
88
+ )
89
+ return recommended
90
+
91
+ effective = validate_workers(configured, "--workers")
92
+ if effective > recommended:
93
+ logger.warning(
94
+ f"{effective} scanner workers requested on a machine with "
95
+ f"{describe_capacity()}; each worker holds a scanner process, so this may "
96
+ f"contend for CPU or memory. {recommended} is what this machine suggests."
97
+ )
98
+ return effective
99
+
100
+
101
+ def resolve_tag_limit(limit: int | None) -> int:
102
+ """`--limit` falls back to the configured `max_tags`."""
103
+ s = _settings()
104
+ return validate_threshold(s.max_tags if limit is None else limit, "--limit")
105
+
106
+
107
+ def build_evidence_store() -> EvidenceStore:
108
+ return EvidenceStore(_settings().evidence_dir)
109
+
110
+
111
+ async def build_repository(cache: SQLiteCache | None = None) -> DockerHubClient:
112
+ s = _settings()
113
+ username = s.dockerhub_username
114
+ token = s.dockerhub_token
115
+ if not username or not token:
116
+ username, token = load_credentials()
117
+
118
+ client = DockerHubClient(
119
+ username=username,
120
+ token=token,
121
+ timeout=s.http_timeout,
122
+ cache=cache,
123
+ max_attempts=s.retry_max_attempts,
124
+ backoff_base=s.retry_backoff_base,
125
+ tag_ttl_seconds=s.tag_cache_ttl_seconds,
126
+ guard=build_host_guard(),
127
+ )
128
+ if username and token:
129
+ await client.authenticate()
130
+ return client
131
+
132
+
133
+ @lru_cache(maxsize=1)
134
+ def build_cache() -> SQLiteCache:
135
+ # Import tardio: `SQLiteCache` puxa o SQLAlchemy, que sozinho responde por
136
+ # cerca de um segundo do arranque do processo. Comandos que nunca tocam o
137
+ # cache -- `version`, `--help`, `controls`, `policy` -- pagavam esse
138
+ # segundo em toda invocação, e um segundo de espera antes de um `--help` é
139
+ # o tipo de coisa que faz uma ferramenta parecer pesada sem ser.
140
+ from dockerls.cache.sqlite_cache import SQLiteCache
141
+
142
+ s = _settings()
143
+ return SQLiteCache(s.db_path)
144
+
145
+
146
+ def close_cache() -> None:
147
+ """Dispose the shared SQLite engine, if a command ever built one.
148
+
149
+ Called once, after the command finishes (see `cli/app.py`). Every
150
+ caller of `build_cache()` -- `recommend`, `cache`, `registry-audit`,
151
+ `_threat_intel`, `_exploitdb` -- gets the same memoized instance, so
152
+ there is exactly one engine to close per process, and a command that
153
+ never touched the cache (`version`, `--help`) never built one: this is
154
+ then a no-op that costs nothing.
155
+ """
156
+ if build_cache.cache_info().currsize:
157
+ build_cache().close()
158
+ build_cache.cache_clear()
159
+
160
+
161
+ @lru_cache(maxsize=1)
162
+ def _threat_intel() -> ThreatIntelClient | None:
163
+ """KEV catalogue and EPSS scores, cached to disk like Exploit-DB below.
164
+
165
+ Both feeds move roughly once a day, so without a disk cache every single
166
+ invocation re-downloaded the whole KEV catalogue and re-queried FIRST.org
167
+ for every CRITICAL/HIGH CVE from scratch -- including two `recommend`
168
+ runs back to back against the same image a minute apart.
169
+ """
170
+ s = _settings()
171
+ if not s.enable_threat_intel:
172
+ return None
173
+ return ThreatIntelClient(timeout=s.http_timeout, cache=build_cache())
174
+
175
+
176
+ @lru_cache(maxsize=1)
177
+ def _exploitdb() -> ExploitDBClient | None:
178
+ """O catálogo do Exploit-DB, atrás da mesma chave que KEV/EPSS.
179
+
180
+ Segue `enable_threat_intel` porque responde à mesma pergunta -- quão
181
+ explorável é isto -- e quem desliga o enriquecimento não quer que este
182
+ fique de fora. Recebe o cache em disco pelo mesmo motivo do
183
+ `ThreatIntelClient`: o CSV tem cerca de 10 MB, e rebaixá-lo a cada
184
+ invocação seria pagar o download inteiro para reler o mesmo dia de
185
+ catálogo.
186
+ """
187
+ s = _settings()
188
+ if not s.enable_threat_intel:
189
+ return None
190
+ return ExploitDBClient(timeout=s.http_timeout, cache=build_cache(), guard=build_host_guard())
191
+
192
+
193
+ def build_source_registry(cache: SQLiteCache | None = None) -> SourceRegistry:
194
+ """Every catalogue this build can search, keyed by its `--source` token.
195
+
196
+ This is the one place that knows the full set. Commands ask the registry
197
+ to resolve a selection; none of them names a provider, so adding one is
198
+ a `register()` call here and nothing else.
199
+ """
200
+ s = _settings()
201
+ registry = SourceRegistry()
202
+ registry.register(
203
+ SourceSpec(
204
+ name="dockerhub",
205
+ label=DOCKER_HUB,
206
+ build=lambda: build_repository(cache=cache),
207
+ primary=True,
208
+ description="Docker Hub (official and community images)",
209
+ )
210
+ )
211
+ registry.register(
212
+ SourceSpec(
213
+ name="chainguard",
214
+ label=CHAINGUARD,
215
+ build=_source_builder(
216
+ lambda: ChainguardRepository(timeout=s.http_timeout, guard=build_host_guard())
217
+ ),
218
+ default_enabled=s.include_hardened_sources,
219
+ description="Chainguard free tier (cgr.dev)",
220
+ )
221
+ )
222
+ registry.register(
223
+ SourceSpec(
224
+ name="distroless",
225
+ label=DISTROLESS,
226
+ build=_source_builder(
227
+ lambda: DistrolessRepository(timeout=s.http_timeout, guard=build_host_guard())
228
+ ),
229
+ default_enabled=s.include_hardened_sources,
230
+ description="Google Distroless (gcr.io/distroless)",
231
+ )
232
+ )
233
+ registry.register(
234
+ SourceSpec(
235
+ name="dhi",
236
+ label=DHI,
237
+ build=_source_builder(
238
+ lambda: DHIRepository(
239
+ catalog=DHICatalogClient(
240
+ timeout=s.http_timeout,
241
+ cache=cache,
242
+ ttl_seconds=s.dhi_catalog_ttl_seconds,
243
+ token=s.github_token,
244
+ ),
245
+ definition_limit=s.dhi_definition_limit,
246
+ )
247
+ ),
248
+ # Off unless asked for: dhi.io refuses anonymous pulls, so its
249
+ # candidates cannot be scanned on a machine without Docker
250
+ # Hardened Images credentials, and an unscannable candidate is
251
+ # reported as UNVERIFIED rather than ranked.
252
+ default_enabled=s.include_dhi_source,
253
+ requires_auth=True,
254
+ description="Docker Hardened Images catalog (dhi.io, needs credentials to scan)",
255
+ )
256
+ )
257
+ if s.private_registry_host:
258
+ # Registered only when a host is actually configured: an option
259
+ # with nothing behind it would make `--source private` fail with
260
+ # "no repository named that" instead of the CLI's own unknown-
261
+ # source message, and would list a source in `--help`/`doctor`
262
+ # that cannot do anything.
263
+ registry.register(
264
+ SourceSpec(
265
+ name="private",
266
+ label=PRIVATE_REGISTRY,
267
+ build=_source_builder(
268
+ lambda: PrivateRegistryRepository(
269
+ s.private_registry_host,
270
+ s.private_registry_namespace,
271
+ timeout=s.http_timeout,
272
+ guard=build_host_guard(),
273
+ username=s.private_registry_username,
274
+ password=s.private_registry_password,
275
+ )
276
+ ),
277
+ default_enabled=False,
278
+ requires_auth=bool(s.private_registry_username),
279
+ description=f"Private registry ({s.private_registry_host})",
280
+ )
281
+ )
282
+ return registry
283
+
284
+
285
+ def _source_builder(factory: Callable[[], ImageRepositoryInterface]) -> SourceBuilder:
286
+ """Adapt a synchronous constructor to the registry's async builder."""
287
+
288
+ async def build() -> ImageRepositoryInterface:
289
+ return factory()
290
+
291
+ return build
292
+
293
+
294
+ async def build_composite_repository(specs: list[SourceSpec]) -> CompositeImageRepository:
295
+ """Instantiate the resolved sources and fan a query across them.
296
+
297
+ The first spec is the primary and gets the full `--limit`; the rest are
298
+ capped at `hardened_tag_limit`, because a curated catalogue publishes a
299
+ handful of tags where Docker Hub publishes hundreds.
300
+ """
301
+ if not specs:
302
+ raise ValueError("at least one image source must be selected")
303
+ built = [await spec.build() for spec in specs]
304
+ return CompositeImageRepository(built[0], built[1:], extra_limit=_settings().hardened_tag_limit)
305
+
306
+
307
+ async def build_sources(
308
+ selection: Sequence[str] | None = None,
309
+ *,
310
+ all_sources: bool = False,
311
+ include_hardened: bool | None = None,
312
+ cache: SQLiteCache | None = None,
313
+ ) -> CompositeImageRepository:
314
+ """Resolve a `--source`/`--all-sources` selection into a live repository.
315
+
316
+ One entry point for every command, so `search`, `recommend`,
317
+ `alternatives` and `advisor` cannot drift into searching different sets
318
+ of catalogues for the same flags.
319
+ """
320
+ s = _settings()
321
+ registry = build_source_registry(cache=cache)
322
+ specs = registry.resolve(
323
+ selection,
324
+ all_sources=all_sources,
325
+ include_optional=(
326
+ s.include_hardened_sources if include_hardened is None else include_hardened
327
+ ),
328
+ )
329
+ return await build_composite_repository(specs)
330
+
331
+
332
+ def available_source_names() -> list[str]:
333
+ """`--source` choices, for help text and error messages."""
334
+ return build_source_registry().names
335
+
336
+
337
+ async def build_recommend_use_case(
338
+ max_critical: int | None = None,
339
+ max_high: int | None = None,
340
+ max_medium: int | None = None,
341
+ workers: int | None = None,
342
+ observer: ScanObserver | None = None,
343
+ cross_validate: bool | None = None,
344
+ verify_hub_tags: bool | None = None,
345
+ include_hardened: bool | None = None,
346
+ use_cache: bool = True,
347
+ sources: Sequence[str] | None = None,
348
+ all_sources: bool = False,
349
+ scan_budget: int | None = None,
350
+ ) -> RecommendImagesUseCase:
351
+ s = _settings()
352
+ # None means "not given on the command line", so the configured value
353
+ # applies. Previously these carried hard-coded defaults that shadowed
354
+ # Settings entirely, which made DOCKERLS_MAX_MEDIUM and the config file
355
+ # silently do nothing.
356
+ max_critical = validate_threshold(
357
+ s.max_critical if max_critical is None else max_critical, "--max-critical"
358
+ )
359
+ max_high = validate_threshold(s.max_high if max_high is None else max_high, "--max-high")
360
+ max_medium = validate_threshold(
361
+ s.max_medium if max_medium is None else max_medium, "--max-medium"
362
+ )
363
+ workers = resolve_workers(workers)
364
+
365
+ # `--no-cache` força uma medição nova: o cache é uma otimização, e às
366
+ # vezes o que se quer é justamente contorná-lo.
367
+ cache = build_cache() if use_cache else None
368
+ repo = await build_sources(
369
+ sources,
370
+ all_sources=all_sources,
371
+ include_hardened=include_hardened,
372
+ cache=cache,
373
+ )
374
+ evidence = build_evidence_store()
375
+ scanner = await ScannerFactory.create(
376
+ timeout=s.scanner_timeout,
377
+ workers=workers,
378
+ cache_dir=s.trivy_cache_dir,
379
+ evidence=evidence,
380
+ guard=build_host_guard(),
381
+ )
382
+ eol = EndOfLifeChecker(
383
+ timeout=s.http_timeout,
384
+ max_attempts=s.retry_max_attempts,
385
+ backoff_base=s.retry_backoff_base,
386
+ )
387
+
388
+ secondary = None
389
+ if s.cross_validate if cross_validate is None else cross_validate:
390
+ secondary = await ScannerFactory.create_secondary(
391
+ scanner,
392
+ timeout=s.scanner_timeout,
393
+ evidence=evidence,
394
+ guard=build_host_guard(),
395
+ # O mesmo teto do passo principal: a cross-validação roda depois
396
+ # dele e herda o orçamento, em vez de abrir um segundo maior.
397
+ workers=min(resolve_workers(s.cross_validate_workers or None), workers),
398
+ )
399
+
400
+ return RecommendImagesUseCase(
401
+ repository=repo,
402
+ hardening=build_hardening_analyzer(),
403
+ resolve_digests=s.resolve_digests,
404
+ scanner=scanner,
405
+ eol_checker=eol,
406
+ cache=cache,
407
+ max_critical=max_critical,
408
+ max_high=max_high,
409
+ max_medium=max_medium,
410
+ workers=workers,
411
+ threat_intel=_threat_intel(),
412
+ exploitdb=_exploitdb(),
413
+ observer=observer,
414
+ cross_validator=CrossValidator(
415
+ secondary,
416
+ # Capped at the primary worker count as well as the machine's:
417
+ # cross-validation runs after the main pass, so it inherits the
418
+ # same budget rather than opening a second, larger one.
419
+ workers=min(resolve_workers(s.cross_validate_workers or None), workers),
420
+ ),
421
+ evidence=evidence,
422
+ verify_hub_tags=s.verify_hub_tags if verify_hub_tags is None else verify_hub_tags,
423
+ log_file=current_log_file(),
424
+ cache_ttl_seconds=s.cache_ttl_seconds,
425
+ scan_budget=s.scan_budget if scan_budget is None else scan_budget,
426
+ )
427
+
428
+
429
+ def build_host_guard() -> HostGuard:
430
+ """Where a reference is allowed to make this process connect.
431
+
432
+ Built here rather than on `Settings` so the settings object stays a
433
+ plain data holder and the policy is assembled in the one place that
434
+ assembles everything else.
435
+ """
436
+ s = _settings()
437
+ return HostGuard(
438
+ NetworkPolicy(
439
+ allow_private_networks=s.network_allow_private_networks,
440
+ allow_loopback=s.network_allow_loopback,
441
+ allow_link_local=s.network_allow_link_local,
442
+ allowed_hosts=frozenset(s.network_allowed_hosts),
443
+ )
444
+ )
445
+
446
+
447
+ def build_registry_credentials() -> dict[str, tuple[str, str]]:
448
+ """Host -> (username, password) for every registry this run has
449
+ credentials for -- the configured private registry, today.
450
+
451
+ `RegistryInspector` resolves a reference by its host, not by a
452
+ `--source` name, so `analyze`/`compare`/`alternatives` -- which take a
453
+ reference directly, never a source -- reach the same credentials
454
+ `--source private` uses for `recommend`/`search` through this instead
455
+ of a second, separate configuration.
456
+ """
457
+ s = _settings()
458
+ if not (s.private_registry_host and s.private_registry_username):
459
+ return {}
460
+ return {s.private_registry_host: (s.private_registry_username, s.private_registry_password)}
461
+
462
+
463
+ def build_hardening_analyzer() -> HardeningAnalyzer:
464
+ """The registry-backed evidence gatherer, or a disabled one.
465
+
466
+ With `inspect_image_config` off the analyzer still exists but has no
467
+ inspector, so every hardening fact stays UNKNOWN and every dimension
468
+ reports as not determined -- which is the honest result of choosing not
469
+ to look, and is very different from reporting an image as clean.
470
+ """
471
+ s = _settings()
472
+ inspector = (
473
+ RegistryInspector(
474
+ timeout=s.http_timeout,
475
+ guard=build_host_guard(),
476
+ credentials=build_registry_credentials(),
477
+ )
478
+ if s.inspect_image_config
479
+ else None
480
+ )
481
+ return HardeningAnalyzer(inspector=inspector)
482
+
483
+
484
+ async def build_analyze_use_case() -> AnalyzeImageUseCase:
485
+ s = _settings()
486
+ repo = await build_repository()
487
+ scanner = await ScannerFactory.create(timeout=s.scanner_timeout, guard=build_host_guard())
488
+ eol = EndOfLifeChecker(
489
+ timeout=s.http_timeout,
490
+ max_attempts=s.retry_max_attempts,
491
+ backoff_base=s.retry_backoff_base,
492
+ )
493
+ # Import tardio, como o resto do módulo: essas duas stores só custam algo
494
+ # quando algum comando de fato as usa.
495
+ from dockerls.application.services.scan_history_store import ScanHistoryStore
496
+ from dockerls.application.services.tag_history_store import TagHistoryStore
497
+
498
+ cache = build_cache()
499
+ return AnalyzeImageUseCase(
500
+ repository=repo,
501
+ scanner=scanner,
502
+ eol_checker=eol,
503
+ threat_intel=_threat_intel(),
504
+ exploitdb=_exploitdb(),
505
+ hardening=build_hardening_analyzer(),
506
+ tag_history=TagHistoryStore(cache),
507
+ scan_history=ScanHistoryStore(cache),
508
+ )
509
+
510
+
511
+ async def build_compare_use_case() -> CompareImagesUseCase:
512
+ analyze = await build_analyze_use_case()
513
+ return CompareImagesUseCase(analyze_use_case=analyze)
514
+
515
+
516
+ async def build_search_use_case(
517
+ sources: Sequence[str] | None = None,
518
+ *,
519
+ all_sources: bool = False,
520
+ ) -> SearchImagesUseCase:
521
+ """`search` goes through its use case like every other command, so the
522
+ CLI never reaches past the application layer into a repository.
523
+
524
+ With no selection this is Docker Hub alone, which is what `search` has
525
+ always been: a listing of one repository's tags. `--source`/
526
+ `--all-sources` widen it to the same catalogues `recommend` searches.
527
+ """
528
+ if sources is None and not all_sources:
529
+ return SearchImagesUseCase(repository=await build_repository())
530
+ return SearchImagesUseCase(repository=await build_sources(sources, all_sources=all_sources))
@@ -0,0 +1,79 @@
1
+ """O nome que aparece na tabela, sem o registry que a coluna ao lado já diz.
2
+
3
+ A tabela de resultados tem treze colunas e nenhuma largura sobrando. Com
4
+ `overflow="fold"`, um nome como `gcr.io/distroless/nodejs22-debian12` era
5
+ quebrado no meio da palavra e saía em duas ou três linhas ilegíveis --
6
+ enquanto a coluna `Source`, encostada nele, já dizia "Distroless".
7
+
8
+ Encurtar aqui não é cosmético: o leitor precisa reconhecer *que runtime é
9
+ aquele* de relance, e é justamente essa metade que se perdia. `nodejs22-debian12`
10
+ diz Node 22 sobre Debian 12; `gcr.io/distrole` / `ss/nodejs22-de` não diz nada.
11
+
12
+ O corte só acontece quando a coluna vizinha carrega a informação removida.
13
+ Um registry que a tabela não identifica -- `ghcr.io/org/app` -- é mostrado
14
+ inteiro, porque ali o host *é* a identidade e escondê-lo confundiria duas
15
+ imagens diferentes com o mesmo nome final.
16
+
17
+ A referência completa não se perde em lugar nenhum: `--format json`, a linha
18
+ `Pin to:` e a evidência continuam com o nome inteiro, que é o que alguém
19
+ copia para um Dockerfile.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from dockerls.domain.value_objects.image_reference import (
25
+ split_repository_and_tag,
26
+ )
27
+
28
+ #: Prefixos de catálogos que a coluna `Source` já nomeia.
29
+ _REDUNDANT_PREFIXES = (
30
+ "cgr.dev/chainguard/",
31
+ "gcr.io/distroless/",
32
+ "dhi.io/",
33
+ "docker.io/library/",
34
+ "index.docker.io/library/",
35
+ )
36
+
37
+
38
+ def display_name(name: str) -> str:
39
+ """O nome do runtime, sem o prefixo que a coluna `Source` repete."""
40
+ value = name.strip()
41
+ for prefix in _REDUNDANT_PREFIXES:
42
+ if value.lower().startswith(prefix):
43
+ return value[len(prefix) :] or value
44
+ return value
45
+
46
+
47
+ def display_reference(name: str, tag: str) -> str:
48
+ short = display_name(name)
49
+ return f"{short}:{tag}" if tag else short
50
+
51
+
52
+ def reject_tagged_reference(image: str, command: str) -> str | None:
53
+ """An error message when `image` names a tag instead of a bare
54
+ repository, or None when the argument is fine as given.
55
+
56
+ `search`, `recommend` and `export` all take a repository name and
57
+ discover its tags themselves; a caller who passes `node:18` almost
58
+ certainly meant to inspect that one tag, not search for a repository
59
+ literally named `node:18` (which does not exist and used to fail with
60
+ an opaque "No tags found").
61
+ """
62
+ repository, tag = split_repository_and_tag(image)
63
+ if not tag:
64
+ return None
65
+ return (
66
+ f"Error: '{command}' takes the image name, not a specific tag. "
67
+ f"Did you mean 'dockerls {command} {repository}'? "
68
+ f"To analyze one specific tag, use 'dockerls analyze {image}'."
69
+ )
70
+
71
+
72
+ # Reexportado: a regra mora no domínio (a `application` também precisa dela),
73
+ # e os chamadores da CLI seguem importando daqui.
74
+ __all__ = [
75
+ "display_name",
76
+ "display_reference",
77
+ "reject_tagged_reference",
78
+ "split_repository_and_tag",
79
+ ]
@@ -0,0 +1,42 @@
1
+ """Shared option types for the CLI.
2
+
3
+ Modelling the choice as a StrEnum keeps the accepted values in one place, so
4
+ a plain ``str`` compared with ``== "json"`` can no longer fall through to the
5
+ default branch on a typo like ``--format jsonn``.
6
+
7
+ The value is parsed in the command body rather than declared as a Typer enum
8
+ parameter on purpose. Typer rejects an unknown choice with **exit code 2**,
9
+ and 2 is not free in this CLI: ``recommend`` publishes it as "no image met
10
+ the baseline, but alternatives were found". A CI gate keying on the exit
11
+ code would read a typo in a flag as a security verdict. Parsing here keeps
12
+ usage errors on the shared operational code (1), which is what they are.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from enum import StrEnum
18
+
19
+ import typer
20
+ from rich.console import Console
21
+
22
+ from dockerls.exit_codes import EXIT_ERROR
23
+
24
+ _console = Console()
25
+
26
+
27
+ class OutputFormat(StrEnum):
28
+ TABLE = "table"
29
+ JSON = "json"
30
+
31
+
32
+ def parse_output_format(value: str) -> OutputFormat:
33
+ """Resolve ``--format`` or fail with an actionable message and exit 1."""
34
+ try:
35
+ return OutputFormat(value)
36
+ except ValueError as e:
37
+ choices = ", ".join(f.value for f in OutputFormat)
38
+ _console.print(
39
+ f"[red]Error:[/red] unsupported --format {value!r}.\n"
40
+ f"[dim]Suggested action: use one of: {choices}[/dim]"
41
+ )
42
+ raise typer.Exit(EXIT_ERROR) from e