yuzu

discovery

What it doesNetwork device discovery — ARP scan and ping sweep
Version0.1.0
KindCollector · read-only · gathered (device.discovery.scan_subnet)
PlatformsWindows ✅ · macOS ✅ · Linux 🟡 constrained
Actionsscan_subnet (definition device.discovery.scan_subnet)
Securitysecurable Infrastructure · operation Read · risk Low · dispatch ReadOnly · approval gate AdminOrApproval
Rolesexecute: endpoint-admin · author: content-author

How it works

scan_subnet combines two reads: the OS ARP/neighbour table (hosts the kernel already knows about) and an ICMP ping sweep of every host address in the requested subnet (hosts that answer but aren’t yet cached). The ARP table is read twice — once before the sweep, once after — so hosts that only just replied to a probe still get a MAC address in the output. Input is validated as a CIDR block first (digits/dots/slash only), then parsed into octets and a prefix length; only /24 through /30 is accepted, a wider prefix is refused rather than scanned to bound the sweep to 254 hosts.

Every degrade condition the scan can hit — an ARP read that failed or was truncated, an ICMP socket denied or unavailable, probes that could not transmit, the scan’s own 300-second deadline, or throttled reverse-DNS lookups — is accumulated by severity across the whole run and applied to the typed result status exactly once at the end, so an earlier and more specific reason is never silently overwritten by a later, less specific one. The plugin deliberately never reports an empty network as a clean success when a read simply didn’t happen: a degraded read yields real ARP-table hosts with a CONSTRAINED/UNAVAILABLE status, not a fabricated empty result.

It is deliberately not a subprocess wrapper: Wave 2 removed the last popen()/ping spawn, so every leg is a native, in-process API call.

flowchart LR
  OP[Operator / workflow<br/>dispatches definition] --> SRV[Server<br/>authz: Infrastructure.Read<br/>approval gate: AdminOrApproval]
  SRV -- gRPC mTLS --> HOST[Agent plugin host]
  HOST --> EX[discovery.execute]
  EX --> WIN[Windows leg<br/>GetIpNetTable2 + IcmpSendEcho]
  EX --> MAC[macOS leg<br/>sysctl NET_RT_FLAGS/RTF_LLINFO + SOCK_DGRAM ICMP]
  EX --> LIN[Linux leg<br/>/proc/net/arp + SOCK_DGRAM ICMP<br/>gated by net.ipv4.ping_group_range]
  WIN & MAC & LIN --> ROWS[host / scan_complete rows +<br/>typed result status]
  ROWS -- CommandResponse --> RS[(ResponseStore<br/>server-configured retention)]
  RS --> API[REST /api/responses]

OS capability

ActionWindowsmacOSLinux
scan_subnet✅ supported · rung 1 · GetIpNetTable2 + IcmpSendEcho✅ supported · rung 1 · sysctl NET_RT_FLAGS/RTF_LLINFO + SOCK_DGRAM ICMP🟡 constrained · rung 1 · /proc/net/arp + unprivileged SOCK_DGRAM ICMP

Declared limits per leg (descriptor fallback text, verbatim):

Privileges and prerequisites

OSRuns asExtra grant neededMeasuredIf the read is refused
Windowsagent service account (captured as SYSTEM; no dedicated row in docs/agent-privilege-model.md for this plugin)None. GetIpNetTable2 and IcmpSendEcho (linked via iphlpapi/ws2_32) need no elevated handle open.2026-09-07, bare-metal, Windows 10.0.26200.0, SYSTEMARP failure → UNAVAILABLE/PARTIAL arp:read_failed; ICMP session unavailable → UNAVAILABLE/PARTIAL icmp:socket_error
macOSagent daemon (captured unprivileged, euid 501)None. The routing-socket sysctl read and an unprivileged SOCK_DGRAM ICMP socket need no elevated privilege.2026-09-07, bare-metal, macOS 26.6.2, euid 501 (jsmith)ARP failure → UNAVAILABLE/PARTIAL arp:read_failed; ICMP session unavailable → UNAVAILABLE/PARTIAL icmp:socket_error
Linuxagent daemon (captured as euid 0 in a container; production identity not pinned by a row in docs/agent-privilege-model.md)net.ipv4.ping_group_range must admit the agent’s gid for the unprivileged ICMP socket, or the sweep falls back to ARP-only.2026-09-07, Debian GNU/Linux 13 (trixie) aarch64, container, euid 0ICMP denied → CONSTRAINED/PARTIAL icmp:ping_group_range; ICMP unavailable for any other reason → UNAVAILABLE/PARTIAL icmp:socket_error; /proc/net/arp unreadable → UNAVAILABLE/PARTIAL arp:read_failed

No external binaries or subprocesses on any leg (the last popen()/ping spawn was removed in Wave 2). Network: yes — sends ICMP echo probes to every host address in the requested subnet, plus a reverse-DNS (PTR) query for each host found alive.

Data contract

Inputs

DefinitionParameterTypeRequiredDefaultConstraintsDescription
device.discovery.scan_subnetsubnetstringyes-pattern: ^\d{1,3}.\d{1,3}.\d{1,3}.\d{1,3}/\d{1,2}$ · minLength 9 · maxLength 18Target subnet in CIDR notation (e.g., 192.168.1.0/24). The plugin accepts only /24 through /30 (254 down to 2 usable hosts) — a request outside that range is refused with an error (“too many hosts to scan” or “contains no usable host addresses”) rather than scanned. Only IPv4 is supported.
device.discovery.scan_subnettimeout_msint32no1000minimum 100 · maximum 10000Per-host ICMP probe timeout in milliseconds, validated to 100-10000. The effective per-host budget is separately clamped to 100-300ms regardless of the requested value, so a value above 300 has no further effect. This is not the overall scan deadline, which is a fixed 300 seconds.

Outputs

The plugin writes four distinct pipe-delimited line shapes, only two of which are structured data: a host row per discovered host, and one scan_complete summary row per run. status| (error/warning) and progress| lines are free text used for operator-facing progress and degrade warnings; they are not represented in the definition’s result.columns schema at all. A run that fails input validation emits only a status|error|... line and no host/scan_complete rows (not the case in any of the current captures, which all completed a real sweep).

device.discovery.scan_subnet — host|ip_address|mac_address|hostname|managed|scan_complete

FieldTypeValuesAvailableExampleDescription
hoststringhostWindows, Linux, macOShostLiteral row-type discriminator marking this line as a discovered-host row, as opposed to a status/progress/ scan_complete line, which this schema does not separately enumerate.
ip_addressstring-Windows, Linux, macOS127.0.0.1IPv4 address of a host found in the target subnet, either already present in the OS ARP/neighbour table or reached by an ICMP probe. Values: free text (dotted-quad IPv4).
mac_addressstring-Windows, Linux, macOSunknownColon-separated lowercase MAC48 read from the ARP/neighbour table, or the literal “unknown” when the host has no ARP entry. Values: colon-hex MAC48, or “unknown”.
hostnamestring-Windows, Linux, macOSlocalhostReverse-DNS (PTR) result for the IP, or the literal “unknown” when the lookup found nothing, timed out, or was throttled. Values: free text (PTR hostname), or “unknown”.
managedstringunknownWindows, Linux, macOSunknownAlways the literal “unknown” from the agent — the server correlates the IP/MAC against enrolled agents separately.
scan_completestringscan_completeWindows, Linux, macOSscan_completeLiteral row-type discriminator for the scan’s one summary line, emitted once per run; the same line also carries “found” and “total” host counts as two further pipe-fields not enumerated as separate columns here.

Result status

StatusCompletenessProvenanceWhen
OK (derived from UNDECLARED)——a clean scan hit none of the conditions below
CONSTRAINEDPARTIALicmp:ping_group_rangeLinux ICMP socket denied for a permissions reason — ARP-table hosts only
UNAVAILABLEPARTIALicmp:socket_errorICMP socket construction failed for a non-permissions reason — ARP-table hosts only
CONSTRAINEDPARTIALicmp:transmit_blockedevery ICMP probe was constructed but could not transmit
UNAVAILABLEPARTIALarp:read_failedthe ARP/neighbour table read failed outright
CONSTRAINEDPARTIALarp:table_truncatedthe ARP/neighbour table was only partially decodable
CONSTRAINEDPARTIALscan:timeoutthe scan hit its 300-second deadline; wins ties against arp:table_truncated/dns:hostname_lookup_degraded
CONSTRAINEDPARTIALdns:hostname_lookup_degradedone or more reverse-DNS lookups timed out or were throttled

Multiple conditions can fire in one run; they are accumulated by severity (UNAVAILABLE > CONSTRAINED/PERMISSION_DENIED > none) and the worst one is applied to the result status exactly once, at the end of the run. Every condition still writes its own status|warning line regardless of which one wins the merge. set_result_status is called only inside if (worst_degrade.has_report) — a clean run that hits none of the conditions above never calls it at all, so even a fully successful scan reports UNDECLARED/UNKNOWN rather than an explicit OK. All three current samples are exactly this case: a real 127.0.0.0/30 sweep with no ARP/ICMP/timeout degrade, so every leg shows UNDECLARED / UNKNOWN /.

Where the data goes

Sample output

Windows — captured: windows Microsoft Windows NT 10.0.26200.0 x64 · bare-metal · 2026-09-07 · SYSTEM · leg-hash 77514adb726f

== action=scan_subnet subnet=127.0.0.0/30
host|127.0.0.1|unknown|kubernetes.docker.internal|unknown
host|127.0.0.2|unknown|unknown|unknown
scan_complete|2|2
[result_status] UNDECLARED / UNKNOWN

macOS — captured: macos macOS 26.6.2 arm64 · bare-metal · 2026-09-07 · euid 501 (jsmith) · leg-hash 77514adb726f

== action=scan_subnet subnet=127.0.0.0/30
host|127.0.0.1|unknown|localhost|unknown
scan_complete|1|2
[result_status] UNDECLARED / UNKNOWN

Linux — captured: linux Debian GNU/Linux 13 (trixie) aarch64 · container · 2026-09-07 · euid 0 · leg-hash 77514adb726f

== action=scan_subnet subnet=127.0.0.0/30
host|127.0.0.1|unknown|localhost|unknown
host|127.0.0.2|unknown|unknown|unknown
scan_complete|2|2
[result_status] UNDECLARED / UNKNOWN

Caveats and known gaps

  1. A clean scan reports UNDECLARED, not OK. All three legs completed a real 127.0.0.0/30 loopback sweep with no ARP/ICMP/timeout degrade, so set_result_status — which fires only inside if (worst_degrade.has_report) — is never called. The agent records UNDECLARED/UNKNOWN for what is otherwise a fully successful scan; this is not an error, but it means a machine consumer cannot distinguish “clean scan” from “no status was ever set” by result status alone. Also visible in the same captures: 127.0.0.1 resolved a hostname (localhost on macOS/Linux, kubernetes.docker.internal on Windows) while 127.0.0.2 did not (unknown) and, on macOS, was not found alive at all (scan_complete|1|2 vs 2|2 on Linux/Windows) — loopback interface behaviour differs per OS, and mac_address is unknown on every leg because a loopback address has no ARP/neighbour-table entry.
  2. scan_subnet’s results are not wired into DiscoveryStore. POST /api/discovery/scan is the only path that populates the managed/unmanaged device table, and nothing in this plugin or the agent daemon calls it — the instruction result is the only place a scan’s rows land today.
  3. Linux ICMP is permission-gated. An unprivileged SOCK_DGRAM ICMP socket needs net.ipv4.ping_group_range to admit the agent’s gid; without it the scan falls back to ARP-only hosts with CONSTRAINED/PARTIAL icmp:ping_group_range rather than reporting a clean, smaller network.
  4. The definition’s result.columns names one row shape but the plugin emits four. Only host (5 pipe-fields) and scan_complete (3 pipe-fields, of which found/total are not separate columns) rows are structured; status| and progress| lines are free text and not represented in the schema.
  5. Was rung 3 (subprocess), now rung 1 (native). The plugin previously shelled out via popen()/ping per host (declared rung 3 alongside firewall/quarantine/network_actions); Wave 2 replaced every leg with a native, in-process API and a single shared ICMP session per scan. Do not reintroduce a per-host process spawn.

Source and tests