Skip to content

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.

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_destination stays 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.

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.

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_by and approved_by both), with policy_decision.mode: "automatic". The executor picks it up standby-first like any approval, announces response.executed on 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 proposed action for a person, requested_by: nyxtrace:automatic, the refusing reason as the last entry of policy_decision.reasons (for example calibration gate closed: learning phase until 2026-09-24), announced as response.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 a dhcp-leases or unifi connector (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.

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 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 as zeek; Zeek counts on top only through another, unmatched Zeek record. One record is never two classes.
  • A model alone is never enough. behavior_model is 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 markersthreat_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.

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
Mail 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 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.
  • unknown is 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.

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.

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.

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’s ttl must 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.

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:

  1. Add on the standby node, read back.
  2. Add on the active node, read back.
  3. Only when both hold the entry: executed, then kill_states if 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.

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.

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.

The order matters, and every step is reversible.

  1. Run the canary of the target and at least the seven days of proposal-only operation the spec recommends.
  2. Make sure an identity source feeds the tenant: the dhcp-leases or unifi connector, a NetBox or Proxmox inventory for servers. Without one every proof is none and nothing becomes automatic.
  3. Label incidents through response feedback during the learning phase — responder role, one incident is one label — and watch ndr response calibration until it says open.
  4. Set auto_enabled: true on the response target, increase policy_revision, run ndr connectors validate, restart. The log line operation=response_worker … auto_enabled=true confirms it; a standstill reason (operation=response_produce … standstill=…) is logged once per change.
  5. Watch the first automatic action: response.executed on the webhook or chat sink (subscribe to all four response.* events), the Response page with policy_decision.mode: "automatic" under Why, and pfctl -t <alias> -T show on both nodes.
  6. To pause: auto_enabled: false stops the producer, and held actions expire or are released as before; kill_switch: true stops everything. A wrong automatic quarantine is undone with Release or with a false_positive verdict, which releases every held action of the incident.

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.