Có hai họ công cụ kiểm thử bảo mật tự động mà người ta hay nhắc chung một hơi: SAST và DAST. SAST (Static Application Security Testing) đọc mã nguồn mà không chạy nó. DAST (Dynamic Application Security Testing) thì ngược lại — không nhìn mã, mà tấn công ứng dụng đang chạy bằng những request độc hại. Câu hỏi thực dụng: nếu chỉ đủ thời gian cho một loại, nên 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ôngeval 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.

đ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.

Ghép lại

Sơ đồ SAST và DAST bắt được gì trên cùng một app

Đặ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ỗ.

Mô-típ quen của Series lại hiện: công cụ tự động hiện đại đều tốt và đều đúng trong phạm vi của nó — cái sai là kỳ vọng một công cụ phủ hết. Không có phép đo đơn nào bắt trọn; mỗi phương pháp có một hình chiếu, và bảo mật là chỗ giao của nhiều hình chiếu, cộng thêm con người đọc lại (như câu SQLi mà cả hai công cụ trên đều đo hụt theo cách riêng).

Thử ba mươi giây

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.