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/README.md +176 -565
- package/cli.mjs +271 -37
- package/mcp_server.mjs +27 -16
- package/package.json +2 -2
- package/plugin_manager.mjs +105 -13
- package/plugins/040_tls_cert_auditor.mjs +48 -0
- package/plugins/mcp_scanner.mjs +12 -0
- package/plugins/port_scanner.mjs +27 -23
- package/plugins/upnp_scanner.mjs +88 -9
- package/utils/capabilities.mjs +10 -8
- package/utils/cloud_finding_summary.mjs +1 -1
- package/utils/cloud_providers.mjs +90 -0
- package/utils/delta_reporter.mjs +8 -1
- package/utils/executive_report.mjs +100 -1
- package/utils/finding_identity.mjs +127 -0
- package/utils/host_iterator.mjs +8 -5
- package/utils/report_inputs.mjs +273 -17
- package/utils/run_chain.mjs +296 -0
- package/utils/run_record.mjs +103 -3
- package/utils/scan_delta.mjs +1122 -0
- package/utils/scan_delta_view.mjs +218 -0
- package/utils/scan_history.mjs +92 -2
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nsauditor-ai",
|
|
3
|
-
"version": "0.2.
|
|
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": {
|
package/plugin_manager.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
155
|
-
//
|
|
156
|
-
const
|
|
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 ${
|
|
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
|
-
|
|
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
|
-
|
|
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 ${
|
|
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
|
|
package/plugins/mcp_scanner.mjs
CHANGED
|
@@ -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,
|
package/plugins/port_scanner.mjs
CHANGED
|
@@ -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 —
|
|
76
|
-
* package copy is the floor
|
|
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(
|
|
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
|
-
|
|
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
|
|
package/plugins/upnp_scanner.mjs
CHANGED
|
@@ -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
|
|
232
|
-
|
|
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 ??
|
|
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
|
}
|
package/utils/capabilities.mjs
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
// (
|
|
83
|
-
//
|
|
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) {
|