@blamejs/exceptd-skills 0.13.35 → 0.13.37

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,428 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * lib/collectors/library-author.js
5
+ *
6
+ * Companion collector for the `library-author` playbook. Audits a
7
+ * publisher-side repository for supply-chain posture markers:
8
+ * SECURITY.md / security.txt presence, sbom + signature pair,
9
+ * publish-workflow shape (id-token write, action-ref pinning, frozen
10
+ * install), package.json provenance opt-in, vendor-tree provenance,
11
+ * lockfile integrity.
12
+ *
13
+ * Skipped indicators (require external API or runtime data, left
14
+ * unflipped so the runner returns inconclusive rather than a forced
15
+ * miss):
16
+ *
17
+ * tag-protection-absent GitHub branch-protection API
18
+ * private-vuln-reporting-disabled GitHub repo-settings API
19
+ * no-rekor-entry-for-latest-release sigstore lookup
20
+ * release-tag-not-signed git tag --verify on each release
21
+ * release-signed-with-personal-gpg-key GPG identity policy
22
+ * sbom-regenerated-at-request-time timing-based; needs build
23
+ * pipeline trace
24
+ * skill-signing-but-verification-not-gated project-internal CI
25
+ * gate inspection
26
+ * ssdf-claimed-cra-not-ready operator interview shape
27
+ * gha-workflow-script-injection-sink complex pattern; future pass
28
+ *
29
+ * Interface: see lib/collectors/README.md
30
+ */
31
+
32
+ const fs = require("node:fs");
33
+ const path = require("node:path");
34
+
35
+ const COLLECTOR_ID = "library-author";
36
+
37
+ const DEFAULT_MAX_DEPTH = 6;
38
+ const DEFAULT_EXCLUDES = new Set([
39
+ "node_modules", ".git", "dist", "build", "out",
40
+ ".venv", "venv", "__pycache__", ".pytest_cache",
41
+ "target", ".idea", ".vscode",
42
+ ]);
43
+
44
+ function readSafe(full, max = 512 * 1024) {
45
+ try {
46
+ const s = fs.statSync(full);
47
+ if (s.size > max) return null;
48
+ return fs.readFileSync(full, "utf8");
49
+ } catch { return null; }
50
+ }
51
+
52
+ function walkWorkflows(root) {
53
+ const dir = path.join(root, ".github", "workflows");
54
+ if (!fs.existsSync(dir)) return [];
55
+ const out = [];
56
+ let entries;
57
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
58
+ catch { return []; }
59
+ for (const e of entries) {
60
+ if (!e.isFile()) continue;
61
+ if (!/\.(ya?ml)$/i.test(e.name)) continue;
62
+ const full = path.join(dir, e.name);
63
+ out.push({ full, name: e.name, rel: path.relative(root, full) });
64
+ }
65
+ return out;
66
+ }
67
+
68
+ function looksLikePublishWorkflow(name, content) {
69
+ // Heuristic: filename starts with `release`/`publish`/`deploy` OR
70
+ // the workflow body mentions `npm publish` / `pypi-publish` /
71
+ // `cargo publish` / `goreleaser` somewhere.
72
+ const lower = name.toLowerCase();
73
+ if (/^(release|publish|deploy)/.test(lower)) return true;
74
+ if (/\bnpm\s+publish\b/.test(content)) return true;
75
+ if (/pypa\/gh-action-pypi-publish/.test(content)) return true;
76
+ if (/\bcargo\s+publish\b/.test(content)) return true;
77
+ if (/goreleaser/.test(content)) return true;
78
+ if (/softprops\/action-gh-release/.test(content)) return true;
79
+ return false;
80
+ }
81
+
82
+ function scanPublishWorkflow(content, rel) {
83
+ const hits = {
84
+ "publish-workflow-uses-static-token": [],
85
+ "publish-workflow-no-id-token-write": [],
86
+ "publish-workflow-action-refs-mutable": [],
87
+ "release-workflow-non-frozen-install": [],
88
+ "publish-workflow-runs-on-self-hosted": [],
89
+ };
90
+
91
+ // static-token: workflow references a publish-credential secret
92
+ // without a corresponding `permissions: id-token: write`. The
93
+ // predicate (per data/playbooks/library-author.json) lists
94
+ // NPM_TOKEN / PYPI_TOKEN / CARGO_TOKEN / RUBYGEMS_API_KEY /
95
+ // GEM_HOST_API_KEY; expand to cover the common variants for each
96
+ // ecosystem.
97
+ const usesStaticToken = /\bsecrets\.(NPM_TOKEN|PYPI_TOKEN|PYPI_API_TOKEN|CARGO_TOKEN|CARGO_REGISTRY_TOKEN|RUBYGEMS_API_KEY|GEM_HOST_API_KEY|MAVEN_TOKEN|MAVEN_CENTRAL_TOKEN|GH_TOKEN)\b/.test(content);
98
+ const hasIdTokenWrite = /\bid-token:\s*write\b/.test(content);
99
+ if (usesStaticToken && !hasIdTokenWrite) {
100
+ hits["publish-workflow-uses-static-token"].push({ file: rel, line: 0, snippet: "publish workflow uses a static long-lived token (NPM_TOKEN / PYPI / Cargo / Maven) without id-token: write for OIDC" });
101
+ }
102
+ if (!hasIdTokenWrite) {
103
+ hits["publish-workflow-no-id-token-write"].push({ file: rel, line: 0, snippet: "permissions block lacks id-token: write — npm provenance / sigstore signing unavailable" });
104
+ }
105
+
106
+ // action-refs-mutable: any `uses: <action>@<ref>` where ref is NOT
107
+ // a 40-char hex sha. Excludes `uses: ./local-action`.
108
+ const lines = content.split(/\r?\n/);
109
+ for (let i = 0; i < lines.length; i++) {
110
+ const line = lines[i];
111
+ // Allow optional leading `-` from a YAML list item: `- uses: ...`.
112
+ const m = line.match(/^\s*-?\s*uses:\s*['"]?([^'"\s]+)['"]?\s*$/);
113
+ if (!m) continue;
114
+ const ref = m[1];
115
+ if (ref.startsWith("./") || ref.startsWith("./.github/")) continue; // local
116
+ const atIdx = ref.lastIndexOf("@");
117
+ if (atIdx === -1) continue;
118
+ const rev = ref.slice(atIdx + 1);
119
+ if (!/^[0-9a-f]{40}$/i.test(rev)) {
120
+ hits["publish-workflow-action-refs-mutable"].push({ file: rel, line: i + 1, snippet: line.trim() });
121
+ }
122
+ }
123
+
124
+ // non-frozen-install: workflow uses `npm install` instead of `npm ci`,
125
+ // or `pip install <pkg>` without `--require-hashes`, or `cargo
126
+ // install` without `--locked`.
127
+ if (/\bnpm\s+install\b/.test(content) && !/\bnpm\s+ci\b/.test(content)) {
128
+ hits["release-workflow-non-frozen-install"].push({ file: rel, line: 0, snippet: "publish workflow uses `npm install` rather than `npm ci` — lockfile is not enforced" });
129
+ }
130
+ if (/\bcargo\s+(?:build|install)\b/.test(content) && !/--locked\b/.test(content) && !/--frozen\b/.test(content)) {
131
+ hits["release-workflow-non-frozen-install"].push({ file: rel, line: 0, snippet: "cargo build/install without --locked / --frozen" });
132
+ }
133
+
134
+ // runs-on-self-hosted: any `runs-on: self-hosted` line.
135
+ if (/runs-on:\s*['"]?(?:self-hosted|\[?\s*self-hosted)/i.test(content)) {
136
+ hits["publish-workflow-runs-on-self-hosted"].push({ file: rel, line: 0, snippet: "publish workflow runs on a self-hosted runner — non-ephemeral execution context" });
137
+ }
138
+
139
+ return hits;
140
+ }
141
+
142
+ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
143
+ const errors = [];
144
+ const startTime = Date.now();
145
+ const root = path.resolve(cwd);
146
+
147
+ // package-manifest detection.
148
+ const manifests = [];
149
+ const manifestFiles = [
150
+ "package.json", "pyproject.toml", "Cargo.toml", "go.mod",
151
+ "composer.json", "build.gradle", "build.gradle.kts", "pom.xml",
152
+ "Gemfile",
153
+ ];
154
+ for (const f of manifestFiles) {
155
+ const p = path.join(root, f);
156
+ if (fs.existsSync(p)) {
157
+ manifests.push({ file: f, path: p, content: readSafe(p) });
158
+ }
159
+ }
160
+ // *.gemspec — glob at root only.
161
+ try {
162
+ for (const e of fs.readdirSync(root)) {
163
+ if (e.endsWith(".gemspec")) {
164
+ const p = path.join(root, e);
165
+ manifests.push({ file: e, path: p, content: readSafe(p) });
166
+ }
167
+ }
168
+ } catch {}
169
+
170
+ // Release / publish workflows.
171
+ const workflows = walkWorkflows(root).map(w => ({ ...w, content: readSafe(w.full) || "" }));
172
+ const publishWorkflows = workflows.filter(w => looksLikePublishWorkflow(w.name, w.content));
173
+
174
+ // Aggregate publish-workflow indicator hits.
175
+ const workflowHits = {
176
+ "publish-workflow-uses-static-token": [],
177
+ "publish-workflow-no-id-token-write": [],
178
+ "publish-workflow-action-refs-mutable": [],
179
+ "release-workflow-non-frozen-install": [],
180
+ "publish-workflow-runs-on-self-hosted": [],
181
+ };
182
+ for (const w of publishWorkflows) {
183
+ const h = scanPublishWorkflow(w.content, w.rel);
184
+ for (const [id, list] of Object.entries(h)) workflowHits[id].push(...list);
185
+ }
186
+
187
+ // package.json provenance: per playbook, the indicator fires
188
+ // when EITHER the manifest opts in (`publishConfig.provenance:
189
+ // true`) is missing AND the publish workflow does not invoke
190
+ // `npm publish --provenance`. Repos that flip provenance on at
191
+ // the workflow level (the modern path) should not be flagged.
192
+ // We need the workflow inventory above this point.
193
+ let provenanceMissing = "miss";
194
+ const pkgManifest = manifests.find(m => m.file === "package.json");
195
+ if (pkgManifest && pkgManifest.content) {
196
+ try {
197
+ const j = JSON.parse(pkgManifest.content);
198
+ const manifestOptIn = j?.publishConfig?.provenance === true;
199
+ const workflowOptIn = publishWorkflows.some(w => /npm\s+publish[^\n]*--provenance\b/.test(w.content));
200
+ provenanceMissing = (manifestOptIn || workflowOptIn) ? "miss" : "hit";
201
+ } catch (e) {
202
+ errors.push({ artifact_id: "package-manifest", kind: "parse_failed", reason: `package.json: ${e.message}` });
203
+ }
204
+ }
205
+
206
+ // lockfile-missing-integrity (per playbook): "any pinned entry
207
+ // in walked lockfiles lacks an integrity field (sha512/sha384/
208
+ // sha256, sri-integrity, go.sum h1: hash)". The predicate
209
+ // covers every ecosystem's lockfile shape, not just npm. Walk
210
+ // each supported lockfile and flip the indicator if any one of
211
+ // them has a missing-integrity entry. When NO lockfile is
212
+ // present, leave the indicator unflipped (undefined) so the
213
+ // runner returns inconclusive — a no-lockfile repo is not
214
+ // evidence of "no missing integrity"; it's evidence we can't
215
+ // tell, and the playbook covers that case as inconclusive.
216
+ let lockfileMissingIntegrity = undefined;
217
+ const lockfilesChecked = [];
218
+ const lockfileScans = [
219
+ {
220
+ file: "package-lock.json",
221
+ scan: (text) => {
222
+ const j = JSON.parse(text);
223
+ let missing = 0;
224
+ const walkObj = (obj) => {
225
+ if (!obj || typeof obj !== "object") return;
226
+ if (obj.resolved && obj.integrity == null) missing++;
227
+ for (const v of Object.values(obj)) if (v && typeof v === "object") walkObj(v);
228
+ };
229
+ walkObj(j.packages || j.dependencies || {});
230
+ return missing;
231
+ },
232
+ },
233
+ {
234
+ file: "yarn.lock",
235
+ scan: (text) => {
236
+ // yarn.lock entries: a `resolved "https://..."` line should be
237
+ // followed within ~5 lines by an `integrity sha512-...` line.
238
+ const lines = text.split(/\r?\n/);
239
+ let missing = 0;
240
+ for (let i = 0; i < lines.length; i++) {
241
+ if (!/^\s*resolved\s+/.test(lines[i])) continue;
242
+ const window = lines.slice(i + 1, Math.min(lines.length, i + 8)).join("\n");
243
+ if (!/integrity\s+sha\d{3}-/.test(window)) missing++;
244
+ }
245
+ return missing;
246
+ },
247
+ },
248
+ {
249
+ file: "pnpm-lock.yaml",
250
+ scan: (text) => {
251
+ // pnpm entries have an `integrity:` field. Count entries
252
+ // with `resolution: { tarball: ... }` lines whose nearby
253
+ // block lacks `integrity:`.
254
+ const blocks = text.split(/\n(?=\s+\/)/);
255
+ let missing = 0;
256
+ for (const b of blocks) {
257
+ if (!/resolution:/.test(b)) continue;
258
+ if (!/integrity:/.test(b)) missing++;
259
+ }
260
+ return missing;
261
+ },
262
+ },
263
+ {
264
+ file: "Cargo.lock",
265
+ scan: (text) => {
266
+ // Cargo.lock entries have `checksum = "..."` per [[package]]
267
+ // (registry deps only — path / git deps legitimately have
268
+ // none, so count only registry packages).
269
+ const packages = text.split(/^\[\[package\]\]/m).slice(1);
270
+ let missing = 0;
271
+ for (const p of packages) {
272
+ // Skip path / git deps — they have a `source = "<...>"`
273
+ // line; registry deps have `source = "registry+..."`.
274
+ const sourceMatch = p.match(/^source\s*=\s*"([^"]+)"/m);
275
+ if (!sourceMatch) continue; // path dep, no integrity expected
276
+ if (!sourceMatch[1].startsWith("registry+")) continue;
277
+ if (!/^checksum\s*=\s*"/m.test(p)) missing++;
278
+ }
279
+ return missing;
280
+ },
281
+ },
282
+ {
283
+ file: "go.sum",
284
+ scan: (text) => {
285
+ // go.sum entries are `<module> <version> h1:<hash>=`.
286
+ // Missing-integrity = a line without `h1:` (rare; malformed).
287
+ const lines = text.split(/\r?\n/).filter(Boolean);
288
+ let missing = 0;
289
+ for (const l of lines) {
290
+ if (!/\bh1:[A-Za-z0-9+/=]+\b/.test(l)) missing++;
291
+ }
292
+ return missing;
293
+ },
294
+ },
295
+ ];
296
+ for (const { file, scan } of lockfileScans) {
297
+ const p = path.join(root, file);
298
+ if (!fs.existsSync(p)) continue;
299
+ lockfilesChecked.push(file);
300
+ try {
301
+ const text = fs.readFileSync(p, "utf8");
302
+ const missing = scan(text);
303
+ if (missing > 0) {
304
+ lockfileMissingIntegrity = "hit";
305
+ break;
306
+ }
307
+ // If at least one lockfile scanned cleanly, the indicator
308
+ // moves to miss — we have evidence of integrity coverage.
309
+ if (lockfileMissingIntegrity === undefined) lockfileMissingIntegrity = "miss";
310
+ } catch (e) {
311
+ errors.push({
312
+ artifact_id: "lockfile",
313
+ kind: "lockfile_scan_failed",
314
+ reason: `${file}: ${e.message}`,
315
+ });
316
+ }
317
+ }
318
+ // If no supported lockfile was present, leave the indicator
319
+ // unflipped (undefined). The runner returns inconclusive.
320
+
321
+ // SECURITY.md presence (root + .github/SECURITY.md).
322
+ const securityMdPresent =
323
+ fs.existsSync(path.join(root, "SECURITY.md")) ||
324
+ fs.existsSync(path.join(root, ".github", "SECURITY.md"));
325
+
326
+ // security.txt presence (.well-known/security.txt + security.txt).
327
+ const securityTxtPresent =
328
+ fs.existsSync(path.join(root, ".well-known", "security.txt")) ||
329
+ fs.existsSync(path.join(root, "security.txt"));
330
+
331
+ // sbom-absent-or-unsigned: no sbom file at root, OR sbom file
332
+ // present but no matching .sig sidecar.
333
+ let sbomFile = null;
334
+ for (const f of ["sbom.cdx.json", "sbom.json", "bom.json", "sbom.spdx.json", "sbom.cdx.xml"]) {
335
+ if (fs.existsSync(path.join(root, f))) { sbomFile = f; break; }
336
+ }
337
+ let sbomAbsentOrUnsigned = "hit";
338
+ if (sbomFile) {
339
+ const sigPath = path.join(root, `${sbomFile}.sig`);
340
+ sbomAbsentOrUnsigned = fs.existsSync(sigPath) ? "miss" : "hit";
341
+ }
342
+
343
+ // vendored-no-provenance: vendor/ directory exists without a
344
+ // _PROVENANCE.json at any level inside it.
345
+ let vendoredNoProvenance = "miss";
346
+ const vendorDir = path.join(root, "vendor");
347
+ if (fs.existsSync(vendorDir)) {
348
+ let foundProvenance = false;
349
+ const walkVendor = (dir, depth) => {
350
+ if (depth > 3 || foundProvenance) return;
351
+ let entries;
352
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
353
+ for (const e of entries) {
354
+ if (e.name === "_PROVENANCE.json") { foundProvenance = true; return; }
355
+ if (e.isDirectory()) walkVendor(path.join(dir, e.name), depth + 1);
356
+ }
357
+ };
358
+ walkVendor(vendorDir, 0);
359
+ vendoredNoProvenance = foundProvenance ? "miss" : "hit";
360
+ }
361
+
362
+ const signal_overrides = {
363
+ "publish-workflow-uses-static-token": workflowHits["publish-workflow-uses-static-token"].length > 0 ? "hit" : "miss",
364
+ "publish-workflow-no-id-token-write": workflowHits["publish-workflow-no-id-token-write"].length > 0 ? "hit" : "miss",
365
+ "publish-workflow-action-refs-mutable": workflowHits["publish-workflow-action-refs-mutable"].length > 0 ? "hit" : "miss",
366
+ "release-workflow-non-frozen-install": workflowHits["release-workflow-non-frozen-install"].length > 0 ? "hit" : "miss",
367
+ "publish-workflow-runs-on-self-hosted": workflowHits["publish-workflow-runs-on-self-hosted"].length > 0 ? "hit" : "miss",
368
+ "package-json-provenance-missing": provenanceMissing,
369
+ "sbom-absent-or-unsigned": sbomAbsentOrUnsigned,
370
+ "no-security-md": securityMdPresent ? "miss" : "hit",
371
+ "no-security-txt": securityTxtPresent ? "miss" : "hit",
372
+ "vendored-no-provenance": vendoredNoProvenance,
373
+ };
374
+ // Conditionally include lockfile-missing-integrity — leave it
375
+ // out (so the runner returns inconclusive) when no supported
376
+ // lockfile was present to scan.
377
+ if (lockfileMissingIntegrity !== undefined) {
378
+ signal_overrides["lockfile-missing-integrity"] = lockfileMissingIntegrity;
379
+ }
380
+
381
+ const artifacts = {
382
+ "release-workflows": {
383
+ value: publishWorkflows.length
384
+ ? publishWorkflows.map(w => w.rel).join(", ") + ` (${publishWorkflows.length}/${workflows.length} workflows recognised as publish-related)`
385
+ : `${workflows.length} workflow(s); 0 recognised as publish-related`,
386
+ captured: true,
387
+ },
388
+ "package-manifest": {
389
+ value: manifests.length
390
+ ? manifests.map(m => m.file).join(", ")
391
+ : "no manifest file found at cwd root",
392
+ captured: manifests.length > 0,
393
+ reason: manifests.length === 0 ? "no package.json / Cargo.toml / pyproject.toml / etc. at cwd root" : undefined,
394
+ },
395
+ "supply-chain-posture-files": {
396
+ value: [
397
+ `SECURITY.md=${securityMdPresent}`,
398
+ `security.txt=${securityTxtPresent}`,
399
+ `sbom=${sbomFile || "(none)"}`,
400
+ `sbom_signed=${sbomFile && fs.existsSync(path.join(root, `${sbomFile}.sig`))}`,
401
+ `vendor_provenance=${vendoredNoProvenance === "miss" ? "present-or-no-vendor" : "missing"}`,
402
+ ].join("; "),
403
+ captured: true,
404
+ },
405
+ };
406
+
407
+ return {
408
+ precondition_checks: {
409
+ "publisher-context": manifests.length > 0,
410
+ },
411
+ artifacts,
412
+ signal_overrides,
413
+ collector_meta: {
414
+ collector_id: COLLECTOR_ID,
415
+ collector_version: "2026-05-20",
416
+ platform: process.platform,
417
+ captured_at: new Date().toISOString(),
418
+ cwd: root,
419
+ duration_ms: Date.now() - startTime,
420
+ manifests_found: manifests.map(m => m.file),
421
+ workflows_total: workflows.length,
422
+ publish_workflows: publishWorkflows.map(w => w.name),
423
+ },
424
+ collector_errors: errors,
425
+ };
426
+ }
427
+
428
+ module.exports = { playbook_id: COLLECTOR_ID, collect };