@blamejs/exceptd-skills 0.12.10 → 0.12.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/prefetch.js CHANGED
@@ -188,9 +188,93 @@ function loadIndex(cacheDir) {
188
188
  }
189
189
  }
190
190
 
191
- function saveIndex(cacheDir, idx) {
191
+ // v0.12.12 C4: atomic write helper — tmp + rename. Concurrent readers either
192
+ // see the prior file in full or the new file in full, never a half-written
193
+ // buffer. fs.renameSync is atomic on POSIX and on Windows for same-volume
194
+ // renames; a `.tmp.<pid>.<rand>` sibling to the destination is always
195
+ // same-volume.
196
+ function writeFileAtomic(p, body) {
197
+ const tmpPath = `${p}.tmp.${process.pid}.${Math.random().toString(36).slice(2, 10)}`;
198
+ fs.writeFileSync(tmpPath, body);
199
+ try {
200
+ fs.renameSync(tmpPath, p);
201
+ } catch (err) {
202
+ try { fs.unlinkSync(tmpPath); } catch {}
203
+ throw err;
204
+ }
205
+ }
206
+
207
+ // v0.12.12 C2: lockfile-gated read-modify-write for _index.json. Two
208
+ // concurrent prefetch runs against the same cache dir previously raced —
209
+ // each loaded the index at start, mutated its in-memory copy as entries
210
+ // fetched, then wrote at the end. The second writer overwrote the first,
211
+ // silently dropping any entries the first run wrote.
212
+ //
213
+ // Stale-lock recovery: if a holder crashes without unlinking, the lockfile
214
+ // persists. After backoff, if the lockfile's mtime is older than 30s we
215
+ // treat it as orphaned and unlink it before retrying.
216
+ async function withIndexLock(cacheDir, mutator) {
192
217
  if (!fs.existsSync(cacheDir)) fs.mkdirSync(cacheDir, { recursive: true });
193
- fs.writeFileSync(path.join(cacheDir, "_index.json"), JSON.stringify(idx, null, 2) + "\n", "utf8");
218
+ const lockPath = path.join(cacheDir, "_index.json.lock");
219
+ const indexPath = path.join(cacheDir, "_index.json");
220
+ const MAX_RETRIES = 50;
221
+ const STALE_LOCK_MS = 30_000;
222
+ let acquired = false;
223
+ for (let i = 0; i < MAX_RETRIES; i++) {
224
+ try {
225
+ fs.writeFileSync(lockPath, String(process.pid), { flag: "wx" });
226
+ acquired = true;
227
+ break;
228
+ } catch (e) {
229
+ // EEXIST is the POSIX signal another process holds the lock. On
230
+ // Windows the same race surfaces as EPERM (a sharing-violation
231
+ // raised when the other process is mid-unlink). Treat both as
232
+ // "lock held, back off" rather than a fatal error.
233
+ if (e.code !== "EEXIST" && e.code !== "EPERM") throw e;
234
+ try {
235
+ const stat = fs.statSync(lockPath);
236
+ if (Date.now() - stat.mtimeMs > STALE_LOCK_MS) {
237
+ try { fs.unlinkSync(lockPath); } catch {}
238
+ continue;
239
+ }
240
+ } catch {}
241
+ await new Promise((r) => setTimeout(r, 50 + Math.random() * 150));
242
+ }
243
+ }
244
+ if (!acquired) {
245
+ throw new Error(`withIndexLock: could not acquire ${lockPath} after ${MAX_RETRIES} attempts`);
246
+ }
247
+ try {
248
+ // Always re-read the current on-disk index inside the lock. Stale
249
+ // in-memory copies from before acquisition are the entire bug class.
250
+ let current;
251
+ if (fs.existsSync(indexPath)) {
252
+ try { current = JSON.parse(fs.readFileSync(indexPath, "utf8")); }
253
+ catch { current = { entries: {}, generated_at: null }; }
254
+ } else {
255
+ current = { entries: {}, generated_at: null };
256
+ }
257
+ const mutated = await mutator(current);
258
+ const toWrite = mutated === undefined ? current : mutated;
259
+ writeFileAtomic(indexPath, JSON.stringify(toWrite, null, 2) + "\n");
260
+ return toWrite;
261
+ } finally {
262
+ try { fs.unlinkSync(lockPath); } catch {}
263
+ }
264
+ }
265
+
266
+ // Back-compat: existing callers used saveIndex(cacheDir, idx). The thin
267
+ // wrapper merges entries under the lock so a concurrent run's writes are
268
+ // preserved (rather than blindly overwriting them with the caller's
269
+ // possibly-stale in-memory `idx`).
270
+ async function saveIndex(cacheDir, idx) {
271
+ await withIndexLock(cacheDir, (current) => {
272
+ const mergedEntries = { ...current.entries, ...idx.entries };
273
+ return {
274
+ entries: mergedEntries,
275
+ generated_at: idx.generated_at || current.generated_at,
276
+ };
277
+ });
194
278
  }
195
279
 
196
280
  function entryKey(source, id) {
@@ -292,17 +376,32 @@ async function prefetch(options = {}) {
292
376
  run: () => timedFetch(item.url, reqHeaders),
293
377
  meta: { id: item.id },
294
378
  })
295
- .then((res) => {
296
- const dir = path.dirname(entryPath(opts.cacheDir, item.source, item.id));
379
+ .then(async (res) => {
380
+ const targetPath = entryPath(opts.cacheDir, item.source, item.id);
381
+ const dir = path.dirname(targetPath);
297
382
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
298
- fs.writeFileSync(entryPath(opts.cacheDir, item.source, item.id), JSON.stringify(res.json, null, 2) + "\n", "utf8");
299
- idx.entries[entryKey(item.source, item.id)] = {
383
+ // v0.12.12 C4: atomic write of the payload. A concurrent reader
384
+ // (refresh --from-cache running in parallel) sees the prior
385
+ // payload in full or the new payload in full, never a partial
386
+ // buffer.
387
+ writeFileAtomic(targetPath, JSON.stringify(res.json, null, 2) + "\n");
388
+ const meta = {
300
389
  fetched_at: new Date().toISOString(),
301
390
  etag: res.etag,
302
391
  last_modified: res.lastModified,
303
392
  url: item.url,
304
393
  sha256: crypto.createHash("sha256").update(JSON.stringify(res.json)).digest("hex"),
305
394
  };
395
+ idx.entries[entryKey(item.source, item.id)] = meta;
396
+ // v0.12.12 C2: persist this entry's metadata to _index.json under
397
+ // lock immediately, merging with whatever the on-disk index has
398
+ // (another concurrent prefetch may have written sibling entries).
399
+ // Without this, only the in-memory idx is updated; the final
400
+ // saveIndex() would overwrite a sibling run's writes.
401
+ await withIndexLock(opts.cacheDir, (current) => {
402
+ current.entries[entryKey(item.source, item.id)] = meta;
403
+ return current;
404
+ });
306
405
  result.fetched++;
307
406
  result.by_source[item.source].fetched++;
308
407
  log(` [${item.source}] ${item.id} — ok`);
@@ -317,7 +416,10 @@ async function prefetch(options = {}) {
317
416
  await Promise.all(jobPromises);
318
417
  await queue.drain();
319
418
  idx.generated_at = new Date().toISOString();
320
- saveIndex(opts.cacheDir, idx);
419
+ // v0.12.12 C2: saveIndex now merges under lock with whatever is on disk
420
+ // (another concurrent prefetch's entries). Without the merge, a sibling
421
+ // run's writes would be silently overwritten here at the end of our run.
422
+ await saveIndex(opts.cacheDir, idx);
321
423
 
322
424
  // Final summary is unconditional — --quiet suppresses per-entry chatter
323
425
  // (the noisy part) but the operator still needs one line confirming success.
@@ -398,4 +500,15 @@ async function main() {
398
500
 
399
501
  if (require.main === module) main();
400
502
 
401
- module.exports = { prefetch, readCached, parseArgs, SOURCES, DEFAULT_CACHE };
503
+ module.exports = {
504
+ prefetch,
505
+ readCached,
506
+ parseArgs,
507
+ SOURCES,
508
+ DEFAULT_CACHE,
509
+ // v0.12.12 C2: exported for the concurrent-writer regression test.
510
+ // Not part of the operator-facing API — internal contract for tests
511
+ // that need to exercise the lockfile path without spawning the full
512
+ // prefetch network pipeline.
513
+ _internal: { withIndexLock, writeFileAtomic, loadIndex, saveIndex },
514
+ };
@@ -125,10 +125,14 @@ Modes:
125
125
  --swarm fan out sources across worker threads. Best with --from-cache.
126
126
  --advisory <id> (v0.12.0) seed a single catalog entry from an advisory ID.
127
127
  CVE-* and GHSA-* route through the GitHub Advisory
128
- Database. MAL-*, SNYK-*, RUSTSEC-*, USN-*, UVI-*, GO-*,
129
- MGASA-*, PYSEC-*, and other OSV-native namespaces route
130
- through OSV.dev (v0.12.10). Writes a DRAFT to
131
- data/cve-catalog.json marked with _auto_imported: true.
128
+ Database. When GHSA returns 404 for a CVE-* id
129
+ (CNAs / OSV mirrors operate on different cadences) the
130
+ dispatcher falls back to OSV.dev's /v1/vulns/{id}
131
+ before failing (v0.12.11). MAL-*, SNYK-*, RUSTSEC-*,
132
+ USN-*, UVI-*, GO-*, MGASA-*, PYSEC-*, and other
133
+ OSV-native namespaces route through OSV.dev (v0.12.10).
134
+ Writes a DRAFT to data/cve-catalog.json marked with
135
+ _auto_imported: true.
132
136
  Editorial fields (framework_control_gaps, iocs,
133
137
  atlas_refs, attack_refs) remain null pending review via:
134
138
  exceptd run cve-curation --advisory <id>
@@ -137,6 +141,17 @@ Modes:
137
141
  exceptd refresh --advisory GHSA-xxxx-xxxx-xxxx --apply
138
142
  exceptd refresh --advisory MAL-2026-3083
139
143
  exceptd refresh --advisory RUSTSEC-2025-0001
144
+ --curate <CVE-ID> emit editorial questions + ranked candidates
145
+ (ATLAS/ATT&CK/CWE/framework gaps) for a draft entry.
146
+ With --answers <path> the operator-supplied answers
147
+ are validated, applied to the catalog entry, and the
148
+ draft is promoted out of _auto_imported / _draft once
149
+ every required schema field is populated. Atomic write;
150
+ concurrent --apply runs against the same catalog are
151
+ safe. --apply is an alias for "--answers implies write".
152
+ Examples:
153
+ exceptd refresh --curate CVE-2026-45321
154
+ exceptd refresh --curate CVE-2026-45321 --answers a.json --apply
140
155
 
141
156
  Sources (default = all):
142
157
  kev CISA Known Exploited Vulnerabilities
@@ -210,26 +225,32 @@ const KEV_SOURCE = {
210
225
  let updated = 0;
211
226
  let added = 0;
212
227
  const errors = [];
213
- for (const d of diffs) {
214
- if (d.op === "add") {
215
- // Auto-discovered new entry. Refuse to overwrite if the entry
216
- // somehow exists (race condition / stale fixture); skip silently.
217
- if (ctx.cveCatalog[d.id]) continue;
218
- ctx.cveCatalog[d.id] = d.entry;
219
- added++;
220
- continue;
221
- }
222
- if (!ctx.cveCatalog[d.id]) {
223
- errors.push(`KEV: no local entry for ${d.id}`);
224
- continue;
228
+ const catalogPath = ctx.cvePath || ABS("data/cve-catalog.json");
229
+ await withCatalogLock(catalogPath, (catalog) => {
230
+ for (const d of diffs) {
231
+ if (d.op === "add") {
232
+ // Auto-discovered new entry. Refuse to overwrite if the entry
233
+ // somehow exists (race condition / stale fixture); skip silently.
234
+ if (catalog[d.id]) continue;
235
+ catalog[d.id] = d.entry;
236
+ added++;
237
+ continue;
238
+ }
239
+ if (!catalog[d.id]) {
240
+ errors.push(`KEV: no local entry for ${d.id}`);
241
+ continue;
242
+ }
243
+ catalog[d.id][d.field] = d.after;
244
+ catalog[d.id].last_verified = TODAY;
245
+ updated++;
225
246
  }
226
- ctx.cveCatalog[d.id][d.field] = d.after;
227
- ctx.cveCatalog[d.id].last_verified = TODAY;
228
- updated++;
229
- }
230
- ctx.cveCatalog._meta = ctx.cveCatalog._meta || {};
231
- ctx.cveCatalog._meta.last_updated = TODAY;
232
- writeJson(ctx.cvePath || ABS("data/cve-catalog.json"), ctx.cveCatalog);
247
+ catalog._meta = catalog._meta || {};
248
+ catalog._meta.last_updated = TODAY;
249
+ // Refresh the in-memory view so later sources in the same process
250
+ // (sequential or --swarm) see the post-write state.
251
+ ctx.cveCatalog = catalog;
252
+ return catalog;
253
+ });
233
254
  return { updated: updated + added, added, drift_updated: updated, errors };
234
255
  },
235
256
  };
@@ -293,18 +314,22 @@ const EPSS_SOURCE = {
293
314
  async applyDiff(ctx, diffs) {
294
315
  let updated = 0;
295
316
  const errors = [];
296
- for (const d of diffs) {
297
- if (!ctx.cveCatalog[d.id]) {
298
- errors.push(`EPSS: no local entry for ${d.id}`);
299
- continue;
317
+ const catalogPath = ctx.cvePath || ABS("data/cve-catalog.json");
318
+ await withCatalogLock(catalogPath, (catalog) => {
319
+ for (const d of diffs) {
320
+ if (!catalog[d.id]) {
321
+ errors.push(`EPSS: no local entry for ${d.id}`);
322
+ continue;
323
+ }
324
+ catalog[d.id][d.field] = d.after;
325
+ catalog[d.id].last_verified = TODAY;
326
+ updated++;
300
327
  }
301
- ctx.cveCatalog[d.id][d.field] = d.after;
302
- ctx.cveCatalog[d.id].last_verified = TODAY;
303
- updated++;
304
- }
305
- ctx.cveCatalog._meta = ctx.cveCatalog._meta || {};
306
- ctx.cveCatalog._meta.last_updated = TODAY;
307
- writeJson(ctx.cvePath || ABS("data/cve-catalog.json"), ctx.cveCatalog);
328
+ catalog._meta = catalog._meta || {};
329
+ catalog._meta.last_updated = TODAY;
330
+ ctx.cveCatalog = catalog;
331
+ return catalog;
332
+ });
308
333
  return { updated, errors };
309
334
  },
310
335
  };
@@ -338,18 +363,22 @@ const NVD_SOURCE = {
338
363
  async applyDiff(ctx, diffs) {
339
364
  let updated = 0;
340
365
  const errors = [];
341
- for (const d of diffs) {
342
- if (!ctx.cveCatalog[d.id]) {
343
- errors.push(`NVD: no local entry for ${d.id}`);
344
- continue;
366
+ const catalogPath = ctx.cvePath || ABS("data/cve-catalog.json");
367
+ await withCatalogLock(catalogPath, (catalog) => {
368
+ for (const d of diffs) {
369
+ if (!catalog[d.id]) {
370
+ errors.push(`NVD: no local entry for ${d.id}`);
371
+ continue;
372
+ }
373
+ catalog[d.id][d.field] = d.after;
374
+ catalog[d.id].last_verified = TODAY;
375
+ updated++;
345
376
  }
346
- ctx.cveCatalog[d.id][d.field] = d.after;
347
- ctx.cveCatalog[d.id].last_verified = TODAY;
348
- updated++;
349
- }
350
- ctx.cveCatalog._meta = ctx.cveCatalog._meta || {};
351
- ctx.cveCatalog._meta.last_updated = TODAY;
352
- writeJson(ctx.cvePath || ABS("data/cve-catalog.json"), ctx.cveCatalog);
377
+ catalog._meta = catalog._meta || {};
378
+ catalog._meta.last_updated = TODAY;
379
+ ctx.cveCatalog = catalog;
380
+ return catalog;
381
+ });
353
382
  return { updated, errors };
354
383
  },
355
384
  };
@@ -394,26 +423,30 @@ const RFC_SOURCE = {
394
423
  let updated = 0;
395
424
  let added = 0;
396
425
  const errors = [];
397
- for (const d of diffs) {
398
- if (d.op === "add") {
399
- if (ctx.rfcCatalog[d.id]) continue;
400
- ctx.rfcCatalog[d.id] = d.entry;
401
- added++;
402
- continue;
403
- }
404
- if (d.field !== "status") continue; // notes are informational
405
- const entry = ctx.rfcCatalog[d.id];
406
- if (!entry) {
407
- errors.push(`RFC: no local entry for ${d.id}`);
408
- continue;
426
+ const rfcPath = ABS("data/rfc-references.json");
427
+ await withCatalogLock(rfcPath, (rfcCatalog) => {
428
+ for (const d of diffs) {
429
+ if (d.op === "add") {
430
+ if (rfcCatalog[d.id]) continue;
431
+ rfcCatalog[d.id] = d.entry;
432
+ added++;
433
+ continue;
434
+ }
435
+ if (d.field !== "status") continue; // notes are informational
436
+ const entry = rfcCatalog[d.id];
437
+ if (!entry) {
438
+ errors.push(`RFC: no local entry for ${d.id}`);
439
+ continue;
440
+ }
441
+ entry.status = d.after;
442
+ entry.last_verified = TODAY;
443
+ updated++;
409
444
  }
410
- entry.status = d.after;
411
- entry.last_verified = TODAY;
412
- updated++;
413
- }
414
- ctx.rfcCatalog._meta = ctx.rfcCatalog._meta || {};
415
- ctx.rfcCatalog._meta.last_updated = TODAY;
416
- writeJson(ABS("data/rfc-references.json"), ctx.rfcCatalog);
445
+ rfcCatalog._meta = rfcCatalog._meta || {};
446
+ rfcCatalog._meta.last_updated = TODAY;
447
+ ctx.rfcCatalog = rfcCatalog;
448
+ return rfcCatalog;
449
+ });
417
450
  return { updated: updated + added, added, drift_updated: updated, errors };
418
451
  },
419
452
  };
@@ -820,8 +853,97 @@ function loadCtx(opts) {
820
853
  return ctx;
821
854
  }
822
855
 
856
+ // v0.12.12 C4: every persisted JSON write goes through writeJsonAtomic — a
857
+ // tmp + rename pattern. fs.renameSync is atomic on POSIX and on Windows for
858
+ // same-volume renames (which a `.tmp.<pid>.<rand>` adjacent to the target
859
+ // always satisfies). A concurrent reader either sees the prior file content
860
+ // in full or the new content in full — never a half-written buffer. The
861
+ // tmp name carries pid + random so two writers in the same process (e.g.
862
+ // worker threads) never collide on the same scratch path.
863
+ function writeJsonAtomic(p, obj) {
864
+ const tmpPath = `${p}.tmp.${process.pid}.${Math.random().toString(36).slice(2, 10)}`;
865
+ fs.writeFileSync(tmpPath, JSON.stringify(obj, null, 2) + "\n", "utf8");
866
+ try {
867
+ fs.renameSync(tmpPath, p);
868
+ } catch (err) {
869
+ try { fs.unlinkSync(tmpPath); } catch {}
870
+ throw err;
871
+ }
872
+ }
873
+
874
+ // Back-compat alias — exported callers and historical sites still reference
875
+ // writeJson. Atomic by default; never the unsafe direct-write form.
823
876
  function writeJson(p, obj) {
824
- fs.writeFileSync(p, JSON.stringify(obj, null, 2) + "\n", "utf8");
877
+ writeJsonAtomic(p, obj);
878
+ }
879
+
880
+ /**
881
+ * v0.12.12 C1: lockfile-gated read-modify-write helper for JSON catalogs.
882
+ *
883
+ * Two concurrent `refresh --advisory CVE-A --apply` and
884
+ * `refresh --advisory CVE-B --apply` processes against the same catalog used
885
+ * to race: each read the catalog, mutated its in-memory copy, then wrote —
886
+ * the second write overwrote the first, silently dropping one CVE. The fix
887
+ * is a sidecar lockfile (created with O_EXCL via `flag: 'wx'`) that
888
+ * serializes the read-mutate-write triple. The mutator receives the
889
+ * current-on-disk catalog (re-read inside the lock, NOT a stale in-memory
890
+ * copy from before lock acquisition) and returns it after mutation; the
891
+ * helper then writes atomically via writeJsonAtomic.
892
+ *
893
+ * Stale-lock recovery: if a holder crashes without unlinking, the lockfile
894
+ * persists. After backoff, if the lockfile's mtime is older than 30s we
895
+ * treat it as orphaned and unlink it before retrying. 30s is well past any
896
+ * legitimate single-CVE apply (sub-second on modern disks).
897
+ *
898
+ * On acquisition failure after N retries, we throw — better than silently
899
+ * proceeding without the lock.
900
+ *
901
+ * @param {string} catalogPath path to the JSON catalog to lock
902
+ * @param {(catalog: object) => object | Promise<object>} mutator
903
+ * receives current-on-disk catalog, returns mutated catalog. May be
904
+ * async. The return value is what gets written; if it returns
905
+ * undefined, the in-place mutation of the passed-in catalog is used.
906
+ * @returns {Promise<{ wrote: boolean, result: any }>}
907
+ */
908
+ async function withCatalogLock(catalogPath, mutator) {
909
+ const lockPath = `${catalogPath}.lock`;
910
+ const MAX_RETRIES = 50;
911
+ const STALE_LOCK_MS = 30_000;
912
+ let acquired = false;
913
+ for (let i = 0; i < MAX_RETRIES; i++) {
914
+ try {
915
+ fs.writeFileSync(lockPath, String(process.pid), { flag: "wx" });
916
+ acquired = true;
917
+ break;
918
+ } catch (e) {
919
+ // EEXIST is the POSIX signal another process holds the lock. On
920
+ // Windows the same race surfaces as EPERM (sharing-violation raised
921
+ // when the holder is mid-unlink). Treat both as "lock held, back off."
922
+ if (e.code !== "EEXIST" && e.code !== "EPERM") throw e;
923
+ // Stale-lock check before sleeping — a long-dead holder shouldn't keep
924
+ // us waiting MAX_RETRIES * backoff before we recover.
925
+ try {
926
+ const stat = fs.statSync(lockPath);
927
+ if (Date.now() - stat.mtimeMs > STALE_LOCK_MS) {
928
+ try { fs.unlinkSync(lockPath); } catch {}
929
+ continue; // retry immediately without sleeping
930
+ }
931
+ } catch {} // lockfile vanished between EEXIST and stat — fine, retry
932
+ await new Promise((r) => setTimeout(r, 50 + Math.random() * 150));
933
+ }
934
+ }
935
+ if (!acquired) {
936
+ throw new Error(`withCatalogLock: could not acquire ${lockPath} after ${MAX_RETRIES} attempts`);
937
+ }
938
+ try {
939
+ const catalog = JSON.parse(fs.readFileSync(catalogPath, "utf8"));
940
+ const mutated = await mutator(catalog);
941
+ const toWrite = mutated === undefined ? catalog : mutated;
942
+ writeJsonAtomic(catalogPath, toWrite);
943
+ return { wrote: true, result: toWrite };
944
+ } finally {
945
+ try { fs.unlinkSync(lockPath); } catch {}
946
+ }
825
947
  }
826
948
 
827
949
  function chosenSources(opts) {
@@ -830,8 +952,13 @@ function chosenSources(opts) {
830
952
  const out = [];
831
953
  for (const n of names) {
832
954
  if (!ALL_SOURCES[n]) {
833
- console.error(`refresh-external: unknown source "${n}". Valid: ${Object.keys(ALL_SOURCES).join(", ")}`);
834
- process.exit(2);
955
+ // v0.12.12 C3: previously `process.exit(2)` after a console.error.
956
+ // Stdout writes elsewhere in this run could truncate; throwing lets
957
+ // main().catch() surface the error through the standard channel and
958
+ // exit code via process.exitCode + natural event-loop drain.
959
+ const err = new Error(`refresh-external: unknown source "${n}". Valid: ${Object.keys(ALL_SOURCES).join(", ")}`);
960
+ err._exceptd_unknown_source = true;
961
+ throw err;
835
962
  }
836
963
  out.push(ALL_SOURCES[n]);
837
964
  }
@@ -861,7 +988,27 @@ async function seedSingleAdvisory(opts) {
861
988
  const sourceName = useOsv ? "osv" : "ghsa";
862
989
  const fixtureEnv = useOsv ? "EXCEPTD_OSV_FIXTURE" : "EXCEPTD_GHSA_FIXTURE";
863
990
 
864
- const result = await sourceMod.fetchAdvisoryById(id, {});
991
+ let result = await sourceMod.fetchAdvisoryById(id, {});
992
+ // F4 (v0.12.11): CVE-* identifiers may have an OSV record before GHSA
993
+ // publishes one (CNAs and OSV mirrors operate on different cadences).
994
+ // When GHSA returns 404 specifically, retry through OSV's /v1/vulns/{id}
995
+ // — OSV indexes CVE ids as primary keys. If both 404, surface a combined
996
+ // error message so operators know both sources were tried before failing.
997
+ let fallbackSourceUsed = null;
998
+ if (!result.ok && !useOsv && /^CVE-/i.test(id) && /HTTP 404/.test(result.error || "")) {
999
+ const fallback = await osvMod.fetchAdvisoryById(id, {});
1000
+ if (fallback.ok) {
1001
+ result = fallback;
1002
+ fallbackSourceUsed = "osv";
1003
+ } else if (/HTTP 404/.test(fallback.error || "") || /not in fixture/.test(fallback.error || "")) {
1004
+ // Both sources tried, both 404 — combine the error message.
1005
+ const combined = { ok: false, verb: "refresh", error: `--advisory ${id}: not found in GHSA or OSV (GHSA: ${result.error}; OSV: ${fallback.error})`, source: "offline", routed_to: "ghsa+osv", hint: `Both GHSA and OSV.dev returned 404 for ${id}. Verify the CVE id (CVE-YYYY-NNNN) and that an advisory record exists upstream.` };
1006
+ if (opts.json) process.stdout.write(JSON.stringify(combined) + "\n");
1007
+ else process.stderr.write(`[refresh --advisory] ${combined.error}\n hint: ${combined.hint}\n`);
1008
+ process.exitCode = 2;
1009
+ return;
1010
+ }
1011
+ }
865
1012
  if (!result.ok) {
866
1013
  const err = { ok: false, verb: "refresh", error: `--advisory ${id}: ${result.error}`, source: result.source, routed_to: sourceName, hint: `Verify the ID format (CVE-YYYY-NNNN, GHSA-*, MAL-*, SNYK-*, RUSTSEC-*, USN-*, etc.) and network reachability. Set ${fixtureEnv} for offline testing.` };
867
1014
  if (opts.json) process.stdout.write(JSON.stringify(err) + "\n");
@@ -869,17 +1016,21 @@ async function seedSingleAdvisory(opts) {
869
1016
  process.exitCode = 2;
870
1017
  return;
871
1018
  }
1019
+ // If the OSV fallback fired, normalize/route through the OSV module from
1020
+ // here on — the advisory shape is OSV's, not GHSA's.
1021
+ const effectiveMod = fallbackSourceUsed === "osv" ? osvMod : sourceMod;
1022
+ const effectiveName = fallbackSourceUsed === "osv" ? "osv" : sourceName;
872
1023
  const advisory = result.advisories[0];
873
1024
  if (!advisory) {
874
- const err = { ok: false, verb: "refresh", error: `--advisory ${id}: no matching advisory found`, source: result.source, routed_to: sourceName };
1025
+ const err = { ok: false, verb: "refresh", error: `--advisory ${id}: no matching advisory found`, source: result.source, routed_to: effectiveName };
875
1026
  if (opts.json) process.stdout.write(JSON.stringify(err) + "\n");
876
1027
  else process.stderr.write(`[refresh --advisory] ${err.error}\n`);
877
1028
  process.exitCode = 2;
878
1029
  return;
879
1030
  }
880
- const normalized = sourceMod.normalizeAdvisory(advisory);
1031
+ const normalized = effectiveMod.normalizeAdvisory(advisory);
881
1032
  if (!normalized) {
882
- const err = { ok: false, verb: "refresh", error: `--advisory ${id}: advisory could not be normalized (missing required fields)`, routed_to: sourceName, source_id: advisory.ghsa_id || advisory.id || null };
1033
+ const err = { ok: false, verb: "refresh", error: `--advisory ${id}: advisory could not be normalized (missing required fields)`, routed_to: effectiveName, source_id: advisory.ghsa_id || advisory.id || null };
883
1034
  if (opts.json) process.stdout.write(JSON.stringify(err) + "\n");
884
1035
  else process.stderr.write(`[refresh --advisory] ${err.error}\n`);
885
1036
  process.exitCode = 2;
@@ -911,18 +1062,29 @@ async function seedSingleAdvisory(opts) {
911
1062
 
912
1063
  // Apply: write to cve-catalog.json with the _auto_imported flag.
913
1064
  // v0.12.8: honor --catalog / EXCEPTD_CVE_CATALOG so tests can redirect.
1065
+ // v0.12.12 C1: lock-gated RMW. Without this, two concurrent
1066
+ // `refresh --advisory CVE-A --apply` + `--advisory CVE-B --apply`
1067
+ // processes against the same catalog silently dropped one CVE 1-in-20
1068
+ // trials (read-old → mutate → write-overwrites-sibling-mutation).
914
1069
  const catalogPath = resolveCatalogPath(opts);
915
- const catalog = JSON.parse(fs.readFileSync(catalogPath, "utf8"));
916
- if (catalog[cveId] && !catalog[cveId]._auto_imported && !catalog[cveId]._draft) {
917
- // Refuse to overwrite a human-curated entry.
918
- const err = { ok: false, verb: "refresh", error: `${cveId} already present in catalog and is human-curated (not a draft). Refusing to overwrite. Edit manually if intentional.`, existing_last_updated: catalog[cveId].last_updated };
1070
+ let humanCurated = null;
1071
+ await withCatalogLock(catalogPath, (catalog) => {
1072
+ if (catalog[cveId] && !catalog[cveId]._auto_imported && !catalog[cveId]._draft) {
1073
+ // Refuse to overwrite a human-curated entry signal via closure so
1074
+ // we can emit the structured error after the lock releases.
1075
+ humanCurated = { last_updated: catalog[cveId].last_updated };
1076
+ return catalog; // unchanged write — idempotent, releases lock
1077
+ }
1078
+ catalog[cveId] = normalized[cveId];
1079
+ return catalog;
1080
+ });
1081
+ if (humanCurated) {
1082
+ const err = { ok: false, verb: "refresh", error: `${cveId} already present in catalog and is human-curated (not a draft). Refusing to overwrite. Edit manually if intentional.`, existing_last_updated: humanCurated.last_updated };
919
1083
  if (opts.json) process.stdout.write(JSON.stringify(err) + "\n");
920
1084
  else process.stderr.write(`[refresh --advisory] ${err.error}\n`);
921
1085
  process.exitCode = 4;
922
1086
  return;
923
1087
  }
924
- catalog[cveId] = normalized[cveId];
925
- fs.writeFileSync(catalogPath, JSON.stringify(catalog, null, 2) + "\n", "utf8");
926
1088
  const output = {
927
1089
  ok: true,
928
1090
  verb: "refresh",
@@ -943,7 +1105,9 @@ async function main() {
943
1105
  const opts = parseArgs(process.argv);
944
1106
  if (opts.help) {
945
1107
  printHelp();
946
- process.exit(0);
1108
+ // v0.12.12 C3: exitCode + return so buffered stdout flushes naturally.
1109
+ process.exitCode = 0;
1110
+ return;
947
1111
  }
948
1112
 
949
1113
  // v0.12.0: `--advisory <id>` short-circuits the normal source loop and
@@ -1040,7 +1204,12 @@ async function main() {
1040
1204
  }
1041
1205
  }
1042
1206
 
1043
- process.exit(hadFailure ? 1 : 0);
1207
+ // v0.12.12 C3: same anti-pattern v0.12.9 fixed in prefetch's main(). After
1208
+ // Promise.all(sources.map(runOne)) in --swarm mode, process.exit() could
1209
+ // truncate buffered stdout (refresh-report path log line, summary log
1210
+ // lines piped to a consumer). exitCode + return lets the event loop end
1211
+ // naturally and stdout drains in full.
1212
+ process.exitCode = hadFailure ? 1 : 0;
1044
1213
  }
1045
1214
 
1046
1215
  async function sequential(items, fn) {
@@ -1056,11 +1225,18 @@ if (require.main === module) {
1056
1225
  if (err && err._exceptd_hint) {
1057
1226
  console.error(err.message);
1058
1227
  console.error(JSON.stringify({ ok: false, error: err.message.split("\n")[0], hint: err.message.split("\n").slice(1).join(" ").trim(), verb: "refresh" }));
1228
+ } else if (err && err._exceptd_unknown_source) {
1229
+ // v0.12.12 C3: surface the source-validation error without leaking a
1230
+ // stack trace; chosenSources throws this for unknown --source values.
1231
+ console.error(err.message);
1059
1232
  } else {
1060
1233
  console.error(`refresh-external: fatal: ${err && err.stack ? err.stack : err}`);
1061
1234
  }
1062
- process.exit(2);
1235
+ // v0.12.12 C3: exitCode + return rather than process.exit(2) — the
1236
+ // event loop has no further work after main()'s rejection, so this
1237
+ // ends the process with code 2 but lets stderr drain first.
1238
+ process.exitCode = 2;
1063
1239
  });
1064
1240
  }
1065
1241
 
1066
- module.exports = { ALL_SOURCES, loadCtx, parseArgs };
1242
+ module.exports = { ALL_SOURCES, loadCtx, parseArgs, seedSingleAdvisory, withCatalogLock, writeJsonAtomic };
@@ -125,6 +125,18 @@ function parseTar(buf) {
125
125
  const entries = [];
126
126
  let offset = 0;
127
127
  let pendingLongName = null;
128
+ // v0.12.12: tarballs from a compromised registry CDN could ship entries
129
+ // with `..`-bearing names targeting paths outside the install root. The
130
+ // immediate callers (verify-shipped-tarball.js + the network update path)
131
+ // do hash + signature checks before honoring entries, so this is
132
+ // defense-in-depth — drop the entry rather than handing a path-traversal
133
+ // string downstream.
134
+ const isSafeName = (n) => {
135
+ if (typeof n !== "string" || n.length === 0) return false;
136
+ // Reject absolute paths AND any segment that is exactly ".."
137
+ if (/^[\\/]/.test(n) || /^[A-Za-z]:[\\/]/.test(n)) return false;
138
+ return !n.split(/[\\/]/).some((seg) => seg === "..");
139
+ };
128
140
  while (offset + 512 <= buf.length) {
129
141
  const block = buf.subarray(offset, offset + 512);
130
142
  // empty block = end-of-archive marker
@@ -141,7 +153,9 @@ function parseTar(buf) {
141
153
  if (type === "L") {
142
154
  pendingLongName = buf.subarray(dataStart, dataEnd).toString("utf8").replace(/\0.*$/, "");
143
155
  } else if (type === "0" || type === "" || type === "\0") {
144
- entries.push({ name, body: buf.subarray(dataStart, dataEnd) });
156
+ if (isSafeName(name)) {
157
+ entries.push({ name, body: buf.subarray(dataStart, dataEnd) });
158
+ }
145
159
  }
146
160
  // round up to 512
147
161
  offset = dataStart + Math.ceil(size / 512) * 512;