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.
@@ -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,5 @@
1
+ """Cooldown-aware `uv audit`."""
2
+
3
+ from importlib.metadata import version
4
+
5
+ __version__ = version("uv-audit-cooldown")
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
@@ -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