Skip to content

Command and Control (C2) ​

Command and Control is the enterprise (business tier and above) subsystem for managing your own fleet of authorized scanner agents from the consultation portal. An agent is a netvuln_daemon.sh process running on a scanner host that you control; C2 lets a portal administrator queue commands for one agent or a group, have the agent execute them, and report results back. It is authorized fleet management, not remote access to third-party systems, and it requires portal connectivity plus jq on the agent.

The agent side is lib/command_handler.sh; the transport is the daemon heartbeat channel described in Daemon Mode.

How Commands Reach an Agent ​

Commands ride back on the heartbeat response, or are picked up by an idle-tick poll; no inbound port is needed on the agent:

Admin (portal dashboard / API)              Agent (netvuln_daemon.sh)
------------------------------              --------------------------
POST /api/agent-commands  ── queue ──▶  agent_commands (status = pending)
                                                │
                          ┌── heartbeat POST ───┘   (every DAEMON_HEARTBEAT_INTERVAL)
                          │  or GET /api/agent-commands (every DAEMON_POLL_INTERVAL, idle ticks)
                          ▼
    response/poll returns pending commands (status → sent)
                          │
                          ▼
             nv_process_commands() executes each command
                          │
             PATCH /api/agent-commands  (status → acked, running, then completed / failed)
                          │            ▲
                          │      keeper posts status: busy heartbeats
                          │      (current_command) every DAEMON_HEARTBEAT_INTERVAL
                          │      while the command runs
                          ▼
              command_results (exit_code, stdout, stderr, session_id)

The heartbeat piggyback returns up to 20 commands per cycle; an explicit GET /api/agent-commands?agent_id=… poll returns up to 50. Since #384 the daemon also polls this endpoint on every idle DAEMON_POLL_INTERVAL tick (default 60s, DAEMON_COMMAND_POLL to disable); the heartbeat piggyback remains a fallback for when the poll is disabled or misses a beat.

Requirements and Gating ​

  • License tier: business (msp also qualifies, as the higher tier). nv_process_commands() calls nv_require_tier c2; below business, command processing is skipped as a soft gate (a warning, never an error). See License Provisioning.
  • Portal connectivity: the agent config must define CONSULTATION_API_URL and an API key (NV_API_KEY, or the legacy CONSULTATION_API_KEY).
  • jq must be installed on the agent.

Agent Identity and Lifecycle ​

Identity. Each agent has an id of the form nvd_<first 8 hex of md5(hostname)> (for example nvd_89f6e23d), derived automatically. NVD_AGENT_ID overrides it (used in tests).

Registration. Agents auto-register on their first heartbeat. There is no approval step and no allowlist; the portal records the agent and emits an agent_first_seen audit event.

Deregistration (soft). An administrator can deregister an agent (portal PATCH /api/agents, action: deregister), which sets a deregisteredAt timestamp. On the agent's next heartbeat the portal returns HTTP 403; the daemon logs Agent has been unregistered by administrator and shuts down. The record is retained (hidden from the fleet list) and can be re-enabled with action: reenable.

Purge (hard). An already-deregistered agent can be permanently removed (portal DELETE /api/agents / action: purge), which deletes its heartbeat row, queued commands, results, and group memberships. Audit history is retained and an agent_purged event is appended.

Command Priority ​

priority is an integer 0-10 set when a command is queued. It is a portal-side dispatch-ordering hint only:

  • Both the poll and the heartbeat piggyback select pending commands ORDER BY priority DESC, created_at ASC, so within a dispatch batch a higher priority is handed over first, oldest-first as the tiebreak.
  • The agent does not read priority. Once commands reach the agent they run in the order received.

Use a higher priority to jump a command ahead of already-queued work for the same agent (for example a restart ahead of a backlog of scans).

Command Reference ​

All commands are queued with POST /api/agent-commands; payload is a JSON object. The source of truth for the keys each command reads is lib/command_handler.sh.

run_scan ​

Runs vulnscan_recon.sh on the agent.

keyrequireddefaultnotes
targetsyesnonehost(s)/CIDR(s), comma-separated. Empty fails with "No targets specified for run_scan".
profilenofull_reconfull_recon/full add --full; quick/quick_recon add -P quick_recon; any other value adds no mode flag.
uploadnofalsewhen true, the resulting session (session JSON + pipeline log + report HTML + exec summary) is pushed to the portal after a successful scan, using the daemon's credentials.
confignononeconfig basename without .conf (a trailing .conf is tolerated); must match ^[a-zA-Z0-9_-]{1,64}$. A value with a slash, .., or an empty string is never a match (#398).

run_scan parses the session id it created from the scan output and reports it back, so the portal's Command History row links to the session detail.

Which config a C2 scan runs under: the agent resolves the client config in order: the requested config, if it names a loaded config (reason requested); otherwise the loaded config whose CLIENT_ID matches the agent's own registered client id, the first loaded config's id (reason client_match); otherwise the sole loaded config, if exactly one is loaded (reason single); otherwise no config at all (reason none). The command's stdout names the outcome: Using config for this scan: <name> (reason: requested|client_match|single), or, when nothing matched, no client config matched for this agent; scanning without -c, report will lack client name and assessor. Before this change, run_scan never passed a config, so every C2-triggered report showed a blank client and the OS account as assessor; the scheduled-scan path was unaffected (#398).

push_session ​

Uploads an existing session on the agent to the portal on demand, using the daemon's credentials.

keyrequireddefaultnotes
session_idnolatestspecific session id (YYYYMMDD_HHMMSS); defaults to the most recent session. The id is validated against the session-name pattern, so it cannot traverse out of the sessions tree.

Use this when a run_scan was dispatched without upload: true, or to re-push a session.

update_schedule ​

Updates an in-memory schedule entry loaded from a config file.

keyrequireddefaultnotes
schedule_idyesnonemust match a loaded schedule.
actionnoupdateupdate, create, or delete.
cronnononenew cron expression.
profilenononenew scan profile.
targetsnononenew targets.

delete is acknowledged but does not remove the config file (that requires manual action).

push_config ​

Updates a single allowlisted daemon setting (see below).

keyrequirednotes
config_keyyesmust be in the allowlist.
config_valueyesthe value to set (control characters are rejected).

push_session benchmark token ​

push_session's post_scan array also accepts benchmark (#430). The agent uploads the session, fetches comparative benchmark data from the portal, refreshes the report, and uploads again, so the Benchmark Comparison card on /collect/ fills for that session; the share token is preserved. A failure at any step is a [WARN] post_scan: benchmark ... line in the output and the push still succeeds. An older daemon drops the unknown token silently.

run_script ​

Runs a fixed catalog of read-only scripts on the agent, one after another, and returns one output with a section per script (#430). Admins use it from the portal Dispatch tab to check an agent without shell access.

keyrequireddefaultnotes
scriptsyesnone1 to 10 names matching ^[a-z_]{1,40}$. Never a path or an argument.
confignoresolvedconfig basename (same rules and resolution order as run_scan); supplies the license key to the child scripts
timeoutno600total seconds for the whole command, clamped to 1800; a non-number or a value below 1 means the default

Catalog (v1, latest session only):

namerunsown limit
scan_diffscripts/scan_diff.sh --json -c <latest session>/session_results.json120 s
schedule_statusscripts/schedule_status.sh60 s
schedule_listscripts/schedule_list.sh --no-cron-check60 s

The agent advertises these names in the heartbeat capabilities.run_script field. The portal offers only advertised names, and an agent that reports none is never sent the command. Output sections look like:

=== schedule_status (exit 0) ===
...
=== scan_diff (exit 2) ===
[scan_diff] NEW_CRITICAL_HIGH=1
...
skipped: schedule_list (time budget exhausted)

Each script is capped at 10 KB, CSI and OSC escape sequences, other control bytes and invalid UTF-8 are stripped, and the whole output stays under 50000 characters. scan_diff exit 2 (new critical or high findings) is a success. The command exits 0 when at least one script ran and every script that ran succeeded, else 1 (1 with stderr run_script: no scripts ran when every entry was skipped). A script that hits its limit shows exit 124; later scripts still run until the total budget is spent, and the rest are skipped: <name> (time budget exhausted). Each child gets a kill grace (5 s, or 1 s when 10 s or less remain) reserved inside the total budget, enforced where timeout(1) is available (without it the child runs with no limit). scan_diff uses the newest finished session (non-empty metadata.end_time), never one still in progress. The skipped: reasons are invalid name, duplicate, not in catalog, no session available, time budget exhausted, and not supported (trimmed by the portal). The schedule scripts read the agent's own base directory (NVD_BASE_DIR, as the daemon does), so they work on /opt installs as well as the home-directory layout.

set_log_level ​

keyrequiredaccepted
levelyesdebug, info, warning, error.

restart ​

No payload. Unconditionally re-execs the daemon (a full process replacement, same PID), so it re-sources every library and reports the new version within one heartbeat cycle. Works the same in a container as elsewhere, since it does not touch git. Uptime and last-heartbeat reset, which is expected.

shutdown ​

keyrequireddefaultnotes
gracefulnotrueboolean; signals the daemon to shut down.

update ​

Self-updates the agent from git (requires the agent to be a git checkout). On success, the daemon re-execs itself (same PID) when the checkout HEAD actually advanced; an already-up-to-date pull still reloads config but does not re-exec. Uptime and last-heartbeat reset after a real update, which is expected. Container installs have no .git directory in the published image, so they cannot self-update this way; update them by re-pulling the published image tag and recreating the container.

keyrequireddefaultnotes
branchnomaingit branch to check out and pull. Must match [A-Za-z0-9._/-]+, must not start with -, and must be a valid git branch name (no .., @{, leading or trailing /, or .lock suffix); anything else fails the command with Invalid branch name: ... before git runs (#429).
versionnononetag vX.Y.Z to check out (takes precedence over branch).

Credentials for self-update. update needs a git remote the daemon's user can fetch from without an interactive prompt. A checkout of the public repository needs no credential; on a private remote, provision whatever read-only credential your forge supports for the daemon's user first. When authentication fails, update now reports a distinct diagnosis before it ever reaches a checkout, rather than the misleading tag-not-found error a failed fetch used to leave behind. See the C2 Guide's "Credentials for self-update" section (under update in the command reference) for the exact diagnosis text.

Configuration Editing and Persistence ​

push_config (and the portal's Runtime Config editor) can change only an allowlist of safe keys:

DAEMON_POLL_INTERVAL       DAEMON_HEARTBEAT_INTERVAL
DAEMON_SCAN_RETRY_MAX      DAEMON_SCAN_RETRY_BACKOFF
DAEMON_LOG_LEVEL           DAEMON_KEEPER_HEARTBEAT
DAEMON_COMMAND_POLL        ALERT_ENABLED
ALERT_RISK_THRESHOLD       ALERT_ON_NEW_CRITICAL

Any key outside this list is rejected ("Config key not in remote-update allowlist"). Scan targets, schedules, storage path, concurrency, and enabled modules are not remotely editable; they are managed in the config file.

A pushed change is applied to in-memory daemon state (live effect) and persisted so it survives a reload and a full restart:

  • push_config writes each accepted KEY=VALUE to <configs_dir>/.c2_overrides.conf, a dotfile that the *.conf schedule glob never picks up, stored mode 600 and upserted (last write per key wins).
  • On reload, _nvd_load_configs() re-applies that file as the final layer after the .conf files, so a pushed override beats the config-file value.
  • The allowlist is re-validated on reload, and newline/CR characters are refused, so a tampered overrides file cannot smuggle in an arbitrary key or extra line.

To clear a pushed override, delete or edit .c2_overrides.conf on the agent and reload the daemon (SIGHUP).

Execution Details ​

  • Timeout: each command runs under NV_CMD_TIMEOUT (default 300 seconds). A command that exceeds it is killed and reported failed (exit 124). A full_recon scan against a large range commonly exceeds 300s; run those on the host directly or raise NV_CMD_TIMEOUT. run_script ignores NV_CMD_TIMEOUT and uses its payload timeout (default 600 s, clamp 1800 s) with a per-script limit.
  • Output capture: stdout and stderr are truncated to 10 KB in the result. ANSI escape codes (color and cursor sequences a scanned command's output may carry) are stripped before reporting, so the portal never has to render raw escape bytes. run_script builds its own per-script 10 KB cap and a 50000-character total (#430).
  • Console color output: separate from the C2 capture above, nv_print_color() (used by nv_info/nv_warning/nv_error/nv_success/nv_print_header/nv_print_section throughout the tool) only emits ANSI color when stdout is an interactive terminal and NO_COLOR (see no-color.org) is unset; otherwise it prints plain text, so raw escape sequences no longer leak into pipes, log files, or captured output (#255). One consequence: a script that captures nv_print_color's own output into a variable via $(...) to build a colored table cell renders that cell plain even when run interactively, because a command-substitution subshell is not a TTY.
  • Status flow: pending → sent → acked → running → completed or failed. Terminal states insert a command_results row.
  • Result reporting: _nv_report_command_status() PATCHes the derived ${api_url%/upload}/agent-commands endpoint with the x-api-key header, sending command_id, agent_id, status, and (when present) exit_code, stdout, stderr, and session_id. Reporting is non-fatal.

Dispatching a Command ​

Admin dashboard → Command and Control → Dispatch: choose an agent (or group), pick a command, fill the guided form (or raw JSON payload), set a priority, and send.

From the API ​

Admin auth is a Netlify Identity JWT (not the agent API key), sent in the X-Identity-Token header. It cannot travel in Authorization: the portal's edge intercepts any function request carrying a validly-signed Identity JWT there and terminates it before the handler runs.

bash
PORTAL="https://<portal-host>"
JWT="<Netlify Identity admin access token>"

curl -sS -X POST "$PORTAL/api/agent-commands" \
  -H "X-Identity-Token: $JWT" \
  -H "Content-Type: application/json" \
  -d '{
        "command_type": "run_scan",
        "agent_id": "nvd_89f6e23d",
        "payload": { "profile": "full_recon", "targets": "192.168.50.0/24" },
        "priority": 0
      }'

Replace agent_id with group_target for a group broadcast. Track the result (admin, read-only) with the id from the POST response:

bash
curl -sS -H "X-Identity-Token: $JWT" "$PORTAL/api/agent-commands?command_id=1"

Troubleshooting ​

  • Agent never runs the command: confirm it is at least business-tiered, not deregistered (a deregistered agent gets 403 and shuts down), and has portal connectivity. Commands sit pending until the next heartbeat.
  • run_scan failed at ~300s: the command timeout was hit. Use quick, a smaller target, run it on the host directly, or raise NV_CMD_TIMEOUT.
  • A config change reverted after restart: confirm .c2_overrides.conf exists in the agent configs dir; persistence relies on it.
  • Command shows completed but nothing changed: check the returned stdout/stderr in the History detail (truncated to 10 KB).
  • History Session ID has no link / 404s: the id only resolves once the session has been uploaded. Dispatch run_scan with upload: true, or send a push_session command.

Apache-2.0 licensed (appliance subtree proprietary)