💡 先搞懂問題
「晴空咖啡」的線上訂位系統架在一台 Linux 主機上。某天早上,值班的工程師發現 SSH 登入紀錄(auth.log)一夜之間多了幾千行,想知道是誰在試密碼、試了幾次、有沒有人最後真的登進來。打開檔案一看,每一行長得像這樣:
2026-10-07T09:00:01 sshd[311]: Failed password for root from 203.0.113.7 port 50122 ssh2
2026-10-07T09:00:03 sshd[311]: Failed password for invalid user admin from 203.0.113.7 port 50124 ssh2
2026-10-07T09:00:09 sshd[311]: Accepted password for amy from 203.0.113.7 port 50130 ssh2
人眼看得懂,但程式要先把它拆成欄位才能統計。最直覺的做法是 line.split() 再取第幾個元素,問題是位置不固定:帳號不存在時多了「invalid user」兩個字,IP 就從第 8 個元素跑到第 10 個;同一台主機上的網站存取日誌又是另一種格式。用 in、find 搭配一堆 if 也可以,但規則一多就難以維護,還容易漏掉例外。這類「有固定模式、但不是表格」的文字,稱為半結構化(semi-structured)資料。
正規表示式(regular expression,簡稱 regex)就是為這種情況設計的:它是一個描述文字模式的小語言,你寫下「Failed password for,後面可能有 invalid user,接著一段不含空白的帳號,再接 from 和一段 IP」,re 模組就會在每一行裡找出符合的部分,並把括號圈起來的段落取出來當欄位。新手最常卡在三個地方:分不清 search、match、fullmatch 何時有結果;不知道 findall 遇到括號時回傳的東西會變;以及樣式寫得「看起來能用」,實際上卻把 999.1.1.1 這種不合法的字串也抓了進來。
(?:invalid user )? 表示「可有可無、而且不取出來」。下方是比對成功後用 m.groupdict() 拿到的一列資料,之後就能像表格一樣篩選與計數。生活比喻:倉管照著規格單找貨
想像一間物流倉庫的倉管,主管給他的不是「去拿編號 A1023 的那一箱」,而是一張規格單:「找出所有標籤是一個大寫字母、接著四位數字的箱子,把數字的部分抄下來。」倉管拿著規格單,沿著貨架一箱一箱比對標籤。比對的方式也有幾種:主管可能只要他看「這排第一箱是不是符合」,也可能要他「整排找,找到第一個就回報」,或是「把整排符合的全部搬出來」。如果是驗收新進貨,則要求「整張標籤從頭到尾都要符合規格」,標籤後面多貼了一張貼紙也不行。
r"Failed password for (?:invalid user )?(?P<user>\S+) from (?P<ip>\S+)");「只看第一箱」是 re.match,只從字串開頭比對;「整排找、找到第一個就停」是 re.search;「驗收時整張標籤都要符合」是 re.fullmatch,驗證使用者輸入時要用它;「全部搬出來」是 re.findall 與 re.finditer;「只抄數字部分」是用括號建立的群組(group),比對成功後可以用 m.group("ip") 單獨取出。這個比喻有一個地方容易誤解:倉管看得懂標籤的意思,知道「Z9999」雖然格式對,但倉庫根本沒有這個區;正規表示式只認字元的形狀,不懂意義。樣式 \d{1,3}(?:\.\d{1,3}){3} 會很開心地抓下 999.1.1.1,因為它「長得像」IP。所以實務上 regex 負責「找出長得像的」,抓到之後還要交給 ipaddress 這類專門的模組確認它真的合法。另外,倉管累了會停下來,regex 引擎不會:某些寫法遇到特定的輸入,會在各種可能的組合之間來回嘗試很久,這就是後面會談的 ReDoS。
🎮 互動實驗室一:Regex 試驗台
下面 12 行是虛構的 SSH 與網站日誌(IP 都用 RFC 5737 文件專用網段,999.1.1.1 是刻意放的不合法位址)。點上方的預設樣式,或在輸入框直接改寫,再切換要用的函式;每一行會即時以黃底標出整段命中、以藍底標出群組,下方灰字是 Python 會印出的結果。程式邏輯等同對每一行執行一次 pat.函式(line)。輸入框請用 Python 的語法:畫面在瀏覽器裡用 JavaScript 模擬,會先把 (?P<name>…) 這類 Python 寫法轉成 JavaScript 能懂的寫法再執行;兩邊不支援的語法會在下方提示。
🎮 互動實驗室二:從日誌到告警的逐步管線
這裡有 14 行虛構的 SSH 登入紀錄。按「下一步」會依序執行五個步驟:用命名群組抽欄位、用 ipaddress 驗證 IP、篩出 Failed、用 Counter 計數、套用門檻。左邊是這一步的 Python 程式,右邊是執行後的中間結果。走到第 4、5 步時,可以切換要依「IP」還是「帳號」計數,並拖動門檻滑桿,看哪些來源會被標紅,以及有沒有「多次失敗後登入成功」的紀錄。所有數字都和 Python 實跑結果一致。
🎮 互動實驗室三:這個 pattern 會抓到什麼
每張卡是一小段原創的 Python,請預測它印出什麼。選項裡刻意放了最常見的誤解,例如以為 \d+ 會抓整個 IP、以為 . 只代表句點、以為 findall 一定回傳整段命中。答完會說明原因;所有正解都用 Python 3 實際執行過。
📘 原理補完
實驗室裡的直覺可以整理成三件事:先選對比對函式,再用群組把欄位取出來,最後別相信 regex 抓到的就一定是對的。下面依序接回 re 模組的正式用語,最後整理 Python 與 JavaScript 的差異,以及最容易寫錯的地方。
1. 五個比對函式:從哪裡開始找、找幾個、回傳什麼
re 模組的函式都接受「樣式、字串、旗標」,差別在比對的位置與回傳型別。search、match、fullmatch 回傳一個 Match 物件(或 None),Match 物件記錄了命中的位置 span()、內容 group() 與各群組;findall 直接回傳串列;finditer 回傳一個迭代器,逐一產生 Match 物件,資料量大時比較省記憶體,也能拿到每筆命中的位置。
match 只看開頭、fullmatch 要求整串符合,所以都是 None。這張圖的結果都是在 Python 實際執行得到的。| 函式 | 比對位置 | 回傳 | 沒找到時 | 典型用途 |
|---|---|---|---|---|
re.search | 從頭往後掃,第一個符合就停 | Match | None | 這一行有沒有某個欄位 |
re.match | 只從字串開頭(位置 0)比對 | Match | None | 格式固定、從行首開始的日誌 |
re.fullmatch | 整個字串都要符合 | Match | None | 驗證使用者輸入、欄位格式 |
re.findall | 全部、不重疊 | 串列(有群組時內容會變) | [] | 抽出所有 IP、所有網址 |
re.finditer | 全部、不重疊 | Match 的迭代器 | 空的迭代器 | 大檔案、需要位置或多個群組 |
re.sub | 全部(可用 count 限制次數) | 取代後的新字串 | 原字串 | 遮蔽敏感資料、正規化格式 |
Match 物件的布林值是 True,None 是 False,所以常見寫法是 if m: 或 if m is None: continue。題目最常設計的陷阱是直接寫 re.search(...).group(1):沒找到時 None 沒有 group 方法,程式會丟 AttributeError。
2. 群組、命名群組與 findall 的回傳形狀
括號 ( ) 建立擷取群組(capturing group),依左括號出現的順序編號:m.group(0) 是整段命中,m.group(1) 是第一組,m.groups() 一次取出全部。欄位一多,數第幾組很容易數錯,這時改用命名群組 (?P<name>…),再用 m.group("ip")、m["ip"] 或 m.groupdict() 依名稱取值。只想把幾個字元綁在一起套用量詞、不想取出來時,用非擷取群組 (?:…)。
(?:…)? 參與比對但不出現在結果裡。下半:同樣想抓「帳號@主機」,樣式裡括號的數量決定 findall 回傳字串、單一群組的內容,還是 tuple。下半的字串是示意。findall 這條規則是程式閱讀題的常客。最經典的情況是用 \d{1,3}(\.\d{1,3}){3} 抓 IP:作者只是想讓 \.\d{1,3} 重複三次,卻用了會擷取的括號,結果 findall 只回傳那個群組「最後一次」比對到的內容,實測得到的是 ['.7', '.4'] 而不是完整的 IP。改成 (?:\.\d{1,3}){3} 就正常了。
3. 貪婪與非貪婪
*、+、?、{m,n} 這些量詞預設是貪婪(greedy)的:先盡量多吃,後面比對不下去時再一個一個吐回來。"(.*)" 套在 GET "/a" 200 "curl" 上,.* 會一路吃到最後一個引號前,得到 /a" 200 "curl。在量詞後面加 ? 就變成非貪婪(lazy),盡量少吃,"(.*?)" 得到 /a。更穩的寫法是直接描述「不是引號的字元」:"([^"]*)",意圖清楚,回溯也少。
200,把兩段引號內容黏在一起;藍色與綠色都只取第一段。解析網站日誌的請求列時,橘色是最常見的錯誤。4. 原始字串與 re.compile
樣式在交給 re 之前,要先經過一層 Python 字串的處理。"\b" 在一般字串裡是退格字元(長度 1),re 看到的根本不是「單字邊界」;寫成原始字串(raw string)r"\b",反斜線原封不動,re 才看得到 \b。"\d" 雖然剛好能用,但它是無效的跳脫序列,Python 3.12 起會發出 SyntaxWarning。結論很單純:樣式一律寫成 r"…"。
re.compile(pattern, flags) 把樣式先編譯成 Pattern 物件,之後呼叫 pat.search(line)。官方文件說明,模組層級的函式(例如 re.search)也會快取最近用過的樣式,所以少量樣式不編譯也不會明顯變慢;預先編譯的主要好處是在同一支程式裡重複使用時更有效率,而且把樣式取個名字(例如 SSH_FAIL),程式比較好讀,也方便集中測試。
5. 完整管線:解析、驗證、計數、門檻
把管線寫成程式就是下面這樣。重點在於每一步都只做一件事,而且「失敗」的資料有被數到,而不是默默消失:
import ipaddress, re
from collections import Counter
PAT = re.compile(
r"^(?P<ts>\S+) sshd\[\d+\]: (?P<result>Failed|Accepted) "
r"(?:password|publickey) for (?:invalid user )?(?P<user>\S+) "
r"from (?P<ip>\S+) port (?P<port>\d+)"
) # 編譯一次,迴圈裡重複使用
LOG = """2026-10-07T09:00:01 sshd[311]: Failed password for root from 203.0.113.7 port 50122 ssh2
2026-10-07T09:00:03 sshd[311]: Failed password for invalid user admin from 203.0.113.7 port 50124 ssh2
2026-10-07T09:00:05 sshd[311]: Failed password for amy from 203.0.113.7 port 50126 ssh2
2026-10-07T09:00:09 sshd[311]: Accepted password for amy from 203.0.113.7 port 50130 ssh2
2026-10-07T09:02:00 sshd[433]: Failed password for root from 192.0.2.99 port 33001 ssh2
2026-10-07T09:02:30 sshd[515]: Connection closed by 192.0.2.44 port 52011 [preauth]
2026-10-07T09:03:00 sshd[520]: Failed password for carol from 999.1.1.1 port 40001 ssh2"""
rows, skipped = [], 0
for line in LOG.splitlines():
m = PAT.match(line) # 從行首比對,格式不符回傳 None
if m is None:
skipped += 1 # 不符的行要計數,數量暴增本身就是警訊
continue
r = m.groupdict() # 依名稱取出全部欄位,變成 dict
try:
ipaddress.ip_address(r["ip"]) # regex 只看形狀,合法性交給 ipaddress
except ValueError:
skipped += 1
continue
rows.append(r)
fails = Counter(r["ip"] for r in rows if r["result"] == "Failed")
print(fails.most_common()) # [('203.0.113.7', 3), ('192.0.2.99', 1)]
print("略過", skipped, "行") # 略過 2 行
THRESHOLD = 3 # 門檻是示意值,實務上要搭配時間窗
for ip, n in fails.items():
if n >= THRESHOLD:
print("⚠ 疑似暴力破解:", ip, n, "次") # ⚠ 疑似暴力破解: 203.0.113.7 3 次
Counter.most_common() 依次數由多到少排序,次數相同時保留第一次出現的順序。實務上還要注意兩件事:門檻要搭配時間窗(例如 5 分鐘內失敗 10 次),整份日誌加總會讓慢速攻擊混在正常的忘記密碼裡;另外,同一個來源嘗試很多不同帳號、每個只試一兩次,是密碼噴灑(password spraying)的樣貌,要改成計算「每個 IP 涉及幾個帳號」才看得出來。
6. 寫太寬、寫太窄:regex 只看形狀
樣式寫得太寬,會把不是目標的東西也抓進來(誤判,false positive);寫得太窄,真正的目標會漏掉(漏判,false negative)。抓 IP 是最好的例子:\d+\.\d+\.\d+\.\d+ 會從版本號 10.2.3.4.5 裡切出一段假的 IP;加上前後的環視(lookaround)(?<![\d.]) 與 (?![\d.]) 排除了版本號,但 999.1.1.1 仍然「形狀正確」。想用 regex 把每一段限制在 0~255 不是不行,但樣式會變得很長又難以審查。比較好的分工是:regex 寫得稍寬、負責找出候選,再交給 ipaddress.ip_address() 確認,它會拒絕超過 255 的數字與 198.51.100.010 這種有前導零的寫法。
import ipaddress, re
text = "來源 203.0.113.45 與 198.51.100.7;內部 10.1.2.3;版本 10.2.3.4.5;筆誤 999.1.1.1"
WIDE = re.compile(r"\d+\.\d+\.\d+\.\d+") # 太寬:任何 數字.數字.數字.數字
BOUNDED = re.compile(r"(?<![\d.])\d{1,3}(?:\.\d{1,3}){3}(?![\d.])") # 前後不能再接數字或點
INTERNAL = [ipaddress.ip_network("10.0.0.0/8"),
ipaddress.ip_network("192.168.0.0/16")] # 自家內網段
def external_ip(s: str) -> bool:
try:
ip = ipaddress.ip_address(s) # 999.1.1.1 這種不合法的丟 ValueError
except ValueError:
return False
return not any(ip in net for net in INTERNAL)
print(WIDE.findall(text)) # ['203.0.113.45', '198.51.100.7', '10.1.2.3', '10.2.3.4', '999.1.1.1']
print(BOUNDED.findall(text)) # ['203.0.113.45', '198.51.100.7', '10.1.2.3', '999.1.1.1']
print([s for s in BOUNDED.findall(text) if external_ip(s)]) # ['203.0.113.45', '198.51.100.7']
注意這裡用自訂的內網清單,而不是 ip.is_private:ipaddress 把 RFC 5737 的文件專用網段也歸為 private,本頁範例的 IP 全都會被排除。實務上要依自家的網段規劃決定。
7. 災難性回溯(ReDoS)
Python 的 re 是回溯式(backtracking)引擎:比對失敗時,會退回上一個有其他選擇的地方重新嘗試。大部分樣式退幾步就結束,但巢狀的重複(例如 (a+)+、(\w+\s?)*)讓同一段字元有非常多種切法;遇到「差一點就符合」的輸入,引擎會把每一種切法都試過,時間隨長度呈指數成長。攻擊者若能控制被比對的文字,就能用一行字讓伺服器忙上很久,這叫 ReDoS(Regular expression Denial of Service,正規表示式阻斷服務)。
防範方式依效果排序:一、避免巢狀量詞與「重疊的選項」(例如 (\d|\d\d)+),用明確的字元類別取代 .*;二、先限制長度再比對,例如帳號欄位超過 64 字元直接拒絕;三、Python 3.11 起支援原子群組 (?>…) 與佔有量詞 *+、++,一旦比對過就不再回頭;四、處理不可信的大量文字時,可考慮線性時間的 regex 引擎(例如 RE2 系列的套件),代價是不支援回溯參照等功能。最後,把會處理外部輸入的樣式放進單元測試,包含「很長而且差一點就符合」的輸入。
8. Python re 與 JavaScript RegExp 的差異
實驗室一在瀏覽器裡執行,所以會先把 Python 寫法轉成 JavaScript。兩者大部分語法相同,但下列差異會讓同一個樣式在兩邊結果不同,從網頁工具複製樣式到 Python 時要特別留意:
| 項目 | Python re | JavaScript RegExp |
|---|---|---|
| 命名群組 | (?P<name>…);寫成 (?<name>…) 會丟 re.error | (?<name>…) |
| 引用命名群組 | (?P=name) | \k<name> |
| 字串結尾 | \Z;3.14 起也可寫 \z | 不加 m 旗標時的 $ |
$ | 字串結尾,或結尾換行字元之前 | 只有字串結尾(不加 m) |
\d、\w(str 樣式) | 任何 Unicode 數字與文字,例如全形「3」;加 re.ASCII 才只認 ASCII | \d 只認 0-9,\w 只認英數字與底線 |
| lookbehind | 必須固定長度 | 可以不固定長度 |
| 原子群組、佔有量詞 | 3.11 起支援 | 不支援 |
| 旗標寫法 | re.I、re.M、re.S、re.X | i、m、s,沒有 x |
版本方面還有兩點:Python 3.13 起,re.sub 的 count、flags 與 re.split 的 maxsplit、flags 用位置參數傳入已被標為棄用,請寫成 re.sub(p, repl, s, count=1) 這種關鍵字形式;\z 是 3.14 才加入的寫法,要相容舊版就繼續用 \Z。
9. 寫一個日誌樣式的判斷步驟
- 先收集 5~10 行真實格式的樣本,包含例外(invalid user、IPv6、多一個空白),不要只看一行就寫。
- 決定比對方式:格式從行首固定就用
match並加^;欄位出現位置不定用search;驗證整個值用fullmatch。 - 要的欄位用命名群組,只是為了套量詞的括號用
(?:…)。 - 用字元類別描述欄位(
\S+、[^\]]+、\d{3}),少用.*;一定要用時考慮非貪婪。 - 抓到的值再用專門工具驗證:IP 用
ipaddress、時間用datetime.fromisoformat。 - 統計格式不符的行數,並把樣本與邊界案例寫成測試。
容易寫錯或考錯的地方
re.match(r"\d+", "IP 203.0.113.7") 是 None;search 才會往後找。^…$ 也要小心,$ 會放過結尾的換行字元。(?:…)。192.0.2.1 裡的 . 代表任意字元,會抓到 192x0y2z1,還會從 192.0.2.10 裡切出一段。要寫 192\.0\.2\.1,或用 re.escape() 處理要照字面比對的字串。re.MULTILINE,或改成逐行比對。"\b" 是退格字元,比對會安靜地失敗。\d{1,3} 接受 999;合法性交給 ipaddress。✅ 自我檢測
以下 6 題都是原創的程式閱讀題,輸出都用 Python 3 實際跑過。選完會立即顯示對錯與解析,全部作答後會出現總分。目前得分:0 / 6