Engagement Report: Stealth Mode Never Loaded Its Own Rules

A full re-verification of Flea Flicker NetFilter's rule counts plus a one-line constructor bug that zeroed out every stealth-mode rule file at every stealth level, found and fixed, alongside a deeper rule-ordering flaw in the live engine that's documented but not safely fixable tonight.

Another in the series of redacted-format engagement reports, this one on Flea Flicker NetFilter, a Python IDS-evasion and packet-manipulation toolkit built for authorized pentest work against commercial IDS/IPS and AI-vendor telemetry collection. An earlier pass on this repo had already config-level verified the rule counts and flagged that the demo script doesn't exercise the tool's actual headline feature, calling that a minor polish gap. Running the real engine instead of just reading the config files turned up something considerably bigger.

Scope and Objective

Target: netfilter.py (the PentestNetFilter engine), stealth_mode.py (StealthMode), demo.py, and the nine JSON rule files that drive them, principally ai_stealth_rules.json and advanced_stealth_rules.json.

Objective: re-verify the rule-count and AI-telemetry-block claims against the actual files rather than trusting the prior note, then determine whether the tool's headline AI-evasion feature can be made to produce a real DENY/block result without root or a Linux netfilter stack, by installing the real dependencies in a disposable virtualenv and calling the actual matching code directly.

Findings

Finding 1 (High, fixed): stealth mode never loaded any rule file, at any stealth level

PentestNetFilter.__init__() initializes stealth mode with:

self.stealth_mode = StealthMode(stealth_level)

stealth_level is one of the strings "basic", "advanced", or "maximum", the exact values the CLI's --stealth-mode flag accepts. StealthMode.__init__(self, config_file: str = "advanced_stealth_rules.json") expects a file path there, not a level name. Passing "advanced" as a path means _load_config() calls open("advanced", "r"), which raises FileNotFoundError on every single invocation, caught by the method's own except FileNotFoundError, which silently returns _default_config(). That default config has no "rules" key at all, so get_stealth_rules() returns [] forever.

The result: every destination and process block rule in ai_stealth_rules.json (54 rules) and advanced_stealth_rules.json (100 rules), the files that hold the entire AI-vendor and security-tool blocklist this tool is named for, was dead code on the stealth-mode path, for every stealth level, every time it was ever run that way. A helper method, _get_stealth_config_file(), already existed in the same class to correctly map stealth level to filename. It was never called anywhere.

Confirmed directly against the unmodified engine before the fix:

>>> nf = PentestNetFilter(config_file='ai_stealth_rules.json', stealth=True, stealth_level='advanced')
>>> len(nf.stealth_mode.get_stealth_rules())
0

Finding 2 (High, structural, documented not fixed): destination-based blocks can't fire against real traffic regardless of rule loading

Even with Finding 1 fixed and the rule files loading correctly, a second, independent problem defeats the AI-telemetry blocks specifically. Two things compound:

  1. Rule ordering. check_rules() and check_rules_with_stealth() both walk the rule list in file order and return the first match. In ai_stealth_rules.json, rule index 3 is {"action": "allow", "port": 443, "description": "HTTPS - encrypted traffic appears normal"}, with no destination field, so it matches any packet on port 443. All 33 AI-vendor destination blocks (OpenAI, Anthropic, CrowdStrike, SentinelOne, and the rest) sit at indices 4 through 36, below it. Since HTTPS is how virtually every one of those services is actually reached, the generic allow rule wins first every time, before the specific blocks are ever consulted. advanced_stealth_rules.json repeats the identical pattern at the identical indices.

  2. No hostname ever reaches the matcher. handle_packet() builds packet_info['dst_ip'] from ip_layer.dst, scapy's raw IP-layer destination address. There is no DNS query inspection, no TLS SNI extraction, and no reverse-lookup cache anywhere in netfilter.py or stealth_mode.py. A rule like "*.openai.com" is a domain-suffix match written against a field that is structurally always a dotted-quad IP address in the live code path. Even if ordering were fixed, the field it's matching against would never contain a hostname.

To isolate these two causes from each other and from the matching code itself, I ran three cases directly against the real rule_matches()/check_rules() methods, no mocking of the matching logic itself:

Case 1: nmap scan on an uncommon port (no earlier rule collides)
  Packet: {'process_name': 'nmap', 'dst_ip': '45.33.32.156', 'dst_port': 9999, 'protocol': 'TCP'}
  Result: BLOCK - Block obvious scanning tools in AI stealth mode

Case 2: HTTPS request to an OpenAI-owned IP (realistic packet)
  Packet: {'process_name': 'curl', 'dst_ip': '104.18.7.12', 'dst_port': 443, 'protocol': 'TCP'}
  Result: ALLOW - HTTPS - encrypted traffic appears normal

Case 3: same destination, synthetic hostname-as-dst_ip, non-443 port
  Packet: {'process_name': 'curl', 'dst_ip': 'api.openai.com', 'dst_port': 8443, 'protocol': 'TCP'}
  Result: BLOCK - Block OpenAI API telemetry

Case 1 shows the matcher genuinely can and does block, when nothing earlier in the list collides. Case 2 is the realistic scenario and shows the actual failure. Case 3 is a synthetic unit-style check, a hostname can never really appear in dst_ip, used only to confirm the destination-matching code itself is written correctly. The actual fix belongs in rule evaluation order and in adding a hostname-resolution step (SNI parsing on the TLS ClientHello, or a DNS-response cache keyed by resolved IP), not in the comparison logic. That's new capability, not a bug fix, so it's documented here rather than attempted tonight.

Finding 3 (Low, fixed): the demo script used OR logic where the real engine uses AND, and never ran the actual headline rule file

demo.py's own simplified rule_matches() returned True if any single field on a rule matched the packet, process name alone, or port alone, or destination alone. The real engine's rule_matches() in netfilter.py requires every field present on a rule to match (AND). The two were never equivalent, meaning the demo could have produced a visibly wrong result for any rule combining two or more fields even before Finding 2's ordering problem. Separately, the demo's load_rules() defaulted to default_rules.json, a 21-rule generic file with no AI-vendor entries, so it never ran against ai_stealth_rules.json at all, the file that is actually the subject of the README's AI Detection Evasion section.

Verification

Rule counts, re-verified fresh, not carried over from the prior note:

ai_stealth_rules.json       -> 54 rules  (41 action=block, incl. 33 AI/security-vendor destination blocks)
advanced_stealth_rules.json -> 100 rules
default_rules.json          -> 21 rules

The 54-rule figure for ai_stealth_rules.json and the presence of genuine AI-vendor destination entries (*.openai.com, *.anthropic.com, *.crowdstrike.com, *.sentinelone.com, and 29 others) both check out.

Dependencies psutil and scapy aren't installed system-wide and the repo requires root plus Linux netfilter for live enforcement, neither available here on macOS. Rather than stop at reading the source, I installed both into a disposable virtualenv (not touching the system Python) and imported netfilter.py directly, then called its real, unmodified methods against crafted packet dictionaries. That's a real execution of the real matching code, not a reimplementation of it, just without an actual kernel-level packet capture behind it.

Stealth-mode rule loading, before the Finding 1 fix, all three levels:

stealth_level=basic:    0 rules loaded
stealth_level=advanced: 0 rules loaded
stealth_level=maximum:  0 rules loaded

After the fix, same three levels, same unmodified call site, only the constructor argument changed:

stealth_level=basic:    54 rules loaded (ai_stealth_rules.json)
stealth_level=advanced: 100 rules loaded (advanced_stealth_rules.json)
stealth_level=maximum:  100 rules loaded (advanced_stealth_rules.json)

demo.py --demo, after the Finding 3 fix, run fresh:

Case 1: nmap scan on an uncommon port (no earlier rule collides)
  Result: BLOCK - Block obvious scanning tools in AI stealth mode

Case 2: HTTPS request to an OpenAI-owned IP (realistic packet)
  Result: ALLOW - HTTPS - encrypted traffic appears normal

Case 3: same destination, synthetic hostname-as-dst_ip, non-443 port
  Result: BLOCK - Block OpenAI API telemetry

python3 -m py_compile on both modified files passed clean after each change.

Mitigations and Next Steps

  1. Shipped tonight: netfilter.py now calls self._get_stealth_config_file() instead of passing the raw stealth-level string to StealthMode(). Stealth mode loads its actual rule file at every level, verified by rule count before and after.
  2. Shipped tonight: demo.py's rule_matches() now mirrors the real engine's AND semantics, and a new demo_ai_evasion_feature() function runs the real ai_stealth_rules.json headline file with three cases that show a genuine block, the realistic-packet failure, and an isolated check of the matcher logic, instead of only exercising default_rules.json with packets that never collide with anything.
  3. Not shipped, needs real design work: rule evaluation should weight specificity (a rule combining destination plus port should be considered before a bare port rule) rather than first-match-in-file-order, and the live capture path needs an actual hostname source, TLS SNI extraction off the ClientHello or a DNS-response cache keyed by the resolved IP, before any destination-based rule can mean anything against real traffic. Until one of those lands, the AI-vendor blocklist is accurate as a list of domains worth blocking and non-functional as a live filter.

GhostInThePrompt.com // A rule file with the right domains in it still does nothing if the code that was supposed to load it never ran.