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
forceupdate/engine.py ADDED
@@ -0,0 +1,313 @@
1
+ """Le moteur : enchaîne les étapes d'une analyse, de la source au rapport.
2
+
3
+ L'interface web et la ligne de commande l'appellent, et n'ont pas de logique métier.
4
+
5
+ - ``inventory`` : langages, projets, dépendances. Synchrone, sans réseau ;
6
+ - ``check`` : demande aux registres ce qu'ils savent de chaque paquet, et à OSV les failles
7
+ de chaque version, et en tire les constats (``checks/``). Asynchrone : tous les paquets
8
+ sont demandés en même temps ;
9
+ - ``analyze`` : les deux à la suite. C'est la coroutine que lancera le serveur (E12) ;
10
+ - ``scan`` : ``analyze`` pour qui n'a pas de boucle ``asyncio`` (la ligne de commande).
11
+
12
+ **Synchrone et asynchrone (tranché à E07)** : la lecture des fichiers reste synchrone (elle
13
+ est rapide, et les parseurs le sont) ; seule la vérification est une coroutine. ``scan`` la
14
+ lance avec ``asyncio.run``, une boucle par analyse. Le serveur, qui a déjà sa boucle, attend
15
+ ``analyze`` directement, avec un ``HttpClient`` et un ``Cache`` à lui ; il devra la lancer hors
16
+ de la boucle qui répond aux requêtes (E12 : un gros document npm se lit sans rendre la main).
17
+
18
+ Chaque étape émet un ``Event`` (``on_event``) : la ligne de commande en fait une barre de
19
+ progression, l'API l'enverra au navigateur.
20
+ """
21
+
22
+ import asyncio
23
+ import contextlib
24
+ from collections.abc import Callable
25
+ from datetime import UTC, datetime
26
+
27
+ from forceupdate import checks, parsers, registries
28
+ from forceupdate.advisories import Osv, OsvError, Query, Vulnerabilities
29
+ from forceupdate.checks import vulns
30
+ from forceupdate.checks.base import Current
31
+ from forceupdate.detect.languages import detect_languages
32
+ from forceupdate.detect.manifests import find_projects
33
+ from forceupdate.models import (
34
+ Dependency,
35
+ Ecosystem,
36
+ Event,
37
+ EventKind,
38
+ Finding,
39
+ FindingKind,
40
+ Notice,
41
+ Project,
42
+ Report,
43
+ ScanOptions,
44
+ )
45
+ from forceupdate.net import Cache, HttpClient
46
+ from forceupdate.parsers.base import REASON_WORKSPACE
47
+ from forceupdate.registries import PackageInfo, PackageNotFound, Registry, RegistryError
48
+ from forceupdate.sources.base import Source
49
+ from forceupdate.update import planner
50
+
51
+ EventCallback = Callable[[Event], None]
52
+
53
+ _Outcome = PackageInfo | PackageNotFound | RegistryError
54
+ _Found = Vulnerabilities | OsvError
55
+
56
+
57
+ def inventory(source: Source, *, include_tests: bool = False) -> Report:
58
+ """Lit une source : ses langages, ses projets et leurs dépendances, sans rien vérifier.
59
+
60
+ ``include_tests`` garde aussi les projets rangés dans des dossiers de tests, de
61
+ fixtures ou d'exemples ; sinon ils sont listés dans ``Report.ignored_manifests``.
62
+ """
63
+ files = source.files()
64
+ projects, ignored = find_projects((file.path for file in files), include_tests=include_tests)
65
+ project_dirs = [project.path for project in projects]
66
+ for project in projects:
67
+ parsers.parse_project(project, source.read_text, project_dirs)
68
+ return Report(
69
+ source=source.origin,
70
+ languages=detect_languages(files),
71
+ projects=projects,
72
+ ignored_manifests=ignored,
73
+ )
74
+
75
+
76
+ async def analyze(
77
+ source: Source,
78
+ options: ScanOptions | None = None,
79
+ *,
80
+ on_event: EventCallback | None = None,
81
+ http: HttpClient | None = None,
82
+ cache: Cache | None = None,
83
+ ) -> Report:
84
+ """Analyse une source : ``inventory``, puis ``check``. Voir ``check`` pour ``http`` et
85
+ ``cache``."""
86
+ options = options or ScanOptions()
87
+ emit = on_event or _ignore
88
+ scanned_at = datetime.now(UTC)
89
+ emit(Event(kind=EventKind.READING))
90
+ report = inventory(source, include_tests=options.include_tests)
91
+ report.scanned_at = scanned_at
92
+ emit(Event(kind=EventKind.PROJECTS_FOUND, total=len(report.projects)))
93
+ await check(report, options, on_event=emit, http=http, cache=cache)
94
+ emit(Event(kind=EventKind.FINISHED))
95
+ return report
96
+
97
+
98
+ def scan(
99
+ source: Source, options: ScanOptions | None = None, *, on_event: EventCallback | None = None
100
+ ) -> Report:
101
+ """``analyze``, depuis du code synchrone. Ne pas l'appeler depuis une boucle ``asyncio``
102
+ en cours : y attendre ``analyze``."""
103
+ return asyncio.run(analyze(source, options, on_event=on_event))
104
+
105
+
106
+ async def check(
107
+ report: Report,
108
+ options: ScanOptions | None = None,
109
+ *,
110
+ on_event: EventCallback | None = None,
111
+ http: HttpClient | None = None,
112
+ cache: Cache | None = None,
113
+ ) -> None:
114
+ """Vérifie les dépendances du rapport auprès des registres et d'OSV, et range les constats
115
+ dans chaque projet, du plus grave au moins grave.
116
+
117
+ Sont vérifiées auprès des registres les directes, plus les indirectes avec
118
+ ``options.transitive`` ; les failles sont cherchées pour toutes, indirectes comprises
119
+ (``checks.vulns``). Celles de développement sauf sans ``options.dev``. Chaque paquet n'est
120
+ demandé qu'une fois, même déclaré deux fois ou dans plusieurs projets, et tous le sont en
121
+ même temps, en même temps qu'OSV.
122
+
123
+ ``http`` et ``cache`` : ceux de la session s'ils sont donnés (le serveur les partage entre
124
+ ses analyses), sinon un ``HttpClient`` (hors ligne avec ``options.offline``) et un
125
+ ``Cache`` (``Cache.disabled()`` sans ``options.cache``) créés pour l'analyse, et fermés
126
+ après. Un paquet introuvable donne un constat « non vérifiable », une erreur du registre
127
+ un « non concluant » ; toute autre exception est un bug, et remonte. Si OSV ne répond pas,
128
+ une dépendance vérifiée a un constat « non concluant » ; pour les autres indirectes, une
129
+ remarque du projet le dit.
130
+ """
131
+ options = options or ScanOptions()
132
+ emit = on_event or _ignore
133
+ report.options = options
134
+ selected = [
135
+ (project, dependency)
136
+ for project in report.projects
137
+ for dependency in project.dependencies
138
+ if checks.selected(dependency, options)
139
+ ]
140
+ to_check: list[tuple[Project, Dependency]] = []
141
+ for project, dependency in selected:
142
+ if dependency.verifiable:
143
+ to_check.append((project, dependency))
144
+ elif dependency.unverifiable_reason != REASON_WORKSPACE:
145
+ # Un membre du workspace est du code du dépôt lui-même : rien à vérifier.
146
+ project.findings.append(
147
+ Finding(
148
+ kind=FindingKind.UNVERIFIABLE,
149
+ message=f"Non vérifiable : {dependency.unverifiable_reason}.",
150
+ dependency=dependency,
151
+ )
152
+ )
153
+ audited: list[tuple[Project, Dependency, Current]] = [
154
+ (project, dependency, current)
155
+ for project in report.projects
156
+ for dependency in project.dependencies
157
+ if vulns.audited(dependency, options)
158
+ and (current := vulns.version_to_check(dependency)) is not None
159
+ ]
160
+ queries = sorted({_query(dependency, current) for _, dependency, current in audited})
161
+
162
+ async with contextlib.AsyncExitStack() as stack:
163
+ if http is None:
164
+ http = await stack.enter_async_context(HttpClient(offline=options.offline))
165
+ if cache is None:
166
+ cache = Cache.default() if options.cache else Cache.disabled()
167
+ stack.callback(cache.close)
168
+ clients: dict[Ecosystem, Registry] = {}
169
+ unsupported: dict[Ecosystem, str] = {}
170
+ ecosystems = {dependency.ecosystem for _, dependency in to_check}
171
+ ecosystems |= {dependency.ecosystem for _, dependency, _ in audited}
172
+ for ecosystem in sorted(ecosystems):
173
+ try:
174
+ clients[ecosystem] = registries.for_ecosystem(ecosystem, http, cache)
175
+ except LookupError as error:
176
+ unsupported[ecosystem] = str(error)
177
+ packages = sorted(
178
+ {
179
+ (dependency.ecosystem, clients[dependency.ecosystem].normalize(dependency.name))
180
+ for _, dependency in to_check
181
+ if dependency.ecosystem in clients
182
+ }
183
+ )
184
+ progress = _Progress(emit, total=len(packages) + (1 if queries else 0))
185
+ emit(Event(kind=EventKind.CHECKING, total=progress.total))
186
+ outcomes, found = await asyncio.gather(
187
+ _lookup_all(clients, packages, progress),
188
+ _vulnerabilities(Osv(http, cache), queries, progress),
189
+ )
190
+ # Les versions publiées des indirectes en faille, pour choisir celle qui corrige.
191
+ vulnerable = {
192
+ (dependency.ecosystem, clients[dependency.ecosystem].normalize(dependency.name))
193
+ for _, dependency, current in audited
194
+ if dependency.ecosystem in clients
195
+ and isinstance(result := found[_query(dependency, current)], Vulnerabilities)
196
+ and result.advisories
197
+ }
198
+ outcomes |= await _lookup_all(clients, sorted(vulnerable - set(outcomes)), None)
199
+
200
+ for project, dependency in to_check:
201
+ client = clients.get(dependency.ecosystem)
202
+ if client is None:
203
+ outcome: _Outcome = RegistryError(unsupported[dependency.ecosystem])
204
+ else:
205
+ outcome = outcomes[dependency.ecosystem, client.normalize(dependency.name)]
206
+ project.findings.extend(_findings(dependency, outcome))
207
+ unchecked: dict[tuple[int, str, str], int] = {}
208
+ for project, dependency, current in audited:
209
+ result = found[_query(dependency, current)]
210
+ if isinstance(result, Vulnerabilities):
211
+ project.findings.extend(vulns.check(dependency, current, result))
212
+ elif checks.selected(dependency, options):
213
+ project.findings.append(vulns.unchecked(dependency, current, result))
214
+ else:
215
+ # Des centaines d'indirectes « non concluantes » noieraient le rapport : une
216
+ # remarque par lockfile le dit à leur place.
217
+ key = (id(project), dependency.file, str(result))
218
+ unchecked[key] = unchecked.get(key, 0) + 1
219
+ for project in report.projects:
220
+ for (owner, file, error), count in unchecked.items():
221
+ if owner == id(project):
222
+ indirect = "dépendance indirecte" if count == 1 else "dépendances indirectes"
223
+ message = f"Failles non vérifiées pour {count} {indirect}. {error}"
224
+ project.notices.append(Notice(file=file, message=message))
225
+ project.findings = checks.sort_findings(project.findings)
226
+ infos = {
227
+ (dependency.ecosystem, dependency.name): outcome
228
+ for project in report.projects
229
+ for dependency in project.dependencies
230
+ if dependency.ecosystem in clients
231
+ and isinstance(
232
+ outcome := outcomes.get(
233
+ (dependency.ecosystem, clients[dependency.ecosystem].normalize(dependency.name))
234
+ ),
235
+ PackageInfo,
236
+ )
237
+ }
238
+ planner.plan(report, infos, {q: v for q, v in found.items() if isinstance(v, Vulnerabilities)})
239
+ dates = [o.fetched_at for o in outcomes.values() if isinstance(o, PackageInfo)]
240
+ dates += [f.fetched_at for f in found.values() if isinstance(f, Vulnerabilities)]
241
+ report.oldest_data = min(dates, default=None)
242
+
243
+
244
+ def _query(dependency: Dependency, current: Current) -> Query:
245
+ assert current.version is not None
246
+ return dependency.ecosystem, dependency.name, current.version
247
+
248
+
249
+ class _Progress:
250
+ """Compte les vérifications terminées (les paquets des registres, puis les failles) pour
251
+ les événements de progression."""
252
+
253
+ def __init__(self, emit: EventCallback, *, total: int) -> None:
254
+ self.emit = emit
255
+ self.total = total
256
+ self.done = 0
257
+
258
+ def step(
259
+ self, kind: EventKind, *, ecosystem: Ecosystem | None = None, name: str | None = None
260
+ ) -> None:
261
+ self.done += 1
262
+ self.emit(
263
+ Event(kind=kind, done=self.done, total=self.total, ecosystem=ecosystem, name=name)
264
+ )
265
+
266
+
267
+ async def _vulnerabilities(
268
+ osv: Osv, queries: list[Query], progress: _Progress
269
+ ) -> dict[Query, _Found]:
270
+ """Les failles de toutes les versions demandées, auprès d'OSV."""
271
+ if not queries:
272
+ return {}
273
+ found = await osv.vulnerabilities(queries)
274
+ progress.step(EventKind.VULNERABILITIES_CHECKED)
275
+ return found
276
+
277
+
278
+ async def _lookup_all(
279
+ clients: dict[Ecosystem, Registry],
280
+ packages: list[tuple[Ecosystem, str]],
281
+ progress: _Progress | None,
282
+ ) -> dict[tuple[Ecosystem, str], _Outcome]:
283
+ """Demande tous les paquets en même temps ; un événement par paquet vérifié, sauf sans
284
+ ``progress``."""
285
+
286
+ async def lookup(ecosystem: Ecosystem, name: str) -> PackageInfo:
287
+ try:
288
+ return await clients[ecosystem].package(name)
289
+ finally:
290
+ if progress is not None:
291
+ progress.step(EventKind.PACKAGE_CHECKED, ecosystem=ecosystem, name=name)
292
+
293
+ results = await asyncio.gather(
294
+ *(lookup(ecosystem, name) for ecosystem, name in packages), return_exceptions=True
295
+ )
296
+ for result in results:
297
+ if isinstance(result, BaseException) and not isinstance(
298
+ result, PackageNotFound | RegistryError
299
+ ):
300
+ raise result
301
+ return dict(zip(packages, results, strict=True)) # type: ignore[arg-type]
302
+
303
+
304
+ def _findings(dependency: Dependency, outcome: _Outcome) -> list[Finding]:
305
+ if isinstance(outcome, PackageNotFound):
306
+ return [Finding(kind=FindingKind.UNVERIFIABLE, message=str(outcome), dependency=dependency)]
307
+ if isinstance(outcome, RegistryError):
308
+ return [Finding(kind=FindingKind.INCONCLUSIVE, message=str(outcome), dependency=dependency)]
309
+ return checks.check_dependency(dependency, outcome)
310
+
311
+
312
+ def _ignore(_: Event) -> None:
313
+ pass