@biffo/cli 0.315.7 → 0.315.8

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,304 @@
1
+ #!/usr/bin/env sh
2
+ #
3
+ # Proves js-dependency-audit.sh (#2040) 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
+ # This is the JS side of the fix py-dependency-audit.sh already got in
8
+ # #1673; see that file's own classification test for the Python case table
9
+ # this one mirrors.
10
+ #
11
+ # The real incident this guards against: two routine npm advisories
12
+ # (GHSA-p293-qw3h-jr36 / Next.js, GHSA-82fw-gwwq-j7x9 / vitest) were
13
+ # published against packages already on `dev` on 2026-09-08/09, with no code
14
+ # change anywhere -- and turned the JS lane red on every open PR at once,
15
+ # producing 89 fleet dispatches across 61 units in 8 repos and 10 duplicate
16
+ # tickets for one classifier bug (#2040's own cost accounting). Case 1 below
17
+ # reconstructs exactly that shape: base and PR both pinned at the flagged
18
+ # version, PR diff untouched.
19
+ #
20
+ # ## Real `pnpm audit --json` shape, captured live
21
+ #
22
+ # Run against this repo's own workspace (pnpm 9.15.9, 2026-09-10):
23
+ #
24
+ # $ pnpm audit --json
25
+ # {
26
+ # "advisories": {
27
+ # "1090893": {
28
+ # "severity": "low",
29
+ # "module_name": "cli",
30
+ # "github_advisory_id": "GHSA-6cpc-mj5c-m9rq",
31
+ # "findings": [{"version": "0.0.0", "paths": []}],
32
+ # ...
33
+ # }
34
+ # },
35
+ # "metadata": {
36
+ # "vulnerabilities": {"info": 0, "low": 1, "moderate": 0, "high": 0, "critical": 0},
37
+ # "totalDependencies": 929
38
+ # }
39
+ # }
40
+ #
41
+ # `.advisories` is an OBJECT keyed by an internal numeric id, not an array --
42
+ # `.advisories[]?` iterates its values the same as an array would. Each
43
+ # value carries `.severity`, `.module_name` and `.github_advisory_id`, which
44
+ # is exactly what js-dependency-audit.sh's classification reads. The fixture
45
+ # JSON below is shaped to match this real structure, not invented -- the
46
+ # OLD version of js-dependency-audit-parallel.test.sh's stub JSON carried
47
+ # only `.metadata.vulnerabilities` with no `.advisories` key at all, which
48
+ # classification silently read as zero findings (fixed alongside this file;
49
+ # see that test's own updated `_fail_json` comment).
50
+ #
51
+ # Also proves `pnpm audit` runs against a BARE lockfile copy with no install
52
+ # and no workspace context -- verified directly, not assumed:
53
+ #
54
+ # $ mkdir /tmp/probe && cp pnpm-lock.yaml /tmp/probe/ && cd /tmp/probe
55
+ # $ pnpm audit --json --ignore-workspace # exits 0, real advisory output
56
+ #
57
+ # which is why the base-branch side of this comparison is a second real
58
+ # `pnpm audit` call against a `git show`-extracted copy of the lockfile,
59
+ # not a hand-rolled pnpm-lock.yaml parser.
60
+ #
61
+ # Builds a real, tiny, throwaway git repo under mktemp (never under /tmp as
62
+ # a full worktree copy -- this is a from-scratch repo with a handful of
63
+ # files, not a copy of this repository's own object store -- see AGENTS.md's
64
+ # "never create a git worktree ... under /tmp", which this is not) with a
65
+ # base branch and a PR-head state, and stubs `pnpm` on PATH so `pnpm audit
66
+ # --json` returns a canned finding selected by a marker comment inside
67
+ # whichever pnpm-lock.yaml the stub is invoked against -- no network, no
68
+ # real registry. `jq`, `git` and `grep` are used for real -- they are
69
+ # exactly what the target script itself depends on, so stubbing them would
70
+ # test nothing.
71
+ #
72
+ # Run: sh scripts/js-dependency-audit-classification.test.sh
73
+
74
+ set -u
75
+
76
+ SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
77
+ TARGET="$SCRIPT_DIR/js-dependency-audit.sh"
78
+
79
+ REPO_DIR=$(mktemp -d)
80
+ STUB_DIR=$(mktemp -d)
81
+ TMPROOT_DIR=$(mktemp -d)
82
+ OUT_FILE=$(mktemp)
83
+ trap 'rm -rf "$REPO_DIR" "$STUB_DIR" "$TMPROOT_DIR"; rm -f "$OUT_FILE"' EXIT
84
+
85
+ FAILURES=0
86
+
87
+ # --- pnpm stub ---------------------------------------------------------------
88
+ # Selected by a `# STATE:<tag>` marker comment inside whatever
89
+ # pnpm-lock.yaml is in the CURRENT DIRECTORY when invoked -- the target
90
+ # script always `cd`s into the tree it is auditing (the repo's own working
91
+ # tree for the HEAD run, or a scratch dir holding a `git show`-extracted
92
+ # copy for the BASE run), so the marker travels with whichever commit's
93
+ # content is actually being read, exactly like a real lockfile diff would.
94
+ cat > "$STUB_DIR/pnpm" <<'STUB'
95
+ #!/usr/bin/env sh
96
+ if [ "$1" = "audit" ]; then
97
+ state=$(grep -o 'STATE:[A-Za-z0-9_-]*' pnpm-lock.yaml 2>/dev/null | head -1 | cut -d: -f2)
98
+ out_file="$STUB_DIR_ENV/output-${state:-clean}.json"
99
+ if [ -f "$out_file" ]; then
100
+ cat "$out_file"
101
+ else
102
+ cat "$STUB_DIR_ENV/output-clean.json"
103
+ fi
104
+ exit 0
105
+ fi
106
+ echo "pnpm stub: unexpected invocation: $*" >&2
107
+ exit 99
108
+ STUB
109
+ chmod +x "$STUB_DIR/pnpm"
110
+
111
+ # --- fixture JSON, one per STATE tag ----------------------------------------
112
+ cat > "$STUB_DIR/output-clean.json" <<'JSON'
113
+ {"metadata":{"vulnerabilities":{"critical":0,"high":0,"moderate":0,"low":0},"totalDependencies":5}}
114
+ JSON
115
+
116
+ # One high-severity advisory against vuln-pkg-a.
117
+ cat > "$STUB_DIR/output-vuln-a.json" <<'JSON'
118
+ {"metadata":{"vulnerabilities":{"critical":0,"high":1,"moderate":0,"low":0},"totalDependencies":5},"advisories":{"1":{"severity":"high","github_advisory_id":"GHSA-test-0001","module_name":"vuln-pkg-a","findings":[{"version":"1.2.3","paths":[]}]}}}
119
+ JSON
120
+
121
+ # Only vuln-pkg-b's advisory (used as the mixed case's BASE state -- pkg-a
122
+ # is absent entirely, pkg-b is present and already flagged).
123
+ cat > "$STUB_DIR/output-vuln-b-only.json" <<'JSON'
124
+ {"metadata":{"vulnerabilities":{"critical":0,"high":1,"moderate":0,"low":0},"totalDependencies":5},"advisories":{"2":{"severity":"high","github_advisory_id":"GHSA-test-0002","module_name":"vuln-pkg-b","findings":[{"version":"2.0.0","paths":[]}]}}}
125
+ JSON
126
+
127
+ # Both advisories together (the mixed case's HEAD state).
128
+ cat > "$STUB_DIR/output-vuln-a-b.json" <<'JSON'
129
+ {"metadata":{"vulnerabilities":{"critical":0,"high":2,"moderate":0,"low":0},"totalDependencies":5},"advisories":{"1":{"severity":"high","github_advisory_id":"GHSA-test-0001","module_name":"vuln-pkg-a","findings":[{"version":"1.2.3","paths":[]}]},"2":{"severity":"high","github_advisory_id":"GHSA-test-0002","module_name":"vuln-pkg-b","findings":[{"version":"2.0.0","paths":[]}]}}}
130
+ JSON
131
+
132
+ # --- repo scaffolding --------------------------------------------------------
133
+ # One workspace-level pnpm-lock.yaml so discovery finds exactly one tree,
134
+ # matching the real incident (the workspace root, not a vendored tree).
135
+ _write_lock() {
136
+ # $1 = destination file, $2 = STATE tag
137
+ cat > "$1" <<LOCK
138
+ lockfileVersion: '9.0'
139
+ # STATE:$2
140
+ LOCK
141
+ }
142
+
143
+ _init_repo() {
144
+ # $1 = base STATE tag (what "dev" / origin/<base> holds)
145
+ # $2 = head STATE tag (what the PR branch under test holds)
146
+ base_state=$1
147
+ head_state=$2
148
+
149
+ rm -rf "$REPO_DIR"
150
+ mkdir -p "$REPO_DIR"
151
+ ( cd "$REPO_DIR" \
152
+ && git init -q -b trunk \
153
+ && git config user.email test@example.com \
154
+ && git config user.name "Test" )
155
+
156
+ _write_lock "$REPO_DIR/pnpm-lock.yaml" "$base_state"
157
+ ( cd "$REPO_DIR" && git add -A && git commit -q -m base )
158
+ # A literal branch named "origin/<base>" -- git tolerates slashes in
159
+ # branch names, so this resolves via `git show origin/<base>:<path>`
160
+ # exactly like a real remote-tracking ref would, with no remote required.
161
+ ( cd "$REPO_DIR" && git branch -q "origin/dev" )
162
+
163
+ if [ "$head_state" != "$base_state" ]; then
164
+ _write_lock "$REPO_DIR/pnpm-lock.yaml" "$head_state"
165
+ ( cd "$REPO_DIR" && git add -A && git commit -q -m "pr change" )
166
+ fi
167
+ }
168
+
169
+ # --- runner ------------------------------------------------------------------
170
+ _run() {
171
+ # Runs the target script with GITHUB_BASE_REF set from $1 (empty string
172
+ # means unset -- a push/workflow_dispatch/merge_group context), cwd inside
173
+ # the synthetic repo, pnpm stubbed, PATH otherwise untouched (jq/git/grep
174
+ # are the real system tools, same as production).
175
+ base_ref=$1
176
+ (
177
+ cd "$REPO_DIR" || exit 97
178
+ PATH="$STUB_DIR:$PATH"
179
+ STUB_DIR_ENV="$STUB_DIR"
180
+ TMPDIR="$TMPROOT_DIR"
181
+ export PATH STUB_DIR_ENV TMPDIR
182
+ if [ -n "$base_ref" ]; then
183
+ GITHUB_BASE_REF="$base_ref"
184
+ export GITHUB_BASE_REF
185
+ else
186
+ unset GITHUB_BASE_REF
187
+ fi
188
+ sh "$TARGET"
189
+ ) >"$OUT_FILE" 2>&1
190
+ LAST_RC=$?
191
+ }
192
+
193
+ _assert_exit() {
194
+ name=$1
195
+ expected=$2
196
+ if [ "$LAST_RC" -eq "$expected" ]; then
197
+ echo "PASS: $name (exit $LAST_RC)"
198
+ else
199
+ echo "FAIL: $name -- expected exit $expected, got $LAST_RC"
200
+ echo "--- output ---"
201
+ cat "$OUT_FILE"
202
+ echo "--------------"
203
+ FAILURES=$((FAILURES + 1))
204
+ fi
205
+ }
206
+
207
+ _assert_output_contains() {
208
+ name=$1
209
+ needle=$2
210
+ if grep -qF "$needle" "$OUT_FILE"; then
211
+ echo "PASS: $name mentions '$needle'"
212
+ else
213
+ echo "FAIL: $name -- expected output to mention '$needle'"
214
+ echo "--- output ---"
215
+ cat "$OUT_FILE"
216
+ echo "--------------"
217
+ FAILURES=$((FAILURES + 1))
218
+ fi
219
+ }
220
+
221
+ # ==============================================================================
222
+ # Case table (must-NOT-block first, then must-block), each run against the
223
+ # real target script -- not a reimplementation of its logic.
224
+ # ==============================================================================
225
+
226
+ # 1. PRE-EXISTING, PR context, advisory UNCHANGED from base (the
227
+ # GHSA-p293-qw3h-jr36 / 2026-09-08 shape: same version on both sides of
228
+ # the diff). Must NOT block, and must say so as pre-existing rather than
229
+ # a flat red.
230
+ _init_repo "vuln-a" "vuln-a"
231
+ _run dev
232
+ _assert_exit "pre-existing, advisory unchanged by diff" 0
233
+ _assert_output_contains "pre-existing case names it as pre-existing" "pre-existing"
234
+ _assert_output_contains "pre-existing case cites #2040" "#2040"
235
+
236
+ # 2. INTRODUCED via upgrade: base had a clean lockfile, this diff's
237
+ # pnpm-lock.yaml moved it to the flagged version. Must block.
238
+ _init_repo "clean" "vuln-a"
239
+ _run dev
240
+ _assert_exit "introduced by upgrade" 1
241
+ _assert_output_contains "introduced-by-upgrade case names it as introduced" "introduced or upgraded by this diff"
242
+
243
+ # 3. INTRODUCED via new dependency: base's lockfile carries no such package
244
+ # at all; this diff added it at an already-vulnerable version. For the
245
+ # advisory-ID-diffing design this is mechanically identical to case 2
246
+ # (base's audit reports no matching id either way) -- kept as its own
247
+ # case for documentation, matching py-dependency-audit-classification's
248
+ # own case 3, which IS mechanically distinct there because Python
249
+ # classifies per-package lockfile version rather than by diffing two
250
+ # full audit runs.
251
+ _init_repo "clean" "vuln-a"
252
+ _run dev
253
+ _assert_exit "introduced via brand-new dependency" 1
254
+ _assert_output_contains "new-dependency case names it as introduced" "introduced or upgraded by this diff"
255
+
256
+ # 4. Non-PR context (push to the integration branch itself, or
257
+ # workflow_dispatch/merge_group -- GITHUB_BASE_REF unset). No diff exists
258
+ # to attribute the finding to, so the classification must NOT apply: any
259
+ # finding blocks, exactly as before this fix. Deliberately the SAME
260
+ # lockfile shape as case 1 (unchanged advisory) to prove the difference
261
+ # in outcome is driven by PR-context alone -- the scheduled base-branch
262
+ # scan added alongside this file (#2040) relies on exactly this: every
263
+ # current finding on `dev` must be reported, not just new ones.
264
+ _init_repo "vuln-a" "vuln-a"
265
+ _run ""
266
+ _assert_exit "no PR context -- always blocks" 1
267
+
268
+ # 5. PR context, but the base ref cannot be resolved locally (e.g. a shallow
269
+ # checkout, or a distributed copy running where fetch-depth: 0 was not
270
+ # honoured). Comparison fails CLOSED: blocks, same as before this fix,
271
+ # rather than silently waving a real regression through because the
272
+ # comparison itself could not be made.
273
+ _init_repo "vuln-a" "vuln-a"
274
+ _run "some-branch-that-was-never-fetched"
275
+ _assert_exit "base ref unresolvable -- fails closed to blocking" 1
276
+ _assert_output_contains "base ref unresolvable -- warns why" "does not resolve locally"
277
+
278
+ # 6. Mixed tree: one pre-existing finding (vuln-pkg-b, unchanged from base)
279
+ # and one introduced finding (vuln-pkg-a, new to this diff) in the same
280
+ # run. Must still block overall (the introduced one), while the
281
+ # pre-existing one is still named as such rather than folded into an
282
+ # undifferentiated total.
283
+ _init_repo "vuln-b-only" "vuln-a-b"
284
+ _run dev
285
+ _assert_exit "mixed tree -- introduced finding still blocks" 1
286
+ _assert_output_contains "mixed tree -- introduced vuln-pkg-a is named" "vuln-pkg-a advisory GHSA-test-0001"
287
+ _assert_output_contains "mixed tree -- pre-existing vuln-pkg-b is named separately" "vuln-pkg-b advisory GHSA-test-0002"
288
+ _assert_output_contains "mixed tree -- pre-existing vuln-pkg-b says so" "pre-existing"
289
+
290
+ # 7. Sanity: a clean run (no vulnerabilities at all) is unaffected by any of
291
+ # the above -- still exits 0, still audits normally with the new
292
+ # 5-arg audit_dir signature.
293
+ _init_repo "clean" "clean"
294
+ _run dev
295
+ _assert_exit "clean run unaffected" 0
296
+
297
+ echo
298
+ if [ "$FAILURES" -eq 0 ]; then
299
+ echo "js-dependency-audit-classification.test.sh: all checks passed."
300
+ exit 0
301
+ else
302
+ echo "js-dependency-audit-classification.test.sh: $FAILURES check(s) failed."
303
+ exit 1
304
+ fi
@@ -62,6 +62,50 @@
62
62
  # or the copies stop being interchangeable and `shared-sync.sh --check` starts
63
63
  # reporting drift that is really divergence.
64
64
  #
65
+ # ## Pre-existing vs introduced (#2040)
66
+ #
67
+ # Every check above answers "can this audit be trusted" (network flake vs a
68
+ # real finding). None of them answer WHEN the vulnerable version got there —
69
+ # so a genuine high/critical advisory published overnight against a package
70
+ # already sitting on `dev`, untouched by this diff, used to read identically
71
+ # to a PR that actually introduced the vulnerable version. #1880 (2026-09-04)
72
+ # tried to paper over exactly that by moving this whole check to a
73
+ # `continue-on-error`, non-required job — which stopped a real per-PR finding
74
+ # from blocking too, and produced ten separate biffo-fleet tickets and $86.72
75
+ # of dispatch spend for two routine advisories that no PR under test could
76
+ # have fixed (#2040's own cost accounting). `py-dependency-audit.sh` closed
77
+ # this same gap for Python in #1673; this is the JS side of the same fix.
78
+ #
79
+ # The fix compares the ADVISORY IDS found against the SAME tree's pnpm audit
80
+ # run against the PR's BASE branch, not against the diff itself: a PR can
81
+ # leave pnpm-lock.yaml untouched while a sibling change moves a transitive
82
+ # version, or the base branch may already carry the flagged version. An
83
+ # advisory ID present in both base and head is pre-existing and does not
84
+ # block; an advisory ID present only in head is introduced by this diff (a
85
+ # new dependency, or an upgrade/downgrade into a vulnerable version) and
86
+ # blocks exactly as before. This mirrors the issue's own "run against base
87
+ # and head, diff the advisory IDs, fail on head-only" design rather than
88
+ # hand-parsing pnpm-lock.yaml's YAML — `pnpm audit` already works against a
89
+ # bare copy of just the lockfile with no install (verified directly against
90
+ # this repo's own pnpm 9.15.9), so the base side is a second real audit
91
+ # call, not a reimplementation of what `pnpm audit` already does.
92
+ #
93
+ # `GITHUB_BASE_REF` is the base branch name and is set ONLY for a
94
+ # `pull_request`/`pull_request_target` event — empty on `push`,
95
+ # `workflow_dispatch` and `merge_group`. That is deliberate, not a gap:
96
+ # outside a PR there is no "diff" to attribute a finding to, so every finding
97
+ # blocks exactly as before (the correct behaviour for `dev` itself, and for
98
+ # the scheduled base-branch scan added alongside this file in #2040, which
99
+ # wants every current finding reported, not just new ones).
100
+ #
101
+ # `origin/$GITHUB_BASE_REF` must already be a resolvable local ref for the
102
+ # comparison to run at all — the `js` job's checkout uses `fetch-depth: 0`
103
+ # precisely so it is (see that job's own comment). If it is NOT resolvable
104
+ # (a shallow checkout, or a distributed copy of this script running
105
+ # somewhere that checkout is missing), comparison fails CLOSED: every
106
+ # finding blocks, same as before this fix, rather than silently waving a
107
+ # real regression through because the check could not be performed.
108
+ #
65
109
  # POSIX sh (the CI step runs `sh scripts/...`, i.e. dash) — no `pipefail`.
66
110
  set -u
67
111
 
@@ -118,12 +162,60 @@ failed=0
118
162
  # invocation has finished.
119
163
  #
120
164
  # `$1` is the directory, `$2` a human label, `$3` extra pnpm flags, `$4` the
121
- # result file this invocation must write its verdict to.
165
+ # result file this invocation must write its verdict to, `$5` this tree's
166
+ # pnpm-lock.yaml path relative to the repo root — used to look up the SAME
167
+ # lockfile's advisories on the base branch (#2040) — never empty, every
168
+ # caller passes one.
169
+ #
170
+ # Returns (on stdout) the set of advisory IDs recorded against the SAME
171
+ # lockfile path on the PR's base branch, one per line — any severity, not
172
+ # just high/critical, because presence is all classification needs. Prints
173
+ # nothing and returns non-zero when a comparison genuinely cannot be made
174
+ # (network/parse failure, a temp-dir failure) — the caller then treats every
175
+ # finding as introduced, the same fail-closed default #1673 established for
176
+ # Python. An empty base lockfile (the path did not exist on the base branch
177
+ # at all — a brand-new lockfile or a brand-new vendored tree this diff
178
+ # itself introduced) is NOT a failure: it prints nothing and returns 0,
179
+ # because an empty set is the correct answer — there is nothing to be
180
+ # pre-existing against, so every finding in a tree the base never had is
181
+ # introduced by definition.
182
+ _base_advisory_ids() {
183
+ lock_rel="$1"
184
+
185
+ base_content="$(git show "${BASE_REMOTE_REF}:${lock_rel}" 2>/dev/null)"
186
+ if [ -z "$base_content" ]; then
187
+ return 0
188
+ fi
189
+
190
+ workdir=$(mktemp -d "${TMPDIR:-/tmp}/js-dependency-audit-base.XXXXXX") || return 1
191
+ printf '%s' "$base_content" >"$workdir/pnpm-lock.yaml"
192
+
193
+ for attempt in $(seq 1 "$attempts"); do
194
+ # shellcheck disable=SC2086
195
+ base_out="$(cd "$workdir" && timeout "$AUDIT_TIMEOUT_SECS" pnpm audit --json --ignore-workspace 2>/dev/null)"
196
+ base_status=$?
197
+ if [ "$base_status" -eq 124 ]; then
198
+ [ "$attempt" -lt "$attempts" ] && sleep "$((attempt * 2))"
199
+ continue
200
+ fi
201
+ if printf '%s' "$base_out" | jq -e '.metadata.vulnerabilities' >/dev/null 2>&1; then
202
+ printf '%s' "$base_out" | jq -r '.advisories[]? | (.github_advisory_id // (.id|tostring))' 2>/dev/null
203
+ rm -rf "$workdir"
204
+ return 0
205
+ fi
206
+ [ "$attempt" -lt "$attempts" ] && sleep "$((attempt * 2))"
207
+ done
208
+
209
+ rm -rf "$workdir"
210
+ return 1
211
+ }
212
+
122
213
  audit_dir() {
123
214
  dir="$1"
124
215
  label="$2"
125
216
  extra="$3"
126
217
  resultfile="$4"
218
+ lock_rel="$5"
127
219
 
128
220
  for attempt in $(seq 1 "$attempts"); do
129
221
  # printf, never echo: the CI step runs `sh scripts/...` i.e. dash, whose
@@ -172,10 +264,54 @@ audit_dir() {
172
264
  low="$(printf '%s' "$out" | jq '.metadata.vulnerabilities.low // 0')"
173
265
  total="$(printf '%s' "$out" | jq '.metadata.totalDependencies // 0')"
174
266
  if [ "$((high + crit))" -gt 0 ]; then
175
- echo "::error::${label}: ${crit} critical + ${high} high advisory(ies) across ${total} package(s); registry answered ${seen_at}."
267
+ # Classify each qualifying (high/critical) advisory against the base
268
+ # branch's SAME lockfile before deciding to block (#2040). Written to
269
+ # a regular file, not a pipe, and read with `while read ... done <
270
+ # file` rather than `| while read`, for the same reason
271
+ # py-dependency-audit.sh's findings loop does: a pipeline runs the
272
+ # loop in a subshell, and a subshell's counter updates vanish the
273
+ # instant it exits.
274
+ findings_file="$(mktemp)"
275
+ printf '%s' "$out" | jq -r '.advisories[]? | select(.severity=="high" or .severity=="critical") | "\(.github_advisory_id // (.id|tostring))\t\(.severity)\t\(.module_name)"' >"$findings_file"
276
+
277
+ base_ids_file=""
278
+ base_available=0
279
+ if [ "$COMPARE_MODE" -eq 1 ]; then
280
+ candidate_ids="$(mktemp)"
281
+ if _base_advisory_ids "$lock_rel" >"$candidate_ids" 2>/dev/null; then
282
+ base_ids_file="$candidate_ids"
283
+ base_available=1
284
+ else
285
+ rm -f "$candidate_ids"
286
+ fi
287
+ fi
288
+
289
+ introduced_count=0
290
+ preexisting_count=0
291
+ while IFS="$(printf '\t')" read -r f_id f_sev f_mod; do
292
+ [ -z "$f_id" ] && continue
293
+ if [ "$base_available" -eq 1 ] && grep -qxF "$f_id" "$base_ids_file" 2>/dev/null; then
294
+ preexisting_count=$((preexisting_count + 1))
295
+ echo "::warning::${label}: ${f_mod} advisory ${f_id} (${f_sev}) is already present in ${BASE_REMOTE_REF}'s lockfile — pre-existing, not introduced by this diff (#2040)."
296
+ else
297
+ introduced_count=$((introduced_count + 1))
298
+ echo "::error::${label}: ${f_mod} advisory ${f_id} (${f_sev}) — new to this tree, or a version this diff introduced/upgraded (or a base-branch comparison was not possible)."
299
+ fi
300
+ done <"$findings_file"
301
+ rm -f "$findings_file"
302
+ [ -n "$base_ids_file" ] && rm -f "$base_ids_file"
303
+
176
304
  printf '%s' "$out" | jq '.advisories // .metadata.vulnerabilities' 2>/dev/null | head -c 4000
177
- echo "fail" >"$resultfile"
178
- return 1
305
+
306
+ if [ "$introduced_count" -gt 0 ]; then
307
+ echo "::error::${label}: ${introduced_count} critical/high advisory(ies) introduced or upgraded by this diff across ${total} package(s) (${preexisting_count} more pre-existing, not counted against it); registry answered ${seen_at}."
308
+ echo "fail" >"$resultfile"
309
+ return 1
310
+ fi
311
+
312
+ echo "${label}: ${preexisting_count} critical/high advisory(ies) found, all pre-existing on ${BASE_REMOTE_REF:-the base branch} and unrelated to this diff — not blocking (#2040); registry answered ${seen_at}."
313
+ echo "ok" >"$resultfile"
314
+ return 0
179
315
  fi
180
316
  # A bare "no advisories" is not falsifiable. State the population, the
181
317
  # severities that did NOT block, and when the registry was asked, so a
@@ -210,6 +346,23 @@ fi
210
346
 
211
347
  WORKSPACE_ABS=$(pwd -P)
212
348
 
349
+ # Decide once, for the whole run, whether a base-branch comparison is even
350
+ # possible (#2040 — see the "Pre-existing vs introduced" docstring above
351
+ # `set -u`). `GITHUB_BASE_REF` is only ever set by a
352
+ # `pull_request`/`pull_request_target` event; everywhere else COMPARE_MODE
353
+ # stays 0 and every finding blocks, unchanged from before this fix.
354
+ BASE_REMOTE_REF=""
355
+ COMPARE_MODE=0
356
+ if [ -n "${GITHUB_BASE_REF:-}" ]; then
357
+ candidate="origin/${GITHUB_BASE_REF}"
358
+ if git rev-parse --verify --quiet "${candidate}^{commit}" >/dev/null 2>&1; then
359
+ BASE_REMOTE_REF="$candidate"
360
+ COMPARE_MODE=1
361
+ else
362
+ echo "::warning::js-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 #2040."
363
+ fi
364
+ fi
365
+
213
366
  # shellcheck disable=SC2016
214
367
  ALL_LOCKS=$(find "$REPO_ROOT" \
215
368
  \( -name node_modules -o -name .git -o -name .worktrees \) -prune -o \
@@ -286,10 +439,14 @@ for lock in $ALL_LOCKS; do
286
439
  esac
287
440
  i=$((i + 1))
288
441
  resultfile="$TMP_DIR/result.$i"
442
+ # `find` was rooted at $REPO_ROOT, so $lock is always an absolute path
443
+ # under it — this strip is unconditional, unlike $rel's dir_abs case above
444
+ # (which also has to tolerate a symlink resolving outside the root).
445
+ lock_rel=${lock#"$REPO_ROOT"/}
289
446
  if [ "$dir_abs" = "$WORKSPACE_ABS" ]; then
290
- audit_dir "$dir" "pnpm audit (workspace: ${rel})" "" "$resultfile" &
447
+ audit_dir "$dir" "pnpm audit (workspace: ${rel})" "" "$resultfile" "$lock_rel" &
291
448
  else
292
- audit_dir "$dir" "pnpm audit (${rel})" "--ignore-workspace" "$resultfile" &
449
+ audit_dir "$dir" "pnpm audit (${rel})" "--ignore-workspace" "$resultfile" "$lock_rel" &
293
450
  fi
294
451
  done
295
452
 
@@ -53,7 +53,14 @@ jobs:
53
53
  run:
54
54
  working-directory: apps/frontend
55
55
  steps:
56
+ # `fetch-depth: 0` (biffo-template#2040) — the Dependency audit step
57
+ # below needs `origin/${GITHUB_BASE_REF}` to resolve locally for its
58
+ # pre-existing-vs-introduced comparison (biffo-template#1673's Python
59
+ # design, now shared by the JS script too), and the default shallow
60
+ # checkout does not carry it.
56
61
  - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
62
+ with:
63
+ fetch-depth: 0
57
64
  - uses: pnpm/action-setup@v4
58
65
  with:
59
66
  version: 9.15.9
@@ -74,6 +81,27 @@ jobs:
74
81
  - name: Test
75
82
  if: ${{ !cancelled() }}
76
83
  run: pnpm run test
84
+ # Runs IN this required job again, rather than in a separate
85
+ # `continue-on-error` job, as of biffo-template#2040 — reverting the
86
+ # biffo-template#1880 split (see that repo's root `ci.yml` `js` job
87
+ # for the full reasoning this mirrors). #1880 moved this step out
88
+ # because a registry-side INCONCLUSIVE result could block real,
89
+ # unrelated PRs; that risk is unchanged and still accepted here. What
90
+ # changed: js-dependency-audit.sh now classifies findings as
91
+ # "pre-existing on the base branch" vs "introduced by this diff", so
92
+ # the routine case #1880 was reacting to — an advisory published
93
+ # against something already on `dev`, unrelated to any PR's diff — no
94
+ # longer blocks, and only a finding this diff actually introduced or
95
+ # upgraded into does.
96
+ #
97
+ # Path is relative: this job's working-directory is apps/frontend,
98
+ # which is exactly the tree to audit, and the script audits its own
99
+ # cwd — it is byte-identical to biffo-template's copy and to every
100
+ # other satellite's, distributed by that repo's shared-sync.sh, so it
101
+ # cannot assume a layout. Do not edit it here; edit it upstream.
102
+ - name: Dependency audit
103
+ if: ${{ !cancelled() }}
104
+ run: sh ../../scripts/js-dependency-audit.sh
77
105
  # Run with NO NEXT_PUBLIC_CORE_COGNITO_* in scope — this is now the
78
106
  # PERMANENT state, not a build-guard quirk. The frontend NEVER reads those
79
107
  # vars: it resolves the core's Cognito identity at runtime from
@@ -126,8 +154,8 @@ jobs:
126
154
  if: ${{ !cancelled() }}
127
155
  # This job's working-directory default is apps/frontend (top of this
128
156
  # job), but scripts/biffo.sh lives at the repo root -- same relative-
129
- # path convention as the js-audit job's own
130
- # `sh ../../scripts/js-dependency-audit.sh` below. A bare
157
+ # path convention as this job's own Dependency audit step's
158
+ # `sh ../../scripts/js-dependency-audit.sh` above. A bare
131
159
  # `scripts/biffo.sh` here fails unconditionally with "No such file",
132
160
  # on every run, regardless of whether there is a real
133
161
  # lambda-output/terraform-input violation (biffo-template#1983).
@@ -193,51 +221,6 @@ jobs:
193
221
  - name: E2E tests
194
222
  run: pnpm run e2e
195
223
 
196
- # Split out of the `js` job deliberately (biffo-template#1880): `pnpm
197
- # audit`'s registry call has been chronically, intermittently unreachable
198
- # since npm/GitHub retired the legacy audit endpoints on 2026-07-15, an
199
- # ongoing upstream infrastructure condition unrelated to this repo's own
200
- # code. Mirrors biffo-template's own root `ci.yml` split; see that repo's
201
- # `js-audit` job and #1880 for the full evidence. `js` (required) no
202
- # longer runs this step, so a registry hang can never again block this
203
- # required check; this job runs the exact same script informationally on
204
- # every push/PR (`continue-on-error: true`) and is deliberately NOT part
205
- # of DEFAULT_STATUS_CHECKS/SIBLING_STATUS_CHECKS.
206
- js-audit:
207
- name: JS Dependency Audit (non-blocking, #1880)
208
- runs-on: ${{ vars.RUNNER_LABEL || 'ubuntu-latest' }}
209
- timeout-minutes: 20
210
- continue-on-error: true
211
- defaults:
212
- run:
213
- working-directory: apps/frontend
214
- steps:
215
- - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
216
- - uses: pnpm/action-setup@v4
217
- with:
218
- version: 9.15.9
219
- - uses: actions/setup-node@v4
220
- with:
221
- node-version: ${{ env.NODE_VERSION }}
222
- cache: pnpm
223
- cache-dependency-path: apps/frontend/pnpm-lock.yaml
224
- - run: pnpm install --frozen-lockfile
225
- - name: Dependency audit
226
- if: ${{ !cancelled() }}
227
- # Fails on a real high/critical advisory, but treats a broken audit
228
- # registry (non-JSON response, or the chronic #1880 timeout) as
229
- # INCONCLUSIVE rather than blocking (#591, #743) — and this job's own
230
- # non-required status is what stops that inconclusive result from
231
- # blocking the merge queue in the first place.
232
- #
233
- # The relative path is deliberate. This job's working-directory is
234
- # apps/frontend, which is exactly the tree to audit, and the script
235
- # audits its own cwd — it is byte-identical to biffo-template's copy
236
- # and to every other satellite's, distributed by that repo's
237
- # shared-sync.sh, so it cannot assume a layout. Do not edit it here;
238
- # edit it upstream.
239
- run: sh ../../scripts/js-dependency-audit.sh
240
-
241
224
  python:
242
225
  name: Python (lint, types, test, security)
243
226
  runs-on: ${{ vars.RUNNER_LABEL || 'ubuntu-latest' }}
@@ -246,14 +229,14 @@ jobs:
246
229
  run:
247
230
  working-directory: services/api
248
231
  steps:
249
- # `fetch-depth: 0` is no longer required here (#1882): it existed
250
- # solely for py-dependency-audit.sh's pre-existing-vs-introduced
251
- # comparison (#1673), which needed `origin/${GITHUB_BASE_REF}` to
252
- # resolve locally -- that step, and the same full-depth checkout it
253
- # needs, moved to the `python-audit` job below when the audit was
254
- # split out of this required job. Nothing remaining in this job reads
255
- # `origin/` state, so the default shallow checkout is correct here now.
232
+ # `fetch-depth: 0` is required again (biffo-template#2040, reverting
233
+ # #1882's removal of it): the Dependency audit step below (folded back
234
+ # into this required job) needs `origin/${GITHUB_BASE_REF}` to resolve
235
+ # locally for py-dependency-audit.sh's pre-existing-vs-introduced
236
+ # comparison (#1673).
256
237
  - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
238
+ with:
239
+ fetch-depth: 0
257
240
  - uses: astral-sh/setup-uv@v5
258
241
  with:
259
242
  enable-cache: true
@@ -264,6 +247,19 @@ jobs:
264
247
  # committed lock just stops describing what actually ran
265
248
  # (biffo-template#1731).
266
249
  - run: uv sync --all-groups --locked
250
+ # Runs IN this required job again, rather than in a separate
251
+ # `continue-on-error` job, as of biffo-template#2040 — reverting the
252
+ # biffo-template#1882 split (mirroring the identical `js`-side revert
253
+ # above; see that job's own comment for the full reasoning). What
254
+ # changed: py-dependency-audit.sh has classified findings as
255
+ # "pre-existing on the base branch" vs "introduced by this diff"
256
+ # since #1673, so the routine case #1882 was reacting to no longer
257
+ # blocks, and only a finding this diff actually introduced or
258
+ # upgraded into does. cwd is services/api, which is the environment
259
+ # `uv sync` installed and so the one to audit.
260
+ - name: Dependency audit
261
+ if: ${{ !cancelled() }}
262
+ run: sh ../../scripts/py-dependency-audit.sh
267
263
  - name: Lint
268
264
  if: ${{ !cancelled() }}
269
265
  run: uv run ruff check .
@@ -367,39 +363,6 @@ jobs:
367
363
  # *.test.sh guard is added with no run: line anywhere.
368
364
  run: sh scripts/guard-self-test-wiring.sh
369
365
 
370
- # Split out of the `python` job deliberately (biffo-template#1882, mirroring
371
- # #1880/#1881's identical JS-side fix): `pip-audit`'s advisory lookups are
372
- # exactly as registry-dependent and chronically unreliable as `pnpm audit`'s
373
- # were. `python` (required) no longer runs this step, so a registry-side
374
- # advisory event can never again block that job; this job runs the exact
375
- # same script informationally on every push/PR (`continue-on-error: true`)
376
- # and is deliberately NOT part of DEFAULT_STATUS_CHECKS/SIBLING_STATUS_CHECKS.
377
- python-audit:
378
- name: Python Dependency Audit (non-blocking, #1882)
379
- runs-on: ${{ vars.RUNNER_LABEL || 'ubuntu-latest' }}
380
- timeout-minutes: 20
381
- continue-on-error: true
382
- defaults:
383
- run:
384
- working-directory: services/api
385
- steps:
386
- - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
387
- with:
388
- fetch-depth: 0
389
- - uses: astral-sh/setup-uv@v5
390
- with:
391
- enable-cache: true
392
- - run: uv sync --all-groups --locked
393
- - name: Dependency audit
394
- if: ${{ !cancelled() }}
395
- # Same treatment as the JS job's Dependency audit: fails on a real
396
- # advisory, INCONCLUSIVE on an unreachable PyPI/OSV (#591, #743) —
397
- # and this job's own non-required status is what stops that
398
- # inconclusive result from blocking the merge queue in the first
399
- # place. cwd is services/api, which is the environment `uv sync`
400
- # installed and so the one to audit.
401
- run: sh ../../scripts/py-dependency-audit.sh
402
-
403
366
  security-secrets:
404
367
  name: Secret Scan
405
368
  runs-on: ${{ vars.RUNNER_LABEL || 'ubuntu-latest' }}
@@ -0,0 +1,304 @@
1
+ #!/usr/bin/env sh
2
+ #
3
+ # Proves js-dependency-audit.sh (#2040) 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
+ # This is the JS side of the fix py-dependency-audit.sh already got in
8
+ # #1673; see that file's own classification test for the Python case table
9
+ # this one mirrors.
10
+ #
11
+ # The real incident this guards against: two routine npm advisories
12
+ # (GHSA-p293-qw3h-jr36 / Next.js, GHSA-82fw-gwwq-j7x9 / vitest) were
13
+ # published against packages already on `dev` on 2026-09-08/09, with no code
14
+ # change anywhere -- and turned the JS lane red on every open PR at once,
15
+ # producing 89 fleet dispatches across 61 units in 8 repos and 10 duplicate
16
+ # tickets for one classifier bug (#2040's own cost accounting). Case 1 below
17
+ # reconstructs exactly that shape: base and PR both pinned at the flagged
18
+ # version, PR diff untouched.
19
+ #
20
+ # ## Real `pnpm audit --json` shape, captured live
21
+ #
22
+ # Run against this repo's own workspace (pnpm 9.15.9, 2026-09-10):
23
+ #
24
+ # $ pnpm audit --json
25
+ # {
26
+ # "advisories": {
27
+ # "1090893": {
28
+ # "severity": "low",
29
+ # "module_name": "cli",
30
+ # "github_advisory_id": "GHSA-6cpc-mj5c-m9rq",
31
+ # "findings": [{"version": "0.0.0", "paths": []}],
32
+ # ...
33
+ # }
34
+ # },
35
+ # "metadata": {
36
+ # "vulnerabilities": {"info": 0, "low": 1, "moderate": 0, "high": 0, "critical": 0},
37
+ # "totalDependencies": 929
38
+ # }
39
+ # }
40
+ #
41
+ # `.advisories` is an OBJECT keyed by an internal numeric id, not an array --
42
+ # `.advisories[]?` iterates its values the same as an array would. Each
43
+ # value carries `.severity`, `.module_name` and `.github_advisory_id`, which
44
+ # is exactly what js-dependency-audit.sh's classification reads. The fixture
45
+ # JSON below is shaped to match this real structure, not invented -- the
46
+ # OLD version of js-dependency-audit-parallel.test.sh's stub JSON carried
47
+ # only `.metadata.vulnerabilities` with no `.advisories` key at all, which
48
+ # classification silently read as zero findings (fixed alongside this file;
49
+ # see that test's own updated `_fail_json` comment).
50
+ #
51
+ # Also proves `pnpm audit` runs against a BARE lockfile copy with no install
52
+ # and no workspace context -- verified directly, not assumed:
53
+ #
54
+ # $ mkdir /tmp/probe && cp pnpm-lock.yaml /tmp/probe/ && cd /tmp/probe
55
+ # $ pnpm audit --json --ignore-workspace # exits 0, real advisory output
56
+ #
57
+ # which is why the base-branch side of this comparison is a second real
58
+ # `pnpm audit` call against a `git show`-extracted copy of the lockfile,
59
+ # not a hand-rolled pnpm-lock.yaml parser.
60
+ #
61
+ # Builds a real, tiny, throwaway git repo under mktemp (never under /tmp as
62
+ # a full worktree copy -- this is a from-scratch repo with a handful of
63
+ # files, not a copy of this repository's own object store -- see AGENTS.md's
64
+ # "never create a git worktree ... under /tmp", which this is not) with a
65
+ # base branch and a PR-head state, and stubs `pnpm` on PATH so `pnpm audit
66
+ # --json` returns a canned finding selected by a marker comment inside
67
+ # whichever pnpm-lock.yaml the stub is invoked against -- no network, no
68
+ # real registry. `jq`, `git` and `grep` are used for real -- they are
69
+ # exactly what the target script itself depends on, so stubbing them would
70
+ # test nothing.
71
+ #
72
+ # Run: sh scripts/js-dependency-audit-classification.test.sh
73
+
74
+ set -u
75
+
76
+ SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
77
+ TARGET="$SCRIPT_DIR/js-dependency-audit.sh"
78
+
79
+ REPO_DIR=$(mktemp -d)
80
+ STUB_DIR=$(mktemp -d)
81
+ TMPROOT_DIR=$(mktemp -d)
82
+ OUT_FILE=$(mktemp)
83
+ trap 'rm -rf "$REPO_DIR" "$STUB_DIR" "$TMPROOT_DIR"; rm -f "$OUT_FILE"' EXIT
84
+
85
+ FAILURES=0
86
+
87
+ # --- pnpm stub ---------------------------------------------------------------
88
+ # Selected by a `# STATE:<tag>` marker comment inside whatever
89
+ # pnpm-lock.yaml is in the CURRENT DIRECTORY when invoked -- the target
90
+ # script always `cd`s into the tree it is auditing (the repo's own working
91
+ # tree for the HEAD run, or a scratch dir holding a `git show`-extracted
92
+ # copy for the BASE run), so the marker travels with whichever commit's
93
+ # content is actually being read, exactly like a real lockfile diff would.
94
+ cat > "$STUB_DIR/pnpm" <<'STUB'
95
+ #!/usr/bin/env sh
96
+ if [ "$1" = "audit" ]; then
97
+ state=$(grep -o 'STATE:[A-Za-z0-9_-]*' pnpm-lock.yaml 2>/dev/null | head -1 | cut -d: -f2)
98
+ out_file="$STUB_DIR_ENV/output-${state:-clean}.json"
99
+ if [ -f "$out_file" ]; then
100
+ cat "$out_file"
101
+ else
102
+ cat "$STUB_DIR_ENV/output-clean.json"
103
+ fi
104
+ exit 0
105
+ fi
106
+ echo "pnpm stub: unexpected invocation: $*" >&2
107
+ exit 99
108
+ STUB
109
+ chmod +x "$STUB_DIR/pnpm"
110
+
111
+ # --- fixture JSON, one per STATE tag ----------------------------------------
112
+ cat > "$STUB_DIR/output-clean.json" <<'JSON'
113
+ {"metadata":{"vulnerabilities":{"critical":0,"high":0,"moderate":0,"low":0},"totalDependencies":5}}
114
+ JSON
115
+
116
+ # One high-severity advisory against vuln-pkg-a.
117
+ cat > "$STUB_DIR/output-vuln-a.json" <<'JSON'
118
+ {"metadata":{"vulnerabilities":{"critical":0,"high":1,"moderate":0,"low":0},"totalDependencies":5},"advisories":{"1":{"severity":"high","github_advisory_id":"GHSA-test-0001","module_name":"vuln-pkg-a","findings":[{"version":"1.2.3","paths":[]}]}}}
119
+ JSON
120
+
121
+ # Only vuln-pkg-b's advisory (used as the mixed case's BASE state -- pkg-a
122
+ # is absent entirely, pkg-b is present and already flagged).
123
+ cat > "$STUB_DIR/output-vuln-b-only.json" <<'JSON'
124
+ {"metadata":{"vulnerabilities":{"critical":0,"high":1,"moderate":0,"low":0},"totalDependencies":5},"advisories":{"2":{"severity":"high","github_advisory_id":"GHSA-test-0002","module_name":"vuln-pkg-b","findings":[{"version":"2.0.0","paths":[]}]}}}
125
+ JSON
126
+
127
+ # Both advisories together (the mixed case's HEAD state).
128
+ cat > "$STUB_DIR/output-vuln-a-b.json" <<'JSON'
129
+ {"metadata":{"vulnerabilities":{"critical":0,"high":2,"moderate":0,"low":0},"totalDependencies":5},"advisories":{"1":{"severity":"high","github_advisory_id":"GHSA-test-0001","module_name":"vuln-pkg-a","findings":[{"version":"1.2.3","paths":[]}]},"2":{"severity":"high","github_advisory_id":"GHSA-test-0002","module_name":"vuln-pkg-b","findings":[{"version":"2.0.0","paths":[]}]}}}
130
+ JSON
131
+
132
+ # --- repo scaffolding --------------------------------------------------------
133
+ # One workspace-level pnpm-lock.yaml so discovery finds exactly one tree,
134
+ # matching the real incident (the workspace root, not a vendored tree).
135
+ _write_lock() {
136
+ # $1 = destination file, $2 = STATE tag
137
+ cat > "$1" <<LOCK
138
+ lockfileVersion: '9.0'
139
+ # STATE:$2
140
+ LOCK
141
+ }
142
+
143
+ _init_repo() {
144
+ # $1 = base STATE tag (what "dev" / origin/<base> holds)
145
+ # $2 = head STATE tag (what the PR branch under test holds)
146
+ base_state=$1
147
+ head_state=$2
148
+
149
+ rm -rf "$REPO_DIR"
150
+ mkdir -p "$REPO_DIR"
151
+ ( cd "$REPO_DIR" \
152
+ && git init -q -b trunk \
153
+ && git config user.email test@example.com \
154
+ && git config user.name "Test" )
155
+
156
+ _write_lock "$REPO_DIR/pnpm-lock.yaml" "$base_state"
157
+ ( cd "$REPO_DIR" && git add -A && git commit -q -m base )
158
+ # A literal branch named "origin/<base>" -- git tolerates slashes in
159
+ # branch names, so this resolves via `git show origin/<base>:<path>`
160
+ # exactly like a real remote-tracking ref would, with no remote required.
161
+ ( cd "$REPO_DIR" && git branch -q "origin/dev" )
162
+
163
+ if [ "$head_state" != "$base_state" ]; then
164
+ _write_lock "$REPO_DIR/pnpm-lock.yaml" "$head_state"
165
+ ( cd "$REPO_DIR" && git add -A && git commit -q -m "pr change" )
166
+ fi
167
+ }
168
+
169
+ # --- runner ------------------------------------------------------------------
170
+ _run() {
171
+ # Runs the target script with GITHUB_BASE_REF set from $1 (empty string
172
+ # means unset -- a push/workflow_dispatch/merge_group context), cwd inside
173
+ # the synthetic repo, pnpm stubbed, PATH otherwise untouched (jq/git/grep
174
+ # are the real system tools, same as production).
175
+ base_ref=$1
176
+ (
177
+ cd "$REPO_DIR" || exit 97
178
+ PATH="$STUB_DIR:$PATH"
179
+ STUB_DIR_ENV="$STUB_DIR"
180
+ TMPDIR="$TMPROOT_DIR"
181
+ export PATH STUB_DIR_ENV TMPDIR
182
+ if [ -n "$base_ref" ]; then
183
+ GITHUB_BASE_REF="$base_ref"
184
+ export GITHUB_BASE_REF
185
+ else
186
+ unset GITHUB_BASE_REF
187
+ fi
188
+ sh "$TARGET"
189
+ ) >"$OUT_FILE" 2>&1
190
+ LAST_RC=$?
191
+ }
192
+
193
+ _assert_exit() {
194
+ name=$1
195
+ expected=$2
196
+ if [ "$LAST_RC" -eq "$expected" ]; then
197
+ echo "PASS: $name (exit $LAST_RC)"
198
+ else
199
+ echo "FAIL: $name -- expected exit $expected, got $LAST_RC"
200
+ echo "--- output ---"
201
+ cat "$OUT_FILE"
202
+ echo "--------------"
203
+ FAILURES=$((FAILURES + 1))
204
+ fi
205
+ }
206
+
207
+ _assert_output_contains() {
208
+ name=$1
209
+ needle=$2
210
+ if grep -qF "$needle" "$OUT_FILE"; then
211
+ echo "PASS: $name mentions '$needle'"
212
+ else
213
+ echo "FAIL: $name -- expected output to mention '$needle'"
214
+ echo "--- output ---"
215
+ cat "$OUT_FILE"
216
+ echo "--------------"
217
+ FAILURES=$((FAILURES + 1))
218
+ fi
219
+ }
220
+
221
+ # ==============================================================================
222
+ # Case table (must-NOT-block first, then must-block), each run against the
223
+ # real target script -- not a reimplementation of its logic.
224
+ # ==============================================================================
225
+
226
+ # 1. PRE-EXISTING, PR context, advisory UNCHANGED from base (the
227
+ # GHSA-p293-qw3h-jr36 / 2026-09-08 shape: same version on both sides of
228
+ # the diff). Must NOT block, and must say so as pre-existing rather than
229
+ # a flat red.
230
+ _init_repo "vuln-a" "vuln-a"
231
+ _run dev
232
+ _assert_exit "pre-existing, advisory unchanged by diff" 0
233
+ _assert_output_contains "pre-existing case names it as pre-existing" "pre-existing"
234
+ _assert_output_contains "pre-existing case cites #2040" "#2040"
235
+
236
+ # 2. INTRODUCED via upgrade: base had a clean lockfile, this diff's
237
+ # pnpm-lock.yaml moved it to the flagged version. Must block.
238
+ _init_repo "clean" "vuln-a"
239
+ _run dev
240
+ _assert_exit "introduced by upgrade" 1
241
+ _assert_output_contains "introduced-by-upgrade case names it as introduced" "introduced or upgraded by this diff"
242
+
243
+ # 3. INTRODUCED via new dependency: base's lockfile carries no such package
244
+ # at all; this diff added it at an already-vulnerable version. For the
245
+ # advisory-ID-diffing design this is mechanically identical to case 2
246
+ # (base's audit reports no matching id either way) -- kept as its own
247
+ # case for documentation, matching py-dependency-audit-classification's
248
+ # own case 3, which IS mechanically distinct there because Python
249
+ # classifies per-package lockfile version rather than by diffing two
250
+ # full audit runs.
251
+ _init_repo "clean" "vuln-a"
252
+ _run dev
253
+ _assert_exit "introduced via brand-new dependency" 1
254
+ _assert_output_contains "new-dependency case names it as introduced" "introduced or upgraded by this diff"
255
+
256
+ # 4. Non-PR context (push to the integration branch itself, or
257
+ # workflow_dispatch/merge_group -- GITHUB_BASE_REF unset). No diff exists
258
+ # to attribute the finding to, so the classification must NOT apply: any
259
+ # finding blocks, exactly as before this fix. Deliberately the SAME
260
+ # lockfile shape as case 1 (unchanged advisory) to prove the difference
261
+ # in outcome is driven by PR-context alone -- the scheduled base-branch
262
+ # scan added alongside this file (#2040) relies on exactly this: every
263
+ # current finding on `dev` must be reported, not just new ones.
264
+ _init_repo "vuln-a" "vuln-a"
265
+ _run ""
266
+ _assert_exit "no PR context -- always blocks" 1
267
+
268
+ # 5. PR context, but the base ref cannot be resolved locally (e.g. a shallow
269
+ # checkout, or a distributed copy running where fetch-depth: 0 was not
270
+ # honoured). Comparison fails CLOSED: blocks, same as before this fix,
271
+ # rather than silently waving a real regression through because the
272
+ # comparison itself could not be made.
273
+ _init_repo "vuln-a" "vuln-a"
274
+ _run "some-branch-that-was-never-fetched"
275
+ _assert_exit "base ref unresolvable -- fails closed to blocking" 1
276
+ _assert_output_contains "base ref unresolvable -- warns why" "does not resolve locally"
277
+
278
+ # 6. Mixed tree: one pre-existing finding (vuln-pkg-b, unchanged from base)
279
+ # and one introduced finding (vuln-pkg-a, new to this diff) in the same
280
+ # run. Must still block overall (the introduced one), while the
281
+ # pre-existing one is still named as such rather than folded into an
282
+ # undifferentiated total.
283
+ _init_repo "vuln-b-only" "vuln-a-b"
284
+ _run dev
285
+ _assert_exit "mixed tree -- introduced finding still blocks" 1
286
+ _assert_output_contains "mixed tree -- introduced vuln-pkg-a is named" "vuln-pkg-a advisory GHSA-test-0001"
287
+ _assert_output_contains "mixed tree -- pre-existing vuln-pkg-b is named separately" "vuln-pkg-b advisory GHSA-test-0002"
288
+ _assert_output_contains "mixed tree -- pre-existing vuln-pkg-b says so" "pre-existing"
289
+
290
+ # 7. Sanity: a clean run (no vulnerabilities at all) is unaffected by any of
291
+ # the above -- still exits 0, still audits normally with the new
292
+ # 5-arg audit_dir signature.
293
+ _init_repo "clean" "clean"
294
+ _run dev
295
+ _assert_exit "clean run unaffected" 0
296
+
297
+ echo
298
+ if [ "$FAILURES" -eq 0 ]; then
299
+ echo "js-dependency-audit-classification.test.sh: all checks passed."
300
+ exit 0
301
+ else
302
+ echo "js-dependency-audit-classification.test.sh: $FAILURES check(s) failed."
303
+ exit 1
304
+ fi
@@ -62,6 +62,50 @@
62
62
  # or the copies stop being interchangeable and `shared-sync.sh --check` starts
63
63
  # reporting drift that is really divergence.
64
64
  #
65
+ # ## Pre-existing vs introduced (#2040)
66
+ #
67
+ # Every check above answers "can this audit be trusted" (network flake vs a
68
+ # real finding). None of them answer WHEN the vulnerable version got there —
69
+ # so a genuine high/critical advisory published overnight against a package
70
+ # already sitting on `dev`, untouched by this diff, used to read identically
71
+ # to a PR that actually introduced the vulnerable version. #1880 (2026-09-04)
72
+ # tried to paper over exactly that by moving this whole check to a
73
+ # `continue-on-error`, non-required job — which stopped a real per-PR finding
74
+ # from blocking too, and produced ten separate biffo-fleet tickets and $86.72
75
+ # of dispatch spend for two routine advisories that no PR under test could
76
+ # have fixed (#2040's own cost accounting). `py-dependency-audit.sh` closed
77
+ # this same gap for Python in #1673; this is the JS side of the same fix.
78
+ #
79
+ # The fix compares the ADVISORY IDS found against the SAME tree's pnpm audit
80
+ # run against the PR's BASE branch, not against the diff itself: a PR can
81
+ # leave pnpm-lock.yaml untouched while a sibling change moves a transitive
82
+ # version, or the base branch may already carry the flagged version. An
83
+ # advisory ID present in both base and head is pre-existing and does not
84
+ # block; an advisory ID present only in head is introduced by this diff (a
85
+ # new dependency, or an upgrade/downgrade into a vulnerable version) and
86
+ # blocks exactly as before. This mirrors the issue's own "run against base
87
+ # and head, diff the advisory IDs, fail on head-only" design rather than
88
+ # hand-parsing pnpm-lock.yaml's YAML — `pnpm audit` already works against a
89
+ # bare copy of just the lockfile with no install (verified directly against
90
+ # this repo's own pnpm 9.15.9), so the base side is a second real audit
91
+ # call, not a reimplementation of what `pnpm audit` already does.
92
+ #
93
+ # `GITHUB_BASE_REF` is the base branch name and is set ONLY for a
94
+ # `pull_request`/`pull_request_target` event — empty on `push`,
95
+ # `workflow_dispatch` and `merge_group`. That is deliberate, not a gap:
96
+ # outside a PR there is no "diff" to attribute a finding to, so every finding
97
+ # blocks exactly as before (the correct behaviour for `dev` itself, and for
98
+ # the scheduled base-branch scan added alongside this file in #2040, which
99
+ # wants every current finding reported, not just new ones).
100
+ #
101
+ # `origin/$GITHUB_BASE_REF` must already be a resolvable local ref for the
102
+ # comparison to run at all — the `js` job's checkout uses `fetch-depth: 0`
103
+ # precisely so it is (see that job's own comment). If it is NOT resolvable
104
+ # (a shallow checkout, or a distributed copy of this script running
105
+ # somewhere that checkout is missing), comparison fails CLOSED: every
106
+ # finding blocks, same as before this fix, rather than silently waving a
107
+ # real regression through because the check could not be performed.
108
+ #
65
109
  # POSIX sh (the CI step runs `sh scripts/...`, i.e. dash) — no `pipefail`.
66
110
  set -u
67
111
 
@@ -118,12 +162,60 @@ failed=0
118
162
  # invocation has finished.
119
163
  #
120
164
  # `$1` is the directory, `$2` a human label, `$3` extra pnpm flags, `$4` the
121
- # result file this invocation must write its verdict to.
165
+ # result file this invocation must write its verdict to, `$5` this tree's
166
+ # pnpm-lock.yaml path relative to the repo root — used to look up the SAME
167
+ # lockfile's advisories on the base branch (#2040) — never empty, every
168
+ # caller passes one.
169
+ #
170
+ # Returns (on stdout) the set of advisory IDs recorded against the SAME
171
+ # lockfile path on the PR's base branch, one per line — any severity, not
172
+ # just high/critical, because presence is all classification needs. Prints
173
+ # nothing and returns non-zero when a comparison genuinely cannot be made
174
+ # (network/parse failure, a temp-dir failure) — the caller then treats every
175
+ # finding as introduced, the same fail-closed default #1673 established for
176
+ # Python. An empty base lockfile (the path did not exist on the base branch
177
+ # at all — a brand-new lockfile or a brand-new vendored tree this diff
178
+ # itself introduced) is NOT a failure: it prints nothing and returns 0,
179
+ # because an empty set is the correct answer — there is nothing to be
180
+ # pre-existing against, so every finding in a tree the base never had is
181
+ # introduced by definition.
182
+ _base_advisory_ids() {
183
+ lock_rel="$1"
184
+
185
+ base_content="$(git show "${BASE_REMOTE_REF}:${lock_rel}" 2>/dev/null)"
186
+ if [ -z "$base_content" ]; then
187
+ return 0
188
+ fi
189
+
190
+ workdir=$(mktemp -d "${TMPDIR:-/tmp}/js-dependency-audit-base.XXXXXX") || return 1
191
+ printf '%s' "$base_content" >"$workdir/pnpm-lock.yaml"
192
+
193
+ for attempt in $(seq 1 "$attempts"); do
194
+ # shellcheck disable=SC2086
195
+ base_out="$(cd "$workdir" && timeout "$AUDIT_TIMEOUT_SECS" pnpm audit --json --ignore-workspace 2>/dev/null)"
196
+ base_status=$?
197
+ if [ "$base_status" -eq 124 ]; then
198
+ [ "$attempt" -lt "$attempts" ] && sleep "$((attempt * 2))"
199
+ continue
200
+ fi
201
+ if printf '%s' "$base_out" | jq -e '.metadata.vulnerabilities' >/dev/null 2>&1; then
202
+ printf '%s' "$base_out" | jq -r '.advisories[]? | (.github_advisory_id // (.id|tostring))' 2>/dev/null
203
+ rm -rf "$workdir"
204
+ return 0
205
+ fi
206
+ [ "$attempt" -lt "$attempts" ] && sleep "$((attempt * 2))"
207
+ done
208
+
209
+ rm -rf "$workdir"
210
+ return 1
211
+ }
212
+
122
213
  audit_dir() {
123
214
  dir="$1"
124
215
  label="$2"
125
216
  extra="$3"
126
217
  resultfile="$4"
218
+ lock_rel="$5"
127
219
 
128
220
  for attempt in $(seq 1 "$attempts"); do
129
221
  # printf, never echo: the CI step runs `sh scripts/...` i.e. dash, whose
@@ -172,10 +264,54 @@ audit_dir() {
172
264
  low="$(printf '%s' "$out" | jq '.metadata.vulnerabilities.low // 0')"
173
265
  total="$(printf '%s' "$out" | jq '.metadata.totalDependencies // 0')"
174
266
  if [ "$((high + crit))" -gt 0 ]; then
175
- echo "::error::${label}: ${crit} critical + ${high} high advisory(ies) across ${total} package(s); registry answered ${seen_at}."
267
+ # Classify each qualifying (high/critical) advisory against the base
268
+ # branch's SAME lockfile before deciding to block (#2040). Written to
269
+ # a regular file, not a pipe, and read with `while read ... done <
270
+ # file` rather than `| while read`, for the same reason
271
+ # py-dependency-audit.sh's findings loop does: a pipeline runs the
272
+ # loop in a subshell, and a subshell's counter updates vanish the
273
+ # instant it exits.
274
+ findings_file="$(mktemp)"
275
+ printf '%s' "$out" | jq -r '.advisories[]? | select(.severity=="high" or .severity=="critical") | "\(.github_advisory_id // (.id|tostring))\t\(.severity)\t\(.module_name)"' >"$findings_file"
276
+
277
+ base_ids_file=""
278
+ base_available=0
279
+ if [ "$COMPARE_MODE" -eq 1 ]; then
280
+ candidate_ids="$(mktemp)"
281
+ if _base_advisory_ids "$lock_rel" >"$candidate_ids" 2>/dev/null; then
282
+ base_ids_file="$candidate_ids"
283
+ base_available=1
284
+ else
285
+ rm -f "$candidate_ids"
286
+ fi
287
+ fi
288
+
289
+ introduced_count=0
290
+ preexisting_count=0
291
+ while IFS="$(printf '\t')" read -r f_id f_sev f_mod; do
292
+ [ -z "$f_id" ] && continue
293
+ if [ "$base_available" -eq 1 ] && grep -qxF "$f_id" "$base_ids_file" 2>/dev/null; then
294
+ preexisting_count=$((preexisting_count + 1))
295
+ echo "::warning::${label}: ${f_mod} advisory ${f_id} (${f_sev}) is already present in ${BASE_REMOTE_REF}'s lockfile — pre-existing, not introduced by this diff (#2040)."
296
+ else
297
+ introduced_count=$((introduced_count + 1))
298
+ echo "::error::${label}: ${f_mod} advisory ${f_id} (${f_sev}) — new to this tree, or a version this diff introduced/upgraded (or a base-branch comparison was not possible)."
299
+ fi
300
+ done <"$findings_file"
301
+ rm -f "$findings_file"
302
+ [ -n "$base_ids_file" ] && rm -f "$base_ids_file"
303
+
176
304
  printf '%s' "$out" | jq '.advisories // .metadata.vulnerabilities' 2>/dev/null | head -c 4000
177
- echo "fail" >"$resultfile"
178
- return 1
305
+
306
+ if [ "$introduced_count" -gt 0 ]; then
307
+ echo "::error::${label}: ${introduced_count} critical/high advisory(ies) introduced or upgraded by this diff across ${total} package(s) (${preexisting_count} more pre-existing, not counted against it); registry answered ${seen_at}."
308
+ echo "fail" >"$resultfile"
309
+ return 1
310
+ fi
311
+
312
+ echo "${label}: ${preexisting_count} critical/high advisory(ies) found, all pre-existing on ${BASE_REMOTE_REF:-the base branch} and unrelated to this diff — not blocking (#2040); registry answered ${seen_at}."
313
+ echo "ok" >"$resultfile"
314
+ return 0
179
315
  fi
180
316
  # A bare "no advisories" is not falsifiable. State the population, the
181
317
  # severities that did NOT block, and when the registry was asked, so a
@@ -210,6 +346,23 @@ fi
210
346
 
211
347
  WORKSPACE_ABS=$(pwd -P)
212
348
 
349
+ # Decide once, for the whole run, whether a base-branch comparison is even
350
+ # possible (#2040 — see the "Pre-existing vs introduced" docstring above
351
+ # `set -u`). `GITHUB_BASE_REF` is only ever set by a
352
+ # `pull_request`/`pull_request_target` event; everywhere else COMPARE_MODE
353
+ # stays 0 and every finding blocks, unchanged from before this fix.
354
+ BASE_REMOTE_REF=""
355
+ COMPARE_MODE=0
356
+ if [ -n "${GITHUB_BASE_REF:-}" ]; then
357
+ candidate="origin/${GITHUB_BASE_REF}"
358
+ if git rev-parse --verify --quiet "${candidate}^{commit}" >/dev/null 2>&1; then
359
+ BASE_REMOTE_REF="$candidate"
360
+ COMPARE_MODE=1
361
+ else
362
+ echo "::warning::js-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 #2040."
363
+ fi
364
+ fi
365
+
213
366
  # shellcheck disable=SC2016
214
367
  ALL_LOCKS=$(find "$REPO_ROOT" \
215
368
  \( -name node_modules -o -name .git -o -name .worktrees \) -prune -o \
@@ -286,10 +439,14 @@ for lock in $ALL_LOCKS; do
286
439
  esac
287
440
  i=$((i + 1))
288
441
  resultfile="$TMP_DIR/result.$i"
442
+ # `find` was rooted at $REPO_ROOT, so $lock is always an absolute path
443
+ # under it — this strip is unconditional, unlike $rel's dir_abs case above
444
+ # (which also has to tolerate a symlink resolving outside the root).
445
+ lock_rel=${lock#"$REPO_ROOT"/}
289
446
  if [ "$dir_abs" = "$WORKSPACE_ABS" ]; then
290
- audit_dir "$dir" "pnpm audit (workspace: ${rel})" "" "$resultfile" &
447
+ audit_dir "$dir" "pnpm audit (workspace: ${rel})" "" "$resultfile" "$lock_rel" &
291
448
  else
292
- audit_dir "$dir" "pnpm audit (${rel})" "--ignore-workspace" "$resultfile" &
449
+ audit_dir "$dir" "pnpm audit (${rel})" "--ignore-workspace" "$resultfile" "$lock_rel" &
293
450
  fi
294
451
  done
295
452
 
package/dist/index.js CHANGED
@@ -7632,10 +7632,6 @@ function rewriteDesignTokens(targetDir, tokens) {
7632
7632
  " timeout-minutes: 20\n defaults:\n run:\n working-directory: apps/frontend\n steps:",
7633
7633
  " timeout-minutes: 20\n permissions:\n contents: read\n packages: read\n defaults:\n run:\n working-directory: apps/frontend\n steps:"
7634
7634
  );
7635
- yml = yml.replace(
7636
- " continue-on-error: true\n defaults:\n run:\n working-directory: apps/frontend\n steps:",
7637
- " continue-on-error: true\n permissions:\n contents: read\n packages: read\n defaults:\n run:\n working-directory: apps/frontend\n steps:"
7638
- );
7639
7635
  yml = yml.replace(
7640
7636
  /( {6}- run: pnpm install --frozen-lockfile)\n(?! {8}env:)/g,
7641
7637
  "$1\n env:\n NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@biffo/cli",
3
- "version": "0.315.7",
3
+ "version": "0.315.8",
4
4
  "description": "Biffo project scaffolding CLI",
5
5
  "license": "MIT",
6
6
  "type": "module",