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,145 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ from rich.console import Console
6
+ from rich.progress import (
7
+ BarColumn,
8
+ Progress,
9
+ SpinnerColumn,
10
+ TaskID,
11
+ TextColumn,
12
+ TimeElapsedColumn,
13
+ )
14
+
15
+ if TYPE_CHECKING:
16
+ from collections.abc import Sequence
17
+ from types import TracebackType
18
+
19
+
20
+ class RichScanObserver:
21
+ """Renders scan progress as a single self-updating line.
22
+
23
+ Exactly one `Progress` (one Rich live display) exists per run, and it
24
+ renders to **stderr** while results are printed to stdout. That split is
25
+ what makes duplication structurally impossible: the live region and the
26
+ results stream are different file objects, so a result can never be
27
+ drawn into a progress frame, and piping stdout leaves the progress
28
+ display on the terminal where it belongs.
29
+
30
+ Anything that writes through `sys.stdout`/`sys.stderr` during the run
31
+ (a stray print, a loguru console sink) is captured by Rich and re-emitted
32
+ above the bar rather than tearing through the live region.
33
+ """
34
+
35
+ def __init__(self, console: Console | None = None, enabled: bool = True):
36
+ # Default to a dedicated stderr console so the caller's stdout
37
+ # console is never shared with the live display.
38
+ self._console = console if console is not None else Console(stderr=True)
39
+ self._enabled = enabled
40
+ self._progress: Progress | None = None
41
+ self._task_id: TaskID | None = None
42
+ self._total = 0
43
+ self._done = 0
44
+ self._entered = False
45
+
46
+ @property
47
+ def progress(self) -> Progress | None:
48
+ """The single live display, or None when disabled/not started."""
49
+ return self._progress
50
+
51
+ def __enter__(self) -> RichScanObserver:
52
+ if self._entered:
53
+ raise RuntimeError(
54
+ "RichScanObserver is not re-entrant: a second live display "
55
+ "would render a duplicate progress bar"
56
+ )
57
+ self._entered = True
58
+ if self._enabled:
59
+ self._progress = Progress(
60
+ SpinnerColumn(),
61
+ TextColumn("[progress.description]{task.description}"),
62
+ BarColumn(bar_width=24),
63
+ TimeElapsedColumn(),
64
+ console=self._console,
65
+ transient=True,
66
+ # Stray writes get rendered above the bar instead of
67
+ # corrupting the live region into a duplicate.
68
+ redirect_stdout=True,
69
+ redirect_stderr=True,
70
+ refresh_per_second=8,
71
+ )
72
+ self._progress.start()
73
+ return self
74
+
75
+ def __exit__(
76
+ self,
77
+ exc_type: type[BaseException] | None,
78
+ exc: BaseException | None,
79
+ tb: TracebackType | None,
80
+ ) -> None:
81
+ if self._progress is not None:
82
+ self._progress.stop()
83
+ self._progress = None
84
+ self._task_id = None
85
+
86
+ def _describe(self, image_reference: str) -> str:
87
+ return f"Scanning {image_reference}... [{self._done + 1}/{self._total}]"
88
+
89
+ def phase(self, description: str) -> None:
90
+ if self._progress is None:
91
+ return
92
+ if self._task_id is None:
93
+ self._task_id = self._progress.add_task(description, total=None)
94
+ else:
95
+ self._progress.update(self._task_id, description=description)
96
+
97
+ def phase_result(self, title: str, facts: Sequence[tuple[str, str]]) -> None:
98
+ """Print what a phase produced, above the live progress line.
99
+
100
+ Rendered as a small tree so a run reads as a sequence of accounted
101
+ steps rather than a spinner that may or may not be stuck:
102
+
103
+ Discovering tags
104
+ ├─ found 100
105
+ ├─ unique digests 84
106
+ └─ duplicates 16
107
+
108
+ Written through `self._console` (stderr), so it never mixes with
109
+ results on stdout and `dockerls recommend > out.txt` still yields a
110
+ clean file. When the display is disabled -- `--no-progress`, or
111
+ `--format json` -- nothing is emitted at all.
112
+ """
113
+ if self._progress is None or not facts:
114
+ return
115
+ width = max(len(label) for label, _ in facts)
116
+ # Leading blank line: consecutive phases run together otherwise, and
117
+ # in a CI log they are the only structure the reader gets.
118
+ lines = ["", f"[bold]{title}[/bold]"]
119
+ for index, (label, value) in enumerate(facts):
120
+ branch = "└─" if index == len(facts) - 1 else "├─"
121
+ lines.append(f"[dim]{branch}[/dim] {label:<{width}} [cyan]{value}[/cyan]")
122
+ # `Progress.console.print` routes through the live display, which
123
+ # moves the bar down and prints above it instead of tearing it.
124
+ self._progress.console.print("\n".join(lines))
125
+
126
+ def start(self, total: int) -> None:
127
+ self._total = total
128
+ self._done = 0
129
+ if self._progress is None:
130
+ return
131
+ if self._task_id is None:
132
+ self._task_id = self._progress.add_task("Scanning...", total=total)
133
+ else:
134
+ self._progress.update(self._task_id, total=total, completed=0)
135
+
136
+ def scanning(self, image_reference: str) -> None:
137
+ if self._progress is None or self._task_id is None:
138
+ return
139
+ self._progress.update(self._task_id, description=self._describe(image_reference))
140
+
141
+ def finished(self, image_reference: str, ok: bool) -> None: # noqa: ARG002
142
+ self._done += 1
143
+ if self._progress is None or self._task_id is None:
144
+ return
145
+ self._progress.update(self._task_id, completed=self._done)
@@ -0,0 +1,123 @@
1
+ """Perguntar destino e responsabilidade **antes** do build, não depois.
2
+
3
+ Três coisas precisam estar decididas antes de um `docker build` começar: para
4
+ onde a imagem vai, quem responde por ela, e para quem se avisa quando ela
5
+ tiver uma vulnerabilidade. Nenhuma delas era perguntada -- o `--push` publicava
6
+ a tag local como está, e `--labels` aceitava um JSON vazio sem exigir nada.
7
+
8
+ A ordem importa e é o ponto do módulo. Descobrir que o destino está errado
9
+ depois de validar, construir e escanear desperdiça o trabalho inteiro, e é
10
+ exatamente quando alguém está mais propenso a publicar em qualquer lugar só
11
+ para não repetir a espera. Rotular depois do build é pior ainda: a imagem já
12
+ existe, e rotular passa a significar reconstruir.
13
+
14
+ Nada aqui pergunta o que já foi respondido por opção de linha de comando, e
15
+ `--non-interactive` (assim como `--ci-mode`) troca a pergunta por uma falha
16
+ explícita, porque um pipeline não tem quem responda e travar esperando entrada
17
+ é o pior comportamento possível num runner.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import sys
23
+
24
+ from rich.console import Console
25
+ from rich.prompt import Prompt
26
+
27
+ from dockerls.domain.value_objects.build_labels import REQUIRED_FIELDS, BuildIdentity
28
+ from dockerls.domain.value_objects.registry_target import RegistryTarget
29
+
30
+ console = Console()
31
+
32
+ #: Exemplos por provedor, mostrados na pergunta do destino. Um formato errado
33
+ #: de Artifact Registry só falharia na hora do push, minutos depois.
34
+ DESTINATION_EXAMPLES = (
35
+ "Azure ACR meuregistro.azurecr.io/apps/minha-app",
36
+ "Google Artifact us-central1-docker.pkg.dev/meu-projeto/containers/minha-app",
37
+ "Google GCR gcr.io/meu-projeto/minha-app",
38
+ "Docker Hub minhaorg/minha-app",
39
+ "Registry privado registry.interna:5000/time/minha-app",
40
+ )
41
+
42
+
43
+ def interactive_available(*, non_interactive: bool) -> bool:
44
+ """Se dá para perguntar: alguém precisa estar do outro lado do terminal."""
45
+ return not non_interactive and sys.stdin.isatty()
46
+
47
+
48
+ def resolve_destination(
49
+ destination: str | None,
50
+ tag: str,
51
+ *,
52
+ non_interactive: bool,
53
+ ) -> RegistryTarget | None:
54
+ """O destino de publicação, perguntado quando não veio por opção.
55
+
56
+ None significa "não publicar", que é uma resposta legítima e o padrão:
57
+ construir sem publicar é o fluxo mais comum.
58
+ """
59
+ answer = (destination or "").strip()
60
+ if not answer:
61
+ if not interactive_available(non_interactive=non_interactive):
62
+ return None
63
+ console.print("\n[bold]Where is this image going?[/bold]")
64
+ for example in DESTINATION_EXAMPLES:
65
+ console.print(f" [dim]{example}[/dim]")
66
+ answer = Prompt.ask(
67
+ "Destination (empty = do not publish)",
68
+ default="",
69
+ show_default=False,
70
+ console=console,
71
+ ).strip()
72
+ if not answer:
73
+ return None
74
+
75
+ target = RegistryTarget.parse(answer, tag)
76
+ target.validate()
77
+ console.print(
78
+ f"[dim]Destino: {target.reference} ({target.provider})\n"
79
+ f"Authentication: {target.login_hint}[/dim]"
80
+ )
81
+ return target
82
+
83
+
84
+ def resolve_identity(
85
+ identity: BuildIdentity,
86
+ *,
87
+ non_interactive: bool,
88
+ ) -> BuildIdentity:
89
+ """Completa os rótulos obrigatórios, perguntando o que falta.
90
+
91
+ Em modo não interativo os que faltarem viram erro em vez de pergunta: um
92
+ pipeline não tem quem responda, e publicar sem responsável é o cenário que
93
+ estes campos existem para impedir.
94
+ """
95
+ missing = identity.missing()
96
+ if not missing:
97
+ return identity
98
+ if not interactive_available(non_interactive=non_interactive):
99
+ identity.require_complete()
100
+ return identity
101
+
102
+ prompts = dict(REQUIRED_FIELDS)
103
+ answers: dict[str, str] = {}
104
+ console.print("\n[bold]Who answers for this image?[/bold]")
105
+ console.print(
106
+ "[dim]Becomes an OCI label on the manifest. It is what someone reads at three "
107
+ "in the morning to find out where the image came from and who to call.[/dim]"
108
+ )
109
+ for name in missing:
110
+ answers[name] = Prompt.ask(f" {prompts[name]}", console=console).strip()
111
+
112
+ completed = BuildIdentity(
113
+ owner=answers.get("owner", identity.owner),
114
+ security_contact=answers.get("security_contact", identity.security_contact),
115
+ source=answers.get("source", identity.source),
116
+ title=identity.title,
117
+ description=identity.description,
118
+ version=identity.version,
119
+ revision=identity.revision,
120
+ extra=identity.extra,
121
+ )
122
+ completed.require_complete()
123
+ return completed
@@ -0,0 +1,214 @@
1
+ """Shared Rich rendering for Dockerfile validation results.
2
+
3
+ `analyze-dockerfile` and `build --validate-only` report the same thing --
4
+ which OWASP checks a Dockerfile passed, warned on or failed -- so they render
5
+ it through the same code. Duplicating the table is how the two commands
6
+ started drifting apart, with `build` printing nothing at all.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import TYPE_CHECKING
12
+
13
+ from rich.panel import Panel
14
+ from rich.table import Table
15
+
16
+ from dockerls.domain.entities.dockerfile_analysis import ValidationStatus
17
+
18
+ if TYPE_CHECKING:
19
+ from rich.console import Console
20
+
21
+ from dockerls.domain.entities.dockerfile_analysis import (
22
+ DockerfileAnalysis,
23
+ DockerfileValidationResult,
24
+ HardeningRule,
25
+ )
26
+
27
+ # Valores de status renderizados, não senhas. Bandit's B105 (hardcoded
28
+ # password) matches on the dict *key* looking like a secret name -- "PASS"
29
+ # does, "WARN"/"FAIL"/"SKIP" don't -- so only that one line ever needs the
30
+ # suppression; the others never triggered anything to suppress.
31
+ _STATUS_ICONS = {
32
+ "PASS": "[green]PASS[/green]", # nosec B105
33
+ "WARN": "[yellow]WARN[/yellow]",
34
+ "FAIL": "[red]FAIL[/red]",
35
+ "SKIP": "[dim]SKIP[/dim]",
36
+ }
37
+
38
+ _SEVERITY_STYLES = {
39
+ "CRITICAL": "red bold",
40
+ "HIGH": "red",
41
+ "MEDIUM": "yellow",
42
+ "LOW": "dim",
43
+ "INFO": "dim",
44
+ }
45
+
46
+ _TIER_COLORS = {"A": "green", "B": "yellow", "C": "yellow", "D": "red", "F": "red"}
47
+
48
+
49
+ def render_validation_report(
50
+ console: Console,
51
+ validation: DockerfileValidationResult,
52
+ analysis: DockerfileAnalysis | None = None,
53
+ suggestions: list[HardeningRule] | None = None,
54
+ title: str = "Dockerfile Analysis Report",
55
+ ) -> None:
56
+ """Render the header, summary, checks table, score and suggestions.
57
+
58
+ Every section is optional except the checks table: a caller that has no
59
+ analysis or no suggestions simply gets fewer panels, never a `None`.
60
+ """
61
+ _render_header(console, validation, title)
62
+ _render_summary(console, validation)
63
+ _render_checks(console, validation)
64
+ if analysis is not None:
65
+ _render_score(console, analysis)
66
+ _render_template_origin(console, analysis)
67
+ if suggestions:
68
+ _render_suggestions(console, suggestions)
69
+
70
+
71
+ def _render_header(console: Console, validation: DockerfileValidationResult, title: str) -> None:
72
+ path = validation.dockerfile_path or "Dockerfile"
73
+ console.print(Panel(f"[bold cyan]{title}[/bold cyan]\n[dim]{path}[/dim]", expand=False))
74
+ console.print()
75
+
76
+
77
+ def _render_summary(console: Console, validation: DockerfileValidationResult) -> None:
78
+ status_color = "green" if validation.errors == 0 else "red"
79
+ console.print(
80
+ f"[{status_color} bold]Summary:[/{status_color} bold] "
81
+ f"[green]{validation.passed} passed[/green] | "
82
+ f"[yellow]{validation.warnings} warnings[/yellow] | "
83
+ f"[red]{validation.errors} errors[/red]"
84
+ )
85
+ console.print()
86
+
87
+
88
+ def _render_checks(console: Console, validation: DockerfileValidationResult) -> None:
89
+ if not validation.checks:
90
+ console.print("[dim]No validation checks were produced for this Dockerfile.[/dim]")
91
+ console.print()
92
+ return
93
+
94
+ table = Table(title="Validation Checks", expand=False)
95
+ table.add_column("Status", style="bold", width=8)
96
+ table.add_column("Check", style="cyan")
97
+ table.add_column("Message", style="white")
98
+ table.add_column("Severity", justify="center")
99
+
100
+ for check in validation.checks:
101
+ severity = check.severity.value
102
+ severity_style = _SEVERITY_STYLES.get(severity, "")
103
+ table.add_row(
104
+ _STATUS_ICONS.get(check.status.value, check.status.value),
105
+ check.check,
106
+ check.message,
107
+ f"[{severity_style}]{severity}[/{severity_style}]" if severity_style else severity,
108
+ )
109
+
110
+ console.print(table)
111
+ console.print()
112
+ _render_controls(console, validation)
113
+
114
+
115
+ def _render_controls(console: Console, validation: DockerfileValidationResult) -> None:
116
+ """Cite the published control behind each finding that failed.
117
+
118
+ Only failures and warnings: a passing check needs no justification, and
119
+ printing a citation for all twelve rules would bury the two the reader
120
+ has to act on.
121
+
122
+ A rule with no published control says so. Leaving the line out would
123
+ read as "citation omitted", and the difference between "this is CIS 4.1"
124
+ and "this is our opinion" is exactly what the reader is entitled to.
125
+ """
126
+ actionable = [
127
+ check
128
+ for check in validation.checks
129
+ if check.status in (ValidationStatus.FAIL, ValidationStatus.WARN) and check.rule_id
130
+ ]
131
+ if not actionable:
132
+ return
133
+
134
+ console.print("[bold]Reference controls[/bold]")
135
+ seen: set[str] = set()
136
+ for check in actionable:
137
+ rule_id = check.rule_id or ""
138
+ if rule_id in seen:
139
+ continue
140
+ seen.add(rule_id)
141
+ console.print(f" [cyan]{rule_id}[/cyan] {check.check}")
142
+ if check.rationale:
143
+ console.print(f" [dim]{check.rationale}[/dim]")
144
+ for reference in check.references:
145
+ console.print(f" [green]->[/green] {reference}")
146
+ if not check.references:
147
+ console.print(
148
+ " [dim]-> DockerLs guidance; no published control in the catalogue "
149
+ "covers this rule[/dim]"
150
+ )
151
+ console.print()
152
+
153
+
154
+ def _render_score(console: Console, analysis: DockerfileAnalysis) -> None:
155
+ tier_color = _TIER_COLORS.get(analysis.security_tier, "white")
156
+ ready = "[green]Yes[/green]" if analysis.is_production_ready else "[red]No[/red]"
157
+ console.print(
158
+ Panel(
159
+ f"[bold]Security Score: {analysis.security_score}/100[/bold]\n"
160
+ f"Tier: [{tier_color} bold]{analysis.security_tier}[/{tier_color} bold]\n"
161
+ f"Production Ready: {ready}",
162
+ expand=False,
163
+ )
164
+ )
165
+ console.print()
166
+
167
+
168
+ def _render_template_origin(console: Console, analysis: DockerfileAnalysis) -> None:
169
+ """This Dockerfile against the hardened template it most resembles.
170
+
171
+ Purely informative -- it does not touch the score or the tier, which
172
+ keep measuring the Dockerfile as it is, not where it came from. Silent
173
+ when nothing matched closely enough: a generic Dockerfile is not "an
174
+ edited template with a 0% match", it is just not one.
175
+ """
176
+ origin = analysis.template_origin
177
+ if origin is None:
178
+ return
179
+
180
+ console.print(
181
+ Panel(
182
+ f"[bold]Derived from template:[/bold] [cyan]{origin.template_name}[/cyan]\n"
183
+ f"Similarity: {origin.similarity:.0%}\n"
184
+ "[dim]Diff against the hardened template shown below.[/dim]",
185
+ title="Template origin",
186
+ expand=False,
187
+ )
188
+ )
189
+ for line in origin.diff[:60]:
190
+ style = ""
191
+ if line.startswith("+") and not line.startswith("+++"):
192
+ style = "green"
193
+ elif line.startswith("-") and not line.startswith("---"):
194
+ style = "red"
195
+ elif line.startswith("@@"):
196
+ style = "cyan"
197
+ console.print(f"[{style}]{line}[/{style}]" if style else line, soft_wrap=True)
198
+ if len(origin.diff) > 60:
199
+ console.print(f"[dim]... {len(origin.diff) - 60} more diff line(s) omitted[/dim]")
200
+ console.print()
201
+
202
+
203
+ def _render_suggestions(console: Console, suggestions: list[HardeningRule]) -> None:
204
+ console.print(Panel("[bold yellow]Recommendations[/bold yellow]", expand=False))
205
+
206
+ for i, suggestion in enumerate(suggestions, 1):
207
+ priority_style = _SEVERITY_STYLES.get(suggestion.priority.value, "")
208
+ console.print(f"\n[{priority_style}]#{i}. {suggestion.title}[/{priority_style}]")
209
+ console.print(f" [dim]{suggestion.description}[/dim]")
210
+ console.print(f" Current: [yellow]{suggestion.current_state}[/yellow]")
211
+ console.print(f" Fix: [green]{suggestion.suggested_fix}[/green]")
212
+ console.print(f" [italic]Reason: {suggestion.reason}[/italic]")
213
+
214
+ console.print()
@@ -0,0 +1,65 @@
1
+ """As Settings do processo e o logging, sem o contêiner de dependências.
2
+
3
+ Isto morava em `cli/dependencies.py`, e o problema não era o código: era o
4
+ módulo. `dependencies.py` importa a aplicação inteira -- pipeline de scan,
5
+ clientes HTTP, pydantic-settings, SQLAlchemy -- e o callback que roda antes
6
+ de *todo* subcomando só precisava daqui. `dockerls version`, que imprime
7
+ uma string, pagava ~250ms para configurar um sink de log.
8
+
9
+ Separado, o callback importa este módulo (Settings + logging, e nada mais)
10
+ e os comandos que de fato trabalham seguem importando `dependencies`, que
11
+ reexporta estes nomes para não mudar nada do lado de quem chama.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from functools import lru_cache
17
+ from typing import TYPE_CHECKING
18
+
19
+ from dockerls.infrastructure.config.settings import Settings
20
+ from dockerls.infrastructure.logging.setup import setup_logging
21
+
22
+ if TYPE_CHECKING:
23
+ from pathlib import Path
24
+
25
+ # Populated by _settings() on first use; exposed so commands can tell the
26
+ # user exactly which file the run's diagnostics landed in.
27
+ _LOG_FILE: Path | None = None
28
+
29
+
30
+ @lru_cache(maxsize=1)
31
+ def _settings() -> Settings:
32
+ global _LOG_FILE
33
+ s = Settings()
34
+ s.ensure_dirs()
35
+ _LOG_FILE = setup_logging(s.log_level, log_dir=s.log_dir)
36
+ return s
37
+
38
+
39
+ def current_log_file() -> Path | None:
40
+ _settings()
41
+ return _LOG_FILE
42
+
43
+
44
+ def configure_logging() -> None:
45
+ """Detach loguru's default stderr sink before any command runs.
46
+
47
+ Until a sink is configured, loguru logs everything from DEBUG up to
48
+ stderr. Commands that never touched Settings -- `build` was one --
49
+ inherited that default and leaked INFO lines into the terminal.
50
+ """
51
+ _settings()
52
+
53
+
54
+ def enable_console_logging() -> None:
55
+ """Re-attach the stderr sink (``--verbose``) on top of the file sink.
56
+
57
+ The stderr sink runs at the configured `log_level` here (INFO by
58
+ default, DEBUG via DOCKERLS_LOG_LEVEL) rather than the WARNING floor
59
+ that applies without ``--verbose``.
60
+ """
61
+ s = _settings()
62
+ global _LOG_FILE
63
+ _LOG_FILE = setup_logging(
64
+ s.log_level, log_dir=s.log_dir, console=True, console_level=s.log_level
65
+ )
@@ -0,0 +1,71 @@
1
+ """A causa de um scan que não completou, em uma linha legível.
2
+
3
+ O terminal mostrava o stderr cru do scanner. Para uma tag inexistente o
4
+ Trivy responde com um bloco de várias linhas que menciona o socket do
5
+ Docker -- um daemon que este modo de scan nem usa --, e o leitor tinha
6
+ que garimpar nesse texto a única informação que importa: a tag não existe
7
+ no registry.
8
+
9
+ A causa já vem classificada em `ScanErrorKind` (ver
10
+ `integrations/scan_errors.py`). Aqui ela vira a frase que o usuário lê. O
11
+ texto bruto continua indo para o arquivo de log e para `--format json`
12
+ via `ScanResult.error_message`: resumir na tela não é esconder, é parar
13
+ de despejar num lugar onde ninguém consegue ler.
14
+
15
+ Uma função só, usada por `analyze`, `compare`, `advisor` e `alternatives`,
16
+ porque quatro cópias da mesma frase divergem na primeira vez que uma
17
+ delas for corrigida.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from dockerls.domain.entities.scan_result import ScanErrorKind
23
+
24
+ #: O que cada causa classificada significa, nos termos de quem lê. São
25
+ #: afirmações sobre o que aconteceu, não instruções -- a ação sugerida é
26
+ #: assunto de quem renderiza (`recommend` tem a sua própria tabela de
27
+ #: dicas, que responde "o que fazer" em vez de "o que houve").
28
+ _CAUSES: dict[ScanErrorKind, str] = {
29
+ ScanErrorKind.NOT_FOUND: "tag not found on the registry",
30
+ ScanErrorKind.AUTH_REQUIRED: "the registry requires credentials",
31
+ ScanErrorKind.RATE_LIMITED: "rate limited by the registry",
32
+ ScanErrorKind.DB_INIT_FAILED: "the vulnerability database could not be prepared",
33
+ ScanErrorKind.TIMEOUT: "the scan exceeded its timeout",
34
+ ScanErrorKind.SCANNER_MISSING: "no scanner executable was found",
35
+ ScanErrorKind.INVALID_OUTPUT: "the scanner produced output that could not be parsed",
36
+ ScanErrorKind.BLOCKED_BY_POLICY: "the network policy refused this host",
37
+ }
38
+
39
+ #: Quanto do stderr cru sobrevive quando não há causa classificada. O
40
+ #: texto inteiro está no log e no JSON; aqui só cabe o suficiente para
41
+ #: distinguir uma falha da outra.
42
+ REASON_MAX_LEN = 90
43
+
44
+
45
+ def short_reason(reason: str) -> str:
46
+ """Colapsa um stderr de várias linhas em uma linha legível."""
47
+ collapsed = " ".join(reason.split())
48
+ if len(collapsed) <= REASON_MAX_LEN:
49
+ return collapsed
50
+ return collapsed[: REASON_MAX_LEN - 3] + "..."
51
+
52
+
53
+ def describe_scan_failure(kind: ScanErrorKind | str, message: str = "") -> str:
54
+ """`KIND -- causa em português claro`, sem o dump do scanner.
55
+
56
+ Sem classificação (UNKNOWN, ou um scanner cuja saída ninguém mapeou
57
+ ainda) sobra o começo do stderr, colapsado numa linha: é pior que uma
58
+ frase, e ainda assim melhor que nada.
59
+ """
60
+ try:
61
+ classified = ScanErrorKind(kind)
62
+ except ValueError:
63
+ # Um `kind` que não é um membro do enum vem de dado externo
64
+ # (cache antigo, JSON de outra versão). Não é motivo para o
65
+ # comando morrer: o rótulo é mostrado como veio.
66
+ return f"{kind} -- {short_reason(message) or 'no details'}"
67
+
68
+ cause = _CAUSES.get(classified)
69
+ if cause:
70
+ return f"{classified.value} -- {cause}"
71
+ return f"{classified.value} -- {short_reason(message) or 'no details'}"
dockerls/cli/text.py ADDED
@@ -0,0 +1,39 @@
1
+ """Render third-party text as text, never as formatting instructions.
2
+
3
+ Rich interprets square-bracket markup in everything it prints, and a large
4
+ share of what this tool prints did not originate here. CVE titles come from
5
+ upstream advisories, package names and file paths come from inside the image
6
+ under analysis, error strings come from a scanner's stderr, and catalogue
7
+ metadata comes from a repository on the internet. All of it is data.
8
+
9
+ Left unescaped, a description reading
10
+
11
+ [red]FIXED - no action needed[/red]
12
+
13
+ is *interpreted*: the brackets vanish, the words are styled, and the report
14
+ now contains a sentence that looks like the tool's own annotation. Whoever
15
+ controls an advisory or a package's metadata controls how a finding is
16
+ presented -- they can style a CRITICAL to look benign, or make text
17
+ disappear. That is the one place in this codebase where untrusted input
18
+ becomes presentation logic, and it closes here.
19
+
20
+ `safe()` is applied at the point of interpolation rather than by turning
21
+ markup off wholesale, because the styling this tool applies to its *own*
22
+ strings is what makes the output readable. The rule is simple: our literals
23
+ may carry markup; anything that arrived from outside goes through `safe`.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ from rich.markup import escape
29
+
30
+
31
+ def safe(value: object) -> str:
32
+ """Escape Rich markup in `value` so it renders exactly as it reads.
33
+
34
+ Accepts any object and stringifies it: the call sites interpolate CVE
35
+ ids, package names, paths, counts and enum values, and requiring the
36
+ caller to remember which of those are already strings is how one gets
37
+ missed.
38
+ """
39
+ return escape("" if value is None else str(value))