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,271 @@
|
|
|
1
|
+
"""Public exploits catalogued in Exploit-DB, matched strictly by CVE-ID.
|
|
2
|
+
|
|
3
|
+
Exploit-DB has no official public API, and this project does not scrape:
|
|
4
|
+
CISA KEV and EPSS are consumed from their published feeds, and a scraper
|
|
5
|
+
breaks silently every time somebody edits the site's HTML. What Exploit-DB
|
|
6
|
+
*does* publish is the same `files_exploits.csv` that `searchsploit` reads,
|
|
7
|
+
kept in the project's own repository. One HTTP request, one CSV parse, no
|
|
8
|
+
system dependency and nothing to install.
|
|
9
|
+
|
|
10
|
+
**Matching is CVE-ID to CVE-ID, and nothing else.** The catalogue records a
|
|
11
|
+
CVE only when the exploit's author declared one, so plenty of entries carry
|
|
12
|
+
none -- roughly 42% of them. The tempting fallback is to search by product
|
|
13
|
+
name and attach whatever comes back, and it manufactures false positives: a
|
|
14
|
+
2015 exploit for an old release of curl says nothing about the curl CVE
|
|
15
|
+
Trivy found in this image today, and reporting it as "exploit available"
|
|
16
|
+
would be worse than reporting nothing. An unmatched CVE comes back FALSE
|
|
17
|
+
(the catalogue answered and does not list it), never a guess.
|
|
18
|
+
|
|
19
|
+
Failure degrades to UNKNOWN for every CVE in the scan, never to FALSE and
|
|
20
|
+
never to an exception: an unreachable catalogue is an absent measurement,
|
|
21
|
+
and the single rule this project is organised around is that an absent
|
|
22
|
+
measurement is never spent as reassurance.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import asyncio
|
|
28
|
+
import csv
|
|
29
|
+
import io
|
|
30
|
+
from typing import TYPE_CHECKING, Any
|
|
31
|
+
|
|
32
|
+
import httpx
|
|
33
|
+
from loguru import logger
|
|
34
|
+
|
|
35
|
+
from dockerls.infrastructure.network.host_guard import host_of_url
|
|
36
|
+
|
|
37
|
+
if TYPE_CHECKING:
|
|
38
|
+
from dockerls.domain.interfaces.cache_store import CacheStoreInterface
|
|
39
|
+
from dockerls.infrastructure.network.host_guard import HostGuard
|
|
40
|
+
|
|
41
|
+
#: The CSV the Exploit-DB project publishes and `searchsploit` consumes.
|
|
42
|
+
#: The repository moved from GitHub to GitLab; the old
|
|
43
|
+
#: `offensive-security/exploitdb` path is gone and answers 404.
|
|
44
|
+
EXPLOITDB_CSV_URL = "https://gitlab.com/exploit-database/exploitdb/-/raw/main/files_exploits.csv"
|
|
45
|
+
|
|
46
|
+
#: Where a reader goes to judge the exploit rather than trust our summary.
|
|
47
|
+
EXPLOIT_URL_TEMPLATE = "https://www.exploit-db.com/exploits/{edb_id}"
|
|
48
|
+
|
|
49
|
+
#: Refreshed daily. The catalogue grows by a handful of entries a day, so a
|
|
50
|
+
#: fresher copy would cost 10 MB per run to learn almost nothing.
|
|
51
|
+
CACHE_TTL_SECONDS = 24 * 60 * 60
|
|
52
|
+
CACHE_KEY = "exploitdb:cve-index:v1"
|
|
53
|
+
|
|
54
|
+
#: The file is ~10 MB and ~47k rows today. The ceiling is not a performance
|
|
55
|
+
#: tuning knob: it bounds what an unexpected response (a redirect to
|
|
56
|
+
#: something else, a compromised mirror, an error page) can make this
|
|
57
|
+
#: process allocate. Generous enough to absorb years of growth.
|
|
58
|
+
MAX_CSV_BYTES = 128 * 1024 * 1024
|
|
59
|
+
|
|
60
|
+
#: Defence against a malformed or hostile row claiming thousands of CVEs.
|
|
61
|
+
#: Real entries carry a handful.
|
|
62
|
+
MAX_CODES_PER_ROW = 64
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class ExploitEntry:
|
|
66
|
+
"""One catalogued exploit, reduced to what the report shows."""
|
|
67
|
+
|
|
68
|
+
__slots__ = ("edb_id", "verified")
|
|
69
|
+
|
|
70
|
+
def __init__(self, edb_id: str, verified: bool):
|
|
71
|
+
self.edb_id = edb_id
|
|
72
|
+
self.verified = verified
|
|
73
|
+
|
|
74
|
+
def __repr__(self) -> str: # pragma: no cover - debugging aid
|
|
75
|
+
return f"ExploitEntry({self.edb_id!r}, verified={self.verified})"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class ExploitDBClient:
|
|
79
|
+
"""Best-effort Exploit-DB lookups over the published CSV.
|
|
80
|
+
|
|
81
|
+
Mirrors `ThreatIntelClient` in shape -- an `available` tri-state, a
|
|
82
|
+
memo guarded by a lock so a 100-tag `recommend` run does not start 100
|
|
83
|
+
simultaneous downloads -- and adds the persistent cache that one lacks,
|
|
84
|
+
because 10 MB per invocation is a different proposition from 2 MB.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
def __init__(
|
|
88
|
+
self,
|
|
89
|
+
timeout: int = 30,
|
|
90
|
+
cache: CacheStoreInterface | None = None,
|
|
91
|
+
guard: HostGuard | None = None,
|
|
92
|
+
):
|
|
93
|
+
self._timeout = timeout
|
|
94
|
+
self._cache = cache
|
|
95
|
+
self._guard = guard
|
|
96
|
+
self._index: dict[str, list[ExploitEntry]] | None = None
|
|
97
|
+
# None before anything was asked, True once the catalogue answered,
|
|
98
|
+
# False after it failed. The caller needs this to tell "not listed"
|
|
99
|
+
# from "never looked", which is the whole point of the tri-state.
|
|
100
|
+
self._available: bool | None = None
|
|
101
|
+
self._lock = asyncio.Lock()
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def available(self) -> bool | None:
|
|
105
|
+
"""True once the catalogue answered, False after it failed, None
|
|
106
|
+
before anything was asked."""
|
|
107
|
+
return self._available
|
|
108
|
+
|
|
109
|
+
async def exploits_for(self, cve_ids: list[str]) -> dict[str, list[ExploitEntry]]:
|
|
110
|
+
"""Map each of `cve_ids` that has catalogued exploits to them.
|
|
111
|
+
|
|
112
|
+
A CVE absent from the result was *not* found in the catalogue, which
|
|
113
|
+
is a finding -- but only when `available` is True. With `available`
|
|
114
|
+
False nothing was consulted and every CVE must stay UNKNOWN.
|
|
115
|
+
"""
|
|
116
|
+
if not cve_ids:
|
|
117
|
+
return {}
|
|
118
|
+
index = await self._load()
|
|
119
|
+
if not index:
|
|
120
|
+
return {}
|
|
121
|
+
found: dict[str, list[ExploitEntry]] = {}
|
|
122
|
+
for cve in cve_ids:
|
|
123
|
+
entries = index.get(cve.upper())
|
|
124
|
+
if entries:
|
|
125
|
+
found[cve.upper()] = entries
|
|
126
|
+
return found
|
|
127
|
+
|
|
128
|
+
async def _load(self) -> dict[str, list[ExploitEntry]]:
|
|
129
|
+
if self._index is not None:
|
|
130
|
+
return self._index
|
|
131
|
+
async with self._lock:
|
|
132
|
+
# Re-checked inside the lock: everything that queued behind the
|
|
133
|
+
# first downloader is served the memo instead of repeating it.
|
|
134
|
+
if self._index is not None:
|
|
135
|
+
return self._index
|
|
136
|
+
self._index = await self._load_uncached()
|
|
137
|
+
return self._index
|
|
138
|
+
|
|
139
|
+
async def _load_uncached(self) -> dict[str, list[ExploitEntry]]:
|
|
140
|
+
cached = await self._from_cache()
|
|
141
|
+
if cached is not None:
|
|
142
|
+
self._available = True
|
|
143
|
+
return cached
|
|
144
|
+
|
|
145
|
+
raw = await self._download()
|
|
146
|
+
if raw is None:
|
|
147
|
+
self._available = False
|
|
148
|
+
return {}
|
|
149
|
+
|
|
150
|
+
index = self._parse(raw)
|
|
151
|
+
# An empty index is not a successful lookup: the real catalogue
|
|
152
|
+
# carries tens of thousands of rows, so parsing to nothing means the
|
|
153
|
+
# response was not the file we asked for.
|
|
154
|
+
self._available = bool(index)
|
|
155
|
+
if index:
|
|
156
|
+
await self._store(index)
|
|
157
|
+
else:
|
|
158
|
+
logger.warning(
|
|
159
|
+
"Exploit-DB catalogue parsed to zero entries: exploit status stays UNKNOWN"
|
|
160
|
+
)
|
|
161
|
+
return index
|
|
162
|
+
|
|
163
|
+
async def _download(self) -> str | None:
|
|
164
|
+
# The network policy decides who this process may contact, exactly
|
|
165
|
+
# as it does for registries and scanners. A guard that refuses is a
|
|
166
|
+
# deliberate configuration, not an error to route around.
|
|
167
|
+
# `allows` julga um host, nunca uma URL: entregar a URL inteira faz o
|
|
168
|
+
# guard perguntar ao DNS por `https`, que não resolve, e recusar todo
|
|
169
|
+
# download como se fosse decisão de política.
|
|
170
|
+
if self._guard is not None and not self._guard.allows(host_of_url(EXPLOITDB_CSV_URL)):
|
|
171
|
+
logger.warning(
|
|
172
|
+
"Exploit-DB catalogue refused by the network policy: exploit status will be UNKNOWN"
|
|
173
|
+
)
|
|
174
|
+
return None
|
|
175
|
+
try:
|
|
176
|
+
async with (
|
|
177
|
+
httpx.AsyncClient(timeout=self._timeout, follow_redirects=True) as client,
|
|
178
|
+
client.stream("GET", EXPLOITDB_CSV_URL) as resp,
|
|
179
|
+
):
|
|
180
|
+
resp.raise_for_status()
|
|
181
|
+
chunks: list[bytes] = []
|
|
182
|
+
total = 0
|
|
183
|
+
# Streamed with a running ceiling rather than read whole:
|
|
184
|
+
# `resp.text` on an unexpected response would allocate
|
|
185
|
+
# whatever the other end decided to send.
|
|
186
|
+
async for chunk in resp.aiter_bytes():
|
|
187
|
+
total += len(chunk)
|
|
188
|
+
if total > MAX_CSV_BYTES:
|
|
189
|
+
logger.warning(
|
|
190
|
+
f"Exploit-DB catalogue exceeded {MAX_CSV_BYTES} bytes; "
|
|
191
|
+
"refusing it and leaving exploit status UNKNOWN"
|
|
192
|
+
)
|
|
193
|
+
return None
|
|
194
|
+
chunks.append(chunk)
|
|
195
|
+
return b"".join(chunks).decode("utf-8", errors="replace")
|
|
196
|
+
except (httpx.HTTPError, UnicodeDecodeError) as e:
|
|
197
|
+
logger.warning(f"Exploit-DB catalogue unavailable: exploit status stays UNKNOWN ({e})")
|
|
198
|
+
return None
|
|
199
|
+
|
|
200
|
+
@staticmethod
|
|
201
|
+
def _parse(raw: str) -> dict[str, list[ExploitEntry]]:
|
|
202
|
+
"""Index the CSV by CVE-ID.
|
|
203
|
+
|
|
204
|
+
`codes` holds identifiers separated by `;` and mixes schemes --
|
|
205
|
+
`CVE-2009-3699;OSVDB-58726` -- so only the CVE-shaped ones are kept.
|
|
206
|
+
Everything here treats the file as untrusted input: a row missing the
|
|
207
|
+
fields this needs is skipped rather than raising.
|
|
208
|
+
|
|
209
|
+
A `csv.Error` -- a NUL byte, an unterminated quote, a field past the
|
|
210
|
+
module limit -- discards the **whole** index instead of keeping what
|
|
211
|
+
was read so far, and that is deliberate rather than lazy: a partial
|
|
212
|
+
catalogue would answer FALSE for every CVE whose row came after the
|
|
213
|
+
break, turning rows that were never read into "no published exploit".
|
|
214
|
+
Discarding leaves `available` False and every CVE UNKNOWN, which is
|
|
215
|
+
what actually happened.
|
|
216
|
+
"""
|
|
217
|
+
index: dict[str, list[ExploitEntry]] = {}
|
|
218
|
+
try:
|
|
219
|
+
reader = csv.DictReader(io.StringIO(raw))
|
|
220
|
+
for row in reader:
|
|
221
|
+
edb_id = (row.get("id") or "").strip()
|
|
222
|
+
if not edb_id or not edb_id.isdigit():
|
|
223
|
+
# The EDB-ID becomes a URL in the report; anything but
|
|
224
|
+
# digits does not belong in one.
|
|
225
|
+
continue
|
|
226
|
+
codes = (row.get("codes") or "").strip()
|
|
227
|
+
if not codes:
|
|
228
|
+
continue
|
|
229
|
+
verified = (row.get("verified") or "").strip() == "1"
|
|
230
|
+
entry = ExploitEntry(edb_id, verified)
|
|
231
|
+
for code in codes.split(";")[:MAX_CODES_PER_ROW]:
|
|
232
|
+
cve = code.strip().upper()
|
|
233
|
+
if not cve.startswith("CVE-"):
|
|
234
|
+
continue
|
|
235
|
+
index.setdefault(cve, []).append(entry)
|
|
236
|
+
except csv.Error as e:
|
|
237
|
+
logger.warning(f"Exploit-DB catalogue could not be parsed: {e}")
|
|
238
|
+
return {}
|
|
239
|
+
return index
|
|
240
|
+
|
|
241
|
+
async def _from_cache(self) -> dict[str, list[ExploitEntry]] | None:
|
|
242
|
+
if self._cache is None:
|
|
243
|
+
return None
|
|
244
|
+
try:
|
|
245
|
+
data = await self._cache.get(CACHE_KEY)
|
|
246
|
+
except Exception as e: # pragma: no cover - an unreadable cache is a miss
|
|
247
|
+
logger.debug(f"Could not read the cached Exploit-DB index: {e}")
|
|
248
|
+
return None
|
|
249
|
+
if not isinstance(data, dict) or not data:
|
|
250
|
+
return None
|
|
251
|
+
try:
|
|
252
|
+
return {
|
|
253
|
+
str(cve): [ExploitEntry(str(e["id"]), bool(e["verified"])) for e in entries]
|
|
254
|
+
for cve, entries in data.items()
|
|
255
|
+
}
|
|
256
|
+
except (KeyError, TypeError, ValueError) as e:
|
|
257
|
+
# A cache written by another version is a miss, not a crash.
|
|
258
|
+
logger.debug(f"Discarding an unreadable Exploit-DB cache entry: {e}")
|
|
259
|
+
return None
|
|
260
|
+
|
|
261
|
+
async def _store(self, index: dict[str, list[ExploitEntry]]) -> None:
|
|
262
|
+
if self._cache is None:
|
|
263
|
+
return
|
|
264
|
+
payload: dict[str, Any] = {
|
|
265
|
+
cve: [{"id": e.edb_id, "verified": e.verified} for e in entries]
|
|
266
|
+
for cve, entries in index.items()
|
|
267
|
+
}
|
|
268
|
+
try:
|
|
269
|
+
await self._cache.set(CACHE_KEY, payload, ttl_seconds=CACHE_TTL_SECONDS)
|
|
270
|
+
except Exception as e: # pragma: no cover - a cache that will not write is not fatal
|
|
271
|
+
logger.debug(f"Could not cache the Exploit-DB index: {e}")
|
|
File without changes
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
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.utils.executables import ExecutableNotFoundError, resolve_executable
|
|
18
|
+
from dockerls.utils.subprocess_runner import (
|
|
19
|
+
VERSION_TIMEOUT_SECONDS,
|
|
20
|
+
OutputTooLargeError,
|
|
21
|
+
run_capture,
|
|
22
|
+
)
|
|
23
|
+
from dockerls.utils.validation import sanitize_image_name
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from dockerls.infrastructure.evidence import EvidenceStore
|
|
27
|
+
from dockerls.infrastructure.network.host_guard import HostGuard
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class GrypeScanner(ScannerInterface):
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
timeout: int = 300,
|
|
34
|
+
evidence: EvidenceStore | None = None,
|
|
35
|
+
guard: HostGuard | None = None,
|
|
36
|
+
workers: int = 1,
|
|
37
|
+
):
|
|
38
|
+
self._timeout = timeout
|
|
39
|
+
self._evidence = evidence
|
|
40
|
+
self._version: str | None = None
|
|
41
|
+
self._skip_db_update = False
|
|
42
|
+
self._batch: EngineBatchScanner | None = None
|
|
43
|
+
self._workers = max(1, workers)
|
|
44
|
+
# Grype pulls the image itself, so the reference has to clear the
|
|
45
|
+
# network policy here or it never clears it at all.
|
|
46
|
+
self._guard = guard
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def batch(self) -> EngineBatchScanner:
|
|
50
|
+
"""O caminho em lote, quando a engine Go estiver disponível.
|
|
51
|
+
|
|
52
|
+
O Grype não tem `--cache-dir`: a base dele mora num diretório único
|
|
53
|
+
e o que desliga a atualização automática são variáveis de ambiente.
|
|
54
|
+
Por isso o lote vai sem diretórios de cache e com o mesmo par de
|
|
55
|
+
variáveis que o caminho individual usa -- e a engine sabe que, sem
|
|
56
|
+
o lock BoltDB do Trivy, não há motivo para serializar.
|
|
57
|
+
"""
|
|
58
|
+
if self._batch is None:
|
|
59
|
+
self._batch = EngineBatchScanner(
|
|
60
|
+
scanner="grype",
|
|
61
|
+
timeout_seconds=float(self._timeout),
|
|
62
|
+
skip_db_update=self._skip_db_update,
|
|
63
|
+
workers=self._workers,
|
|
64
|
+
guard=self._guard,
|
|
65
|
+
evidence=self._evidence,
|
|
66
|
+
raw_dir=None,
|
|
67
|
+
# Só o par que interessa, e nunca `_scan_env()`: aquele
|
|
68
|
+
# devolve `os.environ.copy()` inteiro, e mandá-lo pela
|
|
69
|
+
# fronteira escreveria DOCKERHUB_TOKEN e companhia dentro
|
|
70
|
+
# de um documento JSON. A engine soma o que recebe ao
|
|
71
|
+
# ambiente que ela própria herdou.
|
|
72
|
+
env=self._batch_env(),
|
|
73
|
+
)
|
|
74
|
+
return self._batch
|
|
75
|
+
|
|
76
|
+
def _batch_env(self) -> dict[str, str]:
|
|
77
|
+
"""As variáveis que o lote precisa, e só elas."""
|
|
78
|
+
if not self._skip_db_update:
|
|
79
|
+
return {}
|
|
80
|
+
return {"GRYPE_DB_AUTO_UPDATE": "false", "GRYPE_CHECK_FOR_APP_UPDATE": "false"}
|
|
81
|
+
|
|
82
|
+
async def is_available(self) -> bool:
|
|
83
|
+
return shutil.which("grype") is not None
|
|
84
|
+
|
|
85
|
+
async def version(self) -> str:
|
|
86
|
+
"""The scanner's own version string, memoised for the run.
|
|
87
|
+
|
|
88
|
+
Recorded so an analysis can be reconstructed: a score produced by
|
|
89
|
+
grype 0.48 and one produced by 0.58 are different measurements of
|
|
90
|
+
the same image, and until this existed nothing wrote down which one
|
|
91
|
+
happened. It is also what keeps a cached result from being served
|
|
92
|
+
across a scanner upgrade.
|
|
93
|
+
|
|
94
|
+
Returns "" when the binary is missing or does not answer -- an
|
|
95
|
+
unknown version, which the cache key treats as its own value rather
|
|
96
|
+
than as "same as before".
|
|
97
|
+
"""
|
|
98
|
+
if self._version is not None:
|
|
99
|
+
return self._version
|
|
100
|
+
self._version = await self._read_version()
|
|
101
|
+
return self._version
|
|
102
|
+
|
|
103
|
+
async def _read_version(self) -> str:
|
|
104
|
+
try:
|
|
105
|
+
returncode, stdout, _ = await run_capture(
|
|
106
|
+
[resolve_executable("grype"), "--version"], timeout=VERSION_TIMEOUT_SECONDS
|
|
107
|
+
)
|
|
108
|
+
except (TimeoutError, OSError, ExecutableNotFoundError) as e:
|
|
109
|
+
logger.debug(f"Could not read grype version: {e}")
|
|
110
|
+
return ""
|
|
111
|
+
if returncode != 0 or not stdout:
|
|
112
|
+
return ""
|
|
113
|
+
# `<tool> --version` prints a banner; the first line carries the
|
|
114
|
+
# version and the rest is database metadata that changes on its own
|
|
115
|
+
# schedule and would churn the cache key for no reason.
|
|
116
|
+
return stdout.decode(errors="replace").strip().splitlines()[0].strip()
|
|
117
|
+
|
|
118
|
+
def _scan_env(self) -> dict[str, str] | None:
|
|
119
|
+
"""Environment for a scan invocation.
|
|
120
|
+
|
|
121
|
+
Left at None until the DB has been refreshed once, so a scanner used
|
|
122
|
+
without `refresh_db()` still behaves exactly as before.
|
|
123
|
+
"""
|
|
124
|
+
if not self._skip_db_update:
|
|
125
|
+
return None
|
|
126
|
+
env = os.environ.copy()
|
|
127
|
+
env["GRYPE_DB_AUTO_UPDATE"] = "false"
|
|
128
|
+
env["GRYPE_CHECK_FOR_APP_UPDATE"] = "false"
|
|
129
|
+
return env
|
|
130
|
+
|
|
131
|
+
async def refresh_db(self) -> bool:
|
|
132
|
+
"""Update the vulnerability DB once, up front.
|
|
133
|
+
|
|
134
|
+
Grype otherwise checks its DB freshness on *every* invocation, which
|
|
135
|
+
is a network round trip per scan -- the dominant cost when
|
|
136
|
+
cross-validating several images. After this succeeds, scans run with
|
|
137
|
+
GRYPE_DB_AUTO_UPDATE=false so they go straight to matching.
|
|
138
|
+
"""
|
|
139
|
+
try:
|
|
140
|
+
returncode, _, stderr = await run_capture(
|
|
141
|
+
[resolve_executable("grype"), "db", "update"], timeout=self._timeout
|
|
142
|
+
)
|
|
143
|
+
if returncode != 0:
|
|
144
|
+
logger.warning(f"Grype DB refresh failed: {stderr.decode()[:200]}")
|
|
145
|
+
return False
|
|
146
|
+
except (TimeoutError, OSError, ExecutableNotFoundError) as e:
|
|
147
|
+
logger.warning(f"Grype DB refresh failed: {e}")
|
|
148
|
+
return False
|
|
149
|
+
|
|
150
|
+
self._skip_db_update = True
|
|
151
|
+
logger.info("Grype DB ready; per-scan auto-update disabled")
|
|
152
|
+
return True
|
|
153
|
+
|
|
154
|
+
async def scan(self, image_reference: str) -> ScanResult:
|
|
155
|
+
safe_ref = sanitize_image_name(image_reference)
|
|
156
|
+
blocked = blocked_target_reason(safe_ref, self._guard)
|
|
157
|
+
if blocked:
|
|
158
|
+
return blocked_scan_result(safe_ref, "grype", blocked)
|
|
159
|
+
logger.info(f"Scanning {safe_ref} with Grype")
|
|
160
|
+
timestamp = datetime.now(tz=UTC).isoformat()
|
|
161
|
+
|
|
162
|
+
try:
|
|
163
|
+
returncode, stdout, stderr = await run_capture(
|
|
164
|
+
[resolve_executable("grype"), safe_ref, "-o", "json", "--quiet"],
|
|
165
|
+
timeout=self._timeout,
|
|
166
|
+
env=self._scan_env(),
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
if returncode != 0:
|
|
170
|
+
err = stderr.decode(errors="replace")[:500]
|
|
171
|
+
logger.error(f"Grype returned code {returncode} for {safe_ref}: {err}")
|
|
172
|
+
return ScanResult(
|
|
173
|
+
image_reference=safe_ref,
|
|
174
|
+
scanner="grype",
|
|
175
|
+
scan_timestamp=timestamp,
|
|
176
|
+
status=ScanStatus.ERROR,
|
|
177
|
+
error_message=err,
|
|
178
|
+
error_kind=classify_scanner_error(err),
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
if not stdout:
|
|
182
|
+
return ScanResult(
|
|
183
|
+
image_reference=safe_ref,
|
|
184
|
+
scanner="grype",
|
|
185
|
+
scan_timestamp=timestamp,
|
|
186
|
+
status=ScanStatus.ERROR,
|
|
187
|
+
error_message="Grype produced no output",
|
|
188
|
+
error_kind=ScanErrorKind.INVALID_OUTPUT,
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
raw = stdout.decode()
|
|
192
|
+
data = json.loads(raw)
|
|
193
|
+
result = self._parse_results(safe_ref, data)
|
|
194
|
+
if self._evidence is not None:
|
|
195
|
+
result.evidence_path = await self._evidence.record_scan(safe_ref, "grype", raw)
|
|
196
|
+
return result
|
|
197
|
+
|
|
198
|
+
except OutputTooLargeError as e:
|
|
199
|
+
# Unbounded output is not a measurement: the document was never
|
|
200
|
+
# read in full, so there is nothing to parse and nothing to
|
|
201
|
+
# conclude. INVALID_OUTPUT is already a non-verified state.
|
|
202
|
+
logger.error(f"Grype output exceeded the size limit for {safe_ref}: {e}")
|
|
203
|
+
return ScanResult(
|
|
204
|
+
image_reference=safe_ref,
|
|
205
|
+
scanner="grype",
|
|
206
|
+
scan_timestamp=timestamp,
|
|
207
|
+
status=ScanStatus.ERROR,
|
|
208
|
+
error_message=str(e),
|
|
209
|
+
error_kind=ScanErrorKind.INVALID_OUTPUT,
|
|
210
|
+
)
|
|
211
|
+
except TimeoutError:
|
|
212
|
+
logger.error(f"Grype scan timed out for {safe_ref}")
|
|
213
|
+
return ScanResult(
|
|
214
|
+
image_reference=safe_ref,
|
|
215
|
+
scanner="grype",
|
|
216
|
+
scan_timestamp=timestamp,
|
|
217
|
+
status=ScanStatus.TIMEOUT,
|
|
218
|
+
error_message=f"Scan exceeded {self._timeout}s timeout",
|
|
219
|
+
error_kind=ScanErrorKind.TIMEOUT,
|
|
220
|
+
)
|
|
221
|
+
except json.JSONDecodeError as e:
|
|
222
|
+
logger.error(f"Grype produced unparseable JSON for {safe_ref}: {e}")
|
|
223
|
+
return ScanResult(
|
|
224
|
+
image_reference=safe_ref,
|
|
225
|
+
scanner="grype",
|
|
226
|
+
scan_timestamp=timestamp,
|
|
227
|
+
status=ScanStatus.ERROR,
|
|
228
|
+
error_message=str(e),
|
|
229
|
+
error_kind=ScanErrorKind.INVALID_OUTPUT,
|
|
230
|
+
)
|
|
231
|
+
except ExecutableNotFoundError as e:
|
|
232
|
+
logger.error(f"Grype scan failed for {safe_ref}: {e}")
|
|
233
|
+
return ScanResult(
|
|
234
|
+
image_reference=safe_ref,
|
|
235
|
+
scanner="grype",
|
|
236
|
+
scan_timestamp=timestamp,
|
|
237
|
+
status=ScanStatus.ERROR,
|
|
238
|
+
error_message=str(e),
|
|
239
|
+
error_kind=ScanErrorKind.SCANNER_MISSING,
|
|
240
|
+
)
|
|
241
|
+
except OSError as e:
|
|
242
|
+
logger.error(f"Grype scan failed for {safe_ref}: {e}")
|
|
243
|
+
return ScanResult(
|
|
244
|
+
image_reference=safe_ref,
|
|
245
|
+
scanner="grype",
|
|
246
|
+
scan_timestamp=timestamp,
|
|
247
|
+
status=ScanStatus.ERROR,
|
|
248
|
+
error_message=str(e),
|
|
249
|
+
error_kind=classify_scanner_error(str(e)),
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
def _parse_results(self, image_ref: str, data: dict[str, Any]) -> ScanResult:
|
|
253
|
+
vulns: list[Vulnerability] = []
|
|
254
|
+
for match in data.get("matches", []):
|
|
255
|
+
vd = match.get("vulnerability", {})
|
|
256
|
+
sev_str = vd.get("severity", "Unknown").upper()
|
|
257
|
+
if sev_str == "NEGLIGIBLE":
|
|
258
|
+
sev_str = "LOW"
|
|
259
|
+
try:
|
|
260
|
+
severity = Severity(sev_str)
|
|
261
|
+
except ValueError:
|
|
262
|
+
severity = Severity.UNKNOWN
|
|
263
|
+
|
|
264
|
+
artifact = match.get("artifact", {})
|
|
265
|
+
fixed_versions = vd.get("fix", {}).get("versions", [])
|
|
266
|
+
fixed_version = fixed_versions[0] if fixed_versions else ""
|
|
267
|
+
|
|
268
|
+
cvss_score, cvss_source = self._extract_cvss(vd.get("cvss", []))
|
|
269
|
+
|
|
270
|
+
vulns.append(
|
|
271
|
+
Vulnerability(
|
|
272
|
+
cve_id=vd.get("id", ""),
|
|
273
|
+
severity=severity,
|
|
274
|
+
cvss_score=cvss_score,
|
|
275
|
+
cvss_source=cvss_source,
|
|
276
|
+
package_name=artifact.get("name", ""),
|
|
277
|
+
installed_version=artifact.get("version", ""),
|
|
278
|
+
fixed_version=fixed_version,
|
|
279
|
+
description=vd.get("description", "")[:200],
|
|
280
|
+
package_type=str(artifact.get("type") or ""),
|
|
281
|
+
target=str(artifact.get("locations", [{}])[0].get("path", ""))
|
|
282
|
+
if artifact.get("locations")
|
|
283
|
+
else "",
|
|
284
|
+
)
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
family, version = self._parse_distro(data)
|
|
288
|
+
return ScanResult(
|
|
289
|
+
image_reference=image_ref,
|
|
290
|
+
scanner="grype",
|
|
291
|
+
vulnerabilities=vulns,
|
|
292
|
+
scan_timestamp=datetime.now(tz=UTC).isoformat(),
|
|
293
|
+
os_family=family,
|
|
294
|
+
os_version=version,
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
@staticmethod
|
|
298
|
+
def _parse_distro(data: dict[str, Any]) -> tuple[str, str]:
|
|
299
|
+
"""The base distribution Grype identified, from its `distro` block.
|
|
300
|
+
|
|
301
|
+
Same fact Trivy reports under `Metadata.OS`, spelled differently.
|
|
302
|
+
Normalising it here means the migration analysis reads one field
|
|
303
|
+
regardless of which scanner produced the result.
|
|
304
|
+
"""
|
|
305
|
+
distro = data.get("distro")
|
|
306
|
+
if not isinstance(distro, dict):
|
|
307
|
+
return "", ""
|
|
308
|
+
return str(distro.get("name") or ""), str(distro.get("version") or "")
|
|
309
|
+
|
|
310
|
+
@staticmethod
|
|
311
|
+
def _extract_cvss(entries: list[dict[str, Any]]) -> tuple[float, str]:
|
|
312
|
+
"""Deterministic CVSS selection: NVD source > any other vendor
|
|
313
|
+
source > first available, instead of an arbitrary max() across
|
|
314
|
+
differently-scored advisories. Returns (score, source) so the report
|
|
315
|
+
can say which base produced the number."""
|
|
316
|
+
if not entries:
|
|
317
|
+
return 0.0, ""
|
|
318
|
+
|
|
319
|
+
def base_score(entry: dict[str, Any]) -> float:
|
|
320
|
+
# Grype emits `"metrics": null` for advisories with no CVSS
|
|
321
|
+
# vector, and a null/non-numeric score must read as "unscored",
|
|
322
|
+
# not blow up the whole parse of an otherwise good scan.
|
|
323
|
+
metrics = entry.get("metrics") or {}
|
|
324
|
+
if not isinstance(metrics, dict):
|
|
325
|
+
return 0.0
|
|
326
|
+
try:
|
|
327
|
+
return float(metrics.get("baseScore", 0.0))
|
|
328
|
+
except (TypeError, ValueError):
|
|
329
|
+
return 0.0
|
|
330
|
+
|
|
331
|
+
for entry in entries:
|
|
332
|
+
source = str(entry.get("source", ""))
|
|
333
|
+
if "nvd" in source.lower():
|
|
334
|
+
return base_score(entry), source or "nvd"
|
|
335
|
+
|
|
336
|
+
return base_score(entries[0]), str(entries[0].get("source", ""))
|
|
File without changes
|