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,290 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import math
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
from loguru import logger
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from dockerls.domain.interfaces.cache_store import CacheStoreInterface
|
|
12
|
+
|
|
13
|
+
#: Smallest catalogue that could plausibly be the real KEV feed. It has
|
|
14
|
+
#: carried more than a thousand entries since 2023 and only grows, so a
|
|
15
|
+
#: floor an order of magnitude below that discriminates against proxy error
|
|
16
|
+
#: pages and truncated transfers, not against the feed.
|
|
17
|
+
MIN_PLAUSIBLE_KEV_ENTRIES = 100
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _probability(value: object) -> float | None:
|
|
21
|
+
"""A finite 0.0-1.0 float, or None when the value is not one."""
|
|
22
|
+
try:
|
|
23
|
+
number = float(value) # type: ignore[arg-type]
|
|
24
|
+
except (TypeError, ValueError):
|
|
25
|
+
return None
|
|
26
|
+
if not math.isfinite(number) or not (0.0 <= number <= 1.0):
|
|
27
|
+
return None
|
|
28
|
+
return number
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ThreatIntelClient:
|
|
32
|
+
"""Best-effort CISA KEV + FIRST EPSS lookups. Both sources are treated
|
|
33
|
+
as optional enrichment: any network/parse failure degrades to "no
|
|
34
|
+
signal" (empty set / 0.0 score) instead of breaking the scan."""
|
|
35
|
+
|
|
36
|
+
KEV_URL = "https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json"
|
|
37
|
+
EPSS_URL = "https://api.first.org/data/v1/epss"
|
|
38
|
+
|
|
39
|
+
# Both feeds move roughly daily -- KEV gets a handful of new entries a
|
|
40
|
+
# day, EPSS republishes once a day -- so a fresher copy buys nothing and
|
|
41
|
+
# costs a full re-download (KEV) or one HTTP round trip per CVE (EPSS)
|
|
42
|
+
# on every single invocation. Without this, `recommend` re-fetched the
|
|
43
|
+
# whole KEV catalogue and re-queried EPSS for every CRITICAL/HIGH CVE on
|
|
44
|
+
# every run, even back-to-back runs against the same image.
|
|
45
|
+
CACHE_TTL_SECONDS = 24 * 60 * 60
|
|
46
|
+
_KEV_CACHE_KEY = "threat-intel:kev:v1"
|
|
47
|
+
_EPSS_CACHE_PREFIX = "threat-intel:epss:v1:"
|
|
48
|
+
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
timeout: int = 15,
|
|
52
|
+
cache: CacheStoreInterface | None = None,
|
|
53
|
+
min_kev_entries: int = MIN_PLAUSIBLE_KEV_ENTRIES,
|
|
54
|
+
):
|
|
55
|
+
self._timeout = timeout
|
|
56
|
+
self._cache = cache
|
|
57
|
+
# Injectable so a test can exercise the parsing path with a small
|
|
58
|
+
# fixture without lowering the floor that protects real runs.
|
|
59
|
+
self._min_kev_entries = min_kev_entries
|
|
60
|
+
self._kev_ids: set[str] | None = None
|
|
61
|
+
# Whether each feed actually answered during this run. Without this,
|
|
62
|
+
# "no KEV hits" and "the KEV catalogue was unreachable" are the same
|
|
63
|
+
# empty set, and the caller cannot tell a negative finding from a
|
|
64
|
+
# missing lookup.
|
|
65
|
+
self._kev_available: bool | None = None
|
|
66
|
+
self._epss_available: bool | None = None
|
|
67
|
+
# EPSS percentiles, kept alongside the probabilities. A probability
|
|
68
|
+
# of 0.42 means little on its own; the percentile says where that
|
|
69
|
+
# sits among everything FIRST scored, which is what makes it
|
|
70
|
+
# comparable between runs on different days.
|
|
71
|
+
self._percentiles: dict[str, float] = {}
|
|
72
|
+
# `recommend` enriches every tag concurrently, and the memo below is
|
|
73
|
+
# only populated *after* the first download finishes. Without this
|
|
74
|
+
# lock, a 100-tag run started 100 simultaneous downloads of the same
|
|
75
|
+
# multi-megabyte KEV catalogue -- a self-inflicted burst against
|
|
76
|
+
# cisa.gov that the memo was written to prevent.
|
|
77
|
+
self._kev_lock = asyncio.Lock()
|
|
78
|
+
|
|
79
|
+
async def _load_kev(self) -> set[str]:
|
|
80
|
+
if self._kev_ids is not None:
|
|
81
|
+
return self._kev_ids
|
|
82
|
+
async with self._kev_lock:
|
|
83
|
+
if self._kev_ids is not None:
|
|
84
|
+
return self._kev_ids
|
|
85
|
+
cached = await self._kev_from_cache()
|
|
86
|
+
if cached is not None:
|
|
87
|
+
self._kev_available = True
|
|
88
|
+
self._kev_ids = cached
|
|
89
|
+
return self._kev_ids
|
|
90
|
+
self._kev_ids = await self._fetch_kev()
|
|
91
|
+
if self._kev_ids:
|
|
92
|
+
await self._store_kev_cache(self._kev_ids)
|
|
93
|
+
return self._kev_ids
|
|
94
|
+
|
|
95
|
+
async def _kev_from_cache(self) -> set[str] | None:
|
|
96
|
+
if self._cache is None:
|
|
97
|
+
return None
|
|
98
|
+
try:
|
|
99
|
+
data = await self._cache.get(self._KEV_CACHE_KEY)
|
|
100
|
+
except Exception as e: # pragma: no cover - an unreadable cache is a miss
|
|
101
|
+
logger.debug(f"Could not read the cached KEV catalogue: {e}")
|
|
102
|
+
return None
|
|
103
|
+
if not isinstance(data, list) or not data:
|
|
104
|
+
return None
|
|
105
|
+
return {str(cve).upper() for cve in data}
|
|
106
|
+
|
|
107
|
+
async def _store_kev_cache(self, ids: set[str]) -> None:
|
|
108
|
+
if self._cache is None:
|
|
109
|
+
return
|
|
110
|
+
try:
|
|
111
|
+
await self._cache.set(
|
|
112
|
+
self._KEV_CACHE_KEY, sorted(ids), ttl_seconds=self.CACHE_TTL_SECONDS
|
|
113
|
+
)
|
|
114
|
+
except Exception as e: # pragma: no cover - a cache that will not write is not fatal
|
|
115
|
+
logger.debug(f"Could not cache the KEV catalogue: {e}")
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def kev_available(self) -> bool | None:
|
|
119
|
+
"""True once the KEV catalogue answered, False after it failed,
|
|
120
|
+
None before anything was asked."""
|
|
121
|
+
return self._kev_available
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def epss_available(self) -> bool | None:
|
|
125
|
+
"""True once at least one EPSS batch answered; see `kev_available`."""
|
|
126
|
+
return self._epss_available
|
|
127
|
+
|
|
128
|
+
async def _fetch_kev(self) -> set[str]:
|
|
129
|
+
try:
|
|
130
|
+
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
|
131
|
+
resp = await client.get(self.KEV_URL)
|
|
132
|
+
resp.raise_for_status()
|
|
133
|
+
data = resp.json()
|
|
134
|
+
entries = data.get("vulnerabilities", [])
|
|
135
|
+
if not isinstance(entries, list):
|
|
136
|
+
raise ValueError(f"KEV payload 'vulnerabilities' was {type(entries).__name__}")
|
|
137
|
+
ids = {
|
|
138
|
+
str(v.get("cveID", "")).upper()
|
|
139
|
+
for v in entries
|
|
140
|
+
if isinstance(v, dict) and v.get("cveID")
|
|
141
|
+
}
|
|
142
|
+
# A short catalogue is not a successful lookup either. The
|
|
143
|
+
# real feed carries thousands of entries, so a handful means
|
|
144
|
+
# a proxy error page, a truncated transfer or a captive
|
|
145
|
+
# portal parsed as JSON. Accepting it would mark every CVE
|
|
146
|
+
# not in that handful as `kev_status = FALSE` -- an
|
|
147
|
+
# affirmative "not known to be exploited" derived from a
|
|
148
|
+
# response that was never the catalogue.
|
|
149
|
+
self._kev_available = len(ids) >= self._min_kev_entries
|
|
150
|
+
if ids and not self._kev_available:
|
|
151
|
+
logger.warning(
|
|
152
|
+
f"CISA KEV answered with only {len(ids)} entries, far below the "
|
|
153
|
+
f"{self._min_kev_entries} a real catalogue carries; treating "
|
|
154
|
+
f"exploitation status as UNKNOWN rather than trusting it"
|
|
155
|
+
)
|
|
156
|
+
return set()
|
|
157
|
+
return ids
|
|
158
|
+
except (httpx.HTTPError, ValueError) as e:
|
|
159
|
+
logger.warning(
|
|
160
|
+
f"CISA KEV catalog unavailable: exploitation status will be UNKNOWN ({e})"
|
|
161
|
+
)
|
|
162
|
+
self._kev_available = False
|
|
163
|
+
return set()
|
|
164
|
+
|
|
165
|
+
async def known_exploited(self, cve_ids: list[str]) -> set[str]:
|
|
166
|
+
"""Return the subset of `cve_ids` present in the CISA KEV catalog."""
|
|
167
|
+
if not cve_ids:
|
|
168
|
+
return set()
|
|
169
|
+
kev = await self._load_kev()
|
|
170
|
+
return {cve.upper() for cve in cve_ids if cve.upper() in kev}
|
|
171
|
+
|
|
172
|
+
# A API do FIRST pagina o resultado e a query vai na URL. Pedir 200 CVEs
|
|
173
|
+
# de uma vez devolvia calado só a primeira página -- e o restante perdia
|
|
174
|
+
# o sinal de EPSS justamente nas imagens que mais têm CRITICAL/HIGH, que
|
|
175
|
+
# são as que mais precisam dele. O lote é pedido com `limit` explícito
|
|
176
|
+
# em vez de confiar no default do serviço.
|
|
177
|
+
EPSS_BATCH_SIZE = 100
|
|
178
|
+
|
|
179
|
+
async def epss_scores(self, cve_ids: list[str]) -> dict[str, float]:
|
|
180
|
+
"""Return {cve_id: epss_probability} for whatever FIRST.org returns;
|
|
181
|
+
missing/unreachable CVEs are simply absent from the result.
|
|
182
|
+
|
|
183
|
+
Checked against the cache first, one CVE at a time: `recommend`
|
|
184
|
+
enriches dozens of tags of the same image family, and their
|
|
185
|
+
CRITICAL/HIGH findings overlap heavily (the same OS package CVE
|
|
186
|
+
shows up in `node:20`, `node:22` and every variant of each). Without
|
|
187
|
+
this, every one of those tags re-queried FIRST.org for CVEs this
|
|
188
|
+
process, or an earlier run today, had already scored.
|
|
189
|
+
"""
|
|
190
|
+
if not cve_ids:
|
|
191
|
+
return {}
|
|
192
|
+
|
|
193
|
+
wanted = sorted({cve.upper() for cve in cve_ids})
|
|
194
|
+
scores: dict[str, float] = {}
|
|
195
|
+
if self._cache is not None:
|
|
196
|
+
cached = await asyncio.gather(*[self._epss_from_cache(cve) for cve in wanted])
|
|
197
|
+
for cve, hit in zip(wanted, cached, strict=True):
|
|
198
|
+
if hit is not None:
|
|
199
|
+
scores[cve] = hit[0]
|
|
200
|
+
self._percentiles[cve] = hit[1]
|
|
201
|
+
missing = [cve for cve in wanted if cve not in scores]
|
|
202
|
+
if cached_hit := bool(scores):
|
|
203
|
+
self._epss_available = True
|
|
204
|
+
|
|
205
|
+
if missing:
|
|
206
|
+
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
|
207
|
+
for start in range(0, len(missing), self.EPSS_BATCH_SIZE):
|
|
208
|
+
batch = missing[start : start + self.EPSS_BATCH_SIZE]
|
|
209
|
+
# Um lote que falha não pode descartar os que já vieram: o
|
|
210
|
+
# sinal parcial ainda é melhor que nenhum.
|
|
211
|
+
batch_scores = await self._epss_batch(client, batch)
|
|
212
|
+
if batch_scores:
|
|
213
|
+
self._epss_available = True
|
|
214
|
+
scores.update(batch_scores)
|
|
215
|
+
await self._store_epss_cache(batch_scores)
|
|
216
|
+
if self._epss_available is None and not cached_hit:
|
|
217
|
+
# Every batch came back empty and nothing was cached: either the
|
|
218
|
+
# service is down or it knows none of these CVEs. Neither
|
|
219
|
+
# supports a claim of low exploitation probability.
|
|
220
|
+
self._epss_available = False
|
|
221
|
+
return scores
|
|
222
|
+
|
|
223
|
+
async def _epss_from_cache(self, cve: str) -> tuple[float, float] | None:
|
|
224
|
+
if self._cache is None:
|
|
225
|
+
return None
|
|
226
|
+
try:
|
|
227
|
+
data = await self._cache.get(self._EPSS_CACHE_PREFIX + cve)
|
|
228
|
+
except Exception as e: # pragma: no cover - an unreadable cache is a miss
|
|
229
|
+
logger.debug(f"Could not read the cached EPSS score for {cve}: {e}")
|
|
230
|
+
return None
|
|
231
|
+
if not isinstance(data, dict) or "score" not in data:
|
|
232
|
+
return None
|
|
233
|
+
score = _probability(data["score"])
|
|
234
|
+
if score is None:
|
|
235
|
+
# A row written before this check existed may still carry a
|
|
236
|
+
# NaN/out-of-range value on disk; treat it the same as a miss
|
|
237
|
+
# rather than serving it back as a probability.
|
|
238
|
+
return None
|
|
239
|
+
percentile = _probability(data.get("percentile", 0.0)) or 0.0
|
|
240
|
+
return score, percentile
|
|
241
|
+
|
|
242
|
+
async def _store_epss_cache(self, batch_scores: dict[str, float]) -> None:
|
|
243
|
+
if self._cache is None or not batch_scores:
|
|
244
|
+
return
|
|
245
|
+
for cve, score in batch_scores.items():
|
|
246
|
+
payload = {"score": score, "percentile": self._percentiles.get(cve, 0.0)}
|
|
247
|
+
try:
|
|
248
|
+
await self._cache.set(
|
|
249
|
+
self._EPSS_CACHE_PREFIX + cve, payload, ttl_seconds=self.CACHE_TTL_SECONDS
|
|
250
|
+
)
|
|
251
|
+
except Exception as e: # pragma: no cover - a cache that will not write is not fatal
|
|
252
|
+
logger.debug(f"Could not cache the EPSS score for {cve}: {e}")
|
|
253
|
+
|
|
254
|
+
def percentile_of(self, cve_id: str) -> float:
|
|
255
|
+
"""EPSS percentile for `cve_id`, or 0.0 when the source did not
|
|
256
|
+
provide one. Only meaningful when `epss_available` is True."""
|
|
257
|
+
return self._percentiles.get(cve_id.upper(), 0.0)
|
|
258
|
+
|
|
259
|
+
async def _epss_batch(self, client: httpx.AsyncClient, batch: list[str]) -> dict[str, float]:
|
|
260
|
+
try:
|
|
261
|
+
resp = await client.get(
|
|
262
|
+
self.EPSS_URL,
|
|
263
|
+
params={"cve": ",".join(batch), "limit": str(len(batch))},
|
|
264
|
+
)
|
|
265
|
+
resp.raise_for_status()
|
|
266
|
+
data = resp.json()
|
|
267
|
+
scores: dict[str, float] = {}
|
|
268
|
+
for entry in data.get("data", []):
|
|
269
|
+
if "cve" not in entry or "epss" not in entry:
|
|
270
|
+
continue
|
|
271
|
+
cve = entry["cve"].upper()
|
|
272
|
+
probability = _probability(entry["epss"])
|
|
273
|
+
if probability is None:
|
|
274
|
+
# `float()` accepts "nan", "inf" and "-1"; none of them
|
|
275
|
+
# is a probability, and all of them reached the scoring
|
|
276
|
+
# engine. Dropping the CVE from the result leaves it
|
|
277
|
+
# `epss_known = False` downstream, which is the honest
|
|
278
|
+
# reading: the feed answered, but not with a number.
|
|
279
|
+
logger.warning(
|
|
280
|
+
f"Discarding implausible EPSS value for {cve}: {entry['epss']!r}"
|
|
281
|
+
)
|
|
282
|
+
continue
|
|
283
|
+
scores[cve] = probability
|
|
284
|
+
percentile = _probability(entry.get("percentile"))
|
|
285
|
+
if percentile is not None:
|
|
286
|
+
self._percentiles[cve] = percentile
|
|
287
|
+
return scores
|
|
288
|
+
except (httpx.HTTPError, ValueError, KeyError, TypeError) as e:
|
|
289
|
+
logger.debug(f"EPSS lookup unavailable for {len(batch)} CVEs, continuing without: {e}")
|
|
290
|
+
return {}
|
|
File without changes
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import contextlib
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import tempfile
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
from loguru import logger
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from collections.abc import AsyncIterator
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def default_trivy_cache_dir() -> Path:
|
|
18
|
+
"""Mirror Trivy's own default cache location resolution."""
|
|
19
|
+
env = os.environ.get("TRIVY_CACHE_DIR")
|
|
20
|
+
if env:
|
|
21
|
+
return Path(env)
|
|
22
|
+
xdg = os.environ.get("XDG_CACHE_HOME")
|
|
23
|
+
if xdg:
|
|
24
|
+
return Path(xdg) / "trivy"
|
|
25
|
+
return Path.home() / ".cache" / "trivy"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class TrivyCachePool:
|
|
29
|
+
"""Hands out an isolated ``--cache-dir`` to each concurrent Trivy scan.
|
|
30
|
+
|
|
31
|
+
Root cause of the "cache may be in use by another process: timeout"
|
|
32
|
+
errors: Trivy takes an exclusive BoltDB lock on its cache directory, so
|
|
33
|
+
N parallel `trivy image` calls sharing one cache dir serialize on that
|
|
34
|
+
lock and the losers time out and exit non-zero.
|
|
35
|
+
|
|
36
|
+
The fix is to give every in-flight scan its own cache directory. The
|
|
37
|
+
vulnerability DB itself is downloaded once into the shared cache and
|
|
38
|
+
then **hard-linked** into each slot -- a copy would multiply a
|
|
39
|
+
multi-hundred-MB DB by the worker count.
|
|
40
|
+
|
|
41
|
+
If hard-linking is unavailable (different filesystem, restricted
|
|
42
|
+
permissions) the pool degrades to a single slot pointing at the shared
|
|
43
|
+
cache dir. That serializes scans, which is slower but still correct --
|
|
44
|
+
only one process touches the lock at a time, so nothing times out.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
_DB_FILES = ("trivy.db", "metadata.json")
|
|
48
|
+
|
|
49
|
+
def __init__(self, base_cache_dir: Path, size: int):
|
|
50
|
+
self._base = base_cache_dir
|
|
51
|
+
self._size = max(1, size)
|
|
52
|
+
self._slots: asyncio.Queue[Path] | None = None
|
|
53
|
+
# A mesma lista que alimenta a fila, guardada para quem faz o
|
|
54
|
+
# próprio rodízio -- a engine Go recebe todos os slots de uma vez e
|
|
55
|
+
# os reveza por dentro, em vez de pedir um por scan.
|
|
56
|
+
self._slot_paths: list[Path] = []
|
|
57
|
+
self._temp_dirs: list[Path] = []
|
|
58
|
+
self._isolated = False
|
|
59
|
+
# prepare() awaits before assigning _slots, so concurrent first
|
|
60
|
+
# scans could otherwise each build a full pool -- leaking temp dirs
|
|
61
|
+
# and handing out more slots than there are workers.
|
|
62
|
+
self._prepare_lock = asyncio.Lock()
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def isolated(self) -> bool:
|
|
66
|
+
"""True when each concurrent scan got its own cache directory."""
|
|
67
|
+
return self._isolated
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def base_dir(self) -> Path:
|
|
71
|
+
return self._base
|
|
72
|
+
|
|
73
|
+
async def prepare(self) -> bool:
|
|
74
|
+
"""Build the slot pool. Returns True when isolation was achieved."""
|
|
75
|
+
await self._ensure_slots()
|
|
76
|
+
return self._isolated
|
|
77
|
+
|
|
78
|
+
async def _ensure_slots(self) -> asyncio.Queue[Path]:
|
|
79
|
+
if self._slots is not None:
|
|
80
|
+
return self._slots
|
|
81
|
+
|
|
82
|
+
async with self._prepare_lock:
|
|
83
|
+
if self._slots is not None:
|
|
84
|
+
return self._slots
|
|
85
|
+
|
|
86
|
+
slots: list[Path] = []
|
|
87
|
+
if self._size > 1:
|
|
88
|
+
slots = await asyncio.to_thread(self._build_isolated_slots)
|
|
89
|
+
|
|
90
|
+
if slots:
|
|
91
|
+
self._isolated = True
|
|
92
|
+
else:
|
|
93
|
+
if self._size > 1:
|
|
94
|
+
logger.warning(
|
|
95
|
+
"Falling back to a single shared Trivy cache dir; scans will be "
|
|
96
|
+
"serialized to avoid cache lock contention"
|
|
97
|
+
)
|
|
98
|
+
slots = [self._base]
|
|
99
|
+
self._isolated = False
|
|
100
|
+
|
|
101
|
+
queue: asyncio.Queue[Path] = asyncio.Queue()
|
|
102
|
+
for slot in slots:
|
|
103
|
+
queue.put_nowait(slot)
|
|
104
|
+
self._slot_paths = list(slots)
|
|
105
|
+
self._slots = queue
|
|
106
|
+
return queue
|
|
107
|
+
|
|
108
|
+
def _build_isolated_slots(self) -> list[Path]:
|
|
109
|
+
db_dir = self._base / "db"
|
|
110
|
+
sources = [db_dir / name for name in self._DB_FILES]
|
|
111
|
+
if not all(src.exists() for src in sources):
|
|
112
|
+
logger.warning(f"Trivy DB not found under {db_dir}; cannot build isolated cache dirs")
|
|
113
|
+
return []
|
|
114
|
+
|
|
115
|
+
parent = self._base.parent
|
|
116
|
+
try:
|
|
117
|
+
parent.mkdir(parents=True, exist_ok=True)
|
|
118
|
+
except OSError as e:
|
|
119
|
+
logger.warning(f"Cannot create Trivy cache pool parent {parent}: {e}")
|
|
120
|
+
return []
|
|
121
|
+
|
|
122
|
+
slots: list[Path] = []
|
|
123
|
+
for _ in range(self._size):
|
|
124
|
+
try:
|
|
125
|
+
slot = Path(tempfile.mkdtemp(prefix="dockerls-trivy-", dir=parent))
|
|
126
|
+
except OSError as e:
|
|
127
|
+
logger.warning(f"Cannot create isolated Trivy cache dir: {e}")
|
|
128
|
+
break
|
|
129
|
+
self._temp_dirs.append(slot)
|
|
130
|
+
try:
|
|
131
|
+
(slot / "db").mkdir(parents=True, exist_ok=True)
|
|
132
|
+
for src in sources:
|
|
133
|
+
os.link(src, slot / "db" / src.name)
|
|
134
|
+
except OSError as e:
|
|
135
|
+
logger.warning(f"Cannot hard-link Trivy DB into {slot}: {e}")
|
|
136
|
+
break
|
|
137
|
+
slots.append(slot)
|
|
138
|
+
|
|
139
|
+
if len(slots) < self._size:
|
|
140
|
+
# Partial pools are still safe, but an empty one is not usable.
|
|
141
|
+
logger.warning(f"Prepared {len(slots)}/{self._size} isolated Trivy cache dirs")
|
|
142
|
+
return slots
|
|
143
|
+
|
|
144
|
+
async def slot_paths(self) -> list[Path]:
|
|
145
|
+
"""Todos os slots do pool, para um chamador que faz o próprio rodízio.
|
|
146
|
+
|
|
147
|
+
`acquire()` empresta um slot por vez, que é o que o pipeline Python
|
|
148
|
+
precisa. A engine Go recebe o lote inteiro e reveza por dentro, e
|
|
149
|
+
para isso precisa da lista.
|
|
150
|
+
"""
|
|
151
|
+
await self._ensure_slots()
|
|
152
|
+
return list(self._slot_paths)
|
|
153
|
+
|
|
154
|
+
@contextlib.asynccontextmanager
|
|
155
|
+
async def acquire(self) -> AsyncIterator[Path]:
|
|
156
|
+
slots = await self._ensure_slots()
|
|
157
|
+
slot = await slots.get()
|
|
158
|
+
try:
|
|
159
|
+
yield slot
|
|
160
|
+
finally:
|
|
161
|
+
slots.put_nowait(slot)
|
|
162
|
+
|
|
163
|
+
async def cleanup(self) -> None:
|
|
164
|
+
"""Remove every temporary cache dir created by this pool."""
|
|
165
|
+
dirs, self._temp_dirs = self._temp_dirs, []
|
|
166
|
+
if not dirs:
|
|
167
|
+
return
|
|
168
|
+
await asyncio.to_thread(self._cleanup_sync, dirs)
|
|
169
|
+
|
|
170
|
+
@staticmethod
|
|
171
|
+
def _cleanup_sync(dirs: list[Path]) -> None:
|
|
172
|
+
for path in dirs:
|
|
173
|
+
try:
|
|
174
|
+
shutil.rmtree(path, ignore_errors=True)
|
|
175
|
+
except OSError as e: # pragma: no cover - rmtree already swallows
|
|
176
|
+
logger.warning(f"Could not remove temporary Trivy cache dir {path}: {e}")
|