Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 12 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@

Finch is a lightweight reverse proxy written in Go. It inspects TLS handshakes and HTTP requests to extract JA3, JA4, JA4H, and Akamai HTTP/2 fingerprints, then evaluates them—alongside the rest of the request metadata—against flexible, hot‑reloadable rules written in HCL. On a per‑request basis, Finch can:

- **allow** legitimate traffic
- **deny** or **tarpit** scanners
- **route** clients to alternate upstreams
- **allow** legitimate traffic
- **deny** unwanted traffic
- **route** clients to alternate upstreams
- **deceive** attackers with on‑the‑fly, LLM‑generated responses via [Galah](https://github.com/0x4D31/galah)
- **tarpit** scanners with slow drip responses

Finch also offers an authenticated admin API for live configuration and rule updates, a real‑time SSE feed for observability, Suricata HTTP rule matching, and an echo mode for testing or dataset collection. Experimental HTTP/3 and QUIC fingerprinting support is included. Use Finch to block scrapers and other unwanted traffic, slow down scanners, or deploy dynamic honeypots.

Expand All @@ -18,9 +19,10 @@ Finch also offers an authenticated admin API for live configuration and rule upd
### Key Features

- **Fingerprint extraction** – Capture [JA3](https://engineering.salesforce.com/tls-fingerprinting-with-ja3-and-ja3s-247362855967/), [JA4](https://github.com/FoxIO-LLC/ja4/blob/main/technical_details/JA4.md), [JA4H](https://github.com/FoxIO-LLC/ja4/blob/main/technical_details/JA4H.md), and [Akamai HTTP/2](https://www.blackhat.com/docs/eu-17/materials/eu-17-Shuster-Passive-Fingerprinting-Of-HTTP2-Clients-wp.pdf) fingerprints for every request, providing high‑fidelity identification of client libraries and TLS stacks.
- **Flexible rule engine** – Define policies using [HCL](https://github.com/hashicorp/hcl) that evaluate fingerprints, HTTP methods and paths, header values, source‑IP ranges, and Suricata alert messages. Rules can nest `when all` / `when any` blocks and decide whether to `allow`, `deny`, `route`, or `deceive` each request. See the [Rule Schema](docs/rule-schema.md) for details.
- **Flexible rule engine** – Define policies using [HCL](https://github.com/hashicorp/hcl) that evaluate fingerprints, HTTP methods and paths, header values, source‑IP ranges, and Suricata alert messages. Rules can nest `when all` / `when any` blocks and decide whether to `allow`, `deny`, `route`, `tarpit`, or `deceive` each request. See the [Rule Schema](docs/rule-schema.md) for details.
- **Suricata HTTP rules** – Finch loads Suricata `.rules`, evaluates requests against them, hot‑reloads rules on changes, logs matches, and exposes each matched rule’s `msg` to your HCL policies—no Suricata installation needed.
- **Deception mode** – Trigger the `deceive` action to serve LLM‑generated honeypot responses via [Galah](https://github.com/0x4D31/galah) or slow scanners with a configurable tarpit.
- **Deception** – Trigger the `deceive` action to serve LLM‑generated honeypot responses via [Galah](https://github.com/0x4D31/galah).
- **Tarpit** – Frustrate scanners with configurable slow responses.
- **Live event feed** – Stream structured JSON events over Server‑Sent Events (SSE) for immediate visibility.
- **Echo mode** – Run a standalone server that echoes fingerprint data—ideal for testing and dataset collection.
- **Multiple listeners & hot reloads** – Define multiple listeners, each with its own upstream, TLS certificate, rule file, and log. Finch hot‑reloads configuration and rule files automatically whenever they change or when it receives `SIGHUP`.
Expand Down Expand Up @@ -138,7 +140,7 @@ See [Configuration](docs/configuration.md) for a detailed description of every f

## Rule Engine

Rules are written in HCL and define an `action` (`allow`, `deny`, `route` or `deceive`), optional metadata (e.g. `upstream`, `strip_prefix`, `expires`, `deception_mode`) and a `when` block containing conditions. The top‑level `when` block can be labelled `when all` (AND) or `when any` (OR); omitting the label implies `all`.
Rules are written in HCL and define an `action` (`allow`, `deny`, `route`, `deceive` or `tarpit`), optional metadata (e.g. `upstream`, `strip_prefix`, `expires`, `deception_mode`) and a `when` block containing conditions. The top‑level `when` block can be labelled `when all` (AND) or `when any` (OR); omitting the label implies `all`.

Condition fields include TLS and HTTP fingerprints, HTTP methods and paths, header maps, client IP ranges and Suricata messages. Prefix (`^`), exact (`=`) and regex (`~`) matching operators are supported. See [Rule Schema](docs/rule-schema.md) for full details and examples.

Expand All @@ -153,10 +155,9 @@ rule "block-scanner-ja3" {
}
}

# Deceive anything that matches an evasive JA4 profile
rule "deception-rule" {
action = "deceive"
deception_mode = "tarpit" # or "galah" for AI‑generated responses
# Slow anything that matches an evasive JA4 profile
rule "tarpit-rule" {
action = "tarpit"

when any { # match if any field is true
tls_ja4 = ["q13d0312h3_55b375c5d22e_c183556c78e2"]
Expand Down Expand Up @@ -237,4 +238,4 @@ This tool is intended to help defenders detect, filter, or deceive unwanted traf

## License

Finch is developed by Adel “0x4D31” Ka and licensed under the [Apache License 2.0](LICENSE). It relies on the [fingerproxy](https://github.com/wi1dcard/fingerproxy) package, also licensed under Apache‑2.0. The integrated JA4H fingerprinting method is licensed under the [FoxIO License 1.1](https://github.com/FoxIO-LLC/ja4/blob/main/LICENSE) and includes certain usage restrictions.
Finch is developed by Adel “0x4D31” Ka and licensed under the [Apache License 2.0](LICENSE). It relies on the [fingerproxy](https://github.com/wi1dcard/fingerproxy) package, also licensed under Apache‑2.0. The integrated JA4H fingerprinting method is licensed under the [FoxIO License 1.1](https://github.com/FoxIO-LLC/ja4/blob/main/LICENSE) and includes certain usage restrictions.
6 changes: 2 additions & 4 deletions configs/default.rules.hcl
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,7 @@ rule "route-safari" {

# 3. Tarpit curl‑like requests hitting /yo
rule "tarpit-curl-sus" {
action = "deceive"
deception_mode = "tarpit"
action = "tarpit"

when all {
tls_ja3 = ["4f2655722e37c542ebeaf1eed48cbbbb"]
Expand All @@ -33,8 +32,7 @@ rule "tarpit-curl-sus" {

# 4. Deceive any request that triggers a Suricata HTTP rule
rule "deceive-suri-match" {
action = "deceive"
deception_mode = "galah"
action = "deceive"

when {
suricata_msg = ["~ .+"] # match if the message is non‑empty
Expand Down
18 changes: 8 additions & 10 deletions docs/rule-schema.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Rule Specification

Finch rules are written in HCL. Each rule defines an `action` (`allow`, `deny`, `route` or `deceive`) and optional metadata such as an `upstream` URL, `strip_prefix`, `expires` timestamp or `deception_mode`. Conditions are grouped under a single `when` block; additional nested `when` blocks may be added for complex logic. If no label is specified the default aggregator is `all` (logical AND). A rule with multiple top‑level `when` blocks is invalid.
Finch rules are written in HCL. Each rule defines an `action` (`allow`, `deny`, `route`, `deceive` or `tarpit`) and optional metadata such as an `upstream` URL, `strip_prefix`, `expires` timestamp or `deception_mode`. Conditions are grouped under a single `when` block; additional nested `when` blocks may be added for complex logic. If no label is specified the default aggregator is `all` (logical AND). A rule with multiple top‑level `when` blocks is invalid.

## Condition fields

Expand Down Expand Up @@ -84,24 +84,22 @@ rule "static" {

## Deception Mode

The `deceive` action instructs Finch to generate a fake HTTP response instead of forwarding the request. The response is produced by an external deception service and controlled via the optional `deception_mode` attribute. Supported modes:

1. `galah` – Use [Galah](https://github.com/0x4D31/galah) to generate LLM-based responses. This is the default mode when `deception_mode` is omitted. A `galah` block must be present in the configuration; otherwise Finch exits with an error.
2. `tarpit` – Send a trickle of random bytes for 45–120 seconds (configurable) to frustrate scanners. Concurrent responses are limited (16 by default).
3. `agent` *(upcoming)* – Serve static responses crafted ahead of time by a local AI agent. For unknown paths the agent returns a stub response and later learns a realistic one.

The `deceive` action instructs Finch to generate a fake HTTP response instead of forwarding the request. The response is produced by an external deception service and controlled via the optional `deception_mode` attribute. Currently the only supported mode is `galah`, which is used by default when `deception_mode` is omitted. A `galah` block must be present in the configuration; otherwise Finch exits with an error.

Example using Galah:

```hcl
rule "honeypot" {
action = "deceive"
deception_mode = "galah"
action = "deceive"

when {
http_path = ["/admin"]
}
}
```

Refer to the Galah documentation for configuration options.
Refer to the Galah documentation for configuration options.

## Tarpit Action

The `tarpit` action sends a trickle of random bytes for 45–120 seconds (configurable) to frustrate scanners. Concurrent responses are limited (16 by default).
10 changes: 7 additions & 3 deletions internal/proxy/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ func emojiForAction(action rules.Action) string {
return "↪️"
case rules.ActionDeceive:
return "🎭"
case rules.ActionTarpit:
return "🐌"
default:
return "❓"
}
Expand Down Expand Up @@ -464,8 +466,10 @@ func (h *ruleHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
var deceiveErr error
deceptionMode := ""
respSource := ""
if action == rules.ActionDeceive && matchedRule != nil {
deceptionMode = matchedRule.DeceptionMode
if action == rules.ActionDeceive {
if matchedRule != nil {
deceptionMode = matchedRule.DeceptionMode
}
if deceptionMode == "" {
deceptionMode = "galah"
}
Expand Down Expand Up @@ -647,7 +651,7 @@ func (h *ruleHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusForbidden)
return
}
if action == rules.ActionDeceive && matchedRule != nil && matchedRule.DeceptionMode == "tarpit" {
if action == rules.ActionTarpit {
tarpitResponder.ServeHTTP(w, r)
return
}
Expand Down
2 changes: 1 addition & 1 deletion internal/proxy/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -537,7 +537,7 @@ func TestServer_TarpitConcurrency(t *testing.T) {
tarpitLimit = oldLimit
}()

rule := rules.Rule{ID: "tp", Action: rules.ActionDeceive, DeceptionMode: "tarpit",
rule := rules.Rule{ID: "tp", Action: rules.ActionTarpit,
Expr: rules.Cond{Matcher: func(*fingerprint.RequestCtx) bool { return true }},
}
eng := &rules.Engine{Rules: []*rules.Rule{&rule}, DefaultAction: rules.ActionAllow}
Expand Down
2 changes: 1 addition & 1 deletion internal/proxy/tarpit.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import (
// tarpit responses. Tests may override this value.
var tarpitLimit = make(chan struct{}, 16)

// tarpitResponder is the shared handler used for the "tarpit" deception mode.
// tarpitResponder is the shared handler used for the tarpit action.
var tarpitResponder = newTarpitHandler(TarpitConfig{Concurrency: tarpitLimit})

// TarpitConfig configures the tarpit handler.
Expand Down
1 change: 1 addition & 0 deletions internal/rules/action.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,5 @@ const (
ActionDeny Action = "deny"
ActionRoute Action = "route"
ActionDeceive Action = "deceive"
ActionTarpit Action = "tarpit"
)
4 changes: 2 additions & 2 deletions internal/rules/hcl.go
Original file line number Diff line number Diff line change
Expand Up @@ -294,7 +294,7 @@ func compileRule(rb ruleBlock) (*Rule, error) {
return nil, fmt.Errorf("rule %s: missing action", rb.Name)
}
switch rb.Action {
case string(ActionAllow), string(ActionDeny), string(ActionRoute), string(ActionDeceive):
case string(ActionAllow), string(ActionDeny), string(ActionRoute), string(ActionDeceive), string(ActionTarpit):
r.Action = Action(rb.Action)
default:
return nil, fmt.Errorf("rule %s: invalid action", rb.Name)
Expand All @@ -321,7 +321,7 @@ func compileRule(rb ruleBlock) (*Rule, error) {
if mode == "" {
mode = "galah"
}
if mode != "galah" && mode != "agent" && mode != "tarpit" {
if mode != "galah" {
return nil, fmt.Errorf("rule %s: invalid deception_mode", rb.Name)
}
r.DeceptionMode = mode
Expand Down
47 changes: 35 additions & 12 deletions internal/rules/hcl_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -513,22 +513,13 @@ func TestLoadHCLDeceiveModes(t *testing.T) {
want: "galah",
},
{
name: "agent-mode",
name: "explicit-galah",
hcl: `rule "d" {
action = "deceive"
deception_mode = "agent"
deception_mode = "galah"
when {}
}`,
want: "agent",
},
{
name: "tarpit-mode",
hcl: `rule "d" {
action = "deceive"
deception_mode = "tarpit"
when {}
}`,
want: "tarpit",
want: "galah",
},
}

Expand Down Expand Up @@ -563,6 +554,38 @@ func TestLoadHCLDeceiveModes(t *testing.T) {
}
}

func TestLoadHCLTarpitAction(t *testing.T) {
hcl := `rule "t" {
action = "tarpit"
when {}
}`
tmp, err := os.CreateTemp(t.TempDir(), "rule-*.hcl")
if err != nil {
t.Fatalf("temp: %v", err)
}
if _, err := tmp.WriteString(hcl); err != nil {
t.Fatalf("write: %v", err)
}
if err := tmp.Close(); err != nil {
t.Fatalf("close: %v", err)
}

rs, err := LoadHCL(tmp.Name())
if err != nil {
t.Fatalf("load: %v", err)
}
if len(rs.Rules) != 1 {
t.Fatalf("expected 1 rule")
}
r := rs.Rules[0]
if r.Action != ActionTarpit {
t.Fatalf("want action tarpit got %s", r.Action)
}
if r.DeceptionMode != "" {
t.Fatalf("expected empty deception_mode got %s", r.DeceptionMode)
}
}

func TestLoadHCLNestedWhenAny(t *testing.T) {
hcl := `rule "nested" {
action = "deny"
Expand Down