ssl-checker
Version:
Zero-dependency SSL/TLS certificate checker for Node.js — HTTPS, SMTP, IMAP, POP3, FTP via STARTTLS
385 lines (382 loc) • 13.3 kB
JavaScript
import * as https from 'https';
import * as tls from 'tls';
import * as net from '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.createConnection({ host, port }, async () => {
try {
await starttlsHandshake(socket, proto);
const tlsSocket = tls.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.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.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.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.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;
};
export { sslChecker as default, sslChecker, sslCheckerBatch };