@blamejs/exceptd-skills 0.14.10 → 0.14.12

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.
@@ -30,7 +30,7 @@
30
30
  const fs = require("node:fs");
31
31
  const path = require("node:path");
32
32
 
33
- const { codeExcludeSet, isLinkedWorktreeDir, buildEvidenceLocations } = require("./scan-excludes");
33
+ const { codeExcludeSet, isLinkedWorktreeDir, buildEvidenceLocations, lineFromOffset } = require("./scan-excludes");
34
34
 
35
35
  const COLLECTOR_ID = "citation-hygiene";
36
36
 
@@ -334,12 +334,15 @@ function collect({ cwd = process.cwd() } = {}) {
334
334
  for (const m of content.matchAll(CVE_CITATION_RE)) {
335
335
  const full = m[0];
336
336
  totalCveCitations++;
337
+ // 1-based line of the citation so the evidence location carries a SARIF
338
+ // startLine region. Does not change any hit/miss verdict.
339
+ const cveLine = lineFromOffset(content, m.index);
337
340
  const canonical = CVE_CANONICAL_RE.test(full);
338
341
  if (!canonical) {
339
342
  // Fabricated / malformed. Illustrative surfaces (templates,
340
343
  // fixtures, the format-explaining docs) are demoted.
341
344
  if (!illustrative) {
342
- hits["fabricated-cve-id"].push({ file: f.rel, citation: full });
345
+ hits["fabricated-cve-id"].push({ file: f.rel, citation: full, line: cveLine });
343
346
  }
344
347
  continue;
345
348
  }
@@ -347,7 +350,7 @@ function collect({ cwd = process.cwd() } = {}) {
347
350
  if (cveKeys.has(full)) {
348
351
  const note = cveNotes.get(full) || "";
349
352
  if (REJECT_DISPUTE_RE.test(note) && !illustrative) {
350
- hits["rejected-or-disputed-cve"].push({ file: f.rel, citation: full });
353
+ hits["rejected-or-disputed-cve"].push({ file: f.rel, citation: full, line: cveLine });
351
354
  }
352
355
  } else if (catalogsLoaded && !illustrative) {
353
356
  // Absent from the curated catalog: needs an external lookup.
@@ -362,6 +365,7 @@ function collect({ cwd = process.cwd() } = {}) {
362
365
  const num = Number(m[1]);
363
366
  if (!Number.isFinite(num)) continue;
364
367
  const line = lineAround(content, m.index);
368
+ const rfcLineNo = lineFromOffset(content, m.index);
365
369
  if (rfcTitles.has(num)) {
366
370
  const verdict = classifyRfcTitle(line, rfcTitles.get(num));
367
371
  if (verdict === "mismatch" && !illustrative) {
@@ -369,6 +373,7 @@ function collect({ cwd = process.cwd() } = {}) {
369
373
  file: f.rel,
370
374
  citation: `RFC ${num}`,
371
375
  real_title: rfcTitles.get(num),
376
+ line: rfcLineNo,
372
377
  });
373
378
  }
374
379
  } else if (catalogsLoaded && !illustrative) {
@@ -449,8 +454,8 @@ function collect({ cwd = process.cwd() } = {}) {
449
454
 
450
455
  // Per-indicator file locations for the indicators flipped to "hit",
451
456
  // so SARIF results point at the source file that carries the bad
452
- // citation. The hits record the file but not a line, so locations are
453
- // file-level (no startLine).
457
+ // citation. The hits record a 1-based `line` (from the match offset),
458
+ // so locations include a startLine region.
454
459
  const evidence_locations = {};
455
460
  for (const id of Object.keys(hits)) {
456
461
  if (signal_overrides[id] === "hit") {
@@ -16,7 +16,7 @@
16
16
 
17
17
  const fs = require("node:fs");
18
18
  const path = require("node:path");
19
- const { codeExcludeSet, isLinkedWorktreeDir, buildEvidenceLocations } = require("./scan-excludes");
19
+ const { codeExcludeSet, isLinkedWorktreeDir, buildEvidenceLocations, lineFromOffset } = require("./scan-excludes");
20
20
 
21
21
  const COLLECTOR_ID = "crypto-codebase";
22
22
 
@@ -298,14 +298,17 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
298
298
  if (RSA_1024_RE.test(content)) {
299
299
  hits["rsa-1024-anywhere"].push({ file: f.rel });
300
300
  }
301
+ // Attach a 1-based `line` (from the match offset) so the evidence
302
+ // location carries a SARIF startLine region rather than pointing at
303
+ // the file. Does not change hit/miss — the same matches still fire.
301
304
  const mrHits = scanMathRandom(content);
302
- for (const h of mrHits) hits["math-random-in-security-path"].push({ file: f.rel, offset: h.offset });
305
+ for (const h of mrHits) hits["math-random-in-security-path"].push({ file: f.rel, offset: h.offset, line: lineFromOffset(content, h.offset) });
303
306
 
304
307
  const pHits = scanPbkdf2(content);
305
- for (const h of pHits) hits["pbkdf2-under-iterated"].push({ file: f.rel, iter: h.iter, threshold: h.threshold });
308
+ for (const h of pHits) hits["pbkdf2-under-iterated"].push({ file: f.rel, offset: h.offset, line: lineFromOffset(content, h.offset), iter: h.iter, threshold: h.threshold });
306
309
 
307
310
  const bHits = scanBcrypt(content);
308
- for (const h of bHits) hits["bcrypt-cost-low"].push({ file: f.rel, cost: h.cost });
311
+ for (const h of bHits) hits["bcrypt-cost-low"].push({ file: f.rel, offset: h.offset, line: lineFromOffset(content, h.offset), cost: h.cost });
309
312
 
310
313
  if (PEM_RE.test(content)) {
311
314
  hits["hardcoded-key-material"].push({ file: f.rel });
@@ -460,8 +463,10 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
460
463
  // no-ml-kem-implementation, fips-claim-without-runtime-activation,
461
464
  // vendored-pqc-no-provenance) describe a whole-repo state rather than a
462
465
  // single offending file, so they carry no file-level location. The
463
- // call-site scans record the file but not a line, so locations are
464
- // file-level (no startLine).
466
+ // offset-bearing call-site scans (math-random / pbkdf2 / bcrypt) now record
467
+ // a 1-based `line`, so their locations include a startLine region; the
468
+ // remaining whole-file scans (weak-hash / weak-cipher / rsa-1024 /
469
+ // hardcoded-key / tls) stay file-level (no startLine).
465
470
  const evidence_locations = {};
466
471
  for (const id of Object.keys(hits)) {
467
472
  if (signal_overrides[id] === "hit") {
@@ -356,8 +356,15 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
356
356
  let withoutIntegrity = 0;
357
357
  const walk = (obj) => {
358
358
  if (!obj || typeof obj !== "object") return;
359
- if (obj.integrity != null) withIntegrity++;
360
- else if (obj.resolved != null || obj.version != null) withoutIntegrity++;
359
+ // Only remote-tarball entries (those with a `resolved` URL) are
360
+ // expected to carry an `integrity` hash. The npm 7+ root entry
361
+ // `"": { name, version }` legitimately has no `resolved` and no
362
+ // `integrity`, so keying off `version` would false-positive on
363
+ // every clean lockfile. Mirror library-author.js's guard.
364
+ if (obj.resolved != null) {
365
+ if (obj.integrity != null) withIntegrity++;
366
+ else withoutIntegrity++;
367
+ }
361
368
  for (const v of Object.values(obj)) if (v && typeof v === "object") walk(v);
362
369
  };
363
370
  walk(j.packages || j.dependencies || {});
Binary file
@@ -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
 
@@ -41,7 +45,12 @@ const { resolveCve } = require("./citation-resolve.js");
41
45
  }
42
46
 
43
47
  const r = await resolveCve(id, { airGap: flags.has("--air-gap"), noNetwork: flags.has("--no-network") });
44
- const body = { ok: true, verb: "cve", ...r };
48
+ // A citation that won't stand up exits non-zero so a CI/script gate trips.
49
+ // Derive `ok` from the same set of statuses that drive the exit code — a
50
+ // non-zero exit must carry ok:false, never the inverted ok:true the
51
+ // envelope previously hardcoded.
52
+ const fails = r.status === "rejected" || r.status === "fabricated" || r.status === "nonexistent" || r.status === "withdrawn";
53
+ const body = { verb: "cve", ...r, ok: !fails };
45
54
 
46
55
  if (json) {
47
56
  process.stdout.write(JSON.stringify(body, null, pretty ? 2 : 0) + "\n");
@@ -57,8 +66,7 @@ const { resolveCve } = require("./citation-resolve.js");
57
66
  if (r.reason) line += `\n ${r.reason}`;
58
67
  process.stdout.write(line + "\n");
59
68
  }
60
- // A citation that won't stand up is a non-zero exit so a CI/script gate trips.
61
- if (r.status === "rejected" || r.status === "fabricated" || r.status === "nonexistent" || r.status === "withdrawn") {
69
+ if (fails) {
62
70
  process.exitCode = 2;
63
71
  }
64
72
  })();
@@ -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
  }
@@ -2345,7 +2345,7 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
2345
2345
  }] : [];
2346
2346
  const base = {
2347
2347
  scores,
2348
- threats: c.active_exploitation === 'confirmed' ? [{ category: 'exploit_status', details: 'Active exploitation confirmed (CISA KEV).' }] : [],
2348
+ threats: c.active_exploitation === 'confirmed' ? [{ category: 'exploit_status', details: `Active exploitation confirmed${c.cisa_kev ? ' (CISA KEV)' : ''}.` }] : [],
2349
2349
  remediations,
2350
2350
  product_status: isFixed ? { fixed: [productId] } : { known_affected: [productId] }
2351
2351
  };
@@ -2473,9 +2473,18 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
2473
2473
  ? { tlp: { label: CSAF_TLP_LABEL[runOpts.tlp] }, text: `TLP:${runOpts.tlp}` }
2474
2474
  : null;
2475
2475
 
2476
+ // CSAF 2.0: an advisory with zero vulnerabilities is a csaf_informational_advisory
2477
+ // (Profile 5, which does not require /vulnerabilities) rather than a
2478
+ // csaf_security_advisory (Profile 4, where an empty vulnerabilities array is
2479
+ // semantically wrong and warns under strict profile validators). A clean run
2480
+ // becomes an informational attestation; any firing CVE/indicator keeps the
2481
+ // security-advisory category.
2482
+ const csafCategory = (cveVulns.length + indicatorVulns.length) > 0
2483
+ ? 'csaf_security_advisory'
2484
+ : 'csaf_informational_advisory';
2476
2485
  return {
2477
2486
  document: {
2478
- category: 'csaf_security_advisory',
2487
+ category: csafCategory,
2479
2488
  csaf_version: '2.0',
2480
2489
  publisher: publisherBlock,
2481
2490
  title: `exceptd finding: ${playbook.domain.name} (${analyze.matched_cves.length} CVE(s), ${indicatorHits.length} indicator hit(s), ${(analyze.framework_gap_mapping || []).length} framework gap(s))`,
@@ -2562,20 +2571,29 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
2562
2571
  // every rule definition is unambiguously attributable to one playbook,
2563
2572
  // and cross-playbook merges retain all results.
2564
2573
  const rulePrefix = `${playbookSlug}/`;
2565
- const cveResults = analyze.matched_cves.map(c => ({
2566
- ruleId: `${rulePrefix}${c.cve_id}`,
2567
- level: c.rwep >= 90 ? 'error' : c.rwep >= 70 ? 'warning' : 'note',
2568
- message: { text: `${c.cve_id}: RWEP ${c.rwep}, blast_radius ${analyze.blast_radius_score}. ${validate.selected_remediation?.description || ''}` },
2569
- properties: stripNulls({
2570
- kind: 'cve_match',
2571
- rwep: c.rwep,
2572
- cisa_kev: c.cisa_kev,
2573
- cisa_kev_due_date: c.cisa_kev_due_date ?? null,
2574
- active_exploitation: c.active_exploitation ?? null,
2575
- ai_discovered: c.ai_discovered ?? null,
2576
- blast_radius_score: analyze.blast_radius_score,
2577
- }),
2578
- }));
2574
+ // CVE-match results get the coarse playbook-source location fallback
2575
+ // (passing a null indicator skips the per-indicator evidence-locations
2576
+ // branch). Without any `locations`, GitHub Code Scanning silently DROPS
2577
+ // these results the highest-severity result class would never surface.
2578
+ const cveFallbackLocs = sarifLocationsForIndicator(playbook, null);
2579
+ const cveResults = analyze.matched_cves.map(c => {
2580
+ const result = {
2581
+ ruleId: `${rulePrefix}${c.cve_id}`,
2582
+ level: c.rwep >= 90 ? 'error' : c.rwep >= 70 ? 'warning' : 'note',
2583
+ message: { text: `${c.cve_id}: RWEP ${c.rwep}, blast_radius ${analyze.blast_radius_score == null ? 'not assessed' : analyze.blast_radius_score}. ${validate.selected_remediation?.description || ''}` },
2584
+ properties: stripNulls({
2585
+ kind: 'cve_match',
2586
+ rwep: c.rwep,
2587
+ cisa_kev: c.cisa_kev,
2588
+ cisa_kev_due_date: c.cisa_kev_due_date ?? null,
2589
+ active_exploitation: c.active_exploitation ?? null,
2590
+ ai_discovered: c.ai_discovered ?? null,
2591
+ blast_radius_score: analyze.blast_radius_score,
2592
+ }),
2593
+ };
2594
+ if (cveFallbackLocs) result.locations = cveFallbackLocs;
2595
+ return result;
2596
+ });
2579
2597
  const indicatorHits = (analyze._detect_indicators || []).filter(i => i.verdict === 'hit');
2580
2598
  const indicatorResults = indicatorHits.map(i => {
2581
2599
  const locs = sarifLocationsForIndicator(playbook, i);
@@ -2696,7 +2714,7 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
2696
2714
  vulnerability: { '@id': vulnIdToUrn(c.cve_id), name: c.cve_id },
2697
2715
  products: [productEntry],
2698
2716
  timestamp: issued,
2699
- impact_statement: `RWEP ${c.rwep}. Blast radius ${analyze.blast_radius_score}/5.`,
2717
+ impact_statement: `RWEP ${c.rwep}. ${analyze.blast_radius_score == null ? 'Blast radius not assessed.' : `Blast radius ${analyze.blast_radius_score}/5.`}`,
2700
2718
  };
2701
2719
  if (c.vex_status === 'fixed') {
2702
2720
  stmt.status = 'fixed';
@@ -2767,11 +2785,14 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
2767
2785
  vexAuthor = vexOperatorClean;
2768
2786
  } else {
2769
2787
  vexAuthor = 'urn:exceptd:operator:unknown';
2788
+ // Same shape + singleton dedupe as the CSAF path so a multi-format emit
2789
+ // produces one canonical bundle_publisher_unclaimed entry that machine
2790
+ // consumers can read consistently (reason/remediation, not message).
2770
2791
  pushRunError(runOpts._runErrors, {
2771
2792
  kind: 'bundle_publisher_unclaimed',
2772
- format: 'openvex',
2773
- message: 'OpenVEX author falls back to urn:exceptd:operator:unknown supply runOpts.operator or runOpts.publisherNamespace to claim disposition attribution.',
2774
- });
2793
+ reason: 'OpenVEX author fell back to urn:exceptd:operator:unknown because no --publisher-namespace and no URL-shaped --operator were supplied. Disposition attribution is unclaimed on this VEX document.',
2794
+ remediation: 'Re-run with --publisher-namespace <https-url> (or a URL-shaped --operator).'
2795
+ }, { dedupeKey: () => 'singleton' });
2775
2796
  }
2776
2797
  return {
2777
2798
  '@context': 'https://openvex.dev/ns/v0.2.0',
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];
@@ -121,7 +121,17 @@ function parseArgs(argv) {
121
121
  else if (a.startsWith("--max-age=")) out.maxAgeMs = parseDuration(a.slice("--max-age=".length));
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
+ // Any remaining --flag is an unrecognized typo. Record it; main() refuses
125
+ // before any network work rather than silently dropping it.
126
+ else if (typeof a === "string" && a.startsWith("--")) {
127
+ const base = a.indexOf("=") === -1 ? a : a.slice(0, a.indexOf("="));
128
+ (out._unknownFlags || (out._unknownFlags = [])).push(base);
129
+ }
124
130
  }
131
+ // The global air-gap switch implies a report-only / no-egress run: treat
132
+ // EXCEPTD_AIR_GAP=1 the same as --no-network so prefetch never plans live
133
+ // fetches under air-gap.
134
+ if (process.env.EXCEPTD_AIR_GAP === "1") out.noNetwork = true;
125
135
  return out;
126
136
  }
127
137
 
@@ -646,12 +656,36 @@ function readCached(cacheDir, source, id, opts = {}) {
646
656
  }
647
657
  }
648
658
 
659
+ // Known --flag base names prefetch accepts. Drives the unknown-flag error
660
+ // message's known list.
661
+ const PREFETCH_KNOWN_FLAGS = Object.freeze([
662
+ "--force", "--no-network", "--dry-run", "--air-gap", "--quiet", "--help", "-h",
663
+ "--source", "--max-age", "--cache-dir",
664
+ ]);
665
+
649
666
  async function main() {
650
667
  const opts = parseArgs(process.argv);
651
668
  if (opts.help) {
652
669
  printHelp();
653
670
  return;
654
671
  }
672
+
673
+ // Reject unknown flags BEFORE any network work. A swallowed typo (e.g.
674
+ // `--max-aeg 12h`) previously fell through to a default full-cache fetch.
675
+ // Exit 2 matches prefetch's existing usage-error convention (invalid
676
+ // --source / --max-age also surface as exit 2 via main()'s catch).
677
+ if (Array.isArray(opts._unknownFlags) && opts._unknownFlags.length > 0) {
678
+ const uniq = [...new Set(opts._unknownFlags)];
679
+ process.stderr.write(JSON.stringify({
680
+ ok: false,
681
+ verb: "prefetch",
682
+ error: `prefetch: unknown flag(s): ${uniq.join(", ")}`,
683
+ unknown_flags: uniq,
684
+ known_flags: PREFETCH_KNOWN_FLAGS,
685
+ }) + "\n");
686
+ process.exitCode = 2;
687
+ return;
688
+ }
655
689
  // Why process.exitCode and not process.exit():
656
690
  // On Windows + Node 25 (libuv), calling process.exit() synchronously
657
691
  // while in-flight fetch / AbortController teardown is still mid-close
@@ -109,6 +109,22 @@ 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.)
117
+ else if (
118
+ a === "--no-network" || a === "--prefetch" || a === "--indexes-only" ||
119
+ a === "--network" || a === "--curate" || a === "--force-stale-acked"
120
+ ) { /* accepted, no-op at this layer */ }
121
+ // Any remaining --flag is an unrecognized typo. Record it; refuse after
122
+ // the loop rather than silently dropping it into a default full-refresh
123
+ // (which previously hit the live network on every source).
124
+ else if (typeof a === "string" && a.startsWith("--")) {
125
+ const base = a.indexOf("=") === -1 ? a : a.slice(0, a.indexOf("="));
126
+ (out._unknownFlags || (out._unknownFlags = [])).push(base);
127
+ }
112
128
  }
113
129
  if (process.env.EXCEPTD_FORCE_STALE === "1") out.forceStale = true;
114
130
  // Report-only is intrinsic to the advisory poll regardless of flag order —
@@ -202,10 +218,11 @@ Outputs:
202
218
 
203
219
  Exit codes (refresh's own scheme — distinct from the seven-phase verbs):
204
220
  0 applied (or a clean dry-run with no diffs to surface)
221
+ 1 apply-mode downstream gate failed (build-indexes, or a per-source error)
205
222
  2 error (unknown --source, unreadable fixture, invalid --advisory id, air-gap refusal)
206
223
  3 draft produced, editorial review pending (a successful --advisory seed —
207
224
  NOT a failure; run --advisory <id> --apply to land it, or curate first)
208
- 4 network/source unreachable
225
+ 4 network/source unreachable OR cache precondition refused (unsigned/stale/tampered/unindexed cache)
209
226
  Note: exit 3 here means "review needed", which differs from \`exceptd run\`'s
210
227
  exit 3 ("ran but no evidence"). Script \`refresh --advisory\` on the body's
211
228
  \`ok\` field, not on \`$? == 0\`.
@@ -1252,8 +1269,18 @@ async function withCatalogLock(catalogPath, mutator) {
1252
1269
  }
1253
1270
 
1254
1271
  function chosenSources(opts) {
1255
- if (!opts.source) return Object.values(ALL_SOURCES);
1272
+ // Flag-absent (opts.source == null) means "all sources" — the default
1273
+ // refresh behavior. Flag-present-but-empty (`--source ""`, or a value that
1274
+ // trims to nothing like `--source ","`) is an operator error, not a
1275
+ // silent run-everything: refuse and list the valid names so the typo is
1276
+ // visible rather than masquerading as a full refresh.
1277
+ if (opts.source == null) return Object.values(ALL_SOURCES);
1256
1278
  const names = opts.source.split(",").map((s) => s.trim()).filter(Boolean);
1279
+ if (names.length === 0) {
1280
+ const err = new Error(`refresh-external: --source requires at least one source name. Valid: ${Object.keys(ALL_SOURCES).join(", ")}`);
1281
+ err._exceptd_unknown_source = true;
1282
+ throw err;
1283
+ }
1257
1284
  const out = [];
1258
1285
  for (const n of names) {
1259
1286
  if (!ALL_SOURCES[n]) {
@@ -1412,6 +1439,15 @@ async function seedSingleAdvisory(opts) {
1412
1439
  process.exitCode = 3;
1413
1440
  }
1414
1441
 
1442
+ // Known --flag base names refresh accepts (operator-facing surface + the
1443
+ // bin-translated aliases). Drives the unknown-flag error message's known list.
1444
+ const REFRESH_KNOWN_FLAGS = Object.freeze([
1445
+ "--apply", "--quiet", "--swarm", "--json", "--help", "-h", "--advisory",
1446
+ "--check-advisories", "--catalog", "--from-cache", "--source", "--from-fixture",
1447
+ "--report-out", "--air-gap", "--force-stale", "--force-stale-acked",
1448
+ "--no-network", "--prefetch", "--indexes-only", "--network", "--curate",
1449
+ ]);
1450
+
1415
1451
  async function main() {
1416
1452
  const opts = parseArgs(process.argv);
1417
1453
  if (opts.help) {
@@ -1421,6 +1457,22 @@ async function main() {
1421
1457
  return;
1422
1458
  }
1423
1459
 
1460
+ // Reject unknown flags BEFORE any network / catalog work. A swallowed typo
1461
+ // (e.g. `--aply`) previously fell through to a default all-sources live
1462
+ // refresh. Exit 2 matches refresh's own scheme (2 = error / unknown source).
1463
+ if (Array.isArray(opts._unknownFlags) && opts._unknownFlags.length > 0) {
1464
+ const uniq = [...new Set(opts._unknownFlags)];
1465
+ process.stderr.write(JSON.stringify({
1466
+ ok: false,
1467
+ verb: "refresh",
1468
+ error: `refresh: unknown flag(s): ${uniq.join(", ")}`,
1469
+ unknown_flags: uniq,
1470
+ known_flags: REFRESH_KNOWN_FLAGS,
1471
+ }) + "\n");
1472
+ process.exitCode = 2;
1473
+ return;
1474
+ }
1475
+
1424
1476
  // v0.12.0: `--advisory <id>` short-circuits the normal source loop and
1425
1477
  // seeds a single CVE catalog entry from GHSA. Exits non-zero ("draft
1426
1478
  // written, please review") so CI pipelines surface the needed editorial
@@ -1428,7 +1480,7 @@ async function main() {
1428
1480
  // the seed is printed to stdout for review.
1429
1481
  // An empty --advisory value (`--advisory ""` / `--advisory=`) must error
1430
1482
  // rather than silently falling through to a full-refresh dry-run.
1431
- if (opts.advisory === "") {
1483
+ if (opts.advisory != null && opts.advisory.trim() === "") {
1432
1484
  process.stderr.write(JSON.stringify({
1433
1485
  ok: false,
1434
1486
  error: "refresh: --advisory requires a non-empty identifier (e.g. CVE-2026-1234, GHSA-xxxx-xxxx-xxxx, MAL-2026-1).",
@@ -1496,11 +1548,22 @@ async function main() {
1496
1548
  ? await Promise.all(sources.map(runOne))
1497
1549
  : await sequential(sources, runOne);
1498
1550
 
1551
+ // Cache-integrity refusals (sha256 mismatch, missing/partial _index.json,
1552
+ // unindexed payload) are thrown by readCachedJson with _exceptd_exit_code=4
1553
+ // but caught inside runOne and returned as a per-source error — so the
1554
+ // throw never reaches main().catch where the code is otherwise honored.
1555
+ // Carry the marker through here so main() can prefer exit 4 (BLOCKED /
1556
+ // precondition refusal) over the generic per-source-failure exit 1.
1557
+ let cacheIntegrityFailure = false;
1499
1558
  for (const { src, diff, error } of outcomes) {
1500
1559
  if (error) {
1501
1560
  log(`\n [${src.name}] ${src.description}`);
1502
1561
  log(` error: ${error.message}`);
1503
1562
  report.sources[src.name] = { status: "error", error: error.message };
1563
+ if (error._exceptd_cache_integrity || error._exceptd_exit_code === 4) {
1564
+ report.sources[src.name].cache_integrity = true;
1565
+ cacheIntegrityFailure = true;
1566
+ }
1504
1567
  hadFailure = true;
1505
1568
  continue;
1506
1569
  }
@@ -1550,7 +1613,10 @@ async function main() {
1550
1613
  // truncate buffered stdout (refresh-report path log line, summary log
1551
1614
  // lines piped to a consumer). exitCode + return lets the event loop end
1552
1615
  // naturally and stdout drains in full.
1553
- process.exitCode = hadFailure ? 1 : 0;
1616
+ // Prefer the documented BLOCKED (4) code when any source refused on a
1617
+ // cache-integrity precondition; fall back to generic failure (1) for other
1618
+ // per-source errors / downstream gate failures.
1619
+ process.exitCode = cacheIntegrityFailure ? 4 : (hadFailure ? 1 : 0);
1554
1620
  }
1555
1621
 
1556
1622
  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;
package/lib/rfc-cli.js CHANGED
@@ -57,7 +57,12 @@ const { resolveRfc } = require("./citation-resolve.js");
57
57
  const a = norm(claimedTitle), b = norm(r.title);
58
58
  titleMatch = a.length > 0 && (b.includes(a) || a.includes(b));
59
59
  }
60
- const body = { ok: true, verb: "rfc", ...r, ...(claimedTitle ? { claimed_title: claimedTitle, title_match: titleMatch } : {}) };
60
+ // Derive `ok` from the resolved status + title-check the same way the exit
61
+ // code is derived below — a non-zero exit (status nonexistent OR an explicit
62
+ // title mismatch) must carry ok:false, not the inverted ok:true the envelope
63
+ // previously hardcoded.
64
+ const fails = r.status === "nonexistent" || titleMatch === false;
65
+ const body = { verb: "rfc", ...r, ...(claimedTitle ? { claimed_title: claimedTitle, title_match: titleMatch } : {}), ok: !fails };
61
66
 
62
67
  if (json) {
63
68
  process.stdout.write(JSON.stringify(body, null, pretty ? 2 : 0) + "\n");
@@ -77,5 +82,5 @@ const { resolveRfc } = require("./citation-resolve.js");
77
82
  process.stdout.write(line + "\n");
78
83
  }
79
84
  // A mismatched or nonexistent citation is a non-zero exit for gates.
80
- if (r.status === "nonexistent" || titleMatch === false) process.exitCode = 2;
85
+ if (fails) process.exitCode = 2;
81
86
  })();