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
@@ -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
+ )