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,321 @@
1
+ """Deriva um patch de Dockerfile a partir das vulnerabilidades encontradas.
2
+
3
+ O que esta ferramenta analisa é uma **imagem publicada**, não o Dockerfile de
4
+ quem a executa -- e não há como recuperar um do outro. Então o produto honesto
5
+ não é "o seu Dockerfile corrigido": é um patch que parte da imagem analisada e
6
+ aplica, em camadas, exatamente o que os achados justificam. Quem tem o
7
+ Dockerfile original copia as linhas; quem não tem constrói em cima.
8
+
9
+ Cada ação sai do dado, nunca de um palpite:
10
+
11
+ * pacotes de SO com correção disponível -> `apk upgrade` / `apt-get upgrade`,
12
+ escolhido pela distro que o próprio scanner reportou no `Target`;
13
+ * pacotes de linguagem com correção -> upgrade do gerenciador correspondente;
14
+ * o npm embutido nas imagens Node -> as duas saídas reais (atualizar ou
15
+ remover), porque `apk upgrade` não toca nele;
16
+ * achados sem correção -> nenhuma ação inventada, apenas o registro de que
17
+ sobram e de que só trocar de base resolve.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from dataclasses import dataclass, field
23
+ from typing import TYPE_CHECKING
24
+
25
+ from dockerls.domain.entities.vulnerability import PackageOrigin
26
+
27
+ if TYPE_CHECKING:
28
+ from collections.abc import Iterable, Sequence
29
+
30
+ from dockerls.application.dto.analysis import ImageAnalysis
31
+ from dockerls.domain.entities.vulnerability import Vulnerability
32
+
33
+ # Marcadores de distro no `Target` do Trivy -> comando de upgrade. A ordem
34
+ # importa só para "ubuntu" não casar antes de "debian" em alvos que citam as
35
+ # duas; ambos usam apt, então o resultado é o mesmo de qualquer forma.
36
+ _OS_UPGRADE = (
37
+ ("alpine", "apk", "apk upgrade --no-cache"),
38
+ ("wolfi", "apk", "apk upgrade --no-cache"),
39
+ ("chainguard", "apk", "apk upgrade --no-cache"),
40
+ ("debian", "apt", "apt-get update && apt-get upgrade -y && rm -rf /var/lib/apt/lists/*"),
41
+ ("ubuntu", "apt", "apt-get update && apt-get upgrade -y && rm -rf /var/lib/apt/lists/*"),
42
+ ("redhat", "dnf", "dnf upgrade -y && dnf clean all"),
43
+ ("centos", "dnf", "dnf upgrade -y && dnf clean all"),
44
+ ("rocky", "dnf", "dnf upgrade -y && dnf clean all"),
45
+ ("alma", "dnf", "dnf upgrade -y && dnf clean all"),
46
+ ("amazon", "dnf", "dnf upgrade -y && dnf clean all"),
47
+ ("photon", "tdnf", "tdnf upgrade -y && tdnf clean all"),
48
+ ("suse", "zypper", "zypper update -y && zypper clean --all"),
49
+ )
50
+
51
+ # Ecossistema de linguagem -> como atualizar um pacote nomeado.
52
+ _LANG_UPGRADE = {
53
+ "npm": "npm install -g {packages}",
54
+ "yarn": "yarn global upgrade {packages}",
55
+ "pip": "pip install --no-cache-dir --upgrade {packages}",
56
+ "gem": "gem update {packages}",
57
+ "composer": "composer global update {packages}",
58
+ }
59
+
60
+ _LANG_MARKERS = {
61
+ "npm": ("npm", "node-pkg", "node_modules", "yarn"),
62
+ "pip": ("pip", "python-pkg", "site-packages", "poetry"),
63
+ "gem": ("gem", "gemspec", "ruby"),
64
+ "composer": ("composer", "php"),
65
+ }
66
+
67
+ _NPM_BUNDLE_MARKERS = ("node_modules/npm", "/npm/")
68
+
69
+
70
+ @dataclass(frozen=True)
71
+ class RemediationAction:
72
+ """Uma linha do patch, com o motivo que a justifica."""
73
+
74
+ title: str
75
+ dockerfile: str
76
+ rationale: str
77
+ addresses: tuple[str, ...] = ()
78
+ #: Ações que não podem ser aplicadas junto com outra (atualizar o npm
79
+ #: versus removê-lo). O chamador escolhe uma; ambas ficam no patch,
80
+ #: com a alternativa comentada.
81
+ alternative_to: str = ""
82
+
83
+ @property
84
+ def fixes(self) -> int:
85
+ return len(self.addresses)
86
+
87
+
88
+ @dataclass
89
+ class RemediationPlan:
90
+ image_reference: str
91
+ actions: list[RemediationAction] = field(default_factory=list)
92
+ #: CVEs que nenhuma ação deste plano resolve.
93
+ unresolved: tuple[str, ...] = ()
94
+ base_suggestion: str = ""
95
+
96
+ @property
97
+ def is_empty(self) -> bool:
98
+ return not self.actions and not self.base_suggestion
99
+
100
+ @property
101
+ def resolved_count(self) -> int:
102
+ """Achados distintos cobertos por pelo menos uma ação."""
103
+ covered: set[str] = set()
104
+ for action in self.actions:
105
+ covered.update(action.addresses)
106
+ return len(covered)
107
+
108
+
109
+ def _ecosystem_of(vuln: Vulnerability) -> str | None:
110
+ haystack = f"{vuln.package_type} {vuln.target}".lower()
111
+ for ecosystem, markers in _LANG_MARKERS.items():
112
+ if any(marker in haystack for marker in markers):
113
+ return ecosystem
114
+ return None
115
+
116
+
117
+ def _os_upgrade_for(vulns: Sequence[Vulnerability]) -> tuple[str, str] | None:
118
+ """(nome do gerenciador, comando) para a distro que o scanner reportou."""
119
+ haystack = " ".join(f"{v.package_type} {v.target}" for v in vulns).lower()
120
+ for marker, manager, command in _OS_UPGRADE:
121
+ if marker in haystack:
122
+ return manager, command
123
+ return None
124
+
125
+
126
+ def _is_bundled_npm(vuln: Vulnerability) -> bool:
127
+ return vuln.origin is PackageOrigin.LANGUAGE and any(
128
+ marker in vuln.target.lower() for marker in _NPM_BUNDLE_MARKERS
129
+ )
130
+
131
+
132
+ def build_remediation_plan(analysis: ImageAnalysis) -> RemediationPlan:
133
+ """Derive the actions the findings actually justify -- and nothing else."""
134
+ vulns = list(analysis.scan.vulnerabilities)
135
+ plan = RemediationPlan(image_reference=analysis.image.full_reference)
136
+ if not vulns:
137
+ return plan
138
+
139
+ fixable = [v for v in vulns if v.is_fixable]
140
+ handled: set[str] = set()
141
+
142
+ # 1. O npm embutido vem primeiro: é o caso em que o upgrade genérico da
143
+ # linguagem não é a melhor resposta, e em que remover resolve tudo.
144
+ bundled_npm = [v for v in vulns if _is_bundled_npm(v)]
145
+ if bundled_npm:
146
+ ids = tuple(v.cve_id for v in bundled_npm)
147
+ plan.actions.append(
148
+ RemediationAction(
149
+ title="Update the bundled npm CLI",
150
+ dockerfile="RUN npm install -g npm@latest",
151
+ rationale=(
152
+ "These findings are in the npm CLI shipped inside the image, not in "
153
+ "OS packages -- apk/apt upgrade does not touch them."
154
+ ),
155
+ addresses=ids,
156
+ alternative_to="remove-npm",
157
+ )
158
+ )
159
+ plan.actions.append(
160
+ RemediationAction(
161
+ title="Remove the bundled npm CLI",
162
+ dockerfile=(
163
+ "RUN rm -rf /usr/local/lib/node_modules/npm "
164
+ "/usr/local/bin/npm /usr/local/bin/npx"
165
+ ),
166
+ rationale=(
167
+ "If npm is not needed at runtime (multi-stage build shipping only "
168
+ "dist/), removing it clears every one of these findings outright."
169
+ ),
170
+ addresses=ids,
171
+ alternative_to="update-npm",
172
+ )
173
+ )
174
+ handled.update(ids)
175
+
176
+ # 2. Pacotes de SO com correção publicada.
177
+ os_fixable = [v for v in fixable if v.origin is PackageOrigin.OS]
178
+ if os_fixable:
179
+ upgrade = _os_upgrade_for(os_fixable)
180
+ if upgrade is not None:
181
+ manager, command = upgrade
182
+ ids = tuple(v.cve_id for v in os_fixable)
183
+ plan.actions.append(
184
+ RemediationAction(
185
+ title=f"Upgrade OS packages ({manager})",
186
+ dockerfile=f"RUN {command}",
187
+ rationale=(
188
+ f"{len(ids)} OS-package finding(s) have a fixed version published upstream."
189
+ ),
190
+ addresses=ids,
191
+ )
192
+ )
193
+ handled.update(ids)
194
+
195
+ # 3. Pacotes de linguagem com correção, agrupados por ecossistema. O npm
196
+ # embutido já foi tratado acima e não se repete aqui.
197
+ lang_fixable = [
198
+ v for v in fixable if v.origin is PackageOrigin.LANGUAGE and not _is_bundled_npm(v)
199
+ ]
200
+ by_ecosystem: dict[str, list[Vulnerability]] = {}
201
+ for vuln in lang_fixable:
202
+ ecosystem = _ecosystem_of(vuln)
203
+ if ecosystem in _LANG_UPGRADE:
204
+ by_ecosystem.setdefault(str(ecosystem), []).append(vuln)
205
+
206
+ for ecosystem, items in sorted(by_ecosystem.items()):
207
+ # Pin ao alvo corrigido, não um upgrade cego: é o dado que o scanner
208
+ # já entregou, e vale mais que "atualize tudo e torça".
209
+ pinned = sorted({f"{v.package_name}@{v.fixed_version}" for v in items if v.package_name})
210
+ if not pinned:
211
+ continue
212
+ template = _LANG_UPGRADE[ecosystem]
213
+ if ecosystem in ("pip", "gem", "composer"):
214
+ pinned = [p.replace("@", "==" if ecosystem == "pip" else ":") for p in pinned]
215
+ ids = tuple(v.cve_id for v in items)
216
+ plan.actions.append(
217
+ RemediationAction(
218
+ title=f"Upgrade {ecosystem} packages",
219
+ dockerfile=f"RUN {template.format(packages=' '.join(pinned))}",
220
+ rationale=f"{len(ids)} {ecosystem} finding(s) have a published fixed version.",
221
+ addresses=ids,
222
+ )
223
+ )
224
+ handled.update(ids)
225
+
226
+ plan.unresolved = tuple(sorted({v.cve_id for v in vulns} - handled))
227
+
228
+ # 4. Trocar de base só é sugerido quando sobra algo que as camadas acima
229
+ # não resolvem -- caso contrário seria conselho gratuito.
230
+ if plan.unresolved and not (analysis.image.is_alpine or analysis.image.is_distroless):
231
+ plan.base_suggestion = (
232
+ f"{len(plan.unresolved)} finding(s) have no published fix. A minimal base "
233
+ f"(alpine, distroless, or a hardened vendor image) removes the packages "
234
+ f"they live in rather than patching them; `dockerls recommend "
235
+ f"{analysis.image.name}` ranks the alternatives by measured vulnerabilities."
236
+ )
237
+
238
+ return plan
239
+
240
+
241
+ def render_dockerfile_patch(plan: RemediationPlan) -> str:
242
+ """Render the plan as a Dockerfile that starts from the analysed image.
243
+
244
+ Não é "o seu Dockerfile corrigido" -- esta ferramenta nunca viu o seu
245
+ Dockerfile. É um patch aplicável: quem tem o original copia as camadas,
246
+ quem não tem constrói a partir daqui.
247
+ """
248
+ lines = [
249
+ "# Generated by dockerls --fix",
250
+ f"# Base: {plan.image_reference}",
251
+ "#",
252
+ "# These layers are derived from the findings of a real scan of the image",
253
+ "# above. dockerls analyses published images, not your Dockerfile, so this",
254
+ "# is a patch to apply -- copy the RUN lines into your own build, or build",
255
+ "# from here directly.",
256
+ ]
257
+
258
+ if plan.is_empty:
259
+ lines += [
260
+ "#",
261
+ "# Nothing to remediate: no finding in this image has a published fix",
262
+ "# that a layer could apply.",
263
+ "",
264
+ f"FROM {plan.image_reference}",
265
+ "",
266
+ ]
267
+ return "\n".join(lines)
268
+
269
+ lines += ["", f"FROM {plan.image_reference}", ""]
270
+
271
+ chosen_alternatives: set[str] = set()
272
+ for action in plan.actions:
273
+ commented = bool(action.alternative_to) and action.alternative_to in chosen_alternatives
274
+ if action.alternative_to:
275
+ chosen_alternatives.add(_action_key(action))
276
+
277
+ lines.append(f"# {action.title} -- fixes {action.fixes} finding(s)")
278
+ for wrapped in _wrap_comment(action.rationale):
279
+ lines.append(f"# {wrapped}")
280
+ if commented:
281
+ lines.append("# Alternative to the layer above -- pick one, not both:")
282
+ lines.append(f"# {action.dockerfile}")
283
+ else:
284
+ lines.append(action.dockerfile)
285
+ lines.append("")
286
+
287
+ if plan.unresolved:
288
+ lines.append(f"# {len(plan.unresolved)} finding(s) remain with no published fix:")
289
+ for cve in plan.unresolved[:20]:
290
+ lines.append(f"# {cve}")
291
+ if len(plan.unresolved) > 20:
292
+ lines.append(f"# ... and {len(plan.unresolved) - 20} more")
293
+ lines.append("")
294
+
295
+ if plan.base_suggestion:
296
+ for wrapped in _wrap_comment(plan.base_suggestion):
297
+ lines.append(f"# {wrapped}")
298
+ lines.append("")
299
+
300
+ return "\n".join(lines)
301
+
302
+
303
+ def _action_key(action: RemediationAction) -> str:
304
+ """A ação que a alternativa dela referencia por nome."""
305
+ return "update-npm" if "install -g npm" in action.dockerfile else "remove-npm"
306
+
307
+
308
+ def _wrap_comment(text: str, width: int = 72) -> list[str]:
309
+ words: Iterable[str] = text.split()
310
+ lines: list[str] = []
311
+ current = ""
312
+ for word in words:
313
+ candidate = f"{current} {word}".strip()
314
+ if len(candidate) > width and current:
315
+ lines.append(current)
316
+ current = word
317
+ else:
318
+ current = candidate
319
+ if current:
320
+ lines.append(current)
321
+ return lines
@@ -0,0 +1,88 @@
1
+ """Where an image's vulnerability-count history persists between runs.
2
+
3
+ Mirrors `tag_history_store.py` exactly, for the same reason its own module
4
+ docstring gives: the cache that scores lean on expires because a stale
5
+ score is worse than none, while a history *is* the past and is worth more
6
+ the longer it has been kept. A year-long TTL, renewed on every write, is
7
+ what lets the second observation ever recorded still answer "since when".
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from datetime import UTC, datetime
13
+ from typing import TYPE_CHECKING
14
+
15
+ from loguru import logger
16
+
17
+ from dockerls.domain.value_objects.scan_history import ScanHistory, ScanObservation, record
18
+
19
+ if TYPE_CHECKING:
20
+ from dockerls.domain.interfaces.cache_store import CacheStoreInterface
21
+
22
+ #: A year, matching `tag_history_store.HISTORY_TTL_SECONDS`: this is the
23
+ #: past, and it does not go stale.
24
+ HISTORY_TTL_SECONDS = 365 * 24 * 60 * 60
25
+
26
+ _KEY_PREFIX = "scan-history"
27
+
28
+
29
+ class ScanHistoryStore:
30
+ """Reads and writes vulnerability-count history, keyed by reference."""
31
+
32
+ def __init__(self, cache: CacheStoreInterface | None = None):
33
+ self._cache = cache
34
+
35
+ async def get(self, reference: str) -> ScanHistory:
36
+ """The stored history, or an empty one when there is none (or it
37
+ could not be read)."""
38
+ if self._cache is None or not reference:
39
+ return ScanHistory(reference=reference)
40
+ try:
41
+ raw = await self._cache.get(_key(reference))
42
+ except Exception as e: # pragma: no cover - the cache is the unstable path
43
+ logger.debug(f"Could not read the scan history of {reference}: {e}")
44
+ return ScanHistory(reference=reference)
45
+ return ScanHistory.from_dict(reference, raw)
46
+
47
+ async def observe(
48
+ self,
49
+ reference: str,
50
+ *,
51
+ digest: str,
52
+ critical: int,
53
+ high: int,
54
+ medium: int,
55
+ low: int,
56
+ total: int,
57
+ observed_at: datetime | None = None,
58
+ ) -> ScanHistory:
59
+ """Incorporates this scan's counts and returns the resulting history.
60
+
61
+ Writes only when something changed: rewriting an unchanged entry on
62
+ every query would do nothing but renew the TTL, which the write
63
+ that matters already does.
64
+ """
65
+ current = await self.get(reference)
66
+ observation = ScanObservation(
67
+ digest=digest,
68
+ observed_at=(observed_at or datetime.now(UTC)).isoformat(timespec="seconds"),
69
+ critical=critical,
70
+ high=high,
71
+ medium=medium,
72
+ low=low,
73
+ total=total,
74
+ )
75
+ updated = record(current, observation)
76
+ if updated is current or self._cache is None:
77
+ return updated
78
+ try:
79
+ await self._cache.set(
80
+ _key(reference), updated.to_dict(), ttl_seconds=HISTORY_TTL_SECONDS
81
+ )
82
+ except Exception as e: # pragma: no cover - the cache is the unstable path
83
+ logger.debug(f"Could not write the scan history of {reference}: {e}")
84
+ return updated
85
+
86
+
87
+ def _key(reference: str) -> str:
88
+ return f"{_KEY_PREFIX}:{reference}"
@@ -0,0 +1,88 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ from loguru import logger
6
+
7
+ from dockerls.application.services.fallback_scanner import FallbackScanner
8
+ from dockerls.integrations.grype.scanner import GrypeScanner
9
+ from dockerls.integrations.trivy.scanner import TrivyScanner
10
+
11
+ if TYPE_CHECKING:
12
+ from pathlib import Path
13
+
14
+ from dockerls.domain.interfaces.scanner import ScannerInterface
15
+ from dockerls.infrastructure.evidence import EvidenceStore
16
+ from dockerls.infrastructure.network.host_guard import HostGuard
17
+
18
+
19
+ class ScannerFactory:
20
+ @staticmethod
21
+ async def create(
22
+ timeout: int = 300,
23
+ workers: int = 1,
24
+ cache_dir: Path | None = None,
25
+ evidence: EvidenceStore | None = None,
26
+ guard: HostGuard | None = None,
27
+ ) -> ScannerInterface:
28
+ """Build the scanner the pipeline will use.
29
+
30
+ When both tools are installed the result is a `FallbackScanner`: the
31
+ Trivy runs first and the Grype picks up whatever it could not measure.
32
+ Selecting on `is_available()` alone -- which is just `shutil.which` --
33
+ meant an installed-but-broken Trivy monopolised every scan and the
34
+ Grype was never asked, no matter how many targets failed.
35
+ """
36
+ trivy = TrivyScanner(
37
+ timeout=timeout,
38
+ workers=workers,
39
+ cache_dir=cache_dir,
40
+ evidence=evidence,
41
+ guard=guard,
42
+ )
43
+ grype = GrypeScanner(timeout=timeout, evidence=evidence, guard=guard, workers=workers)
44
+ has_trivy = await trivy.is_available()
45
+ has_grype = await grype.is_available()
46
+
47
+ if has_trivy and has_grype:
48
+ logger.info("Using Trivy, with Grype as per-scan fallback")
49
+ return FallbackScanner(primary=trivy, secondary=grype)
50
+ if has_trivy:
51
+ logger.info("Using Trivy scanner (Grype not installed; no fallback available)")
52
+ return trivy
53
+ if has_grype:
54
+ logger.info("Trivy not installed, using Grype")
55
+ return grype
56
+
57
+ logger.warning("No scanner available, using Trivy (commands will fail)")
58
+ return trivy
59
+
60
+ @staticmethod
61
+ async def create_secondary(
62
+ primary: ScannerInterface,
63
+ timeout: int = 300,
64
+ evidence: EvidenceStore | None = None,
65
+ guard: HostGuard | None = None,
66
+ workers: int = 1,
67
+ ) -> ScannerInterface | None:
68
+ """Return an *independent* scanner for cross-validation.
69
+
70
+ Cross-validation is only meaningful between two different tools, so
71
+ this returns None when the only available scanner is the one already
72
+ producing the primary results.
73
+ """
74
+ # Um `FallbackScanner` já usa os dois: revalidar com qualquer um deles
75
+ # confrontaria um resultado com a ferramenta que possivelmente o
76
+ # produziu, o que não é validação independente nenhuma.
77
+ if isinstance(primary, FallbackScanner):
78
+ primary = primary.primary
79
+
80
+ if isinstance(primary, GrypeScanner):
81
+ trivy = TrivyScanner(timeout=timeout, evidence=evidence, guard=guard, workers=workers)
82
+ return trivy if await trivy.is_available() else None
83
+
84
+ grype = GrypeScanner(timeout=timeout, evidence=evidence, guard=guard, workers=workers)
85
+ if await grype.is_available():
86
+ return grype
87
+ logger.info("Grype not installed; cross-validation disabled")
88
+ return None
@@ -0,0 +1,151 @@
1
+ """Named image sources, resolved from a selection instead of a branch.
2
+
3
+ `recommend` and `search` both need to answer "which catalogues should this
4
+ run look at", and the answer comes from three places at once: the config
5
+ file, the legacy `--no-hardened` flag, and the new `--source`/`--all-sources`
6
+ options. Written as conditionals that ask `if source == "dhi"`, that logic
7
+ would have to be repeated in every command and extended in every command
8
+ each time a catalogue is added.
9
+
10
+ So the catalogues register themselves here, and a command asks for a
11
+ *selection*. Adding a provider is one `register()` call in the wiring layer;
12
+ no command changes, and no branch anywhere grows a new arm.
13
+
14
+ The registry deliberately knows nothing about HTTP, registries or scanners:
15
+ a `SourceSpec` carries a name, a label and a coroutine that builds the
16
+ repository. That keeps the application layer free of `httpx` while still
17
+ letting the CLI enumerate what exists (`--source` help text, `doctor`).
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from dataclasses import dataclass, field
23
+ from typing import TYPE_CHECKING
24
+
25
+ if TYPE_CHECKING:
26
+ from collections.abc import Awaitable, Callable, Iterable, Sequence
27
+
28
+ from dockerls.domain.interfaces.image_repository import ImageRepositoryInterface
29
+
30
+ SourceBuilder = Callable[[], Awaitable[ImageRepositoryInterface]]
31
+
32
+ #: `--source all` and `--all-sources` mean the same thing; accepting the
33
+ #: token as well keeps `--source` self-sufficient in scripts.
34
+ ALL_SOURCES = "all"
35
+
36
+
37
+ class UnknownSourceError(ValueError):
38
+ """Raised for a `--source` value no provider is registered under.
39
+
40
+ Carries the valid names so the CLI can say what *is* available instead
41
+ of only what is not.
42
+ """
43
+
44
+ def __init__(self, unknown: Iterable[str], available: Iterable[str]):
45
+ self.unknown = sorted(set(unknown))
46
+ self.available = list(available)
47
+ super().__init__(
48
+ f"unknown source(s): {', '.join(self.unknown)}. "
49
+ f"Available: {', '.join([*self.available, ALL_SOURCES])}"
50
+ )
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class SourceSpec:
55
+ """One catalogue DockerLs can discover candidates in."""
56
+
57
+ #: CLI token, lowercase and stable: this is what `--source` accepts.
58
+ name: str
59
+ #: Human label carried on every `DockerImage` this source produces, and
60
+ #: shown in the results table. Must match what the repository sets, or
61
+ #: the table would credit a candidate to the wrong catalogue.
62
+ label: str
63
+ #: Builds the repository. Async because some sources authenticate.
64
+ build: SourceBuilder
65
+ #: The source that sets the bulk of the candidate list. Exactly one
66
+ #: should be primary; it receives the full `--limit` while the others
67
+ #: are capped, because a broad catalogue and a curated one need very
68
+ #: different fan-outs.
69
+ primary: bool = False
70
+ #: Searched when no explicit `--source` is given.
71
+ default_enabled: bool = True
72
+ #: The registry behind this catalogue refuses anonymous pulls, so its
73
+ #: candidates cannot be scanned without credentials. Not a reason to
74
+ #: hide the source -- it is a reason the verdict will be UNVERIFIED,
75
+ #: which is exactly what the user needs to be told.
76
+ requires_auth: bool = False
77
+ #: Short note surfaced next to the source name in `--help`/`doctor`.
78
+ description: str = ""
79
+
80
+
81
+ @dataclass
82
+ class SourceRegistry:
83
+ """The catalogues this process knows about, in registration order."""
84
+
85
+ _specs: dict[str, SourceSpec] = field(default_factory=dict)
86
+
87
+ def register(self, spec: SourceSpec) -> None:
88
+ if spec.name in self._specs:
89
+ raise ValueError(f"source already registered: {spec.name}")
90
+ if spec.name == ALL_SOURCES:
91
+ raise ValueError(f"{ALL_SOURCES!r} is reserved and cannot name a source")
92
+ self._specs[spec.name] = spec
93
+
94
+ @property
95
+ def specs(self) -> list[SourceSpec]:
96
+ return list(self._specs.values())
97
+
98
+ @property
99
+ def names(self) -> list[str]:
100
+ return list(self._specs)
101
+
102
+ def get(self, name: str) -> SourceSpec | None:
103
+ return self._specs.get(name.strip().lower())
104
+
105
+ def resolve(
106
+ self,
107
+ selection: Sequence[str] | None = None,
108
+ *,
109
+ all_sources: bool = False,
110
+ include_optional: bool = True,
111
+ ) -> list[SourceSpec]:
112
+ """Which sources this run should search.
113
+
114
+ * `all_sources` (or a selection containing `all`) -- every registered
115
+ source, including the ones that are off by default.
116
+ * an explicit selection -- exactly those, in registration order so
117
+ the primary stays first regardless of the order they were typed.
118
+ An explicit selection overrides `include_optional`: asking for a
119
+ source by name is a stronger statement than a default.
120
+ * neither -- the default-enabled sources, minus the non-primary ones
121
+ when `include_optional` is False (that is `--no-hardened`).
122
+
123
+ Order matters downstream: the first spec becomes the composite's
124
+ primary and therefore gets the full tag limit.
125
+ """
126
+ requested = [s.strip().lower() for s in (selection or []) if s.strip()]
127
+ if all_sources or ALL_SOURCES in requested:
128
+ return self._ordered(self._specs)
129
+
130
+ if requested:
131
+ unknown = [name for name in requested if name not in self._specs]
132
+ if unknown:
133
+ raise UnknownSourceError(unknown, self.names)
134
+ return self._ordered({name: self._specs[name] for name in requested})
135
+
136
+ chosen = {
137
+ name: spec
138
+ for name, spec in self._specs.items()
139
+ if spec.default_enabled and (include_optional or spec.primary)
140
+ }
141
+ return self._ordered(chosen)
142
+
143
+ def _ordered(self, chosen: dict[str, SourceSpec]) -> list[SourceSpec]:
144
+ """Registration order, primary first.
145
+
146
+ `CompositeImageRepository` gives its primary the full limit and caps
147
+ the rest, so which spec lands in position zero is a behavioural
148
+ decision, not a cosmetic one.
149
+ """
150
+ selected = [spec for name, spec in self._specs.items() if name in chosen]
151
+ return sorted(selected, key=lambda spec: not spec.primary)