@biffo/cli 0.298.41 → 0.298.42
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.
- package/_skeletons/plugin-template/scripts/py-dependency-audit-classification.test.sh +284 -0
- package/_skeletons/plugin-template/scripts/py-dependency-audit.sh +162 -8
- package/_skeletons/sibling-template/scripts/py-dependency-audit-classification.test.sh +284 -0
- package/_skeletons/sibling-template/scripts/py-dependency-audit.sh +162 -8
- package/package.json +1 -1
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
#!/usr/bin/env sh
|
|
2
|
+
#
|
|
3
|
+
# Proves py-dependency-audit.sh (#1673) distinguishes "this PR's diff
|
|
4
|
+
# introduced or upgraded to a vulnerable package version" from "an advisory
|
|
5
|
+
# was published against a version already sitting on the base branch,
|
|
6
|
+
# unrelated to this diff" -- rather than reporting an identical red for both.
|
|
7
|
+
#
|
|
8
|
+
# The real incident this guards against: PYSEC-2026-3721 was published
|
|
9
|
+
# against pip 26.1.2 on 2026-08-21 and turned the Python lane red on PRs
|
|
10
|
+
# #1669/#1670, neither of which touched uv.lock or any Python dependency --
|
|
11
|
+
# dev's own lockfile was already on that exact pip version, green, the same
|
|
12
|
+
# morning before the advisory existed. Case 1 below reconstructs exactly that
|
|
13
|
+
# shape: base and PR both pinned at the flagged version, PR diff untouched.
|
|
14
|
+
#
|
|
15
|
+
# Builds a real, tiny, throwaway git repo under mktemp (never under /tmp as
|
|
16
|
+
# a full worktree copy -- this is a from-scratch repo with a handful of
|
|
17
|
+
# files, not a copy of this repository's own object store) with a base
|
|
18
|
+
# branch and a PR-head state, and stubs `uv` on PATH so `uv run pip-audit -f
|
|
19
|
+
# json` returns a canned finding without any network access or real
|
|
20
|
+
# environment. `jq`, `git` and `awk` are used for real -- they are exactly
|
|
21
|
+
# what the target script itself depends on, so stubbing them would test
|
|
22
|
+
# nothing.
|
|
23
|
+
#
|
|
24
|
+
# Run: sh scripts/py-dependency-audit-classification.test.sh
|
|
25
|
+
|
|
26
|
+
set -u
|
|
27
|
+
|
|
28
|
+
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
|
|
29
|
+
TARGET="$SCRIPT_DIR/py-dependency-audit.sh"
|
|
30
|
+
|
|
31
|
+
REPO_DIR=$(mktemp -d)
|
|
32
|
+
STUB_DIR=$(mktemp -d)
|
|
33
|
+
OUT_FILE=$(mktemp)
|
|
34
|
+
trap 'rm -rf "$REPO_DIR" "$STUB_DIR"; rm -f "$OUT_FILE"' EXIT
|
|
35
|
+
|
|
36
|
+
FAILURES=0
|
|
37
|
+
|
|
38
|
+
# --- uv stub -------------------------------------------------------------
|
|
39
|
+
# Implements only what the workspace path of the target script calls: `uv
|
|
40
|
+
# run pip-audit -f json`. Prints whatever JSON the current scenario staged
|
|
41
|
+
# at $STUB_DIR/pip-audit-output.json. Any other invocation is a test bug --
|
|
42
|
+
# fail loudly rather than silently returning nothing.
|
|
43
|
+
cat > "$STUB_DIR/uv" <<'STUB'
|
|
44
|
+
#!/usr/bin/env sh
|
|
45
|
+
if [ "$1" = "run" ] && [ "$2" = "pip-audit" ]; then
|
|
46
|
+
cat "$PIPAUDIT_STUB_OUTPUT"
|
|
47
|
+
exit 0
|
|
48
|
+
fi
|
|
49
|
+
echo "uv stub: unexpected invocation: $*" >&2
|
|
50
|
+
exit 99
|
|
51
|
+
STUB
|
|
52
|
+
chmod +x "$STUB_DIR/uv"
|
|
53
|
+
|
|
54
|
+
# --- repo scaffolding ------------------------------------------------------
|
|
55
|
+
# One workspace-level uv.lock so discovery finds exactly one tree, matching
|
|
56
|
+
# the real incident (the workspace/root lockfile, not a vendored one).
|
|
57
|
+
_write_lock() {
|
|
58
|
+
# $1 = destination file, $2 = package name, $3 = version
|
|
59
|
+
cat > "$1" <<LOCK
|
|
60
|
+
version = 1
|
|
61
|
+
requires-python = ">=3.13"
|
|
62
|
+
|
|
63
|
+
[[package]]
|
|
64
|
+
name = "$2"
|
|
65
|
+
version = "$3"
|
|
66
|
+
source = { registry = "https://pypi.org/simple" }
|
|
67
|
+
LOCK
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
_write_lock_two() {
|
|
71
|
+
# $1 = destination file, $2/$3 = first package name/version,
|
|
72
|
+
# $4/$5 = second package name/version. Used only by the mixed-tree case.
|
|
73
|
+
cat > "$1" <<LOCK
|
|
74
|
+
version = 1
|
|
75
|
+
requires-python = ">=3.13"
|
|
76
|
+
|
|
77
|
+
[[package]]
|
|
78
|
+
name = "$2"
|
|
79
|
+
version = "$3"
|
|
80
|
+
source = { registry = "https://pypi.org/simple" }
|
|
81
|
+
|
|
82
|
+
[[package]]
|
|
83
|
+
name = "$4"
|
|
84
|
+
version = "$5"
|
|
85
|
+
source = { registry = "https://pypi.org/simple" }
|
|
86
|
+
LOCK
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
_init_repo() {
|
|
90
|
+
# $1 = base package version (what "dev" / origin/<base> holds)
|
|
91
|
+
# $2 = head package version (what the PR branch under test holds)
|
|
92
|
+
# $3 = package name (default "pip")
|
|
93
|
+
base_version=$1
|
|
94
|
+
head_version=$2
|
|
95
|
+
pkg=${3:-pip}
|
|
96
|
+
|
|
97
|
+
rm -rf "$REPO_DIR"
|
|
98
|
+
mkdir -p "$REPO_DIR"
|
|
99
|
+
( cd "$REPO_DIR" \
|
|
100
|
+
&& git init -q -b trunk \
|
|
101
|
+
&& git config user.email test@example.com \
|
|
102
|
+
&& git config user.name "Test" )
|
|
103
|
+
|
|
104
|
+
_write_lock "$REPO_DIR/uv.lock" "$pkg" "$base_version"
|
|
105
|
+
( cd "$REPO_DIR" && git add -A && git commit -q -m base )
|
|
106
|
+
# A literal branch named "origin/<base>" -- git tolerates slashes in
|
|
107
|
+
# branch names, so this resolves via `git show origin/<base>:<path>`
|
|
108
|
+
# exactly like a real remote-tracking ref would, with no remote required.
|
|
109
|
+
( cd "$REPO_DIR" && git branch -q "origin/dev" )
|
|
110
|
+
|
|
111
|
+
if [ "$head_version" != "$base_version" ]; then
|
|
112
|
+
_write_lock "$REPO_DIR/uv.lock" "$pkg" "$head_version"
|
|
113
|
+
( cd "$REPO_DIR" && git add -A && git commit -q -m "pr change" )
|
|
114
|
+
fi
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
# --- pip-audit output staging ---------------------------------------------
|
|
118
|
+
_stage_finding() {
|
|
119
|
+
# $1 = package name, $2 = version pip-audit reports as installed/flagged
|
|
120
|
+
pkg=$1
|
|
121
|
+
version=$2
|
|
122
|
+
cat > "$STUB_DIR/pip-audit-output.json" <<JSON
|
|
123
|
+
{"dependencies": [{"name": "$pkg", "version": "$version", "vulns": [{"id": "PYSEC-2026-3721", "fix_versions": ["26.2.1"]}]}]}
|
|
124
|
+
JSON
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
_stage_clean() {
|
|
128
|
+
cat > "$STUB_DIR/pip-audit-output.json" <<'JSON'
|
|
129
|
+
{"dependencies": [{"name": "pip", "version": "26.2.1", "vulns": []}]}
|
|
130
|
+
JSON
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
# --- runner ----------------------------------------------------------------
|
|
134
|
+
_run() {
|
|
135
|
+
# Runs the target script with GITHUB_BASE_REF set from $1 (empty string
|
|
136
|
+
# means unset -- a push/workflow_dispatch/merge_group context), cwd inside
|
|
137
|
+
# the synthetic repo, uv stubbed, PATH otherwise untouched (jq/git/awk are
|
|
138
|
+
# the real system tools, same as production).
|
|
139
|
+
base_ref=$1
|
|
140
|
+
(
|
|
141
|
+
cd "$REPO_DIR" || exit 97
|
|
142
|
+
PATH="$STUB_DIR:$PATH"
|
|
143
|
+
export PATH
|
|
144
|
+
PIPAUDIT_STUB_OUTPUT="$STUB_DIR/pip-audit-output.json"
|
|
145
|
+
export PIPAUDIT_STUB_OUTPUT
|
|
146
|
+
if [ -n "$base_ref" ]; then
|
|
147
|
+
GITHUB_BASE_REF="$base_ref"
|
|
148
|
+
export GITHUB_BASE_REF
|
|
149
|
+
else
|
|
150
|
+
unset GITHUB_BASE_REF
|
|
151
|
+
fi
|
|
152
|
+
sh "$TARGET"
|
|
153
|
+
) >"$OUT_FILE" 2>&1
|
|
154
|
+
LAST_RC=$?
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
_assert_exit() {
|
|
158
|
+
name=$1
|
|
159
|
+
expected=$2
|
|
160
|
+
if [ "$LAST_RC" -eq "$expected" ]; then
|
|
161
|
+
echo "PASS: $name (exit $LAST_RC)"
|
|
162
|
+
else
|
|
163
|
+
echo "FAIL: $name -- expected exit $expected, got $LAST_RC"
|
|
164
|
+
echo "--- output ---"
|
|
165
|
+
cat "$OUT_FILE"
|
|
166
|
+
echo "--------------"
|
|
167
|
+
FAILURES=$((FAILURES + 1))
|
|
168
|
+
fi
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
_assert_output_contains() {
|
|
172
|
+
name=$1
|
|
173
|
+
needle=$2
|
|
174
|
+
if grep -qF "$needle" "$OUT_FILE"; then
|
|
175
|
+
echo "PASS: $name mentions '$needle'"
|
|
176
|
+
else
|
|
177
|
+
echo "FAIL: $name -- expected output to mention '$needle'"
|
|
178
|
+
echo "--- output ---"
|
|
179
|
+
cat "$OUT_FILE"
|
|
180
|
+
echo "--------------"
|
|
181
|
+
FAILURES=$((FAILURES + 1))
|
|
182
|
+
fi
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
# ============================================================================
|
|
186
|
+
# Case table (must-NOT-block first, then must-block), each run against the
|
|
187
|
+
# real target script -- not a reimplementation of its logic.
|
|
188
|
+
# ============================================================================
|
|
189
|
+
|
|
190
|
+
# 1. PRE-EXISTING, PR context, package UNCHANGED from base (the PYSEC-2026-3721
|
|
191
|
+
# / PR #1669 / #1670 shape: same version on both sides of the diff). Must
|
|
192
|
+
# NOT block, and must say so as pre-existing rather than a flat red.
|
|
193
|
+
_init_repo "26.1.2" "26.1.2" pip
|
|
194
|
+
_stage_finding pip 26.1.2
|
|
195
|
+
_run dev
|
|
196
|
+
_assert_exit "pre-existing, version unchanged by diff" 0
|
|
197
|
+
_assert_output_contains "pre-existing case names it as pre-existing" "pre-existing"
|
|
198
|
+
_assert_output_contains "pre-existing case cites #1673" "#1673"
|
|
199
|
+
|
|
200
|
+
# 2. INTRODUCED via upgrade: base had a clean version, this diff's uv.lock
|
|
201
|
+
# moved it to the flagged version. Must block.
|
|
202
|
+
_init_repo "26.0.0" "26.1.2" pip
|
|
203
|
+
_stage_finding pip 26.1.2
|
|
204
|
+
_run dev
|
|
205
|
+
_assert_exit "introduced by upgrade" 1
|
|
206
|
+
_assert_output_contains "introduced-by-upgrade case names it as introduced" "introduced"
|
|
207
|
+
|
|
208
|
+
# 3. INTRODUCED via new dependency: base's lockfile has no such package at
|
|
209
|
+
# all; this diff added it at an already-vulnerable version. Must block.
|
|
210
|
+
_init_repo "0.0.0" "0.0.0" some-other-package
|
|
211
|
+
_write_lock "$REPO_DIR/uv.lock" "cryptography" "49.0.0"
|
|
212
|
+
( cd "$REPO_DIR" && git add -A && git commit -q -m "add new dependency" )
|
|
213
|
+
_stage_finding cryptography 49.0.0
|
|
214
|
+
_run dev
|
|
215
|
+
_assert_exit "introduced via brand-new dependency" 1
|
|
216
|
+
_assert_output_contains "new-dependency case names it as introduced" "introduced"
|
|
217
|
+
|
|
218
|
+
# 4. Non-PR context (push to the integration branch itself, or
|
|
219
|
+
# workflow_dispatch/merge_group -- GITHUB_BASE_REF unset). No diff exists
|
|
220
|
+
# to attribute the finding to, so the classification must NOT apply: any
|
|
221
|
+
# finding blocks, exactly as before this fix. This is deliberately the
|
|
222
|
+
# SAME lockfile shape as case 1 (unchanged version) to prove the
|
|
223
|
+
# difference in outcome is driven by PR-context alone, matching #1671
|
|
224
|
+
# (dev's own red had to be fixed, not reclassified away).
|
|
225
|
+
_init_repo "26.1.2" "26.1.2" pip
|
|
226
|
+
_stage_finding pip 26.1.2
|
|
227
|
+
_run ""
|
|
228
|
+
_assert_exit "no PR context -- always blocks" 1
|
|
229
|
+
|
|
230
|
+
# 5. PR context, but the base ref cannot be resolved locally (e.g. a shallow
|
|
231
|
+
# checkout, or a distributed copy running where fetch-depth: 0 was not
|
|
232
|
+
# honoured). Comparison fails CLOSED: blocks, same as before this fix,
|
|
233
|
+
# rather than silently waving a real regression through because the
|
|
234
|
+
# comparison itself could not be made.
|
|
235
|
+
_init_repo "26.1.2" "26.1.2" pip
|
|
236
|
+
_stage_finding pip 26.1.2
|
|
237
|
+
_run "some-branch-that-was-never-fetched"
|
|
238
|
+
_assert_exit "base ref unresolvable -- fails closed to blocking" 1
|
|
239
|
+
|
|
240
|
+
# 6. Mixed tree: one pre-existing finding (another-pkg, unchanged from base)
|
|
241
|
+
# and one introduced finding (pip, upgraded by this diff) in the same
|
|
242
|
+
# run. Must still block overall (the introduced one), while the
|
|
243
|
+
# pre-existing one is still named as such rather than folded into an
|
|
244
|
+
# undifferentiated total.
|
|
245
|
+
rm -rf "$REPO_DIR"
|
|
246
|
+
mkdir -p "$REPO_DIR"
|
|
247
|
+
( cd "$REPO_DIR" \
|
|
248
|
+
&& git init -q -b trunk \
|
|
249
|
+
&& git config user.email test@example.com \
|
|
250
|
+
&& git config user.name "Test" )
|
|
251
|
+
_write_lock_two "$REPO_DIR/uv.lock" pip "26.0.0" another-pkg "1.0.0"
|
|
252
|
+
( cd "$REPO_DIR" && git add -A && git commit -q -m base )
|
|
253
|
+
( cd "$REPO_DIR" && git branch -q "origin/dev" )
|
|
254
|
+
# PR upgrades pip only; another-pkg's pin is untouched.
|
|
255
|
+
_write_lock_two "$REPO_DIR/uv.lock" pip "26.1.2" another-pkg "1.0.0"
|
|
256
|
+
( cd "$REPO_DIR" && git add -A && git commit -q -m "pr upgrades pip" )
|
|
257
|
+
cat > "$STUB_DIR/pip-audit-output.json" <<'JSON'
|
|
258
|
+
{"dependencies": [
|
|
259
|
+
{"name": "pip", "version": "26.1.2", "vulns": [{"id": "PYSEC-2026-3721"}]},
|
|
260
|
+
{"name": "another-pkg", "version": "1.0.0", "vulns": [{"id": "PYSEC-0000-0000"}]}
|
|
261
|
+
]}
|
|
262
|
+
JSON
|
|
263
|
+
_run dev
|
|
264
|
+
_assert_exit "mixed tree -- introduced finding still blocks" 1
|
|
265
|
+
_assert_output_contains "mixed tree -- introduced pip is named" "pip@26.1.2"
|
|
266
|
+
_assert_output_contains "mixed tree -- pre-existing another-pkg is named separately" "another-pkg@1.0.0"
|
|
267
|
+
_assert_output_contains "mixed tree -- pre-existing another-pkg says so" "pre-existing"
|
|
268
|
+
|
|
269
|
+
# 7. Sanity: a clean run (no vulnerabilities at all) is unaffected by any of
|
|
270
|
+
# the above -- still exits 0, still audits normally with the new 3-arg
|
|
271
|
+
# audit_deps signature.
|
|
272
|
+
_init_repo "26.2.1" "26.2.1" pip
|
|
273
|
+
_stage_clean
|
|
274
|
+
_run dev
|
|
275
|
+
_assert_exit "clean run unaffected" 0
|
|
276
|
+
|
|
277
|
+
echo
|
|
278
|
+
if [ "$FAILURES" -eq 0 ]; then
|
|
279
|
+
echo "py-dependency-audit-classification.test.sh: all checks passed."
|
|
280
|
+
exit 0
|
|
281
|
+
else
|
|
282
|
+
echo "py-dependency-audit-classification.test.sh: $FAILURES check(s) failed."
|
|
283
|
+
exit 1
|
|
284
|
+
fi
|
|
@@ -62,6 +62,43 @@
|
|
|
62
62
|
# it layout-agnostic, or the copies stop being interchangeable and
|
|
63
63
|
# `shared-sync.sh --check` starts reporting drift that is really divergence.
|
|
64
64
|
#
|
|
65
|
+
# ## Pre-existing vs introduced (#1673)
|
|
66
|
+
#
|
|
67
|
+
# The checks above (network flake vs real finding) say nothing about WHEN the
|
|
68
|
+
# vulnerable version got there. PYSEC-2026-3721 was published against pip
|
|
69
|
+
# 26.1.2 on 2026-08-21 and turned this lane red on PRs #1669/#1670 — neither
|
|
70
|
+
# of which touched uv.lock or any Python dependency; dev's own lockfile had
|
|
71
|
+
# been green on that exact pip version the same morning, before the advisory
|
|
72
|
+
# existed. The timestamp this file already prints proves the *scan* was
|
|
73
|
+
# fresh; it says nothing about whether the *finding* belongs to the diff
|
|
74
|
+
# under test, so a pre-existing advisory on dev and a real regression read
|
|
75
|
+
# as an identical red — a reader cannot tell which one to fix without
|
|
76
|
+
# re-deriving the same by-hand diagnosis #1673 was filed to save.
|
|
77
|
+
#
|
|
78
|
+
# The fix compares the flagged package's version against the SAME tree's
|
|
79
|
+
# lockfile on the PR's base branch, not against the diff: a PR can leave
|
|
80
|
+
# uv.lock untouched while another package's line moves it, or the base
|
|
81
|
+
# branch itself may already have upgraded — checking "did the diff touch
|
|
82
|
+
# uv.lock" would get both wrong. Comparing per-package versions is also what
|
|
83
|
+
# tells a PR that upgrades a DIFFERENT package into a vulnerable version
|
|
84
|
+
# apart from one that never touches the flagged package at all.
|
|
85
|
+
#
|
|
86
|
+
# `GITHUB_BASE_REF` is the base branch name and is set ONLY for a
|
|
87
|
+
# `pull_request`/`pull_request_target` event — empty on `push`,
|
|
88
|
+
# `workflow_dispatch` and `merge_group`. That is deliberate, not a gap:
|
|
89
|
+
# outside a PR there is no "diff" to attribute a finding to, so the
|
|
90
|
+
# classification does not apply and every finding blocks exactly as before
|
|
91
|
+
# — which is the correct behaviour for dev itself (#1671 fixed dev's own red
|
|
92
|
+
# by bumping the lockfile, not by reclassifying it away).
|
|
93
|
+
#
|
|
94
|
+
# `origin/$GITHUB_BASE_REF` must already be a resolvable local ref for the
|
|
95
|
+
# comparison to run at all — the `python` job's checkout uses
|
|
96
|
+
# `fetch-depth: 0` precisely so it is (see that job's own comment). If it is
|
|
97
|
+
# NOT resolvable (a shallow checkout, or a distributed copy of this script
|
|
98
|
+
# running somewhere that checkout is missing), comparison fails CLOSED: every
|
|
99
|
+
# finding blocks, same as before this fix, rather than silently waving a real
|
|
100
|
+
# regression through because the check could not be performed.
|
|
101
|
+
#
|
|
65
102
|
# POSIX sh (the CI step runs `sh scripts/...`, i.e. dash) — no `pipefail`.
|
|
66
103
|
set -u
|
|
67
104
|
|
|
@@ -79,14 +116,80 @@ fi
|
|
|
79
116
|
attempts=3
|
|
80
117
|
inconclusive=0
|
|
81
118
|
failed=0
|
|
119
|
+
preexisting_total=0
|
|
120
|
+
|
|
121
|
+
# Extract the version pinned for package `$1` from a uv.lock's content, read
|
|
122
|
+
# on stdin. uv.lock's TOML shape is a flat list of `[[package]]` tables, each
|
|
123
|
+
# starting with a `name = "..."` line immediately followed by a
|
|
124
|
+
# `version = "..."` line (verified against this repo's own uv.lock) — so a
|
|
125
|
+
# small awk state machine is enough without pulling in a real TOML parser.
|
|
126
|
+
# Prints nothing (empty string) if the package is not in the lockfile at all.
|
|
127
|
+
_lockfile_version_for() {
|
|
128
|
+
pkg="$1"
|
|
129
|
+
awk -v pkg="$pkg" '
|
|
130
|
+
/^\[\[package\]\]/ { name = "" }
|
|
131
|
+
/^name = "/ {
|
|
132
|
+
line = $0
|
|
133
|
+
sub(/^name = "/, "", line)
|
|
134
|
+
sub(/".*$/, "", line)
|
|
135
|
+
name = line
|
|
136
|
+
next
|
|
137
|
+
}
|
|
138
|
+
/^version = "/ {
|
|
139
|
+
if (name == pkg) {
|
|
140
|
+
line = $0
|
|
141
|
+
sub(/^version = "/, "", line)
|
|
142
|
+
sub(/".*$/, "", line)
|
|
143
|
+
print line
|
|
144
|
+
exit
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
'
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
# Classify one (package, version) finding as "introduced" (blocks) or
|
|
151
|
+
# "pre-existing" (does not) by comparing against the SAME tree's uv.lock on
|
|
152
|
+
# the PR's base branch. `$1` name, `$2` the version pip-audit flagged, `$3`
|
|
153
|
+
# the tree's uv.lock path relative to the repo root. Always prints
|
|
154
|
+
# "introduced" — the old, safe behaviour — when a comparison cannot be made
|
|
155
|
+
# at all: no base ref (not a PR), or the ref does not resolve locally.
|
|
156
|
+
_classify_finding() {
|
|
157
|
+
finding_name="$1"
|
|
158
|
+
finding_version="$2"
|
|
159
|
+
finding_lockfile_relpath="$3"
|
|
160
|
+
|
|
161
|
+
if [ "$COMPARE_MODE" -ne 1 ]; then
|
|
162
|
+
printf 'introduced'
|
|
163
|
+
return
|
|
164
|
+
fi
|
|
165
|
+
|
|
166
|
+
base_lock_content="$(git show "${BASE_REMOTE_REF}:${finding_lockfile_relpath}" 2>/dev/null)"
|
|
167
|
+
if [ -z "$base_lock_content" ]; then
|
|
168
|
+
# Tree (or this path within it) did not exist on the base branch at all
|
|
169
|
+
# — cannot be anything other than new, by this diff.
|
|
170
|
+
printf 'introduced'
|
|
171
|
+
return
|
|
172
|
+
fi
|
|
173
|
+
|
|
174
|
+
base_version="$(printf '%s\n' "$base_lock_content" | _lockfile_version_for "$finding_name")"
|
|
175
|
+
if [ -n "$base_version" ] && [ "$base_version" = "$finding_version" ]; then
|
|
176
|
+
printf 'pre-existing'
|
|
177
|
+
else
|
|
178
|
+
printf 'introduced'
|
|
179
|
+
fi
|
|
180
|
+
}
|
|
82
181
|
|
|
83
182
|
# Audit one dependency set. Returns 0 if clean or inconclusive, 1 on a real
|
|
84
|
-
# finding. `$1` is a human label,
|
|
85
|
-
# what to audit — empty for this
|
|
86
|
-
# `-r <file>` for a requirements file
|
|
183
|
+
# finding that this diff introduced or upgraded to. `$1` is a human label,
|
|
184
|
+
# `$2` the extra pip-audit flags that select what to audit — empty for this
|
|
185
|
+
# workspace's installed environment, or `-r <file>` for a requirements file
|
|
186
|
+
# exported from a skeleton lockfile. `$3` is this tree's uv.lock path
|
|
187
|
+
# relative to the repo root, used to look up the SAME package's version on
|
|
188
|
+
# the base branch (#1673) — never empty, every caller passes one.
|
|
87
189
|
audit_deps() {
|
|
88
190
|
label="$1"
|
|
89
191
|
extra="$2"
|
|
192
|
+
lockfile_relpath="$3"
|
|
90
193
|
|
|
91
194
|
for attempt in $(seq 1 "$attempts"); do
|
|
92
195
|
# shellcheck disable=SC2086
|
|
@@ -108,9 +211,36 @@ audit_deps() {
|
|
|
108
211
|
vuln_count="$(printf '%s' "$out" | jq '[.dependencies[].vulns[]?] | length')"
|
|
109
212
|
pkg_count="$(printf '%s' "$out" | jq '.dependencies | length')"
|
|
110
213
|
if [ "$vuln_count" -gt 0 ]; then
|
|
111
|
-
|
|
214
|
+
introduced_count=0
|
|
215
|
+
preexisting_count=0
|
|
216
|
+
findings_file="$(mktemp)"
|
|
217
|
+
printf '%s' "$out" | jq -r '.dependencies[] | select(.vulns | length > 0) | "\(.name)\t\(.version)"' > "$findings_file"
|
|
218
|
+
# POSIX `while read` from a file (not a pipe) so counters set inside
|
|
219
|
+
# the loop survive it — a pipeline would run the loop in a subshell
|
|
220
|
+
# and lose every update the instant it exits.
|
|
221
|
+
while IFS="$(printf '\t')" read -r finding_pkg finding_ver; do
|
|
222
|
+
[ -z "$finding_pkg" ] && continue
|
|
223
|
+
verdict="$(_classify_finding "$finding_pkg" "$finding_ver" "$lockfile_relpath")"
|
|
224
|
+
if [ "$verdict" = "pre-existing" ]; then
|
|
225
|
+
preexisting_count=$((preexisting_count + 1))
|
|
226
|
+
echo "::warning::${label}: ${finding_pkg}@${finding_ver} has an advisory but is unchanged from ${BASE_REMOTE_REF}'s lockfile — pre-existing, not introduced by this diff (#1673)."
|
|
227
|
+
else
|
|
228
|
+
introduced_count=$((introduced_count + 1))
|
|
229
|
+
echo "::error::${label}: ${finding_pkg}@${finding_ver} — new to this tree or upgraded to this version by this diff (or a base-branch comparison was not possible)."
|
|
230
|
+
fi
|
|
231
|
+
done < "$findings_file"
|
|
232
|
+
rm -f "$findings_file"
|
|
233
|
+
|
|
112
234
|
printf '%s' "$out" | jq '[.dependencies[] | select(.vulns | length > 0)]' 2>/dev/null | head -c 4000
|
|
113
|
-
|
|
235
|
+
|
|
236
|
+
if [ "$introduced_count" -gt 0 ]; then
|
|
237
|
+
echo "::error::${label}: ${introduced_count} vulnerability(ies) introduced or upgraded by this diff (${preexisting_count} more pre-existing, not counted against it)."
|
|
238
|
+
return 1
|
|
239
|
+
fi
|
|
240
|
+
|
|
241
|
+
preexisting_total=$((preexisting_total + preexisting_count))
|
|
242
|
+
echo "${label}: ${preexisting_count} vulnerability(ies) found, all pre-existing on ${BASE_REMOTE_REF} and unrelated to this diff — not blocking (#1673)."
|
|
243
|
+
return 0
|
|
114
244
|
fi
|
|
115
245
|
# State the population and the moment, so the green is falsifiable: a
|
|
116
246
|
# pass over 0 packages and a pass over 92 look identical otherwise, and
|
|
@@ -143,6 +273,22 @@ fi
|
|
|
143
273
|
|
|
144
274
|
WORKSPACE_ABS=$(pwd -P)
|
|
145
275
|
|
|
276
|
+
# Decide once, for the whole run, whether a base-branch comparison is even
|
|
277
|
+
# possible (#1673 — see the docstring above `set -u`). `GITHUB_BASE_REF` is
|
|
278
|
+
# only ever set by a `pull_request`/`pull_request_target` event; everywhere
|
|
279
|
+
# else COMPARE_MODE stays 0 and every finding blocks, unchanged from before.
|
|
280
|
+
BASE_REMOTE_REF=""
|
|
281
|
+
COMPARE_MODE=0
|
|
282
|
+
if [ -n "${GITHUB_BASE_REF:-}" ]; then
|
|
283
|
+
candidate="origin/${GITHUB_BASE_REF}"
|
|
284
|
+
if git rev-parse --verify --quiet "${candidate}^{commit}" >/dev/null 2>&1; then
|
|
285
|
+
BASE_REMOTE_REF="$candidate"
|
|
286
|
+
COMPARE_MODE=1
|
|
287
|
+
else
|
|
288
|
+
echo "::warning::py-dependency-audit: base branch is '${GITHUB_BASE_REF}' but '${candidate}' does not resolve locally (shallow checkout?) — cannot tell a pre-existing finding from one this diff introduced, so every finding will block, same as before #1673."
|
|
289
|
+
fi
|
|
290
|
+
fi
|
|
291
|
+
|
|
146
292
|
# shellcheck disable=SC2016
|
|
147
293
|
ALL_LOCKS=$(find "$REPO_ROOT" \
|
|
148
294
|
\( -name node_modules -o -name .git -o -name .worktrees -o -name .venv \) -prune -o \
|
|
@@ -195,9 +341,13 @@ for lock in $ALL_LOCKS; do
|
|
|
195
341
|
"$REPO_ROOT"/*) rel=${dir_abs#"$REPO_ROOT"/} ;;
|
|
196
342
|
*) rel="$dir_abs" ;;
|
|
197
343
|
esac
|
|
344
|
+
# `find` was rooted at $REPO_ROOT, so $lock is always an absolute path
|
|
345
|
+
# under it — this strip is unconditional, unlike $rel's dir_abs case
|
|
346
|
+
# above (which also has to tolerate a symlink resolving outside the root).
|
|
347
|
+
lock_rel=${lock#"$REPO_ROOT"/}
|
|
198
348
|
|
|
199
349
|
if [ "$dir_abs" = "$WORKSPACE_ABS" ]; then
|
|
200
|
-
audit_deps "pip-audit (workspace: ${rel})" "" || failed=1
|
|
350
|
+
audit_deps "pip-audit (workspace: ${rel})" "" "$lock_rel" || failed=1
|
|
201
351
|
continue
|
|
202
352
|
fi
|
|
203
353
|
|
|
@@ -231,7 +381,7 @@ for lock in $ALL_LOCKS; do
|
|
|
231
381
|
if [ "${local_deps:-0}" -gt 0 ]; then
|
|
232
382
|
echo " (${rel}: ${local_deps} local path dependenc$([ "$local_deps" -eq 1 ] && echo y || echo ies) excluded -- first-party, audited in their own repo; their transitive dependencies are still scanned)"
|
|
233
383
|
fi
|
|
234
|
-
audit_deps "pip-audit (${rel})" "-r $reqs" || failed=1
|
|
384
|
+
audit_deps "pip-audit (${rel})" "-r $reqs" "$lock_rel" || failed=1
|
|
235
385
|
else
|
|
236
386
|
# An export failure is "couldn't run", not "clean" — same discipline as a
|
|
237
387
|
# transient pip-audit error, and it must never read as a pass.
|
|
@@ -255,5 +405,9 @@ if [ "$inconclusive" -ne 0 ]; then
|
|
|
255
405
|
exit 2
|
|
256
406
|
fi
|
|
257
407
|
|
|
258
|
-
|
|
408
|
+
if [ "$preexisting_total" -gt 0 ]; then
|
|
409
|
+
echo "py-dependency-audit: audited ${tree_count} tree(s), 0 blocking findings (${preexisting_total} pre-existing on ${BASE_REMOTE_REF}, unrelated to this diff — see #1673)."
|
|
410
|
+
else
|
|
411
|
+
echo "py-dependency-audit: audited ${tree_count} tree(s), 0 blocking findings."
|
|
412
|
+
fi
|
|
259
413
|
exit 0
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
#!/usr/bin/env sh
|
|
2
|
+
#
|
|
3
|
+
# Proves py-dependency-audit.sh (#1673) distinguishes "this PR's diff
|
|
4
|
+
# introduced or upgraded to a vulnerable package version" from "an advisory
|
|
5
|
+
# was published against a version already sitting on the base branch,
|
|
6
|
+
# unrelated to this diff" -- rather than reporting an identical red for both.
|
|
7
|
+
#
|
|
8
|
+
# The real incident this guards against: PYSEC-2026-3721 was published
|
|
9
|
+
# against pip 26.1.2 on 2026-08-21 and turned the Python lane red on PRs
|
|
10
|
+
# #1669/#1670, neither of which touched uv.lock or any Python dependency --
|
|
11
|
+
# dev's own lockfile was already on that exact pip version, green, the same
|
|
12
|
+
# morning before the advisory existed. Case 1 below reconstructs exactly that
|
|
13
|
+
# shape: base and PR both pinned at the flagged version, PR diff untouched.
|
|
14
|
+
#
|
|
15
|
+
# Builds a real, tiny, throwaway git repo under mktemp (never under /tmp as
|
|
16
|
+
# a full worktree copy -- this is a from-scratch repo with a handful of
|
|
17
|
+
# files, not a copy of this repository's own object store) with a base
|
|
18
|
+
# branch and a PR-head state, and stubs `uv` on PATH so `uv run pip-audit -f
|
|
19
|
+
# json` returns a canned finding without any network access or real
|
|
20
|
+
# environment. `jq`, `git` and `awk` are used for real -- they are exactly
|
|
21
|
+
# what the target script itself depends on, so stubbing them would test
|
|
22
|
+
# nothing.
|
|
23
|
+
#
|
|
24
|
+
# Run: sh scripts/py-dependency-audit-classification.test.sh
|
|
25
|
+
|
|
26
|
+
set -u
|
|
27
|
+
|
|
28
|
+
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
|
|
29
|
+
TARGET="$SCRIPT_DIR/py-dependency-audit.sh"
|
|
30
|
+
|
|
31
|
+
REPO_DIR=$(mktemp -d)
|
|
32
|
+
STUB_DIR=$(mktemp -d)
|
|
33
|
+
OUT_FILE=$(mktemp)
|
|
34
|
+
trap 'rm -rf "$REPO_DIR" "$STUB_DIR"; rm -f "$OUT_FILE"' EXIT
|
|
35
|
+
|
|
36
|
+
FAILURES=0
|
|
37
|
+
|
|
38
|
+
# --- uv stub -------------------------------------------------------------
|
|
39
|
+
# Implements only what the workspace path of the target script calls: `uv
|
|
40
|
+
# run pip-audit -f json`. Prints whatever JSON the current scenario staged
|
|
41
|
+
# at $STUB_DIR/pip-audit-output.json. Any other invocation is a test bug --
|
|
42
|
+
# fail loudly rather than silently returning nothing.
|
|
43
|
+
cat > "$STUB_DIR/uv" <<'STUB'
|
|
44
|
+
#!/usr/bin/env sh
|
|
45
|
+
if [ "$1" = "run" ] && [ "$2" = "pip-audit" ]; then
|
|
46
|
+
cat "$PIPAUDIT_STUB_OUTPUT"
|
|
47
|
+
exit 0
|
|
48
|
+
fi
|
|
49
|
+
echo "uv stub: unexpected invocation: $*" >&2
|
|
50
|
+
exit 99
|
|
51
|
+
STUB
|
|
52
|
+
chmod +x "$STUB_DIR/uv"
|
|
53
|
+
|
|
54
|
+
# --- repo scaffolding ------------------------------------------------------
|
|
55
|
+
# One workspace-level uv.lock so discovery finds exactly one tree, matching
|
|
56
|
+
# the real incident (the workspace/root lockfile, not a vendored one).
|
|
57
|
+
_write_lock() {
|
|
58
|
+
# $1 = destination file, $2 = package name, $3 = version
|
|
59
|
+
cat > "$1" <<LOCK
|
|
60
|
+
version = 1
|
|
61
|
+
requires-python = ">=3.13"
|
|
62
|
+
|
|
63
|
+
[[package]]
|
|
64
|
+
name = "$2"
|
|
65
|
+
version = "$3"
|
|
66
|
+
source = { registry = "https://pypi.org/simple" }
|
|
67
|
+
LOCK
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
_write_lock_two() {
|
|
71
|
+
# $1 = destination file, $2/$3 = first package name/version,
|
|
72
|
+
# $4/$5 = second package name/version. Used only by the mixed-tree case.
|
|
73
|
+
cat > "$1" <<LOCK
|
|
74
|
+
version = 1
|
|
75
|
+
requires-python = ">=3.13"
|
|
76
|
+
|
|
77
|
+
[[package]]
|
|
78
|
+
name = "$2"
|
|
79
|
+
version = "$3"
|
|
80
|
+
source = { registry = "https://pypi.org/simple" }
|
|
81
|
+
|
|
82
|
+
[[package]]
|
|
83
|
+
name = "$4"
|
|
84
|
+
version = "$5"
|
|
85
|
+
source = { registry = "https://pypi.org/simple" }
|
|
86
|
+
LOCK
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
_init_repo() {
|
|
90
|
+
# $1 = base package version (what "dev" / origin/<base> holds)
|
|
91
|
+
# $2 = head package version (what the PR branch under test holds)
|
|
92
|
+
# $3 = package name (default "pip")
|
|
93
|
+
base_version=$1
|
|
94
|
+
head_version=$2
|
|
95
|
+
pkg=${3:-pip}
|
|
96
|
+
|
|
97
|
+
rm -rf "$REPO_DIR"
|
|
98
|
+
mkdir -p "$REPO_DIR"
|
|
99
|
+
( cd "$REPO_DIR" \
|
|
100
|
+
&& git init -q -b trunk \
|
|
101
|
+
&& git config user.email test@example.com \
|
|
102
|
+
&& git config user.name "Test" )
|
|
103
|
+
|
|
104
|
+
_write_lock "$REPO_DIR/uv.lock" "$pkg" "$base_version"
|
|
105
|
+
( cd "$REPO_DIR" && git add -A && git commit -q -m base )
|
|
106
|
+
# A literal branch named "origin/<base>" -- git tolerates slashes in
|
|
107
|
+
# branch names, so this resolves via `git show origin/<base>:<path>`
|
|
108
|
+
# exactly like a real remote-tracking ref would, with no remote required.
|
|
109
|
+
( cd "$REPO_DIR" && git branch -q "origin/dev" )
|
|
110
|
+
|
|
111
|
+
if [ "$head_version" != "$base_version" ]; then
|
|
112
|
+
_write_lock "$REPO_DIR/uv.lock" "$pkg" "$head_version"
|
|
113
|
+
( cd "$REPO_DIR" && git add -A && git commit -q -m "pr change" )
|
|
114
|
+
fi
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
# --- pip-audit output staging ---------------------------------------------
|
|
118
|
+
_stage_finding() {
|
|
119
|
+
# $1 = package name, $2 = version pip-audit reports as installed/flagged
|
|
120
|
+
pkg=$1
|
|
121
|
+
version=$2
|
|
122
|
+
cat > "$STUB_DIR/pip-audit-output.json" <<JSON
|
|
123
|
+
{"dependencies": [{"name": "$pkg", "version": "$version", "vulns": [{"id": "PYSEC-2026-3721", "fix_versions": ["26.2.1"]}]}]}
|
|
124
|
+
JSON
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
_stage_clean() {
|
|
128
|
+
cat > "$STUB_DIR/pip-audit-output.json" <<'JSON'
|
|
129
|
+
{"dependencies": [{"name": "pip", "version": "26.2.1", "vulns": []}]}
|
|
130
|
+
JSON
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
# --- runner ----------------------------------------------------------------
|
|
134
|
+
_run() {
|
|
135
|
+
# Runs the target script with GITHUB_BASE_REF set from $1 (empty string
|
|
136
|
+
# means unset -- a push/workflow_dispatch/merge_group context), cwd inside
|
|
137
|
+
# the synthetic repo, uv stubbed, PATH otherwise untouched (jq/git/awk are
|
|
138
|
+
# the real system tools, same as production).
|
|
139
|
+
base_ref=$1
|
|
140
|
+
(
|
|
141
|
+
cd "$REPO_DIR" || exit 97
|
|
142
|
+
PATH="$STUB_DIR:$PATH"
|
|
143
|
+
export PATH
|
|
144
|
+
PIPAUDIT_STUB_OUTPUT="$STUB_DIR/pip-audit-output.json"
|
|
145
|
+
export PIPAUDIT_STUB_OUTPUT
|
|
146
|
+
if [ -n "$base_ref" ]; then
|
|
147
|
+
GITHUB_BASE_REF="$base_ref"
|
|
148
|
+
export GITHUB_BASE_REF
|
|
149
|
+
else
|
|
150
|
+
unset GITHUB_BASE_REF
|
|
151
|
+
fi
|
|
152
|
+
sh "$TARGET"
|
|
153
|
+
) >"$OUT_FILE" 2>&1
|
|
154
|
+
LAST_RC=$?
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
_assert_exit() {
|
|
158
|
+
name=$1
|
|
159
|
+
expected=$2
|
|
160
|
+
if [ "$LAST_RC" -eq "$expected" ]; then
|
|
161
|
+
echo "PASS: $name (exit $LAST_RC)"
|
|
162
|
+
else
|
|
163
|
+
echo "FAIL: $name -- expected exit $expected, got $LAST_RC"
|
|
164
|
+
echo "--- output ---"
|
|
165
|
+
cat "$OUT_FILE"
|
|
166
|
+
echo "--------------"
|
|
167
|
+
FAILURES=$((FAILURES + 1))
|
|
168
|
+
fi
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
_assert_output_contains() {
|
|
172
|
+
name=$1
|
|
173
|
+
needle=$2
|
|
174
|
+
if grep -qF "$needle" "$OUT_FILE"; then
|
|
175
|
+
echo "PASS: $name mentions '$needle'"
|
|
176
|
+
else
|
|
177
|
+
echo "FAIL: $name -- expected output to mention '$needle'"
|
|
178
|
+
echo "--- output ---"
|
|
179
|
+
cat "$OUT_FILE"
|
|
180
|
+
echo "--------------"
|
|
181
|
+
FAILURES=$((FAILURES + 1))
|
|
182
|
+
fi
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
# ============================================================================
|
|
186
|
+
# Case table (must-NOT-block first, then must-block), each run against the
|
|
187
|
+
# real target script -- not a reimplementation of its logic.
|
|
188
|
+
# ============================================================================
|
|
189
|
+
|
|
190
|
+
# 1. PRE-EXISTING, PR context, package UNCHANGED from base (the PYSEC-2026-3721
|
|
191
|
+
# / PR #1669 / #1670 shape: same version on both sides of the diff). Must
|
|
192
|
+
# NOT block, and must say so as pre-existing rather than a flat red.
|
|
193
|
+
_init_repo "26.1.2" "26.1.2" pip
|
|
194
|
+
_stage_finding pip 26.1.2
|
|
195
|
+
_run dev
|
|
196
|
+
_assert_exit "pre-existing, version unchanged by diff" 0
|
|
197
|
+
_assert_output_contains "pre-existing case names it as pre-existing" "pre-existing"
|
|
198
|
+
_assert_output_contains "pre-existing case cites #1673" "#1673"
|
|
199
|
+
|
|
200
|
+
# 2. INTRODUCED via upgrade: base had a clean version, this diff's uv.lock
|
|
201
|
+
# moved it to the flagged version. Must block.
|
|
202
|
+
_init_repo "26.0.0" "26.1.2" pip
|
|
203
|
+
_stage_finding pip 26.1.2
|
|
204
|
+
_run dev
|
|
205
|
+
_assert_exit "introduced by upgrade" 1
|
|
206
|
+
_assert_output_contains "introduced-by-upgrade case names it as introduced" "introduced"
|
|
207
|
+
|
|
208
|
+
# 3. INTRODUCED via new dependency: base's lockfile has no such package at
|
|
209
|
+
# all; this diff added it at an already-vulnerable version. Must block.
|
|
210
|
+
_init_repo "0.0.0" "0.0.0" some-other-package
|
|
211
|
+
_write_lock "$REPO_DIR/uv.lock" "cryptography" "49.0.0"
|
|
212
|
+
( cd "$REPO_DIR" && git add -A && git commit -q -m "add new dependency" )
|
|
213
|
+
_stage_finding cryptography 49.0.0
|
|
214
|
+
_run dev
|
|
215
|
+
_assert_exit "introduced via brand-new dependency" 1
|
|
216
|
+
_assert_output_contains "new-dependency case names it as introduced" "introduced"
|
|
217
|
+
|
|
218
|
+
# 4. Non-PR context (push to the integration branch itself, or
|
|
219
|
+
# workflow_dispatch/merge_group -- GITHUB_BASE_REF unset). No diff exists
|
|
220
|
+
# to attribute the finding to, so the classification must NOT apply: any
|
|
221
|
+
# finding blocks, exactly as before this fix. This is deliberately the
|
|
222
|
+
# SAME lockfile shape as case 1 (unchanged version) to prove the
|
|
223
|
+
# difference in outcome is driven by PR-context alone, matching #1671
|
|
224
|
+
# (dev's own red had to be fixed, not reclassified away).
|
|
225
|
+
_init_repo "26.1.2" "26.1.2" pip
|
|
226
|
+
_stage_finding pip 26.1.2
|
|
227
|
+
_run ""
|
|
228
|
+
_assert_exit "no PR context -- always blocks" 1
|
|
229
|
+
|
|
230
|
+
# 5. PR context, but the base ref cannot be resolved locally (e.g. a shallow
|
|
231
|
+
# checkout, or a distributed copy running where fetch-depth: 0 was not
|
|
232
|
+
# honoured). Comparison fails CLOSED: blocks, same as before this fix,
|
|
233
|
+
# rather than silently waving a real regression through because the
|
|
234
|
+
# comparison itself could not be made.
|
|
235
|
+
_init_repo "26.1.2" "26.1.2" pip
|
|
236
|
+
_stage_finding pip 26.1.2
|
|
237
|
+
_run "some-branch-that-was-never-fetched"
|
|
238
|
+
_assert_exit "base ref unresolvable -- fails closed to blocking" 1
|
|
239
|
+
|
|
240
|
+
# 6. Mixed tree: one pre-existing finding (another-pkg, unchanged from base)
|
|
241
|
+
# and one introduced finding (pip, upgraded by this diff) in the same
|
|
242
|
+
# run. Must still block overall (the introduced one), while the
|
|
243
|
+
# pre-existing one is still named as such rather than folded into an
|
|
244
|
+
# undifferentiated total.
|
|
245
|
+
rm -rf "$REPO_DIR"
|
|
246
|
+
mkdir -p "$REPO_DIR"
|
|
247
|
+
( cd "$REPO_DIR" \
|
|
248
|
+
&& git init -q -b trunk \
|
|
249
|
+
&& git config user.email test@example.com \
|
|
250
|
+
&& git config user.name "Test" )
|
|
251
|
+
_write_lock_two "$REPO_DIR/uv.lock" pip "26.0.0" another-pkg "1.0.0"
|
|
252
|
+
( cd "$REPO_DIR" && git add -A && git commit -q -m base )
|
|
253
|
+
( cd "$REPO_DIR" && git branch -q "origin/dev" )
|
|
254
|
+
# PR upgrades pip only; another-pkg's pin is untouched.
|
|
255
|
+
_write_lock_two "$REPO_DIR/uv.lock" pip "26.1.2" another-pkg "1.0.0"
|
|
256
|
+
( cd "$REPO_DIR" && git add -A && git commit -q -m "pr upgrades pip" )
|
|
257
|
+
cat > "$STUB_DIR/pip-audit-output.json" <<'JSON'
|
|
258
|
+
{"dependencies": [
|
|
259
|
+
{"name": "pip", "version": "26.1.2", "vulns": [{"id": "PYSEC-2026-3721"}]},
|
|
260
|
+
{"name": "another-pkg", "version": "1.0.0", "vulns": [{"id": "PYSEC-0000-0000"}]}
|
|
261
|
+
]}
|
|
262
|
+
JSON
|
|
263
|
+
_run dev
|
|
264
|
+
_assert_exit "mixed tree -- introduced finding still blocks" 1
|
|
265
|
+
_assert_output_contains "mixed tree -- introduced pip is named" "pip@26.1.2"
|
|
266
|
+
_assert_output_contains "mixed tree -- pre-existing another-pkg is named separately" "another-pkg@1.0.0"
|
|
267
|
+
_assert_output_contains "mixed tree -- pre-existing another-pkg says so" "pre-existing"
|
|
268
|
+
|
|
269
|
+
# 7. Sanity: a clean run (no vulnerabilities at all) is unaffected by any of
|
|
270
|
+
# the above -- still exits 0, still audits normally with the new 3-arg
|
|
271
|
+
# audit_deps signature.
|
|
272
|
+
_init_repo "26.2.1" "26.2.1" pip
|
|
273
|
+
_stage_clean
|
|
274
|
+
_run dev
|
|
275
|
+
_assert_exit "clean run unaffected" 0
|
|
276
|
+
|
|
277
|
+
echo
|
|
278
|
+
if [ "$FAILURES" -eq 0 ]; then
|
|
279
|
+
echo "py-dependency-audit-classification.test.sh: all checks passed."
|
|
280
|
+
exit 0
|
|
281
|
+
else
|
|
282
|
+
echo "py-dependency-audit-classification.test.sh: $FAILURES check(s) failed."
|
|
283
|
+
exit 1
|
|
284
|
+
fi
|
|
@@ -62,6 +62,43 @@
|
|
|
62
62
|
# it layout-agnostic, or the copies stop being interchangeable and
|
|
63
63
|
# `shared-sync.sh --check` starts reporting drift that is really divergence.
|
|
64
64
|
#
|
|
65
|
+
# ## Pre-existing vs introduced (#1673)
|
|
66
|
+
#
|
|
67
|
+
# The checks above (network flake vs real finding) say nothing about WHEN the
|
|
68
|
+
# vulnerable version got there. PYSEC-2026-3721 was published against pip
|
|
69
|
+
# 26.1.2 on 2026-08-21 and turned this lane red on PRs #1669/#1670 — neither
|
|
70
|
+
# of which touched uv.lock or any Python dependency; dev's own lockfile had
|
|
71
|
+
# been green on that exact pip version the same morning, before the advisory
|
|
72
|
+
# existed. The timestamp this file already prints proves the *scan* was
|
|
73
|
+
# fresh; it says nothing about whether the *finding* belongs to the diff
|
|
74
|
+
# under test, so a pre-existing advisory on dev and a real regression read
|
|
75
|
+
# as an identical red — a reader cannot tell which one to fix without
|
|
76
|
+
# re-deriving the same by-hand diagnosis #1673 was filed to save.
|
|
77
|
+
#
|
|
78
|
+
# The fix compares the flagged package's version against the SAME tree's
|
|
79
|
+
# lockfile on the PR's base branch, not against the diff: a PR can leave
|
|
80
|
+
# uv.lock untouched while another package's line moves it, or the base
|
|
81
|
+
# branch itself may already have upgraded — checking "did the diff touch
|
|
82
|
+
# uv.lock" would get both wrong. Comparing per-package versions is also what
|
|
83
|
+
# tells a PR that upgrades a DIFFERENT package into a vulnerable version
|
|
84
|
+
# apart from one that never touches the flagged package at all.
|
|
85
|
+
#
|
|
86
|
+
# `GITHUB_BASE_REF` is the base branch name and is set ONLY for a
|
|
87
|
+
# `pull_request`/`pull_request_target` event — empty on `push`,
|
|
88
|
+
# `workflow_dispatch` and `merge_group`. That is deliberate, not a gap:
|
|
89
|
+
# outside a PR there is no "diff" to attribute a finding to, so the
|
|
90
|
+
# classification does not apply and every finding blocks exactly as before
|
|
91
|
+
# — which is the correct behaviour for dev itself (#1671 fixed dev's own red
|
|
92
|
+
# by bumping the lockfile, not by reclassifying it away).
|
|
93
|
+
#
|
|
94
|
+
# `origin/$GITHUB_BASE_REF` must already be a resolvable local ref for the
|
|
95
|
+
# comparison to run at all — the `python` job's checkout uses
|
|
96
|
+
# `fetch-depth: 0` precisely so it is (see that job's own comment). If it is
|
|
97
|
+
# NOT resolvable (a shallow checkout, or a distributed copy of this script
|
|
98
|
+
# running somewhere that checkout is missing), comparison fails CLOSED: every
|
|
99
|
+
# finding blocks, same as before this fix, rather than silently waving a real
|
|
100
|
+
# regression through because the check could not be performed.
|
|
101
|
+
#
|
|
65
102
|
# POSIX sh (the CI step runs `sh scripts/...`, i.e. dash) — no `pipefail`.
|
|
66
103
|
set -u
|
|
67
104
|
|
|
@@ -79,14 +116,80 @@ fi
|
|
|
79
116
|
attempts=3
|
|
80
117
|
inconclusive=0
|
|
81
118
|
failed=0
|
|
119
|
+
preexisting_total=0
|
|
120
|
+
|
|
121
|
+
# Extract the version pinned for package `$1` from a uv.lock's content, read
|
|
122
|
+
# on stdin. uv.lock's TOML shape is a flat list of `[[package]]` tables, each
|
|
123
|
+
# starting with a `name = "..."` line immediately followed by a
|
|
124
|
+
# `version = "..."` line (verified against this repo's own uv.lock) — so a
|
|
125
|
+
# small awk state machine is enough without pulling in a real TOML parser.
|
|
126
|
+
# Prints nothing (empty string) if the package is not in the lockfile at all.
|
|
127
|
+
_lockfile_version_for() {
|
|
128
|
+
pkg="$1"
|
|
129
|
+
awk -v pkg="$pkg" '
|
|
130
|
+
/^\[\[package\]\]/ { name = "" }
|
|
131
|
+
/^name = "/ {
|
|
132
|
+
line = $0
|
|
133
|
+
sub(/^name = "/, "", line)
|
|
134
|
+
sub(/".*$/, "", line)
|
|
135
|
+
name = line
|
|
136
|
+
next
|
|
137
|
+
}
|
|
138
|
+
/^version = "/ {
|
|
139
|
+
if (name == pkg) {
|
|
140
|
+
line = $0
|
|
141
|
+
sub(/^version = "/, "", line)
|
|
142
|
+
sub(/".*$/, "", line)
|
|
143
|
+
print line
|
|
144
|
+
exit
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
'
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
# Classify one (package, version) finding as "introduced" (blocks) or
|
|
151
|
+
# "pre-existing" (does not) by comparing against the SAME tree's uv.lock on
|
|
152
|
+
# the PR's base branch. `$1` name, `$2` the version pip-audit flagged, `$3`
|
|
153
|
+
# the tree's uv.lock path relative to the repo root. Always prints
|
|
154
|
+
# "introduced" — the old, safe behaviour — when a comparison cannot be made
|
|
155
|
+
# at all: no base ref (not a PR), or the ref does not resolve locally.
|
|
156
|
+
_classify_finding() {
|
|
157
|
+
finding_name="$1"
|
|
158
|
+
finding_version="$2"
|
|
159
|
+
finding_lockfile_relpath="$3"
|
|
160
|
+
|
|
161
|
+
if [ "$COMPARE_MODE" -ne 1 ]; then
|
|
162
|
+
printf 'introduced'
|
|
163
|
+
return
|
|
164
|
+
fi
|
|
165
|
+
|
|
166
|
+
base_lock_content="$(git show "${BASE_REMOTE_REF}:${finding_lockfile_relpath}" 2>/dev/null)"
|
|
167
|
+
if [ -z "$base_lock_content" ]; then
|
|
168
|
+
# Tree (or this path within it) did not exist on the base branch at all
|
|
169
|
+
# — cannot be anything other than new, by this diff.
|
|
170
|
+
printf 'introduced'
|
|
171
|
+
return
|
|
172
|
+
fi
|
|
173
|
+
|
|
174
|
+
base_version="$(printf '%s\n' "$base_lock_content" | _lockfile_version_for "$finding_name")"
|
|
175
|
+
if [ -n "$base_version" ] && [ "$base_version" = "$finding_version" ]; then
|
|
176
|
+
printf 'pre-existing'
|
|
177
|
+
else
|
|
178
|
+
printf 'introduced'
|
|
179
|
+
fi
|
|
180
|
+
}
|
|
82
181
|
|
|
83
182
|
# Audit one dependency set. Returns 0 if clean or inconclusive, 1 on a real
|
|
84
|
-
# finding. `$1` is a human label,
|
|
85
|
-
# what to audit — empty for this
|
|
86
|
-
# `-r <file>` for a requirements file
|
|
183
|
+
# finding that this diff introduced or upgraded to. `$1` is a human label,
|
|
184
|
+
# `$2` the extra pip-audit flags that select what to audit — empty for this
|
|
185
|
+
# workspace's installed environment, or `-r <file>` for a requirements file
|
|
186
|
+
# exported from a skeleton lockfile. `$3` is this tree's uv.lock path
|
|
187
|
+
# relative to the repo root, used to look up the SAME package's version on
|
|
188
|
+
# the base branch (#1673) — never empty, every caller passes one.
|
|
87
189
|
audit_deps() {
|
|
88
190
|
label="$1"
|
|
89
191
|
extra="$2"
|
|
192
|
+
lockfile_relpath="$3"
|
|
90
193
|
|
|
91
194
|
for attempt in $(seq 1 "$attempts"); do
|
|
92
195
|
# shellcheck disable=SC2086
|
|
@@ -108,9 +211,36 @@ audit_deps() {
|
|
|
108
211
|
vuln_count="$(printf '%s' "$out" | jq '[.dependencies[].vulns[]?] | length')"
|
|
109
212
|
pkg_count="$(printf '%s' "$out" | jq '.dependencies | length')"
|
|
110
213
|
if [ "$vuln_count" -gt 0 ]; then
|
|
111
|
-
|
|
214
|
+
introduced_count=0
|
|
215
|
+
preexisting_count=0
|
|
216
|
+
findings_file="$(mktemp)"
|
|
217
|
+
printf '%s' "$out" | jq -r '.dependencies[] | select(.vulns | length > 0) | "\(.name)\t\(.version)"' > "$findings_file"
|
|
218
|
+
# POSIX `while read` from a file (not a pipe) so counters set inside
|
|
219
|
+
# the loop survive it — a pipeline would run the loop in a subshell
|
|
220
|
+
# and lose every update the instant it exits.
|
|
221
|
+
while IFS="$(printf '\t')" read -r finding_pkg finding_ver; do
|
|
222
|
+
[ -z "$finding_pkg" ] && continue
|
|
223
|
+
verdict="$(_classify_finding "$finding_pkg" "$finding_ver" "$lockfile_relpath")"
|
|
224
|
+
if [ "$verdict" = "pre-existing" ]; then
|
|
225
|
+
preexisting_count=$((preexisting_count + 1))
|
|
226
|
+
echo "::warning::${label}: ${finding_pkg}@${finding_ver} has an advisory but is unchanged from ${BASE_REMOTE_REF}'s lockfile — pre-existing, not introduced by this diff (#1673)."
|
|
227
|
+
else
|
|
228
|
+
introduced_count=$((introduced_count + 1))
|
|
229
|
+
echo "::error::${label}: ${finding_pkg}@${finding_ver} — new to this tree or upgraded to this version by this diff (or a base-branch comparison was not possible)."
|
|
230
|
+
fi
|
|
231
|
+
done < "$findings_file"
|
|
232
|
+
rm -f "$findings_file"
|
|
233
|
+
|
|
112
234
|
printf '%s' "$out" | jq '[.dependencies[] | select(.vulns | length > 0)]' 2>/dev/null | head -c 4000
|
|
113
|
-
|
|
235
|
+
|
|
236
|
+
if [ "$introduced_count" -gt 0 ]; then
|
|
237
|
+
echo "::error::${label}: ${introduced_count} vulnerability(ies) introduced or upgraded by this diff (${preexisting_count} more pre-existing, not counted against it)."
|
|
238
|
+
return 1
|
|
239
|
+
fi
|
|
240
|
+
|
|
241
|
+
preexisting_total=$((preexisting_total + preexisting_count))
|
|
242
|
+
echo "${label}: ${preexisting_count} vulnerability(ies) found, all pre-existing on ${BASE_REMOTE_REF} and unrelated to this diff — not blocking (#1673)."
|
|
243
|
+
return 0
|
|
114
244
|
fi
|
|
115
245
|
# State the population and the moment, so the green is falsifiable: a
|
|
116
246
|
# pass over 0 packages and a pass over 92 look identical otherwise, and
|
|
@@ -143,6 +273,22 @@ fi
|
|
|
143
273
|
|
|
144
274
|
WORKSPACE_ABS=$(pwd -P)
|
|
145
275
|
|
|
276
|
+
# Decide once, for the whole run, whether a base-branch comparison is even
|
|
277
|
+
# possible (#1673 — see the docstring above `set -u`). `GITHUB_BASE_REF` is
|
|
278
|
+
# only ever set by a `pull_request`/`pull_request_target` event; everywhere
|
|
279
|
+
# else COMPARE_MODE stays 0 and every finding blocks, unchanged from before.
|
|
280
|
+
BASE_REMOTE_REF=""
|
|
281
|
+
COMPARE_MODE=0
|
|
282
|
+
if [ -n "${GITHUB_BASE_REF:-}" ]; then
|
|
283
|
+
candidate="origin/${GITHUB_BASE_REF}"
|
|
284
|
+
if git rev-parse --verify --quiet "${candidate}^{commit}" >/dev/null 2>&1; then
|
|
285
|
+
BASE_REMOTE_REF="$candidate"
|
|
286
|
+
COMPARE_MODE=1
|
|
287
|
+
else
|
|
288
|
+
echo "::warning::py-dependency-audit: base branch is '${GITHUB_BASE_REF}' but '${candidate}' does not resolve locally (shallow checkout?) — cannot tell a pre-existing finding from one this diff introduced, so every finding will block, same as before #1673."
|
|
289
|
+
fi
|
|
290
|
+
fi
|
|
291
|
+
|
|
146
292
|
# shellcheck disable=SC2016
|
|
147
293
|
ALL_LOCKS=$(find "$REPO_ROOT" \
|
|
148
294
|
\( -name node_modules -o -name .git -o -name .worktrees -o -name .venv \) -prune -o \
|
|
@@ -195,9 +341,13 @@ for lock in $ALL_LOCKS; do
|
|
|
195
341
|
"$REPO_ROOT"/*) rel=${dir_abs#"$REPO_ROOT"/} ;;
|
|
196
342
|
*) rel="$dir_abs" ;;
|
|
197
343
|
esac
|
|
344
|
+
# `find` was rooted at $REPO_ROOT, so $lock is always an absolute path
|
|
345
|
+
# under it — this strip is unconditional, unlike $rel's dir_abs case
|
|
346
|
+
# above (which also has to tolerate a symlink resolving outside the root).
|
|
347
|
+
lock_rel=${lock#"$REPO_ROOT"/}
|
|
198
348
|
|
|
199
349
|
if [ "$dir_abs" = "$WORKSPACE_ABS" ]; then
|
|
200
|
-
audit_deps "pip-audit (workspace: ${rel})" "" || failed=1
|
|
350
|
+
audit_deps "pip-audit (workspace: ${rel})" "" "$lock_rel" || failed=1
|
|
201
351
|
continue
|
|
202
352
|
fi
|
|
203
353
|
|
|
@@ -231,7 +381,7 @@ for lock in $ALL_LOCKS; do
|
|
|
231
381
|
if [ "${local_deps:-0}" -gt 0 ]; then
|
|
232
382
|
echo " (${rel}: ${local_deps} local path dependenc$([ "$local_deps" -eq 1 ] && echo y || echo ies) excluded -- first-party, audited in their own repo; their transitive dependencies are still scanned)"
|
|
233
383
|
fi
|
|
234
|
-
audit_deps "pip-audit (${rel})" "-r $reqs" || failed=1
|
|
384
|
+
audit_deps "pip-audit (${rel})" "-r $reqs" "$lock_rel" || failed=1
|
|
235
385
|
else
|
|
236
386
|
# An export failure is "couldn't run", not "clean" — same discipline as a
|
|
237
387
|
# transient pip-audit error, and it must never read as a pass.
|
|
@@ -255,5 +405,9 @@ if [ "$inconclusive" -ne 0 ]; then
|
|
|
255
405
|
exit 2
|
|
256
406
|
fi
|
|
257
407
|
|
|
258
|
-
|
|
408
|
+
if [ "$preexisting_total" -gt 0 ]; then
|
|
409
|
+
echo "py-dependency-audit: audited ${tree_count} tree(s), 0 blocking findings (${preexisting_total} pre-existing on ${BASE_REMOTE_REF}, unrelated to this diff — see #1673)."
|
|
410
|
+
else
|
|
411
|
+
echo "py-dependency-audit: audited ${tree_count} tree(s), 0 blocking findings."
|
|
412
|
+
fi
|
|
259
413
|
exit 0
|