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/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
|