@blamejs/exceptd-skills 0.13.37 → 0.13.39

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 CHANGED
@@ -372,7 +372,7 @@ This split costs every consumer the same translation work on every invocation. C
372
372
  exceptd collect secrets | exceptd run secrets --evidence -
373
373
  ```
374
374
 
375
- The collector library is small and grows as playbooks are touched. Six reference collectors ship today (`lib/collectors/secrets.js`, `lib/collectors/kernel.js`, `lib/collectors/sbom.js`, `lib/collectors/containers.js`, `lib/collectors/library-author.js`, `lib/collectors/crypto-codebase.js`); the rest are written when each playbook needs them. Until a playbook has a collector, the AI/operator owns evidence collection as before.
375
+ The collector library is small and grows as playbooks are touched. Eight reference collectors ship today (`lib/collectors/secrets.js`, `lib/collectors/kernel.js`, `lib/collectors/sbom.js`, `lib/collectors/containers.js`, `lib/collectors/library-author.js`, `lib/collectors/crypto-codebase.js`, `lib/collectors/cred-stores.js`, `lib/collectors/hardening.js`); the rest are written when each playbook needs them. Until a playbook has a collector, the AI/operator owns evidence collection as before.
376
376
 
377
377
  ### Precision target for new `look.artifacts[].source` strings
378
378
 
package/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.13.39 — 2026-05-20
4
+
5
+ Eighth reference collector.
6
+
7
+ ### Features
8
+
9
+ - **`lib/collectors/hardening.js`** — Linux-only host-hardening posture collector. Reads `/proc/sys/kernel/kptr_restrict`, `/proc/sys/kernel/unprivileged_userns_clone`, `/proc/sys/kernel/unprivileged_bpf_disabled`, `/proc/sys/kernel/yama/ptrace_scope`, `/proc/sys/fs/suid_dumpable`, `/proc/cmdline`, `/sys/kernel/security/lockdown`, `/etc/ssh/sshd_config` (+ `/etc/ssh/sshd_config.d/*.conf`), and flips eight deterministic indicators: `kptr-restrict-disabled` (`kernel.kptr_restrict == 0`), `unprivileged-userns-enabled` (`kernel.unprivileged_userns_clone == 1`), `unprivileged-bpf-allowed` (`kernel.unprivileged_bpf_disabled == 0`), `yama-ptrace-permissive` (`kernel.yama.ptrace_scope == 0`), `kaslr-disabled-at-boot` (`nokaslr` or `kaslr=off` in `/proc/cmdline`), `mitigations-off` (`mitigations=off` in `/proc/cmdline`), `sshd-permitrootlogin-yes` (effective `yes` or `without-password`), `kernel-lockdown-none` (`[none]` bracket in `/sys/kernel/security/lockdown` OR file absent AND no `lockdown=` cmdline parameter). On non-Linux hosts the precondition `linux-platform` fails and the collector emits an empty submission rather than producing phantom values. Attests `kptr-restrict-disabled__fp_checks[1]` when `/proc/kallsyms` actually leaks non-zero pointer addresses (the catalogued counter-evidence for false-positive demotion). Path overrides via `args.paths` so the collector can be exercised against synthetic tempdir layouts in tests.
10
+
11
+ ## 0.13.38 — 2026-05-20
12
+
13
+ Seventh reference collector.
14
+
15
+ ### Features
16
+
17
+ - **`lib/collectors/cred-stores.js`** — inspects local credential carriers (`~/.aws/credentials`, `~/.kube/config`, `~/.docker/config.json`, `~/.npmrc`, `~/.pypirc`, `~/.config/gcloud/application_default_credentials.json`, plus project-level `.npmrc` / `.pypirc` under cwd). Flips seven deterministic indicators from the `cred-stores` playbook: `aws-static-key-present` (any `aws_access_key_id` profile with no `sso_session` / `credential_process` / `role_arn` sibling), `kube-static-token` (any `users[].user.token` field non-null with no `exec:` provider on the same user), `gcp-service-account-json-adc` (`type: "service_account"` in `application_default_credentials.json`), `docker-cleartext-auth` (any `auths[<registry>].auth` field with no `credsStore` / `credHelpers[<registry>]` covering it), `npm-pat-present` (`:_authToken=npm_[A-Za-z0-9]{36,}` in either home or project `.npmrc`), `pypi-token-present` (`password = pypi-[A-Za-z0-9_-]{40,}` in either home or project `.pypirc`), `credentials-file-bad-perms` (POSIX only — any of the listed carriers with mode != 0600). The `aws-sso-cache`, `gcloud-credentials` (SQLite path), `gpg-keys`, `ssh-keys-inventory`, `ssh-config`, `keychain-inventory` artifacts are explicitly marked `captured: false` with a `reason` so the runner records partial-evidence coverage — `ssh-key-rsa-short-bits` / `ssh-key-old` / `gpg-key-old-or-weak` / `all-stores-empty-or-federated` need ssh-keygen / gpg / keychain access that would force a child_process out of the stdlib-only collector contract; left to operator-supplied evidence.
18
+
3
19
  ## 0.13.37 — 2026-05-20
4
20
 
5
21
  Sixth reference collector.
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "schema_version": "1.1.0",
3
- "generated_at": "2026-05-21T03:49:45.015Z",
3
+ "generated_at": "2026-05-21T05:15:19.319Z",
4
4
  "generator": "scripts/build-indexes.js",
5
5
  "source_count": 54,
6
6
  "source_hashes": {
7
- "manifest.json": "5eeb28b7edf5ebf368b201b1988d0b6ccbb7ca838ee7fe45e63a878e7d3650f9",
7
+ "manifest.json": "0785c4b4b99897d6846d7e324c8a093da8bfdeaa48ca9ab234cd82009d1e04d0",
8
8
  "data/atlas-ttps.json": "d296c1d3e71807c9279b731f047e57796e85137f186586743a8cdad214b408f9",
9
9
  "data/attack-techniques.json": "49b6010b317edd219def135171ea8f3b1bbf1e00e9c5a08bf7237215ff54e2c3",
10
10
  "data/cve-catalog.json": "a09c83af3f9679a7ea73935726a1ff9de2cab94b4ab6321fc017fc147747d7c3",
@@ -0,0 +1,471 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * lib/collectors/cred-stores.js
5
+ *
6
+ * Companion collector for the `cred-stores` playbook. Inspects local
7
+ * credential carriers (~/.aws/credentials, ~/.kube/config, ~/.docker/
8
+ * config.json, ~/.npmrc, ~/.pypirc, ~/.config/gcloud/application_
9
+ * default_credentials.json, project-level .npmrc / .pypirc), and
10
+ * flips signal_overrides for the deterministic indicators. Defers
11
+ * non-deterministic indicators (ssh-key-rsa-short-bits, ssh-key-old,
12
+ * gpg-key-old-or-weak, all-stores-empty-or-federated) so the runner
13
+ * returns inconclusive rather than a forced miss.
14
+ *
15
+ * Scope: $HOME credential dotfiles + project-level .npmrc / .pypirc
16
+ * under cwd. Posix-mode-bits indicators are skipped on win32 (ACL
17
+ * audit out of scope).
18
+ *
19
+ * Interface: see lib/collectors/README.md
20
+ */
21
+
22
+ const fs = require("node:fs");
23
+ const path = require("node:path");
24
+ const os = require("node:os");
25
+
26
+ const COLLECTOR_ID = "cred-stores";
27
+
28
+ function readSafe(full, max = 512 * 1024) {
29
+ try {
30
+ const s = fs.statSync(full);
31
+ if (s.size > max) return null;
32
+ return fs.readFileSync(full, "utf8");
33
+ } catch { return null; }
34
+ }
35
+
36
+ function statSafe(full) {
37
+ try { return fs.statSync(full); } catch { return null; }
38
+ }
39
+
40
+ function modeOf(full) {
41
+ const s = statSafe(full);
42
+ if (!s) return null;
43
+ return s.mode & 0o777;
44
+ }
45
+
46
+ function fileExists(full) {
47
+ try { return fs.statSync(full).isFile(); } catch { return false; }
48
+ }
49
+
50
+ // AWS credentials INI: any [profile] block carrying
51
+ // `aws_access_key_id` AND no `sso_session` / `credential_process`.
52
+ function parseAwsCredentials(content) {
53
+ if (!content) return { staticProfiles: [], federatedProfiles: [] };
54
+ const lines = content.split(/\r?\n/);
55
+ const profiles = {};
56
+ let current = null;
57
+ for (const raw of lines) {
58
+ const line = raw.replace(/[#;].*$/, "").trim();
59
+ if (!line) continue;
60
+ const sec = line.match(/^\[([^\]]+)\]$/);
61
+ if (sec) {
62
+ current = sec[1].trim();
63
+ profiles[current] = {};
64
+ continue;
65
+ }
66
+ if (!current) continue;
67
+ const kv = line.match(/^([A-Za-z0-9_-]+)\s*=\s*(.*)$/);
68
+ if (!kv) continue;
69
+ profiles[current][kv[1].trim().toLowerCase()] = kv[2].trim();
70
+ }
71
+ const staticProfiles = [];
72
+ const federatedProfiles = [];
73
+ for (const [name, kv] of Object.entries(profiles)) {
74
+ const hasKey = !!kv["aws_access_key_id"];
75
+ const hasFederation = !!(kv["sso_session"] || kv["credential_process"] || kv["role_arn"]);
76
+ if (hasKey && !hasFederation) staticProfiles.push(name);
77
+ if (hasFederation) federatedProfiles.push(name);
78
+ }
79
+ return { staticProfiles, federatedProfiles };
80
+ }
81
+
82
+ // kubeconfig: users[].user.token field present (non-empty) with no
83
+ // users[].user.exec sibling. Use a tolerant line-based scan rather
84
+ // than pulling a YAML parser into the stdlib-only contract.
85
+ function parseKubeConfig(content) {
86
+ if (!content) return { hasStaticToken: false, hasExec: false };
87
+ // Find every users: block + each `- name: ...` user entry and
88
+ // its sub-keys. The kubeconfig schema is regular enough that a
89
+ // line-window scan is reliable.
90
+ const lines = content.split(/\r?\n/);
91
+ let inUsers = false;
92
+ let userIndent = -1;
93
+ let blocks = [];
94
+ let buf = [];
95
+ let blockIndent = -1;
96
+ for (const raw of lines) {
97
+ if (/^users:\s*$/.test(raw)) { inUsers = true; userIndent = -1; continue; }
98
+ if (inUsers) {
99
+ const m = raw.match(/^(\s*)-\s+name:/);
100
+ if (m) {
101
+ if (buf.length) { blocks.push(buf.join("\n")); buf = []; }
102
+ userIndent = m[1].length;
103
+ blockIndent = userIndent;
104
+ buf.push(raw);
105
+ continue;
106
+ }
107
+ if (buf.length) {
108
+ // If we leave the users list (dedent), close current block.
109
+ if (raw.trim() === "" || /^\S/.test(raw)) {
110
+ if (/^\S/.test(raw) && !/^users:/.test(raw)) {
111
+ blocks.push(buf.join("\n")); buf = [];
112
+ inUsers = false;
113
+ continue;
114
+ }
115
+ }
116
+ buf.push(raw);
117
+ }
118
+ }
119
+ }
120
+ if (buf.length) blocks.push(buf.join("\n"));
121
+
122
+ // Match `token:` / `token-data:` ONLY at the user-block indent level
123
+ // (i.e. inside `user:`). Auth-provider blocks carry sub-keys like
124
+ // `access-token`, `id-token`, `refresh-token` which are dynamic /
125
+ // cached tokens, not static-credential evidence. Use a line-prefix
126
+ // anchor + auth-provider-vs-user proximity check to refuse those.
127
+ let hasStaticToken = false;
128
+ let hasExec = false;
129
+ const userKvRe = /^(\s+)(token|token-data)\s*:\s*(\S[^\n]*)/gm;
130
+ for (const block of blocks) {
131
+ const execPresent = /^\s+exec\s*:\s*(?:\n|$)/m.test(block);
132
+ if (execPresent) hasExec = true;
133
+ let blockHasStatic = false;
134
+ for (const m of block.matchAll(userKvRe)) {
135
+ const upto = block.slice(0, m.index);
136
+ const lastUserAt = upto.lastIndexOf("\n user:");
137
+ const lastAuthProviderAt = upto.lastIndexOf("auth-provider:");
138
+ // Reject when the closest enclosing key is auth-provider rather
139
+ // than user — those are dynamic tokens, not static credentials.
140
+ if (lastAuthProviderAt > lastUserAt) continue;
141
+ const value = m[3];
142
+ if (!value || value.startsWith("null")) continue;
143
+ blockHasStatic = true;
144
+ break;
145
+ }
146
+ if (blockHasStatic && !execPresent) hasStaticToken = true;
147
+ }
148
+ return { hasStaticToken, hasExec };
149
+ }
150
+
151
+ function parseGcloudAdc(content) {
152
+ if (!content) return { hasServiceAccount: false };
153
+ try {
154
+ const j = JSON.parse(content);
155
+ return { hasServiceAccount: j?.type === "service_account" };
156
+ } catch {
157
+ return { hasServiceAccount: false };
158
+ }
159
+ }
160
+
161
+ function parseDockerConfig(content) {
162
+ if (!content) return { hasCleartext: false, hasCredHelper: false, registriesWithCleartext: [] };
163
+ let j;
164
+ try { j = JSON.parse(content); } catch { return { hasCleartext: false, hasCredHelper: false, registriesWithCleartext: [] }; }
165
+ const auths = (j && typeof j.auths === "object") ? j.auths : {};
166
+ const credHelpers = (j && typeof j.credHelpers === "object") ? j.credHelpers : {};
167
+ const credsStore = typeof j?.credsStore === "string" ? j.credsStore : "";
168
+ const registriesWithCleartext = [];
169
+ for (const [registry, entry] of Object.entries(auths)) {
170
+ if (!entry || typeof entry !== "object") continue;
171
+ const hasAuth = typeof entry.auth === "string" && entry.auth.length > 0;
172
+ if (!hasAuth) continue;
173
+ if (credsStore || credHelpers[registry]) continue;
174
+ registriesWithCleartext.push(registry);
175
+ }
176
+ return {
177
+ hasCleartext: registriesWithCleartext.length > 0,
178
+ hasCredHelper: !!credsStore || Object.keys(credHelpers).length > 0,
179
+ registriesWithCleartext,
180
+ };
181
+ }
182
+
183
+ const NPM_PAT_RE = /:_authToken\s*=\s*npm_[A-Za-z0-9]{36,}/;
184
+ const PYPI_TOKEN_RE = /password\s*=\s*pypi-[A-Za-z0-9_-]{40,}/;
185
+
186
+ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
187
+ const errors = [];
188
+ const startTime = Date.now();
189
+ const root = path.resolve(cwd);
190
+ const home = (env && env.HOME) || (env && env.USERPROFILE) || os.homedir();
191
+ const isPosix = process.platform !== "win32";
192
+
193
+ const carriers = {
194
+ "aws-credentials": path.join(home, ".aws", "credentials"),
195
+ "aws-config": path.join(home, ".aws", "config"),
196
+ "kube-config": path.join(home, ".kube", "config"),
197
+ "docker-config": path.join(home, ".docker", "config.json"),
198
+ "npmrc-home": path.join(home, ".npmrc"),
199
+ "pypirc-home": path.join(home, ".pypirc"),
200
+ "gcloud-adc": path.join(home, ".config", "gcloud", "application_default_credentials.json"),
201
+ "npmrc-project": path.join(root, ".npmrc"),
202
+ "pypirc-project": path.join(root, ".pypirc"),
203
+ };
204
+ const ssoCacheDir = path.join(home, ".aws", "sso", "cache");
205
+
206
+ const presence = {};
207
+ for (const [id, p] of Object.entries(carriers)) presence[id] = fileExists(p);
208
+
209
+ // Read carriers we care about.
210
+ const awsCredsContent = presence["aws-credentials"] ? readSafe(carriers["aws-credentials"]) : null;
211
+ const awsCfgContent = presence["aws-config"] ? readSafe(carriers["aws-config"]) : null;
212
+ const kubeContent = presence["kube-config"] ? readSafe(carriers["kube-config"]) : null;
213
+ const dockerContent = presence["docker-config"] ? readSafe(carriers["docker-config"]) : null;
214
+ const npmrcHomeContent = presence["npmrc-home"] ? readSafe(carriers["npmrc-home"]) : null;
215
+ const pypirHomeContent = presence["pypirc-home"] ? readSafe(carriers["pypirc-home"]) : null;
216
+ const npmrcProjContent = presence["npmrc-project"] ? readSafe(carriers["npmrc-project"]) : null;
217
+ const pypirProjContent = presence["pypirc-project"] ? readSafe(carriers["pypirc-project"]) : null;
218
+ const gcloudAdcContent = presence["gcloud-adc"] ? readSafe(carriers["gcloud-adc"]) : null;
219
+
220
+ // Indicator predicates.
221
+ const awsCredsParsed = parseAwsCredentials(awsCredsContent);
222
+ const awsCfgParsed = parseAwsCredentials(awsCfgContent);
223
+ const ssoCacheFiles = (() => {
224
+ try { return fs.readdirSync(ssoCacheDir).filter(f => f.endsWith(".json")); }
225
+ catch { return []; }
226
+ })();
227
+ // aws-static-key-present: any AKIA* key with no federation. Apply
228
+ // the playbook's catalogued FP[0] (AWS-published doc-fixture key)
229
+ // and FP[2] (break-glass profile-name pattern) directly in the
230
+ // collector — they're deterministic and the collector has the
231
+ // evidence locally. FP[1] requires `aws sts get-caller-identity`
232
+ // which is out of stdlib scope, so the collector cannot attest it;
233
+ // the runner downgrades hit → inconclusive with that one
234
+ // unsatisfied, which is the honest outcome.
235
+ const AWS_DOC_FIXTURE_KEY = "AKIAIOSFODNN7EXAMPLE";
236
+ const realAwsProfiles = awsCredsParsed.staticProfiles.filter(p => {
237
+ // Parse the raw INI again for this profile's key value + name.
238
+ // For doc-fixture demotion (FP[0]) we look up the key value; for
239
+ // break-glass demotion (FP[2]) we check the profile name pattern.
240
+ const block = (awsCredsContent || "").split(/^\[/m).find(b => b.startsWith(p + "]"));
241
+ if (!block) return true;
242
+ if (block.includes(AWS_DOC_FIXTURE_KEY)) return false; // FP[0]
243
+ if (/^breakglass-/i.test(p) || /^break-glass-/i.test(p)) return false; // FP[2]
244
+ return true;
245
+ });
246
+ const awsStaticKey = realAwsProfiles.length > 0;
247
+
248
+ const kubeParsed = parseKubeConfig(kubeContent);
249
+ const gcloudParsed = parseGcloudAdc(gcloudAdcContent);
250
+ const dockerParsed = parseDockerConfig(dockerContent);
251
+
252
+ // docker-cleartext-auth FP checks (per playbook):
253
+ // FP[0] — vendor-token user pattern (decoded `user:pass`) is
254
+ // `<token>` / `AWS` / `oauth2accesstoken` / a zero-UUID;
255
+ // treat as a deliberately published convention, demote.
256
+ // FP[1] — local-only registry (loopback IP, *.local, *.svc.
257
+ // cluster.local, kind.local) on a dev workstation, demote.
258
+ // FP[2] — global credsStore overrides per-registry omission —
259
+ // the collector already accounts for this via
260
+ // parseDockerConfig.hasCredHelper.
261
+ const VENDOR_TOKEN_USERS = new Set([
262
+ "<token>", "AWS", "oauth2accesstoken",
263
+ "00000000-0000-0000-0000-000000000000",
264
+ ]);
265
+ function isLocalOnlyRegistry(registry) {
266
+ return /^(?:127\.0\.0\.1|localhost)(?::\d+)?$/.test(registry) ||
267
+ /\.local(?::\d+)?$/.test(registry) ||
268
+ /\.svc\.cluster\.local(?::\d+)?$/.test(registry) ||
269
+ /^kind\.local(?::\d+)?$/.test(registry);
270
+ }
271
+ function dockerAuthDemoted(registry, entry) {
272
+ // FP[1]: local-only registry → demote.
273
+ if (isLocalOnlyRegistry(registry)) return true;
274
+ // FP[0]: decode `auth` base64 and check for vendor-token user.
275
+ try {
276
+ const decoded = Buffer.from(entry.auth, "base64").toString("utf8");
277
+ const [user] = decoded.split(":", 1);
278
+ if (VENDOR_TOKEN_USERS.has(user)) return true;
279
+ } catch { /* unparseable, treat as real */ }
280
+ return false;
281
+ }
282
+ const realCleartextRegistries = dockerParsed.registriesWithCleartext.filter(reg => {
283
+ const entry = (() => { try { return JSON.parse(dockerContent || "{}").auths[reg]; } catch { return null; } })();
284
+ if (!entry) return false;
285
+ return !dockerAuthDemoted(reg, entry);
286
+ });
287
+ const dockerCleartext = realCleartextRegistries.length > 0;
288
+
289
+ const npmPatPresent =
290
+ (npmrcHomeContent && NPM_PAT_RE.test(npmrcHomeContent)) ||
291
+ (npmrcProjContent && NPM_PAT_RE.test(npmrcProjContent));
292
+ const pypiTokenPresent =
293
+ (pypirHomeContent && PYPI_TOKEN_RE.test(pypirHomeContent)) ||
294
+ (pypirProjContent && PYPI_TOKEN_RE.test(pypirProjContent));
295
+
296
+ // credentials-file-bad-perms: POSIX only. Any of the listed
297
+ // carriers with mode != 0600. Per playbook the indicator covers
298
+ // `~/.config/gcloud/*` too, so include the gcloud ADC file (and
299
+ // its parent dir mode != 0700 expectation per the spec).
300
+ let credsFileBadPerms;
301
+ const permViolations = [];
302
+ if (isPosix) {
303
+ const permTargets = [
304
+ ["aws-credentials", carriers["aws-credentials"], 0o600],
305
+ ["aws-config", carriers["aws-config"], 0o600],
306
+ ["kube-config", carriers["kube-config"], 0o600],
307
+ ["docker-config", carriers["docker-config"], 0o600],
308
+ ["npmrc-home", carriers["npmrc-home"], 0o600],
309
+ ["pypirc-home", carriers["pypirc-home"], 0o600],
310
+ ["gcloud-adc", carriers["gcloud-adc"], 0o600],
311
+ ];
312
+ for (const [id, p, expectedMode] of permTargets) {
313
+ if (!presence[id]) continue;
314
+ // FP[1]: 0-byte placeholder OR symlink to broker socket / tmpfs —
315
+ // mode bits don't carry the same blast radius. Skip these.
316
+ let lstat;
317
+ try { lstat = fs.lstatSync(p); } catch { continue; }
318
+ if (lstat.size === 0) continue;
319
+ if (lstat.isSymbolicLink()) continue;
320
+ const m = modeOf(p);
321
+ if (m == null) continue;
322
+ if (m !== expectedMode) {
323
+ permViolations.push({ id, mode_octal: "0" + m.toString(8) });
324
+ }
325
+ }
326
+ // Also check the gcloud directory itself (expected 0700 per spec).
327
+ const gcloudDir = path.join(home, ".config", "gcloud");
328
+ try {
329
+ const gs = fs.statSync(gcloudDir);
330
+ if (gs.isDirectory()) {
331
+ const dm = gs.mode & 0o777;
332
+ if (dm !== 0o700) {
333
+ permViolations.push({ id: "gcloud-dir", mode_octal: "0" + dm.toString(8) });
334
+ }
335
+ }
336
+ } catch { /* not present, no violation */ }
337
+ credsFileBadPerms = permViolations.length > 0 ? "hit" : "miss";
338
+ }
339
+
340
+ const signal_overrides = {
341
+ "aws-static-key-present": awsStaticKey ? "hit" : "miss",
342
+ "kube-static-token": kubeParsed.hasStaticToken ? "hit" : "miss",
343
+ "gcp-service-account-json-adc": gcloudParsed.hasServiceAccount ? "hit" : "miss",
344
+ "docker-cleartext-auth": dockerCleartext ? "hit" : "miss",
345
+ "npm-pat-present": npmPatPresent ? "hit" : "miss",
346
+ "pypi-token-present": pypiTokenPresent ? "hit" : "miss",
347
+ };
348
+ if (credsFileBadPerms !== undefined) {
349
+ signal_overrides["credentials-file-bad-perms"] = credsFileBadPerms;
350
+ }
351
+
352
+ // Per-indicator __fp_checks attestation. The runner gates a 'hit'
353
+ // verdict on false_positive_checks_required[] entries; an
354
+ // unsatisfied check downgrades to 'inconclusive'. Attest exactly
355
+ // the checks the collector itself ran (don't attest network /
356
+ // operator-judgement checks). Use the index-keyed form because
357
+ // false_positive_checks_required entries are free-text prose, not
358
+ // ids — the index is the stable cross-reference.
359
+ //
360
+ // aws-static-key-present:
361
+ // [0] doc-fixture demotion (AKIAIOSFODNN7EXAMPLE) — DONE
362
+ // [1] live-key sts check — NOT DONE (needs network)
363
+ // [2] break-glass profile-name pattern — DONE
364
+ //
365
+ // docker-cleartext-auth:
366
+ // [0] vendor-token user pattern — DONE
367
+ // [1] local-only registry — DONE
368
+ // [2] global credsStore — DONE
369
+ //
370
+ // credentials-file-bad-perms:
371
+ // [0] Windows / WSL skip — DONE (POSIX guard)
372
+ // [1] 0-byte / symlink skip — DONE
373
+ // [2] ACL-by-design (operator interview) — NOT DONE
374
+ if (awsStaticKey) {
375
+ signal_overrides["aws-static-key-present__fp_checks"] = { "0": true, "2": true };
376
+ }
377
+ if (dockerCleartext) {
378
+ signal_overrides["docker-cleartext-auth__fp_checks"] = { "0": true, "1": true, "2": true };
379
+ }
380
+ if (credsFileBadPerms === "hit") {
381
+ signal_overrides["credentials-file-bad-perms__fp_checks"] = { "0": true, "1": true };
382
+ }
383
+
384
+ // Artifact-level captures (one entry per artifact id in
385
+ // data/playbooks/cred-stores.json look.artifacts[]). We only
386
+ // populate the ones the collector actually reads; the rest are
387
+ // marked captured=false with a "reason" so the runner records
388
+ // partial-evidence coverage rather than a phantom miss.
389
+ const artifacts = {
390
+ "aws-credentials": presence["aws-credentials"]
391
+ ? { value: `present (${awsCredsParsed.staticProfiles.length} static profile(s), ${awsCredsParsed.federatedProfiles.length} federated)`, captured: true }
392
+ : { value: "absent", captured: true },
393
+ "aws-sso-cache": {
394
+ value: ssoCacheFiles.length > 0 ? `${ssoCacheFiles.length} cached SSO session(s)` : "empty",
395
+ captured: true,
396
+ },
397
+ "kube-config": presence["kube-config"]
398
+ ? { value: `present; static_token=${kubeParsed.hasStaticToken}; exec_provider=${kubeParsed.hasExec}`, captured: true }
399
+ : { value: "absent", captured: true },
400
+ "gcloud-credentials": presence["gcloud-adc"]
401
+ ? { value: `application_default_credentials.json present; service_account=${gcloudParsed.hasServiceAccount}`, captured: true }
402
+ : { value: "absent", captured: true, reason: "application_default_credentials.json not found; credentials.db SQLite inspection skipped (no stdlib SQLite reader)" },
403
+ "docker-config": presence["docker-config"]
404
+ ? { value: `auths_present=${Object.keys(dockerParsed.registriesWithCleartext).length > 0 || dockerParsed.hasCredHelper}; cleartext_registries=[${dockerParsed.registriesWithCleartext.join(", ")}]; cred_helper=${dockerParsed.hasCredHelper}`, captured: true }
405
+ : { value: "absent", captured: true },
406
+ "npmrc": {
407
+ value: [
408
+ presence["npmrc-home"] ? "~/.npmrc=present" : "~/.npmrc=absent",
409
+ presence["npmrc-project"] ? "project .npmrc=present" : "project .npmrc=absent",
410
+ `_authToken_present=${!!npmPatPresent}`,
411
+ ].join("; "),
412
+ captured: true,
413
+ },
414
+ "pypirc": {
415
+ value: [
416
+ presence["pypirc-home"] ? "~/.pypirc=present" : "~/.pypirc=absent",
417
+ presence["pypirc-project"] ? "project .pypirc=present" : "project .pypirc=absent",
418
+ `token_present=${!!pypiTokenPresent}`,
419
+ ].join("; "),
420
+ captured: true,
421
+ },
422
+ "gpg-keys": {
423
+ value: "skipped — gpg CLI invocation deferred to operator/AI evidence",
424
+ captured: false,
425
+ reason: "deterministic gpg-key-old-or-weak parsing requires gpg --list-secret-keys; left to operator-supplied evidence",
426
+ },
427
+ "ssh-keys-inventory": {
428
+ value: "skipped — ssh-keygen invocation deferred to operator/AI evidence",
429
+ captured: false,
430
+ reason: "ssh-key-rsa-short-bits / ssh-key-old need ssh-keygen output + mtime correlation with ssh-config; left to operator-supplied evidence",
431
+ },
432
+ "ssh-config": {
433
+ value: "skipped — ssh-config inspection deferred to operator/AI evidence",
434
+ captured: false,
435
+ reason: "ssh-config CertificateFile / ProxyJump correlation is judgement-shaped; collector leaves it to operator",
436
+ },
437
+ "keychain-inventory": {
438
+ value: "skipped — host keychain access deferred to operator/AI evidence",
439
+ captured: false,
440
+ reason: "secret-tool / security dump-keychain require interactive auth or platform-specific binaries; out of stdlib collector scope",
441
+ },
442
+ };
443
+
444
+ if (permViolations.length > 0) {
445
+ artifacts["credentials-file-perms"] = {
446
+ value: permViolations.map(v => `${v.id} (${v.mode_octal})`).join("; "),
447
+ captured: true,
448
+ };
449
+ }
450
+
451
+ return {
452
+ precondition_checks: {
453
+ "home-dir-readable": fs.existsSync(home),
454
+ },
455
+ artifacts,
456
+ signal_overrides,
457
+ collector_meta: {
458
+ collector_id: COLLECTOR_ID,
459
+ collector_version: "2026-05-20",
460
+ platform: process.platform,
461
+ captured_at: new Date().toISOString(),
462
+ cwd: root,
463
+ home,
464
+ duration_ms: Date.now() - startTime,
465
+ carriers_present: Object.entries(presence).filter(([_, v]) => v).map(([k]) => k),
466
+ },
467
+ collector_errors: errors,
468
+ };
469
+ }
470
+
471
+ module.exports = { playbook_id: COLLECTOR_ID, collect };