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,145 @@
1
+ """The one place that decides whether an image may go to production.
2
+
3
+ Before this module the decision was `tier in (A, B) and not is_eol`, computed
4
+ inside `SecurityTier` from the score alone. That expression cannot see
5
+ whether the scan finished, whether two scanners agreed, or whether anything
6
+ was verified at all -- so a `PARTIAL` scan with no findings in the targets it
7
+ managed to read produced a high score, tier A, and `production_ready = True`,
8
+ while the very same analysis reported `confidence = UNVERIFIED`.
9
+
10
+ An analysis that says both things is worse than one that says neither. The
11
+ field a CI gate reads is this one, so it is the field that must be hardest to
12
+ fool.
13
+
14
+ The policy is deliberately a *list of blockers* rather than a boolean with a
15
+ reason attached: a reader who is told "not production ready" immediately asks
16
+ why, and an image can fail for several independent causes at once. Every
17
+ blocker names itself in the reader's terms, and the same list feeds the
18
+ terminal, `--format json` and every exporter.
19
+
20
+ **Absence of evidence blocks; it does not excuse.** An unknown EOL status
21
+ does not prove the image is supported, an unresolved digest does not prove
22
+ the tag is stable, and a missing second scanner does not prove the first one
23
+ was right. None of those *penalise* the score -- there is nothing to penalise
24
+ -- but none of them may be spent as if it were a positive finding either.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ from dataclasses import dataclass, field
30
+ from enum import StrEnum
31
+
32
+ from dockerls.domain.value_objects.confidence import Confidence, confidence_rank
33
+ from dockerls.domain.value_objects.security_tier import PRODUCTION_READY_TIERS, Tier
34
+ from dockerls.domain.value_objects.tristate import Tristate
35
+
36
+
37
+ class BlockingReason(StrEnum):
38
+ """Why an image is not production ready. Stable codes, so a pipeline can
39
+ branch on them without parsing prose."""
40
+
41
+ NOT_MEASURED = "NOT_MEASURED"
42
+ LOW_CONFIDENCE = "LOW_CONFIDENCE"
43
+ END_OF_LIFE = "END_OF_LIFE"
44
+ CRITICAL_FINDINGS = "CRITICAL_FINDINGS"
45
+ HIGH_FINDINGS = "HIGH_FINDINGS"
46
+ UNFIXABLE_CRITICAL = "UNFIXABLE_CRITICAL"
47
+ SCANNER_DIVERGENCE = "SCANNER_DIVERGENCE"
48
+ TIER_TOO_LOW = "TIER_TOO_LOW"
49
+
50
+
51
+ #: What each code means where a human reads it.
52
+ EXPLANATIONS: dict[BlockingReason, str] = {
53
+ BlockingReason.NOT_MEASURED: (
54
+ "the scan did not complete, so nothing about this image was measured"
55
+ ),
56
+ BlockingReason.LOW_CONFIDENCE: (
57
+ "the evidence behind this result has a material problem (see confidence reasons)"
58
+ ),
59
+ BlockingReason.END_OF_LIFE: "this release is end-of-life and will not receive security fixes",
60
+ BlockingReason.CRITICAL_FINDINGS: "CRITICAL vulnerabilities are present",
61
+ BlockingReason.HIGH_FINDINGS: "HIGH vulnerabilities are present",
62
+ BlockingReason.UNFIXABLE_CRITICAL: "a CRITICAL vulnerability has no fix available",
63
+ BlockingReason.SCANNER_DIVERGENCE: (
64
+ "two scanners disagreed materially and the disagreement is unresolved"
65
+ ),
66
+ BlockingReason.TIER_TOO_LOW: "the security tier is below the production threshold",
67
+ }
68
+
69
+ #: Confidence below which no image is production ready, whatever its score.
70
+ #: MEDIUM is the floor rather than HIGH because HIGH requires a second
71
+ #: scanner, and refusing every single-scanner installation would make the
72
+ #: verdict a statement about the operator's toolchain instead of about the
73
+ #: image.
74
+ MINIMUM_CONFIDENCE = Confidence.MEDIUM
75
+
76
+
77
+ @dataclass(frozen=True)
78
+ class ReadinessInputs:
79
+ """Everything the decision depends on, stated explicitly.
80
+
81
+ A frozen record rather than a reference to the analysis DTO: the domain
82
+ must not depend on the application layer, and listing the inputs is what
83
+ makes the rule auditable without reading its callers.
84
+ """
85
+
86
+ tier: Tier
87
+ confidence: Confidence
88
+ scan_verified: bool
89
+ #: Tri-state on purpose: `UNKNOWN` does not block (nothing says the
90
+ #: release is dead) and does not excuse either -- it is surfaced, and it
91
+ #: keeps confidence from reaching the top.
92
+ eol: Tristate = Tristate.UNKNOWN
93
+ critical_count: int = 0
94
+ high_count: int = 0
95
+ unfixable_critical_count: int = 0
96
+ max_critical: int = 0
97
+ max_high: int = 0
98
+ has_material_divergence: bool = False
99
+
100
+
101
+ @dataclass(frozen=True)
102
+ class ProductionReadiness:
103
+ """Applies the policy and reports every reason it failed."""
104
+
105
+ inputs: ReadinessInputs
106
+ blockers: list[BlockingReason] = field(default_factory=list)
107
+
108
+ @classmethod
109
+ def evaluate(cls, inputs: ReadinessInputs) -> ProductionReadiness:
110
+ blockers: list[BlockingReason] = []
111
+
112
+ # Measurement first. Everything below is a statement about findings,
113
+ # and a statement about findings presupposes that somebody looked.
114
+ if not inputs.scan_verified:
115
+ blockers.append(BlockingReason.NOT_MEASURED)
116
+ if confidence_rank(inputs.confidence) < confidence_rank(MINIMUM_CONFIDENCE):
117
+ blockers.append(BlockingReason.LOW_CONFIDENCE)
118
+
119
+ if inputs.eol.is_true:
120
+ blockers.append(BlockingReason.END_OF_LIFE)
121
+ if inputs.critical_count > inputs.max_critical:
122
+ blockers.append(BlockingReason.CRITICAL_FINDINGS)
123
+ if inputs.high_count > inputs.max_high:
124
+ blockers.append(BlockingReason.HIGH_FINDINGS)
125
+ if inputs.unfixable_critical_count > 0:
126
+ blockers.append(BlockingReason.UNFIXABLE_CRITICAL)
127
+ if inputs.has_material_divergence:
128
+ blockers.append(BlockingReason.SCANNER_DIVERGENCE)
129
+ if inputs.tier not in PRODUCTION_READY_TIERS:
130
+ blockers.append(BlockingReason.TIER_TOO_LOW)
131
+
132
+ return cls(inputs=inputs, blockers=blockers)
133
+
134
+ @property
135
+ def is_ready(self) -> bool:
136
+ return not self.blockers
137
+
138
+ @property
139
+ def reasons(self) -> list[str]:
140
+ """The blockers in the reader's terms, in the order they were found."""
141
+ return [EXPLANATIONS[reason] for reason in self.blockers]
142
+
143
+ @property
144
+ def codes(self) -> list[str]:
145
+ return [reason.value for reason in self.blockers]
@@ -0,0 +1,211 @@
1
+ """A cadeia entre o que entrou no build e o que saiu dele.
2
+
3
+ Um relatório de segurança afirma coisas sobre uma imagem. Sem procedência, a
4
+ afirmação não é verificável: dois builds do mesmo `--tag` produziam relatórios
5
+ indistinguíveis mesmo partindo de Dockerfiles diferentes, e nada ligava o
6
+ scan ao artefato que ele mediu. Numa cadeia de fornecimento, "nós escaneamos
7
+ essa imagem" sem digest é uma frase sobre nada.
8
+
9
+ O registro tem duas metades, e as duas existem por um motivo distinto:
10
+
11
+ * **antes do build** -- digest do Dockerfile, digest do contexto, digests das
12
+ imagens base declaradas nos `FROM`, e a revisão do repositório. Isto
13
+ responde "o que foi construído".
14
+ * **depois do build** -- id da imagem, digest do manifesto publicado, e a
15
+ identidade do scanner que a mediu. Isto responde "o que saiu, e quem
16
+ atestou".
17
+
18
+ A parte que faz disto controle e não decoração é a verificação entre as duas:
19
+ o Dockerfile e o contexto são digeridos de novo **depois** do build, e se
20
+ mudaram no meio do caminho o registro é marcado como quebrado. Um build cuja
21
+ entrada mudou enquanto ele acontecia não produziu a imagem que o relatório
22
+ descreve -- seja por edição concorrente, seja porque alguém quis exatamente
23
+ isso. É o mesmo princípio que governa o resto desta ferramenta: uma coisa que
24
+ não pôde ser verificada não é apresentada como verificada.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ from dataclasses import dataclass, field
30
+ from enum import StrEnum
31
+
32
+
33
+ class ProvenanceStatus(StrEnum):
34
+ """O que o registro pode afirmar sobre si mesmo."""
35
+
36
+ #: Entrada e saída digeridas, e a entrada não mudou durante o build.
37
+ VERIFIED = "VERIFIED"
38
+ #: O build aconteceu, mas algo não pôde ser digerido (contexto grande
39
+ #: demais, arquivo ilegível). Não é uma acusação -- é a ausência de prova.
40
+ INCOMPLETE = "INCOMPLETE"
41
+ #: O Dockerfile ou o contexto mudaram entre o início e o fim do build.
42
+ #: A imagem existe; o que ela não tem é uma entrada conhecida.
43
+ INPUT_CHANGED = "INPUT_CHANGED"
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class SourceDigests:
48
+ """O que entrou. Medido antes do `docker build` começar."""
49
+
50
+ dockerfile: str = ""
51
+ context: str = ""
52
+ context_files: int = 0
53
+ #: `FROM` declarado -> digest resolvido, quando resolvível. Uma base sem
54
+ #: digest é uma tag móvel, e dizer isso é mais útil do que omitir.
55
+ base_images: dict[str, str] = field(default_factory=dict)
56
+ git_revision: str = ""
57
+ #: Repositório com alterações não commitadas no momento do build. O commit
58
+ #: sozinho mentiria sobre o que gerou a imagem.
59
+ git_dirty: bool = False
60
+
61
+
62
+ @dataclass(frozen=True)
63
+ class ArtifactDigests:
64
+ """O que saiu. Medido depois do build, e depois do push quando há um."""
65
+
66
+ image_id: str = ""
67
+ #: Digest do manifesto no registry. Só existe após o push -- é o único
68
+ #: identificador que outra máquina consegue usar para puxar exatamente
69
+ #: esta imagem.
70
+ repo_digest: str = ""
71
+ published_reference: str = ""
72
+ scanner: str = ""
73
+
74
+
75
+ @dataclass(frozen=True)
76
+ class BuildProvenance:
77
+ """Procedência completa de um build, pronta para ser arquivada."""
78
+
79
+ tag: str
80
+ source: SourceDigests = field(default_factory=SourceDigests)
81
+ artifact: ArtifactDigests = field(default_factory=ArtifactDigests)
82
+ #: Digests recalculados após o build. Iguais aos de `source` num build
83
+ #: íntegro.
84
+ source_after: SourceDigests = field(default_factory=SourceDigests)
85
+ started_at: str = ""
86
+ finished_at: str = ""
87
+
88
+ @property
89
+ def status(self) -> ProvenanceStatus:
90
+ if not self.source.dockerfile or not self.source_after.dockerfile:
91
+ return ProvenanceStatus.INCOMPLETE
92
+ if (
93
+ self.source.dockerfile != self.source_after.dockerfile
94
+ or self.source.context != self.source_after.context
95
+ ):
96
+ return ProvenanceStatus.INPUT_CHANGED
97
+ if not self.artifact.image_id:
98
+ return ProvenanceStatus.INCOMPLETE
99
+ return ProvenanceStatus.VERIFIED
100
+
101
+ @property
102
+ def is_verified(self) -> bool:
103
+ return self.status is ProvenanceStatus.VERIFIED
104
+
105
+ def explain(self) -> str:
106
+ """Uma frase que um humano usa para decidir se confia no registro."""
107
+ if self.status is ProvenanceStatus.INPUT_CHANGED:
108
+ changed = []
109
+ if self.source.dockerfile != self.source_after.dockerfile:
110
+ changed.append("the Dockerfile")
111
+ if self.source.context != self.source_after.context:
112
+ changed.append("the build context")
113
+ return (
114
+ f"{' and '.join(changed)} changed during the build: the image exists, "
115
+ "but does not correspond to the input measured at the start"
116
+ )
117
+ if self.status is ProvenanceStatus.INCOMPLETE:
118
+ return (
119
+ "provenance incomplete: part of the input or the output could not be "
120
+ "digested, so the chain does not close"
121
+ )
122
+ return "input and output digested, and the input did not change during the build"
123
+
124
+ @staticmethod
125
+ def from_dict(raw: object) -> BuildProvenance:
126
+ """Reconstrói o documento arquivado, para conferi-lo depois.
127
+
128
+ Um documento é uma afirmação sobre um build que já terminou, e quem o
129
+ lê -- um passo de CI, uma revisão de incidente -- precisa recalcular o
130
+ `status` a partir dos digests, e não aceitar o `status` gravado. Um
131
+ campo `"status": "VERIFIED"` num arquivo JSON é editável por qualquer
132
+ um; a comparação entre `source` e `source_after_build` não é.
133
+
134
+ O que não parsear vira campo vazio, o que leva o status a
135
+ `INCOMPLETE` -- nunca a `VERIFIED` por omissão.
136
+ """
137
+ if not isinstance(raw, dict):
138
+ return BuildProvenance(tag="")
139
+ source = _mapping(raw.get("source"))
140
+ after = _mapping(raw.get("source_after_build"))
141
+ artifact = _mapping(raw.get("artifact"))
142
+ bases = source.get("base_images")
143
+ return BuildProvenance(
144
+ tag=_text(raw.get("tag")),
145
+ source=SourceDigests(
146
+ dockerfile=_text(source.get("dockerfile_sha256")),
147
+ context=_text(source.get("context_sha256")),
148
+ context_files=_count(source.get("context_files")),
149
+ base_images=(
150
+ {str(k): str(v) for k, v in bases.items()} if isinstance(bases, dict) else {}
151
+ ),
152
+ git_revision=_text(source.get("git_revision")),
153
+ git_dirty=source.get("git_dirty") is True,
154
+ ),
155
+ source_after=SourceDigests(
156
+ dockerfile=_text(after.get("dockerfile_sha256")),
157
+ context=_text(after.get("context_sha256")),
158
+ ),
159
+ artifact=ArtifactDigests(
160
+ image_id=_text(artifact.get("image_id")),
161
+ repo_digest=_text(artifact.get("repo_digest")),
162
+ published_reference=_text(artifact.get("published_reference")),
163
+ scanner=_text(artifact.get("scanner")),
164
+ ),
165
+ started_at=_text(raw.get("started_at")),
166
+ finished_at=_text(raw.get("finished_at")),
167
+ )
168
+
169
+ def to_dict(self) -> dict[str, object]:
170
+ """O documento arquivado junto do relatório.
171
+
172
+ Formato próprio, e propositalmente plano: um consumidor precisa
173
+ conseguir comparar dois builds com um `diff` sem instalar nada.
174
+ """
175
+ return {
176
+ "tag": self.tag,
177
+ "status": str(self.status),
178
+ "explanation": self.explain(),
179
+ "started_at": self.started_at,
180
+ "finished_at": self.finished_at,
181
+ "source": {
182
+ "dockerfile_sha256": self.source.dockerfile,
183
+ "context_sha256": self.source.context,
184
+ "context_files": self.source.context_files,
185
+ "base_images": dict(self.source.base_images),
186
+ "git_revision": self.source.git_revision,
187
+ "git_dirty": self.source.git_dirty,
188
+ },
189
+ "source_after_build": {
190
+ "dockerfile_sha256": self.source_after.dockerfile,
191
+ "context_sha256": self.source_after.context,
192
+ },
193
+ "artifact": {
194
+ "image_id": self.artifact.image_id,
195
+ "repo_digest": self.artifact.repo_digest,
196
+ "published_reference": self.artifact.published_reference,
197
+ "scanner": self.artifact.scanner,
198
+ },
199
+ }
200
+
201
+
202
+ def _mapping(value: object) -> dict[str, object]:
203
+ return value if isinstance(value, dict) else {}
204
+
205
+
206
+ def _text(value: object) -> str:
207
+ return value.strip() if isinstance(value, str) else ""
208
+
209
+
210
+ def _count(value: object) -> int:
211
+ return value if isinstance(value, int) and not isinstance(value, bool) and value >= 0 else 0
@@ -0,0 +1,188 @@
1
+ """O que muda de superfície entre duas receitas de imagem base.
2
+
3
+ O menu do `base-image` faz a pessoa escolher família, runtime e pacotes, e
4
+ cada escolha custa alguma coisa. O custo aparece na hora de marcar o pacote,
5
+ uma linha por vez -- o que não ajuda em nada na pergunta que de fato se faz:
6
+ "alpine ou debian para isto?". Comparar exigia gerar os dois Dockerfiles e ler
7
+ os dois lado a lado, contando pacotes na mão.
8
+
9
+ Este módulo responde a pergunta como diferença: o que a receita B tem que a A
10
+ não tem, o que ela perde, e o que cada uma dessas trocas significa. É puro --
11
+ recebe duas receitas e devolve o delta, sem tocar em registry, disco ou build.
12
+
13
+ **O que ele deliberadamente não faz é eleger uma vencedora.** Contar pacotes
14
+ não mede vulnerabilidade: uma base com menos pacotes e um deles desatualizado
15
+ é pior do que uma com mais pacotes e todos corrigidos, e esta ferramenta
16
+ inteira é construída sobre a recusa de apresentar como medido o que não foi
17
+ medido. O delta descreve as trocas com precisão e manda escanear as duas -- que
18
+ é a única coisa que responde de verdade.
19
+
20
+ A troca de família tem destaque próprio porque é a única que muda o contrato
21
+ binário: alpine é musl, debian e ubuntu são glibc. Uma dependência compilada
22
+ sem roda musllinux não instala do outro lado, e descobrir isso no diff custa
23
+ segundos; descobrir no build de produção custa a janela de deploy.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ from dataclasses import dataclass, field
29
+
30
+ from dockerls.domain.value_objects.base_recipe import PACKAGE_CATALOG, BaseRecipe
31
+
32
+ _BY_KEY = {choice.key: choice for choice in PACKAGE_CATALOG}
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class PackageDelta:
37
+ """Um pacote que existe só de um lado, com o que ele traz junto."""
38
+
39
+ key: str
40
+ purpose: str
41
+ cost: str
42
+
43
+ @staticmethod
44
+ def of(key: str) -> PackageDelta:
45
+ choice = _BY_KEY.get(key)
46
+ if choice is None:
47
+ # Pacote fora do catálogo não deveria chegar aqui (a receita
48
+ # valida antes), mas o diff não é lugar de levantar: descrever o
49
+ # que não se conhece como desconhecido é mais útil que estourar.
50
+ return PackageDelta(key=key, purpose="not catalogued", cost="unknown")
51
+ return PackageDelta(key=choice.key, purpose=choice.purpose, cost=choice.cost)
52
+
53
+ def to_dict(self) -> dict[str, str]:
54
+ return {"package": self.key, "purpose": self.purpose, "cost": self.cost}
55
+
56
+
57
+ @dataclass(frozen=True)
58
+ class RecipeDiff:
59
+ """As diferenças entre duas receitas, da esquerda para a direita."""
60
+
61
+ left: BaseRecipe
62
+ right: BaseRecipe
63
+ added: tuple[PackageDelta, ...] = field(default_factory=tuple)
64
+ removed: tuple[PackageDelta, ...] = field(default_factory=tuple)
65
+
66
+ @property
67
+ def family_changed(self) -> bool:
68
+ return self.left.family is not self.right.family
69
+
70
+ @property
71
+ def libc_changed(self) -> bool:
72
+ """A troca que quebra binário compilado, e não só tamanho de imagem."""
73
+ return self.left.family.libc != self.right.family.libc
74
+
75
+ @property
76
+ def runtime_changed(self) -> bool:
77
+ return self.left.runtime is not self.right.runtime
78
+
79
+ @property
80
+ def manager_strip_changed(self) -> bool:
81
+ return self.left.strip_bundled_manager != self.right.strip_bundled_manager
82
+
83
+ @property
84
+ def pinning_changed(self) -> bool:
85
+ return bool(self.left.digest) != bool(self.right.digest)
86
+
87
+ @property
88
+ def has_changes(self) -> bool:
89
+ return bool(
90
+ self.added
91
+ or self.removed
92
+ or self.family_changed
93
+ or self.runtime_changed
94
+ or self.manager_strip_changed
95
+ or self.pinning_changed
96
+ )
97
+
98
+ def notes(self) -> list[str]:
99
+ """As trocas que não são pacote, ditas em uma linha cada."""
100
+ lines: list[str] = []
101
+ both_install = self.left.family.installs_packages and self.right.family.installs_packages
102
+ if self.libc_changed:
103
+ lines.append(
104
+ f"libc changes from {self.left.family.libc} to "
105
+ f"{self.right.family.libc}: compiled dependencies need a wheel for the "
106
+ "new one, or they get built from source -- and some simply do not build"
107
+ )
108
+ elif self.family_changed:
109
+ lines.append(
110
+ f"family changes from {self.left.family} to {self.right.family}, "
111
+ "keeping the same libc"
112
+ )
113
+ if self.runtime_changed:
114
+ lines.append(f"runtime changes from {self.left.runtime} to {self.right.runtime}")
115
+ if not both_install:
116
+ distroless = (
117
+ self.right.family if not self.right.family.installs_packages else self.left.family
118
+ )
119
+ lines.append(
120
+ f"{distroless} has no package manager and no shell: nothing can be "
121
+ "installed into it afterwards, and no `docker exec` will work"
122
+ )
123
+ # Numa distroless não há gerenciador embutido para remover: dizer que
124
+ # um lado "remove e o outro não" descreveria uma diferença que não
125
+ # existe, e a nota do distroless logo acima já cobre o caso.
126
+ if self.manager_strip_changed and both_install:
127
+ quem = "the right" if self.right.strip_bundled_manager else "the left"
128
+ lines.append(
129
+ f"only {quem} removes the package manager the official image ships -- "
130
+ "the largest surface difference between the two, and it does not show "
131
+ "up in a package count"
132
+ )
133
+ if self.pinning_changed:
134
+ solta = "the right" if not self.right.digest else "the left"
135
+ lines.append(
136
+ f"{solta} sits on a moving tag, with no digest: two builds of the same "
137
+ "file can produce different images"
138
+ )
139
+ return lines
140
+
141
+ def verdict(self) -> str:
142
+ """Por que este diff não elege uma vencedora."""
143
+ return (
144
+ "this is a content diff, not a vulnerability diff: counting packages does "
145
+ "not measure CVEs, and the only answer to which of the two is safer comes "
146
+ "from scanning both. Build them and run `dockerls scan` on each"
147
+ )
148
+
149
+ def to_dict(self) -> dict[str, object]:
150
+ return {
151
+ "left": _describe(self.left),
152
+ "right": _describe(self.right),
153
+ "added": [d.to_dict() for d in self.added],
154
+ "removed": [d.to_dict() for d in self.removed],
155
+ "libc_changed": self.libc_changed,
156
+ "notes": self.notes(),
157
+ "verdict": self.verdict(),
158
+ }
159
+
160
+
161
+ def compare(left: BaseRecipe, right: BaseRecipe) -> RecipeDiff:
162
+ """As diferenças da receita `left` para a `right`."""
163
+ esquerda = set(left.packages)
164
+ direita = set(right.packages)
165
+ return RecipeDiff(
166
+ left=left,
167
+ right=right,
168
+ added=tuple(PackageDelta.of(k) for k in sorted(direita - esquerda)),
169
+ removed=tuple(PackageDelta.of(k) for k in sorted(esquerda - direita)),
170
+ )
171
+
172
+
173
+ def _describe(recipe: BaseRecipe) -> dict[str, object]:
174
+ try:
175
+ reference = recipe.base.reference
176
+ except Exception:
177
+ # Combinação sem imagem publicada: o diff ainda descreve o resto, e
178
+ # `validate()` é quem recusa a receita impossível.
179
+ reference = f"{recipe.runtime} on {recipe.family} (no published image)"
180
+ return {
181
+ "family": str(recipe.family),
182
+ "runtime": str(recipe.runtime),
183
+ "libc": recipe.family.libc,
184
+ "reference": reference,
185
+ "packages": list(recipe.packages),
186
+ "pinned": bool(recipe.digest),
187
+ "strips_bundled_manager": recipe.strip_bundled_manager,
188
+ }