@biffo/cli 0.315.6 → 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.
- 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/dist/index.js +0 -4
- package/package.json +2 -4
- package/scripts/runner-drop-forensics.mjs +77 -5
- package/scripts/verify.sh +5 -12
- package/scripts/practices-corpus.mjs +0 -202
- package/scripts/practices-metrics.mjs +0 -2768
|
@@ -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
|
|