nsauditor-ai 0.2.55 → 0.2.57

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 (42) hide show
  1. package/README.md +76 -62
  2. package/cli.mjs +210 -179
  3. package/mcp_server.mjs +71 -31
  4. package/package.json +1 -1
  5. package/plugin_manager.mjs +49 -8
  6. package/plugins/040_tls_cert_auditor.mjs +176 -56
  7. package/plugins/050_tribe_health.mjs +13 -34
  8. package/plugins/060_dns_sec_auditor.mjs +24 -30
  9. package/plugins/dns_scanner.mjs +16 -8
  10. package/plugins/ftp_banner_check.mjs +21 -13
  11. package/plugins/host_up_check.mjs +31 -72
  12. package/plugins/http_probe.mjs +93 -25
  13. package/plugins/mcp_scanner.mjs +15 -1
  14. package/plugins/netbios_scanner.mjs +32 -13
  15. package/plugins/opensearch_scanner.mjs +4 -0
  16. package/plugins/port_scanner.mjs +29 -49
  17. package/plugins/result_concluder.mjs +122 -30
  18. package/plugins/snmp_scanner.mjs +20 -8
  19. package/plugins/syn_scanner.mjs +2 -1
  20. package/plugins/upnp_scanner.mjs +82 -13
  21. package/plugins/webapp_detector.mjs +82 -15
  22. package/utils/conclusion_utils.mjs +13 -1
  23. package/utils/cookie_redaction.mjs +50 -0
  24. package/utils/delta_reporter.mjs +70 -25
  25. package/utils/ee_load.mjs +45 -0
  26. package/utils/executive_report.mjs +48 -8
  27. package/utils/export_csv.mjs +14 -22
  28. package/utils/mcp_call_digest.mjs +31 -0
  29. package/utils/net_validation.mjs +107 -37
  30. package/utils/output_dir.mjs +70 -0
  31. package/utils/plugin_discovery.mjs +4 -2
  32. package/utils/ports_spec.mjs +46 -0
  33. package/utils/report_inputs.mjs +64 -1
  34. package/utils/report_md.mjs +51 -112
  35. package/utils/sarif.mjs +33 -93
  36. package/utils/scan_delta.mjs +514 -38
  37. package/utils/scan_delta_view.mjs +5 -3
  38. package/utils/scan_history.mjs +149 -0
  39. package/utils/scheduler.mjs +17 -5
  40. package/utils/service_flags.mjs +363 -0
  41. package/utils/watch_cycle.mjs +37 -0
  42. package/utils/webhook.mjs +2 -1
@@ -8,6 +8,7 @@ import fsp from "node:fs/promises";
8
8
  import path from "node:path";
9
9
  import { fileURLToPath } from "node:url";
10
10
  import { mapLimit } from "../utils/concurrency.mjs";
11
+ import { parsePortsSpec, uniqInts } from "../utils/ports_spec.mjs";
11
12
 
12
13
  /* ------------------------------ helpers ------------------------------ */
13
14
 
@@ -16,48 +17,9 @@ const toInt = (v, d) => {
16
17
  return Number.isFinite(n) && n >= 0 ? n : d;
17
18
  };
18
19
 
19
- function uniqInts(arr = []) {
20
- return [...new Set((arr || []).map((x) => Number(x)).filter(Number.isFinite))];
21
- }
22
-
23
- /**
24
- * Parse a CLI-style ports spec string into TCP/UDP port arrays.
25
- *
26
- * Accepted formats (entries comma-separated, whitespace tolerated):
27
- * "8090" → { tcp: [8090], udp: [] }
28
- * "8090,9090" → { tcp: [8090, 9090], udp: [] }
29
- * "8090/tcp" → { tcp: [8090], udp: [] }
30
- * "8090/udp" → { tcp: [], udp: [8090] }
31
- * "8090,9090/udp" → { tcp: [8090], udp: [9090] }
32
- * "8090/tcp,9090/udp" → { tcp: [8090], udp: [9090] }
33
- *
34
- * Default protocol when not specified: TCP.
35
- *
36
- * Malformed entries (non-numeric, out-of-range 1–65535, empty, unknown
37
- * protocol suffix) are silently skipped — defensive for sloppy CLI input.
38
- *
39
- * @param {string} spec
40
- * @returns {{ tcp: number[], udp: number[] }}
41
- */
42
- export function parsePortsSpec(spec) {
43
- const out = { tcp: [], udp: [] };
44
- if (typeof spec !== 'string') return out;
45
- const entries = spec.split(',').map(s => s.trim()).filter(Boolean);
46
- for (const entry of entries) {
47
- // Reject entries with more than one '/' separator (e.g. "8090/tcp/extra")
48
- const parts = entry.split('/');
49
- if (parts.length > 2) continue;
50
- const portStr = parts[0];
51
- const proto = (parts[1] || 'tcp').toLowerCase();
52
- if (proto !== 'tcp' && proto !== 'udp') continue;
53
- const port = Number(portStr);
54
- if (!Number.isInteger(port) || port < 1 || port > 65535) continue;
55
- out[proto].push(port);
56
- }
57
- out.tcp = uniqInts(out.tcp);
58
- out.udp = uniqInts(out.udp);
59
- return out;
60
- }
20
+ // parsePortsSpec lives in utils/ports_spec.mjs (1.3.0 item 12: the webapp detector parses --ports the same way) and is
21
+ // re-exported here, where the CLI flag's tests and the build-5 text leg import it.
22
+ export { parsePortsSpec };
61
23
 
62
24
  /**
63
25
  * ⚠️ THE PACKAGE'S OWN DATA IS FOUND RELATIVE TO THE PACKAGE, NOT TO THE CALLER.
@@ -122,9 +84,14 @@ async function readPortSet(fp) {
122
84
  * purpose must not silently get the full set back.
123
85
  */
124
86
  export async function loadConfigPortsFromServicesJson(cwd = process.cwd(), pkgRoot = PKG_ROOT) {
125
- const override = await readPortSet(path.join(cwd, "config", "services.json"));
87
+ const overridePath = path.join(cwd, "config", "services.json");
88
+ const override = await readPortSet(overridePath);
126
89
  if (override.tcp.length || override.udp.length) return { ...override, source: "override" };
127
- return { ...(await readPortSet(path.join(pkgRoot, "config", "services.json"))), source: "package" };
90
+ const floor = { ...(await readPortSet(path.join(pkgRoot, "config", "services.json"))), source: "package" };
91
+ // B6-4j (1.3.0): a cwd file that EXISTS and yields nothing is ignored OUT LOUD (run() warns), named by its RELATIVE path —
92
+ // the working directory's absolute path is the operator's layout, which does not travel in a log.
93
+ const present = await fsp.access(overridePath).then(() => true, () => false);
94
+ return present ? { ...floor, overrideIgnored: path.join("config", "services.json") } : floor;
128
95
  }
129
96
 
130
97
  function classifyTcpError(err) {
@@ -249,7 +216,12 @@ export default {
249
216
  priority: 30,
250
217
  protocols: ["tcp", "udp"],
251
218
  ports: [],
252
- requirements: { host: "up" },
219
+ // 1.3.0 lane 1 F, ruling (ii): NOT gated on `host: "up"`. The port scan is the measurement that
220
+ // decides liveness, so it does not wait for a liveness guess: with no discovery evidence it still
221
+ // runs, and its own `up` (a port answered) opens every `host: "up"` gate downstream. Gated, a host
222
+ // that drops ping and the common ports lost its port scan and read "Host appears DOWN" over an
223
+ // open uncommon port (driven through the CLI: `003 skipped "host not up"`).
224
+ requirements: {},
253
225
 
254
226
  // run(host, _portIgnored, opts)
255
227
  async run(host, _port = 0, opts = {}) {
@@ -287,6 +259,10 @@ export default {
287
259
  + `the surface cannot be measured, not that nothing is listening. Check that the file is `
288
260
  + `readable (it ships mode 0644), or pass the ports explicitly.`);
289
261
  }
262
+ if (cfg.overrideIgnored) {
263
+ console.warn(`[port_scanner] WARNING: ${cfg.overrideIgnored} in the working directory yields no ports — it lists none in `
264
+ + `the forms read here (a "services" array, or "tcp" / "udp" arrays) — so the package's default port set is scanned instead.`);
265
+ }
290
266
  }
291
267
 
292
268
  // Additive merge of CLI --ports flag (string spec)
@@ -334,12 +310,16 @@ export default {
334
310
  const udpClosed = data.filter(d => d.probe_protocol === "udp" && d.status === "closed").map(d => d.probe_port); // typically empty
335
311
  const udpNoResponse = data.filter(d => d.probe_protocol === "udp" && d.status === "no-response").map(d => d.probe_port);
336
312
 
337
- // Consider host "up" if we saw any TCP evidence (open/closed/filtered) OR any UDP open.
338
- const anyTcpEvidence = tcpOpen.length > 0 || tcpClosed.length > 0 || tcpFiltered.length > 0;
339
- const anyUdpOpen = udpOpen.length > 0;
313
+ // `up` means a port answered — open or refused; a timeout, an unreachable or a socket error is
314
+ // not an answer. (1.3.0 lane 1 F: 'filtered' — every timeout, EHOSTUNREACH, ENETUNREACH and other
315
+ // socket error, classifyTcpError — used to count too, so a host that answered nothing read UP from
316
+ // its own timeouts; driven against a silent 127.0.0.2: `up: true, tcpFiltered: [22, 8443]`.
317
+ // Ungated, that would read every silent host UP and open every downstream gate.)
318
+ const anyTcpAnswer = tcpOpen.length > 0 || tcpClosed.length > 0;
319
+ const anyUdpOpen = udpOpen.length > 0;
340
320
 
341
321
  return {
342
- up: anyTcpEvidence || anyUdpOpen,
322
+ up: anyTcpAnswer || anyUdpOpen,
343
323
  program: "Unknown",
344
324
  version: "Unknown",
345
325
  os: null,
@@ -1,4 +1,7 @@
1
1
  // plugins/result_concluder.mjs — plug-and-play dispatcher with full metadata and evidence
2
+ import { readdir } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { fileURLToPath, pathToFileURL } from 'node:url';
2
5
  import { normalizeService, upsertService, keyOf } from '../utils/conclusion_utils.mjs';
3
6
 
4
7
  function pickResultsFromArgs(args) {
@@ -8,14 +11,79 @@ function pickResultsFromArgs(args) {
8
11
  return [];
9
12
  }
10
13
 
11
- // Stable slug from plugin name, falling back to IDs
12
- function slugify(name, id) {
13
- const base = String(name || '').toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '');
14
- if (base) return base;
15
- const map = { '001':'ping_checker','002':'ssh_scanner','003':'port_scanner','004':'ftp_banner_check','005':'host_up_check','006':'http_probe','007':'snmp_scanner','009':'dns_scanner','010':'webapp_detector','011':'tls_scanner','012':'opensearch_scanner','013':'os_detector','014':'netbios__smb_scanner','015':'sunrpc_scanner','024':'syn_scanner','025':'db_scanner','026':'arp_scanner','027':'mdns_scanner','028':'upnp_scanner' };
16
- return map[String(id)] || String(id);
14
+ // The second argument (or opts.adapters in the (host, port, { results }) form) may carry the PluginManager's adapter
15
+ // registry: a Map (or plain object) from plugin id to { conclude, authoritativePorts, cloudProvider }.
16
+ function pickOptsFromArgs(args) {
17
+ if (Array.isArray(args[0]) && args[1] && typeof args[1] === 'object') return args[1];
18
+ if (args.length >= 3 && args[2] && typeof args[2] === 'object') return args[2];
19
+ if (args.length === 1 && args[0] && typeof args[0] === 'object' && !Array.isArray(args[0])) return args[0];
20
+ return {};
17
21
  }
18
22
 
23
+ // A plugin name as a label — the default `source` of an adapter record that names none. It no longer RESOLVES
24
+ // anything: until 1.3.0 the concluder imported `./<this slug>.mjs` and read only a named `conclude`, so 014 and 024
25
+ // (slug ≠ file name) and 040 / 050 / 060 (conclude on the default object) were never reached.
26
+ function nameSlug(name) {
27
+ return String(name || 'plugin').toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'plugin';
28
+ }
29
+
30
+ /** One adapter-registry entry from a plugin module: `conclude` named or on the default object, by plugin ID. */
31
+ function adapterEntry(mod) {
32
+ const p = mod?.default;
33
+ if (!p || typeof p !== 'object' || p.id == null) return null;
34
+ const conclude = typeof mod.conclude === 'function' ? mod.conclude
35
+ : (typeof p.conclude === 'function' ? p.conclude.bind(p) : null);
36
+ return {
37
+ id: String(p.id),
38
+ name: typeof p.name === 'string' ? p.name : null,
39
+ conclude,
40
+ authoritativePorts: mod.authoritativePorts ?? p.authoritativePorts ?? null,
41
+ cloudProvider: p.cloudProvider ?? null,
42
+ };
43
+ }
44
+
45
+ // The concluder's own directory as a registry, built once — used for any id the caller's registry does not carry
46
+ // (a direct concluder.run, or a test manager with injected plugins).
47
+ const SELF = fileURLToPath(import.meta.url);
48
+ let ownRegistry = null;
49
+ function ownDirectoryAdapters() {
50
+ ownRegistry ??= (async () => {
51
+ const map = new Map();
52
+ let names = [];
53
+ try { names = (await readdir(path.dirname(SELF))).filter((f) => f.endsWith('.mjs')).sort(); } catch { return map; }
54
+ for (const f of names) {
55
+ if (f === path.basename(SELF)) continue;
56
+ try {
57
+ const e = adapterEntry(await import(pathToFileURL(path.join(path.dirname(SELF), f)).href));
58
+ if (e && !map.has(e.id)) map.set(e.id, e);
59
+ } catch { /* a plugin that fails to load has no adapter here */ }
60
+ }
61
+ return map;
62
+ })();
63
+ return ownRegistry;
64
+ }
65
+
66
+ function toRegistry(adapters) {
67
+ if (adapters instanceof Map) return adapters;
68
+ if (adapters && typeof adapters === 'object') return new Map(Object.entries(adapters));
69
+ return new Map();
70
+ }
71
+
72
+ // A result with NO id (some callers pass names only) resolves by the plugin's DECLARED name — never by a file name
73
+ // derived from it, which is what left 014 / 024 unreached.
74
+ function byDeclaredName(registry) {
75
+ const out = new Map();
76
+ for (const e of registry.values()) {
77
+ const n = typeof e?.name === 'string' ? e.name.trim().toLowerCase() : '';
78
+ if (n && !out.has(n)) out.set(n, e);
79
+ }
80
+ return out;
81
+ }
82
+
83
+ // Adapter payloads that travel under a namespace. A record without a positive port is evidence, not a service row,
84
+ // and these ride along onto its evidence entry so moving it there loses nothing.
85
+ export const ADAPTER_PAYLOAD_KEYS = ['certAudit', 'tribeHealth', 'dnsSecurity'];
86
+
19
87
  function scoreOsLabel(label) {
20
88
  const s = String(label||'').toLowerCase();
21
89
  if (!s || s === 'unknown') return 0;
@@ -133,6 +201,9 @@ export default {
133
201
 
134
202
  async run(...args) {
135
203
  const results = pickResultsFromArgs(args);
204
+ const supplied = toRegistry(pickOptsFromArgs(args).adapters);
205
+ const own = await ownDirectoryAdapters();
206
+ const pendingAttach = [];
136
207
  const services = [];
137
208
  const evidence = [];
138
209
  let os = null;
@@ -162,8 +233,8 @@ export default {
162
233
 
163
234
  for (const r of results) {
164
235
  const name = r?.name || r?.id || 'plugin';
165
- const slug = slugify(name, r?.id);
166
- const modPath = `./${slug}.mjs`;
236
+ const id = String(r?.id ?? '');
237
+ const slug = nameSlug(name);
167
238
 
168
239
  // Prefer OS and osVersion provided by plugins, but pick the most specific; OS Detector wins ties
169
240
  if (r?.result?.os) {
@@ -173,33 +244,43 @@ export default {
173
244
  osSource = picked.source;
174
245
  }
175
246
 
176
- // Extract host name from UPnP Scanner if available
177
- if (slug === 'upnp_scanner') {
247
+ // Host names by plugin ID (they keyed on a name slug, and the UPnP one never matched: the plugin is named
248
+ // "Enhanced UPnP Scanner").
249
+ if (id === '028') {
178
250
  hostName = extractHostNameFromUpnp(r?.result) || hostName;
179
251
  }
180
-
181
- // Extract host name from mDNS Scanner if available
182
- if (slug === 'mdns_scanner') {
252
+ if (id === '027') {
183
253
  hostName = extractHostNameFromMdns(r?.result) || hostName;
184
254
  }
185
255
 
186
- let recs = null;
187
- try {
188
- const mod = await import(modPath);
189
- if (typeof mod.conclude === 'function') {
190
- recs = await mod.conclude({ host: typeof args[0] === 'string' ? args[0] : undefined, result: r?.result });
191
- const authSet = mod?.authoritativePorts instanceof Set ? mod.authoritativePorts : null;
192
- for (const item of (recs || [])) {
193
- const rec = normalizeService({ ...item, source: item.source || slug });
194
- const key = keyOf(rec);
195
- const authoritative = (authSet && authSet.has(key)) || !!item.authoritative;
196
- upsertService(services, rec, { authoritative });
256
+ // Resolve the adapter by ID: the caller's registry first, then this directory. A cloudProvider plugin is
257
+ // EXEMPT — its findings travel raw (cloud_finding_summary / harvestCloudFindings), and through a service-record
258
+ // adapter they carry no port and collapse into one fabricated row.
259
+ const entry = id
260
+ ? (supplied.get(id) ?? own.get(id))
261
+ : (byDeclaredName(supplied).get(String(name).trim().toLowerCase())
262
+ ?? byDeclaredName(own).get(String(name).trim().toLowerCase()));
263
+ if (entry && typeof entry.conclude === 'function' && !entry.cloudProvider) {
264
+ let recs = null;
265
+ try {
266
+ recs = await entry.conclude({ host: typeof args[0] === 'string' ? args[0] : undefined, result: r?.result });
267
+ } catch {
268
+ recs = null; // an adapter that throws falls through to the fallback record, as before
269
+ }
270
+ if (recs) {
271
+ const authSet = entry.authoritativePorts instanceof Set ? entry.authoritativePorts : null;
272
+ for (const item of recs) {
273
+ const { attachOnly, ...rest } = item || {};
274
+ const rec = normalizeService({ ...rest, source: rest.source || slug });
275
+ const authoritative = (authSet && authSet.has(keyOf(rec))) || !!rest.authoritative;
276
+ // An attach-only record (060's domain DNS-posture audit) lands only on a service a port-level probe
277
+ // found; decided after every result is in, so result order does not matter.
278
+ if (attachOnly) pendingAttach.push(rec);
279
+ else upsertService(services, rec, { authoritative });
197
280
  }
198
281
  if (r?.result?.data) pushEvidence(name, r.result.data);
199
282
  continue;
200
283
  }
201
- } catch {
202
- // no adapter -> fall through
203
284
  }
204
285
  for (const item of fallbackRecord(name, r?.result)) {
205
286
  upsertService(services, normalizeService(item), { authoritative: false });
@@ -207,6 +288,12 @@ export default {
207
288
  if (r?.result?.data) pushEvidence(name, r.result.data);
208
289
  }
209
290
 
291
+ const unattached = [];
292
+ for (const rec of pendingAttach) {
293
+ if (services.some((s) => keyOf(s) === keyOf(rec))) upsertService(services, rec, { authoritative: false });
294
+ else unattached.push(rec);
295
+ }
296
+
210
297
  for (const svc of services) delete svc.__authoritative;
211
298
 
212
299
  services.sort((a,b)=> (a.port - b.port) || String(a.protocol).localeCompare(String(b.protocol)) );
@@ -215,24 +302,28 @@ export default {
215
302
  const META_PROTOCOLS = new Set(['assessment', 'icmp', 'os-detector', 'arp']);
216
303
  const PORT_ZERO_META_PROTOCOLS = new Set(['api', 'tcp', 'udp']);
217
304
  const isMetaEntry = (s) => {
305
+ // A service has a port. A record without a positive integer one (no port at all keys to `<proto>:NaN`) is
306
+ // evidence — 040's "no TLS found", 060's per-domain findings — never a service row with port null.
307
+ if (!(Number.isInteger(s.port) && s.port > 0)) return true;
218
308
  if (META_PROTOCOLS.has(s.protocol)) return true;
219
309
  if (s.port === 0 && PORT_ZERO_META_PROTOCOLS.has(s.protocol)) return true;
220
310
  if (s.info && /Skipped:/i.test(String(s.info))) return true;
221
311
  return false;
222
312
  };
223
313
 
224
- const metaEntries = services.filter(isMetaEntry);
314
+ const metaEntries = services.filter(isMetaEntry).concat(unattached);
225
315
  const realServices = services.filter(s => !isMetaEntry(s));
226
316
 
227
- // Move meta entries into evidence only
317
+ // Move meta entries into evidence only — with any namespaced adapter payload, so nothing is lost on the way
228
318
  for (const m of metaEntries) {
229
319
  evidence.push({
230
320
  from: m.source || 'meta',
231
321
  protocol: m.protocol,
232
- port: m.port,
322
+ port: Number.isFinite(m.port) ? m.port : null,
233
323
  status: m.status,
234
324
  info: m.info,
235
325
  ...(m.banner ? { banner: m.banner } : {}),
326
+ ...Object.fromEntries(ADAPTER_PAYLOAD_KEYS.filter((k) => m[k] != null).map((k) => [k, m[k]])),
236
327
  });
237
328
  }
238
329
 
@@ -251,7 +342,8 @@ export default {
251
342
  const hostUp = results.some(r => r?.result?.up === true) || services.some(s => s.status === 'open');
252
343
  const open = services.filter(s => s.status === 'open');
253
344
  const parts = [];
254
- parts.push(hostUp ? `Host${hostName ? ` (${hostName})` : ''} is UP` : 'Host appears DOWN');
345
+ // Not UP means no probe got an answer — no evidence either way, never "DOWN" (1.3.0 lane 1 F).
346
+ parts.push(hostUp ? `Host${hostName ? ` (${hostName})` : ''} is UP` : 'No evidence the host is up');
255
347
  if (os) parts.push(`OS: ${os}`);
256
348
  if (osVersion) parts.push(`Version: ${osVersion}`);
257
349
  if (open.length) {
@@ -4,6 +4,10 @@
4
4
  // Returns { up: boolean, program: string, version: string, os: string|null, type: string, data: [{ probe_protocol, probe_port, probe_info, response_banner }], serialNumber: string, hardwareVersion: string, firmwareVersion: string, ip6: string|null, deviceWebPage: string|null, deviceWebPageInstruction: string }.
5
5
 
6
6
  export const DEFAULT_COMMUNITIES = ['public', 'private'];
7
+ // ⚠️ A COMMUNITY STRING THE OPERATOR SUPPLIED (SNMP_COMMUNITY) IS A CREDENTIAL — it authenticates to their devices — so
8
+ // it is never written into a result, a record or an evidence row. Only a DEFAULT community is recorded by name, because
9
+ // a default community answering IS the finding; any other string is recorded as the word 'custom'.
10
+ export const communityLabel = (comm) => (DEFAULT_COMMUNITIES.includes(comm) ? comm : 'custom');
7
11
  export const snmpCommunities = process.env.SNMP_COMMUNITY
8
12
  ? String(process.env.SNMP_COMMUNITY).split(',').map(s => s.trim()).filter(Boolean)
9
13
  : DEFAULT_COMMUNITIES;
@@ -124,6 +128,7 @@ export default {
124
128
  let deviceWebPageInstruction = '';
125
129
  let deviceFullName = null;
126
130
  let community = null;
131
+ let communityCustom = false; // a custom (operator-supplied) string ANSWERED — not a finding, and not "not tested"
127
132
  const communitiesTried = [];
128
133
  const data = [];
129
134
 
@@ -137,7 +142,7 @@ export default {
137
142
  const useOid = options.oid ? options.oid.split('.').map(Number) : oidTable.default;
138
143
 
139
144
  for (const comm of snmpCommunities) {
140
- communitiesTried.push(comm);
145
+ communitiesTried.push(communityLabel(comm));
141
146
  const s = new Session({ host, community: comm, timeouts: [5000] });
142
147
 
143
148
  try {
@@ -157,7 +162,7 @@ export default {
157
162
  const val = String(vbs?.[0]?.value || '');
158
163
  if (val) {
159
164
  up = true;
160
- community = comm;
165
+ if (DEFAULT_COMMUNITIES.includes(comm)) community = comm; else communityCustom = true;
161
166
  const parsed = fromSysDescr(val);
162
167
  program = parsed.family || 'Unknown';
163
168
  version = parsed.version || 'Unknown';
@@ -168,7 +173,7 @@ export default {
168
173
  else if (/microsoft|windows|linux|unix/i.test(program)) type = 'server';
169
174
 
170
175
  let probeInfo = `SNMP response received: ${program} ${version}${os ? ` (OS: ${os})` : ''} (Type: ${type})`;
171
- if (comm === 'public' || comm === 'private') {
176
+ if (DEFAULT_COMMUNITIES.includes(comm)) {
172
177
  probeInfo += ` WARNING: Default SNMP community string '${comm}' accepted — misconfiguration`;
173
178
  }
174
179
 
@@ -220,7 +225,7 @@ export default {
220
225
  if (hardwareVersion) infoPieces.push(`HW Ver: ${hardwareVersion}`);
221
226
  if (firmwareVersion) infoPieces.push(`FW Ver: ${firmwareVersion}`);
222
227
  infoPieces.push(')');
223
- if (comm === 'public' || comm === 'private') {
228
+ if (DEFAULT_COMMUNITIES.includes(comm)) {
224
229
  infoPieces.push(` WARNING: Default SNMP community string '${comm}' accepted — misconfiguration`);
225
230
  }
226
231
  data[0].probe_info = infoPieces.join(', ');
@@ -240,7 +245,7 @@ export default {
240
245
  data.push({
241
246
  probe_protocol: 'udp',
242
247
  probe_port: 161,
243
- probe_info: `No SNMP response for community "${comm}"`,
248
+ probe_info: `No SNMP response for community "${communityLabel(comm)}"`,
244
249
  response_banner: null
245
250
  });
246
251
  }
@@ -257,7 +262,7 @@ export default {
257
262
  });
258
263
  }
259
264
 
260
- return { up, program, version, os, type, serialNumber, hardwareVersion, firmwareVersion, ip6, deviceWebPage, deviceWebPageInstruction, community, communitiesTried, data };
265
+ return { up, program, version, os, type, serialNumber, hardwareVersion, firmwareVersion, ip6, deviceWebPage, deviceWebPageInstruction, community, communityCustom, communitiesTried, data };
261
266
  }
262
267
  };
263
268
 
@@ -265,14 +270,21 @@ export async function conclude({ host, result }) {
265
270
  const rows = Array.isArray(result?.data) ? result.data : [];
266
271
  const row = rows[0] || null;
267
272
  const isDefault = DEFAULT_COMMUNITIES.includes(result?.community);
268
- const communityInfo = result?.community ? ` [community=${isDefault ? result.community : 'custom'}]` : '';
273
+ // A result written by an earlier release carries a custom string in `community`; the field is masked here as well.
274
+ // (Its evidence rows are carried as they were written — this adapter does not rewrite another release's evidence.)
275
+ const custom = result?.communityCustom === true || (result?.community != null && !isDefault);
276
+ const communityInfo = isDefault ? ` [community=${result.community}]` : custom ? ' [community=custom]' : '';
269
277
  return [{
270
278
  port: 161, protocol: 'udp', service: 'snmp',
271
279
  program: result?.program || 'Unknown', version: result?.version || 'Unknown',
272
280
  status: result?.up ? 'open' : 'no response',
273
281
  info: row?.probe_info ? `${row.probe_info}${communityInfo}` : communityInfo || null,
274
282
  banner: row?.response_banner ? `${row.response_banner}${communityInfo}` : null,
275
- community: result?.community || null,
283
+ community: isDefault ? result.community : null,
284
+ ...(custom ? { communityCustom: true } : {}),
285
+ // 1.3.0 (s1) B: what was TRIED, as labels — a default community is MEASURED only where it was tried. Re-labelled here,
286
+ // so a raw result an earlier release wrote (which carried the strings themselves) never puts one on the record.
287
+ communitiesTried: Array.isArray(result?.communitiesTried) ? result.communitiesTried.map(communityLabel) : null,
276
288
  source: 'snmp', evidence: rows, authoritative: true
277
289
  }];
278
290
  }
@@ -299,7 +299,8 @@ export async function conclude({ host, result }) {
299
299
  port,
300
300
  protocol: proto,
301
301
  service: r?.service || "unknown",
302
- program: result?.program || "nmap",
302
+ // nmap is the tool that saw the port, not the program listening on it (1.3.0).
303
+ program: "Unknown",
303
304
  version: r?.response_banner || "Unknown",
304
305
  status,
305
306
  info: r?.probe_info || null,
@@ -5,7 +5,7 @@
5
5
  // Set UPNP_INCLUDE_NON_MATCHED=1 to keep all discovered devices.
6
6
 
7
7
  import upnp from 'node-upnp-utils';
8
- import { isPrivateLike } from '../utils/net_validation.mjs';
8
+ import { isPrivateLike, canonicalIp } from '../utils/net_validation.mjs';
9
9
 
10
10
  const DEBUG = /^(1|true|yes|on)$/i.test(String(process.env.DEBUG_MODE || process.env.UPNP_DEBUG || ""));
11
11
  function dlog(...a) { if (DEBUG) console.log("[upnp-scanner]", ...a); }
@@ -54,17 +54,33 @@ export function perTargetMx(waitSec) {
54
54
  // its search's result — what fails is the library's own description, which 028 does not depend on: it
55
55
  // fetches the description of every device it reports itself. The original is called SYNCHRONOUSLY, so the
56
56
  // device is listed exactly when it would have been; only its promise is caught.
57
+ //
58
+ // 1.3.0 lane 2 (R2): the same wrapper also refuses a NOTIFY whose LOCATION names a host other than the
59
+ // device that sent it. The library fetches the LOCATION of every NOTIFY it keeps with NO host check (its
60
+ // M-SEARCH-answer path does check), so any device on the segment could point it at loopback, cloud
61
+ // metadata or a third host. Such a NOTIFY is dropped before the library sees it, and COUNTED
62
+ // (refusedAnnouncements), never silently. The decision is taken on the LIBRARY'S OWN PARSE of the packet
63
+ // (`_parseSdpResponseHeader`, the same start-line test) — a parser of our own disagreed with it on a
64
+ // repeated LOCATION (it keeps the last), a space before the colon and a `NOTIFYX` start line. Should that
65
+ // parser ever be missing, every NOTIFY is refused: the check fails closed.
57
66
  const RECEIVE_GUARD = Symbol.for('nsauditor.upnp.receivePacketGuard');
58
67
  const DROPPED = Symbol.for('nsauditor.upnp.droppedResponses');
68
+ const REFUSED = Symbol.for('nsauditor.upnp.refusedAnnouncements');
59
69
  export function guardReceivePacket(upnp) {
60
70
  if (!upnp || typeof upnp._receivePacket !== 'function' || upnp[RECEIVE_GUARD]) return upnp;
61
71
  const original = upnp._receivePacket;
62
72
  upnp[DROPPED] = {};
73
+ upnp[REFUSED] = 0;
63
74
  const count = (err) => {
64
75
  const name = err?.name || 'Error';
65
76
  upnp[DROPPED][name] = (upnp[DROPPED][name] ?? 0) + 1;
66
77
  };
67
78
  upnp._receivePacket = function guardedReceivePacket(...args) {
79
+ const [buffer, rinfo] = args;
80
+ if (refuseAnnouncement(upnp, buffer, rinfo)) {
81
+ upnp[REFUSED] += 1;
82
+ return Promise.resolve();
83
+ }
68
84
  let p;
69
85
  try { p = original.apply(this, args); } catch (err) { count(err); return Promise.resolve(); }
70
86
  return Promise.resolve(p).catch(count);
@@ -72,10 +88,44 @@ export function guardReceivePacket(upnp) {
72
88
  upnp[RECEIVE_GUARD] = true;
73
89
  return upnp;
74
90
  }
91
+ /** Whether the library would fetch this packet's LOCATION as a NOTIFY, from a host other than the sender. */
92
+ function refuseAnnouncement(upnp, buffer, rinfo) {
93
+ const text = Buffer.isBuffer(buffer) ? buffer.toString('utf8') : String(buffer ?? '');
94
+ if (/^M-SEARCH/.test(text)) return false; // the library ignores these
95
+ if (typeof upnp._parseSdpResponseHeader !== 'function') return /^NOTIFY/.test(text);
96
+ let headers;
97
+ try { headers = upnp._parseSdpResponseHeader(text); } catch { return /^NOTIFY/.test(text); }
98
+ if (!headers || !headers.USN || !/^NOTIFY/.test(String(headers['$']))) return false;
99
+ const loc = headers.LOCATION;
100
+ return loc !== undefined && !descriptionUrlAllowed(loc, rinfo?.address);
101
+ }
102
+
75
103
  /** Library errors caught so far on this instance, by error name. */
76
104
  export function droppedResponses(upnp) {
77
105
  return { ...(upnp?.[DROPPED] ?? {}) };
78
106
  }
107
+ /** NOTIFY announcements refused so far on this instance: their LOCATION named a host other than the sender. */
108
+ export function refusedAnnouncements(upnp) {
109
+ return upnp?.[REFUSED] ?? 0;
110
+ }
111
+
112
+ /**
113
+ * Whether a description URL a UPnP device advertised may be fetched: http or https, on the address of the
114
+ * device that answered — never another host, a name, or another scheme. The device chooses this URL, not the
115
+ * operator, so a LOCATION aimed at loopback, cloud metadata or a third host is not followed (1.3.0 lane 2, R2).
116
+ * @param {string} location the advertised LOCATION
117
+ * @param {string} answeringAddress the source address of the SSDP answer
118
+ */
119
+ export function descriptionUrlAllowed(location, answeringAddress) {
120
+ let url;
121
+ try { url = new URL(String(location ?? '')); } catch { return false; }
122
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') return false;
123
+ const host = canonicalIp(url.hostname);
124
+ let from = canonicalIp(answeringAddress);
125
+ if (!host || !from) return false;
126
+ from = from.replace(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i, '$1');
127
+ return host.toLowerCase() === from.toLowerCase();
128
+ }
79
129
  const droppedSince = (upnp, before) => {
80
130
  const now = droppedResponses(upnp); const out = {};
81
131
  for (const [k, v] of Object.entries(now)) if (v - (before[k] ?? 0) > 0) out[k] = v - (before[k] ?? 0);
@@ -197,23 +247,31 @@ function extractDeviceInfo(device, deviceXml) {
197
247
  return info;
198
248
  }
199
249
 
200
- async function fetchDeviceDescription(location, timeout = 5000) {
250
+ // The description is fetched only from the answering device's own address, with the platform fetch and
251
+ // `redirect: 'manual'` — a device that answers with a redirect is not followed elsewhere. (It used
252
+ // node-fetch, which CE never declared, following up to 20 redirects to any host.)
253
+ export const DESCRIPTION_REFUSED = Symbol('description refused');
254
+ async function fetchDeviceDescription(location, answeringAddress, timeout = 5000) {
201
255
  if (!location) return null;
202
-
256
+ if (!descriptionUrlAllowed(location, answeringAddress)) {
257
+ dlog(`Refused device description URL ${location}: not on the answering device ${answeringAddress}`);
258
+ return DESCRIPTION_REFUSED;
259
+ }
260
+
203
261
  try {
204
- const { default: fetch } = await import('node-fetch');
205
262
  const controller = new AbortController();
206
263
  const timeoutId = setTimeout(() => controller.abort(), timeout);
207
-
208
- const response = await fetch(location, {
264
+
265
+ const response = await fetch(location, {
209
266
  signal: controller.signal,
267
+ redirect: 'manual',
210
268
  headers: {
211
269
  'User-Agent': 'UPnP-Scanner/1.0'
212
270
  }
213
271
  });
214
-
272
+
215
273
  clearTimeout(timeoutId);
216
-
274
+
217
275
  if (response.ok) {
218
276
  return await response.text();
219
277
  } else {
@@ -280,6 +338,7 @@ async function runWithUpnp(targetHost, timeoutMs, opts) {
280
338
  upnp = guardReceivePacket(upnpModule);
281
339
  }
282
340
  const droppedBefore = droppedResponses(upnp);
341
+ const refusedBefore = refusedAnnouncements(upnp);
283
342
 
284
343
  const allDevices = [];
285
344
  let matched = false;
@@ -326,8 +385,10 @@ async function runWithUpnp(targetHost, timeoutMs, opts) {
326
385
 
327
386
  // Fetch and parse device description if available
328
387
  let deviceXml = null;
388
+ let descriptionRefused = false;
329
389
  if (location) {
330
- deviceXml = await fetchDeviceDescription(location, 3000);
390
+ deviceXml = await fetchDeviceDescription(location, address, 3000);
391
+ if (deviceXml === DESCRIPTION_REFUSED) { deviceXml = null; descriptionRefused = true; }
331
392
  }
332
393
 
333
394
  // Extract detailed device information
@@ -360,6 +421,9 @@ async function runWithUpnp(targetHost, timeoutMs, opts) {
360
421
  if (location) {
361
422
  infoParts.push(`location=${location}`);
362
423
  }
424
+ if (descriptionRefused) {
425
+ infoParts.push('description refused (LOCATION is not on the answering device)');
426
+ }
363
427
 
364
428
  // Enhanced banner with comprehensive data
365
429
  const bannerObj = {
@@ -376,7 +440,8 @@ async function runWithUpnp(targetHost, timeoutMs, opts) {
376
440
  ssdpAnalysis,
377
441
  deviceInfo,
378
442
  descriptionXML: deviceXml ? deviceXml.substring(0, 2000) : null, // Limit XML size
379
- xmlTruncated: deviceXml && deviceXml.length > 2000
443
+ xmlTruncated: deviceXml && deviceXml.length > 2000,
444
+ ...(descriptionRefused ? { descriptionRefused: true } : {})
380
445
  };
381
446
 
382
447
  const row = {
@@ -398,11 +463,11 @@ async function runWithUpnp(targetHost, timeoutMs, opts) {
398
463
  }
399
464
  }
400
465
 
401
- return { rows, matched, dropped: droppedSince(upnp, droppedBefore) };
466
+ return { rows, matched, dropped: droppedSince(upnp, droppedBefore), refused: refusedAnnouncements(upnp) - refusedBefore };
402
467
 
403
468
  } catch (e) {
404
469
  dlog("UPnP discovery error:", e?.message || e);
405
- return { rows: [], matched: false, dropped: droppedSince(upnp, droppedBefore) };
470
+ return { rows: [], matched: false, dropped: droppedSince(upnp, droppedBefore), refused: refusedAnnouncements(upnp) - refusedBefore };
406
471
  }
407
472
  }
408
473
 
@@ -448,7 +513,10 @@ export default {
448
513
  };
449
514
  }
450
515
 
451
- const { rows, matched, dropped } = await runWithUpnp(host, timeoutMs, opts);
516
+ const { rows, matched, dropped, refused = 0 } = await runWithUpnp(host, timeoutMs, opts);
517
+ if (refused > 0) {
518
+ console.warn(`[upnp-scanner] ${refused} ssdp:alive announcement(s) named a description URL on a host other than the announcing device and were dropped before the UPnP library could fetch it`);
519
+ }
452
520
  dlog(`Discovery complete: matched=${matched}, rows.length=${rows.length}`);
453
521
  const upnpLibraryErrors = Object.values(dropped).reduce((a, b) => a + b, 0);
454
522
  if (upnpLibraryErrors > 0) {
@@ -514,6 +582,7 @@ export default {
514
582
  waitPerTargetSec: perTargetWaitSec(timeoutMs),
515
583
  upnpLibraryErrors,
516
584
  upnpLibraryErrorsByName: dropped,
585
+ upnpRefusedAnnouncements: refused,
517
586
  data
518
587
  };
519
588
  }