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,174 @@
1
+ """Um retrato de todos os Dockerfiles de uma vez.
2
+
3
+ Cada comando desta ferramenta olha para um artefato: um Dockerfile, uma
4
+ imagem, um build. Isso resolve a pergunta de quem está com o arquivo aberto e
5
+ não resolve nenhuma das perguntas de quem responde por trinta repositórios --
6
+ "quantos ainda rodam como root?", "quantos fixam a base?", "por onde eu
7
+ começo?". Sem resposta, a resposta na prática vira "por onde alguém reclamar".
8
+
9
+ Este módulo é a agregação, e é pura: recebe o que foi lido de cada arquivo e
10
+ devolve o retrato. O que ele **não** faz é tão importante quanto o que faz:
11
+ uma varredura de frota lê Dockerfiles, não constrói imagens nem chama scanner.
12
+ Portanto ela nunca diz que uma imagem está limpa -- ela diz o que o arquivo
13
+ declara, e nada além. Chamar isso de "auditoria de segurança da frota" seria
14
+ exatamente a promessa que esta ferramenta existe para não fazer.
15
+
16
+ A ordenação é por número de violações e depois por caminho. O empate resolvido
17
+ pelo caminho não é detalhe: sem ele, a mesma frota produziria uma ordem
18
+ diferente a cada varredura, e nenhum relatório seria comparável com o
19
+ anterior.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from dataclasses import dataclass, field
25
+ from typing import TYPE_CHECKING
26
+
27
+ from dockerls.domain.value_objects.tristate import Tristate
28
+
29
+ if TYPE_CHECKING:
30
+ from dockerls.domain.value_objects.build_policy import PolicyViolation
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class FleetEntry:
35
+ """Um Dockerfile da frota, com o que se conseguiu ler dele."""
36
+
37
+ path: str
38
+ #: Bases declaradas, na ordem dos `FROM`.
39
+ bases: tuple[str, ...] = ()
40
+ pinned_bases: int = 0
41
+ total_bases: int = 0
42
+ nonroot: Tristate = Tristate.UNKNOWN
43
+ stages: int = 1
44
+ violations: tuple[PolicyViolation, ...] = ()
45
+ #: Preenchido quando o arquivo não pôde ser lido ou analisado. Uma entrada
46
+ #: com erro nunca conta como conforme: é ausência de leitura.
47
+ error: str = ""
48
+
49
+ @property
50
+ def fully_pinned(self) -> bool:
51
+ return bool(self.total_bases) and self.pinned_bases == self.total_bases
52
+
53
+ @property
54
+ def readable(self) -> bool:
55
+ return not self.error
56
+
57
+ def to_dict(self) -> dict[str, object]:
58
+ return {
59
+ "path": self.path,
60
+ "bases": list(self.bases),
61
+ "pinned_bases": self.pinned_bases,
62
+ "total_bases": self.total_bases,
63
+ "fully_pinned": self.fully_pinned,
64
+ "nonroot": str(self.nonroot),
65
+ "stages": self.stages,
66
+ "violations": [v.to_dict() for v in self.violations],
67
+ "error": self.error,
68
+ }
69
+
70
+
71
+ @dataclass(frozen=True)
72
+ class FleetReport:
73
+ """O retrato agregado, e a fila de trabalho que sai dele."""
74
+
75
+ root: str
76
+ entries: tuple[FleetEntry, ...] = field(default_factory=tuple)
77
+ #: Diretórios que a varredura não conseguiu percorrer. Reportar é o que
78
+ #: impede um relatório de parecer completo quando não é.
79
+ unreadable_paths: tuple[str, ...] = ()
80
+ #: Se a varredura parou no teto de arquivos. Um retrato truncado que não
81
+ #: diz que foi truncado é pior do que nenhum retrato.
82
+ truncated: bool = False
83
+ #: Se havia política declarada. Sem ela, `violations` está sempre vazio --
84
+ #: e isso não significa conformidade.
85
+ policy_applied: bool = False
86
+
87
+ @property
88
+ def total(self) -> int:
89
+ return len(self.entries)
90
+
91
+ @property
92
+ def readable(self) -> tuple[FleetEntry, ...]:
93
+ return tuple(e for e in self.entries if e.readable)
94
+
95
+ @property
96
+ def unreadable(self) -> tuple[FleetEntry, ...]:
97
+ return tuple(e for e in self.entries if not e.readable)
98
+
99
+ @property
100
+ def fully_pinned(self) -> int:
101
+ return sum(1 for e in self.readable if e.fully_pinned)
102
+
103
+ @property
104
+ def running_as_root(self) -> int:
105
+ return sum(1 for e in self.readable if e.nonroot.is_false)
106
+
107
+ @property
108
+ def undetermined_user(self) -> int:
109
+ """Arquivos em que não se conseguiu decidir o usuário.
110
+
111
+ Contado separado de `running_as_root` de propósito: juntar os dois
112
+ transformaria ausência de medida em acusação, e a fila de trabalho de
113
+ cada um é diferente."""
114
+ return sum(1 for e in self.readable if not e.nonroot.is_known)
115
+
116
+ @property
117
+ def with_violations(self) -> int:
118
+ return sum(1 for e in self.entries if e.violations)
119
+
120
+ @property
121
+ def total_violations(self) -> int:
122
+ return sum(len(e.violations) for e in self.entries)
123
+
124
+ def worst_first(self) -> tuple[FleetEntry, ...]:
125
+ """A fila de trabalho: mais violações primeiro, caminho desempata.
126
+
127
+ O desempate por caminho é o que torna duas varreduras comparáveis --
128
+ sem ele a mesma frota sairia em ordem diferente a cada execução.
129
+ """
130
+ return tuple(sorted(self.entries, key=lambda e: (-len(e.violations), e.path)))
131
+
132
+ def summary(self) -> str:
133
+ """A frase que resume o retrato sem prometer o que não foi medido."""
134
+ if not self.total:
135
+ return "no Dockerfile found"
136
+ partes = [
137
+ f"{self.total} Dockerfile(s)",
138
+ f"{self.fully_pinned} with every base pinned",
139
+ ]
140
+ if self.running_as_root:
141
+ partes.append(f"{self.running_as_root} running as root")
142
+ if self.undetermined_user:
143
+ partes.append(f"{self.undetermined_user} with an undetermined user")
144
+ if self.unreadable:
145
+ partes.append(f"{len(self.unreadable)} unreadable")
146
+ return ", ".join(partes)
147
+
148
+ def caveat(self) -> str:
149
+ """O que este relatório deliberadamente não afirma."""
150
+ return (
151
+ "this scan reads Dockerfiles: it builds no image and calls no scanner. "
152
+ "It reports what the files declare, and nothing about the vulnerabilities "
153
+ "of the images they produce"
154
+ )
155
+
156
+ def to_dict(self) -> dict[str, object]:
157
+ return {
158
+ "root": self.root,
159
+ "summary": self.summary(),
160
+ "caveat": self.caveat(),
161
+ "policy_applied": self.policy_applied,
162
+ "truncated": self.truncated,
163
+ "unreadable_paths": list(self.unreadable_paths),
164
+ "totals": {
165
+ "dockerfiles": self.total,
166
+ "fully_pinned": self.fully_pinned,
167
+ "running_as_root": self.running_as_root,
168
+ "undetermined_user": self.undetermined_user,
169
+ "with_violations": self.with_violations,
170
+ "violations": self.total_violations,
171
+ "unreadable": len(self.unreadable),
172
+ },
173
+ "dockerfiles": [e.to_dict() for e in self.worst_first()],
174
+ }
@@ -0,0 +1,327 @@
1
+ """O que faz um build reprovar, dito como um valor e não como um `if`.
2
+
3
+ O portão olhava só severidade -- `critical`, `high`, `medium`, `low` --, e
4
+ isso era uma incoerência do projeto inteiro: a ferramenta consulta o
5
+ catálogo CISA KEV, o EPSS e o Exploit-DB, usa os três para pontuar
6
+ (`SecurityScore`) e para redigir o veredito, e então reprovava o build por
7
+ um rótulo que o vendor da distro escolheu.
8
+
9
+ O caso concreto que isso deixava passar: um CVE **sendo explorado hoje**,
10
+ classificado MEDIUM pelo vendor, atravessava um `--fail-on high` sem um
11
+ pio. E o caso inverso: um CRITICAL teórico, sem exploit publicado e com
12
+ EPSS de 0,0003, reprovava o build. A ferramenta sabia a diferença e não a
13
+ usava onde ela decide alguma coisa.
14
+
15
+ `kev` e `epss>=N` são medições, não heurísticas -- é por isso que cabem num
16
+ portão, e é por isso que este módulo é puro: a regra que reprova um build
17
+ tem de ser testável sem rede, sem scanner e sem Docker.
18
+
19
+ **O tristate no portão.** `kev_status` UNKNOWN significa "ninguém
20
+ consultou", e há duas formas erradas de tratá-lo aqui. Reprovar por ele
21
+ diria que a imagem tem CVE explorado, o que é inventar. Aprovar por ele
22
+ gastaria a ausência de medição como tranquilidade, que é exatamente o que
23
+ este projeto existe para não fazer -- e pior, desligaria um portão de
24
+ segurança em silêncio numa oscilação de rede.
25
+
26
+ A saída é a terceira: quem pediu `--fail-on kev` pediu que a exploração
27
+ fosse *conferida*. Não conseguir conferir reprova o **run**, com uma
28
+ mensagem que nomeia a causa como falta de medição e não como veredito
29
+ sobre a imagem. São coisas diferentes e a mensagem diz qual das duas é.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import re
35
+ from dataclasses import dataclass
36
+ from enum import StrEnum
37
+ from typing import TYPE_CHECKING
38
+
39
+ from dockerls.domain.value_objects.tristate import Tristate
40
+
41
+ if TYPE_CHECKING:
42
+ from collections.abc import Mapping, Sequence
43
+
44
+ #: Limiares de severidade, do mais severo para o mais brando. Cada um
45
+ #: reprova também tudo que for pior que ele.
46
+ SEVERITY_THRESHOLDS: tuple[str, ...] = ("critical", "high", "medium", "low")
47
+
48
+ #: `epss>=0.5`, `epss >= 0.5`, `epss>0.5`. O `>=` é o sentido pretendido; o
49
+ #: `>` é aceito porque alguém vai escrevê-lo e recusar seria pedantismo.
50
+ _EPSS = re.compile(r"^epss\s*>=?\s*(\d*\.?\d+)$", re.IGNORECASE)
51
+
52
+
53
+ class GateKind(StrEnum):
54
+ """Sobre o que este portão pergunta."""
55
+
56
+ SEVERITY = "SEVERITY"
57
+ #: Listado no catálogo CISA KEV: exploração observada no mundo real.
58
+ KEV = "KEV"
59
+ #: Probabilidade EPSS de exploração nos próximos 30 dias.
60
+ EPSS = "EPSS"
61
+
62
+
63
+ class GateOutcome(StrEnum):
64
+ """O que o portão concluiu."""
65
+
66
+ PASS = "PASS" # noqa: S105 -- veredito de portão, não senha
67
+ #: Achado que dispara o portão. É um veredito sobre a imagem.
68
+ FAIL = "FAIL"
69
+ #: O portão foi pedido e não pôde ser avaliado. **Não** é um veredito
70
+ #: sobre a imagem: é a constatação de que a pergunta ficou sem resposta.
71
+ UNMEASURED = "UNMEASURED"
72
+
73
+
74
+ class InvalidGateError(ValueError):
75
+ """O valor de `--fail-on` não nomeia um portão que existe."""
76
+
77
+
78
+ @dataclass(frozen=True)
79
+ class Finding:
80
+ """O mínimo que o portão precisa saber sobre um achado.
81
+
82
+ Deliberadamente não é a `Vulnerability` do domínio: o `build` carrega
83
+ seus achados como dicionários vindos do scanner, e obrigar uma
84
+ conversão só para consultar quatro campos acoplaria o portão à forma de
85
+ quem o chama.
86
+ """
87
+
88
+ cve_id: str
89
+ severity: str
90
+ kev: Tristate = Tristate.UNKNOWN
91
+ #: Probabilidade EPSS, ou None quando ninguém consultou. `0.0` é uma
92
+ #: resposta ("o FIRST pontuou em zero") e `None` é a falta dela.
93
+ epss: float | None = None
94
+ package: str = ""
95
+ fixed_version: str = ""
96
+
97
+ @property
98
+ def severity_rank(self) -> int:
99
+ """Posição na escala, ou -1 para uma severidade fora dela."""
100
+ try:
101
+ return SEVERITY_THRESHOLDS.index(self.severity.strip().lower())
102
+ except ValueError:
103
+ return -1
104
+
105
+
106
+ @dataclass(frozen=True)
107
+ class GateVerdict:
108
+ """O que o portão decidiu, e por quê."""
109
+
110
+ outcome: GateOutcome
111
+ kind: GateKind
112
+ #: Os achados que dispararam, na ordem em que serão mostrados.
113
+ offenders: tuple[Finding, ...] = ()
114
+ #: Frase para o humano. Nomeia a causa, nunca só "reprovou".
115
+ reason: str = ""
116
+
117
+ @property
118
+ def failed(self) -> bool:
119
+ """Se o build não deve prosseguir.
120
+
121
+ `UNMEASURED` conta: quem pediu o portão pediu que a pergunta fosse
122
+ respondida, e ela não foi.
123
+ """
124
+ return self.outcome is not GateOutcome.PASS
125
+
126
+
127
+ @dataclass(frozen=True)
128
+ class Gate:
129
+ """Um `--fail-on` já interpretado."""
130
+
131
+ kind: GateKind
132
+ #: Para SEVERITY, o limiar (`"high"`). Para EPSS, o piso (`0.5`).
133
+ severity: str = ""
134
+ epss_floor: float = 0.0
135
+ #: O texto original, para a mensagem citar o que o usuário escreveu.
136
+ raw: str = ""
137
+
138
+ @classmethod
139
+ def parse(cls, value: str) -> Gate:
140
+ """Interpreta o valor de `--fail-on`, ou recusa nomeando as opções.
141
+
142
+ Recusar aqui é o ponto: um limiar desconhecido caindo num
143
+ `return False` seria um portão que nunca reprova, em silêncio -- e
144
+ esse bug já existiu neste arquivo com `--fail-on medium`.
145
+ """
146
+ text = value.strip().lower()
147
+ if not text:
148
+ raise InvalidGateError("no gate was named")
149
+
150
+ if text in SEVERITY_THRESHOLDS:
151
+ return cls(kind=GateKind.SEVERITY, severity=text, raw=text)
152
+ if text == "kev":
153
+ return cls(kind=GateKind.KEV, raw=text)
154
+
155
+ match = _EPSS.match(text)
156
+ if match is not None:
157
+ floor = float(match.group(1))
158
+ if not 0.0 <= floor <= 1.0:
159
+ raise InvalidGateError(
160
+ f"EPSS is a probability between 0 and 1; {floor} is outside it"
161
+ )
162
+ return cls(kind=GateKind.EPSS, epss_floor=floor, raw=text)
163
+
164
+ raise InvalidGateError(
165
+ f"unknown --fail-on gate {value!r}; expected one of "
166
+ f"{', '.join(SEVERITY_THRESHOLDS)}, kev, or epss>=N (e.g. epss>=0.5)"
167
+ )
168
+
169
+ def evaluate(
170
+ self, findings: Sequence[Finding], counts: Mapping[str, int] | None = None
171
+ ) -> GateVerdict:
172
+ """Decide, e diz por quê.
173
+
174
+ `counts` é a contagem por severidade do scan **completo**. Ela
175
+ existe porque `findings` costuma ser uma amostra: o relatório retém
176
+ um número limitado de achados, e deixar o portão contar a amostra
177
+ faria o número que reprova ser menor que o número que existe. O que
178
+ reprova e o que se lê têm de ser o mesmo número.
179
+ """
180
+ match self.kind:
181
+ case GateKind.SEVERITY:
182
+ return self._severity(findings, counts)
183
+ case GateKind.KEV:
184
+ return self._kev(findings)
185
+ case GateKind.EPSS:
186
+ return self._epss(findings)
187
+
188
+ def _severity(
189
+ self, findings: Sequence[Finding], counts: Mapping[str, int] | None
190
+ ) -> GateVerdict:
191
+ cutoff = SEVERITY_THRESHOLDS.index(self.severity)
192
+ levels = SEVERITY_THRESHOLDS[: cutoff + 1]
193
+ offenders = tuple(f for f in findings if 0 <= f.severity_rank <= cutoff)
194
+ total = (
195
+ sum(counts.get(level, 0) for level in levels) if counts is not None else len(offenders)
196
+ )
197
+ if total == 0:
198
+ return GateVerdict(GateOutcome.PASS, self.kind)
199
+ return GateVerdict(
200
+ GateOutcome.FAIL,
201
+ self.kind,
202
+ offenders,
203
+ f"{total} finding(s) at or above {self.severity.upper()}",
204
+ )
205
+
206
+ def _kev(self, findings: Sequence[Finding]) -> GateVerdict:
207
+ listed = tuple(f for f in findings if f.kev is Tristate.TRUE)
208
+ if listed:
209
+ return GateVerdict(
210
+ GateOutcome.FAIL,
211
+ self.kind,
212
+ listed,
213
+ f"{len(listed)} finding(s) are in the CISA KEV catalogue, which means "
214
+ "exploitation observed in the wild",
215
+ )
216
+ # Nada listado. Resta saber se a pergunta chegou a ser feita.
217
+ if findings and all(f.kev is Tristate.UNKNOWN for f in findings):
218
+ return GateVerdict(
219
+ GateOutcome.UNMEASURED,
220
+ self.kind,
221
+ (),
222
+ "the KEV gate was requested and the catalogue was never consulted, so "
223
+ "nothing here was checked against it. This is an absent measurement, "
224
+ "not a finding: the image may or may not carry an exploited CVE, and "
225
+ "this run cannot say which",
226
+ )
227
+ return GateVerdict(GateOutcome.PASS, self.kind)
228
+
229
+ def _epss(self, findings: Sequence[Finding]) -> GateVerdict:
230
+ scored = tuple(f for f in findings if f.epss is not None)
231
+ over = tuple(f for f in scored if f.epss is not None and f.epss >= self.epss_floor)
232
+ if over:
233
+ worst = max(f.epss or 0.0 for f in over)
234
+ return GateVerdict(
235
+ GateOutcome.FAIL,
236
+ self.kind,
237
+ over,
238
+ f"{len(over)} finding(s) at or above an EPSS of {self.epss_floor:g} "
239
+ f"(worst: {worst:.3f}), the probability of exploitation in the next "
240
+ "30 days",
241
+ )
242
+ if findings and not scored:
243
+ return GateVerdict(
244
+ GateOutcome.UNMEASURED,
245
+ self.kind,
246
+ (),
247
+ f"the EPSS gate was requested and no finding carries a score, so "
248
+ f"nothing here was compared against {self.epss_floor:g}. This is an "
249
+ "absent measurement, not a finding",
250
+ )
251
+ return GateVerdict(GateOutcome.PASS, self.kind)
252
+
253
+
254
+ @dataclass(frozen=True)
255
+ class GateSet:
256
+ """Vários portões, todos obrigatórios.
257
+
258
+ Existe porque "mais estrito" deixou de ser uma comparação possível.
259
+ Entre `high` e `critical` dá para dizer qual reprova mais; entre
260
+ `critical` e `kev` não dá -- são perguntas diferentes sobre coisas
261
+ diferentes, e escolher uma seria descartar a outra em silêncio.
262
+
263
+ Quando a política do repositório pede um portão e a linha de comando
264
+ pede outro, os dois valem. É a única regra que não afrouxa nenhum dos
265
+ dois lados.
266
+ """
267
+
268
+ gates: tuple[Gate, ...]
269
+
270
+ @classmethod
271
+ def parse(cls, value: str) -> GateSet:
272
+ """Interpreta `"critical"`, `"kev"` ou `"critical,kev,epss>=0.5"`."""
273
+ parts = [piece.strip() for piece in value.split(",") if piece.strip()]
274
+ if not parts:
275
+ raise InvalidGateError("no gate was named")
276
+ return cls(tuple(Gate.parse(part) for part in parts))
277
+
278
+ @property
279
+ def raw(self) -> str:
280
+ return ",".join(gate.raw for gate in self.gates)
281
+
282
+ def evaluate(
283
+ self, findings: Sequence[Finding], counts: Mapping[str, int] | None = None
284
+ ) -> tuple[GateVerdict, ...]:
285
+ """Os vereditos que **não** passaram, na ordem declarada.
286
+
287
+ Vazio significa aprovado. Devolver só as falhas é deliberado: o
288
+ chamador precisa listar o que reprovou, e um veredito PASS na
289
+ mensagem seria ruído no log do CI.
290
+ """
291
+ return tuple(
292
+ verdict
293
+ for verdict in (gate.evaluate(findings, counts) for gate in self.gates)
294
+ if verdict.failed
295
+ )
296
+
297
+
298
+ def merge_gates(declared: str, requested: str) -> str:
299
+ """O portão que vale, entre a política do repositório e a linha de comando.
300
+
301
+ Severidade contra severidade continua sendo a regra antiga: vence a
302
+ **mais baixa na escala**, porque `--fail-on low` reprova em LOW e em
303
+ tudo acima enquanto `critical` só olha CRITICAL. Nem o arquivo desliga
304
+ o que o pipeline pediu, nem a flag afrouxa o que a organização exige.
305
+
306
+ Portões de tipos diferentes não competem: somam. Um `.dockerls-policy.yaml`
307
+ que exige `kev` e um `--fail-on high` na linha de comando produzem os
308
+ dois, porque descartar qualquer um seria afrouxar sem dizer.
309
+ """
310
+ sides = [value.strip() for value in (declared, requested) if value.strip()]
311
+ if not sides:
312
+ return ""
313
+ if len(sides) == 1:
314
+ return sides[0]
315
+
316
+ kept: list[str] = []
317
+ severities: list[str] = []
318
+ for side in sides:
319
+ for gate in GateSet.parse(side).gates:
320
+ if gate.kind is GateKind.SEVERITY:
321
+ severities.append(gate.severity)
322
+ elif gate.raw not in kept:
323
+ kept.append(gate.raw)
324
+ if severities:
325
+ strictest = max(severities, key=SEVERITY_THRESHOLDS.index)
326
+ kept.insert(0, strictest)
327
+ return ",".join(kept)