@blamejs/exceptd-skills 0.18.12 → 0.18.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/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.18.13 — 2026-06-22
4
+
5
+ A correctness + robustness pass across the upstream data parsers, the validators, and the prefetch/feed paths.
6
+
7
+ The OSV advisory importer keeps the HIGHEST CVSS vector and score when a record carries multiple same-version severity entries (e.g. an NVD base score alongside a CNA/vendor score), instead of whichever appeared first in the array — a 9.8 critical no longer imports as a 5.3 medium purely because of upstream array order, which previously also flipped the derived KEV-pending / active-exploitation / severity-word fields. The RSS/Atom feed tokenizer strips HTML in a single linear pass, so a malformed or hostile feed body with many unclosed `<` can no longer stall the refresh worker (the previous regex was quadratic on attacker-controlled text). An Atom `<link>` carrying element text is now selected by its `rel` the same way the attribute form is, so a non-`alternate` link cannot override the canonical `alternate`. `refresh --advisory <ghsa-id>` validates the full `GHSA-xxxx-xxxx-xxxx` token shape and URL-encodes it before building the request path.
8
+
9
+ The playbook validator range-checks a partial `phase_overrides.direct.rwep_threshold` override and rejects an impossible calendar date (`2026-02-30`); the CVE-catalog validator reports an absent reference catalog as unvalidatable for ALL five reference families instead of flagging every `atlas_ref`/`cwe_ref` as unresolved; the RFC validator skips non-IETF references (CSAF, ISO) rather than reporting them as permanent drift; the version-pin validator no longer probes the two upstreams that publish no GitHub releases (reported as tracked elsewhere); the vendor inventory cross-check covers `.cjs`/`.mjs`/`.json`, not only `.js`; the package size-budget gate fails closed when `npm pack` omits the size field; and the disputed-CVE check reports the real local status. The skill linter flags a wrong-typed enum value and rejects an impossible `last_threat_review`, and the `d3fend_refs` frontmatter pattern accepts the `D3A-`/`D3F-` id families. `upstream-check` reports a clear error rather than `ok:true` with an undefined version when a fixture lacks dist-tags, and a non-parseable publish date yields a clear value instead of `NaN`. The prefetch index no longer resurrects entries a concurrent run pruned, `ttp-mapper` ignores inherited object keys, and the EPSS lookup returns not-found for an absent CVE instead of the first row.
10
+
3
11
  ## 0.18.12 — 2026-06-22
4
12
 
5
13
  A correctness pass across the CLI, the engine, scoring, the collectors, framework-gap reporting, signature verification, and the CWE-chain index.
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "schema_version": "1.1.0",
3
- "generated_at": "2026-06-22T06:27:15.592Z",
3
+ "generated_at": "2026-06-22T13:09:57.368Z",
4
4
  "generator": "scripts/build-indexes.js",
5
5
  "source_count": 64,
6
6
  "source_hashes": {
7
- "manifest.json": "d897efd048acdde209213af36857c5385c036a650096531408ca978590e38226",
7
+ "manifest.json": "04dc4d4026147da386256d462aec657b1746e9539f8d8dc3ed0ad522f437d765",
8
8
  "README.md": "e7b854e7db9a364a1b368b5084b4f0c2a8282f0459ce39800ac1d1dabdc06074",
9
9
  "data/atlas-ttps.json": "5bc59e23d6c2defa54168de161a0825299b9cc4a49c6b26df2dae70b4f42eedf",
10
10
  "data/attack-techniques.json": "53c6f248760eecb11a0354f74ab467a5814e95075a686b9b3bf18c34e2f7435e",
@@ -206,7 +206,16 @@ function extractNvdMetrics(payload) {
206
206
 
207
207
  function extractEpss(payload, id) {
208
208
  const data = Array.isArray(payload?.data) ? payload.data : [];
209
- const row = data.find((r) => r?.cve === id) || data[0];
209
+ // Match the requested CVE id only. The prior `|| data[0]` fallback silently
210
+ // misattributed a DIFFERENT CVE's EPSS score into the draft whenever the
211
+ // requested id was absent from the payload — a single-row response for the
212
+ // wrong CVE (or a stale/mismatched sidecar) wrote that CVE's score under the
213
+ // discovered id. A non-matching payload now yields null (same
214
+ // null-mechanical-field behavior used when no cache exists). The only
215
+ // accepted fallback is a single-row, cve-less response shape (the requested
216
+ // id is implicit because the payload carries exactly one un-keyed row).
217
+ let row = data.find((r) => r?.cve === id);
218
+ if (!row && data.length === 1 && data[0]?.cve == null) row = data[0];
210
219
  if (!row) return null;
211
220
  return {
212
221
  score: row.epss != null ? Number(row.epss) : null,
@@ -228,7 +228,11 @@ function findRegressionCandidates(diffs, catalog, opts) {
228
228
  const titleStr = (typeof d.title === 'string' && d.title) ? d.title
229
229
  : (typeof d.first_title === 'string' && d.first_title) ? d.first_title
230
230
  : '';
231
- if (titleStr) slot.titles.push(titleStr);
231
+ // Dedupe titles before pushing — mirrors the content-only branch.
232
+ // Without this, duplicate titles across feeds (a common multi-feed
233
+ // surfacing pattern) consume distinct slots and evict genuinely
234
+ // distinct titles from the 5-cap below.
235
+ if (titleStr && !slot.titles.includes(titleStr)) slot.titles.push(titleStr);
232
236
  // Merge content signals — the strongest signal wins.
233
237
  Object.assign(slot.signals, signals);
234
238
  continue;
package/lib/job-queue.js CHANGED
@@ -34,8 +34,15 @@ class TokenBucket {
34
34
  if (elapsed <= 0) return;
35
35
  const add = elapsed / this.refillIntervalMs;
36
36
  if (add >= 1) {
37
- this.tokens = Math.min(this.capacity, this.tokens + Math.floor(add));
38
- this.lastRefill = now;
37
+ const whole = Math.floor(add);
38
+ this.tokens = Math.min(this.capacity, this.tokens + whole);
39
+ // Advance lastRefill by the time the whole tokens we just granted
40
+ // consumed, NOT to `now`. Snapping to `now` discards the sub-interval
41
+ // remainder (`elapsed % refillIntervalMs`), so the bucket refills
42
+ // slightly slower than the configured rate and under-utilizes the
43
+ // budget over many refills. Crediting only the consumed time keeps
44
+ // the long-run refill rate exactly tokens/windowMs.
45
+ this.lastRefill += whole * this.refillIntervalMs;
39
46
  }
40
47
  }
41
48
  tryTake() {
@@ -117,6 +117,26 @@ const ATLAS_ID_RE = /^AML\.T\d{4}(\.\d{3})?$/;
117
117
  const ATTACK_ID_RE = /^T\d{4}(\.\d{3})?$/;
118
118
  const SEMVER_RE = /^\d+\.\d+\.\d+$/;
119
119
  const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
120
+
121
+ /*
122
+ * Strict ISO calendar-date check. ISO_DATE_RE only proves the SHAPE
123
+ * (YYYY-MM-DD); Date.parse() silently rolls non-calendar dates over —
124
+ * 2026-02-30 becomes 2026-03-02, 2026-04-31 becomes 2026-05-01 — so a
125
+ * malformed review date would pass the staleness gate against a date that
126
+ * never existed. Round-trip through Date.UTC and require every component to
127
+ * survive unchanged, which rejects the rollovers while accepting real dates.
128
+ */
129
+ function isStrictIsoCalendarDate(s) {
130
+ if (typeof s !== 'string' || !ISO_DATE_RE.test(s)) return false;
131
+ const [y, m, d] = s.split('-').map((n) => parseInt(n, 10));
132
+ if (m < 1 || m > 12 || d < 1 || d > 31) return false;
133
+ const dt = new Date(Date.UTC(y, m - 1, d));
134
+ return (
135
+ dt.getUTCFullYear() === y &&
136
+ dt.getUTCMonth() + 1 === m &&
137
+ dt.getUTCDate() === d
138
+ );
139
+ }
120
140
  const KEBAB_RE = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/;
121
141
  const JSON_FILENAME_RE = /^[A-Za-z0-9._-]+\.json$/;
122
142
 
@@ -293,8 +313,17 @@ function schemaConstraintErrors(fm, schema) {
293
313
  if (!(field in fm)) continue;
294
314
  const value = fm[field];
295
315
 
296
- if (Array.isArray(spec.enum) && typeof value === 'string') {
297
- if (!spec.enum.includes(value)) {
316
+ // An enum constraint must reject a wrong-typed value, not skip it. The
317
+ // earlier `typeof value === 'string'` guard silently passed any non-string
318
+ // (e.g. `discovery_mode: [standalone]` parsed as an array, or a number),
319
+ // letting a malformed field bypass validation entirely. Flag the type
320
+ // mismatch first; only run the membership check on an actual string.
321
+ if (Array.isArray(spec.enum)) {
322
+ if (typeof value !== 'string') {
323
+ errors.push(
324
+ `frontmatter.${field} must be a string (one of ${JSON.stringify(spec.enum)}), got ${Array.isArray(value) ? 'array' : typeof value}`,
325
+ );
326
+ } else if (!spec.enum.includes(value)) {
298
327
  errors.push(
299
328
  `frontmatter.${field} "${value}" is not one of ${JSON.stringify(spec.enum)}`,
300
329
  );
@@ -433,10 +462,9 @@ function validateFrontmatter(fm, skillName) {
433
462
  }
434
463
 
435
464
  if ('last_threat_review' in fm) {
436
- if (typeof fm.last_threat_review !== 'string' || !ISO_DATE_RE.test(fm.last_threat_review) ||
437
- !Number.isFinite(Date.parse(fm.last_threat_review + 'T00:00:00Z'))) {
465
+ if (!isStrictIsoCalendarDate(fm.last_threat_review)) {
438
466
  errors.push(
439
- `frontmatter.last_threat_review "${fm.last_threat_review}" is not a valid ISO date (YYYY-MM-DD). A structurally ISO but non-calendar value (e.g. 2026-13-99) is rejected so a malformed date cannot slip past the staleness gate.`,
467
+ `frontmatter.last_threat_review "${fm.last_threat_review}" is not a valid ISO date (YYYY-MM-DD). A structurally ISO but non-calendar value (e.g. 2026-13-99 or a rollover like 2026-02-30) is rejected so a malformed date cannot slip past the staleness gate.`,
440
468
  );
441
469
  } else {
442
470
  // v0.13.0: Hard Rule #8 forcing function — refuse skills whose
@@ -1044,6 +1072,7 @@ module.exports = {
1044
1072
  MIN_SECTION_BODY_WORDS,
1045
1073
  validateFrontmatter,
1046
1074
  schemaConstraintErrors,
1075
+ isStrictIsoCalendarDate,
1047
1076
  FRONTMATTER_SCHEMA,
1048
1077
  };
1049
1078
 
package/lib/prefetch.js CHANGED
@@ -738,8 +738,10 @@ async function prefetch(options = {}) {
738
738
  current.entries[entryKey(item.source, item.id)] = meta;
739
739
  return current;
740
740
  });
741
- // Mirror the entry into the in-memory idx for callers that read
742
- // it later in this run (e.g. the final saveIndex merge).
741
+ // Mirror the entry into the in-memory idx snapshot so any
742
+ // later in-run freshness check sees this entry as fresh. The
743
+ // authoritative on-disk write already happened under the lock
744
+ // above; this is the in-memory copy only.
743
745
  idx.entries[entryKey(item.source, item.id)] = meta;
744
746
  } catch (lockErr) {
745
747
  // Lock failure OR rename-inside-lock failure — unlink the staged
@@ -763,11 +765,17 @@ async function prefetch(options = {}) {
763
765
 
764
766
  await Promise.all(jobPromises);
765
767
  await queue.drain();
766
- idx.generated_at = new Date().toISOString();
767
- // v0.12.12 C2: saveIndex now merges under lock with whatever is on disk
768
- // (another concurrent prefetch's entries). Without the merge, a sibling
769
- // run's writes would be silently overwritten here at the end of our run.
770
- await saveIndex(opts.cacheDir, idx);
768
+ // Each fetched entry was already persisted to the on-disk index under
769
+ // lock during the run (the per-entry withIndexLock above), so the final
770
+ // write only needs to stamp generated_at. Re-merging the whole
771
+ // start-of-run `idx` snapshot here would RESURRECT entries a concurrent
772
+ // run pruned between our snapshot and now — partially defeating the
773
+ // concurrency fix the per-entry lock provides. Bump generated_at on the
774
+ // CURRENT on-disk index under lock instead, touching nothing else.
775
+ await withIndexLock(opts.cacheDir, (current) => {
776
+ current.generated_at = new Date().toISOString();
777
+ return current;
778
+ });
771
779
 
772
780
  // Sign the freshly-written _index.json with the Ed25519 private key
773
781
  // (.keys/private.pem). The signature is a sidecar `_index.json.sig`;
@@ -87,9 +87,9 @@
87
87
  "type": "array",
88
88
  "items": {
89
89
  "type": "string",
90
- "pattern": "^D3-[A-Z]+$"
90
+ "pattern": "^D3[AF]?-[A-Z0-9]+(?:-[A-Z0-9]+)*$"
91
91
  },
92
- "description": "Optional. MITRE D3FEND defensive technique IDs the skill maps to. Each must resolve in data/d3fend-catalog.json."
92
+ "description": "Optional. MITRE D3FEND defensive technique IDs the skill maps to, across the D3-, D3A- (analysis), and D3F- (offensive/forensic) namespaces. Each must resolve in data/d3fend-catalog.json."
93
93
  },
94
94
  "dlp_refs": {
95
95
  "type": "array",
@@ -246,8 +246,14 @@ async function fetchAdvisoryById(id, opts = {}) {
246
246
  if (!match) return { ok: false, error: `${id} not in fixture`, source: "fixture" };
247
247
  return { ok: true, advisories: [match], source: "fixture" };
248
248
  }
249
- if (/^GHSA-/i.test(id)) {
250
- return fetchAdvisories({ ...opts, path: `/advisories/${id.toLowerCase()}` });
249
+ // Validate the FULL GHSA token shape (GHSA-xxxx-xxxx-xxxx) before building the
250
+ // request path, and encodeURIComponent the segment — symmetric with the CVE
251
+ // branch below. A prefix-only `/^GHSA-/i` left the remainder unconstrained, so
252
+ // an operator-supplied id like `GHSA-aaaa/../../meta` survived verbatim into
253
+ // `/advisories/...` as path-control segments; the strict shape now routes a
254
+ // malformed id to the unrecognized-id error envelope instead.
255
+ if (/^GHSA-[0-9a-z]{4}-[0-9a-z]{4}-[0-9a-z]{4}$/i.test(id)) {
256
+ return fetchAdvisories({ ...opts, path: `/advisories/${encodeURIComponent(id.toLowerCase())}` });
251
257
  }
252
258
  if (/^CVE-\d{4}-\d+$/i.test(id)) {
253
259
  return fetchAdvisories({ ...opts, path: `/advisories?cve_id=${encodeURIComponent(id.toUpperCase())}` });
package/lib/source-osv.js CHANGED
@@ -542,6 +542,16 @@ function extractCvss(rec) {
542
542
  // back from v4 -> v3 if v4 fails to compute.
543
543
  const vectorsByVersion = new Map(); // version (number) -> vector string
544
544
  let bareScore = null;
545
+ // Computed score for a single CVSS vector string: its trailing /N.N score if
546
+ // present, else the derived CVSS 3.x base score, else null (e.g. an
547
+ // uncomputable v4 vector). Used to keep the HIGHEST-scoring vector per major
548
+ // version regardless of severity[] array order.
549
+ const vectorScore = (vec) => {
550
+ const tail = vec.match(/\/(\d+(?:\.\d+)?)$/);
551
+ if (tail) { const t = parseFloat(tail[1]); if (t >= 0 && t <= 10) return t; }
552
+ if (/^CVSS:3\./.test(vec)) { const c = cvss3BaseScore(vec); if (c != null) return c; }
553
+ return null;
554
+ };
545
555
  for (const s of sev) {
546
556
  if (s == null) continue;
547
557
  let raw = null;
@@ -554,15 +564,21 @@ function extractCvss(rec) {
554
564
  // Bare numeric score (no vector prefix).
555
565
  const num = parseFloat(v);
556
566
  if (!Number.isNaN(num) && num >= 0 && num <= 10 && !v.includes("/")) {
557
- if (bareScore == null) bareScore = num;
567
+ // HIGHEST-wins, not first-wins: an OSV record legitimately carries
568
+ // multiple same-version severity entries (e.g. an NVD base score plus a
569
+ // CNA/vendor score) and the upstream array order is not guaranteed. A
570
+ // first-wins guard silently downgraded a 9.8 critical to a 5.3 medium
571
+ // purely because the lower score appeared earlier in severity[].
572
+ if (bareScore == null || num > bareScore) bareScore = num;
558
573
  continue;
559
574
  }
560
575
  const m = v.match(/^CVSS:(\d+\.\d+)/);
561
576
  if (!m) continue;
562
577
  const ver = parseFloat(m[1]);
563
- // Keep the highest score within each major version.
578
+ // Keep the HIGHEST-scoring vector within each major version (same rationale
579
+ // as the bare-score path above). Compare computed scores, not array order.
564
580
  const prev = vectorsByVersion.get(ver);
565
- if (!prev) vectorsByVersion.set(ver, v);
581
+ if (!prev || (vectorScore(v) ?? -1) > (vectorScore(prev) ?? -1)) vectorsByVersion.set(ver, v);
566
582
  }
567
583
  // Try versions in descending order. CVSS 4.0 derivation is not yet
568
584
  // implemented here — if v4 was the highest but can't be computed, walk
package/lib/ttp-mapper.js CHANGED
@@ -5,7 +5,16 @@
5
5
  * and surfaces gaps where controls fail to cover attacker techniques.
6
6
  */
7
7
 
8
+ const hasOwn = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key);
9
+
8
10
  function map(controlId, gapCatalog) {
11
+ // hasOwnProperty guard: a bare gapCatalog[controlId] dereferences inherited
12
+ // Object.prototype members, so controlId='__proto__' / 'toString' /
13
+ // 'constructor' returned found:true for a key that is not a real catalog
14
+ // entry. Only own enumerable keys are catalog controls.
15
+ if (!gapCatalog || !hasOwn(gapCatalog, controlId)) {
16
+ return { control_id: controlId, found: false, message: 'Control not in gap catalog' };
17
+ }
9
18
  const entry = gapCatalog[controlId];
10
19
  if (!entry) return { control_id: controlId, found: false, message: 'Control not in gap catalog' };
11
20
  return {
@@ -47,6 +56,12 @@ function coverage(frameworkId, ttpId, gapCatalog, atlasCatalog) {
47
56
  return { ttp_id: ttpId, found: false, error: 'frameworkId required', partially_covered_by: null, not_covered_by: null };
48
57
  }
49
58
 
59
+ // hasOwnProperty guard: ttpId='__proto__' / 'toString' / 'constructor'
60
+ // would otherwise resolve to an inherited Object.prototype member and be
61
+ // treated as a real ATLAS technique. Only own keys are catalog techniques.
62
+ if (!atlasCatalog || !hasOwn(atlasCatalog, ttpId)) {
63
+ return { ttp_id: ttpId, found: false };
64
+ }
50
65
  const ttp = atlasCatalog[ttpId];
51
66
  if (!ttp) return { ttp_id: ttpId, found: false };
52
67
 
@@ -47,7 +47,15 @@ async function fetchLatestPublished({ timeoutMs = REQUEST_TIMEOUT_MS, pkgName =
47
47
  const fs = require("fs");
48
48
  const fixture = JSON.parse(fs.readFileSync(process.env.EXCEPTD_REGISTRY_FIXTURE, "utf8"));
49
49
  const version = fixture["dist-tags"]?.latest || fixture.version;
50
- const published = fixture.time?.[version] || fixture.time?.modified;
50
+ // Same guard the network branch applies: a fixture with neither
51
+ // dist-tags.latest nor a top-level version yields no freshness signal.
52
+ // Returning ok:true with version:undefined would let an undefined
53
+ // version leak into buildFreshnessReport's semver compare and the
54
+ // operator-facing hint. Degrade to an explicit offline refusal.
55
+ if (!version) {
56
+ return { ok: false, error: "fixture missing dist-tags.latest / version", source: "offline" };
57
+ }
58
+ const published = fixture.time?.[version] || fixture.time?.modified || null;
51
59
  return { ok: true, version, published_at: published, source: "fixture" };
52
60
  } catch (e) {
53
61
  return { ok: false, error: `fixture: ${e.message}`, source: "offline" };
@@ -120,8 +128,14 @@ function buildFreshnessReport({ localVersion, registry, localManifest }) {
120
128
  };
121
129
  }
122
130
  const cmp = semverCmp(localVersion, registry.version);
123
- const daysBehind = registry.published_at
124
- ? Math.max(0, Math.floor((Date.now() - new Date(registry.published_at).getTime()) / (24 * 3600 * 1000)))
131
+ // Parse the publish timestamp once and validate it. A non-empty but
132
+ // unparseable date string (e.g. a malformed registry `time` value) makes
133
+ // `new Date(...).getTime()` return NaN, which would otherwise propagate
134
+ // NaN into days_since_latest_publish. Degrade an unparseable date to the
135
+ // explicit null (signal-absent) branch instead of emitting NaN.
136
+ const publishedTs = registry.published_at ? new Date(registry.published_at).getTime() : NaN;
137
+ const daysBehind = Number.isFinite(publishedTs)
138
+ ? Math.max(0, Math.floor((Date.now() - publishedTs) / (24 * 3600 * 1000)))
125
139
  : null;
126
140
  // Manifest's last_threat_review (per-skill) — surface the most stale.
127
141
  let oldestReview = null;
@@ -384,8 +384,16 @@ function main() {
384
384
  const schema = readJson(SCHEMA_PATH);
385
385
  const catalog = readJson(CATALOG_PATH);
386
386
  const lessons = readJson(LESSONS_PATH);
387
- const atlas = fs.existsSync(ATLAS_PATH) ? readJson(ATLAS_PATH) : {};
388
- const cwe = fs.existsSync(CWE_PATH) ? readJson(CWE_PATH) : {};
387
+ // All five reference catalogs default to null when absent so the
388
+ // "cannot validate — catalog absent" branch in additionalChecks fires
389
+ // uniformly for every ref family. atlas/cwe previously defaulted to {},
390
+ // which built an EMPTY Set (never null) — so an absent atlas/cwe catalog
391
+ // silently FLAGGED every ref as unresolved (false positive) instead of
392
+ // surfacing the catalog-absent warning, the asymmetric opposite of how
393
+ // attack/d3fend/framework behaved. Uniform null makes the absence
394
+ // observable rather than misclassified.
395
+ const atlas = fs.existsSync(ATLAS_PATH) ? readJson(ATLAS_PATH) : null;
396
+ const cwe = fs.existsSync(CWE_PATH) ? readJson(CWE_PATH) : null;
389
397
  const attack = fs.existsSync(ATTACK_PATH) ? readJson(ATTACK_PATH) : null;
390
398
  const d3fend = fs.existsSync(D3FEND_PATH) ? readJson(D3FEND_PATH) : null;
391
399
  const frameworks = fs.existsSync(FRAMEWORK_GAPS_PATH)
@@ -393,8 +401,12 @@ function main() {
393
401
  : null;
394
402
 
395
403
  const ctx = {
396
- atlasKeys: new Set(Object.keys(atlas).filter((k) => !k.startsWith('_'))),
397
- cweKeys: new Set(Object.keys(cwe).filter((k) => !k.startsWith('_'))),
404
+ atlasKeys: atlas
405
+ ? new Set(Object.keys(atlas).filter((k) => !k.startsWith('_')))
406
+ : null,
407
+ cweKeys: cwe
408
+ ? new Set(Object.keys(cwe).filter((k) => !k.startsWith('_')))
409
+ : null,
398
410
  attackKeys: attack
399
411
  ? new Set(Object.keys(attack).filter((k) => !k.startsWith('_')))
400
412
  : null,
@@ -150,8 +150,15 @@ function main() {
150
150
  }
151
151
  }
152
152
 
153
- // Size budget
154
- if (typeof packInfo.size === "number" && packInfo.size > SIZE_BUDGET_BYTES) {
153
+ // Size budget. An absent/non-numeric `size` field is NOT a pass — it means
154
+ // the gate cannot evaluate the budget at all. Fail loudly instead of
155
+ // silently skipping (the absent-field-fails-open class, symmetric with the
156
+ // license_sha256 / vendored_sha256 guards in validate-vendor.js): a future
157
+ // npm-pack output shape change that drops or renames `size` must not turn the
158
+ // size budget into a no-op.
159
+ if (typeof packInfo.size !== "number") {
160
+ issues.push(`npm pack output missing numeric size — cannot enforce size budget`);
161
+ } else if (packInfo.size > SIZE_BUDGET_BYTES) {
155
162
  issues.push(`tarball size ${(packInfo.size / 1024 / 1024).toFixed(2)} MB exceeds budget ${(SIZE_BUDGET_BYTES / 1024 / 1024).toFixed(0)} MB`);
156
163
  }
157
164
 
@@ -129,6 +129,25 @@ function typeOf(value) {
129
129
  return typeof value;
130
130
  }
131
131
 
132
+ // Strict ISO calendar-date check for format:date fields. The shape regex alone
133
+ // accepts impossible dates — `new Date('2026-02-30T00:00:00Z')` does NOT throw,
134
+ // it silently rolls over to March 2 and reports a valid getTime() — so a
135
+ // malformed _meta.last_threat_review or changelog `date` (2026-02-30,
136
+ // 2026-13-40) would pass with only the YYYY-MM-DD shape test. Require the
137
+ // parsed Y-M-D to round-trip back to the input components, mirroring
138
+ // parseIsoDateStrict in lib/validate-catalog-meta.js.
139
+ function isStrictIsoDate(value) {
140
+ if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
141
+ const d = new Date(value + 'T00:00:00Z');
142
+ if (Number.isNaN(d.getTime())) return false;
143
+ const [y, m, day] = value.split('-').map(Number);
144
+ return (
145
+ d.getUTCFullYear() === y &&
146
+ d.getUTCMonth() + 1 === m &&
147
+ d.getUTCDate() === day
148
+ );
149
+ }
150
+
132
151
  function typeMatches(value, expected) {
133
152
  if (Array.isArray(expected)) return expected.some((t) => typeMatches(value, t));
134
153
  const actual = typeOf(value);
@@ -185,7 +204,7 @@ function validate(value, schema, schemaName, pathStr) {
185
204
  }
186
205
  }
187
206
  if (schema.format === 'date') {
188
- if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
207
+ if (!isStrictIsoDate(value)) {
189
208
  err(`${here}: value ${JSON.stringify(value)} is not an ISO date (YYYY-MM-DD)`);
190
209
  }
191
210
  }
@@ -669,6 +688,21 @@ function checkCrossRefs(playbook, ctx, playbookIds) {
669
688
  // the source is the base phase or a directive override.
670
689
  function checkRwepThreshold(rwepObj, pathPrefix) {
671
690
  const rwep = rwepObj || {};
691
+ // Range check is PER-KEY and independent of the others. A directive's
692
+ // phase_overrides.direct.rwep_threshold is a partial fragment the runner
693
+ // deep-merges, so it may carry only one of the three keys — gating the
694
+ // whole check on all-three-present let an out-of-range single-key override
695
+ // (e.g. {escalate: 150} or {close: -5}) ship unchecked and reach the
696
+ // runner. Validate each present numeric key on its own.
697
+ for (const k of ['close', 'monitor', 'escalate']) {
698
+ const v = rwep[k];
699
+ if (typeof v === 'number' && (v < 0 || v > 100)) {
700
+ err(`${pathPrefix}.${k}: ${v} outside 0..100`);
701
+ }
702
+ }
703
+ // Ordering is only meaningful when the full triple is present (a partial
704
+ // override inherits the missing edges from the base phase, so close <=
705
+ // monitor <= escalate can't be evaluated on the fragment alone).
672
706
  if (
673
707
  typeof rwep.close === 'number' &&
674
708
  typeof rwep.monitor === 'number' &&
@@ -679,15 +713,6 @@ function checkCrossRefs(playbook, ctx, playbookIds) {
679
713
  `${pathPrefix}: ordering violation — expected close <= monitor <= escalate, got close=${rwep.close} monitor=${rwep.monitor} escalate=${rwep.escalate}`,
680
714
  );
681
715
  }
682
- for (const [k, v] of [
683
- ['close', rwep.close],
684
- ['monitor', rwep.monitor],
685
- ['escalate', rwep.escalate],
686
- ]) {
687
- if (v < 0 || v > 100) {
688
- err(`${pathPrefix}.${k}: ${v} outside 0..100`);
689
- }
690
- }
691
716
  }
692
717
  }
693
718
 
@@ -111,17 +111,23 @@ function main() {
111
111
  }
112
112
 
113
113
  // On-disk inventory cross-check (BJS-04): the loop above is one-directional
114
- // (manifest → disk). A .js file PRESENT in vendor/blamejs/ but ABSENT from
115
- // _PROVENANCE.json would ship in the tarball and be require()'d by lib code
116
- // while NOTHING verifies its integrity — an unregistered or smuggled-in
117
- // vendored module. Flag every on-disk .js that the manifest does not record.
114
+ // (manifest → disk). A loadable JS module PRESENT in vendor/blamejs/ but
115
+ // ABSENT from _PROVENANCE.json would ship in the tarball and be require()'d
116
+ // by lib code while NOTHING verifies its integrity — an unregistered or
117
+ // smuggled-in vendored module. Node executes `.js`, `.cjs`, and `.mjs` as
118
+ // code, so a `.cjs`/`.mjs` smuggled module carries the same arbitrary-code
119
+ // risk as a `.js` one — checking only `.js` let a renamed extension slip the
120
+ // gate. `.json`/`.node` are deliberately NOT swept here: `_PROVENANCE.json`
121
+ // itself lives in this directory and is the manifest, not a registered
122
+ // vendored module, so sweeping `.json` would false-positive on the manifest.
123
+ const LOADABLE_MODULE = /\.(c|m)?js$/;
118
124
  const registered = new Set(
119
125
  Object.values(prov.files || {}).map((info) => path.basename(info.vendored_path))
120
126
  );
121
127
  const vendorDir = path.join(ROOT, "vendor", "blamejs");
122
128
  try {
123
129
  for (const f of fs.readdirSync(vendorDir)) {
124
- if (!f.endsWith(".js")) continue;
130
+ if (!LOADABLE_MODULE.test(f)) continue;
125
131
  if (!registered.has(f)) {
126
132
  issues.push(`unregistered vendored file: vendor/blamejs/${f} is on disk but absent from _PROVENANCE.json — its integrity is not verified`);
127
133
  }
@@ -400,6 +400,7 @@ function parseFeedDetailed(xml) {
400
400
  let activeField = null; // active field local-name
401
401
  let buffer = ""; // accumulator for current field text
402
402
  let linkRank = 0; // rel-rank of the best <link> captured so far
403
+ let linkRel = null; // rel of the link element currently open (for its element-text close)
403
404
 
404
405
  tokenize(xml, {
405
406
  rawTextElements: LEAF_FIELDS,
@@ -413,6 +414,10 @@ function parseFeedDetailed(xml) {
413
414
  if (FIELD_MAP[name]) {
414
415
  activeField = FIELD_MAP[name];
415
416
  buffer = "";
417
+ // Remember this link element's rel so its element-text close (below) is
418
+ // ranked the same way the attribute path is — a non-alternate link with
419
+ // text must not clobber a captured rel=alternate.
420
+ if (name === "link") linkRel = attrs ? attrs.rel : null;
416
421
  // Atom <link href="..."/> — rel-aware selection. Only let an
417
422
  // alternate / rel-absent link upgrade the captured value, and never
418
423
  // let a non-alternate rel (self/replies/edit) clobber an alternate
@@ -442,14 +447,19 @@ function parseFeedDetailed(xml) {
442
447
  if (!current) return;
443
448
  if (FIELD_MAP[name] && activeField === FIELD_MAP[name]) {
444
449
  const value = buffer.trim();
445
- // RSS <link>...</link> — element text is authoritative (rank 2),
446
- // unconditional. An empty element-text link falls back to whatever
447
- // attribute capture onTagOpen already recorded.
450
+ // <link>...</link> element text — ranked by the link's rel, the SAME
451
+ // gate the attribute path uses, so a non-alternate (self/replies/edit)
452
+ // link with text cannot clobber a captured rel=alternate. RSS links
453
+ // carry no rel, and relRank(undefined) === 2, so RSS element-text links
454
+ // keep their previous authoritative rank. An empty element-text link
455
+ // falls back to whatever attribute capture onTagOpen already recorded.
448
456
  if (name === "link") {
449
- if (value) {
457
+ const r = relRank(linkRel);
458
+ if (value && r > linkRank) {
450
459
  current.link = value;
451
- linkRank = 2;
460
+ linkRank = r;
452
461
  }
462
+ linkRel = null;
453
463
  } else if (activeField === "body" || activeField === "title") {
454
464
  // Strip HTML tags from title + description / content / summary.
455
465
  // Many feeds embed inline HTML (<b>, <em>, <a>) in titles for
@@ -498,9 +508,27 @@ function parseFeed(xml, errors = null) {
498
508
 
499
509
  function stripHtml(s) {
500
510
  if (typeof s !== "string") return "";
501
- // First strip tags, then collapse runs of whitespace including
502
- // newlines. Entity decoding has already happened by this point.
503
- return s.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
511
+ // Single-pass tag strip. A backtracking /<[^>]+>/g is O(n^2) on
512
+ // attacker-controlled feed text with many '<' and no '>' (a ~150 KB
513
+ // <title>/<description> of bare '<' froze the refresh worker for seconds —
514
+ // a ReDoS-class DoS, since parseFeed() runs on network-fetched RSS/Atom
515
+ // bodies). Walk '<'..'>' instead, preserving the prior contract exactly: a
516
+ // real `<...>` becomes a space; a stray '<' with no closing '>' and an empty
517
+ // '<>' survive as literal text (the `[^>]+` quantifier required >=1 char, so
518
+ // neither matched). Then collapse whitespace as before.
519
+ let out = "";
520
+ let i = 0;
521
+ while (i < s.length) {
522
+ const lt = s.indexOf("<", i);
523
+ if (lt === -1) { out += s.slice(i); break; }
524
+ out += s.slice(i, lt);
525
+ const gt = s.indexOf(">", lt + 1);
526
+ if (gt === -1) { out += s.slice(lt); break; } // stray '<' -> literal
527
+ if (gt === lt + 1) { out += "<>"; i = gt + 1; continue; } // empty '<>' is not a tag
528
+ out += " "; // a real tag -> space
529
+ i = gt + 1;
530
+ }
531
+ return out.replace(/\s+/g, " ").trim();
504
532
  }
505
533
 
506
534
  module.exports = { tokenize, parseFeed, parseFeedDetailed, decodeEntities, localName, parseAttrs, stripHtml };