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.
This commit is contained in:
2026-10-26 11:03:31 +01:00
parent 69d9447de7
commit 236c00ada7
22 changed files with 597 additions and 99 deletions
+15
View File
@@ -552,6 +552,21 @@ user (sync, scheduler) stay as they are: no name, no IP — they read
old → new; a settings `PUT` writes `CONFIG_CHANGE` with the changed field old → new; a settings `PUT` writes `CONFIG_CHANGE` with the changed field
names and redacted old/new values, and nothing when the value is unchanged. names and redacted old/new values, and nothing when the value is unchanged.
Every POST/PUT/PATCH/DELETE route audits itself, and
`tests/test_audit_user_actions.py` fails for a new one that does not. Two
helpers in `app/services/audit_trail.py`: `record_change` (one row naming each
field old → new; create passes `before={}`, delete `after={}`; nothing changed,
no row) for groups, policies, scan schedules, finding/asset assignment and
suppression; `record_action` for actions that change no single record. Buttons
(sync, scan, refresh, connection test) take the route dependency
`audited("What")` from `app/auth/dependencies.py`: one row once the endpoint
returned, before the response goes out (`scope="function"`) — a refused call
or one that raises writes none, a connection test answering `{"ok": false}`
is recorded as attempted — with the query parameters and only the named
`body_fields` (bodies carry passwords). Groups, policies and
schedules log as `CONFIG_CHANGE`, buttons as `SCAN_TRIGGERED`, both told apart
by `resource_type` and the description; no new enum values, so no migration.
### Custom CA certificates (internal PKI) ### Custom CA certificates (internal PKI)
Integrations behind an internal-PKI certificate used to work only with Integrations behind an internal-PKI certificate used to work only with
+38
View File
@@ -166,3 +166,41 @@ async def capture_request_origin(request: Request) -> None:
from app.models.audit_log import request_origin from app.models.audit_log import request_origin
request_origin.set((request.client.host if request.client else None, request_origin.set((request.client.host if request.client else None,
request.headers.get("user-agent"))) request.headers.get("user-agent")))
def audited(what: str, event_type=None, resource_type: Optional[str] = None,
body_fields: tuple = ()):
"""Route dependency for an action button that changes no single record
(sync, scan, refresh, connection test): one audit row naming who started
`what`, written once the endpoint returned — a refused call or one that
raises writes none; a test that answers {"ok": false} counts as attempted. The query parameters go into the row, and of a JSON body only
`body_fields` ("a" or "a.b"), never the rest: bodies carry passwords.
With `resource_type`, the route's path id is the resource.
Returns the Depends itself: scope="function" writes the row before the
response goes out, so a failing write is a 500, not a silent gap."""
from starlette.concurrency import run_in_threadpool
from app.models.audit_log import AuditEventType
from app.services.audit_trail import record_action
async def _audit(request: Request, db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)):
yield
q = request.query_params
details = {k: q.getlist(k) if len(q.getlist(k)) > 1 else q[k] for k in q.keys()}
if body_fields:
body = await request.json() # cached from the endpoint's own parse
for path in body_fields:
value = body
for part in path.split("."):
value = value.get(part) if isinstance(value, dict) else None
if value is not None:
details[path] = value
resource_id = next(iter(request.path_params.values()), None) if resource_type else None
def _write():
record_action(db, current_user.id, event_type or AuditEventType.SCAN_TRIGGERED, what,
resource_type=resource_type, resource_id=resource_id, details=details)
db.commit()
await run_in_threadpool(_write)
return Depends(_audit, scope="function")
+4 -4
View File
@@ -6,7 +6,7 @@ from sqlalchemy.orm import Session
from app.database import get_db from app.database import get_db
from app.models.user import User from app.models.user import User
from app.auth.dependencies import get_current_user, RequireEditor from app.auth.dependencies import get_current_user, RequireEditor, audited
router = APIRouter(prefix="/api/v1/advisories", tags=["Advisories"]) router = APIRouter(prefix="/api/v1/advisories", tags=["Advisories"])
@@ -36,7 +36,7 @@ def advisory_feeds(
return cached return cached
@router.post("/feeds/refresh") @router.post("/feeds/refresh", dependencies=[audited("Advisory feeds refresh")])
# Sync def → worker threadpool; N feed fetches are blocking I/O. # Sync def → worker threadpool; N feed fetches are blocking I/O.
def refresh_advisory_feeds( def refresh_advisory_feeds(
db: Session = Depends(get_db), db: Session = Depends(get_db),
@@ -71,7 +71,7 @@ def kev_recent(
} }
@router.post("/kev/refresh") @router.post("/kev/refresh", dependencies=[audited("CISA KEV refresh")])
# Sync def → worker threadpool; three catalog downloads are blocking I/O. # Sync def → worker threadpool; three catalog downloads are blocking I/O.
def refresh_kev( def refresh_kev(
db: Session = Depends(get_db), db: Session = Depends(get_db),
@@ -106,7 +106,7 @@ def kev_alert_preview(
} }
@router.post("/kev-alerts/run") @router.post("/kev-alerts/run", dependencies=[audited("KEV alert run")])
# Sync def → worker threadpool; blocking SMTP must stay off the event loop. # Sync def → worker threadpool; blocking SMTP must stay off the event loop.
def run_kev_alerts_now( def run_kev_alerts_now(
db: Session = Depends(get_db), db: Session = Depends(get_db),
+19 -22
View File
@@ -17,7 +17,8 @@ from app.models.asset import Asset, AssetSource, AssetStatus, ContainerPlacement
from app.models.policy import Policy from app.models.policy import Policy
from app.models.vulnerability import Vulnerability, VulnerabilityStatus from app.models.vulnerability import Vulnerability, VulnerabilityStatus
from app.models.audit_log import AuditLog, AuditEventType from app.models.audit_log import AuditLog, AuditEventType
from app.auth.dependencies import get_current_user, RequireEditor, RequireAdmin from app.auth.dependencies import get_current_user, RequireEditor, RequireAdmin, audited
from app.services.audit_trail import record_change
import csv import csv
import io import io
import logging import logging
@@ -168,22 +169,13 @@ def _asset_state(asset: Asset, fields) -> dict:
def _audit_asset_update(db: Session, user: User, asset: Asset, before: dict) -> None: def _audit_asset_update(db: Session, user: User, asset: Asset, before: dict) -> None:
"""One ASSET_UPDATED row naming every changed field, old → new. Nothing """One ASSET_UPDATED row naming every changed field, old → new. Nothing
changed, no row.""" changed, no row."""
after = _asset_state(asset, before) record_change(db, user.id, AuditEventType.ASSET_UPDATED, "asset", asset.id,
changed = [f for f in before if before[f] != after[f]] f"Asset updated: {asset.hostname}", before, _asset_state(asset, before))
if not changed:
return
shown = "; ".join(f"{f}: {'–' if before[f] is None else before[f]} → " def _assignment(asset: Asset) -> dict:
f"{'–' if after[f] is None else after[f]}" for f in changed) return {"assigned_user": asset.assigned_user.username if asset.assigned_user else None,
db.add(AuditLog( "groups": sorted(g.name for g in asset.groups)}
user_id=user.id,
event_type=AuditEventType.ASSET_UPDATED,
event_description=f"Asset updated: {asset.hostname} — {shown}"[:500],
resource_type="asset",
resource_id=str(asset.id),
old_value=json.dumps({f: before[f] for f in changed}, default=str),
new_value=json.dumps({f: after[f] for f in changed}, default=str),
timestamp=datetime.now(),
))
# ============================================ # ============================================
@@ -250,7 +242,7 @@ def bulk_update_assets(
return {"message": f"{count} assets successfully updated", "count": count} return {"message": f"{count} assets successfully updated", "count": count}
@router.post("/reconcile-lifecycle") @router.post("/reconcile-lifecycle", dependencies=[audited("Asset lifecycle reconcile")])
def reconcile_lifecycle( def reconcile_lifecycle(
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(RequireEditor), current_user: User = Depends(RequireEditor),
@@ -469,7 +461,7 @@ def asset_containers(
for a, containers, n in rows]} for a, containers, n in rows]}
@router.post("/refresh-exposure") @router.post("/refresh-exposure", dependencies=[audited("Asset exposure refresh")])
def refresh_exposure( def refresh_exposure(
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(RequireEditor), current_user: User = Depends(RequireEditor),
@@ -1101,7 +1093,8 @@ def assign_asset(
user = db.query(User).filter(User.id == assign_data.user_id).first() user = db.query(User).filter(User.id == assign_data.user_id).first()
if not user: if not user:
raise HTTPException(status_code=404, detail="User not found") raise HTTPException(status_code=404, detail="User not found")
before = _assignment(asset)
asset.assigned_user_id = assign_data.user_id asset.assigned_user_id = assign_data.user_id
# Handle single group assignment (legacy support) # Handle single group assignment (legacy support)
@@ -1113,7 +1106,11 @@ def assign_asset(
group = db.query(Group).filter(Group.id == assign_data.group_id).first() group = db.query(Group).filter(Group.id == assign_data.group_id).first()
if group: if group:
asset.groups = [group] asset.groups = [group]
db.flush()
db.expire(asset, ["assigned_user"]) # reload it from the new id
record_change(db, current_user.id, AuditEventType.ASSET_UPDATED, "asset", asset.id,
f"Asset updated: {asset.hostname}", before, _assignment(asset))
db.commit() db.commit()
db.refresh(asset) db.refresh(asset)
@@ -1161,7 +1158,7 @@ from app.integrations.wazuh_client import WazuhClient, WazuhAPIError
from app.models.setting import Setting from app.models.setting import Setting
from app.services.asset_matching import like_literal, pick_one from app.services.asset_matching import like_literal, pick_one
@router.post("/sync_wazuh", response_model=dict) @router.post("/sync_wazuh", response_model=dict, dependencies=[audited("Wazuh asset sync")])
def sync_wazuh_assets( def sync_wazuh_assets(
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(RequireEditor) current_user: User = Depends(RequireEditor)
+5 -1
View File
@@ -29,6 +29,7 @@ from app.auth.jwt_handler import (
TOKEN_TYPE_MFA_SETUP, TOKEN_TYPE_MFA_SETUP,
) )
from app.auth.dependencies import get_current_user, RequireAdmin from app.auth.dependencies import get_current_user, RequireAdmin
from app.services.audit_trail import record_change
from app.auth.orchestrator import AuthOrchestrator from app.auth.orchestrator import AuthOrchestrator
from app.auth.strategies.base import AuthError from app.auth.strategies.base import AuthError
from app.auth import totp as totp_helper from app.auth import totp as totp_helper
@@ -1142,7 +1143,10 @@ def update_user(
existing = db.query(User).filter(User.email == user_data.email).first() existing = db.query(User).filter(User.email == user_data.email).first()
if existing and existing.id != user_id: if existing and existing.id != user_id:
raise HTTPException(status_code=400, detail="Email already in use") raise HTTPException(status_code=400, detail="Email already in use")
record_change(db, current_user.id, AuditEventType.CONFIG_CHANGE, "user", user.id,
f"User updated: {user.username}",
{"email": user.email}, {"email": user_data.email})
user.email = user_data.email user.email = user_data.email
changes.append("email") changes.append("email")
+4 -4
View File
@@ -27,7 +27,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel, Field, field_validator from pydantic import BaseModel, Field, field_validator
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.auth.dependencies import RequireAdmin from app.auth.dependencies import RequireAdmin, audited
from app.auth.role_mapper import SETTINGS_KEY as ROLE_MAP_SETTINGS_KEY from app.auth.role_mapper import SETTINGS_KEY as ROLE_MAP_SETTINGS_KEY
from app.database import get_db from app.database import get_db
from app.models.audit_log import AuditEventType, AuditLog from app.models.audit_log import AuditEventType, AuditLog
@@ -228,7 +228,7 @@ def put_role_mappings(
# Provider test endpoints # Provider test endpoints
# ============================================ # ============================================
@router.post("/ldap/test") @router.post("/ldap/test", dependencies=[audited("LDAP connection test")])
def test_ldap( def test_ldap(
payload: LdapTestRequest, payload: LdapTestRequest,
db: Session = Depends(get_db), db: Session = Depends(get_db),
@@ -277,7 +277,7 @@ def test_ldap(
return out return out
@router.post("/oidc/test") @router.post("/oidc/test", dependencies=[audited("OIDC connection test")])
def test_oidc( def test_oidc(
current_user: User = Depends(RequireAdmin), current_user: User = Depends(RequireAdmin),
): ):
@@ -313,7 +313,7 @@ def test_oidc(
} }
@router.post("/saml/test") @router.post("/saml/test", dependencies=[audited("SAML configuration test")])
def test_saml( def test_saml(
current_user: User = Depends(RequireAdmin), current_user: User = Depends(RequireAdmin),
): ):
+13 -5
View File
@@ -22,7 +22,7 @@ from pydantic import BaseModel
from sqlalchemy import func from sqlalchemy import func
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.auth.dependencies import RequireEditor, get_current_user, RequireAdmin from app.auth.dependencies import RequireEditor, get_current_user, RequireAdmin, audited
from app.database import get_db from app.database import get_db
from app.models.asset import Asset from app.models.asset import Asset
from app.models.compliance import ( from app.models.compliance import (
@@ -35,6 +35,8 @@ from app.services.compliance_service import (
refresh_asset_compliance, refresh_asset_compliance,
) )
from app.services.compliance_impact_import import import_impact_csv_files from app.services.compliance_impact_import import import_impact_csv_files
from app.models.audit_log import AuditEventType
from app.services.audit_trail import record_action
from app.services.urs_service import ( from app.services.urs_service import (
compute_urs, compute_urs_bulk, compute_urs_for_all, get_urs_trend, compute_urs, compute_urs_bulk, compute_urs_for_all, get_urs_trend,
) )
@@ -247,7 +249,7 @@ def get_asset_policy_checks(
# ---------- mutation endpoints ---------- # ---------- mutation endpoints ----------
@router.post("/{asset_id:int}/refresh") @router.post("/{asset_id:int}/refresh", dependencies=[audited("Compliance refresh", resource_type="asset")])
def refresh_one_asset( def refresh_one_asset(
asset_id: int, asset_id: int,
db: Session = Depends(get_db), db: Session = Depends(get_db),
@@ -285,7 +287,13 @@ async def import_compliance_impacts(
f"{f.filename or 'upload.csv'} ist groesser als " f"{f.filename or 'upload.csv'} ist groesser als "
f"{MAX_UPLOAD_BYTES // (1024 * 1024)} MB — abgelehnt") f"{MAX_UPLOAD_BYTES // (1024 * 1024)} MB — abgelehnt")
payloads.append((f.filename or "upload.csv", blob)) payloads.append((f.filename or "upload.csv", blob))
return import_impact_csv_files(db, payloads) result = import_impact_csv_files(db, payloads)
record_action(db, current_user.id, AuditEventType.CONFIG_CHANGE,
f"Compliance impact CSVs imported: {', '.join(name for name, _ in payloads)}",
resource_type="compliance_impact",
details={k: result[k] for k in ("parsed", "imported", "skipped_no_id", "skipped_not_cis")})
db.commit()
return result
@router.get("/impacts/stats") @router.get("/impacts/stats")
@@ -398,7 +406,7 @@ def list_urs(
return rows[:limit] return rows[:limit]
@router.post("/urs/recompute-all") @router.post("/urs/recompute-all", dependencies=[audited("URS recompute (all assets)")])
def recompute_all_urs( def recompute_all_urs(
avs_mode: str = Query("hybrid", pattern="^(hybrid|avg|max)$"), avs_mode: str = Query("hybrid", pattern="^(hybrid|avg|max)$"),
db: Session = Depends(get_db), db: Session = Depends(get_db),
@@ -410,7 +418,7 @@ def recompute_all_urs(
return stats return stats
@router.post("/refresh") @router.post("/refresh", dependencies=[audited("Compliance refresh (all assets)")])
def refresh_everything( def refresh_everything(
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(RequireEditor), current_user: User = Depends(RequireEditor),
+3 -3
View File
@@ -16,7 +16,7 @@ import time
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.auth.dependencies import RequireAdmin, RequireEditor from app.auth.dependencies import RequireAdmin, RequireEditor, audited
from app.database import SessionLocal, get_db from app.database import SessionLocal, get_db
from app.models.user import User from app.models.user import User
from app.services import dockhand_service from app.services import dockhand_service
@@ -32,7 +32,7 @@ _DH_SYNC: dict = {"running": False, "result": None, "error": None,
"started_at": None, "finished_at": None} "started_at": None, "finished_at": None}
@router.post("/test") @router.post("/test", dependencies=[audited("Dockhand connection test")])
def test_dockhand( def test_dockhand(
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(RequireAdmin), current_user: User = Depends(RequireAdmin),
@@ -61,7 +61,7 @@ def _run_dockhand_sync_threaded() -> None:
_DH_SYNC["finished_at"] = time.time() _DH_SYNC["finished_at"] = time.time()
@router.post("/sync", status_code=202) @router.post("/sync", status_code=202, dependencies=[audited("Dockhand sync")])
def sync_dockhand( def sync_dockhand(
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(RequireEditor), current_user: User = Depends(RequireEditor),
+23 -1
View File
@@ -12,6 +12,8 @@ from app.models.user import User, UserRole
from app.models.group import Group, user_groups from app.models.group import Group, user_groups
from app.models.setting import Setting from app.models.setting import Setting
from app.auth.dependencies import get_current_user, RequireAdmin from app.auth.dependencies import get_current_user, RequireAdmin
from app.models.audit_log import AuditEventType
from app.services.audit_trail import record_change
router = APIRouter(prefix="/api/v1/groups", tags=["Groups"]) router = APIRouter(prefix="/api/v1/groups", tags=["Groups"])
@@ -38,6 +40,17 @@ class GroupResponse(BaseModel):
class Config: class Config:
from_attributes = True from_attributes = True
def _audit_state(group: Group) -> dict:
return {"name": group.name, "description": group.description,
"members": sorted(u.username for u in group.users),
"assets": sorted(a.hostname for a in group.assets)}
def _audit(db: Session, user: User, group: Group, verb: str, before: dict, after: dict) -> None:
record_change(db, user.id, AuditEventType.CONFIG_CHANGE, "group", group.id,
f"Group {verb}: {group.name}", before, after)
# --- Endpoints --- # --- Endpoints ---
@router.get("", response_model=List[GroupResponse]) @router.get("", response_model=List[GroupResponse])
@@ -81,6 +94,7 @@ def create_group(
users = db.query(User).filter(User.id.in_(group_in.user_ids)).all() users = db.query(User).filter(User.id.in_(group_in.user_ids)).all()
group.users = users group.users = users
_audit(db, current_user, group, "created", {}, _audit_state(group))
db.commit() db.commit()
db.refresh(group) db.refresh(group)
@@ -104,6 +118,7 @@ def update_group(
group = db.query(Group).filter(Group.id == group_id).first() group = db.query(Group).filter(Group.id == group_id).first()
if not group: if not group:
raise HTTPException(status_code=404, detail="Group not found") raise HTTPException(status_code=404, detail="Group not found")
before = _audit_state(group)
if group_in.name: if group_in.name:
existing = db.query(Group).filter(Group.name == group_in.name, Group.id != group_id).first() existing = db.query(Group).filter(Group.name == group_in.name, Group.id != group_id).first()
@@ -118,6 +133,7 @@ def update_group(
users = db.query(User).filter(User.id.in_(group_in.user_ids)).all() users = db.query(User).filter(User.id.in_(group_in.user_ids)).all()
group.users = users group.users = users
_audit(db, current_user, group, "updated", before, _audit_state(group))
db.commit() db.commit()
db.refresh(group) db.refresh(group)
@@ -141,6 +157,8 @@ def delete_group(
if not group: if not group:
raise HTTPException(status_code=404, detail="Group not found") raise HTTPException(status_code=404, detail="Group not found")
_audit(db, current_user, group, "deleted", _audit_state(group), {})
# Clear M2M relationships first (user_groups and asset_groups tables) # Clear M2M relationships first (user_groups and asset_groups tables)
group.users = [] group.users = []
group.assets = [] group.assets = []
@@ -178,12 +196,16 @@ def set_default_group(
raise HTTPException(status_code=404, detail="Group not found") raise HTTPException(status_code=404, detail="Group not found")
setting = db.query(Setting).filter(Setting.key == "default_group_id").first() setting = db.query(Setting).filter(Setting.key == "default_group_id").first()
record_change(db, current_user.id, AuditEventType.CONFIG_CHANGE, "setting", "default_group_id",
"Default group changed",
{"default_group_id": setting.value if setting else None},
{"default_group_id": None if group_id is None else str(group_id)})
if group_id is None: if group_id is None:
# Clear the default group # Clear the default group
if setting: if setting:
db.delete(setting) db.delete(setting)
db.commit() db.commit()
return {"message": "Default group cleared"} return {"message": "Default group cleared"}
if not setting: if not setting:
+3 -3
View File
@@ -16,7 +16,7 @@ import time
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.auth.dependencies import RequireAdmin, RequireEditor from app.auth.dependencies import RequireAdmin, RequireEditor, audited
from app.services.sync_run_service import record_sync_run from app.services.sync_run_service import record_sync_run
from app.database import get_db, SessionLocal from app.database import get_db, SessionLocal
from app.models.user import User from app.models.user import User
@@ -35,7 +35,7 @@ _IGEL_SYNC: dict = {"running": False, "result": None, "error": None,
"started_at": None, "finished_at": None} "started_at": None, "finished_at": None}
@router.post("/test") @router.post("/test", dependencies=[audited("IGEL connection test")])
def test_igel( def test_igel(
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(RequireAdmin), current_user: User = Depends(RequireAdmin),
@@ -79,7 +79,7 @@ def _run_igel_sync_threaded() -> None:
_IGEL_SYNC["finished_at"] = time.time() _IGEL_SYNC["finished_at"] = time.time()
@router.post("/sync", status_code=202) @router.post("/sync", status_code=202, dependencies=[audited("IGEL sync")])
def sync_igel( def sync_igel(
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(RequireEditor), current_user: User = Depends(RequireEditor),
+3 -3
View File
@@ -18,7 +18,7 @@ from typing import Optional
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.auth.dependencies import RequireAdmin, RequireEditor from app.auth.dependencies import RequireAdmin, RequireEditor, audited
from app.services.sync_run_service import record_sync_run from app.services.sync_run_service import record_sync_run
from app.database import get_db, SessionLocal from app.database import get_db, SessionLocal
from app.models.user import User from app.models.user import User
@@ -35,7 +35,7 @@ _INTUNE_SYNC: dict = {"running": False, "result": None, "error": None,
"started_at": None, "finished_at": None} "started_at": None, "finished_at": None}
@router.post("/test") @router.post("/test", dependencies=[audited("Intune connection test")])
def test_intune( def test_intune(
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(RequireAdmin), current_user: User = Depends(RequireAdmin),
@@ -71,7 +71,7 @@ def _run_intune_sync_threaded() -> None:
_INTUNE_SYNC["finished_at"] = time.time() _INTUNE_SYNC["finished_at"] = time.time()
@router.post("/sync", status_code=202) @router.post("/sync", status_code=202, dependencies=[audited("Intune sync")])
def sync_intune( def sync_intune(
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(RequireEditor), current_user: User = Depends(RequireEditor),
+7 -6
View File
@@ -16,7 +16,8 @@ from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.auth.dependencies import RequireAdmin, RequireEditor from app.auth.dependencies import RequireAdmin, RequireEditor, audited
from app.models.audit_log import AuditEventType
from app.services.sync_run_service import fail_if_source_broken, record_sync_run from app.services.sync_run_service import fail_if_source_broken, record_sync_run
from app.database import get_db, job_slot from app.database import get_db, job_slot
from app.integrations.nessus_client import ( from app.integrations.nessus_client import (
@@ -65,7 +66,7 @@ class NessusScanImportRequest(BaseModel):
# ---------- endpoints ---------- # ---------- endpoints ----------
@router.post("/test") @router.post("/test", dependencies=[audited("Nessus connection test")])
def test_nessus_connection( def test_nessus_connection(
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(RequireAdmin), current_user: User = Depends(RequireAdmin),
@@ -138,7 +139,7 @@ def list_nessus_scans(
raise HTTPException(502, f"Nessus API error: {e}") raise HTTPException(502, f"Nessus API error: {e}")
@router.post("/sync") @router.post("/sync", dependencies=[audited("Nessus sync")])
# Sync def → worker threadpool (off the event loop) so a long Nessus # Sync def → worker threadpool (off the event loop) so a long Nessus
# import doesn't freeze the web GUI. # import doesn't freeze the web GUI.
def trigger_nessus_sync( def trigger_nessus_sync(
@@ -185,7 +186,7 @@ def trigger_nessus_sync(
raise HTTPException(500, f"Nessus sync failed: {e}") raise HTTPException(500, f"Nessus sync failed: {e}")
@router.post("/scan-host") @router.post("/scan-host", dependencies=[audited("Nessus targeted scan", body_fields=("asset_id", "scan_id"))])
def nessus_scan_host( def nessus_scan_host(
payload: NessusScanHostRequest, payload: NessusScanHostRequest,
db: Session = Depends(get_db), db: Session = Depends(get_db),
@@ -295,7 +296,7 @@ def nessus_scan_host(
} }
@router.post("/scan-and-import") @router.post("/scan-and-import", dependencies=[audited("Nessus scan and import", body_fields=("asset_id", "scan_id"))])
def nessus_scan_and_import( def nessus_scan_and_import(
payload: NessusScanImportRequest, payload: NessusScanImportRequest,
db: Session = Depends(get_db), db: Session = Depends(get_db),
@@ -332,7 +333,7 @@ def nessus_scan_and_import_status(
return job return job
@router.post("/cleanup-pseudo-cves") @router.post("/cleanup-pseudo-cves", dependencies=[audited("Nessus pseudo-CVE cleanup", event_type=AuditEventType.VULNERABILITY_UPDATED)])
def cleanup_pseudo_cves( def cleanup_pseudo_cves(
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(RequireAdmin), current_user: User = Depends(RequireAdmin),
+3 -3
View File
@@ -17,7 +17,7 @@ import time
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.auth.dependencies import RequireAdmin, RequireEditor from app.auth.dependencies import RequireAdmin, RequireEditor, audited
from app.services.sync_run_service import record_sync_run from app.services.sync_run_service import record_sync_run
from app.database import get_db, SessionLocal from app.database import get_db, SessionLocal
from app.models.user import User from app.models.user import User
@@ -36,7 +36,7 @@ _ND_SYNC: dict = {"running": False, "result": None, "error": None,
"started_at": None, "finished_at": None} "started_at": None, "finished_at": None}
@router.post("/test") @router.post("/test", dependencies=[audited("Netdisco connection test")])
def test_netdisco( def test_netdisco(
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(RequireAdmin), current_user: User = Depends(RequireAdmin),
@@ -72,7 +72,7 @@ def _run_netdisco_sync_threaded() -> None:
_ND_SYNC["finished_at"] = time.time() _ND_SYNC["finished_at"] = time.time()
@router.post("/sync", status_code=202) @router.post("/sync", status_code=202, dependencies=[audited("Netdisco sync")])
def sync_netdisco( def sync_netdisco(
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(RequireEditor), current_user: User = Depends(RequireEditor),
+3 -3
View File
@@ -15,7 +15,7 @@ from app.models.user import User
from app.models.notification_log import NotificationLog, NotificationType, NotificationStatus from app.models.notification_log import NotificationLog, NotificationType, NotificationStatus
from app.models.vulnerability import Vulnerability from app.models.vulnerability import Vulnerability
from app.models.asset import Asset from app.models.asset import Asset
from app.auth.dependencies import get_current_user, RequireAdmin from app.auth.dependencies import get_current_user, RequireAdmin, audited
from app.services.email_service import send_email, get_smtp_config from app.services.email_service import send_email, get_smtp_config
router = APIRouter(prefix="/api/v1/notifications", tags=["Notifications"]) router = APIRouter(prefix="/api/v1/notifications", tags=["Notifications"])
@@ -111,7 +111,7 @@ def list_notification_logs(
return results return results
@router.post("/test") @router.post("/test", dependencies=[audited("Test email", body_fields=("to_email", "smtp_config.host"))])
# Sync def → threadpool; the blocking SMTP send won't stall the event loop. # Sync def → threadpool; the blocking SMTP send won't stall the event loop.
def send_test_email( def send_test_email(
test_data: TestEmailRequest, test_data: TestEmailRequest,
@@ -145,7 +145,7 @@ def send_test_email(
raise HTTPException(status_code=500, detail=f"Email sending failed: {message}") raise HTTPException(status_code=500, detail=f"Email sending failed: {message}")
@router.post("/notify-critical") @router.post("/notify-critical", dependencies=[audited("Critical vulnerability notification run")])
# Sync def → Starlette runs it in a worker threadpool (off the event loop), so # Sync def → Starlette runs it in a worker threadpool (off the event loop), so
# the blocking SMTP send loop over thousands of CVEs doesn't freeze the web GUI. # the blocking SMTP send loop over thousands of CVEs doesn't freeze the web GUI.
def notify_unnotified_critical_vulnerabilities( def notify_unnotified_critical_vulnerabilities(
+21 -1
View File
@@ -11,9 +11,24 @@ from app.database import get_db
from app.models.policy import Policy, PolicyStatus from app.models.policy import Policy, PolicyStatus
from app.models.user import User from app.models.user import User
from app.auth.dependencies import get_current_user, RequireAdmin, RequireEditor from app.auth.dependencies import get_current_user, RequireAdmin, RequireEditor
from app.models.audit_log import AuditEventType
from app.services.audit_trail import record_change
router = APIRouter(prefix="/api/v1/policies", tags=["Policies"]) router = APIRouter(prefix="/api/v1/policies", tags=["Policies"])
_AUDITED = ("name", "description", "category", "status",
"critical_sla_days", "high_sla_days", "medium_sla_days", "low_sla_days")
def _audit(db: Session, user: User, policy: Policy, verb: str, before: dict, after: dict) -> None:
record_change(db, user.id, AuditEventType.CONFIG_CHANGE, "policy", policy.id,
f"Policy {verb}: {policy.name}", before, after)
def _audit_state(policy: Policy) -> dict:
return {f: getattr(policy, f) for f in _AUDITED}
# --- Schemas --- # --- Schemas ---
class PolicyBase(BaseModel): class PolicyBase(BaseModel):
name: str name: str
@@ -70,6 +85,8 @@ def create_policy(
"""Create a new policy""" """Create a new policy"""
policy = Policy(**policy_data.model_dump()) policy = Policy(**policy_data.model_dump())
db.add(policy) db.add(policy)
db.flush()
_audit(db, current_user, policy, "created", {}, _audit_state(policy))
db.commit() db.commit()
db.refresh(policy) db.refresh(policy)
return policy return policy
@@ -86,8 +103,10 @@ def update_policy(
if not policy: if not policy:
raise HTTPException(status_code=404, detail="Policy not found") raise HTTPException(status_code=404, detail="Policy not found")
before = _audit_state(policy)
for field, value in policy_data.dict(exclude_unset=True).items(): for field, value in policy_data.dict(exclude_unset=True).items():
setattr(policy, field, value) setattr(policy, field, value)
_audit(db, current_user, policy, "updated", before, _audit_state(policy))
db.commit() db.commit()
db.refresh(policy) db.refresh(policy)
@@ -103,7 +122,8 @@ def delete_policy(
policy = db.query(Policy).filter(Policy.id == policy_id).first() policy = db.query(Policy).filter(Policy.id == policy_id).first()
if not policy: if not policy:
raise HTTPException(status_code=404, detail="Policy not found") raise HTTPException(status_code=404, detail="Policy not found")
_audit(db, current_user, policy, "deleted", _audit_state(policy), {})
db.delete(policy) db.delete(policy)
db.commit() db.commit()
return None return None
+26 -3
View File
@@ -13,7 +13,9 @@ from app.models.scan import Scan, ScanType, ScanStatus
from app.models.scan_schedule import ScanSchedule, ScheduleInterval from app.models.scan_schedule import ScanSchedule, ScheduleInterval
from app.models.asset import Asset from app.models.asset import Asset
from app.models.user import User from app.models.user import User
from app.auth.dependencies import get_current_user, RequireEditor from app.auth.dependencies import get_current_user, RequireEditor, audited
from app.models.audit_log import AuditEventType
from app.services.audit_trail import record_action, record_change
from app.routers.vulnerabilities import ( from app.routers.vulnerabilities import (
sync_agent_vulnerabilities, reconcile_empty_agents, sync_agent_vulnerabilities, reconcile_empty_agents,
) )
@@ -219,6 +221,9 @@ def clear_scans(
raise HTTPException(status_code=400, detail="Provide scan_ids, status_filter, or set all=true") raise HTTPException(status_code=400, detail="Provide scan_ids, status_filter, or set all=true")
count = query.delete(synchronize_session=False) count = query.delete(synchronize_session=False)
record_action(db, current_user.id, AuditEventType.CONFIG_CHANGE,
f"Scan history cleared: {count} scans", resource_type="scan",
details=body.model_dump(exclude_defaults=True))
db.commit() db.commit()
return {"deleted": count} return {"deleted": count}
@@ -228,7 +233,7 @@ import json
from app.integrations.wazuh_client import WazuhClient, WazuhAPIError from app.integrations.wazuh_client import WazuhClient, WazuhAPIError
from app.models.setting import Setting from app.models.setting import Setting
@router.post("/autoscan", response_model=dict) @router.post("/autoscan", response_model=dict, dependencies=[audited("Wazuh autoscan (all assets)")])
def trigger_autoscan( def trigger_autoscan(
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(RequireEditor) current_user: User = Depends(RequireEditor)
@@ -357,6 +362,17 @@ INTERVAL_DELTA_MAP = {
ScheduleInterval.WEEKLY: timedelta(weeks=1), ScheduleInterval.WEEKLY: timedelta(weeks=1),
} }
def _audit_schedule(db: Session, user: User, schedule: ScanSchedule, verb: str,
before: dict, after: dict) -> None:
record_change(db, user.id, AuditEventType.CONFIG_CHANGE, "scan_schedule", schedule.id,
f"Scan schedule {verb}: {schedule.name}", before, after)
def _schedule_state(schedule: ScanSchedule) -> dict:
return {f: getattr(schedule, f)
for f in ("name", "interval", "cron_expression", "scanner_type", "enabled")}
@router.get("/schedules", response_model=List[ScheduleResponse]) @router.get("/schedules", response_model=List[ScheduleResponse])
def list_schedules( def list_schedules(
db: Session = Depends(get_db), db: Session = Depends(get_db),
@@ -384,6 +400,8 @@ def create_schedule(
next_run=datetime.now() + INTERVAL_DELTA_MAP.get(data.interval, timedelta(days=1)) next_run=datetime.now() + INTERVAL_DELTA_MAP.get(data.interval, timedelta(days=1))
) )
db.add(schedule) db.add(schedule)
db.flush()
_audit_schedule(db, current_user, schedule, "created", {}, _schedule_state(schedule))
db.commit() db.commit()
db.refresh(schedule) db.refresh(schedule)
return schedule return schedule
@@ -400,6 +418,7 @@ def update_schedule(
schedule = db.query(ScanSchedule).filter(ScanSchedule.id == schedule_id).first() schedule = db.query(ScanSchedule).filter(ScanSchedule.id == schedule_id).first()
if not schedule: if not schedule:
raise HTTPException(status_code=404, detail="Schedule not found") raise HTTPException(status_code=404, detail="Schedule not found")
before = _schedule_state(schedule)
schedule.name = data.name schedule.name = data.name
schedule.interval = data.interval schedule.interval = data.interval
@@ -413,7 +432,8 @@ def update_schedule(
schedule.next_run = None schedule.next_run = None
else: else:
schedule.next_run = datetime.now() + INTERVAL_DELTA_MAP.get(data.interval, timedelta(days=1)) schedule.next_run = datetime.now() + INTERVAL_DELTA_MAP.get(data.interval, timedelta(days=1))
_audit_schedule(db, current_user, schedule, "updated", before, _schedule_state(schedule))
db.commit() db.commit()
db.refresh(schedule) db.refresh(schedule)
return schedule return schedule
@@ -430,6 +450,7 @@ def toggle_schedule(
if not schedule: if not schedule:
raise HTTPException(status_code=404, detail="Schedule not found") raise HTTPException(status_code=404, detail="Schedule not found")
before = _schedule_state(schedule)
schedule.enabled = not schedule.enabled schedule.enabled = not schedule.enabled
if schedule.enabled: if schedule.enabled:
if schedule.interval == ScheduleInterval.CRON and schedule.cron_expression: if schedule.interval == ScheduleInterval.CRON and schedule.cron_expression:
@@ -438,6 +459,7 @@ def toggle_schedule(
schedule.next_run = datetime.now() + INTERVAL_DELTA_MAP.get(schedule.interval, timedelta(days=1)) schedule.next_run = datetime.now() + INTERVAL_DELTA_MAP.get(schedule.interval, timedelta(days=1))
else: else:
schedule.next_run = None schedule.next_run = None
_audit_schedule(db, current_user, schedule, "updated", before, _schedule_state(schedule))
db.commit() db.commit()
db.refresh(schedule) db.refresh(schedule)
return schedule return schedule
@@ -454,6 +476,7 @@ def delete_schedule(
if not schedule: if not schedule:
raise HTTPException(status_code=404, detail="Schedule not found") raise HTTPException(status_code=404, detail="Schedule not found")
_audit_schedule(db, current_user, schedule, "deleted", _schedule_state(schedule), {})
db.delete(schedule) db.delete(schedule)
db.commit() db.commit()
+3 -3
View File
@@ -12,7 +12,7 @@ from app.database import get_db
from app.models.audit_log import AuditEventType, AuditLog from app.models.audit_log import AuditEventType, AuditLog
from app.models.setting import Setting from app.models.setting import Setting
from app.models.user import User from app.models.user import User
from app.auth.dependencies import RequireAdmin from app.auth.dependencies import RequireAdmin, audited
from app.auth.setting_crypto import ( from app.auth.setting_crypto import (
PROTECTED_SETTING_KEYS, PROTECTED_SETTING_KEYS,
decrypt_value, decrypt_value,
@@ -31,7 +31,7 @@ class SyslogTestRequest(BaseModel):
facility: int = 16 facility: int = 16
@router.post("/syslog/test") @router.post("/syslog/test", dependencies=[audited("Syslog test message", body_fields=("host", "port", "protocol"))])
def test_syslog( def test_syslog(
payload: SyslogTestRequest, payload: SyslogTestRequest,
current_user: User = Depends(RequireAdmin), current_user: User = Depends(RequireAdmin),
@@ -240,7 +240,7 @@ class AIModelsRequest(BaseModel):
base_url: Optional[str] = None base_url: Optional[str] = None
@router.post("/ai/models") @router.post("/ai/models", dependencies=[audited("AI model list fetched", body_fields=("provider", "base_url"))])
async def list_ai_models( async def list_ai_models(
body: AIModelsRequest, body: AIModelsRequest,
db: Session = Depends(get_db), db: Session = Depends(get_db),
+4 -4
View File
@@ -17,7 +17,7 @@ import time
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.auth.dependencies import RequireAdmin, RequireEditor from app.auth.dependencies import RequireAdmin, RequireEditor, audited
from app.services.sync_run_service import record_sync_run from app.services.sync_run_service import record_sync_run
from app.database import get_db, SessionLocal from app.database import get_db, SessionLocal
from app.models.user import User from app.models.user import User
@@ -36,7 +36,7 @@ _VCENTER_SYNC: dict = {"running": False, "result": None, "error": None,
"started_at": None, "finished_at": None} "started_at": None, "finished_at": None}
@router.post("/test") @router.post("/test", dependencies=[audited("vCenter connection test")])
def test_vcenter( def test_vcenter(
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(RequireAdmin), current_user: User = Depends(RequireAdmin),
@@ -72,7 +72,7 @@ def _run_vcenter_sync_threaded() -> None:
_VCENTER_SYNC["finished_at"] = time.time() _VCENTER_SYNC["finished_at"] = time.time()
@router.post("/sync", status_code=202) @router.post("/sync", status_code=202, dependencies=[audited("vCenter sync")])
def sync_vcenter( def sync_vcenter(
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(RequireEditor), current_user: User = Depends(RequireEditor),
@@ -103,7 +103,7 @@ def sync_vcenter_status(current_user: User = Depends(RequireEditor)):
"error": s["error"], "finished_at": s["finished_at"]} "error": s["error"], "finished_at": s["finished_at"]}
@router.post("/catalog/refresh") @router.post("/catalog/refresh", dependencies=[audited("vCenter build catalog refresh")])
def refresh_build_catalog( def refresh_build_catalog(
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(RequireAdmin), current_user: User = Depends(RequireAdmin),
+60 -27
View File
@@ -25,7 +25,8 @@ from app.models.vulnerability import Vulnerability, VulnerabilitySeverity, Vulne
from app.models.asset import Asset, AssetStatus, ContainerPlacement from app.models.asset import Asset, AssetStatus, ContainerPlacement
from app.models.ai_analysis import AIAnalysis from app.models.ai_analysis import AIAnalysis
from app.models.audit_log import AuditLog, AuditEventType from app.models.audit_log import AuditLog, AuditEventType
from app.auth.dependencies import get_current_user, RequireEditor from app.auth.dependencies import get_current_user, RequireEditor, audited
from app.services.audit_trail import record_change
from app.integrations.wazuh_client import get_wazuh_client, WazuhClient from app.integrations.wazuh_client import get_wazuh_client, WazuhClient
from app.models.setting import Setting from app.models.setting import Setting
from app.integrations.infomaniak_ai_client import InfomaniakAIClient, get_infomaniak_ai_client, AIAnalysisService from app.integrations.infomaniak_ai_client import InfomaniakAIClient, get_infomaniak_ai_client, AIAnalysisService
@@ -250,6 +251,24 @@ def _auto_reason(new_status, user_name: str) -> str:
return f"Manually set to {label} by {user_name} on {ts}" return f"Manually set to {label} by {user_name} on {ts}"
def _assignment(vuln: Vulnerability) -> dict:
return {"assigned_user": vuln.assigned_user.username if vuln.assigned_user else None,
"assigned_group": vuln.group.name if vuln.group else None}
def _deferral(vuln: Vulnerability) -> dict:
return {"deferred_until": vuln.deferred_until, "defer_reason": vuln.defer_reason,
"notification_suppressed": vuln.notification_suppressed}
def _audit_vuln_update(db: Session, user: User, vuln: Vulnerability, before: dict, after: dict) -> None:
"""Changes to a finding other than its status (log_vulnerability_change)."""
host = vuln.asset.hostname if vuln.asset else None
record_change(db, user.id, AuditEventType.VULNERABILITY_UPDATED, "vulnerability", vuln.id,
f"Vulnerability {vuln.cve_id}" + (f" on {host}" if host else "") + " updated",
before, after)
def log_vulnerability_change( def log_vulnerability_change(
db: Session, db: Session,
user_id: Optional[int], user_id: Optional[int],
@@ -1721,7 +1740,7 @@ def _run_date_backfill_threaded() -> dict:
_DATE_BACKFILL_RUNNING = False _DATE_BACKFILL_RUNNING = False
@router.post("/dates/backfill", status_code=202) @router.post("/dates/backfill", status_code=202, dependencies=[audited("CVE date backfill")])
def backfill_cve_dates( def backfill_cve_dates(
current_user: User = Depends(RequireEditor), current_user: User = Depends(RequireEditor),
): ):
@@ -1747,7 +1766,7 @@ def backfill_cve_dates(
"(watch the backend logs)."} "(watch the backend logs)."}
@router.post("/enrich/kev/refresh") @router.post("/enrich/kev/refresh", dependencies=[audited("CISA KEV refresh")])
def refresh_kev_catalog( def refresh_kev_catalog(
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(RequireEditor) current_user: User = Depends(RequireEditor)
@@ -1783,8 +1802,12 @@ def assign_vulnerability(
if not group: if not group:
raise HTTPException(status_code=404, detail="Group not found") raise HTTPException(status_code=404, detail="Group not found")
before = _assignment(vuln)
vuln.assigned_user_id = assign_data.user_id vuln.assigned_user_id = assign_data.user_id
vuln.assigned_group_id = assign_data.group_id vuln.assigned_group_id = assign_data.group_id
db.flush()
db.expire(vuln, ["assigned_user", "group"]) # reload them from the new ids
_audit_vuln_update(db, current_user, vuln, before, _assignment(vuln))
db.commit() db.commit()
db.refresh(vuln) db.refresh(vuln)
return _build_vuln_response(vuln) return _build_vuln_response(vuln)
@@ -1802,15 +1825,17 @@ def defer_vulnerability(
if not vuln: if not vuln:
raise HTTPException(status_code=404, detail="Vulnerability not found") raise HTTPException(status_code=404, detail="Vulnerability not found")
old_status = vuln.status old_status, before = vuln.status, _deferral(vuln)
vuln.status = VulnerabilityStatus.deferred vuln.status = VulnerabilityStatus.deferred
vuln.deferred_until = defer_data.deferred_until vuln.deferred_until = defer_data.deferred_until
vuln.defer_reason = defer_data.reason vuln.defer_reason = defer_data.reason
vuln.notification_suppressed = True vuln.notification_suppressed = True
_audit_vuln_update(db, current_user, vuln, before, _deferral(vuln))
db.commit() db.commit()
db.refresh(vuln) db.refresh(vuln)
log_vulnerability_change(db, current_user.id, vuln_id, old_status, vuln.status) log_vulnerability_change(db, current_user.id, vuln_id, old_status, vuln.status,
reason=defer_data.reason, cve_id=vuln.cve_id)
return _build_vuln_response(vuln) return _build_vuln_response(vuln)
@@ -1825,15 +1850,16 @@ def undefer_vulnerability(
if not vuln: if not vuln:
raise HTTPException(status_code=404, detail="Vulnerability nicht gefunden") raise HTTPException(status_code=404, detail="Vulnerability nicht gefunden")
old_status = vuln.status old_status, before = vuln.status, _deferral(vuln)
vuln.status = VulnerabilityStatus.open vuln.status = VulnerabilityStatus.open
vuln.deferred_until = None vuln.deferred_until = None
vuln.defer_reason = None vuln.defer_reason = None
vuln.notification_suppressed = False vuln.notification_suppressed = False
_audit_vuln_update(db, current_user, vuln, before, _deferral(vuln))
db.commit() db.commit()
db.refresh(vuln) db.refresh(vuln)
log_vulnerability_change(db, current_user.id, vuln_id, old_status, vuln.status) log_vulnerability_change(db, current_user.id, vuln_id, old_status, vuln.status, cve_id=vuln.cve_id)
return _build_vuln_response(vuln) return _build_vuln_response(vuln)
@@ -1848,7 +1874,10 @@ def toggle_notification_suppression(
if not vuln: if not vuln:
raise HTTPException(status_code=404, detail="Vulnerability nicht gefunden") raise HTTPException(status_code=404, detail="Vulnerability nicht gefunden")
before = {"notification_suppressed": vuln.notification_suppressed}
vuln.notification_suppressed = not vuln.notification_suppressed vuln.notification_suppressed = not vuln.notification_suppressed
_audit_vuln_update(db, current_user, vuln, before,
{"notification_suppressed": vuln.notification_suppressed})
db.commit() db.commit()
db.refresh(vuln) db.refresh(vuln)
return _build_vuln_response(vuln) return _build_vuln_response(vuln)
@@ -1874,15 +1903,17 @@ def mark_false_positive(
if not vuln: if not vuln:
raise HTTPException(status_code=404, detail="Vulnerability not found") raise HTTPException(status_code=404, detail="Vulnerability not found")
old_status = vuln.status old_status, before = vuln.status, _deferral(vuln)
vuln.status = VulnerabilityStatus.false_positive vuln.status = VulnerabilityStatus.false_positive
vuln.notification_suppressed = True vuln.notification_suppressed = True
if payload.reason: if payload.reason:
vuln.defer_reason = payload.reason # reuse existing field for context vuln.defer_reason = payload.reason # reuse existing field for context
_audit_vuln_update(db, current_user, vuln, before, _deferral(vuln))
db.commit() db.commit()
db.refresh(vuln) db.refresh(vuln)
log_vulnerability_change(db, current_user.id, vuln_id, old_status, vuln.status) log_vulnerability_change(db, current_user.id, vuln_id, old_status, vuln.status,
reason=payload.reason, cve_id=vuln.cve_id)
return _build_vuln_response(vuln) return _build_vuln_response(vuln)
@@ -1899,18 +1930,20 @@ def unmark_false_positive(
if vuln.status != VulnerabilityStatus.false_positive: if vuln.status != VulnerabilityStatus.false_positive:
raise HTTPException(status_code=400, detail="Not currently marked as false positive") raise HTTPException(status_code=400, detail="Not currently marked as false positive")
old_status = vuln.status old_status, before = vuln.status, _deferral(vuln)
vuln.status = VulnerabilityStatus.open vuln.status = VulnerabilityStatus.open
vuln.notification_suppressed = False vuln.notification_suppressed = False
vuln.defer_reason = None vuln.defer_reason = None
_audit_vuln_update(db, current_user, vuln, before, _deferral(vuln))
db.commit() db.commit()
db.refresh(vuln) db.refresh(vuln)
log_vulnerability_change(db, current_user.id, vuln_id, old_status, vuln.status) log_vulnerability_change(db, current_user.id, vuln_id, old_status, vuln.status, cve_id=vuln.cve_id)
return _build_vuln_response(vuln) return _build_vuln_response(vuln)
@router.post("/sync/container") @router.post("/sync/container", dependencies=[audited(
"Container image report import", body_fields=("ArtifactName", "source.target.userInput"))])
def sync_container_image_report( def sync_container_image_report(
report: dict = Body(..., description="Trivy `--format json` or Grype `-o json` output of ONE image"), report: dict = Body(..., description="Trivy `--format json` or Grype `-o json` output of ONE image"),
min_severity: str = Query("high", description="none|low|medium|high|critical — below is not imported"), min_severity: str = Query("high", description="none|low|medium|high|critical — below is not imported"),
@@ -1926,7 +1959,7 @@ def sync_container_image_report(
raise HTTPException(status_code=422, detail=str(e)) raise HTTPException(status_code=422, detail=str(e))
@router.post("/sync/wazuh") @router.post("/sync/wazuh", dependencies=[audited("Wazuh vulnerability sync")])
def sync_vulnerabilities_from_wazuh( def sync_vulnerabilities_from_wazuh(
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(RequireEditor) current_user: User = Depends(RequireEditor)
@@ -2974,7 +3007,7 @@ def override_from_vulnrichment(
return result return result
@router.post("/override/vulnrichment/start") @router.post("/override/vulnrichment/start", dependencies=[audited("CISA Vulnrichment CVSS correction", event_type=AuditEventType.VULNERABILITY_UPDATED)])
def start_vulnrichment_correction_job( def start_vulnrichment_correction_job(
cve_ids: Optional[List[str]] = Query(None, description="CVE-IDs zum Korrigieren (leer = alle)"), cve_ids: Optional[List[str]] = Query(None, description="CVE-IDs zum Korrigieren (leer = alle)"),
asset_ids: Optional[List[int]] = Query(None, description="Asset-IDs (leer = alle)"), asset_ids: Optional[List[int]] = Query(None, description="Asset-IDs (leer = alle)"),
@@ -3038,7 +3071,7 @@ def list_vulnrichment_correction_jobs(
return list_override_jobs(limit=limit) return list_override_jobs(limit=limit)
@router.post("/exploit-intel/refresh") @router.post("/exploit-intel/refresh", dependencies=[audited("Exploit intel refresh")])
# Sync def → FastAPI runs it in a worker threadpool, off the event loop, # Sync def → FastAPI runs it in a worker threadpool, off the event loop,
# so the catalog downloads don't freeze the web GUI. # so the catalog downloads don't freeze the web GUI.
@job_slot("exploit_intel") @job_slot("exploit_intel")
@@ -3088,7 +3121,7 @@ def _eol_job_runner(asset_id: Optional[int], user):
_EOL_JOB["finished_at"] = _time.time() _EOL_JOB["finished_at"] = _time.time()
@router.post("/eol-check/start", status_code=202) @router.post("/eol-check/start", status_code=202, dependencies=[audited("End-of-life check")])
def start_eol_check_job( def start_eol_check_job(
asset_id: Optional[int] = Query(None, description="Scope to one asset; omit for all"), asset_id: Optional[int] = Query(None, description="Scope to one asset; omit for all"),
current_user: User = Depends(RequireEditor), current_user: User = Depends(RequireEditor),
@@ -3123,7 +3156,7 @@ def get_eol_check_status(current_user: User = Depends(get_current_user)):
} }
@router.post("/eol-check") @router.post("/eol-check", dependencies=[audited("End-of-life check")])
# Sync def → runs in a worker threadpool (off the event loop) so the # Sync def → runs in a worker threadpool (off the event loop) so the
# per-asset endoflife.date / MS-lifecycle work doesn't freeze the web GUI. # per-asset endoflife.date / MS-lifecycle work doesn't freeze the web GUI.
@job_slot("eol") @job_slot("eol")
@@ -3265,7 +3298,7 @@ def run_eol_check(
return stats return stats
@router.post("/m365-check") @router.post("/m365-check", dependencies=[audited("Microsoft 365 check")])
# Sync def → runs in a worker threadpool (off the event loop) so the # Sync def → runs in a worker threadpool (off the event loop) so the
# cvelistV5/MSRC metadata work doesn't freeze the web GUI. # cvelistV5/MSRC metadata work doesn't freeze the web GUI.
@job_slot("m365") @job_slot("m365")
@@ -3356,7 +3389,7 @@ def _appscan_job_runner(asset_id: Optional[int], rebuild_index: bool):
_APPSCAN_JOB["finished_at"] = _time.time() _APPSCAN_JOB["finished_at"] = _time.time()
@router.post("/app-cve-scan/start", status_code=202) @router.post("/app-cve-scan/start", status_code=202, dependencies=[audited("Application CVE scan")])
def start_app_cve_scan_job( def start_app_cve_scan_job(
asset_id: Optional[int] = Query(None, description="Scope to one asset; omit for all"), asset_id: Optional[int] = Query(None, description="Scope to one asset; omit for all"),
rebuild_index: bool = Query(False, description="Rebuild the CVE indexes first, as the 01:30 job does (downloads the ~557 MB cvelistV5 ZIP)"), rebuild_index: bool = Query(False, description="Rebuild the CVE indexes first, as the 01:30 job does (downloads the ~557 MB cvelistV5 ZIP)"),
@@ -3395,7 +3428,7 @@ def get_app_cve_scan_status(current_user: User = Depends(get_current_user)):
} }
@router.post("/app-cve-scan") @router.post("/app-cve-scan", dependencies=[audited("Application CVE scan")])
# Sync def → worker threadpool; OSV/NVD-CPE lookups are blocking I/O and # Sync def → worker threadpool; OSV/NVD-CPE lookups are blocking I/O and
# must not freeze the web GUI. Kept for API clients; the GUI uses start/status. # must not freeze the web GUI. Kept for API clients; the GUI uses start/status.
@job_slot("app_cve_scan", "vuln_index") @job_slot("app_cve_scan", "vuln_index")
@@ -3454,7 +3487,7 @@ def _msrc_scan_job_runner(asset_id: Optional[int], rebuild_index: bool):
_MSRC_SCAN_JOB["finished_at"] = _time.time() _MSRC_SCAN_JOB["finished_at"] = _time.time()
@router.post("/msrc-scan/start", status_code=202) @router.post("/msrc-scan/start", status_code=202, dependencies=[audited("MSRC scan")])
def start_msrc_scan_job( def start_msrc_scan_job(
asset_id: Optional[int] = Query(None, description="Scope to one asset; omit for all Windows Server assets"), asset_id: Optional[int] = Query(None, description="Scope to one asset; omit for all Windows Server assets"),
rebuild_index: bool = Query(True, description="Re-pull the MSRC CVRF documents first"), rebuild_index: bool = Query(True, description="Re-pull the MSRC CVRF documents first"),
@@ -3495,7 +3528,7 @@ def get_msrc_scan_status(current_user: User = Depends(get_current_user)):
} }
@router.post("/msrc-scan") @router.post("/msrc-scan", dependencies=[audited("MSRC scan")])
# Sync def → worker threadpool; the CVRF fetch is blocking I/O. # Sync def → worker threadpool; the CVRF fetch is blocking I/O.
@job_slot("msrc_scan", "vuln_index") @job_slot("msrc_scan", "vuln_index")
def run_msrc_scan_endpoint( def run_msrc_scan_endpoint(
@@ -3521,7 +3554,7 @@ def run_msrc_scan_endpoint(
raise HTTPException(502, f"MSRC scan failed: {e}") raise HTTPException(502, f"MSRC scan failed: {e}")
@router.post("/suppress-false-positives") @router.post("/suppress-false-positives", dependencies=[audited("False-positive suppression run", event_type=AuditEventType.VULNERABILITY_UPDATED)])
# Sync def → worker threadpool; reads the cvelistV5 ZIP (blocking I/O). # Sync def → worker threadpool; reads the cvelistV5 ZIP (blocking I/O).
def suppress_false_positives_endpoint( def suppress_false_positives_endpoint(
asset_id: Optional[int] = Query(None, description="Scope to one asset; omit for all"), asset_id: Optional[int] = Query(None, description="Scope to one asset; omit for all"),
@@ -3551,7 +3584,7 @@ def ai_remediation_status(
return {"enabled": ai_service.is_enabled(db)} return {"enabled": ai_service.is_enabled(db)}
@router.post("/{vuln_id}/ai-remediation") @router.post("/{vuln_id}/ai-remediation", dependencies=[audited("AI remediation requested", event_type=AuditEventType.AI_ANALYSIS_REQUESTED, resource_type="vulnerability")])
async def ai_remediation( async def ai_remediation(
vuln_id: int, vuln_id: int,
db: Session = Depends(get_db), db: Session = Depends(get_db),
@@ -3789,7 +3822,7 @@ def _run_msrc_refresh_threaded(months_back: Optional[int] = None) -> None:
_MSRC_REFRESH["finished_at"] = _time.time() _MSRC_REFRESH["finished_at"] = _time.time()
@router.post("/msrc/refresh", status_code=202) @router.post("/msrc/refresh", status_code=202, dependencies=[audited("MSRC catalogue refresh")])
def refresh_msrc_endpoint( def refresh_msrc_endpoint(
months_back: Optional[int] = Query(None, description="How many recent monthly MSRC docs to ingest"), months_back: Optional[int] = Query(None, description="How many recent monthly MSRC docs to ingest"),
current_user: User = Depends(RequireEditor), current_user: User = Depends(RequireEditor),
@@ -3818,7 +3851,7 @@ def msrc_refresh_status(current_user: User = Depends(RequireEditor)):
"error": s["error"], "finished_at": s["finished_at"]} "error": s["error"], "finished_at": s["finished_at"]}
@router.post("/recompute-scores") @router.post("/recompute-scores", dependencies=[audited("Priority score recompute")])
def recompute_priority_scores( def recompute_priority_scores(
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(RequireEditor), current_user: User = Depends(RequireEditor),
@@ -3841,7 +3874,7 @@ def recompute_priority_scores(
return {"updated": updated} return {"updated": updated}
@router.post("/canonicalize-cve-metadata") @router.post("/canonicalize-cve-metadata", dependencies=[audited("CVE metadata canonicalisation", event_type=AuditEventType.VULNERABILITY_UPDATED)])
def canonicalize_cve_metadata( def canonicalize_cve_metadata(
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(RequireEditor), current_user: User = Depends(RequireEditor),
+3 -3
View File
@@ -18,7 +18,7 @@ import time
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.auth.dependencies import RequireAdmin, RequireEditor from app.auth.dependencies import RequireAdmin, RequireEditor, audited
from app.services.sync_run_service import record_sync_run from app.services.sync_run_service import record_sync_run
from app.database import get_db, SessionLocal from app.database import get_db, SessionLocal
from app.models.user import User from app.models.user import User
@@ -36,7 +36,7 @@ _XS_SYNC: dict = {"running": False, "result": None, "error": None,
"started_at": None, "finished_at": None} "started_at": None, "finished_at": None}
@router.post("/test") @router.post("/test", dependencies=[audited("XenServer connection test")])
def test_xenserver( def test_xenserver(
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(RequireAdmin), current_user: User = Depends(RequireAdmin),
@@ -73,7 +73,7 @@ def _run_xenserver_sync_threaded() -> None:
_XS_SYNC["finished_at"] = time.time() _XS_SYNC["finished_at"] = time.time()
@router.post("/sync", status_code=202) @router.post("/sync", status_code=202, dependencies=[audited("XenServer sync")])
def sync_xenserver( def sync_xenserver(
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(RequireEditor), current_user: User = Depends(RequireEditor),
+73
View File
@@ -0,0 +1,73 @@
"""Audit rows for what a user did: one action, or one change naming every field
old → new. Username, client IP and user agent are stamped on insert
(audit_log._stamp_actor), so callers pass only the user id.
"""
import json
from datetime import datetime
from enum import Enum
from typing import Any, Optional
from sqlalchemy.orm import Session
from app.models.audit_log import AuditEventType, AuditLog
def _plain(v: Any) -> Any:
return v.value if isinstance(v, Enum) else v
def _shown(v: Any) -> str:
return "–" if v is None else str(v)
def record_change(
db: Session,
user_id: Optional[int],
event_type: AuditEventType,
resource_type: str,
resource_id: Any,
subject: str,
before: dict,
after: dict,
) -> bool:
"""One row naming every field that differs between `before` and `after`
(a create passes before={}, a delete after={}; empty fields of the one
side are left out). Nothing differs, no row."""
before = {k: _plain(v) for k, v in before.items() if after or v not in (None, "", [])}
after = {k: _plain(v) for k, v in after.items() if before or v not in (None, "", [])}
changed = [k for k in dict.fromkeys([*before, *after]) if before.get(k) != after.get(k)]
if not changed:
return False
shown = "; ".join(f"{k}: {_shown(before.get(k))} → {_shown(after.get(k))}" for k in changed)
db.add(AuditLog(
user_id=user_id,
event_type=event_type,
event_description=f"{subject} — {shown}"[:500],
resource_type=resource_type,
resource_id=None if resource_id is None else str(resource_id)[:50],
old_value=json.dumps({k: before[k] for k in changed if k in before}, default=str),
new_value=json.dumps({k: after[k] for k in changed if k in after}, default=str),
timestamp=datetime.now(),
))
return True
def record_action(
db: Session,
user_id: Optional[int],
event_type: AuditEventType,
description: str,
resource_type: Optional[str] = None,
resource_id: Any = None,
details: Optional[dict] = None,
) -> None:
"""One row for an action that changes no single record (sync, scan, test)."""
db.add(AuditLog(
user_id=user_id,
event_type=event_type,
event_description=description[:500],
resource_type=resource_type,
resource_id=None if resource_id is None else str(resource_id)[:50],
new_value=json.dumps(details, default=str) if details else None,
timestamp=datetime.now(),
))
+264
View File
@@ -0,0 +1,264 @@
"""Every user action leaves an audit row, with old → new where it changes
something — run: venv/bin/python -m pytest tests/test_audit_user_actions.py
Follow-up to tests/test_audit_who_what_where.py: groups, policies, scan
schedules, finding assignment/suppression and every sync/scan/test button
wrote no row at all, so "who did this" had no answer for them.
"""
import json
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
os.environ.setdefault("JWT_SECRET_KEY", "test-key-test-key-test-key-test-key")
from datetime import datetime # noqa: E402
import pytest # noqa: E402
from fastapi import Depends, FastAPI, HTTPException # noqa: E402
from fastapi.testclient import TestClient # noqa: E402
from sqlalchemy import create_engine # noqa: E402
from sqlalchemy.orm import sessionmaker # noqa: E402
from sqlalchemy.pool import StaticPool # noqa: E402
import app.models # noqa: F401,E402 (registers every mapper)
from app.database import get_db # noqa: E402
from app.auth.dependencies import RequireEditor, audited, capture_request_origin, get_current_user # noqa: E402
from app.models.asset import Asset # noqa: E402
from app.models.audit_log import AuditEventType, AuditLog # noqa: E402
from app.models.base import Base # noqa: E402
from app.models.group import Group # noqa: E402
from app.models.user import User, UserRole # noqa: E402
from app.models.vulnerability import Vulnerability, VulnerabilitySeverity # noqa: E402
def _db():
engine = create_engine("sqlite://", poolclass=StaticPool,
connect_args={"check_same_thread": False})
Base.metadata.create_all(engine)
db = sessionmaker(bind=engine)()
admin = User(username="alice", email="alice@example.test", password_hash="x",
role=UserRole.ADMIN)
db.add(admin)
db.commit()
return db, admin
def _rows(db):
return db.query(AuditLog).order_by(AuditLog.id).all()
# --- action buttons -------------------------------------------------------
def _button_app(db, user, fail=False):
api = FastAPI(dependencies=[Depends(capture_request_origin)])
@api.post("/sync/{asset_id}", dependencies=[audited("Wazuh sync", resource_type="asset")])
def sync(asset_id: int, current_user: User = Depends(RequireEditor)):
if fail:
raise HTTPException(502, "Wazuh unreachable")
return {"ok": True}
api.dependency_overrides[get_db] = lambda: db
api.dependency_overrides[get_current_user] = lambda: user
return TestClient(api)
def test_button_writes_one_row_naming_who_and_what():
db, admin = _db()
assert _button_app(db, admin).post("/sync/7").status_code == 200
(row,) = _rows(db)
assert (row.event_type, row.event_description, row.resource_type, row.resource_id, row.username,
row.ip_address) == \
(AuditEventType.SCAN_TRIGGERED, "Wazuh sync", "asset", "7", "alice", "testclient")
def test_failed_or_forbidden_button_writes_no_row():
db, admin = _db()
assert _button_app(db, admin, fail=True).post("/sync/7").status_code == 502
reader = User(username="carol", email="carol@example.test", password_hash="x",
role=UserRole.READONLY)
db.add(reader)
db.commit()
assert _button_app(db, reader).post("/sync/7").status_code == 403
assert _rows(db) == []
# --- records that change ----------------------------------------------------
def test_group_create_update_delete():
from app.routers.groups import GroupCreate, GroupUpdate, create_group, delete_group, update_group
db, admin = _db()
g = create_group(GroupCreate(name="Ops", user_ids=[admin.id]), db=db, current_user=admin)
update_group(g["id"], GroupUpdate(name="SecOps", user_ids=[]), db=db, current_user=admin)
update_group(g["id"], GroupUpdate(name="SecOps"), db=db, current_user=admin) # no change, no row
delete_group(g["id"], db=db, current_user=admin)
assert [r.event_description for r in _rows(db)] == [
"Group created: Ops — name: – → Ops; members: – → ['alice']",
"Group updated: SecOps — name: Ops → SecOps; members: ['alice'] → []",
"Group deleted: SecOps — name: SecOps → –",
]
assert {r.resource_type for r in _rows(db)} == {"group"}
def test_default_group_change():
from app.routers.groups import set_default_group
db, admin = _db()
db.add(Group(name="Ops"))
db.commit()
set_default_group(group_id=1, db=db, current_user=admin)
set_default_group(group_id=None, db=db, current_user=admin)
assert [r.event_description for r in _rows(db)] == [
"Default group changed — default_group_id: – → 1",
"Default group changed — default_group_id: 1 → –",
]
def test_policy_update_names_each_field():
from app.routers.policies import PolicyCreate, PolicyUpdate, create_policy, update_policy
db, admin = _db()
p = create_policy(PolicyCreate(name="Servers", category="infra"), db=db, current_user=admin)
update_policy(p.id, PolicyUpdate(critical_sla_days=1, name="Servers"), db=db, current_user=admin)
created, updated = _rows(db)
assert created.event_description.startswith("Policy created: Servers — name: – → Servers")
assert "status: – → active" in created.event_description
assert updated.event_description == "Policy updated: Servers — critical_sla_days: 2 → 1"
assert json.loads(updated.old_value) == {"critical_sla_days": 2}
def test_schedule_toggle_and_delete():
from app.models.scan_schedule import ScheduleInterval
from app.routers.scans import ScheduleCreateRequest, create_schedule, delete_schedule, toggle_schedule
db, admin = _db()
s = create_schedule(ScheduleCreateRequest(name="nightly", interval=ScheduleInterval.DAILY),
db=db, current_user=admin)
toggle_schedule(s.id, db=db, current_user=admin)
delete_schedule(s.id, db=db, current_user=admin)
descs = [r.event_description for r in _rows(db)]
assert descs[0].startswith("Scan schedule created: nightly — name: – → nightly; interval: – → daily")
assert descs[1] == "Scan schedule updated: nightly — enabled: True → False"
assert descs[2].startswith("Scan schedule deleted: nightly — name: nightly → –")
def _vuln(db):
asset = Asset(hostname="srv01")
db.add(asset)
db.flush()
v = Vulnerability(asset_id=asset.id, cve_id="CVE-2026-0001", severity=VulnerabilitySeverity.high,
detected_at=datetime.now())
db.add(v)
db.commit()
return v
def test_finding_assign_and_suppression():
from app.routers.vulnerabilities import (AssignRequest, assign_vulnerability,
toggle_notification_suppression)
db, admin = _db()
v = _vuln(db)
assign_vulnerability(v.id, AssignRequest(user_id=admin.id), db=db, current_user=admin)
toggle_notification_suppression(v.id, db=db, current_user=admin)
assert [r.event_description for r in _rows(db)] == [
"Vulnerability CVE-2026-0001 on srv01 updated — assigned_user: – → alice",
"Vulnerability CVE-2026-0001 on srv01 updated — notification_suppressed: False → True",
]
assert {(r.resource_type, r.resource_id) for r in _rows(db)} == {("vulnerability", str(v.id))}
def test_defer_and_false_positive_keep_the_reason():
from app.routers.vulnerabilities import (DeferRequest, FalsePositiveRequest, defer_vulnerability,
mark_false_positive)
db, admin = _db()
v = _vuln(db)
defer_vulnerability(v.id, DeferRequest(deferred_until=datetime(2026, 12, 31), reason="patch window"),
db=db, current_user=admin)
mark_false_positive(v.id, FalsePositiveRequest(reason="backported fix"), db=db, current_user=admin)
defer_fields, defer_status, fp_fields, fp_status = _rows(db)
assert defer_fields.event_description == (
"Vulnerability CVE-2026-0001 on srv01 updated — deferred_until: – → 2026-12-31 00:00:00; "
"defer_reason: – → patch window; notification_suppressed: False → True")
assert json.loads(defer_status.new_value)["reason"] == "patch window"
assert fp_fields.event_description.endswith("defer_reason: patch window → backported fix")
assert json.loads(fp_status.new_value)["reason"] == "backported fix"
def test_undefer_records_what_it_clears():
from app.routers.vulnerabilities import DeferRequest, defer_vulnerability, undefer_vulnerability
db, admin = _db()
v = _vuln(db)
defer_vulnerability(v.id, DeferRequest(deferred_until=datetime(2026, 12, 31), reason="patch window"),
db=db, current_user=admin)
undefer_vulnerability(v.id, db=db, current_user=admin)
fields, status = _rows(db)[2:]
assert "deferred_until: 2026-12-31 00:00:00 → –; defer_reason: patch window → –" in fields.event_description
assert status.event_description.startswith("Vulnerability CVE-2026-0001 status changed: deferred → open")
def test_asset_assign_diff():
from app.routers.assets import AssetAssignRequest, assign_asset
db, admin = _db()
asset = Asset(hostname="srv01")
db.add_all([asset, Group(name="Ops")])
db.commit()
assign_asset(asset.id, AssetAssignRequest(user_id=admin.id, group_id=1), db=db, current_user=admin)
(row,) = _rows(db)
assert row.event_description == "Asset updated: srv01 — assigned_user: – → alice; groups: [] → ['Ops']"
def test_user_email_change():
from starlette.requests import Request
from app.routers.auth import UserUpdateRequest, update_user
db, admin = _db()
req = Request({"type": "http", "headers": [], "client": ("10.0.0.9", 1)})
update_user(req, admin.id, UserUpdateRequest(email="a2@example.com"), current_user=admin, db=db)
(row,) = _rows(db)
assert row.event_description == "User updated: alice — email: alice@example.test → a2@example.com"
_ROUTERS = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "app", "routers")
# Session upkeep, not an action.
_NOT_ACTIONS = {"refresh_token"}
@pytest.mark.parametrize("name", sorted(f for f in os.listdir(_ROUTERS) if f.endswith(".py")))
def test_every_write_route_is_audited(name):
"""Inventory guard: a new POST/PUT/PATCH/DELETE route has to audit itself
(AuditLog, an audit helper, or the audited() dependency)."""
import ast
import re
src = open(os.path.join(_ROUTERS, name)).read()
missing = []
for node in ast.parse(src).body:
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
continue
for d in node.decorator_list:
if (isinstance(d, ast.Call) and isinstance(d.func, ast.Attribute)
and d.func.attr in ("post", "put", "patch", "delete")):
text = ast.unparse(node) # decorators included, comments not
if not re.search(r"AuditLog\(|log_audit_event\(|\b_audit(_asset_update|_schedule|_vuln_update)?\(|log_vulnerability_change\(|"
r"record_change\(|record_action\(|audited\(|apply_status\(", text):
missing.append(node.name)
assert set(missing) - _NOT_ACTIONS == set()
def test_button_keeps_named_body_fields_never_the_rest():
db, admin = _db()
api = FastAPI()
@api.post("/test-mail", dependencies=[audited("Test email", body_fields=("to_email", "smtp_config.host"))])
def test_mail(body: dict, current_user: User = Depends(RequireEditor)):
return {"ok": True}
api.dependency_overrides[get_db] = lambda: db
api.dependency_overrides[get_current_user] = lambda: admin
TestClient(api).post("/test-mail?dry_run=true", json={
"to_email": "ops@example.test", "smtp_config": {"host": "mail.example.test", "password": "pw"}})
(row,) = _rows(db)
assert json.loads(row.new_value) == {"dry_run": "true", "to_email": "ops@example.test",
"smtp_config.host": "mail.example.test"}