Nhật ký kiểm toán là thứ bạn biết ơn khi có sự cố: ai đã đăng nhập, từ đâu, làm gì, lúc mấy giờ. Không có nó thì điều tra một vụ xâm nhập giống như dò trong bóng tối. Nhưng có một nghịch lý ít ai để ý: cái camera an ninh bạn lắp để bảo vệ nhà, nếu quay luôn cả màn hình lúc bạn gõ mật khẩu két, thì chính đoạn băng đó trở thành thứ nguy hiểm nhất trong nhà. Log cũng vậy — ghi quá tay thì cái sổ dựng lên để bảo vệ bạn lại thành một kho bí mật rò rỉ, sống lâu và đông người đọc hơn cả dữ liệu gốc. Bài này đo cả hai mặt: cái gì cần ghi, và cái gì ghi vào là tự hại mình.
Tôi dựng một bộ ghi log nhỏ, cho nó xử lý một request đăng nhập chứa cả dữ liệu kiểm toán lẫn bí mật, rồi đo xem cái gì lọt vào file log. Ba lần, kết quả giống hệt. Chạy trong môi trường của mình với bí mật giả, không nhắm vào hệ thống của ai khác.
Đo 1: ghi ngây thơ — đẩy nguyên request vào log
Cách viết log phổ biến và tai hại nhất chỉ có một dòng: log nguyên cái request cho "đầy đủ để sau này còn lần". Request đăng nhập của tôi có user, ip, action, result (dữ liệu kiểm toán hợp lệ) nhưng cũng có password, authorization (token), và card (số thẻ). Đo số lần bí mật xuất hiện trong log:
so lan bi mat xuat hien trong log: 3
trich log: {"user":"an","password":"SieuMatKhau!2026","action":"login","authorization":"Bear...
Ba bí mật — mật khẩu, token, số thẻ — nằm nguyên văn trong file log. Đây không phải lỗi hiếm; nó là kết quả mặc định của việc log cả object request, log cả header, hay log cả body của một lời gọi API. Và điều khiến nó nguy hiểm hơn vẻ ngoài là vòng đời của log: log không nằm yên trong một file trên một máy — nó được gom về một nơi tập trung (ELK, Splunk, CloudWatch), giữ hàng tháng tới hàng năm cho mục đích tuân thủ, và được nhiều người đọc (đội vận hành, đội hỗ trợ, bất kỳ ai có quyền vào dashboard log). Một mật khẩu ghi vào log là một mật khẩu bị lộ ở một kho thứ hai, kho mà bạn còn không nghĩ tới khi lo bảo vệ cơ sở dữ liệu. Nói cách khác: bí mật vào log thì coi như đã rò — không có "log nội bộ nên không sao", vì log là một bản sao dữ liệu, và mọi bản sao đều là bề mặt tấn công.
Đo 2: bộ lọc che — xoá bí mật, giữ dấu vết
Cách đúng không phải bỏ log đi, mà là che (redact) theo khoá nhạy cảm trước khi ghi. Tôi thêm một bộ lọc: bất kỳ khoá nào tên là password, authorization, token, secret, card, cookie thì thay giá trị bằng ***, còn lại giữ nguyên. Đo lại:
so lan bi mat xuat hien trong log: 0
truong kiem toan 'user' con trong log? CO
truong kiem toan 'action' con trong log? CO
truong kiem toan 'ip' con trong log? CO
truong kiem toan 'result' con trong log? CO
trich log: {"user":"an","password":"***","action":"login","ip":"203.0.113.9","card":"***","result":"success"}
Đây chính là cái đích: 0 bí mật lọt ra, nhưng toàn bộ trường kiểm toán vẫn còn. Bạn vẫn biết an đã đăng nhập từ 203.0.113.9 lúc nào và thành công hay không — đủ để điều tra sau này — mà không giữ lại một ký tự nào của mật khẩu hay số thẻ. Log kiểm toán tốt trả lời được câu "ai làm gì" mà không trả lời câu "mật khẩu của họ là gì". Điểm mấu chốt của bộ lọc là nó che theo khoá, chạy ngay trước khi ghi, chứ không dựa vào lập trình viên nhớ bỏ bí mật ra ở từng chỗ gọi log — trông cậy vào trí nhớ thì chỉ cần một dòng log.debug(request) quên sót là rò, còn che tập trung một chỗ thì mọi đường ghi log đều đi qua cùng một tấm lưới.
Đo 3: cái bẫy — log lỗi kéo theo dữ liệu
Có một đường rò tinh vi hơn mà bộ lọc trên chưa chắc bắt được: thông báo lỗi. Tôi ném một exception mô phỏng lỗi cơ sở dữ liệu rồi ghi nguyên nó vào log:
ghi nguyen e -> so lan bi mat trong log: 1
Thông báo lỗi từ tầng dưới — cơ sở dữ liệu, thư viện — thường nhúng sẵn dữ liệu gây lỗi vào chuỗi thông báo: "duplicate key: INSERT INTO users(pass) VALUES('...')". Bạn ghi cái lỗi đó để gỡ rối, và vô tình ghi luôn giá trị nhạy cảm nằm trong câu SQL. Đây đúng là loại bẫy mà một dự án dễ vấp: bộ lọc che khoá password không giúp gì, vì bí mật lần này đến trong một câu văn lỗi, không phải một trường có tên. Cách chặn là đừng ghi nguyên str(exception) ra ngoài — ghi loại lỗi và mã, giữ chi tiết đầy đủ ở một nơi riêng quyền chặt, và không bao giờ trả thông báo lỗi thô của tầng dữ liệu ra log chung.
Còn một chuyện: bảo vệ chính cái log
Một nhật ký kiểm toán chỉ có giá trị nếu kẻ tấn công không sửa được nó. Nghịch lý là kẻ đột nhập gần như luôn tìm cách xoá dấu vết — mà dấu vết nằm đúng trong log. Nếu log chỉ là một file mà tài khoản ứng dụng có quyền ghi đè, thì kẻ chiếm được ứng dụng cũng xoá được log ngay sau khi gây án, và bạn mất đúng thứ cần để điều tra. Vì thế log kiểm toán nghiêm túc thường chỉ được thêm, không được sửa (append-only), và được đẩy sang một nơi khác mà tài khoản ứng dụng không có quyền xoá — một dịch vụ log tập trung, một bucket chỉ-ghi, hay một máy riêng. Ở mức cao hơn, người ta ký hoặc nối chuỗi băm các bản ghi (mỗi dòng chứa băm của dòng trước) để một chỉnh sửa giữa chừng làm gãy cả chuỗi — đúng ý tưởng chữ ký ở bài trước, áp cho nhật ký. Điểm chung: tách quyền ghi khỏi quyền xoá, và đưa log ra khỏi tầm với của thứ mà nó đang giám sát.
Chia đôi rạch ròi thì: cần ghi (để điều tra được) là ai (user/id), khi nào (mốc thời gian), từ đâu (IP), làm gì (action), kết quả (thành công/thất bại), và với thao tác nhạy cảm thì cả đối tượng bị tác động — bộ khung "who/what/when/where/result" của mọi log kiểm toán tử tế; còn không được ghi là mật khẩu, token/khoá phiên, số thẻ đầy đủ, dữ liệu cá nhân nhạy cảm, và nội dung thông báo lỗi thô từ tầng dưới — những thứ vào log là nhân bản rủi ro sang một kho sống lâu và đông người đọc. Cái ngược trực giác ở đây là ghi nhiều hơn không an toàn hơn: quá một ngưỡng, mỗi trường ghi thêm là thêm một thứ có thể rò, nên log kiểm toán tốt là log ghi đủ để điều tra, không phải ghi tất cả cho chắc.
Mẫu số chung
Cái lõi ở đây — mỗi dòng log là một bản sao dữ liệu, sống lâu hơn và đông người đọc hơn bản gốc, nên phải quyết định cái gì không sao chép, ngay tại nguồn — không riêng gì Python; nó là bài toán của mọi hệ ghi vết.
Trong thế giới Java, đây đúng là lý do Logback/Log4j2 có cơ chế masking (một PatternLayout converter hay MessageConverter tự viết thay password=... thành ***), và vì sao người ta đánh dấu @ToString.Exclude (Lombok) trên trường nhạy cảm để một log.info(user) vô tình không in mật khẩu qua toString(). Cùng nguyên tắc "che tại nguồn, đừng trông vào trí nhớ ở từng call site".
Trong Go, slog cho bạn LogValuer — một kiểu tự khai giá trị nó phơi ra khi log, nên một Password có thể luôn trả "***" bất kể ai log nó ở đâu; đó là bản Go của "che theo khoá tập trung", đẩy quyết định-cái-gì-không-lộ vào chính kiểu dữ liệu.
Còn ở cơ sở dữ liệu, cùng câu chuyện: bật log_statement=all kéo nguyên tham số câu lệnh (gồm giá trị nhạy cảm) vào log của DB — đúng cái bẫy "log lỗi kéo theo dữ liệu"; và bảng audit nên append-only (chỉ INSERT, REVOKE mọi UPDATE/DELETE) với các công cụ như pgaudit, đưa bản ghi ra khỏi tầm sửa của ứng dụng, đúng như log append-only ở trên.
Mẫu số chung: một bản ghi vết là một bản sao dữ liệu ở nơi khác, thường sống lâu hơn và ít được canh giữ hơn bản gốc — nên rủi ro không nằm ở chuyện ghi, mà ở chuyện sao chép cái không nên sao chép, và cách chặn đúng là che tại nguồn (theo khoá, theo kiểu) chứ không trông vào việc nhớ bỏ ra ở từng chỗ. Vì thế câu hỏi đúng khi thêm một dòng log không phải "ghi thế này có đủ chi tiết không" mà là "nếu file này rơi vào tay người ngoài thì tôi vừa tặng họ cái gì" — vì log tốt là log trả lời được ai làm gì mà không trả lời được bí mật của họ là gì.
Muốn tự soi trong ba mươi giây, tìm trong log của chính bạn xem có bí mật nào đang nằm đó:
grep -iE 'password|passwd|authorization|bearer |secret|token=|"card"|[0-9]{16}' /duong/dan/toi/app.log | head
Nếu có dòng nào hiện ra kèm một giá trị thật, đó không phải cảnh báo giả — đó là một bí mật đã rò vào log, và có thể đã được gom lên hệ thống log tập trung từ lâu. Việc cần làm gồm hai phần: che nó ở nguồn (bộ lọc theo khoá), và coi mọi bí mật từng xuất hiện trong log như đã lộ — tức là xoay vòng chúng, đúng như bài "Bí mật trong mã nguồn" đã đo.