Skip to content

Threat intelligence

Nyxtrace can carry one local list of indicators — domains, addresses and networks — and mark a match on flow, destination, domain and graph records. The list is a file on the appliance; nothing is looked up online at query time.

A match never invents risk. Scores stay incident-derived, and the marker is a fact on the record rather than a score contribution. A list is a hint, not a verdict.

NDR_THREAT_INTEL_PATH in /etc/ndr/ndr.env names a YAML file with three lists. Any of them may be empty; unknown keys are refused.

domains:
- 'bad.example.invalid'
ips:
- '203.0.113.8'
cidrs:
- '198.51.100.0/24'

The loader is strict:

  • at most 100,000 entries across the three lists together — a larger file is refused as a whole,
  • a domain is lower-cased, a trailing dot is removed, and it may not be empty, longer than 253 characters or contain whitespace or slashes,
  • an address must parse; IPv4-mapped IPv6 addresses are unmapped,
  • a network must be in canonical form (198.51.100.0/24, not 198.51.100.7/24).

One invalid entry fails the whole file. What happens then depends on whether a set is already loaded, see the hot reload below: a file that fails at process start leaves the provider absent — matches are then false everywhere and the health line says so — while a file that fails after a good one was loaded leaves the good one in use.

Matching compares the destination address against ips and cidrs and the queried or observed name against domains, exactly — no suffix matching, no wildcards.

Since 0.33 the running process follows the file. On the lookup path it checks the file’s modification time and size at most once a minute and, when either changed, re-reads it with the same strict parser and swaps the set atomically. No restart is involved: neither the managed refresh nor an edit of your own needs systemctl restart ndr-api. The API and the response worker share one set, so a match means the same thing on a record and in a response decision.

A file that is missing at start is picked up later, when it appears. A file that cannot be read, or that the parser rejects, keeps the previous set in use — matching continues on the last good indicators — and the health component turns degraded with the reason for admins. The rejected file is not re-parsed every minute: the process remembers the file identity it refused and tries again only when the file changes.

Two things still need a restart, because they are configuration rather than content: setting NDR_THREAT_INTEL_PATH for the first time, and removing it.

The appliance ships a refresh script and a systemd timer that produce that file once a day. Since 0.39 the refresh has two paths and picks one on every run:

Path Who Source Signed
Pro — the licence carries feeds ndr threat-intel pull one curated bundle from the key server: abuse.ch Feodo Tracker, SSLBL and URLhaus, normalised and published daily yes, with the feed key nyx-feed-2026a, verified against /etc/ndr/feed-pubkeys.json
Local — everything else the script itself the same three abuse.ch lists, fetched by the appliance no; validated fail-closed as before

Both produce the same kind of file, and the free core loses nothing: the local path is what every installation ran before 0.39, with one change. Spamhaus DROP is no longer in the default source set. It is free for non-commercial use only, which a commercial product cannot redistribute; an installation that wants it adds it by hand and carries the licence question itself. The cidrs list of a locally built file is therefore empty today.

Source Contributes Terms
abuse.ch Feodo Tracker ips CC0
abuse.ch SSL Blacklist (SSLBL) ips CC0
abuse.ch URLhaus host file domains CC0

What the Pro bundle is, how long it is valid and what the download gate honestly relies on is on Pro content.

Terminal window
# in /etc/ndr/ndr.env (root-only, mode 0600)
NDR_THREAT_INTEL_PATH=/etc/ndr/threat-intel.yaml
Terminal window
systemctl enable --now ndr-threat-intel.timer
systemctl start ndr-threat-intel.service
systemctl status ndr-threat-intel.service ndr-threat-intel.timer

A Pro installation additionally needs the feed keyring, a third ring next to the licence ring and the release ring:

Terminal window
install -o root -g root -m 0644 feed-pubkeys.json /etc/ndr/feed-pubkeys.json

NDR_FEED_KEYRING names it (that path is the default). Without the ring a Pro pull fails with the keyring that verifies feeds is not usable and the local sources apply — the refresh never stops over it. Three more variables have defaults you will not normally touch: NDR_THREAT_INTEL_STATE_DIR (/var/lib/ndr/threat-intel, where the candidate and the state file live), NDR_CONTENT_CHANNEL (stable or beta; empty follows the update channel) and NDR_MODEL_PACK_DIR, which belongs to model packs.

The installer places the script at /usr/local/bin/ndr-threat-intel-refresh and the two units at /etc/systemd/system/ndr-threat-intel.{service,timer} — on the bundle update path too, not only on a fresh install. The timer fires daily at 02:17 with a random delay of up to six hours, so a fleet of appliances does not hit the sources at the same minute, and it is persistent: a run missed while the machine was off happens at the next boot.

ndr-threat-intel.service is one oneshot unit with two halves and two privileges. The candidate is produced as the service user, because the Pro path reads the entitlement token out of state.sqlite3, which only nyxtrace may open; installing it into /etc/ndr needs root, and that is an ExecStartPost=+ step — the same user split the API already has, in one unit. The unit is hardened (no new privileges, private /tmp, read-only system with /etc/ndr and /var/lib/ndr as the only writable paths) and takes a lock so two runs cannot overlap.

1. The Pro path, as nyxtrace. The script runs ndr threat-intel pull --out /var/lib/ndr/threat-intel/candidate.yaml. The pull reads the cached entitlement, asks the key server for the newest manifest in the channel, verifies the manifest against the feed keyring — the artefact type is inside the signature, so a release key can never vouch for a feed and a feed key never for a pack —, downloads the bundle through the key server’s redirect with no credential (the token never reaches the storage host), checks the signed size and SHA-256, gunzips it under a bound, parses it with the same strict parser ndr-api reads the installed file with, compares the indicator count against the signed entries, and stages the candidate atomically. It records what it did in state.json beside it: source, version, channel, key_id, sha256, entries, pulled_at and the last error. An unchanged feed — same version and checksum as the last successful pull — stages nothing; an empty channel is not a failure either.

2. The fallback. Exit code 3 of the pull is not entitled — the free core, licensing switched off, a licence without feeds — and opens no connection: the script builds the candidate from the local sources exactly as before, writing {"source":"local", …} to the same state file. Any other failure of the pull — key server unreachable, bundle rejected, keyring unusable — falls back to the local sources as well, so a Pro installation is never worse off than a free one; the reason stays in the state file and in the health row’s pull_error.

3. The local sources, as nyxtrace. The script:

  1. downloads the three files over HTTPS with TLS 1.2 or newer, a 10 s connect and 120 s total timeout and two retries each, and bounded: curl --max-filesize stops a source at 32 MiB and the three together may not exceed 96 MiB — a failed or oversized download ends the run before anything is written;
  2. parses every source strictly: a Feodo or SSLBL line must be exactly one field, a URLhaus line must be a host-file line (127.0.0.1 or 0.0.0.0 followed by a name) — one malformed line fails the run;
  3. deduplicates, lower-cases the domains, and validates every address and every domain label again with a separate parser;
  4. caps URLhaus at 40,000 domains and refuses any candidate with 100,000 or more entries in total — the parser’s own limit;
  5. refuses to produce an empty candidate: three valid-but-empty upstream answers are not a feed. The running file stays, the state file records entries: 0 and the reason, and no candidate means the root half installs nothing — rather than switching detection off;
  6. stages the candidate under /var/lib/ndr/threat-intel atomically.

4. The install, as root. ExecStartPost=+ runs the same script with --install, which hands the candidate to ndr threat-intel install --candidate … --target /etc/ndr/threat-intel.yaml --owner nyxtrace — a subcommand that reads no configuration and treats the candidate as hostile input, because the directory belongs to the service user and a compromised service user must gain nothing from root doing the copy. It lstats the path (a symlink is refused, never followed), accepts a regular file only, owned by nyxtrace, without a group or other write bit, opens it with O_NOFOLLOW and compares device and inode against the lstat, reads through that descriptor into a root-owned temporary file in /etc/ndr, re-validates it with the API’s strict parser, requires at least one indicator, and only then renames it into place. A refused candidate stays where it is for inspection and the running file stays in use. No candidate at all — an unchanged Pro feed, an empty local answer — is no candidate; the running file stays and exit 0.

Nothing is restarted. The rename gives the file a new modification time, and ndr-api picks the new set up within a minute through the hot reload. Before 0.33 the unit ended with systemctl try-restart ndr-api; that step is gone.

Comments and feed metadata are discarded; a locally built file holds indicators only, with a header naming the sources and their terms.

Fail-closed is the property to rely on, on both paths: a failed download, a bad signature, a checksum that does not match, a malformed row, a count that does not match what was signed, an oversized or an empty candidate leaves the last good file untouched and the service running on it. Nothing partial is ever installed.

Terminal window
ndr threat-intel status # the state file, readable
ndr threat-intel pull [--channel stable|beta] [--out PATH] # the Pro path alone, as nyxtrace

status prints the state directory, source (keyserver, local or unknown when no refresh was ever recorded), the bundle version, channel and signing key, entries, pulled at, the last error if there is one, and the feed keyring path. pull without --out verifies and stages into the state directory; it never touches /etc/ndr, and on a free installation it ends with exit code 3 and the local sources apply before any connection is opened. ndr threat-intel install exists for the unit’s root half and is not meant to be run by hand.

The oneshot’s journal ends with the counts, or with which path applied:

Terminal window
journalctl -u ndr-threat-intel.service -n 5
# threat-intel refresh: candidate pulled from the key server
# threat-intel install: ndr-api re-reads the file within a minute
# — or —
# threat-intel refresh: not entitled to the key server feed; the local sources apply
# threat-intel refresh complete (local sources): domains=... ips=... cidrs=0 total=...

Within a minute, the system view and GET /api/v1/health show it in the enrichment component threat-intel:

Field Since Meaning
loaded_at 0.33 when the running process last read the file; also the component’s last_event_at
entries 0.33 the number of indicators in the set in use — domains plus addresses plus prefixes
source 0.39 keyserver — the signed Pro feed —, local — the abuse.ch sources —, or unknown when no refresh was ever recorded
bundle_version, signing_key_id 0.39 the feed’s version (2026.9.18) and key id (nyx-feed-2026a); null for a local or unknown source
pulled_at 0.39 when the refresh last recorded a result, on either path; null never
pull_error 0.39 admin only: why the last pull produced nothing — null for readers and after a successful pull

and one of three states, unchanged by the provenance:

Status Meaning
ok the set in use is the file on disk
degraded a newer file could not be read or was rejected; the previous set keeps matching, and the admin-only message says which of the two it was
unavailable no path is configured (Optional provider is not configured), or nothing was ever read (loaded_at null, entries 0)

None of the three degrades the overall status; the provider is optional. A Pro installation running on source: local with a pull_error is not degraded either — it is a working installation on the fallback, and the error is the thing to read.

After a refresh, loaded_at should move and entries should match the oneshot’s count. A loaded_at that does not move after a refresh that reported success means the file did not change from the process’s point of view — same modification time and size — which is worth a look at the oneshot’s journal. And systemctl show ndr-api -p ActiveEnterTimestamp should not have moved at all: a refresh is no longer a restart.

Disable the timer, restore the previous YAML and keep the failed candidate out of /etc/ndr. The process picks the restored file up within a minute; no restart. Removing NDR_THREAT_INTEL_PATH altogether is the one change that still needs one, because the path is configuration.

Terminal window
systemctl disable --now ndr-threat-intel.timer

To force the local sources on a Pro installation without touching the timer, set NDR_LICENSE_ENABLED=off or remove feeds from the licence in the portal; the next refresh says not entitled … the local sources apply and the health row shows source: local.

The boolean threat_intel_match is on every flow, on each row of a device’s top destinations and domain projections, on the destinations page (list and detail) and on graph records. The interface renders it as a small warning-coloured marker next to the name or address. Absent configuration yields false and never a hit.

Two things it does not do: it does not change a score, and it does not create an incident. If you want a list match to alert, that is a model condition, not a property of the list. See detection models.

The refresh script is one way to produce the file. A file you maintain yourself — an internal blocklist, a vendor feed you are licensed for, Spamhaus DROP if your use is non-commercial — works the same way as long as it has the shape above, and an edit is live within a minute. Write it atomically (to a staged file, then mv over the target) so the process never reads a half-written file; a half-written file is rejected and the previous set stays in use until the next change, so the failure mode is a degraded line, not a wrong match. Leave the timer disabled in that case, or the refresh will overwrite it at 02:17.