tosijs-ui 1.7.3 → 1.8.0
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/CHANGELOG.md +89 -0
- package/dist/doc-browser.d.ts +11 -0
- package/dist/doc-browser.js +63 -7
- package/dist/doc-system/doc-system.js +1 -0
- package/dist/doc-system/nav-tree.d.ts +24 -5
- package/dist/doc-system/nav-tree.js +0 -0
- package/dist/doc-system/site/audit-guard.d.ts +124 -0
- package/dist/doc-system/site/audit-guard.js +555 -0
- package/dist/doc-system/site/dev-server.js +59 -3
- package/dist/doc-system/site/generate-site.d.ts +2 -0
- package/dist/doc-system/site/generate-site.js +1 -0
- package/dist/doc-system/site/index.d.ts +4 -0
- package/dist/doc-system/site/index.js +2 -0
- package/dist/doc-system/site/open-browser.d.ts +53 -0
- package/dist/doc-system/site/open-browser.js +231 -0
- package/dist/doc-system/site/orchestrator.d.ts +3 -1
- package/dist/doc-system/site/orchestrator.js +14 -1
- package/dist/doc-system/site/site-config.d.ts +48 -0
- package/dist/hydrate/{ajs-language-qm5w38ae.js → ajs-language-xvpytqfm.js} +2 -2
- package/dist/hydrate/{code-editor-cm-95n31m4f.js → code-editor-cm-59je4d8g.js} +6 -6
- package/dist/hydrate/{code-editor-cm-95n31m4f.js.map → code-editor-cm-59je4d8g.js.map} +4 -4
- package/dist/hydrate/hydrate.js +52 -52
- package/dist/hydrate/hydrate.js.map +6 -6
- package/dist/hydrate/index-iife-2q1gndgz.js +27 -0
- package/dist/hydrate/{index-iife-kwhe0yt8.js.map → index-iife-2q1gndgz.js.map} +3 -3
- package/dist/hydrate/{index-iife-ype8sch3.js → index-iife-xv69gy4m.js} +6 -6
- package/dist/hydrate/index-iife-xv69gy4m.js.map +12 -0
- package/dist/hydrate/{index-0ps8jzdn.js → index-vk8ek4jm.js} +2 -2
- package/dist/iife.js +75 -75
- package/dist/iife.js.map +10 -10
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/llms.txt +1 -1
- package/package.json +17 -13
- package/tls/create-dev-certs.sh +1 -1
- package/dist/hydrate/index-iife-kwhe0yt8.js +0 -27
- package/dist/hydrate/index-iife-ype8sch3.js.map +0 -12
- /package/dist/hydrate/{ajs-language-qm5w38ae.js.map → ajs-language-xvpytqfm.js.map} +0 -0
- /package/dist/hydrate/{index-0ps8jzdn.js.map → index-vk8ek4jm.js.map} +0 -0
|
@@ -0,0 +1,555 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Dependency-audit gate for the doc-site build and dev server.
|
|
3
|
+
|
|
4
|
+
`bun audit` knows the registry advisory database; nothing in this build ever
|
|
5
|
+
asked it. So ask it, once, at the point a human is looking: the initial build
|
|
6
|
+
and the dev-server launch. A high-or-worse advisory FAILS the build — unless the
|
|
7
|
+
specific advisory is explicitly gated with a reason AND an expiry date, which
|
|
8
|
+
forces it back onto the table instead of being silenced forever.
|
|
9
|
+
|
|
10
|
+
Design notes that mirror `preflight.ts`:
|
|
11
|
+
|
|
12
|
+
- Returns a result; NEVER `process.exit`. This is reachable through the public
|
|
13
|
+
`tosijs-ui/site` export, and library code does not get to kill a caller's
|
|
14
|
+
process from inside a health check. `buildSite` turns a blocking result into a
|
|
15
|
+
failed build; the dev server (which by then owns a running process on which the
|
|
16
|
+
finding is the whole point) is the one place that self-terminates — same
|
|
17
|
+
precedent as the memory watchdog.
|
|
18
|
+
- Fail-OPEN on inability to check (offline, registry down, `bun` too old): a
|
|
19
|
+
dependency advisory you couldn't fetch must not ground you on a plane. Fail-
|
|
20
|
+
CLOSED on an actual finding.
|
|
21
|
+
- NOT downgraded in CI. Unlike the machine-health preflight — a heuristic about
|
|
22
|
+
someone's local box — an advisory is deterministic and environment-independent,
|
|
23
|
+
so CI is exactly where you want the gate to bite.
|
|
24
|
+
|
|
25
|
+
`bun audit` is itself a subprocess, so shelling out already satisfies the "never a
|
|
26
|
+
native-heavy API in a long-lived process" rule. Build-time only (Bun/Node APIs);
|
|
27
|
+
never import this from browser code.
|
|
28
|
+
*/
|
|
29
|
+
import { $ } from 'bun';
|
|
30
|
+
const SEVERITY_RANK = {
|
|
31
|
+
info: 0,
|
|
32
|
+
low: 1,
|
|
33
|
+
moderate: 2,
|
|
34
|
+
high: 3,
|
|
35
|
+
critical: 4,
|
|
36
|
+
};
|
|
37
|
+
/*
|
|
38
|
+
CWEs that routinely escalate beyond what the vector admits. Prototype pollution is
|
|
39
|
+
the canonical case and it is not hypothetical: in the sample set, protobufjs had an
|
|
40
|
+
advisory scored A:H (availability-only) tagged CWE-1321, AND a separate C:H/I:H
|
|
41
|
+
advisory titled "code generation gadget AFTER prototype pollution" — the escalation
|
|
42
|
+
chain the first vector does not encode. A vector-only reading calls that benign.
|
|
43
|
+
*/
|
|
44
|
+
const ESCALATABLE_CWE = new Set(['1321', '915', '502', '94', '78', '77']);
|
|
45
|
+
function parseVector(v) {
|
|
46
|
+
if (!v)
|
|
47
|
+
return null;
|
|
48
|
+
const out = {};
|
|
49
|
+
let version = '';
|
|
50
|
+
for (const part of v.split('/')) {
|
|
51
|
+
const [k, val] = part.split(':');
|
|
52
|
+
if (!k || !val)
|
|
53
|
+
continue;
|
|
54
|
+
if (k === 'CVSS')
|
|
55
|
+
version = val;
|
|
56
|
+
else
|
|
57
|
+
out[k] = val;
|
|
58
|
+
}
|
|
59
|
+
if (!Object.keys(out).length)
|
|
60
|
+
return null;
|
|
61
|
+
out.__version = version || '?';
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
/** Classify one advisory's nature from its CVSS vector + CWEs. Fails CLOSED. */
|
|
65
|
+
export function classifyRisk(raw) {
|
|
66
|
+
const vec = parseVector(raw.cvss?.vectorString ?? '');
|
|
67
|
+
const cwes = (raw.cwe ?? []).map((c) => String(c).replace(/^CWE-/i, ''));
|
|
68
|
+
const escalatable = cwes.some((c) => ESCALATABLE_CWE.has(c));
|
|
69
|
+
if (!vec) {
|
|
70
|
+
return {
|
|
71
|
+
nature: 'unknown',
|
|
72
|
+
label: 'UNCLASSIFIED',
|
|
73
|
+
basis: 'no CVSS vector in advisory — treat as worst case',
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
// CVSS 4.0 renamed the impact metrics: VC/VI/VA (vulnerable system) and
|
|
77
|
+
// SC/SI/SA (subsequent system). 2.0/3.x use plain C/I/A. Support both; any
|
|
78
|
+
// other shape is unknown rather than assumed benign.
|
|
79
|
+
const v = vec.__version;
|
|
80
|
+
const isV4 = v.startsWith('4');
|
|
81
|
+
const conf = isV4 ? vec.VC ?? vec.SC : vec.C;
|
|
82
|
+
const integ = isV4 ? vec.VI ?? vec.SI : vec.I;
|
|
83
|
+
const avail = isV4 ? vec.VA ?? vec.SA : vec.A;
|
|
84
|
+
if (conf === undefined || integ === undefined || avail === undefined) {
|
|
85
|
+
return {
|
|
86
|
+
nature: 'unknown',
|
|
87
|
+
label: 'UNCLASSIFIED',
|
|
88
|
+
basis: `CVSS ${v} vector missing impact metrics — treat as worst case`,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
const hit = (x) => x === 'L' || x === 'H';
|
|
92
|
+
if (hit(conf) || hit(integ)) {
|
|
93
|
+
return {
|
|
94
|
+
nature: 'compromise',
|
|
95
|
+
label: 'LEAK/ALTER',
|
|
96
|
+
basis: `CVSS ${v} C:${conf} I:${integ}`,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
if (escalatable) {
|
|
100
|
+
return {
|
|
101
|
+
nature: 'unknown',
|
|
102
|
+
label: 'DoS?+ESCALATABLE',
|
|
103
|
+
basis: `CVSS ${v} A:${avail} only, but CWE-${cwes.join('/')} can escalate`,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
nature: 'dos',
|
|
108
|
+
label: 'DoS-only',
|
|
109
|
+
basis: `CVSS ${v} C:N I:N A:${avail}`,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
/*
|
|
113
|
+
The audit is FAST — sub-second even on a large tree, because the dependency
|
|
114
|
+
resolution is local and it is one registry round-trip. That is why it runs
|
|
115
|
+
synchronously everywhere (see buildSite / devServer): a gate you wait for is a gate
|
|
116
|
+
that cannot be raced, and it removes a whole class of "it printed after I'd already
|
|
117
|
+
started working" edge cases.
|
|
118
|
+
|
|
119
|
+
What sync DOES introduce is a hang: a captive portal, a VPN coming up, or a
|
|
120
|
+
registry black-holing the connection makes a fetch that never returns, and a build
|
|
121
|
+
that hangs forever is worse than one that skips a check. So bound it — on timeout
|
|
122
|
+
we fail OPEN (same as offline), because an advisory we could not fetch must not
|
|
123
|
+
ground someone on a plane.
|
|
124
|
+
*/
|
|
125
|
+
export const AUDIT_TIMEOUT_MS = 20_000;
|
|
126
|
+
/*
|
|
127
|
+
The runner MUST read the exit code. Measured on bun 1.3.14:
|
|
128
|
+
|
|
129
|
+
clean tree → exit 0, stdout `{}` (3 bytes — never empty)
|
|
130
|
+
advisories found → exit 1, stdout `{...}`
|
|
131
|
+
no lockfile → exit 1, stdout EMPTY, stderr `error: Lockfile not found`
|
|
132
|
+
offline / registry
|
|
133
|
+
refused / old bun → exit 1, stdout EMPTY
|
|
134
|
+
|
|
135
|
+
so **empty stdout means the audit FAILED — it never means "clean"**. An earlier
|
|
136
|
+
version returned stdout blind and let `parseAuditJson('')` answer `[]`, which the
|
|
137
|
+
caller read as `ran: true, ok: true` and printed "✅ dependency audit clean" — a
|
|
138
|
+
green checkmark for a check that never contacted the registry, on every build,
|
|
139
|
+
forever, for anyone behind a proxy or on a non-bun lockfile. A security gate that
|
|
140
|
+
reports a pass it did not earn is worse than no gate: it converts an unknown into a
|
|
141
|
+
false assurance. Exit code first, then content.
|
|
142
|
+
*/
|
|
143
|
+
const defaultRunner = async () => {
|
|
144
|
+
const proc = $ `bun audit --json`.nothrow().quiet();
|
|
145
|
+
const timeout = new Promise((resolve) => setTimeout(() => resolve(null), AUDIT_TIMEOUT_MS).unref?.());
|
|
146
|
+
const r = await Promise.race([proc, timeout]);
|
|
147
|
+
if (r === null)
|
|
148
|
+
throw new Error('bun audit timed out');
|
|
149
|
+
const stdout = r.stdout.toString();
|
|
150
|
+
// 0 = clean, 1 = advisories found. Both print JSON. Anything else — or a
|
|
151
|
+
// "success" with no output at all — is a failed run, not a clean one: throw so
|
|
152
|
+
// the caller takes the honest fail-OPEN path (ran:false) and warns.
|
|
153
|
+
if (r.exitCode !== 0 && r.exitCode !== 1) {
|
|
154
|
+
throw new Error(`bun audit exited ${r.exitCode}: ${r.stderr.toString().trim().slice(0, 300)}`);
|
|
155
|
+
}
|
|
156
|
+
if (stdout.trim() === '') {
|
|
157
|
+
throw new Error(`bun audit produced no output (exit ${r.exitCode}): ` +
|
|
158
|
+
`${r.stderr.toString().trim().slice(0, 300) || 'no stderr'}`);
|
|
159
|
+
}
|
|
160
|
+
return stdout;
|
|
161
|
+
};
|
|
162
|
+
/** Extract a GHSA id from an advisory URL, if the URL carries one. */
|
|
163
|
+
function parseGhsa(url) {
|
|
164
|
+
const m = url?.match(/GHSA-[0-9a-z]{4}-[0-9a-z]{4}-[0-9a-z]{4}/i);
|
|
165
|
+
return m ? m[0] : undefined;
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Parse `bun audit --json` output — `{ "<pkg>": [advisory, …] }` — into a flat
|
|
169
|
+
* advisory list. Returns null when the text is not a JSON object (offline, an
|
|
170
|
+
* error dump, an incompatible bun): the caller reads null as "couldn't check".
|
|
171
|
+
*/
|
|
172
|
+
export function parseAuditJson(text) {
|
|
173
|
+
const trimmed = text.trim();
|
|
174
|
+
// Empty is NOT "clean" — a clean tree prints `{}`. Empty stdout only happens
|
|
175
|
+
// when the audit failed (no lockfile, offline, registry refused, bun too old),
|
|
176
|
+
// so answering `[]` here manufactured a green result out of a failed check.
|
|
177
|
+
if (trimmed === '')
|
|
178
|
+
return null;
|
|
179
|
+
let data;
|
|
180
|
+
try {
|
|
181
|
+
data = JSON.parse(trimmed);
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
if (data === null || typeof data !== 'object' || Array.isArray(data))
|
|
187
|
+
return null;
|
|
188
|
+
const out = [];
|
|
189
|
+
for (const [pkg, advisories] of Object.entries(data)) {
|
|
190
|
+
if (!Array.isArray(advisories))
|
|
191
|
+
continue;
|
|
192
|
+
for (const a of advisories) {
|
|
193
|
+
if (!a || typeof a !== 'object')
|
|
194
|
+
continue;
|
|
195
|
+
const adv = a;
|
|
196
|
+
const severity = String(adv.severity ?? 'info').toLowerCase();
|
|
197
|
+
out.push({
|
|
198
|
+
package: pkg,
|
|
199
|
+
id: Number(adv.id) || 0,
|
|
200
|
+
url: String(adv.url ?? ''),
|
|
201
|
+
title: String(adv.title ?? ''),
|
|
202
|
+
severity: severity in SEVERITY_RANK ? severity : 'info',
|
|
203
|
+
vulnerableVersions: adv.vulnerable_versions != null
|
|
204
|
+
? String(adv.vulnerable_versions)
|
|
205
|
+
: undefined,
|
|
206
|
+
ghsa: parseGhsa(String(adv.url ?? '')),
|
|
207
|
+
risk: classifyRisk({
|
|
208
|
+
cvss: adv.cvss,
|
|
209
|
+
cwe: Array.isArray(adv.cwe) ? adv.cwe : null,
|
|
210
|
+
}),
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return out;
|
|
215
|
+
}
|
|
216
|
+
/** yyyy-mm-dd for a Date, in UTC (so gate expiry is timezone-stable). */
|
|
217
|
+
function isoDay(d) {
|
|
218
|
+
return d.toISOString().slice(0, 10);
|
|
219
|
+
}
|
|
220
|
+
function daysBetween(fromIso, toIso) {
|
|
221
|
+
const a = Date.parse(fromIso + 'T00:00:00Z');
|
|
222
|
+
const b = Date.parse(toIso + 'T00:00:00Z');
|
|
223
|
+
return Math.round((b - a) / 86400000);
|
|
224
|
+
}
|
|
225
|
+
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
226
|
+
/** Does a gate refer to this advisory? By GHSA, numeric id, or package name. */
|
|
227
|
+
function gateMatches(gate, adv) {
|
|
228
|
+
const ref = gate.advisory?.trim();
|
|
229
|
+
if (!ref)
|
|
230
|
+
return false;
|
|
231
|
+
if (adv.ghsa && ref.toLowerCase() === adv.ghsa.toLowerCase())
|
|
232
|
+
return true;
|
|
233
|
+
if (adv.id && ref === String(adv.id))
|
|
234
|
+
return true;
|
|
235
|
+
if (ref === adv.package)
|
|
236
|
+
return true;
|
|
237
|
+
return false;
|
|
238
|
+
}
|
|
239
|
+
/** Resolve the effective mode from config + the TOSIJS_AUDIT env override. */
|
|
240
|
+
export function resolveAuditMode(config) {
|
|
241
|
+
const env = process.env.TOSIJS_AUDIT?.toLowerCase();
|
|
242
|
+
if (env === 'off' || env === 'warn' || env === 'fail')
|
|
243
|
+
return env;
|
|
244
|
+
if (config === false)
|
|
245
|
+
return 'off';
|
|
246
|
+
if (config === true || config === undefined)
|
|
247
|
+
return 'fail'; // on by default
|
|
248
|
+
return config.mode ?? 'fail';
|
|
249
|
+
}
|
|
250
|
+
/*
|
|
251
|
+
"Audit ONCE per process, never on a watch rebuild" is the real invariant, and it
|
|
252
|
+
used to be enforced by three separate call sites remembering to pass `skipAudit`
|
|
253
|
+
— which means it was owned by nobody. The documented adopter pattern already broke
|
|
254
|
+
it: `doc-site-system.md` tells you to hand `devServer` a `{ build }` that calls
|
|
255
|
+
`buildSite(config)`, with no `skipAudit`, so every keystroke rebuild fired a
|
|
256
|
+
registry round-trip — a network call in the edit loop, and offline dev broken, for
|
|
257
|
+
anyone who followed the docs.
|
|
258
|
+
|
|
259
|
+
Own it here instead. The first real audit in a process is remembered and every
|
|
260
|
+
later call returns it, so the invariant holds no matter how many callers there are
|
|
261
|
+
or which of them forgot a flag. `skipAudit` still short-circuits earlier (it avoids
|
|
262
|
+
even the first call), and an injected `runAudit` always re-runs — tests need to
|
|
263
|
+
drive many scenarios in one process, and memoizing those would make them lie.
|
|
264
|
+
*/
|
|
265
|
+
let processAudit = null;
|
|
266
|
+
/** Test-only: forget the per-process audit memo. */
|
|
267
|
+
export function resetAuditMemo() {
|
|
268
|
+
processAudit = null;
|
|
269
|
+
}
|
|
270
|
+
/** Remember a real (non-injected) audit so later callers reuse it. */
|
|
271
|
+
function memo(result, injected) {
|
|
272
|
+
if (!injected)
|
|
273
|
+
processAudit = result;
|
|
274
|
+
return result;
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Run `bun audit`, classify findings against the configured threshold and the
|
|
278
|
+
* time-boxed allowlist, and return a structured verdict. Never exits.
|
|
279
|
+
*
|
|
280
|
+
* Memoized per process (see above) unless `opts.runAudit` is injected.
|
|
281
|
+
*/
|
|
282
|
+
export async function auditDependencies(config, opts = {}) {
|
|
283
|
+
if (!opts.runAudit && processAudit)
|
|
284
|
+
return processAudit;
|
|
285
|
+
const mode = resolveAuditMode(config);
|
|
286
|
+
const cfg = config && typeof config === 'object' ? config : {};
|
|
287
|
+
const level = cfg.level ?? 'high';
|
|
288
|
+
const now = opts.now ?? new Date();
|
|
289
|
+
const today = isoDay(now);
|
|
290
|
+
const base = {
|
|
291
|
+
ran: false,
|
|
292
|
+
ok: true,
|
|
293
|
+
mode,
|
|
294
|
+
level,
|
|
295
|
+
blocking: [],
|
|
296
|
+
gated: [],
|
|
297
|
+
expired: [],
|
|
298
|
+
invalid: [],
|
|
299
|
+
stale: [],
|
|
300
|
+
belowThreshold: [],
|
|
301
|
+
};
|
|
302
|
+
// Not memoized: nothing ran, so there is no result to reuse.
|
|
303
|
+
if (mode === 'off')
|
|
304
|
+
return base;
|
|
305
|
+
let text;
|
|
306
|
+
try {
|
|
307
|
+
text = await (opts.runAudit ?? defaultRunner)();
|
|
308
|
+
}
|
|
309
|
+
catch {
|
|
310
|
+
return memo(base, !!opts.runAudit); // couldn't even spawn — fail open
|
|
311
|
+
}
|
|
312
|
+
const advisories = parseAuditJson(text);
|
|
313
|
+
// Unparseable/empty => couldn't check. Memoized so a watch session doesn't
|
|
314
|
+
// retry the registry on every rebuild while offline.
|
|
315
|
+
if (advisories === null)
|
|
316
|
+
return memo(base, !!opts.runAudit);
|
|
317
|
+
const result = { ...base, ran: true };
|
|
318
|
+
const threshold = SEVERITY_RANK[level];
|
|
319
|
+
// Validate gates once; an invalid gate (no reason / no valid date) never
|
|
320
|
+
// suppresses — that is what "explicitly and specifically gated" means.
|
|
321
|
+
const gates = cfg.allow ?? [];
|
|
322
|
+
const validGates = [];
|
|
323
|
+
for (const gate of gates) {
|
|
324
|
+
const problems = [];
|
|
325
|
+
if (!gate.advisory?.trim())
|
|
326
|
+
problems.push('no advisory id/package');
|
|
327
|
+
if (!gate.reason?.trim())
|
|
328
|
+
problems.push('no reason');
|
|
329
|
+
if (!gate.expires?.trim() || !DATE_RE.test(gate.expires.trim())) {
|
|
330
|
+
problems.push('no valid expires (YYYY-MM-DD)');
|
|
331
|
+
}
|
|
332
|
+
else if (Number.isNaN(Date.parse(gate.expires.trim() + 'T00:00:00Z'))) {
|
|
333
|
+
problems.push('unparseable expires date');
|
|
334
|
+
}
|
|
335
|
+
if (problems.length) {
|
|
336
|
+
result.invalid.push({ gate, problem: problems.join('; ') });
|
|
337
|
+
}
|
|
338
|
+
else {
|
|
339
|
+
validGates.push(gate);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
const usedGates = new Set();
|
|
343
|
+
for (const adv of advisories) {
|
|
344
|
+
if (SEVERITY_RANK[adv.severity] < threshold) {
|
|
345
|
+
result.belowThreshold.push(adv);
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
const gate = validGates.find((g) => gateMatches(g, adv));
|
|
349
|
+
if (!gate) {
|
|
350
|
+
result.blocking.push(adv);
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
usedGates.add(gate);
|
|
354
|
+
const expires = gate.expires.trim();
|
|
355
|
+
if (today >= expires) {
|
|
356
|
+
// Gate has expired (inclusive of the expiry day) — re-evaluate; it blocks.
|
|
357
|
+
result.expired.push({
|
|
358
|
+
advisory: adv,
|
|
359
|
+
reason: gate.reason,
|
|
360
|
+
daysAgo: daysBetween(expires, today),
|
|
361
|
+
});
|
|
362
|
+
result.blocking.push(adv);
|
|
363
|
+
}
|
|
364
|
+
else {
|
|
365
|
+
result.gated.push({
|
|
366
|
+
advisory: adv,
|
|
367
|
+
reason: gate.reason,
|
|
368
|
+
daysLeft: daysBetween(today, expires),
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
// Valid gates that suppressed nothing this run — the advisory is gone; delete them.
|
|
373
|
+
result.stale = validGates.filter((g) => !usedGates.has(g));
|
|
374
|
+
result.ok = result.blocking.length === 0;
|
|
375
|
+
return memo(result, !!opts.runAudit);
|
|
376
|
+
}
|
|
377
|
+
const DUE_DILIGENCE = [
|
|
378
|
+
'Before adopting a patch:',
|
|
379
|
+
' • Read the advisory. Confirm the patched version genuinely fixes it and is',
|
|
380
|
+
' published by the package’s real maintainers (guard against hijacked releases).',
|
|
381
|
+
' • Prefer the MINIMAL fix — a targeted `overrides`/`resolutions` pin to the',
|
|
382
|
+
' patched version — over a broad `bun update --latest` that churns dozens of',
|
|
383
|
+
' transitive deps, each a fresh supply-chain surface.',
|
|
384
|
+
' • Treat large churn in a "patch" as itself suspicious; review what moved.',
|
|
385
|
+
' • If you can’t patch now, GATE it (reason + near-term expiry) — don’t silence it.',
|
|
386
|
+
].join('\n');
|
|
387
|
+
export function groupAdvisories(advisories) {
|
|
388
|
+
const groups = new Map();
|
|
389
|
+
for (const adv of advisories) {
|
|
390
|
+
// Same package AND same advisory → one entry. Different packages stay
|
|
391
|
+
// separate even under a shared advisory id (they're genuinely different fixes).
|
|
392
|
+
const key = `${adv.package}::${adv.ghsa ?? adv.id}`;
|
|
393
|
+
const existing = groups.get(key);
|
|
394
|
+
if (existing) {
|
|
395
|
+
if (adv.vulnerableVersions && !existing.ranges.includes(adv.vulnerableVersions)) {
|
|
396
|
+
existing.ranges.push(adv.vulnerableVersions);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
else {
|
|
400
|
+
groups.set(key, {
|
|
401
|
+
advisory: adv,
|
|
402
|
+
ranges: adv.vulnerableVersions ? [adv.vulnerableVersions] : [],
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
return Array.from(groups.values());
|
|
407
|
+
}
|
|
408
|
+
/*
|
|
409
|
+
Order by what a reader must act on FIRST. Severity descending, then by nature —
|
|
410
|
+
`compromise` and `unknown` ahead of `dos`, because "can execute code" outranks "can
|
|
411
|
+
be made slow". On the real tree this was not cosmetic: the ONE critical (a happy-dom
|
|
412
|
+
VM-context-escape → RCE) printed sixth, wedged between ReDoS entries, purely because
|
|
413
|
+
`bun audit` emits in package order.
|
|
414
|
+
*/
|
|
415
|
+
const NATURE_RANK = {
|
|
416
|
+
compromise: 0,
|
|
417
|
+
unknown: 1,
|
|
418
|
+
dos: 2,
|
|
419
|
+
};
|
|
420
|
+
function bySeverityThenNature(a, b) {
|
|
421
|
+
const sev = SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity];
|
|
422
|
+
if (sev !== 0)
|
|
423
|
+
return sev;
|
|
424
|
+
const nature = NATURE_RANK[a.risk?.nature ?? 'unknown'] -
|
|
425
|
+
NATURE_RANK[b.risk?.nature ?? 'unknown'];
|
|
426
|
+
if (nature !== 0)
|
|
427
|
+
return nature;
|
|
428
|
+
return a.package.localeCompare(b.package);
|
|
429
|
+
}
|
|
430
|
+
function line(group) {
|
|
431
|
+
const adv = group.advisory;
|
|
432
|
+
// The risk label is triage sugar, not a verdict — this finding blocks either way.
|
|
433
|
+
const risk = adv.risk ? ` [${adv.risk.label}]` : '';
|
|
434
|
+
const basis = adv.risk ? `\n ${adv.risk.basis}` : '';
|
|
435
|
+
const ranges = group.ranges.length
|
|
436
|
+
? `\n affects ${group.ranges.join(', ')}`
|
|
437
|
+
: '';
|
|
438
|
+
return (` ${adv.severity.toUpperCase().padEnd(8)} ${adv.package}${risk}\n` +
|
|
439
|
+
` ${adv.title}${basis}${ranges}\n` +
|
|
440
|
+
` ${adv.ghsa ?? adv.id} — ${adv.url}`);
|
|
441
|
+
}
|
|
442
|
+
/*
|
|
443
|
+
Below-threshold findings used to be collected and never shown, which made them
|
|
444
|
+
invisible: a moderate today is a high the day someone re-scores it, and you want to
|
|
445
|
+
have seen it coming. They get ONE line each — severity, package, id, truncated title
|
|
446
|
+
— so the whole tail is scannable without competing with what actually blocks.
|
|
447
|
+
*/
|
|
448
|
+
function compactLine(group) {
|
|
449
|
+
const adv = group.advisory;
|
|
450
|
+
const title = adv.title.length > 68 ? adv.title.slice(0, 67) + '…' : adv.title;
|
|
451
|
+
return (` ${adv.severity.padEnd(8)} ${adv.package.padEnd(22)} ` +
|
|
452
|
+
`${(adv.ghsa ?? String(adv.id)).padEnd(22)} ${title}`);
|
|
453
|
+
}
|
|
454
|
+
/**
|
|
455
|
+
* Print the verdict. Blocking findings first (why the build is failing), then
|
|
456
|
+
* active gates, expired/invalid gates, stale gates, and — when blocking — the
|
|
457
|
+
* due-diligence footer. Returns nothing; the caller decides what to do with `ok`.
|
|
458
|
+
*/
|
|
459
|
+
export function reportAudit(result, label = 'Build') {
|
|
460
|
+
if (!result.ran) {
|
|
461
|
+
console.warn(`⚠️ ${label}: could not run \`bun audit\` (offline, registry down, or ` +
|
|
462
|
+
`unsupported bun). Skipping the dependency gate for this run.`);
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
if (result.gated.length) {
|
|
466
|
+
console.warn(`\n🔓 ${label}: ${result.gated.length} audit finding(s) allowed by an ` +
|
|
467
|
+
`active gate:\n` +
|
|
468
|
+
result.gated
|
|
469
|
+
.map((g) => ` ${g.advisory.severity.toUpperCase()} ${g.advisory.package} ` +
|
|
470
|
+
`(${g.advisory.ghsa ?? g.advisory.id}) — ${g.daysLeft}d left\n` +
|
|
471
|
+
` reason: ${g.reason}`)
|
|
472
|
+
.join('\n'));
|
|
473
|
+
}
|
|
474
|
+
if (result.stale.length) {
|
|
475
|
+
console.warn(`\n🧹 ${label}: ${result.stale.length} audit gate(s) match no current ` +
|
|
476
|
+
`advisory — remove them:\n` +
|
|
477
|
+
result.stale.map((g) => ` ${g.advisory} — ${g.reason}`).join('\n'));
|
|
478
|
+
}
|
|
479
|
+
// Everything below the blocking threshold, one line each, severity-sorted. These
|
|
480
|
+
// used to be collected and never printed — invisible until the day one is
|
|
481
|
+
// re-scored upward. Shown whether or not the build is failing.
|
|
482
|
+
if (result.belowThreshold.length) {
|
|
483
|
+
const groups = groupAdvisories(result.belowThreshold).sort((a, b) => bySeverityThenNature(a.advisory, b.advisory));
|
|
484
|
+
console.warn(`\nℹ️ ${label}: ${groups.length} advisory(ies) below the ${result.level} ` +
|
|
485
|
+
`threshold — not blocking:\n` +
|
|
486
|
+
groups.map(compactLine).join('\n'));
|
|
487
|
+
}
|
|
488
|
+
/*
|
|
489
|
+
Advisory COUNT PER PACKAGE, across every severity — the code-smell signal.
|
|
490
|
+
|
|
491
|
+
A single moderate advisory is noise; a dependency that keeps generating them is
|
|
492
|
+
telling you something the per-finding view cannot. Deciding to drop a library
|
|
493
|
+
with a long tail of quasi-flaky advisories is a legitimate engineering call, and
|
|
494
|
+
it needs the aggregate, which is exactly what a threshold-filtered report hides.
|
|
495
|
+
Only printed when something actually repeats, so a healthy tree stays silent.
|
|
496
|
+
*/
|
|
497
|
+
const perPackage = new Map();
|
|
498
|
+
for (const adv of [...result.blocking, ...result.belowThreshold]) {
|
|
499
|
+
const key = `${adv.package}::${adv.ghsa ?? adv.id}`;
|
|
500
|
+
if (!perPackage.has(key))
|
|
501
|
+
perPackage.set(key, 0);
|
|
502
|
+
}
|
|
503
|
+
const tally = new Map();
|
|
504
|
+
for (const key of perPackage.keys()) {
|
|
505
|
+
const pkg = key.split('::')[0];
|
|
506
|
+
tally.set(pkg, (tally.get(pkg) ?? 0) + 1);
|
|
507
|
+
}
|
|
508
|
+
const repeat = Array.from(tally.entries())
|
|
509
|
+
.filter(([, n]) => n > 1)
|
|
510
|
+
.sort((a, b) => b[1] - a[1]);
|
|
511
|
+
if (repeat.length) {
|
|
512
|
+
console.warn(`\n📊 ${label}: advisories per package — a package that keeps producing them ` +
|
|
513
|
+
`may be worth replacing, not just patching:\n` +
|
|
514
|
+
repeat.map(([pkg, n]) => ` ${String(n).padStart(3)} ${pkg}`).join('\n'));
|
|
515
|
+
}
|
|
516
|
+
if (result.ok) {
|
|
517
|
+
if (result.blocking.length === 0 && result.ran) {
|
|
518
|
+
// Quiet success line so the build log shows the gate ran.
|
|
519
|
+
console.log(`✅ ${label}: dependency audit clean (level: ${result.level}).`);
|
|
520
|
+
}
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
const invalidNote = result.invalid.length
|
|
524
|
+
? `\n\n ${result.invalid.length} gate(s) ignored as invalid:\n` +
|
|
525
|
+
result.invalid.map((i) => ` • ${i.gate.advisory}: ${i.problem}`).join('\n')
|
|
526
|
+
: '';
|
|
527
|
+
const expiredNote = result.expired.length
|
|
528
|
+
? `\n\n ${result.expired.length} gate(s) have EXPIRED (re-evaluate):\n` +
|
|
529
|
+
result.expired
|
|
530
|
+
.map((g) => ` • ${g.advisory.package} (${g.advisory.ghsa ?? g.advisory.id}) — ` +
|
|
531
|
+
`expired ${g.daysAgo}d ago; was: ${g.reason}`)
|
|
532
|
+
.join('\n')
|
|
533
|
+
: '';
|
|
534
|
+
const verb = result.mode === 'warn' ? 'proceeding anyway' : 'failing the build';
|
|
535
|
+
const emoji = result.mode === 'warn' ? '⚠️' : '🛑';
|
|
536
|
+
// Group + sort so the count is the real workload and the worst thing is first.
|
|
537
|
+
const groups = groupAdvisories(result.blocking).sort((a, b) => bySeverityThenNature(a.advisory, b.advisory));
|
|
538
|
+
const packages = new Set(result.blocking.map((a) => a.package)).size;
|
|
539
|
+
// Only mention the raw finding count when grouping actually collapsed something,
|
|
540
|
+
// so the usual case stays quiet.
|
|
541
|
+
const spread = groups.length === result.blocking.length
|
|
542
|
+
? `in ${packages} package${packages === 1 ? '' : 's'}`
|
|
543
|
+
: `in ${packages} package${packages === 1 ? '' : 's'}, ` +
|
|
544
|
+
`from ${result.blocking.length} findings`;
|
|
545
|
+
console.error(`\n${emoji} ${label}: ${groups.length} unaddressed ` +
|
|
546
|
+
`${result.level}+ dependency advisory(ies) ${spread} — ${verb}.\n\n` +
|
|
547
|
+
groups.map(line).join('\n\n') +
|
|
548
|
+
expiredNote +
|
|
549
|
+
invalidNote +
|
|
550
|
+
`\n\n${DUE_DILIGENCE}\n\n` +
|
|
551
|
+
` To accept a risk on a deadline, add to your site config's ` +
|
|
552
|
+
`\`audit.allow\`:\n` +
|
|
553
|
+
` { advisory: '<GHSA-…|id|package>', reason: '…', expires: 'YYYY-MM-DD' }\n` +
|
|
554
|
+
` Turn the gate off with \`audit: false\` or TOSIJS_AUDIT=off (not recommended).\n`);
|
|
555
|
+
}
|
|
@@ -13,6 +13,8 @@ import { watch } from 'chokidar';
|
|
|
13
13
|
import { $, spawn } from 'bun';
|
|
14
14
|
import { buildSite } from './orchestrator';
|
|
15
15
|
import { preflight } from './preflight';
|
|
16
|
+
import { auditDependencies, reportAudit } from './audit-guard';
|
|
17
|
+
import { openDevBrowser } from './open-browser';
|
|
16
18
|
const TEST_RESULTS_FILE = '.browser-tests.json';
|
|
17
19
|
const DEFAULT_IDLE_HOURS = 8;
|
|
18
20
|
/**
|
|
@@ -374,7 +376,7 @@ export async function devServer(config, opts = {}) {
|
|
|
374
376
|
console.error('');
|
|
375
377
|
}
|
|
376
378
|
else if (results.passed > 0) {
|
|
377
|
-
console.log(`\n
|
|
379
|
+
console.log(`\n✅ Browser tests: ${results.passed} passed\n`);
|
|
378
380
|
}
|
|
379
381
|
if (testReportResolve && (results.passed > 0 || results.failed > 0)) {
|
|
380
382
|
testReportResolve(results);
|
|
@@ -470,7 +472,21 @@ export async function devServer(config, opts = {}) {
|
|
|
470
472
|
`should be ~0 — if this keeps climbing, restart and report it.`);
|
|
471
473
|
}
|
|
472
474
|
};
|
|
473
|
-
|
|
475
|
+
/*
|
|
476
|
+
Serve WITHOUT watching (`DEV_NO_WATCH=1`).
|
|
477
|
+
|
|
478
|
+
An automated suite wants a server, not a rebuilder. Playwright's `webServer` ran
|
|
479
|
+
`bun start`, so a watcher sat live for the whole run and `rm -rf docs/` +
|
|
480
|
+
regenerate could land underneath a test that was mid-navigation — the lane
|
|
481
|
+
flaked against its own build system, which reads as a product bug and is the
|
|
482
|
+
worst kind of red. `testMode` already implied this, but that flag also means
|
|
483
|
+
"drive haltija", so it could not be reused. Pair this with dropping `--watch`
|
|
484
|
+
from the command to silence bun's own watcher too.
|
|
485
|
+
*/
|
|
486
|
+
const noWatch = process.env.DEV_NO_WATCH === '1' || process.env.DEV_NO_WATCH === 'true';
|
|
487
|
+
if (noWatch)
|
|
488
|
+
console.log('Watching disabled (DEV_NO_WATCH) — serving only.');
|
|
489
|
+
if (!testMode && !noWatch) {
|
|
474
490
|
// Rebuild on any source change. By default that's just buildSite(), but a
|
|
475
491
|
// consumer whose full build has steps BEYOND buildSite — e.g. a custom IIFE
|
|
476
492
|
// bundle built separately (because it needs a Bun plugin buildSite can't
|
|
@@ -479,7 +495,10 @@ export async function devServer(config, opts = {}) {
|
|
|
479
495
|
// (iife.js, etc.) is deleted on the first rebuild and, without opts.build,
|
|
480
496
|
// never regenerated — leaving the page's /iife.js to 404 into the SPA
|
|
481
497
|
// fallback (it "loads as html"). Serialize builds and coalesce bursts.
|
|
482
|
-
|
|
498
|
+
// Watch rebuilds skip the dependency audit: it hits the registry over the
|
|
499
|
+
// network, and re-running it on every hot-reload save would add latency to the
|
|
500
|
+
// edit loop and break offline dev. The audit ran once at launch (below).
|
|
501
|
+
const runBuild = opts.build ?? (() => buildSite(config, { skipAudit: true }));
|
|
483
502
|
// Rebuild-storm detector.
|
|
484
503
|
//
|
|
485
504
|
// The other way this process eats the machine is not a leak but a LOOP: if the
|
|
@@ -577,6 +596,30 @@ export async function devServer(config, opts = {}) {
|
|
|
577
596
|
void rebuild();
|
|
578
597
|
});
|
|
579
598
|
}
|
|
599
|
+
// ── dependency audit (synchronous, before we bind the port) ─────────────────
|
|
600
|
+
//
|
|
601
|
+
// Runs BEFORE the server comes up, not after. The audit is sub-second (local
|
|
602
|
+
// resolution + one registry round-trip, bounded by AUDIT_TIMEOUT_MS and failing
|
|
603
|
+
// open on timeout), so the wait costs nothing next to the alternative: an async
|
|
604
|
+
// audit spikes a server that is already listening and possibly already in use,
|
|
605
|
+
// which reads as a crash and races whatever the developer started doing in the
|
|
606
|
+
// meantime. A gate you wait for cannot be raced.
|
|
607
|
+
//
|
|
608
|
+
// THROWS rather than exits: `devServer` is a public export of `tosijs-ui/site`,
|
|
609
|
+
// and refusing to start is the caller's business to handle — same contract as the
|
|
610
|
+
// launch-time preflight above. (The health tick below still exits, because by then
|
|
611
|
+
// we own a running server and stopping it IS the guard.)
|
|
612
|
+
// Interactive only — `--test` gates synchronously in buildSite().
|
|
613
|
+
if (!testMode) {
|
|
614
|
+
const audit = await auditDependencies(config.audit);
|
|
615
|
+
if (audit.mode !== 'off')
|
|
616
|
+
reportAudit(audit, 'Dev server');
|
|
617
|
+
if (!audit.ok && audit.mode === 'fail') {
|
|
618
|
+
throw new Error('dev server: refusing to start — unaddressed dependency advisory (see above). ' +
|
|
619
|
+
"Fix it, gate it with a reason + expiry in `audit.allow`, set `audit: { mode: 'warn' }`, " +
|
|
620
|
+
'or TOSIJS_AUDIT=off.');
|
|
621
|
+
}
|
|
622
|
+
}
|
|
580
623
|
await ensureDevCerts();
|
|
581
624
|
const server = Bun.serve({
|
|
582
625
|
port: PORT,
|
|
@@ -624,6 +667,19 @@ export async function devServer(config, opts = {}) {
|
|
|
624
667
|
},
|
|
625
668
|
});
|
|
626
669
|
console.log(`Listening on https://localhost:${PORT}`);
|
|
670
|
+
// ── open (or bring to front) this project's browser tab ─────────────────────
|
|
671
|
+
//
|
|
672
|
+
// create-react-app's "open the tab" trick: reuse the project's existing tab
|
|
673
|
+
// instead of spawning a new one on every launch/restart. Interactive only, off
|
|
674
|
+
// by default (config.openBrowser), and self-skips in CI / non-TTY. Best-effort,
|
|
675
|
+
// fire-and-forget — never blocks startup, never throws. See open-browser.ts.
|
|
676
|
+
if (!testMode) {
|
|
677
|
+
void openDevBrowser({
|
|
678
|
+
url: `https://localhost:${PORT}/`,
|
|
679
|
+
setting: config.openBrowser,
|
|
680
|
+
name: config.name,
|
|
681
|
+
});
|
|
682
|
+
}
|
|
627
683
|
// ── health tick ───────────────────────────────────────────────────────────
|
|
628
684
|
//
|
|
629
685
|
// Everything else in this file is edge-triggered: the RSS check fires after a
|
|
@@ -18,6 +18,8 @@ export interface GenerateSiteConfig {
|
|
|
18
18
|
/** default og:image (per-page overridable via doc metadata) */
|
|
19
19
|
ogImage?: string;
|
|
20
20
|
projectLinks?: ProjectLinks;
|
|
21
|
+
/** brand mark left of the title: icon name, image URL, or inline <svg> */
|
|
22
|
+
logo?: string;
|
|
21
23
|
/** header-bar links (rendered as real <a> for no-JS, upgraded on hydration) */
|
|
22
24
|
navbarLinks?: LinkItem[];
|
|
23
25
|
/** translation table (TSV) for the settings menu's language picker */
|
|
@@ -251,6 +251,7 @@ export async function generateSite(config) {
|
|
|
251
251
|
const configAttr = escapeAttr(JSON.stringify({
|
|
252
252
|
projectName: config.projectName,
|
|
253
253
|
projectLinks: config.projectLinks,
|
|
254
|
+
logo: config.logo,
|
|
254
255
|
}));
|
|
255
256
|
// The theme stylesheet (config.stylesUrl) is written separately by
|
|
256
257
|
// ./generate-css.ts; pages here just <link> to it.
|