@biffo/cli 0.177.0 → 0.177.2
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/.github/workflows/ci.yml +11 -1
- package/_skeletons/plugin-template/scripts/js-dependency-audit.sh +133 -0
- package/_skeletons/plugin-template/scripts/py-dependency-audit.sh +145 -0
- package/_skeletons/sibling-template/.github/workflows/ci.yml +18 -2
- package/_skeletons/sibling-template/scripts/js-dependency-audit.sh +133 -0
- package/_skeletons/sibling-template/scripts/py-dependency-audit.sh +145 -0
- package/package.json +1 -1
|
@@ -148,4 +148,14 @@ jobs:
|
|
|
148
148
|
with:
|
|
149
149
|
enable-cache: true
|
|
150
150
|
- run: uv sync --all-groups
|
|
151
|
-
|
|
151
|
+
# Fails on a real advisory, but treats an unreachable PyPI/OSV as
|
|
152
|
+
# INCONCLUSIVE rather than reddening a required check for an
|
|
153
|
+
# infrastructure hiccup (#591, #743). The raw `uv run pip-audit` this
|
|
154
|
+
# replaces exits non-zero identically whether it found a vulnerability or
|
|
155
|
+
# simply could not reach the advisory database.
|
|
156
|
+
#
|
|
157
|
+
# The script is byte-identical to biffo-template's copy and to every other
|
|
158
|
+
# satellite's, distributed by that repo's shared-sync.sh. Do not edit it
|
|
159
|
+
# here; edit it upstream.
|
|
160
|
+
- name: Dependency audit
|
|
161
|
+
run: sh scripts/py-dependency-audit.sh
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
#
|
|
3
|
+
# JS dependency audit that fails the build on a real high/critical advisory but
|
|
4
|
+
# NOT on a broken audit registry (#591).
|
|
5
|
+
#
|
|
6
|
+
# `pnpm audit --audit-level=high` exits non-zero identically whether it found a
|
|
7
|
+
# vulnerability OR simply couldn't parse the registry's response — and the npm
|
|
8
|
+
# audit endpoint intermittently returns a non-JSON/gzip body pnpm chokes on
|
|
9
|
+
# ("Unexpected token, is not valid JSON"), which then fails the required JS check
|
|
10
|
+
# on every open PR at once, blocking the whole merge queue for an infrastructure
|
|
11
|
+
# hiccup that has nothing to do with the PR.
|
|
12
|
+
#
|
|
13
|
+
# `pnpm audit --json` disambiguates: a real run yields `.metadata.vulnerabilities`
|
|
14
|
+
# (counts by severity); a registry/parse failure yields `.error`. So we fail only
|
|
15
|
+
# on a genuine high+critical finding, retry a transient registry error a few
|
|
16
|
+
# times, and treat a persistent one as INCONCLUSIVE (warn, don't block) rather
|
|
17
|
+
# than as a vulnerability. "Couldn't run the check" and "the check found a
|
|
18
|
+
# problem" must not be the same signal on a required gate.
|
|
19
|
+
#
|
|
20
|
+
# ## Skeleton lockfiles are audited too (#644)
|
|
21
|
+
#
|
|
22
|
+
# `_skeletons/**/` are scaffolding trees: `biffo sibling create` / `plugin
|
|
23
|
+
# create` copy them verbatim into a brand-new repo. They sit OUTSIDE the pnpm
|
|
24
|
+
# workspace, so `pnpm install` never installs them and a workspace-scoped audit
|
|
25
|
+
# never sees them — which meant every sibling scaffolded from this template was
|
|
26
|
+
# born with four high-severity advisories nobody could see. Dependabot found
|
|
27
|
+
# them; the required JS gate could not, by construction.
|
|
28
|
+
#
|
|
29
|
+
# That is the fail-open shape this file already exists to fight, one level up:
|
|
30
|
+
# the gate was green because it was not looking, and "we checked and it is
|
|
31
|
+
# clean" read identically to "we never checked".
|
|
32
|
+
#
|
|
33
|
+
# ## This file is distributed verbatim (#743)
|
|
34
|
+
#
|
|
35
|
+
# Both skeletons and every existing sibling/plugin repo hold a BYTE-IDENTICAL
|
|
36
|
+
# copy, listed in `shared-files.json` and pushed by `scripts/shared-sync.sh`.
|
|
37
|
+
# Until then the hardening above lived in this repo only, while both skeletons
|
|
38
|
+
# shipped the raw `pnpm audit --audit-level=high` that #591 was filed about — so
|
|
39
|
+
# every satellite was born with the original defect and reddened its required JS
|
|
40
|
+
# check on any registry hiccup.
|
|
41
|
+
#
|
|
42
|
+
# It therefore takes its target from the CURRENT WORKING DIRECTORY — the CI
|
|
43
|
+
# job's `working-directory` — rather than assuming a repo layout: `.` is the
|
|
44
|
+
# root workspace here, `apps/frontend` in a sibling, and the `_skeletons/**`
|
|
45
|
+
# sweep below simply finds nothing outside this repo. Keep it layout-agnostic,
|
|
46
|
+
# or the copies stop being interchangeable and `shared-sync.sh --check` starts
|
|
47
|
+
# reporting drift that is really divergence.
|
|
48
|
+
#
|
|
49
|
+
# POSIX sh (the CI step runs `sh scripts/...`, i.e. dash) — no `pipefail`.
|
|
50
|
+
set -u
|
|
51
|
+
|
|
52
|
+
# jq is the parser this whole distinction rests on. Without it EVERY response
|
|
53
|
+
# reads as unparseable, so the script reports INCONCLUSIVE on every invocation
|
|
54
|
+
# and the gate goes green while scanning nothing — the exact fail-open shape it
|
|
55
|
+
# exists to prevent, and precisely what the dash `echo` defect did until #717. A
|
|
56
|
+
# missing jq is a deterministic environment defect, not a transient registry
|
|
57
|
+
# hiccup, so it fails loudly instead of degrading into a permanent pass.
|
|
58
|
+
if ! command -v jq >/dev/null 2>&1; then
|
|
59
|
+
echo "::error::jq is not installed on this runner. The dependency audit cannot tell a clean run from an unparseable one without it, and would report INCONCLUSIVE forever — a gate that is green because it never ran. Install jq."
|
|
60
|
+
exit 1
|
|
61
|
+
fi
|
|
62
|
+
|
|
63
|
+
attempts=3
|
|
64
|
+
inconclusive=0
|
|
65
|
+
failed=0
|
|
66
|
+
|
|
67
|
+
# Audit one directory. Returns 0 if clean or inconclusive, 1 on a real finding.
|
|
68
|
+
# `$1` is the directory, `$2` a human label, `$3` extra pnpm flags.
|
|
69
|
+
audit_dir() {
|
|
70
|
+
dir="$1"
|
|
71
|
+
label="$2"
|
|
72
|
+
extra="$3"
|
|
73
|
+
|
|
74
|
+
for attempt in $(seq 1 "$attempts"); do
|
|
75
|
+
# printf, never echo: the CI step runs `sh scripts/...` i.e. dash, whose
|
|
76
|
+
# `echo` interprets backslash escapes. Advisory payloads contain them, so
|
|
77
|
+
# `echo "$out" | jq` mangles the JSON and jq rejects it — the run then reads
|
|
78
|
+
# as "the registry returned junk" and reports INCONCLUSIVE. That is this
|
|
79
|
+
# gate failing open inside the very fix that exists to stop it failing open
|
|
80
|
+
# (#591): green, every time, while scanning nothing.
|
|
81
|
+
#
|
|
82
|
+
# The flag differs by tree, and both directions matter:
|
|
83
|
+
# - skeletons need --ignore-workspace, or pnpm walks up, finds this repo's
|
|
84
|
+
# workspace root and audits THAT instead — reporting a clean result for
|
|
85
|
+
# a tree it never looked at, which is the failure this step closes.
|
|
86
|
+
# - the workspace must NOT have it, or pnpm has no project to audit and
|
|
87
|
+
# every run reports INCONCLUSIVE. That fails open: the gate goes green
|
|
88
|
+
# forever while scanning nothing. (Caught by running this script before
|
|
89
|
+
# trusting it — the workspace audit had silently stopped working.)
|
|
90
|
+
# shellcheck disable=SC2086
|
|
91
|
+
out="$(cd "$dir" 2>/dev/null && pnpm audit --json $extra 2>/dev/null)"
|
|
92
|
+
|
|
93
|
+
if printf '%s' "$out" | jq -e '.metadata.vulnerabilities' >/dev/null 2>&1; then
|
|
94
|
+
high="$(printf '%s' "$out" | jq '.metadata.vulnerabilities.high // 0')"
|
|
95
|
+
crit="$(printf '%s' "$out" | jq '.metadata.vulnerabilities.critical // 0')"
|
|
96
|
+
if [ "$((high + crit))" -gt 0 ]; then
|
|
97
|
+
echo "::error::${label}: ${crit} critical + ${high} high advisory(ies)."
|
|
98
|
+
printf '%s' "$out" | jq '.advisories // .metadata.vulnerabilities' 2>/dev/null | head -c 4000
|
|
99
|
+
return 1
|
|
100
|
+
fi
|
|
101
|
+
echo "${label}: no high/critical advisories."
|
|
102
|
+
return 0
|
|
103
|
+
fi
|
|
104
|
+
|
|
105
|
+
msg="$(printf '%s' "$out" | jq -r '.error.message // "no parseable audit output"' 2>/dev/null | head -c 200)"
|
|
106
|
+
echo "${label}: attempt ${attempt}/${attempts} could not run: ${msg}"
|
|
107
|
+
[ "$attempt" -lt "$attempts" ] && sleep "$((attempt * 3))"
|
|
108
|
+
done
|
|
109
|
+
|
|
110
|
+
echo "::warning::${label}: audit could not run after ${attempts} attempts (the registry returned a non-JSON/error response); treating as INCONCLUSIVE and not blocking. Advisory scanning was NOT performed for this tree — see #591."
|
|
111
|
+
inconclusive=$((inconclusive + 1))
|
|
112
|
+
return 0
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
# The workspace itself.
|
|
116
|
+
audit_dir "." "pnpm audit (workspace)" "" || failed=1
|
|
117
|
+
|
|
118
|
+
# Every scaffolding tree with its own lockfile. Discovered rather than listed,
|
|
119
|
+
# so a new skeleton is covered the day it lands instead of the day someone
|
|
120
|
+
# remembers to add it here.
|
|
121
|
+
for lock in $(find _skeletons -name pnpm-lock.yaml -not -path '*/node_modules/*' 2>/dev/null | sort); do
|
|
122
|
+
audit_dir "$(dirname "$lock")" "pnpm audit ($(dirname "$lock"))" "--ignore-workspace" || failed=1
|
|
123
|
+
done
|
|
124
|
+
|
|
125
|
+
if [ "$failed" -ne 0 ]; then
|
|
126
|
+
exit 1
|
|
127
|
+
fi
|
|
128
|
+
|
|
129
|
+
if [ "$inconclusive" -ne 0 ]; then
|
|
130
|
+
echo "${inconclusive} tree(s) could not be audited; see warnings above."
|
|
131
|
+
fi
|
|
132
|
+
|
|
133
|
+
exit 0
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
#
|
|
3
|
+
# Python dependency audit that fails the build on a real advisory but NOT on a
|
|
4
|
+
# broken registry/network response (#591) — same class of flake as the JS
|
|
5
|
+
# dependency audit (scripts/js-dependency-audit.sh), applied preventively here
|
|
6
|
+
# since pip-audit queries PyPI/OSV over the network per dependency and is
|
|
7
|
+
# subject to the same kind of transient hiccup.
|
|
8
|
+
#
|
|
9
|
+
# `pip-audit -f json` emits a `{"dependencies": [{"name", "version", "vulns"},
|
|
10
|
+
# ...]}` shape on a real run; a network/parse failure produces no parseable
|
|
11
|
+
# JSON at all (traceback on stderr, non-zero exit, empty/partial stdout). So we
|
|
12
|
+
# fail only when the parsed output actually contains a vulnerability, retry a
|
|
13
|
+
# few times on an unparseable run, and treat a persistent failure as
|
|
14
|
+
# INCONCLUSIVE (warn, don't block) rather than as a vulnerability.
|
|
15
|
+
# "Couldn't run the check" and "the check found a problem" must not be the same
|
|
16
|
+
# signal on a required gate.
|
|
17
|
+
#
|
|
18
|
+
# ## Skeleton lockfiles are audited too (#719)
|
|
19
|
+
#
|
|
20
|
+
# `_skeletons/**/` are scaffolding trees: `biffo sibling create` / `plugin
|
|
21
|
+
# create` copy them verbatim into a brand-new repo. They are NOT part of this
|
|
22
|
+
# repo's uv project, so `uv sync` never installs them and a workspace-scoped
|
|
23
|
+
# `uv run pip-audit` never saw them — which meant the Python half of every
|
|
24
|
+
# scaffolded sibling came from a lockfile no CI gate had ever looked at.
|
|
25
|
+
#
|
|
26
|
+
# #644 closed exactly this hole for the JS skeleton lockfile, where it had
|
|
27
|
+
# already cost four unseen high-severity advisories; this is the same hole one
|
|
28
|
+
# language over. It is the fail-open shape this file already exists to fight,
|
|
29
|
+
# one level up: the gate was green because it was not looking, and "we checked
|
|
30
|
+
# and it is clean" read identically to "we never checked".
|
|
31
|
+
#
|
|
32
|
+
# Unlike `pnpm audit --ignore-workspace`, pip-audit cannot simply be run inside
|
|
33
|
+
# a skeleton directory: the skeleton is not an installed environment, and
|
|
34
|
+
# pip-audit's default mode audits an environment. So we export `--frozen`
|
|
35
|
+
# requirements from the skeleton's own lockfile (a read of the committed lock,
|
|
36
|
+
# no re-resolution) and audit those with `-r`, from the workspace, which is
|
|
37
|
+
# where pip-audit is actually installed.
|
|
38
|
+
#
|
|
39
|
+
# ## This file is distributed verbatim (#743)
|
|
40
|
+
#
|
|
41
|
+
# Both skeletons and every existing sibling/plugin repo hold a BYTE-IDENTICAL
|
|
42
|
+
# copy, listed in `shared-files.json` and pushed by `scripts/shared-sync.sh`.
|
|
43
|
+
# Until then the hardening above lived in this repo only, while both skeletons
|
|
44
|
+
# shipped a raw `uv run pip-audit` — so every satellite was born with the
|
|
45
|
+
# original defect and reddened a required check on any PyPI/OSV hiccup.
|
|
46
|
+
#
|
|
47
|
+
# It therefore takes its target from the CURRENT WORKING DIRECTORY — the CI
|
|
48
|
+
# job's `working-directory` — rather than assuming a repo layout: `.` is the
|
|
49
|
+
# root project here and in a plugin repo, `services/api` in a sibling, and the
|
|
50
|
+
# `_skeletons/**` sweep below simply finds nothing outside this repo. Keep it
|
|
51
|
+
# layout-agnostic, or the copies stop being interchangeable and
|
|
52
|
+
# `shared-sync.sh --check` starts reporting drift that is really divergence.
|
|
53
|
+
#
|
|
54
|
+
# POSIX sh (the CI step runs `sh scripts/...`, i.e. dash) — no `pipefail`.
|
|
55
|
+
set -u
|
|
56
|
+
|
|
57
|
+
# jq is the parser this whole distinction rests on. Without it EVERY response
|
|
58
|
+
# reads as unparseable, so the script reports INCONCLUSIVE on every invocation
|
|
59
|
+
# and the gate goes green while scanning nothing — the exact fail-open shape it
|
|
60
|
+
# exists to prevent, and precisely what the dash `echo` defect did until #717. A
|
|
61
|
+
# missing jq is a deterministic environment defect, not a transient network
|
|
62
|
+
# hiccup, so it fails loudly instead of degrading into a permanent pass.
|
|
63
|
+
if ! command -v jq >/dev/null 2>&1; then
|
|
64
|
+
echo "::error::jq is not installed on this runner. The dependency audit cannot tell a clean run from an unparseable one without it, and would report INCONCLUSIVE forever — a gate that is green because it never ran. Install jq."
|
|
65
|
+
exit 1
|
|
66
|
+
fi
|
|
67
|
+
|
|
68
|
+
attempts=3
|
|
69
|
+
inconclusive=0
|
|
70
|
+
failed=0
|
|
71
|
+
|
|
72
|
+
# Audit one dependency set. Returns 0 if clean or inconclusive, 1 on a real
|
|
73
|
+
# finding. `$1` is a human label, `$2` the extra pip-audit flags that select
|
|
74
|
+
# what to audit — empty for this workspace's installed environment, or
|
|
75
|
+
# `-r <file>` for a requirements file exported from a skeleton lockfile.
|
|
76
|
+
audit_deps() {
|
|
77
|
+
label="$1"
|
|
78
|
+
extra="$2"
|
|
79
|
+
|
|
80
|
+
for attempt in $(seq 1 "$attempts"); do
|
|
81
|
+
# shellcheck disable=SC2086
|
|
82
|
+
out="$(uv run pip-audit -f json $extra 2>/dev/null)"
|
|
83
|
+
|
|
84
|
+
# printf, never echo: the CI step runs `sh scripts/...` i.e. dash, whose
|
|
85
|
+
# `echo` interprets backslash escapes. Advisory payloads contain them, so
|
|
86
|
+
# `echo "$out" | jq` mangles the JSON and jq rejects it — the run then reads
|
|
87
|
+
# as "the registry returned junk" and reports INCONCLUSIVE. That is this
|
|
88
|
+
# gate failing open inside the very fix that exists to stop it failing open
|
|
89
|
+
# (#591): green, every time, while scanning nothing. #644 found the JS gate
|
|
90
|
+
# had been doing exactly that on every run.
|
|
91
|
+
if printf '%s' "$out" | jq -e '.dependencies' >/dev/null 2>&1; then
|
|
92
|
+
vuln_count="$(printf '%s' "$out" | jq '[.dependencies[].vulns[]?] | length')"
|
|
93
|
+
if [ "$vuln_count" -gt 0 ]; then
|
|
94
|
+
echo "::error::${label}: ${vuln_count} vulnerability(ies)."
|
|
95
|
+
printf '%s' "$out" | jq '[.dependencies[] | select(.vulns | length > 0)]' 2>/dev/null | head -c 4000
|
|
96
|
+
return 1
|
|
97
|
+
fi
|
|
98
|
+
echo "${label}: no vulnerabilities found."
|
|
99
|
+
return 0
|
|
100
|
+
fi
|
|
101
|
+
|
|
102
|
+
echo "${label}: attempt ${attempt}/${attempts} produced no parseable JSON output."
|
|
103
|
+
[ "$attempt" -lt "$attempts" ] && sleep "$((attempt * 3))"
|
|
104
|
+
done
|
|
105
|
+
|
|
106
|
+
echo "::warning::${label}: could not produce parseable output after ${attempts} attempts (a transient network/registry error); treating as INCONCLUSIVE and not blocking. Advisory scanning was NOT performed for this tree — see #591."
|
|
107
|
+
inconclusive=$((inconclusive + 1))
|
|
108
|
+
return 0
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
# The workspace itself.
|
|
112
|
+
audit_deps "pip-audit (workspace)" "" || failed=1
|
|
113
|
+
|
|
114
|
+
# Every scaffolding tree with its own lockfile. Discovered rather than listed,
|
|
115
|
+
# so a new skeleton is covered the day it lands instead of the day someone
|
|
116
|
+
# remembers to add it here.
|
|
117
|
+
for lock in $(find _skeletons -name uv.lock -not -path '*/node_modules/*' 2>/dev/null | sort); do
|
|
118
|
+
dir="$(dirname "$lock")"
|
|
119
|
+
reqs="$(mktemp)"
|
|
120
|
+
|
|
121
|
+
# `--frozen` reads the committed lock verbatim instead of re-resolving, so
|
|
122
|
+
# what gets audited is exactly what a scaffolded sibling would be born with.
|
|
123
|
+
# `--no-emit-project` drops the skeleton package itself (nothing published to
|
|
124
|
+
# audit); `--no-dev` keeps the audit to what actually ships.
|
|
125
|
+
if (cd "$dir" && uv export --frozen --no-dev --no-emit-project) >"$reqs" 2>/dev/null && [ -s "$reqs" ]; then
|
|
126
|
+
audit_deps "pip-audit (${dir})" "-r $reqs" || failed=1
|
|
127
|
+
else
|
|
128
|
+
# An export failure is "couldn't run", not "clean" — same discipline as a
|
|
129
|
+
# transient pip-audit error, and it must never read as a pass.
|
|
130
|
+
echo "::warning::pip-audit (${dir}): could not export requirements from ${lock}; treating as INCONCLUSIVE and not blocking. Advisory scanning was NOT performed for this tree — see #719."
|
|
131
|
+
inconclusive=$((inconclusive + 1))
|
|
132
|
+
fi
|
|
133
|
+
|
|
134
|
+
rm -f "$reqs"
|
|
135
|
+
done
|
|
136
|
+
|
|
137
|
+
if [ "$failed" -ne 0 ]; then
|
|
138
|
+
exit 1
|
|
139
|
+
fi
|
|
140
|
+
|
|
141
|
+
if [ "$inconclusive" -ne 0 ]; then
|
|
142
|
+
echo "${inconclusive} tree(s) could not be audited; see warnings above."
|
|
143
|
+
fi
|
|
144
|
+
|
|
145
|
+
exit 0
|
|
@@ -82,7 +82,19 @@ jobs:
|
|
|
82
82
|
run: pnpm run build
|
|
83
83
|
- name: Dependency audit
|
|
84
84
|
if: ${{ !cancelled() }}
|
|
85
|
-
|
|
85
|
+
# Fails on a real high/critical advisory, but treats a broken audit
|
|
86
|
+
# registry as INCONCLUSIVE rather than reddening a required check for an
|
|
87
|
+
# infrastructure hiccup (#591, #743). The raw `pnpm audit
|
|
88
|
+
# --audit-level=high` this replaces exits non-zero identically whether it
|
|
89
|
+
# found a vulnerability or simply could not parse the registry's
|
|
90
|
+
# response, so any npm blip failed this job on every open PR at once.
|
|
91
|
+
#
|
|
92
|
+
# The relative path is deliberate. This job's working-directory is
|
|
93
|
+
# apps/frontend, which is exactly the tree to audit, and the script
|
|
94
|
+
# audits its own cwd — it is byte-identical to biffo-template's copy and
|
|
95
|
+
# to every other satellite's, distributed by that repo's shared-sync.sh,
|
|
96
|
+
# so it cannot assume a layout. Do not edit it here; edit it upstream.
|
|
97
|
+
run: sh ../../scripts/js-dependency-audit.sh
|
|
86
98
|
|
|
87
99
|
python:
|
|
88
100
|
name: Python (lint, types, test, security)
|
|
@@ -124,7 +136,11 @@ jobs:
|
|
|
124
136
|
path: services/api/bandit-report.json
|
|
125
137
|
- name: Dependency audit
|
|
126
138
|
if: ${{ !cancelled() }}
|
|
127
|
-
|
|
139
|
+
# Same treatment as the JS job's Dependency audit above: fails on a real
|
|
140
|
+
# advisory, INCONCLUSIVE on an unreachable PyPI/OSV (#591, #743). cwd is
|
|
141
|
+
# services/api, which is the environment `uv sync` installed and so the
|
|
142
|
+
# one to audit.
|
|
143
|
+
run: sh ../../scripts/py-dependency-audit.sh
|
|
128
144
|
|
|
129
145
|
security-secrets:
|
|
130
146
|
name: Secret Scan
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
#
|
|
3
|
+
# JS dependency audit that fails the build on a real high/critical advisory but
|
|
4
|
+
# NOT on a broken audit registry (#591).
|
|
5
|
+
#
|
|
6
|
+
# `pnpm audit --audit-level=high` exits non-zero identically whether it found a
|
|
7
|
+
# vulnerability OR simply couldn't parse the registry's response — and the npm
|
|
8
|
+
# audit endpoint intermittently returns a non-JSON/gzip body pnpm chokes on
|
|
9
|
+
# ("Unexpected token, is not valid JSON"), which then fails the required JS check
|
|
10
|
+
# on every open PR at once, blocking the whole merge queue for an infrastructure
|
|
11
|
+
# hiccup that has nothing to do with the PR.
|
|
12
|
+
#
|
|
13
|
+
# `pnpm audit --json` disambiguates: a real run yields `.metadata.vulnerabilities`
|
|
14
|
+
# (counts by severity); a registry/parse failure yields `.error`. So we fail only
|
|
15
|
+
# on a genuine high+critical finding, retry a transient registry error a few
|
|
16
|
+
# times, and treat a persistent one as INCONCLUSIVE (warn, don't block) rather
|
|
17
|
+
# than as a vulnerability. "Couldn't run the check" and "the check found a
|
|
18
|
+
# problem" must not be the same signal on a required gate.
|
|
19
|
+
#
|
|
20
|
+
# ## Skeleton lockfiles are audited too (#644)
|
|
21
|
+
#
|
|
22
|
+
# `_skeletons/**/` are scaffolding trees: `biffo sibling create` / `plugin
|
|
23
|
+
# create` copy them verbatim into a brand-new repo. They sit OUTSIDE the pnpm
|
|
24
|
+
# workspace, so `pnpm install` never installs them and a workspace-scoped audit
|
|
25
|
+
# never sees them — which meant every sibling scaffolded from this template was
|
|
26
|
+
# born with four high-severity advisories nobody could see. Dependabot found
|
|
27
|
+
# them; the required JS gate could not, by construction.
|
|
28
|
+
#
|
|
29
|
+
# That is the fail-open shape this file already exists to fight, one level up:
|
|
30
|
+
# the gate was green because it was not looking, and "we checked and it is
|
|
31
|
+
# clean" read identically to "we never checked".
|
|
32
|
+
#
|
|
33
|
+
# ## This file is distributed verbatim (#743)
|
|
34
|
+
#
|
|
35
|
+
# Both skeletons and every existing sibling/plugin repo hold a BYTE-IDENTICAL
|
|
36
|
+
# copy, listed in `shared-files.json` and pushed by `scripts/shared-sync.sh`.
|
|
37
|
+
# Until then the hardening above lived in this repo only, while both skeletons
|
|
38
|
+
# shipped the raw `pnpm audit --audit-level=high` that #591 was filed about — so
|
|
39
|
+
# every satellite was born with the original defect and reddened its required JS
|
|
40
|
+
# check on any registry hiccup.
|
|
41
|
+
#
|
|
42
|
+
# It therefore takes its target from the CURRENT WORKING DIRECTORY — the CI
|
|
43
|
+
# job's `working-directory` — rather than assuming a repo layout: `.` is the
|
|
44
|
+
# root workspace here, `apps/frontend` in a sibling, and the `_skeletons/**`
|
|
45
|
+
# sweep below simply finds nothing outside this repo. Keep it layout-agnostic,
|
|
46
|
+
# or the copies stop being interchangeable and `shared-sync.sh --check` starts
|
|
47
|
+
# reporting drift that is really divergence.
|
|
48
|
+
#
|
|
49
|
+
# POSIX sh (the CI step runs `sh scripts/...`, i.e. dash) — no `pipefail`.
|
|
50
|
+
set -u
|
|
51
|
+
|
|
52
|
+
# jq is the parser this whole distinction rests on. Without it EVERY response
|
|
53
|
+
# reads as unparseable, so the script reports INCONCLUSIVE on every invocation
|
|
54
|
+
# and the gate goes green while scanning nothing — the exact fail-open shape it
|
|
55
|
+
# exists to prevent, and precisely what the dash `echo` defect did until #717. A
|
|
56
|
+
# missing jq is a deterministic environment defect, not a transient registry
|
|
57
|
+
# hiccup, so it fails loudly instead of degrading into a permanent pass.
|
|
58
|
+
if ! command -v jq >/dev/null 2>&1; then
|
|
59
|
+
echo "::error::jq is not installed on this runner. The dependency audit cannot tell a clean run from an unparseable one without it, and would report INCONCLUSIVE forever — a gate that is green because it never ran. Install jq."
|
|
60
|
+
exit 1
|
|
61
|
+
fi
|
|
62
|
+
|
|
63
|
+
attempts=3
|
|
64
|
+
inconclusive=0
|
|
65
|
+
failed=0
|
|
66
|
+
|
|
67
|
+
# Audit one directory. Returns 0 if clean or inconclusive, 1 on a real finding.
|
|
68
|
+
# `$1` is the directory, `$2` a human label, `$3` extra pnpm flags.
|
|
69
|
+
audit_dir() {
|
|
70
|
+
dir="$1"
|
|
71
|
+
label="$2"
|
|
72
|
+
extra="$3"
|
|
73
|
+
|
|
74
|
+
for attempt in $(seq 1 "$attempts"); do
|
|
75
|
+
# printf, never echo: the CI step runs `sh scripts/...` i.e. dash, whose
|
|
76
|
+
# `echo` interprets backslash escapes. Advisory payloads contain them, so
|
|
77
|
+
# `echo "$out" | jq` mangles the JSON and jq rejects it — the run then reads
|
|
78
|
+
# as "the registry returned junk" and reports INCONCLUSIVE. That is this
|
|
79
|
+
# gate failing open inside the very fix that exists to stop it failing open
|
|
80
|
+
# (#591): green, every time, while scanning nothing.
|
|
81
|
+
#
|
|
82
|
+
# The flag differs by tree, and both directions matter:
|
|
83
|
+
# - skeletons need --ignore-workspace, or pnpm walks up, finds this repo's
|
|
84
|
+
# workspace root and audits THAT instead — reporting a clean result for
|
|
85
|
+
# a tree it never looked at, which is the failure this step closes.
|
|
86
|
+
# - the workspace must NOT have it, or pnpm has no project to audit and
|
|
87
|
+
# every run reports INCONCLUSIVE. That fails open: the gate goes green
|
|
88
|
+
# forever while scanning nothing. (Caught by running this script before
|
|
89
|
+
# trusting it — the workspace audit had silently stopped working.)
|
|
90
|
+
# shellcheck disable=SC2086
|
|
91
|
+
out="$(cd "$dir" 2>/dev/null && pnpm audit --json $extra 2>/dev/null)"
|
|
92
|
+
|
|
93
|
+
if printf '%s' "$out" | jq -e '.metadata.vulnerabilities' >/dev/null 2>&1; then
|
|
94
|
+
high="$(printf '%s' "$out" | jq '.metadata.vulnerabilities.high // 0')"
|
|
95
|
+
crit="$(printf '%s' "$out" | jq '.metadata.vulnerabilities.critical // 0')"
|
|
96
|
+
if [ "$((high + crit))" -gt 0 ]; then
|
|
97
|
+
echo "::error::${label}: ${crit} critical + ${high} high advisory(ies)."
|
|
98
|
+
printf '%s' "$out" | jq '.advisories // .metadata.vulnerabilities' 2>/dev/null | head -c 4000
|
|
99
|
+
return 1
|
|
100
|
+
fi
|
|
101
|
+
echo "${label}: no high/critical advisories."
|
|
102
|
+
return 0
|
|
103
|
+
fi
|
|
104
|
+
|
|
105
|
+
msg="$(printf '%s' "$out" | jq -r '.error.message // "no parseable audit output"' 2>/dev/null | head -c 200)"
|
|
106
|
+
echo "${label}: attempt ${attempt}/${attempts} could not run: ${msg}"
|
|
107
|
+
[ "$attempt" -lt "$attempts" ] && sleep "$((attempt * 3))"
|
|
108
|
+
done
|
|
109
|
+
|
|
110
|
+
echo "::warning::${label}: audit could not run after ${attempts} attempts (the registry returned a non-JSON/error response); treating as INCONCLUSIVE and not blocking. Advisory scanning was NOT performed for this tree — see #591."
|
|
111
|
+
inconclusive=$((inconclusive + 1))
|
|
112
|
+
return 0
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
# The workspace itself.
|
|
116
|
+
audit_dir "." "pnpm audit (workspace)" "" || failed=1
|
|
117
|
+
|
|
118
|
+
# Every scaffolding tree with its own lockfile. Discovered rather than listed,
|
|
119
|
+
# so a new skeleton is covered the day it lands instead of the day someone
|
|
120
|
+
# remembers to add it here.
|
|
121
|
+
for lock in $(find _skeletons -name pnpm-lock.yaml -not -path '*/node_modules/*' 2>/dev/null | sort); do
|
|
122
|
+
audit_dir "$(dirname "$lock")" "pnpm audit ($(dirname "$lock"))" "--ignore-workspace" || failed=1
|
|
123
|
+
done
|
|
124
|
+
|
|
125
|
+
if [ "$failed" -ne 0 ]; then
|
|
126
|
+
exit 1
|
|
127
|
+
fi
|
|
128
|
+
|
|
129
|
+
if [ "$inconclusive" -ne 0 ]; then
|
|
130
|
+
echo "${inconclusive} tree(s) could not be audited; see warnings above."
|
|
131
|
+
fi
|
|
132
|
+
|
|
133
|
+
exit 0
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
#
|
|
3
|
+
# Python dependency audit that fails the build on a real advisory but NOT on a
|
|
4
|
+
# broken registry/network response (#591) — same class of flake as the JS
|
|
5
|
+
# dependency audit (scripts/js-dependency-audit.sh), applied preventively here
|
|
6
|
+
# since pip-audit queries PyPI/OSV over the network per dependency and is
|
|
7
|
+
# subject to the same kind of transient hiccup.
|
|
8
|
+
#
|
|
9
|
+
# `pip-audit -f json` emits a `{"dependencies": [{"name", "version", "vulns"},
|
|
10
|
+
# ...]}` shape on a real run; a network/parse failure produces no parseable
|
|
11
|
+
# JSON at all (traceback on stderr, non-zero exit, empty/partial stdout). So we
|
|
12
|
+
# fail only when the parsed output actually contains a vulnerability, retry a
|
|
13
|
+
# few times on an unparseable run, and treat a persistent failure as
|
|
14
|
+
# INCONCLUSIVE (warn, don't block) rather than as a vulnerability.
|
|
15
|
+
# "Couldn't run the check" and "the check found a problem" must not be the same
|
|
16
|
+
# signal on a required gate.
|
|
17
|
+
#
|
|
18
|
+
# ## Skeleton lockfiles are audited too (#719)
|
|
19
|
+
#
|
|
20
|
+
# `_skeletons/**/` are scaffolding trees: `biffo sibling create` / `plugin
|
|
21
|
+
# create` copy them verbatim into a brand-new repo. They are NOT part of this
|
|
22
|
+
# repo's uv project, so `uv sync` never installs them and a workspace-scoped
|
|
23
|
+
# `uv run pip-audit` never saw them — which meant the Python half of every
|
|
24
|
+
# scaffolded sibling came from a lockfile no CI gate had ever looked at.
|
|
25
|
+
#
|
|
26
|
+
# #644 closed exactly this hole for the JS skeleton lockfile, where it had
|
|
27
|
+
# already cost four unseen high-severity advisories; this is the same hole one
|
|
28
|
+
# language over. It is the fail-open shape this file already exists to fight,
|
|
29
|
+
# one level up: the gate was green because it was not looking, and "we checked
|
|
30
|
+
# and it is clean" read identically to "we never checked".
|
|
31
|
+
#
|
|
32
|
+
# Unlike `pnpm audit --ignore-workspace`, pip-audit cannot simply be run inside
|
|
33
|
+
# a skeleton directory: the skeleton is not an installed environment, and
|
|
34
|
+
# pip-audit's default mode audits an environment. So we export `--frozen`
|
|
35
|
+
# requirements from the skeleton's own lockfile (a read of the committed lock,
|
|
36
|
+
# no re-resolution) and audit those with `-r`, from the workspace, which is
|
|
37
|
+
# where pip-audit is actually installed.
|
|
38
|
+
#
|
|
39
|
+
# ## This file is distributed verbatim (#743)
|
|
40
|
+
#
|
|
41
|
+
# Both skeletons and every existing sibling/plugin repo hold a BYTE-IDENTICAL
|
|
42
|
+
# copy, listed in `shared-files.json` and pushed by `scripts/shared-sync.sh`.
|
|
43
|
+
# Until then the hardening above lived in this repo only, while both skeletons
|
|
44
|
+
# shipped a raw `uv run pip-audit` — so every satellite was born with the
|
|
45
|
+
# original defect and reddened a required check on any PyPI/OSV hiccup.
|
|
46
|
+
#
|
|
47
|
+
# It therefore takes its target from the CURRENT WORKING DIRECTORY — the CI
|
|
48
|
+
# job's `working-directory` — rather than assuming a repo layout: `.` is the
|
|
49
|
+
# root project here and in a plugin repo, `services/api` in a sibling, and the
|
|
50
|
+
# `_skeletons/**` sweep below simply finds nothing outside this repo. Keep it
|
|
51
|
+
# layout-agnostic, or the copies stop being interchangeable and
|
|
52
|
+
# `shared-sync.sh --check` starts reporting drift that is really divergence.
|
|
53
|
+
#
|
|
54
|
+
# POSIX sh (the CI step runs `sh scripts/...`, i.e. dash) — no `pipefail`.
|
|
55
|
+
set -u
|
|
56
|
+
|
|
57
|
+
# jq is the parser this whole distinction rests on. Without it EVERY response
|
|
58
|
+
# reads as unparseable, so the script reports INCONCLUSIVE on every invocation
|
|
59
|
+
# and the gate goes green while scanning nothing — the exact fail-open shape it
|
|
60
|
+
# exists to prevent, and precisely what the dash `echo` defect did until #717. A
|
|
61
|
+
# missing jq is a deterministic environment defect, not a transient network
|
|
62
|
+
# hiccup, so it fails loudly instead of degrading into a permanent pass.
|
|
63
|
+
if ! command -v jq >/dev/null 2>&1; then
|
|
64
|
+
echo "::error::jq is not installed on this runner. The dependency audit cannot tell a clean run from an unparseable one without it, and would report INCONCLUSIVE forever — a gate that is green because it never ran. Install jq."
|
|
65
|
+
exit 1
|
|
66
|
+
fi
|
|
67
|
+
|
|
68
|
+
attempts=3
|
|
69
|
+
inconclusive=0
|
|
70
|
+
failed=0
|
|
71
|
+
|
|
72
|
+
# Audit one dependency set. Returns 0 if clean or inconclusive, 1 on a real
|
|
73
|
+
# finding. `$1` is a human label, `$2` the extra pip-audit flags that select
|
|
74
|
+
# what to audit — empty for this workspace's installed environment, or
|
|
75
|
+
# `-r <file>` for a requirements file exported from a skeleton lockfile.
|
|
76
|
+
audit_deps() {
|
|
77
|
+
label="$1"
|
|
78
|
+
extra="$2"
|
|
79
|
+
|
|
80
|
+
for attempt in $(seq 1 "$attempts"); do
|
|
81
|
+
# shellcheck disable=SC2086
|
|
82
|
+
out="$(uv run pip-audit -f json $extra 2>/dev/null)"
|
|
83
|
+
|
|
84
|
+
# printf, never echo: the CI step runs `sh scripts/...` i.e. dash, whose
|
|
85
|
+
# `echo` interprets backslash escapes. Advisory payloads contain them, so
|
|
86
|
+
# `echo "$out" | jq` mangles the JSON and jq rejects it — the run then reads
|
|
87
|
+
# as "the registry returned junk" and reports INCONCLUSIVE. That is this
|
|
88
|
+
# gate failing open inside the very fix that exists to stop it failing open
|
|
89
|
+
# (#591): green, every time, while scanning nothing. #644 found the JS gate
|
|
90
|
+
# had been doing exactly that on every run.
|
|
91
|
+
if printf '%s' "$out" | jq -e '.dependencies' >/dev/null 2>&1; then
|
|
92
|
+
vuln_count="$(printf '%s' "$out" | jq '[.dependencies[].vulns[]?] | length')"
|
|
93
|
+
if [ "$vuln_count" -gt 0 ]; then
|
|
94
|
+
echo "::error::${label}: ${vuln_count} vulnerability(ies)."
|
|
95
|
+
printf '%s' "$out" | jq '[.dependencies[] | select(.vulns | length > 0)]' 2>/dev/null | head -c 4000
|
|
96
|
+
return 1
|
|
97
|
+
fi
|
|
98
|
+
echo "${label}: no vulnerabilities found."
|
|
99
|
+
return 0
|
|
100
|
+
fi
|
|
101
|
+
|
|
102
|
+
echo "${label}: attempt ${attempt}/${attempts} produced no parseable JSON output."
|
|
103
|
+
[ "$attempt" -lt "$attempts" ] && sleep "$((attempt * 3))"
|
|
104
|
+
done
|
|
105
|
+
|
|
106
|
+
echo "::warning::${label}: could not produce parseable output after ${attempts} attempts (a transient network/registry error); treating as INCONCLUSIVE and not blocking. Advisory scanning was NOT performed for this tree — see #591."
|
|
107
|
+
inconclusive=$((inconclusive + 1))
|
|
108
|
+
return 0
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
# The workspace itself.
|
|
112
|
+
audit_deps "pip-audit (workspace)" "" || failed=1
|
|
113
|
+
|
|
114
|
+
# Every scaffolding tree with its own lockfile. Discovered rather than listed,
|
|
115
|
+
# so a new skeleton is covered the day it lands instead of the day someone
|
|
116
|
+
# remembers to add it here.
|
|
117
|
+
for lock in $(find _skeletons -name uv.lock -not -path '*/node_modules/*' 2>/dev/null | sort); do
|
|
118
|
+
dir="$(dirname "$lock")"
|
|
119
|
+
reqs="$(mktemp)"
|
|
120
|
+
|
|
121
|
+
# `--frozen` reads the committed lock verbatim instead of re-resolving, so
|
|
122
|
+
# what gets audited is exactly what a scaffolded sibling would be born with.
|
|
123
|
+
# `--no-emit-project` drops the skeleton package itself (nothing published to
|
|
124
|
+
# audit); `--no-dev` keeps the audit to what actually ships.
|
|
125
|
+
if (cd "$dir" && uv export --frozen --no-dev --no-emit-project) >"$reqs" 2>/dev/null && [ -s "$reqs" ]; then
|
|
126
|
+
audit_deps "pip-audit (${dir})" "-r $reqs" || failed=1
|
|
127
|
+
else
|
|
128
|
+
# An export failure is "couldn't run", not "clean" — same discipline as a
|
|
129
|
+
# transient pip-audit error, and it must never read as a pass.
|
|
130
|
+
echo "::warning::pip-audit (${dir}): could not export requirements from ${lock}; treating as INCONCLUSIVE and not blocking. Advisory scanning was NOT performed for this tree — see #719."
|
|
131
|
+
inconclusive=$((inconclusive + 1))
|
|
132
|
+
fi
|
|
133
|
+
|
|
134
|
+
rm -f "$reqs"
|
|
135
|
+
done
|
|
136
|
+
|
|
137
|
+
if [ "$failed" -ne 0 ]; then
|
|
138
|
+
exit 1
|
|
139
|
+
fi
|
|
140
|
+
|
|
141
|
+
if [ "$inconclusive" -ne 0 ]; then
|
|
142
|
+
echo "${inconclusive} tree(s) could not be audited; see warnings above."
|
|
143
|
+
fi
|
|
144
|
+
|
|
145
|
+
exit 0
|