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
forceupdate/models.py
ADDED
|
@@ -0,0 +1,510 @@
|
|
|
1
|
+
"""Les structures de données du moteur : dépendances, constats, projets, rapport.
|
|
2
|
+
|
|
3
|
+
Elles suivent le chemin de l'analyse (Source → Project → Dependency → Finding →
|
|
4
|
+
Report) et sortent en JSON avec ``to_dict`` : c'est ce que l'API renverra au front
|
|
5
|
+
et ce que ``scan --format json`` écrira.
|
|
6
|
+
|
|
7
|
+
Les chemins sont des chaînes au format POSIX (``backend/pyproject.toml``),
|
|
8
|
+
relatifs à la racine de la source analysée (le dossier choisi, ou le dépôt). Ils
|
|
9
|
+
ont la même forme sous Windows, sous Linux et pour un dépôt distant.
|
|
10
|
+
|
|
11
|
+
Les enums sont des ``StrEnum`` : leur valeur passe telle quelle en JSON. Pour trier
|
|
12
|
+
par gravité ou par écart, utiliser ``rank`` : comparer deux membres avec ``<``
|
|
13
|
+
compare des chaînes.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import dataclasses
|
|
17
|
+
import enum
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from datetime import datetime
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Ecosystem(enum.StrEnum):
|
|
24
|
+
"""Un écosystème de paquets. Les valeurs sont les noms qu'utilise OSV."""
|
|
25
|
+
|
|
26
|
+
PYPI = "PyPI"
|
|
27
|
+
NPM = "npm"
|
|
28
|
+
CRATES_IO = "crates.io"
|
|
29
|
+
GO = "Go"
|
|
30
|
+
PACKAGIST = "Packagist"
|
|
31
|
+
RUBYGEMS = "RubyGems"
|
|
32
|
+
MAVEN = "Maven"
|
|
33
|
+
NUGET = "NuGet"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Severity(enum.StrEnum):
|
|
37
|
+
"""Gravité d'un constat, du plus grave au moins grave.
|
|
38
|
+
|
|
39
|
+
Remplace l'enum d'Argos (``ARGOS (1)`` dans ``reference/``), mêmes membres.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
CRITICAL = "critical"
|
|
43
|
+
HIGH = "high"
|
|
44
|
+
MEDIUM = "medium"
|
|
45
|
+
LOW = "low"
|
|
46
|
+
INFO = "info"
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def rank(self) -> int:
|
|
50
|
+
"""Rang pour trier : 4 pour ``CRITICAL``, 0 pour ``INFO``."""
|
|
51
|
+
return _SEVERITY_RANKS[self]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
_SEVERITY_RANKS = {
|
|
55
|
+
Severity.CRITICAL: 4,
|
|
56
|
+
Severity.HIGH: 3,
|
|
57
|
+
Severity.MEDIUM: 2,
|
|
58
|
+
Severity.LOW: 1,
|
|
59
|
+
Severity.INFO: 0,
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class FindingKind(enum.StrEnum):
|
|
64
|
+
"""Le type d'un constat : les quatre types du brief, et ce qui n'a pas pu être vérifié."""
|
|
65
|
+
|
|
66
|
+
OUTDATED = "outdated"
|
|
67
|
+
"""Type 1 : une version plus récente existe."""
|
|
68
|
+
VULNERABLE = "vulnerable"
|
|
69
|
+
"""Type 2 : la version utilisée a une faille connue."""
|
|
70
|
+
EOL = "eol"
|
|
71
|
+
"""Type 3 : le runtime ou le framework est en fin de vie, ou le sera bientôt."""
|
|
72
|
+
YANKED = "yanked"
|
|
73
|
+
"""Type 4 : la version utilisée a été retirée du registre."""
|
|
74
|
+
DEPRECATED = "deprecated"
|
|
75
|
+
"""Type 4 : le paquet est déprécié ou abandonné, ou la version utilisée est dépréciée (npm,
|
|
76
|
+
qui n'a pas de retrait : c'est sa façon de désavouer une version)."""
|
|
77
|
+
UNVERIFIABLE = "unverifiable"
|
|
78
|
+
"""Impossible à vérifier : dépendance git ou locale, paquet introuvable sur le registre."""
|
|
79
|
+
INCONCLUSIVE = "inconclusive"
|
|
80
|
+
"""On ne conclut rien : la vérification a échoué (réseau, hors ligne sans cache), ou il
|
|
81
|
+
manque de quoi conclure (version utilisée inconnue, inconnue du registre)."""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class Gap(enum.StrEnum):
|
|
85
|
+
"""L'écart entre deux versions.
|
|
86
|
+
|
|
87
|
+
Depuis ``2.3.1`` : ``2.3.4`` est un correctif, ``2.9.0`` une mineure, ``3.0.0`` une majeure.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
PATCH = "patch"
|
|
91
|
+
MINOR = "minor"
|
|
92
|
+
MAJOR = "major"
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def rank(self) -> int:
|
|
96
|
+
"""Rang pour trier : 2 pour ``MAJOR``, 0 pour ``PATCH``."""
|
|
97
|
+
return _GAP_RANKS[self]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
_GAP_RANKS = {Gap.MAJOR: 2, Gap.MINOR: 1, Gap.PATCH: 0}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class Scope(enum.StrEnum):
|
|
104
|
+
"""À quoi sert une dépendance dans son projet."""
|
|
105
|
+
|
|
106
|
+
RUNTIME = "runtime"
|
|
107
|
+
"""Nécessaire pour faire tourner le code."""
|
|
108
|
+
DEV = "dev"
|
|
109
|
+
"""Développement et tests seulement (``devDependencies``, groupe ``dev``…)."""
|
|
110
|
+
OPTIONAL = "optional"
|
|
111
|
+
"""Facultative (extras Python, ``optionalDependencies`` npm)."""
|
|
112
|
+
PEER = "peer"
|
|
113
|
+
"""Fournie par le projet qui l'utilise (``peerDependencies`` npm)."""
|
|
114
|
+
BUILD = "build"
|
|
115
|
+
"""Nécessaire à la construction seulement (``build-dependencies`` Cargo…)."""
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@dataclass(frozen=True, kw_only=True)
|
|
119
|
+
class Location:
|
|
120
|
+
"""L'endroit exact où une contrainte est écrite dans son fichier.
|
|
121
|
+
|
|
122
|
+
``line`` commence à 1 ; les colonnes commencent à 0, comptent des caractères
|
|
123
|
+
(pas des octets), et ``end_column`` est exclue. C'est ce qui permet de
|
|
124
|
+
réécrire la contrainte sans toucher au reste du fichier (E10).
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
line: int
|
|
128
|
+
column: int
|
|
129
|
+
end_column: int
|
|
130
|
+
|
|
131
|
+
def __post_init__(self) -> None:
|
|
132
|
+
if self.line < 1 or self.column < 0 or self.end_column < self.column:
|
|
133
|
+
raise ValueError(f"emplacement invalide : {self!r}")
|
|
134
|
+
|
|
135
|
+
def extract(self, text: str) -> str:
|
|
136
|
+
"""Le texte à cet emplacement dans ``text``, le contenu du fichier.
|
|
137
|
+
|
|
138
|
+
Les lignes sont coupées sur ``\\n`` seulement, comme le font les parseurs.
|
|
139
|
+
Chaîne vide si la ligne n'existe pas.
|
|
140
|
+
"""
|
|
141
|
+
lines = text.split("\n")
|
|
142
|
+
if self.line > len(lines):
|
|
143
|
+
return ""
|
|
144
|
+
return lines[self.line - 1][self.column : self.end_column]
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@dataclass(frozen=True, kw_only=True)
|
|
148
|
+
class Dependency:
|
|
149
|
+
"""Une dépendance d'un projet.
|
|
150
|
+
|
|
151
|
+
Une directe vient du manifeste (``package.json``, ``pyproject.toml``…). Une
|
|
152
|
+
transitive vient seulement du lockfile : ``direct=False``, sans ``location``.
|
|
153
|
+
"""
|
|
154
|
+
|
|
155
|
+
ecosystem: Ecosystem
|
|
156
|
+
name: str
|
|
157
|
+
"""Nom du paquet, tel que le registre le connaît (normalisé selon PEP 503 pour PyPI)."""
|
|
158
|
+
constraint: str = ""
|
|
159
|
+
"""Contrainte telle qu'écrite (``^4.18.0``, ``>=2.0,<3``) ; vide s'il n'y en a pas."""
|
|
160
|
+
resolved_constraint: str | None = None
|
|
161
|
+
"""La plage que désigne ``constraint`` quand elle renvoie ailleurs : pour ``catalog:``
|
|
162
|
+
(pnpm), celle du catalogue dans ``pnpm-workspace.yaml``. ``None`` si ``constraint`` est
|
|
163
|
+
elle-même la plage, ou si la plage est introuvable."""
|
|
164
|
+
installed: str | None = None
|
|
165
|
+
"""Version installée, lue dans le lockfile ; ``None`` sans lockfile."""
|
|
166
|
+
lockfile: str | None = None
|
|
167
|
+
"""Le lockfile où ``installed`` a été lu, celui qu'une mise à jour régénère (E11) ;
|
|
168
|
+
``None`` sans version installée. Pour une indirecte, c'est ``file``."""
|
|
169
|
+
direct: bool = True
|
|
170
|
+
scope: Scope = Scope.RUNTIME
|
|
171
|
+
file: str
|
|
172
|
+
"""Fichier où la dépendance est déclarée (le lockfile pour une transitive)."""
|
|
173
|
+
location: Location | None = None
|
|
174
|
+
"""Emplacement de ``constraint`` dans ``file`` ; ``None`` si on ne sait pas la réécrire."""
|
|
175
|
+
unverifiable_reason: str | None = None
|
|
176
|
+
"""Pourquoi on ne peut pas la vérifier (dépendance git, ``file:``, ``workspace:``…)."""
|
|
177
|
+
|
|
178
|
+
@property
|
|
179
|
+
def verifiable(self) -> bool:
|
|
180
|
+
"""Vrai si on peut interroger le registre et OSV pour cette dépendance."""
|
|
181
|
+
return self.unverifiable_reason is None
|
|
182
|
+
|
|
183
|
+
@property
|
|
184
|
+
def effective_constraint(self) -> str:
|
|
185
|
+
"""La contrainte à interpréter (``forceupdate.versions``) : ``resolved_constraint``
|
|
186
|
+
s'il y en a une, sinon ``constraint``."""
|
|
187
|
+
return self.constraint if self.resolved_constraint is None else self.resolved_constraint
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@dataclass(frozen=True, kw_only=True)
|
|
191
|
+
class Override:
|
|
192
|
+
"""Une version forcée : ``overrides`` (npm), ``resolutions`` (Yarn), ``pnpm.overrides``.
|
|
193
|
+
|
|
194
|
+
Elle s'impose à toutes les copies du paquet dans l'arbre (ou à celles que tire un paquet
|
|
195
|
+
précis), quoi que demandent les paquets qui le tirent. Une indirecte forcée ne bouge pas
|
|
196
|
+
avec ``npm update x`` : c'est la version forcée qu'il faut réécrire.
|
|
197
|
+
"""
|
|
198
|
+
|
|
199
|
+
ecosystem: Ecosystem
|
|
200
|
+
name: str
|
|
201
|
+
"""Le paquet forcé."""
|
|
202
|
+
constraint: str
|
|
203
|
+
"""La version forcée telle qu'écrite (``^3.4.13``, ``$dompurify``, ``npm:autre@1.0``)."""
|
|
204
|
+
key: str
|
|
205
|
+
"""La clé telle qu'écrite : ``dompurify``, ``**/lodash`` (Yarn), ``foo@<2`` ou ``a>b``
|
|
206
|
+
(pnpm) ; pour npm, le chemin des clés imbriquées, séparées par ``>`` (``a>b``)."""
|
|
207
|
+
section: str
|
|
208
|
+
"""``overrides``, ``resolutions`` ou ``pnpm.overrides``."""
|
|
209
|
+
file: str
|
|
210
|
+
location: Location | None = None
|
|
211
|
+
"""Emplacement de ``constraint`` dans ``file`` ; ``None`` si on ne sait pas la réécrire."""
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
@dataclass(frozen=True, kw_only=True)
|
|
215
|
+
class Finding:
|
|
216
|
+
"""Un constat : quelque chose à mettre à jour, ou qui n'a pas pu être vérifié.
|
|
217
|
+
|
|
218
|
+
Ce sont les contrôles (``checks/``) qui les produisent ; E07, E08 et E27
|
|
219
|
+
complètent ce que chaque type remplit.
|
|
220
|
+
"""
|
|
221
|
+
|
|
222
|
+
kind: FindingKind
|
|
223
|
+
severity: Severity = Severity.INFO
|
|
224
|
+
"""Pour une faille, sa gravité (CVSS) ; pour les autres types, fixée par le contrôle."""
|
|
225
|
+
message: str
|
|
226
|
+
"""Le constat en une phrase, en français."""
|
|
227
|
+
dependency: Dependency | None = None
|
|
228
|
+
"""La dépendance concernée ; ``None`` pour la fin de vie d'un runtime (Python 3.8…)."""
|
|
229
|
+
current_version: str | None = None
|
|
230
|
+
"""Version actuelle : l'installée, sinon la borne basse de la contrainte."""
|
|
231
|
+
current_is_approximate: bool = False
|
|
232
|
+
"""Vrai quand ``current_version`` vient de la contrainte, faute de lockfile."""
|
|
233
|
+
target_version: str | None = None
|
|
234
|
+
"""Version visée : la dernière stable, la plus proche non retirée, ou la plus petite
|
|
235
|
+
version qui corrige la faille."""
|
|
236
|
+
wanted_version: str | None = None
|
|
237
|
+
"""La plus haute version que la contrainte accepte déjà (``outdated``) : on y monte en
|
|
238
|
+
régénérant le lockfile, sans toucher au manifeste. ``None`` si on ne la connaît pas
|
|
239
|
+
(contrainte illisible, dépendance indirecte)."""
|
|
240
|
+
gap: Gap | None = None
|
|
241
|
+
"""Écart entre ``current_version`` et ``target_version``."""
|
|
242
|
+
identifiers: tuple[str, ...] = ()
|
|
243
|
+
"""Identifiants de la faille : les CVE, puis les GHSA, puis les autres (PYSEC, MAL…)."""
|
|
244
|
+
cvss_score: float | None = None
|
|
245
|
+
"""Score CVSS 3 de la faille, de 0 à 10, s'il est connu."""
|
|
246
|
+
detail: str | None = None
|
|
247
|
+
"""Ce que dit la source, tel quel : la raison d'un retrait, le message de dépréciation, le
|
|
248
|
+
titre de l'avis de sécurité."""
|
|
249
|
+
url: str | None = None
|
|
250
|
+
"""Lien pour en savoir plus : avis de sécurité (osv.dev), page du paquet, endoflife.date."""
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
class UpdateReason(enum.StrEnum):
|
|
254
|
+
"""Pourquoi une version est proposée. Une même version peut l'être pour plusieurs raisons
|
|
255
|
+
(la dernière mineure corrige souvent aussi les failles)."""
|
|
256
|
+
|
|
257
|
+
LATEST_PATCH = "latest_patch"
|
|
258
|
+
"""Le dernier correctif de la version utilisée."""
|
|
259
|
+
LATEST_MINOR = "latest_minor"
|
|
260
|
+
"""La dernière mineure : la plus haute version sans changer de majeure."""
|
|
261
|
+
LATEST_MAJOR = "latest_major"
|
|
262
|
+
"""La dernière version, quand elle est majeure."""
|
|
263
|
+
SECURITY_FIX = "security_fix"
|
|
264
|
+
"""La plus petite version publiée qui corrige toutes les failles qui peuvent l'être."""
|
|
265
|
+
REPLACEMENT = "replacement"
|
|
266
|
+
"""Remplace une version retirée ou dépréciée."""
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
class EditKind(enum.StrEnum):
|
|
270
|
+
"""Ce qu'une réécriture modifie."""
|
|
271
|
+
|
|
272
|
+
DEPENDENCY = "dependency"
|
|
273
|
+
"""La contrainte d'une dépendance, dans son manifeste."""
|
|
274
|
+
CATALOG = "catalog"
|
|
275
|
+
"""La plage d'un catalogue pnpm, dans ``pnpm-workspace.yaml`` (E10 l'y trouve)."""
|
|
276
|
+
OVERRIDE = "override"
|
|
277
|
+
"""Une version forcée (``overrides``, ``resolutions``, ``pnpm.overrides``)."""
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
@dataclass(frozen=True, kw_only=True)
|
|
281
|
+
class Edit:
|
|
282
|
+
"""Une contrainte à réécrire : à ``location`` dans ``file``, ``old`` devient ``new``.
|
|
283
|
+
|
|
284
|
+
C'est le planificateur (E09) qui le calcule ; la réécriture (E10) vérifie que le fichier
|
|
285
|
+
contient bien ``old`` à cet endroit avant d'y écrire ``new``. La déclaration réécrite est
|
|
286
|
+
celle de ``Update.dependencies`` qui a ce fichier et cet emplacement (pour un catalogue :
|
|
287
|
+
ce fichier et ``old`` pour ``resolved_constraint``).
|
|
288
|
+
"""
|
|
289
|
+
|
|
290
|
+
kind: EditKind
|
|
291
|
+
file: str
|
|
292
|
+
"""Le manifeste ; pour un catalogue pnpm, le ``package.json`` qui y renvoie."""
|
|
293
|
+
location: Location | None
|
|
294
|
+
"""``None`` pour un catalogue pnpm (E10 cherche sa plage dans ``pnpm-workspace.yaml``)."""
|
|
295
|
+
old: str
|
|
296
|
+
new: str
|
|
297
|
+
override: Override | None = None
|
|
298
|
+
"""La version forcée réécrite (``OVERRIDE``)."""
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
@dataclass(frozen=True, kw_only=True)
|
|
302
|
+
class UpdateOption:
|
|
303
|
+
"""Une version vers laquelle monter un paquet : une case à cocher de l'interface."""
|
|
304
|
+
|
|
305
|
+
version: str
|
|
306
|
+
gap: Gap | None
|
|
307
|
+
"""Écart depuis la version utilisée ; ``None`` pour un retour à une version plus ancienne
|
|
308
|
+
(la plus proche non retirée d'une version retirée peut être en dessous)."""
|
|
309
|
+
reasons: tuple[UpdateReason, ...]
|
|
310
|
+
fixes: tuple[str, ...] = ()
|
|
311
|
+
"""Les failles qu'elle corrige : le premier identifiant de chacune (une CVE s'il y en a)."""
|
|
312
|
+
unfixed: tuple[str, ...] = ()
|
|
313
|
+
"""Les failles qu'elle laisse."""
|
|
314
|
+
edits: tuple[Edit, ...] = ()
|
|
315
|
+
"""Les contraintes à réécrire. Vide : la version est déjà dans la contrainte, on y monte en
|
|
316
|
+
régénérant le lockfile seul."""
|
|
317
|
+
lockfile: str | None = None
|
|
318
|
+
"""Le lockfile à régénérer (E11) ; ``None`` sans lockfile."""
|
|
319
|
+
manual: str | None = None
|
|
320
|
+
"""Pourquoi forceupdate ne sait pas l'appliquer seul (contrainte sans emplacement, style
|
|
321
|
+
non géré…) ; ``None`` s'il le sait. Elle n'est jamais pré-cochée."""
|
|
322
|
+
warnings: tuple[str, ...] = ()
|
|
323
|
+
"""Ce qu'il faut savoir avant de la cocher (« Majeure : peut demander d'adapter le
|
|
324
|
+
code. »)."""
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
@dataclass(frozen=True, kw_only=True)
|
|
328
|
+
class Update:
|
|
329
|
+
"""Les mises à jour possibles d'un paquet d'un projet. Au plus une est choisie : ses
|
|
330
|
+
options s'excluent l'une l'autre, comme des boutons radio qu'on peut aussi tous décocher.
|
|
331
|
+
|
|
332
|
+
Pour une directe, toutes ses déclarations (runtime et dev, deux extras) montent ensemble ;
|
|
333
|
+
une indirecte est une version installée précise (npm peut en installer plusieurs).
|
|
334
|
+
"""
|
|
335
|
+
|
|
336
|
+
id: str
|
|
337
|
+
"""Identifiant unique dans le rapport : c'est lui que la sélection désigne."""
|
|
338
|
+
ecosystem: Ecosystem
|
|
339
|
+
name: str
|
|
340
|
+
direct: bool
|
|
341
|
+
dependencies: tuple[Dependency, ...]
|
|
342
|
+
"""Ses déclarations (directe), ou l'indirecte."""
|
|
343
|
+
current_version: str | None
|
|
344
|
+
current_is_approximate: bool = False
|
|
345
|
+
options: tuple[UpdateOption, ...] = ()
|
|
346
|
+
"""De la plus ancienne à la plus récente version."""
|
|
347
|
+
selected: str | None = None
|
|
348
|
+
"""La version pré-cochée (brief, décision n°12) ; ``None`` si aucune ne l'est."""
|
|
349
|
+
note: str | None = None
|
|
350
|
+
"""Ce que les options ne disent pas : faille sans version corrigée, versions publiées
|
|
351
|
+
inconnues…"""
|
|
352
|
+
|
|
353
|
+
def option(self, version: str) -> UpdateOption | None:
|
|
354
|
+
"""L'option de version ``version`` (le même texte) ; ``None`` s'il n'y en a pas."""
|
|
355
|
+
return next((option for option in self.options if option.version == version), None)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
@dataclass(frozen=True, kw_only=True)
|
|
359
|
+
class Language:
|
|
360
|
+
"""Un langage de la source, avec son poids en octets, comme GitHub Linguist."""
|
|
361
|
+
|
|
362
|
+
name: str
|
|
363
|
+
"""Nom du langage, celui de Linguist (``Python``, ``TypeScript``, ``C++``)."""
|
|
364
|
+
bytes: int
|
|
365
|
+
"""Octets de code dans ce langage, sans les fichiers vendorisés, générés ni la documentation."""
|
|
366
|
+
ecosystem: Ecosystem | None = None
|
|
367
|
+
"""Son écosystème de paquets ; ``None`` si forceupdate n'en gère aucun (C, C++, Shell…)."""
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
@dataclass(frozen=True, kw_only=True)
|
|
371
|
+
class Notice:
|
|
372
|
+
"""Une remarque sur un fichier d'un projet : non lu, illisible, mal formé…
|
|
373
|
+
|
|
374
|
+
Ce n'est pas un constat sur une dépendance : c'est ce que l'analyse n'a pas pu
|
|
375
|
+
faire, pour que le rapport ne passe rien sous silence.
|
|
376
|
+
"""
|
|
377
|
+
|
|
378
|
+
file: str
|
|
379
|
+
"""Le fichier concerné, relatif à la source."""
|
|
380
|
+
message: str
|
|
381
|
+
"""La remarque en une phrase, en français."""
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
@dataclass(kw_only=True)
|
|
385
|
+
class Project:
|
|
386
|
+
"""Un projet : un dossier qui contient au moins un manifeste ou un lockfile.
|
|
387
|
+
|
|
388
|
+
Un monorepo en donne plusieurs (``backend/`` en Python, ``frontend/`` en npm).
|
|
389
|
+
"""
|
|
390
|
+
|
|
391
|
+
path: str
|
|
392
|
+
"""Dossier du projet (``.`` pour la racine de la source)."""
|
|
393
|
+
ecosystems: list[Ecosystem] = field(default_factory=list)
|
|
394
|
+
"""Écosystèmes de ses manifestes et lockfiles, dans l'ordre de ``Ecosystem``."""
|
|
395
|
+
manifests: list[str] = field(default_factory=list)
|
|
396
|
+
lockfiles: list[str] = field(default_factory=list)
|
|
397
|
+
"""Ses lockfiles : ceux de son dossier, ou celui de la racine d'un workspace."""
|
|
398
|
+
dependencies: list[Dependency] = field(default_factory=list)
|
|
399
|
+
"""Les directes d'abord, dans l'ordre des manifestes, puis les transitives."""
|
|
400
|
+
overrides: list[Override] = field(default_factory=list)
|
|
401
|
+
"""Les versions forcées de son ``package.json`` (seules celles de la racine d'un workspace
|
|
402
|
+
comptent pour npm, Yarn et pnpm)."""
|
|
403
|
+
findings: list[Finding] = field(default_factory=list)
|
|
404
|
+
updates: list[Update] = field(default_factory=list)
|
|
405
|
+
"""Les mises à jour possibles, une par paquet à mettre à jour, dans l'ordre des constats
|
|
406
|
+
(``update.planner``) ; vide si les dépendances n'ont pas été vérifiées."""
|
|
407
|
+
notices: list[Notice] = field(default_factory=list)
|
|
408
|
+
"""Ce que l'analyse n'a pas pu lire (``setup.py``, fichier invalide…)."""
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
@dataclass(frozen=True, kw_only=True)
|
|
412
|
+
class ScanOptions:
|
|
413
|
+
"""Ce que vérifie une analyse."""
|
|
414
|
+
|
|
415
|
+
include_tests: bool = False
|
|
416
|
+
"""Garder aussi les projets rangés dans des dossiers de tests, de fixtures ou d'exemples."""
|
|
417
|
+
dev: bool = True
|
|
418
|
+
"""Vérifier aussi les dépendances de développement (``Scope.DEV``)."""
|
|
419
|
+
transitive: bool = False
|
|
420
|
+
"""Vérifier aussi les dépendances indirectes (celles du lockfile seul) : versions plus
|
|
421
|
+
récentes, retirées, dépréciées. Par défaut, ce n'est fait que pour les directes (brief,
|
|
422
|
+
décision n°3). Les failles, elles, sont toujours cherchées sur toutes les dépendances."""
|
|
423
|
+
offline: bool = False
|
|
424
|
+
"""Hors ligne : seulement le cache, même périmé ; le reste est « non concluant »."""
|
|
425
|
+
cache: bool = True
|
|
426
|
+
"""Lire et écrire le cache disque ; faux pour « sans cache »."""
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
@dataclass(kw_only=True)
|
|
430
|
+
class Report:
|
|
431
|
+
"""Le résultat d'une analyse : une source, ses langages et ses projets."""
|
|
432
|
+
|
|
433
|
+
source: str
|
|
434
|
+
"""Ce qui a été analysé : un chemin local, ou ``owner/repo`` sur une forge."""
|
|
435
|
+
languages: list[Language] = field(default_factory=list)
|
|
436
|
+
"""Les langages, du plus lourd au plus léger (en octets, comme GitHub Linguist)."""
|
|
437
|
+
projects: list[Project] = field(default_factory=list)
|
|
438
|
+
ignored_manifests: list[str] = field(default_factory=list)
|
|
439
|
+
"""Manifestes et lockfiles écartés : rangés dans un dossier de tests, de fixtures ou
|
|
440
|
+
d'exemples, ce ne sont pas de vrais projets (voir ``detect.manifests.is_test_path``)."""
|
|
441
|
+
options: ScanOptions | None = None
|
|
442
|
+
"""Ce qui a été vérifié ; ``None`` si les dépendances n'ont pas été vérifiées auprès des
|
|
443
|
+
registres (``engine.inventory``)."""
|
|
444
|
+
scanned_at: datetime | None = None
|
|
445
|
+
"""Quand l'analyse a été lancée (UTC)."""
|
|
446
|
+
oldest_data: datetime | None = None
|
|
447
|
+
"""La date des plus anciennes données des registres et d'OSV utilisées (UTC). Hors ligne,
|
|
448
|
+
elles viennent du cache, et peuvent dater de plusieurs jours : le rapport le dit."""
|
|
449
|
+
|
|
450
|
+
@property
|
|
451
|
+
def findings(self) -> list[Finding]:
|
|
452
|
+
"""Les constats de tous les projets."""
|
|
453
|
+
return [finding for project in self.projects for finding in project.findings]
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
class EventKind(enum.StrEnum):
|
|
457
|
+
"""Les étapes d'une analyse, dans l'ordre où elles arrivent."""
|
|
458
|
+
|
|
459
|
+
READING = "reading"
|
|
460
|
+
"""Lecture de la source : langages, projets, dépendances."""
|
|
461
|
+
PROJECTS_FOUND = "projects_found"
|
|
462
|
+
"""Projets trouvés : ``total`` en donne le nombre."""
|
|
463
|
+
CHECKING = "checking"
|
|
464
|
+
"""Vérification auprès des registres et d'OSV : ``total`` vérifications, une par paquet à
|
|
465
|
+
demander aux registres, plus une pour les failles."""
|
|
466
|
+
PACKAGE_CHECKED = "package_checked"
|
|
467
|
+
"""Un paquet vérifié auprès de son registre (``ecosystem``, ``name``) : ``done`` sur
|
|
468
|
+
``total``."""
|
|
469
|
+
VULNERABILITIES_CHECKED = "vulnerabilities_checked"
|
|
470
|
+
"""Les failles cherchées auprès d'OSV, pour toutes les dépendances : ``done`` sur
|
|
471
|
+
``total``."""
|
|
472
|
+
FINISHED = "finished"
|
|
473
|
+
"""Le rapport est prêt."""
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
@dataclass(frozen=True, kw_only=True)
|
|
477
|
+
class Event:
|
|
478
|
+
"""Un événement de progression d'une analyse : la ligne de commande en fait une barre de
|
|
479
|
+
progression, l'API (E12) l'envoie au navigateur."""
|
|
480
|
+
|
|
481
|
+
kind: EventKind
|
|
482
|
+
done: int = 0
|
|
483
|
+
total: int = 0
|
|
484
|
+
ecosystem: Ecosystem | None = None
|
|
485
|
+
name: str | None = None
|
|
486
|
+
"""Le paquet vérifié (``PACKAGE_CHECKED``)."""
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def to_dict(model: Any) -> dict[str, Any]:
|
|
490
|
+
"""Convertit un modèle en dictionnaire de types simples, prêt pour ``json.dumps``.
|
|
491
|
+
|
|
492
|
+
Les enums donnent leur valeur, les tuples des listes, les dates leur forme ISO 8601.
|
|
493
|
+
"""
|
|
494
|
+
if not dataclasses.is_dataclass(model) or isinstance(model, type):
|
|
495
|
+
raise TypeError(f"pas un modèle : {model!r}")
|
|
496
|
+
return _to_plain(model)
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def _to_plain(value: Any) -> Any:
|
|
500
|
+
if dataclasses.is_dataclass(value) and not isinstance(value, type):
|
|
501
|
+
return {f.name: _to_plain(getattr(value, f.name)) for f in dataclasses.fields(value)}
|
|
502
|
+
if isinstance(value, enum.Enum):
|
|
503
|
+
return value.value
|
|
504
|
+
if isinstance(value, datetime):
|
|
505
|
+
return value.isoformat()
|
|
506
|
+
if isinstance(value, dict):
|
|
507
|
+
return {str(key): _to_plain(item) for key, item in value.items()}
|
|
508
|
+
if isinstance(value, list | tuple):
|
|
509
|
+
return [_to_plain(item) for item in value]
|
|
510
|
+
return value
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Le réseau : le client HTTP commun (``http.py``) et le cache disque (``cache.py``).
|
|
2
|
+
|
|
3
|
+
Tout ce qui interroge le réseau (registres, OSV, endoflife.date, forges) passe par
|
|
4
|
+
``HttpClient``. Une erreur réseau sort en ``NetworkError`` : le constat sera « non
|
|
5
|
+
concluant », jamais faux.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from forceupdate.net.cache import Cache, CacheEntry, CacheKind, default_cache_path
|
|
9
|
+
from forceupdate.net.http import USER_AGENT, HttpClient, NetworkError, Response
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"USER_AGENT",
|
|
13
|
+
"Cache",
|
|
14
|
+
"CacheEntry",
|
|
15
|
+
"CacheKind",
|
|
16
|
+
"HttpClient",
|
|
17
|
+
"NetworkError",
|
|
18
|
+
"Response",
|
|
19
|
+
"default_cache_path",
|
|
20
|
+
]
|