@biffo/cli 0.315.7 → 0.315.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/_skeletons/plugin-template/scripts/js-dependency-audit-classification.test.sh +304 -0
- package/_skeletons/plugin-template/scripts/js-dependency-audit.sh +163 -6
- package/_skeletons/sibling-template/.github/workflows/ci.yml +50 -87
- package/_skeletons/sibling-template/scripts/js-dependency-audit-classification.test.sh +304 -0
- package/_skeletons/sibling-template/scripts/js-dependency-audit.sh +163 -6
- package/_skeletons/sibling-template/services/api/uv.lock +4 -3
- package/dist/index.js +0 -4
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
178
|
-
|
|
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
|
|
130
|
-
# `sh ../../scripts/js-dependency-audit.sh`
|
|
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
|
|
250
|
-
#
|
|
251
|
-
#
|
|
252
|
-
#
|
|
253
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
178
|
-
|
|
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
|
|
|
@@ -25,14 +25,15 @@ wheels = [
|
|
|
25
25
|
|
|
26
26
|
[[package]]
|
|
27
27
|
name = "anyio"
|
|
28
|
-
version = "4.
|
|
28
|
+
version = "4.15.1"
|
|
29
29
|
source = { registry = "https://pypi.org/simple" }
|
|
30
30
|
dependencies = [
|
|
31
31
|
{ name = "idna" },
|
|
32
|
+
{ name = "typing-extensions", marker = "python_full_version < '3.15'" },
|
|
32
33
|
]
|
|
33
|
-
sdist = { url = "https://files.pythonhosted.org/packages/
|
|
34
|
+
sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" }
|
|
34
35
|
wheels = [
|
|
35
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
36
|
+
{ url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" },
|
|
36
37
|
]
|
|
37
38
|
|
|
38
39
|
[[package]]
|
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"
|