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,195 @@
1
+ """O que se consegue apurar sobre uma imagem *pelo registry*, sem credencial.
2
+
3
+ Auditar a configuração de um registry -- políticas de retenção, IAM, content
4
+ trust -- exige credencial de nuvem e uma API diferente para cada provedor. Não
5
+ é isto aqui. Este módulo é a parte que se mede com o protocolo OCI e mais nada,
6
+ e é deliberadamente essa: um relatório que precisa de acesso administrativo
7
+ para existir é um relatório que ninguém roda.
8
+
9
+ O que dá para apurar assim é menos do que parece e mais do que se costuma
10
+ olhar:
11
+
12
+ * a referência resolve para um digest, e qual;
13
+ * a referência que a pessoa usou já era um digest, ou era uma tag;
14
+ * a tag já mudou de digest desde que esta ferramenta começou a olhar -- que é
15
+ a única evidência *medida* de que ela é mutável, em vez da configuração
16
+ declarada de imutabilidade, que ninguém confere;
17
+ * existe assinatura cosign publicada para aquele digest;
18
+ * existe atestação cosign publicada para aquele digest;
19
+ * o registry respondeu sem nenhuma credencial -- ou seja, a imagem é legível
20
+ por qualquer pessoa da internet.
21
+
22
+ Cada achado é tri-estado por construção. `UNKNOWN` não é um detalhe do formato:
23
+ sem ele, "o registry não respondeu" viraria "não há assinatura", e essa é
24
+ exatamente a substituição que faz um relatório de segurança mentir.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ from dataclasses import dataclass, field
30
+ from enum import StrEnum
31
+ from typing import TYPE_CHECKING
32
+
33
+ if TYPE_CHECKING:
34
+ from dockerls.domain.value_objects.tristate import Tristate
35
+
36
+
37
+ class AuditCheck(StrEnum):
38
+ """Cada coisa que se pergunta ao registry."""
39
+
40
+ RESOLVABLE = "RESOLVABLE"
41
+ PINNED_REFERENCE = "PINNED_REFERENCE"
42
+ TAG_STABLE = "TAG_STABLE"
43
+ SIGNATURE_PRESENT = "SIGNATURE_PRESENT"
44
+ ATTESTATION_PRESENT = "ATTESTATION_PRESENT"
45
+ PUBLICLY_READABLE = "PUBLICLY_READABLE"
46
+
47
+
48
+ #: Checagens que são relatadas e nunca alertam. `PUBLICLY_READABLE` está aqui
49
+ #: porque "público" é o estado *correto* de uma imagem base oficial e o estado
50
+ #: errado de um artefato interno -- e a diferença entre os dois é a intenção de
51
+ #: quem publicou, que esta ferramenta não tem como medir. Transformar o fato em
52
+ #: alerta seria afirmar uma intenção; relatá-lo sem alertar entrega o fato a
53
+ #: quem sabe qual era.
54
+ _INFORMATIONAL = frozenset({AuditCheck.PUBLICLY_READABLE})
55
+
56
+
57
+ @dataclass(frozen=True)
58
+ class AuditFinding:
59
+ """Uma resposta do registry, com o que ela significa."""
60
+
61
+ check: AuditCheck
62
+ state: Tristate
63
+ detail: str = ""
64
+
65
+ @property
66
+ def is_informational(self) -> bool:
67
+ """Se é um fato relatado sem juízo de valor. Ver `_INFORMATIONAL`."""
68
+ return self.check in _INFORMATIONAL
69
+
70
+ @property
71
+ def is_alert(self) -> bool:
72
+ """Se este achado pede ação.
73
+
74
+ `UNKNOWN` nunca é alerta e nunca é aprovação: é ausência de resposta, e
75
+ entra no relatório como tal.
76
+ """
77
+ if not self.state.is_known or self.check in _INFORMATIONAL:
78
+ return False
79
+ return self.state.is_false
80
+
81
+ @property
82
+ def is_unmeasured(self) -> bool:
83
+ return not self.state.is_known
84
+
85
+ def explain(self) -> str:
86
+ return _EXPLANATIONS[self.check][str(self.state)]
87
+
88
+ def to_dict(self) -> dict[str, object]:
89
+ return {
90
+ "check": str(self.check),
91
+ "state": str(self.state),
92
+ "alert": self.is_alert,
93
+ "explanation": self.explain(),
94
+ "detail": self.detail,
95
+ }
96
+
97
+
98
+ _EXPLANATIONS: dict[AuditCheck, dict[str, str]] = {
99
+ AuditCheck.RESOLVABLE: {
100
+ "true": "the registry answered which digest this reference points at",
101
+ "false": (
102
+ "the registry did not resolve this reference: without a digest, nothing "
103
+ "else here can be measured"
104
+ ),
105
+ "unknown": "the registry could not be asked",
106
+ },
107
+ AuditCheck.PINNED_REFERENCE: {
108
+ "true": "the reference is already a digest: it points at specific bytes",
109
+ "false": (
110
+ "the reference is a tag: what was tested and what runs can be different "
111
+ "bytes with no change of yours"
112
+ ),
113
+ "unknown": "the shape of the reference could not be determined",
114
+ },
115
+ AuditCheck.TAG_STABLE: {
116
+ "true": "this tag has not changed digest since we started watching it",
117
+ "false": (
118
+ "this tag has already changed digest: measured evidence that it is "
119
+ "mutable, whatever the registry immutability setting says"
120
+ ),
121
+ "unknown": (
122
+ "there is no history for this tag: what happened before the first "
123
+ "observation is unknown, not absent"
124
+ ),
125
+ },
126
+ AuditCheck.SIGNATURE_PRESENT: {
127
+ "true": "a cosign signature is published for this digest",
128
+ "false": (
129
+ "no cosign signature is published for this digest: nobody publicly "
130
+ "attested to producing these bytes"
131
+ ),
132
+ "unknown": "the registry could not be asked about the signature",
133
+ },
134
+ AuditCheck.ATTESTATION_PRESENT: {
135
+ "true": "a cosign attestation is published for this digest",
136
+ "false": (
137
+ "no attestation is published for this digest: the registry holds no "
138
+ "record of how this image was built"
139
+ ),
140
+ "unknown": "the registry could not be asked about the attestation",
141
+ },
142
+ AuditCheck.PUBLICLY_READABLE: {
143
+ "true": (
144
+ "the registry answered with no credential at all: anyone on the internet "
145
+ "can pull this image and inspect what is inside it. Whether that is a "
146
+ "problem depends on what it exists for, and only you know that part"
147
+ ),
148
+ "false": "the registry required a credential to answer",
149
+ "unknown": "whether anonymous access is allowed could not be determined",
150
+ },
151
+ }
152
+
153
+
154
+ @dataclass(frozen=True)
155
+ class RegistryAudit:
156
+ """O conjunto de respostas para uma referência."""
157
+
158
+ reference: str
159
+ digest: str = ""
160
+ findings: tuple[AuditFinding, ...] = field(default_factory=tuple)
161
+
162
+ @property
163
+ def alerts(self) -> tuple[AuditFinding, ...]:
164
+ return tuple(f for f in self.findings if f.is_alert)
165
+
166
+ @property
167
+ def unmeasured(self) -> tuple[AuditFinding, ...]:
168
+ return tuple(f for f in self.findings if f.is_unmeasured)
169
+
170
+ def summary(self) -> str:
171
+ if not self.findings:
172
+ return "nothing could be established about this reference"
173
+ partes = [f"{len(self.alerts)} finding(s) that want attention"]
174
+ if self.unmeasured:
175
+ partes.append(f"{len(self.unmeasured)} not measured")
176
+ return ", ".join(partes)
177
+
178
+ def caveat(self) -> str:
179
+ return (
180
+ "this audit uses the OCI protocol alone, with no cloud credential: it "
181
+ "does not read retention policies, IAM, or the provider immutability "
182
+ "settings. What it measures, it measures for real; what it does not, it "
183
+ "says it did not"
184
+ )
185
+
186
+ def to_dict(self) -> dict[str, object]:
187
+ return {
188
+ "reference": self.reference,
189
+ "digest": self.digest,
190
+ "summary": self.summary(),
191
+ "caveat": self.caveat(),
192
+ "alerts": len(self.alerts),
193
+ "unmeasured": len(self.unmeasured),
194
+ "findings": [f.to_dict() for f in self.findings],
195
+ }
@@ -0,0 +1,225 @@
1
+ """Para onde a imagem vai, e o que cada provedor exige antes de aceitar.
2
+
3
+ O `--push` existente rodava `docker push <tag>` com a tag local, exatamente
4
+ como ela foi construída. Numa tag sem host -- `dockerls:1.3.2`, que é a forma
5
+ que todo mundo digita -- isso vira uma tentativa de publicar em
6
+ `docker.io/library/dockerls`, que falha com "denied" para qualquer pessoa que
7
+ não seja mantenedora de uma imagem oficial. E o assistente interativo oferecia
8
+ escolher entre "dockerhub", "ghcr" e "harbor" sem usar a resposta para nada:
9
+ nenhuma delas mudava o destino do push.
10
+
11
+ Este módulo é a peça que faltava. Ele não fala com registry nenhum -- é
12
+ domínio puro, testável sem rede -- e responde três perguntas que precisam de
13
+ resposta *antes* do build começar:
14
+
15
+ * **para onde**, montando a referência completa a partir de host, namespace,
16
+ repositório e tag;
17
+ * **isso é válido para este provedor**, porque as regras diferem de verdade: o
18
+ Artifact Registry do Google exige `projeto/repositório` no caminho, o ACR do
19
+ Azure exige um host `<registro>.azurecr.io`, e o Docker Hub exige um
20
+ namespace que não seja `library`;
21
+ * **como autenticar**, nomeando o comando de login de cada provedor, já que é
22
+ a primeira coisa que falta quando um push é recusado.
23
+
24
+ Perguntar antes do build, e não depois, é o ponto: descobrir que o destino
25
+ está errado depois de escanear e construir desperdiça o trabalho todo, e é
26
+ justamente quando a pessoa está mais propensa a publicar em qualquer lugar só
27
+ para não repetir a espera.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import re
33
+ from dataclasses import dataclass
34
+ from enum import StrEnum
35
+
36
+
37
+ class RegistryProvider(StrEnum):
38
+ """Quem hospeda o destino. Determina validação e forma de login."""
39
+
40
+ DOCKER_HUB = "Docker Hub"
41
+ AZURE_ACR = "Azure Container Registry"
42
+ GOOGLE_ARTIFACT_REGISTRY = "Google Artifact Registry"
43
+ GOOGLE_CONTAINER_REGISTRY = "Google Container Registry"
44
+ DHI = "Docker Hardened Images"
45
+ GITHUB_GHCR = "GitHub Container Registry"
46
+ OTHER = "Registry privado"
47
+
48
+
49
+ #: Hosts que significam Docker Hub.
50
+ _DOCKER_HUB_HOSTS = frozenset({"", "docker.io", "index.docker.io", "registry-1.docker.io"})
51
+
52
+ #: `<registro>.azurecr.io`, incluindo as nuvens soberanas (`.azurecr.cn`,
53
+ #: `.azurecr.us`), que são o mesmo produto em outra geografia.
54
+ _ACR_HOST = re.compile(r"^[a-z0-9]+\.azurecr\.(io|cn|us)$")
55
+
56
+ #: `<região>-docker.pkg.dev` do Artifact Registry.
57
+ _GAR_HOST = re.compile(r"^[a-z0-9-]+-docker\.pkg\.dev$")
58
+
59
+ #: O GCR clássico, incluindo os espelhos regionais (`eu.gcr.io`).
60
+ _GCR_HOST = re.compile(r"^(?:[a-z0-9]+\.)?gcr\.io$")
61
+
62
+ #: Componente de caminho aceito por qualquer registry OCI.
63
+ _PATH_COMPONENT = re.compile(r"^[a-z0-9]+(?:(?:[._]|__|-+)[a-z0-9]+)*$")
64
+
65
+ #: Tag OCI.
66
+ _TAG = re.compile(r"^[a-zA-Z0-9_][a-zA-Z0-9._-]{0,127}$")
67
+
68
+
69
+ def detect_provider(host: str) -> RegistryProvider:
70
+ """Quem hospeda `host`. Um host desconhecido é registry privado, não erro."""
71
+ value = host.strip().lower()
72
+ if value in _DOCKER_HUB_HOSTS:
73
+ return RegistryProvider.DOCKER_HUB
74
+ if _ACR_HOST.match(value):
75
+ return RegistryProvider.AZURE_ACR
76
+ if _GAR_HOST.match(value):
77
+ return RegistryProvider.GOOGLE_ARTIFACT_REGISTRY
78
+ if _GCR_HOST.match(value):
79
+ return RegistryProvider.GOOGLE_CONTAINER_REGISTRY
80
+ if value == "dhi.io":
81
+ return RegistryProvider.DHI
82
+ if value == "ghcr.io":
83
+ return RegistryProvider.GITHUB_GHCR
84
+ return RegistryProvider.OTHER
85
+
86
+
87
+ #: Como autenticar em cada provedor. Nomeado porque é o que falta em quase
88
+ #: todo push recusado, e porque a mensagem genérica do Docker ("denied")
89
+ #: não diz qual comando resolve.
90
+ LOGIN_HINTS: dict[RegistryProvider, str] = {
91
+ RegistryProvider.DOCKER_HUB: (
92
+ "docker login (or `dockerls login`, which stores it in the keyring)"
93
+ ),
94
+ RegistryProvider.AZURE_ACR: "az acr login --name <registry>",
95
+ RegistryProvider.GOOGLE_ARTIFACT_REGISTRY: (
96
+ "gcloud auth configure-docker <region>-docker.pkg.dev"
97
+ ),
98
+ RegistryProvider.GOOGLE_CONTAINER_REGISTRY: "gcloud auth configure-docker gcr.io",
99
+ RegistryProvider.DHI: "docker login dhi.io (requires a Docker Hardened Images subscription)",
100
+ RegistryProvider.GITHUB_GHCR: (
101
+ "echo $GITHUB_TOKEN | docker login ghcr.io -u <username> --password-stdin"
102
+ ),
103
+ RegistryProvider.OTHER: "docker login <host>",
104
+ }
105
+
106
+
107
+ class InvalidRegistryTargetError(ValueError):
108
+ """O destino não é publicável como está, com o motivo em texto."""
109
+
110
+
111
+ @dataclass(frozen=True)
112
+ class RegistryTarget:
113
+ """Um destino de publicação completo, validado por provedor.
114
+
115
+ `namespace` é o caminho entre o host e o repositório, e é onde os
116
+ provedores divergem: no Docker Hub é o usuário ou organização; no ACR
117
+ costuma ser vazio ou um agrupamento livre; no Artifact Registry é
118
+ obrigatoriamente `<projeto>/<repositório>`.
119
+ """
120
+
121
+ host: str
122
+ repository: str
123
+ tag: str
124
+ namespace: str = ""
125
+
126
+ @property
127
+ def provider(self) -> RegistryProvider:
128
+ return detect_provider(self.host)
129
+
130
+ @property
131
+ def path(self) -> str:
132
+ parts = [p for p in (self.namespace.strip("/"), self.repository.strip("/")) if p]
133
+ return "/".join(parts)
134
+
135
+ @property
136
+ def reference(self) -> str:
137
+ """A referência que vai para `docker tag` e `docker push`."""
138
+ host = self.host.strip().strip("/")
139
+ base = f"{host}/{self.path}" if host else self.path
140
+ return f"{base}:{self.tag}"
141
+
142
+ @property
143
+ def login_hint(self) -> str:
144
+ return LOGIN_HINTS[self.provider]
145
+
146
+ def validate(self) -> None:
147
+ """Levanta `InvalidRegistryTargetError` com o motivo, ou não faz nada.
148
+
149
+ Validar aqui, antes do build, é o objetivo do módulo: um destino
150
+ malformado descoberto depois do scan custa o build inteiro.
151
+ """
152
+ if not self.repository.strip():
153
+ raise InvalidRegistryTargetError("the destination repository cannot be empty")
154
+ if not _TAG.match(self.tag or ""):
155
+ raise InvalidRegistryTargetError(f"invalid tag: {self.tag!r}")
156
+ for component in self.path.split("/"):
157
+ if not _PATH_COMPONENT.match(component):
158
+ raise InvalidRegistryTargetError(
159
+ f"invalid path component: {component!r} "
160
+ "(lowercase, digits, and the . _ - separators)"
161
+ )
162
+ self._validate_provider()
163
+
164
+ def _validate_provider(self) -> None:
165
+ provider = self.provider
166
+ if provider is RegistryProvider.DOCKER_HUB:
167
+ # Sem namespace, `docker push` mira `library/<repo>`, que é
168
+ # reservado às imagens oficiais: o push é recusado com "denied" e
169
+ # a mensagem não explica por quê.
170
+ if not self.namespace.strip("/"):
171
+ raise InvalidRegistryTargetError(
172
+ "Docker Hub requires the user or organization as the namespace "
173
+ "(e.g. myorg/dockerls); without it the push targets library/, "
174
+ "which is reserved for official images"
175
+ )
176
+ if self.namespace.strip("/").lower() == "library":
177
+ raise InvalidRegistryTargetError(
178
+ "`library` is the namespace of Docker Hub official images and "
179
+ "does not accept third-party publishing"
180
+ )
181
+ elif provider is RegistryProvider.GOOGLE_ARTIFACT_REGISTRY:
182
+ # gcr.io aceitava `projeto/imagem`; o Artifact Registry exige o
183
+ # repositório no caminho, e omiti-lo falha só na hora do push.
184
+ if len(self.path.split("/")) < 3:
185
+ raise InvalidRegistryTargetError(
186
+ "Artifact Registry requires <project>/<repository>/<image> in the "
187
+ "path (e.g. my-project/containers/dockerls)"
188
+ )
189
+ elif provider is RegistryProvider.DHI:
190
+ raise InvalidRegistryTargetError(
191
+ "dhi.io is a Docker image catalogue, not a publish destination: it "
192
+ "distributes hardened images and does not accept pushes"
193
+ )
194
+
195
+ @classmethod
196
+ def parse(cls, destination: str, tag: str) -> RegistryTarget:
197
+ """Monta um destino a partir de `host/namespace/repo` e uma tag.
198
+
199
+ O host é reconhecido pela mesma regra do Docker -- primeiro componente
200
+ com ponto, dois-pontos, ou igual a `localhost` --, e o que sobra é
201
+ dividido em namespace e repositório.
202
+ """
203
+ value = destination.strip().strip("/")
204
+ if not value:
205
+ raise InvalidRegistryTargetError("empty destination")
206
+ # Uma tag embutida no destino é ambiguidade, não conveniência: qual
207
+ # das duas vale, a de `--tag` ou a colada aqui?
208
+ head = value.split("/", 1)[0]
209
+ if ":" in value.rsplit("/", 1)[-1]:
210
+ raise InvalidRegistryTargetError(
211
+ "give the destination without a tag; the tag comes from --tag so "
212
+ "there are never two"
213
+ )
214
+
215
+ if "." in head or ":" in head or head.lower() == "localhost":
216
+ host, _, rest = value.partition("/")
217
+ else:
218
+ host, rest = "", value
219
+ if not rest:
220
+ raise InvalidRegistryTargetError(
221
+ f"the destination {destination!r} does not name a repository"
222
+ )
223
+
224
+ namespace, _, repository = rest.rpartition("/")
225
+ return cls(host=host, repository=repository, tag=tag, namespace=namespace)
@@ -0,0 +1,62 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ if TYPE_CHECKING:
6
+ from dockerls.domain.entities.scan_result import ScanResult
7
+
8
+
9
+ #: Faixas de `fixable / total` e a nota que cada uma vale. É uma escala
10
+ #: ordinal de cinco degraus, deliberadamente -- não uma porcentagem. A
11
+ #: proporção exata oscila a cada atualização de banco de vulnerabilidades, e
12
+ #: um número que muda de 41% para 39% sozinho sugere uma precisão que o dado
13
+ #: não tem. O degrau responde à pergunta que interessa: "dá para consertar a
14
+ #: maior parte disto?".
15
+ _BANDS: tuple[tuple[float, int], ...] = (
16
+ (1.0, 100),
17
+ (0.75, 80),
18
+ (0.5, 60),
19
+ (0.25, 40),
20
+ )
21
+ _LOWEST_BAND = 20
22
+
23
+
24
+ class RemediationScore:
25
+ """Quão remediável é um scan, numa escala ordinal de 0 a 100.
26
+
27
+ **Não é a porcentagem de vulnerabilidades corrigíveis.** É a razão
28
+ `fixable / total` mapeada em cinco degraus:
29
+
30
+ | fixable / total | nota |
31
+ |-----------------|------|
32
+ | 100% | 100 |
33
+ | >= 75% | 80 |
34
+ | >= 50% | 60 |
35
+ | >= 25% | 40 |
36
+ | < 25% | 20 |
37
+ | sem vulns | 100 |
38
+
39
+ Ou seja: 16 corrigíveis em 170 (9,4%) valem **20**, e isso está correto.
40
+ Ler esse 20 como "20%" -- que era o que o terminal sugeria ao imprimir um
41
+ `%` ao lado -- faz o número parecer errado por um fator de dois. Quem
42
+ quiser a proporção crua a tem impressa ao lado, em `analyze`.
43
+ """
44
+
45
+ def __init__(self, scan: ScanResult):
46
+ self._scan = scan
47
+ self._value = self._calculate()
48
+
49
+ @property
50
+ def value(self) -> int:
51
+ return self._value
52
+
53
+ def _calculate(self) -> int:
54
+ total = self._scan.total_count
55
+ if total == 0:
56
+ return 100
57
+
58
+ ratio = self._scan.fixable_count / total
59
+ for threshold, score in _BANDS:
60
+ if ratio >= threshold:
61
+ return score
62
+ return _LOWEST_BAND
@@ -0,0 +1,183 @@
1
+ """How an image's vulnerability counts moved across scans over time.
2
+
3
+ `tag_history.py` answers "did the bytes behind this reference change";
4
+ this answers the question underneath it -- "when this reference was
5
+ scanned again, did the count of findings go up or down". Neither implies
6
+ the other: a tag can move to a new digest with fewer CVEs, or stay on the
7
+ exact same digest while the scanner's database learns about a new one
8
+ between two runs.
9
+
10
+ Same two choices as `tag_history.py`, for the same reasons:
11
+
12
+ * **Only a change enters.** Scanning the same reference twice with the
13
+ same digest and the same counts is not a second event; recording it
14
+ anyway would bury the actual changes in noise.
15
+ * **The first observation is never the one pruning drops.** It anchors
16
+ "since when", and a history that starts at an arbitrary later point is
17
+ a history that lies about how long it has been watching.
18
+
19
+ Nothing here claims a reference was ever *clean*: the history begins at
20
+ the first scan this tool happened to run, and whatever came before that is
21
+ unknown, not absent.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from dataclasses import dataclass, field, replace
27
+
28
+ #: Same ceiling as `tag_history.py`, for the same reason: enough
29
+ #: observations to see a trend, small enough that a whole fleet's history
30
+ #: does not turn the cache into a database by accident.
31
+ MAX_OBSERVATIONS = 24
32
+
33
+ #: The dimensions tracked and, in this order, reported when they change.
34
+ _FIELDS = ("critical", "high", "medium", "low", "total")
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class ScanObservation:
39
+ """One scan's counts, and when this tool saw them."""
40
+
41
+ digest: str
42
+ observed_at: str
43
+ critical: int = 0
44
+ high: int = 0
45
+ medium: int = 0
46
+ low: int = 0
47
+ total: int = 0
48
+
49
+ def counts(self) -> tuple[int, ...]:
50
+ """The tracked dimensions as a tuple, in `_FIELDS` order -- for
51
+ comparing two observations without naming every field."""
52
+ return tuple(getattr(self, name) for name in _FIELDS)
53
+
54
+ def to_dict(self) -> dict[str, object]:
55
+ return {
56
+ "digest": self.digest,
57
+ "observed_at": self.observed_at,
58
+ **{name: getattr(self, name) for name in _FIELDS},
59
+ }
60
+
61
+ @staticmethod
62
+ def from_dict(raw: object) -> ScanObservation | None:
63
+ """An observation from the cache, or `None` when the row is unusable.
64
+
65
+ The cache is content from outside this call: a corrupted entry or
66
+ one written by an earlier version must not take down a scan.
67
+ """
68
+ if not isinstance(raw, dict):
69
+ return None
70
+ digest = raw.get("digest")
71
+ observed = raw.get("observed_at")
72
+ if not isinstance(digest, str) or not isinstance(observed, str) or not digest.strip():
73
+ return None
74
+ counts: dict[str, int] = {}
75
+ for name in _FIELDS:
76
+ value = raw.get(name, 0)
77
+ if not isinstance(value, int) or isinstance(value, bool) or value < 0:
78
+ return None
79
+ counts[name] = value
80
+ return ScanObservation(digest=digest.strip(), observed_at=observed.strip(), **counts)
81
+
82
+
83
+ @dataclass(frozen=True)
84
+ class ScanHistory:
85
+ """What is known about a reference's vulnerability counts, in order."""
86
+
87
+ reference: str
88
+ observations: tuple[ScanObservation, ...] = field(default_factory=tuple)
89
+ #: Same accounting as `TagHistory.dropped`: observations pruned off the
90
+ #: front, counted rather than silently forgotten.
91
+ dropped: int = 0
92
+
93
+ @property
94
+ def is_empty(self) -> bool:
95
+ return not self.observations
96
+
97
+ @property
98
+ def scans(self) -> int:
99
+ """Total scans this history has ever recorded, pruned ones included."""
100
+ return len(self.observations) + self.dropped
101
+
102
+ @property
103
+ def first_seen(self) -> str:
104
+ return self.observations[0].observed_at if self.observations else ""
105
+
106
+ @property
107
+ def latest(self) -> ScanObservation | None:
108
+ return self.observations[-1] if self.observations else None
109
+
110
+ def explain(self) -> str:
111
+ """The sentence that turns two counts into a fact worth reading."""
112
+ if self.is_empty:
113
+ return "first scan recorded for this reference: there is no history to compare against"
114
+ if len(self.observations) == 1:
115
+ return f"first scan recorded on {self.first_seen}; nothing to compare it to yet"
116
+
117
+ previous, current = self.observations[-2], self.observations[-1]
118
+ deltas = _describe_deltas(previous, current)
119
+ if not deltas:
120
+ return f"unchanged since the previous scan on {previous.observed_at}"
121
+ return f"since {previous.observed_at}: " + ", ".join(deltas)
122
+
123
+ def to_dict(self) -> dict[str, object]:
124
+ return {
125
+ "reference": self.reference,
126
+ "scans": self.scans,
127
+ "dropped_observations": self.dropped,
128
+ "first_seen": self.first_seen,
129
+ "explanation": self.explain(),
130
+ "observations": [o.to_dict() for o in self.observations],
131
+ }
132
+
133
+ @staticmethod
134
+ def from_dict(reference: str, raw: object) -> ScanHistory:
135
+ """Reconstructs from the cache, discarding whatever does not parse.
136
+
137
+ An unreadable history becomes an empty one -- which is honest:
138
+ nothing usable is known about this reference. Never an exception:
139
+ this is an extra on top of the scan, not something that may block it.
140
+ """
141
+ if not isinstance(raw, dict):
142
+ return ScanHistory(reference=reference)
143
+ entries = raw.get("observations")
144
+ if not isinstance(entries, list):
145
+ return ScanHistory(reference=reference)
146
+ parsed = [o for o in (ScanObservation.from_dict(e) for e in entries) if o is not None]
147
+ dropped = raw.get("dropped_observations")
148
+ return ScanHistory(
149
+ reference=reference,
150
+ observations=tuple(parsed),
151
+ dropped=dropped if isinstance(dropped, int) and dropped >= 0 else 0,
152
+ )
153
+
154
+
155
+ def _describe_deltas(previous: ScanObservation, current: ScanObservation) -> list[str]:
156
+ deltas = []
157
+ for name in _FIELDS:
158
+ change = getattr(current, name) - getattr(previous, name)
159
+ if change:
160
+ deltas.append(f"{name} {change:+d}")
161
+ return deltas
162
+
163
+
164
+ def record(history: ScanHistory, observation: ScanObservation) -> ScanHistory:
165
+ """The history with this observation incorporated.
166
+
167
+ An observation whose digest and every count match the last one
168
+ recorded changes nothing worth saying, so it is dropped -- the same
169
+ rule `tag_history.record` applies to a tag that did not move.
170
+ """
171
+ if not observation.digest.strip():
172
+ return history
173
+ if history.observations:
174
+ last = history.observations[-1]
175
+ if last.digest == observation.digest and last.counts() == observation.counts():
176
+ return history
177
+
178
+ entries = (*history.observations, observation)
179
+ dropped = history.dropped
180
+ if len(entries) > MAX_OBSERVATIONS:
181
+ dropped += len(entries) - MAX_OBSERVATIONS
182
+ entries = (entries[0], *entries[-(MAX_OBSERVATIONS - 1) :])
183
+ return replace(history, observations=entries, dropped=dropped)