Skip to main content

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 onWhere
Access rulesA static address, CIDR, country or ASN listXDP, before routing
Smart FirewallFingerprints and expressions over themKernel, after evaluation
WAFThe HTTP requestIn 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

FieldType
ip.src, ip.dstIPSource and destination address
ip.protoBytesIP protocol
tcp.src_port, tcp.dst_portIntTCP ports
udp.src_port, udp.dst_portIntUDP ports
tcp.ttl, tcp.window, tcp.mss, tcp.window_scaleIntThe raw inputs JA4T is built from, for when you want one component rather than the whole fingerprint

Application

FieldType
tls.sni, tls.alpnBytesFrom the TLS ClientHello
http.method, http.host, http.path, http.user_agentBytesRequest line and headers
dns.fqdnBytesDestination 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:

FamilyFields
Latencyja4l.rtt_us, ja4l.ttl, ja4ls.rtt_us, ja4ls.ttl
Latency deltaja4l.delta_us, .delta_abs_us, .delta_ms
App-handshake timingja4l.app_rtt_us, ja4ls.app_rtt_us, and tcp_app_delta_us / _abs_us / _ms on both
SSH countsja4ssh.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.

FamilyFields
GeoIPip.src.country, ip.dst.country, ip.src.asn, ip.dst.asn, ip.src.asn_org, ip.dst.asn_org
Threat intelthreat.score (0–100), threat.advice, threat.labels
IDSids.alert_count, ids.block_count, ids.notice_count, ids.score, ids.severity, ids.sids
Network scopeids.src_home_net, ids.dst_home_net, ids.src_external_net, ids.dst_external_net, ids.src_pod_net, ids.dst_pod_net
Flow behaviourflow.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.score

ids.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.

FamilyFields
Traffic classifierml.traffic.class, .confidence, and a per-class probability for benign, scan, bruteforce, dos, exfil, c2, malware
Flow classifierml.flow.label, .benign, .malicious
JA4-JEPAml.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

FieldType
identity.k8s.src_workload, dst_workloadBytesWorkload name, resolved from the identity feed
identity.k8s.src_namespace, dst_namespaceBytesNamespace
identity.k8s.src_label, dst_labelMapPod labels by key: identity.k8s.src_label["role"] eq "database"
edge.declaredInt1 when this conversation is allowed by a NetworkPolicy
edge.policy_violationInt1 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.

Fields carry no value unless something fills them

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.

Two actions only

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.

Order your exceptions above the rule they escape

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:

RuleEnforcement
ja4t blockKernel BPF, at SYN time — the connection is dropped before the handshake completes
Everything elseThe 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 buildXDP, with nftables and iptables as fallbacks
WindowsThe 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 ja4t stops 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_rule to 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