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,98 @@
|
|
|
1
|
+
"""Ce que partagent les contrôles : la version actuelle d'une dépendance, la plus proche
|
|
2
|
+
version de remplacement, les noms des écarts.
|
|
3
|
+
|
|
4
|
+
La version actuelle (brief, décision n°2) est celle du lockfile. Sans lockfile, c'est la
|
|
5
|
+
version qu'impose une contrainte exacte (``==1.2.3``), sinon la borne basse de la contrainte,
|
|
6
|
+
marquée « approximative » : la version réellement installée peut être plus haute. Sans borne
|
|
7
|
+
basse (``*``, ``<2``, une étiquette npm), on ne la connaît pas, et on le dit.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from collections.abc import Iterable
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
from forceupdate import versions
|
|
14
|
+
from forceupdate.models import Dependency, Gap
|
|
15
|
+
from forceupdate.versions import InvalidConstraint, VersionScheme
|
|
16
|
+
from forceupdate.versions.npm import NpmRange
|
|
17
|
+
|
|
18
|
+
GAP_LABELS = {Gap.MAJOR: "majeure", Gap.MINOR: "mineure", Gap.PATCH: "correctif"}
|
|
19
|
+
"""L'écart en français, tel qu'on l'affiche."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True, kw_only=True)
|
|
23
|
+
class Current:
|
|
24
|
+
"""La version actuelle d'une dépendance : celle qu'on compare à ce que dit le registre."""
|
|
25
|
+
|
|
26
|
+
version: str | None
|
|
27
|
+
"""L'installée ; sinon celle qu'impose une contrainte exacte ; sinon la borne basse de la
|
|
28
|
+
contrainte. ``None`` si on ne peut rien en dire (voir ``unknown``)."""
|
|
29
|
+
approximate: bool = False
|
|
30
|
+
"""Vrai pour une borne basse : la version réellement installée peut être plus haute."""
|
|
31
|
+
constraint: str | None = None
|
|
32
|
+
"""La contrainte à interpréter (``Dependency.effective_constraint``) ; ``None`` si elle est
|
|
33
|
+
illisible (ou un ``catalog:`` introuvable) : on se fie alors à la version installée."""
|
|
34
|
+
unknown: str | None = None
|
|
35
|
+
"""Pourquoi la version actuelle est inconnue, en français, quand ``version`` est ``None``."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def current_version(dependency: Dependency) -> Current:
|
|
39
|
+
"""La version actuelle de ``dependency`` (voir ``Current``)."""
|
|
40
|
+
scheme = versions.for_ecosystem(dependency.ecosystem)
|
|
41
|
+
text = dependency.effective_constraint
|
|
42
|
+
try:
|
|
43
|
+
parsed = scheme.parse_constraint(text)
|
|
44
|
+
except InvalidConstraint:
|
|
45
|
+
parsed = None
|
|
46
|
+
constraint = None if parsed is None else text
|
|
47
|
+
if dependency.installed is not None:
|
|
48
|
+
return Current(version=dependency.installed, constraint=constraint)
|
|
49
|
+
if parsed is None:
|
|
50
|
+
if dependency.constraint.startswith("catalog:") and dependency.resolved_constraint is None:
|
|
51
|
+
why = f"la plage de « {dependency.constraint} » est introuvable"
|
|
52
|
+
else:
|
|
53
|
+
why = f"la contrainte « {text} » est illisible"
|
|
54
|
+
return Current(version=None, unknown=f"{why}, et pas de version installée")
|
|
55
|
+
exact = parsed.exact()
|
|
56
|
+
if exact is not None:
|
|
57
|
+
return Current(version=exact, constraint=constraint)
|
|
58
|
+
lower = parsed.lower_bound()
|
|
59
|
+
if lower is not None:
|
|
60
|
+
return Current(version=lower, approximate=True, constraint=constraint)
|
|
61
|
+
if not text.strip():
|
|
62
|
+
why = "aucune contrainte"
|
|
63
|
+
elif isinstance(parsed, NpmRange) and parsed.tag is not None:
|
|
64
|
+
why = f"l'étiquette « {parsed.tag} » suit le registre"
|
|
65
|
+
else:
|
|
66
|
+
why = f"la contrainte « {text} » n'a pas de borne basse"
|
|
67
|
+
return Current(
|
|
68
|
+
version=None, constraint=constraint, unknown=f"{why}, et pas de version installée"
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def sentence(text: str) -> str:
|
|
73
|
+
"""``text`` terminé par un point, sans en doubler un (le message d'un registre ou d'un avis
|
|
74
|
+
peut déjà finir par ``.``, ``!``, ``?`` ou ``…``)."""
|
|
75
|
+
text = text.rstrip()
|
|
76
|
+
return text if text.endswith((".", "!", "?", "…")) else text + "."
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def nearest(scheme: VersionScheme, current: str, choices: Iterable[str]) -> str | None:
|
|
80
|
+
"""La version de ``choices`` la plus proche de ``current``, pour remplacer une version
|
|
81
|
+
retirée ou dépréciée : la plus petite au-dessus sans changer de majeure (souvent celle
|
|
82
|
+
qui corrige), sinon la plus haute en dessous, sinon la plus petite au-dessus. Parmi les
|
|
83
|
+
candidates seulement (pas de pré-version, sauf celles de ``current`` si c'en est une).
|
|
84
|
+
``None`` s'il n'y en a aucune."""
|
|
85
|
+
above, below = [], []
|
|
86
|
+
for version in scheme.candidates(choices, current):
|
|
87
|
+
order = scheme.compare(version, current)
|
|
88
|
+
if order > 0:
|
|
89
|
+
above.append(version)
|
|
90
|
+
elif order < 0:
|
|
91
|
+
below.append(version)
|
|
92
|
+
above.sort(key=scheme.sort_key)
|
|
93
|
+
same_line = [v for v in above if scheme.gap(current, v) is not Gap.MAJOR]
|
|
94
|
+
if same_line:
|
|
95
|
+
return same_line[0]
|
|
96
|
+
if below:
|
|
97
|
+
return max(below, key=scheme.sort_key)
|
|
98
|
+
return above[0] if above else None
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Type 4 du brief : le paquet est abandonné ou déprécié, la version utilisée est retirée.
|
|
2
|
+
|
|
3
|
+
- **paquet déprécié** (``PackageInfo.deprecated``) : projet PyPI archivé ou déprécié, paquet
|
|
4
|
+
npm dont la ``latest`` est dépréciée, ou retiré par l'équipe sécurité de npm. Signalé, sans
|
|
5
|
+
version à proposer : le remplacer est un choix humain ;
|
|
6
|
+
- **version retirée** (PyPI, ``yanked``), avec la raison donnée : à remplacer par la plus
|
|
7
|
+
proche non retirée (``checks.base.nearest``) ;
|
|
8
|
+
- **version dépréciée** (npm, qui n'a pas de retrait : c'est sa façon de désavouer une version,
|
|
9
|
+
lodash 4.18.0 « Bad release ») : à remplacer par la plus proche non dépréciée. Quand tout le
|
|
10
|
+
paquet est déprécié, seul le paquet est signalé ;
|
|
11
|
+
- **version inconnue du registre** : dépubliée, ou venue d'un autre index. On ne peut pas dire
|
|
12
|
+
si elle est retirée : « non concluant ».
|
|
13
|
+
|
|
14
|
+
Seule une version connue est examinée : l'installée, ou celle qu'impose une contrainte exacte.
|
|
15
|
+
Une borne basse (``>=2.32.0``) ne dit pas qu'on utilise ``2.32.0``.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from forceupdate import versions
|
|
19
|
+
from forceupdate.checks.base import Current, nearest, sentence
|
|
20
|
+
from forceupdate.models import Dependency, Finding, FindingKind, Gap, Severity
|
|
21
|
+
from forceupdate.registries import PackageInfo
|
|
22
|
+
from forceupdate.versions import InvalidVersion
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def check(dependency: Dependency, info: PackageInfo, current: Current) -> list[Finding]:
|
|
26
|
+
"""Les constats « déprécié » et « retiré » de ``dependency`` ; une liste vide s'il n'y a
|
|
27
|
+
rien à signaler."""
|
|
28
|
+
findings = []
|
|
29
|
+
common = {
|
|
30
|
+
"dependency": dependency,
|
|
31
|
+
"current_version": current.version,
|
|
32
|
+
"current_is_approximate": current.approximate,
|
|
33
|
+
"url": info.url,
|
|
34
|
+
}
|
|
35
|
+
if info.deprecated is not None:
|
|
36
|
+
findings.append(
|
|
37
|
+
Finding(
|
|
38
|
+
kind=FindingKind.DEPRECATED,
|
|
39
|
+
severity=Severity.LOW,
|
|
40
|
+
message=f"Paquet déprécié : {info.deprecated}",
|
|
41
|
+
detail=info.deprecated,
|
|
42
|
+
**common,
|
|
43
|
+
)
|
|
44
|
+
)
|
|
45
|
+
if current.version is None or current.approximate:
|
|
46
|
+
return findings
|
|
47
|
+
registry = dependency.ecosystem.value
|
|
48
|
+
published = info.get(current.version)
|
|
49
|
+
if published is None:
|
|
50
|
+
findings.append(
|
|
51
|
+
Finding(
|
|
52
|
+
kind=FindingKind.INCONCLUSIVE,
|
|
53
|
+
message=(
|
|
54
|
+
f"Version {current.version} inconnue de {registry} : dépubliée, ou venue "
|
|
55
|
+
f"d'un autre index. Impossible de dire si elle est retirée."
|
|
56
|
+
),
|
|
57
|
+
**common,
|
|
58
|
+
)
|
|
59
|
+
)
|
|
60
|
+
return findings
|
|
61
|
+
scheme = versions.for_ecosystem(dependency.ecosystem)
|
|
62
|
+
if published.yanked:
|
|
63
|
+
target = _nearest(scheme, current.version, info.available())
|
|
64
|
+
reason = f" : {published.yanked_reason}" if published.yanked_reason else ""
|
|
65
|
+
reason = sentence(f"Version {current.version} retirée de {registry}{reason}")
|
|
66
|
+
replacement = (
|
|
67
|
+
f"La plus proche non retirée : {target}."
|
|
68
|
+
if target is not None
|
|
69
|
+
else "Aucune version non retirée à proposer."
|
|
70
|
+
)
|
|
71
|
+
findings.append(
|
|
72
|
+
Finding(
|
|
73
|
+
kind=FindingKind.YANKED,
|
|
74
|
+
severity=Severity.MEDIUM,
|
|
75
|
+
message=f"{reason} {replacement}",
|
|
76
|
+
target_version=target,
|
|
77
|
+
gap=_gap(scheme, current.version, target),
|
|
78
|
+
detail=published.yanked_reason,
|
|
79
|
+
**common,
|
|
80
|
+
)
|
|
81
|
+
)
|
|
82
|
+
elif published.deprecated is not None and info.deprecated is None:
|
|
83
|
+
fresh = [v.version for v in info.versions if not v.yanked and v.deprecated is None]
|
|
84
|
+
target = _nearest(scheme, current.version, fresh)
|
|
85
|
+
replacement = (
|
|
86
|
+
f"La plus proche non dépréciée : {target}."
|
|
87
|
+
if target is not None
|
|
88
|
+
else "Aucune version non dépréciée à proposer."
|
|
89
|
+
)
|
|
90
|
+
findings.append(
|
|
91
|
+
Finding(
|
|
92
|
+
kind=FindingKind.DEPRECATED,
|
|
93
|
+
severity=Severity.LOW,
|
|
94
|
+
message=(
|
|
95
|
+
sentence(f"Version {current.version} dépréciée : {published.deprecated}")
|
|
96
|
+
+ f" {replacement}"
|
|
97
|
+
),
|
|
98
|
+
target_version=target,
|
|
99
|
+
gap=_gap(scheme, current.version, target),
|
|
100
|
+
detail=published.deprecated,
|
|
101
|
+
**common,
|
|
102
|
+
)
|
|
103
|
+
)
|
|
104
|
+
return findings
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _nearest(scheme: versions.VersionScheme, current: str, choices: list[str]) -> str | None:
|
|
108
|
+
try:
|
|
109
|
+
return nearest(scheme, current, choices)
|
|
110
|
+
except InvalidVersion: # version utilisée illisible : outdated le dit déjà
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _gap(scheme: versions.VersionScheme, current: str, target: str | None) -> Gap | None:
|
|
115
|
+
return None if target is None else scheme.gap(current, target)
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Type 1 du brief : une version plus récente existe.
|
|
2
|
+
|
|
3
|
+
Pour une dépendance, trois versions, comme ``npm outdated`` :
|
|
4
|
+
|
|
5
|
+
- **utilisée** : la version actuelle (``checks.base.current_version``) ;
|
|
6
|
+
- **dans la contrainte** (``wanted_version``) : la plus haute que la contrainte accepte déjà.
|
|
7
|
+
On y monte en régénérant le lockfile, sans toucher au manifeste ;
|
|
8
|
+
- **dernière** (``target_version``) : la dernière stable du registre (``PackageInfo.latest``),
|
|
9
|
+
à deux précisions près, prises à ``npm outdated`` :
|
|
10
|
+
|
|
11
|
+
- si elle est dépréciée, la plus haute non dépréciée qui ne la dépasse pas, s'il y en a :
|
|
12
|
+
``@types/parse-json`` 7.0.0 est un paquet vide déprécié, on ne le propose pas à qui a la
|
|
13
|
+
4.0.2. Le paquet déprécié est signalé à part (``deprecated.py``) ;
|
|
14
|
+
- si la version utilisée est une pré-version, une pré-version plus récente de la même version
|
|
15
|
+
compte aussi (de ``2.0.0-rc.1``, on propose ``2.0.0-rc.2``), et l'étiquette ``latest`` de
|
|
16
|
+
npm même si c'est une pré-version : chromium-bidi publie ainsi toutes ses versions
|
|
17
|
+
(``158.0.8127-2``).
|
|
18
|
+
|
|
19
|
+
Le constat porte l'écart entre la version utilisée et la dernière. Une version utilisée au-
|
|
20
|
+
dessus de la dernière (la voie ``next`` de npm) ne donne rien.
|
|
21
|
+
|
|
22
|
+
``npm outdated`` écarte aussi les versions que le Node installé ne peut pas faire tourner
|
|
23
|
+
(``engines``) : jsdom 30 demande Node ``^24.15.0``, il ne la propose pas sous Node 24.12.
|
|
24
|
+
forceupdate ne connaît pas le Node de la machine (ni celui d'un dépôt distant), et la propose.
|
|
25
|
+
|
|
26
|
+
**Dans la contrainte, pour npm (tranché à E07)** : on choisit comme ``npm install`` et
|
|
27
|
+
``npm outdated`` (``npm-pick-manifest``), pas simplement la plus haute qui respecte la plage.
|
|
28
|
+
La version de l'étiquette ``latest`` si la plage l'accepte et qu'elle n'est pas dépréciée ;
|
|
29
|
+
sinon la plus haute non dépréciée ; sinon la plus haute. Une plage ``>=4`` donne donc la
|
|
30
|
+
``latest`` 4.17.21, pas une 5.0.0 publiée sous ``next``. Une étiquette (``"react": "next"``)
|
|
31
|
+
donne sa version. PyPI : la plus haute non retirée qui respecte la contrainte, comme pip.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from forceupdate import versions
|
|
35
|
+
from forceupdate.checks.base import GAP_LABELS, Current
|
|
36
|
+
from forceupdate.models import Dependency, Ecosystem, Finding, FindingKind, Gap, Severity
|
|
37
|
+
from forceupdate.registries import PackageInfo
|
|
38
|
+
from forceupdate.versions import InvalidVersion, VersionScheme
|
|
39
|
+
from forceupdate.versions.npm import NPM, NpmRange
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def check(dependency: Dependency, info: PackageInfo, current: Current) -> list[Finding]:
|
|
43
|
+
"""Le constat « plus récente » de ``dependency`` ; une liste vide si elle est à jour, ou
|
|
44
|
+
si le registre n'a rien à proposer (pas de dernière stable)."""
|
|
45
|
+
scheme = versions.for_ecosystem(dependency.ecosystem)
|
|
46
|
+
if current.version is not None:
|
|
47
|
+
try:
|
|
48
|
+
scheme.parse(current.version)
|
|
49
|
+
except InvalidVersion:
|
|
50
|
+
return [
|
|
51
|
+
Finding(
|
|
52
|
+
kind=FindingKind.INCONCLUSIVE,
|
|
53
|
+
message=(
|
|
54
|
+
f"Version « {current.version} » illisible selon les règles de "
|
|
55
|
+
f"{dependency.ecosystem} : impossible de la comparer."
|
|
56
|
+
),
|
|
57
|
+
dependency=dependency,
|
|
58
|
+
current_version=current.version,
|
|
59
|
+
url=info.url,
|
|
60
|
+
)
|
|
61
|
+
]
|
|
62
|
+
target = _target(scheme, info, current.version)
|
|
63
|
+
if target is None:
|
|
64
|
+
return []
|
|
65
|
+
wanted = wanted_version(dependency, info, current)
|
|
66
|
+
if current.version is None:
|
|
67
|
+
return [
|
|
68
|
+
Finding(
|
|
69
|
+
kind=FindingKind.INCONCLUSIVE,
|
|
70
|
+
message=f"Version utilisée inconnue : {current.unknown}. Dernière : {target}.",
|
|
71
|
+
dependency=dependency,
|
|
72
|
+
target_version=target,
|
|
73
|
+
wanted_version=wanted,
|
|
74
|
+
url=info.url,
|
|
75
|
+
)
|
|
76
|
+
]
|
|
77
|
+
gap = scheme.gap(current.version, target)
|
|
78
|
+
if gap is None:
|
|
79
|
+
return []
|
|
80
|
+
return [
|
|
81
|
+
Finding(
|
|
82
|
+
kind=FindingKind.OUTDATED,
|
|
83
|
+
severity=Severity.LOW if gap is Gap.MAJOR else Severity.INFO,
|
|
84
|
+
message=_message(scheme, current, target, gap, wanted),
|
|
85
|
+
dependency=dependency,
|
|
86
|
+
current_version=current.version,
|
|
87
|
+
current_is_approximate=current.approximate,
|
|
88
|
+
target_version=target,
|
|
89
|
+
wanted_version=wanted,
|
|
90
|
+
gap=gap,
|
|
91
|
+
url=info.url,
|
|
92
|
+
)
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def wanted_version(dependency: Dependency, info: PackageInfo, current: Current) -> str | None:
|
|
97
|
+
"""La plus haute version que la contrainte accepte déjà, choisie comme le ferait le
|
|
98
|
+
gestionnaire de paquets ; ``None`` pour une dépendance indirecte (sa contrainte est celle
|
|
99
|
+
du paquet qui la tire) ou une contrainte illisible."""
|
|
100
|
+
if not dependency.direct or current.constraint is None:
|
|
101
|
+
return None
|
|
102
|
+
scheme = versions.for_ecosystem(dependency.ecosystem)
|
|
103
|
+
constraint = scheme.parse_constraint(current.constraint)
|
|
104
|
+
if isinstance(constraint, NpmRange) and constraint.tag is not None:
|
|
105
|
+
return info.tags.get(constraint.tag)
|
|
106
|
+
if dependency.ecosystem is Ecosystem.NPM:
|
|
107
|
+
return _npm_wanted(current.constraint, info, current.version)
|
|
108
|
+
return scheme.max_satisfying(current.constraint, info.available(), current.version)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _npm_wanted(constraint: str, info: PackageInfo, current: str | None) -> str | None:
|
|
112
|
+
"""Le choix de ``npm-pick-manifest`` pour une plage (voir la docstring du module)."""
|
|
113
|
+
tagged = info.tags.get("latest")
|
|
114
|
+
published = None if tagged is None else info.get(tagged)
|
|
115
|
+
if published is not None and published.deprecated is None:
|
|
116
|
+
try:
|
|
117
|
+
if NPM.satisfies(constraint, published.version):
|
|
118
|
+
return published.version
|
|
119
|
+
except InvalidVersion:
|
|
120
|
+
pass
|
|
121
|
+
fresh = [v.version for v in info.versions if not v.yanked and v.deprecated is None]
|
|
122
|
+
return NPM.max_satisfying(constraint, fresh, current) or NPM.max_satisfying(
|
|
123
|
+
constraint, info.available(), current
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _target(scheme: VersionScheme, info: PackageInfo, current: str | None) -> str | None:
|
|
128
|
+
"""La version à proposer (voir la docstring du module)."""
|
|
129
|
+
target = info.latest
|
|
130
|
+
published = None if target is None else info.get(target)
|
|
131
|
+
if target is not None and published is not None and published.deprecated is not None:
|
|
132
|
+
ceiling = scheme.sort_key(target)
|
|
133
|
+
fresh = [
|
|
134
|
+
v.version
|
|
135
|
+
for v in info.versions
|
|
136
|
+
if not v.yanked and v.deprecated is None and scheme.sort_key(v.version) <= ceiling
|
|
137
|
+
]
|
|
138
|
+
target = scheme.latest(fresh) or target
|
|
139
|
+
if current is None or not scheme.is_prerelease(current):
|
|
140
|
+
return target
|
|
141
|
+
line = [v for v in scheme.candidates(info.available(), current) if scheme.is_prerelease(v)]
|
|
142
|
+
for option in (scheme.latest(line, current), _prerelease_tag(scheme, info, current)):
|
|
143
|
+
if option is not None and (target is None or scheme.compare(option, target) > 0):
|
|
144
|
+
target = option
|
|
145
|
+
return target
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _prerelease_tag(scheme: VersionScheme, info: PackageInfo, current: str) -> str | None:
|
|
149
|
+
"""L'étiquette ``latest`` de npm si c'est une pré-version plus récente que ``current``, non
|
|
150
|
+
dépréciée ; ``None`` sinon."""
|
|
151
|
+
tagged = info.tags.get("latest")
|
|
152
|
+
if tagged is None:
|
|
153
|
+
return None
|
|
154
|
+
published = info.get(tagged)
|
|
155
|
+
if published is None or published.yanked or published.deprecated is not None:
|
|
156
|
+
return None
|
|
157
|
+
try:
|
|
158
|
+
if scheme.is_prerelease(tagged) and scheme.compare(tagged, current) > 0:
|
|
159
|
+
return tagged
|
|
160
|
+
except InvalidVersion:
|
|
161
|
+
pass
|
|
162
|
+
return None
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _message(
|
|
166
|
+
scheme: VersionScheme, current: Current, target: str, gap: Gap, wanted: str | None
|
|
167
|
+
) -> str:
|
|
168
|
+
assert current.version is not None
|
|
169
|
+
if current.approximate:
|
|
170
|
+
used = f"au moins {current.version} d'après la contrainte (pas de version installée)"
|
|
171
|
+
else:
|
|
172
|
+
used = current.version
|
|
173
|
+
text = f"Version {target} disponible ({GAP_LABELS[gap]}) ; utilisée : {used}."
|
|
174
|
+
if wanted is None:
|
|
175
|
+
return text
|
|
176
|
+
try:
|
|
177
|
+
if scheme.compare(wanted, target) == 0:
|
|
178
|
+
text += " La contrainte l'accepte déjà."
|
|
179
|
+
elif scheme.compare(wanted, current.version) > 0:
|
|
180
|
+
text += f" La contrainte accepte jusqu'à {wanted}."
|
|
181
|
+
except InvalidVersion: # une étiquette npm qui pointe vers une version illisible
|
|
182
|
+
pass
|
|
183
|
+
return text
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""L'ordre des constats, et le seuil qui fait échouer ``scan --fail-on``.
|
|
2
|
+
|
|
3
|
+
L'ordre est celui du brief (décision n°8) : faille (par sévérité) > fin de vie > retiré >
|
|
4
|
+
déprécié > majeure > mineure > correctif. Viennent ensuite ce qui n'a pas pu être vérifié
|
|
5
|
+
(non vérifiable), puis ce qui n'a rien conclu (non concluant).
|
|
6
|
+
|
|
7
|
+
**Gravité des constats autres que les failles (tranché à E07)**. Une faille a celle de son
|
|
8
|
+
avis (CVSS, E08). Pour les autres, elle sert à l'affichage ; l'ordre, lui, suit le type :
|
|
9
|
+
|
|
10
|
+
- version retirée : ``MEDIUM`` (ses mainteneurs l'ont retirée, souvent parce qu'elle casse
|
|
11
|
+
ou qu'elle est dangereuse) ;
|
|
12
|
+
- paquet ou version déprécié : ``LOW`` ;
|
|
13
|
+
- version plus récente : ``LOW`` pour une majeure, ``INFO`` pour une mineure ou un correctif ;
|
|
14
|
+
- non vérifiable, non concluant : ``INFO``.
|
|
15
|
+
|
|
16
|
+
Un paquet malveillant retiré par npm (crossenv) est aussi une faille critique : OSV le connaît
|
|
17
|
+
(``checks.vulns``).
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import enum
|
|
21
|
+
from collections.abc import Iterable
|
|
22
|
+
|
|
23
|
+
from forceupdate.models import Finding, FindingKind, Gap
|
|
24
|
+
|
|
25
|
+
_KIND_ORDER = {
|
|
26
|
+
FindingKind.VULNERABLE: 0,
|
|
27
|
+
FindingKind.EOL: 1,
|
|
28
|
+
FindingKind.YANKED: 2,
|
|
29
|
+
FindingKind.DEPRECATED: 3,
|
|
30
|
+
FindingKind.OUTDATED: 4,
|
|
31
|
+
FindingKind.UNVERIFIABLE: 5,
|
|
32
|
+
FindingKind.INCONCLUSIVE: 6,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def priority(finding: Finding) -> tuple[int, int]:
|
|
37
|
+
"""La clé de tri d'un constat : la plus petite pour le plus grave."""
|
|
38
|
+
rank = _KIND_ORDER[finding.kind]
|
|
39
|
+
if finding.kind in (FindingKind.VULNERABLE, FindingKind.EOL):
|
|
40
|
+
return rank, -finding.severity.rank
|
|
41
|
+
if finding.kind is FindingKind.OUTDATED and finding.gap is not None:
|
|
42
|
+
return rank, -finding.gap.rank
|
|
43
|
+
return rank, 0
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def sort_findings(findings: Iterable[Finding]) -> list[Finding]:
|
|
47
|
+
"""Les constats du plus grave au moins grave ; à gravité égale, par nom de paquet."""
|
|
48
|
+
return sorted(findings, key=_sort_key)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _sort_key(finding: Finding) -> tuple[tuple[int, int], str]:
|
|
52
|
+
name = finding.dependency.name if finding.dependency is not None else ""
|
|
53
|
+
return priority(finding), name
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class FailOn(enum.StrEnum):
|
|
57
|
+
"""Le seuil de ``scan --fail-on`` : à partir de quel constat l'analyse échoue (code 1)."""
|
|
58
|
+
|
|
59
|
+
VULN = "vuln"
|
|
60
|
+
"""Une faille."""
|
|
61
|
+
MAJOR = "major"
|
|
62
|
+
"""Une faille, une fin de vie, une version retirée, un paquet ou une version déprécié, une
|
|
63
|
+
version majeure plus récente : tout ce qui précède les mineures dans l'ordre du brief."""
|
|
64
|
+
ANY = "any"
|
|
65
|
+
"""Tout ce qui est à mettre à jour, mineures et correctifs compris. Ce qui n'a pas pu être
|
|
66
|
+
vérifié ou n'a rien conclu ne compte jamais."""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def reaches(finding: Finding, threshold: FailOn) -> bool:
|
|
70
|
+
"""Vrai si ``finding`` atteint le seuil ``threshold``."""
|
|
71
|
+
if threshold is FailOn.VULN:
|
|
72
|
+
return finding.kind is FindingKind.VULNERABLE
|
|
73
|
+
if finding.kind in (FindingKind.UNVERIFIABLE, FindingKind.INCONCLUSIVE):
|
|
74
|
+
return False
|
|
75
|
+
if threshold is FailOn.ANY:
|
|
76
|
+
return True
|
|
77
|
+
return priority(finding) <= (_KIND_ORDER[FindingKind.OUTDATED], -Gap.MAJOR.rank)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def fails(findings: Iterable[Finding], threshold: FailOn) -> bool:
|
|
81
|
+
"""Vrai si un des constats atteint le seuil."""
|
|
82
|
+
return any(reaches(finding, threshold) for finding in findings)
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""Type 2 du brief : la version utilisée a une faille connue.
|
|
2
|
+
|
|
3
|
+
Les failles se cherchent sur toutes les dépendances vérifiables, indirectes comprises (brief,
|
|
4
|
+
décision n°3), quelle que soit ``--transitive``, qui ne vaut que pour les versions plus
|
|
5
|
+
récentes et les retraits ; celles de développement sauf avec ``--no-dev``. C'est le moteur qui
|
|
6
|
+
interroge OSV (``advisories``) ; ce module en tire les constats.
|
|
7
|
+
|
|
8
|
+
- **Une faille, un constat.** OSV a souvent plusieurs avis pour la même faille : celui de
|
|
9
|
+
GitHub (GHSA) et celui de PyPI (PYSEC), qui se citent l'un l'autre dans leurs alias. Ils
|
|
10
|
+
sont réunis, avec tous leurs identifiants : les CVE d'abord, puis les GHSA, puis les autres.
|
|
11
|
+
Les avis d'un paquet malveillant sont tous réunis : ils disent la même chose.
|
|
12
|
+
- **Gravité** : celle du plus grave des avis réunis (CVSS 3, sinon celle de GitHub). Un paquet
|
|
13
|
+
malveillant est toujours critique. Sans rien pour en juger, moyenne : le message dit
|
|
14
|
+
« gravité inconnue ».
|
|
15
|
+
- **Version corrigée** (``target_version``) : la plus petite qui corrige la faille au-dessus de
|
|
16
|
+
la version utilisée (``Advisory.fixed_in``) ; si les avis réunis ne sont pas d'accord, la
|
|
17
|
+
plus haute des leurs.
|
|
18
|
+
- **Version utilisée** : l'installée, ou celle d'une contrainte exacte. Sans lockfile, la
|
|
19
|
+
borne basse de la contrainte, « approximative » : la faille n'est alors que **possible**
|
|
20
|
+
(affichée « faille possible »), car la version réellement installée peut être plus récente,
|
|
21
|
+
et corrigée. Elle reste une faille, et compte pour ``--fail-on vuln`` : la contrainte
|
|
22
|
+
accepte une version concernée, et la relever la corrige. Sans version utilisée (``*``, une
|
|
23
|
+
étiquette npm), rien n'est demandé à OSV ; le contrôle « plus récente » le dit déjà.
|
|
24
|
+
- **Erreur** : jamais « pas de faille ». Une version qu'OSV n'a pas pu examiner donne un
|
|
25
|
+
constat « non concluant » (``unchecked``) ; un avis illisible reste une faille, de gravité et
|
|
26
|
+
de version corrigée inconnues.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from collections.abc import Sequence
|
|
30
|
+
|
|
31
|
+
from forceupdate import versions
|
|
32
|
+
from forceupdate.advisories import Advisory, OsvError, Vulnerabilities
|
|
33
|
+
from forceupdate.checks.base import Current, current_version, sentence
|
|
34
|
+
from forceupdate.models import Dependency, Finding, FindingKind, ScanOptions, Scope, Severity
|
|
35
|
+
|
|
36
|
+
SEVERITY_LABELS = {
|
|
37
|
+
Severity.CRITICAL: "critique",
|
|
38
|
+
Severity.HIGH: "élevée",
|
|
39
|
+
Severity.MEDIUM: "moyenne",
|
|
40
|
+
Severity.LOW: "faible",
|
|
41
|
+
Severity.INFO: "nulle",
|
|
42
|
+
}
|
|
43
|
+
"""La gravité d'une faille en français, telle qu'on l'affiche."""
|
|
44
|
+
|
|
45
|
+
_PREFIXES = ("CVE-", "GHSA-")
|
|
46
|
+
"""Les identifiants qu'on cite dans le message, et qui passent en tête de ``identifiers``."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def audited(dependency: Dependency, options: ScanOptions) -> bool:
|
|
50
|
+
"""Vrai si l'analyse cherche les failles de ``dependency`` : vérifiable, directe ou non ;
|
|
51
|
+
de développement seulement avec ``options.dev``. Encore faut-il connaître sa version
|
|
52
|
+
(``version_to_check``)."""
|
|
53
|
+
return dependency.verifiable and (options.dev or dependency.scope is not Scope.DEV)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def version_to_check(dependency: Dependency) -> Current | None:
|
|
57
|
+
"""La version actuelle de ``dependency``, à soumettre à OSV ; ``None`` si on ne la connaît
|
|
58
|
+
pas, ou si les versions de son écosystème ne sont pas encore gérées."""
|
|
59
|
+
try:
|
|
60
|
+
current = current_version(dependency)
|
|
61
|
+
except LookupError:
|
|
62
|
+
return None
|
|
63
|
+
return current if current.version is not None else None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def check(dependency: Dependency, current: Current, found: Vulnerabilities) -> list[Finding]:
|
|
67
|
+
"""Les failles de ``dependency`` en version ``current``, d'après ce qu'OSV en dit
|
|
68
|
+
(``found``) : un constat par faille, les plus graves d'abord (gravité, puis score CVSS).
|
|
69
|
+
Une liste vide s'il n'y en a pas."""
|
|
70
|
+
findings = [_finding(dependency, current, group) for group in _groups(found.advisories)]
|
|
71
|
+
return sorted(findings, key=lambda f: (-f.severity.rank, -(f.cvss_score or 0), f.identifiers))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def unchecked(dependency: Dependency, current: Current, error: OsvError) -> Finding:
|
|
75
|
+
"""Le constat « non concluant » de ``dependency`` quand OSV n'a pas pu dire quelles failles
|
|
76
|
+
concernent sa version."""
|
|
77
|
+
return Finding(
|
|
78
|
+
kind=FindingKind.INCONCLUSIVE,
|
|
79
|
+
message=f"Failles non vérifiées. {error}",
|
|
80
|
+
dependency=dependency,
|
|
81
|
+
current_version=current.version,
|
|
82
|
+
current_is_approximate=current.approximate,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _groups(advisories: Sequence[Advisory]) -> list[list[Advisory]]:
|
|
87
|
+
"""Les avis réunis par faille : ceux qui partagent un identifiant (le leur, ou un alias),
|
|
88
|
+
de proche en proche ; et tous ceux d'un paquet malveillant ensemble. Les avis retirés
|
|
89
|
+
sont écartés."""
|
|
90
|
+
groups: list[tuple[set[str], list[Advisory]]] = []
|
|
91
|
+
malicious = []
|
|
92
|
+
for advisory in advisories:
|
|
93
|
+
if advisory.withdrawn:
|
|
94
|
+
continue
|
|
95
|
+
if advisory.malicious:
|
|
96
|
+
malicious.append(advisory)
|
|
97
|
+
continue
|
|
98
|
+
names = {advisory.id, *advisory.aliases}
|
|
99
|
+
members = [advisory]
|
|
100
|
+
kept = []
|
|
101
|
+
for group_names, group in groups:
|
|
102
|
+
if group_names & names:
|
|
103
|
+
names |= group_names
|
|
104
|
+
members = group + members
|
|
105
|
+
else:
|
|
106
|
+
kept.append((group_names, group))
|
|
107
|
+
groups = [*kept, (names, members)]
|
|
108
|
+
found = [group for _, group in groups]
|
|
109
|
+
if malicious:
|
|
110
|
+
found.append(malicious)
|
|
111
|
+
return found
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _finding(dependency: Dependency, current: Current, group: list[Advisory]) -> Finding:
|
|
115
|
+
assert current.version is not None
|
|
116
|
+
scheme = versions.for_ecosystem(dependency.ecosystem)
|
|
117
|
+
known = [advisory for advisory in group if advisory.unavailable is None]
|
|
118
|
+
identifiers = _identifiers(group)
|
|
119
|
+
shown = ", ".join([i for i in identifiers if i.startswith(_PREFIXES)] or identifiers[:1])
|
|
120
|
+
malicious = any(advisory.malicious for advisory in group)
|
|
121
|
+
severities = [a.severity for a in known if a.severity is not None]
|
|
122
|
+
score = max((a.score for a in known if a.score is not None), default=None)
|
|
123
|
+
if malicious:
|
|
124
|
+
severity = Severity.CRITICAL
|
|
125
|
+
elif severities:
|
|
126
|
+
severity = max(severities, key=lambda s: s.rank)
|
|
127
|
+
else:
|
|
128
|
+
severity = Severity.MEDIUM
|
|
129
|
+
fixes = [
|
|
130
|
+
fix
|
|
131
|
+
for advisory in known
|
|
132
|
+
if (fix := advisory.fixed_in(dependency.ecosystem, dependency.name, current.version))
|
|
133
|
+
]
|
|
134
|
+
target = max(fixes, key=scheme.sort_key) if fixes else None
|
|
135
|
+
main = min(group, key=_main_order)
|
|
136
|
+
summary = next((a.summary for a in sorted(group, key=_main_order) if a.summary), "")
|
|
137
|
+
|
|
138
|
+
if not known:
|
|
139
|
+
reasons = "; ".join(dict.fromkeys(a.unavailable or "" for a in group))
|
|
140
|
+
message = (
|
|
141
|
+
f"{shown} : détail de l'avis indisponible ({reasons}) ; gravité et version corrigée "
|
|
142
|
+
"inconnues."
|
|
143
|
+
)
|
|
144
|
+
else:
|
|
145
|
+
if malicious:
|
|
146
|
+
head = f"Code malveillant ({shown})"
|
|
147
|
+
elif severities:
|
|
148
|
+
cvss = "" if score is None else f", CVSS {score:.1f}".replace(".", ",")
|
|
149
|
+
head = f"{shown} (gravité {SEVERITY_LABELS[severity]}{cvss})"
|
|
150
|
+
else:
|
|
151
|
+
head = f"{shown} (gravité inconnue)"
|
|
152
|
+
message = sentence(f"{head} : {summary}" if summary else head)
|
|
153
|
+
if target is not None:
|
|
154
|
+
message += f" Corrigée en {target}."
|
|
155
|
+
elif malicious:
|
|
156
|
+
message += " Retirer ce paquet : aucune version sûre connue."
|
|
157
|
+
else:
|
|
158
|
+
message += " Aucune version corrigée connue."
|
|
159
|
+
if current.approximate:
|
|
160
|
+
message += (
|
|
161
|
+
f" Faille possible : {current.version} est la borne basse de « "
|
|
162
|
+
f"{dependency.effective_constraint} », faute de lockfile ; la version installée peut "
|
|
163
|
+
"être plus récente."
|
|
164
|
+
)
|
|
165
|
+
return Finding(
|
|
166
|
+
kind=FindingKind.VULNERABLE,
|
|
167
|
+
severity=severity,
|
|
168
|
+
message=message,
|
|
169
|
+
dependency=dependency,
|
|
170
|
+
current_version=current.version,
|
|
171
|
+
current_is_approximate=current.approximate,
|
|
172
|
+
target_version=target,
|
|
173
|
+
gap=None if target is None else scheme.gap(current.version, target),
|
|
174
|
+
identifiers=identifiers,
|
|
175
|
+
cvss_score=score,
|
|
176
|
+
detail=summary or None,
|
|
177
|
+
url=main.url,
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _identifiers(group: list[Advisory]) -> tuple[str, ...]:
|
|
182
|
+
"""Les identifiants des avis et leurs alias, sans doublon : les CVE, puis les GHSA, puis
|
|
183
|
+
les autres, chacun dans l'ordre alphabétique."""
|
|
184
|
+
found = {name for advisory in group for name in (advisory.id, *advisory.aliases)}
|
|
185
|
+
|
|
186
|
+
def order(name: str) -> tuple[int, str]:
|
|
187
|
+
rank = next((i for i, p in enumerate(_PREFIXES) if name.startswith(p)), len(_PREFIXES))
|
|
188
|
+
return rank, name
|
|
189
|
+
|
|
190
|
+
return tuple(sorted(found, key=order))
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _main_order(advisory: Advisory) -> tuple[bool, bool, bool, str]:
|
|
194
|
+
"""L'avis qui représente la faille (son lien, son titre) : un avis lu, de GitHub de
|
|
195
|
+
préférence (ils ont un titre), avec un titre."""
|
|
196
|
+
return (
|
|
197
|
+
advisory.unavailable is not None,
|
|
198
|
+
not advisory.id.startswith("GHSA-"),
|
|
199
|
+
not advisory.summary,
|
|
200
|
+
advisory.id,
|
|
201
|
+
)
|