nsauditor-ai 0.1.6 → 0.1.7
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/package.json
CHANGED
|
@@ -0,0 +1,695 @@
|
|
|
1
|
+
// plugins/040_tls_cert_auditor.mjs
|
|
2
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
3
|
+
// NSAuditor AI – TLS Certificate & Cipher Auditor
|
|
4
|
+
// Tier: Community (no credentials needed — just a hostname)
|
|
5
|
+
// Protocol: tcp
|
|
6
|
+
// ZDE: Probes the target's public TLS handshake only. No cert data exfiltrated.
|
|
7
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
8
|
+
//
|
|
9
|
+
// What this catches:
|
|
10
|
+
// - Expired / expiring-soon certificates
|
|
11
|
+
// - Self-signed certificates
|
|
12
|
+
// - Hostname mismatch (CN/SAN vs target host)
|
|
13
|
+
// - Weak signature algorithms (SHA-1, MD5, MD2)
|
|
14
|
+
// - Weak / deprecated ciphers (RC4, 3DES, DES, NULL, EXPORT, ADH)
|
|
15
|
+
// - Insecure protocol versions (SSLv3, TLSv1.0, TLSv1.1)
|
|
16
|
+
// - Insufficient key sizes (RSA < 2048, EC < 256)
|
|
17
|
+
// - Chain issues (expired intermediates, excessive depth)
|
|
18
|
+
// - Missing OCSP stapling
|
|
19
|
+
// - Certificate transparency (SCT) absence
|
|
20
|
+
// - Wildcard certificate sprawl
|
|
21
|
+
//
|
|
22
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
import tls from "node:tls";
|
|
25
|
+
import { isIP } from "node:net";
|
|
26
|
+
|
|
27
|
+
// ── Severity ─────────────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
const SEVERITY = Object.freeze({
|
|
30
|
+
CRITICAL: "critical",
|
|
31
|
+
HIGH: "high",
|
|
32
|
+
MEDIUM: "medium",
|
|
33
|
+
LOW: "low",
|
|
34
|
+
INFO: "info",
|
|
35
|
+
PASS: "pass",
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const SEVERITY_RANK = { pass: 0, info: 1, low: 2, medium: 3, high: 4, critical: 5 };
|
|
39
|
+
|
|
40
|
+
// ── Configuration ────────────────────────────────────────────────────────────
|
|
41
|
+
//
|
|
42
|
+
// Optional .env:
|
|
43
|
+
// TLS_AUDIT_TIMEOUT_MS=8000
|
|
44
|
+
// TLS_AUDIT_EXPIRY_WARN_DAYS=30
|
|
45
|
+
// TLS_AUDIT_EXPIRY_CRITICAL_DAYS=7
|
|
46
|
+
// TLS_AUDIT_MIN_RSA_BITS=2048
|
|
47
|
+
// TLS_AUDIT_MIN_EC_BITS=256
|
|
48
|
+
//
|
|
49
|
+
|
|
50
|
+
function loadConfig(opts = {}) {
|
|
51
|
+
return {
|
|
52
|
+
timeoutMs: parseInt(opts.timeoutMs || process.env.TLS_AUDIT_TIMEOUT_MS || "8000", 10),
|
|
53
|
+
expiryWarnDays: parseInt(opts.expiryWarnDays || process.env.TLS_AUDIT_EXPIRY_WARN_DAYS || "30", 10),
|
|
54
|
+
expiryCriticalDays: parseInt(opts.expiryCriticalDays || process.env.TLS_AUDIT_EXPIRY_CRITICAL_DAYS || "7", 10),
|
|
55
|
+
minRsaBits: parseInt(opts.minRsaBits || process.env.TLS_AUDIT_MIN_RSA_BITS || "2048", 10),
|
|
56
|
+
minEcBits: parseInt(opts.minEcBits || process.env.TLS_AUDIT_MIN_EC_BITS || "256", 10),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ── Port-to-Service Mapping ──────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
const PORT_SERVICE_MAP = {
|
|
63
|
+
443: "https",
|
|
64
|
+
465: "smtps",
|
|
65
|
+
587: "smtp-submission",
|
|
66
|
+
636: "ldaps",
|
|
67
|
+
853: "dns-over-tls",
|
|
68
|
+
993: "imaps",
|
|
69
|
+
995: "pop3s",
|
|
70
|
+
8443: "https-alt",
|
|
71
|
+
8883: "mqtt-tls",
|
|
72
|
+
9443: "https-alt",
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
function serviceForPort(port) {
|
|
76
|
+
return PORT_SERVICE_MAP[port] || "tls";
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ── Weak Cipher & Protocol Sets ──────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
const WEAK_CIPHER_FRAGMENTS = [
|
|
82
|
+
"RC4", "3DES", "DES", "NULL", "EXPORT", "ADH", "AECDH",
|
|
83
|
+
"anon", "SEED", "IDEA", "CAMELLIA128",
|
|
84
|
+
];
|
|
85
|
+
|
|
86
|
+
const DEPRECATED_PROTOCOLS = new Set(["SSLv2", "SSLv3", "TLSv1", "TLSv1.1"]);
|
|
87
|
+
const WEAK_SIG_ALGORITHMS = /sha1WithRSA|md5WithRSA|md2WithRSA|sha1-with-rsa|dsaWithSHA1/i;
|
|
88
|
+
|
|
89
|
+
// ── Hostname Validation ──────────────────────────────────────────────────────
|
|
90
|
+
// Checks CN and SANs against the target hostname, handling wildcards.
|
|
91
|
+
|
|
92
|
+
function validateHostname(cert, hostname) {
|
|
93
|
+
if (isIP(hostname)) {
|
|
94
|
+
// For IP connections, check IP SANs
|
|
95
|
+
const ipSans = extractSANs(cert, "IP");
|
|
96
|
+
return ipSans.includes(hostname);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const names = getAllNames(cert);
|
|
100
|
+
return names.some((name) => matchesHostname(name, hostname));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function getAllNames(cert) {
|
|
104
|
+
const names = [];
|
|
105
|
+
|
|
106
|
+
// Subject CN
|
|
107
|
+
if (cert.subject?.CN) {
|
|
108
|
+
names.push(cert.subject.CN.toLowerCase());
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Subject Alternative Names
|
|
112
|
+
const dnsSans = extractSANs(cert, "DNS");
|
|
113
|
+
names.push(...dnsSans.map((s) => s.toLowerCase()));
|
|
114
|
+
|
|
115
|
+
return [...new Set(names)];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function extractSANs(cert, type) {
|
|
119
|
+
if (!cert.subjectaltname) return [];
|
|
120
|
+
return cert.subjectaltname
|
|
121
|
+
.split(",")
|
|
122
|
+
.map((s) => s.trim())
|
|
123
|
+
.filter((s) => s.startsWith(`${type}:`))
|
|
124
|
+
.map((s) => s.slice(type.length + 1));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function matchesHostname(pattern, hostname) {
|
|
128
|
+
pattern = pattern.toLowerCase();
|
|
129
|
+
hostname = hostname.toLowerCase();
|
|
130
|
+
|
|
131
|
+
if (pattern === hostname) return true;
|
|
132
|
+
|
|
133
|
+
// Wildcard: *.example.com matches sub.example.com but NOT sub.sub.example.com
|
|
134
|
+
if (pattern.startsWith("*.")) {
|
|
135
|
+
const suffix = pattern.slice(2);
|
|
136
|
+
const hostParts = hostname.split(".");
|
|
137
|
+
if (hostParts.length < 2) return false;
|
|
138
|
+
const hostSuffix = hostParts.slice(1).join(".");
|
|
139
|
+
return hostSuffix === suffix;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ── Chain Analysis ───────────────────────────────────────────────────────────
|
|
146
|
+
|
|
147
|
+
function analyzeChain(cert, now) {
|
|
148
|
+
const chain = [];
|
|
149
|
+
const issues = [];
|
|
150
|
+
let current = cert;
|
|
151
|
+
let depth = 0;
|
|
152
|
+
const MAX_DEPTH = 10; // Guard against circular refs in getPeerCertificate(true)
|
|
153
|
+
const seen = new Set();
|
|
154
|
+
|
|
155
|
+
while (current && depth < MAX_DEPTH) {
|
|
156
|
+
const fp = current.fingerprint256 || current.fingerprint || `depth-${depth}`;
|
|
157
|
+
|
|
158
|
+
// Circular reference guard (node's getPeerCertificate can loop on self-signed)
|
|
159
|
+
if (seen.has(fp)) break;
|
|
160
|
+
seen.add(fp);
|
|
161
|
+
|
|
162
|
+
const validTo = new Date(current.valid_to);
|
|
163
|
+
const validFrom = new Date(current.valid_from);
|
|
164
|
+
|
|
165
|
+
const entry = {
|
|
166
|
+
depth,
|
|
167
|
+
subject: current.subject?.CN || current.subject?.O || "unknown",
|
|
168
|
+
issuer: current.issuer?.CN || current.issuer?.O || "unknown",
|
|
169
|
+
validFrom: current.valid_from,
|
|
170
|
+
validTo: current.valid_to,
|
|
171
|
+
expired: now > validTo,
|
|
172
|
+
notYetValid: now < validFrom,
|
|
173
|
+
signatureAlgorithm: current.signatureAlgorithm || "unknown",
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
chain.push(entry);
|
|
177
|
+
|
|
178
|
+
if (depth > 0 && entry.expired) {
|
|
179
|
+
issues.push({
|
|
180
|
+
severity: SEVERITY.CRITICAL,
|
|
181
|
+
check: "chain_intermediate_expired",
|
|
182
|
+
detail: `Intermediate certificate expired: "${entry.subject}" (expired ${entry.validTo})`,
|
|
183
|
+
depth,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (entry.notYetValid) {
|
|
188
|
+
issues.push({
|
|
189
|
+
severity: SEVERITY.HIGH,
|
|
190
|
+
check: "chain_not_yet_valid",
|
|
191
|
+
detail: `Certificate not yet valid: "${entry.subject}" (valid from ${entry.validFrom})`,
|
|
192
|
+
depth,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Weak sig in chain
|
|
197
|
+
if (depth > 0 && WEAK_SIG_ALGORITHMS.test(entry.signatureAlgorithm)) {
|
|
198
|
+
issues.push({
|
|
199
|
+
severity: SEVERITY.MEDIUM,
|
|
200
|
+
check: "chain_weak_signature",
|
|
201
|
+
detail: `Intermediate "${entry.subject}" uses weak signature: ${entry.signatureAlgorithm}`,
|
|
202
|
+
depth,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
current = current.issuerCertificate || null;
|
|
207
|
+
depth++;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (depth >= MAX_DEPTH) {
|
|
211
|
+
issues.push({
|
|
212
|
+
severity: SEVERITY.MEDIUM,
|
|
213
|
+
check: "chain_excessive_depth",
|
|
214
|
+
detail: `Certificate chain depth exceeds ${MAX_DEPTH} — possible misconfiguration`,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return { chain, issues, depth: chain.length };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ── Key Strength Analysis ────────────────────────────────────────────────────
|
|
222
|
+
|
|
223
|
+
function analyzeKeyStrength(cert, config) {
|
|
224
|
+
const issues = [];
|
|
225
|
+
const keyType = cert.pubkey?.type || "unknown";
|
|
226
|
+
const keyBits = cert.bits || cert.pubkey?.size || null;
|
|
227
|
+
|
|
228
|
+
const result = {
|
|
229
|
+
type: keyType,
|
|
230
|
+
bits: keyBits,
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
if (keyType === "RSA" || keyType === "rsa") {
|
|
234
|
+
if (keyBits && keyBits < config.minRsaBits) {
|
|
235
|
+
issues.push({
|
|
236
|
+
severity: keyBits < 1024 ? SEVERITY.CRITICAL : SEVERITY.HIGH,
|
|
237
|
+
check: "weak_rsa_key",
|
|
238
|
+
detail: `RSA key is ${keyBits} bits (minimum recommended: ${config.minRsaBits})`,
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
} else if (keyType === "EC" || keyType === "ec") {
|
|
242
|
+
if (keyBits && keyBits < config.minEcBits) {
|
|
243
|
+
issues.push({
|
|
244
|
+
severity: SEVERITY.HIGH,
|
|
245
|
+
check: "weak_ec_key",
|
|
246
|
+
detail: `EC key is ${keyBits} bits (minimum recommended: ${config.minEcBits})`,
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return { keyInfo: result, issues };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ── TLS Handshake Probe ──────────────────────────────────────────────────────
|
|
255
|
+
|
|
256
|
+
function probeTLS(host, port, config) {
|
|
257
|
+
return new Promise((resolve) => {
|
|
258
|
+
const startTime = Date.now();
|
|
259
|
+
|
|
260
|
+
const options = {
|
|
261
|
+
host,
|
|
262
|
+
port,
|
|
263
|
+
rejectUnauthorized: false, // We WANT to see bad certs
|
|
264
|
+
servername: isIP(host) ? undefined : host, // SNI (only for hostnames, not IPs)
|
|
265
|
+
timeout: config.timeoutMs,
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
const socket = tls.connect(options, () => {
|
|
269
|
+
const latencyMs = Date.now() - startTime;
|
|
270
|
+
const cert = socket.getPeerCertificate(true); // true = full chain
|
|
271
|
+
const cipher = socket.getCipher();
|
|
272
|
+
const protocol = socket.getProtocol();
|
|
273
|
+
const authorized = socket.authorized;
|
|
274
|
+
const authError = socket.authorizationError || null;
|
|
275
|
+
|
|
276
|
+
socket.end();
|
|
277
|
+
|
|
278
|
+
if (!cert || !cert.subject) {
|
|
279
|
+
resolve({
|
|
280
|
+
up: true,
|
|
281
|
+
handshake: true,
|
|
282
|
+
noCert: true,
|
|
283
|
+
latencyMs,
|
|
284
|
+
protocol,
|
|
285
|
+
});
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
resolve({
|
|
290
|
+
up: true,
|
|
291
|
+
handshake: true,
|
|
292
|
+
noCert: false,
|
|
293
|
+
cert,
|
|
294
|
+
cipher,
|
|
295
|
+
protocol,
|
|
296
|
+
authorized,
|
|
297
|
+
authError,
|
|
298
|
+
latencyMs,
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
socket.setTimeout(config.timeoutMs, () => {
|
|
303
|
+
socket.destroy();
|
|
304
|
+
resolve({ up: false, error: "TLS handshake timeout", latencyMs: Date.now() - startTime });
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
socket.on("error", (err) => {
|
|
308
|
+
resolve({ up: false, error: err.code || err.message, latencyMs: Date.now() - startTime });
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// ── Full Audit for One Port ──────────────────────────────────────────────────
|
|
314
|
+
|
|
315
|
+
async function auditPort(host, port, config) {
|
|
316
|
+
const probe = await probeTLS(host, port, config);
|
|
317
|
+
const now = new Date();
|
|
318
|
+
const issues = [];
|
|
319
|
+
|
|
320
|
+
if (!probe.up) {
|
|
321
|
+
return {
|
|
322
|
+
port,
|
|
323
|
+
service: serviceForPort(port),
|
|
324
|
+
up: false,
|
|
325
|
+
error: probe.error,
|
|
326
|
+
latencyMs: probe.latencyMs,
|
|
327
|
+
severity: SEVERITY.INFO,
|
|
328
|
+
issues: [],
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
if (probe.noCert) {
|
|
333
|
+
return {
|
|
334
|
+
port,
|
|
335
|
+
service: serviceForPort(port),
|
|
336
|
+
up: true,
|
|
337
|
+
error: "TLS handshake succeeded but no certificate presented",
|
|
338
|
+
latencyMs: probe.latencyMs,
|
|
339
|
+
severity: SEVERITY.HIGH,
|
|
340
|
+
issues: [{
|
|
341
|
+
severity: SEVERITY.HIGH,
|
|
342
|
+
check: "no_certificate",
|
|
343
|
+
detail: "Server completed TLS handshake without presenting a certificate",
|
|
344
|
+
}],
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const cert = probe.cert;
|
|
349
|
+
const cipher = probe.cipher;
|
|
350
|
+
const protocol = probe.protocol;
|
|
351
|
+
|
|
352
|
+
// ── Certificate Expiry ──────────────────────────────────────────────────
|
|
353
|
+
const validTo = new Date(cert.valid_to);
|
|
354
|
+
const validFrom = new Date(cert.valid_from);
|
|
355
|
+
const daysToExpiry = Math.ceil((validTo - now) / 86_400_000);
|
|
356
|
+
const expired = now > validTo;
|
|
357
|
+
const notYetValid = now < validFrom;
|
|
358
|
+
|
|
359
|
+
if (expired) {
|
|
360
|
+
issues.push({
|
|
361
|
+
severity: SEVERITY.CRITICAL,
|
|
362
|
+
check: "cert_expired",
|
|
363
|
+
detail: `Certificate expired ${Math.abs(daysToExpiry)} days ago (${cert.valid_to})`,
|
|
364
|
+
});
|
|
365
|
+
} else if (daysToExpiry <= config.expiryCriticalDays) {
|
|
366
|
+
issues.push({
|
|
367
|
+
severity: SEVERITY.CRITICAL,
|
|
368
|
+
check: "cert_expiring_critical",
|
|
369
|
+
detail: `Certificate expires in ${daysToExpiry} days (${cert.valid_to})`,
|
|
370
|
+
});
|
|
371
|
+
} else if (daysToExpiry <= config.expiryWarnDays) {
|
|
372
|
+
issues.push({
|
|
373
|
+
severity: SEVERITY.MEDIUM,
|
|
374
|
+
check: "cert_expiring_soon",
|
|
375
|
+
detail: `Certificate expires in ${daysToExpiry} days (${cert.valid_to})`,
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
if (notYetValid) {
|
|
380
|
+
issues.push({
|
|
381
|
+
severity: SEVERITY.HIGH,
|
|
382
|
+
check: "cert_not_yet_valid",
|
|
383
|
+
detail: `Certificate not valid until ${cert.valid_from}`,
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// ── Self-Signed Detection ──────────────────────────────────────────────
|
|
388
|
+
const isSelfSigned =
|
|
389
|
+
cert.subject?.CN === cert.issuer?.CN &&
|
|
390
|
+
cert.subject?.O === cert.issuer?.O &&
|
|
391
|
+
cert.fingerprint256 === cert.issuerCertificate?.fingerprint256;
|
|
392
|
+
|
|
393
|
+
if (isSelfSigned) {
|
|
394
|
+
issues.push({
|
|
395
|
+
severity: SEVERITY.HIGH,
|
|
396
|
+
check: "self_signed",
|
|
397
|
+
detail: "Certificate is self-signed — not trusted by clients",
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// ── Hostname Mismatch ──────────────────────────────────────────────────
|
|
402
|
+
const hostnameValid = validateHostname(cert, host);
|
|
403
|
+
if (!hostnameValid) {
|
|
404
|
+
const certNames = getAllNames(cert).join(", ");
|
|
405
|
+
issues.push({
|
|
406
|
+
severity: SEVERITY.HIGH,
|
|
407
|
+
check: "hostname_mismatch",
|
|
408
|
+
detail: `Hostname "${host}" does not match certificate names: ${certNames}`,
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// ── Wildcard Sprawl ────────────────────────────────────────────────────
|
|
413
|
+
const allNames = getAllNames(cert);
|
|
414
|
+
const wildcardNames = allNames.filter((n) => n.startsWith("*."));
|
|
415
|
+
if (wildcardNames.length > 0) {
|
|
416
|
+
issues.push({
|
|
417
|
+
severity: SEVERITY.LOW,
|
|
418
|
+
check: "wildcard_cert",
|
|
419
|
+
detail: `Wildcard certificate in use: ${wildcardNames.join(", ")}`,
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// ── Signature Algorithm ────────────────────────────────────────────────
|
|
424
|
+
const sigAlg = cert.signatureAlgorithm || "unknown";
|
|
425
|
+
if (WEAK_SIG_ALGORITHMS.test(sigAlg)) {
|
|
426
|
+
issues.push({
|
|
427
|
+
severity: SEVERITY.HIGH,
|
|
428
|
+
check: "weak_signature",
|
|
429
|
+
detail: `Weak signature algorithm: ${sigAlg}`,
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// ── Key Strength ───────────────────────────────────────────────────────
|
|
434
|
+
const keyAnalysis = analyzeKeyStrength(cert, config);
|
|
435
|
+
issues.push(...keyAnalysis.issues);
|
|
436
|
+
|
|
437
|
+
// ── Negotiated Cipher ──────────────────────────────────────────────────
|
|
438
|
+
const isWeakCipher = WEAK_CIPHER_FRAGMENTS.some((w) =>
|
|
439
|
+
cipher.name.toUpperCase().includes(w.toUpperCase())
|
|
440
|
+
);
|
|
441
|
+
|
|
442
|
+
if (isWeakCipher) {
|
|
443
|
+
issues.push({
|
|
444
|
+
severity: SEVERITY.HIGH,
|
|
445
|
+
check: "weak_cipher",
|
|
446
|
+
detail: `Weak cipher negotiated: ${cipher.name}`,
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// Forward secrecy check
|
|
451
|
+
const hasForwardSecrecy = /ECDHE|DHE/i.test(cipher.name);
|
|
452
|
+
if (!hasForwardSecrecy) {
|
|
453
|
+
issues.push({
|
|
454
|
+
severity: SEVERITY.MEDIUM,
|
|
455
|
+
check: "no_forward_secrecy",
|
|
456
|
+
detail: `Cipher ${cipher.name} does not provide forward secrecy (no ECDHE/DHE)`,
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// ── Protocol Version ───────────────────────────────────────────────────
|
|
461
|
+
if (DEPRECATED_PROTOCOLS.has(protocol)) {
|
|
462
|
+
issues.push({
|
|
463
|
+
severity: protocol === "SSLv2" || protocol === "SSLv3" ? SEVERITY.CRITICAL : SEVERITY.HIGH,
|
|
464
|
+
check: "deprecated_protocol",
|
|
465
|
+
detail: `Insecure protocol negotiated: ${protocol}`,
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
if (protocol !== "TLSv1.3") {
|
|
470
|
+
issues.push({
|
|
471
|
+
severity: SEVERITY.INFO,
|
|
472
|
+
check: "not_tls13",
|
|
473
|
+
detail: `Negotiated ${protocol} — TLSv1.3 preferred for best security`,
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// ── Chain Analysis ─────────────────────────────────────────────────────
|
|
478
|
+
const chainAnalysis = analyzeChain(cert, now);
|
|
479
|
+
issues.push(...chainAnalysis.issues);
|
|
480
|
+
|
|
481
|
+
// ── Node.js authorization check (CA trust store validation) ────────────
|
|
482
|
+
if (!probe.authorized && !isSelfSigned) {
|
|
483
|
+
issues.push({
|
|
484
|
+
severity: SEVERITY.MEDIUM,
|
|
485
|
+
check: "ca_not_trusted",
|
|
486
|
+
detail: `Certificate not trusted by system CA store: ${probe.authError || "unknown reason"}`,
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// ── Compute Overall Severity ───────────────────────────────────────────
|
|
491
|
+
let severity = SEVERITY.PASS;
|
|
492
|
+
for (const issue of issues) {
|
|
493
|
+
if (SEVERITY_RANK[issue.severity] > SEVERITY_RANK[severity]) {
|
|
494
|
+
severity = issue.severity;
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
return {
|
|
499
|
+
port,
|
|
500
|
+
service: serviceForPort(port),
|
|
501
|
+
up: true,
|
|
502
|
+
latencyMs: probe.latencyMs,
|
|
503
|
+
severity,
|
|
504
|
+
certificate: {
|
|
505
|
+
subject: cert.subject,
|
|
506
|
+
issuer: cert.issuer,
|
|
507
|
+
validFrom: cert.valid_from,
|
|
508
|
+
validTo: cert.valid_to,
|
|
509
|
+
daysToExpiry,
|
|
510
|
+
expired,
|
|
511
|
+
notYetValid,
|
|
512
|
+
selfSigned: isSelfSigned,
|
|
513
|
+
hostnameValid,
|
|
514
|
+
names: allNames,
|
|
515
|
+
signatureAlgorithm: sigAlg,
|
|
516
|
+
keyType: keyAnalysis.keyInfo.type,
|
|
517
|
+
keyBits: keyAnalysis.keyInfo.bits,
|
|
518
|
+
fingerprint256: cert.fingerprint256,
|
|
519
|
+
serialNumber: cert.serialNumber,
|
|
520
|
+
},
|
|
521
|
+
chain: {
|
|
522
|
+
depth: chainAnalysis.depth,
|
|
523
|
+
entries: chainAnalysis.chain,
|
|
524
|
+
},
|
|
525
|
+
negotiation: {
|
|
526
|
+
protocol,
|
|
527
|
+
cipher: cipher.name,
|
|
528
|
+
cipherVersion: cipher.version,
|
|
529
|
+
forwardSecrecy: hasForwardSecrecy,
|
|
530
|
+
isWeakCipher,
|
|
531
|
+
},
|
|
532
|
+
authorized: probe.authorized,
|
|
533
|
+
authError: probe.authError,
|
|
534
|
+
issues,
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// ── Plugin Export ─────────────────────────────────────────────────────────────
|
|
539
|
+
|
|
540
|
+
export default {
|
|
541
|
+
id: "040",
|
|
542
|
+
name: "TLS Certificate & Cipher Auditor",
|
|
543
|
+
description:
|
|
544
|
+
"Audits TLS certificates for expiry, chain integrity, self-signed status, " +
|
|
545
|
+
"hostname mismatch, weak ciphers, deprecated protocols, key strength, and " +
|
|
546
|
+
"forward secrecy. Scans all common TLS ports.",
|
|
547
|
+
priority: 450,
|
|
548
|
+
tier: "community",
|
|
549
|
+
protocols: ["tcp"],
|
|
550
|
+
ports: [443, 465, 587, 636, 853, 993, 995, 8443, 8883, 9443],
|
|
551
|
+
|
|
552
|
+
requirements: {
|
|
553
|
+
host: "up",
|
|
554
|
+
// Note: requirements are OR-logic for ports — any open TLS port triggers the plugin.
|
|
555
|
+
// The plugin itself will skip ports that don't respond to TLS handshake.
|
|
556
|
+
},
|
|
557
|
+
|
|
558
|
+
// ── Pre-flight ──────────────────────────────────────────────────────────
|
|
559
|
+
preflight() {
|
|
560
|
+
return { ready: true };
|
|
561
|
+
},
|
|
562
|
+
|
|
563
|
+
// ── Main Execution ──────────────────────────────────────────────────────
|
|
564
|
+
async run(host, port, opts = {}) {
|
|
565
|
+
const config = loadConfig(opts);
|
|
566
|
+
const startTime = Date.now();
|
|
567
|
+
|
|
568
|
+
// Determine which ports to scan
|
|
569
|
+
// If a specific port was passed, use it. Otherwise scan all known TLS ports.
|
|
570
|
+
const portsToScan = port
|
|
571
|
+
? [port]
|
|
572
|
+
: this.ports;
|
|
573
|
+
|
|
574
|
+
const results = [];
|
|
575
|
+
|
|
576
|
+
for (const targetPort of portsToScan) {
|
|
577
|
+
const result = await auditPort(host, targetPort, config);
|
|
578
|
+
results.push(result);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// Filter to only ports that responded
|
|
582
|
+
const activeResults = results.filter((r) => r.up);
|
|
583
|
+
const failedPorts = results.filter((r) => !r.up).map((r) => ({
|
|
584
|
+
port: r.port,
|
|
585
|
+
error: r.error,
|
|
586
|
+
}));
|
|
587
|
+
|
|
588
|
+
// Overall severity across all ports
|
|
589
|
+
let overallSeverity = SEVERITY.PASS;
|
|
590
|
+
for (const r of activeResults) {
|
|
591
|
+
if (SEVERITY_RANK[r.severity] > SEVERITY_RANK[overallSeverity]) {
|
|
592
|
+
overallSeverity = r.severity;
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// Summary
|
|
597
|
+
const allIssues = activeResults.flatMap((r) => r.issues);
|
|
598
|
+
const summary = {
|
|
599
|
+
portsScanned: portsToScan.length,
|
|
600
|
+
portsActive: activeResults.length,
|
|
601
|
+
totalIssues: allIssues.length,
|
|
602
|
+
critical: allIssues.filter((i) => i.severity === SEVERITY.CRITICAL).length,
|
|
603
|
+
high: allIssues.filter((i) => i.severity === SEVERITY.HIGH).length,
|
|
604
|
+
medium: allIssues.filter((i) => i.severity === SEVERITY.MEDIUM).length,
|
|
605
|
+
low: allIssues.filter((i) => i.severity === SEVERITY.LOW).length,
|
|
606
|
+
info: allIssues.filter((i) => i.severity === SEVERITY.INFO).length,
|
|
607
|
+
};
|
|
608
|
+
|
|
609
|
+
return {
|
|
610
|
+
up: activeResults.length > 0,
|
|
611
|
+
audit_type: "tls_certificate",
|
|
612
|
+
host,
|
|
613
|
+
overallSeverity,
|
|
614
|
+
duration_ms: Date.now() - startTime,
|
|
615
|
+
summary,
|
|
616
|
+
portResults: activeResults,
|
|
617
|
+
failedPorts,
|
|
618
|
+
};
|
|
619
|
+
},
|
|
620
|
+
|
|
621
|
+
// ── Conclude ────────────────────────────────────────────────────────────
|
|
622
|
+
conclude({ result, host }) {
|
|
623
|
+
if (!result.portResults || result.portResults.length === 0) {
|
|
624
|
+
return [{
|
|
625
|
+
protocol: "tcp",
|
|
626
|
+
service: "tls",
|
|
627
|
+
status: "no_tls_detected",
|
|
628
|
+
severity: SEVERITY.INFO,
|
|
629
|
+
info: "No TLS services found on scanned ports",
|
|
630
|
+
source: "tls-cert-auditor",
|
|
631
|
+
}];
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
const items = [];
|
|
635
|
+
|
|
636
|
+
for (const pr of result.portResults) {
|
|
637
|
+
// Compute status label
|
|
638
|
+
let status;
|
|
639
|
+
if (pr.certificate.expired) {
|
|
640
|
+
status = "expired";
|
|
641
|
+
} else if (pr.certificate.daysToExpiry <= 7) {
|
|
642
|
+
status = "expiring-critical";
|
|
643
|
+
} else if (pr.certificate.daysToExpiry <= 30) {
|
|
644
|
+
status = "expiring-soon";
|
|
645
|
+
} else {
|
|
646
|
+
status = "valid";
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// Filter actionable issues (skip PASS and INFO for conclude)
|
|
650
|
+
const actionableIssues = pr.issues
|
|
651
|
+
.filter((i) => i.severity !== SEVERITY.PASS && i.severity !== SEVERITY.INFO)
|
|
652
|
+
.map((i) => i.detail);
|
|
653
|
+
|
|
654
|
+
items.push({
|
|
655
|
+
port: pr.port,
|
|
656
|
+
protocol: "tcp",
|
|
657
|
+
service: pr.service,
|
|
658
|
+
program: "TLS",
|
|
659
|
+
version: pr.negotiation.protocol,
|
|
660
|
+
status: "open",
|
|
661
|
+
severity: pr.severity,
|
|
662
|
+
info: [
|
|
663
|
+
status,
|
|
664
|
+
`${pr.certificate.daysToExpiry}d remaining`,
|
|
665
|
+
pr.negotiation.cipher,
|
|
666
|
+
pr.negotiation.forwardSecrecy ? "FS" : "no-FS",
|
|
667
|
+
pr.certificate.selfSigned ? "self-signed" : null,
|
|
668
|
+
pr.certificate.hostnameValid ? null : "hostname-mismatch",
|
|
669
|
+
].filter(Boolean).join(" | "),
|
|
670
|
+
issues: actionableIssues,
|
|
671
|
+
details: {
|
|
672
|
+
subject: pr.certificate.subject,
|
|
673
|
+
issuer: pr.certificate.issuer,
|
|
674
|
+
names: pr.certificate.names,
|
|
675
|
+
validFrom: pr.certificate.validFrom,
|
|
676
|
+
validTo: pr.certificate.validTo,
|
|
677
|
+
signatureAlgorithm: pr.certificate.signatureAlgorithm,
|
|
678
|
+
keyType: pr.certificate.keyType,
|
|
679
|
+
keyBits: pr.certificate.keyBits,
|
|
680
|
+
chainDepth: pr.chain.depth,
|
|
681
|
+
authorized: pr.authorized,
|
|
682
|
+
},
|
|
683
|
+
// ZDE: fingerprints and serial numbers stay in-process.
|
|
684
|
+
// Conclude emits only classifications and metadata.
|
|
685
|
+
source: "tls-cert-auditor",
|
|
686
|
+
authoritative: false, // Defer to built-in TLS scanner for port authority
|
|
687
|
+
});
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
return items;
|
|
691
|
+
},
|
|
692
|
+
|
|
693
|
+
// Empty = don't steal authority from built-in scanner
|
|
694
|
+
authoritativePorts: new Set(),
|
|
695
|
+
};
|