Files
vulncheck/app/routers/netdisco.py
T
vulncheck 236c00ada7 feat(audit): every write route leaves an audit row
Groups, policies, scan schedules, finding assignment and suppression, and
every sync, scan, refresh and connection-test button wrote no audit row,
so "who did this, and what changed" had no answer for them.

Records that change now write one row naming each field old -> new
(audit_trail.record_change; a create passes before={}, a delete after={},
nothing changed writes nothing), in the same transaction as the change.
Defer, undefer and false-positive also record the date, reason and
suppression they set or clear; an admin's email change is logged.

Buttons take the route dependency audited("What"): one row once the
endpoint returned, before the response goes out, so a refused or raising
call writes none and a failing audit write is a 500 rather than a gap. It
keeps the query parameters and only the named body fields, because request
bodies carry passwords: the test-mail recipient and SMTP host, the syslog
target, the AI provider and base URL, the Nessus asset and scan.

A guard test fails for any new POST/PUT/PATCH/DELETE route that does not
audit itself. No new event types, so no migration: groups, policies and
schedules log as CONFIG_CHANGE, buttons as SCAN_TRIGGERED, told apart by
resource_type and the description.
2026-10-26 11:03:31 +01:00

105 lines
3.9 KiB
Python

"""Netdisco integration HTTP endpoints.
- POST /api/v1/integrations/netdisco/test auth/connectivity probe
- POST /api/v1/integrations/netdisco/sync trigger inventory sync
- GET /api/v1/integrations/netdisco/sync/status poll target for the GUI
Config lives in the settings table under `netdisco_config` (encrypted JSON:
host, port, use_https, verify_ssl, api_key, username, password,
auto_create_assets).
"""
from __future__ import annotations
import logging
import threading
import time
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.auth.dependencies import RequireAdmin, RequireEditor, audited
from app.services.sync_run_service import record_sync_run
from app.database import get_db, SessionLocal
from app.models.user import User
from app.services.netdisco_service import (
_build_client, load_netdisco_config, run_netdisco_sync,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/integrations/netdisco", tags=["Netdisco"])
# Last/current manual-sync state so the GUI can poll for completion (the sync
# itself is fire-and-forget 202). ponytail: module-level state assumes the
# single uvicorn worker we ship with; if you add --workers, move this to the DB.
_ND_SYNC: dict = {"running": False, "result": None, "error": None,
"started_at": None, "finished_at": None}
@router.post("/test", dependencies=[audited("Netdisco connection test")])
def test_netdisco(
db: Session = Depends(get_db),
current_user: User = Depends(RequireAdmin),
):
"""Verify the Netdisco connection: login (if any), then the inventory read."""
cfg = load_netdisco_config(db)
if not cfg:
raise HTTPException(400, "Netdisco is not configured (host missing).")
client = _build_client(cfg)
try:
return client.test_connection()
finally:
client.close()
def _run_netdisco_sync_threaded() -> None:
db = SessionLocal()
try:
with record_sync_run("netdisco", "manual") as run:
stats = run_netdisco_sync(db)
run.stats.update(stats)
result = {k: v for k, v in stats.items() if k != "errors"}
_ND_SYNC["result"] = result
_ND_SYNC["error"] = None
logger.info("Netdisco sync (manual) done: %s", result)
except Exception as e:
_ND_SYNC["result"] = None
_ND_SYNC["error"] = str(e)
logger.error("Netdisco sync (manual) failed: %s", e)
finally:
db.close()
_ND_SYNC["running"] = False
_ND_SYNC["finished_at"] = time.time()
@router.post("/sync", status_code=202, dependencies=[audited("Netdisco sync")])
def sync_netdisco(
db: Session = Depends(get_db),
current_user: User = Depends(RequireEditor),
):
"""Kick off a Netdisco inventory sync in the background (202).
Fire-and-forget so a large estate can't trip the reverse-proxy request
timeout — a campus Netdisco holds thousands of devices. Poll GET
/sync/status."""
if not load_netdisco_config(db):
raise HTTPException(400, "Netdisco is not configured.")
if _ND_SYNC["running"]:
return {"status": "already_running", "detail": "A Netdisco sync is already in progress."}
_ND_SYNC.update({"running": True, "result": None, "error": None,
"started_at": time.time(), "finished_at": None})
threading.Thread(target=_run_netdisco_sync_threaded, daemon=True).start()
return {"status": "started", "detail": "Netdisco sync started in the background."}
@router.get("/sync/status")
def sync_netdisco_status(current_user: User = Depends(RequireEditor)):
"""Poll target for the GUI: current/last manual-sync state + result stats."""
s = _ND_SYNC
state = ("running" if s["running"]
else "error" if s["error"]
else "done" if s["result"]
else "idle")
return {"state": state, "running": s["running"], "result": s["result"],
"error": s["error"], "finished_at": s["finished_at"]}