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,303 @@
1
+ """Hardening score: how well-configured an image is, independently of its CVEs.
2
+
3
+ Two images with identical vulnerability counts are not equally safe. One may
4
+ run as root with a shell, a package manager and a compiler in it; the other
5
+ may run as an unprivileged account with none of that. Nothing in a CVE count
6
+ expresses the difference, which is why hardening is a separate dimension
7
+ here rather than a term folded into the security score.
8
+
9
+ **Scoring over determined facts only.** The obvious design -- fixed weights
10
+ summed into a fixed maximum -- fails on the data actually available. Most
11
+ facts about most images cannot be determined without unpacking a filesystem,
12
+ so a fixed denominator would give a genuinely excellent image a score in the
13
+ thirties purely because nobody could inspect it, and that number would be
14
+ read as a hardening verdict. Instead the denominator is the weight of the
15
+ facts that *were* determined, and `coverage` reports how much of the model
16
+ that represents. A score of 100 at 30% coverage says "everything we could
17
+ check was good, and we could check less than a third of it" -- which is the
18
+ truth, and is what `Confidence` and the renderers surface next to it.
19
+
20
+ **Hardening never masks vulnerabilities.** This score is deliberately not an
21
+ input to `SecurityScore`. It is reported beside it, and the verdict layer
22
+ enforces the rule that no hardening result can lift an image carrying an
23
+ unfixable CRITICAL into "production ready". A perfectly configured image
24
+ full of exploitable CVEs is a perfectly configured vulnerable image.
25
+
26
+ Every weight below is a constant, documented in place, and covered by tests
27
+ that pin the arithmetic. Nothing here is tuned per-image or per-vendor.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ from typing import TYPE_CHECKING, NamedTuple
33
+
34
+ from dockerls.domain.value_objects.tristate import Tristate
35
+
36
+ if TYPE_CHECKING:
37
+ from dockerls.domain.entities.image_facts import HardeningFacts
38
+
39
+ #: Weight of each hardening property, and the direction that earns credit.
40
+ #: The privilege facts dominate because privilege is what turns a bug into a
41
+ #: breach: a shell in an image that runs as an unprivileged account with no
42
+ #: capabilities is a much smaller problem than root with no shell.
43
+ NON_ROOT_WEIGHT = 25.0
44
+ NO_SHELL_WEIGHT = 15.0
45
+ NO_PACKAGE_MANAGER_WEIGHT = 12.0
46
+ NO_DEBUG_TOOLS_WEIGHT = 8.0
47
+ NO_SETUID_WEIGHT = 10.0
48
+ NO_PRIVILEGED_PORTS_WEIGHT = 8.0
49
+ MINIMAL_PACKAGES_WEIGHT = 12.0
50
+ EXPLICIT_ENTRYPOINT_WEIGHT = 5.0
51
+ HEALTHCHECK_WEIGHT = 5.0
52
+
53
+ #: Total weight of the model, used to express coverage. Not a denominator
54
+ #: for the score itself -- see the module docstring.
55
+ TOTAL_WEIGHT = (
56
+ NON_ROOT_WEIGHT
57
+ + NO_SHELL_WEIGHT
58
+ + NO_PACKAGE_MANAGER_WEIGHT
59
+ + NO_DEBUG_TOOLS_WEIGHT
60
+ + NO_SETUID_WEIGHT
61
+ + NO_PRIVILEGED_PORTS_WEIGHT
62
+ + MINIMAL_PACKAGES_WEIGHT
63
+ + EXPLICIT_ENTRYPOINT_WEIGHT
64
+ + HEALTHCHECK_WEIGHT
65
+ )
66
+
67
+ #: A package set at or below this is treated as minimal. Chosen from the
68
+ #: catalogue data: hardened runtime definitions install 10-40 packages,
69
+ #: while a general-purpose base lands in the hundreds.
70
+ MINIMAL_PACKAGE_COUNT = 50
71
+
72
+ #: Below this share of the model, the score is not reported as a number.
73
+ #: Two determined facts cannot characterise an image's hardening, and a
74
+ #: confident-looking number computed from them would be worse than no
75
+ #: number at all.
76
+ MIN_REPORTABLE_COVERAGE = 0.25
77
+
78
+
79
+ class HardeningFactor(NamedTuple):
80
+ """One scored property, kept so the score can explain itself."""
81
+
82
+ name: str
83
+ weight: float
84
+ #: Credit earned, 0.0 to `weight`.
85
+ earned: float
86
+ #: False when the underlying fact was not determined; such a factor
87
+ #: contributes to neither the numerator nor the denominator.
88
+ determined: bool
89
+ detail: str = ""
90
+
91
+
92
+ class HardeningScore:
93
+ """Deterministic 0-100 hardening rating over the facts that are known."""
94
+
95
+ def __init__(self, facts: HardeningFacts):
96
+ self._facts = facts
97
+ self._factors = _score_factors(facts)
98
+ determined = [f for f in self._factors if f.determined]
99
+ self._available = sum(f.weight for f in determined)
100
+ self._earned = sum(f.earned for f in determined)
101
+ self._value = (
102
+ round(100.0 * self._earned / self._available, 1) if self._available > 0 else 0.0
103
+ )
104
+
105
+ @property
106
+ def value(self) -> float:
107
+ """0-100 over the determined facts. Meaningless below `MIN_REPORTABLE_COVERAGE`."""
108
+ return self._value
109
+
110
+ @property
111
+ def coverage(self) -> float:
112
+ """Share of the hardening model that could be determined, 0.0-1.0."""
113
+ return round(self._available / TOTAL_WEIGHT, 3)
114
+
115
+ @property
116
+ def is_reportable(self) -> bool:
117
+ """Whether enough was determined for the number to mean anything."""
118
+ return self.coverage >= MIN_REPORTABLE_COVERAGE
119
+
120
+ @property
121
+ def strengths(self) -> list[str]:
122
+ """Determined properties that earned full credit, for the "why" text."""
123
+ return [f.detail or f.name for f in self._factors if f.determined and f.earned >= f.weight]
124
+
125
+ @property
126
+ def weaknesses(self) -> list[str]:
127
+ """Determined properties that earned nothing -- real, named findings."""
128
+ return [f.detail or f.name for f in self._factors if f.determined and f.earned <= 0.0]
129
+
130
+ @property
131
+ def undetermined(self) -> list[str]:
132
+ """Properties nothing could establish, stated rather than hidden."""
133
+ return [f.name for f in self._factors if not f.determined]
134
+
135
+
136
+ def _score_factors(facts: HardeningFacts) -> list[HardeningFactor]:
137
+ """Evaluate every factor, marking the ones nothing determined.
138
+
139
+ Written as one flat list rather than a chain of conditionals so the
140
+ model is readable end to end: each line is a property, its weight, and
141
+ the fact that decides it.
142
+ """
143
+ return [
144
+ _from_tristate(
145
+ "non-root",
146
+ NON_ROOT_WEIGHT,
147
+ facts.runs_as_non_root,
148
+ good=f"runs as {facts.user or 'a non-root account'}",
149
+ bad="runs as root by default",
150
+ ),
151
+ _from_tristate(
152
+ "no-shell",
153
+ NO_SHELL_WEIGHT,
154
+ _negate(facts.has_shell),
155
+ good="no shell present",
156
+ bad="ships an interactive shell",
157
+ ),
158
+ _from_tristate(
159
+ "no-package-manager",
160
+ NO_PACKAGE_MANAGER_WEIGHT,
161
+ _negate(facts.has_package_manager),
162
+ good="no package manager present",
163
+ bad="ships a package manager",
164
+ ),
165
+ _from_tristate(
166
+ "no-debug-tools",
167
+ NO_DEBUG_TOOLS_WEIGHT,
168
+ _negate(facts.has_debug_tools),
169
+ good="no compilers or network utilities",
170
+ bad="ships compilers or network utilities",
171
+ ),
172
+ _from_tristate(
173
+ "no-setuid",
174
+ NO_SETUID_WEIGHT,
175
+ _negate(facts.has_setuid),
176
+ good="no SUID/SGID binaries",
177
+ bad="contains SUID/SGID binaries",
178
+ ),
179
+ _privileged_ports(facts),
180
+ _minimal_packages(facts),
181
+ _entrypoint(facts),
182
+ _from_tristate(
183
+ "healthcheck",
184
+ HEALTHCHECK_WEIGHT,
185
+ facts.has_healthcheck,
186
+ good="declares a healthcheck",
187
+ bad="declares no healthcheck",
188
+ ),
189
+ ]
190
+
191
+
192
+ def _negate(state: Tristate) -> Tristate:
193
+ """Invert a fact, preserving UNKNOWN.
194
+
195
+ `not has_shell` on a bool would turn "nobody looked" into "no shell",
196
+ which is the exact substitution this module exists to prevent.
197
+ """
198
+ if state is Tristate.UNKNOWN:
199
+ return Tristate.UNKNOWN
200
+ return Tristate.FALSE if state.is_true else Tristate.TRUE
201
+
202
+
203
+ def _from_tristate(
204
+ name: str, weight: float, state: Tristate, *, good: str, bad: str
205
+ ) -> HardeningFactor:
206
+ if not state.is_known:
207
+ return HardeningFactor(name, weight, 0.0, determined=False)
208
+ earned = weight if state.is_true else 0.0
209
+ return HardeningFactor(
210
+ name, weight, earned, determined=True, detail=good if state.is_true else bad
211
+ )
212
+
213
+
214
+ def _privileged_ports(facts: HardeningFacts) -> HardeningFactor:
215
+ """Credit for declaring no port that implies elevated privileges.
216
+
217
+ Determined only when the OCI config was actually read: an image whose
218
+ config was never fetched has an empty port list because nothing looked,
219
+ not because it declares none.
220
+ """
221
+ name = "no-privileged-ports"
222
+ if not facts.ports_known:
223
+ return HardeningFactor(name, NO_PRIVILEGED_PORTS_WEIGHT, 0.0, determined=False)
224
+ privileged = facts.privileged_ports
225
+ if privileged:
226
+ detail = f"exposes privileged port(s) {', '.join(str(p) for p in sorted(privileged))}"
227
+ return HardeningFactor(
228
+ name, NO_PRIVILEGED_PORTS_WEIGHT, 0.0, determined=True, detail=detail
229
+ )
230
+ return HardeningFactor(
231
+ name,
232
+ NO_PRIVILEGED_PORTS_WEIGHT,
233
+ NO_PRIVILEGED_PORTS_WEIGHT,
234
+ determined=True,
235
+ detail="exposes no privileged ports",
236
+ )
237
+
238
+
239
+ def _minimal_packages(facts: HardeningFacts) -> HardeningFactor:
240
+ """Credit for a small package set, scaled rather than a cliff edge.
241
+
242
+ A step function at exactly 50 packages would rank a 50-package image
243
+ far above a 51-package one, which is not a real difference. Credit
244
+ tapers from full at `MINIMAL_PACKAGE_COUNT` to zero at four times that.
245
+ """
246
+ name = "minimal-packages"
247
+ count = facts.package_count
248
+ if count is None:
249
+ return HardeningFactor(name, MINIMAL_PACKAGES_WEIGHT, 0.0, determined=False)
250
+ ceiling = MINIMAL_PACKAGE_COUNT * 4
251
+ if count <= MINIMAL_PACKAGE_COUNT:
252
+ earned = MINIMAL_PACKAGES_WEIGHT
253
+ elif count >= ceiling:
254
+ earned = 0.0
255
+ else:
256
+ span = ceiling - MINIMAL_PACKAGE_COUNT
257
+ earned = MINIMAL_PACKAGES_WEIGHT * (ceiling - count) / span
258
+ return HardeningFactor(
259
+ name,
260
+ MINIMAL_PACKAGES_WEIGHT,
261
+ round(earned, 3),
262
+ determined=True,
263
+ detail=f"{count} packages",
264
+ )
265
+
266
+
267
+ def _entrypoint(facts: HardeningFacts) -> HardeningFactor:
268
+ """Credit for declaring an explicit entrypoint.
269
+
270
+ An image with a fixed entrypoint constrains what running it does; one
271
+ whose entrypoint is a shell does the opposite, and is scored as such.
272
+ """
273
+ name = "explicit-entrypoint"
274
+ if not facts.config_verified:
275
+ return HardeningFactor(name, EXPLICIT_ENTRYPOINT_WEIGHT, 0.0, determined=False)
276
+ entry = facts.entrypoint or facts.cmd
277
+ if not entry:
278
+ return HardeningFactor(
279
+ name, EXPLICIT_ENTRYPOINT_WEIGHT, 0.0, determined=True, detail="declares no entrypoint"
280
+ )
281
+ if _is_shell_entrypoint(entry):
282
+ return HardeningFactor(
283
+ name,
284
+ EXPLICIT_ENTRYPOINT_WEIGHT,
285
+ 0.0,
286
+ determined=True,
287
+ detail=f"entrypoint is a shell ({entry[0]})",
288
+ )
289
+ return HardeningFactor(
290
+ name,
291
+ EXPLICIT_ENTRYPOINT_WEIGHT,
292
+ EXPLICIT_ENTRYPOINT_WEIGHT,
293
+ determined=True,
294
+ detail=f"entrypoint {' '.join(entry[:2])}",
295
+ )
296
+
297
+
298
+ _SHELL_BASENAMES = frozenset({"sh", "bash", "ash", "dash", "zsh", "ksh", "busybox"})
299
+
300
+
301
+ def _is_shell_entrypoint(entry: list[str]) -> bool:
302
+ head = entry[0].strip() if entry else ""
303
+ return head.rsplit("/", 1)[-1] in _SHELL_BASENAMES
@@ -0,0 +1,90 @@
1
+ """Where an image reference says it will make this process connect.
2
+
3
+ The SSRF policy already guards the registry inspector, but the inspector is
4
+ not the only thing a reference reaches the network with. `trivy image X` and
5
+ `grype X` **pull X themselves**, over a socket this process opened, from a
6
+ host named inside the reference. A reference of
7
+ `169.254.169.254/latest:v1` therefore aimed the scanner's own pull at the
8
+ cloud metadata endpoint while the guard watched a request that was never
9
+ made -- the guard was on one door of a building with two.
10
+
11
+ Extracting the host is the pure half of closing that, and it lives here for
12
+ the same reason `NetworkPolicy` does: the rule can then be tested against
13
+ every reference shape without a network, and the resolution stays in
14
+ infrastructure where the sockets are.
15
+
16
+ The rule is Docker's own: the first path component is a registry host only
17
+ when it carries a dot or a colon, or is exactly `localhost`. Everything else
18
+ is a namespace on Docker Hub. `localhost` is the case a naive
19
+ dot-or-colon test misses, and it is precisely the one an attacker wants.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ #: Registry hosts that mean Docker Hub. Contacting them is the default
25
+ #: behaviour of every reference, so they are not host-qualified for policy.
26
+ _DOCKER_HUB_HOSTS = frozenset({"docker.io", "index.docker.io", "registry-1.docker.io"})
27
+
28
+
29
+ def registry_host_of(reference: str) -> str:
30
+ """The registry host named in `reference`, or "" when it means Docker Hub.
31
+
32
+ Returns the host **as written**, port included, because that is what an
33
+ allowlist entry and a policy explanation have to match.
34
+ """
35
+ name = reference.strip()
36
+ if not name:
37
+ return ""
38
+ # A digest or tag suffix never contains the registry, and dropping it
39
+ # first stops `node@sha256:...` from looking port-qualified.
40
+ name = name.split("@", 1)[0]
41
+ head = name.split("/", 1)[0]
42
+ if "/" not in name:
43
+ return ""
44
+ if not is_registry_host(head):
45
+ return ""
46
+ return "" if head.lower() in _DOCKER_HUB_HOSTS else head
47
+
48
+
49
+ def is_registry_host(segment: str) -> bool:
50
+ """Whether a leading path component names a registry rather than a namespace.
51
+
52
+ `localhost` is included explicitly: it carries neither a dot nor a port,
53
+ so a dot-or-colon test reads `localhost/evil` as the Docker Hub user
54
+ "localhost" and lets the pull through to a service on this machine.
55
+ """
56
+ return "." in segment or ":" in segment or segment.lower() == "localhost"
57
+
58
+
59
+ def split_repository_and_tag(reference: str) -> tuple[str, str]:
60
+ """Split an image reference into (repository, tag).
61
+
62
+ Unlike a plain `rsplit(":", 1)`, this never mistakes a `host:port`
63
+ registry prefix for a tag: only a colon in the *last* path segment
64
+ counts. A digest suffix (`@sha256:...`) is stripped first and never
65
+ treated as part of the tag.
66
+
67
+ Mora aqui, ao lado de `is_registry_host`, porque é a mesma regra sobre a
68
+ mesma string: o que conta como host. `application/` precisa dela para
69
+ não ler a porta do registry como versão na consulta de EOL, e a camada
70
+ de aplicação não pode importar da `cli/` -- `test_architecture` reprova,
71
+ e com razão.
72
+ """
73
+ ref = reference.split("@", 1)[0]
74
+ parts = ref.split("/")
75
+ prefix = ""
76
+ if len(parts) > 1 and is_registry_host(parts[0]):
77
+ prefix = parts[0] + "/"
78
+ parts = parts[1:]
79
+ tail = "/".join(parts)
80
+ # Only the *last* path segment, as the docstring says. `rsplit` over the
81
+ # whole tail read the colon of a second host-shaped segment as a tag
82
+ # separator and answered a "tag" with a slash in it -- something no
83
+ # Docker tag can contain. `_is_moving_reference` then saw a non-empty,
84
+ # non-`latest` tag and reported the base as pinned: the same
85
+ # port-hides-an-unpinned-base shape as DF013, one segment deeper.
86
+ head, separator, last = tail.rpartition("/")
87
+ if ":" in last:
88
+ repo_tail, tag = last.rsplit(":", 1)
89
+ return prefix + head + separator + repo_tail, tag
90
+ return ref, ""