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:
-
Rule ordering.
check_rules()andcheck_rules_with_stealth()both walk the rule list in file order and return the first match. Inai_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.jsonrepeats the identical pattern at the identical indices. -
No hostname ever reaches the matcher.
handle_packet()buildspacket_info['dst_ip']fromip_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 innetfilter.pyorstealth_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: