forceupdate 0.1.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.
- forceupdate/__init__.py +9 -0
- forceupdate/__main__.py +5 -0
- forceupdate/advisories/__init__.py +30 -0
- forceupdate/advisories/cvss.py +95 -0
- forceupdate/advisories/osv.py +584 -0
- forceupdate/checks/__init__.py +49 -0
- forceupdate/checks/base.py +98 -0
- forceupdate/checks/deprecated.py +115 -0
- forceupdate/checks/outdated.py +183 -0
- forceupdate/checks/priority.py +82 -0
- forceupdate/checks/vulns.py +201 -0
- forceupdate/cli.py +309 -0
- forceupdate/detect/__init__.py +5 -0
- forceupdate/detect/languages.py +266 -0
- forceupdate/detect/manifests.py +478 -0
- forceupdate/engine.py +313 -0
- forceupdate/models.py +510 -0
- forceupdate/net/__init__.py +20 -0
- forceupdate/net/cache.py +217 -0
- forceupdate/net/http.py +270 -0
- forceupdate/parsers/__init__.py +43 -0
- forceupdate/parsers/base.py +22 -0
- forceupdate/parsers/json_spans.py +123 -0
- forceupdate/parsers/lockfiles.py +231 -0
- forceupdate/parsers/npm.py +1257 -0
- forceupdate/parsers/python.py +1421 -0
- forceupdate/parsers/toml_spans.py +297 -0
- forceupdate/registries/__init__.py +52 -0
- forceupdate/registries/base.py +294 -0
- forceupdate/registries/npm.py +153 -0
- forceupdate/registries/pypi.py +242 -0
- forceupdate/report/__init__.py +359 -0
- forceupdate/report/json.py +34 -0
- forceupdate/report/markdown.py +169 -0
- forceupdate/report/terminal.py +376 -0
- forceupdate/sources/__init__.py +1 -0
- forceupdate/sources/base.py +45 -0
- forceupdate/sources/local.py +183 -0
- forceupdate/update/__init__.py +8 -0
- forceupdate/update/constraints.py +317 -0
- forceupdate/update/planner.py +531 -0
- forceupdate/versions/__init__.py +40 -0
- forceupdate/versions/base.py +229 -0
- forceupdate/versions/npm.py +432 -0
- forceupdate/versions/pypi.py +220 -0
- forceupdate-0.1.0.dist-info/METADATA +79 -0
- forceupdate-0.1.0.dist-info/RECORD +50 -0
- forceupdate-0.1.0.dist-info/WHEEL +4 -0
- forceupdate-0.1.0.dist-info/entry_points.txt +2 -0
- forceupdate-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,584 @@
|
|
|
1
|
+
"""Les failles connues, demandées à OSV (https://osv.dev), la base qui réunit les avis de
|
|
2
|
+
GitHub (GHSA), de PyPI (PYSEC), des paquets malveillants (MAL) et des autres écosystèmes.
|
|
3
|
+
|
|
4
|
+
Deux temps :
|
|
5
|
+
|
|
6
|
+
1. ``POST /v1/querybatch`` : pour chaque version de paquet, les identifiants des failles qui
|
|
7
|
+
la concernent, et leur date de modification. Jusqu'à 1000 versions par requête. C'est
|
|
8
|
+
tout ce que donne cette route : ni gravité, ni version corrigée. Argos s'arrêtait là
|
|
9
|
+
(``ARGOS (3)`` dans ``reference/``) : toutes ses failles sortaient en gravité moyenne,
|
|
10
|
+
sans version corrigée ;
|
|
11
|
+
2. ``GET /v1/vulns/{id}`` : chaque avis complet. Une seule fois par session, même s'il
|
|
12
|
+
concerne plusieurs paquets ou plusieurs projets, et gardé 7 jours en cache tant que sa
|
|
13
|
+
date de modification ne change pas.
|
|
14
|
+
|
|
15
|
+
**Une erreur ne donne jamais « pas de faille »** : une version qu'on n'a pas pu soumettre à
|
|
16
|
+
OSV donne ``OsvError`` (le constat sera « non concluant ») ; un avis qu'on n'a pas pu lire
|
|
17
|
+
reste une faille, de gravité et de version corrigée inconnues (``Advisory.unavailable``).
|
|
18
|
+
Hors ligne, tout vient du cache, même périmé ; le reste est « non concluant ».
|
|
19
|
+
|
|
20
|
+
Repris de ``reference/argos_sca_parsers.py`` (``query_osv_batch``, ``_parse_osv_vuln``), puis
|
|
21
|
+
adapté : le client HTTP de forceupdate au lieu de la session d'Argos (``ARGOS (2)``), les
|
|
22
|
+
avis complets (``ARGOS (3)``), la version corrigée lue dans les événements des plages au lieu
|
|
23
|
+
de la liste de toutes les versions « fixed », les paquets malveillants reconnus, et une
|
|
24
|
+
erreur signalée au lieu d'un résultat vide.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
import asyncio
|
|
28
|
+
import re
|
|
29
|
+
from collections.abc import Iterable, Sequence
|
|
30
|
+
from dataclasses import dataclass
|
|
31
|
+
from datetime import UTC, datetime
|
|
32
|
+
from typing import Any
|
|
33
|
+
from urllib.parse import quote, urlencode
|
|
34
|
+
|
|
35
|
+
from packaging.utils import canonicalize_name
|
|
36
|
+
|
|
37
|
+
from forceupdate import versions
|
|
38
|
+
from forceupdate.advisories import cvss
|
|
39
|
+
from forceupdate.models import Ecosystem, Severity
|
|
40
|
+
from forceupdate.net.cache import Cache, CacheEntry, CacheKind
|
|
41
|
+
from forceupdate.net.http import HttpClient, NetworkError
|
|
42
|
+
from forceupdate.versions import InvalidVersion, VersionScheme
|
|
43
|
+
|
|
44
|
+
BASE_URL = "https://api.osv.dev"
|
|
45
|
+
BATCH_SIZE = 1000
|
|
46
|
+
"""Versions par requête ``querybatch`` : le maximum qu'accepte OSV."""
|
|
47
|
+
MAX_PAGES = 10
|
|
48
|
+
"""Pages suivies pour une même version (``next_page_token``, quand OSV coupe une réponse
|
|
49
|
+
trop longue)."""
|
|
50
|
+
|
|
51
|
+
_FORMAT = 1
|
|
52
|
+
"""Version du format des entrées ``VULNERABILITIES`` du cache : une autre est ignorée."""
|
|
53
|
+
_RANGE_TYPES = frozenset({"SEMVER", "ECOSYSTEM"})
|
|
54
|
+
"""Les plages lues. ``GIT`` donne des commits, pas des versions publiées."""
|
|
55
|
+
_EVENTS = frozenset({"introduced", "fixed", "last_affected", "limit"})
|
|
56
|
+
_GITHUB_SEVERITIES = {
|
|
57
|
+
"CRITICAL": Severity.CRITICAL,
|
|
58
|
+
"HIGH": Severity.HIGH,
|
|
59
|
+
"MODERATE": Severity.MEDIUM,
|
|
60
|
+
"MEDIUM": Severity.MEDIUM,
|
|
61
|
+
"LOW": Severity.LOW,
|
|
62
|
+
}
|
|
63
|
+
"""La gravité que GitHub donne à ses avis (``database_specific.severity``)."""
|
|
64
|
+
_MALICIOUS_CWE = "CWE-506"
|
|
65
|
+
"""« Embedded Malicious Code » : ce que GitHub met sur ses avis de paquets malveillants."""
|
|
66
|
+
_SUMMARY_LENGTH = 200
|
|
67
|
+
"""Longueur maximale d'un titre tiré de la description d'un avis."""
|
|
68
|
+
_BEYOND_MICROSECONDS = re.compile(r"(\.\d{1,6})\d*")
|
|
69
|
+
|
|
70
|
+
Query = tuple[Ecosystem, str, str]
|
|
71
|
+
"""Une version de paquet à soumettre à OSV : écosystème, nom, version."""
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class OsvError(Exception):
|
|
75
|
+
"""OSV n'a pas pu dire quelles failles concernent une version : réseau, mode hors ligne
|
|
76
|
+
sans cache, réponse illisible. Le constat est « non concluant », jamais « pas de faille ».
|
|
77
|
+
|
|
78
|
+
Le message, en français, est fait pour être montré tel quel dans le rapport.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(frozen=True, kw_only=True)
|
|
83
|
+
class AffectedRange:
|
|
84
|
+
"""Une plage de versions concernées, décrite par des événements, dans l'ordre d'OSV."""
|
|
85
|
+
|
|
86
|
+
type: str
|
|
87
|
+
"""``SEMVER`` ou ``ECOSYSTEM`` : des versions à comparer selon les règles de l'écosystème."""
|
|
88
|
+
events: tuple[tuple[str, str], ...]
|
|
89
|
+
"""``("introduced", "0")``, ``("fixed", "4.17.21")``, ``("last_affected", "1.0.1")``,
|
|
90
|
+
``("limit", …)``. ``introduced`` à ``"0"`` : depuis toujours."""
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass(frozen=True, kw_only=True)
|
|
94
|
+
class Affected:
|
|
95
|
+
"""Un paquet concerné par un avis. Un avis en cite parfois plusieurs (lodash, lodash-es,
|
|
96
|
+
lodash.template…), ou le même plusieurs fois (une entrée par branche corrigée)."""
|
|
97
|
+
|
|
98
|
+
ecosystem: str
|
|
99
|
+
"""Tel qu'OSV l'écrit : les valeurs d'``Ecosystem``, ou d'autres (``Debian:12``…)."""
|
|
100
|
+
name: str
|
|
101
|
+
ranges: tuple[AffectedRange, ...] = ()
|
|
102
|
+
versions: tuple[str, ...] = ()
|
|
103
|
+
"""Les versions concernées, quand l'avis les énumère."""
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass(frozen=True, kw_only=True)
|
|
107
|
+
class Advisory:
|
|
108
|
+
"""Un avis de sécurité d'OSV, réduit à ce qui sert."""
|
|
109
|
+
|
|
110
|
+
id: str
|
|
111
|
+
"""``GHSA-…``, ``PYSEC-…``, ``MAL-…``…"""
|
|
112
|
+
aliases: tuple[str, ...] = ()
|
|
113
|
+
"""Les autres identifiants de la même faille (``CVE-…``, l'avis d'une autre base)."""
|
|
114
|
+
summary: str = ""
|
|
115
|
+
"""Le titre de l'avis, tel quel (en anglais en général) ; vide s'il n'en a pas."""
|
|
116
|
+
modified: str = ""
|
|
117
|
+
"""Date de dernière modification, telle qu'OSV l'écrit (ISO 8601)."""
|
|
118
|
+
severity: Severity | None = None
|
|
119
|
+
"""``None`` si l'avis ne dit rien de sa gravité (ou seulement en CVSS 4.0)."""
|
|
120
|
+
score: float | None = None
|
|
121
|
+
"""Le score CVSS 3, de 0 à 10, s'il est connu."""
|
|
122
|
+
malicious: bool = False
|
|
123
|
+
"""Un paquet malveillant (``MAL-…``, ou un avis GitHub marqué ``CWE-506``)."""
|
|
124
|
+
withdrawn: bool = False
|
|
125
|
+
"""Avis retiré par sa base : ce n'est plus une faille."""
|
|
126
|
+
affected: tuple[Affected, ...] = ()
|
|
127
|
+
unavailable: str | None = None
|
|
128
|
+
"""Pourquoi l'avis complet n'a pas pu être lu (réseau, hors ligne…) ; ``None`` s'il l'a
|
|
129
|
+
été. Un avis indisponible reste une faille : on n'en connaît que l'identifiant."""
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def url(self) -> str:
|
|
133
|
+
"""La page de l'avis sur osv.dev."""
|
|
134
|
+
return f"https://osv.dev/vulnerability/{quote(self.id, safe='')}"
|
|
135
|
+
|
|
136
|
+
def fixed_in(self, ecosystem: Ecosystem, name: str, version: str) -> str | None:
|
|
137
|
+
"""La plus petite version qui corrige la faille pour qui utilise ``version`` de
|
|
138
|
+
``name`` : la plus petite des versions « fixed » des plages au-dessus de ``version``
|
|
139
|
+
qui n'est elle-même plus concernée.
|
|
140
|
+
|
|
141
|
+
Avec deux branches corrigées (Flask : ``< 2.2.5`` et ``>= 2.3.0, < 2.3.2``), depuis
|
|
142
|
+
0.12 c'est 2.2.5, depuis 2.3.0 c'est 2.3.2. ``None`` si l'avis n'en donne pas : faille
|
|
143
|
+
jamais corrigée, seulement une dernière version concernée, paquet malveillant.
|
|
144
|
+
"""
|
|
145
|
+
scheme = versions.for_ecosystem(ecosystem)
|
|
146
|
+
try:
|
|
147
|
+
current = scheme.parse(version)
|
|
148
|
+
except InvalidVersion:
|
|
149
|
+
return None
|
|
150
|
+
entries = self._entries(ecosystem, name)
|
|
151
|
+
fixes = []
|
|
152
|
+
for entry in entries:
|
|
153
|
+
for affected_range in entry.ranges:
|
|
154
|
+
for kind, value in affected_range.events:
|
|
155
|
+
if kind != "fixed":
|
|
156
|
+
continue
|
|
157
|
+
try:
|
|
158
|
+
parsed = scheme.parse(value)
|
|
159
|
+
except InvalidVersion:
|
|
160
|
+
continue
|
|
161
|
+
if current < parsed:
|
|
162
|
+
fixes.append((parsed, value))
|
|
163
|
+
fixes.sort(key=lambda fix: fix[0])
|
|
164
|
+
for parsed, text in fixes:
|
|
165
|
+
if not _concerned(scheme, entries, parsed):
|
|
166
|
+
return text
|
|
167
|
+
return None
|
|
168
|
+
|
|
169
|
+
def affects(self, ecosystem: Ecosystem, name: str, version: str) -> bool:
|
|
170
|
+
"""Vrai si ``version`` de ``name`` est concernée, d'après les plages et les versions
|
|
171
|
+
de l'avis. C'est OSV qui décide quelles failles concernent une version ; ceci sert à
|
|
172
|
+
choisir la version corrigée."""
|
|
173
|
+
scheme = versions.for_ecosystem(ecosystem)
|
|
174
|
+
try:
|
|
175
|
+
parsed = scheme.parse(version)
|
|
176
|
+
except InvalidVersion:
|
|
177
|
+
return False
|
|
178
|
+
return _concerned(scheme, self._entries(ecosystem, name), parsed)
|
|
179
|
+
|
|
180
|
+
def _entries(self, ecosystem: Ecosystem, name: str) -> list[Affected]:
|
|
181
|
+
return [
|
|
182
|
+
entry
|
|
183
|
+
for entry in self.affected
|
|
184
|
+
if entry.ecosystem == ecosystem.value and _same_name(ecosystem, entry.name, name)
|
|
185
|
+
]
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@dataclass(frozen=True, kw_only=True)
|
|
189
|
+
class Vulnerabilities:
|
|
190
|
+
"""Les failles qu'OSV connaît pour une version d'un paquet."""
|
|
191
|
+
|
|
192
|
+
advisories: tuple[Advisory, ...]
|
|
193
|
+
"""Les avis, dans l'ordre d'OSV ; vide si la version n'a pas de faille connue."""
|
|
194
|
+
fetched_at: datetime
|
|
195
|
+
"""Quand OSV a répondu (UTC) : plus ancien si la réponse vient du cache."""
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
class Osv:
|
|
199
|
+
"""Le client d'OSV pour une session, avec son client HTTP et son cache :
|
|
200
|
+
``await osv.vulnerabilities([(Ecosystem.NPM, "lodash", "4.17.15")])``.
|
|
201
|
+
|
|
202
|
+
Une instance par session : c'est elle qui fait qu'un avis n'est demandé qu'une fois.
|
|
203
|
+
"""
|
|
204
|
+
|
|
205
|
+
def __init__(
|
|
206
|
+
self,
|
|
207
|
+
http: HttpClient,
|
|
208
|
+
cache: Cache,
|
|
209
|
+
*,
|
|
210
|
+
base_url: str | None = None,
|
|
211
|
+
batch_size: int = BATCH_SIZE,
|
|
212
|
+
) -> None:
|
|
213
|
+
self._http = http
|
|
214
|
+
self._cache = cache
|
|
215
|
+
self.base_url = (base_url or BASE_URL).rstrip("/")
|
|
216
|
+
self._batch_size = batch_size
|
|
217
|
+
self._advisories: dict[str, asyncio.Future[Advisory]] = {}
|
|
218
|
+
|
|
219
|
+
async def vulnerabilities(
|
|
220
|
+
self, queries: Iterable[Query]
|
|
221
|
+
) -> dict[Query, Vulnerabilities | OsvError]:
|
|
222
|
+
"""Pour chaque version de paquet, ses failles connues, ou l'erreur qui empêche de les
|
|
223
|
+
connaître. Les versions sont demandées par lots, les avis tous en même temps."""
|
|
224
|
+
wanted = list(dict.fromkeys(queries))
|
|
225
|
+
found: dict[Query, tuple[list[tuple[str, str]], datetime] | OsvError] = {}
|
|
226
|
+
missing = []
|
|
227
|
+
for query in wanted:
|
|
228
|
+
cached = self._cached_ids(query)
|
|
229
|
+
if cached is None:
|
|
230
|
+
missing.append(query)
|
|
231
|
+
else:
|
|
232
|
+
found[query] = cached
|
|
233
|
+
if missing and self._http.offline:
|
|
234
|
+
# Le même message pour toutes : une remarque les réunit (``engine.check``).
|
|
235
|
+
offline = OsvError("OSV : mode hors ligne, sans réponse en cache.")
|
|
236
|
+
found.update(dict.fromkeys(missing, offline))
|
|
237
|
+
elif missing:
|
|
238
|
+
size = self._batch_size
|
|
239
|
+
batches = [missing[start : start + size] for start in range(0, len(missing), size)]
|
|
240
|
+
for result in await asyncio.gather(*(self._query_batch(b) for b in batches)):
|
|
241
|
+
found.update(result)
|
|
242
|
+
|
|
243
|
+
modified: dict[str, str] = {}
|
|
244
|
+
for value in found.values():
|
|
245
|
+
if not isinstance(value, OsvError):
|
|
246
|
+
for vuln_id, when in value[0]:
|
|
247
|
+
modified[vuln_id] = max(modified.get(vuln_id, ""), when)
|
|
248
|
+
ids = sorted(modified)
|
|
249
|
+
advisories = await asyncio.gather(*(self.advisory(i, modified[i] or None) for i in ids))
|
|
250
|
+
by_id = dict(zip(ids, advisories, strict=True))
|
|
251
|
+
|
|
252
|
+
result: dict[Query, Vulnerabilities | OsvError] = {}
|
|
253
|
+
for query in wanted:
|
|
254
|
+
value = found[query]
|
|
255
|
+
if isinstance(value, OsvError):
|
|
256
|
+
result[query] = value
|
|
257
|
+
else:
|
|
258
|
+
pairs, fetched_at = value
|
|
259
|
+
result[query] = Vulnerabilities(
|
|
260
|
+
advisories=tuple(by_id[vuln_id] for vuln_id, _ in pairs),
|
|
261
|
+
fetched_at=fetched_at,
|
|
262
|
+
)
|
|
263
|
+
return result
|
|
264
|
+
|
|
265
|
+
async def advisory(self, vuln_id: str, modified: str | None = None) -> Advisory:
|
|
266
|
+
"""L'avis complet ``vuln_id``. ``modified`` : sa date de modification d'après
|
|
267
|
+
``querybatch`` ; un avis en cache d'une autre date est redemandé.
|
|
268
|
+
|
|
269
|
+
Ne lève pas : un avis qu'on ne peut pas lire est rendu avec ``unavailable``. Une seule
|
|
270
|
+
requête par avis dans la session, même pour des appels simultanés.
|
|
271
|
+
"""
|
|
272
|
+
lookup = self._advisories.get(vuln_id)
|
|
273
|
+
if lookup is None:
|
|
274
|
+
lookup = self._advisories[vuln_id] = asyncio.ensure_future(
|
|
275
|
+
self._fetch_advisory(vuln_id, modified)
|
|
276
|
+
)
|
|
277
|
+
# shield : un appelant annulé n'annule pas la requête que d'autres attendent.
|
|
278
|
+
return await asyncio.shield(lookup)
|
|
279
|
+
|
|
280
|
+
# -- querybatch -------------------------------------------------------------
|
|
281
|
+
|
|
282
|
+
def _query_key(self, query: Query) -> str:
|
|
283
|
+
ecosystem, name, version = query
|
|
284
|
+
fields = {"ecosystem": ecosystem.value, "name": name, "version": version}
|
|
285
|
+
return f"{self.base_url}/v1/query?{urlencode(fields)}"
|
|
286
|
+
|
|
287
|
+
def _cached_ids(self, query: Query) -> tuple[list[tuple[str, str]], datetime] | None:
|
|
288
|
+
entry = self._cache.get(
|
|
289
|
+
CacheKind.VULNERABILITIES, self._query_key(query), stale_ok=self._http.offline
|
|
290
|
+
)
|
|
291
|
+
if entry is None:
|
|
292
|
+
return None
|
|
293
|
+
value = entry.value
|
|
294
|
+
if not isinstance(value, dict) or value.get("format") != _FORMAT:
|
|
295
|
+
return None
|
|
296
|
+
try:
|
|
297
|
+
pairs = [(str(vuln_id), str(when)) for vuln_id, when in value["vulns"]]
|
|
298
|
+
except (KeyError, TypeError, ValueError):
|
|
299
|
+
return None
|
|
300
|
+
return pairs, entry.stored_at
|
|
301
|
+
|
|
302
|
+
async def _query_batch(
|
|
303
|
+
self, batch: Sequence[Query]
|
|
304
|
+
) -> dict[Query, tuple[list[tuple[str, str]], datetime] | OsvError]:
|
|
305
|
+
"""Un lot de versions, avec les pages suivantes de celles qu'OSV a coupées."""
|
|
306
|
+
url = f"{self.base_url}/v1/querybatch"
|
|
307
|
+
pairs: dict[Query, list[tuple[str, str]]] = {query: [] for query in batch}
|
|
308
|
+
tokens: dict[Query, str] = {}
|
|
309
|
+
todo = list(batch)
|
|
310
|
+
for _ in range(MAX_PAGES):
|
|
311
|
+
body = {"queries": [_query_body(query, tokens.get(query)) for query in todo]}
|
|
312
|
+
try:
|
|
313
|
+
response = await self._http.request("POST", url, json=body, retry=True)
|
|
314
|
+
except NetworkError as error:
|
|
315
|
+
return _failed(batch, f"OSV : {error}")
|
|
316
|
+
if response.status_code != 200:
|
|
317
|
+
return _failed(batch, f"OSV : réponse inattendue (HTTP {response.status_code}).")
|
|
318
|
+
try:
|
|
319
|
+
results = _batch_results(response.json(), len(todo))
|
|
320
|
+
except (ValueError, KeyError, TypeError) as error:
|
|
321
|
+
return _failed(batch, f"OSV : réponse illisible ({error}).")
|
|
322
|
+
pending = []
|
|
323
|
+
for query, (ids, token) in zip(todo, results, strict=True):
|
|
324
|
+
pairs[query].extend(ids)
|
|
325
|
+
if token:
|
|
326
|
+
tokens[query] = token
|
|
327
|
+
pending.append(query)
|
|
328
|
+
todo = pending
|
|
329
|
+
if not todo:
|
|
330
|
+
break
|
|
331
|
+
fetched_at = datetime.now(UTC)
|
|
332
|
+
result: dict[Query, tuple[list[tuple[str, str]], datetime] | OsvError] = {}
|
|
333
|
+
for query in batch:
|
|
334
|
+
if query in todo:
|
|
335
|
+
_, name, version = query
|
|
336
|
+
result[query] = OsvError(
|
|
337
|
+
f"OSV : trop de failles pour {name} {version} (plus de {MAX_PAGES} pages)."
|
|
338
|
+
)
|
|
339
|
+
continue
|
|
340
|
+
found = list(dict.fromkeys(pairs[query]))
|
|
341
|
+
self._cache.set(
|
|
342
|
+
CacheKind.VULNERABILITIES,
|
|
343
|
+
self._query_key(query),
|
|
344
|
+
{"format": _FORMAT, "vulns": [list(pair) for pair in found]},
|
|
345
|
+
)
|
|
346
|
+
result[query] = (found, fetched_at)
|
|
347
|
+
return result
|
|
348
|
+
|
|
349
|
+
# -- vulns/{id} -------------------------------------------------------------
|
|
350
|
+
|
|
351
|
+
async def _fetch_advisory(self, vuln_id: str, modified: str | None) -> Advisory:
|
|
352
|
+
url = f"{self.base_url}/v1/vulns/{quote(vuln_id, safe='')}"
|
|
353
|
+
entry = self._cache.get(CacheKind.ADVISORIES, url, stale_ok=True)
|
|
354
|
+
cached = _from_cache(entry)
|
|
355
|
+
if (
|
|
356
|
+
entry is not None
|
|
357
|
+
and cached is not None
|
|
358
|
+
and (
|
|
359
|
+
self._http.offline
|
|
360
|
+
or (entry.fresh and (modified is None or _same_time(modified, cached.modified)))
|
|
361
|
+
)
|
|
362
|
+
):
|
|
363
|
+
return cached
|
|
364
|
+
if self._http.offline:
|
|
365
|
+
return Advisory(id=vuln_id, unavailable="mode hors ligne, et l'avis n'est pas en cache")
|
|
366
|
+
try:
|
|
367
|
+
response = await self._http.get(url)
|
|
368
|
+
except NetworkError as error:
|
|
369
|
+
# Mieux vaut l'avis d'il y a quelques jours que rien.
|
|
370
|
+
return cached or Advisory(id=vuln_id, unavailable=str(error).rstrip("."))
|
|
371
|
+
if response.status_code != 200:
|
|
372
|
+
reason = f"OSV a répondu HTTP {response.status_code}"
|
|
373
|
+
return cached or Advisory(id=vuln_id, unavailable=reason)
|
|
374
|
+
try:
|
|
375
|
+
raw = response.json()
|
|
376
|
+
except ValueError:
|
|
377
|
+
raw = None
|
|
378
|
+
advisory = parse_vuln(raw)
|
|
379
|
+
if advisory is None:
|
|
380
|
+
return cached or Advisory(id=vuln_id, unavailable="avis illisible")
|
|
381
|
+
self._cache.set(CacheKind.ADVISORIES, url, raw)
|
|
382
|
+
return advisory
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def parse_vuln(raw: Any) -> Advisory | None:
|
|
386
|
+
"""Un avis au format OSV (``GET /v1/vulns/{id}``) ; ``None`` s'il n'a pas d'identifiant.
|
|
387
|
+
|
|
388
|
+
Gravité : le score du vecteur CVSS 3, sinon le score que donne la base, sinon la gravité
|
|
389
|
+
que lui donne GitHub ; ``None`` sinon.
|
|
390
|
+
"""
|
|
391
|
+
if not isinstance(raw, dict):
|
|
392
|
+
return None
|
|
393
|
+
vuln_id = raw.get("id")
|
|
394
|
+
if not isinstance(vuln_id, str) or not vuln_id:
|
|
395
|
+
return None
|
|
396
|
+
aliases = tuple(alias for alias in _list(raw.get("aliases")) if isinstance(alias, str))
|
|
397
|
+
summary = raw.get("summary")
|
|
398
|
+
if not isinstance(summary, str) or not summary.strip():
|
|
399
|
+
details = raw.get("details")
|
|
400
|
+
summary = _shorten(details) if isinstance(details, str) else ""
|
|
401
|
+
database = raw.get("database_specific")
|
|
402
|
+
database = database if isinstance(database, dict) else {}
|
|
403
|
+
cwe_ids = _list(database.get("cwe_ids"))
|
|
404
|
+
severity, score = _severity(raw, database)
|
|
405
|
+
return Advisory(
|
|
406
|
+
id=vuln_id,
|
|
407
|
+
aliases=aliases,
|
|
408
|
+
summary=summary.strip(),
|
|
409
|
+
modified=str(raw.get("modified") or ""),
|
|
410
|
+
severity=severity,
|
|
411
|
+
score=score,
|
|
412
|
+
malicious=(
|
|
413
|
+
any(i.startswith("MAL-") for i in (vuln_id, *aliases)) or _MALICIOUS_CWE in cwe_ids
|
|
414
|
+
),
|
|
415
|
+
withdrawn=bool(raw.get("withdrawn")),
|
|
416
|
+
affected=tuple(
|
|
417
|
+
entry for entry in map(_affected, _list(raw.get("affected"))) if entry is not None
|
|
418
|
+
),
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def _severity(
|
|
423
|
+
raw: dict[str, Any], database: dict[str, Any]
|
|
424
|
+
) -> tuple[Severity | None, float | None]:
|
|
425
|
+
for entry in _list(raw.get("severity")):
|
|
426
|
+
if isinstance(entry, dict) and entry.get("type") == "CVSS_V3":
|
|
427
|
+
score = cvss.base_score(str(entry.get("score", "")))
|
|
428
|
+
if score is not None:
|
|
429
|
+
return cvss.severity(score), score
|
|
430
|
+
info = database.get("cvss")
|
|
431
|
+
if isinstance(info, dict):
|
|
432
|
+
try:
|
|
433
|
+
score = float(info.get("score") or 0)
|
|
434
|
+
except (TypeError, ValueError):
|
|
435
|
+
score = 0.0
|
|
436
|
+
if 0 < score <= 10:
|
|
437
|
+
return cvss.severity(score), score
|
|
438
|
+
text = database.get("severity")
|
|
439
|
+
if isinstance(text, str) and text.upper() in _GITHUB_SEVERITIES:
|
|
440
|
+
return _GITHUB_SEVERITIES[text.upper()], None
|
|
441
|
+
return None, None
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def _affected(raw: Any) -> Affected | None:
|
|
445
|
+
if not isinstance(raw, dict):
|
|
446
|
+
return None
|
|
447
|
+
package = raw.get("package")
|
|
448
|
+
if not isinstance(package, dict):
|
|
449
|
+
return None
|
|
450
|
+
ecosystem, name = package.get("ecosystem"), package.get("name")
|
|
451
|
+
if not isinstance(ecosystem, str) or not isinstance(name, str):
|
|
452
|
+
return None
|
|
453
|
+
ranges = []
|
|
454
|
+
for item in _list(raw.get("ranges")):
|
|
455
|
+
if not isinstance(item, dict) or item.get("type") not in _RANGE_TYPES:
|
|
456
|
+
continue
|
|
457
|
+
events = tuple(
|
|
458
|
+
(kind, value)
|
|
459
|
+
for event in _list(item.get("events"))
|
|
460
|
+
if isinstance(event, dict)
|
|
461
|
+
for kind, value in event.items()
|
|
462
|
+
if kind in _EVENTS and isinstance(value, str)
|
|
463
|
+
)
|
|
464
|
+
ranges.append(AffectedRange(type=item["type"], events=events))
|
|
465
|
+
return Affected(
|
|
466
|
+
ecosystem=ecosystem,
|
|
467
|
+
name=name,
|
|
468
|
+
ranges=tuple(ranges),
|
|
469
|
+
versions=tuple(v for v in _list(raw.get("versions")) if isinstance(v, str)),
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def _shorten(details: str) -> str:
|
|
474
|
+
"""Un titre tiré de la description d'un avis qui n'en a pas (les PYSEC) : sa première
|
|
475
|
+
ligne, coupée entre deux mots au-delà de ``_SUMMARY_LENGTH`` caractères."""
|
|
476
|
+
line = details.strip().split("\n", 1)[0].strip()
|
|
477
|
+
if len(line) <= _SUMMARY_LENGTH:
|
|
478
|
+
return line
|
|
479
|
+
cut = line[:_SUMMARY_LENGTH].rsplit(" ", 1)[0].rstrip(" ,;:.")
|
|
480
|
+
return f"{cut}…"
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def _list(value: Any) -> list[Any]:
|
|
484
|
+
return value if isinstance(value, list) else []
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def _same_time(first: str, second: str) -> bool:
|
|
488
|
+
"""Vrai si deux dates de modification d'OSV sont les mêmes, à la microseconde :
|
|
489
|
+
``querybatch`` les arrondit (``…38.085813Z``), ``vulns/{id}`` donne les nanosecondes
|
|
490
|
+
(``…38.085813876Z``)."""
|
|
491
|
+
|
|
492
|
+
def moment(text: str) -> datetime | str:
|
|
493
|
+
try:
|
|
494
|
+
return datetime.fromisoformat(_BEYOND_MICROSECONDS.sub(r"\1", text))
|
|
495
|
+
except ValueError:
|
|
496
|
+
return text
|
|
497
|
+
|
|
498
|
+
return moment(first) == moment(second)
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def _same_name(ecosystem: Ecosystem, first: str, second: str) -> bool:
|
|
502
|
+
if ecosystem is Ecosystem.PYPI:
|
|
503
|
+
return canonicalize_name(first) == canonicalize_name(second)
|
|
504
|
+
return first == second
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def _concerned(scheme: VersionScheme[Any], entries: Iterable[Affected], version: Any) -> bool:
|
|
508
|
+
"""Vrai si ``version`` (déjà lue) est dans une des plages ou des versions de ``entries``."""
|
|
509
|
+
for entry in entries:
|
|
510
|
+
if any(_in_range(scheme, r, version) for r in entry.ranges):
|
|
511
|
+
return True
|
|
512
|
+
for listed in entry.versions:
|
|
513
|
+
try:
|
|
514
|
+
if scheme.parse(listed) == version:
|
|
515
|
+
return True
|
|
516
|
+
except InvalidVersion:
|
|
517
|
+
continue
|
|
518
|
+
return False
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def _in_range(scheme: VersionScheme[Any], affected_range: AffectedRange, version: Any) -> bool:
|
|
522
|
+
"""L'algorithme de la spécification d'OSV : les événements triés par version, le dernier
|
|
523
|
+
franchi décide. Un événement dont la version est illisible est ignoré."""
|
|
524
|
+
events: list[tuple[Any, str]] = []
|
|
525
|
+
for kind, value in affected_range.events:
|
|
526
|
+
if kind == "introduced" and value == "0":
|
|
527
|
+
events.append((None, kind))
|
|
528
|
+
continue
|
|
529
|
+
try:
|
|
530
|
+
events.append((scheme.parse(value), kind))
|
|
531
|
+
except InvalidVersion:
|
|
532
|
+
continue
|
|
533
|
+
events.sort(key=lambda event: (event[0] is not None, event[0]))
|
|
534
|
+
concerned = False
|
|
535
|
+
for bound, kind in events:
|
|
536
|
+
if kind == "introduced":
|
|
537
|
+
if bound is None or not version < bound:
|
|
538
|
+
concerned = True
|
|
539
|
+
elif kind in ("fixed", "limit"):
|
|
540
|
+
if not version < bound:
|
|
541
|
+
concerned = False
|
|
542
|
+
elif bound < version: # last_affected
|
|
543
|
+
concerned = False
|
|
544
|
+
return concerned
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def _query_body(query: Query, page_token: str | None) -> dict[str, Any]:
|
|
548
|
+
ecosystem, name, version = query
|
|
549
|
+
body: dict[str, Any] = {
|
|
550
|
+
"package": {"ecosystem": ecosystem.value, "name": name},
|
|
551
|
+
"version": version,
|
|
552
|
+
}
|
|
553
|
+
if page_token:
|
|
554
|
+
body["page_token"] = page_token
|
|
555
|
+
return body
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def _batch_results(payload: Any, count: int) -> list[tuple[list[tuple[str, str]], str | None]]:
|
|
559
|
+
"""Les résultats d'une réponse ``querybatch``, un par version demandée, dans l'ordre."""
|
|
560
|
+
results = payload["results"]
|
|
561
|
+
if not isinstance(results, list) or len(results) != count:
|
|
562
|
+
size = len(results) if isinstance(results, list) else "pas de liste de"
|
|
563
|
+
raise ValueError(f"{size} résultats pour {count} versions")
|
|
564
|
+
found = []
|
|
565
|
+
for item in results:
|
|
566
|
+
if not isinstance(item, dict):
|
|
567
|
+
raise TypeError("un résultat n'est pas un objet")
|
|
568
|
+
ids = [(str(v["id"]), str(v.get("modified") or "")) for v in _list(item.get("vulns"))]
|
|
569
|
+
token = item.get("next_page_token")
|
|
570
|
+
found.append((ids, token if isinstance(token, str) and token else None))
|
|
571
|
+
return found
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def _failed(batch: Sequence[Query], message: str) -> dict[Query, Any]:
|
|
575
|
+
error = OsvError(message)
|
|
576
|
+
return dict.fromkeys(batch, error)
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def _from_cache(entry: CacheEntry | None) -> Advisory | None:
|
|
580
|
+
"""L'avis rangé dans le cache (le document d'OSV tel quel), relu ; ``None`` sans entrée."""
|
|
581
|
+
if entry is None:
|
|
582
|
+
return None
|
|
583
|
+
advisory = parse_vuln(entry.value)
|
|
584
|
+
return None if advisory is None or advisory.unavailable else advisory
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Les contrôles : les constats d'une dépendance, d'après ce que dit son registre.
|
|
2
|
+
|
|
3
|
+
- ``base.py`` : la version actuelle (brief, décision n°2), la plus proche version de
|
|
4
|
+
remplacement ;
|
|
5
|
+
- ``outdated.py`` : une version plus récente existe (type 1) ;
|
|
6
|
+
- ``deprecated.py`` : paquet ou version déprécié, version retirée (type 4) ;
|
|
7
|
+
- ``vulns.py`` : la version utilisée a une faille connue (type 2) ;
|
|
8
|
+
- ``priority.py`` : l'ordre des constats (décision n°8) et le seuil de ``--fail-on``.
|
|
9
|
+
|
|
10
|
+
Les fins de vie (``eol.py``, type 3) viennent à E27. Les contrôles ne parlent pas au réseau :
|
|
11
|
+
le moteur leur donne le ``PackageInfo`` du registre, et ce qu'OSV sait des failles.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from forceupdate.checks import deprecated, outdated
|
|
15
|
+
from forceupdate.checks.base import GAP_LABELS, Current, current_version
|
|
16
|
+
from forceupdate.checks.priority import FailOn, fails, priority, reaches, sort_findings
|
|
17
|
+
from forceupdate.models import Dependency, Finding, ScanOptions, Scope
|
|
18
|
+
from forceupdate.registries import PackageInfo
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"GAP_LABELS",
|
|
22
|
+
"Current",
|
|
23
|
+
"FailOn",
|
|
24
|
+
"check_dependency",
|
|
25
|
+
"current_version",
|
|
26
|
+
"fails",
|
|
27
|
+
"priority",
|
|
28
|
+
"reaches",
|
|
29
|
+
"selected",
|
|
30
|
+
"sort_findings",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def selected(dependency: Dependency, options: ScanOptions) -> bool:
|
|
35
|
+
"""Vrai si l'analyse vérifie ``dependency`` : une directe, ou une indirecte avec
|
|
36
|
+
``options.transitive`` ; de développement seulement avec ``options.dev``."""
|
|
37
|
+
return (dependency.direct or options.transitive) and (
|
|
38
|
+
options.dev or dependency.scope is not Scope.DEV
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def check_dependency(dependency: Dependency, info: PackageInfo) -> list[Finding]:
|
|
43
|
+
"""Les constats de ``dependency``, vérifiable, d'après ``info``, ce que son registre en
|
|
44
|
+
dit : déprécié, retiré, plus récente. Une liste vide si elle est à jour."""
|
|
45
|
+
current = current_version(dependency)
|
|
46
|
+
return [
|
|
47
|
+
*deprecated.check(dependency, info, current),
|
|
48
|
+
*outdated.check(dependency, info, current),
|
|
49
|
+
]
|