nsauditor-ai 0.2.53 → 0.2.55

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/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "nsauditor-ai",
3
- "version": "0.2.53",
3
+ "version": "0.2.55",
4
4
  "description": "Modular AI-assisted network security audit platform — Community Edition",
5
5
  "type": "module",
6
6
  "private": false,
7
7
  "scripts": {
8
8
  "start": "node cli.mjs",
9
- "test": "node --test",
9
+ "test": "node --test 'tests/**/*.test.mjs'",
10
10
  "mcp": "node mcp_server.mjs"
11
11
  },
12
12
  "bin": {
@@ -26,7 +26,68 @@ import { mapLimit } from './utils/concurrency.mjs';
26
26
  const __filename = fileURLToPath(import.meta.url);
27
27
 
28
28
  const VERBOSE = /^(1|true|yes|on)$/i.test(String(process.env.NSA_VERBOSE || ''));
29
- const PLUGIN_TIMEOUT_MS = Number(process.env.PLUGIN_TIMEOUT_MS || 30000);
29
+ // Exported so a caller that must bind every plugin to the operator's own budget (MCP `scan_host`,
30
+ // `probe_service`) passes THIS value as its wall — never a re-read of the environment or a retyped number.
31
+ export const PLUGIN_TIMEOUT_MS = Number(process.env.PLUGIN_TIMEOUT_MS || 30000);
32
+
33
+ // ── A CALLER'S WALL TRAVELS ON ITS OWN KEY, AND NO PLUGIN EVER RECEIVES IT (EE 1.1.0 build 6) ─────
34
+ // `opts.timeoutMs` belongs to the PLUGINS: twelve network plugins read it as their own discovery
35
+ // window or per-probe timeout (port_scanner, ping_checker, 028, 070 …), and the caller's opts are
36
+ // forwarded into every run. Through CE 0.2.54 the manager ALSO read that key as the caller's wall, so
37
+ // one name meant two things — the cloud path injected its 25 s wall there, harmless only because no
38
+ // cloud plugin reads it, and a wall carried that way on the network path would have become
39
+ // port_scanner's per-probe timeout. The wall is `pluginWallMs` now: the manager reads it here and
40
+ // STRIPS it before any plugin's opts are built. `opts.timeoutMs` is forwarded untouched and is no
41
+ // longer a wall for anyone.
42
+ export const PLUGIN_WALL_KEY = 'pluginWallMs';
43
+ function splitWall(opts) {
44
+ if (!opts || typeof opts !== 'object') return { wallMs: undefined, forwarded: {} };
45
+ const { [PLUGIN_WALL_KEY]: wallMs, ...forwarded } = opts;
46
+ return { wallMs, forwarded };
47
+ }
48
+
49
+ // ── O1(b): A PLUGIN MAY DECLARE ITS OWN TIME BUDGET, BOUNDED BY THIS CEILING ────────────────
50
+ // A plugin whose cost is LINEAR in what the account holds cannot be budgeted by a single global
51
+ // constant: plugin 1020 audits every S3 bucket serially at roughly 3 s each, so it ran in ~18 s
52
+ // on a six-bucket account and hit `status=timeout duration_ms=30001 findings=0` once a few more
53
+ // buckets existed. The producer is the only party that knows its own cost, so it declares it —
54
+ // and the ceiling is what stops a declaration from hanging a scan. An exceeded DECLARED budget
55
+ // still fails closed to not-measured; a budget buys TIME, never a pass.
56
+ export const PLUGIN_TIMEOUT_CEILING_MS = Number(process.env.PLUGIN_TIMEOUT_CEILING_MS || 120000);
57
+
58
+ /**
59
+ * The ONE place the budget is decided. Both enforcement sites call it, because a policy with
60
+ * two call sites is how one of them silently keeps the old behaviour.
61
+ *
62
+ * PRECEDENCE: a plugin-declared budget outranks the GLOBAL DEFAULT ONLY. A caller budget is a
63
+ * WALL and always binds: `min(declared, ceiling, callerWall)`.
64
+ *
65
+ * ⚠️ DO NOT RE-INVERT THIS — a declaration outranking the caller reads reasonable from the
66
+ * PLUGIN's frame and is wrong in the CONSUMER's. The callers that name a wall are the three MCP
67
+ * tools that run plugins, all on the `pluginWallMs` carrier: `scan_cloud` (via
68
+ * `_runCloudPluginsParallel`, `CLOUD_PLUGIN_TIMEOUT_MS || 25000`), and `scan_host` / `probe_service`
69
+ * (the effective `PLUGIN_TIMEOUT_MS`, so on those two a declaration never reaches Desktop and every
70
+ * plugin gets exactly the budget it had before declarations existed) — and CLAUDE DESKTOP HARD-KILLS
71
+ * AN MCP TOOL CALL AT ABOUT 60 s. Measured live 2026-06-01: a 45,000 ms budget returned 15 of 20 plugins with an
72
+ * honest "5 incomplete"; 90,000 ms returned NOTHING AT ALL. So letting a 90 s declaration
73
+ * override a 25 s wall does not buy that plugin more time — it converts a DISCLOSED PARTIAL
74
+ * into a killed call with no result, which is strictly worse than the timeout it was meant to
75
+ * fix. The wall is the operator's, and it binds.
76
+ *
77
+ * CONSEQUENCE, STATED: on the Desktop path a large-estate S3 audit still reads not-measured
78
+ * unless the operator raises `CLOUD_PLUGIN_TIMEOUT_MS` toward 45-55 s. The CLI — which is the
79
+ * compliance path, and the one that writes evidence — names no caller budget and therefore gets
80
+ * the full declared budget. `PLUGIN_TIMEOUT_CEILING_MS` bounds a declaration on every path.
81
+ */
82
+ export function resolvePluginTimeoutMs(mod, callerTimeoutMs, globalMs = PLUGIN_TIMEOUT_MS) {
83
+ const caller = Number(callerTimeoutMs);
84
+ const hasWall = Number.isFinite(caller) && caller > 0;
85
+ const base = hasWall ? caller : globalMs;
86
+ const declared = Number(mod && mod.timeoutMs);
87
+ if (!Number.isFinite(declared) || declared <= 0) return base;
88
+ const bounded = Math.min(declared, PLUGIN_TIMEOUT_CEILING_MS);
89
+ return hasWall ? Math.min(bounded, caller) : bounded;
90
+ }
30
91
  const PREFIX = '[nsauditor]';
31
92
  const vlog = VERBOSE ? (...a) => console.log(PREFIX, ...a) : () => {};
32
93
  const vwarn = VERBOSE ? (...a) => console.warn(PREFIX, ...a) : () => {};
@@ -141,6 +202,12 @@ async function callPlugin(mod, host, ctx, priorOutputs = null, cliOpts = {}) {
141
202
  // Special-case OS Detector: pass prior plugin outputs so it can reason over them
142
203
  const isOsDetector = (mod?.id === "013") || /os\s*detector/i.test(String(mod?.name || ""));
143
204
 
205
+ // Hoisted: the resolved budget does not depend on the port, and the failure branch below
206
+ // reports it OUTSIDE runWithCtx's scope — printing the global constant there was false the
207
+ // moment any plugin declared its own.
208
+ const { wallMs, forwarded: pluginOpts } = splitWall(cliOpts);
209
+ const resolvedTimeoutMs = resolvePluginTimeoutMs(mod, wallMs);
210
+
144
211
  const runWithCtx = (port) => {
145
212
  const extra = isOsDetector && Array.isArray(priorOutputs) ? { results: priorOutputs } : {};
146
213
  // Forward CLI-derived opts (ports, etc.) so plugins can honor flags like --ports.
@@ -149,12 +216,16 @@ async function callPlugin(mod, host, ctx, priorOutputs = null, cliOpts = {}) {
149
216
  // Promise.resolve().then(...) so a plugin that throws SYNCHRONOUSLY (before its
150
217
  // first await) becomes a rejected promise the race below catches, instead of
151
218
  // propagating out of callPlugin and aborting the whole (sequential or parallel) batch.
152
- const pluginPromise = Promise.resolve().then(() => mod.run(host, port, { ...cliOpts, context: withBaseContext(ctx), ...extra }));
219
+ // `effectiveTimeoutMs` IS the number raced below — the plugin reads its budget, it does not re-derive
220
+ // one. EE 1.1.0 build 7's Gate 2: plugin 1040 declared 60 s and capped itself at 0.8 × the 30 s
221
+ // environment default, because nothing told it what the manager actually allowed.
222
+ const pluginPromise = Promise.resolve().then(() => mod.run(host, port, {
223
+ ...pluginOpts, context: { ...withBaseContext(ctx), effectiveTimeoutMs: resolvedTimeoutMs }, ...extra,
224
+ }));
153
225
 
154
- // Honor a per-run timeout (cloud path) but clamp to a positive number; an
155
- // undefined (network path) or non-positive value falls back to PLUGIN_TIMEOUT_MS.
156
- const reqTimeout = Number(cliOpts && cliOpts.timeoutMs);
157
- const timeoutMs = Number.isFinite(reqTimeout) && reqTimeout > 0 ? reqTimeout : PLUGIN_TIMEOUT_MS;
226
+ // A caller's wall (the MCP tools) binds; with none (the CLI) a declared budget outranks
227
+ // PLUGIN_TIMEOUT_MS, and a plugin that declares nothing gets PLUGIN_TIMEOUT_MS.
228
+ const timeoutMs = resolvedTimeoutMs;
158
229
  let timer;
159
230
  const timeoutPromise = new Promise((_, reject) => {
160
231
  timer = setTimeout(() => reject(new Error(`Plugin "${mod.name}" timed out after ${timeoutMs}ms`)), timeoutMs);
@@ -188,7 +259,7 @@ async function callPlugin(mod, host, ctx, priorOutputs = null, cliOpts = {}) {
188
259
  if (!r.ok) {
189
260
  const isTimeout = r.error?.message?.includes('timed out') || false;
190
261
  if (isTimeout) {
191
- vlog(`Plugin "${mod.name}" timed out after ${PLUGIN_TIMEOUT_MS}ms — skipping`);
262
+ vlog(`Plugin "${mod.name}" timed out after ${resolvedTimeoutMs}ms — skipping`);
192
263
  }
193
264
  return {
194
265
  id: String(mod.id || ""),
@@ -506,15 +577,21 @@ export class PluginManager {
506
577
  }
507
578
 
508
579
  async _runOne(plugin, host, port, opts = {}) {
509
- const timeoutMs = parseInt(process.env.PLUGIN_TIMEOUT_MS, 10) || PLUGIN_TIMEOUT_MS;
580
+ // Same resolver as callPlugin — the env read stays the GLOBAL floor, not the whole answer.
581
+ const { wallMs, forwarded } = splitWall(opts);
582
+ const timeoutMs = resolvePluginTimeoutMs(
583
+ plugin, wallMs,
584
+ parseInt(process.env.PLUGIN_TIMEOUT_MS, 10) || PLUGIN_TIMEOUT_MS,
585
+ );
510
586
  let timer;
511
587
  const timeoutPromise = new Promise((_, reject) => {
512
588
  timer = setTimeout(() => reject(new Error(`Plugin ${plugin.name || plugin.id} timed out after ${timeoutMs}ms`)), timeoutMs);
513
589
  });
514
590
  try {
515
591
  vlog(`Running ${plugin.name} on ${host}:${port}`);
516
- // Ensure every run gets the BASE_CTX helpers merged into opts.context
517
- const mergedOpts = { ...opts, context: withBaseContext(opts?.context || {}) };
592
+ // Ensure every run gets the BASE_CTX helpers merged into opts.context — and the budget raced below,
593
+ // LAST, so a caller's context cannot name a budget the manager is not enforcing.
594
+ const mergedOpts = { ...forwarded, context: { ...withBaseContext(forwarded.context || {}), effectiveTimeoutMs: timeoutMs } };
518
595
  const raw = await Promise.race([
519
596
  plugin.run(host, port, mergedOpts),
520
597
  timeoutPromise,
@@ -686,6 +763,18 @@ export class PluginManager {
686
763
  tcpOpen: new Set(),
687
764
  udpOpen: new Set(),
688
765
  // guessedOs / pingOs / arpOs will be filled as plugins run
766
+ // ⚠️ WHAT EACH UPSTREAM ACTUALLY DID, so a LATE plugin can tell "scanned, nothing open"
767
+ // from "never scanned". Emptiness of `tcpOpen` is NOT that signal — it is the same value
768
+ // for both, which is why a consumer computing posture from `openPorts.length` emitted
769
+ // `Good segmentation` over a port scanner that had TIMED OUT, and the cross-run delta then
770
+ // reported a real exposure as RESOLVED.
771
+ //
772
+ // THREE STATES, and the third is ABSENCE: an entry reading `ran` means the surface was
773
+ // measured (an empty result is then genuinely empty); an entry reading `timeout` / `error`
774
+ // / `skipped` means requested and NOT measured, which is an evidence gap; NO ENTRY means
775
+ // the plugin was never requested in this run, which is not a gap at all — the surface was
776
+ // never in scope here, and the cross-run case is already `plugin-not-run` in the delta.
777
+ pluginRunStatus: new Map(),
689
778
  });
690
779
 
691
780
  // Sort by priority (stable)
@@ -740,7 +829,7 @@ export class PluginManager {
740
829
  for (const wrapped of wrappedRuns) {
741
830
  if (wrapped.result?.timedOut) {
742
831
  status = 'timeout';
743
- reason = wrapped.result.error || `timed out after ${PLUGIN_TIMEOUT_MS}ms`;
832
+ reason = wrapped.result.error || `timed out after ${resolvePluginTimeoutMs(mod, opts && opts[PLUGIN_WALL_KEY])}ms`;
744
833
  } else if (wrapped.result?.error && status !== 'timeout') {
745
834
  status = 'error';
746
835
  reason = wrapped.result.error;
@@ -762,6 +851,9 @@ export class PluginManager {
762
851
  reason,
763
852
  duration_ms,
764
853
  });
854
+ // Recorded from the SAME `status` the manifest reports, so the context and the run record
855
+ // cannot disagree about what an upstream did — a second derivation is a second thing to rot.
856
+ ctx.pluginRunStatus.set(String(mod.id || ''), status);
765
857
 
766
858
  for (const wrapped of wrappedRuns) {
767
859
  vlog(`${mod.name} Result:`, JSON.stringify(wrapped, null, 2));
@@ -985,7 +1077,7 @@ export class PluginManager {
985
1077
  // the defense-in-depth signal is present on EVERY cloud dispatch route
986
1078
  // (selection here is already scoped to each plugin's cloudProvider).
987
1079
  const cloudHostKind = mod.cloudProvider ? `cloud:${mod.cloudProvider}` : opts.hostKind;
988
- const wrappedRuns = await callPlugin(mod, host, ctx, [], { ...opts, timeoutMs, hostKind: cloudHostKind });
1080
+ const wrappedRuns = await callPlugin(mod, host, ctx, [], { ...opts, [PLUGIN_WALL_KEY]: timeoutMs, hostKind: cloudHostKind });
989
1081
  const duration_ms = Date.now() - startMs;
990
1082
  // Same classifier as _runOrchestrated: a gate-skip envelope
991
1083
  // ({ up:false, skipped:true, ... }) → 'skipped' (not 'ran') iff nothing ran
@@ -1000,7 +1092,7 @@ export class PluginManager {
1000
1092
  let sawRealRun = false;
1001
1093
  let skipReason = null;
1002
1094
  for (const w of wrappedRuns) {
1003
- if (w.result?.timedOut) { status = 'timeout'; reason = w.result.error || `timed out after ${timeoutMs}ms`; }
1095
+ if (w.result?.timedOut) { status = 'timeout'; reason = w.result.error || `timed out after ${resolvePluginTimeoutMs(mod, timeoutMs)}ms`; }
1004
1096
  else if (w.result?.error && status !== 'timeout') { status = 'error'; reason = w.result.error; }
1005
1097
  else if (w.result?.skipped === true) { skipReason = w.result.reason || w.result.skipReason || 'skipped by plugin gate'; }
1006
1098
  else { sawRealRun = true; }
@@ -535,6 +535,51 @@ async function auditPort(host, port, config) {
535
535
  };
536
536
  }
537
537
 
538
+ // ── A certificate audit that did not happen is SAID, where the reader looks ────
539
+ // Through CE 0.2.55 a failed handshake reached only the INFO `tls-not-responding` service row,
540
+ // which the report loader never shapes. EE 1.1.0 build 6's Gate-2 run had 443 OPEN per the port
541
+ // scanner, and 040's handshake there was RESET (`ECONNRESET`): `totalIssues 0`, `overallSeverity:
542
+ // "pass"`. The executive report said nothing about a certificate audit that never happened.
543
+ //
544
+ // ⚠️ KEYED ON WHAT THE PORT SCANNER SAW, NOT ON THE ERROR. The test estate's router answers
545
+ // ENETDOWN on 993/995 in every run since 0.34.0, and the port scanner reads the same, so those ports
546
+ // were never open. A rule keyed on "any error but ECONNREFUSED" would have put a gap on every scan
547
+ // (measured over the evidence tree before this was written).
548
+ // · WITH port-scanner evidence (`context.tcpOpen` is a Set AND 003 ran): a failed port is a gap iff
549
+ // 003 saw it open — with ANY error, a refusal included, since it was open moments earlier.
550
+ // · WITHOUT it (a direct call, or 003 not measured): every failure except ECONNREFUSED is a gap,
551
+ // restricted to the NAMED port when the call names one. It is stated as `openness: 'unknown'`.
552
+ // The gap is a FINDING in `result.findings`, the container the loader reads, flagged
553
+ // `details.evidenceGap`: it is shown AS a gap and excluded from the finding count. The INFO row stays.
554
+ export function tlsCoverageGap(failedPorts, context, namedPort) {
555
+ const failed = Array.isArray(failedPorts) ? failedPorts : [];
556
+ const tcpOpen = context?.tcpOpen instanceof Set ? context.tcpOpen : null;
557
+ const scannerRan = context?.pluginRunStatus instanceof Map && context.pluginRunStatus.get("003") === "ran";
558
+ const evidence = tcpOpen !== null && scannerRan;
559
+ const named = Number(namedPort) > 0 ? Number(namedPort) : null;
560
+ const gaps = evidence
561
+ ? failed.filter((f) => tcpOpen.has(Number(f.port)))
562
+ : failed.filter((f) => f.error !== "ECONNREFUSED" && (named === null || Number(f.port) === named));
563
+ if (gaps.length === 0) return null;
564
+ const list = gaps.map((f) => `${f.port} (${f.error})`).join(", ");
565
+ return {
566
+ severity: SEVERITY.INFO,
567
+ check: "tls_audit_incomplete",
568
+ title: `[COVERAGE GAP] TLS certificate audit could not complete on ${list}`,
569
+ detail: evidence
570
+ ? `The port scanner saw ${gaps.length === 1 ? "this port" : "these ports"} open, and the TLS handshake failed: ` +
571
+ "the certificate, chain, cipher and protocol checks did not run there. This is not a pass."
572
+ : "The TLS handshake failed, and this run holds no port-scanner evidence of whether the port was open: " +
573
+ "the certificate, chain, cipher and protocol checks did not run there. This is not a pass.",
574
+ port: gaps.length === 1 ? Number(gaps[0].port) : null,
575
+ details: {
576
+ evidenceGap: true,
577
+ openness: evidence ? "open-per-port-scanner" : "unknown",
578
+ failedPorts: gaps,
579
+ },
580
+ };
581
+ }
582
+
538
583
  // ── Plugin Export ─────────────────────────────────────────────────────────────
539
584
 
540
585
  export default {
@@ -607,6 +652,8 @@ export default {
607
652
  info: allIssues.filter((i) => i.severity === SEVERITY.INFO).length,
608
653
  };
609
654
 
655
+ const coverageGap = tlsCoverageGap(failedPorts, opts.context, port);
656
+
610
657
  return {
611
658
  up: activeResults.length > 0,
612
659
  audit_type: "tls_certificate",
@@ -616,6 +663,7 @@ export default {
616
663
  summary,
617
664
  portResults: activeResults,
618
665
  failedPorts,
666
+ ...(coverageGap ? { findings: [coverageGap] } : {}),
619
667
  };
620
668
  },
621
669
 
@@ -343,6 +343,18 @@ function buildFindings({ host, port, detection }) {
343
343
 
344
344
  export default {
345
345
  id: '070',
346
+ // ── O1(b) DECLARED BUDGET (CE 0.2.55, EE 1.1.0 build 6) — the STRUCTURAL bound ───────────────
347
+ // Measured maximum 44 512 ms at Gate 2 build 5 (pack 192.168.1.1_20260923_182912), and a timeout at
348
+ // the 30 s default on every other network scan in the evidence since EE 0.44.0. The cost is
349
+ // STRUCTURAL and linear: candidate ports are probed one after another — MCP_CANDIDATE_PORTS plus
350
+ // every open TCP port in 3000–9000 — and each port tries every MCP_PROBE_PATHS entry in turn, each
351
+ // bounded by the probe timeout (MCP_PROBE_TIMEOUT_MS, 2 s by default): 10 s a port with no MCP
352
+ // server, up to 3 s more on the port that finds one (the SSE check ≤ 1 s, tools/list ≤ 2 s). The test
353
+ // gateway offered 10 candidate ports → 100 s, above 1.5 × the measured maximum (66 768 ms), so the
354
+ // structural bound wins (architect ruling, build 6): a declaration a known worst case exceeds fails
355
+ // closed on a foreseeable input. A target with more candidate ports exceeds any fixed budget and
356
+ // reads not-measured — a budget buys TIME, never a pass — and a caller wall still binds.
357
+ timeoutMs: 100_000,
346
358
  name: 'MCP Scanner',
347
359
  description: 'Detects HTTP/SSE-transport MCP (Model Context Protocol) servers and audits them for cleartext transport, missing authentication, deprecated protocol versions, and Inspector exposure.',
348
360
  priority: 70,
@@ -72,8 +72,14 @@ export function parsePortsSpec(spec) {
72
72
  * That is a FALSE CLEAN shipped to every npm customer: an empty sweep is indistinguishable
73
73
  * from a host with nothing listening, and downstream every plugin gated on `tcp_open` is then
74
74
  * skipped silently. cwd is still tried FIRST so an operator can override the port set by
75
- * placing their own `config/services.json` beside them — the documented behaviour — but the
76
- * package copy is the floor, and a scan can no longer fall through to nothing.
75
+ * placing their own `config/services.json` beside them — the documented behaviour — and the
76
+ * package copy is the floor.
77
+ *
78
+ * ⚠️ AND THE FLOOR ITSELF CAN BE EMPTY, WHICH THIS COMMENT USED TO DENY (board item 18). It said "a
79
+ * scan can no longer fall through to nothing" — true only while the package copy yields ports. An
80
+ * unreadable floor (every published tarball carried it mode 0600 until 0.2.55), a missing one or an
81
+ * empty one fell through to the same `up:false` over zero probes. The scan now REFUSES instead: `run()`
82
+ * throws, the plugin manager records status `error`, and every consumer reads that as "not measured".
77
83
  */
78
84
  const PKG_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
79
85
 
@@ -115,10 +121,10 @@ async function readPortSet(fp) {
115
121
  * A non-empty override REPLACES rather than merges — an operator narrowing the sweep on
116
122
  * purpose must not silently get the full set back.
117
123
  */
118
- async function loadConfigPortsFromServicesJson(cwd = process.cwd()) {
124
+ export async function loadConfigPortsFromServicesJson(cwd = process.cwd(), pkgRoot = PKG_ROOT) {
119
125
  const override = await readPortSet(path.join(cwd, "config", "services.json"));
120
- if (override.tcp.length || override.udp.length) return override;
121
- return readPortSet(path.join(PKG_ROOT, "config", "services.json"));
126
+ if (override.tcp.length || override.udp.length) return { ...override, source: "override" };
127
+ return { ...(await readPortSet(path.join(pkgRoot, "config", "services.json"))), source: "package" };
122
128
  }
123
129
 
124
130
  function classifyTcpError(err) {
@@ -263,9 +269,24 @@ export default {
263
269
  let udpPorts = Array.isArray(opts.udpPorts) ? uniqInts(opts.udpPorts) : [];
264
270
 
265
271
  if (!tcpPorts.length && !udpPorts.length) {
266
- const cfg = await loadConfigPortsFromServicesJson();
272
+ // `_servicesCwd` / `_servicesFloorRoot`: test seams, absent in every real invocation.
273
+ const cwd = opts._servicesCwd ?? process.cwd();
274
+ const floorRoot = opts._servicesFloorRoot ?? PKG_ROOT;
275
+ const cfg = await loadConfigPortsFromServicesJson(cwd, floorRoot);
267
276
  tcpPorts = cfg.tcp;
268
277
  udpPorts = cfg.udp;
278
+ // ⚠️ REFUSE, NEVER FALL THROUGH (board item 18). An empty DEFAULT set means neither the
279
+ // caller's override nor the package's own floor could be read — the surface cannot be
280
+ // measured. Returning `up:false` over zero probes reads, downstream, exactly like a host with
281
+ // nothing listening. `--ports` extras do not rescue it: they ADD to the default sweep, and the
282
+ // default sweep the operator asked for was not possible.
283
+ if (!tcpPorts.length && !udpPorts.length) {
284
+ throw new Error(`port scanner refused: the default port set is empty — neither `
285
+ + `${path.join(cwd, "config", "services.json")} nor the package's own `
286
+ + `${path.join(floorRoot, "config", "services.json")} yielded a port. An empty port set means `
287
+ + `the surface cannot be measured, not that nothing is listening. Check that the file is `
288
+ + `readable (it ships mode 0644), or pass the ports explicitly.`);
289
+ }
269
290
  }
270
291
 
271
292
  // Additive merge of CLI --ports flag (string spec)
@@ -275,23 +296,6 @@ export default {
275
296
  udpPorts = uniqInts([...udpPorts, ...extra.udp]);
276
297
  }
277
298
 
278
- // If still nothing, just return empty structure
279
- if (!tcpPorts.length && !udpPorts.length) {
280
- return {
281
- up: false,
282
- program: "Unknown",
283
- version: "Unknown",
284
- os: null,
285
- type: "port-scan",
286
- tcpOpen: [],
287
- tcpClosed: [],
288
- tcpFiltered: [],
289
- udpOpen: [],
290
- udpClosed: [],
291
- udpNoResponse: [],
292
- data: [],
293
- };
294
- }
295
299
 
296
300
  const data = [];
297
301
 
@@ -21,6 +21,67 @@ const SEARCH_TARGETS = [
21
21
  'urn:schemas-wifialliance-org:device:WFADevice:1'
22
22
  ];
23
23
 
24
+ // ⚠️ node-upnp-utils' discover() takes its window as `wait`, in WHOLE SECONDS from 1 to 120 (default 5),
25
+ // and reads no other key for it (lib/upnp-utils.js, discover: `const sec = params.wait || 5`). Through
26
+ // CE 0.2.54 this passed `timeout` in milliseconds, which the library never read: every one of the seven
27
+ // searches waited the 5 s default whatever NSA_UPNP_TIMEOUT_MS said, a scan cost ~39 s — over the 30 s
28
+ // plugin default on every network scan in the evidence — and the pack recorded a `timeout` window that
29
+ // was never applied. The fake the unit tests used accepted any key, which is how it survived.
30
+ // A window that is not a positive number falls back to the 15 s default rather than reaching the
31
+ // library as NaN, which it rejects — and a rejected search is swallowed below as zero devices.
32
+ export const DEFAULT_UPNP_WINDOW_MS = 15000;
33
+ export function perTargetWaitSec(windowMs) {
34
+ const ms = Number.isFinite(windowMs) && windowMs > 0 ? windowMs : DEFAULT_UPNP_WINDOW_MS;
35
+ return Math.min(120, Math.max(1, Math.round(ms / SEARCH_TARGETS.length / 1000)));
36
+ }
37
+ // The M-SEARCH MX header tells devices how long they may take to answer; the library's default is 3 s.
38
+ // An MX longer than the wait loses every device that answers after the window closes, so it sits
39
+ // INSIDE the window, leaving a second for the description fetch each answer starts.
40
+ export function perTargetMx(waitSec) {
41
+ return Math.max(1, waitSec - 1);
42
+ }
43
+
44
+ // ⚠️ THE LIBRARY'S PACKET HANDLER CAN REJECT, AND NOTHING CATCHES IT (EE 1.1.0 build 6, Gate 2).
45
+ // node-upnp-utils 1.0.3 — the latest published — calls its async `_receivePacket` from the socket handler
46
+ // without a catch, so any rejection is unhandled and ends the WHOLE process. Two ways, both measured on the
47
+ // real library: (1) an answer whose description fetch is still in flight when the NEXT search's
48
+ // `startDiscovery()` resets the device table reads an entry that no longer exists
49
+ // (`SyntaxError: "undefined" is not valid JSON`) — the shorter per-search wait made that likely; (2) an
50
+ // answer with no LOCATION, or one that is not a URL (`TypeError: Invalid URL`) — one misbehaving device on
51
+ // the LAN, in every release. The guard catches the rejection on the module's single instance, once, and
52
+ // COUNTS it by error, and the plugin reports the count and warns. MEASURED, not assumed: the library lists a
53
+ // device the moment its answer arrives, BEFORE it fetches the description, so a caught answer is still in
54
+ // its search's result — what fails is the library's own description, which 028 does not depend on: it
55
+ // fetches the description of every device it reports itself. The original is called SYNCHRONOUSLY, so the
56
+ // device is listed exactly when it would have been; only its promise is caught.
57
+ const RECEIVE_GUARD = Symbol.for('nsauditor.upnp.receivePacketGuard');
58
+ const DROPPED = Symbol.for('nsauditor.upnp.droppedResponses');
59
+ export function guardReceivePacket(upnp) {
60
+ if (!upnp || typeof upnp._receivePacket !== 'function' || upnp[RECEIVE_GUARD]) return upnp;
61
+ const original = upnp._receivePacket;
62
+ upnp[DROPPED] = {};
63
+ const count = (err) => {
64
+ const name = err?.name || 'Error';
65
+ upnp[DROPPED][name] = (upnp[DROPPED][name] ?? 0) + 1;
66
+ };
67
+ upnp._receivePacket = function guardedReceivePacket(...args) {
68
+ let p;
69
+ try { p = original.apply(this, args); } catch (err) { count(err); return Promise.resolve(); }
70
+ return Promise.resolve(p).catch(count);
71
+ };
72
+ upnp[RECEIVE_GUARD] = true;
73
+ return upnp;
74
+ }
75
+ /** Library errors caught so far on this instance, by error name. */
76
+ export function droppedResponses(upnp) {
77
+ return { ...(upnp?.[DROPPED] ?? {}) };
78
+ }
79
+ const droppedSince = (upnp, before) => {
80
+ const now = droppedResponses(upnp); const out = {};
81
+ for (const [k, v] of Object.entries(now)) if (v - (before[k] ?? 0) > 0) out[k] = v - (before[k] ?? 0);
82
+ return out;
83
+ };
84
+
24
85
  function ipMatches(target, address) {
25
86
  const t = String(target || "").trim();
26
87
  const a = String(address || "").trim();
@@ -216,8 +277,9 @@ async function runWithUpnp(targetHost, timeoutMs, opts) {
216
277
  upnp = globalThis.__upnpFakeFactory();
217
278
  } else {
218
279
  const { default: upnpModule } = await import('node-upnp-utils');
219
- upnp = upnpModule;
280
+ upnp = guardReceivePacket(upnpModule);
220
281
  }
282
+ const droppedBefore = droppedResponses(upnp);
221
283
 
222
284
  const allDevices = [];
223
285
  let matched = false;
@@ -228,10 +290,8 @@ async function runWithUpnp(targetHost, timeoutMs, opts) {
228
290
  for (const searchTarget of SEARCH_TARGETS) {
229
291
  try {
230
292
  dlog(`Searching for ${searchTarget}`);
231
- const devices = await upnp.discover({
232
- timeout: Math.floor(timeoutMs / SEARCH_TARGETS.length),
233
- st: searchTarget
234
- });
293
+ const wait = perTargetWaitSec(timeoutMs);
294
+ const devices = await upnp.discover({ wait, mx: perTargetMx(wait), st: searchTarget });
235
295
  allDevices.push(...devices);
236
296
  dlog(`Found ${devices.length} devices for ${searchTarget}`);
237
297
  } catch (e) {
@@ -338,16 +398,27 @@ async function runWithUpnp(targetHost, timeoutMs, opts) {
338
398
  }
339
399
  }
340
400
 
341
- return { rows, matched };
401
+ return { rows, matched, dropped: droppedSince(upnp, droppedBefore) };
342
402
 
343
403
  } catch (e) {
344
404
  dlog("UPnP discovery error:", e?.message || e);
345
- return { rows: [], matched: false };
405
+ return { rows: [], matched: false, dropped: droppedSince(upnp, droppedBefore) };
346
406
  }
347
407
  }
348
408
 
349
409
  export default {
350
410
  id: "028",
411
+ // ── O1(b) DECLARED BUDGET (CE 0.2.55, EE 1.1.0 build 6) ──────────────────────────────────────
412
+ // Two bases, both named. PRE-FIX: 39 320 ms measured at Gate 2 build 5 (pack
413
+ // 192.168.1.1_20260923_182912), and a timeout at the 30 s default on every network scan in the
414
+ // evidence since EE 0.44.0 — seven searches at the library's 5 s default, the `wait` defect above.
415
+ // POST-FIX: seven searches at `perTargetWaitSec` (2 s at the default window) plus ~0.6 s of send
416
+ // overhead each, ~18 s with no device to describe, and up to 3 s more per matching device's
417
+ // description fetch; Gate 2 build 6 records the measured figure. Rule of record (architect ruling,
418
+ // build 6): ≥ 1.5 × the measured maximum, ≤ the 120 000 ceiling — 60 000 (1.53× the pre-fix
419
+ // maximum). A budget buys TIME, never a pass: an exceeded declared budget still fails closed to
420
+ // not-measured, and a caller wall still binds.
421
+ timeoutMs: 60_000,
351
422
  name: "Enhanced UPnP Scanner",
352
423
  description: "Comprehensive UPnP/SSDP discovery with active M-SEARCH probing, detailed header analysis, and enhanced device fingerprinting. Returns only instances matching the target host IP by default.",
353
424
  priority: 346,
@@ -357,7 +428,7 @@ export default {
357
428
  runStrategy: "single",
358
429
 
359
430
  async run(host, _port = 1900, opts = {}) {
360
- const timeoutMs = Number(opts.timeoutMs ?? process.env.NSA_UPNP_TIMEOUT_MS ?? 15000); // Increased timeout
431
+ const timeoutMs = Number(opts.timeoutMs ?? process.env.NSA_UPNP_TIMEOUT_MS ?? DEFAULT_UPNP_WINDOW_MS);
361
432
  const data = [];
362
433
 
363
434
  if (!isPrivateLike(host)) {
@@ -377,8 +448,12 @@ export default {
377
448
  };
378
449
  }
379
450
 
380
- const { rows, matched } = await runWithUpnp(host, timeoutMs, opts);
451
+ const { rows, matched, dropped } = await runWithUpnp(host, timeoutMs, opts);
381
452
  dlog(`Discovery complete: matched=${matched}, rows.length=${rows.length}`);
453
+ const upnpLibraryErrors = Object.values(dropped).reduce((a, b) => a + b, 0);
454
+ if (upnpLibraryErrors > 0) {
455
+ console.warn(`[upnp-scanner] ${upnpLibraryErrors} UPnP answer(s) failed inside the UPnP library and were caught (${Object.entries(dropped).map(([k, v]) => `${k} ×${v}`).join(', ')}); each device is still listed from its answer, and this scanner fetches the description of every device it reports itself`);
456
+ }
382
457
 
383
458
  if (rows.length === 0) {
384
459
  data.push({
@@ -388,6 +463,7 @@ export default {
388
463
  response_banner: JSON.stringify({
389
464
  searchTargets: SEARCH_TARGETS,
390
465
  timeout: timeoutMs,
466
+ waitPerTargetSec: perTargetWaitSec(timeoutMs),
391
467
  reason: "No responses received"
392
468
  })
393
469
  });
@@ -435,6 +511,9 @@ export default {
435
511
  type: "upnp",
436
512
  deviceCount: rows.length,
437
513
  searchTargets: SEARCH_TARGETS,
514
+ waitPerTargetSec: perTargetWaitSec(timeoutMs),
515
+ upnpLibraryErrors,
516
+ upnpLibraryErrorsByName: dropped,
438
517
  data
439
518
  };
440
519
  }
@@ -42,7 +42,7 @@ export const CAPABILITIES = {
42
42
  // Enterprise
43
43
  cloudScanners: { tier: 'enterprise', desc: 'AWS / Azure / GCP cloud posture audit plugins (Enterprise plugin pack).' },
44
44
  zeroTrust: { tier: 'enterprise', desc: 'Zero-trust posture checks over collected identity and network evidence.' },
45
- complianceEngine: { tier: 'enterprise', desc: 'Maps findings to SOC 2, HIPAA, NIST CSF 2.0, PCI DSS, ISO 27001, CIS v8 and GDPR Art. 32 controls, and renders the report artifacts.' },
45
+ complianceEngine: { tier: 'enterprise', desc: 'Maps findings to SOC 2, HIPAA, NIST CSF 2.0, PCI DSS, ISO 27001, CIS v8, GDPR Art. 32 and NIST SP 800-171 Rev 2 controls, and renders the report artifacts.' },
46
46
  enterpriseMCP: { tier: 'enterprise', desc: 'MCP tools scan_cloud and get_findings.' },
47
47
  airGapped: { tier: 'enterprise', desc: 'Offline OPERATION: licence validation is local (ES256 against an embedded key, no callback, no phone-home) and CVE matching runs against a local feed. This describes how the software RUNS, not how it is delivered or installed.' },
48
48
  // Removed 2026-07-21 (capability claim audit): verificationEngine / brandedReports /
@@ -75,15 +75,17 @@ export function hasCapability(capabilities, cap) {
75
75
  return Boolean(capabilities?.[cap]);
76
76
  }
77
77
 
78
- // CE-0.1.30.3 reviewer M2 fold: derive the highest tier among a plugin's
79
- // required capabilities. Used by `license --plugins` to render
80
- // "✗ requires: <tier>" accurately when a plugin doesn't declare a `tier`
81
- // field. Pre-fold the CLI fell back to `'pro'` for plugins like 021/022/023
82
- // (no `tier` field, but require `cloudScanners` which is enterprise-gated)
83
- // — which misled customers about what license they needed.
78
+ // Derive the highest tier among a plugin's required capabilities — the tier the
79
+ // plugin manager's gate enforces. `license --plugins` renders "✗ requires: <tier>"
80
+ // from this ALONE since 0.2.55; before that a manifest's own `tier` word outranked it,
81
+ // and EE 1020 / 1030 declared "pro" over `cloudScanners`, so the list told a Community
82
+ // user that Pro unlocks them. (An earlier version of this comment said EE 021/022/023
83
+ // "require cloudScanners"; they declared no capability at all until EE 1.1.0.)
84
84
  //
85
85
  // Returns 'ce' / 'pro' / 'enterprise' (the highest tier among all required
86
- // caps), or null when the plugin has no requiredCapabilities.
86
+ // caps), or null when the plugin has no requiredCapabilities. A capability the
87
+ // registry does not define is SKIPPED here — the CLI names such a capability
88
+ // rather than printing this function's answer for it.
87
89
  const _TIER_RANK = { ce: 0, pro: 1, enterprise: 2 };
88
90
  const _RANK_TO_TIER = ['ce', 'pro', 'enterprise'];
89
91
 
@@ -223,7 +223,7 @@ export function incompleteCoverageAdvisory(scanScope) {
223
223
  return {
224
224
  severity: 'info',
225
225
  kind: 'incomplete-region-coverage',
226
- text: `Incomplete region coverage — ${unscanned.length} enabled region(s) not scanned (${unscanned.join(', ')}). Re-run with --aws-region all (or set AWS_REGION) for full coverage.`,
226
+ text: `Incomplete region coverage — ${unscanned.length} enabled region(s) not scanned by the auditors that take their region from the client (${unscanned.join(', ')}); the auditors that enumerate their own region list report their own coverage. Re-run with --aws-region all (or set AWS_REGION) for full coverage.`,
227
227
  };
228
228
  }
229
229
  if (scanScope.resolveError) {