nsauditor-ai 0.2.55 → 0.2.57
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +76 -62
- package/cli.mjs +210 -179
- package/mcp_server.mjs +71 -31
- 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 -1
- 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 -30
- 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/ee_load.mjs +45 -0
- package/utils/executive_report.mjs +48 -8
- 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 +70 -0
- package/utils/plugin_discovery.mjs +4 -2
- package/utils/ports_spec.mjs +46 -0
- package/utils/report_inputs.mjs +64 -1
- package/utils/report_md.mjs +51 -112
- package/utils/sarif.mjs +33 -93
- package/utils/scan_delta.mjs +514 -38
- package/utils/scan_delta_view.mjs +5 -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
|
@@ -24,7 +24,9 @@ export default {
|
|
|
24
24
|
let banner = '';
|
|
25
25
|
let up = false;
|
|
26
26
|
let anonState = 'banner'; // banner | send-user | send-pass | done
|
|
27
|
-
|
|
27
|
+
// 1.3.0 (s3): null unless MEASURED; anonTested is true when it was, else the reason it was not.
|
|
28
|
+
let anonymousLogin = null;
|
|
29
|
+
let anonTested = anonEnabled ? 'no-answer' : 'opt-in-off';
|
|
28
30
|
let bannerCollected = false;
|
|
29
31
|
let resolved = false;
|
|
30
32
|
const safeResolve = (value) => { if (resolved) return; resolved = true; resolve(value); };
|
|
@@ -77,7 +79,7 @@ export default {
|
|
|
77
79
|
response_banner: parsed.bannerTrimmed || null
|
|
78
80
|
}];
|
|
79
81
|
|
|
80
|
-
if (anonEnabled && anonymousLogin) {
|
|
82
|
+
if (anonEnabled && anonymousLogin === true) {
|
|
81
83
|
evidenceRows.push({
|
|
82
84
|
probe_protocol: 'tcp',
|
|
83
85
|
probe_port: port,
|
|
@@ -94,9 +96,8 @@ export default {
|
|
|
94
96
|
data: evidenceRows
|
|
95
97
|
};
|
|
96
98
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
}
|
|
99
|
+
result.anonymousLogin = anonymousLogin;
|
|
100
|
+
result.anonymousLoginTested = anonTested;
|
|
100
101
|
|
|
101
102
|
dlog(`FTP Banner Check result: up=${up}, program=${parsed.program}, version=${parsed.version}, os=${parsed.os}, anonymousLogin=${anonymousLogin}, banner=${parsed.bannerTrimmed || 'none'}`);
|
|
102
103
|
return result;
|
|
@@ -138,20 +139,22 @@ export default {
|
|
|
138
139
|
} else if (response.startsWith('230')) {
|
|
139
140
|
// Logged in without password
|
|
140
141
|
anonymousLogin = true;
|
|
142
|
+
anonTested = true;
|
|
141
143
|
anonState = 'done';
|
|
142
144
|
socket.end();
|
|
143
145
|
} else {
|
|
144
|
-
//
|
|
146
|
+
// The server answered USER with something else — a refusal it made, so a measurement
|
|
147
|
+
anonymousLogin = false;
|
|
148
|
+
anonTested = true;
|
|
145
149
|
anonState = 'done';
|
|
146
150
|
socket.end();
|
|
147
151
|
}
|
|
148
152
|
} else if (anonState === 'send-pass') {
|
|
149
153
|
const response = chunk.trim();
|
|
150
154
|
dlog(`Anon PASS response: ${response}`);
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
// 530/421 or anything else = denied
|
|
155
|
+
// 230 = accepted; 530/421 or anything else the server answered = refused. Either is a measurement.
|
|
156
|
+
anonymousLogin = response.startsWith('230');
|
|
157
|
+
anonTested = true;
|
|
155
158
|
anonState = 'done';
|
|
156
159
|
socket.end();
|
|
157
160
|
}
|
|
@@ -170,6 +173,8 @@ export default {
|
|
|
170
173
|
program: 'Unknown',
|
|
171
174
|
version: 'Unknown',
|
|
172
175
|
os: null,
|
|
176
|
+
anonymousLogin: null,
|
|
177
|
+
anonymousLoginTested: anonTested,
|
|
173
178
|
data: [{
|
|
174
179
|
probe_protocol: 'tcp',
|
|
175
180
|
probe_port: port,
|
|
@@ -194,6 +199,8 @@ export default {
|
|
|
194
199
|
program: 'Unknown',
|
|
195
200
|
version: 'Unknown',
|
|
196
201
|
os: null,
|
|
202
|
+
anonymousLogin: null,
|
|
203
|
+
anonymousLoginTested: anonTested,
|
|
197
204
|
data: [{
|
|
198
205
|
probe_protocol: 'tcp',
|
|
199
206
|
probe_port: port,
|
|
@@ -237,9 +244,10 @@ export async function conclude({ host, result }) {
|
|
|
237
244
|
authoritative: true
|
|
238
245
|
};
|
|
239
246
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
247
|
+
// 1.3.0 (s3): the result and its tested state, on every FTP record — null with no state is a result an earlier release
|
|
248
|
+
// wrote, which did not record whether the check ran.
|
|
249
|
+
record.anonymousLogin = result?.anonymousLogin ?? null;
|
|
250
|
+
record.anonymousLoginTested = result?.anonymousLoginTested ?? null;
|
|
243
251
|
|
|
244
252
|
return [record];
|
|
245
253
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// plugins/host_up_check.mjs
|
|
2
|
-
// Real plugin
|
|
2
|
+
// Real plugin that looks for evidence a host is up — ICMP (ping), TCP (common ports) and UDP (a high, likely-closed
|
|
3
|
+
// port). No answer is recorded as no evidence, never as down.
|
|
3
4
|
// Updated to prioritize ICMP TTL-based OS detection, with TCP probes refining ambiguous TTLs (e.g., TTL 64) using banners and port heuristics.
|
|
4
5
|
// Extracts router name and version from port 443 banner (e.g., Netgear R8000) and includes in result.
|
|
5
6
|
// Returns { up: boolean, os: string|null, router_info: { name: string|null, version: string|null }|null, data: [{ probe_protocol, probe_port, probe_info, response_banner }] }.
|
|
@@ -76,7 +77,7 @@ const OS_SPECIFICITY = {
|
|
|
76
77
|
export default {
|
|
77
78
|
id: '005',
|
|
78
79
|
name: 'Host Up Check',
|
|
79
|
-
description: '
|
|
80
|
+
description: 'Looks for evidence that the host is up using ICMP, TCP (common ports) and UDP (a high, likely-closed port) probes, with enhanced OS detection; no answer is no evidence, not down.',
|
|
80
81
|
priority: 20,
|
|
81
82
|
requirements: { host: "down" }, // run when ping hasn't marked it UP (ping blocked/filtered)
|
|
82
83
|
protocols: ['tcp', 'udp', 'icmp'],
|
|
@@ -245,88 +246,46 @@ export default {
|
|
|
245
246
|
});
|
|
246
247
|
}
|
|
247
248
|
|
|
248
|
-
// UDP Probe (send to likely
|
|
249
|
+
// UDP Probe (send to a likely-closed high port).
|
|
250
|
+
// 1.3.0 lane 1 F: a successful send is NOT evidence that the host is up — it says only that the
|
|
251
|
+
// local stack accepted the datagram. The leg used to set `up` in the send callback, clear the wait
|
|
252
|
+
// and close the socket, so a host that never answered read UP and the ICMP port-unreachable that
|
|
253
|
+
// follows a send (the real order on loopback) was discarded with the socket. Evidence of UP is that
|
|
254
|
+
// port-unreachable or a reply. Silence until the timeout is recorded as NO EVIDENCE, never as down,
|
|
255
|
+
// and costs up to UDP_REPLY_WAIT_MS on every run of this plugin where nothing answers on UDP.
|
|
256
|
+
// Settles once: the first of reply / port-unreachable / error / timeout writes the one UDP row.
|
|
249
257
|
const udpPort = 54321;
|
|
258
|
+
const UDP_REPLY_WAIT_MS = 3000;
|
|
250
259
|
await new Promise((resolve) => {
|
|
251
260
|
dlog(`Attempting UDP probe on ${host}:${udpPort}`);
|
|
252
261
|
const socket = dgram.createSocket('udp4');
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
});
|
|
262
|
+
let settled = false;
|
|
263
|
+
const settle = (probe_info, answered) => {
|
|
264
|
+
if (settled) return;
|
|
265
|
+
settled = true;
|
|
266
|
+
clearTimeout(timeoutId);
|
|
267
|
+
if (answered) up = true;
|
|
268
|
+
data.push({ probe_protocol: 'udp', probe_port: udpPort, probe_info, response_banner: null });
|
|
260
269
|
socket.close();
|
|
261
270
|
resolve();
|
|
262
|
-
}
|
|
271
|
+
};
|
|
272
|
+
const timeoutId = setTimeout(
|
|
273
|
+
() => settle(`No reply within ${UDP_REPLY_WAIT_MS / 1000} s - no evidence either way`, false),
|
|
274
|
+
UDP_REPLY_WAIT_MS
|
|
275
|
+
);
|
|
263
276
|
|
|
277
|
+
socket.on('message', () => settle('UDP reply - host up', true));
|
|
264
278
|
socket.on('error', (err) => {
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
up = true;
|
|
268
|
-
data.push({
|
|
269
|
-
probe_protocol: 'udp',
|
|
270
|
-
probe_port: udpPort,
|
|
271
|
-
probe_info: 'ICMP Port Unreachable - host up',
|
|
272
|
-
response_banner: null
|
|
273
|
-
});
|
|
274
|
-
} else {
|
|
275
|
-
data.push({
|
|
276
|
-
probe_protocol: 'udp',
|
|
277
|
-
probe_port: udpPort,
|
|
278
|
-
probe_info: `Error: ${err.code} - ${err.message}`,
|
|
279
|
-
response_banner: null
|
|
280
|
-
});
|
|
281
|
-
}
|
|
282
|
-
socket.close();
|
|
283
|
-
resolve();
|
|
279
|
+
if (err.code === 'ECONNREFUSED') settle('ICMP Port Unreachable - host up', true);
|
|
280
|
+
else settle(`Error: ${err.code} - ${err.message}`, false);
|
|
284
281
|
});
|
|
285
282
|
|
|
286
283
|
socket.connect(udpPort, host, (err) => {
|
|
287
|
-
if (err) {
|
|
288
|
-
clearTimeout(timeoutId);
|
|
289
|
-
data.push({
|
|
290
|
-
probe_protocol: 'udp',
|
|
291
|
-
probe_port: udpPort,
|
|
292
|
-
probe_info: `Connect error: ${err.message}`,
|
|
293
|
-
response_banner: null
|
|
294
|
-
});
|
|
295
|
-
socket.close();
|
|
296
|
-
resolve();
|
|
297
|
-
return;
|
|
298
|
-
}
|
|
299
|
-
|
|
284
|
+
if (err) return settle(`Connect error: ${err.message}`, false);
|
|
300
285
|
socket.send(Buffer.alloc(0), (err) => {
|
|
301
|
-
|
|
302
|
-
if (err)
|
|
303
|
-
|
|
304
|
-
up = true;
|
|
305
|
-
data.push({
|
|
306
|
-
probe_protocol: 'udp',
|
|
307
|
-
probe_port: udpPort,
|
|
308
|
-
probe_info: 'ICMP Port Unreachable - host up',
|
|
309
|
-
response_banner: null
|
|
310
|
-
});
|
|
311
|
-
} else {
|
|
312
|
-
data.push({
|
|
313
|
-
probe_protocol: 'udp',
|
|
314
|
-
probe_port: udpPort,
|
|
315
|
-
probe_info: `Send error: ${err.message}`,
|
|
316
|
-
response_banner: null
|
|
317
|
-
});
|
|
318
|
-
}
|
|
319
|
-
} else {
|
|
320
|
-
up = true;
|
|
321
|
-
data.push({
|
|
322
|
-
probe_protocol: 'udp',
|
|
323
|
-
probe_port: udpPort,
|
|
324
|
-
probe_info: 'Send successful, no error - host up (port may be open)',
|
|
325
|
-
response_banner: null
|
|
326
|
-
});
|
|
327
|
-
}
|
|
328
|
-
socket.close();
|
|
329
|
-
resolve();
|
|
286
|
+
if (!err) return; // sent — now wait for a reply, a port-unreachable or the timeout
|
|
287
|
+
if (err.code === 'ECONNREFUSED') settle('ICMP Port Unreachable - host up', true);
|
|
288
|
+
else settle(`Send error: ${err.message}`, false);
|
|
330
289
|
});
|
|
331
290
|
});
|
|
332
291
|
});
|
package/plugins/http_probe.mjs
CHANGED
|
@@ -6,16 +6,31 @@ import http from 'node:http';
|
|
|
6
6
|
import https from 'node:https';
|
|
7
7
|
import { promisify } from 'node:util';
|
|
8
8
|
import { execFile } from 'node:child_process';
|
|
9
|
+
import { redactSetCookie, redactLocation } from '../utils/cookie_redaction.mjs';
|
|
9
10
|
|
|
10
11
|
const execFileP = promisify(execFile);
|
|
11
12
|
|
|
12
13
|
const DEBUG = /^(1|true|yes|on)$/i.test(String(process.env.DEBUG_MODE || process.env.HTTP_DEBUG || ""));
|
|
13
14
|
function dlog(...a) { if (DEBUG) console.log("[http-probe]", ...a); }
|
|
14
15
|
|
|
16
|
+
/**
|
|
17
|
+
* The response headers 006 keeps on its record — ONE (1.3.0 item 1, ruled): Strict-Transport-Security, for Enterprise's
|
|
18
|
+
* Missing-HSTS check. A header map is a channel into every consumer of the record (artifacts, AI payloads, the webhook,
|
|
19
|
+
* GRC), so the set is held by a census over the real concluder's output (tests/service_flag_table.test.mjs), never
|
|
20
|
+
* widened silently.
|
|
21
|
+
*/
|
|
22
|
+
export const RECORD_HEADERS = Object.freeze(['strict-transport-security']);
|
|
23
|
+
const recordHeaders = (h) => Object.fromEntries(RECORD_HEADERS.filter((k) => h?.[k] != null).map((k) => [k, String(h[k])]));
|
|
24
|
+
|
|
15
25
|
function buildBanner(status, headers) {
|
|
16
26
|
const lines = [];
|
|
17
27
|
lines.push(`${status.code} ${status.message}`);
|
|
18
|
-
// Capture the most useful fingerprinting headers
|
|
28
|
+
// Capture the most useful fingerprinting headers. Each VALUE kept here reaches the record, the artifacts and the AI
|
|
29
|
+
// prompt, so none may be a secret (1.3.0, the audit seat's ruling): a Set-Cookie value is the target's session token —
|
|
30
|
+
// its NAME is kept, its value redacted; a Location query can carry an SSO ticket or an OAuth code — the URL is kept
|
|
31
|
+
// without it. The rest describe the product or its configuration: an auth scheme and realm, a server string, a framing
|
|
32
|
+
// policy, a media type.
|
|
33
|
+
const redact = { 'set-cookie': redactSetCookie, location: redactLocation };
|
|
19
34
|
const pick = [
|
|
20
35
|
'www-authenticate',
|
|
21
36
|
'server',
|
|
@@ -27,10 +42,11 @@ function buildBanner(status, headers) {
|
|
|
27
42
|
for (const k of pick) {
|
|
28
43
|
const v = headers[k];
|
|
29
44
|
if (!v) continue;
|
|
45
|
+
const shown = redact[k] ?? String;
|
|
30
46
|
if (Array.isArray(v)) {
|
|
31
|
-
for (const vv of v) lines.push(`${k}: ${vv}`);
|
|
47
|
+
for (const vv of v) lines.push(`${k}: ${shown(vv)}`);
|
|
32
48
|
} else {
|
|
33
|
-
lines.push(`${k}: ${v}`);
|
|
49
|
+
lines.push(`${k}: ${shown(v)}`);
|
|
34
50
|
}
|
|
35
51
|
}
|
|
36
52
|
return lines.join('\r\n').slice(0, 512); // Limit banner size
|
|
@@ -152,6 +168,10 @@ export default {
|
|
|
152
168
|
const req = mod.request(reqOpts, async (res) => {
|
|
153
169
|
const headers = res.headers;
|
|
154
170
|
const status = { code: res.statusCode, message: res.statusMessage };
|
|
171
|
+
// 1.3.0 item 1: the allowlisted headers, on an HTTPS response only — a user agent ignores Strict-Transport-Security
|
|
172
|
+
// over plain HTTP (RFC 6797 §8.1). Set here, where a response ARRIVED: no response, no `headers` key at all, since an
|
|
173
|
+
// empty map would read as "the header is missing".
|
|
174
|
+
if (isHttps) result.headers = recordHeaders(headers);
|
|
155
175
|
|
|
156
176
|
// Initial "up" via response
|
|
157
177
|
result.up = true;
|
|
@@ -260,31 +280,79 @@ export default {
|
|
|
260
280
|
|
|
261
281
|
// Keep OS null unless we have a trustworthy hint; HTTP headers rarely reveal OS reliably.
|
|
262
282
|
|
|
263
|
-
// HTTP method testing via OPTIONS
|
|
283
|
+
// HTTP method testing via OPTIONS. `methodsTested` means AN ALLOW HEADER WAS READ — the probe has no other way to see
|
|
284
|
+
// the method list, so an OPTIONS answer without Allow (a 405, a 200) and no answer at all are both NOT TESTED:
|
|
285
|
+
// methodsTested false and null arrays, never [] (1.3.0, the audit seat's ruling; all three read [] before, so "not
|
|
286
|
+
// tested" read as "none dangerous"). Do not widen "tested" to "answered": a per-method probe is a different capability.
|
|
287
|
+
let optResult = null;
|
|
264
288
|
try {
|
|
265
|
-
|
|
266
|
-
if (optResult?.allow) {
|
|
267
|
-
const methods = optResult.allow.split(',').map(m => m.trim().toUpperCase()).filter(Boolean);
|
|
268
|
-
const dangerous = methods.filter(m => ['PUT', 'DELETE', 'TRACE', 'CONNECT'].includes(m));
|
|
269
|
-
result.allowedMethods = methods;
|
|
270
|
-
result.dangerousMethods = dangerous;
|
|
271
|
-
if (dangerous.length > 0) {
|
|
272
|
-
result.data.push({
|
|
273
|
-
probe_protocol: isHttps ? 'https' : 'http',
|
|
274
|
-
probe_port: port,
|
|
275
|
-
probe_info: `WARNING: Dangerous HTTP methods enabled: ${dangerous.join(', ')}`,
|
|
276
|
-
response_banner: `Allow: ${optResult.allow}`
|
|
277
|
-
});
|
|
278
|
-
}
|
|
279
|
-
} else {
|
|
280
|
-
result.allowedMethods = [];
|
|
281
|
-
result.dangerousMethods = [];
|
|
282
|
-
}
|
|
289
|
+
optResult = await probeOptions(host, port, isHttps, allowInsecure, timeoutMs);
|
|
283
290
|
} catch {
|
|
284
|
-
|
|
285
|
-
|
|
291
|
+
optResult = null;
|
|
292
|
+
}
|
|
293
|
+
if (!optResult || !String(optResult.allow || '').trim()) {
|
|
294
|
+
result.methodsTested = false;
|
|
295
|
+
result.allowedMethods = null;
|
|
296
|
+
result.dangerousMethods = null;
|
|
297
|
+
result.data.push({
|
|
298
|
+
probe_protocol: isHttps ? 'https' : 'http',
|
|
299
|
+
probe_port: port,
|
|
300
|
+
probe_info: optResult
|
|
301
|
+
? `OPTIONS answered ${optResult.status} with no Allow header — HTTP methods not tested`
|
|
302
|
+
: 'OPTIONS was not answered — HTTP methods not tested',
|
|
303
|
+
response_banner: null
|
|
304
|
+
});
|
|
305
|
+
} else {
|
|
306
|
+
const methods = String(optResult.allow || '').split(',').map(m => m.trim().toUpperCase()).filter(Boolean);
|
|
307
|
+
const dangerous = methods.filter(m => ['PUT', 'DELETE', 'TRACE', 'CONNECT'].includes(m));
|
|
308
|
+
result.methodsTested = true;
|
|
309
|
+
result.allowedMethods = methods;
|
|
310
|
+
result.dangerousMethods = dangerous;
|
|
311
|
+
if (dangerous.length > 0) {
|
|
312
|
+
result.data.push({
|
|
313
|
+
probe_protocol: isHttps ? 'https' : 'http',
|
|
314
|
+
probe_port: port,
|
|
315
|
+
probe_info: `WARNING: Dangerous HTTP methods enabled: ${dangerous.join(', ')}`,
|
|
316
|
+
response_banner: `Allow: ${optResult.allow}`
|
|
317
|
+
});
|
|
318
|
+
}
|
|
286
319
|
}
|
|
287
320
|
|
|
288
321
|
return result;
|
|
289
322
|
},
|
|
290
|
-
};
|
|
323
|
+
};
|
|
324
|
+
/**
|
|
325
|
+
* Concluder adapter (1.3.0 lane 3 (a4), the audit seat's Option A). The HTTP probe had no adapter, so its methods result
|
|
326
|
+
* never reached a service record. Its record lands EXACTLY where the concluder's fallback put it — the http / https key,
|
|
327
|
+
* the same program, version, status and source — so no service identity, CPE or Services-detected count moves across
|
|
328
|
+
* 1.2.0 -> 1.3.0; it adds only the methods result. `methodsTested` is true only when an Allow header was READ; otherwise
|
|
329
|
+
* both arrays are null (NOT TESTED, never "none"). Merging into the tcp key is a separate, boarded decision.
|
|
330
|
+
*/
|
|
331
|
+
export function conclude({ result }) {
|
|
332
|
+
const rows = Array.isArray(result?.data) ? result.data : [];
|
|
333
|
+
const row = rows.find(Boolean) || {};
|
|
334
|
+
let status = result?.up ? 'open' : 'unknown';
|
|
335
|
+
if (row?.probe_info && /refused|ECONNREFUSED/i.test(String(row.probe_info))) status = 'closed';
|
|
336
|
+
return [{
|
|
337
|
+
port: Number(row?.probe_port ?? result?.port ?? 0),
|
|
338
|
+
protocol: row?.probe_protocol || result?.protocol || 'tcp',
|
|
339
|
+
service: 'http',
|
|
340
|
+
program: result?.program || 'Unknown',
|
|
341
|
+
version: result?.version || 'Unknown',
|
|
342
|
+
status,
|
|
343
|
+
info: row?.probe_info || null,
|
|
344
|
+
banner: row?.response_banner || null,
|
|
345
|
+
source: 'http',
|
|
346
|
+
evidence: rows,
|
|
347
|
+
// 1.3.0 item 1: the allowlisted response headers, on the HTTPS record only and only when a response arrived (run()
|
|
348
|
+
// sets `headers` there and nowhere else) — filtered again here, so the record can never carry a header outside
|
|
349
|
+
// RECORD_HEADERS whatever the result holds.
|
|
350
|
+
...(row?.probe_protocol === 'https' && result?.headers && typeof result.headers === 'object'
|
|
351
|
+
? { headers: recordHeaders(result.headers) } : {}),
|
|
352
|
+
...('methodsTested' in (result || {}) ? {
|
|
353
|
+
methodsTested: result.methodsTested === true,
|
|
354
|
+
allowedMethods: result.methodsTested === true ? result.allowedMethods : null,
|
|
355
|
+
dangerousMethods: result.methodsTested === true ? result.dangerousMethods : null,
|
|
356
|
+
} : {}),
|
|
357
|
+
}];
|
|
358
|
+
}
|
package/plugins/mcp_scanner.mjs
CHANGED
|
@@ -42,6 +42,17 @@ export const MCP_CANDIDATE_PORTS = [1967, 3000, 3005, 5173, 6274, 6277, 8000, 80
|
|
|
42
42
|
// network-reachable. Detection on a non-loopback target = MEDIUM finding.
|
|
43
43
|
export const MCP_INSPECTOR_PORTS = new Set([5173, 6274, 6277]);
|
|
44
44
|
|
|
45
|
+
// The grade of each security flag buildFindings() sets, declared beside the code that sets it (the research grades in
|
|
46
|
+
// its comments). The shared service-flag table (utils/service_flags.mjs) reads this, so every reader grades a flag the
|
|
47
|
+
// moment it is added here — and tests/service_flag_table.test.mjs fails if buildFindings sets a flag this does not grade.
|
|
48
|
+
export const MCP_FLAG_SEVERITY = Object.freeze({
|
|
49
|
+
mcpAnonymousAccess: 'Critical',
|
|
50
|
+
mcpAnonymousToolList: 'Critical',
|
|
51
|
+
mcpCleartextTransport: 'High',
|
|
52
|
+
mcpDeprecatedProtocol: 'High',
|
|
53
|
+
mcpInspectorExposed: 'Medium',
|
|
54
|
+
});
|
|
55
|
+
|
|
45
56
|
// MCP RPC method paths (in priority order — try canonical mountpoints first)
|
|
46
57
|
export const MCP_PROBE_PATHS = ['/', '/mcp', '/jsonrpc', '/sse', '/messages'];
|
|
47
58
|
|
|
@@ -454,7 +465,10 @@ export function conclude({ result }) {
|
|
|
454
465
|
detection.tools.length ? `tools=${detection.tools.length}` : null,
|
|
455
466
|
].filter(Boolean).join(' '),
|
|
456
467
|
authoritative: true,
|
|
457
|
-
//
|
|
468
|
+
// The producer's own name for its records — the shared service-flag table keys the MCP rows on it (1.3.0 (s1) B).
|
|
469
|
+
source: 'mcp',
|
|
470
|
+
// Security flags, graded by MCP_FLAG_SEVERITY through the shared service-flag table — the Markdown report, SARIF,
|
|
471
|
+
// the CSV and --fail-on all read them there (1.3.0 (s1)).
|
|
458
472
|
...flags,
|
|
459
473
|
// Evidence fields per N.5 FindingSchema
|
|
460
474
|
evidence: { cwe, owasp, mitre },
|
|
@@ -452,19 +452,23 @@ async function probeMdnsSmb(){
|
|
|
452
452
|
}
|
|
453
453
|
|
|
454
454
|
// SMB2 null session probe — attempts anonymous auth, IPC$ tree connect, and share/user enum
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
455
|
+
// `port`, `enabled` and `timeoutMs` default to production's 445, SMB_NULL_SESSION and SMB_NULL_SESSION_TIMEOUT; they are
|
|
456
|
+
// parameters so the outcomes can be driven against a loopback listener.
|
|
457
|
+
export async function probeNullSession(host, { port = 445, enabled = ENABLE_NULL_SESSION, timeoutMs = NULL_SESSION_TIMEOUT } = {}){
|
|
458
|
+
// 1.3.0 (s3): null unless MEASURED; nullSessionTested is true when it was, else the reason it was not.
|
|
459
|
+
const result = { nullSessionAllowed: null, nullSessionTested: enabled ? 'no-answer' : 'opt-in-off', shares: [], users: [] };
|
|
460
|
+
if (!enabled) return result;
|
|
461
|
+
const measured = (allowed) => { result.nullSessionAllowed = allowed; result.nullSessionTested = true; };
|
|
458
462
|
|
|
459
463
|
return new Promise(resolve => {
|
|
460
464
|
let resolved = false;
|
|
461
465
|
const safeResolve = (v) => { if (resolved) return; resolved = true; resolve(v); };
|
|
462
466
|
|
|
463
|
-
const sock = net.createConnection({ host, port
|
|
467
|
+
const sock = net.createConnection({ host, port });
|
|
464
468
|
const to = setTimeout(() => {
|
|
465
469
|
try { sock.destroy(); } catch {}
|
|
466
470
|
safeResolve(result);
|
|
467
|
-
},
|
|
471
|
+
}, timeoutMs);
|
|
468
472
|
|
|
469
473
|
let phase = 'negotiate'; // negotiate | session-setup-1 | session-setup-2 | tree-connect | done
|
|
470
474
|
let sessionIdLo = 0;
|
|
@@ -517,23 +521,25 @@ async function probeNullSession(host){
|
|
|
517
521
|
// Some servers accept directly (guest/anonymous)
|
|
518
522
|
sessionIdLo = hdr.sessionIdLo;
|
|
519
523
|
sessionIdHi = hdr.sessionIdHi;
|
|
520
|
-
|
|
524
|
+
measured(true);
|
|
521
525
|
phase = 'tree-connect';
|
|
522
526
|
try { sock.write(wrapNbss(buildSmb2TreeConnect(host, sessionIdLo, sessionIdHi))); } catch { finish(); }
|
|
523
527
|
} else {
|
|
524
|
-
// ACCESS_DENIED or other — null session not allowed
|
|
528
|
+
// ACCESS_DENIED or other — null session not allowed (the server answered: a measurement)
|
|
525
529
|
dlog('null-session: session setup denied, status=0x' + hdr.status.toString(16));
|
|
530
|
+
measured(false);
|
|
526
531
|
finish();
|
|
527
532
|
}
|
|
528
533
|
} else if (phase === 'session-setup-2') {
|
|
529
534
|
if (hdr.status === STATUS_SUCCESS) {
|
|
530
|
-
|
|
535
|
+
measured(true);
|
|
531
536
|
sessionIdLo = hdr.sessionIdLo; // H2 fix: unconditional assign, not ||
|
|
532
537
|
sessionIdHi = hdr.sessionIdHi;
|
|
533
538
|
phase = 'tree-connect';
|
|
534
539
|
try { sock.write(wrapNbss(buildSmb2TreeConnect(host, sessionIdLo, sessionIdHi))); } catch { finish(); }
|
|
535
540
|
} else {
|
|
536
541
|
dlog('null-session: session auth denied, status=0x' + hdr.status.toString(16));
|
|
542
|
+
measured(false);
|
|
537
543
|
finish();
|
|
538
544
|
}
|
|
539
545
|
} else if (phase === 'tree-connect') {
|
|
@@ -638,30 +644,39 @@ export default {
|
|
|
638
644
|
}
|
|
639
645
|
}catch{ /* best-effort only */ }
|
|
640
646
|
|
|
641
|
-
// SMB2 null session enumeration (opt-in via SMB_NULL_SESSION env)
|
|
642
|
-
let nullSessionAllowed =
|
|
647
|
+
// SMB2 null session enumeration (opt-in via SMB_NULL_SESSION env). 1.3.0 (s3): null unless measured, with its state.
|
|
648
|
+
let nullSessionAllowed = null;
|
|
649
|
+
let nullSessionTested = ENABLE_NULL_SESSION ? 'no-answer' : 'opt-in-off';
|
|
643
650
|
let shares = [];
|
|
644
651
|
let users = [];
|
|
645
652
|
|
|
646
653
|
try {
|
|
647
654
|
const ns = await probeNullSession(host);
|
|
648
655
|
nullSessionAllowed = ns.nullSessionAllowed;
|
|
656
|
+
nullSessionTested = ns.nullSessionTested;
|
|
649
657
|
shares = ns.shares;
|
|
650
658
|
users = ns.users;
|
|
651
|
-
if (nullSessionAllowed) {
|
|
659
|
+
if (nullSessionAllowed === true) {
|
|
652
660
|
data.push({
|
|
653
661
|
probe_protocol: 'tcp',
|
|
654
662
|
probe_port: 445,
|
|
655
663
|
probe_info: 'WARNING: SMB null session authentication succeeded',
|
|
656
664
|
response_banner: `Null session allowed. Shares: ${shares.length}, Users: ${users.length}`
|
|
657
665
|
});
|
|
658
|
-
} else if (
|
|
666
|
+
} else if (nullSessionAllowed === false) {
|
|
659
667
|
data.push({
|
|
660
668
|
probe_protocol: 'tcp',
|
|
661
669
|
probe_port: 445,
|
|
662
670
|
probe_info: 'SMB null session denied (good)',
|
|
663
671
|
response_banner: null
|
|
664
672
|
});
|
|
673
|
+
} else if (ENABLE_NULL_SESSION) {
|
|
674
|
+
data.push({
|
|
675
|
+
probe_protocol: 'tcp',
|
|
676
|
+
probe_port: 445,
|
|
677
|
+
probe_info: 'SMB null session not measured — the exchange did not complete',
|
|
678
|
+
response_banner: null
|
|
679
|
+
});
|
|
665
680
|
}
|
|
666
681
|
} catch {
|
|
667
682
|
if (ENABLE_NULL_SESSION) {
|
|
@@ -680,6 +695,7 @@ export default {
|
|
|
680
695
|
version,
|
|
681
696
|
type: 'netbios/smb',
|
|
682
697
|
nullSessionAllowed,
|
|
698
|
+
nullSessionTested,
|
|
683
699
|
shares,
|
|
684
700
|
users,
|
|
685
701
|
data
|
|
@@ -706,7 +722,9 @@ export async function conclude({ host, result }){
|
|
|
706
722
|
program: result?.program || 'Unknown',
|
|
707
723
|
version: result?.version || 'Unknown',
|
|
708
724
|
status, info, banner,
|
|
709
|
-
|
|
725
|
+
// 1.3.0 (s3): null unless measured — the old default `false` read "refused" for a check that never ran.
|
|
726
|
+
nullSessionAllowed: result?.nullSessionAllowed ?? null,
|
|
727
|
+
nullSessionTested: result?.nullSessionTested ?? null,
|
|
710
728
|
shares: result?.shares ?? [],
|
|
711
729
|
users: result?.users ?? [],
|
|
712
730
|
source: 'netbios',
|
|
@@ -725,6 +743,7 @@ export async function conclude({ host, result }){
|
|
|
725
743
|
status: 'open', info: 'WARNING: SMB null session allowed — anonymous enumeration possible',
|
|
726
744
|
banner: `Shares: ${(result.shares||[]).join(', ') || 'none'}, Users: ${(result.users||[]).join(', ') || 'none'}`,
|
|
727
745
|
nullSessionAllowed: true,
|
|
746
|
+
nullSessionTested: true,
|
|
728
747
|
shares: result?.shares ?? [],
|
|
729
748
|
users: result?.users ?? [],
|
|
730
749
|
source: 'netbios',
|
|
@@ -128,8 +128,12 @@ export default {
|
|
|
128
128
|
const agent = await buildAgentIfNeeded(scheme, insecure);
|
|
129
129
|
const ctrl = new AbortController();
|
|
130
130
|
const to = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
131
|
+
// 1.3.0 lane 2 (R2): never follow a redirect. The banner and version are read at `/`; a 3xx is
|
|
132
|
+
// the target's answer. Followed, a target could send the scanner to any host — loopback and
|
|
133
|
+
// cloud metadata included — with no SSRF check at the hop.
|
|
131
134
|
const res = await doFetch(url, {
|
|
132
135
|
method: 'GET',
|
|
136
|
+
redirect: 'manual',
|
|
133
137
|
headers: {
|
|
134
138
|
'User-Agent': 'opensearch-js/3.4.0 (opensearch-scanner)'
|
|
135
139
|
},
|