nsauditor-ai 0.2.56 → 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 (40) hide show
  1. package/README.md +41 -40
  2. package/cli.mjs +171 -147
  3. package/mcp_server.mjs +64 -37
  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 -2
  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 -31
  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/executive_report.mjs +34 -7
  26. package/utils/export_csv.mjs +14 -22
  27. package/utils/mcp_call_digest.mjs +31 -0
  28. package/utils/net_validation.mjs +107 -37
  29. package/utils/output_dir.mjs +4 -2
  30. package/utils/plugin_discovery.mjs +4 -2
  31. package/utils/ports_spec.mjs +46 -0
  32. package/utils/report_md.mjs +49 -125
  33. package/utils/sarif.mjs +33 -93
  34. package/utils/scan_delta.mjs +173 -18
  35. package/utils/scan_delta_view.mjs +3 -3
  36. package/utils/scan_history.mjs +149 -0
  37. package/utils/scheduler.mjs +17 -5
  38. package/utils/service_flags.mjs +363 -0
  39. package/utils/watch_cycle.mjs +37 -0
  40. 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,15 +11,79 @@ function pickResultsFromArgs(args) {
8
11
  return [];
9
12
  }
10
13
 
11
- // Stable slug from plugin name, falling back to IDs. Exported so tests/concluder_drops_honesty.test.mjs derives the
12
- // adapter census with THIS rule rather than a copy of it.
13
- export function slugify(name, id) {
14
- const base = String(name || '').toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '');
15
- if (base) return base;
16
- 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' };
17
- 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 {};
18
21
  }
19
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
+
20
87
  function scoreOsLabel(label) {
21
88
  const s = String(label||'').toLowerCase();
22
89
  if (!s || s === 'unknown') return 0;
@@ -134,6 +201,9 @@ export default {
134
201
 
135
202
  async run(...args) {
136
203
  const results = pickResultsFromArgs(args);
204
+ const supplied = toRegistry(pickOptsFromArgs(args).adapters);
205
+ const own = await ownDirectoryAdapters();
206
+ const pendingAttach = [];
137
207
  const services = [];
138
208
  const evidence = [];
139
209
  let os = null;
@@ -163,8 +233,8 @@ export default {
163
233
 
164
234
  for (const r of results) {
165
235
  const name = r?.name || r?.id || 'plugin';
166
- const slug = slugify(name, r?.id);
167
- const modPath = `./${slug}.mjs`;
236
+ const id = String(r?.id ?? '');
237
+ const slug = nameSlug(name);
168
238
 
169
239
  // Prefer OS and osVersion provided by plugins, but pick the most specific; OS Detector wins ties
170
240
  if (r?.result?.os) {
@@ -174,33 +244,43 @@ export default {
174
244
  osSource = picked.source;
175
245
  }
176
246
 
177
- // Extract host name from UPnP Scanner if available
178
- 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') {
179
250
  hostName = extractHostNameFromUpnp(r?.result) || hostName;
180
251
  }
181
-
182
- // Extract host name from mDNS Scanner if available
183
- if (slug === 'mdns_scanner') {
252
+ if (id === '027') {
184
253
  hostName = extractHostNameFromMdns(r?.result) || hostName;
185
254
  }
186
255
 
187
- let recs = null;
188
- try {
189
- const mod = await import(modPath);
190
- if (typeof mod.conclude === 'function') {
191
- recs = await mod.conclude({ host: typeof args[0] === 'string' ? args[0] : undefined, result: r?.result });
192
- const authSet = mod?.authoritativePorts instanceof Set ? mod.authoritativePorts : null;
193
- for (const item of (recs || [])) {
194
- const rec = normalizeService({ ...item, source: item.source || slug });
195
- const key = keyOf(rec);
196
- const authoritative = (authSet && authSet.has(key)) || !!item.authoritative;
197
- 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 });
198
280
  }
199
281
  if (r?.result?.data) pushEvidence(name, r.result.data);
200
282
  continue;
201
283
  }
202
- } catch {
203
- // no adapter -> fall through
204
284
  }
205
285
  for (const item of fallbackRecord(name, r?.result)) {
206
286
  upsertService(services, normalizeService(item), { authoritative: false });
@@ -208,6 +288,12 @@ export default {
208
288
  if (r?.result?.data) pushEvidence(name, r.result.data);
209
289
  }
210
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
+
211
297
  for (const svc of services) delete svc.__authoritative;
212
298
 
213
299
  services.sort((a,b)=> (a.port - b.port) || String(a.protocol).localeCompare(String(b.protocol)) );
@@ -216,24 +302,28 @@ export default {
216
302
  const META_PROTOCOLS = new Set(['assessment', 'icmp', 'os-detector', 'arp']);
217
303
  const PORT_ZERO_META_PROTOCOLS = new Set(['api', 'tcp', 'udp']);
218
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;
219
308
  if (META_PROTOCOLS.has(s.protocol)) return true;
220
309
  if (s.port === 0 && PORT_ZERO_META_PROTOCOLS.has(s.protocol)) return true;
221
310
  if (s.info && /Skipped:/i.test(String(s.info))) return true;
222
311
  return false;
223
312
  };
224
313
 
225
- const metaEntries = services.filter(isMetaEntry);
314
+ const metaEntries = services.filter(isMetaEntry).concat(unattached);
226
315
  const realServices = services.filter(s => !isMetaEntry(s));
227
316
 
228
- // 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
229
318
  for (const m of metaEntries) {
230
319
  evidence.push({
231
320
  from: m.source || 'meta',
232
321
  protocol: m.protocol,
233
- port: m.port,
322
+ port: Number.isFinite(m.port) ? m.port : null,
234
323
  status: m.status,
235
324
  info: m.info,
236
325
  ...(m.banner ? { banner: m.banner } : {}),
326
+ ...Object.fromEntries(ADAPTER_PAYLOAD_KEYS.filter((k) => m[k] != null).map((k) => [k, m[k]])),
237
327
  });
238
328
  }
239
329
 
@@ -252,7 +342,8 @@ export default {
252
342
  const hostUp = results.some(r => r?.result?.up === true) || services.some(s => s.status === 'open');
253
343
  const open = services.filter(s => s.status === 'open');
254
344
  const parts = [];
255
- 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');
256
347
  if (os) parts.push(`OS: ${os}`);
257
348
  if (osVersion) parts.push(`Version: ${osVersion}`);
258
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,