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,104 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from typing import TYPE_CHECKING, Any
5
+
6
+ import typer
7
+ from rich.console import Console
8
+ from rich.table import Table
9
+
10
+ from dockerls.cli.dependencies import build_cache
11
+ from dockerls.exit_codes import EXIT_ERROR
12
+
13
+ if TYPE_CHECKING:
14
+ from collections.abc import Coroutine
15
+
16
+ console = Console()
17
+ #: `no_args_is_help`: `dockerls cache` with no subcommand used to print a bare
18
+ #: "Missing command." and nothing else, forcing anyone who typed it to know
19
+ #: to add `--help` themselves. This lists the subcommands right there instead.
20
+ cache_app = typer.Typer(help="Manage the scan cache", no_args_is_help=True)
21
+
22
+
23
+ def _run(coro: Coroutine[Any, Any, None]) -> None:
24
+ """Run a cache operation, reporting storage failures as user errors.
25
+
26
+ A corrupt or unreadable cache database is a normal operational state --
27
+ it must not exit with a traceback, especially for `clear`, which is
28
+ exactly what a user reaches for when the cache is broken.
29
+ """
30
+ # Import tardio: `sqlalchemy.exc` arrasta o SQLAlchemy inteiro, e este
31
+ # módulo é importado no arranque de *toda* invocação do CLI para registrar
32
+ # o subcomando. Aqui dentro ele só é pago por quem realmente mexe no cache.
33
+ from sqlalchemy.exc import SQLAlchemyError
34
+
35
+ try:
36
+ asyncio.run(coro)
37
+ except (OSError, SQLAlchemyError) as e:
38
+ console.print(f"[red]Cache operation failed:[/red] {e}")
39
+ raise typer.Exit(EXIT_ERROR) from e
40
+
41
+
42
+ @cache_app.command("clear")
43
+ def cache_clear() -> None:
44
+ """Clear all cached scan results."""
45
+ _run(_clear())
46
+
47
+
48
+ async def _clear() -> None:
49
+ cache = build_cache()
50
+ await cache.clear()
51
+ console.print("[green]Cache cleared.[/green]")
52
+
53
+
54
+ @cache_app.command("cleanup")
55
+ def cache_cleanup() -> None:
56
+ """Remove expired cache entries."""
57
+ _run(_cleanup())
58
+
59
+
60
+ async def _cleanup() -> None:
61
+ cache = build_cache()
62
+ count = await cache.cleanup_expired()
63
+ console.print(f"[green]Removed {count} expired entries.[/green]")
64
+
65
+
66
+ @cache_app.command("stats")
67
+ def cache_stats() -> None:
68
+ """Show cache size, entry count, and how much is reclaimable."""
69
+ _run(_stats())
70
+
71
+
72
+ async def _stats() -> None:
73
+ """Report what the cache is holding.
74
+
75
+ Entries expire lazily -- a stale row is dropped when it is next read --
76
+ so a cache can carry entries for tags nobody asks about again. Without a
77
+ way to see that, "why is this directory large" and "would cleanup help"
78
+ were both unanswerable.
79
+ """
80
+ cache = build_cache()
81
+ stats = await cache.stats()
82
+
83
+ table = Table(show_header=False, box=None, padding=(0, 2))
84
+ table.add_column("Field", style="bold")
85
+ table.add_column("Value")
86
+ table.add_row("Location", stats.path)
87
+ table.add_row("Entries", str(stats.total))
88
+ table.add_row("Expired (reclaimable)", str(stats.expired))
89
+ table.add_row("Size on disk", _human_bytes(stats.size_bytes))
90
+ console.print(table)
91
+
92
+ if stats.expired:
93
+ console.print(
94
+ f"\n[dim]Run `dockerls cache cleanup` to remove {stats.expired} expired entries.[/dim]"
95
+ )
96
+
97
+
98
+ def _human_bytes(size: int) -> str:
99
+ value = float(size)
100
+ for unit in ("B", "KB", "MB", "GB"):
101
+ if value < 1024 or unit == "GB":
102
+ return f"{value:.1f} {unit}" if unit != "B" else f"{int(value)} B"
103
+ value /= 1024
104
+ return f"{value:.1f} GB" # pragma: no cover - unreachable, loop returns first
@@ -0,0 +1,177 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from typing import TYPE_CHECKING
5
+
6
+ import typer
7
+ from rich.console import Console
8
+ from rich.panel import Panel
9
+ from rich.table import Table
10
+
11
+ from dockerls.cli.dependencies import build_compare_use_case
12
+ from dockerls.cli.scan_failure import describe_scan_failure
13
+ from dockerls.cli.text import safe
14
+ from dockerls.exit_codes import EXIT_ERROR, EXIT_OK
15
+
16
+ if TYPE_CHECKING:
17
+ from dockerls.application.dto.analysis import ComparisonResult
18
+
19
+ console = Console()
20
+
21
+ # Exit codes, em ordem de severidade:
22
+ # 0 = comparação completa: toda imagem pedida foi escaneada
23
+ # 1 = erro rígido: uso inválido, ou nenhuma imagem pôde ser escaneada
24
+ # 2 = comparação parcial: 2+ escanearam, 1+ falharam
25
+ # 3 = dado insuficiente: menos de duas imagens escaneadas, nada a comparar
26
+ # 0 e 1 vêm do contrato compartilhado; 2 e 3 são próprios de `compare` --
27
+ # a diferença entre "comparei tudo" e "comparei o que deu" é justamente o
28
+ # que um portão de CI precisa poder distinguir.
29
+ EXIT_COMPLETE = EXIT_OK
30
+ EXIT_ERROR_CODE = EXIT_ERROR
31
+ EXIT_PARTIAL = 2
32
+ EXIT_INSUFFICIENT = 3
33
+
34
+
35
+ def compare(
36
+ images: list[str] = typer.Argument(help="Two or more image references to compare"),
37
+ no_color: bool = typer.Option(False, "--no-color", help="Disable colored output"),
38
+ ) -> None:
39
+ """Compare security posture of multiple Docker images.
40
+
41
+ Exit codes: 0 = every image was scanned and compared; 1 = invalid use, or
42
+ no image could be scanned at all; 2 = partial comparison, at least two
43
+ images were scanned and at least one failed; 3 = fewer than two images
44
+ could be scanned, so there is nothing to compare.
45
+
46
+ An image that could not be scanned is never given a score or a tier: it
47
+ is listed separately, with the classified reason.
48
+ """
49
+ if no_color:
50
+ console.no_color = True
51
+ if len(images) < 2:
52
+ console.print("[red]Provide at least two images to compare.[/red]")
53
+ raise typer.Exit(EXIT_ERROR_CODE)
54
+ asyncio.run(_compare(images))
55
+
56
+
57
+ async def _compare(images: list[str]) -> None:
58
+ use_case = await build_compare_use_case()
59
+ try:
60
+ result = await use_case.execute(images)
61
+ except ValueError as e:
62
+ console.print(f"[red]Scan failed: {e}[/red]")
63
+ raise typer.Exit(EXIT_ERROR_CODE) from e
64
+
65
+ # `result.images` já chega filtrado pelo use case: só entra aqui quem
66
+ # tem scan verificado. A tabela abaixo, portanto, não tem como exibir
67
+ # um score que ninguém mediu.
68
+ measured = result.images
69
+
70
+ if not measured:
71
+ # Nada foi medido. Não é um veredito sobre as imagens -- é a
72
+ # ausência de um --, e o código 1 diz isso: falha de execução.
73
+ _print_failures(result)
74
+ console.print(
75
+ "\n[bold red]No image could be scanned.[/bold red]\n"
76
+ "[dim]This is a technical failure, not a security verdict: nothing was "
77
+ "measured, so nothing can be said about these images.[/dim]"
78
+ )
79
+ raise typer.Exit(EXIT_ERROR_CODE)
80
+
81
+ if len(measured) < 2:
82
+ # Uma imagem medida não é uma comparação. Mostrar a tabela com uma
83
+ # linha só sugeriria um vencedor onde não houve disputa.
84
+ _print_failures(result)
85
+ console.print(
86
+ f"\n[bold yellow]Not enough data to compare.[/bold yellow]\n"
87
+ f"[yellow]Only {safe(measured[0].image.full_reference)} could be scanned; "
88
+ f"a comparison needs at least two.[/yellow]"
89
+ )
90
+ raise typer.Exit(EXIT_INSUFFICIENT)
91
+
92
+ console.print(Panel("[bold]Image Comparison[/bold]", expand=False))
93
+
94
+ table = Table()
95
+ table.add_column("Image", style="cyan")
96
+ table.add_column("Score", justify="right", style="green")
97
+ table.add_column("Tier", justify="center")
98
+ table.add_column("Critical", justify="right", style="red")
99
+ table.add_column("High", justify="right", style="yellow")
100
+ table.add_column("Medium", justify="right")
101
+ table.add_column("Total Vulns", justify="right")
102
+ table.add_column("Fixable", justify="right")
103
+ table.add_column("Remediation", justify="right")
104
+
105
+ for a in measured:
106
+ table.add_row(
107
+ safe(a.image.full_reference),
108
+ str(a.security_score),
109
+ a.tier,
110
+ str(a.scan.critical_count),
111
+ str(a.scan.high_count),
112
+ str(a.scan.medium_count),
113
+ str(a.scan.total_count),
114
+ str(a.scan.fixable_count),
115
+ f"{a.remediation_score}/100",
116
+ )
117
+ console.print(table)
118
+
119
+ _print_verdict(result)
120
+ _print_failures(result)
121
+
122
+ if result.unverified:
123
+ console.print(
124
+ f"\n[dim]Partial comparison: {len(measured)} of "
125
+ f"{len(measured) + len(result.unverified)} image(s) were measured.[/dim]"
126
+ )
127
+ raise typer.Exit(EXIT_PARTIAL)
128
+ raise typer.Exit(EXIT_COMPLETE)
129
+
130
+
131
+ def _print_failures(result: ComparisonResult) -> None:
132
+ """As imagens que não puderam ser medidas, com a causa classificada.
133
+
134
+ Seção própria, e nunca uma linha na tabela: o leitor precisa ver de
135
+ imediato que estas não foram comparadas com as outras, e sim deixadas
136
+ de fora por falta de medição.
137
+ """
138
+ if not result.unverified:
139
+ return
140
+ console.print("\n[bold yellow]Failed (not compared)[/bold yellow]")
141
+ console.print("[dim] These images were never scored -- no successful scan.[/dim]")
142
+ for item in result.unverified:
143
+ console.print(
144
+ f" {safe(item.image_reference)} "
145
+ f"[dim]{safe(describe_scan_failure(item.kind, item.reason))}[/dim]"
146
+ )
147
+
148
+
149
+ def _print_verdict(result: ComparisonResult) -> None:
150
+ """Vencedor primeiro, depois cada perdedor com o próprio delta.
151
+
152
+ A versão anterior juntava tudo numa linha -- vencedor, score absoluto e
153
+ diferença separados por ponto e vírgula --, e o `-36.0 points` do final
154
+ lia como um score negativo em vez de uma distância até o vencedor.
155
+ """
156
+ winner = next((a for a in result.images if a.image.full_reference == result.winner), None)
157
+ if winner is None:
158
+ return
159
+
160
+ console.print(
161
+ f"\n[bold green]Winner: {safe(winner.image.full_reference)}[/bold green] "
162
+ f"[dim](Score {winner.security_score}, Tier {winner.tier})[/dim]"
163
+ )
164
+ for a in result.images:
165
+ if a.image.full_reference == result.winner:
166
+ continue
167
+ delta = a.security_score - winner.security_score
168
+ console.print(
169
+ f" {safe(a.image.full_reference)} Score {a.security_score}, Tier {a.tier} "
170
+ f"[dim]({delta:+.1f} vs. winner)[/dim]"
171
+ )
172
+
173
+ if result.common_vulns:
174
+ console.print(f"\n[bold]Shared vulnerabilities:[/bold] {len(result.common_vulns)}")
175
+ for reference, vulns in result.unique_vulns.items():
176
+ if vulns:
177
+ console.print(f"[dim]Unique to {safe(reference)}: {len(vulns)}[/dim]")
@@ -0,0 +1,110 @@
1
+ """`dockerls controls` -- the published controls behind each Dockerfile rule.
2
+
3
+ `analyze-dockerfile` cites a control next to every finding it reports, which
4
+ answers "why is this a problem" at the moment the reader has to act. It does
5
+ not answer the question that comes before that one: *what does this tool
6
+ check, and on whose authority*. Somebody deciding whether to put DockerLs in
7
+ front of a build has to be able to read the whole rulebook without first
8
+ producing a Dockerfile that fails.
9
+
10
+ This command is that rulebook. It also states, per rule, whether the
11
+ citation exists at all: a rule backed by CIS or NIST and a rule backed by
12
+ this project's own judgement are both legitimate, but they are not the same
13
+ claim, and collapsing them would be the same kind of overstatement as
14
+ reporting an unmeasured image as clean.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+
21
+ import typer
22
+ from rich.console import Console
23
+
24
+ from dockerls.cli.options import OutputFormat, parse_output_format
25
+ from dockerls.cli.text import safe
26
+ from dockerls.domain.security_controls import RULE_MAPPINGS, RuleMapping, mapping_for
27
+ from dockerls.exit_codes import EXIT_ERROR, EXIT_OK
28
+
29
+ console = Console()
30
+
31
+ #: Shown when a rule carries no published control. Spelled out rather than
32
+ #: left blank, because a blank reads as "citation omitted".
33
+ UNDOCUMENTED_NOTE = "DockerLs guidance; no published control in the catalogue covers this rule"
34
+
35
+
36
+ def controls(
37
+ rule: str = typer.Argument(
38
+ "", help="Rule to explain (e.g. DF002). Omit to list the whole catalogue."
39
+ ),
40
+ output_format: str = typer.Option(
41
+ OutputFormat.TABLE.value, "--format", "-f", help="Output format: table or json"
42
+ ),
43
+ no_color: bool = typer.Option(False, "--no-color", help="Disable colored output"),
44
+ ) -> None:
45
+ """Show the security controls each Dockerfile rule implements."""
46
+ if no_color:
47
+ console.no_color = True
48
+ fmt = parse_output_format(output_format)
49
+
50
+ if rule:
51
+ mapping = mapping_for(rule)
52
+ if mapping is None:
53
+ _report_unknown(rule, fmt)
54
+ raise typer.Exit(EXIT_ERROR)
55
+ selected: tuple[RuleMapping, ...] = (mapping,)
56
+ else:
57
+ selected = RULE_MAPPINGS
58
+
59
+ if fmt == OutputFormat.JSON:
60
+ console.print(json.dumps([_as_dict(m) for m in selected], indent=2), soft_wrap=True)
61
+ else:
62
+ _render(selected, detailed=bool(rule))
63
+ raise typer.Exit(EXIT_OK)
64
+
65
+
66
+ def _as_dict(mapping: RuleMapping) -> dict[str, object]:
67
+ return {
68
+ "rule_id": mapping.rule_id,
69
+ "summary": mapping.summary,
70
+ "rationale": mapping.rationale,
71
+ "documented": mapping.is_documented,
72
+ "controls": [
73
+ {
74
+ "source": control.source.value,
75
+ "identifier": control.identifier,
76
+ "title": control.title,
77
+ "reference": str(control),
78
+ }
79
+ for control in mapping.controls
80
+ ],
81
+ }
82
+
83
+
84
+ def _report_unknown(rule: str, output_format: OutputFormat) -> None:
85
+ known = ", ".join(mapping.rule_id for mapping in RULE_MAPPINGS)
86
+ message = f"regra desconhecida: {rule}"
87
+ if output_format == OutputFormat.JSON:
88
+ console.print(json.dumps({"error": message, "known_rules": known.split(", ")}))
89
+ return
90
+ console.print(f"[red]Error:[/red] {safe(message)}")
91
+ console.print(f"[dim]Regras catalogadas: {known}[/dim]")
92
+
93
+
94
+ def _render(mappings: tuple[RuleMapping, ...], *, detailed: bool) -> None:
95
+ for mapping in mappings:
96
+ console.print(f"[cyan bold]{mapping.rule_id}[/cyan bold] {safe(mapping.summary)}")
97
+ if detailed:
98
+ console.print(f" [dim]{safe(mapping.rationale)}[/dim]")
99
+ for control in mapping.controls:
100
+ console.print(f" [green]->[/green] {safe(str(control))}")
101
+ if not mapping.is_documented:
102
+ console.print(f" [dim]-> {UNDOCUMENTED_NOTE}[/dim]")
103
+ console.print()
104
+
105
+ if not detailed:
106
+ documented = sum(1 for mapping in mappings if mapping.is_documented)
107
+ console.print(
108
+ f"[dim]{documented} of {len(mappings)} rules cite a published control. "
109
+ "Run `dockerls controls DF002` to see the rationale for one rule.[/dim]"
110
+ )