Skip to content

netvuln-tool ↔ Bullium Site API Contract

This document defines the integration contract between the netvuln-tool scanner (this repository) and the Bullium Consulting website collect portal (~/bullium-site/). 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://bullium.com/share/?token=<64-char-hex>",
    "message": "Report submitted successfully"
}

Error Responses

CodeMeaningExample
400Bad requestMissing session_json, invalid JSON
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 mapprofessionalpro, businessbiz, mspmsp
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)

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
Professional505
Business50025
MSPUnlimitedUnlimited

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": 50, "max_agents": 5 },
  "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 require business; c2, agent_commands require msp; 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://bullium.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
notestringOpen-ended 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

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: 300)

Heartbeat Payload

FieldTypeDescription
agent_idstringnvd_<8-char-md5> of hostname
versionstringnetvuln-tool version
timestampISO 8601When heartbeat was sent
statusstring"running"
uptime_secondsnumberSeconds since daemon start
client_idstringClient identifier from first loaded config
schedulesarrayPer-schedule status objects
schedules[].configstringConfig filename
schedules[].schedule_idstringDeterministic schedule hash
schedules[].last_runISO 8601/nullLast scan execution time
schedules[].last_statusstringnone, success, failed
schedules[].next_runISO 8601Next scheduled scan time
schedules[].retry_countnumberConsecutive failure count
system_infoobjectOptional host telemetry (OS, packages, network, config) gathered by the daemon; absent on older daemons. Stored in the system_info jsonb column.

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 }

Each agent object includes agent_id, version, timestamp, status, uptime_seconds, client_id, schedules, system_info, received_at, is_stale (computed), age_seconds (computed).

POST /api/health (daemon heartbeat)

PropertyValue
URLPOST /api/health
Authx-api-key header
Storageagent_heartbeats table in Neon PostgreSQL (upsert on heartbeat)
Stale threshold600 seconds (2x default heartbeat interval)

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.

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 (%)
--trendstableimproving|worsening|stable
--with-exceptions0Add N exception entries
--seed(random)Deterministic seed
--client-id(empty)Portal multi-tenancy

Report HTML Coupling

The share page (~/bullium-site/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), and upload (optional boolean; when true, the resulting session is pushed to the portal after a successful scan). push_session payload: optional session_id (defaults to latest).

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

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.

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.

Valid statuses: pendingsentackedcompleted | failed

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).

Cross-Project Change Checklist

When modifying shared interfaces, check BOTH repositories:

  • [ ] Upload fieldssubmit_session.sh AND upload.mjs
  • [ ] Severity levelsclassify_severity.sh AND collect portal filter dropdown
  • [ ] Session ID formatnv_timestamp_filename() AND all Netlify Functions
  • [ ] Report HTML structurereport_template.sh AND share/index.html bridge
  • [ ] API response formatsubmit_session.sh response parsing AND upload.mjs
  • [ ] Share URL patternpush_session.sh display AND share.mjs lookup
  • [ ] Config variablesreport_config.conf.example AND upload.mjs env vars
  • [ ] Remediation statusremediation_tracker.sh AND collect portal remediation view
  • [ ] Alert webhook payloadalert_notifier.sh AND consumer webhook handler
  • [ ] Remediation summaryupload.mjs metadata extraction AND collect portal stats/progress bars
  • [ ] Alerts summaryalert_notifier.sh capture AND upload.mjs extraction AND collect portal badges
  • [ ] Exec summary uploadsubmit_session.sh 6th param AND upload.mjs AND download.mjs whitelist
  • [ ] Benchmark databenchmark_compare.sh JSON key AND collect portal benchmark section
  • [ ] Schedule metadatascheduled_scan.sh injection AND upload.mjs extraction AND collect portal schedule dashboard
  • [ ] Client ID scopingrun_pipeline.sh / nv_json_set_client_id() AND upload.mjs extraction AND collect portal client filtering (v2.14.1)
  • [ ] Report white-labelreport_config.conf.example variables AND report_template.sh CSS overrides (portal-transparent, no portal changes needed)
  • [ ] Daemon heartbeatnetvuln_daemon.sh JSON POST AND portal /api/health endpoint (v3.0.0)
  • [ ] Daemon schedule metadatanetvuln_daemon.sh scheduled_by=daemon injection AND upload.mjs extraction (v3.0.0)
  • [ ] Exception statusremediation_update.sh CLI AND remediation_tracker.sh carry-forward AND collect portal exception view
  • [ ] Exception summaryupload.mjs extraction AND collect portal Risk Register + badges
  • [ ] Operational scorerisk_score.sh dual model AND upload.mjs extraction AND collect portal dual display
  • [ ] Compliance mappingcompliance_mapper.sh AND upload.mjs extraction AND collect portal compliance panel
  • [ ] Topology datanetwork_topology.sh AND upload.mjs extraction AND collect portal topology panel
  • [ ] Webhook event typeswebhook-dispatch.mjs VALID_EVENT_TYPES AND webhooks.mjs validation AND API_CONTRACT.md event type table
  • [ ] Webhook event payloadwebhook-dispatch.mjs payload builder AND consumer verification AND API_CONTRACT.md payload schema
  • [ ] Webhook subscription schemawebhooks.mjs CRUD AND API_CONTRACT.md subscription model
  • [ ] Report refreshrefresh_reports.sh AND refresh.mjs endpoint AND API_CONTRACT.md refresh endpoint
  • [ ] License key formatnvt_{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 teststests/e2e/test_license_lifecycle.sh AND auth.mjs permission matrix AND licenses.mjs CRUD
  • [ ] License tier permissionsTIER_PERMISSIONS in auth.mjs AND requirePermission() in API-key endpoints AND admin dashboard Licenses panel AND API_CONTRACT.md tier table
  • [ ] License validationlicense.mjs + describeLicense AND lib/license_validate.sh AND netvuln/license.py AND e2e suite_validate
  • [ ] ORC 9.64 readinesscompliance_mapper.sh .orc_964 AND upload.mjs extraction AND schema.mjs orc964Summary AND collect portal ORC readiness panel
  • [ ] C2 command priorityagent-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 persistencelib/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 purgeagents.mjs action: "purge" (cascade + agent_purged event) AND admin dashboard "Remove permanently" AND API_CONTRACT.md Agent Lifecycle Endpoint
  • [ ] C2 command typeslib/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_scan profile mappinglib/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 result session_idlib/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 pushlib/command_handler.sh push_session + run_scan upload flag AND admin dashboard builder AND collect portal ?session=<id> deep-link

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. bullium-site: 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. bullium-site: 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. bullium-site: 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 bullium-siteFeature 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)

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        │
└────────────────────────┼────────────────────────────────┘


              ┌─────────────────────┐
              │   bullium-site      │
              │   (Netlify Portal)  │
              │                     │
              │ /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 (2106 tests)
  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://dev--bullium.netlify.app/api/upload"' > /tmp/dev-test.conf
    echo 'NV_API_KEY="nvt_pro_<hex>"' >> /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/ (1088 tests)
  2. Run client JS tests: npm run test:client (143 tests)
  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)