UNPKG

ssl-checker

Version:

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

412 lines (405 loc) 14.1 kB
'use strict'; Object.defineProperty(exports, '__esModule', { value: true }); 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 sslCheckerBatch = async (hosts, options = {}) => { const results = await Promise.allSettled(hosts.map((host) => sslChecker(host, options))); const map = new Map(); hosts.forEach((host, i) => { const result = results[i]; map.set(host, result.status === "fulfilled" ? result.value : result.reason); }); return map; }; exports.default = sslChecker; exports.sslChecker = sslChecker; exports.sslCheckerBatch = sslCheckerBatch;