Security Probes

Security probes

Axiom Border assesses the network it sits on with four probes. They share one execution model, one audit trail and one accumulated view of results, so the findings of each one reinforce the others instead of living in separate reports.

Probe What it answers Requires
Network discovery What is out there, and what is it listening on? nmap 7.80+ on PATH
Vulnerability scanning Which of those services are vulnerable? A templates directory (OT/ICS checks are included)
SNMP What does the device say about itself? Reachable SNMP agent, credentials or a profile
Traffic capture What is actually crossing the wire? libpcap, a valid capture interface, root

Each runs two ways: automatically on a configurable interval, and manually on demand from the API or the web UI. Results are kept locally as probe state and written to InfluxDB as metrics, and every execution is audited.

flowchart TB
    DISC["Network discovery<br><i>live hosts and ports</i>"]
    DISC --> VULN["Vulnerabilities"]
    DISC --> SNMP["SNMP identity"]
    DISC --> CAP["Traffic capture"]
    VULN --> INV
    SNMP --> INV
    CAP --> INV
    DISC --> INV[("Consolidated<br>inventory")]
    INV --> ALARM["Alarms and<br>audit trail"]

Network discovery establishes the baseline and the other three enrich it. That ordering matters: a vulnerability scan with no targets scans every host in the baseline, so a discovery scan must have run first for it to have anything to do. Newly discovered hosts also trigger a vulnerability scan on their own, grouped together after a short delay, so new assets get assessed without waiting for the next interval.

Where you see the results

Network status in the console is the operational view of everything on this page: the discovered hosts with their addresses, when each was first and last seen, and per-host actions for its open ports, its vulnerabilities and its SNMP data. The charts below summarise findings by severity, hosts by status, and how many hosts carry vulnerabilities at all.

The Network status view, listing discovered hosts with their ports and vulnerabilities

The header names the last assessment that ran and how it finished, so you can tell at a glance whether what you are looking at is current.

Each row carries its own actions. Scan opens the Ports panel for that host — what it is listening on, with the service the scan identified and when the port was first and last seen:

The Ports panel for a single host

Vulnerabilities opens the findings for that host. Each row names the check that fired and explains what it confirmed, which is what makes a finding actionable rather than just a label:

The Vulnerabilities panel, listing the checks that fired against one host

Both panels carry a re-scan button, so you can reassess a single host without launching a sweep of the whole range.

How to read a finding

Name says what was confirmed — Modbus/TCP Diagnostics Function Exposed Without Authentication — and Description explains how it was confirmed and why it matters. Two more columns sit to the right of the description: CVE, empty whenever the finding is an exposure rather than a published vulnerability, and Template ID, which always identifies the check (modbus-diagnostics-exposure and the like). Since not every finding has a CVE, the template identifier is the stable way to refer to one.

Checks from the write/control layer are marked twice over: the name ends in [INTRUSIVE] and the description opens with INTRUSIVE / GATED. Those only ever run when a scan explicitly asks for intrusive mode — see OT/ICS vulnerability scanning.

The shared execution model

Every probe launch is asynchronous: the request is accepted, you get an identifier back immediately, and the work continues in the background.

Open Network status and use Edit to set the scan parameters, then Reload to refresh the view once the run finishes. The header keeps showing the state of the most recent execution while it progresses.

curl -s -X POST http://192.168.1.10:8083/security/scan/network \
  -H "Authorization: Bearer <jwt-token>" \
  -H 'Content-Type: application/json' \
  -d '{"targets": ["192.168.1.0/24"], "service": true}'
{ "uuid": "4a79214d-3deb-4ae8-933f-9874cbdc1128", "status": "inprogress" }

Poll GET /security/executions/{uuid} until the status leaves inprogress, landing on finished or failed. An execution record carries two payloads that are easy to confuse: currentOutput is the result of that run alone, while currentStatus is the consolidated view of the affected hosts. To list completed runs, filter by finished.

Executions also publish over MQTT

If you would rather not poll, the embedded broker publishes started, finishedok and failed events for every execution. See MQTT and OpenGate operations.

Network discovery and port scanning

The discovery probe. It finds live hosts, enumerates ports, and optionally identifies services and operating systems.

curl -s -X POST http://192.168.1.10:8083/security/scan/network \
  -H "Authorization: Bearer <jwt-token>" \
  -H 'Content-Type: application/json' \
  -d '{
        "targets": ["192.168.1.0/24"],
        "portFilter": "1-1024,502,2404,20000,47808",
        "timing": "T3",
        "service": true,
        "os": false,
        "timeout": "30m"
      }'

The portFilter above is worth copying if you have industrial equipment: it covers the common IT range plus the Modbus, IEC-104, DNP3 and BACnet ports, so the OT assets show up in the baseline that the vulnerability probe will later work from.

Practical notes:

  • os: true needs root, because OS fingerprinting implies a SYN scan over raw sockets. Without it, leave OS detection off.
  • udp: true is slow. Restrict udpPorts rather than sweeping.
  • Only one discovery scan runs at a time. A second request returns 409 Conflict unless you pass ?force=true, which cancels the running one and marks it failed with cancelled due to forced run.
  • The default portFilter is the full 1-65535 range. On a /24 at cautious timing that is a long scan — narrow it.

Scheduled behaviour is configured under securityProbes.nmap — from the console’s Configuration view, or in the file. See Configuration.

Vulnerability scanning

Template-based scanning, in two distinct halves. The web and CVE half uses the standard upstream template tree and needs that tree present on disk. The OT/ICS half uses Axiom Border’s own suite of 57 industrial protocol checks, which are installed with the product and work with no internet access.

curl -s -X POST http://192.168.1.10:8083/security/scan/vulns \
  -H "Authorization: Bearer <jwt-token>" \
  -H 'Content-Type: application/json' \
  -d '{"targets": ["192.168.1.50"], "level": "medio", "severity": "high,critical"}'

Depth is chosen with level, which takes one of four values — ligero, medio, profundo or ot. Always use one of those four: each selects a different set of checks, and the mapping table in Configuration is worth reading before you pick one.

Industrial protocol scanning has its own page, because the safety model deserves the space:

OT/ICS vulnerability scanning — Modbus/TCP, IEC 60870-5-104, DNP3, BACnet/IP and OPC UA, with the two-lock model that governs write operations.

Check the templates directory before trusting an empty report

A web scan that reports nothing looks exactly like a web scan that had no templates to run. Confirm the directory is populated:

ls /opt/axiom-border/vulnscan-templates/

You should see per-protocol directories such as http/, network/ and ssl/. CVE templates live under http/cves/, not at the root. OT/ICS findings are unaffected, because those checks are installed with the product.

SNMP — interrogation and profiles

Two operations: a GET of specific OIDs, and a walk from a root OID.

Symbolic names work as well as numeric OIDs — sysDescr is resolved through the bundled MIB catalogue, which matters on an air-gapped probe where you cannot look an OID up.

curl -s -X POST http://192.168.1.10:8083/security/scan/snmp \
  -H "Authorization: Bearer <jwt-token>" \
  -H 'Content-Type: application/json' \
  -d '{
        "target": "192.168.1.50",
        "oids": ["sysDescr", "sysName", "sysObjectID"],
        "timeout": "15s"
      }'

Profiles and associations

Rather than passing credentials with every request, store them once as a profile and bind them to hosts with an association:

# Create a v3 profile
curl -s -X POST http://192.168.1.10:8083/security/snmp/profiles \
  -H "Authorization: Bearer <jwt-token>" \
  -H 'Content-Type: application/json' \
  -d '{
        "name": "plc-floor-v3",
        "version": "3",
        "username": "<snmp-user>",
        "securityLevel": "authPriv",
        "authProtocol": "SHA",
        "authPass": "<auth-passphrase>",
        "privProtocol": "AES",
        "privPass": "<priv-passphrase>"
      }'

# Bind it to a host
curl -s -X POST http://192.168.1.10:8083/security/snmp/associations \
  -H "Authorization: Bearer <jwt-token>" \
  -H 'Content-Type: application/json' \
  -d '{"ipAddress": "192.168.1.50", "profileName": "plc-floor-v3"}'

Resolution order when a scan runs: the request’s profileName wins, then the per-IP association, then the configured default. A target that resolves to no profile fails with snmp omitted targets due to missing profile: <ip>.

Supported versions are v1, v2c and v3. For v3, defaults when fields are left empty are authPriv with SHA and AES.

Prefer profiles over inline credentials

Credentials sent inline as customParams are persisted with the execution record and returned in its detail. Profiles keep them out of execution history. Use customParams for one-off diagnostics only.

Note

The scheduled SNMP probe runs a discovery pass first, with a fixed budget of 30 seconds. A /24 at cautious timing will exhaust it, so narrow the range or raise the discovery timing value.

Traffic capture

Two modes, both capturing IPv4 traffic through libpcap:

  • Continuous, driven by configuration. When securityProbes.sniffing.interfaces is non-empty, capture starts automatically and persists flow counters to the networktraffic bucket every flushInterval.
  • On demand, via the API, for a bounded duration, merged into the latest scan result.
curl -s -X POST http://192.168.1.10:8083/security/scan/sniff \
  -H "Authorization: Bearer <jwt-token>" \
  -H 'Content-Type: application/json' \
  -d '{
        "interfaces": ["eth0"],
        "bpf": "tcp port 502",
        "duration": "5m",
        "promiscuous": true
      }'

The BPF filter above captures Modbus traffic only, which is a good way to confirm an industrial segment is actually active before scanning it.

Capture contributes bytesIn/bytesOut and packetsIn/packetsOut counters plus samples to each host in the merged result.

The interface name is the usual culprit

Capture failing silently almost always comes down to the interface. It must be a pcap name: eth0 or enp3s0 on Linux, en0 on macOS, and an Npcap NPF device path such as \\Device\\NPF_{GUID} on Windows — the friendly name Ethernet does not work there. Confirm with nmap --iflist.

Also confirm the service runs as root, and that there is traffic to see: sudo tcpdump -i <iface> -c 10.

The merged result view

Individual executions tell you what one probe found. GET /security/results/last tells you what is known about a host, merged across all four probes:

curl -s 'http://192.168.1.10:8083/security/results/last?ipAddress=192.168.1.50' \
  -H "Authorization: Bearer <jwt-token>"

Each host carries ipAddress, hostname, macAddress, os, manufacturer, status, firstSeen and updatedAt, then the per-probe contributions: ports from discovery, vulnerabilities from the vulnerability scan, snmp entries from the SNMP probe, and sniffing counters from traffic capture. This is the view worth putting on a dashboard.

The firstSeen and updatedAt pair is what turns the baseline into change detection: a host with a recent firstSeen is new to the network.

Feeding results into rules

Probe results are available to the rules engine, so a scan finding can raise an alarm the same way a metric can — a new host appearing, a critical vulnerability, a port opening that should not be open. See Alarms and rules.

Results also flow to InfluxDB as metrics under the SECSCAN group with a probe tag, and are queryable through POST /auditlog with agent set to network, vulns, snmp or sniff.

Index

Subsections of Security Probes

OT/ICS Vulnerability Scanning

OT/ICS vulnerability scanning

Generic vulnerability scanners are built for IT. Point one at an industrial segment and two things go wrong: it probes for a web server that a PLC does not have and drops the host from the scan, and when it does find something it has no idea what a coil or a Common Address is.

Axiom Border ships its own suite of 57 checks for five industrial protocols, written specifically for this problem. They are installed with the product and made available to every scan automatically, so they travel in every deployment role and work with no internet access and no upstream template feed.

Industrial equipment is not a web application

A write to the wrong register on a live PLC has physical consequences. Axiom Border defaults to read-only and requires two independent switches before it will send a single write frame. Read the safety model before you enable anything, and never enable intrusive mode without written authorisation for the segment you are testing.

Protocol coverage

Protocol Port Checks Of which intrusive
Modbus/TCP 502/TCP 19 6
IEC 60870-5-104 2404/TCP 11 4
DNP3 20000/TCP 10 3
BACnet/IP 47808/UDP 10 1
OPC UA 4840/TCP 7 2
Total 57 16

Every check is labelled with the protocol it targets, and the ones in the write/control layer are additionally labelled as intrusive. That intrusive label is what the safety model keys on.

What each layer detects

The suite is organised in four layers of increasing invasiveness:

flowchart TB
    L2["<b>Layer 2 — Detection</b><br>Is this protocol here?<br>Identify vendor and version"]
    L3["<b>Layer 3 — Exposure</b><br>Structural weaknesses reachable<br>without authentication"]
    L3B["<b>Layer 3b — Recon</b><br>Read process data and object lists<br>without writing anything"]
    L4["<b>Layer 4 — Intrusive</b><br>Write / control confirmation<br><i>gated behind two locks</i>"]
    CVE["<b>CVE checks</b><br>Vendor-specific, only where<br>fingerprinting is reliable"]

    L2 --> L3 --> L3B --> L4
    L2 --> CVE

    L4:::danger

    classDef danger fill:#fff0ed,stroke:#ff664e,color:#101010

Detection confirms the protocol is listening and extracts identity where the protocol allows it — Modbus device identification, DNP3 Object Group 0 device attributes, BACnet vendor identifier, OPC UA BuildInfo software version.

Exposure reports structural problems that need no credentials to observe: Modbus and DNP3 responding to unauthenticated requests, IEC-104 accepting a station interrogation, BACnet/IP being usable as a reflection and amplification source, OPC UA offering endpoints with None security policy or anonymous authentication.

Recon reads real process data — Modbus holding registers via FC03, Modbus diagnostics via FC08 sub-function 0, IEC-104 counter interrogation and read commands, DNP3 event classes and class-0 integrity polls, BACnet Who-Is discovery and object enumeration. These checks read; they never write.

Intrusive confirms a write is actually possible. It is described in detail below.

CVE checks exist only for the cases where the protocol itself reveals enough to be sure: Schneider Modicon over Modbus/UMAS, Delta enteliBUS (CVE-2019-9569) and Contemporary Controls (CVE-2025-13926) over BACnet, plus a multi-vendor Modbus fingerprint that maps identity to known advisories.

Why some known CVEs are deliberately absent

Axiom Border only reports what it can actually confirm. Three known CVEs cannot be confirmed over the industrial protocol itself, so no check claims to detect them: DNP3 CVE-2020-6996 (the Triangle MicroWorks stack version is not observable over DNP3), OPC UA stack versions below 1.5.374.158 (only the Basic128Rsa15 precondition is observable, and that is already covered by an exposure check), and the IEC-104 device CVEs (IEC-104 carries no native device identifier). For those, use SNMP interrogation to fingerprint the device and correlate the version externally.

Scan depth and how OT checks get selected

The level field of a vulnerability scan decides which checks run. OT checks are never included by accident — you either ask for a deep scan, ask for OT explicitly, or opt in for scheduled scans:

level Web templates OT read-only checks Notes
ligero Yes No Unless enableOT: true for the scheduled probe
medio Yes No Unless enableOT: true for the scheduled probe
profundo Yes (full) Yes Full web scan plus a separate OT pass
ot No Yes OT/ICS checks only — for dedicated industrial segments
flowchart TB
    REQ["Scan request"] --> LVL{"level"}
    LVL -->|"ligero<br>medio"| W["Web templates<br>only"]
    LVL -->|"profundo"| WOT["Web scan +<br>OT read-only"]
    LVL -->|"ot"| OT["OT/ICS<br>checks only"]

Scheduled scans with enableOT: true add the read-only OT layer to whatever level they run at.

The OT pass runs separately, on purpose

When a scan includes OT checks, that part runs as a pass of its own that does not discard hosts without a web server. A general-purpose scan pre-filters targets by HTTP reachability, which would drop a PLC or RTU before a single Modbus request was ever sent. Running the OT pass separately is what makes industrial assets visible at all.

The safety model: two locks

Intrusive checks send write or control frames. Enabling them requires two independent switches that live in different places, so neither an API caller nor a configuration mistake can unlock them alone:

  1. Per-request opt-in — the POST /security/scan/vulns body must contain "intrusive": true.
  2. Deployment kill-switch — the configuration must have securityProbes.vulnScan.allowIntrusive: true. The default is false.

If the configuration lock is closed, a request asking for intrusive mode is rejected — over REST and over MQTT alike. There is no path that bypasses it.

flowchart TB
    REQ["Vulnerability<br>scan request"] --> Q1{"intrusive: true<br>in the request?"}
    Q1 -->|no| RO["Read-only scan<br><i>write layer excluded</i>"]
    Q1 -->|yes| Q2{"allowIntrusive<br>in configuration?"}
    Q2 -->|no| REJ["Request rejected<br><i>REST and MQTT</i>"]
    Q2 -->|yes| INT["Write layer enabled<br><i>audited as WARN</i>"]

    REJ:::danger
    INT:::danger

    classDef danger fill:#fff0ed,stroke:#ff664e,color:#101010

Three further guarantees hold regardless:

  • The scheduled probe never runs the intrusive layer. Automatic periodic scans are always read-only, whatever the configuration says. Intrusive checks only ever happen because someone asked for one, explicitly, right now.
  • Every intrusive execution is audited. A WARN entry goes to the log and a notice is attached to the details field of the execution record.
  • Intrusive means “no net change”, not “no writes”. The approach is read-then-write-back — read the current value, write the same value back — or SELECT-only for command protocols, issuing the select phase without the execute phase. Genuinely destructive actuation is excluded from the suite entirely.

What the intrusive layer actually does, per protocol

Protocol Intrusive checks Technique
Modbus/TCP write-single-register (FC03→FC06), write-single-coil (FC01→FC05), write-multiple-registers (FC03→FC16), write-multiple-coils (FC0F), mask-write-register (FC22), read-write-multiple-registers (FC17) Read current value, write the identical value back
IEC 60870-5-104 control-select (C_SC), double-command-select (C_DC), setpoint-select (C_SE), regulating-step-select (C_RC) SELECT phase only, S/E=1, execute phase never sent
DNP3 analog-output-writeback (g40→g41), binary-output-crob (g10v2→g12v1), crob-select-only Write-back of the read value; CROB in SELECT-only form
BACnet/IP writeproperty-noauth Writes present-value back verbatim, preserving the original tag encoding
OPC UA anonymous-session, node-write-back Establishes an anonymous session; writes a read value back
Real physical risk

“No net change” is a design goal, not a law of physics. A PLC may react to the act of being written to — some stacks latch, some log, some fault. A SELECT without an execute leaves a control point reserved on some IEC-104 implementations. Treat intrusive mode as an operation on live plant, because that is what it is: schedule it, get authorisation, and have someone watching the process while it runs.

Examples

A dedicated OT segment scan, read-only

The common case: assess an industrial segment without touching a single register.

curl -X POST https://probe.example.com:8083/security/scan/vulns \
  -H "Authorization: Bearer <jwt-token>" \
  -H "Content-Type: application/json" \
  -d '{
        "target": "192.168.1.0/24",
        "level": "ot"
      }'

The response returns immediately with an execution UUID — scans are asynchronous:

{
  "uuid": "3f7c1a92-5b64-4e0d-9a11-8c2de4f07b35",
  "status": "inprogress"
}

A deep scan covering both web and OT

curl -X POST https://probe.example.com:8083/security/scan/vulns \
  -H "Authorization: Bearer <jwt-token>" \
  -H "Content-Type: application/json" \
  -d '{
        "target": "192.168.1.0/24",
        "level": "profundo"
      }'

An intrusive confirmation scan

Only after the configuration lock is open and the operation is authorised:

curl -X POST https://probe.example.com:8083/security/scan/vulns \
  -H "Authorization: Bearer <jwt-token>" \
  -H "Content-Type: application/json" \
  -d '{
        "target": "192.168.1.42",
        "level": "ot",
        "intrusive": true
      }'

Note the narrowed target — a single host rather than a subnet. That is deliberate, and it is the recommended practice for intrusive runs.

If allowIntrusive is false, the same request is rejected rather than silently downgraded to read-only, so you always know which mode actually ran.

Enabling OT checks on the scheduled probe

To have the periodic automatic scan include the OT read-only layer without changing its level:

securityProbes:
  vulnScan:
    enabled: true
    level: "medio"
    enableOT: true          # adds the read-only OT layer to scheduled scans
    allowIntrusive: false   # keep the master lock closed

This is the recommended steady-state configuration for a probe sitting on an industrial segment: continuous read-only OT visibility, with the write layer bolted shut.

Reading the results

OT findings surface through the same execution model as every other probe — poll GET /security/executions/{uuid} for status, and read the findings from the execution record once the status reaches finished. See Security probes for the shared asynchronous execution model.

Findings identify the protocol, the affected host and port, the check that fired, and the severity. For fingerprint checks the extracted identity (vendor, model, firmware or software version) is part of the finding, which is what makes the CVE correlation useful downstream.

Operational guidance

Start with level: "ot" on a narrow target. A /24 sweep of an industrial segment generates traffic that some networks are not used to. Validate against one host, confirm the findings make sense, then widen.

Keep allowIntrusive: false as the normal state. Open it for the duration of an authorised test window and close it again. It is a configuration change and requires a service restart, which is a feature here rather than an inconvenience — it makes the unlock deliberate and visible.

Expect true negatives. The multi-vendor Modbus fingerprint reports nothing when no listed vendor is present. That is correct behaviour, not a missed detection.

Segment scans do not need internet. The OT checks are installed with the product and run offline. If your OT network is air-gapped — and it should be — nothing about this capability degrades.