Skip to content

feat(discovery): VM discovery — network sweep, LDAP, and SSH package inventory - #118

Merged
mayankpande88 merged 8 commits into
mainfrom
feat/discovery-inventory
Aug 3, 2026
Merged

feat(discovery): VM discovery — network sweep, LDAP, and SSH package inventory#118
mayankpande88 merged 8 commits into
mainfrom
feat/discovery-inventory

Conversation

@mayankpande88

@mayankpande88 mayankpande88 commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Description

Adds pkg/proxy/discovery: the forager can now find VMs on a network segment and collect their OS and package inventory over SSH, with nothing installed on the target hosts — their agent is the sshd and package manager the OS already ships.

Three actions, deliberately separate:

Action Answers Needs credentials
discovery_sweep what is on this network no
discovery_ldap what does the directory know exists directory bind
discovery_inventory what is installed on this host SSH

Sweeps find, they do not inventory — package data always requires credentials, and that split is why these are separate actions rather than one. Nothing here decides when to run: the server schedules everything and the forager holds no state between actions.

Collection commands do not live in the binary. They ship as a versioned, Ed25519-signed content pack. A request selects a pack by version; the forager reads it from pack_dir, verifies the signature, evaluates each collector's when guard against facts probed from the host, and runs the surviving commands verbatim. Adding a distro or fixing a command is a pack release, not a fleet-wide agent upgrade — the constraint that shaped this design. Output comes back raw; parsing is server-side (nudgebee/nudgebee-enterprise#35405), so a parser fix never touches hosts.

Part of the VM discovery epic (nudgebee/nudgebee-enterprise#35404).

Fixes #113
Fixes #114

File Role
sweep.go CIDR expansion, rate-limited TCP probing, rDNS
arp_linux.go / arp_other.go Neighbour-cache MAC enrichment
ldap.go AD computer objects, staleness filter, GUID decoding
pack.go Pack format, signature verification, collector selection
when.go Guard language — deliberately minimal
facts.go Probes os_family/os_id/os_major/arch
executor.go Bounded-concurrency SSH fan-out, per-host isolation
proxy.go Module wiring, config, scope enforcement, pack cache

Decisions worth reviewing

Sweep safety is contract, not tuning. Only well-formed TCP connects — no crafted packets, because the malformed probes port scanners emit are what destabilize embedded and OT gear, and a discovery tool that knocks over a PLC has failed regardless of what it found. The rate cap is enforced in the forager: the server asks for a rate, the forager still refuses to exceed its own. Exclusions are applied during address expansion so an excluded host is never handed to a prober. Scope is enforced twice — a requested CIDR outside allowed_cidrs is refused, so one bad or malicious request cannot turn a segment collector into a general-purpose scanner.

No raw sockets. MACs come from reading the kernel's neighbour cache after probing rather than sending ARP frames, so the forager needs no CAP_NET_RAW. A discovery node that requires elevated privileges is a much harder thing to get deployed into a customer's network. The honest cost: a host with none of the probed ports open is invisible to a sweep — which is exactly why the directory and hypervisor sources exist.

LDAP staleness filtering is correctness, not optimization. AD accumulates tombstones of machines decommissioned years ago; importing them would report permanent phantom gaps in the coverage report. A machine that has never logged on is kept — that is a new host, not a stale one, and the two are easy to conflate.

Requests select a pack; they cannot carry one. Pack bodies reach pack_dir through the distribution pipeline, never through an action, so no part of an action payload is ever executed.

The guard language is intentionally tiny==/!= against a quoted literal over four known facts. It should not grow: anything richer is an expression evaluator inside a binary that runs signed content against production hosts.

Failures are data, not errors. One unreachable host never fails a batch. Per-target statuses (ssh-refused, ssh-auth-failed, timeout) are exactly the coverage information Phase 0 exists to produce.

Known gaps

  • Host keys are unverified unless known_hosts_file is set. Discovery finds hosts nobody has catalogued, so their keys are unknown on first contact and change when a VM is re-imaged; strict-by-default would break the module's purpose. Residual risk is bounded — read-only collection, unprivileged credential, signature-verified commands. Server-side key pinning removes the tradeoff and belongs with that work.
  • IPv6 sweeps are unsupported — enumerating a v6 prefix by address is not viable; v6 discovery needs the directory or hypervisor sources.
  • Powered-off VMs are invisible until the hypervisor connector ([VM Discovery P4] Hypervisor connector (blocked: target hypervisor confirmation) #115) lands. Neither a sweep nor a reachability check can see a machine that is switched off.

Type of change

  • New feature (non-breaking change which adds functionality)

How Has This Been Tested?

  • Unit tests

make validate passes: lint 0 issues, -race clean, package at ~78% coverage.

Sweep and LDAP:

  • Network/broadcast addresses excluded; /31 point-to-point handled correctly
  • Exclusions verified removed before probing, both single addresses and ranges
  • Rate cap verified by timing: a sweep provably cannot finish faster than the cap allows
  • Oversized scope (a /8) and IPv6 rejected rather than attempted
  • CIDR outside the datasource scope refused; a wider prefix than configured also refused
  • objectGUID decoded with AD's mixed-endian byte order (wrong order would split one machine into two assets)
  • "Never logged on" distinguished from "stale"; bind DN redacted from LDAP errors

Inventory (from the earlier rounds):

  • Tampered pack, wrong key, unsigned pack, and 9 malformed-pack shapes rejected
  • TestHandleInventory_NoUnverifiedCommandPath pins the core security invariant across all six ways a pack can fail to verify, including that an inline body is refused
  • Real collection against an in-process sshd for Debian-like and RHEL-like hosts; rpm/dpkg output byte-exact (epoch and release suffix intact — epic 2 depends on it)
  • 1 dead host in 10 → 9 ok + 1 classified failure, batch succeeds
  • 100 concurrent targets with peak concurrency provably within the limit
  • Credentials absent from both logs and responses

Review rounds

Three real defects found in review, each fixed with a regression test:

  1. Pipe deadlock — stdout and stderr were read sequentially, so a collector whose stderr exhausted the SSH channel window before writing stdout hung until timeout. Now drained concurrently, each followed by io.Copy(io.Discard, ...) past the cap so an over-producing command can still exit. Verified the test discriminates: it fails against the old code after a 3s stall. (A first attempt using 256KB of stderr passed against the buggy code — the ~2MB channel window absorbed it. It only reproduces above the window.)
  2. Data raceisTargetAllowed read the CIDR allowlist without the lock Configure writes under. Now snapshotted under a short read lock, kept off the slow DNS path.
  3. CRLF signature failures — a pack signed with \n failed verification after a CRLF checkout, indistinguishably from tampering. Line endings are normalized before computing signed bytes; a test confirms tamper detection still works on CRLF input.

The CodeQL command-injection finding was resolved by a design change (packs selected by version, never carried in a request) rather than by dismissing the alert. The insecure-host-key finding was resolved by adding known_hosts_file.

Checklist

  • CLA signed (the CLA bot will prompt on your first PR)
  • make validate passes (fmt + lint + test)
  • Docs updated if the wire shape, config surface, or proxy module behavior changed

Adds pkg/proxy/discovery implementing the discovery_inventory action:
collects OS and package inventory from VMs over SSH with nothing
installed on the targets.

Collection commands ship as versioned, Ed25519-signed content packs
rather than living in the binary, so adding a distro or fixing a
command is a pack release instead of a fleet-wide agent upgrade. The
agent verifies the signature, evaluates each collector's when-guard
against facts probed from the host, runs the commands verbatim, and
returns raw output — parsing stays server-side.

One host failing never fails the batch: per-target statuses
(ssh-refused, ssh-auth-failed, timeout) are the coverage data phase 0
exists to produce.

Exports signing.ParsePublicKey so packs verify against the same trust
root as signed actions.

Refs #113
Comment thread pkg/proxy/discovery/executor.go Fixed
Comment thread pkg/proxy/discovery/proxy.go Fixed

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a new SSH-based discovery proxy package to collect OS and package inventory from VMs. The implementation uses signed, versioned content packs to evaluate guards and run commands without agent updates. The review feedback highlights several critical improvements: resolving a potential deadlock in SSH session pipes by reading stdout and stderr concurrently, fixing a data race in target allowance checks by properly locking configuration fields, ensuring robustness against platform-specific line endings during signature verification, and using the standard errors.As function for idiomatic error handling.

Comment thread pkg/proxy/discovery/executor.go
Comment thread pkg/proxy/discovery/proxy.go
Comment thread pkg/proxy/discovery/pack.go
Comment thread pkg/proxy/discovery/executor.go
- Drain stdout and stderr concurrently. Reading them in sequence
  deadlocked once a collector's stderr exhausted the SSH channel
  window before any stdout was written; the regression test fails
  against the old code after a 3s stall.
- Snapshot the CIDR allowlist under a read lock in isTargetAllowed.
  Configure could replace it concurrently.
- Add known_hosts_file so customers who can supply host keys get real
  SSH host key verification; unreadable file now fails configuration
  instead of silently accepting any key.
- Normalize line endings before computing signed pack bytes: a CRLF
  checkout otherwise fails verification indistinguishably from
  tampering.
- Use errors.As for ssh.ExitError so wrapped errors are handled.

Refs #113
Comment thread pkg/proxy/discovery/executor.go Fixed
Adds a test covering every path into command execution — missing pack
key, unsigned pack, pack signed by another key, and an uncached
version — so a future refactor cannot quietly allow an unverified
command through.

Documents at the exec call why static analysis flags it: signature
verification is the sanitizer, and CodeQL cannot model it.

Refs #113
Comment thread pkg/proxy/discovery/executor.go Fixed
A request now selects a content pack by version; it can no longer
carry the pack body. Pack bytes come only from pack_dir, already
verified against the configured Ed25519 key.

The inline parameter was a demo convenience, not the production model
(the ticket specifies a pack ref, and P6 distributes packs to disk).
Removing it keeps executable content off the request path entirely
rather than relying solely on the signature check to gate it, and
resolves the CodeQL command-injection finding honestly instead of by
dismissal.

Also refuses a pack whose declared version differs from the requested
one — mislabelled results would corrupt server-side correlation.

Adds a test that output exceeding max_output_bytes returns truncated
rather than stalling, covering the second half of the pipe-draining
review comment.

Refs #113
@mayankpande88

Copy link
Copy Markdown
Contributor Author

/gemini review

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a new SSH-based discovery proxy module to collect OS and package inventory from VMs concurrently using signed, versioned content packs. Feedback on the implementation highlights a potential performance bottleneck due to sequential DNS lookups during target validation, a bug where plain IP addresses without CIDR masks in the allowed CIDRs list are parsed incorrectly, and fragility in stripping the signature line from YAML documents.

Comment thread pkg/proxy/discovery/proxy.go Outdated
Comment thread pkg/proxy/discovery/proxy.go
Comment thread pkg/proxy/discovery/pack.go
Implements P3 (#114): the forager can now find hosts it was not told
about, not only inventory a supplied list.

discovery_sweep probes CIDRs with plain TCP connects. Safety is part
of the contract rather than tuning: only well-formed connections (no
crafted packets that destabilize embedded and OT gear), a rate cap the
forager enforces regardless of what the server requests, exclusions
applied during address expansion so an excluded host is never probed,
and requested CIDRs refused unless contained by the datasource's
allowed_cidrs — one bad request must not turn a segment collector into
a general-purpose scanner.

MAC addresses come from reading the kernel neighbour cache after
probing rather than sending ARP frames, so the forager needs no raw
sockets or CAP_NET_RAW. A discovery node requiring elevated privileges
is far harder to get deployed.

discovery_ldap lists AD computer objects, skipping tombstones older
than active_within_days — importing machines decommissioned years ago
would report permanent phantom gaps in the coverage report. A machine
that never logged on is kept: that is a new host, not a stale one.
objectGUID is decoded with AD's mixed-endian byte order since it is a
STRONG merge identifier, and bind failures are redacted because they
echo the bind DN back.

Refs #114, #113
@mayankpande88 mayankpande88 changed the title feat(discovery): SSH inventory via signed content packs feat(discovery): VM discovery — network sweep, LDAP, and SSH package inventory Aug 2, 2026
Caught by CI, not locally: arp_linux.go sits behind a linux build tag,
so lint on darwin never compiled it. Verified with GOOS=linux.
@mayankpande88

Copy link
Copy Markdown
Contributor Author

/gemini review

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a new discovery proxy module designed to find VMs in a network segment and collect their OS and package inventory over SSH, as well as query computer objects from Active Directory. Key feedback focuses on optimizing performance by replacing fmt.Sscanf with strconv.ParseInt in ldap.go, handling context cancellation for LDAP operations, and addressing potential performance bottlenecks. Specifically, it is recommended to resolve target hostnames and reverse DNS lookups concurrently with bounded concurrency to prevent blocking or overwhelming DNS resolvers. Additionally, the sweep worker pool should scale with the requested probe rate to avoid capping scan speeds on dead hosts, and a safety check should be added to prevent shift overflow when expanding small CIDR blocks.

Comment thread pkg/proxy/discovery/ldap.go Outdated
Comment thread pkg/proxy/discovery/ldap.go Outdated
Comment thread pkg/proxy/discovery/ldap.go
Comment thread pkg/proxy/discovery/proxy.go
Comment thread pkg/proxy/discovery/sweep.go
Comment thread pkg/proxy/discovery/sweep.go Outdated
Comment thread pkg/proxy/discovery/sweep.go
… checks

Three findings from review:

Signature-line stripping now requires exactly one top-level line and
matches only at column 0 (tolerating space before the colon). Because
verification removes these lines before checking the signature, every
line the stripper removes is a line an attacker could add or alter
without invalidating it. A second one was already blocked downstream —
yaml.v3 rejects the duplicate key — but that is a property of the
parser, not a guarantee this package makes, and the signature check
must not rest on it. Both injection variants are now regression tests.

A bare IP in allowed_cidrs became a hostname entry, so a target given
by name that resolved to it never matched: name resolution is only
compared against the network list. Bare addresses now become /32 or
/128 networks, matching what the sweep parser already did.

Scope checks run concurrently. Each hostname target costs a DNS
lookup, so a batch of named hosts previously blocked the handler for
the sum of every lookup before a single host was contacted. Order is
preserved so results stay correlatable.

Refs #113, #114
…able workers

More review findings:

go-ldap has no context-aware operations, so a cancelled action kept a
directory query alive for up to timeout_seconds after nobody was
waiting. Cancellation now closes the connection, failing an in-flight
bind or search immediately.

Address-space size was computed as int, so 1<<32 for a /0 overflows on
32-bit platforms: the scope would silently expand to nothing rather
than be rejected. Computed as int64 now, with a test.

Reverse DNS lookups were unbounded — a sweep finding many hosts fired
that many simultaneous queries at the customer's resolver, which looks
like a DNS flood and is liable to be treated as one. Bounded to 32.

Sweep workers are configurable (default 64, max 512), which helps when
a scope is mostly dead addresses and each probe costs a full timeout.
A test asserts raising workers cannot raise the probe rate: the
limiter owns rate, and the cap is a safety property no request
parameter may erode.

strconv.ParseInt replaces fmt.Sscanf for the AD timestamp and
userAccountControl parsing.

Refs #113, #114
@mayankpande88
mayankpande88 merged commit c2ad9c7 into main Aug 3, 2026
6 checks passed
@mayankpande88
mayankpande88 deleted the feat/discovery-inventory branch August 3, 2026 08:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[VM Discovery P3] Network sweep + Active Directory lookup [VM Discovery P1] SSH inventory + signed content-pack runner

3 participants