nsauditor-ai 0.1.0
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/CONTRIBUTING.md +24 -0
- package/LICENSE +21 -0
- package/README.md +584 -0
- package/bin/nsauditor-ai-mcp.mjs +2 -0
- package/bin/nsauditor-ai.mjs +2 -0
- package/cli.mjs +939 -0
- package/config/services.json +304 -0
- package/docs/EULA-nsauditor-ai.md +324 -0
- package/index.mjs +15 -0
- package/mcp_server.mjs +382 -0
- package/package.json +44 -0
- package/plugin_manager.mjs +829 -0
- package/plugins/arp_scanner.mjs +162 -0
- package/plugins/db_scanner.mjs +248 -0
- package/plugins/dns_scanner.mjs +369 -0
- package/plugins/dnssd-scanner.mjs +245 -0
- package/plugins/ftp_banner_check.mjs +247 -0
- package/plugins/host_up_check.mjs +337 -0
- package/plugins/http_probe.mjs +290 -0
- package/plugins/llmnr_scanner.mjs +130 -0
- package/plugins/mdns_scanner.mjs +522 -0
- package/plugins/netbios_scanner.mjs +737 -0
- package/plugins/opensearch_scanner.mjs +276 -0
- package/plugins/os_detector.mjs +436 -0
- package/plugins/ping_checker.mjs +271 -0
- package/plugins/port_scanner.mjs +250 -0
- package/plugins/result_concluder.mjs +274 -0
- package/plugins/snmp_scanner.mjs +278 -0
- package/plugins/ssh_scanner.mjs +421 -0
- package/plugins/sunrpc_scanner.mjs +339 -0
- package/plugins/syn_scanner.mjs +314 -0
- package/plugins/tls_scanner.mjs +225 -0
- package/plugins/upnp_scanner.mjs +441 -0
- package/plugins/webapp_detector.mjs +246 -0
- package/plugins/wsd_scanner.mjs +290 -0
- package/utils/attack_map.mjs +180 -0
- package/utils/capabilities.mjs +53 -0
- package/utils/conclusion_utils.mjs +70 -0
- package/utils/cpe.mjs +74 -0
- package/utils/cve_validator.mjs +64 -0
- package/utils/cvss.mjs +129 -0
- package/utils/delta_reporter.mjs +110 -0
- package/utils/export_csv.mjs +82 -0
- package/utils/finding_queue.mjs +64 -0
- package/utils/finding_schema.mjs +36 -0
- package/utils/host_iterator.mjs +166 -0
- package/utils/license.mjs +29 -0
- package/utils/net_validation.mjs +66 -0
- package/utils/nvd_cache.mjs +77 -0
- package/utils/nvd_client.mjs +130 -0
- package/utils/oui.mjs +107 -0
- package/utils/plugin_discovery.mjs +89 -0
- package/utils/prompts.mjs +143 -0
- package/utils/raw_report_html.mjs +170 -0
- package/utils/redact.mjs +79 -0
- package/utils/report_html.mjs +236 -0
- package/utils/sarif.mjs +225 -0
- package/utils/scan_history.mjs +248 -0
- package/utils/scheduler.mjs +157 -0
- package/utils/webhook.mjs +177 -0
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
// plugins/ssh_scanner.mjs
|
|
2
|
+
// SSH Scanner — connects to TCP/22, parses the RFC 4253 identification string,
|
|
3
|
+
// and optionally performs SSH_MSG_KEXINIT exchange to extract supported algorithms.
|
|
4
|
+
|
|
5
|
+
import net from "node:net";
|
|
6
|
+
import crypto from "node:crypto";
|
|
7
|
+
|
|
8
|
+
/** Your parser, kept exactly as provided (minor safety trims) */
|
|
9
|
+
export function parseSshBanner(b) {
|
|
10
|
+
const line = (b || "").split(/\r?\n/)[0] || "";
|
|
11
|
+
const m = /^SSH-([0-9.]+)-([^\s]+)(?:\s+(.+))?/.exec(line);
|
|
12
|
+
if (!m) return null;
|
|
13
|
+
|
|
14
|
+
const proto = m[1] || "";
|
|
15
|
+
const prodToken = m[2] || "";
|
|
16
|
+
const trail = (m[3] || "").trim();
|
|
17
|
+
|
|
18
|
+
let product = prodToken;
|
|
19
|
+
let version = "";
|
|
20
|
+
let pm = /^(OpenSSH)[-_]?(\d[\w.]+)/i.exec(prodToken);
|
|
21
|
+
if (pm) {
|
|
22
|
+
product = pm[1];
|
|
23
|
+
version = pm[2];
|
|
24
|
+
} else {
|
|
25
|
+
pm = /^([A-Za-z]+_?SSH)[-_]?(\d[\w.]+)/.exec(prodToken);
|
|
26
|
+
if (pm) {
|
|
27
|
+
product = pm[1];
|
|
28
|
+
version = pm[2];
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
let os = "";
|
|
33
|
+
let osVersion = "";
|
|
34
|
+
|
|
35
|
+
// Detect Solaris from Sun_SSH even when there is no trailing comment
|
|
36
|
+
if (/Sun_SSH/i.test(prodToken)) {
|
|
37
|
+
os = "Solaris";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (trail) {
|
|
41
|
+
const tok = trail.split(/\s+/)[0];
|
|
42
|
+
const om = /^(Ubuntu|Debian|Raspbian|FreeBSD|OpenBSD|Alpine|Oracle|SUSE|CentOS|RedHat|Arch|Manjaro|Gentoo)-(.+)$/.exec(tok);
|
|
43
|
+
if (om) {
|
|
44
|
+
os = om[1];
|
|
45
|
+
osVersion = om[2];
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return { name: "ssh", product: product || "ssh", version: version || "", proto: proto || "", os, osVersion };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/* ---- Weak algorithm definitions ---- */
|
|
53
|
+
const WEAK_KEX = ['diffie-hellman-group1-sha1', 'diffie-hellman-group14-sha1', 'diffie-hellman-group-exchange-sha1'];
|
|
54
|
+
const WEAK_CIPHERS = ['aes128-cbc', 'aes192-cbc', 'aes256-cbc', '3des-cbc', 'blowfish-cbc', 'arcfour', 'arcfour128', 'arcfour256'];
|
|
55
|
+
const WEAK_MACS = ['hmac-sha1', 'hmac-md5', 'hmac-sha1-96', 'hmac-md5-96'];
|
|
56
|
+
|
|
57
|
+
/* ---- Client KEXINIT offered algorithms ---- */
|
|
58
|
+
const CLIENT_KEX = 'curve25519-sha256,ecdh-sha2-nistp256,diffie-hellman-group14-sha256';
|
|
59
|
+
const CLIENT_HOST_KEY = 'ssh-ed25519,ecdsa-sha2-nistp256,rsa-sha2-512,rsa-sha2-256';
|
|
60
|
+
const CLIENT_ENCRYPTION = 'aes256-gcm@openssh.com,aes128-gcm@openssh.com,aes256-ctr,aes128-ctr';
|
|
61
|
+
const CLIENT_MAC = 'hmac-sha2-256-etm@openssh.com,hmac-sha2-256';
|
|
62
|
+
const CLIENT_COMPRESSION = 'none';
|
|
63
|
+
|
|
64
|
+
/* ---- SSH binary packet helpers ---- */
|
|
65
|
+
|
|
66
|
+
function encodeNameList(str) {
|
|
67
|
+
const buf = Buffer.from(str, 'utf8');
|
|
68
|
+
const len = Buffer.alloc(4);
|
|
69
|
+
len.writeUInt32BE(buf.length);
|
|
70
|
+
return Buffer.concat([len, buf]);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function buildClientKexinit() {
|
|
74
|
+
const parts = [
|
|
75
|
+
Buffer.from([20]), // SSH_MSG_KEXINIT
|
|
76
|
+
crypto.randomBytes(16), // cookie
|
|
77
|
+
encodeNameList(CLIENT_KEX),
|
|
78
|
+
encodeNameList(CLIENT_HOST_KEY),
|
|
79
|
+
encodeNameList(CLIENT_ENCRYPTION), // c2s
|
|
80
|
+
encodeNameList(CLIENT_ENCRYPTION), // s2c
|
|
81
|
+
encodeNameList(CLIENT_MAC), // c2s
|
|
82
|
+
encodeNameList(CLIENT_MAC), // s2c
|
|
83
|
+
encodeNameList(CLIENT_COMPRESSION), // c2s
|
|
84
|
+
encodeNameList(CLIENT_COMPRESSION), // s2c
|
|
85
|
+
encodeNameList(''), // languages c2s
|
|
86
|
+
encodeNameList(''), // languages s2c
|
|
87
|
+
Buffer.from([0]), // first_kex_packet_follows
|
|
88
|
+
Buffer.alloc(4, 0), // reserved
|
|
89
|
+
];
|
|
90
|
+
const payload = Buffer.concat(parts);
|
|
91
|
+
|
|
92
|
+
// SSH binary packet: [4:packet_length][1:padding_length][payload][padding]
|
|
93
|
+
// padding must be at least 4 bytes and bring total (padding_length + payload + padding) to multiple of 8
|
|
94
|
+
const blockSize = 8;
|
|
95
|
+
const minPadding = 4;
|
|
96
|
+
let paddingLen = blockSize - ((1 + payload.length + minPadding) % blockSize);
|
|
97
|
+
if (paddingLen < minPadding) paddingLen += blockSize;
|
|
98
|
+
// ensure paddingLen stays within a single unsigned byte
|
|
99
|
+
if (paddingLen > 255) paddingLen = minPadding; // defensive
|
|
100
|
+
|
|
101
|
+
const packetLength = 1 + payload.length + paddingLen;
|
|
102
|
+
const header = Buffer.alloc(5);
|
|
103
|
+
header.writeUInt32BE(packetLength, 0);
|
|
104
|
+
header[4] = paddingLen;
|
|
105
|
+
const padding = crypto.randomBytes(paddingLen);
|
|
106
|
+
|
|
107
|
+
return Buffer.concat([header, payload, padding]);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function readNameList(buf, offset) {
|
|
111
|
+
if (offset + 4 > buf.length) return { value: [], next: buf.length };
|
|
112
|
+
const len = buf.readUInt32BE(offset);
|
|
113
|
+
if (offset + 4 + len > buf.length) return { value: [], next: buf.length };
|
|
114
|
+
const str = buf.subarray(offset + 4, offset + 4 + len).toString('utf8');
|
|
115
|
+
return { value: str ? str.split(',') : [], next: offset + 4 + len };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function parseServerKexinit(packetPayload) {
|
|
119
|
+
// packetPayload starts at the SSH_MSG_KEXINIT byte (20)
|
|
120
|
+
if (!packetPayload || packetPayload.length < 17 || packetPayload[0] !== 20) return null;
|
|
121
|
+
|
|
122
|
+
let offset = 1 + 16; // skip type + cookie
|
|
123
|
+
const names = [
|
|
124
|
+
'kex', 'hostKey',
|
|
125
|
+
'encryptionC2S', 'encryptionS2C',
|
|
126
|
+
'macC2S', 'macS2C',
|
|
127
|
+
'compressionC2S', 'compressionS2C',
|
|
128
|
+
'languagesC2S', 'languagesS2C',
|
|
129
|
+
];
|
|
130
|
+
const result = {};
|
|
131
|
+
for (const name of names) {
|
|
132
|
+
const { value, next } = readNameList(packetPayload, offset);
|
|
133
|
+
result[name] = value;
|
|
134
|
+
offset = next;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
kex: result.kex,
|
|
139
|
+
hostKey: result.hostKey,
|
|
140
|
+
encryption: [...new Set([...result.encryptionC2S, ...result.encryptionS2C])],
|
|
141
|
+
mac: [...new Set([...result.macC2S, ...result.macS2C])],
|
|
142
|
+
compression: [...new Set([...result.compressionC2S, ...result.compressionS2C])],
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function findWeakAlgorithms(algorithms) {
|
|
147
|
+
if (!algorithms) return [];
|
|
148
|
+
const weak = [];
|
|
149
|
+
for (const a of algorithms.kex || []) { if (WEAK_KEX.includes(a)) weak.push(a); }
|
|
150
|
+
for (const a of algorithms.encryption || []) { if (WEAK_CIPHERS.includes(a)) weak.push(a); }
|
|
151
|
+
for (const a of algorithms.mac || []) { if (WEAK_MACS.includes(a)) weak.push(a); }
|
|
152
|
+
return weak;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function extractKexinitPayload(binaryBuf) {
|
|
156
|
+
// Reads an SSH binary packet from the buffer and returns the payload if it's KEXINIT
|
|
157
|
+
if (binaryBuf.length < 5) return null;
|
|
158
|
+
const packetLength = binaryBuf.readUInt32BE(0);
|
|
159
|
+
if (binaryBuf.length < 4 + packetLength) return null;
|
|
160
|
+
const paddingLength = binaryBuf[4];
|
|
161
|
+
const payloadLength = packetLength - 1 - paddingLength;
|
|
162
|
+
if (payloadLength < 1) return null;
|
|
163
|
+
const payload = binaryBuf.subarray(5, 5 + payloadLength);
|
|
164
|
+
if (payload[0] !== 20) return null; // not KEXINIT
|
|
165
|
+
return payload;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function shouldCheckAlgorithms() {
|
|
169
|
+
const v = (process.env.SSH_CHECK_ALGORITHMS ?? 'true').toLowerCase();
|
|
170
|
+
return v !== 'false' && v !== '0' && v !== 'no';
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function readSshBanner(host, port, timeoutMs) {
|
|
174
|
+
return new Promise((resolve) => {
|
|
175
|
+
const result = {
|
|
176
|
+
up: false,
|
|
177
|
+
program: "Unknown",
|
|
178
|
+
version: "Unknown",
|
|
179
|
+
os: null,
|
|
180
|
+
type: "ssh",
|
|
181
|
+
algorithms: null,
|
|
182
|
+
weakAlgorithms: [],
|
|
183
|
+
data: []
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
let done = false;
|
|
187
|
+
let connected = false;
|
|
188
|
+
let rawBuf = Buffer.alloc(0); // always accumulate raw bytes
|
|
189
|
+
let bannerLine = null;
|
|
190
|
+
let phase = 'banner'; // 'banner' | 'kexinit' | 'done'
|
|
191
|
+
let kexTimer = null;
|
|
192
|
+
|
|
193
|
+
const finalize = (probe_info, banner = null) => {
|
|
194
|
+
if (done) return;
|
|
195
|
+
done = true;
|
|
196
|
+
phase = 'done';
|
|
197
|
+
if (kexTimer) { clearTimeout(kexTimer); kexTimer = null; }
|
|
198
|
+
|
|
199
|
+
// Parse banner if present
|
|
200
|
+
if (banner && typeof banner === "string") {
|
|
201
|
+
const parsed = parseSshBanner(banner);
|
|
202
|
+
if (parsed) {
|
|
203
|
+
result.up = true;
|
|
204
|
+
result.program = parsed.product || "Unknown";
|
|
205
|
+
result.version = parsed.version || "Unknown";
|
|
206
|
+
result.os = parsed.os ? (parsed.osVersion ? `${parsed.os} ${parsed.osVersion}` : parsed.os) : null;
|
|
207
|
+
probe_info = probe_info || `SSH banner parsed (proto ${parsed.proto || "?"})`;
|
|
208
|
+
} else {
|
|
209
|
+
// We got a line but couldn't parse — still useful evidence.
|
|
210
|
+
result.up = connected || !!banner;
|
|
211
|
+
probe_info = probe_info || "SSH banner received (unparsed)";
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// If connection was refused, still mark host up (closed port heuristic)
|
|
216
|
+
if (!banner && !result.up && connected) result.up = true;
|
|
217
|
+
|
|
218
|
+
result.data.push({
|
|
219
|
+
probe_protocol: "tcp",
|
|
220
|
+
probe_port: port,
|
|
221
|
+
probe_info,
|
|
222
|
+
response_banner: banner
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
resolve(result);
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
const tryParseKexinit = (binaryBuf) => {
|
|
229
|
+
const payload = extractKexinitPayload(binaryBuf);
|
|
230
|
+
if (!payload) return false;
|
|
231
|
+
const algorithms = parseServerKexinit(payload);
|
|
232
|
+
if (algorithms) {
|
|
233
|
+
result.algorithms = algorithms;
|
|
234
|
+
result.weakAlgorithms = findWeakAlgorithms(algorithms);
|
|
235
|
+
}
|
|
236
|
+
return true;
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
try {
|
|
240
|
+
const sock = net.createConnection({ host, port });
|
|
241
|
+
|
|
242
|
+
// Set conservative timeout (env override)
|
|
243
|
+
const to = Number(process.env.SSH_BANNER_TIMEOUT || timeoutMs || 1500);
|
|
244
|
+
sock.setTimeout(Number.isFinite(to) && to > 0 ? to : 1500);
|
|
245
|
+
// Do NOT setEncoding — keep raw Buffers to preserve binary KEXINIT data
|
|
246
|
+
|
|
247
|
+
const checkAlgs = shouldCheckAlgorithms();
|
|
248
|
+
|
|
249
|
+
sock.on("connect", () => {
|
|
250
|
+
connected = true;
|
|
251
|
+
// For SSH, server sends banner first—no need to send anything
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
const MAX_SSH_BUF = 256 * 1024; // 256KB — more than enough for banner + KEXINIT
|
|
255
|
+
sock.on("data", (chunk) => {
|
|
256
|
+
const chunkBuf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
257
|
+
rawBuf = Buffer.concat([rawBuf, chunkBuf]);
|
|
258
|
+
if (rawBuf.length > MAX_SSH_BUF) {
|
|
259
|
+
try { sock.destroy(); } catch {}
|
|
260
|
+
finalize('SSH data exceeded safe limit', bannerLine);
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (phase === 'banner') {
|
|
265
|
+
// Look for newline in the raw buffer (SSH banner is ASCII, terminated by \n)
|
|
266
|
+
const nlIdx = rawBuf.indexOf(0x0a); // \n
|
|
267
|
+
if (nlIdx === -1) return; // need more data
|
|
268
|
+
|
|
269
|
+
// Extract the banner line (strip trailing \r if present)
|
|
270
|
+
let lineEnd = nlIdx;
|
|
271
|
+
if (lineEnd > 0 && rawBuf[lineEnd - 1] === 0x0d) lineEnd--; // strip \r
|
|
272
|
+
const line = rawBuf.subarray(0, lineEnd).toString('utf8');
|
|
273
|
+
|
|
274
|
+
// Remainder after the banner line (may contain binary KEXINIT)
|
|
275
|
+
const remainder = rawBuf.subarray(nlIdx + 1);
|
|
276
|
+
|
|
277
|
+
if (!checkAlgs) {
|
|
278
|
+
// Banner-only mode
|
|
279
|
+
try { sock.end(); } catch {}
|
|
280
|
+
finalize("SSH banner received", line);
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Check if server KEXINIT is already in the remainder
|
|
285
|
+
if (remainder.length > 0 && tryParseKexinit(remainder)) {
|
|
286
|
+
try { sock.end(); } catch {}
|
|
287
|
+
finalize("SSH banner received", line);
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Switch to KEXINIT phase
|
|
292
|
+
bannerLine = line;
|
|
293
|
+
phase = 'kexinit';
|
|
294
|
+
// Reset rawBuf to only hold the remainder (post-banner binary data)
|
|
295
|
+
rawBuf = remainder;
|
|
296
|
+
|
|
297
|
+
// Send our client banner identification string (required before KEXINIT)
|
|
298
|
+
sock.write('SSH-2.0-NSAuditor_1.0\r\n');
|
|
299
|
+
// Send our KEXINIT
|
|
300
|
+
sock.write(buildClientKexinit());
|
|
301
|
+
|
|
302
|
+
// Set a separate timeout for KEXINIT exchange
|
|
303
|
+
const kexTimeoutMs = Number(process.env.SSH_KEXINIT_TIMEOUT || 3000);
|
|
304
|
+
kexTimer = setTimeout(() => {
|
|
305
|
+
// KEXINIT timed out — finalize with banner only (graceful fallback)
|
|
306
|
+
try { sock.end(); } catch {}
|
|
307
|
+
finalize("SSH banner received", bannerLine);
|
|
308
|
+
}, kexTimeoutMs);
|
|
309
|
+
} else if (phase === 'kexinit') {
|
|
310
|
+
// Try to extract KEXINIT payload from accumulated binary data
|
|
311
|
+
if (tryParseKexinit(rawBuf)) {
|
|
312
|
+
try { sock.end(); } catch {}
|
|
313
|
+
finalize("SSH banner received", bannerLine);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
sock.on("timeout", () => {
|
|
319
|
+
try { sock.destroy(new Error("Timeout")); } catch {}
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
sock.on("error", (err) => {
|
|
323
|
+
if (phase === 'kexinit' && bannerLine) {
|
|
324
|
+
// KEXINIT failed but we have the banner — graceful fallback
|
|
325
|
+
finalize("SSH banner received", bannerLine);
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
// ECONNREFUSED implies host up, port closed (like your FTP plugin)
|
|
329
|
+
if (err?.code === "ECONNREFUSED") {
|
|
330
|
+
finalize("Connection refused - host up, SSH port closed", null);
|
|
331
|
+
} else if (err?.code === "ETIMEDOUT") {
|
|
332
|
+
finalize("Timeout", null);
|
|
333
|
+
} else {
|
|
334
|
+
finalize(`Error: ${err?.code || err?.message || String(err)}`, null);
|
|
335
|
+
}
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
sock.on("close", () => {
|
|
339
|
+
if (!done && phase === 'kexinit' && bannerLine) {
|
|
340
|
+
// Connection closed during KEXINIT — graceful fallback with banner
|
|
341
|
+
finalize("SSH banner received", bannerLine);
|
|
342
|
+
} else if (!done) {
|
|
343
|
+
finalize(connected ? "Connection closed before banner" : "No response", null);
|
|
344
|
+
}
|
|
345
|
+
});
|
|
346
|
+
} catch (err) {
|
|
347
|
+
finalize(`Exception: ${err?.message || String(err)}`, null);
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
export default {
|
|
353
|
+
id: "002",
|
|
354
|
+
name: "SSH Scanner",
|
|
355
|
+
description: "Connects to SSH (TCP 22), reads the identification banner, and extracts product, version, and OS hints.",
|
|
356
|
+
priority: 50,
|
|
357
|
+
requirements: { host: "up", tcp_open: [22] },
|
|
358
|
+
protocols: ["tcp"],
|
|
359
|
+
ports: [22],
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* @param {string} host
|
|
363
|
+
* @param {number} [port=22]
|
|
364
|
+
* @param {object} [options]
|
|
365
|
+
* @returns {Promise<object>} result object (manager wraps with id/name)
|
|
366
|
+
*/
|
|
367
|
+
async run(host, port = 22, options = {}) {
|
|
368
|
+
console.log(`Running SSH Scanner on ${host}:${port}`);
|
|
369
|
+
const res = await readSshBanner(host, port, options?.timeoutMs || 1500);
|
|
370
|
+
// Mirror your other plugins’ console style (manager will also print the wrapped result)
|
|
371
|
+
console.log("SSH Scanner Result:", JSON.stringify({
|
|
372
|
+
id: this.id,
|
|
373
|
+
name: this.name,
|
|
374
|
+
result: res
|
|
375
|
+
}, null, 2));
|
|
376
|
+
return res; // manager expects only the "result" object
|
|
377
|
+
}
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
import { statusFrom } from '../utils/conclusion_utils.mjs';
|
|
381
|
+
|
|
382
|
+
export async function conclude({ host, result }) {
|
|
383
|
+
const rows = Array.isArray(result?.data) ? result.data : [];
|
|
384
|
+
const items = [];
|
|
385
|
+
for (const r of rows) {
|
|
386
|
+
if (String(r?.probe_protocol||'') !== 'tcp') continue;
|
|
387
|
+
const port = Number(r?.probe_port ?? 22);
|
|
388
|
+
const info = r?.probe_info || '';
|
|
389
|
+
const banner = r?.response_banner || '';
|
|
390
|
+
// SSH timeout policy
|
|
391
|
+
let status;
|
|
392
|
+
if (/timeout/i.test(String(info))) {
|
|
393
|
+
const pol = String(process.env.CONCLUDER_SSH_TIMEOUT_AS || 'filtered').toLowerCase();
|
|
394
|
+
status = (pol === 'closed' || pol === 'unknown' || pol === 'filtered') ? pol : 'filtered';
|
|
395
|
+
} else {
|
|
396
|
+
status = statusFrom({ info, banner, fallbackUp: result?.up });
|
|
397
|
+
}
|
|
398
|
+
items.push({
|
|
399
|
+
port, protocol: 'tcp', service: 'ssh',
|
|
400
|
+
program: result?.program || 'Unknown',
|
|
401
|
+
version: result?.version || 'Unknown',
|
|
402
|
+
status, info: info || null, banner: banner || null,
|
|
403
|
+
algorithms: result?.algorithms || null,
|
|
404
|
+
weakAlgorithms: result?.weakAlgorithms || [],
|
|
405
|
+
source: 'ssh', evidence: rows, authoritative: true
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
if (!items.length) {
|
|
409
|
+
items.push({
|
|
410
|
+
port: 22, protocol: 'tcp', service: 'ssh',
|
|
411
|
+
program: result?.program || 'Unknown',
|
|
412
|
+
version: result?.version || 'Unknown',
|
|
413
|
+
status: result?.up ? 'open' : 'unknown',
|
|
414
|
+
info: null, banner: null,
|
|
415
|
+
algorithms: result?.algorithms || null,
|
|
416
|
+
weakAlgorithms: result?.weakAlgorithms || [],
|
|
417
|
+
source: 'ssh', evidence: rows, authoritative: true
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
return items;
|
|
421
|
+
}
|