Files
labs/Notebooks-CSAI/Lab3_Honeypot_ML.ipynb

211 KiB

Module 3 — Phát hiện tấn công bằng AI, thực hành trên log honeypot THẬT

Cách làm: bấm vào một ô code rồi nhấn Shift + Enter để chạy ô đó và sang ô kế tiếp. Chạy lần lượt từ trên xuống. Kernel phải là Python (lớp CSAI). Ô có dòng ✏️ SỬA là chỗ mời bạn đổi số / chữ rồi chạy lại. Sửa hỏng? Menu Kernel → Restart Kernel and Run All Cells.

Notebook đi theo thứ tự bài giảng Module 3, và tự giải thích đầy đủ từng khái niệm ngay tại chỗ — không cần mở slide. Mỗi khái niệm có ngay một ô code chạy trên log honeypot SSH thật của mình (30 ngày 24/08–22/09/2026; bao nhiêu dòng, bao nhiêu IP thì ô 0.3 sẽ tự đếm).

Dữ liệu là gì? Honeypot là máy "mồi": giả làm máy chủ SSH nhưng không chạy dịch vụ thật nào, không ai được mời dùng. Ngoài người dựng honeypot, không ai có lý do chính đáng để kết nối tới nó → gần như mọi kết nối là kẻ tấn công (bot quét cổng, dò mật khẩu). Kết nối từ IP nội bộ và IP của người quản trị đã được lọc bỏ khi xuất dữ liệu cho lớp (đầu Phần 0). Điều này làm thay đổi ý nghĩa của chữ "bất thường" ở Phần 5 — đọc kỹ mục 5.0.

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

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

  1. Chỉ ra được vì sao luật ngưỡng SOC bỏ lọt kẻ tấn công "chậm mà chắc", đo trên log thật (Phần 1).
  2. Phân biệt học có giám sát / không giám sát và biết chọn cách học khi chưa có nhãn (Phần 2).
  3. Tự biến log thô thành bảng đặc trưng mỗi IP, rồi chuẩn bị bảng số: điền ô trống, log1p, chuẩn hoá (Phần 3–4).
  4. Dùng Isolation Forest xếp hạng IP bất thường và chọn số IP gắn cờ (contamination) theo sức đội SOC (Phần 5).
  5. Giải thích một cảnh báo bằng SHAP (Phần 7) và nhận ra 2 cách mô hình bị phá: dữ liệu học bị nhiễm (Phần 6), kẻ tấn công né (Phần 8).
  6. Xây Random Forest có nhãn đúng quy trình: chia học / thi không rò rỉ, đọc precision / recall / F1, xử lý mất cân bằng lớp, chọn ngưỡng (Phần 9).

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

  • Bài toán: trong hàng trăm nghìn dòng log SSH, IP nào đội SOC nên xem trước? Đây là bài phát hiện bất thường / xếp hạng ưu tiên: đầu ra là một danh sách xếp hạng, không phải "đúng / sai" cho từng dòng log.
  • Dữ liệu: log sự kiện (mỗi dòng một sự kiện JSON), không có nhãn "nguy hiểm / không nguy hiểm" → phải gom theo IP thành bảng số trước khi đưa cho máy.
  • Vì vậy chọn: mốc so sánh = luật ngưỡng; không có nhãn → học không giám sát: Isolation Forest (ra thứ hạng), giải thích bằng SHAP. Riêng câu hỏi có sẵn đáp án trong log (IP có thử mật khẩu không) → học có giám sát: Random Forest (Phần 9). Xem bảng ① ② ở mục Trước khi bắt đầu ngay dưới đây.
  • Xử lý gì trước: làm sạch dữ liệu trước, thuật toán sau: lọc IP nội bộ → tính đặc trưng → chuẩn bị bảng số → mô hình. Bỏ bước lọc thì mô hình học sai và kẻ trinh sát rơi khỏi danh sách đội SOC xem (Phần 6 đo).
  • Thước đo: không có nhãn thì không tính được precision / recall → dùng thứ hạng + người phân tích xem lại top N (5.6). Có nhãn (Phần 9) → precision, recall, F1.

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ài này đi 2 vòng: vòng 1 không có nhãn (Isolation Forest), vòng 2 có nhãn (Random Forest).

Bước Câu hỏi phải trả lời Vòng 1 — Isolation Forest Vòng 2 — Random Forest
1. Thu thập dữ liệu dữ liệu gì, lấy ở đâu, thu thập thế nào, có nhãn hay không nhãn? Phần 0 (5 câu hỏi), 0.1–0.3 9A.1, 9A.3 (tạo nhãn)
2. Làm sạch dữ liệu có gì bẩn: IP nội bộ, ô trống, số quá lớn, thang đo khác nhau? Phần 0 (log thô) · 3.2, Phần 4 (bảng số) 9B.1
3. Chọn mô hình phù hợp với bài toán bài toán gì, có nhãn không, cách cũ làm được đến đâu? 0.4, Phần 1 (mốc: luật ngưỡng), Phần 2 mở đầu Phần 9
4. Coding biến log thành bảng số, dựng mô hình Phần 3, 5.1–5.3 9A.2, 9B.2, 9C
5. Lưu ý khi coding lỗi nào làm mô hình sai mà không báo lỗi? Phần 6 (lọc trước khi tính đặc trưng), 4d 9B.3 (chỉ học từ phần học), 9B.4 (rò rỉ nhãn)
6. Đánh giá các mô hình hơn mốc bao nhiêu, vì sao máy báo, có bị né không, chọn ngưỡng nào? 5.4–5.6, Phần 7, Phần 8 9D, 9E, 9F
7. Quy tắc bàn tay phải ✋ gói lại thành 5 điều mang theo khi đi làm Phần 10, 11 Phần 10, 11

Thực tế các bước lặp lại, không đi một mạch: ví dụ bài này làm sạch 2 lần — trên log thô (Phần 0) và trên bảng số (Phần 4).

📌 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.

🧭 Ký hiệu trong bài — cái gì BẮT BUỘC, cái gì TUỲ CHỌN

Ký hiệu Nghĩa Bắt buộc?
ô code bình thường phải chạy, lần lượt từ trên xuống: ô sau dùng các biến mà ô trước tạo ra. Bỏ một ô → các ô sau báo lỗi NameError (không biết tên biến) BẮT BUỘC
🎯 Mục tiêu · 📥 Đầu vào · 📤 Đầu ra đặt ngay trên mỗi ô code: ô này làm gì, dùng dữ liệu / biến gì có sẵn, tạo ra gì cho các bước sau đọc trước khi chạy
✏️ SỬA chỗ mời đổi số / chữ để thử. Giữ nguyên cũng chạy đúng. Dòng Các lựa chọn ngay trên ô cho biết nên thử giá trị nào và sẽ thấy gì (đo thật) tuỳ chọn
🧰 tham số một hàm nhận những gì: tham số bắt buộc (thiếu là lỗi) và tuỳ chọn (không ghi thì dùng giá trị mặc định), kèm các lựa chọn để tra khi cần
Kết quả · 👉 Hiểu · 📦 số đo thật trên máy lớp · ý nghĩa cần nhớ · bên trong một biến có gì (biến sẽ được dùng lại) —
💼 Dùng khi nào · ⚖️ Ảnh hưởng tới ngoài bài này, khi đi làm bạn sẽ cần kỹ thuật này lúc nào · nó quyết định điều gì (làm sai thì hỏng cái gì) đọc để nhớ lâu

Tham số bắt buộc / tuỳ chọn trông thế nào? Hàm def ham(a, b=1): a bắt buộc, b tuỳ chọn (không ghi thì b = 1). Gọi được ham(5) hoặc ham(5, b=3); gọi ham() thì lỗi vì thiếu a. Các mô hình của scikit-learn cũng vậy: IsolationForest() chạy được ngay với mọi tham số mặc định, ta chỉ ghi những tham số muốn đổi.

📖 Thuật ngữ dùng trong bài

Thuật ngữ Nghĩa Ở bài này
mẫu (sample) 1 hàng của bảng, 1 thứ cần phân loại 1 IP
đặc trưng (feature) 1 cột số mô tả mẫu số lần sai, số username, nhịp...
nhãn (label) đáp án đúng chỉ có ở Phần 9 (học có giám sát)
học không giám sát không có đáp án, máy tự tìm cái khác thường Isolation Forest (Phần 5)
học có giám sát có đáp án, máy học cách đoán đáp án Random Forest (Phần 9)
điểm bất thường decision_function: càng âm càng bất thường; predict: -1 = bất thường, +1 = bình thường Phần 5
FP / FN báo nhầm / bỏ lọt Phần 9D
.shape kích thước bảng: (số hàng, số cột). Dùng sau mỗi bước biến đổi để kiểm tra bảng còn đủ dòng / đúng số cột không F.shape = (số IP, 7): mỗi IP một hàng × 7 đặc trưng (Phần 3)

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 (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 bài này (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 (bài này 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 (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 (Phần 7).
  • 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. Bài này đ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é (Phần 8).

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, 1.2) M1, 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 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 (Phần 6); phải chọn contamination nếu dùng predict (5.6) bài này 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 Phần 10

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) — xem 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 ✋

Phần 0 — Thu thập dữ liệu: dữ liệu gì, lấy ở đâu, có nhãn chưa, có cần làm sạch?

📍 Bước 1 — Thu thập dữ liệu và Bước 2 — có cần làm sạch không?

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ì? log sự kiện SSH: mỗi dòng là một sự kiện JSON (kết nối, khai tên phần mềm, thử mật khẩu sai / đúng). 30 ngày (24/08–22/09/2026); bao nhiêu dòng, bao nhiêu IP thì ô 0.3 đếm
Lấy ở đâu? honeypot SSH thật của mình (phần mềm Cowrie) mở ra Internet. Trên máy lớp: 30 file /opt/csai/data/honeypot_m1/cowrie.json.*, mỗi ngày một file
Thu thập thế nào? Cowrie tự ghi mọi sự kiện, không ai phải nhập tay. Khi xuất cho lớp chỉ giữ 4 loại sự kiện (session.connect, client.version, login.failed, login.success) và 8 trường (loại sự kiện, thời gian, IP, cổng, phiên, username, password, tên phần mềm)
Có nhãn chưa? Chưa có nhãn "nguy hiểm / không nguy hiểm" → vòng 1 dùng học không giám sát. Nhưng dòng login.* cho biết IP có thử mật khẩu hay không → tự tạo được một nhãn cho vòng 2 (9A.3)
Có cần làm sạch? Có, 2 lần. (a) Log thô — đã làm khi xuất: bỏ IP nội bộ (10.x, 192.168.x, 127.x…) và IP của người quản trị; thời gian dạng chữ đổi sang kiểu thời gian ở 0.3. (b) Bảng số — ô trống (3.2), số rất lớn và thang đo khác nhau (Phần 4). Trong hệ thống thật, (a) là việc bạn phải tự làm: Phần 6 cho thấy bỏ qua thì mô hình học sai

Giờ xem tận mắt log thô (0.1 – 0.3).

0.1 IP thứ nhất: 1.10.195.220 — toàn bộ log của nó trong 30 ngày:

🎯 Mục tiêu: xem tận mắt toàn bộ log của một IP bình thường nhất
📥 Đầu vào: 30 file log trong /opt/csai/data/honeypot_m1
📤 Đầu ra: chỉ in ra, không tạo biến. Ô này chạy lệnh Linux (dòng đầu %%bash), không phải Python

In [ ]:
%%bash
# grep -h = tìm mọi dòng chứa IP này trong cả 30 file log (không in tên file)
grep -h '"src_ip": "1.10.195.220"' /opt/csai/data/honeypot_m1/cowrie.json.*

Kết quả: đúng 2 dòng cowrie.session.connect (01/09 lúc 23:31 và 23:32 UTC), không có dòng client.version, không có dòng login.

👉 Hiểu: IP này gõ cửa 2 lần, ngắt ngay trước cả khi khai tên phần mềm, rồi biến mất — một máy quét điển hình.

💼 Dùng khi nào: điều tra một IP cụ thể: lấy toàn bộ dấu vết của nó trong nhiều ngày bằng một lệnh.
⚖️ Ảnh hưởng tới: nhìn đủ dấu vết trước khi kết luận "IP này vô hại" hay "IP này nguy hiểm".

0.2 IP thứ hai: 130.12.182.196 — 8 dòng đầu tiên của nó (nó có hơn 11 nghìn dòng):

🎯 Mục tiêu: xem log của một IP ồn ào để so với IP trên
📥 Đầu vào: file log ngày 30/08
📤 Đầu ra: chỉ in ra 8 dòng

In [ ]:
%%bash
# grep -m 8 = dừng sau 8 dòng khớp đầu tiên
grep -h -m 8 '"src_ip": "130.12.182.196"' /opt/csai/data/honeypot_m1/cowrie.json.2026-08-30

Kết quả: 8 dòng trong ~1,5 phút (10:40:04 → 10:41:37 UTC): session.connect → client.version (SSH-2.0-Go) → login.failed (root / root) → kết nối mới → login.failed (root / 123456) → ...

👉 Hiểu: cứ ~16 giây một lần thử, công cụ viết bằng Go, nhắm root — một máy dò mật khẩu. Người đọc log thấy ngay sự khác biệt với IP 0.1. Còn bao nhiêu IP như thế nữa? → ô 0.3 đếm.

💼 Dùng khi nào: so một IP ồn ào với một IP lặng lẽ — bước đầu để nghĩ ra đặc trưng nào phân biệt được chúng.
⚖️ Ảnh hưởng tới: đặc trưng chọn sai thì mọi mô hình phía sau đều học sai.

0.3 Cả kho log lớn cỡ nào?

🎯 Mục tiêu: nạp cả 30 ngày log vào một bảng để mọi phần sau dùng chung
📥 Đầu vào: 30 file cowrie.json.*
📤 Đầu ra: log (bảng mọi sự kiện, cả notebook dùng) · RANDOM_STATE (hạt giống ngẫu nhiên) · so_kn, so_thu

🧰 pd.read_json(f, lines=True, dtype=False, convert_dates=False): f bắt buộc · lines=True = mỗi dòng 1 JSON (dạng log Cowrie; mặc định False) · dtype=False = giữ nguyên kiểu, không để pandas tự đoán (mật khẩu "0123" không bị thành số) · convert_dates=False = giữ thời gian ở dạng chữ, ta tự đổi bằng pd.to_datetime(..., utc=True).

In [ ]:
import glob
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
%matplotlib inline
pd.set_option("display.max_colwidth", 80)
pd.set_option("display.width", 200)
RANDOM_STATE = 42          # "hạt giống" ngẫu nhiên: cố định để lần nào chạy cũng ra cùng kết quả

# Đọc 30 file log (mỗi dòng 1 sự kiện JSON), ghép thành 1 bảng
log = pd.concat(pd.read_json(f, lines=True, dtype=False, convert_dates=False)
                for f in sorted(glob.glob("/opt/csai/data/honeypot_m1/cowrie.json.*")))
log["thoi_gian"] = pd.to_datetime(log.timestamp, utc=True)      # chữ -> thời gian thật (UTC)
# 📦 log = bảng MỌI sự kiện 30 ngày; cả notebook dùng bảng này
so_kn = (log.eventid == "cowrie.session.connect").sum()
so_thu = log.eventid.str.startswith("cowrie.login").sum()
print("log:", len(log), "dòng,", log.src_ip.nunique(), "IP")
print(log.eventid.value_counts().to_string())
print(f"Trung bình cứ {30 * 86400 / so_kn:.0f} giây có 1 kết nối mới, cứ {30 * 86400 / so_thu:.0f} giây có 1 lần thử mật khẩu — suốt 30 ngày")

Kết quả: log: 395124 dòng, 2906 IP · session.connect 136063 · client.version 129951 · login.success 65772 · login.failed 63338 · Trung bình cứ 19 giây có 1 kết nối mới, cứ 20 giây có 1 lần thử mật khẩu — suốt 30 ngày

📦 log = bảng mọi sự kiện 30 ngày, thêm cột thoi_gian (kiểu thời gian). Cả notebook dùng bảng này.

🔢 2.906 từ đâu ra? Là số địa chỉ IP khác nhau từng kết nối tới honeypot trong 30 ngày: log.src_ip.nunique() trong ô trên (đếm cột src_ip, mỗi IP chỉ tính 1 lần dù kết nối bao nhiêu lần). Con số này xuất hiện suốt bài: mỗi IP sẽ thành một hàng của bảng đặc trưng (Phần 3).

👉 Hiểu: máy chủ nhỏ của mình bị gõ cửa 3 lần mỗi phút, ngày đêm không nghỉ. Không người trực nào đọc hết được.

❓ Câu hỏi của cả bài: 2 IP ở 0.1–0.2 thì đọc bằng mắt là thấy khác nhau. Với 2.906 IP thì làm sao để máy tự thấy sự khác biệt đó?

💼 Dùng khi nào: ước lượng quy mô trước khi chọn cách làm: vài nghìn dòng thì đọc tay được, vài trăm nghìn thì phải tự động.
⚖️ Ảnh hưởng tới: lý do cần AI: con người không đọc kịp 1 lần thử mật khẩu mỗi ~20 giây suốt 30 ngày.

0.4 Vậy đây là bài toán gì? Chọn cách giải nào, xử lý gì trước?

📍 Bước 3 — Chọn mô hình phù hợp với bài toán.

Đã thấy log thật, giờ trả lời 4 câu hỏi chọn mô hình (mục Trước khi bắt đầu) cho chính log này:

Câu hỏi Trả lời cho log honeypot Nên chọn
Bài toán loại gì? 2.906 IP trong 30 ngày, đội SOC chỉ xem kịp vài chục IP mỗi ngày (bài này giả định 50, mục 6.1) xếp hạng ưu tiên / phát hiện bất thường: đưa IP đáng ngờ nhất lên đầu
① Có nhãn không? không ai gắn "nguy hiểm / không" cho từng IP; kẻ tấn công kiểu mới thì chưa có nhãn nào không giám sát: Isolation Forest (Phần 5). Chỉ câu "IP có thử mật khẩu không" có sẵn đáp án trong log → có giám sát: Random Forest (Phần 9)
② Dữ liệu dạng gì? 395.124 dòng sự kiện JSON, mỗi dòng một việc nhỏ (kết nối, thử mật khẩu, gõ lệnh) gom theo IP thành bảng số (Phần 3) → hợp với mô hình cây / rừng
③ Sợ bỏ lọt hay báo nhầm? bỏ lọt kẻ "chậm mà chắc" là nguy hiểm, nhưng đội SOC không xem nổi hàng trăm cảnh báo chọn số IP gắn cờ theo sức đội (5.6), người phân tích xem lại top N
④ Ràng buộc khi chạy thật cảnh báo phải giải thích được cho người trực; kẻ tấn công biết ta đo gì sẽ né SHAP (Phần 7), thử né (Phần 8)

Xử lý theo thứ tự nào? Làm sạch dữ liệu trước, thuật toán sau:

  1. Lọc IP nội bộ / máy giám sát của mình (6.2) — bỏ bước này thì mô hình học sai từ đầu (Phần 6).
  2. Gom theo IP → đặc trưng hành vi (Phần 3).
  3. Chuẩn bị bảng số: điền ô trống, log1p, chuẩn hoá (Phần 4).
  4. Mô hình: luật ngưỡng làm mốc (Phần 1) → Isolation Forest (Phần 5).
  5. Giải thích + người xem lại top N (Phần 7, 5.6).

👉 Hiểu: chọn mô hình sau khi nhìn dữ liệu, không phải trước. Ở đây dữ liệu không có nhãn và là log sự kiện → cách giải là "gom thành bảng số rồi tìm cái khác thường", không phải "phân loại từng dòng".


Phần 1 — Giải pháp truyền thống: ngưỡng cảnh báo, và điểm mù "chậm mà chắc"

📍 Bước 3 — Chọn mô hình (mốc so sánh): trước khi dùng máy học, đo xem cách cũ (luật ngưỡng) bắt được ai, bỏ lọt ai. Mô hình nào cũng phải vượt mốc này.

Luật phổ biến nhất trong SOC: "một IP sai mật khẩu quá 5 lần trong 10 phút → cảnh báo". Ta chạy đúng luật đó trên 30 ngày log.

SOC thật không chỉ có một luật: còn luật "vào được sau nhiều lần sai", "một IP rải nhiều tài khoản", "một tài khoản bị nhiều IP cùng thử", "đăng nhập được từ IP lạ", "quét dồn dập", "công cụ lạ", "kiên trì nhiều ngày"… Mục 1.3 (tuỳ chọn) chạy thử 7 luật như vậy trên cùng log: mỗi luật bắt ai, bỏ ai, đánh đổi gì.

1.1 Chạy luật ngưỡng

🎯 Mục tiêu: chạy luật SOC phổ biến nhất: "sai mật khẩu quá NGUONG_LAN lần trong CUA_SO → cảnh báo"
📥 Đầu vào: log
📤 Đầu ra: sai (các lần sai, chỉ mục = thời gian) · dinh_10_phut (mỗi IP → khung dồn dập nhất) · bi_bat (tập IP bị luật bắt) · tong_sai, lot (dùng lại ở 1.2, 5.5, 10.1)

🧰 .rolling(cua_so).count(): cua_so bắt buộc — một số dòng (ví dụ 5 = 5 dòng gần nhất) hoặc một khoảng thời gian khi chỉ mục là thời gian: "10min", "60min", "1h", "1D". Mỗi dòng nhận giá trị = số dòng nằm trong cửa sổ tính ngược về trước.

Các lựa chọn ✏️ (đo thật trên 561 IP có thử sai):

NGUONG_LAN, CUA_SO IP bị bắt IP lọt qua (tổng lần sai) bắt 2.57.121.112? bắt 185.246.128.133?
5, "10min" (có sẵn) 190 371 (8.244) không không
3, "10min" (nhạy hơn) 263 298 (3.937) có không
10, "10min" (lỏng hơn) 105 456 (13.075) không không
5, "60min" (cửa sổ dài) 257 304 (2.407) có không
20, "60min" 99 462 (8.963) không không

185.246.128.133 không bao giờ bị bắt dù chỉnh thế nào: nó có 0 lần sai. Luật đếm số lần sai mù hẳn trước kẻ đã có mật khẩu đúng.

In [ ]:
# ✏️ SỬA: ngưỡng số lần sai và độ dài cửa sổ thời gian
NGUONG_LAN, CUA_SO = 5, "10min"
# Các lần đăng nhập THẤT BẠI, xếp theo thời gian, lấy cột thời gian làm chỉ mục (để đếm theo khung thời gian)
sai = log[log.eventid == "cowrie.login.failed"].sort_values("thoi_gian").set_index("thoi_gian")
# rolling(CUA_SO).count(): tại mỗi lần sai, đếm số lần sai của CÙNG IP trong 10 phút tính ngược về trước
# .max() theo IP = "đỉnh": khung 10 phút dồn dập nhất của IP đó
dinh_10_phut = sai.groupby("src_ip").eventid.rolling(CUA_SO).count().groupby(level=0).max()
# 📦 dinh_10_phut = mỗi IP (có thử sai) -> số lần sai nhiều nhất trong 1 khung 10 phút
bi_bat = set(dinh_10_phut[dinh_10_phut > NGUONG_LAN].index)
tong_sai = sai.groupby("src_ip").size()
print("IP có thử sai mật khẩu:", len(dinh_10_phut))
print(f"Luật ngưỡng bắt được  : {len(bi_bat)} IP")
lot = tong_sai.drop(list(bi_bat))
print(f"Lọt qua luật          : {len(lot)} IP, tổng cộng {lot.sum():,} lần thử sai mà không một cảnh báo nào")

Kết quả: IP có thử sai mật khẩu: 561 · Luật ngưỡng bắt được: 190 IP · Lọt qua luật: 371 IP, tổng cộng 8,244 lần thử sai mà không một cảnh báo nào

📦 dinh_10_phut = mỗi IP → số lần sai nhiều nhất trong một khung 10 phút · bi_bat = tập IP mà luật cảnh báo · tong_sai = mỗi IP → tổng số lần sai 30 ngày · lot = các IP lọt qua luật.

👉 Hiểu: luật ngưỡng bắt được 1/3 số IP dò mật khẩu sai; 2/3 lọt qua cùng hơn 8 nghìn lần thử. Đổi NGUONG_LAN xuống 3 hoặc lên 10 rồi chạy lại để xem đánh đổi.

💼 Dùng khi nào: luật cảnh báo đầu tiên của mọi SOC (fail2ban, luật SIEM, khoá tài khoản sau N lần sai).
⚖️ Ảnh hưởng tới: ngưỡng và cửa sổ quyết định ai bị chặn tự động — và ai lọt qua.

1.2 Ai đã lọt qua ngưỡng?

🎯 Mục tiêu: so 4 IP quen thuộc với luật, và soi kỹ IP luôn đứng dưới ngưỡng
📥 Đầu vào: tong_sai, dinh_10_phut, bi_bat, sai (1.1)
📤 Đầu ra: bang (bảng 4 IP) · cua_so_257

In [ ]:
cac_ip = ["130.12.182.196", "109.160.32.31", "2.57.121.112", "185.246.128.133"]
bang = pd.DataFrame({
    "tổng lần sai 30 ngày": tong_sai.reindex(cac_ip, fill_value=0),
    "đỉnh 10 phút": dinh_10_phut.reindex(cac_ip, fill_value=0).astype(int),
    "luật bắt được?": [ip in bi_bat for ip in cac_ip],
})
print(bang.to_string())
# Soi kỹ 2.57.121.112: trong MỌI khung 10 phút, nó sai bao nhiêu lần?
cua_so_257 = sai[sai.src_ip == "2.57.121.112"].eventid.rolling(CUA_SO).count()
print("\n2.57.121.112 — số lần sai trong khung 10 phút (đếm tại mỗi lần thử):")
print(cua_so_257.value_counts().sort_index().rename("số lần gặp").to_string())

Kết quả:

                 tổng lần sai 30 ngày  đỉnh 10 phút  luật bắt được?
130.12.182.196                   3310            34            True
109.160.32.31                    1989           150            True
2.57.121.112                     1225             5           False
185.246.128.133                     0             0           False

2.57.121.112 — số lần sai trong khung 10 phút:
1.0    245
2.0    245
3.0    245
4.0    245
5.0    245

👉 Hiểu: đây chính là kiểu "chậm mà chắc", nhưng là log thật:

  • 2.57.121.112 sai 1.225 lần mà luật không hề kêu. Bảng cuối cho thấy vì sao: nó tấn công đúng 245 đợt, mỗi đợt đếm 1-2-3-4-5 rồi dừng, không bao giờ chạm lần thứ 6. Kẻ tấn công biết ngưỡng 5 của ta.
  • 185.246.128.133 0 lần sai (nó đã có mật khẩu đúng) — luật đếm lần sai vô dụng.
  • Kẻ ồn ào (130.12.182.196, 109.160.32.31) thì bị bắt dễ dàng.

💼 Dùng khi nào: kiểm tra điểm mù của luật đang chạy: có kẻ nào cố tình đứng ngay dưới ngưỡng không.
⚖️ Ảnh hưởng tới: kẻ biết ngưỡng sẽ né ngưỡng → luật đơn lẻ không đủ, cần thêm lớp khác (Phần 5).

1.2b 👀 Nhìn tận mắt trong log thô: vì sao luật không kêu?

Bảng 1.2 nói 2.57.121.112 "đếm 1-2-3-4-5 rồi dừng" và 185.246.128.133 "0 lần sai". Đừng tin bảng — mở log thô ra xem.

Kẻ thứ nhất 2.57.121.112: lấy các dòng thử sai của nó, chỉ giữ 3 trường thời gian / tên / mật khẩu cho dễ đọc.

🎯 Mục tiêu: thấy bằng mắt nhịp "5 lần rồi dừng" 📥 Đầu vào: 30 file log trong /opt/csai/data/honeypot_m1 📤 Đầu ra: chỉ in ra 15 dòng, không tạo biến (ô lệnh Linux %%bash)

In [ ]:
%%bash
cd /opt/csai/data/honeypot_m1
# grep IP -> chỉ lấy dòng login.failed -> head -15 = 15 dòng đầu (lưu ý: grep -m 8 với nhiều file là 8 dòng MỖI FILE)
# grep -o = chỉ in phần khớp (3 trường) ; paste - - - = ghép 3 trường về lại 1 dòng
grep -h '"src_ip": "2.57.121.112"' cowrie.json.* | grep login.failed | head -15 \
  | grep -o '"timestamp": "[^"]*"\|"username": "[^"]*"\|"password": "[^"]*"' | paste - - -

Kết quả (15 dòng đầu, rút gọn):

"timestamp": "2026-08-25T22:32:27.575822Z"	"username": "finnegan"	"password": "finnegan"
"timestamp": "2026-08-25T22:32:28.872265Z"	"username": "finnegan"	"password": "finnegan1"
"timestamp": "2026-08-25T22:32:30.168268Z"	"username": "finnegan"	"password": "finnegan123"
"timestamp": "2026-08-25T22:32:31.460071Z"	"username": "finnegan"	"password": "finnegan1234"
"timestamp": "2026-08-25T22:32:32.751741Z"	"username": "finnegan"	"password": "finnegan12345"
"timestamp": "2026-08-26T00:15:50.130357Z"	"username": "kamal"	"password": "kamal"
...                                                                 (kamal1 ... kamal12345)
"timestamp": "2026-08-26T01:54:22.244180Z"	"username": "kohl"	"password": "kohl"
...                                                                 (kohl1 ... kohl12345)

👉 Hiểu: mỗi đợt đúng 5 lần trong khoảng 5 giây, theo một công thức: tên, tên1, tên123, tên1234, tên12345 — rồi ngắt, gần 1 giờ 40 phút sau quay lại với tên mới (finnegan → kamal → kohl). Luật "quá 5 lần trong 10 phút" không bao giờ chạm tới: kẻ tấn công biết ngưỡng và đứng ngay dưới nó.

💼 Dùng khi nào: mỗi khi một bảng tổng hợp nói điều gì "lạ", mở log thô của 1–2 IP ra kiểm chứng trước khi tin. ⚖️ Ảnh hưởng tới: người phân tích thấy được công thức của kẻ tấn công — thứ mà một con số đếm không nói ra.

Kẻ thứ hai 185.246.128.133: đếm mọi loại sự kiện của nó trong 30 ngày, xem 3 lần đăng nhập đầu tiên, và tên phần mềm nó khai.

🎯 Mục tiêu: thấy bằng mắt vì sao luật đếm lần sai "mù" trước IP này 📥 Đầu vào: 30 file log 📤 Đầu ra: chỉ in ra (ô %%bash)

In [ ]:
%%bash
cd /opt/csai/data/honeypot_m1
IP='"src_ip": "185.246.128.133"'
echo "--- Mọi loại sự kiện của nó trong 30 ngày (uniq -c = đếm số dòng giống nhau):"
grep -h "$IP" cowrie.json.* | grep -o '"eventid": "[^"]*"' | sort | uniq -c
echo "--- 3 lần đăng nhập ĐÚNG đầu tiên:"
grep -h "$IP" cowrie.json.* | grep login.success | head -3 \
  | grep -o '"timestamp": "[^"]*"\|"username": "[^"]*"\|"password": "[^"]*"' | paste - - -
echo "--- Tên phần mềm nó khai ở 5 phiên đầu:"
grep -h "$IP" cowrie.json.* | grep client.version | head -5 | grep -o '"version": "[^"]*"'
echo "--- Số tên phần mềm khác nhau trong 30 ngày:"
grep -h "$IP" cowrie.json.* | grep client.version | grep -o '"version": "[^"]*"' | sort -u | wc -l

Kết quả:

--- Mọi loại sự kiện của nó trong 30 ngày:
   1796 "eventid": "cowrie.client.version"
   1689 "eventid": "cowrie.login.success"
   1796 "eventid": "cowrie.session.connect"
--- 3 lần đăng nhập ĐÚNG đầu tiên:
"timestamp": "2026-08-24T00:03:26.050127Z"	"username": "root"	"password": "Aa112211"
"timestamp": "2026-08-24T00:20:55.811582Z"	"username": "root"	"password": "Aa112211"
"timestamp": "2026-08-24T01:01:42.498991Z"	"username": "root"	"password": "Aa112211"
--- Tên phần mềm nó khai ở 5 phiên đầu:
"version": "SSH-2.0-paramiko_2.1.2"
"version": "SSH-2.0-PuTTY_Release_0.62"
"version": "SSH-2.0-Nmap_SSH2_Hostkey"
"version": "SSH-2.0-PuTTY_Release_0.62"
"version": "SSH-2.0-PuTTY_Release_0.59"
--- Số tên phần mềm khác nhau trong 30 ngày:
55

👉 Hiểu:

  • Không có một dòng login.failed nào — chỉ có kết nối, khai tên phần mềm, và đăng nhập đúng 1.689 lần. Luật đếm lần sai không có gì để đếm.
  • Ngay ngày đầu nó đã vào bằng root / Aa112211, rồi cứ khoảng 20 phút lại vào một lần: một máy đã có mật khẩu, đang giữ chỗ.
  • Mỗi phiên khai một tên phần mềm khác (55 tên trong 30 ngày) — giả làm nhiều công cụ để khó bị nhận diện.

💼 Dùng khi nào: khi một IP "sạch" theo mọi luật đếm lỗi nhưng xuất hiện ngày nào cũng có — mở log thô xem nó làm gì sau khi vào. ⚖️ Ảnh hưởng tới: đây là kiểu kẻ tấn công nguy hiểm nhất (đã có mật khẩu) và cũng là kiểu luật ngưỡng không bao giờ thấy → lý do cần nhìn nhiều đặc trưng cùng lúc (Phần 3–5).

1.3 (TUỲ CHỌN) Thêm 7 luật SOC khác — mỗi luật nhìn một góc

🎯 Mục tiêu: chạy thêm 7 luật SOC phổ biến trên cùng log; xem mỗi luật bắt bao nhiêu IP, có bắt thêm ai mà luật 1.1 bỏ sót, và có bắt được 2 kẻ "chậm mà chắc" không 📥 Đầu vào: log (0.3) · bi_bat (1.1) 📤 Đầu ra: luat (tên luật → tập IP bị luật đó cảnh báo) · bang_luat (bảng so sánh) · so_luat (mỗi IP → bị mấy luật cùng bắt)

Luật 1.1 chỉ nhìn một góc: số lần sai trong 10 phút. Log honeypot này có 4 loại sự kiện (kết nối · phần mềm SSH tự khai · đăng nhập sai · đăng nhập được), đủ để dựng thêm 7 luật SOC hay dùng:

Luật Bắt cái gì Ngưỡng có sẵn ✏️ IP bị bắt mới (luật 1.1 không bắt) Đánh đổi
R1 dò dồn dập (1.1) thử sai liên tục > 5 sai / 10 phút 190 — né được bằng cách thử chậm (1.2)
R2 vào được sau nhiều lần sai dò mật khẩu thành công — nguy hiểm nhất ≥ 5 lần sai rồi vào được 122 41 báo khi kẻ đã vào (muộn); kẻ có sẵn mật khẩu (0 lần sai) lọt hẳn
R3 rải tài khoản (password spraying) 1 IP thử nhiều tên đăng nhập ≥ 10 tên / 1 giờ 134 31 chỉ thử 1 tài khoản (root) là né được
R4 1 tài khoản bị nhiều IP thử botnet rải IP cùng đánh 1 tài khoản ≥ 20 IP / ngày 708 (chỉ 4 tài khoản, đứng đầu là root) 525 quá nhiều IP để chặn từng cái → hành động đúng là bảo vệ tài khoản (tắt đăng nhập root, dùng khoá SSH / MFA)
R5 đăng nhập được từ IP lạ ai ngoài danh sách cho phép mà vào được có / không 658 478 máy thật: luật mạnh nhất, nhưng phải giữ danh sách cho phép chuẩn (nhân viên đi công tác, IP nhà mạng thay đổi → báo nhầm). Honeypot cố ý cho vào, nên ở đây "vào được" chưa phải chiếm được máy thật
R6 quét dồn dập gõ cửa liên tục mà không thử mật khẩu ≥ 10 kết nối / 10 phút 15 15 mới là trinh sát → ưu tiên thấp
R7 phần mềm SSH hiếm công cụ lạ, ít ai dùng phần mềm chỉ ≤ 2 IP dùng 47 39 rẻ, bắt được 185.246.128.133; nhưng tên phần mềm do kẻ tấn công tự khai, đổi được
R8 kiên trì quay lại nhiều ngày ≥ 7 ngày 113 92 bắt cả 2 kẻ "chậm mà chắc"; nhưng phải lưu log dài ngày, chỉ chạy được theo ngày, không báo tức thì

Các lựa chọn ✏️ (đo thật, số IP bị bắt): R2 ≥1 → 306 · ≥20 → 8 · R3 ≥5 → 173 · ≥20 → 71 · R4 ≥10 → 763 (64 tài khoản) · ≥50 → 465 (2 tài khoản) · R6 ≥5 → 27 · ≥20 → 1 · R8 ≥3 → 225 · ≥14 → 17.

🧰 pd.Grouper(freq="1h"): chia thời gian thành từng khung 1 giờ khi groupby (chỉ mục phải là thời gian); freq bắt buộc: "10min", "1h", "1D"… · .nunique() = đếm số giá trị khác nhau · set(...) = tập hợp không trùng; a - b = có trong a mà không có trong b; set().union(*cac_tap) = gộp mọi tập.

In [ ]:
# ✏️ SỬA: ngưỡng của từng luật (đổi rồi chạy lại để xem đánh đổi)
SAI_TRUOC_KHI_VAO = 5      # R2: vào được sau >= bao nhiêu lần sai
SO_USERNAME_1H = 10        # R3: 1 IP thử >= bao nhiêu tên đăng nhập khác nhau trong 1 giờ
SO_IP_MOT_USERNAME = 20    # R4: 1 tên đăng nhập bị >= bao nhiêu IP thử trong 1 ngày
KET_NOI_KHONG_THU = 10     # R6: >= bao nhiêu kết nối / 10 phút mà không thử mật khẩu lần nào
SO_IP_CONG_CU_HIEM = 2     # R7: phần mềm SSH chỉ <= bao nhiêu IP dùng thì coi là "hiếm"
SO_NGAY_KIEN_TRI = 7       # R8: hoạt động >= bao nhiêu ngày khác nhau

thu = log[log.eventid.str.startswith("cowrie.login")]          # mọi lần thử đăng nhập (sai + được)
vao = log[log.eventid == "cowrie.login.success"]               # các lần vào được
ket_noi = log[log.eventid == "cowrie.session.connect"]
luat = {"R1 dò dồn dập (1.1)": bi_bat}

# R2: số lần sai TRƯỚC lần vào được đầu tiên của mỗi IP
lan_vao_dau = vao.groupby("src_ip").thoi_gian.min().rename("t_vao")
sai_co_vao = log[log.eventid == "cowrie.login.failed"].join(lan_vao_dau, on="src_ip", how="inner")
sai_truoc = sai_co_vao[sai_co_vao.thoi_gian < sai_co_vao.t_vao].groupby("src_ip").size()
luat["R2 vào được sau nhiều lần sai"] = set(sai_truoc[sai_truoc >= SAI_TRUOC_KHI_VAO].index)

# R3: trong khung 1 giờ "đông" nhất, IP thử bao nhiêu tên đăng nhập khác nhau
ten_1h = (thu.set_index("thoi_gian").groupby(["src_ip", pd.Grouper(freq="1h")])
             .username.nunique().groupby(level=0).max())
luat["R3 rải tài khoản"] = set(ten_1h[ten_1h >= SO_USERNAME_1H].index)

# R4: cặp (tên đăng nhập, ngày) bị nhiều IP thử -> mọi IP tham gia
thu_ngay = thu.assign(ngay=thu.thoi_gian.dt.date)
ip_moi_ten = thu_ngay.groupby(["username", "ngay"]).src_ip.nunique()
ten_nong = ip_moi_ten[ip_moi_ten >= SO_IP_MOT_USERNAME].reset_index()[["username", "ngay"]]
luat["R4 1 tài khoản bị nhiều IP thử"] = set(thu_ngay.merge(ten_nong).src_ip)

# R5: honeypot không có "danh sách cho phép" -> mọi IP vào được đều là IP lạ
luat["R5 vào được từ IP lạ"] = set(vao.src_ip)

# R6: IP chưa thử mật khẩu lần nào, nhưng gõ cửa dồn dập
chi_go = set(ket_noi.src_ip) - set(thu.src_ip)
go_10p = (ket_noi[ket_noi.src_ip.isin(chi_go)].set_index("thoi_gian").sort_index()
             .groupby("src_ip").eventid.rolling("10min").count().groupby(level=0).max())
luat["R6 quét dồn dập"] = set(go_10p[go_10p >= KET_NOI_KHONG_THU].index)

# R7: phần mềm SSH mà rất ít IP dùng
phan_mem = log[log.eventid == "cowrie.client.version"]
so_ip_dung = phan_mem.groupby("version").src_ip.nunique()
hiem = so_ip_dung[so_ip_dung <= SO_IP_CONG_CU_HIEM].index
luat["R7 phần mềm SSH hiếm"] = set(phan_mem[phan_mem.version.isin(hiem)].src_ip)

# R8: số ngày khác nhau có hoạt động
so_ngay_hd = log.assign(ngay=log.thoi_gian.dt.date).groupby("src_ip").ngay.nunique()
luat["R8 kiên trì nhiều ngày"] = set(so_ngay_hd[so_ngay_hd >= SO_NGAY_KIEN_TRI].index)
# 📦 luat = tên luật -> tập IP bị luật đó cảnh báo

bang_luat = pd.DataFrame({ten: {"IP bị bắt": len(tap), "mới (R1 không bắt)": len(tap - bi_bat),
                                "bắt 2.57.121.112?": "2.57.121.112" in tap,
                                "bắt 185.246.128.133?": "185.246.128.133" in tap}
                          for ten, tap in luat.items()}).T
print(bang_luat.to_string())

bi_canh_bao = set().union(*luat.values())
so_luat = pd.Series({ip: sum(ip in tap for tap in luat.values()) for ip in bi_canh_bao})
# 📦 so_luat = mỗi IP bị cảnh báo -> bị mấy luật cùng bắt
print(f"\nBật cả {len(luat)} luật: {len(bi_canh_bao)} / {log.src_ip.nunique()} IP bị ít nhất 1 luật cảnh báo")
print("Số IP theo số luật cùng bắt:", so_luat.value_counts().sort_index().to_dict())
print(f"IP bị >= 4 luật cùng bắt: {(so_luat >= 4).sum()}")

Kết quả:

Luật IP bị bắt mới (R1 không bắt) bắt 2.57.121.112? bắt 185.246.128.133?
R1 dò dồn dập (1.1) 190 0 không không
R2 vào được sau nhiều lần sai 122 41 không không
R3 rải tài khoản 134 31 không không
R4 1 tài khoản bị nhiều IP thử 708 525 không có
R5 vào được từ IP lạ 658 478 không có
R6 quét dồn dập 15 15 không không
R7 phần mềm SSH hiếm 47 39 không có
R8 kiên trì nhiều ngày 113 92 có có

Bật cả 8 luật: 780 / 2906 IP bị ít nhất 1 luật cảnh báo · Số IP theo số luật cùng bắt: {1: 107, 2: 357, 3: 132, 4: 153, 5: 28, 6: 3} · IP bị >= 4 luật cùng bắt: 184

📦 luat = tên luật → tập IP bị cảnh báo · bang_luat = bảng trên · so_luat = mỗi IP → bị mấy luật cùng bắt.

👉 Hiểu: mỗi luật nhìn một góc và đều có điểm mù: chỉ R8 (kiên trì nhiều ngày) bắt được kẻ "chậm mà chắc" 2.57.121.112; R1 và R2 (đếm lần sai) không bao giờ thấy 185.246.128.133 vì nó có 0 lần sai. Bật cả 8 luật thì 780 IP bị cảnh báo trong 30 ngày, phần lớn bị 2–3 luật cùng bắt (trùng lặp) → đội SOC cần thứ tự ưu tiên: 184 IP bị ≥ 4 luật cùng bắt nên xem trước. Ý tưởng "gộp nhiều góc nhìn thành một điểm để xếp hạng" chính là điều Isolation Forest làm ở Phần 5, mà không cần tự đặt từng ngưỡng.

💼 Dùng khi nào: xây bộ luật cho SIEM / fail2ban; khi viết báo cáo "kẻ tấn công làm gì": mỗi luật là một hành vi gọi được tên (dò mật khẩu, rải tài khoản, botnet, trinh sát, công cụ lạ, kiên trì).
⚖️ Ảnh hưởng tới: số cảnh báo mỗi ngày đội SOC phải xem: thêm luật thì bắt thêm, nhưng cảnh báo trùng và báo nhầm cũng tăng; ngưỡng của từng luật quyết định ai bị chặn tự động.


Phần 2 — Chọn cách học: có giám sát hay không giám sát?

📍 Bước 3 — Chọn mô hình: có nhãn hay không quyết định dùng loại mô hình nào. Bảng tra đầy đủ ở mục Trước khi bắt đầu (đầu bài).

Học có giám sát (supervised) Học không giám sát (unsupervised)
Cần gì dữ liệu đã gắn nhãn (đáp án) chỉ dữ liệu thô, không nhãn
Máy làm gì học cách đoán đáp án cho mẫu mới tự tìm cái khác hẳn phần còn lại
Ví dụ khóa học lọc email lừa đảo (Module 1–2) log SSH (Module 3)
Thuật toán ở bài này Random Forest — Phần 9 Isolation Forest — Phần 5

Vì sao log SSH cần học KHÔNG giám sát? Không ai ngồi gắn nhãn "IP này nguy hiểm / không nguy hiểm" cho 2.906 IP mỗi tháng — và kẻ tấn công kiểu mới thì chưa có nhãn nào. Ta chỉ có thể hỏi: "IP nào hành xử khác thường so với đám đông?"

(Ở Phần 9 ta vẫn dùng học có giám sát cho một câu hỏi có sẵn đáp án trong log: IP có thử mật khẩu hay không.)


Phần 3 — Feature Engineering: biến log thô thành ngôn ngữ của AI

📍 Bước 4 — Coding: biến log thô thành bảng số, mỗi IP một hàng.

AI không đọc được dòng {"eventid": "cowrie.login.failed", ...}. Feature Engineering = trích ra các con số mô tả hành vi mỗi IP: thu thập (gom log theo IP) → trích xuất (tính đặc trưng) → chuẩn hóa (Phần 4). Mỗi IP thành một hàng số.

3.1 Bảng đặc trưng — các "câu hỏi" ta hỏi mỗi IP

Đặc trưng (tên quốc tế hay dùng) Cách tính từ log Tín hiệu bảo mật
failed_logins đếm dòng login.failed cường độ dò mật khẩu
unique_usernames số username khác nhau đã thử dò theo từ điển hay nhắm 1 tài khoản
attempt_interval_std độ lệch chuẩn khoảng cách (giây) giữa 2 lần thử liên tiếp nhỏ = đều như máy; lớn = rải rác
credential_reuse_score tỉ lệ lần thử dùng mật khẩu mà ≥ 3 IP khác cũng thử dùng chung danh sách = botnet phối hợp
so_ket_noi số lần kết nối quy mô
so_ngay số ngày có mặt kiên trì, "chậm mà chắc"
ip_cung_phan_mem số IP khác dùng cùng tên phần mềm SSH với IP này thay cho "vân tay công cụ" HASSH: cùng công cụ = cùng botnet

Bộ log 30 ngày này chưa có HASSH, lệnh gõ sau khi vào (command_count, session_speed_ms) — các đặc trưng đó cần log đầy đủ hơn (Module 5). Ta làm với những gì log thật sự có.

Viết thành một hàm để dùng lại ở Phần 6:

🎯 Mục tiêu: viết hàm biến log thô thành bảng đặc trưng (mỗi IP 1 hàng số), rồi áp dụng cho log 30 ngày
📥 Đầu vào: log
📤 Đầu ra: hàm tao_dac_trung (Phần 6 dùng lại) · F (2.906 IP × 7 đặc trưng)

🧰 tao_dac_trung(bang_log): 1 tham số bắt buộc — một bảng log có các cột eventid, src_ip, thoi_gian, username, password, version (như log). Trả về bảng đặc trưng: chỉ mục = IP, 7 cột như bảng 3.1. IP nào không có giá trị cho một cột thì để trống (NaN) hoặc 0 — xem 3.2.

In [ ]:
def tao_dac_trung(bang_log):
    """Nhận một bảng log (như 'log'), trả về bảng đặc trưng: mỗi IP 1 hàng, 7 cột."""
    kn_ = bang_log[bang_log.eventid == "cowrie.session.connect"]                                  # các lần kết nối
    dn_ = bang_log[bang_log.eventid.str.startswith("cowrie.login")].sort_values("thoi_gian")      # các lần thử đăng nhập
    sai_ = dn_[dn_.eventid == "cowrie.login.failed"]                                              # các lần thử SAI
    F_ = pd.DataFrame(index=pd.Index(kn_.src_ip.unique(), name="ip"))                             # bảng rỗng, mỗi IP 1 hàng
    # 1. failed_logins: đếm số lần sai của mỗi IP; IP không sai lần nào -> 0
    F_["failed_logins"] = sai_.groupby("src_ip").size().reindex(F_.index, fill_value=0)
    # 2. unique_usernames: số username KHÁC NHAU đã thử
    F_["unique_usernames"] = dn_.groupby("src_ip").username.nunique().reindex(F_.index, fill_value=0)
    # 3. attempt_interval_std: với mỗi IP, khoảng cách (giây) tới lần thử trước (diff), rồi lấy độ lệch chuẩn (std)
    cach = dn_.groupby("src_ip").thoi_gian.diff().dt.total_seconds()
    F_["attempt_interval_std"] = cach.groupby(dn_.src_ip).std().reindex(F_.index)
    # 4. credential_reuse_score: mỗi mật khẩu được bao nhiêu IP khác nhau thử? >= 3 IP = "mật khẩu dùng chung"
    so_ip_moi_mk = dn_.groupby("password").src_ip.nunique()
    dung_chung = dn_.password.map(so_ip_moi_mk) >= 3                                              # True/False cho từng lần thử
    F_["credential_reuse_score"] = dung_chung.groupby(dn_.src_ip).mean().reindex(F_.index, fill_value=0)
    # 5-6. so_ket_noi, so_ngay
    F_["so_ket_noi"] = kn_.groupby("src_ip").size()
    F_["so_ngay"] = kn_.groupby("src_ip").thoi_gian.apply(lambda t: t.dt.date.nunique())
    # 7. ip_cung_phan_mem: phần mềm chính của IP (mode = giá trị hay gặp nhất) được bao nhiêu IP dùng chung
    pm_ = bang_log[bang_log.eventid == "cowrie.client.version"]
    pm_chinh = pm_.groupby("src_ip").version.agg(lambda s: s.mode()[0])
    so_ip_moi_pm = pm_.groupby("version").src_ip.nunique()
    F_["ip_cung_phan_mem"] = pm_chinh.map(so_ip_moi_pm).reindex(F_.index, fill_value=0)
    return F_

F = tao_dac_trung(log)
# .shape = (số hàng, số cột) của bảng: dùng để kiểm tra nhanh kích thước sau mỗi bước
print("F.shape =", F.shape, "-> (số IP, số đặc trưng)")
# 📦 F = bảng đặc trưng: mỗi HÀNG 1 IP (tên IP ở chỉ mục bên trái), mỗi CỘT 1 đặc trưng — "ngôn ngữ" của AI
print("F:", F.shape[0], "IP x", F.shape[1], "đặc trưng")
F.loc[["1.10.195.220", "130.12.182.196", "109.160.32.31", "2.57.121.112", "185.246.128.133"]].round(2)

Kết quả: F.shape = (2906, 7) -> (số IP, số đặc trưng) · F: 2906 IP x 7 đặc trưng, rồi 5 hàng:

                 failed_logins  unique_usernames  attempt_interval_std  credential_reuse_score  so_ket_noi  so_ngay  ip_cung_phan_mem
1.10.195.220                 0                 0                   NaN                    0.00           2        1               0.0
130.12.182.196            3310                10                782.95                    0.99        3757        6             723.0
109.160.32.31             1989               434              15248.87                    0.95        2774        3             723.0
2.57.121.112              1225               244              18247.92                    0.00         261       20               3.0
185.246.128.133              0                 1               3714.81                    0.97        1796       29               1.0

📦 tao_dac_trung(bang_log) = hàm biến bảng log bất kỳ thành bảng đặc trưng (dùng lại ở Phần 6) · F = bảng đặc trưng của log thật: mỗi hàng 1 IP, 7 cột.

👉 Hiểu: 395.124 dòng chữ đã thành 2.906 hàng × 7 con số — "ngôn ngữ" mà AI đọc được. Mỗi IP có "chân dung" riêng: 2.57.121.112 thử 244 username, nhưng không dùng chung mật khẩu với ai (credential_reuse_score = 0, danh sách riêng); 185.246.128.133 có mặt 29 ngày, chỉ 1 username, dùng mật khẩu chung với nhiều IP (0,97); 130.12.182.196 dùng công cụ Go như 723 IP khác.

💼 Dùng khi nào: bước bắt buộc trước mọi mô hình máy học trên log: biến sự kiện thành bảng số, mỗi đối tượng (IP, người dùng, máy chủ) một hàng.
⚖️ Ảnh hưởng tới: đặc trưng là "đôi mắt" của mô hình: đặc trưng nào không có thì mô hình không bao giờ thấy được hành vi đó.

3.2 Ô trống (NaN) là gì, và vì sao bảng F có ô trống?

NaN (đọc là "nan", viết tắt Not a Number) = ô không có giá trị, vì không thể tính được. NaN khác 0:

nghĩa
attempt_interval_std = 0 IP thử đăng nhập nhiều lần, các lần cách nhau đều tuyệt đối (rất giống máy)
attempt_interval_std = NaN IP không có đủ lần thử để đo: 0 lần thử (không có khoảng cách nào), 1 lần (không có khoảng cách), 2 lần (chỉ có 1 khoảng cách — không đo được độ lệch giữa các khoảng cách) — không biết đều hay không đều

Nếu ta lỡ điền 0 vào chỗ NaN, máy sẽ hiểu nhầm "IP chỉ gõ cửa 1 lần" thành "IP thử đều như đồng hồ" — ngược hẳn nghĩa. Vì vậy phải hiểu ô trống trước khi xử lý.

🎯 Mục tiêu: tìm các ô trống trong F và hiểu vì sao chúng trống
📥 Đầu vào: F, log
📤 Đầu ra: tom_tat (bảng nhỏ nhất / trung vị / lớn nhất / số ô trống) · so_lan_thu, trong

In [ ]:
# Bảng tóm tắt 7 cột: nhỏ nhất / trung vị (số đứng giữa) / lớn nhất / số ô trống
tom_tat = pd.DataFrame({"nhỏ nhất": F.min(), "trung vị": F.median(), "lớn nhất": F.max(), "số ô trống (NaN)": F.isna().sum()})
print(tom_tat.round(2).to_string())

# Ô trống ở attempt_interval_std: IP đó đã thử đăng nhập bao nhiêu lần?
so_lan_thu = log[log.eventid.str.startswith("cowrie.login")].groupby("src_ip").size().reindex(F.index, fill_value=0)
trong = F.attempt_interval_std.isna()                       # True ở các IP bị trống cột này
print("\nCác IP trống attempt_interval_std đã thử đăng nhập bao nhiêu lần?")
print(so_lan_thu[trong].value_counts().sort_index().rename("số IP").to_string())
print("\nVí dụ 3 IP bị trống:")
print(F[trong].head(3).to_string())

Kết quả:

                        nhỏ nhất  trung vị   lớn nhất  số ô trống (NaN)
failed_logins                0.0      0.00    3310.00                 0
unique_usernames             0.0      0.00     500.00                 0
attempt_interval_std         0.0    143.98  730435.04              2312
credential_reuse_score       0.0      0.00       1.00                 0
so_ket_noi                   1.0      2.00    5569.00                 0
so_ngay                      1.0      1.00      30.00                 0
ip_cung_phan_mem             0.0      0.00     723.00                36

Các IP trống attempt_interval_std đã thử đăng nhập bao nhiêu lần?
0    2049
1     222
2      41

rồi 3 IP ví dụ (đều có failed_logins = 0, attempt_interval_std = NaN).

📦 so_lan_thu = mỗi IP đã thử đăng nhập bao nhiêu lần · trong = True ở các IP bị trống cột attempt_interval_std.

👉 Hiểu:

  • Trung vị gần như toàn 0 hoặc 1–2: một nửa số IP chỉ ghé 1–2 lần, không thử mật khẩu. Kẻ "nặng ký" nằm ở đuôi (3.310, 5.569...) → dữ liệu lệch rất mạnh.
  • 2.312 ô trống ở attempt_interval_std đều có lý do rõ ràng: 2.049 IP chưa thử lần nào, 222 IP thử 1 lần, 41 IP thử 2 lần (chỉ có 1 khoảng cách, không đo được độ lệch). Đó là thông tin "không đo được", không phải 0.
  • 36 ô trống ở ip_cung_phan_mem: "tên phần mềm" mà 36 IP này khai là byte nhị phân — thật ra là gói tin TLS (\\x16\\x03\\x01... = mở đầu kết nối HTTPS) hoặc RDP (Cookie: mstshash=hello = máy quét Remote Desktop) gửi nhầm vào cổng SSH; phép tra map không khớp được các chuỗi này nên để trống.
  • Phần 4a sẽ quyết định điền gì vào các ô này.

💼 Dùng khi nào: mọi bảng số có ô trống: trước khi điền phải hỏi "vì sao trống?".
⚖️ Ảnh hưởng tới: điền sai nghĩa (0 thay vì "chưa đo được") → mô hình hiểu ngược hành vi.


Phần 4 — Chuẩn bị bảng số trước khi đưa cho AI

📍 Bước 2 — Làm sạch (lần 2, trên bảng số): điền ô trống, nén số lớn, chuẩn hoá. 4d là một lưu ý khi coding (bước 5): bỏ bước nào thì mô hình nào hỏng.

Phần 1–3 nói bằng ngôn ngữ SOC (IP, mật khẩu, số ngày). Phần này chuyển sang vài khái niệm thống kê — đọc chậm, mỗi bước đều có ví dụ bằng số thật và một câu so sánh đời thường.

Vì sao không đưa thẳng bảng F cho AI?

Bảng F còn 3 vấn đề, mỗi vấn đề một bước xử lý:

Vấn đề trong bảng F Hình ảnh đời thường Bước xử lý
Có ô trống (2.312 ô ở attempt_interval_std) thí sinh vắng một môn thi: không đuổi về (xóa dòng), cũng không chấm 0 4a. điền ô trống
Có số khổng lồ: so_ket_noi từ 1 đến 5.569, nhưng 84% IP chỉ ≤ 10 độ to âm thanh hay động đất trải từ rất nhỏ tới cực lớn → người ta dùng thang decibel / Richter 4b. nén số lớn (log1p)
Mỗi cột một đơn vị: lần, ngày, giây, tỉ lệ 0–1 so chiều cao (cm) với điểm thi (thang 10): phải quy về "hơn mức trung bình mấy bậc" mới so được 4c. chuẩn hóa (StandardScaler)
┌──────────────────┐        ┌──────────────────┐        ┌──────────────────┐        ┌──────────────────┐
│     BẢNG F       │  4a    │      F_du        │  4b    │      F_log       │  4c    │     F_chuan      │
│ gốc, còn ô trống │ ─────► │ đã điền đủ số    │ ─────► │ số lớn đã nén    │ ─────► │ cùng một thước đo│ ──► đưa vào AI (Phần 5)
└──────────────────┘        └──────────────────┘        └──────────────────┘        └──────────────────┘

Bước nào quan trọng với mô hình nào? (đo thật ở 4d cuối phần này)

Bước Isolation Forest, Random Forest (mô hình dạng cây) Mô hình đo khoảng cách (kNN, K-Means), Logistic Regression, mạng nơ-ron
4a điền ô trống không bắt buộc (scikit-learn 1.9 tự xử lý ô trống) — ta vẫn điền để tự quyết định ý nghĩa bắt buộc — gặp ô trống là báo lỗi Input X contains NaN
4b nén log1p quan trọng quan trọng
4c chuẩn hóa không đổi kết quả — cây chỉ hỏi "lớn hơn ngưỡng không?" trong từng cột riêng bắt buộc — cột có số to sẽ áp đảo khoảng cách

4a. Điền ô trống

Có những cách nào?

Cách Ưu Nhược
Xóa dòng có ô trống đơn giản mất 2.312 IP — gần 80% dữ liệu!
Điền 0 đơn giản ở đây sai nghĩa: 0 = "đều tuyệt đối như máy"
Điền trung bình (mean) quen thuộc bị kéo lệch bởi vài giá trị khổng lồ
Điền trung vị (median) không bị giá trị khổng lồ kéo lệch ← ta chọn cách này
Điền một số đặc biệt (ví dụ −1) + thêm cột "có bị trống không" giữ được thông tin "đã từng trống" thêm cột, phức tạp hơn

Trung vị là gì? Xếp các số từ nhỏ đến lớn, lấy số đứng giữa. Ví dụ 5 số 1, 2, 2, 3, 1000:

  • trung bình = (1 + 2 + 2 + 3 + 1000) / 5 = 201,6 — bị con số 1000 kéo vọt lên, chẳng giống số nào;
  • trung vị = số đứng thứ 3 = 2 — đúng "mức thường gặp".

🎯 Mục tiêu: điền ô trống bằng trung vị của từng cột
📥 Đầu vào: F
📤 Đầu ra: trung_vi_cot (7 trung vị, Phần 7–8 dùng lại) · F_du (F không còn ô trống)

🧰 F.fillna(gia_tri): gia_tri bắt buộc — một số (điền cùng số cho mọi ô) hoặc một dãy theo tên cột (như trung_vi_cot: mỗi cột điền số của riêng nó).

In [ ]:
# ==============================================================================
# BƯỚC 4a: ĐIỀN Ô TRỐNG
# ==============================================================================
# Vì sao ô bị trống? attempt_interval_std đo "các lần thử mật khẩu cách nhau có đều không".
#   IP thử 0, 1 hoặc 2 lần thì không đủ khoảng cách để đo -> NaN (xem 3.2).
# Vì sao KHÔNG điền 0? Độ lệch = 0 nghĩa là "đều tăm tắp như đồng hồ" = dấu hiệu của MÁY.
#   Điền 0 sẽ biến một IP gõ cửa 1 lần rồi đi thành "siêu bot đều nhịp" -> sai hẳn nghĩa.
# Giải pháp: điền TRUNG VỊ của chính cột đó = coi IP "chưa đo được" là IP bình thường nhất
#   về mặt này: không phạt, không thưởng.

cot = F.attempt_interval_std
print(f"attempt_interval_std: trung bình = {cot.mean():,.0f} giây | trung vị = {cot.median():,.0f} giây")
print("-> vài IP 'rải rác cả tháng' kéo trung bình lên hơn 7 tiếng; trung vị (2,4 phút) mới là mức thường gặp")

trung_vi_cot = F.median()        # F.median() = trung vị của TỪNG cột (7 số, mỗi cột 1 số)
F_du = F.fillna(trung_vi_cot)    # fillna = điền vào ô trống; cột nào dùng trung vị của cột đó
# 📦 trung_vi_cot = 7 trung vị (Phần 7, 8 dùng lại) · F_du = bảng F đã điền đủ số

print("\nTrung vị dùng để điền:")
print(trung_vi_cot.round(2).to_string())
print("\nSố ô trống còn lại:", int(F_du.isna().sum().sum()))
print("Ví dụ IP 1.10.195.220 (Phần 0, chưa thử mật khẩu lần nào):")
print("   TRƯỚC: attempt_interval_std =", F.loc["1.10.195.220", "attempt_interval_std"], "(trống)")
print("   SAU  : attempt_interval_std =", round(F_du.loc["1.10.195.220", "attempt_interval_std"], 2), "(mức bình thường)")

Kết quả:

attempt_interval_std: trung bình = 26,008 giây | trung vị = 144 giây
Trung vị dùng để điền: failed_logins 0 · unique_usernames 0 · attempt_interval_std 143.98 · credential_reuse_score 0 · so_ket_noi 2 · so_ngay 1 · ip_cung_phan_mem 0
Số ô trống còn lại: 0
Ví dụ IP 1.10.195.220: TRƯỚC = nan (trống) | SAU = 143.98 (mức bình thường)

📦 trung_vi_cot = 7 trung vị, mỗi cột một số · F_du = bảng F đã điền đủ số.

👉 Hiểu:

  • Nếu điền trung bình (26.008 giây ≈ 7 tiếng), ta sẽ gán cho 2.312 IP chưa đo được một "nhịp rất rải rác" — một con số chẳng giống ai, do vài IP cá biệt kéo lên. Trung vị (144 giây ≈ 2,4 phút) mới là mức mà một IP bình thường có.
  • Mỗi cột được điền bằng trung vị của chính nó: cột thời gian điền 143,98, cột ip_cung_phan_mem điền 0. Không cột nào bị "mượn" số của cột khác.

💼 Dùng khi nào: dữ liệu thật luôn thiếu: máy chủ mất log vài giờ, IP chỉ xuất hiện 1 lần, thiết bị không gửi trường đó.
⚖️ Ảnh hưởng tới: xoá dòng thiếu có thể mất gần hết dữ liệu; điền bừa thì sai nghĩa — cách điền là một quyết định phải ghi lại.

4b. Nén số lớn bằng log1p

np.log1p(x) = log(1 + x). Giống thang Richter: động đất cấp 8 có biên độ rung gấp 100 lần cấp 6, nhưng trên giấy ta chỉ ghi 6 và 8. Số lớn vẫn lớn hơn (thứ tự giữ nguyên), nhưng khoảng cách bị co lại. Cộng 1 để số 0 vẫn là 0 (log của 0 không tính được).

Vì sao quan trọng với Isolation Forest? Mỗi nhát chặt chọn một ngưỡng ngẫu nhiên đều giữa giá trị nhỏ nhất và lớn nhất của cột. Cột so_ket_noi kéo dài từ 1 đến 5.569 nhưng 84% IP nằm trong đoạn 1–10. Không nén thì gần như mọi nhát chặt rơi vào khoảng mênh mông 10–5.569, nơi chỉ có vài IP "khổng lồ" → rừng chỉ mải tách các IP ồn ào nhất, bỏ quên kẻ chậm mà chắc (4d sẽ cho thấy).

Hình dung bằng cây to, cây nhỏ: xếp 2.906 IP thành hàng cây dọc một con đường, mỗi cây đứng ở vị trí = số kết nối của IP đó. Đường dài từ mét 1 đến mét 5.569. 84% số cây (IP nhỏ) chen chúc trong 10 mét đầu; vài cây to (IP khổng lồ) đứng rải rác trên quãng đường còn lại. Người tiều phu nhắm mắt bổ dao xuống một điểm ngẫu nhiên trên con đường (đó là một nhát chặt):

  • Không nén: đoạn 10 mét đầu chỉ chiếm 0,16% chiều dài đường → gần như nhát nào cũng rơi vào quãng của mấy cây to, chỉ tách được cây to ra khỏi nhau. Đám đông 84% gần như không bao giờ bị chạm tới, nên kẻ "chậm mà chắc" lẫn trong đó không được tách riêng. lúc nào cũng chặt vào cây to.
  • Sau log1p: con đường bị "co" lại (1 → 0,69; 10 → 2,40; 5.569 → 8,63), đoạn của đám đông chiếm 21% chiều dài → cứ khoảng 5 nhát lại có 1 nhát rơi vào đám đông, rừng mới có cơ hội tách kẻ lạ ở trong đó.

(Đây là nhát chặt đầu tiên của một cây; các nhát sau chặt trong từng nhóm nhỏ hơn, nhưng xu hướng giống vậy.)

🎯 Mục tiêu: nén số lớn bằng log1p để vài IP khổng lồ không lấn át
📥 Đầu vào: F_du
📤 Đầu ra: F_log

In [ ]:
# ==============================================================================
# BƯỚC 4b: NÉN SỐ LỚN BẰNG log1p
# ==============================================================================
# Bảng minh họa: cột phải "co" lại thế nào so với cột trái
vd = pd.Series([0, 1, 2, 10, 100, 1000, 3310], name="x")
print(pd.DataFrame({"x (số gốc)": vd, "log1p(x)": np.log1p(vd).round(2)}).to_string(index=False))
print(f"\n3310 lớn gấp {3310 / 2:,.0f} lần 2 | sau log1p chỉ còn gấp {np.log1p(3310) / np.log1p(2):.1f} lần")

F_log = np.log1p(F_du)           # áp dụng cho MỌI ô của bảng (mọi cột đều >= 0 nên dùng được)
# 📦 F_log = bảng F_du sau khi nén

print("\nHai IP quen thuộc — số gốc -> sau khi nén:")
for ip, mo_ta in [("130.12.182.196", "kẻ xả lũ"), ("185.246.128.133", "kẻ trinh sát 29 ngày")]:
    print(f"  {ip:16s} ({mo_ta:20s}): so_ket_noi {F_du.loc[ip, 'so_ket_noi']:5.0f} -> {F_log.loc[ip, 'so_ket_noi']:.2f}"
          f" | so_ngay {F_du.loc[ip, 'so_ngay']:3.0f} -> {F_log.loc[ip, 'so_ngay']:.2f}")

Kết quả: bảng 0 → 0.00, 1 → 0.69, 2 → 1.10, 10 → 2.40, 100 → 4.62, 1000 → 6.91, 3310 → 8.11 · 3310 lớn gấp 1,655 lần 2 | sau log1p chỉ còn gấp 7.4 lần, rồi:

130.12.182.196   (kẻ xả lũ            ): so_ket_noi  3757 -> 8.23 | so_ngay   6 -> 1.95
185.246.128.133  (kẻ trinh sát 29 ngày): so_ket_noi  1796 -> 7.49 | so_ngay  29 -> 3.40

📦 F_log = bảng sau khi nén.

👉 Hiểu: đọc bảng minh họa từ trên xuống: từ 0 lên 10 tăng 2,4 đơn vị, nhưng từ 100 lên 1000 (gấp 10 lần) cũng chỉ tăng 2,3 đơn vị. Tức là log1p đo "gấp mấy lần" chứ không đo "hơn bao nhiêu". Nhờ vậy 3.757 kết nối và 29 ngày giờ là 8,23 và 3,40 — cùng một "tầm nhìn", không con số nào đè bẹp con số nào.

💼 Dùng khi nào: mọi số liệu kiểu "đa số rất nhỏ, vài cái khổng lồ": số kết nối, dung lượng tải về, số byte, số lần đăng nhập.
⚖️ Ảnh hưởng tới: không nén thì vài đối tượng khổng lồ chiếm hết sự chú ý của mô hình, kẻ lặng lẽ bị bỏ quên.

4c. Chuẩn hóa bằng StandardScaler: bỏ đơn vị, chỉ hỏi "hơn mức thường mấy bậc?"

StandardScaler là gì? Là một công cụ có sẵn của thư viện scikit-learn (thư viện máy học dùng suốt khóa), làm đúng một việc: đưa mỗi cột số về cùng một thang đo "hơn hay kém mức trung bình bao nhiêu bậc". Tên gọi: Standard = chuẩn, Scaler = bộ đổi thang đo. Nó không tự quyết định gì, không biết IP nào xấu — chỉ là một phép đổi đơn vị, giống quy điểm của nhiều môn thi (thang 10, thang 100, thang 4) về cùng một thang để so được với nhau.

Với mỗi cột, StandardScaler tính 2 con số trên toàn bộ IP:

  • trung bình (TB) của cột;
  • độ lệch chuẩn (ĐLC) = trung bình các giá trị cách xa TB bao nhiêu (cột mà các IP rất khác nhau thì ĐLC lớn).

Rồi đổi mỗi giá trị x thành z = (x − TB) / ĐLC — giống điểm thi được quy về "hơn điểm trung bình của lớp mấy bậc":

z nghĩa
0 đúng bằng mức trung bình của 2.906 IP
+1 cao hơn trung bình 1 bậc lệch — hơi khác thường
+3 trở lên khác thường rõ rệt, hiếm
+6 trở lên cực kỳ hiếm
−0,5 thấp hơn trung bình một chút

Cách dùng: chuan_hoa.fit(...) = học TB và ĐLC của từng cột; chuan_hoa.transform(...) = áp dụng công thức; fit_transform = làm cả hai một lượt.

Với Isolation Forest, bước này không đổi kết quả (4d đo chênh lệch đúng bằng 0). Ta vẫn làm vì: (1) bảng z dễ đọc cho người — đọc là biết ngay ô nào "khác thường"; (2) giữ cùng một quy trình nếu sau này thay bằng mô hình đo khoảng cách, vốn bắt buộc phải chuẩn hóa.

🎯 Mục tiêu: đưa 7 cột về cùng một thước đo (điểm z)
📥 Đầu vào: F_log
📤 Đầu ra: chuan_hoa (bộ chuẩn hoá đã học, Phần 8 dùng lại) · F_chuan (đầu vào của Isolation Forest)

🧰 StandardScaler(with_mean=True, with_std=True): không có tham số bắt buộc · with_mean tuỳ chọn: True (mặc định, trừ trung bình) / False · with_std tuỳ chọn: True (mặc định, chia độ lệch chuẩn) / False. Dùng: .fit(bang) học, .transform(bang) áp dụng, .fit_transform(bang) cả hai. Học xong thì xem được .mean_ (trung bình từng cột) và .scale_ (độ lệch chuẩn từng cột).

In [ ]:
# ==============================================================================
# BƯỚC 4c: QUY VỀ CÙNG MỘT THƯỚC ĐO BẰNG StandardScaler
# ==============================================================================
from sklearn.preprocessing import StandardScaler
chuan_hoa = StandardScaler()
F_chuan = pd.DataFrame(chuan_hoa.fit_transform(F_log), index=F.index, columns=F.columns)
# 📦 chuan_hoa = bộ chuẩn hóa đã HỌC TB/ĐLC của 7 cột (Phần 7, 8 dùng lại) · F_chuan = bảng đưa vào Isolation Forest

# Tự tính lại bằng tay 1 ô để thấy công thức không có gì bí ẩn: cột so_ngay của 185.246.128.133
i = list(F.columns).index("so_ngay")
tb, dlc = chuan_hoa.mean_[i], chuan_hoa.scale_[i]           # TB và ĐLC mà chuan_hoa học được cho cột so_ngay (trên F_log)
x = F_log.loc["185.246.128.133", "so_ngay"]
print(f"so_ngay của 185.246.128.133: gốc 29 ngày -> log1p = {x:.3f}")
print(f"TB cột = {tb:.3f}, ĐLC cột = {dlc:.3f} -> z = ({x:.3f} - {tb:.3f}) / {dlc:.3f} = {(x - tb) / dlc:.2f}")
print(f"StandardScaler cho ra: {F_chuan.loc['185.246.128.133', 'so_ngay']:.2f}  <- khớp")

print("\nSO SÁNH TRÊN CÙNG MỘT THƯỚC ĐO (điểm z):")
for ip, mo_ta in [("130.12.182.196", "kẻ xả lũ"), ("185.246.128.133", "kẻ trinh sát 29 ngày")]:
    print(f"  {ip} ({mo_ta}): số lần sai z = {F_chuan.loc[ip, 'failed_logins']:+.2f} | "
          f"số kết nối z = {F_chuan.loc[ip, 'so_ket_noi']:+.2f} | số ngày có mặt z = {F_chuan.loc[ip, 'so_ngay']:+.2f}")
print("\nKiểm tra: TB mỗi cột sau chuẩn hóa =", F_chuan.mean().round(2).tolist(), "| ĐLC =", F_chuan.std(ddof=0).round(2).tolist())

Kết quả:

so_ngay của 185.246.128.133: gốc 29 ngày -> log1p = 3.401
TB cột = 0.822, ĐLC cột = 0.380 -> z = (3.401 - 0.822) / 0.380 = 6.79
StandardScaler cho ra: 6.79  <- khớp

SO SÁNH TRÊN CÙNG MỘT THƯỚC ĐO (điểm z):
  130.12.182.196 (kẻ xả lũ): số lần sai z = +5.85 | số kết nối z = +4.64 | số ngày có mặt z = +2.96
  185.246.128.133 (kẻ trinh sát 29 ngày): số lần sai z = -0.37 | số kết nối z = +4.13 | số ngày có mặt z = +6.79
Kiểm tra: TB mỗi cột sau chuẩn hóa = [0.0, ...] | ĐLC = [1.0, ...]

📦 chuan_hoa = bộ chuẩn hóa đã học TB/ĐLC của 7 cột · F_chuan = bảng đưa vào Isolation Forest.

👉 Hiểu:

  • Tự tính bằng tay ra đúng 6,79 như máy → công thức không có gì bí ẩn.
  • Giờ mọi con số đều cùng một nghĩa: "hơn mức thường mấy bậc". Đọc là thấy ngay "chân dung" 2 kẻ tấn công:
    • kẻ xả lũ nổi bật nhất ở số lần sai (+5,85);
    • kẻ trinh sát nổi bật nhất ở số ngày có mặt (+6,79 — hiếm hơn cả số lần sai của kẻ xả lũ), còn số lần sai của nó lại thấp hơn bình thường (−0,37) vì nó không sai lần nào.
  • Bảng số gốc (lần, ngày) không cho ta so sánh kiểu này.

💼 Dùng khi nào: trước mọi mô hình đo khoảng cách (kNN, K-Means ở Module 7) và mạng nơ-ron; và khi muốn đọc nhanh "ô nào khác thường".
⚖️ Ảnh hưởng tới: không chuẩn hoá thì cột có đơn vị to (giây, byte) lấn át cột đơn vị nhỏ (tỉ lệ 0–1).

4d. Thí nghiệm: bỏ từng bước thì chuyện gì xảy ra?

Ô dưới thử 2 loại mô hình trên cùng bảng, với từng cách chuẩn bị:

  • Isolation Forest (Phần 5): mô hình dạng cây.
  • kNN (k hàng xóm gần nhất): mô hình đo khoảng cách — IP nào càng xa 5 IP giống nó nhất thì càng bất thường.

Ta xem 2 kẻ "chậm mà chắc" của Phần 1 đứng hạng mấy (hạng 1 = bất thường nhất).

🎯 Mục tiêu: đo xem bỏ từng bước chuẩn bị (4a/4b/4c) thì 2 loại mô hình xếp hạng 2 kẻ "chậm mà chắc" thế nào
📥 Đầu vào: F_du, F_log, F_chuan
📤 Đầu ra: 2 hàm xep_hang, chuan · bảng so sánh (không dùng lại ở đâu)

🧰 xep_hang(bang, mo_hinh): cả 2 bắt buộc — bang là bảng số (mỗi IP 1 hàng); mo_hinh = "Isolation Forest" hoặc chữ bất kỳ khác (→ dùng kNN). Trả về thứ hạng của mỗi IP (1 = bất thường nhất). chuan(bang): 1 tham số bắt buộc, trả về bảng đã chuẩn hoá bằng StandardScaler.

In [ ]:
from sklearn.ensemble import IsolationForest
from sklearn.neighbors import NearestNeighbors

def xep_hang(bang, mo_hinh):
    """Trả về thứ hạng bất thường của mỗi IP (1 = bất thường nhất) theo 1 mô hình."""
    if mo_hinh == "Isolation Forest":
        m = IsolationForest(n_estimators=300, random_state=RANDOM_STATE).fit(bang)
        diem_ = m.decision_function(bang)                                    # càng âm càng bất thường
    else:
        lang_gieng = NearestNeighbors(n_neighbors=6).fit(bang)               # 6 = chính nó + 5 hàng xóm
        khoang_cach = lang_gieng.kneighbors(bang)[0][:, 1:].mean(axis=1)     # khoảng cách trung bình tới 5 hàng xóm
        diem_ = -khoang_cach                                                 # càng xa -> càng âm -> càng bất thường
    return pd.Series(diem_, index=bang.index).rank().astype(int)

def chuan(bang):
    return pd.DataFrame(StandardScaler().fit_transform(bang), index=bang.index, columns=bang.columns)

cach_chuan_bi = {
    "chỉ điền ô trống (F_du)": F_du,
    "+ chuẩn hóa, KHÔNG nén": chuan(F_du),
    "+ nén log1p, KHÔNG chuẩn hóa (F_log)": F_log,
    "+ nén + chuẩn hóa (F_chuan, đầy đủ)": F_chuan,
}
dong = []
for ten, bang in cach_chuan_bi.items():
    for mo_hinh in ["Isolation Forest", "kNN (đo khoảng cách)"]:
        h = xep_hang(bang, mo_hinh)
        dong.append({"cách chuẩn bị": ten, "mô hình": mo_hinh,
                     "185.246.128.133 hạng": h["185.246.128.133"], "2.57.121.112 hạng": h["2.57.121.112"]})
pd.DataFrame(dong).set_index(["cách chuẩn bị", "mô hình"])

Kết quả:

cách chuẩn bị Isolation Forest: 185.246.128.133 Isolation Forest: 2.57.121.112 kNN: 185.246.128.133 kNN: 2.57.121.112
chỉ điền ô trống (F_du) 17 1 93 98
+ chuẩn hóa, KHÔNG nén 17 1 5 3
+ nén log1p, KHÔNG chuẩn hóa 3 1 5 1
+ nén + chuẩn hóa (đầy đủ) 3 1 3 1

📦 xep_hang(bang, mo_hinh) = hàm trả về thứ hạng bất thường của mọi IP · chuan(bang) = chuẩn hóa nhanh một bảng.

👉 Hiểu (đọc bảng theo từng cột):

  • kNN (mô hình đo khoảng cách) không chuẩn bị gì thì gần như mù: 2 kẻ chậm mà chắc tụt xuống hạng 93 và 98, vì khoảng cách bị cột so_ket_noi (tới 5.569) áp đảo, cột so_ngay (tối đa 30) gần như không được tính. Chỉ cần chuẩn hóa là lên hạng 5 và 3; làm đủ cả hai bước thì lên hạng 3 và 1.
  • Isolation Forest: hàng "chuẩn hóa, không nén" giống hệt hàng "chỉ điền ô trống" (17 và 1), và hàng "nén, không chuẩn hóa" giống hệt hàng "đầy đủ" (3 và 1). Tức là chuẩn hóa không đổi gì, còn nén log1p đưa kẻ trinh sát từ hạng 17 lên hạng 3. Lý do: cây chỉ hỏi "lớn hơn ngưỡng không?" trong từng cột — đổi thước đo không đổi câu trả lời, nhưng log1p làm các nhát chặt ngẫu nhiên rơi đều vào chỗ có nhiều IP, thay vì phí vào khoảng trống mênh mông giữa 10 và 5.569 kết nối.
  • Bài học: mỗi bước chuẩn bị có lý do của nó, và tùy mô hình mà bước nào là bắt buộc. Không chắc thì làm như ô này: thử bỏ từng bước và đo.

💼 Dùng khi nào: đổi sang một mô hình khác: kiểm tra bước chuẩn bị nào bắt buộc với mô hình đó.
⚖️ Ảnh hưởng tới: bỏ nhầm một bước có thể đẩy kẻ nguy hiểm nhất từ hạng đầu xuống hạng hàng trăm.

Lưu ý khi có chia học / thi (Phần 9): TB và ĐLC phải học chỉ từ phần học (fit trên phần học), rồi áp dụng y nguyên cho phần thi (transform). Nếu học TB/ĐLC từ cả phần thi, thông tin đề thi đã "lọt" vào lúc học. Ở Phần 5, Isolation Forest học không giám sát trên toàn bộ IP, không có phần thi, nên fit trên tất cả.


Phần 5 — Isolation Forest ("rừng cô lập"): tìm IP bất thường mà không cần đáp án

📍 Bước 4 — Coding (5.1–5.3: dựng rừng, xếp hạng IP) rồi Bước 6 — Đánh giá (5.4–5.6: so với luật ngưỡng, chọn số IP gắn cờ).

5.0 Nhắc lại: ta đang có gì?

log (395.124 dòng)  ──tao_dac_trung──>  F (2.906 IP × 7 cột)  ──4a, 4b, 4c──>  F_chuan (2.906 IP × 7 cột, cùng thước đo)

F_chuan là đầu vào duy nhất của Isolation Forest. Không có cột đáp án — máy không được ai nói IP nào là xấu.

⚠️ Ở honeypot gần như IP nào cũng xấu — vậy "bất thường" nghĩa là gì?

Isolation Forest không biết "xấu" hay "tốt". Nó chỉ tìm IP khác đám đông. Vì vậy chữ "bất thường" mang nghĩa khác nhau tùy đám đông là ai:

Nơi dùng Đám đông (đa số) là "Bất thường" nghĩa là Dùng để
máy chủ thật (SSH, web của công ty) người dùng và dịch vụ hợp lệ hành vi lạ → nghi là tấn công phát hiện tấn công
honeypot (bài này) 2.049 IP (70,5%) chỉ gõ cửa 1–2 lần rồi đi: máy quét hàng loạt kẻ tấn công hành xử khác đám máy quét: kiên trì nhiều ngày, có mật khẩu đúng, nhịp lạ, công cụ hiếm xếp hạng ưu tiên: trong hàng nghìn kẻ tấn công, kẻ nào đáng người phân tích xem trước

Bằng chứng (đo ở 5.6): trong 146 IP bị gắn cờ, 139 IP (95%) có thử mật khẩu, trong khi cả kho chỉ có 29,5% IP thử mật khẩu → máy tự đẩy những kẻ "ra tay thật" lên trên đám máy quét. Ở đây câu hỏi đúng không phải "IP này có xấu không?" (đã biết: gần như đều xấu) mà là "kẻ nào khác thường nhất, đáng điều tra trước?" — đúng việc của đội SOC khi mỗi ngày có hàng nghìn cảnh báo.

Honeypot là chỗ tốt để tập vì biết chắc đa số là tấn công. Mang mô hình sang máy chủ thật thì phải học lại trên dữ liệu của máy đó: đám đông khác → "bình thường" khác.

Ý tưởng — trò chơi "tách riêng":

  1. Chọn ngẫu nhiên 1 cột (ví dụ so_ngay) và 1 ngưỡng ngẫu nhiên trong khoảng giá trị của cột → chia các IP thành 2 nhóm (nhỏ hơn / lớn hơn ngưỡng). Đó là một nhát chặt.
  2. Lặp lại trong từng nhóm, cho tới khi một IP đứng một mình.
  3. IP khác thường (ví dụ duy nhất có mặt 29 ngày) thường bị tách riêng sau rất ít nhát. IP bình thường lẫn trong đám đông hàng trăm IP giống hệt nhau — chặt mãi vẫn chưa tách ra.

Một lần chặt như vậy là một cây. Rừng = nhiều cây, mỗi cây chặt ngẫu nhiên theo cách khác nhau trên 256 IP chọn ngẫu nhiên, sâu tối đa 8 tầng (vì 2⁸ = 256). Kết luận của rừng = số nhát chặt trung bình qua mọi cây.

5.1 Xây rừng

🎯 Mục tiêu: xây rừng 300 cây trên F_chuan
📥 Đầu vào: F_chuan (4c) — không có cột đáp án
📤 Đầu ra: rung_co_lap (rừng đã xây; 5.2 → 8 đều dùng) · CONTAMINATION

🧰 Tham số của IsolationForest(...) — không có tham số bắt buộc, tất cả tuỳ chọn:

Tham số Mặc định Các lựa chọn Ở đây
n_estimators 100 số cây; nhiều cây → kết luận ổn định hơn, chạy lâu hơn 300
max_samples "auto" = 256 IP mỗi cây (hoặc tất cả nếu ít hơn 256) một số nguyên (số IP) hoặc tỉ lệ 0.5 mặc định
contamination "auto" "auto" hoặc tỉ lệ từ trên 0 đến 0,5 — xem kỹ ở 5.6 0.05
max_features 1.0 (mọi cột) tỉ lệ / số cột mỗi cây được dùng mặc định
random_state None (mỗi lần chạy một khác) một số bất kỳ → lần nào chạy cũng ra cùng kết quả 42
n_jobs None (1 nhân CPU) số nhân CPU dùng song song mặc định

Các lựa chọn ✏️ CONTAMINATION: 0.01 → 30 IP · 0.05 (có sẵn) → 146 · 0.10 → 291 · 0.20 → 581 IP bị gắn cờ (bảng đầy đủ ở 5.6).

In [ ]:
from sklearn.ensemble import IsolationForest
# ✏️ SỬA: tỉ lệ IP coi là bất thường. Thử 0.01, 0.10, 0.20
CONTAMINATION = 0.05
rung_co_lap = IsolationForest(
    n_estimators=300,               # số cây trong rừng (càng nhiều, kết luận càng ổn định)
    contamination=CONTAMINATION,    # đoán trước khoảng bao nhiêu phần IP là bất thường -> dùng để đặt ranh giới ở 5.3
    random_state=RANDOM_STATE,      # cố định cách chọn ngẫu nhiên -> lần nào chạy cũng ra cùng kết quả
)
rung_co_lap.fit(F_chuan)            # "học" = xây 300 cây trên F_chuan. Chỉ đưa F_chuan, KHÔNG đưa đáp án nào
# 📦 rung_co_lap = rừng 300 cây đã xây xong
print("Số cây:", len(rung_co_lap.estimators_), "| mỗi cây xây trên", rung_co_lap.max_samples_, "IP chọn ngẫu nhiên")

Kết quả: Số cây: 300 | mỗi cây xây trên 256 IP chọn ngẫu nhiên

👉 Hiểu: fit không cần đáp án: máy chỉ xây 300 cây "chặt ngẫu nhiên". Mỗi cây học trên 256 IP chọn ngẫu nhiên nên các cây khác nhau — rừng càng đa dạng, kết luận càng chắc.

💼 Dùng khi nào: khi chưa có nhãn: log mới, hệ thống mới, kiểu tấn công chưa ai gặp.
⚖️ Ảnh hưởng tới: chọn số cây và random_state quyết định kết quả có ổn định giữa các lần chạy không.

5.2 Đếm số nhát chặt THẬT trong rừng

🎯 Mục tiêu: đếm số nhát chặt thật trong từng cây cho 3 IP
📥 Đầu vào: rung_co_lap (5.1), F_chuan
📤 Đầu ra: hàm so_nhat_chat

🧰 so_nhat_chat(ip): 1 tham số bắt buộc (chuỗi IP có trong F_chuan). Trả về mảng 300 số: số nhát chặt của IP đó trong từng cây.

In [ ]:
def so_nhat_chat(ip):
    """Đếm, trong từng cây của rừng, phải chặt bao nhiêu nhát thì IP này tới được ô lá cuối cùng."""
    x = F_chuan.loc[[ip]].to_numpy()
    ket_qua = []
    for cay, cot_cua_cay in zip(rung_co_lap.estimators_, rung_co_lap.estimators_features_):
        # decision_path = các nút cây mà IP đi qua từ gốc tới lá; số nút - 1 = số nhát chặt
        ket_qua.append(cay.decision_path(x[:, cot_cua_cay]).sum() - 1)
    return np.array(ket_qua)

for ip, mo_ta in [("1.10.195.220", "máy quét bình thường (Phần 0)"), ("185.246.128.133", "kẻ trinh sát 29 ngày"), ("2.57.121.112", "kẻ dò 5 lần rồi dừng")]:
    n = so_nhat_chat(ip)
    print(f"{ip:16s} ({mo_ta:30s}): cây số 1 chặt {n[0]} nhát | trung bình 300 cây: {n.mean():.1f} nhát (ít nhất {n.min()}, nhiều nhất {n.max()})")

Kết quả:

1.10.195.220     (máy quét bình thường (Phần 0) ): cây số 1 chặt 8 nhát | trung bình 300 cây: 7.7 nhát (ít nhất 4, nhiều nhất 8)
185.246.128.133  (kẻ trinh sát 29 ngày          ): cây số 1 chặt 8 nhát | trung bình 300 cây: 4.6 nhát (ít nhất 1, nhiều nhất 8)
2.57.121.112     (kẻ dò 5 lần rồi dừng          ): cây số 1 chặt 3 nhát | trung bình 300 cây: 4.1 nhát (ít nhất 1, nhiều nhất 8)

📦 so_nhat_chat(ip) = hàm trả về 300 con số: số nhát chặt của IP trong từng cây.

👉 Hiểu:

  • Máy quét bình thường 1.10.195.220 gần như luôn chặt hết 8 tầng (trung bình 7,7) mà vẫn chưa tách được — vì có hàng trăm IP y hệt nó (2 kết nối, 1 ngày, không thử mật khẩu).
  • Hai kẻ "chậm mà chắc" chỉ cần khoảng 4 nhát; có cây tách được chúng chỉ sau 1 nhát (chọn trúng cột so_ngay và một ngưỡng như "> 15 ngày" là chúng đứng một mình ngay).
  • Một cây đơn lẻ có thể "xui" (cây số 1 chặt 185.246.128.133 tới 8 nhát) — vì vậy cần rừng 300 cây rồi lấy trung bình.

💼 Dùng khi nào: giải thích cho người khác (lãnh đạo, kiểm toán) vì sao mô hình gắn cờ — "ít nhát chặt = dễ tách khỏi đám đông".
⚖️ Ảnh hưởng tới: người hiểu được cách mô hình nghĩ thì mới tin và dám dùng kết quả.

5.3 Từ số nhát chặt đến điểm bất thường và kết luận

  • decision_function đổi số nhát chặt trung bình thành điểm: càng ít nhát → điểm càng âm. Ranh giới 0 được đặt sao cho đúng khoảng CONTAMINATION (5%) số IP có điểm âm.
  • predict = kết luận: −1 nếu điểm < 0 (bất thường), +1 nếu ngược lại (bình thường).
  • rank = xếp hạng điểm từ thấp lên cao: hạng 1 = bất thường nhất.

Điểm âm chỉ là gợi ý điều tra, không phải bằng chứng tấn công — người phân tích vẫn phải xem xét.

🎯 Mục tiêu: chấm điểm, kết luận và xếp hạng mọi IP
📥 Đầu vào: rung_co_lap, F_chuan
📤 Đầu ra: diem, ket_luan, hang (5.4 → 10.1 dùng lại)

In [ ]:
diem = pd.Series(rung_co_lap.decision_function(F_chuan), index=F.index)      # điểm liên tục: càng ÂM càng bất thường
ket_luan = pd.Series(rung_co_lap.predict(F_chuan), index=F.index)            # -1 = bất thường, +1 = bình thường
hang = diem.rank().astype(int)                                               # hạng 1 = điểm thấp nhất = bất thường nhất
# 📦 diem, ket_luan, hang = 3 cột song song, mỗi IP một giá trị
print(f"Số IP bị gắn cờ bất thường (-1): {(ket_luan == -1).sum()} / {len(F)}")
for ip in ["1.10.195.220", "185.246.128.133", "2.57.121.112"]:
    print(f"  {ip:16s} điểm {diem[ip]:+.3f} -> {'BẤT THƯỜNG' if ket_luan[ip] == -1 else 'bình thường'} (hạng {hang[ip]})")
print("\n15 IP bất thường nhất:")
F.assign(diem=diem.round(3), hang=hang).sort_values("diem").head(15)

Kết quả:

Số IP bị gắn cờ bất thường (-1): 146 / 2906
  1.10.195.220     điểm +0.296 -> bình thường (hạng 2513)
  185.246.128.133  điểm -0.094 -> BẤT THƯỜNG (hạng 3)
  2.57.121.112     điểm -0.125 -> BẤT THƯỜNG (hạng 1)

rồi 15 IP bất thường nhất, đứng đầu: 2.57.121.112 (hạng 1), 193.46.255.86 (2), 185.246.128.133 (3), 2.57.121.25 (4), 109.160.32.31 (5).

📦 diem = điểm của từng IP · ket_luan = −1 / +1 · hang = thứ hạng (1 = bất thường nhất).

👉 Hiểu: đúng như số nhát chặt ở 5.2: ít nhát → điểm âm → bị gắn cờ. Không cần ai gắn nhãn, máy tự đưa lên đầu đúng 2 kẻ mà luật ngưỡng bỏ lọt ở Phần 1 (hạng 1 và hạng 3). Hạng 4 là 2.57.121.25 — cùng dải 2.57.121.x với hạng 1, cũng luôn ở dưới ngưỡng (đỉnh 10 phút chỉ 3).

💼 Dùng khi nào: danh sách ưu tiên điều tra mỗi ngày: đội SOC xem từ hạng 1 trở xuống.
⚖️ Ảnh hưởng tới: thứ tự này quyết định ai được điều tra trước khi kịp gây hại.

5.4 Nhìn toàn cảnh điểm của 2.906 IP

Cách đọc hình:

Nhìn vào Nghĩa
trục ngang điểm bất thường: bên phải = bình thường, bên trái = bất thường
trục đứng có bao nhiêu IP mang điểm đó
vạch đỏ đứt ở 0 ranh giới: IP bên trái vạch bị gắn cờ -1
3 vạch màu vị trí của 3 IP quen thuộc trong bài

🎯 Mục tiêu: nhìn phân bố điểm của cả 2.906 IP
📥 Đầu vào: diem, hang
📤 Đầu ra: 1 hình

In [ ]:
plt.figure(figsize=(12, 4))
plt.hist(diem, bins=80, color="#457b9d")
plt.axvline(0, color="red", linestyle="--", label="ranh giới (điểm < 0 -> gắn cờ -1)")
for ip, mau in [("2.57.121.112", "orange"), ("185.246.128.133", "purple"), ("130.12.182.196", "green")]:
    plt.axvline(diem[ip], color=mau, label=f"{ip}  (hạng {hang[ip]})")
plt.xlabel("điểm bất thường (decision_function)"); plt.ylabel("số IP"); plt.legend(); plt.title("Phân bố điểm bất thường")
plt.show()

Kết quả: biểu đồ cột: một cột rất cao ở khoảng +0,29 (~1.400 IP), vài cụm nhỏ ở +0,1 đến +0,25, và một đuôi dài bên trái vượt qua vạch đỏ 0. Ba vạch màu: 2.57.121.112 (hạng 1) ở ngoài cùng bên trái ≈ −0,13, 185.246.128.133 (hạng 3) ≈ −0,09, 130.12.182.196 ≈ −0,07.

👉 Hiểu: cột cao bên phải là đám đông — các IP gõ cửa 1–2 lần rồi đi, giống hệt nhau nên phải chặt hết 8 tầng vẫn chưa tách được (như 1.10.195.220 ở 5.2) → rất "bình thường". Kẻ tấn công đáng chú ý nằm ở đuôi trái: ít, khác biệt, bị tách ra sau vài nhát.

💼 Dùng khi nào: chọn ngưỡng và giải thích kết quả cho lãnh đạo bằng một hình.
⚖️ Ảnh hưởng tới: IP nằm sát vạch ranh giới là vùng "không chắc" — cần người xem, không nên tự động chặn.

5.5 So với luật ngưỡng ở Phần 1

🎯 Mục tiêu: so Isolation Forest với luật ngưỡng ở Phần 1
📥 Đầu vào: ket_luan, bi_bat (1.1), diem, hang, dinh_10_phut
📤 Đầu ra: co_if (tập IP bị rừng gắn cờ) · chi_if (IP chỉ rừng thấy) — 10.1 dùng lại

In [ ]:
co_if = set(ket_luan[ket_luan == -1].index)
print(f"Luật ngưỡng gắn cờ     : {len(bi_bat)} IP")
print(f"Isolation Forest gắn cờ: {len(co_if)} IP")
print(f"Cả hai cùng gắn cờ     : {len(bi_bat & co_if)} IP")
chi_if = co_if - bi_bat
print(f"CHỈ Isolation Forest thấy (luật ngưỡng bỏ sót): {len(chi_if)} IP — 8 IP điểm thấp nhất:")
F.loc[list(chi_if)].assign(diem=diem.round(3), hang=hang, dinh_10_phut=dinh_10_phut.reindex(list(chi_if), fill_value=0).astype(int)).sort_values("diem").head(8)

Kết quả: Luật ngưỡng gắn cờ: 190 IP · Isolation Forest gắn cờ: 146 IP · Cả hai cùng gắn cờ: 91 IP · CHỈ Isolation Forest thấy (luật ngưỡng bỏ sót): 55 IP, 8 IP điểm thấp nhất gồm 2.57.121.112 (đỉnh 10 phút = 5), 193.46.255.86 (3), 185.246.128.133 (0), 2.57.121.25 (3), 185.246.130.20 (0 lần sai, 29 ngày — "anh em" cùng dải với 185.246.128.133), 94.154.43.56 (29 ngày, 1 username)...

👉 Hiểu: 55 IP — toàn kẻ chậm, kiên trì (đỉnh 10 phút chỉ 0–5) — luật ngưỡng không bao giờ thấy, Isolation Forest thấy vì nhìn nhiều cột cùng lúc (số ngày, mật khẩu dùng chung, công cụ...). Ngược lại luật ngưỡng cũng bắt được 99 IP mà Isolation Forest không xếp vào top 5% → hai cách bổ sung cho nhau, không thay thế nhau.

💼 Dùng khi nào: chứng minh giá trị của AI so với luật đang chạy: "AI tìm thêm 55 IP mà luật bỏ sót".
⚖️ Ảnh hưởng tới: quyết định có đầu tư thêm lớp AI không, và giữ lại luật cũ (vì luật vẫn bắt được 99 IP mà AI không xếp vào top).

5.6 Tham số contamination: gắn cờ nhiều hay ít?

Là gì? Một tham số của IsolationForest (5.1): bạn đoán trước khoảng bao nhiêu phần dữ liệu là bất thường. Nó không làm rừng học khác đi: 300 cây, số nhát chặt, điểm và thứ hạng của mọi IP giữ nguyên (đã kiểm tra: score_samples giống hệt nhau với mọi giá trị contamination). Nó chỉ dời vạch ranh giới (vạch đỏ ở 5.4) sao cho đúng chừng ấy phần IP nằm bên trái vạch → bị predict gắn -1.

🧰 Giá trị hợp lệ: "auto" (mặc định) = không đoán tỉ lệ, dùng một vạch cố định theo bài báo gốc của thuật toán · một số lớn hơn 0 và không quá 0,5 (ví dụ 0.05 = 5%). Số lớn hơn 0,5 → lỗi InvalidParameterError.

Khi nào cần? Chỉ khi bạn dùng kết luận predict (−1 / +1) — ví dụ tự động đưa IP vào danh sách theo dõi, tự gửi cảnh báo. Nếu chỉ dùng thứ hạng (decision_function → rank → lấy top N mỗi ngày, như TOP_SOC ở Phần 6) thì contamination không ảnh hưởng gì.

Chọn thế nào? 3 cách, từ thực tế nhất:

  1. Theo sức của đội SOC: mỗi ngày xem kỹ được N IP trên tổng M IP → contamination = N / M. Ví dụ 50 IP / 2.906 IP ≈ 0.017.
  2. Theo tỉ lệ tấn công ước tính của hệ thống: ít bị tấn công 0,01–0,03; hay bị quét 0,05–0,10; honeypot 0,15–0,30.
  3. Chưa biết gì → để "auto", xem số IP bị gắn cờ, rồi chỉnh dần.

Ô dưới xây lại rừng với nhiều giá trị (cả "auto") và đếm kết quả:

🎯 Mục tiêu: xem số IP bị gắn cờ thay đổi thế nào theo contamination
📥 Đầu vào: F_chuan, F
📤 Đầu ra: bảng so sánh (không dùng lại)

In [ ]:
co_dang_nhap = F.unique_usernames > 0               # IP có thử đăng nhập (chỉ để tham khảo, mô hình KHÔNG dùng)
dong = []
for c in ["auto", 0.01, 0.03, 0.05, 0.10, 0.20, 0.30]:
    m = IsolationForest(n_estimators=300, contamination=c, random_state=RANDOM_STATE).fit(F_chuan)
    co = pd.Series(m.predict(F_chuan), index=F.index) == -1
    dong.append({"contamination": c, "số IP gắn cờ": co.sum(), "trong đó có thử đăng nhập": (co & co_dang_nhap).sum(),
                 "185.246.128.133?": co["185.246.128.133"], "2.57.121.112?": co["2.57.121.112"]})
pd.DataFrame(dong).set_index("contamination")

Kết quả:

contamination số IP gắn cờ trong đó có thử đăng nhập 185.246.128.133? 2.57.121.112?
auto 747 650 True True
0.01 30 30 True True
0.03 87 84 True True
0.05 146 139 True True
0.10 291 268 True True
0.20 581 536 True True
0.30 861 713 True True

👉 Hiểu: contamination = "mỗi ngày đội SOC xem bao nhiêu phần trăm IP". Để "auto" thì rừng gắn cờ 747 IP (26%) — quá nhiều cho một đội SOC, và 97 IP trong đó chưa từng thử đăng nhập: "auto" chỉ là điểm xuất phát, không phải lựa chọn cuối. Hai kẻ "chậm mà chắc" luôn nằm trong 1% đầu nên giá trị nào cũng bắt. Tăng lên 0,30 thì gắn cờ 861 IP, trong đó 148 IP chưa từng thử đăng nhập → nhiều cảnh báo "rác" hơn. Gợi ý thường dùng: hệ thống ít bị tấn công 0,01–0,03; hay bị quét 0,05–0,10; honeypot 0,15–0,30. Đây là quyết định nghiệp vụ: đội SOC xử lý được bao nhiêu cảnh báo mỗi ngày?

💼 Dùng khi nào: cấu hình mô hình cho đúng sức đội SOC: mỗi ngày xem được bao nhiêu IP.
⚖️ Ảnh hưởng tới: số cảnh báo mỗi ngày: quá nhiều → đội trực bỏ qua cảnh báo; quá ít → bỏ lọt.


Phần 6 — Bẫy nguy hiểm: dữ liệu huấn luyện bị nhiễm

📍 Bước 5 — Lưu ý khi coding: lọc IP nội bộ trước khi tính đặc trưng. Làm sai thứ tự thì code vẫn chạy, không báo lỗi, nhưng mô hình học sai.

Kịch bản: nhiều máy giám sát nội bộ (health-checker, 127.0.0.1, 10.0.x.x) cứ vài phút lại đăng nhập SSH để kiểm tra dịch vụ còn sống: đăng nhập thành công, 0 lần sai, đều đặn, ngày nào cũng có. Hành vi này rất giống kẻ trinh sát 185.246.128.133 (chỉ đúng 1 mật khẩu, 0 lần sai, 30 ngày, đều 20 phút).

⚠️ Log honeypot của ta không có máy nội bộ (đo thật: 0 trong 2.906 IP thuộc dải nội bộ 10.x, 127.x, 172.16–31.x, 192.168.x). Ô dưới giả lập chúng — đây là phần duy nhất trong bài dùng dữ liệu giả, để tái hiện đúng kịch bản trên.

6.1 Thêm máy health-checker vào log rồi huấn luyện lại

🎯 Mục tiêu: giả lập máy health-checker nội bộ, chấm điểm lại, xem kẻ trinh sát tụt hạng
📥 Đầu vào: log, tao_dac_trung (3.1)
📤 Đầu ra: log_nhiem (log thật + giả lập) · hàm cham_diem (6.2 dùng lại) · diem_nhiem

🧰 cham_diem(F_): 1 tham số bắt buộc — một bảng đặc trưng (như kết quả của tao_dac_trung). Làm lại đúng Phần 4–5 (điền trung vị → log1p → chuẩn hoá → Isolation Forest). Trả về điểm của từng IP (càng âm càng bất thường).

Các lựa chọn ✏️ (hạng của 185.246.128.133, đo thật; dữ liệu sạch: hạng 3):

SO_MAY, CU_MOI_GIAY Hạng Vì sao
20, 1200 25 ít máy → ít "che"
150, 1200 (có sẵn) 112 nhiều máy cùng nhịp 20 phút giống hệt kẻ trinh sát → che mạnh
500, 1200 166 càng nhiều máy càng che
150, 300 28 máy kiểm tra mỗi 5 phút: nhịp khác kẻ trinh sát (20 phút) → che ít hơn hẳn

TOP_SOC: số IP đội SOC xem mỗi ngày (thử 20, 100) — chỉ đổi dòng kết luận "NẰM TRONG / RƠI KHỎI".

In [ ]:
import warnings
warnings.simplefilter("ignore", DeprecationWarning)      # ẩn một cảnh báo kỹ thuật vô hại của thư viện khi tạo mốc thời gian
# ✏️ SỬA: số máy health-checker nội bộ, mỗi máy đăng nhập bao nhiêu giây một lần
SO_MAY, CU_MOI_GIAY = 150, 1200
bat_dau = pd.Timestamp("2026-08-24", tz="UTC")
gia_lap = []
for j in range(SO_MAY):
    ip = "127.0.0.1" if j == 0 else f"10.0.{j // 250}.{j % 250 + 1}"           # địa chỉ NỘI BỘ
    moc = bat_dau + pd.to_timedelta(np.arange(0, 30 * 86400, CU_MOI_GIAY) + j * 7, unit="s")
    gia_lap.append(pd.DataFrame({"eventid": "cowrie.session.connect", "thoi_gian": moc, "src_ip": ip}))
    gia_lap.append(pd.DataFrame({"eventid": "cowrie.login.success", "thoi_gian": moc + pd.Timedelta(seconds=1),
                                 "src_ip": ip, "username": "healthcheck", "password": "hc"}))
log_nhiem = pd.concat([log] + gia_lap, ignore_index=True)
# 📦 log_nhiem = log thật + log giả lập của SO_MAY máy nội bộ

def cham_diem(F_):
    """Làm lại đúng Phần 4-5 cho một bảng đặc trưng bất kỳ: điền trung vị từng cột -> log1p -> chuẩn hóa -> Isolation Forest. Trả về điểm của từng IP."""
    X_ = StandardScaler().fit_transform(np.log1p(F_.fillna(F_.median())))
    m = IsolationForest(n_estimators=300, contamination=CONTAMINATION, random_state=RANDOM_STATE).fit(X_)
    return pd.Series(m.decision_function(X_), index=F_.index)

# ✏️ SỬA: mỗi ngày đội SOC chỉ kịp điều tra bao nhiêu IP đứng đầu danh sách
TOP_SOC = 50
diem_nhiem = cham_diem(tao_dac_trung(log_nhiem))
for ten, d in [("Dữ liệu sạch (Phần 5)", diem), (f"Có {SO_MAY} máy health-checker", diem_nhiem)]:
    h = int(d.rank()["185.246.128.133"])
    print(f"{ten:32s}: 185.246.128.133 điểm {d['185.246.128.133']:+.3f}, hạng {h:4d} / {len(d)} -> "
          f"{'NẰM TRONG' if h <= TOP_SOC else 'RƠI KHỎI'} top {TOP_SOC} đội SOC xem mỗi ngày")

Kết quả:

Dữ liệu sạch (Phần 5)           : 185.246.128.133 điểm -0.094, hạng    3 / 2906 -> NẰM TRONG top 50 đội SOC xem mỗi ngày
Có 150 máy health-checker       : 185.246.128.133 điểm -0.022, hạng  112 / 3056 -> RƠI KHỎI top 50 đội SOC xem mỗi ngày

📦 log_nhiem = log thật + log giả lập của 150 máy nội bộ · cham_diem(F_) = hàm làm lại đúng Phần 4–5 cho một bảng đặc trưng bất kỳ.

👉 Hiểu: 150 máy giám sát nội bộ "dạy" mô hình rằng "đăng nhập thành công đều đặn, 0 lần sai, ngày nào cũng có = bình thường". Kẻ trinh sát 185.246.128.133 có đúng hành vi đó → điểm gần về 0, tụt từ hạng 3 xuống hạng 112, ra khỏi danh sách đội SOC kịp xem. Không cần hacker nào can thiệp — chính dữ liệu của ta tạo ra điểm mù. Thử SO_MAY = 20 hoặc CU_MOI_GIAY = 300.

💼 Dùng khi nào: hệ thống có máy giám sát, sao lưu, script tự động đăng nhập SSH đều đặn — gần như công ty nào cũng có.
⚖️ Ảnh hưởng tới: chính dữ liệu của mình tạo ra điểm mù: kẻ tấn công giả dạng hành vi "đều đặn" sẽ lọt.

6.2 Nguyên tắc vàng: lọc IP nội bộ TRƯỚC khi tính đặc trưng

🎯 Mục tiêu: lọc IP nội bộ trước khi tính đặc trưng, rồi chấm điểm lại
📥 Đầu vào: log_nhiem, cham_diem, tao_dac_trung
📤 Đầu ra: la_noi_bo, log_sach, diem_sach

🧰 ipaddress.ip_address(chuoi_ip).is_private: 1 tham số bắt buộc (chuỗi IP) — trả True nếu IP thuộc dải nội bộ.

In [ ]:
import ipaddress
# is_private = True cho 127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 (và vài dải đặc biệt khác)
la_noi_bo = log_nhiem.src_ip.map(lambda ip: ipaddress.ip_address(ip).is_private)
log_sach = log_nhiem[~la_noi_bo]                       # LỌC TRƯỚC, rồi mới tạo đặc trưng
print("Đã loại", la_noi_bo.sum(), "dòng log từ IP nội bộ")
diem_sach = cham_diem(tao_dac_trung(log_sach))
print(f"Sau khi lọc: 185.246.128.133 điểm {diem_sach['185.246.128.133']:+.3f}, hạng {int(diem_sach.rank()['185.246.128.133'])} / {len(diem_sach)}")

Kết quả: Đã loại 648000 dòng log từ IP nội bộ · Sau khi lọc: 185.246.128.133 điểm -0.094, hạng 3 / 2906

👉 Hiểu: lọc dải nội bộ (127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16) trước khi tính đặc trưng → mô hình học từ dữ liệu sạch → kẻ trinh sát trở lại hạng 3. Nếu lọc sau (bỏ IP nội bộ khỏi danh sách cảnh báo) thì đã muộn: mô hình đã học sai rằng hành vi đó là bình thường. Đường ống dữ liệu là tuyến phòng thủ đầu tiên, trước cả thuật toán.

💼 Dùng khi nào: luôn lọc IP nội bộ / IP nhà mình / máy giám sát trước khi huấn luyện — bước đầu tiên của mọi quy trình.
⚖️ Ảnh hưởng tới: lọc sau khi huấn luyện là quá muộn: mô hình đã học sai "bình thường" là gì.


Phần 7 — Giải thích AI: "bị cờ đỏ" chưa đủ, cần biết TẠI SAO

📍 Bước 6 — Đánh giá: trước khi tin một cảnh báo, hỏi vì sao máy báo.

SHAP (thư viện shap) chia kết quả của mô hình thành phần đóng góp của từng đặc trưng. Với Isolation Forest, kết quả đó rất dễ hình dung:

  • Mỗi cây chặt dữ liệu ngẫu nhiên; số nhát chặt cần để tách riêng một IP = "độ dài đường đi". Một IP bình thường lẫn trong đám đông cần nhiều nhát; IP khác thường bị tách ra sau ít nhát.
  • SHAP bắt đầu từ số nhát trung bình của mọi IP (giá trị nền), rồi cho biết mỗi đặc trưng làm IP này cần thêm hay bớt bao nhiêu nhát.
  • Vì sao ở đây là ~13 nhát, còn ở 5.2 đếm được ~7,7 nhát? Mỗi cây chỉ sâu tối đa 8 tầng. Khi cây dừng mà IP vẫn lẫn trong một nhóm nhiều IP giống nhau, mô hình cộng thêm số nhát ước tính "lẽ ra còn phải chặt" để tách IP đó ra khỏi nhóm (nhóm càng đông, cộng càng nhiều). 5.2 chỉ đếm các nhát đã chặt thật; con số SHAP dùng là số nhát đã cộng phần ước tính — chính con số mà decision_function dùng để chấm điểm.
  • SHAP âm (bớt nhát) = đặc trưng đó giúp cô lập IP nhanh hơn = đẩy về bất thường. Để dễ đọc (dương = nguy hiểm hơn), bảng có thêm cột "đóng góp vào cảnh báo" = −SHAP.

7.1 Giải thích một cảnh báo

🎯 Mục tiêu: giải thích vì sao một IP bị gắn cờ: đặc trưng nào đóng góp bao nhiêu
📥 Đầu vào: rung_co_lap (5.1), F_chuan, F, diem
📤 Đầu ra: bo_giai_thich (7.3 dùng lại) · hàm giai_thich · 1 bảng + 1 hình

🧰 shap.TreeExplainer(mo_hinh): 1 tham số bắt buộc — một mô hình dạng cây đã học (Isolation Forest, Random Forest...). giai_thich(ip): 1 tham số bắt buộc (chuỗi IP); vẽ hình và trả về bảng đóng góp của 7 đặc trưng.

Các lựa chọn ✏️ IP: "130.12.182.196" (kẻ xả lũ), "193.46.255.86" (hạng 2), hoặc IP bất kỳ trong bảng 15 IP ở 5.3.

In [ ]:
import warnings
warnings.filterwarnings("ignore", message="IProgress not found")   # ẩn cảnh báo về thanh tiến trình, không ảnh hưởng kết quả
import shap
# TreeExplainer: bộ giải thích SHAP cho mô hình dạng cây (Isolation Forest ở 5.1)
bo_giai_thich = shap.TreeExplainer(rung_co_lap)

def giai_thich(ip):
    """In và vẽ phần đóng góp SHAP của từng đặc trưng cho 1 IP."""
    sv = bo_giai_thich(F_chuan.loc[[ip]])                  # SHAP cho đúng 1 hàng (đã chuẩn hóa như lúc học)
    nen = float(sv.base_values[0])                          # số nhát chặt trung bình của MỌI IP
    tong = nen + sv.values[0].sum()                         # số nhát chặt của IP này
    print(f"{ip}: điểm {diem[ip]:+.3f} ({'BẤT THƯỜNG' if diem[ip] < 0 else 'bình thường'}) | "
          f"IP trung bình cần {nen:.1f} nhát chặt, IP này chỉ cần {tong:.1f} nhát")
    bang_gt = pd.DataFrame({"giá trị của IP": F.loc[ip], "SHAP (nhát chặt +/-)": sv.values[0],
                            "đóng góp vào cảnh báo": -sv.values[0]}, index=F.columns)
    bang_gt = bang_gt.sort_values("đóng góp vào cảnh báo", ascending=False)
    bang_gt["đóng góp vào cảnh báo"].sort_values().plot(kind="barh", figsize=(7, 3), color="#e76f51",
                                                         title=f"SHAP: vì sao {ip} bị gắn cờ? (dài sang phải = nguy hiểm hơn)")
    plt.axvline(0, color="black"); plt.xlabel("số nhát chặt bớt đi nhờ đặc trưng này"); plt.show()
    return bang_gt.round(3)

# Cách đọc hình: mỗi thanh = 1 đặc trưng; thanh dài sang PHẢI = đặc trưng đó làm IP bị cô lập NHANH hơn (nguy hiểm hơn);
# thanh sang TRÁI = đặc trưng đó làm IP trông bình thường hơn; thanh bằng 0 = không ảnh hưởng.
# ✏️ SỬA: IP muốn giải thích (thử "130.12.182.196", "193.46.255.86", hoặc IP trong bảng 5.1)
giai_thich("185.246.128.133")

Kết quả: 185.246.128.133: điểm -0.094 (BẤT THƯỜNG) | IP trung bình cần 13.0 nhát chặt, IP này chỉ cần 4.7 nhát, biểu đồ thanh, và bảng:

đặc trưng giá trị của IP SHAP (nhát chặt +/−) đóng góp vào cảnh báo
so_ngay 29 −3,098 3,098
so_ket_noi 1.796 −2,406 2,406
credential_reuse_score 0,975 −0,971 0,971
ip_cung_phan_mem 1 −0,710 0,710
attempt_interval_std 3.714,8 −0,620 0,620
unique_usernames 1 −0,582 0,582
failed_logins 0 +0,034 −0,034

📦 bo_giai_thich = bộ giải thích SHAP gắn với rừng ở 5.1 · giai_thich(ip) = hàm in, vẽ và trả về bảng SHAP cho 1 IP bất kỳ.

👉 Hiểu: IP bình thường cần 13 nhát (đã cộng phần ước tính, xem giải thích ở trên); IP này chỉ cần 4,7 nhát. Trong 8,3 nhát "bớt đi" đó, riêng việc có mặt 29 ngày đã bớt 3,1 nhát, 1.796 lần kết nối bớt 2,4 nhát, dùng mật khẩu chung với nhiều IP khác bớt gần 1 nhát. Chỉ có failed_logins = 0 là kéo về phía bình thường một chút ("chưa sai lần nào"). Người phân tích SOC đọc ra ngay: "kẻ đã có mật khẩu, kiểm tra định kỳ suốt 29 ngày" → đổi mật khẩu root ngay, không chỉ chặn IP.

💼 Dùng khi nào: mỗi cảnh báo gửi cho người phân tích phải kèm lý do: "bị gắn cờ vì có mặt 29 ngày, 0 lần sai".
⚖️ Ảnh hưởng tới: cảnh báo không có lý do → không ai tin, không ai xử lý.

🎯 Mục tiêu: giải thích IP hạng 1 (kẻ dò đúng 5 lần rồi dừng)
📥 Đầu vào: hàm giai_thich (7.1)
📤 Đầu ra: 1 bảng + 1 hình

In [ ]:
giai_thich("2.57.121.112")

Kết quả: 2.57.121.112: điểm -0.125 (BẤT THƯỜNG) | IP trung bình cần 13.0 nhát chặt, IP này chỉ cần 4.1 nhát, đóng góp lớn nhất: so_ngay 20 (2,285), unique_usernames 244 (1,984), failed_logins 1.225 (1,818), so_ket_noi 261 (1,514); credential_reuse_score 0 gần như không ảnh hưởng (0,071).

👉 Hiểu: cùng là "bất thường", nhưng lý do khác hẳn 185.246.128.133: IP này bị tố cáo bởi việc dò rộng (244 username, 1.225 lần sai) và kiên trì (20 ngày), dùng danh sách mật khẩu riêng (không trùng ai). Hành động tiếp theo cũng khác: chặn IP + kiểm tra các tài khoản có tên người trên máy chủ. Giải thích AI cho biết bước xử lý tiếp theo, không chỉ biết "có tấn công".

💼 Dùng khi nào: so lý do của 2 cảnh báo để biết chúng là hai kiểu tấn công khác nhau.
⚖️ Ảnh hưởng tới: kiểu khác nhau → cách xử lý khác nhau (chặn IP, đổi mật khẩu, chặn dải).

7.3 Trên TOÀN BỘ 2.906 IP: đặc trưng nào quan trọng nhất?

🎯 Mục tiêu: tính SHAP cho mọi IP để biết đặc trưng nào quan trọng nhất nói chung, và với riêng các IP bị gắn cờ
📥 Đầu vào: bo_giai_thich (7.1), F_chuan, ket_luan
📤 Đầu ra: sv_tat_ca, anh_huong, to_cao · 1 hình

In [ ]:
sv_tat_ca = bo_giai_thich(F_chuan)                          # SHAP cho mọi IP (mất vài giây)
# Mức ảnh hưởng trung bình = trung bình |SHAP| của mỗi đặc trưng trên mọi IP (không quan tâm dấu)
anh_huong = pd.Series(np.abs(sv_tat_ca.values).mean(axis=0), index=F.columns).sort_values()
# 📦 anh_huong = mỗi đặc trưng trung bình làm thay đổi bao nhiêu nhát chặt
anh_huong.plot(kind="barh", figsize=(7, 3), color="#2a9d8f", title="SHAP trung bình |giá trị| trên 2.906 IP")
plt.xlabel("số nhát chặt thay đổi trung bình"); plt.show()
print(anh_huong.sort_values(ascending=False).round(3).to_string())
# Riêng 146 IP bị gắn cờ: đặc trưng nào hay "tố cáo" chúng nhất?
co = (ket_luan == -1).to_numpy()
to_cao = pd.Series((-sv_tat_ca.values[co]).mean(axis=0), index=F.columns).sort_values(ascending=False)
print("\nVới các IP bị gắn cờ, đóng góp vào cảnh báo trung bình:")
print(to_cao.round(3).to_string())

Kết quả: biểu đồ thanh và 2 bảng:

đặc trưng ảnh hưởng trung bình trên mọi IP đóng góp vào cảnh báo trung bình trên 146 IP bị gắn cờ
ip_cung_phan_mem 0,810 0,352
so_ket_noi 0,613 1,674
so_ngay 0,572 1,203
unique_usernames 0,519 1,390
credential_reuse_score 0,491 0,488
failed_logins 0,363 1,359
attempt_interval_std 0,314 0,646

📦 sv_tat_ca = SHAP của cả 2.906 IP · anh_huong = mức ảnh hưởng trung bình của mỗi đặc trưng · to_cao = đóng góp trung bình vào cảnh báo, chỉ tính các IP bị gắn cờ.

👉 Hiểu: câu hỏi "đặc trưng nào quan trọng nhất?" có hai câu trả lời:

  • Trên mọi IP, ip_cung_phan_mem ảnh hưởng nhiều nhất: nó chia đám đông thành các nhóm công cụ (723 IP dùng Go, nhiều IP không khai...) — giúp mô hình biết "thế nào là bình thường".
  • Với các IP bị gắn cờ, thứ tố cáo chúng là số kết nối, số username, số lần sai, số ngày — tức cường độ và sự kiên trì.

💼 Dùng khi nào: chọn đặc trưng đáng thu thập: đặc trưng ảnh hưởng nhiều thì giữ, gần 0 thì cân nhắc bỏ.
⚖️ Ảnh hưởng tới: chi phí thu thập / lưu trữ log, và hiểu mô hình đang "nhìn" vào đâu.


Phần 8 — Kẻ tấn công nhắm vào chính mô hình AI: né tránh

📍 Bước 6 — Đánh giá: biết ta đo gì, kẻ tấn công đổi hành vi để né — mô hình có trụ được không?

Nếu kẻ tấn công biết ta đo những đặc trưng nào (bảng ở Phần 3.1), chúng đổi hành vi để điểm thành "bình thường". Thử với 130.12.182.196 (cỗ máy đều nhịp, đang bị gắn cờ):

🎯 Mục tiêu: thử các cách kẻ tấn công đổi hành vi để điểm thành "bình thường"
📥 Đầu vào: F, chuan_hoa (4c), trung_vi_cot (4a), rung_co_lap (5.1)
📤 Đầu ra: hàm diem_cua · in điểm của từng cách né

🧰 diem_cua(hang_df): 1 tham số bắt buộc — bảng 1 hàng có đủ 7 cột như F. Trả về 1 số: điểm bất thường (âm = bị gắn cờ).

Các lựa chọn ✏️ cach_ne: thêm một dòng của riêng bạn, ví dụ "Chỉ giả vờ ít ngày": {"so_ngay": 1} hoặc "Dùng phần mềm phổ biến": {"ip_cung_phan_mem": 500}, rồi chạy lại xem có né được không.

In [ ]:
def diem_cua(hang_df):
    """Chấm điểm 1 hàng đặc trưng bằng mô hình ở 5.1 (điền -> log1p -> chuẩn hóa -> Isolation Forest)."""
    x = pd.DataFrame(chuan_hoa.transform(np.log1p(hang_df.fillna(trung_vi_cot))), columns=F.columns)
    return rung_co_lap.decision_function(x)[0]
goc = F.loc[["130.12.182.196"]]
# ✏️ SỬA: các cách né tránh — mỗi cách là một bộ đặc trưng bị kẻ tấn công thay đổi
cach_ne = {
    "Không đổi gì": {},
    "Thêm độ trễ ngẫu nhiên (std 3.000 giây)": {"attempt_interval_std": 3000},
    "Rải ra 20 IP (mỗi IP 1/20 khối lượng)": {"failed_logins": 3310 / 20, "so_ket_noi": 3757 / 20},
    "Rải ra 100 IP + trễ ngẫu nhiên, mỗi IP 1 ngày": {"failed_logins": 33, "so_ket_noi": 38, "attempt_interval_std": 3000, "so_ngay": 1},
}
for ten, thay_doi in cach_ne.items():
    thu = goc.copy()
    for cot, gia_tri in thay_doi.items():
        thu[cot] = gia_tri
    d = diem_cua(thu)
    print(f"{ten:48s} điểm {d:+.3f} -> {'BẤT THƯỜNG' if d < 0 else 'BÌNH THƯỜNG — né được!'}")

Kết quả:

Không đổi gì                                     điểm -0.065 -> BẤT THƯỜNG
Thêm độ trễ ngẫu nhiên (std 3.000 giây)          điểm -0.064 -> BẤT THƯỜNG
Rải ra 20 IP (mỗi IP 1/20 khối lượng)            điểm -0.019 -> BẤT THƯỜNG
Rải ra 100 IP + trễ ngẫu nhiên, mỗi IP 1 ngày    điểm +0.037 -> BÌNH THƯỜNG — né được!

📦 diem_cua(hang_df) = chấm điểm một hàng đặc trưng (đã sửa) bằng rừng ở 5.1 · cach_ne = các kịch bản né tránh (sửa được).

👉 Hiểu: chỉ thêm độ trễ thì không đủ (mô hình nhìn nhiều cột cùng lúc). Nhưng nếu kẻ tấn công có botnet 100 máy, mỗi máy chỉ làm một phần nhỏ trong 1 ngày → từng IP trông bình thường. Phòng thủ và tấn công là một cuộc chạy đua liên tục. Thử thêm kịch bản của bạn vào cach_ne.

💼 Dùng khi nào: đánh giá mô hình trước khi triển khai: đóng vai kẻ tấn công, thử né.
⚖️ Ảnh hưởng tới: mô hình né được dễ dàng thì không nên là lớp phòng thủ duy nhất.

Phòng thủ:

  • Kết hợp nhiều lớp (ensemble): luật ngưỡng + Isolation Forest + mô hình có giám sát (Phần 9) + nhìn theo dải mạng / công cụ (ip_cung_phan_mem): rải ra 100 IP thì mỗi IP "bình thường", nhưng 100 IP cùng một công cụ, cùng danh sách mật khẩu lại là một dấu hiệu.
  • Huấn luyện lại định kỳ trên dữ liệu đã kiểm tra sạch (Phần 6).
  • AI là một lớp phòng thủ, không phải lớp duy nhất — người phân tích SOC quyết định cuối cùng.

Phần 9 — Học CÓ giám sát: Random Forest đoán IP nào sẽ dò mật khẩu

📍 Vòng 2: đi lại bước 1 → 6, lần này có nhãn (bước 3: có nhãn → học có giám sát, Random Forest). 9A thu thập + nhãn · 9B làm sạch, chia học / thi, lưu ý · 9C coding · 9D–9F đánh giá.

Lần này ta có đáp án: dòng login trong log cho biết IP đã thử mật khẩu hay chưa.

Nhãn ở đây là gì? — đúng, là "có dòng login hay không": IP có ít nhất 1 dòng cowrie.login.failed hoặc cowrie.login.success trong 30 ngày → nhãn 1 ("dò mật khẩu"); không có dòng nào → nhãn 0 ("chỉ quét"). Log được chia làm 2 phần không chồng lên nhau:

Phần log Dùng làm Vì sao
dòng session.connect, client.version (kết nối, khai phần mềm) đặc trưng X — thứ máy được nhìn có ngay từ giây đầu tiên IP kết nối
dòng login.failed, login.success nhãn y — đáp án chỉ có sau khi IP đã thử mật khẩu
(không dùng dòng login làm đặc trưng) — nếu dùng, máy "nhìn trộm đáp án" → 100% giả (9B.4)

Ý nghĩa thực tế: chỉ từ cách một IP vừa kết nối, đoán trước nó sắp dò mật khẩu không → chặn trước khi nó thử. Câu hỏi: chỉ nhìn cách IP kết nối (không nhìn dòng login), máy có đoán trước được không? Trong SOC, đoán sớm = chặn sớm.

Phần 9A — Mẫu, đặc trưng, nhãn

📍 Bước 1 + 4 (vòng 2) — Thu thập, tạo nhãn, coding bảng đặc trưng.

9A.1 Chuẩn bị bảng KẾT NỐI (nguồn của mọi đặc trưng)

🎯 Mục tiêu: chuẩn bị bảng các lần kết nối (nguồn của mọi đặc trưng ở Phần 9)
📥 Đầu vào: log
📤 Đầu ra: kn (mỗi hàng 1 kết nối, thêm 3 cột phan_mem, cach_lan_truoc, dai_mang) · phan_mem

In [ ]:
# Mỗi dòng session.connect = 1 lần kết nối; xếp theo thời gian để tính khoảng cách giữa 2 lần
kn = log[log.eventid == "cowrie.session.connect"].sort_values("thoi_gian").copy()
# Tên phần mềm SSH khai báo, nằm ở dòng client.version cùng mã phiên (session) -> tra theo session
phan_mem = log[log.eventid == "cowrie.client.version"].drop_duplicates("session").set_index("session").version
kn["phan_mem"] = kn.session.map(phan_mem).str.extract(r"^SSH-[\d.]+-([A-Za-z]+)")[0].fillna("không khai")
# Khoảng cách (giây) tới lần kết nối TRƯỚC của CÙNG IP: groupby theo IP rồi diff(); lần đầu tiên của mỗi IP -> trống (NaN)
kn["cach_lan_truoc"] = kn.groupby("src_ip").thoi_gian.diff().dt.total_seconds()
kn["dai_mang"] = kn.src_ip.str.rsplit(".", n=1).str[0] + ".0/24"           # 3 số đầu của IP = dải mạng /24
# 📦 kn = bảng các lần kết nối, mỗi hàng 1 kết nối, thêm 3 cột ta vừa tạo
print("kn:", len(kn), "kết nối, các cột:", list(kn.columns))
kn[["thoi_gian", "src_ip", "src_port", "phan_mem", "cach_lan_truoc", "dai_mang"]].head(4)

Kết quả: kn: 136063 kết nối, các cột: [... 'phan_mem', 'cach_lan_truoc', 'dai_mang'] + 4 dòng đầu (ví dụ 195.178.110.227, Go, cách lần trước 96.37 giây, dải 195.178.110.0/24).

📦 kn = bảng các lần kết nối, thêm 3 cột: tên phần mềm rút gọn, khoảng cách tới lần kết nối trước của cùng IP (giây), dải /24.

👉 Hiểu: lần này đặc trưng chỉ lấy từ dòng kết nối và dòng khai phần mềm. Dòng login để dành làm đáp án — không được lọt vào đặc trưng (xem 9B.4).

💼 Dùng khi nào: chuẩn bị dữ liệu cho mô hình có giám sát: chọn đúng phần log làm đặc trưng (thứ có sẵn lúc cần đoán).
⚖️ Ảnh hưởng tới: dùng phần log chỉ có sau sự việc làm đặc trưng → mô hình đẹp trên giấy, vô dụng ngoài đời.

9A.2 Gom thành bảng đặc trưng X: mỗi IP một hàng

Đặc trưng Cách tính Vì sao có thể giúp
so_ket_noi số lần kết nối kẻ dò mật khẩu phải kết nối nhiều lần
so_ngay số ngày khác nhau có mặt kiên trì hay ghé 1 lần
so_khung_gio số giờ khác nhau trong ngày (0–24) chạy liên tục hay chớp nhoáng
ket_noi_moi_ngay so_ket_noi / so_ngay mức độ dồn dập
nhip_trung_vi trung vị khoảng cách giữa 2 lần kết nối (giây) máy đều nhịp hay rải rác
so_phan_mem số tên phần mềm khác nhau ngụy trang / công cụ quét
ti_le_khong_khai tỉ lệ kết nối ngắt trước khi khai phần mềm máy quét thường ngắt ngay
ti_le_go tỉ lệ kết nối khai SSH-2.0-Go công cụ dò mật khẩu phổ biến (PATTERN-06)
cong_nguon_tb cổng nguồn trung bình hệ điều hành / công cụ khác nhau chọn cổng khác nhau
gio_xuat_hien giờ (UTC) của lần kết nối đầu tiên thời điểm bắt đầu
so_ip_cung_dai số IP cùng dải /24 cũng tấn công botnet thường đi theo dải

🎯 Mục tiêu: gom kết nối theo IP thành bảng đặc trưng X (11 cột)
📥 Đầu vào: kn (9A.1)
📤 Đầu ra: g (các nhóm theo IP, 9F.2 dùng lại) · X (2.906 IP × 11 đặc trưng)

In [ ]:
g = kn.groupby("src_ip")            # gom các kết nối theo IP: mỗi nhóm = mọi kết nối của 1 IP
X = pd.DataFrame({
    "so_ket_noi":       g.size(),                                              # đếm số hàng của nhóm
    "so_ngay":          g.thoi_gian.apply(lambda t: t.dt.date.nunique()),      # số ngày khác nhau
    "so_khung_gio":     g.thoi_gian.apply(lambda t: t.dt.hour.nunique()),      # số giờ khác nhau
    "nhip_trung_vi":    g.cach_lan_truoc.median(),                             # trung vị khoảng cách (giây)
    "so_phan_mem":      g.phan_mem.nunique(),
    "ti_le_khong_khai": g.phan_mem.apply(lambda s: (s == "không khai").mean()),   # mean của True/False = tỉ lệ True
    "ti_le_go":         g.phan_mem.apply(lambda s: (s == "Go").mean()),
    "cong_nguon_tb":    g.src_port.mean(),
    "gio_xuat_hien":    g.thoi_gian.min().dt.hour,
})
X["ket_noi_moi_ngay"] = X.so_ket_noi / X.so_ngay
# map: tra mỗi dải /24 -> số IP khác nhau của dải đó
X["so_ip_cung_dai"] = g.dai_mang.first().map(kn.groupby("dai_mang").src_ip.nunique())
# 📦 X = bảng đặc trưng: mỗi HÀNG là 1 IP (tên IP nằm ở chỉ mục bên trái), mỗi CỘT là 1 đặc trưng
print("X:", X.shape[0], "IP (mẫu) x", X.shape[1], "đặc trưng")
X.loc[["1.10.195.220", "130.12.182.196"]].round(2)

Kết quả: X: 2906 IP (mẫu) x 11 đặc trưng, 2 hàng: 1.10.195.220 (2 kết nối, 1 ngày, 100% không khai phần mềm) và 130.12.182.196 (3.757 kết nối, 24 khung giờ, nhịp 16 giây, 97% Go, 626 kết nối/ngày).

📦 X = bảng đặc trưng cho học có giám sát (11 cột, không có thông tin đăng nhập) · g = các kết nối gom theo IP (9F dùng lại).

💼 Dùng khi nào: mỗi khi muốn mô hình đoán về một đối tượng: dựng bảng "mỗi đối tượng một hàng, mỗi đặc trưng một cột".
⚖️ Ảnh hưởng tới: đặc trưng có sẵn sớm → đoán (và chặn) sớm.

9A.3 Nhãn y: IP đó có thử mật khẩu không?

🎯 Mục tiêu: tạo nhãn (đáp án): IP có thử mật khẩu không
📥 Đầu vào: log, X
📤 Đầu ra: y (0/1 cho từng IP) · so_lan_dang_nhap (chỉ để tạo nhãn và soi lỗi)

In [ ]:
# Đếm số dòng login (thất bại + thành công) của mỗi IP; IP không có dòng login nào -> 0
so_lan_dang_nhap = (log[log.eventid.str.startswith("cowrie.login")].groupby("src_ip").size()
                    .reindex(X.index, fill_value=0))       # reindex: xếp đúng thứ tự IP của X, thiếu thì 0
y = (so_lan_dang_nhap > 0).astype(int)                     # 1 = dò mật khẩu, 0 = chỉ quét
# 📦 y = 2.906 số 0/1, thứ tự trùng với các hàng của X
print("Nhãn của 2 IP ở Phần 0:", y.loc[["1.10.195.220", "130.12.182.196"]].to_dict())
print(y.value_counts().rename({0: "0 = chỉ quét", 1: "1 = dò mật khẩu"}).to_string())
print(f"Tỉ lệ dò mật khẩu: {y.mean():.1%}")
print(f"Đoán bừa 'IP nào cũng chỉ quét' -> đúng {(y == 0).mean():.1%}  <- mốc phải vượt")

Kết quả: Nhãn của 2 IP ở Phần 0: {'1.10.195.220': 0, '130.12.182.196': 1} · 0 = chỉ quét 2049 · 1 = dò mật khẩu 857 · Tỉ lệ dò mật khẩu: 29.5% · Đoán bừa 'IP nào cũng chỉ quét' -> đúng 70.5%

📦 y = 2.906 số 0/1, cùng thứ tự với các hàng của X · so_lan_dang_nhap = số dòng login thật của mỗi IP (chỉ dùng để tạo nhãn và để soi lỗi).

👉 Hiểu: 70,5% là mốc phải vượt.

💼 Dùng khi nào: tạo nhãn từ dữ liệu có sẵn (log, ticket sự cố đã đóng, danh sách IP đã xác minh) thay vì gắn nhãn tay.
⚖️ Ảnh hưởng tới: mốc đoán bừa (70,5%): mô hình không vượt rõ mốc này thì không có giá trị.

9A.4 Một mẫu = một vector đặc trưng + một nhãn

🎯 Mục tiêu: xem 1 mẫu = 1 hàng số + 1 nhãn
📥 Đầu vào: X, y
📤 Đầu ra: chỉ in ra

Các lựa chọn ✏️ IP_XEM: "1.10.195.220" (chỉ quét) · "185.246.128.133" (trinh sát 30 ngày) · "2.57.121.112".

In [ ]:
# ✏️ SỬA: IP muốn xem (lấy từ các bảng trên)
IP_XEM = "130.12.182.196"
print("Vector đặc trưng của", IP_XEM, "(đây là thứ máy 'nhìn thấy'):")
print(X.loc[IP_XEM].round(2).to_string())
print("Nhãn (đáp án):", y.loc[IP_XEM], "=", "dò mật khẩu" if y.loc[IP_XEM] == 1 else "chỉ quét")

Kết quả: 11 con số của 130.12.182.196 (so_ket_noi 3757, so_ngay 6, so_khung_gio 24, nhip_trung_vi 16.05, ...) và Nhãn (đáp án): 1 = dò mật khẩu.

👉 Hiểu: một mẫu = một vector 11 số (thứ máy "nhìn thấy") + một nhãn (thứ máy phải đoán).

💼 Dùng khi nào: giải thích cho người mới: máy chỉ thấy một hàng số, không thấy IP hay log.
⚖️ Ảnh hưởng tới: thông tin không nằm trong hàng số thì máy không dùng được.


Phần 9B — Làm sạch, chia học / thi, chống rò rỉ

📍 Bước 2 + 5 (vòng 2) — Làm sạch (9B.1), chia học / thi (9B.2), lưu ý khi coding: chỉ học từ phần học (9B.3), rò rỉ nhãn (9B.4).

9B.1 Kiểm tra chất lượng bảng X

🎯 Mục tiêu: kiểm tra chất lượng X: hàng trùng, vô cùng, ô trống
📥 Đầu vào: X
📤 Đầu ra: trong (IP có nhip_trung_vi trống)

In [ ]:
print("Hàng trùng nhau:", X.duplicated().sum(), "(nhiều IP có hành vi giống hệt nhau — đó là thật, không xóa)")
print("Giá trị vô cùng (inf):", int(np.isinf(X.to_numpy()).sum()))
print("Ô trống mỗi cột:\n" + X.isna().sum()[X.isna().sum() > 0].to_string())
# Vì sao nhip_trung_vi trống? -> IP chỉ kết nối 1 lần thì không có "khoảng cách tới lần trước"
trong = X.nhip_trung_vi.isna()
print("IP có nhip_trung_vi trống: so_ket_noi nhỏ nhất =", X.so_ket_noi[trong].min(), ", lớn nhất =", X.so_ket_noi[trong].max())

Kết quả: Hàng trùng nhau: 0 · Giá trị vô cùng (inf): 0 · ô trống: nhip_trung_vi 1103 · IP có nhip_trung_vi trống: so_ket_noi nhỏ nhất = 1 , lớn nhất = 1

👉 Hiểu: 1.103 IP chỉ kết nối đúng 1 lần → không có "khoảng cách tới lần trước" → ô trống là thật và có lý do, không phải lỗi. Ta điền nó ở 9B.3 — và phải điền đúng cách.

💼 Dùng khi nào: kiểm tra chất lượng bảng số trước khi huấn luyện: trùng, vô cùng, ô trống.
⚖️ Ảnh hưởng tới: một giá trị vô cùng (chia cho 0) làm nhiều mô hình báo lỗi hoặc học sai.

9B.2 Chia học / thi (có phân tầng)

🎯 Mục tiêu: chia IP thành phần học (80%) và phần thi (20%)
📥 Đầu vào: X, y
📤 Đầu ra: X_hoc, X_thi, y_hoc, y_thi (mọi bước sau dùng)

🧰 train_test_split(X, y, test_size=..., random_state=..., stratify=...): X, y bắt buộc · test_size tuỳ chọn: tỉ lệ phần thi (mặc định 0.25; ở đây 0.2) · random_state tuỳ chọn: cố định cách chia · stratify tuỳ chọn: None (mặc định, chia ngẫu nhiên thuần) / y (giữ cùng tỉ lệ nhãn ở cả 2 phần — nên dùng khi một lớp hiếm).

In [ ]:
from sklearn.model_selection import train_test_split
# test_size=0.2: giữ lại 20% số IP để thi; stratify=y: 2 phần giữ CÙNG tỉ lệ dò mật khẩu / chỉ quét
X_hoc, X_thi, y_hoc, y_thi = train_test_split(X, y, test_size=0.2, random_state=RANDOM_STATE, stratify=y)
print("Học:", X_hoc.shape, "| Thi:", X_thi.shape)
print(f"Tỉ lệ dò mật khẩu: học {y_hoc.mean():.3f} | thi {y_thi.mean():.3f}")
print("5 IP đầu của phần thi:", list(X_thi.index[:5]))
Warning:
Output truncated. This notebook contains too many cells to display efficiently.