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.
- package/README.md +41 -40
- package/cli.mjs +171 -147
- package/mcp_server.mjs +64 -37
- package/package.json +1 -1
- package/plugin_manager.mjs +49 -8
- package/plugins/040_tls_cert_auditor.mjs +176 -56
- package/plugins/050_tribe_health.mjs +13 -34
- package/plugins/060_dns_sec_auditor.mjs +24 -30
- package/plugins/dns_scanner.mjs +16 -8
- package/plugins/ftp_banner_check.mjs +21 -13
- package/plugins/host_up_check.mjs +31 -72
- package/plugins/http_probe.mjs +93 -25
- package/plugins/mcp_scanner.mjs +15 -2
- package/plugins/netbios_scanner.mjs +32 -13
- package/plugins/opensearch_scanner.mjs +4 -0
- package/plugins/port_scanner.mjs +29 -49
- package/plugins/result_concluder.mjs +122 -31
- package/plugins/snmp_scanner.mjs +20 -8
- package/plugins/syn_scanner.mjs +2 -1
- package/plugins/upnp_scanner.mjs +82 -13
- package/plugins/webapp_detector.mjs +82 -15
- package/utils/conclusion_utils.mjs +13 -1
- package/utils/cookie_redaction.mjs +50 -0
- package/utils/delta_reporter.mjs +70 -25
- package/utils/executive_report.mjs +34 -7
- package/utils/export_csv.mjs +14 -22
- package/utils/mcp_call_digest.mjs +31 -0
- package/utils/net_validation.mjs +107 -37
- package/utils/output_dir.mjs +4 -2
- package/utils/plugin_discovery.mjs +4 -2
- package/utils/ports_spec.mjs +46 -0
- package/utils/report_md.mjs +49 -125
- package/utils/sarif.mjs +33 -93
- package/utils/scan_delta.mjs +173 -18
- package/utils/scan_delta_view.mjs +3 -3
- package/utils/scan_history.mjs +149 -0
- package/utils/scheduler.mjs +17 -5
- package/utils/service_flags.mjs +363 -0
- package/utils/watch_cycle.mjs +37 -0
- package/utils/webhook.mjs +2 -1
package/plugins/upnp_scanner.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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 -
|
|
147
|
-
*
|
|
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
|
-
//
|
|
172
|
-
const ports =
|
|
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
|
+
}
|
package/utils/delta_reporter.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|