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
@@ -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
  }
@@ -2,7 +2,9 @@
2
2
  // New plugin: Webapp Detector
3
3
  // Uses the in-house zero-dep fingerprinter (utils/tech_fingerprint.mjs) to identify
4
4
  // web applications present on a host.
5
- // Tries HTTPS first (port 443), then HTTP (port 80), and can also try custom ports via opts.ports.
5
+ // Tries https:443, then http:80, then each TCP port --ports adds (both schemes), and stops at the first that answers.
6
+ // THE REACH, stated: 010 runs only when TCP 80 or 443 is open; an added port is tried after https:443 and http:80 and
7
+ // only if neither answers (1.3.0 item 12 — the CLI's --ports string never reached this plugin before).
6
8
  // NOTE: Unlike http_probe, undici/fetch cannot ignore TLS easily per-request, so self-signed
7
9
  // HTTPS will usually fail and the plugin will fall back to HTTP.
8
10
  //
@@ -28,6 +30,8 @@
28
30
  // }
29
31
 
30
32
  import { fingerprint } from '../utils/tech_fingerprint.mjs';
33
+ import { canonicalIp, classifyAddress, resolveAndValidate, allowAllHosts } from '../utils/net_validation.mjs';
34
+ import { parsePortsSpec } from '../utils/ports_spec.mjs';
31
35
 
32
36
  const DEBUG =
33
37
  String(process.env.DEBUG_MODE || '').toLowerCase() === '1' ||
@@ -70,21 +74,83 @@ function normalizeTarget(target) {
70
74
  return (target.host || target.hostname || target.name || '').replace(/^https?:\/\//i, '').split('/')[0];
71
75
  }
72
76
 
77
+ // 1.3.0 lane 2 (R2): redirects are followed HERE, one hop at a time, never by undici. The target chooses
78
+ // a hop, not the operator, and the scan-entry guard ran once, on the host the operator named — so with
79
+ // `redirect: 'follow'` a target could send the scanner to loopback or cloud metadata and have the
80
+ // answer's headers reflected into the result. A hop to the host the operator named (any port, http or
81
+ // https) was admitted at entry and is followed; any other host goes through the MCP guard's policy:
82
+ // loopback, link-local, metadata and unspecified refused in every configuration, private ranges only
83
+ // under NSA_ALLOW_ALL_HOSTS, a name refused if ANY of its answers is. Stated limit, as at entry: the
84
+ // checked name is resolved again by the request (no pin against rebinding).
85
+ const MAX_REDIRECT_HOPS = 5;
86
+
87
+ async function assertHopAllowed(next, entryHost) {
88
+ if (next.protocol !== 'http:' && next.protocol !== 'https:') {
89
+ throw new Error(`redirect to ${next.href} refused: only http and https hops are followed`);
90
+ }
91
+ const host = next.hostname.replace(/^\[|\]$/g, '');
92
+ if (host.toLowerCase() === String(entryHost).replace(/^\[|\]$/g, '').toLowerCase()) return;
93
+ const allowPrivate = allowAllHosts();
94
+ if (canonicalIp(host)) {
95
+ const cls = classifyAddress(host);
96
+ if (cls === 'always' || (cls === 'private' && !allowPrivate)) {
97
+ throw new Error(`redirect to ${host} refused by the SSRF guard (blocked address range)`);
98
+ }
99
+ return;
100
+ }
101
+ try {
102
+ await resolveAndValidate(host, { allowPrivate });
103
+ } catch (err) {
104
+ throw new Error(`redirect to ${host} refused by the SSRF guard (${err.message})`);
105
+ }
106
+ }
107
+
73
108
  async function fetchOnce(url, signal) {
74
109
  const extra = parseExtraHeaders();
75
- const headers = {
110
+ const base = {
76
111
  'User-Agent': 'Mozilla/5.0 (compatible; NetworkSecurityAuditor/1.18.0; +https://example.invalid)',
77
112
  DNT: '1',
78
- ...extra,
79
113
  };
80
- // global fetch (undici) is available in Node >=18
81
- const res = await fetch(url, { redirect: 'follow', headers, signal });
82
- const finalUrl = res.url || url;
83
- const statusCode = res.status;
84
- const rawHeaders = {};
85
- res.headers.forEach((v, k) => (rawHeaders[k.toLowerCase()] = v));
86
- const html = await res.text();
87
- return { url: finalUrl, statusCode, headers: rawHeaders, html };
114
+ const entry = new URL(url);
115
+ const entryHost = entry.hostname;
116
+ // The operator's HTTP_EXTRA_HEADERS (where an Authorization or an API key goes) are for the origin the
117
+ // operator named. Once a hop leaves that origin — another host, scheme or port — none of them is sent
118
+ // again, even if the chain comes back. (undici's own follow drops only Authorization cross-origin.)
119
+ let leftOrigin = false;
120
+ let current = url;
121
+ for (let hop = 0; ; hop++) {
122
+ const headers = leftOrigin ? base : { ...base, ...extra };
123
+ // global fetch (undici) is available in Node >=18
124
+ const res = await fetch(current, { redirect: 'manual', headers, signal });
125
+ const location = res.status >= 300 && res.status < 400 ? res.headers.get('location') : null;
126
+ if (location) {
127
+ if (hop >= MAX_REDIRECT_HOPS) {
128
+ throw new Error(`redirect limit (${MAX_REDIRECT_HOPS} hops) reached at ${current}`);
129
+ }
130
+ const next = new URL(location, current);
131
+ await assertHopAllowed(next, entryHost);
132
+ if (next.origin !== entry.origin) leftOrigin = true;
133
+ await res.body?.cancel?.().catch(() => {});
134
+ current = next.href;
135
+ continue;
136
+ }
137
+ const statusCode = res.status;
138
+ const rawHeaders = {};
139
+ res.headers.forEach((v, k) => (rawHeaders[k.toLowerCase()] = v));
140
+ const html = await res.text();
141
+ return { url: current, statusCode, headers: rawHeaders, html };
142
+ }
143
+ }
144
+
145
+ /**
146
+ * The TCP ports --ports adds, in every form a caller passes: the CLI's string ('8443,9090/udp'), an array, or
147
+ * { tcp, udp }. One grammar with the port scanner (utils/ports_spec.mjs); a /udp entry adds no URL.
148
+ */
149
+ export function addedTcpPorts(spec) {
150
+ if (typeof spec === 'string') return parsePortsSpec(spec).tcp;
151
+ if (Array.isArray(spec)) return parsePortsSpec(spec.join(',')).tcp;
152
+ if (spec && typeof spec === 'object' && Array.isArray(spec.tcp)) return parsePortsSpec(spec.tcp.join(',')).tcp;
153
+ return [];
88
154
  }
89
155
 
90
156
  async function tryDetectAt(url) {
@@ -143,8 +209,9 @@ export default {
143
209
 
144
210
  /**
145
211
  * @param {string} host - target hostname or IP
146
- * @param {number} port - optional hint (ignored; detection tries 443 then 80 unless opts.ports provided)
147
- * @param {object} opts - options: { ports?: number[] }
212
+ * @param {number} port - the port the manager dispatched (ignored: each run walks the whole candidate list — the double
213
+ * fetch when 80 and 443 are both open is boarded with the per-port dispatch)
214
+ * @param {object} opts - options: { ports?: string | number[] | { tcp, udp } } — the CLI passes its --ports string
148
215
  */
149
216
  async run(host, port = 0, opts = {}) {
150
217
  const result = {
@@ -168,8 +235,8 @@ export default {
168
235
  set.add(`${proto}://${target}${portPart}/`);
169
236
  };
170
237
 
171
- // If specific ports given, try both schemes for each; else default to 443 then 80
172
- const ports = Array.isArray(opts.ports) && opts.ports.length ? opts.ports : [443, 80];
238
+ // https:443 and http:80 first, then each TCP port --ports ADDS (both schemes) — never in place of the defaults.
239
+ const ports = [443, 80, ...addedTcpPorts(opts.ports).filter((p) => p !== 443 && p !== 80)];
173
240
  for (const p of ports) {
174
241
  if (p === 443) addUrl('https', 443);
175
242
  else if (p === 80) addUrl('http', 80);
@@ -78,6 +78,11 @@ function resolveLiveness(base, other) {
78
78
  return base?.status || 'unknown';
79
79
  }
80
80
 
81
+ // What a service IS (or how it was merged) — the fields authority governs. Everything else on a record is an adapter's
82
+ // finding or flag, and a merge must not drop it.
83
+ export const IDENTITY_FIELDS = new Set(['port', 'protocol', 'service', 'program', 'version', 'cpe', 'status', 'info', 'banner',
84
+ 'source', 'evidence', 'authoritative', '__authoritative']);
85
+
81
86
  // Merge by protocol:port with basic authority precedence.
82
87
  // If 'authoritative' flag is true on a record, it wins over non-authoritative —
83
88
  // for identity. See resolveLiveness above for why status is handled separately.
@@ -104,9 +109,16 @@ export function upsertService(services, next, { authoritative = false } = {}) {
104
109
  return;
105
110
  }
106
111
  if (!authoritative && cur.__authoritative) {
107
- // keep current authoritative, but allow filling blanks
112
+ // keep current authoritative, but allow filling blanks — and carry every NON-identity field the authoritative
113
+ // record lacks (1.3.0): this branch kept only identity, so a non-authoritative adapter that reached an
114
+ // authoritative record (040's certificate audit on 011's 443) landed nothing.
115
+ const carried = {};
116
+ for (const [k, v] of Object.entries(next)) {
117
+ if (!IDENTITY_FIELDS.has(k) && v !== undefined && cur[k] === undefined) carried[k] = v;
118
+ }
108
119
  services[i] = {
109
120
  ...cur,
121
+ ...carried,
110
122
  program: cur.program || next.program || null,
111
123
  version: cur.version || next.version || null,
112
124
  info: cur.info || next.info || null,
@@ -0,0 +1,50 @@
1
+ // utils/cookie_redaction.mjs
2
+ // A scanned service's Set-Cookie VALUE is that service's secret (a session token); its NAME fingerprints a framework
3
+ // (PHPSESSID, JSESSIONID). A Location header's query string can carry an SSO ticket or an OAuth code the same way.
4
+ // 1.3.0 (the audit seat's ruling (A), at BOTH seams): the HTTP probe redacts them when it builds its banner, and the AI
5
+ // redactor redacts any Set-Cookie value it meets, so the AI path holds even where a producer does not.
6
+
7
+ export const REDACTED = '<redacted>';
8
+
9
+ // One Set-Cookie header value may carry several cookies joined with ", " (fetch merges them) — split only where the next
10
+ // chunk starts a new `name=`, so a ", " inside an Expires date is not a boundary.
11
+ const COOKIES = /,\s*(?=[^=;,\s]+=)/;
12
+ // In free text a Set-Cookie line ends at CR, LF, or a literal backslash-r / backslash-n (the webapp detector joins its
13
+ // banner lines with those two-character sequences).
14
+ const SET_COOKIE_LINE = /(set-cookie:[ \t]*)((?:(?!\r|\n|\\r|\\n).)*)/gi;
15
+
16
+ /** One Set-Cookie header value: every cookie's VALUE redacted; its name and attributes (Path, HttpOnly, Expires) kept. */
17
+ export function redactSetCookie(value) {
18
+ return String(value).split(COOKIES).map((c) => c.replace(/^(\s*[^=;\s]+)=[^;]*/, `$1=${REDACTED}`)).join(', ');
19
+ }
20
+
21
+ /** A Location header without its query string and fragment — the query can carry a credential. */
22
+ export function redactLocation(value) {
23
+ const v = String(value);
24
+ const i = v.search(/[?#]/);
25
+ if (i < 0) return v;
26
+ return v[i] === '?' ? `${v.slice(0, i)}?${REDACTED}` : v.slice(0, i);
27
+ }
28
+
29
+ /** Free text (a banner, an evidence row): every `set-cookie:` line's cookie values redacted. */
30
+ export function redactSetCookieLines(text) {
31
+ return String(text).replace(SET_COOKIE_LINE, (_, head, value) => head + redactSetCookie(value));
32
+ }
33
+
34
+ /** Every UNREDACTED `name=value` a Set-Cookie line carries anywhere in a structure — the census helper. */
35
+ export function setCookieValues(x) {
36
+ const found = [];
37
+ const visit = (v) => {
38
+ if (typeof v === 'string') {
39
+ for (const m of v.matchAll(SET_COOKIE_LINE)) {
40
+ for (const c of m[2].split(COOKIES)) {
41
+ const kv = /^\s*([^=;\s]+)=([^;]*)/.exec(c);
42
+ if (kv && kv[2].trim() !== REDACTED) found.push(`${kv[1]}=${kv[2].trim()}`);
43
+ }
44
+ }
45
+ } else if (Array.isArray(v)) v.forEach(visit);
46
+ else if (v && typeof v === 'object') Object.values(v).forEach(visit);
47
+ };
48
+ visit(x);
49
+ return found;
50
+ }
@@ -1,7 +1,69 @@
1
1
  // utils/delta_reporter.mjs
2
2
  // Delta reporting: compare two full scan cycles across multiple hosts.
3
3
 
4
- import { computeDiff } from './scan_history.mjs';
4
+ import { computeDiff, flagsChanged } from './scan_history.mjs';
5
+
6
+ // 1.3.0 lane 4, items 4 + 11. The watch loop hands this module each host's scanSingleHost OUTPUT, which carries no
7
+ // services, finding count or tier — so every cycle compared two empty summaries, stdout read "No significant changes
8
+ // detected." and the webhook gate never opened on a change. The output now carries the summary [ScanHistory] records
9
+ // (`scanSummary`), and the cycle compares those. A host whose summary is MISSING is never read as an empty one: that
10
+ // would print "N service(s) removed" for a scan that failed (the naive fix) or "No changes" (the shipped defect).
11
+
12
+ /** The per-host states in which two cycles are NOT compared — a closed set, held two ways by its test. */
13
+ export const WATCH_NOT_COMPARABLE = Object.freeze(['scan-failed', 'baseline-scan-failed', 'no-summary', 'baseline-no-summary']);
14
+
15
+ const NOT_COMPARED_TEXT = {
16
+ 'scan-failed': (detail) => `scan failed this cycle — not compared${detail ? ` (${detail})` : ''}`,
17
+ 'baseline-scan-failed': () => "the previous cycle's scan failed — not compared",
18
+ 'no-summary': () => 'no scan summary was recorded this cycle — not compared',
19
+ 'baseline-no-summary': () => 'no scan summary was recorded for the previous cycle — not compared',
20
+ };
21
+
22
+ /**
23
+ * What one host's cycle value offers the comparison: `{ summary }`, or `{ failed: detail }` for the scheduler's
24
+ * `{ error }`, or `{ missing: true }` when no summary is there to read.
25
+ */
26
+ export function deltaInputOf(value) {
27
+ if (value && typeof value === 'object') {
28
+ if (value.error != null && !('scanSummary' in value) && !Array.isArray(value.services)) return { failed: String(value.error) };
29
+ if ('scanSummary' in value) return value.scanSummary && typeof value.scanSummary === 'object' ? { summary: value.scanSummary } : { missing: true };
30
+ if (Array.isArray(value.services)) return { summary: value };
31
+ }
32
+ return { missing: true };
33
+ }
34
+
35
+ function notComparedDiff(reason, detail) {
36
+ return {
37
+ newServices: [], removedServices: [], changedServices: [],
38
+ newFindings: null, findingsNotComparable: false, findingsNotComparableReason: null,
39
+ changedFlags: [], flagsNotComparable: false, flagsNotComparableReason: null,
40
+ notCompared: reason,
41
+ summary: NOT_COMPARED_TEXT[reason](detail),
42
+ };
43
+ }
44
+
45
+ /** Compare one host's two cycle values. */
46
+ function hostDiff(current, previous) {
47
+ const cur = deltaInputOf(current);
48
+ if (cur.failed !== undefined) return notComparedDiff('scan-failed', cur.failed);
49
+ if (cur.missing) return notComparedDiff('no-summary');
50
+ if (previous == null) return computeDiff(cur.summary, null);
51
+ const prev = deltaInputOf(previous);
52
+ if (prev.failed !== undefined) return notComparedDiff('baseline-scan-failed');
53
+ if (prev.missing) return notComparedDiff('baseline-no-summary');
54
+ return computeDiff(cur.summary, prev.summary);
55
+ }
56
+
57
+ /**
58
+ * THE one per-host change predicate (ruled): stdout and the webhook read the same definition. A service appeared,
59
+ * went or changed; the finding count moved, or could not be compared; a service check appeared, cleared or could not
60
+ * be compared; or the two cycles could not be compared at all.
61
+ */
62
+ export function hostChanged(diff) {
63
+ if (!diff) return false;
64
+ return Boolean(diff.notCompared || diff.newServices?.length || diff.removedServices?.length || diff.changedServices?.length
65
+ || diff.newFindings || diff.findingsNotComparable || flagsChanged(diff));
66
+ }
5
67
 
6
68
  /**
7
69
  * Compare two full scan cycle results and produce a delta report.
@@ -32,9 +94,7 @@ export function buildDeltaReport(currentResults, previousResults) {
32
94
 
33
95
  const hostDiffs = new Map();
34
96
  for (const h of currentHosts) {
35
- const curr = currMap.get(h);
36
- const prev = prevMap.has(h) ? prevMap.get(h) : null;
37
- hostDiffs.set(h, computeDiff(curr, prev));
97
+ hostDiffs.set(h, hostDiff(currMap.get(h), prevMap.has(h) ? prevMap.get(h) : null));
38
98
  }
39
99
 
40
100
  return { newHosts, removedHosts, hostDiffs };
@@ -70,18 +130,11 @@ export function formatDeltaSummary(deltaReport) {
70
130
  if (!deltaReport.newHosts.length && !deltaReport.removedHosts.length) {
71
131
  let anyChange = false;
72
132
  if (deltaReport.hostDiffs) {
73
- for (const diff of deltaReport.hostDiffs.values()) {
74
- // ⚠️ `findingsNotComparable` COUNTS AS A CHANGE (board C10). `newFindings` is `null` when
75
- // the two scans counted findings on a different basis, and null is falsy — so without
76
- // this the diff drops out of both gates and the operator hears NOTHING, which reads as
77
- // "no change since last scan". That is the false clean this item is about, one layer out.
78
- // A comparison we cannot make is news: it is what tells an operator to rescan.
79
- if (diff.newServices?.length || diff.removedServices?.length || diff.changedServices?.length
80
- || diff.newFindings || diff.findingsNotComparable) {
81
- anyChange = true;
82
- break;
83
- }
84
- }
133
+ // ⚠️ A comparison that could not be made COUNTS AS A CHANGE (board C10; 1.3.0 items 4 + 11): `newFindings` is null
134
+ // when the two scans counted on a different basis, and a host whose scan failed has nothing to compare — without
135
+ // this the operator hears NOTHING, which reads as "no change since last scan". A comparison we cannot make is
136
+ // news: it is what tells an operator to rescan. hostChanged is the one predicate the webhook reads too.
137
+ anyChange = [...deltaReport.hostDiffs.values()].some(hostChanged);
85
138
  }
86
139
  if (!anyChange) {
87
140
  lines.push('No significant changes detected.');
@@ -103,15 +156,7 @@ export function hasSignificantChanges(deltaReport) {
103
156
  if (deltaReport.newHosts.length > 0) return true;
104
157
  if (deltaReport.removedHosts.length > 0) return true;
105
158
 
106
- if (deltaReport.hostDiffs) {
107
- for (const diff of deltaReport.hostDiffs.values()) {
108
- if (diff.newServices?.length > 0) return true;
109
- if (diff.removedServices?.length > 0) return true;
110
- if (diff.changedServices?.length > 0) return true;
111
- if (diff.newFindings && diff.newFindings !== 0) return true;
112
- if (diff.findingsNotComparable) return true; // see the note in formatDeltaSummary
113
- }
114
- }
159
+ if (deltaReport.hostDiffs && [...deltaReport.hostDiffs.values()].some(hostChanged)) return true;
115
160
 
116
161
  return false;
117
162
  }
@@ -29,7 +29,7 @@
29
29
  import { escapeHtml } from './brand.mjs';
30
30
  // The two evaluability SENTINELS, imported so the per-row basis is keyed on identity rather
31
31
  // than on prose. `scan_delta.mjs` imports nothing from here, so there is no cycle.
32
- import { SCOPE_NOT_EVALUATED, FRAMEWORK_MOVEMENT_NOT_EVALUATED } from './scan_delta.mjs';
32
+ import { SCOPE_NOT_EVALUATED, FRAMEWORK_MOVEMENT_NOT_EVALUATED, RUN_NAMES, NOT_REQUESTED_RECORD_SINCE_EE, cmpVersion } from './scan_delta.mjs';
33
33
 
34
34
  /* ------------------------------------------------------------------------------------------
35
35
  * egressViolations — deny-by-default. Two levels: every TAG, then EVERY attribute of it
@@ -625,15 +625,42 @@ function describeBrand(v) {
625
625
  //
626
626
  // ⚠️ KEYED ON THE EXPORTED SENTINELS, NEVER ON PROSE. Matching the limit text would rot the first
627
627
  // time somebody copy-edits a sentence, and it would rot SILENTLY in the direction that overstates.
628
- function deltaBasis(delta) {
628
+ //
629
+ // ⚠️ PER ROW (1.3.0, lane 6 — deltaBasis). The sentence used to be ONE for the whole table, so an analysis agent's or the
630
+ // CVE mapper's row read "host, plugin, scope … present in both runs" although an agent is not a requested plugin and its
631
+ // "plugin" leg was never checked. Now:
632
+ // - a plugin's row keeps the legs above;
633
+ // - an agent's row (`producerKind: 'agent'`) names the run that LACKS it — the baseline for a NEW row, this run for a
634
+ // RESOLVED one — as having recorded no evidence gap from its producer covering the row. TRUE BY CONSTRUCTION: the
635
+ // delta refuses a row when that run recorded such a gap, and reads it in that run only (a gap explains what a run is
636
+ // missing, never what it holds), so "either run" would be false;
637
+ // - where that lacking run predates EE 1.3.0 it could not record an input plugin LEFT OUT of the scan, so the row is
638
+ // not refused, and the cell says so; a run that records no EE version says that instead — never assumed either way.
639
+ // Keyed through `cmpVersion`, the delta's one comparator, on the version each run's own record carries.
640
+ function deltaBasis(delta, f = null, bucket = null) {
629
641
  const limits = delta.limits ?? [];
630
642
  const scopeEvaluated = !limits.includes(SCOPE_NOT_EVALUATED);
631
643
  const fwEvaluated = !limits.includes(FRAMEWORK_MOVEMENT_NOT_EVALUATED);
632
- const legs = ['host', 'plugin', scopeEvaluated ? 'scope' : null].filter(Boolean).join(', ');
644
+ const agent = f?.producerKind === 'agent';
645
+ const legs = ['host', agent ? null : 'plugin', scopeEvaluated ? 'scope' : null].filter(Boolean).join(', ');
633
646
  const fw = fwEvaluated ? 'present in both runs' : 'not evaluated';
634
647
  // BOTH sides' integrity: the run being REPORTED is as alterable as the one compared against.
635
648
  const integrity = `baseline ${delta.baselineIntegrity} · current ${delta.currentIntegrity ?? 'not recorded'}`;
636
- return `comparable: ${legs} present in both runs; framework enumeration: ${fw}; ${integrity}`;
649
+ const lacking = bucket === 'new' ? { name: RUN_NAMES.baseline, ee: delta.baselineEeVersion }
650
+ : bucket === 'resolved' ? { name: RUN_NAMES.current, ee: delta.currentEeVersion } : null;
651
+ const producer = !agent ? ''
652
+ : lacking ? `; producer: ${f.plugin} — ${lacking.name} recorded no evidence gap from it covering this row`
653
+ : `; producer: ${f.plugin} — the row is in both runs`;
654
+ let limit = '';
655
+ if (agent && lacking && lacking.ee !== undefined) {
656
+ if (lacking.ee === null) {
657
+ limit = `; limit: ${lacking.name} records no EE version, so whether an input plugin was left out of it is not known`;
658
+ } else if (cmpVersion(lacking.ee, NOT_REQUESTED_RECORD_SINCE_EE) < 0) {
659
+ limit = `; limit: ${lacking.name} predates EE ${NOT_REQUESTED_RECORD_SINCE_EE}, so it could not record an input plugin left `
660
+ + 'out of the scan: an agent row it lacks is not refused';
661
+ }
662
+ }
663
+ return `comparable: ${legs} present in both runs${producer}; framework enumeration: ${fw}; ${integrity}${limit}`;
637
664
  }
638
665
 
639
666
  // What a not-comparable row's direction means, in the words a client reads.
@@ -682,9 +709,9 @@ ${escapeHtml(delta.refusal.detail)}</p>
682
709
  const rows = [
683
710
  // A UDP row carries WHY its port counts as measured (1.1.1 — `basisNote`, e.g. "53/udp answered in the other run
684
711
  // (dns open · dnsmasq 2.79)"), so a resolved UDP row shows its work on the row, never in a footer.
685
- ...delta.resolved.map((f) => deltaRow('resolved', f, `${deltaBasis(delta)}${f.basisNote ? `; ${f.basisNote}` : ''}`)),
686
- ...delta.newFindings.map((f) => deltaRow('new', f, `${deltaBasis(delta)}${f.basisNote ? `; ${f.basisNote}` : ''}`)),
687
- ...delta.changed.map((f) => deltaRow('changed', f, `${deltaBasis(delta)}; severity ${escapeHtml(String(f.from))} → ${escapeHtml(String(f.to))}`)),
712
+ ...delta.resolved.map((f) => deltaRow('resolved', f, `${deltaBasis(delta, f, 'resolved')}${f.basisNote ? `; ${f.basisNote}` : ''}`)),
713
+ ...delta.newFindings.map((f) => deltaRow('new', f, `${deltaBasis(delta, f, 'new')}${f.basisNote ? `; ${f.basisNote}` : ''}`)),
714
+ ...delta.changed.map((f) => deltaRow('changed', f, `${deltaBasis(delta, f, 'changed')}; severity ${escapeHtml(String(f.from))} → ${escapeHtml(String(f.to))}`)),
688
715
  // The reason rides the row, not a legend.
689
716
  // ⚠️ THE CODE IS PRINTED VERBATIM, AND THAT IS THE DESIGN. A render-time label map was tried
690
717
  // here and reverted: it moved this seam and left the terminal's, so one outcome carried two