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
|
@@ -0,0 +1,566 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import os
|
|
5
|
+
import platform
|
|
6
|
+
import shutil
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
import typer
|
|
12
|
+
from loguru import logger
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
from rich.table import Table
|
|
15
|
+
|
|
16
|
+
from dockerls.cli.dependencies import available_source_names, build_source_registry
|
|
17
|
+
from dockerls.cli.text import safe
|
|
18
|
+
from dockerls.domain.value_objects.scanner_db import (
|
|
19
|
+
DatabaseFreshness,
|
|
20
|
+
DatabaseState,
|
|
21
|
+
classify,
|
|
22
|
+
)
|
|
23
|
+
from dockerls.domain.value_objects.tool_release import (
|
|
24
|
+
DEFAULT_VERSIONS,
|
|
25
|
+
INSTALLABLE,
|
|
26
|
+
OS,
|
|
27
|
+
Arch,
|
|
28
|
+
ToolSpec,
|
|
29
|
+
detect_arch,
|
|
30
|
+
detect_os,
|
|
31
|
+
)
|
|
32
|
+
from dockerls.exit_codes import EXIT_ERROR, EXIT_OK
|
|
33
|
+
from dockerls.infrastructure.toolchain.db_metadata import (
|
|
34
|
+
read_grype_built_at,
|
|
35
|
+
read_trivy_built_at,
|
|
36
|
+
)
|
|
37
|
+
from dockerls.infrastructure.toolchain.installer import (
|
|
38
|
+
InstallError,
|
|
39
|
+
InstallOutcome,
|
|
40
|
+
InstallPlan,
|
|
41
|
+
ToolInstaller,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
if TYPE_CHECKING:
|
|
45
|
+
from collections.abc import Sequence
|
|
46
|
+
|
|
47
|
+
from dockerls.infrastructure.network.host_guard import HostGuard
|
|
48
|
+
|
|
49
|
+
console = Console()
|
|
50
|
+
|
|
51
|
+
TRIVY_INSTALL_URL = "https://aquasecurity.github.io/trivy"
|
|
52
|
+
GRYPE_INSTALL_URL = "https://github.com/anchore/grype"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def doctor(
|
|
56
|
+
install: bool = typer.Option(
|
|
57
|
+
False,
|
|
58
|
+
"--install",
|
|
59
|
+
help=(
|
|
60
|
+
"Download and install the missing scanners from their projects' official "
|
|
61
|
+
"GitHub releases, verifying the published SHA-256 before installing. "
|
|
62
|
+
"Asks for confirmation first unless --yes is given"
|
|
63
|
+
),
|
|
64
|
+
),
|
|
65
|
+
yes: bool = typer.Option(
|
|
66
|
+
False,
|
|
67
|
+
"--yes",
|
|
68
|
+
"-y",
|
|
69
|
+
help="Skip the confirmation prompt for --install (for non-interactive CI)",
|
|
70
|
+
),
|
|
71
|
+
install_dir: str = typer.Option(
|
|
72
|
+
"",
|
|
73
|
+
"--install-dir",
|
|
74
|
+
help="Where to install the binaries [default: ~/.local/bin, no privilege required]",
|
|
75
|
+
),
|
|
76
|
+
trivy_version: str = typer.Option(
|
|
77
|
+
"",
|
|
78
|
+
"--trivy-version",
|
|
79
|
+
help="Install this Trivy version instead of resolving the latest one (no 'v' "
|
|
80
|
+
"prefix, e.g. 0.58.1). Skips the releases/latest API entirely -- useful when a "
|
|
81
|
+
"proxy or allowlist blocks api.github.com but github.com/.../releases/download "
|
|
82
|
+
"is reachable",
|
|
83
|
+
),
|
|
84
|
+
grype_version: str = typer.Option(
|
|
85
|
+
"",
|
|
86
|
+
"--grype-version",
|
|
87
|
+
help="Install this Grype version instead of resolving the latest one (no 'v' "
|
|
88
|
+
"prefix, e.g. 0.85.0). Same rationale as --trivy-version",
|
|
89
|
+
),
|
|
90
|
+
require_fresh_db: bool = typer.Option(
|
|
91
|
+
False,
|
|
92
|
+
"--require-fresh-db",
|
|
93
|
+
help="Also fail when a scanner's vulnerability database is stale, or when its "
|
|
94
|
+
"age cannot be determined. Off by default: the exit code has always meant "
|
|
95
|
+
"'the components are present', and changing that silently would break pipelines",
|
|
96
|
+
),
|
|
97
|
+
) -> None:
|
|
98
|
+
"""Check system dependencies and configuration.
|
|
99
|
+
|
|
100
|
+
Diagnosis is read-only and is what runs by default. `--install` is the
|
|
101
|
+
only thing that writes anything, and it never runs without either an
|
|
102
|
+
interactive confirmation or an explicit `--yes`.
|
|
103
|
+
|
|
104
|
+
What `--install` downloads, and from where:
|
|
105
|
+
|
|
106
|
+
\b
|
|
107
|
+
trivy https://github.com/aquasecurity/trivy/releases
|
|
108
|
+
grype https://github.com/anchore/grype/releases
|
|
109
|
+
|
|
110
|
+
For each tool it fetches the release archive and the `checksums.txt`
|
|
111
|
+
published alongside it, verifies the SHA-256, and extracts only the
|
|
112
|
+
binary. Nothing downloaded is ever executed, and no install script is
|
|
113
|
+
fetched or run.
|
|
114
|
+
|
|
115
|
+
When the project publishes `checksums.txt.sig` and `checksums.txt.pem`
|
|
116
|
+
and cosign is installed, the signature on `checksums.txt` is verified
|
|
117
|
+
first -- constrained to an identity under that project's own GitHub
|
|
118
|
+
repository, so "someone signed" is never mistaken for "the publisher
|
|
119
|
+
signed". Only then is the archive's SHA-256 compared against it.
|
|
120
|
+
|
|
121
|
+
An invalid signature aborts the install. A project with no signature
|
|
122
|
+
known here, a missing cosign, or a check that could not conclude are
|
|
123
|
+
reported as *not verified*, never as unsigned, and the install proceeds
|
|
124
|
+
on the published SHA-256 alone.
|
|
125
|
+
"""
|
|
126
|
+
# `doctor` is the documented way for a pipeline to check its
|
|
127
|
+
# prerequisites before a scan job, so it has to *gate*: it used to print
|
|
128
|
+
# "Some components are missing" and exit 0 anyway, which meant a runner
|
|
129
|
+
# with no Trivy installed sailed past the check and failed later, in the
|
|
130
|
+
# scan, where the cause is far less obvious. `health` already gates the
|
|
131
|
+
# same way for network dependencies.
|
|
132
|
+
if install:
|
|
133
|
+
pinned_versions = {
|
|
134
|
+
name: version
|
|
135
|
+
for name, version in (("trivy", trivy_version), ("grype", grype_version))
|
|
136
|
+
if version.strip()
|
|
137
|
+
}
|
|
138
|
+
raise typer.Exit(
|
|
139
|
+
asyncio.run(
|
|
140
|
+
_install_missing(
|
|
141
|
+
assume_yes=yes, install_dir=install_dir, pinned_versions=pinned_versions
|
|
142
|
+
)
|
|
143
|
+
)
|
|
144
|
+
)
|
|
145
|
+
raise typer.Exit(asyncio.run(_doctor(require_fresh_db=require_fresh_db)))
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _database_ages(*, has_trivy: bool, has_grype: bool) -> list[DatabaseFreshness]:
|
|
149
|
+
"""A idade da base de cada scanner instalado.
|
|
150
|
+
|
|
151
|
+
Só dos instalados: dizer que a base do Grype tem idade desconhecida
|
|
152
|
+
numa máquina sem Grype seria ruído, e ruído é como um aviso de verdade
|
|
153
|
+
deixa de ser lido.
|
|
154
|
+
"""
|
|
155
|
+
ages: list[DatabaseFreshness] = []
|
|
156
|
+
if has_trivy:
|
|
157
|
+
built, detail = read_trivy_built_at()
|
|
158
|
+
ages.append(classify("trivy", built, detail=detail))
|
|
159
|
+
if has_grype:
|
|
160
|
+
built, detail = read_grype_built_at()
|
|
161
|
+
ages.append(classify("grype", built, detail=detail))
|
|
162
|
+
return ages
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _print_database_ages(*, has_trivy: bool, has_grype: bool) -> list[DatabaseFreshness]:
|
|
166
|
+
"""Conferir que o scanner existe não é conferir que ele mede.
|
|
167
|
+
|
|
168
|
+
Um Trivy com base de três semanas devolve um scan limpo, verde e sem
|
|
169
|
+
erro nenhum que simplesmente não conhece os CVEs do último mês. É a
|
|
170
|
+
falha de medição mais silenciosa que há aqui, e a mais perigosa por
|
|
171
|
+
isso: nada no relatório indica que a resposta está velha.
|
|
172
|
+
"""
|
|
173
|
+
ages = _database_ages(has_trivy=has_trivy, has_grype=has_grype)
|
|
174
|
+
if not ages:
|
|
175
|
+
return ages
|
|
176
|
+
|
|
177
|
+
console.print("\n[bold]Vulnerability database[/bold] [dim](how recent the answers are)[/dim]")
|
|
178
|
+
table = Table(show_header=False, box=None, padding=(0, 2))
|
|
179
|
+
table.add_column("Scanner", style="bold")
|
|
180
|
+
table.add_column("State")
|
|
181
|
+
table.add_column("Detail")
|
|
182
|
+
for age in ages:
|
|
183
|
+
table.add_row(age.scanner, _DB_STATES[age.state], safe(age.explain()))
|
|
184
|
+
console.print(table)
|
|
185
|
+
return ages
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
#: Cor por estado. `UNKNOWN` é ciano e não verde de propósito: ele não é
|
|
189
|
+
#: uma aprovação, e pintá-lo como uma seria a mentira que este bloco existe
|
|
190
|
+
#: para não contar.
|
|
191
|
+
_DB_STATES: dict[DatabaseState, str] = {
|
|
192
|
+
DatabaseState.FRESH: "[green]Fresh[/green]",
|
|
193
|
+
DatabaseState.AGING: "[yellow]Aging[/yellow]",
|
|
194
|
+
DatabaseState.STALE: "[red]Stale[/red]",
|
|
195
|
+
DatabaseState.UNKNOWN: "[cyan]Unknown[/cyan]",
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _print_sources() -> None:
|
|
200
|
+
"""List the catalogues this build can search, and their caveats.
|
|
201
|
+
|
|
202
|
+
`--source` accepts these names, and a source whose registry needs
|
|
203
|
+
credentials is called out here rather than discovered as a wall of
|
|
204
|
+
failed scans: an unauthenticated DHI run produces UNVERIFIED
|
|
205
|
+
candidates, which is correct behaviour but a confusing surprise.
|
|
206
|
+
"""
|
|
207
|
+
console.print("\n[bold]Image sources[/bold] [dim](--source ...)[/dim]")
|
|
208
|
+
table = Table(show_header=False, box=None, padding=(0, 2))
|
|
209
|
+
table.add_column("Name", style="bold cyan")
|
|
210
|
+
table.add_column("Detail")
|
|
211
|
+
for spec in build_source_registry().specs:
|
|
212
|
+
notes = [spec.description]
|
|
213
|
+
notes.append("searched by default" if spec.default_enabled else "opt-in")
|
|
214
|
+
if spec.requires_auth:
|
|
215
|
+
notes.append("[yellow]registry requires credentials to scan[/yellow]")
|
|
216
|
+
table.add_row(spec.name, " -- ".join(n for n in notes if n))
|
|
217
|
+
console.print(table)
|
|
218
|
+
console.print(f" [dim]accepted: {', '.join(available_source_names())}, all[/dim]")
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _print_threat_sources() -> None:
|
|
222
|
+
"""As fontes de explorabilidade que enriquecem cada achado.
|
|
223
|
+
|
|
224
|
+
Listadas aqui porque nenhuma delas é obrigatória e todas degradam para
|
|
225
|
+
"não consultado" em silêncio: sem esta linha, um usuário atrás de um
|
|
226
|
+
proxy vê a coluna Threat cheia de `-` sem nada dizendo por quê.
|
|
227
|
+
`dockerls health` é quem testa se respondem.
|
|
228
|
+
"""
|
|
229
|
+
console.print("\n[bold]Threat intelligence[/bold] [dim](optional enrichment)[/dim]")
|
|
230
|
+
table = Table(show_header=False, box=None, padding=(0, 2))
|
|
231
|
+
table.add_column("Source", style="bold cyan")
|
|
232
|
+
table.add_column("Detail")
|
|
233
|
+
table.add_row("CISA KEV", "vulnerabilities observed being exploited in the wild")
|
|
234
|
+
table.add_row("EPSS (FIRST)", "predicted probability of exploitation")
|
|
235
|
+
table.add_row("Exploit-DB", "published exploit code, matched by CVE id")
|
|
236
|
+
console.print(table)
|
|
237
|
+
console.print(
|
|
238
|
+
" [dim]All three degrade to 'not consulted' when unreachable, never to "
|
|
239
|
+
"'no exploit known'. Run `dockerls health` to test them.[/dim]"
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
async def _doctor(*, require_fresh_db: bool = False) -> int:
|
|
244
|
+
console.print("[bold]DockerLs System Check[/bold]\n")
|
|
245
|
+
|
|
246
|
+
checks = Table(show_header=False, box=None, padding=(0, 2))
|
|
247
|
+
checks.add_column("Component", style="bold")
|
|
248
|
+
checks.add_column("Status")
|
|
249
|
+
|
|
250
|
+
has_trivy = shutil.which("trivy") is not None
|
|
251
|
+
has_grype = shutil.which("grype") is not None
|
|
252
|
+
|
|
253
|
+
for tool, desc, available in (
|
|
254
|
+
("trivy", "Primary vulnerability scanner", has_trivy),
|
|
255
|
+
("grype", "Fallback scanner / cross-validation", has_grype),
|
|
256
|
+
):
|
|
257
|
+
status = "[green]Available[/green]" if available else "[yellow]Not found[/yellow]"
|
|
258
|
+
checks.add_row(f"{tool} ({desc})", status)
|
|
259
|
+
|
|
260
|
+
has_httpx = True
|
|
261
|
+
try:
|
|
262
|
+
import httpx # noqa: F401
|
|
263
|
+
|
|
264
|
+
checks.add_row("httpx", "[green]Available[/green]")
|
|
265
|
+
except ImportError: # pragma: no cover - httpx is a hard dependency
|
|
266
|
+
checks.add_row("httpx", "[red]Missing[/red]")
|
|
267
|
+
has_httpx = False
|
|
268
|
+
|
|
269
|
+
try:
|
|
270
|
+
import keyring # noqa: F401
|
|
271
|
+
|
|
272
|
+
checks.add_row("keyring", "[green]Available[/green]")
|
|
273
|
+
except ImportError:
|
|
274
|
+
checks.add_row("keyring", "[yellow]Not installed (optional)[/yellow]")
|
|
275
|
+
|
|
276
|
+
console.print(checks)
|
|
277
|
+
database_ages = _print_database_ages(has_trivy=has_trivy, has_grype=has_grype)
|
|
278
|
+
_print_sources()
|
|
279
|
+
_print_threat_sources()
|
|
280
|
+
|
|
281
|
+
# The requirement is *a* scanner, not Trivy specifically: `ScannerFactory`
|
|
282
|
+
# runs on Grype alone. Flagging a Grype-only machine as broken would have
|
|
283
|
+
# been a false alarm, and flagging a machine with neither as fine was the
|
|
284
|
+
# real failure.
|
|
285
|
+
stale = [age for age in database_ages if not age.is_usable_measurement]
|
|
286
|
+
if stale and require_fresh_db:
|
|
287
|
+
# Só com `--require-fresh-db`. O código de saída deste comando sempre
|
|
288
|
+
# significou "os componentes estão presentes", e mudar isso em
|
|
289
|
+
# silêncio quebraria pipelines que já dependem do contrato -- que é
|
|
290
|
+
# exatamente o tipo de mudança que este projeto cobra dos outros.
|
|
291
|
+
console.print(
|
|
292
|
+
"\n[red]A vulnerability database is not fit to measure with.[/red]\n"
|
|
293
|
+
+ "\n".join(f" {safe(age.scanner)}: {safe(age.explain())}" for age in stale)
|
|
294
|
+
+ "\n[dim]Refresh it (`trivy image --download-db-only`, `grype db update`) "
|
|
295
|
+
"or drop --require-fresh-db to keep this a warning.[/dim]"
|
|
296
|
+
)
|
|
297
|
+
return EXIT_ERROR
|
|
298
|
+
if stale:
|
|
299
|
+
console.print(
|
|
300
|
+
"\n[yellow]A scan run now would answer from a database that is not "
|
|
301
|
+
"current.[/yellow] [dim]The result would look exactly like a clean one. "
|
|
302
|
+
"Use --require-fresh-db to make this fail.[/dim]"
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
if has_httpx and (has_trivy or has_grype):
|
|
306
|
+
if not (has_trivy and has_grype):
|
|
307
|
+
console.print(
|
|
308
|
+
"\n[yellow]Only one scanner is installed.[/yellow] "
|
|
309
|
+
"[dim]Cross-validation needs both, so scores will be reported "
|
|
310
|
+
"unconfirmed by a second tool.[/dim]"
|
|
311
|
+
)
|
|
312
|
+
else:
|
|
313
|
+
console.print("\n[green]All required components are available.[/green]")
|
|
314
|
+
return EXIT_OK
|
|
315
|
+
|
|
316
|
+
console.print("\n[red]DockerLs cannot measure anything on this machine.[/red]")
|
|
317
|
+
console.print("\n[bold]Cause[/bold]")
|
|
318
|
+
if not (has_trivy or has_grype):
|
|
319
|
+
console.print(" No vulnerability scanner is installed (needs Trivy or Grype).")
|
|
320
|
+
if not has_httpx: # pragma: no cover - httpx is a hard dependency
|
|
321
|
+
console.print(" The httpx package is missing; reinstall dockerls.")
|
|
322
|
+
|
|
323
|
+
console.print("\n[bold]Suggested action[/bold]")
|
|
324
|
+
if not (has_trivy or has_grype):
|
|
325
|
+
console.print(f" Install Trivy: [cyan]{TRIVY_INSTALL_URL}[/cyan]")
|
|
326
|
+
console.print(f" or install Grype: [cyan]{GRYPE_INSTALL_URL}[/cyan]")
|
|
327
|
+
console.print(
|
|
328
|
+
"\n[dim]Without a scanner, `recommend`, `analyze` and `advisor` report "
|
|
329
|
+
"every tag as unverified rather than as safe.[/dim]"
|
|
330
|
+
)
|
|
331
|
+
return EXIT_ERROR
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def default_install_dir() -> Path:
|
|
335
|
+
"""Onde instalar sem pedir privilégio a ninguém.
|
|
336
|
+
|
|
337
|
+
`~/.local/bin` no Linux/macOS e o equivalente no Windows: são diretórios
|
|
338
|
+
do próprio usuário, então a instalação nunca precisa de sudo nem de
|
|
339
|
+
prompt de administrador. Um destino que exigisse privilégio teria de
|
|
340
|
+
aparecer na confirmação, e a forma de nunca precisar disso é não
|
|
341
|
+
escolher um.
|
|
342
|
+
"""
|
|
343
|
+
# `detect_os`, e não uma comparação de string própria: a regra de qual
|
|
344
|
+
# SO é qual já mora em `tool_release`, e duas cópias dela divergem na
|
|
345
|
+
# primeira vez que uma for corrigida.
|
|
346
|
+
if detect_os(platform.system()) is OS.WINDOWS:
|
|
347
|
+
base = os.environ.get("LOCALAPPDATA") or str(Path.home() / "AppData" / "Local")
|
|
348
|
+
return Path(base) / "Programs" / "dockerls" / "bin"
|
|
349
|
+
return Path.home() / ".local" / "bin"
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _writable_without_privilege(target: Path) -> bool:
|
|
353
|
+
"""Se dá para escrever ali com o usuário atual.
|
|
354
|
+
|
|
355
|
+
Sobe até o primeiro ancestral existente: um destino que ainda não existe
|
|
356
|
+
é criável se o pai deixar.
|
|
357
|
+
"""
|
|
358
|
+
probe = target
|
|
359
|
+
while not probe.exists() and probe != probe.parent:
|
|
360
|
+
probe = probe.parent
|
|
361
|
+
return os.access(probe, os.W_OK)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _plan_for(spec: ToolSpec, version: str, os_: OS, arch: Arch, destination: Path) -> InstallPlan:
|
|
365
|
+
asset = spec.asset_for(version, os_, arch)
|
|
366
|
+
if asset is None: # pragma: no cover - guarded by the caller
|
|
367
|
+
raise InstallError(f"{spec.name} publishes no build for {os_.value}/{arch.value}")
|
|
368
|
+
return InstallPlan(
|
|
369
|
+
tool=spec.name,
|
|
370
|
+
version=version,
|
|
371
|
+
asset=asset,
|
|
372
|
+
destination=destination,
|
|
373
|
+
needs_privilege=not _writable_without_privilege(destination),
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _print_plans(plans: list[InstallPlan]) -> None:
|
|
378
|
+
"""Exatamente o que será baixado, antes de baixar qualquer coisa.
|
|
379
|
+
|
|
380
|
+
Consentimento só é informado se a pessoa vê a URL antes de dizer sim --
|
|
381
|
+
e isso inclui dizer, aqui, se algo vai exigir privilégio, em vez de
|
|
382
|
+
descobrir isso no meio da instalação.
|
|
383
|
+
"""
|
|
384
|
+
console.print("\n[bold]The following will be downloaded and verified[/bold]\n")
|
|
385
|
+
for plan in plans:
|
|
386
|
+
console.print(f" [bold cyan]{plan.tool} {plan.version}[/bold cyan]")
|
|
387
|
+
console.print(f" archive [dim]{safe(plan.asset.archive_url)}[/dim]", soft_wrap=True)
|
|
388
|
+
console.print(f" checksums [dim]{safe(plan.asset.checksums_url)}[/dim]", soft_wrap=True)
|
|
389
|
+
console.print(f" install [dim]{safe(str(plan.destination))}[/dim]", soft_wrap=True)
|
|
390
|
+
console.print()
|
|
391
|
+
|
|
392
|
+
console.print(
|
|
393
|
+
"[dim]The SHA-256 published by each project is verified before anything is "
|
|
394
|
+
"installed, and only the binary is extracted. No install script is fetched "
|
|
395
|
+
"or run, and nothing downloaded is executed.[/dim]"
|
|
396
|
+
)
|
|
397
|
+
if any(p.needs_privilege for p in plans):
|
|
398
|
+
console.print(
|
|
399
|
+
"\n[yellow]This destination is not writable by the current user, so the "
|
|
400
|
+
"install will need elevated privileges.[/yellow]\n"
|
|
401
|
+
"[dim]Use --install-dir to pick a directory you own instead.[/dim]"
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
async def _install_missing(
|
|
406
|
+
*, assume_yes: bool, install_dir: str, pinned_versions: dict[str, str] | None = None
|
|
407
|
+
) -> int:
|
|
408
|
+
"""Instala os scanners que faltam, um independente do outro.
|
|
409
|
+
|
|
410
|
+
`pinned_versions` (de `--trivy-version`/`--grype-version`) evita
|
|
411
|
+
consultar `releases/latest` por completo: um proxy ou allowlist
|
|
412
|
+
restritivo devolve 403 para `api.github.com` em vários ambientes de CI
|
|
413
|
+
mesmo quando `github.com/.../releases/download/...` -- o único outro
|
|
414
|
+
host que este instalador toca -- segue liberado.
|
|
415
|
+
"""
|
|
416
|
+
system, machine = platform.system(), platform.machine()
|
|
417
|
+
os_, arch = detect_os(system), detect_arch(machine)
|
|
418
|
+
if os_ is None or arch is None:
|
|
419
|
+
console.print(
|
|
420
|
+
f"[red]Unsupported platform:[/red] {safe(system)} / {safe(machine)}.\n"
|
|
421
|
+
"[dim]Install Trivy or Grype manually; see the links in `dockerls doctor`.[/dim]"
|
|
422
|
+
)
|
|
423
|
+
return EXIT_ERROR
|
|
424
|
+
|
|
425
|
+
missing = [spec for spec in INSTALLABLE if shutil.which(spec.name) is None]
|
|
426
|
+
if not missing:
|
|
427
|
+
console.print("[green]Nothing to install: every scanner is already available.[/green]")
|
|
428
|
+
return EXIT_OK
|
|
429
|
+
|
|
430
|
+
destination = Path(install_dir).expanduser() if install_dir else default_install_dir()
|
|
431
|
+
unsupported = [s for s in missing if not s.supports(os_, arch)]
|
|
432
|
+
for spec in unsupported:
|
|
433
|
+
console.print(
|
|
434
|
+
f"[yellow]{spec.name} publishes no build for {os_.value}/{arch.value}; "
|
|
435
|
+
f"skipping it.[/yellow] [dim]{spec.repo_url}/releases[/dim]"
|
|
436
|
+
)
|
|
437
|
+
installable = [s for s in missing if s.supports(os_, arch)]
|
|
438
|
+
if not installable:
|
|
439
|
+
return EXIT_ERROR
|
|
440
|
+
|
|
441
|
+
installer = ToolInstaller(guard=_install_guard())
|
|
442
|
+
pinned_versions = pinned_versions or {}
|
|
443
|
+
plans: list[InstallPlan] = []
|
|
444
|
+
for spec in installable:
|
|
445
|
+
pinned = pinned_versions.get(spec.name, "").strip()
|
|
446
|
+
if pinned:
|
|
447
|
+
# An explicit version skips releases/latest entirely -- there is
|
|
448
|
+
# nothing to resolve, and nothing to fail on a 403.
|
|
449
|
+
plans.append(_plan_for(spec, pinned, os_, arch, destination))
|
|
450
|
+
continue
|
|
451
|
+
|
|
452
|
+
# Uma ferramenta que não resolve versão não impede a outra.
|
|
453
|
+
try:
|
|
454
|
+
version = await installer.latest_version(spec)
|
|
455
|
+
plans.append(_plan_for(spec, version, os_, arch, destination))
|
|
456
|
+
except InstallError as e:
|
|
457
|
+
fallback = DEFAULT_VERSIONS.get(spec.name, "")
|
|
458
|
+
if not fallback:
|
|
459
|
+
console.print(f"[red]{spec.name}:[/red] {safe(str(e))}")
|
|
460
|
+
continue
|
|
461
|
+
console.print(
|
|
462
|
+
f"[yellow]{spec.name}:[/yellow] {safe(str(e))}\n"
|
|
463
|
+
f"[dim]Falling back to the built-in default version {fallback} -- pass "
|
|
464
|
+
f"--{spec.name}-version to pin a different one.[/dim]"
|
|
465
|
+
)
|
|
466
|
+
plans.append(_plan_for(spec, fallback, os_, arch, destination))
|
|
467
|
+
|
|
468
|
+
if not plans:
|
|
469
|
+
return EXIT_ERROR
|
|
470
|
+
|
|
471
|
+
_print_plans(plans)
|
|
472
|
+
if not _confirmed(assume_yes):
|
|
473
|
+
console.print("\n[yellow]Nothing was installed.[/yellow]")
|
|
474
|
+
return EXIT_ERROR
|
|
475
|
+
|
|
476
|
+
outcomes = []
|
|
477
|
+
for plan in plans:
|
|
478
|
+
console.print(f"\n[bold]{plan.tool}[/bold] downloading and verifying...")
|
|
479
|
+
outcome = await installer.install(plan, cosign=_cosign_if_available())
|
|
480
|
+
outcomes.append(outcome)
|
|
481
|
+
if outcome.installed:
|
|
482
|
+
signed = (
|
|
483
|
+
" [dim](cosign signature verified)[/dim]" if outcome.signature_verified else ""
|
|
484
|
+
)
|
|
485
|
+
console.print(f" [green]OK[/green] {safe(outcome.detail)}{signed}")
|
|
486
|
+
else:
|
|
487
|
+
console.print(f" [red]FAILED[/red] {safe(outcome.detail)}")
|
|
488
|
+
|
|
489
|
+
_warn_if_not_on_path(destination, outcomes)
|
|
490
|
+
|
|
491
|
+
console.print("\n[bold]Re-running the diagnosis[/bold]\n")
|
|
492
|
+
# O veredito final é o mesmo diagnóstico de sempre, sobre o estado real
|
|
493
|
+
# da máquina: dizer "instalado" sem reconferir seria reportar a intenção
|
|
494
|
+
# em vez do resultado.
|
|
495
|
+
return await _doctor(require_fresh_db=False)
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def _confirmed(assume_yes: bool) -> bool:
|
|
499
|
+
"""Consentimento explícito, e nunca presumido num terminal não-interativo.
|
|
500
|
+
|
|
501
|
+
Sem `--yes` e sem alguém do outro lado, a resposta é não: um pipeline
|
|
502
|
+
que não pediu instalação explicitamente não deve receber uma.
|
|
503
|
+
"""
|
|
504
|
+
if assume_yes:
|
|
505
|
+
return True
|
|
506
|
+
if not sys.stdin.isatty():
|
|
507
|
+
console.print(
|
|
508
|
+
"\n[yellow]Not an interactive terminal, and --yes was not given.[/yellow]\n"
|
|
509
|
+
"[dim]Re-run with --yes to install without confirmation.[/dim]"
|
|
510
|
+
)
|
|
511
|
+
return False
|
|
512
|
+
return typer.confirm("Proceed with the download and install?", default=False)
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def _install_guard() -> HostGuard | None:
|
|
516
|
+
"""A mesma política de rede que governa registries e scanners."""
|
|
517
|
+
try:
|
|
518
|
+
from dockerls.cli.dependencies import build_host_guard
|
|
519
|
+
|
|
520
|
+
return build_host_guard()
|
|
521
|
+
except Exception as e: # pragma: no cover - a guard is best-effort here
|
|
522
|
+
logger.debug(f"Network policy unavailable for the installer: {e}")
|
|
523
|
+
return None
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def _cosign_if_available() -> object | None:
|
|
527
|
+
"""O verificador de assinatura, quando existir um que sirva.
|
|
528
|
+
|
|
529
|
+
`ToolInstaller` pede `verify_blob`: um release de scanner é um *blob*
|
|
530
|
+
assinado, não uma imagem de container. O `CosignClient` sabia verificar
|
|
531
|
+
só referência de imagem (`cosign verify`), então devolvê-lo aqui fazia
|
|
532
|
+
o instalador procurar `verify_blob`, não achar, e seguir em silêncio --
|
|
533
|
+
uma verificação anunciada que nunca acontecia. A checagem abaixo
|
|
534
|
+
sobrevive à implementação de `verify_blob` de propósito: ela é o que
|
|
535
|
+
impede a mesma armadilha de voltar por um caminho diferente.
|
|
536
|
+
"""
|
|
537
|
+
if shutil.which("cosign") is None:
|
|
538
|
+
return None
|
|
539
|
+
try:
|
|
540
|
+
from dockerls.integrations.signing.cosign import CosignClient
|
|
541
|
+
|
|
542
|
+
client: object = CosignClient()
|
|
543
|
+
except Exception as e: # pragma: no cover
|
|
544
|
+
logger.debug(f"cosign client unavailable: {e}")
|
|
545
|
+
return None
|
|
546
|
+
if not callable(getattr(client, "verify_blob", None)):
|
|
547
|
+
logger.debug(
|
|
548
|
+
"cosign is on PATH but no blob verifier is implemented; "
|
|
549
|
+
"the published SHA-256 remains the only verification"
|
|
550
|
+
)
|
|
551
|
+
return None
|
|
552
|
+
return client
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def _warn_if_not_on_path(destination: Path, outcomes: Sequence[InstallOutcome]) -> None:
|
|
556
|
+
"""Instalar fora do PATH deixa a ferramenta invisível para o próximo
|
|
557
|
+
comando, e o diagnóstico abaixo diria "Not found" sem explicar por quê."""
|
|
558
|
+
if not any(o.installed for o in outcomes):
|
|
559
|
+
return
|
|
560
|
+
entries = {Path(p).expanduser() for p in os.environ.get("PATH", "").split(os.pathsep) if p}
|
|
561
|
+
if destination.expanduser() in entries:
|
|
562
|
+
return
|
|
563
|
+
console.print(
|
|
564
|
+
f"\n[yellow]{safe(str(destination))} is not on your PATH.[/yellow]\n"
|
|
565
|
+
f"[dim]Add it, or the scanners will not be found by the next command.[/dim]"
|
|
566
|
+
)
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
|
|
9
|
+
from dockerls.cli.dependencies import build_recommend_use_case, resolve_tag_limit
|
|
10
|
+
from dockerls.cli.image_names import reject_tagged_reference
|
|
11
|
+
from dockerls.cli.validators import check_limit, check_workers
|
|
12
|
+
from dockerls.exit_codes import EXIT_ERROR
|
|
13
|
+
from dockerls.exporters.factory import ExporterFactory
|
|
14
|
+
|
|
15
|
+
console = Console()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def export(
|
|
19
|
+
image: str = typer.Argument(
|
|
20
|
+
help=(
|
|
21
|
+
"Docker image name only, without a tag (e.g. 'node', not 'node:18'). "
|
|
22
|
+
"Use 'analyze' or 'advisor' for a specific tag."
|
|
23
|
+
)
|
|
24
|
+
),
|
|
25
|
+
output_format: str = typer.Option(
|
|
26
|
+
"json", "--format", "-f", help="Export format: json, csv, html, markdown, sarif"
|
|
27
|
+
),
|
|
28
|
+
output: str = typer.Option("", "--output", "-o", help="Output file path (default: stdout)"),
|
|
29
|
+
workers: int | None = typer.Option(
|
|
30
|
+
None, "--workers", "-w", help="Concurrent workers [config: workers, default 10]"
|
|
31
|
+
),
|
|
32
|
+
limit: int | None = typer.Option(
|
|
33
|
+
None, "--limit", "-l", help="Max tags to discover [config: max_tags, default 100]"
|
|
34
|
+
),
|
|
35
|
+
) -> None:
|
|
36
|
+
"""Export analysis results in various formats."""
|
|
37
|
+
# `None` means "not given", so the configured value applies; only an
|
|
38
|
+
# explicitly supplied value is range-checked here.
|
|
39
|
+
if workers is not None:
|
|
40
|
+
workers = check_workers(workers)
|
|
41
|
+
if limit is not None:
|
|
42
|
+
limit = check_limit(limit)
|
|
43
|
+
error = reject_tagged_reference(image, "export")
|
|
44
|
+
if error:
|
|
45
|
+
console.print(f"[red]{error}[/red]")
|
|
46
|
+
raise typer.Exit(EXIT_ERROR)
|
|
47
|
+
try:
|
|
48
|
+
asyncio.run(_export(image, output_format, output, workers, limit))
|
|
49
|
+
except ValueError as e:
|
|
50
|
+
console.print(f"[red]Invalid configuration:[/red] {e}")
|
|
51
|
+
raise typer.Exit(EXIT_ERROR) from e
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
async def _export(
|
|
55
|
+
image: str, fmt: str, output: str, workers: int | None, limit: int | None
|
|
56
|
+
) -> None:
|
|
57
|
+
# Same fallback as `recommend`: omitting a flag means "use the
|
|
58
|
+
# configured value", rather than a hard-coded default shadowing it.
|
|
59
|
+
use_case = await build_recommend_use_case(workers=workers)
|
|
60
|
+
result = await use_case.execute(image, limit=resolve_tag_limit(limit))
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
exporter = ExporterFactory.create(fmt)
|
|
64
|
+
except ValueError as e:
|
|
65
|
+
console.print(f"[red]{e}[/red]")
|
|
66
|
+
raise typer.Exit(EXIT_ERROR) from e
|
|
67
|
+
|
|
68
|
+
if output:
|
|
69
|
+
path = Path(output)
|
|
70
|
+
try:
|
|
71
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
72
|
+
exporter.export(result, path)
|
|
73
|
+
except OSError as e:
|
|
74
|
+
# An unwritable destination is user error, not a crash.
|
|
75
|
+
console.print(f"[red]Could not write {path}:[/red] {e}")
|
|
76
|
+
raise typer.Exit(EXIT_ERROR) from e
|
|
77
|
+
console.print(f"[green]Report exported to {path}[/green]")
|
|
78
|
+
else:
|
|
79
|
+
# soft_wrap avoids Rich reflowing/inserting newlines into
|
|
80
|
+
# machine-readable output (JSON, CSV, SARIF).
|
|
81
|
+
console.print(exporter.export_string(result), soft_wrap=True)
|