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
dockerls/__init__.py ADDED
@@ -0,0 +1,31 @@
1
+ """DockerLs: Enterprise Docker Image Security Advisor."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ __all__ = ["__version__"]
8
+
9
+
10
+ def __getattr__(name: str) -> Any:
11
+ """Resolve `__version__` sob demanda.
12
+
13
+ `importlib.metadata` custa ~24ms para importar, e este `__init__` roda
14
+ antes de qualquer `dockerls.*` -- ou seja, todo comando da CLI pagava
15
+ esse preço, inclusive os que nunca mostram a versão. Com o
16
+ `__getattr__` de módulo (PEP 562) quem escreve
17
+ `from dockerls import __version__` continua funcionando igual, e quem
18
+ não escreve não paga.
19
+ """
20
+ if name != "__version__":
21
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
22
+
23
+ from importlib.metadata import PackageNotFoundError, version
24
+
25
+ try:
26
+ resolved = version("dockerls")
27
+ except PackageNotFoundError:
28
+ # Editable/dev checkout without an installed distribution record.
29
+ resolved = "0.0.0+dev"
30
+ globals()["__version__"] = resolved
31
+ return resolved
File without changes
@@ -0,0 +1,3 @@
1
+ from dockerls.application.dto.analysis import AnalysisResult, ComparisonResult, ImageAnalysis
2
+
3
+ __all__ = ["AnalysisResult", "ImageAnalysis", "ComparisonResult"]
@@ -0,0 +1,257 @@
1
+ from __future__ import annotations
2
+
3
+ from pydantic import BaseModel, Field
4
+
5
+ from dockerls.domain.entities.image import DockerImage
6
+ from dockerls.domain.entities.image_facts import HardeningFacts
7
+ from dockerls.domain.entities.recommendation import Recommendation
8
+ from dockerls.domain.entities.scan_result import ScanResult
9
+ from dockerls.domain.entities.vulnerability import Vulnerability
10
+ from dockerls.domain.value_objects.confidence import Confidence
11
+ from dockerls.domain.value_objects.scan_plan import DeferredTag
12
+ from dockerls.domain.value_objects.tristate import Tristate
13
+
14
+
15
+ class DimensionReport(BaseModel):
16
+ """A derived score plus everything needed to defend it.
17
+
18
+ Both the hardening and attack-surface models are computed over the
19
+ facts that could be determined, so the number alone is not enough: a
20
+ reader needs the coverage it was computed at, and the named findings on
21
+ either side. Carrying them together means the terminal, the JSON output
22
+ and every exporter show the same defence of the same number.
23
+ """
24
+
25
+ score: float = 0.0
26
+ #: Share of the model that could be determined, 0.0-1.0.
27
+ coverage: float = 0.0
28
+ #: False when coverage was too thin for the score to mean anything. The
29
+ #: renderers show "n/a" rather than a confident-looking number.
30
+ reportable: bool = False
31
+ #: Determined properties that counted in the image's favour.
32
+ positives: list[str] = Field(default_factory=list)
33
+ #: Determined properties that counted against it.
34
+ negatives: list[str] = Field(default_factory=list)
35
+ #: Properties nothing could establish, named rather than omitted.
36
+ undetermined: list[str] = Field(default_factory=list)
37
+
38
+
39
+ class ImageAnalysis(BaseModel):
40
+ image: DockerImage
41
+ scan: ScanResult
42
+ security_score: float
43
+ tier: str
44
+ remediation_score: int
45
+ # The domain's verdict. Written **only** by the central
46
+ # ProductionReadiness policy (see application/services/verdict.py), never
47
+ # by the tier: the tier can see the score and nothing else, so it cannot
48
+ # tell a clean image from an image nobody managed to scan. Defaults to
49
+ # False so an analysis that never reached the policy is not ready by
50
+ # omission rather than ready by omission.
51
+ production_ready: bool = False
52
+ #: Stable codes for every rule the image failed (NOT_MEASURED,
53
+ #: END_OF_LIFE, ...), so a pipeline can branch without parsing prose.
54
+ readiness_blockers: list[str] = Field(default_factory=list)
55
+ #: The same blockers in the reader's terms.
56
+ readiness_reasons: list[str] = Field(default_factory=list)
57
+ # Kept as the boolean every exporter and template already reads. It
58
+ # answers False both for "supported" and for "nobody could tell", which
59
+ # is why `eol_status` carries the three-valued truth beside it.
60
+ is_eol: bool = False
61
+ #: TRUE / FALSE / UNKNOWN. An unknown lifecycle does not penalise the
62
+ #: score -- there is nothing to penalise -- but it is never spent as if
63
+ #: it were a confirmation that the release is still supported.
64
+ eol_status: Tristate = Tristate.UNKNOWN
65
+ is_lts: bool = False
66
+ recommendation: Recommendation | None = None
67
+ # Set when a second scanner disagreed materially with the primary one.
68
+ # A non-empty value means the score must be presented as disputed.
69
+ scan_divergence: str = ""
70
+ #: AGREEMENT / MINOR_DIVERGENCE / MATERIAL_DIVERGENCE / NO_SECOND_SCANNER.
71
+ #: Distinct from `scan_divergence`, which stays reserved for the material
72
+ #: case: two databases differing on a finding or two is ordinary, and
73
+ #: calling that "disputed" would make every image look contested.
74
+ cross_validation: str = "NO_SECOND_SCANNER"
75
+ #: Which findings differed, named, so the disagreement can be checked.
76
+ cross_validation_detail: str = ""
77
+ # Docker Hub linkage. `hub_tag_verified` is deliberately tri-state:
78
+ # True = confirmed present, False = confirmed absent, None = not checked
79
+ # (image not on Docker Hub, or verification unavailable).
80
+ hub_url: str = ""
81
+ hub_tag_verified: bool | None = None
82
+ # scanner name -> raw scan JSON path, backing the score shown above.
83
+ evidence_paths: dict[str, str] = Field(default_factory=dict)
84
+
85
+ # --- Multi-dimensional assessment ------------------------------------
86
+ # The evidence record every derived dimension below is computed from,
87
+ # carried so a consumer can recompute them or apply its own policy.
88
+ facts: HardeningFacts = Field(default_factory=HardeningFacts)
89
+ # How well the image is configured, independently of its CVE counts.
90
+ hardening: DimensionReport = Field(default_factory=DimensionReport)
91
+ # How much an attacker inherits inside the container. Higher is *worse*.
92
+ attack_surface: DimensionReport = Field(default_factory=DimensionReport)
93
+ # How much the evidence behind all of the above is worth. Defaults to
94
+ # UNVERIFIED so an analysis that skipped assessment can never read as
95
+ # trustworthy by omission.
96
+ confidence: Confidence = Confidence.UNVERIFIED
97
+ confidence_reasons: list[str] = Field(default_factory=list)
98
+ # Plain-language reasons this image ranked where it did, so a
99
+ # recommendation never reduces to an unexplained number.
100
+ why: list[str] = Field(default_factory=list)
101
+ # Costs and caveats of moving to this image, stated alongside the
102
+ # reasons. A recommendation that lists only upsides is advertising.
103
+ trade_offs: list[str] = Field(default_factory=list)
104
+ # Set when this tag has previously been observed (by an earlier run) on
105
+ # a *different* digest than the one just resolved. Empty when this is
106
+ # the first time this tag was seen, or when it has stayed on the same
107
+ # digest since. `base` already reports this for Dockerfile-pinned bases
108
+ # (see `tag_history.py`/`base_cmd.py`); this is the same fact for a tag
109
+ # looked up directly with `analyze`.
110
+ tag_drift_note: str = ""
111
+ # Set when a previous `analyze` run of this exact reference recorded
112
+ # different vulnerability counts than this scan just found. Empty on the
113
+ # first scan ever recorded, or when the counts are unchanged since the
114
+ # last one. Unlike `tag_drift_note`, this applies to a digest reference
115
+ # too: the same bytes can gain a CVE between two scans as the scanner's
116
+ # database learns about it.
117
+ vuln_trend_note: str = ""
118
+
119
+ @property
120
+ def pinned_reference(self) -> str:
121
+ """What to actually deploy: digest-pinned when one was resolved."""
122
+ return self.image.pinned_reference
123
+
124
+
125
+ class BaselineCriteria(BaseModel):
126
+ """The exact thresholds an image had to clear to count as a match.
127
+
128
+ Carried on the result so "no image found matching baseline" can state
129
+ what the baseline actually was instead of leaving the user to guess.
130
+ """
131
+
132
+ max_critical: int
133
+ max_high: int
134
+ max_medium: int
135
+
136
+ def describe(self) -> str:
137
+ return (
138
+ f"{self.max_critical} Critical, "
139
+ f"{self.max_high} High, "
140
+ f"{self.max_medium} Medium (and not EOL)"
141
+ )
142
+
143
+
144
+ class UnverifiedImage(BaseModel):
145
+ """A tag that could not be scanned successfully.
146
+
147
+ These never carry a score or a tier -- an image with no proof of a
148
+ successful scan is reported as unverified, not ranked.
149
+ """
150
+
151
+ image_reference: str
152
+ status: str
153
+ reason: str
154
+ # Causa classificada (DB_INIT_FAILED, TIMEOUT, NOT_FOUND, ...). O terminal
155
+ # mostra isto; `reason` guarda o stderr completo para log e --format json.
156
+ kind: str = "UNKNOWN"
157
+
158
+
159
+ class RunMetrics(BaseModel):
160
+ """What the run actually did, as opposed to what it found.
161
+
162
+ The pipeline already knew every one of these numbers and discarded all
163
+ of them, so "why did that take four minutes" and "is the cache working"
164
+ were unanswerable from the outside. They are the difference between
165
+ tags *discovered* and scans *performed*, which the digest deduplication
166
+ and the cache can make very different.
167
+
168
+ Carried on the result rather than printed, so `--format json` and the
169
+ terminal report the same figures.
170
+ """
171
+
172
+ tags_discovered: int = 0
173
+ # Tags left after collapsing those that share a manifest digest. The gap
174
+ # between this and `tags_discovered` is what deduplication saved.
175
+ unique_digests: int = 0
176
+ cache_hits: int = 0
177
+ # Scanner invocations actually made, excluding cache hits and duplicates.
178
+ scans_performed: int = 0
179
+ cross_validations: int = 0
180
+ workers: int = 0
181
+ # Tags that arrived without a digest and were pinned to one. Each is a
182
+ # registry HEAD that buys deduplication across every source.
183
+ digests_resolved: int = 0
184
+ # Candidates whose OCI config was fetched and verified, which is what
185
+ # makes their hardening facts measurements rather than claims.
186
+ images_inspected: int = 0
187
+ #: Which scanner, at which version, produced this run's measurements.
188
+ #: Reported rather than assumed: two runs of the same command against the
189
+ #: same image are only comparable if this matches.
190
+ scanner_identity: str = ""
191
+
192
+ @property
193
+ def duplicates_collapsed(self) -> int:
194
+ return max(0, self.tags_discovered - self.unique_digests)
195
+
196
+ @property
197
+ def cache_hit_rate(self) -> float:
198
+ """Share of candidates answered from cache, 0.0-1.0."""
199
+ considered = self.cache_hits + self.scans_performed
200
+ return self.cache_hits / considered if considered else 0.0
201
+
202
+
203
+ class AnalysisResult(BaseModel):
204
+ query: str
205
+ total_tags_scanned: int
206
+ baseline_met: bool
207
+ recommendations: list[ImageAnalysis] = []
208
+ alternatives: list[ImageAnalysis] = []
209
+ errors: list[str] = []
210
+ # Run accounting, used to render the summary line above the table.
211
+ total_tags_analyzed: int = 0
212
+ unverified: list[UnverifiedImage] = []
213
+ log_file: str = ""
214
+ evidence_manifest: str = ""
215
+ baseline: BaselineCriteria | None = None
216
+ # Catalogues that returned at least one candidate for this query.
217
+ sources_searched: list[str] = []
218
+ metrics: RunMetrics = Field(default_factory=RunMetrics)
219
+ #: Tags que a busca encontrou e que este run deliberadamente **não
220
+ #: mediu**, com o motivo de cada uma. Deliberadamente separado de
221
+ #: `unverified`: ali estão as medições que falharam, aqui as que nunca
222
+ #: foram tentadas. As duas são ausência de medição, e nenhuma das duas
223
+ #: é um veredito sobre a imagem -- mas confundi-las esconderia que uma
224
+ #: é escolha desta ferramenta e a outra é uma falha.
225
+ deferred: list[DeferredTag] = []
226
+ #: Quantas tags a busca trouxe, antes de qualquer corte.
227
+ tags_discovered: int = 0
228
+
229
+ @property
230
+ def unverified_count(self) -> int:
231
+ return len(self.unverified)
232
+
233
+ @property
234
+ def deferred_count(self) -> int:
235
+ return len(self.deferred)
236
+
237
+
238
+ class ComparisonResult(BaseModel):
239
+ """O que a comparação mediu, e o que ela não conseguiu medir.
240
+
241
+ `images` carrega **apenas** as imagens cujo scan completou. Uma imagem
242
+ que ninguém conseguiu escanear não entra aqui em hipótese alguma: ela
243
+ tem `security_score` 0.0 e tier F por construção (o fallback de
244
+ `AnalyzeImageUseCase`), e uma linha na tabela de comparação com esses
245
+ valores afirma que a imagem foi medida e foi mal -- que é exatamente a
246
+ substituição que esta ferramenta existe para não fazer. As que
247
+ falharam ficam em `unverified`, com a causa classificada.
248
+ """
249
+
250
+ images: list[ImageAnalysis]
251
+ winner: str = ""
252
+ summary: str = ""
253
+ common_vulns: list[Vulnerability] = []
254
+ unique_vulns: dict[str, list[Vulnerability]] = {}
255
+ #: As referências pedidas que não puderam ser medidas, na ordem em que
256
+ #: foram pedidas. Nunca recebem score nem tier.
257
+ unverified: list[UnverifiedImage] = []
File without changes
@@ -0,0 +1,167 @@
1
+ """Achar uma alternativa **medida** para uma imagem, e dizer o que ela custa.
2
+
3
+ O `alternatives` já respondia isto para uma referência digitada à mão. O que
4
+ faltava era ligar a resposta ao lugar onde a pergunta nasce: o `FROM` de um
5
+ Dockerfile. O `base` sabia dizer que uma base apodreceu e sabia atualizar o
6
+ digest -- e continuava propondo a mesma imagem, mais nova. Trocar `node:22`
7
+ por `node:22` de ontem resolve a data e não resolve a escolha.
8
+
9
+ Este serviço é a metade compartilhada entre os dois comandos, e existe para
10
+ que eles não divirjam: a mesma definição de "melhor", o mesmo baseline medido,
11
+ os mesmos trade-offs impressos ao lado dos ganhos.
12
+
13
+ Duas recusas o definem:
14
+
15
+ * **Sem baseline medido não há sugestão.** Se a imagem atual não pôde ser
16
+ escaneada, não há como afirmar que outra é melhor. O serviço devolve o
17
+ motivo, e quem chama reporta isso -- nunca uma alternativa apresentada
18
+ contra um baseline desconhecido.
19
+ * **Uma alternativa pior não é escondida.** O plano de migração carrega
20
+ `score_delta` negativo quando é o caso, e quem chama decide. Filtrar
21
+ silenciosamente o que ficou pior transformaria a lista num argumento em vez
22
+ de uma medição.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ from dataclasses import dataclass
28
+ from typing import TYPE_CHECKING
29
+
30
+ from loguru import logger
31
+
32
+ from dockerls.application.services.migration import plan_migration
33
+
34
+ if TYPE_CHECKING:
35
+ from dockerls.application.dto.analysis import ImageAnalysis
36
+ from dockerls.application.services.migration import MigrationPlan
37
+ from dockerls.application.use_cases.analyze_image import AnalyzeImageUseCase
38
+ from dockerls.application.use_cases.recommend_images import RecommendImagesUseCase
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class AlternativeSuggestion:
43
+ """A melhor alternativa medida para uma referência, com o custo da troca."""
44
+
45
+ reference: str
46
+ current: ImageAnalysis
47
+ candidate: ImageAnalysis
48
+ plan: MigrationPlan
49
+
50
+ @property
51
+ def improves(self) -> bool:
52
+ """Se a troca melhora alguma coisa que foi medida.
53
+
54
+ Um `score_delta` positivo sozinho não basta: o que decide na prática é
55
+ CVE a menos, e um score melhor com mais CRITICAL seria uma melhora no
56
+ papel.
57
+ """
58
+ return (
59
+ self.plan.critical_delta < 0
60
+ or self.plan.high_delta < 0
61
+ or (
62
+ self.plan.score_delta > 0
63
+ and self.plan.critical_delta <= 0
64
+ and self.plan.high_delta <= 0
65
+ )
66
+ )
67
+
68
+ def to_dict(self) -> dict[str, object]:
69
+ return {
70
+ "for": self.reference,
71
+ "candidate": self.candidate.image.full_reference,
72
+ "pinned": self.plan.to_pinned_reference,
73
+ "improves": self.improves,
74
+ "score_delta": self.plan.score_delta,
75
+ "critical_delta": self.plan.critical_delta,
76
+ "high_delta": self.plan.high_delta,
77
+ "improvements": list(self.plan.improvements),
78
+ "trade_offs": list(self.plan.trade_offs),
79
+ }
80
+
81
+
82
+ @dataclass(frozen=True)
83
+ class AlternativeFailure:
84
+ """Por que não houve sugestão. Nunca confundido com "não há nada melhor"."""
85
+
86
+ reference: str
87
+ reason: str
88
+
89
+ def to_dict(self) -> dict[str, str]:
90
+ return {"for": self.reference, "reason": self.reason}
91
+
92
+
93
+ async def best_alternative(
94
+ reference: str,
95
+ *,
96
+ analyzer: AnalyzeImageUseCase,
97
+ recommender: RecommendImagesUseCase,
98
+ ) -> AlternativeSuggestion | AlternativeFailure:
99
+ """A melhor alternativa medida para `reference`, ou o motivo de não haver.
100
+
101
+ O tipo de retorno é a garantia que importa: quem chama é obrigado a
102
+ distinguir "não achamos nada melhor" de "não conseguimos medir", porque as
103
+ duas coisas chegam como valores diferentes em vez de como `None`.
104
+ """
105
+ # Uma medição que não aconteceu nunca vira baseline. A frase é a mesma
106
+ # para as duas formas de falhar, porque significam a mesma coisa.
107
+ unmeasured = (
108
+ f"{reference} could not be scanned, so no improvement over it can be measured. "
109
+ "This is a technical failure, not a verdict about the image"
110
+ )
111
+ try:
112
+ current = await analyzer.execute(reference)
113
+ except (ValueError, RuntimeError) as e:
114
+ logger.debug(f"Could not analyze {reference}: {e}")
115
+ return AlternativeFailure(reference=reference, reason=unmeasured)
116
+
117
+ # A falha chega de duas formas e as duas valem o mesmo aqui. A exceção
118
+ # é o caso antigo; hoje um scan que não completou devolve um
119
+ # `ImageAnalysis` com score 0.0 e tier F por construção, e aceitá-lo
120
+ # como baseline faria toda candidata aparecer como uma melhora enorme
121
+ # sobre uma imagem que ninguém mediu -- exatamente a substituição que a
122
+ # primeira recusa deste módulo existe para impedir.
123
+ if not current.scan.is_verified:
124
+ return AlternativeFailure(reference=reference, reason=unmeasured)
125
+
126
+ repository = _repository_of(reference)
127
+ try:
128
+ result = await recommender.execute(repository)
129
+ except (ValueError, RuntimeError) as e:
130
+ logger.debug(f"Could not search for alternatives to {repository}: {e}")
131
+ return AlternativeFailure(
132
+ reference=reference,
133
+ reason=f"the search for alternatives to {repository} failed: {e}",
134
+ )
135
+
136
+ for candidate in result.recommendations or result.alternatives:
137
+ if candidate.image.full_reference == current.image.full_reference:
138
+ continue
139
+ if not candidate.confidence.is_recommendable:
140
+ # Uma candidata que a própria ferramenta não consegue afirmar não
141
+ # entra: sugerir com pouca confiança é transferir a incerteza para
142
+ # quem vai fazer a migração sem dizer que ela existe.
143
+ continue
144
+ return AlternativeSuggestion(
145
+ reference=reference,
146
+ current=current,
147
+ candidate=candidate,
148
+ plan=plan_migration(current, candidate),
149
+ )
150
+
151
+ return AlternativeFailure(
152
+ reference=reference,
153
+ reason=(
154
+ f"no alternative to {repository} was measured with enough confidence to be recommended"
155
+ ),
156
+ )
157
+
158
+
159
+ def _repository_of(reference: str) -> str:
160
+ """A parte que a descoberta procura: `node:22@sha256:...` -> `node`."""
161
+ head = reference.split("@", 1)[0]
162
+ repository, separator, tail = head.rpartition(":")
163
+ # `registry:5000/app` tem `:` no host, não na tag: só é tag se o que vem
164
+ # depois não contiver barra.
165
+ if separator and "/" not in tail:
166
+ return repository
167
+ return head
@@ -0,0 +1,106 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from typing import TYPE_CHECKING
5
+
6
+ from loguru import logger
7
+
8
+ from dockerls.domain.interfaces.image_repository import ImageRepositoryInterface
9
+
10
+ if TYPE_CHECKING:
11
+ from dockerls.domain.entities.image import DockerImage
12
+
13
+
14
+ class CompositeImageRepository(ImageRepositoryInterface):
15
+ """Fans a query out across every configured image source.
16
+
17
+ The primary source (Docker Hub) sets the bulk of the candidate list;
18
+ hardened catalogues contribute a small number of tags each. All of them
19
+ feed the same scan pipeline, so a hardened image wins on measured
20
+ vulnerabilities rather than on reputation.
21
+
22
+ A source that fails is logged and skipped -- one unreachable registry
23
+ must not take down a search the other sources can still answer.
24
+ """
25
+
26
+ def __init__(
27
+ self,
28
+ primary: ImageRepositoryInterface,
29
+ extra: list[ImageRepositoryInterface] | None = None,
30
+ extra_limit: int = 10,
31
+ ):
32
+ self._primary = primary
33
+ self._extra = extra or []
34
+ self._extra_limit = extra_limit
35
+
36
+ @property
37
+ def sources(self) -> list[ImageRepositoryInterface]:
38
+ return [self._primary, *self._extra]
39
+
40
+ async def search_tags(self, image_name: str, limit: int = 100) -> list[DockerImage]:
41
+ async def safe(repo: ImageRepositoryInterface, per_source_limit: int) -> list[DockerImage]:
42
+ try:
43
+ return await repo.search_tags(image_name, limit=per_source_limit)
44
+ except Exception as e:
45
+ logger.warning(f"{type(repo).__name__} search failed for {image_name}: {e}")
46
+ return []
47
+
48
+ results = await asyncio.gather(
49
+ safe(self._primary, limit),
50
+ *[safe(repo, self._extra_limit) for repo in self._extra],
51
+ )
52
+
53
+ merged: list[DockerImage] = []
54
+ seen: set[str] = set()
55
+ for source_tags in results:
56
+ for image in source_tags:
57
+ if image.full_reference in seen:
58
+ continue
59
+ seen.add(image.full_reference)
60
+ merged.append(image)
61
+ return merged
62
+
63
+ async def get_image_metadata(self, image_name: str, tag: str) -> DockerImage | None:
64
+ for repo in self.sources:
65
+ try:
66
+ found = await repo.get_image_metadata(image_name, tag)
67
+ except Exception as e:
68
+ logger.warning(f"{type(repo).__name__} metadata failed for {image_name}: {e}")
69
+ continue
70
+ if found is not None:
71
+ return found
72
+ return None
73
+
74
+ async def tag_exists(self, image_name: str, tag: str) -> bool | None:
75
+ """Route the check to the source that owns the reference.
76
+
77
+ `image_name` here is the fully-qualified name the pipeline scanned
78
+ (e.g. "cgr.dev/chainguard/node"), so the owning source is whichever
79
+ one recognises it.
80
+ """
81
+ for repo in self.sources:
82
+ host = getattr(repo, "host", "")
83
+ if host and image_name.startswith(f"{host}/"):
84
+ return await _safe_tag_exists(repo, _strip_host(image_name, host), tag)
85
+ return await _safe_tag_exists(self._primary, image_name, tag)
86
+
87
+
88
+ def _strip_host(image_name: str, host: str) -> str:
89
+ """Turn "cgr.dev/chainguard/node" back into the bare query "node" the
90
+ source's own `repository_for()` expects."""
91
+ remainder = image_name[len(host) + 1 :]
92
+ return remainder.split("/", 1)[1] if "/" in remainder else remainder
93
+
94
+
95
+ async def _safe_tag_exists(
96
+ repo: ImageRepositoryInterface, image_name: str, tag: str
97
+ ) -> bool | None:
98
+ checker = getattr(repo, "tag_exists", None)
99
+ if not callable(checker):
100
+ return None
101
+ try:
102
+ result: bool | None = await checker(image_name, tag)
103
+ except Exception as e:
104
+ logger.warning(f"{type(repo).__name__} tag check failed for {image_name}:{tag}: {e}")
105
+ return None
106
+ return result