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,352 @@
1
+ """De quem é cada CVE: da base que você escolheu, ou das camadas que você escreveu.
2
+
3
+ Um relatório de build diz "47 vulnerabilidades" e manda a pessoa consertar. A
4
+ pergunta que ela faz em seguida -- toda vez -- é "consertar *o quê*?", e a
5
+ contagem não responde. Nesta ferramenta a resposta apareceu por acaso: uma
6
+ `base-node` recém-gerada reprovava com um CRITICAL que não vinha de nada que o
7
+ Dockerfile fazia, e sim do npm que a imagem oficial embute. Foi preciso um
8
+ terceiro produto para descobrir isso.
9
+
10
+ Este módulo faz esse cruzamento em casa. Escaneia-se a base declarada no `FROM`
11
+ e a imagem construída, e comparam-se os dois conjuntos de achados pela mesma
12
+ identidade que a validação cruzada entre scanners já usa (`CVE|pacote`). O
13
+ resultado divide as vulnerabilidades em três, e as três levam a ações
14
+ completamente diferentes:
15
+
16
+ * **`INHERITED`** -- está na base e continua na sua imagem. Nada no seu
17
+ Dockerfile causou, e nada no seu Dockerfile resolve: ou a base é atualizada,
18
+ ou é trocada. É aqui que costuma morar a grande maioria.
19
+ * **`INTRODUCED`** -- não está na base e está na sua imagem. Veio do que você
20
+ instalou, copiou ou construiu. É a única parte sobre a qual o seu código tem
21
+ poder direto.
22
+ * **`REMOVED`** -- estava na base e não está mais. O `apk upgrade` do seu
23
+ Dockerfile, ou a remoção de um pacote, resolveu. Aparece porque é a medida
24
+ do que o seu endurecimento efetivamente comprou.
25
+
26
+ A regra que sustenta o módulo é a de sempre: **sem os dois scans não há
27
+ atribuição.** Se a base não pôde ser escaneada, o resultado é `UNAVAILABLE` e
28
+ o relatório diz isso -- nunca "tudo é seu" nem "tudo é herdado", que seriam as
29
+ duas maneiras de transformar ausência de medição em acusação.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ from dataclasses import dataclass, field
35
+ from enum import StrEnum
36
+ from typing import TYPE_CHECKING
37
+
38
+ from dockerls.domain.entities.vulnerability import finding_identity
39
+
40
+ if TYPE_CHECKING:
41
+ from collections.abc import Iterable
42
+
43
+ from dockerls.domain.entities.vulnerability import Vulnerability
44
+
45
+
46
+ class FindingOrigin(StrEnum):
47
+ """De onde cada achado veio, do ponto de vista de quem escreveu o build."""
48
+
49
+ INHERITED = "INHERITED"
50
+ INTRODUCED = "INTRODUCED"
51
+ REMOVED = "REMOVED"
52
+
53
+
54
+ #: O que fazer com cada grupo, dito em uma linha.
55
+ ACTIONS: dict[FindingOrigin, str] = {
56
+ FindingOrigin.INHERITED: (
57
+ "came from the base and no line of your Dockerfile fixes it: update the base "
58
+ "(`dockerls base`) or swap it (`dockerls base --alternatives`)"
59
+ ),
60
+ FindingOrigin.INTRODUCED: (
61
+ "came from what this Dockerfile installs, copies or builds: the part you have "
62
+ "direct power over"
63
+ ),
64
+ FindingOrigin.REMOVED: (
65
+ "was in the base and is no longer in the final image: the measure of what your "
66
+ "hardening bought"
67
+ ),
68
+ }
69
+
70
+
71
+ @dataclass(frozen=True)
72
+ class AttributedFinding:
73
+ """Um achado, e de quem ele é."""
74
+
75
+ identity: str
76
+ cve_id: str
77
+ package_name: str
78
+ severity: str
79
+ origin: FindingOrigin
80
+ #: Versão em que o upstream publicou a correção, quando publicou. Vazio é
81
+ #: "não há correção", e essa distinção é metade do plano de trabalho:
82
+ #: atualizar não resolve o que ninguém corrigiu.
83
+ fixed_version: str = ""
84
+
85
+ @property
86
+ def fixable(self) -> bool:
87
+ return bool(self.fixed_version.strip())
88
+
89
+ def to_dict(self) -> dict[str, object]:
90
+ return {
91
+ "cve": self.cve_id,
92
+ "package": self.package_name,
93
+ "severity": self.severity,
94
+ "origin": str(self.origin),
95
+ "fixed_version": self.fixed_version,
96
+ "fixable": self.fixable,
97
+ }
98
+
99
+
100
+ #: Severidades da mais grave para a menos, para o plano abrir pelo que importa.
101
+ _SEVERITY_RANK = ("CRITICAL", "HIGH", "MEDIUM", "LOW", "UNKNOWN")
102
+
103
+
104
+ @dataclass(frozen=True)
105
+ class RemediationBucket:
106
+ """Um grupo do plano de trabalho: mesma origem, mesma situação de correção.
107
+
108
+ Existe porque origem sozinha não é plano. "41 vêm da base" ainda não diz se
109
+ atualizar a base adianta -- e a resposta muda tudo: se nenhuma delas tem
110
+ correção publicada, atualizar é trabalho perdido e trocar a base é o único
111
+ caminho.
112
+ """
113
+
114
+ origin: FindingOrigin
115
+ fixable: bool
116
+ findings: tuple[AttributedFinding, ...]
117
+
118
+ @property
119
+ def count(self) -> int:
120
+ return len(self.findings)
121
+
122
+ @property
123
+ def critical(self) -> int:
124
+ return sum(1 for f in self.findings if f.severity.upper() == "CRITICAL")
125
+
126
+ def action(self) -> str:
127
+ """O que fazer com este grupo, e o que essa ação não garante."""
128
+ if self.origin is FindingOrigin.INHERITED:
129
+ if self.fixable:
130
+ return (
131
+ "a fix is published upstream: updating the base may resolve it -- "
132
+ "may, because a fix existing does not mean whoever publishes the "
133
+ "base has rebuilt with it. `dockerls base` checks whether the tag "
134
+ "moved"
135
+ )
136
+ return (
137
+ "no fix is published: updating the base resolves nothing here. "
138
+ "Swapping the base is the only path -- `dockerls base --alternatives` "
139
+ "measures the candidates"
140
+ )
141
+ if self.fixable:
142
+ return "a fix is published: raise the dependency version in your manifest and rebuild"
143
+ return (
144
+ "no fix is published and the package is yours: consider removing, "
145
+ "replacing or isolating it. This is the group where a documented exemption "
146
+ "in `.dockerls-ignore.yaml` makes sense -- with an expiry date"
147
+ )
148
+
149
+ def to_dict(self) -> dict[str, object]:
150
+ return {
151
+ "origin": str(self.origin),
152
+ "fixable": self.fixable,
153
+ "count": self.count,
154
+ "critical": self.critical,
155
+ "action": self.action(),
156
+ "findings": [f.to_dict() for f in self.findings],
157
+ }
158
+
159
+
160
+ @dataclass(frozen=True)
161
+ class InheritanceReport:
162
+ """A divisão entre o que é da base e o que é seu."""
163
+
164
+ base_reference: str = ""
165
+ #: Vazio quando a atribuição foi possível. Preenchido com o motivo quando
166
+ #: não foi -- e aí nenhuma contagem abaixo significa coisa alguma.
167
+ unavailable_reason: str = ""
168
+ findings: tuple[AttributedFinding, ...] = field(default_factory=tuple)
169
+
170
+ @property
171
+ def available(self) -> bool:
172
+ return not self.unavailable_reason
173
+
174
+ def of(self, origin: FindingOrigin) -> tuple[AttributedFinding, ...]:
175
+ return tuple(f for f in self.findings if f.origin is origin)
176
+
177
+ @property
178
+ def inherited(self) -> tuple[AttributedFinding, ...]:
179
+ return self.of(FindingOrigin.INHERITED)
180
+
181
+ @property
182
+ def introduced(self) -> tuple[AttributedFinding, ...]:
183
+ return self.of(FindingOrigin.INTRODUCED)
184
+
185
+ @property
186
+ def removed(self) -> tuple[AttributedFinding, ...]:
187
+ return self.of(FindingOrigin.REMOVED)
188
+
189
+ @property
190
+ def inherited_share(self) -> float:
191
+ """Fração das vulnerabilidades da imagem que vieram da base, 0.0--1.0.
192
+
193
+ Só conta o que está *na imagem*: `REMOVED` descreve o que não está lá,
194
+ e incluí-lo no denominador diluiria a conta com achados que ninguém
195
+ precisa tratar.
196
+ """
197
+ presentes = len(self.inherited) + len(self.introduced)
198
+ return len(self.inherited) / presentes if presentes else 0.0
199
+
200
+ def plan(self) -> tuple[RemediationBucket, ...]:
201
+ """O plano de trabalho: origem cruzada com "existe correção?".
202
+
203
+ Origem sozinha diz de quem é o problema; correção diz se ele tem
204
+ solução. Só as duas juntas dizem o que fazer na segunda-feira.
205
+
206
+ `REMOVED` fica de fora: não está na imagem, e um plano que lista o que
207
+ já não existe faz a lista parecer maior do que o trabalho é.
208
+ """
209
+ buckets: list[RemediationBucket] = []
210
+ for origin in (FindingOrigin.INHERITED, FindingOrigin.INTRODUCED):
211
+ for fixable in (True, False):
212
+ achados = tuple(
213
+ sorted(
214
+ (f for f in self.of(origin) if f.fixable is fixable),
215
+ key=_by_severity,
216
+ )
217
+ )
218
+ if achados:
219
+ buckets.append(
220
+ RemediationBucket(origin=origin, fixable=fixable, findings=achados)
221
+ )
222
+ # O grupo com mais CRITICAL abre a lista: é onde a primeira hora de
223
+ # trabalho rende mais, e ordenar por total faria um monte de LOW passar
224
+ # à frente de dois CRITICAL sem correção.
225
+ return tuple(sorted(buckets, key=lambda b: (-b.critical, -b.count)))
226
+
227
+ @property
228
+ def fixable_inherited(self) -> int:
229
+ return sum(1 for f in self.inherited if f.fixable)
230
+
231
+ def explain(self) -> str:
232
+ """A frase que responde "consertar o quê?"."""
233
+ if not self.available:
234
+ return (
235
+ f"the vulnerabilities could not be attributed: {self.unavailable_reason}. "
236
+ "Without both scans, calling them yours or the base's would be inventing"
237
+ )
238
+ herdadas, suas = len(self.inherited), len(self.introduced)
239
+ if not herdadas and not suas:
240
+ if self.removed:
241
+ # É o melhor resultado possível, e a versão anterior o
242
+ # escondia atrás de "nada a atribuir": a imagem está limpa
243
+ # *e* o build tirou coisa da base. Vale dizer.
244
+ quantas = len(self.removed)
245
+ if quantas == 1:
246
+ return (
247
+ "no vulnerabilities in this image, and the 1 the base had did "
248
+ "not survive the build"
249
+ )
250
+ return (
251
+ f"no vulnerabilities in this image, and the {quantas} the base had "
252
+ "did not survive the build"
253
+ )
254
+ return "no vulnerabilities to attribute in this image"
255
+ partes = [
256
+ f"{herdadas} of {herdadas + suas} {_come(herdadas)} from the base "
257
+ f"{self.base_reference}",
258
+ f"{suas} {_come(suas)} from the layers of this Dockerfile",
259
+ ]
260
+ if self.removed:
261
+ quantas = len(self.removed)
262
+ partes.append(
263
+ f"{quantas} the base had {'was' if quantas == 1 else 'were'} removed by the build"
264
+ )
265
+ if herdadas:
266
+ # A pergunta imediatamente seguinte a "41 vêm da base" é "e
267
+ # atualizar a base resolve?". Responder junto poupa a viagem.
268
+ corrigiveis = self.fixable_inherited
269
+ partes.append(
270
+ f"{corrigiveis} of the inherited {_have(corrigiveis)} a fix published upstream"
271
+ )
272
+ return "; ".join(partes)
273
+
274
+ def to_dict(self) -> dict[str, object]:
275
+ return {
276
+ "base": self.base_reference,
277
+ "available": self.available,
278
+ "unavailable_reason": self.unavailable_reason,
279
+ "explanation": self.explain(),
280
+ "counts": {
281
+ "inherited": len(self.inherited),
282
+ "introduced": len(self.introduced),
283
+ "removed": len(self.removed),
284
+ },
285
+ "inherited_share": round(self.inherited_share, 3),
286
+ "actions": {str(origin): action for origin, action in ACTIONS.items()},
287
+ "plan": [b.to_dict() for b in self.plan()],
288
+ "findings": [f.to_dict() for f in self.findings],
289
+ }
290
+
291
+
292
+ def attribute(
293
+ built: Iterable[Vulnerability],
294
+ base: Iterable[Vulnerability],
295
+ *,
296
+ base_reference: str,
297
+ ) -> InheritanceReport:
298
+ """Divide os achados entre herdados da base, introduzidos e removidos.
299
+
300
+ A identidade comparada é `CVE|pacote`, a mesma da validação cruzada entre
301
+ scanners: o mesmo CVE em dois pacotes são dois problemas a resolver, e o
302
+ mesmo pacote com dois CVEs também. A versão instalada fica de fora de
303
+ propósito -- a base e a imagem final frequentemente reportam o mesmo pacote
304
+ com strings de versão normalizadas de formas diferentes, e comparar isso
305
+ fabricaria diferença a partir de formatação.
306
+ """
307
+ por_identidade_construida = {finding_identity(v): v for v in built}
308
+ identidades_base = {finding_identity(v): v for v in base}
309
+
310
+ achados: list[AttributedFinding] = []
311
+ for identity, vuln in por_identidade_construida.items():
312
+ origin = (
313
+ FindingOrigin.INHERITED if identity in identidades_base else FindingOrigin.INTRODUCED
314
+ )
315
+ achados.append(_attributed(identity, vuln, origin))
316
+
317
+ for identity, vuln in identidades_base.items():
318
+ if identity not in por_identidade_construida:
319
+ achados.append(_attributed(identity, vuln, FindingOrigin.REMOVED))
320
+
321
+ return InheritanceReport(base_reference=base_reference, findings=tuple(achados))
322
+
323
+
324
+ def unavailable(base_reference: str, reason: str) -> InheritanceReport:
325
+ """A atribuição que não pôde ser feita, com o motivo em vez de um palpite."""
326
+ return InheritanceReport(base_reference=base_reference, unavailable_reason=reason)
327
+
328
+
329
+ def _attributed(identity: str, vuln: Vulnerability, origin: FindingOrigin) -> AttributedFinding:
330
+ return AttributedFinding(
331
+ identity=identity,
332
+ cve_id=vuln.cve_id,
333
+ package_name=vuln.package_name,
334
+ severity=str(vuln.severity),
335
+ origin=origin,
336
+ fixed_version=vuln.fixed_version,
337
+ )
338
+
339
+
340
+ def _come(quantidade: int) -> str:
341
+ return "comes" if quantidade == 1 else "come"
342
+
343
+
344
+ def _have(quantidade: int) -> str:
345
+ return "has" if quantidade == 1 else "have"
346
+
347
+
348
+ def _by_severity(finding: AttributedFinding) -> tuple[int, str]:
349
+ """Mais grave primeiro; CVE desempata para a ordem ser estável."""
350
+ severity = finding.severity.upper()
351
+ rank = _SEVERITY_RANK.index(severity) if severity in _SEVERITY_RANK else len(_SEVERITY_RANK)
352
+ return rank, finding.cve_id
@@ -0,0 +1,280 @@
1
+ """Where DockerLs is allowed to send a request, and why that needs a policy.
2
+
3
+ An image reference is user input, and it carries a hostname. `dockerls
4
+ analyze 169.254.169.254/latest` is a well-formed reference, and resolving it
5
+ means issuing `GET https://169.254.169.254/v2/latest/manifests/...` -- the
6
+ cloud metadata endpoint. On a CI runner, a reference arriving from a pull
7
+ request, a config file or an environment variable therefore turns this tool
8
+ into an SSRF primitive against the host's internal network. The response body
9
+ never reaches the requester, but the *reach* does, and blind is still SSRF.
10
+
11
+ The naive fix -- refuse every private address -- is wrong here, and the
12
+ project brief says so explicitly: internal registries on RFC1918 addresses
13
+ are ordinary, legitimate infrastructure, and a scanner that cannot look at
14
+ `registry.internal:5000` is a scanner nobody can use. So the policy
15
+ distinguishes two things that get lumped together:
16
+
17
+ * **loopback and link-local** are refused by default. No legitimate registry
18
+ is reachable at `127.0.0.1` *from the perspective of a reference someone
19
+ else supplied*, and `169.254.0.0/16` is where every cloud provider parks
20
+ its credential endpoint. This is the actual attack.
21
+ * **private ranges** (RFC1918, unique-local) are allowed by default, because
22
+ that is where real internal registries live -- and tightened with a single
23
+ setting when a deployment wants to.
24
+
25
+ Either default can be overridden, and an explicit host allowlist wins over
26
+ both: an operator who genuinely runs a registry on localhost says so once.
27
+
28
+ Hostnames are judged by where they *resolve*, not by how they are spelled:
29
+ `localhost` and an attacker-controlled name whose A record points at
30
+ 127.0.0.1 are the same request, and only one of them looks suspicious in a
31
+ config file. Resolving a name is I/O, so it does not happen here -- this
32
+ module decides over addresses it is handed, and
33
+ `infrastructure/network/host_guard.py` is what performs the lookup. That
34
+ split is what keeps the rule testable without a network and the domain free
35
+ of sockets.
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ import ipaddress
41
+ from dataclasses import dataclass, field
42
+ from enum import StrEnum
43
+
44
+
45
+ class NetworkDecision(StrEnum):
46
+ """Why a host was allowed or refused. Reported, never silent."""
47
+
48
+ ALLOWED = "ALLOWED"
49
+ ALLOWED_BY_ALLOWLIST = "ALLOWED_BY_ALLOWLIST"
50
+ BLOCKED_LOOPBACK = "BLOCKED_LOOPBACK"
51
+ BLOCKED_LINK_LOCAL = "BLOCKED_LINK_LOCAL"
52
+ BLOCKED_PRIVATE = "BLOCKED_PRIVATE"
53
+ BLOCKED_UNSPECIFIED = "BLOCKED_UNSPECIFIED"
54
+ BLOCKED_UNRESOLVABLE = "BLOCKED_UNRESOLVABLE"
55
+ BLOCKED_SPECIAL = "BLOCKED_SPECIAL"
56
+ BLOCKED_SCHEME = "BLOCKED_SCHEME"
57
+
58
+
59
+ #: Decisions that permit the request.
60
+ _ALLOWING = (NetworkDecision.ALLOWED, NetworkDecision.ALLOWED_BY_ALLOWLIST)
61
+
62
+
63
+ @dataclass(frozen=True)
64
+ class NetworkPolicy:
65
+ """What a reference is permitted to make this process talk to."""
66
+
67
+ #: RFC1918 / unique-local. Default True: internal registries are normal.
68
+ allow_private_networks: bool = True
69
+ #: 127.0.0.0/8, ::1. Default False -- this is the SSRF case, and a
70
+ #: registry on localhost is a deliberate, local choice that deserves to
71
+ #: be stated.
72
+ allow_loopback: bool = False
73
+ #: 169.254.0.0/16, fe80::/10. Default False: cloud metadata lives here
74
+ #: and nothing else legitimate does.
75
+ allow_link_local: bool = False
76
+ #: Hosts permitted regardless of where they resolve, exactly as written
77
+ #: in the reference (host or host:port).
78
+ allowed_hosts: frozenset[str] = field(default_factory=frozenset)
79
+
80
+ def is_allowlisted(self, host: str) -> bool:
81
+ """Whether `host` is permitted outright, before any lookup.
82
+
83
+ Checked first by the caller so an operator who deliberately runs a
84
+ registry on localhost is never subjected to a DNS round-trip to be
85
+ told what they already declared.
86
+ """
87
+ candidates = {host.strip().lower(), hostname_of(host).lower()}
88
+ return bool(candidates & {entry.strip().lower() for entry in self.allowed_hosts})
89
+
90
+ def decide_addresses(
91
+ self, addresses: list[ipaddress.IPv4Address | ipaddress.IPv6Address]
92
+ ) -> NetworkDecision:
93
+ """Classify a host from the addresses it resolves to.
94
+
95
+ **Every** address must pass. A name answering with one public and
96
+ one loopback address is the shape of a DNS-rebinding attack, and the
97
+ connection would be free to use either.
98
+ """
99
+ if not addresses:
100
+ # A name that resolves to nothing cannot be reached anyway;
101
+ # refusing here keeps the failure on the policy's terms rather
102
+ # than as a connection error deep inside an HTTP client.
103
+ return NetworkDecision.BLOCKED_UNRESOLVABLE
104
+ for address in addresses:
105
+ decision = self._classify(address)
106
+ if decision not in _ALLOWING:
107
+ return decision
108
+ return NetworkDecision.ALLOWED
109
+
110
+ def explain(self, host: str, decision: NetworkDecision) -> str:
111
+ """A refusal a reader can act on, naming the setting that changes it."""
112
+ return _EXPLANATIONS.get(decision, "").format(host=host)
113
+
114
+ def _classify(self, address: ipaddress.IPv4Address | ipaddress.IPv6Address) -> NetworkDecision:
115
+ # An IPv6 address may *carry* an IPv4 one. `::ffff:127.0.0.1`,
116
+ # `2002:7f00:1::` (6to4) and `64:ff9b::7f00:1` (NAT64) all reach
117
+ # 127.0.0.1 on a host with the matching translation configured, and
118
+ # only the first of the three is recognised by `is_loopback`. Judge
119
+ # the address that would actually be contacted, then fall through to
120
+ # judging the wrapper too -- a tunnel does not launder a destination.
121
+ embedded = _embedded_ipv4(address)
122
+ if embedded is not None:
123
+ decision = self._classify(embedded)
124
+ if decision not in _ALLOWING:
125
+ return decision
126
+
127
+ if address.is_unspecified or _in_any(address, _WILDCARD_SOURCE):
128
+ # 0.0.0.0/8 in full, not just the exact 0.0.0.0: Linux routes
129
+ # `0.x.y.z` to the local host, so the whole block is a spelling
130
+ # of "loopback" that `is_loopback` does not catch.
131
+ return NetworkDecision.BLOCKED_UNSPECIFIED
132
+ if address.is_loopback:
133
+ return (
134
+ NetworkDecision.ALLOWED if self.allow_loopback else NetworkDecision.BLOCKED_LOOPBACK
135
+ )
136
+ if address.is_link_local:
137
+ return (
138
+ NetworkDecision.ALLOWED
139
+ if self.allow_link_local
140
+ else NetworkDecision.BLOCKED_LINK_LOCAL
141
+ )
142
+ if _in_any(address, _SPECIAL) or address.is_multicast:
143
+ # Shared/benchmark/reserved space and the carrier-grade NAT block
144
+ # where Alibaba Cloud serves instance credentials
145
+ # (100.100.100.200). None of it hosts a registry, all of it is
146
+ # reachable from a CI runner, so it is refused outright rather
147
+ # than folded into `allow_private_networks` -- an operator who
148
+ # turns private networks on to reach 10.0.0.0/8 has not thereby
149
+ # asked for a route to a metadata service.
150
+ #
151
+ # `is_reserved` used to be part of this condition and was
152
+ # removed: for an IPv4-mapped IPv6 address (`::ffff:a.b.c.d`),
153
+ # its answer has changed between CPython patch releases (a
154
+ # private embedded address such as `::ffff:10.0.0.5` came back
155
+ # reserved on some 3.11.x builds and not on others), so the
156
+ # exact same instability the module comment above already
157
+ # calls out for `is_global` applied here too, just unnoticed.
158
+ # Every range this project actually intends to block through
159
+ # `is_reserved` is already named explicitly in `_SPECIAL`
160
+ # (TEST-NETs, the 240.0.0.0/4 block, NAT64, discard-only,
161
+ # documentation) or handled by the embedded-address recursion
162
+ # above -- a security boundary should not move with the
163
+ # runtime's patch version.
164
+ return NetworkDecision.BLOCKED_SPECIAL
165
+ if address.is_private:
166
+ return (
167
+ NetworkDecision.ALLOWED
168
+ if self.allow_private_networks
169
+ else NetworkDecision.BLOCKED_PRIVATE
170
+ )
171
+ return NetworkDecision.ALLOWED
172
+
173
+
174
+ #: 0.0.0.0/8. `is_unspecified` is only true for the single address 0.0.0.0,
175
+ #: but the whole block behaves as "this host" on Linux.
176
+ _WILDCARD_SOURCE = (ipaddress.ip_network("0.0.0.0/8"),)
177
+
178
+ #: Ranges that are neither public nor plausible registry homes. Enumerated
179
+ #: rather than derived from `is_global`, whose membership has changed between
180
+ #: Python releases -- a security boundary should not move with the runtime.
181
+ _SPECIAL = (
182
+ ipaddress.ip_network("100.64.0.0/10"), # RFC 6598 CGNAT; Alibaba metadata
183
+ ipaddress.ip_network("192.0.0.0/24"), # IETF protocol assignments
184
+ ipaddress.ip_network("192.0.2.0/24"), # TEST-NET-1
185
+ ipaddress.ip_network("192.88.99.0/24"), # deprecated 6to4 relay anycast
186
+ ipaddress.ip_network("198.18.0.0/15"), # benchmarking
187
+ ipaddress.ip_network("198.51.100.0/24"), # TEST-NET-2
188
+ ipaddress.ip_network("203.0.113.0/24"), # TEST-NET-3
189
+ ipaddress.ip_network("240.0.0.0/4"), # reserved, incl. 255.255.255.255
190
+ ipaddress.ip_network("64:ff9b::/96"), # NAT64
191
+ ipaddress.ip_network("64:ff9b:1::/48"), # local-use NAT64
192
+ ipaddress.ip_network("100::/64"), # discard-only
193
+ ipaddress.ip_network("2001:db8::/32"), # documentation
194
+ )
195
+
196
+ #: 6to4 (RFC 3056) and Teredo (RFC 4380) prefixes, whose payload is an IPv4
197
+ #: address that the host may actually route to.
198
+ _SIXTOFOUR = ipaddress.ip_network("2002::/16")
199
+ _TEREDO = ipaddress.ip_network("2001::/32")
200
+ _NAT64 = ipaddress.ip_network("64:ff9b::/96")
201
+
202
+
203
+ def _in_any(
204
+ address: ipaddress.IPv4Address | ipaddress.IPv6Address,
205
+ networks: tuple[ipaddress.IPv4Network | ipaddress.IPv6Network, ...],
206
+ ) -> bool:
207
+ return any(address.version == net.version and address in net for net in networks)
208
+
209
+
210
+ def _embedded_ipv4(
211
+ address: ipaddress.IPv4Address | ipaddress.IPv6Address,
212
+ ) -> ipaddress.IPv4Address | None:
213
+ """The IPv4 address an IPv6 address encodes, if it encodes one.
214
+
215
+ Covers the four encodings a host can be configured to translate:
216
+ IPv4-mapped and IPv4-compatible (`::ffff:a.b.c.d`, `::a.b.c.d`), 6to4,
217
+ Teredo and NAT64. Returns None for an ordinary IPv6 address.
218
+ """
219
+ if not isinstance(address, ipaddress.IPv6Address):
220
+ return None
221
+ if address.ipv4_mapped is not None:
222
+ return address.ipv4_mapped
223
+ if address.sixtofour is not None and address in _SIXTOFOUR:
224
+ return address.sixtofour
225
+ if address.teredo is not None and address in _TEREDO:
226
+ # (server, client); the client is the address packets reach.
227
+ return address.teredo[1]
228
+ if address in _NAT64:
229
+ return ipaddress.IPv4Address(int(address) & 0xFFFFFFFF)
230
+ return None
231
+
232
+
233
+ _EXPLANATIONS = {
234
+ NetworkDecision.BLOCKED_LOOPBACK: (
235
+ "{host} resolves to a loopback address. Refused by default: a reference that "
236
+ "reaches localhost is how an untrusted image name becomes a request to a "
237
+ "service on this machine. Set network_allow_loopback = true, or add the host "
238
+ "to network_allowed_hosts, if this is deliberate."
239
+ ),
240
+ NetworkDecision.BLOCKED_LINK_LOCAL: (
241
+ "{host} resolves to a link-local address (169.254.0.0/16). Refused by default: "
242
+ "this is where cloud providers serve instance credentials. Set "
243
+ "network_allow_link_local = true only if you know why you need it."
244
+ ),
245
+ NetworkDecision.BLOCKED_PRIVATE: (
246
+ "{host} resolves to a private address and network_allow_private_networks is "
247
+ "off. Turn it on, or add the host to network_allowed_hosts."
248
+ ),
249
+ NetworkDecision.BLOCKED_UNSPECIFIED: (
250
+ "{host} resolves to an unspecified address (0.0.0.0/8 or ::), which names this "
251
+ "host rather than a registry."
252
+ ),
253
+ NetworkDecision.BLOCKED_UNRESOLVABLE: "{host} could not be resolved to any address.",
254
+ NetworkDecision.BLOCKED_SPECIAL: (
255
+ "{host} resolves into reserved, shared or carrier-grade-NAT space (for example "
256
+ "100.64.0.0/10, where some clouds serve instance credentials). No registry is "
257
+ "published there; add the host to network_allowed_hosts if this is deliberate."
258
+ ),
259
+ NetworkDecision.BLOCKED_SCHEME: (
260
+ "{host} was requested over a scheme other than http/https, which this tool never follows."
261
+ ),
262
+ }
263
+
264
+
265
+ def hostname_of(host: str) -> str:
266
+ """Strip an optional `:port`, leaving the name or literal address.
267
+
268
+ IPv6 literals in a registry reference are bracketed (`[::1]:5000`), so
269
+ the bracket form is handled before the naive rsplit that would otherwise
270
+ cut a bare `::1` in half.
271
+ """
272
+ value = host.strip()
273
+ if not value:
274
+ return ""
275
+ if value.startswith("["):
276
+ closing = value.find("]")
277
+ return value[1:closing] if closing > 1 else ""
278
+ if value.count(":") == 1:
279
+ return value.rsplit(":", 1)[0]
280
+ return value