uv-audit-cooldown 1.0.0__tar.gz
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.
- uv_audit_cooldown-1.0.0/LICENSE +21 -0
- uv_audit_cooldown-1.0.0/PKG-INFO +116 -0
- uv_audit_cooldown-1.0.0/README.md +87 -0
- uv_audit_cooldown-1.0.0/pyproject.toml +89 -0
- uv_audit_cooldown-1.0.0/pyproject.toml.orig +80 -0
- uv_audit_cooldown-1.0.0/src/uv_audit_cooldown/__init__.py +5 -0
- uv_audit_cooldown-1.0.0/src/uv_audit_cooldown/__main__.py +3 -0
- uv_audit_cooldown-1.0.0/src/uv_audit_cooldown/cli.py +308 -0
- uv_audit_cooldown-1.0.0/src/uv_audit_cooldown/core.py +316 -0
- uv_audit_cooldown-1.0.0/src/uv_audit_cooldown/py.typed +0 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 JGStew
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: uv-audit-cooldown
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Cooldown-aware `uv audit`: warns while a fix is inside your `exclude-newer` window, fails once it isn't.
|
|
5
|
+
Keywords: uv,audit,security,supply-chain,pre-commit,github-action
|
|
6
|
+
Author: JGStew
|
|
7
|
+
Author-email: JGStew <admin@jgstew.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
20
|
+
Classifier: Topic :: Security
|
|
21
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
22
|
+
Classifier: Typing :: Typed
|
|
23
|
+
Requires-Dist: packaging>=24
|
|
24
|
+
Requires-Python: >=3.10
|
|
25
|
+
Project-URL: Homepage, https://github.com/jgstew/uv-audit-cooldown
|
|
26
|
+
Project-URL: Issues, https://github.com/jgstew/uv-audit-cooldown/issues
|
|
27
|
+
Project-URL: Source, https://github.com/jgstew/uv-audit-cooldown
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# uv-audit-cooldown
|
|
31
|
+
|
|
32
|
+
Cooldown-aware `uv audit`: it warns while a fix is inside your `exclude-newer` window, and fails once it isn't.
|
|
33
|
+
|
|
34
|
+
**Unofficial, and not affiliated with Astral.** It relies on `uv audit`'s preview JSON output.
|
|
35
|
+
|
|
36
|
+
```yaml
|
|
37
|
+
# GitHub Action
|
|
38
|
+
- uses: jgstew/uv-audit-cooldown@<full-commit-sha> # v1.0.0
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
```yaml
|
|
42
|
+
# pre-commit
|
|
43
|
+
repos:
|
|
44
|
+
- repo: https://github.com/jgstew/uv-audit-cooldown
|
|
45
|
+
rev: v1.0.0
|
|
46
|
+
hooks:
|
|
47
|
+
- id: uv-audit-cooldown
|
|
48
|
+
verbose: true # pre-commit hides output of passing hooks
|
|
49
|
+
stages: [manual] # needs the network
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
```sh
|
|
53
|
+
uvx uv-audit-cooldown@1.0.0
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## What it does
|
|
57
|
+
|
|
58
|
+
It runs `uv audit`. For each vulnerable package it then runs
|
|
59
|
+
`uv lock --dry-run --upgrade-package <name>`, which shows the newest version
|
|
60
|
+
your own cooldown allows and leaves `uv.lock` untouched. If no audit
|
|
61
|
+
finding is reported, no other network call is made.
|
|
62
|
+
|
|
63
|
+
| Situation | Result |
|
|
64
|
+
| --- | --- |
|
|
65
|
+
| Every fix is still inside the cooldown | pass, with a one-line summary |
|
|
66
|
+
| An upgrade is allowed, but it doesn't reach the fix yet | pass, and the summary says so |
|
|
67
|
+
| A fixed version is allowed now | fail |
|
|
68
|
+
| A fix is blocked by something other than the cooldown (a pin) | fail |
|
|
69
|
+
| No fix has been published | fail (`--unfixed warning` to soften) |
|
|
70
|
+
| No project or no `uv.lock` (as uv finds them) | pass with a warning (`--no-lockfile error` to fail) |
|
|
71
|
+
|
|
72
|
+
To tell whether a fix is held back only by the cooldown, it runs a second
|
|
73
|
+
dry run with the cooldown lifted. That one uses `--no-build`, so it never
|
|
74
|
+
runs build code from a release that is still inside the cooldown; if it would
|
|
75
|
+
need a build, the tool exits `2`.
|
|
76
|
+
|
|
77
|
+
In GitHub Actions, messages are printed as `::warning`/`::error` annotations.
|
|
78
|
+
`--verbose` prints one line per advisory. Other arguments go to `uv audit`,
|
|
79
|
+
e.g. `--no-dev` or `--ignore GHSA-…`. Location, config, index, cache, build
|
|
80
|
+
and resolver options (`--project`, `--index`, `--prerelease`, `--exclude-newer`, …)
|
|
81
|
+
also go to the dry runs, so both see the same settings; `UV_*` environment
|
|
82
|
+
variables reach both too. `--offline` isn't used: on a cold
|
|
83
|
+
CI cache it fails, and with a stale cache it could hide a fix that is already allowed.
|
|
84
|
+
|
|
85
|
+
Exit codes: `0` nothing to do now, `1` action needed, `2` uv failed or its
|
|
86
|
+
output wasn't understood.
|
|
87
|
+
|
|
88
|
+
## Action inputs
|
|
89
|
+
|
|
90
|
+
`working-directory` (default `.`), `args` (extra `uv audit` arguments),
|
|
91
|
+
`unfixed` (`error`/`warning`), `no-lockfile` (`warning`/`error`),
|
|
92
|
+
`setup-uv` (`auto`/`true`/`false`), `uv-version`.
|
|
93
|
+
|
|
94
|
+
## Pinning
|
|
95
|
+
|
|
96
|
+
- **Action:** pin `uses:` to a commit SHA. The Action runs its own checkout
|
|
97
|
+
(`uv run --frozen --project "$GITHUB_ACTION_PATH"`) and never installs from PyPI.
|
|
98
|
+
- **pre-commit:** pin `rev:` to a tag, or to a SHA with `pre-commit autoupdate --freeze`.
|
|
99
|
+
- **uvx:** pin a version: `uvx uv-audit-cooldown@1.0.0`.
|
|
100
|
+
|
|
101
|
+
### pre-commit limitation
|
|
102
|
+
|
|
103
|
+
pre-commit installs the hook with pip (`language: python`), which ignores this
|
|
104
|
+
repo's `uv.lock` and `exclude-newer`. The hook's dependency (`packaging`) and
|
|
105
|
+
its build backend (`uv_build`) therefore come from PyPI at their newest allowed
|
|
106
|
+
versions, with no cooldown. `rev:` pins only this repo's code. If that matters,
|
|
107
|
+
use the Action (it runs `uv run --frozen` from its own lockfile), or a `local`
|
|
108
|
+
hook with `language: system` and
|
|
109
|
+
`entry: uvx --exclude-newer "7 days" uv-audit-cooldown@1.0.0`.
|
|
110
|
+
|
|
111
|
+
## Related uv issues
|
|
112
|
+
|
|
113
|
+
[astral-sh/uv#18995](https://github.com/astral-sh/uv/issues/18995),
|
|
114
|
+
[astral-sh/uv#22944](https://github.com/astral-sh/uv/issues/22944),
|
|
115
|
+
[astral-sh/uv#19265](https://github.com/astral-sh/uv/issues/19265),
|
|
116
|
+
[astral-sh/uv#18506](https://github.com/astral-sh/uv/issues/18506)
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# uv-audit-cooldown
|
|
2
|
+
|
|
3
|
+
Cooldown-aware `uv audit`: it warns while a fix is inside your `exclude-newer` window, and fails once it isn't.
|
|
4
|
+
|
|
5
|
+
**Unofficial, and not affiliated with Astral.** It relies on `uv audit`'s preview JSON output.
|
|
6
|
+
|
|
7
|
+
```yaml
|
|
8
|
+
# GitHub Action
|
|
9
|
+
- uses: jgstew/uv-audit-cooldown@<full-commit-sha> # v1.0.0
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
```yaml
|
|
13
|
+
# pre-commit
|
|
14
|
+
repos:
|
|
15
|
+
- repo: https://github.com/jgstew/uv-audit-cooldown
|
|
16
|
+
rev: v1.0.0
|
|
17
|
+
hooks:
|
|
18
|
+
- id: uv-audit-cooldown
|
|
19
|
+
verbose: true # pre-commit hides output of passing hooks
|
|
20
|
+
stages: [manual] # needs the network
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
uvx uv-audit-cooldown@1.0.0
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## What it does
|
|
28
|
+
|
|
29
|
+
It runs `uv audit`. For each vulnerable package it then runs
|
|
30
|
+
`uv lock --dry-run --upgrade-package <name>`, which shows the newest version
|
|
31
|
+
your own cooldown allows and leaves `uv.lock` untouched. If no audit
|
|
32
|
+
finding is reported, no other network call is made.
|
|
33
|
+
|
|
34
|
+
| Situation | Result |
|
|
35
|
+
| --- | --- |
|
|
36
|
+
| Every fix is still inside the cooldown | pass, with a one-line summary |
|
|
37
|
+
| An upgrade is allowed, but it doesn't reach the fix yet | pass, and the summary says so |
|
|
38
|
+
| A fixed version is allowed now | fail |
|
|
39
|
+
| A fix is blocked by something other than the cooldown (a pin) | fail |
|
|
40
|
+
| No fix has been published | fail (`--unfixed warning` to soften) |
|
|
41
|
+
| No project or no `uv.lock` (as uv finds them) | pass with a warning (`--no-lockfile error` to fail) |
|
|
42
|
+
|
|
43
|
+
To tell whether a fix is held back only by the cooldown, it runs a second
|
|
44
|
+
dry run with the cooldown lifted. That one uses `--no-build`, so it never
|
|
45
|
+
runs build code from a release that is still inside the cooldown; if it would
|
|
46
|
+
need a build, the tool exits `2`.
|
|
47
|
+
|
|
48
|
+
In GitHub Actions, messages are printed as `::warning`/`::error` annotations.
|
|
49
|
+
`--verbose` prints one line per advisory. Other arguments go to `uv audit`,
|
|
50
|
+
e.g. `--no-dev` or `--ignore GHSA-…`. Location, config, index, cache, build
|
|
51
|
+
and resolver options (`--project`, `--index`, `--prerelease`, `--exclude-newer`, …)
|
|
52
|
+
also go to the dry runs, so both see the same settings; `UV_*` environment
|
|
53
|
+
variables reach both too. `--offline` isn't used: on a cold
|
|
54
|
+
CI cache it fails, and with a stale cache it could hide a fix that is already allowed.
|
|
55
|
+
|
|
56
|
+
Exit codes: `0` nothing to do now, `1` action needed, `2` uv failed or its
|
|
57
|
+
output wasn't understood.
|
|
58
|
+
|
|
59
|
+
## Action inputs
|
|
60
|
+
|
|
61
|
+
`working-directory` (default `.`), `args` (extra `uv audit` arguments),
|
|
62
|
+
`unfixed` (`error`/`warning`), `no-lockfile` (`warning`/`error`),
|
|
63
|
+
`setup-uv` (`auto`/`true`/`false`), `uv-version`.
|
|
64
|
+
|
|
65
|
+
## Pinning
|
|
66
|
+
|
|
67
|
+
- **Action:** pin `uses:` to a commit SHA. The Action runs its own checkout
|
|
68
|
+
(`uv run --frozen --project "$GITHUB_ACTION_PATH"`) and never installs from PyPI.
|
|
69
|
+
- **pre-commit:** pin `rev:` to a tag, or to a SHA with `pre-commit autoupdate --freeze`.
|
|
70
|
+
- **uvx:** pin a version: `uvx uv-audit-cooldown@1.0.0`.
|
|
71
|
+
|
|
72
|
+
### pre-commit limitation
|
|
73
|
+
|
|
74
|
+
pre-commit installs the hook with pip (`language: python`), which ignores this
|
|
75
|
+
repo's `uv.lock` and `exclude-newer`. The hook's dependency (`packaging`) and
|
|
76
|
+
its build backend (`uv_build`) therefore come from PyPI at their newest allowed
|
|
77
|
+
versions, with no cooldown. `rev:` pins only this repo's code. If that matters,
|
|
78
|
+
use the Action (it runs `uv run --frozen` from its own lockfile), or a `local`
|
|
79
|
+
hook with `language: system` and
|
|
80
|
+
`entry: uvx --exclude-newer "7 days" uv-audit-cooldown@1.0.0`.
|
|
81
|
+
|
|
82
|
+
## Related uv issues
|
|
83
|
+
|
|
84
|
+
[astral-sh/uv#18995](https://github.com/astral-sh/uv/issues/18995),
|
|
85
|
+
[astral-sh/uv#22944](https://github.com/astral-sh/uv/issues/22944),
|
|
86
|
+
[astral-sh/uv#19265](https://github.com/astral-sh/uv/issues/19265),
|
|
87
|
+
[astral-sh/uv#18506](https://github.com/astral-sh/uv/issues/18506)
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["uv_build>=0.13.0,<0.14.0"]
|
|
3
|
+
build-backend = "uv_build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "uv-audit-cooldown"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Cooldown-aware `uv audit`: warns while a fix is inside your `exclude-newer` window, fails once it isn't."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
requires-python = ">=3.10"
|
|
13
|
+
keywords = [
|
|
14
|
+
"uv",
|
|
15
|
+
"audit",
|
|
16
|
+
"security",
|
|
17
|
+
"supply-chain",
|
|
18
|
+
"pre-commit",
|
|
19
|
+
"github-action",
|
|
20
|
+
]
|
|
21
|
+
classifiers = [
|
|
22
|
+
"Development Status :: 5 - Production/Stable",
|
|
23
|
+
"Environment :: Console",
|
|
24
|
+
"Intended Audience :: Developers",
|
|
25
|
+
"Operating System :: OS Independent",
|
|
26
|
+
"Programming Language :: Python :: 3",
|
|
27
|
+
"Programming Language :: Python :: 3.10",
|
|
28
|
+
"Programming Language :: Python :: 3.11",
|
|
29
|
+
"Programming Language :: Python :: 3.12",
|
|
30
|
+
"Programming Language :: Python :: 3.13",
|
|
31
|
+
"Programming Language :: Python :: 3.14",
|
|
32
|
+
"Topic :: Security",
|
|
33
|
+
"Topic :: Software Development :: Quality Assurance",
|
|
34
|
+
"Typing :: Typed",
|
|
35
|
+
]
|
|
36
|
+
dependencies = ["packaging>=24"]
|
|
37
|
+
|
|
38
|
+
[[project.authors]]
|
|
39
|
+
name = "JGStew"
|
|
40
|
+
email = "admin@jgstew.com"
|
|
41
|
+
|
|
42
|
+
[project.scripts]
|
|
43
|
+
uv-audit-cooldown = "uv_audit_cooldown.cli:main"
|
|
44
|
+
|
|
45
|
+
[project.urls]
|
|
46
|
+
Homepage = "https://github.com/jgstew/uv-audit-cooldown"
|
|
47
|
+
Issues = "https://github.com/jgstew/uv-audit-cooldown/issues"
|
|
48
|
+
Source = "https://github.com/jgstew/uv-audit-cooldown"
|
|
49
|
+
|
|
50
|
+
[dependency-groups]
|
|
51
|
+
dev = [
|
|
52
|
+
"mypy>=1.10",
|
|
53
|
+
"pytest>=8",
|
|
54
|
+
"pytest-cov>=5",
|
|
55
|
+
"pyyaml>=6",
|
|
56
|
+
"ruff>=0.5",
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
[tool.uv]
|
|
60
|
+
exclude-newer = "7 days"
|
|
61
|
+
|
|
62
|
+
[tool.pytest.ini_options]
|
|
63
|
+
testpaths = ["tests"]
|
|
64
|
+
markers = ["network: reaches PyPI and the OSV vulnerability service through real uv (skip with -m 'not network')"]
|
|
65
|
+
|
|
66
|
+
[tool.coverage.run]
|
|
67
|
+
source = ["src"]
|
|
68
|
+
branch = true
|
|
69
|
+
|
|
70
|
+
[tool.ruff]
|
|
71
|
+
line-length = 100
|
|
72
|
+
target-version = "py310"
|
|
73
|
+
|
|
74
|
+
[tool.ruff.lint]
|
|
75
|
+
extend-select = [
|
|
76
|
+
"B",
|
|
77
|
+
"I",
|
|
78
|
+
"UP",
|
|
79
|
+
"SIM",
|
|
80
|
+
"T20",
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
[tool.ruff.lint.per-file-ignores]
|
|
84
|
+
"src/uv_audit_cooldown/cli.py" = ["T20"]
|
|
85
|
+
|
|
86
|
+
[tool.mypy]
|
|
87
|
+
python_version = "3.10"
|
|
88
|
+
strict = true
|
|
89
|
+
files = ["src"]
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["uv_build>=0.13.0,<0.14.0"]
|
|
3
|
+
build-backend = "uv_build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "uv-audit-cooldown"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Cooldown-aware `uv audit`: warns while a fix is inside your `exclude-newer` window, fails once it isn't."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "JGStew", email = "admin@jgstew.com" },
|
|
14
|
+
]
|
|
15
|
+
requires-python = ">=3.10"
|
|
16
|
+
keywords = ["uv", "audit", "security", "supply-chain", "pre-commit", "github-action"]
|
|
17
|
+
classifiers = [
|
|
18
|
+
"Development Status :: 5 - Production/Stable",
|
|
19
|
+
"Environment :: Console",
|
|
20
|
+
"Intended Audience :: Developers",
|
|
21
|
+
"Operating System :: OS Independent",
|
|
22
|
+
"Programming Language :: Python :: 3",
|
|
23
|
+
"Programming Language :: Python :: 3.10",
|
|
24
|
+
"Programming Language :: Python :: 3.11",
|
|
25
|
+
"Programming Language :: Python :: 3.12",
|
|
26
|
+
"Programming Language :: Python :: 3.13",
|
|
27
|
+
"Programming Language :: Python :: 3.14",
|
|
28
|
+
"Topic :: Security",
|
|
29
|
+
"Topic :: Software Development :: Quality Assurance",
|
|
30
|
+
"Typing :: Typed",
|
|
31
|
+
]
|
|
32
|
+
# uv is not a dependency: the tool drives the caller's own uv
|
|
33
|
+
dependencies = [
|
|
34
|
+
"packaging>=24",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
[dependency-groups]
|
|
38
|
+
dev = [
|
|
39
|
+
"mypy>=1.10",
|
|
40
|
+
"pytest>=8",
|
|
41
|
+
"pytest-cov>=5",
|
|
42
|
+
"pyyaml>=6",
|
|
43
|
+
"ruff>=0.5",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
[project.scripts]
|
|
47
|
+
uv-audit-cooldown = "uv_audit_cooldown.cli:main"
|
|
48
|
+
|
|
49
|
+
[project.urls]
|
|
50
|
+
Homepage = "https://github.com/jgstew/uv-audit-cooldown"
|
|
51
|
+
Issues = "https://github.com/jgstew/uv-audit-cooldown/issues"
|
|
52
|
+
Source = "https://github.com/jgstew/uv-audit-cooldown"
|
|
53
|
+
|
|
54
|
+
[tool.uv]
|
|
55
|
+
exclude-newer = "7 days"
|
|
56
|
+
|
|
57
|
+
[tool.pytest.ini_options]
|
|
58
|
+
testpaths = ["tests"]
|
|
59
|
+
markers = [
|
|
60
|
+
"network: reaches PyPI and the OSV vulnerability service through real uv (skip with -m 'not network')",
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
[tool.coverage.run]
|
|
64
|
+
source = ["src"]
|
|
65
|
+
branch = true
|
|
66
|
+
|
|
67
|
+
[tool.ruff]
|
|
68
|
+
line-length = 100
|
|
69
|
+
target-version = "py310"
|
|
70
|
+
|
|
71
|
+
[tool.ruff.lint]
|
|
72
|
+
extend-select = ["B", "I", "UP", "SIM", "T20"]
|
|
73
|
+
|
|
74
|
+
[tool.ruff.lint.per-file-ignores]
|
|
75
|
+
"src/uv_audit_cooldown/cli.py" = ["T20"]
|
|
76
|
+
|
|
77
|
+
[tool.mypy]
|
|
78
|
+
python_version = "3.10"
|
|
79
|
+
strict = true
|
|
80
|
+
files = ["src"]
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
"""Run `uv audit`, then one `uv lock --dry-run` per vulnerable package.
|
|
2
|
+
|
|
3
|
+
Exit codes: 0 nothing to do now, 1 action needed, 2 uv failed or its output was not understood.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import os
|
|
10
|
+
import shlex
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from packaging.utils import canonicalize_name
|
|
18
|
+
|
|
19
|
+
from . import __version__
|
|
20
|
+
from .core import (
|
|
21
|
+
Finding,
|
|
22
|
+
Kind,
|
|
23
|
+
SchemaError,
|
|
24
|
+
Severity,
|
|
25
|
+
classify,
|
|
26
|
+
cooldown_summary,
|
|
27
|
+
parse_audit,
|
|
28
|
+
reachable_versions,
|
|
29
|
+
relevant_fixes,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
TITLE = "uv-audit-cooldown"
|
|
33
|
+
AUDIT = ["audit", "--preview-features", "audit-command,json-output", "--frozen"]
|
|
34
|
+
AUDIT += ["--output-format", "json", "--color", "never"]
|
|
35
|
+
DRY_RUN = ["lock", "--dry-run", "--color", "never", "--upgrade-package"]
|
|
36
|
+
# the probe that lifts the cooldown must not build (run code from) a release still inside it
|
|
37
|
+
LIFTED = ("--exclude-newer", "false", "--no-build")
|
|
38
|
+
# also passed to `uv lock`, so both commands see the same project, indexes and resolver settings
|
|
39
|
+
SHARED_OPTIONS = (
|
|
40
|
+
"--directory",
|
|
41
|
+
"--project",
|
|
42
|
+
"--script",
|
|
43
|
+
"--config-file",
|
|
44
|
+
"--cache-dir",
|
|
45
|
+
"--index",
|
|
46
|
+
"--default-index",
|
|
47
|
+
"--index-url",
|
|
48
|
+
"-i",
|
|
49
|
+
"--extra-index-url",
|
|
50
|
+
"--find-links",
|
|
51
|
+
"-f",
|
|
52
|
+
"--index-strategy",
|
|
53
|
+
"--keyring-provider",
|
|
54
|
+
"--allow-insecure-host",
|
|
55
|
+
"--resolution",
|
|
56
|
+
"--prerelease",
|
|
57
|
+
"--prerelease-package",
|
|
58
|
+
"--fork-strategy",
|
|
59
|
+
"--exclude-newer",
|
|
60
|
+
"--exclude-newer-package",
|
|
61
|
+
"--no-sources-package",
|
|
62
|
+
"--no-binary-package",
|
|
63
|
+
"--no-build-package",
|
|
64
|
+
"--no-build-isolation-package",
|
|
65
|
+
"--config-setting",
|
|
66
|
+
"-C",
|
|
67
|
+
"--config-settings-package",
|
|
68
|
+
)
|
|
69
|
+
SHARED_FLAGS = (
|
|
70
|
+
"--no-config",
|
|
71
|
+
"--no-cache",
|
|
72
|
+
"-n",
|
|
73
|
+
"--system-certs",
|
|
74
|
+
"--no-index",
|
|
75
|
+
"--no-sources",
|
|
76
|
+
"--no-binary",
|
|
77
|
+
"--no-build",
|
|
78
|
+
"--no-build-isolation",
|
|
79
|
+
"--require-build-hashes",
|
|
80
|
+
"--no-require-build-hashes",
|
|
81
|
+
)
|
|
82
|
+
# uv rejects these twice, and the lifted probe sets its own
|
|
83
|
+
NOT_LIFTED = ("--exclude-newer", "--exclude-newer-package", "--no-build")
|
|
84
|
+
# uv's own messages for "nothing to audit here"
|
|
85
|
+
NOTHING_TO_AUDIT = ("Unable to find lockfile", "No `pyproject.toml` found")
|
|
86
|
+
# would make the dry runs refuse to resolve
|
|
87
|
+
DROPPED_ENV = ("VIRTUAL_ENV", "UV_FROZEN", "UV_LOCKED")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass(frozen=True)
|
|
91
|
+
class RunResult:
|
|
92
|
+
returncode: int
|
|
93
|
+
stdout: str
|
|
94
|
+
stderr: str
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
Runner = Callable[[Sequence[str], Mapping[str, str]], RunResult]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class UvFailed(Exception):
|
|
101
|
+
pass
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class NothingToAudit(Exception):
|
|
105
|
+
pass
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _subprocess_runner(cwd: Path) -> Runner:
|
|
109
|
+
def run(cmd: Sequence[str], env: Mapping[str, str]) -> RunResult:
|
|
110
|
+
proc = subprocess.run(
|
|
111
|
+
list(cmd),
|
|
112
|
+
cwd=cwd,
|
|
113
|
+
env=dict(env),
|
|
114
|
+
capture_output=True,
|
|
115
|
+
text=True,
|
|
116
|
+
encoding="utf-8",
|
|
117
|
+
errors="replace",
|
|
118
|
+
check=False,
|
|
119
|
+
)
|
|
120
|
+
return RunResult(proc.returncode, proc.stdout, proc.stderr)
|
|
121
|
+
|
|
122
|
+
return run
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def workflow_command(level: str, message: str) -> str:
|
|
126
|
+
"""A GitHub Actions annotation, with the data escaped per the runner's rules."""
|
|
127
|
+
data = message.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A")
|
|
128
|
+
return f"::{level} title={TITLE}::{data}"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _parser() -> argparse.ArgumentParser:
|
|
132
|
+
parser = argparse.ArgumentParser(
|
|
133
|
+
prog=TITLE,
|
|
134
|
+
allow_abbrev=False,
|
|
135
|
+
description=(
|
|
136
|
+
"Run `uv audit`, but warn instead of failing while an advisory's fix is still "
|
|
137
|
+
"inside the project's exclude-newer cooldown. Any other argument (or anything "
|
|
138
|
+
"after `--`) is passed to `uv audit`."
|
|
139
|
+
),
|
|
140
|
+
)
|
|
141
|
+
parser.add_argument(
|
|
142
|
+
"--unfixed",
|
|
143
|
+
choices=[s.value for s in Severity],
|
|
144
|
+
default=Severity.ERROR.value,
|
|
145
|
+
help="how to report an advisory with no fixed version at all (default: error)",
|
|
146
|
+
)
|
|
147
|
+
parser.add_argument(
|
|
148
|
+
"--no-lockfile",
|
|
149
|
+
choices=[s.value for s in Severity],
|
|
150
|
+
default=Severity.WARNING.value,
|
|
151
|
+
help="how to report a directory with no project or no uv.lock (default: warning)",
|
|
152
|
+
)
|
|
153
|
+
parser.add_argument(
|
|
154
|
+
"--extra-args",
|
|
155
|
+
default="",
|
|
156
|
+
metavar="STRING",
|
|
157
|
+
help="more `uv audit` arguments as one shell-quoted string (used by the GitHub Action)",
|
|
158
|
+
)
|
|
159
|
+
parser.add_argument(
|
|
160
|
+
"--verbose",
|
|
161
|
+
action="store_true",
|
|
162
|
+
help="also print one line per advisory that waits on the cooldown",
|
|
163
|
+
)
|
|
164
|
+
parser.add_argument("--version", action="version", version=f"{TITLE} {__version__}")
|
|
165
|
+
return parser
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _shared_args(args: Sequence[str]) -> list[list[str]]:
|
|
169
|
+
"""The arguments `uv lock` should see too, one list per option with its value."""
|
|
170
|
+
out: list[list[str]] = []
|
|
171
|
+
short = tuple(o for o in SHARED_OPTIONS if len(o) == 2)
|
|
172
|
+
it = iter(args)
|
|
173
|
+
for arg in it:
|
|
174
|
+
if arg in SHARED_OPTIONS:
|
|
175
|
+
out.append([arg, next(it, "")])
|
|
176
|
+
elif arg in SHARED_FLAGS or arg.startswith(tuple(f"{o}=" for o in SHARED_OPTIONS)):
|
|
177
|
+
out.append([arg])
|
|
178
|
+
elif arg.startswith(short) and not arg.startswith("--"):
|
|
179
|
+
out.append([arg]) # `-iURL`
|
|
180
|
+
return out
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _option_name(arg: list[str]) -> str:
|
|
184
|
+
return arg[0].split("=", 1)[0]
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _flatten(args: Sequence[list[str]]) -> list[str]:
|
|
188
|
+
return [a for arg in args for a in arg]
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def main(
|
|
192
|
+
argv: Sequence[str] | None = None,
|
|
193
|
+
*,
|
|
194
|
+
runner: Runner | None = None,
|
|
195
|
+
environ: Mapping[str, str] | None = None,
|
|
196
|
+
cwd: Path | None = None,
|
|
197
|
+
) -> int:
|
|
198
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
199
|
+
environ = os.environ if environ is None else environ
|
|
200
|
+
cwd = Path.cwd() if cwd is None else cwd
|
|
201
|
+
|
|
202
|
+
after: list[str] = []
|
|
203
|
+
if "--" in argv:
|
|
204
|
+
split = argv.index("--")
|
|
205
|
+
argv, after = argv[:split], argv[split + 1 :]
|
|
206
|
+
opts, passthrough = _parser().parse_known_args(argv)
|
|
207
|
+
passthrough += shlex.split(opts.extra_args) + after
|
|
208
|
+
shared = _shared_args(passthrough)
|
|
209
|
+
shared_lifted = [a for a in shared if _option_name(a) not in NOT_LIFTED]
|
|
210
|
+
|
|
211
|
+
uv = environ.get("UV") or "uv"
|
|
212
|
+
env = {k: v for k, v in environ.items() if k not in DROPPED_ENV}
|
|
213
|
+
run = runner or _subprocess_runner(cwd)
|
|
214
|
+
github = bool(environ.get("GITHUB_ACTIONS"))
|
|
215
|
+
|
|
216
|
+
def emit(severity: Severity, message: str) -> None:
|
|
217
|
+
level = severity.value
|
|
218
|
+
print(workflow_command(level, message) if github else f"{level}: {message}")
|
|
219
|
+
|
|
220
|
+
try:
|
|
221
|
+
findings, adverse = _check(
|
|
222
|
+
uv,
|
|
223
|
+
passthrough,
|
|
224
|
+
_flatten(shared),
|
|
225
|
+
_flatten(shared_lifted),
|
|
226
|
+
run,
|
|
227
|
+
env,
|
|
228
|
+
Severity(opts.unfixed),
|
|
229
|
+
)
|
|
230
|
+
except NothingToAudit as exc:
|
|
231
|
+
# visible, so a missing or uncommitted lockfile does not pass unnoticed
|
|
232
|
+
severity = Severity(opts.no_lockfile)
|
|
233
|
+
emit(severity, f"nothing to audit ({exc}).")
|
|
234
|
+
return 1 if severity is Severity.ERROR else 0
|
|
235
|
+
except FileNotFoundError:
|
|
236
|
+
print(f"{TITLE}: `{uv}` not found; install uv or put it on PATH.", file=sys.stderr)
|
|
237
|
+
return 2
|
|
238
|
+
except (UvFailed, SchemaError) as exc:
|
|
239
|
+
print(f"{TITLE}: {exc}", file=sys.stderr)
|
|
240
|
+
return 2
|
|
241
|
+
|
|
242
|
+
for status in adverse:
|
|
243
|
+
emit(Severity.ERROR, f"adverse project status: {status}")
|
|
244
|
+
for f in findings:
|
|
245
|
+
if f.kind is not Kind.COOLDOWN or opts.verbose:
|
|
246
|
+
emit(f.severity, f.message)
|
|
247
|
+
summary = cooldown_summary(findings)
|
|
248
|
+
if summary:
|
|
249
|
+
emit(Severity.WARNING, summary)
|
|
250
|
+
if not findings and not adverse:
|
|
251
|
+
print(f"{TITLE}: no known vulnerabilities.")
|
|
252
|
+
|
|
253
|
+
failed = adverse or any(f.severity is Severity.ERROR for f in findings)
|
|
254
|
+
return 1 if failed else 0
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _check(
|
|
258
|
+
uv: str,
|
|
259
|
+
passthrough: Sequence[str],
|
|
260
|
+
shared: Sequence[str],
|
|
261
|
+
shared_lifted: Sequence[str],
|
|
262
|
+
run: Runner,
|
|
263
|
+
env: Mapping[str, str],
|
|
264
|
+
unfixed: Severity,
|
|
265
|
+
) -> tuple[list[Finding], tuple[object, ...]]:
|
|
266
|
+
audit = run([uv, *AUDIT, *passthrough], env)
|
|
267
|
+
if audit.returncode != 0 and not audit.stdout.strip():
|
|
268
|
+
for line in audit.stderr.splitlines():
|
|
269
|
+
if any(m in line for m in NOTHING_TO_AUDIT):
|
|
270
|
+
raise NothingToAudit(line.removeprefix("error: ").strip())
|
|
271
|
+
# uv audit exits 1 when it finds something; the JSON says what
|
|
272
|
+
if audit.returncode not in (0, 1):
|
|
273
|
+
raise UvFailed(f"`uv audit` exited {audit.returncode}:\n{audit.stderr}".rstrip())
|
|
274
|
+
try:
|
|
275
|
+
report = parse_audit(audit.stdout)
|
|
276
|
+
except SchemaError as exc:
|
|
277
|
+
raise SchemaError(f"{exc}\n{audit.stderr}".rstrip()) from None
|
|
278
|
+
|
|
279
|
+
def dry_run(name: str, *lift: str) -> str:
|
|
280
|
+
result = run([uv, *DRY_RUN, name, *lift, *(shared_lifted if lift else shared)], env)
|
|
281
|
+
if result.returncode != 0:
|
|
282
|
+
# a lifted probe that needs a build can't be decided without running that code
|
|
283
|
+
cmd = " ".join(["uv lock --dry-run --upgrade-package", name, *lift])
|
|
284
|
+
raise UvFailed(f"`{cmd}` exited {result.returncode}:\n{result.stderr}".rstrip())
|
|
285
|
+
# uv prints the changes on stderr; read both in case that moves
|
|
286
|
+
return result.stdout + result.stderr
|
|
287
|
+
|
|
288
|
+
# one dry-run per package, and one lifted probe per package, at most
|
|
289
|
+
reachable_cache: dict[tuple[str, str], tuple[str, ...] | None] = {}
|
|
290
|
+
lifted_cache: dict[tuple[str, str], tuple[str, ...] | None] = {}
|
|
291
|
+
findings = []
|
|
292
|
+
for v in report.vulnerabilities:
|
|
293
|
+
key = (canonicalize_name(v.name), v.version)
|
|
294
|
+
if not relevant_fixes(v):
|
|
295
|
+
# nothing to upgrade to, so nothing to ask uv
|
|
296
|
+
findings.append(classify(v, None, lambda: None, unfixed=unfixed))
|
|
297
|
+
continue
|
|
298
|
+
if key not in reachable_cache:
|
|
299
|
+
reachable_cache[key] = reachable_versions(dry_run(v.name), v.name, v.version)
|
|
300
|
+
|
|
301
|
+
def lifted(name: str = v.name, key: tuple[str, str] = key) -> tuple[str, ...] | None:
|
|
302
|
+
if key not in lifted_cache:
|
|
303
|
+
text = dry_run(name, *LIFTED, "--exclude-newer-package", f"{name}=false")
|
|
304
|
+
lifted_cache[key] = reachable_versions(text, name, key[1])
|
|
305
|
+
return lifted_cache[key]
|
|
306
|
+
|
|
307
|
+
findings.append(classify(v, reachable_cache[key], lifted, unfixed=unfixed))
|
|
308
|
+
return findings, report.adverse_statuses
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
"""Parse `uv audit` JSON and `uv lock --dry-run` output, and classify findings."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from collections.abc import Callable, Sequence
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from enum import Enum
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from packaging.utils import canonicalize_name
|
|
13
|
+
from packaging.version import InvalidVersion, Version
|
|
14
|
+
|
|
15
|
+
# the only `uv audit` JSON schema version understood; anything else fails
|
|
16
|
+
SCHEMA_VERSION = "preview"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class SchemaError(Exception):
|
|
20
|
+
"""uv printed something this tool does not understand. Never treated as a pass."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Severity(Enum):
|
|
24
|
+
WARNING = "warning"
|
|
25
|
+
ERROR = "error"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Kind(Enum):
|
|
29
|
+
COOLDOWN = "cooldown" # a fix exists; only the cooldown keeps it out
|
|
30
|
+
FIX_ALLOWED = "fix-allowed" # a fix is allowed now: bump
|
|
31
|
+
BLOCKED = "blocked" # a fix exists, but something besides the cooldown holds it back
|
|
32
|
+
UNFIXED = "unfixed" # no fix published
|
|
33
|
+
NO_APPLICABLE_FIX = "no-applicable-fix" # every listed fix is at or below the locked version
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class Vulnerability:
|
|
38
|
+
name: str
|
|
39
|
+
version: str
|
|
40
|
+
id: str
|
|
41
|
+
fix_versions: tuple[str, ...]
|
|
42
|
+
link: str | None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class AuditReport:
|
|
47
|
+
vulnerabilities: tuple[Vulnerability, ...]
|
|
48
|
+
adverse_statuses: tuple[Any, ...]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class Finding:
|
|
53
|
+
vuln: Vulnerability
|
|
54
|
+
kind: Kind
|
|
55
|
+
severity: Severity
|
|
56
|
+
message: str
|
|
57
|
+
# newest versions the project's own cooldown allows now (None: no upgrade)
|
|
58
|
+
reachable: tuple[str, ...] | None = None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# --- uv audit ------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _require(obj: Any, key: str, kind: type | tuple[type, ...], where: str) -> Any:
|
|
65
|
+
if not isinstance(obj, dict) or key not in obj:
|
|
66
|
+
raise SchemaError(f"uv audit JSON: {where} has no {key!r}")
|
|
67
|
+
value = obj[key]
|
|
68
|
+
if not isinstance(value, kind):
|
|
69
|
+
raise SchemaError(f"uv audit JSON: {where}.{key} is {type(value).__name__}: {value!r}")
|
|
70
|
+
return value
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _version(text: str, where: str) -> Version:
|
|
74
|
+
try:
|
|
75
|
+
return Version(text)
|
|
76
|
+
except InvalidVersion:
|
|
77
|
+
raise SchemaError(f"uv audit JSON: {where} is not a PEP 440 version: {text!r}") from None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def parse_audit(text: str) -> AuditReport:
|
|
81
|
+
try:
|
|
82
|
+
doc = json.loads(text)
|
|
83
|
+
except json.JSONDecodeError as exc:
|
|
84
|
+
raise SchemaError(f"uv audit did not print JSON: {exc}") from None
|
|
85
|
+
if not isinstance(doc, dict):
|
|
86
|
+
raise SchemaError(f"uv audit JSON is {type(doc).__name__}, not an object")
|
|
87
|
+
|
|
88
|
+
schema = _require(doc, "schema", dict, "document")
|
|
89
|
+
version = schema.get("version")
|
|
90
|
+
if version != SCHEMA_VERSION:
|
|
91
|
+
raise SchemaError(
|
|
92
|
+
f"uv audit JSON schema version {version!r} is not {SCHEMA_VERSION!r}; "
|
|
93
|
+
"this uv is newer than uv-audit-cooldown understands"
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
vulns: list[Vulnerability] = []
|
|
97
|
+
for i, raw in enumerate(_require(doc, "vulnerabilities", list, "document")):
|
|
98
|
+
where = f"vulnerabilities[{i}]"
|
|
99
|
+
dep = _require(raw, "dependency", dict, where)
|
|
100
|
+
name = _require(dep, "name", str, f"{where}.dependency")
|
|
101
|
+
ver = _require(dep, "version", str, f"{where}.dependency")
|
|
102
|
+
_version(ver, f"{where}.dependency.version")
|
|
103
|
+
fixes = _require(raw, "fix_versions", list, where)
|
|
104
|
+
for j, fix in enumerate(fixes):
|
|
105
|
+
if not isinstance(fix, str):
|
|
106
|
+
raise SchemaError(f"uv audit JSON: {where}.fix_versions[{j}] is {fix!r}")
|
|
107
|
+
_version(fix, f"{where}.fix_versions[{j}]")
|
|
108
|
+
link = raw.get("link")
|
|
109
|
+
if link is not None and not isinstance(link, str):
|
|
110
|
+
raise SchemaError(f"uv audit JSON: {where}.link is {link!r}")
|
|
111
|
+
vulns.append(
|
|
112
|
+
Vulnerability(
|
|
113
|
+
name=name,
|
|
114
|
+
version=ver,
|
|
115
|
+
id=_require(raw, "id", str, where),
|
|
116
|
+
fix_versions=tuple(fixes),
|
|
117
|
+
link=link,
|
|
118
|
+
)
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
adverse = _require(doc, "adverse_statuses", list, "document")
|
|
122
|
+
|
|
123
|
+
# cross-check the summary when uv gives one, so a truncated list can't pass
|
|
124
|
+
summary = doc.get("summary")
|
|
125
|
+
if isinstance(summary, dict):
|
|
126
|
+
for key, items in (("vulnerabilities", vulns), ("adverse_statuses", adverse)):
|
|
127
|
+
if key in summary and summary[key] != len(items):
|
|
128
|
+
raise SchemaError(
|
|
129
|
+
f"uv audit JSON: summary.{key} is {summary[key]!r} "
|
|
130
|
+
f"but {len(items)} {key} are listed"
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
return AuditReport(vulnerabilities=tuple(vulns), adverse_statuses=tuple(adverse))
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# --- uv lock --dry-run ---------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
# `Update virtualenv v21.14.11 -> v21.14.12`; a forked resolution lists several
|
|
139
|
+
# versions on one side: `Update urllib3 v1.26.4 -> v2.6.3, v2.8.0`.
|
|
140
|
+
_VERSIONS = r"v\S+(?:, v\S+)*"
|
|
141
|
+
_CHANGE = re.compile(
|
|
142
|
+
rf"^\s*(?P<verb>Update|Add|Remove) (?P<name>\S+) (?P<old>{_VERSIONS})(?: -> (?P<new>{_VERSIONS}))?\s*$",
|
|
143
|
+
re.MULTILINE,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _versions(group: str) -> tuple[str, ...]:
|
|
148
|
+
return tuple(v.strip().removeprefix("v") for v in group.split(","))
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def reachable_versions(text: str, name: str, version: str) -> tuple[str, ...] | None:
|
|
152
|
+
"""What the dry run would move <name> to; None if it is not named (already newest)."""
|
|
153
|
+
want = canonicalize_name(name)
|
|
154
|
+
lines = []
|
|
155
|
+
for line in text.splitlines():
|
|
156
|
+
match = _CHANGE.match(line.strip())
|
|
157
|
+
if match and canonicalize_name(match["name"]) == want:
|
|
158
|
+
lines.append(match)
|
|
159
|
+
if not lines:
|
|
160
|
+
if "No lockfile changes detected" in text or _CHANGE.search(text):
|
|
161
|
+
return None
|
|
162
|
+
raise SchemaError(f"uv lock --dry-run output not understood for {name}:\n{text}".rstrip())
|
|
163
|
+
|
|
164
|
+
exact = [m for m in lines if version in _versions(m["old"])]
|
|
165
|
+
if len(exact) == 1:
|
|
166
|
+
chosen = exact[0]
|
|
167
|
+
elif len(lines) == 1 and not exact:
|
|
168
|
+
chosen = lines[0]
|
|
169
|
+
else:
|
|
170
|
+
raise SchemaError(
|
|
171
|
+
f"uv lock --dry-run names {name} more than once and none is clearly "
|
|
172
|
+
f"{version}: {[m.group(0) for m in lines]}"
|
|
173
|
+
)
|
|
174
|
+
if chosen["verb"] != "Update" or chosen["new"] is None:
|
|
175
|
+
raise SchemaError(f"uv lock --dry-run: unexpected change for {name}: {chosen.group(0)!r}")
|
|
176
|
+
return _versions(chosen["new"])
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
# --- classification ------------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _subject(v: Vulnerability) -> str:
|
|
183
|
+
return f"{v.name} {v.version}: {v.id}"
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _with_link(message: str, v: Vulnerability) -> str:
|
|
187
|
+
return f"{message} {v.link}" if v.link else message
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _join(versions: Sequence[str]) -> str:
|
|
191
|
+
return ", ".join(versions)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def relevant_fixes(v: Vulnerability) -> list[Version]:
|
|
195
|
+
"""Fix versions above the locked one, lowest first (lower ones fix older branches)."""
|
|
196
|
+
current = Version(v.version)
|
|
197
|
+
return sorted(f for f in map(Version, v.fix_versions) if f > current)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def is_fixed(version: Version, fixes: Sequence[Version]) -> bool:
|
|
201
|
+
"""At or past every fix, or past a fix on its own major.minor branch."""
|
|
202
|
+
return version >= max(fixes) or any(
|
|
203
|
+
f <= version and f.release[:2] == version.release[:2] for f in fixes
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def classify(
|
|
208
|
+
v: Vulnerability,
|
|
209
|
+
reachable: tuple[str, ...] | None,
|
|
210
|
+
lifted: Callable[[], tuple[str, ...] | None],
|
|
211
|
+
*,
|
|
212
|
+
unfixed: Severity,
|
|
213
|
+
) -> Finding:
|
|
214
|
+
"""reachable: allowed now (None: no upgrade); lifted: reachable without the cooldown."""
|
|
215
|
+
if not v.fix_versions:
|
|
216
|
+
return Finding(
|
|
217
|
+
v,
|
|
218
|
+
Kind.UNFIXED,
|
|
219
|
+
unfixed,
|
|
220
|
+
_with_link(f"{_subject(v)} has no fixed version yet.", v),
|
|
221
|
+
)
|
|
222
|
+
fixes = relevant_fixes(v)
|
|
223
|
+
if not fixes:
|
|
224
|
+
return Finding(
|
|
225
|
+
v,
|
|
226
|
+
Kind.NO_APPLICABLE_FIX,
|
|
227
|
+
Severity.ERROR,
|
|
228
|
+
_with_link(
|
|
229
|
+
f"{_subject(v)} lists fixes {_join(v.fix_versions)}, none above the locked "
|
|
230
|
+
"version; check the advisory.",
|
|
231
|
+
v,
|
|
232
|
+
),
|
|
233
|
+
)
|
|
234
|
+
fix_text = _join([str(f) for f in fixes])
|
|
235
|
+
|
|
236
|
+
def reaches(versions: tuple[str, ...] | None) -> list[str]:
|
|
237
|
+
return [r for r in versions or () if is_fixed(Version(r), fixes)]
|
|
238
|
+
|
|
239
|
+
hit = reaches(reachable)
|
|
240
|
+
if hit:
|
|
241
|
+
return Finding(
|
|
242
|
+
v,
|
|
243
|
+
Kind.FIX_ALLOWED,
|
|
244
|
+
Severity.ERROR,
|
|
245
|
+
_with_link(
|
|
246
|
+
f"{_subject(v)} is fixed in {fix_text}, and the cooldown allows {_join(hit)} "
|
|
247
|
+
f"now: run `uv lock --upgrade-package {v.name}`.",
|
|
248
|
+
v,
|
|
249
|
+
),
|
|
250
|
+
reachable,
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
now = (
|
|
254
|
+
f"the newest version allowed now is {_join(reachable)}"
|
|
255
|
+
if reachable
|
|
256
|
+
else f"{v.version} is already the newest version allowed now"
|
|
257
|
+
)
|
|
258
|
+
without_cooldown = lifted()
|
|
259
|
+
if reaches(without_cooldown):
|
|
260
|
+
return Finding(
|
|
261
|
+
v,
|
|
262
|
+
Kind.COOLDOWN,
|
|
263
|
+
Severity.WARNING,
|
|
264
|
+
_with_link(
|
|
265
|
+
f"{_subject(v)} is fixed in {fix_text}, which is still inside the exclude-newer "
|
|
266
|
+
f"cooldown; {now}.",
|
|
267
|
+
v,
|
|
268
|
+
),
|
|
269
|
+
reachable,
|
|
270
|
+
)
|
|
271
|
+
best = _join(without_cooldown) if without_cooldown else v.version
|
|
272
|
+
return Finding(
|
|
273
|
+
v,
|
|
274
|
+
Kind.BLOCKED,
|
|
275
|
+
Severity.ERROR,
|
|
276
|
+
_with_link(
|
|
277
|
+
f"{_subject(v)} is fixed in {fix_text}, but no fixed version is reachable even with "
|
|
278
|
+
f"the cooldown lifted (best: {best}); a version constraint is holding {v.name} back.",
|
|
279
|
+
v,
|
|
280
|
+
),
|
|
281
|
+
reachable,
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def cooldown_summary(findings: Sequence[Finding]) -> str | None:
|
|
286
|
+
"""One line for every finding that only waits on the cooldown."""
|
|
287
|
+
waiting = [f for f in findings if f.kind is Kind.COOLDOWN]
|
|
288
|
+
if not waiting:
|
|
289
|
+
return None
|
|
290
|
+
by_package: dict[tuple[str, str], list[Finding]] = {}
|
|
291
|
+
for f in waiting:
|
|
292
|
+
by_package.setdefault((f.vuln.name, f.vuln.version), []).append(f)
|
|
293
|
+
|
|
294
|
+
parts = []
|
|
295
|
+
moves = []
|
|
296
|
+
for (name, version), group in by_package.items():
|
|
297
|
+
if len(group) == 1:
|
|
298
|
+
(f,) = group
|
|
299
|
+
parts.append(f"{name} {version} ({f.vuln.id}, fixed in {relevant_fixes(f.vuln)[0]})")
|
|
300
|
+
else:
|
|
301
|
+
earliest = min(relevant_fixes(f.vuln)[0] for f in group)
|
|
302
|
+
parts.append(f"{name} {version} ({len(group)} advisories, earliest fix {earliest})")
|
|
303
|
+
if group[0].reachable:
|
|
304
|
+
moves.append(f"{name} can go to {_join(group[0].reachable)} now")
|
|
305
|
+
|
|
306
|
+
count = len(waiting)
|
|
307
|
+
head = (
|
|
308
|
+
f"{count} finding waits" if count == 1 else f"{count} findings wait"
|
|
309
|
+
) + " on the exclude-newer cooldown: "
|
|
310
|
+
them = "it" if count == 1 else "them"
|
|
311
|
+
tail = (
|
|
312
|
+
f"{'; '.join(moves)}, which does not fix {them} yet."
|
|
313
|
+
if moves
|
|
314
|
+
else f"Nothing to do until it allows {them}."
|
|
315
|
+
)
|
|
316
|
+
return f"{head}{'; '.join(parts)}. {tail}"
|
|
File without changes
|