Skip to content

netvuln-tool ↔ netvuln-portal API Contract ​

This document defines the integration contract between the netvuln-tool scanner (this repository) and the netvuln-portal collection portal (gitlab.com/bullium/netvuln-portal, live at portal.netvulntool.com, local checkout ~/netvuln-portal). Changes to either side must be coordinated.

Upload Endpoint ​

PropertyValue
URLPOST /api/upload
Authx-api-key header
Env varsCONSULTATION_API_URL, NV_API_KEY (or legacy CONSULTATION_API_KEY)
Config filetemplates/report_config.conf.example
Max size25 MB
Content-Typemultipart/form-data

Multipart Fields ​

FieldTypeRequiredSource
session_jsonapplication/jsonYessession_results.json
pipeline_logtext/plainNopipeline.log
report_htmltext/htmlNoreport_*.html
exec_summary_htmltext/htmlNoexec_summary_*.html

Success Response (HTTP 200) ​

json
{
    "success": true,
    "session_id": "20260301_120000",
    "share_token": "<64-char-hex>",
    "share_url": "https://portal.netvulntool.com/share/?token=<64-char-hex>",
    "message": "Report submitted successfully"
}

Error Responses ​

CodeMeaningExample
400Bad requestMissing session_json, invalid JSON, or a metadata.session_id that fails the Session ID Format regex ^[a-zA-Z0-9_-]+$ (portal v0.8.1)
403Auth failureInvalid or missing API key
413Too largeUpload exceeds 25 MB
500Server errorStorage failure

Refresh Endpoint ​

PropertyValue
URLPUT /api/refresh?session={id}&file={name}
Authx-api-key header
BodyRaw file content (text/html)
Allowed filesreport.html, exec_summary.html

Replaces a single display file for an existing session without modifying metadata or other files. Used by refresh_reports.sh to bulk re-upload reports after template changes.

Success Response (HTTP 200) ​

json
{
    "success": true,
    "session_id": "20260301_120000",
    "file": "report.html",
    "message": "File refreshed successfully"
}

Error Responses ​

CodeMeaningExample
400Bad requestMissing session or file parameter, file not in whitelist
401Auth failureInvalid or missing API key
404Not foundSession does not exist
405Wrong methodMust use PUT

API Authentication ​

Legacy API Key (deprecated) ​

PropertyValue
Headerx-api-key
Env varNV_UPLOAD_API_KEY (server), CONSULTATION_API_KEY (CLI)
Tiermsp (all permissions)
ScopeNo client_id scoping

License Keys (v1.20.0+) ​

PropertyValue
Headerx-api-key
Key formatnvt_{tier}_{64 hex} (e.g., nvt_pro_a1b2..., nvt_biz_..., nvt_msp_...)
Tier prefix mapprofessional → pro, business → biz, msp → msp
Key prefixFirst 14 chars (e.g., nvt_pro_a1b2c3), used for DB lookup
HashSHA-256 of full plaintext key, stored in license_keys.key_hash
ValidationQuery by prefix, timing-safe hash comparison, check active/revoked/expired/limits
Client scopingUpload auto-tags client_id from license key, overriding session JSON
Config filetemplates/report_config.conf.example: NV_API_KEY (primary) or CONSULTATION_API_KEY (legacy alias)

Netlify Identity JWT ​

Every endpoint below whose Auth row reads "Netlify Identity JWT" expects the token in this header. It is not Authorization.

PropertyValue
HeaderX-Identity-Token
Env varNV_JWT or CONSULTATION_JWT (CLI), --jwt flag
Portal sideIDENTITY_TOKEN_HEADER in the portal's auth.mjs
Client sideIDENTITY_TOKEN_HEADER in netvuln/remote.py; _ad_add_auth jwt in scripts/api_debug.sh; jwt_request in tests/e2e/test_license_lifecycle.sh; the documented examples in Command and Control and License Provisioning

Authorization: Bearer <jwt> does not work and cannot be made to work from the client side. The Netlify edge in front of the portal intercepts any function request whose Authorization header or nf_jwt cookie holds a validly-signed Identity JWT and terminates it before the function runs, so the token has to travel in a header the edge does not inspect.

This fails in the least visible way possible: an invalid or dummy token returns a clean 401 under either header, so a probe with a fake credential shows no difference. The failure only appears once a caller authenticates successfully. Issue #147 records a shipped command that carried the wrong header for exactly this reason.

This applies to portal functions under /api/. Calls to Netlify's own Identity service at /.netlify/identity/... are not function requests and do use Authorization: Bearer, which is why the admin token acquisition step in the license provisioning guide looks different. Netlify Identity is also per site, so that token has to be minted by the same host that will accept it.

Tier Permissions ​

PermissionProfessionalBusinessMSP
uploadYYY
downloadYYY
sessions:readYYY
update:statusYYY
benchmarkYY
remediation:updateYY
webhooksYY
complianceYY
agent:commandsY
agent:groupsY
db:healthY
auditY
health:agentsY

Default Limits ​

TierMax SessionsMax Agents
Professional1003
Business75015
MSP3,00040

Limits are configurable per key. No tier is unlimited; null (no limit) is an admin-only per-key override, never a tier default.

License Validation Endpoint (v4.1.0) ​

GET /api/license reports the status of the presented key without side effects. Implemented by license.mjs on the portal; consumed by lib/license_validate.sh and netvuln/license.py on the client.

PropertyValue
MethodGET
Authx-api-key header (license key or legacy shared key)
URL derivation${CONSULTATION_API_URL%/upload}/license (same convention the daemon uses for /health)

Request ​

GET /api/license
x-api-key: nvt_pro_a1b2c3...

Response (HTTP 200, even for revoked/expired/unknown keys) ​

json
{
  "valid": true,
  "tier": "professional",
  "client_id": "acme-corp",
  "active": true,
  "revoked": false,
  "expires_at": "2027-01-01T00:00:00Z",
  "limits": { "max_sessions": 100, "max_agents": 3 },
  "reason": "ok",
  "checked_at": "2026-06-09T12:00:00Z"
}
FieldTypeNotes
validbooleanTrue only when the key is usable right now
tierstring/nullprofessional / business / msp; the key's tier even when revoked/expired/inactive; null when the key is not recognized (not_found / invalid_format)
client_idstring/nullClient scoping tag bound to the key; null for unrecognized keys and the legacy shared key
activebooleanAdmin activation flag
revokedbooleanRevocation flag
expires_atstring/nullISO8601 expiry, null when perpetual
limitsobjectmax_sessions, max_agents (number/null)
reasonstringok / revoked / expired / inactive / not_found / invalid_format
checked_atstringServer timestamp of this check
legacybooleantrue only for the legacy shared key; false for all nvt_ keys. Always present on the response.

Special cases:

CaseResponse
Legacy shared key (NV_UPLOAD_API_KEY match){ "valid": true, "tier": "msp", "legacy": true }
Missing x-api-key headerHTTP 401 (the only non-200 auth outcome)
Server error / unreachable5xx or no response; clients treat this as "can't tell", never as invalid

Client-Side Validation, Tier Gating, Cache and Grace ​

The scanner derives a tier from the key format (nvt_{pro|biz|msp}_{64 hex}) at load time and exports NV_LICENSED and NV_LICENSE_TIER. Key resolution checks names in the same order in bash and Python: NV_LICENSE_KEY, then NV_API_KEY, then CONSULTATION_API_KEY, then ~/.netvuln/license.key. Within a name, NV_API_KEY and CONSULTATION_API_KEY prefer a config-file value over the environment (bash config sourcing overwrites those variables; netvuln/license.py matches that behavior), while NV_LICENSE_KEY is never shadowed by a config and stays env-first. Non-nvt keys map to tier legacy (all features, deprecation warning); malformed nvt_ keys warn loudly and also map to legacy so a truncated paste never locks the client out. The server 403 stays authoritative.

Feature gates (nv_require_tier / netvuln.license.require_tier) are soft: warn and skip, never block core scanning. Minimum tiers: compliance, benchmark, remediation, webhooks, c2, agent_commands require business; unknown features require msp.

Online validation (nv_license_refresh / netvuln.license.refresh) runs once per pipeline run and once per daemon heartbeat cycle:

  • Results cache in ~/.netvuln/license_cache.json (mode 600, sha256 fingerprint prefix of the key, never the plaintext) with a 24h TTL.
  • A fresh cache short-circuits the network call.
  • valid:false downgrades the session to community features with a warning naming the reason.
  • An unreachable portal keeps the cached tier for a further 72h grace window, then downgrades to community. A cached valid:false (revoked/expired/inactive) is authoritative regardless of age: the client stays downgraded until the portal answers valid:true again. Only when no fingerprint-matching cache exists at all does the format-derived tier stand (first run offline is not punished).
  • A locally known past expires_at counts as expired regardless of connectivity.
  • Hosts without jq skip the online/cache path entirely and keep the format/presence gate.

Shared Constants ​

Session ID Format ​

Pattern: YYYYMMDD_HHMMSS (e.g., 20260301_120000)

Generated by: nv_timestamp_filename() in lib/netvuln_common.sh

Used by: All Netlify Functions (stored as session_{id}/ prefix in Blobs)

Regex validation: /^[a-zA-Z0-9_-]+$/ (both repos)

Severity Levels ​

Canonical values: critical, high, medium, low, info

Generated by: classify_severity.sh in netvuln-tool

Consumed by: Collect portal filters, CSV export, PDF summary

Share URL Pattern ​

Format: https://portal.netvulntool.com/share/?token={64-char-hex}

Token: 32 random bytes → 64 hex characters

Generated by: upload.mjs (server-side) and returned in upload response

Displayed by: push_session.sh (CLI output to user)

Remediation Status Values ​

Canonical values: new, persistent, in_progress, resolved, deferred

Generated by: remediation_tracker.sh (auto-detection), remediation_update.sh (manual)

Consumed by: Report template status badges, portal remediation dashboard

Remediation Tracking Fields ​

New fields added to .remediation[] entries:

FieldTypeDescription
statusstringnew, persistent, in_progress, resolved, deferred
status_changedISO 8601Timestamp of last status change
notestringFree-text annotation
first_seenstringSession ID where this remediation first appeared
prior_statusstring/nullStatus from the prior session

New top-level key: .remediation_resolved[], an array of remediation entries from the prior session that are no longer detected in the current scan.

Remediation Summary (Metadata) ​

Added in v2.8.0. Stored in session metadata.json, populated at upload time by upload.mjs. Defaulted at read time by formatSession() in lib/format.mjs when the Neon column is null (the pre-Neon sessions.mjs blob backfill was removed in the v1.19.0 migration).

FieldTypeDescription
remediation_summary.totalnumberCount of active remediation items
remediation_summary.by_status.newnumberItems with status new
remediation_summary.by_status.persistentnumberItems with status persistent
remediation_summary.by_status.in_progressnumberItems with status in_progress
remediation_summary.by_status.resolvednumberItems with status resolved
remediation_summary.by_status.deferrednumberItems with status deferred
remediation_summary.resolved_countnumberCount of items in remediation_resolved[] (no longer detected)
remediation_summary.by_severity.criticalnumberItems with severity critical
remediation_summary.by_severity.highnumberItems with severity high
remediation_summary.by_severity.mediumnumberItems with severity medium
remediation_summary.by_severity.lownumberItems with severity low
remediation_summary.by_severity.infonumberItems with severity info

Exception / Accept-Liability Tracking (v3.1.0) ​

New fields on .remediation[] entries:

FieldTypeDescription
exception_statusstring|nullnull (no exception), "accepted", "expired"
exception_reasonstring|nullJustification for accepting risk
exception_approverstring|nullWho approved the exception
exception_approved_atISO 8601|nullApproval timestamp
exception_expiryYYYY-MM-DD|nullAuto-expiry date
exception_expiredbooleantrue if past expiry date

Dual Scoring Model (v3.1.0) ​

New fields in .summary:

FieldTypeDescription
risk_score_operationalnumberPost-exception score (0-100)
risk_grade_operationalstringPost-exception grade (A-F)
exception_impact.excepted_countnumberActive exception count
exception_impact.score_deltanumberActual minus operational
exception_impact.excepted_findingsobjectBy-severity breakdown

Exception Summary (Portal Metadata, v3.1.0) ​

Extracted by upload.mjs, stored in metadata.json:

FieldTypeDescription
exception_summary.total_exceptionsnumberTotal accepted + expired
exception_summary.by_status.acceptednumberActive exceptions
exception_summary.by_status.expirednumberExpired exceptions
exception_summary.score_deltanumberScore impact
risk_score_operationalnumber|nullOperational score
risk_grade_operationalstringOperational grade

Alerts Summary (Metadata) ​

Added in v2.9.0. Captured by nv_capture_alert_results() in alert_notifier.sh and written to session_results.json. Extracted at upload time by upload.mjs into metadata. Defaulted at read time by formatSession() in lib/format.mjs when the Neon column is null (the pre-Neon sessions.mjs blob backfill was removed in the v1.19.0 migration).

FieldTypeDescription
alerts_summary.evaluated_atISO 8601 / nullWhen alerts were evaluated
alerts_summary.total_triggerednumberCount of alerts fired (0 if none)
alerts_summary.alerts_triggeredarrayList of triggered alert objects
alerts_summary.alerts_triggered[].typestringrisk_threshold, new_critical, scan_complete
alerts_summary.alerts_triggered[].timestampISO 8601When the alert fired
alerts_summary.alerts_triggered[].messagestringHuman-readable alert description
alerts_summary.alerts_triggered[].channels_notifiedarrayChannels used (e.g., ["email","slack"])
alerts_summary.configobjectAlert config snapshot
alerts_summary.config.risk_thresholdnumberConfigured risk threshold
alerts_summary.config.on_new_criticalbooleanWhether new_critical alerts enabled
alerts_summary.config.on_scan_completebooleanWhether scan_complete alerts enabled
alerts_summary.config.channelsarrayConfigured channel names

Executive Summary ​

Added in v2.9.0. The exec_summary_html multipart field is stored as session_{id}/exec_summary.html in blob storage. Presence is tracked via has_exec_summary (boolean) in session metadata, defaulted at read time by formatSession() in lib/format.mjs when the Neon column is null.

CLI sources: push_session.sh (-e flag or auto-detect exec_summary_*.html), upload_session.sh (-e flag or auto-detect), submit_session.sh (6th parameter).

Portal: Download via GET /api/download?session={id}&file=exec_summary.html&mode=view.

Benchmark Data ​

Benchmark percentile data is generated client-side by benchmark_compare.sh and stored in session_results.json under the .benchmark key. The portal reads this on-demand when the user expands a session row.

FieldTypeDescription
benchmark.sample_sizenumberSessions in comparison cohort
benchmark.fetched_atISO 8601When benchmark was computed
benchmark.metrics.risk_scoreobjectRisk score percentile data
benchmark.metrics.findings_per_hostobjectFindings/host percentile data
benchmark.metrics.critical_high_ratioobjectCritical+High % percentile data
benchmark.metrics.*.valuenumberRaw metric value for this session
benchmark.metrics.*.percentilenumberPercentile rank (0-100, higher = better)
benchmark.metrics.*.labelstringHuman-readable metric name
benchmark.metrics.*.directionstringlower_is_better

Scheduling Metadata ​

Added in v2.10.0. Injected into session_results.json by scheduled_scan.sh before auto-upload. Extracted at upload time by upload.mjs into metadata. Backfilled on first read by sessions.mjs (reads session_results.json from blob store).

FieldTypeDescription
metadata.schedule_idstringDeterministic hash: sched_<12-char-md5> of target|cron
metadata.schedule_cronstringCron expression (e.g., 0 2 * * 0)
metadata.schedule_profilestringScan profile name (e.g., full_recon)
metadata.is_scheduledbooleantrue for automated scans

CLI source: scheduled_scan.sh injects via _inject_schedule_metadata() after scan; netvuln_daemon.sh injects via _nvd_inject_schedule_metadata() with scheduled_by=daemon

Additional daemon-injected field:

FieldTypeDescription
metadata.scheduled_bystring"daemon" when run by daemon, "cron" when run by cron, "workstation" when pushed from the local NetVuln Workstation

Portal: Schedule dashboard groups sessions by schedule_id, displays cron expression, run count, and risk score timeline per schedule.

Session License Metadata (v4.2.3) ​

Added in netvuln-tool v4.2.3 (#46). nv_json_set_license() writes a metadata.license block into every session JSON (called from run_pipeline.sh):

FieldTypeDescription
metadata.license.tierstringResolved license tier (pro / biz / msp / legacy)
metadata.license.fingerprintstringSHA-256 fingerprint of the key (never the plaintext key)
metadata.license.sourcestringWhere the key was resolved from (env var name or config path)
metadata.license.validated_atISO 8601When the key was last validated

Portal: currently ignores this block (upload.mjs does not read metadata.license). Documented here so a future portal feature does not collide with or duplicate it. The fingerprint is safe to store; the plaintext key is never written to the session JSON.

Daemon Heartbeat Payload ​

Added in v3.0.0. The daemon (netvuln_daemon.sh) sends periodic JSON heartbeats to the portal health endpoint. Heartbeat interval is configurable via DAEMON_HEARTBEAT_INTERVAL.

PropertyValue
URLPOST /api/health (derived from CONSULTATION_API_URL)
Authx-api-key header
IntervalDAEMON_HEARTBEAT_INTERVAL seconds (default: 3600, aligned to the top of the hour with DAEMON_HEARTBEAT_ALIGN=1; needs portal 0.14.0 or later, or the agent shows Stale)

Heartbeat Payload ​

FieldRequiredTypeDescription
agent_idyesstringnvd_<8-char-md5> of the machine-id (or hostname when none)
versionyesstringnetvuln-tool version
timestampyesISO 8601When heartbeat was sent
statusyesstring"running" or "busy" (busy while a C2 command is executing; see current_command below, #384)
uptime_secondsyesnumberSeconds since daemon start. api_debug.sh heartbeat sends 0, having no daemon uptime to report
client_idnostringClient identifier from first loaded config
schedulesnoarrayPer-schedule status objects
schedules[].confignostringConfig filename, including its .conf suffix. The portal derives the run_scan config choices it offers for this agent from this value, stripping the suffix (#398)
schedules[].schedule_idnostringDeterministic schedule hash
schedules[].last_runnoISO 8601/nullLast scan execution time
schedules[].last_statusnostringnone, success, failed, deferred (a scan waiting on the shared scan lock, or skipped after DAEMON_SCAN_RETRY_MAX deferrals)
schedules[].next_runnoISO 8601Next scheduled scan time
schedules[].retry_countnonumberConsecutive failure count
system_infonoobjectOptional host telemetry (OS, packages, network, config) gathered by the daemon; absent on older daemons. Stored in the system_info jsonb column.
raw_scan_capablenobooleanWhether the daemon currently holds privileged-scan capability (root, or non-root CAP_NET_RAW via nv_can_raw_scan()) for OS fingerprinting (-O) and SNMP/UDP (-sU); sent in every heartbeat since v4.6.0 (#215), absent on older daemons
current_commandnoobject/nullPresent (non-null) while status is busy: {id, type, started_at}. Null or absent when idle (#384)
current_command.idnonumberThe running command's id
current_command.typenostringThe running command's type, e.g. run_scan
current_command.started_atnoISO 8601UTC time the command started running
local_scan_activenobooleantrue while any process on the agent holds the shared scan lock <base>/logs/scan.lock (a scheduled scan, a C2 run_scan, a CLI recon, or the workstation UI); false otherwise. Sent in idle and busy heartbeats since the release that ships #432; absent on older daemons, where consumers treat absent as false
local_scan_originnostring/nullWho holds the scan lock: one of workstation, schedule, c2, cli, or null when no scan runs or the holder is not yet identifiable (a held lock with an unreadable or unrecognised origin reports local_scan_active true and local_scan_origin null). cli (an interactive vulnscan_recon.sh or netvuln recon) is an addition to the three origins agreed for the workstation design (netvuln-portal #149 must tolerate unknown strings); absent on older daemons. Field order in the payload is current_command, local_scan_active, local_scan_origin
capabilitiesnoobjectWhat this agent accepts, built from its own catalog; absent on older daemons, and absent means the portal offers nothing (#430). Optional, additive, sent on idle and busy heartbeats, always after current_command (and after local_scan_active / local_scan_origin when present); omitted when the command handler is not loaded
capabilities.run_scriptnostring[]Logical names run_script accepts, currently scan_diff, schedule_status, schedule_list. Each matches ^[a-z_]{1,40}$
capabilities.push_session_post_scannostring[]post_scan tokens push_session honors, currently compliance, topology, exec_summary, benchmark

The five required fields are validated in the order listed. The portal returns 400 {"error": "Missing required field: <name>"} on the first one missing, so a payload short more than one field only ever names the first, and a client fixed field by field takes one round trip per gap (#173). A field present but null or an empty string fails the same check; 0 passes. The portal-side source of truth is REQUIRED_HEARTBEAT_FIELDS in netlify/functions/health.mjs.

Client ID / Multi-Tenancy ​

Added in v2.14.0. Optional client identifier for portal multi-tenancy session scoping. Scripts-side injection shipped in v2.14.0; portal-side filtering shipped in v2.14.1 (bullium-site v1.8.0).

FieldTypeDescription
metadata.client_idstringClient identifier for session scoping (e.g., acme-corp)

Config variable: CLIENT_ID in report_config.conf.example

CLI source: run_pipeline.sh passes CLIENT_ID as 5th arg to nv_json_set_client(), or calls nv_json_set_client_id() directly when CLIENT_NAME is empty but CLIENT_ID is set.

Portal (shipped v2.14.1 / bullium-site v1.8.0): getUserScope() in auth.mjs extracts app_metadata.roles and app_metadata.client_ids; sessions.mjs filters by scope; download.mjs, update.mjs, delete.mjs enforce 403 for out-of-scope access. Backward compatible: no app_metadata → admin (sees all sessions).

Report White-Label Configuration ​

Added in v2.14.0. Config-driven branding variables that affect report HTML output via CSS custom property overrides in rt_brand_overrides().

Config VariableCSS Custom PropertyDefaultDescription
REPORT_PRIMARY_COLOR--primary-color#dc3545Primary accent color (links, borders, badges)
REPORT_HEADER_BG--header-bg#1a1a2eReport header background
REPORT_HEADER_TEXT--header-text#ffffffReport header text color
REPORT_SUBTITLE-Bullium Consulting LLCHeader subtitle text
REPORT_FOOTER_TEXT-Generated by netvuln-tool...Footer paragraph text
REPORT_FOOTER_EMAIL-info@bullium.comFooter contact email
REPORT_FOOTER_URL-https://bullium.comFooter website URL
COMPANY_LOGO-Auto-detectedPath to custom logo file for report header

CLI source: Variables read from config file, applied by generate_report.sh calling rt_brand_overrides after rt_html_head.

Portal impact: White-labeled reports display with custom branding when viewed via the share page or downloaded from the collect portal. No portal code changes needed.

Health Check Endpoint ​

Added in v1.7.0. Extended in v1.9.0 with daemon heartbeat support.

GET /api/health (public) ​

PropertyValue
URLGET /api/health
AuthNone (public)

Response: { "status": "ok", "version": "1.25.9", "timestamp": "ISO 8601", "environment": "production" }

version is the portal package version (illustrative here) and environment reflects the Netlify deploy context (production / deploy-preview / branch-deploy / dev, or unknown when unset), so it is not always production.

GET /api/health?agents=true (protected) ​

PropertyValue
URLGET /api/health?agents=true
AuthNetlify Identity JWT
ScopingNon-admin users see only agents matching their client_ids

Response: { "agents": [...], "stale_threshold_seconds": 600 } (the top-level value is the default; from portal 0.14.0 each agent object also carries its own stale_threshold_seconds)

Each agent object includes agent_id, version, timestamp, status, uptime_seconds, client_id, schedules, system_info, raw_scan_capable, received_at, is_stale (computed), age_seconds (computed), and from portal 0.8.5 license and license_source (portal-side read fields derived from the license key that authenticated the heartbeat, portal #116; the daemon does not send them and no heartbeat field changed).

POST /api/health (daemon heartbeat) ​

PropertyValue
URLPOST /api/health
Authx-api-key header
Storageagent_heartbeats table in Neon PostgreSQL (upsert on heartbeat)
Stale thresholdPer agent: 2x the agent's reported heartbeat_interval_seconds, at least 600 s and at most 10800 s (netvuln-portal 0.14.0, #154); 600 s when the agent reports none (older daemons). Portals before 0.14.0 use a fixed 600 s, so an agent on the hourly default shows Stale there

Request body: See Daemon Heartbeat Payload section.

Response: { "success": true, "received_at": "ISO 8601", "commands": [ ... ] }

The heartbeat response piggybacks any pending C2 commands for the agent (up to 20), marking them sent in the same cycle. Each command object matches the GET /api/agent-commands agent-poll shape: id, command_type, payload, priority, created_by, created_at. The commands array is [] when nothing is queued.

Delivery here has been at-least-once since netvuln-portal 0.7.2 (#243): a stale unacked sent command can be redelivered on a later heartbeat rather than assumed lost. See Command and Control for the agent-side handling this interacts with.

Agent Lifecycle Endpoint (PATCH /api/agents) ​

Admin agent management (portal-side). Deregister removes a daemon agent from the active roster (and its group memberships); reenable restores it; purge permanently deletes a deregistered agent and its history. Implemented by agents.mjs.

PropertyValue
URLPATCH /api/agents
AuthNetlify Identity JWT (admin-only)
Storageagent_heartbeats (+ agent_commands, command_results, agent_groups on purge); emits an audit event + webhook

Request body:

json
{ "agent_id": "nvd_abc12345", "action": "deregister" }

action is "deregister", "reenable", or "purge".

ResponseMeaning
200 { "ok": true }Action applied
404 Agent not foundUnknown agent_id
409Already in the requested state, or purge attempted on an agent that is not deregistered

Deregister sets deregistered_at, drops the agent's group memberships, and emits the agent_deregistered audit event + webhook; reenable clears deregistered_at and emits agent_reregistered. A deregistered agent's heartbeat (POST /api/health) returns 403 "Agent unregistered..." until it is reenabled.

Purge (v1.26.0) permanently deletes an already-deregistered agent: it removes the agent_heartbeats row plus the agent's agent_commands (cascading command_results) and agent_groups rows. audit_events are retained and an agent_purged event + webhook are emitted. Purging an agent that is not deregistered returns 409 (deregister it first).

Synthetic Collection Generator (synthetic_collection.sh) ​

Creates test session datasets for development, demos, and CI/CD validation.

FlagDefaultDescription
--target(required)Target string
--sessions1Number of sessions to generate
--findings15Findings per session
--mix"critical:10,..."Severity distribution (%)
--posture(none)Severity mix preset banded by posture (0-100); --mix wins if both given
--trendstableimproving|worsening|stable
--with-exceptions0Add N exception entries
--resolved-rate0Percent of remediation entries marked resolved, highest priority first
--domainsynthetic.localHostname domain suffix for generated hosts
--seed(random)Deterministic seed
--client-id(empty)Portal multi-tenancy

Demo Report Pipeline (demo_report.sh) ​

Generates a posture-steered demo report from a fictional Northwind Traders engagement for marketing screenshots and portal seeding (#323).

FlagDefaultDescription
--posture50Pass/fail character, 0=failing everywhere, 100=clean
--seed(none)Deterministic seed forwarded to synthetic_collection.sh
--outmktemp -dOutput directory

Output directory: session_results.json, report.html, exec_summary.html, inventory.xlsx. Requires a business-tier or higher NV_API_KEY (compliance_mapper.sh is business-tier gated). Remaining arguments are forwarded to synthetic_collection.sh and override this wrapper's own defaults.

Demo Portal Seeding (seed_demo_portal.sh) ​

Uploads a demo_report.sh output directory to the netvuln-portal dev tenant so console, collect, and inventory views populate for screenshot capture (#323).

FlagDefaultDescription
--dir(required)demo_report.sh output directory
--api-urldev deployPortal API base URL
--prod-northwindfalseRequired to target production (only valid with --upgrade-northwind)
--upgrade-northwindfalseOne-time merge of compliance/ORC/topology/benchmark data into the production Northwind (20260821_northwind) share report
--dry-runfalseDev mode: print the upload plan, no upload. --upgrade-northwind: downloads the current production session and writes the merged JSON and re-rendered report artifacts locally without uploading (requires the production credentials)

Session ids are rewritten demo_<tool_version>_<original> before upload, dev only, with dots in the version replaced by underscores (demo_4_10_0_...) so the id satisfies the portal's ^[a-zA-Z0-9_-]+$ session-id contract (#334). Reads NV_UPLOAD_API_KEY_DEV for dev seeding, and NV_JWT plus NV_UPLOAD_API_KEY_PROD for --upgrade-northwind, all from the environment, never from a flag. Refuses to upload any demo_* session to the production host, and refuses production entirely outside --upgrade-northwind with --prod-northwind. Refuses to merge when the synthetic session lacks any target section.

Report HTML Coupling ​

The share page (~/netvuln-portal/share/index.html) injects a bridge script into the report HTML. These CSS/HTML markers in report_template.sh must be preserved for the bridge to work:

MarkerTypePurpose
#consultation-sectionIDAnchor link target for CTA buttons
.consultation-sectionClassSection container styling
#scheduleBtnIDSchedule consultation button
</body>HTML tagBridge script injection point

CRITICAL: Use lastIndexOf('</body>') for injection, never String.replace('</body>', ...) because the report's loadScheduleWidget() function contains </body> inside a JS string literal.

Alert Webhook Payload ​

The alert_notifier.sh script dispatches structured JSON alerts to a configured webhook URL (ALERT_WEBHOOK_URL).

FieldTypeDescription
eventstringAlways "netvuln_alert"
alert_typestringrisk_threshold, new_critical, scan_complete, test
timestampISO 8601When the alert was generated
session_idstringSession identifier (e.g., 20260301_120000)
targetstringScan target(s)
risk_scorenumberCurrent risk score (0-100)
risk_gradestringLetter grade (A-F)
new_critical_highnumberCount of new critical/high findings vs prior scan
total_findingsnumberTotal findings count
subjectstringAlert subject line
messagestringAlert body text

Alert Types ​

TypeConditionConfig
risk_thresholdrisk_score >= ALERT_RISK_THRESHOLDALERT_RISK_THRESHOLD (default: 60)
new_criticalNew Critical/High findings vs prior scanALERT_ON_NEW_CRITICAL (default: true)
scan_completeAny scan completionALERT_ON_SCAN_COMPLETE (default: false)
testManual test via --test flag-

Channels ​

ChannelConfig VariableFormat
EmailALERT_EMAIL (fallback: REMINDER_EMAIL)Plain text
SlackALERT_SLACK_WEBHOOKSlack Block Kit JSON
WebhookALERT_WEBHOOK_URLJSON (see payload above)

Compliance Mapping (v3.2.0) ​

.compliance[] Array ​

FieldTypeDescription
frameworkstringFramework name (CIS Controls v8, NIST CSF, PCI-DSS v4.0, SOC 2, ORC 9.64)
control_idstringControl identifier (e.g., "CIS 3.10", "PR.DS-2", "9.64(C)(1)")
control_namestringHuman-readable control name
finding_idsstring[]Finding IDs mapped to this control

.summary.compliance_summary ​

FieldTypeDescription
total_mappingsnumberTotal compliance control mappings
by_framework[].frameworkstringFramework name
by_framework[].mapped_controlsnumberControls with associated findings
by_framework[].total_controlsnumberTotal controls checked
by_framework[].gap_controlsnumberControls without findings
by_framework[].findings_countnumberFindings mapped to this framework

Portal Metadata ​

FieldSourceDescription
compliance_summarysummary.compliance_summaryCompliance summary object
has_topologytopology.nodes.length > 0Boolean topology flag

ORC 9.64 Readiness (v4.1.0) ​

Ohio Revised Code 9.64 requires every Ohio political subdivision to adopt a cybersecurity program. compliance_mapper.sh maps scan findings to the five technical divisions, ORC 9.64(C)(1) through ORC 9.64(C)(5), and records four attestation items a scanner cannot observe (annual training, incident notifications, ransomware payment policy).

Activation: opt-in via ORC_964_ENABLED="true" or CLIENT_TYPE="political_subdivision". The feature is gated behind the compliance license permission (business tier or higher); without it the mapper strips ORC rows and omits .orc_964 entirely.

Gap math: total_controls for ORC is 5 (technical divisions only) so gap analysis never penalizes subdivisions for policy items a scanner cannot see.

.orc_964 Object ​

FieldTypeDescription
enabledbooleanAlways true when the block is present
deadlines.county_citystring"2026-01-01" (counties and cities)
deadlines.otherstring"2026-07-01" (all other subdivisions)
deadlines.applicablestringcounty_city or other, from ORC_SUBDIVISION_CLASS (default other)
divisions[]object[]Always all five technical divisions ORC 9.64(C)(1) through (C)(5)
divisions[].idstringDivision id, e.g. "9.64(C)(1)"
divisions[].namestringDivision requirement text
divisions[].typestringAlways "technical"
divisions[].mappedbooleanTrue when at least one finding maps to the division
divisions[].finding_idsstring[]Finding IDs evidencing the division (empty when unmapped)
divisions[].finding_countnumberfinding_ids length
attestations[]object[]Four items: 9.64(C)(6) training, 9.64(D)(1) 7-day Ohio Homeland Security notice, 9.64(D)(2) 30-day Auditor of State notice, 9.64(B) ransomware payment resolution
attestations[].idstringStatutory reference
attestations[].namestringRequirement text
attestations[].statestringtrue, false, or unknown (from ORC_ATTEST_* config, default unknown)
attestations[].notestringOptional guidance note (training item only)
coverage.technical_divisions_totalnumberAlways 5
coverage.technical_divisions_coverednumberDistinct ORC divisions present in .compliance

Portal Metadata ​

FieldSourceDescription
orc964_summary.enabledorc_964.enabledBoolean readiness flag
orc964_summary.technical_coveredorc_964.coverage.technical_divisions_coveredDivisions evidenced by findings
orc964_summary.technical_totalorc_964.coverage.technical_divisions_totalAlways 5
orc964_summary.attest_pendingcount of orc_964.attestations[] with state != "true"Attestation items still requiring confirmation

Network Topology (v3.2.0) ​

.topology.nodes[] ​

FieldTypeDescription
ipstringHost IP address
hostnamestringHostname if available
os_guessstringOS detection guess
port_countnumberNumber of open ports
risk_scorenumberPer-host risk score (0-100)
risk_gradestringRisk grade (A-F)
subnetstring/24 subnet prefix
FieldTypeDescription
source_ipstringSource host IP
target_ipstringTarget host IP
typestringLink type ("subnet")

.topology.subnets[] ​

FieldTypeDescription
prefixstringSubnet prefix (e.g., "10.0.0")
host_countnumberHosts in subnet
avg_risknumberAverage risk score

Database Health Endpoint (v1.19.0) ​

Admin-only endpoint for monitoring Neon PostgreSQL database health.

DetailValue
URLGET /api/db-health
AuthNetlify Identity JWT (admin-only)
StorageQueries Neon PostgreSQL system tables

Response:

json
{
  "table_counts": { "sessions": 42, "session_notes": 108, "status_history": 75, "share_tokens": 40, "agent_heartbeats": 3, "agents": 3, "webhook_subscriptions": 2, "webhooks": 2, "audit_events": 310 },
  "database_info": { "size_bytes": 52428800, "size_human": "50.00 MB", "active_connections": 5, "host": "...", "version": "PostgreSQL 17.8", "database_name": "neondb", "max_connections": "100", "provider": "Neon PostgreSQL" },
  "recent_activity": [{ "event_type": "session_uploaded", "session_id": "20260301_120000", "actor": "admin@bullium.com", "data": {}, "created_at": "ISO 8601", "timestamp": "ISO 8601" }],
  "session_stats": { "by_status": { "new": 15, "in_review": 10, "resolved": 12, "archived": 5 }, "by_client": [{ "client_id": "acme-corp", "client": "Acme Corp", "count": 20 }], "recent_uploads": [{ "session_id": "...", "client": "...", "target": "...", "risk_score": 65, "risk_grade": "D", "uploaded_at": "ISO 8601" }] },
  "neon_usage": { "tier": "Free", "storage_limit_mb": 512, "storage_used_mb": 50, "storage_mb": 50, "storage_pct": 9.8, "storage_percent": 9.8, "active_connections": 5, "health": "green" }
}

Note: DB size uses pg_column_size() row sampling (Neon separates compute from storage, so pg_database_size returns 0). Version and max_connections fall back to SHOW commands when the Neon pooler restricts standard functions.

Remediation Update Endpoint (v1.20.0) ​

Update individual remediation item statuses from the report view.

DetailValue
URLPATCH /api/remediation-update
AuthNetlify Identity JWT (scoped by client_id)
StorageNetlify Blobs (read-modify-write session_results.json) + Neon PostgreSQL

Request body:

json
{
  "session_id": "20240301_120000",
  "items": [
    { "title": "Close SMTP Open Relay", "status": "resolved" },
    { "title": "Add Missing Security Headers", "status": "new" }
  ]
}

Response:

json
{
  "success": true,
  "session_id": "20240301_120000",
  "changed": 2,
  "remediation_summary": { "total": 10, "by_status": { ... }, "resolved_count": 5 }
}

Items are matched by title. Status resolved moves items from remediation[] to remediation_resolved[]; other statuses move them back or update in-place. An audit_events entry with type remediation_updated is logged. Uses version-based optimistic locking on blob writes (max 3 retries); returns 409 Conflict if concurrent updates exhaust retries.

Audit Log Endpoint (v1.20.0) ​

Tenant-scoped audit event log with time-based retention.

DetailValue
URLGET /api/audit
AuthNetlify Identity JWT (admins see all; scoped users see own client_id events)
StorageNeon PostgreSQL audit_events table

Query parameters:

ParamTypeDefaultDescription
daysint30Time window (1-90)
typestring-Filter by event type (scan_completed, status_changed, remediation_updated, session_deleted, agent_first_seen, agent_deregistered, agent_reregistered, agent_purged, license_created, license_revoked, license_deleted)
sessionstring-Filter by session ID

Response:

json
{
  "events": [
    {
      "id": 1,
      "event_type": "status_changed",
      "session_id": "20240301_120000",
      "client_id": "acme-corp",
      "actor": "user@acme.com",
      "data": { "previous_status": "new", "new_status": "in_review" },
      "created_at": "2026-03-19T12:00:00Z"
    }
  ],
  "days": 30,
  "total": 1
}

Fire-and-forget cleanup prunes events older than 90 days on each request.

Webhook Events (v1.12.0 / Portal API v2) ​

Added in bullium-site v1.12.0. Structured event system for PSA/RMM integrations. Replaces threshold-based alert_notifier.sh webhooks with subscription-based event delivery, HMAC signing, and multi-subscriber dispatch.

Note: This system is separate from alert_notifier.sh alerts. The legacy alert webhook (ALERT_WEBHOOK_URL) continues to work as-is for scan-time threshold alerts. The portal webhook events fire on data changes (upload, status change, deletion).

Subscription Management ​

PropertyValue
URL/api/webhooks
AuthNetlify Identity JWT (admin-only)
MethodsGET (list), POST (create), PATCH (update), DELETE (remove)
Storagewebhook_subscriptions table in Neon PostgreSQL

Subscription schema:

FieldTypeDescription
idstringwhsub_<12-char-hex>
urlstringHTTPS webhook endpoint (no localhost/private IPs)
event_typesstring[]Subscribed event types
signing_secretstring64-char hex HMAC key (shown once at creation)
enabledbooleanWhether subscription receives events
descriptionstringHuman-readable label
client_idstringOptional, scopes to matching sessions
created_bystringAdmin email
created_atISO 8601Creation timestamp
updated_atISO 8601Last modification timestamp

Event Payload Format ​

json
{
    "api_version": "2",
    "event_type": "scan_completed",
    "event_id": "evt_<16-char-hex>",
    "timestamp": "ISO 8601",
    "data": { }
}

Delivery Headers ​

HeaderValue
Content-Typeapplication/json
X-NV-EventEvent type name
X-NV-Signature-256sha256=<HMAC-SHA256 of body using signing_secret>
X-NV-DeliveryEvent ID
X-NV-API-Version2
User-AgentBullium-netvuln-webhook/2.0

Event Types ​

EventSourceTrigger
scan_completedupload.mjsAfter session blob writes succeed
status_changedupdate.mjsAfter status transition metadata write
session_deleteddelete.mjsAfter all session blobs deleted
report_expiringsessions.mjsLazy-evaluated during session listing when a session enters the 7-day expiry window; expiry_notified_at prevents duplicates
agent_deregisteredagents.mjsAfter an admin deregisters a daemon agent
agent_reregisteredagents.mjsAfter an admin reenables a deregistered agent
agent_purgedagents.mjsAfter an admin permanently deletes a deregistered agent

Event Data Fields ​

scan_completed: session_id, client, client_id, target, risk_score, risk_grade, total_findings, total_hosts, severity_summary, share_url, uploaded_at, tool_version

status_changed: session_id, client, client_id, target, previous_status, new_status, changed_by, changed_at

session_deleted: session_id, client, client_id, deleted_by, deleted_at

report_expiring: session_id, client, client_id, target, uploaded_at, expires_at, days_remaining

agent_deregistered: agent_id, deregistered_by

agent_reregistered: agent_id, reregistered_by

agent_purged: agent_id, purged_by

Delivery Semantics ​

  • Fire-and-forget with single retry (5s timeout, 500ms retry delay)
  • Promise.allSettled() for parallel delivery to multiple subscribers
  • Non-fatal: dispatch errors never fail the triggering operation
  • HTTPS-only webhook URLs enforced at subscription creation

C2 Command Pipeline (v1.20.0) ​

Remote command-and-control for netvuln-tool daemon agents. Commands are queued by portal admins and polled by agents via heartbeat cycle.

POST /api/agent-commands (admin: queue command) ​

PropertyValue
URLPOST /api/agent-commands
AuthNetlify Identity JWT (admin-only)
Storageagent_commands table in Neon PostgreSQL

Request body:

json
{
  "command_type": "run_scan",
  "agent_id": "nvd_abc12345",
  "payload": { "profile": "full_recon", "targets": "192.168.1.0/24", "upload": true },
  "priority": 0
}

run_scan payload keys: targets (required), profile (optional; full_recon/full → --full, quick/quick_recon → -P quick_recon, other → default phases), upload (optional boolean; when true, the resulting session is pushed to the portal after a successful scan), post_scan (optional array, default []; recognized tokens compliance, topology, exec_summary map to the matching vulnscan_recon.sh flag, an unrecognized token is dropped silently, and an absent/empty array scans exactly as before #229), and config (optional string; the config basename without .conf, matching the portal's naming pattern ^[a-zA-Z0-9_-]{1,64}$; a trailing .conf suffix is tolerated by the agent, and a value containing a slash, .., or an empty string never matches). push_session payload: optional session_id (defaults to latest), and optional post_scan (same token set and same silent-drop-of-unrecognized-tokens semantics as run_scan's, but different mechanics: push_session targets a session that already finished, so instead of passing scanner flags into a scan about to run, each requested module is regenerated directly against the existing session's session_results.json, then report.html is refreshed so the new data shows in what gets uploaded; an absent/empty post_scan, or one whose tokens are all unrecognized, pushes exactly as before this feature (#239). Both the exec summary and report refresh are generated into a temp file and promoted only when generation succeeds and produces non-empty output, so a failed or partial regeneration always leaves a prior good artifact untouched rather than uploading a truncated one.

push_session also accepts the post_scan token benchmark (#430). Order: the agent uploads the session, calls fetch_benchmark, refreshes the report, then uploads again, so the portal's Benchmark Comparison card fills and the share token is preserved (a re-upload keeps it). A benchmark failure (initial upload, fetch, or missing credentials) adds a [WARN] post_scan: benchmark ... line to the output and the push still succeeds. Older daemons drop the unknown token silently.

run_script payload (#430): {"scripts": ["scan_diff","schedule_status"], "config": "archer.conf", "timeout": 600}.

  • scripts: required, 1 to 10 names, each matching ^[a-z_]{1,40}$. Never a path or an argument. Names outside the agent's catalog are reported as skipped: lines, not run.
  • config: optional basename, resolved by the same rules and in the same order as run_scan's config; used only to supply the license key (as NV_API_KEY, scoped to each child process) to the child scripts.
  • timeout: optional total seconds for the whole command, default 600, clamped to 1800; a non-number or a value below 1 means the default. It replaces NV_CMD_TIMEOUT for this command. Each script also has its own limit (scan_diff 120 s, the schedule scripts 60 s) and receives the smaller of its own limit and the remaining budget, less a kill grace (5 s, or 1 s when 10 s or less remain) reserved inside the total, so, where timeout(1) is available, a script that ignores the TERM signal is still killed within the budget (without timeout(1) the child runs with no limit).
  • skipped_scripts: optional, written by the portal when it trimmed scripts the agent does not advertise; the agent echoes each as skipped: <name> (not supported).
  • v1 scripts operate on the latest session only (scan_diff uses the newest finished session, one whose session_results.json has a non-empty metadata.end_time, so a scan still in progress is never diffed, and compares it with its prior session, or reports a no-prior summary).

run_script result: stdout is a sequence of sections headed === <name> (exit <rc>) ===, each holding that script's stdout and stderr, capped at 10 KB per script (a longer script ends with an [output truncated at 10240 bytes] line; when the whole output would pass the total cap, the cut body ends with a [output truncated: total cap] line) with CSI and OSC escape sequences, other control bytes and invalid UTF-8 removed, the whole under 50000 characters. Lines of the form skipped: <name> (<reason>) report why an entry did not run; the reasons are invalid name (an entry that is not a string matching the name pattern, shown as skipped: <invalid entry> (invalid name)), duplicate, not in catalog, no session available (scan_diff with no session on the agent), time budget exhausted, and not supported (trimmed by the portal). A script that exceeds its limit shows exit 124. The command's exit code is 0 when at least one script ran and every script that ran succeeded, else 1 (when nothing ran, for example every entry was skipped, it is 1 with stderr run_script: no scripts ran); scan_diff exit 2 (new critical or high findings) counts as success and adds the marker line [scan_diff] NEW_CRITICAL_HIGH=1 directly under its header.

The agent resolves which client config a run_scan command actually runs under, in this order: (1) the requested config basename, when it names one of the agent's loaded configs; (2) otherwise the loaded config whose CLIENT_ID equals the agent's own registered client id (the first loaded config's id, the same convention the heartbeat's client_id field uses); (3) otherwise the sole loaded config, when exactly one is loaded; (4) otherwise no config at all, in which case the command's reported stdout starts with a warning line and the resulting report has no client name and no assessor. Before this change, the C2 run_scan path never passed a config to the scanner, so every C2-triggered report showed a blank client and the OS account as assessor; the scheduled-scan path was unaffected, since it has always passed -c <conf> on its own (#398). The field is absent-safe both ways: a payload that omits config falls straight to step 2, and an agent talking to a portal that has not shipped the config picker never receives the field at all.

For group broadcast, replace agent_id with group_target:

json
{
  "command_type": "restart",
  "group_target": "prod-scanners",
  "payload": {},
  "priority": 5
}

Valid command types: run_scan, update_schedule, push_config, push_session, restart, shutdown, update, set_log_level, run_script

Response (HTTP 201):

json
{
  "success": true,
  "commands": [{ "id": 1, "agent_id": "nvd_abc12345", "command_type": "run_scan", "status": "pending", "created_at": "ISO 8601" }]
}

GET /api/agent-commands (agent polling) ​

PropertyValue
URLGET /api/agent-commands?agent_id=nvd_abc12345
Authx-api-key header
Side effectMarks returned commands as sent

Response: { "commands": [{ "id": 1, "command_type": "run_scan", "payload": {...}, "priority": 0, "created_by": "admin@bullium.com", "group_target": null, "created_at": "ISO 8601" }] }

group_target is the target group name for broadcast commands and null for direct-to-agent commands; it is present in both the agent-poll and admin-history responses.

Dispatch ordering. Pending commands are selected ORDER BY priority DESC, created_at ASC in both the agent poll and the heartbeat piggyback (health.mjs). Higher priority (0 to 10) is dispatched first within a batch, with oldest-first as the tiebreak. The poll returns up to 50 commands; the heartbeat piggyback up to 20. Priority is a portal-side ordering hint only: the agent does not read priority and executes commands in the order received.

Delivery guarantee. Since netvuln-portal 0.7.2, command delivery through this endpoint is at-least-once, not exactly-once (#243): a command left sent with no terminal status can be redelivered on a later poll rather than assumed lost, which means an agent whose own completed/failed PATCH was lost in transit (not the ack) can be handed a command it already ran. See Command and Control for the agent-side handling that makes redelivery safe.

Poll cadence (#384). Since netvuln-tool #384 the daemon also calls this endpoint directly on every idle DAEMON_POLL_INTERVAL tick (default 60s), that is, every main-loop tick on which the heartbeat did not just run, so a freshly dispatched command does not wait up to DAEMON_HEARTBEAT_INTERVAL (default 3600s) for the next heartbeat piggyback. Gated by DAEMON_COMMAND_POLL (default on, C2-overridable); when disabled, the heartbeat piggyback remains the only delivery path. The #243 at-least-once dedup above applies identically to commands delivered by the poll.

GET /api/agent-commands (admin history) ​

PropertyValue
URLGET /api/agent-commands?limit=100 or GET /api/agent-commands?command_id=42
AuthNetlify Identity JWT (admin-only)
Side effectNone (read-only)

List mode returns { "commands": [...] }. Detail mode (?command_id=X) returns { "command": {...}, "result": { "exit_code": 0, "stdout": "...", "stderr": "...", "session_id": "20260714_120000", "completed_at": "ISO 8601" } }. session_id is populated for run_scan and push_session results and links to the collect-portal session detail (/collect/?session=<id>); it is null for other commands.

PATCH /api/agent-commands (agent status update) ​

PropertyValue
URLPATCH /api/agent-commands
Authx-api-key header
StorageUpdates agent_commands status; inserts command_results on completion/failure

Request body:

json
{
  "command_id": 1,
  "agent_id": "nvd_abc12345",
  "status": "completed",
  "exit_code": 0,
  "stdout": "Scan complete. 5 hosts found.",
  "stderr": "",
  "session_id": "20260714_120000"
}

session_id is optional; the agent includes it for run_scan and push_session so the portal can store it on command_results and link History to the session detail.

stdout and stderr have ANSI escape codes stripped by the agent before this PATCH is sent, so the portal always receives clean text with no raw escape bytes (for example a literal [0m).

Valid statuses: pending → sent → acked → running → completed | failed

running is an informational PATCH the agent sends immediately before the scan subprocess launches (#384, portal #98). A portal that does not yet accept it (pre-#98) returns 400; a portal that accepts it only from sent or acked returns 409 once the command has already moved past running. Either response is logged by the agent at info level and never retried; the scan itself is unaffected either way.

Scan lock (scan_locked). Since the release that ships netvuln-tool #432, a run_scan that finds another scan in progress on the agent (the shared scan lock <base>/logs/scan.lock, held by a scheduled scan, a CLI recon, or the workstation UI) is not started. It is reported with status failed (the status lifecycle is unchanged), and its stderr is exactly scan_locked: scan in progress (<origin>), where <origin> is one of workstation, schedule, c2, cli, or unknown (the lock exists but its holder record is not written yet, or names an origin outside that set). The scan_locked: prefix is the very first bytes of stderr, with nothing before it, so a consumer can recognise it by prefix. Origin is conveyed to the scanner by the NV_SCAN_ORIGIN environment variable (workstation, schedule, c2, cli; default and fallback cli). A recon CLI refused by the lock prints the same message and exits 75. Locking is an atomic mkdir plus pid liveness on every platform (no flock), a dead holder is recovered as stale, and an unwritable lock directory never blocks a scan. The lock is per base directory, so a manual scan shares the daemon's lock only when it runs with the daemon's NVD_BASE_DIR (for example as the service account). The portal's History list payload does not carry stderr today (netvuln-portal #148 adds it or upgrades the badge when the result loads).

Agent Groups ​

PropertyValue
URL/api/agent-groups
AuthNetlify Identity JWT (admin-only)
MethodsGET (list groups), POST (add agent to group), DELETE (remove agent from group)

POST body: { "agent_id": "nvd_abc12345", "group_name": "prod-scanners" }

DELETE params: ?agent_id=nvd_abc12345&group_name=prod-scanners

GET response: { "groups": { "prod-scanners": [{ "agent_id": "nvd_abc12345", "created_at": "ISO 8601" }] } }

C2 Webhook Events ​

EventSourceTrigger
command_dispatchedagent-commands.mjsAfter command(s) inserted into queue
command_completedagent-commands.mjsAgent reports successful execution
command_failedagent-commands.mjsAgent reports failed execution

C2 Audit Events ​

All commands are logged to audit_events: command_dispatched (actor: admin email), command_completed (actor: agent_id), command_failed (actor: agent_id).

C2 Command Field Parity (run_scan and push_session) ​

The portal's C&C panel and any local client that mirrors it (the NetVuln Workstation form) accept the same options. The two specs stay duplicated on purpose: a shared JSON contract file would need a cross-repo build dependency. These tables are the canonical field list. tests/test_docs_consistency.bats pins them to the agent's command handler, and the local client's tests pin them to its form, so a field added on one side fails a test on the other.

run_scan fields ​

FieldTypeDefaultNotes
targetsstringrequiredComma-separated hosts, CIDRs, ranges or domains
profilestringfull_reconfull_recon or quick
post_scanarray of strings[]Any of compliance, topology, exec_summary; a local form requires upload for exec_summary
uploadbooleanfalsePush the finished session to the portal
configstringnoneConfig basename without .conf; see the resolution order above
timeoutintegeragent defaultSeconds, at most 10800; a local form shows minutes, at most 180
hide_consultationbooleanfalseHide the consultation section of the report

push_session fields ​

FieldTypeDefaultNotes
session_idstringlatestYYYYMMDD_HHMMSS
post_scanarray of strings[]Modules regenerated against the existing session before the upload
hide_consultationbooleanfalseAs for run_scan

Installation Booking (Sales Site to Portal) ​

Live technician availability from Google Calendar, plus entitlement-gated booking, for purchasable professional fleet installation (epic #126). Unlike the rest of this contract, the caller is netvulntool-site, not the netvuln-tool scanner: all state and writes live in netvuln-portal Netlify Functions backed by Neon PostgreSQL, and netvulntool.com's installation.html (BOOKING_API config in js/booking.js) calls the portal cross-origin. It is recorded here because this file is the canonical cross-repo interface log.

Both endpoints are served through the portal's /api/* splat rewrite (netlify/functions/bookings-slots.mjs, netlify/functions/bookings.mjs) and are aliased under /api/v1/* as well. There is no separate /api/bookings/manage route: manage actions post to /api/bookings with a manage flag in the body, because the splat rewrite cannot resolve a nested path to a function.

Every endpoint returns 503 {"error":"bookings_disabled"} when BOOKINGS_ENABLED is not exactly true, and 503 {"error":"bookings_unconfigured"} when the flag is on but the calendar or token-pepper env vars are still absent. This fails closed on purpose: an unconfigured calendar never falls back to an empty busy list, which would publish slots that are actually taken. Secrets and config vars for this feature are in Secrets Management.

GET /api/bookings-slots ​

PropertyValue
URLGET /api/bookings-slots?sku=single_node or ?sku=small_fleet
AuthNone (public)
CacheCache-Control: public, max-age=60
CORS* (read-only)

Success Response (HTTP 200) ​

json
{
  "sku": "single_node",
  "slot_minutes": 120,
  "slots": ["2026-08-06T14:00:00Z", "2026-08-06T16:00:00Z"]
}

slots are open start times in UTC: the working windows (BOOKING_TZ, default America/Chicago) intersected with the technician's Google Calendar availability and confirmed rows in the bookings table, minus lead time and buffer.

Error Responses ​

CodeMeaningBody
400Unknown or missing sku{"error":"unknown_sku"}
502Calendar could not be read{"error":"availability_unavailable"}
503Bookings disabled{"error":"bookings_disabled"}
503Calendar or pepper env vars not set{"error":"bookings_unconfigured"}

POST /api/bookings (create) ​

PropertyValue
URLPOST /api/bookings
Authentitlement_token in the body, not a header
CORSRestricted to https://netvulntool.com and https://www.netvulntool.com
Rate limit10 requests/hour/IP, DB-backed

Entitlement tokens are minted at purchase (issue #8): shape nvtb_ plus 43 base62 characters (32 random bytes). Only a peppered SHA-256 hash is stored; the raw token is shown once and is the caller's sole credential here.

Request Body ​

json
{
  "entitlement_token": "nvtb_...",
  "sku": "single_node",
  "slot_start": "2026-08-06T14:00:00Z",
  "contact": { "name": "Jane Admin", "email": "jane@example.com", "phone": "555-0100" },
  "fleet": { "nodes": 1, "sites": 1, "target_networks": ["10.0.0.0/24"] },
  "client_tz": "America/Chicago"
}

contact.phone and fleet (including fleet.target_networks) are optional; when fleet is present, fleet.nodes must be a positive integer.

Success Response ​

201 on a new booking:

json
{
  "booking_id": "...",
  "starts_at": "2026-08-06T14:00:00Z",
  "ends_at": "2026-08-06T16:00:00Z",
  "manage_token": "nvtm_..."
}

Resubmitting an entitlement token that already has a confirmed booking returns 200 with the same booking shape, not an error, so a double submit cannot double-book. On that 200 replay manage_token is null: only its hash is stored, so a resubmit cannot be answered with the token itself. The manage token is issued exactly once, in the 201 response.

Error Responses ​

CodeMeaningBody
400Validation failure, offending field named{"error":"invalid_request","field":"..."}
401Unknown, revoked, or expired entitlement token{"error":"invalid_entitlement"}
409Slot no longer available (stale slot list, or the Postgres exclusion constraint lost a race, SQLSTATE 23P01){"error":"slot_taken"}
429Rate limited{"error":"rate_limited"}
502Calendar could not be read during the availability recheck{"error":"availability_unavailable"}
503Disabled or unconfiguredsee above

The Google Calendar insert, the Syncro ticket, and the confirmation email all run after the booking transaction commits, each independently, and never fail the booking: a client who has paid keeps their slot even if a third party is down. Syncro gets 3 attempts; a calendar or email failure is logged, not retried, and does not change the response already sent.

POST /api/bookings (manage: cancel or reschedule) ​

PropertyValue
URLPOST /api/bookings with "manage": true (or a bare manage_token) in the body
Authmanage_token in the body, shape nvtm_ plus 43 base62 characters
CORSRestricted to https://netvulntool.com and https://www.netvulntool.com
Rate limit10 requests/hour/IP, DB-backed

Request Body ​

json
{
  "manage": true,
  "manage_token": "nvtm_...",
  "action": "reschedule",
  "new_slot_start": "2026-08-07T15:00:00Z"
}

action is "cancel" or "reschedule"; new_slot_start is required for reschedule only. Cancel re-arms the entitlement (status back to unused) so the client can rebook. Reschedule is an atomic move under the same entitlement and the same manage token. Both actions refuse within 24 hours of starts_at.

Responses ​

CodeMeaningBody
200Cancelled{"booking_id":"...","status":"cancelled"}
200Rescheduled{"booking_id":"...","starts_at":"...","ends_at":"...","status":"confirmed"}
400Unknown action, or missing new_slot_start on reschedule{"error":"invalid_request","field":"..."}
401Unknown manage token{"error":"invalid_manage_token"}
409Booking is not in confirmed status{"error":"not_confirmed"}
409Within 24 hours of starts_at{"error":"too_late"}
409New slot no longer available (reschedule only){"error":"slot_taken"}
429Rate limited{"error":"rate_limited"}
503Disabled or unconfiguredsee above

Consistency Note ​

The SKU names (single_node, small_fleet) and their slot lengths (BOOKING_SLOT_MIN_SINGLE 120 min, BOOKING_SLOT_MIN_FLEET 240 min) must match the installation options and pricing presented on netvulntool.com's installation.html. A SKU renamed or added on one side without the other produces a 400 unknown_sku from /api/bookings-slots, or a page offering a booking flow with no matching slots.

Installation Handoff (Syncro ticket + portal tenant registration) ​

Sub-issue 3 of the professional installation epic (#126). Once a booking is placed, two handoffs carry it into service delivery: the fleet inventory reaches the Syncro ticket so the technician arrives scoped, and the installed fleet binds to the client's portal tenant so the first assessment is ready to run.

Fleet inventory into Syncro (portal-side). The fleet object captured at POST /api/bookings (fleet.nodes, fleet.sites, fleet.target_networks) is written into the Syncro ticket when the booking is created. Today describeBooking() in netvuln-portal bookings.mjs emits it as free-text lines (Nodes:, Sites:, Target networks:) inside a hidden, non-emailing ticket comment via syncro.createTicket(). The structured form this sub-issue calls for (mapping those fields to Syncro custom fields rather than a text blob) is a portal-side change in netvuln-portal netlify/lib/syncro.mjs and bookings.mjs; it is not a scanner change. The structured payload the ticket should carry:

FieldSourceNotes
skubookingsingle_node or small_fleet
client_idbooking/tenantthe portal CLIENT_ID the fleet will register under (see below)
booking_idbookinglinks the ticket back to the booking row
fleet.nodesbookinginteger node count
fleet.sitesbookingsite count or description
fleet.target_networksbookinglist of CIDR/host target ranges

Tenant registration (light-touch, scanner-side). Binding the installed fleet to the client's portal tenant reuses the existing CLIENT_ID scoping (v2.14.0) and daemon heartbeat (v3.0.0) rather than adding a new endpoint. The booking, and the Syncro ticket built from it, carries the client's CLIENT_ID. The technician runs install.sh --client-id <CLIENT_ID>, which nvti_scaffold_config() writes into the fleet's report_config.conf as CLIENT_ID="...". From then on every daemon heartbeat and every uploaded session carries that client_id (see Client ID / Multi-Tenancy above), and the portal's health.mjs adopts it as the agent's authoritative client, so the fleet is scoped to the tenant on its first heartbeat with no explicit "register" call. No scanner code change is required: install.sh already accepts --client-id and prompts for it interactively.

Out of scope here. Routing a completed installation into a scoped consulting proposal has no automated plumbing in either repository today; it is currently a manual step (for example a Syncro ticket status transition) and is left to a follow-up rather than assumed to be an API integration. Go-live also depends on BOOKINGS_ENABLED being turned on in the portal (off by default), so this path is exercised against synthesized or manually minted bookings until then.

Cross-Project Change Checklist ​

When modifying shared interfaces, check BOTH repositories:

  • [ ] Upload fields → submit_session.sh AND upload.mjs
  • [ ] Severity levels → classify_severity.sh AND collect portal filter dropdown
  • [ ] Session ID format → nv_timestamp_filename() AND all Netlify Functions
  • [ ] Report HTML structure → report_template.sh AND share/index.html bridge
  • [ ] API response format → submit_session.sh response parsing AND upload.mjs
  • [ ] Share URL pattern → push_session.sh display AND share.mjs lookup
  • [ ] Config variables → report_config.conf.example AND upload.mjs env vars
  • [ ] Remediation status → remediation_tracker.sh AND collect portal remediation view
  • [ ] Alert webhook payload → alert_notifier.sh AND consumer webhook handler
  • [ ] Remediation summary → upload.mjs metadata extraction AND collect portal stats/progress bars
  • [ ] Alerts summary → alert_notifier.sh capture AND upload.mjs extraction AND collect portal badges
  • [ ] Exec summary upload → submit_session.sh 6th param AND upload.mjs AND download.mjs whitelist
  • [ ] Benchmark data → benchmark_compare.sh JSON key AND collect portal benchmark section
  • [ ] Schedule metadata → scheduled_scan.sh injection AND upload.mjs extraction AND collect portal schedule dashboard
  • [ ] Client ID scoping → run_pipeline.sh / nv_json_set_client_id() AND upload.mjs extraction AND collect portal client filtering (v2.14.1)
  • [ ] Report white-label → report_config.conf.example variables AND report_template.sh CSS overrides (portal-transparent, no portal changes needed)
  • [ ] Daemon heartbeat → netvuln_daemon.sh JSON POST AND portal /api/health endpoint (v3.0.0)
  • [ ] Daemon schedule metadata → netvuln_daemon.sh scheduled_by=daemon injection AND upload.mjs extraction (v3.0.0)
  • [ ] Exception status → remediation_update.sh CLI AND remediation_tracker.sh carry-forward AND collect portal exception view
  • [ ] Exception summary → upload.mjs extraction AND collect portal Risk Register + badges
  • [ ] Operational score → risk_score.sh dual model AND upload.mjs extraction AND collect portal dual display
  • [ ] Compliance mapping → compliance_mapper.sh AND upload.mjs extraction AND collect portal compliance panel
  • [ ] Topology data → network_topology.sh AND upload.mjs extraction AND collect portal topology panel
  • [ ] Webhook event types → webhook-dispatch.mjs VALID_EVENT_TYPES AND webhooks.mjs validation AND API_CONTRACT.md event type table
  • [ ] Webhook event payload → webhook-dispatch.mjs payload builder AND consumer verification AND API_CONTRACT.md payload schema
  • [ ] Webhook subscription schema → webhooks.mjs CRUD AND API_CONTRACT.md subscription model
  • [ ] Report refresh → refresh_reports.sh AND refresh.mjs endpoint AND API_CONTRACT.md refresh endpoint
  • [ ] License key format → nvt_{tier}_{hex} generation in licenses.mjs AND auth.mjs validation AND CLI report_config.conf NV_API_KEY variable (or legacy CONSULTATION_API_KEY)
  • [ ] E2E license tests → tests/e2e/test_license_lifecycle.sh AND auth.mjs permission matrix AND licenses.mjs CRUD
  • [ ] License tier permissions → TIER_PERMISSIONS in auth.mjs AND requirePermission() in API-key endpoints AND admin dashboard Licenses panel AND API_CONTRACT.md tier table
  • [ ] License validation → license.mjs + describeLicense AND lib/license_validate.sh AND netvuln/license.py AND e2e suite_validate
  • [ ] ORC 9.64 readiness → compliance_mapper.sh .orc_964 AND upload.mjs extraction AND schema.mjs orc964Summary AND collect portal ORC readiness panel
  • [ ] C2 command priority → agent-commands.mjs + health.mjs ORDER BY priority DESC AND admin dashboard priority input (0 to 10) AND API_CONTRACT.md dispatch-ordering note AND C2_GUIDE.md section 4
  • [ ] C2 config persistence → lib/command_handler.sh .c2_overrides.conf write AND netvuln_daemon.sh _nvd_load_configs re-apply AND admin dashboard Runtime Config editor AND C2_GUIDE.md section 6
  • [ ] Agent purge → agents.mjs action: "purge" (cascade + agent_purged event) AND admin dashboard "Remove permanently" AND API_CONTRACT.md Agent Lifecycle Endpoint
  • [ ] C2 command types → lib/command_handler.sh dispatch switch AND agent-commands.mjs valid types AND admin dashboard C2_COMMAND_SPECS builder AND API_CONTRACT.md valid-command-types list (push_session added in #86)
  • [ ] C2 run_script catalog and capabilities → lib/command_handler.sh (_NV_RUN_SCRIPT_CATALOG, _NV_PUSH_SESSION_POST_SCAN_TOKENS, _nv_run_script_argv, _nv_cmd_run_script) AND scripts/netvuln_daemon.sh _nvd_heartbeat_payload capabilities AND netvuln-portal health.mjs capability sanitization, agent-commands.mjs resolveRunScriptTarget and the Dispatch C2_COMMAND_SPECS script list AND API_CONTRACT.md heartbeat capabilities rows and run_script payload/result (#430, portal #146)
  • [ ] C2 run_scan profile mapping → lib/command_handler.sh (quick/quick_recon → -P quick_recon, full/full_recon → --full) AND vulnscan_recon.sh accepted flags AND admin dashboard run_scan profile options AND C2_GUIDE.md section 5
  • [ ] C2 run_scan post_scan modules → lib/command_handler.sh (compliance/topology/exec_summary → --compliance/--topology/--exec-summary, unrecognized tokens dropped silently) AND vulnscan_recon.sh accepted flags AND admin dashboard run_scan post-scan module picker AND C2_GUIDE.md section 5 (#229 / portal #45)
  • [ ] C2 push_session post_scan modules → lib/command_handler.sh _nv_push_session_regenerate_post_scan (compliance/topology/exec_summary regenerated directly against the existing session's session_results.json, unrecognized tokens dropped silently, report.html refreshed afterward only when at least one recognized token ran, both refreshes promoted atomically from a temp file on success and non-empty output only) AND admin dashboard push_session post-scan module picker AND C2_GUIDE.md section 5 (#239)
  • [ ] C2 result session_id → lib/command_handler.sh status PATCH session_id AND agent-commands.mjs PATCH store + GET return AND schema.mjs command_results.session_id column AND admin dashboard History Session ID column linking /collect/?session=<id>
  • [ ] C2 session push → lib/command_handler.sh push_session + run_scan upload flag AND admin dashboard builder AND collect portal ?session=<id> deep-link
  • [ ] Installation booking interface → netvuln-portal bookings-slots.mjs / bookings.mjs Netlify Functions AND netvulntool-site installation.html + js/booking.js BOOKING_API config AND API_CONTRACT.md Installation Booking section
  • [ ] Installation handoff → booking/Syncro client_id + fleet fields AND scanner install.sh --client-id → nvti_scaffold_config() CLIENT_ID AND daemon heartbeat client_id AND netvuln-portal describeBooking() / syncro.mjs structured Syncro fields AND portal health.mjs tenant scoping (#153)
  • [ ] Asset inventory & CVE matching (portal #29) → netvuln-portal asset-inventory-upload.mjs / asset-inventory-jobs.mjs / asset-inventory-match-background.mjs / asset-inventory-results.mjs Netlify Functions AND schema.mjs assetInventoryJobs / assets / assetSoftware / cveMatches tables AND netvuln/demo_inventory.py's Syncro-shaped inventory.xlsx export (#323) staged for manual admin upload AND portal admin/inventory/index.html + client-scoped inventory/index.html dashboards
  • [ ] C2 busy heartbeat / current_command → scripts/netvuln_daemon.sh (_nvd_keeper_start/_nvd_keeper_stop, _nvd_send_busy_heartbeat, _nvd_current_command_json) AND netvuln-portal health.mjs (portal #98) AND API_CONTRACT.md heartbeat status enum + current_command rows
  • [ ] Scan lock / local_scan fields / scan_locked → lib/netvuln_common.sh nv_scan_lock_* AND netvuln/scanlock.py (same on-disk format) AND scripts/netvuln_daemon.sh _nvd_heartbeat_payload AND lib/command_handler.sh _nv_cmd_run_scan AND netvuln-portal #148 (History badge) AND netvuln-portal #149 (Fleet Scanning vs Idle) AND API_CONTRACT.md heartbeat rows + scan_locked paragraph (#432)
  • [ ] C2 running status → lib/command_handler.sh (_nv_report_running, PATCHed between acked and completed) AND netvuln-portal agent-commands.mjs (portal #98) AND API_CONTRACT.md command lifecycle line
  • [ ] C2 idle command poll → scripts/netvuln_daemon.sh (_nvd_poll_commands, DAEMON_COMMAND_POLL) AND netvuln-portal agent-commands.mjs GET AND API_CONTRACT.md poll cadence note under GET /api/agent-commands (agent polling)
  • [ ] C2 run_scan config field → scripts/netvuln_daemon.sh _nvd_config_for_command AND lib/command_handler.sh _nv_cmd_run_scan AND netvuln-portal dispatch payload builder + validation (portal #114) AND API_CONTRACT.md run_scan payload keys + resolution order (#398)

Secrets & Environment Management ​

Full credential inventory, environment setup, rotation procedures, and security practices are documented in Secrets Management.

Key topics covered:

  • Complete secret inventory for both repos (what, where, risk level)
  • Dev vs production credential separation (Neon branching, two-key rule)
  • Environment setup guides (local dev, Netlify dashboard, scanner configs)
  • Rotation procedures for each secret type (license keys, DB URLs, API tokens)
  • Secret hygiene checklist and quarterly audit commands
  • Architecture rationale for current approach vs vault

Deployment Order ​

When a change touches both repositories, deploy in the correct order to avoid breaking the integration. The general rule: the consumer deploys before the producer for new fields, and the producer deploys before the consumer for removed fields.

Adding a New Feature (Field, Endpoint, or Payload) ​

1. netvuln-portal: Add support for the new field/endpoint (accept but don't require)
2. Merge to dev → validate on dev Neon branch
3. Merge dev → main (production portal now accepts the new field)
4. netvuln-tool: Start sending the new field
5. Release netvuln-tool version

Why portal first: If the scanner sends a field the portal doesn't expect, upload may fail or silently drop data. If the portal accepts a field the scanner doesn't send yet, it gracefully handles the absence (null/default).

License validation: the portal license.mjs (GET /api/license) deploys to production BEFORE any netvuln client release that performs online validation. Clients treat an unreachable or missing endpoint as "can't tell" and fall back to cache/format tiers, so the old-client/new-portal combination is safe, but new clients pointed at an old portal would burn their grace window.

Removing a Deprecated Feature ​

1. netvuln-tool: Stop sending the deprecated field
2. Release netvuln-tool version
3. Wait for all deployed scanners to update (or set a deprecation deadline)
4. netvuln-portal: Remove support for the deprecated field
5. Merge to dev → validate → merge dev → main

Why scanner first: If the portal stops accepting a field that scanners still send, existing deployments break. Remove the source before the sink.

Schema-Only Changes (No Scanner Impact) ​

1. Modify schema.mjs on feature branch
2. Merge to dev → Netlify branch deploy runs drizzle-kit push against dev Neon
3. Verify functions work on dev deploy (test all affected endpoints)
4. Merge dev → main → drizzle-kit push against production Neon

Breaking Changes (Rare, avoid if possible) ​

If a change is unavoidably breaking (e.g., renaming a field, changing a response format), coordinate a cutover:

1. netvuln-portal: Add support for BOTH old and new format (backward compatible)
2. Deploy portal to production
3. netvuln-tool: Switch to new format
4. Release netvuln-tool version with minimum portal version requirement
5. After all scanners update: remove old format support from portal

Version Compatibility Matrix ​

Minimum portal version required for each scanner feature set.

netvuln-tool VersionMinimum Portal VersionFeature Introduced
v2.6.2v1.4.0Session upload, share page, collection portal
v2.8.0v1.5.0Risk scoring, remediation tracking, alert webhooks
v2.9.0v1.6.0Executive summary upload, alerts_summary metadata
v2.10.0v1.7.0Schedule metadata, health check endpoint
v2.14.0v1.8.0CLIENT_ID multi-tenancy, report white-labeling
v3.0.0v1.9.0Daemon heartbeat, agent status dashboard
v3.1.0v1.10.0Exception/accept-liability, dual scoring model
v3.2.0v1.11.0Compliance mapping, network topology
v3.2.1+v1.12.0Webhook event system (portal-side only)
v3.2.1+v1.19.0Neon PostgreSQL (replaces blob metadata)
v3.2.1+v1.20.0License keys, C2 command pipeline
v4.1.0v1.24.0ORC 9.64 framework + readiness view (business tier, compliance permission); GET /api/license scan-time validation
v4.2.3v1.20.0Session metadata.license block (fingerprint only; portal-transparent, currently ignored)
v4.3.0v1.25.12push_session C2 command type (#86 / portal #179): agent uploads an existing session on demand; command results carry session_id for run_scan and push_session
v4.7.0netvuln-portal 0.6.0C2 run_scan post_scan payload array (#229 / portal #45): compliance/topology/exec_summary regenerated before the scan uploads; unrecognized tokens dropped silently
v4.8.0netvuln-portal 0.6.0C2 push_session accepts the same post_scan payload array (#239), applied to an already-finished session's existing results; no portal-side wire change beyond v4.7.0's minimum
v4.8.1netvuln-portal 0.7.2At-least-once C2 command delivery (#243): a stale unacked sent command can be redelivered; agent-side dedup keyed on command id
v4.8.2netvuln-portal 0.7.4C2 gate lowered from msp to business tier (#246 / portal #57): nv_require_tier c2 passes for business keys; portal grants agent:commands to business
v4.8.6netvuln-portal 0.7.9C2 run_scan payload gains an optional timeout field (seconds, default 300, clamped to 3600s) (#267); run_scan and push_session payloads gain an optional hide_consultation boolean that suppresses the report's professional-review upsell blocks and emits an NV_CONSULTATION_HIDDEN marker (#268); both fields are additive and absent-safe, and the portal dispatch builder's matching UI shipped in the same portal release (portal #68)
v4.22.0netvuln-portal 0.8.1POST /api/upload rejects a metadata.session_id that fails ^[a-zA-Z0-9_-]+$ with 400 (portal #71); no tool-side wire change, ids from nv_timestamp_filename() and the seeder's dot-replaced demo ids (#334) already conform
v4.23.0netvuln-portal 0.8.2 (busy and running awareness ships in 0.8.2 via portal #98)Heartbeat gains a busy status and current_command while a C2 command runs; the agent sends an informational running PATCH between acked and completed; the daemon polls GET /api/agent-commands on idle ticks instead of waiting only on the heartbeat piggyback (#384). Additive: a pre-#98 portal accepts the busy heartbeat's extra field (ignored) and answers the running PATCH with 400/409, which the agent logs and continues past
v4.24.0netvuln-portal 0.8.2C2 run_scan payload timeout ceiling raised from 3600 s to 10800 s (#388); the portal dispatch UI moves to minutes with a 180 minute maximum (portal #106); no wire change, the field stays seconds
v4.25.0netvuln-portal 0.8.2update reports an authentication failure distinctly instead of a misleading bad-tag error; the installer gains an internal-only self-update credential step that is not part of the Community Edition (#391); no wire change
v4.26.0netvuln-portal 0.8.3 (the payload field works with any portal; the dispatch config picker shipped in portal 0.8.4, portal #114)C2 run_scan passes the agent's client config to the scanner and accepts an optional config field naming which one (#398); heartbeat unchanged
v4.26.1Any (no contract change)Malformed client configs rejected before sourcing or scheduling, with the message on the C2 command's stdout and stderr (#402); no wire change
v4.26.2Any (no contract change)Scheduled uploads carry report_html and exec_summary_html in a single upload (#409); pipeline auto-submit no longer licence-gated (#411); no wire change
v4.27.0Any (no contract change)Installer extension point and internal-only self-update credential step moved out of the public installer (#418); internal credential verification probes the daemon's own path (#416); no wire change
v4.27.1Any (no contract change)Latest-session resolution by session id for --latest and a C2 push_session without session_id (#394); client test suite on Node 25+ (#343); internal installer strict-mode fix (#422); test fixture isolation (#426); no wire change
v4.28.0netvuln-portal 0.10.0 for the UI (Run Script dispatch, portal #146; Busy badge for scan_locked, portal #148; Fleet local Scanning, portal #149); the wire fields are additive and older portals ignore themC2 run_script command and heartbeat capabilities (#430), push_session post_scan token benchmark (#430), heartbeat local_scan_active / local_scan_origin and the scan_locked contract (#432), C2 update branch validation (#429); a portal that does not read the new fields ignores them
v4.28.0Any (additive)Shared scan lock; heartbeat gains local_scan_active and local_scan_origin; a C2 run_scan blocked by the lock fails with the scan_locked stderr prefix (#432); older daemons omit the fields and portals treat absent as false/null
v4.28.0netvuln-portal ships first, no minimumC2 run_script command, heartbeat capabilities field, push_session post_scan token benchmark (#430, portal #146). Additive: the portal hides the new features until an agent reports capabilities; an older daemon drops the unknown benchmark token and rejects an unknown command type, which is why the portal only sends run_script to agents that advertise it
v4.29.0Any (additive); the portal reads the fields in the release that closes netvuln-portal#154, the tool ships firstHeartbeat gains heartbeat_interval_seconds and poll_interval_seconds; DAEMON_HEARTBEAT_INTERVAL and DAEMON_POLL_INTERVAL are validated and clamped (60 to 7200, 5 to 3600) at load and by push_config; out-of-cycle heartbeats on scan lock change, command completion and interval change (60 s minimum spacing); new DAEMON_HEARTBEAT_ALIGN option (#456)
v4.30.0netvuln-portal 0.14.0 or later (presence scales with the reported interval, netvuln-portal #154)The hourly aligned default (DAEMON_HEARTBEAT_INTERVAL=3600, DAEMON_HEARTBEAT_ALIGN=1) needs the portal that sizes the stale threshold to the reported heartbeat_interval_seconds (2x, at least 600 s, at most 10800 s); portals before 0.14.0 use a fixed 600 s, so an hourly agent shows Stale there. No wire change; an explicit interval in a config keeps its value

Backward compatibility rule: The portal always accepts older scanner formats. A v2.6.2 scanner can still upload to a v1.21.1 portal. Newer metadata fields simply won't be populated. However, a v3.2.0 scanner uploading compliance data to a v1.8.0 portal would lose that data silently (portal doesn't extract it).

Architecture Complexity Guide ​

As netvuln-tool grows beyond a Bash script collection into a multi-subsystem platform (Python orchestrator, persistent daemon, C2 pipeline, plugin system), this section documents known integration points, friction areas, and the plan for managing complexity.

Current Subsystem Map ​

┌─────────────────────────────────────────────────────────┐
│                    netvuln-tool                          │
│                                                         │
│  ┌──────────────┐   ┌──────────────┐   ┌────────────┐  │
│  │ Python CLI   │   │ Bash Daemon  │   │ Plugin     │  │
│  │ (netvuln/)   │   │ (netvuln_    │   │ Loader     │  │
│  │              │   │  daemon.sh)  │   │ (plugin_   │  │
│  │ recon cmd ✓  │   │              │   │  loader.sh)│  │
│  │ scan cmd ◻   │   │ Scheduler ✓  │   │            │  │
│  │ upload cmd ◻ │   │ Heartbeat ✓  │   │ Core ✓     │  │
│  │ push cmd ◻   │   │ C2 handler ✓ │   │ Enterprise │  │
│  │ diff cmd ◻   │   │ Retry ✓      │   │  (license) │  │
│  │ status cmd ◻ │   │ Systemd ✓    │   │            │  │
│  └──────┬───────┘   └──────┬───────┘   └─────┬──────┘  │
│         │                  │                  │         │
│         │  BashRunner      │  source          │  hooks  │
│         ▼                  ▼                  ▼         │
│  ┌─────────────────────────────────────────────────┐    │
│  │          Bash Scripts (50+ modules)              │    │
│  │  discover_*, enum_*, vuln_*, report_*, risk_*   │    │
│  └─────────────────────┬───────────────────────────┘    │
│                        │                                │
│                        │ upload / heartbeat / C2        │
└────────────────────────┼────────────────────────────────┘
                         │
                         ▼
              ┌─────────────────────┐
              │   netvuln-portal    │
              │   (Netlify)         │
              │                     │
              │ /api/upload         │
              │ /api/health         │
              │ /api/agent-commands │
              │ /api/agents         │
              │ /api/webhooks       │
              │ Neon PostgreSQL     │
              │ Netlify Blobs       │
              └─────────────────────┘

✓ = implemented   ◻ = stubbed

Known Integration Friction Points ​

These are architectural risks that grow worse as more features are added. Address them proactively to avoid painful refactors later.

1. Config Key Sprawl (Priority: HIGH) ​

Problem: Config keys are defined ad-hoc across Python, daemon, plugins, and individual scripts. No central registry means typos are silently ignored, documentation drifts from implementation, and new contributors can't discover valid options.

Current state: ~40+ config keys across report_config.conf.example, daemon globals, Python config.py, and plugin manifests.

Recommended fix: Create lib/config_schema.sh, a central registry of all valid keys with types, defaults, and descriptions. Both Python and Bash config loaders validate against this schema.

bash
# lib/config_schema.sh (example)
# Format: KEY|TYPE|DEFAULT|DESCRIPTION
_NV_CONFIG_SCHEMA=(
  "TARGETS|string||Scan target(s): CIDR, hostname, or IP"
  "SCAN_PROFILE|string|full|Scan profile name"
  "DAEMON_ENABLED|bool|false|Enable daemon scheduling for this config"
  "DAEMON_POLL_INTERVAL|int|60|Seconds between schedule checks"
  # ...
)

2. No API Versioning Between Daemon and Portal (Priority: HIGH) ​

Problem: The daemon assumes the portal responds with a .commands array in the heartbeat response, and the portal assumes the daemon supports all command types. Neither side advertises its version or capabilities. If the portal adds a new command type, older daemons will fail silently.

Recommended fix: Add version negotiation to the heartbeat:

  • Daemon side: Include protocol_version and supported_commands[] in heartbeat payload
  • Portal side: Only dispatch commands the agent supports; include min_agent_version in command payloads for safety
  • Add protocol_version to the API contract

3. Shared State Race Conditions (Priority: MEDIUM) ​

Problem: Both daemon and Python pipeline write session_results.json. If a scan is running via daemon and a manual upload happens concurrently, session state can be corrupted.

Current mitigations: None, no file locking, no optimistic concurrency.

Recommended fix: Use flock(1) for Bash, fcntl.lockf for Python, both on a session-level lock file ($SESSION_DIR/.lock). Short-term, just document that manual operations should not overlap with daemon scans.

4. C2 Command Persistence Gap (Priority: MEDIUM): Phase B resolved (#84) ​

Problem: update_schedule and push_config C2 commands modified daemon state in memory only. On daemon restart or SIGHUP reload, changes reverted to what's in the config file.

Status: push_config is now persisted (issue #84). Accepted allowlist keys are written to <configs_dir>/.c2_overrides.conf and re-applied by _nvd_load_configs() as the final layer after the .conf files, so a pushed config change survives reload and restart (override wins over the config file). See Command & Control section 6. update_schedule remains in-memory only.

Remaining (phased):

  • Phase A: Surface the (now narrow) limitation in the admin UI (update_schedule).
  • Phase B: Done for push_config via .c2_overrides.conf.
  • Phase C: Add portal-side "pending config" re-dispatched on agent reconnect, and extend persistence to update_schedule.

5. Plugin Dependency Cycle Risk (Priority: LOW) ​

Problem: plugin_loader.sh does topological sort on dependencies but has no cycle detection. Circular deps will infinite loop.

Recommended fix: Track visited set during DFS; if a node is revisited before completion, log error and skip the cycle.

6. Python CLI Completion (Priority: LOW, until needed) ​

Current state: Only recon command is wired. scan, upload, push, diff, and status are stubbed.

Recommendation: Complete these as the Python wrapper becomes the primary entry point. Priority order:

  1. upload / push: needed for daemon Python migration
  2. status: useful for monitoring
  3. diff: useful for reporting
  4. scan: lower priority (recon subsumes it)

Dependency Direction Rule ​

When choosing which subsystem depends on which, follow this principle:

Python CLI ──depends on──▶ Bash scripts (via BashRunner)
Bash daemon ──depends on──▶ Bash scripts (via source)
Plugin loader ──depends on──▶ Bash scripts (via hooks)
Bash scripts ──depends on──▶ NOTHING above (stay independent)

Why: Bash scripts are the stable foundation. They should never import or call the Python wrapper, daemon, or plugin loader. This keeps individual scripts testable and runnable standalone. Higher-level orchestrators (Python, daemon, plugins) compose the scripts: scripts don't know about orchestrators.

Future Architecture Decision Points ​

These are forks in the road that don't need to be decided yet, but should be discussed when they become relevant:

  1. Python vs Bash daemon: Should netvuln_daemon.sh eventually be rewritten in Python? The Python orchestrator already has config loading and subprocess management. But the Bash daemon is battle-tested and signal handling in Python is trickier.

  2. Plugin system scope: Should plugins be able to register new C2 command types? Currently, command types are hardcoded in command_handler.sh. Plugin-registered commands would need a dispatch table.

  3. Portal-side agent management: As the fleet grows, should agents register capabilities (installed tools, platform, network access) so the portal can make smarter command dispatch decisions?

  4. Config as code: Should scanner configs eventually live in the portal (downloadable by agents) rather than as local files? This would solve the C2 config persistence gap but adds portal dependency for cold starts.

Cross-Repository Testing Strategy ​

Before Merging Portal Changes ​

  1. Run full test suite: npm test
  2. Deploy to dev: merge feature branch → dev
  3. Run E2E smoke test from netvuln-tool against dev deploy:
    bash
    # Point smoke test at the dev deploy URL
    # Create a temp config pointing at the dev deploy
    echo 'CONSULTATION_API_URL="https://<your-dev-deploy>.netlify.app/api/upload"' > /tmp/dev-test.conf
    echo 'NV_API_KEY="<your-license-key>"' >> /tmp/dev-test.conf
    bash scripts/e2e_smoke_test.sh -c /tmp/dev-test.conf
  4. If schema changed: verify functions work against dev Neon branch
  5. Validate via Netlify preview deploy (visual check)

Before Merging Scanner Changes ​

  1. Run BATS tests: bats tests/
  2. Run client JS tests: npm run test:client
  3. If upload fields changed: test upload against dev portal deploy
  4. If report template changed: verify share page rendering
  5. Run shellcheck on modified scripts

Integration Test Workflow ​

For changes touching both repos (e.g., new metadata field):

1. Portal: feature branch → add field support → merge to dev → deploy
2. Scanner: feature branch → add field generation → test against dev deploy
3. Portal: dev → main (production)
4. Scanner: merge feature branch → tag release
5. Verify: upload from updated scanner to production portal

Apache-2.0 licensed (appliance subtree proprietary)