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,362 @@
1
+ """Published security controls, and which of this tool's rules implement them.
2
+
3
+ `analyze-dockerfile` has always answered with a code: `DF002 failed`. That
4
+ code means nothing outside this repository. A reader who wants to know
5
+ whether the rule is a real requirement or one maintainer's preference has
6
+ nowhere to look, and an auditor who needs to map findings onto a compliance
7
+ framework has to do it by hand, from the message text.
8
+
9
+ This module closes that gap by naming, for each rule, the published control
10
+ it implements. The value is not decoration: a finding that cites *CIS Docker
11
+ Benchmark 4.1* can be argued about, escalated, waived with a reason, and
12
+ mapped to an audit programme. A finding that cites `DF002` can only be
13
+ obeyed or ignored.
14
+
15
+ **Every identifier and title here was verified against its primary source**
16
+ rather than recalled, on 2026-08-18:
17
+
18
+ * **CIS Docker Benchmark, section 4** -- checked against Docker's own
19
+ implementation of the benchmark, `docker/docker-bench-security`
20
+ (`tests/4_container_images.sh`), which carries the control numbers and
21
+ titles verbatim.
22
+ * **OWASP Docker Security Cheat Sheet** -- checked against the published
23
+ cheat sheet at cheatsheetseries.owasp.org, whose rules are numbered
24
+ `RULE #0` through `RULE #13`.
25
+ * **NIST SP 800-190** -- checked against the table of contents of the
26
+ official NIST publication.
27
+
28
+ That verification is the point, and it changed the content: three of the
29
+ four citations drafted from memory were wrong. `NIST SP 800-190 4.4.2` is
30
+ *Unbounded network access from containers*, not "least privilege", and
31
+ `OWASP RULE #8` is *Set filesystem and volumes to read-only*, not "minimal
32
+ base images". A tool that refuses to state a vulnerability count it did not
33
+ measure cannot cite a control it did not check either.
34
+
35
+ Where no published control covers a rule, that is stated rather than
36
+ stretched: `controls_for` returns an empty tuple, and the renderers say the
37
+ rule is this project's own guidance. Inventing a plausible-looking control
38
+ number would be worse than having none, because it is the kind of error that
39
+ survives review.
40
+ """
41
+
42
+ from __future__ import annotations
43
+
44
+ from dataclasses import dataclass
45
+ from enum import StrEnum
46
+
47
+
48
+ class ControlSource(StrEnum):
49
+ """Where a control is published. Cited by name so a reader can find it."""
50
+
51
+ CIS_DOCKER = "CIS Docker Benchmark"
52
+ NIST_800_190 = "NIST SP 800-190"
53
+ OWASP_DOCKER = "OWASP Docker Security Cheat Sheet"
54
+ DOCKER_DOCS = "Docker documentation"
55
+ OCI_SPEC = "OCI Image Format Specification"
56
+
57
+
58
+ @dataclass(frozen=True)
59
+ class Control:
60
+ """One published control, quoted rather than paraphrased.
61
+
62
+ `title` is the source's own wording. Paraphrasing would make the
63
+ citation unsearchable, which defeats the purpose of having one.
64
+ """
65
+
66
+ source: ControlSource
67
+ #: The source's identifier: "4.1", "RULE #2", "4.1.2". Empty for
68
+ #: documentation that is cited by page rather than by number.
69
+ identifier: str
70
+ title: str
71
+
72
+ def __str__(self) -> str:
73
+ head = f"{self.source.value} {self.identifier}".strip()
74
+ return f"{head} -- {self.title}"
75
+
76
+
77
+ # --- The catalogue, verified against primary sources ---------------------
78
+
79
+ CIS_4_1 = Control(
80
+ ControlSource.CIS_DOCKER, "4.1", "Ensure that a user for the container has been created"
81
+ )
82
+ CIS_4_2 = Control(
83
+ ControlSource.CIS_DOCKER, "4.2", "Ensure that containers use only trusted base images"
84
+ )
85
+ CIS_4_3 = Control(
86
+ ControlSource.CIS_DOCKER,
87
+ "4.3",
88
+ "Ensure that unnecessary packages are not installed in the container",
89
+ )
90
+ CIS_4_4 = Control(
91
+ ControlSource.CIS_DOCKER,
92
+ "4.4",
93
+ "Ensure images are scanned and rebuilt to include security patches",
94
+ )
95
+ CIS_4_6 = Control(
96
+ ControlSource.CIS_DOCKER,
97
+ "4.6",
98
+ "Ensure that HEALTHCHECK instructions have been added to container images",
99
+ )
100
+ CIS_4_7 = Control(
101
+ ControlSource.CIS_DOCKER,
102
+ "4.7",
103
+ "Ensure update instructions are not used alone in the Dockerfile",
104
+ )
105
+ CIS_4_8 = Control(
106
+ ControlSource.CIS_DOCKER, "4.8", "Ensure setuid and setgid permissions are removed"
107
+ )
108
+ CIS_4_9 = Control(
109
+ ControlSource.CIS_DOCKER, "4.9", "Ensure that COPY is used instead of ADD in Dockerfiles"
110
+ )
111
+ CIS_4_10 = Control(ControlSource.CIS_DOCKER, "4.10", "Ensure secrets are not stored in Dockerfiles")
112
+ CIS_4_12 = Control(ControlSource.CIS_DOCKER, "4.12", "Ensure all signed artifacts are validated")
113
+
114
+ OWASP_2 = Control(ControlSource.OWASP_DOCKER, "RULE #2", "Set a user")
115
+ OWASP_4 = Control(
116
+ ControlSource.OWASP_DOCKER, "RULE #4", "Prevent in-container privilege escalation"
117
+ )
118
+ OWASP_9 = Control(
119
+ ControlSource.OWASP_DOCKER,
120
+ "RULE #9",
121
+ "Integrate container scanning tools into your CI/CD pipeline",
122
+ )
123
+ OWASP_12 = Control(
124
+ ControlSource.OWASP_DOCKER, "RULE #12", "Utilize Docker Secrets for Sensitive Data Management"
125
+ )
126
+ OWASP_13 = Control(ControlSource.OWASP_DOCKER, "RULE #13", "Enhance Supply Chain Security")
127
+
128
+ NIST_4_1_1 = Control(ControlSource.NIST_800_190, "4.1.1", "Image vulnerabilities")
129
+ NIST_4_1_2 = Control(ControlSource.NIST_800_190, "4.1.2", "Image configuration defects")
130
+ NIST_4_1_4 = Control(ControlSource.NIST_800_190, "4.1.4", "Embedded clear text secrets")
131
+ NIST_4_1_5 = Control(ControlSource.NIST_800_190, "4.1.5", "Use of untrusted images")
132
+
133
+ DOCKER_MULTISTAGE = Control(ControlSource.DOCKER_DOCS, "", "Multi-stage builds")
134
+ DOCKER_ENTRYPOINT = Control(ControlSource.DOCKER_DOCS, "", "Dockerfile reference: ENTRYPOINT")
135
+ DOCKER_BUILD_CONTEXT = Control(ControlSource.DOCKER_DOCS, "", "Build context and .dockerignore")
136
+ OCI_ANNOTATIONS = Control(ControlSource.OCI_SPEC, "", "Pre-defined annotation keys")
137
+
138
+
139
+ @dataclass(frozen=True)
140
+ class RuleMapping:
141
+ """A DockerLs rule, the controls it implements, and why it matters.
142
+
143
+ `rationale` is this project's own words: the controls say *what*, and a
144
+ reader deciding whether to act needs *why*, in terms of what an attacker
145
+ gains. Keeping them in separate fields is what stops a paraphrase from
146
+ being mistaken for a quotation.
147
+ """
148
+
149
+ rule_id: str
150
+ summary: str
151
+ rationale: str
152
+ controls: tuple[Control, ...] = ()
153
+
154
+ @property
155
+ def is_documented(self) -> bool:
156
+ """False when this is DockerLs's own guidance, not a published control."""
157
+ return bool(self.controls)
158
+
159
+
160
+ RULE_MAPPINGS: tuple[RuleMapping, ...] = (
161
+ RuleMapping(
162
+ rule_id="DF001",
163
+ summary="Pin the base image",
164
+ rationale=(
165
+ "A floating tag means the image you tested and the image that ships can "
166
+ "differ with no change on your side, so nothing you verified about the "
167
+ "base still holds at deploy time."
168
+ ),
169
+ controls=(CIS_4_2, NIST_4_1_5, OWASP_13),
170
+ ),
171
+ RuleMapping(
172
+ rule_id="DF002",
173
+ summary="Run as a non-root user",
174
+ rationale=(
175
+ "A process running as uid 0 starts from the most privileged position "
176
+ "available inside the container, so any code-execution bug begins with "
177
+ "control of the filesystem and of anything mounted into it."
178
+ ),
179
+ controls=(CIS_4_1, OWASP_2, NIST_4_1_2),
180
+ ),
181
+ RuleMapping(
182
+ rule_id="DF003",
183
+ summary="Use a multi-stage build",
184
+ rationale=(
185
+ "Compilers, headers and package managers are needed to build and never "
186
+ "needed to run. A builder stage is how they stay out of the shipped "
187
+ "image without giving up a reproducible build."
188
+ ),
189
+ controls=(CIS_4_3, DOCKER_MULTISTAGE),
190
+ ),
191
+ RuleMapping(
192
+ rule_id="DF004",
193
+ summary="Keep secrets out of ENV and ARG",
194
+ rationale=(
195
+ "A value in ENV is in the image metadata, readable by anyone who can "
196
+ "pull it, and it survives in the layer history even if a later layer "
197
+ "removes it."
198
+ ),
199
+ controls=(CIS_4_10, NIST_4_1_4, OWASP_12),
200
+ ),
201
+ RuleMapping(
202
+ rule_id="DF005",
203
+ summary="Clean the package manager cache in the same layer",
204
+ rationale=(
205
+ "A cache removed in a later layer is still present in the one that "
206
+ "created it, so the image carries the bytes and the attack surface "
207
+ "while appearing not to."
208
+ ),
209
+ controls=(CIS_4_3, CIS_4_7),
210
+ ),
211
+ RuleMapping(
212
+ rule_id="DF006",
213
+ summary="Declare a HEALTHCHECK",
214
+ rationale=(
215
+ "An orchestrator that cannot distinguish a wedged container from a "
216
+ "healthy one keeps sending it traffic. This is availability rather "
217
+ "than confidentiality, which is why it weighs less than the rest."
218
+ ),
219
+ controls=(CIS_4_6,),
220
+ ),
221
+ RuleMapping(
222
+ rule_id="DF007",
223
+ summary="Label the image with provenance metadata",
224
+ rationale=(
225
+ "Standard annotations are what let a responder answer 'where did this "
226
+ "image come from and which commit built it' during an incident, "
227
+ "without guessing from the tag."
228
+ ),
229
+ controls=(OCI_ANNOTATIONS,),
230
+ ),
231
+ RuleMapping(
232
+ rule_id="DF008",
233
+ summary="Start from a minimal base",
234
+ rationale=(
235
+ "Every package in the base is code nobody in your organisation audited "
236
+ "and a future CVE somebody will have to triage; the smallest base that "
237
+ "runs your application is the smallest such backlog."
238
+ ),
239
+ controls=(CIS_4_3, NIST_4_1_2),
240
+ ),
241
+ RuleMapping(
242
+ rule_id="DF009",
243
+ summary="Do not install sudo",
244
+ rationale=(
245
+ "sudo exists to cross a privilege boundary, and it is setuid to do it. "
246
+ "In a container that should already be running unprivileged, it is a "
247
+ "ready-made escalation path."
248
+ ),
249
+ controls=(CIS_4_8, OWASP_4),
250
+ ),
251
+ RuleMapping(
252
+ rule_id="DF010",
253
+ summary="Use exec form for ENTRYPOINT and CMD",
254
+ rationale=(
255
+ "Shell form starts a shell as pid 1, which swallows SIGTERM: the "
256
+ "container stops being able to shut down cleanly, and orchestrators "
257
+ "fall back to killing it."
258
+ ),
259
+ controls=(DOCKER_ENTRYPOINT,),
260
+ ),
261
+ RuleMapping(
262
+ rule_id="DF011",
263
+ summary="Avoid shipping an interactive shell",
264
+ rationale=(
265
+ "A shell turns a limited primitive -- a file write, a template "
266
+ "injection -- into arbitrary command execution, and it is the first "
267
+ "thing most published container exploit chains reach for."
268
+ ),
269
+ controls=(CIS_4_3, NIST_4_1_2),
270
+ ),
271
+ RuleMapping(
272
+ rule_id="DF012",
273
+ summary="Keep a .dockerignore",
274
+ rationale=(
275
+ "Without one the build context carries .git, .env and local "
276
+ "credentials to the daemon, and anything a COPY happens to match ends "
277
+ "up in the image."
278
+ ),
279
+ controls=(CIS_4_10, DOCKER_BUILD_CONTEXT),
280
+ ),
281
+ RuleMapping(
282
+ rule_id="DF013",
283
+ summary="Use COPY instead of ADD",
284
+ rationale=(
285
+ "ADD does two things COPY does not, and neither is written on the line: "
286
+ "it fetches URLs with nothing verifying what came back, and it "
287
+ "auto-extracts local archives, so a tarball carrying '../../etc' writes "
288
+ "outside the destination. A reader sees what looks like a copy."
289
+ ),
290
+ controls=(CIS_4_9, NIST_4_1_5),
291
+ ),
292
+ RuleMapping(
293
+ rule_id="DF014",
294
+ summary="Do not pipe a downloaded script into a shell",
295
+ rationale=(
296
+ "`curl | sh` leaves the script nowhere: nothing signs it, nothing checks a "
297
+ "digest, and no layer records what ran. Whoever controls that host, the "
298
+ "path to it, or the DNS in between chooses what executes as root during "
299
+ "your build, and the Dockerfile keeps reading the same."
300
+ ),
301
+ controls=(NIST_4_1_5, OWASP_13),
302
+ ),
303
+ RuleMapping(
304
+ rule_id="DF015",
305
+ summary="Do not leave setuid or setgid binaries in the image",
306
+ rationale=(
307
+ "A setuid binary runs as its owner, and the owner is root. In a container "
308
+ "that correctly runs as an unprivileged user, it is the ready-made path "
309
+ "back to uid 0 -- the one piece missing to turn a limited command "
310
+ "execution into a complete one."
311
+ ),
312
+ controls=(CIS_4_8, OWASP_4, NIST_4_1_2),
313
+ ),
314
+ RuleMapping(
315
+ rule_id="DF016",
316
+ summary="Pin the version of every installed package",
317
+ rationale=(
318
+ "An unpinned `apt-get install`, `apk add`, `pip install` or `npm install` "
319
+ "resolves against whatever the index serves the day the build runs -- the "
320
+ "same Dockerfile can install different bytes, and a different CVE, "
321
+ "tomorrow than it did today. This is a reproducibility rule this project "
322
+ "checked no published benchmark for by name, not a citation stretched to "
323
+ "fit."
324
+ ),
325
+ controls=(),
326
+ ),
327
+ RuleMapping(
328
+ rule_id="DF017",
329
+ summary="Multi-architecture build awareness",
330
+ rationale=(
331
+ "`TARGETPLATFORM`/`--platform` have no correct state to check for, only "
332
+ "presence or absence -- this is informational, not a pass/fail control, "
333
+ "and exists to flag a package version pinned for one architecture that is "
334
+ "not guaranteed to exist, or to mean the same package, on another."
335
+ ),
336
+ controls=(),
337
+ ),
338
+ )
339
+
340
+ _BY_RULE: dict[str, RuleMapping] = {mapping.rule_id: mapping for mapping in RULE_MAPPINGS}
341
+
342
+
343
+ def mapping_for(rule_id: str | None) -> RuleMapping | None:
344
+ """The mapping for `rule_id`, or None when the rule is not catalogued.
345
+
346
+ None is a legitimate answer, not a gap to paper over: a rule this
347
+ catalogue does not cover must render without a citation rather than
348
+ with a guessed one.
349
+ """
350
+ if not rule_id:
351
+ return None
352
+ return _BY_RULE.get(rule_id.strip().upper())
353
+
354
+
355
+ def controls_for(rule_id: str | None) -> tuple[Control, ...]:
356
+ mapping = mapping_for(rule_id)
357
+ return mapping.controls if mapping else ()
358
+
359
+
360
+ def references_for(rule_id: str | None) -> list[str]:
361
+ """Citations as display strings, for serialisation and rendering."""
362
+ return [str(control) for control in controls_for(rule_id)]
@@ -0,0 +1,49 @@
1
+ """Os objetos de valor do domínio, resolvidos sob demanda.
2
+
3
+ Este pacote reexportava tudo no topo do arquivo, e o preço não era o
4
+ import em si: cada símbolo aqui é um modelo pydantic, e o pydantic compila
5
+ o modelo *no momento do import*. Como `import
6
+ dockerls.domain.value_objects.image_reference` -- uma função de parsing de
7
+ strings, sem dependência nenhuma -- executa o `__init__` do pacote antes de
8
+ chegar no submódulo, todo comando da CLI pagava essa compilação. Eram 82ms
9
+ em `dockerls --help`, que não pontua imagem nenhuma.
10
+
11
+ O `__getattr__` de módulo (PEP 562) mantém `from dockerls.domain.value_objects
12
+ import SecurityScore` funcionando exatamente como antes; o submódulo só é
13
+ importado quando alguém realmente pede o nome.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from typing import TYPE_CHECKING, Any
19
+
20
+ if TYPE_CHECKING:
21
+ from dockerls.domain.value_objects.remediation_score import RemediationScore
22
+ from dockerls.domain.value_objects.security_score import SecurityScore
23
+ from dockerls.domain.value_objects.security_tier import SecurityTier
24
+
25
+ #: Nome público -> submódulo que o define.
26
+ _EXPORTS = {
27
+ "RemediationScore": "remediation_score",
28
+ "SecurityScore": "security_score",
29
+ "SecurityTier": "security_tier",
30
+ }
31
+
32
+ __all__ = ["RemediationScore", "SecurityScore", "SecurityTier"]
33
+
34
+
35
+ def __getattr__(name: str) -> Any:
36
+ module = _EXPORTS.get(name)
37
+ if module is None:
38
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
39
+ from importlib import import_module
40
+
41
+ value = getattr(import_module(f"{__name__}.{module}"), name)
42
+ # Cacheado no próprio módulo: `__getattr__` só é consultado quando o
43
+ # nome não está no namespace, então o segundo acesso não paga nada.
44
+ globals()[name] = value
45
+ return value
46
+
47
+
48
+ def __dir__() -> list[str]:
49
+ return sorted(__all__)
@@ -0,0 +1,198 @@
1
+ """Attack surface: how much an attacker inherits after reaching the container.
2
+
3
+ Distinct from hardening, and distinct again from vulnerabilities. Hardening
4
+ asks "is this configured defensively"; attack surface asks "if code
5
+ execution happens inside this container, what is already there to use". A
6
+ shell, a package manager, a compiler and a network fetcher are each a rung
7
+ on that ladder, and none of them is a CVE.
8
+
9
+ **Size is not surface.** A 900 MB image built from a single statically
10
+ linked binary has a smaller attack surface than a 40 MB image carrying
11
+ busybox, apk and curl. Bytes are therefore not scored here at all; package
12
+ *count* is, because a package is a piece of installed functionality, and
13
+ capabilities are, because they are what the functionality can do.
14
+
15
+ The scale runs 0 (nothing found that expands surface) to 100 (every scored
16
+ item present). Like hardening, it is computed over determined facts only and
17
+ reports its coverage, so an image nobody could inspect reads as "unknown",
18
+ never as "clean".
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from typing import TYPE_CHECKING, NamedTuple
24
+
25
+ if TYPE_CHECKING:
26
+ from dockerls.domain.entities.image_facts import HardeningFacts
27
+ from dockerls.domain.value_objects.tristate import Tristate
28
+
29
+ #: Weights, highest first. A package manager outweighs a shell: a shell lets
30
+ #: an attacker use what is already installed, while a package manager lets
31
+ #: them install whatever is missing.
32
+ PACKAGE_MANAGER_WEIGHT = 25.0
33
+ SHELL_WEIGHT = 20.0
34
+ DEBUG_TOOLS_WEIGHT = 15.0
35
+ SETUID_WEIGHT = 15.0
36
+ ROOT_DEFAULT_WEIGHT = 15.0
37
+ PACKAGE_VOLUME_WEIGHT = 10.0
38
+
39
+ TOTAL_WEIGHT = (
40
+ PACKAGE_MANAGER_WEIGHT
41
+ + SHELL_WEIGHT
42
+ + DEBUG_TOOLS_WEIGHT
43
+ + SETUID_WEIGHT
44
+ + ROOT_DEFAULT_WEIGHT
45
+ + PACKAGE_VOLUME_WEIGHT
46
+ )
47
+
48
+ #: Package count at which the volume term saturates. A runtime image with a
49
+ #: few hundred packages carries a large amount of code nobody audited.
50
+ PACKAGE_VOLUME_CEILING = 400
51
+
52
+ #: Below this share of determined weight the number is not reported, for the
53
+ #: same reason as in the hardening model: it would look like a measurement.
54
+ MIN_REPORTABLE_COVERAGE = 0.25
55
+
56
+
57
+ class SurfaceItem(NamedTuple):
58
+ """One scored element of the surface, kept so the score can explain itself."""
59
+
60
+ name: str
61
+ weight: float
62
+ #: Surface contributed, 0.0 to `weight`.
63
+ contributed: float
64
+ determined: bool
65
+ detail: str = ""
66
+
67
+
68
+ class AttackSurfaceScore:
69
+ """0-100 where **higher means more surface**, over determined facts only.
70
+
71
+ The inverted direction relative to every other score in this codebase is
72
+ deliberate and is stated everywhere the number is rendered: "attack
73
+ surface 80" must never be mistaken for "80% good".
74
+ """
75
+
76
+ def __init__(self, facts: HardeningFacts):
77
+ self._items = _surface_items(facts)
78
+ determined = [item for item in self._items if item.determined]
79
+ self._available = sum(item.weight for item in determined)
80
+ self._contributed = sum(item.contributed for item in determined)
81
+ self._value = (
82
+ round(100.0 * self._contributed / self._available, 1) if self._available > 0 else 0.0
83
+ )
84
+
85
+ @property
86
+ def value(self) -> float:
87
+ return self._value
88
+
89
+ @property
90
+ def coverage(self) -> float:
91
+ return round(self._available / TOTAL_WEIGHT, 3)
92
+
93
+ @property
94
+ def is_reportable(self) -> bool:
95
+ return self.coverage >= MIN_REPORTABLE_COVERAGE
96
+
97
+ @property
98
+ def items(self) -> list[SurfaceItem]:
99
+ return list(self._items)
100
+
101
+ @property
102
+ def present(self) -> list[str]:
103
+ """Surface elements confirmed present, for the explanation text."""
104
+ return [
105
+ item.detail or item.name
106
+ for item in self._items
107
+ if item.determined and item.contributed > 0.0
108
+ ]
109
+
110
+ @property
111
+ def absent(self) -> list[str]:
112
+ """Surface elements confirmed absent -- a genuine finding, not a default."""
113
+ return [
114
+ item.detail or item.name
115
+ for item in self._items
116
+ if item.determined and item.contributed <= 0.0
117
+ ]
118
+
119
+
120
+ def _surface_items(facts: HardeningFacts) -> list[SurfaceItem]:
121
+ return [
122
+ _from_presence(
123
+ "package-manager",
124
+ PACKAGE_MANAGER_WEIGHT,
125
+ facts.has_package_manager,
126
+ present="package manager installed",
127
+ absent="no package manager",
128
+ ),
129
+ _from_presence(
130
+ "shell", SHELL_WEIGHT, facts.has_shell, present="shell present", absent="no shell"
131
+ ),
132
+ _from_presence(
133
+ "debug-tools",
134
+ DEBUG_TOOLS_WEIGHT,
135
+ facts.has_debug_tools,
136
+ present="compilers or network utilities present",
137
+ absent="no compilers or network utilities",
138
+ ),
139
+ _from_presence(
140
+ "setuid",
141
+ SETUID_WEIGHT,
142
+ facts.has_setuid,
143
+ present="SUID/SGID binaries present",
144
+ absent="no SUID/SGID binaries",
145
+ ),
146
+ _root_default(facts),
147
+ _package_volume(facts),
148
+ ]
149
+
150
+
151
+ def _from_presence(
152
+ name: str, weight: float, state: Tristate, *, present: str, absent: str
153
+ ) -> SurfaceItem:
154
+ if not state.is_known:
155
+ return SurfaceItem(name, weight, 0.0, determined=False)
156
+ contributed = weight if state.is_true else 0.0
157
+ return SurfaceItem(
158
+ name, weight, contributed, determined=True, detail=present if state.is_true else absent
159
+ )
160
+
161
+
162
+ def _root_default(facts: HardeningFacts) -> SurfaceItem:
163
+ """Running as root multiplies what every other element is worth."""
164
+ name = "root-default"
165
+ if not facts.runs_as_non_root.is_known:
166
+ return SurfaceItem(name, ROOT_DEFAULT_WEIGHT, 0.0, determined=False)
167
+ if facts.runs_as_non_root.is_true:
168
+ return SurfaceItem(
169
+ name, ROOT_DEFAULT_WEIGHT, 0.0, determined=True, detail="runs unprivileged"
170
+ )
171
+ return SurfaceItem(
172
+ name,
173
+ ROOT_DEFAULT_WEIGHT,
174
+ ROOT_DEFAULT_WEIGHT,
175
+ determined=True,
176
+ detail="runs as root by default",
177
+ )
178
+
179
+
180
+ def _package_volume(facts: HardeningFacts) -> SurfaceItem:
181
+ """Installed code volume, scaled linearly to a ceiling.
182
+
183
+ Counted in packages, not bytes: the question is how much distinct
184
+ installed functionality exists, and a single large binary is one thing
185
+ to audit while three hundred small packages are three hundred.
186
+ """
187
+ name = "package-volume"
188
+ count = facts.package_count
189
+ if count is None:
190
+ return SurfaceItem(name, PACKAGE_VOLUME_WEIGHT, 0.0, determined=False)
191
+ ratio = min(1.0, max(0, count) / PACKAGE_VOLUME_CEILING)
192
+ return SurfaceItem(
193
+ name,
194
+ PACKAGE_VOLUME_WEIGHT,
195
+ round(PACKAGE_VOLUME_WEIGHT * ratio, 3),
196
+ determined=True,
197
+ detail=f"{count} installed packages",
198
+ )