├── .gitignore ├── environment.yml ├── LICENSE ├── ssl_expiry_lambda.py ├── README.md └── ssl_expiry.py /.gitignore: -------------------------------------------------------------------------------- 1 | Archive.zip 2 | -------------------------------------------------------------------------------- /environment.yml: -------------------------------------------------------------------------------- 1 | name: ssl-expiry 2 | channels: 3 | - defaults 4 | dependencies: 5 | - appnope=0.1.0=py36_0 6 | - decorator=4.0.11=py36_0 7 | - ipython=6.1.0=py36_0 8 | - ipython_genutils=0.2.0=py36_0 9 | - jedi=0.10.2=py36_2 10 | - openssl=1.0.2l=0 11 | - path.py=10.3.1=py36_0 12 | - pexpect=4.2.1=py36_0 13 | - pickleshare=0.7.4=py36_0 14 | - pip=9.0.1=py36_1 15 | - prompt_toolkit=1.0.14=py36_0 16 | - ptyprocess=0.5.1=py36_0 17 | - pygments=2.2.0=py36_0 18 | - python=3.6.1=2 19 | - readline=6.2=2 20 | - setuptools=27.2.0=py36_0 21 | - simplegeneric=0.8.1=py36_1 22 | - six=1.10.0=py36_0 23 | - sqlite=3.13.0=0 24 | - tk=8.5.18=0 25 | - traitlets=4.3.2=py36_0 26 | - wcwidth=0.1.7=py36_0 27 | - wheel=0.29.0=py36_0 28 | - xz=5.2.2=1 29 | - zlib=1.2.8=3 30 | - pip: 31 | - ipython-genutils==0.2.0 32 | - prompt-toolkit==1.0.14 33 | 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Lucas Roesler 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. -------------------------------------------------------------------------------- /ssl_expiry_lambda.py: -------------------------------------------------------------------------------- 1 | # Author: Lucas Roelser 2 | """ 3 | ssl_expriy_lambda uses the ssl_expiry script to allow you to check a list of 4 | SSL certificate expiration dates via AWS Gateway. 5 | 6 | See the README for configuration information 7 | """ 8 | 9 | 10 | import json 11 | import logging 12 | import os 13 | 14 | import ssl_expiry 15 | 16 | loglevel = os.environ.get('LOGLEVEL', 'INFO') 17 | numeric_level = getattr(logging, loglevel.upper(), None) 18 | if not isinstance(numeric_level, int): 19 | raise ValueError('Invalid log level: %s' % loglevel) 20 | logging.basicConfig(level=numeric_level) 21 | 22 | logger = logging.getLogger('SSLVerifyLambda') 23 | 24 | 25 | def main(event, *args, **kwargs) -> list: 26 | # use the env var HOSTLIST to define a default list of hostnames 27 | HOST_LIST = os.environ.get('HOSTLIST', '').split(',') 28 | EXPIRY_BUFFER = int(os.environ.get('EXPIRY_BUFFER', '14')) 29 | 30 | try: 31 | # pull any additional hostnames from the potential AWS Gateway 32 | # event object 33 | query_params = event.get('params', {}).get('querystring', {}) 34 | additional_hosts = query_params.get('host_list', '').split(',') 35 | except Exception: 36 | additional_hosts = [] 37 | 38 | # cleanup the host list 39 | HOST_LIST += additional_hosts 40 | HOST_LIST = filter(None, (x.strip() for x in HOST_LIST)) 41 | 42 | logger.debug('Testing hosts {}'.format(HOST_LIST)) 43 | response = [ 44 | ssl_expiry.test_host(host, buffer_days=EXPIRY_BUFFER) 45 | for host in HOST_LIST 46 | ] 47 | for msg in response: 48 | if 'error' in msg or 'expire' in msg: 49 | error = { 50 | 'message': 'Cert Errors', 51 | 'results': response, 52 | 'additional_hosts': additional_hosts, 53 | } 54 | raise Exception(json.dumps(error)) 55 | 56 | return { 57 | 'message': 'All certs are fine', 58 | 'results': response, 59 | 'additional_hosts': additional_hosts, 60 | } 61 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SSL Expiry 2 | 3 | A simple script to check the expiration date on a list of domains. 4 | 5 | This simple python 3 utility is meant to be deployed as a cron or run from a lambda service. 6 | 7 | ## Usage 8 | 9 | ```sh 10 | $ echo "google.com\nfacebook.com" | python ssl_expiry.py 11 | > google.com cert is fine 12 | > facebook.com cert is fine 13 | ``` 14 | 15 | 16 | ## Install 17 | 18 | Setup your python environment and test it as follows. 19 | 20 | ```sh 21 | $ conda env create -f environment.yml 22 | $ source activate ssl-expiry 23 | $ echo "google.com\nfacebook.com" | python ssl_expiry.py 24 | ``` 25 | 26 | ## AWS API Gateway and Lambda 27 | 28 | ### AWS Lambda 29 | To deploy to Lambda, create a zip that contains `ssl_expiry.py` and `ssl_expiry_lambda.py` and then follow the normal instructions to setup and configure a Lambda function. The `ssl_expiry_lambda` will use, if they exist, two env parameters: 30 | 31 | - `HOSTLIST`: a comma separated string of hostnames to validate, and 32 | - `EXPIRY_BUFFER`: an int that represents the days prior to expiration that the script will alert for, ie alert if the expiration is within `EXPIRY_BUFFER` days. 33 | 34 | 35 | ### AWS API Gateway 36 | Once the Lambda is configured, you can setup a new api in API Gateway. The important parts that are not obvious from the API Gateway admin ui are as follows: 37 | 38 | 39 | You will need to create a new Integration Response for the exception that 40 | is raised when the check finds a failing or soon to fail certificate. 41 | 42 | I configured this a a new Integration Response with a regex of 43 | 44 | `.*Cert Errors.*` 45 | 46 | and a Body Mapping Template with content type `application/json` and 47 | the template: 48 | 49 | ``` 50 | #set($inputRoot = $input.path('$')) 51 | $input.path('$.errorMessage') 52 | ``` 53 | 54 | With this configuration, the exception raised by the `main` method will 55 | be parsed and returned as the body of the response. The HTTP status code 56 | will be a 400. 57 | 58 | Additionally, in the Method Request section, I declared URL Query String 59 | Parameters for `host_list` and `expiry_buffer`. 60 | 61 | Finally, you should also define a Method Response for the 400 status. 62 | This can be left with all for the default empty values for response headers 63 | and response body. -------------------------------------------------------------------------------- /ssl_expiry.py: -------------------------------------------------------------------------------- 1 | # Author: Lucas Roelser 2 | # Modified from serverlesscode.com/post/ssl-expiration-alerts-with-lambda/ 3 | 4 | import datetime 5 | import fileinput 6 | import logging 7 | import os 8 | import socket 9 | import ssl 10 | import time 11 | 12 | 13 | logger = logging.getLogger('SSLVerify') 14 | 15 | 16 | def ssl_expiry_datetime(hostname: str) -> datetime.datetime: 17 | ssl_date_fmt = r'%b %d %H:%M:%S %Y %Z' 18 | 19 | context = ssl.create_default_context() 20 | conn = context.wrap_socket( 21 | socket.socket(socket.AF_INET), 22 | server_hostname=hostname, 23 | ) 24 | # 3 second timeout because Lambda has runtime limitations 25 | conn.settimeout(3.0) 26 | 27 | logger.debug('Connect to {}'.format(hostname)) 28 | conn.connect((hostname, 443)) 29 | ssl_info = conn.getpeercert() 30 | # parse the string from the certificate into a Python datetime object 31 | return datetime.datetime.strptime(ssl_info['notAfter'], ssl_date_fmt) 32 | 33 | 34 | def ssl_valid_time_remaining(hostname: str) -> datetime.timedelta: 35 | """Get the number of days left in a cert's lifetime.""" 36 | expires = ssl_expiry_datetime(hostname) 37 | logger.debug( 38 | 'SSL cert for {} expires at {}'.format( 39 | hostname, expires.isoformat() 40 | ) 41 | ) 42 | return expires - datetime.datetime.utcnow() 43 | 44 | 45 | def test_host(hostname: str, buffer_days: int=30) -> str: 46 | """Return test message for hostname cert expiration.""" 47 | try: 48 | will_expire_in = ssl_valid_time_remaining(hostname) 49 | except ssl.CertificateError as e: 50 | return f'{hostname} cert error {e}' 51 | except ssl.SSLError as e: 52 | return f'{hostname} cert error {e}' 53 | except socket.timeout as e: 54 | return f'{hostname} could not connect' 55 | else: 56 | if will_expire_in < datetime.timedelta(days=0): 57 | return f'{hostname} cert will expired' 58 | elif will_expire_in < datetime.timedelta(days=buffer_days): 59 | return f'{hostname} cert will expire in {will_expire_in}' 60 | else: 61 | return f'{hostname} cert is fine' 62 | 63 | 64 | if __name__ == '__main__': 65 | loglevel = os.environ.get('LOGLEVEL', 'INFO') 66 | numeric_level = getattr(logging, loglevel.upper(), None) 67 | if not isinstance(numeric_level, int): 68 | raise ValueError('Invalid log level: %s' % loglevel) 69 | logging.basicConfig(level=numeric_level) 70 | 71 | start = time.time() 72 | for host in fileinput.input(): 73 | host = host.strip() 74 | logger.debug('Testing host {}'.format(host)) 75 | message = test_host(host) 76 | print(message) 77 | 78 | logger.debug('Time: {}'.format(time.time() - start)) 79 | --------------------------------------------------------------------------------