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,133 @@
1
+ """Perguntar ao registry o que ele conta sobre uma imagem publicada.
2
+
3
+ A metade pura está em `domain/value_objects/registry_audit.py`. Aqui mora o
4
+ que ela não pode fazer: falar com o registry.
5
+
6
+ Cada checagem é uma pergunta com resposta de três valores, e a terceira --
7
+ "não deu para perguntar" -- é a razão de este módulo existir em vez de uma
8
+ sequência de `if`. Um registry que não responde sobre a assinatura produziria,
9
+ num código com booleano, exatamente a mesma saída de um registry que respondeu
10
+ "não há assinatura". As duas frases levam a decisões opostas.
11
+
12
+ A assinatura e a atestação são procuradas onde o cosign as publica: como tags
13
+ derivadas do digest (`sha256-<hex>.sig` e `sha256-<hex>.att`). É uma convenção
14
+ do sigstore, não do OCI -- então a ausência delas significa "não está assinado
15
+ *com cosign nesse esquema*", e a explicação diz isso em vez de afirmar que
16
+ ninguém assinou nada.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from typing import TYPE_CHECKING
22
+
23
+ from loguru import logger
24
+
25
+ from dockerls.domain.entities.image import DockerImage
26
+ from dockerls.domain.value_objects.registry_audit import (
27
+ AuditCheck,
28
+ AuditFinding,
29
+ RegistryAudit,
30
+ )
31
+ from dockerls.domain.value_objects.tristate import Tristate
32
+
33
+ if TYPE_CHECKING:
34
+ from dockerls.application.services.tag_history_store import TagHistoryStore
35
+ from dockerls.integrations.registry.inspector import RegistryInspector
36
+
37
+
38
+ class RegistryAuditUseCase:
39
+ """Apura o que o protocolo OCI revela sobre uma referência publicada."""
40
+
41
+ def __init__(self, inspector: RegistryInspector, history: TagHistoryStore | None = None):
42
+ self._inspector = inspector
43
+ self._history = history
44
+
45
+ async def execute(self, reference: str) -> RegistryAudit:
46
+ name, tag, given_digest = _split(reference)
47
+ if not name:
48
+ return RegistryAudit(reference=reference)
49
+
50
+ digest = given_digest or await self._digest(name, tag)
51
+ findings = [
52
+ AuditFinding(
53
+ check=AuditCheck.RESOLVABLE,
54
+ state=Tristate.of(bool(digest)),
55
+ detail=digest,
56
+ ),
57
+ AuditFinding(
58
+ check=AuditCheck.PINNED_REFERENCE,
59
+ state=Tristate.of(bool(given_digest)),
60
+ ),
61
+ # O inspector fala com o registry sem nenhuma credencial. Ter
62
+ # obtido resposta *é* a medição do acesso anônimo -- não uma
63
+ # inferência sobre ela.
64
+ AuditFinding(
65
+ check=AuditCheck.PUBLICLY_READABLE,
66
+ state=Tristate.of(bool(digest)) if not given_digest else Tristate.UNKNOWN,
67
+ ),
68
+ await self._tag_stability(name, tag, given_digest=bool(given_digest)),
69
+ ]
70
+
71
+ if digest:
72
+ findings.append(await self._cosign_artifact(name, digest, AuditCheck.SIGNATURE_PRESENT))
73
+ findings.append(
74
+ await self._cosign_artifact(name, digest, AuditCheck.ATTESTATION_PRESENT)
75
+ )
76
+ else:
77
+ # Sem digest não há o que procurar: reportar `UNKNOWN` é a única
78
+ # resposta honesta, e é diferente de "não há assinatura".
79
+ findings.append(
80
+ AuditFinding(check=AuditCheck.SIGNATURE_PRESENT, state=Tristate.UNKNOWN)
81
+ )
82
+ findings.append(
83
+ AuditFinding(check=AuditCheck.ATTESTATION_PRESENT, state=Tristate.UNKNOWN)
84
+ )
85
+
86
+ return RegistryAudit(reference=reference, digest=digest, findings=tuple(findings))
87
+
88
+ async def _digest(self, name: str, tag: str) -> str:
89
+ try:
90
+ return await self._inspector.resolve_digest(DockerImage(name=name, tag=tag))
91
+ except Exception as e: # pragma: no cover - rede é o caminho instável
92
+ logger.debug(f"Could not resolve {name}:{tag}: {e}")
93
+ return ""
94
+
95
+ async def _tag_stability(self, name: str, tag: str, *, given_digest: bool) -> AuditFinding:
96
+ """Se a tag já mudou de digest -- a evidência *medida* de mutabilidade.
97
+
98
+ A configuração de imutabilidade do registry é uma declaração; o
99
+ histórico é uma observação. Quando as duas discordam, a observação é
100
+ que descreve o que aconteceu de fato.
101
+ """
102
+ if given_digest or self._history is None:
103
+ return AuditFinding(check=AuditCheck.TAG_STABLE, state=Tristate.UNKNOWN)
104
+ history = await self._history.get(f"{name}:{tag}")
105
+ if history.is_empty:
106
+ return AuditFinding(check=AuditCheck.TAG_STABLE, state=Tristate.UNKNOWN)
107
+ return AuditFinding(
108
+ check=AuditCheck.TAG_STABLE,
109
+ state=Tristate.of(history.moves == 0),
110
+ detail=history.explain(),
111
+ )
112
+
113
+ async def _cosign_artifact(self, name: str, digest: str, check: AuditCheck) -> AuditFinding:
114
+ """Procura a tag derivada onde o cosign publica assinatura/atestação."""
115
+ suffix = ".sig" if check is AuditCheck.SIGNATURE_PRESENT else ".att"
116
+ derived = f"{digest.replace(':', '-')}{suffix}"
117
+ try:
118
+ found = await self._inspector.resolve_digest(DockerImage(name=name, tag=derived))
119
+ except Exception as e: # pragma: no cover - rede é o caminho instável
120
+ logger.debug(f"Could not query {derived}: {e}")
121
+ return AuditFinding(check=check, state=Tristate.UNKNOWN)
122
+ # "" aqui é resposta: o registry falou e não tem essa tag. O caso de
123
+ # não conseguir falar cai no `except` acima, e vira UNKNOWN.
124
+ return AuditFinding(check=check, state=Tristate.of(bool(found)), detail=found)
125
+
126
+
127
+ def _split(reference: str) -> tuple[str, str, str]:
128
+ """`nome`, `tag`, `digest` -- com os vazios explícitos."""
129
+ remainder, _, digest = reference.partition("@")
130
+ head, slash, last = remainder.rpartition("/")
131
+ name_part, _, tag = last.partition(":")
132
+ name = f"{head}{slash}{name_part}"
133
+ return name, tag or "latest", digest
@@ -0,0 +1,23 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ from dockerls.application.services.teardown import close_quietly, sources_of
6
+
7
+ if TYPE_CHECKING:
8
+ from dockerls.domain.entities.image import DockerImage
9
+ from dockerls.domain.interfaces.image_repository import ImageRepositoryInterface
10
+
11
+
12
+ class SearchImagesUseCase:
13
+ def __init__(self, repository: ImageRepositoryInterface):
14
+ self._repository = repository
15
+
16
+ async def execute(self, image_name: str, limit: int = 100) -> list[DockerImage]:
17
+ try:
18
+ return await self._repository.search_tags(image_name, limit=limit)
19
+ finally:
20
+ # The repository keeps one connection pool alive for the whole
21
+ # run so requests can reuse it, which makes handing it back the
22
+ # caller's job rather than a context manager's.
23
+ await close_quietly(*sources_of(self._repository))
@@ -0,0 +1,167 @@
1
+ """Perguntar ao registry o que cada `FROM` aponta hoje, e propor a correção.
2
+
3
+ A metade pura do trabalho está em `domain/value_objects/base_upgrade.py`. Aqui
4
+ mora a única coisa que ela não pode fazer: falar com o registry para descobrir
5
+ o digest que uma tag aponta *neste momento*.
6
+
7
+ A comparação entre esse digest e o que está escrito no Dockerfile é o que
8
+ detecta a base que apodreceu -- o caso que não avisa ninguém. Foi assim que
9
+ uma base de meados de 2024 continuou sendo construída nesta imagem por meses,
10
+ carregando duas CVEs CRITICAL do `libexpat1` que já tinham correção publicada.
11
+ O Dockerfile estava "corretamente" fixado por digest o tempo todo; fixar sem
12
+ nunca reavaliar é como trancar a porta e jogar fora o calendário.
13
+
14
+ Uma base que o registry não soube responder fica `UNRESOLVED`, nunca
15
+ "atualizada": é ausência de resposta, e o resto desta ferramenta é construído
16
+ sobre a recusa de transformar isso em afirmação.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from dataclasses import dataclass, field
22
+ from pathlib import Path
23
+ from typing import TYPE_CHECKING
24
+
25
+ from loguru import logger
26
+
27
+ from dockerls.domain.entities.image import DockerImage
28
+ from dockerls.domain.value_objects.base_upgrade import (
29
+ BaseFinding,
30
+ BaseStatus,
31
+ DeclaredBase,
32
+ classify,
33
+ parse_bases,
34
+ rewrite,
35
+ )
36
+
37
+ if TYPE_CHECKING:
38
+ from dockerls.application.services.tag_history_store import TagHistoryStore
39
+ from dockerls.domain.value_objects.tag_history import TagHistory
40
+ from dockerls.integrations.registry.inspector import RegistryInspector
41
+
42
+
43
+ @dataclass
44
+ class UpgradeBaseResult:
45
+ """O que foi encontrado e, quando pedido, o que foi aplicado."""
46
+
47
+ dockerfile: str
48
+ findings: list[BaseFinding] = field(default_factory=list)
49
+ #: Substituições escritas em disco. Zero em `--dry-run`, e zero também
50
+ #: quando não havia nada a mudar.
51
+ applied: int = 0
52
+ #: Conteúdo resultante, escrito ou não. Serve para mostrar o diff.
53
+ updated_content: str = ""
54
+ error: str = ""
55
+ #: Histórico de digests por referência (`nome:tag`), quando há onde
56
+ #: guardá-lo. É o que transforma "esta base mudou" em "esta base muda com
57
+ #: esta frequência" -- duas frases que pedem decisões diferentes.
58
+ histories: dict[str, TagHistory] = field(default_factory=dict)
59
+
60
+ @property
61
+ def outdated(self) -> list[BaseFinding]:
62
+ return [f for f in self.findings if f.status is BaseStatus.PINNED_STALE]
63
+
64
+ @property
65
+ def unpinned(self) -> list[BaseFinding]:
66
+ return [f for f in self.findings if f.status is BaseStatus.UNPINNED]
67
+
68
+ @property
69
+ def unresolved(self) -> list[BaseFinding]:
70
+ return [f for f in self.findings if f.status is BaseStatus.UNRESOLVED]
71
+
72
+ @property
73
+ def needs_action(self) -> bool:
74
+ return any(f.status.needs_action for f in self.findings)
75
+
76
+ def to_dict(self) -> dict[str, object]:
77
+ return {
78
+ "dockerfile": self.dockerfile,
79
+ "applied": self.applied,
80
+ "bases": [
81
+ {
82
+ "line": f.base.line,
83
+ "reference": f.base.reference,
84
+ "stage": f.base.stage,
85
+ "status": str(f.status),
86
+ "explanation": f.explain(),
87
+ "current_digest": f.current_digest,
88
+ "proposed": f.proposed_reference,
89
+ "digest_arg": f.base.digest_arg,
90
+ "history": self._history_dict(f.base),
91
+ }
92
+ for f in self.findings
93
+ ],
94
+ "error": self.error,
95
+ }
96
+
97
+ def history_for(self, base: DeclaredBase) -> TagHistory | None:
98
+ return self.histories.get(_history_key(base))
99
+
100
+ def _history_dict(self, base: DeclaredBase) -> dict[str, object] | None:
101
+ history = self.history_for(base)
102
+ return history.to_dict() if history and not history.is_empty else None
103
+
104
+
105
+ def _history_key(base: DeclaredBase) -> str:
106
+ return f"{base.name}:{base.tag or 'latest'}"
107
+
108
+
109
+ class UpgradeBaseUseCase:
110
+ """Confere as bases de um Dockerfile contra o registry."""
111
+
112
+ def __init__(self, inspector: RegistryInspector, history: TagHistoryStore | None = None):
113
+ self._inspector = inspector
114
+ self._history = history
115
+
116
+ async def execute(
117
+ self, dockerfile_path: str | Path, *, apply: bool = True
118
+ ) -> UpgradeBaseResult:
119
+ path = Path(dockerfile_path)
120
+ if path.is_dir():
121
+ path = path / "Dockerfile"
122
+ if not path.is_file():
123
+ return UpgradeBaseResult(dockerfile=str(path), error=f"Dockerfile not found at {path}")
124
+
125
+ content = path.read_text(encoding="utf-8")
126
+ bases = parse_bases(content)
127
+ if not bases:
128
+ return UpgradeBaseResult(dockerfile=str(path), error="no FROM instruction found")
129
+
130
+ findings: list[BaseFinding] = []
131
+ histories: dict[str, TagHistory] = {}
132
+ for base in bases:
133
+ digest = await self._current_digest(base)
134
+ findings.append(classify(base, digest))
135
+ if self._history is not None and digest:
136
+ key = _history_key(base)
137
+ histories[key] = await self._history.observe(key, digest)
138
+ updated, would_apply = rewrite(content, findings)
139
+
140
+ result = UpgradeBaseResult(
141
+ dockerfile=str(path),
142
+ findings=findings,
143
+ updated_content=updated,
144
+ histories=histories,
145
+ )
146
+ if apply and would_apply and updated != content:
147
+ try:
148
+ path.write_text(updated, encoding="utf-8")
149
+ except OSError as e:
150
+ # Não conseguir escrever não invalida o diagnóstico: o
151
+ # relatório continua válido e a pessoa aplica à mão.
152
+ result.error = f"could not write {path}: {e}"
153
+ return result
154
+ result.applied = would_apply
155
+ return result
156
+
157
+ async def _current_digest(self, base: DeclaredBase) -> str:
158
+ """O digest que a tag aponta agora, ou "" quando não deu para perguntar."""
159
+ name = getattr(base, "name", "")
160
+ tag = getattr(base, "tag", "") or "latest"
161
+ if not name:
162
+ return ""
163
+ try:
164
+ return await self._inspector.resolve_digest(DockerImage(name=name, tag=tag))
165
+ except Exception as e: # pragma: no cover - rede é o caminho instável
166
+ logger.debug(f"Could not resolve the digest of {name}:{tag}: {e}")
167
+ return ""
File without changes
@@ -0,0 +1,184 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ import time
6
+ from typing import TYPE_CHECKING, Any, NamedTuple, cast
7
+
8
+ from sqlalchemy import CursorResult, delete, func, select
9
+ from sqlalchemy.dialects.sqlite import insert as sqlite_insert
10
+
11
+ from dockerls.domain.interfaces.cache_store import CacheStoreInterface
12
+ from dockerls.infrastructure.database.models import CacheEntry, create_db_engine
13
+
14
+ if TYPE_CHECKING:
15
+ from pathlib import Path
16
+
17
+ from sqlalchemy.orm import Session
18
+
19
+ # Bump this when the shape of cached payloads changes so stale entries from
20
+ # an older schema are treated as misses instead of crashing on load.
21
+ # v2: ImageAnalysis gained verification metadata (scan evidence paths, Hub
22
+ # tag state, scanner divergence) and ScanResult gained `evidence_path`.
23
+ # v3: ImageAnalysis gained the assessment fields (hardening facts, hardening
24
+ # and attack-surface reports, confidence, why/trade-offs) and ScanResult
25
+ # gained the scanner-reported base distribution. A v2 row would still
26
+ # *validate* against the new model -- pydantic would fill the missing fields
27
+ # with their defaults -- and that is exactly the problem: the defaults are
28
+ # "nothing determined" and `UNVERIFIED`, so a stale row would present an
29
+ # image as uninspected rather than as unscanned. Orphaning the old rows
30
+ # costs one cold run and removes the ambiguity entirely.
31
+ # v4: ImageAnalysis gained the readiness verdict (production_ready is now
32
+ # written by the central policy, and its default flipped to False), the
33
+ # three-valued EOL status, and the cross-validation outcome; Vulnerability
34
+ # gained the three-valued KEV status and the EPSS provenance fields. A v3 row
35
+ # would validate and fill all of them with defaults -- which read as
36
+ # "nothing determined" and would present a cached image as uninspected
37
+ # rather than as measured.
38
+ CACHE_SCHEMA_VERSION = "v4"
39
+
40
+
41
+ class CacheStats(NamedTuple):
42
+ """What `dockerls cache stats` reports.
43
+
44
+ Entries expire lazily -- a stale row is dropped when it is next read --
45
+ so `expired` is the amount `dockerls cache cleanup` would reclaim right
46
+ now, and the gap between it and `total` is what the cache is holding on
47
+ to for nothing.
48
+ """
49
+
50
+ total: int
51
+ expired: int
52
+ size_bytes: int
53
+ path: str
54
+
55
+
56
+ class SQLiteCache(CacheStoreInterface):
57
+ def __init__(self, db_path: Path):
58
+ db_path.parent.mkdir(parents=True, exist_ok=True)
59
+ self._db_path = db_path
60
+ self._engine, self._session_factory = create_db_engine(str(db_path))
61
+
62
+ def _session(self) -> Session:
63
+ return self._session_factory()
64
+
65
+ def close(self) -> None:
66
+ """Dispose the SQLAlchemy engine and release its pooled connection.
67
+
68
+ Nothing here ever called this: the engine opened in `__init__` lived
69
+ until the process exited, which is why `pytest` -- which keeps the
70
+ interpreter running across thousands of these -- reported unclosed
71
+ `sqlite3.Connection` objects (`ResourceWarning`) from tests that
72
+ never mention caching at all. A short-lived CLI invocation masked
73
+ the same leak by exiting anyway.
74
+ """
75
+ self._engine.dispose()
76
+
77
+ def __enter__(self) -> SQLiteCache:
78
+ return self
79
+
80
+ def __exit__(self, *exc_info: object) -> None:
81
+ self.close()
82
+
83
+ def _versioned_key(self, key: str) -> str:
84
+ return f"{CACHE_SCHEMA_VERSION}:{key}"
85
+
86
+ async def get(self, key: str) -> Any | None:
87
+ return await asyncio.to_thread(self._get_sync, key)
88
+
89
+ def _get_sync(self, key: str) -> Any | None:
90
+ vkey = self._versioned_key(key)
91
+ with self._session() as session:
92
+ stmt = select(CacheEntry).where(CacheEntry.key == vkey)
93
+ entry = session.execute(stmt).scalar_one_or_none()
94
+ if entry is None:
95
+ return None
96
+ if entry.expires_at < time.time():
97
+ session.delete(entry)
98
+ session.commit()
99
+ return None
100
+ return json.loads(entry.value)
101
+
102
+ async def set(self, key: str, value: Any, ttl_seconds: int = 86400) -> None:
103
+ await asyncio.to_thread(self._set_sync, key, value, ttl_seconds)
104
+
105
+ def _set_sync(self, key: str, value: Any, ttl_seconds: int) -> None:
106
+ vkey = self._versioned_key(key)
107
+ serialized = json.dumps(value, default=str)
108
+ expires_at = time.time() + ttl_seconds
109
+ # Writes run on a thread pool (`asyncio.to_thread`) and `recommend`
110
+ # issues them concurrently, so select-then-insert had a real window:
111
+ # two threads could both miss and then both INSERT the same unique
112
+ # key. A single atomic upsert closes it -- SQLite's ON CONFLICT does
113
+ # the check and the write in one statement.
114
+ stmt = (
115
+ sqlite_insert(CacheEntry)
116
+ .values(key=vkey, value=serialized, expires_at=expires_at)
117
+ .on_conflict_do_update(
118
+ index_elements=[CacheEntry.key],
119
+ set_={"value": serialized, "expires_at": expires_at},
120
+ )
121
+ )
122
+ with self._session() as session:
123
+ session.execute(stmt)
124
+ session.commit()
125
+
126
+ async def delete(self, key: str) -> None:
127
+ await asyncio.to_thread(self._delete_sync, key)
128
+
129
+ def _delete_sync(self, key: str) -> None:
130
+ vkey = self._versioned_key(key)
131
+ with self._session() as session:
132
+ session.execute(delete(CacheEntry).where(CacheEntry.key == vkey))
133
+ session.commit()
134
+
135
+ async def clear(self) -> None:
136
+ await asyncio.to_thread(self._clear_sync)
137
+
138
+ def _clear_sync(self) -> None:
139
+ with self._session() as session:
140
+ session.execute(delete(CacheEntry))
141
+ session.commit()
142
+
143
+ async def cleanup_expired(self) -> int:
144
+ return await asyncio.to_thread(self._cleanup_expired_sync)
145
+
146
+ def _cleanup_expired_sync(self) -> int:
147
+ with self._session() as session:
148
+ stmt = delete(CacheEntry).where(CacheEntry.expires_at < time.time())
149
+ result = cast("CursorResult[Any]", session.execute(stmt))
150
+ session.commit()
151
+ return result.rowcount
152
+
153
+ async def stats(self) -> CacheStats:
154
+ return await asyncio.to_thread(self._stats_sync)
155
+
156
+ def _stats_sync(self) -> CacheStats:
157
+ now = time.time()
158
+ with self._session() as session:
159
+ total = session.execute(select(func.count()).select_from(CacheEntry)).scalar_one()
160
+ expired = session.execute(
161
+ select(func.count()).select_from(CacheEntry).where(CacheEntry.expires_at < now)
162
+ ).scalar_one()
163
+ return CacheStats(
164
+ total=int(total),
165
+ expired=int(expired),
166
+ size_bytes=self._size_on_disk(),
167
+ path=str(self._db_path),
168
+ )
169
+
170
+ def _size_on_disk(self) -> int:
171
+ """Bytes the cache occupies, including the WAL sidecar.
172
+
173
+ The write-ahead log holds committed data that has not been
174
+ checkpointed back into the main file yet, so reporting only the
175
+ `.db` would understate the footprint -- sometimes by most of it.
176
+ """
177
+ total = 0
178
+ for suffix in ("", "-wal", "-shm"):
179
+ part = self._db_path.with_name(self._db_path.name + suffix)
180
+ try:
181
+ total += part.stat().st_size
182
+ except OSError:
183
+ continue
184
+ return total
File without changes
@@ -0,0 +1,98 @@
1
+ """Cross-referencing a `build` run against an earlier `analyze-dockerfile`.
2
+
3
+ `analyze-dockerfile` and `build` both validate the same Dockerfile -- build
4
+ always does it as its first step -- but the two commands ran as two
5
+ separate documents, with no way to say whether what `analyze-dockerfile`
6
+ found earlier was actually fixed by the time `build` ran. This closes that
7
+ gap for whoever passes `analyze-dockerfile --output <file>` into `build
8
+ --compare-to-analysis <file>`: nothing is inferred that either command did
9
+ not already measure, this only compares the two results check by check.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ from dataclasses import dataclass
16
+ from pathlib import Path
17
+ from typing import TYPE_CHECKING
18
+
19
+ if TYPE_CHECKING:
20
+ from dockerls.domain.entities.dockerfile_analysis import DockerfileValidationResult
21
+
22
+
23
+ class BaselineLoadError(ValueError):
24
+ """The file at `--compare-to-analysis` is not a usable analyze-dockerfile report."""
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class AnalysisComparison:
29
+ """What changed, check by check, between the earlier report and now."""
30
+
31
+ #: Checks that were FAIL or WARN in the baseline and still are now.
32
+ still_present: tuple[str, ...] = ()
33
+ #: Checks that were FAIL or WARN in the baseline and are PASS now.
34
+ resolved: tuple[str, ...] = ()
35
+ #: Checks that are FAIL or WARN now but were not flagged in the baseline.
36
+ newly_introduced: tuple[str, ...] = ()
37
+
38
+ @property
39
+ def baseline_total(self) -> int:
40
+ return len(self.still_present) + len(self.resolved)
41
+
42
+ def model_dump(self) -> dict[str, object]:
43
+ return {
44
+ "still_present": list(self.still_present),
45
+ "resolved": list(self.resolved),
46
+ "newly_introduced": list(self.newly_introduced),
47
+ }
48
+
49
+
50
+ def load_baseline_findings(path: str | Path) -> dict[str, str]:
51
+ """The FAIL/WARN check names from a report `analyze-dockerfile --output`
52
+ wrote, as `{check_name: status}`.
53
+
54
+ Raises `BaselineLoadError` for anything that is not that document --
55
+ missing file, invalid JSON, or JSON that is valid but is not an
56
+ analyze-dockerfile report -- so the caller can report one clear cause
57
+ instead of a `KeyError` three lines deep.
58
+ """
59
+ try:
60
+ raw = Path(path).read_text(encoding="utf-8")
61
+ except OSError as e:
62
+ raise BaselineLoadError(f"could not read {path}: {e}") from e
63
+ try:
64
+ document = json.loads(raw)
65
+ except json.JSONDecodeError as e:
66
+ raise BaselineLoadError(f"{path} is not valid JSON: {e}") from e
67
+
68
+ validation = document.get("validation") if isinstance(document, dict) else None
69
+ if not isinstance(validation, dict):
70
+ raise BaselineLoadError(
71
+ f"{path} does not look like an analyze-dockerfile report (no 'validation' key)"
72
+ )
73
+ checks = validation.get("checks")
74
+ if not isinstance(checks, list):
75
+ raise BaselineLoadError(f"{path} has no 'validation.checks' list to compare against")
76
+
77
+ return {
78
+ check["check"]: check["status"]
79
+ for check in checks
80
+ if isinstance(check, dict)
81
+ and check.get("status") in ("FAIL", "WARN")
82
+ and isinstance(check.get("check"), str)
83
+ }
84
+
85
+
86
+ def compare(baseline: dict[str, str], current: DockerfileValidationResult) -> AnalysisComparison:
87
+ """Baseline FAIL/WARN checks against the checks in `current`."""
88
+ current_bad = {
89
+ c.check: c.status.value for c in current.checks if c.status.value in ("FAIL", "WARN")
90
+ }
91
+
92
+ still_present = tuple(sorted(name for name in baseline if name in current_bad))
93
+ resolved = tuple(sorted(name for name in baseline if name not in current_bad))
94
+ newly_introduced = tuple(sorted(name for name in current_bad if name not in baseline))
95
+
96
+ return AnalysisComparison(
97
+ still_present=still_present, resolved=resolved, newly_introduced=newly_introduced
98
+ )