@blamejs/exceptd-skills 0.19.33 → 0.19.34

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 (119) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/bin/exceptd.js +896 -2824
  3. package/data/_indexes/_meta.json +2 -2
  4. package/lib/auto-discovery.js +56 -286
  5. package/lib/canonical-eq.js +7 -40
  6. package/lib/citation-resolve.js +22 -70
  7. package/lib/collectors/ai-api.js +20 -54
  8. package/lib/collectors/cicd-pipeline-compromise.js +40 -108
  9. package/lib/collectors/citation-hygiene.js +72 -210
  10. package/lib/collectors/containers.js +41 -130
  11. package/lib/collectors/cred-stores.js +31 -115
  12. package/lib/collectors/crypto-codebase.js +55 -138
  13. package/lib/collectors/crypto.js +24 -54
  14. package/lib/collectors/hardening.js +20 -78
  15. package/lib/collectors/kernel.js +16 -46
  16. package/lib/collectors/library-author.js +57 -206
  17. package/lib/collectors/mcp.js +24 -70
  18. package/lib/collectors/runtime.js +24 -86
  19. package/lib/collectors/sbom.js +34 -106
  20. package/lib/collectors/scan-excludes.js +31 -138
  21. package/lib/collectors/secrets.js +62 -178
  22. package/lib/cross-ref-api.js +39 -123
  23. package/lib/currency-severity.js +8 -27
  24. package/lib/cve-batch.js +13 -21
  25. package/lib/cve-cli.js +13 -20
  26. package/lib/cve-curation.js +72 -239
  27. package/lib/cve-regression-watcher.js +29 -152
  28. package/lib/cvss.js +13 -54
  29. package/lib/doctor-bucketing.js +3 -19
  30. package/lib/exit-codes.js +10 -42
  31. package/lib/flag-suggest.js +7 -25
  32. package/lib/framework-gap.js +35 -114
  33. package/lib/gap-detectors.js +37 -159
  34. package/lib/id-validation.js +9 -30
  35. package/lib/job-queue.js +13 -36
  36. package/lib/lint-skills.js +64 -232
  37. package/lib/playbook-runner.js +693 -2095
  38. package/lib/prefetch.js +100 -376
  39. package/lib/refresh-external.js +199 -627
  40. package/lib/refresh-network.js +75 -307
  41. package/lib/rfc-cli.js +23 -68
  42. package/lib/scoring.js +77 -145
  43. package/lib/sign.js +43 -229
  44. package/lib/source-advisories.js +43 -194
  45. package/lib/source-ghsa.js +37 -120
  46. package/lib/source-osv.js +94 -266
  47. package/lib/ttp-mapper.js +14 -24
  48. package/lib/upstream-check-cli.js +10 -28
  49. package/lib/upstream-check.js +19 -44
  50. package/lib/validate-catalog-meta.js +17 -61
  51. package/lib/validate-cve-catalog.js +43 -119
  52. package/lib/validate-indexes.js +25 -76
  53. package/lib/validate-package.js +16 -62
  54. package/lib/validate-playbooks.js +69 -275
  55. package/lib/validate-vendor.js +16 -49
  56. package/lib/verify.js +56 -286
  57. package/lib/version-pins.js +5 -34
  58. package/lib/worker-pool.js +11 -30
  59. package/lib/xml-tokenizer.js +47 -152
  60. package/manifest.json +53 -53
  61. package/orchestrator/dispatcher.js +17 -68
  62. package/orchestrator/event-bus.js +11 -74
  63. package/orchestrator/index.js +138 -412
  64. package/orchestrator/pipeline.js +28 -85
  65. package/orchestrator/scanner.js +34 -138
  66. package/orchestrator/scheduler.js +20 -84
  67. package/package.json +1 -1
  68. package/sbom.cdx.json +241 -241
  69. package/scripts/audit-catalog-gaps.js +9 -62
  70. package/scripts/audit-cross-skill.js +5 -31
  71. package/scripts/audit-perf.js +6 -16
  72. package/scripts/backfill-theater-test.js +7 -64
  73. package/scripts/bootstrap.js +12 -44
  74. package/scripts/build-indexes.js +40 -154
  75. package/scripts/builders/activity-feed.js +4 -14
  76. package/scripts/builders/catalog-summaries.js +3 -10
  77. package/scripts/builders/currency.js +7 -20
  78. package/scripts/builders/cwe-chains.js +7 -30
  79. package/scripts/builders/did-ladders.js +6 -13
  80. package/scripts/builders/frequency.js +5 -19
  81. package/scripts/builders/jurisdiction-clocks.js +6 -25
  82. package/scripts/builders/recipes.js +6 -14
  83. package/scripts/builders/section-offsets.js +13 -51
  84. package/scripts/builders/stale-content.js +7 -28
  85. package/scripts/builders/summary-cards.js +8 -29
  86. package/scripts/builders/theater-fingerprints.js +12 -27
  87. package/scripts/builders/token-budget.js +4 -31
  88. package/scripts/check-agents-md-collectors.js +11 -54
  89. package/scripts/check-catalog-gap-budget.js +15 -32
  90. package/scripts/check-changelog-extract.js +18 -48
  91. package/scripts/check-codebase-patterns-currency.js +6 -22
  92. package/scripts/check-codebase-patterns.js +50 -143
  93. package/scripts/check-epss-consistency.js +9 -64
  94. package/scripts/check-framework-gap-coverage.js +13 -31
  95. package/scripts/check-manifest-snapshot.js +13 -73
  96. package/scripts/check-sbom-currency.js +44 -142
  97. package/scripts/check-test-count.js +15 -52
  98. package/scripts/check-test-coverage.js +66 -197
  99. package/scripts/check-test-subjects.js +21 -62
  100. package/scripts/check-ttp-references.js +14 -38
  101. package/scripts/check-ttp-upstream.js +8 -40
  102. package/scripts/check-version-bump.js +9 -61
  103. package/scripts/check-version-tags.js +20 -121
  104. package/scripts/predeploy.js +38 -184
  105. package/scripts/refresh-manifest-snapshot.js +16 -38
  106. package/scripts/refresh-mitre-atlas.js +3 -8
  107. package/scripts/refresh-mitre-attack.js +1 -8
  108. package/scripts/refresh-mitre-d3fend.js +3 -9
  109. package/scripts/refresh-mitre-ics-attack.js +3 -8
  110. package/scripts/refresh-reverse-refs.js +27 -94
  111. package/scripts/refresh-rfc-index.js +2 -10
  112. package/scripts/refresh-sbom.js +31 -161
  113. package/scripts/refresh-upstream-catalogs.js +40 -137
  114. package/scripts/release.js +69 -232
  115. package/scripts/run-e2e-scenarios.js +24 -71
  116. package/scripts/sync-manifest-metadata.js +10 -34
  117. package/scripts/sync-package-description.js +8 -17
  118. package/scripts/validate-vendor-online.js +13 -44
  119. package/scripts/verify-shipped-tarball.js +35 -140
@@ -1,13 +1,9 @@
1
1
  'use strict';
2
2
 
3
3
  /**
4
- * Multi-agent pipeline coordinator.
5
- * Orchestrates: threat-researcher → source-validator → skill-updater → report-generator
6
- *
7
- * This module coordinates agent handoffs using a structured JSON protocol.
8
- * Each stage produces a handoff package that the next stage consumes.
9
- * Agents themselves are defined in agents/ and executed by AI assistants — not by this code.
10
- * This module tracks state, validates handoffs, and routes between stages.
4
+ * Multi-agent pipeline coordinator: threat-researcher → source-validator →
5
+ * skill-updater → report-generator. Tracks state, validates handoffs and routes
6
+ * between stages; the agents live in agents/ and are run by AI assistants.
11
7
  */
12
8
 
13
9
  const fs = require('fs');
@@ -20,8 +16,6 @@ const REPORTS_DIR = path.join(__dirname, '..', 'reports');
20
16
 
21
17
  const PIPELINE_STAGES = ['threat-researcher', 'source-validator', 'skill-updater', 'report-generator'];
22
18
 
23
- // --- public API ---
24
-
25
19
  /**
26
20
  * Initialize a new pipeline run for a given trigger.
27
21
  *
@@ -52,8 +46,7 @@ function initPipeline(triggerType, triggerPayload) {
52
46
  }
53
47
 
54
48
  /**
55
- * Build the handoff package for a specific pipeline stage.
56
- * This is what an AI assistant reads to understand what to do and what to pass forward.
49
+ * Build the handoff package for a pipeline stage — what the next agent reads.
57
50
  *
58
51
  * @param {object} run - Pipeline run object from initPipeline()
59
52
  * @param {number} stageIndex - 0-based stage index
@@ -61,10 +54,8 @@ function initPipeline(triggerType, triggerPayload) {
61
54
  * @returns {object} Handoff package for the next stage
62
55
  */
63
56
  function buildHandoff(run, stageIndex, stageOutput) {
64
- // Bounds-check the stage index up front. Without this, an out-of-range
65
- // index throws an opaque "cannot read properties of undefined" deep inside
66
- // validateHandoff. With it, callers see a structured error that names the
67
- // exact invalid input.
57
+ // Bounds-check up front, else an out-of-range index surfaces as an opaque
58
+ // "cannot read properties of undefined" from inside validateHandoff.
68
59
  if (!run || !Array.isArray(run.stages)) {
69
60
  throw new TypeError('buildHandoff: run.stages must be an array');
70
61
  }
@@ -102,18 +93,8 @@ function buildHandoff(run, stageIndex, stageOutput) {
102
93
  return handoff;
103
94
  }
104
95
 
105
- /**
106
- * Check the currency of all skills and return a report.
107
- * Used by the scheduler for weekly currency checks.
108
- *
109
- * @returns {{ currency_report: object[], action_required: boolean }}
110
- */
111
- // Manifest read cache. currencyCheck() runs on every weekly tick AND on every
112
- // `exceptd currency` invocation; in `watch` mode the scheduler triggers it
113
- // repeatedly. Re-reading + JSON.parse'ing the (~80 KB) manifest each time is
114
- // pure waste when the file hasn't changed within the cache window. 60s TTL is
115
- // short enough that a manual edit during a long-running watcher shows up by
116
- // the next periodic tick.
96
+ // currencyCheck() runs on every weekly tick and every `exceptd currency` call, so
97
+ // the TTL is short enough that a manual manifest edit shows by the next tick.
117
98
  const MANIFEST_CACHE_TTL_MS = 60_000;
118
99
  let _manifestCache = { value: null, mtimeMs: 0, readAt: 0 };
119
100
 
@@ -121,8 +102,7 @@ function _loadManifestCached() {
121
102
  const manifestPath = path.join(__dirname, '..', 'manifest.json');
122
103
  const now = Date.now();
123
104
  if (_manifestCache.value && (now - _manifestCache.readAt) < MANIFEST_CACHE_TTL_MS) {
124
- // Within TTL — return cached value. The cost of a stat() per call is
125
- // ~tens of microseconds; trade it for the JSON.parse() cost saved.
105
+ // Within TTL: one stat() to confirm, in place of a re-parse.
126
106
  try {
127
107
  const st = fs.statSync(manifestPath);
128
108
  if (st.mtimeMs === _manifestCache.mtimeMs) {
@@ -141,25 +121,17 @@ function _loadManifestCached() {
141
121
  }
142
122
 
143
123
  /**
144
- * Compute the currency row for a single skill against a reference `now`.
145
- *
146
- * Pulled out of currencyCheck so the parse-guard is unit-testable without a
147
- * real manifest read. A malformed `last_threat_review` (e.g. "2026-13-99",
148
- * which is structurally ISO-shaped but not a real calendar date) must map to
149
- * maximally STALE — score 0, action_required true — not to the safe-LOOKING
150
- * 100 a NaN delta would otherwise yield. The misformat is also surfaced via
151
- * `unparseable_review_date` so a bad date is OBSERVABLE rather than silently
152
- * defaulting (matching the project rule that a no-match/fall-through path must
153
- * surface, not silently pass).
124
+ * Currency row for a single skill against a reference `now`. A malformed
125
+ * `last_threat_review` maps to maximally STALE — score 0, action_required true,
126
+ * `unparseable_review_date` set — not the safe-LOOKING 100 a NaN delta yields.
154
127
  *
155
128
  * @param {object} skill
156
129
  * @param {Date} now
157
130
  */
158
131
  function _skillCurrencyRow(skill, now) {
159
132
  const rawDate = skill.last_threat_review || '2020-01-01';
160
- // Use the timezone-stable date-only convention the sibling builders use
161
- // (append T00:00:00Z for bare YYYY-MM-DD) so the day delta doesn't shift by
162
- // the runner's local offset.
133
+ // A bare YYYY-MM-DD gets an explicit T00:00:00Z, so the day delta does not
134
+ // shift with the runner's local offset.
163
135
  const t = Date.parse(/^\d{4}-\d{2}-\d{2}$/.test(rawDate) ? rawDate + 'T00:00:00Z' : rawDate);
164
136
  const unparseable = !Number.isFinite(t);
165
137
  const reviewDate = unparseable ? new Date('2020-01-01T00:00:00Z') : new Date(t);
@@ -199,10 +171,8 @@ function currencyCheck() {
199
171
  }
200
172
 
201
173
  /**
202
- * Get the agent definition for a stage.
203
- *
204
- * @param {string} stageName - Agent name
205
- * @returns {string|null} Agent instruction content
174
+ * Reads agents/<stageName>.md.
175
+ * @returns {string|null} Agent instruction content, null when unreadable.
206
176
  */
207
177
  function getAgentDefinition(stageName) {
208
178
  const agentPath = path.join(AGENTS_DIR, `${stageName}.md`);
@@ -213,16 +183,10 @@ function getAgentDefinition(stageName) {
213
183
  }
214
184
  }
215
185
 
216
- // --- private helpers ---
217
-
218
186
  function validateHandoff(stageName, output) {
219
- // Guard the `in` deref below: a null / non-object / array `output` would
220
- // otherwise throw an opaque "Cannot use 'in' operator …" TypeError deep in
221
- // the missing-fields filter. Reject up front with a named error in the same
222
- // style as buildHandoff's stageIndex RangeError, so the caller sees which
223
- // input was wrong. A handoff payload is a keyed object — an array is not a
224
- // valid payload, so Array.isArray is rejected too (today it would yield the
225
- // less-precise "missing fields" error).
187
+ // Guard the `in` deref below: null, a non-object or an array would otherwise
188
+ // throw an opaque "Cannot use 'in' operator". A payload is keyed, so an array
189
+ // is rejected here rather than reported as missing fields.
226
190
  if (output === null || typeof output !== 'object' || Array.isArray(output)) {
227
191
  throw new TypeError(
228
192
  `buildHandoff: stageOutput for ${stageName} must be a non-null object`
@@ -264,26 +228,11 @@ function getStageInstructions(stageName, previousOutput) {
264
228
  }
265
229
 
266
230
  function _currencyScore(daysSinceReview, _forwardWatchCount) {
267
- // Currency = function of last_threat_review age, period. Earlier
268
- // versions subtracted 5 per forward_watch entry, which created a
269
- // perverse incentive: skills that diligently track upcoming threats
270
- // (e.g. cloud-security with 14 forward_watch items) scored 30%
271
- // currency even on the day after a review. forward_watch is a
272
- // signal of ACTIVE maintenance, not staleness, so the count no
273
- // longer affects the score. The arg is retained for ABI compat.
274
- // The penalty schedule must be able to cross the tiers the gate checks
275
- // against (currencyCheck: action_required at < 70, critical_count at < 50;
276
- // _currencyLabel: 'stale' < 70, 'critical_stale' < 50). A schedule whose
277
- // worst penalty was -30 floored the score at 70, so the warn/critical tiers —
278
- // and the workflow issue they gate — could never fire. The deeper penalties
279
- // only bite past 180/270/365 days, so a normally-maintained skill stays
280
- // 'acceptable' while a genuinely abandoned one reaches the gate.
281
- //
282
- // Belt-and-suspenders: a NaN delta (the day-count derived from an
283
- // unparseable last_threat_review) must map to maximally STALE (0), never the
284
- // safe-LOOKING 100 the additive schedule below would leave it at. This is an
285
- // exported surface other callers/tests use, so guard here even though
286
- // _skillCurrencyRow already substitutes a stale fallback date upstream.
231
+ // Age of last_threat_review alone. A forward_watch entry signals ACTIVE
232
+ // maintenance, so the count must not penalise the score; the argument stays for
233
+ // callers. The schedule has to cross the tiers the gate checks — 'stale' below
234
+ // 70, 'critical_stale' below 50 — so a worst penalty of -30 would floor the
235
+ // score at 70 and neither tier could fire. A non-finite delta maps to STALE.
287
236
  if (!Number.isFinite(daysSinceReview)) return 0;
288
237
  let score = 100;
289
238
  if (daysSinceReview > 365) score -= 100; // a year+ unreviewed → 0 (critical_stale)
@@ -302,12 +251,8 @@ function _currencyLabel(score) {
302
251
  return 'critical_stale';
303
252
  }
304
253
 
305
- // Test-only hook to reset the in-memory manifest cache. Not part of the
306
- // operator surface. v0.12.14: moved out of the export object literal so
307
- // the inline { mtimeMs: 0, readAt: 0 } reset values don't get detected
308
- // by scripts/check-test-coverage.js's extractLibExports regex as
309
- // pseudo-exports (`mtimeMs` and `readAt` are private cache fields, not
310
- // module exports).
254
+ // Test-only hook. Defined here rather than inline in the export literal so its
255
+ // reset values are not read as exports by scripts/check-test-coverage.js.
311
256
  function _resetManifestCache() {
312
257
  _manifestCache = { value: null, mtimeMs: 0, readAt: 0 };
313
258
  }
@@ -319,10 +264,8 @@ module.exports = {
319
264
  getAgentDefinition,
320
265
  MANIFEST_CACHE_TTL_MS,
321
266
  _resetManifestCache,
322
- // Exported for the gate-reachability contract test: the schedule must be able
323
- // to reach the warn (< 70) and critical (< 50) tiers the workflow issues on.
267
+ // Exported for the gate-reachability contract test (warn < 70, critical < 50).
324
268
  _currencyScore,
325
- // Exported so the malformed-date staleness guard is unit-testable without a
326
- // real manifest read.
269
+ // Exported so the malformed-date guard is testable without a manifest read.
327
270
  _skillCurrencyRow,
328
271
  };
@@ -1,11 +1,8 @@
1
1
  'use strict';
2
2
 
3
3
  /**
4
- * Environment scanner. Discovers security posture signals from the current host.
5
- * Produces structured findings that dispatcher.js routes to relevant skills.
6
- *
7
- * Designed to be run by a human operator or an AI assistant — not a background daemon.
8
- * All discovery is read-only. No writes, no network calls beyond local probes.
4
+ * Environment scanner: discovers host security-posture signals as structured
5
+ * findings that dispatcher.js routes to skills.
9
6
  */
10
7
 
11
8
  const fs = require('fs');
@@ -15,22 +12,14 @@ const { execFileSync, spawnSync } = require('child_process');
15
12
 
16
13
  const DATA_DIR = process.env.EXCEPTD_DATA_DIR || path.join(__dirname, '..', 'data');
17
14
 
18
- // CLI flags that request air-gap (no-egress) operation. `--air-gap` is the
19
- // documented form; `--offline` / `--no-network` are accepted aliases (the
20
- // orchestrator's flag allowlists accept all three). The flag MUST be honored
21
- // equivalently to EXCEPTD_AIR_GAP=1 on the egress path — historically the env
22
- // var suppressed the outbound TLS probe but the CLI flag did not, so an
23
- // operator passing `--air-gap` still fired a TLS s_client connect.
15
+ // CLI flags requesting air-gap (no-egress) operation. All three must be honored
16
+ // equivalently to EXCEPTD_AIR_GAP=1 on the egress path.
24
17
  const AIR_GAP_FLAGS = ['--air-gap', '--offline', '--no-network'];
25
18
 
26
19
  /**
27
- * Resolve whether air-gap mode is active. True when EXCEPTD_AIR_GAP=1, when an
28
- * explicit `opts.airGap` is passed, OR when any air-gap flag is present on the
29
- * invoking process's argv. Checking argv makes the CLI flag effective even
30
- * though the scanner is reached in-process from the orchestrator entry — the
31
- * flag and the env var are now equivalent on the egress path.
32
- *
33
- * @param {{ airGap?: boolean }} [opts]
20
+ * True for EXCEPTD_AIR_GAP=1, `opts.airGap`, or an air-gap flag on argv. Reading
21
+ * argv is what makes the CLI flag effective, since the scanner is reached
22
+ * in-process from the orchestrator entry.
34
23
  */
35
24
  function isAirGap(opts) {
36
25
  if (opts && opts.airGap) return true;
@@ -39,28 +28,12 @@ function isAirGap(opts) {
39
28
  return argv.some(a => typeof a === 'string' && AIR_GAP_FLAGS.includes(a));
40
29
  }
41
30
 
42
- // --- public API ---
43
-
44
31
  /**
45
- * Run all scanners and return consolidated findings.
46
- *
47
- * DEPRECATED in v0.10.0. This is the pre-seven-phase legacy scanner that
48
- * shells out from Node — kept as a safety-net path for non-AI operators.
49
- * New code should call the playbook runner instead:
50
- *
51
- * const runner = require('../lib/playbook-runner');
52
- * const result = await runner.run('kernel', 'all-catalogued-kernel-cves', agentSubmission);
53
- *
54
- * The runner emits seven-phase findings (govern through close) with full
55
- * GRC closure: CSAF-2.0 evidence bundles, jurisdiction-aware notification
56
- * deadlines, auditor-ready exception language, regression schedules.
57
- * `exceptd scan` will be removed in v1.0.
32
+ * Run all scanners and return consolidated findings. `opts.airGap` suppresses
33
+ * the outbound TLS probe.
58
34
  *
59
- * @param {{ airGap?: boolean }} [opts] - When `opts.airGap` is set, the
60
- * outbound TLS probe is suppressed exactly as EXCEPTD_AIR_GAP=1 does. The
61
- * air-gap CLI flags (--air-gap / --offline / --no-network) are also honored
62
- * directly off process.argv, so the flag is equivalent to the env var.
63
- * @returns {{ timestamp: string, host: object, findings: object[], summary: object }}
35
+ * Deprecated: new code calls lib/playbook-runner, which emits seven-phase
36
+ * findings with full GRC closure; this is a safety net for non-AI operators.
64
37
  */
65
38
  async function scan(opts) {
66
39
  const timestamp = new Date().toISOString();
@@ -83,16 +56,9 @@ async function scan(opts) {
83
56
  findings.push(...frameworkScan());
84
57
 
85
58
  const summary = summarize(findings);
86
- // _deprecation is a stderr-only banner — it MUST NOT appear in the JSON
87
- // shape that downstream consumers ingest. Internal narrative belongs on
88
- // stderr (the banner above), never in the structured result body.
89
59
  return { timestamp, host, findings, summary };
90
60
  }
91
61
 
92
- /**
93
- * Run a targeted scan for a specific domain.
94
- * @param {'kernel'|'mcp'|'crypto'|'ai_api'|'framework'} domain
95
- */
96
62
  async function scanDomain(domain, opts) {
97
63
  const scanners = { kernel: kernelScan, mcp: mcpScan, crypto: cryptoScan, ai_api: aiApiScan, framework: frameworkScan };
98
64
  const fn = scanners[domain];
@@ -100,8 +66,6 @@ async function scanDomain(domain, opts) {
100
66
  return fn(opts);
101
67
  }
102
68
 
103
- // --- domain scanners ---
104
-
105
69
  function kernelScan() {
106
70
  const findings = [];
107
71
  if (os.platform() !== 'linux') return findings;
@@ -117,8 +81,8 @@ function kernelScan() {
117
81
  value: kernel,
118
82
  cve_id: cveId,
119
83
  rwep_score: cve.rwep_score,
120
- // Carry the catalog CVSS so the CSAF report emits a real cvss_v3 block
121
- // (base score + vector) instead of a placeholder base_score:0.
84
+ // Carried so the CSAF report emits a real cvss_v3 block rather than a
85
+ // placeholder base_score:0.
122
86
  cvss_score: cve.cvss_score,
123
87
  cvss_vector: cve.cvss_vector,
124
88
  cisa_kev: cve.cisa_kev,
@@ -161,13 +125,8 @@ function mcpScan() {
161
125
 
162
126
  for (const serverName of serverList) {
163
127
  const server = mcpServers[serverName];
164
- // A null / non-object / array server entry can't be probed for
165
- // signature or pinned-version the way a real server config can.
166
- // Dereferencing its fields would throw inside this per-server loop,
167
- // which the outer catch would mis-report as a whole-file config
168
- // parse error — dropping every sibling server's finding. Emit a
169
- // per-server malformed marker and continue so siblings still scan,
170
- // mirroring how a genuine parse failure is surfaced per-file.
128
+ // A non-object entry would throw into the outer catch, mis-reported as
129
+ // a whole-file parse error that drops every sibling server's finding.
171
130
  if (server === null || typeof server !== 'object' || Array.isArray(server)) {
172
131
  findings.push({
173
132
  domain: 'mcp',
@@ -210,9 +169,8 @@ function mcpScan() {
210
169
  tool,
211
170
  config_path: p,
212
171
  severity: 'low',
213
- // Route directly like the successful mcp_server_detected finding, so a
214
- // parse-error finding reaches mcp-agent-trust even if the domain
215
- // routing table changes — domain fallback alone left it brittle.
172
+ // Routed directly, so the finding reaches mcp-agent-trust whatever the
173
+ // domain routing table does.
216
174
  skill_hint: 'mcp-agent-trust',
217
175
  action_required: 'MCP config file exists but could not be parsed'
218
176
  });
@@ -233,10 +191,7 @@ function cryptoScan(opts) {
233
191
  const [major, minor] = version.split('.').map(Number);
234
192
  const isPqcReady = major > 3 || (major === 3 && minor >= 5);
235
193
 
236
- // Probe the full NIST PQC suite + stateful hash signatures.
237
- // Reports per-algo via boolean flags so downstream callers can
238
- // decide which gaps matter for their threat model (ML-KEM for
239
- // HNDL exposure, LMS/XMSS for firmware signing, etc.).
194
+ // Per-algo flags: the caller decides which gaps matter for its threat model.
240
195
  const pqc = probePqcAlgorithms();
241
196
  const pqcDetail = [
242
197
  `ML-KEM=${pqc.ml_kem ? 'avail' : 'missing'}`, // FIPS 203
@@ -264,12 +219,8 @@ function cryptoScan(opts) {
264
219
  });
265
220
  }
266
221
 
267
- // Air-gap mode short-circuits the outbound TLS probe entirely; emit a
268
- // skipped annotation so operators can see the probe was intentionally
269
- // suppressed rather than failing silently. Honored equivalently for the
270
- // EXCEPTD_AIR_GAP=1 env var AND the --air-gap / --offline / --no-network CLI
271
- // flags (isAirGap inspects opts, env, and process.argv) — the flag is NOT a
272
- // no-op on this egress path.
222
+ // Air-gap short-circuits the outbound TLS probe; the skipped annotation makes
223
+ // the suppression visible rather than a silent absence.
273
224
  if (isAirGap(opts)) {
274
225
  findings.push({
275
226
  domain: 'crypto',
@@ -376,8 +327,6 @@ function frameworkScan() {
376
327
  return findings;
377
328
  }
378
329
 
379
- // --- helpers ---
380
-
381
330
  function hostInfo() {
382
331
  return {
383
332
  platform: os.platform(),
@@ -415,68 +364,24 @@ function safeExecFile(cmd, args) {
415
364
  }
416
365
 
417
366
  /**
418
- * Probe runtime for PQC algorithm availability across the full
419
- * emerging-standards landscape. Returns boolean flags per algorithm
420
- * + a `provider_hint` indicating which surface confirmed each one.
421
- *
422
- * --- NIST PQC finalized (FIPS 203/204/205, 2024) ---
423
- * ml_kem ML-KEM (Kyber) — FIPS 203, key encapsulation
424
- * ml_dsa ML-DSA (Dilithium) — FIPS 204, signatures
425
- * slh_dsa SLH-DSA (SPHINCS+) — FIPS 205, stateless hash sigs
426
- *
427
- * --- NIST PQC draft / alternate (2025+) ---
428
- * fn_dsa FN-DSA (Falcon) — FIPS 206 draft, compact lattice sigs
429
- * hqc HQC — alternate KEM selected March 2025
430
- *
431
- * --- NIST PQC Round-4 alternates (still relevant in niche / archival) ---
432
- * frodo FrodoKEM — conservative lattice KEM
433
- * ntru NTRU / NTRU-Prime / sNTRU — original lattice KEM family
434
- * mceliece Classic McEliece — code-based, long-term archival use
435
- * bike BIKE — code-based KEM, OQS-Provider exposed
436
- *
437
- * --- NIST additional signature on-ramp (2023+ Round 2) ---
438
- * hawk HAWK — NTRU-based lattice signatures
439
- * mayo MAYO — multivariate
440
- * sqisign SQIsign — isogeny-based, small signatures
441
- * cross CROSS — code-based
442
- * uov UOV / SNOVA — Unbalanced Oil & Vinegar multivariate
443
- * sdith SDitH — code-based (Syndrome Decoding in the Head)
444
- * mirath MIRATH — code-based (rank metric)
445
- * faest FAEST — symmetric-key/AES-based signatures
446
- * perk PERK — code-based (Permuted Kernel Problem)
447
- *
448
- * --- Stateful hash signatures (RFC 8391 / 8554) ---
449
- * lms LMS — Leighton-Micali Signatures (firmware)
450
- * xmss XMSS — eXtended Merkle Signature Scheme
451
- * hss HSS — Hierarchical Signature System
452
- *
453
- * --- IETF hybrid / composite sigs (emerging RFC drafts) ---
454
- * composite_sig Composite Signatures (e.g. RSA+ML-DSA, ECDSA+ML-DSA)
455
- * composite_kem Composite KEMs (e.g. X25519+ML-KEM)
367
+ * Probes the runtime for PQC algorithm availability. Returns a boolean flag per
368
+ * algorithm plus a `provider_hint` naming the surface that confirmed each one.
456
369
  */
457
370
  function probePqcAlgorithms() {
458
371
  const result = {
459
- // NIST finalized
460
372
  ml_kem: false, ml_dsa: false, slh_dsa: false,
461
- // NIST draft / alternate
462
373
  fn_dsa: false, hqc: false,
463
- // NIST Round-4 / niche
464
374
  frodo: false, ntru: false, mceliece: false, bike: false,
465
- // NIST signature on-ramp (Round 2)
466
375
  hawk: false, mayo: false, sqisign: false, cross: false,
467
376
  uov: false, sdith: false, mirath: false, faest: false, perk: false,
468
- // Stateful hash sigs
469
377
  lms: false, xmss: false, hss: false,
470
- // IETF composite / hybrid
471
378
  composite_sig: false, composite_kem: false,
472
379
  provider_hint: {},
473
380
  };
474
381
  const crypto = require('crypto');
475
382
 
476
- // Pattern table — values are regexes matching common spellings
477
- // emitted by Node / OpenSSL / OQS-Provider / Bouncy Castle. Tested
478
- // against algorithm-list output specifically (no English-prose
479
- // false positives expected; the lists contain only algo names).
383
+ // Spellings Node / OpenSSL / OQS-Provider / Bouncy Castle emit. Matched only
384
+ // against algorithm-list output, never prose, which would false-positive.
480
385
  const PATTERNS = {
481
386
  // NIST finalized
482
387
  ml_kem: /\b(ml-?kem|kyber)\b/i,
@@ -490,7 +395,7 @@ function probePqcAlgorithms() {
490
395
  ntru: /\b(s?ntru(-?prime)?(-?\d+)?|hrss\d*|hps\d+)\b/i,
491
396
  mceliece: /\b(classic-?)?mceliece(-?\d+)?\b/i,
492
397
  bike: /\bbike(-?l?\d+)?\b/i,
493
- // NIST signature on-ramp (Round 2, 2024+)
398
+ // NIST signature on-ramp (Round 2)
494
399
  hawk: /\bhawk(-?\d+)?\b/i,
495
400
  mayo: /\bmayo(-?\d+)?\b/i,
496
401
  sqisign: /\bsqi-?sign(?:hd)?\b/i,
@@ -516,9 +421,8 @@ function probePqcAlgorithms() {
516
421
  }
517
422
  }
518
423
 
519
- // Channel 1 — Node's crypto APIs (no shellouts). Node 24+ exposes
520
- // crypto.kemEncapsulate as an experimental ML-KEM gateway; its
521
- // presence is itself an ML-KEM availability signal.
424
+ // Channel 1 — Node's crypto APIs, no shellouts. crypto.kemEncapsulate is an
425
+ // experimental ML-KEM gateway, so its presence is itself an ML-KEM signal.
522
426
  try {
523
427
  if (typeof crypto.kemEncapsulate === 'function') {
524
428
  record('ml_kem', 'node:crypto.kemEncapsulate');
@@ -535,9 +439,7 @@ function probePqcAlgorithms() {
535
439
  }
536
440
  } catch (_) { /* probe failure → fall through to openssl */ }
537
441
 
538
- // Channel 2 — `openssl list` enumerations. KEMs and signature
539
- // algorithms split across two list commands; OQS-Provider exposes
540
- // the on-ramp / niche algos when installed.
442
+ // Channel 2 — `openssl list`: KEMs and signatures split across two commands.
541
443
  const kemList = safeExecFile('openssl', ['list', '-kem-algorithms']);
542
444
  if (kemList) {
543
445
  const kemAlgos = ['ml_kem', 'hqc', 'frodo', 'ntru', 'mceliece', 'bike', 'composite_kem'];
@@ -562,8 +464,7 @@ function probePqcAlgorithms() {
562
464
  }
563
465
 
564
466
  function probeTls(target) {
565
- // Target is "host:port"; default lives at the call site so the env var
566
- // is read once per scan rather than baked in here.
467
+ // Target is "host:port"; the call site owns the env-var default.
567
468
  const t = typeof target === 'string' && target.length > 0 ? target : 'registry.npmjs.org:443';
568
469
  const result = spawnSync('openssl', ['s_client', '-connect', t, '-brief'], {
569
470
  input: '',
@@ -576,14 +477,11 @@ function probeTls(target) {
576
477
  return match ? match[1] : null;
577
478
  }
578
479
 
579
- // Key names that signal a credential value, matched case-insensitively at any
580
- // nesting depth. MCP server configs place real secrets inside `env` and
581
- // `headers` sub-objects (e.g. env.OPENAI_API_KEY, headers.Authorization), so a
582
- // top-level-only sweep leaks them into emitted findings.
480
+ // Key names that signal a credential value, matched at any nesting depth: MCP
481
+ // configs put real secrets inside `env` and `headers` sub-objects.
583
482
  const SECRET_KEY_RE = /token|key|secret|password|credential|auth|bearer|api[-_]?key|access[-_]?key/i;
584
483
 
585
- // Value shapes that look like live credentials even under an innocuous key name
586
- // (e.g. args: ['--token', 'sk-...'] or a positional bearer string).
484
+ // Value shapes that look like live credentials under an innocuous key name.
587
485
  const SECRET_VALUE_RES = [
588
486
  /\bsk-[A-Za-z0-9_-]{8,}/, // OpenAI-style keys
589
487
  /\bAKIA[0-9A-Z]{12,}/, // AWS access key id
@@ -599,10 +497,8 @@ function looksLikeSecretValue(value) {
599
497
  return SECRET_VALUE_RES.some((re) => re.test(value));
600
498
  }
601
499
 
602
- // Recursively redact credential-shaped data so nothing emitted to stdout or
603
- // persisted carries an operator's live secrets. Redacts values of secret-named
604
- // keys at any depth and any standalone string value that matches a known
605
- // credential shape. Cycles are guarded with a seen-set.
500
+ // Redacts credential-shaped data at any depth so nothing emitted or persisted
501
+ // carries an operator's live secrets. Cycle-guarded.
606
502
  function redactDeep(value, seen) {
607
503
  if (value === null || typeof value !== 'object') {
608
504
  return looksLikeSecretValue(value) ? '[REDACTED]' : value;