Có hai cách rất khác nhau để kiểm một toà nhà an toàn. Cách thứ nhất: cầm bản thiết kế, dò từng phòng trên giấy — thấy được cả cái cửa hầm bị bịt kín mà không ai dùng, nhưng không biết ổ khoá thực tế có kẹt không. Cách thứ hai: thuê người thật đi thử mở từng cửa — biết chính xác cửa nào mở được, nhưng không bao giờ tìm ra căn phòng không có lối vào trên sơ đồ. Hai cách soi hai thứ khác nhau, và cái này mù đúng chỗ cái kia sáng. Kiểm thử bảo mật tự động chia làm đúng hai họ đó: SAST đọc mã nguồn mà không chạy nó, DAST tấn công ứng dụng đang chạy mà không nhìn mã. Câu hỏi thực dụng: nếu chỉ đủ thời gian cho một loại thì chọn cái nào, và nếu chạy cả hai thì chúng có trùng lặp không?
Để trả lời bằng số thay vì bằng cảm giác, tôi tự viết một ứng dụng nhỏ cố tình gài nhiều lỗ hổng, rồi chạy bandit (một SAST cho Python) và một DAST tự viết trên chính nó. Đo 3 lần trong container dùng một lần, tự dọn. Ứng dụng là của mình, không nhắm vào hệ thống của ai khác.
SAST: đọc mọi dòng, kể cả dòng không chạy
bandit quét mã tĩnh — nó phân tích cây cú pháp của file mà không thực thi. Kết quả trên app của tôi, giống hệt qua 3 lần:
so phat hien: 3
dong 1 B404 LOW import subprocess (can than)
dong 6 B307 MEDIUM Use of eval - possibly insecure
dong 9 B602 HIGH subprocess call with shell=True
Điểm mạnh lộ ra ngay ở dòng 6: bandit bắt được eval nằm trong hàm admin_eval() — một hàm tôi cố tình không nối vào route nào cả. Không có URL nào gọi tới nó; một người tấn công từ bên ngoài không có cách nào chạm được. Nhưng SAST không quan tâm đường chạy — nó đọc mọi dòng, nên nó thấy con dao dù con dao đang cất trong ngăn kéo. Đó là giá trị riêng của SAST: nó soi được cả code phòng khi, code chết, nhánh hiếm khi chạy — những chỗ mà một cuộc tấn công thật ít khi ghé qua nhưng vẫn là nợ kỹ thuật chờ ngày bùng.
Nhưng cũng đo được điểm mù của nó. bandit không báo hai lỗ hổng khác trong cùng file: câu SQL nối chuỗi ở /user và mật khẩu ghi cứng. bandit có luật cho SQL, nhưng mẫu nối chuỗi của tôi không khớp đúng dạng nó nhận ra, nên nó im lặng bỏ qua. Đây là bản chất của SAST theo luật: nó chỉ thấy những khuôn mẫu đã được lập trình để tìm, và một lỗ hổng viết hơi khác khuôn là lọt lưới. Thêm nữa, ngay cả với ba cái nó bắt, SAST không biết chúng có khai thác được thật không — eval trong hàm chết vẫn bị báo như một phát hiện, dù rủi ro thực tế bằng không. SAST cho bạn danh sách khả năng, không phải danh sách thực tế.
DAST: chỉ thấy cái chạy, nhưng thấy thật
DAST của tôi không đọc một dòng mã nào. Nó khởi động app rồi bắn request độc hại vào, y như kẻ tấn công ngoài Internet. Hai đòn:
-- XSS phan chieu /echo:
phan hoi: <html>ban go: <script>alert(1)</script></html>
-> payload phan chieu NGUYEN VAN? CO_bi_XSS
-- Gui ky tu ' pha SQL vao /user:
phan hoi: HTTP500: TRACEBACK: OperationalError('unrecognized token...')
-> lo TRACEBACK/debug? CO
Khác biệt cốt lõi với SAST nằm ở chữ thật. DAST không nói "chỗ này có thể bị XSS"; nó gửi <script>alert(1)</script> và cho bạn xem payload phản chiếu nguyên văn trong phản hồi — bằng chứng khai thác được, không phải phỏng đoán. Tương tự, khi bắn một dấu nháy vào /user, nó thấy server trả về nguyên cả traceback lỗi — một rò rỉ thông tin runtime mà SAST khó thấy, vì nó không nằm ở một dòng mã cụ thể mà ở hành vi khi chạy (server đang ở chế độ trả lỗi thô). DAST bắt được những thứ chỉ xuất hiện lúc vận hành: cấu hình sai, header thiếu, trang lỗi lộ thông tin, hành vi của cả chuỗi middleware.
Và điểm mù của DAST cũng rõ đúng bằng thế: nó không bao giờ tìm ra admin_eval(). Không có link, không có route, không có tham số nào dẫn tới — DAST bắn mù theo những đường nó biết, nên một lỗ hổng nằm ngoài mọi đường đi được nó chẳng thấy. DAST chỉ mạnh bằng độ phủ của những request nó gửi; code không được chạm tới thì với DAST là vô hình.
Chi phí thật: dương tính giả và chỗ đặt trong pipeline
Đo được lỗ hổng chỉ là một nửa; nửa kia là sống chung với kết quả. SAST nổi tiếng nhiều dương tính giả — đúng như eval trong admin_eval() ở trên: bandit báo nó là phát hiện, nhưng vì hàm không chạy được từ ngoài, rủi ro thực tế bằng không. Nếu đội ngũ phải lội qua hàng trăm cảnh báo kiểu đó mỗi lần build, họ sẽ sớm bỏ qua tất cả — kể cả cái thật. Vì thế giá trị của SAST phụ thuộc nặng vào việc chỉnh luật và xếp hạng: chỉ chặn build ở mức HIGH, để mức thấp làm ghi chú. DAST thì ít dương tính giả hơn (nó có bằng chứng khai thác), nhưng đổi lại chậm và cần một môi trường chạy thật.
Điều đó quyết định chỗ đặt mỗi loại trong đường ống. SAST nhanh và chỉ cần mã nguồn nên hợp với pre-commit hoặc bước kiểm mỗi pull request — bắt lỗi sớm, ngay khi lập trình viên còn nhớ mình vừa viết gì. DAST cần app chạy nên hợp với môi trường staging, chạy theo lịch đêm hoặc trước mỗi lần phát hành. Gộp với hai bài trước — quét phụ thuộc và quét bí mật — bốn thứ này tạo thành một bộ lưới nhiều lớp: mỗi lớp bắt một họ vấn đề, và chỗ mạnh của lớp này che đúng chỗ hở của lớp kia.
Đặt hai bảng cạnh nhau thì thấy chúng gần như bù trừ, không chồng lấn:
| SAST (bandit) | DAST (tự viết) | |
|---|---|---|
| Nhìn gì | mã nguồn, mọi dòng | app đang chạy, qua HTTP |
| Bắt tốt | eval/shell trong code chết, mẫu nguy hiểm | XSS thật, lộ traceback, lỗi cấu hình runtime |
| Điểm mù | SQLi viết lệch khuôn, "có khai thác được không" | code không có đường chạm tới |
| Kết quả là | danh sách khả năng | danh sách thực tế |
Câu trả lời cho "chọn cái nào" vì thế là: không chọn. Điểm mù của SAST đúng là vùng sáng của DAST và ngược lại — SAST thấy admin_eval() mà DAST bỏ, DAST thấy lộ traceback mà SAST bỏ. Chạy một cái rồi tưởng đã "kiểm thử bảo mật" là tự tin sai chỗ. Và đáng chú ý: câu SQLi nối chuỗi ở /user cả hai đều đo hụt theo cách riêng — SAST vì mẫu lệch khuôn, DAST thì thấy được traceback nhưng không tự khẳng định đó là SQL injection khai thác được — nên vẫn cần con người đọc lại, không lưới tự động nào phủ trọn.
Mẫu số chung
Cái lằn ranh SAST/DAST — đọc bản thiết kế so với chạy thử toà nhà — không riêng gì bảo mật; nó là hai họ phương pháp tồn tại song song ở mọi tầng, và mỗi họ mù đúng chỗ họ kia sáng.
Trong thế giới Java, đây là phân tích tĩnh (trình biên dịch bắt kiểu, SpotBugs/Find Security Bugs quét bytecode, Error Prone) so với phân tích động (chạy test tích hợp, OWASP ZAP bắn vào app đang chạy). Bài học lặp y hệt: một NullPointerException chỉ nổ với một tổ hợp dữ liệu runtime cụ thể thì phân tích tĩnh khó thấy, còn một nhánh code chết thì test động không bao giờ chạm — nên các đội nghiêm túc chạy cả hai, không coi cái này thay cái kia.
Trong Go, đây là go vet/staticcheck/gosec (đọc mã) so với -race detector và fuzzing (quan sát hành vi lúc chạy): race condition gần như chỉ lộ ra khi chạy với -race, còn một hàm không ai gọi thì fuzzer không tới — cùng sự bù trừ.
Còn ở cơ sở dữ liệu, đúng cặp này là EXPLAIN (tĩnh — kế hoạch dự đoán từ thống kê, không chạy query) so với EXPLAIN ANALYZE (động — chạy thật rồi báo số hàng và thời gian thực tế): kế hoạch dự đoán có thể đẹp mà chạy thật lại lệch vì thống kê cũ, đúng như SAST báo "khả năng" còn DAST cho "thực tế".
Mẫu số chung: phân tích tĩnh đọc mọi khả năng trong artifact nhưng không biết cái nào thật sự xảy ra, còn phân tích động quan sát cái thật sự xảy ra nhưng chỉ trên những đường nó chạm tới — nên không họ nào phủ trọn, và điểm mù của bên này gần như luôn là vùng sáng của bên kia. Vì thế câu hỏi đúng khi kiểm bất cứ thứ gì (bảo mật, đúng đắn, hiệu năng) không phải "dùng phương pháp nào tốt hơn" mà là "tôi đã có cả góc đọc-artifact lẫn góc chạy-thật chưa" — vì một mình mỗi góc đều để lại đúng cái khoảng mù mà cái kia sinh ra để lấp.
Muốn tự thử ngay, chạy SAST trên một dự án Python của chính bạn:
pip install bandit
bandit -r duong/dan/du-an -ll # -ll: chi hien muc MEDIUM tro len
Đọc kết quả với một câu hỏi trong đầu cho mỗi dòng: "chỗ này có thật sự chạy với dữ liệu từ người dùng không?". Nếu có, đó là việc cần sửa ngay. Nếu không (code chết, script nội bộ), đó là thứ SAST thấy mà DAST sẽ bỏ — và là lý do bạn vẫn cần thêm một vòng DAST trên bản đang chạy để bắt những gì chỉ lộ ra lúc vận hành.