[Linux Kernal P2] Ai bảo cần AI? DLP Engine của tôi bắt dữ liệu nhạy cảm trong 200ms, không tốn một xu GPU nào
Một ngày đẹp trời, nhân viên vô tình copy file khách hàng ra USB. Một lần rò rỉ có thể tốn hàng tỷ đồng tiền phạt. Và thế là xong
[Content-Aware DLP] Bên trong một Content Policy Engine thật: Từ Regex ngây thơ đến một cỗ máy chấm điểm rủi ro
Bạn có bao giờ tự hỏi: giữa lúc tiến trình cat đang bị Kernel "đóng băng" chờ phán quyết — như trong bài viết trước — thì bên trong DLP Agent đang chạy nền kia, điều gì thực sự đang xảy ra trong vài trăm mili-giây ngắn ngủi đó?
Câu trả lời không còn nằm ở Kernel nữa, mà nằm ở một cuộc đua với thời gian. Agent phải mở file, đọc nội dung, chạy hàng loạt bộ dò tìm, tính điểm rủi ro, ra quyết định, tất cả trong một ngân sách duy nhất: 200 mili-giây. Chậm hơn con số đó, tiến trình người dùng bị đứng hình vô lý. Nhanh mà ẩu thì một chuỗi số trông giống thẻ tín dụng lại bị báo động giả, hoặc tệ hơn, một dữ liệu nhạy cảm thật lọt qua vì hệ thống chỉ biết "có chữ giống mẫu" mà không biết đánh giá độ tin cậy của chính phát hiện đó.
Đây là bài toán khó nhất của một Content Policy Engine: không chỉ phát hiện được dữ liệu nhạy cảm, mà phải phát hiện đúng, có ngữ cảnh, có trọng số rủi ro, tất cả trong 200ms. Bài viết này mổ xẻ cách hệ thống giải bài toán đó, với code thật, benchmark đo trực tiếp trên máy đang chạy.
Mục lục
Phần 1: Kiến trúc & Pipeline — nối tiếp anon_fd từ bài Fanotify
Phần 2: Detection Layer — biến text thành Finding có tọa độ
Phần 3: Confidence Scoring — không phải mọi match ngang hàng
Phần 4: Policy Engine — bộ não ra quyết định (AND/OR/SCORE/Proximity)
Phần 5: Action Layer — không còn là placeholder (Mask/Alert)
Phần 6: Cái giá của tốc độ — benchmark 200ms thật
Phần 1: Kiến trúc & Pipeline
Nhắc lại: ở Step 7 của bài viết trước, Kernel tạo ra một Anonymous File Descriptor (anon_fd) trỏ thẳng vào struct file của file đang bị chặn, giao nó cho DLP Agent ở Step 10. Mọi thứ diễn ra dưới đây xảy ra trong lúc tiến trình người dùng đang bị đóng băng ở Step 9, nên mọi bước ở đây đều nằm trong ngân sách 200ms.
Điều đầu tiên cần nói: Agent không open(path) lại theo đường dẫn. Làm vậy sẽ tự kích hoạt lại chính cái mark Fanotify đã gắn, tạo vòng lặp vô hạn tự chặn chính mình. Thay vào đó, Agent nhân bản thẳng từ anon_fd:
// crates/linux-agent/src/main.rs:1334-1338
let file_opt = match nix::unistd::dup(fd.as_raw_fd()) {
Ok(raw_dup_fd) => unsafe { Some(std::fs::File::from_raw_fd(raw_dup_fd)) },
Err(_) => None,
};
Việc đánh giá được đẩy vào một bounded thread pool 16 luồng (chi tiết ở Phần 6), với trần trích xuất nội dung cứng 10MB:
// crates/linux-agent/src/main.rs:1173
let detector = Arc::new(Detector::from_rules(&rules, 10)); // 10MB limit
Nếu file vượt 10MB, hệ thống không cắt cụt phần đầu một cách ngây thơ, vì dữ liệu nhạy cảm hoàn toàn có thể nằm ở giữa hoặc cuối file. Nó lấy mẫu ba vùng — đầu, giữa, cuối — mỗi vùng khoảng 3.33MB:
// crates/mydlp-core/src/extraction/file.rs:237-260
let chunk_size = max_scan_size / 3;
file.read_exact(&mut head)?; // đầu file
let mid_offset = (file_size / 2).saturating_sub(chunk_size / 2);
file.seek(SeekFrom::Start(mid_offset as u64))?; ... // giữa file
let tail_offset = file_size.saturating_sub(chunk_size);
file.seek(SeekFrom::Start(tail_offset as u64))?; ... // cuối file
Pipeline đầy đủ từ đây: extract_file_content_from_file_with_meta → evaluate_detectors → resolve_channel (usb/web_upload/network_share...) → policy_engine.evaluate, nếu có Action::Block thì trả FanotifyDecision::Deny ngược lại tầng Fanotify, chính là FAN_DENY ở Step 12 của bài trước. Song song, một Incident được dựng và ghi qua kênh non-blocking:
// crates/linux-agent/src/main.rs:1431-1432
let _ = crate::incident_queue::enqueue_incident(&platform_ref.agent_data_dir(), &incident);
let _ = incident_tx_ref.try_send(incident); // try_send, không phải send — không bao giờ được nghẽn vòng lặp fanotify
Chi tiết nhỏ nhưng sống còn: nếu kênh gửi Incident đầy, Agent thà đánh rơi một report còn hơn làm nghẽn vòng lặp fanotify, vì vòng lặp đó đang giữ mọi tiến trình khác trong trạng thái chờ.
Phần 2: Detection Layer — biến text thành Finding có tọa độ
2.1 Keyword — không phải vòng lặp contains() cho từng từ
Khi một rule khai báo hàng trăm từ khóa, hệ thống không lặp content.contains(keyword) cho từng cái (độ phức tạp O(n×m)). Nó dùng Aho-Corasick, quét nội dung đúng một lượt, khớp đồng thời mọi từ khóa:
// crates/mydlp-core/src/detection/keyword.rs:11-22
let builder = AhoCorasick::builder()
.ascii_case_insensitive(!match_case)
.match_kind(MatchKind::Standard)
.build(&keywords)
.unwrap_or_else(|_| AhoCorasick::new(Vec::<String>::new()).unwrap());
Mỗi match được gắn đúng nhãn của từ khóa đã khớp, không gộp chung thành một label rỗng:
// crates/mydlp-core/src/detection/keyword.rs:27-39
pub fn evaluate(&self, content: &str) -> HashMap<String, Vec<(usize, usize)>> {
let mut label_offsets: HashMap<String, Vec<(usize, usize)>> = HashMap::new();
for m in self.automaton.find_iter(content) {
let keyword = self.keywords.get(m.pattern().as_usize()).cloned().unwrap_or_default();
label_offsets.entry(keyword).or_default().push((m.start(), m.end()));
}
label_offsets
}
Nhờ vậy một Incident không còn ghi chung chung "khớp keyword rule" nữa, mà ghi chính xác từ khóa nào đã kích hoạt. Điều này quan trọng cho audit, và cũng quan trọng cho Proximity ở Phần 4, vì Proximity cần biết chính xác vị trí byte của từng từ khóa cụ thể, không phải vị trí của "cả cụm keyword" gộp lại.
2.2 Regex + Luhn Checksum — khi Regex "tưởng đúng" nhưng chưa đủ
Một Regex bắt số thẻ tín dụng kiểu \b4[0-9]{12}(?:[0-9]{3})?\b sẽ khớp với bất kỳ dãy 16 số bắt đầu bằng 4, kể cả một dãy số tình cờ trông giống thẻ. Mọi match ở label thẻ tín dụng vì vậy phải qua thêm một lớp kiểm định toán học: Luhn (mod-10 checksum).
// crates/mydlp-core/src/detection/engine.rs:29-46
pub fn passes_luhn(digits: &str) -> bool {
let clean_digits: String = digits.chars().filter(|c| c.is_ascii_digit()).collect();
if clean_digits.len() < 13 || clean_digits.len() > 16 { return false; }
let mut sum = 0;
let mut double = false;
for c in clean_digits.chars().rev() {
let mut val = c.to_digit(10).unwrap();
if double { val *= 2; if val > 9 { val -= 9; } }
sum += val;
double = !double;
}
sum % 10 == 0
}
Duyệt từ phải sang trái: digit tận cùng bên phải giữ nguyên, rồi toggle mỗi bước. Các digit ở vị trí chẵn tính từ phải (2, 4, 6...) bị nhân đôi, nếu vượt 9 thì trừ 9. Tổng cuối phải chia hết cho 10. Bộ lọc áp dụng ngay tại thời điểm match, loại thẳng những gì fail:
// crates/mydlp-core/src/detection/regex_detector.rs:33-39
if (pat.name == "CREDIT_CARD" || pat.name == "THE_TIN_DUNG" || pat.name == "SO_THE_TIN_DUNG")
&& !crate::detection::engine::passes_luhn(matched_str)
{
continue;
}
Ví dụ thật từ test case, hai chuỗi cấu trúc giống hệt nhau, chỉ khác đúng 1 chữ số cuối:
| Số | Khớp Regex? | Tổng Luhn | Qua Luhn? | Kết quả |
|---|---|---|---|---|
4532015112830366 |
✅ | 50 | ✅ | Finding, confidence 0.95 |
4532015112830367 |
✅ | 51 | ❌ | Bị loại hoàn toàn, count = 0 |
Regex hoàn toàn không phân biệt được hai dãy này. Chỉ phép kiểm định toán học mới tách được đâu là số thẻ thật, đâu là dãy số ngẫu nhiên trông giống thẻ.
2.3 OOXML — DOCX/XLSX không còn bị "cắt tag thô"
File Word/Excel không phải plain text, chúng là ZIP chứa XML. Cắt tag XML bằng regex ngây thơ (<[^>]+> → xóa) từng gây false-negative thật: Microsoft Word tách một câu ra nhiều <w:t> run khi bật spell-check, khiến một số CCCD liền mạch trong mắt người đọc lại bị chia vụn trong XML gốc. Nếu cắt tag thô, phần còn lại có thể không còn khớp regex nữa. Giải pháp là parse bằng XML parser thật:
// crates/mydlp-core/src/extraction/file.rs:9-12
/// Each part is parsed with a real XML parser (`ooxml::extract_wordprocessing_text`)
/// instead of naive tag-stripping, so text split across adjacent `<w:t>` runs (spell-check
/// suggestions, etc.) is joined back correctly before scanning.
Phần 3: Confidence Scoring — không phải mọi match ngang hàng
Một dãy 9 số ngẫu nhiên và một số CCCD 12 số đúng cấu trúc mã tỉnh không thể mang cùng trọng số rủi ro. Toàn bộ hệ thống vận hành theo một bảng độ tin cậy phân tầng:
| Label | Confidence | Vì sao |
|---|---|---|
| EDM (khớp hash đúng dữ liệu thật) | 0.98 | Cao nhất, khớp chính xác chứ không phải "giống định dạng" |
CREDIT_CARD / THE_TIN_DUNG |
0.95 | Đã qua kiểm định Luhn |
SO_CCCD (≥12 số) |
0.85 | Mã tỉnh + thế kỷ đã bị ràng buộc cấu trúc ngay trong regex |
DIA_CHI_EMAIL / IPv4 / IPv6 |
0.80 | |
SO_DIEN_THOAI |
0.75 | |
| IDM | = tỉ lệ trùng khớp tài liệu | Không cố định, chính là % overlap |
SO_CCCD (9 số, CMND cũ) |
0.50 | Không có validator cấu trúc nào |
| Keyword (bất kỳ) | 0.50 | Không có kiểm định cấu trúc |
| Regex tùy chỉnh khác | 0.60 | Mặc định trung bình |
Con số này không chỉ để hiển thị. Nó là input trực tiếp cho confidence_gte ở Policy Engine (Phần 4), và có thể được cộng thêm nếu finding đứng gần một detector khác theo đúng ngữ cảnh — đây là chỗ Confidence Scoring và Proximity Matching giao nhau, có lẽ là phần thú vị nhất của toàn bộ engine.
Phần 4: Policy Engine — bộ não ra quyết định
Có Finding chưa đủ để ra quyết định. Một CCCD lẻ loi trong file log 10.000 dòng khác hẳn CCCD đó nằm ngay cạnh dòng "tên khách hàng".
4.1 AND / OR
// crates/mydlp-core/src/policy/engine.rs:66-68
"any" | "or" => condition.rules.iter().any(|r| self.evaluate_condition_rule(r, findings, condition)),
"all" | "and" => condition.rules.iter().all(|r| self.evaluate_condition_rule(r, findings, condition)),
Một ConditionRule được coi là thỏa mãn khi tìm được ít nhất một Finding cùng detector_id (và label nếu chỉ định) vượt qua cả hai cổng phụ nếu có cấu hình: confidence_gte và count_gte.
4.2 SCORE — chấm điểm rủi ro theo bậc, không cộng dồn
Mỗi ConditionRule có danh sách bậc điểm (count_gte → points), và rule chỉ lấy bậc cao nhất vượt qua — các bậc không cộng dồn:
// crates/mydlp-core/src/policy/engine.rs:244-248
tiers.iter().filter(|t| total_count >= t.count_gte).map(|t| t.points).fold(0.0, f64::max)
Ví dụ thật từ test (score_test_rule), 2 detector, mỗi cái có bậc điểm riêng:
| Detector | Bậc điểm cấu hình |
|---|---|
cccd_det (SO_CCCD) |
count_gte:1 → 30đ, count_gte:5 → 60đ |
kw_det |
count_gte:1 → 25đ |
| Findings thực tế | Điểm | Ngưỡng | Kết quả |
|---|---|---|---|
cccd_det=1 (30đ) + kw_det=1 (25đ) |
55 | 50 | ✅ Match |
Chỉ kw_det=1 (25đ) |
25 | 50 | ❌ No match |
cccd_det=5, vượt cả 2 bậc |
60 (không phải 30+60=90) | 61 | ❌ No match |
Kịch bản thứ 3 là điểm đáng chú ý nhất. Nếu hệ thống cộng dồn (90), rule sẽ khớp nhầm ở ngưỡng 61. Còn một chốt an toàn dễ bị bỏ qua: nếu Condition không cấu hình score_threshold, logic SCORE không bao giờ khớp dù điểm cao đến đâu — test thật cho thấy 100 lần match CCCD confidence 0.95 vẫn không match vì thiếu ngưỡng.
4.3 Proximity / Context Matching — khoảng cách giữa 2 detector
Đo khoảng cách byte offset giữa mọi cặp match của 2 detector, tìm cặp gần nhau nhất:
// crates/mydlp-core/src/policy/engine.rs:100-116
let distance = if a_end <= b_start { b_start - a_end }
else if b_end <= a_start { a_start - b_end }
else { 0 }; // hai span chồng lấn
if distance <= rule.max_distance { return true; }
Ví dụ thật: keyword "khach hang" kết thúc byte 10, CCCD bắt đầu byte 11, khoảng cách 1 byte, nằm trong max_distance: 50 nên match. Đẩy CCCD ra byte 500-512, vượt ngưỡng, không match, dù cả 2 detector vẫn "có mặt" trong file. Proximity chỉ được xét sau khi AND/OR/SCORE gốc đã đúng — nếu một detector bắt buộc chưa từng xuất hiện, hệ thống không tốn công tính khoảng cách làm gì.
4.4 Proximity Boost — cú bắt tay giữa ngữ cảnh và độ tin cậy
Đây có lẽ là cơ chế tinh vi nhất trong cả engine: proximity không chỉ lọc điều kiện, nó còn cộng thêm confidence.
// crates/mydlp-core/src/policy/engine.rs:130-149
const PROXIMITY_CONFIDENCE_BOOST: f64 = 0.2;
fn effective_confidence(finding: &Finding, condition: &Condition, findings: &[Finding]) -> f64 {
let base = finding.confidence.unwrap_or(0.5);
...
if boosted { (base + PROXIMITY_CONFIDENCE_BOOST).min(1.0) } else { base }
}
| Kịch bản | Confidence hiệu lực | Ngưỡng 0.6 | Kết quả |
|---|---|---|---|
| Có proximity, 2 match gần nhau | 0.5 + 0.2 = 0.7 | ✅ Match | |
| Cùng findings, không khai báo proximity | 0.5 | ❌ No match | |
| Có proximity nhưng match cách xa | 0.5 (boost không áp dụng) | ❌ No match |
Một keyword đơn lẻ (confidence nền 0.5) không đủ vượt ngưỡng 0.6. Nhưng nếu nó đứng đúng ngữ cảnh cạnh một detector khác, nó được nâng lên 0.7 và vượt qua. Đây chính là "ngữ cảnh" theo đúng nghĩa: cùng dữ liệu, cùng ngưỡng, nhưng kết quả khác nhau tùy vị trí tương đối trong văn bản.
Phần 5: Action Layer — không còn là placeholder
5.1 Action::Mask — redact đúng span, giữ nguyên phần còn lại
Fanotify không cho phép rewrite lại byte thực tế mà tiến trình đọc được, đây là giới hạn cấu trúc của chính API chứ không phải do lười code. "Mask" ở đây thật ra nghĩa là: redact các span nhạy cảm ra khỏi bằng chứng lưu vào Incident, giữ nguyên phần còn lại để phục vụ điều tra:
// crates/mydlp-core/src/actions/mask.rs:13
pub fn mask_content(content: &str, findings: &[Finding]) -> String {
// gộp các span chồng lấn/liền kề trước khi thay bằng "[MASKED]"
Ví dụ thật từ test:
| Input | Output |
|---|---|
"CCCD: 001099012345, please process." |
"CCCD: [MASKED], please process." |
"CCCD 001099012345 and phone 0912345678 leaked." |
"CCCD [MASKED] and phone [MASKED] leaked." |
| Hai finding có span chồng lấn (0-15 và 10-22) | "[MASKED]" — gộp thành một token duy nhất, không phải hai |
Một chi tiết phòng thủ nhỏ: span không hợp lệ (vượt độ dài content, hoặc không nằm trên ranh giới UTF-8) bị bỏ qua thay vì làm crash chương trình.
5.2 Action::Alert — kênh riêng, không chặn luồng incident chính
// crates/master-server/src/notification.rs:11-19
pub fn spawn_alert_dispatch(db: Database, incident: Incident) {
if !incident.decision.actions.iter().any(|a| matches!(a, Action::Alert { .. })) {
return;
}
tokio::spawn(async move { ... }); // task riêng, tách khỏi luồng lưu incident
Webhook thật, gửi bằng reqwest, timeout 5 giây, payload format sẵn cho Slack/Teams:
// crates/mydlp-core/src/actions/alert.rs:36-41
let text = format!(
"🚨 *[MyDLP SOC Alert]* Incident `{}` on `{}` | Severity: *{}* | Channel: `{}` | Findings: {}\n> {}",
...
);
Nếu webhook chậm hoặc không phản hồi, nó không bao giờ làm chậm việc lưu Incident vào database, vì chạy trong task riêng, tách hoàn toàn khỏi đường ghi chính.
Phần 6: Cái giá của tốc độ — benchmark 200ms thật
Benchmark đo trực tiếp trên máy đang phát triển hệ thống này, không phải số liệu lý thuyết:
| File Size | Extract | Scan | Total | % ngân sách 200ms |
|---|---|---|---|---|
| 10 KB | 0.03 ms | 0.09 ms | 0.12 ms | 0.06% |
| 100 KB | 0.09 ms | 0.77 ms | 0.87 ms | 0.44% |
| 1.000 KB | 1.26 ms | 5.62 ms | 6.89 ms | 3.4% |
| 5.000 KB | 9.53 ms | 30.37 ms | 39.91 ms | 20% |
| 10.000 KB (= cap 10MB) | 17.01 ms | 68.31 ms | 85.32 ms | 42.7% |
Tính ms/KB ở 4 mốc từ 100KB trở lên (bỏ mốc 10KB vì overhead cố định lấn át): dao động trong biên hẹp 0.0069–0.0087 ms/KB, không có dấu hiệu tăng phi tuyến. Bộ Regex/Keyword không "nổ" độ phức tạp khi nội dung tăng lên.
Insight đáng chú ý nhất ở đây: ngay tại đúng ngưỡng cap 10MB, extraction cộng detection chỉ tiêu tốn khoảng 43% ngân sách 200ms. Phần còn lại (~115ms) là khoảng đệm có chủ đích cho phần còn lại của pipeline và cho các máy chậm hơn máy dev. Đây là lý do 10MB được chọn, không phải vì hệ thống "không chịu nổi" ở kích thước lớn hơn.
Về thread pool, cần nói rõ cho chính xác: không phải "16+16 riêng biệt" như nhiều người hay hiểu nhầm.
// crates/linux-agent/src/fanotify_runner.rs:22-42, 82
let (sender, receiver) = std::sync::mpsc::sync_channel::<EvaluationJob>(workers_count);
for _ in 0..workers_count { thread::spawn(...); }
...
thread_pool: EvaluationThreadPool::new(16),
Cùng một workers_count = 16 dùng cho cả số luồng OS và dung lượng buffer, tức chính xác 16 luồng, hàng đợi sâu tối đa 16 job. Khi cả hai đầy, hệ thống fail-closed ngay lập tức để chống DoS.
Có thêm một chính sách "2 lần khoan hồng" khi timeout thật xảy ra, theo (dev, ino), cửa sổ 60 giây: timeout lần 1 và 2 thì Allow (có thể chỉ là đĩa chậm nhất thời), từ lần 3 trở lên thì Deny cố định vì lúc này nghi ngờ là vấn đề thật.
Cuối cùng là deep-scan tier: khi file bị sample (vượt 10MB), một tác vụ quét bù toàn bộ file chạy trên pool riêng, 4 luồng, cap 200MB (gấp 20 lần), thậm chí chạy được cả OCR ảnh vì không còn đua với 200ms nữa. Nhưng có một sự thật không né tránh được: khi deep-scan xong thì quyền truy cập đã được cấp từ lâu, không thể thu hồi FAN_ALLOW. Hệ thống chỉ tạo được Incident "delayed detection" để điều tra thủ công sau đó — một đánh đổi có chủ đích, không phải lỗ hổng bị bỏ sót.
Kết bài
Nếu Fanotify là "người gác cổng" quyết định đóng băng hay thả một tiến trình, thì Content Policy Engine chính là bộ não đứng sau cánh cổng đó. Nhưng như bài viết này cho thấy, một bộ não thật không chỉ biết "có khớp hay không". Nó biết phân biệt một số ngẫu nhiên với một số thẻ tín dụng thật nhờ Luhn, biết một match đứng một mình khác gì một match đứng đúng ngữ cảnh nhờ Proximity, biết chấm điểm rủi ro theo bậc thay vì cộng dồn ngây thơ nhờ SCORE, và biết cả giới hạn của chính công cụ nó đang dùng — Mask không rewrite được byte thật, Deep-scan không thể chặn hồi tố. Mọi quyết định thiết kế, từ cap 10MB đến pool 16 luồng, đều xoay quanh việc bảo vệ đúng 200ms đó, có tính toán, có đo đạc, chứ không phải đoán mò.
Ở bài viết tiếp theo, mình sẽ đi tiếp vào phần khó nhằn hơn: EDM/IDM, làm sao khớp chính xác dữ liệu nhạy cảm thật (số CCCD khách hàng, hợp đồng mẫu...) mà không bao giờ để dữ liệu gốc rời khỏi máy khách, cùng câu chuyện hash-salt và fingerprinting đứng sau nó. Đón đọc nhé! À, nếu muốn tham khảo source code sớm thì ib mình nhé, còn không thì hết serial mình sẽ open.
All Rights Reserved