@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.
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +16 -0
- package/data/_indexes/_meta.json +2 -2
- package/lib/collectors/crypto-codebase.js +438 -0
- package/lib/collectors/library-author.js +428 -0
- package/manifest.json +44 -44
- package/package.json +1 -1
- package/sbom.cdx.json +44 -14
|
@@ -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 };
|