🗺️ 程式互動式地圖
資安線・防禦自動化

用正規表示式讀日誌:從一堆文字裡抓出可疑 IP

伺服器日誌是一行一行的文字,欄位沒有明確的分隔方式。這一頁說明怎麼用 Python 的 re 模組把每一行拆成時間、IP、帳號、結果,接著用 Counter 數出誰失敗最多次,以及樣式寫太寬、太窄或寫出災難性回溯時會發生什麼事。

search/match/fullmatch/findall 對照 命名群組與 Counter 管線 太寬、太窄與 ReDoS

💡 先搞懂問題

「晴空咖啡」的線上訂位系統架在一台 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 這種不合法的字串也抓了進來。

一行文字 → 一列欄位(日誌與 IP 皆為虛構示意) 2026-10-07T09:00:03 sshd[311]: Failed password for invalid user admin from 203.0.113.7 port 50124 ssh2 時間 ts 帳號 user IP port invalid user:有時才出現 結果 result(第一行) 樣式(Python 寫法) ^(?P<ts>\S+) sshd\[\d+\]: (?P<result>Failed|Accepted) password for (?:invalid user )?(?P<user>\S+) from (?P<ip>\S+) port (?P<port>\d+) ts result user ip port …T09:00:03 Failed admin 203.0.113.7 50124
上方黑框是原始的一行,底線顏色對應樣式裡的五個命名群組;灰色虛線的「invalid user」用 (?:invalid user )? 表示「可有可無、而且不取出來」。下方是比對成功後用 m.groupdict() 拿到的一列資料,之後就能像表格一樣篩選與計數。

生活比喻:倉管照著規格單找貨

想像一間物流倉庫的倉管,主管給他的不是「去拿編號 A1023 的那一箱」,而是一張規格單:「找出所有標籤是一個大寫字母、接著四位數字的箱子,把數字的部分抄下來。」倉管拿著規格單,沿著貨架一箱一箱比對標籤。比對的方式也有幾種:主管可能只要他看「這排第一箱是不是符合」,也可能要他「整排找,找到第一個就回報」,或是「把整排符合的全部搬出來」。如果是驗收新進貨,則要求「整張標籤從頭到尾都要符合規格」,標籤後面多貼了一張貼紙也不行。

倉庫(比喻) re 模組的對應 規格單:字母+四位數字 pattern:[A-Z]\d{4} 只看這排第一箱 re.match(只比開頭) 整排找,第一個就回報 re.search 驗收:整張標籤完全符合 re.fullmatch 符合的全部搬出來 re.findall/finditer 只抄下數字的部分 群組 ( ) 與 group()
左欄是倉管的工作方式,右欄是 re 模組裡對應的函式。重點在第二到第四列:同一張規格單,用不同的比對方式,結果會不一樣,這正是 match、search、fullmatch 最常被考的差別。
回到程式:剛才的規格單,對應的就是 pattern(例如 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 能懂的寫法再執行;兩邊不支援的語法會在下方提示。

整段命中群組灰字:Python 會印出的結果
畫面說明:載入中。

🎮 互動實驗室二:從日誌到告警的逐步管線

這裡有 14 行虛構的 SSH 登入紀錄。按「下一步」會依序執行五個步驟:用命名群組抽欄位、用 ipaddress 驗證 IP、篩出 Failed、用 Counter 計數、套用門檻。左邊是這一步的 Python 程式,右邊是執行後的中間結果。走到第 4、5 步時,可以切換要依「IP」還是「帳號」計數,並拖動門檻滑桿,看哪些來源會被標紅,以及有沒有「多次失敗後登入成功」的紀錄。所有數字都和 Python 實跑結果一致。

畫面說明:尚未開始。

🎮 互動實驗室三:這個 pattern 會抓到什麼

每張卡是一小段原創的 Python,請預測它印出什麼。選項裡刻意放了最常見的誤解,例如以為 \d+ 會抓整個 IP、以為 . 只代表句點、以為 findall 一定回傳整段命中。答完會說明原因;所有正解都用 Python 3 實際執行過。

得分 0 / 0
連續答對 0
共 12 張
畫面說明:先看函式是哪一個,再看樣式裡有沒有括號。

📘 原理補完

實驗室裡的直覺可以整理成三件事:先選對比對函式,再用群組把欄位取出來,最後別相信 regex 抓到的就一定是對的。下面依序接回 re 模組的正式用語,最後整理 Python 與 JavaScript 的差異,以及最容易寫錯的地方。

1. 五個比對函式:從哪裡開始找、找幾個、回傳什麼

re 模組的函式都接受「樣式、字串、旗標」,差別在比對的位置與回傳型別。search、match、fullmatch 回傳一個 Match 物件(或 None),Match 物件記錄了命中的位置 span()、內容 group() 與各群組;findall 直接回傳串列;finditer 回傳一個迭代器,逐一產生 Match 物件,資料量大時比較省記憶體,也能拿到每筆命中的位置。

樣式 r"\d+\.\d+\.\d+\.\d+" 套在同一個字串上(Python 實跑) IP 203.0.113.7 and 198.51.100.4 match None開頭是「IP」兩個字,不是數字 search Match span=(3, 14)往後找,第一個就停 fullmatch None整串不是一個 IP findall ['203.0.113.7', '198.51.100.4']全部,回傳字串串列 finditer (3, 14) (19, 31)逐一產生 Match,可取位置
同一個樣式、同一個字串,五個函式給出五種答案。上方黃底是兩段符合的位置;match 只看開頭、fullmatch 要求整串符合,所以都是 None。這張圖的結果都是在 Python 實際執行得到的。
函式比對位置回傳沒找到時典型用途
re.search從頭往後掃,第一個符合就停MatchNone這一行有沒有某個欄位
re.match只從字串開頭(位置 0)比對MatchNone格式固定、從行首開始的日誌
re.fullmatch整個字串都要符合MatchNone驗證使用者輸入、欄位格式
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() 依名稱取值。只想把幾個字元綁在一起套用量詞、不想取出來時,用非擷取群組 (?:…)。

for (?:invalid user )? (?P<user>\S+) from (?P<ip>\S+) for invalid user admin from 203.0.113.7 比對但不取出 m.groupdict() → {'user': 'admin', 'ip': '203.0.113.7'} findall 的回傳形狀看「擷取群組有幾個」 0 個群組['a@b', 'c@d']整段命中的字串 1 個群組['a', 'c']只有那一組的內容 2 個以上[('a','b'), ('c','d')]每筆是一個 tuple
上半:黃色與藍色的命名群組各自對到字串裡的一段,灰色的 (?:…)? 參與比對但不出現在結果裡。下半:同樣想抓「帳號@主機」,樣式裡括號的數量決定 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。更穩的寫法是直接描述「不是引號的字元」:"([^"]*)",意圖清楚,回溯也少。

同一個字串,三種寫法(Python 實跑) GET "/a" 200 "curl" GET "/a" 200 "curl" GET "/a" 200 "curl" r'"(.*)"' 貪婪 group(1) = '/a" 200 "curl',吃到最後一個引號 r'"(.*?)"' 非貪婪 group(1) = '/a',遇到下一個引號就停 r'"([^"]*)"' 字元類別 同樣是 '/a',而且不必來回嘗試
色條標出群組實際涵蓋的範圍。橘色的貪婪寫法跨過了中間的 200,把兩段引號內容黏在一起;藍色與綠色都只取第一段。解析網站日誌的請求列時,橘色是最常見的錯誤。

4. 原始字串與 re.compile

樣式在交給 re 之前,要先經過一層 Python 字串的處理。"\b" 在一般字串裡是退格字元(長度 1),re 看到的根本不是「單字邊界」;寫成原始字串(raw string)r"\b",反斜線原封不動,re 才看得到 \b。"\d" 雖然剛好能用,但它是無效的跳脫序列,Python 3.12 起會發出 SyntaxWarning。結論很單純:樣式一律寫成 r"…"。

你寫的 第 1 層:Python 字串 第 2 層:re 看到的 "\bFailed\b" ␈Failed␈ 找退格字元 → [] r"\bFailed\b" \bFailed\b 單字邊界 → ['Failed'] ␈ 代表退格字元(\x08)。結果是對字串 "Failed login" 執行 re.findall 的實跑輸出。 原始字串讓反斜線跳過第 1 層,原封不動交給 re。
上排的反斜線在第一層就被 Python 吃掉,re 收到的是兩個看不見的退格字元,所以什麼都找不到,也不會報錯;下排加了 r 前綴,re 收到的才是你寫的樣式。這類錯誤不會丟例外,只會安靜地漏抓,最難發現。

re.compile(pattern, flags) 把樣式先編譯成 Pattern 物件,之後呼叫 pat.search(line)。官方文件說明,模組層級的函式(例如 re.search)也會快取最近用過的樣式,所以少量樣式不編譯也不會明顯變慢;預先編譯的主要好處是在同一支程式裡重複使用時更有效率,而且把樣式取個名字(例如 SSH_FAIL),程式比較好讀,也方便集中測試。

5. 完整管線:解析、驗證、計數、門檻

原始日誌14 行 ① 解析PAT.match ② 驗證ipaddress ③ 篩選Failed ④ 計數Counter ⑤ 門檻 n ≥ 3 203.0.113.7、192.0.2.99 格式不符 1 行另外計數 剔除 999.1.1.1 剩 10 筆 數字對應實驗室二的 14 行示意日誌;門檻 3 也是示意值,實務上要搭配時間窗與基準線。
由左到右是實驗室二的五個步驟。灰色虛線是「被拿掉的東西」:格式不符的行與不合法的 IP 不應直接丟掉不管,數量突然變多,可能代表日誌格式改了,或有人在灌假的紀錄。

把管線寫成程式就是下面這樣。重點在於每一步都只做一件事,而且「失敗」的資料有被數到,而不是默默消失:

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 這種有前導零的寫法。

候選越來越少(ex2 的實跑結果) 10.2.3.4(版本號片段) 999.1.1.1 10.1.2.3(內網) 203.0.113.45 198.51.100.7 外圈:\d+\.\d+\.\d+\.\d+太寬,5 筆 中圈:加上前後邊界版本號消失,4 筆 內圈:ipaddress+排除內網剩真正的外部 IP,2 筆 太窄的反例:只寫 203\.0\.113\.\d+,換個來源就全部漏掉
由外往內,每一層都少掉一種誤判。外圈與中圈是 regex 能做到的事,內圈靠的是懂得 IP 規則的 ipaddress 模組與自家網段清單。底部紅字提醒另一個方向:把樣式寫死成特定來源,等於只能抓到已知的那一個。
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,正規表示式阻斷服務)。

r"(a+)+$" 比對「n 個 a + !」的耗時(本機實測示意) n=160.003 s n=180.010 s n=200.042 s n=220.159 s 對照:r"a+$" 5000 個 a + ! 約 0.0002 s 沒有巢狀重複 每多 2 個字元,時間約變 4 倍;照此推算,再多 20 個字元約要兩天。實際秒數依電腦而定。
同樣是「一串 a 後面接一個不符合的字元」,左邊的巢狀寫法時間呈指數成長,右邊即使長 200 多倍也幾乎瞬間結束。差別不在輸入多長,而在樣式給了引擎多少種「重新切切看」的選擇。

防範方式依效果排序:一、避免巢狀量詞與「重疊的選項」(例如 (\d|\d\d)+),用明確的字元類別取代 .*;二、先限制長度再比對,例如帳號欄位超過 64 字元直接拒絕;三、Python 3.11 起支援原子群組 (?>…) 與佔有量詞 *+、++,一旦比對過就不再回頭;四、處理不可信的大量文字時,可考慮線性時間的 regex 引擎(例如 RE2 系列的套件),代價是不支援回溯參照等功能。最後,把會處理外部輸入的樣式放進單元測試,包含「很長而且差一點就符合」的輸入。

8. Python re 與 JavaScript RegExp 的差異

實驗室一在瀏覽器裡執行,所以會先把 Python 寫法轉成 JavaScript。兩者大部分語法相同,但下列差異會讓同一個樣式在兩邊結果不同,從網頁工具複製樣式到 Python 時要特別留意:

項目Python reJavaScript 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.Xi、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. 寫一個日誌樣式的判斷步驟

  1. 先收集 5~10 行真實格式的樣本,包含例外(invalid user、IPv6、多一個空白),不要只看一行就寫。
  2. 決定比對方式:格式從行首固定就用 match 並加 ^;欄位出現位置不定用 search;驗證整個值用 fullmatch。
  3. 要的欄位用命名群組,只是為了套量詞的括號用 (?:…)。
  4. 用字元類別描述欄位(\S+、[^\]]+、\d{3}),少用 .*;一定要用時考慮非貪婪。
  5. 抓到的值再用專門工具驗證:IP 用 ipaddress、時間用 datetime.fromisoformat。
  6. 統計格式不符的行數,並把樣本與邊界案例寫成測試。

容易寫錯或考錯的地方

match 與 search 對調:match 只比字串開頭,re.match(r"\d+", "IP 203.0.113.7") 是 None;search 才會往後找。
驗證輸入用 match 或 search:只要開頭或其中一段符合就成立,後面夾帶多餘字元也會過關;驗證要用 fullmatch。用 ^…$ 也要小心,$ 會放過結尾的換行字元。
findall 有群組:回傳的是群組內容,不是整段命中;兩個以上群組回傳 tuple。量詞用的括號改成 (?:…)。
點號沒有跳脫:192.0.2.1 裡的 . 代表任意字元,會抓到 192x0y2z1,還會從 192.0.2.10 裡切出一段。要寫 192\.0\.2\.1,或用 re.escape() 處理要照字面比對的字串。
^ 和 $ 沒有加 re.M:整段多行文字預設只有一個開頭;要每一行都能當開頭,要加 re.MULTILINE,或改成逐行比對。
忘了原始字串:"\b" 是退格字元,比對會安靜地失敗。
沒找到就直接 .group():結果是 None 時會丟 AttributeError,先判斷再取值。
以為 regex 能驗證 IP 是否合法:\d{1,3} 接受 999;合法性交給 ipaddress。

✅ 自我檢測

以下 6 題都是原創的程式閱讀題,輸出都用 Python 3 實際跑過。選完會立即顯示對錯與解析,全部作答後會出現總分。目前得分:0 / 6