integrated and refactore docs + minor changes

This commit is contained in:
Domingo Dirutigliano 2026-07-23 16:53:09 +02:00
parent f06c389161
commit bd55a7bc9c
No known key found for this signature in database
26 changed files with 920 additions and 1120 deletions

View File

@ -84,4 +84,7 @@ One Socket.IO event, `"update"`, whose payload is a list of strings treated as a
`pages/<Module>/` (routes) + `components/<Module>/` (ServiceRow, AddEditService, `utils.ts` with query key + API wrapper) per module, mirroring the backend split. Detail drill-down lives at `pages/<Module>/ServiceDetails.tsx` under a nested `:srv` route (see `App.tsx`'s `PageRouting`). Theme tokens are CSS variables in `src/index.css`; Mantine `Card` defaults are set globally in `index.tsx`. Prefer Mantine `Group`/`Stack` with `gap` over the legacy `Box className="center-flex"` utility classes still present in older parts of the codebase. `pages/<Module>/` (routes) + `components/<Module>/` (ServiceRow, AddEditService, `utils.ts` with query key + API wrapper) per module, mirroring the backend split. Detail drill-down lives at `pages/<Module>/ServiceDetails.tsx` under a nested `:srv` route (see `App.tsx`'s `PageRouting`). Theme tokens are CSS variables in `src/index.css`; Mantine `Card` defaults are set globally in `index.tsx`. Prefer Mantine `Group`/`Stack` with `gap` over the legacy `Box className="center-flex"` utility classes still present in older parts of the codebase.
### `fgex-lib/` ### `fgex-lib/`
Separate, independently-published PyPI package (`pip install fgex`/`firegex`) — the library users write nfproxy filters against (`@pyfilter`, `RawPacket`/`HttpRequest`/etc.), plus the `fgex` CLI and a local `proxysim` for testing filters without a real firegex instance. Not part of the backend/frontend build. Separate, independently-published PyPI package (`pip install fgex`/`firegex`) — the library users write nfproxy filters against (`@pyfilter`, `RawPacket`/`HttpRequest`/etc.), plus the `fgex` CLI and a local `proxysim` for testing filters without a real firegex instance. Not part of the backend/frontend build. `fgex-lib/README.md` is a short index (PyPI-facing) linking out to `docs/*.md`; it isn't rendered anywhere itself.
### Module documentation (`docs/*.md`)
Each module's user-facing docs (`docs/nfproxy.md`, `docs/nfregex.md`, `docs/porthijack.md`, `docs/firewall.md`, `docs/tls.md`) are single markdown sources, each imported directly with Vite's `?raw` suffix by a matching `<Module>Docs.tsx` component (e.g. `components/NFProxy/NFProxyDocs.tsx`) and rendered through the shared `components/MarkdownDocs.tsx` (React Markdown + `remark-gfm`, code fences routed through `CodeHighlight`, relative `.md` cross-links rewritten to the equivalent GitHub blob URL since they'd otherwise resolve against the app's own origin). `DocsButton.tsx`'s `DocType` enum wires each module to its component. Editing a module's docs means editing its one `docs/*.md` file — never the `*Docs.tsx` component directly. Reading these files across the `frontend/`↔repo-root boundary requires `vite.config.ts`'s `server.fs.allow: ['..']` (dev) and the `Dockerfile` frontend build stage's `COPY ./docs/*.md /docs/` (prod/standalone rootfs) to stay in sync with any path changes.

View File

@ -10,6 +10,7 @@ ADD ./frontend/package.json .
ADD ./frontend/bun.lock . ADD ./frontend/bun.lock .
RUN bun i RUN bun i
COPY ./frontend/ . COPY ./frontend/ .
COPY ./docs/*.md /docs/
RUN bun run build RUN bun run build
# Base fedora container # Base fedora container

View File

@ -43,40 +43,46 @@ class IPFilterMiddleware:
self.app = app self.app = app
async def __call__(self, scope, receive, send): async def __call__(self, scope, receive, send):
if scope["type"] in ["http", "websocket"]: if scope["type"] in ["http", "websocket"] and ALLOWED_NETWORKS:
if ALLOWED_NETWORKS: client_ip = None
client_ip = None if PROXY_IP_HEADER:
if PROXY_IP_HEADER: headers = dict(scope.get("headers", []))
headers = dict(scope.get("headers", [])) header_val = headers.get(PROXY_IP_HEADER.lower().encode())
header_val = headers.get(PROXY_IP_HEADER.lower().encode()) if header_val:
if header_val: client_ip = header_val.decode().split(",")[0].strip()
client_ip = header_val.decode().split(",")[0].strip()
if not client_ip and scope.get("client"): if not client_ip and scope.get("client"):
client_ip = scope["client"][0] client_ip = scope["client"][0]
if client_ip: # Fail closed: an allowlist must deny whenever a client IP can't be
try: # positively determined and matched (missing IP, unparseable IP, or an
ip_obj = ip_address(client_ip) # IP that just isn't in the list all get denied the same way) - a
if not any(ip_obj in net for net in ALLOWED_NETWORKS): # malformed/missing value must never be treated as an implicit pass.
if scope["type"] == "http": allowed = False
await send({ if client_ip:
"type": "http.response.start", try:
"status": 403, ip_obj = ip_address(client_ip)
"headers": [(b"content-type", b"text/plain")], allowed = any(ip_obj in net for net in ALLOWED_NETWORKS)
}) except ValueError:
await send({ allowed = False
"type": "http.response.body",
"body": b"Forbidden", if not allowed:
}) if scope["type"] == "http":
elif scope["type"] == "websocket": await send({
await send({ "type": "http.response.start",
"type": "websocket.close", "status": 403,
"code": 1008 "headers": [(b"content-type", b"text/plain")],
}) })
return await send({
except ValueError: "type": "http.response.body",
pass "body": b"Forbidden",
})
elif scope["type"] == "websocket":
await send({
"type": "websocket.close",
"code": 1008
})
return
await self.app(scope, receive, send) await self.app(scope, receive, send)

35
docs/firewall.md Normal file
View File

@ -0,0 +1,35 @@
# 🧱 Firewall Rules
Firewall Rules is a plain [nftables](https://netfilter.org/) allow/drop/reject rule manager — no packet inspection, no filtering module attached, just classic firewall rules. Use it to restrict which hosts/ports can reach the machine (or be reached by it) alongside the other, traffic-inspecting modules.
## How to use it
Each rule matches on:
- **Protocol**: `tcp`, `udp`, `both` (adds a matching TCP and UDP rule) or `any` (matches all protocols, ports are ignored).
- **Source / Destination**: an IP/CIDR, or an interface name (e.g. `eth0`) instead of an address.
- **Source / Destination port range**: `from`/`to` for each side; leave as the full `1-65535` range to match any port.
- **Direction** (`mode`): `in` (incoming traffic — the nftables `INPUT`/`PREROUTING` hook), `out` (outgoing traffic — `OUTPUT`/`POSTROUTING`), or `forward` (traffic routed through this host, `FORWARD` — only meaningful with the `filter` table, see below).
- **Table**: `filter` — standard firewall rules evaluated at the normal input/output/forward hooks; `mangle` — rules evaluated earlier in the pipeline (`prerouting`/`postrouting`, before routing decisions), useful when a rule needs to run before other processing (e.g. before a [Hijack Port to Proxy](porthijack.md) or [TLS Decrypt](tls.md) rule takes effect on the same traffic).
- **Action**: `accept`, `drop`, or `reject` (closes the connection with an ICMP/RST reply instead of silently dropping it). `reject` on outgoing (`out`) traffic isn't meaningful — Firegex silently treats it as `drop` in that direction.
Rules are evaluated in order; the first match wins. Traffic that matches no rule falls through to the global **policy** (`accept`/`drop`/`reject`), which applies to incoming and forwarded traffic — outgoing traffic is always allowed by default regardless of the policy, so Firegex itself is never at risk of losing its own outbound connectivity by misconfiguring rules.
### Global settings
Beyond the rule list and policy, a few toggles affect the whole module:
- **keep_rules**: if enabled, the nftables rules stay applied when Firegex shuts down instead of being torn down — useful so the firewall doesn't silently open up if the Firegex process/container restarts or crashes.
- **allow_loopback**: always accept traffic on the loopback interface, regardless of other rules.
- **allow_established**: always accept traffic belonging to an already-established/related connection, so rules only need to cover new connections.
- **drop_invalid**: drop packets nftables' connection tracking considers invalid (malformed/out-of-state).
- **allow_icmp**: always accept ICMP (ping, etc.).
- **allow_dhcp**: always accept DHCP traffic.
- **multicast_dns**: always accept mDNS (multicast DNS) traffic.
- **allow_upnp**: always accept UPnP traffic.
Each of these, when enabled, inserts a small accept rule ahead of your own rules — they're conveniences for common cases you'd otherwise have to write by hand.
## How it works
Rules are compiled directly into nftables' JSON rule format and applied via the nftables JSON API — there's no packet interception/inspection involved (unlike [Netfilter Regex](nfregex.md) or [Netfilter Proxy](nfproxy.md), which sit in front of a service via nfqueue). Firegex maintains its own dedicated chains (jumped to from the base `INPUT`/`OUTPUT`/`FORWARD` filter hooks and `PREROUTING`/`POSTROUTING` mangle hooks) so its rules can be fully reset without touching anything else on the system.

323
docs/nfproxy.md Normal file
View File

@ -0,0 +1,323 @@
# 🌐 Netfilter Proxy
Netfilter Proxy (`nfproxy`) is a filtering module that leverages [nfqueue](https://netfilter.org/projects/libnetfilter_queue/) to intercept network packets, then runs your own Python filter code against them inside the C++ core via an embedded interpreter. Unlike [Netfilter Regex](nfregex.md), which only matches raw byte patterns, nfproxy gives filters structured access to the traffic (parsed HTTP requests/responses, TCP streams, per-stream history, ...) and lets them make arbitrary decisions in Python.
## How to use it
1. Create a service: pick a protocol (`tcp` or `http`), the target `ip:port` (or attach it to a [TLS Decrypt](tls.md) stream, to see decrypted traffic), and a name.
2. Upload one or more Python filter files (see [writing a filter](#writing-a-filter) below). Each file can define multiple `@pyfilter` functions.
3. Start the service. From then on, every matching packet is routed through nfqueue to the embedded Python interpreter, which runs your filters and returns an action (accept/drop/reject/mangle) back to the C++ core.
4. The service page shows live counters (packets seen, blocked/mangled) and any Python exception raised by a filter, reported back over a unix socket — no restart needed to see them.
Filters can be edited and re-uploaded while the service is running: changes take effect on the next packet. `fail_open` (an advanced per-service option) controls what happens if the filter process itself crashes or can't be reached: enabled, traffic is allowed through unfiltered rather than blocked, trading protection for availability — useful if you'd rather risk letting an attack through than take the service down during a competition.
## Writing a filter
Install the library and CLI:
```bash
pip install -U firegex
```
`fgex` is an alias package for `firegex`: installing either one gives you the same `firegex` module and the `fgex`/`firegex` CLI commands.
### The `pyfilter` decorator
```python
from firegex.nfproxy import pyfilter
```
`pyfilter` marks a function as an active filter. A filter function:
- must be decorated with `@pyfilter`;
- must have every parameter type-annotated with one of the data structures listed below (parameters without a type annotation make the filter invalid, and default/keyword-argument values are ignored — the caller always builds the arguments itself from the live packet data);
- must `return` one of the [packet statements](#packet-statements) below (or `None`, which behaves like `ACCEPT`).
```python
from firegex.nfproxy import pyfilter, ACCEPT, REJECT
@pyfilter
def none_filter(): # A filter that does nothing
return ACCEPT
```
Filter names (the function name) must be unique within a filter file.
Each TCP stream (i.e. each connection) gets its own isolated set of global variables: code at module level runs once per stream, and the same globals are reused across every packet of that stream. Don't store state in another module's globals — that memory is shared across every stream handled by the same thread and will cause data to leak/interfere between unrelated connections. Global variable names starting with `__firegex` are reserved for internal use.
```python
from firegex.nfproxy import pyfilter, ACCEPT, REJECT
from firegex.nfproxy.models import HttpRequest
@pyfilter
def filter_with_args(http_request: HttpRequest):
if http_request.body and b"ILLEGAL" in http_request.body:
return REJECT
```
Here the filter is only called once the data required to build an `HttpRequest` is available (i.e. once the HTTP headers have been parsed, and again once the body is complete). If a filter needs multiple parameters, it's only called once every parameter can be built from the data received so far.
### Packet statements
A filter must return one of these values (importable from `firegex.nfproxy`):
| Statement | Effect |
|---|---|
| `ACCEPT` | The packet is accepted and forwarded to the destination. This is also the default behavior if `None` is returned. |
| `REJECT` | The connection is closed and every packet in the stream is dropped. |
| `DROP` | This packet, and every subsequent packet in the stream, is silently dropped (unlike `REJECT`, this doesn't simulate a connection closure). |
| `UNSTABLE_MANGLE` | The packet is modified and forwarded. Only available when filtering `RawPacket` — see below. This is an unstable feature, use it carefully. |
### Data structures
These are the types you can use as filter parameter annotations. Some are only available when the service's protocol is `http` (marked **HTTP only**); the rest work for both `tcp` and `http` services.
#### `RawPacket`
```python
from firegex.nfproxy.models import RawPacket
```
The raw packet as received from nfqueue — the lowest level of abstraction, and the **only** data structure that can be mutated (for `UNSTABLE_MANGLE`).
- `data: bytes` — the packet's data, reassembled/reordered as a TCP stream (read-only).
- `is_input: bool` — `True` for an incoming packet, `False` for outgoing.
- `is_ipv6: bool` — `True` for IPv6, `False` for IPv4.
- `is_tcp: bool` — `True` for TCP, `False` for UDP.
- `l4_size: int` — size of the layer-4 payload (read-only).
- `raw_packet_header_len: int` — size of the original packet's header (read-only).
- `raw_packet: bytes` — the full packet, including the IP/TCP headers. Writable: assigning to it mangles the packet if the filter returns `UNSTABLE_MANGLE`, and updates `l4_size`/`l4_data` accordingly.
- `l4_data: bytes` — the layer-4 payload only, taken directly from the raw packet. Also writable for mangling; updates `l4_size`/`raw_packet` accordingly.
Be careful: `l4_data`/`raw_packet` reflect a single physical packet as seen by nfqueue, while `data` is the TCP stream already reassembled/reordered by the C++ core — they can legitimately differ (e.g. out-of-order packets are accepted by default without invoking Python at all).
#### `TCPInputStream` (alias `TCPClientStream`)
```python
from firegex.nfproxy.models import TCPInputStream
```
The assembled TCP stream in the input (client → server) direction. A filter using this type is only called for incoming packets.
- `data: bytes` — the entire input-direction stream assembled so far (read-only).
- `total_stream_size: int` — size of that stream (read-only).
- `is_ipv6: bool`
#### `TCPOutputStream` (alias `TCPServerStream`)
```python
from firegex.nfproxy.models import TCPOutputStream
```
Same as `TCPInputStream`, but for the output (server → client) direction; only called for outgoing packets.
#### `HttpRequest` — HTTP only
```python
from firegex.nfproxy.models import HttpRequest
```
The current HTTP request. This handler is called up to twice: once when the headers are complete, and again once the body is complete (if the whole request arrives in a single TCP packet, it's called only once).
- `method: bytes` — the request method.
- `url: str | None` — the request URL.
- `headers: dict[str, str]` — request headers, keys/values exactly as received (case-sensitive); a repeated header becomes a list of values.
- `get_header(header: str, default=None) -> str` — looks up a header case-insensitively; if the header was repeated, its values are joined with a comma (this method never returns a list).
- `user_agent: str`
- `content_encoding: str`
- `content_length: int | None`
- `body: bytes` — `None` until the body has arrived.
- `body_decoded` — the body decoded according to `content_encoding` (`gzip`, `br`, `deflate` and `zstd` are supported). `False` if decoding failed and `body` isn't `None`.
- `http_version: str`
- `keep_alive: bool`
- `should_upgrade: bool`
- `upgrading_to_h2: bool`
- `upgrading_to_ws: bool`
- `ws_stream: list[websockets.frames.Frame]` — decoded WebSocket frames (permessage-deflate supported); see the [websockets docs](https://websockets.readthedocs.io/en/stable/).
- `stream: bytes` — buffer of the raw WebSocket traffic in this direction; only meaningful once `should_upgrade` is `True`.
- `headers_complete: bool`
- `message_complete: bool`
- `total_size: int` — size of the whole request seen so far.
- `history: HttpHistory` — previously completed requests/responses on this same stream, see [`HttpHistory`](#httphistory-alias-httpstreamhistory--http-only) below.
#### `HttpRequestHeader` — HTTP only
```python
from firegex.nfproxy.models import HttpRequestHeader
```
Same fields as `HttpRequest`, but only called once, when the headers are complete — the body is never buffered and `body` is always `None`.
#### `HttpFullRequest` — HTTP only
```python
from firegex.nfproxy.models import HttpFullRequest
```
Same fields as `HttpRequest`, but only called once the whole request (headers + body) is complete. Completed instances of this type are also what gets stored in `HttpHistory.requests`.
#### `HttpResponse` — HTTP only
```python
from firegex.nfproxy.models import HttpResponse
```
The current HTTP response — same shape and calling convention as `HttpRequest` (up to twice: headers complete, then body complete), plus:
- `status_code: int`
All the other fields listed for `HttpRequest` (`headers`, `get_header`, `body`, `body_decoded`, `content_encoding`, `content_length`, `http_version`, `keep_alive`, `should_upgrade`, `upgrading_to_h2`, `upgrading_to_ws`, `ws_stream`, `stream`, `headers_complete`, `message_complete`, `total_size`, `history`, `user_agent`) apply here too.
#### `HttpResponseHeader` — HTTP only
```python
from firegex.nfproxy.models import HttpResponseHeader
```
Same as `HttpResponse`, but only called once the headers are complete; `body` is always `None`.
#### `HttpFullResponse` — HTTP only
```python
from firegex.nfproxy.models import HttpFullResponse
```
Same as `HttpResponse`, but only called once the whole response is complete. Completed instances of this type are what gets stored in `HttpHistory.responses`.
#### `HttpHistory` (alias `HttpStreamHistory`) — HTTP only
```python
from firegex.nfproxy.models import HttpHistory
```
Gives a filter access to previously *completed* requests/responses on the same TCP stream — useful for correlating a response with the request(s) that came before it on a keep-alive connection, or for stateful logic that spans more than one exchange.
- `requests: list[HttpFullRequest]` — a snapshot copy of the requests completed so far on this stream (does not include the request currently being processed).
- `responses: list[HttpFullResponse]` — a snapshot copy of the completed responses so far.
You can use it in two ways:
```python
from firegex.nfproxy import pyfilter, REJECT
from firegex.nfproxy.models import HttpHistory, HttpResponse
# 1. as its own filter parameter
@pyfilter
def check_history(hist: HttpHistory):
if len(hist.requests) > 50:
return REJECT
# 2. via the .history property available on any Http* instance
@pyfilter
def check_previous_requests(resp: HttpResponse):
if any(b"admin" in req.url.encode() for req in resp.history.requests if req.url):
return REJECT
```
The number of entries kept per stream is capped by the `FGEX_MAX_HISTORY_SIZE` global (default `100`) — see [Other global options](#other-global-options) below; once the cap is reached, the oldest entry is dropped as a new one is added.
## Stream limiter
What happens if a single TCP stream carries a lot of data? Past a configurable size, a "full stream" action kicks in. First import the enum:
```python
from firegex.nfproxy import FullStreamAction
```
Then set these in the filter file's globals:
- `FGEX_STREAM_MAX_SIZE: int` — maximum size (in bytes) of a stream before `FGEX_FULL_STREAM_ACTION` is triggered. This limit applies per data structure independently: e.g. if `TCPInputStream` has reached the limit but `HttpResponse` hasn't, the action only affects `TCPInputStream`. Default: 1MB.
- `FGEX_FULL_STREAM_ACTION: FullStreamAction` — the action taken once the limit is exceeded. Default: `FullStreamAction.FLUSH`.
`FullStreamAction` values:
| Value | Effect |
|---|---|
| `FLUSH` | Flush the stream and keep acquiring new packets (default). |
| `DROP` | Drop the next stream packets, like a `DROP` filter statement. |
| `REJECT` | Reject the stream and close the connection, like a `REJECT` filter statement. |
| `ACCEPT` | Stop calling pyfilters and accept the rest of the traffic as-is. |
## Other global options
```python
from firegex.nfproxy import ExceptionAction
```
- `FGEX_INVALID_ENCODING_ACTION: ExceptionAction` — action taken when parsing hits an invalid/unsupported encoding (a parser-level failure). Default: `ExceptionAction.REJECT`. Values: `ACCEPT` (accept the packet that caused the error), `DROP` (drop the connection), `REJECT` (reject the connection), `NOACTION` (do nothing — the error is signaled and the stream is accepted without calling any more pyfilters on it).
- `FGEX_MAX_HISTORY_SIZE: int` — max number of requests/responses kept per stream by [`HttpHistory`](#httphistory-alias-httpstreamhistory--http-only). Default: `100`.
## Testing a filter locally
You don't need a running Firegex instance to try out a filter: the `firegex` pip package ships a local proxy simulator.
```text
➤ fgex nfproxy -h
Usage: fgex nfproxy [OPTIONS] FILTER_FILE ADDRESS PORT
Run an nfproxy simulation
╭─ Arguments ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ * filter_file TEXT The path to the filter file [default: None] [required] │
│ * address TEXT The address of the target to proxy [default: None] [required] │
│ * port INTEGER The port of the target to proxy [default: None] [required] │
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
╭─ Options ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ --proto [tcp|http] The protocol to proxy [default: tcp] │
│ --from-address TEXT The address of the local server [default: None] │
│ --from-port INTEGER The port of the local server [default: 7474] │
│ -6 Use IPv6 for the connection │
│ --help -h Show this message and exit. │
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
```
This runs a local proxy in front of `ADDRESS:PORT`, applying `FILTER_FILE` to the traffic — no Docker, no nftables/root privileges required. The filter file is reloaded automatically whenever it changes on disk, so you don't need to restart the simulator while iterating.
```bash
fgex nfproxy test_http.py 127.0.0.1 8080 --proto http
```
## Full example
```python
# Example filter file for an http-protocol service
from firegex.nfproxy.models import RawPacket, HttpRequest, HttpHistory
from firegex.nfproxy import pyfilter, ACCEPT, REJECT, DROP, UNSTABLE_MANGLE, FullStreamAction
# Lowest level of abstraction: only RawPacket can be mangled.
@pyfilter
def mangle_example(packet: RawPacket):
if b"TEST_MANGLING" in packet.l4_data:
packet.l4_data = packet.l4_data.replace(b"TEST", b"UNSTABLE")
return UNSTABLE_MANGLE
if b"BAD DATA" in packet.data:
return DROP
return ACCEPT
# Higher level of abstraction: parsed HTTP requests.
@pyfilter
def http_filter(http: HttpRequest):
if http.method == b"GET" and http.url and "test" in http.url:
return REJECT
# Using history to see previous requests on the same keep-alive stream.
@pyfilter
def repeated_probe_filter(hist: HttpHistory):
if len(hist.requests) > 20:
return REJECT
# Stream size limits (applies per data structure, e.g. only to RawPacket streams above)
FGEX_STREAM_MAX_SIZE = 4096
FGEX_FULL_STREAM_ACTION = FullStreamAction.REJECT
```
## How it works
The proxy is built on a multi-threaded architecture that embeds Python for dynamic filtering:
- **Packet interception**: the [nfqueue](https://netfilter.org/projects/libnetfilter_queue/) kernel module (part of [netfilter](https://netfilter.org/)) intercepts network packets; the rules attaching nfqueue to traffic are generated with the nftables JSON API by the Python manager.
- **Packet reading**: a dedicated thread reads packets from nfqueue.
- **Multi-threaded analysis**: the C++ binary launches multiple threads, each with its own Python interpreter — Python 3.12's [per-interpreter GIL](https://peps.python.org/pep-0684/) makes this real multithreading. Traffic is distributed across threads by hashing IP/port, so all packets of the same flow are handled by the same thread.
- **Python filter integration**: uploaded filters run inside these interpreters.
- **HTTP parsing**: [a Python wrapper for llhttp](https://github.com/domysh/pyllhttp) (forked/adapted to work across multiple interpreters) parses HTTP traffic.

35
docs/nfregex.md Normal file
View File

@ -0,0 +1,35 @@
# 📡 Netfilter Regex
Netfilter Regex (`nfregex`) filters network traffic by matching regular expressions directly against packet content, entirely inside a C++ binary (no embedded interpreter, no user-supplied code). It's the fastest filtering module Firegex offers, at the cost of flexibility: if you need custom logic (parsing structured data, stateful decisions, mangling packets), use [Netfilter Proxy](nfproxy.md) instead.
## How to use it
1. Create a service: pick a protocol (`tcp` or `udp`), the target `ip:port` (or attach it to a [TLS Decrypt](tls.md) stream, to see decrypted traffic, `tcp` only), and a name.
2. Add one or more regexes to the service.
3. Start the service. Matching traffic is dropped/rejected according to each regex's direction; everything else passes through unmodified — nfregex has no mangling capability (only [Netfilter Proxy](nfproxy.md) can mangle traffic).
Each regex has:
- **Pattern**: a PCRE2-compatible regular expression, matched as raw bytes (not text) against the traffic.
- **Direction** (`mode`): `S` — match only client → server traffic (to the server); `C` — match only server → client traffic (to the client); `B` — match both directions.
- **Case sensitivity**: matching can be case-sensitive or case-insensitive per regex.
- **Active/inactive**: a regex can be disabled without deleting it — inactive regexes are kept (and still shown with their stats) but never evaluated against traffic.
The service page shows, per regex, how many packets it has blocked so far, and whether it's currently active.
`fail_open` (an advanced per-service option, shared with nfproxy) controls what happens if the underlying nfqueue/binary can't be reached: if enabled, traffic is allowed through unfiltered rather than blocked.
### TCP vs UDP matching
- **TCP**: nfregex reassembles and reorders the stream before matching, so a regex can match a pattern that's split across multiple packets. Out-of-order packets are handled transparently.
- **UDP**: there's no stream to reassemble — each datagram is matched independently, and only the match context (not the full payload) is retained afterwards.
## How it works
The packet filtering process is implemented in C++ and involves several key steps:
- **Packet interception**: the [nfqueue](https://netfilter.org/projects/libnetfilter_queue/) kernel module intercepts network packets (a [netfilter](https://netfilter.org/) module) 🔍. The rules attaching nfqueue to the traffic are generated via the nftables JSON API by the Python manager.
- **Packet reading**: a dedicated thread reads packets from nfqueue. 🧵
- **Packet parsing**: intercepted packets are parsed by [libtins](https://libtins.github.io/), a C++ library that extracts the payload from each packet. 📄
- **Multi-threaded analysis**: multiple threads analyze packets concurrently. While the nfqueue module balances load based solely on IP addresses — resulting in a single thread handling all traffic in NAT environments like CTF networks — Firegex manages threads at the user level differently: traffic is routed based on IP addresses combined with port hashing, giving a more balanced workload while guaranteeing that a given flow is always analyzed by the same thread. ⚡️
- **TCP handling**: for TCP connections, libtins uses a TCP follower to reorder packets received from the kernel. 📈
- **Regex matching**: the extracted payload is matched using [vectorscan](https://github.com/VectorCamp/vectorscan) — a fork of [hyperscan](https://github.com/intel/hyperscan) that also runs on arm64. 🎯

31
docs/porthijack.md Normal file
View File

@ -0,0 +1,31 @@
# ⚡️ Hijack Port to Proxy
Hijack Port to Proxy (`porthijack`) redirects traffic arriving on a public `ip:port` to a proxy you run yourself on `localhost`, without touching the target service's own configuration. Your proxy can then reach the real service on loopback (`127.0.0.1`/`::1`) and do whatever it wants with the traffic — including things Firegex's other modules can't do, like mangling packets (something [Netfilter Proxy](nfproxy.md) can't fully guarantee, since altering TCP payload sizes can desync the stream, while a userspace proxy fully re-terminates the connection and can freely resize packets).
## How to use it
1. Write and run your own proxy, listening on some local port, forwarding to the real service on loopback.
2. Create a service in the UI: the public `ip:port` clients connect to, the protocol (`tcp` or `udp`), and the local port your proxy listens on.
3. Start the service. Public traffic to that `ip:port` is now transparently redirected to your proxy; your proxy's own traffic to the real service (over loopback) is left untouched.
To the original client, responses still appear to come from the original public `ip:port` — the client never sees your proxy's address. To the real service (if it's configured to only accept loopback connections, or logs source IPs), requests appear to come from your proxy, not from the original client.
### Using the nfproxy simulator as your proxy
You don't have to write a proxy from scratch: the `firegex` pip package's `fgex nfproxy` simulator (see the [Netfilter Proxy guide](nfproxy.md)) is a ready-made local proxy that applies a Python filter file to the traffic, and can double as the proxy this module redirects to — this is a convenient way to get nfproxy-style filtering (including mangling, since the simulator fully re-terminates the connection) for a service, driven entirely by port hijacking instead of nfqueue.
```bash
pip install -U firegex
fgex nfproxy test_http.py 127.0.0.1 8080 --proto http --from-port 13377
```
Then create a Hijack Port to Proxy service pointing at local port `13377`. Note: in the simulator, `RawPacket.raw_packet` always equals `RawPacket.l4_data` (there's no real IP/TCP header to mangle, since the simulator re-terminates the connection at the application layer) — see the [nfproxy guide](nfproxy.md) for more on `RawPacket`.
## How it works
This module works by rewriting packet fields directly with [nftables](https://netfilter.org/) rules — there's no connection tracking/NAT involved, just field mangling on matching packets:
- **Inbound** (prerouting): for packets destined to the public `ip:port`, the destination address and port are rewritten in place to loopback and your proxy's port.
- **Outbound** (postrouting): for packets sourced from your proxy's port (destined back to the original client), the source address and port are rewritten back to the public `ip:port`, so the client sees replies coming from where it expects.
This mangling only applies to packets actually matching the public `ip:port`/protocol you configured — traffic to loopback that isn't part of a hijacked flow is left alone.

27
docs/tls.md Normal file
View File

@ -0,0 +1,27 @@
# 🔒 TLS Decryption
TLS Decryption is a decrypt-and-reinspect bridge for services that speak TLS natively. It exists because [Netfilter Regex](nfregex.md)/[Netfilter Proxy](nfproxy.md) can only inspect plaintext: if the real service terminates TLS itself, those modules would only ever see encrypted bytes. A TLS Decrypt stream terminates the public TLS connection, hands the plaintext to a local port where a filter service can attach, then re-encrypts before forwarding to the real backend.
This is **not** a generic "put TLS in front of a plaintext service" reverse proxy — the real backend is expected to also speak TLS natively (mirroring, for example, a CTF challenge service that does its own TLS).
## How to use it
1. Create a stream: the public `ip:port` clients connect to, and the certificate/key pair to present to them.
2. Start the stream.
3. Create an nfproxy or nfregex service with target type "TLS Decrypt", pointing at this stream, and start it. The filter now sees decrypted traffic.
4. You can edit a stream's certificate, IP or port at any time, even while services are attached to it — dependent services are automatically rebound to the new values, no manual reconfiguration needed.
### Start/stop cascade
Starting a filter service attached to a stopped TLS stream automatically starts the stream first. Stopping a TLS stream automatically stops every filter service attached to it (since there would be nothing left for them to inspect). A stream that still has a service attached to it can't be deleted — stop and detach (or delete) the dependent services first.
## How it works
Two loopback ports are involved, deterministically derived from a hash of the stream's public `ip:port` (so they don't need to be manually assigned or tracked):
1. `ssl_port` (loopback): [nginx](https://nginx.org/) terminates the public TLS connection here (TLS 1.2/1.3 only), using the stream's configured certificate/key, and forwards the **decrypted** traffic to `clear_port`.
2. `clear_port` (loopback): nginx re-encrypts the traffic (without verifying the real backend's certificate, since CTF services are commonly self-signed) and forwards it to the real `ip:port`.
The only unencrypted hop is the loopback leg between `ssl_port` and `clear_port` — this is exactly where an nfproxy/nfregex service attaches to see decrypted content. Traffic never touches the network unencrypted: both the public-facing leg and the leg to the real backend are TLS.
nftables DNATs the public `ip:port` to `ssl_port` so clients don't need to know about any of this — they just connect to the same address as always.

View File

@ -1,6 +1,6 @@
# Firegex Python Library and CLI # Firegex Python Library and CLI
This is the Python library for Firegex. It is used to get additional features of Firegex and use the feature of the command `fgex`. This is the Python library for [Firegex](https://github.com/Pwnzer0tt1/firegex), a firewall built for CTF Attack-Defense competitions. It provides the `firegex.nfproxy` package used to write [Netfilter Proxy](../docs/nfproxy.md) Python packet filters, the `fgex` CLI, and a local proxy simulator (`proxysim`) for testing filters without a running Firegex instance.
## Installation ## Installation
@ -8,144 +8,19 @@ This is the Python library for Firegex. It is used to get additional features of
pip install -U firegex pip install -U firegex
``` ```
fgex is an alias of firegex. You can use `fgex` instead of `firegex`. `fgex` is an alias package for `firegex`: installing either one gives you the same `firegex` module and the `fgex`/`firegex` CLI commands.
## Command line usage: ## Documentation
```text **➡️ [Netfilter Proxy: writing and testing `@pyfilter` scripts](../docs/nfproxy.md)** — the `pyfilter` decorator, every data structure (`RawPacket`, `TCPInputStream`/`TCPOutputStream`, `HttpRequest`/`HttpResponse` and their variants, `HttpHistory`), packet statements, stream limits, the `fgex nfproxy` CLI simulator, and a full worked example.
➤ fgex nfproxy -h
Usage: fgex nfproxy [OPTIONS] FILTER_FILE ADDRESS PORT That page is also what's shown in the Firegex web UI itself (the docs button on the nfproxy pages) — both are generated from that single file, so they can never drift out of sync.
Run an nfproxy simulation ## Firegex's other filtering modules
╭─ Arguments ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ This library only covers Netfilter Proxy. Firegex ships several other independent modules, each with its own guide:
│ * filter_file TEXT The path to the filter file [default: None] [required] │
│ * address TEXT The address of the target to proxy [default: None] [required] │
│ * port INTEGER The port of the target to proxy [default: None] [required] │
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
╭─ Options ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ --proto [tcp|http] The protocol to proxy [default: tcp] │
│ --from-address TEXT The address of the local server [default: None] │
│ --from-port INTEGER The port of the local server [default: 7474] │
│ -6 Use IPv6 for the connection │
│ --help -h Show this message and exit. │
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
```
## Library usage: - **[Netfilter Regex](../docs/nfregex.md)** — fast, PCRE2/hyperscan-based regex matching against raw traffic.
- **[Hijack Port to Proxy](../docs/porthijack.md)** — redirect traffic to your own external proxy without touching the target service.
## NfProxy decorator - **[Firewall Rules](../docs/firewall.md)** — plain nftables allow/drop/reject rules.
- **[TLS Decryption](../docs/tls.md)** — decrypt-and-reinspect bridge for services that speak TLS natively.
```python
from firegex.nfproxy import pyfilter
```
This decorator is used to create a filter for the nfproxy.
Example:
```python
@pyfilter
def my_filter(raw_packet: RawPacket): #Logging filter
print(raw_packet.data)
```
## Data handlers
### RawPacket
```python
from firegex.nfproxy import RawPacket
```
This handler will be called every time arrives a packet from the network. It will receive a RawPacket object with the following properties:
- is_input: bool - It's true if the packet is an input packet, false if it's an output packet
- is_ipv6: bool - It's true if the packet is an ipv6 packet, false if it's an ipv4 packet
- is_tcp: bool - It's true if the packet is a tcp packet, false if it's an udp packet
- data: bytes - The data of the packet assembled and sorted from TCP
- l4_size: int - The size of the layer 4 data
- raw_packet_header_len: int - The size of the original packet header
- l4_data: bytes - The layer 4 payload of the packet
- raw_packet: bytes - The raw packet with IP and TCP headers
### TCPInputStream
Alias: TCPClientStream
```python
from firegex.nfproxy import TCPInputStream
```
This handler will be called every time a TCP stream is assembled in input. It will receive a TCPInputStream object with the following properties:
- data: bytes - The data of the packets assembled and sorted from TCP
- is_ipv6: bool - It's true if the packet is an ipv6 packet, false if it's an ipv4 packet
- total_stream_size: int - The size of the stream
### TCPOutputStream
Alias: TCPServerStream
```python
from firegex.nfproxy import TCPOutputStream
```
This handler will be called every time a TCP stream is assembled in output. It will receive a TCPOutputStream object with the following properties:
- data: bytes - The data of the packets assembled and sorted from TCP
- is_ipv6: bool - It's true if the packet is an ipv6 packet, false if it's an ipv4 packet
- total_stream_size: int - The size of the stream
### HttpRequest
```python
from firegex.nfproxy import HttpRequest
```
This handler will be called twice: one for the request headers and one for the request body. It will receive a HttpRequest object with the following properties:
- method: bytes - The method of the request
- url: str - The url of the request
- headers: dict - The headers of the request
- user_agent: str - The user agent of the request
- content_encoding: str - The content encoding of the request
- body: bytes - The body of the request
- headers_complete: bool - It's true if the headers are complete
- message_complete: bool - It's true if the message is complete
- http_version: str - The http version of the request
- keep_alive: bool - It's true if the request should keep alive
- should_upgrade: bool - It's true if the request should upgrade
- content_length: int - The content length of the request
- get_header(header: str, default=None): str - Get a header from the request without caring about the case
- total_size: int - The total size of the stream
- stream: bytes - The stream of the request
### HttpRequestHeader
```python
from firegex.nfproxy import HttpRequestHeader
```
This handler will be called only when the request headers are complete. It will receive a HttpRequestHeader object with the same properties as HttpRequest.
### HttpFullRequest
```python
from firegex.nfproxy import HttpFullRequest
```
This handler will be called only when the request is complete. It will receive a HttpFullRequest object with the same properties as HttpRequest.
### HttpResponse
```python
from firegex.nfproxy import HttpResponse
```
This handler will be called twice: one for the response headers and one for the response body. It will receive a HttpResponse object with the following properties:
- status_code: int - The status code of the response
- url: str - The url of the response
- headers: dict - The headers of the response
- user_agent: str - The user agent of the response
- content_encoding: str - The content encoding of the response
- body: bytes - The body of the response
- headers_complete: bool - It's true if the headers are complete
- message_complete: bool - It's true if the message is complete
- http_version: str - The http version of the response
- keep_alive: bool - It's true if the response should keep alive
- should_upgrade: bool - It's true if the response should upgrade
- content_length: int - The content length of the response
- get_header(header: str, default=None): str - Get a header from the response without caring about the case
- total_size: int - The total size of the stream
- stream: bytes - The stream of the response
### HttpResponseHeader
```python
from firegex.nfproxy import HttpResponseHeader
```
This handler will be called only when the response headers are complete. It will receive a HttpResponseHeader object with the same properties as HttpResponse.
### HttpFullResponse
```python
from firegex.nfproxy import HttpFullResponse
```
This handler will be called only when the response is complete. It will receive a HttpFullResponse object with the same properties as HttpResponse.

View File

@ -8,7 +8,7 @@ from firegex.nfproxy.models import (
HttpHistory, HttpHistory,
HttpStreamHistory, HttpStreamHistory,
) )
from firegex.nfproxy.internals.models import Action, FullStreamAction from firegex.nfproxy.internals.models import Action, FullStreamAction, ExceptionAction
ACCEPT = Action.ACCEPT ACCEPT = Action.ACCEPT
DROP = Action.DROP DROP = Action.DROP
@ -44,7 +44,7 @@ def clear_pyfilter_registry():
__all__ = [ __all__ = [
"ACCEPT", "DROP", "REJECT", "UNSTABLE_MANGLE", "ACCEPT", "DROP", "REJECT", "UNSTABLE_MANGLE",
"Action", "FullStreamAction", "pyfilter", "Action", "FullStreamAction", "ExceptionAction", "pyfilter",
"RawPacket", "TCPInputStream", "TCPOutputStream", "TCPClientStream", "TCPServerStream", "RawPacket", "TCPInputStream", "TCPOutputStream", "TCPClientStream", "TCPServerStream",
"HttpHistory", "HttpStreamHistory" "HttpHistory", "HttpStreamHistory"
] ]

View File

@ -19,7 +19,9 @@
"react": "^19.2.7", "react": "^19.2.7",
"react-dom": "^19.2.7", "react-dom": "^19.2.7",
"react-icons": "^5.6.0", "react-icons": "^5.6.0",
"react-markdown": "^10.1.0",
"react-router": "^8.0.1", "react-router": "^8.0.1",
"remark-gfm": "^4.0.1",
"shiki": "^4.2.0", "shiki": "^4.2.0",
"socket.io-client": "^4.8.3", "socket.io-client": "^4.8.3",
"typescript": "^6.0.3", "typescript": "^6.0.3",
@ -254,8 +256,12 @@
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="], "@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="],
"@types/debug": ["@types/debug@4.1.13", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="],
"@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="], "@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="],
"@types/istanbul-lib-coverage": ["@types/istanbul-lib-coverage@2.0.6", "", {}, "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w=="], "@types/istanbul-lib-coverage": ["@types/istanbul-lib-coverage@2.0.6", "", {}, "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w=="],
@ -268,6 +274,8 @@
"@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="], "@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="],
"@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
"@types/node": ["@types/node@26.0.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-vf2YFi1iY9lHGwNJMs01biZFbKJkrZR1T6/MlzjhJLPdntOHLhTrDSnSVcdtvjihi4VQNlrFRIxLsDBlQpAipA=="], "@types/node": ["@types/node@26.0.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-vf2YFi1iY9lHGwNJMs01biZFbKJkrZR1T6/MlzjhJLPdntOHLhTrDSnSVcdtvjihi4VQNlrFRIxLsDBlQpAipA=="],
"@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="], "@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="],
@ -296,6 +304,8 @@
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="],
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
"browserslist": ["browserslist@4.24.4", "", { "dependencies": { "caniuse-lite": "^1.0.30001688", "electron-to-chromium": "^1.5.73", "node-releases": "^2.0.19", "update-browserslist-db": "^1.1.1" }, "bin": { "browserslist": "cli.js" } }, "sha512-KDi1Ny1gSePi1vm0q4oxSF8b4DR44GF4BbmS2YdhPLOEqd8pDviZOGH/GsmRwoWJ2+5Lr085X7naowMwKHDG1A=="], "browserslist": ["browserslist@4.24.4", "", { "dependencies": { "caniuse-lite": "^1.0.30001688", "electron-to-chromium": "^1.5.73", "node-releases": "^2.0.19", "update-browserslist-db": "^1.1.1" }, "bin": { "browserslist": "cli.js" } }, "sha512-KDi1Ny1gSePi1vm0q4oxSF8b4DR44GF4BbmS2YdhPLOEqd8pDviZOGH/GsmRwoWJ2+5Lr085X7naowMwKHDG1A=="],
@ -312,10 +322,14 @@
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="],
"character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="], "character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="],
"character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="], "character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="],
"character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="],
"ci-info": ["ci-info@4.4.0", "", {}, "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg=="], "ci-info": ["ci-info@4.4.0", "", {}, "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg=="],
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
@ -338,6 +352,8 @@
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="],
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
@ -366,10 +382,14 @@
"escape-string-regexp": ["escape-string-regexp@2.0.0", "", {}, "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w=="], "escape-string-regexp": ["escape-string-regexp@2.0.0", "", {}, "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w=="],
"estree-util-is-identifier-name": ["estree-util-is-identifier-name@3.0.0", "", {}, "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg=="],
"estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], "estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
"expect": ["expect@30.4.1", "", { "dependencies": { "@jest/expect-utils": "30.4.1", "@jest/get-type": "30.1.0", "jest-matcher-utils": "30.4.1", "jest-message-util": "30.4.1", "jest-mock": "30.4.1", "jest-util": "30.4.1" } }, "sha512-PMARsyh/JtqC20HoGqlFcIlQAyqUtW4PlI1rup1uhYJtKuwAjbvWi3GQMAn+STdHum/dk8xrKfUM1+5SAwpolA=="], "expect": ["expect@30.4.1", "", { "dependencies": { "@jest/expect-utils": "30.4.1", "@jest/get-type": "30.1.0", "jest-matcher-utils": "30.4.1", "jest-message-util": "30.4.1", "jest-mock": "30.4.1", "jest-util": "30.4.1" } }, "sha512-PMARsyh/JtqC20HoGqlFcIlQAyqUtW4PlI1rup1uhYJtKuwAjbvWi3GQMAn+STdHum/dk8xrKfUM1+5SAwpolA=="],
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
@ -388,16 +408,32 @@
"hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="], "hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="],
"hast-util-to-jsx-runtime": ["hast-util-to-jsx-runtime@2.3.6", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "vfile-message": "^4.0.0" } }, "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg=="],
"hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="], "hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="],
"html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="],
"html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="], "html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="],
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
"inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="],
"is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="],
"is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="],
"is-arrayish": ["is-arrayish@0.2.1", "", {}, "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="], "is-arrayish": ["is-arrayish@0.2.1", "", {}, "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="],
"is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="],
"is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="],
"is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
"jest-diff": ["jest-diff@30.4.1", "", { "dependencies": { "@jest/diff-sequences": "30.4.0", "@jest/get-type": "30.1.0", "chalk": "^4.1.2", "pretty-format": "30.4.1" } }, "sha512-CRpFK0RtLriVDGcPPAnR6HMVI8bSR2jnUIgralhauzYQZIb4RH9AtEInTuQr65LmmGggGcRT6HIASxwqsVsmlA=="], "jest-diff": ["jest-diff@30.4.1", "", { "dependencies": { "@jest/diff-sequences": "30.4.0", "@jest/get-type": "30.1.0", "chalk": "^4.1.2", "pretty-format": "30.4.1" } }, "sha512-CRpFK0RtLriVDGcPPAnR6HMVI8bSR2jnUIgralhauzYQZIb4RH9AtEInTuQr65LmmGggGcRT6HIASxwqsVsmlA=="],
"jest-matcher-utils": ["jest-matcher-utils@30.4.1", "", { "dependencies": { "@jest/get-type": "30.1.0", "chalk": "^4.1.2", "jest-diff": "30.4.1", "pretty-format": "30.4.1" } }, "sha512-zvYfX5CaeEkFrrLS9suWe9rvJrm9J1Iv3ua8kIBv9GEPzcnsfBf0bob37la7s67fs0nlBC3EuvkOLnXQKxtx4A=="], "jest-matcher-utils": ["jest-matcher-utils@30.4.1", "", { "dependencies": { "@jest/get-type": "30.1.0", "chalk": "^4.1.2", "jest-diff": "30.4.1", "pretty-format": "30.4.1" } }, "sha512-zvYfX5CaeEkFrrLS9suWe9rvJrm9J1Iv3ua8kIBv9GEPzcnsfBf0bob37la7s67fs0nlBC3EuvkOLnXQKxtx4A=="],
@ -448,22 +484,100 @@
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
"longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="],
"loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
"lower-case": ["lower-case@2.0.2", "", { "dependencies": { "tslib": "^2.0.3" } }, "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg=="], "lower-case": ["lower-case@2.0.2", "", { "dependencies": { "tslib": "^2.0.3" } }, "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg=="],
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="],
"marked": ["marked@14.0.0", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ=="], "marked": ["marked@14.0.0", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ=="],
"mdast-util-find-and-replace": ["mdast-util-find-and-replace@3.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "escape-string-regexp": "^5.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg=="],
"mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.3", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q=="],
"mdast-util-gfm": ["mdast-util-gfm@3.1.0", "", { "dependencies": { "mdast-util-from-markdown": "^2.0.0", "mdast-util-gfm-autolink-literal": "^2.0.0", "mdast-util-gfm-footnote": "^2.0.0", "mdast-util-gfm-strikethrough": "^2.0.0", "mdast-util-gfm-table": "^2.0.0", "mdast-util-gfm-task-list-item": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ=="],
"mdast-util-gfm-autolink-literal": ["mdast-util-gfm-autolink-literal@2.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "ccount": "^2.0.0", "devlop": "^1.0.0", "mdast-util-find-and-replace": "^3.0.0", "micromark-util-character": "^2.0.0" } }, "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ=="],
"mdast-util-gfm-footnote": ["mdast-util-gfm-footnote@2.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0" } }, "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ=="],
"mdast-util-gfm-strikethrough": ["mdast-util-gfm-strikethrough@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg=="],
"mdast-util-gfm-table": ["mdast-util-gfm-table@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "markdown-table": "^3.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg=="],
"mdast-util-gfm-task-list-item": ["mdast-util-gfm-task-list-item@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ=="],
"mdast-util-mdx-expression": ["mdast-util-mdx-expression@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ=="],
"mdast-util-mdx-jsx": ["mdast-util-mdx-jsx@3.2.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "parse-entities": "^4.0.0", "stringify-entities": "^4.0.0", "unist-util-stringify-position": "^4.0.0", "vfile-message": "^4.0.0" } }, "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q=="],
"mdast-util-mdxjs-esm": ["mdast-util-mdxjs-esm@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg=="],
"mdast-util-phrasing": ["mdast-util-phrasing@4.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "unist-util-is": "^6.0.0" } }, "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w=="],
"mdast-util-to-hast": ["mdast-util-to-hast@13.2.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA=="], "mdast-util-to-hast": ["mdast-util-to-hast@13.2.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA=="],
"mdast-util-to-markdown": ["mdast-util-to-markdown@2.1.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "longest-streak": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "mdast-util-to-string": "^4.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA=="],
"mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="],
"micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="],
"micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="],
"micromark-extension-gfm": ["micromark-extension-gfm@3.0.0", "", { "dependencies": { "micromark-extension-gfm-autolink-literal": "^2.0.0", "micromark-extension-gfm-footnote": "^2.0.0", "micromark-extension-gfm-strikethrough": "^2.0.0", "micromark-extension-gfm-table": "^2.0.0", "micromark-extension-gfm-tagfilter": "^2.0.0", "micromark-extension-gfm-task-list-item": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w=="],
"micromark-extension-gfm-autolink-literal": ["micromark-extension-gfm-autolink-literal@2.1.0", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw=="],
"micromark-extension-gfm-footnote": ["micromark-extension-gfm-footnote@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw=="],
"micromark-extension-gfm-strikethrough": ["micromark-extension-gfm-strikethrough@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw=="],
"micromark-extension-gfm-table": ["micromark-extension-gfm-table@2.1.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg=="],
"micromark-extension-gfm-tagfilter": ["micromark-extension-gfm-tagfilter@2.0.0", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg=="],
"micromark-extension-gfm-task-list-item": ["micromark-extension-gfm-task-list-item@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw=="],
"micromark-factory-destination": ["micromark-factory-destination@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA=="],
"micromark-factory-label": ["micromark-factory-label@2.0.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg=="],
"micromark-factory-space": ["micromark-factory-space@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg=="],
"micromark-factory-title": ["micromark-factory-title@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw=="],
"micromark-factory-whitespace": ["micromark-factory-whitespace@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ=="],
"micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="], "micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="],
"micromark-util-chunked": ["micromark-util-chunked@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA=="],
"micromark-util-classify-character": ["micromark-util-classify-character@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q=="],
"micromark-util-combine-extensions": ["micromark-util-combine-extensions@2.0.1", "", { "dependencies": { "micromark-util-chunked": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg=="],
"micromark-util-decode-numeric-character-reference": ["micromark-util-decode-numeric-character-reference@2.0.2", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw=="],
"micromark-util-decode-string": ["micromark-util-decode-string@2.0.1", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ=="],
"micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="], "micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="],
"micromark-util-html-tag-name": ["micromark-util-html-tag-name@2.0.1", "", {}, "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA=="],
"micromark-util-normalize-identifier": ["micromark-util-normalize-identifier@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q=="],
"micromark-util-resolve-all": ["micromark-util-resolve-all@2.0.1", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg=="],
"micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="], "micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="],
"micromark-util-subtokenize": ["micromark-util-subtokenize@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA=="],
"micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="], "micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="],
"micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="], "micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="],
@ -486,6 +600,8 @@
"parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
"parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="],
"parse-json": ["parse-json@5.2.0", "", { "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", "json-parse-even-better-errors": "^2.3.0", "lines-and-columns": "^1.1.6" } }, "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg=="], "parse-json": ["parse-json@5.2.0", "", { "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", "json-parse-even-better-errors": "^2.3.0", "lines-and-columns": "^1.1.6" } }, "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg=="],
"path-type": ["path-type@4.0.0", "", {}, "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw=="], "path-type": ["path-type@4.0.0", "", {}, "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw=="],
@ -516,6 +632,8 @@
"react-is-19": ["react-is@19.2.7", "", {}, "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A=="], "react-is-19": ["react-is@19.2.7", "", {}, "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A=="],
"react-markdown": ["react-markdown@10.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "html-url-attributes": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" }, "peerDependencies": { "@types/react": ">=18", "react": ">=18" } }, "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ=="],
"react-number-format": ["react-number-format@5.4.5", "", { "peerDependencies": { "react": "^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-y8O2yHHj3w0aE9XO8d2BCcUOOdQTRSVq+WIuMlLVucAm5XNjJAy+BoOJiuQMldVYVOKTMyvVNfnbl2Oqp+YxGw=="], "react-number-format": ["react-number-format@5.4.5", "", { "peerDependencies": { "react": "^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-y8O2yHHj3w0aE9XO8d2BCcUOOdQTRSVq+WIuMlLVucAm5XNjJAy+BoOJiuQMldVYVOKTMyvVNfnbl2Oqp+YxGw=="],
"react-redux": ["react-redux@9.3.0", "", { "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "@types/react": "^18.2.25 || ^19", "react": "^18.0 || ^19", "redux": "^5.0.0" }, "optionalPeers": ["@types/react", "redux"] }, "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g=="], "react-redux": ["react-redux@9.3.0", "", { "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "@types/react": "^18.2.25 || ^19", "react": "^18.0 || ^19", "redux": "^5.0.0" }, "optionalPeers": ["@types/react", "redux"] }, "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g=="],
@ -540,6 +658,14 @@
"regex-utilities": ["regex-utilities@2.3.0", "", {}, "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="], "regex-utilities": ["regex-utilities@2.3.0", "", {}, "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="],
"remark-gfm": ["remark-gfm@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="],
"remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="],
"remark-rehype": ["remark-rehype@11.1.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "mdast-util-to-hast": "^13.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw=="],
"remark-stringify": ["remark-stringify@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="],
"resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
"rolldown": ["rolldown@1.0.3", "", { "dependencies": { "@oxc-project/types": "=0.133.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.3", "@rolldown/binding-darwin-arm64": "1.0.3", "@rolldown/binding-darwin-x64": "1.0.3", "@rolldown/binding-freebsd-x64": "1.0.3", "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", "@rolldown/binding-linux-arm64-gnu": "1.0.3", "@rolldown/binding-linux-arm64-musl": "1.0.3", "@rolldown/binding-linux-ppc64-gnu": "1.0.3", "@rolldown/binding-linux-s390x-gnu": "1.0.3", "@rolldown/binding-linux-x64-gnu": "1.0.3", "@rolldown/binding-linux-x64-musl": "1.0.3", "@rolldown/binding-openharmony-arm64": "1.0.3", "@rolldown/binding-wasm32-wasi": "1.0.3", "@rolldown/binding-win32-arm64-msvc": "1.0.3", "@rolldown/binding-win32-x64-msvc": "1.0.3" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g=="], "rolldown": ["rolldown@1.0.3", "", { "dependencies": { "@oxc-project/types": "=0.133.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.3", "@rolldown/binding-darwin-arm64": "1.0.3", "@rolldown/binding-darwin-x64": "1.0.3", "@rolldown/binding-freebsd-x64": "1.0.3", "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", "@rolldown/binding-linux-arm64-gnu": "1.0.3", "@rolldown/binding-linux-arm64-musl": "1.0.3", "@rolldown/binding-linux-ppc64-gnu": "1.0.3", "@rolldown/binding-linux-s390x-gnu": "1.0.3", "@rolldown/binding-linux-x64-gnu": "1.0.3", "@rolldown/binding-linux-x64-musl": "1.0.3", "@rolldown/binding-openharmony-arm64": "1.0.3", "@rolldown/binding-wasm32-wasi": "1.0.3", "@rolldown/binding-win32-arm64-msvc": "1.0.3", "@rolldown/binding-win32-x64-msvc": "1.0.3" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g=="],
@ -568,6 +694,10 @@
"stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="], "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
"style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="],
"style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="],
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"svg-parser": ["svg-parser@2.0.4", "", {}, "sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ=="], "svg-parser": ["svg-parser@2.0.4", "", {}, "sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ=="],
@ -582,6 +712,8 @@
"trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
"trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"type-fest": ["type-fest@5.7.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg=="], "type-fest": ["type-fest@5.7.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg=="],
@ -590,6 +722,8 @@
"undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
"unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="],
"unist-util-is": ["unist-util-is@6.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw=="], "unist-util-is": ["unist-util-is@6.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw=="],
"unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="], "unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="],
@ -646,6 +780,10 @@
"engine.io-client/debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="], "engine.io-client/debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="],
"mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
"parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
"parse-json/@babel/code-frame": ["@babel/code-frame@7.26.2", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.25.9", "js-tokens": "^4.0.0", "picocolors": "^1.0.0" } }, "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ=="], "parse-json/@babel/code-frame": ["@babel/code-frame@7.26.2", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.25.9", "js-tokens": "^4.0.0", "picocolors": "^1.0.0" } }, "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ=="],
"socket.io-parser/debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="], "socket.io-parser/debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="],

View File

@ -18,7 +18,9 @@
"react": "^19.2.7", "react": "^19.2.7",
"react-dom": "^19.2.7", "react-dom": "^19.2.7",
"react-icons": "^5.6.0", "react-icons": "^5.6.0",
"react-markdown": "^10.1.0",
"react-router": "^8.0.1", "react-router": "^8.0.1",
"remark-gfm": "^4.0.1",
"shiki": "^4.2.0", "shiki": "^4.2.0",
"socket.io-client": "^4.8.3", "socket.io-client": "^4.8.3",
"typescript": "^6.0.3", "typescript": "^6.0.3",

View File

@ -4,12 +4,16 @@ import { FaBookBookmark } from "react-icons/fa6";
import { NFRegexDocs } from "./NFRegex/NFRegexDocs"; import { NFRegexDocs } from "./NFRegex/NFRegexDocs";
import { NFProxyDocs } from "./NFProxy/NFProxyDocs"; import { NFProxyDocs } from "./NFProxy/NFProxyDocs";
import { PortHijackDocs } from "./PortHijack/PortHijackDocs"; import { PortHijackDocs } from "./PortHijack/PortHijackDocs";
import { FirewallDocs } from "./Firewall/FirewallDocs";
import { TLSDocs } from "./TLSDocs";
import { EnumToPrimitiveUnion } from "../js/utils"; import { EnumToPrimitiveUnion } from "../js/utils";
export enum DocType{ export enum DocType{
NFREGEX = "nfregex", NFREGEX = "nfregex",
NFPROXY = "nfproxy", NFPROXY = "nfproxy",
PORTHIJACK = "porthijack", PORTHIJACK = "porthijack",
FIREWALL = "firewall",
TLS = "tls",
} }
@ -31,6 +35,10 @@ export const DocsButton = ({ doc, ...props }: { doc: EnumToPrimitiveUnion<DocTyp
<NFProxyDocs />: <NFProxyDocs />:
doc == DocType.PORTHIJACK ? doc == DocType.PORTHIJACK ?
<PortHijackDocs />: <PortHijackDocs />:
doc == DocType.FIREWALL ?
<FirewallDocs />:
doc == DocType.TLS ?
<TLSDocs />:
<Title order={3}>Docs not found</Title> <Title order={3}>Docs not found</Title>
} }
</Container> </Container>

View File

@ -0,0 +1,4 @@
import { MarkdownDocs } from "../MarkdownDocs";
import docsMarkdown from "../../../../docs/firewall.md?raw";
export const FirewallDocs = () => <MarkdownDocs content={docsMarkdown} sourcePath="docs/firewall.md" />;

View File

@ -0,0 +1,66 @@
import { CodeHighlight } from "@mantine/code-highlight";
import { Typography } from "@mantine/core";
import ReactMarkdown, { type Components } from "react-markdown";
import remarkGfm from "remark-gfm";
function toHighlightLanguage(lang: string | undefined): string {
return lang === "python" ? "python" : "";
}
const GITHUB_BLOB_BASE = "https://github.com/Pwnzer0tt1/firegex/blob/main/";
// Cross-references between doc files (e.g. "nfregex.md", "../fgex-lib/README.md")
// are relative to the file's position in the repo, which only resolves correctly
// on GitHub - inside the app they'd resolve against the current page's URL instead.
// Repoint any relative link at the equivalent file on GitHub, given where the
// currently-rendered markdown source lives in the repo.
function resolveHref(href: string, sourcePath: string): string {
if (/^([a-z][a-z0-9+.-]*:)?\/\//i.test(href) || href.startsWith("mailto:") || href.startsWith("#")) {
return href;
}
return new URL(href, GITHUB_BLOB_BASE + sourcePath).href;
}
function buildMarkdownComponents(sourcePath: string): Components {
return {
// CodeHighlight already renders its own <pre>, so unwrap the one react-markdown adds
pre: ({ children }) => <>{children}</>,
code: ({ className, children, ...props }) => {
const match = /language-(\w+)/.exec(className ?? "");
if (match) {
return (
<CodeHighlight
code={String(children).replace(/\n$/, "")}
language={toHighlightLanguage(match[1])}
my="sm"
/>
);
}
return (
<code className={className} {...props}>
{children}
</code>
);
},
a: ({ href, children, ...props }) => (
<a href={href ? resolveHref(href, sourcePath) : href} target="_blank" rel="noreferrer" {...props}>
{children}
</a>
),
};
}
// Renders a markdown documentation source (imported elsewhere via `?raw`) with
// Firegex's Mantine styling, so every module's docs page shares one renderer
// and one on-disk source of truth instead of duplicating hand-written JSX.
// `sourcePath` is the file's repo-relative path (e.g. "docs/nfproxy.md"), used
// to resolve relative cross-references to other doc files.
export const MarkdownDocs = ({ content, sourcePath }: { content: string; sourcePath: string }) => {
return (
<Typography>
<ReactMarkdown remarkPlugins={[remarkGfm]} components={buildMarkdownComponents(sourcePath)}>
{content}
</ReactMarkdown>
</Typography>
);
};

View File

@ -1,833 +1,7 @@
import { CodeHighlight } from "@mantine/code-highlight"; import { MarkdownDocs } from "../MarkdownDocs";
import { // Single source of truth for this documentation: docs/nfproxy.md is rendered
Container, // here as-is, and is also linked from fgex-lib/README.md (the pip package's
Title, // own README), so the UI and the markdown files can never drift apart.
Text, import docsMarkdown from "../../../../docs/nfproxy.md?raw";
List,
Code,
Space,
Badge,
Box,
} from "@mantine/core";
import { CgEditBlackPoint } from "react-icons/cg";
import { EXAMPLE_PYFILTER } from "./utils";
const IMPORT_CODE_EXAMPLE = `from firegex.nfproxy import pyfilter, ACCEPT, REJECT`; export const NFProxyDocs = () => <MarkdownDocs content={docsMarkdown} sourcePath="docs/nfproxy.md" />;
const FOO_FILTER_CODE = `from firegex.nfproxy import pyfilter, ACCEPT, REJECT
# This is NOT a filter
def useless_function() -> int:
print("This is a useless function")
return 42
@pyfilter
def none_filter(): # This is a filter that does nothing
useless_function()
return ACCEPT
`;
const TYPING_ARGS_EXAMPLE = `from firegex.nfproxy import pyfilter, ACCEPT, REJECT
from firegex.nfproxy.models import HttpRequest
@pyfilter
def filter_with_args(http_request: HttpRequest) -> int:
if http_request.body:
if b"ILLEGAL" in http_request.body:
return REJECT
`;
const IMPORT_FULL_ACTION_STREAM = `from firegex.nfproxy import FullStreamAction
# Here the definition of FullStreamAction enum
class FullStreamAction(Enum):
"""Action to be taken by the filter when the stream is full"""
FLUSH = 0
ACCEPT = 1
REJECT = 2
DROP = 3
`;
const ENUM_IMPORT_AND_DEFINITION = `from firegex.nfproxy import ExceptionAction
# Here the definition of ExceptionAction enum
class ExceptionAction(Enum):
"""Action to be taken by the filter when an exception occurs (used in some cases)"""
ACCEPT = 0 # Accept the packet that caused the exception
DROP = 1 # Drop the connection that caused the exception
REJECT = 2 # Reject the connection that caused the exception
NOACTION = 3 # Do nothing, the excpetion will be signaled and the stream will be accepted without calling anymore the pyfilters (for the current stream)
`;
export const HELP_NFPROXY_SIM = `➤ fgex nfproxy -h
Usage: fgex nfproxy [OPTIONS] FILTER_FILE ADDRESS PORT
Run an nfproxy simulation
╭─ Arguments ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ * filter_file TEXT The path to the filter file [default: None] [required] │
│ * address TEXT The address of the target to proxy [default: None] [required] │
│ * port INTEGER The port of the target to proxy [default: None] [required] │
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
╭─ Options ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ --proto [tcp|http] The protocol to proxy [default: tcp] │
│ --from-address TEXT The address of the local server [default: None] │
│ --from-port INTEGER The port of the local server [default: 7474] │
│ -6 Use IPv6 for the connection │
│ --help -h Show this message and exit. │
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯`;
const HttpBadge = () => {
return (
<Badge
size="md"
ml="xs"
radius="md"
variant="gradient"
gradient={{ from: "red", to: "grape", deg: 107 }}
style={{ fontSize: "13px", fontWeight: "bolder" }}
>
HTTP
</Badge>
);
};
const TCPBadge = () => {
return (
<Badge
size="md"
ml="xs"
radius="md"
variant="gradient"
gradient={{ from: "indigo", to: "teal", deg: 164 }}
style={{ fontSize: "13px", fontWeight: "bolder" }}
>
TCP
</Badge>
);
};
export const NFProxyDocs = () => {
return (
<>
<Title order={1}>🌐 Netfilter Proxy Documentation</Title>
<Title order={2} mt="xl" mb="sm">
📖 Overview
</Title>
<Text size="lg">
Netfilter Proxy is a simulated proxy that leverages{" "}
<a href="https://netfilter.org/projects/libnetfilter_queue/">
nfqueue
</a>{" "}
to intercept network packets. It follows a similar workflow to
NFRegex but introduces Python-based filtering capabilities,
providing users with the flexibility to upload custom filters.
</Text>
<Title order={2} mt="lg" mb="sm">
⚙️ How to use Netfilter Proxy
</Title>
<Text size="lg">
To use Netfilter Proxy, simply create and upload a Python
filter. The filter is passed to the C++ binary, which then
processes packets using the provided logic. This allows you to
tailor the filtering behavior to your needs.
</Text>
<Title order={2} mt="lg" mb="sm">
💡 How to write pyfilters?
</Title>
<Text size="lg">
First of all install the firegex lib and update it running{" "}
<Code>pip install -U fgex</Code>. After that you can use{" "}
<Code>firegex</Code> module.
<CodeHighlight
code={IMPORT_CODE_EXAMPLE}
language="python"
my="sm"
/>
With this code we imported the <Code>pyfilter</Code> decorator
and the <Code>ACCEPT</Code> and <Code>REJECT</Code> statements.
<br />
Let's create a first (useless) filter to see the syntax:
<CodeHighlight
code={FOO_FILTER_CODE}
language="python"
my="sm"
/>
You see that the filter must be decorated with the{" "}
<Code>pyfilter</Code> decorator and must return a statement
about how to manage that packet.
<br />
<Space h="sm" />
You can save every data about the current flow in the global
variables, the code you write will be executed only once for
flow. The globals variables are isolated between flows. For each
packet the filter functions will be called with the required
paramethers and using the same globals as before.
<br />
<Space h="sm" />
<strong>
Saving data in globals of other modules is not recommended,
because that memory is shared by the flows managed by the
same thread and lead to unexpected behaviors.
</strong>
<br />
<Space h="sm" />
<strong>
Global variables that starts with '__firegex' are reserved
for internal use, don't use them.
</strong>
<br />
<Space h="sm" />
You can manage when the function is called and also getting some
data specifying some paramethers, using type decorators. Default
values of the paramethers will be ignored, also kvargs values
will be ignored.
<br />
<Space h="sm" />
<strong>
Functions with no type decorator are considered invalid
pyfilters!
</strong>
<br />
<Space h="sm" />
<CodeHighlight
code={TYPING_ARGS_EXAMPLE}
language="python"
my="sm"
/>
In this code we are filtering all the http requests that
contains the word "ILLEGAL" in the body. All the other packets
will be accepted (default behavior). The function will be called
only if at least internally teh HTTP request header has been
parsed, and also when the body will be parsed.
<br />
<Space h="sm" />
If we have multiple paramether, the function will be called only
if with the packet arrived is possible to build all the
paramethers.
</Text>
<Title order={2} mt="lg" mb="sm">
🔧 How can I test the filter?
</Title>
<Text size="lg">
You can test your filter by using <Code>fgex</Code> command
installed by firegex lib: This will run a local proxy to a
remote destination with the filter you specified.
<br />
<Space h="sm" />
This can be done by running for instance:{" "}
<Code>
fgex nfproxy test_http.py 127.0.0.1 8080 --proto http
</Code>
<CodeHighlight code={HELP_NFPROXY_SIM} language="" my="sm" />
You don't need to restart the proxy every time you change the
filter, the filter will be reloaded automatically.
</Text>
<Title order={2} mt="lg" mb="sm">
📦 Packet Statements
</Title>
<Text size="lg" my="xs">
Here there are all the statments you can return from a filter:
<List>
<List.Item>
<strong>ACCEPT: </strong> The packet will be accepted
and forwarded to the destination. (default if None is
returned)
</List.Item>
<List.Item>
<strong>REJECT: </strong> The connection will be closed
and all the packets will be dropped.
</List.Item>
<List.Item>
<strong>DROP: </strong> This packet and all the
following will be dropped. (This not simulate a
connection closure)
</List.Item>
<List.Item>
<strong>UNSTABLE_MANGLE: </strong> The packet will be
modified and forwarded. You can edit the packet only
with RawPacket data handler. (This is an unstable
statement, use it carefully)
</List.Item>
</List>
</Text>
<Title order={2} mt="lg" mb="sm">
⚙️ Data Structures
</Title>
<Text size="lg" my="xs">
Here there are all the data structure you can use for your
filters:
</Text>
<Box display="flex" style={{ alignItems: "center" }}>
<Title order={3} my="xs">
<CgEditBlackPoint style={{ marginBottom: -3 }} /> RawPacket
</Title>
<Space w="sm" />
<TCPBadge />
<HttpBadge />
</Box>
<Text size="lg">
This data is the raw packet processed by nfqueue. It contains:
</Text>
<Space h="sm" />
<Text size="lg" ml="xs">
<List>
<List.Item>
<strong>data: </strong> The raw packet data assembled by
libtins (read only).
</List.Item>
<List.Item>
<strong>is_input: </strong> It's true if the packet is
incoming, false if it's outgoing. (read only)
</List.Item>
<List.Item>
<strong>is_ipv6: </strong> It's true if the packet is
IPv6, false if it's IPv4. (read only)
</List.Item>
<List.Item>
<strong>is_tcp: </strong> It's true if the packet is
TCP, false if it's UDP. (read only)
</List.Item>
<List.Item>
<strong>l4_size: </strong> The size of l4 payload (read
only)
</List.Item>
<List.Item>
<strong>raw_packet_header_len: </strong> The size of the
raw packet header (read only)
</List.Item>
<List.Item>
<strong>raw_packet: </strong> The raw packet data with
ip and TCP header. You can edit all the packet content
and it will be modified if you send the UNSTABLE_MANGLE
statement.{" "}
<strong>
Be careful, beacause the associated layer 4 data can
be different from 'data' filed that instead arrives
from libtins.
</strong>
When you edit this field, l4_size and l4_data will be
updated automatically.
</List.Item>
<List.Item>
<strong>l4_data: </strong> The l4 payload data, directly
taken by the raw packet. You can edit all the packet
content and it will be modified if you send the
UNSTABLE_MANGLE statement.{" "}
<strong>
Be careful, beacause the associated layer 4 data can
be different from 'data' filed that instead arrives
from libtins.
</strong>{" "}
When you edit this field, l4_size and raw_packet will be
updated automatically.
</List.Item>
</List>
</Text>
<Box display="flex" style={{ alignItems: "center" }}>
<Title order={3} my="xs">
<CgEditBlackPoint style={{ marginBottom: -3 }} />{" "}
TCPInputStream (alias: TCPClientStream)
</Title>
<Space w="sm" />
<TCPBadge />
<HttpBadge />
</Box>
<Text size="lg">
This data is the TCP input stream: this handler is called only
on is_input=True packets. The filters that handles this data
will be called only in this case.
</Text>
<Space h="sm" />
<Text size="lg" ml="xs">
<List>
<List.Item>
<strong>data: </strong> The entire stream in input
direction. (read only)
</List.Item>
<List.Item>
<strong>total_stream_size: </strong> The size of the
entire stream in input direction. (read only)
</List.Item>
<List.Item>
<strong>is_ipv6: </strong> It's true if the stream is
IPv6, false if it's IPv4. (read only)
</List.Item>
</List>
</Text>
<Box display="flex" style={{ alignItems: "center" }}>
<Title order={3} my="xs">
<CgEditBlackPoint style={{ marginBottom: -3 }} />{" "}
TCPOutputStream (alias TCPServerStream)
</Title>
<Space w="sm" />
<TCPBadge />
<HttpBadge />
</Box>
<Text size="lg">
This data is the TCP output stream: this handler is called only
on is_input=False packets. The filters that handles this data
will be called only in this case.
</Text>
<Space h="sm" />
<Text size="lg" ml="xs">
<List>
<List.Item>
<strong>data: </strong> The entire stream in output
direction. (read only)
</List.Item>
<List.Item>
<strong>total_stream_size: </strong> The size of the
entire stream in output direction. (read only)
</List.Item>
<List.Item>
<strong>is_ipv6: </strong> It's true if the stream is
IPv6, false if it's IPv4. (read only)
</List.Item>
</List>
</Text>
<Box display="flex" style={{ alignItems: "center" }}>
<Title order={3} my="xs">
<CgEditBlackPoint style={{ marginBottom: -3 }} />{" "}
HttpRequest
</Title>
<Space w="sm" />
<HttpBadge />
</Box>
<Text size="lg">
This data is the Http request processed by nfqueue. This handler
can be called twice per request: once when the http headers are
complete, and once when the body is complete.
</Text>
<Text size="lg">
If the http data arrives in 1 single TCP packet, this handler
will be called once
</Text>
<Space h="sm" />
<Text size="lg" ml="xs">
<List>
<List.Item>
<strong>url: </strong> The url of the request (read
only)
</List.Item>
<List.Item>
<strong>headers: </strong> The headers of the request
(read only). The keys and values are exactly the same as
the original request (case sensitive). (values can be
list in case the same header field is repeated)
</List.Item>
<List.Item>
<strong>get_header(key:str, default = None): </strong> A
function that returns the value of a header: it matches
the key without case sensitivity. If the header is not
found, it returns the default value. (if the same header
field is repeated, its value is concatenated with a
comma, this function will never return a list)
</List.Item>
<List.Item>
<strong>user_agent: </strong> The user agent of the
request (read only)
</List.Item>
<List.Item>
<strong>content_encoding: </strong> The content encoding
of the request (read only)
</List.Item>
<List.Item>
<strong>content_length: </strong> The content length of
the request (read only)
</List.Item>
<List.Item>
<strong>body: </strong> The body of the request (read
only). It's None if the body has not arrived yet.
</List.Item>
<List.Item>
<strong>body_decoded: </strong> By default the body will
be decoded following the content encoding. gzip, br,
deflate and zstd are supported. If the decoding fails
and body is not None this paramether will be False.
</List.Item>
<List.Item>
<strong>http_version: </strong> The http version of the
request (read only)
</List.Item>
<List.Item>
<strong>keep_alive: </strong> It's true if the
connection was marked for keep alive, false if it's not.
(read only)
</List.Item>
<List.Item>
<strong>should_upgrade: </strong> It's true if the
connection should be upgraded, false if it's not. (read
only)
</List.Item>
<List.Item>
<strong>upgrading_to_h2: </strong> It's true if the
connection is upgrading to h2, false if it's not. (read
only)
</List.Item>
<List.Item>
<strong>ws_stream: </strong> It's a list of
websockets.frames.Frame decoded (permessage-deflate is
supported). (read only) [
<a href="https://websockets.readthedocs.io/en/stable/">
docs
</a>
]
</List.Item>
<List.Item>
<strong>upgrading_to_ws: </strong> It's true if the
connection is upgrading to ws, false if it's not. (read
only)
</List.Item>
<List.Item>
<strong>method: </strong> The method of the request
(read only)
</List.Item>
<List.Item>
<strong>headers_complete: </strong> It's true if the
headers are complete, false if they are not. (read only)
</List.Item>
<List.Item>
<strong>message_complete: </strong> It's true if the
message is complete, false if it's not. (read only)
</List.Item>
<List.Item>
<strong>total_size: </strong> The size of the entire
http request (read only)
</List.Item>
<List.Item>
<strong>stream: </strong> It's the buffer that contains
the stream of the websocket traffic in input. This is
used only if should_upgrade is True. (read only)
</List.Item>
</List>
</Text>
<Box display="flex" style={{ alignItems: "center" }}>
<Title order={3} my="xs">
<CgEditBlackPoint style={{ marginBottom: -3 }} />{" "}
HttpRequestHeader
</Title>
<Space w="sm" />
<HttpBadge />
</Box>
<Text size="lg">
Same as HttpRequest, but this handler is called only when the
headers are complete and body is not buffered. Body will always
be None
</Text>
<Box display="flex" style={{ alignItems: "center" }}>
<Title order={3} my="xs">
<CgEditBlackPoint style={{ marginBottom: -3 }} />{" "}
HttpFullRequest
</Title>
<Space w="sm" />
<HttpBadge />
</Box>
<Text size="lg">
Same as HttpRequest, but this handler is called only when the
request data is complete
</Text>
<Box display="flex" style={{ alignItems: "center" }}>
<Title order={3} my="xs">
<CgEditBlackPoint style={{ marginBottom: -3 }} />{" "}
HttpResponse
</Title>
<Space w="sm" />
<HttpBadge />
</Box>
<Text size="lg">
This data is the Http response processed by nfqueue. This
handler can be called twice per response: once when the http
headers are complete, and once when the body is complete.
</Text>
<Text size="lg">
If the http data arrives in 1 single TCP packet, this handler
will be called once
</Text>
<Space h="sm" />
<Text size="lg" ml="xs">
<List>
<List.Item>
<strong>url: </strong> The url of the response (read
only)
</List.Item>
<List.Item>
<strong>headers: </strong> The headers of the response
(read only). The keys and values are exactly the same as
the original response (case sensitive). (values can be
list in case the same header field is repeated)
</List.Item>
<List.Item>
<strong>get_header(key:str, default = None): </strong> A
function that returns the value of a header: it matches
the key without case sensitivity. If the header is not
found, it returns the default value. (if the same header
field is repeated, its value is concatenated with a
comma, this function will never return a list)
</List.Item>
<List.Item>
<strong>user_agent: </strong> The user agent of the
response (read only)
</List.Item>
<List.Item>
<strong>content_encoding: </strong> The content encoding
of the response (read only)
</List.Item>
<List.Item>
<strong>content_length: </strong> The content length of
the response (read only)
</List.Item>
<List.Item>
<strong>body: </strong> The body of the response (read
only). It's None if the body has not arrived yet.
</List.Item>
<List.Item>
<strong>body_decoded: </strong> By default the body will
be decoded following the content encoding. gzip, br,
deflate and zstd are supported. If the decoding fails
and body is not None this paramether will be False.
</List.Item>
<List.Item>
<strong>http_version: </strong> The http version of the
response (read only)
</List.Item>
<List.Item>
<strong>keep_alive: </strong> It's true if the
connection was marked for keep alive, false if it's not.
(read only)
</List.Item>
<List.Item>
<strong>should_upgrade: </strong> It's true if the
connection should be upgraded, false if it's not. (read
only)
</List.Item>
<List.Item>
<strong>upgrading_to_h2: </strong> It's true if the
connection is upgrading to h2, false if it's not. (read
only)
</List.Item>
<List.Item>
<strong>ws_stream: </strong> It's a list of
websockets.frames.Frame decoded (permessage-deflate is
supported). (read only) [
<a href="https://websockets.readthedocs.io/en/stable/">
docs
</a>
]
</List.Item>
<List.Item>
<strong>upgrading_to_ws: </strong> It's true if the
connection is upgrading to ws, false if it's not. (read
only)
</List.Item>
<List.Item>
<strong>status_code: </strong> The status code of the
response (read only) (int)
</List.Item>
<List.Item>
<strong>headers_complete: </strong> It's true if the
headers are complete, false if they are not. (read only)
</List.Item>
<List.Item>
<strong>message_complete: </strong> It's true if the
message is complete, false if it's not. (read only)
</List.Item>
<List.Item>
<strong>total_size: </strong> The size of the entire
http response (read only)
</List.Item>
<List.Item>
<strong>stream: </strong> It's the buffer that contains
the stream of the websocket traffic in output. This is
used only if should_upgrade is True. (read only)
</List.Item>
</List>
</Text>
<Box display="flex" style={{ alignItems: "center" }}>
<Title order={3} my="xs">
<CgEditBlackPoint style={{ marginBottom: -3 }} />{" "}
HttpResponseHeader
</Title>
<Space w="sm" />
<HttpBadge />
</Box>
<Text size="lg">
Same as HttpResponse, but this handler is called only when the
headers are complete and body is not buffered. Body will always
be None
</Text>
<Box display="flex" style={{ alignItems: "center" }}>
<Title order={3} my="xs">
<CgEditBlackPoint style={{ marginBottom: -3 }} />{" "}
HttpFullResponse
</Title>
<Space w="sm" />
<HttpBadge />
</Box>
<Text size="lg">
Same as HttpResponse, but this handler is called only when the
response data is complete
</Text>
<Title order={2} mt="lg" mb="sm">
⚠️ Stream Limiter
</Title>
<Text size="lg" my="xs">
What happen if in a specific TCP stream you have a lot of data?
The stream limiter will be activated and some action will be
taken. You can configure the action performed by setting some
option in the globals:
<br />
<Space h="sm" />
First import the FullStreamAction enum:
<CodeHighlight
code={IMPORT_FULL_ACTION_STREAM}
language="python"
my="sm"
/>
Then you can set in the globals these options:
<List>
<List.Item>
<strong>FGEX_STREAM_MAX_SIZE: </strong> Sets the maximum
size of the stream. If the stream exceeds this size, the
FGEX_FULL_STREAM_ACTION will be performed. (this limit
is applyed at the single stream related to the single
data handler). For example if TCPInputStream has reached
the limit but HttpResponse has not, the action will be
performed only on the TCPInputStream. The default is
1MB.
</List.Item>
<List.Item>
<strong>FGEX_FULL_STREAM_ACTION: </strong> Sets the
action performed when the stream exceeds the
FGEX_STREAM_MAX_SIZE. The default is
FullStreamAction.FLUSH.
</List.Item>
</List>
Heres will be explained every type of action you can set:
<List>
<List.Item>
<strong>FLUSH: </strong> Flush the stream and continue
to acquire new packets (default)
</List.Item>
<List.Item>
<strong>DROP: </strong> Drop the next stream packets -
like a DROP action by filter
</List.Item>
<List.Item>
<strong>REJECT: </strong> Reject the stream and close
the connection - like a REJECT action by filter
</List.Item>
<List.Item>
<strong>ACCEPT: </strong> Stops to call pyfilters and
accept the traffic
</List.Item>
</List>
</Text>
<Title order={2} mt="lg" mb="sm">
⚠️ Other Options
</Title>
<Text size="lg" my="xs">
Here's other enums that you could need to use:
<CodeHighlight
code={ENUM_IMPORT_AND_DEFINITION}
language="python"
my="sm"
/>
Then you can set in the globals these options:
<List>
<List.Item>
<strong>FGEX_INVALID_ENCODING_ACTION: </strong> Sets the
action performed when the stream has an invalid encoding
(due to a parser crash). The default is
ExceptionAction.REJECT.
</List.Item>
</List>
</Text>
<Title order={2} mt="lg" mb="sm">
🚀 How It Works
</Title>
<Text mb="sm" size="lg">
The proxy is built on a multi-threaded architecture and
integrates Python for dynamic filtering:
</Text>
<List>
<List.Item>
<Text size="lg">
<strong>Packet Interception: </strong>
The{" "}
<a href="https://netfilter.org/projects/libnetfilter_queue/">
nfqueue
</a>{" "}
kernel module intercepts network packets(a{" "}
<a href="https://netfilter.org/">netfilter</a> module)
🔍
<br />
The rules for attach the nfqueue on the network traffic
is done by the nftables lib with json APIs by the python
manager.
</Text>
</List.Item>
<List.Item>
<Text size="lg">
<strong>Packet Reading: </strong>A dedicated thread
reads packets from{" "}
<a href="https://netfilter.org/projects/libnetfilter_queue/">
nfqueue
</a>
. 🧵
</Text>
</List.Item>
<List.Item>
<Text size="lg">
<strong>Multi-threaded Analysis: </strong>
The C++ binary launches multiple threads, each starting
its own Python interpreter. Thanks to Python 3.12’s
support for{" "}
<a href="https://peps.python.org/pep-0684/">
a per-interpeter GIL
</a>
, real multithreading is achieved. Traffic is
distributed among threads based on IP addresses and port
hashing, ensuring that packets belonging to the same
flow are processed by the same thread. ⚡️
</Text>
</List.Item>
<List.Item>
<Text size="lg">
<strong>Python Filter Integration: </strong>
Users can upload custom Python filters which are then
executed by the interpreter, allowing for dynamic and
flexible packet handling. 🐍
</Text>
</List.Item>
<List.Item>
<Text size="lg">
<strong>HTTP Parsing: </strong>
<a href="https://github.com/domysh/pyllhttp">
A Python wrapper for llhttp
</a>{" "}
(forked and adapted for working with multi-interpeters)
is used to parse HTTP connections, making it easier to
handle and analyze HTTP traffic. 📡
</Text>
</List.Item>
</List>
<Space h="xl" />
<Title order={2} mt="lg" mb="sm">
📚 Additional Resources
</Title>
<Text size="lg">
Here's a pyfilter code commented example:
<CodeHighlight
code={EXAMPLE_PYFILTER}
language="python"
my="sm"
/>
</Text>
</>
);
};

View File

@ -121,7 +121,7 @@ export const EXAMPLE_PYFILTER = `# This in an example of a filter file with http
# From here we can import the DataTypes that we want to use: # From here we can import the DataTypes that we want to use:
# The data type must be specified in the filter functions # The data type must be specified in the filter functions
# And will also interally be used to decide when call some filters and how aggregate data # And will also interally be used to decide when call some filters and how aggregate data
from firegex.nfproxy.models import RawPacket from firegex.nfproxy.models import RawPacket, HttpRequest
# global context in this execution is dedicated to a single TCP stream # global context in this execution is dedicated to a single TCP stream
# - This code will be executed once at the TCP stream start # - This code will be executed once at the TCP stream start
@ -165,7 +165,7 @@ def strange_filter(packet:RawPacket):
# Example with a higher level of abstraction # Example with a higher level of abstraction
@pyfilter @pyfilter
def http_filter(http:HTTPRequest): def http_filter(http:HttpRequest):
if http.method == "GET" and "test" in http.url: if http.method == "GET" and "test" in http.url:
return REJECT return REJECT
@ -184,7 +184,7 @@ def http_filter(http:HTTPRequest):
# - FullStreamAction.REJECT: Reject the stream and close the connection - like a REJECT action by filter # - FullStreamAction.REJECT: Reject the stream and close the connection - like a REJECT action by filter
# - FullStreamAction.ACCEPT: Stops to call pyfilters and accept the traffic # - FullStreamAction.ACCEPT: Stops to call pyfilters and accept the traffic
from firege.nfproxy import FullStreamAction from firegex.nfproxy import FullStreamAction
# Example of a global context # Example of a global context
FGEX_STREAM_MAX_SIZE = 4096 FGEX_STREAM_MAX_SIZE = 4096

View File

@ -1,69 +1,4 @@
import { Container, Title, Text, List } from "@mantine/core"; import { MarkdownDocs } from "../MarkdownDocs";
import docsMarkdown from "../../../../docs/nfregex.md?raw";
export const NFRegexDocs = () => { export const NFRegexDocs = () => <MarkdownDocs content={docsMarkdown} sourcePath="docs/nfregex.md" />;
return (
<>
<Title order={1}>📡 Netfilter Regex Documentation</Title>
<Title order={2} mt="xl" mb="sm">📖 Overview</Title>
<Text size="lg">
Netfilter Regex is a powerful feature that enables filtering of network packets using regular expressions. This capability is especially useful when you need to inspect packet content and match specific strings or patterns.
</Text>
<Title order={2} mt="lg" mb="sm">⚙️ How to Use Netfilter Regex</Title>
<Text size="lg">
To get started, create a service and attach a regular expression to it. Once the service is configured, apply it to a network interface to dynamically filter packets based on the defined regex.
</Text>
<Title order={2} mt="lg" mb="sm">🚀 How It Works</Title>
<Text mb="sm" size="lg">
The packet filtering process is implemented in C++ and involves several key steps:
</Text>
<List>
<List.Item>
<Text size="lg">
<strong>Packet Interception: </strong>
The <a href="https://netfilter.org/projects/libnetfilter_queue/">nfqueue</a> kernel module intercepts network packets (a <a href="https://netfilter.org/">netfilter</a> module) 🔍<br />
The rules for attach the nfqueue on the network traffic is done by the nftables lib with json APIs by the python manager.
</Text>
</List.Item>
<List.Item>
<Text size="lg">
<strong>Packet Reading: </strong>
A dedicated thread reads packets from <a href="https://netfilter.org/projects/libnetfilter_queue/">nfqueue</a>. 🧵
</Text>
</List.Item>
<List.Item>
<Text size="lg">
<strong>Packet Parsing: </strong>
Intercepted packets are parsed by <a href="https://libtins.github.io/">libtins</a>, a C++ library that extracts the payload from each packet. 📄
</Text>
</List.Item>
<List.Item>
<Text size="lg">
<strong>Multi-threaded Analysis: </strong>
Multiple threads analyze packets concurrently.
While the <a href="https://netfilter.org/projects/libnetfilter_queue/">nfqueue</a> module balances network
load based solely on IP addresses—resulting in a single thread handling traffic in NAT environments
like CTF networks, firegex manage this threads user-level in a different way.
The traffic is routed in the threads based on IP addresses combined with port hashing,
ensuring a more balanced workload and that flows will be analyzed by the same thread. ⚡️
</Text>
</List.Item>
<List.Item>
<Text size="lg">
<strong>TCP Handling: </strong>
For TCP connections, <a href="https://libtins.github.io/">libtins</a> employs a TCP follower to order packets received from the kernel. 📈
</Text>
</List.Item>
<List.Item>
<Text size="lg">
<strong>Regex Matching: </strong>
The extracted payload is processed using <a href="https://github.com/VectorCamp/vectorscan">vectorscan</a> — a fork of <a href="https://github.com/intel/hyperscan">hyperscan</a> that runs also on arm64.
For UDP packets, matching occurs on a per-packet basis while saving only the match context rather than the full payload. 🎯
</Text>
</List.Item>
</List>
</>
);
};

View File

@ -1,6 +1,6 @@
import { Divider, Group, MantineColor, ScrollArea, Text, ThemeIcon, Title, UnstyledButton, Box, AppShell } from "@mantine/core"; import { Divider, Group, MantineColor, ScrollArea, Text, ThemeIcon, Title, UnstyledButton, Box, AppShell } from "@mantine/core";
import { useState } from "react"; import { useState } from "react";
import { TbPlugConnected } from "react-icons/tb"; import { TbPlugConnected, TbShieldLock } from "react-icons/tb";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { GrDirections } from "react-icons/gr"; import { GrDirections } from "react-icons/gr";
@ -43,7 +43,7 @@ export default function NavBar() {
<NavBarButton navigate="firewall" closeNav={closeNav} name="Firewall Rules" color="red" icon={<PiWallLight size={19} />} /> <NavBarButton navigate="firewall" closeNav={closeNav} name="Firewall Rules" color="red" icon={<PiWallLight size={19} />} />
<NavBarButton navigate="porthijack" closeNav={closeNav} name="Hijack Port to Proxy" color="blue" icon={<GrDirections size={19} />} /> <NavBarButton navigate="porthijack" closeNav={closeNav} name="Hijack Port to Proxy" color="blue" icon={<GrDirections size={19} />} />
<NavBarButton navigate="nfproxy" closeNav={closeNav} name="Netfilter Proxy" color="lime" icon={<TbPlugConnected size={19} />} /> <NavBarButton navigate="nfproxy" closeNav={closeNav} name="Netfilter Proxy" color="lime" icon={<TbPlugConnected size={19} />} />
<NavBarButton navigate="tls-decrypt" closeNav={closeNav} name="TLS Decryption" color="cyan" icon={<TbPlugConnected size={19} />} /> <NavBarButton navigate="tls-decrypt" closeNav={closeNav} name="TLS Decryption" color="cyan" icon={<TbShieldLock size={19} />} />
<Box px="xs" mt="lg"> <Box px="xs" mt="lg">
<Title order={5}>Backup & Restore</Title> <Title order={5}>Backup & Restore</Title>
</Box> </Box>

View File

@ -1,37 +1,4 @@
import { CodeHighlight } from "@mantine/code-highlight" import { MarkdownDocs } from "../MarkdownDocs";
import { Code, Container, Space, Text, Title } from "@mantine/core" import docsMarkdown from "../../../../docs/porthijack.md?raw";
import { HELP_NFPROXY_SIM } from "../NFProxy/NFProxyDocs"
export const PortHijackDocs = () => <MarkdownDocs content={docsMarkdown} sourcePath="docs/porthijack.md" />;
export const PortHijackDocs = () => {
return <>
<Title order={1}>⚡️ Hijack port to proxy</Title>
<Title order={2} mt="xl" mb="sm">📖 Overview</Title>
<Text size="lg">
'Hijack port to proxy' uses <a href="https://netfilter.org/">nftables</a> to redirect the traffic from an external IP to a localhost server.
You are responsable to run and keep alive this server, that is your proxy. The original service will be accessible using loopback (127.0.0.1).
In this way you can run your custom proxy without touching the service configuration.
</Text>
<Title order={2} mt="lg" mb="sm">⚙️ How to use Hijack port to proxy</Title>
<Text size="lg">
To use this feature, simply create your proxy, run it, than create a new service and set the proxy port and the external ip and port.
The traffic will be redirected to your proxy, that will still be able to contact the original service using loopback.
The responses of your proxy will be redirected to the original client, and teh proxy will see as the requests were made by the original client.
<br /><Space h="sm" />
You can use for instance the proxy simulator of nfproxy feature of firegex, and run it using nfproxy features. This will advantage you if for instance you need to mangle the traffic.
changing packets it's possible but not sure to do with nfproxy, but the simulator can change the packets normally (on PacketRaw data is always == l4_data in the simulator, check the nfproxy docs for more info)
<br /><Space h="sm" />
You will need to install firegex library with <Code>pip install -U fgex</Code> and than use the simulator command
<CodeHighlight code={HELP_NFPROXY_SIM} language="" my="sm"/>
for instance: <Code>fgex nfproxy test_http.py 127.0.0.1 8080 --proto http --from-port 13377</Code>
</Text>
<Title order={2} mt="lg" mb="sm">🚀 How It Works</Title>
<Text mb="sm" size="lg">
This modules works in a simple way: this only thing done is to change the destination and source ip using <a href="https://netfilter.org/">nftables</a> rules so that the kernel will see that the request was done to the proxy port,
but externaly the packets exists as connections to the original service. This mangle is done only for external packet arriving from the external ip indicated, localhost traffic won't be touched.
</Text>
<Space h="xl" />
</>
}

View File

@ -0,0 +1,4 @@
import { MarkdownDocs } from "./MarkdownDocs";
import docsMarkdown from "../../../docs/tls.md?raw";
export const TLSDocs = () => <MarkdownDocs content={docsMarkdown} sourcePath="docs/tls.md" />;

View File

@ -22,6 +22,7 @@ import { IoSettingsSharp } from "react-icons/io5";
import { SettingsModal } from "./SettingsModal"; import { SettingsModal } from "./SettingsModal";
import { FaDirections } from "react-icons/fa"; import { FaDirections } from "react-icons/fa";
import { PiWallLight } from "react-icons/pi"; import { PiWallLight } from "react-icons/pi";
import { DocsButton } from "../../components/DocsButton";
export const Firewall = () => { export const Firewall = () => {
@ -352,6 +353,8 @@ export const Firewall = () => {
<ActionIcon color="cyan" onClick={() => setSettingsModal(true)} size="lg" radius="md" variant="filled"><IoSettingsSharp size={18} /></ActionIcon> <ActionIcon color="cyan" onClick={() => setSettingsModal(true)} size="lg" radius="md" variant="filled"><IoSettingsSharp size={18} /></ActionIcon>
</Tooltip> </Tooltip>
<Space w="xs" /> <Space w="xs" />
<DocsButton doc="firewall" />
<Space w="xs" />
<Tooltip label="Apply" position='bottom' color="grape"> <Tooltip label="Apply" position='bottom' color="grape">
<ActionIcon color="grape" onClick={applyChanges} size="lg" radius="md" variant="filled" disabled={!valuesChanged}> <ActionIcon color="grape" onClick={applyChanges} size="lg" radius="md" variant="filled" disabled={!valuesChanged}>
<TiTick size={22} /></ActionIcon> <TiTick size={22} /></ActionIcon>

View File

@ -8,6 +8,7 @@ import { MdAdd } from "react-icons/md";
import { getapi, postapi, putapi, deleteapi, errorNotify, okNotify } from "../../js/utils"; import { getapi, postapi, putapi, deleteapi, errorNotify, okNotify } from "../../js/utils";
import PortAndInterface from "../../components/PortAndInterface"; import PortAndInterface from "../../components/PortAndInterface";
import { TbShieldLock } from "react-icons/tb"; import { TbShieldLock } from "react-icons/tb";
import { DocsButton } from "../../components/DocsButton";
export const tlsStreamsQueryKey = ["tls_streams"]; export const tlsStreamsQueryKey = ["tls_streams"];
@ -153,7 +154,10 @@ const TLSDecrypt = () => {
<Box p="md"> <Box p="md">
<Group justify="space-between" mb="md"> <Group justify="space-between" mb="md">
<Title order={2}>TLS Decryption Streams</Title> <Title order={2}>TLS Decryption Streams</Title>
<Button leftSection={<MdAdd />} onClick={openAdd}>Add Stream</Button> <Group gap="xs">
<DocsButton doc="tls" />
<Button leftSection={<MdAdd />} onClick={openAdd}>Add Stream</Button>
</Group>
</Group> </Group>
<Text c="dimmed" mb="xl"> <Text c="dimmed" mb="xl">

View File

@ -8,4 +8,8 @@ export default defineConfig({
resolve: { resolve: {
tsconfigPaths: true, tsconfigPaths: true,
}, },
server: {
// Allows reading ../fgex-lib/README.md (the nfproxy docs source) from outside the project root
fs: { allow: ['..'] },
},
}) })

10
run.py
View File

@ -116,6 +116,13 @@ def load_config():
for key, value in default_config.items(): for key, value in default_config.items():
if key not in config: if key not in config:
config[key] = value config[key] = value
# port/host always need a concrete value: a stale/explicit null in the
# config file (unlike socket_dir/allowed_ips/proxy_ip_header, which are
# legitimately optional) must fall back to the default, not stay None.
if config.get("port") is None:
config["port"] = default_config["port"]
if config.get("host") is None:
config["host"] = default_config["host"]
return config return config
except (json.JSONDecodeError, IOError) as e: except (json.JSONDecodeError, IOError) as e:
puts(f"Warning: Failed to load config file {g.configfile}: {e}", color=colors.yellow) puts(f"Warning: Failed to load config file {g.configfile}: {e}", color=colors.yellow)
@ -1196,7 +1203,8 @@ def main():
case "config": case "config":
handle_config_command(args) handle_config_command(args)
write_compose() if args.command not in ("config", "status"):
write_compose()
if args.clear: if args.clear:
if volume_exists(): if volume_exists():

147
tests/ipfilter_test.py Normal file
View File

@ -0,0 +1,147 @@
#!/usr/bin/env python3
"""
Verifies the IP-based access control middleware (IPFilterMiddleware in backend/app.py),
configured via `run.py`'s --allowed-ips / --proxy-ip-header flags (ALLOWED_IPS / PROXY_IP_HEADER
env vars).
Unlike the rest of the test suite, this script manages firegex's own start/stop lifecycle,
since ALLOWED_IPS/PROXY_IP_HEADER are only read once at process startup (no live reload) -
each scenario needs a fresh restart with different flags. Must be run on the same Linux/Docker
host firegex runs on (same requirements as run.py itself).
"""
import argparse
import os
import socket
import subprocess
import sys
import time
import requests
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
BASE = "http://127.0.0.1:4444/"
failures = []
def check(name, cond, detail=""):
if cond:
print(f"\033[32mPASS: {name}\033[0m")
else:
print(f"\033[31mFAIL: {name}{' - ' + detail if detail else ''}\033[0m")
failures.append(name)
def run_py(*args, timeout=180):
res = subprocess.run(["python3", "run.py", *args], cwd=REPO_ROOT, timeout=timeout)
return res.returncode
def wait_ready(timeout=90):
start = time.time()
while time.time() - start < timeout:
try:
# A bare /api/status request is never blocked by design considerations of this
# test (it's the endpoint we probe), so poll it via a raw request expecting
# either 200 or 403 - both mean "the app answered", unlike a connection error.
r = requests.get(BASE + "api/status", timeout=2)
if r.status_code in (200, 403):
return True
except requests.RequestException:
pass
time.sleep(1)
return False
def restart_with(allowed_ips=None, proxy_ip_header=None):
print(f"\n--- restarting firegex (allowed_ips={allowed_ips!r}, proxy_ip_header={proxy_ip_header!r}) ---")
run_py("stop")
args = ["start", "-P", "testpassword"]
# An empty string still needs to be passed explicitly to clear a previous value.
if allowed_ips is not None:
args += ["--allowed-ips", allowed_ips]
if proxy_ip_header is not None:
args += ["--proxy-ip-header", proxy_ip_header]
run_py(*args)
if not wait_ready():
print("FATAL: firegex didn't come back up after restart")
sys.exit(1)
def raw_ws_handshake_rejected(path="/sock/socket.io/?EIO=4&transport=websocket", extra_headers=None, timeout=5):
"""Best-effort check: performs a raw WS upgrade handshake and reports whether the
server completed it (101 Switching Protocols) or refused/closed the connection.
Returns True if the handshake looks rejected, False if it looks accepted, None if
inconclusive (e.g. connection reset before any bytes came back)."""
lines = [
f"GET {path} HTTP/1.1",
"Host: 127.0.0.1:4444",
"Upgrade: websocket",
"Connection: Upgrade",
"Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==",
"Sec-WebSocket-Version: 13",
]
for k, v in (extra_headers or {}).items():
lines.append(f"{k}: {v}")
req = "\r\n".join(lines) + "\r\n\r\n"
try:
with socket.create_connection(("127.0.0.1", 4444), timeout=timeout) as s:
s.sendall(req.encode())
data = s.recv(4096)
except (ConnectionResetError, OSError):
return True
if not data:
return True
return b"101" not in data.split(b"\r\n", 1)[0]
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--skip-cleanup-restart", action="store_true",
help="Don't restart firegex without restrictions at the end (leaves the last scenario's config applied)")
args = parser.parse_args()
try:
# 1. No restriction configured -> unrestricted access (backward compatible default)
restart_with(allowed_ips="")
r = requests.get(BASE + "api/status")
check("no ALLOWED_IPS configured -> request succeeds", r.status_code == 200, f"got {r.status_code}")
# 2. Restrict to a network that excludes the loopback test client -> blocked
restart_with(allowed_ips="203.0.113.0/24")
r = requests.get(BASE + "api/status")
check("ALLOWED_IPS excludes client -> 403", r.status_code == 403, f"got {r.status_code}")
ws_rejected = raw_ws_handshake_rejected()
check("ALLOWED_IPS excludes client -> websocket upgrade rejected", ws_rejected is True, f"result={ws_rejected}")
# 3. Restrict to a network (CIDR) that includes the loopback test client -> allowed
restart_with(allowed_ips="127.0.0.1/32,::1/128")
r = requests.get(BASE + "api/status")
check("ALLOWED_IPS includes client (CIDR) -> 200", r.status_code == 200, f"got {r.status_code}")
# 4. PROXY_IP_HEADER: the header value is trusted over the raw TCP peer
restart_with(allowed_ips="203.0.113.55/32", proxy_ip_header="X-Forwarded-For")
r = requests.get(BASE + "api/status", headers={"X-Forwarded-For": "203.0.113.55"})
check("PROXY_IP_HEADER value matches allowlist -> 200", r.status_code == 200, f"got {r.status_code}")
r = requests.get(BASE + "api/status", headers={"X-Forwarded-For": "8.8.8.8"})
check("PROXY_IP_HEADER value NOT in allowlist -> 403", r.status_code == 403, f"got {r.status_code}")
r = requests.get(BASE + "api/status") # header absent -> falls back to raw peer (127.0.0.1, not allowed here)
check("PROXY_IP_HEADER configured but header absent -> falls back to raw peer -> 403",
r.status_code == 403, f"got {r.status_code}")
# 5. Malformed value in the trusted header must fail CLOSED (deny), not open -
# otherwise a client that can reach the port directly could send a garbage header
# value to bypass the allowlist entirely.
r = requests.get(BASE + "api/status", headers={"X-Forwarded-For": "not-an-ip"})
check("malformed PROXY_IP_HEADER value -> fails closed (403)", r.status_code == 403, f"got {r.status_code}")
finally:
if not args.skip_cleanup_restart:
restart_with(allowed_ips="")
print()
if failures:
print(f"\033[31m{len(failures)} check(s) failed: {failures}\033[0m")
sys.exit(1)
else:
print("\033[32mAll IP filter checks passed\033[0m")