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,245 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import math
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ from dockerls import __version__
8
+ from dockerls.domain.entities.vulnerability import Severity
9
+ from dockerls.exporters.base import ExporterInterface
10
+
11
+ if TYPE_CHECKING:
12
+ from pathlib import Path
13
+
14
+ from dockerls.application.dto.analysis import AnalysisResult, ImageAnalysis
15
+
16
+ _SEVERITY_TO_LEVEL = {
17
+ Severity.CRITICAL: "error",
18
+ Severity.HIGH: "error",
19
+ Severity.MEDIUM: "warning",
20
+ Severity.LOW: "note",
21
+ Severity.UNKNOWN: "note",
22
+ }
23
+
24
+ # GitHub code scanning reads `security-severity` as a number and buckets it:
25
+ # >= 9.0 critical, >= 7.0 high, >= 4.0 medium, > 0.0 low. These are the floor
26
+ # of each bucket, used only when the scanner classified a finding without
27
+ # publishing a CVSS score -- which is the *normal* case for Debian, Alpine and
28
+ # Ubuntu advisories, as `TrivyScanner._extract_cvss` documents.
29
+ #
30
+ # Emitting the literal 0.0 for those, as this exporter used to, filed every
31
+ # unscored CRITICAL in the security dashboard at the bottom of the scale. The
32
+ # severity the scanner actually assigned was thrown away on the way out.
33
+ #
34
+ # This is a translation of a category into GitHub's numeric channel, not an
35
+ # invented measurement, so the rule records which of the two it was in
36
+ # `properties.severity-source`.
37
+ _SEVERITY_FLOOR = {
38
+ Severity.CRITICAL: 9.0,
39
+ Severity.HIGH: 7.0,
40
+ Severity.MEDIUM: 4.0,
41
+ Severity.LOW: 1.0,
42
+ Severity.UNKNOWN: 0.0,
43
+ }
44
+
45
+ # SARIF requires `reportingDescriptor.id` to be a non-empty string, and a
46
+ # scanner can report a finding with no advisory ID at all (Trivy leaves
47
+ # `VulnerabilityID` empty). Grouping those by package keeps the document
48
+ # valid and keeps unrelated unnamed findings from collapsing into one rule.
49
+ _UNIDENTIFIED_PREFIX = "DOCKERLS-UNIDENTIFIED"
50
+
51
+ # The URL published in `$schema`. The document points a consumer at the
52
+ # schema it claims to satisfy, so the URL has to resolve: the previous one
53
+ # (`.../sarif-spec/master/Schemata/...`) 404s -- the repository's default
54
+ # branch was renamed and the schema moved under `sarif-2.1/schema/`.
55
+ _SCHEMA_URL = (
56
+ "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/main/"
57
+ "sarif-2.1/schema/sarif-schema-2.1.0.json"
58
+ )
59
+
60
+ # `artifactLocation.uri` is required to be a URI reference, and a SARIF
61
+ # consumer keys findings by it. An analysis carrying no usable reference
62
+ # would otherwise render as `":"` -- `f"{name}:{tag}"` over two empty
63
+ # strings -- which is neither a URI reference nor an identifier.
64
+ _UNKNOWN_ARTIFACT = "unknown-image"
65
+
66
+ # The upper bound of the CVSS v3 base-score range. Anything outside
67
+ # `0 < score <= 10` did not come from a CVSS calculator.
68
+ _CVSS_MAX = 10.0
69
+
70
+
71
+ class SARIFExporter(ExporterInterface):
72
+ """Exports scan findings as SARIF 2.1.0 for consumption by GitHub code
73
+ scanning and other SARIF-aware tooling."""
74
+
75
+ def export(self, result: AnalysisResult, output_path: Path) -> None:
76
+ output_path.write_bytes(self.export_string(result).encode("utf-8"))
77
+
78
+ def export_string(self, result: AnalysisResult) -> str:
79
+ images: list[ImageAnalysis] = [*result.recommendations, *result.alternatives]
80
+ rules: dict[str, dict[str, Any]] = {}
81
+ sarif_results: list[dict[str, Any]] = []
82
+
83
+ for analysis in images:
84
+ for vuln in analysis.scan.vulnerabilities:
85
+ rule_id = _rule_id(vuln)
86
+ if rule_id not in rules:
87
+ rules[rule_id] = _rule_for(vuln, rule_id)
88
+ sarif_results.append(
89
+ {
90
+ "ruleId": rule_id,
91
+ "level": _SEVERITY_TO_LEVEL.get(vuln.severity, "warning"),
92
+ "message": {
93
+ "text": (
94
+ f"{vuln.severity.value} vulnerability in "
95
+ f"{vuln.package_name} {vuln.installed_version}"
96
+ + (f" (fix: {vuln.fixed_version})" if vuln.fixed_version else "")
97
+ )
98
+ },
99
+ "locations": [
100
+ {
101
+ "physicalLocation": {
102
+ "artifactLocation": {"uri": _artifact_uri(analysis)}
103
+ }
104
+ }
105
+ ],
106
+ # Per-result rather than per-run: a SARIF file can
107
+ # carry findings from several images, and a consumer
108
+ # gating on confidence needs to know which image an
109
+ # UNVERIFIED verdict belongs to. `properties` is the
110
+ # spec's extension point, so nothing existing moves.
111
+ "properties": _image_properties(analysis),
112
+ }
113
+ )
114
+
115
+ sarif = {
116
+ "version": "2.1.0",
117
+ "$schema": _SCHEMA_URL,
118
+ "runs": [
119
+ {
120
+ "tool": {
121
+ "driver": {
122
+ "name": "DockerLs",
123
+ "informationUri": "https://github.com/Ivomsantiago/DockerLs",
124
+ "version": __version__,
125
+ "rules": list(rules.values()),
126
+ }
127
+ },
128
+ "results": sarif_results,
129
+ }
130
+ ],
131
+ }
132
+ # `allow_nan=False` is the assertion, `_json_safe` is what keeps it
133
+ # from firing. Python's default is to emit the bare tokens `NaN` and
134
+ # `Infinity`, which RFC 8259 does not allow: a single non-finite
135
+ # score anywhere in the document makes the *whole file* unparseable
136
+ # to a strict reader, and GitHub code scanning's ingester is one.
137
+ # Every finding in the upload is then discarded together -- the
138
+ # failure mode this project exists to refuse, a security report that
139
+ # silently reports nothing.
140
+ return json.dumps(_json_safe(sarif), indent=2, allow_nan=False, default=str)
141
+
142
+
143
+ def _json_safe(value: Any) -> Any:
144
+ """Replace non-finite floats with `None`, recursively.
145
+
146
+ A `NaN` score is not a measurement, so it is not published as one: the
147
+ property is emitted as JSON `null`, which a consumer reads as "no value"
148
+ rather than as a number that happens to compare falsely against every
149
+ threshold.
150
+ """
151
+ if isinstance(value, float):
152
+ return value if math.isfinite(value) else None
153
+ if isinstance(value, dict):
154
+ return {key: _json_safe(item) for key, item in value.items()}
155
+ if isinstance(value, list | tuple):
156
+ return [_json_safe(item) for item in value]
157
+ return value
158
+
159
+
160
+ def _artifact_uri(analysis: ImageAnalysis) -> str:
161
+ """A non-empty URI reference naming the scanned image."""
162
+ for candidate in (analysis.image.full_reference, analysis.scan.image_reference):
163
+ text = (candidate or "").strip().strip(":")
164
+ if text:
165
+ return text
166
+ return _UNKNOWN_ARTIFACT
167
+
168
+
169
+ def _image_properties(analysis: ImageAnalysis) -> dict[str, Any]:
170
+ """Image-level context attached to every finding from that image.
171
+
172
+ The digest is included whenever one was resolved: a SARIF file that
173
+ names only a tag cannot be matched back to the bytes that were scanned.
174
+ """
175
+ properties: dict[str, Any] = {
176
+ "image": analysis.image.full_reference,
177
+ "source": analysis.image.source,
178
+ "securityScore": analysis.security_score,
179
+ "tier": analysis.tier,
180
+ "confidence": analysis.confidence.value,
181
+ "productionReady": analysis.production_ready,
182
+ "eolStatus": analysis.eol_status.value,
183
+ "crossValidation": analysis.cross_validation,
184
+ }
185
+ if analysis.readiness_blockers:
186
+ properties["readinessBlockers"] = list(analysis.readiness_blockers)
187
+ if analysis.image.digest_known:
188
+ properties["digest"] = analysis.image.digest
189
+ properties["pinnedReference"] = analysis.pinned_reference
190
+ if analysis.hardening.reportable:
191
+ properties["hardeningScore"] = analysis.hardening.score
192
+ properties["hardeningCoverage"] = analysis.hardening.coverage
193
+ if analysis.attack_surface.reportable:
194
+ properties["attackSurfaceScore"] = analysis.attack_surface.score
195
+ return properties
196
+
197
+
198
+ def _rule_id(vuln: Any) -> str:
199
+ """A stable, non-empty rule identifier for a finding."""
200
+ cve_id = (vuln.cve_id or "").strip()
201
+ if cve_id:
202
+ return cve_id
203
+ package = (vuln.package_name or "").strip() or "unknown-package"
204
+ return f"{_UNIDENTIFIED_PREFIX}-{package}"
205
+
206
+
207
+ def _security_severity(vuln: Any) -> tuple[str, str]:
208
+ """Return (value, source) for GitHub's `security-severity` property.
209
+
210
+ `cvss` means the number is the scanner's measured CVSS base score.
211
+ `severity-band` means the advisory carried no score, so the floor of the
212
+ bucket matching the severity the scanner assigned is used instead --
213
+ otherwise an unscored CRITICAL is published to code scanning as 0.0.
214
+ """
215
+ try:
216
+ score = float(vuln.cvss_score)
217
+ except (TypeError, ValueError):
218
+ score = 0.0
219
+ # GitHub parses `security-severity` as a number. A score that is not a
220
+ # finite value inside the CVSS range never came from a calculator, and
221
+ # publishing it verbatim put the strings "inf" and "nan" in that channel
222
+ # -- the same "unusable value spent as evidence" that `_probability`
223
+ # refuses on the way in. The severity the scanner assigned is the better
224
+ # answer, and `severity-source` says that is what this is.
225
+ if math.isfinite(score) and 0.0 < score <= _CVSS_MAX:
226
+ return str(score), "cvss"
227
+ return str(_SEVERITY_FLOOR.get(vuln.severity, 0.0)), "severity-band"
228
+
229
+
230
+ def _rule_for(vuln: Any, rule_id: str) -> dict[str, Any]:
231
+ severity_value, severity_source = _security_severity(vuln)
232
+ rule: dict[str, Any] = {
233
+ "id": rule_id,
234
+ "shortDescription": {"text": vuln.description or rule_id},
235
+ "properties": {
236
+ "security-severity": severity_value,
237
+ "severity-source": severity_source,
238
+ "tags": ["security", vuln.severity.value],
239
+ },
240
+ }
241
+ # Only link to NVD for a real advisory ID -- the bare detail URL for an
242
+ # empty ID is a 404 pointing nowhere.
243
+ if (vuln.cve_id or "").strip():
244
+ rule["helpUri"] = f"https://nvd.nist.gov/vuln/detail/{vuln.cve_id.strip()}"
245
+ return rule
File without changes
File without changes
@@ -0,0 +1,165 @@
1
+ """Ler `.dockerls-policy.yaml` -- e recusar o que não se entende.
2
+
3
+ Aqui mora a única diferença de comportamento importante entre este arquivo e
4
+ o `.dockerls-ignore.yaml`: **um arquivo de política malformado é erro, não
5
+ ausência de política**.
6
+
7
+ O motivo é a direção da falha. Uma regra de ignore que não carrega deixa de
8
+ esconder uma CVE -- o resultado é mais alarme, e alarme a mais é seguro. Uma
9
+ regra de política que não carrega deixa de exigir alguma coisa, e o build
10
+ passa parecendo ter sido conferido. Uma chave digitada errado (`require_non_root`
11
+ em vez de `require_nonroot`) viraria um portão aberto com cara de fechado, e
12
+ ninguém descobre isso olhando a saída verde.
13
+
14
+ Por isso: chave desconhecida é erro, tipo errado é erro, YAML quebrado é erro,
15
+ severidade inexistente é erro. Só a ausência do arquivo é silêncio -- e é
16
+ silêncio explícito, porque aí ninguém declarou nada.
17
+
18
+ O YAML passa pelo `safe_load_yaml`, que já recusa documentos grandes demais,
19
+ profundos demais ou expandidos demais antes de construir qualquer coisa: um
20
+ arquivo de política pode perfeitamente vir de um repositório que não é seu.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from typing import TYPE_CHECKING, Any
26
+
27
+ from dockerls.domain.value_objects.build_policy import (
28
+ SEVERITY_ORDER,
29
+ BuildPolicy,
30
+ )
31
+ from dockerls.domain.value_objects.gate import GateSet, InvalidGateError
32
+ from dockerls.utils.safe_yaml import UnsafeYAMLError, safe_load_yaml
33
+
34
+ if TYPE_CHECKING:
35
+ from pathlib import Path
36
+
37
+ DEFAULT_POLICY_FILENAME = ".dockerls-policy.yaml"
38
+
39
+ #: As chaves aceitas. Qualquer outra é erro -- ver o docstring do módulo.
40
+ _KNOWN_KEYS = frozenset(
41
+ {
42
+ "fail_on",
43
+ "max_vulnerabilities",
44
+ "require_scan",
45
+ "require_pinned_bases",
46
+ "require_nonroot",
47
+ "required_labels",
48
+ "allowed_base_registries",
49
+ "require_provenance",
50
+ }
51
+ )
52
+
53
+
54
+ class PolicyFileError(ValueError):
55
+ """O arquivo existe e não pôde ser entendido. Nunca vira política vazia."""
56
+
57
+
58
+ def find_policy_file(context: Path) -> Path | None:
59
+ """O arquivo de política do contexto de build, se houver um."""
60
+ candidate = context / DEFAULT_POLICY_FILENAME
61
+ return candidate if candidate.is_file() else None
62
+
63
+
64
+ def load_policy(path: Path) -> BuildPolicy:
65
+ """Carrega a política declarada, ou levanta explicando o que está errado."""
66
+ try:
67
+ raw = path.read_text(encoding="utf-8")
68
+ except OSError as e:
69
+ raise PolicyFileError(f"could not read {path}: {e}") from e
70
+
71
+ try:
72
+ data = safe_load_yaml(raw, origin=str(path))
73
+ except UnsafeYAMLError as e:
74
+ raise PolicyFileError(f"{path}: {e}") from e
75
+
76
+ if data is None:
77
+ raise PolicyFileError(
78
+ f"{path} is empty. An empty policy file is almost always a mistake; "
79
+ "remove it if the intent is to have no policy."
80
+ )
81
+ if not isinstance(data, dict):
82
+ raise PolicyFileError(f"{path}: the document must be a map of rules")
83
+
84
+ desconhecidas = sorted(set(data) - _KNOWN_KEYS)
85
+ if desconhecidas:
86
+ raise PolicyFileError(
87
+ f"{path}: unknown rule(s): {', '.join(desconhecidas)}. "
88
+ f"Accepted rules are: {', '.join(sorted(_KNOWN_KEYS))}. "
89
+ "A mistyped key would be an open gate that looks closed."
90
+ )
91
+
92
+ policy = BuildPolicy(
93
+ fail_on=_severity(data, "fail_on", path),
94
+ max_vulnerabilities=_ceilings(data, path),
95
+ require_scan=_flag(data, "require_scan", path),
96
+ require_pinned_bases=_flag(data, "require_pinned_bases", path),
97
+ require_nonroot=_flag(data, "require_nonroot", path),
98
+ required_labels=_strings(data, "required_labels", path),
99
+ allowed_base_registries=_strings(data, "allowed_base_registries", path),
100
+ require_provenance=_flag(data, "require_provenance", path),
101
+ )
102
+ if policy.is_empty:
103
+ raise PolicyFileError(
104
+ f"{path}: no rule was declared. A file that is present but demands "
105
+ "nothing is indistinguishable from a gate that is switched off."
106
+ )
107
+ return policy
108
+
109
+
110
+ def _flag(data: dict[str, Any], key: str, path: Path) -> bool:
111
+ value = data.get(key, False)
112
+ if not isinstance(value, bool):
113
+ raise PolicyFileError(f"{path}: {key} must be true or false, not {value!r}")
114
+ return value
115
+
116
+
117
+ def _severity(data: dict[str, Any], key: str, path: Path) -> str:
118
+ value = data.get(key, "")
119
+ if not value:
120
+ return ""
121
+ # Recusar aqui é o que evita um build que morre com erro técnico no meio
122
+ # do caminho por causa de uma linha de YAML. `unknown` é severidade
123
+ # válida numa contagem e não é portão válido; `kev` e `epss>=N` são, e
124
+ # quem decide isso é `GateSet.parse`, para que o arquivo e a linha de
125
+ # comando não possam discordar sobre o que existe.
126
+ if not isinstance(value, str):
127
+ raise PolicyFileError(f"{path}: {key} must be a string, not {value!r}")
128
+ try:
129
+ GateSet.parse(value)
130
+ except InvalidGateError as e:
131
+ raise PolicyFileError(f"{path}: {key} is not a valid gate -- {e}") from e
132
+ return value.strip().lower()
133
+
134
+
135
+ def _ceilings(data: dict[str, Any], path: Path) -> dict[str, int]:
136
+ value = data.get("max_vulnerabilities", {})
137
+ if not value:
138
+ return {}
139
+ if not isinstance(value, dict):
140
+ raise PolicyFileError(f"{path}: max_vulnerabilities must be a map of severity to number")
141
+ ceilings: dict[str, int] = {}
142
+ for severity, limit in value.items():
143
+ chave = str(severity).strip().lower()
144
+ if chave not in SEVERITY_ORDER:
145
+ raise PolicyFileError(f"{path}: unknown severity in max_vulnerabilities: {severity!r}")
146
+ # `bool` é subclasse de `int` em Python: `high: true` passaria como
147
+ # teto de 1, que não é o que ninguém quis dizer.
148
+ if not isinstance(limit, int) or isinstance(limit, bool) or limit < 0:
149
+ raise PolicyFileError(
150
+ f"{path}: the {chave} ceiling must be an integer >= 0, not {limit!r}"
151
+ )
152
+ ceilings[chave] = limit
153
+ return ceilings
154
+
155
+
156
+ def _strings(data: dict[str, Any], key: str, path: Path) -> tuple[str, ...]:
157
+ value = data.get(key, [])
158
+ if not value:
159
+ return ()
160
+ if not isinstance(value, list) or not all(isinstance(v, str) for v in value):
161
+ raise PolicyFileError(f"{path}: {key} must be a list of strings")
162
+ itens = tuple(v.strip() for v in value if v.strip())
163
+ if not itens:
164
+ raise PolicyFileError(f"{path}: {key} was declared with no usable value")
165
+ return itens
@@ -0,0 +1,197 @@
1
+ from __future__ import annotations
2
+
3
+ import contextlib
4
+ import os
5
+ from pathlib import Path
6
+
7
+ from pydantic import Field
8
+ from pydantic_settings import (
9
+ BaseSettings,
10
+ PydanticBaseSettingsSource,
11
+ SettingsConfigDict,
12
+ TomlConfigSettingsSource,
13
+ )
14
+
15
+ from dockerls.domain.value_objects.scan_plan import DEFAULT_SCAN_BUDGET
16
+
17
+
18
+ def _default_cache_dir() -> Path:
19
+ xdg = os.environ.get("XDG_CACHE_HOME")
20
+ if xdg:
21
+ return Path(xdg) / "dockerls"
22
+ return Path.home() / ".cache" / "dockerls"
23
+
24
+
25
+ def _default_state_dir() -> Path:
26
+ xdg = os.environ.get("XDG_STATE_HOME")
27
+ if xdg:
28
+ return Path(xdg) / "dockerls"
29
+ return Path.home() / ".local" / "state" / "dockerls"
30
+
31
+
32
+ def _default_log_dir() -> Path:
33
+ return _default_state_dir() / "logs"
34
+
35
+
36
+ def _default_evidence_dir() -> Path:
37
+ return _default_state_dir() / "scans"
38
+
39
+
40
+ def _default_config_path() -> Path:
41
+ """~/.config/dockerls/config.toml (or $XDG_CONFIG_HOME/dockerls/config.toml)."""
42
+ xdg_config = os.environ.get("XDG_CONFIG_HOME")
43
+ base = Path(xdg_config) if xdg_config else Path.home() / ".config"
44
+ return base / "dockerls" / "config.toml"
45
+
46
+
47
+ class Settings(BaseSettings):
48
+ """Configuration resolved, highest priority first, from: constructor
49
+ kwargs -> environment variables -> ~/.config/dockerls/config.toml ->
50
+ field defaults. DOCKERHUB_USERNAME and DOCKERHUB_TOKEN keep
51
+ their historical unprefixed env var names for backward compatibility;
52
+ every other setting is DOCKERLS_<FIELD_NAME>.
53
+ """
54
+
55
+ model_config = SettingsConfigDict(
56
+ env_prefix="DOCKERLS_",
57
+ toml_file=_default_config_path(),
58
+ extra="ignore",
59
+ )
60
+
61
+ cache_dir: Path = Field(default_factory=_default_cache_dir)
62
+ cache_ttl_seconds: int = 86400
63
+ # Tag existence is cached separately and more briefly: a tag
64
+ # disappearing matters sooner than a score going slightly stale.
65
+ tag_cache_ttl_seconds: int = 6 * 3600
66
+ max_tags: int = 100
67
+ # Quantas das tags descobertas este run realmente mede. `max_tags`
68
+ # governa a *descoberta*; isto governa a *medição*, e são coisas
69
+ # diferentes: descobrir 100 tags custa uma chamada HTTP, medir as 100
70
+ # custa dois a quatro minutos de Trivy para exibir cinco.
71
+ #
72
+ # O corte não esconde nada. As tags não medidas voltam no resultado
73
+ # (`deferred`) com o motivo -- quase sempre "existe uma tag mais nova
74
+ # da mesma linha" --, porque uma tag não medida não é uma tag pior.
75
+ # `0` mede todas, que é o comportamento anterior.
76
+ scan_budget: int = DEFAULT_SCAN_BUDGET
77
+ # 0 means "derive from this machine": each worker holds a scanner
78
+ # process that wants a core and hundreds of megabytes, so a flat number
79
+ # oversubscribes small runners and underuses large ones. Any explicit
80
+ # value is honoured as given -- the operator knows their machine.
81
+ workers: int = 0
82
+ max_critical: int = 0
83
+ max_high: int = 0
84
+ max_medium: int = 5
85
+ dockerhub_username: str = Field(default="", validation_alias="DOCKERHUB_USERNAME")
86
+ dockerhub_token: str = Field(default="", validation_alias="DOCKERHUB_TOKEN")
87
+ log_level: str = "INFO"
88
+ # Diagnostics go here, never to the terminal (see setup_logging).
89
+ log_dir: Path = Field(default_factory=_default_log_dir)
90
+ # Raw scanner JSON, kept so every displayed score is auditable.
91
+ evidence_dir: Path = Field(default_factory=_default_evidence_dir)
92
+ # Trivy's own cache root; the per-worker cache pool is built next to it.
93
+ trivy_cache_dir: Path | None = None
94
+ # Re-scan the top candidates with the secondary scanner and flag
95
+ # material disagreements instead of showing an undisputed score.
96
+ cross_validate: bool = True
97
+ # Confirm each recommended tag really exists on Docker Hub.
98
+ verify_hub_tags: bool = True
99
+ # Concurrent secondary scans during cross-validation. 0 means "derive
100
+ # from this machine", like `workers`: these are scanner processes too,
101
+ # and five of them on a two-core runner contend for exactly the same
102
+ # cores the primary scan just finished using.
103
+ cross_validate_workers: int = 0
104
+ # Search free hardened catalogues (Chainguard, Distroless) alongside
105
+ # Docker Hub, so a hardened image can win on measured vulnerabilities.
106
+ include_hardened_sources: bool = True
107
+ # Tags pulled per hardened source; these catalogues are small and their
108
+ # listings are unordered, so a wide fetch buys nothing.
109
+ hardened_tag_limit: int = 10
110
+ # Docker Hardened Images. Off by default because dhi.io refuses
111
+ # anonymous pulls: without credentials its candidates cannot be scanned,
112
+ # and an unscannable candidate is reported UNVERIFIED rather than
113
+ # ranked. `--source dhi` turns it on for a single run regardless.
114
+ include_dhi_source: bool = False
115
+ # How long the DHI catalogue index stays usable before it is refetched.
116
+ # The catalogue moves a few times a day; six hours keeps discovery
117
+ # current while costing one GitHub API request per window.
118
+ dhi_catalog_ttl_seconds: int = 6 * 3600
119
+ # Definition files read per DHI query. Each is one CDN request, and a
120
+ # popular image has dozens across OS variants and build flavours.
121
+ dhi_definition_limit: int = 12
122
+ # Raises GitHub's anonymous 60-requests/hour ceiling for catalogue
123
+ # refreshes. Read-only public data: no scope is required.
124
+ github_token: str = Field(default="", validation_alias="DOCKERLS_GITHUB_TOKEN")
125
+ # Resolve every unpinned tag to a manifest digest before scanning. This
126
+ # is what makes deduplication work across sources: without it, tags that
127
+ # share a manifest are scanned once each.
128
+ resolve_digests: bool = True
129
+ # Fetch the OCI config of each finalist to measure how it is configured
130
+ # (non-root, ports, entrypoint) instead of relying on vendor claims.
131
+ inspect_image_config: bool = True
132
+ # Where an image reference is allowed to make this process connect. A
133
+ # reference is user input, so without these a crafted name reaches the
134
+ # cloud metadata endpoint or a service on the runner. Private ranges are
135
+ # allowed by default because internal registries are ordinary; loopback
136
+ # and link-local are not, because that is the actual attack.
137
+ network_allow_private_networks: bool = True
138
+ network_allow_loopback: bool = False
139
+ network_allow_link_local: bool = False
140
+ #: Hosts permitted regardless of where they resolve ("registry:5000").
141
+ network_allowed_hosts: list[str] = Field(default_factory=list)
142
+ scanner_timeout: int = 300
143
+ http_timeout: int = 30
144
+ retry_max_attempts: int = 3
145
+ retry_backoff_base: float = 2.0
146
+ enable_threat_intel: bool = True
147
+ # A private/organization registry: host, optional namespace prefix, and
148
+ # Basic credentials exchanged for a bearer token at the standard Docker
149
+ # Registry HTTP API V2 token endpoint -- the same protocol ECR, Harbor,
150
+ # GHCR's container registry and a self-hosted `registry:2` all speak.
151
+ # Off by default (empty host): a source nobody configured must not
152
+ # appear as an option, the same reasoning `include_dhi_source` follows
153
+ # for a source that needs credentials to be useful.
154
+ private_registry_host: str = ""
155
+ private_registry_namespace: str = ""
156
+ private_registry_username: str = Field(
157
+ default="", validation_alias="DOCKERLS_PRIVATE_REGISTRY_USERNAME"
158
+ )
159
+ private_registry_password: str = Field(
160
+ default="", validation_alias="DOCKERLS_PRIVATE_REGISTRY_PASSWORD"
161
+ )
162
+
163
+ @classmethod
164
+ def settings_customise_sources(
165
+ cls,
166
+ settings_cls: type[BaseSettings],
167
+ init_settings: PydanticBaseSettingsSource,
168
+ env_settings: PydanticBaseSettingsSource,
169
+ dotenv_settings: PydanticBaseSettingsSource,
170
+ file_secret_settings: PydanticBaseSettingsSource,
171
+ ) -> tuple[PydanticBaseSettingsSource, ...]:
172
+ return (
173
+ init_settings,
174
+ env_settings,
175
+ dotenv_settings,
176
+ TomlConfigSettingsSource(settings_cls),
177
+ file_secret_settings,
178
+ )
179
+
180
+ def model_post_init(self, __context: object) -> None:
181
+ # Legacy opt-out flag from before the DOCKERLS_ env prefix was
182
+ # introduced; keep honoring it alongside DOCKERLS_ENABLE_THREAT_INTEL.
183
+ if os.environ.get("DOCKERLS_DISABLE_THREAT_INTEL"):
184
+ self.enable_threat_intel = False
185
+
186
+ @property
187
+ def db_path(self) -> Path:
188
+ return self.cache_dir / "cache.db"
189
+
190
+ def ensure_dirs(self) -> None:
191
+ self.cache_dir.mkdir(parents=True, exist_ok=True)
192
+ # Log and evidence dirs are best-effort: a read-only working
193
+ # directory must degrade (setup_logging falls back to the cache dir,
194
+ # evidence recording is skipped) rather than abort the command.
195
+ for path in (self.log_dir, self.evidence_dir):
196
+ with contextlib.suppress(OSError):
197
+ path.mkdir(parents=True, exist_ok=True)
File without changes