Smart Firewall
The Smart Firewall blocks by who the client is, not by which address it happens to be using today. Rules match JA4+ fingerprints and address expressions, and the result is installed into the kernel firewall — so enforcement costs a kernel map lookup, not a request.
It sits between the two things either side of it:
| Matches on | Where | |
|---|---|---|
| Access rules | A static address, CIDR, country or ASN list | XDP, before routing |
| Smart Firewall | Fingerprints and expressions over them | Kernel, after evaluation |
| WAF | The HTTP request | In the proxy, after decryption |
What a rule can match
Rules are wirefilter expressions over the fingerprint scheme — the whole JA4+ family, plus the source address:
ja4t == "64240_2-4-8-1-3_1460_10"
ja4 in {"t13d3112h2_e8f1e7e78f70_..." "t13d301100_..."} and ip.src in 5.0.0.0/8
Sets use in { }, addresses take CIDR ranges, and any() / all() reduce an array
comparison to a boolean.
Supported fields
The scheme carries 87 fields. Every one is optional — a single packet rarely has all of them, and a rule referencing a field that was never populated simply does not match.
Network and transport
| Field | Type | |
|---|---|---|
ip.src, ip.dst | IP | Source and destination address |
ip.proto | Bytes | IP protocol |
tcp.src_port, tcp.dst_port | Int | TCP ports |
udp.src_port, udp.dst_port | Int | UDP ports |
tcp.ttl, tcp.window, tcp.mss, tcp.window_scale | Int | The raw inputs JA4T is built from, for when you want one component rather than the whole fingerprint |
Application
| Field | Type | |
|---|---|---|
tls.sni, tls.alpn | Bytes | From the TLS ClientHello |
http.method, http.host, http.path, http.user_agent | Bytes | Request line and headers |
dns.fqdn | Bytes | Destination hostname resolved from a snooped DNS answer — this is what makes egress-by-domain rules possible: `dns.fqdn matches "(^ |
Fingerprints
All nine JA4+ identifiers are Bytes, so they take eq, in { }, contains, wildcard
and matches:
ja4 · ja4s · ja4t · ja4ts · ja4h · ja4l · ja4ls · ja4x · ja4ssh
The latency and SSH fingerprints are also decomposed into integers, so you can express a band rather than match a literal:
| Family | Fields |
|---|---|
| Latency | ja4l.rtt_us, ja4l.ttl, ja4ls.rtt_us, ja4ls.ttl |
| Latency delta | ja4l.delta_us, .delta_abs_us, .delta_ms |
| App-handshake timing | ja4l.app_rtt_us, ja4ls.app_rtt_us, and tcp_app_delta_us / _abs_us / _ms on both |
| SSH counts | ja4ssh.c2s_pkts, .s2c_pkts, .c2s_acks, .s2c_acks, .c2s_bytes, .s2c_bytes |
The latency delta is worth understanding rather than skipping: it is client RTT minus server RTT, so a genuinely distant client shows a large positive delta while a spoofed or colocated bot shows one near zero.
Enrichment
These are populated by the agent rather than read off the packet, so they are present only when the corresponding feature is enabled.
| Family | Fields |
|---|---|
| GeoIP | ip.src.country, ip.dst.country, ip.src.asn, ip.dst.asn, ip.src.asn_org, ip.dst.asn_org |
| Threat intel | threat.score (0–100), threat.advice, threat.labels |
| IDS | ids.alert_count, ids.block_count, ids.notice_count, ids.score, ids.severity, ids.sids |
| Network scope | ids.src_home_net, ids.dst_home_net, ids.src_external_net, ids.dst_external_net, ids.src_pod_net, ids.dst_pod_net |
| Flow behaviour | flow.flows_per_min, flow.unique_dst_ports, flow.unique_src_ports, flow.dst_port_entropy, flow.burstiness |
ids.severity runs the opposite way to ids.scoreids.score is 0–100 where higher is worse. ids.severity is the raw Suricata-style
value where 1 is the most severe. So ids.severity > 3 selects the least serious
alerts, which is almost never what someone writing it means.
Prefer ids.score for thresholds. Reach for ids.severity only when you specifically want
the raw scale, and read it as a rank rather than a magnitude.
ids.sids is an array of the signature IDs that fired, which is how a rule enforces on an
IDS detection that has no dedicated field of its own:
any(ids.sids[*] in {2054155 2047703})
ML verdicts
Alert-first by design: the models emit a verdict, and a rule opts in to acting on it.
| Family | Fields |
|---|---|
| Traffic classifier | ml.traffic.class, .confidence, and a per-class probability for benign, scan, bruteforce, dos, exfil, c2, malware |
| Flow classifier | ml.flow.label, .benign, .malicious |
| JA4-JEPA | ml.jepa.label, .similarity |
Per-class probabilities matter because they let a rule fire on a class that was not the
argmax — ml.traffic.c2 ge 50 catches a flow the classifier ranked as something else but
still considered plausibly C2.
Kubernetes identity
| Field | Type | |
|---|---|---|
identity.k8s.src_workload, dst_workload | Bytes | Workload name, resolved from the identity feed |
identity.k8s.src_namespace, dst_namespace | Bytes | Namespace |
identity.k8s.src_label, dst_label | Map | Pod labels by key: identity.k8s.src_label["role"] eq "database" |
edge.declared | Int | 1 when this conversation is allowed by a NetworkPolicy |
edge.policy_violation | Int | 1 when the destination is governed by policy and this edge is not in the allow-list |
edge.policy_violation == 1 is the microsegmentation signal — a real policy-deny rather
than merely an undeclared edge. See Service Graph.
The TLS fields need a parsed ClientHello, the HTTP fields need a parsed request, and the enrichment families need their feature enabled and its feed loaded. An unset field does not error — the rule just does not match, which is why a rule that appears to do nothing is usually a rule referencing a field nothing populated.
Field names deliberately mirror the WAF scheme where they overlap, so an expression can move between the two without translation.
Rules arrive in three lists — block, allow, and standalone expressions — and are
delivered from the platform rather than written into the agent's local config. The
Signal API is how an automation or SOAR playbook adds one.
A Smart Firewall rule is block or allow. There is no challenge and no rate limit here,
because the decision happens before a connection is established — there is no request
yet to challenge. Those actions belong to the WAF.
Precedence
The first matching rule wins, in order. An allow does not outrank a block by virtue
of being an allow — it outranks it by being earlier.
This is the same evaluation model as firewall rules, and it catches
people the same way: an allow placed below a broad block never runs, because the block
already decided.
An expression that does not parse is logged and skipped at load. A bad rule does not stop the agent from starting, and it does not stop the rest of the ruleset from applying — but it also does not tell you at write time, so check the log after shipping one.
How enforcement lands
Two paths, and the difference matters:
| Rule | Enforcement |
|---|---|
ja4t block | Kernel BPF, at SYN time — the connection is dropped before the handshake completes |
| Everything else | The reactor installs an address block into the kernel firewall, applied from then on |
The reason for the split is what is available when. JA4T is computed from TCP options present in the SYN, so the kernel can decide immediately. A JA4 needs the TLS ClientHello, which arrives after the handshake — by then the connection exists, so what a match buys you is that address blocked going forward, not that packet dropped.
Blocks are direction-aware: the remote end of the flow is the block target, so a match on traffic you initiated does not blocklist your own host.
Blocks carry a TTL and are swept when they expire, so a fingerprint seen once does not become a permanent entry in a kernel map that only grows.
Platform coverage
| Linux, eBPF build | XDP, with nftables and iptables as fallbacks |
| Windows | The NDIS path, same rule model |
The ja4t SYN-time drop needs an eBPF build; without one, rules still apply through the
reactor and the address-block path.
Limits worth knowing
- A fingerprint identifies a population, not a person. Every client of a given TLS stack and build shares one. Blocking a common browser fingerprint blocks everyone using that browser — pair it with an address condition, or use it as one signal among several.
- Only
ja4tstops the first connection. For every other fingerprint the first connection completes; you are blocking the second onwards. - Rules come from the platform. This is not a file you hand-edit on the host, which is deliberate — but it means an agent that cannot reach the platform keeps the ruleset it last received.
- Invalid expressions fail quietly. They are skipped with a log line, not rejected at submission.
Use cases
- Block a scanner that rotates addresses — the fingerprint is stable even when the address is not.
- Neutralise a botnet family by the TLS stack its malware links against, in one rule.
- Act on a classifier verdict — take the fingerprint the ML models flagged and install it as a kernel block.
- Automate response from a playbook by posting a
smart_firewall_ruleto the Signal API and having every agent enforce it.
See also
- JA4+ — what each fingerprint captures and how stable it is
- Amygdala — the engine that evaluates these rules
- Hillock — the kernel firewall that enforces the result
- Access Rules — the cheaper, static, address-only layer
- Signal API — submitting rules programmatically