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,159 @@
1
+ """`dockerls fleet` -- o retrato de todos os Dockerfiles de uma vez.
2
+
3
+ Cada comando desta ferramenta olha para um artefato. Isso resolve a pergunta
4
+ de quem está com o arquivo aberto e não resolve nenhuma das perguntas de quem
5
+ responde por trinta repositórios: "quantos ainda rodam como root?", "quantos
6
+ fixam a base?", "por onde eu começo?". Sem resposta, a resposta na prática vira
7
+ "por onde alguém reclamar".
8
+
9
+ A saída é uma fila de trabalho, ordenada por violações e desempatada pelo
10
+ caminho -- para que duas varreduras da mesma frota sejam comparáveis.
11
+
12
+ O que este comando não faz está dito na própria saída, e não é modéstia: ele
13
+ lê Dockerfiles, não constrói imagem nem chama scanner. Um "relatório de
14
+ segurança da frota" que na verdade leu arquivos seria exatamente a promessa
15
+ que o resto desta ferramenta existe para não fazer.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ from pathlib import Path
22
+ from typing import TYPE_CHECKING
23
+
24
+ import typer
25
+ from rich.console import Console
26
+
27
+ from dockerls.cli.options import OutputFormat, parse_output_format
28
+ from dockerls.cli.text import safe
29
+ from dockerls.domain.value_objects.tristate import Tristate
30
+ from dockerls.exit_codes import EXIT_ERROR, EXIT_OK, EXIT_POLICY
31
+
32
+ if TYPE_CHECKING:
33
+ from dockerls.domain.value_objects.fleet import FleetReport
34
+
35
+ console = Console()
36
+
37
+ _USER_LABEL = {
38
+ Tristate.TRUE: "[green]non-root[/green]",
39
+ Tristate.FALSE: "[red]root[/red]",
40
+ Tristate.UNKNOWN: "[yellow]undetermined[/yellow]",
41
+ }
42
+
43
+
44
+ def fleet(
45
+ root: str = typer.Argument(".", help="Root of the tree to scan"),
46
+ policy_path: str | None = typer.Option(
47
+ None,
48
+ "--policy",
49
+ help=(
50
+ "Policy to check every Dockerfile against (default: .dockerls-policy.yaml "
51
+ "at the root, when present). Only rules decidable without a build apply"
52
+ ),
53
+ ),
54
+ output_format: str = typer.Option(
55
+ OutputFormat.TABLE.value, "--format", "-f", help="Output format: table or json"
56
+ ),
57
+ limit: int = typer.Option(0, "--limit", help="Show only the first N in the queue (0 = all)"),
58
+ no_color: bool = typer.Option(False, "--no-color", help="Disable colored output"),
59
+ ) -> None:
60
+ """Scan a tree of repositories and summarise the state of its Dockerfiles."""
61
+ if no_color:
62
+ console.no_color = True
63
+ fmt = parse_output_format(output_format)
64
+
65
+ from dockerls.application.use_cases.fleet_scan import FleetScanRequest, FleetScanUseCase
66
+ from dockerls.infrastructure.config.policy_file import (
67
+ PolicyFileError,
68
+ find_policy_file,
69
+ load_policy,
70
+ )
71
+ from dockerls.infrastructure.dockerfile_validator import DockerfileValidator
72
+
73
+ target = Path(root)
74
+ if not target.is_dir():
75
+ console.print(f"[red]Error:[/red] {safe(root)} is not a directory")
76
+ raise typer.Exit(EXIT_ERROR)
77
+
78
+ policy_file = Path(policy_path) if policy_path else find_policy_file(target)
79
+ declared = None
80
+ if policy_file is not None:
81
+ if not policy_file.is_file():
82
+ console.print(f"[red]Error:[/red] policy not found: {safe(str(policy_file))}")
83
+ raise typer.Exit(EXIT_ERROR)
84
+ try:
85
+ declared = load_policy(policy_file)
86
+ except PolicyFileError as e:
87
+ console.print(f"[red]Error:[/red] {safe(str(e))}")
88
+ raise typer.Exit(EXIT_ERROR) from e
89
+
90
+ report = FleetScanUseCase(DockerfileValidator()).execute(
91
+ FleetScanRequest(root=str(target), policy=declared)
92
+ )
93
+
94
+ if fmt == OutputFormat.JSON:
95
+ console.print(json.dumps(report.to_dict(), indent=2, ensure_ascii=False), soft_wrap=True)
96
+ else:
97
+ _render(report, limit=limit)
98
+
99
+ # Reprova quando há violação: é o que torna a varredura utilizável como
100
+ # portão de um repositório-guarda-chuva. Sem política declarada não há o
101
+ # que reprovar, e o silêncio aqui não é conformidade.
102
+ raise typer.Exit(EXIT_POLICY if report.total_violations else EXIT_OK)
103
+
104
+
105
+ def _render(report: FleetReport, *, limit: int) -> None:
106
+ console.print(f"\n[bold]{safe(report.root)}[/bold]")
107
+ console.print(f"[dim]{safe(report.summary())}[/dim]\n")
108
+
109
+ if not report.total:
110
+ console.print("[yellow]No Dockerfile found under this root.[/yellow]")
111
+ return
112
+
113
+ fila = report.worst_first()
114
+ mostrados = fila[:limit] if limit > 0 else fila
115
+ for entry in mostrados:
116
+ if not entry.readable:
117
+ console.print(f" [red]![/red] {safe(entry.path)} [dim]{safe(entry.error)}[/dim]")
118
+ continue
119
+
120
+ fixadas = (
121
+ f"{entry.pinned_bases}/{entry.total_bases} pinned"
122
+ if entry.total_bases
123
+ else "no readable FROM"
124
+ )
125
+ cor = "green" if entry.fully_pinned else "yellow"
126
+ console.print(
127
+ f" {safe(entry.path)}\n"
128
+ f" [{cor}]{fixadas}[/{cor}] {_USER_LABEL[entry.nonroot]} "
129
+ f"[dim]{entry.stages} stage(s)[/dim]"
130
+ )
131
+ for violation in entry.violations:
132
+ console.print(
133
+ f" [red]x[/red] {violation.rule} [dim]{safe(violation.message)}[/dim]"
134
+ )
135
+
136
+ if limit > 0 and len(fila) > limit:
137
+ console.print(f"\n[dim]... and {len(fila) - limit} more (use --limit 0 for all).[/dim]")
138
+
139
+ console.print()
140
+ if report.truncated:
141
+ console.print(
142
+ "[yellow]The scan hit the file or depth ceiling: this picture is incomplete.[/yellow]"
143
+ )
144
+ for path in report.unreadable_paths:
145
+ console.print(f"[yellow]Directory not walked: {safe(path)}[/yellow]")
146
+
147
+ if report.policy_applied:
148
+ console.print(
149
+ f"[bold]{report.with_violations} file(s) with violations, "
150
+ f"{report.total_violations} in total.[/bold]\n"
151
+ "[dim]Only rules decidable without a build were applied; the ones that "
152
+ "need a scan still hold in `dockerls build`.[/dim]"
153
+ )
154
+ else:
155
+ console.print(
156
+ "[dim]No policy declared: nothing was checked against any rule, and that "
157
+ "is not the same as being compliant.[/dim]"
158
+ )
159
+ console.print(f"[dim]{safe(report.caveat())}[/dim]")
@@ -0,0 +1,84 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+
5
+ import httpx
6
+ import typer
7
+ from rich.console import Console
8
+ from rich.table import Table
9
+
10
+ from dockerls.cli.dependencies import build_host_guard
11
+ from dockerls.exit_codes import EXIT_ERROR, EXIT_OK
12
+ from dockerls.infrastructure.network.guarded_client import guarded_async_client
13
+ from dockerls.integrations.exploitdb.client import EXPLOITDB_CSV_URL
14
+
15
+ console = Console()
16
+
17
+ # Exit codes so `health` is usable as a CI gate. They come from the shared
18
+ # contract rather than from literals re-declared here: a probe that cannot
19
+ # reach a dependency is "não sei" (EXIT_ERROR), not a policy verdict.
20
+ EXIT_HEALTHY = EXIT_OK
21
+ EXIT_DEGRADED = EXIT_ERROR
22
+
23
+ # Each probe must be an endpoint the tool actually depends on *and* one that
24
+ # answers 2xx when healthy. `https://hub.docker.com/v2/` was neither: it
25
+ # returns 404 by design, so every single run reported the Hub as degraded.
26
+ # The list tracks the real dependencies -- the image catalogues that feed
27
+ # the scan pipeline and the threat-intel feeds that weight the score.
28
+ ENDPOINTS = {
29
+ "Docker Hub API": "https://hub.docker.com/v2/repositories/library/node/",
30
+ "Chainguard (cgr.dev)": "https://cgr.dev/token?scope=repository:chainguard/node:pull&service=cgr.dev",
31
+ "Distroless (gcr.io)": "https://gcr.io/v2/distroless/base/tags/list",
32
+ "endoflife.date": "https://endoflife.date/api/python.json",
33
+ "CISA KEV": "https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json",
34
+ "EPSS (FIRST)": "https://api.first.org/data/v1/epss?cve=CVE-2021-44228",
35
+ "Exploit-DB catalogue": EXPLOITDB_CSV_URL,
36
+ }
37
+
38
+ #: Endpoints grandes demais para baixar num probe de saúde. O CSV do
39
+ #: Exploit-DB tem cerca de 10 MB, e `health` pergunta se a fonte responde --
40
+ #: não quer o conteúdo dela. Um HEAD responde exatamente isso.
41
+ _HEAD_ONLY = frozenset({EXPLOITDB_CSV_URL})
42
+
43
+
44
+ def health() -> None:
45
+ """Check connectivity to external services."""
46
+ raise typer.Exit(asyncio.run(_health()))
47
+
48
+
49
+ async def _health() -> int:
50
+ console.print("[bold]Service Health Check[/bold]\n")
51
+
52
+ table = Table(show_header=False, box=None, padding=(0, 2))
53
+ table.add_column("Service", style="bold")
54
+ table.add_column("Status")
55
+
56
+ degraded = False
57
+ # The URLs above are constants, but the redirects they may answer with
58
+ # are not, and this client follows them. Same policy, same reason as
59
+ # every other client in the tool.
60
+ async with guarded_async_client(
61
+ build_host_guard(), timeout=10, follow_redirects=True
62
+ ) as client:
63
+ for name, url in ENDPOINTS.items():
64
+ try:
65
+ resp = await client.head(url) if url in _HEAD_ONLY else await client.get(url)
66
+ except httpx.HTTPError as e:
67
+ # Unreachable is a failure, not a curiosity: DNS, TLS,
68
+ # proxy and timeout errors all land here.
69
+ table.add_row(name, f"[red]Unreachable: {type(e).__name__}[/red]")
70
+ degraded = True
71
+ continue
72
+
73
+ if resp.status_code < 400:
74
+ table.add_row(name, f"[green]OK ({resp.status_code})[/green]")
75
+ else:
76
+ table.add_row(name, f"[red]HTTP {resp.status_code}[/red]")
77
+ degraded = True
78
+
79
+ console.print(table)
80
+ if degraded:
81
+ console.print("\n[yellow]One or more services are degraded.[/yellow]")
82
+ return EXIT_DEGRADED
83
+ console.print("\n[green]All services reachable.[/green]")
84
+ return EXIT_HEALTHY
@@ -0,0 +1,53 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+
5
+ import typer
6
+ from rich.console import Console
7
+
8
+ from dockerls.exit_codes import EXIT_ERROR
9
+ from dockerls.integrations.dockerhub.client import DockerHubClient
10
+ from dockerls.utils.auth import clear_credentials, store_credentials
11
+
12
+ console = Console()
13
+
14
+
15
+ def login(
16
+ username: str = typer.Option("", "--username", "-u", prompt="Docker Hub username"),
17
+ token: str = typer.Option("", "--token", "-t", prompt="Docker Hub token", hide_input=True),
18
+ ) -> None:
19
+ """Authenticate with Docker Hub. Credentials are stored in your system keyring."""
20
+ if not username or not token:
21
+ console.print("[red]Username and token are required.[/red]")
22
+ raise typer.Exit(EXIT_ERROR)
23
+
24
+ asyncio.run(_login(username, token))
25
+
26
+
27
+ async def _login(username: str, token: str) -> None:
28
+ client = DockerHubClient(username=username, token=token)
29
+ if not await client.authenticate():
30
+ console.print("[red]Authentication failed. Check your username and token.[/red]")
31
+ raise typer.Exit(EXIT_ERROR)
32
+
33
+ if store_credentials(username, token):
34
+ console.print("[green]Authenticated. Credentials stored securely in keyring.[/green]")
35
+ else:
36
+ console.print(
37
+ "[yellow]Authenticated, but keyring is not available. Set "
38
+ "DOCKERHUB_USERNAME and DOCKERHUB_TOKEN environment variables "
39
+ "to persist credentials instead.[/yellow]"
40
+ )
41
+
42
+
43
+ def logout() -> None:
44
+ """Remove stored Docker Hub credentials.
45
+
46
+ `login` could store credentials with no supported way to remove them,
47
+ which left `clear_credentials` implemented and unreachable.
48
+ """
49
+ if clear_credentials():
50
+ console.print("[green]Stored credentials removed.[/green]")
51
+ return
52
+ console.print("[yellow]No stored credentials to remove.[/yellow]")
53
+ raise typer.Exit(EXIT_ERROR)
@@ -0,0 +1,111 @@
1
+ """`dockerls policy` -- ler o regulamento sem precisar de um build para isso.
2
+
3
+ Uma política que só se manifesta quando um build reprova é uma política que
4
+ ninguém revisa. Este comando existe para as duas perguntas que aparecem antes
5
+ disso: "o que este repositório exige?" e "este arquivo que acabei de escrever
6
+ está válido?".
7
+
8
+ A segunda importa mais do que parece. O carregador recusa chave desconhecida,
9
+ tipo errado e severidade inexistente -- e recusa com razão, porque uma regra
10
+ que não carrega deixa de exigir alguma coisa. Mas descobrir isso no meio de um
11
+ build de dez minutos é caro; descobrir aqui custa um segundo.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ from pathlib import Path
18
+
19
+ import typer
20
+ from rich.console import Console
21
+
22
+ from dockerls.cli.options import OutputFormat, parse_output_format
23
+ from dockerls.cli.text import safe
24
+ from dockerls.exit_codes import EXIT_ERROR, EXIT_OK
25
+ from dockerls.infrastructure.config.policy_file import (
26
+ DEFAULT_POLICY_FILENAME,
27
+ PolicyFileError,
28
+ find_policy_file,
29
+ load_policy,
30
+ )
31
+
32
+ console = Console()
33
+
34
+ #: O que cada regra exige, em uma linha, para a saída de tabela.
35
+ _DESCRIPTIONS = {
36
+ "fail_on": "fails the build from this severity upwards",
37
+ "max_vulnerabilities": "ceiling on findings per severity",
38
+ "require_scan": "requires that a scanner has run",
39
+ "require_pinned_bases": "requires every FROM pinned by digest",
40
+ "require_nonroot": "requires the image to run without privilege",
41
+ "required_labels": "labels the image must carry",
42
+ "allowed_base_registries": "where bases may come from",
43
+ "require_provenance": "requires verified provenance",
44
+ }
45
+
46
+
47
+ def policy(
48
+ path: str = typer.Argument(
49
+ ".", help="Directory containing the policy file, or the file itself"
50
+ ),
51
+ output_format: str = typer.Option(
52
+ OutputFormat.TABLE.value, "--format", "-f", help="Output format: table or json"
53
+ ),
54
+ no_color: bool = typer.Option(False, "--no-color", help="Disable colored output"),
55
+ ) -> None:
56
+ """Show and validate the policy declared in `.dockerls-policy.yaml`."""
57
+ if no_color:
58
+ console.no_color = True
59
+ fmt = parse_output_format(output_format)
60
+
61
+ target = Path(path)
62
+ if target.is_dir():
63
+ found = find_policy_file(target)
64
+ if found is None:
65
+ if fmt == OutputFormat.JSON:
66
+ console.print(json.dumps({"policy": None}, indent=2), soft_wrap=True)
67
+ else:
68
+ console.print(
69
+ f"[yellow]No {DEFAULT_POLICY_FILENAME} in {safe(str(target))}.[/yellow]\n"
70
+ "[dim]With no declared policy, only the gates the command line "
71
+ "asks for apply -- and a rule that lives on the command line is a "
72
+ "rule every pipeline rewrites by hand.[/dim]"
73
+ )
74
+ raise typer.Exit(EXIT_OK)
75
+ target = found
76
+
77
+ try:
78
+ declared = load_policy(target)
79
+ except PolicyFileError as e:
80
+ console.print(f"[red]Error:[/red] {safe(str(e))}")
81
+ raise typer.Exit(EXIT_ERROR) from e
82
+
83
+ if fmt == OutputFormat.JSON:
84
+ console.print(
85
+ json.dumps({"file": str(target), "policy": declared.to_dict()}, indent=2),
86
+ soft_wrap=True,
87
+ )
88
+ raise typer.Exit(EXIT_OK)
89
+
90
+ console.print(f"\n[bold]{safe(str(target))}[/bold]\n")
91
+ for key, value in declared.to_dict().items():
92
+ if not value:
93
+ continue
94
+ console.print(
95
+ f" [cyan]{key}[/cyan] [dim]{_DESCRIPTIONS.get(key, '')}[/dim]\n"
96
+ f" {safe(_format(value))}"
97
+ )
98
+ console.print(
99
+ "\n[dim]Checked on every `dockerls build` in this context. Between the "
100
+ "threshold here and the one on the command line, the stricter wins: a file in "
101
+ "the repository cannot switch off a gate the pipeline asked for.[/dim]"
102
+ )
103
+ raise typer.Exit(EXIT_OK)
104
+
105
+
106
+ def _format(value: object) -> str:
107
+ if isinstance(value, dict):
108
+ return ", ".join(f"{k}: {v}" for k, v in value.items())
109
+ if isinstance(value, list):
110
+ return ", ".join(str(v) for v in value)
111
+ return str(value)
@@ -0,0 +1,162 @@
1
+ """`dockerls provenance` -- conferir um documento de procedência, e atestá-lo.
2
+
3
+ O `build --provenance` arquiva um JSON dizendo o que entrou no build e o que
4
+ saiu. Arquivar é metade do controle; a outra metade é alguém conferir antes de
5
+ o artefato seguir adiante -- e era exatamente essa metade que faltava. Um
6
+ documento que ninguém lê descreve com precisão uma imagem que ninguém sabe se
7
+ deveria ter sido publicada.
8
+
9
+ Este comando é essa leitura, e ele faz duas coisas que só fazem sentido juntas:
10
+
11
+ * **Recalcula o veredito** em vez de acreditar no que está escrito. O campo
12
+ `"status": "VERIFIED"` de um arquivo JSON é editável por qualquer pessoa com
13
+ um editor de texto; a comparação entre os digests de antes e depois do build
14
+ não é. Ler o status gravado seria pedir ao documento que se auto-aprovasse.
15
+ * **Recusa por código de saída** quando a procedência não fecha. É o que
16
+ transforma o documento em portão: num workflow, o passo de atestação só roda
17
+ se este comando passar, e uma imagem cuja entrada mudou durante o build nunca
18
+ chega a receber uma assinatura dizendo que veio dali.
19
+
20
+ `--github-output` existe para o último elo: `actions/attest-build-provenance`
21
+ precisa do nome e do digest do artefato, e tirá-los do documento (em vez de
22
+ redigitá-los no YAML) garante que a atestação fala da mesma imagem que o scan
23
+ mediu. Redigitar é onde a cadeia arrebenta sem ninguém perceber.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import json
29
+ import os
30
+ from pathlib import Path
31
+
32
+ import typer
33
+ from rich.console import Console
34
+
35
+ from dockerls.cli.options import OutputFormat, parse_output_format
36
+ from dockerls.cli.text import safe
37
+ from dockerls.domain.value_objects.provenance import BuildProvenance, ProvenanceStatus
38
+ from dockerls.exit_codes import EXIT_ERROR, EXIT_OK, EXIT_POLICY
39
+
40
+ console = Console()
41
+
42
+ _COLORS = {
43
+ ProvenanceStatus.VERIFIED: "green",
44
+ ProvenanceStatus.INCOMPLETE: "yellow",
45
+ ProvenanceStatus.INPUT_CHANGED: "red",
46
+ }
47
+
48
+
49
+ def provenance(
50
+ document: str = typer.Argument(..., help="JSON file written by `build --provenance`"),
51
+ output_format: str = typer.Option(
52
+ OutputFormat.TABLE.value, "--format", "-f", help="Output format: table or json"
53
+ ),
54
+ github_output: bool = typer.Option(
55
+ False,
56
+ "--github-output",
57
+ help=(
58
+ "Write subject-name and subject-digest to $GITHUB_OUTPUT for "
59
+ "actions/attest-build-provenance to consume"
60
+ ),
61
+ ),
62
+ no_color: bool = typer.Option(False, "--no-color", help="Disable colored output"),
63
+ ) -> None:
64
+ """Check a provenance document and prepare the attestation."""
65
+ if no_color:
66
+ console.no_color = True
67
+ fmt = parse_output_format(output_format)
68
+
69
+ path = Path(document)
70
+ try:
71
+ raw = json.loads(path.read_text(encoding="utf-8"))
72
+ except OSError as e:
73
+ console.print(f"[red]Error:[/red] could not read {safe(str(path))}: {safe(str(e))}")
74
+ raise typer.Exit(EXIT_ERROR) from e
75
+ except json.JSONDecodeError as e:
76
+ console.print(f"[red]Error:[/red] {safe(str(path))} is not valid JSON: {safe(str(e))}")
77
+ raise typer.Exit(EXIT_ERROR) from e
78
+
79
+ record = BuildProvenance.from_dict(raw)
80
+ subject_name, subject_digest = _subject(record)
81
+
82
+ if fmt == OutputFormat.JSON:
83
+ payload = record.to_dict()
84
+ payload["subject_name"] = subject_name
85
+ payload["subject_digest"] = subject_digest
86
+ payload["attestable"] = record.is_verified and bool(subject_digest)
87
+ console.print(json.dumps(payload, indent=2, ensure_ascii=False), soft_wrap=True)
88
+ else:
89
+ _render(record, subject_name, subject_digest)
90
+
91
+ if github_output:
92
+ _write_github_output(subject_name, subject_digest, record)
93
+
94
+ if not record.is_verified:
95
+ raise typer.Exit(EXIT_POLICY)
96
+ if not subject_digest:
97
+ # Sem digest não há o que atestar: uma assinatura precisa apontar para
98
+ # bytes específicos, e "a imagem com esta tag" não são bytes.
99
+ console.print(
100
+ "[yellow]The document carries no artifact digest: there is no subject to "
101
+ "attest.[/yellow]\n[dim]Publish the image (`build --push`) so the registry "
102
+ "returns the manifest digest.[/dim]"
103
+ )
104
+ raise typer.Exit(EXIT_POLICY)
105
+ raise typer.Exit(EXIT_OK)
106
+
107
+
108
+ def _subject(record: BuildProvenance) -> tuple[str, str]:
109
+ """Nome e digest do que será atestado.
110
+
111
+ O digest do manifesto no registry vem primeiro: é o único identificador
112
+ que outra máquina consegue usar para puxar exatamente estes bytes. O id
113
+ local serve de segundo melhor, e é rotulado como tal na saída.
114
+ """
115
+ name = record.artifact.published_reference or record.tag
116
+ digest = record.artifact.repo_digest or record.artifact.image_id
117
+ return name, digest
118
+
119
+
120
+ def _render(record: BuildProvenance, subject_name: str, subject_digest: str) -> None:
121
+ color = _COLORS.get(record.status, "white")
122
+ console.print(f"\n[bold]{safe(record.tag or '(no tag)')}[/bold]")
123
+ console.print(f" [{color}]{record.status}[/{color}] [dim]{safe(record.explain())}[/dim]\n")
124
+
125
+ console.print(" [bold]input[/bold]")
126
+ console.print(f" Dockerfile {safe(record.source.dockerfile or '(not measured)')}")
127
+ console.print(
128
+ f" context {safe(record.source.context or '(not measured)')}"
129
+ f" [dim]({record.source.context_files} file(s))[/dim]"
130
+ )
131
+ if record.source.git_revision:
132
+ sujo = " [yellow](with uncommitted changes)[/yellow]" if record.source.git_dirty else ""
133
+ console.print(f" revision {safe(record.source.git_revision)}{sujo}")
134
+ for reference, digest in record.source.base_images.items():
135
+ console.print(f" base {safe(reference)} -> {safe(digest or '(moving tag)')}")
136
+
137
+ console.print("\n [bold]output[/bold]")
138
+ console.print(f" subject {safe(subject_name or '(no name)')}")
139
+ origem = "registry manifest" if record.artifact.repo_digest else "local image id"
140
+ console.print(f" digest {safe(subject_digest or '(no digest)')} [dim]({origem})[/dim]")
141
+ console.print(f" scanner {safe(record.artifact.scanner or '(not recorded)')}\n")
142
+
143
+
144
+ def _write_github_output(name: str, digest: str, record: BuildProvenance) -> None:
145
+ """Publica o sujeito da atestação para o próximo passo do workflow.
146
+
147
+ Fora do Actions a variável não existe, e escrever num caminho arbitrário
148
+ seria efeito colateral fora de contexto: o comando avisa e segue.
149
+ """
150
+ destination = os.environ.get("GITHUB_OUTPUT", "")
151
+ if not destination:
152
+ console.print(
153
+ "[yellow]$GITHUB_OUTPUT is not set: --github-output only has an effect "
154
+ "inside a GitHub Actions workflow.[/yellow]"
155
+ )
156
+ return
157
+ linhas = f"subject-name={name}\nsubject-digest={digest}\nprovenance-status={record.status}\n"
158
+ try:
159
+ with Path(destination).open("a", encoding="utf-8") as handle:
160
+ handle.write(linhas)
161
+ except OSError as e:
162
+ console.print(f"[yellow]Could not write to $GITHUB_OUTPUT: {safe(str(e))}[/yellow]")