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,531 @@
|
|
|
1
|
+
"""Le planificateur : pour chaque paquet à mettre à jour, les versions possibles, ce qu'il faut
|
|
2
|
+
toucher, et ce qui est pré-coché. Il n'écrit rien : la réécriture des manifestes est E10, celle
|
|
3
|
+
des lockfiles E11.
|
|
4
|
+
|
|
5
|
+
**Les versions proposées** (``UpdateOption``), pour un paquet qui a un constat :
|
|
6
|
+
|
|
7
|
+
- le **dernier correctif** et la **dernière mineure** de la version utilisée, sans dépasser la
|
|
8
|
+
dernière version du constat « plus récente » ; en 0.x, ``gap`` (E05) classe déjà en majeure
|
|
9
|
+
ce qui sort de ``^`` (``0.2.3`` → ``0.3.0``) ;
|
|
10
|
+
- la **dernière majeure** : la cible du constat « plus récente » quand elle est majeure. Pas
|
|
11
|
+
pour une indirecte, qui suit le paquet qui la tire ;
|
|
12
|
+
- la **correction des failles** : la plus petite version publiée qui les corrige toutes (au
|
|
13
|
+
moins la plus haute des versions corrigées, et qu'aucun des avis ne concerne plus : Flask
|
|
14
|
+
2.3.0 est au-dessus de 2.2.5 mais concernée), ni retirée ni dépréciée. lodash 4.17.15 :
|
|
15
|
+
4.18.0 corrige tout, mais est dépréciée (« Bad release ») ; c'est 4.18.1. Une faille sans
|
|
16
|
+
version corrigée (paquet malveillant, ``last_affected`` seul) n'est corrigée par aucune ;
|
|
17
|
+
- le **remplacement** d'une version retirée ou dépréciée (``checks.deprecated``), parfois en
|
|
18
|
+
dessous de la version utilisée.
|
|
19
|
+
|
|
20
|
+
Comme ``npm-pick-manifest`` (E07), les versions dépréciées ne sont proposées que faute
|
|
21
|
+
d'autre ; les retirées jamais ; les pré-versions seulement de la même version qu'une version
|
|
22
|
+
utilisée en pré-version. Une même version proposée pour plusieurs raisons ne fait qu'une
|
|
23
|
+
option.
|
|
24
|
+
|
|
25
|
+
**Ce qu'il faut toucher** :
|
|
26
|
+
|
|
27
|
+
- la version visée sort de la contrainte : la contrainte est réécrite dans son style
|
|
28
|
+
(``update.constraints``), puis le lockfile régénéré ;
|
|
29
|
+
- elle y est déjà : le lockfile seul. Sans lockfile, la version utilisée est la borne basse
|
|
30
|
+
(« approximative ») : on relève la borne basse ;
|
|
31
|
+
- une indirecte : le lockfile seul (``uv lock --upgrade-package x``, ``npm update x``), sauf
|
|
32
|
+
si une version forcée (``overrides``, ``resolutions``, ``pnpm.overrides``) la fige et
|
|
33
|
+
n'accepte pas la version visée : c'est elle qu'on réécrit ;
|
|
34
|
+
- une dépendance pair (npm) : sa plage s'élargit au lieu d'être remplacée.
|
|
35
|
+
|
|
36
|
+
Pour une faille, la nouvelle contrainte n'accepte plus de version concernée (``floor``).
|
|
37
|
+
|
|
38
|
+
**La sélection par défaut** (brief, décision n°12) : correctifs, mineures et corrections de
|
|
39
|
+
failles pré-cochés ; majeures proposées, jamais cochées d'office (« Majeure : peut demander
|
|
40
|
+
d'adapter le code. »). Une faille corrigée seulement par une majeure : proposée, pas cochée,
|
|
41
|
+
avec les deux mentions. Pour un paquet, l'option pré-cochée est, parmi celles qui ne sont pas
|
|
42
|
+
majeures, celle qui corrige le plus de failles, puis la plus récente.
|
|
43
|
+
|
|
44
|
+
**La sélection est une donnée** (``Selection``) : l'identifiant de chaque ``Update`` choisie et
|
|
45
|
+
sa version. ``select`` la calcule selon des règles (``Rules`` : les options de ``forceupdate
|
|
46
|
+
update``) ; l'interface la modifiera case par case.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
import dataclasses
|
|
50
|
+
from collections.abc import Iterable, Mapping, Sequence
|
|
51
|
+
from dataclasses import dataclass
|
|
52
|
+
from fnmatch import fnmatchcase
|
|
53
|
+
|
|
54
|
+
from forceupdate import versions
|
|
55
|
+
from forceupdate.advisories import Advisory, Query, Vulnerabilities
|
|
56
|
+
from forceupdate.checks.base import Current, current_version
|
|
57
|
+
from forceupdate.models import (
|
|
58
|
+
Dependency,
|
|
59
|
+
Ecosystem,
|
|
60
|
+
Edit,
|
|
61
|
+
EditKind,
|
|
62
|
+
Finding,
|
|
63
|
+
FindingKind,
|
|
64
|
+
Gap,
|
|
65
|
+
Override,
|
|
66
|
+
Project,
|
|
67
|
+
Report,
|
|
68
|
+
Scope,
|
|
69
|
+
Update,
|
|
70
|
+
UpdateOption,
|
|
71
|
+
UpdateReason,
|
|
72
|
+
)
|
|
73
|
+
from forceupdate.parsers.lockfiles import dirname
|
|
74
|
+
from forceupdate.registries import PackageInfo
|
|
75
|
+
from forceupdate.update.constraints import rewrite
|
|
76
|
+
from forceupdate.versions import InvalidConstraint, InvalidVersion, VersionScheme
|
|
77
|
+
|
|
78
|
+
MAJOR_WARNING = "Majeure : peut demander d'adapter le code."
|
|
79
|
+
DOWNGRADE_WARNING = "Revient à une version plus ancienne."
|
|
80
|
+
INDIRECT_MAJOR_WARNING = (
|
|
81
|
+
"Dépendance indirecte : le paquet qui la tire devra peut-être monter aussi."
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
Selection = dict[str, str]
|
|
85
|
+
"""Les mises à jour choisies : l'identifiant d'une ``Update``, et la version choisie parmi ses
|
|
86
|
+
options. Un paquet absent n'est pas mis à jour."""
|
|
87
|
+
|
|
88
|
+
_Operations = tuple[list[Edit], str | None, str | None]
|
|
89
|
+
"""Ce qu'une option touche : les contraintes à réécrire, le lockfile à régénérer, et pourquoi
|
|
90
|
+
il faut le faire à la main s'il le faut."""
|
|
91
|
+
|
|
92
|
+
_ACTIONABLE = frozenset(
|
|
93
|
+
{FindingKind.VULNERABLE, FindingKind.OUTDATED, FindingKind.YANKED, FindingKind.DEPRECATED}
|
|
94
|
+
)
|
|
95
|
+
"""Les constats qui peuvent donner une mise à jour."""
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass(frozen=True, kw_only=True)
|
|
99
|
+
class Rules:
|
|
100
|
+
"""Ce que garde une sélection : les options de ``forceupdate update``."""
|
|
101
|
+
|
|
102
|
+
level: Gap = Gap.MINOR
|
|
103
|
+
"""L'écart le plus grand accepté (``--level``) : ``minor`` par défaut, la décision n°12."""
|
|
104
|
+
security_only: bool = False
|
|
105
|
+
"""Seulement les corrections de failles (``--security-only``) : pour chaque paquet, la plus
|
|
106
|
+
petite version qui corrige le plus de failles."""
|
|
107
|
+
include: tuple[str, ...] = ()
|
|
108
|
+
"""Motifs de noms de paquets à garder (``--include``, ``*`` et ``?`` permis) ; tous si
|
|
109
|
+
vide."""
|
|
110
|
+
exclude: tuple[str, ...] = ()
|
|
111
|
+
"""Motifs de noms de paquets à écarter (``--exclude``)."""
|
|
112
|
+
ecosystems: frozenset[Ecosystem] = frozenset()
|
|
113
|
+
"""Les écosystèmes à garder (``--ecosystem``) ; tous si vide."""
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
DEFAULT_RULES = Rules()
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def plan(
|
|
120
|
+
report: Report,
|
|
121
|
+
infos: Mapping[tuple[Ecosystem, str], PackageInfo],
|
|
122
|
+
vulnerabilities: Mapping[Query, Vulnerabilities],
|
|
123
|
+
) -> None:
|
|
124
|
+
"""Remplit ``updates`` de chaque projet de ``report``, d'après ses constats (déjà triés),
|
|
125
|
+
ce que les registres disent des paquets (``infos``, par écosystème et nom de dépendance) et
|
|
126
|
+
les failles qu'OSV connaît de chaque version (``vulnerabilities``)."""
|
|
127
|
+
overrides = {project.path: project.overrides for project in report.projects}
|
|
128
|
+
for project in report.projects:
|
|
129
|
+
project.updates = _plan_project(project, infos, vulnerabilities, overrides)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def select(report: Report, rules: Rules = DEFAULT_RULES) -> Selection:
|
|
133
|
+
"""La sélection selon ``rules`` : pour chaque mise à jour possible, la version à cocher, s'il
|
|
134
|
+
y en a une. Avec les règles par défaut, ce sont les versions pré-cochées
|
|
135
|
+
(``Update.selected``)."""
|
|
136
|
+
selection = {}
|
|
137
|
+
for project in report.projects:
|
|
138
|
+
for update in project.updates:
|
|
139
|
+
version = choose(update, rules)
|
|
140
|
+
if version is not None:
|
|
141
|
+
selection[update.id] = version
|
|
142
|
+
return selection
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def chosen(report: Report, selection: Selection) -> list[tuple[Project, Update, UpdateOption]]:
|
|
146
|
+
"""Les options choisies par ``selection``, projet par projet. Lève ``ValueError`` pour un
|
|
147
|
+
identifiant ou une version qui n'existent pas dans le rapport."""
|
|
148
|
+
found = []
|
|
149
|
+
known = set()
|
|
150
|
+
for project in report.projects:
|
|
151
|
+
for update in project.updates:
|
|
152
|
+
known.add(update.id)
|
|
153
|
+
version = selection.get(update.id)
|
|
154
|
+
if version is None:
|
|
155
|
+
continue
|
|
156
|
+
option = update.option(version)
|
|
157
|
+
if option is None:
|
|
158
|
+
raise ValueError(f"{update.name} : la version {version} n'est pas proposée.")
|
|
159
|
+
found.append((project, update, option))
|
|
160
|
+
unknown = sorted(set(selection) - known)
|
|
161
|
+
if unknown:
|
|
162
|
+
raise ValueError(f"mise à jour inconnue : {unknown[0]}")
|
|
163
|
+
return found
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def choose(update: Update, rules: Rules = DEFAULT_RULES) -> str | None:
|
|
167
|
+
"""La version que ``rules`` cochent pour ``update`` ; ``None`` pour aucune."""
|
|
168
|
+
if not _kept(update, rules):
|
|
169
|
+
return None
|
|
170
|
+
scheme = versions.for_ecosystem(update.ecosystem)
|
|
171
|
+
eligible = [
|
|
172
|
+
option
|
|
173
|
+
for option in update.options
|
|
174
|
+
if option.gap is not None and option.gap.rank <= rules.level.rank and option.manual is None
|
|
175
|
+
]
|
|
176
|
+
if rules.security_only:
|
|
177
|
+
eligible = [option for option in eligible if option.fixes]
|
|
178
|
+
if not eligible:
|
|
179
|
+
return None
|
|
180
|
+
most = max(len(option.fixes) for option in eligible)
|
|
181
|
+
best = [option for option in eligible if len(option.fixes) == most]
|
|
182
|
+
return min(best, key=lambda option: scheme.sort_key(option.version)).version
|
|
183
|
+
if not eligible:
|
|
184
|
+
return None
|
|
185
|
+
return max(eligible, key=lambda o: (len(o.fixes), scheme.sort_key(o.version))).version
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _kept(update: Update, rules: Rules) -> bool:
|
|
189
|
+
if rules.ecosystems and update.ecosystem not in rules.ecosystems:
|
|
190
|
+
return False
|
|
191
|
+
name = update.name.lower()
|
|
192
|
+
if rules.include and not any(fnmatchcase(name, p.lower()) for p in rules.include):
|
|
193
|
+
return False
|
|
194
|
+
return not any(fnmatchcase(name, p.lower()) for p in rules.exclude)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
# ---------------------------------------------------------------------------
|
|
198
|
+
# Un projet
|
|
199
|
+
# ---------------------------------------------------------------------------
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _plan_project(
|
|
203
|
+
project: Project,
|
|
204
|
+
infos: Mapping[tuple[Ecosystem, str], PackageInfo],
|
|
205
|
+
vulnerabilities: Mapping[Query, Vulnerabilities],
|
|
206
|
+
overrides: Mapping[str, list[Override]],
|
|
207
|
+
) -> list[Update]:
|
|
208
|
+
groups: dict[tuple[str, ...], list[Dependency]] = {}
|
|
209
|
+
for dependency in project.dependencies:
|
|
210
|
+
if dependency.verifiable:
|
|
211
|
+
groups.setdefault(_group_key(dependency), []).append(dependency)
|
|
212
|
+
planned: dict[tuple[str, ...], Update | None] = {}
|
|
213
|
+
for finding in project.findings: # du plus grave au moins grave
|
|
214
|
+
dependency = finding.dependency
|
|
215
|
+
if dependency is None or finding.kind not in _ACTIONABLE:
|
|
216
|
+
continue
|
|
217
|
+
key = _group_key(dependency)
|
|
218
|
+
if key in planned or key not in groups:
|
|
219
|
+
continue
|
|
220
|
+
members = groups[key]
|
|
221
|
+
planned[key] = _Group(
|
|
222
|
+
project=project,
|
|
223
|
+
dependencies=members,
|
|
224
|
+
findings=[f for f in project.findings if f.dependency in members],
|
|
225
|
+
info=infos.get((dependency.ecosystem, dependency.name)),
|
|
226
|
+
vulnerabilities=vulnerabilities,
|
|
227
|
+
overrides=overrides.get(dirname(dependency.file), []) if not dependency.direct else [],
|
|
228
|
+
).plan()
|
|
229
|
+
return [update for update in planned.values() if update is not None]
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _group_key(dependency: Dependency) -> tuple[str, ...]:
|
|
233
|
+
"""Une directe : toutes ses déclarations ensemble. Une indirecte : une version installée."""
|
|
234
|
+
key = (dependency.ecosystem.value, dependency.name)
|
|
235
|
+
return key if dependency.direct else (*key, dependency.installed or "")
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
class _Group:
|
|
239
|
+
"""Un paquet d'un projet, avec ses déclarations et ses constats."""
|
|
240
|
+
|
|
241
|
+
def __init__(
|
|
242
|
+
self,
|
|
243
|
+
*,
|
|
244
|
+
project: Project,
|
|
245
|
+
dependencies: list[Dependency],
|
|
246
|
+
findings: list[Finding],
|
|
247
|
+
info: PackageInfo | None,
|
|
248
|
+
vulnerabilities: Mapping[Query, Vulnerabilities],
|
|
249
|
+
overrides: Sequence[Override],
|
|
250
|
+
) -> None:
|
|
251
|
+
self.project = project
|
|
252
|
+
self.dependencies = dependencies
|
|
253
|
+
self.lead = dependencies[0]
|
|
254
|
+
self.ecosystem = self.lead.ecosystem
|
|
255
|
+
self.scheme: VersionScheme = versions.for_ecosystem(self.ecosystem)
|
|
256
|
+
self.findings = findings
|
|
257
|
+
self.info = info
|
|
258
|
+
self.overrides = overrides
|
|
259
|
+
self.currents = [current_version(d) for d in dependencies]
|
|
260
|
+
self.vulns = [f for f in findings if f.kind is FindingKind.VULNERABLE]
|
|
261
|
+
self.fixable = [f for f in self.vulns if f.target_version is not None]
|
|
262
|
+
self.advisories = _advisories(dependencies, self.currents, vulnerabilities)
|
|
263
|
+
|
|
264
|
+
def plan(self) -> Update | None:
|
|
265
|
+
current = self._current()
|
|
266
|
+
if current is None:
|
|
267
|
+
return None
|
|
268
|
+
assert current.version is not None
|
|
269
|
+
update_id = "|".join((self.project.path, *_group_key(self.lead)))
|
|
270
|
+
base = {
|
|
271
|
+
"id": update_id,
|
|
272
|
+
"ecosystem": self.ecosystem,
|
|
273
|
+
"name": self.lead.name,
|
|
274
|
+
"direct": self.lead.direct,
|
|
275
|
+
"dependencies": tuple(self.dependencies),
|
|
276
|
+
"current_version": current.version,
|
|
277
|
+
"current_is_approximate": current.approximate,
|
|
278
|
+
}
|
|
279
|
+
if self.info is None:
|
|
280
|
+
if not self.vulns:
|
|
281
|
+
return None
|
|
282
|
+
note = "Versions publiées inconnues du registre : mise à jour non calculée."
|
|
283
|
+
return Update(**base, note=note)
|
|
284
|
+
reasons = self._versions(current.version)
|
|
285
|
+
options = []
|
|
286
|
+
for version in sorted(reasons, key=self.scheme.sort_key):
|
|
287
|
+
option = self._option(current.version, version, reasons[version])
|
|
288
|
+
if option is not None:
|
|
289
|
+
options.append(option)
|
|
290
|
+
note = self._note(reasons)
|
|
291
|
+
if not options and note is None:
|
|
292
|
+
return None
|
|
293
|
+
update = Update(**base, options=tuple(options), note=note)
|
|
294
|
+
return dataclasses.replace(update, selected=choose(update))
|
|
295
|
+
|
|
296
|
+
def _current(self) -> Current | None:
|
|
297
|
+
"""La version utilisée du paquet : la plus basse de ses déclarations (elles ont la même
|
|
298
|
+
version installée ; sans lockfile, leurs bornes basses peuvent différer)."""
|
|
299
|
+
known = [c for c in self.currents if c.version is not None]
|
|
300
|
+
if not known:
|
|
301
|
+
return None
|
|
302
|
+
try:
|
|
303
|
+
return min(known, key=lambda c: self.scheme.parse(c.version or ""))
|
|
304
|
+
except InvalidVersion: # outdated l'a déjà dit
|
|
305
|
+
return None
|
|
306
|
+
|
|
307
|
+
# -- les versions --------------------------------------------------------
|
|
308
|
+
|
|
309
|
+
def _versions(self, current: str) -> dict[str, list[UpdateReason]]:
|
|
310
|
+
"""Les versions à proposer, avec leurs raisons."""
|
|
311
|
+
assert self.info is not None
|
|
312
|
+
found: dict[str, list[UpdateReason]] = {}
|
|
313
|
+
|
|
314
|
+
def add(version: str | None, reason: UpdateReason) -> None:
|
|
315
|
+
if version is not None:
|
|
316
|
+
found.setdefault(version, []).append(reason)
|
|
317
|
+
|
|
318
|
+
fresh = [v.version for v in self.info.versions if not v.yanked and v.deprecated is None]
|
|
319
|
+
pool = sorted(self.scheme.candidates(fresh, current), key=self.scheme.sort_key)
|
|
320
|
+
above = [v for v in pool if self.scheme.compare(v, current) > 0]
|
|
321
|
+
outdated = next((f for f in self.findings if f.kind is FindingKind.OUTDATED), None)
|
|
322
|
+
ceiling = None if outdated is None else outdated.target_version
|
|
323
|
+
if ceiling is None and not self.lead.direct and self.vulns:
|
|
324
|
+
# Une indirecte en faille, vérifiée sans --transitive : son dernier correctif et sa
|
|
325
|
+
# dernière mineure, s'ils corrigent au moins une faille sans changer de majeure.
|
|
326
|
+
ceiling = self.info.latest
|
|
327
|
+
if ceiling is not None:
|
|
328
|
+
line = [
|
|
329
|
+
v
|
|
330
|
+
for v in above
|
|
331
|
+
if self.scheme.compare(v, ceiling) <= 0
|
|
332
|
+
and self.scheme.gap(current, v) is not Gap.MAJOR
|
|
333
|
+
]
|
|
334
|
+
if outdated is None:
|
|
335
|
+
line = [v for v in line if any(not self._affected(f, v) for f in self.fixable)]
|
|
336
|
+
patches = [v for v in line if self.scheme.gap(current, v) is Gap.PATCH]
|
|
337
|
+
minors = [v for v in line if self.scheme.gap(current, v) is Gap.MINOR]
|
|
338
|
+
add(patches[-1] if patches else None, UpdateReason.LATEST_PATCH)
|
|
339
|
+
add(minors[-1] if minors else None, UpdateReason.LATEST_MINOR)
|
|
340
|
+
if self.lead.direct and outdated is not None and outdated.gap is Gap.MAJOR:
|
|
341
|
+
add(ceiling, UpdateReason.LATEST_MAJOR)
|
|
342
|
+
add(self._security_fix(current, pool), UpdateReason.SECURITY_FIX)
|
|
343
|
+
for finding in self.findings:
|
|
344
|
+
if finding.kind in (FindingKind.YANKED, FindingKind.DEPRECATED):
|
|
345
|
+
add(finding.target_version, UpdateReason.REPLACEMENT)
|
|
346
|
+
return found
|
|
347
|
+
|
|
348
|
+
def _security_fix(self, current: str, pool: list[str]) -> str | None:
|
|
349
|
+
"""La plus petite version qui corrige toutes les failles qui peuvent l'être : au moins
|
|
350
|
+
la plus haute des versions corrigées, qu'aucun avis ne concerne plus ; non dépréciée
|
|
351
|
+
s'il y en a une, sinon dépréciée ; jamais retirée."""
|
|
352
|
+
if not self.fixable:
|
|
353
|
+
return None
|
|
354
|
+
assert self.info is not None
|
|
355
|
+
floor = max((f.target_version or "" for f in self.fixable), key=self.scheme.sort_key)
|
|
356
|
+
available = sorted(
|
|
357
|
+
self.scheme.candidates(self.info.available(), current), key=self.scheme.sort_key
|
|
358
|
+
)
|
|
359
|
+
for choices in (pool, available):
|
|
360
|
+
for version in choices:
|
|
361
|
+
if self.scheme.compare(version, floor) >= 0 and not any(
|
|
362
|
+
self._affected(f, version) for f in self.fixable
|
|
363
|
+
):
|
|
364
|
+
return version
|
|
365
|
+
return None
|
|
366
|
+
|
|
367
|
+
def _affected(self, finding: Finding, version: str) -> bool:
|
|
368
|
+
"""Vrai si la faille ``finding`` concerne encore ``version``, d'après ses avis ; faute
|
|
369
|
+
d'avis lisible, d'après sa version corrigée."""
|
|
370
|
+
mine = [a for a in self._advisories_of(finding) if a.unavailable is None]
|
|
371
|
+
if mine:
|
|
372
|
+
return any(a.affects(self.ecosystem, self.lead.name, version) for a in mine)
|
|
373
|
+
fix = finding.target_version
|
|
374
|
+
return fix is None or self.scheme.compare(version, fix) < 0
|
|
375
|
+
|
|
376
|
+
def _advisories_of(self, finding: Finding) -> list[Advisory]:
|
|
377
|
+
return [a for a in self.advisories if a.id in finding.identifiers]
|
|
378
|
+
|
|
379
|
+
def _note(self, reasons: Mapping[str, list[UpdateReason]]) -> str | None:
|
|
380
|
+
unfixable = [f for f in self.vulns if f.target_version is None]
|
|
381
|
+
if unfixable:
|
|
382
|
+
ids = ", ".join(_main_id(f) for f in unfixable)
|
|
383
|
+
malicious = any(a.malicious for f in unfixable for a in self._advisories_of(f))
|
|
384
|
+
if malicious:
|
|
385
|
+
return f"Code malveillant ({ids}) : retirer ce paquet, aucune version sûre connue."
|
|
386
|
+
return f"Aucune version corrigée connue pour {ids}."
|
|
387
|
+
if self.fixable and not any(UpdateReason.SECURITY_FIX in r for r in reasons.values()):
|
|
388
|
+
return "Aucune version publiée ne corrige toutes ses failles."
|
|
389
|
+
return None
|
|
390
|
+
|
|
391
|
+
# -- une option -----------------------------------------------------------
|
|
392
|
+
|
|
393
|
+
def _option(
|
|
394
|
+
self, current: str, version: str, reasons: list[UpdateReason]
|
|
395
|
+
) -> UpdateOption | None:
|
|
396
|
+
assert self.info is not None
|
|
397
|
+
gap = self.scheme.gap(current, version)
|
|
398
|
+
if gap is None and self.scheme.compare(version, current) == 0:
|
|
399
|
+
return None
|
|
400
|
+
fixes = tuple(_main_id(f) for f in self.fixable if not self._affected(f, version))
|
|
401
|
+
unfixed = tuple(_main_id(f) for f in self.vulns if _main_id(f) not in fixes)
|
|
402
|
+
if self.lead.direct:
|
|
403
|
+
edits, lockfile, manual = self._direct_edits(version, floor=bool(fixes))
|
|
404
|
+
else:
|
|
405
|
+
edits, lockfile, manual = self._indirect_edits(version, floor=bool(fixes))
|
|
406
|
+
if not edits and lockfile is None and manual is None:
|
|
407
|
+
return None # sans lockfile, la contrainte accepte déjà cette version : rien à faire
|
|
408
|
+
warnings = []
|
|
409
|
+
if gap is Gap.MAJOR:
|
|
410
|
+
warnings.append(MAJOR_WARNING)
|
|
411
|
+
if not self.lead.direct:
|
|
412
|
+
warnings.append(INDIRECT_MAJOR_WARNING)
|
|
413
|
+
elif gap is None:
|
|
414
|
+
warnings.append(DOWNGRADE_WARNING)
|
|
415
|
+
published = self.info.get(version)
|
|
416
|
+
if published is not None and published.deprecated:
|
|
417
|
+
warnings.append(f"Version dépréciée : {published.deprecated}")
|
|
418
|
+
return UpdateOption(
|
|
419
|
+
version=version,
|
|
420
|
+
gap=gap,
|
|
421
|
+
reasons=tuple(dict.fromkeys(reasons)),
|
|
422
|
+
fixes=fixes,
|
|
423
|
+
unfixed=unfixed,
|
|
424
|
+
edits=tuple(edits),
|
|
425
|
+
lockfile=lockfile,
|
|
426
|
+
manual=manual,
|
|
427
|
+
warnings=tuple(warnings),
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
def _direct_edits(self, version: str, *, floor: bool) -> _Operations:
|
|
431
|
+
"""Les contraintes à réécrire pour monter à ``version`` : celles qui ne l'acceptent pas,
|
|
432
|
+
et, sans lockfile, celles dont la borne basse est à relever."""
|
|
433
|
+
edits = []
|
|
434
|
+
manual = None
|
|
435
|
+
for dependency, current in zip(self.dependencies, self.currents, strict=True):
|
|
436
|
+
text = dependency.effective_constraint
|
|
437
|
+
try:
|
|
438
|
+
inside = self.scheme.satisfies(text, version)
|
|
439
|
+
except (InvalidConstraint, InvalidVersion):
|
|
440
|
+
manual = f"Contrainte « {text} » illisible : à réécrire à la main."
|
|
441
|
+
continue
|
|
442
|
+
if inside and not current.approximate:
|
|
443
|
+
continue
|
|
444
|
+
new = rewrite(
|
|
445
|
+
self.ecosystem,
|
|
446
|
+
text,
|
|
447
|
+
version,
|
|
448
|
+
floor=floor,
|
|
449
|
+
widen=dependency.scope is Scope.PEER,
|
|
450
|
+
)
|
|
451
|
+
if new is None:
|
|
452
|
+
manual = f"Contrainte « {text} » : style non géré, à réécrire à la main."
|
|
453
|
+
continue
|
|
454
|
+
if new == text:
|
|
455
|
+
continue
|
|
456
|
+
catalog = dependency.resolved_constraint is not None
|
|
457
|
+
if dependency.location is None and not catalog:
|
|
458
|
+
manual = (
|
|
459
|
+
f"Contrainte « {text} » de {dependency.file} : emplacement inconnu, à "
|
|
460
|
+
f"réécrire à la main en « {new} »."
|
|
461
|
+
)
|
|
462
|
+
continue
|
|
463
|
+
edits.append(
|
|
464
|
+
Edit(
|
|
465
|
+
kind=EditKind.CATALOG if catalog else EditKind.DEPENDENCY,
|
|
466
|
+
file=dependency.file,
|
|
467
|
+
location=dependency.location,
|
|
468
|
+
old=text,
|
|
469
|
+
new=new,
|
|
470
|
+
)
|
|
471
|
+
)
|
|
472
|
+
lockfile = next((d.lockfile for d in self.dependencies if d.lockfile), None)
|
|
473
|
+
return edits, lockfile, manual
|
|
474
|
+
|
|
475
|
+
def _indirect_edits(self, version: str, *, floor: bool) -> _Operations:
|
|
476
|
+
"""Une indirecte monte par son lockfile ; une version forcée qui ne l'accepte pas est
|
|
477
|
+
réécrite."""
|
|
478
|
+
lockfile = self.lead.lockfile or self.lead.file
|
|
479
|
+
override = _override(self.lead.name, self.overrides)
|
|
480
|
+
if override is None:
|
|
481
|
+
return [], lockfile, None
|
|
482
|
+
described = f"Version forcée « {override.key} » ({override.section}, {override.file})"
|
|
483
|
+
try:
|
|
484
|
+
inside = self.scheme.satisfies(override.constraint, version)
|
|
485
|
+
except (InvalidConstraint, InvalidVersion):
|
|
486
|
+
return [], lockfile, f"{described} : « {override.constraint} », à réécrire à la main."
|
|
487
|
+
if inside:
|
|
488
|
+
return [], lockfile, None
|
|
489
|
+
new = rewrite(self.ecosystem, override.constraint, version, floor=floor)
|
|
490
|
+
if new is None or override.location is None:
|
|
491
|
+
return [], lockfile, f"{described} : « {override.constraint} », à réécrire à la main."
|
|
492
|
+
edit = Edit(
|
|
493
|
+
kind=EditKind.OVERRIDE,
|
|
494
|
+
file=override.file,
|
|
495
|
+
location=override.location,
|
|
496
|
+
old=override.constraint,
|
|
497
|
+
new=new,
|
|
498
|
+
override=override,
|
|
499
|
+
)
|
|
500
|
+
return [edit], lockfile, None
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def _advisories(
|
|
504
|
+
dependencies: Iterable[Dependency],
|
|
505
|
+
currents: Iterable[Current],
|
|
506
|
+
vulnerabilities: Mapping[Query, Vulnerabilities],
|
|
507
|
+
) -> list[Advisory]:
|
|
508
|
+
"""Les avis d'OSV pour les versions utilisées du paquet, sans doublon."""
|
|
509
|
+
found: dict[str, Advisory] = {}
|
|
510
|
+
for dependency, current in zip(dependencies, currents, strict=True):
|
|
511
|
+
if current.version is None:
|
|
512
|
+
continue
|
|
513
|
+
query = (dependency.ecosystem, dependency.name, current.version)
|
|
514
|
+
result = vulnerabilities.get(query)
|
|
515
|
+
if result is not None:
|
|
516
|
+
for advisory in result.advisories:
|
|
517
|
+
found.setdefault(advisory.id, advisory)
|
|
518
|
+
return list(found.values())
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def _override(name: str, overrides: Iterable[Override]) -> Override | None:
|
|
522
|
+
"""La version forcée de ``name`` : celle qui vaut pour tout l'arbre de préférence, sinon
|
|
523
|
+
celle d'un paquet parent."""
|
|
524
|
+
mine = [o for o in overrides if o.name == name]
|
|
525
|
+
whole_tree = [o for o in mine if o.key in (name, f"**/{name}") or o.key.startswith(f"{name}@")]
|
|
526
|
+
return next(iter(whole_tree or mine), None)
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def _main_id(finding: Finding) -> str:
|
|
530
|
+
"""Le premier identifiant d'une faille : une CVE s'il y en a une."""
|
|
531
|
+
return finding.identifiers[0] if finding.identifiers else finding.message
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Les versions : comparer, classer l'écart, tester une contrainte, un module par écosystème.
|
|
2
|
+
|
|
3
|
+
Les modèles gardent versions et contraintes en chaînes, telles qu'écrites. Pour les
|
|
4
|
+
interpréter, prendre les règles de l'écosystème avec ``for_ecosystem`` :
|
|
5
|
+
|
|
6
|
+
scheme = for_ecosystem(Ecosystem.NPM)
|
|
7
|
+
scheme.max_satisfying("^4.17.0", ["4.17.21", "5.0.0"]) # "4.17.21"
|
|
8
|
+
scheme.gap("4.17.15", "5.0.0") # Gap.MAJOR
|
|
9
|
+
|
|
10
|
+
PyPI et npm pour l'instant ; les autres écosystèmes arrivent avec leur étape (E21-E26),
|
|
11
|
+
chacun avec sa ``VersionScheme``.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from forceupdate.models import Ecosystem
|
|
17
|
+
from forceupdate.versions.base import Constraint, InvalidConstraint, InvalidVersion, VersionScheme
|
|
18
|
+
from forceupdate.versions.npm import NPM
|
|
19
|
+
from forceupdate.versions.pypi import PYPI
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"Constraint",
|
|
23
|
+
"InvalidConstraint",
|
|
24
|
+
"InvalidVersion",
|
|
25
|
+
"VersionScheme",
|
|
26
|
+
"for_ecosystem",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
_SCHEMES: dict[Ecosystem, VersionScheme[Any]] = {
|
|
30
|
+
Ecosystem.PYPI: PYPI,
|
|
31
|
+
Ecosystem.NPM: NPM,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def for_ecosystem(ecosystem: Ecosystem) -> VersionScheme[Any]:
|
|
36
|
+
"""Les règles de version de ``ecosystem`` ; ``LookupError`` s'il n'est pas encore géré."""
|
|
37
|
+
try:
|
|
38
|
+
return _SCHEMES[ecosystem]
|
|
39
|
+
except KeyError:
|
|
40
|
+
raise LookupError(f"versions {ecosystem} pas encore gérées par forceupdate.") from None
|