@blamejs/exceptd-skills 0.13.33 → 0.13.34

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,278 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * lib/collectors/sbom.js
5
+ *
6
+ * Companion collector for the `sbom` playbook. Identifies the lockfile
7
+ * fingerprint of the cwd (npm / yarn / pnpm / pip / cargo / go / ruby /
8
+ * composer) so the runner can correlate against the SBOM-currency +
9
+ * supply-chain integrity indicators. Counts components per lockfile
10
+ * for a coarse SBOM-presence signal.
11
+ *
12
+ * Scope: any cwd with a recognizable lockfile. Multi-ecosystem repos
13
+ * report every detected lockfile.
14
+ *
15
+ * Interface: see lib/collectors/README.md
16
+ */
17
+
18
+ const fs = require("node:fs");
19
+ const path = require("node:path");
20
+
21
+ const COLLECTOR_ID = "sbom";
22
+
23
+ // Lockfile fingerprints. Each entry: { file: <basename>, ecosystem,
24
+ // parser: <function(content) -> { component_count, top_level_count }> }.
25
+ const LOCKFILES = [
26
+ {
27
+ file: "package-lock.json",
28
+ ecosystem: "npm",
29
+ parser: (content) => {
30
+ try {
31
+ const j = JSON.parse(content);
32
+ const components = j.packages ? Object.keys(j.packages).filter(k => k !== "").length
33
+ : (j.dependencies ? Object.keys(j.dependencies).length : 0);
34
+ const topLevel = j.packages ? Object.keys(j.packages || {}).filter(k => /^node_modules\/[^/]+$/.test(k)).length
35
+ : (j.dependencies ? Object.keys(j.dependencies).length : 0);
36
+ return { component_count: components, top_level_count: topLevel, lockfile_version: j.lockfileVersion };
37
+ } catch (e) { return { error: e.message }; }
38
+ },
39
+ },
40
+ {
41
+ file: "yarn.lock",
42
+ ecosystem: "yarn",
43
+ parser: (content) => {
44
+ // yarn.lock isn't JSON. Coarse count: each block starts with `"<spec>":` at column 0.
45
+ const blocks = content.match(/^[^\s#].*:$/gm) || [];
46
+ return { component_count: blocks.length, top_level_count: null, lockfile_version: null };
47
+ },
48
+ },
49
+ {
50
+ file: "pnpm-lock.yaml",
51
+ ecosystem: "pnpm",
52
+ parser: (content) => {
53
+ const packages = (content.match(/^\s+\/[a-zA-Z0-9@_\-/.]+/gm) || []).length;
54
+ return { component_count: packages, top_level_count: null, lockfile_version: null };
55
+ },
56
+ },
57
+ {
58
+ file: "requirements.txt",
59
+ ecosystem: "pip",
60
+ parser: (content) => {
61
+ const lines = content.split(/\r?\n/).filter(l => l && !l.startsWith("#") && !l.startsWith("-"));
62
+ return { component_count: lines.length, top_level_count: lines.length, lockfile_version: null };
63
+ },
64
+ },
65
+ {
66
+ file: "Pipfile.lock",
67
+ ecosystem: "pipenv",
68
+ parser: (content) => {
69
+ try {
70
+ const j = JSON.parse(content);
71
+ const def = Object.keys(j.default || {}).length;
72
+ const dev = Object.keys(j.develop || {}).length;
73
+ return { component_count: def + dev, top_level_count: def, lockfile_version: j._meta?.["pipfile-spec"] };
74
+ } catch (e) { return { error: e.message }; }
75
+ },
76
+ },
77
+ {
78
+ file: "poetry.lock",
79
+ ecosystem: "poetry",
80
+ parser: (content) => {
81
+ const packages = (content.match(/^\[\[package\]\]/gm) || []).length;
82
+ return { component_count: packages, top_level_count: null, lockfile_version: null };
83
+ },
84
+ },
85
+ {
86
+ file: "Cargo.lock",
87
+ ecosystem: "cargo",
88
+ parser: (content) => {
89
+ const packages = (content.match(/^\[\[package\]\]/gm) || []).length;
90
+ return { component_count: packages, top_level_count: null, lockfile_version: null };
91
+ },
92
+ },
93
+ {
94
+ file: "go.sum",
95
+ ecosystem: "go",
96
+ parser: (content) => {
97
+ const modules = new Set(content.split(/\r?\n/).map(l => l.split(/\s+/)[0]).filter(Boolean));
98
+ return { component_count: modules.size, top_level_count: null, lockfile_version: null };
99
+ },
100
+ },
101
+ {
102
+ file: "Gemfile.lock",
103
+ ecosystem: "rubygems",
104
+ parser: (content) => {
105
+ const match = content.match(/GEM\s+remote:[\s\S]*?specs:([\s\S]*?)(?:\n\n|\nPLATFORMS)/);
106
+ if (!match) return { component_count: 0, top_level_count: null, lockfile_version: null };
107
+ const specs = match[1].split(/\r?\n/).filter(l => /^\s+[a-z0-9_-]+\s*\(/.test(l));
108
+ return { component_count: specs.length, top_level_count: null, lockfile_version: null };
109
+ },
110
+ },
111
+ {
112
+ file: "composer.lock",
113
+ ecosystem: "composer",
114
+ parser: (content) => {
115
+ try {
116
+ const j = JSON.parse(content);
117
+ return {
118
+ component_count: (j.packages || []).length + (j["packages-dev"] || []).length,
119
+ top_level_count: (j.packages || []).length,
120
+ lockfile_version: j["content-hash"] ? "content-hash" : null,
121
+ };
122
+ } catch (e) { return { error: e.message }; }
123
+ },
124
+ },
125
+ ];
126
+
127
+ const SBOM_FORMATS = [
128
+ { file: "sbom.cdx.json", format: "cyclonedx-1.x" },
129
+ { file: "bom.json", format: "cyclonedx-1.x" },
130
+ { file: "sbom.json", format: "unknown" },
131
+ { file: "sbom.spdx.json", format: "spdx-2.x" },
132
+ { file: "sbom.cdx.xml", format: "cyclonedx-xml" },
133
+ ];
134
+
135
+ function findLockfiles(cwd) {
136
+ const found = [];
137
+ for (const lf of LOCKFILES) {
138
+ const p = path.join(cwd, lf.file);
139
+ if (fs.existsSync(p)) {
140
+ try {
141
+ const content = fs.readFileSync(p, "utf8");
142
+ const stats = lf.parser(content);
143
+ found.push({
144
+ file: lf.file,
145
+ ecosystem: lf.ecosystem,
146
+ path: p,
147
+ size_bytes: Buffer.byteLength(content, "utf8"),
148
+ ...stats,
149
+ });
150
+ } catch (e) {
151
+ found.push({ file: lf.file, ecosystem: lf.ecosystem, path: p, error: e.message });
152
+ }
153
+ }
154
+ }
155
+ return found;
156
+ }
157
+
158
+ function findSbomDocuments(cwd) {
159
+ const found = [];
160
+ for (const s of SBOM_FORMATS) {
161
+ const p = path.join(cwd, s.file);
162
+ if (fs.existsSync(p)) {
163
+ try {
164
+ const stat = fs.statSync(p);
165
+ let content;
166
+ try { content = fs.readFileSync(p, "utf8"); } catch { content = null; }
167
+ let component_count = null;
168
+ if (content && s.format === "cyclonedx-1.x") {
169
+ try {
170
+ const j = JSON.parse(content);
171
+ component_count = (j.components || []).length;
172
+ } catch {}
173
+ }
174
+ found.push({ file: s.file, format: s.format, size_bytes: stat.size, component_count });
175
+ } catch (e) {
176
+ found.push({ file: s.file, format: s.format, error: e.message });
177
+ }
178
+ }
179
+ }
180
+ return found;
181
+ }
182
+
183
+ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
184
+ const errors = [];
185
+ const startTime = Date.now();
186
+ const root = path.resolve(cwd);
187
+
188
+ // Precondition: sbom-tool-available — the runner / playbook treats
189
+ // this as "an operator has SOME way to produce an SBOM". For the
190
+ // collector, "we found a lockfile or an SBOM" is a sufficient proxy.
191
+ const lockfiles = findLockfiles(root);
192
+ const sbomDocuments = findSbomDocuments(root);
193
+ const hasAnything = lockfiles.length > 0 || sbomDocuments.length > 0;
194
+
195
+ const artifacts = {
196
+ "lockfile-inventory": {
197
+ value: lockfiles.length
198
+ ? lockfiles.map(l => `${l.ecosystem}:${l.file} (${l.component_count ?? "?"} components${l.lockfile_version ? `, v${l.lockfile_version}` : ""})`).join("; ")
199
+ : "no lockfile found",
200
+ captured: true,
201
+ },
202
+ "sbom-document": {
203
+ value: sbomDocuments.length
204
+ ? sbomDocuments.map(s => `${s.format}:${s.file} (${s.size_bytes} bytes${s.component_count != null ? `, ${s.component_count} components` : ""})`).join("; ")
205
+ : "no SBOM document at cwd root",
206
+ captured: true,
207
+ },
208
+ };
209
+
210
+ // The sbom playbook's detect indicators (package-matches-catalogued-cve,
211
+ // lockfile-no-integrity, transitive-deps-incomplete-sbom,
212
+ // matched-cve-without-vex, ai-code-no-provenance, ...) require
213
+ // catalog cross-referencing the collector does not have — that's
214
+ // the runner's job. The collector's role here is to surface the
215
+ // artifacts (lockfile-inventory + sbom-document) and let the
216
+ // runner evaluate the indicators against them. Emitting
217
+ // signal_overrides for keys that don't exist in the playbook
218
+ // would be silently ignored; surfacing the artifacts honestly
219
+ // is the contract.
220
+ //
221
+ // One indicator the collector CAN decide deterministically:
222
+ // lockfile-no-integrity — true when an npm package-lock.json
223
+ // exists but has zero `integrity` entries (lockfileVersion 1
224
+ // legacy) OR when the dependency list contains entries lacking
225
+ // `integrity` strings.
226
+ const npmLockfile = lockfiles.find(l => l.file === "package-lock.json");
227
+ const signal_overrides = {};
228
+ if (npmLockfile && !npmLockfile.error) {
229
+ try {
230
+ const j = JSON.parse(fs.readFileSync(npmLockfile.path, "utf8"));
231
+ let withIntegrity = 0;
232
+ let withoutIntegrity = 0;
233
+ const walk = (obj) => {
234
+ if (!obj || typeof obj !== "object") return;
235
+ if (obj.integrity != null) withIntegrity++;
236
+ else if (obj.resolved != null || obj.version != null) withoutIntegrity++;
237
+ for (const v of Object.values(obj)) if (v && typeof v === "object") walk(v);
238
+ };
239
+ walk(j.packages || j.dependencies || {});
240
+ // Fire only if integrity is missing on ANY package entry that
241
+ // resolves to a remote tarball — the indicator captures the
242
+ // class, not full coverage.
243
+ if (withoutIntegrity > 0) {
244
+ signal_overrides["lockfile-no-integrity"] = "hit";
245
+ } else if (withIntegrity > 0) {
246
+ signal_overrides["lockfile-no-integrity"] = "miss";
247
+ }
248
+ // Stash diagnostic counts on collector_meta further below.
249
+ npmLockfile.integrity_present_count = withIntegrity;
250
+ npmLockfile.integrity_missing_count = withoutIntegrity;
251
+ } catch {
252
+ // Malformed lockfile — leave the indicator unflipped so the
253
+ // runner returns inconclusive rather than a forced miss.
254
+ }
255
+ }
256
+
257
+ return {
258
+ precondition_checks: {
259
+ "sbom-tool-available": hasAnything,
260
+ },
261
+ artifacts,
262
+ signal_overrides,
263
+ collector_meta: {
264
+ collector_id: COLLECTOR_ID,
265
+ collector_version: "2026-05-20",
266
+ platform: process.platform,
267
+ captured_at: new Date().toISOString(),
268
+ cwd: root,
269
+ duration_ms: Date.now() - startTime,
270
+ lockfiles_found: lockfiles.length,
271
+ sbom_documents_found: sbomDocuments.length,
272
+ ecosystems_detected: [...new Set(lockfiles.map(l => l.ecosystem))],
273
+ },
274
+ collector_errors: errors,
275
+ };
276
+ }
277
+
278
+ module.exports = { playbook_id: COLLECTOR_ID, collect };
@@ -0,0 +1,323 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * lib/collectors/secrets.js
5
+ *
6
+ * Companion collector for the `secrets` playbook. Walks the cwd
7
+ * tree, identifies the artifact files (env / auth-config / ssh-keys /
8
+ * iac-credential-bearers), runs the catalogued regex set against text
9
+ * file contents, and stats permission posture on secret-carrier
10
+ * files. Emits a submission with deterministic signal_overrides per
11
+ * indicator that fired.
12
+ *
13
+ * Scope: any cwd. Cross-platform (Windows / macOS / Linux).
14
+ * Permission-posture indicator only meaningful on POSIX hosts.
15
+ *
16
+ * Interface: see lib/collectors/README.md
17
+ */
18
+
19
+ const fs = require("node:fs");
20
+ const path = require("node:path");
21
+
22
+ const COLLECTOR_ID = "secrets";
23
+
24
+ // Walk depth + exclusion list mirrors the secrets playbook's
25
+ // `look.artifacts[repo-tree].source` declaration.
26
+ const DEFAULT_MAX_DEPTH = 6;
27
+ const DEFAULT_EXCLUDES = new Set([
28
+ "node_modules", ".git", "dist", "build", "out",
29
+ ".venv", "venv", "__pycache__", ".pytest_cache",
30
+ "target", ".idea", ".vscode",
31
+ ]);
32
+
33
+ const ENV_FILE_PREDICATE = (name) => {
34
+ if (name === ".env" || name === ".envrc") return true;
35
+ if (name.startsWith(".env.")) return true;
36
+ if (name.endsWith(".env")) return true;
37
+ return false;
38
+ };
39
+
40
+ const AUTH_CONFIG_FILES = new Set([
41
+ ".npmrc", ".pypirc", ".netrc", ".git-credentials",
42
+ "config.json", // .docker/config.json — caller checks parent dir
43
+ ".yarnrc.yml", ".yarnrc",
44
+ "settings.xml", "gradle.properties",
45
+ ]);
46
+
47
+ const SSH_PRIVATE_KEY_FILES = new Set(["id_rsa", "id_ed25519", "id_ecdsa", "id_dsa"]);
48
+ const SSH_PRIVATE_KEY_EXTS = new Set([".pem", ".key", ".p12", ".pfx"]);
49
+
50
+ const IAC_EXTS = new Set([".tf", ".tfvars", ".bicep"]);
51
+ const IAC_EXACT = new Set(["terraform.tfstate", "values.yaml", "secret.yaml"]);
52
+ const IAC_GLOB_PREFIX = ["pulumi.", "arm."];
53
+
54
+ // Indicator regex set — must mirror data/playbooks/secrets.json's
55
+ // detect.indicators[].value embedded patterns. The playbook is the
56
+ // source of truth for what counts as a hit; the collector
57
+ // implements the same patterns so its signal_overrides match what
58
+ // the runner would compute.
59
+ const INDICATOR_PATTERNS = [
60
+ { id: "aws-access-key-id", re: /\bAKIA[0-9A-Z]{16}\b/g },
61
+ { id: "aws-secret-access-key", re: /\baws_secret_access_key\s*[=:]\s*['"]?([A-Za-z0-9/+=]{40})['"]?/gi },
62
+ { id: "gcp-service-account-json", re: /"type"\s*:\s*"service_account"[\s\S]{0,1200}?"private_key"\s*:\s*"-----BEGIN PRIVATE KEY-----/g },
63
+ { id: "github-personal-access-token", re: /\bghp_[A-Za-z0-9]{36}\b/g },
64
+ { id: "github-fine-grained-pat", re: /\bgithub_pat_[A-Za-z0-9_]{82}\b/g },
65
+ { id: "slack-bot-or-user-token", re: /\bxox[abposr]-[A-Za-z0-9-]{10,}\b/g },
66
+ { id: "stripe-secret-key", re: /\b(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{24,}\b/g },
67
+ { id: "jwt-token-with-secret-context", re: /\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g },
68
+ { id: "ssh-private-key-block", re: /-----BEGIN (?:RSA |EC |OPENSSH |DSA |ENCRYPTED |)PRIVATE KEY-----/g },
69
+ { id: "openai-api-key", re: /\bsk-(?:proj-|svcacct-|admin-|)[A-Za-z0-9_-]{20,}\b/g },
70
+ { id: "anthropic-api-key", re: /\bsk-ant-[A-Za-z0-9_-]{20,}\b/g },
71
+ ];
72
+
73
+ const TEXT_EXTENSIONS = new Set([
74
+ ".env", ".envrc", ".txt", ".md", ".json", ".yaml", ".yml", ".toml",
75
+ ".tf", ".tfvars", ".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx",
76
+ ".py", ".rb", ".go", ".rs", ".java", ".cs", ".php", ".sh", ".bash",
77
+ ".zsh", ".fish", ".ps1", ".psm1", ".bicep", ".html", ".xml", ".ini",
78
+ ".conf", ".cfg", ".properties", ".gradle", ".sql", ".dockerfile",
79
+ ]);
80
+ const TEXT_EXACT = new Set(["Dockerfile", "Makefile", "Procfile", ".env", ".envrc"]);
81
+ const MAX_FILE_BYTES = 1024 * 1024; // 1 MB per file content scan
82
+
83
+ function walkTree(root, opts = {}) {
84
+ const maxDepth = opts.maxDepth ?? DEFAULT_MAX_DEPTH;
85
+ const excludes = opts.excludes ?? DEFAULT_EXCLUDES;
86
+ const out = [];
87
+ const seen = new Set();
88
+
89
+ function walk(dir, depth) {
90
+ if (depth > maxDepth) return;
91
+ let entries;
92
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
93
+ catch { return; }
94
+ for (const entry of entries) {
95
+ if (excludes.has(entry.name)) continue;
96
+ const full = path.join(dir, entry.name);
97
+ let real;
98
+ try { real = fs.realpathSync(full); } catch { continue; }
99
+ if (seen.has(real)) continue;
100
+ seen.add(real);
101
+ if (entry.isDirectory()) {
102
+ walk(full, depth + 1);
103
+ } else if (entry.isFile()) {
104
+ out.push({ full, rel: path.relative(root, full), name: entry.name });
105
+ }
106
+ }
107
+ }
108
+ walk(root, 0);
109
+ return out;
110
+ }
111
+
112
+ function classify(file) {
113
+ const name = file.name;
114
+ const ext = path.extname(name).toLowerCase();
115
+ const rel = file.rel;
116
+ const isDockerConfig = /(^|\/|\\)\.docker\/config\.json$/.test(rel.replace(/\\/g, "/"));
117
+ const isHelmValues = name === "values.yaml" || rel.toLowerCase().includes("/helm/");
118
+ const isAnsible = (ext === ".yml" || ext === ".yaml") &&
119
+ /(roles|group_vars|host_vars)\//.test(rel.replace(/\\/g, "/"));
120
+
121
+ return {
122
+ isEnv: ENV_FILE_PREDICATE(name),
123
+ isAuthConfig: AUTH_CONFIG_FILES.has(name) || isDockerConfig,
124
+ isSshKey:
125
+ (SSH_PRIVATE_KEY_FILES.has(name) ||
126
+ (SSH_PRIVATE_KEY_EXTS.has(ext) && !name.endsWith(".pub"))),
127
+ isIac:
128
+ IAC_EXTS.has(ext) || IAC_EXACT.has(name) || isHelmValues || isAnsible ||
129
+ IAC_GLOB_PREFIX.some(p => name.startsWith(p) && (name.endsWith(".yaml") || name.endsWith(".yml") || name.endsWith(".json"))),
130
+ isText: TEXT_EXACT.has(name) || TEXT_EXTENSIONS.has(ext) || name.endsWith(".env"),
131
+ };
132
+ }
133
+
134
+ function statPosture(full) {
135
+ try {
136
+ const s = fs.statSync(full);
137
+ const mode = s.mode & 0o777;
138
+ return {
139
+ mode,
140
+ mode_octal: "0" + mode.toString(8),
141
+ world_writable: (mode & 0o002) !== 0,
142
+ world_readable: (mode & 0o004) !== 0,
143
+ group_writable: (mode & 0o020) !== 0,
144
+ group_readable: (mode & 0o040) !== 0,
145
+ };
146
+ } catch (e) {
147
+ return { error: e.message };
148
+ }
149
+ }
150
+
151
+ function redactMatch(literal) {
152
+ if (literal.length <= 6) return "<redacted:" + literal.length + "ch>";
153
+ return literal.slice(0, 4) + "…[" + (literal.length - 4) + "ch-redacted]";
154
+ }
155
+
156
+ function scanContent(full, rel) {
157
+ let buf;
158
+ try {
159
+ const s = fs.statSync(full);
160
+ if (s.size > MAX_FILE_BYTES) return { skipped: "file_too_large", bytes: s.size, hits: [] };
161
+ buf = fs.readFileSync(full, "utf8");
162
+ } catch (e) {
163
+ return { skipped: "read_error", reason: e.message, hits: [] };
164
+ }
165
+ const hits = [];
166
+ for (const p of INDICATOR_PATTERNS) {
167
+ const matches = buf.matchAll(p.re);
168
+ let count = 0;
169
+ for (const m of matches) {
170
+ hits.push({
171
+ indicator_id: p.id,
172
+ file: rel,
173
+ offset: m.index,
174
+ redacted_match: redactMatch(m[0]),
175
+ });
176
+ if (++count >= 5) break; // cap per-indicator-per-file
177
+ }
178
+ }
179
+ return { hits };
180
+ }
181
+
182
+ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
183
+ const errors = [];
184
+ const startTime = Date.now();
185
+ const root = path.resolve(cwd);
186
+
187
+ let files;
188
+ try {
189
+ files = walkTree(root);
190
+ } catch (e) {
191
+ errors.push({ kind: "walk_failed", reason: e.message });
192
+ files = [];
193
+ }
194
+ if (files.length > 50000) {
195
+ errors.push({
196
+ kind: "file_count_capped",
197
+ reason: `walked ${files.length} files; capping content scan at 50000. Narrow the cwd or raise the cap explicitly.`,
198
+ });
199
+ files = files.slice(0, 50000);
200
+ }
201
+
202
+ const envFiles = [];
203
+ const authConfigFiles = [];
204
+ const sshPrivateKeys = [];
205
+ const iacFiles = [];
206
+ const textFiles = [];
207
+ for (const f of files) {
208
+ const c = classify(f);
209
+ if (c.isEnv) envFiles.push(f);
210
+ if (c.isAuthConfig) authConfigFiles.push(f);
211
+ if (c.isSshKey) sshPrivateKeys.push(f);
212
+ if (c.isIac) iacFiles.push(f);
213
+ if (c.isText) textFiles.push(f);
214
+ }
215
+
216
+ const worldWritablePosture = [];
217
+ if (process.platform !== "win32") {
218
+ const carriers = [...new Set([...envFiles, ...authConfigFiles, ...sshPrivateKeys].map(f => f.full))]
219
+ .map(p => files.find(f => f.full === p))
220
+ .filter(Boolean);
221
+ for (const f of carriers) {
222
+ const p = statPosture(f.full);
223
+ if (p.world_writable || p.world_readable) {
224
+ worldWritablePosture.push({ file: f.rel, ...p });
225
+ }
226
+ }
227
+ }
228
+
229
+ const allHits = [];
230
+ for (const f of textFiles) {
231
+ const r = scanContent(f.full, f.rel);
232
+ if (r.hits) allHits.push(...r.hits);
233
+ if (r.skipped === "read_error") {
234
+ errors.push({ artifact_id: "secret-regex-scan-text-files", kind: "read_failed", reason: `${f.rel}: ${r.reason}` });
235
+ }
236
+ }
237
+
238
+ const hitsByIndicator = {};
239
+ for (const h of allHits) {
240
+ (hitsByIndicator[h.indicator_id] = hitsByIndicator[h.indicator_id] || []).push(h);
241
+ }
242
+ const signal_overrides = {};
243
+ for (const p of INDICATOR_PATTERNS) {
244
+ signal_overrides[p.id] = hitsByIndicator[p.id] && hitsByIndicator[p.id].length > 0 ? "hit" : "miss";
245
+ }
246
+ // world-writable-env-file predicate (per data/playbooks/secrets.json):
247
+ // restricted to env-files artifact entries
248
+ // any .env / .env.* / .envrc with mode 0666 or 0664 (group/world writable)
249
+ // i.e. group-write OR world-write bit set (mode & 0o022).
250
+ const envFilePostures = process.platform === "win32" ? [] : envFiles.map(f => ({ file: f.rel, ...statPosture(f.full) }));
251
+ signal_overrides["world-writable-env-file"] = envFilePostures.some(p => p.error == null && (p.mode & 0o022) !== 0) ? "hit" : "miss";
252
+
253
+ // ssh-key-bad-perms predicate (per playbook):
254
+ // restricted to ssh-private-keys artifact + ~/.ssh/id_* paths
255
+ // any private-key file with mode != 0600
256
+ // The collector scope is the cwd; ~/.ssh enumeration is outside this
257
+ // walk root. Within cwd, flag any discovered private key whose mode
258
+ // is anything other than 0600 (strict).
259
+ const sshKeyPostures = process.platform === "win32" ? [] : sshPrivateKeys.map(f => ({ file: f.rel, ...statPosture(f.full) }));
260
+ signal_overrides["ssh-key-bad-perms"] = sshKeyPostures.some(p => p.error == null && p.mode !== 0o600) ? "hit" : "miss";
261
+
262
+ const summarizeFiles = (list) => list.map(f => f.rel).join(", ");
263
+ const artifacts = {
264
+ "repo-tree": {
265
+ value: `${files.length} file(s) walked (depth ≤ ${DEFAULT_MAX_DEPTH}, exclude ${[...DEFAULT_EXCLUDES].slice(0, 8).join("/")}/…)`,
266
+ captured: true,
267
+ },
268
+ "env-files": {
269
+ value: envFiles.length ? summarizeFiles(envFiles) : "none found",
270
+ captured: true,
271
+ },
272
+ "auth-config-files": {
273
+ value: authConfigFiles.length ? summarizeFiles(authConfigFiles) : "none found",
274
+ captured: true,
275
+ },
276
+ "ssh-private-keys": {
277
+ value: sshPrivateKeys.length ? summarizeFiles(sshPrivateKeys) : "none found",
278
+ captured: true,
279
+ },
280
+ "iac-credential-bearers": {
281
+ value: iacFiles.length ? summarizeFiles(iacFiles) : "none found",
282
+ captured: true,
283
+ },
284
+ "secret-regex-scan-text-files": {
285
+ value: allHits.length
286
+ ? `${allHits.length} hit(s): ` + allHits.slice(0, 20).map(h => `${h.indicator_id}@${h.file}:${h.offset} ${h.redacted_match}`).join("; ") + (allHits.length > 20 ? "; …" : "")
287
+ : `scanned ${textFiles.length} text file(s); 0 hits`,
288
+ captured: true,
289
+ },
290
+ "world-writable-secret-files": {
291
+ value: process.platform === "win32"
292
+ ? "skipped on win32 (POSIX mode bits not load-bearing)"
293
+ : (worldWritablePosture.length
294
+ ? worldWritablePosture.map(p => `${p.file} (mode ${p.mode_octal}, wr=${p.world_writable}, rd=${p.world_readable})`).join("; ")
295
+ : "scanned for world-writable; 0 carriers above 0644"),
296
+ captured: process.platform !== "win32",
297
+ reason: process.platform === "win32" ? "POSIX mode bits not meaningful on Windows; ACL audit out of scope" : undefined,
298
+ },
299
+ };
300
+
301
+ return {
302
+ precondition_checks: {
303
+ "repo-context": true,
304
+ "regex-engine": true,
305
+ },
306
+ artifacts,
307
+ signal_overrides,
308
+ collector_meta: {
309
+ collector_id: COLLECTOR_ID,
310
+ collector_version: "2026-05-20",
311
+ platform: process.platform,
312
+ captured_at: new Date().toISOString(),
313
+ cwd: root,
314
+ duration_ms: Date.now() - startTime,
315
+ files_walked: files.length,
316
+ text_files_scanned: textFiles.length,
317
+ hits_total: allHits.length,
318
+ },
319
+ collector_errors: errors,
320
+ };
321
+ }
322
+
323
+ module.exports = { playbook_id: COLLECTOR_ID, collect };
@@ -109,8 +109,9 @@ const VERB_FLAG_ALLOWLIST = Object.freeze({
109
109
  reattest: [
110
110
  'playbook', 'since', 'latest', 'force-replay', 'attestation-root',
111
111
  ],
112
- doctor: ['signatures', 'cves', 'rfcs', 'fix', 'registry-check', 'exit-codes'],
112
+ doctor: ['signatures', 'cves', 'rfcs', 'fix', 'registry-check', 'exit-codes', 'shipped-tarball', 'ai-config', 'currency'],
113
113
  lint: ['evidence'],
114
+ collect: ['cwd'],
114
115
  refresh: [
115
116
  'apply', 'dry-run', 'from-cache', 'from-fixture', 'network', 'source',
116
117
  'advisory', 'force-stale', 'force-stale-acked', 'air-gap', 'swarm',