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,76 @@
1
+ """Onde o histórico de uma tag persiste entre execuções.
2
+
3
+ A metade pura está em `domain/value_objects/tag_history.py`. Aqui mora só o
4
+ que ela não pode fazer: guardar o que foi visto para que a próxima execução
5
+ saiba comparar.
6
+
7
+ O cache já existente serve, com uma diferença que importa: entradas de cache
8
+ normais expiram porque uma resposta velha é pior que nenhuma. Um histórico é o
9
+ oposto -- ele *é* o passado, e vale mais quanto mais antigo. Por isso o TTL
10
+ aqui é de um ano e é renovado a cada gravação: um histórico que expira em 24h
11
+ nunca chega a registrar a segunda observação, e a pergunta que este módulo
12
+ existe para responder ("com que frequência esta tag muda?") ficaria sem
13
+ resposta para sempre.
14
+
15
+ Nada aqui levanta exceção para fora. O histórico enriquece o diagnóstico do
16
+ `base`; se o cache estiver indisponível ou corrompido, o diagnóstico continua,
17
+ apenas sem a frase extra. Um extra que derruba o principal não é um extra.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from datetime import UTC, datetime
23
+ from typing import TYPE_CHECKING
24
+
25
+ from loguru import logger
26
+
27
+ from dockerls.domain.value_objects.tag_history import TagHistory, record
28
+
29
+ if TYPE_CHECKING:
30
+ from dockerls.domain.interfaces.cache_store import CacheStoreInterface
31
+
32
+ #: Um ano. O histórico é o passado; ele não fica obsoleto, fica valioso.
33
+ HISTORY_TTL_SECONDS = 365 * 24 * 60 * 60
34
+
35
+ _KEY_PREFIX = "tag-history"
36
+
37
+
38
+ class TagHistoryStore:
39
+ """Lê e grava o histórico de digests por referência de tag."""
40
+
41
+ def __init__(self, cache: CacheStoreInterface | None = None):
42
+ self._cache = cache
43
+
44
+ async def get(self, reference: str) -> TagHistory:
45
+ """O histórico guardado, ou um vazio quando não há (ou não deu para ler)."""
46
+ if self._cache is None or not reference:
47
+ return TagHistory(reference=reference)
48
+ try:
49
+ raw = await self._cache.get(_key(reference))
50
+ except Exception as e: # pragma: no cover - o cache é o caminho instável
51
+ logger.debug(f"Could not read the history of {reference}: {e}")
52
+ return TagHistory(reference=reference)
53
+ return TagHistory.from_dict(reference, raw)
54
+
55
+ async def observe(self, reference: str, digest: str) -> TagHistory:
56
+ """Incorpora o digest observado agora e devolve o histórico resultante.
57
+
58
+ Grava apenas quando algo mudou: reescrever a mesma entrada a cada
59
+ consulta só serviria para renovar o TTL, e a renovação já acontece na
60
+ gravação que importa.
61
+ """
62
+ current = await self.get(reference)
63
+ updated = record(current, digest, datetime.now(UTC))
64
+ if updated is current or self._cache is None:
65
+ return updated
66
+ try:
67
+ await self._cache.set(
68
+ _key(reference), updated.to_dict(), ttl_seconds=HISTORY_TTL_SECONDS
69
+ )
70
+ except Exception as e: # pragma: no cover - o cache é o caminho instável
71
+ logger.debug(f"Could not write the history of {reference}: {e}")
72
+ return updated
73
+
74
+
75
+ def _key(reference: str) -> str:
76
+ return f"{_KEY_PREFIX}:{reference}"
@@ -0,0 +1,50 @@
1
+ """Release the resources a use case borrowed, without letting cleanup lose a result.
2
+
3
+ Scanners hold temporary Trivy cache directories; repositories hold an
4
+ `httpx.AsyncClient` whose connection pool is kept alive for the whole run so
5
+ requests can reuse it. Both therefore have to be handed back, and the only
6
+ place that knows when a run is over is the use case that started it.
7
+
8
+ Everything here is duck-typed on `close()` for the same reason the rest of
9
+ the pipeline is: a test double, a repository with no pool, and a scanner
10
+ that owns nothing all need to work without implementing anything.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import Any
16
+
17
+ from loguru import logger
18
+
19
+
20
+ async def close_quietly(*resources: Any) -> None:
21
+ """Await `close()` on each resource that has one.
22
+
23
+ A failure to clean up is logged and swallowed. It must never replace the
24
+ result the caller is about to return: an unclosed socket is a smaller
25
+ problem than a completed scan being reported as an error, which is what
26
+ letting the exception escape a `finally` would do.
27
+ """
28
+ for resource in resources:
29
+ if resource is None:
30
+ continue
31
+ close = getattr(resource, "close", None)
32
+ if not callable(close):
33
+ continue
34
+ try:
35
+ await close()
36
+ except Exception as e: # pragma: no cover - cleanup must not mask results
37
+ logger.warning(f"Cleanup failed for {type(resource).__name__}: {e}")
38
+
39
+
40
+ def sources_of(repository: Any) -> list[Any]:
41
+ """The individual image sources behind a repository.
42
+
43
+ `CompositeImageRepository` exposes a real list of them; anything else --
44
+ a single client, or a test double whose attributes are auto-created --
45
+ is the one source it is.
46
+ """
47
+ sources = getattr(repository, "sources", None)
48
+ if isinstance(sources, list | tuple):
49
+ return list(sources)
50
+ return [repository]
@@ -0,0 +1,298 @@
1
+ """Turn measurements into a verdict, and make the verdict explain itself.
2
+
3
+ The scoring pieces each answer one question -- how vulnerable, how hardened,
4
+ how much surface, how trustworthy is the evidence. This is where they are
5
+ put together into what the user actually asked for: *should I run this*, and
6
+ *why did it beat the alternative*.
7
+
8
+ Two rules govern the composition, and both exist because the obvious
9
+ alternative is wrong:
10
+
11
+ **Hardening never offsets vulnerabilities.** The dimensions are reported
12
+ side by side and are never summed. A perfectly configured image carrying an
13
+ unfixable CRITICAL is a perfectly configured vulnerable image, and the
14
+ production-ready verdict it gets is the one its CVEs earn. `SecurityTier`
15
+ already enforces the ceiling; nothing here may route around it.
16
+
17
+ **Confidence is a gate, not a decoration.** An UNVERIFIED candidate is not a
18
+ low-scoring candidate: it is one about which nothing is known, and the
19
+ ranking refuses to place it above anything that was actually measured.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from typing import TYPE_CHECKING
25
+
26
+ from dockerls.application.services.cross_validation import CrossValidationOutcome
27
+ from dockerls.domain.value_objects.attack_surface import AttackSurfaceScore
28
+ from dockerls.domain.value_objects.confidence import (
29
+ Confidence,
30
+ ConfidenceAssessment,
31
+ ConfidenceInputs,
32
+ confidence_rank,
33
+ )
34
+ from dockerls.domain.value_objects.hardening import HardeningScore
35
+ from dockerls.domain.value_objects.production_readiness import (
36
+ ProductionReadiness,
37
+ ReadinessInputs,
38
+ )
39
+ from dockerls.domain.value_objects.security_tier import Tier
40
+ from dockerls.domain.value_objects.tristate import Tristate
41
+
42
+ if TYPE_CHECKING:
43
+ from dockerls.application.dto.analysis import ImageAnalysis
44
+ from dockerls.domain.entities.image_facts import HardeningFacts
45
+
46
+ from dockerls.application.dto.analysis import DimensionReport
47
+
48
+
49
+ def apply_facts(analysis: ImageAnalysis, facts: HardeningFacts) -> None:
50
+ """Attach an evidence record and the two dimensions derived from it.
51
+
52
+ In-place, matching how `CrossValidator` annotates an analysis: the
53
+ pipeline enriches one object as evidence arrives rather than rebuilding
54
+ it at each stage.
55
+ """
56
+ hardening = HardeningScore(facts)
57
+ surface = AttackSurfaceScore(facts)
58
+
59
+ analysis.facts = facts
60
+ analysis.hardening = DimensionReport(
61
+ score=hardening.value,
62
+ coverage=hardening.coverage,
63
+ reportable=hardening.is_reportable,
64
+ positives=hardening.strengths,
65
+ negatives=hardening.weaknesses,
66
+ undetermined=hardening.undetermined,
67
+ )
68
+ analysis.attack_surface = DimensionReport(
69
+ score=surface.value,
70
+ coverage=surface.coverage,
71
+ reportable=surface.is_reportable,
72
+ # Inverted relative to hardening on purpose: for surface, the
73
+ # elements *present* are what count against the image.
74
+ positives=surface.absent,
75
+ negatives=surface.present,
76
+ undetermined=[item.name for item in surface.items if not item.determined],
77
+ )
78
+
79
+
80
+ def finalize_verdict(analysis: ImageAnalysis, *, cross_validated: bool) -> None:
81
+ """Set confidence and the explanation, once all evidence is in.
82
+
83
+ Called after tag verification and cross-validation, because both feed
84
+ the confidence assessment: computing it earlier would report a
85
+ confidence that ignores the checks still to come.
86
+ """
87
+ assessment = ConfidenceAssessment(
88
+ ConfidenceInputs(
89
+ scan_verified=analysis.scan.is_verified,
90
+ cross_validated=cross_validated,
91
+ scanners_disagree=bool(analysis.scan_divergence),
92
+ scanners_differ_slightly=(
93
+ analysis.cross_validation == CrossValidationOutcome.MINOR_DIVERGENCE.value
94
+ ),
95
+ digest_resolved=analysis.image.digest_known,
96
+ registry_verified=analysis.hub_tag_verified,
97
+ hardening_coverage=analysis.hardening.coverage,
98
+ )
99
+ )
100
+ analysis.confidence = assessment.level
101
+ analysis.confidence_reasons = assessment.reasons
102
+
103
+ # Production readiness is decided here, not by the tier, and not before
104
+ # the evidence is in. `SecurityTier.production_ready` can only see the
105
+ # score, so a PARTIAL scan with no findings in the targets it managed to
106
+ # read produced tier A and "production ready" on the same analysis that
107
+ # reported UNVERIFIED. The policy below is the only thing that writes
108
+ # this field.
109
+ readiness = ProductionReadiness.evaluate(
110
+ ReadinessInputs(
111
+ tier=Tier(analysis.tier),
112
+ confidence=analysis.confidence,
113
+ scan_verified=analysis.scan.is_verified,
114
+ eol=analysis.eol_status,
115
+ critical_count=analysis.scan.critical_count,
116
+ high_count=analysis.scan.high_count,
117
+ unfixable_critical_count=(
118
+ analysis.scan.critical_count - analysis.scan.fixable_critical_count
119
+ ),
120
+ has_material_divergence=bool(analysis.scan_divergence),
121
+ )
122
+ )
123
+ analysis.production_ready = readiness.is_ready
124
+ analysis.readiness_blockers = readiness.codes
125
+ analysis.readiness_reasons = readiness.reasons
126
+
127
+ analysis.why = _why(analysis)
128
+ analysis.trade_offs = _trade_offs(analysis)
129
+
130
+
131
+ def ranking_key(analysis: ImageAnalysis) -> tuple[float, ...]:
132
+ """Total ordering across sources, best first under `reverse=True`.
133
+
134
+ The order of the terms *is* the policy, so it is stated here once and
135
+ read the same way by `recommend`, `alternatives` and the advisor:
136
+
137
+ 1. **confidence** -- an unverified candidate never outranks a measured
138
+ one, whatever its numbers say;
139
+ 2. **security score** -- the measured vulnerability position, which is
140
+ what the whole exercise is about;
141
+ 3. **hardening**, but only when enough of it was determined to mean
142
+ something; a thin-coverage 100 must not beat a fully-inspected 85;
143
+ 4. **attack surface**, negated so less surface ranks higher;
144
+ 5. **remediation score** -- between two equivalent images, prefer the
145
+ one whose findings can actually be fixed.
146
+
147
+ Hardening enters only after the vulnerability position, which is the
148
+ structural reason it can never mask a CRITICAL: no value of terms 3-5
149
+ can move a candidate past a difference in term 2.
150
+ """
151
+ return (
152
+ float(confidence_rank(analysis.confidence)),
153
+ analysis.security_score,
154
+ analysis.hardening.score if analysis.hardening.reportable else 0.0,
155
+ -(analysis.attack_surface.score if analysis.attack_surface.reportable else 0.0),
156
+ float(analysis.remediation_score),
157
+ )
158
+
159
+
160
+ def rank(analyses: list[ImageAnalysis]) -> list[ImageAnalysis]:
161
+ """Order candidates from every source under one comparable policy."""
162
+ return sorted(analyses, key=lambda analysis: ranking_key(analysis), reverse=True)
163
+
164
+
165
+ def _why(analysis: ImageAnalysis) -> list[str]:
166
+ """The case for this image, in facts a reader can check.
167
+
168
+ Only statements backed by something that was actually determined get in.
169
+ "No critical vulnerabilities" is earned by a completed scan; "runs as
170
+ non-root" is earned by a config that was read or a declaration that was
171
+ made, and the phrasing says which.
172
+ """
173
+ scan = analysis.scan
174
+ reasons: list[str] = []
175
+
176
+ if scan.critical_count == 0:
177
+ reasons.append("no CRITICAL vulnerabilities")
178
+ if scan.high_count == 0:
179
+ reasons.append("no HIGH vulnerabilities")
180
+ # "No known-exploited vulnerabilities" is the strongest claim this tool
181
+ # makes about real-world exploitation, and it may only be made when the
182
+ # KEV catalogue actually answered. With the feed unreachable every CVE
183
+ # comes back `exploit_known=False`, and stating the claim on that basis
184
+ # would be reporting a failed lookup as a security property.
185
+ #
186
+ # A CVE the catalogue *does* list is the mirror case, and it belongs in
187
+ # `_trade_offs`, not here: it used to land in this list, so a log4j RCE
188
+ # under active exploitation (CVE-2021-44228, CISA KEV) printed as a `+`
189
+ # reason to pick the image. Exploitation observed in the wild is never a
190
+ # point in an image's favour.
191
+ checked = [v for v in scan.vulnerabilities if v.kev_status.is_known]
192
+ if checked and not any(v.exploit_known for v in checked):
193
+ reasons.append(
194
+ f"no known-exploited (CISA KEV) vulnerabilities among the "
195
+ f"{len(checked)} finding(s) checked"
196
+ )
197
+ if analysis.eol_status.is_false:
198
+ reasons.append("not end-of-life")
199
+ if analysis.is_lts:
200
+ reasons.append("long-term-support release")
201
+
202
+ facts = analysis.facts
203
+ if facts.runs_as_non_root.is_true:
204
+ reasons.append(f"runs as a non-root account ({facts.source_of('runs_as_non_root').value})")
205
+ if analysis.hardening.reportable:
206
+ reasons.extend(analysis.hardening.positives)
207
+ if analysis.attack_surface.reportable and analysis.attack_surface.positives:
208
+ reasons.extend(analysis.attack_surface.positives)
209
+
210
+ if analysis.image.digest_known:
211
+ reasons.append("pinned to a resolved manifest digest")
212
+ if analysis.hub_tag_verified is True:
213
+ reasons.append("tag confirmed in its source registry")
214
+ if cross_validation_agreed(analysis):
215
+ reasons.append("two scanners agreed on the vulnerability counts")
216
+
217
+ return _deduplicate(reasons)
218
+
219
+
220
+ def cross_validation_agreed(analysis: ImageAnalysis) -> bool:
221
+ """A second scanner ran and did not disagree materially.
222
+
223
+ Distinguished from "no second scanner ran": the evidence path for the
224
+ secondary scanner is what proves one did.
225
+ """
226
+ if analysis.cross_validation == CrossValidationOutcome.AGREEMENT.value:
227
+ return True
228
+ # Fall back to the older signal for analyses produced before the outcome
229
+ # was recorded (a cache entry, a hand-built DTO): two evidence files and
230
+ # no material dispute is what "agreed" used to mean.
231
+ scanners = set(analysis.evidence_paths)
232
+ return len(scanners) > 1 and not analysis.scan_divergence
233
+
234
+
235
+ def _trade_offs(analysis: ImageAnalysis) -> list[str]:
236
+ """What this image costs, stated next to what it offers."""
237
+ costs: list[str] = []
238
+ scan = analysis.scan
239
+
240
+ if scan.critical_count:
241
+ costs.append(f"{scan.critical_count} CRITICAL vulnerability(ies) present")
242
+ if scan.high_count:
243
+ costs.append(f"{scan.high_count} HIGH vulnerability(ies) present")
244
+ if analysis.eol_status.is_true:
245
+ costs.append("this release is end-of-life and will not receive security fixes")
246
+ elif analysis.eol_status is Tristate.UNKNOWN:
247
+ costs.append(
248
+ "end-of-life status could not be determined: this is not a statement that "
249
+ "the release is supported"
250
+ )
251
+ if analysis.scan.total_count and not any(
252
+ v.kev_status.is_known for v in analysis.scan.vulnerabilities
253
+ ):
254
+ costs.append("exploitation status (CISA KEV) could not be determined for any finding")
255
+ exploited = sum(1 for v in scan.vulnerabilities if v.exploit_known)
256
+ if exploited:
257
+ costs.append(f"{exploited} known-exploited (CISA KEV) vulnerability(ies) present")
258
+ if analysis.scan_divergence:
259
+ costs.append(f"scanners disagree: {analysis.scan_divergence}")
260
+ if analysis.confidence is not Confidence.HIGH:
261
+ costs.extend(analysis.confidence_reasons)
262
+
263
+ facts = analysis.facts
264
+ if facts.runs_as_non_root.is_false:
265
+ costs.append("runs as root by default")
266
+ if analysis.hardening.reportable:
267
+ costs.extend(analysis.hardening.negatives)
268
+ if analysis.attack_surface.reportable:
269
+ costs.extend(analysis.attack_surface.negatives)
270
+ costs.extend(facts.conflicts)
271
+
272
+ if not analysis.image.digest_known:
273
+ costs.append("no manifest digest resolved: the tag may move under you")
274
+ declared = analysis.image.declared
275
+ if declared is not None and declared.is_dev_variant:
276
+ costs.append(
277
+ f"this is the '{declared.variant}' variant, which ships build tooling by design"
278
+ )
279
+ if facts.has_shell is Tristate.UNKNOWN and facts.has_package_manager is Tristate.UNKNOWN:
280
+ costs.append("image contents could not be inspected: shell and package manager unknown")
281
+
282
+ return _deduplicate(costs)
283
+
284
+
285
+ def _deduplicate(items: list[str]) -> list[str]:
286
+ """Preserve order, drop repeats.
287
+
288
+ Reasons are gathered from several models that legitimately observe the
289
+ same property, and a list that says "no shell present" twice reads as
290
+ sloppy rather than thorough.
291
+ """
292
+ seen: set[str] = set()
293
+ unique: list[str] = []
294
+ for item in items:
295
+ if item and item not in seen:
296
+ seen.add(item)
297
+ unique.append(item)
298
+ return unique
@@ -0,0 +1,108 @@
1
+ """The current stable versions of a runtime or OS, read from the registry.
2
+
3
+ `RUNTIME_BASES` in `base_recipe.py` hardcodes one version per (runtime,
4
+ family) -- Alpine 3.21, Node 22, Python 3.12 -- and that stays the default
5
+ when nobody asks for anything else. This module answers a narrower
6
+ question for `--os-version`/`--runtime-version`: what versions actually
7
+ exist right now, so a menu can offer "the two latest" without a number
8
+ baked into this file going stale the day a project ships its next release.
9
+
10
+ Nothing here decides which version is *safer* -- that is still `recommend`
11
+ and a real scan's job. This only says what is published.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import re
17
+ from dataclasses import dataclass
18
+ from typing import TYPE_CHECKING
19
+
20
+ from dockerls.domain.value_objects.base_recipe import OsFamily, Runtime
21
+ from dockerls.integrations.dockerhub.client import DockerHubClient
22
+
23
+ if TYPE_CHECKING:
24
+ from collections.abc import Sequence
25
+
26
+ #: Which Docker Hub repository names this (runtime, family), and the tag
27
+ #: shape that names a *stable* release in that family -- one capture group,
28
+ #: the version. `python:3.13-rc-alpine` and `node:22-alpine3.20-slim` do not
29
+ #: match: a release candidate or a variant tag is not what "the latest
30
+ #: stable version" means here.
31
+ _SOURCES: dict[tuple[Runtime, OsFamily], tuple[str, re.Pattern[str]]] = {
32
+ (Runtime.NONE, OsFamily.ALPINE): ("alpine", re.compile(r"^(\d+\.\d+)$")),
33
+ (Runtime.NONE, OsFamily.DEBIAN): ("debian", re.compile(r"^(\d+)-slim$")),
34
+ (Runtime.NONE, OsFamily.UBUNTU): ("ubuntu", re.compile(r"^(\d+\.\d+)$")),
35
+ (Runtime.JAVA, OsFamily.ALPINE): ("eclipse-temurin", re.compile(r"^(\d+)-jre-alpine$")),
36
+ (Runtime.JAVA, OsFamily.DEBIAN): ("eclipse-temurin", re.compile(r"^(\d+)-jre$")),
37
+ (Runtime.JAVA, OsFamily.UBUNTU): ("eclipse-temurin", re.compile(r"^(\d+)-jre-noble$")),
38
+ (Runtime.NODE, OsFamily.ALPINE): ("node", re.compile(r"^(\d+)-alpine$")),
39
+ (Runtime.NODE, OsFamily.DEBIAN): ("node", re.compile(r"^(\d+)-slim$")),
40
+ (Runtime.PYTHON, OsFamily.ALPINE): ("python", re.compile(r"^(\d+\.\d+)-alpine$")),
41
+ (Runtime.PYTHON, OsFamily.DEBIAN): ("python", re.compile(r"^(\d+\.\d+)-slim-bookworm$")),
42
+ (Runtime.GO, OsFamily.ALPINE): ("golang", re.compile(r"^(\d+\.\d+)-alpine$")),
43
+ (Runtime.GO, OsFamily.DEBIAN): ("golang", re.compile(r"^(\d+\.\d+)-bookworm$")),
44
+ (Runtime.RUBY, OsFamily.ALPINE): ("ruby", re.compile(r"^(\d+\.\d+)-alpine$")),
45
+ (Runtime.RUBY, OsFamily.DEBIAN): ("ruby", re.compile(r"^(\d+\.\d+)-slim-bookworm$")),
46
+ (Runtime.PHP, OsFamily.ALPINE): ("php", re.compile(r"^(\d+\.\d+)-cli-alpine$")),
47
+ (Runtime.PHP, OsFamily.DEBIAN): ("php", re.compile(r"^(\d+\.\d+)-cli-bookworm$")),
48
+ }
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class VersionChoice:
53
+ """One version this (runtime, family) can be pinned to, and the tag it
54
+ resolves to today."""
55
+
56
+ version: str
57
+ tag: str
58
+
59
+
60
+ def supports_version_discovery(runtime: Runtime, family: OsFamily) -> bool:
61
+ return (runtime, family) in _SOURCES
62
+
63
+
64
+ def _sort_key(version: str) -> tuple[int, ...]:
65
+ return tuple(int(part) for part in version.split("."))
66
+
67
+
68
+ async def discover_versions(
69
+ runtime: Runtime,
70
+ family: OsFamily,
71
+ *,
72
+ count: int = 2,
73
+ client: DockerHubClient | None = None,
74
+ ) -> Sequence[VersionChoice]:
75
+ """The `count` newest stable versions published for this combination,
76
+ newest first.
77
+
78
+ Returns an empty sequence -- never raises -- when the registry cannot
79
+ be reached or this combination has no known tag shape: an empty result
80
+ is "could not measure this", and the caller falls back to the catalog
81
+ default rather than being handed a guess dressed up as one.
82
+ """
83
+ source = _SOURCES.get((runtime, family))
84
+ if source is None:
85
+ return ()
86
+ repository, pattern = source
87
+
88
+ owns_client = client is None
89
+ client = client or DockerHubClient()
90
+ try:
91
+ # `search_tags` already degrades to a partial (or empty) result on
92
+ # any HTTP error instead of raising -- there is nothing further to
93
+ # catch here.
94
+ tags = await client.search_tags(repository, limit=100)
95
+ finally:
96
+ if owns_client:
97
+ await client.close()
98
+
99
+ by_version: dict[str, str] = {}
100
+ for image in tags:
101
+ match = pattern.match(image.tag)
102
+ if not match:
103
+ continue
104
+ version = match.group(1)
105
+ by_version.setdefault(version, image.tag)
106
+
107
+ ordered = sorted(by_version, key=_sort_key, reverse=True)
108
+ return [VersionChoice(version=v, tag=by_version[v]) for v in ordered[:count]]
File without changes
@@ -0,0 +1,103 @@
1
+ """Use case para análise de Dockerfiles."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ from typing import TYPE_CHECKING, Any
8
+
9
+ if TYPE_CHECKING:
10
+ from dockerls.domain.entities.dockerfile_analysis import (
11
+ DockerfileAnalysis,
12
+ DockerfileValidationResult,
13
+ HardeningRule,
14
+ )
15
+ from dockerls.domain.interfaces.dockerfile_validator import (
16
+ DockerfileValidatorInterface,
17
+ HardeningTemplateProvider,
18
+ )
19
+
20
+
21
+ @dataclass
22
+ class AnalyzeDockerfileRequest:
23
+ """Request para análise de Dockerfile."""
24
+
25
+ dockerfile_path: str | Path
26
+ include_suggestions: bool = True
27
+ validate_only: bool = False
28
+
29
+
30
+ @dataclass
31
+ class AnalyzeDockerfileResponse:
32
+ """Resposta da análise de Dockerfile."""
33
+
34
+ success: bool
35
+ analysis: DockerfileAnalysis | None = None
36
+ validation: DockerfileValidationResult | None = None
37
+ suggestions: list[HardeningRule] | None = None
38
+ error: str | None = None
39
+
40
+ def model_dump(self) -> dict[str, Any]:
41
+ """Retorna dicionário serializável."""
42
+ return {
43
+ "success": self.success,
44
+ "analysis": self.analysis.model_dump() if self.analysis else None,
45
+ "validation": self.validation.model_dump() if self.validation else None,
46
+ "suggestions": [s.model_dump() for s in self.suggestions] if self.suggestions else [],
47
+ "error": self.error,
48
+ }
49
+
50
+
51
+ class AnalyzeDockerfileUseCase:
52
+ """Caso de uso para análise de Dockerfiles."""
53
+
54
+ def __init__(
55
+ self,
56
+ validator: DockerfileValidatorInterface,
57
+ template_provider: HardeningTemplateProvider | None = None,
58
+ ):
59
+ self._validator = validator
60
+ self._template_provider = template_provider
61
+
62
+ def execute(self, request: AnalyzeDockerfileRequest) -> AnalyzeDockerfileResponse:
63
+ """Executa a análise do Dockerfile."""
64
+ try:
65
+ path = Path(request.dockerfile_path)
66
+ if path.is_dir():
67
+ path = path / "Dockerfile"
68
+
69
+ if not path.exists():
70
+ return AnalyzeDockerfileResponse(
71
+ success=False,
72
+ error=f"Dockerfile not found at {path}",
73
+ )
74
+
75
+ # Validação sempre é executada
76
+ validation = self._validator.validate(path)
77
+
78
+ if request.validate_only:
79
+ return AnalyzeDockerfileResponse(
80
+ success=True,
81
+ validation=validation,
82
+ )
83
+
84
+ # Análise completa
85
+ analysis = self._validator.analyze(path)
86
+
87
+ # Sugestões se solicitado
88
+ suggestions = []
89
+ if request.include_suggestions:
90
+ suggestions = self._validator.suggest_hardening(path)
91
+
92
+ return AnalyzeDockerfileResponse(
93
+ success=True,
94
+ analysis=analysis,
95
+ validation=validation,
96
+ suggestions=suggestions,
97
+ )
98
+
99
+ except Exception as e:
100
+ return AnalyzeDockerfileResponse(
101
+ success=False,
102
+ error=str(e),
103
+ )