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,216 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from datetime import UTC, datetime
|
|
5
|
+
from typing import Any, cast
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
from loguru import logger
|
|
9
|
+
|
|
10
|
+
from dockerls.domain.interfaces.eol_checker import EOLCheckerInterface
|
|
11
|
+
from dockerls.domain.value_objects.tristate import Tristate
|
|
12
|
+
from dockerls.utils.retry import (
|
|
13
|
+
DEFAULT_BACKOFF_BASE,
|
|
14
|
+
DEFAULT_MAX_ATTEMPTS,
|
|
15
|
+
retry_policy,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
# Docker Hub image name -> endoflife.date product slug. Docker Hub names and
|
|
19
|
+
# endoflife.date slugs frequently diverge (e.g. "node" vs "nodejs").
|
|
20
|
+
DOCKER_TO_ENDOFLIFE: dict[str, str] = {
|
|
21
|
+
"node": "nodejs",
|
|
22
|
+
"python": "python",
|
|
23
|
+
"postgres": "postgresql",
|
|
24
|
+
"postgresql": "postgresql",
|
|
25
|
+
"mysql": "mysql",
|
|
26
|
+
"mariadb": "mariadb",
|
|
27
|
+
"redis": "redis",
|
|
28
|
+
"nginx": "nginx",
|
|
29
|
+
"httpd": "apache",
|
|
30
|
+
"php": "php",
|
|
31
|
+
"ruby": "ruby",
|
|
32
|
+
"golang": "go",
|
|
33
|
+
"go": "go",
|
|
34
|
+
"django": "django",
|
|
35
|
+
"rails": "rails",
|
|
36
|
+
"ubuntu": "ubuntu",
|
|
37
|
+
"debian": "debian",
|
|
38
|
+
"alpine": "alpine",
|
|
39
|
+
"elasticsearch": "elasticsearch",
|
|
40
|
+
"rabbitmq": "rabbitmq",
|
|
41
|
+
"mongo": "mongodb",
|
|
42
|
+
"mongodb": "mongodb",
|
|
43
|
+
"dotnet": "dotnet",
|
|
44
|
+
"grafana": "grafana",
|
|
45
|
+
"prometheus": "prometheus",
|
|
46
|
+
"kafka": "kafka",
|
|
47
|
+
"cassandra": "cassandra",
|
|
48
|
+
"erlang": "erlang",
|
|
49
|
+
"haproxy": "haproxy",
|
|
50
|
+
"jenkins": "jenkins",
|
|
51
|
+
"traefik": "traefik",
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _version_parts(version: str) -> tuple[int, ...]:
|
|
56
|
+
"""Parse the leading numeric dot-separated segments of a version string."""
|
|
57
|
+
parts: list[int] = []
|
|
58
|
+
for segment in version.split("."):
|
|
59
|
+
if segment.isdigit():
|
|
60
|
+
parts.append(int(segment))
|
|
61
|
+
else:
|
|
62
|
+
break
|
|
63
|
+
return tuple(parts)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _cycle_matches(version: str, cycle: str) -> bool:
|
|
67
|
+
"""True if `version` falls under endoflife.date `cycle`, using SemVer-aware
|
|
68
|
+
segment comparison (not naive substring prefix matching)."""
|
|
69
|
+
vparts = _version_parts(version)
|
|
70
|
+
cparts = _version_parts(cycle)
|
|
71
|
+
if not vparts or not cparts:
|
|
72
|
+
return version == cycle
|
|
73
|
+
if len(cparts) > len(vparts):
|
|
74
|
+
return False
|
|
75
|
+
return vparts[: len(cparts)] == cparts
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class EndOfLifeChecker(EOLCheckerInterface):
|
|
79
|
+
BASE_URL = "https://endoflife.date/api"
|
|
80
|
+
|
|
81
|
+
def __init__(
|
|
82
|
+
self,
|
|
83
|
+
timeout: int = 15,
|
|
84
|
+
max_attempts: int = DEFAULT_MAX_ATTEMPTS,
|
|
85
|
+
backoff_base: float = DEFAULT_BACKOFF_BASE,
|
|
86
|
+
):
|
|
87
|
+
self._timeout = timeout
|
|
88
|
+
self._max_attempts = max_attempts
|
|
89
|
+
self._backoff_base = backoff_base
|
|
90
|
+
self._cache: dict[str, list[dict[str, Any]]] = {}
|
|
91
|
+
# One lock per product slug. The memo below only closes the window
|
|
92
|
+
# *after* a response lands, and `recommend` asks `is_eol` + `is_lts`
|
|
93
|
+
# for ~100 tags concurrently -- so a single-product run fired up to
|
|
94
|
+
# 200 identical requests before the first one returned. That is the
|
|
95
|
+
# rate limiting (and the resulting inconsistent EOL verdicts within
|
|
96
|
+
# one run) that the memo was added to stop.
|
|
97
|
+
self._locks: dict[str, asyncio.Lock] = {}
|
|
98
|
+
|
|
99
|
+
def _resolve_product(self, product: str) -> str:
|
|
100
|
+
return DOCKER_TO_ENDOFLIFE.get(product.lower(), product.lower())
|
|
101
|
+
|
|
102
|
+
async def _fetch_product(self, product: str) -> list[dict[str, Any]]:
|
|
103
|
+
slug = self._resolve_product(product)
|
|
104
|
+
cached = self._cache.get(slug)
|
|
105
|
+
if cached is not None:
|
|
106
|
+
return cached
|
|
107
|
+
|
|
108
|
+
lock = self._locks.setdefault(slug, asyncio.Lock())
|
|
109
|
+
async with lock:
|
|
110
|
+
cached = self._cache.get(slug)
|
|
111
|
+
if cached is not None:
|
|
112
|
+
return cached
|
|
113
|
+
policy = retry_policy(self._max_attempts, self._backoff_base)
|
|
114
|
+
result: list[dict[str, Any]] = await policy(self._fetch_product_once, product)
|
|
115
|
+
return result
|
|
116
|
+
|
|
117
|
+
async def _fetch_product_once(self, product: str) -> list[dict[str, Any]]:
|
|
118
|
+
slug = self._resolve_product(product)
|
|
119
|
+
if slug in self._cache:
|
|
120
|
+
return self._cache[slug]
|
|
121
|
+
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
|
122
|
+
try:
|
|
123
|
+
resp = await client.get(f"{self.BASE_URL}/{slug}.json")
|
|
124
|
+
if resp.status_code == 200:
|
|
125
|
+
data = cast("list[dict[str, Any]]", resp.json())
|
|
126
|
+
self._cache[slug] = data
|
|
127
|
+
return data
|
|
128
|
+
if resp.status_code == 404:
|
|
129
|
+
# O produto não existe no catálogo -- uma resposta
|
|
130
|
+
# definitiva, e cacheável. Sem isso, cada uma das ~100
|
|
131
|
+
# tags de uma execução repetia a mesma consulta perdida
|
|
132
|
+
# (duas, contando is_eol e is_lts). Além do desperdício,
|
|
133
|
+
# o volume provocava rate limiting: parte das tags então
|
|
134
|
+
# recebia dados e parte recebia lista vazia, e a mesma
|
|
135
|
+
# execução emitia vereditos de EOL inconsistentes entre
|
|
136
|
+
# tags do mesmo produto.
|
|
137
|
+
self._cache[slug] = []
|
|
138
|
+
return []
|
|
139
|
+
logger.debug(f"EOL check for {slug} returned HTTP {resp.status_code}")
|
|
140
|
+
return []
|
|
141
|
+
except httpx.HTTPError as e:
|
|
142
|
+
logger.debug(f"EOL check failed for {slug}: {e}")
|
|
143
|
+
return []
|
|
144
|
+
|
|
145
|
+
def _find_cycle(self, cycles: list[dict[str, Any]], version: str) -> dict[str, Any] | None:
|
|
146
|
+
best: dict[str, Any] | None = None
|
|
147
|
+
best_len = -1
|
|
148
|
+
for cycle in cycles:
|
|
149
|
+
cycle_ver = str(cycle.get("cycle", ""))
|
|
150
|
+
if _cycle_matches(version, cycle_ver):
|
|
151
|
+
clen = len(_version_parts(cycle_ver))
|
|
152
|
+
if clen > best_len:
|
|
153
|
+
best = cycle
|
|
154
|
+
best_len = clen
|
|
155
|
+
return best
|
|
156
|
+
|
|
157
|
+
async def is_eol(self, product: str, version: str) -> bool:
|
|
158
|
+
"""Whether this release is past its end-of-life date.
|
|
159
|
+
|
|
160
|
+
Kept as the boolean the interface has always published. It answers
|
|
161
|
+
`False` for "supported" *and* for "nobody could tell", which is why
|
|
162
|
+
`eol_status` exists -- callers that can represent the difference
|
|
163
|
+
should ask that instead.
|
|
164
|
+
"""
|
|
165
|
+
return (await self.eol_status(product, version)).is_true
|
|
166
|
+
|
|
167
|
+
async def eol_status(self, product: str, version: str) -> Tristate:
|
|
168
|
+
"""Three-valued end-of-life: TRUE, FALSE, or UNKNOWN.
|
|
169
|
+
|
|
170
|
+
Every path that previously answered `False` had one of two very
|
|
171
|
+
different meanings behind it, and collapsing them meant an image
|
|
172
|
+
whose lifecycle nobody could look up was scored, and gated, exactly
|
|
173
|
+
like one confirmed to be inside its support window:
|
|
174
|
+
|
|
175
|
+
* no version could be extracted from the tag -- UNKNOWN;
|
|
176
|
+
* the product is not in the catalogue (404) -- UNKNOWN;
|
|
177
|
+
* the catalogue could not be reached -- UNKNOWN;
|
|
178
|
+
* no cycle matches this version -- UNKNOWN;
|
|
179
|
+
* the cycle exists and carries a date or a boolean -- TRUE/FALSE,
|
|
180
|
+
the only two answers actually supported by evidence.
|
|
181
|
+
"""
|
|
182
|
+
if not version:
|
|
183
|
+
return Tristate.UNKNOWN
|
|
184
|
+
cycles = await self._fetch_product(product)
|
|
185
|
+
if not cycles:
|
|
186
|
+
# Empty covers "unknown product" and "request failed" alike;
|
|
187
|
+
# neither is a statement that the release is supported.
|
|
188
|
+
return Tristate.UNKNOWN
|
|
189
|
+
cycle = self._find_cycle(cycles, version)
|
|
190
|
+
if cycle is None:
|
|
191
|
+
return Tristate.UNKNOWN
|
|
192
|
+
|
|
193
|
+
eol = cycle.get("eol")
|
|
194
|
+
if isinstance(eol, bool):
|
|
195
|
+
return Tristate.of(eol)
|
|
196
|
+
if isinstance(eol, str):
|
|
197
|
+
try:
|
|
198
|
+
eol_date = datetime.strptime(eol, "%Y-%m-%d").replace(tzinfo=UTC)
|
|
199
|
+
except ValueError:
|
|
200
|
+
# A date the catalogue publishes but we cannot parse is a
|
|
201
|
+
# gap in our reading of it, not a supported release.
|
|
202
|
+
logger.debug(f"Unparseable EOL date for {product} {version}: {eol!r}")
|
|
203
|
+
return Tristate.UNKNOWN
|
|
204
|
+
return Tristate.of(datetime.now(tz=UTC) > eol_date)
|
|
205
|
+
# `eol: false` arrives as a bool; anything else (null, missing) means
|
|
206
|
+
# the catalogue has not set a date yet.
|
|
207
|
+
return Tristate.UNKNOWN
|
|
208
|
+
|
|
209
|
+
async def is_lts(self, product: str, version: str) -> bool:
|
|
210
|
+
if not version:
|
|
211
|
+
return False
|
|
212
|
+
cycles = await self._fetch_product(product)
|
|
213
|
+
cycle = self._find_cycle(cycles, version)
|
|
214
|
+
if cycle is None:
|
|
215
|
+
return False
|
|
216
|
+
return bool(cycle.get("lts", False))
|
|
File without changes
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""Medir um lote com a engine Go, sem tirar nenhuma decisão do Python.
|
|
2
|
+
|
|
3
|
+
Este módulo é a fronteira, e ela é estreita de propósito. A engine recebe
|
|
4
|
+
referências **já sanitizadas e já aprovadas pela política de rede**, e
|
|
5
|
+
devolve resultados. Tudo que decide alguma coisa continua deste lado:
|
|
6
|
+
|
|
7
|
+
* `sanitize_image_name` e o `HostGuard` são aplicados aqui, antes de a
|
|
8
|
+
requisição existir. `trivy image X` e `grype X` abrem os próprios
|
|
9
|
+
sockets, e foi para cobrir esse buraco que `scan_target.py` existe -- um
|
|
10
|
+
lote que contornasse o guard abriria a mesma porta em nome de desempenho;
|
|
11
|
+
* `redact()` continua aqui. A engine guarda o JSON cru em arquivo `0600` e
|
|
12
|
+
devolve o caminho; quem decide o que vai para a evidência definitiva é o
|
|
13
|
+
`EvidenceStore`;
|
|
14
|
+
* nada de score, tier, EOL ou inteligência de ameaça atravessa.
|
|
15
|
+
|
|
16
|
+
Serve Trivy e Grype: o que difere entre os dois -- argv, forma do JSON,
|
|
17
|
+
diretório de cache versus variável de ambiente -- está inteiramente do lado
|
|
18
|
+
Go. Aqui os dois são o mesmo lote.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import asyncio
|
|
24
|
+
import contextlib
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import TYPE_CHECKING
|
|
28
|
+
|
|
29
|
+
from loguru import logger
|
|
30
|
+
|
|
31
|
+
from dockerls.integrations.engine.client import EngineClient, EngineTarget
|
|
32
|
+
from dockerls.integrations.engine.locator import find_engine, probe
|
|
33
|
+
from dockerls.integrations.scan_target import blocked_scan_result, blocked_target_reason
|
|
34
|
+
from dockerls.utils.executables import ExecutableNotFoundError, resolve_executable
|
|
35
|
+
from dockerls.utils.validation import sanitize_image_name
|
|
36
|
+
|
|
37
|
+
if TYPE_CHECKING:
|
|
38
|
+
from collections.abc import Awaitable, Callable, Mapping, Sequence
|
|
39
|
+
|
|
40
|
+
from dockerls.domain.entities.scan_result import ScanResult
|
|
41
|
+
from dockerls.infrastructure.evidence import EvidenceStore
|
|
42
|
+
from dockerls.infrastructure.network.host_guard import HostGuard
|
|
43
|
+
from dockerls.integrations.engine.client import EngineOutcome
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class BatchOutcome:
|
|
48
|
+
"""O que um lote medido pela engine produziu."""
|
|
49
|
+
|
|
50
|
+
#: Um resultado por alvo, na ordem em que foram pedidos.
|
|
51
|
+
results: list[ScanResult]
|
|
52
|
+
#: Scans que de fato aconteceram -- irmãos do mesmo digest não contam.
|
|
53
|
+
scans_performed: int
|
|
54
|
+
#: Alvos servidos pelo digest de um irmão.
|
|
55
|
+
duplicates_collapsed: int
|
|
56
|
+
#: Quanto o lote levou, medido pela engine.
|
|
57
|
+
wall_seconds: float
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class EngineBatchScanner:
|
|
61
|
+
"""Mede um lote inteiro com uma travessia de processo, não N.
|
|
62
|
+
|
|
63
|
+
O ganho é bom ser exato sobre qual é: Trivy e Grype já são Go, e cada
|
|
64
|
+
scan continua custando os mesmos 1,2-2,5s. O que muda é o entorno --
|
|
65
|
+
criar e colher N processos, revezar o diretório de cache, coordenar o
|
|
66
|
+
dedup por digest --, que sai de N travessias Python<->processo para uma.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
def __init__(
|
|
70
|
+
self,
|
|
71
|
+
*,
|
|
72
|
+
scanner: str,
|
|
73
|
+
timeout_seconds: float,
|
|
74
|
+
skip_db_update: bool,
|
|
75
|
+
workers: int,
|
|
76
|
+
guard: HostGuard | None = None,
|
|
77
|
+
evidence: EvidenceStore | None = None,
|
|
78
|
+
raw_dir: Path | None = None,
|
|
79
|
+
cache_dirs: Callable[[], Awaitable[Sequence[Path]]] | None = None,
|
|
80
|
+
env: Mapping[str, str] | None = None,
|
|
81
|
+
) -> None:
|
|
82
|
+
self._scanner = scanner
|
|
83
|
+
self._timeout_seconds = timeout_seconds
|
|
84
|
+
self._skip_db_update = skip_db_update
|
|
85
|
+
self._workers = workers
|
|
86
|
+
self._guard = guard
|
|
87
|
+
self._evidence = evidence
|
|
88
|
+
self._raw_dir = raw_dir
|
|
89
|
+
self._cache_dirs = cache_dirs
|
|
90
|
+
self._env = dict(env or {})
|
|
91
|
+
self._client: EngineClient | None = None
|
|
92
|
+
self._resolved = False
|
|
93
|
+
|
|
94
|
+
def _resolve(self) -> EngineClient | None:
|
|
95
|
+
"""Localiza o binário uma vez por processo.
|
|
96
|
+
|
|
97
|
+
`probe()` custa milissegundos e `find_engine()` toca o disco; fazer
|
|
98
|
+
isso por lote seria pagar a descoberta repetidamente por uma
|
|
99
|
+
resposta que não muda dentro de um run.
|
|
100
|
+
"""
|
|
101
|
+
if self._resolved:
|
|
102
|
+
return self._client
|
|
103
|
+
self._resolved = True
|
|
104
|
+
|
|
105
|
+
path = find_engine()
|
|
106
|
+
if not path or not probe(path):
|
|
107
|
+
return None
|
|
108
|
+
try:
|
|
109
|
+
scanner_path = resolve_executable(self._scanner)
|
|
110
|
+
except ExecutableNotFoundError:
|
|
111
|
+
# Sem o scanner não há o que a engine dirija, e o caminho Python
|
|
112
|
+
# dará a mesma resposta -- SCANNER_MISSING -- por si.
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
logger.info(f"Using the Go engine at {path} for {self._scanner} batch scans")
|
|
116
|
+
self._client = EngineClient(
|
|
117
|
+
engine_path=path,
|
|
118
|
+
scanner=self._scanner,
|
|
119
|
+
scanner_path=scanner_path,
|
|
120
|
+
timeout_seconds=self._timeout_seconds,
|
|
121
|
+
skip_db_update=self._skip_db_update,
|
|
122
|
+
raw_dir=self._raw_dir,
|
|
123
|
+
env=self._env,
|
|
124
|
+
)
|
|
125
|
+
return self._client
|
|
126
|
+
|
|
127
|
+
async def scan_batch(self, targets: Sequence[tuple[str, str]]) -> BatchOutcome | None:
|
|
128
|
+
"""Mede `(referência, chave_de_dedup)`, ou None para usar o Python."""
|
|
129
|
+
client = self._resolve()
|
|
130
|
+
if client is None:
|
|
131
|
+
return None
|
|
132
|
+
|
|
133
|
+
# A política primeiro, e do lado de cá. Um alvo recusado é um
|
|
134
|
+
# resultado ERROR/BLOCKED_BY_POLICY como sempre foi, e não entra na
|
|
135
|
+
# requisição: o binário Go nunca vê a referência que a política
|
|
136
|
+
# negou.
|
|
137
|
+
blocked: dict[int, ScanResult] = {}
|
|
138
|
+
allowed: list[tuple[int, EngineTarget]] = []
|
|
139
|
+
for index, (reference, dedup_key) in enumerate(targets):
|
|
140
|
+
safe_ref = sanitize_image_name(reference)
|
|
141
|
+
reason = blocked_target_reason(safe_ref, self._guard)
|
|
142
|
+
if reason:
|
|
143
|
+
blocked[index] = blocked_scan_result(safe_ref, self._scanner, reason)
|
|
144
|
+
continue
|
|
145
|
+
allowed.append((index, EngineTarget(reference=safe_ref, dedup_key=dedup_key)))
|
|
146
|
+
|
|
147
|
+
cache_dirs = list(await self._cache_dirs()) if self._cache_dirs is not None else []
|
|
148
|
+
outcome = await client.scan_batch(
|
|
149
|
+
[target for _, target in allowed],
|
|
150
|
+
workers=self._workers,
|
|
151
|
+
cache_dirs=cache_dirs,
|
|
152
|
+
)
|
|
153
|
+
if outcome is None:
|
|
154
|
+
return None
|
|
155
|
+
|
|
156
|
+
results: list[ScanResult] = [None] * len(targets) # type: ignore[list-item]
|
|
157
|
+
for position, (index, _) in enumerate(allowed):
|
|
158
|
+
results[index] = outcome.results[position]
|
|
159
|
+
for index, result in blocked.items():
|
|
160
|
+
results[index] = result
|
|
161
|
+
|
|
162
|
+
await self._archive_evidence(outcome)
|
|
163
|
+
return BatchOutcome(
|
|
164
|
+
results=results,
|
|
165
|
+
scans_performed=outcome.scans_performed,
|
|
166
|
+
duplicates_collapsed=outcome.duplicates_collapsed,
|
|
167
|
+
wall_seconds=outcome.wall_seconds,
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
async def _archive_evidence(self, outcome: EngineOutcome) -> None:
|
|
171
|
+
"""Lê o JSON cru que a engine guardou, redige e arquiva.
|
|
172
|
+
|
|
173
|
+
A redação continua aqui de propósito: `redact()` é um controle de
|
|
174
|
+
segurança, e uma segunda implementação dele em Go seria uma segunda
|
|
175
|
+
a divergir desta. A engine só guarda o arquivo; quem decide o que
|
|
176
|
+
vai para o disco definitivo é o EvidenceStore.
|
|
177
|
+
"""
|
|
178
|
+
evidence = self._evidence
|
|
179
|
+
if evidence is None:
|
|
180
|
+
return
|
|
181
|
+
for result, raw_path in zip(outcome.results, outcome.raw_paths, strict=True):
|
|
182
|
+
if not raw_path:
|
|
183
|
+
continue
|
|
184
|
+
try:
|
|
185
|
+
raw = await asyncio.to_thread(Path(raw_path).read_text, encoding="utf-8")
|
|
186
|
+
except OSError as e:
|
|
187
|
+
logger.warning(f"Could not read engine evidence at {raw_path}: {e}")
|
|
188
|
+
continue
|
|
189
|
+
finally:
|
|
190
|
+
# O temporário sai do disco em qualquer caminho: ele é o
|
|
191
|
+
# documento não redigido, e deixá-lo para trás desfaria a
|
|
192
|
+
# redação que acabou de acontecer.
|
|
193
|
+
with contextlib.suppress(OSError):
|
|
194
|
+
await asyncio.to_thread(Path(raw_path).unlink)
|
|
195
|
+
result.evidence_path = await evidence.record_scan(
|
|
196
|
+
result.image_reference, self._scanner, raw
|
|
197
|
+
)
|