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.
- dockerls/__init__.py +31 -0
- dockerls/application/__init__.py +0 -0
- dockerls/application/dto/__init__.py +3 -0
- dockerls/application/dto/analysis.py +257 -0
- dockerls/application/services/__init__.py +0 -0
- dockerls/application/services/alternatives_lookup.py +167 -0
- dockerls/application/services/composite_repository.py +106 -0
- dockerls/application/services/cross_validation.py +233 -0
- dockerls/application/services/ecosystems.py +350 -0
- dockerls/application/services/fallback_scanner.py +97 -0
- dockerls/application/services/hardening_analysis.py +174 -0
- dockerls/application/services/migration.py +297 -0
- dockerls/application/services/progress.py +56 -0
- dockerls/application/services/remediation.py +321 -0
- dockerls/application/services/scan_history_store.py +88 -0
- dockerls/application/services/scanner_factory.py +88 -0
- dockerls/application/services/source_registry.py +151 -0
- dockerls/application/services/tag_history_store.py +76 -0
- dockerls/application/services/teardown.py +50 -0
- dockerls/application/services/verdict.py +298 -0
- dockerls/application/services/version_discovery.py +108 -0
- dockerls/application/use_cases/__init__.py +0 -0
- dockerls/application/use_cases/analyze_dockerfile.py +103 -0
- dockerls/application/use_cases/analyze_image.py +173 -0
- dockerls/application/use_cases/build_image.py +1795 -0
- dockerls/application/use_cases/compare_images.py +91 -0
- dockerls/application/use_cases/fleet_scan.py +240 -0
- dockerls/application/use_cases/recommend_images.py +1078 -0
- dockerls/application/use_cases/registry_audit.py +133 -0
- dockerls/application/use_cases/search_images.py +23 -0
- dockerls/application/use_cases/upgrade_base.py +167 -0
- dockerls/cache/__init__.py +0 -0
- dockerls/cache/sqlite_cache.py +184 -0
- dockerls/cli/__init__.py +0 -0
- dockerls/cli/analysis_baseline.py +98 -0
- dockerls/cli/app.py +294 -0
- dockerls/cli/commands/__init__.py +0 -0
- dockerls/cli/commands/advisor.py +262 -0
- dockerls/cli/commands/alternatives.py +291 -0
- dockerls/cli/commands/analyze.py +429 -0
- dockerls/cli/commands/analyze_dockerfile.py +104 -0
- dockerls/cli/commands/base_cmd.py +244 -0
- dockerls/cli/commands/base_image.py +551 -0
- dockerls/cli/commands/build.py +1300 -0
- dockerls/cli/commands/cache_cmd.py +104 -0
- dockerls/cli/commands/compare.py +177 -0
- dockerls/cli/commands/controls.py +110 -0
- dockerls/cli/commands/doctor.py +566 -0
- dockerls/cli/commands/export.py +81 -0
- dockerls/cli/commands/fleet.py +159 -0
- dockerls/cli/commands/health.py +84 -0
- dockerls/cli/commands/login.py +53 -0
- dockerls/cli/commands/policy_cmd.py +111 -0
- dockerls/cli/commands/provenance_cmd.py +162 -0
- dockerls/cli/commands/recommend.py +761 -0
- dockerls/cli/commands/registry_audit_cmd.py +103 -0
- dockerls/cli/commands/sbom.py +144 -0
- dockerls/cli/commands/search.py +86 -0
- dockerls/cli/commands/verify.py +115 -0
- dockerls/cli/commands/version.py +12 -0
- dockerls/cli/commands/vex_cmd.py +117 -0
- dockerls/cli/dependencies.py +530 -0
- dockerls/cli/image_names.py +79 -0
- dockerls/cli/options.py +42 -0
- dockerls/cli/progress.py +145 -0
- dockerls/cli/publish_prompt.py +123 -0
- dockerls/cli/rendering.py +214 -0
- dockerls/cli/runtime.py +65 -0
- dockerls/cli/scan_failure.py +71 -0
- dockerls/cli/text.py +39 -0
- dockerls/cli/validators.py +35 -0
- dockerls/cli/vulnerability_view.py +154 -0
- dockerls/domain/__init__.py +0 -0
- dockerls/domain/entities/__init__.py +75 -0
- dockerls/domain/entities/declared_metadata.py +147 -0
- dockerls/domain/entities/dockerfile_analysis.py +318 -0
- dockerls/domain/entities/image.py +109 -0
- dockerls/domain/entities/image_facts.py +137 -0
- dockerls/domain/entities/recommendation.py +33 -0
- dockerls/domain/entities/scan_result.py +129 -0
- dockerls/domain/entities/vulnerability.py +232 -0
- dockerls/domain/interfaces/__init__.py +17 -0
- dockerls/domain/interfaces/cache_store.py +18 -0
- dockerls/domain/interfaces/dockerfile_validator.py +99 -0
- dockerls/domain/interfaces/eol_checker.py +11 -0
- dockerls/domain/interfaces/image_repository.py +15 -0
- dockerls/domain/interfaces/scanner.py +15 -0
- dockerls/domain/security_controls.py +362 -0
- dockerls/domain/value_objects/__init__.py +49 -0
- dockerls/domain/value_objects/attack_surface.py +198 -0
- dockerls/domain/value_objects/base_recipe.py +600 -0
- dockerls/domain/value_objects/base_upgrade.py +292 -0
- dockerls/domain/value_objects/build_labels.py +99 -0
- dockerls/domain/value_objects/build_policy.py +412 -0
- dockerls/domain/value_objects/confidence.py +156 -0
- dockerls/domain/value_objects/fleet.py +174 -0
- dockerls/domain/value_objects/gate.py +327 -0
- dockerls/domain/value_objects/hardening.py +303 -0
- dockerls/domain/value_objects/image_reference.py +90 -0
- dockerls/domain/value_objects/inheritance.py +352 -0
- dockerls/domain/value_objects/network_policy.py +280 -0
- dockerls/domain/value_objects/production_readiness.py +145 -0
- dockerls/domain/value_objects/provenance.py +211 -0
- dockerls/domain/value_objects/recipe_diff.py +188 -0
- dockerls/domain/value_objects/registry_audit.py +195 -0
- dockerls/domain/value_objects/registry_target.py +225 -0
- dockerls/domain/value_objects/remediation_score.py +62 -0
- dockerls/domain/value_objects/scan_history.py +183 -0
- dockerls/domain/value_objects/scan_plan.py +193 -0
- dockerls/domain/value_objects/scanner_db.py +143 -0
- dockerls/domain/value_objects/security_score.py +160 -0
- dockerls/domain/value_objects/security_tier.py +122 -0
- dockerls/domain/value_objects/tag_history.py +180 -0
- dockerls/domain/value_objects/tool_release.py +253 -0
- dockerls/domain/value_objects/tristate.py +47 -0
- dockerls/domain/value_objects/vex.py +249 -0
- dockerls/exit_codes.py +23 -0
- dockerls/exporters/__init__.py +0 -0
- dockerls/exporters/base.py +17 -0
- dockerls/exporters/csv_exporter.py +85 -0
- dockerls/exporters/factory.py +31 -0
- dockerls/exporters/html_exporter.py +105 -0
- dockerls/exporters/json_exporter.py +19 -0
- dockerls/exporters/markdown_exporter.py +84 -0
- dockerls/exporters/sarif_exporter.py +245 -0
- dockerls/infrastructure/__init__.py +0 -0
- dockerls/infrastructure/config/__init__.py +0 -0
- dockerls/infrastructure/config/policy_file.py +165 -0
- dockerls/infrastructure/config/settings.py +197 -0
- dockerls/infrastructure/database/__init__.py +0 -0
- dockerls/infrastructure/database/models.py +78 -0
- dockerls/infrastructure/dockerfile_validator.py +1899 -0
- dockerls/infrastructure/evidence.py +99 -0
- dockerls/infrastructure/hashing.py +165 -0
- dockerls/infrastructure/logging/__init__.py +0 -0
- dockerls/infrastructure/logging/setup.py +98 -0
- dockerls/infrastructure/network/__init__.py +0 -0
- dockerls/infrastructure/network/guarded_client.py +107 -0
- dockerls/infrastructure/network/host_guard.py +117 -0
- dockerls/infrastructure/redaction.py +135 -0
- dockerls/infrastructure/templates/hardening/alpine.dockerfile +44 -0
- dockerls/infrastructure/templates/hardening/debian.dockerfile +45 -0
- dockerls/infrastructure/templates/hardening/distroless.dockerfile +34 -0
- dockerls/infrastructure/templates/hardening/go-alpine.dockerfile +52 -0
- dockerls/infrastructure/templates/hardening/go-debian.dockerfile +54 -0
- dockerls/infrastructure/templates/hardening/go-distroless.dockerfile +43 -0
- dockerls/infrastructure/templates/hardening/go-scratch.dockerfile +48 -0
- dockerls/infrastructure/templates/hardening/go.dockerfile +50 -0
- dockerls/infrastructure/templates/hardening/gradle-alpine.dockerfile +51 -0
- dockerls/infrastructure/templates/hardening/gradle.dockerfile +52 -0
- dockerls/infrastructure/templates/hardening/java-alpine.dockerfile +50 -0
- dockerls/infrastructure/templates/hardening/java-debian.dockerfile +50 -0
- dockerls/infrastructure/templates/hardening/java-distroless.dockerfile +39 -0
- dockerls/infrastructure/templates/hardening/java-ubuntu.dockerfile +54 -0
- dockerls/infrastructure/templates/hardening/java.dockerfile +60 -0
- dockerls/infrastructure/templates/hardening/maven-alpine.dockerfile +56 -0
- dockerls/infrastructure/templates/hardening/maven.dockerfile +57 -0
- dockerls/infrastructure/templates/hardening/node-alpine.dockerfile +47 -0
- dockerls/infrastructure/templates/hardening/node-debian.dockerfile +54 -0
- dockerls/infrastructure/templates/hardening/node-distroless.dockerfile +46 -0
- dockerls/infrastructure/templates/hardening/node-ubuntu.dockerfile +63 -0
- dockerls/infrastructure/templates/hardening/node.dockerfile +61 -0
- dockerls/infrastructure/templates/hardening/php-alpine.dockerfile +45 -0
- dockerls/infrastructure/templates/hardening/php-debian.dockerfile +45 -0
- dockerls/infrastructure/templates/hardening/php-ubuntu.dockerfile +49 -0
- dockerls/infrastructure/templates/hardening/php.dockerfile +44 -0
- dockerls/infrastructure/templates/hardening/python-alpine.dockerfile +51 -0
- dockerls/infrastructure/templates/hardening/python-debian.dockerfile +54 -0
- dockerls/infrastructure/templates/hardening/python-distroless.dockerfile +51 -0
- dockerls/infrastructure/templates/hardening/python-ubuntu.dockerfile +60 -0
- dockerls/infrastructure/templates/hardening/python.dockerfile +58 -0
- dockerls/infrastructure/templates/hardening/ruby-alpine.dockerfile +48 -0
- dockerls/infrastructure/templates/hardening/ruby-debian.dockerfile +50 -0
- dockerls/infrastructure/templates/hardening/rust-alpine.dockerfile +50 -0
- dockerls/infrastructure/templates/hardening/rust-debian.dockerfile +48 -0
- dockerls/infrastructure/templates/hardening/rust-scratch.dockerfile +44 -0
- dockerls/infrastructure/templates/hardening/rust.dockerfile +54 -0
- dockerls/infrastructure/templates/hardening/ubuntu.dockerfile +49 -0
- dockerls/infrastructure/toolchain/__init__.py +0 -0
- dockerls/infrastructure/toolchain/db_metadata.py +115 -0
- dockerls/infrastructure/toolchain/installer.py +435 -0
- dockerls/integrations/__init__.py +0 -0
- dockerls/integrations/dhi/__init__.py +0 -0
- dockerls/integrations/dhi/catalog.py +457 -0
- dockerls/integrations/dhi/definition.py +151 -0
- dockerls/integrations/dhi/repository.py +238 -0
- dockerls/integrations/dockerhub/__init__.py +0 -0
- dockerls/integrations/dockerhub/client.py +318 -0
- dockerls/integrations/dockerhub/urls.py +75 -0
- dockerls/integrations/endoflife/__init__.py +0 -0
- dockerls/integrations/endoflife/checker.py +216 -0
- dockerls/integrations/engine/__init__.py +0 -0
- dockerls/integrations/engine/batch.py +197 -0
- dockerls/integrations/engine/client.py +330 -0
- dockerls/integrations/engine/locator.py +96 -0
- dockerls/integrations/exploitdb/__init__.py +0 -0
- dockerls/integrations/exploitdb/client.py +271 -0
- dockerls/integrations/grype/__init__.py +0 -0
- dockerls/integrations/grype/scanner.py +336 -0
- dockerls/integrations/registry/__init__.py +0 -0
- dockerls/integrations/registry/hardened.py +284 -0
- dockerls/integrations/registry/inspector.py +420 -0
- dockerls/integrations/registry/oci.py +259 -0
- dockerls/integrations/registry/private.py +79 -0
- dockerls/integrations/registry/urls.py +36 -0
- dockerls/integrations/scan_errors.py +67 -0
- dockerls/integrations/scan_target.py +57 -0
- dockerls/integrations/signing/__init__.py +0 -0
- dockerls/integrations/signing/cosign.py +484 -0
- dockerls/integrations/threat_intel/__init__.py +0 -0
- dockerls/integrations/threat_intel/client.py +290 -0
- dockerls/integrations/trivy/__init__.py +0 -0
- dockerls/integrations/trivy/cache_pool.py +176 -0
- dockerls/integrations/trivy/scanner.py +447 -0
- dockerls/utils/__init__.py +0 -0
- dockerls/utils/auth.py +108 -0
- dockerls/utils/executables.py +39 -0
- dockerls/utils/ignore_file.py +130 -0
- dockerls/utils/rate_limit.py +130 -0
- dockerls/utils/resources.py +183 -0
- dockerls/utils/retry.py +31 -0
- dockerls/utils/safe_yaml.py +166 -0
- dockerls/utils/subprocess_runner.py +216 -0
- dockerls/utils/validation.py +72 -0
- dockerls-1.0.0.dist-info/METADATA +563 -0
- dockerls-1.0.0.dist-info/RECORD +230 -0
- dockerls-1.0.0.dist-info/WHEEL +5 -0
- dockerls-1.0.0.dist-info/entry_points.txt +2 -0
- dockerls-1.0.0.dist-info/licenses/LICENSE +21 -0
- dockerls-1.0.0.dist-info/top_level.txt +1 -0
dockerls/cli/app.py
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
"""O ponto de entrada da CLI, com os subcomandos carregados sob demanda.
|
|
2
|
+
|
|
3
|
+
Este módulo importava os 24 comandos no topo, e cada um deles puxa a
|
|
4
|
+
árvore inteira da aplicação: o pipeline de scan, os clientes HTTP, o
|
|
5
|
+
pydantic-settings, o SQLAlchemy. Como o `import` é o que registra o
|
|
6
|
+
comando no Typer, `dockerls version` -- que só imprime uma string --
|
|
7
|
+
pagava os mesmos ~390ms de arranque que `dockerls advisor`.
|
|
8
|
+
|
|
9
|
+
Aqui o registro deixa de depender do import. `_LazyGroup` conhece os
|
|
10
|
+
comandos por uma tabela estática (nome, módulo, resumo) e só importa o
|
|
11
|
+
módulo quando o comando é de fato invocado. O `--help` do grupo é
|
|
12
|
+
desenhado a partir da tabela, sem importar nada.
|
|
13
|
+
|
|
14
|
+
O preço é a tabela precisar acompanhar as docstrings, e é por isso que
|
|
15
|
+
`tests/unit/cli/test_lazy_command_table.py` compara as duas: um comando
|
|
16
|
+
novo, renomeado ou com o resumo alterado quebra o teste antes de chegar
|
|
17
|
+
na CLI.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import sys
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from importlib import import_module
|
|
25
|
+
from typing import TYPE_CHECKING, Any
|
|
26
|
+
|
|
27
|
+
import typer
|
|
28
|
+
from typer.core import TyperCommand, TyperGroup
|
|
29
|
+
from typer.main import get_command_from_info, get_group
|
|
30
|
+
from typer.models import CommandInfo
|
|
31
|
+
|
|
32
|
+
if TYPE_CHECKING:
|
|
33
|
+
from collections.abc import MutableMapping
|
|
34
|
+
|
|
35
|
+
from typer import _click
|
|
36
|
+
|
|
37
|
+
#: O click que o Typer usa. A partir do Typer 0.27 ele é vendorizado em
|
|
38
|
+
#: `typer._click`, e não há mais um `click` de topo garantido no ambiente;
|
|
39
|
+
#: `typer.core.TyperCommand`/`TyperGroup` são as classes públicas
|
|
40
|
+
#: correspondentes, e é delas que herdamos.
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class _Lazy:
|
|
45
|
+
"""Um subcomando e onde encontrá-lo, sem importá-lo."""
|
|
46
|
+
|
|
47
|
+
#: Nome na linha de comando.
|
|
48
|
+
name: str
|
|
49
|
+
#: Módulo em `dockerls.cli.commands`.
|
|
50
|
+
module: str
|
|
51
|
+
#: Nome do objeto dentro do módulo.
|
|
52
|
+
attr: str
|
|
53
|
+
#: Primeiro parágrafo da docstring, que é o que o `--help` do grupo
|
|
54
|
+
#: mostra. Duplicado aqui de propósito: é o único jeito de listar os
|
|
55
|
+
#: comandos sem importar todos eles.
|
|
56
|
+
summary: str
|
|
57
|
+
#: `True` quando o objeto é um `typer.Typer` (um subgrupo) em vez de
|
|
58
|
+
#: uma função de comando.
|
|
59
|
+
is_group: bool = False
|
|
60
|
+
|
|
61
|
+
def load(self, *, rich_markup_mode: Any) -> _click.Command:
|
|
62
|
+
"""Importa o módulo e monta o comando click de verdade."""
|
|
63
|
+
target = getattr(import_module(f"dockerls.cli.commands.{self.module}"), self.attr)
|
|
64
|
+
if self.is_group:
|
|
65
|
+
group = get_group(target)
|
|
66
|
+
group.name = self.name
|
|
67
|
+
group.help = self.summary
|
|
68
|
+
return group
|
|
69
|
+
command = get_command_from_info(
|
|
70
|
+
CommandInfo(name=self.name, callback=target),
|
|
71
|
+
pretty_exceptions_short=False,
|
|
72
|
+
rich_markup_mode=rich_markup_mode,
|
|
73
|
+
)
|
|
74
|
+
return command
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
#: Os subcomandos, na ordem em que aparecem no `--help`.
|
|
78
|
+
COMMANDS: tuple[_Lazy, ...] = (
|
|
79
|
+
_Lazy(
|
|
80
|
+
"search",
|
|
81
|
+
"search",
|
|
82
|
+
"search",
|
|
83
|
+
"Search for available tags of an image, on Docker Hub or any configured source.",
|
|
84
|
+
),
|
|
85
|
+
_Lazy("recommend", "recommend", "recommend", "Recommend the most secure Docker image tags."),
|
|
86
|
+
_Lazy(
|
|
87
|
+
"advisor",
|
|
88
|
+
"advisor",
|
|
89
|
+
"advisor",
|
|
90
|
+
"Security advisor: analyze and provide actionable remediation plan.",
|
|
91
|
+
),
|
|
92
|
+
_Lazy(
|
|
93
|
+
"alternatives",
|
|
94
|
+
"alternatives",
|
|
95
|
+
"alternatives",
|
|
96
|
+
"Find safer alternatives to an image you already run, with trade-offs.",
|
|
97
|
+
),
|
|
98
|
+
_Lazy("analyze", "analyze", "analyze", "Deep-analyze a specific Docker image tag."),
|
|
99
|
+
_Lazy(
|
|
100
|
+
"analyze-dockerfile",
|
|
101
|
+
"analyze_dockerfile",
|
|
102
|
+
"analyze",
|
|
103
|
+
"Analyze a Dockerfile for security problems.",
|
|
104
|
+
),
|
|
105
|
+
_Lazy(
|
|
106
|
+
"base",
|
|
107
|
+
"base_cmd",
|
|
108
|
+
"base",
|
|
109
|
+
"Check the Dockerfile bases against the registry and refresh their digests.",
|
|
110
|
+
),
|
|
111
|
+
_Lazy(
|
|
112
|
+
"base-image",
|
|
113
|
+
"base_image",
|
|
114
|
+
"base_image",
|
|
115
|
+
"Generate the Dockerfile for a base image from a menu of choices.",
|
|
116
|
+
),
|
|
117
|
+
_Lazy(
|
|
118
|
+
"build",
|
|
119
|
+
"build",
|
|
120
|
+
"build",
|
|
121
|
+
"Build secure Docker images with validation, scanning and auto-remediation.",
|
|
122
|
+
),
|
|
123
|
+
_Lazy("compare", "compare", "compare", "Compare security posture of multiple Docker images."),
|
|
124
|
+
_Lazy(
|
|
125
|
+
"controls",
|
|
126
|
+
"controls",
|
|
127
|
+
"controls",
|
|
128
|
+
"Show the security controls each Dockerfile rule implements.",
|
|
129
|
+
),
|
|
130
|
+
_Lazy(
|
|
131
|
+
"fleet",
|
|
132
|
+
"fleet",
|
|
133
|
+
"fleet",
|
|
134
|
+
"Scan a tree of repositories and summarise the state of its Dockerfiles.",
|
|
135
|
+
),
|
|
136
|
+
_Lazy(
|
|
137
|
+
"policy",
|
|
138
|
+
"policy_cmd",
|
|
139
|
+
"policy",
|
|
140
|
+
"Show and validate the policy declared in `.dockerls-policy.yaml`.",
|
|
141
|
+
),
|
|
142
|
+
_Lazy(
|
|
143
|
+
"provenance",
|
|
144
|
+
"provenance_cmd",
|
|
145
|
+
"provenance",
|
|
146
|
+
"Check a provenance document and prepare the attestation.",
|
|
147
|
+
),
|
|
148
|
+
_Lazy(
|
|
149
|
+
"registry-audit",
|
|
150
|
+
"registry_audit_cmd",
|
|
151
|
+
"registry_audit",
|
|
152
|
+
"Establish, through the registry, what is known about a published image.",
|
|
153
|
+
),
|
|
154
|
+
_Lazy("verify", "verify", "verify", "Check an image signature with cosign."),
|
|
155
|
+
_Lazy("export", "export", "export", "Export analysis results in various formats."),
|
|
156
|
+
_Lazy(
|
|
157
|
+
"login",
|
|
158
|
+
"login",
|
|
159
|
+
"login",
|
|
160
|
+
"Authenticate with Docker Hub. Credentials are stored in your system keyring.",
|
|
161
|
+
),
|
|
162
|
+
_Lazy("logout", "login", "logout", "Remove stored Docker Hub credentials."),
|
|
163
|
+
_Lazy("version", "version", "version", "Show DockerLs version."),
|
|
164
|
+
_Lazy("doctor", "doctor", "doctor", "Check system dependencies and configuration."),
|
|
165
|
+
_Lazy("health", "health", "health", "Check connectivity to external services."),
|
|
166
|
+
_Lazy(
|
|
167
|
+
"sbom",
|
|
168
|
+
"sbom",
|
|
169
|
+
"sbom",
|
|
170
|
+
"Generate a Software Bill of Materials (SBOM) for an image via Trivy.",
|
|
171
|
+
),
|
|
172
|
+
_Lazy(
|
|
173
|
+
"vex",
|
|
174
|
+
"vex_cmd",
|
|
175
|
+
"vex",
|
|
176
|
+
"Emit this repository's exemptions as an OpenVEX document.",
|
|
177
|
+
),
|
|
178
|
+
_Lazy("cache", "cache_cmd", "cache_app", "Manage scan cache", is_group=True),
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
_BY_NAME: dict[str, _Lazy] = {spec.name: spec for spec in COMMANDS}
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
class _Stub(TyperCommand):
|
|
185
|
+
"""O comando como o `--help` do grupo o vê: nome e resumo, mais nada.
|
|
186
|
+
|
|
187
|
+
Existe para que listar os comandos não importe nenhum deles. Tudo que
|
|
188
|
+
precisa do comando de verdade -- executar, mostrar o próprio `--help`,
|
|
189
|
+
completar argumentos -- passa por `make_context`/`shell_complete`, e
|
|
190
|
+
esses resolvem o módulo antes de responder.
|
|
191
|
+
"""
|
|
192
|
+
|
|
193
|
+
def __init__(self, spec: _Lazy, rich_markup_mode: Any) -> None:
|
|
194
|
+
super().__init__(name=spec.name, short_help=spec.summary)
|
|
195
|
+
self._spec = spec
|
|
196
|
+
self._rich_markup_mode = rich_markup_mode
|
|
197
|
+
|
|
198
|
+
def _real(self) -> _click.Command:
|
|
199
|
+
return self._spec.load(rich_markup_mode=self._rich_markup_mode)
|
|
200
|
+
|
|
201
|
+
def make_context(
|
|
202
|
+
self,
|
|
203
|
+
info_name: str | None,
|
|
204
|
+
args: list[str],
|
|
205
|
+
parent: _click.Context | None = None,
|
|
206
|
+
**extra: Any,
|
|
207
|
+
) -> _click.Context:
|
|
208
|
+
# O contexto devolvido carrega o comando de verdade, e é dele que
|
|
209
|
+
# o click tira o `invoke`: o stub some da execução aqui.
|
|
210
|
+
return self._real().make_context(info_name, args, parent=parent, **extra)
|
|
211
|
+
|
|
212
|
+
def shell_complete(self, ctx: _click.Context, incomplete: str) -> list[Any]:
|
|
213
|
+
return self._real().shell_complete(ctx, incomplete)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
class _LazyGroup(TyperGroup):
|
|
217
|
+
"""O grupo raiz: conhece os nomes sempre, os módulos só quando preciso."""
|
|
218
|
+
|
|
219
|
+
def list_commands(self, ctx: _click.Context) -> list[str]:
|
|
220
|
+
return [spec.name for spec in COMMANDS]
|
|
221
|
+
|
|
222
|
+
def get_command(self, ctx: _click.Context, cmd_name: str) -> _click.Command | None:
|
|
223
|
+
spec = _BY_NAME.get(cmd_name)
|
|
224
|
+
if spec is None:
|
|
225
|
+
return None
|
|
226
|
+
return _Stub(spec, self.rich_markup_mode)
|
|
227
|
+
|
|
228
|
+
@property
|
|
229
|
+
def commands(self) -> MutableMapping[str, _click.Command]:
|
|
230
|
+
"""Compatibilidade: click e typer leem `.commands` em alguns
|
|
231
|
+
caminhos (completação, `to_info_dict`). Devolver os stubs mantém
|
|
232
|
+
esses caminhos funcionando sem importar nada."""
|
|
233
|
+
return {spec.name: _Stub(spec, self.rich_markup_mode) for spec in COMMANDS}
|
|
234
|
+
|
|
235
|
+
@commands.setter
|
|
236
|
+
def commands(self, value: MutableMapping[str, _click.Command]) -> None:
|
|
237
|
+
# `TyperGroup.__init__` atribui o dicionário (vazio) de comandos
|
|
238
|
+
# registrados; a tabela estática é a fonte da verdade, então a
|
|
239
|
+
# atribuição é descartada.
|
|
240
|
+
return
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
app = typer.Typer(
|
|
244
|
+
name="dockerls",
|
|
245
|
+
cls=_LazyGroup,
|
|
246
|
+
help="DockerLs -- Enterprise Docker Image Security Advisor. "
|
|
247
|
+
"Discover the most secure Docker images available on Docker Hub.",
|
|
248
|
+
no_args_is_help=True,
|
|
249
|
+
pretty_exceptions_enable=False,
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def configure_logging() -> None:
|
|
254
|
+
"""Detach loguru's default sink, importing the container only now.
|
|
255
|
+
|
|
256
|
+
Indireção de propósito: nem `cli.runtime` pode ser importado no topo,
|
|
257
|
+
ou `dockerls --help` -- que não configura sink nenhum, porque nem chega
|
|
258
|
+
a invocar um subcomando -- pagaria o pydantic-settings junto. Manter o
|
|
259
|
+
nome aqui, em vez de esconder o import dentro de `_bootstrap`, preserva
|
|
260
|
+
o ponto de substituição que os testes de bootstrap usam.
|
|
261
|
+
"""
|
|
262
|
+
from dockerls.cli import runtime
|
|
263
|
+
|
|
264
|
+
runtime.configure_logging()
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
@app.callback()
|
|
268
|
+
def _bootstrap() -> None:
|
|
269
|
+
"""Runs before every subcommand, so no command can start with loguru's
|
|
270
|
+
default DEBUG-to-stderr sink still attached."""
|
|
271
|
+
configure_logging()
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def main() -> None:
|
|
275
|
+
try:
|
|
276
|
+
app()
|
|
277
|
+
finally:
|
|
278
|
+
_dispose_cache_if_loaded()
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _dispose_cache_if_loaded() -> None:
|
|
282
|
+
"""Close the shared SQLite engine, without paying to import the module
|
|
283
|
+
that owns it when the command never touched it.
|
|
284
|
+
|
|
285
|
+
`cli.dependencies` pulls in SQLAlchemy and the rest of the application
|
|
286
|
+
stack, which is exactly what the lazy command table above exists to
|
|
287
|
+
keep `dockerls --help`/`version` from paying for. Checking
|
|
288
|
+
`sys.modules` instead of importing unconditionally means a command that
|
|
289
|
+
never built a cache -- which never imports `cli.dependencies` at all --
|
|
290
|
+
still doesn't, even after it finishes.
|
|
291
|
+
"""
|
|
292
|
+
dependencies = sys.modules.get("dockerls.cli.dependencies")
|
|
293
|
+
if dependencies is not None:
|
|
294
|
+
dependencies.close_cache()
|
|
File without changes
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
from rich.panel import Panel
|
|
10
|
+
from rich.table import Table
|
|
11
|
+
|
|
12
|
+
from dockerls.application.services.ecosystems import get_ecosystem_insights
|
|
13
|
+
from dockerls.application.services.migration import MigrationPlan, plan_migration
|
|
14
|
+
from dockerls.application.use_cases.recommend_images import build_recommendation
|
|
15
|
+
from dockerls.cli.dependencies import build_analyze_use_case, build_recommend_use_case
|
|
16
|
+
from dockerls.cli.image_names import split_repository_and_tag
|
|
17
|
+
from dockerls.cli.options import OutputFormat, parse_output_format
|
|
18
|
+
from dockerls.cli.scan_failure import describe_scan_failure
|
|
19
|
+
from dockerls.cli.text import safe
|
|
20
|
+
from dockerls.cli.validators import check_workers
|
|
21
|
+
from dockerls.exit_codes import EXIT_ERROR
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from dockerls.application.dto.analysis import ImageAnalysis
|
|
25
|
+
|
|
26
|
+
console = Console()
|
|
27
|
+
# Diagnostics go to stderr, results to stdout. Printing a warning to stdout
|
|
28
|
+
# put a human sentence in front of the JSON document and made `--format json`
|
|
29
|
+
# unparseable -- a machine-readable format is only machine-readable if
|
|
30
|
+
# nothing else can land in the stream.
|
|
31
|
+
diagnostics = Console(stderr=True)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def advisor(
|
|
35
|
+
image: str = typer.Argument(help="Docker image name (e.g., node, python, nginx)"),
|
|
36
|
+
workers: int | None = typer.Option(
|
|
37
|
+
None,
|
|
38
|
+
"--workers",
|
|
39
|
+
"-w",
|
|
40
|
+
help="Concurrent scanner processes; 0 sizes it to this machine [config: workers]",
|
|
41
|
+
),
|
|
42
|
+
output_format: str = typer.Option(
|
|
43
|
+
OutputFormat.TABLE.value, "--format", "-f", help="Output format: table or json"
|
|
44
|
+
),
|
|
45
|
+
no_color: bool = typer.Option(False, "--no-color", help="Disable colored output"),
|
|
46
|
+
) -> None:
|
|
47
|
+
"""Security advisor: analyze and provide actionable remediation plan."""
|
|
48
|
+
if no_color:
|
|
49
|
+
console.no_color = True
|
|
50
|
+
fmt = parse_output_format(output_format)
|
|
51
|
+
# `0` means "size it to this machine", so it is passed through rather
|
|
52
|
+
# than validated: the resolver, not the flag, decides what it becomes.
|
|
53
|
+
if workers:
|
|
54
|
+
workers = check_workers(workers)
|
|
55
|
+
try:
|
|
56
|
+
asyncio.run(_advisor(image, workers, fmt))
|
|
57
|
+
except ValueError as e:
|
|
58
|
+
console.print(f"[red]Invalid configuration:[/red] {e}")
|
|
59
|
+
raise typer.Exit(EXIT_ERROR) from e
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
async def _advisor(image: str, workers: int | None, output_format: OutputFormat) -> None:
|
|
63
|
+
# A tagged argument names an image the user runs *today*, so the advice
|
|
64
|
+
# can be a migration rather than a standalone suggestion. A bare name
|
|
65
|
+
# ("node") has no current image to move away from, and the command
|
|
66
|
+
# behaves exactly as it always has.
|
|
67
|
+
# O `rsplit(":", 1)` que morava aqui lia a porta do registry como tag:
|
|
68
|
+
# `advisor registry.internal:5000/app` procurava o repositório
|
|
69
|
+
# "registry.internal". A regra compartilhada só aceita dois-pontos no
|
|
70
|
+
# último segmento do caminho.
|
|
71
|
+
repository, current_tag = split_repository_and_tag(image)
|
|
72
|
+
current = await _analyze_current(image) if current_tag else None
|
|
73
|
+
|
|
74
|
+
use_case = await build_recommend_use_case(workers=workers)
|
|
75
|
+
result = await use_case.execute(repository)
|
|
76
|
+
|
|
77
|
+
items = result.recommendations or result.alternatives
|
|
78
|
+
if not items:
|
|
79
|
+
if output_format == OutputFormat.JSON:
|
|
80
|
+
error_payload = {"error": "No images found to advise on", "errors": result.errors}
|
|
81
|
+
console.print(json.dumps(error_payload), soft_wrap=True)
|
|
82
|
+
else:
|
|
83
|
+
console.print("[red]No images found to advise on.[/red]")
|
|
84
|
+
raise typer.Exit(EXIT_ERROR)
|
|
85
|
+
|
|
86
|
+
best = items[0]
|
|
87
|
+
# O plano de remediação é sobre a imagem que o usuário roda **hoje**,
|
|
88
|
+
# sempre que ele nomeou uma. Montá-lo sobre `best` -- a candidata que a
|
|
89
|
+
# busca elegeu -- descrevia as CVEs de outra imagem sob o título da que
|
|
90
|
+
# foi pedida: `advisor eclipse-temurin:21-jre-alpine` respondia com
|
|
91
|
+
# "Update stdlib (go1.26.5 -> 1.25.13)" e IDs `GO-...`, que não existem
|
|
92
|
+
# dentro de um JRE. Corrigir a saída não bastaria: os passos precisam
|
|
93
|
+
# nascer das vulnerabilidades que o scanner devolveu para *aquela*
|
|
94
|
+
# imagem, e é isso que a escolha do alvo aqui garante.
|
|
95
|
+
#
|
|
96
|
+
# Sem tag na linha de comando não há imagem atual, e o plano sobre a
|
|
97
|
+
# melhor candidata é o comportamento correto (e o original).
|
|
98
|
+
target = current if current is not None else best
|
|
99
|
+
rec = target.recommendation or build_recommendation(target)
|
|
100
|
+
insights = get_ecosystem_insights(target.image.full_reference or image)
|
|
101
|
+
# Only when the target is genuinely a different image: a "migration"
|
|
102
|
+
# from an image to itself is noise, and printing a checklist for it
|
|
103
|
+
# would suggest work that does not exist.
|
|
104
|
+
plan = (
|
|
105
|
+
plan_migration(current, best)
|
|
106
|
+
if current is not None and current.image.full_reference != best.image.full_reference
|
|
107
|
+
else None
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
if output_format == OutputFormat.JSON:
|
|
111
|
+
payload = best.model_dump()
|
|
112
|
+
payload["remediation"] = rec.model_dump()
|
|
113
|
+
# Qual imagem o plano endereça, dito explicitamente: o documento
|
|
114
|
+
# carrega `best` na raiz, e sem este campo um consumidor não teria
|
|
115
|
+
# como saber que a remediação é sobre outra imagem.
|
|
116
|
+
payload["remediation_target"] = target.image.full_reference
|
|
117
|
+
payload["ecosystem_insights"] = {
|
|
118
|
+
# As particularidades seguem o mesmo alvo do plano, e não a raiz
|
|
119
|
+
# do documento (que carrega `best`). Sem este campo um consumidor
|
|
120
|
+
# leria conselho sobre Debian ao lado de uma candidata Alpine sem
|
|
121
|
+
# nada dizendo que são imagens diferentes.
|
|
122
|
+
"for": target.image.full_reference,
|
|
123
|
+
"ecosystem": insights.ecosystem,
|
|
124
|
+
"version": insights.version,
|
|
125
|
+
"runtime_features": insights.runtime_features,
|
|
126
|
+
"base_distro_advice": insights.base_distro_advice,
|
|
127
|
+
"security_guidelines": insights.security_guidelines,
|
|
128
|
+
"common_pitfalls": insights.common_pitfalls,
|
|
129
|
+
"snippets": insights.recommended_dockerfile_snippets,
|
|
130
|
+
}
|
|
131
|
+
if plan is not None:
|
|
132
|
+
payload["migration"] = plan.model_dump()
|
|
133
|
+
if current is not None:
|
|
134
|
+
payload["current"] = current.model_dump()
|
|
135
|
+
console.print(json.dumps(payload, indent=2, default=str), soft_wrap=True)
|
|
136
|
+
return
|
|
137
|
+
|
|
138
|
+
console.print(Panel(f"[bold cyan]DockerLs Security Advisor: {image}[/bold cyan]", expand=False))
|
|
139
|
+
console.print()
|
|
140
|
+
|
|
141
|
+
info = Table(show_header=False, box=None, padding=(0, 2))
|
|
142
|
+
info.add_column("Key", style="bold")
|
|
143
|
+
info.add_column("Value")
|
|
144
|
+
info.add_row("Current Best Image", f"[cyan]{safe(best.image.full_reference)}[/cyan]")
|
|
145
|
+
info.add_row("Ecosystem / Runtime", f"{insights.ecosystem} ({insights.version})")
|
|
146
|
+
info.add_row("Security Score", f"[green]{best.security_score}[/green]")
|
|
147
|
+
info.add_row("Tier", best.tier)
|
|
148
|
+
info.add_row("Critical", f"[red]{best.scan.critical_count}[/red]")
|
|
149
|
+
info.add_row("High", f"[yellow]{best.scan.high_count}[/yellow]")
|
|
150
|
+
info.add_row("Medium", str(best.scan.medium_count))
|
|
151
|
+
info.add_row("Fixable High", str(best.scan.fixable_high_count))
|
|
152
|
+
info.add_row("Remediation Score", f"{best.remediation_score}%")
|
|
153
|
+
info.add_row("EOL", "Yes" if best.is_eol else "No")
|
|
154
|
+
info.add_row("LTS", "Yes" if best.is_lts else "No")
|
|
155
|
+
console.print(info)
|
|
156
|
+
|
|
157
|
+
if insights.base_distro_advice or insights.security_guidelines:
|
|
158
|
+
console.print()
|
|
159
|
+
console.print(
|
|
160
|
+
Panel(
|
|
161
|
+
"[bold magenta]Ecosystem Particularities & Hardening[/bold magenta]",
|
|
162
|
+
expand=False,
|
|
163
|
+
)
|
|
164
|
+
)
|
|
165
|
+
if insights.base_distro_advice:
|
|
166
|
+
console.print("\n[bold]Base Image & Distribution Notes:[/bold]")
|
|
167
|
+
for advice in insights.base_distro_advice:
|
|
168
|
+
console.print(f" - {advice}")
|
|
169
|
+
if insights.security_guidelines:
|
|
170
|
+
console.print("\n[bold]Production & Security Guidelines:[/bold]")
|
|
171
|
+
for item in insights.security_guidelines:
|
|
172
|
+
console.print(f" - {item}")
|
|
173
|
+
if insights.common_pitfalls:
|
|
174
|
+
console.print("\n[bold red]Common Pitfalls to Avoid:[/bold red]")
|
|
175
|
+
for pit in insights.common_pitfalls:
|
|
176
|
+
console.print(f" [yellow]![/yellow] {pit}")
|
|
177
|
+
|
|
178
|
+
if plan is not None:
|
|
179
|
+
_print_migration(plan)
|
|
180
|
+
|
|
181
|
+
if rec.steps:
|
|
182
|
+
console.print()
|
|
183
|
+
# A imagem nomeada no cabeçalho: um plano cujos passos citam versões
|
|
184
|
+
# de pacote precisa dizer de qual imagem essas versões vieram.
|
|
185
|
+
console.print(
|
|
186
|
+
f"[bold]Remediation Plan[/bold] [dim]for {safe(target.image.full_reference)}[/dim]"
|
|
187
|
+
)
|
|
188
|
+
console.print()
|
|
189
|
+
for step in rec.steps:
|
|
190
|
+
desc = step.description
|
|
191
|
+
if step.from_value and step.to_value:
|
|
192
|
+
desc += f" [dim]({step.from_value} -> {step.to_value})[/dim]"
|
|
193
|
+
console.print(f" STEP {step.step_number}: {desc}")
|
|
194
|
+
if step.expected_impact:
|
|
195
|
+
console.print(f" [dim]{step.expected_impact}[/dim]")
|
|
196
|
+
|
|
197
|
+
if rec.summary:
|
|
198
|
+
console.print()
|
|
199
|
+
console.print(f"[bold]Summary:[/bold] {rec.summary}")
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
async def _analyze_current(reference: str) -> ImageAnalysis | None:
|
|
203
|
+
"""Scan the image named on the command line, or give up quietly.
|
|
204
|
+
|
|
205
|
+
Failing to measure the current image costs the migration section, not
|
|
206
|
+
the command: the advice about the best available image is still valid,
|
|
207
|
+
and claiming an improvement over something never measured would not be.
|
|
208
|
+
"""
|
|
209
|
+
use_case = await build_analyze_use_case()
|
|
210
|
+
try:
|
|
211
|
+
analysis = await use_case.execute(reference)
|
|
212
|
+
except (ValueError, RuntimeError) as e:
|
|
213
|
+
diagnostics.print(f"[yellow]Could not analyze {reference} for comparison: {e}[/yellow]")
|
|
214
|
+
return None
|
|
215
|
+
finally:
|
|
216
|
+
# O scanner e o pool de conexões do repositório ficam abertos até
|
|
217
|
+
# alguém fechá-los, e este comando abre um segundo conjunto logo
|
|
218
|
+
# abaixo para a busca. `analyze` fecha no mesmo ponto; não fechar
|
|
219
|
+
# aqui era o único caminho que vazava.
|
|
220
|
+
await use_case.close()
|
|
221
|
+
|
|
222
|
+
# Um scan que não completou devolve score 0.0 e tier F por construção.
|
|
223
|
+
# Aceitá-lo como medição faria o plano de migração afirmar uma melhora
|
|
224
|
+
# sobre um número que não mede nada.
|
|
225
|
+
if not analysis.scan.is_verified:
|
|
226
|
+
cause = describe_scan_failure(analysis.scan.error_kind, analysis.scan.error_message)
|
|
227
|
+
diagnostics.print(
|
|
228
|
+
f"[yellow]Could not analyze {safe(reference)} for comparison: {safe(cause)}[/yellow]"
|
|
229
|
+
)
|
|
230
|
+
return None
|
|
231
|
+
return analysis
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _print_migration(plan: MigrationPlan) -> None:
|
|
235
|
+
"""The move from what the user runs to what was measured as better."""
|
|
236
|
+
console.print()
|
|
237
|
+
console.print(Panel("[bold green]Migration[/bold green]", expand=False))
|
|
238
|
+
console.print(f" CURRENT [cyan]{plan.from_reference}[/cyan]")
|
|
239
|
+
console.print(f" RECOMMENDED [green]{plan.to_reference}[/green]")
|
|
240
|
+
if plan.to_pinned_reference != plan.to_reference:
|
|
241
|
+
console.print(f" PIN TO [dim]{plan.to_pinned_reference}[/dim]")
|
|
242
|
+
|
|
243
|
+
delta = plan.score_delta
|
|
244
|
+
colour = "green" if delta > 0 else "red"
|
|
245
|
+
console.print(f"\n SECURITY IMPROVEMENT [{colour}]{delta:+.1f} points[/{colour}]")
|
|
246
|
+
|
|
247
|
+
if plan.improvements:
|
|
248
|
+
console.print("\n[bold]WHY[/bold]")
|
|
249
|
+
for reason in plan.improvements:
|
|
250
|
+
console.print(f" [green]OK[/green] {safe(reason)}")
|
|
251
|
+
if plan.trade_offs:
|
|
252
|
+
console.print("\n[bold]TRADE-OFFS[/bold]")
|
|
253
|
+
for cost in plan.trade_offs:
|
|
254
|
+
console.print(f" [yellow]![/yellow] {safe(cost)}")
|
|
255
|
+
if plan.checklist:
|
|
256
|
+
console.print("\n[bold]MIGRATION CHECKLIST[/bold]")
|
|
257
|
+
for i, step in enumerate(plan.checklist, 1):
|
|
258
|
+
console.print(f" {i}. {safe(step)}")
|
|
259
|
+
console.print(
|
|
260
|
+
"\n[dim]Compatibility is never assumed: nothing here can tell you your "
|
|
261
|
+
"application still runs. That is what the checklist is for.[/dim]"
|
|
262
|
+
)
|