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,447 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import shutil
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
|
+
from typing import TYPE_CHECKING, Any
|
|
8
|
+
|
|
9
|
+
from loguru import logger
|
|
10
|
+
|
|
11
|
+
from dockerls.domain.entities.scan_result import ScanErrorKind, ScanResult, ScanStatus
|
|
12
|
+
from dockerls.domain.entities.vulnerability import Severity, Vulnerability
|
|
13
|
+
from dockerls.domain.interfaces.scanner import ScannerInterface
|
|
14
|
+
from dockerls.integrations.engine.batch import EngineBatchScanner
|
|
15
|
+
from dockerls.integrations.scan_errors import classify_scanner_error
|
|
16
|
+
from dockerls.integrations.scan_target import blocked_scan_result, blocked_target_reason
|
|
17
|
+
from dockerls.integrations.trivy.cache_pool import TrivyCachePool, default_trivy_cache_dir
|
|
18
|
+
from dockerls.utils.executables import ExecutableNotFoundError, resolve_executable
|
|
19
|
+
from dockerls.utils.subprocess_runner import (
|
|
20
|
+
VERSION_TIMEOUT_SECONDS,
|
|
21
|
+
OutputTooLargeError,
|
|
22
|
+
run_capture,
|
|
23
|
+
)
|
|
24
|
+
from dockerls.utils.validation import sanitize_image_name
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
from dockerls.infrastructure.evidence import EvidenceStore
|
|
30
|
+
from dockerls.infrastructure.network.host_guard import HostGuard
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class TrivyScanner(ScannerInterface):
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
timeout: int = 300,
|
|
37
|
+
skip_db_update: bool = False,
|
|
38
|
+
cache_dir: Path | None = None,
|
|
39
|
+
workers: int = 1,
|
|
40
|
+
evidence: EvidenceStore | None = None,
|
|
41
|
+
guard: HostGuard | None = None,
|
|
42
|
+
):
|
|
43
|
+
self._timeout = timeout
|
|
44
|
+
self._skip_db_update = skip_db_update
|
|
45
|
+
self._workers = max(1, workers)
|
|
46
|
+
self._cache_pool = TrivyCachePool(cache_dir or default_trivy_cache_dir(), workers)
|
|
47
|
+
self._evidence = evidence
|
|
48
|
+
self._version: str | None = None
|
|
49
|
+
self._batch: EngineBatchScanner | None = None
|
|
50
|
+
# Trivy performs its own pull, so the reference has to clear the
|
|
51
|
+
# network policy here or it never clears it at all.
|
|
52
|
+
self._guard = guard
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def cache_pool(self) -> TrivyCachePool:
|
|
56
|
+
return self._cache_pool
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def batch(self) -> EngineBatchScanner:
|
|
60
|
+
"""O caminho em lote, quando a engine Go estiver disponível."""
|
|
61
|
+
if self._batch is None:
|
|
62
|
+
self._batch = EngineBatchScanner(
|
|
63
|
+
scanner="trivy",
|
|
64
|
+
timeout_seconds=float(self._timeout),
|
|
65
|
+
skip_db_update=self._skip_db_update,
|
|
66
|
+
workers=self._workers,
|
|
67
|
+
guard=self._guard,
|
|
68
|
+
evidence=self._evidence,
|
|
69
|
+
raw_dir=self._evidence_raw_dir(),
|
|
70
|
+
cache_dirs=self._cache_pool.slot_paths,
|
|
71
|
+
)
|
|
72
|
+
return self._batch
|
|
73
|
+
|
|
74
|
+
def _evidence_raw_dir(self) -> Path | None:
|
|
75
|
+
"""Onde a engine deposita o JSON cru para este lado redigir.
|
|
76
|
+
|
|
77
|
+
`None` quando a evidência está desligada: sem ninguém para ler e
|
|
78
|
+
arquivar, gravar o documento **não redigido** em disco seria deixar
|
|
79
|
+
para trás exatamente o que a redação existe para não deixar.
|
|
80
|
+
"""
|
|
81
|
+
if self._evidence is None:
|
|
82
|
+
return None
|
|
83
|
+
return self._cache_pool.base_dir / "engine-raw"
|
|
84
|
+
|
|
85
|
+
async def is_available(self) -> bool:
|
|
86
|
+
return shutil.which("trivy") is not None
|
|
87
|
+
|
|
88
|
+
async def version(self) -> str:
|
|
89
|
+
"""The scanner's own version string, memoised for the run.
|
|
90
|
+
|
|
91
|
+
Recorded so an analysis can be reconstructed: a score produced by
|
|
92
|
+
trivy 0.48 and one produced by 0.58 are different measurements of
|
|
93
|
+
the same image, and until this existed nothing wrote down which one
|
|
94
|
+
happened. It is also what keeps a cached result from being served
|
|
95
|
+
across a scanner upgrade.
|
|
96
|
+
|
|
97
|
+
Returns "" when the binary is missing or does not answer -- an
|
|
98
|
+
unknown version, which the cache key treats as its own value rather
|
|
99
|
+
than as "same as before".
|
|
100
|
+
"""
|
|
101
|
+
if self._version is not None:
|
|
102
|
+
return self._version
|
|
103
|
+
self._version = await self._read_version()
|
|
104
|
+
return self._version
|
|
105
|
+
|
|
106
|
+
async def _read_version(self) -> str:
|
|
107
|
+
try:
|
|
108
|
+
returncode, stdout, _ = await run_capture(
|
|
109
|
+
[resolve_executable("trivy"), "--version"], timeout=VERSION_TIMEOUT_SECONDS
|
|
110
|
+
)
|
|
111
|
+
except (TimeoutError, OSError, ExecutableNotFoundError) as e:
|
|
112
|
+
logger.debug(f"Could not read trivy version: {e}")
|
|
113
|
+
return ""
|
|
114
|
+
if returncode != 0 or not stdout:
|
|
115
|
+
return ""
|
|
116
|
+
# `<tool> --version` prints a banner; the first line carries the
|
|
117
|
+
# version and the rest is database metadata that changes on its own
|
|
118
|
+
# schedule and would churn the cache key for no reason.
|
|
119
|
+
return stdout.decode(errors="replace").strip().splitlines()[0].strip()
|
|
120
|
+
|
|
121
|
+
def _cache_args(self, cache_dir: Path) -> list[str]:
|
|
122
|
+
return ["--cache-dir", str(cache_dir)]
|
|
123
|
+
|
|
124
|
+
async def generate_sbom(self, image_reference: str, fmt: str = "cyclonedx") -> str | None:
|
|
125
|
+
"""Generate an SBOM for `image_reference` using Trivy's built-in
|
|
126
|
+
generators. `fmt` is one of "cyclonedx" or "spdx-json"."""
|
|
127
|
+
if fmt not in ("cyclonedx", "spdx-json"):
|
|
128
|
+
raise ValueError(f"Unsupported SBOM format: {fmt}")
|
|
129
|
+
|
|
130
|
+
safe_ref = sanitize_image_name(image_reference)
|
|
131
|
+
blocked = blocked_target_reason(safe_ref, self._guard)
|
|
132
|
+
if blocked:
|
|
133
|
+
logger.warning(f"Refusing to generate an SBOM for {safe_ref}: {blocked}")
|
|
134
|
+
return None
|
|
135
|
+
|
|
136
|
+
async with self._cache_pool.acquire() as cache_dir:
|
|
137
|
+
try:
|
|
138
|
+
cmd = [
|
|
139
|
+
resolve_executable("trivy"),
|
|
140
|
+
"image",
|
|
141
|
+
"--format",
|
|
142
|
+
fmt,
|
|
143
|
+
"--quiet",
|
|
144
|
+
*self._cache_args(cache_dir),
|
|
145
|
+
]
|
|
146
|
+
if self._skip_db_update:
|
|
147
|
+
cmd.append("--skip-db-update")
|
|
148
|
+
cmd.append(safe_ref)
|
|
149
|
+
|
|
150
|
+
returncode, stdout, stderr = await run_capture(cmd, timeout=self._timeout)
|
|
151
|
+
if returncode != 0 or not stdout:
|
|
152
|
+
logger.error(f"SBOM generation failed for {safe_ref}: {stderr.decode()[:300]}")
|
|
153
|
+
return None
|
|
154
|
+
return stdout.decode()
|
|
155
|
+
except (TimeoutError, OSError, ExecutableNotFoundError) as e:
|
|
156
|
+
logger.error(f"SBOM generation failed for {safe_ref}: {e}")
|
|
157
|
+
return None
|
|
158
|
+
|
|
159
|
+
#: Tentativas do download da DB antes de desistir. A baixa vem do GHCR e
|
|
160
|
+
#: falha de forma transitória com muito mais frequência que um scan: rate
|
|
161
|
+
#: limit, corte de conexão no meio de centenas de MB, 5xx do registry.
|
|
162
|
+
DB_DOWNLOAD_ATTEMPTS = 3
|
|
163
|
+
DB_BACKOFF_SECONDS = 2.0
|
|
164
|
+
|
|
165
|
+
async def refresh_db(self) -> bool:
|
|
166
|
+
"""Download the vulnerability DB once, up front, then build the
|
|
167
|
+
per-worker cache dir pool.
|
|
168
|
+
|
|
169
|
+
Doing the download here (rather than letting the first scan trigger
|
|
170
|
+
it) is what makes `--skip-db-update` safe for every subsequent scan,
|
|
171
|
+
and it removes the single biggest source of cache lock contention.
|
|
172
|
+
|
|
173
|
+
Retorna False quando a DB não ficou pronta -- e isso **importa**: sem
|
|
174
|
+
ela, `_skip_db_update` continua False e cada worker sai baixando a
|
|
175
|
+
própria cópia em paralelo, que é precisamente a corrida que produz
|
|
176
|
+
`init error: DB error` em série. Quem chama precisa tratar o False.
|
|
177
|
+
"""
|
|
178
|
+
base = self._cache_pool.base_dir
|
|
179
|
+
for attempt in range(1, self.DB_DOWNLOAD_ATTEMPTS + 1):
|
|
180
|
+
ok, detail = await self._download_db(base)
|
|
181
|
+
if ok:
|
|
182
|
+
break
|
|
183
|
+
if attempt == self.DB_DOWNLOAD_ATTEMPTS:
|
|
184
|
+
logger.warning(
|
|
185
|
+
f"Trivy DB refresh failed after {attempt} attempts: {detail}. "
|
|
186
|
+
"Scans will be unable to skip the DB update."
|
|
187
|
+
)
|
|
188
|
+
return False
|
|
189
|
+
wait = self.DB_BACKOFF_SECONDS * (2 ** (attempt - 1))
|
|
190
|
+
logger.warning(
|
|
191
|
+
f"Trivy DB refresh attempt {attempt}/{self.DB_DOWNLOAD_ATTEMPTS} "
|
|
192
|
+
f"failed ({detail}); retrying in {wait:.0f}s"
|
|
193
|
+
)
|
|
194
|
+
await asyncio.sleep(wait)
|
|
195
|
+
|
|
196
|
+
self._skip_db_update = True
|
|
197
|
+
isolated = await self._cache_pool.prepare()
|
|
198
|
+
logger.info(
|
|
199
|
+
f"Trivy DB ready at {base}; "
|
|
200
|
+
f"cache isolation {'enabled' if isolated else 'unavailable (scans serialized)'}"
|
|
201
|
+
)
|
|
202
|
+
return True
|
|
203
|
+
|
|
204
|
+
async def _download_db(self, base: Path) -> tuple[bool, str]:
|
|
205
|
+
"""One `--download-db-only` attempt. Returns (ok, detail)."""
|
|
206
|
+
try:
|
|
207
|
+
returncode, _, stderr = await run_capture(
|
|
208
|
+
[
|
|
209
|
+
resolve_executable("trivy"),
|
|
210
|
+
"image",
|
|
211
|
+
"--download-db-only",
|
|
212
|
+
"--quiet",
|
|
213
|
+
*self._cache_args(base),
|
|
214
|
+
],
|
|
215
|
+
timeout=self._timeout,
|
|
216
|
+
)
|
|
217
|
+
except ExecutableNotFoundError as e:
|
|
218
|
+
# Binário ausente não melhora com repetição.
|
|
219
|
+
return False, str(e)
|
|
220
|
+
except (TimeoutError, OSError) as e:
|
|
221
|
+
return False, str(e)
|
|
222
|
+
if returncode != 0:
|
|
223
|
+
return False, stderr.decode(errors="replace")[:200]
|
|
224
|
+
return True, ""
|
|
225
|
+
|
|
226
|
+
async def close(self) -> None:
|
|
227
|
+
await self._cache_pool.cleanup()
|
|
228
|
+
|
|
229
|
+
async def scan(self, image_reference: str) -> ScanResult:
|
|
230
|
+
safe_ref = sanitize_image_name(image_reference)
|
|
231
|
+
blocked = blocked_target_reason(safe_ref, self._guard)
|
|
232
|
+
if blocked:
|
|
233
|
+
return blocked_scan_result(safe_ref, "trivy", blocked)
|
|
234
|
+
logger.info(f"Scanning {safe_ref} with Trivy")
|
|
235
|
+
timestamp = datetime.now(tz=UTC).isoformat()
|
|
236
|
+
|
|
237
|
+
async with self._cache_pool.acquire() as cache_dir:
|
|
238
|
+
try:
|
|
239
|
+
cmd = [
|
|
240
|
+
resolve_executable("trivy"),
|
|
241
|
+
"image",
|
|
242
|
+
"--format",
|
|
243
|
+
"json",
|
|
244
|
+
"--severity",
|
|
245
|
+
"CRITICAL,HIGH,MEDIUM,LOW",
|
|
246
|
+
"--quiet",
|
|
247
|
+
*self._cache_args(cache_dir),
|
|
248
|
+
]
|
|
249
|
+
if self._skip_db_update:
|
|
250
|
+
# A DB de Java é baixada separadamente da principal. Sem
|
|
251
|
+
# este par, o `--download-db-only` do warm-up cobria só
|
|
252
|
+
# metade: cada worker ainda saía para a rede buscar a
|
|
253
|
+
# java-db, que é a corrida que o pool de cache existe
|
|
254
|
+
# para eliminar.
|
|
255
|
+
cmd.extend(["--skip-db-update", "--skip-java-db-update"])
|
|
256
|
+
cmd.append(safe_ref)
|
|
257
|
+
|
|
258
|
+
returncode, stdout, stderr = await run_capture(cmd, timeout=self._timeout)
|
|
259
|
+
|
|
260
|
+
if returncode != 0:
|
|
261
|
+
# Trivy writes its own diagnostics to stderr; they are
|
|
262
|
+
# captured into the log file and folded into the run
|
|
263
|
+
# summary rather than dumped raw onto the terminal.
|
|
264
|
+
err = stderr.decode(errors="replace")[:500]
|
|
265
|
+
logger.error(f"Trivy returned code {returncode} for {safe_ref}: {err}")
|
|
266
|
+
return ScanResult(
|
|
267
|
+
image_reference=safe_ref,
|
|
268
|
+
scanner="trivy",
|
|
269
|
+
scan_timestamp=timestamp,
|
|
270
|
+
status=ScanStatus.ERROR,
|
|
271
|
+
error_message=err,
|
|
272
|
+
error_kind=classify_scanner_error(err),
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
if not stdout:
|
|
276
|
+
return ScanResult(
|
|
277
|
+
image_reference=safe_ref,
|
|
278
|
+
scanner="trivy",
|
|
279
|
+
scan_timestamp=timestamp,
|
|
280
|
+
status=ScanStatus.ERROR,
|
|
281
|
+
error_message="Trivy produced no output",
|
|
282
|
+
error_kind=ScanErrorKind.INVALID_OUTPUT,
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
raw = stdout.decode()
|
|
286
|
+
data = json.loads(raw)
|
|
287
|
+
result = self._parse_results(safe_ref, data)
|
|
288
|
+
if self._evidence is not None:
|
|
289
|
+
result.evidence_path = await self._evidence.record_scan(safe_ref, "trivy", raw)
|
|
290
|
+
return result
|
|
291
|
+
|
|
292
|
+
except TimeoutError:
|
|
293
|
+
logger.error(f"Trivy scan timed out for {safe_ref}")
|
|
294
|
+
return ScanResult(
|
|
295
|
+
image_reference=safe_ref,
|
|
296
|
+
scanner="trivy",
|
|
297
|
+
scan_timestamp=timestamp,
|
|
298
|
+
status=ScanStatus.TIMEOUT,
|
|
299
|
+
error_message=f"Scan exceeded {self._timeout}s timeout",
|
|
300
|
+
error_kind=ScanErrorKind.TIMEOUT,
|
|
301
|
+
)
|
|
302
|
+
except OutputTooLargeError as e:
|
|
303
|
+
# Unbounded output is not a measurement: the document was
|
|
304
|
+
# never read in full, so there is nothing to parse and
|
|
305
|
+
# nothing to conclude.
|
|
306
|
+
logger.error(f"{'trivy'} output exceeded the size limit for {safe_ref}: {e}")
|
|
307
|
+
return ScanResult(
|
|
308
|
+
image_reference=safe_ref,
|
|
309
|
+
scanner="trivy",
|
|
310
|
+
scan_timestamp=timestamp,
|
|
311
|
+
status=ScanStatus.ERROR,
|
|
312
|
+
error_message=str(e),
|
|
313
|
+
error_kind=ScanErrorKind.INVALID_OUTPUT,
|
|
314
|
+
)
|
|
315
|
+
except json.JSONDecodeError as e:
|
|
316
|
+
logger.error(f"Trivy produced unparseable JSON for {safe_ref}: {e}")
|
|
317
|
+
return ScanResult(
|
|
318
|
+
image_reference=safe_ref,
|
|
319
|
+
scanner="trivy",
|
|
320
|
+
scan_timestamp=timestamp,
|
|
321
|
+
status=ScanStatus.ERROR,
|
|
322
|
+
error_message=str(e),
|
|
323
|
+
error_kind=ScanErrorKind.INVALID_OUTPUT,
|
|
324
|
+
)
|
|
325
|
+
except ExecutableNotFoundError as e:
|
|
326
|
+
logger.error(f"Trivy scan failed for {safe_ref}: {e}")
|
|
327
|
+
return ScanResult(
|
|
328
|
+
image_reference=safe_ref,
|
|
329
|
+
scanner="trivy",
|
|
330
|
+
scan_timestamp=timestamp,
|
|
331
|
+
status=ScanStatus.ERROR,
|
|
332
|
+
error_message=str(e),
|
|
333
|
+
error_kind=ScanErrorKind.SCANNER_MISSING,
|
|
334
|
+
)
|
|
335
|
+
except OSError as e:
|
|
336
|
+
logger.error(f"Trivy scan failed for {safe_ref}: {e}")
|
|
337
|
+
return ScanResult(
|
|
338
|
+
image_reference=safe_ref,
|
|
339
|
+
scanner="trivy",
|
|
340
|
+
scan_timestamp=timestamp,
|
|
341
|
+
status=ScanStatus.ERROR,
|
|
342
|
+
error_message=str(e),
|
|
343
|
+
error_kind=classify_scanner_error(str(e)),
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
def _parse_results(self, image_ref: str, data: dict[str, Any]) -> ScanResult:
|
|
347
|
+
vulns: list[Vulnerability] = []
|
|
348
|
+
for result in data.get("Results", []):
|
|
349
|
+
# `Type` distingue pacote de SO ("alpine", "debian") de pacote de
|
|
350
|
+
# linguagem ("node-pkg", "python-pkg"); `Target` diz onde ele mora.
|
|
351
|
+
pkg_type = str(result.get("Class") or "") or str(result.get("Type") or "")
|
|
352
|
+
target = str(result.get("Target") or "")
|
|
353
|
+
for v in result.get("Vulnerabilities", []):
|
|
354
|
+
sev_str = v.get("Severity", "UNKNOWN").upper()
|
|
355
|
+
try:
|
|
356
|
+
severity = Severity(sev_str)
|
|
357
|
+
except ValueError:
|
|
358
|
+
severity = Severity.UNKNOWN
|
|
359
|
+
|
|
360
|
+
score, source = self._extract_cvss(v)
|
|
361
|
+
vulns.append(
|
|
362
|
+
Vulnerability(
|
|
363
|
+
cve_id=v.get("VulnerabilityID", ""),
|
|
364
|
+
severity=severity,
|
|
365
|
+
cvss_score=score,
|
|
366
|
+
cvss_source=source,
|
|
367
|
+
package_name=v.get("PkgName", ""),
|
|
368
|
+
installed_version=v.get("InstalledVersion", ""),
|
|
369
|
+
fixed_version=v.get("FixedVersion", ""),
|
|
370
|
+
description=v.get("Title", "")[:200],
|
|
371
|
+
published_date=v.get("PublishedDate", ""),
|
|
372
|
+
package_type=pkg_type,
|
|
373
|
+
target=target,
|
|
374
|
+
)
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
family, version = self._parse_os(data)
|
|
378
|
+
return ScanResult(
|
|
379
|
+
image_reference=image_ref,
|
|
380
|
+
scanner="trivy",
|
|
381
|
+
vulnerabilities=vulns,
|
|
382
|
+
scan_timestamp=datetime.now(tz=UTC).isoformat(),
|
|
383
|
+
os_family=family,
|
|
384
|
+
os_version=version,
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
@staticmethod
|
|
388
|
+
def _parse_os(data: dict[str, Any]) -> tuple[str, str]:
|
|
389
|
+
"""The base distribution Trivy identified, from `Metadata.OS`.
|
|
390
|
+
|
|
391
|
+
Reported by the scanner rather than guessed from the tag: a tag
|
|
392
|
+
called `-alpine` is a naming convention, while this is what the
|
|
393
|
+
package database inside the image actually is.
|
|
394
|
+
"""
|
|
395
|
+
metadata = data.get("Metadata")
|
|
396
|
+
os_block = metadata.get("OS") if isinstance(metadata, dict) else None
|
|
397
|
+
if not isinstance(os_block, dict):
|
|
398
|
+
return "", ""
|
|
399
|
+
return str(os_block.get("Family") or ""), str(os_block.get("Name") or "")
|
|
400
|
+
|
|
401
|
+
# Ordem de desempate quando a base que definiu a severidade não publica
|
|
402
|
+
# CVSS (Debian, Alpine e Ubuntu classificam sem pontuar). NVD primeiro por
|
|
403
|
+
# ser a canônica; o resto é determinístico só para que o mesmo achado
|
|
404
|
+
# produza sempre o mesmo número.
|
|
405
|
+
_CVSS_SOURCE_PRIORITY = ("nvd", "redhat", "ghsa", "amazon", "photon", "oracle-oval")
|
|
406
|
+
|
|
407
|
+
def _extract_cvss(self, vuln_data: dict[str, Any]) -> tuple[float, str]:
|
|
408
|
+
"""Return (score, source), preferring the base that set the severity.
|
|
409
|
+
|
|
410
|
+
O Trivy define `Severity` pela fonte em `SeveritySource` -- em geral o
|
|
411
|
+
vendor da distro -- enquanto o bloco `CVSS` traz o score de várias
|
|
412
|
+
bases ao mesmo tempo. Pegar a severidade de uma e o número de outra
|
|
413
|
+
produzia linhas como `CRITICAL ... 7.5`, que pelo CVSS v3 é uma
|
|
414
|
+
contradição (CRITICAL começa em 9.0). Não era erro de conta: eram duas
|
|
415
|
+
bases diferentes exibidas como se fossem uma. Casar as duas pontas --
|
|
416
|
+
e dizer qual base respondeu -- é o que torna o número conferível.
|
|
417
|
+
"""
|
|
418
|
+
cvss = vuln_data.get("CVSS")
|
|
419
|
+
if not isinstance(cvss, dict) or not cvss:
|
|
420
|
+
return 0.0, ""
|
|
421
|
+
|
|
422
|
+
severity_source = str(vuln_data.get("SeveritySource") or "").strip().lower()
|
|
423
|
+
candidates: list[str] = []
|
|
424
|
+
if severity_source:
|
|
425
|
+
candidates.append(severity_source)
|
|
426
|
+
candidates.extend(s for s in self._CVSS_SOURCE_PRIORITY if s != severity_source)
|
|
427
|
+
candidates.extend(k for k in cvss if k not in candidates)
|
|
428
|
+
|
|
429
|
+
for source in candidates:
|
|
430
|
+
score = self._score_from_entry(cvss.get(source))
|
|
431
|
+
if score is not None:
|
|
432
|
+
return score, source
|
|
433
|
+
return 0.0, ""
|
|
434
|
+
|
|
435
|
+
@staticmethod
|
|
436
|
+
def _score_from_entry(entry: dict[str, Any] | None) -> float | None:
|
|
437
|
+
if not isinstance(entry, dict):
|
|
438
|
+
return None
|
|
439
|
+
for key in ("V4Score", "V3Score"):
|
|
440
|
+
value = entry.get(key)
|
|
441
|
+
if value is None:
|
|
442
|
+
continue
|
|
443
|
+
try:
|
|
444
|
+
return float(value)
|
|
445
|
+
except (TypeError, ValueError):
|
|
446
|
+
continue
|
|
447
|
+
return None
|
|
File without changes
|
dockerls/utils/auth.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
from typing import TYPE_CHECKING, TypeVar
|
|
7
|
+
|
|
8
|
+
from loguru import logger
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from collections.abc import Callable, Iterator
|
|
12
|
+
|
|
13
|
+
# A keyring backend is arbitrary third-party code loaded at call time. It can
|
|
14
|
+
# fail in ways that are *not* `Exception`: the `cryptography` backend used by
|
|
15
|
+
# SecretService is a Rust extension, and a broken install raises
|
|
16
|
+
# `pyo3_runtime.PanicException`, which derives straight from `BaseException`.
|
|
17
|
+
# Catching only `Exception` there let a broken keyring take down every command
|
|
18
|
+
# that merely tries to *read* optional credentials. Credentials are optional
|
|
19
|
+
# enrichment, never a reason to abort, so the guard is widened -- while still
|
|
20
|
+
# letting the two BaseExceptions that must always propagate through.
|
|
21
|
+
_MUST_PROPAGATE = (KeyboardInterrupt, SystemExit, MemoryError)
|
|
22
|
+
|
|
23
|
+
T = TypeVar("T")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@contextlib.contextmanager
|
|
27
|
+
def _quiet_stderr() -> Iterator[None]:
|
|
28
|
+
"""Silence writes to file descriptor 2 for the duration of the block.
|
|
29
|
+
|
|
30
|
+
Catching the failure is not enough to keep the terminal clean. Keyring's
|
|
31
|
+
backend discovery writes its diagnostics *below* Python: the missing
|
|
32
|
+
`_cffi_backend` import and pyo3's `thread '<unnamed>' panicked at ...`
|
|
33
|
+
banner are emitted straight to fd 2 by the C and Rust runtimes before
|
|
34
|
+
any Python exception exists to be caught. On a machine with no keyring
|
|
35
|
+
backend -- a container or a CI runner, which is where this tool mostly
|
|
36
|
+
runs -- that noise printed ahead of the results of *every* command and
|
|
37
|
+
read like a crash.
|
|
38
|
+
|
|
39
|
+
Only the raw fd is redirected, and only around the keyring call. The
|
|
40
|
+
reason for the failure is not lost: it is logged (through loguru's own
|
|
41
|
+
sinks) after the fd is restored.
|
|
42
|
+
|
|
43
|
+
Degrades to a no-op if stderr has no real file descriptor, which is the
|
|
44
|
+
case under pytest's capture and in some embedded runners.
|
|
45
|
+
"""
|
|
46
|
+
try:
|
|
47
|
+
fd = sys.stderr.fileno()
|
|
48
|
+
except (AttributeError, OSError, ValueError):
|
|
49
|
+
yield
|
|
50
|
+
return
|
|
51
|
+
|
|
52
|
+
sys.stderr.flush()
|
|
53
|
+
saved = os.dup(fd)
|
|
54
|
+
devnull = os.open(os.devnull, os.O_WRONLY)
|
|
55
|
+
try:
|
|
56
|
+
os.dup2(devnull, fd)
|
|
57
|
+
yield
|
|
58
|
+
finally:
|
|
59
|
+
sys.stderr.flush()
|
|
60
|
+
os.dup2(saved, fd)
|
|
61
|
+
os.close(saved)
|
|
62
|
+
os.close(devnull)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _guarded(operation: str, action: Callable[[], T], default: T, *, level: str = "DEBUG") -> T:
|
|
66
|
+
"""Run a keyring `action`, returning `default` if the backend misbehaves."""
|
|
67
|
+
try:
|
|
68
|
+
with _quiet_stderr():
|
|
69
|
+
return action()
|
|
70
|
+
except _MUST_PROPAGATE:
|
|
71
|
+
raise
|
|
72
|
+
except BaseException as e:
|
|
73
|
+
logger.log(level, f"{operation}: {type(e).__name__}: {e}")
|
|
74
|
+
return default
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def store_credentials(username: str, token: str) -> bool:
|
|
78
|
+
def action() -> bool:
|
|
79
|
+
import keyring
|
|
80
|
+
|
|
81
|
+
keyring.set_password("dockerls", "username", username)
|
|
82
|
+
keyring.set_password("dockerls", "token", token)
|
|
83
|
+
return True
|
|
84
|
+
|
|
85
|
+
# A failed *store* is worth a warning: the user asked for it explicitly.
|
|
86
|
+
return _guarded("Keyring storage failed", action, False, level="WARNING")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def load_credentials() -> tuple[str, str]:
|
|
90
|
+
def action() -> tuple[str, str]:
|
|
91
|
+
import keyring
|
|
92
|
+
|
|
93
|
+
username = keyring.get_password("dockerls", "username") or ""
|
|
94
|
+
token = keyring.get_password("dockerls", "token") or ""
|
|
95
|
+
return username, token
|
|
96
|
+
|
|
97
|
+
return _guarded("Keyring unavailable, continuing anonymously", action, ("", ""))
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def clear_credentials() -> bool:
|
|
101
|
+
def action() -> bool:
|
|
102
|
+
import keyring
|
|
103
|
+
|
|
104
|
+
keyring.delete_password("dockerls", "username")
|
|
105
|
+
keyring.delete_password("dockerls", "token")
|
|
106
|
+
return True
|
|
107
|
+
|
|
108
|
+
return _guarded("Keyring deletion failed", action, False)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Absolute-path resolution for the external tools DockerLs shells out to.
|
|
2
|
+
|
|
3
|
+
Invoking `docker`, `trivy` or `grype` by bare name hands the choice of binary
|
|
4
|
+
to `$PATH`, so any writable directory earlier in the search order decides what
|
|
5
|
+
runs. That is PATH hijacking -- the same class of finding this tool reports on
|
|
6
|
+
other people's images -- and a security scanner is a particularly good thing to
|
|
7
|
+
hijack, since its verdict is what a pipeline trusts.
|
|
8
|
+
|
|
9
|
+
Every subprocess call resolves the name here first and invokes the absolute
|
|
10
|
+
path that comes back, or fails with a message naming the tool that is missing.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import shutil
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ExecutableNotFoundError(RuntimeError):
|
|
19
|
+
"""A required external tool is not installed, or not on `$PATH`."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, name: str) -> None:
|
|
22
|
+
super().__init__(
|
|
23
|
+
f"'{name}' was not found on PATH. Install it (see `dockerls doctor`) "
|
|
24
|
+
f"and make sure it is on the PATH of the process running dockerls."
|
|
25
|
+
)
|
|
26
|
+
self.name = name
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def resolve_executable(name: str) -> str:
|
|
30
|
+
"""Return the absolute path of `name`, or raise `ExecutableNotFoundError`.
|
|
31
|
+
|
|
32
|
+
The absolute path is what gets executed: resolving and then invoking the
|
|
33
|
+
bare name again would reintroduce the lookup this function exists to pin
|
|
34
|
+
down.
|
|
35
|
+
"""
|
|
36
|
+
path = shutil.which(name)
|
|
37
|
+
if path is None:
|
|
38
|
+
raise ExecutableNotFoundError(name)
|
|
39
|
+
return path
|