├── domains.txt ├── LICENSE ├── README.md ├── .github └── workflows │ └── run.yml ├── check.py └── thttp.py /domains.txt: -------------------------------------------------------------------------------- 1 | github.com 2 | example.com 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2022 Brenton Cleeland 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # cert-checker-template 2 | 3 | This tiny project takes a list of domains (`domains.txt`) and checks the certificate expiry. Once that's done, it does two things: 4 | 5 | - Adds the list of domains with their SSL certificate expiry to the end of this README 6 | - Creates a new Github Issue if the SSL certificate expires in less than 30 days (if `GITHUB_TOKEN` is present) and again with 15 days remaining. 7 | 8 | 9 | ## Usage 10 | 11 | Use this template to create a version of the project in your Github account, then edit `domains.txt` to set up the domains you want to track. 12 | 13 | If running with Github Actions, you need to allow Actions to write to the repository in your repository settings (Settings > Actions > General > Workflow permissions). 14 | 15 | That's it! 16 | 17 | 18 | ### Some Optional Steps 19 | 20 | - Modify `.github/workflows/run.yml` to configure the frequency you run the workflow 21 | - Modify `.github/workflows/run.yml` to update the email address for the workflow 22 | 23 | ## Results 24 | 25 | | Expiry | Domain | 26 | |-----------|----------| 27 | | 2023-03-14 | example.com | 28 | | 2023-03-15 | github.com | 29 | -------------------------------------------------------------------------------- /.github/workflows/run.yml: -------------------------------------------------------------------------------- 1 | name: Run certificate expiry check 2 | 3 | on: 4 | # Triggers the workflow on push or pull request events but only for the main branch 5 | push: 6 | branches: [ main ] 7 | pull_request: 8 | branches: [ main ] 9 | schedule: 10 | - cron: 4 22 * * 1 11 | 12 | # Allows you to run this workflow manually from the Actions tab 13 | workflow_dispatch: 14 | 15 | jobs: 16 | check: 17 | runs-on: ubuntu-latest 18 | 19 | steps: 20 | - uses: actions/checkout@v2 21 | 22 | - name: Execute the checks 23 | run: python check.py 24 | env: 25 | GH_TOKEN: ${{ github.token }} 26 | GH_REPO: ${{ github.repository }} 27 | 28 | - name: Commit and push files 29 | run: | 30 | git pull 31 | git config --local user.email "empty@example.org" 32 | git config --local user.name "Cert Checker Bot" 33 | git add README.md 34 | git diff --exit-code --cached || exit_code=$? 35 | echo $exit_code 36 | if (( exit_code > 0 )); then 37 | git commit -m "Update README with expiry dates" && \ 38 | git push https://${GITHUB_ACTOR}:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git HEAD:main 39 | fi 40 | -------------------------------------------------------------------------------- /check.py: -------------------------------------------------------------------------------- 1 | import os 2 | import ssl 3 | 4 | import errno 5 | import socket 6 | import ssl 7 | 8 | from datetime import datetime, date, timedelta, timezone 9 | from thttp import request 10 | 11 | 12 | CONNECTION_TIMEOUT = 5.0 13 | 14 | 15 | class SSLConnectionFailed(Exception): 16 | pass 17 | 18 | 19 | class UnknownSSLFailure(Exception): 20 | pass 21 | 22 | 23 | class LookupFailed(Exception): 24 | pass 25 | 26 | 27 | def get_ssl_expiry(domain): 28 | try: 29 | sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 30 | sock.settimeout(CONNECTION_TIMEOUT) 31 | 32 | context = ssl.create_default_context() 33 | ssl_sock = context.wrap_socket(sock, server_hostname=domain) 34 | ssl_sock.settimeout(CONNECTION_TIMEOUT) 35 | ssl_sock.connect((domain, 443)) 36 | 37 | cert = ssl_sock.getpeercert() 38 | end = datetime.fromtimestamp( 39 | ssl.cert_time_to_seconds(cert["notAfter"]), tz=timezone.utc 40 | ) 41 | ssl_sock.close() 42 | return end.date() 43 | except socket.gaierror: 44 | raise LookupFailed 45 | except socket.error as e: 46 | if e.errno == errno.ECONNREFUSED: 47 | # connection to port 443 was refused 48 | raise SSLConnectionFailed 49 | raise UnknownSSLFailure 50 | 51 | 52 | if __name__ == "__main__": 53 | domains = [] 54 | 55 | with open("domains.txt") as f: 56 | for l in f.readlines(): 57 | if l.strip() and not l.strip().startswith("#"): 58 | domain = l.strip() 59 | 60 | try: 61 | domains.append((domain, str(get_ssl_expiry(domain)))) 62 | except Exception as e: 63 | domains.append((domain, type(e).__name__)) 64 | 65 | # Update README 66 | with open("README.md", "r") as f: 67 | readme = f.read().split("## Results")[0].strip() 68 | readme += "\n\n## Results\n\n" 69 | readme += "| Expiry | Domain |\n" 70 | readme += "|-----------|----------|\n" 71 | 72 | for domain, expiry in sorted(domains, key=lambda x: x[1]): 73 | readme += f"| {expiry} | {domain} |\n" 74 | 75 | with open("README.md", "w") as f: 76 | f.write(readme) 77 | 78 | # Open Github issues 79 | for domain, expiry in sorted(domains, key=lambda x: x[1]): 80 | if "-" not in expiry: 81 | continue 82 | 83 | d = date(*[int(x) for x in expiry.split("-")]) 84 | if d < date.today() + timedelta(days=30): 85 | token = os.environ.get("GH_TOKEN") 86 | repo = os.environ.get("GH_REPO") 87 | 88 | if token and repo: 89 | if d < date.today() + timedelta(days=14): 90 | title = f"{domain} expires in less than 14 days ({d})" 91 | elif d < date.today() + timedelta(days=30): 92 | title = f"{domain} expires in less than 30 days ({d})" 93 | 94 | existing_issues = request( 95 | f"https://api.github.com/repos/{repo}/issues", 96 | params={"state": "open"}, 97 | headers={"Authorization": f"token {token}"}, 98 | ) 99 | 100 | needs_issue = True 101 | for issue in existing_issues.json: 102 | if issue["title"] == title: 103 | needs_issue = False 104 | break 105 | 106 | if needs_issue: 107 | request( 108 | f"https://api.github.com/repos/{repo}/issues", 109 | headers={"Authorization": f"token {token}"}, 110 | json={"title": title}, 111 | method="post", 112 | ) 113 | -------------------------------------------------------------------------------- /thttp.py: -------------------------------------------------------------------------------- 1 | """ 2 | UNLICENSED 3 | This is free and unencumbered software released into the public domain. 4 | 5 | https://github.com/sesh/thttp 6 | """ 7 | 8 | import gzip 9 | import ssl 10 | import json as json_lib 11 | 12 | from base64 import b64encode 13 | from collections import namedtuple 14 | 15 | from http.cookiejar import CookieJar 16 | from urllib.error import HTTPError, URLError 17 | from urllib.parse import urlencode 18 | from urllib.request import ( 19 | Request, 20 | build_opener, 21 | HTTPRedirectHandler, 22 | HTTPSHandler, 23 | HTTPCookieProcessor, 24 | ) 25 | 26 | 27 | Response = namedtuple("Response", "request content json status url headers cookiejar") 28 | 29 | 30 | class NoRedirect(HTTPRedirectHandler): 31 | def redirect_request(self, req, fp, code, msg, headers, newurl): 32 | return None 33 | 34 | 35 | def request( 36 | url, 37 | params={}, 38 | json=None, 39 | data=None, 40 | headers={}, 41 | method="GET", 42 | verify=True, 43 | redirect=True, 44 | cookiejar=None, 45 | basic_auth=None, 46 | timeout=None, 47 | ): 48 | """ 49 | Returns a (named)tuple with the following properties: 50 | - request 51 | - content 52 | - json (dict; or None) 53 | - headers (dict; all lowercase keys) 54 | - https://stackoverflow.com/questions/5258977/are-http-headers-case-sensitive 55 | - status 56 | - url (final url, after any redirects) 57 | - cookiejar 58 | """ 59 | method = method.upper() 60 | headers = {k.lower(): v for k, v in headers.items()} # lowercase headers 61 | 62 | if params: 63 | url += "?" + urlencode(params) # build URL from query parameters 64 | 65 | if json and data: 66 | raise Exception("Cannot provide both json and data parameters") 67 | 68 | if method not in ["POST", "PATCH", "PUT"] and (json or data): 69 | raise Exception( 70 | "Request method must POST, PATCH or PUT if json or data is provided" 71 | ) 72 | 73 | if not timeout: 74 | timeout = 60 75 | 76 | if json: # if we have json, dump it to a string and put it in our data variable 77 | headers["content-type"] = "application/json" 78 | data = json_lib.dumps(json).encode("utf-8") 79 | elif data: 80 | data = urlencode(data).encode() 81 | 82 | if basic_auth and len(basic_auth) == 2 and "authorization" not in headers: 83 | username, password = basic_auth 84 | headers[ 85 | "authorization" 86 | ] = f'Basic {b64encode(f"{username}:{password}".encode()).decode("ascii")}' 87 | 88 | if not cookiejar: 89 | cookiejar = CookieJar() 90 | 91 | ctx = ssl.create_default_context() 92 | if not verify: # ignore ssl errors 93 | ctx.check_hostname = False 94 | ctx.verify_mode = ssl.CERT_NONE 95 | 96 | handlers = [] 97 | handlers.append(HTTPSHandler(context=ctx)) 98 | handlers.append(HTTPCookieProcessor(cookiejar=cookiejar)) 99 | 100 | if not redirect: 101 | no_redirect = NoRedirect() 102 | handlers.append(no_redirect) 103 | 104 | opener = build_opener(*handlers) 105 | req = Request(url, data=data, headers=headers, method=method) 106 | 107 | try: 108 | with opener.open(req, timeout=timeout) as resp: 109 | status, content, resp_url = (resp.getcode(), resp.read(), resp.geturl()) 110 | headers = {k.lower(): v for k, v in list(resp.info().items())} 111 | 112 | if "gzip" in headers.get("content-encoding", ""): 113 | content = gzip.decompress(content) 114 | 115 | json = ( 116 | json_lib.loads(content) 117 | if "application/json" in headers.get("content-type", "").lower() 118 | and content 119 | else None 120 | ) 121 | except HTTPError as e: 122 | status, content, resp_url = (e.code, e.read(), e.geturl()) 123 | headers = {k.lower(): v for k, v in list(e.headers.items())} 124 | 125 | if "gzip" in headers.get("content-encoding", ""): 126 | content = gzip.decompress(content) 127 | 128 | json = ( 129 | json_lib.loads(content) 130 | if "application/json" in headers.get("content-type", "").lower() and content 131 | else None 132 | ) 133 | 134 | return Response(req, content, json, status, resp_url, headers, cookiejar) 135 | 136 | 137 | import unittest 138 | 139 | 140 | class RequestTestCase(unittest.TestCase): 141 | def test_cannot_provide_json_and_data(self): 142 | with self.assertRaises(Exception): 143 | request( 144 | "https://httpbingo.org/post", 145 | json={"name": "Brenton"}, 146 | data="This is some form data", 147 | ) 148 | 149 | def test_should_fail_if_json_or_data_and_not_p_method(self): 150 | with self.assertRaises(Exception): 151 | request("https://httpbingo.org/post", json={"name": "Brenton"}) 152 | 153 | with self.assertRaises(Exception): 154 | request( 155 | "https://httpbingo.org/post", json={"name": "Brenton"}, method="HEAD" 156 | ) 157 | 158 | def test_should_set_content_type_for_json_request(self): 159 | response = request( 160 | "https://httpbingo.org/post", json={"name": "Brenton"}, method="POST" 161 | ) 162 | self.assertEqual(response.request.headers["Content-type"], "application/json") 163 | 164 | def test_should_work(self): 165 | response = request("https://httpbingo.org/get") 166 | self.assertEqual(response.status, 200) 167 | 168 | def test_should_create_url_from_params(self): 169 | response = request( 170 | "https://httpbingo.org/get", 171 | params={"name": "brenton", "library": "tiny-request"}, 172 | ) 173 | self.assertEqual( 174 | response.url, "https://httpbingo.org/get?name=brenton&library=tiny-request" 175 | ) 176 | 177 | def test_should_return_headers(self): 178 | response = request( 179 | "https://httpbingo.org/response-headers", params={"Test-Header": "value"} 180 | ) 181 | self.assertEqual(response.headers["test-header"], "value") 182 | 183 | def test_should_populate_json(self): 184 | response = request("https://httpbingo.org/json") 185 | self.assertTrue("slideshow" in response.json) 186 | 187 | def test_should_return_response_for_404(self): 188 | response = request("https://httpbingo.org/404") 189 | self.assertEqual(response.status, 404) 190 | self.assertTrue("text/plain" in response.headers["content-type"]) 191 | 192 | def test_should_fail_with_bad_ssl(self): 193 | with self.assertRaises(URLError): 194 | response = request("https://expired.badssl.com/") 195 | 196 | def test_should_load_bad_ssl_with_verify_false(self): 197 | response = request("https://expired.badssl.com/", verify=False) 198 | self.assertEqual(response.status, 200) 199 | 200 | def test_should_form_encode_non_json_post_requests(self): 201 | response = request( 202 | "https://httpbingo.org/post", data={"name": "test-user"}, method="POST" 203 | ) 204 | self.assertEqual(response.json["form"]["name"], ["test-user"]) 205 | 206 | def test_should_follow_redirect(self): 207 | response = request( 208 | "https://httpbingo.org/redirect-to", 209 | params={"url": "https://duckduckgo.com/"}, 210 | ) 211 | self.assertEqual(response.url, "https://duckduckgo.com/") 212 | self.assertEqual(response.status, 200) 213 | 214 | def test_should_not_follow_redirect_if_redirect_false(self): 215 | response = request( 216 | "https://httpbingo.org/redirect-to", 217 | params={"url": "https://duckduckgo.com/"}, 218 | redirect=False, 219 | ) 220 | self.assertEqual(response.status, 302) 221 | 222 | def test_cookies(self): 223 | response = request( 224 | "https://httpbingo.org/cookies/set", 225 | params={"cookie": "test"}, 226 | redirect=False, 227 | ) 228 | response = request( 229 | "https://httpbingo.org/cookies", cookiejar=response.cookiejar 230 | ) 231 | self.assertEqual(response.json["cookie"], "test") 232 | 233 | def test_basic_auth(self): 234 | response = request( 235 | "http://httpbingo.org/basic-auth/user/passwd", basic_auth=("user", "passwd") 236 | ) 237 | self.assertEqual(response.json["authorized"], True) 238 | 239 | def test_should_handle_gzip(self): 240 | response = request( 241 | "http://httpbingo.org/gzip", headers={"Accept-Encoding": "gzip"} 242 | ) 243 | self.assertEqual(response.json["gzipped"], True) 244 | 245 | def test_should_handle_gzip_error(self): 246 | response = request( 247 | "http://httpbingo.org/status/418", headers={"Accept-Encoding": "gzip"} 248 | ) 249 | self.assertEqual(response.content, b"I'm a teapot!") 250 | 251 | def test_should_timeout(self): 252 | import socket 253 | 254 | with self.assertRaises((TimeoutError, socket.timeout)): 255 | response = request("http://httpbingo.org/delay/3", timeout=1) 256 | 257 | def test_should_handle_head_requests(self): 258 | response = request("http://httpbingo.org/head", method="HEAD") 259 | self.assertTrue(response.content == b"") 260 | --------------------------------------------------------------------------------