diff --git a/.github/workflows/live.yaml b/.github/workflows/live.yaml new file mode 100644 index 0000000000..b9cac6e22a --- /dev/null +++ b/.github/workflows/live.yaml @@ -0,0 +1,113 @@ +name: Live Tests + +on: + workflow_dispatch: + push: + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +env: + DEBIAN_FRONTEND: "noninteractive" + +jobs: + live: + runs-on: ubuntu-latest + container: + image: ubuntu:26.04 + options: --privileged + + steps: + - name: Check out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + + - name: Install dependencies + run: | + apt update + apt install -y \ + autoconf \ + automake \ + build-essential \ + cargo \ + cbindgen \ + clang \ + curl \ + ethtool \ + fuse-overlayfs \ + git \ + hping3 \ + inetutils-ping \ + iproute2 \ + iptables \ + jq \ + libcap-ng-dev \ + libcap-ng0 \ + libevent-dev \ + libhiredis-dev \ + libjansson-dev \ + liblua5.1-dev \ + libmagic-dev \ + libnet1-dev \ + libnetfilter-queue-dev \ + libnetfilter-queue1 \ + libnfnetlink-dev \ + libnfnetlink0 \ + libnss3-dev \ + libpcap-dev \ + libpcre2-dev \ + libssl-dev \ + libtool \ + libxdp-dev \ + libyaml-0-2 \ + libyaml-dev \ + make \ + podman \ + procps \ + python3 \ + python3-yaml \ + rustc \ + slirp4netns \ + software-properties-common \ + tcpdump \ + tcpreplay \ + uidmap \ + wget \ + zlib1g \ + zlib1g-dev + + - run: | + apt-get install -y caddy tshark + + - name: Enable Podman in Docker + run: | + cat > /etc/containers/containers.conf <<'EOF' + [containers] + cgroups = "disabled" + events_logger = "file" + EOF + + - name: Clone Suricata + run: | + git clone https://github.com/OISF/suricata + + - name: Build Suricata + working-directory: suricata + run: | + ./autogen.sh + ./configure --enable-nfqueue --enable-debug --enable-ebpf --enable-ebpf-build + make -j$(nproc) + + - name: Run live tests + run: | + ../live/run.py + working-directory: suricata + + - name: Upload live test outputs artifact on failure + if: ${{ failure() }} + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f + with: + name: live-test-output + path: live/tests/**/output/** + if-no-files-found: ignore diff --git a/README.md b/README.md index 097bf7e52d..6bcbec70d6 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,12 @@ requires: files: - src/detect-ipaddr.c + # Require that one or more host commands exist in PATH. If a command is + # missing, the test will be skipped. + command: + - jq + - xargs + # Don't require a pcap file to be present. By default a test will be skipped # if there is no pcap file in the test directory. Not applicable if a # command is provided. diff --git a/lib/__init__.py b/lib/__init__.py new file mode 100644 index 0000000000..e92639cd0f --- /dev/null +++ b/lib/__init__.py @@ -0,0 +1 @@ +"""Shared helpers for Suricata verify runners.""" diff --git a/lib/common.py b/lib/common.py new file mode 100644 index 0000000000..4a7fdc352f --- /dev/null +++ b/lib/common.py @@ -0,0 +1,582 @@ +"""Shared runner helpers for Suricata verify runners.""" + +import os +import platform +import shutil +import subprocess +import filecmp +import json +import re + + +class UnsatisfiedRequirementError(Exception): + pass + + +class ImpossibleRequirementError(Exception): + pass + + +def check_required_commands(requires, unsatisfied_error=UnsatisfiedRequirementError): + """Validate host command requirements from a requires mapping.""" + if not isinstance(requires, dict): + raise ValueError("requires must be a mapping") + commands = requires.get("command", []) + if commands is None: + return + if not isinstance(commands, list) or any( + not isinstance(command, str) for command in commands + ): + raise ValueError("requires.command must be an array of strings") + for command in commands: + if shutil.which(command) is None: + raise unsatisfied_error("requires command {}".format(command)) + + +def check_requires( + requires, + suricata_config, + is_version_compatible, + test_dir=None, + version_is_lt=None, + eval_globals=None, + unsatisfied_error=UnsatisfiedRequirementError, + impossible_error=ImpossibleRequirementError, + unknown_error=ValueError, + unknown_message="unknown requires type: {key}", + gt_message="for version greater than {version}", + script_message="requires script returned false", + include_script_error=False, +): + """Validate a test requires mapping. + + Runner-specific version parsing is supplied by is_version_compatible so this + helper can be shared by both the classic and live runners. + """ + check_required_commands(requires, unsatisfied_error) + + if ( + version_is_lt is not None + and "gt-version" in requires + and "lt-version" in requires + and not version_is_lt(requires["gt-version"], requires["lt-version"]) + ): + raise impossible_error( + "test has both lt-version {} and gt-version {}".format( + requires["lt-version"], requires["gt-version"] + ) + ) + + suri_version = suricata_config.version + for key in requires: + if key == "min-version": + min_version = requires["min-version"] + if not is_version_compatible(min_version, suri_version, "gte"): + raise unsatisfied_error( + "requires at least version {}".format(min_version) + ) + elif key == "lt-version": + lt_version = requires["lt-version"] + if not is_version_compatible(lt_version, suri_version, "lt"): + raise unsatisfied_error("for version less than {}".format(lt_version)) + elif key == "gt-version": + gt_version = requires["gt-version"] + if not is_version_compatible(gt_version, suri_version, "gt"): + raise unsatisfied_error(gt_message.format(version=gt_version)) + elif key == "version": + req_version = requires["version"] + if not is_version_compatible(req_version, suri_version, "equal"): + raise unsatisfied_error("only for version {}".format(req_version)) + elif key == "features": + for feature in requires["features"]: + if not suricata_config.has_feature(feature): + raise unsatisfied_error("requires feature {}".format(feature)) + elif key == "command": + pass + elif key == "env": + for env in requires["env"]: + if env not in os.environ: + raise unsatisfied_error("requires env var {}".format(env)) + elif key == "files": + for filename in requires["files"]: + if test_dir and not os.path.isabs(filename): + filename = os.path.join(test_dir, filename) + if not os.path.exists(filename): + raise unsatisfied_error("requires file {}".format(filename)) + elif key == "script": + for script in requires["script"]: + try: + subprocess.check_call("{}".format(script), shell=True) + except Exception as err: + if include_script_error: + raise unsatisfied_error( + "{}: {}".format(script_message, err) + ) from err + raise unsatisfied_error(script_message) + elif key == "pcap": + pass + elif key == "lambda": + if eval_globals is None: + lambda_result = eval(requires["lambda"]) + else: + lambda_result = eval(requires["lambda"], eval_globals) + if not lambda_result: + raise unsatisfied_error(requires["lambda"]) + elif key == "os": + cur_platform = platform.system().lower() + if not cur_platform.startswith(requires["os"].lower()): + raise unsatisfied_error(requires["os"]) + elif key == "arch": + cur_arch = platform.machine().lower() + if not cur_arch.startswith(requires["arch"].lower()): + raise unsatisfied_error(requires["arch"]) + else: + raise unknown_error(unknown_message.format(key=key)) + +COMPARISON_OPERATORS = { + "__gt": ">", + "__gte": ">=", + "__lt": "<", + "__lte": "<=", +} + +MATCH_OPERATORS = ( + "__contains", + "__find", + "__startswith", + "__endswith", +) + + +class CheckResult: + """Result returned by shared check implementations.""" + + def __init__(self, failures=None, warnings=None): + self.failures = list(failures or []) + self.warnings = list(warnings or []) + + def ok(self): + return not self.failures + + +def _comparison_operators(comparison_operators=None): + return comparison_operators or COMPARISON_OPERATORS + + +def _operator_suffixes(comparison_operators=None): + return set(MATCH_OPERATORS) | set(_comparison_operators(comparison_operators)) + + +def _validate_keys(config, allowed, check_type): + for key in config: + if key not in allowed: + raise ValueError("Unexpected key in {} check: {}".format(check_type, key)) + + +def find_value(name, obj, comparison_operators=None): + """Find the value in an object for a field specified by name. + + Example names: + event_type + alert.signature_id + smtp.rcpt_to[0] + """ + parts = name.split(".") + operator_suffixes = _operator_suffixes(comparison_operators) + for part in parts: + if part == "__len": + try: + return len(obj) + except Exception: + return -1 + + if part in operator_suffixes: + break + + index = None + m = re.match(r"^(.*)\[(\d+)\]$", part) + if m: + key = m.group(1) + index = m.group(2) + else: + key = part + + if not isinstance(obj, dict) or key not in obj: + return None + obj = obj[key] + + if index is not None: + try: + obj = obj[int(index)] + except Exception: + return None + + return obj + + +def get_comparison_operator(key, comparison_operators=None): + """Return the comparison operator suffix from a check key, if present.""" + suffix = key.rsplit(".", 1)[-1] + if suffix in _comparison_operators(comparison_operators): + return suffix + return None + + +def compare_values(actual, expected, operator): + """Compare two numeric values using a comparison operator suffix.""" + if isinstance(actual, bool) or isinstance(expected, bool): + return False + if not isinstance(actual, (int, float)) or not isinstance(expected, (int, float)): + return False + if operator == "__gt": + return actual > expected + if operator == "__gte": + return actual >= expected + if operator == "__lt": + return actual < expected + if operator == "__lte": + return actual <= expected + raise ValueError("unknown comparison operator: {}".format(operator)) + + +def _as_list(value): + if isinstance(value, list): + return value + return [value] + + +def _contains(value, expected): + if value is None: + return False + try: + return expected in value + except TypeError: + return False + + +def match_event(config, event, comparison_operators=None, type_mismatch_callback=None): + for key, expected in config["match"].items(): + if key == "has-key": + for item in _as_list(expected): + if find_value(item, event, comparison_operators) is None: + return False + elif key == "not-has-key": + for item in _as_list(expected): + if find_value(item, event, comparison_operators) is not None: + return False + else: + val = find_value(key, event, comparison_operators) + if key.endswith("__find"): + if val is None or str(val).find(str(expected)) < 0: + return False + elif key.endswith("__contains"): + if not _contains(val, expected): + return False + elif key.endswith("__startswith"): + if val is None or not str(val).startswith(str(expected)): + return False + elif key.endswith("__endswith"): + if val is None or not str(val).endswith(str(expected)): + return False + else: + operator = get_comparison_operator(key, comparison_operators) + if operator is not None: + if not compare_values(val, expected, operator): + return False + elif val != expected: + if ( + type_mismatch_callback is not None + and str(val) == str(expected) + ): + type_mismatch_callback(val, expected) + return False + return True + + +def check_requirements( + requires, + require_checker=None, + suricata_config=None, + test_dir=None, + skip_as_warning=False, + skip_message=None, +): + if require_checker is None: + return CheckResult() + try: + require_checker(requires, suricata_config, test_dir) + except UnsatisfiedRequirementError as err: + if skip_as_warning: + if skip_message is None: + skip_message = "SKIP: check skipped: {}" + return CheckResult(warnings=[skip_message.format(err)]) + raise + return CheckResult() + + +class StatsCheck: + """Check values in the last stats event of eve.json.""" + + def __init__(self, config, output_dir, comparison_operators=None): + self.config = config + self.output_dir = output_dir + self.comparison_operators = comparison_operators + + def run(self): + eve_json_path = os.path.join(self.output_dir, "eve.json") + if not os.path.exists(eve_json_path): + return CheckResult(failures=["eve.json not found: {}".format(eve_json_path)]) + + stats = None + with open(eve_json_path, "r", encoding="utf-8") as fileobj: + for line in fileobj: + try: + event = json.loads(line) + except json.JSONDecodeError: + continue + if event.get("event_type") == "stats": + stats = event["stats"] + + if stats is None: + return CheckResult(failures=["no stats event found in eve.json"]) + + failures = [] + for key, expected in self.config.items(): + val = find_value(key, stats, self.comparison_operators) + operator = get_comparison_operator(key, self.comparison_operators) + if operator is not None: + if not compare_values(val, expected, operator): + symbol = _comparison_operators(self.comparison_operators)[operator] + failures.append( + "stats.{}: expected {} {}; got {}".format( + key, symbol, expected, val + ) + ) + elif val != expected: + failures.append( + "stats.{}: expected {}; got {}".format(key, expected, val) + ) + return CheckResult(failures=failures) + + +class FileCompareCheck: + def __init__(self, config, directory, output_dir, windows=False): + _validate_keys(config, ["requires", "filename", "expected"], "file-compare") + self.config = config + self.directory = directory + self.output_dir = output_dir + self.windows = windows + + def run(self): + if self.windows: + raise UnsatisfiedRequirementError("shell check not supported on Windows") + + expected = os.path.join(self.directory, self.config["expected"]) + filename = self.config["filename"] + if self.output_dir and not os.path.isabs(filename): + filename = os.path.join(self.output_dir, filename) + try: + if filecmp.cmp(expected, filename): + return CheckResult() + return CheckResult( + failures=[ + "{} {} \nFAILED: verification failed".format(expected, filename) + ] + ) + except Exception as err: + return CheckResult( + failures=["file-compare check failed with exception: {}".format(err)] + ) + + +class ShellCheck: + """Run a shell command in the test output directory.""" + + def __init__( + self, + config, + env, + output_dir, + suricata_config=None, + test_dir=None, + require_checker=None, + skip_as_warning=False, + use_bash=False, + windows=False, + ): + _validate_keys(config, ["requires", "args", "expect"], "shell") + self.config = config + self.env = env + self.output_dir = output_dir + self.suricata_config = suricata_config + self.test_dir = test_dir + self.require_checker = require_checker + self.skip_as_warning = skip_as_warning + self.use_bash = use_bash + self.windows = windows + + def run(self): + if not self.config or "args" not in self.config: + return CheckResult(failures=["shell check missing args"]) + + requires = self.config.get("requires", {}) + result = check_requirements( + requires, + self.require_checker, + self.suricata_config, + self.test_dir, + self.skip_as_warning, + "SKIP: shell check skipped: {}", + ) + if result.warnings: + return result + + if self.windows: + if self.skip_as_warning: + return CheckResult( + warnings=["SKIP: shell check skipped: shell check not supported on Windows"] + ) + raise UnsatisfiedRequirementError("shell check not supported on Windows") + + if self.use_bash: + cmd = ["bash", "-c", self.config["args"]] + run_kwargs = {"shell": False} + else: + cmd = self.config["args"] + run_kwargs = {"shell": True} + + completed = subprocess.run( + cmd, + cwd=self.output_dir, + env=self.env, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + universal_newlines=True, + check=False, + **run_kwargs + ) + if completed.returncode != 0: + details = [] + if completed.stdout.strip(): + details.append("stdout={!r}".format(completed.stdout.strip())) + if completed.stderr.strip(): + details.append("stderr={!r}".format(completed.stderr.strip())) + suffix = " ({})".format(", ".join(details)) if details else "" + return CheckResult( + failures=[ + "shell command failed with exit code {}: {!r}{}".format( + completed.returncode, self.config["args"], suffix + ) + ] + ) + + if "expect" in self.config: + output = completed.stdout.strip() + if str(self.config["expect"]) != output: + return CheckResult( + failures=[ + "shell check expected {!r}; got {!r}".format( + self.config["expect"], output + ) + ] + ) + + return CheckResult() + + +class FilterCheck: + """Filter JSON lines output and count matching events.""" + + def __init__( + self, + config, + output_dir, + suricata_config=None, + test_dir=None, + require_checker=None, + skip_as_warning=False, + test_version=None, + version_compat_checker=None, + comparison_operators=None, + type_mismatch_callback=None, + ): + _validate_keys( + config, ["count", "match", "filename", "requires", "comment"], "filter" + ) + self.config = config + self.output_dir = output_dir + self.suricata_config = suricata_config + self.test_dir = test_dir + self.require_checker = require_checker + self.skip_as_warning = skip_as_warning + self.test_version = test_version + self.version_compat_checker = version_compat_checker + self.comparison_operators = comparison_operators + self.type_mismatch_callback = type_mismatch_callback + + def run(self): + if "count" not in self.config: + return CheckResult(failures=["filter check missing count"]) + if "match" not in self.config: + return CheckResult(failures=["filter check missing match"]) + + requires = self.config.get("requires", {}) + if self.version_compat_checker is not None: + self.version_compat_checker(requires, self.test_version) + result = check_requirements( + requires, + self.require_checker, + self.suricata_config, + self.test_dir, + self.skip_as_warning, + "SKIP: filter check skipped: {}", + ) + if result.warnings: + return result + + if "filename" in self.config: + json_filename = self.config["filename"] + if not os.path.isabs(json_filename): + json_filename = os.path.join(self.output_dir, json_filename) + else: + json_filename = os.path.join(self.output_dir, "eve.json") + if not os.path.exists(json_filename): + return CheckResult(failures=["{} does not exist".format(json_filename)]) + + count = 0 + try: + with open(json_filename, "r", encoding="utf-8") as fileobj: + for line in fileobj: + event = json.loads(line) + if self.match(event): + count += 1 + except Exception as err: + return CheckResult( + failures=["filter check failed for {}: {}".format(json_filename, err)] + ) + + if count == self.config["count"]: + return CheckResult() + if "comment" in self.config: + return CheckResult( + failures=[ + "{}: expected {}, got {}".format( + self.config["comment"], self.config["count"], count + ) + ] + ) + return CheckResult( + failures=[ + "expected {} matches; got {} for filter {}".format( + self.config["count"], count, self.config + ) + ] + ) + + def match(self, event): + return match_event( + self.config, + event, + self.comparison_operators, + self.type_mismatch_callback, + ) diff --git a/live/.gitignore b/live/.gitignore new file mode 100644 index 0000000000..77ebff9b52 --- /dev/null +++ b/live/.gitignore @@ -0,0 +1 @@ +__* diff --git a/live/README.md b/live/README.md new file mode 100644 index 0000000000..0f71ce368e --- /dev/null +++ b/live/README.md @@ -0,0 +1,250 @@ +# Suricata-Verify Live Tests + +This directory contains a harness for tests that run Suricata against live +traffic. A live test runs Suricata in IDS or IPS mode while a real client and +server exchange traffic in an isolated network environment. + +## Running Tests + +Run all live tests from a built Suricata source directory: + +``` +sudo ../suricata-verify/live/run.py +``` + +As with the non-live runner, positional arguments select tests by name. Multiple +patterns may be provided, and `--exact` changes them from substring matches to +exact names. Use `--skip-tests` with a comma-separated list to exclude tests. + +``` +sudo ../suricata-verify/live/run.py simple-http pcap-ids +sudo ../suricata-verify/live/run.py --exact simple-http-ids +sudo ../suricata-verify/live/run.py --skip-tests=nfq,dpdk +``` + +## Supported Environments + +- tap: Creates a bridge in the DUT namespace so Suricata can passively monitor + traffic between the client and server namespaces. The Linux bridge acts like + a span port on a switch. Suricata can attach to its `br0` interface with a + compatible capture mechanism such as AF_PACKET or libpcap. + +- inline: Creates an inline topology in which Suricata forwards all traffic + between the client and server. This is useful for IPS testing with capture + mechanisms such as AF_PACKET or DPDK. + +- nfq: Creates a routed topology in which NFQUEUE intercepts traffic between + the client and server. + +Each test's `environment` selects its network topology. Its `args` select the +Suricata capture mechanism and run mode, using arguments such as `--pcap=br0`, +`--af-packet`, `--dpdk`, or `-q 0`. + +## Linux Network Namespaces + +Linux network namespaces provide an isolated network. The runner creates these +namespaces: + +- `dut`: The device under test, where Suricata runs. +- `server0`: The server namespace, where applications such as an HTTP server + run. +- `client0`: The client namespace, where user-controlled scripts run tools such + as `curl`. + +Client and server namespaces are numbered by network. The default topology has +one network (`client0`/`server0`), and there is only ever one `dut` namespace. +Endpoint interfaces are named `client` and `server`. DUT interfaces are named +`client0`/`server0`, `client1`/`server1`, and so on. + +## Default Topologies + +All three environments share the same default single-network layout: a +`client0` and a `server0` namespace, each wired to the `dut` namespace with a +veth pair. They differ in how the two DUT interfaces are connected to each +other. + +### tap + +The DUT interfaces are members of a Linux bridge, `br0`, which forwards traffic +on its own. Suricata attaches to `br0` (e.g., with `--pcap=br0` or +`--af-packet=br0`) and passively observes: + +``` ++------------+ +---------------------+ +------------+ +| client0 | | dut | | server0 | +| | | | | | +| client |-----| client0 server0 |-----| server | +| 10.200.0.2 | | | | | | 10.200.0.1 | ++------------+ | +----br0----+ | +------------+ + | | | + | Suricata | + +---------------------+ +``` + +### inline + +No bridge is created. The client and server can communicate only when Suricata +forwards traffic between the two DUT interfaces, using, for example, AF_PACKET +copy mode or DPDK: + +``` ++------------+ +---------------------+ +------------+ +| client0 | | dut | | server0 | +| | | | | | +| client |-----| client0 server0 |-----| server | +| 10.200.0.2 | | | | | | 10.200.0.1 | ++------------+ | +--Suricata-+ | +------------+ + +---------------------+ +``` + +### nfq + +The DUT is a router: it owns an address on each network, the endpoints use it +as their default gateway, and `iptables` queues all forwarded traffic to +NFQUEUE 0, where Suricata (`-q 0`) provides the verdict: + +``` ++------------+ +-----------------------------+ +------------+ +| client0 | | dut | | server0 | +| | | | | | +| client |-----| client0 server0 |-----| server | +| 10.200.1.2 | | 10.200.1.254 10.200.0.254 | | 10.200.0.1 | ++------------+ | | | | +------------+ + | +----NFQUEUE----+ | + | Suricata (-q 0) | + +-----------------------------+ +``` + +The client's default route points at `10.200.1.254`, the server's at +`10.200.0.254`, and the DUT has `ip_forward` enabled. + +## Per-test Inline Topologies + +Inline tests may replace the default single-network, unbonded, MTU-1500 layout +with a `topology` mapping. A custom topology must contain at least one network, +and every network must specify client and server IPv4 CIDRs in the same subnet. +Omit `bond` and `bond-mode` for an unbonded network. Set `bond: true` to make +both the endpoint and DUT logical interfaces bonds with two veth members. A +bonded network must also set `bond-mode`; there is no default bond mode. +Supported modes are `balance-rr`, `active-backup`, `balance-xor`, `broadcast`, +`802.3ad`, `balance-tlb`, and `balance-alb`. The optional topology MTU defaults +to 1500. + +``` +environment: inline + +topology: + mtu: 9000 + networks: + - client: 10.200.0.2/24 + server: 10.200.0.1/24 + bond: true + bond-mode: balance-rr + - client: 10.200.1.2/24 + server: 10.200.1.1/24 + bond: true + bond-mode: balance-rr +``` + +This creates endpoint namespaces `client0`, `server0`, `client1`, and +`server1`, plus `dut`. Endpoint scripts continue to use logical interfaces +named `client` and `server`; Suricata uses `client0`, `server0`, `client1`, and +`server1` in the DUT. All generated physical member names stay within Linux's +15-character interface-name limit. Custom topologies are rejected for the tap +and NFQ environments. + +The example above is the dual-network, dual-bond topology used by the +`afp-ips-bond-two-networks` test. Each `a`/`b` link below is a veth pair that +acts as a bond member. Endpoint members are named `client-a`/`client-b` (and +likewise `server-a`/`server-b`); DUT members are named +`client0-a`/`client0-b`, and so on. Suricata runs inline across each network's +bond pair (e.g., in AF_PACKET copy mode between `client0`/`server0` and between +`client1`/`server1`): + +``` ++------------+ +---------------------+ +------------+ +| client0 | | dut | | server0 | +| | | | | | +| client |--a--| client0 server0 |--a--| server | +| (bond) |--b--| (bond) (bond) |--b--| (bond) | +| 10.200.0.2 | | | | | | 10.200.0.1 | ++------------+ | +--Suricata-+ | +------------+ + | | ++------------+ | | +------------+ +| client1 | | | | server1 | +| | | | | | +| client |--a--| client1 server1 |--a--| server | +| (bond) |--b--| (bond) (bond) |--b--| (bond) | +| 10.200.1.2 | | | | | | 10.200.1.1 | ++------------+ | +--Suricata-+ | +------------+ + +---------------------+ +``` + +Omitting `topology` uses the default single-network, unbonded, MTU-1500 layout. + +## Test Requirements + +Tests can declare required host commands in `test.yaml`. If a required command +is missing, the test is skipped. Tests in the NFQ environment also implicitly +require Suricata's `NFQ` build feature. + +``` +requires: + command: + - podman + - curl +``` + +## Suricata Arguments + +Tests must provide Suricata command-line arguments with the `args` key in +`test.yaml`, including the capture mechanism and any required run-mode option. +Each entry is parsed using shell syntax after variable substitution. The +supported variables are `SRCDIR`, `TESTDIR`, `TEST_DIR`, `OUTDIR`, and +`OUTPUT_DIR`. + +``` +args: + - --pcap=br0 + - --set stream.checksum-validation=no +``` + +## Life Cycle of a Test + +- First, the runner creates the network namespaces and virtual interfaces. In + the tap environment, it creates a Linux bridge that acts like a switch or + span port. In the NFQ environment, it configures `iptables` for routing and + packet interception. + +- The optional `before` script performs additional per-test setup. Tests that + use Podman should build their containers here (e.g., with `podman build`). + +- The runner starts Suricata and waits for its "Engine started" message. The + test fails if Suricata does not become ready. + +- The runner starts the optional server script. A server may be Caddy, a Python + script, or another long-running service. It must remain alive while the client + runs. + + The server is considered ready after it remains alive for a short grace + period. The test fails if it exits unexpectedly. + +- The runner executes the client script, which drives the test by sending + traffic to the server. The test fails if the client exits with a nonzero + status. + +- After the client exits, the runner stops the server, sends SIGTERM to + Suricata, waits for Suricata to exit, and tears down the network environment. + +- Finally, the runner performs the configured verification checks. + +## Failing a Test + +Common failure conditions include: + +1. Suricata does not become ready. + +2. A setup, server, or client script exits unexpectedly or with a nonzero + status. + +3. A configured verification check fails. diff --git a/live/run.py b/live/run.py new file mode 100755 index 0000000000..68d24fd0a8 --- /dev/null +++ b/live/run.py @@ -0,0 +1,1783 @@ +#!/usr/bin/env python3 + +# Script to prepare live IPS namespace labs for Suricata testing. + +from __future__ import annotations + +import argparse +import fcntl +import glob +import ipaddress +import os +import platform +import re +import shlex +import signal +import stat +import string +import shutil +import subprocess +import sys + +sys.dont_write_bytecode = True + +import tempfile +import threading +import time +from collections import namedtuple +from dataclasses import dataclass +from typing import IO + +import yaml + +SCRIPT_DIR = os.path.dirname(os.path.realpath(__file__)) +VERIFY_DIR = os.path.dirname(SCRIPT_DIR) +if VERIFY_DIR not in sys.path: + sys.path.insert(0, VERIFY_DIR) + +from lib.common import ( + FilterCheck, + ShellCheck, + StatsCheck, + UnsatisfiedRequirementError, + check_required_commands as check_common_required_commands, + check_requires as check_common_requires, +) + +CLIENT_NS = "client0" +SERVER_NS = "server0" +DUT_NS = "dut" + +MTU = "1500" + +CLIENT_IF = "client" +SERVER_IF = "server" +DUT_CLIENT_IF = "client0" +DUT_SERVER_IF = "server0" +DUT_BRIDGE_IF = "br0" + +TMP_CLIENT_IF = "ptp-client" +TMP_SERVER_IF = "ptp-server" +TMP_DUT_CLIENT_IF = "ptp-client0" +TMP_DUT_SERVER_IF = "ptp-server0" + +L2_CLIENT_IP = "10.200.0.2/24" +L2_SERVER_IP = "10.200.0.1/24" + +NFQ_CLIENT_IP = "10.200.1.2/24" +NFQ_DUT_CLIENT_IP = "10.200.1.254/24" +NFQ_SERVER_IP = "10.200.0.1/24" +NFQ_DUT_SERVER_IP = "10.200.0.254/24" +NFQ_CLIENT_GW = "10.200.1.254" +NFQ_SERVER_GW = "10.200.0.254" +NFQ_QUEUE_NUM = "0" + +ALL_NAMESPACES = (CLIENT_NS, SERVER_NS, DUT_NS) +ROOT_LINKS = (TMP_CLIENT_IF, TMP_SERVER_IF, TMP_DUT_CLIENT_IF, TMP_DUT_SERVER_IF) +ENVIRONMENTS = ("inline", "tap", "nfq") + +verbose = False +suricata_config_cache = {} + +RUNNER_RUNTIME_DIR = "/run/suricata-verify-live" +RUNNER_LOCK_PATH = os.path.join(RUNNER_RUNTIME_DIR, "runner.lock") + + +BOND_MODES = { + "balance-rr", + "active-backup", + "balance-xor", + "broadcast", + "802.3ad", + "balance-tlb", + "balance-alb", +} + + +@dataclass(frozen=True) +class TopologyNetwork: + client: str + server: str + bond: bool + bond_mode: str | None + + +@dataclass(frozen=True) +class TestTopology: + mtu: int + networks: tuple[TopologyNetwork, ...] + + +def parse_topology( + config: dict, environment: str, path: str = "test.yaml" +) -> TestTopology | None: + """Parse the optional custom inline topology from a test definition.""" + raw = config.get("topology") + if raw is None: + return None + if environment != "inline": + raise ValueError(f'{path}: custom "topology" is only supported in the inline environment') + if not isinstance(raw, dict): + raise ValueError(f'{path}: "topology" must be a mapping') + + unknown = set(raw) - {"mtu", "networks"} + if unknown: + raise ValueError( + f"{path}: unknown topology key(s): {', '.join(sorted(map(str, unknown)))}" + ) + + mtu = raw.get("mtu", 1500) + if not isinstance(mtu, int) or isinstance(mtu, bool) or not 68 <= mtu <= 65535: + raise ValueError(f'{path}: topology "mtu" must be an integer from 68 to 65535') + + raw_networks = raw.get("networks") + if not isinstance(raw_networks, list) or not raw_networks: + raise ValueError(f'{path}: topology "networks" must contain at least one entry') + + networks = [] + for index, raw_network in enumerate(raw_networks): + prefix = f'{path}: topology network {index}' + if not isinstance(raw_network, dict): + raise ValueError(f"{prefix} must be a mapping") + unknown = set(raw_network) - {"client", "server", "bond", "bond-mode"} + if unknown: + raise ValueError( + f"{prefix} has unknown key(s): {', '.join(sorted(map(str, unknown)))}" + ) + if "client" not in raw_network or "server" not in raw_network: + raise ValueError(f'{prefix} requires explicit "client" and "server" CIDRs') + bond = raw_network.get("bond", False) + if not isinstance(bond, bool): + raise ValueError(f'{prefix} "bond" must be true or false') + bond_mode = raw_network.get("bond-mode") + if bond: + if bond_mode not in BOND_MODES: + modes = ", ".join(sorted(BOND_MODES)) + raise ValueError( + f'{prefix} "bond-mode" must be present and one of: {modes}' + ) + elif bond_mode is not None: + raise ValueError(f'{prefix} "bond-mode" requires "bond: true"') + try: + client = ipaddress.ip_interface(raw_network["client"]) + server = ipaddress.ip_interface(raw_network["server"]) + except (TypeError, ValueError) as err: + raise ValueError(f"{prefix} has an invalid CIDR: {err}") from err + if client.version != 4 or server.version != 4: + raise ValueError(f"{prefix} only supports IPv4 CIDRs") + if client.network != server.network: + raise ValueError(f"{prefix} client and server must be in the same subnet") + if client.ip == server.ip: + raise ValueError(f"{prefix} client and server addresses must differ") + + for iface in (f"client{index}-b", f"server{index}-b"): + if len(iface) > 15: + raise ValueError(f"{prefix} would create an interface name longer than 15 characters") + networks.append( + TopologyNetwork( + client=str(client), + server=str(server), + bond=bond, + bond_mode=bond_mode, + ) + ) + + return TestTopology(mtu=mtu, networks=tuple(networks)) + + +class RunnerLock: + """Non-blocking process lock for the shared live-test namespaces.""" + + def __init__(self, path: str = RUNNER_LOCK_PATH) -> None: + self.path = path + self.file = None + + def _open(self): + runtime_dir, filename = os.path.split(self.path) + if not runtime_dir or not filename: + raise RuntimeError(f"invalid runner lock path: {self.path}") + + try: + os.mkdir(runtime_dir, mode=0o700) + except FileExistsError: + pass + + dir_flags = os.O_RDONLY | os.O_CLOEXEC | os.O_DIRECTORY | os.O_NOFOLLOW + try: + dir_fd = os.open(runtime_dir, dir_flags) + except OSError as err: + raise RuntimeError( + f"unable to safely open runner runtime directory {runtime_dir}: {err}" + ) from err + + lock_fd = None + try: + dir_stat = os.fstat(dir_fd) + if dir_stat.st_uid != os.geteuid(): + raise RuntimeError( + f"runner runtime directory is not owned by uid {os.geteuid()}: " + f"{runtime_dir}" + ) + if stat.S_IMODE(dir_stat.st_mode) != 0o700: + raise RuntimeError( + f"runner runtime directory permissions are not 0700: {runtime_dir}" + ) + + lock_flags = os.O_RDWR | os.O_CREAT | os.O_CLOEXEC | os.O_NOFOLLOW + try: + lock_fd = os.open(filename, lock_flags, 0o600, dir_fd=dir_fd) + except OSError as err: + raise RuntimeError( + f"unable to safely open runner lock {self.path}: {err}" + ) from err + finally: + os.close(dir_fd) + + try: + lock_stat = os.fstat(lock_fd) + if not stat.S_ISREG(lock_stat.st_mode): + raise RuntimeError(f"runner lock is not a regular file: {self.path}") + if lock_stat.st_uid != os.geteuid(): + raise RuntimeError( + f"runner lock is not owned by uid {os.geteuid()}: {self.path}" + ) + if stat.S_IMODE(lock_stat.st_mode) != 0o600: + raise RuntimeError( + f"runner lock permissions are not 0600: {self.path}" + ) + if lock_stat.st_nlink != 1: + raise RuntimeError( + f"runner lock has an unexpected link count: {self.path}" + ) + return os.fdopen(lock_fd, "r+", encoding="utf-8") + except Exception: + os.close(lock_fd) + raise + + def __enter__(self): + self.file = self._open() + try: + fcntl.flock(self.file, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: + self.file.seek(0) + holder = self.file.read().strip() + print( + "ERROR: another live test runner is already active", + file=sys.stderr, + ) + print(f"Lock file: {self.path}", file=sys.stderr) + if holder: + print(holder, file=sys.stderr) + self.file.close() + sys.exit(1) + except Exception: + self.file.close() + self.file = None + raise + + self.file.seek(0) + self.file.truncate() + self.file.write(f"pid: {os.getpid()}\n") + self.file.write(f"cwd: {os.getcwd()}\n") + self.file.write(f"cmd: {shlex.join(sys.argv)}\n") + self.file.flush() + return self + + def __exit__(self, exc_type, exc, tb) -> None: + if self.file is None: + return + self.file.seek(0) + self.file.truncate() + fcntl.flock(self.file, fcntl.LOCK_UN) + self.file.close() + self.file = None + + +def configure_script_env() -> None: + """Expose helper binaries to client/server scripts via environment variables.""" + cwd = os.getcwd() + candidates = [ + os.path.join(cwd, "rust", "target", "debug", "suricatasc"), + os.path.join(cwd, "rust", "target", "release", "suricatasc"), + ] + for candidate in candidates: + if os.path.isfile(candidate): + os.environ["SURICATASC"] = os.path.realpath(candidate) + return + os.environ.pop("SURICATASC", None) + + +def load_markdown_frontmatter(path: str) -> dict: + """Load YAML (---) frontmatter from a Markdown file.""" + if not os.path.isfile(path): + return {} + + with open(path, encoding="utf-8") as f: + lines = f.read().splitlines() + + if not lines or lines[0].strip() != "---": + return {} + + body = [] + for line in lines[1:]: + if line.strip() == "---": + raw = "\n".join(body) + data = yaml.safe_load(raw) or {} + if not isinstance(data, dict): + raise ValueError(f"{path}: frontmatter must be a mapping") + return data + body.append(line) + + raise ValueError(f"{path}: unterminated frontmatter") + + +def get_test_tags(test_dir: str) -> set[str]: + """Return normalized tags from a test README frontmatter.""" + readme_path = None + for candidate in ("README.md", "readme.md"): + path = os.path.join(test_dir, candidate) + if os.path.isfile(path): + readme_path = path + break + + if readme_path is None: + return set() + + frontmatter = load_markdown_frontmatter(readme_path) + tags = frontmatter.get("tags", []) + if tags is None: + return set() + if isinstance(tags, str): + tags = [tags] + if not isinstance(tags, list) or any(not isinstance(tag, str) for tag in tags): + raise ValueError( + f"{readme_path}: frontmatter 'tags' must be a string or list of strings" + ) + return {tag.strip().lower() for tag in tags if tag.strip()} + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Live verification test runner.", + ) + parser.add_argument( + "-v", + "--verbose", + action="store_true", + help="show Suricata stdout/stderr output", + ) + + parser.add_argument( + "--environment", + choices=list(ENVIRONMENTS), + help="only run tests for this environment", + ) + parser.add_argument( + "--tag", + action="append", + default=[], + metavar="TAG", + help="only run tests whose README.md frontmatter tags include TAG; may be specified multiple times", + ) + parser.add_argument( + "--exact", + action="store_true", + help="use supplied names as exact matches", + ) + parser.add_argument( + "--skip-tests", + metavar="PATTERNS", + help="skip tests matching a comma-separated list of patterns", + ) + parser.add_argument( + "patterns", + nargs="*", + default=[], + help="only run tests whose names match these patterns", + ) + + return parser + + +def need_root() -> None: + if os.geteuid() == 0: + return + print( + "ERROR: this script must be run as root. Use: sudo run.py ...", + file=sys.stderr, + ) + sys.exit(1) + + +def need_cmd(cmd: str) -> None: + if shutil.which(cmd) is None: + print(f"ERROR: missing command: {cmd}", file=sys.stderr) + sys.exit(1) + + +def run( + cmd: list[str], *, quiet: bool = False, capture: bool = False +) -> subprocess.CompletedProcess[str]: + kwargs: dict[str, object] = { + "check": True, + "universal_newlines": True, + } + if quiet: + kwargs["stdout"] = subprocess.DEVNULL + kwargs["stderr"] = subprocess.DEVNULL + elif capture: + kwargs["stdout"] = subprocess.PIPE + kwargs["stderr"] = subprocess.PIPE + return subprocess.run(cmd, **kwargs) + + +def run_quiet(cmd: list[str]) -> None: + subprocess.run( + cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False + ) + + +def ns_exec( + ns: str, cmd: list[str], *, quiet: bool = False, capture: bool = False +) -> subprocess.CompletedProcess[str]: + return run(["ip", "netns", "exec", ns, *cmd], quiet=quiet, capture=capture) + + +def ns_run_quiet(ns: str, cmd: list[str]) -> None: + run_quiet(["ip", "netns", "exec", ns, *cmd]) + + +def netns_exists(ns: str) -> bool: + result = run(["ip", "netns", "list"], capture=True) + for line in result.stdout.splitlines(): + parts = line.split() + if parts and parts[0] == ns: + return True + return False + + +def kill_ns_processes(ns: str) -> None: + if not netns_exists(ns): + return + + result = run(["ip", "netns", "pids", ns], capture=True) + pids = [pid for pid in result.stdout.split() if pid.isdigit()] + if not pids: + return + + run_quiet(["kill", *pids]) + time.sleep(0.1) + + result = run(["ip", "netns", "pids", ns], capture=True) + pids = [pid for pid in result.stdout.split() if pid.isdigit()] + if pids: + run_quiet(["kill", "-9", *pids]) + + +def disable_offloads(ns: str, iface: str) -> None: + for feature in ("rx", "tx", "tso", "gro", "lro", "gso", "sg", "rxvlan", "txvlan"): + ns_run_quiet(ns, ["ethtool", "-K", iface, feature, "off"]) + + +def setup_namespaces() -> None: + for ns in ALL_NAMESPACES: + run(["ip", "netns", "add", ns]) + run(["ip", "-n", ns, "link", "set", "lo", "up"]) + ns_exec( + ns, ["sysctl", "-w", "net.ipv4.ping_group_range=0 2147483647"], quiet=True + ) + + +def setup_links() -> None: + run( + [ + "ip", + "link", + "add", + TMP_CLIENT_IF, + "type", + "veth", + "peer", + "name", + TMP_DUT_CLIENT_IF, + ] + ) + run( + [ + "ip", + "link", + "add", + TMP_SERVER_IF, + "type", + "veth", + "peer", + "name", + TMP_DUT_SERVER_IF, + ] + ) + + run(["ip", "link", "set", TMP_CLIENT_IF, "netns", CLIENT_NS]) + run(["ip", "link", "set", TMP_SERVER_IF, "netns", SERVER_NS]) + run(["ip", "link", "set", TMP_DUT_CLIENT_IF, "netns", DUT_NS]) + run(["ip", "link", "set", TMP_DUT_SERVER_IF, "netns", DUT_NS]) + + run(["ip", "-n", CLIENT_NS, "link", "set", TMP_CLIENT_IF, "name", CLIENT_IF]) + run(["ip", "-n", SERVER_NS, "link", "set", TMP_SERVER_IF, "name", SERVER_IF]) + run(["ip", "-n", DUT_NS, "link", "set", TMP_DUT_CLIENT_IF, "name", DUT_CLIENT_IF]) + run(["ip", "-n", DUT_NS, "link", "set", TMP_DUT_SERVER_IF, "name", DUT_SERVER_IF]) + + for ns, iface in ( + (CLIENT_NS, CLIENT_IF), + (SERVER_NS, SERVER_IF), + (DUT_NS, DUT_CLIENT_IF), + (DUT_NS, DUT_SERVER_IF), + ): + run(["ip", "-n", ns, "link", "set", iface, "mtu", MTU]) + + +def bring_up_interface(ns: str, iface: str) -> None: + disable_offloads(ns, iface) + ns_exec(ns, ["ip", "link", "set", iface, "up"]) + + +def add_address(ns: str, iface: str, cidr: str) -> None: + ns_exec(ns, ["ip", "addr", "add", cidr, "dev", iface]) + + +def replace_default_route(ns: str, via: str) -> None: + ns_exec(ns, ["ip", "route", "replace", "default", "via", via]) + + +def topology_namespaces(topology: TestTopology) -> tuple[str, ...]: + namespaces = [] + for index in range(len(topology.networks)): + namespaces.extend((f"client{index}", f"server{index}")) + namespaces.append(DUT_NS) + return tuple(namespaces) + + +def topology_root_links(topology: TestTopology) -> tuple[str, ...]: + links = [] + for index, network in enumerate(topology.networks): + members = "ab" if network.bond else "a" + for member in members: + links.extend( + ( + f"vc{index}{member}", + f"vdc{index}{member}", + f"vs{index}{member}", + f"vds{index}{member}", + ) + ) + return tuple(links) + + +def setup_topology_namespaces(topology: TestTopology) -> None: + for ns in topology_namespaces(topology): + run(["ip", "netns", "add", ns]) + run(["ip", "-n", ns, "link", "set", "lo", "up"]) + ns_exec( + ns, ["sysctl", "-w", "net.ipv4.ping_group_range=0 2147483647"], quiet=True + ) + + +def setup_topology_side( + index: int, side: str, network: TopologyNetwork, mtu: int +) -> None: + endpoint_ns = f"{side}{index}" + endpoint_if = side + dut_if = f"{side}{index}" + members = "ab" if network.bond else "a" + + for member in members: + root_endpoint = f"v{side[0]}{index}{member}" + root_dut = f"vd{side[0]}{index}{member}" + run( + [ + "ip", "link", "add", root_endpoint, "type", "veth", + "peer", "name", root_dut, + ] + ) + run(["ip", "link", "set", root_endpoint, "netns", endpoint_ns]) + run(["ip", "link", "set", root_dut, "netns", DUT_NS]) + + endpoint_member = f"{side}-{member}" if network.bond else endpoint_if + dut_member = f"{dut_if}-{member}" if network.bond else dut_if + run(["ip", "-n", endpoint_ns, "link", "set", root_endpoint, "name", endpoint_member]) + run(["ip", "-n", DUT_NS, "link", "set", root_dut, "name", dut_member]) + run(["ip", "-n", endpoint_ns, "link", "set", endpoint_member, "mtu", str(mtu)]) + run(["ip", "-n", DUT_NS, "link", "set", dut_member, "mtu", str(mtu)]) + + if network.bond: + for ns, bond_if, member_prefix in ( + (endpoint_ns, endpoint_if, side), + (DUT_NS, dut_if, dut_if), + ): + assert network.bond_mode is not None + ns_exec( + ns, + [ + "ip", + "link", + "add", + bond_if, + "type", + "bond", + "mode", + network.bond_mode, + ], + ) + ns_exec(ns, ["ip", "link", "set", bond_if, "mtu", str(mtu)]) + for member in members: + member_if = f"{member_prefix}-{member}" + ns_exec(ns, ["ip", "link", "set", member_if, "master", bond_if]) + ns_exec(ns, ["ip", "link", "set", member_if, "up"]) + + +def inline_topology_up(topology: TestTopology) -> None: + do_down(topology=topology) + setup_topology_namespaces(topology) + for index, network in enumerate(topology.networks): + setup_topology_side(index, "client", network, topology.mtu) + setup_topology_side(index, "server", network, topology.mtu) + add_address(f"client{index}", CLIENT_IF, network.client) + add_address(f"server{index}", SERVER_IF, network.server) + for ns, iface in ( + (f"client{index}", CLIENT_IF), + (f"server{index}", SERVER_IF), + (DUT_NS, f"client{index}"), + (DUT_NS, f"server{index}"), + ): + bring_up_interface(ns, iface) + + +def setup_common_topology() -> None: + do_down() + setup_namespaces() + setup_links() + + +def inline_up() -> None: + setup_common_topology() + + add_address(CLIENT_NS, CLIENT_IF, L2_CLIENT_IP) + add_address(SERVER_NS, SERVER_IF, L2_SERVER_IP) + + bring_up_interface(CLIENT_NS, CLIENT_IF) + bring_up_interface(SERVER_NS, SERVER_IF) + bring_up_interface(DUT_NS, DUT_CLIENT_IF) + bring_up_interface(DUT_NS, DUT_SERVER_IF) + +def setup_tap_bridge() -> None: + ns_exec(DUT_NS, ["ip", "link", "add", "name", DUT_BRIDGE_IF, "type", "bridge"]) + ns_exec(DUT_NS, ["ip", "link", "set", DUT_CLIENT_IF, "master", DUT_BRIDGE_IF]) + ns_exec(DUT_NS, ["ip", "link", "set", DUT_SERVER_IF, "master", DUT_BRIDGE_IF]) + ns_exec(DUT_NS, ["ip", "link", "set", DUT_BRIDGE_IF, "up"]) + + +def tap_up() -> None: + setup_common_topology() + + add_address(CLIENT_NS, CLIENT_IF, L2_CLIENT_IP) + add_address(SERVER_NS, SERVER_IF, L2_SERVER_IP) + + setup_tap_bridge() + + bring_up_interface(CLIENT_NS, CLIENT_IF) + bring_up_interface(SERVER_NS, SERVER_IF) + bring_up_interface(DUT_NS, DUT_CLIENT_IF) + bring_up_interface(DUT_NS, DUT_SERVER_IF) + +def setup_nfq_iptables() -> None: + ns_exec(DUT_NS, ["iptables", "-F"]) + ns_exec(DUT_NS, ["iptables", "-P", "FORWARD", "DROP"]) + ns_exec( + DUT_NS, + [ + "iptables", + "-A", + "FORWARD", + "-i", + DUT_CLIENT_IF, + "-o", + DUT_SERVER_IF, + "-j", + "NFQUEUE", + "--queue-num", + NFQ_QUEUE_NUM, + ], + ) + ns_exec( + DUT_NS, + [ + "iptables", + "-A", + "FORWARD", + "-i", + DUT_CLIENT_IF, + "-o", + DUT_SERVER_IF, + "-j", + "ACCEPT", + ], + ) + ns_exec( + DUT_NS, + [ + "iptables", + "-A", + "FORWARD", + "-i", + DUT_SERVER_IF, + "-o", + DUT_CLIENT_IF, + "-j", + "NFQUEUE", + "--queue-num", + NFQ_QUEUE_NUM, + ], + ) + ns_exec( + DUT_NS, + [ + "iptables", + "-A", + "FORWARD", + "-i", + DUT_SERVER_IF, + "-o", + DUT_CLIENT_IF, + "-j", + "ACCEPT", + ], + ) + + +def nfq_up() -> None: + setup_common_topology() + + add_address(CLIENT_NS, CLIENT_IF, NFQ_CLIENT_IP) + add_address(SERVER_NS, SERVER_IF, NFQ_SERVER_IP) + add_address(DUT_NS, DUT_CLIENT_IF, NFQ_DUT_CLIENT_IP) + add_address(DUT_NS, DUT_SERVER_IF, NFQ_DUT_SERVER_IP) + + bring_up_interface(CLIENT_NS, CLIENT_IF) + bring_up_interface(SERVER_NS, SERVER_IF) + bring_up_interface(DUT_NS, DUT_CLIENT_IF) + bring_up_interface(DUT_NS, DUT_SERVER_IF) + + replace_default_route(CLIENT_NS, NFQ_CLIENT_GW) + replace_default_route(SERVER_NS, NFQ_SERVER_GW) + + ns_exec(DUT_NS, ["sysctl", "-w", "net.ipv4.ip_forward=1"], quiet=True) + setup_nfq_iptables() + +def framework_namespaces(topology: TestTopology | None = None) -> tuple[str, ...]: + """Return expected and existing namespaces reserved by the live runner.""" + namespaces = set(ALL_NAMESPACES) + if topology: + namespaces.update(topology_namespaces(topology)) + + result = run(["ip", "netns", "list"], capture=True) + for line in result.stdout.splitlines(): + parts = line.split() + if parts and re.fullmatch(r"(?:client|server)\d+|dut", parts[0]): + namespaces.add(parts[0]) + return tuple(sorted(namespaces)) + + +def framework_root_links(topology: TestTopology | None = None) -> tuple[str, ...]: + """Return expected and existing root links reserved by the live runner.""" + links = set(ROOT_LINKS) + if topology: + links.update(topology_root_links(topology)) + + for link in os.listdir("/sys/class/net"): + if re.fullmatch(r"v(?:d)?[cs]\d+[ab]", link): + links.add(link) + return tuple(sorted(links)) + + +def do_down(*, topology: TestTopology | None = None) -> None: + namespaces = framework_namespaces(topology) + root_links = framework_root_links(topology) + + for ns in namespaces: + kill_ns_processes(ns) + + for link in root_links: + run_quiet(["ip", "link", "del", link]) + + for ns in namespaces: + run_quiet(["ip", "netns", "del", ns]) + +UP_FUNCS = { + "nfq": nfq_up, + "inline": inline_up, + "tap": tap_up, +} + +SURICATA_READY_MARKER = "Engine started" + + +def render_test_include(test_dir: str, output_dir: str) -> str | None: + """Render test include.yaml into the output directory, if present.""" + src = os.path.join(test_dir, "include.yaml") + if not os.path.isfile(src): + return None + + with open(src, encoding="utf-8") as f: + content = f.read() + + content = content.replace("${TESTDIR}", os.path.realpath(test_dir)) + content = content.replace("${OUTDIR}", os.path.realpath(output_dir)) + + dst = os.path.join(output_dir, "include.yaml") + with open(dst, "w", encoding="utf-8") as f: + f.write(content) + return dst + + +def get_include_args(test_include: str | None = None) -> list[str]: + """Return Suricata --include args for the selected test.""" + args = [] + if test_include: + args += ["--include", os.path.realpath(test_include)] + return args + + +def build_test_env(test_dir: str, output_dir: str) -> dict[str, str]: + """Return the standard environment exposed to test scripts and checks.""" + env = os.environ.copy() + env["SRCDIR"] = os.getcwd() + env["TZ"] = "UTC" + env["TESTDIR"] = os.path.realpath(test_dir) + env["TEST_DIR"] = env["TESTDIR"] + env["OUTDIR"] = os.path.realpath(output_dir) + env["OUTPUT_DIR"] = env["OUTDIR"] + return env + + +def get_test_args( + config: dict, test_dir: str | None = None, output_dir: str | None = None +) -> list[str]: + """Return Suricata CLI args from test.yaml.""" + raw_args = config.get("args", []) + if raw_args is None: + return [] + if not isinstance(raw_args, list): + raise ValueError('test.yaml "args" must be an array') + + substitutions = {"SRCDIR": os.getcwd()} + if test_dir: + substitutions["TESTDIR"] = os.path.realpath(test_dir) + substitutions["TEST_DIR"] = substitutions["TESTDIR"] + if output_dir: + substitutions["OUTDIR"] = os.path.realpath(output_dir) + substitutions["OUTPUT_DIR"] = substitutions["OUTDIR"] + + args: list[str] = [] + for arg in raw_args: + if not isinstance(arg, str): + raise ValueError('test.yaml "args" entries must be strings') + rendered = string.Template(arg).safe_substitute(substitutions) + args.extend(shlex.split(rendered)) + return args + + +def has_suricata_runmode_arg(args: list[str]) -> bool: + """Return true if args select a Suricata capture/runmode.""" + for arg in args: + if arg in ("--af-packet", "--pcap", "--dpdk", "-q"): + return True + if arg.startswith("--af-packet=") or arg.startswith("--pcap="): + return True + return False + + +def start_suricata( + environment: str, + script_dir: str, + test_dir: str, + output_dir: str, + config: dict, + test_include: str | None = None, +) -> subprocess.Popen[str]: + """Start Suricata in the DUT namespace and return the Popen handle.""" + cwd = os.getcwd() + suricata_bin = os.path.join(cwd, "src", "suricata") + suricata_yaml = os.path.join(cwd, "suricata.yaml") + + cmd = [ + "ip", + "netns", + "exec", + DUT_NS, + suricata_bin, + "-c", + suricata_yaml, + *get_include_args(test_include), + "-l", + output_dir, + "--set", + f"unix-command.filename={os.path.join(output_dir, 'suricata.socket')}", + "--set", + f"classification-file={os.path.join(cwd, 'etc', 'classification.config')}", + "--set", + "reference-config-file=./etc/reference.config", + "--set", + "threshold-file=./threshold.config", + ] + + if environment not in UP_FUNCS: + raise ValueError(f"start_suricata: unsupported environment '{environment}'") + + test_args = get_test_args(config, test_dir, output_dir) + if not has_suricata_runmode_arg(test_args): + raise ValueError( + 'test.yaml "args" must include Suricata runmode args ' + "(--af-packet, --pcap=..., --dpdk, or -q ...)" + ) + cmd += test_args + + if verbose: + print(f"===> Suricata command: {shlex.join(cmd)}") + + return subprocess.Popen( + cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True + ) + + +def tee_stream(pipe, log_file, label: str) -> None: + """Read from pipe, write every line to log_file, and print if verbose. + + During shutdown, another thread may close either the pipe or the log file. + Treat that as normal teardown instead of raising noisy thread exceptions. + """ + try: + for line in pipe: + try: + log_file.write(line) + log_file.flush() + except ValueError: + return + if verbose: + print(f"===> {label}: {line}", end="") + except ValueError: + return + + +def wait_for_suricata( + proc: subprocess.Popen[str], stdout_log, timeout: float = 60 +) -> bool: + """Wait until Suricata logs that the engine has started. Returns True on success.""" + deadline = time.monotonic() + timeout + assert proc.stdout is not None + while time.monotonic() < deadline: + line = proc.stdout.readline() + if not line: + if proc.poll() is not None: + print( + "===> ERROR: Suricata exited before becoming ready", file=sys.stderr + ) + return False + continue + stdout_log.write(line) + stdout_log.flush() + if verbose: + print(f"===> suricata stdout: {line}", end="") + if SURICATA_READY_MARKER in line: + return True + print("===> ERROR: timed out waiting for Suricata to start", file=sys.stderr) + return False + + +def stop_suricata(proc: subprocess.Popen[str], timeout: float = 30) -> int: + """Send SIGTERM to Suricata and wait for it to exit.""" + proc.send_signal(signal.SIGTERM) + try: + proc.wait(timeout=timeout) + except subprocess.TimeoutExpired: + print( + "===> WARNING: Suricata did not exit in time, sending SIGKILL", + file=sys.stderr, + ) + proc.kill() + proc.wait() + return proc.returncode + + +def write_script(script: str) -> str: + with tempfile.NamedTemporaryFile(mode="w", suffix=".sh", delete=False) as f: + f.write("#!/bin/bash\nset -e\n") + f.write(script) + f.flush() + script_path = f.name + os.chmod(script_path, 0o755) + return script_path + + +def terminate_process_group(pgid: int, timeout: float = 10) -> None: + """Terminate a process group and wait briefly for it to disappear.""" + try: + os.killpg(pgid, signal.SIGTERM) + except ProcessLookupError: + return + + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + try: + os.killpg(pgid, 0) + except ProcessLookupError: + return + time.sleep(0.1) + + try: + os.killpg(pgid, signal.SIGKILL) + except ProcessLookupError: + return + + deadline = time.monotonic() + 5 + while time.monotonic() < deadline: + try: + os.killpg(pgid, 0) + except ProcessLookupError: + return + time.sleep(0.1) + + +def run_script_logged( + script: str, cwd: str, stdout_path: str, stderr_path: str, label: str = "script" +) -> int: + """Run a script, logging stdout/stderr to files and teeing to terminal in real time.""" + script_path = write_script(script) + try: + with open(stdout_path, "w") as out_f, open(stderr_path, "w") as err_f: + proc = subprocess.Popen( + ["bash", script_path], + cwd=cwd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + start_new_session=True, + ) + stdout_thread = threading.Thread( + target=tee_stream, + args=(proc.stdout, out_f, f"{label} stdout"), + daemon=True, + ) + stderr_thread = threading.Thread( + target=tee_stream, + args=(proc.stderr, err_f, f"{label} stderr"), + daemon=True, + ) + stdout_thread.start() + stderr_thread.start() + result = proc.wait() + terminate_process_group(proc.pid) + + for pipe in (proc.stdout, proc.stderr): + if pipe is not None: + try: + pipe.close() + except Exception: + pass + + stdout_thread.join(timeout=5) + stderr_thread.join(timeout=5) + return result + finally: + os.unlink(script_path) + + +@dataclass +class ServerScript: + """Manages a background server script with log capture and cleanup.""" + + proc: subprocess.Popen[str] + script_path: str + stdout_log: IO[str] + stderr_log: IO[str] + stdout_thread: threading.Thread + stderr_thread: threading.Thread + + def wait_for_start(self, grace_period: float = 1.0) -> bool: + """Return True if the script stays alive for a brief startup window.""" + deadline = time.monotonic() + grace_period + while time.monotonic() < deadline: + if self.proc.poll() is not None: + return False + time.sleep(0.1) + return self.proc.poll() is None + + def stop(self, timeout: float = 10) -> int: + """Stop the script, join threads, close logs, and clean up the temp file.""" + try: + os.killpg(self.proc.pid, signal.SIGTERM) + except ProcessLookupError: + pass + else: + try: + self.proc.wait(timeout=timeout) + except subprocess.TimeoutExpired: + try: + os.killpg(self.proc.pid, signal.SIGKILL) + except ProcessLookupError: + pass + self.proc.wait() + + # Reap the script before polling the process group. Otherwise the + # exited script can remain as a zombie and make os.killpg(pgid, 0) + # look alive until the timeout expires. + if self.proc.returncode is None: + self.proc.wait() + terminate_process_group(self.proc.pid, timeout=1) + + # Ensure the tee threads see EOF/shutdown before we close their log files. + for pipe in (self.proc.stdout, self.proc.stderr): + if pipe is not None: + try: + pipe.close() + except Exception: + pass + + self.stdout_thread.join(timeout=5) + self.stderr_thread.join(timeout=5) + self.stdout_log.close() + self.stderr_log.close() + + try: + os.unlink(self.script_path) + except FileNotFoundError: + pass + + return self.proc.returncode if self.proc.returncode is not None else 0 + + +def start_background_script( + script: str, + cwd: str, + stdout_path: str, + stderr_path: str, + label: str = "script", +) -> ServerScript: + """Start a background server script, logging stdout/stderr to files. Print if verbose.""" + script_path = write_script(script) + try: + proc = subprocess.Popen( + ["bash", script_path], + cwd=cwd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + start_new_session=True, + ) + except Exception: + os.unlink(script_path) + raise + + stdout_log = open(stdout_path, "w") + stderr_log = open(stderr_path, "w") + stdout_thread = threading.Thread( + target=tee_stream, + args=(proc.stdout, stdout_log, f"{label} stdout"), + daemon=True, + ) + stderr_thread = threading.Thread( + target=tee_stream, + args=(proc.stderr, stderr_log, f"{label} stderr"), + daemon=True, + ) + stdout_thread.start() + stderr_thread.start() + return ServerScript( + proc=proc, + script_path=script_path, + stdout_log=stdout_log, + stderr_log=stderr_log, + stdout_thread=stdout_thread, + stderr_thread=stderr_thread, + ) + + +SuricataVersion = namedtuple("SuricataVersion", ["major", "minor", "patch"]) + + +def parse_suricata_version( + buf: object, expr: str | None = None +) -> SuricataVersion | None: + m = re.search( + r"(?:Suricata version |^)(\d+)\.?((?:\d+))?\.?((?:\d+))?.*", str(buf).strip() + ) + default_v = 0 + if expr == "equal": + default_v = None + if not m: + return None + + major = int(m.group(1)) if m.group(1) else default_v + minor = int(m.group(2)) if m.group(2) else default_v + patch = int(m.group(3)) if m.group(3) else default_v + return SuricataVersion(major=major, minor=minor, patch=patch) + + +class Version: + """Class to compare Suricata versions.""" + + def is_equal(self, a: SuricataVersion, b: SuricataVersion) -> bool: + if a.major != b.major: + return False + if a.minor is not None and b.minor is not None and a.minor != b.minor: + return False + if a.patch is not None and b.patch is not None and a.patch != b.patch: + return False + return True + + def is_gte(self, v1: SuricataVersion, v2: SuricataVersion) -> bool: + if v1.major < v2.major: + return False + if v1.major > v2.major: + return True + if v1.minor < v2.minor: + return False + if v1.minor > v2.minor: + return True + if v1.patch < v2.patch: + return False + return True + + def is_gt(self, v1: SuricataVersion, v2: SuricataVersion) -> bool: + if v1.major < v2.major: + return False + if v1.major > v2.major: + return True + if v1.minor < v2.minor: + return False + if v1.minor > v2.minor: + return True + if v1.patch < v2.patch: + return False + if v1.patch == v2.patch: + return False + return True + + def is_lt(self, v1: SuricataVersion, v2: SuricataVersion) -> bool: + if v1.major > v2.major: + return False + if v1.major < v2.major: + return True + if v1.minor < v2.minor: + return True + if v1.patch < v2.patch: + return True + return False + + +class SuricataConfig: + def __init__(self, suricata_bin: str, version: SuricataVersion) -> None: + self.suricata_bin = suricata_bin + self.version = version + self.features = set() + self.config = {} + self.load_build_info() + + def load_build_info(self) -> None: + output = subprocess.check_output([self.suricata_bin, "--build-info"]) + start_support = False + for line in output.splitlines(): + decoded = line.decode() + if decoded.startswith("Features:"): + self.features = set(decoded.split()[1:]) + if "Suricata Configuration" in decoded: + start_support = True + if start_support and "support:" in decoded: + fkey, val = decoded.split(" support:") + fkey = fkey.strip() + val = val.strip() + if val.startswith("yes"): + self.features.add(fkey) + + def load_config( + self, config_filename: str, extra_args: list[str] | None = None + ) -> None: + cmd = [self.suricata_bin, "-c", config_filename] + if extra_args: + cmd.extend(extra_args) + cmd.append("--dump-config") + output = subprocess.check_output(cmd) + self.config = {} + for line in output.decode("utf-8").split("\n"): + parts = [p.strip() for p in line.split("=", 1)] + if parts and parts[0]: + self.config[parts[0]] = parts[1] if len(parts) > 1 else "" + + def has_feature(self, feature: str) -> bool: + return feature in self.features + + +def get_suricata_config( + environment: str, + script_dir: str, + config: dict, + test_dir: str, + output_dir: str, + test_include: str | None = None, +) -> SuricataConfig: + extra_args = [ + *get_include_args(test_include), + *get_test_args(config, test_dir, output_dir), + ] + cache_key = ( + environment, + os.path.realpath(test_include) if test_include else None, + tuple(extra_args), + ) + if cache_key in suricata_config_cache: + return suricata_config_cache[cache_key] + + cwd = os.getcwd() + suricata_bin = os.path.join(cwd, "src", "suricata") + suricata_yaml = os.path.join(cwd, "suricata.yaml") + version = parse_suricata_version(subprocess.check_output([suricata_bin, "-V"])) + if version is None: + raise ValueError("failed to determine Suricata version") + + suricata_config = SuricataConfig(suricata_bin, version) + suricata_config.load_config(suricata_yaml, extra_args) + suricata_config_cache[cache_key] = suricata_config + return suricata_config + + +def is_version_compatible( + version: str, suri_version: SuricataVersion, expr: str +) -> bool: + config_version = parse_suricata_version(version, expr) + if config_version is None: + return False + version_obj = Version() + func = getattr(version_obj, f"is_{expr}") + return func(suri_version, config_version) + + +def check_required_commands(requires: dict) -> None: + check_common_required_commands(requires) + + +def check_requires( + requires: dict, suricata_config: SuricataConfig, suri_dir: str | None = None +) -> None: + check_common_requires( + requires, + suricata_config, + is_version_compatible, + suri_dir, + eval_globals=globals(), + include_script_error=True, + ) + + +def run_checks( + config: dict, output_dir: str, environment: str, script_dir: str, test_dir: str +) -> tuple[list[str], list[str]]: + """Run post-teardown checks. + + Returns (failures, warnings). + """ + failures = [] + warnings = [] + supported_checks = {"stats", "filter", "shell"} + suricata_config = None + test_include = os.path.join(output_dir, "include.yaml") + if not os.path.isfile(test_include): + test_include = None + + for i, check in enumerate(config.get("checks", []), start=1): + if "stats" in check: + result = StatsCheck(check["stats"], output_dir).run() + failures.extend(result.failures) + warnings.extend(result.warnings) + if "filter" in check or "shell" in check: + try: + if suricata_config is None: + suricata_config = get_suricata_config( + environment, script_dir, config, test_dir, output_dir, test_include + ) + if "filter" in check: + result = FilterCheck( + check["filter"], + output_dir, + suricata_config=suricata_config, + test_dir=test_dir, + require_checker=check_requires, + skip_as_warning=True, + ).run() + failures.extend(result.failures) + warnings.extend(result.warnings) + if "shell" in check: + result = ShellCheck( + check["shell"], + build_test_env(test_dir, output_dir), + output_dir, + suricata_config=suricata_config, + test_dir=test_dir, + require_checker=check_requires, + skip_as_warning=True, + use_bash=True, + ).run() + failures.extend(result.failures) + warnings.extend(result.warnings) + except Exception as err: + check_type = "filter" if "filter" in check else "shell" + failures.append(f"{check_type} check #{i} failed: {err}") + for key in check: + if key not in supported_checks: + warnings.append( + f"WARNING: unsupported check type '{key}' in check #{i}" + ) + return failures, warnings + + +def add_script_log_paths(failures: list[str], output_dir: str, name: str) -> None: + """Append the stdout/stderr log paths for a script to the failure list.""" + failures.append(f"{name} stdout: {os.path.join(output_dir, f'{name}.stdout')}") + failures.append(f"{name} stderr: {os.path.join(output_dir, f'{name}.stderr')}") + + +def log_test_step(environment: str, test_name: str, message: str) -> None: + print(f"===> [{environment}/{test_name}] {message}", flush=True) + + +def run_test( + test_name: str, + environment: str, + config: dict, + script_dir: str, + test_dir: str, + topology: TestTopology | None = None, +) -> list[str]: + """Run a single test in the given environment. Returns failure messages.""" + failures = [] + client_script = config.get("client") + if not client_script: + return ["no client script defined"] + + before_script = config.get("before") + server_script = config.get("server") + + output_dir = os.path.join(test_dir, "output") + if os.path.exists(output_dir): + shutil.rmtree(output_dir) + os.makedirs(output_dir) + + test_env = build_test_env(test_dir, output_dir) + managed_env_keys = ["SRCDIR", "TZ", "TESTDIR", "TEST_DIR", "OUTDIR", "OUTPUT_DIR"] + prev_test_env = {key: os.environ.get(key) for key in managed_env_keys} + for key in managed_env_keys: + os.environ[key] = test_env[key] + test_include = render_test_include(test_dir, output_dir) + + stdout_log = None + stderr_log = None + suricata = None + stderr_thread = None + stdout_thread = None + server: ServerScript | None = None + client_rc = -1 + server_rc = 0 + try: + log_test_step(environment, test_name, f"Setting up {environment} environment") + if topology: + inline_topology_up(topology) + else: + UP_FUNCS[environment]() + + if before_script: + log_test_step(environment, test_name, "Running before script") + before_rc = run_script_logged( + before_script, + test_dir, + os.path.join(output_dir, "before.stdout"), + os.path.join(output_dir, "before.stderr"), + label="before", + ) + if before_rc != 0: + failures.append(f"before script exited with code {before_rc}") + add_script_log_paths(failures, output_dir, "before") + return failures + + log_test_step(environment, test_name, "Starting Suricata") + + stdout_log = open(os.path.join(output_dir, "stdout"), "w") + stderr_log = open(os.path.join(output_dir, "stderr"), "w") + try: + suricata = start_suricata( + environment, script_dir, test_dir, output_dir, config, test_include + ) + except ValueError as err: + failures.append(str(err)) + return failures + + stderr_thread = threading.Thread( + target=tee_stream, + args=(suricata.stderr, stderr_log, "suricata stderr"), + daemon=True, + ) + stderr_thread.start() + + if not wait_for_suricata(suricata, stdout_log): + failures.append("Suricata did not become ready") + failures.append(f"suricata stdout: {os.path.join(output_dir, 'stdout')}") + failures.append(f"suricata stderr: {os.path.join(output_dir, 'stderr')}") + return failures + + stdout_thread = threading.Thread( + target=tee_stream, + args=(suricata.stdout, stdout_log, "suricata stdout"), + daemon=True, + ) + stdout_thread.start() + + if server_script: + log_test_step(environment, test_name, "Starting Server") + server = start_background_script( + server_script, + test_dir, + os.path.join(output_dir, "server.stdout"), + os.path.join(output_dir, "server.stderr"), + label="server", + ) + if not server.wait_for_start(): + server_rc = ( + server.proc.returncode if server.proc.returncode is not None else 1 + ) + failures.append( + f"server script exited during startup with code {server_rc}" + ) + add_script_log_paths(failures, output_dir, "server") + return failures + + log_test_step(environment, test_name, "Running Client") + client_rc = run_script_logged( + client_script, + test_dir, + os.path.join(output_dir, "client.stdout"), + os.path.join(output_dir, "client.stderr"), + label="client", + ) + if client_rc != 0: + failures.append(f"client script exited with code {client_rc}") + add_script_log_paths(failures, output_dir, "client") + + if server and server.proc.poll() is not None: + server_rc = server.proc.returncode + if server_rc != 0: + failures.append(f"server script exited with code {server_rc}") + add_script_log_paths(failures, output_dir, "server") + finally: + if server: + log_test_step(environment, test_name, "Stopping Server") + server.stop() + if suricata: + log_test_step(environment, test_name, "Stopping Suricata") + stop_suricata(suricata) + if stdout_thread: + stdout_thread.join(timeout=5) + if stderr_thread: + stderr_thread.join(timeout=5) + if stdout_log: + stdout_log.close() + if stderr_log: + stderr_log.close() + do_down(topology=topology) + for key, value in prev_test_env.items(): + if value is None: + os.environ.pop(key, None) + else: + os.environ[key] = value + + return failures + + +def get_environment_requires(requires: dict, environment: str) -> dict: + """ Get requirements, taking the environment into consideration. + + For example, a test can't depend on NFQ, but NFQ is required to run tests in + the NFQ environment, so slip NFQ into the requirements as needed. + + Adding this requirement here keeps NFQ-specific build checks out of + otherwise generic test definitions. + """ + if environment != "nfq" or not isinstance(requires, dict): + return requires + + environment_requires = dict(requires) + features = list(environment_requires.get("features", [])) + if "NFQ" not in features: + features.append("NFQ") + environment_requires["features"] = features + return environment_requires + + +def check_test_requires(requires: dict, environment: str, test_dir: str) -> None: + """Validate test-level requirements before setting up namespaces.""" + requires = get_environment_requires(requires, environment) + check_required_commands(requires) + if not (set(requires) - {"command"}): + return + + cwd = os.getcwd() + suricata_bin = os.path.join(cwd, "src", "suricata") + version = parse_suricata_version(subprocess.check_output([suricata_bin, "-V"])) + if version is None: + raise ValueError("failed to determine Suricata version") + suricata_config = SuricataConfig(suricata_bin, version) + check_requires(requires, suricata_config, cwd) + + +def do_run( + *, + only_environment: str | None = None, + patterns: list[str] | None = None, + exact: bool = False, + skip_tests: str | None = None, + tags: list[str] | None = None, +) -> bool: + """Run tests. Returns True if all tests passed.""" + passed = 0 + failed = 0 + skipped = 0 + failing_tests = [] + patterns = patterns or [] + skip_patterns = [pattern for pattern in (skip_tests or "").split(",") if pattern] + selected_tags = {tag.strip().lower() for tag in (tags or []) if tag.strip()} + script_dir = os.path.dirname(os.path.realpath(__file__)) + tests_dir = os.path.join(script_dir, "tests") + for root, dirs, files in os.walk(tests_dir): + dirs.sort() + if "test.yaml" not in files: + continue + + test_name = os.path.basename(root) + relative_name = os.path.relpath(root, tests_dir) + if patterns and not any( + pattern in relative_name + if not exact + else pattern in (relative_name, test_name) + for pattern in patterns + ): + continue + if any( + pattern in relative_name + if not exact + else pattern in (relative_name, test_name) + for pattern in skip_patterns + ): + continue + if selected_tags: + try: + test_tags = get_test_tags(root) + except ValueError as err: + print(f"ERROR: {err}", file=sys.stderr) + failed += 1 + failing_tests.append(test_name) + continue + if not selected_tags.issubset(test_tags): + continue + + test_yaml = os.path.join(root, "test.yaml") + with open(test_yaml) as f: + config = yaml.safe_load(f) + + environment = config.get("environment") + if not isinstance(environment, str): + print(f"ERROR: invalid environment in {test_yaml}: {environment!r}", file=sys.stderr) + failed += 1 + failing_tests.append(test_name) + continue + if only_environment and environment != only_environment: + continue + if environment not in UP_FUNCS: + print(f"ERROR: unknown environment '{environment}' in {test_yaml}", file=sys.stderr) + failed += 1 + failing_tests.append(f"{environment}/{test_name}") + continue + + try: + topology = parse_topology(config, environment, test_yaml) + except ValueError as err: + print(f"ERROR: {err}", file=sys.stderr) + failed += 1 + failing_tests.append(f"{environment}/{test_name}") + continue + + requires = config.get("requires", {}) or {} + try: + check_test_requires(requires, environment, root) + except UnsatisfiedRequirementError as err: + skipped += 1 + log_test_step(environment, test_name, f"SKIP ⏭️: {err}") + continue + except ValueError as err: + print(f"ERROR: {test_yaml}: {err}", file=sys.stderr) + failed += 1 + failing_tests.append(f"{environment}/{test_name}") + continue + + failures = run_test(test_name, environment, config, script_dir, root, topology) + warnings = [] + if not failures: + output_dir = os.path.join(root, "output") + failures, warnings = run_checks(config, output_dir, environment, script_dir, root) + ok = not failures + log_test_step(environment, test_name, "OK ✅" if ok else "FAIL ❌") + if ok: + passed += 1 + else: + failed += 1 + failing_tests.append(f"{environment}/{test_name}") + for msg in warnings: + print(f" {msg}") + for msg in failures: + print(f" {msg}") + + print("") + print(f"PASS: {passed}") + print(f"FAIL: {failed}") + print(f"SKIP: {skipped}") + if failing_tests: + print("") + print("Failing tests:") + for test in failing_tests: + print(f"- {test}") + return failed == 0 + + +def main() -> None: + global verbose + + if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(line_buffering=True) + if hasattr(sys.stderr, "reconfigure"): + sys.stderr.reconfigure(line_buffering=True) + + parser = build_parser() + args = parser.parse_args() + verbose = args.verbose + configure_script_env() + + need_root() + for cmd in ["ip", "ethtool", "sysctl", "kill", "iptables"]: + need_cmd(cmd) + with RunnerLock(): + if not do_run( + only_environment=args.environment, + patterns=args.patterns, + exact=args.exact, + skip_tests=args.skip_tests, + tags=args.tag, + ): + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/live/tests/afp-fw/README.md b/live/tests/afp-fw/README.md new file mode 100644 index 0000000000..d9c586bb36 --- /dev/null +++ b/live/tests/afp-fw/README.md @@ -0,0 +1,2 @@ +A port of the "afp-fw-netns-bridge" from the Suricata repo to this +testing harness. diff --git a/live/tests/afp-fw/client/Dockerfile b/live/tests/afp-fw/client/Dockerfile new file mode 100644 index 0000000000..e7f07fa8ed --- /dev/null +++ b/live/tests/afp-fw/client/Dockerfile @@ -0,0 +1,3 @@ +FROM ubuntu:24.04 +RUN apt-get -y update && \ + apt-get -y install curl wget iputils-ping diff --git a/live/tests/afp-fw/empty.rules b/live/tests/afp-fw/empty.rules new file mode 100644 index 0000000000..0832bc245c --- /dev/null +++ b/live/tests/afp-fw/empty.rules @@ -0,0 +1 @@ +# Empty ruleset for live tests that only use firewall rules. diff --git a/live/tests/afp-fw/firewall1.rules b/live/tests/afp-fw/firewall1.rules new file mode 100644 index 0000000000..0008a795d0 --- /dev/null +++ b/live/tests/afp-fw/firewall1.rules @@ -0,0 +1,14 @@ +accept:packet arp:all any any -> any any (alert; sid:200;) + +# allow session setup +accept:hook tcp:all any any <> any 80 (flow:not_established; alert; sid:1021;) + +# pass rest of the flow to +accept:hook tcp:all any any <> any 80 (flow:established; alert; sid:1023;) +#accept:hook ip:all any any <> any any (alert; sid:1024;) + +# default drop + +accept:hook http1:request_started any any -> any any (alert; sid:100;) +accept:hook http1:request_line any any -> any any (http.method; content:"GET"; http.uri; content:"/"; alert; sid:101;) +accept:tx http1:request_headers any any -> any any (http.user_agent; content:"wget"; nocase; alert; sid:102;) diff --git a/live/tests/afp-fw/firewall2.rules b/live/tests/afp-fw/firewall2.rules new file mode 100644 index 0000000000..1f3359be38 --- /dev/null +++ b/live/tests/afp-fw/firewall2.rules @@ -0,0 +1,14 @@ +accept:packet arp:all any any -> any any (alert; sid:200;) + +# allow session setup +accept:hook tcp:all any any <> any 80 (flow:not_established; alert; sid:1021;) + +# pass rest of the flow to +accept:hook tcp:all any any <> any 80 (flow:established; alert; sid:1023;) +#accept:hook ip:all any any <> any any (alert; sid:1024;) + +# default drop + +accept:hook http1:request_started any any -> any any (alert; sid:100;) +accept:hook http1:request_line any any -> any any (http.method; bsize:3; urilen:>1; sid:201; alert;) +accept:tx http1:request_headers any any -> any any (http.user_agent; pcre:"/wget/i"; sid:202; alert;) diff --git a/live/tests/afp-fw/include.yaml b/live/tests/afp-fw/include.yaml new file mode 100644 index 0000000000..eca95f6634 --- /dev/null +++ b/live/tests/afp-fw/include.yaml @@ -0,0 +1,26 @@ +%YAML 1.1 +--- + +default-rule-path: ${TESTDIR} +rule-files: + - empty.rules + +firewall: + enabled: true + rule-path: ${OUTDIR} + rule-files: + - firewall.rules + +af-packet: + - interface: client0 + cluster-id: 80 + copy-mode: ips + copy-iface: server0 + - interface: server0 + cluster-id: 81 + copy-mode: ips + copy-iface: client0 + - interface: default + defrag: false + threads: auto + cluster-type: cluster_flow diff --git a/live/tests/afp-fw/server/Dockerfile b/live/tests/afp-fw/server/Dockerfile new file mode 100644 index 0000000000..19147411d1 --- /dev/null +++ b/live/tests/afp-fw/server/Dockerfile @@ -0,0 +1,5 @@ +FROM ubuntu:24.04 +RUN apt-get -y update && \ + apt-get -y install caddy tshark +COPY /server.sh /server.sh +COPY /index.html /srv/www/index.html \ No newline at end of file diff --git a/live/tests/afp-fw/server/index.html b/live/tests/afp-fw/server/index.html new file mode 100644 index 0000000000..e69de29bb2 diff --git a/live/tests/afp-fw/server/server.sh b/live/tests/afp-fw/server/server.sh new file mode 100755 index 0000000000..30ce5e9f92 --- /dev/null +++ b/live/tests/afp-fw/server/server.sh @@ -0,0 +1,10 @@ +#! /bin/bash + +set -e +set -x + +echo "Starting tshark..." +tshark -i server -f icmp -T json > /out/tshark-server.json & + +echo "Starting caddy..." +cd /srv/www && exec caddy file-server browse diff --git a/live/tests/afp-fw/test.yaml b/live/tests/afp-fw/test.yaml new file mode 100644 index 0000000000..ef773bf80d --- /dev/null +++ b/live/tests/afp-fw/test.yaml @@ -0,0 +1,110 @@ +# Port of qa/live/netns/afp-fw-netns-bridge.sh with the workers runmode. +environment: inline + +requires: + command: + - jq + - podman + +args: + - --af-packet + - --runmode workers + +before: | + cp ${TESTDIR}/firewall1.rules ${OUTDIR}/firewall.rules + + # It can take a while to build containers the first time which + # means the client or server script could start early. So we'll + # build both containers here. + podman build --iidfile=${OUTDIR}/server.iid --network=host server + podman build --iidfile=${OUTDIR}/client.iid --network=host client + +server: | + podman run --rm --cap-add=NET_RAW --network ns:/var/run/netns/server0 \ + -v ${OUTDIR}:/out:rw \ + $(cat ${OUTDIR}/server.iid) \ + /server.sh + +client: | + errors="no" + + iid=$(cat ${OUTDIR}/client.iid) + run() { + echo "Running: $@" + podman run --rm --cap-add=NET_RAW --network ns:/var/run/netns/client0 ${iid} "$@" + } + + # Curl request should not succeed. + echo "Running curl..." + if run timeout --kill-after=1 --preserve-status 2 curl -O http://10.200.0.1/index.html; then + echo "error: curl should not have completed successfully" + errors="yes" + fi + + # Wget request should succeed. + echo "Running wget..." + if ! run timeout --kill-after=1 --preserve-status 2 wget http://10.200.0.1/index.html; then + echo "error: wget should have completed successfully" + errors="yes" + fi + + # Ping should not succeed. + echo "Running ping..." + if run ping -c 10 -i 0.05 -W 1 10.200.0.1; then + echo "error: ping should have failed" + errors="yes" + fi + + cp ${TESTDIR}/firewall2.rules ${OUTDIR}/firewall.rules + ${SURICATASC} -c reload-rules ${OUTDIR}/suricata.socket + + # Wget request should succeed. + if ! run timeout --kill-after=1 --preserve-status 2 wget http://10.200.0.1/index.html; then + echo "error: wget should have completed successfully" + errors="yes" + fi + + if [ "${errors}" = "yes" ]; then + exit 1 + fi + +checks: + - filter: + count: 2 + match: + alert.signature_id: 101 + + - filter: + count: 1 + match: + alert.signature_id: 102 + + - filter: + count: 1 + match: + alert.signature_id: 201 + + - filter: + count: 1 + match: + alert.signature_id: 202 + + - stats: + firewall.accepted: 25 + + # ICMPv6 neighbor traffic is timing-sensitive, so assert firewall accounting + # instead of exact blocked/captured packet totals. + - shell: + args: | + jq -e -s ' + [.[] | select(.event_type == "stats") | .stats][-1] as $stats + | ($stats.firewall.accepted + $stats.firewall.blocked) as $verdicts + | ($stats.firewall.blocked > 0) + and ($stats.capture.kernel_packets == $verdicts) + ' ./eve.json >/dev/null + + # We should not have seen any pings on the server side packet capture. + - shell: + args: | + pings=$(jq -c '.[]' ./tshark-server.json|jq 'select(._source.layers.icmp."icmp.type"=="8")'|wc -l) + test "${pings}" -eq 0 diff --git a/live/tests/afp-ids-tpacket2-autofp/README.md b/live/tests/afp-ids-tpacket2-autofp/README.md new file mode 100644 index 0000000000..6393b157d7 --- /dev/null +++ b/live/tests/afp-ids-tpacket2-autofp/README.md @@ -0,0 +1,15 @@ +--- +tags: +- legacy-afpdpdk +- legacy-afp +--- + +# AF_PACKET IDS tpacket argument 2 autofp test + +Ports `qa/live/afp-ids.sh 2 autofp` from the Suricata repository. The legacy +script maps argument `2` to `af-packet.1.tpacket-v3=true`; this port preserves +that behavior while replacing default-gateway traffic with deterministic ICMP +traffic on the live framework's `10.200.0.0/24` IDS bridge. + +The test covers packet capture, datasets, rule reload, interface and runmode +socket commands, and hostbit management. diff --git a/live/tests/afp-ids-tpacket2-autofp/client.sh b/live/tests/afp-ids-tpacket2-autofp/client.sh new file mode 100644 index 0000000000..3c37939902 --- /dev/null +++ b/live/tests/afp-ids-tpacket2-autofp/client.sh @@ -0,0 +1,95 @@ +#!/bin/bash +set -euo pipefail + +socket="${OUTDIR}/suricata.socket" +eve="${OUTDIR}/eve.json" +target=10.200.0.1 + +sc() { + "${SURICATASC}" -c "$1" "${socket}" +} + +expect_return() { + local command="$1" + local expected="${2:-OK}" + local json + json=$(sc "${command}") + echo "${json}" + test "$(jq -r '.return' <<<"${json}")" = "${expected}" +} + +wait_for_ok() { + local command="$1" + local json + for _ in $(seq 1 40); do + json=$(sc "${command}") + if [ "$(jq -r '.return' <<<"${json}")" = OK ]; then + echo "${json}" + return 0 + fi + sleep 0.25 + done + echo "${json}" + echo "error: command did not return OK: ${command}" + return 1 +} + +wait_for_alerts() { + local sid="$1" + local expected="$2" + local count=0 + for _ in $(seq 1 40); do + count=$(jq -c "select(.event_type == \"alert\" and .alert.signature_id == ${sid})" \ + "${eve}" 2>/dev/null | wc -l) + if [ "${count}" -ge "${expected}" ]; then + return 0 + fi + sleep 0.25 + done + echo "error: expected at least ${expected} alerts for sid ${sid}, got ${count}" + return 1 +} + +ping_once() { + ip netns exec client0 ping -c 1 -W 1 "${target}" +} + +ping_once +wait_for_alerts 222 1 +expect_return "dataset-clear ipv4-list ipv4" +ping_once +wait_for_alerts 222 2 + +json=$(sc "iface-list") +echo "${json}" +iface=$(jq -r '.message.ifaces[0]' <<<"${json}") +sleep 1 +json=$(sc "iface-stat ${iface}") +echo "${json}" +test "$(jq -r '.message.pkts > 0' <<<"${json}")" = true + +cp "${TESTDIR}/icmp2.rules" "${OUTDIR}/suricata.rules" +expect_return "reload-rules" + +sc "iface-bypassed-stat" +json=$(sc "capture-mode") +echo "${json}" +test "$(jq -r '.message' <<<"${json}")" = AF_PACKET_DEV +json=$(wait_for_ok "dump-counters") +test "$(jq -r '.message.uptime >= 0' <<<"${json}")" = true +sc "memcap-list" +json=$(sc "running-mode") +echo "${json}" +test "$(jq -r '.message' <<<"${json}")" = "${EXPECTED_RUNMODE}" +sc "version" +json=$(sc "uptime") +echo "${json}" +test "$(jq -r '.message >= 0' <<<"${json}")" = true + +expect_return "add-hostbit ${target} test 60" +ping_once +wait_for_alerts 2 1 +json=$(sc "list-hostbit ${target}") +echo "${json}" +test "$(jq -r '.message.hostbits[0].name' <<<"${json}")" = test +expect_return "remove-hostbit ${target} test" diff --git a/live/tests/afp-ids-tpacket2-autofp/icmp.rules b/live/tests/afp-ids-tpacket2-autofp/icmp.rules new file mode 100644 index 0000000000..a938596a81 --- /dev/null +++ b/live/tests/afp-ids-tpacket2-autofp/icmp.rules @@ -0,0 +1,3 @@ +alert icmp any any -> any any (itype:8; sid:1;) +alert icmp any any -> any any (itype:8; ip.dst; dataset:set,ipv4-list,type ipv4; sid:222;) +alert icmp any any -> any any (itype:8; ip.dst; dataset:set,ipv6-list,type ipv6; sid:226;) diff --git a/live/tests/afp-ids-tpacket2-autofp/icmp2.rules b/live/tests/afp-ids-tpacket2-autofp/icmp2.rules new file mode 100644 index 0000000000..a60be4dbd1 --- /dev/null +++ b/live/tests/afp-ids-tpacket2-autofp/icmp2.rules @@ -0,0 +1 @@ +alert icmp any any -> any any (itype:8; hostbits:isset,test,dst; sid:2;) diff --git a/live/tests/afp-ids-tpacket2-autofp/include.yaml b/live/tests/afp-ids-tpacket2-autofp/include.yaml new file mode 100644 index 0000000000..ba4d603fee --- /dev/null +++ b/live/tests/afp-ids-tpacket2-autofp/include.yaml @@ -0,0 +1,18 @@ +%YAML 1.1 +--- + +default-rule-path: ${OUTDIR} +rule-files: + - suricata.rules + +outputs: + - eve-log: + enabled: yes + filetype: regular + filename: eve.json + types: + - alert + - stats: + totals: yes + threads: no + interval: 1 diff --git a/live/tests/afp-ids-tpacket2-autofp/test.yaml b/live/tests/afp-ids-tpacket2-autofp/test.yaml new file mode 100644 index 0000000000..038da04c3e --- /dev/null +++ b/live/tests/afp-ids-tpacket2-autofp/test.yaml @@ -0,0 +1,38 @@ +# Port of qa/live/afp-ids.sh 2 autofp. +environment: tap + +requires: + command: + - jq + - ping + +args: + - --af-packet=br0 + - --runmode autofp + - --set af-packet.1.bpf-filter=icmp + - --set af-packet.1.tpacket-v3=true + +before: | + cp ${TESTDIR}/icmp.rules ${OUTDIR}/suricata.rules + +client: | + EXPECTED_RUNMODE=autofp exec bash ${TESTDIR}/client.sh + +checks: + - stats: + capture.kernel_packets.__gt: 0 + - filter: + count: 2 + match: + event_type: alert + alert.signature_id: 1 + - filter: + count: 2 + match: + event_type: alert + alert.signature_id: 222 + - filter: + count: 1 + match: + event_type: alert + alert.signature_id: 2 diff --git a/live/tests/afp-ids-tpacket2-workers/README.md b/live/tests/afp-ids-tpacket2-workers/README.md new file mode 100644 index 0000000000..c43c5e2950 --- /dev/null +++ b/live/tests/afp-ids-tpacket2-workers/README.md @@ -0,0 +1,15 @@ +--- +tags: +- legacy-afpdpdk +- legacy-afp +--- + +# AF_PACKET IDS tpacket argument 2 workers test + +Ports `qa/live/afp-ids.sh 2 workers` from the Suricata repository. The legacy +script maps argument `2` to `af-packet.1.tpacket-v3=true`; this port preserves +that behavior while replacing default-gateway traffic with deterministic ICMP +traffic on the live framework's `10.200.0.0/24` IDS bridge. + +The test covers packet capture, datasets, rule reload, interface and runmode +socket commands, and hostbit management. diff --git a/live/tests/afp-ids-tpacket2-workers/client.sh b/live/tests/afp-ids-tpacket2-workers/client.sh new file mode 100644 index 0000000000..3c37939902 --- /dev/null +++ b/live/tests/afp-ids-tpacket2-workers/client.sh @@ -0,0 +1,95 @@ +#!/bin/bash +set -euo pipefail + +socket="${OUTDIR}/suricata.socket" +eve="${OUTDIR}/eve.json" +target=10.200.0.1 + +sc() { + "${SURICATASC}" -c "$1" "${socket}" +} + +expect_return() { + local command="$1" + local expected="${2:-OK}" + local json + json=$(sc "${command}") + echo "${json}" + test "$(jq -r '.return' <<<"${json}")" = "${expected}" +} + +wait_for_ok() { + local command="$1" + local json + for _ in $(seq 1 40); do + json=$(sc "${command}") + if [ "$(jq -r '.return' <<<"${json}")" = OK ]; then + echo "${json}" + return 0 + fi + sleep 0.25 + done + echo "${json}" + echo "error: command did not return OK: ${command}" + return 1 +} + +wait_for_alerts() { + local sid="$1" + local expected="$2" + local count=0 + for _ in $(seq 1 40); do + count=$(jq -c "select(.event_type == \"alert\" and .alert.signature_id == ${sid})" \ + "${eve}" 2>/dev/null | wc -l) + if [ "${count}" -ge "${expected}" ]; then + return 0 + fi + sleep 0.25 + done + echo "error: expected at least ${expected} alerts for sid ${sid}, got ${count}" + return 1 +} + +ping_once() { + ip netns exec client0 ping -c 1 -W 1 "${target}" +} + +ping_once +wait_for_alerts 222 1 +expect_return "dataset-clear ipv4-list ipv4" +ping_once +wait_for_alerts 222 2 + +json=$(sc "iface-list") +echo "${json}" +iface=$(jq -r '.message.ifaces[0]' <<<"${json}") +sleep 1 +json=$(sc "iface-stat ${iface}") +echo "${json}" +test "$(jq -r '.message.pkts > 0' <<<"${json}")" = true + +cp "${TESTDIR}/icmp2.rules" "${OUTDIR}/suricata.rules" +expect_return "reload-rules" + +sc "iface-bypassed-stat" +json=$(sc "capture-mode") +echo "${json}" +test "$(jq -r '.message' <<<"${json}")" = AF_PACKET_DEV +json=$(wait_for_ok "dump-counters") +test "$(jq -r '.message.uptime >= 0' <<<"${json}")" = true +sc "memcap-list" +json=$(sc "running-mode") +echo "${json}" +test "$(jq -r '.message' <<<"${json}")" = "${EXPECTED_RUNMODE}" +sc "version" +json=$(sc "uptime") +echo "${json}" +test "$(jq -r '.message >= 0' <<<"${json}")" = true + +expect_return "add-hostbit ${target} test 60" +ping_once +wait_for_alerts 2 1 +json=$(sc "list-hostbit ${target}") +echo "${json}" +test "$(jq -r '.message.hostbits[0].name' <<<"${json}")" = test +expect_return "remove-hostbit ${target} test" diff --git a/live/tests/afp-ids-tpacket2-workers/icmp.rules b/live/tests/afp-ids-tpacket2-workers/icmp.rules new file mode 100644 index 0000000000..a938596a81 --- /dev/null +++ b/live/tests/afp-ids-tpacket2-workers/icmp.rules @@ -0,0 +1,3 @@ +alert icmp any any -> any any (itype:8; sid:1;) +alert icmp any any -> any any (itype:8; ip.dst; dataset:set,ipv4-list,type ipv4; sid:222;) +alert icmp any any -> any any (itype:8; ip.dst; dataset:set,ipv6-list,type ipv6; sid:226;) diff --git a/live/tests/afp-ids-tpacket2-workers/icmp2.rules b/live/tests/afp-ids-tpacket2-workers/icmp2.rules new file mode 100644 index 0000000000..a60be4dbd1 --- /dev/null +++ b/live/tests/afp-ids-tpacket2-workers/icmp2.rules @@ -0,0 +1 @@ +alert icmp any any -> any any (itype:8; hostbits:isset,test,dst; sid:2;) diff --git a/live/tests/afp-ids-tpacket2-workers/include.yaml b/live/tests/afp-ids-tpacket2-workers/include.yaml new file mode 100644 index 0000000000..ba4d603fee --- /dev/null +++ b/live/tests/afp-ids-tpacket2-workers/include.yaml @@ -0,0 +1,18 @@ +%YAML 1.1 +--- + +default-rule-path: ${OUTDIR} +rule-files: + - suricata.rules + +outputs: + - eve-log: + enabled: yes + filetype: regular + filename: eve.json + types: + - alert + - stats: + totals: yes + threads: no + interval: 1 diff --git a/live/tests/afp-ids-tpacket2-workers/test.yaml b/live/tests/afp-ids-tpacket2-workers/test.yaml new file mode 100644 index 0000000000..16d325040f --- /dev/null +++ b/live/tests/afp-ids-tpacket2-workers/test.yaml @@ -0,0 +1,38 @@ +# Port of qa/live/afp-ids.sh 2 workers. +environment: tap + +requires: + command: + - jq + - ping + +args: + - --af-packet=br0 + - --runmode workers + - --set af-packet.1.bpf-filter=icmp + - --set af-packet.1.tpacket-v3=true + +before: | + cp ${TESTDIR}/icmp.rules ${OUTDIR}/suricata.rules + +client: | + EXPECTED_RUNMODE=workers exec bash ${TESTDIR}/client.sh + +checks: + - stats: + capture.kernel_packets.__gt: 0 + - filter: + count: 2 + match: + event_type: alert + alert.signature_id: 1 + - filter: + count: 2 + match: + event_type: alert + alert.signature_id: 222 + - filter: + count: 1 + match: + event_type: alert + alert.signature_id: 2 diff --git a/live/tests/afp-ids-tpacket3-autofp/README.md b/live/tests/afp-ids-tpacket3-autofp/README.md new file mode 100644 index 0000000000..f8d6a90e60 --- /dev/null +++ b/live/tests/afp-ids-tpacket3-autofp/README.md @@ -0,0 +1,15 @@ +--- +tags: +- legacy-afpdpdk +- legacy-afp +--- + +# AF_PACKET IDS tpacket argument 3 autofp test + +Ports `qa/live/afp-ids.sh 3 autofp` from the Suricata repository. The legacy +script maps argument `3` to `af-packet.1.tpacket-v3=false`; this port preserves +that behavior while replacing default-gateway traffic with deterministic ICMP +traffic on the live framework's `10.200.0.0/24` IDS bridge. + +The test covers packet capture, datasets, rule reload, interface and runmode +socket commands, and hostbit management. diff --git a/live/tests/afp-ids-tpacket3-autofp/client.sh b/live/tests/afp-ids-tpacket3-autofp/client.sh new file mode 100644 index 0000000000..3c37939902 --- /dev/null +++ b/live/tests/afp-ids-tpacket3-autofp/client.sh @@ -0,0 +1,95 @@ +#!/bin/bash +set -euo pipefail + +socket="${OUTDIR}/suricata.socket" +eve="${OUTDIR}/eve.json" +target=10.200.0.1 + +sc() { + "${SURICATASC}" -c "$1" "${socket}" +} + +expect_return() { + local command="$1" + local expected="${2:-OK}" + local json + json=$(sc "${command}") + echo "${json}" + test "$(jq -r '.return' <<<"${json}")" = "${expected}" +} + +wait_for_ok() { + local command="$1" + local json + for _ in $(seq 1 40); do + json=$(sc "${command}") + if [ "$(jq -r '.return' <<<"${json}")" = OK ]; then + echo "${json}" + return 0 + fi + sleep 0.25 + done + echo "${json}" + echo "error: command did not return OK: ${command}" + return 1 +} + +wait_for_alerts() { + local sid="$1" + local expected="$2" + local count=0 + for _ in $(seq 1 40); do + count=$(jq -c "select(.event_type == \"alert\" and .alert.signature_id == ${sid})" \ + "${eve}" 2>/dev/null | wc -l) + if [ "${count}" -ge "${expected}" ]; then + return 0 + fi + sleep 0.25 + done + echo "error: expected at least ${expected} alerts for sid ${sid}, got ${count}" + return 1 +} + +ping_once() { + ip netns exec client0 ping -c 1 -W 1 "${target}" +} + +ping_once +wait_for_alerts 222 1 +expect_return "dataset-clear ipv4-list ipv4" +ping_once +wait_for_alerts 222 2 + +json=$(sc "iface-list") +echo "${json}" +iface=$(jq -r '.message.ifaces[0]' <<<"${json}") +sleep 1 +json=$(sc "iface-stat ${iface}") +echo "${json}" +test "$(jq -r '.message.pkts > 0' <<<"${json}")" = true + +cp "${TESTDIR}/icmp2.rules" "${OUTDIR}/suricata.rules" +expect_return "reload-rules" + +sc "iface-bypassed-stat" +json=$(sc "capture-mode") +echo "${json}" +test "$(jq -r '.message' <<<"${json}")" = AF_PACKET_DEV +json=$(wait_for_ok "dump-counters") +test "$(jq -r '.message.uptime >= 0' <<<"${json}")" = true +sc "memcap-list" +json=$(sc "running-mode") +echo "${json}" +test "$(jq -r '.message' <<<"${json}")" = "${EXPECTED_RUNMODE}" +sc "version" +json=$(sc "uptime") +echo "${json}" +test "$(jq -r '.message >= 0' <<<"${json}")" = true + +expect_return "add-hostbit ${target} test 60" +ping_once +wait_for_alerts 2 1 +json=$(sc "list-hostbit ${target}") +echo "${json}" +test "$(jq -r '.message.hostbits[0].name' <<<"${json}")" = test +expect_return "remove-hostbit ${target} test" diff --git a/live/tests/afp-ids-tpacket3-autofp/icmp.rules b/live/tests/afp-ids-tpacket3-autofp/icmp.rules new file mode 100644 index 0000000000..a938596a81 --- /dev/null +++ b/live/tests/afp-ids-tpacket3-autofp/icmp.rules @@ -0,0 +1,3 @@ +alert icmp any any -> any any (itype:8; sid:1;) +alert icmp any any -> any any (itype:8; ip.dst; dataset:set,ipv4-list,type ipv4; sid:222;) +alert icmp any any -> any any (itype:8; ip.dst; dataset:set,ipv6-list,type ipv6; sid:226;) diff --git a/live/tests/afp-ids-tpacket3-autofp/icmp2.rules b/live/tests/afp-ids-tpacket3-autofp/icmp2.rules new file mode 100644 index 0000000000..a60be4dbd1 --- /dev/null +++ b/live/tests/afp-ids-tpacket3-autofp/icmp2.rules @@ -0,0 +1 @@ +alert icmp any any -> any any (itype:8; hostbits:isset,test,dst; sid:2;) diff --git a/live/tests/afp-ids-tpacket3-autofp/include.yaml b/live/tests/afp-ids-tpacket3-autofp/include.yaml new file mode 100644 index 0000000000..ba4d603fee --- /dev/null +++ b/live/tests/afp-ids-tpacket3-autofp/include.yaml @@ -0,0 +1,18 @@ +%YAML 1.1 +--- + +default-rule-path: ${OUTDIR} +rule-files: + - suricata.rules + +outputs: + - eve-log: + enabled: yes + filetype: regular + filename: eve.json + types: + - alert + - stats: + totals: yes + threads: no + interval: 1 diff --git a/live/tests/afp-ids-tpacket3-autofp/test.yaml b/live/tests/afp-ids-tpacket3-autofp/test.yaml new file mode 100644 index 0000000000..c66d114d91 --- /dev/null +++ b/live/tests/afp-ids-tpacket3-autofp/test.yaml @@ -0,0 +1,38 @@ +# Port of qa/live/afp-ids.sh 3 autofp. +environment: tap + +requires: + command: + - jq + - ping + +args: + - --af-packet=br0 + - --runmode autofp + - --set af-packet.1.bpf-filter=icmp + - --set af-packet.1.tpacket-v3=false + +before: | + cp ${TESTDIR}/icmp.rules ${OUTDIR}/suricata.rules + +client: | + EXPECTED_RUNMODE=autofp exec bash ${TESTDIR}/client.sh + +checks: + - stats: + capture.kernel_packets.__gt: 0 + - filter: + count: 2 + match: + event_type: alert + alert.signature_id: 1 + - filter: + count: 2 + match: + event_type: alert + alert.signature_id: 222 + - filter: + count: 1 + match: + event_type: alert + alert.signature_id: 2 diff --git a/live/tests/afp-ids-tpacket3-workers/README.md b/live/tests/afp-ids-tpacket3-workers/README.md new file mode 100644 index 0000000000..44420578c8 --- /dev/null +++ b/live/tests/afp-ids-tpacket3-workers/README.md @@ -0,0 +1,15 @@ +--- +tags: +- legacy-afpdpdk +- legacy-afp +--- + +# AF_PACKET IDS tpacket argument 3 workers test + +Ports `qa/live/afp-ids.sh 3 workers` from the Suricata repository. The legacy +script maps argument `3` to `af-packet.1.tpacket-v3=false`; this port preserves +that behavior while replacing default-gateway traffic with deterministic ICMP +traffic on the live framework's `10.200.0.0/24` IDS bridge. + +The test covers packet capture, datasets, rule reload, interface and runmode +socket commands, and hostbit management. diff --git a/live/tests/afp-ids-tpacket3-workers/client.sh b/live/tests/afp-ids-tpacket3-workers/client.sh new file mode 100644 index 0000000000..3c37939902 --- /dev/null +++ b/live/tests/afp-ids-tpacket3-workers/client.sh @@ -0,0 +1,95 @@ +#!/bin/bash +set -euo pipefail + +socket="${OUTDIR}/suricata.socket" +eve="${OUTDIR}/eve.json" +target=10.200.0.1 + +sc() { + "${SURICATASC}" -c "$1" "${socket}" +} + +expect_return() { + local command="$1" + local expected="${2:-OK}" + local json + json=$(sc "${command}") + echo "${json}" + test "$(jq -r '.return' <<<"${json}")" = "${expected}" +} + +wait_for_ok() { + local command="$1" + local json + for _ in $(seq 1 40); do + json=$(sc "${command}") + if [ "$(jq -r '.return' <<<"${json}")" = OK ]; then + echo "${json}" + return 0 + fi + sleep 0.25 + done + echo "${json}" + echo "error: command did not return OK: ${command}" + return 1 +} + +wait_for_alerts() { + local sid="$1" + local expected="$2" + local count=0 + for _ in $(seq 1 40); do + count=$(jq -c "select(.event_type == \"alert\" and .alert.signature_id == ${sid})" \ + "${eve}" 2>/dev/null | wc -l) + if [ "${count}" -ge "${expected}" ]; then + return 0 + fi + sleep 0.25 + done + echo "error: expected at least ${expected} alerts for sid ${sid}, got ${count}" + return 1 +} + +ping_once() { + ip netns exec client0 ping -c 1 -W 1 "${target}" +} + +ping_once +wait_for_alerts 222 1 +expect_return "dataset-clear ipv4-list ipv4" +ping_once +wait_for_alerts 222 2 + +json=$(sc "iface-list") +echo "${json}" +iface=$(jq -r '.message.ifaces[0]' <<<"${json}") +sleep 1 +json=$(sc "iface-stat ${iface}") +echo "${json}" +test "$(jq -r '.message.pkts > 0' <<<"${json}")" = true + +cp "${TESTDIR}/icmp2.rules" "${OUTDIR}/suricata.rules" +expect_return "reload-rules" + +sc "iface-bypassed-stat" +json=$(sc "capture-mode") +echo "${json}" +test "$(jq -r '.message' <<<"${json}")" = AF_PACKET_DEV +json=$(wait_for_ok "dump-counters") +test "$(jq -r '.message.uptime >= 0' <<<"${json}")" = true +sc "memcap-list" +json=$(sc "running-mode") +echo "${json}" +test "$(jq -r '.message' <<<"${json}")" = "${EXPECTED_RUNMODE}" +sc "version" +json=$(sc "uptime") +echo "${json}" +test "$(jq -r '.message >= 0' <<<"${json}")" = true + +expect_return "add-hostbit ${target} test 60" +ping_once +wait_for_alerts 2 1 +json=$(sc "list-hostbit ${target}") +echo "${json}" +test "$(jq -r '.message.hostbits[0].name' <<<"${json}")" = test +expect_return "remove-hostbit ${target} test" diff --git a/live/tests/afp-ids-tpacket3-workers/icmp.rules b/live/tests/afp-ids-tpacket3-workers/icmp.rules new file mode 100644 index 0000000000..a938596a81 --- /dev/null +++ b/live/tests/afp-ids-tpacket3-workers/icmp.rules @@ -0,0 +1,3 @@ +alert icmp any any -> any any (itype:8; sid:1;) +alert icmp any any -> any any (itype:8; ip.dst; dataset:set,ipv4-list,type ipv4; sid:222;) +alert icmp any any -> any any (itype:8; ip.dst; dataset:set,ipv6-list,type ipv6; sid:226;) diff --git a/live/tests/afp-ids-tpacket3-workers/icmp2.rules b/live/tests/afp-ids-tpacket3-workers/icmp2.rules new file mode 100644 index 0000000000..a60be4dbd1 --- /dev/null +++ b/live/tests/afp-ids-tpacket3-workers/icmp2.rules @@ -0,0 +1 @@ +alert icmp any any -> any any (itype:8; hostbits:isset,test,dst; sid:2;) diff --git a/live/tests/afp-ids-tpacket3-workers/include.yaml b/live/tests/afp-ids-tpacket3-workers/include.yaml new file mode 100644 index 0000000000..ba4d603fee --- /dev/null +++ b/live/tests/afp-ids-tpacket3-workers/include.yaml @@ -0,0 +1,18 @@ +%YAML 1.1 +--- + +default-rule-path: ${OUTDIR} +rule-files: + - suricata.rules + +outputs: + - eve-log: + enabled: yes + filetype: regular + filename: eve.json + types: + - alert + - stats: + totals: yes + threads: no + interval: 1 diff --git a/live/tests/afp-ids-tpacket3-workers/test.yaml b/live/tests/afp-ids-tpacket3-workers/test.yaml new file mode 100644 index 0000000000..2203754e48 --- /dev/null +++ b/live/tests/afp-ids-tpacket3-workers/test.yaml @@ -0,0 +1,38 @@ +# Port of qa/live/afp-ids.sh 3 workers. +environment: tap + +requires: + command: + - jq + - ping + +args: + - --af-packet=br0 + - --runmode workers + - --set af-packet.1.bpf-filter=icmp + - --set af-packet.1.tpacket-v3=false + +before: | + cp ${TESTDIR}/icmp.rules ${OUTDIR}/suricata.rules + +client: | + EXPECTED_RUNMODE=workers exec bash ${TESTDIR}/client.sh + +checks: + - stats: + capture.kernel_packets.__gt: 0 + - filter: + count: 2 + match: + event_type: alert + alert.signature_id: 1 + - filter: + count: 2 + match: + event_type: alert + alert.signature_id: 222 + - filter: + count: 1 + match: + event_type: alert + alert.signature_id: 2 diff --git a/live/tests/afp-ips-bond-two-networks/README.md b/live/tests/afp-ips-bond-two-networks/README.md new file mode 100644 index 0000000000..8db5784f08 --- /dev/null +++ b/live/tests/afp-ips-bond-two-networks/README.md @@ -0,0 +1,7 @@ +# AF_PACKET IPS with two bonded networks + +Ports `qa/live/netns/afp-ips-netns-bond-bridge2.sh` into the live test +framework. One workers-mode Suricata process forwards two independent inline +networks over balance-rr bonds at MTU 9000. HTTP must pass on both networks, +while fragmented and ordinary ICMP echo requests must be dropped before they +reach either server. diff --git a/live/tests/afp-ips-bond-two-networks/drop-icmp.rules b/live/tests/afp-ips-bond-two-networks/drop-icmp.rules new file mode 100644 index 0000000000..54280bc8c8 --- /dev/null +++ b/live/tests/afp-ips-bond-two-networks/drop-icmp.rules @@ -0,0 +1 @@ +drop icmp any any -> any any (msg:"Drop ICMP"; sid:1; rev:1;) diff --git a/live/tests/afp-ips-bond-two-networks/include.yaml b/live/tests/afp-ips-bond-two-networks/include.yaml new file mode 100644 index 0000000000..65de078f1a --- /dev/null +++ b/live/tests/afp-ips-bond-two-networks/include.yaml @@ -0,0 +1,45 @@ +%YAML 1.1 +--- + +default-rule-path: ${TESTDIR} +rule-files: + - drop-icmp.rules + +outputs: + - eve-log: + enabled: yes + filetype: regular + filename: eve.json + types: + - alert + - drop: + flows: all + alerts: true + - http + - stats: + totals: yes + threads: no + +af-packet: + - interface: client0 + cluster-id: 80 + copy-mode: ips + copy-iface: server0 + - interface: server0 + cluster-id: 81 + copy-mode: ips + copy-iface: client0 + - interface: client1 + cluster-id: 82 + threads: 4 + copy-mode: ips + copy-iface: server1 + - interface: server1 + cluster-id: 83 + threads: 4 + copy-mode: ips + copy-iface: client1 + - interface: default + defrag: false + threads: 2 + cluster-type: cluster_flow diff --git a/live/tests/afp-ips-bond-two-networks/test.yaml b/live/tests/afp-ips-bond-two-networks/test.yaml new file mode 100644 index 0000000000..568b7666fd --- /dev/null +++ b/live/tests/afp-ips-bond-two-networks/test.yaml @@ -0,0 +1,107 @@ +# Port of qa/live/netns/afp-ips-netns-bond-bridge2.sh. +environment: inline + +topology: + mtu: 9000 + networks: + - client: 10.200.0.2/24 + server: 10.200.0.1/24 + bond: true + bond-mode: balance-rr + - client: 10.200.1.2/24 + server: 10.200.1.1/24 + bond: true + bond-mode: balance-rr + +requires: + command: + - curl + - grep + - hping3 + - jq + - ping + - python3 + - tcpdump + - wc + - wget + +args: + - --af-packet + - --runmode=workers + +server: | + cleanup() { + kill "${http0:-}" "${http1:-}" "${capture0:-}" "${capture1:-}" 2>/dev/null || true + wait 2>/dev/null || true + } + trap cleanup EXIT INT TERM + + ip netns exec server0 tcpdump -U -n -i server -w ${OUTDIR}/server0.pcap icmp & + capture0=$! + ip netns exec server1 tcpdump -U -n -i server -w ${OUTDIR}/server1.pcap icmp & + capture1=$! + ip netns exec server0 python3 -m http.server 8000 --bind 10.200.0.1 & + http0=$! + ip netns exec server1 python3 -m http.server 8000 --bind 10.200.1.1 & + http1=$! + wait + +client: | + errors=no + + for endpoint in 10.200.0.1 10.200.1.1; do + echo "Checking HTTP on ${endpoint}" + if ! ip netns exec client$([ "${endpoint}" = 10.200.0.1 ] && echo 0 || echo 1) \ + curl --retry 10 --retry-connrefused --retry-delay 1 -fsS \ + http://${endpoint}:8000/ -o /dev/null; then + echo "error: HTTP should have succeeded on ${endpoint}" + errors=yes + fi + done + + echo "Checking HTTP with wget on network 0" + if ! ip netns exec client0 wget -qO /dev/null http://10.200.0.1:8000/; then + echo "error: wget should have succeeded on network 0" + errors=yes + fi + + echo "Sending fragmented ICMP on network 0" + hping_output=$(ip netns exec client0 hping3 -c 10 -1 -f -d 15000 \ + 10.200.0.1 2>&1) || true + echo "${hping_output}" + if ! grep -Eq '0 packets received|100% packet loss' <<<"${hping_output}"; then + echo "error: fragmented ICMP should have been dropped" + errors=yes + fi + + echo "Sending ordinary ICMP on network 1" + if ip netns exec client1 ping -c 10 -i 0.2 -W 1 10.200.1.1; then + echo "error: ordinary ICMP should have been dropped" + errors=yes + fi + + test "${errors}" = no + +checks: + - stats: + capture.kernel_packets.__gt: 0 + ips.accepted.__gt: 0 + ips.blocked.__gte: 10 + + - shell: + args: | + http0=$(jq -c 'select(.event_type == "http" and .src_ip == "10.200.0.2")' eve.json | wc -l) + http1=$(jq -c 'select(.event_type == "http" and .src_ip == "10.200.1.2")' eve.json | wc -l) + hping_drops=$(jq -c 'select(.event_type == "drop" and .src_ip == "10.200.0.2")' eve.json | wc -l) + ping_drops=$(jq -c 'select(.event_type == "drop" and .src_ip == "10.200.1.2")' eve.json | wc -l) + test "${http0}" -ge 1 + test "${http1}" -ge 1 + test "${hping_drops}" -ge 20 + test "${ping_drops}" -ge 10 + + - shell: + args: | + server0_echo=$(tcpdump -nn -r server0.pcap 'icmp[0] = 8' 2>/dev/null | wc -l) + server1_echo=$(tcpdump -nn -r server1.pcap 'icmp[0] = 8' 2>/dev/null | wc -l) + test "${server0_echo}" -eq 0 + test "${server1_echo}" -eq 0 diff --git a/live/tests/afp-ips-bond/README.md b/live/tests/afp-ips-bond/README.md new file mode 100644 index 0000000000..9056ef2307 --- /dev/null +++ b/live/tests/afp-ips-bond/README.md @@ -0,0 +1,7 @@ +# AF_PACKET IPS with one bonded network + +Ports `qa/live/netns/afp-ips-netns-bond-bridge.sh` into the live test +framework using the framework's `10.200.0.0/24` address scheme. One +workers-mode Suricata process forwards an inline network over two-member +balance-rr bonds at MTU 9000. HTTP must pass while fragmented ICMP echo +requests must be dropped before they reach the server. diff --git a/live/tests/afp-ips-bond/drop-icmp.rules b/live/tests/afp-ips-bond/drop-icmp.rules new file mode 100644 index 0000000000..54280bc8c8 --- /dev/null +++ b/live/tests/afp-ips-bond/drop-icmp.rules @@ -0,0 +1 @@ +drop icmp any any -> any any (msg:"Drop ICMP"; sid:1; rev:1;) diff --git a/live/tests/afp-ips-bond/include.yaml b/live/tests/afp-ips-bond/include.yaml new file mode 100644 index 0000000000..b9fd95b3e9 --- /dev/null +++ b/live/tests/afp-ips-bond/include.yaml @@ -0,0 +1,35 @@ +%YAML 1.1 +--- + +default-rule-path: ${TESTDIR} +rule-files: + - drop-icmp.rules + +outputs: + - eve-log: + enabled: yes + filetype: regular + filename: eve.json + types: + - alert + - drop: + flows: all + alerts: true + - http + - stats: + totals: yes + threads: no + +af-packet: + - interface: client0 + cluster-id: 80 + copy-mode: ips + copy-iface: server0 + - interface: server0 + cluster-id: 81 + copy-mode: ips + copy-iface: client0 + - interface: default + defrag: false + threads: 2 + cluster-type: cluster_flow diff --git a/live/tests/afp-ips-bond/test.yaml b/live/tests/afp-ips-bond/test.yaml new file mode 100644 index 0000000000..acc1f70c5d --- /dev/null +++ b/live/tests/afp-ips-bond/test.yaml @@ -0,0 +1,85 @@ +# Port of qa/live/netns/afp-ips-netns-bond-bridge.sh. +environment: inline + +topology: + mtu: 9000 + networks: + - client: 10.200.0.2/24 + server: 10.200.0.1/24 + bond: true + bond-mode: balance-rr + +requires: + command: + - curl + - grep + - hping3 + - jq + - python3 + - tcpdump + - wc + - wget + +args: + - --af-packet + - --runmode workers + +server: | + cleanup() { + kill "${http:-}" "${capture:-}" 2>/dev/null || true + wait 2>/dev/null || true + } + trap cleanup EXIT INT TERM + + ip netns exec server0 tcpdump -U -n -i server \ + -w ${OUTDIR}/server0.pcap icmp & + capture=$! + ip netns exec server0 python3 -m http.server 8000 --bind 10.200.0.1 & + http=$! + wait + +client: | + errors=no + + echo "Checking HTTP with curl" + if ! ip netns exec client0 \ + curl --retry 10 --retry-connrefused --retry-delay 1 -fsS \ + http://10.200.0.1:8000/ -o /dev/null; then + echo "error: curl should have succeeded" + errors=yes + fi + + echo "Checking HTTP with wget" + if ! ip netns exec client0 wget -qO /dev/null http://10.200.0.1:8000/; then + echo "error: wget should have succeeded" + errors=yes + fi + + echo "Sending fragmented ICMP" + hping_output=$(ip netns exec client0 hping3 -c 10 -1 -f -d 15000 \ + 10.200.0.1 2>&1) || true + echo "${hping_output}" + if ! grep -Eq '0 packets received|100% packet loss' <<<"${hping_output}"; then + echo "error: fragmented ICMP should have been dropped" + errors=yes + fi + + test "${errors}" = no + +checks: + - stats: + capture.kernel_packets.__gt: 0 + ips.accepted.__gt: 0 + ips.blocked.__gte: 10 + + - shell: + args: | + http=$(jq -c 'select(.event_type == "http" and .src_ip == "10.200.0.2")' eve.json | wc -l) + drops=$(jq -c 'select(.event_type == "drop" and .src_ip == "10.200.0.2")' eve.json | wc -l) + test "${http}" -ge 2 + test "${drops}" -ge 20 + + - shell: + args: | + echoes=$(tcpdump -nn -r server0.pcap 'icmp[0] = 8' 2>/dev/null | wc -l) + test "${echoes}" -eq 0 diff --git a/live/tests/afp-ips-xdp-bypass/README.md b/live/tests/afp-ips-xdp-bypass/README.md new file mode 100644 index 0000000000..8a0d47db75 --- /dev/null +++ b/live/tests/afp-ips-xdp-bypass/README.md @@ -0,0 +1,11 @@ +--- +tags: +- bypass +- xdp +--- + +# AF-PACKET IPS with XDP bypass test + +Test AF-PACKET IPS with XDP bypass/offload. Test checks that packets are +bypassed by Suricata, but uses a client and server to verify that the traffic +still flows. diff --git a/live/tests/afp-ips-xdp-bypass/client.py b/live/tests/afp-ips-xdp-bypass/client.py new file mode 100644 index 0000000000..122170d65f --- /dev/null +++ b/live/tests/afp-ips-xdp-bypass/client.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +"""Client driver for the live bypass test. + +Two real TCP connections through Suricata: + +1. The *bypass* flow: start a flow with our bypass marker, then a story then + the tripwire text. This flow should now alert on the tripwire text and the + flow should be marked as bypassed. + +2. The *control* flow: just send our story followed by the tripwire text. As no + bypass was done, we should alert on the tripwire text. + +In both cases the client always checks that what was sent was echo'd back. + +The script exits non-zero if any byte fails to round-trip, which the runner +treats as a test failure. +""" + +import socket +import sys +import time + +HOST = "10.200.0.1" +PORT = 7000 + + +STORY = b""" +Marty the meerkat stood on the warm desert sand every morning, stretching as tall as his little legs allowed so he could watch over his family. One day, while everyone else searched for breakfast, Marty spotted a shiny blue beetle struggling on its back beside a cactus. He hurried over, flipped it gently upright, and the beetle buzzed in happy circles before flying away. Later that afternoon, when a hungry eagle swept low across the dunes, a flash of blue wings darted in front of Marty and startled the eagle just long enough for him to squeak a warning. His family dove safely into their burrow, and from that day on, Marty learned that even the smallest kindness could come back in the biggest way. +""" + + +def recv_exact(sock, n): + buf = bytearray() + while len(buf) < n: + chunk = sock.recv(n - len(buf)) + if not chunk: + raise EOFError(f"connection closed after {len(buf)}/{n} bytes") + buf.extend(chunk) + return bytes(buf) + + +def send_and_verify_echo(sock, payload, what): + sock.sendall(payload) + echo = recv_exact(sock, len(payload)) + if echo != payload: + raise AssertionError(f"{what}: echo mismatch ({len(echo)}/{len(payload)} bytes)") + print(f"{what}: {len(payload)} bytes echoed back OK", flush=True) + + +def bypass_flow(): + with socket.create_connection((HOST, PORT), timeout=10) as s: + s.settimeout(10) + + # First send that should trigger the bypass. + send_and_verify_echo(s, b"BYPASS", "bypass-flow trigger") + + # Wait a moment for the bypass to be applied. + time.sleep(1.0) + + # Now send the story. + send_and_verify_echo(s, STORY, "bypass-flow payload") + + # Now send the tripwire. + send_and_verify_echo(s, b"TRIPWIRE", "bypass-flow-payload") + +def control_flow(): + with socket.create_connection((HOST, PORT), timeout=10) as s: + s.settimeout(10) + send_and_verify_echo(s, STORY, "control-flow payload") + send_and_verify_echo(s, b"TRIPWIRE", "control-flow-payload") + + +def main(): + try: + bypass_flow() + control_flow() + except Exception as err: # noqa: BLE001 - surface any failure to the runner + print(f"ERROR: {err}", file=sys.stderr, flush=True) + return 1 + print("client OK", flush=True) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/live/tests/afp-ips-xdp-bypass/include.yaml b/live/tests/afp-ips-xdp-bypass/include.yaml new file mode 100644 index 0000000000..149edc3a16 --- /dev/null +++ b/live/tests/afp-ips-xdp-bypass/include.yaml @@ -0,0 +1,30 @@ +%YAML 1.1 +--- + +default-rule-path: ${TESTDIR} +rule-files: + - test.rules + +# AF_PACKET inline copy-mode (IPS) with XDP bypass on both capture interfaces. +# When a flow is bypassed, Suricata installs it in the XDP flow map; the XDP +# program then forwards that flow's packets between client0 and server0 in the +# kernel, so they stop reaching Suricata's userspace while the endpoints keep +# talking. This is the af-packet equivalent of the NFQ capture bypass in +# ../bypass-nfq. +af-packet: + - interface: client0 + cluster-id: 91 + copy-mode: ips + copy-iface: server0 + bypass: yes + xdp-mode: soft + xdp-filter-file: ${OUTDIR}/xdp_filter.bpf + - interface: server0 + cluster-id: 92 + copy-mode: ips + copy-iface: client0 + bypass: yes + xdp-mode: soft + xdp-filter-file: ${OUTDIR}/xdp_filter.bpf + - interface: default + cluster-type: cluster_flow diff --git a/live/tests/afp-ips-xdp-bypass/server.py b/live/tests/afp-ips-xdp-bypass/server.py new file mode 100644 index 0000000000..f12e20956a --- /dev/null +++ b/live/tests/afp-ips-xdp-bypass/server.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +"""Tiny TCP echo + byte-counting server for the live bypass test. + +For every connection it echoes back every byte it receives and counts the +total. When a connection closes it appends a JSON line describing how many +bytes that connection delivered to ``$OUT`` (argv[1]). The client uses the +echo to prove that every byte it sent survived the trip through Suricata +(i.e. that bypassing the flow did not break end-to-end delivery), and the +JSON file lets a check assert the server actually received the full payload. +""" + +import json +import socket +import sys +import threading + +HOST = "0.0.0.0" +PORT = 7000 + +out_path = sys.argv[1] if len(sys.argv) > 1 else None +out_lock = threading.Lock() +conn_no = 0 + + +def record(conn_id, nbytes): + if not out_path: + return + with out_lock: + with open(out_path, "a", encoding="utf-8") as f: + f.write(json.dumps({"conn": conn_id, "bytes": nbytes}) + "\n") + + +def handle(sock, conn_id): + total = 0 + try: + while True: + data = sock.recv(65536) + if not data: + break + total += len(data) + # Echo everything straight back. + sock.sendall(data) + finally: + sock.close() + record(conn_id, total) + print(f"conn {conn_id}: received {total} bytes", flush=True) + + +def main(): + srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + srv.bind((HOST, PORT)) + srv.listen(16) + print(f"listening on {HOST}:{PORT}", flush=True) + global conn_no + while True: + client, _ = srv.accept() + conn_no += 1 + t = threading.Thread(target=handle, args=(client, conn_no), daemon=True) + t.start() + + +if __name__ == "__main__": + main() diff --git a/live/tests/afp-ips-xdp-bypass/test.rules b/live/tests/afp-ips-xdp-bypass/test.rules new file mode 100644 index 0000000000..a26e5cd40d --- /dev/null +++ b/live/tests/afp-ips-xdp-bypass/test.rules @@ -0,0 +1,6 @@ +# Flow to be passed. "BYPASS" will be sent before "TRIPWIRE". +alert tcp any any -> any 7000 (flow:to_server; content:"BYPASS"; bypass; flowbits:set,bypassed; sid:1; rev:1;) + +# Alert when we see "TRIPWIRE". The idea is that we'll see this on flows +# that were not bypassed. +alert tcp any any -> any 7000 (flow:to_server; content:"TRIPWIRE"; sid:2; rev:1;) diff --git a/live/tests/afp-ips-xdp-bypass/test.yaml b/live/tests/afp-ips-xdp-bypass/test.yaml new file mode 100644 index 0000000000..dde4a64fc6 --- /dev/null +++ b/live/tests/afp-ips-xdp-bypass/test.yaml @@ -0,0 +1,61 @@ +environment: inline + +requires: + command: + - python3 + features: + - XDP + files: + - ebpf/xdp_filter.bpf + +args: + - --af-packet + +before: | + set -e + # include.yaml can only expand ${OUTDIR}/${TESTDIR}; stage the XDP object there. + cp ${SRCDIR}/ebpf/xdp_filter.bpf ${OUTDIR}/xdp_filter.bpf + +server: | + exec ip netns exec server0 python3 ${TESTDIR}/server.py ${OUTDIR}/server.json + +client: | + exec ip netns exec client0 python3 ${TESTDIR}/client.py + +checks: + # We should see sid 1 on the BYPASS flow. + - filter: + count: 1 + match: + event_type: alert + alert.signature_id: 1 + metadata.flowbits[0]: "bypassed" + + # SID 2 should match once on the non-bypassed flow. + - filter: + count: 1 + match: + event_type: alert + alert.signature_id: 2 + + # We should have one flow on port 7000 marked as bypassed by capture + - filter: + count: 1 + match: + event_type: flow + dest_port: 7000 + flow.state: bypassed + flow.bypass: capture + + # We should have one flow on port 7000 marked as closed. + - filter: + count: 1 + match: + event_type: flow + dest_port: 7000 + flow.state: closed + not-has-key: flow.bypass + + # One flow should be marked as bypassed in the stats. + - stats: + flow.end.state.capture_bypassed: 1 diff --git a/live/tests/demo-afp-xdp-bypass-stats-shutdown/README.md b/live/tests/demo-afp-xdp-bypass-stats-shutdown/README.md new file mode 100644 index 0000000000..5b08f70cc5 --- /dev/null +++ b/live/tests/demo-afp-xdp-bypass-stats-shutdown/README.md @@ -0,0 +1,8 @@ +--- +tags: +- bypass +- xdp +--- + +Demo test based on the test in +https://github.com/OISF/suricata-verify/pull/3194. diff --git a/live/tests/demo-afp-xdp-bypass-stats-shutdown/client.py b/live/tests/demo-afp-xdp-bypass-stats-shutdown/client.py new file mode 100644 index 0000000000..deb10358db --- /dev/null +++ b/live/tests/demo-afp-xdp-bypass-stats-shutdown/client.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python3 +"""Client driver for the live bypass test. + +Open one TCP connection and send a fixed TLS-like payload 97 times. +""" + +import socket +import sys +import time + +HOST = "10.200.0.1" +PORT = 7000 + +PAYLOAD = b"\x17\x03\x03" + b"\x00" * 100 +COUNT = 97 + + +def main(): + try: + with socket.create_connection((HOST, PORT), timeout=10) as s: + s.settimeout(10) + for _ in range(COUNT): + s.sendall(PAYLOAD) + time.sleep(0.01) + except Exception as err: # noqa: BLE001 - surface any failure to the runner + print(f"ERROR: {err}", file=sys.stderr, flush=True) + return 1 + print(f"client OK: sent {len(PAYLOAD) * COUNT} bytes", flush=True) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/live/tests/demo-afp-xdp-bypass-stats-shutdown/include.yaml b/live/tests/demo-afp-xdp-bypass-stats-shutdown/include.yaml new file mode 100644 index 0000000000..b9a4e5a0c2 --- /dev/null +++ b/live/tests/demo-afp-xdp-bypass-stats-shutdown/include.yaml @@ -0,0 +1,21 @@ +%YAML 1.1 +--- + +default-rule-path: ${TESTDIR} +rule-files: + - test.rules + +# AF_PACKET inline copy-mode (IPS) with XDP bypass on both capture interfaces. +# When a flow is bypassed, Suricata installs it in the XDP flow map; the XDP +# program then forwards that flow's packets between client0 and server0 in the +# kernel, so they stop reaching Suricata's userspace while the endpoints keep +# talking. This is the af-packet equivalent of the NFQ capture bypass in +# ../bypass-nfq. +af-packet: + - interface: br0 + cluster-id: 91 + bypass: yes + xdp-mode: soft + xdp-filter-file: ${OUTDIR}/xdp_filter.bpf + - interface: default + cluster-type: cluster_flow diff --git a/live/tests/demo-afp-xdp-bypass-stats-shutdown/server.py b/live/tests/demo-afp-xdp-bypass-stats-shutdown/server.py new file mode 100644 index 0000000000..8a1d1ee8a7 --- /dev/null +++ b/live/tests/demo-afp-xdp-bypass-stats-shutdown/server.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +"""Tiny TCP sink for the live bypass test. + +For every connection, consume all bytes until the peer closes. When a +connection closes, append a JSON line with the byte count to ``$OUT`` +(argv[1]) if provided. +""" + +import json +import socket +import sys +import threading + +HOST = "0.0.0.0" +PORT = 7000 + +out_path = sys.argv[1] if len(sys.argv) > 1 else None +out_lock = threading.Lock() +conn_no = 0 + + +def record(conn_id, nbytes): + if not out_path: + return + with out_lock: + with open(out_path, "a", encoding="utf-8") as f: + f.write(json.dumps({"conn": conn_id, "bytes": nbytes}) + "\n") + + +def handle(sock, conn_id): + total = 0 + try: + while True: + data = sock.recv(65536) + if not data: + break + total += len(data) + finally: + sock.close() + record(conn_id, total) + print(f"conn {conn_id}: received {total} bytes", flush=True) + + +def main(): + srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + srv.bind((HOST, PORT)) + srv.listen(16) + print(f"listening on {HOST}:{PORT}", flush=True) + global conn_no + while True: + client, _ = srv.accept() + conn_no += 1 + t = threading.Thread(target=handle, args=(client, conn_no), daemon=True) + t.start() + + +if __name__ == "__main__": + main() diff --git a/live/tests/demo-afp-xdp-bypass-stats-shutdown/test.rules b/live/tests/demo-afp-xdp-bypass-stats-shutdown/test.rules new file mode 100644 index 0000000000..895aa615ce --- /dev/null +++ b/live/tests/demo-afp-xdp-bypass-stats-shutdown/test.rules @@ -0,0 +1 @@ +alert ip4 any any -> any any (msg:"Dropping all IPv4 traffic"; bypass; sid:1; rev:1;) diff --git a/live/tests/demo-afp-xdp-bypass-stats-shutdown/test.yaml b/live/tests/demo-afp-xdp-bypass-stats-shutdown/test.yaml new file mode 100644 index 0000000000..014d6d4aa7 --- /dev/null +++ b/live/tests/demo-afp-xdp-bypass-stats-shutdown/test.yaml @@ -0,0 +1,38 @@ +environment: tap + +requires: + command: + - python3 + features: + - XDP + files: + - ebpf/xdp_filter.bpf + +args: + - --af-packet + +before: | + set -e + # include.yaml can only expand ${OUTDIR}/${TESTDIR}; stage the XDP object there. + cp ${SRCDIR}/ebpf/xdp_filter.bpf ${OUTDIR}/xdp_filter.bpf + +server: | + exec ip netns exec server0 python3 ${TESTDIR}/server.py ${OUTDIR}/server.json + +client: | + exec ip netns exec client0 python3 ${TESTDIR}/client.py + +checks: + + - filter: + count: 1 + match: + event_type: flow + dest_port: 7000 + flow.state: bypassed + flow.bypass: capture + + - stats: + flow.end.state.capture_bypassed: 1 + flow_bypassed.local_capture_pkts.__gt: 0 + flow_bypassed.local_capture_bytes.__gt: 0 diff --git a/live/tests/demo-replay-xdp-bypass-vlan/README.md b/live/tests/demo-replay-xdp-bypass-vlan/README.md new file mode 100644 index 0000000000..ddebab6a36 --- /dev/null +++ b/live/tests/demo-replay-xdp-bypass-vlan/README.md @@ -0,0 +1,14 @@ +--- +tags: +- bypass +- replay +- xdp +--- + +Demo live IDS replay test based on the test in +https://github.com/OISF/suricata-verify/pull/3204. + +The pcap contains one VLAN-tagged TCP flow with 100 packets. The client script +replays it from the client namespace onto the IDS bridge path with `tcpreplay`. + +Ticket: https://redmine.openinfosecfoundation.org/issues/8699 diff --git a/live/tests/demo-replay-xdp-bypass-vlan/include.yaml b/live/tests/demo-replay-xdp-bypass-vlan/include.yaml new file mode 100644 index 0000000000..8c823d7fb6 --- /dev/null +++ b/live/tests/demo-replay-xdp-bypass-vlan/include.yaml @@ -0,0 +1,19 @@ +%YAML 1.1 +--- + +default-rule-path: ${TESTDIR} +rule-files: + - test.rules + +flow-timeouts: + tcp: + bypassed: 1 + +af-packet: + - interface: br0 + cluster-id: 92 + bypass: yes + xdp-mode: soft + xdp-filter-file: ${OUTDIR}/xdp_filter.bpf + - interface: default + cluster-type: cluster_flow diff --git a/live/tests/demo-replay-xdp-bypass-vlan/input.pcap b/live/tests/demo-replay-xdp-bypass-vlan/input.pcap new file mode 100644 index 0000000000..0a273ad39b Binary files /dev/null and b/live/tests/demo-replay-xdp-bypass-vlan/input.pcap differ diff --git a/live/tests/demo-replay-xdp-bypass-vlan/test.rules b/live/tests/demo-replay-xdp-bypass-vlan/test.rules new file mode 100644 index 0000000000..d380e1d3ad --- /dev/null +++ b/live/tests/demo-replay-xdp-bypass-vlan/test.rules @@ -0,0 +1 @@ +alert tcp any any -> any any (bypass; sid:1000004; rev:1;) diff --git a/live/tests/demo-replay-xdp-bypass-vlan/test.yaml b/live/tests/demo-replay-xdp-bypass-vlan/test.yaml new file mode 100644 index 0000000000..0be9a23ea9 --- /dev/null +++ b/live/tests/demo-replay-xdp-bypass-vlan/test.yaml @@ -0,0 +1,28 @@ +environment: tap + +requires: + min-version: 9 + os: linux + command: + - tcpreplay + features: + - XDP + files: + - ebpf/xdp_filter.bpf + +args: + - --af-packet + +before: | + set -e + # include.yaml can only expand ${OUTDIR}/${TESTDIR}; stage the XDP object there. + cp ${SRCDIR}/ebpf/xdp_filter.bpf ${OUTDIR}/xdp_filter.bpf + +client: | + set -e + ip netns exec dut tcpreplay -q -i br0 ${TESTDIR}/input.pcap + sleep 2 + +checks: + - stats: + capture.kernel_packets.__gte: 100 diff --git a/live/tests/dpdk-ids-afpacket-pmd/README.md b/live/tests/dpdk-ids-afpacket-pmd/README.md new file mode 100644 index 0000000000..e701ffbaa2 --- /dev/null +++ b/live/tests/dpdk-ids-afpacket-pmd/README.md @@ -0,0 +1,16 @@ +--- +tags: +- dpdk +- dpdk-ids +--- + +# DPDK IDS using the AF_PACKET virtual PMD + +Converts the `afp-ids-tpacket2-workers` live test to DPDK IDS. The live runner +creates its normal tap bridge in the DUT namespace. DPDK's AF_PACKET virtual +PMD attaches to `br0`, allowing Suricata to observe traffic forwarded by the +Linux bridge without owning physical PCI devices. + +The test covers packet capture, datasets, rule reload, interface and runmode +socket commands, and hostbit management. This exercises Suricata's DPDK receive +path, but not a VFIO-bound hardware NIC. diff --git a/live/tests/dpdk-ids-afpacket-pmd/client.sh b/live/tests/dpdk-ids-afpacket-pmd/client.sh new file mode 100644 index 0000000000..060861dc3e --- /dev/null +++ b/live/tests/dpdk-ids-afpacket-pmd/client.sh @@ -0,0 +1,113 @@ +#!/bin/bash +set -euo pipefail + +socket="${OUTDIR}/suricata.socket" +eve="${OUTDIR}/eve.json" +target=10.200.0.1 + +# Drive traffic through the IDS bridge while exercising Suricata's Unix socket +# API. Alerts are asynchronous, so helpers below poll instead of relying on +# fixed sleeps. + +# Run one command through suricatasc and return its JSON response. +sc() { + "${SURICATASC}" -c "$1" "${socket}" +} + +# Require a socket command to return the expected status (OK by default). +expect_return() { + local command="$1" + local expected="${2:-OK}" + local json + json=$(sc "${command}") + echo "${json}" + test "$(jq -r '.return' <<<"${json}")" = "${expected}" +} + +# Some socket commands can briefly return NOK while a reload is completing. +wait_for_ok() { + local command="$1" + local json + for _ in $(seq 1 40); do + json=$(sc "${command}") + if [ "$(jq -r '.return' <<<"${json}")" = OK ]; then + echo "${json}" + return 0 + fi + sleep 0.25 + done + echo "${json}" + echo "error: command did not return OK: ${command}" + return 1 +} + +# Wait until eve.json contains at least the requested number of alerts for SID. +wait_for_alerts() { + local sid="$1" + local expected="$2" + local count=0 + for _ in $(seq 1 40); do + count=$(jq -c "select(.event_type == \"alert\" and .alert.signature_id == ${sid})" \ + "${eve}" 2>/dev/null | wc -l) + if [ "${count}" -ge "${expected}" ]; then + return 0 + fi + sleep 0.25 + done + echo "error: expected at least ${expected} alerts for sid ${sid}, got ${count}" + return 1 +} + +ping_once() { + ip netns exec client0 ping -c 1 -W 1 "${target}" +} + +# The initial rules alert on ICMP and add its destination to ipv4-list. Clear +# the dataset between pings to verify both packet inspection and dataset API +# handling; the final checks expect two alerts from each initial rule. +ping_once +wait_for_alerts 222 1 +expect_return "dataset-clear ipv4-list ipv4" +ping_once +wait_for_alerts 222 2 + +# Discover the DPDK virtual interface and confirm it has captured traffic. +json=$(sc "iface-list") +echo "${json}" +iface=$(jq -r '.message.ifaces[0]' <<<"${json}") +sleep 1 # Allow the interface counters to be published. +json=$(sc "iface-stat ${iface}") +echo "${json}" +test "$(jq -r '.message.pkts > 0' <<<"${json}")" = true + +# Replace the ruleset with a hostbit-dependent rule and reload it live. +cp "${TESTDIR}/icmp2.rules" "${OUTDIR}/suricata.rules" +expect_return "reload-rules" + +# Smoke-test the remaining management commands and verify DPDK/workers are the +# active capture and running modes. Commands without assertions still fail the +# script if suricatasc itself cannot execute them. +sc "iface-bypassed-stat" +json=$(sc "capture-mode") +echo "${json}" +test "$(jq -r '.message' <<<"${json}")" = "${EXPECTED_CAPTURE_MODE}" +json=$(wait_for_ok "dump-counters") +test "$(jq -r '.message.uptime >= 0' <<<"${json}")" = true +sc "memcap-list" +json=$(sc "running-mode") +echo "${json}" +test "$(jq -r '.message' <<<"${json}")" = "${EXPECTED_RUNMODE}" +sc "version" +json=$(sc "uptime") +echo "${json}" +test "$(jq -r '.message >= 0' <<<"${json}")" = true + +# Set the hostbit required by the reloaded rule, then prove that another ICMP +# packet alerts before removing the hostbit again. +expect_return "add-hostbit ${target} test 60" +ping_once +wait_for_alerts 2 1 +json=$(sc "list-hostbit ${target}") +echo "${json}" +test "$(jq -r '.message.hostbits[0].name' <<<"${json}")" = test +expect_return "remove-hostbit ${target} test" diff --git a/live/tests/dpdk-ids-afpacket-pmd/icmp.rules b/live/tests/dpdk-ids-afpacket-pmd/icmp.rules new file mode 100644 index 0000000000..a938596a81 --- /dev/null +++ b/live/tests/dpdk-ids-afpacket-pmd/icmp.rules @@ -0,0 +1,3 @@ +alert icmp any any -> any any (itype:8; sid:1;) +alert icmp any any -> any any (itype:8; ip.dst; dataset:set,ipv4-list,type ipv4; sid:222;) +alert icmp any any -> any any (itype:8; ip.dst; dataset:set,ipv6-list,type ipv6; sid:226;) diff --git a/live/tests/dpdk-ids-afpacket-pmd/icmp2.rules b/live/tests/dpdk-ids-afpacket-pmd/icmp2.rules new file mode 100644 index 0000000000..a60be4dbd1 --- /dev/null +++ b/live/tests/dpdk-ids-afpacket-pmd/icmp2.rules @@ -0,0 +1 @@ +alert icmp any any -> any any (itype:8; hostbits:isset,test,dst; sid:2;) diff --git a/live/tests/dpdk-ids-afpacket-pmd/include.yaml b/live/tests/dpdk-ids-afpacket-pmd/include.yaml new file mode 100644 index 0000000000..4f63bc5f20 --- /dev/null +++ b/live/tests/dpdk-ids-afpacket-pmd/include.yaml @@ -0,0 +1,47 @@ +%YAML 1.1 +--- + +default-rule-path: ${OUTDIR} +rule-files: + - suricata.rules + +outputs: + - eve-log: + enabled: yes + filetype: regular + filename: eve.json + types: + - alert + - stats: + totals: yes + threads: no + interval: 1 + +dpdk: + eal-params: + proc-type: primary + vdev: "net_af_packet0,iface=br0" + no-huge: + no-pci: + m: 256 + + interfaces: + - interface: net_af_packet0 + threads: 1 + multicast: false + mempool-size: auto + mempool-cache-size: auto + rx-descriptors: 16 + tx-descriptors: 16 + copy-mode: none + copy-iface: none + +# DPDK requires CPU affinity. +threading: + set-cpu-affinity: yes + cpu-affinity: + - management-cpu-set: + cpu: [ 0 ] + - worker-cpu-set: + cpu: [ "all" ] + mode: "exclusive" diff --git a/live/tests/dpdk-ids-afpacket-pmd/test.yaml b/live/tests/dpdk-ids-afpacket-pmd/test.yaml new file mode 100644 index 0000000000..169c9e80fb --- /dev/null +++ b/live/tests/dpdk-ids-afpacket-pmd/test.yaml @@ -0,0 +1,38 @@ +# DPDK IDS conversion of afp-ids-tpacket2-workers. +environment: tap + +requires: + command: + - jq + - ping + features: + - DPDK + +args: + - --dpdk + - --runmode workers + +before: | + cp ${TESTDIR}/icmp.rules ${OUTDIR}/suricata.rules + +client: | + EXPECTED_CAPTURE_MODE=DPDK EXPECTED_RUNMODE=workers exec bash ${TESTDIR}/client.sh + +checks: + - stats: + capture.packets.__gt: 0 + - filter: + count: 2 + match: + event_type: alert + alert.signature_id: 1 + - filter: + count: 2 + match: + event_type: alert + alert.signature_id: 222 + - filter: + count: 1 + match: + event_type: alert + alert.signature_id: 2 diff --git a/live/tests/firewall-icmp/README.md b/live/tests/firewall-icmp/README.md new file mode 100644 index 0000000000..2c28f7d2e7 --- /dev/null +++ b/live/tests/firewall-icmp/README.md @@ -0,0 +1,13 @@ +--- +tags: +- firewall +- icmp +--- + +A simple ICMP firewall test. + +- We first start with an empty firewall ruleset and attempt a ping + which we accept to fail. +- Then we update the firewall rules with a rule to allow ICMP and + trigger a reload. +- Then test that a ping is allowed. diff --git a/live/tests/firewall-icmp/client/Dockerfile b/live/tests/firewall-icmp/client/Dockerfile new file mode 100644 index 0000000000..78154c478c --- /dev/null +++ b/live/tests/firewall-icmp/client/Dockerfile @@ -0,0 +1,3 @@ +FROM ubuntu:24.04 +RUN apt update && \ + apt install -y iputils-ping diff --git a/live/tests/firewall-icmp/empty.rules b/live/tests/firewall-icmp/empty.rules new file mode 100644 index 0000000000..0832bc245c --- /dev/null +++ b/live/tests/firewall-icmp/empty.rules @@ -0,0 +1 @@ +# Empty ruleset for live tests that only use firewall rules. diff --git a/live/tests/firewall-icmp/firewall.rules b/live/tests/firewall-icmp/firewall.rules new file mode 100644 index 0000000000..1497983561 --- /dev/null +++ b/live/tests/firewall-icmp/firewall.rules @@ -0,0 +1 @@ +# Fist firewall ruleset, just a drop all. diff --git a/live/tests/firewall-icmp/firewall2.rules b/live/tests/firewall-icmp/firewall2.rules new file mode 100644 index 0000000000..34859aa393 --- /dev/null +++ b/live/tests/firewall-icmp/firewall2.rules @@ -0,0 +1,2 @@ +# Allow ICMP. +accept:packet icmp:all any any -> any any (alert; sid:100;) diff --git a/live/tests/firewall-icmp/include.yaml b/live/tests/firewall-icmp/include.yaml new file mode 100644 index 0000000000..59d8cda94f --- /dev/null +++ b/live/tests/firewall-icmp/include.yaml @@ -0,0 +1,12 @@ +%YAML 1.1 +--- + +default-rule-path: ${TESTDIR} +rule-files: + - empty.rules + +firewall: + enabled: yes + rule-path: ${OUTDIR} + rule-files: + - firewall.rules diff --git a/live/tests/firewall-icmp/test.yaml b/live/tests/firewall-icmp/test.yaml new file mode 100644 index 0000000000..28fcef5691 --- /dev/null +++ b/live/tests/firewall-icmp/test.yaml @@ -0,0 +1,45 @@ +environment: nfq + +requires: + command: + - podman + +args: + - -q 0 + +before: | + cp firewall.rules ${OUTDIR}/firewall.rules + +client: | + id=$(podman build -q --network=host client) + + podman run --cap-add=NET_RAW --rm --network ns:/var/run/netns/client0 ${id} bash -lc ' + if ping -c 1 -W 1 10.200.0.1; then + echo "error: ping should have failed" > /dev/stderr + exit 1 + fi + ' + + cp firewall2.rules ${OUTDIR}/firewall.rules + ${SURICATASC} -c reload-rules ${OUTDIR}/suricata.socket + + podman run --cap-add=NET_RAW --rm --network ns:/var/run/netns/client0 ${id} bash -lc ' + ping -c 1 -W 1 10.200.0.1 + ' + +checks: + - stats: + decoder.icmpv4: 3 + + # I'm not sure about these 2 yet. We've tried to create a closed + # network, but I'm not sure if any unexpected packets could + # still creep in. + firewall.accepted: 2 + firewall.blocked: 1 + + - filter: + count: 2 + match: + event_type: alert + proto: ICMP + alert.action: allowed diff --git a/live/tests/ips-alert-drop-http-afp/README.md b/live/tests/ips-alert-drop-http-afp/README.md new file mode 100644 index 0000000000..57ad416c4a --- /dev/null +++ b/live/tests/ips-alert-drop-http-afp/README.md @@ -0,0 +1,23 @@ +--- +tags: +- http +--- + +# AF_PACKET IPS with overlapping alert and drop rules over HTTP + +Suricata runs inline in AF_PACKET copy-mode IPS with two copies of the +classic testmyids.org "GPL ATTACK_RESPONSE id check returned root" +rule with identical match conditions: sid 1 is a generic alert rule +matching any source and destination, while sid 2 is a drop rule +limited to traffic to the client (10.200.0.2), such as the server +responses. + +The client fetches a benign page which must succeed, then the +testmyids page whose response contains +`uid=0(root) gid=0(root) groups=0(root)`. That response matches both +rules and is dropped, so the fetch must fail. + +The checks verify that both rules alerted with their expected actions +("allowed" for sid 1, "blocked" for sid 2, on the same dropped +response), that the benign transaction completed normally, and that +the IPS blocked packets. diff --git a/live/tests/ips-alert-drop-http-afp/include.yaml b/live/tests/ips-alert-drop-http-afp/include.yaml new file mode 100644 index 0000000000..1a652a6d7c --- /dev/null +++ b/live/tests/ips-alert-drop-http-afp/include.yaml @@ -0,0 +1,20 @@ +%YAML 1.1 +--- + +default-rule-path: ${TESTDIR} +rule-files: + - test.rules + +af-packet: + - interface: client0 + cluster-id: 80 + copy-mode: ips + copy-iface: server0 + - interface: server0 + cluster-id: 81 + copy-mode: ips + copy-iface: client0 + - interface: default + defrag: false + threads: auto + cluster-type: cluster_flow diff --git a/live/tests/ips-alert-drop-http-afp/test.rules b/live/tests/ips-alert-drop-http-afp/test.rules new file mode 100644 index 0000000000..89775e0f27 --- /dev/null +++ b/live/tests/ips-alert-drop-http-afp/test.rules @@ -0,0 +1,2 @@ +alert ip any any -> any any (msg:"GPL ATTACK_RESPONSE id check returned root"; content:"uid=0|28|root|29|"; classtype:bad-unknown; sid:1;) +drop ip any any -> 10.200.0.2 any (msg:"GPL ATTACK_RESPONSE id check returned root"; content:"uid=0|28|root|29|"; classtype:bad-unknown; sid:2;) diff --git a/live/tests/ips-alert-drop-http-afp/test.yaml b/live/tests/ips-alert-drop-http-afp/test.yaml new file mode 100644 index 0000000000..8ba997f375 --- /dev/null +++ b/live/tests/ips-alert-drop-http-afp/test.yaml @@ -0,0 +1,67 @@ +environment: inline + +requires: + command: + - curl + - python3 + +args: + - --af-packet + - --runmode workers + +server: | + exec ip netns exec server0 python3 -m http.server --directory ${TESTDIR}/www 80 + +client: | + errors="no" + + # The benign page does not match any rule and must pass. + echo "Fetching benign page..." + if ! ip netns exec client0 timeout --kill-after=1 --preserve-status 5 \ + curl -fsS --retry 3 --retry-connrefused --retry-delay 1 \ + -o /dev/null http://10.200.0.1/index.html; then + echo "error: fetching the benign page should have succeeded" + errors="yes" + fi + + # The testmyids page matches both rules and the response is dropped, + # so curl must fail. + echo "Fetching testmyids page..." + if ip netns exec client0 timeout --kill-after=1 --preserve-status 5 \ + curl -fsS -o /dev/null http://10.200.0.1/ids.html; then + echo "error: fetching the testmyids page should have failed" + errors="yes" + fi + + if [ "${errors}" = "yes" ]; then + exit 1 + fi + +checks: + # Both rules match the testmyids response, but the alert rule's own + # action stays "allowed" even though the packet was dropped. + - filter: + count: 1 + match: + event_type: alert + alert.signature_id: 1 + alert.action: allowed + + - filter: + count: 1 + match: + event_type: alert + alert.signature_id: 2 + alert.action: blocked + + # The benign request completes as a normal HTTP transaction. + - filter: + count: 1 + match: + event_type: http + http.url: "/index.html" + http.status: 200 + + - stats: + ips.accepted.__gt: 0 + ips.blocked.__gte: 1 diff --git a/live/tests/ips-alert-drop-http-afp/www/ids.html b/live/tests/ips-alert-drop-http-afp/www/ids.html new file mode 100644 index 0000000000..0e70fba574 --- /dev/null +++ b/live/tests/ips-alert-drop-http-afp/www/ids.html @@ -0,0 +1 @@ +uid=0(root) gid=0(root) groups=0(root) diff --git a/live/tests/ips-alert-drop-http-afp/www/index.html b/live/tests/ips-alert-drop-http-afp/www/index.html new file mode 100644 index 0000000000..af5626b4a1 --- /dev/null +++ b/live/tests/ips-alert-drop-http-afp/www/index.html @@ -0,0 +1 @@ +Hello, world! diff --git a/live/tests/ips-alert-drop-icmp-afp/README.md b/live/tests/ips-alert-drop-icmp-afp/README.md new file mode 100644 index 0000000000..bcc4e97c52 --- /dev/null +++ b/live/tests/ips-alert-drop-icmp-afp/README.md @@ -0,0 +1,17 @@ +# AF_PACKET IPS with overlapping alert and drop rules + +Suricata runs inline in AF_PACKET copy-mode IPS with two rules that +have identical match conditions (ICMP echo request), but different +actions and rule headers: + +- sid 1: a generic alert rule matching any source and destination +- sid 2: a drop rule limited to echo requests from the client + (10.200.0.2) to the server (10.200.0.1) + +Pings from the client to the server must fail as they are dropped by +sid 2, while pings from the server to the client only match the alert +rule and must succeed. + +The checks verify that both rules alerted with their expected actions +("allowed" for sid 1, "blocked" for sid 2, including on the same +dropped packets), and that the IPS blocked the dropped pings. diff --git a/live/tests/ips-alert-drop-icmp-afp/include.yaml b/live/tests/ips-alert-drop-icmp-afp/include.yaml new file mode 100644 index 0000000000..1a652a6d7c --- /dev/null +++ b/live/tests/ips-alert-drop-icmp-afp/include.yaml @@ -0,0 +1,20 @@ +%YAML 1.1 +--- + +default-rule-path: ${TESTDIR} +rule-files: + - test.rules + +af-packet: + - interface: client0 + cluster-id: 80 + copy-mode: ips + copy-iface: server0 + - interface: server0 + cluster-id: 81 + copy-mode: ips + copy-iface: client0 + - interface: default + defrag: false + threads: auto + cluster-type: cluster_flow diff --git a/live/tests/ips-alert-drop-icmp-afp/test.rules b/live/tests/ips-alert-drop-icmp-afp/test.rules new file mode 100644 index 0000000000..6dccb112a0 --- /dev/null +++ b/live/tests/ips-alert-drop-icmp-afp/test.rules @@ -0,0 +1,2 @@ +alert icmp any any -> any any (msg:"ICMP echo request"; itype:8; sid:1;) +drop icmp 10.200.0.2 any -> 10.200.0.1 any (msg:"ICMP echo request to server"; itype:8; sid:2;) diff --git a/live/tests/ips-alert-drop-icmp-afp/test.yaml b/live/tests/ips-alert-drop-icmp-afp/test.yaml new file mode 100644 index 0000000000..6f45afebbd --- /dev/null +++ b/live/tests/ips-alert-drop-icmp-afp/test.yaml @@ -0,0 +1,55 @@ +environment: inline + +requires: + command: + - ping + +args: + - --af-packet + - --runmode workers + +client: | + errors="no" + + # Pings to the server match both the generic alert rule and the + # specific drop rule, so they must fail. + echo "Pinging server from client (should be dropped)..." + if ip netns exec client0 ping -c 10 -i 0.2 -W 1 10.200.0.1; then + echo "error: ping to server should have failed" + errors="yes" + fi + + # Pings from the server to the client only match the generic alert + # rule, so they must succeed. + echo "Pinging client from server (should pass)..." + if ! ip netns exec server0 ping -c 5 -i 0.2 -W 1 10.200.0.2; then + echo "error: ping to client should have succeeded" + errors="yes" + fi + + if [ "${errors}" = "yes" ]; then + exit 1 + fi + +checks: + # The generic alert rule matches every echo request in both + # directions: 10 client->server plus 5 server->client. It alerts on + # the dropped packets too, but its own action stays "allowed". + - filter: + count: 15 + match: + event_type: alert + alert.signature_id: 1 + alert.action: allowed + + # The drop rule only matches the 10 echo requests to the server. + - filter: + count: 10 + match: + event_type: alert + alert.signature_id: 2 + alert.action: blocked + + - stats: + ips.accepted.__gt: 0 + ips.blocked.__gte: 10 diff --git a/live/tests/ips-drop-icmp-afp-autofp/README.md b/live/tests/ips-drop-icmp-afp-autofp/README.md new file mode 100644 index 0000000000..2e6d3dd61b --- /dev/null +++ b/live/tests/ips-drop-icmp-afp-autofp/README.md @@ -0,0 +1,6 @@ +A copy of the `ips-drop-icmp` AF_PACKET IPS test that runs Suricata with +`--runmode autofp`. + +Suricata runs inline with AF_PACKET copy-mode IPS and a single rule that drops +ICMP echo requests. HTTP traffic must still pass, ICMP must be dropped, and no +echo requests should reach the server. diff --git a/live/tests/ips-drop-icmp-afp-autofp/client/Dockerfile b/live/tests/ips-drop-icmp-afp-autofp/client/Dockerfile new file mode 100644 index 0000000000..e7f07fa8ed --- /dev/null +++ b/live/tests/ips-drop-icmp-afp-autofp/client/Dockerfile @@ -0,0 +1,3 @@ +FROM ubuntu:24.04 +RUN apt-get -y update && \ + apt-get -y install curl wget iputils-ping diff --git a/live/tests/ips-drop-icmp-afp-autofp/drop-icmp.rules b/live/tests/ips-drop-icmp-afp-autofp/drop-icmp.rules new file mode 100644 index 0000000000..af3f7b14e9 --- /dev/null +++ b/live/tests/ips-drop-icmp-afp-autofp/drop-icmp.rules @@ -0,0 +1 @@ +drop icmp any any -> any any (itype:8; sid:1;) diff --git a/live/tests/ips-drop-icmp-afp-autofp/include.yaml b/live/tests/ips-drop-icmp-afp-autofp/include.yaml new file mode 100644 index 0000000000..ae2bb1d449 --- /dev/null +++ b/live/tests/ips-drop-icmp-afp-autofp/include.yaml @@ -0,0 +1,20 @@ +%YAML 1.1 +--- + +default-rule-path: ${TESTDIR} +rule-files: + - drop-icmp.rules + +af-packet: + - interface: client0 + cluster-id: 80 + copy-mode: ips + copy-iface: server0 + - interface: server0 + cluster-id: 81 + copy-mode: ips + copy-iface: client0 + - interface: default + defrag: false + threads: auto + cluster-type: cluster_flow diff --git a/live/tests/ips-drop-icmp-afp-autofp/server/Dockerfile b/live/tests/ips-drop-icmp-afp-autofp/server/Dockerfile new file mode 100644 index 0000000000..e0561d17c5 --- /dev/null +++ b/live/tests/ips-drop-icmp-afp-autofp/server/Dockerfile @@ -0,0 +1,5 @@ +FROM ubuntu:24.04 +RUN apt-get -y update && \ + apt-get -y install caddy tshark +COPY /server.sh /server.sh +COPY /index.html /srv/www/index.html diff --git a/live/tests/ips-drop-icmp-afp-autofp/server/index.html b/live/tests/ips-drop-icmp-afp-autofp/server/index.html new file mode 100644 index 0000000000..e69de29bb2 diff --git a/live/tests/ips-drop-icmp-afp-autofp/server/server.sh b/live/tests/ips-drop-icmp-afp-autofp/server/server.sh new file mode 100755 index 0000000000..30ce5e9f92 --- /dev/null +++ b/live/tests/ips-drop-icmp-afp-autofp/server/server.sh @@ -0,0 +1,10 @@ +#! /bin/bash + +set -e +set -x + +echo "Starting tshark..." +tshark -i server -f icmp -T json > /out/tshark-server.json & + +echo "Starting caddy..." +cd /srv/www && exec caddy file-server browse diff --git a/live/tests/ips-drop-icmp-afp-autofp/test.yaml b/live/tests/ips-drop-icmp-afp-autofp/test.yaml new file mode 100644 index 0000000000..a6b85fb771 --- /dev/null +++ b/live/tests/ips-drop-icmp-afp-autofp/test.yaml @@ -0,0 +1,70 @@ +# Port of qa/live/netns/afp-ips-netns-bridge.sh with the autofp runmode. +environment: inline + +requires: + command: + - jq + - podman + +args: + - --af-packet + - --runmode autofp + +before: | + # Build both images up front. The first build can be slow and would + # otherwise race the server/client scripts. + podman build --iidfile=${OUTDIR}/server.iid --network=host server + podman build --iidfile=${OUTDIR}/client.iid --network=host client + +server: | + podman run --rm --cap-add=NET_RAW --network ns:/var/run/netns/server0 \ + -v ${OUTDIR}:/out:rw \ + $(cat ${OUTDIR}/server.iid) \ + /server.sh + +client: | + errors="no" + + iid=$(cat ${OUTDIR}/client.iid) + run() { + echo "Running: $@" + podman run --rm --cap-add=NET_RAW --network ns:/var/run/netns/client0 ${iid} "$@" + } + + # Only ICMP is dropped, so HTTP requests should still succeed. + echo "Running curl..." + if ! run timeout --kill-after=1 --preserve-status 5 \ + curl -fsS -O http://10.200.0.1/index.html; then + echo "error: curl should have completed successfully" + errors="yes" + fi + + echo "Running wget..." + if ! run timeout --kill-after=1 --preserve-status 5 \ + wget http://10.200.0.1/index.html; then + echo "error: wget should have completed successfully" + errors="yes" + fi + + # ICMP echo requests should be dropped, so ping should fail. + echo "Running ping..." + if run ping -c 10 -i 0.2 -W 1 10.200.0.1; then + echo "error: ping should have failed" + errors="yes" + fi + + if [ "${errors}" = "yes" ]; then + exit 1 + fi + +checks: + - stats: + capture.kernel_packets.__gt: 0 + ips.accepted.__gt: 0 + ips.blocked.__gte: 10 + + # We should not have seen any ICMP echo requests on the server. + - shell: + args: | + pings=$(jq -c '.[]' ./tshark-server.json|jq 'select(._source.layers.icmp."icmp.type"=="8")'|wc -l) + test "${pings}" -eq 0 diff --git a/live/tests/ips-drop-icmp-afp/README.md b/live/tests/ips-drop-icmp-afp/README.md new file mode 100644 index 0000000000..a179744c5f --- /dev/null +++ b/live/tests/ips-drop-icmp-afp/README.md @@ -0,0 +1,6 @@ +A port of "afp-ips-netns-bridge" (inline environment) and "nfq-ips-netns-route" +(nfq environment) from the Suricata repo to this testing harness. + +Suricata runs inline (AF_PACKET copy-mode IPS or NFQUEUE) with a single +rule that drops ICMP echo requests. HTTP traffic must still pass, ICMP +must be dropped, and no echo requests should reach the server. diff --git a/live/tests/ips-drop-icmp-afp/client/Dockerfile b/live/tests/ips-drop-icmp-afp/client/Dockerfile new file mode 100644 index 0000000000..e7f07fa8ed --- /dev/null +++ b/live/tests/ips-drop-icmp-afp/client/Dockerfile @@ -0,0 +1,3 @@ +FROM ubuntu:24.04 +RUN apt-get -y update && \ + apt-get -y install curl wget iputils-ping diff --git a/live/tests/ips-drop-icmp-afp/drop-icmp.rules b/live/tests/ips-drop-icmp-afp/drop-icmp.rules new file mode 100644 index 0000000000..af3f7b14e9 --- /dev/null +++ b/live/tests/ips-drop-icmp-afp/drop-icmp.rules @@ -0,0 +1 @@ +drop icmp any any -> any any (itype:8; sid:1;) diff --git a/live/tests/ips-drop-icmp-afp/include.yaml b/live/tests/ips-drop-icmp-afp/include.yaml new file mode 100644 index 0000000000..ae2bb1d449 --- /dev/null +++ b/live/tests/ips-drop-icmp-afp/include.yaml @@ -0,0 +1,20 @@ +%YAML 1.1 +--- + +default-rule-path: ${TESTDIR} +rule-files: + - drop-icmp.rules + +af-packet: + - interface: client0 + cluster-id: 80 + copy-mode: ips + copy-iface: server0 + - interface: server0 + cluster-id: 81 + copy-mode: ips + copy-iface: client0 + - interface: default + defrag: false + threads: auto + cluster-type: cluster_flow diff --git a/live/tests/ips-drop-icmp-afp/server/Dockerfile b/live/tests/ips-drop-icmp-afp/server/Dockerfile new file mode 100644 index 0000000000..e0561d17c5 --- /dev/null +++ b/live/tests/ips-drop-icmp-afp/server/Dockerfile @@ -0,0 +1,5 @@ +FROM ubuntu:24.04 +RUN apt-get -y update && \ + apt-get -y install caddy tshark +COPY /server.sh /server.sh +COPY /index.html /srv/www/index.html diff --git a/live/tests/ips-drop-icmp-afp/server/index.html b/live/tests/ips-drop-icmp-afp/server/index.html new file mode 100644 index 0000000000..e69de29bb2 diff --git a/live/tests/ips-drop-icmp-afp/server/server.sh b/live/tests/ips-drop-icmp-afp/server/server.sh new file mode 100755 index 0000000000..30ce5e9f92 --- /dev/null +++ b/live/tests/ips-drop-icmp-afp/server/server.sh @@ -0,0 +1,10 @@ +#! /bin/bash + +set -e +set -x + +echo "Starting tshark..." +tshark -i server -f icmp -T json > /out/tshark-server.json & + +echo "Starting caddy..." +cd /srv/www && exec caddy file-server browse diff --git a/live/tests/ips-drop-icmp-afp/test.yaml b/live/tests/ips-drop-icmp-afp/test.yaml new file mode 100644 index 0000000000..5c2a9a00ee --- /dev/null +++ b/live/tests/ips-drop-icmp-afp/test.yaml @@ -0,0 +1,70 @@ +# Port of qa/live/netns/afp-ips-netns-bridge.sh with the workers runmode. +environment: inline + +requires: + command: + - jq + - podman + +args: + - --af-packet + - --runmode workers + +before: | + # Build both images up front. The first build can be slow and would + # otherwise race the server/client scripts. + podman build --iidfile=${OUTDIR}/server.iid --network=host server + podman build --iidfile=${OUTDIR}/client.iid --network=host client + +server: | + podman run --rm --cap-add=NET_RAW --network ns:/var/run/netns/server0 \ + -v ${OUTDIR}:/out:rw \ + $(cat ${OUTDIR}/server.iid) \ + /server.sh + +client: | + errors="no" + + iid=$(cat ${OUTDIR}/client.iid) + run() { + echo "Running: $@" + podman run --rm --cap-add=NET_RAW --network ns:/var/run/netns/client0 ${iid} "$@" + } + + # Only ICMP is dropped, so HTTP requests should still succeed. + echo "Running curl..." + if ! run timeout --kill-after=1 --preserve-status 5 \ + curl -fsS -O http://10.200.0.1/index.html; then + echo "error: curl should have completed successfully" + errors="yes" + fi + + echo "Running wget..." + if ! run timeout --kill-after=1 --preserve-status 5 \ + wget http://10.200.0.1/index.html; then + echo "error: wget should have completed successfully" + errors="yes" + fi + + # ICMP echo requests should be dropped, so ping should fail. + echo "Running ping..." + if run ping -c 10 -i 0.2 -W 1 10.200.0.1; then + echo "error: ping should have failed" + errors="yes" + fi + + if [ "${errors}" = "yes" ]; then + exit 1 + fi + +checks: + - stats: + capture.kernel_packets.__gt: 0 + ips.accepted.__gt: 0 + ips.blocked.__gte: 10 + + # We should not have seen any ICMP echo requests on the server. + - shell: + args: | + pings=$(jq -c '.[]' ./tshark-server.json|jq 'select(._source.layers.icmp."icmp.type"=="8")'|wc -l) + test "${pings}" -eq 0 diff --git a/live/tests/ips-drop-icmp-nfq-workers/README.md b/live/tests/ips-drop-icmp-nfq-workers/README.md new file mode 100644 index 0000000000..0565b21e53 --- /dev/null +++ b/live/tests/ips-drop-icmp-nfq-workers/README.md @@ -0,0 +1,6 @@ +A copy of the `ips-drop-icmp` NFQ IPS test that runs Suricata with +`--runmode workers`. + +Suricata runs inline with NFQUEUE and a single rule that drops ICMP echo +requests. HTTP traffic must still pass, ICMP must be dropped, and no echo +requests should reach the server. diff --git a/live/tests/ips-drop-icmp-nfq-workers/client/Dockerfile b/live/tests/ips-drop-icmp-nfq-workers/client/Dockerfile new file mode 100644 index 0000000000..e7f07fa8ed --- /dev/null +++ b/live/tests/ips-drop-icmp-nfq-workers/client/Dockerfile @@ -0,0 +1,3 @@ +FROM ubuntu:24.04 +RUN apt-get -y update && \ + apt-get -y install curl wget iputils-ping diff --git a/live/tests/ips-drop-icmp-nfq-workers/drop-icmp.rules b/live/tests/ips-drop-icmp-nfq-workers/drop-icmp.rules new file mode 100644 index 0000000000..af3f7b14e9 --- /dev/null +++ b/live/tests/ips-drop-icmp-nfq-workers/drop-icmp.rules @@ -0,0 +1 @@ +drop icmp any any -> any any (itype:8; sid:1;) diff --git a/live/tests/ips-drop-icmp-nfq-workers/include.yaml b/live/tests/ips-drop-icmp-nfq-workers/include.yaml new file mode 100644 index 0000000000..ff6b818a91 --- /dev/null +++ b/live/tests/ips-drop-icmp-nfq-workers/include.yaml @@ -0,0 +1,6 @@ +%YAML 1.1 +--- + +default-rule-path: ${TESTDIR} +rule-files: + - drop-icmp.rules diff --git a/live/tests/ips-drop-icmp-nfq-workers/server/Dockerfile b/live/tests/ips-drop-icmp-nfq-workers/server/Dockerfile new file mode 100644 index 0000000000..e0561d17c5 --- /dev/null +++ b/live/tests/ips-drop-icmp-nfq-workers/server/Dockerfile @@ -0,0 +1,5 @@ +FROM ubuntu:24.04 +RUN apt-get -y update && \ + apt-get -y install caddy tshark +COPY /server.sh /server.sh +COPY /index.html /srv/www/index.html diff --git a/live/tests/ips-drop-icmp-nfq-workers/server/index.html b/live/tests/ips-drop-icmp-nfq-workers/server/index.html new file mode 100644 index 0000000000..e69de29bb2 diff --git a/live/tests/ips-drop-icmp-nfq-workers/server/server.sh b/live/tests/ips-drop-icmp-nfq-workers/server/server.sh new file mode 100755 index 0000000000..30ce5e9f92 --- /dev/null +++ b/live/tests/ips-drop-icmp-nfq-workers/server/server.sh @@ -0,0 +1,10 @@ +#! /bin/bash + +set -e +set -x + +echo "Starting tshark..." +tshark -i server -f icmp -T json > /out/tshark-server.json & + +echo "Starting caddy..." +cd /srv/www && exec caddy file-server browse diff --git a/live/tests/ips-drop-icmp-nfq-workers/test.yaml b/live/tests/ips-drop-icmp-nfq-workers/test.yaml new file mode 100644 index 0000000000..329cc288d4 --- /dev/null +++ b/live/tests/ips-drop-icmp-nfq-workers/test.yaml @@ -0,0 +1,69 @@ +# Port of qa/live/netns/nfq-ips-netns-route.sh with the workers runmode. +environment: nfq + +requires: + command: + - jq + - podman + +args: + - -q 0 + - --runmode workers + +before: | + # Build both images up front. The first build can be slow and would + # otherwise race the server/client scripts. + podman build --iidfile=${OUTDIR}/server.iid --network=host server + podman build --iidfile=${OUTDIR}/client.iid --network=host client + +server: | + podman run --rm --cap-add=NET_RAW --network ns:/var/run/netns/server0 \ + -v ${OUTDIR}:/out:rw \ + $(cat ${OUTDIR}/server.iid) \ + /server.sh + +client: | + errors="no" + + iid=$(cat ${OUTDIR}/client.iid) + run() { + echo "Running: $@" + podman run --rm --cap-add=NET_RAW --network ns:/var/run/netns/client0 ${iid} "$@" + } + + # Only ICMP is dropped, so HTTP requests should still succeed. + echo "Running curl..." + if ! run timeout --kill-after=1 --preserve-status 5 \ + curl -fsS -O http://10.200.0.1/index.html; then + echo "error: curl should have completed successfully" + errors="yes" + fi + + echo "Running wget..." + if ! run timeout --kill-after=1 --preserve-status 5 \ + wget http://10.200.0.1/index.html; then + echo "error: wget should have completed successfully" + errors="yes" + fi + + # ICMP echo requests should be dropped, so ping should fail. + echo "Running ping..." + if run ping -c 10 -i 0.2 -W 1 10.200.0.1; then + echo "error: ping should have failed" + errors="yes" + fi + + if [ "${errors}" = "yes" ]; then + exit 1 + fi + +checks: + - stats: + ips.accepted.__gt: 0 + ips.blocked.__gte: 10 + + # We should not have seen any ICMP echo requests on the server. + - shell: + args: | + pings=$(jq -c '.[]' ./tshark-server.json|jq 'select(._source.layers.icmp."icmp.type"=="8")'|wc -l) + test "${pings}" -eq 0 diff --git a/live/tests/ips-drop-icmp-nfq/README.md b/live/tests/ips-drop-icmp-nfq/README.md new file mode 100644 index 0000000000..a179744c5f --- /dev/null +++ b/live/tests/ips-drop-icmp-nfq/README.md @@ -0,0 +1,6 @@ +A port of "afp-ips-netns-bridge" (inline environment) and "nfq-ips-netns-route" +(nfq environment) from the Suricata repo to this testing harness. + +Suricata runs inline (AF_PACKET copy-mode IPS or NFQUEUE) with a single +rule that drops ICMP echo requests. HTTP traffic must still pass, ICMP +must be dropped, and no echo requests should reach the server. diff --git a/live/tests/ips-drop-icmp-nfq/client/Dockerfile b/live/tests/ips-drop-icmp-nfq/client/Dockerfile new file mode 100644 index 0000000000..e7f07fa8ed --- /dev/null +++ b/live/tests/ips-drop-icmp-nfq/client/Dockerfile @@ -0,0 +1,3 @@ +FROM ubuntu:24.04 +RUN apt-get -y update && \ + apt-get -y install curl wget iputils-ping diff --git a/live/tests/ips-drop-icmp-nfq/drop-icmp.rules b/live/tests/ips-drop-icmp-nfq/drop-icmp.rules new file mode 100644 index 0000000000..af3f7b14e9 --- /dev/null +++ b/live/tests/ips-drop-icmp-nfq/drop-icmp.rules @@ -0,0 +1 @@ +drop icmp any any -> any any (itype:8; sid:1;) diff --git a/live/tests/ips-drop-icmp-nfq/include.yaml b/live/tests/ips-drop-icmp-nfq/include.yaml new file mode 100644 index 0000000000..ff6b818a91 --- /dev/null +++ b/live/tests/ips-drop-icmp-nfq/include.yaml @@ -0,0 +1,6 @@ +%YAML 1.1 +--- + +default-rule-path: ${TESTDIR} +rule-files: + - drop-icmp.rules diff --git a/live/tests/ips-drop-icmp-nfq/server/Dockerfile b/live/tests/ips-drop-icmp-nfq/server/Dockerfile new file mode 100644 index 0000000000..e0561d17c5 --- /dev/null +++ b/live/tests/ips-drop-icmp-nfq/server/Dockerfile @@ -0,0 +1,5 @@ +FROM ubuntu:24.04 +RUN apt-get -y update && \ + apt-get -y install caddy tshark +COPY /server.sh /server.sh +COPY /index.html /srv/www/index.html diff --git a/live/tests/ips-drop-icmp-nfq/server/index.html b/live/tests/ips-drop-icmp-nfq/server/index.html new file mode 100644 index 0000000000..e69de29bb2 diff --git a/live/tests/ips-drop-icmp-nfq/server/server.sh b/live/tests/ips-drop-icmp-nfq/server/server.sh new file mode 100755 index 0000000000..30ce5e9f92 --- /dev/null +++ b/live/tests/ips-drop-icmp-nfq/server/server.sh @@ -0,0 +1,10 @@ +#! /bin/bash + +set -e +set -x + +echo "Starting tshark..." +tshark -i server -f icmp -T json > /out/tshark-server.json & + +echo "Starting caddy..." +cd /srv/www && exec caddy file-server browse diff --git a/live/tests/ips-drop-icmp-nfq/test.yaml b/live/tests/ips-drop-icmp-nfq/test.yaml new file mode 100644 index 0000000000..0c41cf0ec6 --- /dev/null +++ b/live/tests/ips-drop-icmp-nfq/test.yaml @@ -0,0 +1,69 @@ +# Port of qa/live/netns/nfq-ips-netns-route.sh with the autofp runmode. +environment: nfq + +requires: + command: + - jq + - podman + +args: + - -q 0 + - --runmode autofp + +before: | + # Build both images up front. The first build can be slow and would + # otherwise race the server/client scripts. + podman build --iidfile=${OUTDIR}/server.iid --network=host server + podman build --iidfile=${OUTDIR}/client.iid --network=host client + +server: | + podman run --rm --cap-add=NET_RAW --network ns:/var/run/netns/server0 \ + -v ${OUTDIR}:/out:rw \ + $(cat ${OUTDIR}/server.iid) \ + /server.sh + +client: | + errors="no" + + iid=$(cat ${OUTDIR}/client.iid) + run() { + echo "Running: $@" + podman run --rm --cap-add=NET_RAW --network ns:/var/run/netns/client0 ${iid} "$@" + } + + # Only ICMP is dropped, so HTTP requests should still succeed. + echo "Running curl..." + if ! run timeout --kill-after=1 --preserve-status 5 \ + curl -fsS -O http://10.200.0.1/index.html; then + echo "error: curl should have completed successfully" + errors="yes" + fi + + echo "Running wget..." + if ! run timeout --kill-after=1 --preserve-status 5 \ + wget http://10.200.0.1/index.html; then + echo "error: wget should have completed successfully" + errors="yes" + fi + + # ICMP echo requests should be dropped, so ping should fail. + echo "Running ping..." + if run ping -c 10 -i 0.2 -W 1 10.200.0.1; then + echo "error: ping should have failed" + errors="yes" + fi + + if [ "${errors}" = "yes" ]; then + exit 1 + fi + +checks: + - stats: + ips.accepted.__gt: 0 + ips.blocked.__gte: 10 + + # We should not have seen any ICMP echo requests on the server. + - shell: + args: | + pings=$(jq -c '.[]' ./tshark-server.json|jq 'select(._source.layers.icmp."icmp.type"=="8")'|wc -l) + test "${pings}" -eq 0 diff --git a/live/tests/ips-local-bypass-afp/README.md b/live/tests/ips-local-bypass-afp/README.md new file mode 100644 index 0000000000..890c766fd5 --- /dev/null +++ b/live/tests/ips-local-bypass-afp/README.md @@ -0,0 +1,11 @@ +--- +tags: +- bypass +- local +--- + +# IPS with local bypass test + +Test Suricata's own local bypass in AF_PACKET IPS mode. +The test checks that a flow matched by a `bypass` rule is bypassed by Suricata, +while a client and server verify that traffic still flows end to end. diff --git a/live/tests/ips-local-bypass-afp/client.py b/live/tests/ips-local-bypass-afp/client.py new file mode 100644 index 0000000000..122170d65f --- /dev/null +++ b/live/tests/ips-local-bypass-afp/client.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +"""Client driver for the live bypass test. + +Two real TCP connections through Suricata: + +1. The *bypass* flow: start a flow with our bypass marker, then a story then + the tripwire text. This flow should now alert on the tripwire text and the + flow should be marked as bypassed. + +2. The *control* flow: just send our story followed by the tripwire text. As no + bypass was done, we should alert on the tripwire text. + +In both cases the client always checks that what was sent was echo'd back. + +The script exits non-zero if any byte fails to round-trip, which the runner +treats as a test failure. +""" + +import socket +import sys +import time + +HOST = "10.200.0.1" +PORT = 7000 + + +STORY = b""" +Marty the meerkat stood on the warm desert sand every morning, stretching as tall as his little legs allowed so he could watch over his family. One day, while everyone else searched for breakfast, Marty spotted a shiny blue beetle struggling on its back beside a cactus. He hurried over, flipped it gently upright, and the beetle buzzed in happy circles before flying away. Later that afternoon, when a hungry eagle swept low across the dunes, a flash of blue wings darted in front of Marty and startled the eagle just long enough for him to squeak a warning. His family dove safely into their burrow, and from that day on, Marty learned that even the smallest kindness could come back in the biggest way. +""" + + +def recv_exact(sock, n): + buf = bytearray() + while len(buf) < n: + chunk = sock.recv(n - len(buf)) + if not chunk: + raise EOFError(f"connection closed after {len(buf)}/{n} bytes") + buf.extend(chunk) + return bytes(buf) + + +def send_and_verify_echo(sock, payload, what): + sock.sendall(payload) + echo = recv_exact(sock, len(payload)) + if echo != payload: + raise AssertionError(f"{what}: echo mismatch ({len(echo)}/{len(payload)} bytes)") + print(f"{what}: {len(payload)} bytes echoed back OK", flush=True) + + +def bypass_flow(): + with socket.create_connection((HOST, PORT), timeout=10) as s: + s.settimeout(10) + + # First send that should trigger the bypass. + send_and_verify_echo(s, b"BYPASS", "bypass-flow trigger") + + # Wait a moment for the bypass to be applied. + time.sleep(1.0) + + # Now send the story. + send_and_verify_echo(s, STORY, "bypass-flow payload") + + # Now send the tripwire. + send_and_verify_echo(s, b"TRIPWIRE", "bypass-flow-payload") + +def control_flow(): + with socket.create_connection((HOST, PORT), timeout=10) as s: + s.settimeout(10) + send_and_verify_echo(s, STORY, "control-flow payload") + send_and_verify_echo(s, b"TRIPWIRE", "control-flow-payload") + + +def main(): + try: + bypass_flow() + control_flow() + except Exception as err: # noqa: BLE001 - surface any failure to the runner + print(f"ERROR: {err}", file=sys.stderr, flush=True) + return 1 + print("client OK", flush=True) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/live/tests/ips-local-bypass-afp/include.yaml b/live/tests/ips-local-bypass-afp/include.yaml new file mode 100644 index 0000000000..1a652a6d7c --- /dev/null +++ b/live/tests/ips-local-bypass-afp/include.yaml @@ -0,0 +1,20 @@ +%YAML 1.1 +--- + +default-rule-path: ${TESTDIR} +rule-files: + - test.rules + +af-packet: + - interface: client0 + cluster-id: 80 + copy-mode: ips + copy-iface: server0 + - interface: server0 + cluster-id: 81 + copy-mode: ips + copy-iface: client0 + - interface: default + defrag: false + threads: auto + cluster-type: cluster_flow diff --git a/live/tests/ips-local-bypass-afp/server.py b/live/tests/ips-local-bypass-afp/server.py new file mode 100644 index 0000000000..f12e20956a --- /dev/null +++ b/live/tests/ips-local-bypass-afp/server.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +"""Tiny TCP echo + byte-counting server for the live bypass test. + +For every connection it echoes back every byte it receives and counts the +total. When a connection closes it appends a JSON line describing how many +bytes that connection delivered to ``$OUT`` (argv[1]). The client uses the +echo to prove that every byte it sent survived the trip through Suricata +(i.e. that bypassing the flow did not break end-to-end delivery), and the +JSON file lets a check assert the server actually received the full payload. +""" + +import json +import socket +import sys +import threading + +HOST = "0.0.0.0" +PORT = 7000 + +out_path = sys.argv[1] if len(sys.argv) > 1 else None +out_lock = threading.Lock() +conn_no = 0 + + +def record(conn_id, nbytes): + if not out_path: + return + with out_lock: + with open(out_path, "a", encoding="utf-8") as f: + f.write(json.dumps({"conn": conn_id, "bytes": nbytes}) + "\n") + + +def handle(sock, conn_id): + total = 0 + try: + while True: + data = sock.recv(65536) + if not data: + break + total += len(data) + # Echo everything straight back. + sock.sendall(data) + finally: + sock.close() + record(conn_id, total) + print(f"conn {conn_id}: received {total} bytes", flush=True) + + +def main(): + srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + srv.bind((HOST, PORT)) + srv.listen(16) + print(f"listening on {HOST}:{PORT}", flush=True) + global conn_no + while True: + client, _ = srv.accept() + conn_no += 1 + t = threading.Thread(target=handle, args=(client, conn_no), daemon=True) + t.start() + + +if __name__ == "__main__": + main() diff --git a/live/tests/ips-local-bypass-afp/test.rules b/live/tests/ips-local-bypass-afp/test.rules new file mode 100644 index 0000000000..a26e5cd40d --- /dev/null +++ b/live/tests/ips-local-bypass-afp/test.rules @@ -0,0 +1,6 @@ +# Flow to be passed. "BYPASS" will be sent before "TRIPWIRE". +alert tcp any any -> any 7000 (flow:to_server; content:"BYPASS"; bypass; flowbits:set,bypassed; sid:1; rev:1;) + +# Alert when we see "TRIPWIRE". The idea is that we'll see this on flows +# that were not bypassed. +alert tcp any any -> any 7000 (flow:to_server; content:"TRIPWIRE"; sid:2; rev:1;) diff --git a/live/tests/ips-local-bypass-afp/test.yaml b/live/tests/ips-local-bypass-afp/test.yaml new file mode 100644 index 0000000000..12ccbc4abd --- /dev/null +++ b/live/tests/ips-local-bypass-afp/test.yaml @@ -0,0 +1,61 @@ +environment: inline + +requires: + command: + - python3 + +args: + - --af-packet + +# No `before` script and no capture-bypass configuration: this test uses plain +# inline IPS copy-mode. When the `bypass` rule keyword fires, Suricata has no +# capture-bypass callback to hand the flow off to, so it uses its own local +# bypass instead. Unlike capture bypass, the packets keep arriving in +# Suricata's userspace; they just skip detection. + +server: | + exec ip netns exec server0 python3 ${TESTDIR}/server.py ${OUTDIR}/server.json + +client: | + exec ip netns exec client0 python3 ${TESTDIR}/client.py + +checks: + # We should see sid 1 on the BYPASS flow, with the bypass flowbit set. + - filter: + count: 1 + match: + event_type: alert + alert.signature_id: 1 + metadata.flowbits[0]: "bypassed" + + # SID 2 should match once, on the non-bypassed (control) flow only. On the + # bypassed flow the TRIPWIRE payload still reaches Suricata, but the flow is + # locally bypassed so it skips detection and must not match there. + - filter: + count: 1 + match: + event_type: alert + alert.signature_id: 2 + + # We should have one flow on port 7000 reported local-bypassed (handled by + # Suricata itself), not capture-bypassed (offloaded to the kernel/NIC). + - filter: + count: 1 + match: + event_type: flow + dest_port: 7000 + flow.state: bypassed + flow.bypass: local + + # The control flow on port 7000 should be reported closed. + - filter: + count: 1 + match: + event_type: flow + dest_port: 7000 + flow.state: closed + not-has-key: flow.bypass + + # One flow should be marked as local-bypassed in the stats. + - stats: + flow.end.state.local_bypassed: 1 diff --git a/live/tests/ips-local-bypass-nfq/README.md b/live/tests/ips-local-bypass-nfq/README.md new file mode 100644 index 0000000000..3cfb844b46 --- /dev/null +++ b/live/tests/ips-local-bypass-nfq/README.md @@ -0,0 +1,11 @@ +--- +tags: +- bypass +- local +--- + +# IPS with local bypass test + +Test Suricata's own local bypass in NFQ IPS mode. +The test checks that a flow matched by a `bypass` rule is bypassed by Suricata, +while a client and server verify that traffic still flows end to end. diff --git a/live/tests/ips-local-bypass-nfq/client.py b/live/tests/ips-local-bypass-nfq/client.py new file mode 100644 index 0000000000..122170d65f --- /dev/null +++ b/live/tests/ips-local-bypass-nfq/client.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +"""Client driver for the live bypass test. + +Two real TCP connections through Suricata: + +1. The *bypass* flow: start a flow with our bypass marker, then a story then + the tripwire text. This flow should now alert on the tripwire text and the + flow should be marked as bypassed. + +2. The *control* flow: just send our story followed by the tripwire text. As no + bypass was done, we should alert on the tripwire text. + +In both cases the client always checks that what was sent was echo'd back. + +The script exits non-zero if any byte fails to round-trip, which the runner +treats as a test failure. +""" + +import socket +import sys +import time + +HOST = "10.200.0.1" +PORT = 7000 + + +STORY = b""" +Marty the meerkat stood on the warm desert sand every morning, stretching as tall as his little legs allowed so he could watch over his family. One day, while everyone else searched for breakfast, Marty spotted a shiny blue beetle struggling on its back beside a cactus. He hurried over, flipped it gently upright, and the beetle buzzed in happy circles before flying away. Later that afternoon, when a hungry eagle swept low across the dunes, a flash of blue wings darted in front of Marty and startled the eagle just long enough for him to squeak a warning. His family dove safely into their burrow, and from that day on, Marty learned that even the smallest kindness could come back in the biggest way. +""" + + +def recv_exact(sock, n): + buf = bytearray() + while len(buf) < n: + chunk = sock.recv(n - len(buf)) + if not chunk: + raise EOFError(f"connection closed after {len(buf)}/{n} bytes") + buf.extend(chunk) + return bytes(buf) + + +def send_and_verify_echo(sock, payload, what): + sock.sendall(payload) + echo = recv_exact(sock, len(payload)) + if echo != payload: + raise AssertionError(f"{what}: echo mismatch ({len(echo)}/{len(payload)} bytes)") + print(f"{what}: {len(payload)} bytes echoed back OK", flush=True) + + +def bypass_flow(): + with socket.create_connection((HOST, PORT), timeout=10) as s: + s.settimeout(10) + + # First send that should trigger the bypass. + send_and_verify_echo(s, b"BYPASS", "bypass-flow trigger") + + # Wait a moment for the bypass to be applied. + time.sleep(1.0) + + # Now send the story. + send_and_verify_echo(s, STORY, "bypass-flow payload") + + # Now send the tripwire. + send_and_verify_echo(s, b"TRIPWIRE", "bypass-flow-payload") + +def control_flow(): + with socket.create_connection((HOST, PORT), timeout=10) as s: + s.settimeout(10) + send_and_verify_echo(s, STORY, "control-flow payload") + send_and_verify_echo(s, b"TRIPWIRE", "control-flow-payload") + + +def main(): + try: + bypass_flow() + control_flow() + except Exception as err: # noqa: BLE001 - surface any failure to the runner + print(f"ERROR: {err}", file=sys.stderr, flush=True) + return 1 + print("client OK", flush=True) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/live/tests/ips-local-bypass-nfq/include.yaml b/live/tests/ips-local-bypass-nfq/include.yaml new file mode 100644 index 0000000000..37e2cffa5d --- /dev/null +++ b/live/tests/ips-local-bypass-nfq/include.yaml @@ -0,0 +1,6 @@ +%YAML 1.1 +--- + +default-rule-path: ${TESTDIR} +rule-files: + - test.rules diff --git a/live/tests/ips-local-bypass-nfq/server.py b/live/tests/ips-local-bypass-nfq/server.py new file mode 100644 index 0000000000..f12e20956a --- /dev/null +++ b/live/tests/ips-local-bypass-nfq/server.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +"""Tiny TCP echo + byte-counting server for the live bypass test. + +For every connection it echoes back every byte it receives and counts the +total. When a connection closes it appends a JSON line describing how many +bytes that connection delivered to ``$OUT`` (argv[1]). The client uses the +echo to prove that every byte it sent survived the trip through Suricata +(i.e. that bypassing the flow did not break end-to-end delivery), and the +JSON file lets a check assert the server actually received the full payload. +""" + +import json +import socket +import sys +import threading + +HOST = "0.0.0.0" +PORT = 7000 + +out_path = sys.argv[1] if len(sys.argv) > 1 else None +out_lock = threading.Lock() +conn_no = 0 + + +def record(conn_id, nbytes): + if not out_path: + return + with out_lock: + with open(out_path, "a", encoding="utf-8") as f: + f.write(json.dumps({"conn": conn_id, "bytes": nbytes}) + "\n") + + +def handle(sock, conn_id): + total = 0 + try: + while True: + data = sock.recv(65536) + if not data: + break + total += len(data) + # Echo everything straight back. + sock.sendall(data) + finally: + sock.close() + record(conn_id, total) + print(f"conn {conn_id}: received {total} bytes", flush=True) + + +def main(): + srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + srv.bind((HOST, PORT)) + srv.listen(16) + print(f"listening on {HOST}:{PORT}", flush=True) + global conn_no + while True: + client, _ = srv.accept() + conn_no += 1 + t = threading.Thread(target=handle, args=(client, conn_no), daemon=True) + t.start() + + +if __name__ == "__main__": + main() diff --git a/live/tests/ips-local-bypass-nfq/test.rules b/live/tests/ips-local-bypass-nfq/test.rules new file mode 100644 index 0000000000..a26e5cd40d --- /dev/null +++ b/live/tests/ips-local-bypass-nfq/test.rules @@ -0,0 +1,6 @@ +# Flow to be passed. "BYPASS" will be sent before "TRIPWIRE". +alert tcp any any -> any 7000 (flow:to_server; content:"BYPASS"; bypass; flowbits:set,bypassed; sid:1; rev:1;) + +# Alert when we see "TRIPWIRE". The idea is that we'll see this on flows +# that were not bypassed. +alert tcp any any -> any 7000 (flow:to_server; content:"TRIPWIRE"; sid:2; rev:1;) diff --git a/live/tests/ips-local-bypass-nfq/test.yaml b/live/tests/ips-local-bypass-nfq/test.yaml new file mode 100644 index 0000000000..141e3f3be6 --- /dev/null +++ b/live/tests/ips-local-bypass-nfq/test.yaml @@ -0,0 +1,61 @@ +environment: nfq + +requires: + command: + - python3 + +args: + - -q 0 + +# No `before` script and no capture-bypass configuration: this test uses the +# plain NFQUEUE ruleset. When the `bypass` rule keyword fires, Suricata has no +# capture-bypass callback to hand the flow off to, so it uses its own local +# bypass instead. Unlike capture bypass, the packets keep arriving in +# Suricata's userspace; they just skip detection. + +server: | + exec ip netns exec server0 python3 ${TESTDIR}/server.py ${OUTDIR}/server.json + +client: | + exec ip netns exec client0 python3 ${TESTDIR}/client.py + +checks: + # We should see sid 1 on the BYPASS flow, with the bypass flowbit set. + - filter: + count: 1 + match: + event_type: alert + alert.signature_id: 1 + metadata.flowbits[0]: "bypassed" + + # SID 2 should match once, on the non-bypassed (control) flow only. On the + # bypassed flow the TRIPWIRE payload still reaches Suricata, but the flow is + # locally bypassed so it skips detection and must not match there. + - filter: + count: 1 + match: + event_type: alert + alert.signature_id: 2 + + # We should have one flow on port 7000 reported local-bypassed (handled by + # Suricata itself), not capture-bypassed (offloaded to the kernel/NIC). + - filter: + count: 1 + match: + event_type: flow + dest_port: 7000 + flow.state: bypassed + flow.bypass: local + + # The control flow on port 7000 should be reported closed. + - filter: + count: 1 + match: + event_type: flow + dest_port: 7000 + flow.state: closed + not-has-key: flow.bypass + + # One flow should be marked as local-bypassed in the stats. + - stats: + flow.end.state.local_bypassed: 1 diff --git a/live/tests/nfq-fw/README.md b/live/tests/nfq-fw/README.md new file mode 100644 index 0000000000..29124b913d --- /dev/null +++ b/live/tests/nfq-fw/README.md @@ -0,0 +1,8 @@ +A port of "nfq-fw-netns-route" from the Suricata repo to this testing +harness. + +Suricata runs as a NFQUEUE router enforcing firewall rules. The default +policy is drop; accept rules allow HTTP. The curl request triggers the +request_line alert but is dropped (its user-agent is not accepted), while +the wget request is accepted. A rule reload then swaps in a second rule +set. Uses the L3 rule variants since NFQUEUE operates at layer 3. diff --git a/live/tests/nfq-fw/client/Dockerfile b/live/tests/nfq-fw/client/Dockerfile new file mode 100644 index 0000000000..e7f07fa8ed --- /dev/null +++ b/live/tests/nfq-fw/client/Dockerfile @@ -0,0 +1,3 @@ +FROM ubuntu:24.04 +RUN apt-get -y update && \ + apt-get -y install curl wget iputils-ping diff --git a/live/tests/nfq-fw/empty.rules b/live/tests/nfq-fw/empty.rules new file mode 100644 index 0000000000..0832bc245c --- /dev/null +++ b/live/tests/nfq-fw/empty.rules @@ -0,0 +1 @@ +# Empty ruleset for live tests that only use firewall rules. diff --git a/live/tests/nfq-fw/firewall1.rules b/live/tests/nfq-fw/firewall1.rules new file mode 100644 index 0000000000..f33ea0990c --- /dev/null +++ b/live/tests/nfq-fw/firewall1.rules @@ -0,0 +1,12 @@ +# allow session setup +accept:hook tcp:all any any <> any 80 (flow:not_established; alert; sid:1021;) + +# pass rest of the flow to +accept:hook tcp:all any any <> any 80 (flow:established; alert; sid:1023;) +#accept:hook ip:all any any <> any any (alert; sid:1024;) + +# default drop + +accept:hook http1:request_started any any -> any any (alert; sid:100;) +accept:hook http1:request_line any any -> any any (http.method; content:"GET"; http.uri; content:"/"; alert; sid:101;) +accept:tx http1:request_headers any any -> any any (http.user_agent; content:"wget"; nocase; alert; sid:102;) diff --git a/live/tests/nfq-fw/firewall2.rules b/live/tests/nfq-fw/firewall2.rules new file mode 100644 index 0000000000..d20a74c8c7 --- /dev/null +++ b/live/tests/nfq-fw/firewall2.rules @@ -0,0 +1,12 @@ +# allow session setup +accept:hook tcp:all any any <> any 80 (flow:not_established; alert; sid:1021;) + +# pass rest of the flow to +accept:hook tcp:all any any <> any 80 (flow:established; alert; sid:1023;) +#accept:hook ip:all any any <> any any (alert; sid:1024;) + +# default drop + +accept:hook http1:request_started any any -> any any (alert; sid:100;) +accept:hook http1:request_line any any -> any any (http.method; bsize:3; urilen:>1; sid:201; alert;) +accept:tx http1:request_headers any any -> any any (http.user_agent; pcre:"/wget/i"; sid:202; alert;) diff --git a/live/tests/nfq-fw/include.yaml b/live/tests/nfq-fw/include.yaml new file mode 100644 index 0000000000..90c3c9e48a --- /dev/null +++ b/live/tests/nfq-fw/include.yaml @@ -0,0 +1,12 @@ +%YAML 1.1 +--- + +default-rule-path: ${TESTDIR} +rule-files: + - empty.rules + +firewall: + enabled: true + rule-path: ${OUTDIR} + rule-files: + - firewall.rules diff --git a/live/tests/nfq-fw/server/Dockerfile b/live/tests/nfq-fw/server/Dockerfile new file mode 100644 index 0000000000..e0561d17c5 --- /dev/null +++ b/live/tests/nfq-fw/server/Dockerfile @@ -0,0 +1,5 @@ +FROM ubuntu:24.04 +RUN apt-get -y update && \ + apt-get -y install caddy tshark +COPY /server.sh /server.sh +COPY /index.html /srv/www/index.html diff --git a/live/tests/nfq-fw/server/index.html b/live/tests/nfq-fw/server/index.html new file mode 100644 index 0000000000..e69de29bb2 diff --git a/live/tests/nfq-fw/server/server.sh b/live/tests/nfq-fw/server/server.sh new file mode 100755 index 0000000000..30ce5e9f92 --- /dev/null +++ b/live/tests/nfq-fw/server/server.sh @@ -0,0 +1,10 @@ +#! /bin/bash + +set -e +set -x + +echo "Starting tshark..." +tshark -i server -f icmp -T json > /out/tshark-server.json & + +echo "Starting caddy..." +cd /srv/www && exec caddy file-server browse diff --git a/live/tests/nfq-fw/test.yaml b/live/tests/nfq-fw/test.yaml new file mode 100644 index 0000000000..eb8cb9c7f8 --- /dev/null +++ b/live/tests/nfq-fw/test.yaml @@ -0,0 +1,108 @@ +# NFQUEUE firewall test: default-drop with accept rules for HTTP, then a +# rule reload swaps in a second rule set. +# +# Ports qa/live/netns/nfq-fw-netns-route.sh with the autofp runmode. Uses the +# L3 firewall rule variants (no ARP rule) because NFQUEUE operates at layer 3. +environment: nfq + +requires: + command: + - jq + - podman + +args: + - -q 0 + - --runmode autofp + +before: | + cp ${TESTDIR}/firewall1.rules ${OUTDIR}/firewall.rules + + # It can take a while to build containers the first time which means + # the client or server script could start early. So build both here. + podman build --iidfile=${OUTDIR}/server.iid --network=host server + podman build --iidfile=${OUTDIR}/client.iid --network=host client + +server: | + podman run --rm --cap-add=NET_RAW --network ns:/var/run/netns/server0 \ + -v ${OUTDIR}:/out:rw \ + $(cat ${OUTDIR}/server.iid) \ + /server.sh + +client: | + errors="no" + + iid=$(cat ${OUTDIR}/client.iid) + run() { + echo "Running: $@" + podman run --rm --cap-add=NET_RAW --network ns:/var/run/netns/client0 ${iid} "$@" + } + + # Curl request should not succeed (its user-agent is not accepted, so the + # response is dropped) but it still triggers the request_line alert. + echo "Running curl..." + if run timeout --kill-after=1 --preserve-status 5 curl -O http://10.200.0.1/index.html; then + echo "error: curl should not have completed successfully" + errors="yes" + fi + + # Wget request should succeed. + echo "Running wget..." + if ! run timeout --kill-after=1 --preserve-status 5 wget http://10.200.0.1/index.html; then + echo "error: wget should have completed successfully" + errors="yes" + fi + + # Ping should not succeed (default drop, ICMP is not accepted). + echo "Running ping..." + if run ping -c 10 -i 0.2 -W 1 10.200.0.1; then + echo "error: ping should have failed" + errors="yes" + fi + + cp ${TESTDIR}/firewall2.rules ${OUTDIR}/firewall.rules + ${SURICATASC} -c reload-rules ${OUTDIR}/suricata.socket + + # Wget request should succeed. + echo "Running wget..." + if ! run timeout --kill-after=1 --preserve-status 5 wget http://10.200.0.1/index.html; then + echo "error: wget should have completed successfully" + errors="yes" + fi + + if [ "${errors}" = "yes" ]; then + exit 1 + fi + +checks: + # First rule set: curl + wget both hit the request_line rule (sid 101), + # only wget matches the user-agent rule (sid 102). + - filter: + count: 2 + match: + alert.signature_id: 101 + + - filter: + count: 1 + match: + alert.signature_id: 102 + + # Second rule set (after reload): wget hits sid 201 and sid 202. + - filter: + count: 1 + match: + alert.signature_id: 201 + + - filter: + count: 1 + match: + alert.signature_id: 202 + + - stats: + firewall.accepted.__gt: 0 + firewall.blocked.__gte: 10 + + # We should not have seen any ICMP echo requests on the server. + - shell: + args: | + pings=$(jq -c '.[]' ./tshark-server.json|jq 'select(._source.layers.icmp."icmp.type"=="8")'|wc -l) + test "${pings}" -eq 0 diff --git a/live/tests/nfq-ips-bypass/README.md b/live/tests/nfq-ips-bypass/README.md new file mode 100644 index 0000000000..7045efc2ff --- /dev/null +++ b/live/tests/nfq-ips-bypass/README.md @@ -0,0 +1,18 @@ +--- +tags: +- bypass +- nfq +--- + +# NFQ IPS with capture bypass test + +Test NFQ IPS mode with capture (offload) bypass. The test checks that packets +are bypassed by Suricata, but uses a client and server to verify that the +traffic still flows. + +When a flow is bypassed, Suricata ORs `nfq.bypass-mark` into the NFQUEUE verdict. +The test's `before` script installs a conntrack/CONNMARK ruleset in the DUT +namespace that saves that mark onto the connection and then accepts any +subsequent marked packet without queueing it. Suricata therefore stops seeing +the flow entirely while the endpoints keep talking. This is the NFQ equivalent +of the af-packet XDP capture bypass in `../afp-ips-xdp-bypass`. diff --git a/live/tests/nfq-ips-bypass/client.py b/live/tests/nfq-ips-bypass/client.py new file mode 100644 index 0000000000..122170d65f --- /dev/null +++ b/live/tests/nfq-ips-bypass/client.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +"""Client driver for the live bypass test. + +Two real TCP connections through Suricata: + +1. The *bypass* flow: start a flow with our bypass marker, then a story then + the tripwire text. This flow should now alert on the tripwire text and the + flow should be marked as bypassed. + +2. The *control* flow: just send our story followed by the tripwire text. As no + bypass was done, we should alert on the tripwire text. + +In both cases the client always checks that what was sent was echo'd back. + +The script exits non-zero if any byte fails to round-trip, which the runner +treats as a test failure. +""" + +import socket +import sys +import time + +HOST = "10.200.0.1" +PORT = 7000 + + +STORY = b""" +Marty the meerkat stood on the warm desert sand every morning, stretching as tall as his little legs allowed so he could watch over his family. One day, while everyone else searched for breakfast, Marty spotted a shiny blue beetle struggling on its back beside a cactus. He hurried over, flipped it gently upright, and the beetle buzzed in happy circles before flying away. Later that afternoon, when a hungry eagle swept low across the dunes, a flash of blue wings darted in front of Marty and startled the eagle just long enough for him to squeak a warning. His family dove safely into their burrow, and from that day on, Marty learned that even the smallest kindness could come back in the biggest way. +""" + + +def recv_exact(sock, n): + buf = bytearray() + while len(buf) < n: + chunk = sock.recv(n - len(buf)) + if not chunk: + raise EOFError(f"connection closed after {len(buf)}/{n} bytes") + buf.extend(chunk) + return bytes(buf) + + +def send_and_verify_echo(sock, payload, what): + sock.sendall(payload) + echo = recv_exact(sock, len(payload)) + if echo != payload: + raise AssertionError(f"{what}: echo mismatch ({len(echo)}/{len(payload)} bytes)") + print(f"{what}: {len(payload)} bytes echoed back OK", flush=True) + + +def bypass_flow(): + with socket.create_connection((HOST, PORT), timeout=10) as s: + s.settimeout(10) + + # First send that should trigger the bypass. + send_and_verify_echo(s, b"BYPASS", "bypass-flow trigger") + + # Wait a moment for the bypass to be applied. + time.sleep(1.0) + + # Now send the story. + send_and_verify_echo(s, STORY, "bypass-flow payload") + + # Now send the tripwire. + send_and_verify_echo(s, b"TRIPWIRE", "bypass-flow-payload") + +def control_flow(): + with socket.create_connection((HOST, PORT), timeout=10) as s: + s.settimeout(10) + send_and_verify_echo(s, STORY, "control-flow payload") + send_and_verify_echo(s, b"TRIPWIRE", "control-flow-payload") + + +def main(): + try: + bypass_flow() + control_flow() + except Exception as err: # noqa: BLE001 - surface any failure to the runner + print(f"ERROR: {err}", file=sys.stderr, flush=True) + return 1 + print("client OK", flush=True) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/live/tests/nfq-ips-bypass/include.yaml b/live/tests/nfq-ips-bypass/include.yaml new file mode 100644 index 0000000000..523812b3f5 --- /dev/null +++ b/live/tests/nfq-ips-bypass/include.yaml @@ -0,0 +1,12 @@ +%YAML 1.1 +--- + +default-rule-path: ${TESTDIR} +rule-files: + - test.rules + +# NFQ IPS with capture (offload) bypass. Unlike af-packet, the kernel forwarding +# path is driven by an iptables conntrack/CONNMARK ruleset (installed by the +# test's `before` script) rather than an XDP program; Suricata only ORs the +# bypass mark into the verdict via nfq.bypass-mark. This is the NFQ equivalent of +# the af-packet XDP capture bypass in ../afp-ips-xdp-bypass. diff --git a/live/tests/nfq-ips-bypass/server.py b/live/tests/nfq-ips-bypass/server.py new file mode 100644 index 0000000000..f12e20956a --- /dev/null +++ b/live/tests/nfq-ips-bypass/server.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +"""Tiny TCP echo + byte-counting server for the live bypass test. + +For every connection it echoes back every byte it receives and counts the +total. When a connection closes it appends a JSON line describing how many +bytes that connection delivered to ``$OUT`` (argv[1]). The client uses the +echo to prove that every byte it sent survived the trip through Suricata +(i.e. that bypassing the flow did not break end-to-end delivery), and the +JSON file lets a check assert the server actually received the full payload. +""" + +import json +import socket +import sys +import threading + +HOST = "0.0.0.0" +PORT = 7000 + +out_path = sys.argv[1] if len(sys.argv) > 1 else None +out_lock = threading.Lock() +conn_no = 0 + + +def record(conn_id, nbytes): + if not out_path: + return + with out_lock: + with open(out_path, "a", encoding="utf-8") as f: + f.write(json.dumps({"conn": conn_id, "bytes": nbytes}) + "\n") + + +def handle(sock, conn_id): + total = 0 + try: + while True: + data = sock.recv(65536) + if not data: + break + total += len(data) + # Echo everything straight back. + sock.sendall(data) + finally: + sock.close() + record(conn_id, total) + print(f"conn {conn_id}: received {total} bytes", flush=True) + + +def main(): + srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + srv.bind((HOST, PORT)) + srv.listen(16) + print(f"listening on {HOST}:{PORT}", flush=True) + global conn_no + while True: + client, _ = srv.accept() + conn_no += 1 + t = threading.Thread(target=handle, args=(client, conn_no), daemon=True) + t.start() + + +if __name__ == "__main__": + main() diff --git a/live/tests/nfq-ips-bypass/test.rules b/live/tests/nfq-ips-bypass/test.rules new file mode 100644 index 0000000000..a26e5cd40d --- /dev/null +++ b/live/tests/nfq-ips-bypass/test.rules @@ -0,0 +1,6 @@ +# Flow to be passed. "BYPASS" will be sent before "TRIPWIRE". +alert tcp any any -> any 7000 (flow:to_server; content:"BYPASS"; bypass; flowbits:set,bypassed; sid:1; rev:1;) + +# Alert when we see "TRIPWIRE". The idea is that we'll see this on flows +# that were not bypassed. +alert tcp any any -> any 7000 (flow:to_server; content:"TRIPWIRE"; sid:2; rev:1;) diff --git a/live/tests/nfq-ips-bypass/test.yaml b/live/tests/nfq-ips-bypass/test.yaml new file mode 100644 index 0000000000..1f4286a5ff --- /dev/null +++ b/live/tests/nfq-ips-bypass/test.yaml @@ -0,0 +1,83 @@ +environment: nfq + +requires: + command: + - python3 + +# Enable NFQ capture bypass: Suricata ORs this mark into the verdict of a +# bypassed flow's packets so the kernel can offload the rest of the flow. +args: + - -q 0 + - --set nfq.bypass-mark=1 + - --set nfq.bypass-mask=1 + +# Replace the runner's plain NFQUEUE ruleset with a conntrack/CONNMARK ruleset +# so the bypass mark persists across packets and marked flows skip the queue. +# This is the NFQ analogue of staging the XDP object in afp-ips-xdp-bypass: it is +# what actually forwards a bypassed flow's packets in the kernel, past Suricata. +before: | + set -e + dut() { ip netns exec dut "$@"; } + + dut iptables -F + dut iptables -t mangle -F + dut iptables -P FORWARD DROP + + # Restore the connection's saved mark onto each packet as it enters FORWARD. + dut iptables -A FORWARD -j CONNMARK --restore-mark + # A packet whose flow has already been bypassed carries the mark: accept it + # immediately so it never reaches Suricata. + dut iptables -A FORWARD -m mark --mark 0x1/0x1 -j ACCEPT + # Everything else is queued to Suricata for inspection. + dut iptables -A FORWARD -i client0 -o server0 -j NFQUEUE --queue-num 0 + dut iptables -A FORWARD -i server0 -o client0 -j NFQUEUE --queue-num 0 + + # After Suricata accepts a bypassed packet (with the mark set on the verdict), + # save that packet mark onto the conntrack entry so future packets inherit it. + dut iptables -t mangle -A POSTROUTING -j CONNMARK --save-mark + +server: | + exec ip netns exec server0 python3 ${TESTDIR}/server.py ${OUTDIR}/server.json + +client: | + exec ip netns exec client0 python3 ${TESTDIR}/client.py + +checks: + # We should see sid 1 on the BYPASS flow, with the bypass flowbit set. + - filter: + count: 1 + match: + event_type: alert + alert.signature_id: 1 + metadata.flowbits[0]: "bypassed" + + # SID 2 should match once, on the non-bypassed (control) flow only. On the + # bypassed flow the TRIPWIRE payload is offloaded to the kernel and never + # reaches Suricata, so it must not match there. + - filter: + count: 1 + match: + event_type: alert + alert.signature_id: 2 + + # We should have one flow on port 7000 reported capture-bypassed (offloaded + # to the kernel), not local-bypassed. + - filter: + count: 1 + match: + event_type: flow + dest_port: 7000 + flow.state: bypassed + flow.bypass: capture + + # The control flow on port 7000 should be reported closed. + - filter: + count: 1 + match: + event_type: flow + dest_port: 7000 + flow.state: closed + + # One flow should be marked as capture-bypassed in the stats. + - stats: + flow.end.state.capture_bypassed: 1 diff --git a/live/tests/pcap-ids-autofp/README.md b/live/tests/pcap-ids-autofp/README.md new file mode 100644 index 0000000000..12ba69f7f7 --- /dev/null +++ b/live/tests/pcap-ids-autofp/README.md @@ -0,0 +1,15 @@ +--- +tags: +- legacy-afpdpdk +- legacy-pcap +--- + +# Libpcap IDS autofp test + +Ports `qa/live/pcap.sh autofp` from the Suricata repository. It replaces +host-default-gateway traffic with deterministic ICMP traffic on the live +framework's `10.200.0.0/24` IDS bridge. + +The test covers packet capture, IPv4 and IPv6 datasets, malformed dataset +input, rule reload, interface and runmode socket commands, and hostbit +management. diff --git a/live/tests/pcap-ids-autofp/client.sh b/live/tests/pcap-ids-autofp/client.sh new file mode 100644 index 0000000000..38797e5afa --- /dev/null +++ b/live/tests/pcap-ids-autofp/client.sh @@ -0,0 +1,105 @@ +#!/bin/bash +set -euo pipefail + +socket="${OUTDIR}/suricata.socket" +eve="${OUTDIR}/eve.json" +target=10.200.0.1 + +sc() { + "${SURICATASC}" -c "$1" "${socket}" +} + +expect_return() { + local command="$1" + local expected="${2:-OK}" + local json + json=$(sc "${command}") + echo "${json}" + test "$(jq -r '.return' <<<"${json}")" = "${expected}" +} + +wait_for_ok() { + local command="$1" + local json + for _ in $(seq 1 40); do + json=$(sc "${command}") + if [ "$(jq -r '.return' <<<"${json}")" = OK ]; then + echo "${json}" + return 0 + fi + sleep 0.25 + done + echo "${json}" + echo "error: command did not return OK: ${command}" + return 1 +} + +wait_for_alerts() { + local sid="$1" + local expected="$2" + local count=0 + for _ in $(seq 1 40); do + count=$(jq -c "select(.event_type == \"alert\" and .alert.signature_id == ${sid})" \ + "${eve}" 2>/dev/null | wc -l) + if [ "${count}" -ge "${expected}" ]; then + return 0 + fi + sleep 0.25 + done + echo "error: expected at least ${expected} alerts for sid ${sid}, got ${count}" + return 1 +} + +ping_once() { + ip netns exec client0 ping -c 1 -W 1 "${target}" +} + +ping_once +wait_for_alerts 222 1 +expect_return "dataset-clear ipv4-list ipv4" +ping_once +wait_for_alerts 222 2 + +json=$(sc "dataset-add ipv6-list ip 192.168.1.1") +echo "${json}" +test "$(jq -r '.message' <<<"${json}")" = "data added" +json=$(sc "dataset-lookup ipv6-list ip ::ffff:c0a8:0101") +echo "${json}" +test "$(jq -r '.message' <<<"${json}")" = "item found in set" +json=$(sc "dataset-add ipv6-list ip ::ffff:c0a8:0z0z") +echo "${json}" +test "$(jq -r '.message' <<<"${json}")" = "failed to add data" + +json=$(sc "iface-list") +echo "${json}" +iface=$(jq -r '.message.ifaces[0]' <<<"${json}") +sleep 1 +json=$(sc "iface-stat ${iface}") +echo "${json}" +test "$(jq -r '.message.pkts > 0' <<<"${json}")" = true + +cp "${TESTDIR}/icmp2.rules" "${OUTDIR}/suricata.rules" +expect_return "reload-rules" + +sc "iface-bypassed-stat" +json=$(sc "capture-mode") +echo "${json}" +test "$(jq -r '.message' <<<"${json}")" = PCAP_DEV +json=$(wait_for_ok "dump-counters") +test "$(jq -r '.message.uptime >= 0' <<<"${json}")" = true +sc "memcap-list" +json=$(sc "running-mode") +echo "${json}" +test "$(jq -r '.message' <<<"${json}")" = "${EXPECTED_RUNMODE}" +sc "version" +json=$(sc "uptime") +echo "${json}" +test "$(jq -r '.message >= 0' <<<"${json}")" = true + +expect_return "add-hostbit ${target} test 60" +ping_once +wait_for_alerts 2 1 +json=$(sc "list-hostbit ${target}") +echo "${json}" +test "$(jq -r '.message.hostbits[0].name' <<<"${json}")" = test +expect_return "remove-hostbit ${target} test" diff --git a/live/tests/pcap-ids-autofp/icmp.rules b/live/tests/pcap-ids-autofp/icmp.rules new file mode 100644 index 0000000000..a938596a81 --- /dev/null +++ b/live/tests/pcap-ids-autofp/icmp.rules @@ -0,0 +1,3 @@ +alert icmp any any -> any any (itype:8; sid:1;) +alert icmp any any -> any any (itype:8; ip.dst; dataset:set,ipv4-list,type ipv4; sid:222;) +alert icmp any any -> any any (itype:8; ip.dst; dataset:set,ipv6-list,type ipv6; sid:226;) diff --git a/live/tests/pcap-ids-autofp/icmp2.rules b/live/tests/pcap-ids-autofp/icmp2.rules new file mode 100644 index 0000000000..a60be4dbd1 --- /dev/null +++ b/live/tests/pcap-ids-autofp/icmp2.rules @@ -0,0 +1 @@ +alert icmp any any -> any any (itype:8; hostbits:isset,test,dst; sid:2;) diff --git a/live/tests/pcap-ids-autofp/include.yaml b/live/tests/pcap-ids-autofp/include.yaml new file mode 100644 index 0000000000..ba4d603fee --- /dev/null +++ b/live/tests/pcap-ids-autofp/include.yaml @@ -0,0 +1,18 @@ +%YAML 1.1 +--- + +default-rule-path: ${OUTDIR} +rule-files: + - suricata.rules + +outputs: + - eve-log: + enabled: yes + filetype: regular + filename: eve.json + types: + - alert + - stats: + totals: yes + threads: no + interval: 1 diff --git a/live/tests/pcap-ids-autofp/test.yaml b/live/tests/pcap-ids-autofp/test.yaml new file mode 100644 index 0000000000..05bc33e304 --- /dev/null +++ b/live/tests/pcap-ids-autofp/test.yaml @@ -0,0 +1,37 @@ +# Port of qa/live/pcap.sh autofp. +environment: tap + +requires: + command: + - jq + - ping + +args: + - --pcap=br0 + - --runmode autofp + - --set pcap.bpf-filter=icmp + +before: | + cp ${TESTDIR}/icmp.rules ${OUTDIR}/suricata.rules + +client: | + EXPECTED_RUNMODE=autofp exec bash ${TESTDIR}/client.sh + +checks: + - stats: + capture.kernel_packets.__gt: 0 + - filter: + count: 2 + match: + event_type: alert + alert.signature_id: 1 + - filter: + count: 2 + match: + event_type: alert + alert.signature_id: 222 + - filter: + count: 1 + match: + event_type: alert + alert.signature_id: 2 diff --git a/live/tests/pcap-ids-single/README.md b/live/tests/pcap-ids-single/README.md new file mode 100644 index 0000000000..d11ba9bc35 --- /dev/null +++ b/live/tests/pcap-ids-single/README.md @@ -0,0 +1,15 @@ +--- +tags: +- legacy-afpdpdk +- legacy-pcap +--- + +# Libpcap IDS single runmode test + +Ports `qa/live/pcap.sh single` from the Suricata repository. It replaces +host-default-gateway traffic with deterministic ICMP traffic on the live +framework's `10.200.0.0/24` IDS bridge. + +The test covers packet capture, IPv4 and IPv6 datasets, malformed dataset +input, rule reload, interface and runmode socket commands, and hostbit +management. diff --git a/live/tests/pcap-ids-single/client.sh b/live/tests/pcap-ids-single/client.sh new file mode 100644 index 0000000000..38797e5afa --- /dev/null +++ b/live/tests/pcap-ids-single/client.sh @@ -0,0 +1,105 @@ +#!/bin/bash +set -euo pipefail + +socket="${OUTDIR}/suricata.socket" +eve="${OUTDIR}/eve.json" +target=10.200.0.1 + +sc() { + "${SURICATASC}" -c "$1" "${socket}" +} + +expect_return() { + local command="$1" + local expected="${2:-OK}" + local json + json=$(sc "${command}") + echo "${json}" + test "$(jq -r '.return' <<<"${json}")" = "${expected}" +} + +wait_for_ok() { + local command="$1" + local json + for _ in $(seq 1 40); do + json=$(sc "${command}") + if [ "$(jq -r '.return' <<<"${json}")" = OK ]; then + echo "${json}" + return 0 + fi + sleep 0.25 + done + echo "${json}" + echo "error: command did not return OK: ${command}" + return 1 +} + +wait_for_alerts() { + local sid="$1" + local expected="$2" + local count=0 + for _ in $(seq 1 40); do + count=$(jq -c "select(.event_type == \"alert\" and .alert.signature_id == ${sid})" \ + "${eve}" 2>/dev/null | wc -l) + if [ "${count}" -ge "${expected}" ]; then + return 0 + fi + sleep 0.25 + done + echo "error: expected at least ${expected} alerts for sid ${sid}, got ${count}" + return 1 +} + +ping_once() { + ip netns exec client0 ping -c 1 -W 1 "${target}" +} + +ping_once +wait_for_alerts 222 1 +expect_return "dataset-clear ipv4-list ipv4" +ping_once +wait_for_alerts 222 2 + +json=$(sc "dataset-add ipv6-list ip 192.168.1.1") +echo "${json}" +test "$(jq -r '.message' <<<"${json}")" = "data added" +json=$(sc "dataset-lookup ipv6-list ip ::ffff:c0a8:0101") +echo "${json}" +test "$(jq -r '.message' <<<"${json}")" = "item found in set" +json=$(sc "dataset-add ipv6-list ip ::ffff:c0a8:0z0z") +echo "${json}" +test "$(jq -r '.message' <<<"${json}")" = "failed to add data" + +json=$(sc "iface-list") +echo "${json}" +iface=$(jq -r '.message.ifaces[0]' <<<"${json}") +sleep 1 +json=$(sc "iface-stat ${iface}") +echo "${json}" +test "$(jq -r '.message.pkts > 0' <<<"${json}")" = true + +cp "${TESTDIR}/icmp2.rules" "${OUTDIR}/suricata.rules" +expect_return "reload-rules" + +sc "iface-bypassed-stat" +json=$(sc "capture-mode") +echo "${json}" +test "$(jq -r '.message' <<<"${json}")" = PCAP_DEV +json=$(wait_for_ok "dump-counters") +test "$(jq -r '.message.uptime >= 0' <<<"${json}")" = true +sc "memcap-list" +json=$(sc "running-mode") +echo "${json}" +test "$(jq -r '.message' <<<"${json}")" = "${EXPECTED_RUNMODE}" +sc "version" +json=$(sc "uptime") +echo "${json}" +test "$(jq -r '.message >= 0' <<<"${json}")" = true + +expect_return "add-hostbit ${target} test 60" +ping_once +wait_for_alerts 2 1 +json=$(sc "list-hostbit ${target}") +echo "${json}" +test "$(jq -r '.message.hostbits[0].name' <<<"${json}")" = test +expect_return "remove-hostbit ${target} test" diff --git a/live/tests/pcap-ids-single/icmp.rules b/live/tests/pcap-ids-single/icmp.rules new file mode 100644 index 0000000000..a938596a81 --- /dev/null +++ b/live/tests/pcap-ids-single/icmp.rules @@ -0,0 +1,3 @@ +alert icmp any any -> any any (itype:8; sid:1;) +alert icmp any any -> any any (itype:8; ip.dst; dataset:set,ipv4-list,type ipv4; sid:222;) +alert icmp any any -> any any (itype:8; ip.dst; dataset:set,ipv6-list,type ipv6; sid:226;) diff --git a/live/tests/pcap-ids-single/icmp2.rules b/live/tests/pcap-ids-single/icmp2.rules new file mode 100644 index 0000000000..a60be4dbd1 --- /dev/null +++ b/live/tests/pcap-ids-single/icmp2.rules @@ -0,0 +1 @@ +alert icmp any any -> any any (itype:8; hostbits:isset,test,dst; sid:2;) diff --git a/live/tests/pcap-ids-single/include.yaml b/live/tests/pcap-ids-single/include.yaml new file mode 100644 index 0000000000..ba4d603fee --- /dev/null +++ b/live/tests/pcap-ids-single/include.yaml @@ -0,0 +1,18 @@ +%YAML 1.1 +--- + +default-rule-path: ${OUTDIR} +rule-files: + - suricata.rules + +outputs: + - eve-log: + enabled: yes + filetype: regular + filename: eve.json + types: + - alert + - stats: + totals: yes + threads: no + interval: 1 diff --git a/live/tests/pcap-ids-single/test.yaml b/live/tests/pcap-ids-single/test.yaml new file mode 100644 index 0000000000..3a50e2ceb8 --- /dev/null +++ b/live/tests/pcap-ids-single/test.yaml @@ -0,0 +1,37 @@ +# Port of qa/live/pcap.sh single. +environment: tap + +requires: + command: + - jq + - ping + +args: + - --pcap=br0 + - --runmode single + - --set pcap.bpf-filter=icmp + +before: | + cp ${TESTDIR}/icmp.rules ${OUTDIR}/suricata.rules + +client: | + EXPECTED_RUNMODE=single exec bash ${TESTDIR}/client.sh + +checks: + - stats: + capture.kernel_packets.__gt: 0 + - filter: + count: 2 + match: + event_type: alert + alert.signature_id: 1 + - filter: + count: 2 + match: + event_type: alert + alert.signature_id: 222 + - filter: + count: 1 + match: + event_type: alert + alert.signature_id: 2 diff --git a/live/tests/pcap-multi-tenant-autofp/README.md b/live/tests/pcap-multi-tenant-autofp/README.md new file mode 100644 index 0000000000..c01162bf5c --- /dev/null +++ b/live/tests/pcap-multi-tenant-autofp/README.md @@ -0,0 +1,12 @@ +--- +tags: +- legacy-afpdpdk +- legacy-pcap +--- + +# Libpcap multi-tenant autofp test + +Ports `qa/live/multi-tenant.sh autofp` from the Suricata repository. It runs +libpcap IDS capture on the framework's bridge while exercising tenant +registration, tenant reload, tenant removal, and the expected failure when +removing an unknown tenant through the command socket. diff --git a/live/tests/pcap-multi-tenant-autofp/client.sh b/live/tests/pcap-multi-tenant-autofp/client.sh new file mode 100644 index 0000000000..6ead190aef --- /dev/null +++ b/live/tests/pcap-multi-tenant-autofp/client.sh @@ -0,0 +1,29 @@ +#!/bin/bash +set -euo pipefail + +socket="${OUTDIR}/suricata.socket" +tenant="${OUTDIR}/tenant-1.yaml" + +expect_return() { + local command="$1" + local expected="${2:-OK}" + local json + json=$(timeout --kill-after=60 30 "${SURICATASC}" -c "${command}" "${socket}") + echo "${json}" + test "$(jq -r '.return' <<<"${json}")" = "${expected}" +} + +ip netns exec client0 ping -c 1 -W 1 10.200.0.1 + +# Match the legacy test's startup delay. The engine-ready message can precede +# multi-tenant management becoming reliable under load. +sleep 15 + +expect_return "register-tenant 2 ${tenant}" +expect_return "reload-tenants" +expect_return "register-tenant 3 ${tenant}" +expect_return "reload-tenants" +expect_return "unregister-tenant 2" +expect_return "unregister-tenant 3" +expect_return "unregister-tenant 5" NOK +expect_return "reload-tenants" diff --git a/live/tests/pcap-multi-tenant-autofp/include.yaml b/live/tests/pcap-multi-tenant-autofp/include.yaml new file mode 100644 index 0000000000..be308c2f2c --- /dev/null +++ b/live/tests/pcap-multi-tenant-autofp/include.yaml @@ -0,0 +1,29 @@ +%YAML 1.1 +--- + +default-rule-path: ${TESTDIR} +rule-files: + - suricata.rules + +multi-detect: + enabled: yes + selector: vlan + loaders: 3 + tenants: + - id: 1 + yaml: ${OUTDIR}/tenant-1.yaml + mappings: + - vlan-id: 1000 + tenant-id: 1 + +outputs: + - eve-log: + enabled: yes + filetype: regular + filename: eve.json + types: + - alert + - stats: + totals: yes + threads: no + interval: 1 diff --git a/live/tests/pcap-multi-tenant-autofp/suricata.rules b/live/tests/pcap-multi-tenant-autofp/suricata.rules new file mode 100644 index 0000000000..c0f94ab545 --- /dev/null +++ b/live/tests/pcap-multi-tenant-autofp/suricata.rules @@ -0,0 +1 @@ +alert icmp any any -> any any (itype:8; sid:1;) diff --git a/live/tests/pcap-multi-tenant-autofp/tenant-1.yaml.in b/live/tests/pcap-multi-tenant-autofp/tenant-1.yaml.in new file mode 100644 index 0000000000..f04c41f98e --- /dev/null +++ b/live/tests/pcap-multi-tenant-autofp/tenant-1.yaml.in @@ -0,0 +1,6 @@ +%YAML 1.1 +--- + +default-rule-path: @RULEDIR@ +rule-files: + - suricata.rules diff --git a/live/tests/pcap-multi-tenant-autofp/test.yaml b/live/tests/pcap-multi-tenant-autofp/test.yaml new file mode 100644 index 0000000000..1741bf421e --- /dev/null +++ b/live/tests/pcap-multi-tenant-autofp/test.yaml @@ -0,0 +1,23 @@ +# Port of qa/live/multi-tenant.sh autofp. +environment: tap + +requires: + command: + - jq + - ping + - timeout + +args: + - --pcap=br0 + - --runmode autofp + - --set pcap.bpf-filter=icmp + +before: | + sed "s|@RULEDIR@|${TESTDIR}|" ${TESTDIR}/tenant-1.yaml.in > ${OUTDIR}/tenant-1.yaml + +client: | + exec bash ${TESTDIR}/client.sh + +checks: + - stats: + capture.kernel_packets.__gt: 0 diff --git a/live/tests/ruletype-firewall-96-lt-response-body-no-match-afp/README.md b/live/tests/ruletype-firewall-96-lt-response-body-no-match-afp/README.md new file mode 100644 index 0000000000..63986ded79 --- /dev/null +++ b/live/tests/ruletype-firewall-96-lt-response-body-no-match-afp/README.md @@ -0,0 +1,13 @@ +# Firewall response body no match + +Port of `ruletype-firewall-96-lt-response-body-no-match` as a live AF_PACKET +firewall test. + +The server returns the classic testmyids.org response body: + +```text +uid=0(root) gid=0(root) groups=0(root) +``` + +The firewall rules only allow response bodies containing `suricata`, so this +response should be blocked. diff --git a/live/tests/ruletype-firewall-96-lt-response-body-no-match-afp/client/Dockerfile b/live/tests/ruletype-firewall-96-lt-response-body-no-match-afp/client/Dockerfile new file mode 100644 index 0000000000..8ef35a59fb --- /dev/null +++ b/live/tests/ruletype-firewall-96-lt-response-body-no-match-afp/client/Dockerfile @@ -0,0 +1,2 @@ +FROM ubuntu:24.04 +RUN apt update && apt install -y curl diff --git a/live/tests/ruletype-firewall-96-lt-response-body-no-match-afp/empty.rules b/live/tests/ruletype-firewall-96-lt-response-body-no-match-afp/empty.rules new file mode 100644 index 0000000000..0832bc245c --- /dev/null +++ b/live/tests/ruletype-firewall-96-lt-response-body-no-match-afp/empty.rules @@ -0,0 +1 @@ +# Empty ruleset for live tests that only use firewall rules. diff --git a/live/tests/ruletype-firewall-96-lt-response-body-no-match-afp/firewall.rules b/live/tests/ruletype-firewall-96-lt-response-body-no-match-afp/firewall.rules new file mode 100644 index 0000000000..064f5b67cf --- /dev/null +++ b/live/tests/ruletype-firewall-96-lt-response-body-no-match-afp/firewall.rules @@ -0,0 +1,8 @@ +accept:packet arp:all any any -> any any (sid:99;) +accept:hook tcp:all any any <> any any (sid:100;) + +# Allow the complete request side. +accept:hook,alert http1: any any (sid:999;) + +# Only allow responses with "suricata" in the response body. +accept:flow,alert http1: any any (http.response_body; content:"suricata"; sid:998;) diff --git a/live/tests/ruletype-firewall-96-lt-response-body-no-match-afp/include.yaml b/live/tests/ruletype-firewall-96-lt-response-body-no-match-afp/include.yaml new file mode 100644 index 0000000000..eca95f6634 --- /dev/null +++ b/live/tests/ruletype-firewall-96-lt-response-body-no-match-afp/include.yaml @@ -0,0 +1,26 @@ +%YAML 1.1 +--- + +default-rule-path: ${TESTDIR} +rule-files: + - empty.rules + +firewall: + enabled: true + rule-path: ${OUTDIR} + rule-files: + - firewall.rules + +af-packet: + - interface: client0 + cluster-id: 80 + copy-mode: ips + copy-iface: server0 + - interface: server0 + cluster-id: 81 + copy-mode: ips + copy-iface: client0 + - interface: default + defrag: false + threads: auto + cluster-type: cluster_flow diff --git a/live/tests/ruletype-firewall-96-lt-response-body-no-match-afp/server/Caddyfile b/live/tests/ruletype-firewall-96-lt-response-body-no-match-afp/server/Caddyfile new file mode 100644 index 0000000000..736ee0cb4a --- /dev/null +++ b/live/tests/ruletype-firewall-96-lt-response-body-no-match-afp/server/Caddyfile @@ -0,0 +1,6 @@ +:80 { + root * /srv + rewrite * /index.txt + header Content-Type text/plain + file_server +} diff --git a/live/tests/ruletype-firewall-96-lt-response-body-no-match-afp/server/Dockerfile b/live/tests/ruletype-firewall-96-lt-response-body-no-match-afp/server/Dockerfile new file mode 100644 index 0000000000..c18f17de69 --- /dev/null +++ b/live/tests/ruletype-firewall-96-lt-response-body-no-match-afp/server/Dockerfile @@ -0,0 +1,5 @@ +FROM ubuntu:24.04 +RUN apt update && apt install -y caddy +COPY Caddyfile /etc/caddy/Caddyfile +COPY index.txt /srv/index.txt +CMD ["caddy", "run", "--config", "/etc/caddy/Caddyfile"] diff --git a/live/tests/ruletype-firewall-96-lt-response-body-no-match-afp/server/index.txt b/live/tests/ruletype-firewall-96-lt-response-body-no-match-afp/server/index.txt new file mode 100644 index 0000000000..0e70fba574 --- /dev/null +++ b/live/tests/ruletype-firewall-96-lt-response-body-no-match-afp/server/index.txt @@ -0,0 +1 @@ +uid=0(root) gid=0(root) groups=0(root) diff --git a/live/tests/ruletype-firewall-96-lt-response-body-no-match-afp/test.yaml b/live/tests/ruletype-firewall-96-lt-response-body-no-match-afp/test.yaml new file mode 100644 index 0000000000..29792c6994 --- /dev/null +++ b/live/tests/ruletype-firewall-96-lt-response-body-no-match-afp/test.yaml @@ -0,0 +1,43 @@ +environment: inline + +requires: + min-version: 9 + command: + - podman + +args: + - --af-packet + +before: | + cp ${TESTDIR}/firewall.rules ${OUTDIR}/firewall.rules + podman build --iidfile=${OUTDIR}/server.iid --network=host server + podman build --iidfile=${OUTDIR}/client.iid --network=host client + +server: | + podman run --rm \ + --stop-timeout=0 \ + --network ns:/var/run/netns/server0 \ + $(cat ${OUTDIR}/server.iid) + +client: | + cd ${OUTDIR} + + if podman run --rm --network ns:/var/run/netns/client0 \ + -v ${OUTDIR}:/out:rw \ + $(cat ${OUTDIR}/client.iid) \ + timeout --kill-after=1 --preserve-status 2 \ + curl -sS --retry-connrefused --retry 20 --retry-delay 0 \ + --output /out/response.txt http://10.200.0.1/; then + echo "error: curl should not have completed successfully" + exit 1 + fi + +checks: + - filter: + count: 0 + match: + alert.signature_id: 998 + + - stats: + app_layer.tx.http: 1 + firewall.drop_reason.default_app_policy: 1 diff --git a/live/tests/ruletype-firewall-96-lt-response-body-no-match-nfq/README.md b/live/tests/ruletype-firewall-96-lt-response-body-no-match-nfq/README.md new file mode 100644 index 0000000000..4709d93d2a --- /dev/null +++ b/live/tests/ruletype-firewall-96-lt-response-body-no-match-nfq/README.md @@ -0,0 +1,13 @@ +# Firewall response body no match + +Port of `ruletype-firewall-96-lt-response-body-no-match` as a live NFQ +firewall test. + +The server returns the classic testmyids.org response body: + +```text +uid=0(root) gid=0(root) groups=0(root) +``` + +The firewall rules only allow response bodies containing `suricata`, so this +response should be blocked. diff --git a/live/tests/ruletype-firewall-96-lt-response-body-no-match-nfq/client/Dockerfile b/live/tests/ruletype-firewall-96-lt-response-body-no-match-nfq/client/Dockerfile new file mode 100644 index 0000000000..8ef35a59fb --- /dev/null +++ b/live/tests/ruletype-firewall-96-lt-response-body-no-match-nfq/client/Dockerfile @@ -0,0 +1,2 @@ +FROM ubuntu:24.04 +RUN apt update && apt install -y curl diff --git a/live/tests/ruletype-firewall-96-lt-response-body-no-match-nfq/empty.rules b/live/tests/ruletype-firewall-96-lt-response-body-no-match-nfq/empty.rules new file mode 100644 index 0000000000..0832bc245c --- /dev/null +++ b/live/tests/ruletype-firewall-96-lt-response-body-no-match-nfq/empty.rules @@ -0,0 +1 @@ +# Empty ruleset for live tests that only use firewall rules. diff --git a/live/tests/ruletype-firewall-96-lt-response-body-no-match-nfq/firewall.rules b/live/tests/ruletype-firewall-96-lt-response-body-no-match-nfq/firewall.rules new file mode 100644 index 0000000000..064f5b67cf --- /dev/null +++ b/live/tests/ruletype-firewall-96-lt-response-body-no-match-nfq/firewall.rules @@ -0,0 +1,8 @@ +accept:packet arp:all any any -> any any (sid:99;) +accept:hook tcp:all any any <> any any (sid:100;) + +# Allow the complete request side. +accept:hook,alert http1: any any (sid:999;) + +# Only allow responses with "suricata" in the response body. +accept:flow,alert http1: any any (http.response_body; content:"suricata"; sid:998;) diff --git a/live/tests/ruletype-firewall-96-lt-response-body-no-match-nfq/include.yaml b/live/tests/ruletype-firewall-96-lt-response-body-no-match-nfq/include.yaml new file mode 100644 index 0000000000..90c3c9e48a --- /dev/null +++ b/live/tests/ruletype-firewall-96-lt-response-body-no-match-nfq/include.yaml @@ -0,0 +1,12 @@ +%YAML 1.1 +--- + +default-rule-path: ${TESTDIR} +rule-files: + - empty.rules + +firewall: + enabled: true + rule-path: ${OUTDIR} + rule-files: + - firewall.rules diff --git a/live/tests/ruletype-firewall-96-lt-response-body-no-match-nfq/server/Caddyfile b/live/tests/ruletype-firewall-96-lt-response-body-no-match-nfq/server/Caddyfile new file mode 100644 index 0000000000..736ee0cb4a --- /dev/null +++ b/live/tests/ruletype-firewall-96-lt-response-body-no-match-nfq/server/Caddyfile @@ -0,0 +1,6 @@ +:80 { + root * /srv + rewrite * /index.txt + header Content-Type text/plain + file_server +} diff --git a/live/tests/ruletype-firewall-96-lt-response-body-no-match-nfq/server/Dockerfile b/live/tests/ruletype-firewall-96-lt-response-body-no-match-nfq/server/Dockerfile new file mode 100644 index 0000000000..c18f17de69 --- /dev/null +++ b/live/tests/ruletype-firewall-96-lt-response-body-no-match-nfq/server/Dockerfile @@ -0,0 +1,5 @@ +FROM ubuntu:24.04 +RUN apt update && apt install -y caddy +COPY Caddyfile /etc/caddy/Caddyfile +COPY index.txt /srv/index.txt +CMD ["caddy", "run", "--config", "/etc/caddy/Caddyfile"] diff --git a/live/tests/ruletype-firewall-96-lt-response-body-no-match-nfq/server/index.txt b/live/tests/ruletype-firewall-96-lt-response-body-no-match-nfq/server/index.txt new file mode 100644 index 0000000000..0e70fba574 --- /dev/null +++ b/live/tests/ruletype-firewall-96-lt-response-body-no-match-nfq/server/index.txt @@ -0,0 +1 @@ +uid=0(root) gid=0(root) groups=0(root) diff --git a/live/tests/ruletype-firewall-96-lt-response-body-no-match-nfq/test.yaml b/live/tests/ruletype-firewall-96-lt-response-body-no-match-nfq/test.yaml new file mode 100644 index 0000000000..3925d0cf5e --- /dev/null +++ b/live/tests/ruletype-firewall-96-lt-response-body-no-match-nfq/test.yaml @@ -0,0 +1,43 @@ +environment: nfq + +requires: + min-version: 9 + command: + - podman + +args: + - -q 0 + +before: | + cp ${TESTDIR}/firewall.rules ${OUTDIR}/firewall.rules + podman build --iidfile=${OUTDIR}/server.iid --network=host server + podman build --iidfile=${OUTDIR}/client.iid --network=host client + +server: | + podman run --rm \ + --stop-timeout=0 \ + --network ns:/var/run/netns/server0 \ + $(cat ${OUTDIR}/server.iid) + +client: | + cd ${OUTDIR} + + if podman run --rm --network ns:/var/run/netns/client0 \ + -v ${OUTDIR}:/out:rw \ + $(cat ${OUTDIR}/client.iid) \ + timeout --kill-after=1 --preserve-status 2 \ + curl -sS --retry-connrefused --retry 20 --retry-delay 0 \ + --output /out/response.txt http://10.200.0.1/; then + echo "error: curl should not have completed successfully" + exit 1 + fi + +checks: + - filter: + count: 0 + match: + alert.signature_id: 998 + + - stats: + app_layer.tx.http: 1 + firewall.drop_reason.default_app_policy: 1 diff --git a/live/tests/simple-http-afp/README.md b/live/tests/simple-http-afp/README.md new file mode 100644 index 0000000000..9701e5e9d4 --- /dev/null +++ b/live/tests/simple-http-afp/README.md @@ -0,0 +1,6 @@ +--- +tags: +- http +--- + +A very simple AF_PACKET IPS inline test. diff --git a/live/tests/simple-http-afp/client/Dockerfile b/live/tests/simple-http-afp/client/Dockerfile new file mode 100644 index 0000000000..802c97527f --- /dev/null +++ b/live/tests/simple-http-afp/client/Dockerfile @@ -0,0 +1,3 @@ +FROM ubuntu:24.04 +RUN apt update && \ + apt install -y curl diff --git a/live/tests/simple-http-afp/include.yaml b/live/tests/simple-http-afp/include.yaml new file mode 100644 index 0000000000..1a652a6d7c --- /dev/null +++ b/live/tests/simple-http-afp/include.yaml @@ -0,0 +1,20 @@ +%YAML 1.1 +--- + +default-rule-path: ${TESTDIR} +rule-files: + - test.rules + +af-packet: + - interface: client0 + cluster-id: 80 + copy-mode: ips + copy-iface: server0 + - interface: server0 + cluster-id: 81 + copy-mode: ips + copy-iface: client0 + - interface: default + defrag: false + threads: auto + cluster-type: cluster_flow diff --git a/live/tests/simple-http-afp/server/Caddyfile b/live/tests/simple-http-afp/server/Caddyfile new file mode 100644 index 0000000000..736ee0cb4a --- /dev/null +++ b/live/tests/simple-http-afp/server/Caddyfile @@ -0,0 +1,6 @@ +:80 { + root * /srv + rewrite * /index.txt + header Content-Type text/plain + file_server +} diff --git a/live/tests/simple-http-afp/server/Dockerfile b/live/tests/simple-http-afp/server/Dockerfile new file mode 100644 index 0000000000..135a1e94b9 --- /dev/null +++ b/live/tests/simple-http-afp/server/Dockerfile @@ -0,0 +1,5 @@ +FROM ubuntu:24.04 +RUN apt update && apt install -y caddy +COPY Caddyfile /etc/caddy/Caddyfile +COPY index.txt /srv/index.txt +CMD ["caddy", "run", "--config", "/etc/caddy/Caddyfile"] \ No newline at end of file diff --git a/live/tests/simple-http-afp/server/index.txt b/live/tests/simple-http-afp/server/index.txt new file mode 100644 index 0000000000..0e70fba574 --- /dev/null +++ b/live/tests/simple-http-afp/server/index.txt @@ -0,0 +1 @@ +uid=0(root) gid=0(root) groups=0(root) diff --git a/live/tests/simple-http-afp/test.rules b/live/tests/simple-http-afp/test.rules new file mode 100644 index 0000000000..6e2b6a93bd --- /dev/null +++ b/live/tests/simple-http-afp/test.rules @@ -0,0 +1 @@ +alert ip any any -> any any (msg:"GPL ATTACK_RESPONSE id check returned root"; content:"uid=0|28|root|29|"; classtype:bad-unknown; sid:2100498; rev:7; metadata:created_at 2010_09_23, confidence Medium, signature_severity Informational, updated_at 2019_07_26;) diff --git a/live/tests/simple-http-afp/test.yaml b/live/tests/simple-http-afp/test.yaml new file mode 100644 index 0000000000..e7153a3431 --- /dev/null +++ b/live/tests/simple-http-afp/test.yaml @@ -0,0 +1,35 @@ +# This test runs in AF_PACKET IPS mode. +environment: inline + +requires: + command: + - podman + +args: + - --af-packet + +before: | + set -x + podman build -q --network=host server > ${OUTDIR}/server.iid + podman build -q --network=host client > ${OUTDIR}/client.iid + +server: | + set -x + iid=$(cat ${OUTDIR}/server.iid) + podman run --rm \ + --stop-timeout=0 \ + --network ns:/var/run/netns/server0 \ + ${iid} + +client: | + set -x + iid=$(cat ${OUTDIR}/client.iid) + podman run --rm --network ns:/var/run/netns/client0 ${iid} curl --fail-with-body http://10.200.0.1 + +checks: + - filter: + count: 1 + match: + alert.signature_id: 2100498 + - stats: + app_layer.tx.http: 1 diff --git a/live/tests/simple-http-ids/README.md b/live/tests/simple-http-ids/README.md new file mode 100644 index 0000000000..664246da07 --- /dev/null +++ b/live/tests/simple-http-ids/README.md @@ -0,0 +1,6 @@ +--- +tags: +- http +--- + +A very simple IDS test. diff --git a/live/tests/simple-http-ids/client/Dockerfile b/live/tests/simple-http-ids/client/Dockerfile new file mode 100644 index 0000000000..802c97527f --- /dev/null +++ b/live/tests/simple-http-ids/client/Dockerfile @@ -0,0 +1,3 @@ +FROM ubuntu:24.04 +RUN apt update && \ + apt install -y curl diff --git a/live/tests/simple-http-ids/include.yaml b/live/tests/simple-http-ids/include.yaml new file mode 100644 index 0000000000..37e2cffa5d --- /dev/null +++ b/live/tests/simple-http-ids/include.yaml @@ -0,0 +1,6 @@ +%YAML 1.1 +--- + +default-rule-path: ${TESTDIR} +rule-files: + - test.rules diff --git a/live/tests/simple-http-ids/server/Caddyfile b/live/tests/simple-http-ids/server/Caddyfile new file mode 100644 index 0000000000..736ee0cb4a --- /dev/null +++ b/live/tests/simple-http-ids/server/Caddyfile @@ -0,0 +1,6 @@ +:80 { + root * /srv + rewrite * /index.txt + header Content-Type text/plain + file_server +} diff --git a/live/tests/simple-http-ids/server/Dockerfile b/live/tests/simple-http-ids/server/Dockerfile new file mode 100644 index 0000000000..135a1e94b9 --- /dev/null +++ b/live/tests/simple-http-ids/server/Dockerfile @@ -0,0 +1,5 @@ +FROM ubuntu:24.04 +RUN apt update && apt install -y caddy +COPY Caddyfile /etc/caddy/Caddyfile +COPY index.txt /srv/index.txt +CMD ["caddy", "run", "--config", "/etc/caddy/Caddyfile"] \ No newline at end of file diff --git a/live/tests/simple-http-ids/server/index.txt b/live/tests/simple-http-ids/server/index.txt new file mode 100644 index 0000000000..0e70fba574 --- /dev/null +++ b/live/tests/simple-http-ids/server/index.txt @@ -0,0 +1 @@ +uid=0(root) gid=0(root) groups=0(root) diff --git a/live/tests/simple-http-ids/test.rules b/live/tests/simple-http-ids/test.rules new file mode 100644 index 0000000000..6e2b6a93bd --- /dev/null +++ b/live/tests/simple-http-ids/test.rules @@ -0,0 +1 @@ +alert ip any any -> any any (msg:"GPL ATTACK_RESPONSE id check returned root"; content:"uid=0|28|root|29|"; classtype:bad-unknown; sid:2100498; rev:7; metadata:created_at 2010_09_23, confidence Medium, signature_severity Informational, updated_at 2019_07_26;) diff --git a/live/tests/simple-http-ids/test.yaml b/live/tests/simple-http-ids/test.yaml new file mode 100644 index 0000000000..3d2cdb473e --- /dev/null +++ b/live/tests/simple-http-ids/test.yaml @@ -0,0 +1,35 @@ +# This test runs in pcap IDS mode. +environment: tap + +requires: + command: + - podman + +args: + - --pcap=br0 + +before: | + set -x + podman build -q --network=host server > ${OUTDIR}/server.iid + podman build -q --network=host client > ${OUTDIR}/client.iid + +server: | + set -x + iid=$(cat ${OUTDIR}/server.iid) + podman run --rm \ + --stop-timeout=0 \ + --network ns:/var/run/netns/server0 \ + ${iid} + +client: | + set -x + iid=$(cat ${OUTDIR}/client.iid) + podman run --rm --network ns:/var/run/netns/client0 ${iid} curl --fail-with-body http://10.200.0.1 + +checks: + - filter: + count: 1 + match: + alert.signature_id: 2100498 + - stats: + app_layer.tx.http: 1 diff --git a/live/tests/simple-http-nfq/README.md b/live/tests/simple-http-nfq/README.md new file mode 100644 index 0000000000..ab3073593a --- /dev/null +++ b/live/tests/simple-http-nfq/README.md @@ -0,0 +1,6 @@ +--- +tags: +- http +--- + +A very simple NFQ IPS inline test. diff --git a/live/tests/simple-http-nfq/client/Dockerfile b/live/tests/simple-http-nfq/client/Dockerfile new file mode 100644 index 0000000000..802c97527f --- /dev/null +++ b/live/tests/simple-http-nfq/client/Dockerfile @@ -0,0 +1,3 @@ +FROM ubuntu:24.04 +RUN apt update && \ + apt install -y curl diff --git a/live/tests/simple-http-nfq/include.yaml b/live/tests/simple-http-nfq/include.yaml new file mode 100644 index 0000000000..37e2cffa5d --- /dev/null +++ b/live/tests/simple-http-nfq/include.yaml @@ -0,0 +1,6 @@ +%YAML 1.1 +--- + +default-rule-path: ${TESTDIR} +rule-files: + - test.rules diff --git a/live/tests/simple-http-nfq/server/Caddyfile b/live/tests/simple-http-nfq/server/Caddyfile new file mode 100644 index 0000000000..736ee0cb4a --- /dev/null +++ b/live/tests/simple-http-nfq/server/Caddyfile @@ -0,0 +1,6 @@ +:80 { + root * /srv + rewrite * /index.txt + header Content-Type text/plain + file_server +} diff --git a/live/tests/simple-http-nfq/server/Dockerfile b/live/tests/simple-http-nfq/server/Dockerfile new file mode 100644 index 0000000000..135a1e94b9 --- /dev/null +++ b/live/tests/simple-http-nfq/server/Dockerfile @@ -0,0 +1,5 @@ +FROM ubuntu:24.04 +RUN apt update && apt install -y caddy +COPY Caddyfile /etc/caddy/Caddyfile +COPY index.txt /srv/index.txt +CMD ["caddy", "run", "--config", "/etc/caddy/Caddyfile"] \ No newline at end of file diff --git a/live/tests/simple-http-nfq/server/index.txt b/live/tests/simple-http-nfq/server/index.txt new file mode 100644 index 0000000000..0e70fba574 --- /dev/null +++ b/live/tests/simple-http-nfq/server/index.txt @@ -0,0 +1 @@ +uid=0(root) gid=0(root) groups=0(root) diff --git a/live/tests/simple-http-nfq/test.rules b/live/tests/simple-http-nfq/test.rules new file mode 100644 index 0000000000..6e2b6a93bd --- /dev/null +++ b/live/tests/simple-http-nfq/test.rules @@ -0,0 +1 @@ +alert ip any any -> any any (msg:"GPL ATTACK_RESPONSE id check returned root"; content:"uid=0|28|root|29|"; classtype:bad-unknown; sid:2100498; rev:7; metadata:created_at 2010_09_23, confidence Medium, signature_severity Informational, updated_at 2019_07_26;) diff --git a/live/tests/simple-http-nfq/test.yaml b/live/tests/simple-http-nfq/test.yaml new file mode 100644 index 0000000000..e633bbe4d7 --- /dev/null +++ b/live/tests/simple-http-nfq/test.yaml @@ -0,0 +1,35 @@ +# This test runs in NFQ mode. +environment: nfq + +requires: + command: + - podman + +args: + - -q 0 + +before: | + set -x + podman build -q --network=host server > ${OUTDIR}/server.iid + podman build -q --network=host client > ${OUTDIR}/client.iid + +server: | + set -x + iid=$(cat ${OUTDIR}/server.iid) + podman run --rm \ + --stop-timeout=0 \ + --network ns:/var/run/netns/server0 \ + ${iid} + +client: | + set -x + iid=$(cat ${OUTDIR}/client.iid) + podman run --rm --network ns:/var/run/netns/client0 ${iid} curl --fail-with-body http://10.200.0.1 + +checks: + - filter: + count: 1 + match: + alert.signature_id: 2100498 + - stats: + app_layer.tx.http: 1 diff --git a/run.py b/run.py index 8ec812088b..f951547818 100755 --- a/run.py +++ b/run.py @@ -25,6 +25,7 @@ from __future__ import print_function import sys + import os import os.path import subprocess @@ -35,18 +36,26 @@ import yaml import glob import re -import json import unittest from concurrent.futures import ThreadPoolExecutor from collections import namedtuple import threading -import filecmp import subprocess import yaml import traceback -import platform import signal +from lib.common import ( + FileCompareCheck, + FilterCheck, + ImpossibleRequirementError, + ShellCheck, + StatsCheck, + UnsatisfiedRequirementError, + check_requires as check_common_requires, + compare_values, +) + VALIDATE_EVE = False WIN32 = sys.platform == "win32" suricata_yaml = "suricata.yaml" if WIN32 else "./suricata.yaml" @@ -69,13 +78,6 @@ count_dict['skipped'] = 0 check_args['fail'] = 0 -COMPARISON_OPERATORS = { - "__gt": ">", - "__gte": ">=", - "__lt": "<", - "__lte": "<=", -} - # Global flag for shutdown signal shutdown_requested = False executor_instance = None @@ -140,25 +142,21 @@ def test_numeric_comparison_operators(self): self.assertFalse(compare_values("2", 1, "__gt")) def test_filter_numeric_comparison(self): - check = FilterCheck.__new__(FilterCheck) - check.config = { - "match": { - "flow.age.__gte": 2, - "flow.bytes.__lt": 100, - } - } + check = FilterCheck( + { + "match": { + "flow.age.__gte": 2, + "flow.bytes.__lt": 100, + } + }, + ".", + ) self.assertTrue(check.match({"flow": {"age": 2, "bytes": 99}})) self.assertFalse(check.match({"flow": {"age": 1, "bytes": 99}})) class TestError(Exception): pass -class UnsatisfiedRequirementError(Exception): - pass - -class ImpossibleRequirementError(Exception): - pass - class UnnecessaryRequirementError(Exception): pass @@ -355,153 +353,20 @@ def check_filter_test_version_compat(requires, test_version): raise UnnecessaryRequirementError( "test already requires min {} not needed for the check {}".format(test_version["min"], requires["min-version"])) -def check_requires(requires, suricata_config: SuricataConfig, test_dir=None): - suri_version = suricata_config.version - for key in requires: - if key == "min-version": - min_version = requires["min-version"] - if not is_version_compatible(version=min_version, - suri_version=suri_version, expr="gte"): - raise UnsatisfiedRequirementError( - "requires at least version {}".format(min_version)) - elif key == "lt-version": - if "gt-version" in requires: - if not Version().is_lt(parse_suricata_version(requires["gt-version"]), parse_suricata_version(requires["lt-version"])): - raise ImpossibleRequirementError( - "test has both lt-version {} and gt-version {}".format(requires["lt-version"], requires["gt-version"])) - lt_version = requires["lt-version"] - if not is_version_compatible(version=lt_version, - suri_version=suri_version, expr="lt"): - raise UnsatisfiedRequirementError( - "for version less than {}".format(lt_version)) - elif key == "gt-version": - if "lt-version" in requires: - if not Version().is_lt(parse_suricata_version(requires["gt-version"]), parse_suricata_version(requires["lt-version"])): - raise ImpossibleRequirementError( - "test has both lt-version {} and gt-version {}".format(requires["lt-version"], requires["gt-version"])) - gt_version = requires["gt-version"] - if not is_version_compatible(version=gt_version, - suri_version=suri_version, expr="gt"): - raise UnsatisfiedRequirementError( - "for version great than {}".format(gt_version)) - elif key == "version": - req_version = requires["version"] - if not is_version_compatible(version=req_version, - suri_version=suri_version, expr="equal"): - raise UnsatisfiedRequirementError( - "only for version {}".format(req_version)) - elif key == "features": - for feature in requires["features"]: - if not suricata_config.has_feature(feature): - raise UnsatisfiedRequirementError( - "requires feature %s" % (feature)) - elif key == "env": - for env in requires["env"]: - if not env in os.environ: - raise UnsatisfiedRequirementError( - "requires env var %s" % (env)) - elif key == "files": - for filename in requires["files"]: - if test_dir and not os.path.isabs(filename): - filename = os.path.join(test_dir, filename) - if not os.path.exists(filename): - raise UnsatisfiedRequirementError( - "requires file %s" % (filename)) - elif key == "script": - # This is run for the current directory (the Suricata - # source directory). - for script in requires["script"]: - try: - subprocess.check_call("%s" % script, shell=True) - except: - raise UnsatisfiedRequirementError( - "requires script returned false") - elif key == "pcap": - # A valid requires argument, but not verified here. - pass - elif key == "lambda": - if not eval(requires["lambda"]): - raise UnsatisfiedRequirementError(requires["lambda"]) - elif key == "os": - cur_platform = platform.system().lower() - if not cur_platform.startswith(requires["os"].lower()): - raise UnsatisfiedRequirementError(requires["os"]) - elif key == "arch": - cur_arch = platform.machine().lower() - if not cur_arch.startswith(requires["arch"].lower()): - raise UnsatisfiedRequirementError(requires["arch"]) - else: - raise Exception("unknown requires types: %s" % (key)) - - -def find_value(name, obj): - """Find the value in an object for a field specified by name. - - Example names: - event_type - alert.signature_id - smtp.rcpt_to[0] - """ - parts = name.split(".") - for part in parts: - - if part == "__len": - # Get the length of the object. Return -1 if the object is - # not a type that has a length (numbers). - try: - return len(obj) - except: - return -1 - if part in [ - "__contains", "__find", "__startswith", "__endswith", - *COMPARISON_OPERATORS.keys()]: - # Return full object, caller will handle the special match logic. - break - name = None - index = None - m = re.match(r"^(.*)\[(\d+)\]$", part) - if m: - name = m.group(1) - index = m.group(2) - else: - name = part - - if not name in obj: - return None - obj = obj[name] - - if index is not None: - try: - obj = obj[int(index)] - except: - return None - - return obj - - -def get_comparison_operator(key): - """Return the comparison operator suffix from a check key, if present.""" - suffix = key.rsplit(".", 1)[-1] - if suffix in COMPARISON_OPERATORS: - return suffix - return None - - -def compare_values(actual, expected, operator): - """Compare two numeric values using a comparison operator suffix.""" - if isinstance(actual, bool) or isinstance(expected, bool): - return False - if not isinstance(actual, (int, float)) or not isinstance(expected, (int, float)): - return False - if operator == "__gt": - return actual > expected - if operator == "__gte": - return actual >= expected - if operator == "__lt": - return actual < expected - if operator == "__lte": - return actual <= expected - raise ValueError("unknown comparison operator: {}".format(operator)) +def check_requires(requires, suricata_config: SuricataConfig, suri_dir=None): + check_common_requires( + requires, + suricata_config, + is_version_compatible, + suri_dir, + version_is_lt=lambda left, right: Version().is_lt( + parse_suricata_version(left), parse_suricata_version(right) + ), + eval_globals=globals(), + unknown_error=Exception, + unknown_message="unknown requires types: {key}", + gt_message="for version great than {version}", + ) def is_version_compatible(version, suri_version, expr): @@ -519,174 +384,14 @@ def rule_is_version_compatible(rulefile, suri_version): # default is true return True -class FileCompareCheck: - - def __init__(self, config, directory, cwd): - for key in config: - if key not in ["requires", "filename", "expected"]: - raise Exception("Unexpected key in file-compare check: {}".format(key)) - self.config = config - self.directory = directory - self.cwd = cwd - - def run(self): - if WIN32: - raise UnsatisfiedRequirementError("shell check not supported on Windows") - expected = os.path.join(self.directory, self.config["expected"]) - filename = self.config["filename"] - if self.cwd and not os.path.isabs(filename): - filename = os.path.join(self.cwd, filename) - try: - if filecmp.cmp(expected, filename): - return True - else: - raise TestError("%s %s \nFAILED: verification failed" % (expected, filename)) - except Exception as err: - raise TestError("file-compare check failed with exception: %s" % (err)) - -class ShellCheck: - - def __init__(self, config, env, suricata_config, output_dir, test_dir): - for key in config: - if key not in ["requires", "args", "expect"]: - raise Exception("Unexpected key in shell check: {}".format(key)) - self.config = config - self.env = env - self.suricata_config = suricata_config - self.cwd = output_dir - self.script_cwd = test_dir - - def run(self): - if not self.config or "args" not in self.config: - raise TestError("shell check missing args") - requires = self.config.get("requires", {}) - check_requires(requires, self.suricata_config, self.script_cwd) - - try: - if WIN32: - raise UnsatisfiedRequirementError("shell check not supported on Windows") - output = subprocess.check_output(self.config["args"], shell=True, env=self.env, cwd=self.cwd) - if "expect" in self.config: - return str(self.config["expect"]) == output.decode().strip() - return True - except subprocess.CalledProcessError as err: - raise TestError("Shell command failed: {} -> {}".format( - self.config, err.output)) - -class StatsCheck: - - def __init__(self, config, outdir): - self.config = config - self.outdir = outdir - - def run(self): - stats = None - eve_json_path = os.path.join(self.outdir, "eve.json") - with open(eve_json_path, "r") as fileobj: - for line in fileobj: - event = json.loads(line) - if event["event_type"] == "stats": - stats = event["stats"] - for key in self.config: - expected = self.config[key] - val = find_value(key, stats) - operator = get_comparison_operator(key) - if operator is not None: - if not compare_values(val, expected, operator): - raise TestError("stats.%s: expected %s %s; got %s" % ( - key, COMPARISON_OPERATORS[operator], str(expected), str(val))) - elif val != expected: - raise TestError("stats.%s: expected %s; got %s" % ( - key, str(expected), str(val))) - return True +def check_result_or_raise(result): + if result.failures: + raise TestError("\n".join(result.failures)) + return True -class FilterCheck: - def __init__(self, config, outdir, suricata_config, test_version, script_cwd=None): - for key in config: - if key not in ["count", "match", "filename", "requires"]: - raise Exception("Unexpected key in filter check: {}".format(key)) - self.config = config - self.outdir = outdir - self.suricata_config = suricata_config - self.suri_version = suricata_config.version - self.test_version = test_version - self.script_cwd = script_cwd - - def run(self): - requires = self.config.get("requires", {}) - check_filter_test_version_compat(requires, self.test_version) - check_requires(requires, self.suricata_config, self.script_cwd) - - if "filename" in self.config: - json_filename = self.config["filename"] - if not os.path.isabs(json_filename): - json_filename = os.path.join(self.outdir, json_filename) - else: - json_filename = os.path.join(self.outdir, "eve.json") - if not os.path.exists(json_filename): - raise TestError("%s does not exist" % (json_filename)) - - count = 0 - with open(json_filename, "r", encoding="utf-8") as fileobj: - for line in fileobj: - event = json.loads(line) - if self.match(event): - count += 1 - if count == self.config["count"]: - return True - if "comment" in self.config: - raise TestError("%s: expected %d, got %d" % ( - self.config["comment"], self.config["count"], count)) - raise TestError("expected %d matches; got %d for filter %s" % ( - self.config["count"], count, str(self.config))) - - def match(self, event): - for key, expected in self.config["match"].items(): - if key == "has-key": - if isinstance(expected, list): - for item in expected: - val = find_value(item, event) - if val is None: - return False - else: - val = find_value(expected, event) - if val is None: - return False - elif key == "not-has-key": - if isinstance(expected, list): - for item in expected: - val = find_value(item, event) - if val is not None: - return False - else: - val = find_value(expected, event) - if val is not None: - return False - else: - val = find_value(key, event) - if key.endswith("__find"): - if val.find(expected) < 0: - return False - elif key.endswith("__contains"): - if not expected in val: - return False - elif key.endswith("__startswith"): - if not val.startswith(expected): - return False - elif key.endswith("__endswith"): - if not val.endswith(expected): - return False - else: - operator = get_comparison_operator(key) - if operator is not None: - if not compare_values(val, expected, operator): - return False - elif val != expected: - if str(val) == str(expected): - print("Different types but same string", type(val), val, type(expected), expected) - return False - return True +def print_type_mismatch(val, expected): + print("Different types but same string", type(val), val, type(expected), expected) # wait for suricata to be ready, to send unix-socket commands def grep_start_engine(p, lines): @@ -797,7 +502,7 @@ def check_skip(self): def check_requires(self): requires = self.config.get("requires", {}) - check_requires(requires, self.suricata_config, self.directory) + check_requires(requires, self.suricata_config, self.cwd) for key in requires: if key == "min-version": self.version["min"] = requires["min-version"] @@ -1014,24 +719,45 @@ def pre_check(self): @handle_exceptions def perform_filter_checks(self, check, count, test_num, test_name): - count = FilterCheck(check, self.output, - self.suricata_config, self.version, self.directory).run() - return count + result = FilterCheck( + check, + self.output, + suricata_config=self.suricata_config, + test_dir=self.directory, + require_checker=check_requires, + test_version=self.version, + version_compat_checker=check_filter_test_version_compat, + type_mismatch_callback=print_type_mismatch, + ).run() + return check_result_or_raise(result) @handle_exceptions def perform_shell_checks(self, check, count, test_num, test_name): - count = ShellCheck(check, self.build_env(), self.suricata_config, self.output, self.directory).run() - return count + result = ShellCheck( + check, + self.build_env(), + self.output, + suricata_config=self.suricata_config, + test_dir=self.directory, + require_checker=check_requires, + windows=WIN32, + ).run() + return check_result_or_raise(result) @handle_exceptions def perform_stats_checks(self, check, count, test_num, test_name): - count = StatsCheck(check, self.output).run() - return count + result = StatsCheck(check, self.output).run() + return check_result_or_raise(result) @handle_exceptions def perform_file_compare_checks(self, check, count, test_num, test_name): - count = FileCompareCheck(check, self.directory, self.output).run() - return count + result = FileCompareCheck( + check, + self.directory, + self.output, + windows=WIN32, + ).run() + return check_result_or_raise(result) def reset_count(self, dictionary): for k in dictionary.keys(): @@ -1192,19 +918,20 @@ def check_deps(): try: cmd = "jq --version > nil" if WIN32 else "jq --version > /dev/null 2>&1" subprocess.check_call(cmd, shell=True) - except: + except Exception: print("error: jq is required") return False try: cmd = "echo suricata | xargs > nil" if WIN32 else "echo | xargs > /dev/null 2>&1" subprocess.check_call(cmd, shell=True) - except: + except Exception: print("error: xargs is required") return False return True + def run_test(dirpath, args, cwd, suricata_config): with lock: if check_args['fail'] == 1 or shutdown_requested: