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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nsauditor-ai",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "description": "Modular AI-assisted network security audit platform — Community Edition",
5
5
  "type": "module",
6
6
  "private": false,
@@ -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
+ };