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,530 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from functools import lru_cache
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
from loguru import logger
|
|
7
|
+
|
|
8
|
+
from dockerls.application.services.composite_repository import CompositeImageRepository
|
|
9
|
+
from dockerls.application.services.cross_validation import CrossValidator
|
|
10
|
+
from dockerls.application.services.hardening_analysis import HardeningAnalyzer
|
|
11
|
+
from dockerls.application.services.scanner_factory import ScannerFactory
|
|
12
|
+
from dockerls.application.services.source_registry import SourceRegistry, SourceSpec
|
|
13
|
+
from dockerls.application.use_cases.analyze_image import AnalyzeImageUseCase
|
|
14
|
+
from dockerls.application.use_cases.compare_images import CompareImagesUseCase
|
|
15
|
+
from dockerls.application.use_cases.recommend_images import RecommendImagesUseCase
|
|
16
|
+
from dockerls.application.use_cases.search_images import SearchImagesUseCase
|
|
17
|
+
from dockerls.cli.runtime import (
|
|
18
|
+
_settings,
|
|
19
|
+
configure_logging,
|
|
20
|
+
current_log_file,
|
|
21
|
+
enable_console_logging,
|
|
22
|
+
)
|
|
23
|
+
from dockerls.domain.entities.image import DOCKER_HUB
|
|
24
|
+
from dockerls.domain.value_objects.network_policy import NetworkPolicy
|
|
25
|
+
from dockerls.infrastructure.evidence import EvidenceStore
|
|
26
|
+
from dockerls.infrastructure.network.host_guard import HostGuard
|
|
27
|
+
from dockerls.integrations.dhi.catalog import DHICatalogClient
|
|
28
|
+
from dockerls.integrations.dhi.repository import DHI, DHIRepository
|
|
29
|
+
from dockerls.integrations.dockerhub.client import DockerHubClient
|
|
30
|
+
from dockerls.integrations.endoflife.checker import EndOfLifeChecker
|
|
31
|
+
from dockerls.integrations.exploitdb.client import ExploitDBClient
|
|
32
|
+
from dockerls.integrations.registry.hardened import (
|
|
33
|
+
CHAINGUARD,
|
|
34
|
+
DISTROLESS,
|
|
35
|
+
ChainguardRepository,
|
|
36
|
+
DistrolessRepository,
|
|
37
|
+
)
|
|
38
|
+
from dockerls.integrations.registry.inspector import RegistryInspector
|
|
39
|
+
from dockerls.integrations.registry.private import PRIVATE_REGISTRY, PrivateRegistryRepository
|
|
40
|
+
from dockerls.integrations.threat_intel.client import ThreatIntelClient
|
|
41
|
+
from dockerls.utils.auth import load_credentials
|
|
42
|
+
from dockerls.utils.resources import describe_capacity, recommended_workers
|
|
43
|
+
from dockerls.utils.validation import validate_threshold, validate_workers
|
|
44
|
+
|
|
45
|
+
if TYPE_CHECKING:
|
|
46
|
+
from collections.abc import Callable, Sequence
|
|
47
|
+
|
|
48
|
+
from dockerls.application.services.progress import ScanObserver
|
|
49
|
+
from dockerls.application.services.source_registry import SourceBuilder
|
|
50
|
+
from dockerls.cache.sqlite_cache import SQLiteCache
|
|
51
|
+
from dockerls.domain.interfaces.image_repository import ImageRepositoryInterface
|
|
52
|
+
|
|
53
|
+
# As Settings e o logging moram em `cli/runtime.py`, que não arrasta este
|
|
54
|
+
# módulo junto: o callback de bootstrap precisa deles antes de todo
|
|
55
|
+
# subcomando, e importar o contêiner inteiro para configurar um sink era o
|
|
56
|
+
# que fazia `dockerls version` custar o mesmo que `dockerls advisor`.
|
|
57
|
+
# Reexportados aqui porque todo chamador -- e todo teste -- já os importa
|
|
58
|
+
# deste módulo.
|
|
59
|
+
__all__ = [
|
|
60
|
+
"_settings",
|
|
61
|
+
"configure_logging",
|
|
62
|
+
"current_log_file",
|
|
63
|
+
"enable_console_logging",
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def resolve_workers(requested: int | None = None) -> int:
|
|
68
|
+
"""How many scanner processes this run may hold at once.
|
|
69
|
+
|
|
70
|
+
Precedence: the command line, then the configured value, then the
|
|
71
|
+
machine. `0` in either of the first two means "ask the machine", which
|
|
72
|
+
is the default -- a scanner process is not a coroutine, and ten of them
|
|
73
|
+
on a two-core runner is slower than four, not faster.
|
|
74
|
+
|
|
75
|
+
An explicit value above what the machine can carry is honoured and
|
|
76
|
+
logged. Refusing it would be presumptuous: an operator scanning tiny
|
|
77
|
+
images, or one who has measured their own runner, is entitled to
|
|
78
|
+
overcommit. Doing it silently is what must not happen.
|
|
79
|
+
"""
|
|
80
|
+
s = _settings()
|
|
81
|
+
configured = requested if requested is not None else s.workers
|
|
82
|
+
recommended = recommended_workers()
|
|
83
|
+
|
|
84
|
+
if not configured:
|
|
85
|
+
logger.info(
|
|
86
|
+
f"Using {recommended} scanner worker(s) for this machine "
|
|
87
|
+
f"({describe_capacity()}); set --workers to override"
|
|
88
|
+
)
|
|
89
|
+
return recommended
|
|
90
|
+
|
|
91
|
+
effective = validate_workers(configured, "--workers")
|
|
92
|
+
if effective > recommended:
|
|
93
|
+
logger.warning(
|
|
94
|
+
f"{effective} scanner workers requested on a machine with "
|
|
95
|
+
f"{describe_capacity()}; each worker holds a scanner process, so this may "
|
|
96
|
+
f"contend for CPU or memory. {recommended} is what this machine suggests."
|
|
97
|
+
)
|
|
98
|
+
return effective
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def resolve_tag_limit(limit: int | None) -> int:
|
|
102
|
+
"""`--limit` falls back to the configured `max_tags`."""
|
|
103
|
+
s = _settings()
|
|
104
|
+
return validate_threshold(s.max_tags if limit is None else limit, "--limit")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def build_evidence_store() -> EvidenceStore:
|
|
108
|
+
return EvidenceStore(_settings().evidence_dir)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
async def build_repository(cache: SQLiteCache | None = None) -> DockerHubClient:
|
|
112
|
+
s = _settings()
|
|
113
|
+
username = s.dockerhub_username
|
|
114
|
+
token = s.dockerhub_token
|
|
115
|
+
if not username or not token:
|
|
116
|
+
username, token = load_credentials()
|
|
117
|
+
|
|
118
|
+
client = DockerHubClient(
|
|
119
|
+
username=username,
|
|
120
|
+
token=token,
|
|
121
|
+
timeout=s.http_timeout,
|
|
122
|
+
cache=cache,
|
|
123
|
+
max_attempts=s.retry_max_attempts,
|
|
124
|
+
backoff_base=s.retry_backoff_base,
|
|
125
|
+
tag_ttl_seconds=s.tag_cache_ttl_seconds,
|
|
126
|
+
guard=build_host_guard(),
|
|
127
|
+
)
|
|
128
|
+
if username and token:
|
|
129
|
+
await client.authenticate()
|
|
130
|
+
return client
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@lru_cache(maxsize=1)
|
|
134
|
+
def build_cache() -> SQLiteCache:
|
|
135
|
+
# Import tardio: `SQLiteCache` puxa o SQLAlchemy, que sozinho responde por
|
|
136
|
+
# cerca de um segundo do arranque do processo. Comandos que nunca tocam o
|
|
137
|
+
# cache -- `version`, `--help`, `controls`, `policy` -- pagavam esse
|
|
138
|
+
# segundo em toda invocação, e um segundo de espera antes de um `--help` é
|
|
139
|
+
# o tipo de coisa que faz uma ferramenta parecer pesada sem ser.
|
|
140
|
+
from dockerls.cache.sqlite_cache import SQLiteCache
|
|
141
|
+
|
|
142
|
+
s = _settings()
|
|
143
|
+
return SQLiteCache(s.db_path)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def close_cache() -> None:
|
|
147
|
+
"""Dispose the shared SQLite engine, if a command ever built one.
|
|
148
|
+
|
|
149
|
+
Called once, after the command finishes (see `cli/app.py`). Every
|
|
150
|
+
caller of `build_cache()` -- `recommend`, `cache`, `registry-audit`,
|
|
151
|
+
`_threat_intel`, `_exploitdb` -- gets the same memoized instance, so
|
|
152
|
+
there is exactly one engine to close per process, and a command that
|
|
153
|
+
never touched the cache (`version`, `--help`) never built one: this is
|
|
154
|
+
then a no-op that costs nothing.
|
|
155
|
+
"""
|
|
156
|
+
if build_cache.cache_info().currsize:
|
|
157
|
+
build_cache().close()
|
|
158
|
+
build_cache.cache_clear()
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@lru_cache(maxsize=1)
|
|
162
|
+
def _threat_intel() -> ThreatIntelClient | None:
|
|
163
|
+
"""KEV catalogue and EPSS scores, cached to disk like Exploit-DB below.
|
|
164
|
+
|
|
165
|
+
Both feeds move roughly once a day, so without a disk cache every single
|
|
166
|
+
invocation re-downloaded the whole KEV catalogue and re-queried FIRST.org
|
|
167
|
+
for every CRITICAL/HIGH CVE from scratch -- including two `recommend`
|
|
168
|
+
runs back to back against the same image a minute apart.
|
|
169
|
+
"""
|
|
170
|
+
s = _settings()
|
|
171
|
+
if not s.enable_threat_intel:
|
|
172
|
+
return None
|
|
173
|
+
return ThreatIntelClient(timeout=s.http_timeout, cache=build_cache())
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
@lru_cache(maxsize=1)
|
|
177
|
+
def _exploitdb() -> ExploitDBClient | None:
|
|
178
|
+
"""O catálogo do Exploit-DB, atrás da mesma chave que KEV/EPSS.
|
|
179
|
+
|
|
180
|
+
Segue `enable_threat_intel` porque responde à mesma pergunta -- quão
|
|
181
|
+
explorável é isto -- e quem desliga o enriquecimento não quer que este
|
|
182
|
+
fique de fora. Recebe o cache em disco pelo mesmo motivo do
|
|
183
|
+
`ThreatIntelClient`: o CSV tem cerca de 10 MB, e rebaixá-lo a cada
|
|
184
|
+
invocação seria pagar o download inteiro para reler o mesmo dia de
|
|
185
|
+
catálogo.
|
|
186
|
+
"""
|
|
187
|
+
s = _settings()
|
|
188
|
+
if not s.enable_threat_intel:
|
|
189
|
+
return None
|
|
190
|
+
return ExploitDBClient(timeout=s.http_timeout, cache=build_cache(), guard=build_host_guard())
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def build_source_registry(cache: SQLiteCache | None = None) -> SourceRegistry:
|
|
194
|
+
"""Every catalogue this build can search, keyed by its `--source` token.
|
|
195
|
+
|
|
196
|
+
This is the one place that knows the full set. Commands ask the registry
|
|
197
|
+
to resolve a selection; none of them names a provider, so adding one is
|
|
198
|
+
a `register()` call here and nothing else.
|
|
199
|
+
"""
|
|
200
|
+
s = _settings()
|
|
201
|
+
registry = SourceRegistry()
|
|
202
|
+
registry.register(
|
|
203
|
+
SourceSpec(
|
|
204
|
+
name="dockerhub",
|
|
205
|
+
label=DOCKER_HUB,
|
|
206
|
+
build=lambda: build_repository(cache=cache),
|
|
207
|
+
primary=True,
|
|
208
|
+
description="Docker Hub (official and community images)",
|
|
209
|
+
)
|
|
210
|
+
)
|
|
211
|
+
registry.register(
|
|
212
|
+
SourceSpec(
|
|
213
|
+
name="chainguard",
|
|
214
|
+
label=CHAINGUARD,
|
|
215
|
+
build=_source_builder(
|
|
216
|
+
lambda: ChainguardRepository(timeout=s.http_timeout, guard=build_host_guard())
|
|
217
|
+
),
|
|
218
|
+
default_enabled=s.include_hardened_sources,
|
|
219
|
+
description="Chainguard free tier (cgr.dev)",
|
|
220
|
+
)
|
|
221
|
+
)
|
|
222
|
+
registry.register(
|
|
223
|
+
SourceSpec(
|
|
224
|
+
name="distroless",
|
|
225
|
+
label=DISTROLESS,
|
|
226
|
+
build=_source_builder(
|
|
227
|
+
lambda: DistrolessRepository(timeout=s.http_timeout, guard=build_host_guard())
|
|
228
|
+
),
|
|
229
|
+
default_enabled=s.include_hardened_sources,
|
|
230
|
+
description="Google Distroless (gcr.io/distroless)",
|
|
231
|
+
)
|
|
232
|
+
)
|
|
233
|
+
registry.register(
|
|
234
|
+
SourceSpec(
|
|
235
|
+
name="dhi",
|
|
236
|
+
label=DHI,
|
|
237
|
+
build=_source_builder(
|
|
238
|
+
lambda: DHIRepository(
|
|
239
|
+
catalog=DHICatalogClient(
|
|
240
|
+
timeout=s.http_timeout,
|
|
241
|
+
cache=cache,
|
|
242
|
+
ttl_seconds=s.dhi_catalog_ttl_seconds,
|
|
243
|
+
token=s.github_token,
|
|
244
|
+
),
|
|
245
|
+
definition_limit=s.dhi_definition_limit,
|
|
246
|
+
)
|
|
247
|
+
),
|
|
248
|
+
# Off unless asked for: dhi.io refuses anonymous pulls, so its
|
|
249
|
+
# candidates cannot be scanned on a machine without Docker
|
|
250
|
+
# Hardened Images credentials, and an unscannable candidate is
|
|
251
|
+
# reported as UNVERIFIED rather than ranked.
|
|
252
|
+
default_enabled=s.include_dhi_source,
|
|
253
|
+
requires_auth=True,
|
|
254
|
+
description="Docker Hardened Images catalog (dhi.io, needs credentials to scan)",
|
|
255
|
+
)
|
|
256
|
+
)
|
|
257
|
+
if s.private_registry_host:
|
|
258
|
+
# Registered only when a host is actually configured: an option
|
|
259
|
+
# with nothing behind it would make `--source private` fail with
|
|
260
|
+
# "no repository named that" instead of the CLI's own unknown-
|
|
261
|
+
# source message, and would list a source in `--help`/`doctor`
|
|
262
|
+
# that cannot do anything.
|
|
263
|
+
registry.register(
|
|
264
|
+
SourceSpec(
|
|
265
|
+
name="private",
|
|
266
|
+
label=PRIVATE_REGISTRY,
|
|
267
|
+
build=_source_builder(
|
|
268
|
+
lambda: PrivateRegistryRepository(
|
|
269
|
+
s.private_registry_host,
|
|
270
|
+
s.private_registry_namespace,
|
|
271
|
+
timeout=s.http_timeout,
|
|
272
|
+
guard=build_host_guard(),
|
|
273
|
+
username=s.private_registry_username,
|
|
274
|
+
password=s.private_registry_password,
|
|
275
|
+
)
|
|
276
|
+
),
|
|
277
|
+
default_enabled=False,
|
|
278
|
+
requires_auth=bool(s.private_registry_username),
|
|
279
|
+
description=f"Private registry ({s.private_registry_host})",
|
|
280
|
+
)
|
|
281
|
+
)
|
|
282
|
+
return registry
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _source_builder(factory: Callable[[], ImageRepositoryInterface]) -> SourceBuilder:
|
|
286
|
+
"""Adapt a synchronous constructor to the registry's async builder."""
|
|
287
|
+
|
|
288
|
+
async def build() -> ImageRepositoryInterface:
|
|
289
|
+
return factory()
|
|
290
|
+
|
|
291
|
+
return build
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
async def build_composite_repository(specs: list[SourceSpec]) -> CompositeImageRepository:
|
|
295
|
+
"""Instantiate the resolved sources and fan a query across them.
|
|
296
|
+
|
|
297
|
+
The first spec is the primary and gets the full `--limit`; the rest are
|
|
298
|
+
capped at `hardened_tag_limit`, because a curated catalogue publishes a
|
|
299
|
+
handful of tags where Docker Hub publishes hundreds.
|
|
300
|
+
"""
|
|
301
|
+
if not specs:
|
|
302
|
+
raise ValueError("at least one image source must be selected")
|
|
303
|
+
built = [await spec.build() for spec in specs]
|
|
304
|
+
return CompositeImageRepository(built[0], built[1:], extra_limit=_settings().hardened_tag_limit)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
async def build_sources(
|
|
308
|
+
selection: Sequence[str] | None = None,
|
|
309
|
+
*,
|
|
310
|
+
all_sources: bool = False,
|
|
311
|
+
include_hardened: bool | None = None,
|
|
312
|
+
cache: SQLiteCache | None = None,
|
|
313
|
+
) -> CompositeImageRepository:
|
|
314
|
+
"""Resolve a `--source`/`--all-sources` selection into a live repository.
|
|
315
|
+
|
|
316
|
+
One entry point for every command, so `search`, `recommend`,
|
|
317
|
+
`alternatives` and `advisor` cannot drift into searching different sets
|
|
318
|
+
of catalogues for the same flags.
|
|
319
|
+
"""
|
|
320
|
+
s = _settings()
|
|
321
|
+
registry = build_source_registry(cache=cache)
|
|
322
|
+
specs = registry.resolve(
|
|
323
|
+
selection,
|
|
324
|
+
all_sources=all_sources,
|
|
325
|
+
include_optional=(
|
|
326
|
+
s.include_hardened_sources if include_hardened is None else include_hardened
|
|
327
|
+
),
|
|
328
|
+
)
|
|
329
|
+
return await build_composite_repository(specs)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def available_source_names() -> list[str]:
|
|
333
|
+
"""`--source` choices, for help text and error messages."""
|
|
334
|
+
return build_source_registry().names
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
async def build_recommend_use_case(
|
|
338
|
+
max_critical: int | None = None,
|
|
339
|
+
max_high: int | None = None,
|
|
340
|
+
max_medium: int | None = None,
|
|
341
|
+
workers: int | None = None,
|
|
342
|
+
observer: ScanObserver | None = None,
|
|
343
|
+
cross_validate: bool | None = None,
|
|
344
|
+
verify_hub_tags: bool | None = None,
|
|
345
|
+
include_hardened: bool | None = None,
|
|
346
|
+
use_cache: bool = True,
|
|
347
|
+
sources: Sequence[str] | None = None,
|
|
348
|
+
all_sources: bool = False,
|
|
349
|
+
scan_budget: int | None = None,
|
|
350
|
+
) -> RecommendImagesUseCase:
|
|
351
|
+
s = _settings()
|
|
352
|
+
# None means "not given on the command line", so the configured value
|
|
353
|
+
# applies. Previously these carried hard-coded defaults that shadowed
|
|
354
|
+
# Settings entirely, which made DOCKERLS_MAX_MEDIUM and the config file
|
|
355
|
+
# silently do nothing.
|
|
356
|
+
max_critical = validate_threshold(
|
|
357
|
+
s.max_critical if max_critical is None else max_critical, "--max-critical"
|
|
358
|
+
)
|
|
359
|
+
max_high = validate_threshold(s.max_high if max_high is None else max_high, "--max-high")
|
|
360
|
+
max_medium = validate_threshold(
|
|
361
|
+
s.max_medium if max_medium is None else max_medium, "--max-medium"
|
|
362
|
+
)
|
|
363
|
+
workers = resolve_workers(workers)
|
|
364
|
+
|
|
365
|
+
# `--no-cache` força uma medição nova: o cache é uma otimização, e às
|
|
366
|
+
# vezes o que se quer é justamente contorná-lo.
|
|
367
|
+
cache = build_cache() if use_cache else None
|
|
368
|
+
repo = await build_sources(
|
|
369
|
+
sources,
|
|
370
|
+
all_sources=all_sources,
|
|
371
|
+
include_hardened=include_hardened,
|
|
372
|
+
cache=cache,
|
|
373
|
+
)
|
|
374
|
+
evidence = build_evidence_store()
|
|
375
|
+
scanner = await ScannerFactory.create(
|
|
376
|
+
timeout=s.scanner_timeout,
|
|
377
|
+
workers=workers,
|
|
378
|
+
cache_dir=s.trivy_cache_dir,
|
|
379
|
+
evidence=evidence,
|
|
380
|
+
guard=build_host_guard(),
|
|
381
|
+
)
|
|
382
|
+
eol = EndOfLifeChecker(
|
|
383
|
+
timeout=s.http_timeout,
|
|
384
|
+
max_attempts=s.retry_max_attempts,
|
|
385
|
+
backoff_base=s.retry_backoff_base,
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
secondary = None
|
|
389
|
+
if s.cross_validate if cross_validate is None else cross_validate:
|
|
390
|
+
secondary = await ScannerFactory.create_secondary(
|
|
391
|
+
scanner,
|
|
392
|
+
timeout=s.scanner_timeout,
|
|
393
|
+
evidence=evidence,
|
|
394
|
+
guard=build_host_guard(),
|
|
395
|
+
# O mesmo teto do passo principal: a cross-validação roda depois
|
|
396
|
+
# dele e herda o orçamento, em vez de abrir um segundo maior.
|
|
397
|
+
workers=min(resolve_workers(s.cross_validate_workers or None), workers),
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
return RecommendImagesUseCase(
|
|
401
|
+
repository=repo,
|
|
402
|
+
hardening=build_hardening_analyzer(),
|
|
403
|
+
resolve_digests=s.resolve_digests,
|
|
404
|
+
scanner=scanner,
|
|
405
|
+
eol_checker=eol,
|
|
406
|
+
cache=cache,
|
|
407
|
+
max_critical=max_critical,
|
|
408
|
+
max_high=max_high,
|
|
409
|
+
max_medium=max_medium,
|
|
410
|
+
workers=workers,
|
|
411
|
+
threat_intel=_threat_intel(),
|
|
412
|
+
exploitdb=_exploitdb(),
|
|
413
|
+
observer=observer,
|
|
414
|
+
cross_validator=CrossValidator(
|
|
415
|
+
secondary,
|
|
416
|
+
# Capped at the primary worker count as well as the machine's:
|
|
417
|
+
# cross-validation runs after the main pass, so it inherits the
|
|
418
|
+
# same budget rather than opening a second, larger one.
|
|
419
|
+
workers=min(resolve_workers(s.cross_validate_workers or None), workers),
|
|
420
|
+
),
|
|
421
|
+
evidence=evidence,
|
|
422
|
+
verify_hub_tags=s.verify_hub_tags if verify_hub_tags is None else verify_hub_tags,
|
|
423
|
+
log_file=current_log_file(),
|
|
424
|
+
cache_ttl_seconds=s.cache_ttl_seconds,
|
|
425
|
+
scan_budget=s.scan_budget if scan_budget is None else scan_budget,
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def build_host_guard() -> HostGuard:
|
|
430
|
+
"""Where a reference is allowed to make this process connect.
|
|
431
|
+
|
|
432
|
+
Built here rather than on `Settings` so the settings object stays a
|
|
433
|
+
plain data holder and the policy is assembled in the one place that
|
|
434
|
+
assembles everything else.
|
|
435
|
+
"""
|
|
436
|
+
s = _settings()
|
|
437
|
+
return HostGuard(
|
|
438
|
+
NetworkPolicy(
|
|
439
|
+
allow_private_networks=s.network_allow_private_networks,
|
|
440
|
+
allow_loopback=s.network_allow_loopback,
|
|
441
|
+
allow_link_local=s.network_allow_link_local,
|
|
442
|
+
allowed_hosts=frozenset(s.network_allowed_hosts),
|
|
443
|
+
)
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def build_registry_credentials() -> dict[str, tuple[str, str]]:
|
|
448
|
+
"""Host -> (username, password) for every registry this run has
|
|
449
|
+
credentials for -- the configured private registry, today.
|
|
450
|
+
|
|
451
|
+
`RegistryInspector` resolves a reference by its host, not by a
|
|
452
|
+
`--source` name, so `analyze`/`compare`/`alternatives` -- which take a
|
|
453
|
+
reference directly, never a source -- reach the same credentials
|
|
454
|
+
`--source private` uses for `recommend`/`search` through this instead
|
|
455
|
+
of a second, separate configuration.
|
|
456
|
+
"""
|
|
457
|
+
s = _settings()
|
|
458
|
+
if not (s.private_registry_host and s.private_registry_username):
|
|
459
|
+
return {}
|
|
460
|
+
return {s.private_registry_host: (s.private_registry_username, s.private_registry_password)}
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def build_hardening_analyzer() -> HardeningAnalyzer:
|
|
464
|
+
"""The registry-backed evidence gatherer, or a disabled one.
|
|
465
|
+
|
|
466
|
+
With `inspect_image_config` off the analyzer still exists but has no
|
|
467
|
+
inspector, so every hardening fact stays UNKNOWN and every dimension
|
|
468
|
+
reports as not determined -- which is the honest result of choosing not
|
|
469
|
+
to look, and is very different from reporting an image as clean.
|
|
470
|
+
"""
|
|
471
|
+
s = _settings()
|
|
472
|
+
inspector = (
|
|
473
|
+
RegistryInspector(
|
|
474
|
+
timeout=s.http_timeout,
|
|
475
|
+
guard=build_host_guard(),
|
|
476
|
+
credentials=build_registry_credentials(),
|
|
477
|
+
)
|
|
478
|
+
if s.inspect_image_config
|
|
479
|
+
else None
|
|
480
|
+
)
|
|
481
|
+
return HardeningAnalyzer(inspector=inspector)
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
async def build_analyze_use_case() -> AnalyzeImageUseCase:
|
|
485
|
+
s = _settings()
|
|
486
|
+
repo = await build_repository()
|
|
487
|
+
scanner = await ScannerFactory.create(timeout=s.scanner_timeout, guard=build_host_guard())
|
|
488
|
+
eol = EndOfLifeChecker(
|
|
489
|
+
timeout=s.http_timeout,
|
|
490
|
+
max_attempts=s.retry_max_attempts,
|
|
491
|
+
backoff_base=s.retry_backoff_base,
|
|
492
|
+
)
|
|
493
|
+
# Import tardio, como o resto do módulo: essas duas stores só custam algo
|
|
494
|
+
# quando algum comando de fato as usa.
|
|
495
|
+
from dockerls.application.services.scan_history_store import ScanHistoryStore
|
|
496
|
+
from dockerls.application.services.tag_history_store import TagHistoryStore
|
|
497
|
+
|
|
498
|
+
cache = build_cache()
|
|
499
|
+
return AnalyzeImageUseCase(
|
|
500
|
+
repository=repo,
|
|
501
|
+
scanner=scanner,
|
|
502
|
+
eol_checker=eol,
|
|
503
|
+
threat_intel=_threat_intel(),
|
|
504
|
+
exploitdb=_exploitdb(),
|
|
505
|
+
hardening=build_hardening_analyzer(),
|
|
506
|
+
tag_history=TagHistoryStore(cache),
|
|
507
|
+
scan_history=ScanHistoryStore(cache),
|
|
508
|
+
)
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
async def build_compare_use_case() -> CompareImagesUseCase:
|
|
512
|
+
analyze = await build_analyze_use_case()
|
|
513
|
+
return CompareImagesUseCase(analyze_use_case=analyze)
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
async def build_search_use_case(
|
|
517
|
+
sources: Sequence[str] | None = None,
|
|
518
|
+
*,
|
|
519
|
+
all_sources: bool = False,
|
|
520
|
+
) -> SearchImagesUseCase:
|
|
521
|
+
"""`search` goes through its use case like every other command, so the
|
|
522
|
+
CLI never reaches past the application layer into a repository.
|
|
523
|
+
|
|
524
|
+
With no selection this is Docker Hub alone, which is what `search` has
|
|
525
|
+
always been: a listing of one repository's tags. `--source`/
|
|
526
|
+
`--all-sources` widen it to the same catalogues `recommend` searches.
|
|
527
|
+
"""
|
|
528
|
+
if sources is None and not all_sources:
|
|
529
|
+
return SearchImagesUseCase(repository=await build_repository())
|
|
530
|
+
return SearchImagesUseCase(repository=await build_sources(sources, all_sources=all_sources))
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""O nome que aparece na tabela, sem o registry que a coluna ao lado já diz.
|
|
2
|
+
|
|
3
|
+
A tabela de resultados tem treze colunas e nenhuma largura sobrando. Com
|
|
4
|
+
`overflow="fold"`, um nome como `gcr.io/distroless/nodejs22-debian12` era
|
|
5
|
+
quebrado no meio da palavra e saía em duas ou três linhas ilegíveis --
|
|
6
|
+
enquanto a coluna `Source`, encostada nele, já dizia "Distroless".
|
|
7
|
+
|
|
8
|
+
Encurtar aqui não é cosmético: o leitor precisa reconhecer *que runtime é
|
|
9
|
+
aquele* de relance, e é justamente essa metade que se perdia. `nodejs22-debian12`
|
|
10
|
+
diz Node 22 sobre Debian 12; `gcr.io/distrole` / `ss/nodejs22-de` não diz nada.
|
|
11
|
+
|
|
12
|
+
O corte só acontece quando a coluna vizinha carrega a informação removida.
|
|
13
|
+
Um registry que a tabela não identifica -- `ghcr.io/org/app` -- é mostrado
|
|
14
|
+
inteiro, porque ali o host *é* a identidade e escondê-lo confundiria duas
|
|
15
|
+
imagens diferentes com o mesmo nome final.
|
|
16
|
+
|
|
17
|
+
A referência completa não se perde em lugar nenhum: `--format json`, a linha
|
|
18
|
+
`Pin to:` e a evidência continuam com o nome inteiro, que é o que alguém
|
|
19
|
+
copia para um Dockerfile.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from dockerls.domain.value_objects.image_reference import (
|
|
25
|
+
split_repository_and_tag,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
#: Prefixos de catálogos que a coluna `Source` já nomeia.
|
|
29
|
+
_REDUNDANT_PREFIXES = (
|
|
30
|
+
"cgr.dev/chainguard/",
|
|
31
|
+
"gcr.io/distroless/",
|
|
32
|
+
"dhi.io/",
|
|
33
|
+
"docker.io/library/",
|
|
34
|
+
"index.docker.io/library/",
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def display_name(name: str) -> str:
|
|
39
|
+
"""O nome do runtime, sem o prefixo que a coluna `Source` repete."""
|
|
40
|
+
value = name.strip()
|
|
41
|
+
for prefix in _REDUNDANT_PREFIXES:
|
|
42
|
+
if value.lower().startswith(prefix):
|
|
43
|
+
return value[len(prefix) :] or value
|
|
44
|
+
return value
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def display_reference(name: str, tag: str) -> str:
|
|
48
|
+
short = display_name(name)
|
|
49
|
+
return f"{short}:{tag}" if tag else short
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def reject_tagged_reference(image: str, command: str) -> str | None:
|
|
53
|
+
"""An error message when `image` names a tag instead of a bare
|
|
54
|
+
repository, or None when the argument is fine as given.
|
|
55
|
+
|
|
56
|
+
`search`, `recommend` and `export` all take a repository name and
|
|
57
|
+
discover its tags themselves; a caller who passes `node:18` almost
|
|
58
|
+
certainly meant to inspect that one tag, not search for a repository
|
|
59
|
+
literally named `node:18` (which does not exist and used to fail with
|
|
60
|
+
an opaque "No tags found").
|
|
61
|
+
"""
|
|
62
|
+
repository, tag = split_repository_and_tag(image)
|
|
63
|
+
if not tag:
|
|
64
|
+
return None
|
|
65
|
+
return (
|
|
66
|
+
f"Error: '{command}' takes the image name, not a specific tag. "
|
|
67
|
+
f"Did you mean 'dockerls {command} {repository}'? "
|
|
68
|
+
f"To analyze one specific tag, use 'dockerls analyze {image}'."
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# Reexportado: a regra mora no domínio (a `application` também precisa dela),
|
|
73
|
+
# e os chamadores da CLI seguem importando daqui.
|
|
74
|
+
__all__ = [
|
|
75
|
+
"display_name",
|
|
76
|
+
"display_reference",
|
|
77
|
+
"reject_tagged_reference",
|
|
78
|
+
"split_repository_and_tag",
|
|
79
|
+
]
|
dockerls/cli/options.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Shared option types for the CLI.
|
|
2
|
+
|
|
3
|
+
Modelling the choice as a StrEnum keeps the accepted values in one place, so
|
|
4
|
+
a plain ``str`` compared with ``== "json"`` can no longer fall through to the
|
|
5
|
+
default branch on a typo like ``--format jsonn``.
|
|
6
|
+
|
|
7
|
+
The value is parsed in the command body rather than declared as a Typer enum
|
|
8
|
+
parameter on purpose. Typer rejects an unknown choice with **exit code 2**,
|
|
9
|
+
and 2 is not free in this CLI: ``recommend`` publishes it as "no image met
|
|
10
|
+
the baseline, but alternatives were found". A CI gate keying on the exit
|
|
11
|
+
code would read a typo in a flag as a security verdict. Parsing here keeps
|
|
12
|
+
usage errors on the shared operational code (1), which is what they are.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from enum import StrEnum
|
|
18
|
+
|
|
19
|
+
import typer
|
|
20
|
+
from rich.console import Console
|
|
21
|
+
|
|
22
|
+
from dockerls.exit_codes import EXIT_ERROR
|
|
23
|
+
|
|
24
|
+
_console = Console()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class OutputFormat(StrEnum):
|
|
28
|
+
TABLE = "table"
|
|
29
|
+
JSON = "json"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def parse_output_format(value: str) -> OutputFormat:
|
|
33
|
+
"""Resolve ``--format`` or fail with an actionable message and exit 1."""
|
|
34
|
+
try:
|
|
35
|
+
return OutputFormat(value)
|
|
36
|
+
except ValueError as e:
|
|
37
|
+
choices = ", ".join(f.value for f in OutputFormat)
|
|
38
|
+
_console.print(
|
|
39
|
+
f"[red]Error:[/red] unsupported --format {value!r}.\n"
|
|
40
|
+
f"[dim]Suggested action: use one of: {choices}[/dim]"
|
|
41
|
+
)
|
|
42
|
+
raise typer.Exit(EXIT_ERROR) from e
|