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,193 @@
1
+ """Quais tags medir, quando medir todas custa minutos.
2
+
3
+ `recommend node` descobre até `max_tags` tags e mostra cinco. Com o padrão
4
+ de 100 e o Trivy a 1,2-2,5s por imagem, isso são dois a quatro minutos de
5
+ relógio -- e 95 dos 100 scans existem só para serem descartados no
6
+ ranqueamento.
7
+
8
+ O corte podia ser feito de dois jeitos, e só um deles é honesto neste
9
+ projeto:
10
+
11
+ * **baixar `max_tags`** faria uma tag boa fora da janela simplesmente não
12
+ existir, sem nada dizendo que ela existiu;
13
+ * **escolher quem medir, e declarar quem ficou de fora**, que é o que este
14
+ módulo faz.
15
+
16
+ A distinção que sustenta tudo aqui: uma tag adiada **não é uma tag pior**.
17
+ Ela é uma tag *não medida*, e o resultado a carrega com o motivo, do mesmo
18
+ jeito que `UnverifiedImage` carrega um scan que falhou. Em nenhum momento a
19
+ ausência de medição é gasta como se fosse medição.
20
+
21
+ Nenhuma regra aqui consulta rede, disco ou scanner: todas usam fatos que a
22
+ listagem de tags já trouxe. Uma função pura de decisão se testa
23
+ exaustivamente, e é isso que torna defensável cortar 75 scans.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import re
29
+ from dataclasses import dataclass, field
30
+ from enum import StrEnum
31
+ from typing import TYPE_CHECKING
32
+
33
+ if TYPE_CHECKING:
34
+ from collections.abc import Sequence
35
+
36
+ from dockerls.domain.entities.image import DockerImage
37
+
38
+ #: Quantas tags medir quando nada é configurado. `TOP_N` é 5 e a
39
+ #: verificação final olha 10 finalistas, então 25 deixa folga de sobra para
40
+ #: o ranqueamento ter de quem escolher.
41
+ DEFAULT_SCAN_BUDGET = 25
42
+
43
+ #: Versão no começo da tag: `22`, `22.14`, `3.21.0`. O resto, depois do
44
+ #: primeiro `-`, é a variante (`alpine`, `bookworm-slim`, `jre-alpine`).
45
+ _VERSION_PREFIX = re.compile(r"^v?(\d+(?:\.\d+)*)(?:[-_.](.*))?$", re.IGNORECASE)
46
+
47
+
48
+ class DeferralReason(StrEnum):
49
+ """Por que uma tag não foi medida. Nunca "por ser pior"."""
50
+
51
+ #: Existe uma tag mais nova da mesma linha, com a mesma precisão de
52
+ #: versão e a mesma variante -- `22.13-alpine` diante de `22.14-alpine`.
53
+ SUPERSEDED = "SUPERSEDED"
54
+ #: Coube fora do orçamento de scans deste run.
55
+ OVER_BUDGET = "OVER_BUDGET"
56
+
57
+
58
+ @dataclass(frozen=True)
59
+ class DeferredTag:
60
+ """Uma tag que existe, foi descoberta, e não foi medida."""
61
+
62
+ reference: str
63
+ reason: DeferralReason
64
+ #: Frase para o humano. Diz o fato que motivou o adiamento, e nunca
65
+ #: uma conclusão sobre a segurança da imagem.
66
+ detail: str
67
+
68
+
69
+ @dataclass(frozen=True)
70
+ class ScanPlan:
71
+ """O que este run vai medir, e o que ele declaradamente não vai."""
72
+
73
+ selected: list[DockerImage]
74
+ deferred: list[DeferredTag] = field(default_factory=list)
75
+ #: Quantas tags a busca trouxe, antes de qualquer corte.
76
+ discovered: int = 0
77
+ #: O orçamento em vigor; 0 significa "medir todas".
78
+ budget: int = 0
79
+
80
+ @property
81
+ def deferred_count(self) -> int:
82
+ return len(self.deferred)
83
+
84
+
85
+ def _version_and_variant(tag: str) -> tuple[tuple[int, ...] | None, str]:
86
+ """A versão numérica no início da tag e a variante que sobra.
87
+
88
+ `22.14-alpine` -> `((22, 14), "alpine")`. Uma tag sem versão numérica
89
+ (`latest`, `lts`, `bookworm`) devolve `None`, e tags assim nunca são
90
+ adiadas por sucessão: não há como ordenar o que não tem número.
91
+ """
92
+ match = _VERSION_PREFIX.match(tag.strip())
93
+ if match is None:
94
+ return None, tag.strip().lower()
95
+ version = tuple(int(part) for part in match.group(1).split("."))
96
+ return version, (match.group(2) or "").lower()
97
+
98
+
99
+ def _superseded(tags: Sequence[DockerImage]) -> dict[str, DeferredTag]:
100
+ """As tags que uma irmã mais nova da mesma linha já representa.
101
+
102
+ "Mesma linha" é: mesma variante, mesmo major, e mesma quantidade de
103
+ componentes de versão. As duas primeiras condições são a linha em si;
104
+ a terceira é o que impede colapsar `22-alpine` com `22.14-alpine`, que
105
+ são respostas diferentes para perguntas diferentes -- uma acompanha a
106
+ linha, a outra fixa o patch, e escolher entre as duas é do usuário.
107
+
108
+ Majors não competem entre si: `20-alpine` continua sendo uma resposta
109
+ legítima ao lado de `22-alpine`, e frequentemente é a certa (LTS).
110
+ """
111
+ newest: dict[tuple[str, int, tuple[int, ...]], DockerImage] = {}
112
+ for image in tags:
113
+ version, variant = _version_and_variant(image.tag)
114
+ if version is None:
115
+ continue
116
+ key = (variant, len(version), version[:1])
117
+ current = newest.get(key)
118
+ if current is None:
119
+ newest[key] = image
120
+ continue
121
+ current_version, _ = _version_and_variant(current.tag)
122
+ if current_version is not None and version > current_version:
123
+ newest[key] = image
124
+
125
+ winners = {id(image) for image in newest.values()}
126
+ deferred: dict[str, DeferredTag] = {}
127
+ for image in tags:
128
+ version, variant = _version_and_variant(image.tag)
129
+ if version is None or id(image) in winners:
130
+ continue
131
+ key = (variant, len(version), version[:1])
132
+ winner = newest[key]
133
+ deferred[image.full_reference] = DeferredTag(
134
+ reference=image.full_reference,
135
+ reason=DeferralReason.SUPERSEDED,
136
+ detail=(f"a newer tag of the same line was measured instead ({winner.tag})"),
137
+ )
138
+ return deferred
139
+
140
+
141
+ def _priority(image: DockerImage) -> tuple[int, float, str]:
142
+ """Ordem de preferência para gastar o orçamento.
143
+
144
+ Critérios que a listagem já conhece, sem scan: imagem oficial primeiro,
145
+ depois a mais recentemente publicada, depois o nome -- este último só
146
+ para que dois runs sobre os mesmos dados escolham as mesmas tags.
147
+ """
148
+ published = image.last_updated.timestamp() if image.last_updated is not None else 0.0
149
+ return (0 if image.is_official else 1, -published, image.full_reference)
150
+
151
+
152
+ def plan_scans(tags: Sequence[DockerImage], budget: int = DEFAULT_SCAN_BUDGET) -> ScanPlan:
153
+ """Escolhe quem medir, e nomeia quem ficou de fora.
154
+
155
+ `budget <= 0` mede tudo, que é o comportamento de sempre e continua
156
+ disponível por configuração -- quem precisa da varredura completa não
157
+ perdeu nada.
158
+ """
159
+ discovered = len(tags)
160
+ if budget <= 0 or discovered <= budget:
161
+ return ScanPlan(
162
+ selected=list(tags), deferred=[], discovered=discovered, budget=max(0, budget)
163
+ )
164
+
165
+ superseded = _superseded(tags)
166
+ survivors = [image for image in tags if image.full_reference not in superseded]
167
+
168
+ deferred = [
169
+ superseded[image.full_reference] for image in tags if image.full_reference in superseded
170
+ ]
171
+
172
+ if len(survivors) > budget:
173
+ ranked = sorted(survivors, key=_priority)
174
+ kept = set(map(id, ranked[:budget]))
175
+ survivors = [image for image in tags if id(image) in kept]
176
+ for image in ranked[budget:]:
177
+ deferred.append(
178
+ DeferredTag(
179
+ reference=image.full_reference,
180
+ reason=DeferralReason.OVER_BUDGET,
181
+ detail=f"beyond this run's budget of {budget} scans",
182
+ )
183
+ )
184
+
185
+ # A ordem de saída é a da descoberta: o ranqueamento por segurança
186
+ # acontece depois, sobre medições, e reordenar aqui embaralharia a
187
+ # entrada de uma decisão que nada tem a ver com esta.
188
+ return ScanPlan(
189
+ selected=survivors,
190
+ deferred=deferred,
191
+ discovered=discovered,
192
+ budget=budget,
193
+ )
@@ -0,0 +1,143 @@
1
+ """Há quanto tempo o medidor foi atualizado, e o que isso permite afirmar.
2
+
3
+ `doctor` conferia que o scanner **existe**. Não conferia que ele mede: um
4
+ Trivy com base de vulnerabilidades de três semanas produz um scan limpo,
5
+ verde e sem erro nenhum que simplesmente não conhece os CVEs do último mês.
6
+
7
+ É a falha de medição mais silenciosa que existe aqui, e a mais perigosa
8
+ justamente por ser silenciosa -- nada no relatório indica que a resposta
9
+ está velha. É o tema central do projeto ("ausência de medição nunca é gasta
10
+ como tranquilidade") aplicado ao próprio medidor.
11
+
12
+ O tristate aparece de novo, e pela mesma razão de sempre: não conseguir ler
13
+ a data da base **não** é a base estar fresca. `UNKNOWN` é o valor honesto
14
+ para um arquivo de metadados ausente, ilegível ou com um formato que este
15
+ código não reconhece, e ele nunca é apresentado como aprovação.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from dataclasses import dataclass
21
+ from datetime import UTC, datetime, timedelta
22
+ from enum import StrEnum
23
+
24
+ #: Até aqui, a base cobre o que se publicou recentemente. O Trivy reconstrói
25
+ #: a dele a cada 6h e carimba `NextUpdate` cerca de 24h à frente; o Grype
26
+ #: publica diariamente. Um dia é a janela normal dos dois.
27
+ FRESH_WITHIN = timedelta(hours=24)
28
+
29
+ #: Daqui em diante a base já perdeu dias de publicação. Não é inutilizável
30
+ #: -- a maioria dos CVEs de uma imagem é antiga --, mas quem está decidindo
31
+ #: um deploy precisa saber que a resposta tem essa idade.
32
+ STALE_AFTER = timedelta(hours=72)
33
+
34
+
35
+ class DatabaseState(StrEnum):
36
+ FRESH = "FRESH"
37
+ #: Entre um e três dias. Ainda mede, e já vale dizer a idade.
38
+ AGING = "AGING"
39
+ STALE = "STALE"
40
+ #: Não foi possível ler a data. **Não** é o mesmo que fresca.
41
+ UNKNOWN = "UNKNOWN"
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class DatabaseFreshness:
46
+ """O que se sabe sobre a idade da base de um scanner."""
47
+
48
+ scanner: str
49
+ state: DatabaseState
50
+ #: Quando a base foi construída, quando se conseguiu ler.
51
+ built_at: datetime | None = None
52
+ #: Idade no momento da checagem.
53
+ age: timedelta | None = None
54
+ #: Por que não foi possível ler, quando não foi.
55
+ detail: str = ""
56
+
57
+ @property
58
+ def is_usable_measurement(self) -> bool:
59
+ """Se um scan feito agora mediria o que se publicou recentemente.
60
+
61
+ `UNKNOWN` responde `False` de propósito: a pergunta é "dá para
62
+ confiar na atualidade desta base", e "não sei" não é sim.
63
+ """
64
+ return self.state in (DatabaseState.FRESH, DatabaseState.AGING)
65
+
66
+ def explain(self) -> str:
67
+ """A frase que diz o que se sabe, sem arredondar para o lado bom."""
68
+ match self.state:
69
+ case DatabaseState.FRESH:
70
+ return f"updated {_human(self.age)} ago"
71
+ case DatabaseState.AGING:
72
+ return (
73
+ f"updated {_human(self.age)} ago: still usable, and already missing "
74
+ "whatever was published since"
75
+ )
76
+ case DatabaseState.STALE:
77
+ return (
78
+ f"updated {_human(self.age)} ago. A scan against it comes back clean "
79
+ "for anything published since, which reads exactly like a clean "
80
+ "image and is not one"
81
+ )
82
+ case DatabaseState.UNKNOWN:
83
+ reason = self.detail or "no readable metadata"
84
+ return (
85
+ f"age unknown ({reason}). This is not the same as up to date: "
86
+ "nothing here says how old the answers are"
87
+ )
88
+
89
+ def to_dict(self) -> dict[str, object]:
90
+ return {
91
+ "scanner": self.scanner,
92
+ "state": str(self.state),
93
+ "built_at": self.built_at.isoformat() if self.built_at else "",
94
+ "age_hours": round(self.age.total_seconds() / 3600, 1) if self.age else None,
95
+ "explanation": self.explain(),
96
+ }
97
+
98
+
99
+ def classify(
100
+ scanner: str,
101
+ built_at: datetime | None,
102
+ *,
103
+ now: datetime | None = None,
104
+ detail: str = "",
105
+ ) -> DatabaseFreshness:
106
+ """Classifica a idade de uma base a partir da data em que foi construída.
107
+
108
+ Puro: recebe a data já lida, para que a regra se teste sem tocar disco.
109
+ """
110
+ if built_at is None:
111
+ return DatabaseFreshness(scanner, DatabaseState.UNKNOWN, detail=detail)
112
+
113
+ moment = now or datetime.now(tz=UTC)
114
+ stamped = built_at if built_at.tzinfo is not None else built_at.replace(tzinfo=UTC)
115
+ age = moment - stamped
116
+
117
+ if age < timedelta(0):
118
+ # Uma base "do futuro" é relógio errado numa das duas pontas, e
119
+ # tratá-la como fresquíssima esconderia o problema.
120
+ return DatabaseFreshness(
121
+ scanner,
122
+ DatabaseState.UNKNOWN,
123
+ built_at=stamped,
124
+ detail="the database is stamped in the future; check the clock",
125
+ )
126
+ if age <= FRESH_WITHIN:
127
+ state = DatabaseState.FRESH
128
+ elif age <= STALE_AFTER:
129
+ state = DatabaseState.AGING
130
+ else:
131
+ state = DatabaseState.STALE
132
+ return DatabaseFreshness(scanner, state, built_at=stamped, age=age)
133
+
134
+
135
+ def _human(age: timedelta | None) -> str:
136
+ if age is None:
137
+ return "an unknown time"
138
+ hours = age.total_seconds() / 3600
139
+ if hours < 1:
140
+ return f"{int(age.total_seconds() // 60)} minutes"
141
+ if hours < 48:
142
+ return f"{hours:.0f} hours"
143
+ return f"{hours / 24:.1f} days"
@@ -0,0 +1,160 @@
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ from typing import TYPE_CHECKING
5
+
6
+ from loguru import logger
7
+
8
+ from dockerls.domain.entities.scan_result import ScanResult, ScanStatus
9
+
10
+ if TYPE_CHECKING:
11
+ from dockerls.domain.entities.image import DockerImage
12
+
13
+
14
+ CRITICAL_PENALTY = 20.0
15
+ HIGH_PENALTY = 5.0
16
+ MEDIUM_PENALTY = 1.0
17
+ EOL_PENALTY = 20.0
18
+ # Age is a staleness signal, not a vulnerability. Uncapped it grew by one
19
+ # point per year, so a 10-year-old image lost as much as two HIGH findings
20
+ # on age alone. Capped at 3, it can order equally-clean images without
21
+ # ever competing with measured severity.
22
+ MAX_AGE_PENALTY = 3.0
23
+ EXPLOITED_PENALTY = 10.0
24
+ # The step: a vulnerability at or above this probability draws a flat
25
+ # penalty. Kept because it is the part an operator can reason about ("EPSS
26
+ # over 0.5 costs 5 points"), and because removing it would silently change
27
+ # every score this tool has ever produced.
28
+ HIGH_EPSS_PENALTY = 5.0
29
+ HIGH_EPSS_THRESHOLD = 0.5
30
+ # The slope, added on top. The step alone made 0.97 and 0.51 cost exactly
31
+ # the same, and 0.49 cost nothing -- a cliff edge in the middle of a
32
+ # continuous measurement, where the difference between "half the time" and
33
+ # "almost certainly" is the whole point of having the number. The term is
34
+ # proportional to the probability itself and is capped well below a single
35
+ # HIGH finding, so it orders comparable images without ever competing with
36
+ # measured severity.
37
+ EPSS_SLOPE_PENALTY = 4.0
38
+
39
+ # Qualitative bonuses. Their total is deliberately held *below* the HIGH
40
+ # penalty: no amount of "official + minimal + signed + LTS + recent" may
41
+ # lift an image with an extra HIGH or CRITICAL above a cleaner one. They
42
+ # can outweigh a MEDIUM or two, which is intended -- a signed official
43
+ # distroless image with a couple of mediums is a reasonable pick over an
44
+ # unremarkable image with none.
45
+ OFFICIAL_BONUS = 1.0
46
+ MINIMAL_BASE_BONUS = 1.0
47
+ SIGNED_BONUS = 1.0
48
+ LTS_BONUS = 0.5
49
+ RECENT_BONUS = 0.5
50
+ MAX_BONUS = OFFICIAL_BONUS + MINIMAL_BASE_BONUS + SIGNED_BONUS + LTS_BONUS + RECENT_BONUS
51
+
52
+ # Scoring starts here rather than at 100 so a fully-decorated clean image
53
+ # lands exactly on 100 without being clamped. Clamping at the top was
54
+ # collapsing genuinely different images onto the same number: a clean
55
+ # image, a 1-HIGH image and a 5-MEDIUM image all read 100.0.
56
+ BASE_SCORE = 100.0 - MAX_BONUS
57
+
58
+
59
+ class SecurityScore:
60
+ def __init__(
61
+ self,
62
+ image: DockerImage,
63
+ scan: ScanResult,
64
+ is_eol: bool = False,
65
+ is_lts: bool = False,
66
+ ):
67
+ if scan.status not in (ScanStatus.OK, ScanStatus.PARTIAL):
68
+ raise ValueError(
69
+ f"Cannot score {image.full_reference}: scan status is "
70
+ f"{scan.status.value} ({scan.error_message or 'no details'})"
71
+ )
72
+ self._image = image
73
+ self._scan = scan
74
+ self._is_eol = is_eol
75
+ self._is_lts = is_lts
76
+ self._value = self._calculate()
77
+
78
+ @property
79
+ def value(self) -> float:
80
+ return self._value
81
+
82
+ @property
83
+ def penalty(self) -> float:
84
+ """Everything measured about the image's vulnerabilities.
85
+
86
+ This alone decides the ordering between images with different
87
+ severity profiles -- the qualitative bonuses cannot overturn it for
88
+ HIGH or CRITICAL findings.
89
+ """
90
+ penalty = (
91
+ self._scan.critical_count * CRITICAL_PENALTY
92
+ + self._scan.high_count * HIGH_PENALTY
93
+ + self._scan.medium_count * MEDIUM_PENALTY
94
+ )
95
+
96
+ # CISA KEV / EPSS threat-intel signal: a vulnerability with a
97
+ # confirmed real-world exploit (or a high predicted exploitation
98
+ # probability) is materially worse than an unweighted CVSS count
99
+ # suggests, so it draws an extra penalty on top of the base
100
+ # severity penalties above.
101
+ penalty += EXPLOITED_PENALTY * sum(1 for v in self._scan.vulnerabilities if v.exploit_known)
102
+ penalty += HIGH_EPSS_PENALTY * sum(
103
+ 1 for v in self._scan.vulnerabilities if v.epss_score >= HIGH_EPSS_THRESHOLD
104
+ )
105
+ # Continuous term, over the same findings as the step. No
106
+ # `epss_known` check is needed here and adding one would be wrong:
107
+ # the field defaults to 0.0, so a *non-zero* score is itself proof
108
+ # that a lookup returned it, and a zero contributes nothing either
109
+ # way. `epss_known` earns its keep in the reporting layer, where the
110
+ # difference between "scored at zero" and "never looked up" is a
111
+ # statement rather than a number.
112
+ penalty += EPSS_SLOPE_PENALTY * sum(v.epss_score for v in self._scan.vulnerabilities)
113
+
114
+ if self._is_eol:
115
+ penalty += EOL_PENALTY
116
+ # Age only moves the score when the source actually reported a
117
+ # publish date. Registries that list tag names only (Chainguard,
118
+ # most OCI catalogues) would otherwise be charged the maximum age
119
+ # penalty and denied the recency bonus for missing metadata.
120
+ if self._image.age_known:
121
+ penalty += min(self._image.age_days / 365.0, MAX_AGE_PENALTY)
122
+ return penalty
123
+
124
+ @property
125
+ def bonus(self) -> float:
126
+ """Qualitative signals, capped below a single HIGH finding."""
127
+ bonus = 0.0
128
+ if self._image.is_official:
129
+ bonus += OFFICIAL_BONUS
130
+ # Distroless, hardened-vendor (Chainguard/Wolfi/Bitnami), and Alpine
131
+ # are all "minimal base" signals; an image matching more than one
132
+ # must not be double-counted.
133
+ if self._image.is_distroless or self._image.is_hardened_source or self._image.is_alpine:
134
+ bonus += MINIMAL_BASE_BONUS
135
+ if self._image.is_signed:
136
+ bonus += SIGNED_BONUS
137
+ if self._is_lts:
138
+ bonus += LTS_BONUS
139
+ if self._image.age_known and self._image.recently_updated:
140
+ bonus += RECENT_BONUS
141
+ return bonus
142
+
143
+ def _calculate(self) -> float:
144
+ # No "zero vulnerabilities" bonus: zero findings already means zero
145
+ # penalty, so rewarding it again double-counted the same fact and
146
+ # was part of what pushed clean images into the clamp.
147
+ score = BASE_SCORE - self.penalty + self.bonus
148
+ if not math.isfinite(score):
149
+ # The clamp below cannot catch this: every comparison against
150
+ # NaN is False, so `max(0.0, min(100.0, nan))` answers 100.0 --
151
+ # the arithmetic failing produced the highest possible score.
152
+ # Whatever went wrong, it is not evidence that the image is
153
+ # clean, so the score collapses to the bottom and the reason is
154
+ # loud.
155
+ logger.error(
156
+ f"Security score for {self._image.name}:{self._image.tag} was not a finite "
157
+ f"number (penalty={self.penalty}, bonus={self.bonus}); reporting 0.0"
158
+ )
159
+ return 0.0
160
+ return max(0.0, min(100.0, round(score, 1)))
@@ -0,0 +1,122 @@
1
+ from __future__ import annotations
2
+
3
+ from enum import StrEnum
4
+ from typing import TYPE_CHECKING
5
+
6
+ if TYPE_CHECKING:
7
+ from dockerls.domain.entities.scan_result import ScanResult
8
+
9
+
10
+ class Tier(StrEnum):
11
+ A = "A"
12
+ B = "B"
13
+ C = "C"
14
+ D = "D"
15
+ E = "E"
16
+ F = "F"
17
+
18
+
19
+ #: Do melhor para o pior. A ordem é explícita porque a trava de CRITICAL
20
+ #: precisa comparar tiers, e comparar letras alfabeticamente só funciona por
21
+ #: coincidência -- deixaria de funcionar no instante em que a escala mudar.
22
+ TIER_ORDER: tuple[Tier, ...] = (Tier.A, Tier.B, Tier.C, Tier.D, Tier.E, Tier.F)
23
+
24
+ #: Piso de score de cada faixa, do topo para a base. Cobre toda a faixa
25
+ #: 0-100: antes a escala parava em C, então um score 0.0 com 6 CRITICAL e 170
26
+ #: vulnerabilidades recebia exatamente o mesmo tier de uma imagem 36 pontos
27
+ #: melhor -- a nota deixava de discriminar justamente onde mais importava.
28
+ TIER_BANDS: tuple[tuple[float, Tier], ...] = (
29
+ (90.0, Tier.A),
30
+ (75.0, Tier.B),
31
+ (60.0, Tier.C),
32
+ (40.0, Tier.D),
33
+ (20.0, Tier.E),
34
+ )
35
+ LOWEST_TIER = Tier.F
36
+
37
+ #: Melhor tier que uma imagem com CRITICAL sem correção disponível pode
38
+ #: alcançar. Um CRITICAL que nem dá para consertar não é compensável por
39
+ #: nenhum outro sinal: por mais alto que o score fique, a imagem não passa de
40
+ #: "exige revisão humana".
41
+ UNFIXABLE_CRITICAL_CEILING = Tier.C
42
+
43
+ #: Tiers que uma imagem precisa alcançar para ser considerada pronta para
44
+ #: produção, alinhado com `DockerfileAnalysis.is_production_ready`.
45
+ PRODUCTION_READY_TIERS = (Tier.A, Tier.B)
46
+
47
+
48
+ def tier_for_score(score: float) -> Tier:
49
+ for floor, tier in TIER_BANDS:
50
+ if score >= floor:
51
+ return tier
52
+ return LOWEST_TIER
53
+
54
+
55
+ class SecurityTier:
56
+ """Traduz o score de segurança numa nota de A a F.
57
+
58
+ A escala completa, documentada também no README:
59
+
60
+ | Tier | Score | Leitura |
61
+ |------|---------|--------------------------------------------|
62
+ | A | 90-100 | pronta para produção |
63
+ | B | 75-89 | pronta para produção |
64
+ | C | 60-74 | condicional: exige revisão humana |
65
+ | D | 40-59 | não pronta para produção |
66
+ | E | 20-39 | não pronta para produção |
67
+ | F | 0-19 | não usar |
68
+
69
+ Sobre a trava: uma imagem com CRITICAL **sem correção disponível** nunca
70
+ passa de C, por mais alto que o score tenha ficado. E uma base EOL nunca é
71
+ production-ready, independentemente do tier -- ela vai parar de receber
72
+ correções de segurança de qualquer forma.
73
+ """
74
+
75
+ ADVICE = {
76
+ Tier.C: "conditional -- requires human review before production use",
77
+ Tier.D: "not production ready",
78
+ Tier.E: "not production ready",
79
+ Tier.F: "not production ready -- do not deploy",
80
+ }
81
+
82
+ def __init__(self, scan: ScanResult, security_score: float, is_eol: bool = False):
83
+ self._scan = scan
84
+ self._score = security_score
85
+ self._is_eol = is_eol
86
+ self._tier = self._classify()
87
+
88
+ @property
89
+ def tier(self) -> Tier:
90
+ return self._tier
91
+
92
+ @property
93
+ def unfixable_critical_count(self) -> int:
94
+ return self._scan.critical_count - self._scan.fixable_critical_count
95
+
96
+ @property
97
+ def production_ready(self) -> bool:
98
+ """Whether the *tier* alone would permit production use.
99
+
100
+ **This is not the verdict.** It answers a narrower question than it
101
+ appears to: it can see the score and the EOL flag, and nothing else
102
+ -- not whether the scan completed, not whether two scanners agreed,
103
+ not whether anything was verified. Read as the verdict, it reported
104
+ a PARTIAL scan with no findings as production ready on the same
105
+ analysis that reported UNVERIFIED.
106
+
107
+ `ProductionReadiness` in `domain/value_objects/production_readiness.py`
108
+ is the policy that decides, and `ImageAnalysis.production_ready` is
109
+ written only by it. This property survives as the tier-level rule
110
+ that policy consumes.
111
+ """
112
+ # An EOL base is never production-ready, regardless of its
113
+ # vulnerability tier -- it will stop receiving security patches.
114
+ if self._is_eol:
115
+ return False
116
+ return self._tier in PRODUCTION_READY_TIERS
117
+
118
+ def _classify(self) -> Tier:
119
+ tier = tier_for_score(self._score)
120
+ if self.unfixable_critical_count > 0:
121
+ tier = max(tier, UNFIXABLE_CRITICAL_CEILING, key=TIER_ORDER.index)
122
+ return tier