Automatic response
Since 0.31 Nyxtrace can carry out an approved response action: put a device
into a timed, reversible quarantine on an OPNsense or pfSense pair, or block a
wireless client on a UniFi controller. Nothing is armed by default. Without
a response target declared in connectors.yaml the
Response page records proposals and decisions and
executes nothing, exactly as before. With one, it needs the Pro licence
feature response on top — and even then a person’s approval is the only
thing that starts an execution, until you switch the
automatic mode on and its
calibration gate opens. Both default to no.
This page is the model. The two operator procedures are OPNsense / pfSense and UniFi client block; the analyst’s side of the automatic mode is response feedback.
What a quarantine is — and is not
Section titled “What a quarantine is — and is not”An executed quarantine_device action adds the device’s address to a
quarantine alias on the firewall, which a block rule on every routed
ingress interface of the tenant reads. Optionally the companion then terminates
the routed connection states that address holds, so an established session
does not outlive the block.
What that does not do, stated plainly because a security tool must not claim more than it does:
- Only routed paths. Traffic inside one VLAN never reaches the firewall and is not blockable. A quarantined device can still talk to its neighbours.
- Not an IPS. Suricata runs passively; the response does not replace an inline block.
- No endpoint access, no DNS sinkhole, no zone move. Nyxtrace never touches the device itself.
block_destinationstays a proposal. A tenant source alias crossed with a destination alias is a cartesian rule, not host-to-host isolation, and its semantics have not been accepted separately.- The TTL is a deadline, not an activity window. The alias expiry counts from insertion, not from the last packet.
The UniFi target is narrower still: it blocks one wireless client on the controller, which is a different blast radius and has no fail-open expiry. See its page.
Two response targets, one executor
Section titled “Two response targets, one executor”| Target | Connector id | Acts on | Nodes | Fail-open backstop |
|---|---|---|---|---|
| OPNsense / pfSense pair | opnsense-response |
A quarantine alias per tenant, through a fixed-scope companion over SSH | Standby (b), then active (a) |
The firewall’s own alias expiry cron |
| UniFi controller | unifi-response |
block-sta / unblock-sta on the wireless client that holds the address |
One (unifi) |
None — only the appliance’s release |
Both are Pro (licence feature response, category response, kind
action). Both carry the same policy fields and answer the executor with the
same vocabulary, so the Response page needs no second set of statuses. One
tenant can declare exactly one response target; a second one is a startup
error.
The firewall side uses no firewall API and no API key. A small companion
binary runs on each node as an SSH forced command for a dedicated account:
it accepts one signed request, applies exactly one of six operations with
pfctl through argument arrays, and answers with one signed reply. Alias
names and the protected scopes live in the companion’s own root-owned
configuration; the appliance never sends either. The reasoning is in the
OPNsense page.
Proposal mode and automatic mode
Section titled “Proposal mode and automatic mode”The gate knows two modes with different limits.
Proposal mode is the default. A person creates a proposal on the
incident, a person with the Response role approves it, and the executor applies
it. Every human approval runs in proposal mode, with its limits — never with the
automatic mode’s smaller ones — and its decision says so
(policy_decision.mode: "proposal", reason approved by a person), even when
the score would have been eligible for the automatic mode.
Automatic mode has a producer since 0.32. It runs only when two things are
true at once: the response target says auto_enabled: true, and the
tenant’s calibration gate is open. The default of both
is no, so an appliance that touches neither behaves exactly as in 0.31. With
auto_enabled: true and the gate still closed the producer runs anyway — but
every candidate it would have executed is created as a proposal for a
person, with the gate’s reasons on it. That is the learning phase, and it is the
point of it: the analysts see what the automatic mode would have done, and
label it.
Whatever the mode, the worker resolves and stores three things for every apply, human or automatic, and the Response page shows them under Why: the identity proof of the target address, the evidence classes behind the incident, and the policy decision with its reasons. A human approval does not need a proof to run; an automatic one cannot run without it.
How a candidate becomes automatic — or a proposal
Section titled “How a candidate becomes automatic — or a proposal”Every 30 seconds, for each tenant whose response target has auto_enabled: true, the producer looks at the open incidents of the last 24 hours and
takes the ones at or above automatic_score (95) and automatic_min_severity
(high). The 70–94 band stays a person’s business. For each candidate it
resolves the device, the address, the identity proof and the evidence classes,
and asks the gate. All of the following must hold at once:
| Gate | Requirement | Default |
|---|---|---|
| Score and severity | automatic_score and automatic_min_severity or above |
95, high |
| One target | Exactly one device with exactly one inventory address | — |
| Scope | The address inside allowed_targets and outside every protected scope |
— |
| Identity | A proof of dhcp_lease_and_mac or better |
— |
| Evidence | At least min_evidence_classes independent classes |
2 |
| Confidence | Evidence confidence at or above min_confidence_percent |
90 |
| Nobody against it | No analyst verdict false_positive or incorrect on the incident, and no person with a non-terminal action on it |
— |
| Room | Fewer than max_automatic_active automatic quarantines held or approved but not yet applied, and the device out of its cooldown since its last release |
1, 60m |
| Calibration | The calibration gate open | closed |
What the producer creates depends on which gate said no:
- Every gate open — an action created and approved by
nyxtrace:automatic(requested_byandapproved_byboth), withpolicy_decision.mode: "automatic". The executor picks it up standby-first like any approval, announcesresponse.executedon the webhook and chat sinks, and the card reads Executed (held on every node). - A candidate that clears the ladder but not one of the automatic gates —
identity, evidence, confidence, scope, the calibration gate, the cap or the
cooldown — a
proposedaction for a person,requested_by: nyxtrace:automatic, the refusing reason as the last entry ofpolicy_decision.reasons(for examplecalibration gate closed: learning phase until 2026-09-24), announced asresponse.proposed. Approve or reject it as usual. - A device without an identity source — every proof is
none, so the producer only ever proposes. Feed the tenant adhcp-leasesorunificonnector (a NetBox or Proxmox inventory helps for servers) before you expect an automatic action. This is by design.
One decision per incident. The producer never creates a second action for
an incident it decided, whatever became of the first, and never touches an
incident on which a person holds an action that is not terminal. The database
keeps that rule against a second worker on the same state file: a partial
unique index allows at most one open automatic action per incident, and the
producer treats a refused insert as “another worker got there first”. An
incident an analyst marked false_positive is closed to the producer for good.
The gates are re-evaluated at apply time. When the executor claims an
automatic action it resolves identity, evidence, policy and calibration gate
again. A gate that no longer holds ends the action failed with the reason —
never downgraded to a proposal mode nobody granted. The one exception is the
cap and the cooldown: an automatic action the executor refuses for either is
reopened as a proposal for a person (policy_decision.reopened_from names
the failed action; announced as response.proposed), because the one-decision
rule would otherwise leave nobody looking at a real incident.
An evidence lookup that fails blocks the producer, not a person. When
ClickHouse cannot classify, the producer creates nothing; a human approval runs
with behavior_model assumed and says so in its reasons.
Identity proof grades
Section titled “Identity proof grades”The proof answers one question: is the address the action targets bound to the device the incident names, right now? It is graded from the anchors observed on that address — a MAC, a DHCP lease, an inventory assignment, an enrolled identity — read per address from the identity store over the last seven days. Only anchors fresher than 24 hours count.
| Grade | Shown as | What proves it | Where it comes from |
|---|---|---|---|
enrolled |
Enrolled | An agent or dot1x anchor on the address, or an identity cluster the resolver proved at confidence ≥ 0.90 over a merge, with a fresh exclusive anchor |
An agent, 802.1X, the identity resolver |
static_binding |
Static binding | An authoritative_map anchor: an operator inventory assigns this address to this device on purpose |
netbox, proxmox |
dhcp_lease_and_mac |
DHCP lease and MAC | A fresh, stable (administered) MAC bound to the address by a lease or a controller’s live client table | dhcp-leases, unifi, a mirrored dhcp record |
none |
None | Everything else: a randomised MAC on its own (it rotates with the address), a MAC seen only by the mirror (Zeek), no anchor fresher than 24 hours, or two identities with fresh anchors on one address — a conflict is never resolved in favour of either | — |
The automatic mode needs dhcp_lease_and_mac or better. The grades are a
ladder, not a score: enrolled beats static_binding beats
dhcp_lease_and_mac, and the first rung that holds decides. The card shows the
grade, the number of anchors it was judged on and how fresh the deciding anchor
is; the API carries the same as identity_proof with the reasons in words
(fresh lease: stable MAC … from dhcp-leases seen …).
Two details worth knowing. Connector anchors count: an anchor a connector
wrote carries connector:<id> as its source, and the ladder strips that prefix
before matching — so a lease from the dhcp-leases connector grades exactly as
a lease the identity job saw. And the proof does not depend on the identity
resolver being switched on: with NDR_IDENTITY_ENABLED off there are no
clusters, and the raw anchors still grade static_binding and
dhcp_lease_and_mac; only the cluster-based enrolled rung needs it.
A device id that merely repeats for the same address is not an identity proof against a DHCP reassignment. That is why the rebinding check of 0.31 — inventory binds the address to dev_… (observed, not proven) — was never enough for the automatic mode and is now one line among the reasons.
Evidence classes and the independence rule
Section titled “Evidence classes and the independence rule”The gate counts independent evidence classes, not records. Every resolved record of the incident is mapped onto one of seven classes:
| Class | Shown as | What lands here |
|---|---|---|
zeek |
Zeek | Zeek connection and protocol logs from the mirror |
suricata |
Suricata | Suricata alerts from the mirror |
netflow |
NetFlow | Flow records from a NetFlow / IPFIX exporter |
dns |
DNS | Every *.dns and *.query record kind and every resolver source (Pi-hole, AdGuard Home) |
mail |
Mail events | |
threat_intel |
Threat intel | A record whose destination or query matched the local threat-intelligence list |
behavior_model |
Behaviour model | An unsuppressed model result on the incident |
Three rules make the count honest:
- Two records of one family are one class. Ten Zeek connections are Zeek, once.
- A threat-intel match replaces the family of the record it was seen on. A
Zeek connection to a listed destination counts as
threat_intel, not aszeek; Zeek counts on top only through another, unmatched Zeek record. One record is never two classes. - A model alone is never enough.
behavior_modelis a class, but the confidence bar below keeps a model plus one mirror family out of the automatic mode whatever the score.
Next to the classes the card shows markers — threat_intel_match,
resolver_blocked, beaconing, scan, first_sighting, rare_recurrence,
baseline_deviation, analyst_false_positive — taken from the detectors’
reasons and the resolver’s block verdict. They explain and corroborate; they
never count as a class.
Confidence
Section titled “Confidence”Since 0.32 policy_decision.confidence is the evidence’s confidence, not
the score divided by 100 (which only restated the score floor). It is a
noisy-OR over the independent families — 1 − Π(1 − w) — with the
corroborating markers folded in the same way, then scaled by the share of the
incident’s evidence that could be resolved at all:
| Signal | Weight |
|---|---|
| Threat intel | 0.65 |
| Suricata, Zeek | 0.50 each |
| Behaviour model | 0.45 |
| 0.40 | |
| NetFlow, DNS | 0.35 each |
| Marker: resolver blocked | 0.35 |
| Marker: beaconing, scan | 0.30 each |
| Marker: baseline deviation | 0.15 |
| Marker: first sighting, rare recurrence | 0.10 each |
A single family never reaches the bar on its own, and every additional
independent signal moves it up with diminishing returns. Against the default
min_confidence_percent of 90:
| Evidence | Confidence | Automatic? |
|---|---|---|
| Behaviour model + Zeek | 1 − 0.55 · 0.50 = 0.73 | No — stays a proposal whatever the score |
| Zeek + DNS + threat intel | 1 − 0.50 · 0.65 · 0.35 = 0.89 | No, just short |
| Zeek + Suricata + threat intel | 1 − 0.50 · 0.50 · 0.35 = 0.91 | Yes |
| Zeek + Suricata + behaviour model, beaconing | 1 − 0.50 · 0.50 · 0.55 · 0.70 = 0.90 | Yes |
| Zeek + DNS + threat intel, resolver blocked | 1 − 0.50 · 0.65 · 0.35 · 0.65 = 0.93 | Yes |
The weights are provisional and versioned: the decision stores
confidence_revision next to the value, and its basis is spelled out in
policy_decision.reasons (class zeek 0.50, marker beaconing 0.30, …,
evidence confidence 0.90 (revision 1)). A change to the weights bumps the
revision and never rewrites a stored decision.
The calibration gate
Section titled “The calibration gate”The calibration gate is the appliance’s own bar before it trusts itself. It is closed by default and opens only from analyst feedback — one tenant, one gate, persisted in the state database so a restart forgets nothing.
The learning phase. Nothing runs automatically before learning_until
(default 2026-09-24, a UTC date), whatever the feedback says. During it
the producer proposes and the analysts label. To switch the phase off, write
the word none — an empty field is the default, because the connector schema
fills it, so the choice has to be spelled out.
After the learning phase the gate is open when the last
calibration_window_days (14) hold at least calibration_min_labels (5)
incidents with a true_positive or false_positive verdict, at a
precision — true ÷ (true + false) — of at least
calibration_min_precision_percent (80). Three things about the count:
- An incident is one label, by its latest verdict inside the window. Confirming the same incident five times is one label; a later correction replaces the earlier verdict. Repetition cannot open the gate.
unknownis counted but carries no weight and cannot open or close it.- The number of distinct people behind the labels is reported
(
distinct_actors) so a one-person calibration is visible, but not gated on — a site with one analyst must be able to open the gate.
The counts are recounted live, so labels age out of the window without
anybody writing anything. calibration_min_labels: 0 opens the gate without
labels once the learning phase is over (none required); that is a choice, not
the default.
The gate’s reason is always a sentence, and the same sentence appears
everywhere the gate is shown — on the Response page’s calibration line, in
GET /api/v1/response/calibration and in ndr response calibration:
| Reason | Gate |
|---|---|
learning phase until 2026-09-24 |
closed |
3 of 5 weighted feedback labels in the last 14 days |
closed |
precision 0.75 below 0.80 over 4 labels |
closed |
precision 0.83 over 6 labels in the last 14 days |
open |
no weighted feedback labels in the last 14 days and none required |
open (calibration_min_labels: 0) |
How the verdicts are given, what they weigh, and what a false_positive does
to a held quarantine is on the feedback page.
The decision ladder
Section titled “The decision ladder”| Observed score | Behaviour |
|---|---|
below proposal_score (default 70) |
Observe and notify only |
proposal_score to automatic_score − 1 (70–94) |
Manual proposal |
automatic_score and above (default 95) |
Eligible for automatic execution — and only then |
Eligible is not executed. For an automatic execution all of the gates
above must hold on top:
auto_enabled: true, an active versioned policy, the kill switch off, the
confidence and class bars, the scope, an identity proof of
dhcp_lease_and_mac or better, room under the cap, and an open calibration
gate. A language model can neither approve, nor change a threshold, nor call
the executor — the producer is deterministic code over stored evidence.
Every decision records the policy_revision it was taken under. Change any
number in the policy and increase the revision, so a stored explanation stays
true to the rules it was evaluated against.
Protected scopes and allowed targets
Section titled “Protected scopes and allowed targets”A target must be inside allowed_targets and outside every protected scope,
checked on the appliance and again on the firewall against the companion’s
own copy. Three layers add to what you configure:
| Layer | Protected by | Source |
|---|---|---|
| Configured | protected_scopes in connectors.yaml |
You |
| Implicit, appliance | Both node addresses (standby_host, active_host) or the controller host, and every non-loopback address of the appliance itself — re-read hourly |
The connector |
| Implicit, firewall | Every interface address of the firewall, read at each companion start | The companion |
| Configured, firewall | protected (global and per tenant) in the companion’s config.json — the second, independent copy |
You |
allowed_targets refuses a prefix broader than /16 (IPv4) or /48 (IPv6)
unless allow_broad_targets: true is set explicitly. A quarantine alias that
could hold half a campus is a mistake, not a policy.
TTL, expiry and fail-open
Section titled “TTL, expiry and fail-open”Every membership has a deadline: ttl (default 900s, 1 minute to 24 hours)
from the moment the entry was applied. Two independent clocks release it:
- The appliance retires the action at the deadline and reports
expired. - The firewall runs its own alias-expiry cron every minute
(
pfctl -qt NAME -T expire 900) and removes the entry regardless of whether Nyxtrace is alive. That is the fail-open backstop, it is deliberate, and the interface says so next to every held lease. The appliance’sttlmust not exceed the alias expiry, and the companion refuses a longer one (ttl_out_of_range).
An overlapping second action on the same address shares the first deadline and never extends it. On the UniFi target only the first clock exists.
A firewall pair: standby first
Section titled “A firewall pair: standby first”On an HA pair the alias rules are synchronised, but the alias contents are not — which is why the companion is called on each node, and why the order matters:
- Add on the standby node, read back.
- Add on the active node, read back.
- Only when both hold the entry:
executed, thenkill_statesif enabled.
A refusal on a later node is compensated on the earlier ones and the action ends
failed with nothing held; a compensation that itself fails leaves
reconciling with the membership on record for the next attempt. Release
removes standby first as well: lifting the block on the active node first and
failing on the standby would open the path where traffic flows while a failover
would close it again. “Still blocked” stays consistent until both confirmed.
After a restart or a CARP failover the worker reconciles list_members
against its own memberships: an entry a node lost is re-added, an entry Nyxtrace
does not own is reported and never touched. The companion’s ledger is the
proof of ownership, an alias is never flushed, and a foreign entry is never
deleted.
Statuses
Section titled “Statuses”The response record grew from seven to twelve statuses. A person can create or transition only through the first four; every other status is written by the executor, and a reader that meets a status it does not know keeps and shows it verbatim.
| Status | Shown as | Meaning |
|---|---|---|
proposed |
Proposed | Created on the incident, waiting for a decision |
approved |
Approved (waiting for the executor) | A person approved; without a target or licence it stays here, documented |
rejected |
Rejected | A person declined |
cancelled |
Withdrawn | The requester withdrew it |
executing |
Executing | The worker holds the job and is calling the nodes |
reconciling |
Reconciling (partial application) | Applied on some nodes; compensation or retry pending |
executed |
Executed (held on every node) | Every node confirmed the entry; the lease runs |
rollback_requested |
Release requested | Release pressed or the deadline reached |
rolling_back |
Releasing | Removal in progress; a node could not confirm absence yet |
rolled_back |
Released (absence confirmed) | Every node confirmed the entry is gone |
expired |
Expired (deadline passed) | The executor retired it after the deadline |
failed |
Failed | Refused, rebound, or a node failed with nothing held |
The executor never overwrites a person’s terminal decision: a rejected or
cancelled action is never picked up. An automatic action walks the same
statuses; it enters at approved (by nyxtrace:automatic) instead of
proposed, and a false_positive verdict on its incident cancels it while it
is still there.
What leaves the appliance
Section titled “What leaves the appliance”For the firewall target: an SSH connection to each node’s management address, under a dedicated Ed25519 key, pinned to one host key, carrying one signed JSON request per operation — a tenant id, a node label, an operation name and one address. For the UniFi target: the controller’s HTTPS API under a local account. No alias name, no node address and no state id ever appears in an API response or a health line; only the failure class is persisted and the raw error text is logged once. The security model lists this next to the other outbound paths.
Switching the automatic mode on
Section titled “Switching the automatic mode on”The order matters, and every step is reversible.
- Run the canary of the target and at least the seven days of proposal-only operation the spec recommends.
- Make sure an identity source feeds the tenant: the
dhcp-leasesorunificonnector, a NetBox or Proxmox inventory for servers. Without one every proof isnoneand nothing becomes automatic. - Label incidents through response feedback during the
learning phase — responder role, one incident is one label — and watch
ndr response calibrationuntil it saysopen. - Set
auto_enabled: trueon the response target, increasepolicy_revision, runndr connectors validate, restart. The log lineoperation=response_worker … auto_enabled=trueconfirms it; a standstill reason (operation=response_produce … standstill=…) is logged once per change. - Watch the first automatic action:
response.executedon the webhook or chat sink (subscribe to all fourresponse.*events), the Response page withpolicy_decision.mode: "automatic"under Why, andpfctl -t <alias> -T showon both nodes. - To pause:
auto_enabled: falsestops the producer, and held actions expire or are released as before;kill_switch: truestops everything. A wrong automatic quarantine is undone with Release or with afalse_positiveverdict, which releases every held action of the incident.
Before you switch anything on
Section titled “Before you switch anything on”The spec’s acceptance proofs
on an isolated firewall pair are still open, and so is the live canary. The
code has been tested against a fake firewall and a recorded controller; it has
not yet held an entry on a real OPNsense node in the project’s hands. The
automatic mode adds nothing to that list — it reuses the executor unchanged —
but it removes the person between the evidence and the block, which is why the
calibration gate exists. Read that section before the first approval, keep
kill_switch: true for the first walk through the Response page, start with
one address you own, and leave auto_enabled at false until the six steps
above are behind you.