
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 の Web インターフェースが削除されました。このインターフェースは 2.0.0 以降、root としてインプロセスで動作しており、テストツールとしては大きな攻撃表面となっていました。必要であれば、コードはタグ v2.0.0 の git 履歴に残っています。
ファイアウォールテストは否定アサーション — 「このパケットは通過しなかった」 — であり、否定アサーションは、計測器が正常に動作していることが判明していなければ何の価値もありません。カーネルに一度もアタッチしなかったトレースモニターと、すべてをブロックしたファイアウォールは、バイト単位で同一の出力を生成します。
したがって NSE は、測定したことを示せない判定を報告することを拒否します:
| 保証 | 仕組み |
|---|---|
| モニターが最初のテストパケットの前に接続されていた | レディネスキャナリアが注入され、そのカーネルトレースが観測されるまで再注入されます。観測がなければ実行しません。 |
| モニターが最後のパケットの後もまだ接続されていた | 注入後にライブネスキャナリアが実行されます。これが見逃された場合、判定ストリームは切り詰められたと宣言されます。 |
| パーサーがカーネルの発言を理解した | どのパターンにも一致しないトレース行がカウントされ、カウントがゼロより大きい場合はデバッグログではなくエラーとなります。 |
| モニターが静かに停止しなかった | 読み取りループは終了した理由(正常停止、予期しない EOF、タイムアウト、クラッシュ)を記録し、正常停止のみが許容されます。 |
| 判定の欠落は合格ではない | CLI ランナーは、観測された判定の数が期待される数と異なる場合(どちらの方向でも)失敗します。 |
キャナリパケットはトレース ID によって結果から除外されるため、判定ストリームに現れることはありません。
スイートはこれを主張するのではなく証明します。make test-blind はパーサーに何も理解させないように強制し、ランナーが非ゼロで終了しない限りビルドは失敗します。このジョブは毎回のプッシュで 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 も破棄していた。詳細は issue #14 を参照。
run_test_pipeline)が構造化された Pydantic モデル(TestRequest、TraceEvent)を返す。nse_<id>)をホストに直接接続。nse_router_<id>)とサーバー(nse_server_<id>)のチェーンによるフォワーディングと NAT のテスト。PCAPAsserter が指定したインターフェース上でキャプチャするため、テストは刺激がフレームをまったく生成しなかったことをアサートできる。ワイヤ上でのアサートを参照。nse-runner)。誤った判定および観測に失敗した判定のいずれでも非ゼロで終了する。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 はパケットごとに指定します。未知のキーはデフォルト値で処理されるのではなく拒否されるため、タイプミスは、書いてもいない期待値に静かに置き換わるのではなく、スイートを失敗させます。
スイートを root 権限で実行します:
sudo nse-runner --file firewall_test.yaml