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,249 @@
1
+ """O `.dockerls-ignore.yaml` dito em OpenVEX, sem inventar a afirmação.
2
+
3
+ Um arquivo de isenção já é um documento VEX em tudo menos no formato: ele
4
+ tem o CVE, tem a justificativa e tem o prazo. O que faltava era emiti-lo
5
+ num formato que o resto do mundo lê -- Trivy e Grype consomem OpenVEX
6
+ nativamente --, para que uma exceção decidida uma vez valesse no pipeline
7
+ inteiro em vez de só dentro desta ferramenta.
8
+
9
+ **A parte difícil, e é onde quase toda implementação erra.** VEX tem quatro
10
+ estados, e o que quase todo mundo emite para uma isenção é `not_affected`.
11
+ Mas `not_affected` é uma afirmação **técnica**: diz que o código vulnerável
12
+ não está presente, ou não é alcançável, ou já está mitigado -- e o padrão
13
+ exige que se diga qual das cinco razões é, de um vocabulário fechado.
14
+
15
+ Uma justificativa em texto livre como "a equipe aceitou o risco até o Q3"
16
+ não é nenhuma das cinco. Traduzi-la para `not_affected` seria transformar
17
+ uma decisão de risco numa afirmação técnica que ninguém fez -- exatamente o
18
+ tipo de substituição que este projeto existe para não cometer.
19
+
20
+ Então:
21
+
22
+ * se a regra **declara** uma das cinco justificativas do padrão, o
23
+ documento sai como `not_affected` com ela;
24
+ * se não declara, sai como `affected` com um `action_statement` carregando
25
+ o texto que a pessoa escreveu. O consumidor vê a exceção, vê o motivo, e
26
+ não recebe uma alegação técnica que a ferramenta inventou.
27
+
28
+ O prazo entra no `action_statement` porque VEX não tem campo para
29
+ expiração: uma isenção sem prazo visível é uma isenção que ninguém revisa.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import json
35
+ from dataclasses import dataclass, field
36
+ from datetime import UTC, datetime
37
+ from enum import StrEnum
38
+ from typing import TYPE_CHECKING, Any
39
+
40
+ if TYPE_CHECKING:
41
+ from collections.abc import Sequence
42
+ from datetime import date
43
+
44
+ #: A versão do vocabulário que este módulo emite.
45
+ OPENVEX_CONTEXT = "https://openvex.dev/ns/v0.2.0"
46
+
47
+
48
+ class VexStatus(StrEnum):
49
+ """Os quatro estados do padrão. Não há um quinto para "risco aceito"."""
50
+
51
+ NOT_AFFECTED = "not_affected"
52
+ AFFECTED = "affected"
53
+ FIXED = "fixed"
54
+ UNDER_INVESTIGATION = "under_investigation"
55
+
56
+
57
+ class VexJustification(StrEnum):
58
+ """O vocabulário fechado que `not_affected` exige.
59
+
60
+ Fechado é o ponto: cada uma destas é uma afirmação verificável sobre o
61
+ código, e é por isso que texto livre não cabe aqui.
62
+ """
63
+
64
+ COMPONENT_NOT_PRESENT = "component_not_present"
65
+ VULNERABLE_CODE_NOT_PRESENT = "vulnerable_code_not_present"
66
+ VULNERABLE_CODE_NOT_IN_EXECUTE_PATH = "vulnerable_code_not_in_execute_path"
67
+ VULNERABLE_CODE_CANNOT_BE_CONTROLLED_BY_ADVERSARY = (
68
+ "vulnerable_code_cannot_be_controlled_by_adversary"
69
+ )
70
+ INLINE_MITIGATIONS_ALREADY_EXIST = "inline_mitigations_already_exist"
71
+
72
+
73
+ def parse_justification(value: str) -> VexJustification | None:
74
+ """A justificativa do padrão, ou None quando o texto não é uma delas.
75
+
76
+ None não é erro: é o caso normal. A maioria das isenções é decisão de
77
+ risco, e o documento sai como `affected` por causa disso.
78
+ """
79
+ try:
80
+ return VexJustification(value.strip().lower())
81
+ except ValueError:
82
+ return None
83
+
84
+
85
+ class InvalidVexStatementError(ValueError):
86
+ """Uma afirmação que o padrão não permite emitir.
87
+
88
+ `ValueError` porque a CLI já trata `ValueError` como erro de uso. Ela é
89
+ levantada na construção, e não na serialização, para que o documento
90
+ inválido nunca chegue a existir.
91
+ """
92
+
93
+
94
+ @dataclass(frozen=True)
95
+ class VexStatement:
96
+ """Uma afirmação sobre um CVE num produto."""
97
+
98
+ cve: str
99
+ status: VexStatus
100
+ justification: VexJustification | None = None
101
+ action_statement: str = ""
102
+
103
+ def __post_init__(self) -> None:
104
+ """`not_affected` sem justificativa é a afirmação vazia que o padrão
105
+ proíbe -- e a que este módulo inteiro existe para não emitir.
106
+
107
+ O OpenVEX exige uma das cinco justificativas para `not_affected`
108
+ justamente porque o estado é uma alegação técnica: sem a
109
+ justificativa ele diz "o CVE não se aplica" sem dizer o que foi
110
+ verificado, e um consumidor que o leia suprime o achado com base em
111
+ nada. `statement_for` nunca constrói isso; o invariante mora aqui
112
+ porque a classe é pública e outro chamador o construiria sem saber.
113
+
114
+ Também recusa um CVE vazio: uma afirmação sobre nenhuma
115
+ vulnerabilidade não é uma afirmação, e num documento ela vira uma
116
+ entrada que casa com o que o consumidor decidir.
117
+ """
118
+ if not self.cve.strip():
119
+ raise InvalidVexStatementError(
120
+ "a VEX statement needs a vulnerability identifier: a statement about "
121
+ "no CVE asserts nothing, and consumers match it against whatever they like"
122
+ )
123
+ if self.status is VexStatus.NOT_AFFECTED and self.justification is None:
124
+ raise InvalidVexStatementError(
125
+ f"'not_affected' for {self.cve} needs one of the five OpenVEX "
126
+ "justifications: without one it claims the vulnerability does not "
127
+ "apply without saying what was checked, and a consumer would suppress "
128
+ "the finding on the strength of nothing. Use 'affected' with an "
129
+ "action_statement to record an accepted risk"
130
+ )
131
+ if self.justification is not None and self.status is not VexStatus.NOT_AFFECTED:
132
+ raise InvalidVexStatementError(
133
+ f"a justification only belongs on 'not_affected'; {self.cve} is "
134
+ f"'{self.status}'. The vocabulary describes why the vulnerable code "
135
+ "cannot be reached, which says nothing about a product that is affected"
136
+ )
137
+
138
+ def to_dict(self, products: Sequence[str], timestamp: str) -> dict[str, Any]:
139
+ statement: dict[str, Any] = {
140
+ "vulnerability": {"name": self.cve},
141
+ "products": [{"@id": product} for product in products],
142
+ "status": str(self.status),
143
+ "timestamp": timestamp,
144
+ }
145
+ if self.justification is not None:
146
+ statement["justification"] = str(self.justification)
147
+ if self.action_statement:
148
+ # `action_statement` é o campo que o padrão prevê para
149
+ # `affected`, e é onde o texto humano cabe sem virar alegação
150
+ # técnica.
151
+ statement["action_statement"] = self.action_statement
152
+ return statement
153
+
154
+
155
+ @dataclass(frozen=True)
156
+ class VexDocument:
157
+ """Um documento OpenVEX pronto para ser lido por outra ferramenta."""
158
+
159
+ author: str
160
+ products: tuple[str, ...]
161
+ statements: tuple[VexStatement, ...]
162
+ timestamp: str = ""
163
+ version: int = 1
164
+
165
+ def to_dict(self) -> dict[str, Any]:
166
+ stamp = self.timestamp or datetime.now(tz=UTC).isoformat()
167
+ return {
168
+ "@context": OPENVEX_CONTEXT,
169
+ # O `@id` precisa ser estável para o mesmo conteúdo e diferente
170
+ # para conteúdos diferentes; derivá-lo do documento é o que
171
+ # torna dois arquivos comparáveis sem abrir os dois.
172
+ "@id": f"https://openvex.dev/docs/dockerls/{self._fingerprint(stamp)}",
173
+ "author": self.author,
174
+ "timestamp": stamp,
175
+ "version": self.version,
176
+ "statements": [s.to_dict(self.products, stamp) for s in self.statements],
177
+ }
178
+
179
+ def to_json(self, *, indent: int = 2) -> str:
180
+ return json.dumps(self.to_dict(), indent=indent, sort_keys=False)
181
+
182
+ def _fingerprint(self, stamp: str) -> str:
183
+ import hashlib
184
+
185
+ material = "|".join(
186
+ [self.author, stamp, *self.products, *(f"{s.cve}:{s.status}" for s in self.statements)]
187
+ )
188
+ return hashlib.sha256(material.encode("utf-8")).hexdigest()[:32]
189
+
190
+
191
+ @dataclass(frozen=True)
192
+ class ExemptionInput:
193
+ """O que uma regra de isenção traz, sem depender do modelo que a leu."""
194
+
195
+ cve: str
196
+ justification: str = ""
197
+ expires: date | None = None
198
+ #: A justificativa do padrão, quando quem escreveu declarou uma.
199
+ vex_justification: str = ""
200
+ extra: dict[str, str] = field(default_factory=dict)
201
+
202
+
203
+ def statement_for(exemption: ExemptionInput) -> VexStatement:
204
+ """Traduz uma isenção, sem transformar risco aceito em alegação técnica."""
205
+ declared = parse_justification(exemption.vex_justification)
206
+ if declared is not None:
207
+ return VexStatement(
208
+ cve=exemption.cve,
209
+ status=VexStatus.NOT_AFFECTED,
210
+ justification=declared,
211
+ action_statement=_with_expiry("", exemption),
212
+ )
213
+ return VexStatement(
214
+ cve=exemption.cve,
215
+ status=VexStatus.AFFECTED,
216
+ action_statement=_with_expiry(
217
+ exemption.justification or "exempted in .dockerls-ignore.yaml, with no reason given",
218
+ exemption,
219
+ ),
220
+ )
221
+
222
+
223
+ def _with_expiry(text: str, exemption: ExemptionInput) -> str:
224
+ """Anexa o prazo, que o VEX não tem campo para carregar.
225
+
226
+ Uma isenção sem prazo visível é uma isenção que ninguém revisa, e o
227
+ prazo é justamente a parte que o arquivo de origem sabe e o documento
228
+ perderia.
229
+ """
230
+ if exemption.expires is None:
231
+ return text
232
+ note = f"exemption expires on {exemption.expires.isoformat()}"
233
+ return f"{text}; {note}" if text else note
234
+
235
+
236
+ def build_document(
237
+ exemptions: Sequence[ExemptionInput],
238
+ *,
239
+ products: Sequence[str],
240
+ author: str,
241
+ timestamp: str = "",
242
+ ) -> VexDocument:
243
+ """Monta o documento a partir das isenções ativas."""
244
+ return VexDocument(
245
+ author=author,
246
+ products=tuple(products),
247
+ statements=tuple(statement_for(e) for e in exemptions),
248
+ timestamp=timestamp,
249
+ )
dockerls/exit_codes.py ADDED
@@ -0,0 +1,23 @@
1
+ """The exit code contract every DockerLs command honours.
2
+
3
+ A pipeline can only branch on these numbers, so they are defined once here
4
+ instead of appearing as integer literals scattered through the CLI. The same
5
+ table is documented in the README under "Exit codes".
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Final
11
+
12
+ # The command ran and nothing violated a policy.
13
+ EXIT_OK: Final[int] = 0
14
+
15
+ # The command could not run to completion: a missing dependency, a network
16
+ # failure, a Dockerfile that does not exist, a failing `docker build`.
17
+ # Nothing was measured, so the result says nothing about security.
18
+ EXIT_ERROR: Final[int] = 1
19
+
20
+ # The command ran fine and the result violates a policy: a validation check
21
+ # failed (`errors > 0`), or `--fail-on` was triggered. This is the code a
22
+ # pipeline gate should treat as "the image is not allowed through".
23
+ EXIT_POLICY: Final[int] = 2
File without changes
@@ -0,0 +1,17 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC, abstractmethod
4
+ from typing import TYPE_CHECKING
5
+
6
+ if TYPE_CHECKING:
7
+ from pathlib import Path
8
+
9
+ from dockerls.application.dto.analysis import AnalysisResult
10
+
11
+
12
+ class ExporterInterface(ABC):
13
+ def export(self, result: AnalysisResult, output_path: Path) -> None:
14
+ output_path.write_bytes(self.export_string(result).encode("utf-8"))
15
+
16
+ @abstractmethod
17
+ def export_string(self, result: AnalysisResult) -> str: ...
@@ -0,0 +1,85 @@
1
+ from __future__ import annotations
2
+
3
+ import csv
4
+ import io
5
+ from typing import TYPE_CHECKING
6
+
7
+ from dockerls.exporters.base import ExporterInterface
8
+
9
+ if TYPE_CHECKING:
10
+ from pathlib import Path
11
+
12
+ from dockerls.application.dto.analysis import AnalysisResult
13
+
14
+
15
+ class CSVExporter(ExporterInterface):
16
+ def export(self, result: AnalysisResult, output_path: Path) -> None:
17
+ output_path.write_bytes(self.export_string(result).encode("utf-8"))
18
+
19
+ def export_string(self, result: AnalysisResult) -> str:
20
+ output = io.StringIO()
21
+ writer = csv.writer(output)
22
+ writer.writerow(
23
+ [
24
+ "Image",
25
+ "Tag",
26
+ "Score",
27
+ "Tier",
28
+ "Critical",
29
+ "High",
30
+ "Medium",
31
+ "Low",
32
+ "Fixable",
33
+ "Remediation Score",
34
+ "EOL",
35
+ "LTS",
36
+ # Appended, never inserted: a consumer indexing by column
37
+ # position keeps working, and one reading the header gets
38
+ # the new dimensions.
39
+ "Source",
40
+ "Digest",
41
+ "Pinned Reference",
42
+ "Hardening",
43
+ "Hardening Coverage",
44
+ "Attack Surface",
45
+ "Confidence",
46
+ "Production Ready",
47
+ "Readiness Blockers",
48
+ "EOL Status",
49
+ "Cross Validation",
50
+ ]
51
+ )
52
+ for a in result.recommendations or result.alternatives:
53
+ writer.writerow(
54
+ [
55
+ a.image.name,
56
+ a.image.tag,
57
+ a.security_score,
58
+ a.tier,
59
+ a.scan.critical_count,
60
+ a.scan.high_count,
61
+ a.scan.medium_count,
62
+ a.scan.low_count,
63
+ a.scan.fixable_count,
64
+ a.remediation_score,
65
+ a.is_eol,
66
+ a.is_lts,
67
+ a.image.source,
68
+ a.image.digest,
69
+ a.pinned_reference,
70
+ # "" rather than 0 when coverage was too thin: a zero
71
+ # here would be read as "no hardening", which is the
72
+ # opposite of "not determined".
73
+ a.hardening.score if a.hardening.reportable else "",
74
+ a.hardening.coverage,
75
+ a.attack_surface.score if a.attack_surface.reportable else "",
76
+ a.confidence.value,
77
+ a.production_ready,
78
+ # Codes, semicolon-separated: a CSV consumer gating on
79
+ # "NOT_MEASURED" should not have to parse a sentence.
80
+ ";".join(a.readiness_blockers),
81
+ a.eol_status.value,
82
+ a.cross_validation,
83
+ ]
84
+ )
85
+ return output.getvalue()
@@ -0,0 +1,31 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ from dockerls.exporters.csv_exporter import CSVExporter
6
+ from dockerls.exporters.html_exporter import HTMLExporter
7
+ from dockerls.exporters.json_exporter import JSONExporter
8
+ from dockerls.exporters.markdown_exporter import MarkdownExporter
9
+ from dockerls.exporters.sarif_exporter import SARIFExporter
10
+
11
+ if TYPE_CHECKING:
12
+ from dockerls.exporters.base import ExporterInterface
13
+
14
+
15
+ class ExporterFactory:
16
+ _exporters: dict[str, type[ExporterInterface]] = {
17
+ "json": JSONExporter,
18
+ "csv": CSVExporter,
19
+ "html": HTMLExporter,
20
+ "markdown": MarkdownExporter,
21
+ "md": MarkdownExporter,
22
+ "sarif": SARIFExporter,
23
+ }
24
+
25
+ @classmethod
26
+ def create(cls, format_name: str) -> ExporterInterface:
27
+ key = format_name.lower()
28
+ if key not in cls._exporters:
29
+ supported = ", ".join(sorted(cls._exporters.keys()))
30
+ raise ValueError(f"Unsupported format: {format_name}. Supported: {supported}")
31
+ return cls._exporters[key]()
@@ -0,0 +1,105 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ from dockerls.exporters.base import ExporterInterface
6
+
7
+ if TYPE_CHECKING:
8
+ from pathlib import Path
9
+
10
+ from dockerls.application.dto.analysis import AnalysisResult, ImageAnalysis
11
+
12
+
13
+ class HTMLExporter(ExporterInterface):
14
+ def export(self, result: AnalysisResult, output_path: Path) -> None:
15
+ output_path.write_bytes(self.export_string(result).encode("utf-8"))
16
+
17
+ def export_string(self, result: AnalysisResult) -> str:
18
+ items = result.recommendations or result.alternatives
19
+ rows = "\n".join(self._row(a) for a in items)
20
+ status = "Baseline Met" if result.baseline_met else "Alternative Recommendations"
21
+ return f"""<!DOCTYPE html>
22
+ <html lang="en">
23
+ <head>
24
+ <meta charset="utf-8">
25
+ <title>DockerLs Report - {_esc(result.query)}</title>
26
+ <style>
27
+ body {{ font-family: system-ui, sans-serif; margin: 2rem; background: #fafafa; color: #1a1a1a; }}
28
+ h1 {{ border-bottom: 2px solid #333; padding-bottom: .5rem; }}
29
+ table {{ border-collapse: collapse; width: 100%; margin: 1rem 0; }}
30
+ .note {{ font-size: 0.9em; opacity: 0.8; }}
31
+ th, td {{ border: 1px solid #ddd; padding: 8px 12px; text-align: left; }}
32
+ th {{ background: #333; color: #fff; }}
33
+ tr:nth-child(even) {{ background: #f2f2f2; }}
34
+ .s {{ color: #16a34a; font-weight: bold; }}
35
+ .a {{ color: #2563eb; font-weight: bold; }}
36
+ .b {{ color: #d97706; font-weight: bold; }}
37
+ .c {{ color: #dc2626; font-weight: bold; }}
38
+ .info {{ background: #fff; padding: 1rem; border: 1px solid #ddd;
39
+ border-radius: 4px; margin: 1rem 0; }}
40
+ </style>
41
+ </head>
42
+ <body>
43
+ <h1>DockerLs Security Report</h1>
44
+ <div class="info">
45
+ <p><strong>Query:</strong> {_esc(result.query)}</p>
46
+ <p><strong>Tags Scanned:</strong> {result.total_tags_scanned}</p>
47
+ <p><strong>Status:</strong> {status}</p>
48
+ </div>
49
+ <table>
50
+ <thead><tr>
51
+ <th>Image</th><th>Source</th><th>Score</th><th>Tier</th>
52
+ <th>Critical</th><th>High</th><th>Medium</th><th>Low</th>
53
+ <th>Fixable</th><th>Remediation</th><th>EOL</th>
54
+ <th title="higher is better">Hardening</th>
55
+ <th title="LOWER is better">Attack Surface</th><th>Confidence</th>
56
+ </tr></thead>
57
+ <tbody>{rows}</tbody>
58
+ </table>
59
+ {self._why(result)}
60
+ <p class="note">Hardening and attack surface are scored over the facts that could be
61
+ determined about each image; "n/a" means too little was determined for a number to
62
+ mean anything. Neither is summed into the security score: a well-configured image with
63
+ CRITICAL findings is a well-configured vulnerable image.</p>
64
+ </body>
65
+ </html>"""
66
+
67
+ def _why(self, result: AnalysisResult) -> str:
68
+ """The case for the top pick, so the ranking is auditable in the report."""
69
+ items = result.recommendations or result.alternatives
70
+ if not items:
71
+ return ""
72
+ best = items[0]
73
+ reasons = "".join(f"<li>{_esc(reason)}</li>" for reason in best.why)
74
+ costs = "".join(f"<li>{_esc(cost)}</li>" for cost in best.trade_offs)
75
+ pinned = (
76
+ f"<p><strong>Pin to:</strong> <code>{_esc(best.pinned_reference)}</code></p>"
77
+ if best.image.digest_known
78
+ else ""
79
+ )
80
+ return (
81
+ f"<h2>Why {_esc(best.image.full_reference)}?</h2>"
82
+ f"<ul>{reasons}</ul>"
83
+ + (f"<h3>Trade-offs</h3><ul>{costs}</ul>" if costs else "")
84
+ + f"<p><strong>Confidence:</strong> {_esc(best.confidence.value)} "
85
+ f"({_esc('; '.join(best.confidence_reasons))})</p>" + pinned
86
+ )
87
+
88
+ def _row(self, a: ImageAnalysis) -> str:
89
+ t = a.tier.lower()
90
+ return (
91
+ f"<tr><td>{_esc(a.image.full_reference)}</td>"
92
+ f"<td>{_esc(a.image.source)}</td><td>{a.security_score}</td>"
93
+ f'<td class="{t}">{a.tier}</td>'
94
+ f"<td>{a.scan.critical_count}</td><td>{a.scan.high_count}</td>"
95
+ f"<td>{a.scan.medium_count}</td><td>{a.scan.low_count}</td>"
96
+ f"<td>{a.scan.fixable_count}</td><td>{a.remediation_score}/100</td>"
97
+ f"<td>{'Yes' if a.is_eol else 'No'}</td>"
98
+ f"<td>{f'{a.hardening.score:.0f}' if a.hardening.reportable else 'n/a'}</td>"
99
+ f"<td>{f'{a.attack_surface.score:.0f}' if a.attack_surface.reportable else 'n/a'}</td>"
100
+ f"<td>{_esc(a.confidence.value)}</td></tr>"
101
+ )
102
+
103
+
104
+ def _esc(s: str) -> str:
105
+ return s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;")
@@ -0,0 +1,19 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from typing import TYPE_CHECKING
5
+
6
+ from dockerls.exporters.base import ExporterInterface
7
+
8
+ if TYPE_CHECKING:
9
+ from pathlib import Path
10
+
11
+ from dockerls.application.dto.analysis import AnalysisResult
12
+
13
+
14
+ class JSONExporter(ExporterInterface):
15
+ def export(self, result: AnalysisResult, output_path: Path) -> None:
16
+ output_path.write_bytes(self.export_string(result).encode("utf-8"))
17
+
18
+ def export_string(self, result: AnalysisResult) -> str:
19
+ return json.dumps(result.model_dump(), indent=2, default=str)
@@ -0,0 +1,84 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ from dockerls.exporters.base import ExporterInterface
6
+
7
+ if TYPE_CHECKING:
8
+ from pathlib import Path
9
+
10
+ from dockerls.application.dto.analysis import AnalysisResult
11
+
12
+
13
+ class MarkdownExporter(ExporterInterface):
14
+ def export(self, result: AnalysisResult, output_path: Path) -> None:
15
+ output_path.write_bytes(self.export_string(result).encode("utf-8"))
16
+
17
+ def export_string(self, result: AnalysisResult) -> str:
18
+ items = result.recommendations or result.alternatives
19
+ status = "Baseline Met" if result.baseline_met else "Alternative Recommendations"
20
+ lines = [
21
+ "# DockerLs Security Report",
22
+ "",
23
+ f"**Query:** {result.query}",
24
+ f"**Tags Scanned:** {result.total_tags_scanned}",
25
+ f"**Status:** {status}",
26
+ "",
27
+ "## Results",
28
+ "",
29
+ "| Image | Source | Score | Tier | Critical | High | Medium | Low | Fixable "
30
+ "| Remediation | EOL | Hardening | Attack Surface | Confidence |",
31
+ "|-------|--------|-------|------|----------|------|--------|-----|---------"
32
+ "|-------------|-----|-----------|----------------|------------|",
33
+ ]
34
+ for a in items:
35
+ eol = "Yes" if a.is_eol else "No"
36
+ hardening = f"{a.hardening.score:.0f}" if a.hardening.reportable else "n/a"
37
+ surface = f"{a.attack_surface.score:.0f}" if a.attack_surface.reportable else "n/a"
38
+ lines.append(
39
+ f"| {a.image.full_reference} | {a.image.source} | {a.security_score} | {a.tier} "
40
+ f"| {a.scan.critical_count} | {a.scan.high_count} "
41
+ f"| {a.scan.medium_count} | {a.scan.low_count} "
42
+ f"| {a.scan.fixable_count} | {a.remediation_score}/100 | {eol} "
43
+ f"| {hardening} | {surface} | {a.confidence.value} |"
44
+ )
45
+
46
+ if items:
47
+ best = items[0]
48
+ lines += ["", "## Why this image", ""]
49
+ lines += [f"- {reason}" for reason in best.why] or ["- (no reasons recorded)"]
50
+ if best.trade_offs:
51
+ lines += ["", "### Trade-offs", ""]
52
+ lines += [f"- {cost}" for cost in best.trade_offs]
53
+ if best.image.digest_known:
54
+ lines += ["", f"**Pin to:** `{best.pinned_reference}`"]
55
+ lines += [
56
+ "",
57
+ f"**Confidence:** {best.confidence.value} ({'; '.join(best.confidence_reasons)})",
58
+ "",
59
+ "> Hardening is scored over the facts that could be determined, and its "
60
+ "coverage is reported with it. It is never summed into the security score: "
61
+ "a well-configured image with CRITICAL findings is a well-configured "
62
+ "vulnerable image.",
63
+ ]
64
+
65
+ if items and items[0].recommendation:
66
+ rec = items[0].recommendation
67
+ lines += [
68
+ "",
69
+ "## Top Recommendation",
70
+ "",
71
+ f"**Image:** {rec.image_reference}",
72
+ f"**Score:** {rec.security_score}",
73
+ f"**Summary:** {rec.summary}",
74
+ ]
75
+ if rec.steps:
76
+ lines += ["", "### Remediation Steps", ""]
77
+ for s in rec.steps:
78
+ desc = s.description
79
+ if s.from_value and s.to_value:
80
+ desc += f" ({s.from_value} -> {s.to_value})"
81
+ lines.append(f"{s.step_number}. {desc}")
82
+
83
+ lines.append("")
84
+ return "\n".join(lines)