Báo cáo pentest tự động: một phát hiện không tái lập được thì không phải bằng chứng
Mở bài
Trong hai năm qua, chi phí để sinh ra code đã giảm mạnh. Một team ba người giờ có thể đẩy lên production lượng thay đổi mà trước đây cần mười người. Nhưng chi phí để kiểm chứng những thay đổi đó thì gần như không giảm. Khoảng cách giữa hai đường cong này chính là nơi lỗ hổng sinh ra.
Phản ứng tự nhiên của thị trường là dùng AI để tăng tốc cả khâu kiểm thử. Điều đó hợp lý, và phần lớn công cụ quét hiện nay đều đã làm vậy. Nhưng có một chi tiết bị bỏ qua: khi công cụ tìm ra nhiều phát hiện hơn, khối lượng bằng chứng cần cho mỗi phát hiện không giảm — nó tăng lên, vì người đọc báo cáo không còn đủ thời gian để tự kiểm tra từng dòng.
Kết quả là một nghịch lý quen thuộc: báo cáo dày lên, độ tin cậy giảm xuống. Bài này bàn về phần ít được nói tới — cấu trúc của một bằng chứng đủ để ký, và cách tự động hoá việc thu thập nó trong CI.
Vì sao một phát hiện không tái lập được thì vô giá trị
Hãy tưởng tượng bạn nhận một ticket như sau:
IDOR trên endpoint đơn hàng. Mức độ: Cao. Endpoint
/api/v1/orders/{id}trả về dữ liệu của người dùng khác khi thay đổi tham sốid.
Thông tin này nghe hợp lý, nhưng không thể hành động. Ba câu hỏi cơ bản không có câu trả lời:
- Ai thực hiện hành vi đó? Một tài khoản thuộc tenant nào, vai trò gì, đã xác thực bằng cách nào?
- Cái gì đã được trả về? Chỉ là
200 OK, hay thật sự có dữ liệu của tenant khác? Nếu response rỗng thì đó là lỗi logic, không phải lỗ hổng. - Trong điều kiện nào? Trên môi trường staging với dữ liệu seed, hay trên production? Nếu là staging với một bản ghi test do chính công cụ tạo ra thì mức độ nghiêm trọng khác hẳn.
Ba câu hỏi này không phải chi tiết hành chính. Chúng quyết định toàn bộ giá trị của phát hiện. Một lỗi không tái lập được thì không thể sửa, không thể xác minh sau khi sửa, và không thể đưa vào hợp đồng.
Bốn thành phần của một bằng chứng dùng được
Sau nhiều lần phải đối chiếu báo cáo với log, tôi rút ra một cấu trúc tối thiểu gồm bốn phần. Nếu thiếu một phần, bằng chứng sẽ bị chất vấn ở đúng chỗ đó.
1. Danh tính của tác nhân (actor). Không chỉ là "người dùng đã đăng nhập", mà là định danh cụ thể: tenant, user id, vai trò, và loại token. Ghi lại cả thời điểm token được cấp, vì token hết hạn giữa chừng là nguyên nhân phổ biến của các phát hiện sai.
2. Yêu cầu nguyên văn (raw request). Method, đường dẫn đầy đủ kèm query string, các header liên quan (đã che bí mật), và body. Việc che bí mật phải có quy tắc rõ ràng — che bằng cách thay bằng REDACTED giữ nguyên độ dài sẽ tốt hơn xoá hẳn, vì người đọc vẫn thấy cấu trúc header.
3. Phản hồi nguyên văn (raw response), kèm dấu vân tay. Status code, các header quyết định (đặc biệt Content-Type, Set-Cookie, Content-Length), và hash SHA-256 của toàn bộ body cùng một đoạn trích ngắn. Hash quan trọng vì nó cho phép chứng minh hai lần chạy trả về cùng một thứ mà không cần lưu toàn bộ dữ liệu khách hàng.
4. Ngữ cảnh môi trường. Commit SHA của bản build, tên môi trường, thời điểm chính xác, và phiên bản API. Thiếu phần này, một phát hiện đúng hôm nay có thể không tái lập được tuần sau, và team dev sẽ mất nửa ngày để tranh luận xem ai đúng.
Có một tiêu chí thứ năm, mang tính hệ quả: bằng chứng phải đủ để người khác chạy lại mà không cần bạn. Nếu chỉ bạn tái lập được, đó là ghi chú cá nhân, không phải bằng chứng.
Ba kiểu sai thường gặp khi tự động hoá
Trước khi đi vào code, cần nhận diện ba kiểu sai mà công cụ tự động hay mắc. Chúng không phải lỗi kỹ thuật, mà là lỗi mô hình hoá.
Sai vì tài nguyên dùng chung. Một endpoint trả về dữ liệu "của người khác" không chắc là lỗ hổng. Danh mục sản phẩm, bảng giá, danh sách tỉnh thành — đọc được các bản ghi này là thiết kế, không phải lỗi. Công cụ không tự biết điều đó. Ma trận kỳ vọng (expected matrix) phải do người viết, và đây là phần không thể giao cho mô hình ngôn ngữ.
Sai vì nhảy cóc trạng thái. Nhiều lỗi chỉ xuất hiện sau chuỗi thao tác: tạo đơn → huỷ đơn → hoàn tiền → đọc lại. Công cụ kiểm thử từng endpoint riêng lẻ sẽ không bao giờ thấy. Đây là lý do các công cụ quét truyền thống bỏ sót phần lớn lỗi logic nghiệp vụ.
Sai vì trôi môi trường. Staging có dữ liệu khác production, feature flag khác, và đôi khi cả phiên bản code khác. Một phát hiện chỉ đúng trên staging là thông tin hữu ích, nhưng phải được ghi rõ là staging.
Ba kiểu sai này giải thích tại sao tỉ lệ báo động giả trong các báo cáo tự động thường cao hơn nhiều so với cảm nhận của người dùng công cụ: công cụ đang trả lời đúng một câu hỏi hẹp hơn câu hỏi mà người đọc tưởng nó trả lời.
Thu thập bằng chứng tự động trong CI
Phần này là phần thực hành. Ý tưởng: mỗi assertion trong bộ test bảo mật tự động ghi lại đầy đủ bốn thành phần ở trên, và xuất ra một file JSON có thể đính kèm vào báo cáo.
Dùng httpx với event hook, vì hook cho phép chèn vào cả request và response mà không phải sửa từng lời gọi.
# evidence.py
import hashlib
import json
import time
from datetime import datetime, timezone
SENSITIVE = ("authorization", "cookie", "x-api-key", "set-cookie")
def _redact(headers: dict) -> dict:
out = {}
for k, v in headers.items():
if k.lower() in SENSITIVE:
# giữ tiền tố để thấy loại token, phần còn lại thay bằng độ dài
out[k] = f"REDACTED(len={len(v)})"
else:
out[k] = v
return out
class EvidenceRecorder:
def __init__(self, env: dict):
self.env = env # {"env": "staging", "commit": "...", "api_version": "v1"}
self.items = []
def on_request(self, request):
self._started = time.monotonic()
self._req = {
"at": datetime.now(timezone.utc).isoformat(),
"method": request.method,
"url": str(request.url),
"headers": _redact(dict(request.headers)),
"body": request.content.decode("utf-8", "replace")[:4096],
}
def on_response(self, response):
body = response.content
self.items.append({
**self._req,
"status": response.status_code,
"resp_headers": _redact(dict(response.headers)),
"resp_sha256": hashlib.sha256(body).hexdigest(),
"resp_excerpt": body[:512].decode("utf-8", "replace"),
"elapsed_ms": round((time.monotonic() - self._started) * 1000, 2),
**self.env,
})
def dump(self, path: str) -> None:
with open(path, "w", encoding="utf-8") as f:
json.dump(self.items, f, ensure_ascii=False, indent=2)
Điểm cần lưu ý: resp_excerpt chỉ giữ 512 byte đầu. Với dữ liệu khách hàng, đây là cách cân bằng giữa khả năng chứng minh và yêu cầu bảo mật. Nếu cần chứng minh nhiều hơn, dùng hash và một bộ lọc trường cụ thể, đừng lưu cả response.
Tiếp theo, gắn recorder vào fixture của pytest:
# conftest.py
import os
import pytest
import httpx
from evidence import EvidenceRecorder
BASE_URL = os.environ["TARGET_BASE_URL"]
ENV = {
"env": os.getenv("TARGET_ENV", "staging"),
"commit": os.getenv("GITHUB_SHA", "unknown"),
"api_version": "v1",
}
@pytest.fixture(scope="session")
def recorder():
return EvidenceRecorder(ENV)
@pytest.fixture(scope="session")
def api(recorder):
with httpx.Client(
base_url=BASE_URL,
timeout=15.0,
event_hooks={
"request": [recorder.on_request],
"response": [recorder.on_response],
},
) as client:
yield client
@pytest.fixture(scope="session", autouse=True)
def _dump_evidence(recorder):
yield
recorder.dump("evidence/findings.json")
Fixture autouse ở scope session đảm bảo file chỉ được ghi một lần, sau khi toàn bộ test đã chạy xong. Nếu ghi theo từng test, bạn sẽ có hàng trăm file rời rạc và không ai tổng hợp lại.
Bây giờ đến phần quan trọng nhất — bản thân bài test. Dưới đây là một test IDOR viết theo cách có thể ký được:
# test_orders_idor.py
import os
import pytest
TOKEN_A = os.environ["TOKEN_TENANT_A"]
TOKEN_B = os.environ["TOKEN_TENANT_B"]
ORDER_B = os.environ["ORDER_ID_TENANT_B"]
def test_tenant_a_cannot_read_tenant_b_order(api, recorder):
"""
Kỳ vọng: 403 hoặc 404.
Thực tế nếu trả 200 kèm dữ liệu tenant B => IDOR.
"""
r = api.get(
f"/api/v1/orders/{ORDER_B}",
headers={"Authorization": f"Bearer {TOKEN_A}"},
)
detail = r.text[:512]
assert r.status_code in (403, 404), (
f"IDOR: tenant A đọc được đơn {ORDER_B} của tenant B. "
f"status={r.status_code}, body={detail}"
)
Đoạn test này ngắn, nhưng khi fail, nó tạo ra đúng thứ cần thiết: request thật, response thật, hash, thời điểm, commit. Người review không phải hỏi thêm câu nào.
Một chi tiết nhỏ nhưng hay bị bỏ: đặt tên test bằng hành vi, không bằng lỗi. test_tenant_a_cannot_read_tenant_b_order nói rõ điều được kỳ vọng; test_idor thì không, và sáu tháng sau không ai đọc hiểu.
Biến bằng chứng thành thứ người khác chạy lại được
Có file JSON là chưa đủ. Bước cuối là sinh ra lệnh curl từ chính bằng chứng đó, để người nhận báo cáo có thể chạy lại bằng token của họ.
# make_repro.py
import json
import shlex
import sys
def to_curl(item: dict) -> str:
parts = ["curl", "-i", "-X", item["method"], shlex.quote(item["url"])]
for k, v in item["headers"].items():
if k.lower() in ("content-length", "host"):
continue
parts += ["-H", shlex.quote(f"{k}: {v}")]
if item.get("body"):
parts += ["--data-raw", shlex.quote(item["body"])]
return " \\\n ".join(parts)
if __name__ == "__main__":
items = json.load(open(sys.argv[1], encoding="utf-8"))
for i, item in enumerate(items, 1):
print(f"# --- Bằng chứng {i} | status dự kiến: {item['status']} | {item['at']}")
print(to_curl(item))
print(f"# sha256(response): {item['resp_sha256']}")
print()
Với file này, một kỹ sư backend nhận ticket có thể tái lập trong vòng ba mươi giây, không cần hỏi lại người kiểm thử. Đó là toàn bộ mục tiêu của phần này.
Phần AI không làm được
Tôi dùng công cụ AI để sinh test, sinh payload, và đọc kết quả. Nhưng có ba việc tôi vẫn phải tự làm, và tôi cho rằng sẽ còn phải làm trong một thời gian dài.
Một, viết ma trận kỳ vọng. Ai được đọc cái gì, trong trạng thái nào, là quyết định nghiệp vụ. Mô hình ngôn ngữ có thể đoán, nhưng đoán sai ở đây tạo ra báo cáo sai — và một báo cáo sai tệ hơn không có báo cáo.
Hai, đánh giá tác động. Cùng một lỗi đọc chéo dữ liệu, trên hệ thống y tế và trên hệ thống quản lý ảnh cá nhân, có mức độ hoàn toàn khác nhau. Tác động phụ thuộc vào dữ liệu thật, vào hợp đồng, vào quy định áp dụng.
Ba, chịu trách nhiệm về chữ ký. Nếu báo cáo có tên người ký, thì người đó phải đọc. Đây là lý do các tổ chức có quy trình nghiêm ngặt vẫn giữ bước review thủ công, dù công cụ đã tự động hoá 90% khối lượng.
Nói cách khác: AI làm cho việc tìm rẻ đi rất nhiều, nhưng không làm cho việc khẳng định rẻ đi. Và trong bảo mật, giá trị nằm ở khẳng định.
Liên hệ với sản phẩm
Những gì mô tả ở trên là kiến trúc mà chúng tôi đang xây tại HackBox. HackBox là nền tảng SaaS tự động hoá kiểm thử xâm nhập bằng AI, có trụ sở tại Singapore. Sản phẩm giữ nguyên nguyên tắc: mỗi phát hiện phải kèm bằng chứng tái lập được, và phần đánh giá tác động vẫn do người làm.
Về chi phí, chúng tôi dùng mô hình linh hoạt: khách hàng có thể trả theo kết quả — chỉ thanh toán cho những lỗ hổng đã được xác nhận — hoặc đăng ký theo số IP.
Nếu team bạn đang cố gắng đưa kiểm thử bảo mật vào pipeline và gặp đúng vấn đề "báo cáo nhiều nhưng không ai đọc", phần code ở trên là điểm khởi đầu tốt. Nó không cần công cụ đặc biệt, chỉ cần một chỗ để ghi lại những gì đã thực sự xảy ra.
Kết
Ba điểm mang theo được:
- Bằng chứng không phải ảnh chụp màn hình. Nó là bốn thành phần có cấu trúc: danh tính, yêu cầu, phản hồi kèm hash, và ngữ cảnh môi trường.
- Khoảng năm mươi dòng code là đủ để bắt đầu thu thập tự động, miễn là gắn vào đúng chỗ — event hook ở tầng HTTP client, không phải trong từng bài test.
- Ma trận kỳ vọng là phần của con người. Tự động hoá phần thu thập, đừng tự động hoá phần phán đoán.
Nếu bạn đã thử cách khác để lưu bằng chứng trong CI — đặc biệt là cách xử lý dữ liệu nhạy cảm — rất muốn nghe. Phần che bí mật trong bài này vẫn còn thô, và tôi chưa thấy chuẩn chung nào cho nó.
All Rights Reserved