@blamejs/exceptd-skills 0.16.23 → 0.16.25

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.
@@ -106,6 +106,21 @@ function isWorldWritable(p) {
106
106
  } catch { return false; }
107
107
  }
108
108
 
109
+ // Classify a world-writable hit against the two deterministic
110
+ // false_positive_checks_required entries for world-writable-in-trusted-path:
111
+ // [0] sticky-bit (1777-style) dirs/files intentionally permit per-user write
112
+ // [1] 0-byte stamp / unix-socket / FIFO documented for the application
113
+ // Returns { stickyBit, special } so the caller can both keep only genuine
114
+ // hits and attest exactly the checks it ran.
115
+ function classifyWorldWritable(p) {
116
+ try {
117
+ const s = fs.lstatSync(p);
118
+ const stickyBit = (s.mode & 0o1000) !== 0;
119
+ const special = s.isSocket() || s.isFIFO() || (s.isFile() && s.size === 0);
120
+ return { stickyBit, special };
121
+ } catch { return { stickyBit: false, special: false }; }
122
+ }
123
+
109
124
  function readProcPid(pid, procRoot) {
110
125
  // Returns { pid, ppid, uid, exe } or null.
111
126
  try {
@@ -240,11 +255,22 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
240
255
  const passwdContent = readFileSafe(P.passwd);
241
256
  const uid0 = passwdContent ? parsePasswdUidZero(passwdContent) : null;
242
257
 
243
- // World-writable files under trusted paths.
258
+ // World-writable files under trusted paths. Split into genuine hits
259
+ // (regular non-empty files without the sticky bit) and benign carriers
260
+ // the two false_positive_checks_required entries demote (sticky-bit
261
+ // per-user-write dirs; 0-byte stamps / sockets / FIFOs). Only the
262
+ // genuine hits flip the indicator; the split records which FP checks
263
+ // the collector deterministically ran.
244
264
  const worldWritableFiles = [];
265
+ let sawStickyBitCarrier = false;
266
+ let sawSpecialCarrier = false;
245
267
  for (const tp of P.trustedPaths) {
246
268
  for (const f of walkShallow(tp, TRUSTED_PATH_MAX_DEPTH)) {
247
- if (isWorldWritable(f)) worldWritableFiles.push(f);
269
+ if (!isWorldWritable(f)) continue;
270
+ const { stickyBit, special } = classifyWorldWritable(f);
271
+ if (stickyBit) { sawStickyBitCarrier = true; continue; }
272
+ if (special) { sawSpecialCarrier = true; continue; }
273
+ worldWritableFiles.push(f);
248
274
  }
249
275
  }
250
276
 
@@ -266,7 +292,16 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
266
292
  try { fs.readdirSync(tp); return true; } catch { return false; }
267
293
  });
268
294
  if (anyTpReadable) {
269
- signal_overrides["world-writable-in-trusted-path"] = worldWritableFiles.length > 0 ? "hit" : "miss";
295
+ const wwHit = worldWritableFiles.length > 0;
296
+ signal_overrides["world-writable-in-trusted-path"] = wwHit ? "hit" : "miss";
297
+ // Attest the false_positive_checks_required entries the collector
298
+ // ran against every flagged file: [0] sticky-bit carriers and [1]
299
+ // 0-byte/socket/FIFO carriers were both stat-inspected and excluded,
300
+ // so a surviving hit satisfies both. Without this attestation the
301
+ // runner downgrades a real world-writable hit to inconclusive.
302
+ if (wwHit) {
303
+ signal_overrides["world-writable-in-trusted-path__fp_checks"] = { "0": true, "1": true };
304
+ }
270
305
  }
271
306
  // orphan-privileged-process: only emit when /proc was walkable
272
307
  // AND the scan had enough exe-link visibility to reach a verdict.
@@ -354,6 +354,12 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
354
354
  const j = JSON.parse(fs.readFileSync(npmLockfile.path, "utf8"));
355
355
  let withIntegrity = 0;
356
356
  let withoutIntegrity = 0;
357
+ // Track whether any integrity-less entry is a local-path / workspace /
358
+ // git ref. lockfile-no-integrity FP[0] demotes those — they legitimately
359
+ // have no registry integrity hash. A remote-registry tarball without
360
+ // integrity is the genuine finding.
361
+ let withoutIntegrityLocalOnly = true;
362
+ const LOCAL_REF_RE = /^(?:file:|link:|workspace:|git\+ssh:|git\+https:|git:|github:|portal:)/i;
357
363
  const walk = (obj) => {
358
364
  if (!obj || typeof obj !== "object") return;
359
365
  // Only remote-tarball entries (those with a `resolved` URL) are
@@ -362,8 +368,12 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
362
368
  // `integrity`, so keying off `version` would false-positive on
363
369
  // every clean lockfile. Mirror library-author.js's guard.
364
370
  if (obj.resolved != null) {
365
- if (obj.integrity != null) withIntegrity++;
366
- else withoutIntegrity++;
371
+ if (obj.integrity != null) {
372
+ withIntegrity++;
373
+ } else {
374
+ withoutIntegrity++;
375
+ if (!LOCAL_REF_RE.test(String(obj.resolved))) withoutIntegrityLocalOnly = false;
376
+ }
367
377
  }
368
378
  for (const v of Object.values(obj)) if (v && typeof v === "object") walk(v);
369
379
  };
@@ -373,6 +383,15 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
373
383
  // class, not full coverage.
374
384
  if (withoutIntegrity > 0) {
375
385
  signal_overrides["lockfile-no-integrity"] = "hit";
386
+ // __fp_checks attestation. [0]: at least one integrity-less entry is a
387
+ // remote-registry tarball (not exclusively local-path/workspace/git
388
+ // refs). [1]: the lockfile is the canonical root package-lock.json the
389
+ // build consumes, not a stale copy under archive/ pre-migration/.
390
+ const att = {};
391
+ if (!withoutIntegrityLocalOnly) att["0"] = true;
392
+ const rel = (npmLockfile.path || "").replace(/\\/g, "/");
393
+ if (!/\/(?:archive|pre-migration|old|backup|legacy)\//i.test(rel)) att["1"] = true;
394
+ if (Object.keys(att).length) signal_overrides["lockfile-no-integrity__fp_checks"] = att;
376
395
  } else if (withIntegrity > 0) {
377
396
  signal_overrides["lockfile-no-integrity"] = "miss";
378
397
  }
@@ -112,6 +112,98 @@ const AWS_EXAMPLE_ACCESS_KEY_IDS = new Set([
112
112
  "AKIAIOSFODNN7EXAMPLE",
113
113
  ]);
114
114
 
115
+ // AWS-published sample secret-access-key (paired with AKIAIOSFODNN7EXAMPLE
116
+ // throughout the AWS docs). aws-secret-access-key false_positive_checks_required[1]
117
+ // demotes this exact value.
118
+ const AWS_EXAMPLE_SECRET_KEY = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY";
119
+
120
+ // Placeholder/fixture substrings the API-key indicators' FP[0] checks demote.
121
+ // A literal `PLACEHOLDER` / `EXAMPLE` / `XXXX` run / a `dummy`/`test` infix
122
+ // marks documentation material rather than a live credential.
123
+ const PLACEHOLDER_RE = /placeholder|example|redacted|dummy|x{4,}|0{6,}|1234567890/i;
124
+
125
+ // Path segments the per-indicator FP path checks treat as documentation /
126
+ // fixture material in addition to TEST_PATH_SEGMENTS (which already covers
127
+ // /examples/, /fixtures/, /test/ etc.). The secret indicators' FP prose adds
128
+ // /docs/ and quickstart/snippet paths.
129
+ const DOC_PATH_SEGMENTS = [
130
+ "/docs/", "/doc/", "/sdk-quickstart/", "/quickstart/", "/docs-snippet/",
131
+ ];
132
+
133
+ function isDocOrTestPath(rel) {
134
+ if (isTestPath(rel)) return true;
135
+ const norm = "/" + rel.replace(/\\/g, "/").toLowerCase() + "/";
136
+ return DOC_PATH_SEGMENTS.some((seg) => norm.includes(seg));
137
+ }
138
+
139
+ // Per-indicator deterministic false_positive_checks_required evaluation.
140
+ // Returns the set of FP-check indices (as strings) the collector can attest
141
+ // for a single hit — i.e. the checks it actually ran and that the hit
142
+ // survives. Indices requiring network reachability or operator judgement are
143
+ // deliberately omitted so the runner honestly downgrades to inconclusive.
144
+ // `value` is the matched credential, `file` the relative path, `window` a
145
+ // few-line context slice around the match.
146
+ function fpIndicesSatisfied(indicatorId, value, file, window) {
147
+ const sat = new Set();
148
+ const notDocPath = !isDocOrTestPath(file);
149
+ switch (indicatorId) {
150
+ case "aws-secret-access-key": {
151
+ // [0] co-occurrence with an AKIA*/ASIA*/AGPA*/AIDA* id in a 10-line window
152
+ if (/\b(?:AKIA|ASIA|AGPA|AIDA)[0-9A-Z]{12,}\b/.test(window)) sat.add("0");
153
+ // [1] not the AWS-published sample secret
154
+ if (value !== AWS_EXAMPLE_SECRET_KEY) sat.add("1");
155
+ // [2] not under examples/ docs/ fixtures/ / a test snapshot
156
+ if (notDocPath) sat.add("2");
157
+ break;
158
+ }
159
+ case "slack-bot-or-user-token": {
160
+ // [0] not a placeholder / published doc fixture
161
+ if (!PLACEHOLDER_RE.test(value)) sat.add("0");
162
+ // [1] conforms to a current Slack token shape: at least three
163
+ // dash-separated segments after the xox? prefix
164
+ if (value.split("-").length >= 4) sat.add("1");
165
+ // [2] not under examples/ docs/ fixtures/
166
+ if (notDocPath) sat.add("2");
167
+ break;
168
+ }
169
+ case "stripe-secret-key": {
170
+ // [0] not a sk_test_ published sample (deterministic only for the
171
+ // test prefix; live keys are handled by [2])
172
+ if (!(value.startsWith("sk_test_") && PLACEHOLDER_RE.test(value))) sat.add("0");
173
+ // [1] not under examples/ fixtures/ docs/ / a quickstart template
174
+ if (notDocPath) sat.add("1");
175
+ // [2] the live-validity probe applies only to sk_live_*; a sk_test_
176
+ // key carries no live financial exposure, so the check is moot and
177
+ // the collector can attest it deterministically. sk_live_* still
178
+ // needs operator-authorised network validation — left unattested.
179
+ if (value.startsWith("sk_test_") || value.startsWith("rk_test_")) sat.add("2");
180
+ break;
181
+ }
182
+ case "openai-api-key": {
183
+ // [0] not a placeholder / sk-test- / sk-dummy- fixture
184
+ if (!PLACEHOLDER_RE.test(value) && !/^sk-(?:test|dummy)-/i.test(value)) sat.add("0");
185
+ // [1] post-prefix length meets the entropy floor (>= 48 chars)
186
+ if (value.replace(/^sk-(?:proj-|svcacct-|admin-)?/, "").length >= 48) sat.add("1");
187
+ // [2] vendor disambiguation — sk-ant-* is Anthropic, not OpenAI
188
+ if (!/^sk-ant-/i.test(value)) sat.add("2");
189
+ break;
190
+ }
191
+ case "anthropic-api-key": {
192
+ // [0] not a placeholder / sk-ant-test- fixture
193
+ if (!PLACEHOLDER_RE.test(value) && !/^sk-ant-test-/i.test(value)) sat.add("0");
194
+ // [1] not under examples/ fixtures/ sdk-quickstart/ docs-snippet/
195
+ if (notDocPath) sat.add("1");
196
+ // [2] post-prefix length meets the entropy floor (>= 80 chars after
197
+ // sk-ant-(api03|admin01)-)
198
+ if (value.replace(/^sk-ant-(?:api03|admin01)-/, "").length >= 80) sat.add("2");
199
+ break;
200
+ }
201
+ default:
202
+ break;
203
+ }
204
+ return sat;
205
+ }
206
+
115
207
  const INDICATOR_PATTERNS = [
116
208
  { id: "aws-access-key-id", re: /\bAKIA[0-9A-Z]{16}\b/g },
117
209
  { id: "aws-secret-access-key", re: /\baws_secret_access_key\s*[=:]\s*['"]?([A-Za-z0-9/+=]{40})['"]?/gi },
@@ -209,6 +301,13 @@ function scanContent(full, rel) {
209
301
  // Demote AWS-published example access-key IDs (e.g. the docs' canonical
210
302
  // AKIAIOSFODNN7EXAMPLE). A README quoting the AWS docs must not hit.
211
303
  if (p.id === "aws-access-key-id" && AWS_EXAMPLE_ACCESS_KEY_IDS.has(m[0])) continue;
304
+ // The captured credential is group 1 when the pattern brackets it
305
+ // (aws_secret_access_key=<value>); otherwise the whole match.
306
+ const value = m[1] != null ? m[1] : m[0];
307
+ // ±600-byte context window (a deterministic proxy for "nearby lines")
308
+ // used by the co-occurrence FP check.
309
+ const winStart = Math.max(0, m.index - 600);
310
+ const window = buf.slice(winStart, m.index + 600);
212
311
  hits.push({
213
312
  indicator_id: p.id,
214
313
  file: rel,
@@ -217,6 +316,9 @@ function scanContent(full, rel) {
217
316
  // (SARIF startLine) instead of a bare file-level location.
218
317
  line: lineFromOffset(buf, m.index),
219
318
  redacted_match: redactMatch(m[0]),
319
+ // The false_positive_checks_required indices this hit deterministically
320
+ // survives — attested so the runner doesn't downgrade hit → inconclusive.
321
+ fp_satisfied: fpIndicesSatisfied(p.id, value, rel, window),
220
322
  });
221
323
  if (++count >= 5) break; // cap per-indicator-per-file
222
324
  }
@@ -310,6 +412,29 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
310
412
  for (const p of INDICATOR_PATTERNS) {
311
413
  signal_overrides[p.id] = prodHitsByIndicator[p.id] && prodHitsByIndicator[p.id].length > 0 ? "hit" : "miss";
312
414
  }
415
+ // Per-indicator __fp_checks attestation. For each FP-gated indicator that
416
+ // fired, attest the false_positive_checks_required indices the collector
417
+ // deterministically ran AND that EVERY surviving hit satisfies (the
418
+ // intersection — an index is only universally true if no hit fails it).
419
+ // Network / operator-judgement indices are never in the set, so the runner
420
+ // still downgrades indicators that carry one. Without this, a real secret
421
+ // surfaced by `collect` is downgraded to inconclusive after `run`.
422
+ for (const p of INDICATOR_PATTERNS) {
423
+ if (signal_overrides[p.id] !== "hit") continue;
424
+ const hits = prodHitsByIndicator[p.id] || [];
425
+ if (!hits.length || !hits[0].fp_satisfied) continue;
426
+ let common = null;
427
+ for (const h of hits) {
428
+ const s = h.fp_satisfied || new Set();
429
+ if (common === null) { common = new Set(s); continue; }
430
+ for (const idx of [...common]) if (!s.has(idx)) common.delete(idx);
431
+ }
432
+ if (common && common.size) {
433
+ const att = {};
434
+ for (const idx of common) att[idx] = true;
435
+ signal_overrides[`${p.id}__fp_checks`] = att;
436
+ }
437
+ }
313
438
  // ssh-private-key-block is also flipped by file presence (a private
314
439
  // key file with the matching magic bytes counts even without a
315
440
  // content scan match — e.g. binary-only key formats). Re-flip when
@@ -63,7 +63,6 @@ const path = require('path');
63
63
  const fs = require('fs');
64
64
 
65
65
  const CVE_ID_RE = /^CVE-((?:19|20)\d{2})-\d{4,7}$/;
66
- const TODAY = new Date().toISOString().slice(0, 10);
67
66
 
68
67
  /**
69
68
  * Extract the year from a CVE-YYYY-NNN identifier. Returns null for non-CVE
@@ -274,7 +273,11 @@ function findRegressionCandidates(diffs, catalog, opts) {
274
273
  candidates,
275
274
  historical_id_threshold_year: thresholdYear,
276
275
  evaluated_diffs: evaluated,
277
- generated_at: TODAY,
276
+ // Stamp from the same clock that derives the threshold year, so both
277
+ // date-derived report fields share one instant. Falls back to call-time
278
+ // `new Date()` (via the `now` default) when no clock is injected — never
279
+ // a module-load constant, which would go stale in a long-lived process.
280
+ generated_at: now.toISOString().slice(0, 10),
278
281
  control_ref: 'NEW-CTRL-074',
279
282
  };
280
283
  }
@@ -3425,9 +3425,22 @@ function run(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
3425
3425
  if (runOpts.session_id) {
3426
3426
  sessionId = runOpts.session_id;
3427
3427
  } else if (runOpts.bundleDeterministic) {
3428
- const submissionDigest = crypto.createHash('sha256')
3429
- .update(canonicalStringify(extractSubmissionForHash(agentSubmission)))
3430
- .digest('hex');
3428
+ let submissionDigest;
3429
+ try {
3430
+ submissionDigest = crypto.createHash('sha256')
3431
+ .update(canonicalStringify(extractSubmissionForHash(agentSubmission)))
3432
+ .digest('hex');
3433
+ } catch (e) {
3434
+ // canonicalStringify deliberately throws (EVIDENCE_TOO_DEEP) on
3435
+ // pathological nesting. The mutex lockfile and the _activeRuns entry
3436
+ // are already held here, but the protecting try/finally below has
3437
+ // not opened yet — release both before rethrowing, or the leaked
3438
+ // lockfile blocks every subsequent run of this playbook for as long
3439
+ // as this PID lives.
3440
+ _activeRuns.delete(playbookId);
3441
+ releaseLock(lockPath);
3442
+ throw e;
3443
+ }
3431
3444
  sessionId = crypto.createHash('sha256')
3432
3445
  .update(`${playbookId}\0${submissionDigest}\0${getEngineVersion()}`)
3433
3446
  .digest('hex')
@@ -109,13 +109,17 @@ function parseArgs(argv) {
109
109
  // older than 7d or one that was prefetched without a signing keypair.
110
110
  // EXCEPTD_FORCE_STALE=1 mirrors for non-interactive automation.
111
111
  else if (a === "--force-stale") out.forceStale = true;
112
- // Aliases that bin/exceptd.js may pass through or translate; accept them
113
- // here so the unknown-flag guard below doesn't false-reject a legitimate
114
- // operator invocation. (--no-network / --indexes-only / --network /
115
- // --curate / --prefetch are normally rewritten upstream, but tolerate
116
- // them when refresh-external is invoked directly.)
112
+ // --prefetch / --no-network are prefetch-cache operations. Capture them so
113
+ // main() can delegate to lib/prefetch.js (the same routing bin/exceptd.js
114
+ // performs) when this script is invoked directly — otherwise the help
115
+ // text's "report-only, no cache write" promise for --no-network is a lie
116
+ // on the direct path, which would fall through to the live refresh loop.
117
+ else if (a === "--no-network") { out.noNetwork = true; }
118
+ else if (a === "--prefetch") { out.prefetch = true; }
119
+ // Remaining bin-translated aliases are tolerated as no-ops at this layer
120
+ // so the unknown-flag guard below doesn't false-reject them.
117
121
  else if (
118
- a === "--no-network" || a === "--prefetch" || a === "--indexes-only" ||
122
+ a === "--indexes-only" ||
119
123
  a === "--network" || a === "--curate" || a === "--force-stale-acked"
120
124
  ) { /* accepted, no-op at this layer */ }
121
125
  // Any remaining --flag is an unrecognized typo. Record it; refuse after
@@ -148,8 +152,10 @@ Modes:
148
152
  place. Same trust anchor as \`npm update -g\`, only the
149
153
  data slice changes — useful when you want fresher
150
154
  intel without re-resolving CLI/lib code.
151
- --prefetch (alias: --no-network) populate the cache for offline use.
152
- Equivalent to \`exceptd prefetch\`.
155
+ --prefetch populate the cache for offline use. Equivalent to
156
+ \`exceptd prefetch\`.
157
+ --no-network report-only: list what would be fetched, WITHOUT writing
158
+ the cache (the dry-run opposite of --prefetch).
153
159
  --from-cache [<p>] read from prefetch cache (default .cache/upstream).
154
160
  Combine with --apply to upsert against cached data
155
161
  entirely offline. Cache must be pre-populated via --prefetch.
@@ -1099,15 +1105,16 @@ function loadCtx(opts) {
1099
1105
  const abs = path.resolve(opts.fromCache);
1100
1106
  ctx.cacheDir = abs;
1101
1107
  if (!fs.existsSync(abs)) {
1102
- // v0.11.14 (#129): operators following the website's air-gap workflow
1103
- // hit this with an unhelpful "path does not exist" stack trace. The
1104
- // cache is populated by `exceptd refresh --no-network` (which routes
1105
- // to prefetch). Tell them exactly that, and emit a structured JSON
1106
- // error to stderr instead of a fatal stack trace.
1108
+ // Operators following the air-gap workflow hit this with an unhelpful
1109
+ // "path does not exist" stack trace. The cache is populated by
1110
+ // `exceptd refresh --prefetch` (which routes to prefetch) — NOT by
1111
+ // `--no-network`, which is the report-only dry run that writes nothing.
1112
+ // Tell them exactly that, and emit a structured JSON error to stderr
1113
+ // instead of a fatal stack trace.
1107
1114
  const err = new Error(
1108
1115
  `refresh: --from-cache path does not exist: ${abs}\n` +
1109
- `Hint: the cache is populated by running \`exceptd refresh --no-network\` (or \`exceptd refresh --prefetch\`) ` +
1110
- `on a connected host first. Air-gap workflow: (1) on connected host: \`exceptd refresh --no-network\`, ` +
1116
+ `Hint: the cache is populated by running \`exceptd refresh --prefetch\` ` +
1117
+ `on a connected host first. Air-gap workflow: (1) on connected host: \`exceptd refresh --prefetch\`, ` +
1111
1118
  `(2) copy .cache/upstream/ across the boundary, (3) on offline host: \`exceptd refresh --from-cache --apply\`.`
1112
1119
  );
1113
1120
  err._exceptd_hint = true;
@@ -1526,6 +1533,24 @@ async function main() {
1526
1533
  return;
1527
1534
  }
1528
1535
 
1536
+ // `--prefetch` / `--no-network` are prefetch-cache operations. The operator
1537
+ // path (bin/exceptd.js) routes them to lib/prefetch.js; when this script is
1538
+ // invoked directly, delegate the SAME way so behavior matches the help text:
1539
+ // --prefetch populates the cache, --no-network is a report-only dry run that
1540
+ // writes nothing. Without this, the direct path fell through to the live
1541
+ // refresh loop and could egress + write refresh-report.json despite
1542
+ // --no-network.
1543
+ if (opts.prefetch || opts.noNetwork) {
1544
+ const { spawnSync } = require("child_process");
1545
+ const pfArgs = [require.resolve("./prefetch.js")];
1546
+ if (opts.noNetwork) pfArgs.push("--no-network");
1547
+ if (opts.source) pfArgs.push("--source", opts.source);
1548
+ if (opts.quiet) pfArgs.push("--quiet");
1549
+ const r = spawnSync(process.execPath, pfArgs, { stdio: "inherit" });
1550
+ process.exitCode = r.status == null ? 1 : r.status;
1551
+ return;
1552
+ }
1553
+
1529
1554
  // v0.12.0: `--advisory <id>` short-circuits the normal source loop and
1530
1555
  // seeds a single CVE catalog entry from GHSA. Exits non-zero ("draft
1531
1556
  // written, please review") so CI pipelines surface the needed editorial