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,174 @@
1
+ """Assemble one evidence record per image from every source that can speak.
2
+
3
+ Three sources can say something about how an image is built, and they are
4
+ not equal:
5
+
6
+ * the **registry** serves the OCI config of a resolved digest -- a
7
+ measurement of the published artifact;
8
+ * the **scanner** reports the packages it found -- also a measurement, and
9
+ the only one that can see inside the filesystem;
10
+ * the **catalogue** publishes the vendor's build definition -- a claim about
11
+ intent.
12
+
13
+ They are merged in that order, and the precedence is absolute: a measurement
14
+ is never overwritten by a claim. Where a claim contradicts a measurement,
15
+ the contradiction is *recorded* rather than resolved, because a vendor
16
+ saying an image runs unprivileged while its config says root is a finding in
17
+ its own right -- arguably the most useful thing this whole comparison
18
+ produces.
19
+
20
+ The asymmetry from `DeclaredImageMetadata` carries through here: presence of
21
+ a shell package proves a shell, absence proves nothing. Every fact this
22
+ service cannot establish stays UNKNOWN, and UNKNOWN earns no credit in any
23
+ score downstream.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ from typing import TYPE_CHECKING
29
+
30
+ from loguru import logger
31
+
32
+ from dockerls.domain.entities.declared_metadata import (
33
+ DEBUG_TOOL_PACKAGES,
34
+ PACKAGE_MANAGER_PACKAGES,
35
+ SHELL_PACKAGES,
36
+ )
37
+ from dockerls.domain.entities.image_facts import EvidenceSource, HardeningFacts
38
+ from dockerls.domain.value_objects.tristate import Tristate
39
+
40
+ if TYPE_CHECKING:
41
+ from dockerls.domain.entities.image import DockerImage
42
+ from dockerls.domain.entities.scan_result import ScanResult
43
+ from dockerls.integrations.registry.inspector import RegistryInspector
44
+
45
+
46
+ class HardeningAnalyzer:
47
+ """Produces the digest and merged facts for one candidate."""
48
+
49
+ def __init__(self, inspector: RegistryInspector | None = None):
50
+ self._inspector = inspector
51
+
52
+ async def resolve_digest(self, image: DockerImage) -> str:
53
+ """The image's manifest digest, or "" when it cannot be resolved.
54
+
55
+ Exposed separately from `analyze` because deduplication needs the
56
+ digest for *every* candidate, while the full evidence gathering is
57
+ only worth doing for the finalists.
58
+ """
59
+ if self._inspector is None:
60
+ return ""
61
+ try:
62
+ return await self._inspector.resolve_digest(image)
63
+ except Exception as e:
64
+ logger.warning(f"Could not resolve a digest for {image.full_reference}: {e}")
65
+ return ""
66
+
67
+ async def close(self) -> None:
68
+ """Release the registry connection pools this analyzer opened."""
69
+ if self._inspector is not None:
70
+ await self._inspector.close()
71
+
72
+ async def analyze(
73
+ self, image: DockerImage, scan: ScanResult | None
74
+ ) -> tuple[str, HardeningFacts]:
75
+ """Return (resolved digest, merged facts).
76
+
77
+ The digest is "" when the registry could not be reached; the caller
78
+ keeps whatever digest it already had rather than clearing it.
79
+ """
80
+ digest, facts = "", HardeningFacts()
81
+ if self._inspector is not None:
82
+ try:
83
+ digest, facts = await self._inspector.inspect(image)
84
+ except Exception as e:
85
+ # Inspection is enrichment. A registry that will not answer
86
+ # must cost the candidate its *facts*, never its analysis.
87
+ logger.warning(f"Could not inspect {image.full_reference}: {e}")
88
+
89
+ if scan is not None:
90
+ facts = _merge_scanner_evidence(facts, scan)
91
+ if image.declared is not None:
92
+ facts = _merge_declared(facts, image)
93
+ return digest, facts
94
+
95
+
96
+ def _merge_scanner_evidence(facts: HardeningFacts, scan: ScanResult) -> HardeningFacts:
97
+ """Fold in what the scanner saw inside the filesystem.
98
+
99
+ The scanner reports packages that carry vulnerabilities, not the full
100
+ inventory, so this can only ever establish *presence*: seeing `bash`
101
+ among the findings proves a shell is installed, while not seeing it
102
+ proves nothing at all and leaves the fact UNKNOWN. The package count is
103
+ deliberately not derived from this -- the number of vulnerable packages
104
+ is not the number of packages, and reporting it as one would be a
105
+ fabricated measurement.
106
+ """
107
+ names = {v.package_name.strip().lower() for v in scan.vulnerabilities if v.package_name}
108
+ if not names:
109
+ return facts
110
+
111
+ updates: dict[str, object] = {}
112
+ evidence = dict(facts.evidence)
113
+ for fact, known in (
114
+ ("has_shell", SHELL_PACKAGES),
115
+ ("has_package_manager", PACKAGE_MANAGER_PACKAGES),
116
+ ("has_debug_tools", DEBUG_TOOL_PACKAGES),
117
+ ):
118
+ if names & known and not getattr(facts, fact).is_true:
119
+ updates[fact] = Tristate.TRUE
120
+ evidence[fact] = EvidenceSource.SCANNER
121
+
122
+ if not updates:
123
+ return facts
124
+ updates["evidence"] = evidence
125
+ return facts.model_copy(update=updates)
126
+
127
+
128
+ def _merge_declared(facts: HardeningFacts, image: DockerImage) -> HardeningFacts:
129
+ """Fill remaining gaps from the catalogue, and record contradictions.
130
+
131
+ Only facts still UNKNOWN are filled. Anything the registry or the
132
+ scanner determined stands, and a declaration that disagrees with it is
133
+ appended to `conflicts` instead of being applied.
134
+ """
135
+ declared = image.declared
136
+ if declared is None:
137
+ return facts
138
+
139
+ updates: dict[str, object] = {}
140
+ evidence = dict(facts.evidence)
141
+ conflicts = list(facts.conflicts)
142
+
143
+ claims = (
144
+ ("runs_as_non_root", declared.declared_non_root, "runs as a non-root account"),
145
+ ("has_shell", declared.declared_has_shell, "ships a shell"),
146
+ ("has_package_manager", declared.declared_has_package_manager, "ships a package manager"),
147
+ ("has_debug_tools", declared.declared_has_debug_tools, "ships debug tooling"),
148
+ )
149
+ for fact, claim, description in claims:
150
+ if not claim.is_known:
151
+ continue
152
+ current: Tristate = getattr(facts, fact)
153
+ if not current.is_known:
154
+ updates[fact] = claim
155
+ evidence[fact] = EvidenceSource.CATALOG
156
+ elif current is not claim:
157
+ conflicts.append(
158
+ f"{declared.catalog or 'the catalogue'} declares this image "
159
+ f"{'' if claim.is_true else 'does not '}{description}, but the "
160
+ f"{facts.source_of(fact).value} evidence says otherwise"
161
+ )
162
+
163
+ if facts.package_count is None and declared.declared_package_count is not None:
164
+ updates["package_count"] = declared.declared_package_count
165
+ evidence["package_count"] = EvidenceSource.CATALOG
166
+
167
+ if not facts.os_family and declared.os_id:
168
+ updates["os_family"] = declared.os_id
169
+
170
+ if not updates and conflicts == facts.conflicts:
171
+ return facts
172
+ updates["evidence"] = evidence
173
+ updates["conflicts"] = conflicts
174
+ return facts.model_copy(update=updates)
@@ -0,0 +1,297 @@
1
+ """What changing base images actually costs, stated before it is recommended.
2
+
3
+ A tool that ranks images and stops there is only half-useful, because the
4
+ expensive part of acting on the ranking is not choosing -- it is finding out,
5
+ three days later, that the native module your application depends on was
6
+ compiled against glibc and the "safer" image ships musl. The security
7
+ argument for a migration is easy; the compatibility argument is the one that
8
+ decides whether the migration happens.
9
+
10
+ So every suggestion this codebase makes travels with its costs. The rules
11
+ below are deliberately conservative in one direction: a trade-off is raised
12
+ whenever the evidence *permits* a problem, and compatibility is never
13
+ asserted. There is no analysis here -- and there could not be one -- that can
14
+ tell you your application still runs. The checklist exists because that
15
+ question can only be answered by running it.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from typing import TYPE_CHECKING
21
+
22
+ from pydantic import BaseModel, Field
23
+
24
+ from dockerls.domain.value_objects.tristate import Tristate
25
+
26
+ if TYPE_CHECKING:
27
+ from dockerls.application.dto.analysis import ImageAnalysis
28
+
29
+ #: C library each distribution family ships. The musl/glibc split is the
30
+ #: single most common cause of a base-image migration failing: prebuilt
31
+ #: native extensions (Node's node-gyp output, Python wheels, Go cgo builds)
32
+ #: are linked against one and will not load under the other.
33
+ _LIBC_BY_FAMILY = {
34
+ "alpine": "musl",
35
+ "debian": "glibc",
36
+ "ubuntu": "glibc",
37
+ "wolfi": "glibc",
38
+ "chainguard": "glibc",
39
+ "redhat": "glibc",
40
+ "rhel": "glibc",
41
+ "centos": "glibc",
42
+ "rocky": "glibc",
43
+ "almalinux": "glibc",
44
+ "amazon": "glibc",
45
+ "fedora": "glibc",
46
+ "opensuse": "glibc",
47
+ "sles": "glibc",
48
+ "photon": "glibc",
49
+ "oracle": "glibc",
50
+ }
51
+
52
+ #: Package manager each family uses, so a Dockerfile that runs `apk add`
53
+ #: can be told it will need rewriting rather than discovering it at build
54
+ #: time.
55
+ _PACKAGE_MANAGER_BY_FAMILY = {
56
+ "alpine": "apk",
57
+ "wolfi": "apk",
58
+ "chainguard": "apk",
59
+ "debian": "apt",
60
+ "ubuntu": "apt",
61
+ "redhat": "dnf/yum",
62
+ "rhel": "dnf/yum",
63
+ "centos": "dnf/yum",
64
+ "rocky": "dnf/yum",
65
+ "almalinux": "dnf/yum",
66
+ "amazon": "dnf/yum",
67
+ "fedora": "dnf",
68
+ "opensuse": "zypper",
69
+ "sles": "zypper",
70
+ }
71
+
72
+
73
+ class MigrationPlan(BaseModel):
74
+ """The case for one migration, with its costs and the work it implies."""
75
+
76
+ from_reference: str
77
+ to_reference: str
78
+ #: Digest-pinned form of the target, when one was resolved. This is what
79
+ #: should go in a Dockerfile: a tag can move, a digest cannot.
80
+ to_pinned_reference: str = ""
81
+ #: Change in security score. Negative means the "alternative" is worse,
82
+ #: which is reported rather than hidden.
83
+ score_delta: float = 0.0
84
+ critical_delta: int = 0
85
+ high_delta: int = 0
86
+ improvements: list[str] = Field(default_factory=list)
87
+ trade_offs: list[str] = Field(default_factory=list)
88
+ checklist: list[str] = Field(default_factory=list)
89
+
90
+
91
+ def plan_migration(current: ImageAnalysis, target: ImageAnalysis) -> MigrationPlan:
92
+ """Compare two *measured* images and describe the move between them."""
93
+ return MigrationPlan(
94
+ from_reference=current.image.full_reference,
95
+ to_reference=target.image.full_reference,
96
+ to_pinned_reference=target.image.pinned_reference,
97
+ score_delta=round(target.security_score - current.security_score, 1),
98
+ critical_delta=target.scan.critical_count - current.scan.critical_count,
99
+ high_delta=target.scan.high_count - current.scan.high_count,
100
+ improvements=_improvements(current, target),
101
+ trade_offs=_trade_offs(current, target),
102
+ checklist=_checklist(current, target),
103
+ )
104
+
105
+
106
+ def _improvements(current: ImageAnalysis, target: ImageAnalysis) -> list[str]:
107
+ """Gains, each one a difference between two measurements."""
108
+ gains: list[str] = []
109
+
110
+ for label, before, after in (
111
+ ("CRITICAL", current.scan.critical_count, target.scan.critical_count),
112
+ ("HIGH", current.scan.high_count, target.scan.high_count),
113
+ ("MEDIUM", current.scan.medium_count, target.scan.medium_count),
114
+ ):
115
+ if after < before:
116
+ gains.append(f"{label}: {before} -> {after}")
117
+
118
+ before_kev = sum(1 for v in current.scan.vulnerabilities if v.exploit_known)
119
+ after_kev = sum(1 for v in target.scan.vulnerabilities if v.exploit_known)
120
+ if after_kev < before_kev:
121
+ gains.append(f"known-exploited (CISA KEV) findings: {before_kev} -> {after_kev}")
122
+
123
+ if current.is_eol and not target.is_eol:
124
+ gains.append("target is still supported; the current image is end-of-life")
125
+ if target.is_lts and not current.is_lts:
126
+ gains.append("target is a long-term-support release")
127
+
128
+ if target.facts.runs_as_non_root.is_true and not current.facts.runs_as_non_root.is_true:
129
+ gains.append("target runs as a non-root account by default")
130
+ if (
131
+ target.attack_surface.reportable
132
+ and current.attack_surface.reportable
133
+ and target.attack_surface.score < current.attack_surface.score
134
+ ):
135
+ gains.append(
136
+ f"attack surface: {current.attack_surface.score:.0f} -> "
137
+ f"{target.attack_surface.score:.0f} (lower is better)"
138
+ )
139
+ if (
140
+ target.hardening.reportable
141
+ and current.hardening.reportable
142
+ and target.hardening.score > current.hardening.score
143
+ ):
144
+ gains.append(f"hardening: {current.hardening.score:.0f} -> {target.hardening.score:.0f}")
145
+ if target.image.digest_known:
146
+ gains.append("target can be pinned to an immutable digest")
147
+ return _unique(gains)
148
+
149
+
150
+ def _trade_offs(current: ImageAnalysis, target: ImageAnalysis) -> list[str]:
151
+ """Costs. Raised whenever the evidence permits a problem."""
152
+ costs: list[str] = []
153
+
154
+ # Regressions first: an "alternative" that is worse on a severity band
155
+ # must say so before anything else.
156
+ for label, before, after in (
157
+ ("CRITICAL", current.scan.critical_count, target.scan.critical_count),
158
+ ("HIGH", current.scan.high_count, target.scan.high_count),
159
+ ):
160
+ if after > before:
161
+ costs.append(f"{label} findings increase: {before} -> {after}")
162
+
163
+ costs.extend(_libc_trade_off(current, target))
164
+ costs.extend(_package_manager_trade_off(current, target))
165
+ costs.extend(_shell_trade_off(target))
166
+ costs.extend(_architecture_trade_off(current, target))
167
+
168
+ if target.image.source != current.image.source:
169
+ costs.append(
170
+ f"different publisher: {current.image.source} -> {target.image.source}; "
171
+ "check licensing, support and pull authentication before adopting"
172
+ )
173
+ if target.confidence.value != "HIGH":
174
+ costs.extend(target.confidence_reasons)
175
+ costs.extend(target.facts.conflicts)
176
+
177
+ declared = target.image.declared
178
+ if declared is not None and declared.end_of_life:
179
+ costs.append(f"target release is declared end-of-life on {declared.end_of_life}")
180
+ return _unique(costs)
181
+
182
+
183
+ def _libc_trade_off(current: ImageAnalysis, target: ImageAnalysis) -> list[str]:
184
+ """The musl/glibc question, answered only when both sides are known."""
185
+ before = _libc_of(current)
186
+ after = _libc_of(target)
187
+ if not before or not after:
188
+ # One side's base distribution was not identified, so nothing can be
189
+ # said. Saying "compatible" here is the error this whole module is
190
+ # written to avoid.
191
+ return [
192
+ "the base distribution of one side could not be identified: "
193
+ "assume the C library may differ and test native dependencies"
194
+ ]
195
+ if before == after:
196
+ return []
197
+ return [
198
+ f"C library changes ({before} -> {after}): prebuilt native modules, "
199
+ "wheels and cgo binaries linked against the old one will not load and "
200
+ "must be rebuilt"
201
+ ]
202
+
203
+
204
+ def _package_manager_trade_off(current: ImageAnalysis, target: ImageAnalysis) -> list[str]:
205
+ before = _PACKAGE_MANAGER_BY_FAMILY.get(_family_of(current), "")
206
+ after = _PACKAGE_MANAGER_BY_FAMILY.get(_family_of(target), "")
207
+ if not before or not after or before == after:
208
+ return []
209
+ return [
210
+ f"package manager changes ({before} -> {after}): every install step in "
211
+ "your Dockerfile needs rewriting, and package names differ between them"
212
+ ]
213
+
214
+
215
+ def _shell_trade_off(target: ImageAnalysis) -> list[str]:
216
+ """A missing shell is a hardening win and an operational cost at once."""
217
+ facts = target.facts
218
+ if facts.has_shell is Tristate.FALSE:
219
+ return [
220
+ "target has no shell: `docker exec` debugging, shell-form RUN/CMD "
221
+ "and entrypoint scripts will not work"
222
+ ]
223
+ if facts.has_package_manager is Tristate.FALSE:
224
+ return [
225
+ "target has no package manager: anything your build installs at "
226
+ "runtime must move into a builder stage"
227
+ ]
228
+ return []
229
+
230
+
231
+ def _architecture_trade_off(current: ImageAnalysis, target: ImageAnalysis) -> list[str]:
232
+ """Flag architectures the current image supports and the target may not."""
233
+ before = {a for a in current.image.available_architectures if a}
234
+ after = {a for a in target.image.available_architectures if a}
235
+ if not before or not after:
236
+ return []
237
+ lost = sorted(before - after)
238
+ if not lost:
239
+ return []
240
+ return [
241
+ f"target does not publish {', '.join(lost)}: check every platform your deployment targets"
242
+ ]
243
+
244
+
245
+ def _checklist(current: ImageAnalysis, target: ImageAnalysis) -> list[str]:
246
+ """Ordered steps to validate the move, specific where the facts allow.
247
+
248
+ Generic enough to be honest, specific enough to be worth following: the
249
+ libc and non-root steps only appear when the evidence says they apply.
250
+ """
251
+ steps = [
252
+ f"rebuild your image against {target.image.pinned_reference}",
253
+ ]
254
+ if _libc_of(current) and _libc_of(target) and _libc_of(current) != _libc_of(target):
255
+ steps.append(
256
+ f"rebuild every native dependency for {_libc_of(target)} "
257
+ "(clear prebuilt binaries and caches first)"
258
+ )
259
+ steps.append("run the unit test suite against the rebuilt image")
260
+ steps.append("run the integration test suite against the rebuilt image")
261
+ if target.facts.runs_as_non_root.is_true and not current.facts.runs_as_non_root.is_true:
262
+ steps.append(
263
+ f"fix filesystem ownership for the non-root account "
264
+ f"({target.facts.user or 'the image default'}): writes to paths owned "
265
+ "by root will now fail"
266
+ )
267
+ if target.facts.has_shell is Tristate.FALSE:
268
+ steps.append("replace shell-form CMD/ENTRYPOINT and entrypoint scripts with exec form")
269
+ steps.append("re-scan the resulting image (`dockerls analyze <your-image>`)")
270
+ steps.append("verify runtime behaviour under production-like load")
271
+ steps.append("deploy to a canary before rolling out")
272
+ return steps
273
+
274
+
275
+ def _family_of(analysis: ImageAnalysis) -> str:
276
+ """Base distribution, preferring the scanner's answer over any other.
277
+
278
+ The scanner reads the package database inside the image, which is the
279
+ only one of the available signals that cannot be wrong about what the
280
+ image actually is.
281
+ """
282
+ family = (analysis.scan.os_family or analysis.facts.os_family or "").strip().lower()
283
+ return family
284
+
285
+
286
+ def _libc_of(analysis: ImageAnalysis) -> str:
287
+ return _LIBC_BY_FAMILY.get(_family_of(analysis), "")
288
+
289
+
290
+ def _unique(items: list[str]) -> list[str]:
291
+ seen: set[str] = set()
292
+ unique: list[str] = []
293
+ for item in items:
294
+ if item and item not in seen:
295
+ seen.add(item)
296
+ unique.append(item)
297
+ return unique
@@ -0,0 +1,56 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Protocol, runtime_checkable
4
+
5
+ if TYPE_CHECKING:
6
+ from collections.abc import Sequence
7
+
8
+
9
+ @runtime_checkable
10
+ class ScanObserver(Protocol):
11
+ """Reports scan progress to whatever owns the terminal.
12
+
13
+ The use case must not print: it emits events, and the CLI decides how
14
+ (or whether) to render them. This is what lets the terminal show a
15
+ single clean progress line while every diagnostic goes to the log file.
16
+ """
17
+
18
+ def start(self, total: int) -> None: ...
19
+
20
+ def scanning(self, image_reference: str) -> None: ...
21
+
22
+ def finished(self, image_reference: str, ok: bool) -> None: ...
23
+
24
+ def phase(self, description: str) -> None: ...
25
+
26
+ def phase_result(self, title: str, facts: Sequence[tuple[str, str]]) -> None:
27
+ """Report what a finished phase actually did.
28
+
29
+ `phase()` says what is happening; this says what came of it -- how
30
+ many tags were found, how many collapsed onto one digest, how many
31
+ answers came from cache. The pipeline knew all of it and reported
32
+ none of it, so a long run looked identical to a stalled one.
33
+
34
+ Facts are ordered pairs rather than a mapping so the renderer can
35
+ print them in a deliberate order without sorting them into one.
36
+ """
37
+ ...
38
+
39
+
40
+ class NullObserver:
41
+ """No-op observer used when nothing is watching (JSON output, tests)."""
42
+
43
+ def start(self, total: int) -> None:
44
+ return None
45
+
46
+ def scanning(self, image_reference: str) -> None:
47
+ return None
48
+
49
+ def finished(self, image_reference: str, ok: bool) -> None:
50
+ return None
51
+
52
+ def phase(self, description: str) -> None:
53
+ return None
54
+
55
+ def phase_result(self, title: str, facts: Sequence[tuple[str, str]]) -> None:
56
+ return None