Bài 9 — A09:2025 Security Logging and Alerting Failures: ghi đúng sự kiện bảo mật, không lộ dữ liệu trong log, chống sửa và xoá log, biến log thành cảnh báo có người xử lý
Series OWASP Top 10:2025: ← Bài 0 — tổng quan · ← Trước: Bài 8 — A08 Software or Data Integrity Failures · Bài 9 — A09 Security Logging and Alerting Failures (bài này) · Tiếp: Bài 10 — A10 Mishandling of Exceptional Conditions →
Tám bài trước nói về cách ngăn tấn công. A09 nói về chuyện xảy ra khi ngăn không được — và ở hệ thống thật, sớm muộn sẽ có lúc ngăn không được. Câu hỏi của mục này không phải "hệ thống có log không" (hệ thống nào cũng có), mà là bốn câu liên tiếp: (1) sự kiện có ý nghĩa bảo mật có được ghi lại, kể cả khi control chặn thành công không; (2) bản ghi có đủ ngữ cảnh để điều tra mà không chứa mật khẩu, token, dữ liệu cá nhân không; (3) kẻ đã vào được máy chủ có xoá hoặc sửa được dấu vết không; (4) khi có dấu hiệu bất thường, có người được báo trong vài phút, biết phải làm gì, và không bị chìm trong hàng trăm cảnh báo rác không. Hệ thống trả lời "có" cho câu 1 nhưng "không" cho câu 4 vẫn trượt A09 — đó chính là lý do năm 2025 OWASP đổi chữ "Monitoring" thành "Alerting". Câu interviewer muốn nghe ở mức Senior/Tech Lead: em thiết kế được danh sách sự kiện bảo mật như một phần của yêu cầu (không phải "thêm log khi debug"), em biết vì sao log phải rời khỏi máy chủ ngay, em đo được chất lượng cảnh báo bằng con số, và em phân biệt được observability vận hành (latency, lỗi 5xx) với khả năng phát hiện tấn công — hai thứ thường bị coi là một.
Vì sao bài này dùng khung riêng, và vì sao không có lab. Bài về một nhóm rủi ro, không phải một công nghệ, nên dùng khung riêng của series (10 mục cố định) thay cho 9 câu hỏi của CLAUDE.md. Bài này không có lab (quyết định của series): các nhận định về hành vi dựa trên tài liệu chính thức (OWASP Top 10:2025 và 2021, OWASP Logging Cheat Sheet, Application Logging Vocabulary Cheat Sheet, docs Laravel 13 / NestJS / nestjs-pino / pino tra qua Context7, sourcelaravel/framework13.x, Monolog và MoodleMOODLE_405_STABLEđọc trực tiếp) và được đánh dấu nguồn; chỗ nào cần lab mới khẳng định được thì ghi "chưa verify" — danh sách tổng hợp ở mục 10. Bài tập trung vào phòng thủ: cơ chế được giải thích ở mức "giả định nào trong code sai và hệ quả là gì", không có chuỗi khai thác.
Đọc kèm trong repo — bài này không nhắc lại: Activity log người dùng (đường ghi bất đồng bộ qua queue, bảng partition theo tháng, ẩn danh hoá khi xoá tài khoản), 7 cảnh báo đầu tiên trong Prometheus (cảnh báo theo triệu chứng, tỉ lệ thay cho số tuyệt đối, Watchdog — dead man's switch), Bộ công cụ để vận hành (request id, cardinality, giữ log bao lâu). Bài 9 đứng trên ba bài đó và chỉ thêm phần bảo mật: chọn sự kiện nào, bảo vệ log thế nào, cảnh báo nào là cảnh báo bảo mật.
1. Mục này là gì trong 2025
1.1 Định nghĩa: ghi nhận được, giữ được, và có người phản ứng kịp
1. Câu mở đầu phần Description của OWASP gói cả mục: "Without logging and monitoring, attacks and breaches cannot be detected, and without alerting it is very difficult to respond quickly and effectively during a security incident" — không có log và giám sát thì không phát hiện được tấn công; không có cảnh báo thì rất khó phản ứng nhanh khi có sự cố. Câu này có hai vế, và mỗi vế là một nửa của mục: phát hiện cần dữ liệu (log), phản ứng cần tín hiệu tới đúng người (alert).
2. Phần Background nói rõ mục này gồm ba nhóm lỗi trong code: "properly handling output encoding to log files (CWE-117), inserting sensitive data into log files (CWE-532), and insufficient logging (CWE-778)" — ghi log mà không mã hoá đầu ra đúng cách, ghi dữ liệu nhạy cảm vào log, và ghi không đủ. Ba nhóm này kéo ba hướng ngược nhau: một bên đòi ghi nhiều hơn (778), một bên đòi ghi ít hơn (532), một bên đòi ghi cẩn thận hơn (117). Thiết kế logging tốt là cân ba lực đó, không phải tối đa một lực.
3. OWASP cũng viết thẳng vì sao mục này luôn khó đo: "This category is incredibly difficult to test for, and has minimal representation in the CVE/CVSS data (only 723 CVEs)". Một lỗ hổng injection có thể chứng minh bằng một request; "hệ thống không báo động khi bị tấn công" chỉ chứng minh được bằng cách thử tấn công rồi hỏi đội vận hành có ai biết không. Vì vậy A09 vào danh sách nhờ khảo sát cộng đồng (lần thứ ba), không nhờ dữ liệu — chi tiết phương pháp ở Bài 0.
4. Các dấu hiệu OWASP liệt kê, gom theo bốn câu hỏi ở đầu bài:
| Câu hỏi | Dấu hiệu OWASP nêu (diễn ý) | Giả định sai |
|---|---|---|
| Có ghi không? | Sự kiện cần kiểm toán (đăng nhập, đăng nhập sai, giao dịch giá trị cao) không được ghi, hoặc ghi không nhất quán — "only logging successful logins, but not failed attempts"; cảnh báo và lỗi không sinh log, hoặc log mơ hồ; ứng dụng xử lý lỗi sai nên không biết có lỗi để mà ghi | "Log là để debug, chỗ nào chạy đúng thì không cần ghi" |
| Ghi có an toàn không? | Dữ liệu log không được mã hoá đúng nên log/hệ thống giám sát bị tấn công; ghi thông tin không được ghi (PII, PHI); log và cảnh báo lộ ra cho người dùng hoặc kẻ tấn công thấy | "Log là nội bộ, ai đọc đâu" |
| Log có còn nguyên không? | Toàn vẹn của log không được bảo vệ khỏi việc sửa; log chỉ nằm trên máy cục bộ và không được sao lưu đúng cách | "File log trên server là đủ" |
| Có ai phản ứng không? | Log không được giám sát; không có ngưỡng cảnh báo và quy trình leo thang, hoặc cảnh báo không được nhận/xem kịp; quét DAST và pentest không làm bật cảnh báo nào; không phát hiện được tấn công đang diễn ra gần thời gian thực; thiếu hoặc lỗi thời các "use case" cảnh báo; quá nhiều false positive khiến đội SOC quá tải; playbook xử lý thiếu hoặc cũ | "Có dashboard rồi, ai đó sẽ nhìn" |
5. Cách đọc bảng ở mức tech lead: bốn dòng là bốn mắt xích của một chuỗi — sự kiện xảy ra → được ghi → bản ghi an toàn và còn nguyên → có người được báo và xử lý. Chuỗi chỉ mạnh bằng mắt xích yếu nhất: log hoàn hảo nằm trên một node mà kẻ tấn công xoá được thì bằng không; log hoàn hảo, giữ an toàn, nhưng không ai nhìn thì cũng bằng không cho tới khi người ngoài báo tin — đúng như ba kịch bản OWASP dẫn ở mục 8.
1.2 Thay đổi so với 2021
1. Tên: "Security Logging and Monitoring Failures" → "Security Logging & Alerting Failures". OWASP giải thích: "a slight name change to emphasize the alerting function needed to induce action on relevant logging events" — nhấn vào chức năng cảnh báo, thứ buộc phải có hành động khi có sự kiện log liên quan. Trang Introduction của 2025 nói gọn hơn (đã dẫn ở Bài 0): "Great logging with no alerting is of minimal value". "Monitoring" là một trạng thái (có người có thể nhìn); "Alerting" là một hành động (hệ thống chủ động gọi người). Đổi chữ là đổi tiêu chí đạt: có dashboard đẹp không còn đủ.
2. Hạng: giữ hạng 9. Điểm tổng hợp theo công thức của OWASP thấp nhất trong mười mục (350,19 — xem bảng tính ở Bài 0), nhưng mục này vào danh sách nhờ khảo sát cộng đồng.
3. Số CWE: 4 → 5. CWE mới: CWE-221 Information Loss or Omission (mất hoặc bỏ sót thông tin) — cha của CWE-223 vốn đã có. Bốn CWE cũ giữ nguyên.
4. Description dài gần gấp đôi. Các dấu hiệu mới so với 2021: ghi không nhất quán (chỉ ghi đăng nhập thành công); toàn vẹn log không được bảo vệ; log không được sao lưu; cảnh báo không được nhận hoặc xem kịp; ghi PII/PHI vào log; lỗi bị xử lý sai nên hệ thống không biết để ghi (cầu nối sang A10); thiếu use case cảnh báo; quá nhiều false positive làm SOC quá tải; playbook thiếu hoặc lỗi thời. Bốn dấu hiệu cuối đều nói về con người và quy trình sau log — đây là phần mới thật sự của 2025.
5. How to prevent thêm sáu ý:
- "every part of your app that contains a security control is logged, whether it succeeds or fails" — mọi chỗ có control bảo mật đều ghi, cả khi thành công lẫn thất bại.
- Audit trail cho "all transactions" (2021 chỉ nói "high-value transactions").
- "all transactions that throw an error are rolled back and started over. Always fail closed" — giao dịch lỗi thì rollback, luôn fail closed (từ chối khi không chắc). Ý này nối sang A10.
- Cảnh báo khi ứng dụng hoặc người dùng hành xử đáng ngờ, và viết hướng dẫn cho developer để code theo — hoặc mua hệ thống làm việc này.
- Honeytoken (bẫy — dữ liệu hoặc tài khoản giả, nghiệp vụ bình thường không bao giờ chạm tới): "any access generates logging data that can be alerted with nearly no false positives".
- Phân tích hành vi / AI là kỹ thuật tuỳ chọn để giảm false positive; và dạy developer nhận biết tấn công trông như thế nào để họ báo.
6. Kịch bản: giữ nguyên ba kịch bản của 2021 (nhà cung cấp bảo hiểm y tế trẻ em, hãng bay Ấn Độ, hãng bay châu Âu — mục 8). Danh sách tham khảo thêm bài viết về vụ Snowflake làm ví dụ thực tế, và ASVS chuyển từ V7 (bản 4) sang V16 Security Logging and Error Handling (bản 5.0.0).
1.3 Bảng điểm chính thức
| Chỉ số | A09:2021 | A09:2025 | Đọc thế nào |
|---|---|---|---|
| CWE ánh xạ | 4 | 5 | Thêm CWE-221 |
| Max incidence rate | 19,23% | 11,33% | Ứng dụng tệ nhất giảm từ ~1/5 xuống ~1/9 điểm kiểm tra dính lỗi |
| Avg incidence rate | 6,51% | 3,91% | Giảm — nhưng xem dòng coverage |
| Max coverage | 53,67% | 85,96% | Nhiều bộ test kiểm được mục này hơn hẳn |
| Avg coverage | 39,97% | 46,48% | Tăng nhẹ |
| Avg weighted exploit | 6,87 | 7,19 | Tăng nhẹ |
| Avg weighted impact | 4,99 | 2,65 | Thấp nhất trong mười mục — một CVE "ghi thiếu log" hiếm khi tự nó gây thiệt hại |
| Total occurrences | 53.615 | 260.288 | Tăng ~4,85 lần (tập dữ liệu 2025 lớn hơn) |
| Total CVEs | 242 | 723 | Tăng ~2,99 lần, vẫn rất ít so với các mục khác |
Điểm dễ hiểu sai: impact 2,65 không có nghĩa A09 ít quan trọng. Điểm impact lấy từ CVSS của CVE, mà CVSS chấm một lỗ hổng — "thiếu log" không cho ai vào hệ thống cả. Thiệt hại của A09 là hệ số nhân lên thiệt hại của mọi mục khác: cùng một lỗ hổng A01, phát hiện sau 2 giờ hay sau 7 năm là khác biệt giữa vài trăm bản ghi và vài triệu bản ghi. Không thang điểm CVE nào đo được hệ số nhân đó.
Cách nói khi phỏng vấn: "A09 năm 2025 giữ hạng 9 nhưng đổi chữ Monitoring thành Alerting, vì OWASP muốn nhấn là có log mà không có cảnh báo buộc người hành động thì gần như vô giá trị. Điểm tác động của mục này thấp nhất danh sách, nhưng em đọc con số đó cẩn thận: điểm tác động chấm từng lỗ hổng, còn thiếu log không tự mở cửa cho ai, nó chỉ quyết định cửa bị mở bao lâu trước khi có người biết. Nên em coi A09 là hệ số nhân của mọi mục còn lại. Phần mới đáng chú ý nhất là các dấu hiệu về con người: cảnh báo không ai xem, quá nhiều cảnh báo giả, playbook lỗi thời."
1.4 Gom 5 CWE theo cơ chế
| Nhóm | CWE | Nghĩa ngắn | Mục đi sâu |
|---|---|---|---|
| Ghi thiếu | CWE-778 Insufficient Logging | Sự kiện quan trọng về bảo mật không được ghi, hoặc ghi mà bỏ chi tiết quan trọng | 3.1, 4.1 |
| CWE-223 Omission of Security-relevant Information | Có ghi, nhưng thiếu thông tin cần để biết đó là sự kiện bảo mật (ai, từ đâu, kết quả) | 3.2, 4.2 | |
| CWE-221 Information Loss or Omission | Mất hoặc bỏ sót thông tin nói chung — cha của 223; gồm cả log bị cắt, bị mất trên đường, bị ghi đè | 3.2, 3.5 | |
| Ghi quá | CWE-532 Insertion of Sensitive Information into Log File | Mật khẩu, token, dữ liệu cá nhân vào log — log thành nơi rò rỉ | 3.3, 4.3 |
| Ghi sai cách | CWE-117 Improper Output Neutralization for Logs | Dữ liệu từ ngoài vào log mà không được trung hoà, nên làm giả được bản ghi hoặc làm hỏng công cụ đọc log | 3.4, 4.4 |
Có ba dấu hiệu trong Description không có CWE riêng nhưng chiếm nửa phần How to prevent: toàn vẹn và nơi lưu log (mục 3.5), cảnh báo và quy trình (mục 3.6), lỗi không được nhận ra (mục 3.7). Đó là phần thiết kế và vận hành — đúng phần SAST không quét được.
1.5 Ranh giới với A10, A01, A02 và A07
- A10 Mishandling of Exceptional Conditions — A10 là code xử lý tình huống bất thường sai (nuốt exception, fail open). A09 là không ghi nhận
và không báo. Một
catchrỗng thường dính cả hai: sai ở A10 vì cho qua, sai ở A09 vì không để lại dấu vết. OWASP tự nối hai mục bằng dấu hiệu "the system is unaware there was an error, and is therefore unable to log". - A01 Broken Access Control — lộ log hoặc trang xem log cho người không có quyền là lỗi A01 (OWASP dẫn sang A01 ngay trong Description); A09 lo chuyện log có chứa thứ không nên chứa.
- A02 Security Misconfiguration — để debug mode bật làm stack trace hiện ra cho người dùng là A02; ghi stack trace có mật khẩu vào log là A09.
- A07 Authentication Failures — không giới hạn số lần đăng nhập sai là A07; không biết đang có hàng nghìn lần đăng nhập sai là A09.
Hai mục dùng chung một nguồn dữ liệu: bản ghi
authn_login_failvừa nuôi rate limit vừa nuôi cảnh báo.
2. Bài toán gốc — vì sao có log mà vẫn không ai biết mình bị tấn công
1. Log được viết cho người debug, không cho người điều tra. Developer thêm dòng log khi code chạy sai — exception, timeout, dữ liệu
lạ. Khi một control bảo mật chạy đúng (từ chối mật khẩu sai, chặn người xem bài của người khác), code không có gì sai để ghi, nên thường không ghi gì.
Kết quả: log đầy stack trace nhưng không có một dòng nào cho biết có ai đó đã thử 4.000 mật khẩu trong một giờ. Laravel làm điều này thành mặc định: danh
sách $internalDontReport của exception handler (source 13.x) gồm AuthenticationException, AuthorizationException,
TokenMismatchException (CSRF sai), ValidationException — tức đúng các thất bại có ý nghĩa bảo mật nhất không bao giờ tới
log nếu không làm gì thêm. Đây là lựa chọn đúng cho log vận hành (chúng không phải lỗi của server), nhưng có nghĩa là log bảo mật phải là một kênh
riêng do mình chủ động ghi.
2. Không ai sở hữu yêu cầu logging. Logging là yêu cầu phi chức năng: không nằm trong acceptance criteria của user story, QC không test,
PO không hỏi. Logging Cheat Sheet viết rằng mức giám sát và cảnh báo "needs to be set during the requirements and design stage of projects" — phải được
đặt ở giai đoạn yêu cầu và thiết kế. Khi không ai đặt, mỗi developer ghi theo thói quen riêng: người ghi "login failed", người ghi
"auth error for user X", người không ghi. Không thể viết một rule cảnh báo trên dữ liệu không nhất quán như vậy.
3. Observability vận hành không nhìn thấy tấn công thành công. Các alert phổ biến đo triệu chứng của hỏng: tỉ lệ 5xx, p95 latency,
worker hết chỗ. Một cuộc tấn công thành công thì không làm gì hỏng: người dùng lấy cắp được tài khoản giảng viên rồi sửa điểm nhận về toàn 200 OK,
latency bình thường. Đó là lý do một hệ thống có Prometheus + Grafana + Sentry đầy đủ (như trong CV) vẫn có thể trượt A09 hoàn toàn — các công cụ đó đo
"hệ thống còn chạy tốt không", không đo "ai đang làm gì với hệ thống".
4. Thời gian phát hiện tính bằng tháng. Logging Vocabulary Cheat Sheet dẫn báo cáo IBM Cost of a Data Breach: "mean time to identify a breach continues to hover around 200 days" — trung bình khoảng 200 ngày mới nhận ra bị xâm nhập. Ba kịch bản của OWASP có chung một chi tiết: người phát hiện là bên ngoài (bên thứ ba báo tin, nhà cung cấp hosting báo tin), không phải hệ thống giám sát của nạn nhân.
5. Cảnh báo quá nhiều cũng là không có cảnh báo. Ví dụ minh hoạ: một hệ thống bắn 60 cảnh báo mỗi ngày, trong đó trung bình 1 cảnh báo có việc thật cần làm. Precision (tỉ lệ cảnh báo có ý nghĩa) = 1/60 ≈ 1,7%. Sau hai tuần người trực học được rằng "mở cảnh báo ra gần như luôn phí thời gian" và bắt đầu tắt thông báo — đúng hiện tượng OWASP gọi là "physical overload of the SOC team". Cảnh báo bảo mật thật hôm đó nằm lẫn trong 59 cảnh báo rác.
6. Bản thân log là dữ liệu nhạy cảm và là mục tiêu. Log gom từ mọi nơi về một chỗ, giữ lâu, nhiều người đọc được (dev, ops, nhà cung cấp dịch vụ log) — tức là có đủ điều kiện của một kho dữ liệu rò rỉ: Twitter (2018) và Facebook (2019) đều phát hiện mật khẩu người dùng nằm dạng đọc được trong log/kho dữ liệu nội bộ (mục 8). Và với kẻ đã vào được máy chủ, việc đầu tiên đáng làm là xoá dấu vết — nên log nằm trên chính máy đó là log không đáng tin.
7. Không test được bằng cách thông thường. Unit test kiểm "hàm trả đúng kết quả", không kiểm "hàm có để lại bản ghi đủ để điều tra". DAST tìm lỗ hổng, không tìm "thiếu cảnh báo". Cách test duy nhất có hiệu lực là diễn tập: chạy một kịch bản tấn công giả lập có kiểm soát và đo xem cảnh báo có tới không, sau bao lâu (mục 6).
flowchart TD
E["Sự kiện bảo mật xảy ra<br/>(đăng nhập sai, bị từ chối quyền, đổi role)"] --> W{"Code có ghi không?"}
W -- "không: control chạy đúng nên không ghi<br/>CWE-778" --> X1["Mất dấu vết"]
W -- "có" --> C{"Đủ ngữ cảnh, không lộ bí mật,<br/>đã trung hoà?"}
C -- "không: CWE-223 / 532 / 117" --> X2["Không điều tra được<br/>hoặc log thành nơi rò rỉ"]
C -- "có" --> S{"Rời khỏi máy ngay,<br/>lưu chỗ khó sửa?"}
S -- "không: chỉ lưu local" --> X3["Kẻ vào máy xoá được"]
S -- "có" --> R{"Có rule cảnh báo,<br/>ngưỡng hợp lý?"}
R -- "không, hoặc quá nhiều cảnh báo giả" --> X4["Không ai biết, hoặc biết mà bỏ qua"]
R -- "có" --> P["Người trực nhận trong vài phút<br/>có runbook, xử lý"]
Cách nói khi phỏng vấn: "Em xem A09 là một chuỗi năm mắt xích: sự kiện có được ghi không, bản ghi có đủ ngữ cảnh mà không lộ bí mật không, bản ghi có rời khỏi máy chủ ngay để không bị xoá không, có quy tắc cảnh báo với ngưỡng hợp lý không, và có người nhận cảnh báo kèm hướng dẫn xử lý không. Hỏng bất kỳ mắt xích nào thì kết quả giống nhau: người ngoài báo tin trước mình. Lý do gốc là log thường được viết để debug, nên chỗ control bảo mật chạy đúng lại là chỗ không có log."
3. Cơ chế — mỗi dạng sai ở giả định nào
Mục này giải thích từng dạng ở mức "giả định nào sai, hệ quả là gì". Code cụ thể ở mục 4.
3.1 Không ghi sự kiện bảo mật (CWE-778) — vì control chạy đúng thì không ai nghĩ tới việc ghi
1. Danh sách sự kiện phải ghi là một quyết định thiết kế, không phải thói quen. Logging Cheat Sheet có danh sách "where possible, always log". Gom lại kèm tên sự kiện theo Logging Vocabulary Cheat Sheet (bộ từ vựng OWASP đề xuất để mọi ứng dụng gọi cùng một sự kiện bằng cùng một tên):
| Nhóm | Sự kiện | Tên theo Vocabulary (ví dụ) | Ví dụ trong LMS |
|---|---|---|---|
| Xác thực | Đăng nhập thành công và thất bại, khoá tài khoản, đổi mật khẩu, tạo/thu hồi token | authn_login_success, authn_login_fail, authn_login_fail_max, authn_login_lock, authn_password_change, authn_token_revoked | Sinh viên đăng nhập, giảng viên đổi mật khẩu, API token của hệ thống điểm danh |
| Phân quyền | Bị từ chối quyền; quyền của ai đó bị thay đổi; hành động của admin | authz_fail, authz_change, authz_admin | Sinh viên mở bài nộp của người khác; ai đó được gán vai trò giảng viên |
| Phiên | Tạo, gia hạn, hết hạn, đăng xuất, dùng phiên đã hết hạn; cookie phiên bị sửa, JWT sai chữ ký | session_created, session_expired, session_use_after_expire | Token tải báo cáo hết hạn vẫn bị dùng lại |
| Validate đầu vào | Giá trị ngoài tập cho phép (dropdown), sai định dạng, tham số lạ | input_validation_fail, input_validation_discrete_fail | course_id không thuộc khoá học sinh viên đăng ký |
| Dùng quá mức | Vượt rate limit, quá số phiên | excess_rate_limit_exceeded | Một tài khoản gọi API xuất điểm 500 lần/phút |
| Dữ liệu nhạy cảm | Đọc, sửa, xoá, xuất dữ liệu nhạy cảm (kể cả báo cáo trên màn hình) | sensitive_read, sensitive_update | Sửa điểm, xuất danh sách sinh viên kèm số điện thoại |
| Quản lý người dùng | Tạo, sửa, khoá, xoá người dùng | user_created, user_deleted | Tạo tài khoản admin mới |
| Hệ thống | Khởi động/tắt ứng dụng; bật/tắt agent giám sát hoặc logging; thay đổi cấu hình | sys_startup, sys_monitor_disabled | Ai đó tắt agent gửi log trên một app node |
| Upload | Upload xong, kết quả quét file | upload_complete, upload_validation | File bài nộp bị phát hiện chứa mã độc |
2. Ghi cả thành công lẫn thất bại — vì sao. Nhìn riêng một bản ghi thất bại không nói được nhiều: người dùng gõ sai mật khẩu là chuyện thường.
Cái có ý nghĩa là chuỗi: 30 lần thất bại rồi 1 lần thành công trên cùng tài khoản (Vocabulary có hẳn sự kiện authn_login_successafterfail
cho đúng mẫu này) nghĩa là có khả năng mật khẩu đã bị đoán ra. Không ghi thành công thì không bao giờ thấy được dòng cuối của chuỗi.
3. Ví dụ số (minh hoạ). Kiểu tấn công nhồi thông tin đăng nhập (credential stuffing — thử cặp email/mật khẩu bị lộ từ trang khác) trải mỏng
ra để né rate limit theo tài khoản: trong 10 phút có 2.400 lần đăng nhập thất bại, rơi vào 2.100 tài khoản khác nhau (mỗi tài khoản 1–2 lần), từ 60 địa chỉ
IP. Rate limit "5 lần sai/tài khoản" không bao giờ bật vì không tài khoản nào quá 2 lần. Nếu chỉ log lỗi server, không thấy gì cả. Nếu có bản ghi
authn_login_fail với user, IP và kết quả, một rule đơn giản trên tổng số lần thất bại toàn hệ thống (so với cùng giờ tuần trước) thấy
ngay: bình thường giờ đó chỉ ~80 lần sai/10 phút. Và 14 bản ghi authn_login_success từ đúng 60 IP đó cho biết 14 tài khoản cần khoá và buộc đổi
mật khẩu.
4. Bản ghi validate thất bại là dấu hiệu, chưa phải bằng chứng. Logging Cheat Sheet nhấn mạnh: "Treat these as suspicious events, not proof of an attack: unexpected input can also result from misuse or client defects" — input lạ cũng đến từ app di động bản cũ hay extension trình duyệt. Mức độ nghiêm trọng phải đánh giá qua lặp lại, tương quan và ngữ cảnh. Hệ quả cho thiết kế: ghi ở mức WARN, gom lại theo user/IP rồi mới cảnh báo, không bao giờ page người trực vì một lần validate sai.
5. Sự kiện "logging bị tắt" phải được ghi ở chỗ khác. Vocabulary có sys_monitor_disabled — ai đó tắt agent giám sát. Kẻ tấn công
có quyền trên máy thường tắt thu log trước khi làm việc khác, nên sự kiện này phải được phát hiện từ bên ngoài máy (không còn nhận được log từ node đó)
chứ không chỉ dựa vào node đó tự báo — chi tiết ở 3.6 và 4.6.
flowchart TD
A["Request đăng nhập"] --> V{"Kiểm mật khẩu"}
V -- "sai" --> F["Ghi authn_login_fail<br/>user, IP, request_id"]
V -- "đúng" --> S["Ghi authn_login_success<br/>(kèm số lần sai trước đó nếu có)"]
F --> G["Gom theo cửa sổ 10 phút:<br/>tổng hệ thống, theo IP, theo tài khoản"]
S --> G
G --> D{"Lệch nhiều so với<br/>cùng giờ tuần trước?"}
D -- "có" --> AL["Cảnh báo + runbook"]
D -- "không" --> N["Chỉ lưu để điều tra"]
Cách nói khi phỏng vấn: "Em coi danh sách sự kiện bảo mật là một phần của thiết kế, có tên chuẩn theo bộ từ vựng của OWASP: đăng nhập thành công và thất bại, bị từ chối quyền, thay đổi quyền, đọc và xuất dữ liệu nhạy cảm, tắt giám sát. Phải ghi cả thành công, vì dấu hiệu mật khẩu bị đoán trúng là một chuỗi thất bại kết thúc bằng một lần thành công. Còn tấn công dàn mỏng trên hàng nghìn tài khoản thì rate limit theo tài khoản không thấy, chỉ tổng số lần thất bại toàn hệ thống so với cùng giờ tuần trước mới thấy."
3.2 Ghi thiếu ngữ cảnh (CWE-223, CWE-221) — có dòng log nhưng không trả lời được ai, ở đâu, lúc nào
1. Bốn câu hỏi mỗi bản ghi phải trả lời. Logging Cheat Sheet: "The application logs must record 'when, where, who and what' for each event". Cụ thể với một web app:
- When — thời điểm theo định dạng quốc tế, có múi giờ (Vocabulary yêu cầu ISO 8601 "WITH UTC offset"), và interaction identifier (mã nối mọi sự kiện của cùng một tương tác — thường gọi là request id / correlation id).
- Where — ứng dụng và phiên bản, host/node, route và HTTP method.
- Who — user id (khoá chính, không phải tên hiển thị), IP nguồn, loại người dùng.
- What — loại sự kiện, mức độ nghiêm trọng, cờ "đây là sự kiện bảo mật", đối tượng bị tác động, kết quả (thành công/thất bại) và lý do.
2. Correlation id — vì sao ứng dụng phải tự ghi. Cheat sheet giải thích "interaction identifier": ứng dụng biết các sự kiện này thuộc cùng một tương tác, nên phải ghi lại thay vì "forcing subsequent correlation techniques to re-construct the separate events" — bắt người điều tra dựng lại bằng cách so thời gian. Với LMS có nhiều tầng, một hành động "giảng viên sửa điểm" đi qua Nginx LB → app node → MySQL → job gửi thông báo trên worker VPS. Không có một mã chung thì người điều tra phải ghép bốn nguồn log theo dấu thời gian — và ở đây thời gian không đáng tin (ý 3).
3. Lệch đồng hồ làm hỏng thứ tự sự kiện (ví dụ minh hoạ). Ba node, node 2 chạy chậm 40 giây vì NTP không chạy. Kẻ tấn công đăng nhập ở node 1 lúc 10:00:05 rồi sửa điểm ở node 2 lúc 10:00:20 (giờ thật). Log của node 2 ghi 09:59:40. Đọc log gom theo thời gian thì "sửa điểm" xảy ra trước "đăng nhập" — người điều tra kết luận phiên sửa điểm là một phiên cũ, bỏ qua. Cheat sheet yêu cầu "Synchronize time across all servers and devices"; cảnh báo khi đồng hồ một node lệch là một alert vận hành rẻ mà giữ giá trị pháp lý của log.
4. CWE-221 — mất thông tin trên đường đi. Ngoài việc không ghi, thông tin còn mất do: cắt dòng log quá dài (thường cắt đúng phần cuối chứa lý do), buffer của agent đầy khi hệ thống log chậm nên bản ghi bị bỏ, xoay vòng file (log rotation) xoá file cũ trước khi agent đọc xong, tổng hợp (aggregate) ở điểm trung gian làm mất chi tiết từng sự kiện. Mỗi chỗ này cần một con số theo dõi: số bản ghi bị bỏ ở agent, độ trễ từ lúc ghi tới lúc vào kho.
5. Correlation id từ client — tiện nhưng là dữ liệu không tin cậy. Nhiều hệ thống nhận header X-Request-Id từ phía trước để nối
log xuyên service. Nếu chấp nhận nguyên giá trị từ trình duyệt thì (a) kẻ tấn công đặt cùng một id cho mọi request để trộn dấu vết với người khác, (b) giá trị
đó là một chuỗi tuỳ ý đi thẳng vào log — đúng điều kiện của CWE-117. Cheat sheet: dữ liệu từ vùng tin cậy khác "must always be treated as untrusted
data". Cách làm: chỉ tin header từ LB của mình (LB ghi đè header do client gửi), kiểm định dạng (ví dụ UUID), sai thì sinh mới.
flowchart TD C["Trình duyệt<br/>(có thể tự gửi X-Request-Id)"] --> LB["Nginx LB<br/>ghi đè X-Request-Id = id mới"] LB --> APP["App node<br/>kiểm định dạng UUID, đưa vào context log"] APP --> DB["MySQL / PostgreSQL<br/>(audit row có cột request_id)"] APP --> Q["Queue: job mang theo request_id"] Q --> WK["Worker VPS<br/>log của job có cùng request_id"] APP --> LOG["Kho log tập trung<br/>lọc theo một id ra cả chuỗi"] WK --> LOG
Cách nói khi phỏng vấn: "Mỗi bản ghi bảo mật của em trả lời bốn câu: lúc nào, ở đâu, ai, làm gì và kết quả ra sao. Thời gian ghi kèm múi giờ và đồng hồ các node phải được đồng bộ, vì chỉ lệch vài chục giây là thứ tự sự kiện bị đảo khi điều tra. Mỗi request có một mã tương quan do load balancer sinh, đi theo cả sang job của worker, để lọc một mã là ra cả chuỗi hành động. Mã đó em không nhận nguyên từ trình duyệt, vì đó là dữ liệu người dùng kiểm soát."
3.3 Ghi quá tay: dữ liệu nhạy cảm vào log (CWE-532) — log thành bản sao kém bảo vệ của database
1. Vì sao log là nơi rò rỉ tệ hơn database. Database có phân quyền theo bảng, có mã hoá, có người chịu trách nhiệm. Log thì được sao ra nhiều nơi (file trên node, agent, kho tập trung, bản backup, dịch vụ error tracking bên thứ ba), giữ lâu, nhiều người đọc, và thường không ai kiểm kê. Cheat sheet liệt kê những thứ "should usually not be recorded directly in the logs": mã nguồn, session id, access token, dữ liệu cá nhân nhạy cảm, mật khẩu, connection string, khoá mã hoá và secret, dữ liệu thẻ/tài khoản ngân hàng, dữ liệu có mức phân loại cao hơn mức hệ thống log được phép giữ, thông tin người dùng đã từ chối cho thu thập.
2. Dữ liệu nhạy cảm lọt vào log qua những đường không ai cố ý:
- Ghi nguyên request —
Log::info('login', $request->all())"để debug" mang theo trườngpassword. - Stack trace có tham số hàm — PHP in giá trị tham số trong trace; hàm
attempt($email, $password)ném exception là mật khẩu nằm trong log lỗi. PHP 8.2 có attribute#[\SensitiveParameter]để thay giá trị bằngObject(SensitiveParameterValue)trong trace (PHP manual); Laravel 13 dùng nó ở chính eventFailed(tham số$credentials, source). Lưu ý của người dùng trong manual: đánh dấu ở interface không có tác dụng nếu class cài đặt không đánh dấu lại. - URL chứa bí mật — token đặt lại mật khẩu, link tải ký sẵn (signed URL, presigned URL), JWT trong query string: access log của Nginx ghi nguyên URL. Không cần lỗi code nào — chỉ cần thiết kế đặt bí mật trong URL.
- Header — log nguyên header thì có
AuthorizationvàCookie. - Người dùng gõ nhầm ô — ghi nguyên "tên đăng nhập" của lần đăng nhập thất bại thì thỉnh thoảng sẽ ghi được mật khẩu, vì người dùng gõ mật khẩu vào ô tên đăng nhập. Đây là lý do mục 4.1 ghi hash có khoá của định danh thay vì chuỗi gốc.
- Error tracker — SDK của dịch vụ theo dõi lỗi có thể gửi kèm dữ liệu request ra bên thứ ba; phải kiểm cấu hình gửi PII và quy tắc lọc của SDK đang dùng (chi tiết từng SDK chưa verify trong bài).
3. Allowlist thắng denylist. Có hai cách lọc: denylist — liệt kê trường cần che (password, token) và ghi phần
còn lại; allowlist — chỉ ghi các trường đã khai báo. Denylist hỏng âm thầm mỗi khi có trường mới (otp_code, new_password_confirmation,
api_key) mà không ai cập nhật danh sách. Allowlist hỏng theo hướng an toàn: thiếu trường thì log thiếu thông tin, người cần sẽ thêm vào. Với log
bảo mật, chọn allowlist; denylist (như redact của pino) là lớp thứ hai cho log vận hành.
4. Cần theo dõi theo người mà không lưu danh tính thô — pseudonymization (giả danh hoá). Cheat sheet gợi ý thay session id bằng giá trị hash nếu cần theo dõi sự kiện theo phiên, và dùng "deletion, scrambling or pseudonymization" cho định danh khi không cần danh tính thật. Hash thường (SHA-256) của email không đủ: email đoán được, tính hash của một danh sách email là dò ngược được. Phải dùng HMAC với khoá riêng (hash có khoá), khoá đó không nằm trong hệ thống log.
5. Dữ liệu cá nhân "không nhạy cảm" vẫn là dữ liệu cá nhân. Vocabulary nhắc: IP "may be considered personally identifiable information when combined with other data". Ghi IP là cần cho phát hiện tấn công — nhưng phải có thời hạn giữ và lý do, và nằm trong quy trình xoá dữ liệu cá nhân (bài Activity log mục 6.4 đã có cách ẩn danh hoá thay vì xoá cứng).
flowchart TD
R["Dữ liệu sắp ghi"] --> A{"Trường có trong<br/>allowlist của sự kiện?"}
A -- "không" --> D["Bỏ"]
A -- "có" --> K{"Là định danh<br/>(email, session id)?"}
K -- "có" --> H["Thay bằng HMAC có khoá<br/>(vẫn nối được các lần, không đọc ngược)"]
K -- "không" --> P["Ghi giá trị"]
H --> O["Bản ghi"]
P --> O
O --> RD["Lớp 2: redact theo đường dẫn<br/>(password, authorization, token)"]
Cách nói khi phỏng vấn: "Log được sao ra nhiều nơi và nhiều người đọc hơn database, nên em không để nó thành bản sao kém bảo vệ của dữ liệu thật. Rò rỉ thường không cố ý: ghi nguyên request, stack trace in cả tham số mật khẩu, access log ghi nguyên URL có token, người dùng gõ mật khẩu vào ô tên đăng nhập. Với log bảo mật em chỉ ghi các trường đã khai báo, định danh thì thay bằng mã băm có khoá để vẫn nối được các lần thử mà không đọc ngược ra email; lớp che theo tên trường chỉ là lớp thứ hai."
3.4 Log injection (CWE-117) — dữ liệu người dùng biến thành "bản ghi" hoặc thành lệnh cho công cụ đọc log
1. Giả định sai: "log chỉ là văn bản, ghi gì vào cũng được". Log dạng dòng (mỗi dòng một bản ghi) có một cấu trúc ngầm: ký tự xuống dòng
(CR, LF) ngăn cách bản ghi, dấu cách hoặc dấu | ngăn cách trường. Khi một giá trị do người dùng kiểm soát (tên đăng nhập, User-Agent, tên file)
được ghép thẳng vào chuỗi message, các ký tự cấu trúc trong giá trị đó trở thành cấu trúc của log. Hệ quả theo CWE-117: người đọc hoặc công cụ phân tích thấy
một "bản ghi" mà ứng dụng chưa từng ghi — có thể là một dòng đăng nhập thành công giả, hoặc một dòng làm rối thứ tự để che bản ghi thật. Logging Cheat Sheet xếp
việc này vào nhóm tấn công Accountability: "An attacker causes the wrong identity to be logged in order to conceal the responsible party".
2. Không chỉ là giả dòng. Dữ liệu log còn đi qua các "bộ thông dịch" phía sau: trang xem log hiển thị HTML (giá trị chứa mã HTML/JS chạy trong trình duyệt của admin đang xem log — XSS lưu trữ, và người xem log thường có quyền cao nhất), file CSV xuất ra mở bằng bảng tính (công thức), và chính thư viện logging. Log4Shell (CVE-2021-44228, 12/2021) là ví dụ nặng nhất: Log4j 2 diễn giải một cú pháp tra cứu nằm trong nội dung message, nên chỉ cần ứng dụng ghi một chuỗi do người dùng gửi là thư viện thực hiện tra cứu ra ngoài. Bài học thiết kế: dữ liệu log là dữ liệu, không thành phần nào trên đường đi được diễn giải nó thành lệnh — đúng nguyên tắc của A05 Injection áp dụng cho một "sink" (nơi dữ liệu đổ vào) mà ít ai coi là sink.
3. Hành vi mặc định của Laravel đáng biết. Đọc source LogManager::formatter() (13.x): các channel dạng file mặc định dùng Monolog
LineFormatter với tham số allowInlineLineBreaks = true (để stack trace nhiều dòng đọc được). Trong source LineFormatter,
khi tham số này bật, hàm replaceNewlines giữ nguyên ký tự xuống dòng thay vì đổi thành dấu cách. Nghĩa là: với cấu hình mặc định, một giá trị
có ký tự xuống dòng được ghép vào message sẽ xuống dòng thật trong file log (chưa verify bằng lab). Đây không phải lỗi của Laravel — nó là đánh đổi để
đọc stack trace — nhưng nó là lý do không được ghép input vào message.
4. Hai cách trung hoà, và vì sao structured log là cách chính. Cách thứ nhất: lọc/thoát ký tự CR, LF, ký tự phân cách trước khi ghi (cheat sheet: "Perform sanitization on all event data to prevent log injection attacks e.g. carriage return (CR), line feed (LF) and delimiter characters"). Cách thứ hai: đổi định dạng sang structured log (log có cấu trúc — mỗi bản ghi là một object JSON trên một dòng). Khi ghi bằng JSON, bộ mã hoá JSON tự thoát ký tự điều khiển trong chuỗi, giá trị người dùng nằm trong một trường riêng, không thể "thoát" ra thành trường hay bản ghi khác — giống prepared statement với SQL. Cách thứ hai tốt hơn vì nó đúng theo cấu tạo, không phụ thuộc vào việc mọi developer nhớ gọi hàm lọc. Cheat sheet tóm cả hai bằng một câu: "Encode data correctly for the output (logged) format" — mã hoá theo đúng định dạng của nơi nhận.
5. Trung hoà không có nghĩa là vứt bỏ. Cheat sheet (bản hiện tại) thêm: nếu một trường từ vùng tin cậy khác bị sai định dạng thì bỏ hoặc thay trường đó, ghi nhận lỗi validate, nhưng "do not discard the entire security event" — không được bỏ cả sự kiện bảo mật. Một dòng đăng nhập thất bại có User-Agent lạ vẫn phải được ghi; chỉ trường User-Agent bị thay.
flowchart TD
U["Giá trị từ người dùng<br/>(tên đăng nhập, User-Agent, tên file)"] --> M{"Ghép vào message<br/>dạng dòng?"}
M -- "có" --> X["Ký tự xuống dòng thành ranh giới bản ghi<br/>→ bản ghi giả, thứ tự rối"]
M -- "không: để trong trường riêng" --> J["Bộ mã hoá JSON thoát ký tự điều khiển<br/>một sự kiện = một dòng"]
J --> V["Trang xem log escape HTML<br/>khi hiển thị"]
V --> OK["Dữ liệu vẫn chỉ là dữ liệu<br/>ở mọi chặng"]
Cách nói khi phỏng vấn: "Log injection xảy ra khi giá trị người dùng được ghép thẳng vào một dòng log văn bản: ký tự xuống dòng trong giá trị trở thành ranh giới bản ghi, nên đọc log sẽ thấy những dòng ứng dụng chưa từng ghi. Rộng hơn, log còn đi qua trang xem log, file xuất, và cả thư viện logging — Log4Shell là lúc chính thư viện diễn giải nội dung log thành lệnh. Cách chính của em là log có cấu trúc: giá trị người dùng nằm trong một trường JSON riêng, bộ mã hoá tự thoát ký tự, giống prepared statement với SQL; trang xem log thì escape khi hiển thị."
3.5 Log không được bảo vệ: chỉ lưu local, sửa được, xoá được, làm đầy đĩa được
1. Log trên chính máy bị chiếm là log của kẻ tấn công. Cheat sheet liệt kê nhóm tấn công Accountability: "An attacker prevent writes in order to cover their tracks", "damages the log in order to cover their tracks". Người đã có quyền chạy lệnh trên app node có cùng quyền với tiến trình ghi log — sửa hay xoá file log là việc đầu tiên đáng làm. Phòng thủ là thời gian: bản ghi phải rời khỏi node trước khi kẻ tấn công kịp tới nó. Agent đọc file và gửi đi mỗi vài giây thì phần log trước thời điểm bị chiếm đã an toàn ở nơi khác; log ghi sau thời điểm đó thì không tin được nữa, và việc log từ node đó dừng lại chính là tín hiệu (3.6).
2. Nơi nhận phải khó sửa với chính người gửi. Cheat sheet, phần Protection: "Build in tamper detection so you know if a record has been modified or deleted", "Store or copy log data to read-only media as soon as possible", mọi truy cập vào log phải được ghi và giám sát, quyền đọc phải hẹp và rà định kỳ. Cụ thể hoá:
- Tách quyền ghi khỏi quyền sửa/xoá. Ứng dụng có quyền gửi (append), không có quyền xoá. Với bảng audit trong database: tài khoản database riêng chỉ có quyền INSERT (cheat sheet: "a separate database account that is only used for writing log data"), chặn UPDATE/DELETE (mục 4.5).
- Write-once storage. Object storage có chế độ khoá đối tượng theo thời hạn (S3 Object Lock, MinIO cũng có tính năng object locking — chi tiết cấu hình chưa verify trong bài), hoặc đẩy bản lưu trữ sang tài khoản/hệ thống mà đội vận hành ứng dụng không có quyền xoá.
- Phát hiện sửa bằng chuỗi hash (hash chain). Mỗi bản ghi lưu hash của (hash bản ghi trước + nội dung bản ghi này). Sửa hay xoá một bản ghi ở giữa làm đứt chuỗi từ đó trở đi. Giới hạn: người có toàn quyền có thể tính lại cả chuỗi — nên hash của bản ghi cuối phải được ghi định kỳ ra một nơi khác (neo — anchor). Cheat sheet cũng thừa nhận giới hạn chung: chống chối bỏ (non-repudiation) bằng log "is hard to achieve".
3. OWASP 2025 thêm "and not properly backed up". Kho log tập trung cũng là một hệ thống có thể hỏng hoặc bị mã hoá tống tiền (OWASP dẫn ba tài liệu NIST SP 1800-11/25/26 về toàn vẹn dữ liệu trước ransomware). Log audit phục vụ điều tra phải có bản sao lưu với thời hạn bằng thời hạn giữ.
4. Availability: log là đường làm đầy đĩa. Cheat sheet: kẻ tấn công "floods log files in order to exhaust disk space" — và "the same disk used for log files might be used for SQL storage". Ví dụ minh hoạ: mỗi request sai sinh một bản ghi WARN ~1 KB kèm stack trace 8 KB; 500 request/giây trong một giờ là ~16 GB — đủ làm đầy phân vùng 20 GB mà database cũng nằm trên đó, và database dừng ghi. Phòng thủ: log nằm phân vùng riêng (cheat sheet: "a separate partition"), có giới hạn dung lượng/xoay vòng, không ghi stack trace cho lỗi do client gây ra, và gom sự kiện lặp lại (một bản ghi "đã chặn 412 lần trong 1 phút" thay vì 412 bản ghi).
5. Log hỏng không được kéo ứng dụng theo — nhưng không được im lặng. Cheat sheet: "Ensure failures in the logging processes/systems do not prevent the application from otherwise running". Hai yêu cầu này có vẻ mâu thuẫn với "fail closed" của OWASP. Cách dung hoà thường gặp: log vận hành thì fail open (kho log chết, ứng dụng vẫn phục vụ, bản ghi đệm tại chỗ và có cảnh báo "pipeline log ngừng"); riêng các thao tác mà audit là điều kiện pháp lý (sửa điểm, xuất dữ liệu cá nhân) thì ghi audit trong cùng transaction với thao tác — audit ghi không được thì thao tác rollback (fail closed). Đó là một quyết định nghiệp vụ, phải được PO và người phụ trách tuân thủ chốt, không phải developer tự chọn.
flowchart TD APP["App node ghi log"] --> AG["Agent gửi đi sau vài giây"] AG --> CEN["Kho log tập trung<br/>app chỉ có quyền gửi"] CEN --> ARC["Lưu trữ write-once<br/>+ bản sao lưu"] CEN --> HC["Audit DB: chỉ INSERT,<br/>chuỗi hash, neo hash định kỳ ra ngoài"] ATK["Kẻ tấn công chiếm app node"] -. "xoá file local" .-> APP ATK -. "không có quyền" .-> CEN AG --> HB["Log ngừng chảy từ node<br/>→ chính là tín hiệu cảnh báo"]
Cách nói khi phỏng vấn: "Log nằm trên chính máy bị chiếm thì không đáng tin, vì người vào được máy có cùng quyền với tiến trình ghi log. Nên em cho log rời máy trong vài giây, nơi nhận chỉ cho ứng dụng quyền gửi chứ không có quyền xoá, bản lưu trữ để ở chế độ ghi một lần và có sao lưu. Bảng audit thì tài khoản ứng dụng chỉ được thêm dòng, có chuỗi băm để phát hiện sửa giữa chừng, và băm của dòng cuối được neo ra chỗ khác định kỳ. Log hỏng thì ứng dụng vẫn chạy, trừ những thao tác mà audit là điều kiện bắt buộc như sửa điểm — chỗ đó ghi audit trong cùng giao dịch."
3.6 Có log nhưng không có cảnh báo — hoặc có cảnh báo mà không ai xử lý
1. Use case trước, rule sau. OWASP dùng chữ "use case" cho cảnh báo: mỗi cảnh báo phải ứng với một tình huống cụ thể mà mình muốn biết, kèm playbook (hướng dẫn xử lý). Thứ tự đúng là: chọn tình huống ("có người đoán mật khẩu hàng loạt", "có người được nâng quyền admin ngoài quy trình", "có người xuất dữ liệu sinh viên bất thường") → xác định sự kiện nào trong log chứng minh tình huống đó → viết rule → viết runbook → diễn tập. Làm ngược lại (bật sẵn 200 rule mẫu của một công cụ) là con đường ngắn nhất tới alert fatigue.
2. Ba loại rule, từ ít nhiễu tới nhiều nhiễu.
- Sự kiện mà một lần là đủ — gần như không có false positive: honeytoken bị dùng (tài khoản giả, API key giả, bản ghi giả trong database mà không chức năng nào đọc), một tài khoản được gán quyền admin ngoài cửa sổ thay đổi, agent log trên một node bị tắt. OWASP đánh giá honeytoken là cảnh báo "with nearly no false positives".
- Tỉ lệ và so với baseline — đăng nhập thất bại toàn hệ thống so với cùng giờ tuần trước; tỉ lệ
authz_failtrên tổng request của một user. Dùng tỉ lệ thay cho số tuyệt đối, cùng lý do đã phân tích ở bài 7 cảnh báo đầu tiên (HighErrorRate): số tuyệt đối thay đổi theo lưu lượng, tỉ lệ thì không. - Hành vi bất thường / AI — OWASP để ở mức "optional": hỗ trợ giảm false positive, không thay được hai loại trên. Với đội nhỏ, đây là lớp cuối cùng nên đầu tư.
3. Mỗi cảnh báo phải trả lời được "người nhận làm gì trong 15 phút tới". Nếu câu trả lời là "xem thử rồi thôi" thì đó không phải cảnh báo, đó là
báo cáo — đưa vào dashboard hoặc email tổng hợp hằng ngày. Phân tầng: page (gọi người trực ngay — chỉ cho loại 1 và loại 2 có ngưỡng chặt), ticket
(xử lý trong giờ làm), digest (đọc hằng tuần). Mỗi cảnh báo page có runbook_url: bước xác minh, bước khống chế (khoá tài khoản, thu hồi
token, chặn IP), người cần báo, khi nào leo thang.
4. Đo chất lượng cảnh báo bằng số. Ví dụ minh hoạ cho một tháng: 120 cảnh báo bảo mật, 9 cảnh báo dẫn tới hành động thật → precision 7,5%. Mục tiêu đặt ra (ví dụ ≥ 50% cho mức page) buộc đội xoá hoặc hạ cấp rule nhiễu. Hai con số nữa: thời gian từ lúc cảnh báo bắn tới lúc có người nhận (acknowledge), và kết quả diễn tập (có bao nhiêu kịch bản tấn công giả lập làm bật cảnh báo). OWASP coi "DAST tools ... do not trigger alerts" là dấu hiệu lỗi — tức là đội quét DAST staging mỗi tuần mà không ai nhận được cảnh báo nào thì đó chính là kết quả diễn tập miễn phí, và nó trượt.
5. Giám sát chính hệ thống giám sát. Cheat sheet: "Enable processes to detect whether logging has stopped". Đây là đúng tư duy Watchdog (dead man's switch) của bài 7 cảnh báo đầu tiên: cảnh báo khi thiếu tín hiệu. Áp vào log: mỗi node phải đều đặn gửi log; một node im lặng 15 phút trong giờ có tải là sự kiện cần biết — có thể là agent chết, có thể là ai đó tắt nó.
flowchart TD
UC["Use case: điều mình muốn biết"] --> EV["Sự kiện chứng minh<br/>(tên chuẩn, trường cần có)"]
EV --> RL["Rule: một lần là đủ / tỉ lệ so baseline"]
RL --> TR{"Người nhận làm gì<br/>trong 15 phút?"}
TR -- "có việc cụ thể" --> PG["Page + runbook_url"]
TR -- "xử lý trong giờ làm" --> TK["Ticket"]
TR -- "chỉ để biết" --> DG["Digest / dashboard"]
PG --> MS["Đo: precision, thời gian nhận,<br/>kết quả diễn tập"]
MS -- "rule nhiễu" --> RL
Cách nói khi phỏng vấn: "Em bắt đầu cảnh báo từ tình huống muốn biết, không từ bộ rule mẫu. Loại ít nhiễu nhất là sự kiện một lần là đủ: bẫy honeytoken bị chạm, ai đó được nâng quyền admin ngoài quy trình, một node ngừng gửi log. Loại thứ hai là tỉ lệ so với cùng giờ tuần trước. Cảnh báo nào không trả lời được câu người nhận phải làm gì trong mười lăm phút thì em hạ xuống ticket hoặc báo cáo tổng hợp. Và em đo chất lượng bằng số: tỉ lệ cảnh báo dẫn tới hành động thật, thời gian có người nhận, và mỗi lần quét bảo mật staging có làm bật cảnh báo không."
3.7 Lỗi không được nhận ra thì không được ghi — chỗ A09 gặp A10
1. OWASP 2025 thêm dấu hiệu: "The application is missing or mishandling errors and other exceptional conditions, such that the system is unaware there was an
error, and is therefore unable to log there was a problem". Ví dụ quen thuộc: try { verifySignature(); } catch (Exception $e) { /* bỏ qua */ }
— chữ ký webhook sai bị nuốt, request đi tiếp, không có dòng log nào. Một lỗi đồng thời là A10 (fail open) và A09 (không dấu vết).
2. Phần phòng thủ OWASP đặt ở A09: "all transactions that throw an error are rolled back and started over. Always fail closed". Ở góc logging: mọi
nhánh catch trong code bảo mật phải hoặc ném lại, hoặc ghi một sự kiện có tên (ví dụ crypt_decrypt_fail, authn_token_reuse)
rồi từ chối. Review rule: catch rỗng trong module auth, thanh toán, webhook là lỗi chặn merge. Chi tiết xử lý ngoại lệ ở
Bài 10.
3.8 Hình động: một sự kiện bảo mật đi từ dòng code tới người trực
Hình dưới là luồng phòng thủ đầy đủ cho một sự kiện authn_login_fail: thẻ xanh đi qua sáu chặng theo thứ tự các mục 4.1 → 4.6 cài đặt. Thẻ đỏ là
thiết kế sai phổ biến nhất: log chỉ nằm trong file trên app node — khi node bị chiếm, dấu vết bị xoá cùng lúc, và vì không có rule nào nên cũng không có ai được
báo.
prefers-reduced-motion: khi tắt hoạt ảnh, hiện trạng thái cuối.Cách nói khi phỏng vấn: "Em hình dung một sự kiện bảo mật đi sáu chặng. Code phát sự kiện có tên chuẩn và đủ ngữ cảnh, kể cả khi control chặn thành công. Lọc chỉ giữ trường đã khai báo và ghi dạng JSON một dòng. Agent đưa nó rời khỏi máy trong vài giây. Kho tập trung chỉ cho ứng dụng quyền gửi, có hạn giữ và bản sao. Có rule theo tình huống, so với cùng giờ tuần trước. Và cuối cùng là người trực nhận cảnh báo kèm hướng dẫn. Thiết kế hay hỏng nhất là chỉ ghi file trên máy chủ: máy bị chiếm thì dấu vết mất theo, và không có rule thì cũng chẳng ai biết."
4. Mẫu code sai và mẫu code đúng (Laravel + NestJS)
Code dưới đây là mẫu rút gọn để đọc trong phỏng vấn, không phải module hoàn chỉnh. API Laravel 13, NestJS, nestjs-pino, pino đã tra qua Context7 (docs chính
thức); một số chi tiết đối chiếu thêm với source laravel/framework nhánh 13.x (Foundation/Exceptions/Handler.php,
Log/LogManager.php, Auth/Events/*) và Monolog LineFormatter. Tên channel, tên bảng, con số là ví dụ minh hoạ cho LMS.
Toàn bộ mục này chưa chạy trong lab (series không lab).
4.1 Ghi sự kiện xác thực và phân quyền — kênh log bảo mật riêng, ghi cả khi chặn thành công
// ===== SAI (Laravel) — chỉ ghi khi thành công, ghi nguyên input =====
public function login(Request $request)
{
if (Auth::attempt($request->only('email', 'password'))) {
Log::info('User logged in', $request->all()); // có cả password trong mảng
return redirect('/dashboard');
}
return back()->withErrors(['email' => 'Sai thông tin']); // thất bại: không dấu vết
}
// ===== ĐÚNG (Laravel 13) — subscriber nghe event có sẵn của framework =====
// config/logging.php: channel 'security' (JSON, xem 4.4)
class LogAuthEvents
{
public function onFailed(Failed $e): void
{
$login = mb_strtolower((string) ($e->credentials['email'] ?? ''));
Log::channel('security')->warning('authn_login_fail', [
'event' => 'authn_login_fail',
'user_id' => $e->user?->getAuthIdentifier(), // null nếu tài khoản không tồn tại
'login_hmac' => hash_hmac('sha256', $login, config('logging.pseudonym_key')),
'guard' => $e->guard,
]);
}
public function onLockout(Lockout $e): void
{
Log::channel('security')->warning('authn_login_fail_max', [
'event' => 'authn_login_fail_max',
'ip' => $e->request->ip(),
]);
}
public function onLogin(Login $e): void
{
Log::channel('security')->info('authn_login_success', [
'event' => 'authn_login_success',
'user_id' => $e->user->getAuthIdentifier(),
'guard' => $e->guard,
]);
}
public function subscribe(Dispatcher $events): array
{
return [
Failed::class => 'onFailed',
Lockout::class => 'onLockout',
Login::class => 'onLogin',
];
}
}
Cách nói khi phỏng vấn: "Bản sai có hai lỗi cùng lúc: đăng nhập thất bại không để lại gì, còn đăng nhập thành công thì ghi nguyên mảng input, tức là ghi cả mật khẩu. Bản đúng không sửa từng controller mà nghe các event Laravel phát sẵn khi đăng nhập thành công, thất bại và bị khoá, rồi ghi vào một kênh log bảo mật riêng với tên sự kiện chuẩn. Email người gõ vào em không ghi nguyên văn mà ghi mã băm có khoá, vì người dùng hay gõ nhầm mật khẩu vào ô email, mà vẫn đếm được bao nhiêu lần thử nhắm vào cùng một tài khoản."
Ba chi tiết khi bị hỏi sâu:
- Danh sách event có sẵn. Docs Laravel 13 liệt kê:
Registered,Attempting,Authenticated,Login,Failed,Validated,Verified,Logout,CurrentDeviceLogout,OtherDeviceLogout,Lockout,PasswordReset,PasswordResetLinkSent. Source 13.x:Failedcóguard,user,credentials(đánh dấu#[\SensitiveParameter]);Lockoutmangrequest;Logincóguard,user,remember. Vìcredentialschứa cả mật khẩu, listener phải lấy đúng trường cần, không bao giờ ghi nguyên mảng. - Vì sao kênh riêng. Log bảo mật có thời hạn giữ, quyền đọc và đích gửi khác log vận hành (mục 5.2); trộn chung thì hoặc giữ cả hai quá lâu (tốn và lộ), hoặc xoá log bảo mật quá sớm.
- Request id, IP, URL không cần truyền tay — đã nằm trong context chung (mục 4.2).
// ===== Phân quyền bị từ chối (Laravel 13) =====
// Mặc định: AuthorizationException, AuthenticationException, TokenMismatchException,
// ValidationException nằm trong $internalDontReport của Handler → KHÔNG tới log nào.
// bootstrap/app.php
->withExceptions(function (Exceptions $exceptions) {
$exceptions->stopIgnoring([AuthorizationException::class, TokenMismatchException::class]);
$exceptions->report(function (AuthorizationException $e) {
Log::channel('security')->warning('authz_fail', [
'event' => 'authz_fail',
'user_id' => auth()->id(),
'route' => request()->route()?->getName(),
]);
return false; // không đẩy tiếp vào log lỗi / error tracker
});
$exceptions->report(function (TokenMismatchException $e) {
Log::channel('security')->notice('session_csrf_fail', ['event' => 'session_csrf_fail']);
return false;
});
})
Cách nói khi phỏng vấn: "Laravel mặc định không báo cáo các exception về đăng nhập, phân quyền, CSRF và validate, vì với log vận hành chúng không phải lỗi của server. Nhưng với bảo mật thì đó lại là những thất bại quan trọng nhất. Nên em bỏ hai loại phân quyền và CSRF ra khỏi danh sách bỏ qua, rồi gắn một hàm báo cáo riêng: ghi một sự kiện có tên vào kênh bảo mật và dừng ở đó, để không làm đầy log lỗi và công cụ theo dõi lỗi bằng hàng nghìn lần bị từ chối."
Lưu ý thiết kế: Gate::after (docs 13.x: closure nhận $user, $ability, $result, $arguments)
cũng thấy mọi kết quả kiểm quyền, nhưng nó chạy cả cho các lần kiểm chỉ để hiển thị giao diện (ẩn/hiện nút bằng @can) — ghi mọi
false ở đó sẽ sinh hàng chục bản ghi mỗi lần tải trang mà không có ai cố vượt quyền. Ghi ở chỗ exception (người dùng thật sự bị chặn khi
thực hiện hành động) cho tín hiệu sạch hơn. Hành vi return false dừng báo cáo mặc định và việc stopIgnoring có hiệu lực với
internalDontReport — đọc từ source Handler.php 13.x, chưa chạy.
// ===== ĐÚNG (NestJS 11) — ghi ở guard/filter, cả thành công lẫn thất bại =====
@Injectable()
export class AuthService {
private readonly security = new Logger('security');
async login(dto: LoginDto, ip: string) {
const user = await this.users.findByEmail(dto.email);
const ok = !!user && (await argon2.verify(user.passwordHash, dto.password));
if (!ok) {
this.security.warn({
event: 'authn_login_fail',
userId: user?.id ?? null,
loginHmac: hmacSha256(dto.email.toLowerCase(), this.cfg.pseudonymKey),
ip,
});
throw new UnauthorizedException(); // thông báo chung, không nói "sai email" hay "sai mật khẩu"
}
this.security.log({ event: 'authn_login_success', userId: user.id, ip });
return this.issueTokens(user);
}
}
@Catch(ForbiddenException)
export class AuthzFailFilter implements ExceptionFilter {
private readonly security = new Logger('security');
catch(ex: ForbiddenException, host: ArgumentsHost) {
const http = host.switchToHttp();
const req = http.getRequest();
this.security.warn({ event: 'authz_fail', userId: req.user?.id ?? null, method: req.method, route: req.route?.path });
http.getResponse().status(403).json({ statusCode: 403, message: 'Forbidden' });
}
}
Cách nói khi phỏng vấn: "Bên NestJS em ghi ngay trong service đăng nhập cả hai nhánh, thành công và thất bại, bằng một logger tên security và một object có tên sự kiện, không ghép chuỗi. Bị từ chối quyền thì một exception filter bắt lỗi 403, ghi sự kiện authz_fail kèm user và route rồi trả lời như bình thường. Thông báo trả về cho người dùng vẫn chung chung, chi tiết chỉ nằm trong log."
flowchart TD
L["Đăng nhập / kiểm quyền"] --> R{"Kết quả"}
R -- "thành công" --> S["authn_login_success<br/>(info)"]
R -- "sai mật khẩu" --> F["authn_login_fail<br/>(warn, login_hmac)"]
R -- "bị khoá" --> M["authn_login_fail_max<br/>(warn)"]
R -- "403 khi thực hiện" --> Z["authz_fail<br/>(warn, route)"]
S --> CH["Kênh security<br/>tách khỏi log vận hành"]
F --> CH
M --> CH
Z --> CH
4.2 Correlation id và ngữ cảnh — một id do mình sinh, đi theo cả sang queue
// ===== SAI (Laravel) — tin nguyên header từ client =====
public function handle(Request $request, Closure $next): Response
{
Log::withContext(['request_id' => $request->header('X-Request-Id')]); // chuỗi tuỳ ý vào mọi dòng log
return $next($request);
}
// ===== ĐÚNG (Laravel 13) — Context: tự gắn vào log, tự theo sang job =====
public function handle(Request $request, Closure $next): Response
{
$incoming = (string) $request->header('X-Request-Id'); // Nginx LB đã ghi đè header này
$requestId = Str::isUuid($incoming) ? $incoming : (string) Str::uuid();
Context::add('request_id', $requestId);
Context::add('node', gethostname());
Context::add('route', $request->route()?->getName());
$response = $next($request);
$response->headers->set('X-Request-Id', $requestId); // người dùng báo lỗi kèm mã này
return $response;
}
// Sau khi đăng nhập (middleware chạy sau auth):
Context::add('user_id', $request->user()?->id);
Context::addHidden('tenant_secret', $tenant->apiKey); // dùng được trong code, KHÔNG vào log
Cách nói khi phỏng vấn: "Bản sai lấy nguyên header mã request từ phía client đưa vào mọi dòng log, tức là một chuỗi người dùng kiểm soát được rải khắp log. Bản đúng chỉ chấp nhận giá trị đúng định dạng UUID — load balancer của mình đã ghi đè header đó — sai thì tự sinh mới. Em dùng Context của Laravel vì mọi thứ thêm vào đó tự xuất hiện trong từng dòng log của request, và tự đi theo sang job trong queue, nên log của worker cũng mang đúng mã đó. Dữ liệu cần dùng mà không được ghi ra log thì để ở phần ẩn của Context."
Các điểm đã tra docs Laravel 13 (Context): dữ liệu thêm bằng Context::add "is automatically appended as metadata to any log entries";
khi dispatch job, context "is captured and shared with the job" rồi được hydrate lại khi job chạy; Context::addHidden lưu dữ liệu
"not appended to logs". So với Log::withContext (chỉ channel hiện tại) và Log::shareContext (mọi channel, nhưng docs
nhắc phải dùng job middleware nếu cần trong queue), Context là lựa chọn gọn nhất cho request id xuyên HTTP → queue. Nginx ghi đè header bằng
proxy_set_header/fastcgi_param với biến $request_id — cú pháp cụ thể tuỳ cách LB nối tới PHP-FPM, chưa verify trong bài.
// ===== ĐÚNG (NestJS 11 + nestjs-pino) =====
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
LoggerModule.forRoot({
pinoHttp: {
genReqId: (req, res) => {
const h = req.headers['x-request-id'];
const id = typeof h === 'string' && UUID.test(h) ? h : randomUUID();
res.setHeader('X-Request-Id', id);
return id;
},
customProps: () => ({ app: 'student-portal', node: hostname() }),
},
});
// Sau guard xác thực:
@Injectable()
export class UserContextInterceptor implements NestInterceptor {
constructor(private readonly logger: PinoLogger) {}
intercept(ctx: ExecutionContext, next: CallHandler) {
const user = ctx.switchToHttp().getRequest().user;
if (user) this.logger.assign({ userId: user.id }); // mọi log sau đó trong request có userId
return next.handle();
}
}
// Job BullMQ: đưa req.id vào job data, processor tạo child logger với requestId đó.
Cách nói khi phỏng vấn: "Bên NestJS em dùng nestjs-pino: hàm sinh mã request nhận header từ load balancer nếu đúng định dạng UUID, không thì sinh mới, rồi trả lại cho client trong header. Sau khi xác thực, một interceptor gắn thêm mã người dùng vào ngữ cảnh log của request, nên các log ở service phía sau tự có trường đó. Queue thì Node không tự mang ngữ cảnh sang như Laravel, nên em đưa mã request vào dữ liệu job và processor tạo logger con với mã đó."
Đã tra docs nestjs-pino: genReqId trong pinoHttp; PinoLogger.assign gộp trường vào mọi log sau đó trong context hiện tại
và "throws an error if called outside a request scope"; mặc định không gộp vào dòng log "request completed" trừ khi bật assignResponse: true.
4.3 Không để dữ liệu nhạy cảm vào log — allowlist ở nguồn, redact là lớp hai
// ===== SAI (Laravel) =====
Log::error('Payment callback failed', ['request' => $request->all(), 'headers' => $request->headers->all()]);
Log::info("Reset link sent: {$url}"); // URL chứa token đặt lại mật khẩu
public function connect(string $host, string $password) // exception ở đây in $password trong stack trace
{ /* ... */ }
// ===== ĐÚNG (Laravel 13 / PHP 8.2+) =====
Log::channel('security')->error('payment_callback_fail', [
'event' => 'payment_callback_fail',
'provider' => 'vnpay',
'order_id' => $request->integer('order_id'), // chỉ các trường đã khai báo
'reason' => 'signature_mismatch',
'body_bytes' => strlen($request->getContent()), // kích thước thay cho nội dung
]);
Log::info('authn_password_reset_link_sent', ['user_id' => $user->id]); // không ghi URL
public function connect(string $host, #[\SensitiveParameter] string $password)
{ /* trace hiện Object(SensitiveParameterValue) thay cho giá trị */ }
Cách nói khi phỏng vấn: "Ba chỗ rò rỉ hay gặp nhất là ghi nguyên request và header, ghi nguyên URL có token, và stack trace in giá trị tham số. Bản đúng ghi một sự kiện có tên với đúng những trường đã khai báo: mã đơn, lý do, kích thước body thay cho nội dung. Link đặt lại mật khẩu thì chỉ ghi là đã gửi cho ai. Tham số mật khẩu thì đánh dấu thuộc tính nhạy cảm của PHP 8.2 để stack trace thay giá trị bằng một object rỗng."
// ===== NestJS — pino redact làm lớp thứ hai cho log vận hành =====
LoggerModule.forRoot({
pinoHttp: {
redact: {
paths: [
'req.headers.authorization',
'req.headers.cookie',
'res.headers["set-cookie"]',
'*.password',
'*.token',
],
remove: true, // bỏ hẳn key, không để lại '[Redacted]'
},
serializers: {
req: (req) => ({ id: req.id, method: req.method, url: req.url.split('?')[0] }), // bỏ query string
},
},
});
Cách nói khi phỏng vấn: "Bên Node em cấu hình pino che theo đường dẫn: header xác thực, cookie, các trường tên password và token, và chọn bỏ hẳn khoá thay vì để chữ đã che. Quan trọng hơn là hàm định dạng request chỉ lấy mã, phương thức và đường dẫn không kèm query string, vì token hay nằm trong query string. Em vẫn coi lớp che này là lớp thứ hai: nó chỉ bắt được những tên trường mình đã nghĩ tới."
Đã tra docs pino: redact nhận mảng đường dẫn hoặc object { paths, censor, remove }; censor mặc định '[Redacted]';
remove: true bỏ cả key lẫn value; overhead ~2% khi không dùng wildcard nhưng "wildcard redaction does carry a non-trivial cost" (ví dụ
trong docs: 50%); và cảnh báo "Never allow user input to define redacted paths". Vì wildcard tốn, với log có tải lớn nên liệt kê đường dẫn tường minh.
flowchart TD SRC["Code ghi sự kiện"] --> AL["Lớp 1: allowlist trường<br/>định danh → HMAC"] AL --> RD["Lớp 2: redact theo đường dẫn<br/>header, password, token"] RD --> SER["Serializer request<br/>bỏ query string, bỏ body"] SER --> OUT["Log đi ra"] TR["Stack trace"] --> SP["Thuộc tính SensitiveParameter<br/>thay giá trị tham số"] SP --> OUT
4.4 Chống log injection — log có cấu trúc, giá trị người dùng ở trường riêng
// ===== SAI (Laravel) — ghép input vào message, ghi ra file dạng dòng =====
Log::warning('Login failed for ' . $request->input('email') . ' from ' . $request->userAgent());
// LineFormatter mặc định của Laravel cho phép xuống dòng trong bản ghi (allowInlineLineBreaks = true)
// ===== ĐÚNG (Laravel 13) — channel JSON ra stderr cho agent thu, input chỉ nằm trong context =====
// config/logging.php
'security' => [
'driver' => 'monolog',
'handler' => Monolog\Handler\StreamHandler::class,
'handler_with' => ['stream' => 'php://stderr'],
'formatter' => Monolog\Formatter\JsonFormatter::class,
'level' => 'info',
],
// Khi ghi:
Log::channel('security')->warning('authn_login_fail', [
'event' => 'authn_login_fail',
'user_agent' => Str::limit((string) $request->userAgent(), 256), // giới hạn độ dài, vẫn ghi sự kiện
]);
Cách nói khi phỏng vấn: "Bản sai ghép email và trình duyệt của người dùng vào câu thông báo, ghi ra file dạng dòng mà mặc định của Laravel còn cho phép xuống dòng trong bản ghi, nên một giá trị có ký tự xuống dòng sẽ trông như một bản ghi mới. Bản đúng dùng kênh riêng định dạng JSON: thông báo là tên sự kiện cố định, còn giá trị người dùng nằm trong trường riêng, bộ mã hoá JSON tự thoát ký tự điều khiển. Trường quá dài thì cắt bớt chứ không bỏ cả sự kiện."
Các điểm đã tra: docs Laravel 13 — driver monolog nhận handler, handler_with, formatter,
formatter_with; skeleton config/logging.php 13.x có sẵn channel stderr với formatter => env('LOG_STDERR_FORMATTER').
Source LogManager::formatter() trả new LineFormatter(null, $this->dateFormat, true, true, true), tham số thứ ba là
allowInlineLineBreaks. Hành vi JsonFormatter thoát ký tự xuống dòng thành chuỗi thoát (một bản ghi một dòng) — theo cách
json_encode hoạt động, chưa chạy thử với Monolog bản đang dùng.
// ===== NestJS 11 =====
// SAI: ghép chuỗi, logger mặc định dạng văn bản có màu
this.logger.warn(`Login failed for ${dto.email} from ${req.headers['user-agent']}`);
// ĐÚNG: ConsoleLogger json (hoặc pino), message cố định, dữ liệu là object
const app = await NestFactory.create(AppModule, {
logger: new ConsoleLogger({ json: true, logLevels: ['log', 'warn', 'error', 'fatal'] }),
});
this.logger.warn({ event: 'authn_login_fail', loginHmac, userAgent: ua.slice(0, 256) });
Cách nói khi phỏng vấn: "Bên NestJS, logger dựng sẵn có chế độ JSON từ bản 11: bật lên thì mỗi bản ghi được tuần tự hoá thành một dòng JSON hợp lệ, không màu, không xuống dòng. Em không ghép chuỗi vào thông báo mà truyền một object có tên sự kiện. Nếu dùng pino thì đầu ra vốn đã là JSON mỗi dòng một bản ghi."
Đã tra docs NestJS (Logger): "When json is set to true, the ConsoleLogger defaults colors to
false and compact to true. With those two values, each entry is serialized with JSON.stringify(), so the
output is valid JSON"; nếu bật lại colors hoặc tắt compact thì đầu ra "is no longer strict JSON" — nghĩa là cấu hình dev
không được mang lên production. Phía hiển thị: trang xem log (Grafana, Kibana, trang admin tự viết) phải escape HTML khi render; trang tự viết là chỗ hay quên nhất.
4.5 Audit trail chống sửa — chỉ thêm dòng, chuỗi hash, neo ra ngoài
Phần ghi bất đồng bộ, partition theo tháng và ẩn danh hoá đã có ở bài Activity log — mục này chỉ thêm lớp chống sửa. SQL dưới viết cho PostgreSQL và chưa chạy trên container test (series không lab; CLAUDE.md yêu cầu verify SQL — đây là điểm mở trong danh sách Chưa verify).
-- ===== ĐÚNG (PostgreSQL) — bảng audit chỉ cho thêm dòng =====
CREATE TABLE audit_events (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
occurred_at timestamptz NOT NULL DEFAULT now(),
event text NOT NULL, -- 'sensitive_update', 'authz_change'
actor_id bigint,
object_type text, object_id bigint,
data jsonb NOT NULL DEFAULT '{}', -- trước/sau, chỉ trường đã khai báo
request_id uuid,
prev_hash char(64) NOT NULL,
row_hash char(64) NOT NULL
);
CREATE TABLE audit_chain_head (id int PRIMARY KEY CHECK (id = 1), row_hash char(64) NOT NULL);
REVOKE ALL ON audit_events, audit_chain_head FROM app_writer;
GRANT INSERT, SELECT ON audit_events TO app_writer;
GRANT SELECT, UPDATE ON audit_chain_head TO app_writer;
CREATE FUNCTION audit_block_change() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
RAISE EXCEPTION 'audit_events is append-only';
END $$;
CREATE TRIGGER audit_no_update_delete BEFORE UPDATE OR DELETE ON audit_events
FOR EACH ROW EXECUTE FUNCTION audit_block_change();
CREATE TRIGGER audit_no_truncate BEFORE TRUNCATE ON audit_events
FOR EACH STATEMENT EXECUTE FUNCTION audit_block_change();
Cách nói khi phỏng vấn: "Bảng audit có hai lớp chặn sửa. Lớp quyền: tài khoản database của ứng dụng chỉ được thêm và đọc, không được sửa, xoá. Lớp trigger: kể cả ai đó lỡ cấp quyền, mọi lệnh sửa, xoá hay xoá sạch bảng đều bị từ chối. Mỗi dòng còn lưu mã băm của dòng trước và của chính nó để phát hiện sửa giữa chừng. Em nói rõ giới hạn: người có quyền chủ bảng vẫn tắt được trigger, nên lớp cuối là đẩy bản sao và mã băm mới nhất ra một hệ thống khác."
// ===== Ghi audit có chuỗi hash (Laravel 13) — trong CÙNG transaction với thao tác =====
public function updateGrade(Grade $grade, float $new, User $actor): void
{
DB::transaction(function () use ($grade, $new, $actor) {
$old = $grade->score;
$grade->update(['score' => $new]);
$prev = DB::table('audit_chain_head')->where('id', 1)->lockForUpdate()->value('row_hash');
$row = [
'event' => 'sensitive_update',
'actor_id' => $actor->id,
'object_type' => 'grade',
'object_id' => $grade->id,
'data' => json_encode(['from' => $old, 'to' => $new], JSON_THROW_ON_ERROR),
'request_id' => Context::get('request_id'),
];
$hash = hash('sha256', $prev . json_encode($row, JSON_THROW_ON_ERROR));
DB::table('audit_events')->insert($row + ['prev_hash' => $prev, 'row_hash' => $hash]);
DB::table('audit_chain_head')->where('id', 1)->update(['row_hash' => $hash]);
}); // audit ghi lỗi → sửa điểm rollback: fail closed cho thao tác bắt buộc có audit
}
Cách nói khi phỏng vấn: "Sửa điểm và ghi audit nằm trong cùng một giao dịch: ghi audit không được thì việc sửa điểm cũng huỷ, vì với điểm số, không có dấu vết thì không được phép thay đổi. Mã băm của dòng mới được tính từ mã băm của dòng trước cộng nội dung dòng này. Dòng trước em không lấy bằng cách khoá dòng cuối của bảng audit mà khoá một bảng một dòng giữ đầu chuỗi, để hai giao dịch đồng thời buộc phải xếp hàng chứ không cùng nối vào một dòng cũ. Cái giá là mọi thao tác có audit đi qua một điểm tuần tự."
Bốn chi tiết khi bị hỏi sâu:
- Vì sao không khoá "dòng cuối" của
audit_events. Hai transaction cùng chọn dòng có id lớn nhất kèmFOR UPDATE: một cái chờ cái kia. Theo hiểu biết về cách PostgreSQL đánh giá lại dòng bị khoá sau khi chờ, cái thứ hai vẫn nhận lại dòng cũ (dòng mới do transaction kia tạo không được chọn lại) → hai dòng cùngprev_hash, chuỗi rẽ nhánh. Bảng đầu chuỗi một dòng tránh được vì cả hai cùng khoá và cập nhật đúng một dòng — chưa verify bằng hai session song song. - Điểm tuần tự là giới hạn throughput. Mọi thao tác có audit xếp hàng qua một dòng. Với vài chục thao tác nhạy cảm mỗi giây (sửa điểm, đổi quyền) là chấp nhận được; với hàng nghìn sự kiện/giây thì chia chuỗi (mỗi partition/ngày một chuỗi), hoặc niêm phong theo lô: gom sự kiện mỗi phút, tính một hash cho cả lô.
- Canonical form. Hash tính trên chuỗi JSON nên thứ tự key và cách mã hoá số phải cố định; kiểm lại chuỗi ở một ngôn ngữ khác (job kiểm tra viết bằng Node) phải tạo ra đúng cùng chuỗi byte — nếu không, mọi dòng đều "bị sửa".
- Neo. Một job định kỳ (ví dụ mỗi giờ) ghi
row_hashcủa đầu chuỗi ra một nơi đội ứng dụng không sửa được (kho log tập trung, object storage khoá ghi). Kiểm tra định kỳ: tính lại chuỗi tới mốc neo, lệch là cảnh báo loại "một lần là đủ".
// ===== NestJS 11 (TypeORM) — cùng thiết kế, cùng transaction =====
await this.dataSource.transaction(async (m) => {
await m.query('UPDATE grades SET score = $1 WHERE id = $2', [next, gradeId]);
const [{ row_hash: prev }] = await m.query('SELECT row_hash FROM audit_chain_head WHERE id = 1 FOR UPDATE');
const row = { event: 'sensitive_update', actor_id: actorId, object_type: 'grade', object_id: gradeId,
data: JSON.stringify({ from: prevScore, to: next }), request_id: requestId };
const hash = createHash('sha256').update(prev + JSON.stringify(row)).digest('hex');
await m.query(
`INSERT INTO audit_events (event, actor_id, object_type, object_id, data, request_id, prev_hash, row_hash)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
[row.event, row.actor_id, row.object_type, row.object_id, row.data, row.request_id, prev, hash],
);
await m.query('UPDATE audit_chain_head SET row_hash = $1 WHERE id = 1', [hash]);
});
Cách nói khi phỏng vấn: "Bên NestJS em làm đúng cùng thiết kế trong một giao dịch: cập nhật điểm, khoá dòng đầu chuỗi, tính mã băm từ mã băm cũ cộng nội dung mới, thêm dòng audit, cập nhật đầu chuỗi. Mọi câu lệnh đều truyền tham số chứ không ghép chuỗi. Điều quan trọng là hai bên Laravel và Node phải tuần tự hoá nội dung giống hệt nhau thì job kiểm tra chuỗi mới không báo động giả."
flowchart TD
T["Transaction sửa điểm"] --> U["UPDATE grades"]
U --> H["Khoá audit_chain_head<br/>đọc prev_hash"]
H --> C["row_hash = SHA-256(prev_hash + nội dung)"]
C --> I["INSERT audit_events<br/>(app chỉ có quyền INSERT)"]
I --> N["Cập nhật đầu chuỗi"]
N --> CM{"Commit được?"}
CM -- "không" --> RB["Rollback cả sửa điểm<br/>(fail closed)"]
CM -- "có" --> AN["Job mỗi giờ: neo hash đầu chuỗi ra hệ thống khác"]
4.6 Từ log thành cảnh báo — đếm sự kiện, rule theo baseline, honeytoken, giám sát chính log
CV có Prometheus + Grafana, nên cách ít thay đổi nhất là ứng dụng đếm sự kiện bảo mật thành metric (log giữ chi tiết để điều tra, metric để cảnh báo). Nhãn chỉ có tên sự kiện — không có user id hay IP, vì mỗi giá trị nhãn mới là một chuỗi thời gian mới (cardinality, xem Bộ công cụ để vận hành).
// ===== NestJS (prom-client) =====
const securityEvents = new Counter({
name: 'security_events_total',
help: 'Security events by OWASP vocabulary name',
labelNames: ['event'],
});
// trong AuthService / AuthzFailFilter, cạnh dòng log:
securityEvents.inc({ event: 'authn_login_fail' });
// ===== Laravel — cùng counter qua thư viện Prometheus cho PHP (storage Redis/APCu vì PHP-FPM không giữ state giữa request) =====
// $counter->inc(['authn_login_fail']); // API cụ thể tuỳ thư viện — chưa verify
Cách nói khi phỏng vấn: "Cạnh mỗi dòng log bảo mật em tăng một bộ đếm có nhãn duy nhất là tên sự kiện. Log giữ chi tiết ai, từ đâu để điều tra; bộ đếm thì rẻ, để cảnh báo. Em không gắn mã người dùng hay IP làm nhãn, vì mỗi giá trị nhãn là một chuỗi số liệu riêng và Prometheus sẽ phình ra. Bên PHP thì bộ đếm phải lưu ở Redis hoặc bộ nhớ dùng chung, vì mỗi request PHP-FPM không giữ trạng thái."
# ===== Rule cảnh báo (Prometheus) =====
groups:
- name: security
rules:
- alert: LoginFailureSurge
expr: |
sum(increase(security_events_total{event="authn_login_fail"}[10m]))
> 5 * sum(increase(security_events_total{event="authn_login_fail"}[10m] offset 1w))
and
sum(increase(security_events_total{event="authn_login_fail"}[10m])) > 200
for: 5m
labels: { severity: page }
annotations:
summary: "Đăng nhập thất bại tăng bất thường so với cùng giờ tuần trước"
runbook_url: "https://wiki.example.internal/runbooks/login-failure-surge"
- alert: HoneytokenTouched
expr: increase(security_events_total{event="honeytoken_used"}[5m]) > 0
labels: { severity: page }
annotations:
runbook_url: "https://wiki.example.internal/runbooks/honeytoken"
Cách nói khi phỏng vấn: "Rule đầu tiên bật khi số lần đăng nhập thất bại trong mười phút gần nhất vừa lớn hơn năm lần con số cùng mười phút đó của tuần trước, vừa vượt một mức sàn hai trăm lần, và tình trạng kéo dài năm phút. So với tuần trước để ngày cuối hạn nộp bài, khi sinh viên đăng nhập sai nhiều một cách hợp lệ, không bị coi là tấn công. Mức sàn để giờ vắng, chỉ hai lần thành mười lần, không đánh thức ai. Rule thứ hai thì chỉ cần bẫy honeytoken bị chạm một lần là gọi người, vì nghiệp vụ bình thường không bao giờ chạm vào nó."
Vì sao hai ngưỡng khác nhau về độ lớn: điều kiện "gấp 5 lần tuần trước" đo tỉ lệ — nó nhạy ở giờ đông nhưng vô nghĩa ở giờ vắng (2 → 10 lần cũng là gấp 5).
Mức sàn 200 đo số tuyệt đối và chỉ có nhiệm vụ chặn đúng trường hợp giờ vắng đó, nên nó phải đặt cao hơn hẳn mức thất bại bình thường của giờ vắng (ví dụ minh
hoạ: ~10–20 lần/10 phút lúc 2 giờ sáng) nhưng thấp hơn hẳn quy mô một đợt tấn công (hàng nghìn). Hai con số là ví dụ minh hoạ; số thật lấy từ dữ liệu 2–4 tuần của
chính hệ thống. Lưu ý offset 1w cần dữ liệu tuần trước còn trong Prometheus (retention ≥ 8 ngày) — tuần đầu triển khai rule này không bật được.
Biểu thức chưa chạy trên Prometheus thật trong bài.
# ===== Giám sát chính đường log: node im lặng =====
- alert: SecurityLogSilentOnNode
expr: |
sum by (instance) (rate(log_records_shipped_total{job="lms-app"}[15m])) == 0
and on (instance) up{job="lms-app"} == 1
for: 15m
labels: { severity: ticket }
annotations:
summary: "Node vẫn chạy nhưng không gửi log 15 phút: agent chết hoặc bị tắt"
Cách nói khi phỏng vấn: "Rule này bắt trường hợp một node vẫn đang phục vụ — vẫn trả lời khi Prometheus hỏi — nhưng suốt mười lăm phút không gửi một bản ghi log nào. Đó hoặc là agent gửi log đã chết, hoặc ai đó đã tắt nó, và cả hai đều cần biết. Em ghép với trạng thái node còn sống để không báo nhầm khi node bị tắt có chủ đích; node biến mất hẳn thì đã có cảnh báo riêng."
Tên metric log_records_shipped_total là giả định — mỗi agent thu log (Vector, Fluent Bit, Promtail, Grafana Alloy...) có metric riêng với tên riêng,
phải tra docs của agent đang dùng (chưa verify). Ý thiết kế mới là phần quan trọng: phát hiện "logging đã dừng" từ bên ngoài node, đúng yêu cầu
"detect whether logging has stopped" của cheat sheet.
Honeytoken trong LMS (ví dụ minh hoạ): một tài khoản giảng viên giả không có trong danh sách giảng dạy nào (đăng nhập vào nó là
honeytoken_used); một API key giả nằm trong file cấu hình mẫu của repo nội bộ; một dòng sinh viên giả trong bảng mà không màn hình nào truy vấn tới
nếu không cố ý quét toàn bảng. Điều kiện để honeytoken có giá trị: không quy trình hợp lệ nào chạm vào nó (job export toàn bảng hằng đêm sẽ biến nó thành nguồn
cảnh báo giả — phải loại trừ tường minh) và đội biết cảnh báo đó nghĩa là gì (runbook).
flowchart TD EV["Sự kiện bảo mật trong code"] --> LG["Log JSON: chi tiết để điều tra"] EV --> CT["Counter security_events_total, nhãn event<br/>không có user/IP làm nhãn"] CT --> R1["Rule tỉ lệ so tuần trước<br/>+ mức sàn tuyệt đối"] CT --> R2["Rule một lần là đủ<br/>(honeytoken, nâng quyền admin)"] AG["Metric của agent log"] --> R3["Rule node im lặng"] R1 --> AM["Alertmanager → page/ticket<br/>kèm runbook_url"] R2 --> AM R3 --> AM
5. Phòng thủ nhiều lớp — mỗi lớp chặn gì, không chặn gì
5.1 Bảng tổng hợp
| Lớp | Chặn / giải quyết | Không giải quyết | Hợp với |
|---|---|---|---|
| Danh sách sự kiện bảo mật có tên chuẩn (4.1) | CWE-778: thiếu sự kiện; dữ liệu nhất quán để viết rule | Sự kiện không ai nghĩ tới lúc thiết kế; chất lượng ngữ cảnh | Mọi hệ thống; đưa vào Definition of Done của tính năng có control bảo mật |
| Context chung: request id, user, node, UTC (4.2) | CWE-223: nối được chuỗi hành động qua nhiều tầng | Lệch đồng hồ (cần NTP + cảnh báo); id giả nếu tin client | Hệ có LB + nhiều node + queue (cả hai LMS trong CV) |
| Allowlist trường + HMAC định danh (4.3) | CWE-532 ở log bảo mật | Log vận hành, stack trace, access log của Nginx — cần lớp khác | Log bảo mật, audit |
Redact theo đường dẫn, serializer request, #[\SensitiveParameter] (4.3) | CWE-532 ở log vận hành và trace | Trường mới chưa có trong danh sách; dữ liệu nằm trong message tự do | Lớp thứ hai cho mọi log |
| Structured log JSON (4.4) | CWE-117 theo cấu tạo: không giả được dòng/trường | Trang xem log không escape HTML; công cụ phía sau diễn giải nội dung | Mặc định cho mọi log gửi đi tập trung |
| Ship off-host trong vài giây | Xoá dấu vết trên node bị chiếm; mất log khi node chết | Log ghi sau thời điểm node bị chiếm | Mọi hệ nhiều node |
| Kho tập trung: app chỉ được gửi, write-once, backup | Sửa/xoá log ở kho; ransomware | Người quản trị kho có toàn quyền (cần tách vai trò) | Có yêu cầu điều tra/pháp lý |
| Bảng audit chỉ INSERT + trigger + hash chain + neo (4.5) | Sửa lặng lẽ lịch sử nghiệp vụ (điểm, quyền) | Superuser DB; thao tác không đi qua code có audit (sửa tay bằng SQL) | Thao tác có giá trị pháp lý/học vụ |
| Rule theo use case + runbook (4.6) | "Có log mà không ai biết" | Tấn công không để lại sự kiện nào mình đã định nghĩa | Bắt đầu bằng 3–5 rule ít nhiễu |
| Honeytoken | Phát hiện người đã vào trong và đang dò, gần như không cảnh báo giả | Kẻ tấn công không chạm vào bẫy | Rẻ, hợp đội nhỏ |
| Giám sát đường log (node im lặng, bản ghi bị bỏ) | Logging dừng vì hỏng hoặc bị tắt | Log vẫn chảy nhưng nội dung đã bị lọc bớt | Mọi hệ có agent |
| Diễn tập: DAST/pentest/kịch bản giả lập phải bật cảnh báo | Rule chết, ngưỡng sai, runbook cũ | Kịch bản chưa từng diễn tập | Mỗi quý, hoặc sau thay đổi lớn |
5.2 Log vận hành, log bảo mật và audit trail — ba thứ khác nhau
Logging Cheat Sheet viết: "Process monitoring, audit, and transaction logs/trails etc. are usually collected for different purposes than security event logging, and this often means they should be kept separate". Gộp làm một thì phải chọn một chính sách cho cả ba và luôn sai với hai trong số đó:
| Log vận hành | Log sự kiện bảo mật | Audit trail nghiệp vụ | |
|---|---|---|---|
| Câu hỏi trả lời | Hệ thống hỏng ở đâu? | Có ai đang tấn công không? | Ai đã đổi gì, lúc nào, từ giá trị nào sang giá trị nào? |
| Ai đọc | Dev, ops | Người trực bảo mật, tech lead | Phòng đào tạo, kiểm toán, người khiếu nại điểm |
| Thời hạn giữ (minh hoạ) | 7–14 ngày (bài Bộ công cụ) | 90–365 ngày, theo thời gian phát hiện trung bình | Theo quy định học vụ/hợp đồng, có thể nhiều năm |
| Lưu ở đâu | Kho log tập trung, rẻ, có thể lấy mẫu | Kho log tập trung, không lấy mẫu, quyền đọc hẹp | Database (cùng transaction) + bản sao chống sửa |
| Mất một bản ghi thì | Không sao | Có thể mất dấu một bước tấn công | Không chấp nhận được (fail closed) |
Lấy mẫu (sampling — chỉ giữ 1/N bản ghi) là kỹ thuật hợp lệ cho log vận hành khi tải cao, nhưng phá hỏng log bảo mật: một rule đếm "thất bại liên tiếp trên cùng tài khoản" không thể chạy trên dữ liệu đã lấy mẫu.
5.3 Redact ở nguồn hay ở pipeline — hai trường phái
- Ở nguồn (trong ứng dụng) — dữ liệu nhạy cảm không bao giờ rời process. Ưu: không có bản sao nào chứa dữ liệu, kể cả buffer của agent. Nhược: mỗi ứng dụng, mỗi ngôn ngữ tự làm; dễ sót ở ứng dụng ít được chăm sóc.
- Ở pipeline (agent hoặc collector) — một chỗ lọc cho mọi nguồn, kể cả log của Nginx, Moodle, thư viện bên thứ ba mà mình không sửa được code. Cheat sheet có nhắc: "In some systems, sanitization can be undertaken post log collection, and prior to log display". Nhược: dữ liệu đã nằm trên đĩa node và trong buffer agent; regex lọc ở pipeline tốn CPU và có thể bỏ sót định dạng mới.
Cách thực tế: nguồn chịu trách nhiệm chính với log do mình viết (allowlist), pipeline là lưới an toàn cho log mình không kiểm soát (access log, log của Moodle). Không dựa hoàn toàn vào pipeline cho mật khẩu và token.
5.4 Ngưỡng tĩnh, baseline, hay hành vi/AI
Ngưỡng tĩnh ("> 1.000 lần sai/10 phút") dễ hiểu, dễ giải thích khi bị đánh thức lúc 3 giờ sáng, nhưng sai ở hệ có mùa vụ — LMS có đỉnh cuối hạn nộp bài. Baseline (so với cùng giờ tuần trước) bắt mùa vụ tuần nhưng hỏng ở ngày lễ, kỳ thi đầu tiên của học kỳ, hoặc tuần đầu sau khi thêm trường mới. Phân tích hành vi/AI học được nhiều mẫu hơn nhưng khó giải thích vì sao nó bật, và OWASP chỉ xếp nó là tuỳ chọn. Với đội 5–7 người không có SOC: ngưỡng tĩnh cho sự kiện "một lần là đủ", baseline có mức sàn cho sự kiện theo lưu lượng, và chỉ cân nhắc lớp hành vi khi hai lớp kia đã chạy ổn định và có người xử lý.
5.5 Nếu chỉ có một sprint: thứ tự ưu tiên
- Kênh log bảo mật JSON + sự kiện xác thực/phân quyền (4.1, 4.4) — rẻ nhất, mở khoá mọi thứ phía sau.
- Log rời node: agent gửi về kho tập trung, ứng dụng không có quyền xoá ở kho.
- Rà và chặn dữ liệu nhạy cảm: grep log hiện có tìm
password,token,Authorization; tắt ghi query string ở access log cho route có token (4.3). - Ba cảnh báo: đăng nhập thất bại tăng đột biến, nâng quyền admin, node ngừng gửi log — mỗi cái một runbook một trang.
- Audit trail chống sửa cho đúng một thao tác quan trọng nhất (sửa điểm), mở rộng sau.
6. Phát hiện & vận hành
6.1 Code review: mẫu cần soi
| Mẫu trong diff | Câu hỏi khi review |
|---|---|
| Control bảo mật mới (guard, policy, kiểm chữ ký, rate limit) | Nhánh từ chối có ghi sự kiện có tên không? Nhánh cho qua có cần ghi không? |
$request->all(), req.body, headers trong lời gọi log | Có thể chứa mật khẩu, token không? Đổi sang allowlist. |
| Chuỗi ghép/template literal trong message log có biến từ request | Đưa biến vào context/object, message cố định. |
catch rỗng hoặc chỉ return trong module auth, thanh toán, webhook | Fail open và không dấu vết (A09 + A10). |
Tham số tên password, secret, token trong hàm có thể ném exception | Có #[\SensitiveParameter] chưa? |
| Route mới đặt token/secret trong query string | Access log sẽ ghi nguyên; chuyển sang header/body hoặc che ở Nginx. |
| Trang admin hiển thị log hoặc audit | Có escape khi render? Ai được xem (A01)? Truy cập trang đó có được ghi? |
Thay đổi cấu hình logging (LOG_LEVEL, channel, dontReport) | Cheat sheet: không được tắt hẳn log bảo mật; thay đổi mức log phải qua change management. |
6.2 Công cụ tự động phủ được gì
- SAST — bắt được mẫu CWE-117 (biến từ request chảy vào lời gọi log) và một phần CWE-532 (biến tên
passwordvào log) bằng phân tích luồng dữ liệu. Không bắt được CWE-778 — công cụ không biết sự kiện nào đáng lẽ phải được ghi. Bộ rule cụ thể (Semgrep, SonarQube...) chưa verify trong bài. - Secret scanning trên chính log — chạy định kỳ trên mẫu log ở kho tập trung với cùng bộ pattern dùng cho repo (khoá API, JWT, chuỗi kết nối). Rẻ, bắt được rò rỉ thật đang xảy ra.
- Test tự động cho logging — test tích hợp: gọi endpoint đăng nhập với mật khẩu sai, assert kênh
securitynhận đúng một bản ghiauthn_login_failvà bản ghi không chứa chuỗi mật khẩu đã gửi. Laravel cóLog::spy()/fake qua facade; NestJS có thể inject logger giả. Đây là cách duy nhất để CWE-778 có "test đỏ" khi ai đó xoá listener. - DAST và pentest như bài diễn tập cảnh báo — OWASP coi việc quét không làm bật cảnh báo là một dấu hiệu lỗi. Mỗi lần chạy DAST trên staging (nếu staging có rule giống production): ghi lại cảnh báo nào bật, sau bao lâu.
6.3 Vận hành: con số cần theo dõi
- Độ tươi của log: độ trễ từ lúc ghi tới lúc vào kho; số bản ghi bị agent bỏ; node im lặng (4.6).
- Chất lượng cảnh báo: precision theo tháng, thời gian tới khi có người nhận, số cảnh báo không có runbook (mục tiêu 0).
- Đồng hồ: độ lệch NTP mỗi node (ví dụ cảnh báo khi > 1 giây).
- Dung lượng: tốc độ ghi log, phân vùng log, dự báo đầy đĩa như rule DiskWillFillIn4h của bài 7 cảnh báo.
- Truy cập vào log: ai đã truy vấn kho log bảo mật, xuất dữ liệu gì — cheat sheet: "All access to the logs must be recorded and monitored".
Ước lượng dung lượng (minh hoạ, lấy mốc ~400 request/giây cao điểm của LMS Uni trong CV): giả sử trung bình cả ngày ~120 request/giây, mỗi request một dòng access log ~400 byte và trung bình 0,5 dòng log ứng dụng ~600 byte. Một ngày: 120 × 86.400 ≈ 10,4 triệu request → access log ≈ 4,1 GB, log ứng dụng ≈ 3,1 GB, tổng ≈ 7,2 GB/ngày chưa nén. Nén log văn bản thường được khoảng 5–10 lần → ~0,7–1,4 GB/ngày. Giữ log vận hành 14 ngày và log bảo mật (giả sử 2% số dòng) 180 ngày: ≈ 14 × 1,4 + 180 × 0,03 ≈ 25 GB cận trên. Con số này cho thấy chi phí lưu nằm ở log vận hành, không phải log bảo mật — nên "giữ log bảo mật lâu" gần như không tốn, còn "giữ mọi thứ lâu" thì tốn.
Cách nói khi phỏng vấn: "Em ước lượng thô thế này: trung bình khoảng một trăm hai mươi request mỗi giây cả ngày là khoảng mười triệu request một ngày, mỗi request vài trăm byte access log cộng một ít log ứng dụng, ra khoảng bảy gigabyte mỗi ngày chưa nén, nén còn khoảng một gigabyte. Log bảo mật chỉ chiếm vài phần trăm số dòng, nên giữ nó nửa năm chỉ tốn vài gigabyte. Kết luận là tách chính sách giữ: log vận hành giữ hai tuần, log bảo mật giữ lâu, chứ không giữ tất cả cùng một mức."
Gánh nặng cho đội: mỗi cảnh báo page cần một người trực có quyền thực hiện bước khống chế (khoá tài khoản, thu hồi token) lúc 3 giờ sáng. Nếu đội chưa có lịch trực, cảnh báo bảo mật nên vào kênh ticket có SLA (ví dụ trong 1 giờ làm việc) — một cảnh báo page không ai nhận còn tệ hơn ticket, vì nó tạo cảm giác "đã có giám sát".
7. Áp dụng vào hệ thống trong CV
Dữ kiện từ cv-v3.md; chỗ có dấu * là giả định, CV không nêu. Giá trị Moodle đọc từ source nhánh MOODLE_405_STABLE; CV không nói LMS Uni chạy
bản Moodle nào*, nên phải kiểm lại trên bản thật.
7.1 LMS Uni — Moodle trên 5 app node, MySQL primary/replica, Redis, worker nhiều VPS, student portal NestJS, observability Prometheus + Grafana + Sentry
- Câu interviewer chắc chắn hỏi: "log ở đâu?" CV viết "dựng observability (traces, metrics, logs) với Prometheus, Grafana, Sentry" và "theo chuẩn OpenTelemetry". Prometheus là metric, Sentry là lỗi/trace — CV không nêu hệ thống lưu log tập trung*. Phải chuẩn bị câu trả lời thật: log của 5 app node (Nginx, PHP-FPM, Moodle) được gom về đâu, giữ bao lâu, ai đọc được. Nếu thật ra log vẫn nằm trên từng node*, đó chính là dấu hiệu "Logs are only stored locally" của A09 — nên nói thẳng và nêu hướng (agent + kho tập trung) thay vì để interviewer tự phát hiện.
- Moodle logstore — nguồn audit có sẵn. Bảng
logstore_standard_log(sourceinstall.xml) có các trườngeventname,component,action,crud,userid,relateduserid,courseid,contextid,other,timecreated,origin,ip, vàrealuserid— trường này ghi người thật đứng sau khi một admin dùng "Log in as" đăng nhập thay người khác, đúng loại thông tin CWE-223 đòi hỏi. Hai điểm vận hành từ sourcesettings.phpvàcleanup_task.php:loglifetimemặc định0= không bao giờ xoá (với 100.000 sinh viên, bảng này phình rất nhanh*), còn khi đặt giá trị thì task dọn dẹp chạyDELETEtheo ngày. Logstore nằm trong chính MySQL mà tài khoản Moodle có toàn quyền — nó là audit tiện cho nghiệp vụ, không phải audit chống sửa. Hướng: giữloglifetimehợp lý trên MySQL, và đẩy bản sao (hoặc dùng thêm logstore ra database ngoài) sang nơi tài khoản Moodle không xoá được*. - Sự kiện bảo mật đáng có cảnh báo ở LMS Uni (ví dụ): đăng nhập thất bại tăng so với cùng giờ tuần trước — lưu ý đỉnh cuối hạn nộp bài sinh ra rất nhiều lần đăng nhập (và sai) hợp lệ, nên rule tỉ lệ + mức sàn ở 4.6 hợp hơn ngưỡng tĩnh; gán vai trò quản lý/giảng viên ngoài đợt mở lớp; sửa điểm ngoài khung thời gian chấm; "Log in as" do tài khoản không thuộc nhóm hỗ trợ; xuất danh sách sinh viên quy mô lớn. Moodle phát event cho các hành động này và logstore ghi lại* — cần kiểm tên event cụ thể trên bản đang chạy.
- Link tải báo cáo xác thực bằng JWT (CV có nêu). Nếu JWT nằm trong query string* thì access log của Nginx LB ghi nguyên token — trong thời hạn token,
ai đọc được access log là tải được báo cáo. Hướng: che query string của route tải trong
log_formatcủa Nginx, hạn token ngắn, và vẫn kiểm quyền khi tải (Bài 8 mục 4.5). - Nhiều app node và worker VPS. Correlation id do Nginx LB sinh, đi qua Moodle/portal sang job của worker (4.2); đồng hồ các VPS đồng bộ NTP và có cảnh báo lệch*. Một job "chấm điểm" chạy trên worker phải ghi được request id của hành động gốc — không thì không nối được "ai bấm nộp" với "điểm được tính ra sao".
- Sentry. Kiểm cấu hình gửi dữ liệu cá nhân và dữ liệu request của SDK (PHP cho Moodle*, Node cho portal) — error tracker là bên thứ ba nhận stack trace, đúng điểm 6 của mục 3.3. CV không nói Sentry tự host hay dùng cloud*.
- Student portal NestJS dùng chung database (CV có nêu). Hành động sinh viên làm qua portal không đi qua Moodle nên không vào logstore của Moodle* — portal phải tự phát các sự kiện bảo mật của nó (4.1) và dùng cùng tên sự kiện để rule viết một lần cho cả hai.
7.2 LMS Micro-learning — Laravel + Livewire, NestJS, Next.js, PostgreSQL, Redis, MongoDB, MinIO, GitLab CI, Docker, 4 trường
- Bốn trường trên một codebase (CV có nêu). Mỗi bản ghi bảo mật cần trường
tenant; rule cảnh báo nên tính theo trường để một trường bị dò không bị chìm trong tổng của bốn trường; quyền đọc log theo trường nếu admin của từng trường được xem nhật ký*. - Laravel Context. Phiên bản Laravel không nêu trong CV*. Nếu ≥ 11 thì dùng
Contextcho request id/tenant/user để tự theo sang queue (4.2); nếu thấp hơn thìLog::shareContext+ job middleware như docs gợi ý. - Livewire. Action của component là một request POST tới endpoint chung của Livewire — access log chỉ thấy cùng một URL cho mọi hành động*. Muốn biết "ai sửa điểm" phải ghi sự kiện ở tầng action (4.1, 4.5), không trông vào access log.
- PostgreSQL + idempotency key + constraint ở tầng DB (CV có nêu). Audit trail chỉ INSERT với trigger chặn sửa (4.5) hợp với tư duy "đặt toàn vẹn ở tầng DB" mà CV đã nêu — có thể dùng làm câu chuyện liền mạch khi phỏng vấn.
- MinIO. Presigned URL là bí mật nằm trong URL — cùng vấn đề access log như JWT ở 7.1*. MongoDB có trong stack nhưng CV không nói dùng cho việc gì*; nếu dùng để lưu log thì phải có cùng chính sách quyền và giữ như mục 5.2.
- GitLab CI. Thay đổi cấu hình logging (mức log, channel,
dontReport) đi qua merge request có review — đúng yêu cầu "change management" của cheat sheet; cảnh báo khi biến môi trườngLOG_LEVELproduction bị đổi thành mức tắt log bảo mật*.
7.3 Trả lời mẫu
Cách nói khi phỏng vấn: "Ở LMS Uni, observability ban đầu em dựng để trả lời câu hệ thống có khoẻ không — Prometheus, Grafana, Sentry giúp giảm thời gian khắc phục sự cố. Với A09 em tách thêm một luồng: sự kiện bảo mật có tên chuẩn, ghi cả khi bị chặn, dạng JSON, rời khỏi app node ngay chứ không nằm trên từng máy trong năm node. Moodle có sẵn bảng log sự kiện với cả người thật đứng sau tính năng đăng nhập thay, nhưng nó nằm trong chính database mà Moodle có toàn quyền, và mặc định không bao giờ xoá, nên em đặt hạn giữ và đẩy bản sao ra chỗ khác. Cảnh báo thì em ưu tiên ba cái ít nhiễu: đăng nhập thất bại so với cùng giờ tuần trước có mức sàn, vì cuối hạn nộp bài sinh viên đăng nhập sai rất nhiều một cách hợp lệ; nâng quyền ngoài đợt mở lớp; và node ngừng gửi log."
8. Anti-patterns & bài học thực tế
8.1 Sự cố công khai: điều gì sai và kiểm soát nào giúp
| Sự cố | Điều gì sai (mắt xích nào) | Kiểm soát nào giúp |
|---|---|---|
| Kịch bản #1 OWASP: nhà cung cấp bảo hiểm y tế trẻ em (không nêu tên) | Không có log và giám sát; bên ngoài báo tin; hồ sơ y tế của hơn 3,5 triệu trẻ em bị truy cập và sửa; vụ việc có thể đã diễn ra từ 2013 — hơn 7 năm | Mắt xích 1 và 5: sự kiện truy cập/sửa dữ liệu nhạy cảm (sensitive_read,
sensitive_update) + rule tỉ lệ |
| Kịch bản #2 OWASP: hãng bay lớn ở Ấn Độ | Dữ liệu cá nhân hơn 10 năm của hàng triệu hành khách (hộ chiếu, thẻ) lộ tại nhà cung cấp cloud hosting bên thứ ba; hãng được báo "after some time" | Hợp đồng với bên thứ ba phải có điều khoản log, cảnh báo và thời hạn thông báo sự cố; log của dữ liệu mình nằm ở bên thứ ba vẫn là trách nhiệm của mình |
| Kịch bản #3 OWASP: hãng bay lớn ở châu Âu (báo chí đưa tin là British Airways, sự cố 2018) | Lỗ hổng ứng dụng thanh toán, hơn 400.000 bản ghi thanh toán bị thu thập; phạt 20 triệu bảng theo GDPR | Theo dõi thay đổi script/cấu hình trang thanh toán (sys/cấu hình), cảnh báo một lần là đủ |
| Equifax 2017 (báo cáo Uỷ ban Giám sát Hạ viện Mỹ, 12/2018) | Thiết bị giám sát lưu lượng mạng của hệ thống bị tấn công "had been inactive for 19 months due to an expired security certificate"; sau khi thay chứng chỉ ngày 29/07/2017, đội ngũ thấy ngay lưu lượng đáng ngờ | Giám sát chính hệ thống giám sát (4.6): một công cụ
im lặng 19 tháng mà không ai biết là đúng dấu hiệu sys_monitor_disabled không được cảnh báo |
| Twitter 2018 (blog chính thức "Keeping your account secure") | Một bug lưu mật khẩu "unmasked in an internal log" trước khi hash; khuyến nghị toàn bộ người dùng đổi mật khẩu | CWE-532: allowlist trường, test assert log không chứa mật khẩu (6.2), quét secret trên chính log |
| Facebook 2019 (Facebook Newsroom "Keeping Passwords Secure") | Rà soát bảo mật định kỳ tháng 1/2019 phát hiện một số mật khẩu người dùng "stored in a readable format within our internal data storage systems" | Như trên; và cho thấy giá trị của việc rà log định kỳ — chính lần rà đó là cách phát hiện |
| Log4Shell — CVE-2021-44228 (NVD, 12/2021) | Thư viện logging diễn giải cú pháp tra cứu nằm trong nội dung message — dữ liệu log trở thành lệnh | CWE-117 nghĩa rộng: không thành phần nào trên đường log được diễn giải dữ liệu; SCA + vá nhanh (A03) |
8.2 Anti-pattern thường gặp khi review
- "Có Sentry rồi" — error tracker chỉ thấy exception; đăng nhập sai, bị từ chối quyền, CSRF sai đều không phải exception được báo cáo (Laravel mặc định bỏ qua chúng).
- Chỉ ghi đăng nhập thành công — dấu hiệu OWASP nêu đích danh.
Log::info('...', $request->all())"tạm để debug" — ở lại production nhiều năm.- Ghép input vào message — log injection và rule viết bằng regex trên câu chữ thay đổi theo người viết.
- Log nằm trên từng node, "khi cần thì SSH vào xem" — vừa không điều tra được khi node bị chiếm, vừa không viết được rule trên 5 node.
- Cùng một mức giữ cho mọi log — hoặc tốn, hoặc mất log bảo mật trước khi kịp phát hiện (trung bình ~200 ngày).
- Bật 100 rule mẫu ngày đầu — alert fatigue trong hai tuần, sau đó mọi cảnh báo bị tắt tiếng.
- Cảnh báo không có runbook — người trực mất 30 phút tìm hiểu cảnh báo nghĩa là gì.
- Trang xem log tự viết render HTML không escape — người xem log thường là admin.
- Tin header
X-Request-Id/X-Forwarded-Fortừ client — log ghi sai danh tính và IP, đúng loại tấn công Accountability của cheat sheet. - Không ai biết agent log trên một node đã chết từ tháng trước — phiên bản nhỏ của Equifax.
- Dùng log để tự động khoá tài khoản mà không nghĩ tới DoS — cheat sheet: nếu log dùng cho hành động với người dùng (khoá tài khoản), phải bảo đảm nó "cannot be used to cause denial of service (DoS) of other users": kẻ xấu cố tình đăng nhập sai để khoá tài khoản giảng viên ngay trước giờ thi.
9. Câu hỏi phụ interviewer hay hỏi tiếp
| Câu hỏi | Trả lời ngắn |
|---|---|
| Vì sao 2025 đổi "Monitoring" thành "Alerting"? | Để nhấn chức năng buộc có hành động. Có dashboard (monitoring) mà không ai được gọi (alerting) thì phát hiện vẫn phụ thuộc vào việc ai đó tình cờ nhìn. |
| Phải log những sự kiện bảo mật nào? | Xác thực thành công và thất bại, phân quyền bị từ chối và thay đổi quyền, lỗi phiên/CSRF/JWT, validate thất bại với tập giá trị hữu hạn, vượt rate limit, đọc/sửa/xuất dữ liệu nhạy cảm, hành động admin, bật/tắt giám sát. Dùng tên theo OWASP Logging Vocabulary. |
| Cái gì không được log? | Mật khẩu, token, session id (dùng hash nếu cần), khoá và secret, chuỗi kết nối, dữ liệu thẻ, PII nhạy cảm. Chọn allowlist trường; redact là lớp hai. |
| Log injection là gì, chặn thế nào? | Giá trị người dùng chứa ký tự cấu trúc của log (xuống dòng, phân cách) tạo bản ghi giả hoặc làm hỏng công cụ đọc log. Chặn bằng structured log JSON, giá trị ở trường riêng, escape khi hiển thị. |
| Làm sao chống kẻ tấn công xoá log? | Ship off-host trong vài giây, kho tập trung chỉ cho app quyền gửi, write-once + backup; audit DB chỉ INSERT + trigger + hash chain neo ra ngoài; cảnh báo khi node ngừng gửi log. |
| Log hỏng thì app có nên dừng không? | Log vận hành: không (fail open, có cảnh báo). Thao tác mà audit là điều kiện bắt buộc (sửa điểm, xuất dữ liệu cá nhân): ghi audit trong cùng transaction, audit lỗi thì rollback (fail closed). |
| Giảm alert fatigue thế nào? | Bắt đầu từ use case; ưu tiên sự kiện một lần là đủ (honeytoken, nâng quyền); tỉ lệ so baseline có mức sàn; mỗi cảnh báo page có runbook; đo precision và xoá rule nhiễu. |
| Honeytoken là gì, khi nào không nên dùng? | Dữ liệu/tài khoản giả mà nghiệp vụ không chạm tới; bị chạm là gần như chắc có người dò. Không dùng khi có job quét toàn bảng chưa loại trừ, hoặc khi đội không có runbook cho nó. |
| Đưa user id vào nhãn Prometheus để cảnh báo theo người được không? | Không — cardinality bùng nổ. Metric chỉ nhãn theo tên sự kiện; truy theo người làm trên log/kho log. |
| Làm sao biết hệ thống A09 của mình chạy thật? | Diễn tập: chạy kịch bản giả lập có kiểm soát (hoặc DAST trên staging) và đo cảnh báo nào bật, sau bao lâu, ai nhận. Test tích hợp assert sự kiện được ghi và không chứa bí mật. |
10. Chưa verify và Nguồn
Chưa verify
Bài này không có lab. Ngoài nguyên tắc chung đó, các điểm sau đặc biệt cần lab hoặc đối chiếu thêm mới khẳng định được:
- Laravel:
stopIgnoringgỡ được exception khỏi$internalDontReportvà callbackreporttrảfalsechặn báo cáo mặc định — đọc sourceHandler.php/Configuration/Exceptions.php13.x, chưa chạy.Gate::afterchạy cả cho@cantrong Blade — suy ra từ docs, chưa đo số bản ghi. - Laravel
LineFormattermặc định giữ ký tự xuống dòng trong message (và với chuỗi bắt đầu bằng{/[còn đổi chuỗi thoát thành xuống dòng thật) — đọc sourceLogManager13.x và MonologLineFormatternhánh main, chưa chạy với Monolog bản đi kèm Laravel 13.JsonFormattercho một bản ghi một dòng — theo cáchjson_encodehoạt động, chưa chạy. - Toàn bộ SQL ở 4.5 (REVOKE, trigger chặn UPDATE/DELETE/TRUNCATE, bảng đầu chuỗi) — chưa chạy trên container PostgreSQL test; hành vi "khoá dòng cuối thì chuỗi rẽ nhánh" là suy luận theo cách PostgreSQL đánh giá lại dòng bị khoá, chưa tái hiện bằng hai session song song.
- Biểu thức Prometheus ở 4.6 — chưa chạy; tên metric của agent log (
log_records_shipped_total) là giả định; API counter của thư viện Prometheus cho PHP chưa tra. - nestjs-pino
genReqId/assign, pinoredact, NestJSConsoleLogger({ json: true })— đã tra docs qua Context7, chưa cài và chạy. - Cú pháp Nginx để ghi đè
X-Request-Idvà che query string tronglog_format— chưa viết cấu hình cụ thể, chưa chạy. - Cấu hình gửi PII/dữ liệu request của Sentry SDK (PHP, Node) — chưa tra docs trong bài.
- Object locking của MinIO / S3 Object Lock cho log write-once — theo hiểu biết chung, chưa tra docs và chưa chạy.
- Moodle: trường của
logstore_standard_log,loglifetimemặc định 0 và task dọn dẹp — đọc sourceMOODLE_405_STABLE; tên event cụ thể cho sửa điểm/gán vai trò và bản Moodle thật của LMS Uni chưa kiểm. - Bộ rule SAST cho CWE-117/532 — theo hiểu biết, chưa chạy công cụ nào.
- Sự cố: chi tiết lấy từ OWASP (3 kịch bản), báo cáo Hạ viện Mỹ về Equifax, blog Twitter và Facebook Newsroom (qua kết quả tìm kiếm và bản lưu web.archive.org), NVD; tên "British Airways" cho kịch bản #3 là theo báo chí, OWASP không nêu tên. Bài Snowflake mà OWASP dẫn chưa đọc.
- Các con số ví dụ (2.400 lần/10 phút, gấp 5 lần, mức sàn 200, 60 cảnh báo/ngày, 40 giây lệch đồng hồ, 120 request/giây trung bình, 7,2 GB/ngày, hệ số nén 5–10 lần, thời hạn giữ 14/180 ngày) là minh hoạ, không phải số đo. Các điểm có dấu * ở mục 7 là giả định về hệ thống trong CV.
Nguồn
- OWASP Top 10:2025 — A09 Security Logging & Alerting Failures: top10.owasp.org (Background, bảng điểm, Description, How to prevent, 3 kịch bản, danh sách CWE); bản markdown: github.com/OWASP/Top10; A09:2021 để so sánh: github.com/OWASP/Top10
- OWASP Cheat Sheets: Logging (which events to log, event attributes, interaction identifier, data to exclude, event collection, verification, protection, attacks on logs, disposal), Application Logging Vocabulary (định dạng, tên sự kiện authn/authz/session/input/excess/sensitive/sys, ghi chú về IP là PII, IBM ~200 ngày)
- OWASP tham khảo khác (theo trang A09): Proactive Controls C9, ASVS 5.0.0 V16 Security Logging and Error Handling, NIST SP 800-92, NIST SP 1800-11/25/26 — dẫn, chưa đọc chi tiết trong bài
- CWE: CWE-117, CWE-221, CWE-223, CWE-532, CWE-778
- Laravel 13 docs (Context7
/laravel/docsnhánh 13.x): Logging (withContext, shareContext, tap, driver monolog với formatter/formatter_with), Context (tự gắn vào log, chuyển sang queued job, hidden context), Authentication — Events, Authorization — Gate::after, Errors (dontReport, ShouldntReport) - Source
laravel/framework13.x: Foundation/Exceptions/Handler.php ($internalDontReport,stopIgnoring), Foundation/Configuration/Exceptions.php, Log/LogManager.php (formatter()), Auth/Events (Failed, Lockout, Login); skeleton laravel/laravel config/logging.php - Monolog: Formatter/LineFormatter.php (
allowInlineLineBreaks,replaceNewlines) - PHP manual: SensitiveParameter (PHP 8.2+, ví dụ stack trace, ghi chú về interface)
- NestJS docs (Context7
/nestjs/docs.nestjs.com): Logger (ConsoleLoggerjson,colors/compact,bufferLogs,flattenParams); nestjs-pino (Context7/iamolegga/nestjs-pino): README (genReqId, PinoLogger.assign, assignResponse); pino (Context7/pinojs/pino): Redaction (paths, censor, remove, overhead, cảnh báo user input) - Moodle source
MOODLE_405_STABLE: logstore_standard settings.php (loglifetime, logguests, jsonformat), cleanup_task.php, db/install.xml - Sự cố: House Committee on Oversight — The Equifax Data Breach (12/2018); Twitter — Keeping your account secure (05/2018, bản lưu); Facebook — Keeping Passwords Secure (03/2019); NVD — CVE-2021-44228
- Trong repo: Bài 0 — tổng quan (điểm 350,19, "Great logging with no alerting is of minimal value", survey), Bài 1 — A01, Bài 3 — A03, Bài 5 — A05, Bài 7 — A07, Bài 8 — A08 (link tải ký sẵn), Bài 10 — A10, Activity log người dùng, 7 cảnh báo đầu tiên trong Prometheus, Bộ công cụ để vận hành,
cv-v3.md
Phụ lục lab: không áp dụng — series A01–A10 không có lab (quyết định của series).
Series OWASP Top 10:2025: ← Bài 0 — tổng quan · ← Trước: Bài 8 — A08 Software or Data Integrity Failures · Bài 9 — A09 Security Logging and Alerting Failures (bài này) · Tiếp: Bài 10 — A10 Mishandling of Exceptional Conditions →