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,233 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from enum import StrEnum
5
+ from typing import TYPE_CHECKING
6
+
7
+ from loguru import logger
8
+
9
+ from dockerls.domain.entities.vulnerability import Severity, finding_identity
10
+
11
+ if TYPE_CHECKING:
12
+ from dockerls.application.dto.analysis import ImageAnalysis
13
+ from dockerls.domain.entities.scan_result import ScanResult
14
+ from dockerls.domain.interfaces.scanner import ScannerInterface
15
+
16
+ # A second scanner never reproduces the first one's findings exactly -- the
17
+ # databases differ and each maps severities its own way. Only a difference
18
+ # that is both large in absolute terms *and* large relative to what was found
19
+ # is treated as a real disagreement worth flagging.
20
+ DEFAULT_ABS_TOLERANCE = 2
21
+ DEFAULT_REL_TOLERANCE = 0.5
22
+
23
+
24
+ class CrossValidationOutcome(StrEnum):
25
+ """What the second opinion amounted to.
26
+
27
+ Kept as a named outcome rather than a boolean because the three
28
+ interesting states are not "agrees / disagrees" but "agrees",
29
+ "differs in ways two databases normally differ", and "tells a
30
+ different story" -- and the last of those must reach the confidence
31
+ model, while the middle one merely stops it reaching the top.
32
+ """
33
+
34
+ NO_SECOND_SCANNER = "NO_SECOND_SCANNER"
35
+ AGREEMENT = "AGREEMENT"
36
+ MINOR_DIVERGENCE = "MINOR_DIVERGENCE"
37
+ MATERIAL_DIVERGENCE = "MATERIAL_DIVERGENCE"
38
+
39
+
40
+ #: Severity bands compared by identity. LOW and MEDIUM are deliberately out:
41
+ #: their populations are large, the two databases classify them differently
42
+ #: as a matter of course, and comparing them would make every image look
43
+ #: disputed.
44
+ _COMPARED_SEVERITIES = (Severity.CRITICAL, Severity.HIGH)
45
+
46
+
47
+ # Validations are independent of each other, so they run concurrently. The
48
+ # cap keeps a handful of scanner processes from thrashing the machine.
49
+ DEFAULT_WORKERS = 5
50
+
51
+
52
+ #: Worst-first, so `_worse` needs no comparison table of its own.
53
+ _SEVERITY_OF_OUTCOME = {
54
+ CrossValidationOutcome.MATERIAL_DIVERGENCE: 3,
55
+ CrossValidationOutcome.MINOR_DIVERGENCE: 2,
56
+ CrossValidationOutcome.AGREEMENT: 1,
57
+ CrossValidationOutcome.NO_SECOND_SCANNER: 0,
58
+ }
59
+
60
+
61
+ def _worse(a: CrossValidationOutcome, b: CrossValidationOutcome) -> CrossValidationOutcome:
62
+ return a if _SEVERITY_OF_OUTCOME[a] >= _SEVERITY_OF_OUTCOME[b] else b
63
+
64
+
65
+ #: How many differing findings to name before the message stops being
66
+ #: readable. The full picture is in the raw evidence of both scanners.
67
+ _MAX_EXAMPLES = 3
68
+
69
+
70
+ def _examples(identities: set[str]) -> str:
71
+ """Name a few of the disputed findings, so the reader can go and look."""
72
+ if not identities:
73
+ return ""
74
+ shown = sorted(identities)[:_MAX_EXAMPLES]
75
+ names = ", ".join(identity.split("|", 1)[0] for identity in shown)
76
+ more = len(identities) - len(shown)
77
+ return f" [{names}{f', +{more} more' if more > 0 else ''}]"
78
+
79
+
80
+ class CrossValidator:
81
+ """Re-scans top candidates with a second scanner and flags material
82
+ disagreements, so a score is never presented at full confidence when
83
+ two independent scanners tell different stories."""
84
+
85
+ def __init__(
86
+ self,
87
+ scanner: ScannerInterface | None,
88
+ abs_tolerance: int = DEFAULT_ABS_TOLERANCE,
89
+ rel_tolerance: float = DEFAULT_REL_TOLERANCE,
90
+ workers: int = DEFAULT_WORKERS,
91
+ ):
92
+ self._scanner = scanner
93
+ self._abs_tolerance = abs_tolerance
94
+ self._rel_tolerance = rel_tolerance
95
+ self._workers = max(1, workers)
96
+
97
+ @property
98
+ def enabled(self) -> bool:
99
+ return self._scanner is not None
100
+
101
+ @property
102
+ def scanner(self) -> ScannerInterface | None:
103
+ return self._scanner
104
+
105
+ async def validate(self, analyses: list[ImageAnalysis]) -> None:
106
+ """Annotate each analysis in place with `scan_divergence` and the
107
+ secondary scanner's evidence path.
108
+
109
+ The DB is refreshed once before the batch, then the validations run
110
+ concurrently -- they share no state, so serializing them only added
111
+ latency.
112
+ """
113
+ if self._scanner is None or not analyses:
114
+ return
115
+ if not await self._scanner.is_available():
116
+ logger.info("Cross-validation scanner unavailable; skipping")
117
+ return
118
+
119
+ refresh_db = getattr(self._scanner, "refresh_db", None)
120
+ if callable(refresh_db):
121
+ await refresh_db()
122
+
123
+ prefetched = await self._prescan(analyses)
124
+
125
+ semaphore = asyncio.Semaphore(self._workers)
126
+
127
+ async def guarded(analysis: ImageAnalysis) -> None:
128
+ async with semaphore:
129
+ await self._validate_one(analysis, prefetched)
130
+
131
+ await asyncio.gather(*[guarded(a) for a in analyses])
132
+
133
+ async def _prescan(self, analyses: list[ImageAnalysis]) -> dict[str, ScanResult]:
134
+ """Mede os finalistas de uma vez, quando a engine Go existe.
135
+
136
+ São poucos scans -- os finalistas, não as cem candidatas --, então o
137
+ ganho aqui é menor que no passo principal. Vale mesmo assim: é o
138
+ mesmo caminho, e mantê-lo fora criaria uma segunda forma de
139
+ orquestrar scans para o projeto manter.
140
+
141
+ `{}` quando não há engine, e o caminho de sempre responde.
142
+ """
143
+ batch = getattr(self._scanner, "batch", None)
144
+ if batch is None:
145
+ return {}
146
+ # A chave é a referência: o dedup por digest não se aplica aqui,
147
+ # porque os finalistas já vieram deduplicados do passo principal.
148
+ targets = [(a.image.full_reference, a.image.digest or "") for a in analyses]
149
+ outcome = await batch.scan_batch(targets)
150
+ if outcome is None:
151
+ return {}
152
+ return {
153
+ reference: result
154
+ for (reference, _), result in zip(targets, outcome.results, strict=True)
155
+ }
156
+
157
+ async def _validate_one(
158
+ self, analysis: ImageAnalysis, prefetched: dict[str, ScanResult] | None = None
159
+ ) -> None:
160
+ if self._scanner is None:
161
+ return
162
+ reference = analysis.image.full_reference
163
+ secondary = (prefetched or {}).get(reference)
164
+ if secondary is None:
165
+ secondary = await self._scanner.scan(reference)
166
+
167
+ if not secondary.is_verified:
168
+ logger.warning(
169
+ f"Cross-validation of {reference} did not complete "
170
+ f"({secondary.status.value}: {secondary.error_message or 'no details'})"
171
+ )
172
+ return
173
+
174
+ if secondary.evidence_path:
175
+ analysis.evidence_paths[secondary.scanner] = secondary.evidence_path
176
+
177
+ outcome, description = self.compare(analysis.scan, secondary)
178
+ analysis.cross_validation = outcome.value
179
+ analysis.cross_validation_detail = description
180
+ if outcome is CrossValidationOutcome.MATERIAL_DIVERGENCE:
181
+ logger.warning(f"Material scanner divergence for {reference}: {description}")
182
+ # `scan_divergence` remains the field the table, the exporters
183
+ # and the confidence model already read, and it stays reserved
184
+ # for material disagreement -- a minor one is recorded in
185
+ # `cross_validation` without disputing the score.
186
+ analysis.scan_divergence = description
187
+ elif outcome is CrossValidationOutcome.MINOR_DIVERGENCE:
188
+ logger.info(f"Minor scanner divergence for {reference}: {description}")
189
+
190
+ def compare(
191
+ self, primary: ScanResult, secondary: ScanResult
192
+ ) -> tuple[CrossValidationOutcome, str]:
193
+ """Classify two scans of the same image by *which* findings differ.
194
+
195
+ Comparing counts alone accepted a case it should not: two scanners
196
+ each reporting one CRITICAL, for two entirely different CVEs, agreed
197
+ perfectly on the arithmetic while describing different images. What
198
+ is compared here is the set of findings, so that case reads as the
199
+ divergence it is.
200
+ """
201
+ parts: list[str] = []
202
+ worst = CrossValidationOutcome.AGREEMENT
203
+
204
+ for severity in _COMPARED_SEVERITIES:
205
+ mine = {finding_identity(v) for v in primary.vulnerabilities if v.severity is severity}
206
+ theirs = {
207
+ finding_identity(v) for v in secondary.vulnerabilities if v.severity is severity
208
+ }
209
+ only_mine = mine - theirs
210
+ only_theirs = theirs - mine
211
+ if not only_mine and not only_theirs:
212
+ continue
213
+
214
+ magnitude = len(only_mine) + len(only_theirs)
215
+ baseline = max(len(mine), len(theirs), 1)
216
+ material = (
217
+ magnitude > self._abs_tolerance and (magnitude / baseline) > self._rel_tolerance
218
+ )
219
+ worst = _worse(
220
+ worst,
221
+ CrossValidationOutcome.MATERIAL_DIVERGENCE
222
+ if material
223
+ else CrossValidationOutcome.MINOR_DIVERGENCE,
224
+ )
225
+ parts.append(
226
+ f"{severity.value}: {primary.scanner} found {len(mine)} "
227
+ f"({len(only_mine)} not seen by {secondary.scanner}), "
228
+ f"{secondary.scanner} found {len(theirs)} "
229
+ f"({len(only_theirs)} not seen by {primary.scanner})"
230
+ + _examples(only_mine | only_theirs)
231
+ )
232
+
233
+ return worst, "; ".join(parts)
@@ -0,0 +1,350 @@
1
+ """Conhecimento especializado de ecossistemas, runtimes e particularidades de segurança."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass, field
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class EcosystemInsight:
11
+ ecosystem: str
12
+ version: str
13
+ runtime_features: list[str] = field(default_factory=list)
14
+ base_distro_advice: list[str] = field(default_factory=list)
15
+ security_guidelines: list[str] = field(default_factory=list)
16
+ common_pitfalls: list[str] = field(default_factory=list)
17
+ recommended_dockerfile_snippets: list[str] = field(default_factory=list)
18
+
19
+
20
+ #: Nome exato do repositório -> ecossistema. Consultado antes das palavras
21
+ #: soltas porque um nome exato não erra: `mongo` não é Go, e `maven` é Java
22
+ #: mesmo sem a palavra "java" aparecer em lugar nenhum.
23
+ _ECOSYSTEM_BY_NAME: dict[str, str] = {
24
+ "node": "node",
25
+ "nodejs": "node",
26
+ "bun": "node",
27
+ "deno": "node",
28
+ "python": "python",
29
+ "python3": "python",
30
+ "pypy": "python",
31
+ "go": "go",
32
+ "golang": "go",
33
+ "rust": "rust",
34
+ # Ferramentas de build são o ecossistema que constroem: quem roda `maven`
35
+ # está num projeto Java, e a alternativa endurecida que interessa é a de
36
+ # Java. Sem esta linha, `maven` caía em "generic" e não recebia conselho
37
+ # nenhum -- e ferramenta de build é exatamente onde um projeto de verdade
38
+ # começa o Dockerfile.
39
+ "maven": "java",
40
+ "gradle": "java",
41
+ "ant": "java",
42
+ "sbt": "java",
43
+ "tomcat": "java",
44
+ "jetty": "java",
45
+ "jdk": "java",
46
+ "jre": "java",
47
+ "openjdk": "java",
48
+ "temurin": "java",
49
+ "eclipse-temurin": "java",
50
+ "corretto": "java",
51
+ "amazoncorretto": "java",
52
+ "php": "php",
53
+ "composer": "php",
54
+ "ruby": "ruby",
55
+ "jruby": "ruby",
56
+ "dotnet": "dotnet",
57
+ "aspnet": "dotnet",
58
+ }
59
+
60
+ #: Fragmentos, para nomes compostos que nenhuma tabela cobre inteiramente
61
+ #: (`nodejs22-debian12`, `python3-debian12`). A ordem importa: o primeiro que
62
+ #: casar vence, e os mais específicos vêm antes.
63
+ _ECOSYSTEM_KEYWORDS: tuple[tuple[str, str], ...] = (
64
+ ("nodejs", "node"),
65
+ ("node", "node"),
66
+ ("python", "python"),
67
+ ("golang", "go"),
68
+ ("rust", "rust"),
69
+ ("temurin", "java"),
70
+ ("openjdk", "java"),
71
+ ("corretto", "java"),
72
+ ("maven", "java"),
73
+ ("gradle", "java"),
74
+ ("java", "java"),
75
+ ("aspnet", "dotnet"),
76
+ ("dotnet", "dotnet"),
77
+ ("php", "php"),
78
+ ("ruby", "ruby"),
79
+ )
80
+
81
+
82
+ def detect_ecosystem_and_version(image_reference: str) -> tuple[str, str, str]:
83
+ """Detecta ecossistema (node, python, go, etc.), versão e distribuição base."""
84
+ ref_lower = image_reference.lower()
85
+ # O nome do repositório, sem registry e sem tag. Casar contra a referência
86
+ # inteira lia a tag e o host: `cgr.dev/chainguard/go:latest` não era
87
+ # reconhecido como Go (o "go" estava no caminho, não na tag), enquanto
88
+ # qualquer tag contendo "go" classificava a imagem errada.
89
+ repository = ref_lower.split("@", 1)[0].split(":", 1)[0].rstrip("/")
90
+ basename = repository.rsplit("/", 1)[-1]
91
+
92
+ # 1. Ecossistema
93
+ ecosystem = _ECOSYSTEM_BY_NAME.get(basename, "")
94
+ if not ecosystem:
95
+ for keyword, named in _ECOSYSTEM_KEYWORDS:
96
+ if keyword in basename:
97
+ ecosystem = named
98
+ break
99
+ ecosystem = ecosystem or "generic"
100
+
101
+ # 2. Versão
102
+ version = ""
103
+ tag_part = ref_lower.split(":")[-1] if ":" in ref_lower else ref_lower
104
+ v_match = re.search(r"(\d+(?:\.\d+)*)", tag_part)
105
+ if v_match:
106
+ version = v_match.group(1)
107
+
108
+ # 3. Distro base
109
+ distro = "debian/ubuntu"
110
+ if "alpine" in ref_lower:
111
+ distro = "alpine"
112
+ elif "distroless" in ref_lower:
113
+ distro = "distroless"
114
+ elif "wolfi" in ref_lower or "chainguard" in ref_lower:
115
+ distro = "wolfi/chainguard"
116
+ elif "scratch" in ref_lower:
117
+ distro = "scratch"
118
+ elif "slim" in ref_lower:
119
+ distro = "debian-slim"
120
+
121
+ return ecosystem, version, distro
122
+
123
+
124
+ def get_ecosystem_insights(image_reference: str) -> EcosystemInsight:
125
+ """Gera insights técnicos e de segurança detalhados para a imagem e versão."""
126
+ ecosystem, version, distro = detect_ecosystem_and_version(image_reference)
127
+
128
+ if ecosystem == "node":
129
+ major = version.split(".")[0] if version else "22"
130
+ runtime_features = [
131
+ f"Node.js {major}.x V8 engine with optimised ECMAScript Modules (ESM) support.",
132
+ "Native environment-file support (--env-file=.env), with no need for dotenv.",
133
+ "Native WebSocket client and native fetch API (Undici).",
134
+ "Native Corepack support for managing yarn/pnpm.",
135
+ ]
136
+ base_advice = []
137
+ if distro == "alpine":
138
+ base_advice.extend(
139
+ [
140
+ "Alpine uses musl libc: native C++ packages (sharp, bcrypt, sqlite3) "
141
+ "must be compiled, or need 'libc6-compat'.",
142
+ "For full compatibility without the build overhead, consider "
143
+ "'node:22-bookworm-slim' (glibc).",
144
+ "Official 'node:alpine' images already ship the non-root user "
145
+ "'node' (UID 1000, GID 1000).",
146
+ ]
147
+ )
148
+ else:
149
+ base_advice.extend(
150
+ [
151
+ "Debian Slim is fully compatible with pre-built glibc binaries.",
152
+ "Consider 'distroless/nodejs22-debian12' to drop the shell.",
153
+ ]
154
+ )
155
+
156
+ security = [
157
+ "Set 'ENV NODE_ENV=production' to enable runtime optimisations and "
158
+ "disable devDependencies.",
159
+ "The bundled npm CLI has its own CVE cycle: run "
160
+ "'RUN npm install -g npm@latest', or drop it in a multi-stage build.",
161
+ "Tune '--max-old-space-size' so Node stays within the container memory limit.",
162
+ "Use 'USER node' (or create UID 10001) so the process never runs as root.",
163
+ ]
164
+ pitfalls = [
165
+ "Avoid running 'npm start' as PID 1 (npm does not forward SIGTERM); "
166
+ 'use \'CMD ["node", "dist/index.js"]\'.',
167
+ "Do not leave 'node_modules' in the build context root without a .dockerignore.",
168
+ ]
169
+ snippets = [
170
+ 'ENV NODE_ENV=production\nUSER node\nCMD ["--enable-source-maps", "dist/index.js"]',
171
+ 'HEALTHCHECK --interval=30s --timeout=5s CMD node -e "'
172
+ "require('http').get('http://localhost:3000/health', (r) => {"
173
+ "if (r.statusCode !== 200) process.exit(1)"
174
+ "}).on('error', () => process.exit(1))\"",
175
+ ]
176
+ return EcosystemInsight(
177
+ ecosystem="Node.js",
178
+ version=version or "22.x",
179
+ runtime_features=runtime_features,
180
+ base_distro_advice=base_advice,
181
+ security_guidelines=security,
182
+ common_pitfalls=pitfalls,
183
+ recommended_dockerfile_snippets=snippets,
184
+ )
185
+
186
+ elif ecosystem == "python":
187
+ runtime_features = [
188
+ "Python runtime with dependency isolation through a multi-stage builder.",
189
+ "Supports Python 3.11/3.12/3.13, with execution-speed improvements.",
190
+ ]
191
+ base_advice = []
192
+ if distro == "alpine":
193
+ base_advice.extend(
194
+ [
195
+ "Alpine musl does not support manylinux wheels. Libraries such as "
196
+ "pandas, numpy and cryptography compile from source (slow, needs gcc).",
197
+ "For Python with C/C++ dependencies, 'python:3.12-slim-bookworm' "
198
+ "builds far faster and produces smaller images.",
199
+ ]
200
+ )
201
+ else:
202
+ base_advice.extend(
203
+ [
204
+ "Debian Slim supports every pre-built manylinux wheel on PyPI, "
205
+ "so the final container needs no compilers.",
206
+ ]
207
+ )
208
+
209
+ security = [
210
+ "Set 'ENV PYTHONUNBUFFERED=1' for unbuffered, real-time logs.",
211
+ "Set 'ENV PYTHONDONTWRITEBYTECODE=1' so no .pyc files are written.",
212
+ "Install dependencies with "
213
+ "'pip install --no-cache-dir --user -r requirements.txt' in the builder, "
214
+ "then copy '/root/.local' across for the non-root user.",
215
+ "Create an 'appuser' (UID 10001) to run the process.",
216
+ ]
217
+ pitfalls = [
218
+ "Do not write healthchecks that depend on the external 'requests' "
219
+ "package; use 'urllib.request.urlopen' from the standard library.",
220
+ ]
221
+ snippets = [
222
+ (
223
+ "ENV PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1\n"
224
+ 'USER appuser\nCMD ["python", "-u", "main.py"]'
225
+ ),
226
+ (
227
+ 'HEALTHCHECK --interval=30s --timeout=5s CMD python -c "'
228
+ "import urllib.request; "
229
+ "urllib.request.urlopen('http://localhost:8000/health', timeout=3)\" || exit 1"
230
+ ),
231
+ ]
232
+ return EcosystemInsight(
233
+ ecosystem="Python",
234
+ version=version or "3.12.x",
235
+ runtime_features=runtime_features,
236
+ base_distro_advice=base_advice,
237
+ security_guidelines=security,
238
+ common_pitfalls=pitfalls,
239
+ recommended_dockerfile_snippets=snippets,
240
+ )
241
+
242
+ elif ecosystem == "go":
243
+ return EcosystemInsight(
244
+ ecosystem="Go",
245
+ version=version or "1.23.x",
246
+ runtime_features=[
247
+ "Statically linked native binaries, with no interpreter or runtime dependency.",
248
+ ],
249
+ base_distro_advice=[
250
+ "'scratch' or distroless images give the smallest surface (zero CVEs).",
251
+ "Copy '/etc/ssl/certs/ca-certificates.crt' from the builder for HTTPS calls.",
252
+ ],
253
+ security_guidelines=[
254
+ "Build with 'CGO_ENABLED=0 GOOS=linux go build -ldflags=\"-s -w\" -o app .'.",
255
+ "On 'scratch', use 'USER 65534:65534' (nobody): there is no /etc/passwd.",
256
+ ],
257
+ common_pitfalls=[
258
+ "Do not use shell-form healthchecks on scratch; "
259
+ 'use exec form: CMD ["/app", "-health"].',
260
+ ],
261
+ recommended_dockerfile_snippets=[
262
+ "FROM scratch\n"
263
+ "COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/\n"
264
+ 'COPY --from=builder /app/app /app\nUSER 65534:65534\nENTRYPOINT ["/app"]',
265
+ ],
266
+ )
267
+
268
+ elif ecosystem == "java":
269
+ return EcosystemInsight(
270
+ ecosystem="Java / JVM",
271
+ version=version or "21 LTS",
272
+ runtime_features=[
273
+ "Eclipse Temurin / Amazon Corretto JRE with container awareness.",
274
+ ],
275
+ base_distro_advice=[
276
+ "Use 'eclipse-temurin:21-jre-alpine' rather than the full JDK: it "
277
+ "drops over 300MB of tooling the runtime does not need.",
278
+ ],
279
+ security_guidelines=[
280
+ "Set 'JAVA_OPTS=\"-XX:+UseContainerSupport -XX:MaxRAMPercentage=75.0 "
281
+ "-Djava.security.egd=file:/dev/./urandom\"'.",
282
+ "Run as the non-root user 'appuser' (UID 10001).",
283
+ ],
284
+ common_pitfalls=[
285
+ "Avoid pinning heap size (-Xmx) without accounting for the container memory limit.",
286
+ ],
287
+ recommended_dockerfile_snippets=[
288
+ 'ENV JAVA_OPTS="-XX:+UseContainerSupport -XX:MaxRAMPercentage=75.0"\n'
289
+ "USER appuser\n"
290
+ 'ENTRYPOINT ["sh", "-c", "exec java $JAVA_OPTS -jar /app/app.jar"]',
291
+ ],
292
+ )
293
+
294
+ elif ecosystem == "rust":
295
+ return EcosystemInsight(
296
+ ecosystem="Rust",
297
+ version=version or "1.82",
298
+ runtime_features=[
299
+ "Statically linked native binaries with musl and target-feature=+crt-static.",
300
+ ],
301
+ base_distro_advice=[
302
+ "'scratch' or distroless images reduce CVEs to zero.",
303
+ ],
304
+ security_guidelines=[
305
+ "Use 'cargo build --release --target x86_64-unknown-linux-musl'.",
306
+ "Run as non-root: 'USER 65534:65534'.",
307
+ ],
308
+ common_pitfalls=[],
309
+ recommended_dockerfile_snippets=[
310
+ (
311
+ "FROM scratch\n"
312
+ "COPY --from=builder /app/binary /app\n"
313
+ 'USER 65534:65534\nENTRYPOINT ["/app"]'
314
+ ),
315
+ ],
316
+ )
317
+
318
+ elif ecosystem == "php":
319
+ return EcosystemInsight(
320
+ ecosystem="PHP",
321
+ version=version or "8.3",
322
+ runtime_features=[
323
+ "PHP 8.3 with JIT and Opcache enabled.",
324
+ ],
325
+ base_distro_advice=[
326
+ "Use a multi-stage build with 'composer:2' in the builder, copying "
327
+ "only '/app/vendor'.",
328
+ ],
329
+ security_guidelines=[
330
+ "Enable Opcache for performance, and run as non-root (UID 10001).",
331
+ ],
332
+ common_pitfalls=[],
333
+ recommended_dockerfile_snippets=[
334
+ 'USER appuser\nCMD ["php", "-S", "0.0.0.0:8000", "-t", "public"]',
335
+ ],
336
+ )
337
+
338
+ return EcosystemInsight(
339
+ ecosystem="Generic Container",
340
+ version=version or "latest",
341
+ runtime_features=["Standard Linux container."],
342
+ base_distro_advice=["Prefer minimal distributions such as Alpine or Distroless."],
343
+ security_guidelines=[
344
+ "Never run as root (USER 10001).",
345
+ "Keep a tidy .dockerignore.",
346
+ "Use healthchecks for liveness/readiness monitoring.",
347
+ ],
348
+ common_pitfalls=[],
349
+ recommended_dockerfile_snippets=[],
350
+ )
@@ -0,0 +1,97 @@
1
+ """Um scanner que tenta o secundário quando o primário falha por culpa própria.
2
+
3
+ O fallback para o Grype existia só na *escolha* do scanner: `ScannerFactory`
4
+ olhava `is_available()` -- que é `shutil.which(...)` -- e, se o binário do
5
+ Trivy estivesse no PATH, o Grype nunca mais entrava na conversa. Um Trivy
6
+ instalado porém quebrado (DB corrompida, sem rede para baixá-la, timeout) não
7
+ acionava fallback nenhum: as tags simplesmente eram marcadas como não
8
+ verificadas, uma por uma.
9
+
10
+ Aqui o fallback passa a ser por *scan*, e não por processo. A condição de
11
+ acionamento é o `ScanErrorKind`: erro de DB, timeout, saída inválida e
12
+ rate limit são falhas do scanner, e o outro tem chance real de responder.
13
+ `NOT_FOUND` e `AUTH_REQUIRED` são fatos sobre a imagem -- perguntar de novo,
14
+ a outra ferramenta, só dobra a espera pela mesma resposta.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from typing import TYPE_CHECKING
20
+
21
+ from loguru import logger
22
+
23
+ from dockerls.domain.interfaces.scanner import ScannerInterface
24
+
25
+ if TYPE_CHECKING:
26
+ from dockerls.domain.entities.scan_result import ScanResult
27
+
28
+
29
+ class FallbackScanner(ScannerInterface):
30
+ """Delegates to `primary`, retrying with `secondary` on scanner-side faults."""
31
+
32
+ def __init__(self, primary: ScannerInterface, secondary: ScannerInterface):
33
+ self._primary = primary
34
+ self._secondary = secondary
35
+ # Contabilidade para o resumo da execução: quantas vezes o secundário
36
+ # salvou um alvo que o primário não conseguiu medir.
37
+ self.fallback_successes = 0
38
+ self.fallback_attempts = 0
39
+
40
+ @property
41
+ def primary(self) -> ScannerInterface:
42
+ return self._primary
43
+
44
+ @property
45
+ def secondary(self) -> ScannerInterface:
46
+ return self._secondary
47
+
48
+ async def is_available(self) -> bool:
49
+ return await self._primary.is_available() or await self._secondary.is_available()
50
+
51
+ async def scan(self, image_reference: str) -> ScanResult:
52
+ result = await self._primary.scan(image_reference)
53
+ if result.is_verified or not result.error_kind.is_scanner_fault:
54
+ return result
55
+
56
+ self.fallback_attempts += 1
57
+ logger.warning(
58
+ f"{result.scanner} failed on {image_reference} "
59
+ f"({result.error_kind.value}); retrying with the secondary scanner"
60
+ )
61
+ if not await self._secondary.is_available():
62
+ logger.info("No secondary scanner available; keeping the primary result")
63
+ return result
64
+
65
+ fallback = await self._secondary.scan(image_reference)
66
+ if not fallback.is_verified:
67
+ # Nenhum dos dois conseguiu: devolve o resultado do primário, que
68
+ # é o que descreve a falha da ferramenta que deveria ter medido.
69
+ logger.warning(
70
+ f"Secondary scanner also failed on {image_reference} ({fallback.error_kind.value})"
71
+ )
72
+ return result
73
+
74
+ self.fallback_successes += 1
75
+ logger.info(f"{fallback.scanner} recovered {image_reference} after {result.scanner} failed")
76
+ return fallback
77
+
78
+ async def refresh_db(self) -> bool:
79
+ """Prepara os dois bancos. O secundário só é útil se estiver pronto
80
+ antes de a primeira falha acontecer."""
81
+ primary_ok = await _refresh(self._primary)
82
+ await _refresh(self._secondary)
83
+ return primary_ok
84
+
85
+ async def close(self) -> None:
86
+ for scanner in (self._primary, self._secondary):
87
+ close = getattr(scanner, "close", None)
88
+ if callable(close):
89
+ await close()
90
+
91
+
92
+ async def _refresh(scanner: ScannerInterface) -> bool:
93
+ refresh = getattr(scanner, "refresh_db", None)
94
+ if not callable(refresh):
95
+ return True
96
+ ok: bool = await refresh()
97
+ return ok