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,318 @@
1
+ """Entidades para análise e validação de Dockerfiles."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from enum import StrEnum
7
+ from typing import Any
8
+
9
+ from dockerls.domain.security_controls import mapping_for, references_for
10
+
11
+
12
+ class ValidationStatus(StrEnum):
13
+ """Status de uma validação de Dockerfile."""
14
+
15
+ # "a verificação passou", não uma senha.
16
+ PASS = "PASS" # nosec B105 # noqa: S105
17
+ WARN = "WARN"
18
+ FAIL = "FAIL"
19
+ SKIP = "SKIP"
20
+
21
+
22
+ class SeverityLevel(StrEnum):
23
+ """Níveis de severidade para regras de segurança."""
24
+
25
+ CRITICAL = "CRITICAL"
26
+ HIGH = "HIGH"
27
+ MEDIUM = "MEDIUM"
28
+ LOW = "LOW"
29
+ INFO = "INFO"
30
+
31
+
32
+ @dataclass
33
+ class ValidationCheck:
34
+ """Resultado de uma única verificação de validação."""
35
+
36
+ check: str
37
+ status: ValidationStatus
38
+ message: str
39
+ severity: SeverityLevel = SeverityLevel.INFO
40
+ line: int | None = None
41
+ rule_id: str | None = None
42
+ fix_suggestion: str | None = None
43
+ details: dict[str, Any] = field(default_factory=dict)
44
+
45
+ @property
46
+ def references(self) -> list[str]:
47
+ """Published controls this finding implements, as citable strings.
48
+
49
+ Empty when the rule is DockerLs's own guidance rather than a
50
+ published control -- which the renderers state, instead of leaving
51
+ the reader to assume a citation was merely omitted.
52
+ """
53
+ return references_for(self.rule_id)
54
+
55
+ @property
56
+ def rationale(self) -> str:
57
+ """Why the rule matters, in terms of what an attacker gains."""
58
+ mapping = mapping_for(self.rule_id)
59
+ return mapping.rationale if mapping else ""
60
+
61
+ def model_dump(self) -> dict[str, Any]:
62
+ """Retorna dicionário serializável."""
63
+ return {
64
+ "check": self.check,
65
+ "status": self.status.value,
66
+ "message": self.message,
67
+ "severity": self.severity.value,
68
+ "line": self.line,
69
+ "rule_id": self.rule_id,
70
+ # The published controls this rule implements, resolved from the
71
+ # catalogue rather than stored per check: the mapping is static,
72
+ # and duplicating it into every finding is how the two drift.
73
+ # Emitted in the payload because a consumer mapping findings onto
74
+ # a compliance programme should not have to know the catalogue.
75
+ "references": self.references,
76
+ "rationale": self.rationale,
77
+ "fix_suggestion": self.fix_suggestion,
78
+ "details": self.details,
79
+ }
80
+
81
+
82
+ @dataclass
83
+ class HardeningRule:
84
+ """Regra de hardening sugerida para um Dockerfile."""
85
+
86
+ priority: SeverityLevel
87
+ title: str
88
+ description: str
89
+ current_state: str
90
+ suggested_fix: str
91
+ reason: str
92
+ line: int | None = None
93
+ dockerfile_snippet: str | None = None
94
+
95
+ def model_dump(self) -> dict[str, Any]:
96
+ """Retorna dicionário serializável."""
97
+ return {
98
+ "priority": self.priority.value,
99
+ "title": self.title,
100
+ "description": self.description,
101
+ "current_state": self.current_state,
102
+ "suggested_fix": self.suggested_fix,
103
+ "reason": self.reason,
104
+ "line": self.line,
105
+ "dockerfile_snippet": self.dockerfile_snippet,
106
+ }
107
+
108
+
109
+ @dataclass
110
+ class DockerfileValidationResult:
111
+ """Resultado da validação completa de um Dockerfile."""
112
+
113
+ dockerfile_path: str
114
+ passed: int = 0
115
+ warnings: int = 0
116
+ errors: int = 0
117
+ skipped: int = 0
118
+ checks: list[ValidationCheck] = field(default_factory=list)
119
+ recommendations: list[HardeningRule] = field(default_factory=list)
120
+ metadata: dict[str, Any] = field(default_factory=dict)
121
+
122
+ @property
123
+ def is_valid(self) -> bool:
124
+ """True se não houver erros críticos."""
125
+ return self.errors == 0
126
+
127
+ @property
128
+ def total_checks(self) -> int:
129
+ """Total de verificações realizadas."""
130
+ return self.passed + self.warnings + self.errors + self.skipped
131
+
132
+ def add_check(self, check: ValidationCheck) -> None:
133
+ """Adiciona um resultado de verificação."""
134
+ self.checks.append(check)
135
+ if check.status == ValidationStatus.PASS:
136
+ self.passed += 1
137
+ elif check.status == ValidationStatus.WARN:
138
+ self.warnings += 1
139
+ elif check.status == ValidationStatus.FAIL:
140
+ self.errors += 1
141
+ elif check.status == ValidationStatus.SKIP:
142
+ self.skipped += 1
143
+
144
+ def model_dump(self) -> dict[str, Any]:
145
+ """Retorna dicionário serializável."""
146
+ return {
147
+ "dockerfile_path": self.dockerfile_path,
148
+ "passed": self.passed,
149
+ "warnings": self.warnings,
150
+ "errors": self.errors,
151
+ "skipped": self.skipped,
152
+ "total_checks": self.total_checks,
153
+ "is_valid": self.is_valid,
154
+ "checks": [c.model_dump() for c in self.checks],
155
+ "recommendations": [r.model_dump() for r in self.recommendations],
156
+ "metadata": self.metadata,
157
+ }
158
+
159
+
160
+ @dataclass
161
+ class DockerfileInfo:
162
+ """Informações extraídas de um Dockerfile.
163
+
164
+ Os campos de usuário e de base descrevem o **estágio final** -- o único
165
+ que vira a imagem que roda. Um `USER node` num estágio de build não
166
+ protege nada em produção, e por um tempo era exatamente isso que fazia a
167
+ validação dar PASS num container que subia como root.
168
+ """
169
+
170
+ base_images: list[str] = field(default_factory=list)
171
+ stages: int = 1
172
+ has_user_directive: bool = False
173
+ user_name: str | None = None
174
+ user_uid: int | None = None
175
+ # Base do estágio final, já resolvida através de `FROM <alias>`.
176
+ final_base_image: str | None = None
177
+ has_healthcheck: bool = False
178
+ has_labels: bool = False
179
+ labels: dict[str, str] = field(default_factory=dict)
180
+ exposes_ports: list[int] = field(default_factory=list)
181
+ entrypoint: str | None = None
182
+ cmd: str | None = None
183
+ uses_buildkit: bool = False
184
+ has_secrets_in_env: bool = False
185
+ secret_env_vars: list[str] = field(default_factory=list)
186
+ package_managers_used: list[str] = field(default_factory=list)
187
+ cache_cleaned: bool = False
188
+ uses_sudo: bool = False
189
+ uses_latest_tag: bool = False
190
+ copy_commands: list[dict[str, Any]] = field(default_factory=list)
191
+ #: Cada `ADD`, com o que nele é diferente de um `COPY`: origens
192
+ #: remotas, arquivos que serão descompactados sozinhos, e se veio com
193
+ #: `--checksum`.
194
+ add_commands: list[dict[str, Any]] = field(default_factory=list)
195
+ run_commands: list[dict[str, Any]] = field(default_factory=list)
196
+ #: `ARG SEGREDO=valor`. Um ARG **com valor** fica no Dockerfile e no
197
+ #: histórico; um ARG sem valor é parâmetro de build e não é infração.
198
+ has_secrets_in_build_args: bool = False
199
+ secret_build_args: list[str] = field(default_factory=list)
200
+ #: `curl ... | sh`: script da rede executado sem nada tê-lo conferido.
201
+ pipes_remote_script_to_shell: bool = False
202
+ remote_script_lines: list[int] = field(default_factory=list)
203
+ #: `chmod u+s` / `chmod 4755`: binário setuid deixado dentro da imagem.
204
+ sets_setuid_bit: bool = False
205
+ setuid_lines: list[int] = field(default_factory=list)
206
+ #: `RUN --mount=type=secret,id=...`: a forma correta do BuildKit de
207
+ #: passar um segredo para um `RUN`, sem ele sobrar em ENV/ARG nem em
208
+ #: nenhuma camada. Distinto de `has_secrets_in_env`/`_build_args`: usar
209
+ #: isto não desculpa um segredo que ainda esteja em ENV ou ARG.
210
+ uses_secret_mount: bool = False
211
+ secret_mount_ids: list[str] = field(default_factory=list)
212
+ #: Pacotes instalados sem versão fixada -- `apt-get install curl` sem
213
+ #: `=<versão>`, `apk add` sem `=`, `pip install` sem `==`, `npm install`
214
+ #: sem `@<versão>`. Reprodutibilidade de build, não confidencialidade:
215
+ #: a mesma linha pode instalar bytes diferentes amanhã.
216
+ unpinned_packages: list[dict[str, Any]] = field(default_factory=list)
217
+ #: `npm install`/`npm ci` presente, mas sem `package-lock.json` ao lado
218
+ #: do Dockerfile -- sem lockfile, nem a versão instalada localmente é
219
+ #: reprodutível no build seguinte.
220
+ npm_lockfile_missing: bool = False
221
+ #: `ARG TARGETPLATFORM`/`TARGETARCH`/`BUILDPLATFORM`, ou `FROM
222
+ #: --platform=...`: o Dockerfile declara ciência de build multi-arch.
223
+ declares_multi_arch: bool = False
224
+ platform_args: list[str] = field(default_factory=list)
225
+ raw_lines: list[str] = field(default_factory=list)
226
+
227
+ def model_dump(self) -> dict[str, Any]:
228
+ """Retorna dicionário serializável."""
229
+ return {
230
+ "base_images": self.base_images,
231
+ "final_base_image": self.final_base_image,
232
+ "stages": self.stages,
233
+ "has_user_directive": self.has_user_directive,
234
+ "user_name": self.user_name,
235
+ "user_uid": self.user_uid,
236
+ "has_healthcheck": self.has_healthcheck,
237
+ "has_labels": self.has_labels,
238
+ "labels": self.labels,
239
+ "exposes_ports": self.exposes_ports,
240
+ "entrypoint": self.entrypoint,
241
+ "cmd": self.cmd,
242
+ "uses_buildkit": self.uses_buildkit,
243
+ "has_secrets_in_env": self.has_secrets_in_env,
244
+ "secret_env_vars": self.secret_env_vars,
245
+ "package_managers_used": self.package_managers_used,
246
+ "cache_cleaned": self.cache_cleaned,
247
+ "uses_sudo": self.uses_sudo,
248
+ "uses_latest_tag": self.uses_latest_tag,
249
+ "copy_commands": self.copy_commands,
250
+ "add_commands": self.add_commands,
251
+ "run_commands": self.run_commands,
252
+ "has_secrets_in_build_args": self.has_secrets_in_build_args,
253
+ "secret_build_args": self.secret_build_args,
254
+ "pipes_remote_script_to_shell": self.pipes_remote_script_to_shell,
255
+ "sets_setuid_bit": self.sets_setuid_bit,
256
+ "uses_secret_mount": self.uses_secret_mount,
257
+ "secret_mount_ids": self.secret_mount_ids,
258
+ "unpinned_packages": self.unpinned_packages,
259
+ "npm_lockfile_missing": self.npm_lockfile_missing,
260
+ "declares_multi_arch": self.declares_multi_arch,
261
+ "platform_args": self.platform_args,
262
+ }
263
+
264
+
265
+ @dataclass
266
+ class DockerfileAnalysis:
267
+ """Análise completa de um Dockerfile."""
268
+
269
+ info: DockerfileInfo
270
+ validation: DockerfileValidationResult
271
+ security_score: int = 0
272
+ security_tier: str = "C"
273
+ #: Se este Dockerfile parece derivado de um dos templates hardened
274
+ #: (`--base`), alterado à mão depois. Puramente informativo -- não
275
+ #: entra no score nem no tier, que continuam medindo o Dockerfile como
276
+ #: ele é, não de onde ele veio.
277
+ template_origin: TemplateOriginMatch | None = None
278
+
279
+ @property
280
+ def is_production_ready(self) -> bool:
281
+ """True se o Dockerfile está pronto para produção."""
282
+ return self.security_tier in ("A", "B") and self.validation.is_valid
283
+
284
+ def model_dump(self) -> dict[str, Any]:
285
+ """Retorna dicionário serializável."""
286
+ return {
287
+ "info": self.info.model_dump(),
288
+ "validation": self.validation.model_dump(),
289
+ "security_score": self.security_score,
290
+ "security_tier": self.security_tier,
291
+ "is_production_ready": self.is_production_ready,
292
+ "template_origin": self.template_origin.model_dump() if self.template_origin else None,
293
+ }
294
+
295
+
296
+ @dataclass
297
+ class TemplateOriginMatch:
298
+ """A Dockerfile that looks like one of the 39 hardened templates, edited
299
+ by hand afterwards.
300
+
301
+ `similarity` is a plain line-based ratio (`difflib.SequenceMatcher`),
302
+ not a security judgement -- a 60% match is still worth naming, because
303
+ "this drifted from `node-alpine`" is a more useful starting point than
304
+ treating the file as if it came from nowhere. `diff` carries only the
305
+ lines that differ, unified-diff style, so the reader sees the edit
306
+ instead of two full files.
307
+ """
308
+
309
+ template_name: str
310
+ similarity: float
311
+ diff: list[str] = field(default_factory=list)
312
+
313
+ def model_dump(self) -> dict[str, Any]:
314
+ return {
315
+ "template_name": self.template_name,
316
+ "similarity": self.similarity,
317
+ "diff": self.diff,
318
+ }
@@ -0,0 +1,109 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+ from dockerls.domain.entities.declared_metadata import DeclaredImageMetadata
8
+ from dockerls.domain.value_objects.image_reference import registry_host_of
9
+
10
+ #: Label carried by candidates discovered on Docker Hub. Defined here rather
11
+ #: than written as a literal in each provider so the source registry, the
12
+ #: results table and the repositories cannot disagree about the spelling.
13
+ DOCKER_HUB = "Docker Hub"
14
+
15
+
16
+ class DockerImage(BaseModel):
17
+ name: str
18
+ tag: str
19
+ digest: str = ""
20
+ size_bytes: int = 0
21
+ architecture: str = "amd64"
22
+ available_architectures: list[str] = Field(default_factory=list)
23
+ os: str = "linux"
24
+ last_updated: datetime | None = None
25
+ pull_count: int = 0
26
+ is_official: bool = False
27
+ is_signed: bool = False
28
+ full_reference: str = ""
29
+ # Which catalogue this tag came from: "Docker Hub", "Chainguard",
30
+ # "Distroless". Shown in the results table so a recommendation from a
31
+ # hardened source is identifiable at a glance.
32
+ source: str = DOCKER_HUB
33
+ # What the catalogue that published this image claims about it. Present
34
+ # only for sources that publish build definitions (Docker Hardened
35
+ # Images). A claim, never a measurement -- see DeclaredImageMetadata.
36
+ declared: DeclaredImageMetadata | None = None
37
+
38
+ def model_post_init(self, __context: object) -> None:
39
+ if not self.full_reference:
40
+ self.full_reference = f"{self.name}:{self.tag}"
41
+
42
+ @property
43
+ def registry_host(self) -> str:
44
+ """Registry this image lives on, or "" for Docker Hub.
45
+
46
+ Delegates to the shared rule so the registry this reports and the
47
+ registry the network policy judges can never diverge -- two copies
48
+ of "what counts as a host" is exactly how `localhost/evil` ends up
49
+ guarded in one place and not the other.
50
+ """
51
+ return registry_host_of(self.name)
52
+
53
+ @property
54
+ def pinned_reference(self) -> str:
55
+ """The immutable reference for this image, when one is known.
56
+
57
+ A tag is a moving pointer: `node:22` today and `node:22` next week
58
+ are different bytes, so a recommendation naming only a tag cannot be
59
+ checked against the scan that produced it. When the digest is known
60
+ the pinned form is what should be deployed and what evidence is
61
+ recorded against; without one, the tag is the best available and is
62
+ returned unchanged rather than faked.
63
+ """
64
+ if not self.digest:
65
+ return self.full_reference
66
+ return f"{self.name}@{self.digest}"
67
+
68
+ @property
69
+ def digest_known(self) -> bool:
70
+ return bool(self.digest)
71
+
72
+ @property
73
+ def age_known(self) -> bool:
74
+ """Whether this source told us when the tag was last published.
75
+
76
+ The OCI tag-listing API returns names only, so hardened registries
77
+ usually cannot date their tags. Scoring must treat that as "unknown"
78
+ rather than "ancient", or every Chainguard image is penalised for a
79
+ fact the registry simply did not report.
80
+ """
81
+ return self.last_updated is not None
82
+
83
+ @property
84
+ def age_days(self) -> int:
85
+ if not self.last_updated:
86
+ return 365
87
+ delta = datetime.now(tz=self.last_updated.tzinfo) - self.last_updated
88
+ return max(0, delta.days)
89
+
90
+ @property
91
+ def is_alpine(self) -> bool:
92
+ return "alpine" in self.tag.lower()
93
+
94
+ @property
95
+ def is_distroless(self) -> bool:
96
+ return "distroless" in self.tag.lower() or "distroless" in self.name.lower()
97
+
98
+ _HARDENED_MARKERS = ("chainguard", "cgr.dev", "wolfi", "bitnami")
99
+
100
+ @property
101
+ def is_hardened_source(self) -> bool:
102
+ """True for images from vendors that specialize in minimal,
103
+ security-hardened bases (Chainguard, Wolfi, Bitnami)."""
104
+ name = self.name.lower()
105
+ return any(marker in name for marker in self._HARDENED_MARKERS)
106
+
107
+ @property
108
+ def recently_updated(self) -> bool:
109
+ return self.age_days <= 30
@@ -0,0 +1,137 @@
1
+ """What was actually determined about an image, and who determined it.
2
+
3
+ This is the evidence record that the hardening and attack-surface analyses
4
+ read from. Three rules shape it, and all three exist because the alternative
5
+ is a tool that reports confident nonsense:
6
+
7
+ 1. **Every fact is three-valued.** A property that could not be determined
8
+ is UNKNOWN, and UNKNOWN never earns credit in either direction.
9
+
10
+ 2. **Every fact records its source.** "Runs as non-root" carries a very
11
+ different weight when it comes from the OCI config of a resolved digest
12
+ than when a vendor's build definition claims it. Both are recorded; only
13
+ one is verification.
14
+
15
+ 3. **Nothing is inferred from a name.** An image called `distroless` is not
16
+ evidence of anything. The facts here come from the registry, from the
17
+ scanner, or from a catalogue declaration -- never from a substring.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from enum import StrEnum
23
+
24
+ from pydantic import BaseModel, Field
25
+
26
+ from dockerls.domain.value_objects.tristate import Tristate
27
+
28
+
29
+ class EvidenceSource(StrEnum):
30
+ """Where a fact came from, ordered by how much it is worth.
31
+
32
+ `REGISTRY` and `SCANNER` are measurements of the published artifact.
33
+ `CATALOG` is a vendor's declaration about a build -- useful, auditable,
34
+ and not a measurement. The distinction is carried all the way to the
35
+ JSON output so a consumer can apply its own policy to declared facts.
36
+ """
37
+
38
+ #: OCI image config fetched from the registry at a resolved digest.
39
+ REGISTRY = "registry"
40
+ #: Package inventory observed by a vulnerability scanner.
41
+ SCANNER = "scanner"
42
+ #: A build definition published by the image's vendor.
43
+ CATALOG = "catalog"
44
+ #: Nothing determined this.
45
+ NONE = "none"
46
+
47
+
48
+ class HardeningFacts(BaseModel):
49
+ """Per-image evidence, with the origin of each determined fact.
50
+
51
+ Defaults are all UNKNOWN/empty on purpose: an image nobody inspected
52
+ must read as "nothing is known", never as "nothing was found".
53
+ """
54
+
55
+ # --- Privilege -------------------------------------------------------
56
+ #: Whether the image's *default* execution account is not root. An image
57
+ #: that sets no USER runs as root, so an empty `User` in a config that
58
+ #: was actually fetched is a determined FALSE, not an unknown.
59
+ runs_as_non_root: Tristate = Tristate.UNKNOWN
60
+ #: The configured user string, verbatim ("node", "65532", "" for unset).
61
+ user: str = ""
62
+
63
+ # --- Contents --------------------------------------------------------
64
+ has_shell: Tristate = Tristate.UNKNOWN
65
+ has_package_manager: Tristate = Tristate.UNKNOWN
66
+ has_debug_tools: Tristate = Tristate.UNKNOWN
67
+ #: SUID/SGID binaries cannot be determined without unpacking the
68
+ #: filesystem, which this tool does not do. Kept as a declared field so
69
+ #: the gap is visible rather than silently absent.
70
+ has_setuid: Tristate = Tristate.UNKNOWN
71
+ #: Packages the image is known to contain. None when nothing counted
72
+ #: them -- which is different from zero.
73
+ package_count: int | None = None
74
+
75
+ # --- Runtime configuration -------------------------------------------
76
+ #: Ports the image declares. Empty list means "declared none" only when
77
+ #: the config was fetched; `ports_known` says which case this is.
78
+ exposed_ports: list[int] = Field(default_factory=list)
79
+ has_healthcheck: Tristate = Tristate.UNKNOWN
80
+ entrypoint: list[str] = Field(default_factory=list)
81
+ cmd: list[str] = Field(default_factory=list)
82
+
83
+ # --- Composition -----------------------------------------------------
84
+ layer_count: int | None = None
85
+ size_bytes: int | None = None
86
+ os_family: str = ""
87
+
88
+ #: fact name -> the source that determined it. A fact absent from this
89
+ #: mapping was never determined by anything.
90
+ evidence: dict[str, EvidenceSource] = Field(default_factory=dict)
91
+
92
+ #: True once an OCI config was actually fetched, which is what makes
93
+ #: "no ports declared" and "no USER set" readable as facts.
94
+ config_verified: bool = False
95
+
96
+ #: Places where a catalogue's declaration and the published image
97
+ #: disagree, in the reader's terms. This is the single most valuable
98
+ #: output of comparing declared metadata against a measurement, and it
99
+ #: is surfaced rather than resolved silently: a vendor claiming an image
100
+ #: runs unprivileged while its config says root is exactly the kind of
101
+ #: thing a security tool exists to notice.
102
+ conflicts: list[str] = Field(default_factory=list)
103
+
104
+ def source_of(self, fact: str) -> EvidenceSource:
105
+ return self.evidence.get(fact, EvidenceSource.NONE)
106
+
107
+ def is_verified(self, fact: str) -> bool:
108
+ """Whether `fact` was measured rather than declared.
109
+
110
+ The ranking layer uses this to keep a vendor's claim from carrying
111
+ the same weight as a registry measurement.
112
+ """
113
+ return self.source_of(fact) in (EvidenceSource.REGISTRY, EvidenceSource.SCANNER)
114
+
115
+ @property
116
+ def ports_known(self) -> bool:
117
+ return self.config_verified or bool(self.exposed_ports)
118
+
119
+ @property
120
+ def privileged_ports(self) -> list[int]:
121
+ """Declared ports below 1024.
122
+
123
+ Binding one of these traditionally requires elevated privileges or
124
+ an explicit capability, so an image declaring them is telling us
125
+ something about how it expects to be run.
126
+ """
127
+ return [port for port in self.exposed_ports if port < 1024]
128
+
129
+ @property
130
+ def determined_count(self) -> int:
131
+ """How many facts anything actually determined.
132
+
133
+ Reported alongside every derived score, because a score computed
134
+ over two facts and a score computed over ten are not comparable and
135
+ must not be presented as if they were.
136
+ """
137
+ return len(self.evidence)
@@ -0,0 +1,33 @@
1
+ from __future__ import annotations
2
+
3
+ from enum import StrEnum
4
+
5
+ from pydantic import BaseModel
6
+
7
+
8
+ class ActionType(StrEnum):
9
+ UPGRADE_IMAGE = "UPGRADE_IMAGE"
10
+ SWITCH_TAG = "SWITCH_TAG"
11
+ REBUILD_IMAGE = "REBUILD_IMAGE"
12
+ UPDATE_PACKAGE = "UPDATE_PACKAGE"
13
+ SWITCH_BASE = "SWITCH_BASE"
14
+ WAIT_UPSTREAM = "WAIT_UPSTREAM"
15
+ RESCAN = "RESCAN"
16
+
17
+
18
+ class RemediationStep(BaseModel):
19
+ step_number: int
20
+ action: ActionType
21
+ description: str
22
+ from_value: str = ""
23
+ to_value: str = ""
24
+ expected_impact: str = ""
25
+
26
+
27
+ class Recommendation(BaseModel):
28
+ image_reference: str
29
+ security_score: float
30
+ tier: str
31
+ remediation_score: int
32
+ steps: list[RemediationStep] = []
33
+ summary: str = ""