UNPKG

ssl-checker

Version:

Zero-dependency SSL/TLS certificate checker for Node.js — HTTPS, SMTP, IMAP, POP3, FTP via STARTTLS

512 lines (505 loc) 18.5 kB
#!/usr/bin/env node 'use strict'; var https = require('https'); var tls = require('tls'); var net = require('net'); function _interopNamespaceDefault(e) { var n = Object.create(null); if (e) { Object.keys(e).forEach(function (k) { if (k !== 'default') { var d = Object.getOwnPropertyDescriptor(e, k); Object.defineProperty(n, k, d.get ? d : { enumerable: true, get: function () { return e[k]; } }); } }); } n.default = e; return Object.freeze(n); } var https__namespace = /*#__PURE__*/_interopNamespaceDefault(https); var tls__namespace = /*#__PURE__*/_interopNamespaceDefault(tls); var net__namespace = /*#__PURE__*/_interopNamespaceDefault(net); const DEFAULT_PORTS = { https: 443, smtp: 587, imap: 143, pop3: 110, ftp: 21, }; const checkPort = (port) => !isNaN(parseFloat(port)) && Math.sign(port) === 1 && port <= 65535; const getDaysBetween = (validFrom, validTo) => Math.round(Math.abs(+validFrom - +validTo) / 8.64e7); const getDaysRemaining = (validFrom, validTo) => { const daysRemaining = getDaysBetween(validFrom, validTo); if (new Date(validTo).getTime() < new Date().getTime()) { return -daysRemaining; } return daysRemaining; }; const toSubject = (obj) => ({ CN: String(obj?.CN ?? ""), O: obj?.O ? String(obj.O) : undefined, C: obj?.C ? String(obj.C) : undefined, }); const extractCertData = (socket, opts) => { const cert = socket.getPeerCertificate(true); const { valid_from, valid_to, subjectaltname } = cert; const { issuer, subject, fingerprint256, serialNumber, bits } = cert; const authError = socket.authorizationError; const validationError = authError ? String(authError) : null; const protocol = socket.getProtocol() || "unknown"; const cipherInfo = socket.getCipher(); if (!valid_from || !valid_to) { return new Error("No certificate"); } const chain = []; const seen = new Set(); let current = cert.issuerCertificate; while (current && current.fingerprint256 && !seen.has(current.fingerprint256)) { seen.add(current.fingerprint256); chain.push({ subject: toSubject(current.subject), issuer: toSubject(current.issuer), validFrom: new Date(current.valid_from).toISOString(), validTo: new Date(current.valid_to).toISOString(), fingerprint256: current.fingerprint256, serialNumber: current.serialNumber, }); current = current.issuerCertificate; } const chainComplete = chain.length > 0 && chain[chain.length - 1].subject.CN === chain[chain.length - 1].issuer.CN; const validTo = new Date(valid_to); const result = { daysRemaining: getDaysRemaining(new Date(), validTo), valid: !validationError, validationError, validFrom: new Date(valid_from).toISOString(), validTo: validTo.toISOString(), issuer: toSubject(issuer), subject: toSubject(subject), fingerprint256, serialNumber, protocol, cipher: cipherInfo ? cipherInfo.name : "unknown", bits: bits || 0, chain, chainComplete, hsts: opts.hsts ?? null, }; if (opts.validateSubjectAltName && subjectaltname) { result.validFor = subjectaltname .replace(/DNS:|IP Address:/g, "") .split(", "); } if (opts.warnDays != null) { result.expiringSoon = result.daysRemaining <= opts.warnDays; } return result; }; const normalizeError = (err) => { if (err instanceof Error) { if (!err.message && err.code) { err.message = err.code; } return err; } return new Error(String(err)); }; const readLine = (socket) => new Promise((resolve) => { let data = ""; const onData = (chunk) => { data += chunk.toString(); if (data.includes("\r\n")) { socket.removeListener("data", onData); resolve(data.trim()); } }; socket.on("data", onData); }); const readMultiLine = (socket) => new Promise((resolve) => { let data = ""; const onData = (chunk) => { data += chunk.toString(); const lines = data.split("\r\n"); for (const line of lines) { if (line.length >= 4 && line[3] === " ") { socket.removeListener("data", onData); resolve(data.trim()); return; } } }; socket.on("data", onData); }); const starttlsHandshake = async (socket, protocol) => { switch (protocol) { case "smtp": await readMultiLine(socket); socket.write("EHLO ssl-checker\r\n"); await readMultiLine(socket); socket.write("STARTTLS\r\n"); await readLine(socket); break; case "imap": await readLine(socket); socket.write("a001 STARTTLS\r\n"); await readLine(socket); break; case "pop3": await readLine(socket); socket.write("STLS\r\n"); await readLine(socket); break; case "ftp": await readLine(socket); socket.write("AUTH TLS\r\n"); await readLine(socket); break; } }; const checkStarttls = (host, port, proto, timeout, opts) => new Promise((resolve, reject) => { const socket = net__namespace.createConnection({ host, port }, async () => { try { await starttlsHandshake(socket, proto); const tlsSocket = tls__namespace.connect({ socket, servername: opts.servername || host, rejectUnauthorized: opts.rejectUnauthorized ?? false, }, () => { const result = extractCertData(tlsSocket, { validateSubjectAltName: opts.validateSubjectAltName, warnDays: opts.warnDays, hsts: null, }); tlsSocket.destroy(); if (result instanceof Error) { reject(result); } else { resolve(result); } }); tlsSocket.on("error", (err) => { tlsSocket.destroy(); reject(normalizeError(err)); }); } catch (err) { socket.destroy(); reject(normalizeError(err)); } }); socket.setTimeout(timeout, () => { socket.destroy(); reject(new Error("Timed Out")); }); socket.on("error", (err) => reject(normalizeError(err))); }); const WEAK_CIPHERS = [ "RC4", "DES", "3DES", "NULL", "EXPORT", "anon", "MD5", "RC2", "IDEA", "SEED", ].join(":"); const probeProtocol = (host, port, version, timeout, servername) => new Promise((resolve) => { const socket = tls__namespace.connect({ host, port, servername: servername || host, minVersion: version, maxVersion: version, rejectUnauthorized: false, }, () => { socket.destroy(); resolve(true); }); socket.setTimeout(timeout, () => { socket.destroy(); resolve(false); }); socket.on("error", () => { socket.destroy(); resolve(false); }); }); const probeWeakCiphers = (host, port, timeout, servername) => new Promise((resolve) => { const socket = tls__namespace.connect({ host, port, servername: servername || host, ciphers: WEAK_CIPHERS, maxVersion: "TLSv1.2", rejectUnauthorized: false, }, () => { socket.destroy(); resolve(true); }); socket.setTimeout(timeout, () => { socket.destroy(); resolve(false); }); socket.on("error", () => { socket.destroy(); resolve(false); }); }); const GRADE_ORDER = ["A+", "A", "B", "C", "D", "F"]; const downgrade = (current, to) => { const ci = GRADE_ORDER.indexOf(current); const ti = GRADE_ORDER.indexOf(to); return ti > ci ? to : current; }; const computeGrade = async (host, port, timeout, result, servername) => { const probeTimeout = Math.min(timeout, 5000); const [hasTls10, hasTls11, hasTls12, hasTls13, hasWeakCiphers] = await Promise.all([ probeProtocol(host, port, "TLSv1", probeTimeout, servername), probeProtocol(host, port, "TLSv1.1", probeTimeout, servername), probeProtocol(host, port, "TLSv1.2", probeTimeout, servername), probeProtocol(host, port, "TLSv1.3", probeTimeout, servername), probeWeakCiphers(host, port, probeTimeout, servername), ]); const protocols = []; if (hasTls10) protocols.push("TLSv1.0"); if (hasTls11) protocols.push("TLSv1.1"); if (hasTls12) protocols.push("TLSv1.2"); if (hasTls13) protocols.push("TLSv1.3"); const reasons = []; let grade = "A+"; if (!result.valid) { reasons.push("Certificate is not valid: " + (result.validationError || "unknown error")); return { grade: "F", protocols, weakCiphers: hasWeakCiphers, reasons }; } if (result.daysRemaining <= 0) { reasons.push("Certificate has expired"); return { grade: "F", protocols, weakCiphers: hasWeakCiphers, reasons }; } if (!result.chainComplete) { reasons.push("Incomplete certificate chain"); grade = downgrade(grade, "B"); } if (hasTls10) { reasons.push("TLSv1.0 supported (deprecated)"); grade = downgrade(grade, "C"); } if (hasTls11) { reasons.push("TLSv1.1 supported (deprecated)"); grade = downgrade(grade, "B"); } if (hasWeakCiphers) { reasons.push("Weak ciphers accepted"); grade = downgrade(grade, "B"); } if (!hasTls13) { reasons.push("TLSv1.3 not supported"); grade = downgrade(grade, "A"); } if (result.bits < 128) { reasons.push("Key size below 128 bits"); grade = downgrade(grade, "B"); } if (grade === "A+") { if (!result.hsts) { reasons.push("HSTS not enabled"); grade = "A"; } else if (result.hsts.maxAge && result.hsts.maxAge < 15768000) { reasons.push("HSTS max-age below 6 months"); grade = "A"; } } if (reasons.length === 0) { reasons.push("No issues found"); } return { grade, protocols, weakCiphers: hasWeakCiphers, reasons }; }; const DEFAULT_OPTIONS = { agent: new https__namespace.Agent({ maxCachedSessions: 0, }), method: "HEAD", port: undefined, rejectUnauthorized: false, validateSubjectAltName: false, timeout: 10000, protocol: "https", }; const sslChecker = (host, options = {}) => { const merged = Object.assign({}, DEFAULT_OPTIONS, options); const { validateSubjectAltName, timeout, warnDays, protocol: proto, grade: doGrade, ...requestOptions } = merged; const effectiveProto = proto || "https"; const effectivePort = requestOptions.port || DEFAULT_PORTS[effectiveProto]; requestOptions.port = effectivePort; if (!checkPort(effectivePort)) { return Promise.reject(Error("Invalid port")); } const getBaseResult = () => { if (effectiveProto !== "https") { return checkStarttls(host, effectivePort, effectiveProto, timeout, { validateSubjectAltName, warnDays, rejectUnauthorized: requestOptions.rejectUnauthorized, servername: requestOptions.servername, }); } return new Promise((resolve, reject) => { try { const req = https__namespace.request({ host, ...requestOptions }, (res) => { const socket = res.socket; const hstsHeader = res.headers["strict-transport-security"]; let hsts = null; if (hstsHeader) { const maxAgeMatch = hstsHeader.match(/max-age=(\d+)/); hsts = { enabled: true, maxAge: maxAgeMatch ? parseInt(maxAgeMatch[1], 10) : undefined, includeSubDomains: hstsHeader.includes("includeSubDomains"), preload: hstsHeader.includes("preload"), }; } const result = extractCertData(socket, { validateSubjectAltName, warnDays, hsts }); res.socket.destroy(); if (result instanceof Error) { reject(result); } else { resolve(result); } }); req.setTimeout(timeout, () => { req.destroy(); reject(new Error("Timed Out")); }); req.on("error", (err) => reject(normalizeError(err))); req.end(); } catch (e) { reject(normalizeError(e)); } }); }; return getBaseResult().then(async (result) => { if (doGrade) { result.grade = await computeGrade(host, effectivePort, timeout, result, requestOptions.servername); } return result; }); }; const args = process.argv.slice(2); const flags = {}; const positional = []; for (let i = 0; i < args.length; i++) { if (args[i].startsWith("--") && i + 1 < args.length && !args[i + 1].startsWith("--")) { flags[args[i].slice(2)] = args[i + 1]; i++; } else if (args[i].startsWith("--")) { flags[args[i].slice(2)] = "true"; } else { positional.push(args[i]); } } const host = positional[0]; if (!host || flags.help) { console.log(`Usage: ssl-checker <host> [options] Options: --port <number> Port to connect to (default: auto by protocol) --protocol <proto> Protocol: https, smtp, imap, pop3, ftp (default: https) --timeout <ms> Timeout in milliseconds (default: 10000) --grade Run SSL grading (A+ to F) --json Output raw JSON --help Show this help message`); process.exit(host ? 0 : 1); } const port = flags.port ? parseInt(flags.port, 10) : undefined; const timeout = flags.timeout ? parseInt(flags.timeout, 10) : undefined; const protocol = flags.protocol; const doGrade = flags.grade === "true"; const jsonOutput = flags.json === "true"; async function main() { try { const result = await sslChecker(host, { ...(port && { port }), ...(timeout && { timeout }), ...(protocol && { protocol }), validateSubjectAltName: true, grade: doGrade, }); if (jsonOutput) { console.log(JSON.stringify(result, null, 2)); } else { const status = result.valid ? "\x1b[32m\u2713 Valid\x1b[0m" : "\x1b[31m\u2717 Invalid\x1b[0m"; console.log(`\n SSL Certificate for \x1b[1m${host}\x1b[0m\n`); console.log(` Status: ${status}`); if (result.validationError) { console.log(` Error: \x1b[31m${result.validationError}\x1b[0m`); } console.log(` Days Remaining: ${result.daysRemaining}`); console.log(` Valid From: ${result.validFrom}`); console.log(` Valid To: ${result.validTo}`); console.log(` Subject: ${result.subject.CN}`); console.log(` Issuer: ${result.issuer.CN}${result.issuer.O ? ` (${result.issuer.O})` : ""}`); console.log(` Protocol: ${result.protocol}`); console.log(` Cipher: ${result.cipher}`); console.log(` Bits: ${result.bits}`); console.log(` Fingerprint: ${result.fingerprint256}`); console.log(` Serial: ${result.serialNumber}`); if (result.validFor) { console.log(` Valid For: ${result.validFor.join(", ")}`); } const chainStatus = result.chainComplete ? "\x1b[32m\u2713 Complete\x1b[0m" : "\x1b[33m\u2717 Incomplete\x1b[0m"; console.log(` Chain: ${chainStatus} (${result.chain.length + 1} certificates)`); console.log(` ${result.subject.CN}`); for (const link of result.chain) { console.log(` \u2514\u2500 ${link.subject.CN}${link.subject.O ? ` (${link.subject.O})` : ""}`); } if (result.hsts) { const parts = [`max-age=${result.hsts.maxAge}`]; if (result.hsts.includeSubDomains) parts.push("includeSubDomains"); if (result.hsts.preload) parts.push("preload"); console.log(` HSTS: \x1b[32menabled\x1b[0m (${parts.join(", ")})`); } else { console.log(` HSTS: \x1b[33mnot set\x1b[0m`); } if (result.grade) { const g = result.grade; const gradeColors = { "A+": "\x1b[32m", "A": "\x1b[32m", "B": "\x1b[33m", "C": "\x1b[33m", "D": "\x1b[31m", "F": "\x1b[31m", }; const color = gradeColors[g.grade] || ""; console.log(` Grade: ${color}${g.grade}\x1b[0m`); console.log(` Protocols: ${g.protocols.join(", ")}`); if (g.reasons[0] !== "No issues found") { for (const reason of g.reasons) { console.log(` \x1b[33m- ${reason}\x1b[0m`); } } } console.log(); } process.exit(result.valid ? 0 : 1); } catch (err) { if (jsonOutput) { console.log(JSON.stringify({ host, error: err.message })); } else { console.error(`\x1b[31m Error checking ${host}: ${err.message}\x1b[0m`); } process.exit(1); } } main();