Files
labs/Notebooks-CSAI/Lab9B_Real_Web_Logs.ipynb

36 KiB

Module 9B — Phát hiện request độc hại trên log web THẬT của máy chủ (và vì sao nhãn thật khó)

Bài thực hành Module 9, nối tiếp Lab 9 (URL). Chạy lần lượt, kernel csai. Chỗ # ✏️ SỬA: đổi rồi chạy lại.

Ở Lab 9, Random Forest đạt 99.9% trên URL tự sinh. Bài này làm lại cùng ý tưởng nhưng trên log thật: khoảng một tháng request gửi tới web của máy chủ DietPi (số ngày, số request đếm ở Phần 1). Và kết quả sẽ khác hẳn — không phải vì mô hình kém, mà vì nhãn thật có nhiễu. Đó chính là bài học quan trọng nhất của cả khoá: ngoài đời, gán nhãn cho đúng mới là phần khó.

🔒 Riêng tư: dữ liệu đã được ẩn danh khi xuất ra: mỗi IP → mã HMAC (IP-xxxx), bỏ query (chỉ giữ độ dài), đường dẫn riêng tư của người dùng đã che. File chỉ nằm trên máy lớp, không mang ra ngoài.

🎯 Mục tiêu của bài

Học xong bài này, bạn:

  1. Hiểu nhãn thật lấy từ đâu và vì sao nó có nhiễu (Phần 0, 1).
  2. Biết những gì phải ẩn danh trước khi đưa log thật vào lớp (Phần 2).
  3. Rút đặc trưng từ chính request, không dùng IP (Phần 3).
  4. Thấy cách chia dữ liệu (theo IP hay ngẫu nhiên) làm điểm đổi thế nào (Phần 4).
  5. Đọc lỗi để phân biệt lỗi của mô hình và lỗi của nhãn (Phần 5).
  6. Đề xuất cách làm nhãn tốt hơn (Phần 6).

🗺️ Bài này: bài toán gì, mô hình nào, vì sao?

  • Bài toán: mỗi request web → bình thường hay độc hại: phân loại 2 lớp trên log thật.
  • Dữ liệu: log web thật đã ẩn danh; nhãn mượn từ fail2ban (theo IP, không theo từng request) → có nhiễu.
  • Vì vậy chọn: cùng Random Forest như Lab 9 để thấy khác biệt nằm ở dữ liệu và nhãn, không phải ở mô hình. Bảng ① ② ở mục Trước khi bắt đầu ngay dưới đây.
  • Xử lý gì trước: ẩn danh; không dùng IP làm đặc trưng; chia theo IP để một IP không nằm ở cả hai bên.
  • Thước đo: precision / recall khi chia theo IP, rồi đọc từng lỗi xem lỗi do mô hình hay do nhãn.

Pipeline — cách làm mọi bài toán AI trong khoá, nhớ thuộc lòng 7 bước. Mỗi phần ghi 📍 Bước ở đầu:

Bước Câu hỏi phải trả lời Phần
1. Thu thập dữ liệu dữ liệu gì, từ đâu ra, nhãn lấy từ đâu? Phần 0 (nhãn), Phần 1 (5 câu hỏi, dữ liệu)
2. Làm sạch dữ liệu đã ẩn danh / làm sạch gì khi xuất; còn gì phải kiểm tra? Phần 2
3. Chọn mô hình phù hợp với bài toán bài toán gì; mô hình nào (như Lab 9)? Phần 0, 4
4. Coding rút đặc trưng, huấn luyện Random Forest Phần 3, 4
5. Lưu ý khi coding lỗi nào làm điểm đẹp ảo mà không báo lỗi? Phần 3 (dùng IP = học thuộc IP xấu), Phần 4 (chia ngẫu nhiên thay vì theo IP)
6. Đánh giá các mô hình điểm thật khi chia theo IP; lỗi do mô hình hay do nhãn? Phần 4, 5
7. Quy tắc bàn tay phải ✋** giải pháp, mang gì theo khi đi làm Phần 6

📌 Tra cứu nhanh: Chọn mô hình nào? Khi nào dùng gì, đánh đổi gì — mục Trước khi bắt đầu, ngay dưới đây.

Trước khi bắt đầu — Chọn mô hình nào? Khi nào dùng gì, đánh đổi gì

(Đọc thêm — để tra lại khi cần. Lần đầu có thể đọc lướt, chạy xong bài rồi quay lại.)

Không có mô hình "tốt nhất" cho mọi bài toán (No Free Lunch). Chọn mô hình = trả lời lần lượt 4 câu hỏi:

① Có đáp án (nhãn) không?

  • Có nhãn từ quá khứ (email đã biết sạch / lừa đảo, file đã biết là mã độc) → học có giám sát: Naïve Bayes, Logistic Regression, cây quyết định, Random Forest, XGBoost, SVM.
  • Không có nhãn, hoặc phải tìm cái chưa từng thấy (zero-day) → học không giám sát: Isolation Forest tìm điểm lạ, K-Means gom nhóm hành vi (ví dụ UBA: tách bot tự động khỏi thao tác của người mà không cần ai gắn nhãn). ⚠️ Mô hình không giám sát chỉ nói "khác đám đông", không nói "xấu". Trên honeypot, đám đông chính là kẻ tấn công (Lab 3, 5.0).
  • Có rất ít nhãn → bắt đầu bằng luật + không giám sát, rồi gom nhãn dần từ các cảnh báo đã được người phân tích xác nhận.

② Dữ liệu dạng gì?

Dạng dữ liệu Thường dùng Trong khoá
Bảng số (mỗi đối tượng = một hàng số) mô hình cây: cây quyết định, Random Forest, XGBoost · Logistic Regression làm mốc so sánh Lab 3 (mỗi IP 7 số), M5, Lab 7 (hồ sơ hành vi), M8–M10
Văn bản (email, tin nhắn, log dạng chữ) Naïve Bayes / Logistic Regression trên số đếm từ (TF-IDF) · mô hình ngôn ngữ (BERT, LLM) khi cần hiểu ngữ cảnh M1–M2 (Naïve Bayes), Lab 4 (Perceptron, SVM, Logistic Regression, Naive Bayes, Voting), Lab 10
Chuỗi theo thời gian (số kết nối mỗi giờ, CPU mỗi phút, chuỗi lời gọi API của một chương trình) phân rã chuỗi thời gian (xu hướng + nhịp ngày + phần dư) · HMM, LSTM khi cần học thứ tự sự kiện M4.2, Lab 6 Phần 3
Tệp nhị phân (mã độc) trích đặc trưng (kích thước, entropy…) rồi dùng mô hình cây · đổi file thành ảnh + CNN là hướng nghiên cứu, cần nhiều dữ liệu và GPU M5

③ Sợ bỏ lọt hay sợ báo nhầm?

  • Sợ bỏ lọt (mã độc, xâm nhập, lỗ hổng khi pentest) → ưu tiên recall. Sợ báo nhầm (chặn email khách hàng, khoá tài khoản nhân viên) → ưu tiên precision.
  • ⚠️ Hiểu lầm hay gặp: "muốn precision cao thì chọn Naïve Bayes / SVM". Thực ra gần như mọi mô hình đều ra một điểm / xác suất, và ngưỡng cắt điểm đó mới quyết định đánh đổi: hạ ngưỡng → bắt thêm nhưng báo nhầm thêm (Lab 3 9E.3; Lab 4 9.3). Chọn mô hình tốt rồi chọn ngưỡng theo sức đội SOC.
  • Xác suất của Naïve Bayes thường quá tự tin (sát 0 hoặc 1); SVM mặc định không ra xác suất → muốn đặt ngưỡng theo xác suất phải hiệu chỉnh (calibration) trước.
  • Tìm bất thường / gom nhóm: không có đáp án để tính precision, recall → dùng điểm bất thường, độ tách nhóm (Silhouette), và quan trọng nhất là người phân tích xem lại top N (Lab 3, 5.6).

④ Ràng buộc khi chạy thật

  • Giải thích được: cần báo cáo kiểm toán / ban giám đốc → hộp trắng (luật, cây quyết định nông, Logistic Regression, Naïve Bayes: đọc được nhánh IF/ELSE hoặc trọng số từ). Cần bắt kẻ tinh vi (APT, tấn công "chậm mà chắc") → chấp nhận hộp đen (Random Forest nhiều cây, XGBoost, deep learning) rồi giải trình bằng SHAP (Lab 3 Phần 7, Lab 6 4.2).
  • Tốc độ / phần cứng: router, IoT, máy trạm (RAM < 1 GB, CPU yếu) → luật, cây nông, Logistic Regression, Isolation Forest nhỏ. Trung tâm dữ liệu / cloud có GPU → deep learning, LLM.
  • Mất cân bằng lớp (tấn công hiếm): không mô hình nào tự xử lý — phải chỉnh class_weight hoặc ngưỡng. Lab 3 đo ở 9E: tấn công càng hiếm, mô hình mặc định càng bỏ lọt nhiều; class_weight="balanced" bắt thêm được, đổi lại báo nhầm tăng.
  • Bị né: mô hình dựa vào đặc trưng mà kẻ tấn công tự đổi được (tên phần mềm, tốc độ thử) thì dễ bị né (Lab 3 Phần 8, Lab 4 Phần 10).

Bảng tra nhanh: khi nào dùng gì, đánh đổi gì

Công cụ Dùng khi Được Phải chấp nhận Trong khoá
Luật / ngưỡng (IF/ELSE) kiểu tấn công đã biết rõ nhanh, không cần dữ liệu học, ai cũng hiểu chỉ bắt đúng cái đã viết; dễ bị né (thử chậm, Lab 3 1.2) M1, Lab 3 Phần 1
Naïve Bayes văn bản, ít dữ liệu, cần nhanh học rất nhanh, chạy được trên máy yếu, xem được từ nào "nặng ký" coi các từ độc lập với nhau; xác suất quá tự tin; bị lách bằng cách đổi từ M1–M2, Lab 4 Phần 7
Logistic Regression cần mốc so sánh đơn giản, cần giải thích bằng trọng số nhanh, ổn định, xác suất dùng được chỉ vẽ được ranh giới "thẳng"; quan hệ phức tạp thì kém Lab 4 Phần 6
SVM dữ liệu vừa, nhiều chiều (văn bản) tách tốt khi ít mẫu chậm khi dữ liệu lớn; mặc định không ra xác suất; khó giải thích Lab 4 Phần 4–5
Perceptron mốc đơn giản nhất: "cộng điểm từng đặc trưng" cực đơn giản, nhanh, tự tính tay được chỉ ranh giới thẳng; không ra xác suất; dễ bị lách Lab 4 Phần 3
Hội đồng (Voting) đã có vài mô hình khá, muốn bù chỗ sai cho nhau thường ổn định hơn từng mô hình chậm hơn, khó giải thích hơn; chia phiếu đều không tự động hơn mô hình giỏi nhất Lab 4 Phần 8
Cây quyết định cần luật đọc được cho người không chuyên ra đúng dạng IF/ELSE cây sâu dễ "học thuộc lòng" (overfit); dữ liệu đổi chút là cây đổi Lab M2 (M2.3), M5
Random Forest dữ liệu bảng có nhãn, cần ổn định chính xác, ít phải tinh chỉnh, ít overfit hơn một cây khó đọc từng quyết định (cần SHAP / độ quan trọng đặc trưng); nặng hơn một cây Lab 3 Phần 9, M8–M10
XGBoost dữ liệu bảng lớn, cần chính xác nhất thường đứng đầu trên dữ liệu bảng; chạy được GPU nhiều tham số phải chỉnh, chỉnh ẩu dễ overfit; khó giải thích M5, Lab 10 5.1
Isolation Forest không có nhãn, tìm cái lạ không cần ai gắn nhãn; ra thứ hạng ưu tiên "lạ" ≠ "xấu"; dữ liệu bẩn làm lệch (Lab 3 Phần 6); phải chọn contamination nếu dùng predict (Lab 3, 5.6) Lab 3 Phần 5, Lab 6 Phần 4, Lab 7 Phần 7
K-Means gom nhóm hành vi (người dùng, IP) thấy được "các kiểu" hành vi phải chọn trước số nhóm k; phải chuẩn hoá; chỉ hợp với nhóm "tròn", cỡ gần nhau Lab 6 Phần 6, Lab 7
Phân rã chuỗi thời gian số liệu theo giờ / ngày có nhịp lặp tách nhịp bình thường khỏi đột biến cần chuỗi đủ dài; nhịp thay đổi thì phải tính lại M4.2, Lab 6 Phần 3
Deep learning (LSTM, CNN) dữ liệu rất lớn, dạng chuỗi / ảnh học được mẫu phức tạp cần rất nhiều dữ liệu và GPU; hộp đen Lab 10 5.1 (mạng nơ-ron MLP nhỏ)
LLM đọc, tóm tắt, giải thích log / cảnh báo viết báo cáo nhanh có thể bịa; tốn tiền; không để LLM tự quyết định chặn Lab 3 Phần 10

Bài này dùng gì? log web thật, nhãn mượn từ fail2ban (có nhiễu) → biến mỗi request thành bảng số rồi dùng Random Forest; điểm mấu chốt là cách chia dữ liệu và chất lượng nhãn.

Tóm lại: chọn mô hình là bài toán cân đối (thiết kế cả hệ thống, không chỉ chọn một thuật toán): biết mình có dữ liệu gì (có nhãn không, dạng bảng / chữ / chuỗi) và cần tối ưu điều gì (bỏ lọt hay báo nhầm, giải thích hay tốc độ). Hệ thống thật hầu như luôn kết hợp nhiều lớp (luật + không giám sát + có giám sát) — quy tắc bàn tay ở Lab 3 Phần 11.

📌 Pipeline xử lý luôn là 7 bước (chi tiết từng bước ở bảng đầu bài):

  1. Thu thập dữ liệu — có nhãn hay không nhãn?
  2. Làm sạch dữ liệu
  3. Chọn mô hình phù hợp với bài toán (4 câu hỏi ① – ④ ở trên)
  4. Coding
  5. Lưu ý khi coding — những lỗi không báo lỗi
  6. Đánh giá các mô hình
  7. Quy tắc bàn tay phải ✋
In [ ]:
import numpy as np, pandas as pd, matplotlib.pyplot as plt, re
%matplotlib inline
pd.set_option("display.width", 170); pd.set_option("display.max_columns", None); pd.set_option("display.max_colwidth", 70)

DATA = "/opt/csai/data/web_requests/web_requests.csv"   # chỉ có trên máy lớp; đã ẩn danh
req = pd.read_csv(DATA, keep_default_na=False)           # keep_default_na: giữ nguyên chuỗi, không biến "" thành NaN
print("📦 req =", req.shape, "| cột:", list(req.columns))
req.head(4)

Phần 0 — Đặt vấn đề

📍 Bước 1 + 3 — Nhãn lấy từ đâu, và bài toán gì.

Máy chủ chạy web thật (Nextcloud + Open WebUI). Mỗi ngày có hàng nghìn request từ khắp Internet — người dùng thật lẫn máy quét dò lỗ hổng. Ta muốn một mô hình nhìn vào một request và nói: bình thường hay độc hại.

Nhãn lấy từ đâu? Ta không thể ngồi gán tay hàng chục nghìn dòng. Ta mượn fail2ban — công cụ đang chạy thật trên máy, tự chặn IP tấn công. Quy ước: label = 1 nếu request đến từ một IP từng bị fail2ban chặn, ngược lại 0.

Đây là nhãn thật nhưng KHÔNG hoàn hảo — và đó là nội dung bài:

  • một IP bị chặn cũng xin cả trang bình thường (/, /favicon.ico) → những dòng đó bị gán nhãn "độc" oan;
  • một số IP tấn công chưa bị fail2ban chặn → bị gán nhãn "sạch" oan.

Phần 1 — Thu thập dữ liệu — log web thật

📍 Bước 1 — Thu thập dữ liệu.

5 câu hỏi về dữ liệu — trả lời trước khi viết dòng code nào:

Câu hỏi Trả lời cho dữ liệu bài này
Dữ liệu gì? mỗi dòng một request web: ngày, mã IP (đã băm), method, đường dẫn, mã trả về, độ dài query, nhãn
Lấy ở đâu? log truy cập của web chính trên máy chủ (Nextcloud + Open WebUI) — trang thật, người dùng thật lẫn máy quét
Thu thập thế nào? máy chủ web (nginx) tự ghi mọi request; khi xuất cho lớp: bỏ IP nội bộ và IP chủ máy, IP → mã HMAC, bỏ query (giữ độ dài), che đường dẫn riêng tư (Phần 2)
Có nhãn chưa? có nhưng mượn: label = 1 nếu IP từng bị fail2ban chặn (Phần 0) — nhãn theo IP, không theo từng request → có nhiễu (1.1)
Có cần làm sạch? phần nặng (ẩn danh) đã làm khi xuất; trong bài chỉ kiểm tra lại (Phần 2)
In [ ]:
days = sorted(req.day.unique())
print("Số request :", f"{len(req):,}")
print("Số IP      :", f"{req.ip_token.nunique():,}")
print("Khoảng ngày:", days[0], "->", days[-1], f"({len(days)} ngày có log)")
n_ban = int(req.label.sum())
print("Nhãn 1 (IP từng bị fail2ban chặn):", f"{n_ban:,}", "request",
      "(%.1f%%)" % (100*req.label.mean()), "từ", int(req[req.label==1].ip_token.nunique()), "IP")

Kết quả: 41.142 request, 3.511 IP, 2026-09-10 → 2026-10-09 (29 ngày — thiếu 1 ngày mất log). Nhãn "độc" chiếm 11.9% (4.882 request) từ 377 IP. Lớp "độc" ít hơn nhiều lớp "sạch" — dữ liệu lệch lớp (imbalanced), khác hẳn 40% cân bằng của Lab 9.

1.1 Kẻ tấn công xin những đường dẫn gì?

In [ ]:
print("10 đường dẫn được các IP-bị-chặn xin nhiều nhất:")
for path, n in req[req.label==1].path.value_counts().head(10).items():
    print("  %5d  %s" % (n, path[:85]))

# đường dẫn nào xuất hiện ở CẢ hai nhãn (vừa IP sạch vừa IP bị chặn cùng xin)?
ca_hai = req.groupby("path").label.agg(["min","max"])
print("\nSố đường dẫn xuất hiện với CẢ hai nhãn:", int((ca_hai["min"] != ca_hai["max"]).sum()),
      "<- ví dụ '/' vừa do người thật xin vừa do máy quét xin")

Kết quả: top gồm /.env, /wp-admin/install.php, /.git/config, các chuỗi /cgi-bin/.%2e/... (dò lỗ hổng path-traversal) — dấu hiệu tấn công rõ. Nhưng 457 đường dẫn xuất hiện ở cả hai nhãn: ví dụ / (trang chủ) vừa do người thật xin, vừa do máy quét xin.

👉 Hiểu: đây là gốc rễ của nhiễu nhãn. Cùng một request GET / có khi nhãn 1 (vì IP đó bị chặn vì việc khác), có khi nhãn 0. Mô hình không thể học đúng từ cùng một đầu vào mà nhãn lúc này lúc khác.


Phần 2 — Làm sạch / ẩn danh — những gì đã xử lý khi xuất dữ liệu

📍 Bước 2 — Làm sạch / ẩn danh.

Khác với dữ liệu tự sinh ở Lab 9, log thật đã được xử lý riêng tư trước khi đưa vào lớp:

Việc Vì sao
IP → mã IP-xxxx (HMAC) không lộ IP thật của người dùng/khách
Bỏ query string, chỉ giữ độ dài (query_len) query có thể chứa token, email
Che đường dẫn riêng tư của Nextcloud + mã chia sẻ dữ liệu cá nhân
Bỏ IP nội bộ/LAN và IP của chủ máy chỉ giữ lưu lượng Internet

Ở đây ta chỉ kiểm tra dữ liệu đã sạch chưa để chạy mô hình:

In [ ]:
print("Ô trống (NaN):", int(req.isna().sum().sum()))
print("status là số?  ", req.status.dtype, "| ví dụ các mã:", sorted(req.status.unique())[:8], "...")
print("method:", req.method.value_counts().to_dict())
print("query_len: min %d, trung vị %d, max %d" % (req.query_len.min(), int(req.query_len.median()), req.query_len.max()))

👉 Hiểu: không có ô trống; method, status, query_len đều dùng được ngay. Phần ẩn danh nặng nhất đã làm lúc xuất — nhắc lại bài học Lab 8/9: với dữ liệu thật, chuẩn bị + bảo vệ riêng tư là phần tốn công nhất, không phải việc dựng mô hình.


Phần 3 — Đặc trưng — 10 con số rút từ bản thân request

📍 Bước 4 — Coding (đặc trưng) và Bước 5 — Lưu ý: không dùng IP làm đặc trưng.

Ta không dùng IP làm đặc trưng (IP đã bị băm, và dùng IP sẽ thành "học thuộc IP xấu" thay vì học hình dạng tấn công). Chỉ rút từ đường dẫn + method + độ dài query:

In [ ]:
# server-side code, bí mật, backup -- KHÔNG tính .txt/.xml/.ico (robots.txt, sitemap.xml, favicon.ico là bình thường)
EXT   = re.compile(r"\.(?:php|env|git|asp|aspx|jsp|cgi|sh|sql|bak|zip|tar|gz|ini|conf|yml|yaml|log)(?:$|/|\.)", re.I)
TU_TN = re.compile(r"wp-|admin|login|shell|cgi-bin|phpmyadmin|config|backup|setup|install|xmlrpc|eval|passwd", re.I)

def dac_trung(d):
    p = d.path.astype(str)
    return pd.DataFrame({
        "path_len":    p.str.len(),                                  # đường dẫn dài
        "depth":       p.str.count("/"),                             # số tầng thư mục
        "dot_segment": p.str.contains(r"/\.[^/]").astype(int),       # /.env /.git -> đoạn bắt đầu bằng dấu chấm
        "risky_ext":   p.str.contains(EXT).astype(int),              # đuôi file nguy hiểm
        "attack_word": p.str.contains(TU_TN).astype(int),            # từ hay gặp trong tấn công
        "pct_encoded": p.str.count("%"),                             # số lần mã hoá %xx
        "traversal":   p.str.contains(r"\.\.|%2e|%%32%65", case=False).astype(int),  # leo thư mục ../
        "not_a_path":  (~p.str.startswith("/")).astype(int),         # "host:port" = dò proxy, không phải đường dẫn thật
        "query_len":   d.query_len,
        "method_post": (d.method == "POST").astype(int),
    })

X = dac_trung(req)                                                   # 📦 X = bảng 41142 × 10 con số
y = req.label                                                       # 📦 y = nhãn 0/1 (từ fail2ban)
print("📦 X =", X.shape, "| đặc trưng:", list(X.columns))
X.head(3)

Phần 4 — Random Forest — và cách chia dữ liệu mới là chỗ bẫy

📍 Bước 4 — Coding, Bước 5 — Lưu ý (chia theo IP, không chia ngẫu nhiên) và Bước 6 — Đánh giá.

Thư viện: scikit-learn. Điểm mới so với Lab 9: cách chia học/kiểm tra. Nếu chia ngẫu nhiên từng dòng, cùng một máy quét sẽ có request ở cả phần học lẫn phần kiểm tra → mô hình "học thuộc" máy đó rồi tự chấm điểm cao cho mình. Công bằng hơn là chia theo IP: một IP chỉ nằm ở một bên. Ta làm chia theo IP trước.

In [ ]:
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import GroupShuffleSplit, train_test_split
from sklearn.metrics import precision_score, recall_score, confusion_matrix

# chia theo IP: cùng một IP không nằm ở cả hai bên (groups = ip_token)
tr, te = next(GroupShuffleSplit(n_splits=1, test_size=0.3, random_state=42).split(X, y, groups=req.ip_token))
rf = RandomForestClassifier(n_estimators=200, max_depth=8, class_weight="balanced", random_state=42, n_jobs=2)
rf.fit(X.iloc[tr], y.iloc[tr])
pred = rf.predict(X.iloc[te]); yt = y.iloc[te].to_numpy()
tp = int(((pred==1)&(yt==1)).sum()); fp = int(((pred==1)&(yt==0)).sum()); fn = int(((pred==0)&(yt==1)).sum())
trung = req.iloc[te].ip_token.isin(set(req.iloc[tr].ip_token)).mean()   # % request test có IP trùng phần học
print("CHIA THEO IP (test = %d request từ %d IP; %.0f%% request test có IP trùng phần học):"
      % (len(te), req.iloc[te].ip_token.nunique(), 100*trung))
print("  precision %.2f  recall %.2f  | TP=%d FP=%d FN=%d" % (precision_score(yt,pred), recall_score(yt,pred), tp, fp, fn))

Kết quả: precision 0.46, recall 0.85 (0% request test có IP trùng phần học — chia sạch). Nhớ Lab 9 precision 1.00; ở đây chưa tới một nửa. Trước khi kết luận "mô hình dở", xem cách chia khác:

In [ ]:
# chia NGẪU NHIÊN từng dòng (KHÔNG theo IP) để so sánh
tr2, te2 = train_test_split(np.arange(len(req)), test_size=0.3, stratify=y, random_state=42)
rf2 = RandomForestClassifier(n_estimators=200, max_depth=8, class_weight="balanced", random_state=42, n_jobs=2).fit(X.iloc[tr2], y.iloc[tr2])
pred2 = rf2.predict(X.iloc[te2]); yt2 = y.iloc[te2].to_numpy()
trung2 = req.iloc[te2].ip_token.isin(set(req.iloc[tr2].ip_token)).mean()
print("CHIA NGẪU NHIÊN (%.0f%% request test có IP trùng phần học):" % (100*trung2))
print("  precision %.2f  recall %.2f" % (precision_score(yt2,pred2), recall_score(yt2,pred2)))
print("\nĐộ quan trọng đặc trưng (mô hình chia-theo-IP):")
imp = pd.Series(rf.feature_importances_, index=X.columns).sort_values(ascending=False)
print("  " + "  ".join("%s %.3f" % (k,v) for k,v in imp.head(5).items()))

Kết quả: chia ngẫu nhiên cho precision 0.48 / recall 0.90 với 95% request test có IP trùng phần học. Trông "tốt hơn" nhưng là ảo: mô hình đã thấy chính các IP đó lúc học. Chia theo IP mới phản ánh IP lạ trong tương lai — đúng tình huống thật.

Đặc trưng quan trọng nhất: risky_ext (0.301), path_len (0.227), attack_word (0.176), depth (0.175) — hợp lý: đuôi file nguy hiểm và từ khoá tấn công. (Ở Lab 9 là num_subdomains; mỗi bài toán có dấu hiệu riêng.)

👉 Hiểu: cách chia dữ liệu quyết định con số bạn báo cáo. Chia sai (ngẫu nhiên khi dữ liệu có nhóm) → điểm đẹp giả tạo, y hệt sai lầm thường gặp khi đánh giá mô hình an ninh.

Cách đọc biểu đồ dưới: cột càng dài, mô hình càng dựa vào đặc trưng đó. Đây là của mô hình chia theo IP.

In [ ]:
imp_full = pd.Series(rf.feature_importances_, index=X.columns).sort_values()
ax = imp_full.plot.barh(figsize=(8, 4), color="tab:red")
ax.set_xlabel("độ quan trọng (tổng = 1)"); ax.set_title("Log thật: đặc trưng nào tố cáo request độc hại")
for i, v in enumerate(imp_full):
    ax.text(v + .004, i, "%.3f" % v, va="center", fontsize=9)
plt.tight_layout(); plt.show()

Phần 5 — Đọc các lỗi — vì sao precision chỉ 0.46

📍 Bước 6 — Đánh giá: lỗi do mô hình hay do nhãn?

Một nhà phân tích chặn theo IP, không chặn từng request. Xem theo IP, và đọc thẳng các "lỗi".

In [ ]:
t = req.iloc[te].assign(pred=pred)
ip = t.groupby("ip_token").agg(label=("label","max"), flagged=("pred","mean"))
ip_co = ip.flagged >= 0.5    # gắn cờ một IP nếu >= 50% request của nó bị gắn cờ
print("Theo IP (cờ nếu >=50%% request bị gắn cờ):")
print("  IP bị-chặn bắt được : %d / %d" % (int((ip_co & (ip.label==1)).sum()), int((ip.label==1).sum())))
print("  IP chưa-chặn bị cờ  : %d / %d" % (int((ip_co & (ip.label==0)).sum()), int((ip.label==0).sum())))

# các request bị gắn cờ nhưng IP CHƯA từng bị chặn (nhãn 0) -- "lỗi" của mô hình?
fpm = (t.pred==1) & (t.label==0)
hinh_tancong = X.iloc[te][["dot_segment","risky_ext","attack_word","traversal","not_a_path"]].to_numpy().any(axis=1)
print("\nTrong %d request bị cờ nhưng IP chưa bị chặn:" % int(fpm.sum()))
print("  %d trông ĐÚNG LÀ tấn công (dot-file / đuôi nguy hiểm / từ khoá / leo thư mục / dò proxy)" % int((fpm.to_numpy() & hinh_tancong).sum()))
print("     -> nhiều khả năng là tấn công mà fail2ban CHƯA chặn = nhãn sai, không phải mô hình sai")
print("  %d còn lại là đường dẫn thường (favicon.ico, robots.txt...) mà máy quét cũng xin" % int((fpm.to_numpy() & ~hinh_tancong).sum()))

Kết quả: theo IP, mô hình bắt 113/120 IP bị chặn, và gắn cờ 203/934 IP chưa bị chặn. Trong 1.187 request "báo nhầm", có tới 602 trông đúng là tấn công (dot-file, đuôi nguy hiểm, leo thư mục...).

👉 Hiểu then chốt: nhiều "lỗi" không phải lỗi của mô hình — chúng là tấn công thật mà fail2ban chưa kịp chặn, tức là nhãn mới sai. Precision 0.46 vì thế đánh giá thấp oan mô hình: ta đang chấm nó bằng một thước đo (fail2ban) chính nó cũng sai. Đây là bộ mặt thật của học máy an ninh: chất lượng nhãn giới hạn mọi thứ.


Phần 6 — Giải pháp & kết luận

📍 Bước 7 — Quy tắc bàn tay phải: giải pháp và kết luận.

Vấn đề: nhãn mượn từ fail2ban có nhiễu → điểm số vừa thấp vừa khó tin.

Giải pháp — nhãn bằng chứng (evidence labels): thay vì "IP này từng bị chặn", gán nhãn theo bằng chứng kiểm chứng được của từng request/IP: có xin file bí mật không, có chuỗi leo thư mục không, có khớp mẫu khai thác đã biết không; còn "người dùng" thì xác nhận bằng đăng nhập thành công. Nhãn sạch hơn → mô hình học đúng hơn, và đánh giá mới công bằng. (Máy chủ này thực tế đang chạy một bộ chấm điểm như vậy ở chế độ quan sát (shadow) — chỉ theo dõi, không tự chặn.)

Lab 9 (URL tự sinh) Lab 9B (log thật)
Nhãn đúng tuyệt đối (ta tự sinh) mượn fail2ban — có nhiễu
precision 1.00 0.46 (bị đánh giá thấp oan)
Bài học ML mạnh hơn regex nhãn quyết định tất cả

Kết luận:

  1. Con số đẹp trên dữ liệu sạch không chuyển thẳng sang đời thực — ở đây precision rơi về 0.46.
  2. Phần lớn "sai" là do nhãn sai, không phải mô hình sai → phải đọc từng lỗi, đừng tin mỗi con số.
  3. Cách chia dữ liệu (theo IP) và chất lượng nhãn (bằng chứng) mới là thứ quyết định một mô hình an ninh dùng được.
  4. Dữ liệu thật luôn kèm trách nhiệm riêng tư — vì sao mọi IP ở đây đã được ẩn danh.

Quy tắc bàn tay ✋ áp vào bài này

Xòe bàn tay, đếm từ ngón cái: 5 điều không được bỏ của mọi bài toán phát hiện tấn công bằng AI (đầy đủ ở Lab 3 Phần 11). Bài này đã làm đủ 5 điều:

Ngón Điều Câu hỏi phải tự trả lời Trong bài này Quên điều này thì…
👍 1. Ngón cái DỮ LIỆU sạch Đã ẩn danh gì? Còn ô trống không? IP → mã HMAC, bỏ query, che đường dẫn riêng tư khi xuất; không có ô trống (Phần 2) lộ dữ liệu cá nhân của người dùng thật
☝️ 2. Ngón trỏ ĐẶC TRƯNG đúng Rút gì từ request? Có dùng IP không? 10 con số từ đường dẫn, method, độ dài query; không dùng IP (Phần 3) dùng IP làm đặc trưng = học thuộc IP xấu
✋ 3. Ngón giữa CHỌN CÔNG CỤ Mô hình nào? Random Forest như Lab 9, để thấy khác biệt nằm ở dữ liệu và nhãn (Phần 4) đổi mô hình mãi khi lỗi nằm ở nhãn
💍 4. Ngón áp út ĐO trung thực Chia dữ liệu thế nào? Nhãn có đúng không? chia theo IP: precision 0,46 / recall 0,85; chia ngẫu nhiên 0,48 / 0,90 với 95% request thi có IP đã gặp lúc học (Phần 4) · 457 đường dẫn mang cả hai nhãn (1.1) điểm "tốt hơn" giả do lộ IP; tin nhãn mượn là đúng
🤙 5. Ngón út GIẢI THÍCH & CẢNH GIÁC Lỗi do mô hình hay do nhãn? 602 / 1.187 request "báo nhầm" trông đúng là tấn công (Phần 5) → cần nhãn bằng chứng (Phần 6) sửa mô hình trong khi nhãn sai