├── .devcontainer └── devcontainer.json ├── .github ├── FUNDING.yml ├── dependabot.yml └── workflows │ └── nodejs.yml ├── .gitignore ├── .npmignore ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── package-lock.json ├── package.json ├── prettierignore ├── rollup.config.ts ├── src ├── index.test.ts └── index.ts ├── tsconfig.json ├── tsconfig.types.json └── vitest.config.ts /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "image": "mcr.microsoft.com/vscode/devcontainers/typescript-node:18-bullseye", 3 | "postCreateCommand": "npm install" 4 | } -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: dyaa 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "03:00" 8 | open-pull-requests-limit: 20 9 | ignore: 10 | - dependency-name: "@types/node" 11 | versions: 12 | - 14.14.22 13 | - 14.14.24 14 | - 14.14.25 15 | - 14.14.26 16 | - 14.14.28 17 | - 14.14.30 18 | - 14.14.31 19 | - 14.14.32 20 | - 14.14.33 21 | - 14.14.34 22 | - 14.14.35 23 | - 14.14.36 24 | - 14.14.37 25 | - 14.14.39 26 | - 14.14.41 27 | - 15.0.0 28 | - dependency-name: y18n 29 | versions: 30 | - 4.0.1 31 | - 4.0.2 32 | - dependency-name: "@types/jest" 33 | versions: 34 | - 26.0.20 35 | - 26.0.21 36 | - 26.0.22 37 | - dependency-name: typescript 38 | versions: 39 | - 4.1.3 40 | - 4.1.4 41 | - 4.1.5 42 | - 4.2.2 43 | - 4.2.3 44 | -------------------------------------------------------------------------------- /.github/workflows/nodejs.yml: -------------------------------------------------------------------------------- 1 | name: test-sslChecker 2 | 3 | on: [push] 4 | 5 | env: 6 | CI: true 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | - uses: actions/setup-node@v3 14 | with: 15 | node-version: "18" 16 | - run: npm install 17 | - run: npm test 18 | - run: npm run build 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .idea 3 | .project 4 | .strong-pm 5 | coverage 6 | node_modules 7 | npm-debug.log 8 | yarn.lock 9 | lib 10 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | coverage/ 2 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib" 3 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 8008 :trollface: Dyaa Eldin Moustafa 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Node SSL Checker 2 | 3 | [![Build Status](https://github.com/dyaa/ssl-checker/workflows/test-sslChecker/badge.svg)](https://github.com/dyaa/ssl-checker/actions) 4 | [![npm version](https://badge.fury.io/js/ssl-checker.svg)](https://badge.fury.io/js/ssl-checker) [![npm](https://img.shields.io/npm/dt/ssl-checker.svg)](https://github.com/dyaa/node-ssl-checker) 5 | [![Codacy Badge](https://app.codacy.com/project/badge/Coverage/48857294fa4a42b79710ffc87b58a72b)](https://www.codacy.com/gh/dyaa/ssl-checker/dashboard?utm_source=github.com&utm_medium=referral&utm_content=dyaa/ssl-checker&utm_campaign=Badge_Coverage) 6 | [![Codacy Badge](https://app.codacy.com/project/badge/Grade/48857294fa4a42b79710ffc87b58a72b)](https://www.codacy.com/gh/dyaa/ssl-checker/dashboard?utm_source=github.com&utm_medium=referral&utm_content=dyaa/ssl-checker&utm_campaign=Badge_Grade) 7 | 8 | ## Installation 9 | 10 | Simply add `ssl-checker` as a dependency: 11 | 12 | ```bash 13 | $ npm install ssl-checker --save # npm i -s ssl-checker 14 | 15 | # Or if you prefer using yarn (https://yarnpkg.com/lang/en/) 16 | $ yarn add ssl-checker 17 | ``` 18 | 19 | ## Usage 20 | 21 | ```ts 22 | import sslChecker from "ssl-checker"; 23 | 24 | const getSslDetails = async (hostname: string) => 25 | await sslChecker(hostname`ex. badssl.com`); 26 | ``` 27 | 28 | ## Options 29 | 30 | All valid `https.RequestOptions` values. 31 | 32 | | Option | Default | Description | 33 | | ------------------ | ------- | ------------------------------------------------- | 34 | | method | HEAD | Can be GET too | 35 | | port | 443 | Your SSL/TLS entry point | 36 | | agent | default | Default HTTPS agent with { maxCachedSessions: 0 } | 37 | | rejectUnauthorized | false | Skips authorization by default | 38 | | validateSubjectAltName | false | Skips returning/validating `subjectaltname` | 39 | 40 | ```ts 41 | sslChecker("dyaa.me", { method: "GET", port: 443, validateSubjectAltName: true }).then(console.info); 42 | ``` 43 | 44 | ## Response Example 45 | 46 | ```json 47 | { 48 | "daysRemaining": 90, 49 | "valid": true, 50 | "validFrom": "issue date", 51 | "validTo": "expiry date", 52 | "validFor": ["www.example.com", "example.com"] 53 | } 54 | ``` 55 | 56 | **NOTE: `validFor` is only returned if `validateSubjectAltName` is set to `true`** 57 | 58 | #### License 59 | 60 | Copylefted (c) 8008 :trollface: [Dyaa Eldin Moustafa][1] Licensed under the [MIT license][2]. 61 | 62 | [1]: https://dyaa.me/ 63 | [2]: https://github.com/dyaa/node-ssl-checker/blob/master/LICENSE 64 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ssl-checker", 3 | "version": "2.0.10", 4 | "description": "ssl-checker", 5 | "main": "./lib/cjs/index.js", 6 | "module": "./lib/es/index.js", 7 | "types": "./lib/index.d.ts", 8 | "scripts": { 9 | "test": "vitest", 10 | "test:coverage": "vitest run --coverage && cat ./coverage/lcov.info | codacy-coverage -v", 11 | "build": "tsc --project tsconfig.types.json && rollup -c --configPlugin typescript --perf", 12 | "format": "prettier --write '**/*.{ts,md}'", 13 | "precommit": "pretty-quick --staged", 14 | "prepare": "npm run build", 15 | "prepublishOnly": "npm test", 16 | "version": "npm run format && git add -A src", 17 | "postversion": "git push && git push --tags" 18 | }, 19 | "repository": { 20 | "type": "git", 21 | "url": "git+https://github.com/dyaa/ssl-checker.git" 22 | }, 23 | "keywords": [ 24 | "ssl", 25 | "checker" 26 | ], 27 | "author": "Dyaa Eldin (https://dyaa.me)", 28 | "license": "MIT", 29 | "bugs": { 30 | "url": "https://github.com/dyaa/ssl-checker/issues" 31 | }, 32 | "homepage": "https://github.com/dyaa/ssl-checker#readme", 33 | "devDependencies": { 34 | "@rollup/plugin-typescript": "^11.0.0", 35 | "@types/node": "^18.13.0", 36 | "@vitest/coverage-istanbul": "^0.28.4", 37 | "codacy-coverage": "^3.2.0", 38 | "prettier": "^2.0.4", 39 | "pretty-quick": "^3.0.2", 40 | "rollup": "^3.14.0", 41 | "typescript": "^4.9.5", 42 | "vitest": "^1.3.1" 43 | }, 44 | "files": [ 45 | "lib/**/*" 46 | ] 47 | } 48 | -------------------------------------------------------------------------------- /prettierignore: -------------------------------------------------------------------------------- 1 | lib 2 | coverage 3 | -------------------------------------------------------------------------------- /rollup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "rollup"; 2 | import typescript from "@rollup/plugin-typescript"; 3 | 4 | export default defineConfig({ 5 | input: "src/index.ts", 6 | output: [ 7 | { file: "lib/es/index.js", format: "module" }, 8 | { file: "lib/cjs/index.js", format: "commonjs" }, 9 | ], 10 | external: ["https"], 11 | watch: { 12 | include: "src/**", 13 | }, 14 | plugins: [typescript()], 15 | }); 16 | -------------------------------------------------------------------------------- /src/index.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, describe, it } from "vitest"; 2 | import * as fs from 'fs'; 3 | 4 | import sslChecker from "./"; 5 | 6 | const githubHost = "github.com"; 7 | const expiredSSlHost = "expired.badssl.com"; 8 | const wrongHostDomain = "wrong.host.badssl.com"; 9 | 10 | describe("sslChecker", () => { 11 | it("Should return valid values when valid host is passed", async () => { 12 | const sslDetails = await sslChecker(githubHost, {validateSubjectAltName: true}); 13 | 14 | expect(sslDetails).toEqual( 15 | expect.objectContaining({ 16 | daysRemaining: expect.any(Number), 17 | valid: true, 18 | validFrom: expect.any(String), 19 | validTo: expect.any(String), 20 | validFor: expect.arrayContaining(["github.com", "www.github.com"]), 21 | }) 22 | ); 23 | }); 24 | 25 | it("Should work on subsequent calls for the same domain", async () => { 26 | await sslChecker(githubHost); 27 | await new Promise((r) => setTimeout(r, 1000)); 28 | const sslDetails = await sslChecker(githubHost); 29 | 30 | expect(sslDetails).toEqual( 31 | expect.objectContaining({ 32 | valid: true, 33 | }) 34 | ); 35 | }); 36 | 37 | it("Should return valid = false when provided an expired domain", async () => { 38 | const sslDetails = await sslChecker(expiredSSlHost); 39 | 40 | expect(sslDetails).toEqual( 41 | expect.objectContaining({ 42 | valid: false, 43 | }) 44 | ); 45 | }); 46 | 47 | it("Should allow for specifying `subjectaltname` as a non required field for cert validity", async () => { 48 | const sslDetails = await sslChecker(githubHost, {validateSubjectAltName: false}); 49 | 50 | expect(sslDetails).toEqual( 51 | expect.objectContaining({ 52 | daysRemaining: expect.any(Number), 53 | valid: true, 54 | validFrom: expect.any(String), 55 | validTo: expect.any(String), 56 | }) 57 | ); 58 | }); 59 | 60 | it("Should return an error when passing a wrong host domain", async () => { 61 | try { 62 | await sslChecker(wrongHostDomain); 63 | } catch (e) { 64 | expect(e).toContain( 65 | "getaddrinfo ENOTFOUND expiredSSlHost expiredSSlHost:" 66 | ); 67 | } 68 | }); 69 | 70 | it("Should return 'Invalid port' when no port provided", async () => { 71 | try { 72 | await sslChecker(githubHost, { port: "port" }); 73 | } catch (e) { 74 | expect(e).toEqual(new Error("Invalid port")); 75 | } 76 | }); 77 | 78 | it("Should not leak socket file descriptors with a head request", async () => { 79 | if (process.platform !== 'linux') return 80 | await new Promise((r) => setTimeout(r, 2000)); 81 | const openFdsBefore = fs.readdirSync('/proc/self/fd').length - 1; 82 | await sslChecker(githubHost, { method: "HEAD", port: 443 }) 83 | await new Promise((r) => setTimeout(r, 1000)); 84 | const openFdsAfter = fs.readdirSync('/proc/self/fd').length - 1; 85 | expect(openFdsBefore).toEqual(openFdsAfter); 86 | }); 87 | 88 | it("Should not leak socket file descriptors with a get request", async () => { 89 | if (process.platform !== 'linux') return 90 | await new Promise((r) => setTimeout(r, 2000)); 91 | const openFdsBefore = fs.readdirSync('/proc/self/fd').length - 1; 92 | await sslChecker(githubHost, { method: "GET", port: 443 }) 93 | await new Promise((r) => setTimeout(r, 1000)); 94 | const openFdsAfter = fs.readdirSync('/proc/self/fd').length - 1; 95 | expect(openFdsBefore).toEqual(openFdsAfter); 96 | }); 97 | }); 98 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import * as http from "http"; 2 | import * as https from "https"; 3 | import type { TLSSocket } from "tls"; 4 | 5 | interface IResolvedValues { 6 | valid: boolean; 7 | validFrom: string; 8 | validTo: string; 9 | daysRemaining: number; 10 | validFor?: string[]; 11 | } 12 | 13 | const checkPort = (port: unknown): boolean => 14 | !isNaN(parseFloat(port as string)) && Math.sign(port as number) === 1; 15 | const getDaysBetween = (validFrom: Date, validTo: Date): number => 16 | Math.round(Math.abs(+validFrom - +validTo) / 8.64e7); 17 | const getDaysRemaining = (validFrom: Date, validTo: Date): number => { 18 | const daysRemaining = getDaysBetween(validFrom, validTo); 19 | 20 | if (new Date(validTo).getTime() < new Date().getTime()) { 21 | return -daysRemaining; 22 | } 23 | 24 | return daysRemaining; 25 | }; 26 | 27 | type Options = https.RequestOptions & { validateSubjectAltName?:boolean }; 28 | 29 | const DEFAULT_OPTIONS: Partial = { 30 | agent: new https.Agent({ 31 | maxCachedSessions: 0, 32 | }), 33 | method: "HEAD", 34 | port: 443, 35 | rejectUnauthorized: false, 36 | validateSubjectAltName: false 37 | }; 38 | 39 | const sslChecker = ( 40 | host: string, 41 | options: Partial = {} 42 | ): Promise => 43 | new Promise((resolve, reject) => { 44 | options = Object.assign({}, DEFAULT_OPTIONS, options); 45 | 46 | if (!checkPort(options.port)) { 47 | reject(Error("Invalid port")); 48 | return; 49 | } 50 | 51 | try { 52 | if (options.validateSubjectAltName) { 53 | const req = https.request( 54 | { host, ...options }, 55 | (res: http.IncomingMessage) => { 56 | let { valid_from, valid_to, subjectaltname } = ( 57 | res.socket as TLSSocket 58 | ).getPeerCertificate(); 59 | res.socket.destroy(); 60 | 61 | if (!valid_from || !valid_to || !subjectaltname) { 62 | reject(new Error("No certificate")); 63 | return; 64 | } 65 | 66 | const validTo = new Date(valid_to); 67 | const validFor = subjectaltname 68 | .replace(/DNS:|IP Address:/g, "") 69 | .split(", "); 70 | 71 | resolve({ 72 | daysRemaining: getDaysRemaining(new Date(), validTo), 73 | valid: 74 | ((res.socket as { authorized?: boolean }) 75 | .authorized as boolean) || false, 76 | validFrom: new Date(valid_from).toISOString(), 77 | validTo: validTo.toISOString(), 78 | validFor, 79 | }); 80 | } 81 | ); 82 | 83 | req.on("error", reject); 84 | req.on("timeout", () => { 85 | req.destroy(); 86 | reject(new Error("Timed Out")); 87 | }); 88 | req.end(); 89 | } else { 90 | const req = https.request( 91 | { host, ...options }, 92 | (res: http.IncomingMessage) => { 93 | let { valid_from, valid_to } = ( 94 | res.socket as TLSSocket 95 | ).getPeerCertificate(); 96 | res.socket.destroy(); 97 | 98 | 99 | if (!valid_from || !valid_to) { 100 | reject(new Error("No certificate")); 101 | return; 102 | } 103 | 104 | const validTo = new Date(valid_to); 105 | 106 | resolve({ 107 | daysRemaining: getDaysRemaining(new Date(), validTo), 108 | valid: 109 | ((res.socket as { authorized?: boolean }) 110 | .authorized as boolean) || false, 111 | validFrom: new Date(valid_from).toISOString(), 112 | validTo: validTo.toISOString() 113 | }); 114 | } 115 | ); 116 | req.on("error", reject); 117 | req.on("timeout", () => { 118 | req.destroy(); 119 | reject(new Error("Timed Out")); 120 | }); 121 | req.end(); 122 | } 123 | } catch (e) { 124 | reject(e); 125 | } 126 | }); 127 | 128 | export default sslChecker; 129 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "moduleResolution": "node", 4 | "esModuleInterop": true, 5 | "module": "esnext", 6 | "declaration": false, 7 | "rootDir": "src", 8 | "allowJs": false, 9 | "skipLibCheck": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "noImplicitReturns": true, 12 | "noImplicitThis": true, 13 | "noImplicitAny": true, 14 | "strictNullChecks": true, 15 | "suppressImplicitAnyIndexErrors": true, 16 | "noErrorTruncation": true, 17 | "noUnusedLocals": false, 18 | "lib": ["esnext"] 19 | }, 20 | "include": ["src/*.ts"], 21 | "exclude": ["node_modules", "lib", "src/*.test.*"] 22 | } 23 | -------------------------------------------------------------------------------- /tsconfig.types.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "declaration": true, 5 | "declarationDir": "./lib", 6 | "emitDeclarationOnly": true 7 | }, 8 | "exclude": ["node_modules", "lib", "src/*.test.*"] 9 | } 10 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vitest/config"; 2 | 3 | export default defineConfig({ 4 | test: { 5 | coverage: { 6 | provider: "istanbul", 7 | reporter: ["html", "lcov"], 8 | }, 9 | }, 10 | }); 11 | --------------------------------------------------------------------------------