Files
labs/Notebooks-CSAI/Lab_Python_Honeypot.ipynb

164 KiB

Python + NumPy + Pandas 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. Kết quả hiện ngay dưới ô. Chạy lần lượt từ trên xuống. Kernel phải là Python (lớp CSAI) (góc trên bên phải). Ô có dòng ✏️ SỬA là chỗ mời bạn đổi chữ / số rồi Shift + Enter lại. Sửa hỏng? Menu Kernel → Restart Kernel and Run All Cells.

Dữ liệu: log honeypot SSH thật của mình, 30 ngày 24/08–22/09/2026, ở /opt/csai/data/honeypot_m1 (mỗi ngày 1 file, mỗi dòng 1 sự kiện). Honeypot là máy "mồi": nó giả làm máy chủ SSH nhưng không phục vụ ai cả, nên gần như mọi kết nối tới nó là kẻ tấn công (bot quét cổng, dò mật khẩu). Một phần rất nhỏ là của chính người quản trị vào kiểm tra hệ thống, hoặc của công cụ đo đạc nội bộ (metrics). Khi điều tra thật, việc đầu tiên là loại các IP "nhà mình" này ra rồi mới kết luận.

🧭 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 gì có sẵn, tạo ra biến gì cho các bước sau đọc trước khi chạy
✏️ SỬA chỗ mời đổi chữ / số để thử. Giữ nguyên cũng chạy đúng. Ngay trên ô có dòng Các lựa chọn cho biết nên thử giá trị nào và sẽ thấy gì tuỳ chọn
ô có chữ (TUỲ CHỌN) đào sâu thêm; bỏ qua cũng không ảnh hưởng các ô sau tuỳ chọn
dòng # ... có lời mời "bỏ dấu #" cố tình gây lỗi để biết lỗi trông thế nào tuỳ chọn
🧰 bảng 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ả số đo thật khi chạy bài này trên máy lớp: so với màn hình của bạn —
👉 Hiểu · 📦 ý nghĩa của kết quả · 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? Một hàm được định nghĩa def ham(a, b=1): a bắt buộc, b tuỳ chọn (không ghi thì b = 1). Gọi được cả ham(5) lẫn ham(5, b=3); gọi ham() sẽ báo lỗi vì thiếu a. Các hàm có sẵn của Python, NumPy, Pandas cũng vậy: các bảng 🧰 trong bài liệt kê tham số của những hàm dùng ở đây.

Phần Học gì Làm trên
0 nhìn log gốc tận mắt log thô
1 biến, kiểu dữ liệu, chuỗi, đổi kiểu, toán tử 1 dòng log
2 if / for / while / hàm log 1 ngày (22/09)
3 list, tuple, dict, set log 1 ngày
4 NumPy: mảng, tính trên cả mảng, thống kê, mặt nạ True/False, điểm rủi ro log 30 ngày
5 Pandas: đọc, kiểm tra, làm sạch, lọc, gom, xuất file log 30 ngày
6 Tổng hợp: điều tra như chuyên viên SOC, vẽ biểu đồ, viết báo cáo, hồ sơ từng IP log 30 ngày
7 Kết luận: các kiểu tấn công thấy được, đối chiếu sổ theo dõi tấn công + MITRE ATT&CK log 30 ngày

📖 Bảng tra nhanh: các cách viết Python hay gặp trong bài

Cách viết Nghĩa Ví dụ trong bài
f"... {bien} ..." chuỗi có chèn giá trị của biến vào chỗ {} f"IP {ip} thử {n} lần"
{n:5d} · {x:.1f} · {x:.0%} · {n:,} định dạng số: rộng 5 ký tự · 1 chữ số thập phân · phần trăm · có dấu phẩy nghìn 0.37 in thành 37%
[f(x) for x in ds] list comprehension: vòng for viết gọn, tạo danh sách mới [json.loads(d) for d in dong_ngay]
{x for x in ds} như trên nhưng tạo set (tự bỏ trùng) tập IP khác nhau
sum(1 for x in ds if dk) đếm số phần tử thỏa điều kiện đếm số lần thử sai của 1 IP
any(dk for x in ds) có ít nhất một phần tử thỏa điều kiện không? (True/False) IP có thử root không
for i, x in enumerate(ds) vòng for lấy cả số thứ tự i lẫn phần tử x in vị trí dòng log
lambda x: ... hàm "một dòng" không cần đặt tên, hay dùng làm khóa sắp xếp sorted(..., key=lambda cap: cap[1])
d.get(k, 0) lấy giá trị của khóa k trong dict; chưa có thì trả 0 (không báo lỗi) đếm theo IP
bang.cot.value_counts() (pandas) đếm mỗi giá trị xuất hiện bao nhiêu lần, nhiều nhất lên đầu top IP
bang.groupby("cot") (pandas) gom các dòng có cùng giá trị ở cot thành nhóm để tính riêng từng nhóm số username mỗi IP
bang[dieu_kien] (pandas / NumPy) lọc: chỉ giữ dòng có điều kiện = True sach[sach.src_ip == ip]

Phần 0 — Log gốc trông như thế nào?

Trước khi viết code, hãy nhìn tận mắt thứ mà máy chủ ghi lại mỗi giây.

0.1 Kho log lớn cỡ nào?

🎯 Mục tiêu: biết kho log to cỡ nào trước khi phân tích
📥 Đầu vào: thư mục log /opt/csai/data/honeypot_m1
📤 Đầu ra: chỉ in ra màn hình, không tạo biến. Ô này chạy lệnh Linux (dòng đầu %%bash), không phải Python

In [ ]:
%%bash
cd /opt/csai/data/honeypot_m1
# ls -lh = liệt kê file kèm dung lượng dễ đọc; head -4 = chỉ xem 4 dòng đầu
ls -lh | head -4
# du -sh = tổng dung lượng thư mục
du -sh .
# cat ghép cả 30 file rồi wc -l đếm số dòng = số sự kiện
cat cowrie.json.* | wc -l

Kết quả: 3 file đầu (cowrie.json.2026-08-24 958K, …08-25 1.3M, …08-26 1.8M) · 63M · 395124

👉 Hiểu: 30 file, 63 MB chữ, 395.124 dòng — mỗi dòng là một việc kẻ tấn công vừa làm.

💼 Dùng khi nào: nhận một bộ log mới (từ máy chủ, tường lửa, đối tác gửi) — trước khi chọn công cụ phân tích.
⚖️ Ảnh hưởng tới: đọc cả vào bộ nhớ được không hay phải đọc từng phần; dung lượng lưu trữ cần mua; chi phí SIEM (thường tính tiền theo GB log mỗi ngày).

0.2 Đọc thử 30 dòng log THÔ của ngày bùng nổ 20/09

🎯 Mục tiêu: nhìn tận mắt log gốc
📥 Đầu vào: file log ngày 20/09
📤 Đầu ra: in nguyên văn 30 dòng (dòng 6001 → 6030), không tạo biến

In [ ]:
%%bash
# sed -n "6001,6030p" = in nguyên văn từ dòng 6001 tới dòng 6030 của file ngày 20/09 (không xử lý gì)
sed -n "6001,6030p" /opt/csai/data/honeypot_m1/cowrie.json.2026-09-20

Kết quả: 30 dòng dài, mỗi dòng dạng {"eventid": "cowrie.…", "timestamp": "2026-09-20T05:05:56.199332Z", "src_ip": "109.160.32.27", …}.

💼 Dùng khi nào: bắt đầu điều tra một sự cố: luôn đọc vài dòng thô trước khi viết code, và đọc lại để kiểm tra con số code đưa ra.
⚖️ Ảnh hưởng tới: hiểu sai định dạng (giờ UTC, trường lúc có lúc không) thì mọi thống kê phía sau đều sai.

0.3 Thử trả lời BẰNG MẮT (chưa dùng code) với 30 dòng trên

  1. Có bao nhiêu IP khác nhau?
  2. IP nào thử mật khẩu nhiều nhất, mấy lần?
  3. Có ai đăng nhập thành công không? Username / mật khẩu gì?
  4. 30 dòng này kéo dài bao nhiêu giây?

Mất bao lâu để trả lời? Giờ nhân lên: cả kho có 395.124 dòng — gấp hơn 13.000 lần đoạn vừa đọc. Đó là lý do phải học Python: các phần sau biến những câu hỏi trên thành vài dòng code chạy trong chưa tới 1 giây.

Đáp án (kiểm tra sau khi tự trả lời): (1) 2 IP: 109.160.32.18 và 109.160.32.27 — cùng dải mạng, xen kẽ nhau; (2) 109.160.32.27 thử 6 lần, .18 thử 4 lần; (3) có 1 lần: dòng cowrie.login.success của 109.160.32.27 với root / aassddff, nằm lẫn giữa các dòng thất bại; (4) từ 05:05:56 đến 05:06:21 = 25 giây.

👉 Hiểu: log gốc ghi mọi thứ trộn lẫn theo thứ tự thời gian: nhiều kẻ tấn công xen kẽ, mỗi lần tấn công rải ra nhiều dòng (kết nối → khai phần mềm → thử mật khẩu), giờ là giờ quốc tế UTC, thông tin quan trọng (1 lần đột nhập thành công) chìm giữa hàng chục dòng bình thường.


Phần 1 — Python cơ bản trên MỘT dòng log

Các nhóm lệnh của Phần 1 dùng để làm gì khi xử lý log?

Nhóm lệnh Nói nôm na Khi xử lý log, dùng để… Mục
biến ten = gia_tri một cái hộp dán nhãn để cất một giá trị, lát sau gọi lại bằng tên cất IP, giờ, username của sự kiện đang xem để các dòng sau dùng 1.1, 1.2
kiểu dữ liệu str int float bool dict loại của giá trị: chữ · số nguyên · số thập phân · đúng/sai · sổ tra cứu biết trường nào tính toán được (số: cổng, giờ), trường nào chỉ so khớp được (chữ: IP, mật khẩu) 1.2
xử lý chuỗi [:10] .split() .join() .replace() .upper() cắt, tách, ghép, thay, đổi hoa/thường lấy ngày từ thời gian, tách IP ra dải mạng /24, rút gọn tên sự kiện 1.3
ép kiểu int() str() đổi chữ "07" thành số 7 và ngược lại đổi giờ quốc tế (UTC) sang giờ Việt Nam; biết khi nào không được đổi (mật khẩu "0123") 1.4
so sánh / logic == < >= and or not hỏi một câu đúng/sai, rồi ghép nhiều câu hỏi viết luật cảnh báo: "thử đăng nhập và dùng root và ngoài giờ" 1.5

1.1 Một dòng log thật là một chuỗi chữ

🎯 Mục tiêu: đọc 3 dòng đầu của file log và thấy: với Python, mỗi dòng chỉ là một chuỗi chữ
📥 Đầu vào: file log ngày 22/09
📤 Đầu ra: THU_MUC_LOG, FILE_NGAY (đường dẫn) · ba_dong (danh sách 3 chuỗi chữ = 3 dòng log)

🧰 Tham số: open(duong_dan, mode="r", encoding=None) — duong_dan bắt buộc · mode tuỳ chọn: "r" đọc (mặc định) / "w" ghi đè / "a" ghi thêm vào cuối · encoding tuỳ chọn, ví dụ "utf-8". Viết trong with open(...) as f: thì file tự đóng khi xong. f.readline() không có tham số: mỗi lần gọi đọc 1 dòng kế tiếp. .strip() tuỳ chọn 1 tham số: không ghi = bỏ khoảng trắng và ký tự xuống dòng ở 2 đầu.

In [ ]:
THU_MUC_LOG = "/opt/csai/data/honeypot_m1"
FILE_NGAY = THU_MUC_LOG + "/cowrie.json.2026-09-22"
# Đọc 3 dòng đầu của file ngày 22/09 (readline = đọc 1 dòng; strip = bỏ ký tự xuống dòng ở cuối)
with open(FILE_NGAY) as f:
    ba_dong = [f.readline().strip() for _ in range(3)]
for dong in ba_dong:
    print(dong)
print()
print("Kiểu dữ liệu:", type(ba_dong[0]), "| dài", len(ba_dong[0]), "ký tự")

Kết quả: 3 dòng log (session.connect, client.version, login.failed của IP 109.160.32.67) · Kiểu dữ liệu: <class 'str'> | dài 154 ký tự

👉 Hiểu: với Python, một dòng log mới chỉ là một chuỗi chữ dài 154 ký tự — chưa biết đâu là IP, đâu là giờ.

💼 Dùng khi nào: đọc bất kỳ file log nào (syslog, nginx, tường lửa, Windows Event xuất ra) — với Python tất cả đều bắt đầu là chuỗi chữ.
⚖️ Ảnh hưởng tới: chưa tách thành trường thì chỉ tìm kiếm chữ được (như grep), chưa đếm / tính toán được.

1.2 Biến một dòng thành từ điển, lấy từng trường ra biến

🎯 Mục tiêu: biến chuỗi chữ JSON thành từ điển để lấy từng trường bằng tên (su_kien["src_ip"])
📥 Đầu vào: ba_dong
📤 Đầu ra: su_kien (từ điển của 1 dòng) · loai, thoi_gian, ip, co_cong, cong

🧰 json.loads(chuoi): 1 tham số bắt buộc (chuỗi JSON). d["khoa"] báo lỗi KeyError nếu không có khoá; d.get(khoa, mac_dinh): khoa bắt buộc, mac_dinh tuỳ chọn (không ghi = None), không bao giờ báo lỗi.

Các lựa chọn ✏️ DONG_SO: 0 = dòng kết nối (có src_port) · 1 = dòng khai phần mềm SSH · 2 = dòng thử mật khẩu (có username, password). Số khác → lỗi IndexError vì chỉ đọc 3 dòng.

In [ ]:
import json
# ✏️ SỬA: chọn dòng số mấy (0, 1 hoặc 2)
DONG_SO = 0
su_kien = json.loads(ba_dong[DONG_SO])        # chuỗi JSON -> từ điển (dict) Python
print("Các trường:", list(su_kien.keys()))

loai = su_kien["eventid"]                     # str  (chuỗi chữ)
thoi_gian = su_kien["timestamp"]              # str
ip = su_kien["src_ip"]                        # str
co_cong = "src_port" in su_kien               # bool (True / False): dòng này có cổng nguồn không?
cong = su_kien.get("src_port", 0)             # int  (số nguyên); .get(..., 0) = không có thì lấy 0
print(f"loai={loai} | thoi_gian={thoi_gian} | ip={ip} | co_cong={co_cong} | cong={cong}")
print(type(loai), type(ip), type(co_cong), type(cong))

Kết quả: Các trường: ['eventid', 'timestamp', 'src_ip', 'src_port', 'session'] · loai=cowrie.session.connect | … | ip=109.160.32.67 | co_cong=True | cong=36330 · <class 'str'> <class 'str'> <class 'bool'> <class 'int'>

👉 Hiểu: json.loads biến chuỗi thành từ điển: gọi tên trường là lấy được giá trị, mỗi giá trị có kiểu riêng (chữ, số, đúng/sai). Đổi DONG_SO = 2 → dòng đăng nhập có thêm username, password nhưng không có src_port → cong=0.

💼 Dùng khi nào: log dạng JSON: Cowrie, Suricata (eve.json), log đám mây, kết quả của hầu hết API bảo mật.
⚖️ Ảnh hưởng tới: dùng .get() cho trường có thể thiếu → script chạy đêm không chết giữa chừng chỉ vì 1 dòng lạ.

1.3 Xử lý chuỗi: cắt, tách, thay, viết hoa

🎯 Mục tiêu: cắt / tách chuỗi để lấy ngày, giờ, dải mạng, tên sự kiện gọn
📥 Đầu vào: thoi_gian, ip, loai (từ 1.2)
📤 Đầu ra: ngay, gio_phut_giay, cac_phan_ip, dai_mang, ten_ngan

🧰 chuoi[a:b]: lấy ký tự từ vị trí a tới trước b; cả hai tuỳ chọn ([:10] = từ đầu, [11:] = tới hết) · .split(dau_tach): dau_tach tuỳ chọn (không ghi = tách ở khoảng trắng) · "x".join(danh_sach): ghép danh sách lại, chèn "x" vào giữa · .replace(cu, moi): 2 tham số bắt buộc.

In [ ]:
ngay = thoi_gian[:10]                      # 10 ký tự đầu: năm-tháng-ngày
gio_phut_giay = thoi_gian[11:19]           # ký tự 11 -> 18
cac_phan_ip = ip.split(".")                # tách IP ở dấu chấm -> danh sách 4 phần
dai_mang = ".".join(cac_phan_ip[:3]) + ".0/24"   # ghép lại 3 phần đầu -> dải mạng
ten_ngan = loai.replace("cowrie.", "").upper()   # bỏ chữ "cowrie." rồi viết HOA
print("Ngày:", ngay, "| Giờ (UTC):", gio_phut_giay)
print("4 phần của IP:", cac_phan_ip, "-> dải mạng:", dai_mang)
print("Loại sự kiện viết gọn:", ten_ngan)

Kết quả: Ngày: 2026-09-22 | Giờ (UTC): 00:00:02 · 4 phần của IP: ['109', '160', '32', '67'] -> dải mạng: 109.160.32.0/24 · Loại sự kiện viết gọn: SESSION.CONNECT

👉 Hiểu: cắt [:10], tách split, ghép join, thay replace là 4 thao tác chuỗi dùng hằng ngày khi đọc log.

💼 Dùng khi nào: cắt ngày để gom theo ngày, tách IP ra dải /24, rút gọn tên sự kiện cho bảng báo cáo.
⚖️ Ảnh hưởng tới: dải /24 là đơn vị hay dùng khi chặn trên tường lửa (Phần 7, kiểu D: chặn cả dải thay vì từng IP).

1.4 Đổi kiểu dữ liệu (ép kiểu)

🎯 Mục tiêu: đổi chữ thành số để tính được (giờ UTC → giờ Việt Nam), và thấy khi nào không được đổi
📥 Đầu vào: thoi_gian
📤 Đầu ra: gio_utc, gio_vn, mat_khau

In [ ]:
gio_utc = int(thoi_gian[11:13])            # [11:13] cắt lấy 2 ký tự ở vị trí 11 và 12, tức là chuỗi "00"; "00" (chữ) -> 0 (số) thì mới cộng trừ được
gio_vn = (gio_utc + 7) % 24                # Việt Nam = UTC + 7; % 24 để 25 giờ quay về 1 giờ
print("Giờ UTC:", gio_utc, "-> giờ Việt Nam:", gio_vn)

# Vì sao MẬT KHẨU phải giữ là chuỗi chữ, không đổi sang số?
mat_khau = "0123456"
print("Mật khẩu gốc:", mat_khau, "| đổi sang số:", int(mat_khau), "<- mất số 0 ở đầu = sai mật khẩu")
# Thử bỏ dấu # ở dòng dưới rồi chạy: cộng chữ với số sẽ báo lỗi TypeError
# print(thoi_gian[11:13] + 7)

Kết quả: Giờ UTC: 0 -> giờ Việt Nam: 7 · Mật khẩu gốc: 0123456 | đổi sang số: 123456 <- mất số 0 ở đầu = sai mật khẩu

👉 Hiểu: muốn tính toán thì phải đổi chữ sang số (int); nhưng thứ không phải để tính (mật khẩu, mã bưu điện, số điện thoại) thì phải giữ là chữ.

💼 Dùng khi nào: đối chiếu log với giờ làm việc, giờ người dùng báo sự cố, log của hệ thống khác — log máy chủ thường ghi giờ UTC.
⚖️ Ảnh hưởng tới: lệch 7 tiếng → kết luận "tấn công lúc nửa đêm" sai; đổi mật khẩu / mã nhân viên / số điện thoại sang số thì mất số 0 ở đầu.

1.5 Toán tử so sánh / logic: tính điểm rủi ro cho một sự kiện

🎯 Mục tiêu: viết một luật chấm điểm rủi ro cho 1 sự kiện
📥 Đầu vào: ba_dong[2] (dòng thử mật khẩu)
📤 Đầu ra: dang_nhap, user, la_dang_nhap, thu_root, ngoai_gio, diem

Các lựa chọn ✏️ trọng số (3 số 40, 30, 30): tăng trọng số đăng nhập từ 40 lên 60 → sự kiện này đạt 60 điểm, thành "cần xem xét" · đặt thu_root lên 100 → mọi lần thử root đều bị đánh dấu dù giờ nào.

In [ ]:
# Lấy dòng thứ 3 (một lần thử đăng nhập thật)
dang_nhap = json.loads(ba_dong[2])
user = dang_nhap["username"]
la_dang_nhap = dang_nhap["eventid"].startswith("cowrie.login")
thu_root = user.lower() == "root"
gio_vn = (int(dang_nhap["timestamp"][11:13]) + 7) % 24
ngoai_gio = gio_vn < 6 or gio_vn >= 22                  # 22h -> 6h sáng giờ Việt Nam
# ✏️ SỬA: trọng số (mỗi điều kiện đúng được cộng bao nhiêu điểm)
diem = int(la_dang_nhap) * 40 + int(thu_root) * 30 + int(ngoai_gio) * 30
print(f"user={user} | đăng nhập={la_dang_nhap} | thử root={thu_root} | giờ VN={gio_vn}h | ngoài giờ={ngoai_gio}")
print("Điểm rủi ro:", diem, "-> cần xem xét" if diem >= 60 else "-> bình thường")

Kết quả: user=ftpuser1 | đăng nhập=True | thử root=False | giờ VN=7h | ngoài giờ=False · Điểm rủi ro: 40 -> bình thường

👉 Hiểu: and, or, ==, < ghép lại thành luật; int(True) = 1 nên nhân trọng số được. Thử đổi trọng số đăng nhập từ 40 lên 60 → sự kiện này thành "cần xem xét".

💼 Dùng khi nào: viết luật cảnh báo đơn giản: luật trong SIEM, script cron gửi Telegram / email.
⚖️ Ảnh hưởng tới: trọng số và ngưỡng quyết định cái gì thành cảnh báo → số cảnh báo đội trực phải xử lý mỗi ngày.


Phần 2 — if / for / while / hàm trên log MỘT ngày

2.1 Đọc cả ngày 22/09

🎯 Mục tiêu: nạp toàn bộ sự kiện của một ngày vào bộ nhớ, mỗi dòng đổi sẵn thành từ điển (giống 1.2). Nhờ vậy 2.2 → 3.5 chỉ việc đếm / lọc trên danh sách này, không phải đọc lại file (đọc file chậm hơn đọc bộ nhớ hàng trăm lần). Giống "mở file Excel ra một lần rồi làm việc trên bảng"
📥 Đầu vào: FILE_NGAY = file log ngày 22/09 (10.381 dòng chữ)
📤 Đầu ra: dong_ngay (danh sách 10.381 chuỗi gốc) · su_kien_ngay (danh sách 10.381 từ điển, mỗi phần tử = 1 sự kiện)

🧰 f.read() đọc cả file thành một chuỗi rất dài · .splitlines() cắt chuỗi đó ở mỗi chỗ xuống dòng → danh sách các dòng. [json.loads(d) for d in dong_ngay] = làm json.loads cho từng dòng (xem bảng tra nhanh ở đầu bài: list comprehension).

In [ ]:
with open(FILE_NGAY) as f:
    dong_ngay = f.read().splitlines()          # mỗi phần tử = 1 dòng (chuỗi)
# Đổi tất cả sang dict một lần (list comprehension = vòng for viết gọn trong ngoặc vuông)
su_kien_ngay = [json.loads(d) for d in dong_ngay]
# Bên trong su_kien_ngay có gì? -> một DANH SÁCH (list); mỗi phần tử là một TỪ ĐIỂN (dict) = 1 dòng log, giống 1.2
print("su_kien_ngay là", type(su_kien_ngay).__name__, "gồm", len(su_kien_ngay), "phần tử (= số sự kiện của ngày 22/09)")
print("Phần tử số 0 (kết nối):    ", su_kien_ngay[0])
print("Phần tử số 2 (thử mật khẩu):", su_kien_ngay[2])
print("Lấy 1 trường của 1 phần tử: su_kien_ngay[2]['username'] =", su_kien_ngay[2]["username"])

Kết quả:

su_kien_ngay là list gồm 10381 phần tử (= số sự kiện của ngày 22/09)
Phần tử số 0 (kết nối):     {'eventid': 'cowrie.session.connect', 'timestamp': '2026-09-22T00:00:02.779416Z', 'src_ip': '109.160.32.67', 'src_port': 36330, 'session': '54613b5cec67'}
Phần tử số 2 (thử mật khẩu): {'eventid': 'cowrie.login.failed', ..., 'username': 'ftpuser1', 'password': '123456'}
Lấy 1 trường của 1 phần tử: su_kien_ngay[2]['username'] = ftpuser1

📦 su_kien_ngay = danh sách 10.381 phần tử, mỗi phần tử là một từ điển (một dòng log đã json.loads, giống 1.2). Muốn lấy thông tin: su_kien_ngay[số thứ tự]["tên trường"]. Các mục 2.2 → 2.5 đều đi qua danh sách này bằng for e in su_kien_ngay: — mỗi vòng, e là một từ điển như trên.

💼 Dùng khi nào: phân tích log của một ngày / một ca trực: nạp một lần, hỏi nhiều câu.
⚖️ Ảnh hưởng tới: file nhiều GB thì không nạp hết vào bộ nhớ được → phải đọc từng dòng hoặc dùng pandas đọc theo khối.

2.2 for + if/elif/else: một IP thử sai bao nhiêu lần, mức độ nào?

🎯 Mục tiêu: đếm số lần thử sai của một IP trong ngày, rồi xếp mức độ
📥 Đầu vào: su_kien_ngay
📤 Đầu ra: so_lan_sai, muc_do

Các lựa chọn ✏️ IP_KIEM_TRA: "109.160.32.31" → 766 lần, NGHIÊM TRỌNG · "2.57.121.112" → 110 lần, CAO · "8.8.8.8" (không có trong log) → 0 lần, THẤP. Muốn đổi ranh giới mức độ: sửa các số 500, 100, 1 trong if / elif.

In [ ]:
# ✏️ SỬA: IP muốn kiểm tra (thử "2.57.121.112", "109.160.32.67")
IP_KIEM_TRA = "109.160.32.31"
so_lan_sai = 0
for e in su_kien_ngay:
    if e["eventid"] == "cowrie.login.failed" and e["src_ip"] == IP_KIEM_TRA:
        so_lan_sai += 1

if so_lan_sai >= 500:
    muc_do = "NGHIÊM TRỌNG"
elif so_lan_sai >= 100:
    muc_do = "CAO"
elif so_lan_sai >= 1:
    muc_do = "TRUNG BÌNH"
else:
    muc_do = "THẤP"
print(IP_KIEM_TRA, "thử sai", so_lan_sai, "lần trong ngày ->", muc_do)

Kết quả: 109.160.32.31 thử sai 766 lần trong ngày -> NGHIÊM TRỌNG

👉 Hiểu: for đi qua từng sự kiện, if đếm cái cần đếm, if/elif/else xếp mức độ. Thử "2.57.121.112" → 110 lần → CAO.

💼 Dùng khi nào: có cảnh báo hoặc người dùng báo về một IP cụ thể: kiểm tra nhanh nó đã làm gì.
⚖️ Ảnh hưởng tới: mức độ quyết định cách xử lý: chặn ngay, theo dõi, hay bỏ qua.

2.3 continue và break: bỏ qua dòng không cần, dừng khi thấy điều cần tìm

🎯 Mục tiêu: tìm lần đăng nhập thành công đầu tiên trong ngày, in các lần thử trước nó
📥 Đầu vào: su_kien_ngay
📤 Đầu ra: da_xem (số lần thử đã xem); phần còn lại chỉ in ra

🧰 enumerate(danh_sach, start=0): danh_sach bắt buộc · start tuỳ chọn = số bắt đầu đếm (mặc định 0). str.startswith(dau): dau bắt buộc — chuỗi có bắt đầu bằng dau không (True/False).

In [ ]:
# Duyệt từ đầu ngày: bỏ qua dòng không phải đăng nhập (continue), dừng ở lần đăng nhập THÀNH CÔNG đầu tiên (break)
da_xem = 0
# enumerate: mỗi vòng lấy cả vi_tri (số thứ tự 0, 1, 2...) lẫn e (1 sự kiện = 1 dict)
for vi_tri, e in enumerate(su_kien_ngay):
    if not e["eventid"].startswith("cowrie.login"):
        continue                                  # không phải đăng nhập -> sang dòng kế
    da_xem += 1
    print(vi_tri, e["timestamp"][11:19], e["src_ip"], e["eventid"][7:], e["username"], "/", e.get("password"))
    if e["eventid"] == "cowrie.login.success":
        print("-> Đăng nhập THÀNH CÔNG đầu tiên của ngày ở dòng", vi_tri, "sau", da_xem, "lần thử")
        break                                     # tìm thấy rồi -> dừng vòng lặp

Kết quả:

2 00:00:03 109.160.32.67 login.failed ftpuser1 / 123456
5 00:00:08 109.160.32.67 login.success root / 1234567890
-> Đăng nhập THÀNH CÔNG đầu tiên của ngày ở dòng 5 sau 2 lần thử

👉 Hiểu: chỉ 5 giây sau nửa đêm (UTC) đã có kẻ "đăng nhập thành công" bằng root. Honeypot cố tình cho vào để ghi lại hành vi — trên máy chủ thật, đây là lúc máy bị chiếm.

💼 Dùng khi nào: tìm sự kiện đầu tiên: lần đăng nhập thành công đầu tiên = thời điểm bị xâm nhập — mốc thời gian quan trọng nhất của báo cáo sự cố.
⚖️ Ảnh hưởng tới: break dừng ngay khi tìm thấy → nhanh hơn nhiều trên log rất lớn.

2.4 while: đọc tới khi gặp đủ N IP khác nhau

🎯 Mục tiêu: lặp khi chưa biết trước phải lặp bao nhiêu lần
📥 Đầu vào: su_kien_ngay
📤 Đầu ra: da_gap (danh sách IP đã gặp), i (số dòng đã đọc)

Các lựa chọn ✏️ N_IP: 5 → đọc 289 dòng · số lớn hơn (20, 50) → đọc nhiều dòng hơn · lớn hơn số IP trong ngày → vòng lặp dừng ở cuối danh sách nhờ điều kiện i < len(su_kien_ngay).

In [ ]:
# ✏️ SỬA: muốn gặp bao nhiêu IP khác nhau
N_IP = 5
da_gap = []
i = 0
while len(da_gap) < N_IP and i < len(su_kien_ngay):
    ip_i = su_kien_ngay[i]["src_ip"]
    if ip_i not in da_gap:
        da_gap.append(ip_i)
        print(f"dòng {i:5d} | {su_kien_ngay[i]['timestamp'][11:19]} | IP mới: {ip_i}")
    i += 1
print("Đọc", i, "dòng thì gặp đủ", len(da_gap), "IP khác nhau")

Kết quả: 5 dòng IP mới: 109.160.32.67, 103.245.237.17, 180.93.1.64, 36.140.150.164, 185.246.128.133 · Đọc 289 dòng thì gặp đủ 5 IP khác nhau

👉 Hiểu: while lặp khi điều kiện còn đúng — hợp với việc không biết trước phải lặp bao nhiêu lần.

💼 Dùng khi nào: đọc tới khi đủ điều kiện mà không biết trước bao nhiêu dòng: lấy mẫu N IP, đọc tới một mốc thời gian, chờ tới khi file có dữ liệu.
⚖️ Ảnh hưởng tới: quên điều kiện dừng → vòng lặp chạy mãi, treo script.

2.5 Đóng gói luật thành HÀM, dùng lại cho nhiều IP

🎯 Mục tiêu: gói luật xếp mức độ vào một hàm để gọi lại cho bao nhiêu IP cũng được
📥 Đầu vào: su_kien_ngay
📤 Đầu ra: hàm phan_loai_ip (dùng lại ở các bước sau nếu muốn)

🧰 phan_loai_ip(so_sai, thu_root=False, ngoai_gio=False) — so_sai bắt buộc (số lần thử sai) · thu_root tuỳ chọn, True/False (mặc định False): IP có thử root không, đúng thì +50 điểm · ngoai_gio tuỳ chọn, True/False (mặc định False): đúng thì +30 điểm. Gọi được cả phan_loai_ip(3) lẫn phan_loai_ip(80, thu_root=True).

In [ ]:
def phan_loai_ip(so_sai, thu_root=False, ngoai_gio=False):
    """Trả về THẤP / TRUNG BÌNH / CAO / NGHIÊM TRỌNG cho một IP."""
    diem = so_sai                     # mỗi lần thử sai = 1 điểm
    if thu_root:
        diem += 50
    if ngoai_gio:
        diem += 30
    if diem >= 500:
        return "NGHIÊM TRỌNG"
    if diem >= 100:
        return "CAO"
    if diem >= 30:
        return "TRUNG BÌNH"
    return "THẤP"

print(phan_loai_ip(3))
print(phan_loai_ip(80, thu_root=True))
# Áp dụng cho 5 IP thật của ngày 22/09
for ip_x in ["109.160.32.31", "109.160.32.67", "2.57.121.112", "2.57.121.25", "8.8.8.8"]:
    # sum(1 for ... if ...) = đếm số sự kiện thỏa điều kiện: thử sai VÀ đúng IP này
    sai = sum(1 for e in su_kien_ngay if e["eventid"] == "cowrie.login.failed" and e["src_ip"] == ip_x)
    # any(...) = True nếu có ÍT NHẤT MỘT sự kiện của IP này dùng username root
    root = any(e["src_ip"] == ip_x and e.get("username") == "root" for e in su_kien_ngay)
    print(f"{ip_x:15s} sai {sai:4d} | thử root {str(root):5s} -> {phan_loai_ip(sai, thu_root=root)}")

Kết quả: THẤP · CAO, rồi:

109.160.32.31   sai  766 | thử root True  -> NGHIÊM TRỌNG
109.160.32.67   sai  158 | thử root True  -> CAO
2.57.121.112    sai  110 | thử root False -> CAO
2.57.121.25     sai   84 | thử root False -> TRUNG BÌNH
8.8.8.8         sai    0 | thử root False -> THẤP

👉 Hiểu: viết luật một lần trong hàm, gọi lại cho bao nhiêu IP cũng được. 8.8.8.8 (DNS của Google) không có trong log → THẤP.

💼 Dùng khi nào: luật dùng hằng ngày → đóng gói thành hàm / script, chạy cho cả danh sách IP từ cảnh báo.
⚖️ Ảnh hưởng tới: sửa luật một chỗ, mọi nơi gọi hàm đều đổi theo — không còn 5 bản copy lệch nhau.


Phần 3 — list, tuple, dict, set: biến log thành cấu trúc dữ liệu

4 "đồ đựng" dữ liệu và việc của chúng khi xử lý log:

Cấu trúc Hình dung Đặc điểm Khi xử lý log, dùng để… Mục
list [a, b, c] một hàng xếp có số thứ tự 0, 1, 2… giữ thứ tự, cho phép trùng, sửa được giữ các sự kiện theo đúng thứ tự thời gian; danh sách cảnh báo 2.1, 3.1, 3.5
tuple (a, b) một list bị "đóng băng" không sửa được sau khi tạo danh sách cố định (các trường cần giữ); một cặp (IP, số lần) 3.1, 3.3
set {a, b} một túi không có hai thứ giống nhau tự bỏ trùng; hỏi "x có trong túi không" rất nhanh; có phép hợp / giao / trừ đếm số IP khác nhau; danh sách chặn (blocklist); "IP chỉ gõ cửa" = tất cả IP trừ IP có đăng nhập 3.2, 3.5
dict {khoa: gia_tri} một sổ tra cứu: tra khoá → ra giá trị khoá không trùng, tra rất nhanh đếm theo nhóm (IP → số lần sai); IP → tập username; 1 dòng log (tên trường → giá trị) 1.2, 3.3, 3.4

Chọn cái nào? Cần giữ thứ tự → list. Cần bỏ trùng hoặc kiểm tra "có trong danh sách không" → set. Cần đếm / gom theo một thứ (theo IP, theo username) → dict. Thứ cố định không được sửa → tuple.

3.1 tuple các trường cần giữ, list các sự kiện gọn

🎯 Mục tiêu: bỏ các trường thừa, mỗi sự kiện chỉ giữ 5 trường cần dùng
📥 Đầu vào: su_kien_ngay
📤 Đầu ra: CAC_TRUONG (tuple 5 tên trường), hàm rut_gon, gon (danh sách 10.381 từ điển gọn)

In [ ]:
CAC_TRUONG = ("timestamp", "src_ip", "eventid", "username", "password")   # tuple: cố định, không sửa được
def rut_gon(e):
    """Giữ đúng các trường trong CAC_TRUONG; trường nào không có thì để None"""
    return {truong: e.get(truong) for truong in CAC_TRUONG}

gon = [rut_gon(e) for e in su_kien_ngay]
print("list có", len(gon), "phần tử, phần tử đầu:")
print(gon[0])
# Thử bỏ # dòng dưới: tuple không cho sửa -> TypeError
# CAC_TRUONG[0] = "gio"

Kết quả: list có 10381 phần tử, phần tử đầu: {'timestamp': '2026-09-22T00:00:02.779416Z', 'src_ip': '109.160.32.67', 'eventid': 'cowrie.session.connect', 'username': None, 'password': None}

👉 Hiểu: mọi sự kiện giờ có cùng 5 trường; trường không có thì là None (rỗng) thay vì làm chương trình báo lỗi.

💼 Dùng khi nào: rút log về đúng các trường cần trước khi lưu lâu dài hoặc gửi cho bên khác.
⚖️ Ảnh hưởng tới: dung lượng, chi phí lưu trữ, và lộ dữ liệu (bỏ trường nhạy cảm trước khi chia sẻ).

3.2 set: tập hợp IP khác nhau (tự bỏ trùng)

🎯 Mục tiêu: đếm số IP khác nhau, và số IP chỉ gõ cửa mà không thử mật khẩu
📥 Đầu vào: gon
📤 Đầu ra: ip_trong_ngay, ip_dang_nhap (hai set IP)

In [ ]:
ip_trong_ngay = {e["src_ip"] for e in gon}
ip_dang_nhap = {e["src_ip"] for e in gon if e["eventid"].startswith("cowrie.login")}
print("IP khác nhau trong ngày:", len(ip_trong_ngay))
print("IP có thử đăng nhập:    ", len(ip_dang_nhap))
print("IP chỉ gõ cửa rồi đi (phép trừ tập hợp):", len(ip_trong_ngay - ip_dang_nhap))

Kết quả: IP khác nhau trong ngày: 105 · IP có thử đăng nhập: 52 · IP chỉ gõ cửa rồi đi (phép trừ tập hợp): 53

👉 Hiểu: set tự bỏ trùng, và trừ / giao tập hợp trả lời ngay câu "ai có mặt mà không làm gì".

💼 Dùng khi nào: đếm số IP khác nhau cho báo cáo; so 2 ngày tìm IP mới; kiểm tra IP có trong danh sách chặn (blocklist) không.
⚖️ Ảnh hưởng tới: "10.000 kết nối" nghe đáng sợ, nhưng "10.000 kết nối từ 3 IP" thì chỉ cần chặn 3 IP — quy mô quyết định cách xử lý.

3.3 dict: đếm số lần thử sai của từng IP

🎯 Mục tiêu: đếm số lần thử sai theo từng IP rồi lấy top 5
📥 Đầu vào: gon
📤 Đầu ra: sai_theo_ip (dict IP → số lần sai, dùng lại ở 3.5)

🧰 sorted(danh_sach, key=None, reverse=False) — danh_sach bắt buộc · key tuỳ chọn: một hàm cho biết sắp theo cái gì (ở đây lambda cap: cap[1] = theo số lần) — không ghi thì sắp theo chính phần tử · reverse tuỳ chọn: False nhỏ → lớn (mặc định) / True lớn → nhỏ.

In [ ]:
sai_theo_ip = {}
for e in gon:
    if e["eventid"] != "cowrie.login.failed":
        continue
    sai_theo_ip[e["src_ip"]] = sai_theo_ip.get(e["src_ip"], 0) + 1   # .get(ip, 0): chưa có thì bắt đầu từ 0
# Bên trong sai_theo_ip: dict, KHÓA = IP, GIÁ TRỊ = số lần thử sai của IP đó trong ngày
print("sai_theo_ip là", type(sai_theo_ip).__name__, "có", len(sai_theo_ip), "khóa (= số IP có thử sai). 3 cặp đầu:", dict(list(sai_theo_ip.items())[:3]))
# .items() = các cặp (IP, số lần); sorted theo phần tử thứ 2 của cặp (cap[1] = số lần), lớn trước (reverse=True); [:5] lấy 5 cặp
for ip_x, n in sorted(sai_theo_ip.items(), key=lambda cap: cap[1], reverse=True)[:5]:
    print(f"  {ip_x:15s} {n}")

Kết quả: sai_theo_ip là dict có 32 khóa (= số IP có thử sai). 3 cặp đầu: {'109.160.32.67': 158, '180.93.1.64': 4, '94.154.43.56': 11}, rồi top: 109.160.32.31 766 · 109.160.32.77 766 · 109.160.32.67 158 · 2.57.121.112 110 · 2.57.121.25 84

📦 sai_theo_ip = từ điển IP → số lần thử sai trong ngày 22/09. Hỏi một IP: sai_theo_ip["2.57.121.112"] → 110. Dùng lại ở 3.5.

👉 Hiểu: 2 IP liền dải, đúng cùng 766 lần sai trong một ngày → một hệ thống điều khiển chung, không phải 2 người riêng lẻ.

💼 Dùng khi nào: bảng top IP — dòng đầu tiên của gần như mọi báo cáo SOC.
⚖️ Ảnh hưởng tới: danh sách chặn và thứ tự điều tra.

3.4 dict chứa set: mỗi IP đã thử những username nào?

🎯 Mục tiêu: với mỗi IP, gom tập username nó đã thử
📥 Đầu vào: gon
📤 Đầu ra: user_theo_ip (dict IP → set username, dùng lại ở 3.5)

🧰 d.setdefault(khoa, mac_dinh): cả hai bắt buộc ở đây — chưa có khoa thì thêm nó với giá trị mac_dinh (một set rỗng), rồi trả về giá trị đó để .add() vào.

Các lựa chọn ✏️ IP_XEM: "2.57.121.112" (thử tên người) · "109.160.32.67" (102 username) · IP không có trong ngày → 0 username, không lỗi nhờ .get(IP_XEM, set()).

In [ ]:
user_theo_ip = {}
for e in gon:
    if e["username"] is not None:
        # setdefault(ip, set()): nếu IP chưa có trong dict thì tạo một set rỗng cho nó; rồi .add() thêm username vào set đó
        user_theo_ip.setdefault(e["src_ip"], set()).add(e["username"])
# Bên trong user_theo_ip: dict, KHÓA = IP, GIÁ TRỊ = set các username IP đó đã thử (set tự bỏ trùng)
print("user_theo_ip có", len(user_theo_ip), "khóa (IP). Ví dụ IP 109.160.32.67 ->", len(user_theo_ip["109.160.32.67"]), "username")
# ✏️ SỬA: IP muốn xem
IP_XEM = "2.57.121.112"
print(IP_XEM, "đã thử", len(user_theo_ip.get(IP_XEM, set())), "username khác nhau, ví dụ:", sorted(user_theo_ip.get(IP_XEM, set()))[:15])

Kết quả: user_theo_ip có 52 khóa (IP). Ví dụ IP 109.160.32.67 -> 102 username · 2.57.121.112 đã thử 22 username khác nhau, ví dụ: ['amal', 'capri', 'catarina', …]

📦 user_theo_ip = từ điển IP → tập (set) các username IP đó đã thử. len(user_theo_ip[ip]) = số username khác nhau. Dùng lại ở 3.5.

👉 Hiểu: IP 2.57.121.112 không thử root mà thử tên người (danh sách tên phổ biến) — một kiểu dò khác.

💼 Dùng khi nào: dựng hồ sơ cho từng IP: đã thử những username nào.
⚖️ Ảnh hưởng tới: phân biệt dò hàng loạt (hàng trăm username) với nhắm một tài khoản (1 username → báo chủ tài khoản đổi mật khẩu ngay).

3.5 Kết hợp: danh sách cảnh báo

🎯 Mục tiêu: gộp luật ngưỡng và danh sách chặn có sẵn thành một danh sách cảnh báo
📥 Đầu vào: sai_theo_ip (3.3), user_theo_ip (3.4)
📤 Đầu ra: canh_bao (danh sách các từ điển cảnh báo)

Các lựa chọn ✏️: NGUONG = 100 → 4 cảnh báo · hạ xuống 50 → thêm IP · nâng lên 500 → chỉ còn IP thật ồn ào + IP trong danh sách chặn. Thêm IP vào DANH_SACH_CHAN → IP đó luôn bị cảnh báo dù thử ít.

In [ ]:
# ✏️ SỬA: danh sách chặn có sẵn (set) và ngưỡng số lần thử sai
DANH_SACH_CHAN = {"2.57.121.112", "203.0.113.200"}
NGUONG = 100
canh_bao = []
for ip_x, n in sai_theo_ip.items():
    if n >= NGUONG or ip_x in DANH_SACH_CHAN:
        canh_bao.append({"ip": ip_x, "so_lan_sai": n, "trong_ds_chan": ip_x in DANH_SACH_CHAN,
                         "so_user": len(user_theo_ip.get(ip_x, set()))})
# Sắp theo so_lan_sai giảm dần (dấu trừ = đảo chiều: số lớn lên trước)
for c in sorted(canh_bao, key=lambda c: -c["so_lan_sai"]):
    print(c)

Kết quả: 4 cảnh báo: 109.160.32.31 (766 lần, 350 username) · 109.160.32.77 (766, 350) · 109.160.32.67 (158, 102) · 2.57.121.112 (110, trong_ds_chan: True)

💼 Dùng khi nào: ghép luật của mình với danh sách chặn từ nguồn tình báo mối đe dọa (threat intelligence) → danh sách việc của ca trực.
⚖️ Ảnh hưởng tới: ngưỡng quá thấp → quá tải cảnh báo; quá cao → bỏ lọt.


Phần 4 — NumPy: tính trên cả mảng số liệu 30 ngày

4.1 Nạp 30 ngày, lấy ra các mảng NumPy

🎯 Mục tiêu: nạp cả 30 ngày log (395 nghìn dòng) vào một bảng, giữ các dòng kết nối, rồi lấy 2 cột (giờ, IP) ra thành mảng NumPy — nguyên liệu cho toàn bộ Phần 4
📥 Đầu vào: 30 file cowrie.json.* trong THU_MUC_LOG
📤 Đầu ra: log (bảng mọi sự kiện, dùng lại ở Phần 5–7) · ket_noi (bảng chỉ các kết nối) · mang_gio_utc, mang_ip (2 mảng NumPy)

3 thư viện dùng từ đây: import numpy as np (tính trên mảng số), import pandas as pd (bảng giống Excel), glob (tìm file theo mẫu tên). as np, as pd = đặt tên gọi tắt, ai cũng viết như vậy.

🧰 Tham số các hàm trong ô:

Hàm Bắt buộc Tuỳ chọn (các lựa chọn) — ở đây chọn gì, vì sao
glob.glob(mau) mau: mẫu tên file, * = "chữ gì cũng được" —
pd.read_json(f, ...) f: đường dẫn file lines: False (mặc định, cả file là 1 khối JSON) / True (mỗi dòng 1 JSON — log Cowrie là dạng này) · dtype: True (mặc định, pandas tự đoán kiểu) / False (giữ nguyên, tránh mật khẩu "0123" bị đổi thành số 123) · convert_dates: True (mặc định, tự đổi cột trông giống ngày) / False (giữ chữ, Phần 5 tự đổi có kiểm soát)
pd.concat(cac_bang) danh sách các bảng axis: 0 nối dọc (mặc định, thêm dòng) / 1 nối ngang (thêm cột)
pd.to_datetime(cot, ...) cột chữ thời gian utc: False (mặc định) / True (hiểu là giờ quốc tế UTC, đúng với log) · errors: "raise" (mặc định, chữ sai định dạng → dừng báo lỗi) / "coerce" (chữ sai → NaT = thời gian trống, dùng ở 5.3)
.to_numpy() — — (lấy một cột ra thành mảng NumPy)

Mảng NumPy là gì? Một dãy ô xếp liền nhau, mọi ô cùng một kiểu (ví dụ toàn số nguyên). Ở đây ta lấy cột "giờ" của 136.063 lần kết nối thành mảng mang_gio_utc: ô số 0 = giờ của kết nối thứ nhất, ô số 1 = giờ của kết nối thứ hai... và cột IP thành mảng mang_ip (ô số 0 = IP của kết nối thứ nhất). Nhờ "cùng kiểu, xếp liền nhau" mà NumPy tính trên cả mảng rất nhanh (xem 4.2).

Ba thuộc tính cần biết của mọi mảng:

Thuộc tính Nghĩa Ở đây
shape hình dạng: mấy chiều, mỗi chiều dài bao nhiêu. (136063,) = 1 chiều, 136.063 ô (dấu phẩy cuối nghĩa là "chỉ có 1 chiều") 1 hàng dài, mỗi ô = 1 kết nối
dtype kiểu của mọi ô. int32 = số nguyên 32 bit, mỗi ô chiếm 4 byte giờ là số nguyên 0..23
size tổng số ô = số kết nối
In [ ]:
# Ô NÀY: đọc 30 file log (30 ngày) -> 1 bảng 'log' -> giữ các dòng kết nối -> lấy cột giờ + cột IP ra thành 2 mảng NumPy
import glob, time
import numpy as np
import pandas as pd
# Đọc cả 30 file log, ghép thành MỘT bảng pandas (concat = nối các bảng của từng ngày theo chiều dọc)
cac_file = sorted(glob.glob(THU_MUC_LOG + "/cowrie.json.*"))
log = pd.concat(pd.read_json(f, lines=True, dtype=False, convert_dates=False) for f in cac_file)
# Chỉ giữ các dòng KẾT NỐI (mỗi dòng = 1 lần kẻ tấn công kết nối tới honeypot)
ket_noi = log[log.eventid == "cowrie.session.connect"]
print("log     = bảng MỌI sự kiện 30 ngày:", log.shape[0], "dòng x", log.shape[1], "cột")
print("ket_noi = bảng chỉ gồm các KẾT NỐI:  ", ket_noi.shape[0], "dòng")

# .to_numpy() = lấy MỘT CỘT của bảng ra thành mảng NumPy
mang_gio_utc = pd.to_datetime(ket_noi.timestamp, utc=True).dt.hour.to_numpy()   # mảng số: giờ UTC (0..23) của từng kết nối
mang_ip = ket_noi.src_ip.to_numpy()                                             # mảng chữ: IP của từng kết nối

# Mỗi ô của mảng ứng với 1 dòng log: đặt 3 ô đầu cạnh dòng log gốc của chúng
print("\n3 ô đầu của 2 mảng, cạnh dòng log gốc:")
for i in range(3):
    print(f"  mang_gio_utc[{i}] = {mang_gio_utc[i]:2d} | mang_ip[{i}] = {mang_ip[i]:15s} <- dòng log: {ket_noi.timestamp.iloc[i]}")

# Ba thuộc tính của mảng
print("\nshape =", mang_gio_utc.shape, "-> 1 chiều, dài", mang_gio_utc.shape[0], "ô (= số kết nối)")
print("dtype =", mang_gio_utc.dtype, "-> mỗi ô là số nguyên", mang_gio_utc.itemsize, "byte")
print(f"Cả mảng chiếm {mang_gio_utc.nbytes / 1024:,.0f} KB | nhỏ nhất {mang_gio_utc.min()}, lớn nhất {mang_gio_utc.max()}")
print("Mảng IP có dtype =", mang_ip.dtype, "(object = chữ)")

Kết quả:

log     = bảng MỌI sự kiện 30 ngày: 395124 dòng x 8 cột
ket_noi = bảng chỉ gồm các KẾT NỐI:   136063 dòng

3 ô đầu của 2 mảng, cạnh dòng log gốc:
  mang_gio_utc[0] =  0 | mang_ip[0] = 217.154.13.82   <- dòng log: 2026-08-24T00:01:03.700755Z
  mang_gio_utc[1] =  0 | mang_ip[1] = 195.178.110.227 <- dòng log: 2026-08-24T00:01:24.563780Z
  mang_gio_utc[2] =  0 | mang_ip[2] = 195.178.110.227 <- dòng log: 2026-08-24T00:03:00.934683Z

shape = (136063,) -> 1 chiều, dài 136063 ô (= số kết nối)
dtype = int32 -> mỗi ô là số nguyên 4 byte
Cả mảng chiếm 531 KB | nhỏ nhất 0, lớn nhất 23
Mảng IP có dtype = object (object = chữ)

📦 log = bảng pandas mọi sự kiện 30 ngày (dùng lại ở Phần 5, 6, 7) · ket_noi = chỉ các dòng kết nối · mang_gio_utc = mảng giờ UTC của từng kết nối · mang_ip = mảng IP của từng kết nối. Ô số k của hai mảng cùng nói về một kết nối: kết nối đầu tiên là của 217.154.13.82, lúc 0h UTC ngày 24/08 (2026-08-24T00:01:03).

👉 Hiểu: cả 136 nghìn giờ chỉ chiếm 531 KB vì mỗi ô đúng 4 byte, xếp liền nhau. Mảng IP là chữ (object) — tính toán số học chỉ làm trên mảng số. 4.2 sẽ đổi cả mảng giờ sang giờ Việt Nam bằng một phép tính.

💼 Dùng khi nào: dữ liệu lên hàng trăm nghìn – hàng triệu dòng (log nhiều ngày, nhiều máy). NumPy là nền của pandas và scikit-learn: mô hình máy học ở Module 3 trở đi nhận đầu vào là mảng NumPy.
⚖️ Ảnh hưởng tới: tốc độ và bộ nhớ: mảng số gọn hơn danh sách Python rất nhiều.

4.2 Tính trên CẢ MẢNG một lần (vectorization) — nhanh hơn vòng for bao nhiêu?

🎯 Mục tiêu: đổi cả mảng giờ UTC sang giờ Việt Nam bằng 1 phép tính, so tốc độ với vòng for
📥 Đầu vào: mang_gio_utc
📤 Đầu ra: mang_gio_vn (mảng giờ Việt Nam, dùng lại ở 4.3)

In [ ]:
# Cách 1: vòng for Python, đổi từng phần tử sang giờ Việt Nam
bat_dau = time.perf_counter()
mang_gio_vn_for = [(g + 7) % 24 for g in mang_gio_utc]
t_for = time.perf_counter() - bat_dau
# Cách 2: NumPy, một phép tính cho cả mảng
bat_dau = time.perf_counter()
mang_gio_vn = (mang_gio_utc + 7) % 24
t_np = time.perf_counter() - bat_dau
print(f"vòng for: {t_for * 1000:.1f} ms | NumPy: {t_np * 1000:.2f} ms | nhanh hơn ~{t_for / t_np:.0f} lần")
print("Kết quả giống nhau:", np.array_equal(mang_gio_vn, mang_gio_vn_for))

Kết quả (số ms mỗi lần một khác): vòng for: ~22 ms | NumPy: ~0.9 ms | nhanh hơn ~25 lần · Kết quả giống nhau: True

👉 Hiểu: cùng một phép tính, NumPy làm trên cả mảng một lần nhanh hơn hàng chục lần. Với hàng triệu dòng log, đó là khác biệt giữa vài giây và vài phút.

💼 Dùng khi nào: xử lý cả tháng log trong vài giây thay vì vài phút; script chạy định kỳ (cron) không kịp xong trước lần chạy sau.
⚖️ Ảnh hưởng tới: thời gian chạy → có kịp cảnh báo trong lúc tấn công đang diễn ra hay không.

4.3 Thống kê: mỗi giờ trong ngày có bao nhiêu kết nối?

🎯 Mục tiêu: đếm số kết nối theo từng giờ trong ngày (0h → 23h), rồi tóm tắt 24 con số đó bằng vài con số thống kê
📥 Đầu vào: mang_gio_vn
📤 Đầu ra: theo_gio (mảng 24 ô: ô h = số kết nối lúc h giờ, cộng cả 30 ngày; dùng lại ở Phần 7)

📖 4 con số thống kê — hiểu không cần công thức:

Con số Hiểu nôm na Trong SOC / an ninh mạng dùng để…
trung bình (mean) lấy tổng chia đều cho mọi giờ ước lượng tải: bao nhiêu kết nối / giờ, dung lượng log / ngày
độ lệch chuẩn (std) các giờ thường lệch khỏi trung bình chừng bao nhiêu đo độ đều đặn: nhỏ = đều như máy, lớn = thất thường. Ngưỡng cảnh báo kiểu "trung bình + 3 × độ lệch chuẩn"
trung vị (median) xếp các giá trị từ nhỏ đến lớn, lấy giá trị đứng giữa: một nửa nhỏ hơn, một nửa lớn hơn mức "bình thường" (đường nền, baseline) không bị vài đợt bùng nổ kéo lệch. Một IP bắn 5.000 kết nối làm trung bình vọt lên, nhưng trung vị gần như không đổi
phân vị 95 (percentile 95, viết tắt p95) xếp từ nhỏ đến lớn: 95% giá trị nhỏ hơn hoặc bằng con số này, chỉ 5% vượt ngưỡng cảnh báo bất thường (vượt p95 / p99 = hiếm); cam kết dịch vụ (95% sự cố xử lý trong X phút); thời gian phản hồi p95

Quy tắc dễ nhớ: dữ liệu đều (như 24 giờ ở đây) → trung bình ≈ trung vị. Dữ liệu lệch, kiểu "đa số nhỏ, vài cái khổng lồ" (như số kết nối của mỗi IP, xem ô TUỲ CHỌN ngay sau) → trung bình đánh lừa, phải dùng trung vị và phân vị.

🧰 np.bincount(mang, minlength=0): mang bắt buộc (số nguyên ≥ 0) · minlength tuỳ chọn: độ dài tối thiểu của kết quả (ở đây 24 để luôn đủ 24 giờ, kể cả giờ không có kết nối nào) · np.percentile(mang, q): cả hai bắt buộc, q từ 0 đến 100 (50 = trung vị, 95, 99…), có thể đưa nhiều số một lúc [10, 90] · .argmax() / .argmin() = vị trí của giá trị lớn nhất / nhỏ nhất (ở đây vị trí = giờ).

In [ ]:
# bincount: đếm mỗi số 0..23 xuất hiện bao nhiêu lần trong mang_gio_vn -> theo_gio = mảng 24 ô, ô h = số kết nối lúc h giờ
theo_gio = np.bincount(mang_gio_vn, minlength=24)
print("Kết nối theo giờ VN (0h..23h):", theo_gio)
print(f"Trung bình {theo_gio.mean():.0f}/giờ | độ lệch chuẩn {theo_gio.std():.0f} | trung vị {np.median(theo_gio):.0f}")
print(f"Đông nhất {theo_gio.argmax()}h ({theo_gio.max()}) | vắng nhất {theo_gio.argmin()}h ({theo_gio.min()}) | phân vị 95: {np.percentile(theo_gio, 95):.0f}")

Kết quả: mảng 24 số (7459 6894 6006 …) · Trung bình 5669/giờ | độ lệch chuẩn 842 | trung vị 5580 · Đông nhất 0h (7459) | vắng nhất 18h (4049) | phân vị 95: 6963

👉 Hiểu:

  • Trung vị 5.580 ≈ trung bình 5.669: 24 giờ khá đều, không có giờ nào bùng nổ đến mức kéo lệch trung bình. Đó là dấu hiệu của máy: người thì có giờ làm, giờ ngủ.
  • Độ lệch chuẩn 842 chỉ bằng ~15% trung bình → phần lớn các giờ nằm trong khoảng 4.800 – 6.500 kết nối.
  • Phân vị 95 = 6.963: chỉ 2 giờ vượt mức này (0h: 7.459 và 23h: 6.975). Nếu dùng p95 làm ngưỡng cảnh báo cho 24 con số này thì chỉ 2 giờ quanh nửa đêm bị báo, và cũng chỉ vượt ngưỡng chút ít — chênh lệch nhẹ, không phải bất thường thật.
  • Giờ đông nhất chỉ gấp 1,8 lần giờ vắng nhất → tấn công suốt ngày đêm, không theo giờ hành chính.

💼 Dùng khi nào: dựng đường nền "bình thường" để đặt ngưỡng cảnh báo; viết báo cáo tháng (trung bình mỗi giờ, giờ cao điểm); ước dung lượng hệ thống.
⚖️ Ảnh hưởng tới: đặt ngưỡng theo trung bình khi dữ liệu lệch → báo động giả liên tục; dùng trung vị / phân vị thì ổn định hơn.

4.3b (TUỲ CHỌN) Trung bình hay trung vị? Thử trên số kết nối của MỖI IP

🎯 Mục tiêu: thấy tận mắt trung bình đánh lừa thế nào khi dữ liệu lệch, và dùng phân vị làm ngưỡng
📥 Đầu vào: ket_noi
📤 Đầu ra: kn_moi_ip (mảng 2.906 ô = số kết nối của từng IP), nguong_pv, vuot — không ô nào sau dùng lại, bỏ qua ô này không sao

Các lựa chọn ✏️ PHAN_VI: 90, 95, 99 (phân vị càng cao → ngưỡng càng cao → càng ít IP bị đánh dấu, nhưng mỗi IP bị đánh dấu càng "nặng").

In [ ]:
# value_counts() = mỗi IP có bao nhiêu kết nối; .to_numpy() lấy ra mảng số (mỗi ô = 1 IP)
kn_moi_ip = ket_noi.src_ip.value_counts().to_numpy()
print(f"Số IP: {kn_moi_ip.size} | trung bình {kn_moi_ip.mean():.1f} kết nối/IP | trung vị {np.median(kn_moi_ip):.0f} kết nối/IP")
print(f"Chỉ {(kn_moi_ip > kn_moi_ip.mean()).mean():.1%} số IP có nhiều kết nối hơn mức 'trung bình'")
for q in [50, 90, 95, 99]:
    print(f"  phân vị {q:2d}: {np.percentile(kn_moi_ip, q):6.0f} kết nối -> {q}% số IP có từ chừng này kết nối trở xuống")
# ✏️ SỬA: phân vị dùng làm ngưỡng "IP bất thường"
PHAN_VI = 99
nguong_pv = np.percentile(kn_moi_ip, PHAN_VI)
vuot = kn_moi_ip[kn_moi_ip > nguong_pv]          # mặt nạ True/False (xem 4.4) lọc các IP vượt ngưỡng
print(f"\nNgưỡng phân vị {PHAN_VI} = {nguong_pv:.0f} kết nối: {vuot.size} IP vượt ngưỡng, tạo ra {vuot.sum() / kn_moi_ip.sum():.0%} tổng số kết nối")

Kết quả:

Số IP: 2906 | trung bình 46.8 kết nối/IP | trung vị 2 kết nối/IP
Chỉ 7.6% số IP có nhiều kết nối hơn mức 'trung bình'
  phân vị 50:      2 kết nối -> 50% số IP có từ chừng này kết nối trở xuống
  phân vị 90:     32 kết nối -> 90% số IP có từ chừng này kết nối trở xuống
  phân vị 95:    234 kết nối -> 95% số IP có từ chừng này kết nối trở xuống
  phân vị 99:   1099 kết nối -> 99% số IP có từ chừng này kết nối trở xuống

Ngưỡng phân vị 99 = 1099 kết nối: 29 IP vượt ngưỡng, tạo ra 38% tổng số kết nối

👉 Hiểu:

  • Nói "trung bình mỗi IP 47 kết nối" là sai về cảm nhận: chỉ 7,6% số IP vượt con số đó. IP điển hình (trung vị) chỉ kết nối 2 lần rồi đi — gõ cửa thử xem có ai không.
  • Dữ liệu kiểu "đa số rất nhỏ, vài cái khổng lồ" là chuyện bình thường trong log tấn công → luôn xem trung vị và phân vị, đừng chỉ xem trung bình.
  • Phân vị 99 rơi đúng vào 1.099 — con số "khẩu phần" của botnet 109.160.32.x mà Phần 7 sẽ gặp lại: nhiều IP của dải này làm đúng 1.099 kết nối mỗi ngày.
  • Đặt ngưỡng ở p99 → chỉ 29 IP (1%) bị đánh dấu nhưng chiếm 38% toàn bộ kết nối: ngưỡng theo phân vị giúp tập trung vào số ít kẻ ồn ào nhất.

💼 Dùng khi nào: chọn ngưỡng cảnh báo bằng phân vị ("IP vượt p99 số kết nối"); cam kết dịch vụ ("95% sự cố xử lý trong 30 phút").
⚖️ Ảnh hưởng tới: báo cáo dùng trung bình cho dữ liệu lệch sẽ vẽ sai bức tranh cho người đọc.

4.4 Mặt nạ True/False (boolean mask): lọc không cần vòng for

🎯 Mục tiêu: tạo hồ sơ số cho mỗi IP (số kết nối, số lần sai, số username), rồi lọc IP đáng ngờ bằng mặt nạ True/False
📥 Đầu vào: mang_ip (4.1), log (4.1)
📤 Đầu ra: 4 mảng song song cac_ip, so_ket_noi, so_sai, so_user (dùng lại ở 4.5) · mat_na

np.unique là gì, dùng làm gì? Nó nhận một mảng có trùng lặp (136.063 ô IP, mỗi IP lặp lại nhiều lần) và trả về danh sách không trùng, xếp theo thứ tự (2.906 IP). Thêm return_counts=True thì trả thêm mỗi giá trị xuất hiện bao nhiêu lần. Giống Remove Duplicates + đếm (Count) trong Excel. Trong an ninh mạng dùng để: đếm quy mô tấn công (bao nhiêu IP khác nhau), tìm top IP / top username / top mật khẩu, so 2 ngày xem IP mới nào xuất hiện, làm bảng "mỗi IP một dòng" như ở đây.

🧰 Tham số các hàm trong ô:

Hàm Bắt buộc Tuỳ chọn (các lựa chọn)
np.unique(mang, ...) mang return_counts: False (mặc định, chỉ trả danh sách không trùng) / True (trả thêm mảng số lần) · return_index: True = trả thêm vị trí xuất hiện đầu tiên của mỗi giá trị (→ biết IP xuất hiện lần đầu khi nào) · return_inverse: True = mỗi ô của mảng gốc thuộc giá trị thứ mấy
cot.value_counts() (pandas) — normalize: False (mặc định, ra số lần) / True (ra tỉ lệ 0–1) · ascending: False (mặc định, nhiều nhất lên đầu) / True
.reindex(danh_sach, fill_value=...) danh_sach: thứ tự mới của các dòng fill_value: giá trị điền cho dòng không có (mặc định NaN = trống; ở đây 0 vì "không có lần sai nào" = 0 lần)
.groupby("cot").username.nunique() tên cột để gom — (nunique = đếm số giá trị khác nhau trong mỗi nhóm)
np.where(dieu_kien) mảng True/False — (trả về vị trí các ô True)

Các lựa chọn ✏️ mat_na (| = HOẶC, & = VÀ, ~ = KHÔNG):

Điều kiện Số IP đáng ngờ Họ tạo ra
(so_ket_noi > 1000) | (so_sai > 500) (có sẵn) 62 58% kết nối
(so_ket_noi > 1000) & (so_sai > 500) (chặt hơn: phải thỏa cả hai) 32 38%
so_user > 100 (thử rất nhiều username) 54 42%
so_ket_noi > 100 177 90%
In [ ]:
# np.unique: danh sách IP khác nhau + mỗi IP xuất hiện bao nhiêu lần
cac_ip, so_ket_noi = np.unique(mang_ip, return_counts=True)
# Số lần thử sai của từng IP, xếp đúng thứ tự cac_ip
sai = log[log.eventid == "cowrie.login.failed"].src_ip.value_counts()
# reindex(cac_ip): xếp lại theo ĐÚNG thứ tự của cac_ip; IP không có lần sai nào -> điền 0
so_sai = sai.reindex(cac_ip, fill_value=0).to_numpy()
# Số username khác nhau mỗi IP đã thử
so_user = log[log.eventid.str.startswith("cowrie.login")].groupby("src_ip").username.nunique().reindex(cac_ip, fill_value=0).to_numpy()
print("Số IP:", cac_ip.size)
# 4 mảng SONG SONG: cùng vị trí k = cùng một IP
print("Vị trí k | cac_ip[k]      | so_ket_noi[k] | so_sai[k] | so_user[k]")
for k in range(3):
    print(f"   {k}     | {cac_ip[k]:14s} | {so_ket_noi[k]:13d} | {so_sai[k]:9d} | {so_user[k]:10d}")

# ✏️ SỬA: điều kiện "đáng ngờ" (| = HOẶC, & = VÀ)
mat_na = (so_ket_noi > 1000) | (so_sai > 500)
# mat_na = mảng True/False dài 2.906 ô, ô k ứng với IP cac_ip[k]. Xem 2 ví dụ:
k_vd = int(np.where(cac_ip == "130.12.182.196")[0][0])     # np.where: tìm vị trí của IP này trong cac_ip
for k in [0, k_vd]:
    print(f"mat_na[{k}] = {mat_na[k]!s:5s} vì {cac_ip[k]} có {so_ket_noi[k]} kết nối, {so_sai[k]} lần thử sai")
print("Số IP đáng ngờ:", mat_na.sum(), "/", mat_na.size)
print("Các IP đó:", cac_ip[mat_na])
print(f"Họ tạo ra {so_ket_noi[mat_na].sum() / so_ket_noi.sum():.0%} tổng số kết nối")

Kết quả:

Số IP: 2906
Vị trí k | cac_ip[k]      | so_ket_noi[k] | so_sai[k] | so_user[k]
   0     | 1.10.195.220   |             2 |         0 |          0
   1     | 1.10.206.216   |             2 |         0 |          0
   2     | 1.162.157.61   |             1 |         1 |          1
mat_na[0] = False vì 1.10.195.220 có 2 kết nối, 0 lần thử sai
mat_na[460] = True  vì 130.12.182.196 có 3757 kết nối, 3310 lần thử sai
Số IP đáng ngờ: 62 / 2906

rồi danh sách 62 IP (rất nhiều 109.160.32.x, 91.92.4x.x) · Họ tạo ra 58% tổng số kết nối

📦 4 mảng song song, mỗi mảng 2.906 ô, ô cùng vị trí k = cùng một IP: cac_ip (IP, xếp theo thứ tự chữ) · so_ket_noi (số kết nối) · so_sai (số lần thử sai) · so_user (số username khác nhau). mat_na = mảng True/False cùng độ dài: True ở vị trí IP thỏa điều kiện.

👉 Hiểu: đặt mat_na vào [ ] của một mảng bất kỳ (cac_ip[mat_na], so_ket_noi[mat_na]) là lấy ra đúng các IP đáng ngờ — không cần vòng for. Chỉ 2% số IP tạo ra hơn nửa lưu lượng.

💼 Dùng khi nào: lọc nhanh theo nhiều điều kiện cùng lúc: danh sách IP đáng ngờ, máy có nhiều lỗi, tài khoản đăng nhập lạ.
⚖️ Ảnh hưởng tới: & (VÀ) hay | (HOẶC) đổi danh sách từ 32 thành 62 IP — một ký tự quyết định bao nhiêu IP bị chặn.

4.5 Chuẩn hóa về 0–1 và tính điểm rủi ro cho cả 2.906 IP

🎯 Mục tiêu: gộp 3 thước đo của mỗi IP thành một điểm rủi ro 0–1, rồi xếp hạng
📥 Đầu vào: so_ket_noi, so_sai, so_user, cac_ip (4.4)
📤 Đầu ra: hàm min_max · W_KET_NOI, W_SAI, W_USER (trọng số) · diem_rui_ro (mảng 2.906 điểm)

Vì sao phải chuẩn hoá? 3 thước đo khác đơn vị (lần kết nối, lần sai, số username) và khác độ lớn: số kết nối của một IP lên tới 5.569, còn số username tối đa 500. Cộng thẳng thì số to "nuốt" số nhỏ — giống cộng cân nặng (kg) với chiều cao (m). Ví dụ IP 109.160.32.31: trọng số username là 0,3 nhưng nếu cộng thẳng, phần username chỉ chiếm 7% điểm; sau chuẩn hoá min-max nó chiếm 40%. Chuẩn hoá = đưa mọi thước đo về cùng một thang (ở đây 0–1) để trọng số mới có nghĩa thật.

Các cách chuẩn hoá hay dùng (ô TUỲ CHỌN 4.5b ngay sau so sánh cả 5 cách trên dữ liệu thật):

Cách Làm gì (nôm na) Ra khoảng Ưu Nhược Khi nào dùng
min-max (dùng ở đây) (x − nhỏ nhất) ÷ (lớn nhất − nhỏ nhất) 0 → 1 dễ hiểu, giữ đúng tỉ lệ 1 IP cực lớn làm mọi IP khác dồn sát 0 muốn điểm 0–1 dễ đọc, dữ liệu không quá lệch
log rồi min-max lấy log trước (nén số lớn: 10 → 1, 100 → 2, 1.000 → 3) rồi min-max 0 → 1 giảm sức nặng của vài IP khổng lồ điểm khó diễn giải hơn dữ liệu "đa số nhỏ, vài cái rất lớn" — rất hay gặp trong log tấn công
z-score (x − trung bình) ÷ độ lệch chuẩn âm / dương, không giới hạn cho biết "cách trung bình bao nhiêu lần độ lệch chuẩn" → ngưỡng kiểu "> 3" bị giá trị đột biến kéo lệch; không phải 0–1 dữ liệu khá đều; đầu vào cho máy học (Module 3 trở đi)
xếp hạng phần trăm IP này đứng trên bao nhiêu % số IP 0 → 1 không bị giá trị đột biến ảnh hưởng mất khoảng cách thật (hạng 1 hơn hạng 2 bao nhiêu thì không biết) chỉ cần biết "ai cao hơn ai"
robust (theo trung vị) (x − trung vị) ÷ khoảng tứ phân vị không giới hạn bền với đột biến ở log này không dùng được: hơn nửa số IP có 0 lần sai → khoảng tứ phân vị = 0, phép chia vô nghĩa dữ liệu lệch nhưng không dồn quá nhiều về 0

🧰 np.argsort(mang): mang bắt buộc — trả về vị trí các ô theo thứ tự từ nhỏ đến lớn (không trả giá trị). [::-1] đảo ngược (lớn → nhỏ), [:5] lấy 5 vị trí đầu.

Các lựa chọn ✏️ trọng số (tổng nên = 1; top 3 đo thật):

W_KET_NOI, W_SAI, W_USER Coi trọng Top 3
0.3, 0.4, 0.3 (có sẵn) cân bằng, nhỉnh về thử sai 109.160.32.31, 130.12.182.196, 154.18.197.29
1, 0, 0 chỉ độ ồn (số kết nối) 154.18.197.29, 130.12.182.196, 109.160.32.31
0, 1, 0 chỉ số lần dò mật khẩu 130.12.182.196, 154.18.197.29, 109.160.32.31
0, 0, 1 chỉ độ rộng từ điển (số username) 109.160.32.23, 109.160.32.31, 109.160.32.35
In [ ]:
def min_max(x):
    """Đưa mọi giá trị về khoảng 0..1 (nhỏ nhất -> 0, lớn nhất -> 1)"""
    x = np.asarray(x, dtype=float)
    khoang = x.max() - x.min()
    return np.zeros_like(x) if khoang == 0 else (x - x.min()) / khoang

# ✏️ SỬA: trọng số (tổng nên = 1). Thử dồn hết cho W_USER = 1.0 xem IP đứng đầu đổi thế nào
W_KET_NOI, W_SAI, W_USER = 0.3, 0.4, 0.3
diem_rui_ro = W_KET_NOI * min_max(so_ket_noi) + W_SAI * min_max(so_sai) + W_USER * min_max(so_user)
print("Điểm nằm trong 0..1:", bool(np.all((diem_rui_ro >= 0) & (diem_rui_ro <= 1))))
# argsort: thứ tự từ nhỏ đến lớn; [::-1] đảo ngược; [:5] lấy 5 đầu
for i in np.argsort(diem_rui_ro)[::-1][:5]:
    print(f"{cac_ip[i]:16s} điểm {diem_rui_ro[i]:.3f} | kết nối {so_ket_noi[i]:5d} | thử sai {so_sai[i]:5d} | {so_user[i]:4d} username")

Kết quả: Điểm nằm trong 0..1: True, top 5:

109.160.32.31    điểm 0.650 | kết nối  2774 | thử sai  1989 |  434 username
130.12.182.196   điểm 0.608 | kết nối  3757 | thử sai  3310 |   10 username
154.18.197.29    điểm 0.580 | kết nối  5569 | thử sai  2295 |    4 username
109.160.32.35    điểm 0.495 | kết nối  1601 | thử sai  1266 |  426 username
85.11.167.127    điểm 0.473 | kết nối  2291 | thử sai  1719 |  236 username

👉 Hiểu: 3 thước đo có đơn vị khác nhau (lần kết nối, lần sai, số username) được chuẩn hóa về 0–1 rồi mới cộng. Đổi trọng số → thứ hạng đổi theo: "IP nguy hiểm nhất" phụ thuộc vào điều bạn coi trọng. Ô TUỲ CHỌN 4.5b ngay dưới cho thấy đổi cách chuẩn hoá cũng làm thứ hạng đổi.

💼 Dùng khi nào: xếp hạng ưu tiên khi có nhiều tiêu chí (IP, máy chủ, lỗ hổng, người dùng) → "top 10 việc phải xử lý hôm nay".
⚖️ Ảnh hưởng tới: trọng số và cách chuẩn hoá chính là chính sách ưu tiên của tổ chức → phải ghi rõ trong báo cáo, không ai được đổi ngầm.

4.5b (TUỲ CHỌN) Đổi cách chuẩn hoá thì top 5 có đổi không?

🎯 Mục tiêu: so sánh 5 cách chuẩn hoá trên cùng 3 thước đo, cùng trọng số
📥 Đầu vào: so_ket_noi, so_sai, so_user, cac_ip, min_max, trọng số (4.4, 4.5)
📤 Đầu ra: chỉ in ra; CACH_CHUAN_HOA, diem_thu không dùng lại ở đâu — bỏ qua ô này không sao

Các lựa chọn ✏️ IP_DONG_GOP: IP bất kỳ trong top 5 ở trên, ví dụ "130.12.182.196" (ít username → phần username nhỏ ở mọi cách).

In [ ]:
# 3 thước đo trải rộng thế nào? (nhỏ nhất / trung vị / lớn nhất)
for ten_thuoc_do, x in [("so_ket_noi", so_ket_noi), ("so_sai", so_sai), ("so_user", so_user)]:
    print(f"  {ten_thuoc_do:10s}: {x.min():5d} / {np.median(x):5.0f} / {x.max():5d}")

# Mỗi thước đo đóng góp bao nhiêu % điểm của 1 IP: cộng thẳng vs min-max
# ✏️ SỬA: IP muốn xem
IP_DONG_GOP = "109.160.32.31"
k_dg = int(np.where(cac_ip == IP_DONG_GOP)[0][0])          # vị trí của IP này trong cac_ip
for ten_cach, ham_chuan_hoa in [("cộng thẳng", lambda x: np.asarray(x, dtype=float)), ("min-max   ", min_max)]:
    phan = [W_KET_NOI * ham_chuan_hoa(so_ket_noi)[k_dg], W_SAI * ham_chuan_hoa(so_sai)[k_dg], W_USER * ham_chuan_hoa(so_user)[k_dg]]
    print(f"{IP_DONG_GOP} | {ten_cach}: kết nối {phan[0] / sum(phan):.0%} · thử sai {phan[1] / sum(phan):.0%} · username {phan[2] / sum(phan):.0%} của điểm")

# 5 cách chuẩn hoá: mỗi cách là 1 hàm nhận mảng số, trả về mảng số cùng độ dài
CACH_CHUAN_HOA = {
    "0. KHÔNG chuẩn hoá (cộng thẳng)": lambda x: np.asarray(x, dtype=float),
    "1. min-max (cách của 4.5)":       min_max,
    "2. log rồi min-max":              lambda x: min_max(np.log1p(x)),     # log1p(x) = log(1 + x): x = 0 không bị lỗi
    "3. z-score":                      lambda x: (x - np.mean(x)) / np.std(x),
    "4. xếp hạng phần trăm":           lambda x: pd.Series(x).rank(pct=True).to_numpy(),
}
for ten_cach, ham_chuan_hoa in CACH_CHUAN_HOA.items():
    diem_thu = W_KET_NOI * ham_chuan_hoa(so_ket_noi) + W_SAI * ham_chuan_hoa(so_sai) + W_USER * ham_chuan_hoa(so_user)
    print(f"\n{ten_cach}: điểm từ {diem_thu.min():.2f} đến {diem_thu.max():.2f}")
    print("   top 5:", ", ".join(cac_ip[np.argsort(diem_thu)[::-1][:5]]))

Kết quả:

  so_ket_noi:     1 /     2 /  5569
  so_sai    :     0 /     0 /  3310
  so_user   :     0 /     0 /   500
109.160.32.31 | cộng thẳng: kết nối 47% · thử sai 45% · username 7% của điểm
109.160.32.31 | min-max   : kết nối 23% · thử sai 37% · username 40% của điểm

0. KHÔNG chuẩn hoá (cộng thẳng): điểm từ 0.30 đến 2589.90
   top 5: 154.18.197.29, 130.12.182.196, 109.160.32.31, 85.11.167.127, 109.160.32.35
1. min-max (cách của 4.5): điểm từ 0.00 đến 0.65
   top 5: 109.160.32.31, 130.12.182.196, 154.18.197.29, 109.160.32.35, 85.11.167.127
2. log rồi min-max: điểm từ 0.00 đến 0.94
   top 5: 109.160.32.31, 85.11.167.127, 109.160.32.35, 109.160.32.116, 109.160.32.156
3. z-score: điểm từ -0.17 đến 14.17
   top 5: 130.12.182.196, 154.18.197.29, 109.160.32.31, 85.11.167.127, 109.160.32.35
4. xếp hạng phần trăm: điểm từ 0.32 đến 1.00
   top 5: 109.160.32.31, 109.160.32.35, 109.160.32.156, 109.160.32.116, 109.160.32.91

👉 Hiểu:

  • Không chuẩn hoá: username gần như không được tính (7%), top 5 chỉ là 5 IP ồn ào nhất — trọng số 0,3 cho username thành vô nghĩa.
  • min-max và z-score vẫn bị vài IP khổng lồ chi phối: top 5 gần giống "cộng thẳng".
  • log rồi min-max và xếp hạng phần trăm làm số lớn bớt áp đảo → top 5 nghiêng hẳn về dải 109.160.32.x (thử rất nhiều username, mỗi máy vừa phải) — đúng botnet phối hợp mà Phần 7 nói tới.
  • Không có cách "đúng duy nhất": chọn cách chuẩn hoá cũng là chọn điều mình coi trọng, như chọn trọng số. Báo cáo nên ghi rõ đã dùng cách nào.

💼 Dùng khi nào: số liệu lệch nặng (vài cái khổng lồ): lấy log trước rồi mới chuẩn hoá; đây cũng là bước chuẩn bị đặc trưng cho máy học ở Module 3 (StandardScaler).
⚖️ Ảnh hưởng tới: cùng dữ liệu, đổi cách chuẩn hoá là đổi danh sách top 5 → đổi người / IP được xử lý trước.

4.6 Mảng 2 chiều: bản đồ nhiệt 30 ngày × 24 giờ

🎯 Mục tiêu: xếp 136.063 kết nối vào bảng 30 ngày × 24 giờ, tô màu, rồi tìm thủ phạm của 5 ô nóng nhất
📥 Đầu vào: ket_noi (4.1)
📤 Đầu ra: bang_nhiet (mảng 30 × 24) · tg_vn, cac_ngay, kn · 1 hình

🧰 plt.imshow(bang, aspect=..., cmap=...): bang bắt buộc · aspect tuỳ chọn: "equal" (mặc định, ô vuông) / "auto" (giãn cho vừa khung) · cmap tuỳ chọn = bảng màu: "inferno" (dùng ở đây, đen → vàng) / "viridis" / "Reds" / "coolwarm"… Thử đổi cmap để xem hình nào dễ đọc hơn. Mảng 1 chiều là một hàng số; mảng 2 chiều là một bảng (hàng × cột). Ta tạo bảng 30 hàng (mỗi hàng 1 ngày) × 24 cột (mỗi cột 1 giờ, giờ Việt Nam). Mỗi ô ghi số kết nối trong đúng giờ đó của đúng ngày đó, rồi tô màu theo con số: 720 con số khó đọc thành một bức tranh nhìn là thấy.

Cách đọc hình (đọc trước khi chạy):

Nhìn vào Ý nghĩa Ví dụ trên hình
1 ô 1 giờ của 1 ngày. Màu theo thanh màu bên phải: đen ≈ dưới 100 kết nối · tím ≈ 400 · đỏ cam ≈ 1.000 · vàng nhạt ≈ 1.600 ô số ① (hàng 21/09, cột 0h) vàng nhạt = 1.672 kết nối trong 1 giờ, gấp ~9 lần một ô trung bình (189)
1 hàng (đọc ngang) 1 ngày, từ 0h đến 23h hàng 20/09 có nhiều ô sáng nhất (12h, 23h, 16h, 10–11h) → ngày bận nhất
1 cột (đọc dọc) 1 khung giờ qua 30 ngày không cột nào sáng đều từ trên xuống → không có "giờ cao điểm" cố định
ô sáng lẻ loi một đợt tấn công dồn dập chỉ 1–2 giờ 5 ô sáng nhất đều như vậy (xem bảng in ra dưới hình)
vùng tối yên tĩnh (vẫn có, chỉ ít) gần một nửa số ô dưới 100 kết nối
In [ ]:
import matplotlib.pyplot as plt
%matplotlib inline
# --- BƯỚC 1: mỗi kết nối nằm ở ô nào? ---
# Thời gian từng kết nối, đổi sang giờ Việt Nam
tg_vn = pd.to_datetime(ket_noi.timestamp, utc=True).dt.tz_convert("Asia/Ho_Chi_Minh")
cac_ngay = np.sort(tg_vn.dt.date.unique())                  # danh sách 30 ngày, xếp tăng dần
# Hàng = vị trí của ngày đó trong danh sách (searchsorted tìm vị trí cho cả 136 nghìn kết nối một lúc)
hang = np.searchsorted(cac_ngay, tg_vn.dt.date.to_numpy())
cot = tg_vn.dt.hour.to_numpy()                             # cột = giờ 0..23

# --- BƯỚC 2: đếm vào bảng 2 chiều ---
# Bảng 30 x 24 toàn số 0; np.add.at cộng 1 vào ô (hang, cot) cho MỖI kết nối
bang_nhiet = np.zeros((len(cac_ngay), 24), dtype=int)
np.add.at(bang_nhiet, (hang, cot), 1)
print("Bảng có shape", bang_nhiet.shape, "(ngày, giờ) | tổng", bang_nhiet.sum(), "kết nối | trung bình 1 ô:", round(bang_nhiet.mean()))

# --- BƯỚC 3: tô màu ---
THU = ["T2", "T3", "T4", "T5", "T6", "T7", "CN"]           # tên thứ trong tuần
plt.figure(figsize=(14, 8))
plt.imshow(bang_nhiet, aspect="auto", cmap="inferno")      # imshow: tô mỗi ô của bảng một màu theo giá trị
plt.colorbar(label="số kết nối trong 1 giờ")
plt.yticks(range(len(cac_ngay)), [f"{d:%d/%m} {THU[d.weekday()]}" for d in cac_ngay], fontsize=8)
plt.xticks(range(24), [f"{h}h" for h in range(24)])
plt.xlabel("Giờ Việt Nam"); plt.ylabel("Ngày")
plt.title("Bản đồ nhiệt tấn công SSH: mỗi ô = số kết nối trong 1 giờ của 1 ngày")
# Đánh số 5 ô nóng nhất lên hình: argsort(axis=None) xếp mọi ô của bảng phẳng, [::-1][:5] lấy 5 ô lớn nhất
top5 = np.argsort(bang_nhiet, axis=None)[::-1][:5]
for thu_tu, vi_tri in enumerate(top5, 1):
    h, c = np.unravel_index(vi_tri, bang_nhiet.shape)      # đổi vị trí trên bảng phẳng -> (hàng, cột)
    plt.text(c, h, str(thu_tu), color="cyan", ha="center", va="center", fontsize=13, fontweight="bold")
plt.show()

# --- BƯỚC 4: ai gây ra 5 ô nóng nhất? ---
# kn = bảng nhỏ 3 cột (IP, ngày, giờ VN) của mọi kết nối, để tra xem ai nằm trong mỗi ô
kn = pd.DataFrame({"ip": ket_noi.src_ip.to_numpy(), "ngay": tg_vn.dt.date.to_numpy(), "gio": cot})
kn["dai"] = kn.ip.str.rsplit(".", n=1).str[0] + ".0/24"   # dải mạng = 3 số đầu của IP
for thu_tu, vi_tri in enumerate(top5, 1):              # enumerate(..., 1): đánh số thứ tự từ 1
    h, c = np.unravel_index(vi_tri, bang_nhiet.shape)   # vị trí trên bảng phẳng -> (hàng = ngày, cột = giờ)
    o = kn[(kn.ngay == cac_ngay[h]) & (kn.gio == c)]
    dai_dau = o.dai.value_counts()      # đếm kết nối theo dải mạng trong ô này, nhiều nhất lên đầu
    ip_dau = o.ip.value_counts()        # đếm kết nối theo IP trong ô này
    print(f"ô {thu_tu}: {cac_ngay[h]:%d/%m} lúc {c:2d}h | {len(o):4d} kết nối từ {o.ip.nunique():2d} IP | "
          f"dải {dai_dau.index[0]} chiếm {dai_dau.iloc[0] / len(o):.0%} | 2 IP mạnh nhất: {ip_dau.index[0]} ({ip_dau.iloc[0]}), {ip_dau.index[1]} ({ip_dau.iloc[1]})")
# Cộng theo hàng (axis=1) = tổng mỗi ngày; cộng theo cột (axis=0) = tổng mỗi giờ
print("\nNgày đông nhất:", f"{cac_ngay[bang_nhiet.sum(axis=1).argmax()]:%d/%m}",
      "| giờ đông nhất:", bang_nhiet.sum(axis=0).argmax(), "h | số ô dưới 100 kết nối:", (bang_nhiet < 100).sum(), "/", bang_nhiet.size)

Kết quả: Bảng có shape (30, 24) (ngày, giờ) | tổng 136063 kết nối | trung bình 1 ô: 189, bản đồ nhiệt có đánh số ① → ⑤ ở 5 ô sáng nhất, rồi:

ô 1: 21/09 lúc  0h | 1672 kết nối từ 18 IP | dải 109.160.32.0/24 chiếm 95% | 2 IP mạnh nhất: 109.160.32.89 (722), 109.160.32.40 (709)
ô 2: 20/09 lúc 12h | 1498 kết nối từ 21 IP | dải 109.160.32.0/24 chiếm 94% | ...
ô 3: 13/09 lúc  8h | 1467 kết nối từ 34 IP | dải 109.160.32.0/24 chiếm 91% | ...
ô 4: 27/08 lúc 23h | 1433 kết nối từ 17 IP | dải 91.92.47.0/24 chiếm 42% | 2 IP mạnh nhất: 91.92.47.35 (600), 77.239.124.249 (499)
ô 5: 20/09 lúc 23h | 1425 kết nối từ 22 IP | dải 109.160.32.0/24 chiếm 85% | ...
Ngày đông nhất: 20/09 | giờ đông nhất: 0 h | số ô dưới 100 kết nối: 323 / 720

👉 Hiểu:

  • 4 trong 5 ô sáng nhất là cùng một "thủ phạm": dải 109.160.32.x. Mỗi lần nó ra tay, 2 máy trong dải mỗi máy bắn ~700 kết nối trong 1 giờ (~5 giây/lần) → một ô vàng rực. Cùng dải này ở 3.3 (đúng 766 lần sai) và hồ sơ 109.160.32.31 ở 6.5.
  • Ô ④ (27/08 23h) là thủ phạm khác (91.92.47.x + 77.239.124.x): không phải mọi đợt bùng nổ đều do một nhóm.
  • 323/720 ô (gần một nửa) tối, nhưng chỉ 14 ô thật sự bằng 0 → gần như giờ nào cũng có ai đó gõ cửa.
  • Đọc theo cột: không cột nào sáng đều → kẻ tấn công không có giờ làm việc cố định, các đợt bùng nổ rơi vào bất kỳ giờ nào.

💼 Dùng khi nào: báo cáo tuần / tháng cho lãnh đạo: 1 hình thay cho 720 con số; tìm khung giờ nóng để xếp lịch trực và chọn giờ bảo trì; so trước / sau khi áp biện pháp (ví dụ sau khi chặn dải 109.160.32.0/24). Cùng kiểu hình dùng được cho: đăng nhập theo người dùng × giờ (ai đăng nhập lúc 3 giờ sáng?), lưu lượng theo cổng × giờ, lỗi theo máy chủ × ngày.
⚖️ Ảnh hưởng tới: nhìn ra đợt tấn công dồn dập (ô sáng lẻ) hay nền đều → quyết định chặn cả dải /24 hay theo dõi từng IP; quyết định có cần trực 24/7 không.


Phần 5 — Pandas: kiểm tra và làm sạch log thật

5.1 Kiểm tra tổng quát: bảng có bao nhiêu dòng, cột nào trống, kiểu dữ liệu gì?

🎯 Mục tiêu: "khám sức khoẻ" bảng log trước khi dùng: kích thước, dòng trùng, ô trống, kiểu từng cột
📥 Đầu vào: log (4.1)
📤 Đầu ra: chỉ in ra, không tạo biến

In [ ]:
# shape = (số dòng, số cột); duplicated() = đánh dấu dòng giống hệt một dòng phía trên nó
print("Kích thước:", log.shape, "(dòng, cột)")
print("Dòng trùng nhau:", log.duplicated().sum())
# isna() = True ở ô trống (NaN); .sum() đếm số True trong mỗi cột
print("\nÔ trống mỗi cột:\n" + log.isna().sum().to_string())
# dtypes = kiểu dữ liệu pandas đoán cho mỗi cột: object = chữ, float64 = số thực, int64 = số nguyên
print("\nKiểu dữ liệu mỗi cột:\n" + log.dtypes.to_string())

Kết quả: Kích thước: (395124, 8) · Dòng trùng nhau: 0 · ô trống: src_port 259061, version 265173, username 266014, password 266258 · kiểu: src_port float64, còn lại object

👉 Hiểu: 4 loại sự kiện ép chung một bảng 8 cột → mỗi dòng chỉ điền các cột của loại mình, còn lại trống. Ví dụ username trống 266.014 dòng = đúng số dòng không phải đăng nhập (395.124 − 129.110). Trống kiểu này là bình thường, không phải bẩn. src_port là số nguyên nhưng thành float64 (số thực) chỉ vì có ô trống — pandas không cho số nguyên có ô trống.

💼 Dùng khi nào: bước đầu tiên với mọi bộ dữ liệu mới (log từ hệ thống khác, file CSV đối tác gửi, dữ liệu xuất từ SIEM).
⚖️ Ảnh hưởng tới: phát hiện cột trống, sai kiểu, dòng trùng trước khi kết luận — sau khi báo cáo đã gửi đi mới phát hiện thì muộn.

5.2 Thế nào là dữ liệu "bẩn"? Tìm từng loại trong log thật

🎯 Mục tiêu: đếm từng loại "bẩn" trong các lần thử đăng nhập và gom 1 dòng thật cho mỗi loại
📥 Đầu vào: log
📤 Đầu ra: dn (bảng các lần thử đăng nhập, chưa làm sạch) · KY_TU_AN (mẫu ký tự ẩn) · mau (bảng mẫu, mỗi loại bẩn 1 dòng)

"Bẩn" = giá trị có mặt nhưng không dùng thẳng được: thiếu, rỗng, viết lộn xộn, sai loại, sai kiểu. Không làm sạch thì phép đếm, so sánh, vẽ biểu đồ ở các bước sau sẽ sai mà không báo lỗi.

# Loại bẩn Ví dụ thật trong log Vì sao hại Cách xử lý (pandas)
1 Thiếu (NaN) kẻ tấn công không gửi mật khẩu value_counts() bỏ qua NaN → đếm thiếu; ghép chuỗi username + " / " + password ra NaN fillna("(không gửi)") — ghi rõ, không xóa dòng
2 Rỗng ("") gửi mật khẩu / username là chuỗi rỗng in ra thấy khoảng trắng, tưởng lỗi hiển thị replace("", "(rỗng)")
3 Hoa / thường lộn xộn Root và root đếm thành 2 username khác nhau str.lower()
4 Khoảng trắng thừa " admin" (log này: 0 username, nhưng 3 mật khẩu như "888888 ") " admin" != "admin" str.strip() cho username; KHÔNG strip mật khẩu (dấu cách là một phần mật khẩu)
5 Sai loại "username" là GET / HTTP/1.1 (lệnh web gửi nhầm vào cổng SSH) lẫn vào top username, làm nhiễu thống kê str.startswith("get /") → cột đánh dấu rac_http (giữ lại làm bằng chứng)
6 Sai kiểu timestamp là chữ; src_port thành số thực vì có ô trống chữ thì không tính được giờ, không đổi múi giờ được pd.to_datetime(..., utc=True)
7 Trùng lặp log này: 0 dòng (vẫn phải kiểm) đếm 2 lần drop_duplicates()
8 Ký tự ẩn / nhị phân ký tự \ufeff (BOM) dính trước mật khẩu; \x00 trong "mật khẩu" là gói tin TLS / chữ UTF-16 gửi nhầm vào cổng SSH nhìn bằng mắt không thấy; file CSV làm mất ký tự \x00 str.contains(r"[\x00-\x08\x0e-\x1f\ufeff]") → cột đánh dấu ky_tu_an; lưu bằng JSON nếu cần giữ nguyên
In [ ]:
# Chỉ lấy các lần THỬ ĐĂNG NHẬP (2 loại sự kiện login), 5 cột cần dùng; .copy() = bản sao riêng để sửa thoải mái
dn = log[log.eventid.str.startswith("cowrie.login")][["timestamp", "src_ip", "eventid", "username", "password"]].copy()
# Bên trong dn: bảng các lần THỬ ĐĂNG NHẬP, CHƯA làm sạch, 5 cột
print("dn: bảng", len(dn), "dòng x", dn.shape[1], "cột", list(dn.columns))
print(dn.head(3).to_string(), "\n")
print("Số lần thử đăng nhập:", len(dn))
# Đếm từng loại bẩn
print("1. password THIẾU (NaN)               :", dn.password.isna().sum())
print("2. password RỖNG \"\"                  :", (dn.password == "").sum(), "| username RỖNG:", (dn.username == "").sum())
print("3. username có chữ HOA                :", (dn.username != dn.username.str.lower()).sum(),
      f"-> {len(set(dn.username))} username khác nhau, nhưng chỉ {len(set(dn.username.str.lower()))} nếu không phân biệt hoa/thường")
print("4. username có khoảng trắng thừa      :", (dn.username != dn.username.str.strip()).sum(),
      "| password có khoảng trắng đầu/cuối:", (dn.password.dropna() != dn.password.dropna().str.strip()).sum())
print("5. 'username' thật ra là lệnh web     :", dn.username.str.upper().str.startswith("GET /").sum())
print("6. kiểu của cột timestamp             :", dn.timestamp.dtype, "(chữ) | ví dụ:", repr(dn.timestamp.iloc[0]))
print("7. dòng trùng                         :", dn.duplicated().sum())
# Mẫu regex [...] = "một ký tự bất kỳ trong khoảng này": ký tự điều khiển \x00-\x08, \x0e-\x1f và BOM \ufeff
KY_TU_AN = r"[\x00-\x08\x0e-\x1f\ufeff]"
print("8. có ký tự ẩn / nhị phân             : password", dn.password.fillna("").str.contains(KY_TU_AN).sum(),
      "| username", dn.username.str.contains(KY_TU_AN).sum())

# Gom 1 dòng THẬT cho mỗi loại bẩn để lát nữa xem trước / sau
mau = pd.concat([
    dn[dn.password.isna()].head(1).assign(loai_ban="1. password thiếu"),
    dn[dn.password == ""].head(1).assign(loai_ban="2. password rỗng"),
    dn[dn.username == ""].head(1).assign(loai_ban="2. username rỗng"),
    dn[dn.username == "Root"].head(1).assign(loai_ban="3. chữ hoa"),
    dn[dn.password.fillna("").str.endswith(" ")].head(1).assign(loai_ban="4. mật khẩu có dấu cách"),
    # (chọn dòng lệnh web KHÔNG chứa địa chỉ IP trong mật khẩu)
    dn[dn.username.str.upper().str.startswith("GET /") & ~dn.password.fillna("").str.contains(r"\d+\.\d+\.\d+\.\d+")].head(1).assign(loai_ban="5. lệnh web"),
    dn[dn.password.fillna("").str.contains("\x00")].head(1).assign(loai_ban="8. ký tự nhị phân"),
]).set_index("loai_ban")
# repr() hiện rõ sự khác nhau: nan (thiếu) / '' (rỗng) / 'Root' / '888888 ' (có dấu cách) / '\x00' (ký tự nhị phân)
mau[["timestamp", "username", "password"]].apply(lambda cot: cot.map(repr))

Kết quả: đầu tiên là dn: bảng 129110 dòng x 5 cột ['timestamp', 'src_ip', 'eventid', 'username', 'password'] và 3 dòng đầu của bảng, rồi:

1. password THIẾU (NaN)               : 244
2. password RỖNG ""                  : 138 | username RỖNG: 20
3. username có chữ HOA                : 260 -> 2719 username khác nhau, nhưng chỉ 2713 nếu không phân biệt hoa/thường
4. username có khoảng trắng thừa      : 0 | password có khoảng trắng đầu/cuối: 3
5. 'username' thật ra là lệnh web     : 13
6. kiểu của cột timestamp             : object (chữ) | ví dụ: '2026-08-24T00:01:04.701664Z'
7. dòng trùng                         : 0
8. có ký tự ẩn / nhị phân             : password 60 | username 2

rồi bảng 7 dòng thật, mỗi dòng một loại bẩn. repr() làm hiện ra thứ mắt thường không thấy: nan (thiếu) khác '' (rỗng); '\ufeffLinh123@' có ký tự ẩn BOM dính trước; '0.0.0. ' có dấu cách cuối; 'GET / HTTP/1.0' là lệnh web; '\x01\x00' là byte nhị phân.

📦 dn = bảng các lần thử đăng nhập chưa làm sạch (5 cột) · mau = 7 dòng lấy từ dn, mỗi loại bẩn một dòng, dùng để xem TRƯỚC / SAU ở 5.3.

👉 Hiểu: chỉ trong một cột "mật khẩu" đã có 6 kiểu bẩn khác nhau. Mỗi kiểu cần một cách xử lý riêng — bảng ở trên cho biết hàm pandas dùng cho từng kiểu.

💼 Dùng khi nào: log gộp từ nhiều nguồn / nhiều thiết bị luôn "bẩn"; kẻ tấn công còn cố tình gửi dữ liệu rác.
⚖️ Ảnh hưởng tới: dữ liệu bẩn làm phép đếm sai mà không báo lỗi → báo cáo sai mà không ai biết.

5.3 Làm sạch từng bước — xem TRƯỚC / SAU

🎯 Mục tiêu: viết hàm lam_sach(), thử trên các dòng mẫu (ô thứ nhất), rồi áp dụng cho toàn bộ (ô thứ hai)
📥 Đầu vào: mau, dn (5.2)
📤 Đầu ra: hàm lam_sach · mau_sach, truoc_sau (ô 1) · sach (bảng đăng nhập đã làm sạch — Phần 5.4 → 7 đều dùng) · so_sanh (ô 2)

🧰 Tham số chính: pd.to_datetime(..., errors=): "raise" (mặc định, gặp chữ sai thì dừng) / "coerce" (chữ sai → NaT) · dropna(subset=[...]): subset tuỳ chọn — không ghi thì bỏ dòng thiếu bất kỳ cột nào (quá tay: mất mọi dòng không có mật khẩu); ghi thì chỉ xét các cột đó · fillna(gia_tri): gia_tri bắt buộc · str.contains(mau): mau bắt buộc, mặc định hiểu là regex (mẫu tìm kiếm).

Viết các bước vào một hàm lam_sach() để dùng lại (cho 6 dòng mẫu, rồi cho cả 129 nghìn dòng):

Bước Hàm pandas Làm gì
a pd.to_datetime(cot, utc=True, errors="coerce") chữ → thời gian thật; chuỗi sai định dạng → NaT (thời gian trống) thay vì báo lỗi
b dropna(subset=[...]) bỏ dòng thiếu thứ bắt buộc (giờ, IP) — chỉ những cột này
c fillna(...) rồi replace("", ...) thiếu / rỗng → chữ ghi rõ
d str.strip().str.lower() username: bỏ khoảng trắng 2 đầu, viết thường
e str.startswith("get /") đánh dấu lệnh web (cột True/False)
f map({...}) đổi tên sự kiện dài thành THẤT BẠI / THÀNH CÔNG
g dt.tz_convert(...).dt.hour thêm cột giờ Việt Nam
h str.contains(KY_TU_AN) đánh dấu mật khẩu có ký tự ẩn / nhị phân (giữ nguyên giá trị)
In [ ]:
def lam_sach(bang):
    """Nhận bảng các lần thử đăng nhập (dạng như 'dn'), trả về bảng đã làm sạch. Bảng gốc giữ nguyên."""
    s = bang.copy()
    # a. Chữ -> thời gian (UTC). errors="coerce": gặp chuỗi không phải thời gian thì cho thành NaT, không dừng chương trình
    s["timestamp"] = pd.to_datetime(s.timestamp, utc=True, errors="coerce")
    # b. Bỏ dòng thiếu trường BẮT BUỘC (không có giờ hoặc không có IP thì không phân tích được)
    s = s.dropna(subset=["timestamp", "src_ip"])
    # c. Mật khẩu: thiếu -> "(không gửi)", rỗng -> "(rỗng)". KHÔNG strip: "888888 " khác "888888"
    s["password"] = s.password.fillna("(không gửi)").replace("", "(rỗng)")
    # d. Username: bỏ khoảng trắng 2 đầu, viết thường; rỗng -> "(rỗng)"
    s["username"] = s.username.fillna("(không gửi)").str.strip().str.lower().replace("", "(rỗng)")
    # e. Đánh dấu "username" thật ra là lệnh web (giữ dòng lại làm bằng chứng)
    s["rac_http"] = s.username.str.startswith("get /")
    # f. Tên sự kiện dài -> kết quả ngắn gọn
    s["ket_qua"] = s.eventid.map({"cowrie.login.failed": "THẤT BẠI", "cowrie.login.success": "THÀNH CÔNG"})
    # g. Giờ Việt Nam (0..23) để phân tích "ngoài giờ"
    s["gio_vn"] = s.timestamp.dt.tz_convert("Asia/Ho_Chi_Minh").dt.hour
    # h. Đánh dấu mật khẩu chứa ký tự ẩn / nhị phân (không sửa: đó là dấu vết thật)
    s["ky_tu_an"] = s.password.str.contains(KY_TU_AN)
    return s.drop(columns="eventid")

# Áp dụng cho 6 dòng mẫu và đặt TRƯỚC / SAU cạnh nhau
mau_sach = lam_sach(mau.reset_index()).set_index("loai_ban")
# .apply(lambda c: c.map(repr)): với mỗi cột, đổi từng ô thành dạng repr() để thấy rõ '' / nan / ký tự ẩn
# pd.concat({...}, axis=1): đặt 2 bảng TRƯỚC và SAU cạnh nhau theo chiều ngang
truoc_sau = pd.concat({"TRƯỚC": mau[["username", "password"]].apply(lambda c: c.map(repr)),
                       "SAU": mau_sach[["username", "password", "rac_http", "ky_tu_an", "ket_qua", "gio_vn"]]}, axis=1)
truoc_sau

Kết quả: bảng 2 khối TRƯỚC | SAU đặt cạnh nhau cho 7 dòng mẫu:

loại bẩn TRƯỚC (username / password) SAU
1. password thiếu 'root' / nan root / (không gửi)
2. password rỗng 'probe' / '' probe / (rỗng)
2. username rỗng '' / '' (rỗng) / (rỗng)
3. chữ hoa 'Root' / '\ufeffLinh123@' root / giữ nguyên, ky_tu_an = True
4. mật khẩu có dấu cách 'root' / '0.0.0. ' root / 0.0.0. (giữ dấu cách)
5. lệnh web 'GET / HTTP/1.0' / '' get / http/1.0 / (rỗng), rac_http = True
8. ký tự nhị phân byte lạ / '\x01\x00' giữ nguyên, ky_tu_an = True

👉 Hiểu: làm sạch không xóa dòng nào: cái thiếu được ghi rõ, cái lộn xộn được chuẩn hóa, cái lạ được đánh dấu (cột rac_http, ky_tu_an) để vẫn đếm / lọc được — vì chính cái lạ cũng là dấu vết tấn công.

💼 Dùng khi nào: viết một hàm làm sạch rồi chạy tự động cho log mỗi ngày, trước mọi thống kê.
⚖️ Ảnh hưởng tới: giữ đủ mọi dòng (đánh dấu chứ không xoá) = giữ bằng chứng cho điều tra.

In [ ]:
# Áp dụng cho TOÀN BỘ các lần thử đăng nhập, rồi đếm lại từng vấn đề: trước -> sau
sach = lam_sach(dn).sort_values("timestamp").reset_index(drop=True)
so_sanh = pd.DataFrame({
    "TRƯỚC": [dn.password.isna().sum(), (dn.password == "").sum(), (dn.username == "").sum(),
              (dn.username != dn.username.str.lower()).sum(), len(set(dn.username)), str(dn.timestamp.dtype), int(dn.isna().sum().sum())],
    "SAU":   [sach.password.isna().sum(), (sach.password == "").sum(), (sach.username == "").sum(),
              (sach.username != sach.username.str.lower()).sum(), len(set(sach.username)), str(sach.timestamp.dtype), int(sach.isna().sum().sum())],
}, index=["password thiếu (NaN)", "password rỗng", "username rỗng", "username có chữ hoa",
          "số username khác nhau", "kiểu cột timestamp", "tổng ô trống"])
print("Số dòng: trước", len(dn), "-> sau", len(sach), "(không mất dòng nào)")
# Bên trong sach: bảng đăng nhập ĐÃ làm sạch — Phần 5.5, 6 và 7 đều dùng bảng này
print("sach có các cột:", list(sach.columns))
print(sach.head(3).to_string(), "\n")
so_sanh

Kết quả: Số dòng: trước 129110 -> sau 129110 (không mất dòng nào) · sach có các cột: ['timestamp', 'src_ip', 'username', 'password', 'rac_http', 'ket_qua', 'gio_vn', 'ky_tu_an'] + 3 dòng đầu, rồi:

vấn đề TRƯỚC SAU
password thiếu (NaN) 244 0
password rỗng 138 0
username rỗng 20 0
username có chữ hoa 260 0
số username khác nhau 2719 2713
kiểu cột timestamp object (chữ) datetime64[ns, UTC] (thời gian)
tổng ô trống 244 0

📦 sach = bảng đăng nhập đã làm sạch, 8 cột: giờ (kiểu thời gian), IP, username (đã viết thường), password (đã ghi rõ thiếu/rỗng), rac_http (True = lệnh web), ket_qua (THÀNH CÔNG / THẤT BẠI), gio_vn (giờ Việt Nam 0–23), ky_tu_an (True = có ký tự ẩn). Phần 5.4 → 7 đều dùng bảng này.

👉 Hiểu: "số username khác nhau" giảm 6 vì Root/root, Admin/admin... được gộp — đếm sai 6 tài khoản chỉ vì chữ hoa.

💼 Dùng khi nào: so sánh trước / sau mỗi lần sửa quy trình làm sạch — kiểm tra quy trình không làm mất dữ liệu.
⚖️ Ảnh hưởng tới: một bảng "trước / sau" là bằng chứng cho người khác tin kết quả của bạn.

5.4 Cái bẫy khi LƯU rồi ĐỌC LẠI file CSV

🎯 Mục tiêu: thấy file CSV làm hỏng mật khẩu thế nào, và cách đọc / lưu đúng
📥 Đầu vào: sach (5.3)
📤 Đầu ra: các file trong ~/lab/out_python/ · THU_MUC_RA (thư mục ra)

🧰 pd.read_csv(file, ...): file bắt buộc · dtype tuỳ chọn: không ghi = pandas tự đoán (bẫy!) / str = mọi cột là chữ · keep_default_na: True (mặc định: các chữ "null", "NA", "" bị coi là ô trống) / False (giữ nguyên chữ). to_json(file, orient=...): orient="records" = mỗi dòng một từ điển (dễ đọc nhất). Làm sạch xong thường lưu ra CSV để dùng lại. Nhưng pd.read_csv tự đoán kiểu dữ liệu, và có thể phá hỏng mật khẩu:

In [ ]:
from pathlib import Path
THU_MUC_RA = Path.home() / "lab" / "out_python"
THU_MUC_RA.mkdir(parents=True, exist_ok=True)
sach.to_csv(THU_MUC_RA / "dang_nhap_sach.csv", index=False)

# Đọc lại kiểu MẶC ĐỊNH
doc_sai = pd.read_csv(THU_MUC_RA / "dang_nhap_sach.csv")
hong = doc_sai.password.astype(str) != sach.password.to_numpy()
print("Đọc mặc định   : cột password kiểu", doc_sai.password.dtype, "|", hong.sum(), "mật khẩu bị đổi, ví dụ:",
      list(zip(sach.password[hong.to_numpy()].head(3), doc_sai.password[hong].head(3))))
# Bẫy thứ 2: cột CHỈ có chữ số -> pandas đổi thành SỐ, mất số 0 ở đầu
chi_so = sach[sach.password.str.fullmatch(r"\d+")][["password"]]
chi_so.to_csv(THU_MUC_RA / "mk_chi_so.csv", index=False)
doc_so = pd.read_csv(THU_MUC_RA / "mk_chi_so.csv")
print("Cột toàn chữ số: đọc lại thành kiểu", doc_so.password.dtype, "| mật khẩu bắt đầu bằng 0:", chi_so.password.str.startswith("0").sum(),
      "| ví dụ", list(zip(chi_so.password[chi_so.password.str.startswith("0")].head(3), doc_so.password[chi_so.password.str.startswith("0").to_numpy()].head(3))))

# Cách ĐÚNG: dtype=str (mọi thứ là chữ) + keep_default_na=False (không coi "null", "NA", "" là ô trống)
doc_dung = pd.read_csv(THU_MUC_RA / "dang_nhap_sach.csv", dtype=str, keep_default_na=False)
con_hong = doc_dung.password != sach.password.to_numpy()
print("Đọc đúng cách  :", con_hong.sum(), "mật khẩu vẫn bị đổi -> đều có ký tự nhị phân \x00:", bool(sach.ky_tu_an[con_hong.to_numpy()].all()))
# Muốn giữ NGUYÊN 100% (kể cả ký tự nhị phân) -> lưu JSON thay vì CSV
sach[["password"]].to_json(THU_MUC_RA / "mat_khau.json", orient="records", force_ascii=False)
doc_json = pd.read_json(THU_MUC_RA / "mat_khau.json", orient="records", dtype=False)
print("Lưu / đọc JSON :", (doc_json.password != sach.password.to_numpy()).sum(), "mật khẩu bị đổi")

Kết quả:

Đọc mặc định   : cột password kiểu object | 88 mật khẩu bị đổi, ví dụ: [('null', nan), ('null', nan), ('null', nan)]
Cột toàn chữ số: đọc lại thành kiểu uint64 | mật khẩu bắt đầu bằng 0: 1504 | ví dụ [('000000', 0), ('0987654321', 987654321), ('00008888', 8888)]
Đọc đúng cách  : 13 mật khẩu vẫn bị đổi -> đều có ký tự nhị phân \x00: True
Lưu / đọc JSON : 0 mật khẩu bị đổi

👉 Hiểu:

  • Kẻ tấn công thật sự thử mật khẩu là chữ null — pandas mặc định coi null, NA, nan... là ô trống → 88 mật khẩu thật biến mất không báo lỗi.
  • Cột chỉ có chữ số bị đổi thành số: 000000 thành 0 — sai mật khẩu.
  • dtype=str, keep_default_na=False sửa được cả hai. Còn 13 mật khẩu chứa byte \x00 thì định dạng CSV không chứa nổi → muốn giữ nguyên 100% thì lưu JSON.

💼 Dùng khi nào: xuất / nhập dữ liệu giữa các công cụ (Excel, SIEM, gửi đối tác, nộp cơ quan chức năng): mật khẩu, mã nhân viên, số điện thoại, mã bưu chính bắt đầu bằng 0.
⚖️ Ảnh hưởng tới: dữ liệu bị âm thầm đổi → sai kết luận điều tra, mất giá trị làm bằng chứng.

5.5 Lọc, gom nhóm, xuất file

🎯 Mục tiêu: đếm thành công / thất bại, xếp hạng IP thử sai trên 30 ngày, xuất file kết quả
📥 Đầu vào: sach (5.3)
📤 Đầu ra: that_bai_ip (IP → số lần sai, xếp giảm dần; dùng lại ở Phần 6) · các file CSV / JSON

In [ ]:
from pathlib import Path
THU_MUC_RA = Path.home() / "lab" / "out_python"
THU_MUC_RA.mkdir(parents=True, exist_ok=True)

print(sach.ket_qua.value_counts().to_string())
# that_bai_ip: lọc các lần THẤT BẠI -> đếm theo IP (value_counts) -> đặt tên cột là so_lan_that_bai
# (khác dict sai_theo_ip ở 3.3 chỉ có 1 ngày: đây là Series pandas của cả 30 ngày, KHÓA = IP, GIÁ TRỊ = số lần sai)
that_bai_ip = sach[sach.ket_qua == "THẤT BẠI"].src_ip.value_counts().rename("so_lan_that_bai")
print("\nTop 5 IP thử sai:\n" + that_bai_ip.head(5).to_string())

sach.to_csv(THU_MUC_RA / "dang_nhap_sach.csv", index=False)
that_bai_ip.to_csv(THU_MUC_RA / "that_bai_theo_ip.csv")
sach.head(100).to_json(THU_MUC_RA / "dang_nhap_sach_100.json", orient="records", indent=2, date_format="iso", force_ascii=False)
print("\nĐã xuất:", sorted(p.name for p in THU_MUC_RA.iterdir()))

Kết quả: THÀNH CÔNG 65772 · THẤT BẠI 63338 · top 5 IP thử sai: 130.12.182.196 3310 · 154.18.197.29 2295 · 109.160.32.31 1989 · 85.11.167.127 1719 · 109.160.32.35 1266 · Đã xuất: ['dang_nhap_sach.csv', 'dang_nhap_sach_100.json', 'mat_khau.json', 'mk_chi_so.csv', 'that_bai_theo_ip.csv']

📦 that_bai_ip = Series pandas IP → số lần thử sai trong cả 30 ngày, xếp nhiều nhất lên đầu (that_bai_ip.index[0] = IP đứng đầu, that_bai_ip.iloc[0] = số lần của nó). Phần 6 dùng lại.

👉 Hiểu: hơn một nửa là "thành công" vì honeypot cố tình cho vào (nhất là root). File xuất ra nằm ở ~/lab/out_python/, mở được bằng Excel.

💼 Dùng khi nào: tạo file kết quả cho người khác dùng: CSV mở bằng Excel cho quản lý, JSON cho hệ thống khác đọc tự động.
⚖️ Ảnh hưởng tới: người nhận chỉ thấy file, không thấy code → file phải đúng và đủ.


Phần 6 — Tổng hợp: điều tra như chuyên viên SOC

Quy trình điều tra tổng quát (SOC = Security Operations Center, đội trực giám sát an ninh) — và bài này đã đi qua từng bước ở đâu:

 ① Thu thập ──► ② Làm sạch ──► ③ Trích đặc trưng ──► ④ Sàng lọc, xếp hạng ──► ⑤ Điều tra sâu ──► ⑥ Kết luận, báo cáo ──► ⑦ Hành động
  Phần 0, 4.1     Phần 5         3.3, 3.4, 4.4          4.4, 4.5, 6.1, 6.2        6.5 (hồ sơ IP)     6.3, 6.4, Phần 7          (ngoài bài)
Bước Câu hỏi phải trả lời Trong bài dùng Đầu ra
① Thu thập dữ liệu nằm đâu, bao nhiêu, khoảng thời gian nào? ls, wc -l, pd.read_json bảng log
② Làm sạch dữ liệu có dùng thẳng được không? thiếu / rỗng / sai kiểu? fillna, str.lower, to_datetime bảng sach
③ Trích đặc trưng mô tả mỗi IP bằng những con số nào? value_counts, groupby, np.unique số kết nối, số lần sai, số username…
④ Sàng lọc, xếp hạng trong hàng nghìn IP, ai đáng xem trước? ngưỡng, mặt nạ True/False, điểm rủi ro danh sách IP ưu tiên
⑤ Điều tra sâu IP đó làm gì, khi nào, nhịp ra sao, là người hay máy? hồ sơ IP: lịch, nhịp, từ điển mật khẩu "tính cách" của từng kẻ tấn công
⑥ Kết luận, báo cáo có những kiểu tấn công nào, bằng chứng đâu? biểu đồ, báo cáo bao_cao.txt, đối chiếu MITRE ATT&CK báo cáo cho người ra quyết định
⑦ Hành động chặn ai, đổi cấu hình gì, theo dõi tiếp cái gì? (không làm trong bài — chỉ đề xuất trong báo cáo 6.4) luật chặn, thay đổi cấu hình

Nhiệm vụ của Phần 6: từ 30 ngày log, (1) xếp hạng IP thử sai, (2) đo hoạt động ban đêm, (3) vẽ biểu đồ, (4) viết báo cáo ngắn, (5) dựng hồ sơ từng IP.

6.1 Xếp hạng IP theo ngưỡng

🎯 Mục tiêu: lọc các IP thử sai vượt ngưỡng (bước ④)
📥 Đầu vào: that_bai_ip (5.5)
📤 Đầu ra: NGUONG_SAI, ip_dang_ngo (dùng lại ở 6.4)

Các lựa chọn ✏️ NGUONG_SAI (đo thật): 100 → 96 IP, 93% số lần thử sai · 500 (có sẵn) → 57 IP, 79% · 1000 → 18 IP, 38% · 2000 → 2 IP, 9%. Ngưỡng thấp bắt được nhiều hơn nhưng danh sách dài hơn, dễ chặn nhầm; ngưỡng cao thì ngược lại.

In [ ]:
# ✏️ SỬA: ngưỡng số lần thử sai để coi là đáng ngờ
NGUONG_SAI = 500
ip_dang_ngo = that_bai_ip[that_bai_ip >= NGUONG_SAI]
print(len(ip_dang_ngo), "IP thử sai từ", NGUONG_SAI, "lần trở lên, chiếm",
      f"{ip_dang_ngo.sum() / that_bai_ip.sum():.0%}", "tổng số lần thử sai")
ip_dang_ngo.head(10)

Kết quả: 57 IP thử sai từ 500 lần trở lên, chiếm 79% tổng số lần thử sai, rồi top 10 (130.12.182.196 3310 đứng đầu, nhiều IP 109.160.32.x).

👉 Hiểu: chặn 57 IP (2% số IP) là chặn được 4/5 số lần dò mật khẩu.

💼 Dùng khi nào: chọn danh sách chặn; báo cáo kiểu "chặn N IP = chặn X% số lần tấn công".
⚖️ Ảnh hưởng tới: số luật trên tường lửa / fail2ban, và rủi ro chặn nhầm khách hàng thật.

6.2 Các chỉ số điều tra

🎯 Mục tiêu: tính các chỉ số cho báo cáo: IP sai nhiều nhất, tỉ lệ thất bại, tỉ lệ thử ngoài giờ
📥 Đầu vào: sach, that_bai_ip
📤 Đầu ra: thu_ban_dem, dem_ket_qua, ti_le_sai, ip_dau, so_dau (dùng lại ở 6.3, 6.4, Phần 7)

Các lựa chọn ✏️ GIO_TOI, GIO_SANG (đo thật): 22, 6 (có sẵn) → 37% số lần thử, khung chiếm 33% thời gian · 18, 8 → 59%, khung chiếm 58% · 0, 6 → 29%, khung chiếm 25%. Ở mọi khung, tỉ lệ số lần thử ≈ tỉ lệ thời gian → tấn công không nghỉ đêm.

In [ ]:
# ✏️ SỬA: khung "ngoài giờ" theo giờ Việt Nam (22h tối -> 6h sáng)
GIO_TOI, GIO_SANG = 22, 6
# thu_ban_dem: các lần thử đăng nhập rơi vào khung ngoài giờ (| = HOẶC: từ GIO_TOI trở đi, hoặc trước GIO_SANG)
thu_ban_dem = sach[(sach.gio_vn >= GIO_TOI) | (sach.gio_vn < GIO_SANG)]
# dem_ket_qua: ĐẾM số lần THÀNH CÔNG / THẤT BẠI (reindex để luôn đúng thứ tự này, thiếu thì 0)
dem_ket_qua = sach.ket_qua.value_counts().reindex(["THÀNH CÔNG", "THẤT BẠI"], fill_value=0)
ti_le_sai = np.round(dem_ket_qua["THẤT BẠI"] / dem_ket_qua.sum() * 100, 1)
ip_dau, so_dau = that_bai_ip.index[0], int(that_bai_ip.iloc[0])
so_gio_khung = (24 - GIO_TOI + GIO_SANG) % 24
print(f"IP thử sai nhiều nhất: {ip_dau} ({so_dau} lần)")
print(f"Tỉ lệ thất bại: {ti_le_sai}%")
print(f"Thử đăng nhập ngoài giờ: {len(thu_ban_dem)} lần = {len(thu_ban_dem) / len(sach):.0%} (khung này chiếm {so_gio_khung / 24:.0%} thời gian trong ngày)")

Kết quả: IP thử sai nhiều nhất: 130.12.182.196 (3310 lần) · Tỉ lệ thất bại: 49.1% · Thử đăng nhập ngoài giờ: 47891 lần = 37% (khung này chiếm 33% thời gian trong ngày)

📦 thu_ban_dem = bảng các lần thử rơi vào khung 22h–6h (lọc từ sach) · dem_ket_qua = đếm số lần THÀNH CÔNG / THẤT BẠI · ip_dau, so_dau = IP thử sai nhiều nhất và số lần của nó. 6.3, 6.4 dùng lại.

👉 Hiểu: khung đêm chiếm 1/3 thời gian và nhận 37% số lần thử → kẻ tấn công không nghỉ đêm; ai trực SOC theo giờ hành chính sẽ bỏ lỡ hơn 1/3.

💼 Dùng khi nào: các chỉ số (KPI) của báo cáo định kỳ; lý do xin ngân sách / nhân sự trực 24/7.
⚖️ Ảnh hưởng tới: "37% tấn công rơi vào ban đêm" là con số thuyết phục lãnh đạo bằng dữ liệu, không bằng cảm giác.

6.3 Biểu đồ

🎯 Mục tiêu: vẽ 3 biểu đồ cạnh nhau và lưu ảnh
📥 Đầu vào: dem_ket_qua, that_bai_ip, sach
📤 Đầu ra: file ~/lab/out_python/dieu_tra_dang_nhap.png

🧰 .plot(kind=..., ax=..., color=..., title=...) (pandas): tất cả tuỳ chọn · kind: "line" (mặc định, đường) / "bar" (cột đứng) / "barh" (cột nằm) / "pie" (tròn) / "hist" (phân bố) · ax: vẽ vào ô hình nào (o[0], o[1]…) · plt.subplots(1, 3, figsize=(rộng, cao)): 1 hàng × 3 ô hình, kích thước tính bằng inch.

In [ ]:
import matplotlib.pyplot as plt
%matplotlib inline
hinh, o = plt.subplots(1, 3, figsize=(16, 4))
dem_ket_qua.plot(kind="bar", ax=o[0], color=["#2a9d8f", "#e76f51"], title="Kết quả đăng nhập")
o[0].tick_params(axis="x", rotation=0)
that_bai_ip.head(8).sort_values().plot(kind="barh", ax=o[1], color="#e76f51", title="Top 8 IP thử sai")
sach.groupby("gio_vn").size().plot(kind="bar", ax=o[2], color="#457b9d", title="Thử đăng nhập theo giờ VN")
hinh.tight_layout()
hinh.savefig(THU_MUC_RA / "dieu_tra_dang_nhap.png", dpi=120)
plt.show()
Warning:
Output truncated. This notebook contains too many cells to display efficiently.