@blamejs/exceptd-skills 0.15.47 → 0.15.49
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 +8 -0
- package/data/_indexes/_meta.json +2 -2
- package/lib/collectors/cicd-pipeline-compromise.js +4 -4
- package/lib/lint-skills.js +3 -1
- package/lib/playbook-runner.js +1 -1
- package/lib/sign.js +2 -1
- package/lib/upstream-check-cli.js +3 -1
- package/lib/validate-catalog-meta.js +6 -2
- package/lib/validate-cve-catalog.js +7 -3
- package/lib/validate-package.js +5 -2
- package/lib/validate-playbooks.js +9 -4
- package/lib/validate-vendor.js +5 -2
- package/lib/verify.js +1 -1
- package/manifest.json +44 -44
- package/package.json +3 -1
- package/sbom.cdx.json +81 -36
- package/scripts/check-codebase-patterns-currency.js +142 -0
- package/scripts/check-codebase-patterns.js +296 -0
- package/scripts/predeploy.js +13 -0
- package/scripts/release.js +600 -0
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
/**
|
|
4
|
+
* check-codebase-patterns.js — grep-gate enforcement for code-shape bug
|
|
5
|
+
* classes that have recurred across exceptd releases. One run surfaces every
|
|
6
|
+
* class as a single numbered report instead of dying on the first hit.
|
|
7
|
+
*
|
|
8
|
+
* Shipped v1 classes:
|
|
9
|
+
* - process-exit-after-stdout-write : a library-callable function writes to
|
|
10
|
+
* the result channel (process.stdout.write / console.log) and then calls
|
|
11
|
+
* process.exit(), which truncates the buffered write when stdout is
|
|
12
|
+
* piped. Route through `safeExit(EXIT_CODES.X); return;` (lib/exit-codes).
|
|
13
|
+
* This is the stdout-flush-truncation class the validate-cves fix closed by hand.
|
|
14
|
+
* - dynamic-regex : `new RegExp(<non-literal>)` — a ReDoS sink when the
|
|
15
|
+
* pattern derives from operator input. Use a static literal, or anchor +
|
|
16
|
+
* length-cap the input, or mark the site `// allow:dynamic-regex —
|
|
17
|
+
* <reason>` when the source is a trusted bundled schema.
|
|
18
|
+
* - orphan-allow-class : an `// allow:<class>` marker whose class is not in
|
|
19
|
+
* VALID_ALLOW_CLASSES, or is missing the `— <reason>` tail. A typo'd
|
|
20
|
+
* marker suppresses nothing, so the underlying violation would ship
|
|
21
|
+
* unflagged — this meta-guard keeps the marker mechanism trustworthy.
|
|
22
|
+
*
|
|
23
|
+
* Exceptions live at the violation site, not in this file:
|
|
24
|
+
* - file-level, in the first 50 lines: // codebase-patterns:allow-file <class> — <reason>
|
|
25
|
+
* - per-line, on the same line or up to 2 lines above: // allow:<class> — <reason>
|
|
26
|
+
*
|
|
27
|
+
* NOT covered here (owned elsewhere — do not duplicate):
|
|
28
|
+
* - internal phase/version vocabulary in comments -> scripts/check-version-tags.js
|
|
29
|
+
* - process.exit on the top-level CLI dispatch -> tests/safe-exit-grep.test.js
|
|
30
|
+
* - anti-coincidence test assertions -> scripts/check-test-coverage.js
|
|
31
|
+
* - internal-path leaks in operator output -> tests/operator-leak-grep.test.js
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
const fs = require("node:fs");
|
|
35
|
+
const path = require("node:path");
|
|
36
|
+
|
|
37
|
+
const ROOT = path.resolve(__dirname, "..");
|
|
38
|
+
|
|
39
|
+
// The classes that accept an `// allow:<class>` marker. orphan-allow-class is
|
|
40
|
+
// the meta-guard itself and is intentionally NOT a markable class.
|
|
41
|
+
const VALID_ALLOW_CLASSES = Object.freeze({
|
|
42
|
+
"process-exit-after-stdout-write": true,
|
|
43
|
+
"dynamic-regex": true,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const EXCLUDE_DIRS = new Set([
|
|
47
|
+
"node_modules", "vendor", ".git", ".cache", ".scratch",
|
|
48
|
+
"data", ".test-output", ".keys", "keys", "coverage",
|
|
49
|
+
]);
|
|
50
|
+
|
|
51
|
+
// ---- file walk -----------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
function relPath(abs) {
|
|
54
|
+
return path.relative(ROOT, abs).split(path.sep).join("/");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function walk(dir, out) {
|
|
58
|
+
let entries;
|
|
59
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
|
|
60
|
+
catch (_e) { return out; }
|
|
61
|
+
for (const e of entries) {
|
|
62
|
+
const abs = path.join(dir, e.name);
|
|
63
|
+
if (e.isDirectory()) {
|
|
64
|
+
if (EXCLUDE_DIRS.has(e.name)) continue;
|
|
65
|
+
walk(abs, out);
|
|
66
|
+
} else if (e.isFile() && /\.(c|m)?js$/.test(e.name) && !/\.test\.js$/.test(e.name)) {
|
|
67
|
+
out.push(abs);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Source files under the given top-level roots, as repo-relative POSIX paths.
|
|
74
|
+
function filesUnder(roots) {
|
|
75
|
+
const out = [];
|
|
76
|
+
for (const r of roots) {
|
|
77
|
+
const abs = path.join(ROOT, r);
|
|
78
|
+
try {
|
|
79
|
+
const st = fs.statSync(abs);
|
|
80
|
+
if (st.isDirectory()) walk(abs, out);
|
|
81
|
+
else if (st.isFile()) out.push(abs);
|
|
82
|
+
} catch (_e) { /* missing root — skip */ }
|
|
83
|
+
}
|
|
84
|
+
return out.map(relPath).sort();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const _lineCache = new Map();
|
|
88
|
+
function readLines(rel) {
|
|
89
|
+
if (_lineCache.has(rel)) return _lineCache.get(rel);
|
|
90
|
+
const abs = path.isAbsolute(rel) ? rel : path.join(ROOT, rel);
|
|
91
|
+
let lines;
|
|
92
|
+
try { lines = fs.readFileSync(abs, "utf8").split(/\r?\n/); }
|
|
93
|
+
catch (_e) { lines = []; }
|
|
94
|
+
_lineCache.set(rel, lines);
|
|
95
|
+
return lines;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Strip a trailing `//` line comment for code-shape detection (so a class
|
|
99
|
+
// name mentioned in a comment doesn't arm a detector). Leaves string contents
|
|
100
|
+
// alone enough for the coarse line-level checks here.
|
|
101
|
+
function stripLineComment(line) {
|
|
102
|
+
const idx = line.indexOf("//");
|
|
103
|
+
return idx === -1 ? line : line.slice(0, idx);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ---- allow-marker engine -------------------------------------------------
|
|
107
|
+
|
|
108
|
+
function hasFileAllow(rel, cls) {
|
|
109
|
+
const head = readLines(rel).slice(0, 50);
|
|
110
|
+
const re = new RegExp("codebase-patterns:allow-file\\s+" + cls + "\\b");
|
|
111
|
+
return head.some((l) => re.test(l));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function hasLineAllow(rel, lineNo /* 1-based */, cls) {
|
|
115
|
+
const lines = readLines(rel);
|
|
116
|
+
const re = new RegExp("//.*\\ballow:" + cls + "\\b");
|
|
117
|
+
for (let n = lineNo; n >= lineNo - 2 && n >= 1; n--) {
|
|
118
|
+
if (re.test(lines[n - 1] || "")) return true;
|
|
119
|
+
}
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function filterMarkers(hits, cls) {
|
|
124
|
+
return hits.filter((h) => !hasFileAllow(h.file, cls) && !hasLineAllow(h.file, h.line, cls));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ---- require.main block ranges -------------------------------------------
|
|
128
|
+
|
|
129
|
+
// Line ranges (1-based, inclusive) of `if (require.main === module) { ... }`
|
|
130
|
+
// blocks — the dual-mode CLI-entry section where synchronous-print-then-exit
|
|
131
|
+
// is correct. process.exit there is owned by tests/safe-exit-grep.test.js and
|
|
132
|
+
// is not a library-surface concern.
|
|
133
|
+
function requireMainRanges(lines) {
|
|
134
|
+
const ranges = [];
|
|
135
|
+
for (let i = 0; i < lines.length; i++) {
|
|
136
|
+
if (/\brequire\.main\s*===\s*module\b/.test(lines[i])) {
|
|
137
|
+
// Find the opening brace (same line or next few), then balance.
|
|
138
|
+
let depth = 0;
|
|
139
|
+
let started = false;
|
|
140
|
+
let j = i;
|
|
141
|
+
for (; j < lines.length; j++) {
|
|
142
|
+
for (const ch of lines[j]) {
|
|
143
|
+
if (ch === "{") { depth++; started = true; }
|
|
144
|
+
else if (ch === "}") { depth--; }
|
|
145
|
+
}
|
|
146
|
+
if (started && depth <= 0) break;
|
|
147
|
+
}
|
|
148
|
+
if (started) ranges.push([i + 1, j + 1]);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return ranges;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function inRanges(ranges, lineNo) {
|
|
155
|
+
return ranges.some(([a, b]) => lineNo >= a && lineNo <= b);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ---- detectors -----------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
// A line that opens a new function body (so a backward stdout-write scan stops
|
|
161
|
+
// at the enclosing function and doesn't arm an exit from an unrelated earlier
|
|
162
|
+
// function).
|
|
163
|
+
const FUNCTION_START = /(^|[^.\w])function\b|=>\s*\{?\s*$|^\s*(async\s+)?[A-Za-z_$][\w$]*\s*\([^)]*\)\s*\{/;
|
|
164
|
+
|
|
165
|
+
function detectProcessExitAfterStdout(files) {
|
|
166
|
+
const hits = [];
|
|
167
|
+
for (const rel of (files || filesUnder(["lib", "orchestrator"]))) {
|
|
168
|
+
const lines = readLines(rel);
|
|
169
|
+
const mainRanges = requireMainRanges(lines);
|
|
170
|
+
for (let i = 0; i < lines.length; i++) {
|
|
171
|
+
const code = stripLineComment(lines[i]);
|
|
172
|
+
if (!/\bprocess\.exit\s*\(/.test(code)) continue;
|
|
173
|
+
const lineNo = i + 1;
|
|
174
|
+
if (inRanges(mainRanges, lineNo)) continue; // CLI-entry block: legitimate
|
|
175
|
+
// Scan backward within the enclosing function for a result-channel
|
|
176
|
+
// write (console.log / process.stdout.write). Stop at a function start.
|
|
177
|
+
let sawStdout = false;
|
|
178
|
+
for (let k = i - 1; k >= 0 && k >= i - 60; k--) {
|
|
179
|
+
const prev = stripLineComment(lines[k]);
|
|
180
|
+
if (/\bprocess\.stdout\.write\s*\(/.test(prev) || /\bconsole\.log\s*\(/.test(prev)) {
|
|
181
|
+
sawStdout = true; break;
|
|
182
|
+
}
|
|
183
|
+
if (FUNCTION_START.test(prev)) break; // left the function body
|
|
184
|
+
}
|
|
185
|
+
if (sawStdout) hits.push({ file: rel, line: lineNo, content: lines[i].trim() });
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return filterMarkers(hits, "process-exit-after-stdout-write");
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function detectDynamicRegex(files) {
|
|
192
|
+
const hits = [];
|
|
193
|
+
for (const rel of (files || filesUnder(["lib", "orchestrator", "bin/exceptd.js"]))) {
|
|
194
|
+
const lines = readLines(rel);
|
|
195
|
+
for (let i = 0; i < lines.length; i++) {
|
|
196
|
+
const code = stripLineComment(lines[i]);
|
|
197
|
+
const m = code.match(/\bnew RegExp\s*\(\s*(.)/);
|
|
198
|
+
if (!m) continue;
|
|
199
|
+
// Literal first arg => a quote or a `/` regex literal => static, safe.
|
|
200
|
+
const firstChar = m[1];
|
|
201
|
+
if (firstChar === '"' || firstChar === "'" || firstChar === "/") continue;
|
|
202
|
+
hits.push({ file: rel, line: i + 1, content: lines[i].trim() });
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return filterMarkers(hits, "dynamic-regex");
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function detectOrphanAllowClass(files) {
|
|
209
|
+
const hits = [];
|
|
210
|
+
for (const rel of (files || filesUnder(["bin/exceptd.js", "lib", "orchestrator", "scripts"]))) {
|
|
211
|
+
if (rel === "scripts/check-codebase-patterns.js") continue; // holds the registry + regexes
|
|
212
|
+
const lines = readLines(rel);
|
|
213
|
+
for (let i = 0; i < lines.length; i++) {
|
|
214
|
+
const cmt = lines[i].indexOf("//");
|
|
215
|
+
if (cmt === -1) continue;
|
|
216
|
+
const comment = lines[i].slice(cmt);
|
|
217
|
+
// Validate BOTH marker forms with the same class + reason rules:
|
|
218
|
+
// per-line: allow:<class> — <reason>
|
|
219
|
+
// file-level: codebase-patterns:allow-file <class> — <reason>
|
|
220
|
+
// The file-level form is the broadest exemption (it suppresses every hit
|
|
221
|
+
// of its class in the file), so a reason-less or unknown-class file-level
|
|
222
|
+
// marker must be caught here too — otherwise it would suppress silently
|
|
223
|
+
// and never reach the per-line orphan check.
|
|
224
|
+
const fileLevel = comment.match(/\bcodebase-patterns:allow-file\s+([a-z0-9-]+)\b(.*)$/);
|
|
225
|
+
const perLine = comment.match(/\ballow:([a-z0-9-]+)\b(.*)$/);
|
|
226
|
+
const m = fileLevel || perLine;
|
|
227
|
+
if (!m) continue;
|
|
228
|
+
const cls = m[1];
|
|
229
|
+
const tail = m[2];
|
|
230
|
+
const label = fileLevel ? `allow-file ${cls}` : `allow:${cls}`;
|
|
231
|
+
if (!VALID_ALLOW_CLASSES[cls]) {
|
|
232
|
+
hits.push({ file: rel, line: i + 1, content: lines[i].trim(), why: `unknown allow-class "${cls}"` });
|
|
233
|
+
} else if (!/[—-]\s*\S/.test(tail)) {
|
|
234
|
+
hits.push({ file: rel, line: i + 1, content: lines[i].trim(), why: `${label} is missing the "— <reason>" tail` });
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return hits;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const CLASSES = [
|
|
242
|
+
{
|
|
243
|
+
id: "process-exit-after-stdout-write",
|
|
244
|
+
run: detectProcessExitAfterStdout,
|
|
245
|
+
warnOnly: false,
|
|
246
|
+
hint: "use `safeExit(EXIT_CODES.X); return;` (lib/exit-codes.js) — process.exit() truncates buffered stdout when piped",
|
|
247
|
+
},
|
|
248
|
+
{
|
|
249
|
+
id: "dynamic-regex",
|
|
250
|
+
run: detectDynamicRegex,
|
|
251
|
+
warnOnly: true, // flip to false next release once the known sites carry markers
|
|
252
|
+
hint: "RegExp from operator input is a ReDoS sink — anchor + length-cap, or `// allow:dynamic-regex — <reason>` when the pattern is a trusted bundled schema",
|
|
253
|
+
},
|
|
254
|
+
{
|
|
255
|
+
id: "orphan-allow-class",
|
|
256
|
+
run: detectOrphanAllowClass,
|
|
257
|
+
warnOnly: false,
|
|
258
|
+
hint: "a typo'd or reason-less `// allow:<class>` suppresses nothing — fix the class id or add `— <reason>`",
|
|
259
|
+
},
|
|
260
|
+
];
|
|
261
|
+
|
|
262
|
+
function main() {
|
|
263
|
+
let hardFail = 0;
|
|
264
|
+
let warnTotal = 0;
|
|
265
|
+
let n = 0;
|
|
266
|
+
for (const c of CLASSES) {
|
|
267
|
+
const hits = c.run();
|
|
268
|
+
if (!hits.length) { console.log(` ok ${c.id}: clean`); continue; }
|
|
269
|
+
for (const h of hits) {
|
|
270
|
+
n++;
|
|
271
|
+
const tag = c.warnOnly ? "[warn]" : "FAIL";
|
|
272
|
+
const extra = h.why ? ` (${h.why})` : "";
|
|
273
|
+
console.error(` ${n}. ${tag} ${c.id} ${h.file}:${h.line}: ${String(h.content).slice(0, 110)}${extra}`);
|
|
274
|
+
}
|
|
275
|
+
console.error(` -> ${c.hint}`);
|
|
276
|
+
if (c.warnOnly) warnTotal += hits.length; else hardFail += hits.length;
|
|
277
|
+
}
|
|
278
|
+
if (hardFail === 0) {
|
|
279
|
+
console.log(`[check-codebase-patterns] ok${warnTotal ? ` (${warnTotal} warning(s))` : ""}`);
|
|
280
|
+
process.exitCode = 0;
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
console.error(`[check-codebase-patterns] FAIL — ${hardFail} blocking violation(s).`);
|
|
284
|
+
process.exitCode = 1;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
module.exports = {
|
|
288
|
+
VALID_ALLOW_CLASSES,
|
|
289
|
+
CLASSES,
|
|
290
|
+
detectProcessExitAfterStdout,
|
|
291
|
+
detectDynamicRegex,
|
|
292
|
+
detectOrphanAllowClass,
|
|
293
|
+
filesUnder,
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
if (require.main === module) main();
|
package/scripts/predeploy.js
CHANGED
|
@@ -236,6 +236,19 @@ const GATES = [
|
|
|
236
236
|
args: [path.join(ROOT, "scripts", "check-agents-md-collectors.js")],
|
|
237
237
|
ciJobName: "Data integrity (catalog + manifest snapshot)",
|
|
238
238
|
},
|
|
239
|
+
{
|
|
240
|
+
// Codebase-pattern gate. Blocks the code-shape bug classes that
|
|
241
|
+
// recurred across releases: a library-callable function that writes to
|
|
242
|
+
// stdout then calls process.exit() (truncates the buffered write when
|
|
243
|
+
// piped — the stdout-flush-truncation class), and a stale/typo'd `// allow:` marker.
|
|
244
|
+
// dynamic-RegExp construction is surfaced warn-only this release. The
|
|
245
|
+
// exception mechanism + the "owned elsewhere" boundary are documented in
|
|
246
|
+
// the script header.
|
|
247
|
+
name: "Codebase-pattern gates (process-exit-after-stdout-write, dynamic RegExp)",
|
|
248
|
+
command: process.execPath,
|
|
249
|
+
args: [path.join(ROOT, "scripts", "check-codebase-patterns.js")],
|
|
250
|
+
ciJobName: "Data integrity (catalog + manifest snapshot)",
|
|
251
|
+
},
|
|
239
252
|
];
|
|
240
253
|
|
|
241
254
|
function runGate(gate) {
|