@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.
@@ -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, `$2` the extra pip-audit flags that select
85
- # what to audit — empty for this workspace's installed environment, or
86
- # `-r <file>` for a requirements file exported from a skeleton lockfile.
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
- echo "::error::${label}: ${vuln_count} vulnerability(ies)."
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
- return 1
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
- echo "py-dependency-audit: audited ${tree_count} tree(s), 0 blocking findings."
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, `$2` the extra pip-audit flags that select
85
- # what to audit — empty for this workspace's installed environment, or
86
- # `-r <file>` for a requirements file exported from a skeleton lockfile.
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
- echo "::error::${label}: ${vuln_count} vulnerability(ies)."
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
- return 1
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
- echo "py-dependency-audit: audited ${tree_count} tree(s), 0 blocking findings."
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@biffo/cli",
3
- "version": "0.298.41",
3
+ "version": "0.298.42",
4
4
  "description": "Biffo project scaffolding CLI",
5
5
  "license": "MIT",
6
6
  "type": "module",