最近我其中一個個人網站的 SSL 憑證出現了問題,細查之下才發現是 SSL 憑證已經到期,但伺服器未有自動更新。
趕緊搶修後,忽發奇想:我們能否在 Python 裡監測 SSL 憑證,以致我們可以在憑證到期前更新,避免網站訪客流失?
SSL 憑證的重要性
獲取一個網站的 SSL 憑證
事不宜遲,我們先學習如何獲取單一網站的 SSL 憑證。以下的教學使用 Google Colab Notebook,一個免費的免安裝 Python 平台。如您未曾使用過 Colab 可以參考這篇: 新手 1/3:5 分鐘免安裝學習 Python?Google Colab Notebook 幫緊您!

from urllib.request import ssl, socket from datetime import datetime import pandas as pd import json
#@title 獲取網站 SSL 憑證
url = 'pythonviz.com'  #@param {type: "string"}
context = ssl.create_default_context()
with socket.create_connection((url,'443')) as sock:
  with context.wrap_socket(sock, server_hostname=url) as ssock:
    ver = ssock.version()
    data = ssock.getpeercert()
print('TLS 的版本為:',ver,'\n')
print('SSL 憑證的細節為:')
data
我們在 Colab Notebook 新增 2 個 Code block,然後貼上以上的代碼。
留意我們第 2 個 Code block 有一個互動輸入(上圖紅框),可以讓我們直接輸入一個新的網站。這是由於我們在 url 這個定義後加入 #@param {type: "string"},令 Colab 製做一個可以輸入的空格 Text Field。詳細可以參考這裡:新手 2/3:如何用 Google Colab 製做互動表格和圖表?快來學 Import library/Jupyter
第 2 個 Code block 亦有許多艱辛難明的代碼,需要對網絡建設(internet infrastructure)有一定理解才會明白。我們以下略談每個物件的用處,如果您不太感興趣可以略過這點。
| 物件 | 用途 | 
|---|---|
| context | 即 SSL Context,用於儲存加密連接(SSL Connection)的數據,如 SSL 憑證等。 | 
| socket | 即 SSL Socket,可以想像為一個加密連接(SSL Connection)。socket 會從 context 傳承(inherit)數據並開啟一個前往指定網站的連接。 | 
回到正題:留意我們的回傳 data 是一個字典,載有這個網站 SSL 憑證的資料。
SSL 憑證的失效日期
我們最感興趣的是 data 的「notAfter」數值。這是代表我們網站的 SSL 憑證到期日,假如我們沒有在該日前更新 SSL 憑證,網頁加密將會失效,使我們的用戶暴露在不安全的環境。
如上圖所示,pythonviz.com 的憑證在寫文的一刻是 "notAfter": "Oct 13 19:57:24 2021 GMT"。換言之,我們需要在 10 月前更新 SSL 憑證,確保用戶能安全瀏覽我們的網站。
處理已失效的 SSL 憑證
以上我們探討的例子是 SSL 憑證仍未到期的例子。那麼如果憑證已到期,我們會見到什麼?

這裡我們先介紹這個好用的 badssl.com 網站。我們想要測試 Python 代碼如何處理網站的 SSL 憑證時,可以使用這個網站的不同版面測試。
我們的目的是監測 SSL 憑證是否到期。因此,這次我們使用 Certificate: expired 的選項,來測試我們在上一部份的 Python 編程會回傳什麼。

如果您在 Google Chrome 開啟 https://expired.badssl.com/ (在 badssl.com 選擇 expired certificate),會見到 Google Chrome 顯示一個網站不安全的警告,而原因是「NET::ERR_CERT_DATE_INVALID」,即 SSL 憑證已因到期(或錯誤時間)而失效。

我們使用同樣的 Python 代碼,只改變上圖紅色框裡的 URL 為「expired.badssl.com」。
按下播放鍵後,見到這次我們的出現了一個 Python 錯誤:
SSLCertVerificationError: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: certificate has expired (_ssl.c:1091)
由於 Python 嘗試驗證 (handshake)網站時發現 expired.badssl.com 的 SSL 憑證早已過期,所以我們在提取 SSL 憑證時,便提醒我們這個網站的 SSL 憑證不安全。
既然如此,我們如何處理這個 SSLCertVerificationError 的同時,又不至於使我們的代碼出現 Python 錯誤,不能運行?
SSL 憑證的異常處理

#@title 獲取網站 SSL 憑證(包括異常處理)
url = 'expired.badssl.com'  #@param {type: "string"}
context = ssl.create_default_context()
try:
  with socket.create_connection((url,'443')) as sock:
    with context.wrap_socket(sock, server_hostname=url) as ssock:
      ver = ssock.version()
      data = ssock.getpeercert()
      print('TLS 的版本為:',ver,'\n')
      print('SSL 憑證的細節為:')
      data
except ssl.SSLCertVerificationError as err:
  print(str(err))
  print(str(err).split('certificate verify failed: ')[1])
except Exception as err:
  print(str(err))
要處理這個 SSLCertVerificationError 的問題有多種解法,但我們集中討論使用 try-except 的語法處理異常。
我們把 Python 代碼稍為更改一下,加入了以上 try 和 except 的數行。簡單而說,try 類似一個 if 的語法,分別在於如果 try 裡面的代碼出錯,Python 不會直接回報異常,改而執行 except 裡的代碼。這與 Excel 裡的 IFERROR() 函數差不多。
在上面的代碼,我們加入了 2 個 except 的處理:
- 第 1 個 except 只限於我們上文提及的 SSLCertVerificationError。我們把 Python 異常變成文字(str),然後提取異常的實際原因,即「certificate has expired (_ssl.c:1091)」
- 第 2 個 except 是一個截流器(catch-all),處理其他不是 SSL 憑證引起的異常。我習慣加入這個 catch-all 以確保我們處理異常時不會「多此一舉」
多個網站的 SSL 憑證報告
既然我們要做到監測 SSL 憑證的效果,自然要懂得輸出 SSL 憑證的報告。
如果我們要輸出一個統一標準(standardized),最直接的方法就是寫出一個自訂的功能(custom function)。透過將以上的編程稍作更改,以下是我們的自訂功能:

# 自訂功能
def verifySSLCertificate(url):
  result = {}
  result['url'] = url
  context = ssl.create_default_context()
  try:
    # 1: SSL 憑證有效,回傳 Success 及憑證到期日
    with socket.create_connection((url,'443')) as sock:
      with context.wrap_socket(sock, server_hostname=url) as ssock:
        data = ssock.getpeercert()
        result['status'] = 'Success'
        result['expiration'] = datetime.strptime(data['notAfter'], r'%b %d %H:%M:%S %Y %Z')
        result['message'] = None
  except ssl.SSLCertVerificationError as err:
    # 2: SSL 憑證無效,回傳 SSL Error
    result['status'] = 'SSL Error'
    result['expiration'] = None
    result['message'] = str(err).split('certificate verify failed: ')[1]
  except Exception as err:
    # 3: 未知錯誤,回傳 Unknown Error
    result['status'] = 'Unknown Error'
    result['expiration'] = None
    result['message'] = str(err)
  
  return result
# 測試自訂功能
verifySSLCertificate('pythonviz.com')
我們來分析一下上面的編程。
從編程的回傳(output)而言,我們回送的是 result,一個 Python 字典(dictionary)。這個 result 的字典含有 4 個項目:url、status、expiration、message。
根據我們的 try-except block,我們會更改 result 的內容,使其反映該網站的數據。比較重要的是:
- 如果 SSL 憑證有效,回傳憑證到期日
- 如果 SSL 憑證無效,回傳 ‘SSL Error’
- 如果編程發現未知錯誤,回傳 ‘Unknown Error’
測試多個網站
如果我們想測試多個網站,那麼我們該如何處理?這時我們便可以使用 Python 大名鼎鼎的 list comprehension 獲取這個報告。如果您仍未了解 list comprehension,可以參考:List Comprehension: Python 的 For Loop 怎樣使用?
listOfDomain = ['pythonviz.com','expired.badssl.com','nonexistdomain123.com'] [verifySSLCertificate(x) for x in listOfDomain]
我們可以先定義一個列表 listOfDomain,然後以上圖的語法同時測試 listOfDomain 的多個網站。
留意由於每一個 verifySSLCertificate() 都會回傳一個字典(dictionary),所以我們的輸出是一個字典的列表(list of dictionary)。
pandas dataframe 報告
最後,我們可以將 SSL 憑證的報告輸出成一個 pandas dataframe,方便我們輸出成其他報告、做一些篩選(filtering)等。

listOfDomain = ['pythonviz.com','apple.com','sha256.badssl.com','expired.badssl.com','thisdomaindoesnotexistright.com']
df = pd.DataFrame([verifySSLCertificate(x) for x in listOfDomain])
df['remaining'] = (df['expiration'] - datetime.today()).dt.days
df.sort_values('remaining')
這個語法算是上面介紹過的 list comprehension 的一個延伸。我們同樣地使用 [verifySSLCertificate(x) for x in listOfDomain] ,唯獨是把這個字典的列表(list of dictionary)傳送到 pandas 裡,生成一個新的 pandas dataframe。
有了 pandas dataframe 便好辦事了。我們可以加入新的列(column)、作篩選(filter)去達至我們需要的效果。
譬如我們上面加入了一個叫做 remaining 的列,是距離每個網站 SSL 憑證的到期日還有多少天。我們再使用 df.sort_values('remaining') 便可以把先到期的網站放在上方,斷定我們需要先處理哪個網站。
教學完整代碼
最後為大家送上這篇教學等的完整代碼。還未使用過 Google Colab Notebook?快來看這篇教學吧:新手 1/3:5 分鐘免安裝學習 Python?Google Colab Notebook 幫緊您!
結語
以上我們簡單地介紹了如何用 Python 讀取 SSL 憑證的數據,並進行一些簡單的監測,匯出一個 pandas dataframe。
當然我們還有許多未能盡數的 SSL 憑證問題,例如已經 Revoked 的憑證等,但是希望您透過以上的教學能學會多一些 Python 編程技能,可以按自己需求更改以上代碼。
參考連結:
- https://stackoverflow.com/questions/44280747/how-to-check-a-ssl-certificate-expiration-date-with-aiohttp
- https://stackoverflow.com/questions/41620369/how-to-get-ssl-certificate-details-using-python
- https://serverlesscode.com/post/ssl-expiration-alerts-with-lambda/
- https://lucasroesler.com/2017/06/ssl-expiry-quick-and-simple/
- https://stackoverflow.com/questions/40923820/pandas-timedelta-in-months
- https://badssl.com/
 
								 
				 
															





















