firegex 4.0.6__tar.gz → 5.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. firegex-5.0.0/MANIFEST.in +4 -0
  2. firegex-5.0.0/PKG-INFO +69 -0
  3. firegex-5.0.0/README.md +49 -0
  4. {firegex-4.0.6 → firegex-5.0.0}/firegex/__init__.py +1 -1
  5. firegex-5.0.0/firegex/cli.py +219 -0
  6. firegex-5.0.0/firegex/pyfilters/__init__.py +58 -0
  7. firegex-5.0.0/firegex/pyfilters/internals/__init__.py +317 -0
  8. firegex-5.0.0/firegex/pyfilters/internals/data.py +226 -0
  9. {firegex-4.0.6/firegex/nfproxy → firegex-5.0.0/firegex/pyfilters}/internals/models.py +11 -5
  10. firegex-5.0.0/firegex/pyfilters/internals/zstd_compat.py +66 -0
  11. {firegex-4.0.6/firegex/nfproxy → firegex-5.0.0/firegex/pyfilters}/models/__init__.py +23 -10
  12. firegex-5.0.0/firegex/pyfilters/models/http.py +1387 -0
  13. {firegex-4.0.6/firegex/nfproxy → firegex-5.0.0/firegex/pyfilters}/models/tcp.py +4 -4
  14. {firegex-4.0.6/firegex/nfproxy → firegex-5.0.0/firegex/pyfilters}/proxysim/__init__.py +30 -38
  15. firegex-5.0.0/firegex/regex/__init__.py +342 -0
  16. firegex-5.0.0/firegex/regex/sim.py +104 -0
  17. firegex-5.0.0/firegex.egg-info/PKG-INFO +69 -0
  18. firegex-5.0.0/firegex.egg-info/SOURCES.txt +33 -0
  19. firegex-5.0.0/firegex.egg-info/entry_points.txt +2 -0
  20. {firegex-4.0.6 → firegex-5.0.0}/firegex.egg-info/requires.txt +5 -3
  21. firegex-5.0.0/llhttp/LICENSE.pyllhttp +21 -0
  22. firegex-5.0.0/llhttp/PROVENANCE.md +68 -0
  23. firegex-5.0.0/llhttp/lib/LICENSE.llhttp +22 -0
  24. firegex-5.0.0/llhttp/lib/api.c +517 -0
  25. firegex-5.0.0/llhttp/lib/http.c +170 -0
  26. firegex-5.0.0/llhttp/lib/llhttp.c +10279 -0
  27. firegex-5.0.0/llhttp/lib/llhttp.h +925 -0
  28. firegex-5.0.0/llhttp/llhttp_module.c +658 -0
  29. firegex-5.0.0/pyproject.toml +42 -0
  30. firegex-5.0.0/setup.py +34 -0
  31. firegex-4.0.6/MANIFEST.in +0 -1
  32. firegex-4.0.6/PKG-INFO +0 -57
  33. firegex-4.0.6/README.md +0 -26
  34. firegex-4.0.6/fgex +0 -6
  35. firegex-4.0.6/firegex/cli.py +0 -76
  36. firegex-4.0.6/firegex/nfproxy/__init__.py +0 -50
  37. firegex-4.0.6/firegex/nfproxy/internals/__init__.py +0 -173
  38. firegex-4.0.6/firegex/nfproxy/internals/data.py +0 -166
  39. firegex-4.0.6/firegex/nfproxy/models/http.py +0 -860
  40. firegex-4.0.6/firegex.egg-info/PKG-INFO +0 -57
  41. firegex-4.0.6/firegex.egg-info/SOURCES.txt +0 -22
  42. firegex-4.0.6/requirements.txt +0 -9
  43. firegex-4.0.6/setup.py +0 -31
  44. {firegex-4.0.6 → firegex-5.0.0}/firegex/__main__.py +0 -0
  45. {firegex-4.0.6/firegex/nfproxy → firegex-5.0.0/firegex/pyfilters}/internals/exceptions.py +0 -0
  46. {firegex-4.0.6 → firegex-5.0.0}/firegex.egg-info/dependency_links.txt +0 -0
  47. {firegex-4.0.6 → firegex-5.0.0}/firegex.egg-info/top_level.txt +0 -0
  48. {firegex-4.0.6 → firegex-5.0.0}/setup.cfg +0 -0
@@ -0,0 +1,4 @@
1
+ recursive-include llhttp *.c *.h
2
+ include llhttp/PROVENANCE.md
3
+ include llhttp/LICENSE.pyllhttp
4
+ include llhttp/lib/LICENSE.llhttp
firegex-5.0.0/PKG-INFO ADDED
@@ -0,0 +1,69 @@
1
+ Metadata-Version: 2.4
2
+ Name: firegex
3
+ Version: 5.0.0
4
+ Summary: Firegex client
5
+ Author-email: Pwnzer0tt1 <pwnzer0tt1@poliba.it>
6
+ Project-URL: Homepage, https://github.com/pwnzer0tt1/firegex
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)
9
+ Requires-Python: >=3.10
10
+ Description-Content-Type: text/markdown
11
+ Requires-Dist: typer
12
+ Requires-Dist: rich
13
+ Requires-Dist: pydantic>=2
14
+ Requires-Dist: typing-extensions>=4.7.1
15
+ Requires-Dist: pycryptodome
16
+ Requires-Dist: watchfiles
17
+ Requires-Dist: websockets
18
+ Requires-Dist: brotli>=1.2.0
19
+ Requires-Dist: zstandard; python_version < "3.14"
20
+
21
+ # Firegex Python Library and CLI
22
+
23
+ This is the Python library for [Firegex](https://github.com/Pwnzer0tt1/firegex), a firewall built for CTF Attack-Defense competitions. It provides `firegex.pyfilters` — the package you write [Python filters](../docs/pyfilter.md) against — `firegex.regex` for pattern rulesets, the `fgex` CLI, and local simulators for both, so a filter can be tried before it ever reaches a running Firegex.
24
+
25
+ ## Installation
26
+
27
+ ```bash
28
+ pip install -U firegex
29
+ ```
30
+
31
+ `fgex` is an alias package for `firegex`: installing either one gives you the same `firegex` module and the `fgex`/`firegex` CLI commands.
32
+
33
+ ## Documentation
34
+
35
+ It carries `firegex.regex`, which binds the same hyperscan firegex matches with, so
36
+ a **pattern ruleset** can be checked and tried without a running instance:
37
+
38
+ ```bash
39
+ fgex regex check rules.json # does it compile? what is in it?
40
+ fgex regex test rules.json --sample req.txt # what would it do to this?
41
+ fgex regex proxy rules.json 10.60.3.1 8080 # a local proxy applying it
42
+ ```
43
+
44
+ The ruleset file is the same shape firegex uses. Matching needs `libhs` (vectorscan)
45
+ installed; without it the tools say so and stop rather than answering with a different
46
+ engine, because a tester that disagrees with production is worse than none.
47
+
48
+ **➡️ [Writing and testing `@pyfilter` scripts](../docs/pyfilter.md)** — the `pyfilter` decorator, every data structure (`RawPacket`, `TCPInputStream`/`TCPOutputStream`, `HttpRequest`/`HttpResponse` and their variants, `HttpHistory`), packet statements, stream limits, the `fgex pyfilters` CLI simulator, and a full worked example.
49
+
50
+ That page is also what's shown in the Firegex web UI itself (the docs button on the service pages) — both are generated from that single file, so they can never drift out of sync.
51
+
52
+ ## The rest of Firegex
53
+
54
+ This package is the part you write filters *with*. What runs them is a Firegex instance,
55
+ started from a clone with `python3 run.py` — there is deliberately no deploy command here,
56
+ because any second description of that script's arguments would drift from it.
57
+
58
+ Two guides cover everything an instance does:
59
+
60
+ - **[Services](../docs/services.md)** — a service is a **network layer** (NFQUEUE, the
61
+ terminating proxy, or a hand-off to a proxy you wrote) plus an ordered chain of
62
+ **filters** (hyperscan patterns, or the Python you write with this library). TLS, QUIC
63
+ and every version of HTTP are protocols a service can speak rather than separate things
64
+ to set up.
65
+ - **[Firewall Rules](../docs/firewall.md)** — plain nftables allow/drop/reject rules.
66
+
67
+ The modules this library used to list — `nfregex`, `nfproxy`, `porthijack` and `tls` —
68
+ were folded into services as choices rather than modules in 5.0.0, which is also when
69
+ `firegex.nfproxy` became `firegex.pyfilters`.
@@ -0,0 +1,49 @@
1
+ # Firegex Python Library and CLI
2
+
3
+ This is the Python library for [Firegex](https://github.com/Pwnzer0tt1/firegex), a firewall built for CTF Attack-Defense competitions. It provides `firegex.pyfilters` — the package you write [Python filters](../docs/pyfilter.md) against — `firegex.regex` for pattern rulesets, the `fgex` CLI, and local simulators for both, so a filter can be tried before it ever reaches a running Firegex.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install -U firegex
9
+ ```
10
+
11
+ `fgex` is an alias package for `firegex`: installing either one gives you the same `firegex` module and the `fgex`/`firegex` CLI commands.
12
+
13
+ ## Documentation
14
+
15
+ It carries `firegex.regex`, which binds the same hyperscan firegex matches with, so
16
+ a **pattern ruleset** can be checked and tried without a running instance:
17
+
18
+ ```bash
19
+ fgex regex check rules.json # does it compile? what is in it?
20
+ fgex regex test rules.json --sample req.txt # what would it do to this?
21
+ fgex regex proxy rules.json 10.60.3.1 8080 # a local proxy applying it
22
+ ```
23
+
24
+ The ruleset file is the same shape firegex uses. Matching needs `libhs` (vectorscan)
25
+ installed; without it the tools say so and stop rather than answering with a different
26
+ engine, because a tester that disagrees with production is worse than none.
27
+
28
+ **➡️ [Writing and testing `@pyfilter` scripts](../docs/pyfilter.md)** — the `pyfilter` decorator, every data structure (`RawPacket`, `TCPInputStream`/`TCPOutputStream`, `HttpRequest`/`HttpResponse` and their variants, `HttpHistory`), packet statements, stream limits, the `fgex pyfilters` CLI simulator, and a full worked example.
29
+
30
+ That page is also what's shown in the Firegex web UI itself (the docs button on the service pages) — both are generated from that single file, so they can never drift out of sync.
31
+
32
+ ## The rest of Firegex
33
+
34
+ This package is the part you write filters *with*. What runs them is a Firegex instance,
35
+ started from a clone with `python3 run.py` — there is deliberately no deploy command here,
36
+ because any second description of that script's arguments would drift from it.
37
+
38
+ Two guides cover everything an instance does:
39
+
40
+ - **[Services](../docs/services.md)** — a service is a **network layer** (NFQUEUE, the
41
+ terminating proxy, or a hand-off to a proxy you wrote) plus an ordered chain of
42
+ **filters** (hyperscan patterns, or the Python you write with this library). TLS, QUIC
43
+ and every version of HTTP are protocols a service can speak rather than separate things
44
+ to set up.
45
+ - **[Firewall Rules](../docs/firewall.md)** — plain nftables allow/drop/reject rules.
46
+
47
+ The modules this library used to list — `nfregex`, `nfproxy`, `porthijack` and `tls` —
48
+ were folded into services as choices rather than modules in 5.0.0, which is also when
49
+ `firegex.nfproxy` became `firegex.pyfilters`.
@@ -1,6 +1,6 @@
1
1
 
2
2
  def _get_version():
3
- v = "4.0.6" if "{" not in "4.0.6" else "0.0.0"
3
+ v = "5.0.0" if "{" not in "5.0.0" else "0.0.0"
4
4
  if v == "0.0.0":
5
5
  import os
6
6
  env_v = os.getenv("FIREGEX_VERSION")
@@ -0,0 +1,219 @@
1
+
2
+ #!/usr/bin/env python3
3
+
4
+ import json
5
+ import os
6
+ import socket
7
+ import sys
8
+
9
+ import typer
10
+ from rich import print
11
+ from rich.markup import escape
12
+ from typer import Exit
13
+
14
+ from firegex import __version__
15
+ from firegex.pyfilters.proxysim import run_proxy_simulation
16
+
17
+ app = typer.Typer(
18
+ no_args_is_help=True,
19
+ context_settings={"help_option_names": ["-h", "--help"]}
20
+ )
21
+
22
+ def close_cli(code:int=1):
23
+ raise Exit(code)
24
+
25
+ DEV_MODE = __version__ == "0.0.0"
26
+
27
+ def test_connection(host, port, use_ipv6=False):
28
+ family = socket.AF_INET6 if use_ipv6 else socket.AF_INET
29
+ sock = socket.socket(family, socket.SOCK_STREAM)
30
+
31
+ try:
32
+ sock.settimeout(3)
33
+ sock.connect((host, port))
34
+ return True
35
+ except Exception:
36
+ return False
37
+ finally:
38
+ sock.close()
39
+
40
+ @app.command("pyfilters", help="Run your Python filters against a real service, locally")
41
+ def pyfilters(
42
+ filter_file: str = typer.Argument(..., help="The path to the filter file"),
43
+ address: str = typer.Argument(..., help="The address of the target to proxy"),
44
+ port: int = typer.Argument(..., help="The port of the target to proxy"),
45
+
46
+ from_address: str = typer.Option(None, help="The address of the local server"),
47
+ from_port: int = typer.Option(7474, help="The port of the local server"),
48
+ ipv6: bool = typer.Option(False, "-6", help="Use IPv6 for the connection"),
49
+ ):
50
+ if from_address is None:
51
+ from_address = "::1" if ipv6 else "127.0.0.1"
52
+ if not os.path.isfile(filter_file):
53
+ print(f"[bold red]'{escape(os.path.abspath(filter_file))}' not found[/]")
54
+ close_cli()
55
+ if not test_connection(address, port, ipv6):
56
+ print(f"[bold red]Can't connect to {escape(address)}:{port}[/]")
57
+ close_cli()
58
+ # No protocol to pass: the filter file says which one it speaks by what it asks for.
59
+ run_proxy_simulation(filter_file, address, port, from_address, from_port, ipv6)
60
+
61
+ regex_app = typer.Typer(
62
+ no_args_is_help=True,
63
+ help="Try a regex ruleset with the engine firegex matches with",
64
+ )
65
+ app.add_typer(regex_app, name="regex")
66
+
67
+
68
+ def _load_ruleset(path: str):
69
+ """Read a ruleset, with the errors an operator can act on.
70
+
71
+ The file is the same shape firegex itself uses, so a ruleset can be written here and
72
+ pasted there, or exported from there and replayed here.
73
+ """
74
+ from firegex.regex import HyperscanMissing, Rule, available, validate
75
+
76
+ if not available():
77
+ print(f"[bold red]{escape(str(HyperscanMissing()))}[/]")
78
+ close_cli()
79
+ if not os.path.isfile(path):
80
+ print(f"[bold red]'{escape(os.path.abspath(path))}' not found[/]")
81
+ close_cli()
82
+ try:
83
+ with open(path) as f:
84
+ raw = json.load(f)
85
+ except json.JSONDecodeError as e:
86
+ print(f"[bold red]{escape(path)} is not valid JSON: {escape(str(e))}[/]")
87
+ close_cli()
88
+ if not isinstance(raw, list):
89
+ print("[bold red]A ruleset is a list of rules[/]")
90
+ close_cli()
91
+
92
+ rules = []
93
+ broken = False
94
+ for index, item in enumerate(raw):
95
+ try:
96
+ rule = Rule.from_dict(item)
97
+ except (KeyError, ValueError) as e:
98
+ print(f"[bold red]rule {index} is malformed: {escape(str(e))}[/]")
99
+ broken = True
100
+ continue
101
+ # Judged against the mode it would actually run in, so what is accepted here is
102
+ # what firegex will accept.
103
+ why = validate(rule)
104
+ if why:
105
+ print(f"[bold red]{escape(rule.id)}: {escape(why)}[/]")
106
+ broken = True
107
+ continue
108
+ rules.append(rule)
109
+ if broken:
110
+ close_cli()
111
+ if not rules:
112
+ print("[bold red]The ruleset is empty[/]")
113
+ close_cli()
114
+ return rules
115
+
116
+
117
+ @regex_app.command("check", help="Check that a ruleset compiles, and say what is in it")
118
+ def regex_check(
119
+ rules_file: str = typer.Argument(..., help="The path to the ruleset (JSON)"),
120
+ ):
121
+ rules = _load_ruleset(rules_file)
122
+ print(f"[bold green]{len(rules)} rule(s), all valid[/]")
123
+ for rule in rules:
124
+ case = "" if rule.case_sensitive else ", any case"
125
+ print(f" [bold]{escape(rule.id)}[/] /{escape(rule.pattern)}/ "
126
+ f"[dim]{rule.direction.value}{case}, block[/]")
127
+
128
+
129
+ @regex_app.command("test", help="Run a ruleset over a sample and show what it would do")
130
+ def regex_test(
131
+ rules_file: str = typer.Argument(..., help="The path to the ruleset (JSON)"),
132
+ sample_file: str = typer.Option(None, "--sample", "-s",
133
+ help="File to match against; stdin when omitted"),
134
+ from_service: bool = typer.Option(False, "--from-service",
135
+ help="Treat the sample as traffic from the service"),
136
+ ):
137
+ from firegex.regex import Ruleset
138
+
139
+ rules = _load_ruleset(rules_file)
140
+ if sample_file:
141
+ if not os.path.isfile(sample_file):
142
+ print(f"[bold red]'{escape(os.path.abspath(sample_file))}' not found[/]")
143
+ close_cli()
144
+ with open(sample_file, "rb") as f:
145
+ sample = f.read()
146
+ else:
147
+ sample = sys.stdin.buffer.read()
148
+ if not sample:
149
+ print("[bold red]Nothing to match against[/]")
150
+ close_cli()
151
+
152
+ ruleset = Ruleset(rules)
153
+ is_input = not from_service
154
+ blocked_by = ruleset.apply(sample, is_input)
155
+
156
+ # Said before the verdict, because it changes what the verdict means: "passed
157
+ # through" from a run that could not try every rule is not the same answer.
158
+ for rule_id, why in ruleset.unscannable.items():
159
+ print(f"[bold yellow]not tried here[/] [bold]{escape(rule_id)}[/]: {escape(why)}. "
160
+ f"It is valid and will run on a real service — only matching it against a "
161
+ f"sample needs a mode hyperscan does not offer for it.")
162
+
163
+ if blocked_by is not None:
164
+ print(f"[bold red]blocked[/] by [bold]{escape(blocked_by)}[/]")
165
+ close_cli(0)
166
+ print("[bold green]passed through[/]"
167
+ + (" by the rules that could be tried" if ruleset.unscannable else ""))
168
+
169
+
170
+ @regex_app.command("proxy", help="Run a local proxy applying a ruleset, like `fgex pyfilters`")
171
+ def regex_proxy(
172
+ rules_file: str = typer.Argument(..., help="The path to the ruleset (JSON)"),
173
+ address: str = typer.Argument(..., help="The address of the target to proxy"),
174
+ port: int = typer.Argument(..., help="The port of the target to proxy"),
175
+ from_address: str = typer.Option(None, help="The address of the local server"),
176
+ from_port: int = typer.Option(7474, help="The port of the local server"),
177
+ ipv6: bool = typer.Option(False, "-6", help="Use IPv6 for the connection"),
178
+ ):
179
+ from firegex.regex import Ruleset
180
+ from firegex.regex.sim import run_regex_simulation
181
+
182
+ rules = _load_ruleset(rules_file)
183
+ if from_address is None:
184
+ from_address = "::1" if ipv6 else "127.0.0.1"
185
+ if not test_connection(address, port, ipv6):
186
+ print(f"[bold red]Can't connect to {escape(address)}:{port}[/]")
187
+ close_cli()
188
+ print("[dim]Blocking matches per chunk here, but across the whole stream on a real "
189
+ "service: a pattern split over two reads is caught there and not here.[/]")
190
+ ruleset = Ruleset(rules)
191
+ # Built before the first connection rather than on it, so the warning is on screen
192
+ # while the operator is still reading the banner instead of appearing mid-traffic.
193
+ for is_input in (True, False):
194
+ ruleset._db(is_input)
195
+ for rule_id, why in ruleset.unscannable.items():
196
+ print(f"[bold yellow]not applied here[/] [bold]{escape(rule_id)}[/]: {escape(why)}. "
197
+ f"It is valid and will run on a real service.")
198
+ run_regex_simulation(ruleset, address, port, from_address, from_port, ipv6)
199
+
200
+
201
+ def version_callback(verison: bool):
202
+ if verison:
203
+ print(__version__, "Development Mode" if DEV_MODE else "Release")
204
+ raise typer.Exit()
205
+
206
+ @app.callback()
207
+ def main(
208
+ verison: bool = typer.Option(False, "--version", "-v", help="Show the version of the client", callback=version_callback),
209
+ ):
210
+ pass
211
+
212
+ def run():
213
+ try:
214
+ app()
215
+ except KeyboardInterrupt:
216
+ print("[bold yellow]Operation cancelled[/]")
217
+
218
+ if __name__ == "__main__":
219
+ run()
@@ -0,0 +1,58 @@
1
+ from firegex.pyfilters.models import (
2
+ RawPacket,
3
+ TCPInputStream,
4
+ TCPOutputStream,
5
+ TCPClientStream,
6
+ TCPServerStream,
7
+ HttpHistory,
8
+ HttpStreamHistory,
9
+ )
10
+ from firegex.pyfilters.internals.models import Action, FullStreamAction, ExceptionAction
11
+
12
+ ACCEPT = Action.ACCEPT
13
+ DROP = Action.DROP
14
+ REJECT = Action.REJECT
15
+
16
+ #: Set on a function by `@pyfilter`. Marking the function *is* the registration — there
17
+ #: is no registry to keep, to clear between files, or to race two compilations against.
18
+ PYFILTER_MARK = "__firegex_pyfilter__"
19
+
20
+
21
+ def pyfilter(func):
22
+ """Mark a function as a filter, and hand it back unchanged.
23
+
24
+ The mark lives on the function, so the file that defines it holds the whole list:
25
+ `collect_pyfilters` reads a module's globals in definition order. What that replaced
26
+ was a set of *names* on a module-global attribute of this decorator, which had three
27
+ consequences worth not having back — a set has no order, so the order filters ran in
28
+ was whatever hashing produced; the state outlived the file, so every caller had to
29
+ clear it before and after; and a name registered from somewhere the module globals
30
+ could not reach (a nested `def`) was registered and then not found.
31
+
32
+ The function is returned as it is, not wrapped. The wrapper it used to get forwarded
33
+ `*args` and did nothing else, at the cost of a stack frame in every traceback the
34
+ operator has to read.
35
+ """
36
+ setattr(func, PYFILTER_MARK, True)
37
+ return func
38
+
39
+
40
+ def collect_pyfilters(namespace: dict) -> list[str]:
41
+ """The filters a module defines, in the order it defines them.
42
+
43
+ Definition order is the order they run in, which is the only order a reader of the
44
+ file can predict.
45
+ """
46
+ seen, names = set(), []
47
+ for name, obj in namespace.items():
48
+ if callable(obj) and getattr(obj, PYFILTER_MARK, False) and id(obj) not in seen:
49
+ seen.add(id(obj))
50
+ names.append(name)
51
+ return names
52
+
53
+ __all__ = [
54
+ "ACCEPT", "DROP", "REJECT",
55
+ "Action", "FullStreamAction", "ExceptionAction", "pyfilter", "collect_pyfilters",
56
+ "RawPacket", "TCPInputStream", "TCPOutputStream", "TCPClientStream", "TCPServerStream",
57
+ "HttpHistory", "HttpStreamHistory"
58
+ ]