WAF 不會呼叫任何 API、不會送 webhook。它與整套架構的接點只有一個檔案:audit.log。本篇說明這個檔案什麼時候會多一行、一行裡有什麼、Vector 從裡面取哪些欄位變成平台事件、以及它每小時被輪替一次會有什麼後果。
audit engine 設成 RelevantOnly,加上 SecAuditLogRelevantStatus "^(?:5|4(?!04))",一筆交易只要符合下面任一條就會在 phase 5 寫入:
| 條件 | 寫嗎 | 例子 |
|---|---|---|
| 至少一條規則命中(不論有沒有到門檻) | 寫 | 用 IP 直打命中 920350、回 200 的正常頁面;被 403 的 SQLi |
| 回應碼 5xx | 寫 | 後端掛掉時的 502;後端自己的 500 |
| 回應碼 4xx,但不是 404 | 寫 | 後端回的 405、401、403、413 |
| 回應碼 404,且沒有規則命中 | 不寫 | 亂猜路徑但內容無害的請求。走 Cloudflare 時這類請求最多,刻意排除,否則 log 會被掃描器灌爆 |
| 2xx/3xx,且沒有規則命中 | 不寫 | 所有正常流量 |
| nginx 在解析階段就拒絕的請求 | 不寫 | TRACE 405、畸形 HTTP 400(第 2 篇第三節) |
推論:正常流量在 audit.log 裡零紀錄。這份檔案天生就是告警清單,不是存取紀錄,所以 Vector 讀它時不需要「先篩掉正常請求」這一步。nginx 另有 access log(logging.conf.template 定義的 main 格式,多記一欄 real_ip 換算前的來源),但沒有任何程式在讀它,只用來在容器 log 裡除錯。
ModSecurity 的 audit log 以字母標示段落,SecAuditLogParts 決定記哪些。本安裝包記 A、B、C、F、H、J、Z,JSON 格式時它們會合併成一個 transaction 物件:
| 段 | 內容 | JSON 裡的位置 | 為什麼要/不要 |
|---|---|---|---|
| A | 交易摘要:時間、unique_id、client_ip、client_port、host_ip、host_port | transaction.* 頂層 | 必記。client_ip 是歸因的依據 |
| B | 請求標頭 | transaction.request.headers、method、uri、http_version | 必記。CF-Connecting-IP、XFF、User-Agent 都在這裡 |
| C | 請求本文 | transaction.request.body | 記,作為 payload 證據。POST 的內容會原樣落地,含表單密碼欄位,這是 audit.log 要限制讀取權限的原因 |
| E | 回應本文 | — | 不記。回應本文可能很大且含敏感資料,外洩偵測在 phase 4 做完就夠了 |
| F | 回應標頭 | transaction.response.headers、http_code | 記。看得出回應是後端回的(有後端自己的 CSP、Allow 標頭)還是 nginx 回的 403 |
| H | 審計摘要:命中的規則清單、引擎狀態、producer 版本 | transaction.messages[]、transaction.producer | 必記。Vector 的 rule_id 與 severity 從這裡來 |
| J | multipart 上傳的檔案資訊 | (有檔案上傳時) | 記,檔名與大小,不含檔案內容 |
| Z | 結束標記 | — | 格式要求,必有 |
下面是 2026-09-26 實測那筆 SQLi 探測的 audit.log,去識別化:直連來源改成 cloudflared 的 IP、Host 改成示範網域、補上走 Cloudflare 時會有的 CF-Connecting-IP,其餘欄位原樣。實際檔案是一行,這裡分段排版並加註解。
{"transaction": {
"client_ip": "172.18.0.250", // 直連 WAF 的對象。經 Cloudflare 時是 cloudflared 容器,
// 不是攻擊者;Vector 據此決定要不要信下面的標頭
"time_stamp": "Sat Sep 26 15:33:16 2026", // 容器本地時間(UTC),非 ISO 格式;Vector 不用它
"server_id": "407b8483…", "client_port": 33586,
"host_ip": "172.18.0.7", "host_port": 8080, // WAF 容器自己的位址與埠
"unique_id": "179043679631.558543", // 這筆交易的 ID,Vector 放進 evidence.raw_log_ref.locator
"is_interrupted": true, // 真的被擋了
"request": {
"method": "GET", "http_version": "1.1",
"hostname": "app.example.com",
"uri": "/beakplatform/?id=1%27%20OR%201=1--", // 未解碼的原始 URI
"body": "",
"headers": {
"Host": "app.example.com",
"User-Agent": "curl/8.5.0",
"Accept": "*/*",
"CF-Connecting-IP": "203.0.113.42", // Cloudflare 寫的真實來源
"X-Forwarded-For": "203.0.113.42",
"Cf-Ipcountry": "TW"
}
},
"response": {
"http_code": 403,
"headers": { "Content-Type": "text/html", "Server": "nginx", … } // nginx 的 403,沒有後端標頭
},
"producer": {
"modsecurity": "ModSecurity v3.0.16 (Linux)",
"connector": "ModSecurity-nginx v1.0.4",
"secrules_engine": "Enabled", // DetectionOnly 時這裡會是 DetectionOnly
"components": ["OWASP_CRS/4.25.1"]
},
"messages": [ // 命中的規則,依命中順序
{ "message": "SQL Injection Attack Detected via libinjection",
"details": {
"ruleId": "942100", "severity": "2", // 等級 2 = CRITICAL,+5 分
"file": "/etc/modsecurity.d/owasp-crs/rules/REQUEST-942-APPLICATION-ATTACK-SQLI.conf",
"lineNumber": "…",
"data": "Matched Data: s&1c found within ARGS:id: 1' OR 1=1--", // libinjection 的指紋與命中的參數
"match": "Matched \"Operator `DetectSQLi' with parameter `' against variable `ARGS:id' …",
"tags": ["application-multi","language-multi","platform-multi","attack-sqli",
"paranoia-level/1","OWASP_CRS","OWASP_CRS/SQL-INJECTION","capec/1000/152/248/66"],
"ver": "OWASP_CRS/4.25.1", "maturity": "0", "accuracy": "0" } },
{ "message": "Inbound Anomaly Score Exceeded (Total Score: 5)",
"details": {
"ruleId": "949110", "severity": "0", // 等級 0 = EMERGENCY;這條是「評分後決定擋」
"file": "…/REQUEST-949-BLOCKING-EVALUATION.conf",
"data": "", "tags": ["anomaly-evaluation", "OWASP_CRS", …] } }
]
}}
| 看 log 時常問的問題 | 看哪裡 |
|---|---|
| 攻擊者是誰 | 不是 client_ip。看 request.headers 的 CF-Connecting-IP,但只有 client_ip 是 cloudflared 時才能信(第 2 篇第五節) |
| 有沒有被擋 | is_interrupted,或 response.http_code 是 403 且 messages 裡有 949110/959100 |
| 被哪條規則抓到、抓到什麼 | messages[].details.ruleId 與 data。data 會標出命中的變數名(ARGS:id),寫排除規則時就靠它 |
| 是不是升 PL 才出現的 | tags 裡的 paranoia-level/N |
| 是 WAF 擋的還是後端回的錯 | response.headers:有後端自己的標頭(CSP、Allow、Set-Cookie)就是後端回的 |
| payload 全文 | GET 在 request.uri,POST 在 request.body。都是原始未解碼內容 |
Vector 用 file source 尾隨 waf/log/audit*.log(主站與歡迎頁兩個檔),每讀到一行就 parse_json,經 modsec_with_alerts_only 過濾(有 messages 或 http_code ≥ 400 才留,實際上 RelevantOnly 已經保證了這件事),再由 ocsf_from_modsec 轉成平台契約的 OCSF 事件。對照表:
| 平台事件欄位 | 取自 audit.log | 規則與備註 |
|---|---|---|
| source_system | 固定 coraza | 這個名稱是早期架構用 Coraza 引擎時定的,後來換成 ModSecurity 但契約沒改,平台的 API Key scope、路由規則、ClickHouse 裡看到 coraza 都是指這條 WAF 線。改名等於改動平台端所有依賴,刻意保留 |
| event_class | 固定 web_activity | Suricata 是 network_activity |
| finding.rule_id | messages[0].details.ruleId | 只取第一條命中規則。SQLi 例子裡是 942100 而不是 949110。平台併案用「同 IP + 同 rule_id」,所以用 IP 直打時第一條永遠是 920350,所有內網驗證會併成同一案。messages 為空(純 5xx/4xx)時 rule_id 是空字串,平台不併案,一事件一張單 |
| finding.title | messages[0].message,截 200 字 | 同上,取第一條的訊息 |
| finding.rule_set | 固定 OWASP CRS | |
| severity_id | 整筆 messages 裡最嚴重的 severity,對映後 | 見下表。注意 949110 的等級是 0,所以只要被擋,平台上一律是 4(Critical),不管實際命中的是 SQLi 還是兩條 WARNING 湊到 6 分。命中但沒被擋的(例如單一 920350)依那條規則的等級對映 |
| actor.ip | client_ip 在可信清單內 → CF-Connecting-IP,否則 XFF 第一段,否則 client_ip | 可信清單 TRUSTED_INGRESS_LIST 預設只有 cloudflared 的 172.18.0.250。內網直打時 client_ip 是來源機器自己的內網 IP(2026-09-26 實測:從管理機打,log 記的就是管理機的 IP,不是 docker 網關),不在清單內,事件的 actor 就是那台機器;想在測試時帶假標頭要把那台機器的 IP 加進 TRUSTED_INGRESS_EXTRA |
| actor.xff | X-Forwarded-For 原文 | 證據欄位,平台存進案件的 actor_xff,不參與判定 |
| actor.country | Cf-Ipcountry,僅可信來源時 | 不可信來源一律空字串 |
| actor.user_agent | request.headers.User-Agent | |
| target.host / target.url | request.headers.Host / request.uri | Host 是內網 IP 開頭(192.168./10./172./127.)時,配上內網 actor 會被 intake_filter 整筆丟掉(內網對內網),所以內網驗證的 WAF 事件進 ClickHouse 但不進平台 |
| target.service | 依檔名:audit-welcome.log → waf-welcome,否則 waf-nginx | 平台處置中心可據此分辨刺探的是主站還是歡迎頁 |
| occurred_at | now()(Vector 讀到的時間) | 不用 time_stamp,因為它不是 ISO 格式且不帶時區。差距是秒級,可忽略 |
| correlation_id | uuid_v4() | 每讀一行產生一個新的。這代表 Vector 重讀同一行會變成新事件;file source 有 checkpoint 所以正常不會重讀,但手動 cat audit.log.bak >> audit.log 這種操作會製造重複案件 |
| evidence.raw_log_ref | {type: modsec, locator: unique_id} | 拿 unique_id 可以回 ClickHouse 的 raw 欄位或 audit.log 找整筆 |
| evidence.snippet | request.method | 目前只放方法,payload 本文要看 raw |
嚴重度對映(ModSecurity 沿用 syslog 等級,數字越小越嚴重;平台契約是 1 Low 到 4 Critical):
| ModSecurity severity | CRS 名稱 | 平台 severity_id | 什麼情況 |
|---|---|---|---|
| 0、1、2 | EMERGENCY/ALERT/CRITICAL | 4 Critical | 所有被擋的交易(949110/959100 是 0);命中任何 CRITICAL 規則 |
| 3 | ERROR | 3 High | 只命中 ERROR 級且未達門檻(少見,ERROR 4 分離門檻只差 1) |
| 4 | WARNING | 2 Medium | 只命中 WARNING 級:920350、單一 913 掃描器指紋等,回 200 放行的那些 |
| 5、6、7 | NOTICE/INFO/DEBUG | 1 Low | 幾乎不會出現 |
| messages 為空 | — | 1 Low(預設 7 → 1) | 純 5xx/4xx 交易:後端 502、405 之類。這就是原系列說「後端掛掉建的案件嚴重度到不了自動封鎖門檻」的原因 |
轉成 OCSF 之後分兩路:ClickHouse 拿全量,od-bridge 那一路要再過三道閘門。針對 WAF 事件:
| 閘門 | 對 WAF 事件的規則 | 後果 |
|---|---|---|
| intake_filter | actor 是內網 IP 且 target.host 是內網 IP → 丟。不看嚴重度(Suricata 才丟 Low) | 內網驗證的事件不進平台;經 Cloudflare 的一律進,包括 Medium 的 920350 類 |
| 分來源封頂 | 同「coraza|rule_id|actor_ip」每 300 秒最多 5 筆 | 掃描器一分鐘打 300 個 SQLi,平台只收到 5 筆,ClickHouse 有 300 筆。比 Suricata 的每小時 1 筆寬很多,理由是 WAF 事件是已擋下的攻擊證據,讓 SOC 看得到強度 |
| 全域封頂 | 不分來源每 60 秒 8 筆 | 分散式掃描時保護平台主機。超額的丟棄,不排隊 |
| 平台端併案 | 同 actor_ip + 同 rule_id 在 60 分鐘內併入同一案 | SOC 看到「一個 IP、一條規則、事件數 N」,不是 N 張單 |
封頂是丟棄,不是聚合。「平台上這個 IP 有 5 筆事件」的意思是至少 5 筆,真實數字要查 ClickHouse 的 events 表(source_system='coraza')。做攻擊面統計時不要看案件數。
主機端 cron(/etc/cron.hourly/secstack-rotate-logs,由 install.sh 產生)每小時對 audit.log 與 audit-welcome.log 做 copy + truncate:先複製成 audit.log.YYYYMMDD-HH,再把原檔清空。五分鐘後壓成 gz,超過 360 分鐘的刪除。用 copy + truncate 而不是 rename 是因為 nginx 持有的 fd 要維持有效,且不依賴 logrotate 的降權機制。
| 後果 | 說明 |
|---|---|
| audit.log 本身只是六小時的緩衝 | 要查歷史一律去 ClickHouse(90 天)。audit.log 的角色是「給 Vector 讀」,不是「給人查」 |
| truncate 當下有一個遺失窗口 | Vector 是尾隨讀取,truncate 發生在它讀走之前的那幾行會消失,每小時一次、約一秒。這是 ClickHouse 也不是 100% 全量的原因之一 |
| Vector 不會重讀輪替檔 | source 的 exclude 排除了 audit*.log.* 與 *.gz,所以輪替出來的檔案不會被當成新檔再讀一次 |
| 檔案擁有者是容器內的 nginx(uid 101) | 主機上看到的擁有者名稱會是 uid 101 對應到的任何本機帳號,這是正常的。cron 以 root 執行所以不受影響;手動看檔案用 sudo |
| 在 ClickHouse 或平台看到 | 實際是什麼 |
|---|---|
| rule 920350 Host header is a numeric IP address,Medium,actor 是某台內網管理機 | 有人用 IP 直打 8080 做驗證。經 Cloudflare 的請求 Host 是網域名,不會有這條。內網對內網會被 intake_filter 丟掉,所以通常只在 ClickHouse 看到 |
| rule_id 空、severity Low、很多不同 actor 對同一 host | 後端掛了,WAF 對每個正常訪客回 502。這種案件不該封 IP(原系列第 4 篇情境 C) |
| target.service = waf-welcome,各種 913/930 規則 | 掃描器在刺探根路徑與常見漏洞路徑。歡迎頁存在的目的就是讓這些變成事件 |
| rule 911100 Method is not allowed by policy | 覆寫檔的方法白名單沒生效(容器沒重建)或客戶端用了 TRACE 以外的非標準方法。前者會讓所有 REST API 的 PUT/DELETE/PATCH 全部 403,第 4 篇第二節 |