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,412 @@
1
+ """A política da organização, escrita uma vez e conferida em todo build.
2
+
3
+ `--fail-on critical` é um portão, mas é um portão que mora na linha de comando
4
+ -- e uma regra que mora na linha de comando é uma regra que cada pipeline
5
+ reescreve à mão. Bastava um `--fail-on high` esquecido num repositório para
6
+ que a política da organização deixasse de valer ali, sem que nada acusasse.
7
+
8
+ Este módulo é a política como dado: um `.dockerls-policy.yaml` versionado junto
9
+ do código, conferido contra o que foi **medido** neste build. A parte aqui é
10
+ pura -- recebe os fatos e devolve as violações -- para que cada regra seja
11
+ testável contra o número exato que a produziu.
12
+
13
+ Três princípios moldam o que existe e o que não existe aqui:
14
+
15
+ * **Só entra o que é mensurável.** Não há regra de "não use pacotes inseguros"
16
+ ou "mantenha a imagem pequena": não há como decidir isso a partir de um
17
+ build, e uma regra que não pode ser avaliada é uma regra que reprova por
18
+ engano ou aprova por omissão. As duas corroem a confiança no portão.
19
+ * **Não medir nunca aprova.** Toda regra que dependa de uma medição que não
20
+ aconteceu vira violação, não silêncio. É o mesmo princípio que impede uma
21
+ imagem não escaneada de ser apresentada como segura.
22
+ * **A política nunca afrouxa o que a linha de comando apertou.** Quando as
23
+ duas discordam, vale a mais estrita. Um arquivo no repositório não pode
24
+ desligar um portão que o pipeline pediu -- senão bastaria commitar um YAML
25
+ para publicar o que não passaria.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ from dataclasses import dataclass, field
31
+ from enum import StrEnum
32
+
33
+ from dockerls.domain.value_objects.gate import SEVERITY_THRESHOLDS, merge_gates
34
+ from dockerls.domain.value_objects.tristate import Tristate
35
+
36
+ #: Severidades que uma contagem pode nomear. `unknown` entra: um scanner que
37
+ #: reporta um achado sem severidade ainda reportou um achado, e um teto sobre
38
+ #: ele é legítimo.
39
+ SEVERITY_ORDER: tuple[str, ...] = ("critical", "high", "medium", "low", "unknown")
40
+
41
+ #: Limiares que o portão aceita, do mais brando para o mais estrito -- e a
42
+ #: ordem é essa mesmo. `--fail-on low` reprova em LOW *e em tudo acima dele*,
43
+ #: então é o limiar mais exigente que existe; `--fail-on critical` é o mais
44
+ #: permissivo. Confundir os dois foi um bug real aqui: `effective_fail_on`
45
+ #: escolhia por "gravidade da palavra" e devolvia `critical` quando um lado
46
+ #: pedia `high`, afrouxando em silêncio um portão que alguém tinha apertado.
47
+ #:
48
+ #: `unknown` fica de fora porque o portão não sabe avaliá-lo: aceitá-lo na
49
+ #: política produziria um build que morre com erro técnico no meio do
50
+ #: caminho, em vez de uma política recusada na leitura do arquivo.
51
+ #: Reexportado de `gate.py`, que é onde a escala mora agora: duas
52
+ #: definições de "mais estrito" divergiriam na primeira mudança.
53
+ GATE_THRESHOLDS: tuple[str, ...] = SEVERITY_THRESHOLDS
54
+
55
+
56
+ class PolicyRule(StrEnum):
57
+ """Cada regra que a política pode exigir, nomeada para o relatório."""
58
+
59
+ FAIL_ON = "fail_on"
60
+ MAX_VULNERABILITIES = "max_vulnerabilities"
61
+ REQUIRE_SCAN = "require_scan"
62
+ REQUIRE_PINNED_BASES = "require_pinned_bases"
63
+ REQUIRE_NONROOT = "require_nonroot"
64
+ REQUIRED_LABELS = "required_labels"
65
+ ALLOWED_BASE_REGISTRIES = "allowed_base_registries"
66
+ REQUIRE_PROVENANCE = "require_provenance"
67
+
68
+
69
+ @dataclass(frozen=True)
70
+ class BaseFact:
71
+ """Uma base declarada, reduzida ao que a política consegue avaliar."""
72
+
73
+ reference: str
74
+ registry: str
75
+ pinned: bool
76
+
77
+
78
+ @dataclass(frozen=True)
79
+ class PolicyFacts:
80
+ """O que este build mediu. Tudo que a política tem para trabalhar."""
81
+
82
+ #: Se um scanner chegou a rodar. `False` não é "zero vulnerabilidades".
83
+ scan_ran: bool = False
84
+ #: Contagem por severidade, do scan que rodou.
85
+ severity_counts: dict[str, int] = field(default_factory=dict)
86
+ bases: tuple[BaseFact, ...] = ()
87
+ labels: dict[str, str] = field(default_factory=dict)
88
+ #: Se a imagem roda sem privilégio. `UNKNOWN` quando o Dockerfile não
89
+ #: permitiu decidir -- e a política trata isso como violação, não como sim.
90
+ nonroot: Tristate = Tristate.UNKNOWN
91
+ #: `VERIFIED`, `INCOMPLETE`, `INPUT_CHANGED`, ou "" quando não houve
92
+ #: registro de procedência neste build.
93
+ provenance_status: str = ""
94
+
95
+
96
+ @dataclass(frozen=True)
97
+ class PolicyViolation:
98
+ """Uma regra que este build não cumpriu, e por quê."""
99
+
100
+ rule: PolicyRule
101
+ message: str
102
+
103
+ def to_dict(self) -> dict[str, str]:
104
+ return {"rule": str(self.rule), "message": self.message}
105
+
106
+
107
+ @dataclass(frozen=True)
108
+ class BuildPolicy:
109
+ """As exigências declaradas em `.dockerls-policy.yaml`."""
110
+
111
+ #: O portão que reprova o build: uma severidade (`critical`), `kev`,
112
+ #: `epss>=N`, ou vários separados por vírgula. Vazio deixa a decisão
113
+ #: com a linha de comando.
114
+ fail_on: str = ""
115
+ #: Teto por severidade (`{"high": 5}`). Um teto de zero é diferente de
116
+ #: `fail_on`: permite tolerar 3 HIGH e nenhum CRITICAL no mesmo arquivo.
117
+ max_vulnerabilities: dict[str, int] = field(default_factory=dict)
118
+ require_scan: bool = False
119
+ require_pinned_bases: bool = False
120
+ require_nonroot: bool = False
121
+ required_labels: tuple[str, ...] = ()
122
+ #: Registries de onde as bases podem vir. Vazio não restringe; declarado,
123
+ #: restringe **todas** as bases, inclusive as de estágios intermediários.
124
+ allowed_base_registries: tuple[str, ...] = ()
125
+ require_provenance: bool = False
126
+
127
+ @property
128
+ def is_empty(self) -> bool:
129
+ """Uma política que não exige nada. Vale saber: um arquivo presente e
130
+ vazio quase sempre significa um erro de digitação nas chaves."""
131
+ return not (
132
+ self.fail_on
133
+ or self.max_vulnerabilities
134
+ or self.require_scan
135
+ or self.require_pinned_bases
136
+ or self.require_nonroot
137
+ or self.required_labels
138
+ or self.allowed_base_registries
139
+ or self.require_provenance
140
+ )
141
+
142
+ def effective_fail_on(self, requested: str) -> str:
143
+ """O portão que vale, entre o da política e o da linha de comando.
144
+
145
+ Severidade contra severidade continua sendo a regra antiga: vence o
146
+ limiar **mais baixo na escala**, e não a palavra mais assustadora --
147
+ `--fail-on low` reprova em LOW e em tudo acima, enquanto `critical`
148
+ só olha CRITICAL. Nem o arquivo do repositório desliga um portão que
149
+ o pipeline pediu, nem uma flag afrouxa a política da organização.
150
+
151
+ Portões de tipos diferentes não competem: somam. `kev` e `high` são
152
+ perguntas diferentes sobre coisas diferentes, e escolher uma
153
+ descartaria a outra em silêncio. A regra vive em
154
+ `domain/value_objects/gate.py` para que a política e o portão do
155
+ build não possam divergir sobre o que "mais estrito" significa.
156
+ """
157
+ return merge_gates(self.fail_on, requested)
158
+
159
+ @staticmethod
160
+ def production() -> BuildPolicy:
161
+ """O perfil de produção: o conjunto que uma imagem publicada precisa.
162
+
163
+ Existe porque a alternativa é uma lista de sete flags que cada pipeline
164
+ digita de novo, esquecendo uma diferente por vez. Nomear o conjunto faz
165
+ a omissão virar uma decisão visível (`--no-policy`, `--fail-on low`) em
166
+ vez de um esquecimento invisível.
167
+
168
+ `fail_on` fica em `critical` e não em `high` de propósito. Um perfil que
169
+ ninguém consegue cumprir é um perfil que as pessoas desligam inteiro, e
170
+ `high` reprova praticamente toda base Debian num dia qualquer. O teto de
171
+ `high` fica declarado à parte, onde se enxerga e se discute.
172
+ """
173
+ return BuildPolicy(
174
+ fail_on="critical",
175
+ require_scan=True,
176
+ require_pinned_bases=True,
177
+ require_nonroot=True,
178
+ require_provenance=True,
179
+ required_labels=(
180
+ "org.opencontainers.image.source",
181
+ "org.opencontainers.image.vendor",
182
+ "security.contact",
183
+ ),
184
+ )
185
+
186
+ def merged_with(self, other: BuildPolicy | None) -> BuildPolicy:
187
+ """Este perfil somado a outro, sempre pelo lado mais estrito.
188
+
189
+ Serve para `--production` conviver com um `.dockerls-policy.yaml`: o
190
+ arquivo do repositório pode **apertar** o perfil (exigir um rótulo a
191
+ mais, um registry específico), e não pode afrouxá-lo. Uma exigência
192
+ declarada em qualquer um dos dois vale nos dois.
193
+ """
194
+ if other is None:
195
+ return self
196
+ tetos = {**other.max_vulnerabilities}
197
+ for severity, limit in self.max_vulnerabilities.items():
198
+ tetos[severity] = min(limit, tetos.get(severity, limit))
199
+ return BuildPolicy(
200
+ fail_on=self.effective_fail_on(other.fail_on),
201
+ max_vulnerabilities=tetos,
202
+ require_scan=self.require_scan or other.require_scan,
203
+ require_pinned_bases=self.require_pinned_bases or other.require_pinned_bases,
204
+ require_nonroot=self.require_nonroot or other.require_nonroot,
205
+ require_provenance=self.require_provenance or other.require_provenance,
206
+ required_labels=tuple(dict.fromkeys((*self.required_labels, *other.required_labels))),
207
+ allowed_base_registries=(
208
+ # Interseção não: duas listas disjuntas produziriam um conjunto
209
+ # vazio, que significa "não restringe" -- exatamente o oposto do
210
+ # que as duas pediram. A união mantém as duas restrições
211
+ # satisfazíveis e cada base ainda precisa estar em alguma delas.
212
+ tuple(
213
+ dict.fromkeys((*self.allowed_base_registries, *other.allowed_base_registries))
214
+ )
215
+ ),
216
+ )
217
+
218
+ def static_subset(self) -> BuildPolicy:
219
+ """A política reduzida ao que se decide sem construir nem escanear.
220
+
221
+ Uma varredura de frota lê Dockerfiles; ela não constrói imagem nem
222
+ chama scanner. Aplicar as regras que dependem de scan ali produziria
223
+ uma violação por arquivo, todas dizendo a mesma coisa ("não houve
224
+ scan") -- e uma lista em que tudo está vermelho não distingue nada.
225
+
226
+ As regras removidas não são consideradas cumpridas: elas continuam
227
+ valendo no `build`, que é onde há medição para conferi-las.
228
+ """
229
+ return BuildPolicy(
230
+ require_pinned_bases=self.require_pinned_bases,
231
+ require_nonroot=self.require_nonroot,
232
+ required_labels=self.required_labels,
233
+ allowed_base_registries=self.allowed_base_registries,
234
+ )
235
+
236
+ def to_dict(self) -> dict[str, object]:
237
+ return {
238
+ "fail_on": self.fail_on,
239
+ "max_vulnerabilities": dict(self.max_vulnerabilities),
240
+ "require_scan": self.require_scan,
241
+ "require_pinned_bases": self.require_pinned_bases,
242
+ "require_nonroot": self.require_nonroot,
243
+ "required_labels": list(self.required_labels),
244
+ "allowed_base_registries": list(self.allowed_base_registries),
245
+ "require_provenance": self.require_provenance,
246
+ }
247
+
248
+
249
+ def evaluate(policy: BuildPolicy, facts: PolicyFacts) -> list[PolicyViolation]:
250
+ """As regras que este build não cumpriu, na ordem em que foram declaradas.
251
+
252
+ Uma lista vazia significa "nenhuma regra foi violada", e não "está tudo
253
+ bem": uma política vazia não viola nada e também não garante nada. Quem
254
+ consome precisa olhar a política junto do resultado, e é por isso que
255
+ `is_empty` existe.
256
+ """
257
+ violations: list[PolicyViolation] = []
258
+
259
+ _check_scan(policy, facts, violations)
260
+ _check_ceilings(policy, facts, violations)
261
+ _check_bases(policy, facts, violations)
262
+ _check_nonroot(policy, facts, violations)
263
+ _check_labels(policy, facts, violations)
264
+ _check_provenance(policy, facts, violations)
265
+ return violations
266
+
267
+
268
+ def _check_scan(policy: BuildPolicy, facts: PolicyFacts, violations: list[PolicyViolation]) -> None:
269
+ if policy.require_scan and not facts.scan_ran:
270
+ violations.append(
271
+ PolicyViolation(
272
+ rule=PolicyRule.REQUIRE_SCAN,
273
+ message=(
274
+ "the policy requires a scan and no scanner ran: an image that "
275
+ "could not be measured is not an image without vulnerabilities"
276
+ ),
277
+ )
278
+ )
279
+
280
+
281
+ def _check_ceilings(
282
+ policy: BuildPolicy, facts: PolicyFacts, violations: list[PolicyViolation]
283
+ ) -> None:
284
+ if not policy.max_vulnerabilities:
285
+ return
286
+ if not facts.scan_ran:
287
+ # Sem scan não há contagem, e "contagem ausente" não é "contagem
288
+ # dentro do teto". Aprovar aqui esvaziaria toda regra de teto numa
289
+ # máquina sem scanner.
290
+ violations.append(
291
+ PolicyViolation(
292
+ rule=PolicyRule.MAX_VULNERABILITIES,
293
+ message=(
294
+ "the policy declares per-severity ceilings and no scanner ran: "
295
+ "there is no count to check them against"
296
+ ),
297
+ )
298
+ )
299
+ return
300
+ for severity in SEVERITY_ORDER:
301
+ if severity not in policy.max_vulnerabilities:
302
+ continue
303
+ limit = policy.max_vulnerabilities[severity]
304
+ found = facts.severity_counts.get(severity, 0)
305
+ if found > limit:
306
+ violations.append(
307
+ PolicyViolation(
308
+ rule=PolicyRule.MAX_VULNERABILITIES,
309
+ message=(
310
+ f"{found} {severity.upper()} vulnerability(ies) against a "
311
+ f"policy ceiling of {limit}"
312
+ ),
313
+ )
314
+ )
315
+
316
+
317
+ def _check_bases(
318
+ policy: BuildPolicy, facts: PolicyFacts, violations: list[PolicyViolation]
319
+ ) -> None:
320
+ if policy.require_pinned_bases:
321
+ if not facts.bases:
322
+ violations.append(
323
+ PolicyViolation(
324
+ rule=PolicyRule.REQUIRE_PINNED_BASES,
325
+ message=(
326
+ "the policy requires digest-pinned bases and no base could "
327
+ "be read from the Dockerfile"
328
+ ),
329
+ )
330
+ )
331
+ for base in facts.bases:
332
+ if not base.pinned:
333
+ violations.append(
334
+ PolicyViolation(
335
+ rule=PolicyRule.REQUIRE_PINNED_BASES,
336
+ message=(
337
+ f"{base.reference} is not pinned by digest: what was "
338
+ "tested and what ships to production can be different "
339
+ "bytes with no change of yours"
340
+ ),
341
+ )
342
+ )
343
+
344
+ if not policy.allowed_base_registries:
345
+ return
346
+ permitidos = {r.lower() for r in policy.allowed_base_registries}
347
+ for base in facts.bases:
348
+ # Uma base sem host explícito vem do Docker Hub; tratá-la como "sem
349
+ # registry" faria a regra ignorar exatamente o caso mais comum.
350
+ registry = (base.registry or "docker.io").lower()
351
+ if registry not in permitidos:
352
+ violations.append(
353
+ PolicyViolation(
354
+ rule=PolicyRule.ALLOWED_BASE_REGISTRIES,
355
+ message=(
356
+ f"{base.reference} comes from {registry}, which is not among "
357
+ f"the allowed registries ({', '.join(sorted(permitidos))})"
358
+ ),
359
+ )
360
+ )
361
+
362
+
363
+ def _check_nonroot(
364
+ policy: BuildPolicy, facts: PolicyFacts, violations: list[PolicyViolation]
365
+ ) -> None:
366
+ if not policy.require_nonroot or facts.nonroot.is_true:
367
+ return
368
+ motivo = (
369
+ "the image runs as root"
370
+ if facts.nonroot.is_false
371
+ else "the user the image runs as could not be determined, and not "
372
+ "determining is not the same as being in order"
373
+ )
374
+ violations.append(
375
+ PolicyViolation(
376
+ rule=PolicyRule.REQUIRE_NONROOT,
377
+ message=f"the policy requires running without privilege: {motivo}",
378
+ )
379
+ )
380
+
381
+
382
+ def _check_labels(
383
+ policy: BuildPolicy, facts: PolicyFacts, violations: list[PolicyViolation]
384
+ ) -> None:
385
+ for label in policy.required_labels:
386
+ if not facts.labels.get(label, "").strip():
387
+ violations.append(
388
+ PolicyViolation(
389
+ rule=PolicyRule.REQUIRED_LABELS,
390
+ message=(
391
+ f"required label missing or empty: {label} -- without it "
392
+ "nobody knows who to turn to when this image shows up in an "
393
+ "alert at three in the morning"
394
+ ),
395
+ )
396
+ )
397
+
398
+
399
+ def _check_provenance(
400
+ policy: BuildPolicy, facts: PolicyFacts, violations: list[PolicyViolation]
401
+ ) -> None:
402
+ if not policy.require_provenance:
403
+ return
404
+ if facts.provenance_status == "VERIFIED":
405
+ return
406
+ detalhe = facts.provenance_status or "no record was produced"
407
+ violations.append(
408
+ PolicyViolation(
409
+ rule=PolicyRule.REQUIRE_PROVENANCE,
410
+ message=(f"the policy requires verified provenance, and the record is: {detalhe}"),
411
+ )
412
+ )
@@ -0,0 +1,156 @@
1
+ """How much the evidence behind a verdict is worth.
2
+
3
+ Every number this tool prints is the output of a chain: discover a
4
+ candidate, resolve it to a digest, scan it, cross-check it with a second
5
+ scanner, read its configuration. Links in that chain break routinely -- a
6
+ registry rate-limits, a second scanner is not installed, a catalogue is
7
+ stale -- and the result is still a table full of numbers. Without a
8
+ confidence signal, a score produced from one scanner on an unresolved tag is
9
+ rendered identically to one produced from two agreeing scanners on a pinned
10
+ digest, and the reader has no way to tell them apart.
11
+
12
+ The rule the rest of this codebase is built on gets its final expression
13
+ here: **a technical failure never becomes a security statement.** A scan
14
+ that did not complete is UNVERIFIED, which is not a bad score -- it is the
15
+ absence of a score, and the ranking layer refuses to recommend it at all.
16
+
17
+ The four levels:
18
+
19
+ * `UNVERIFIED` -- no completed scan. Nothing may be concluded, in either
20
+ direction. This is a floor: no other signal can lift a candidate out of it.
21
+ * `LOW` -- scanned, but with a material problem: two scanners disagreed
22
+ substantially, or the reference could not be pinned to a digest and the
23
+ registry could not confirm it.
24
+ * `MEDIUM` -- scanned and consistent, with some evidence missing (no second
25
+ scanner, no digest, or thin hardening coverage).
26
+ * `HIGH` -- scanned, pinned to a digest, confirmed in its registry, and
27
+ corroborated by a second scanner that agreed.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ from enum import StrEnum
33
+ from typing import NamedTuple
34
+
35
+
36
+ class Confidence(StrEnum):
37
+ UNVERIFIED = "UNVERIFIED"
38
+ LOW = "LOW"
39
+ MEDIUM = "MEDIUM"
40
+ HIGH = "HIGH"
41
+
42
+ @property
43
+ def is_recommendable(self) -> bool:
44
+ """Whether a candidate at this confidence may be presented as a pick.
45
+
46
+ UNVERIFIED never is. LOW may be shown -- with its reasons -- but the
47
+ ranking layer will not put it above a comparable MEDIUM/HIGH result.
48
+ """
49
+ return self is not Confidence.UNVERIFIED
50
+
51
+
52
+ #: Ordering for comparisons, worst first. Written out rather than relying on
53
+ #: declaration order, so a future level inserted in the middle cannot
54
+ #: silently reorder existing comparisons.
55
+ CONFIDENCE_ORDER: tuple[Confidence, ...] = (
56
+ Confidence.UNVERIFIED,
57
+ Confidence.LOW,
58
+ Confidence.MEDIUM,
59
+ Confidence.HIGH,
60
+ )
61
+
62
+
63
+ def confidence_rank(level: Confidence) -> int:
64
+ return CONFIDENCE_ORDER.index(level)
65
+
66
+
67
+ class ConfidenceInputs(NamedTuple):
68
+ """The facts that decide a confidence level.
69
+
70
+ A plain record rather than a reference to the analysis DTO: the domain
71
+ must not depend on the application layer, and stating the inputs
72
+ explicitly makes the rule below auditable in one screen.
73
+ """
74
+
75
+ #: The primary scan completed and produced a parsed result.
76
+ scan_verified: bool
77
+ #: A second scanner ran and produced a comparable result.
78
+ cross_validated: bool = False
79
+ #: A second scanner ran and disagreed materially with the first.
80
+ scanners_disagree: bool = False
81
+ #: A second scanner ran and differed on individual findings without the
82
+ #: disagreement being material. Two vulnerability databases legitimately
83
+ #: differ at the margins, so this does not refute the result -- but it is
84
+ #: not the clean corroboration that HIGH is meant to represent either.
85
+ scanners_differ_slightly: bool = False
86
+ #: The candidate is pinned to a manifest digest.
87
+ digest_resolved: bool = False
88
+ #: The registry that owns the reference confirmed the tag exists.
89
+ #: Tri-state upstream (None = not checked); False is a real refutation.
90
+ registry_verified: bool | None = None
91
+ #: Share of the hardening model that could be determined, 0.0-1.0.
92
+ hardening_coverage: float = 0.0
93
+
94
+
95
+ #: Hardening coverage below which the evidence is considered thin. Matches
96
+ #: the reporting threshold of the hardening model itself, so a score that is
97
+ #: not worth printing cannot silently prop up a HIGH confidence.
98
+ THIN_COVERAGE = 0.25
99
+
100
+
101
+ class ConfidenceAssessment:
102
+ """Derives a confidence level and the reasons behind it."""
103
+
104
+ def __init__(self, inputs: ConfidenceInputs):
105
+ self._inputs = inputs
106
+ self._reasons: list[str] = []
107
+ self._level = self._assess()
108
+
109
+ @property
110
+ def level(self) -> Confidence:
111
+ return self._level
112
+
113
+ @property
114
+ def reasons(self) -> list[str]:
115
+ """Why this level, in the reader's terms. Never empty."""
116
+ return list(self._reasons)
117
+
118
+ def _assess(self) -> Confidence:
119
+ i = self._inputs
120
+
121
+ # The floor. Checked first and returned immediately: nothing below
122
+ # is allowed to reason its way past a missing measurement.
123
+ if not i.scan_verified:
124
+ self._reasons.append("no completed scan: nothing was measured")
125
+ return Confidence.UNVERIFIED
126
+
127
+ if i.registry_verified is False:
128
+ self._reasons.append("the registry that owns this reference does not have this tag")
129
+ return Confidence.LOW
130
+
131
+ if i.scanners_disagree:
132
+ self._reasons.append("two scanners disagreed materially on the vulnerability counts")
133
+ return Confidence.LOW
134
+
135
+ if not i.digest_resolved and i.registry_verified is not True:
136
+ self._reasons.append("reference is not pinned to a digest and was not confirmed")
137
+ return Confidence.LOW
138
+
139
+ gaps: list[str] = []
140
+ if not i.cross_validated:
141
+ gaps.append("only one scanner ran")
142
+ if i.scanners_differ_slightly:
143
+ gaps.append("the second scanner differed on individual findings")
144
+ if not i.digest_resolved:
145
+ gaps.append("no manifest digest resolved")
146
+ if i.hardening_coverage < THIN_COVERAGE:
147
+ gaps.append("little of the image configuration could be inspected")
148
+
149
+ if gaps:
150
+ self._reasons.extend(gaps)
151
+ return Confidence.MEDIUM
152
+
153
+ self._reasons.append("scanned, pinned to a digest, confirmed in its registry")
154
+ if i.cross_validated:
155
+ self._reasons.append("corroborated by a second scanner that agreed")
156
+ return Confidence.HIGH