Files
labs/Notebooks-CSAI/Lab7_Honeypot_UBA_KMeans.ipynb

106 KiB

Module 7 — Phân tích hành vi (UBA) & K-Means, trên hồ sơ hành vi THẬT của các IP tấn công honeypot

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.

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

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

  1. Hiểu ý tưởng UBA: mỗi tài khoản có thói quen, lệch xa thói quen thì đáng nghi (Phần 0).
  2. Thấy luật cứng ("sai nhiều thì khoá") bỏ lọt kẻ đã có mật khẩu thế nào (Phần 1).
  3. Hiểu K-Means bằng mắt trên 2 chiều, rồi chạy trên mọi đặc trưng và chọn K (Phần 2, 3).
  4. Đặt tên cụm từ chân dung, biến khoảng cách tới tâm cụm thành điểm khác thường và mức ưu tiên điều tra (Phần 4, 5).
  5. Biết hạn chế của K-Means và so với Isolation Forest (Phần 6, 7).
  6. Kiểm chứng cụm bằng một ít nhãn có sẵn (Phần 8).

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

  • Bài toán: gom các "tài khoản" cư xử giống nhau thành nhóm, rồi tìm tài khoản lệch khỏi nhóm của mình → gom cụm + chấm điểm bất thường.
  • Dữ liệu: bảng số, mỗi IP một hàng hồ sơ hành vi 30 ngày; không có nhãn để học (nhãn của giảng viên chỉ dùng để kiểm chứng ở Phần 8).
  • Vì vậy chọn: mốc = luật cứng; không nhãn → K-Means (gom nhóm, đo khoảng cách), so với Isolation Forest. Bảng ① ② ở mục Trước khi bắt đầu ngay dưới đây.
  • Xử lý gì trước: K-Means đo khoảng cách → phải nén số lớn + chuẩn hoá trước khi gom (Phần 3, 6).
  • Thước đo: không có đáp án → độ tách cụm (silhouette), người đọc chân dung cụm, và đối chiếu với nhãn có sẵn (Phần 8).

UBA (User Behavior Analytics — phân tích hành vi người dùng) dựa trên một ý đơn giản: mỗi tài khoản có thói quen (giờ làm, lượng dữ liệu, máy quen...). Kẻ trộm có thể lấy được mật khẩu, nhưng khó bắt chước được thói quen → khi hành vi lệch xa thói quen, ta nghi ngờ.

Ở bài này, mỗi IP tấn công honeypot được coi như một "tài khoản" có hồ sơ hành vi 30 ngày (24/08 → 21/09/2026). Cách làm y hệt UBA cho nhân viên thật: gom các tài khoản cư xử giống nhau thành nhóm, rồi đo xem mỗi tài khoản lệch khỏi nhóm của mình bao xa.

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

Bước Câu hỏi phải trả lời Phần
1. Thu thập dữ liệu dữ liệu gì, 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 · nhãn giảng viên 8.1
2. Làm sạch dữ liệu có gì cần xử lý: số lệch rất mạnh, thang đo khác nhau? 0.2 · 3.1 (nén + chuẩn hoá)
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ũ (luật cứng) làm được đến đâu? 0.3, Phần 1
4. Coding dựng K-Means, chọn K, đặt tên cụm, chấm điểm khác thường, xếp ưu tiên Phần 2, 3, 4, 5
5. Lưu ý khi coding lỗi nào làm cụm sai mà code không báo lỗi? Phần 6 (không nén số lớn, đổi K)
6. Đánh giá các mô hình so với mô hình khác, đối chiếu với nhãn có sẵn Phần 7, 8
7. Quy tắc bàn tay phải ✋** tổng kết, mang gì theo khi đi làm Phần 9

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

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

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

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

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

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

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

Dạng dữ liệu Thường dùng Trong khoá
Bảng số (mỗi đối tượng = một hàng số) mô hình cây: cây quyết định, Random Forest, XGBoost · Logistic Regression làm mốc so sánh Lab 3 (mỗi IP 7 số), M5, bài này (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, Naïve Bayes, Voting), Lab 10
Chuỗi theo thời gian (số kết nối mỗi giờ, CPU mỗi phút, chuỗi lời gọi API của một chương trình) phân rã chuỗi thời gian (xu hướng + nhịp ngày + phần dư) · HMM, LSTM khi cần học thứ tự sự kiện M4.2, Lab 6 Phần 3
Tệp nhị phân (mã độc) trích đặc trưng (kích thước, entropy…) rồi dùng mô hình cây · đổi file thành ảnh + CNN là hướng nghiên cứu, cần nhiều dữ liệu và GPU M5

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

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

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

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

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

Công cụ Dùng khi Được Phải chấp nhận Trong khoá
Luật / ngưỡng (IF/ELSE) kiểu tấn công đã biết rõ nhanh, không cần dữ liệu học, ai cũng hiểu chỉ bắt đúng cái đã viết; dễ bị né (thử chậm, Lab 3 1.2) M1, Lab 3 Phần 1, bài này 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
Cây quyết định cần luật đọc được cho người không chuyên ra đúng dạng IF/ELSE cây sâu dễ "học thuộc lòng" (overfit); dữ liệu đổi chút là cây đổi Lab M2 (M2.3), M5
Random Forest dữ liệu bảng có nhãn, cần ổn định chính xác, ít phải tinh chỉnh, ít overfit hơn một cây khó đọc từng quyết định (cần SHAP / độ quan trọng đặc trưng); nặng hơn một cây Lab 3 Phần 9, M8–M10
XGBoost dữ liệu bảng lớn, cần chính xác nhất thường đứng đầu trên dữ liệu bảng; chạy được GPU nhiều tham số phải chỉnh, chỉnh ẩu dễ overfit; khó giải thích M5, Lab 10 5.1
Isolation Forest không có nhãn, tìm cái lạ không cần ai gắn nhãn; ra thứ hạng ưu tiên "lạ" ≠ "xấu"; dữ liệu bẩn làm lệch (Lab 3 Phần 6); phải chọn contamination nếu dùng predict (Lab 3 5.6) Lab 3 Phần 5, Lab 6 Phần 4, bài này 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, bài này Phần 2–6
Phân rã chuỗi thời gian số liệu theo giờ / ngày có nhịp lặp tách nhịp bình thường khỏi đột biến cần chuỗi đủ dài; nhịp thay đổi thì phải tính lại M4.2, Lab 6 Phần 3
Deep learning (LSTM, CNN) dữ liệu rất lớn, dạng chuỗi / ảnh học được mẫu phức tạp cần rất nhiều dữ liệu và GPU; hộp đen Lab 10 5.1 (mạng nơ-ron MLP nhỏ)
LLM đọc, tóm tắt, giải thích log / cảnh báo viết báo cáo nhanh có thể bịa; tốn tiền; không để LLM tự quyết định chặn Lab 3 Phần 10

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) — như K-Means + Isolation Forest ở Phần 7; quy tắc bàn tay ở Lab 3 Phần 11.

📌 Pipeline xử lý luôn là 7 bước (chi tiết ở 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: hồ sơ hành vi, mỗi tài khoản một hàng số

📍 Bước 1 — Thu thập dữ liệu và Bước 2 — Làm sạch.

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ì? bảng số: mỗi hàng một IP tấn công ("tài khoản"), các cột thói quen + tên công cụ (0.1)
Lấy ở đâu? tính từ log honeypot SSH thật của mình (phần mềm Cowrie), 30 ngày 24/08 → 21/09/2026 — cùng thời kỳ với log của Lab 3
Thu thập thế nào? một chương trình đọc log Cowrie và gom theo IP: số kết nối, đăng nhập sai / đúng, độ dài phiên, số lệnh, có dùng proxy không, tải file, số ngày có mặt, họ công cụ SSH. File: /opt/csai/data/attackers/features.csv
Có nhãn chưa? bảng hành vi cố ý không có nhãn → học không giám sát. Nhãn của giảng viên (dấu vân tay công cụ, nhóm tấn công) nằm ở file riêng, chỉ dùng để kiểm chứng ở Phần 8
Có cần làm sạch? (a) đã làm khi xuất: bỏ IP nội bộ và IP của người quản trị. (b) trên bảng: phải xem số có lệch mạnh không và thang đo có khác nhau không (0.2) → nếu có, nén + chuẩn hoá trước khi gom cụm (3.1; Phần 6 cho thấy bỏ qua thì sao)

Giờ đọc bảng (0.1) và xem "thói quen" của số đông (0.2).

0.1 Đọc bảng

In [ ]:
import warnings
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
%matplotlib inline
pd.set_option("display.width", 220)
pd.set_option("display.max_columns", 30)
RANDOM_STATE = 42

goc = pd.read_csv("/opt/csai/data/attackers/features.csv").set_index("src_ip")
# Đổi tên cột sang tiếng Việt cho dễ đọc
TEN = {"connections": "so_ket_noi", "login_fail": "dang_nhap_sai", "login_success": "dang_nhap_dung", "fail_ratio": "ti_le_sai",
       "avg_session_sec": "phien_tb_giay", "max_session_sec": "phien_dai_nhat_giay", "sessions_with_cmds": "so_phien_co_lenh",
       "cmds_per_session": "lenh_moi_phien", "distinct_cmds": "so_lenh_khac_nhau", "uses_proxy": "dung_proxy",
       "file_download": "so_lan_tai_file", "active_days": "so_ngay", "client_family": "cong_cu"}
ho_so = goc.drop(columns="period").rename(columns=TEN)
# 📦 ho_so = bảng hồ sơ: mỗi HÀNG 1 IP ("tài khoản"), mỗi CỘT 1 thói quen đo trong 30 ngày
print("ho_so.shape =", ho_so.shape, "-> (số tài khoản, số cột)")
ho_so.loc[["1.10.195.220", "130.12.182.196", "185.246.128.133"]]

Kết quả: ho_so.shape = (2838, 13), rồi 3 hàng: 1.10.195.220 (2 kết nối, 0 đăng nhập, 1 ngày), 130.12.182.196 (3.757 kết nối, 3.310 lần sai, 323 lần đúng, 4 lệnh khác nhau, 6 ngày), 185.246.128.133 (1.764 kết nối, 0 lần sai, 1.661 lần đúng, dùng proxy, 28 ngày, công cụ OpenSSH_5.3).

📦 ho_so = bảng 2.838 tài khoản × 13 cột (cả notebook dùng).

👉 Hiểu: mỗi tài khoản là một hàng số — "chân dung thói quen" 30 ngày. Ba hàng ví dụ đã cho thấy ba kiểu rất khác nhau: gõ cửa rồi đi; dò mật khẩu ồn ào; và vào bằng mật khẩu đúng, đều đặn suốt 28 ngày.

Cột Nghĩa (trong 30 ngày)
so_ket_noi số lần kết nối
dang_nhap_sai, dang_nhap_dung, ti_le_sai số lần đăng nhập sai / đúng, tỉ lệ sai
phien_tb_giay, phien_dai_nhat_giay thời lượng trung bình / dài nhất của một phiên (giây)
so_phien_co_lenh, lenh_moi_phien, so_lenh_khac_nhau có gõ lệnh không, bao nhiêu lệnh mỗi phiên, bao nhiêu lệnh khác nhau
dung_proxy 1 = đã xin dùng máy làm bàn đạp (proxy)
so_lan_tai_file số lần tải file về máy
so_ngay số ngày có mặt
cong_cu phần mềm SSH khai báo (dạng chữ — không đưa vào K-Means, chỉ dùng để đọc kết quả)

0.2 "Thói quen" của số đông trông thế nào?

In [ ]:
SO = [c for c in ho_so.columns if c != "cong_cu"]            # 12 cột số đưa vào máy học
# 📦 SO = danh sách 12 cột số
tom_tat = pd.DataFrame({"trung vị (tài khoản 'giữa')": ho_so[SO].median(), "phân vị 90%": ho_so[SO].quantile(0.9), "lớn nhất": ho_so[SO].max()})
print(tom_tat.round(2).to_string())
print("\nCông cụ hay gặp:", ho_so.cong_cu.value_counts().head(6).to_dict())

Kết quả:

                     trung vị (tài khoản 'giữa')  phân vị 90%  lớn nhất
so_ket_noi                                   2.0        32.00    5569.0
dang_nhap_sai                                0.0         6.00    3310.0
dang_nhap_dung                               0.0        12.00    2295.0
phien_tb_giay                                0.5        21.05     317.4
so_lenh_khac_nhau                            0.0         2.00      21.0
so_ngay                                      1.0         2.00      29.0
...
Công cụ hay gặp: {'unknown': 1474, 'Go': 675, 'libssh_0.9.6': 130, 'ZGrab': 99, ...}

📦 SO = 12 cột số đưa vào máy học.

👉 Hiểu: "thói quen" của số đông là: 2 kết nối, không đăng nhập, phiên nửa giây, 1 ngày — gõ cửa rồi đi. 90% tài khoản dưới 32 kết nối, nhưng có tài khoản tới 5.569 → dữ liệu lệch rất mạnh, phải nén trước khi gom cụm (Phần 3, 6).

0.3 Vậy đây là bài toán gì? Chọn cách giải nào?

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

Không có nhãn, mỗi tài khoản là một hàng số → gom cụm (K-Means) để thấy các kiểu thói quen, rồi đo mỗi tài khoản lệch khỏi cụm của mình bao xa. Mốc so sánh: luật cứng (Phần 1).

👉 Hiểu: chọn cách giải sau khi đã biết dữ liệu có nhãn hay không và số có lệch không (đầu Phần 0, 0.2).


Phần 1 — Luật cứng thất bại thế nào?

📍 Bước 3 — Chọn mô hình (mốc so sánh): cách cũ — luật "khoá nếu..." — làm được đến đâu?

Cách truyền thống: viết luật "khóa nếu...". Ta thử một luật rất hợp lý: khóa tài khoản nếu đăng nhập sai quá 50 lần, HOẶC có tải file về.

In [ ]:
# ✏️ SỬA: luật của bạn
NGUONG_SAI = 50
bi_khoa = (ho_so.dang_nhap_sai > NGUONG_SAI) | (ho_so.so_lan_tai_file > 0)
print(f"Luật khóa {bi_khoa.sum()} / {len(ho_so)} tài khoản")
# Những tài khoản "đáng sợ" mà luật KHÔNG khóa: đăng nhập ĐÚNG rất nhiều lần, hoặc dùng máy làm bàn đạp
lot = ho_so[~bi_khoa & ((ho_so.dang_nhap_dung > 100) | (ho_so.dung_proxy == 1))]
print(f"Lọt luật mà đăng nhập đúng > 100 lần hoặc dùng proxy: {len(lot)} tài khoản. 6 tài khoản đăng nhập đúng nhiều nhất:")
lot.sort_values("dang_nhap_dung", ascending=False).head(6)[["so_ket_noi", "dang_nhap_sai", "dang_nhap_dung", "dung_proxy", "so_ngay", "cong_cu"]]

Kết quả: Luật khóa 245 / 2838 tài khoản · Lọt luật mà đăng nhập đúng > 100 lần hoặc dùng proxy: 90 tài khoản, đứng đầu: 27.71.230.251 (2.001 lần đúng), 185.246.128.133 (0 sai, 1.661 đúng, proxy, 28 ngày), 125.212.226.218 (1.434 đúng, 0 sai), 14.225.204.246...

📦 bi_khoa = True ở tài khoản bị luật khóa · lot = 90 tài khoản đáng ngờ mà luật bỏ qua.

👉 Hiểu: luật "sai nhiều thì khóa" chỉ bắt kẻ đoán mật khẩu. Kẻ đã có mật khẩu đúng (0 lần sai, hàng nghìn lần đúng) thì luật không bao giờ kêu — đúng kịch bản tài khoản bị chiếm quyền (account takeover) mà UBA sinh ra để bắt. Thêm luật mới thì lại khóa nhầm người khác — vòng luẩn quẩn.


Phần 2 — K-Means bằng mắt: 2 chiều, 3 bước

📍 Bước 4 — Coding: hiểu K-Means trên 2 chiều trước.

K-Means gom các điểm giống nhau thành K cụm, không cần ai cho đáp án:

  1. Thả K tâm cụm (dấu ✖) ở vị trí ngẫu nhiên.
  2. Gom: mỗi điểm chạy về tâm gần nhất (tô màu theo tâm đó).
  3. Dời tâm: mỗi tâm dời tới chính giữa (trung bình) các điểm của nó.

Lặp lại bước 2–3 tới khi tâm đứng yên. Để nhìn thấy, ta chỉ dùng 2 đặc trưng: số kết nối và thời lượng phiên trung bình (đều nén bằng log1p như Module 3, để số rất lớn không chiếm hết hình).

"Vòng dời tâm" là gì? Một vòng = làm một lần bước 2 (gom mỗi chấm về tâm gần nhất) rồi bước 3 (dời mỗi tâm vào giữa các chấm của nó). Vòng sau, vì tâm đã dời, một số chấm thấy tâm khác gần hơn → đổi màu → tâm lại dời. Khi không chấm nào đổi màu nữa thì tâm đứng yên: K-Means dừng.

Cách đọc hình (4 hình cạnh nhau, đọc từ trái sang phải):

Thứ trên hình Nghĩa
mỗi chấm 1 tài khoản (1 IP tấn công)
trục ngang số kết nối trong 30 ngày (đã nén log: càng sang phải càng nhiều kết nối)
trục đứng thời lượng trung bình một phiên (đã nén log: càng lên cao phiên càng dài)
màu của chấm tài khoản đó đang thuộc cụm nào (cụm = nhóm chấm có chung tâm gần nhất). Màu chưa có nghĩa gì — máy chỉ đánh số 0, 1, 2; ý nghĩa do ta đọc ra từ vị trí của nhóm
dấu ✖ đen tâm cụm = điểm "trung bình" của nhóm cùng màu
hình 1 → 4 trước khi dời tâm → sau 1 vòng → sau 2 vòng → sau 10 vòng

Ô dưới vẽ 4 hình, rồi in ý nghĩa của từng màu ở hình cuối (nhóm đó có bao nhiêu tài khoản, kết nối và phiên điển hình bao nhiêu).

In [ ]:
from sklearn.cluster import KMeans
hai_chieu = np.log1p(ho_so[["so_ket_noi", "phien_tb_giay"]]).to_numpy()
# ✏️ SỬA: số cụm cho phần minh họa
K_MINH_HOA = 3
MAU = ["#1f77b4", "#ff7f0e", "#2ca02c", "#d62728", "#9467bd", "#8c564b"]          # cụm 0, 1, 2... lần lượt mang các màu này
TEN_MAU = ["xanh dương", "cam", "xanh lá", "đỏ", "tím", "nâu"]
tam_ban_dau = hai_chieu[np.random.RandomState(1).choice(len(hai_chieu), K_MINH_HOA, replace=False)]   # thả tâm ngẫu nhiên
hinh, o = plt.subplots(1, 4, figsize=(18, 4), sharey=True)
for truc, so_vong in zip(o, [0, 1, 2, 10]):
    if so_vong == 0:
        nhan_ = np.argmin(((hai_chieu[:, None, :] - tam_ban_dau[None]) ** 2).sum(-1), axis=1)   # gom về tâm gần nhất
        tam = tam_ban_dau
    else:
        km_ = KMeans(K_MINH_HOA, init=tam_ban_dau, n_init=1, max_iter=so_vong).fit(hai_chieu)   # chạy đúng so_vong vòng
        nhan_, tam = km_.labels_, km_.cluster_centers_
    truc.scatter(hai_chieu[:, 0], hai_chieu[:, 1], c=np.array(MAU)[nhan_], s=6, alpha=0.5)   # màu của cụm c = MAU[c]
    truc.scatter(tam[:, 0], tam[:, 1], marker="X", s=250, c="black")
    truc.set_title("bước 1: thả tâm ngẫu nhiên, gom" if so_vong == 0 else f"sau {so_vong} vòng dời tâm")
    truc.set_xlabel("log(1 + số kết nối)")
o[0].set_ylabel("log(1 + phiên trung bình, giây)")
plt.tight_layout(); plt.show()

# Ý nghĩa từng màu ở hình cuối (sau 10 vòng): đọc từ giá trị điển hình (trung vị) của nhóm
for c in range(K_MINH_HOA):
    nhom = ho_so[nhan_ == c]                      # các tài khoản mang màu c ở hình cuối
    print(f"màu {TEN_MAU[c]:10s}: {len(nhom):5d} tài khoản | kết nối điển hình {nhom.so_ket_noi.median():6.0f} | phiên điển hình {nhom.phien_tb_giay.median():6.1f} giây")

Kết quả: 4 hình cạnh nhau, rồi dòng in ý nghĩa từng màu ở hình cuối:

màu xanh dương:   377 tài khoản | kết nối điển hình      2 | phiên điển hình   26.6 giây
màu cam       :   352 tài khoản | kết nối điển hình     97 | phiên điển hình    4.5 giây
màu xanh lá   :  2109 tài khoản | kết nối điển hình      2 | phiên điển hình    0.0 giây
  • Hình 1 (thả tâm): 3 dấu ✖ rơi ngẫu nhiên — một ở góc trên trái, một ở góc dưới trái, một ở dưới giữa. Mỗi chấm tô màu theo ✖ gần nó nhất.
  • Hình 2–3 (sau 1, 2 vòng): ✖ cam dời từ dưới giữa lên giữa đám chấm bên phải; ✖ xanh dương dời xuống giữa đám chấm phía trên; vài chấm ở ranh giới đổi màu.
  • Hình 4 (sau 10 vòng): ✖ đứng yên — không chấm nào còn đổi màu.

👉 Hiểu — 3 màu là 3 kiểu hành vi mà máy tự tìm ra (tên do ta đặt sau khi đọc dòng in):

  • 🟢 xanh lá (2.109 tài khoản, góc dưới trái): ~2 kết nối, phiên 0 giây → "gõ cửa" — máy quét chạm vào rồi đi.
  • 🔵 xanh dương (377, trên trái): ~2 kết nối nhưng phiên ~27 giây → "ít mà lâu" — vào ít lần nhưng ở lại (thử mật khẩu chậm, hoặc gõ lệnh).
  • 🟠 cam (352, bên phải): ~97 kết nối, phiên ~4,5 giây → "bền bỉ" — quay lại rất nhiều lần.

K-Means chỉ lặp 2 việc đơn giản — gom về tâm gần nhất, dời tâm vào giữa nhóm — mà tách được 3 kiểu này, không ai nói trước. Đổi K_MINH_HOA thành 2 hoặc 4 để xem cách chia thay đổi.


Phần 3 — K-Means trên mọi đặc trưng, và chọn K

📍 Bước 2 (lần 2, trên bảng số) + Bước 4 — Coding: nén + chuẩn hoá (3.1), chọn K, chạy K-Means.

3.1 Chuẩn bị: nén + chuẩn hóa

12 cột nào? Bảng ho_so có 13 cột: 12 cột số (danh sách SO tạo ở 0.2) + 1 cột chữ cong_cu (tên phần mềm SSH). K-Means chỉ tính được khoảng cách giữa số, nên cột chữ để riêng ra — chỉ dùng để đọc kết quả ở Phần 4.

K-Means đo khoảng cách, nên phải đưa 12 cột về cùng thước đo (Module 3, Phần 4): log1p nén số rất lớn (5.569 kết nối), StandardScaler đưa mỗi cột về "hơn mức thường mấy bậc". Không làm bước này, cột có số to sẽ quyết định hết (Phần 6 sẽ cho thấy).

In [ ]:
from sklearn.preprocessing import StandardScaler
X = pd.DataFrame(StandardScaler().fit_transform(np.log1p(ho_so[SO])), index=ho_so.index, columns=SO)
# 📦 X = 12 cột đã nén + chuẩn hóa: đầu vào của K-Means
print("X.shape =", X.shape, "| trung bình mỗi cột ~0:", X.mean().abs().max().round(3), "| độ lệch chuẩn ~1:", X.std(ddof=0).round(2).unique())

Kết quả: X.shape = (2838, 12) | trung bình mỗi cột ~0: 0.0 | độ lệch chuẩn ~1: [1.]

📦 X = 12 cột đã nén + chuẩn hóa — đầu vào của K-Means.

👉 Hiểu: 12 cột = 13 cột của ho_so trừ cột chữ cong_cu. giờ "1 bậc lệch" ở cột số ngày và "1 bậc lệch" ở cột số kết nối nặng như nhau khi K-Means đo khoảng cách.

3.2 Chọn K: "khuỷu tay" và silhouette

K-Means bắt ta nói trước: "chia thành mấy nhóm?" (K). Máy không tự biết. Có hai thước đo giúp ta chọn — hiểu bằng một ví dụ đời thường:

Ví dụ: đặt trạm xe buýt cho một khu phố. Mỗi nhà là một chấm, mỗi trạm là một tâm cụm, mỗi nhà đi bộ tới trạm gần nhất.

① Inertia = tổng quãng đường đi bộ của cả khu phố (chính xác: tổng bình phương khoảng cách từ mỗi chấm tới tâm của nó).

  • Thêm trạm thì ai cũng đi ít hơn hoặc bằng → inertia luôn giảm khi K tăng. Đặt mỗi nhà một trạm thì inertia = 0, nhưng vô nghĩa.
  • Vì vậy không chọn K có inertia nhỏ nhất. Ta tìm chỗ "khuỷu tay": trước đó thêm 1 trạm thì quãng đường giảm mạnh, sau đó thêm trạm chỉ giảm chút ít → dừng ở khuỷu tay là vừa.

② Silhouette = mỗi nhà có thật sự thuộc về trạm của mình không. Với một nhà:

  • a = khoảng cách trung bình tới các nhà cùng trạm (hàng xóm cùng nhóm);
  • b = khoảng cách trung bình tới các nhà của trạm gần nhất kế tiếp (nhóm hàng xóm khác);
  • silhouette = (b − a) / max(a, b): gần 1 → nhà nằm gọn trong nhóm mình, xa nhóm khác; gần 0 → nằm ở ranh giới hai nhóm; âm → có lẽ xếp nhầm nhóm.
  • Silhouette của cả cách chia = trung bình của mọi nhà. Khác inertia, silhouette không tự tăng theo K → so được giữa các K.

Ô 3.2a dưới đây tính silhouette bằng tay cho một tài khoản thật, để thấy a, b là gì. Ô 3.2b vẽ cả hai thước đo cho K = 2 → 8.

In [ ]:
# 3.2a Tính silhouette BẰNG TAY cho 1 tài khoản, với K = 4
from sklearn.metrics import silhouette_score, silhouette_samples
km4 = KMeans(n_clusters=4, n_init=10, random_state=RANDOM_STATE).fit(X)
IP_VI_DU = "1.10.195.220"                                   # ✏️ SỬA: thử tài khoản khác, ví dụ "185.246.128.133"
i = X.index.get_loc(IP_VI_DU)                               # vị trí (số thứ tự hàng) của tài khoản trong X
nhan4 = km4.labels_
kc = np.linalg.norm(X.to_numpy() - X.to_numpy()[i], axis=1) # khoảng cách từ tài khoản này tới MỌI tài khoản
cung = (nhan4 == nhan4[i]); cung[i] = False                 # cùng cụm, trừ chính nó
a = kc[cung].mean()
b = min(kc[nhan4 == c].mean() for c in range(4) if c != nhan4[i])   # cụm khác GẦN NHẤT (trung bình nhỏ nhất)
print(f"{IP_VI_DU}: ở cụm {nhan4[i]} | a (tới bạn cùng cụm) = {a:.2f} | b (tới cụm gần nhất khác) = {b:.2f}")
print(f"silhouette = (b - a) / max(a, b) = ({b:.2f} - {a:.2f}) / {max(a, b):.2f} = {(b - a) / max(a, b):.3f}")
print(f"thư viện sklearn tính được: {silhouette_samples(X, nhan4)[i]:.3f}   (phải giống nhau)")

Kết quả: 1.10.195.220: ở cụm 0 | a (tới bạn cùng cụm) = 0.56 | b (tới cụm gần nhất khác) = 4.19 → silhouette = (4.19 - 0.56) / 4.19 = 0.865; sklearn tính ra 0.865 — giống hệt.

📦 km4 = K-Means với K = 4 · a, b = hai khoảng cách trung bình của tài khoản này.

👉 Hiểu: máy quét 1.10.195.220 nằm sát các bạn cùng cụm "gõ cửa" (0,56) và xa cụm khác gần nhất (4,19) → silhouette 0,865, gần 1: xếp đúng chỗ. Thử 185.246.128.133: một tài khoản lạ sẽ có a lớn, silhouette thấp hơn.

3.2b Inertia và silhouette cho K = 2 → 8

Cách đọc hình: trục ngang = K. Hình trái = inertia: tìm chỗ đường gãy (khuỷu tay). Hình phải = silhouette: càng cao càng tách bạch.

In [ ]:
from sklearn.metrics import silhouette_score
dong = []
for k in range(2, 9):
    km_ = KMeans(n_clusters=k, n_init=10, random_state=RANDOM_STATE).fit(X)
    dong.append({"K": k, "inertia": km_.inertia_, "silhouette": silhouette_score(X, km_.labels_, sample_size=2000, random_state=0)})
chon_k = pd.DataFrame(dong).set_index("K")
hinh, o = plt.subplots(1, 2, figsize=(12, 3.5))
chon_k.inertia.plot(ax=o[0], marker="o", title="Inertia (tìm chỗ gãy)")
chon_k.silhouette.plot(ax=o[1], marker="o", color="green", title="Silhouette (càng cao càng tốt)")
plt.tight_layout(); plt.show()
chon_k.round(3)

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

K inertia silhouette
2 20.564 0,655
3 17.307 0,561
4 14.359 0,574
5 11.509 0,582
6 9.669 0,586
7 8.051 0,604
8 6.916 0,615

📦 chon_k = bảng trên.

👉 Hiểu: đường inertia cong đều, không có "khuỷu tay" rõ; silhouette cao nhất ở K=2 (chỉ tách "gõ cửa" và "còn lại" — đúng nhưng quá thô), rồi tăng dần theo K. Đây là chuyện rất thường gặp với dữ liệu thật: thước đo chỉ gợi ý, không quyết định thay. Vậy chọn K mấy? — xem 3.3.

3.3 Vậy chọn K mấy? Rồi chạy K-Means

Kết quả 3.2b cho thấy cả hai thước đo không chỉ ra một K rõ ràng — chuyện rất hay gặp với dữ liệu thật. Khi đó chọn theo 3 bước:

  1. Loại K quá thô: K = 2 có silhouette cao nhất nhưng chỉ tách "gõ cửa" khỏi "còn lại" — đúng mà vô dụng cho SOC.
  2. Thử vài K ở giữa (3, 4, 5), mỗi K đọc chân dung cụm (Phần 4): mỗi cụm có đặt được tên và xử lý khác nhau được không?
  3. Chọn K nhỏ nhất mà cụm nào cũng có tên và có cách xử lý riêng. Ở bài này là K = 4 (4 kiểu: gõ cửa, thử 1 lần, vào và làm việc, cài đồ — Phần 4). Phần 6.2 xem K = 5 tách thêm được gì.

Nói ngắn: thước đo gợi ý vùng, người phân tích chốt K bằng câu hỏi "có đặt tên và hành động được không?".

In [ ]:
# ✏️ SỬA: số cụm (thử 3, 5)
SO_CUM = 4
kmeans = KMeans(n_clusters=SO_CUM, n_init=10, random_state=RANDOM_STATE).fit(X)
ho_so["cum"] = kmeans.labels_                         # mỗi tài khoản thuộc cụm số mấy
# 📦 kmeans = mô hình đã học (kmeans.cluster_centers_ = tọa độ SO_CUM tâm) · ho_so.cum = cụm của từng tài khoản
print("Số tài khoản mỗi cụm:", ho_so.cum.value_counts().sort_index().to_dict())

Kết quả: Số tài khoản mỗi cụm: {0: 1909, 1: 249, 2: 521, 3: 159}

📦 kmeans = mô hình đã học (kmeans.cluster_centers_ = tọa độ 4 tâm) · ho_so.cum = cụm của từng tài khoản.

👉 Hiểu: chọn K = 4 theo 3 bước ở trên: K = 2 quá thô; K = 3, 4, 5 đọc chân dung; 4 là K nhỏ nhất mà mỗi cụm đều đặt tên và xử lý riêng được (Phần 4). Cụm 0 chiếm 1.909 / 2.838 tài khoản — đúng như số đông "gõ cửa" thấy ở 0.2.


Phần 4 — Chân dung từng cụm: máy gom, người đặt tên

📍 Bước 4 — Coding: đọc chân dung từng cụm.

K-Means không biết cụm nào là gì — nó chỉ đánh số 0, 1, 2... Người phân tích đọc giá trị điển hình (trung vị) của mỗi cụm rồi đặt tên.

In [ ]:
chan_dung = ho_so.groupby("cum").agg(
    so_tai_khoan=("so_ket_noi", "size"), ket_noi=("so_ket_noi", "median"), sai=("dang_nhap_sai", "median"),
    dung=("dang_nhap_dung", "median"), phien_tb=("phien_tb_giay", "median"), lenh_moi_phien=("lenh_moi_phien", "median"),
    lenh_khac_nhau=("so_lenh_khac_nhau", "median"), ti_le_proxy=("dung_proxy", "mean"), tai_file_tb=("so_lan_tai_file", "mean"),
    so_ngay=("so_ngay", "median"))
chan_dung["cong_cu_hay_gap"] = ho_so.groupby("cum").cong_cu.agg(lambda s: ", ".join(f"{k} ({v})" for k, v in s.value_counts().head(2).items()))
# 📦 chan_dung = mỗi cụm 1 hàng: giá trị điển hình + công cụ hay gặp
chan_dung.round(2)

Kết quả:

     so_tai_khoan  ket_noi  sai   dung  phien_tb  lenh_moi_phien  lenh_khac_nhau  ti_le_proxy  tai_file_tb  so_ngay        cong_cu_hay_gap
cum
0            1909      2.0  0.0    0.0      0.00             0.0             0.0         0.00         0.00      1.0   unknown (1377), Go (212)
1             249    328.0  2.0  151.0      6.86             1.0            10.0         0.02         0.00      3.0   Go (222), phpseclib_1.0 (18)
2             521      2.0  1.0    0.0     10.00             0.0             0.0         0.03         0.00      1.0   Go (235), unknown (97)
3             159      5.0  6.0    3.0      3.30             2.0             2.0         0.00         3.91      1.0   libssh_0.9.6 (120), libssh2_1.11.1 (20)

📦 chan_dung = bảng trên.

👉 Hiểu — đặt tên:

  • Cụm 0 — "gõ cửa" (1.909, 67%): 2 kết nối, không đăng nhập, không khai công cụ → máy quét.
  • Cụm 2 — "thử 1 lần" (521): giữ phiên ~10 giây, thử 1 lần rồi đi.
  • Cụm 1 — "vào và làm việc" (249): hàng trăm kết nối, đăng nhập được (trung vị 151 lần), gõ trung bình 10 lệnh khác nhau — kẻ thật sự khai thác.
  • Cụm 3 — "cài đồ" (159): mỗi tài khoản tải file ~4 lần, gần hết dùng libssh_0.9.6 — cùng công cụ mà chiến dịch cài khóa SSH mdrfckr đã dùng trong dữ liệu tháng 7 (rất có thể cùng một chiến dịch; muốn chắc phải xem file đã tải).

Không ai nói cho máy biết chiến dịch đó là gì — nó tự gom các tài khoản cùng hành vi tải file + cùng công cụ vào một cụm.


Phần 5 — Khoảng cách tới tâm cụm → điểm khác thường → ưu tiên điều tra

📍 Bước 4 — Coding: từ cụm ra điểm khác thường và mức ưu tiên.

⚠️ Đừng nhầm: ở công ty, UBA chấm nhân viên — người "lệch thói quen" có thể là tài khoản bị chiếm, nên ta gọi là điểm uy tín và quyết định "cho qua / xác minh / chặn". Nhưng ở bài này mọi "tài khoản" đều là kẻ tấn công (IP tấn công honeypot) — không ai "đáng tin" cả. Cùng một phép tính, câu hỏi đổi thành: trong đám kẻ tấn công, ai khác thường nhất, đáng để người điều tra xem trước? Bot điển hình (giống hệt hàng trăm bot khác trong nhóm) cứ để hệ thống tự chặn; kẻ khác thường (bám dai, dùng máy làm bàn đạp, đăng nhập được rồi làm việc lạ) mới đáng tốn thời gian người.

5.1 Điểm khác thường 0–100

Một tài khoản càng xa tâm cụm của chính nó càng khác các thành viên còn lại của nhóm. Ta đổi khoảng cách thành điểm khác thường:

điểm khác thường = 100 × (khoảng cách / khoảng cách ở phân vị 99%), cắt trong đoạn 0–100

→ gần tâm ≈ 0 (điển hình của nhóm, "bot như mọi bot"); xa bằng hoặc hơn 1% tài khoản lệch nhất → 100.

Cách đọc hình: cột = số tài khoản theo điểm khác thường; 2 vạch dọc = ranh giới 3 mức ưu tiên ở 5.2.

In [ ]:
tam_cua_moi_tk = kmeans.cluster_centers_[ho_so.cum]                       # tọa độ tâm cụm của từng tài khoản
khoang_cach = np.linalg.norm(X.to_numpy() - tam_cua_moi_tk, axis=1)       # khoảng cách thẳng (Euclid) tới tâm đó
moc_99 = np.percentile(khoang_cach, 99)
ho_so["diem_la"] = (100 * khoang_cach / moc_99).clip(0, 100)
# 📦 khoang_cach = độ lệch của từng tài khoản khỏi nhóm mình · ho_so.diem_la = điểm khác thường 0-100
# ✏️ SỬA: 2 ngưỡng ưu tiên
NGUONG_XEM, NGUONG_DIEU_TRA = 20, 60
plt.figure(figsize=(10, 3.5))
plt.hist(ho_so.diem_la, bins=50, color="#457b9d")
plt.axvline(NGUONG_XEM, color="orange", ls="--"); plt.axvline(NGUONG_DIEU_TRA, color="red", ls="--")
plt.xlabel("điểm khác thường (0 = điển hình của nhóm, 100 = khác nhất)"); plt.ylabel("số tài khoản"); plt.yscale("log"); plt.show()
print(f"Khoảng cách: trung vị {np.median(khoang_cach):.2f}, phân vị 99% = {moc_99:.2f}")

Kết quả: biểu đồ cột (trục đứng thang log): phần lớn tài khoản dồn ở gần 0 (điển hình của nhóm mình), đuôi kéo dài sang phải; một cột riêng ở 100 (tài khoản lệch bằng hoặc hơn 1% lệch nhất); 2 vạch ở 20 và 60. Khoảng cách: trung vị 0.41, phân vị 99% = 7.57

📦 khoang_cach = độ lệch của từng tài khoản khỏi tâm cụm mình · ho_so.diem_la = điểm khác thường 0–100.

👉 Hiểu: một nửa số tài khoản cách tâm chưa tới 0,41 — gần như y hệt các thành viên khác của nhóm: bot chạy cùng một kịch bản. Điểm khác thường biến "khoảng cách" khó hiểu thành con số 0–100: càng cao càng đáng để người xem.

5.2 Xếp ưu tiên 3 mức

Mọi tài khoản ở đây đều là kẻ tấn công và đều bị chặn tự động (fail2ban / danh sách đen). Ba mức dưới đây trả lời câu hỏi khác: người phân tích nên nhìn ai trước?

Điểm khác thường Mức Xử lý
< NGUONG_XEM 🟢 bot điển hình để hệ thống tự chặn, không tốn người — hành vi giống hệt hàng trăm bot cùng nhóm
giữa 2 ngưỡng 🟡 xem khi có thời gian khác nhóm một chút; gom lại xem theo lô
≥ NGUONG_DIEU_TRA 🔴 điều tra ngay khác hẳn nhóm của mình: có thể là người thật, công cụ riêng, chiến dịch có chủ đích → đọc log phiên, tra IP, tìm IP cùng kiểu

(Ở công ty, cùng phép tính này áp cho tài khoản nhân viên sẽ thành "cho qua / xác minh thêm (MFA) / khoá" — xem câu hỏi thảo luận cuối bài.)

In [ ]:
muc = pd.cut(ho_so.diem_la, [-1, NGUONG_XEM, NGUONG_DIEU_TRA, 101], right=False,      # right=False: khoảng [a, b)
             labels=["🟢 bot điển hình", "🟡 xem khi có thời gian", "🔴 điều tra ngay"])
ho_so["muc"] = muc
print(ho_so.muc.value_counts().reindex(["🟢 bot điển hình", "🟡 xem khi có thời gian", "🔴 điều tra ngay"]).to_string())
print("\n8 tài khoản KHÁC THƯỜNG nhất:")
ho_so.sort_values("diem_la", ascending=False).head(8)[["so_ket_noi", "dang_nhap_sai", "dang_nhap_dung", "phien_tb_giay", "dung_proxy", "so_lan_tai_file", "so_ngay", "cong_cu", "cum", "diem_la"]].round(1)

Kết quả: 🟢 bot điển hình 1739 · 🟡 xem khi có thời gian 969 · 🔴 điều tra ngay 130, rồi 8 tài khoản khác thường nhất (đều 100): 192.42.116.96, 192.42.116.64 (1 kết nối, đăng nhập được, xin dùng proxy ngay), 80.94.95.118 (34 lần đúng, proxy, 11 ngày), 80.94.95.116…

👉 Hiểu: cả 2.838 tài khoản đều là kẻ tấn công và đều nên bị chặn tự động. Điểm khác thường không nói "ai đáng tin" mà nói "ai đáng tốn thời gian người": thay vì đọc 2.838 IP, người phân tích chỉ cần đọc 130 IP (5%) trước. Những IP đó có điểm chung: dùng máy làm bàn đạp (proxy) hoặc bám rất dai — kiểu có chủ đích, khác hẳn bot "gõ cửa" chạy hàng loạt.

5.2b Điều tra sâu: 8 tài khoản "khác thường nhất" khác ở chỗ nào?

Điểm cao chỉ nói "đáng xem". Người phân tích phải trả lời tiếp: khác ở đâu, và khác như vậy có nghĩa là gì? Ta điều tra theo 3 bước, đúng như một ca trực SOC:

  1. Cột nào kéo tài khoản ra xa tâm? — tách khoảng cách thành phần đóng góp của từng cột.
  2. Log thô nói gì? — xem tận mắt các dòng log của một tài khoản.
  3. Có "anh em" không? — tìm các IP cùng dải mạng có hành vi giống hệt (pivot).

Bước 1 — cách tách: khoảng cách tới tâm = căn của tổng các bình phương độ lệch trên 12 cột. Chia bình phương độ lệch của mỗi cột cho tổng → được % đóng góp của cột đó (12 cột cộng lại = 100%).

In [ ]:
tam_df = pd.DataFrame(kmeans.cluster_centers_[ho_so.cum], index=X.index, columns=SO)   # tâm cụm của từng tài khoản, dạng bảng
binh_phuong = (X - tam_df) ** 2                                   # độ lệch bình phương trên từng cột
dong_gop = binh_phuong.div(binh_phuong.sum(axis=1), axis=0) * 100  # % đóng góp của mỗi cột vào khoảng cách (mỗi hàng cộng = 100)
# 📦 dong_gop = mỗi tài khoản × 12 cột: cột đó chiếm bao nhiêu % "độ khác thường"
SO_TOP = 8                                                        # ✏️ SỬA: xem bao nhiêu tài khoản đầu bảng
dong = []
for ip in ho_so.sort_values("diem_la", ascending=False).index[:SO_TOP]:
    c = ho_so.at[ip, "cum"]
    trung_vi_cum = ho_so.loc[ho_so.cum == c, SO].median()        # tài khoản "điển hình" của cụm đó
    cot1, cot2 = dong_gop.loc[ip].nlargest(2).index              # 2 cột đóng góp nhiều nhất
    dong.append({"IP": ip, "cụm": c, "điểm": round(ho_so.at[ip, "diem_la"]),
                 "cột lệch nhất": f"{cot1} = {ho_so.at[ip, cot1]} (cụm: {trung_vi_cum[cot1]:g}) → {dong_gop.at[ip, cot1]:.0f}%",
                 "cột thứ hai": f"{cot2} = {ho_so.at[ip, cot2]} (cụm: {trung_vi_cum[cot2]:g}) → {dong_gop.at[ip, cot2]:.0f}%"})
print(pd.DataFrame(dong).to_string(index=False))
print("\nTỉ lệ tài khoản dùng proxy trong từng cụm:", ho_so.groupby("cum").dung_proxy.mean().round(3).to_dict())
print("Số tài khoản bị cắt ở đúng 100 điểm:", int((ho_so.diem_la >= 100).sum()))

Kết quả: cả 8 tài khoản đầu bảng đều có cùng một cột chiếm phần lớn khoảng cách: dung_proxy = 1 trong khi tài khoản điển hình của cụm là 0 — chiếm 76–99% "độ khác thường". Cột thứ hai chỉ góp 0–14% (so_ngay: có mặt 4–11 ngày trong khi cụm điển hình 1 ngày; 77.90.185.17 lại không gõ lệnh nào trong khi cụm "vào và làm việc" của nó gõ trung bình 113 phiên có lệnh). Tỉ lệ dùng proxy trong từng cụm: 0% · 2,4% · 3,5% · 0% → đúng là rất hiếm. Có 29 tài khoản bị cắt ở đúng 100 điểm.

📦 dong_gop = mỗi tài khoản × 12 cột: % đóng góp của cột đó vào khoảng cách tới tâm.

👉 Hiểu: "khác thường" ở đây không bí ẩn: các tài khoản này khác nhóm mình ở đúng một hành vi — xin dùng máy của ta làm bàn đạp. Bước 2–3 kiểm tra hành vi đó có thật không và là của ai. (Lưu ý: 29 tài khoản cùng 100 điểm nghĩa là điểm không phân biệt được bên trong nhóm đầu bảng — muốn xếp tiếp phải dùng thêm tiêu chí khác, ví dụ số ngày có mặt.)

Bước 2 — log thô. Cột dung_proxy = 1 nghĩa là tài khoản đã xin mở đường hầm (SSH direct-tcpip): dùng máy của ta làm bàn đạp để đi tiếp ra Internet. Xem tận mắt một tài khoản đầu bảng trong log honeypot (cùng 30 ngày), rồi xem nó xin đi đâu (bảng đường hầm của Module 6, lấy từ toàn bộ log):

In [ ]:
%%bash
IP=192.42.116.96        # ✏️ SỬA: thử 80.94.95.118
echo "--- log honeypot 30 ngày của $IP (giờ UTC) ---"
grep -ah "\"$IP\"" /opt/csai/data/honeypot_m1/* | grep -aoE '"eventid": "[^"]*"|"timestamp": "[^"]*"|"version": "[^"]*"|"username": "[^"]*"|"password": "[^"]*"' | paste -sd' ' | sed 's/"eventid"/\n"eventid"/g' | head -12
echo; echo "--- đường hầm $IP đã xin (ngày, đích, cổng, số lần) ---"
grep -a ",$IP," /opt/csai/data/m6_honeypot/tunnels.csv

Kết quả (log thô của 192.42.116.96, giờ UTC):

cowrie.session.connect   2026-08-27T18:58:44Z
cowrie.client.version    18:58:44  SSH-2.0-OpenSSH_10.5
cowrie.login.failed      18:58:46  root
cowrie.login.success     18:58:47  root / 123

Đường hầm đã xin: 2026-08-28, 192.42.116.96, google.com, 443, 1 (ngày theo giờ Việt Nam).

👉 Hiểu: cả "cuộc tấn công" chỉ 3 giây: kết nối, thử root một lần, vào được bằng root/123, rồi không gõ lệnh nào mà xin mở một đường hầm tới google.com:443. Đây là hành vi của máy dò proxy mở (proxy checker): nó không cần chiếm máy, chỉ cần biết "máy này có cho đi nhờ ra Internet không". Nếu được, máy của ta sẽ bị bán / dùng làm bàn đạp để kẻ khác giấu IP thật khi tấn công nơi khác. Đổi IP=80.94.95.118 để thấy kiểu khác: vào lại hằng ngày bằng mật khẩu rỗng và mở đường hầm tới nhiều địa chỉ.

Bước 3 — pivot theo dải IP. Kẻ chạy dịch vụ dò proxy thường xoay vòng nhiều IP trong cùng một dải để khỏi bị chặn. Đếm các IP cùng dải /24 (3 số đầu giống nhau) trong hồ sơ 30 ngày và trong toàn bộ bảng đường hầm:

In [ ]:
duong_ham = pd.read_csv("/opt/csai/data/m6_honeypot/tunnels.csv")         # ngày, IP nguồn, đích, cổng, số lần
# 📦 duong_ham = mọi yêu cầu mở đường hầm mà honeypot ghi được (toàn bộ log, theo ngày)
for dai in ["192.42.116.", "80.94.95."]:                                   # ✏️ SỬA: thêm dải khác
    ho = ho_so[ho_so.index.str.startswith(dai)]
    dh = duong_ham[duong_ham.src_ip.str.startswith(dai)]
    print(f"Dải {dai}0/24")
    print(f"  hồ sơ 30 ngày : {len(ho)} IP, {int(ho.dung_proxy.sum())} IP dùng proxy, công cụ {ho.cong_cu.value_counts().to_dict()}, "
          f"đăng nhập đúng tổng {int(ho.dang_nhap_dung.sum())}")
    print(f"  đường hầm     : {len(dh)} dòng từ {dh.src_ip.nunique()} IP, {dh.ngay_vn.nunique()} ngày ({dh.ngay_vn.min()} → {dh.ngay_vn.max()}), "
          f"đích hay gặp {dh.dst_ip.value_counts().head(3).to_dict()}, cổng {dh.dst_port.value_counts().to_dict()}")
print(f"\nCả honeypot: {duong_ham.src_ip.nunique()} IP từng xin đường hầm")

Kết quả:

  • Dải 192.42.116.0/24: trong hồ sơ 30 ngày có 6 IP, 5 dùng proxy, cả 6 khai cùng công cụ OpenSSH_10.5. Trong toàn bộ bảng đường hầm: 33 IP khác nhau của dải này, 45 ngày từ 06/05 đến 12/09/2026, mọi yêu cầu đều tới google.com:443, mỗi IP thường chỉ một lần.
  • Dải 80.94.95.0/24: 5 IP trong hồ sơ, 3 dùng proxy, tổng 40 lần đăng nhập đúng. Trong bảng đường hầm: 204 yêu cầu từ 3 IP, 31 ngày (30/05 → 21/09/2026), tới nhiều địa chỉ khác nhau (các IP của Google, Akamai…), toàn cổng 443.
  • Cả honeypot có 777 IP từng xin đường hầm.

📦 duong_ham = bảng mọi yêu cầu mở đường hầm (ngày, IP nguồn, đích, cổng, số lần).

👉 Hiểu — vì sao chúng "thực sự khác thường":

  • 192.42.116.x là một dịch vụ dò proxy chạy suốt 5 tháng, xoay 33 IP, mỗi IP thử một lần rồi đổi — chặn từng IP là vô ích; phải chặn cả dải hoặc chặn hành vi (không cho mở đường hầm).
  • 80.94.95.x khác hẳn: ít IP nhưng quay lại nhiều ngày và thật sự dùng đường hầm (204 lần, nhiều đích) → máy của ta đã bị coi là trạm trung chuyển cho lưu lượng HTTPS của người khác.
  • Cả hai không phải kẻ dò mật khẩu như số đông — luật "đếm lần sai" ở Phần 1 không bao giờ thấy chúng. K-Means thấy được vì chúng khác nhóm của mình, và người phân tích hiểu được vì sao nhờ 3 bước trên. Máy gợi ý, người kết luận.

5.3 Hỏi điểm của một tài khoản

So với luật cứng ở Phần 1 (luật khoá hay cho qua): luật nhìn từng con số riêng lẻ, còn điểm khác thường nhìn cả hồ sơ so với nhóm.

In [ ]:
# ✏️ SỬA: các tài khoản muốn hỏi (gặp ở các lab trước)
for ip in ["185.246.128.133", "2.57.121.112", "130.12.182.196", "1.10.195.220"]:
    h = ho_so.loc[ip]
    print(f"{ip:16s} cụm {h.cum} | khác thường {h.diem_la:5.1f} -> {h.muc} | đăng nhập sai {h.dang_nhap_sai}, đúng {h.dang_nhap_dung}, proxy {h.dung_proxy}, {h.so_ngay} ngày | luật cứng Phần 1: {'KHÓA' if bi_khoa[ip] else 'cho qua'}")

Kết quả:

185.246.128.133  cụm 1 | khác thường 100.0 -> 🔴 điều tra ngay | đăng nhập sai 0, đúng 1661, proxy 1, 28 ngày | luật cứng Phần 1: cho qua
2.57.121.112     cụm 1 | khác thường 100.0 -> 🔴 điều tra ngay | đăng nhập sai 1115, đúng 0, proxy 0, 19 ngày | luật cứng Phần 1: KHÓA
130.12.182.196   cụm 1 | khác thường  73.7 -> 🔴 điều tra ngay | đăng nhập sai 3310, đúng 323, proxy 0, 6 ngày | luật cứng Phần 1: KHÓA
1.10.195.220     cụm 0 | khác thường   4.9 -> 🟢 bot điển hình | đăng nhập sai 0, đúng 0, proxy 0, 1 ngày | luật cứng Phần 1: cho qua

👉 Hiểu: 185.246.128.133 — kẻ trinh sát "chậm mà chắc" của các lab trước — bị luật cứng cho qua (không bao giờ sai mật khẩu) nhưng đứng đầu danh sách điều tra (100): lệch quá xa thói quen của chính nhóm "vào và làm việc". Máy quét 1.10.195.220 là bot điển hình (4,9) — để hệ thống tự chặn, không cần người xem.


Phần 6 — Hạn chế của K-Means

📍 Bước 5 — Lưu ý khi coding: quên nén số lớn hay đổi K thì code vẫn chạy, không báo lỗi, nhưng cụm khác hẳn.

6.1 Rất nhạy với giá trị cực lớn

Ý chính: K-Means xếp nhóm bằng khoảng cách. Nếu một cột có vài giá trị lớn gấp hàng nghìn lần phần còn lại, thì trong khoảng cách, cột đó át hết — các khác biệt nhỏ (nhưng quan trọng) ở số đông trở nên vô hình.

Ví dụ đời thường: so độ giống nhau giữa người bằng hai thứ: chiều cao (chênh vài chục cm) và tài sản (chênh hàng tỷ đồng). Nếu cứ thế cộng vào khoảng cách, chỉ còn tài sản quyết định: một người cao 1m5 và một người cao 1m9 cùng nghèo bị coi là "giống hệt", vì cả hai cách tỷ phú quá xa.

6.1a — nhìn trên một cột. Lấy cột số kết nối, đặt 3 tài khoản cạnh nhau: tài khoản "giữa" (trung vị), tài khoản ở phân vị 90%, và tài khoản lớn nhất. Đổi mỗi số thành "cách mức trung bình mấy độ lệch chuẩn" (đúng việc StandardScaler làm), không nén và có nén log1p:

In [ ]:
cot = ho_so.so_ket_noi
ba_tk = {"tài khoản 'giữa' (trung vị)": cot.median(), "phân vị 90%": cot.quantile(0.9), "lớn nhất": cot.max()}
z_khong_nen = lambda v: (v - cot.mean()) / cot.std(ddof=0)                  # chỉ chuẩn hoá
cot_nen = np.log1p(cot)
z_co_nen = lambda v: (np.log1p(v) - cot_nen.mean()) / cot_nen.std(ddof=0)    # nén log1p rồi mới chuẩn hoá
vi_du = pd.DataFrame({"số kết nối": ba_tk,
                      "KHÔNG nén: vị trí": {k: z_khong_nen(v) for k, v in ba_tk.items()},
                      "CÓ nén: vị trí": {k: z_co_nen(v) for k, v in ba_tk.items()}}).round(2)
# 📦 vi_du = 3 tài khoản × (số gốc, vị trí sau khi chuẩn hoá không nén, vị trí sau khi nén + chuẩn hoá)
print(vi_du.to_string())
k = vi_du.iloc[:, 1]; n = vi_du.iloc[:, 2]
print(f"\nKHÔNG nén: 'giữa' → 90% cách {k.iloc[1] - k.iloc[0]:.2f}, còn 90% → lớn nhất cách {k.iloc[2] - k.iloc[1]:.2f}  (gấp {(k.iloc[2] - k.iloc[1]) / (k.iloc[1] - k.iloc[0]):.0f} lần)")
print(f"CÓ nén   : 'giữa' → 90% cách {n.iloc[1] - n.iloc[0]:.2f}, còn 90% → lớn nhất cách {n.iloc[2] - n.iloc[1]:.2f}")

Kết quả:

                             số kết nối  KHÔNG nén: vị trí  CÓ nén: vị trí
tài khoản 'giữa' (trung vị)         2.0              -0.20           -0.30
phân vị 90%                        32.0              -0.06            1.36
lớn nhất                         5569.0              24.32            4.91

KHÔNG nén: 'giữa' → 90% cách 0.14, còn 90% → lớn nhất cách 24.38 (gấp 174 lần) · CÓ nén: 'giữa' → 90% cách 1.66, còn 90% → lớn nhất cách 3.55

📦 vi_du = 3 tài khoản × (số kết nối gốc, vị trí không nén, vị trí có nén).

👉 Hiểu: không nén, tài khoản 2 kết nối và tài khoản 32 kết nối gần như chồng lên nhau (cách 0,14), trong khi tài khoản lớn nhất ở tít 24 bậc — 90% tài khoản trông như một điểm. K-Means không thể chia số đông ra các kiểu khác nhau, vì nó "không thấy" khác biệt giữa họ. Có nén log1p: 2 và 32 cách nhau 1,66 bậc, tài khoản lớn nhất vẫn xa nhất nhưng không còn át hết. Nén giữ thứ tự (nhiều vẫn là nhiều), chỉ thu hẹp khoảng cách giữa các số rất lớn.

6.1b — trên cả 12 cột. Giờ chạy lại K-Means chỉ chuẩn hoá, không nén, rồi so với bản có nén. Ô dưới in: 5 giá trị lớn nhất của vài cột (để thấy "cực lớn" là bao nhiêu), số tài khoản mỗi cụm của hai cách, và chân dung từng cụm khi không nén.

Cách đọc kết quả: nếu một cụm chiếm gần hết tài khoản, còn các cụm khác chỉ vài chục tài khoản, thì K-Means đã dùng các cụm để tách vài tài khoản dị biệt thay vì tách các kiểu hành vi của số đông.

In [ ]:
# Giá trị cực lớn: so 5 tài khoản lớn nhất với tài khoản "giữa" (trung vị) ở vài cột
for cot in ["so_ket_noi", "dang_nhap_sai", "dang_nhap_dung", "phien_dai_nhat_giay"]:
    top = ho_so[cot].nlargest(5)                                          # 5 giá trị lớn nhất của cột
    print(f"{cot:20s} trung vị {ho_so[cot].median():6.1f} | 5 lớn nhất: {', '.join(f'{v:,.0f} ({ip})' for ip, v in top.items())}")
print()
X_khong_nen = StandardScaler().fit_transform(ho_so[SO])                  # chỉ chuẩn hóa, KHÔNG log1p
km_khong_nen = KMeans(n_clusters=SO_CUM, n_init=10, random_state=RANDOM_STATE).fit(X_khong_nen)
print("KHÔNG nén   — số tài khoản mỗi cụm:", sorted(np.bincount(km_khong_nen.labels_).tolist(), reverse=True))
print("CÓ nén log1p — số tài khoản mỗi cụm:", sorted(np.bincount(kmeans.labels_).tolist(), reverse=True))
# Không nén: mỗi cụm là ai? (cụm nhỏ do số cực lớn hoặc cột 0/1 hiếm tạo ra)
print("\nKhông nén — chân dung từng cụm:")
print(ho_so.assign(c=km_khong_nen.labels_).groupby("c").agg(so_tai_khoan=("so_ket_noi", "size"), ket_noi=("so_ket_noi", "median"),
      sai=("dang_nhap_sai", "median"), dung=("dang_nhap_dung", "median"), phien_dai_nhat=("phien_dai_nhat_giay", "median"),
      ti_le_proxy=("dung_proxy", "mean")).sort_values("so_tai_khoan", ascending=False).round(2).to_string())

Kết quả:

  • Giá trị cực lớn (so với tài khoản "giữa"): so_ket_noi trung vị 2, lớn nhất 5.569 (154.18.197.29); dang_nhap_sai trung vị 0, lớn nhất 3.310; dang_nhap_dung trung vị 0, lớn nhất 2.295; phien_dai_nhat_giay trung vị 0,7 giây, lớn nhất 6.873 giây (gần 2 giờ).
  • KHÔNG nén — số tài khoản mỗi cụm: [2598, 141, 75, 24] · CÓ nén log1p — số tài khoản mỗi cụm: [1909, 521, 249, 159]
  • Chân dung khi không nén: cụm 2.598 tài khoản (92%) gộp gần hết các kiểu; cụm 75 = những tài khoản khổng lồ (kết nối trung vị 806, đăng nhập đúng 441); cụm 141 = nhóm vừa (54 kết nối); cụm 24 = toàn bộ tài khoản dùng proxy.

👉 Hiểu: đúng như 6.1a dự đoán: không nén, K-Means dùng 3 trên 4 cụm để tách vài chục tài khoản dị biệt (khổng lồ, proxy), còn 92% tài khoản — gõ cửa, thử 1 lần, cài đồ — bị nhét chung một cụm. Có nén thì 4 cụm ra 4 kiểu hành vi của số đông (Phần 4). Luôn nén số lệch mạnh + chuẩn hoá trước K-Means.

6.2 Phải chọn K trước — và K đổi thì cụm đổi

Nghĩa là gì? K-Means luôn trả về đúng K nhóm, dù dữ liệu "thật ra" có mấy kiểu. Bảo nó chia 4 thì nó chia 4; bảo chia 5 thì nó buộc phải tách một nhóm cũ ra (hoặc vẽ lại ranh giới). Câu hỏi đáng hỏi: nhóm nào bị tách, và phần tách ra có ý nghĩa không?

Cách đọc bảng chéo in ra dưới đây:

mỗi hàng một cụm khi K = 4 (đúng các cụm 0–3 đã đặt tên ở Phần 4)
mỗi cột một cụm khi K = 5
số ở ô (hàng r, cột c) số tài khoản thuộc cụm r khi K = 4 và cụm c khi K = 5
hàng chỉ có một ô khác 0 cụm đó giữ nguyên khi tăng K
hàng có hai ô khác 0 cụm đó bị tách — xem bảng thứ hai (chân dung 5 cụm) để biết phần tách ra là ai
In [ ]:
# Với K = 5: cụm nào tách ra thêm?
km5 = KMeans(n_clusters=5, n_init=10, random_state=RANDOM_STATE).fit(X)
bang5 = ho_so.assign(cum5=km5.labels_).groupby("cum5").agg(so_tai_khoan=("so_ket_noi", "size"), ti_le_proxy=("dung_proxy", "mean"),
                                                             tai_file_tb=("so_lan_tai_file", "mean"), dung=("dang_nhap_dung", "median"))
print("Bảng chéo: cụm (K=4, hàng) chia thành cụm nào khi K=5 (cột):")
print(pd.crosstab(ho_so.cum, km5.labels_).to_string())
bang5.round(2)

Kết quả: bảng chéo (hàng = cụm K=4, cột = cụm K=5):

      0    1    2   3    4
0  1906    0    0   0    3
1     0    0  243   6    0
2     0    0    0  18  503
3     0  159    0   0    0

Đọc theo hàng: cụm 0 "gõ cửa" → gần nguyên vẹn (1.906 sang cụm 0 mới); cụm 3 "cài đồ" → nguyên vẹn (159 sang cụm 1 mới); cụm 1 "vào và làm việc" → 243 ở lại, 6 tách ra; cụm 2 "thử 1 lần" → 503 ở lại, 18 tách ra. 6 + 18 = 24 tài khoản tách ra hợp thành cụm mới số 3, có ti_le_proxy = 1.0 — toàn bộ là tài khoản dùng máy làm bàn đạp.

📦 km5 = K-Means với K=5.

👉 Hiểu: khi buộc phải chia thành 5, K-Means không xáo trộn lại tất cả — nó giữ 3 cụm cũ gần như y nguyên và gom 24 tài khoản dùng proxy (trước đây lẫn trong 2 cụm khác nhau) thành một cụm riêng. Không có K "đúng tuyệt đối": K = 4 cho bức tranh tổng quan, K = 5 lộ ra một nhóm nhỏ nhưng quan trọng. Chọn K là quyết định của người phân tích, tuỳ câu hỏi cần trả lời.

6.2b Nếu chọn K chưa hợp lý thì sao? Khi nào phải chọn lại?

K sai không làm code báo lỗi — chỉ làm kết luận sai. Hai kiểu sai:

Dấu hiệu nhận ra Hậu quả
K quá nhỏ một cụm có chân dung "chẳng giống ai" (trung vị pha trộn nhiều kiểu); điều tra như 5.2b thấy một nhóm nhỏ có hành vi riêng bị lẫn vào cụm khác nhóm nhỏ nguy hiểm bị chìm; hoặc ngược lại, cả nhóm bị chấm "khác thường" chỉ vì không có cụm của riêng mình
K quá lớn có cụm không đặt được tên, hai cụm chân dung gần như nhau, cụm chỉ vài tài khoản; chạy lại với random_state khác thì cụm đổi lung tung tốn công đặt tên, xử lý những nhóm không thật sự khác nhau

Ô dưới đo hai điều trên dữ liệu thật: (1) 8 tài khoản "khác thường nhất" ở 5.2b được chấm lại thế nào khi K = 5; (2) mỗi K có ổn định không khi đổi hạt giống ngẫu nhiên (đo bằng ARI giữa hai lần chạy: 1 = chia giống hệt, 0 = chẳng liên quan).

In [ ]:
from sklearn.metrics import adjusted_rand_score
# (1) Chấm lại 8 tài khoản đầu bảng 5.2b với K = 5
tam5 = km5.cluster_centers_[km5.labels_]
kc5 = np.linalg.norm(X.to_numpy() - tam5, axis=1)
diem5 = pd.Series((100 * kc5 / np.percentile(kc5, 99)).clip(0, 100), index=X.index)   # cùng công thức điểm khác thường
top8 = ho_so.sort_values("diem_la", ascending=False).index[:8]
print(pd.DataFrame({"điểm K=4": ho_so.loc[top8, "diem_la"].round(0), "điểm K=5": diem5[top8].round(0),
                    "cụm K=5": km5.labels_[X.index.get_indexer(top8)]}).to_string())
print(f"Số tài khoản ≥ 60 điểm ('điều tra ngay'): K=4 {int((ho_so.diem_la >= 60).sum())} | K=5 {int((diem5 >= 60).sum())}")

# (2) Ổn định: chạy mỗi K với 5 hạt giống khác nhau, so từng cặp bằng ARI
print("\nĐộ ổn định (ARI trung bình giữa các lần chạy khác hạt giống):")
for k in [3, 4, 5, 6, 8]:                                       # ✏️ SỬA: thêm K khác
    nhan = [KMeans(n_clusters=k, n_init=1, random_state=h).fit(X).labels_ for h in range(5)]
    ari = [adjusted_rand_score(nhan[i], nhan[j]) for i in range(5) for j in range(i + 1, 5)]
    print(f"  K={k}: {np.mean(ari):.2f}  (thấp nhất {min(ari):.2f})")

Kết quả:

               điểm K=4  điểm K=5  cụm K=5
192.42.116.96     100.0      53.0        3
192.42.116.64     100.0      53.0        3
80.94.95.118      100.0      55.0        3
80.94.95.116      100.0      34.0        3
77.90.185.17      100.0      70.0        3
80.94.95.115      100.0      41.0        3
192.42.116.15     100.0      53.0        3
192.42.116.16     100.0      53.0        3

Số tài khoản ≥ 60 điểm: K=4 130 | K=5 354 · Độ ổn định (ARI trung bình giữa 5 lần chạy, mỗi lần 1 khởi tạo): K=3 0,66 · K=4 0,71 · K=5 0,71 · K=6 0,83 · K=8 0,92.

👉 Hiểu:

  • Với K = 5, cả 8 tài khoản về chung cụm 3 (cụm "dùng proxy") và điểm tụt xuống 34–70: chúng không còn "khác thường" vì đã có nhóm của riêng mình. Điểm khác thường phụ thuộc K — không phải thuộc tính cố định của tài khoản.
  • Cùng lúc, số tài khoản ≥ 60 điểm tăng từ 130 lên 354: vì mốc 99% được tính lại trên cách chia mới. Đổi K thì phải chọn lại ngưỡng ở 5.2, nếu không ca trực bỗng phải đọc gấp gần 3 lần.
  • Độ ổn định: ở đây K lớn lại ổn định hơn (K=8: 0,92), nhưng 8 cụm thì khó đặt tên. Mỗi thước đo (inertia, silhouette, ổn định) chỉ là một gợi ý — quyết định cuối vẫn là "đặt tên và hành động được không". (Bài chạy chính dùng n_init=10: lấy lần tốt nhất trong 10 lần, nên kết quả ổn định hơn từng lần chạy lẻ ở đây.)

Khi nào phải chọn lại K?

  1. Đọc chân dung (Phần 4) thấy cụm không đặt được tên, hoặc hai cụm cùng một tên.
  2. Điều tra (như 5.2b) lộ ra một nhóm nhỏ có hành vi riêng đang bị lẫn — thử tăng K xem nó có tách ra không (6.2: K = 5 tách đúng 24 tài khoản dùng proxy).
  3. Không ổn định: đổi hạt giống mà cách chia đổi nhiều (ARI thấp) → K đó không đáng tin.
  4. Đối chiếu nhãn (Phần 8) thấy một nhóm tấn công đã biết bị xé ra nhiều cụm, hoặc nhiều nhóm bị gộp một cụm.
  5. Dữ liệu đổi: tháng sau, hành vi mới, đổi hệ thống (như chuyển honeypot ở Lab 4B) → chạy lại 3.2 và đọc lại chân dung định kỳ, không chọn K một lần rồi dùng mãi.

Và nhớ: K "đúng" phụ thuộc câu hỏi. Muốn tìm cái lạ để điều tra (Phần 5) thì K = 4 làm nhóm proxy nổi lên; muốn đặt tên từng kiểu tấn công thì K = 5 cho nhóm proxy một cụm riêng.


Phần 7 — K-Means vs Isolation Forest

📍 Bước 6 — Đánh giá các mô hình: hai mô hình không giám sát có thấy cùng những tài khoản lạ không?

Module 3 dùng Isolation Forest (chặt ngẫu nhiên, đo độ dễ tách riêng). K-Means đo khoảng cách tới tâm nhóm. Hai cách có tìm ra cùng những tài khoản lạ không?

In [ ]:
from sklearn.ensemble import IsolationForest
rung = IsolationForest(n_estimators=300, random_state=RANDOM_STATE).fit(X)
diem_if = -rung.decision_function(X)                       # đảo dấu: càng LỚN càng bất thường (cho cùng chiều với khoảng cách)
# ✏️ SỬA: so bao nhiêu tài khoản đứng đầu
TOP = 50
top_km = set(ho_so.index[np.argsort(-khoang_cach)[:TOP]])
top_if = set(ho_so.index[np.argsort(-diem_if)[:TOP]])
print(f"Tương quan giữa khoảng cách K-Means và điểm Isolation Forest: {np.corrcoef(khoang_cach, diem_if)[0, 1]:.2f}")
print(f"Trong {TOP} tài khoản lạ nhất của mỗi cách: {len(top_km & top_if)} tài khoản TRÙNG nhau")
print("\nChỉ K-Means xếp vào top (ví dụ):", list(top_km - top_if)[:4])
print("Chỉ Isolation Forest xếp vào top (ví dụ):", list(top_if - top_km)[:4])
plt.figure(figsize=(6, 4.5))
plt.scatter(khoang_cach, diem_if, s=5, alpha=0.4)
plt.xlabel("K-Means: khoảng cách tới tâm cụm"); plt.ylabel("Isolation Forest: độ bất thường"); plt.title("Mỗi chấm = 1 tài khoản"); plt.show()

Kết quả: Tương quan giữa khoảng cách K-Means và điểm Isolation Forest: 0.89 · Trong 50 tài khoản lạ nhất của mỗi cách: 19 tài khoản TRÙNG nhau; chỉ K-Means xếp vào top: 37.186.122.154, 185.246.130.20, 178.16.54.226...; chỉ Isolation Forest: 2.57.122.168, 92.118.39.71, 103.245.237.17, 109.160.32.31; và biểu đồ chấm (mỗi chấm 1 tài khoản) gần như một đường chéo đi lên.

👉 Hiểu: nhìn tổng thể, hai cách rất đồng ý (tương quan 0,89): tài khoản K-Means thấy lạ thì Isolation Forest cũng thấy lạ. Nhưng ở đỉnh danh sách chúng chọn khác nhau (chỉ 19/50 trùng): K-Means đo "lệch khỏi nhóm của mình", Isolation Forest đo "lạ so với tất cả". Dùng cả hai thì ít bỏ sót hơn.

K-Means Isolation Forest
Cho ra cụm (nhóm hành vi) + khoảng cách tới tâm điểm bất thường
Giải thích "tài khoản này thuộc nhóm X, lệch khỏi nhóm bao nhiêu" — dễ kể cho người khác "dễ bị tách riêng" — khó hình dung hơn
Cần chuẩn hóa bắt buộc (đo khoảng cách) không bắt buộc (dạng cây)
Phải chọn số cụm K tỉ lệ bất thường contamination
Hợp với UBA: hiểu các kiểu người dùng, chấm điểm lệch thói quen săn điểm lạ nhanh, không cần biết có mấy nhóm

Phần 8 — Kiểm chứng bằng nhãn của giảng viên: cụm có "đúng" không?

📍 Bước 6 — Đánh giá các mô hình: đối chiếu cụm với nhãn có sẵn.

K-Means không có đáp án nên ta chỉ đặt tên cụm bằng cách đọc chân dung (Phần 4). Giờ ta có thêm một nguồn để đối chiếu: file nhãn giảng viên đã gán cho từng IP (đáp án thử thách M3.4). Đây là cách kiểm tra mô hình học không giám sát ngoài đời: lấy một ít dữ liệu có nhãn (thường từ điều tra sự cố) để xem cụm có trùng với điều ta biết không.

8.1 Nhãn nói gì? Dấu vân tay HASSH

Khi một phần mềm SSH kết nối, nó gửi danh sách thuật toán mã hóa nó hỗ trợ, theo một thứ tự cố định của phần mềm đó. Băm (MD5) danh sách đó ra một chuỗi 32 ký tự gọi là HASSH. Cùng một công cụ (cùng thư viện, cùng phiên bản) → cùng HASSH, dù chạy từ IP nào. Vì vậy HASSH là dấu vân tay của công cụ, không phải của hành vi.

Cột Nghĩa
top_hassh HASSH xuất hiện nhiều nhất của IP đó (other = không thuộc nhóm dấu vân tay nào đã biết)
hassh_group nhóm tấn công mà giảng viên gán theo dấu vân tay (TA = threat actor, tác nhân đe dọa); other = chưa gán
tool_fingerprint_attributions ghi chú khi gán; chữ (conflict) = một dấu vân tay khớp hai nhóm, chưa phân xử được
ta_confirmed nhóm đã được xác nhận chắc chắn bằng điều tra
In [ ]:
nhan_gv = pd.read_csv("/opt/csai/giangvien/attackers_labels.csv").set_index("src_ip")
# 📦 nhan_gv = mỗi IP 1 hàng, 4 cột nhãn (bảng trên)
print("nhan_gv.shape =", nhan_gv.shape, "-> (số IP, số cột)")
print("\nSố IP mỗi nhóm (hassh_group):")
print(nhan_gv.hassh_group.value_counts().to_string())
print("\nSố IP có ghi chú '(conflict)':", nhan_gv.tool_fingerprint_attributions.str.contains("conflict", na=False).sum())
print("Số IP đã xác nhận chắc chắn (ta_confirmed):", nhan_gv.ta_confirmed.notna().sum())
nhan_gv.loc[["1.10.195.220", "130.12.182.196", "185.246.128.133"]]

Kết quả: nhan_gv.shape = (2847, 4), số IP mỗi nhóm: other 2461 · TA09_GoRecon 202 · TA10_Beary / TA-04 83 · TA-04 / RedTail 55 · TA-06 DutchGo 27 · TA-06 Mdrfckr 12 · TA-07 Lithuania proxy 3 · PATTERN-13 2 · CredScanner 2; Số IP có ghi chú '(conflict)': 138 · Số IP đã xác nhận chắc chắn (ta_confirmed): 1, rồi 3 IP quen thuộc:

  • 1.10.195.220 → other (máy quét gõ cửa 2 lần)
  • 130.12.182.196 → TA10_Beary / TA-04, ghi chú TA10_Beary;TA-04 (conflict)
  • 185.246.128.133 → CredScanner, ta_confirmed = CredScanner — IP duy nhất được xác nhận

📦 nhan_gv = bảng nhãn, chỉ mục là IP.

👉 Hiểu:

  • 86% IP là other: dấu vân tay của chúng chưa thuộc nhóm nào đã biết. Nhãn ngoài đời luôn thưa như vậy — chỉ một phần nhỏ được điều tra.
  • 138 IP mang nhãn "conflict" và chỉ 1 IP được xác nhận chắc chắn: nhãn cũng có độ tin cậy khác nhau. Đừng coi mọi nhãn là chân lý.
  • Bảng nhãn có 2.847 IP, hồ sơ có 2.838 — 9 IP có nhãn nhưng không có hồ sơ hành vi (sẽ bị bỏ khi nối ở 8.2).

8.2 Mỗi nhóm tấn công rơi vào cụm nào?

Nối nhãn vào hồ sơ theo IP, rồi lập bảng chéo: hàng = nhóm theo nhãn, cột = cụm K-Means (Phần 3.3). Cột cuối % ở cụm chính = phần IP của nhóm nằm trong cùng một cụm. Nếu cao: hành vi của nhóm đó đồng nhất và K-Means đã tự gom được, dù không ai cho nó biết nhãn.

Biểu đồ nhiệt Cách đọc
mỗi hàng một nhóm theo nhãn, các ô cộng lại = 100%
ô đậm phần lớn IP của nhóm nằm trong cụm đó
một hàng chỉ có một ô đậm nhóm đồng nhất, khớp với một cụm
một hàng rải đều nhóm này cư xử nhiều kiểu, hoặc nhãn của nó chưa chắc
In [ ]:
TEN_CUM = {0: "0 gõ cửa", 1: "1 vào và làm việc", 2: "2 thử 1 lần", 3: "3 cài đồ"}    # tên đặt ở Phần 4 (với SO_CUM = 4)
gop = ho_so.join(nhan_gv)                                 # join: nối 2 bảng theo chỉ mục (IP)
bang_cheo = pd.crosstab(gop.hassh_group, gop.cum.map(TEN_CUM))
so_ip_nhom = bang_cheo.sum(axis=1)                       # tổng IP mỗi nhóm (cộng theo hàng)
bang_cheo["% ở cụm chính"] = (bang_cheo.max(axis=1) / so_ip_nhom * 100).round(0)
bang_cheo["số IP"] = so_ip_nhom
# 📦 gop = hồ sơ + nhãn · bang_cheo = số IP theo (nhóm, cụm) + % ở cụm đông nhất + tổng IP của nhóm
# Chú ý: nhóm chỉ 1–2 IP thì "100%" không nói lên gì — đọc kèm cột "số IP"
print(bang_cheo.sort_values("% ở cụm chính", ascending=False).to_string())

ti_le_hang = pd.crosstab(gop.hassh_group, gop.cum.map(TEN_CUM), normalize="index") * 100   # mỗi hàng chia cho tổng hàng
plt.figure(figsize=(8, 4.5))
plt.imshow(ti_le_hang, cmap="Blues", aspect="auto")
for i in range(ti_le_hang.shape[0]):
    for j in range(ti_le_hang.shape[1]):
        plt.text(j, i, f"{ti_le_hang.iat[i, j]:.0f}%", ha="center", va="center", fontsize=8,
                 color="white" if ti_le_hang.iat[i, j] > 60 else "black")
plt.xticks(range(ti_le_hang.shape[1]), ti_le_hang.columns, rotation=20)
plt.yticks(range(ti_le_hang.shape[0]), [f"{nhom} ({so_ip_nhom[nhom]} IP)" for nhom in ti_le_hang.index])
plt.title("Mỗi nhóm theo nhãn: bao nhiêu % IP rơi vào từng cụm"); plt.colorbar(label="% IP của nhóm"); plt.show()

Kết quả:

                    0 gõ cửa  1 vào và làm việc  2 thử 1 lần  3 cài đồ  % ở cụm chính  số IP
CredScanner                0                  1            0         0          100.0      1
PATTERN-13                 0                  1            0         0          100.0      1
TA-06 DutchGo              1                  0           25         0           96.0     26
TA09_GoRecon              17                168           14         2           84.0    201
TA-04 / RedTail            1                 10           43         0           80.0     54
TA-06 Mdrfckr              0                  0            2         8           80.0     10
other                   1853                 47          416       145           75.0   2461
TA-07 Lithuania proxy      0                  1            1         0           50.0      2
TA10_Beary / TA-04        37                 21           20         4           45.0     82

và biểu đồ nhiệt: các hàng DutchGo, GoRecon, RedTail, Mdrfckr mỗi hàng có một ô đậm (80–96%); hàng TA10_Beary / TA-04 rải ra cả 4 cụm (45 / 26 / 24 / 5%).

📦 gop = hồ sơ + nhãn · bang_cheo = bảng trên · so_ip_nhom = tổng IP mỗi nhóm · ti_le_hang = % theo hàng (biểu đồ).

👉 Hiểu — K-Means không hề thấy nhãn, vậy mà:

  • TA09_GoRecon (201 IP): 84% nằm ở cụm "vào và làm việc" — đúng nhóm đăng nhập được rồi gõ lệnh dò hệ thống.
  • DutchGo 96% và RedTail 80% nằm ở cụm "thử 1 lần".
  • Mdrfckr 8/10 nằm ở cụm "cài đồ" — xác nhận phần nào điều Phần 4 chỉ đoán ("rất có thể cùng chiến dịch cài khóa SSH"). Nhưng cụm này có 159 IP mà chỉ 8 mang nhãn Mdrfckr: dấu vân tay mới phủ được một phần nhỏ của chiến dịch.
  • TA10_Beary / TA-04 rải khắp: đây chính là nhóm có ghi chú (conflict) — dấu vân tay khớp hai nhóm, nên "nhóm" này thật ra là nhiều kẻ khác nhau dùng cùng công cụ. Khi cụm và nhãn không khớp, hãy nghi cả nhãn, không chỉ nghi mô hình.
  • CredScanner, PATTERN-13 100% nhưng chỉ có 1 IP — con số phần trăm trên 1 mẫu không nói lên gì; luôn đọc kèm cột số IP.

8.3 Một con số cho cả bảng: ARI, độ thuần, độ trọn

Số đo Câu hỏi 1,0 nghĩa là
ARI (Adjusted Rand Index) hai cách chia (cụm và nhãn) giống nhau đến đâu, đã trừ phần trùng do may rủi? giống hệt; 0 = như chia ngẫu nhiên
độ thuần (homogeneity) mỗi cụm có chỉ chứa một nhóm không? mỗi cụm chỉ một nhóm
độ trọn (completeness) mỗi nhóm có nằm gọn trong một cụm không? mỗi nhóm ở đúng một cụm
In [ ]:
from sklearn.metrics import adjusted_rand_score, homogeneity_score, completeness_score
dong_so_khop = []
for ten_cach_chia, nhan_cum in [("K-Means K=4 (Phần 3.3)", ho_so.cum), ("K-Means K=5 (Phần 6.2)", km5.labels_)]:
    dong_so_khop.append({"cách chia": ten_cach_chia, "ARI": adjusted_rand_score(gop.hassh_group, nhan_cum),
                         "độ thuần": homogeneity_score(gop.hassh_group, nhan_cum), "độ trọn": completeness_score(gop.hassh_group, nhan_cum)})
pd.DataFrame(dong_so_khop).set_index("cách chia").round(3)

Kết quả:

                          ARI  độ thuần  độ trọn
K-Means K=4 (Phần 3.3)  0.311     0.366    0.216
K-Means K=5 (Phần 6.2)  0.310     0.378    0.215

📦 dong_so_khop = danh sách 2 dòng số đo (dùng lại ở báo cáo).

👉 Hiểu:

  • ARI 0,31: cụm và nhãn khớp một phần — rõ ràng hơn ngẫu nhiên (0), xa mức giống hệt (1). Lý do chính: hai bên trả lời hai câu hỏi khác nhau — nhãn HASSH hỏi "dùng công cụ gì?", K-Means hỏi "cư xử thế nào?". Cùng một công cụ Go có thể dùng để quét, để thử 1 lần, hay để vào làm việc (bảng 8.2).
  • Độ trọn thấp (0,22) vì nhóm other (2.461 IP) rải ra cả 4 cụm — điều hiển nhiên, vì other là "chưa biết", không phải một nhóm.
  • K = 5 gần như không đổi cả 3 con số — nhưng 8.4 sẽ cho thấy nó tìm ra điều quan trọng nhất. Một con số tổng có thể che mất phát hiện nhỏ mà giá trị.

8.4 K = 5 có "đúng" hơn không? Nhìn vào cụm mới tách ra

Ở 6.2, K = 5 tách ra một cụm nhỏ toàn tài khoản dùng proxy. Nhãn giảng viên nói gì về cụm đó?

In [ ]:
cum_proxy = int(pd.Series(ho_so.dung_proxy.to_numpy()).groupby(km5.labels_).mean().idxmax())   # cụm K=5 có tỉ lệ proxy cao nhất
trong_cum_proxy = gop[km5.labels_ == cum_proxy]
# 📦 trong_cum_proxy = các tài khoản của cụm proxy (K=5), kèm nhãn
print(f"Cụm proxy (K=5) = cụm số {cum_proxy}: {len(trong_cum_proxy)} tài khoản")
print(trong_cum_proxy.hassh_group.value_counts().to_string())
print("\nCác tài khoản trong cụm này có nhãn khác 'other':")
print(trong_cum_proxy[trong_cum_proxy.hassh_group != "other"][["hassh_group", "ta_confirmed", "so_ket_noi", "dang_nhap_dung", "so_ngay", "cong_cu"]].to_string())
cum_cua_lithuania = sorted({int(c) for c in km5.labels_[(gop.hassh_group == "TA-07 Lithuania proxy").to_numpy()]})   # int(): bỏ kiểu số của numpy cho dễ đọc
print("\nNhóm 'TA-07 Lithuania proxy' nằm ở cụm K=5 nào:", cum_cua_lithuania)

Kết quả: Cụm proxy (K=5) = cụm số 3: 24 tài khoản · nhãn trong cụm: other 21 · TA-07 Lithuania proxy 2 · CredScanner 1, rồi 3 tài khoản có nhãn:

77.90.185.17     TA-07 Lithuania proxy  ...  so_ket_noi 93    dang_nhap_dung 89    so_ngay 14  OpenSSH_8.4p1
80.94.95.118     TA-07 Lithuania proxy  ...  so_ket_noi 35    dang_nhap_dung 34    so_ngay 11  OpenSSH_8.4p1
185.246.128.133  CredScanner  CredScanner    so_ket_noi 1764  dang_nhap_dung 1661  so_ngay 28  OpenSSH_5.3

Nhóm 'TA-07 Lithuania proxy' nằm ở cụm K=5 nào: [3]

📦 cum_proxy = số hiệu cụm proxy · trong_cum_proxy = 24 tài khoản của cụm đó kèm nhãn · cum_cua_lithuania = các cụm chứa nhóm Lithuania.

👉 Hiểu:

  • Cụm nhỏ 24 tài khoản mà K = 5 tách ra chứa IP duy nhất được xác nhận (185.246.128.133, kẻ "đăng nhập đúng 1.661 lần, 0 lần sai" của Phần 1) và cả 2 IP nhóm proxy Lithuania (2 IP này ở K = 4 còn bị chia ra hai cụm khác nhau — bảng 8.2).
  • Đây là bằng chứng rằng K = 5 có ý nghĩa nghiệp vụ: nó gom đúng kiểu "đăng nhập bằng mật khẩu đã biết, dùng máy làm bàn đạp" — kiểu nguy hiểm nhất với UBA thật (tài khoản bị chiếm).
  • 21 tài khoản other còn lại trong cụm là danh sách nên điều tra tiếp: cùng hành vi với hai nhóm đã biết nhưng chưa có ai gán nhãn — đây là giá trị thực của K-Means khi đi cùng một ít nhãn.

Phần 9 — Tổng kết

📍 Bước 7 — Quy tắc bàn tay phải: tổng kết, báo cáo.

Bài này đã đo được gì?

Các con số chính, theo đúng thứ tự pipeline:

  • Luật cứng (1.1): khóa 245 tài khoản nhưng lọt 90 tài khoản đăng nhập đúng nhiều / dùng proxy — kẻ đã có mật khẩu thì luật "sai nhiều thì khóa" không bao giờ kêu.
  • Chuẩn bị dữ liệu (6.1): không nén log1p thì 92% tài khoản dồn vào một cụm — K-Means bắt buộc nén + chuẩn hoá.
  • Không có K đúng tuyệt đối (3.2, 6.2): K=5 tách riêng 24 tài khoản dùng proxy mà K=4 trộn lẫn.
  • Hai mô hình không giám sát (7.1): tương quan 0,89 nhưng chỉ 19/50 tài khoản lạ nhất trùng nhau → dùng cả hai. So với nhãn (8.3): ARI 0,31 — "khác nhóm" chưa phải "đúng nhóm tấn công".

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

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

Ngón Điều Câu hỏi phải tự trả lời Trong bài này Quên điều này thì…
👍 1. Ngón cái DỮ LIỆU sạch Số có lệch mạnh không? Thang đo có khác nhau không? nén log1p + chuẩn hoá trước khi gom (3.1) không nén: 2.598 / 2.838 tài khoản dồn vào một cụm (6.1)
☝️ 2. Ngón trỏ ĐẶC TRƯNG đúng Mô tả mỗi tài khoản bằng thói quen nào? 12 cột hành vi 30 ngày (0.2): kết nối, sai / đúng, phiên, lệnh, proxy, số ngày… chỉ đếm lần sai: luật khoá 245 nhưng lọt 90 tài khoản vào bằng mật khẩu đúng (1.1)
✋ 3. Ngón giữa CHỌN CÔNG CỤ Không có nhãn thì gom nhóm bằng gì, chọn K thế nào? K-Means; inertia + silhouette chỉ gợi ý K (3.2), chọn K=4 vì đặt tên được tin một con số: silhouette cao nhất ở K=2, đúng nhưng quá thô (3.2)
💍 4. Ngón áp út ĐO trung thực Cụm có khớp điều đã biết không? Mô hình khác có thấy giống không? ARI 0,31 với nhãn giảng viên (8.3) · tương quan 0,89 với Isolation Forest nhưng chỉ 19 / 50 tài khoản lạ nhất trùng nhau (7.1) tưởng "khác nhóm" là "đúng nhóm tấn công"
🤙 5. Ngón út GIẢI THÍCH & CẢNH GIÁC Người đọc chân dung cụm; điểm này trả lời câu hỏi gì? đặt tên cụm từ chân dung (Phần 4) · toàn kẻ tấn công nên điểm là ưu tiên điều tra, không phải "uy tín": 1.739 bot điển hình / 969 xem sau / 130 điều tra ngay (5.2) gọi bot là "uy tín cao" rồi bỏ qua; hoặc bắt người đọc từng IP một
In [ ]:
from pathlib import Path
THU_MUC_RA = Path.home() / "lab" / "out_lab7"
THU_MUC_RA.mkdir(parents=True, exist_ok=True)
ho_so.sort_values("diem_la", ascending=False)[["cum", "diem_la", "muc"] + SO + ["cong_cu"]].to_csv(THU_MUC_RA / "uu_tien_dieu_tra.csv")
dem_muc = ho_so.muc.value_counts()
bao_cao = f'''BÁO CÁO MODULE 7: UBA & K-MEANS TRÊN {len(ho_so):,} "TÀI KHOẢN" (IP tấn công honeypot, 24/08 - 21/09/2026)
=========================================================================================
Luật cứng (sai > {NGUONG_SAI} hoặc tải file): khóa {int(bi_khoa.sum())} tài khoản; {len(lot)} tài khoản đăng nhập đúng nhiều / dùng proxy vẫn lọt
K-Means K={SO_CUM}       : cụm {ho_so.cum.value_counts().sort_index().to_dict()}
Khớp nhãn GV (ARI) : K=4 {dong_so_khop[0]['ARI']:.2f} | K=5 {dong_so_khop[1]['ARI']:.2f}; nhóm (≥ 10 IP) gọn nhất trong 1 cụm: {bang_cheo[bang_cheo['số IP'] >= 10]['% ở cụm chính'].idxmax()} ({bang_cheo[bang_cheo['số IP'] >= 10]['% ở cụm chính'].max():.0f}%)
Ưu tiên điều tra   : bot điển hình {int(dem_muc.get('🟢 bot điển hình', 0))} | xem khi có thời gian {int(dem_muc.get('🟡 xem khi có thời gian', 0))} | điều tra ngay {int(dem_muc.get('🔴 điều tra ngay', 0))}
Khác thường nhất   : {', '.join(ho_so.sort_values('diem_la', ascending=False).index[:3])}
File ưu tiên       : {THU_MUC_RA / 'uu_tien_dieu_tra.csv'}

Khuyến nghị:
- Chặn tự động mọi IP tấn công; dùng điểm khác thường để xếp THỨ TỰ ĐIỀU TRA — người chỉ đọc nhóm 🔴 trước.
- Áp cho tài khoản nhân viên thì cùng phép tính thành điểm uy tín (cho qua / xác minh / khoá), ngưỡng tùy tài khoản.
- Luôn nén + chuẩn hóa trước K-Means; chọn K bằng cả số liệu (inertia, silhouette) lẫn hiểu biết nghiệp vụ.
- Đọc chân dung cụm, đặt tên, rồi mới hành động — máy gom, người quyết định.
- Kiểm chứng cụm bằng một ít dữ liệu có nhãn từ điều tra (như Phần 8) trước khi tin vào tên cụm.
'''
(THU_MUC_RA / "bao_cao_lab7.txt").write_text(bao_cao, encoding="utf-8")
print(bao_cao)

Kết quả: báo cáo in ra và lưu ở ~/lab/out_lab7/bao_cao_lab7.txt, kèm file uu_tien_dieu_tra.csv (mọi tài khoản xếp từ khác thường nhất xuống): Luật cứng: khóa 245 tài khoản; 90 tài khoản đăng nhập đúng nhiều / dùng proxy vẫn lọt · Khớp nhãn GV (ARI): K=4 0.31 | K=5 0.31; nhóm (≥ 10 IP) gọn nhất trong 1 cụm: TA-06 DutchGo (96%) · K-Means K=4: cụm {0: 1909, 1: 249, 2: 521, 3: 159} · Ưu tiên điều tra: bot điển hình 1739 | xem khi có thời gian 969 | điều tra ngay 130 · 3 tài khoản khác thường nhất, rồi 5 khuyến nghị.

👉 Hiểu: đổi các ô ✏️ (luật, K, ngưỡng) rồi Run All → báo cáo và file điểm tự cập nhật.

Câu hỏi suy nghĩ:

  1. Phần 1: sửa luật thế nào để bắt được 185.246.128.133 mà không khóa nhầm thêm hàng trăm tài khoản?
  2. Phần 3.3: đổi SO_CUM thành 3 và 5. Tên cụm của bạn ở Phần 4 có còn đúng không?
  3. Phần 5.2: nếu áp cùng phép tính cho tài khoản nhân viên công ty (ai cũng là người thật), 3 mức nên đổi tên và cách xử lý thế nào? Với tài khoản quan trọng (giám đốc, quản trị hệ thống) bạn đặt ngưỡng chặt hơn hay lỏng hơn?
  4. Phần 8.2: nhóm nào rải đều ra nhiều cụm? Do nhóm đó cư xử nhiều kiểu, hay do nhãn của nó chưa chắc (xem cột ghi chú)?
  5. Trong công ty của bạn, 3 "thói quen" nào của nhân viên bạn sẽ đưa vào hồ sơ hành vi?