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.
The file
Section titled “The file”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, not198.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.
The hot reload
Section titled “The hot reload”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 managed refresh
Section titled “The managed refresh”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.
Enable it
Section titled “Enable it”# in /etc/ndr/ndr.env (root-only, mode 0600)NDR_THREAT_INTEL_PATH=/etc/ndr/threat-intel.yamlsystemctl enable --now ndr-threat-intel.timersystemctl start ndr-threat-intel.servicesystemctl status ndr-threat-intel.service ndr-threat-intel.timerA Pro installation additionally needs the feed keyring, a third ring next to the licence ring and the release ring:
install -o root -g root -m 0644 feed-pubkeys.json /etc/ndr/feed-pubkeys.jsonNDR_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.
What one run does
Section titled “What one run does”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:
- 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-filesizestops 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; - 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.1or0.0.0.0followed by a name) — one malformed line fails the run; - deduplicates, lower-cases the domains, and validates every address and every domain label again with a separate parser;
- caps URLhaus at 40,000 domains and refuses any candidate with 100,000 or more entries in total — the parser’s own limit;
- 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: 0and the reason, and no candidate means the root half installs nothing — rather than switching detection off; - stages the candidate under
/var/lib/ndr/threat-intelatomically.
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.
By hand
Section titled “By hand”ndr threat-intel status # the state file, readablendr threat-intel pull [--channel stable|beta] [--out PATH] # the Pro path alone, as nyxtracestatus 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.
Verify
Section titled “Verify”The oneshot’s journal ends with the counts, or with which path applied:
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.
Roll back
Section titled “Roll back”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.
systemctl disable --now ndr-threat-intel.timerTo 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.
Where a match shows
Section titled “Where a match shows”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.
Your own list
Section titled “Your own list”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.