@blamejs/exceptd-skills 0.18.6 → 0.18.8

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.
Files changed (63) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/bin/exceptd.js +364 -119
  3. package/data/_indexes/_meta.json +22 -3
  4. package/data/cve-catalog.json +25 -0
  5. package/data/playbooks/framework.json +2 -2
  6. package/data/playbooks/post-quantum-migration.json +1 -1
  7. package/lib/auto-discovery.js +30 -10
  8. package/lib/collectors/ai-api.js +9 -2
  9. package/lib/collectors/cicd-pipeline-compromise.js +24 -5
  10. package/lib/collectors/cred-stores.js +17 -4
  11. package/lib/collectors/crypto.js +9 -2
  12. package/lib/collectors/hardening.js +9 -2
  13. package/lib/collectors/library-author.js +29 -5
  14. package/lib/collectors/mcp.js +9 -2
  15. package/lib/collectors/runtime.js +9 -2
  16. package/lib/collectors/sbom.js +28 -15
  17. package/lib/collectors/scan-excludes.js +25 -0
  18. package/lib/collectors/secrets.js +40 -4
  19. package/lib/cve-curation.js +84 -8
  20. package/lib/lint-skills.js +75 -3
  21. package/lib/playbook-runner.js +443 -50
  22. package/lib/prefetch.js +22 -2
  23. package/lib/refresh-external.js +32 -1
  24. package/lib/refresh-network.js +235 -26
  25. package/lib/schemas/cve-catalog.schema.json +5 -0
  26. package/lib/scoring.js +141 -21
  27. package/lib/sign.js +107 -29
  28. package/lib/source-advisories.js +23 -5
  29. package/lib/source-ghsa.js +25 -1
  30. package/lib/source-osv.js +26 -1
  31. package/lib/upstream-check.js +1 -1
  32. package/lib/validate-cve-catalog.js +30 -4
  33. package/lib/validate-indexes.js +135 -29
  34. package/lib/validate-playbooks.js +19 -7
  35. package/lib/validate-vendor.js +69 -8
  36. package/lib/verify.js +23 -6
  37. package/manifest.json +53 -53
  38. package/orchestrator/dispatcher.js +14 -3
  39. package/orchestrator/index.js +100 -20
  40. package/orchestrator/scanner.js +8 -0
  41. package/package.json +1 -1
  42. package/sbom.cdx.json +130 -130
  43. package/scripts/audit-cross-skill.js +1 -1
  44. package/scripts/bootstrap.js +1 -0
  45. package/scripts/build-indexes.js +84 -11
  46. package/scripts/check-agents-md-collectors.js +41 -13
  47. package/scripts/check-changelog-extract.js +4 -4
  48. package/scripts/check-codebase-patterns.js +19 -5
  49. package/scripts/check-manifest-snapshot.js +74 -30
  50. package/scripts/check-sbom-currency.js +25 -5
  51. package/scripts/check-test-count.js +26 -7
  52. package/scripts/check-test-coverage.js +44 -4
  53. package/scripts/check-version-tags.js +27 -8
  54. package/scripts/predeploy.js +1 -1
  55. package/scripts/refresh-manifest-snapshot.js +14 -4
  56. package/scripts/refresh-reverse-refs.js +7 -1
  57. package/scripts/refresh-sbom.js +1 -1
  58. package/scripts/release.js +3 -3
  59. package/scripts/run-e2e-scenarios.js +18 -8
  60. package/scripts/validate-vendor-online.js +28 -2
  61. package/scripts/verify-shipped-tarball.js +65 -6
  62. package/sources/validators/cve-validator.js +17 -1
  63. package/vendor/blamejs/_PROVENANCE.json +4 -2
@@ -28,6 +28,7 @@ const { currencyCheck, initPipeline } = require('./pipeline');
28
28
  const { bus, EVENT_TYPES } = require('./event-bus');
29
29
  const { start: startScheduler, stop: stopScheduler, runCurrencyNow } = require('./scheduler');
30
30
  const { EXIT_CODES, safeExit } = require('../lib/exit-codes');
31
+ const { withRetry } = require('../vendor/blamejs/retry.js');
31
32
 
32
33
  const cmd = process.argv[2];
33
34
  const args = process.argv.slice(3);
@@ -557,14 +558,34 @@ async function runReport(format) {
557
558
  revision_history: [{ number: '1', date: new Date().toISOString(), summary: 'Initial report emission' }],
558
559
  },
559
560
  },
561
+ // CSAF vulnerabilities[] are CVE-scoped by spec; non-CVE findings (signal
562
+ // detections without a catalogued CVE) are preserved in
563
+ // exceptd_extension.scan_summary below, not dropped.
560
564
  vulnerabilities: scanResult.findings
561
565
  .filter(f => f.cve_id)
562
- .map(f => ({
563
- cve: f.cve_id,
564
- notes: [{ category: 'description', text: f.action_required || f.signal }],
565
- scores: [{ products: [], cvss_v3: { base_score: 0 } }],
566
- threats: f.severity === 'critical' ? [{ category: 'exploit_status', details: f.action_required }] : [],
567
- })),
566
+ .map(f => {
567
+ const vuln = {
568
+ cve: f.cve_id,
569
+ // Never emit a null description/threat detail — the CSAF schema
570
+ // requires non-empty strings, so fall back through signal to a
571
+ // generic label.
572
+ notes: [{ category: 'description', text: f.action_required || f.signal || 'Vulnerability detected' }],
573
+ threats: f.severity === 'critical'
574
+ ? [{ category: 'exploit_status', details: f.action_required || f.signal || 'Critical vulnerability detected' }]
575
+ : [],
576
+ };
577
+ // Emit the REAL catalog CVSS, never a hardcoded base_score:0 (which
578
+ // reads as "no impact" and inverts the risk for a critical CVE).
579
+ // Mirror the playbook-runner CSAF emitter: a cvss_v3 block carries
580
+ // vectorString (CSAF §3.2.1.5), so emit it only when both the score
581
+ // and vector are known, and omit scores entirely otherwise rather
582
+ // than fabricate one.
583
+ if (typeof f.cvss_score === 'number' && Number.isFinite(f.cvss_score) &&
584
+ typeof f.cvss_vector === 'string' && f.cvss_vector) {
585
+ vuln.scores = [{ products: [], cvss_v3: { baseScore: f.cvss_score, vectorString: f.cvss_vector } }];
586
+ }
587
+ return vuln;
588
+ }),
568
589
  exceptd_extension: {
569
590
  scan_summary: scanResult.summary,
570
591
  dispatch_plan: plan,
@@ -685,7 +706,11 @@ function _acquireWatchLock() {
685
706
  const STALE_MS = 60_000;
686
707
 
687
708
  function tryCreate() {
688
- const fd = fsMod.openSync(lockPath, 'wx');
709
+ // O_EXCL create with an explicit owner-only (0o600) mode: the predictable
710
+ // lock-file name is the cross-process mutex, so safety is the exclusive
711
+ // create + restrictive perms, not an unguessable name. The explicit mode
712
+ // also clears the insecure-temporary-file static-analysis finding.
713
+ const fd = fsMod.openSync(lockPath, 'wx', 0o600);
689
714
  fsMod.writeSync(fd, JSON.stringify({ pid: process.pid, started_at: new Date().toISOString() }));
690
715
  fsMod.closeSync(fd);
691
716
  }
@@ -1697,18 +1722,26 @@ async function validateAllCvesPreferCache(catalog, cacheDir) {
1697
1722
  function readCached(source, id) {
1698
1723
  const safe = id.replace(/[^A-Za-z0-9._-]/g, '_');
1699
1724
  const p = path.join(cacheDir, source, `${safe}.json`);
1700
- if (!fs.existsSync(p)) return null;
1725
+ let fd;
1726
+ // Open once and fstat the descriptor — no existsSync→statSync→read TOCTOU.
1727
+ try { fd = fs.openSync(p, 'r'); }
1728
+ catch { return null; } // absent / unreadable (matches the old existsSync:false skip)
1701
1729
  try {
1702
- const st = fs.statSync(p);
1730
+ const st = fs.fstatSync(fd);
1703
1731
  if (st.size > CACHE_FILE_MAX_BYTES) {
1704
1732
  console.error(`[validate-cves] cache file ${p} exceeds ${CACHE_FILE_MAX_BYTES} byte cap (${st.size}); refusing to read.`);
1705
1733
  return null;
1706
1734
  }
1707
- const parsed = JSON.parse(fs.readFileSync(p, 'utf8'));
1735
+ // readFileSync(fd) loops read() to EOF — a single readSync may return
1736
+ // fewer than st.size bytes on network/FUSE/sync-backed fds, leaving the
1737
+ // tail NUL-filled and truncating the JSON. Reading via the open fd keeps
1738
+ // the open→fstat ordering TOCTOU-free.
1739
+ const parsed = JSON.parse(fs.readFileSync(fd, 'utf8'));
1708
1740
  if (!cacheEntryVerified(source, id, parsed)) return null;
1709
1741
  return parsed;
1710
1742
  }
1711
1743
  catch { return null; }
1744
+ finally { if (fd !== undefined) { try { fs.closeSync(fd); } catch { /* non-fatal */ } } }
1712
1745
  }
1713
1746
 
1714
1747
  function extractNvd(payload) {
@@ -1990,21 +2023,49 @@ async function runWatchlistOrgScan(rawArgs = []) {
1990
2023
  safeExit(EXIT_CODES.GENERIC_FAILURE);
1991
2024
  return;
1992
2025
  }
2026
+ // Patterns whose query exhausted retries on a transient failure. Surfaced
2027
+ // in the envelope so a dropped pattern is observable rather than reading as
2028
+ // a clean zero — the false-negative-on-transient class (a flaky 5xx /
2029
+ // reset / timeout silently yielding "no matches found" for a threat-actor
2030
+ // naming pattern) is exactly what NEW-CTRL-052 exists to defend against.
2031
+ const erroredPatterns = [];
2032
+ // A 5xx / timeout / reset is retried with backoff + jitter (mirrors
2033
+ // lib/source-advisories.js#fetchFeed); a permanent 4xx other than the
2034
+ // rate-limit codes is not. 403/429 short-circuit to the rateLimited flag.
2035
+ const retryable = (err) => {
2036
+ // 403/429 are the documented rate-limit signal — surface them at once
2037
+ // (preserving the prior continue-on-rate-limit semantics) rather than
2038
+ // burning the retry budget; the operator re-runs with GITHUB_TOKEN set.
2039
+ if (err && err._rateLimit) return false;
2040
+ if (err && typeof err.statusCode === 'number') return err.statusCode >= 500;
2041
+ if (err && (err.name === 'AbortError' || err.name === 'TimeoutError')) return true;
2042
+ const code = err && (err.code || (err.cause && err.cause.code));
2043
+ return !!code && /^(ECONNRESET|ECONNREFUSED|ECONNABORTED|ETIMEDOUT|EPIPE|EAGAIN|ENOTFOUND|ENETUNREACH|UND_ERR)/.test(String(code));
2044
+ };
1993
2045
  for (const p of patterns) {
1994
2046
  const url = `https://api.github.com/search/repositories?q=${encodeURIComponent(p.q + ' org:' + org)}&per_page=100`;
1995
2047
  const headers = { 'User-Agent': 'exceptd-watchlist-org-scan/0.13.3 (+https://exceptd.com)', 'Accept': 'application/vnd.github+json' };
1996
2048
  if (token) headers.Authorization = `Bearer ${token}`;
1997
- try {
2049
+ // Each attempt gets a fresh abort timer.
2050
+ const attempt = async () => {
1998
2051
  const ac = new AbortController();
1999
2052
  const timer = setTimeout(() => ac.abort(), 10000);
2000
- const r = await fetch(url, { headers, signal: ac.signal });
2001
- clearTimeout(timer);
2002
- if (r.status === 403 || r.status === 429) {
2003
- rateLimited = true;
2004
- continue;
2053
+ try {
2054
+ const r = await fetch(url, { headers, signal: ac.signal });
2055
+ if (r.status === 403 || r.status === 429) {
2056
+ const e = new Error(`HTTP ${r.status}`);
2057
+ e.statusCode = r.status;
2058
+ e._rateLimit = true; // surfaced as rateLimited, never retried
2059
+ throw e;
2060
+ }
2061
+ if (!r.ok) { const e = new Error(`HTTP ${r.status}`); e.statusCode = r.status; throw e; }
2062
+ return await r.json();
2063
+ } finally {
2064
+ clearTimeout(timer);
2005
2065
  }
2006
- if (!r.ok) continue;
2007
- const body = await r.json();
2066
+ };
2067
+ try {
2068
+ const body = await withRetry(attempt, { maxAttempts: 3, baseDelayMs: 100, maxDelayMs: 2000, jitterFactor: 0.5, isRetryable: retryable });
2008
2069
  for (const item of body.items || []) {
2009
2070
  matches.push({
2010
2071
  pattern_id: p.id,
@@ -2018,12 +2079,23 @@ async function runWatchlistOrgScan(rawArgs = []) {
2018
2079
  stars: item.stargazers_count || 0,
2019
2080
  });
2020
2081
  }
2021
- } catch { /* network failure — best effort */ }
2082
+ } catch (err) {
2083
+ // 403/429 (incl. after retries) is the documented rate-limit signal.
2084
+ // Any other exhausted-retry failure marks the pattern errored so the
2085
+ // operator can distinguish a dropped query from a clean no-match.
2086
+ if (err && err._rateLimit) {
2087
+ rateLimited = true;
2088
+ } else {
2089
+ erroredPatterns.push({ pattern_id: p.id, error: (err && err.message) || String(err) });
2090
+ }
2091
+ }
2022
2092
  }
2023
2093
  const generated_at = new Date().toISOString();
2024
2094
  if (outputFormat === 'json') {
2025
2095
  process.stdout.write(JSON.stringify({
2026
- ok: !rateLimited,
2096
+ // A rate-limited OR errored pattern means the scan is incomplete — a
2097
+ // zero match-count is no longer trustworthy as "clean".
2098
+ ok: !rateLimited && erroredPatterns.length === 0,
2027
2099
  verb: 'watchlist',
2028
2100
  mode: 'org-scan',
2029
2101
  generated_at,
@@ -2032,6 +2104,7 @@ async function runWatchlistOrgScan(rawArgs = []) {
2032
2104
  match_count: matches.length,
2033
2105
  matches,
2034
2106
  rate_limited: rateLimited,
2107
+ errored_patterns: erroredPatterns,
2035
2108
  unauthenticated: unauth,
2036
2109
  control_reference: 'NEW-CTRL-052 (MAL-2026-SHAI-HULUD-OSS lesson)',
2037
2110
  }) + '\n');
@@ -2055,6 +2128,10 @@ async function runWatchlistOrgScan(rawArgs = []) {
2055
2128
  lines.push('> **Warning:** GitHub rate limit hit on at least one pattern query. Some matches may be missing. Re-run with `GITHUB_TOKEN` set to lift the limit.');
2056
2129
  lines.push('');
2057
2130
  }
2131
+ if (erroredPatterns.length > 0) {
2132
+ lines.push(`> **Warning:** ${erroredPatterns.length} pattern quer${erroredPatterns.length === 1 ? 'y' : 'ies'} failed after retries (${erroredPatterns.map((e) => e.pattern_id).join(', ')}). A zero match-count for ${erroredPatterns.length === 1 ? 'that pattern' : 'those patterns'} is NOT a clean result — re-run to confirm.`);
2133
+ lines.push('');
2134
+ }
2058
2135
  if (matches.length === 0) {
2059
2136
  lines.push('_No repositories matching threat-actor patterns found._');
2060
2137
  } else {
@@ -2076,6 +2153,9 @@ async function runWatchlistOrgScan(rawArgs = []) {
2076
2153
  console.log(`Org: ${org} patterns: ${patterns.length} matches: ${matches.length}`);
2077
2154
  if (unauth) console.log('(unauthenticated — set GITHUB_TOKEN for private-repo coverage + higher rate limit)');
2078
2155
  if (rateLimited) console.log('WARNING: GitHub rate limit hit on at least one query. Re-run with GITHUB_TOKEN set.');
2156
+ if (erroredPatterns.length > 0) {
2157
+ console.log(`WARNING: ${erroredPatterns.length} pattern quer${erroredPatterns.length === 1 ? 'y' : 'ies'} failed after retries (${erroredPatterns.map((e) => e.pattern_id).join(', ')}). A zero match-count for those is NOT a clean result — re-run to confirm.`);
2158
+ }
2079
2159
  if (matches.length === 0) {
2080
2160
  console.log('No threat-actor-pattern repos found.');
2081
2161
  return;
@@ -89,6 +89,10 @@ function kernelScan() {
89
89
  value: kernel,
90
90
  cve_id: cveId,
91
91
  rwep_score: cve.rwep_score,
92
+ // Carry the catalog CVSS so the CSAF report emits a real cvss_v3 block
93
+ // (base score + vector) instead of a placeholder base_score:0.
94
+ cvss_score: cve.cvss_score,
95
+ cvss_vector: cve.cvss_vector,
92
96
  cisa_kev: cve.cisa_kev,
93
97
  action_required: 'Cross-reference kernel version against patched version for this CVE',
94
98
  skill_hint: 'kernel-lpe-triage',
@@ -158,6 +162,10 @@ function mcpScan() {
158
162
  tool,
159
163
  config_path: p,
160
164
  severity: 'low',
165
+ // Route directly like the successful mcp_server_detected finding, so a
166
+ // parse-error finding reaches mcp-agent-trust even if the domain
167
+ // routing table changes — domain fallback alone left it brittle.
168
+ skill_hint: 'mcp-agent-trust',
161
169
  action_required: 'MCP config file exists but could not be parsed'
162
170
  });
163
171
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/exceptd-skills",
3
- "version": "0.18.6",
3
+ "version": "0.18.8",
4
4
  "description": "AI security skills grounded in mid-2026 threat reality, not stale framework documentation. 51 skills, 11 catalogs (459 CVEs / 181 CWEs / 805 ATT&CK + ICS / 170 ATLAS / 468 D3FEND / 8888 RFCs), 35 jurisdictions, 10-class catalog gap detector + budget gate, real XML parser + canonical-form diff + content-pattern regression detection, Ed25519-signed.",
5
5
  "keywords": [
6
6
  "ai-security",