@blamejs/exceptd-skills 0.14.9 → 0.14.11

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.
@@ -18,7 +18,7 @@
18
18
 
19
19
  const fs = require("node:fs");
20
20
  const path = require("node:path");
21
- const { codeExcludeSet, isLinkedWorktreeDir, buildEvidenceLocations } = require("./scan-excludes");
21
+ const { codeExcludeSet, isLinkedWorktreeDir, buildEvidenceLocations, lineFromOffset } = require("./scan-excludes");
22
22
 
23
23
  const COLLECTOR_ID = "secrets";
24
24
 
@@ -83,6 +83,18 @@ const IAC_GLOB_PREFIX = ["pulumi.", "arm."];
83
83
  // source of truth for what counts as a hit; the collector
84
84
  // implements the same patterns so its signal_overrides match what
85
85
  // the runner would compute.
86
+ // AWS-published documentation/example access-key IDs. These appear verbatim
87
+ // throughout AWS docs, SDK samples, and countless READMEs, so a literal match
88
+ // is example material, not a leaked credential. `cred-stores` demotes the same
89
+ // value (its FP[0]); secrets.js must too or it false-positives on any README
90
+ // that quotes the AWS docs. The 40-char example secret
91
+ // (`wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY`) carries the literal `EXAMPLE`
92
+ // token, which the AWS-secret-access-key pattern already requires elsewhere;
93
+ // the access-key ID is the one that needs an explicit allowlist.
94
+ const AWS_EXAMPLE_ACCESS_KEY_IDS = new Set([
95
+ "AKIAIOSFODNN7EXAMPLE",
96
+ ]);
97
+
86
98
  const INDICATOR_PATTERNS = [
87
99
  { id: "aws-access-key-id", re: /\bAKIA[0-9A-Z]{16}\b/g },
88
100
  { id: "aws-secret-access-key", re: /\baws_secret_access_key\s*[=:]\s*['"]?([A-Za-z0-9/+=]{40})['"]?/gi },
@@ -199,10 +211,16 @@ function scanContent(full, rel) {
199
211
  const matches = buf.matchAll(p.re);
200
212
  let count = 0;
201
213
  for (const m of matches) {
214
+ // Demote AWS-published example access-key IDs (e.g. the docs' canonical
215
+ // AKIAIOSFODNN7EXAMPLE). A README quoting the AWS docs must not hit.
216
+ if (p.id === "aws-access-key-id" && AWS_EXAMPLE_ACCESS_KEY_IDS.has(m[0])) continue;
202
217
  hits.push({
203
218
  indicator_id: p.id,
204
219
  file: rel,
205
220
  offset: m.index,
221
+ // 1-based line of the match so buildEvidenceLocations emits a region
222
+ // (SARIF startLine) instead of a bare file-level location.
223
+ line: lineFromOffset(buf, m.index),
206
224
  redacted_match: redactMatch(m[0]),
207
225
  });
208
226
  if (++count >= 5) break; // cap per-indicator-per-file
@@ -264,6 +282,15 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
264
282
  if (r.hits) allHits.push(...r.hits);
265
283
  if (r.skipped === "read_error") {
266
284
  errors.push({ artifact_id: "secret-regex-scan-text-files", kind: "read_failed", reason: `${f.rel}: ${r.reason}` });
285
+ } else if (r.skipped === "file_too_large") {
286
+ // A secret in the first bytes of a large file would otherwise be
287
+ // dropped silently. Record the skip so the operator knows this file
288
+ // was NOT scanned (mirrors crypto-codebase's >1 MB read_failed entry).
289
+ errors.push({
290
+ artifact_id: "secret-regex-scan-text-files",
291
+ kind: "file_too_large_skipped",
292
+ reason: `${f.rel}: ${r.bytes} bytes exceeds ${MAX_FILE_BYTES}-byte scan limit; not scanned for secrets`,
293
+ });
267
294
  }
268
295
  }
269
296
 
@@ -311,9 +338,10 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
311
338
 
312
339
  // Per-indicator file locations for every indicator flipped to "hit", so
313
340
  // a SARIF result points at the file carrying the secret / bad posture.
314
- // Content-regex hits record a byte offset rather than a line, so these
315
- // are file-level locations (no startLine). The file-presence and
316
- // posture indicators contribute the carrier file path directly.
341
+ // Content-regex hits carry a 1-based `line` (derived from the match offset),
342
+ // so these locations include a startLine region. The file-presence and
343
+ // posture indicators contribute the carrier file path directly (file-level,
344
+ // no line).
317
345
  const evidence_locations = {};
318
346
  for (const p of INDICATOR_PATTERNS) {
319
347
  if (signal_overrides[p.id] === "hit") {
package/lib/cve-cli.js CHANGED
@@ -28,7 +28,11 @@ const { resolveCve } = require("./citation-resolve.js");
28
28
  process.exitCode = 1;
29
29
  return;
30
30
  }
31
- const id = argv.find((a) => !a.startsWith("--"));
31
+ // Trim the positional so a whitespace-only argument (`cve " "`) is
32
+ // treated identically to a missing one (`cve ""`) — a usage error, not a
33
+ // "fabricated" lookup of the literal spaces.
34
+ const rawId = argv.find((a) => !a.startsWith("--"));
35
+ const id = rawId == null ? rawId : rawId.trim();
32
36
  const pretty = flags.has("--pretty");
33
37
  const json = flags.has("--json") || pretty;
34
38
 
@@ -141,7 +141,7 @@ function lagScore(frameworkId, controlGaps, globalFrameworks) {
141
141
  * @param {object} cveCatalog - Parsed cve-catalog.json (optional)
142
142
  * @returns {{ frameworks: object, universal_gaps: object[], theater_risks: object[] }}
143
143
  */
144
- function gapReport(frameworkIds, threatScenario, controlGaps, cveCatalog = {}) {
144
+ function gapReport(frameworkIds, threatScenario, controlGaps, cveCatalog = {}, opts = {}) {
145
145
  const scenario = threatScenario.toLowerCase();
146
146
 
147
147
  const relevantGaps = Object.entries(controlGaps).filter(([, gap]) => {
@@ -207,6 +207,25 @@ function gapReport(frameworkIds, threatScenario, controlGaps, cveCatalog = {}) {
207
207
  theater_test_present: !!g.theater_test,
208
208
  }));
209
209
 
210
+ // Summary matching count. With `all` frameworks the summary counts every
211
+ // scenario-relevant gap across the whole catalog (relevantGaps). With an
212
+ // explicit framework filter the summary must agree with the per-framework
213
+ // body the operator actually sees — otherwise `framework-gap nist-800-53
214
+ // <cve>` shows e.g. "2 matching control gap(s)" per-framework but "Summary:
215
+ // 8 matching gaps" (every framework's hits, pre-filter). Sum the per-
216
+ // framework gap_count so body + summary agree. De-duplicate by gap key in
217
+ // case a single gap matches multiple requested frameworks.
218
+ let matchingGapCount;
219
+ if (opts.allFrameworks) {
220
+ matchingGapCount = relevantGaps.length;
221
+ } else {
222
+ const seen = new Set();
223
+ for (const id of frameworkIds) {
224
+ for (const g of frameworkResults[id]?.gaps ?? []) seen.add(g.id);
225
+ }
226
+ matchingGapCount = seen.size;
227
+ }
228
+
210
229
  return {
211
230
  threat_scenario: threatScenario,
212
231
  frameworks: frameworkResults,
@@ -217,7 +236,7 @@ function gapReport(frameworkIds, threatScenario, controlGaps, cveCatalog = {}) {
217
236
  })),
218
237
  theater_risks: theaterRisks,
219
238
  summary: {
220
- total_gaps: relevantGaps.length,
239
+ total_gaps: matchingGapCount,
221
240
  universal_gaps: universalGaps.length,
222
241
  theater_risk_controls: theaterRisks.length
223
242
  }
@@ -1545,16 +1545,21 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
1545
1545
  const obligation = (g.jurisdiction_obligations || []).find(o =>
1546
1546
  `${o.jurisdiction}/${o.regulation} ${o.window_hours}h` === na.obligation_ref
1547
1547
  );
1548
- // Thread runOpts through so computeClockStart can check
1549
- // operator_consent.explicit before auto-stamping detect_confirmed.
1550
- const clockStart = obligation ? computeClockStart(obligation.clock_starts, agentSignals, runOpts) : null;
1551
- // When the clock event is detect_confirmed AND the classification
1552
- // matched AND the operator did NOT pass --ack, surface
1553
- // clock_pending_ack so the notification record is visibly waiting on
1554
- // acknowledgement.
1548
+ // Thread runOpts + the engine-computed classification through so
1549
+ // computeClockStart can check operator_consent.explicit before
1550
+ // auto-stamping detect_confirmed, and so an engine-confirmed detection
1551
+ // starts the clock even without a separately-submitted classification.
1552
+ const engineClassification = analyzeResult?._detect_classification || null;
1553
+ const clockStart = obligation
1554
+ ? computeClockStart(obligation.clock_starts, agentSignals, runOpts, engineClassification)
1555
+ : null;
1556
+ // When the clock event is detect_confirmed AND detection was confirmed
1557
+ // (by the agent OR the engine) AND the operator did NOT pass --ack,
1558
+ // surface clock_pending_ack so the notification record is visibly waiting
1559
+ // on acknowledgement.
1555
1560
  const clockPendingAck = !clockStart
1556
1561
  && obligation?.clock_starts === 'detect_confirmed'
1557
- && agentSignals?.detection_classification === 'detected'
1562
+ && (agentSignals?.detection_classification === 'detected' || engineClassification === 'detected')
1558
1563
  && !(runOpts && runOpts.operator_consent && runOpts.operator_consent.explicit === true);
1559
1564
  const deadline = obligation && clockStart
1560
1565
  ? new Date(clockStart.getTime() + obligation.window_hours * 3600 * 1000).toISOString()
@@ -3571,13 +3576,21 @@ function stripOuterParens(expr) {
3571
3576
  * waiting on acknowledgement.
3572
3577
  * - All other events without an explicit timestamp: return null.
3573
3578
  */
3574
- function computeClockStart(eventName, agentSignals, runOpts = {}) {
3579
+ function computeClockStart(eventName, agentSignals, runOpts = {}, engineClassification = null) {
3575
3580
  // The agent submits clock_started_at_<event> ISO strings as it progresses.
3576
3581
  const key = `clock_started_at_${eventName}`;
3577
3582
  if (agentSignals && agentSignals[key]) return new Date(agentSignals[key]);
3578
3583
  // For detect_confirmed: only auto-stamp when the operator has explicitly
3579
3584
  // acknowledged the result via --ack. Otherwise leave the clock pending.
3580
- if (eventName === 'detect_confirmed' && agentSignals?.detection_classification === 'detected'
3585
+ // Detection is "confirmed" when EITHER the agent submitted
3586
+ // detection_classification:'detected' OR the engine itself classified the
3587
+ // detect phase as 'detected'. Pre-fix only the agent-submitted signal was
3588
+ // honored, so an engine-confirmed detection (indicators fired from
3589
+ // signal_overrides without a separate classification submission) never
3590
+ // started the regulatory clock — notification deadlines silently stalled.
3591
+ const detected = agentSignals?.detection_classification === 'detected'
3592
+ || engineClassification === 'detected';
3593
+ if (eventName === 'detect_confirmed' && detected
3581
3594
  && runOpts && runOpts.operator_consent && runOpts.operator_consent.explicit === true) {
3582
3595
  return new Date();
3583
3596
  }
package/lib/prefetch.js CHANGED
@@ -112,7 +112,7 @@ function parseArgs(argv) {
112
112
  for (let i = 2; i < argv.length; i++) {
113
113
  const a = argv[i];
114
114
  if (a === "--force") out.force = true;
115
- else if (a === "--no-network" || a === "--dry-run") out.noNetwork = true;
115
+ else if (a === "--no-network" || a === "--dry-run" || a === "--air-gap") out.noNetwork = true;
116
116
  else if (a === "--quiet") out.quiet = true;
117
117
  else if (a === "--help" || a === "-h") out.help = true;
118
118
  else if (a === "--source") out.source = argv[++i];
@@ -122,6 +122,10 @@ function parseArgs(argv) {
122
122
  else if (a === "--cache-dir") out.cacheDir = path.resolve(argv[++i]);
123
123
  else if (a.startsWith("--cache-dir=")) out.cacheDir = path.resolve(a.slice("--cache-dir=".length));
124
124
  }
125
+ // The global air-gap switch implies a report-only / no-egress run: treat
126
+ // EXCEPTD_AIR_GAP=1 the same as --no-network so prefetch never plans live
127
+ // fetches under air-gap.
128
+ if (process.env.EXCEPTD_AIR_GAP === "1") out.noNetwork = true;
125
129
  return out;
126
130
  }
127
131
 
@@ -202,10 +202,11 @@ Outputs:
202
202
 
203
203
  Exit codes (refresh's own scheme — distinct from the seven-phase verbs):
204
204
  0 applied (or a clean dry-run with no diffs to surface)
205
+ 1 apply-mode downstream gate failed (build-indexes, or a per-source error)
205
206
  2 error (unknown --source, unreadable fixture, invalid --advisory id, air-gap refusal)
206
207
  3 draft produced, editorial review pending (a successful --advisory seed —
207
208
  NOT a failure; run --advisory <id> --apply to land it, or curate first)
208
- 4 network/source unreachable
209
+ 4 network/source unreachable OR cache precondition refused (unsigned/stale/tampered/unindexed cache)
209
210
  Note: exit 3 here means "review needed", which differs from \`exceptd run\`'s
210
211
  exit 3 ("ran but no evidence"). Script \`refresh --advisory\` on the body's
211
212
  \`ok\` field, not on \`$? == 0\`.
@@ -1252,8 +1253,18 @@ async function withCatalogLock(catalogPath, mutator) {
1252
1253
  }
1253
1254
 
1254
1255
  function chosenSources(opts) {
1255
- if (!opts.source) return Object.values(ALL_SOURCES);
1256
+ // Flag-absent (opts.source == null) means "all sources" — the default
1257
+ // refresh behavior. Flag-present-but-empty (`--source ""`, or a value that
1258
+ // trims to nothing like `--source ","`) is an operator error, not a
1259
+ // silent run-everything: refuse and list the valid names so the typo is
1260
+ // visible rather than masquerading as a full refresh.
1261
+ if (opts.source == null) return Object.values(ALL_SOURCES);
1256
1262
  const names = opts.source.split(",").map((s) => s.trim()).filter(Boolean);
1263
+ if (names.length === 0) {
1264
+ const err = new Error(`refresh-external: --source requires at least one source name. Valid: ${Object.keys(ALL_SOURCES).join(", ")}`);
1265
+ err._exceptd_unknown_source = true;
1266
+ throw err;
1267
+ }
1257
1268
  const out = [];
1258
1269
  for (const n of names) {
1259
1270
  if (!ALL_SOURCES[n]) {
@@ -1428,7 +1439,7 @@ async function main() {
1428
1439
  // the seed is printed to stdout for review.
1429
1440
  // An empty --advisory value (`--advisory ""` / `--advisory=`) must error
1430
1441
  // rather than silently falling through to a full-refresh dry-run.
1431
- if (opts.advisory === "") {
1442
+ if (opts.advisory != null && opts.advisory.trim() === "") {
1432
1443
  process.stderr.write(JSON.stringify({
1433
1444
  ok: false,
1434
1445
  error: "refresh: --advisory requires a non-empty identifier (e.g. CVE-2026-1234, GHSA-xxxx-xxxx-xxxx, MAL-2026-1).",
@@ -1496,11 +1507,22 @@ async function main() {
1496
1507
  ? await Promise.all(sources.map(runOne))
1497
1508
  : await sequential(sources, runOne);
1498
1509
 
1510
+ // Cache-integrity refusals (sha256 mismatch, missing/partial _index.json,
1511
+ // unindexed payload) are thrown by readCachedJson with _exceptd_exit_code=4
1512
+ // but caught inside runOne and returned as a per-source error — so the
1513
+ // throw never reaches main().catch where the code is otherwise honored.
1514
+ // Carry the marker through here so main() can prefer exit 4 (BLOCKED /
1515
+ // precondition refusal) over the generic per-source-failure exit 1.
1516
+ let cacheIntegrityFailure = false;
1499
1517
  for (const { src, diff, error } of outcomes) {
1500
1518
  if (error) {
1501
1519
  log(`\n [${src.name}] ${src.description}`);
1502
1520
  log(` error: ${error.message}`);
1503
1521
  report.sources[src.name] = { status: "error", error: error.message };
1522
+ if (error._exceptd_cache_integrity || error._exceptd_exit_code === 4) {
1523
+ report.sources[src.name].cache_integrity = true;
1524
+ cacheIntegrityFailure = true;
1525
+ }
1504
1526
  hadFailure = true;
1505
1527
  continue;
1506
1528
  }
@@ -1550,7 +1572,10 @@ async function main() {
1550
1572
  // truncate buffered stdout (refresh-report path log line, summary log
1551
1573
  // lines piped to a consumer). exitCode + return lets the event loop end
1552
1574
  // naturally and stdout drains in full.
1553
- process.exitCode = hadFailure ? 1 : 0;
1575
+ // Prefer the documented BLOCKED (4) code when any source refused on a
1576
+ // cache-integrity precondition; fall back to generic failure (1) for other
1577
+ // per-source errors / downstream gate failures.
1578
+ process.exitCode = cacheIntegrityFailure ? 4 : (hadFailure ? 1 : 0);
1554
1579
  }
1555
1580
 
1556
1581
  async function sequential(items, fn) {
@@ -45,14 +45,16 @@ const PKG_NAME = "@blamejs/exceptd-skills";
45
45
  const REQUEST_TIMEOUT_MS = 15000;
46
46
 
47
47
  function parseArgs(argv) {
48
- const out = { force: false, dryRun: false, timeoutMs: REQUEST_TIMEOUT_MS, json: false };
48
+ const out = { force: false, dryRun: false, timeoutMs: REQUEST_TIMEOUT_MS, json: false, airGap: false };
49
49
  for (let i = 2; i < argv.length; i++) {
50
50
  const a = argv[i];
51
51
  if (a === "--force") out.force = true;
52
52
  else if (a === "--dry-run") out.dryRun = true;
53
53
  else if (a === "--json") out.json = true;
54
+ else if (a === "--air-gap") out.airGap = true;
54
55
  else if (a === "--timeout") out.timeoutMs = parseInt(argv[++i], 10) || REQUEST_TIMEOUT_MS;
55
56
  }
57
+ if (process.env.EXCEPTD_AIR_GAP === "1") out.airGap = true;
56
58
  return out;
57
59
  }
58
60
 
@@ -275,6 +277,19 @@ async function main() {
275
277
  const localPkg = JSON.parse(fs.readFileSync(path.join(ROOT, "package.json"), "utf8"));
276
278
  const localVersion = localPkg.version;
277
279
 
280
+ // Air-gap refusal. --network needs egress to registry.npmjs.org for the
281
+ // /latest metadata + tarball pull. Under air-gap there is no offline
282
+ // substitute (the test fixture path remains available for offline tests),
283
+ // so refuse before any network attempt and point at the offline workflow.
284
+ if (opts.airGap && !process.env.EXCEPTD_REGISTRY_FIXTURE) {
285
+ emit({
286
+ ok: false,
287
+ source: "air-gap",
288
+ error: "air-gap: refresh --network requires network egress; refused. Use --from-cache --apply for the offline path.",
289
+ }, opts.json);
290
+ process.exitCode = 4; return;
291
+ }
292
+
278
293
  progress(`local v${localVersion} — querying npm registry...`, opts.json);
279
294
 
280
295
  let meta;