
nftables 규칙을 테스트하기 위한 결정론적 네트워크 샌드박스입니다. 임시 Linux 네트워크 네임스페이스(netns)와 Scapy를 사용하여 방화벽 로직을 안전하게 검증합니다.
NSE를 사용하는 이유? • 오라클 계약 • 와이어 상의 검증 • 기능 • 요구 사항 • 설치 • 빠른 시작 • 작동 방식 • 프로젝트 구조
실제 운영 중인 Linux 시스템에서 방화벽 규칙 세트를 테스트하는 것은 상당한 위험을 수반합니다. 잘못된 규칙은 SSH 관리 세션을 끊거나, 테스트 중 평문 트래픽을 유출하거나, 호스트에 고아 방화벽 테이블을 활성 상태로 남길 수 있습니다.
Network Sandbox Engine(NSE)은 안전하고 재현 가능한 테스트 하네스를 제공합니다. 임시 Linux 네트워크 네임스페이스를 생성하고, 가상 이더넷 쌍을 연결하며, nftables 규칙 세트를 컴파일하고, Scapy를 사용해 합성 계층 2 및 계층 3 패킷을 주입합니다. 모든 평가는 샌드박스 네임스페이스 내부에서 이루어지며, 호스트 방화벽 상태는 절대 변경되지 않습니다.
핵심 아키텍처 특성:
nse_<uuid>)에만 로드되며, 정리 단계에서 완전히 제거됩니다.nse/는 약 1200개의 문장으로 구성되며 테스트 커버리지는 98%입니다.NSE는 네트워크 네임스페이스를 생성하고, nftables 규칙 세트를 로드하며, 커널 트레이스 이벤트를 읽기 때문에 root로 실행됩니다. 소켓, 포트, RPC 엔드포인트는 어떤 종류도 열지 않습니다. 사용자가 호출하는 라이브러리이자 CLI이며, 실행 중에만 권한을 보유합니다.
버전 2.1.0에서는 이전 릴리스에 포함되어 있던 FastAPI/Svelte 웹 인터페이스가 제거되었습니다. 해당 인터페이스는 2.0.0부터 root 권한으로 프로세스 내에서 실행되었으며, 이는 테스트 도구로서는 큰 공격 표면이었습니다. 필요한 경우 코드는 git 히스토리의 v2.0.0 태그에 남아 있습니다.
방화벽 테스트는 부정적 단언 — "이 패킷은 통과하지 못했다" — 이며, 부정적 단언은 측정 도구가 제대로 작동한다는 것이 확인되지 않는 한 아무 가치가 없습니다. 커널에 연결되지 않은 트레이스 모니터와 모든 것을 차단하는 방화벽은 바이트 단위로 동일한 출력을 만들어냅니다.
따라서 NSE는 측정했음을 입증할 수 없는 판정은 보고하지 않습니다:
| 보장 | 메커니즘 |
|---|---|
| 모니터가 첫 번째 테스트 패킷 이전에 연결되었음 | 준비 상태 카나리가 주입되고, 그 커널 트레이스가 관찰될 때까지 다시 주입된다. 관찰이 없으면 실행도 없다. |
| 모니터가 마지막 패킷 이후에도 여전히 연결되어 있었음 | 주입 후 활성 상태 카나리가 실행된다. 이를 놓치면 판정 스트림이 잘린 것으로 선언된다. |
| 파서가 커널이 말한 내용을 이해했음 | 어떤 패턴과도 일치하지 않는 트레이스 라인을 집계하며, 0보다 큰 집계값은 디버그 로그가 아니라 오류로 처리된다. |
| 모니터가 조용히 죽지 않았음 | 읽기 루프는 왜 종료되었는지 — 정상 중지, 예기치 않은 EOF, 타임아웃 또는 크래시 — 를 기록하며, 정상 중지만 허용된다. |
| 판정 누락은 통과가 아님 | 관찰된 판정 수가 예상 수와 다를 경우(어느 방향이든) CLI 러너가 실패한다. |
카나리 패킷은 트레이스 id로 결과에서 제외되므로, 판정 스트림에는 절대 나타나지 않는다.
이 스위트는 이를 단언하는 대신 증명한다: make test-blind는 파서가
아무것도 이해하지 못하도록 강제하며, 러너가 0이 아닌 값으로 종료하지 않으면
빌드가 실패한다. 해당 작업은 매 푸시마다 CI에서 실행된다.
nft monitor trace는 규칙 집합이 결정한 내용을 보고한다. 규칙 집합이
한 번도 매칭하지 않은 인터페이스에서 무엇이 나갔는지는 보고할 수 없으며,
이것이 바로 제로 누출 주장이 실제로 묻는 질문이다. PCAPAsserter가 이를
담당한다: 지정된 인터페이스에서 캡처하여 프레임을 돌려주는 AsyncSniffer
래퍼로, 테스트가 어떤 자극이 아무것도 생성하지 않았음을 단언할 수 있게 한다.
from nse import PCAPAsserter
asserter = PCAPAsserter(iface="veth-host")
await asserter.start()
# ... send the stimulus ...
leaked = await asserter.stop()
assert not leaked, leaked
트레이스 오라클과 동일한 규칙이 적용되며, 이를 강제하는 것은 호출자의 몫이다. 한 번도 연결되지 않은 스니퍼와 모든 것을 차단한 방화벽은 둘 다 빈 목록을 반환한다. 규칙셋을 비운 상태에서 자극을 한 번 캡처하고, 프레임이 관찰되었음을 확인한 뒤에야 그 부재를 단언해야 한다.
기본 필터는 링크-로컬 노이즈만 억제하며, 그 외에는 아무것도 억제하지 않는다.
DEFAULT_FILTER는 ARP와 ICMPv6 타입 133-137을 제외한다. 이는 Neighbour Discovery로, 홉 제한 255를 가지며 원격 관찰자에게 도달할 수 없다. 그 외의 모든 것은 캡처되며, 전역 라우팅 가능 주소로 향하는 ICMPv6 에코도 포함된다. filter 인자는 이 기본값과 결합되며, replace_default_filter=True를 사용하면 사용자의 필터만 사용된다. 이 탈출구가 존재하는 이유는 필터가 BPF로 컴파일되어 커널에서 평가되기 때문이다. 필터가 제외한 패킷은 결코 사용자 공간에 도달하지 않으므로, 어떤 소비자도 하위 단계에서 이를 복구할 수 없다. 2.1.2 이전까지 기본값은 not arp and not icmp6였으며, 이는 라우팅 가능한 ICMPv6까지 폐기했다. 이슈 #14를 참조하라.
TestRequest, TraceEvent)을 반환하는 직접 Python API(run_test_pipeline).nse_<id>).nse_router_<id>)와 서버(nse_server_<id>) 체인.PCAPAsserter가 지정된 인터페이스에서 캡처하므로, 테스트는 자극이 프레임을 전혀 생성하지 않았음을 단언할 수 있다. 와이어 상에서의 단언을 참조하라.nse-runner). 잘못된 판정 시 그리고 관찰하지 못한 판정 시에도 0이 아닌 값으로 종료한다.mypy --strict), 아키텍처 경계 강제(import-linter), ruff 포매팅, 그리고 커버리지 래칫(make coverage, 하한 98%).nft)ip)ip netns 및 커널 트레이스 작업에 필요)Debian 또는 Ubuntu 시스템에서:
sudo apt update && sudo apt install -y nftables iproute2 conntrack
CLI 지원이 포함된 핵심 엔진을 설치하세요:
pip install "network-sandbox-engine[cli]"
로컬 개발의 경우:
git clone https://github.com/onyks-os/NetworkSandboxEngine.git
cd NetworkSandboxEngine
make setup
import asyncio
from nse.core.netns_controller import NetnsController
from nse.core.pipeline import run_test_pipeline
from nse.models.test_request import TestRequest, PacketSpec
rules = """
table ip filter {
chain input {
type filter hook input priority 0; policy drop;
tcp dport 80 accept
}
}
"""
request = TestRequest(
rules=rules,
packets=[
PacketSpec(protocol="tcp", src_ip="10.0.0.1", dst_ip="10.0.0.2", dst_port=80),
PacketSpec(protocol="tcp", src_ip="10.0.0.1", dst_ip="10.0.0.2", dst_port=22),
],
)
async def main():
controller = NetnsController()
events = await run_test_pipeline(request=request, controller=controller)
for evt in events:
if evt.verdict:
print(f"[{evt.chain}] Verdict: {evt.verdict}")
asyncio.run(main())
테스트 파일 firewall_test.yaml을 생성합니다:
tests:
- name: "Allow HTTP Port 80, Drop SSH Port 22"
topology: simple
rules: |
table ip filter {
chain input {
type filter hook input priority 0; policy drop;
tcp dport 80 accept
}
}
packets:
- protocol: tcp
src_ip: 10.0.0.1
dst_ip: 10.0.0.2
dst_port: 80
expected_verdict: ACCEPT
- protocol: tcp
src_ip: 10.0.0.1
dst_ip: 10.0.0.2
dst_port: 22
expected_verdict: DROP
expected_verdict는 패킷별로 지정됩니다. 알 수 없는 키는 기본값으로 처리되지 않고 거부되므로, 오타가 있으면 조용히 작성하지 않은 기대값이 되는 대신 테스트 스위트가 실패합니다.
루트 권한으로 테스트 스위트를 실행하세요:
sudo nse-runner --file firewall_test.yaml