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,103 @@
1
+ """`dockerls registry-audit` -- o que o registry conta sobre uma imagem.
2
+
3
+ Auditar a configuração de um registry -- retenção, IAM, content trust -- exige
4
+ credencial de nuvem e uma API diferente para cada provedor. Este comando não
5
+ faz isso, e a saída diz que não faz: ele usa o protocolo OCI e mais nada. A
6
+ troca é deliberada. Um relatório que precisa de acesso administrativo para
7
+ existir é um relatório que ninguém roda, e o que dá para medir sem credencial
8
+ é menos do que parece e mais do que se costuma olhar.
9
+
10
+ Cada achado é tri-estado. `UNKNOWN` não é enfeite de formato: sem ele, "o
11
+ registry não respondeu sobre a assinatura" viraria "não há assinatura", e as
12
+ duas frases levam a decisões opostas.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import asyncio
18
+ import json
19
+ from typing import TYPE_CHECKING
20
+
21
+ import typer
22
+ from rich.console import Console
23
+
24
+ from dockerls.cli.dependencies import build_host_guard, build_registry_credentials
25
+ from dockerls.cli.options import OutputFormat, parse_output_format
26
+ from dockerls.cli.text import safe
27
+ from dockerls.exit_codes import EXIT_ERROR, EXIT_OK, EXIT_POLICY
28
+
29
+ if TYPE_CHECKING:
30
+ from dockerls.domain.value_objects.registry_audit import RegistryAudit
31
+
32
+ console = Console()
33
+
34
+
35
+ def registry_audit(
36
+ reference: str = typer.Argument(..., help="Published image (`ghcr.io/org/app:1.0`)"),
37
+ output_format: str = typer.Option(
38
+ OutputFormat.TABLE.value, "--format", "-f", help="Output format: table or json"
39
+ ),
40
+ no_color: bool = typer.Option(False, "--no-color", help="Disable colored output"),
41
+ ) -> None:
42
+ """Establish, through the registry, what is known about a published image."""
43
+ if no_color:
44
+ console.no_color = True
45
+ fmt = parse_output_format(output_format)
46
+ asyncio.run(_audit(reference, output_format=fmt))
47
+
48
+
49
+ async def _audit(reference: str, *, output_format: OutputFormat) -> None:
50
+ from dockerls.application.services.tag_history_store import TagHistoryStore
51
+ from dockerls.application.use_cases.registry_audit import RegistryAuditUseCase
52
+ from dockerls.cli.dependencies import build_cache
53
+ from dockerls.integrations.registry.inspector import RegistryInspector
54
+
55
+ inspector = RegistryInspector(
56
+ guard=build_host_guard(), credentials=build_registry_credentials()
57
+ )
58
+ try:
59
+ history = TagHistoryStore(build_cache())
60
+ except Exception: # pragma: no cover - abrir o cache é o caminho instável
61
+ history = TagHistoryStore(None)
62
+
63
+ try:
64
+ audit = await RegistryAuditUseCase(inspector, history).execute(reference)
65
+ finally:
66
+ await inspector.close()
67
+
68
+ if output_format == OutputFormat.JSON:
69
+ console.print(json.dumps(audit.to_dict(), indent=2, ensure_ascii=False), soft_wrap=True)
70
+ else:
71
+ _render(audit)
72
+
73
+ if not audit.findings:
74
+ raise typer.Exit(EXIT_ERROR)
75
+ raise typer.Exit(EXIT_POLICY if audit.alerts else EXIT_OK)
76
+
77
+
78
+ def _render(audit: RegistryAudit) -> None:
79
+ console.print(f"\n[bold]{safe(audit.reference)}[/bold]")
80
+ if audit.digest:
81
+ console.print(f"[dim]{safe(audit.digest)}[/dim]")
82
+ console.print()
83
+
84
+ if not audit.findings:
85
+ console.print("[red]Invalid reference: nothing could be established.[/red]")
86
+ return
87
+
88
+ for finding in audit.findings:
89
+ if finding.is_unmeasured:
90
+ marca, cor = "?", "yellow"
91
+ elif finding.is_alert:
92
+ marca, cor = "x", "red"
93
+ elif finding.is_informational:
94
+ # Fato relatado sem juízo: se ser público é problema depende de
95
+ # para que a imagem existe, e isso a ferramenta não sabe.
96
+ marca, cor = "i", "cyan"
97
+ else:
98
+ marca, cor = "v", "green"
99
+ console.print(f" [{cor}]{marca}[/{cor}] [bold]{finding.check}[/bold]")
100
+ console.print(f" [dim]{safe(finding.explain())}[/dim]")
101
+
102
+ console.print(f"\n[bold]{safe(audit.summary())}[/bold]")
103
+ console.print(f"[dim]{safe(audit.caveat())}[/dim]\n")
@@ -0,0 +1,144 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import contextlib
5
+ import tempfile
6
+ from pathlib import Path
7
+
8
+ import typer
9
+ from rich.console import Console
10
+
11
+ from dockerls.cli.dependencies import build_host_guard
12
+ from dockerls.cli.text import safe
13
+ from dockerls.exit_codes import EXIT_ERROR
14
+ from dockerls.integrations.trivy.scanner import TrivyScanner
15
+
16
+ console = Console()
17
+
18
+ _FORMAT_ALIASES = {"cyclonedx": "cyclonedx", "spdx": "spdx-json", "spdx-json": "spdx-json"}
19
+
20
+
21
+ def sbom(
22
+ image: str = typer.Argument(help="Full image reference (e.g., node:22-alpine)"),
23
+ output_format: str = typer.Option("cyclonedx", "--format", "-f", help="cyclonedx or spdx"),
24
+ output: str = typer.Option("", "--output", "-o", help="Output file path (default: stdout)"),
25
+ attest: bool = typer.Option(
26
+ False,
27
+ "--attest",
28
+ help="Publish the SBOM to the registry as a signed cosign attestation. Requires "
29
+ "cosign and a reference by digest",
30
+ ),
31
+ ) -> None:
32
+ """Generate a Software Bill of Materials (SBOM) for an image via Trivy.
33
+
34
+ `--attest` publishes it: the SBOM is signed with cosign and attached to
35
+ the image manifest, which is what `dockerls registry-audit` looks for
36
+ when it asks whether an attestation exists. Without it the SBOM is a
37
+ file on your disk -- useful, and invisible to anyone pulling the image.
38
+
39
+ Attestation is by **digest** only. A tag can move, and an attestation
40
+ that outlives the move would go on describing an image it never saw.
41
+ """
42
+ fmt = _FORMAT_ALIASES.get(output_format.lower())
43
+ if fmt is None:
44
+ console.print(
45
+ f"[red]Unsupported SBOM format: {output_format}. Use cyclonedx or spdx.[/red]"
46
+ )
47
+ raise typer.Exit(EXIT_ERROR)
48
+ if attest and "@sha256:" not in image:
49
+ # Recusado aqui, antes de gerar: descobrir isso depois de escanear
50
+ # a imagem inteira desperdiça o trabalho, e a correção é uma linha
51
+ # de comando diferente e não uma flag a mais.
52
+ console.print(
53
+ "[red]Error:[/red] --attest needs a digest reference "
54
+ "(name@sha256:...), not a tag.\n"
55
+ "[dim]A tag can move, and the attestation would go on describing an image "
56
+ "it never saw. `docker inspect --format='{{index .RepoDigests 0}}' "
57
+ "<image>` gives you the digest.[/dim]"
58
+ )
59
+ raise typer.Exit(EXIT_ERROR)
60
+
61
+ try:
62
+ asyncio.run(_sbom(image, fmt, output, attest=attest))
63
+ except ValueError as e:
64
+ # A malformed image reference is rejected by `sanitize_image_name`
65
+ # inside the scanner; surfacing it as a message keeps `sbom` in line
66
+ # with every other command.
67
+ console.print(f"[red]Invalid image reference:[/red] {e}")
68
+ raise typer.Exit(EXIT_ERROR) from e
69
+
70
+
71
+ async def _sbom(image: str, fmt: str, output: str, *, attest: bool = False) -> None:
72
+ scanner = TrivyScanner(guard=build_host_guard())
73
+ if not await scanner.is_available():
74
+ console.print("[red]Trivy is required for SBOM generation. Run `dockerls doctor`.[/red]")
75
+ raise typer.Exit(EXIT_ERROR)
76
+
77
+ content = await scanner.generate_sbom(image, fmt=fmt)
78
+ if content is None:
79
+ console.print(f"[red]Failed to generate SBOM for {image}[/red]")
80
+ raise typer.Exit(EXIT_ERROR)
81
+
82
+ if output:
83
+ path = Path(output)
84
+ try:
85
+ path.parent.mkdir(parents=True, exist_ok=True)
86
+ path.write_text(content, encoding="utf-8")
87
+ except OSError as e:
88
+ # An unwritable destination is user error, not a traceback.
89
+ console.print(f"[red]Could not write {path}:[/red] {e}")
90
+ raise typer.Exit(EXIT_ERROR) from e
91
+ console.print(f"[green]SBOM written to {output}[/green]")
92
+ elif not attest:
93
+ console.print(content, soft_wrap=True)
94
+
95
+ if attest:
96
+ await _attest(image, content, fmt)
97
+
98
+
99
+ #: O tipo de predicado que o cosign espera para cada formato de SBOM. Sem
100
+ #: isto, o documento é anexado como um predicado genérico e quem consome não
101
+ #: sabe que é um SBOM -- o que é quase o mesmo que não ter anexado.
102
+ _PREDICATE_TYPES = {"cyclonedx": "cyclonedx", "spdx-json": "spdxjson"}
103
+
104
+
105
+ async def _attest(image: str, content: str, fmt: str) -> None:
106
+ """Publica o SBOM como atestação assinada, ou diz por que não publicou.
107
+
108
+ O documento vai para um arquivo temporário e sai dele em qualquer
109
+ caminho: é o inventário completo da imagem, e deixá-lo para trás em
110
+ `/tmp` seria vazar por descuido o que o comando existe para publicar de
111
+ forma controlada.
112
+ """
113
+ from dockerls.integrations.signing.cosign import CosignClient, SignatureStatus
114
+
115
+ with tempfile.NamedTemporaryFile("w", suffix=".json", encoding="utf-8", delete=False) as handle:
116
+ handle.write(content)
117
+ predicate = Path(handle.name)
118
+
119
+ try:
120
+ result = await CosignClient().attest(
121
+ image, predicate=str(predicate), predicate_type=_PREDICATE_TYPES[fmt]
122
+ )
123
+ finally:
124
+ with contextlib.suppress(OSError):
125
+ predicate.unlink()
126
+
127
+ if result.status is SignatureStatus.SIGNED:
128
+ console.print(f"[green]SBOM attested to {safe(image)}[/green]")
129
+ console.print(
130
+ "[dim]`dockerls registry-audit` will now find it, and so will anyone "
131
+ "running `cosign verify-attestation`.[/dim]"
132
+ )
133
+ return
134
+
135
+ if result.status is SignatureStatus.SIGNER_MISSING:
136
+ # Ausência de ferramenta, não falha da imagem. O SBOM foi gerado e
137
+ # continua válido; o que não aconteceu foi a publicação.
138
+ console.print(
139
+ "[yellow]The SBOM was generated but not attested:[/yellow] cosign is not installed."
140
+ )
141
+ raise typer.Exit(EXIT_ERROR)
142
+
143
+ console.print(f"[red]Attestation failed:[/red] {safe(result.detail or result.explain())}")
144
+ raise typer.Exit(EXIT_ERROR)
@@ -0,0 +1,86 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+
5
+ import typer
6
+ from rich.console import Console
7
+ from rich.table import Table
8
+
9
+ from dockerls.application.services.source_registry import UnknownSourceError
10
+ from dockerls.cli.dependencies import build_search_use_case
11
+ from dockerls.cli.image_names import reject_tagged_reference
12
+ from dockerls.cli.validators import check_limit
13
+ from dockerls.exit_codes import EXIT_ERROR
14
+
15
+ console = Console()
16
+
17
+
18
+ def search(
19
+ image: str = typer.Argument(
20
+ help=(
21
+ "Docker image name only, without a tag (e.g. 'node', not 'node:18'). "
22
+ "Use 'analyze' or 'advisor' for a specific tag."
23
+ )
24
+ ),
25
+ limit: int = typer.Option(100, "--limit", "-l", help="Maximum tags to retrieve"),
26
+ source: list[str] = typer.Option(
27
+ [],
28
+ "--source",
29
+ "-s",
30
+ help=(
31
+ "Image source to search; repeatable. "
32
+ "One of: dockerhub, chainguard, distroless, dhi, private (if configured), all"
33
+ ),
34
+ ),
35
+ all_sources: bool = typer.Option(
36
+ False, "--all-sources", help="Search every configured source, including opt-in ones"
37
+ ),
38
+ ) -> None:
39
+ """Search for available tags of an image, on Docker Hub or any configured source."""
40
+ limit = check_limit(limit)
41
+ error = reject_tagged_reference(image, "search")
42
+ if error:
43
+ console.print(f"[red]{error}[/red]")
44
+ raise typer.Exit(EXIT_ERROR)
45
+ try:
46
+ asyncio.run(_search(image, limit, list(source) or None, all_sources))
47
+ except UnknownSourceError as e:
48
+ console.print(f"[red]Error:[/red] {e}")
49
+ raise typer.Exit(EXIT_ERROR) from e
50
+ except ValueError as e:
51
+ # `sanitize_image_name` rejects a malformed reference inside the
52
+ # client; surfacing it as a message keeps `search` in line with
53
+ # every other command instead of answering with a stack trace.
54
+ console.print(f"[red]Invalid image reference:[/red] {e}")
55
+ raise typer.Exit(EXIT_ERROR) from e
56
+
57
+
58
+ async def _search(
59
+ image: str,
60
+ limit: int,
61
+ sources: list[str] | None = None,
62
+ all_sources: bool = False,
63
+ ) -> None:
64
+ use_case = await build_search_use_case(sources, all_sources=all_sources)
65
+ tags = await use_case.execute(image, limit=limit)
66
+
67
+ if not tags:
68
+ console.print(f"[red]No tags found for '{image}'[/red]")
69
+ raise typer.Exit(EXIT_ERROR)
70
+
71
+ table = Table(title=f"Tags for {image}")
72
+ table.add_column("Tag", style="cyan")
73
+ table.add_column("Source", style="magenta")
74
+ table.add_column("Size (MB)", justify="right")
75
+ table.add_column("Architecture")
76
+ table.add_column("Last Updated")
77
+ table.add_column("Official", justify="center")
78
+
79
+ for tag in tags:
80
+ size_mb = f"{tag.size_bytes / (1024 * 1024):.1f}" if tag.size_bytes else "-"
81
+ updated = tag.last_updated.strftime("%Y-%m-%d") if tag.last_updated else "-"
82
+ official = "Yes" if tag.is_official else "No"
83
+ table.add_row(tag.tag, tag.source, size_mb, tag.architecture, updated, official)
84
+
85
+ console.print(table)
86
+ console.print(f"\n[dim]Total: {len(tags)} tags[/dim]")
@@ -0,0 +1,115 @@
1
+ """`dockerls verify` -- quem publicou estes bytes?
2
+
3
+ O `scan` diz o que há dentro de uma imagem e o `provenance` diz de onde ela
4
+ veio. Nenhum dos dois impede alguém com acesso de escrita ao registry de
5
+ sobrescrever a tag com outra coisa: os dois falam sobre o artefato que
6
+ mediram, e a tag deixou de apontar para ele. A assinatura é o elo que fecha
7
+ isso.
8
+
9
+ O comando tem uma regra acima de todas: **`cosign` ausente nunca vira "não
10
+ assinado"**. Confundir os dois acusaria alguém de não assinar por causa de uma
11
+ ferramenta que faltava na máquina -- e, na direção oposta, uma verificação que
12
+ falha em silêncio produz confiança sem base, que é pior do que desconfiança.
13
+
14
+ Verificar sem restringir identidade e emissor responde "alguém assinou", e não
15
+ "quem você espera assinou". O comando aceita rodar assim porque é útil num
16
+ diagnóstico rápido, e **diz na saída** que foi isso que aconteceu.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import asyncio
22
+ import json
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.exit_codes import EXIT_ERROR, EXIT_OK, EXIT_POLICY
30
+ from dockerls.integrations.signing.cosign import CosignClient, SignatureStatus
31
+
32
+ console = Console()
33
+
34
+ _COLORS = {
35
+ SignatureStatus.VERIFIED: "green",
36
+ SignatureStatus.SIGNED: "green",
37
+ SignatureStatus.UNSIGNED: "red",
38
+ # Vermelho e em negrito: é o desfecho mais grave que este comando tem.
39
+ SignatureStatus.VERIFICATION_FAILED: "bold red",
40
+ SignatureStatus.SIGNER_MISSING: "yellow",
41
+ SignatureStatus.FAILED: "yellow",
42
+ }
43
+
44
+
45
+ def verify(
46
+ reference: str = typer.Argument(..., help="Image to verify (ideally by digest)"),
47
+ identity: str = typer.Option(
48
+ "",
49
+ "--identity",
50
+ help="Regex for the identity that must have signed (e.g. 'https://github.com/org/.*')",
51
+ ),
52
+ issuer: str = typer.Option(
53
+ "",
54
+ "--issuer",
55
+ help="Expected OIDC issuer (e.g. https://token.actions.githubusercontent.com)",
56
+ ),
57
+ output_format: str = typer.Option(
58
+ OutputFormat.TABLE.value, "--format", "-f", help="Output format: table or json"
59
+ ),
60
+ no_color: bool = typer.Option(False, "--no-color", help="Disable colored output"),
61
+ ) -> None:
62
+ """Check an image signature with cosign."""
63
+ if no_color:
64
+ console.no_color = True
65
+ fmt = parse_output_format(output_format)
66
+
67
+ result = asyncio.run(
68
+ CosignClient().verify(
69
+ reference,
70
+ certificate_identity_regexp=identity,
71
+ certificate_oidc_issuer=issuer,
72
+ )
73
+ )
74
+
75
+ if fmt == OutputFormat.JSON:
76
+ console.print(json.dumps(result.to_dict(), indent=2, ensure_ascii=False), soft_wrap=True)
77
+ else:
78
+ color = _COLORS.get(result.status, "white")
79
+ console.print(f"\n[bold]{safe(result.reference)}[/bold]")
80
+ console.print(f" [{color}]{result.status}[/{color}] [dim]{safe(result.explain())}[/dim]")
81
+ for who in result.identities:
82
+ console.print(f" [dim]signed by {safe(who)}[/dim]")
83
+ if result.detail and result.status is SignatureStatus.VERIFIED:
84
+ console.print(f" [yellow]![/yellow] [dim]{safe(result.detail)}[/dim]")
85
+ if result.status is SignatureStatus.VERIFICATION_FAILED:
86
+ # O motivo que o cosign deu é a parte acionável, e antes só
87
+ # aparecia para o caso VERIFIED. Uma verificação que falha sem
88
+ # dizer por quê é indistinguível de uma que não rodou.
89
+ if result.detail:
90
+ console.print(f" [red]![/red] [dim]{safe(result.detail)}[/dim]")
91
+ console.print(
92
+ " [dim]Do not treat this as an unsigned image: signing material is "
93
+ "published for this reference and it did not check out.[/dim]"
94
+ )
95
+ if "@sha256:" not in reference:
96
+ console.print(
97
+ "\n[dim]This reference is not a digest: the signature checked is the "
98
+ "one for what the tag points at now, and the tag can move.[/dim]"
99
+ )
100
+ console.print()
101
+
102
+ # Três saídas distintas de propósito. Sem isso, um pipeline não conseguiria
103
+ # diferenciar "esta imagem não está assinada" de "não deu para conferir",
104
+ # e trataria as duas do mesmo jeito -- que é exatamente o erro.
105
+ if result.trustworthy:
106
+ raise typer.Exit(EXIT_OK)
107
+ if result.status.is_conclusive:
108
+ # O cosign rodou e respondeu, e a resposta reprova: ou não há
109
+ # assinatura, ou há e ela não confere. As duas são veredito sobre a
110
+ # imagem -- e por isso as duas saem por EXIT_POLICY, e não pelo
111
+ # EXIT_ERROR reservado a "não deu para conferir".
112
+ raise typer.Exit(EXIT_POLICY)
113
+ # O cosign não rodou, ou rodou e falhou. Isso é falha do medidor, e sai
114
+ # como erro técnico -- nunca como "não assinado".
115
+ raise typer.Exit(EXIT_ERROR)
@@ -0,0 +1,12 @@
1
+ from __future__ import annotations
2
+
3
+ from rich.console import Console
4
+
5
+ from dockerls import __version__
6
+
7
+ console = Console()
8
+
9
+
10
+ def version() -> None:
11
+ """Show DockerLs version."""
12
+ console.print(f"DockerLs v{__version__}")
@@ -0,0 +1,117 @@
1
+ """Emite as isenções deste repositório como um documento OpenVEX.
2
+
3
+ O `.dockerls-ignore.yaml` já é um VEX em tudo menos no formato: tem o CVE,
4
+ tem a justificativa e tem o prazo. O que faltava era escrevê-lo num formato
5
+ que o resto do mundo lê -- Trivy e Grype consomem OpenVEX nativamente --,
6
+ para que uma exceção decidida uma vez valha no pipeline inteiro em vez de
7
+ só dentro desta ferramenta.
8
+
9
+ O que este comando **não** faz é transformar risco aceito em alegação
10
+ técnica. Ver `domain/value_objects/vex.py`: `not_affected` só sai quando a
11
+ regra declara uma das cinco justificativas do padrão.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from pathlib import Path
17
+
18
+ import typer
19
+ from rich.console import Console
20
+
21
+ from dockerls.cli.text import safe
22
+ from dockerls.domain.value_objects.image_reference import split_repository_and_tag
23
+ from dockerls.domain.value_objects.vex import ExemptionInput, build_document
24
+ from dockerls.exit_codes import EXIT_ERROR, EXIT_OK
25
+ from dockerls.utils.ignore_file import DEFAULT_IGNORE_FILENAME, load_ignore_rules
26
+
27
+ console = Console()
28
+ diagnostics = Console(stderr=True)
29
+
30
+
31
+ def vex(
32
+ image: str = typer.Argument(
33
+ help="Image the statements are about (e.g. ghcr.io/org/app:1.0, or a digest)"
34
+ ),
35
+ ignore_file: str | None = typer.Option(
36
+ None,
37
+ "--ignore-file",
38
+ help=f"Exemptions to translate (default: {DEFAULT_IGNORE_FILENAME} in this directory)",
39
+ ),
40
+ author: str = typer.Option(
41
+ "",
42
+ "--author",
43
+ help="Who is making these statements. Required: a VEX with no author "
44
+ "names nobody, and nobody is not accountable",
45
+ ),
46
+ output: str | None = typer.Option(
47
+ None, "--output", "-o", help="Write to a file instead of stdout"
48
+ ),
49
+ ) -> None:
50
+ """Emit this repository's exemptions as an OpenVEX document."""
51
+ if not author.strip():
52
+ console.print(
53
+ "[red]Error:[/red] --author is required.\n"
54
+ "[dim]A VEX statement is an assertion someone makes. Without an author it "
55
+ "asserts nothing anyone can be held to, and consumers have no way to decide "
56
+ "whether to trust it.[/dim]"
57
+ )
58
+ raise typer.Exit(EXIT_ERROR)
59
+
60
+ path = Path(ignore_file) if ignore_file else Path.cwd() / DEFAULT_IGNORE_FILENAME
61
+ if ignore_file and not path.exists():
62
+ # Um caminho explícito que não existe é erro de configuração. Cair
63
+ # no silêncio de "nenhuma regra" produziria um documento vazio que
64
+ # parece uma resposta.
65
+ console.print(f"[red]Error:[/red] {safe(str(path))} does not exist")
66
+ raise typer.Exit(EXIT_ERROR)
67
+
68
+ rules = load_ignore_rules(path)
69
+ document = build_document(
70
+ [
71
+ ExemptionInput(
72
+ cve=rule.cve,
73
+ justification=rule.justification,
74
+ expires=rule.expires,
75
+ vex_justification=rule.vex_justification,
76
+ )
77
+ for rule in rules
78
+ ],
79
+ products=[_product_id(image)],
80
+ author=author.strip(),
81
+ )
82
+
83
+ rendered = document.to_json()
84
+ if output:
85
+ Path(output).write_text(rendered + "\n", encoding="utf-8")
86
+ diagnostics.print(f"[green]Wrote {len(rules)} statement(s) to {safe(output)}[/green]")
87
+ else:
88
+ console.print(rendered, soft_wrap=True)
89
+
90
+ if not rules:
91
+ # Documento vazio é uma resposta válida -- "nada foi isentado" --,
92
+ # mas quem rodou o comando quase sempre esperava statements, e o
93
+ # silêncio faria parecer que o arquivo foi lido e ignorado.
94
+ diagnostics.print(
95
+ f"[yellow]No active exemptions in {safe(str(path))}: the document is empty.[/yellow]\n"
96
+ "[dim]Expired rules are dropped on read, so an exemption that lapsed no longer "
97
+ "appears here -- which is the point of the expiry date.[/dim]"
98
+ )
99
+ raise typer.Exit(EXIT_OK)
100
+
101
+
102
+ def _product_id(image: str) -> str:
103
+ """A referência como um purl OCI, que é o que o padrão espera.
104
+
105
+ Um digest sobrevive intacto: ele já aponta para bytes específicos, que
106
+ é exatamente o que uma afirmação VEX deveria cobrir. Uma tag entra como
107
+ versão porque é o que existe -- e é o consumidor que decide o quanto
108
+ confia numa afirmação sobre um ponteiro móvel.
109
+ """
110
+ reference = image.strip()
111
+ if "@" in reference:
112
+ name, _, digest = reference.partition("@")
113
+ return f"pkg:oci/{name.rsplit('/', 1)[-1]}@{digest}?repository_url={name}"
114
+ repository, tag = split_repository_and_tag(reference)
115
+ short = repository.rsplit("/", 1)[-1]
116
+ version = f"?tag={tag}" if tag else ""
117
+ return f"pkg:oci/{short}{version}&repository_url={repository}" if tag else f"pkg:oci/{short}"