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.
Files changed (50) hide show
  1. forceupdate/__init__.py +9 -0
  2. forceupdate/__main__.py +5 -0
  3. forceupdate/advisories/__init__.py +30 -0
  4. forceupdate/advisories/cvss.py +95 -0
  5. forceupdate/advisories/osv.py +584 -0
  6. forceupdate/checks/__init__.py +49 -0
  7. forceupdate/checks/base.py +98 -0
  8. forceupdate/checks/deprecated.py +115 -0
  9. forceupdate/checks/outdated.py +183 -0
  10. forceupdate/checks/priority.py +82 -0
  11. forceupdate/checks/vulns.py +201 -0
  12. forceupdate/cli.py +309 -0
  13. forceupdate/detect/__init__.py +5 -0
  14. forceupdate/detect/languages.py +266 -0
  15. forceupdate/detect/manifests.py +478 -0
  16. forceupdate/engine.py +313 -0
  17. forceupdate/models.py +510 -0
  18. forceupdate/net/__init__.py +20 -0
  19. forceupdate/net/cache.py +217 -0
  20. forceupdate/net/http.py +270 -0
  21. forceupdate/parsers/__init__.py +43 -0
  22. forceupdate/parsers/base.py +22 -0
  23. forceupdate/parsers/json_spans.py +123 -0
  24. forceupdate/parsers/lockfiles.py +231 -0
  25. forceupdate/parsers/npm.py +1257 -0
  26. forceupdate/parsers/python.py +1421 -0
  27. forceupdate/parsers/toml_spans.py +297 -0
  28. forceupdate/registries/__init__.py +52 -0
  29. forceupdate/registries/base.py +294 -0
  30. forceupdate/registries/npm.py +153 -0
  31. forceupdate/registries/pypi.py +242 -0
  32. forceupdate/report/__init__.py +359 -0
  33. forceupdate/report/json.py +34 -0
  34. forceupdate/report/markdown.py +169 -0
  35. forceupdate/report/terminal.py +376 -0
  36. forceupdate/sources/__init__.py +1 -0
  37. forceupdate/sources/base.py +45 -0
  38. forceupdate/sources/local.py +183 -0
  39. forceupdate/update/__init__.py +8 -0
  40. forceupdate/update/constraints.py +317 -0
  41. forceupdate/update/planner.py +531 -0
  42. forceupdate/versions/__init__.py +40 -0
  43. forceupdate/versions/base.py +229 -0
  44. forceupdate/versions/npm.py +432 -0
  45. forceupdate/versions/pypi.py +220 -0
  46. forceupdate-0.1.0.dist-info/METADATA +79 -0
  47. forceupdate-0.1.0.dist-info/RECORD +50 -0
  48. forceupdate-0.1.0.dist-info/WHEEL +4 -0
  49. forceupdate-0.1.0.dist-info/entry_points.txt +2 -0
  50. 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
+ ]