@blamejs/exceptd-skills 0.16.23 → 0.16.24

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')