@biffo/cli 0.315.2 → 0.315.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,272 @@
1
+ #!/usr/bin/env sh
2
+ #
3
+ # Proves py-dependency-audit.sh (#1995) no longer pipes a large writer
4
+ # directly into `head -c N`. `head -c` closes its read end the instant it
5
+ # has enough bytes; if the upstream write is still in flight when that
6
+ # happens, the writer gets SIGPIPE. Confirmed live via `strace -f` against
7
+ # the real script's line-234 command (a >4000-byte jq-filtered finding
8
+ # dump): `jq`, forked from that exact pipeline, was silently killed by
9
+ # SIGPIPE the instant `head -c 4000` closed early on a large payload. That
10
+ # is the general mechanism #1995 reports. The CI-only "printf: printf: I/O
11
+ # error" wording it also reports needs dash's own builtin `printf` to be the
12
+ # process still writing at that instant -- a narrower timing window this
13
+ # workstation's dash/kernel did not reproduce across repeated attempts up to
14
+ # a 94MB payload (see the PR body for the full trail). The issue's own
15
+ # citation -- a real, timestamped CI job log
16
+ # (biffo-plugin-idea-scout PR #126) -- is the evidence for that exact
17
+ # wording; this file does not re-derive it.
18
+ #
19
+ # That same "I/O error" shape hit Case 1's own negative control for real, in
20
+ # guard-self-test-wiring.sh's CI run (34391849474, job 102607880682, head
21
+ # eeabce12): the runner's dash does not die to SIGPIPE the way this
22
+ # workstation's does -- its printf builtin catches the broken pipe, reports
23
+ # "printf: printf: I/O error" to stderr, returns a nonzero status, and lets
24
+ # the subshell keep running past it. Case 1's check used to recognise only
25
+ # the SIGPIPE-death shape (marker file absent) and treated any marker
26
+ # present as "the defect didn't reproduce" -- so on that runner it failed
27
+ # for the same reason the fix exists: an outcome the control's own author
28
+ # had not seen. The fix is to the control's DETECTION, not the mechanism
29
+ # under test: it accepts either manifestation of the same defect (killed
30
+ # outright, or survived with a reported failure) as reproduced.
31
+ #
32
+ # ## Case 1: deterministic proof of the pipe-shape defect and its fix
33
+ #
34
+ # Removes scheduling luck from the underlying mechanism instead of racing
35
+ # for it: the writer sleeps briefly before writing, so a reader that exits
36
+ # immediately is GUARANTEED to have already closed its read end before the
37
+ # writer's first write() call. Shows the OLD shape (writer piped straight
38
+ # into a reader that may close early) never gets through cleanly -- either
39
+ # the writer is killed before it can report anything, or it survives but its
40
+ # own write reports the broken pipe as a failure -- and the NEW shape this
41
+ # fix uses (write to a regular file first, read the FINISHED file) never
42
+ # does either, regardless of payload size or timing -- because there is no
43
+ # concurrent writer left for a reader to break.
44
+ #
45
+ # ## Case 2: the real script, end to end, with a >4000-byte finding
46
+ #
47
+ # Reuses the uv-stub harness from py-dependency-audit-classification.test.sh
48
+ # to run the actual target script against a finding payload whose filtered
49
+ # JSON exceeds the 4000-byte truncation threshold, and checks the
50
+ # classification/exit-code behaviour and the truncated dump are unaffected
51
+ # by the fix.
52
+ #
53
+ # Run: sh scripts/py-dependency-audit-truncation.test.sh
54
+
55
+ set -u
56
+
57
+ SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
58
+ TARGET="$SCRIPT_DIR/py-dependency-audit.sh"
59
+
60
+ WORK_DIR=$(mktemp -d)
61
+ trap 'rm -rf "$WORK_DIR"' EXIT
62
+
63
+ FAILURES=0
64
+
65
+ # ============================================================================
66
+ # Case 0: the target script actually uses the safe shape
67
+ # ============================================================================
68
+ #
69
+ # Case 1 below proves the OLD shape is unsafe and the NEW shape is not, as a
70
+ # standalone fact about pipes -- it does not read $TARGET, so on its own it
71
+ # would not notice a regression that reverted the real script back to the
72
+ # vulnerable pattern. This case closes that gap directly against the file.
73
+
74
+ if grep -Eq "jq '\[\.dependencies\[\] \| select\(\.vulns \| length > 0\)\]' 2>/dev/null \| head -c" "$TARGET"; then
75
+ echo "FAIL: $TARGET pipes the vulns dump straight from jq into head -c again -- this is the exact shape #1995 reports"
76
+ FAILURES=$((FAILURES + 1))
77
+ else
78
+ echo "PASS: $TARGET does not pipe the vulns dump straight from jq into head -c"
79
+ fi
80
+
81
+ if grep -q 'head -c 4000 "\$dump_file"' "$TARGET"; then
82
+ echo "PASS: $TARGET truncates from a finished regular file, not a live pipe"
83
+ else
84
+ echo "FAIL: $TARGET no longer truncates from a regular file -- expected a 'head -c 4000 \"\$dump_file\"' line"
85
+ FAILURES=$((FAILURES + 1))
86
+ fi
87
+
88
+ # ============================================================================
89
+ # Case 1: deterministic pipe-shape proof
90
+ # ============================================================================
91
+
92
+ # 2MB of 'a' -- comfortably over the 64KiB Linux pipe buffer, so a writer
93
+ # emitting it in one shot cannot fit in the buffer and must block on a
94
+ # reader that may already be gone.
95
+ PAYLOAD="$(head -c 2000000 /dev/zero | tr '\0' 'a')"
96
+
97
+ # OLD shape: writer piped straight into an early-closing reader (`{ : ; }`
98
+ # reads nothing and exits immediately). The `sleep 0.5` guarantees the
99
+ # reader has already exited and closed its read end before the writer's
100
+ # first write() call -- this is not racing the scheduler, it is removing
101
+ # the race.
102
+ #
103
+ # The manifestation of "the writer did not survive" is NOT uniform across
104
+ # dash builds. On this workstation, the write gets SIGPIPE and the whole
105
+ # subshell dies mid-write, so it never reaches the `printf_rc` capture below
106
+ # and the marker file is simply absent -- that used to be the only shape
107
+ # this case checked for. But the CI runner's dash does NOT die to the
108
+ # signal: its printf builtin catches the broken pipe itself, prints
109
+ # "printf: printf: I/O error" to stderr, returns a NONZERO exit status, and
110
+ # the subshell keeps running past it -- there is no `&&`/`set -e` between
111
+ # these statements -- so the marker gets written anyway. Confirmed verbatim
112
+ # in run 34391849474 job 102607880682: line 91 (this line) is exactly where
113
+ # "I/O error" was reported, and the case still failed with "unexpectedly let
114
+ # the writer complete", because the old check only recognised the
115
+ # SIGPIPE-death shape and treated ANY marker as proof the writer silently
116
+ # succeeded -- including one written after printf itself had just reported
117
+ # failure.
118
+ #
119
+ # So the marker now carries printf's own exit status instead of a fixed
120
+ # "wrote", and either manifestation of the same underlying defect counts as
121
+ # reproduced: the writer is killed outright (marker absent), or it survives
122
+ # but printf reports the broken pipe as a failure (marker present with a
123
+ # nonzero code, or stderr non-empty). Only a marker present with an
124
+ # unqualified rc=0 and empty stderr -- meaning the write to an
125
+ # already-closed reader somehow succeeded without a trace -- fails to
126
+ # reproduce the defect this case exists to demonstrate.
127
+ old_marker="$WORK_DIR/old_marker"
128
+ old_stderr="$WORK_DIR/old_stderr"
129
+ (
130
+ sleep 0.5
131
+ printf '%s' "$PAYLOAD"
132
+ printf_rc=$?
133
+ echo "$printf_rc" > "$old_marker"
134
+ ) | { : ; } 2>"$old_stderr"
135
+
136
+ old_rc=""
137
+ if [ -s "$old_marker" ]; then
138
+ old_rc=$(cat "$old_marker")
139
+ fi
140
+
141
+ if [ ! -s "$old_marker" ]; then
142
+ echo "PASS: old pipe shape (writer | early-closing reader) kills the writer before it can report anything -- the defect #1995 reports, reproduced deterministically (writer terminated outright, e.g. by SIGPIPE)"
143
+ elif [ "$old_rc" != "0" ] || [ -s "$old_stderr" ]; then
144
+ echo "PASS: old pipe shape (writer | early-closing reader) lets the writer survive but its own write reports the broken pipe as a failure -- the defect #1995 reports, reproduced deterministically (printf exit=$old_rc, stderr: $(tr '\n' ' ' < "$old_stderr"))"
145
+ else
146
+ echo "FAIL: old pipe shape unexpectedly let the writer complete with no error of any kind -- the defect this case exists to demonstrate did not reproduce"
147
+ FAILURES=$((FAILURES + 1))
148
+ fi
149
+
150
+ # NEW shape: writer completes into a regular file first; the "reader" then
151
+ # reads the FINISHED file, with no concurrent writer left to break. Must
152
+ # always succeed, regardless of payload size or timing.
153
+ new_dump="$WORK_DIR/new_dump"
154
+ printf '%s' "$PAYLOAD" > "$new_dump"
155
+ new_write_rc=$?
156
+ : < "$new_dump"
157
+ new_read_rc=$?
158
+
159
+ if [ "$new_write_rc" -eq 0 ] && [ "$new_read_rc" -eq 0 ]; then
160
+ echo "PASS: new shape (write to a file, then read the finished file) always succeeds -- no live pipe for a reader to close early"
161
+ else
162
+ echo "FAIL: new shape unexpectedly failed (write rc=$new_write_rc, read rc=$new_read_rc)"
163
+ FAILURES=$((FAILURES + 1))
164
+ fi
165
+
166
+ # ============================================================================
167
+ # Case 2: the real script, end to end, against a >4000-byte finding
168
+ # ============================================================================
169
+
170
+ STUB_DIR="$WORK_DIR/stub"
171
+ REPO_DIR="$WORK_DIR/repo"
172
+ OUT_FILE="$WORK_DIR/out"
173
+ mkdir -p "$STUB_DIR" "$REPO_DIR"
174
+
175
+ cat > "$STUB_DIR/uv" <<'STUB'
176
+ #!/usr/bin/env sh
177
+ if [ "$1" = "run" ] && [ "$2" = "pip-audit" ]; then
178
+ cat "$PIPAUDIT_STUB_OUTPUT"
179
+ exit 0
180
+ fi
181
+ echo "uv stub: unexpected invocation: $*" >&2
182
+ exit 99
183
+ STUB
184
+ chmod +x "$STUB_DIR/uv"
185
+
186
+ ( cd "$REPO_DIR" \
187
+ && git init -q -b trunk \
188
+ && git config user.email test@example.com \
189
+ && git config user.name "Test" )
190
+ cat > "$REPO_DIR/uv.lock" <<'LOCK'
191
+ version = 1
192
+ requires-python = ">=3.13"
193
+
194
+ [[package]]
195
+ name = "pip"
196
+ version = "26.1.2"
197
+ source = { registry = "https://pypi.org/simple" }
198
+ LOCK
199
+ ( cd "$REPO_DIR" && git add -A && git commit -q -m base )
200
+ ( cd "$REPO_DIR" && git branch -q "origin/dev" )
201
+
202
+ # A single package carrying enough vulns that jq's filtered dump of it
203
+ # exceeds 4000 bytes -- the exact condition #1995 requires (small payloads
204
+ # never hit the truncation path at all).
205
+ {
206
+ printf '{"dependencies": [{"name": "pip", "version": "26.1.2", "vulns": ['
207
+ i=0
208
+ while [ "$i" -lt 60 ]; do
209
+ [ "$i" -gt 0 ] && printf ','
210
+ printf '{"id": "PYSEC-2026-%d", "fix_versions": ["26.2.1"], "description": "%s"}' \
211
+ "$i" "$(head -c 200 /dev/zero | tr '\0' 'x')"
212
+ i=$((i + 1))
213
+ done
214
+ printf ']}]}'
215
+ } > "$STUB_DIR/pip-audit-output.json"
216
+
217
+ filtered_size=$(cd "$REPO_DIR" && PATH="$STUB_DIR:$PATH" jq -c '[.dependencies[] | select(.vulns | length > 0)]' "$STUB_DIR/pip-audit-output.json" | wc -c | tr -d ' ')
218
+ if [ "$filtered_size" -le 4000 ]; then
219
+ echo "FAIL: test fixture's filtered JSON is only ${filtered_size} bytes -- must exceed 4000 to exercise the truncation path #1995 is about"
220
+ FAILURES=$((FAILURES + 1))
221
+ fi
222
+
223
+ (
224
+ cd "$REPO_DIR" || exit 97
225
+ PATH="$STUB_DIR:$PATH"
226
+ export PATH
227
+ PIPAUDIT_STUB_OUTPUT="$STUB_DIR/pip-audit-output.json"
228
+ export PIPAUDIT_STUB_OUTPUT
229
+ GITHUB_BASE_REF="dev"
230
+ export GITHUB_BASE_REF
231
+ sh "$TARGET"
232
+ ) >"$OUT_FILE" 2>&1
233
+ rc=$?
234
+
235
+ # pip@26.1.2 is unchanged between base and PR-head (#1673 classification),
236
+ # so this must NOT block, same as case 1 in
237
+ # py-dependency-audit-classification.test.sh.
238
+ if [ "$rc" -eq 0 ]; then
239
+ echo "PASS: real script exits 0 on a >4000-byte pre-existing finding"
240
+ else
241
+ echo "FAIL: real script exited ${rc}, expected 0 -- output:"
242
+ cat "$OUT_FILE"
243
+ FAILURES=$((FAILURES + 1))
244
+ fi
245
+
246
+ if grep -qF "pre-existing" "$OUT_FILE"; then
247
+ echo "PASS: real script still classifies the finding as pre-existing"
248
+ else
249
+ echo "FAIL: real script's output does not mention 'pre-existing' -- output:"
250
+ cat "$OUT_FILE"
251
+ FAILURES=$((FAILURES + 1))
252
+ fi
253
+
254
+ out_size=$(wc -c < "$OUT_FILE" | tr -d ' ')
255
+ # The whole run's combined output, not just the dump, so this is a loose
256
+ # ceiling -- it exists to catch a regression that stops truncating at all
257
+ # (e.g. the dump ever growing back to the full ~13KB filtered JSON).
258
+ if [ "$out_size" -lt 8000 ]; then
259
+ echo "PASS: real script's combined output (${out_size} bytes) stays bounded -- the dump is still truncated"
260
+ else
261
+ echo "FAIL: real script's combined output is ${out_size} bytes -- the truncation may no longer be applied"
262
+ FAILURES=$((FAILURES + 1))
263
+ fi
264
+
265
+ echo
266
+ if [ "$FAILURES" -eq 0 ]; then
267
+ echo "py-dependency-audit-truncation.test.sh: all checks passed."
268
+ exit 0
269
+ else
270
+ echo "py-dependency-audit-truncation.test.sh: $FAILURES check(s) failed."
271
+ exit 1
272
+ fi
@@ -231,7 +231,20 @@ audit_deps() {
231
231
  done < "$findings_file"
232
232
  rm -f "$findings_file"
233
233
 
234
- printf '%s' "$out" | jq '[.dependencies[] | select(.vulns | length > 0)]' 2>/dev/null | head -c 4000
234
+ # Written to a regular file first, then truncated with `head -c` from
235
+ # that file rather than from a live pipe -- the same reason
236
+ # `findings_file` above does it. `head -c 4000` piped directly onto
237
+ # `jq`'s output closes its read end the instant it has enough bytes;
238
+ # if the filtered JSON exceeds 4000 bytes, jq (and on a big enough
239
+ # payload, the upstream `printf` still writing into jq's now-closed
240
+ # stdin) can get SIGPIPE and dash's builtin `printf` reports that as a
241
+ # stray "printf: I/O error" line into an otherwise-clean, passing log
242
+ # (#1995). Reading a finished, regular file has no concurrent writer
243
+ # to break, so there is no pipe left for `head` to close early on.
244
+ dump_file="$(mktemp)"
245
+ printf '%s' "$out" | jq '[.dependencies[] | select(.vulns | length > 0)]' 2>/dev/null > "$dump_file"
246
+ head -c 4000 "$dump_file"
247
+ rm -f "$dump_file"
235
248
 
236
249
  if [ "$introduced_count" -gt 0 ]; then
237
250
  echo "::error::${label}: ${introduced_count} vulnerability(ies) introduced or upgraded by this diff (${preexisting_count} more pre-existing, not counted against it)."
@@ -0,0 +1,272 @@
1
+ #!/usr/bin/env sh
2
+ #
3
+ # Proves py-dependency-audit.sh (#1995) no longer pipes a large writer
4
+ # directly into `head -c N`. `head -c` closes its read end the instant it
5
+ # has enough bytes; if the upstream write is still in flight when that
6
+ # happens, the writer gets SIGPIPE. Confirmed live via `strace -f` against
7
+ # the real script's line-234 command (a >4000-byte jq-filtered finding
8
+ # dump): `jq`, forked from that exact pipeline, was silently killed by
9
+ # SIGPIPE the instant `head -c 4000` closed early on a large payload. That
10
+ # is the general mechanism #1995 reports. The CI-only "printf: printf: I/O
11
+ # error" wording it also reports needs dash's own builtin `printf` to be the
12
+ # process still writing at that instant -- a narrower timing window this
13
+ # workstation's dash/kernel did not reproduce across repeated attempts up to
14
+ # a 94MB payload (see the PR body for the full trail). The issue's own
15
+ # citation -- a real, timestamped CI job log
16
+ # (biffo-plugin-idea-scout PR #126) -- is the evidence for that exact
17
+ # wording; this file does not re-derive it.
18
+ #
19
+ # That same "I/O error" shape hit Case 1's own negative control for real, in
20
+ # guard-self-test-wiring.sh's CI run (34391849474, job 102607880682, head
21
+ # eeabce12): the runner's dash does not die to SIGPIPE the way this
22
+ # workstation's does -- its printf builtin catches the broken pipe, reports
23
+ # "printf: printf: I/O error" to stderr, returns a nonzero status, and lets
24
+ # the subshell keep running past it. Case 1's check used to recognise only
25
+ # the SIGPIPE-death shape (marker file absent) and treated any marker
26
+ # present as "the defect didn't reproduce" -- so on that runner it failed
27
+ # for the same reason the fix exists: an outcome the control's own author
28
+ # had not seen. The fix is to the control's DETECTION, not the mechanism
29
+ # under test: it accepts either manifestation of the same defect (killed
30
+ # outright, or survived with a reported failure) as reproduced.
31
+ #
32
+ # ## Case 1: deterministic proof of the pipe-shape defect and its fix
33
+ #
34
+ # Removes scheduling luck from the underlying mechanism instead of racing
35
+ # for it: the writer sleeps briefly before writing, so a reader that exits
36
+ # immediately is GUARANTEED to have already closed its read end before the
37
+ # writer's first write() call. Shows the OLD shape (writer piped straight
38
+ # into a reader that may close early) never gets through cleanly -- either
39
+ # the writer is killed before it can report anything, or it survives but its
40
+ # own write reports the broken pipe as a failure -- and the NEW shape this
41
+ # fix uses (write to a regular file first, read the FINISHED file) never
42
+ # does either, regardless of payload size or timing -- because there is no
43
+ # concurrent writer left for a reader to break.
44
+ #
45
+ # ## Case 2: the real script, end to end, with a >4000-byte finding
46
+ #
47
+ # Reuses the uv-stub harness from py-dependency-audit-classification.test.sh
48
+ # to run the actual target script against a finding payload whose filtered
49
+ # JSON exceeds the 4000-byte truncation threshold, and checks the
50
+ # classification/exit-code behaviour and the truncated dump are unaffected
51
+ # by the fix.
52
+ #
53
+ # Run: sh scripts/py-dependency-audit-truncation.test.sh
54
+
55
+ set -u
56
+
57
+ SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
58
+ TARGET="$SCRIPT_DIR/py-dependency-audit.sh"
59
+
60
+ WORK_DIR=$(mktemp -d)
61
+ trap 'rm -rf "$WORK_DIR"' EXIT
62
+
63
+ FAILURES=0
64
+
65
+ # ============================================================================
66
+ # Case 0: the target script actually uses the safe shape
67
+ # ============================================================================
68
+ #
69
+ # Case 1 below proves the OLD shape is unsafe and the NEW shape is not, as a
70
+ # standalone fact about pipes -- it does not read $TARGET, so on its own it
71
+ # would not notice a regression that reverted the real script back to the
72
+ # vulnerable pattern. This case closes that gap directly against the file.
73
+
74
+ if grep -Eq "jq '\[\.dependencies\[\] \| select\(\.vulns \| length > 0\)\]' 2>/dev/null \| head -c" "$TARGET"; then
75
+ echo "FAIL: $TARGET pipes the vulns dump straight from jq into head -c again -- this is the exact shape #1995 reports"
76
+ FAILURES=$((FAILURES + 1))
77
+ else
78
+ echo "PASS: $TARGET does not pipe the vulns dump straight from jq into head -c"
79
+ fi
80
+
81
+ if grep -q 'head -c 4000 "\$dump_file"' "$TARGET"; then
82
+ echo "PASS: $TARGET truncates from a finished regular file, not a live pipe"
83
+ else
84
+ echo "FAIL: $TARGET no longer truncates from a regular file -- expected a 'head -c 4000 \"\$dump_file\"' line"
85
+ FAILURES=$((FAILURES + 1))
86
+ fi
87
+
88
+ # ============================================================================
89
+ # Case 1: deterministic pipe-shape proof
90
+ # ============================================================================
91
+
92
+ # 2MB of 'a' -- comfortably over the 64KiB Linux pipe buffer, so a writer
93
+ # emitting it in one shot cannot fit in the buffer and must block on a
94
+ # reader that may already be gone.
95
+ PAYLOAD="$(head -c 2000000 /dev/zero | tr '\0' 'a')"
96
+
97
+ # OLD shape: writer piped straight into an early-closing reader (`{ : ; }`
98
+ # reads nothing and exits immediately). The `sleep 0.5` guarantees the
99
+ # reader has already exited and closed its read end before the writer's
100
+ # first write() call -- this is not racing the scheduler, it is removing
101
+ # the race.
102
+ #
103
+ # The manifestation of "the writer did not survive" is NOT uniform across
104
+ # dash builds. On this workstation, the write gets SIGPIPE and the whole
105
+ # subshell dies mid-write, so it never reaches the `printf_rc` capture below
106
+ # and the marker file is simply absent -- that used to be the only shape
107
+ # this case checked for. But the CI runner's dash does NOT die to the
108
+ # signal: its printf builtin catches the broken pipe itself, prints
109
+ # "printf: printf: I/O error" to stderr, returns a NONZERO exit status, and
110
+ # the subshell keeps running past it -- there is no `&&`/`set -e` between
111
+ # these statements -- so the marker gets written anyway. Confirmed verbatim
112
+ # in run 34391849474 job 102607880682: line 91 (this line) is exactly where
113
+ # "I/O error" was reported, and the case still failed with "unexpectedly let
114
+ # the writer complete", because the old check only recognised the
115
+ # SIGPIPE-death shape and treated ANY marker as proof the writer silently
116
+ # succeeded -- including one written after printf itself had just reported
117
+ # failure.
118
+ #
119
+ # So the marker now carries printf's own exit status instead of a fixed
120
+ # "wrote", and either manifestation of the same underlying defect counts as
121
+ # reproduced: the writer is killed outright (marker absent), or it survives
122
+ # but printf reports the broken pipe as a failure (marker present with a
123
+ # nonzero code, or stderr non-empty). Only a marker present with an
124
+ # unqualified rc=0 and empty stderr -- meaning the write to an
125
+ # already-closed reader somehow succeeded without a trace -- fails to
126
+ # reproduce the defect this case exists to demonstrate.
127
+ old_marker="$WORK_DIR/old_marker"
128
+ old_stderr="$WORK_DIR/old_stderr"
129
+ (
130
+ sleep 0.5
131
+ printf '%s' "$PAYLOAD"
132
+ printf_rc=$?
133
+ echo "$printf_rc" > "$old_marker"
134
+ ) | { : ; } 2>"$old_stderr"
135
+
136
+ old_rc=""
137
+ if [ -s "$old_marker" ]; then
138
+ old_rc=$(cat "$old_marker")
139
+ fi
140
+
141
+ if [ ! -s "$old_marker" ]; then
142
+ echo "PASS: old pipe shape (writer | early-closing reader) kills the writer before it can report anything -- the defect #1995 reports, reproduced deterministically (writer terminated outright, e.g. by SIGPIPE)"
143
+ elif [ "$old_rc" != "0" ] || [ -s "$old_stderr" ]; then
144
+ echo "PASS: old pipe shape (writer | early-closing reader) lets the writer survive but its own write reports the broken pipe as a failure -- the defect #1995 reports, reproduced deterministically (printf exit=$old_rc, stderr: $(tr '\n' ' ' < "$old_stderr"))"
145
+ else
146
+ echo "FAIL: old pipe shape unexpectedly let the writer complete with no error of any kind -- the defect this case exists to demonstrate did not reproduce"
147
+ FAILURES=$((FAILURES + 1))
148
+ fi
149
+
150
+ # NEW shape: writer completes into a regular file first; the "reader" then
151
+ # reads the FINISHED file, with no concurrent writer left to break. Must
152
+ # always succeed, regardless of payload size or timing.
153
+ new_dump="$WORK_DIR/new_dump"
154
+ printf '%s' "$PAYLOAD" > "$new_dump"
155
+ new_write_rc=$?
156
+ : < "$new_dump"
157
+ new_read_rc=$?
158
+
159
+ if [ "$new_write_rc" -eq 0 ] && [ "$new_read_rc" -eq 0 ]; then
160
+ echo "PASS: new shape (write to a file, then read the finished file) always succeeds -- no live pipe for a reader to close early"
161
+ else
162
+ echo "FAIL: new shape unexpectedly failed (write rc=$new_write_rc, read rc=$new_read_rc)"
163
+ FAILURES=$((FAILURES + 1))
164
+ fi
165
+
166
+ # ============================================================================
167
+ # Case 2: the real script, end to end, against a >4000-byte finding
168
+ # ============================================================================
169
+
170
+ STUB_DIR="$WORK_DIR/stub"
171
+ REPO_DIR="$WORK_DIR/repo"
172
+ OUT_FILE="$WORK_DIR/out"
173
+ mkdir -p "$STUB_DIR" "$REPO_DIR"
174
+
175
+ cat > "$STUB_DIR/uv" <<'STUB'
176
+ #!/usr/bin/env sh
177
+ if [ "$1" = "run" ] && [ "$2" = "pip-audit" ]; then
178
+ cat "$PIPAUDIT_STUB_OUTPUT"
179
+ exit 0
180
+ fi
181
+ echo "uv stub: unexpected invocation: $*" >&2
182
+ exit 99
183
+ STUB
184
+ chmod +x "$STUB_DIR/uv"
185
+
186
+ ( cd "$REPO_DIR" \
187
+ && git init -q -b trunk \
188
+ && git config user.email test@example.com \
189
+ && git config user.name "Test" )
190
+ cat > "$REPO_DIR/uv.lock" <<'LOCK'
191
+ version = 1
192
+ requires-python = ">=3.13"
193
+
194
+ [[package]]
195
+ name = "pip"
196
+ version = "26.1.2"
197
+ source = { registry = "https://pypi.org/simple" }
198
+ LOCK
199
+ ( cd "$REPO_DIR" && git add -A && git commit -q -m base )
200
+ ( cd "$REPO_DIR" && git branch -q "origin/dev" )
201
+
202
+ # A single package carrying enough vulns that jq's filtered dump of it
203
+ # exceeds 4000 bytes -- the exact condition #1995 requires (small payloads
204
+ # never hit the truncation path at all).
205
+ {
206
+ printf '{"dependencies": [{"name": "pip", "version": "26.1.2", "vulns": ['
207
+ i=0
208
+ while [ "$i" -lt 60 ]; do
209
+ [ "$i" -gt 0 ] && printf ','
210
+ printf '{"id": "PYSEC-2026-%d", "fix_versions": ["26.2.1"], "description": "%s"}' \
211
+ "$i" "$(head -c 200 /dev/zero | tr '\0' 'x')"
212
+ i=$((i + 1))
213
+ done
214
+ printf ']}]}'
215
+ } > "$STUB_DIR/pip-audit-output.json"
216
+
217
+ filtered_size=$(cd "$REPO_DIR" && PATH="$STUB_DIR:$PATH" jq -c '[.dependencies[] | select(.vulns | length > 0)]' "$STUB_DIR/pip-audit-output.json" | wc -c | tr -d ' ')
218
+ if [ "$filtered_size" -le 4000 ]; then
219
+ echo "FAIL: test fixture's filtered JSON is only ${filtered_size} bytes -- must exceed 4000 to exercise the truncation path #1995 is about"
220
+ FAILURES=$((FAILURES + 1))
221
+ fi
222
+
223
+ (
224
+ cd "$REPO_DIR" || exit 97
225
+ PATH="$STUB_DIR:$PATH"
226
+ export PATH
227
+ PIPAUDIT_STUB_OUTPUT="$STUB_DIR/pip-audit-output.json"
228
+ export PIPAUDIT_STUB_OUTPUT
229
+ GITHUB_BASE_REF="dev"
230
+ export GITHUB_BASE_REF
231
+ sh "$TARGET"
232
+ ) >"$OUT_FILE" 2>&1
233
+ rc=$?
234
+
235
+ # pip@26.1.2 is unchanged between base and PR-head (#1673 classification),
236
+ # so this must NOT block, same as case 1 in
237
+ # py-dependency-audit-classification.test.sh.
238
+ if [ "$rc" -eq 0 ]; then
239
+ echo "PASS: real script exits 0 on a >4000-byte pre-existing finding"
240
+ else
241
+ echo "FAIL: real script exited ${rc}, expected 0 -- output:"
242
+ cat "$OUT_FILE"
243
+ FAILURES=$((FAILURES + 1))
244
+ fi
245
+
246
+ if grep -qF "pre-existing" "$OUT_FILE"; then
247
+ echo "PASS: real script still classifies the finding as pre-existing"
248
+ else
249
+ echo "FAIL: real script's output does not mention 'pre-existing' -- output:"
250
+ cat "$OUT_FILE"
251
+ FAILURES=$((FAILURES + 1))
252
+ fi
253
+
254
+ out_size=$(wc -c < "$OUT_FILE" | tr -d ' ')
255
+ # The whole run's combined output, not just the dump, so this is a loose
256
+ # ceiling -- it exists to catch a regression that stops truncating at all
257
+ # (e.g. the dump ever growing back to the full ~13KB filtered JSON).
258
+ if [ "$out_size" -lt 8000 ]; then
259
+ echo "PASS: real script's combined output (${out_size} bytes) stays bounded -- the dump is still truncated"
260
+ else
261
+ echo "FAIL: real script's combined output is ${out_size} bytes -- the truncation may no longer be applied"
262
+ FAILURES=$((FAILURES + 1))
263
+ fi
264
+
265
+ echo
266
+ if [ "$FAILURES" -eq 0 ]; then
267
+ echo "py-dependency-audit-truncation.test.sh: all checks passed."
268
+ exit 0
269
+ else
270
+ echo "py-dependency-audit-truncation.test.sh: $FAILURES check(s) failed."
271
+ exit 1
272
+ fi
@@ -231,7 +231,20 @@ audit_deps() {
231
231
  done < "$findings_file"
232
232
  rm -f "$findings_file"
233
233
 
234
- printf '%s' "$out" | jq '[.dependencies[] | select(.vulns | length > 0)]' 2>/dev/null | head -c 4000
234
+ # Written to a regular file first, then truncated with `head -c` from
235
+ # that file rather than from a live pipe -- the same reason
236
+ # `findings_file` above does it. `head -c 4000` piped directly onto
237
+ # `jq`'s output closes its read end the instant it has enough bytes;
238
+ # if the filtered JSON exceeds 4000 bytes, jq (and on a big enough
239
+ # payload, the upstream `printf` still writing into jq's now-closed
240
+ # stdin) can get SIGPIPE and dash's builtin `printf` reports that as a
241
+ # stray "printf: I/O error" line into an otherwise-clean, passing log
242
+ # (#1995). Reading a finished, regular file has no concurrent writer
243
+ # to break, so there is no pipe left for `head` to close early on.
244
+ dump_file="$(mktemp)"
245
+ printf '%s' "$out" | jq '[.dependencies[] | select(.vulns | length > 0)]' 2>/dev/null > "$dump_file"
246
+ head -c 4000 "$dump_file"
247
+ rm -f "$dump_file"
235
248
 
236
249
  if [ "$introduced_count" -gt 0 ]; then
237
250
  echo "::error::${label}: ${introduced_count} vulnerability(ies) introduced or upgraded by this diff (${preexisting_count} more pre-existing, not counted against it)."
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@biffo/cli",
3
- "version": "0.315.2",
3
+ "version": "0.315.4",
4
4
  "description": "Biffo project scaffolding CLI",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -35,8 +35,9 @@
35
35
  # `verify.sh` does: forks drift, and a per-instance copy of this would drift from
36
36
  # the DDL layout it is meant to build. Everything instance-specific is DERIVED --
37
37
  # the schema directories from `db/imports/*/`, the engine image from whether the
38
- # DDL asks for PostGIS, and the did-it-build threshold from the number of
39
- # policies the DDL itself declares. Nothing here names a product.
38
+ # DDL asks for PostGIS, and the did-it-build check from what THIS repo's own
39
+ # last known-good build actually produced (see #2023 below), never from a
40
+ # guessed ratio. Nothing here names a product.
40
41
  #
41
42
  # ## Usage
42
43
  #
@@ -557,20 +558,63 @@ fingerprint() {
557
558
 
558
559
  WANT=$(fingerprint)
559
560
  HAVE=""
561
+ HAVE_POLICIES=""
562
+ HAVE_MODULES=""
560
563
  if [ "$RECREATE" -eq 0 ] &&
561
564
  psql_admin -tAc "SELECT 1 FROM pg_database WHERE datname='$DB'" 2>/dev/null | grep -q 1; then
562
565
  HAVE=$(psql -tAq -h "$HOST" -p "$PORT" -U "$USER_" -d "$DB" \
563
566
  -c "SELECT value FROM biffo_pg_test_fingerprint LIMIT 1" 2>/dev/null || true)
567
+ # policy_count/module_count were added alongside this guard (#2023). A row
568
+ # written before that migration has neither column, so this query fails
569
+ # (unknown column) rather than returning NULL, and `|| true` turns that
570
+ # failure into the same empty string a genuinely missing value would give --
571
+ # which is exactly what "unverifiable, so rebuild" needs below: it must read
572
+ # identically to "no stored count", never silently as "verified".
573
+ HAVE_POLICIES=$(psql -tAq -h "$HOST" -p "$PORT" -U "$USER_" -d "$DB" \
574
+ -c "SELECT policy_count FROM biffo_pg_test_fingerprint LIMIT 1" 2>/dev/null || true)
575
+ HAVE_MODULES=$(psql -tAq -h "$HOST" -p "$PORT" -U "$USER_" -d "$DB" \
576
+ -c "SELECT module_count FROM biffo_pg_test_fingerprint LIMIT 1" 2>/dev/null || true)
564
577
  fi
565
578
 
579
+ # ── #2023: reuse must be verified against a known-good build, not a guess ────
580
+ #
581
+ # The old guard compared the live policy count to HALF of what the DDL
582
+ # *declares* (`grep -c CREATE POLICY`) -- a number a correct, complete build
583
+ # never reaches (521 declared vs. 362 produced on tabsii-platform, so the
584
+ # healthy ratio is ~0.70, not 1.0). A database missing a quarter of its schema
585
+ # passed it and was then blessed with a fingerprint every later run trusted.
586
+ #
587
+ # Worse, that check only ever ran on a fresh REBUILD (old section 4 below). The
588
+ # reuse path here never re-verified anything beyond the content hash matching --
589
+ # so a template that drifted after being blessed, by any means, was reused
590
+ # forever with no check of its own at all.
591
+ #
592
+ # The fix compares against reality instead of a guess: on every successful
593
+ # build (section 4), THIS script records the counts that build actually
594
+ # produced. A fingerprint match only says the DDL inputs are unchanged; it says
595
+ # nothing about whether the database in front of us still reflects what was
596
+ # built from them. So reuse additionally requires the live counts to equal the
597
+ # stored ones, exactly -- and a row with no stored counts (pre-#2023) is
598
+ # unverifiable, not innocent, so it rebuilds rather than being trusted.
566
599
  if [ -n "$HAVE" ] && [ "$HAVE" = "$WANT" ]; then
567
- say "schema is current, reusing $DB"
568
- clone_for_this_run
569
- emit
570
- exit 0
600
+ if [ -z "$HAVE_POLICIES" ] || [ -z "$HAVE_MODULES" ]; then
601
+ say "fingerprint matches but this row predates stored policy/module counts - unverifiable, rebuilding rather than trusting it"
602
+ else
603
+ _live_policies=$(psql -tAq -h "$HOST" -p "$PORT" -U "$USER_" -d "$DB" \
604
+ -c "SELECT count(*) FROM pg_policies" 2>/dev/null || echo 0)
605
+ _live_modules=0
606
+ [ -n "$DDL_FILES" ] && _live_modules=$(echo "$DDL_FILES" | wc -l | tr -d ' ')
607
+ if [ "${_live_policies:-0}" = "$HAVE_POLICIES" ] && [ "$_live_modules" = "$HAVE_MODULES" ]; then
608
+ say "schema is current ($_live_policies policies, $_live_modules modules), reusing $DB"
609
+ clone_for_this_run
610
+ emit
611
+ exit 0
612
+ fi
613
+ say "fingerprint matches but live schema diverged from its own record (have $_live_policies policies/$_live_modules modules, recorded $HAVE_POLICIES/$HAVE_MODULES) - rebuilding rather than reusing a partial schema"
614
+ fi
571
615
  fi
572
616
 
573
- [ -n "$HAVE" ] && say "schema inputs changed - rebuilding rather than serving a stale schema"
617
+ [ -n "$HAVE" ] && [ "$HAVE" != "$WANT" ] && say "schema inputs changed - rebuilding rather than serving a stale schema"
574
618
 
575
619
  # --- 3. rebuild the way the app and CI do ------------------------------------
576
620
  say "rebuilding $DB"
@@ -589,6 +633,7 @@ if [ -n "$ALEMBIC_DIR" ]; then
589
633
  say "alembic upgrade head"
590
634
  fi
591
635
 
636
+ _module_count=0
592
637
  if [ -n "$DDL_FILES" ]; then
593
638
  # ONE psql session, sorted by filename, mirroring the API's own DDL import.
594
639
  # Session state an early module sets -- typically `SET search_path` in the
@@ -598,38 +643,37 @@ if [ -n "$DDL_FILES" ]; then
598
643
  # shellcheck disable=SC2046
599
644
  psql -q -v ON_ERROR_STOP=1 -h "$HOST" -p "$PORT" -U "$USER_" -d "$DB" \
600
645
  --single-transaction $(echo "$DDL_FILES" | sed 's/^/-f /' | tr '\n' ' ') >/dev/null
601
- say "$(echo "$DDL_FILES" | wc -l | tr -d ' ') DDL modules applied"
646
+ _module_count=$(echo "$DDL_FILES" | wc -l | tr -d ' ')
647
+ say "$_module_count DDL modules applied"
602
648
  fi
603
649
 
604
- # --- 4. refuse to bless a half-built schema ----------------------------------
605
- #
606
- # The threshold is derived, not guessed: count the policies the DDL declares and
607
- # require the database to hold at least half. Recording a fingerprint against a
608
- # partial schema is worse than failing, because the NEXT run would trust it and
609
- # every failure after that would look like the developer's own change.
650
+ # --- 4. record what this build actually produced (#2023) ---------------------
651
+ #
652
+ # No guessed threshold here anymore. The DDL apply above is one
653
+ # `--single-transaction`, `ON_ERROR_STOP=1` psql session, so a build that fails
654
+ # partway already aborts the whole script before reaching this line -- it does
655
+ # not limp to here half-applied. What DOES reach here is simply recorded, and
656
+ # it is that RECORD -- this build's own live counts, not a guess at what they
657
+ # "should" be -- that the reuse guard above checks every later database
658
+ # against. See the comment there for why a guessed ratio (521 declared, 362
659
+ # produced) blessed a schema missing a quarter of itself.
660
+ _policy_count=0
610
661
  if [ -n "$DDL_FILES" ]; then
611
- _declared=$(echo "$DDL_FILES" | xargs grep -ciE '^[[:space:]]*CREATE[[:space:]]+POLICY' 2>/dev/null |
612
- awk -F: '{s+=$NF} END {print s+0}')
613
- if [ "${_declared:-0}" -gt 0 ]; then
614
- _actual=$(psql -tAq -h "$HOST" -p "$PORT" -U "$USER_" -d "$DB" \
615
- -c "SELECT count(*) FROM pg_policies" 2>/dev/null || echo 0)
616
- if [ "${_actual:-0}" -lt $((_declared / 2)) ]; then
617
- say "only ${_actual:-0} policies present against $_declared declared - the schema did not build."
618
- say "Not recording a fingerprint; fix the DDL and re-run."
619
- exit 1
620
- fi
621
- say "$_actual RLS policies ($_declared declared)"
622
- fi
662
+ _policy_count=$(psql -tAq -h "$HOST" -p "$PORT" -U "$USER_" -d "$DB" \
663
+ -c "SELECT count(*) FROM pg_policies" 2>/dev/null || echo 0)
664
+ say "$_policy_count RLS policies, $_module_count DDL modules applied"
623
665
  fi
624
666
 
625
667
  psql_db \
626
668
  -c "CREATE TABLE IF NOT EXISTS biffo_pg_test_fingerprint (value text primary key)" \
669
+ -c "ALTER TABLE biffo_pg_test_fingerprint ADD COLUMN IF NOT EXISTS policy_count integer" \
670
+ -c "ALTER TABLE biffo_pg_test_fingerprint ADD COLUMN IF NOT EXISTS module_count integer" \
627
671
  -c "TRUNCATE biffo_pg_test_fingerprint" \
628
- -c "INSERT INTO biffo_pg_test_fingerprint (value) VALUES ('$WANT')" >/dev/null
672
+ -c "INSERT INTO biffo_pg_test_fingerprint (value, policy_count, module_count) VALUES ('$WANT', $_policy_count, $_module_count)" >/dev/null
629
673
 
630
- # Only now, with the fingerprint recorded against a schema that passed the
631
- # check above, is the template fit to copy. Cloning earlier would hand out a
632
- # half-built database and record the failure against whoever ran next.
674
+ # Only now, with the fingerprint and its counts recorded against a build that
675
+ # actually completed, is the template fit to copy. Cloning earlier would hand
676
+ # out a half-built database and record the failure against whoever ran next.
633
677
  clone_for_this_run
634
678
 
635
679
  say "ready"