Two faults, either one enough on its own. The inventory filter dropped every
package whose vendor contains "citrix", which was meant for the "Delivered by
Citrix" published-app stubs but also removed every real install ("Citrix
Workspace 2507", vendor "Citrix Systems, Inc."). And behind it, Citrix states
its fixes as release names, not builds: CVE-2026-78546/-78547 give
"2603.11 Current Release (CR)", "2507.1 LTSR CU3" and "LTSR 2607". Read as
digits, "LTSR 2607" is (2607,), so every 25.x/26.x build would have matched,
the patched CU3, CR 2603.11 and LTSR 2607 hosts included.
The inventory states only the build (25.7.1000.1025), with no CR/LTSR and no
CU. Both sides now meet on the build via a catalog read from Citrix's download
pages (title + "Version:"), kept only when the build agrees with its title. Five
CR pages print a sidebar build (22.12.0.48) first, and without that check
they would have entered the catalog as 2302..2307.1. The catalog is seeded with
44 pages and refreshed weekly by the 01:30 index job.
CR and LTSR never share a year.month, so the build's first two fields name the
branch. The decision reuses vmware_release_service.is_affected: CR is one
line, each LTSR is its own line with the CU as update line (CVE-2025-4879 fixes
2402 in CU2 HF1 and CU3 HF1). A bound that resolves to no single catalog build
leaves the CVE undecided and held open, never guessed. Windows hosts only; the
Mac app shares the name and its 25.07.x numbering.
1522 lines
64 KiB
Python
1522 lines
64 KiB
Python
"""
|
|
Background Scheduler for automated scan jobs
|
|
|
|
Uses APScheduler for periodic scan execution.
|
|
"""
|
|
import logging
|
|
import json
|
|
import os
|
|
from datetime import datetime, timedelta
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
try:
|
|
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
|
from apscheduler.triggers.interval import IntervalTrigger
|
|
from apscheduler.triggers.cron import CronTrigger
|
|
HAS_APSCHEDULER = True
|
|
except ImportError:
|
|
HAS_APSCHEDULER = False
|
|
logger.warning("apscheduler not installed - automated scans disabled. Install with: pip install apscheduler")
|
|
|
|
from app.database import SessionLocal
|
|
from app.models.scan_schedule import ScanSchedule, ScheduleInterval
|
|
from app.models.asset import Asset
|
|
from app.models.scan import Scan, ScanType, ScanStatus
|
|
from app.models.setting import Setting
|
|
from app.models.vulnerability import Vulnerability, VulnerabilityStatus, VulnerabilitySeverity
|
|
from app.models.policy import Policy
|
|
from app.models.notification_log import NotificationLog, NotificationType, NotificationStatus
|
|
from app.models.user import User
|
|
from app.integrations.wazuh_client import WazuhClient
|
|
from app.services.sync_run_service import (
|
|
record_sync_run, mark_interrupted_runs, fail_if_source_broken,
|
|
probe_wazuh_api, report_source_failure, source_failure,
|
|
)
|
|
|
|
scheduler = AsyncIOScheduler() if HAS_APSCHEDULER else None
|
|
|
|
INTERVAL_MAP = {
|
|
ScheduleInterval.EVERY_HOUR: {"hours": 1},
|
|
ScheduleInterval.EVERY_6_HOURS: {"hours": 6},
|
|
ScheduleInterval.EVERY_12_HOURS: {"hours": 12},
|
|
ScheduleInterval.DAILY: {"days": 1},
|
|
ScheduleInterval.WEEKLY: {"weeks": 1},
|
|
}
|
|
|
|
|
|
def execute_scheduled_scan(schedule_id: int):
|
|
"""Executes a scheduled scan. Routes by ScanSchedule.scanner_type
|
|
to either the Wazuh agent loop or the Nessus sync service."""
|
|
db = SessionLocal()
|
|
try:
|
|
schedule = db.query(ScanSchedule).filter(ScanSchedule.id == schedule_id).first()
|
|
if not schedule or not schedule.enabled:
|
|
return
|
|
|
|
scanner = (getattr(schedule, "scanner_type", None) or "wazuh").lower()
|
|
logger.info(
|
|
f"Scheduled scan '{schedule.name}' (ID: {schedule_id}, scanner={scanner}) started"
|
|
)
|
|
|
|
# ---- Nessus branch ----
|
|
if scanner == "nessus":
|
|
try:
|
|
from app.services.nessus_sync import run_nessus_sync
|
|
with record_sync_run("nessus", "scheduled") as run:
|
|
stats = run_nessus_sync(db)
|
|
run.stats.update(stats)
|
|
logger.info(f"Scheduled Nessus sync done: {stats}")
|
|
except Exception as e:
|
|
logger.error(f"Scheduled Nessus sync failed: {e}")
|
|
schedule.last_run = datetime.now()
|
|
# next_run derivation: ask the actual APScheduler trigger
|
|
# when it will fire next. INTERVAL_MAP lookup with default
|
|
# `{"days": 1}` was wrong for CRON schedules — it always
|
|
# wrote a fake +24h next_run even when the cron fires every
|
|
# minute. Trigger.get_next_fire_time gives the authoritative
|
|
# answer for both interval and cron triggers.
|
|
try:
|
|
from apscheduler.schedulers.asyncio import AsyncIOScheduler # type: ignore # noqa: F401
|
|
job = scheduler.get_job(f"scan_schedule_{schedule.id}") if scheduler else None
|
|
next_fire = job.next_run_time if job else None
|
|
except Exception:
|
|
next_fire = None
|
|
if next_fire is not None:
|
|
# strip tz so it matches the rest of the column (naive)
|
|
schedule.next_run = next_fire.replace(tzinfo=None)
|
|
else:
|
|
interval_delta = INTERVAL_MAP.get(schedule.interval, {"days": 1})
|
|
schedule.next_run = datetime.now() + timedelta(**interval_delta)
|
|
db.commit()
|
|
return
|
|
|
|
with record_sync_run("wazuh", "scheduled") as run:
|
|
# ---- Wazuh branch (default, existing behaviour) ----
|
|
# Wazuh Config laden (transparently decrypted)
|
|
from app.auth.setting_crypto import read_setting_value
|
|
raw_wazuh = read_setting_value(db, "wazuh_config")
|
|
if not raw_wazuh:
|
|
logger.error("Scheduled scan aborted: Wazuh configuration missing")
|
|
run.fail("Wazuh configuration missing")
|
|
return
|
|
|
|
config = json.loads(raw_wazuh)
|
|
|
|
# Asset sync first — the same call the manual "Sync Data (Wazuh)"
|
|
# button makes before it syncs vulnerabilities. Without it this job
|
|
# only ever sees the agent ids already in the DB: a host that
|
|
# re-registers with a NEW agent id keeps its stale id forever, so
|
|
# every CVE and EOL lookup asks Wazuh about an agent that no longer
|
|
# exists, and the asset never flips back from INACTIVE to ACTIVE
|
|
# until someone clicks sync by hand.
|
|
# ponytail: the endpoint function is called directly (current_user is
|
|
# unused in its body) rather than extracted into a service — same
|
|
# pattern as the sync_agent_vulnerabilities import below.
|
|
#
|
|
# The asset sync logs in to the manager API (55000); the agent
|
|
# loop below reads the indexer (9200). One can be dead while the
|
|
# other answers — the API refused every login for a day while 64
|
|
# agents "synced" fine each hour, and the run stayed COMPLETED
|
|
# with the error folded into a list. A phase that could not log
|
|
# in is a failed run, whatever the other phase did.
|
|
phase_failures = []
|
|
try:
|
|
from app.routers.assets import sync_wazuh_assets
|
|
logger.info(f"Scheduled Wazuh asset sync: {sync_wazuh_assets(db=db, current_user=None)}")
|
|
except Exception as e:
|
|
detail = getattr(e, "detail", None) or str(e)
|
|
logger.error(f"Scheduled Wazuh asset sync failed: {detail}")
|
|
run.stats.setdefault("errors", []).append(f"asset sync: {detail}")
|
|
phase_failures.append(f"asset sync failed: {detail}")
|
|
|
|
assets = db.query(Asset).filter(Asset.wazuh_agent_id.isnot(None)).all()
|
|
if not assets:
|
|
logger.info("No Wazuh assets found for scheduled scan")
|
|
schedule.last_run = datetime.now()
|
|
db.commit()
|
|
fail_if_source_broken(run, 0, [], phase_failures)
|
|
return
|
|
|
|
triggered = 0
|
|
errors = []
|
|
|
|
try:
|
|
with WazuhClient(
|
|
base_url=config.get("api_url"),
|
|
username=config.get("username"),
|
|
password=config.get("password"),
|
|
indexer_url=config.get("indexer_url"),
|
|
indexer_username=config.get("indexer_username"),
|
|
indexer_password=config.get("indexer_password"),
|
|
verify_ssl=bool(config.get("verify_ssl", True))
|
|
) as client:
|
|
# Sync vulnerabilities for each agent
|
|
from app.routers.vulnerabilities import (
|
|
sync_agent_vulnerabilities, reconcile_empty_agents,
|
|
)
|
|
# Shared across the run so an agent that returns nothing can be
|
|
# judged against the run as a whole (see reconcile_empty_agents).
|
|
run_stats: dict = {}
|
|
for asset in assets:
|
|
try:
|
|
scan = Scan(
|
|
asset_id=asset.id,
|
|
scan_type=ScanType.WAZUH, # source-labelled (was FULL)
|
|
status=ScanStatus.RUNNING,
|
|
started_at=datetime.now()
|
|
)
|
|
db.add(scan)
|
|
|
|
sync_agent_vulnerabilities(db, client, asset.wazuh_agent_id,
|
|
asset, run_stats=run_stats)
|
|
|
|
scan.status = ScanStatus.COMPLETED
|
|
scan.completed_at = datetime.now()
|
|
triggered += 1
|
|
except Exception as e:
|
|
scan.status = ScanStatus.FAILED
|
|
scan.error_message = str(e)
|
|
errors.append(f"{asset.hostname}: {e}")
|
|
logger.error(f"Scheduled scan error for {asset.hostname}: {e}")
|
|
|
|
# Now that the run is done, agents that returned nothing can be
|
|
# judged: real emptiness if the API answered for anyone else.
|
|
try:
|
|
reconcile_empty_agents(db, run_stats)
|
|
except Exception as e:
|
|
logger.error(f"Wazuh empty-agent reconcile failed: {e}")
|
|
# Every agent answered without a single CVE: the indexer
|
|
# is down or empty, whatever the manager API said.
|
|
if run_stats.get("outage"):
|
|
phase_failures.append(f"vulnerability sync: {run_stats['outage']}")
|
|
|
|
except Exception as e:
|
|
logger.error(f"Wazuh connection error during scheduled scan: {e}")
|
|
run.fail(f"Wazuh connection failed: {e}")
|
|
|
|
schedule.last_run = datetime.now()
|
|
interval_delta = INTERVAL_MAP.get(schedule.interval, {"days": 1})
|
|
schedule.next_run = datetime.now() + timedelta(**interval_delta)
|
|
db.commit()
|
|
|
|
logger.info(f"Scheduled scan '{schedule.name}' completed: {triggered} assets scanned, {len(errors)} errors")
|
|
run.stats.update({"phase": "assets+vulnerabilities", "agents_synced": triggered,
|
|
"errors": run.stats.get("errors", []) + errors})
|
|
if run.error is None: # a connection failure above already named the cause
|
|
fail_if_source_broken(run, triggered, errors, phase_failures)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Scheduled scan error: {e}")
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def check_sla_breaches():
|
|
"""
|
|
Hourly SLA-breach check. Honors notification_mode setting:
|
|
- 'digest' (default): one summary mail per recipient
|
|
- 'single': one mail per (vuln, recipient) as before
|
|
Per-vuln 24h throttle still applies in both modes.
|
|
|
|
Master toggle: setting `sla_breach_enabled` (default true). When
|
|
set to "false" the entire job is a no-op — covers the case where
|
|
the operator disabled all Security Policies but doesn't want any
|
|
SLA mails firing for assets that fall through to default_sla.
|
|
"""
|
|
db = SessionLocal()
|
|
try:
|
|
from app.services.email_service import (
|
|
get_smtp_config,
|
|
get_notification_mode,
|
|
get_email_template,
|
|
render_template,
|
|
send_email,
|
|
send_sla_breach_digest,
|
|
)
|
|
from app.models.setting import Setting
|
|
|
|
# Master toggle — string "false" disables the whole check.
|
|
sla_enabled_row = db.query(Setting).filter(
|
|
Setting.key == "sla_breach_enabled"
|
|
).first()
|
|
if sla_enabled_row and (sla_enabled_row.value or "").strip().lower() in ("false", "0", "no", "off"):
|
|
logger.info("SLA breach check skipped — setting sla_breach_enabled is off")
|
|
return
|
|
|
|
if not get_smtp_config(db):
|
|
return
|
|
|
|
vulns = db.query(Vulnerability).join(Asset).filter(
|
|
Vulnerability.status == VulnerabilityStatus.open,
|
|
Vulnerability.notification_suppressed == False
|
|
).all()
|
|
if not vulns:
|
|
return
|
|
|
|
now = datetime.now()
|
|
mode = get_notification_mode(db)
|
|
dashboard_url = os.getenv("DASHBOARD_URL", "http://localhost:3000").rstrip("/") + "/vulnerabilities"
|
|
|
|
default_sla = {
|
|
VulnerabilitySeverity.critical: 2,
|
|
VulnerabilitySeverity.high: 7,
|
|
VulnerabilitySeverity.medium: 30,
|
|
VulnerabilitySeverity.low: 90,
|
|
VulnerabilitySeverity.none: 365,
|
|
}
|
|
|
|
# Bucket per recipient email so the digest stays one-mail-per-person.
|
|
# buckets[email] = {user_id, username, items: [{vuln, hours_overdue, asset, assigned_name}, ...]}
|
|
buckets: dict[str, dict] = {}
|
|
|
|
def _resolve_recipients(vuln):
|
|
recs = []
|
|
seen = set()
|
|
|
|
def _push(u):
|
|
if u and u.email and u.email not in seen:
|
|
recs.append((u.id, u.email, u.username))
|
|
seen.add(u.email)
|
|
|
|
if vuln.assigned_user_id:
|
|
_push(db.query(User).filter(User.id == vuln.assigned_user_id).first())
|
|
elif vuln.assigned_group_id:
|
|
from app.models.group import Group
|
|
g = db.query(Group).filter(Group.id == vuln.assigned_group_id).first()
|
|
if g:
|
|
for u in g.users:
|
|
_push(u)
|
|
elif vuln.asset and vuln.asset.assigned_user_id:
|
|
_push(db.query(User).filter(User.id == vuln.asset.assigned_user_id).first())
|
|
elif vuln.asset and vuln.asset.groups:
|
|
# Asset uses M2M groups (no single assigned_group_id column)
|
|
for g in vuln.asset.groups:
|
|
for u in g.users:
|
|
_push(u)
|
|
return recs
|
|
|
|
# Phase 1: classify breaches + de-duplicate / throttle
|
|
for vuln in vulns:
|
|
asset = vuln.asset
|
|
|
|
if asset.policy_id:
|
|
policy = db.query(Policy).filter(Policy.id == asset.policy_id).first()
|
|
if policy:
|
|
# Disabled policy = operator opted out of SLA tracking
|
|
# for these assets. Skip breach evaluation entirely so
|
|
# no notification is generated. Field report: mails
|
|
# kept arriving after disabling all policies.
|
|
from app.models.policy import PolicyStatus
|
|
if policy.status == PolicyStatus.DISABLED:
|
|
continue
|
|
sla_map = {
|
|
VulnerabilitySeverity.critical: policy.critical_sla_days,
|
|
VulnerabilitySeverity.high: policy.high_sla_days,
|
|
VulnerabilitySeverity.medium: policy.medium_sla_days,
|
|
VulnerabilitySeverity.low: policy.low_sla_days,
|
|
VulnerabilitySeverity.none: 365,
|
|
}
|
|
sla_days = sla_map.get(vuln.severity, 30)
|
|
else:
|
|
sla_days = default_sla.get(vuln.severity, 30)
|
|
else:
|
|
sla_days = default_sla.get(vuln.severity, 30)
|
|
|
|
deadline = vuln.detected_at + timedelta(days=sla_days)
|
|
if now <= deadline:
|
|
continue
|
|
hours_overdue = int((now - deadline).total_seconds() / 3600)
|
|
|
|
# Per-vuln 24h throttle still applies in both modes — prevents
|
|
# repeat-pinging on the same finding if the hourly scheduler keeps firing.
|
|
recent = db.query(NotificationLog).filter(
|
|
NotificationLog.vulnerability_id == vuln.id,
|
|
NotificationLog.notification_type == NotificationType.SLA_BREACH,
|
|
NotificationLog.sent_at >= now - timedelta(hours=24)
|
|
).first()
|
|
if recent:
|
|
continue
|
|
|
|
recipients = _resolve_recipients(vuln)
|
|
if not recipients:
|
|
continue
|
|
|
|
assigned_name = "Unassigned"
|
|
if vuln.assigned_user:
|
|
assigned_name = vuln.assigned_user.username
|
|
elif vuln.assigned_group_id:
|
|
assigned_name = "Group"
|
|
elif asset.assigned_user:
|
|
assigned_name = asset.assigned_user.username
|
|
elif asset.groups:
|
|
assigned_name = "Group"
|
|
|
|
for r_uid, r_email, r_username in recipients:
|
|
bucket = buckets.setdefault(r_email, {
|
|
"user_id": r_uid,
|
|
"username": r_username,
|
|
"items": [],
|
|
})
|
|
bucket["items"].append({
|
|
"vuln": vuln,
|
|
"asset": asset,
|
|
"hours_overdue": hours_overdue,
|
|
"assigned_name": assigned_name,
|
|
})
|
|
|
|
if not buckets:
|
|
return
|
|
|
|
notifications_sent = 0
|
|
checked_at_str = now.strftime("%Y-%m-%d %H:%M UTC")
|
|
|
|
# Phase 2: dispatch
|
|
for email, bucket in buckets.items():
|
|
items = bucket["items"]
|
|
user_id = bucket["user_id"]
|
|
username = bucket["username"]
|
|
|
|
if mode == "digest":
|
|
digest_items = [
|
|
{
|
|
"cve_id": it["vuln"].cve_id,
|
|
"severity": it["vuln"].severity.value if it["vuln"].severity else "none",
|
|
"asset_hostname": it["asset"].hostname,
|
|
"detected_at": it["vuln"].detected_at.strftime("%Y-%m-%d %H:%M") if it["vuln"].detected_at else "—",
|
|
"hours_overdue": it["hours_overdue"],
|
|
}
|
|
for it in items
|
|
]
|
|
success, err = send_sla_breach_digest(
|
|
db,
|
|
to_email=email,
|
|
recipient_name=username,
|
|
items=digest_items,
|
|
checked_at=checked_at_str,
|
|
dashboard_url=dashboard_url,
|
|
)
|
|
|
|
# One NotificationLog row per vuln so the per-vuln 24h throttle
|
|
# remains effective on the next hourly tick.
|
|
for it in items:
|
|
db.add(NotificationLog(
|
|
vulnerability_id=it["vuln"].id,
|
|
asset_id=it["asset"].id,
|
|
user_id=user_id,
|
|
notification_type=NotificationType.SLA_BREACH,
|
|
sent_at=now,
|
|
subject=f"[TRUEVULN] {len(items)} SLA-breached vulnerabilities require action",
|
|
recipient_email=email,
|
|
status=NotificationStatus.SENT if success else NotificationStatus.FAILED,
|
|
message_body=f"SLA digest: {it['vuln'].cve_id} on {it['asset'].hostname} ({it['hours_overdue']}h overdue)",
|
|
error_message=None if success else err,
|
|
))
|
|
if success:
|
|
notifications_sent += 1
|
|
else:
|
|
logger.warning("SLA digest mail failed for %s: %s", email, err)
|
|
else:
|
|
# legacy single-mail mode — one mail per vuln per recipient
|
|
subject_template, body_template = get_email_template(db, "email_template_sla_breach")
|
|
for it in items:
|
|
vuln = it["vuln"]
|
|
asset = it["asset"]
|
|
variables = {
|
|
"cve_id": vuln.cve_id,
|
|
"severity": vuln.severity.value if vuln.severity else "unknown",
|
|
"severity_upper": vuln.severity.value.upper() if vuln.severity else "UNKNOWN",
|
|
"cvss_score": str(vuln.cvss_score or "N/A"),
|
|
"asset_hostname": asset.hostname,
|
|
"hours_overdue": str(it["hours_overdue"]),
|
|
"assigned_user": it["assigned_name"],
|
|
"recipient_name": username,
|
|
"package_name": vuln.package_name or "N/A",
|
|
"title": vuln.title or "N/A",
|
|
"detected_at": vuln.detected_at.strftime("%Y-%m-%d %H:%M UTC") if vuln.detected_at else "N/A",
|
|
"dashboard_url": dashboard_url,
|
|
}
|
|
subject = render_template(subject_template, variables)
|
|
body = render_template(body_template, variables)
|
|
success, error_msg = send_email(db, email, subject, body)
|
|
db.add(NotificationLog(
|
|
vulnerability_id=vuln.id,
|
|
asset_id=asset.id,
|
|
user_id=user_id,
|
|
notification_type=NotificationType.SLA_BREACH,
|
|
sent_at=now,
|
|
subject=subject,
|
|
recipient_email=email,
|
|
status=NotificationStatus.SENT if success else NotificationStatus.FAILED,
|
|
message_body=body,
|
|
error_message=None if success else error_msg,
|
|
))
|
|
if success:
|
|
notifications_sent += 1
|
|
|
|
db.commit()
|
|
|
|
if notifications_sent > 0:
|
|
logger.info(
|
|
f"SLA breach check ({mode} mode): {notifications_sent} mails sent, "
|
|
f"{len(buckets)} recipients, "
|
|
f"{sum(len(b['items']) for b in buckets.values())} breached findings"
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error(f"SLA breach check error: {e}")
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def refresh_threat_intel_enrichment():
|
|
"""Daily refresh of EPSS scores + CISA KEV catalog for all open vulnerabilities."""
|
|
from app.services.enrichment_service import enrich_all_open_vulnerabilities
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
stats = enrich_all_open_vulnerabilities(db)
|
|
logger.info(
|
|
f"Threat intel refresh done: "
|
|
f"{stats['total']} open vulns, epss_updated={stats['epss_updated']}, "
|
|
f"kev_marked={stats['kev_marked']}, kev_cleared={stats['kev_cleared']}"
|
|
)
|
|
except Exception as e:
|
|
logger.error(f"Threat intel refresh failed: {e}")
|
|
finally:
|
|
db.close()
|
|
# The catalogs just moved — check straight away rather than waiting for the
|
|
# hourly tick. A CVE that became "actively exploited" a second ago and is
|
|
# open on our machines is the whole point of the alert.
|
|
kev_alert_check()
|
|
|
|
|
|
def exploit_intel_nightly():
|
|
"""Refresh public-exploit catalogs (Exploit-DB / PoC-in-GitHub /
|
|
Metasploit). Runs 03:45 UTC — after Vulnrichment (03:00) and the
|
|
audit prune (03:30), before URS recompute (04:00) so the new
|
|
priority_score values feed the URS run.
|
|
|
|
Sources cached on disk 24h — the job is idempotent.
|
|
"""
|
|
from app.services.exploit_intel_service import refresh_all_exploit_intel
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
stats = refresh_all_exploit_intel(db, only_open=True)
|
|
logger.info("exploit-intel nightly done: %s", stats)
|
|
except Exception as e:
|
|
logger.error("exploit-intel nightly failed: %s", e)
|
|
db.rollback()
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def eol_check_nightly():
|
|
"""Run endoflife.date EOL detection for every Wazuh-linked asset.
|
|
|
|
Pseudo-CVE rows (cve_id starts with EOL-) get upserted so the next
|
|
morning's vuln list highlights newly-EOL software. Cheap — only
|
|
products in the mapping table get scanned; unmapped names skipped.
|
|
|
|
Slotted at 03:15 UTC so it runs after SCA (02:00), before
|
|
Vulnrichment (03:30 = after the audit prune) — leaves a gap so it
|
|
doesn't compete for DB connections.
|
|
"""
|
|
from app.integrations.wazuh_client import WazuhClient
|
|
from app.models.asset import Asset
|
|
from app.services import eol_service
|
|
from app.auth.setting_crypto import read_setting_value
|
|
import json as _json
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
raw = read_setting_value(db, "wazuh_config")
|
|
if not raw:
|
|
logger.info("EOL check skipped — wazuh_config not set")
|
|
return
|
|
try:
|
|
cfg = _json.loads(raw)
|
|
except _json.JSONDecodeError:
|
|
logger.warning("EOL check skipped — invalid wazuh_config JSON")
|
|
return
|
|
if not all([cfg.get("api_url"), cfg.get("username"), cfg.get("password")]):
|
|
logger.info("EOL check skipped — wazuh_config incomplete")
|
|
return
|
|
wazuh = WazuhClient(
|
|
base_url=cfg.get("api_url"),
|
|
username=cfg.get("username"),
|
|
password=cfg.get("password"),
|
|
indexer_url=cfg.get("indexer_url"),
|
|
indexer_username=cfg.get("indexer_username"),
|
|
indexer_password=cfg.get("indexer_password"),
|
|
verify_ssl=bool(cfg.get("verify_ssl", True)),
|
|
)
|
|
# A refused login is a Wazuh failure, recorded and mailed as one —
|
|
# not 64 "package fetch failed" warnings and a job that "completed".
|
|
if probe_wazuh_api(wazuh, phase="eol-check", trigger="scheduled"):
|
|
return
|
|
assets = db.query(Asset).filter(Asset.wazuh_agent_id.isnot(None)).all()
|
|
upserts = 0
|
|
closed = 0
|
|
fetch_errors = []
|
|
for asset in assets:
|
|
# Every EOL finding this asset still justifies; what is missing at
|
|
# the end of the pass has left the inventory. Same contract as the
|
|
# GUI-triggered check, so both produce the same result.
|
|
seen_eol_ids: set = set()
|
|
# OS-level EOL first (independent of package fetch).
|
|
try:
|
|
os_status = eol_service.check_os_eol(
|
|
db, asset.operating_system or "", asset.os_version or ""
|
|
)
|
|
if os_status and (os_status.is_eol or os_status.is_eol_soon or os_status.is_eoas):
|
|
os_vid, _ = eol_service.upsert_eol_vulnerability(
|
|
db,
|
|
asset_id=asset.id,
|
|
product_name=(asset.operating_system or "Operating System").strip(),
|
|
installed_version=(asset.os_version or os_status.release_name or "unknown"),
|
|
status=os_status,
|
|
)
|
|
if os_vid:
|
|
seen_eol_ids.add(os_vid)
|
|
upserts += 1
|
|
except Exception as e:
|
|
logger.warning("EOL: OS check failed for %s: %s", asset.hostname, e)
|
|
|
|
try:
|
|
pkgs = wazuh.get_packages(asset.wazuh_agent_id) or []
|
|
except Exception as e:
|
|
logger.warning("EOL: package fetch failed for %s: %s", asset.hostname, e)
|
|
fetch_errors.append(f"{asset.hostname}: {e}")
|
|
continue
|
|
# Same sweep the button runs — one implementation, so the nightly
|
|
# result and the on-demand result cannot drift apart.
|
|
res = eol_service.run_eol_for_packages(
|
|
db, asset, pkgs, reconcile=True, seen_ids=seen_eol_ids)
|
|
upserts += res["findings"]
|
|
closed += res["closed"]
|
|
db.commit()
|
|
logger.info("EOL nightly: %d assets scanned, %d EOL upserts, %d closed "
|
|
"(no longer installed), %d fetch errors",
|
|
len(assets), upserts, closed, len(fetch_errors))
|
|
# The API dying mid-run looks like per-asset noise; it is not.
|
|
hit = source_failure(fetch_errors)
|
|
if hit:
|
|
report_source_failure("wazuh", "scheduled", "eol-check packages", hit)
|
|
except Exception as e:
|
|
logger.error("EOL nightly failed: %s", e)
|
|
db.rollback()
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def intune_sync_nightly():
|
|
"""Sync Microsoft Intune managed devices → assets + OS-EOL (Graph API).
|
|
|
|
Skipped when intune_config is not set. Slotted at 02:10 UTC, before the
|
|
other inventory-derived jobs."""
|
|
from app.services.intune_service import load_intune_config, run_intune_sync
|
|
db = SessionLocal()
|
|
try:
|
|
if not load_intune_config(db):
|
|
logger.info("Intune sync skipped — intune_config not set")
|
|
return
|
|
# This sync runs M365-Apps detection for the Intune-only devices (the
|
|
# 03:10 job only covers Wazuh-linked ones) off the same 24h page cache,
|
|
# and it asks an hour earlier — so without its own refresh it would read
|
|
# what the 03:10 job stored the night before, every night. Same rule as
|
|
# there: pull the page, keep the cache when the pull fails.
|
|
try:
|
|
from app.services import m365_service
|
|
m365_service.fetch_security_data(db, force_refresh=True)
|
|
except Exception as e:
|
|
logger.warning("M365 page refresh failed (non-fatal, cache kept): %s", e)
|
|
with record_sync_run("intune", "scheduled") as run:
|
|
stats = run_intune_sync(db)
|
|
run.stats.update(stats)
|
|
logger.info("Intune nightly: %s", {k: v for k, v in stats.items() if k != "errors"})
|
|
except Exception as e:
|
|
logger.error("Intune nightly failed: %s", e)
|
|
db.rollback()
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def vcenter_sync_nightly():
|
|
"""Sync VMware vCenter + its ESXi hosts → assets, EOL and vSphere CVEs.
|
|
|
|
Skipped when vcenter_config is not set. Slotted at 02:20 UTC, right after
|
|
the Intune sync and before the app-CVE scan at 03:20 — the inventory has to
|
|
exist before anything scans it.
|
|
|
|
`refresh_catalog=True` re-reads Broadcom KB 326316 first: a vCenter CVE
|
|
names its fix as a release ("8.0 U3k") and only that table turns it into a
|
|
comparable build. A stale catalog silently drops the newest fixes.
|
|
"""
|
|
from app.services.vcenter_service import load_vcenter_config, run_vcenter_sync
|
|
db = SessionLocal()
|
|
try:
|
|
if not load_vcenter_config(db):
|
|
logger.info("vCenter sync skipped — vcenter_config not set")
|
|
return
|
|
with record_sync_run("vcenter", "scheduled") as run:
|
|
stats = run_vcenter_sync(db, refresh_catalog=True)
|
|
run.stats.update(stats)
|
|
logger.info("vCenter nightly: %s", {k: v for k, v in stats.items() if k != "errors"})
|
|
except Exception as e:
|
|
logger.error("vCenter nightly failed: %s", e)
|
|
db.rollback()
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def igel_sync_nightly():
|
|
"""Sync IGEL UMS + its endpoint devices → assets and IGEL OS CVEs.
|
|
|
|
Skipped when igel_config is not set. Slotted at 02:30 UTC, right after the
|
|
vCenter sync and before the app-CVE scan at 03:20 — the inventory has to
|
|
exist before anything scans it.
|
|
"""
|
|
from app.services.igel_service import load_igel_config, run_igel_sync
|
|
db = SessionLocal()
|
|
try:
|
|
if not load_igel_config(db):
|
|
logger.info("IGEL sync skipped — igel_config not set")
|
|
return
|
|
with record_sync_run("igel", "scheduled") as run:
|
|
stats = run_igel_sync(db)
|
|
run.stats.update(stats)
|
|
logger.info("IGEL nightly: %s", {k: v for k, v in stats.items() if k != "errors"})
|
|
except Exception as e:
|
|
logger.error("IGEL nightly failed: %s", e)
|
|
db.rollback()
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def netdisco_sync_nightly():
|
|
"""Sync Netdisco's device inventory → assets and firmware CVEs.
|
|
|
|
Skipped when netdisco_config is not set. Slotted at 02:40 UTC, right after
|
|
the IGEL sync and before the app-CVE scan at 03:20 — the inventory has to
|
|
exist before anything scans it, and the cvelistV5 index it reads was
|
|
rebuilt at 01:30 (vuln_index_refresh_nightly).
|
|
"""
|
|
from app.services.netdisco_service import load_netdisco_config, run_netdisco_sync
|
|
db = SessionLocal()
|
|
try:
|
|
if not load_netdisco_config(db):
|
|
logger.info("Netdisco sync skipped — netdisco_config not set")
|
|
return
|
|
with record_sync_run("netdisco", "scheduled") as run:
|
|
stats = run_netdisco_sync(db)
|
|
run.stats.update(stats)
|
|
logger.info("Netdisco nightly: %s", {k: v for k, v in stats.items() if k != "errors"})
|
|
except Exception as e:
|
|
logger.error("Netdisco nightly failed: %s", e)
|
|
db.rollback()
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def m365_check_nightly():
|
|
"""Detect Microsoft 365 Apps CVEs (Plan P) for every Wazuh-linked asset.
|
|
|
|
M365 Apps security fixes never reach NVD and are invisible to Wazuh's
|
|
vulnerability detector. This parses the MS365 Apps security-updates
|
|
page (cached 24h), compares the installed build per channel, and
|
|
upserts real-CVE rows for the months each host is behind on.
|
|
|
|
Slotted at 03:20 UTC — right after the EOL nightly (03:15), before the
|
|
audit prune (03:30).
|
|
"""
|
|
from app.integrations.wazuh_client import WazuhClient
|
|
from app.services import m365_service
|
|
from app.auth.setting_crypto import read_setting_value
|
|
import json as _json
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
raw = read_setting_value(db, "wazuh_config")
|
|
if not raw:
|
|
logger.info("M365 check skipped — wazuh_config not set")
|
|
return
|
|
try:
|
|
cfg = _json.loads(raw)
|
|
except _json.JSONDecodeError:
|
|
logger.warning("M365 check skipped — invalid wazuh_config JSON")
|
|
return
|
|
if not all([cfg.get("api_url"), cfg.get("username"), cfg.get("password")]):
|
|
logger.info("M365 check skipped — wazuh_config incomplete")
|
|
return
|
|
wazuh = WazuhClient(
|
|
base_url=cfg.get("api_url"),
|
|
username=cfg.get("username"),
|
|
password=cfg.get("password"),
|
|
indexer_url=cfg.get("indexer_url"),
|
|
indexer_username=cfg.get("indexer_username"),
|
|
indexer_password=cfg.get("indexer_password"),
|
|
verify_ssl=bool(cfg.get("verify_ssl", True)),
|
|
)
|
|
# Re-parse the security page first. It caches 24h and TWO callers share
|
|
# that cache — this job (03:10) and the Intune sync (02:10) — so the
|
|
# freshness of what this comparison decides from depended on which of
|
|
# them happened to ask on the far side of the TTL. That is the MFSA bug
|
|
# (978d4f3) with a second consumer instead of a later one. Patch Tuesday
|
|
# builds are the whole point of this job, so it pulls its own page.
|
|
# Failure keeps the cached parse (fetch_security_data raises before it
|
|
# stores), and run_m365_check below then reads that.
|
|
try:
|
|
m365_service.fetch_security_data(db, force_refresh=True)
|
|
except Exception as e:
|
|
logger.warning("M365 page refresh failed (non-fatal, cache kept): %s", e)
|
|
stats = m365_service.run_m365_check(db, wazuh)
|
|
logger.info("M365 nightly: %s", stats)
|
|
except m365_service.M365Error as e:
|
|
logger.warning("M365 nightly skipped — %s", e)
|
|
except Exception as e:
|
|
logger.error("M365 nightly failed: %s", e)
|
|
db.rollback()
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def vuln_index_refresh_nightly():
|
|
"""Rebuild every vulnerability index a nightly sync decides from — BEFORE
|
|
the first sync of the night, not inside the last scan.
|
|
|
|
01:30 UTC. The Intune sync at 02:10 runs the app-CVE scan for each of its
|
|
devices (and the Defender TVM pull), the IGEL sync at 02:30 scans its
|
|
endpoints and the UMS server, the vCenter sync at 02:20 its hosts, the
|
|
Netdisco sync at 02:40 its switches — and
|
|
every one of them reads the cvelistV5 index and the vendor indexes as
|
|
stored. Those used to be rebuilt by the app-CVE job at 03:20, i.e. AFTER
|
|
all three syncs, so an Intune-only device was matched against yesterday's
|
|
catalogue every night (the 03:20 pass over all assets caught up, but only
|
|
once it got there — hours later on a large estate). Now:
|
|
|
|
01:30 this job cvelistV5 (+MFSA), GitHub advisories,
|
|
TeamViewer bulletins, IGEL ISNs, MSRC fixed builds
|
|
02:10 Intune/Defender sync reads them fresh
|
|
02:20 vCenter sync
|
|
02:30 IGEL sync
|
|
02:40 Netdisco sync
|
|
03:20 app-CVE scan reads the same index; builds only if missing
|
|
03:50 MSRC OS scan reuses the MSRC index from 01:30
|
|
|
|
Each rebuild is independent and a failed one keeps the cached index (see
|
|
the build functions), so an offline vendor site costs that source a night,
|
|
never the others and never the syncs.
|
|
|
|
ponytail: the 40-minute gap is the guarantee. The elapsed time is logged;
|
|
if this job ever runs past 02:10, chain the Intune sync onto it instead of
|
|
widening the gap.
|
|
"""
|
|
import time as _time
|
|
from app.services import (cvelistv5_scan_service, github_repo_advisory_service,
|
|
igel_isn_service, teamviewer_bulletin_service)
|
|
db = SessionLocal()
|
|
t0 = _time.monotonic()
|
|
try:
|
|
# force_fresh: the /tmp ZIP is shared with the threat-intel refresh,
|
|
# which runs on an interval anchored to app startup — under the plain
|
|
# 12h TTL this build kept reusing an afternoon snapshot and missed the
|
|
# evening's CVEs for a full extra night (Chrome 151.0.7922.169).
|
|
try:
|
|
cvelistv5_scan_service.build_product_index(db, force_fresh=True)
|
|
except Exception as e:
|
|
logger.warning("cvelistV5 index build failed (non-fatal, cache kept): %s", e)
|
|
# Vendor indexes: all cache 24h and used to be refreshed by whoever
|
|
# asked first — the scan itself, one build-duration short of the TTL
|
|
# every night, so they only ever cleared it every OTHER night (the
|
|
# MFSA bug, 978d4f3). Notepad++, Wazuh and the IGEL ISNs without a
|
|
# CVE reach no other source at all; TeamViewer publishes days before
|
|
# NVD — a night late is a night blind.
|
|
from app.services import citrix_workspace_service
|
|
for _mod, _label in ((github_repo_advisory_service, "repo-advisory"),
|
|
(teamviewer_bulletin_service, "teamviewer-bulletin"),
|
|
(citrix_workspace_service, "citrix-build-catalog"),
|
|
(igel_isn_service, "igel-isn")):
|
|
try:
|
|
_mod.build_index(db)
|
|
except Exception as e:
|
|
logger.warning("%s index build failed (non-fatal, cache kept): %s",
|
|
_label, e)
|
|
# MSRC fixed builds — the one index b802f3a left where it was. The
|
|
# 03:20 app scan's package pass creates the Edge findings from it, and
|
|
# it was rebuilt by the MSRC job at 03:50, i.e. AFTER that scan, so a
|
|
# Chromium CVE MSRC filed under Edge on day D reached the host on D+2
|
|
# (CVE-2026-84324: published 03.09. 17:00, "app-scan only" on 04.09.).
|
|
# Edge CVEs exist in no other source, so a night late is a night blind.
|
|
# The 03:50 job reuses this build (ensure_index) instead of pulling
|
|
# the 18 documents again; a failed build keeps the cached index.
|
|
try:
|
|
from app.services import msrc_scan_service
|
|
msrc_scan_service.build_product_index(db)
|
|
except Exception as e:
|
|
logger.warning("MSRC index build failed (non-fatal, cache kept): %s", e)
|
|
logger.info("Vulnerability index refresh done in %.0fs — the 02:10 "
|
|
"Intune/Defender sync reads these", _time.monotonic() - t0)
|
|
except Exception as e:
|
|
logger.error("Vulnerability index refresh failed: %s", e)
|
|
db.rollback()
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def app_cve_scan_nightly():
|
|
"""Built-in app→CVE scanner for every asset with software inventory.
|
|
|
|
Maps installed software (Wazuh packages + Intune detectedApps) to real
|
|
CVEs via OSV / NVD-CPE (curated + precise, own version-range check) —
|
|
closes the coverage gap for Intune-only / mobile devices that have no
|
|
real scanner. Source 'app-scan'; cross-confirms with the other scanners.
|
|
|
|
Slotted at 03:20 UTC. The cvelistV5 and vendor indexes it decides from
|
|
are rebuilt at 01:30 by vuln_index_refresh_nightly — before the Intune,
|
|
vCenter, IGEL and Netdisco syncs that read them too; run_app_cve_scan builds the
|
|
cvelistV5 index itself only when none is stored yet.
|
|
Cache (TTL 7d) keeps OSV/NVD load bounded; NVD_API_KEY recommended.
|
|
"""
|
|
from app.services import app_cve_scanner_service
|
|
db = SessionLocal()
|
|
try:
|
|
# FP-suppression is part of run_app_cve_scan itself now, so every way of
|
|
# starting a scan — nightly, GUI, single asset — produces the same
|
|
# result. It used to hang off this job alone.
|
|
stats = app_cve_scanner_service.run_app_cve_scan(db, trigger="scheduled")
|
|
logger.info("App CVE scan nightly: %s", {k: v for k, v in stats.items() if k != "errors"})
|
|
except Exception as e:
|
|
logger.error("App CVE scan nightly failed: %s", e)
|
|
db.rollback()
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def msrc_refresh_weekly():
|
|
"""Ingest the recent monthly MSRC CVRF documents into cve_remediations.
|
|
|
|
Microsoft revises advisories (containment-only first, KBs later), so a
|
|
weekly pull keeps the per-CVE fixes/workarounds/mitigations current.
|
|
"""
|
|
from app.services import msrc_service
|
|
db = SessionLocal()
|
|
try:
|
|
stats = msrc_service.refresh_msrc(db)
|
|
logger.info("MSRC weekly: %s", {k: v for k, v in stats.items() if k != "errors"})
|
|
except Exception as e:
|
|
logger.error("MSRC weekly refresh failed: %s", e)
|
|
db.rollback()
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def msrc_scan_nightly():
|
|
"""Rebuild the MSRC fixed-build index and flag Windows-Server OS CVEs whose
|
|
FixedBuild is ahead of the host's build.
|
|
|
|
MSRC publishes on Patch Tuesday, well before the CVE reaches the Wazuh CTI
|
|
feed — this closes that gap, and it is patch-level accurate (NVD/cvelistV5
|
|
carry no fixed build for MS products, see msrc_scan_service).
|
|
"""
|
|
from app.services import msrc_scan_service, msrc_service
|
|
db = SessionLocal()
|
|
try:
|
|
# The index was rebuilt at 01:30 by vuln_index_refresh_nightly, before
|
|
# the app scan that creates the Edge findings from it. Reuse that
|
|
# build; rebuild only when it did not happen (or is a day old).
|
|
msrc_scan_service.ensure_index(db, max_age=msrc_scan_service.SCAN_INDEX_MAX_AGE)
|
|
stats = msrc_scan_service.run_msrc_scan(db)
|
|
logger.info("MSRC scan nightly: %s", {k: v for k, v in stats.items() if k != "errors"})
|
|
# Remediations for what this pass (and the app scan before it) just
|
|
# found. refresh_msrc only stores rows for CVEs already in the
|
|
# vulnerabilities table, so running it AFTER the scans is what makes
|
|
# a Patch-Tuesday CVE show its KB the next morning. Weekly-only meant
|
|
# up to 7 days with no Remediation block at all (observed: CVE-2026-6727
|
|
# and CVE-2026-70304, found Wed 12.08., still bare on Fri 14.08.).
|
|
# Two months back: the current document plus the one that just rolled
|
|
# over. The Sunday job still does the full 18-month backfill.
|
|
rstats = msrc_service.refresh_msrc(db, months_back=2)
|
|
logger.info("MSRC scan nightly → remediation catch-up: %s",
|
|
{k: v for k, v in rstats.items() if k != "errors"})
|
|
except Exception as e:
|
|
logger.error("MSRC scan nightly failed: %s", e)
|
|
db.rollback()
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def prune_audit_logs_nightly():
|
|
"""Prune audit_logs older than `audit_log_retention_days` setting.
|
|
|
|
Default 1825 days (≈ 5 years) so ISO 27001 / SOX / DSGVO Art.5
|
|
retention windows are covered. Setting key:
|
|
`audit_log_retention_days` — integer string. 0 = keep forever.
|
|
|
|
Runs 03:30 UTC, between Vulnrichment (03:00) and URS (04:00).
|
|
"""
|
|
from app.models.audit_log import AuditLog
|
|
from app.models.setting import Setting
|
|
from datetime import timedelta
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
row = db.query(Setting).filter(Setting.key == "audit_log_retention_days").first()
|
|
try:
|
|
days = int((row.value if row else "1825").strip())
|
|
except (ValueError, AttributeError):
|
|
days = 1825
|
|
if days <= 0:
|
|
logger.info("audit-log prune skipped (retention_days=0 → keep forever)")
|
|
return
|
|
|
|
cutoff = datetime.now() - timedelta(days=days)
|
|
pruned = (
|
|
db.query(AuditLog)
|
|
.filter(AuditLog.timestamp < cutoff)
|
|
.delete(synchronize_session=False)
|
|
)
|
|
db.commit()
|
|
logger.info("audit-log prune: deleted %d entries older than %d days", pruned, days)
|
|
except Exception as e:
|
|
logger.error("audit-log prune failed: %s", e)
|
|
db.rollback()
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def reconcile_assets_nightly():
|
|
"""Soft-inactivate assets no source has reported within the window;
|
|
revive recently-seen inactive ones. Runs 04:15 UTC, after URS."""
|
|
from app.services.asset_lifecycle import reconcile_asset_lifecycle
|
|
db = SessionLocal()
|
|
try:
|
|
stats = reconcile_asset_lifecycle(db)
|
|
logger.info("asset lifecycle nightly: %s", stats)
|
|
except Exception as e:
|
|
logger.error("asset lifecycle nightly failed: %s", e)
|
|
db.rollback()
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def refresh_exposure_nightly():
|
|
"""Refresh network-exposure scores from Wazuh syscollector ports.
|
|
Runs 02:30 UTC, after SCA (02:00)."""
|
|
from app.auth.setting_crypto import read_setting_value
|
|
from app.services.exposure_service import refresh_all_exposure
|
|
import json as _json
|
|
db = SessionLocal()
|
|
try:
|
|
raw = read_setting_value(db, "wazuh_config")
|
|
if not raw:
|
|
logger.info("exposure nightly skipped — wazuh_config not set")
|
|
return
|
|
cfg = _json.loads(raw)
|
|
if not all([cfg.get("api_url"), cfg.get("username"), cfg.get("password")]):
|
|
logger.info("exposure nightly skipped — wazuh_config incomplete")
|
|
return
|
|
wazuh = WazuhClient(
|
|
base_url=cfg.get("api_url"),
|
|
username=cfg.get("username"),
|
|
password=cfg.get("password"),
|
|
indexer_url=cfg.get("indexer_url"),
|
|
indexer_username=cfg.get("indexer_username"),
|
|
indexer_password=cfg.get("indexer_password"),
|
|
verify_ssl=bool(cfg.get("verify_ssl", True)),
|
|
)
|
|
stats = refresh_all_exposure(db, wazuh)
|
|
logger.info("exposure nightly: %s", stats)
|
|
except Exception as e:
|
|
logger.error("exposure nightly failed: %s", e)
|
|
db.rollback()
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def recompute_urs_nightly():
|
|
"""Recompute Unified Risk Score (URS) for every asset and snapshot.
|
|
|
|
Runs 04:00 UTC — after SCA refresh (02:00) and Vulnrichment
|
|
correction (03:00) so AVS/ASS inputs are fresh. Snapshots written
|
|
by compute_urs_for_all() feed the 7-day trend arrow.
|
|
|
|
Also prunes asset_risk_snapshots older than 90 days.
|
|
"""
|
|
from app.models.compliance import AssetRiskSnapshot
|
|
from app.services.urs_service import compute_urs_for_all
|
|
from datetime import timedelta
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
stats = compute_urs_for_all(db, avs_mode="hybrid")
|
|
db.commit()
|
|
cutoff = datetime.now() - timedelta(days=90)
|
|
pruned = (
|
|
db.query(AssetRiskSnapshot)
|
|
.filter(AssetRiskSnapshot.snapshot_date < cutoff)
|
|
.delete(synchronize_session=False)
|
|
)
|
|
db.commit()
|
|
logger.info(
|
|
"URS nightly recompute done: %d assets, %d with URS, %d errors, %d snapshots pruned",
|
|
stats["assets"], stats["with_urs"], stats["errors"], pruned,
|
|
)
|
|
except Exception as e:
|
|
logger.error("URS nightly recompute failed: %s", e)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def refresh_compliance_sca():
|
|
"""Nightly Wazuh SCA refresh — pulls /sca/{agent_id} for every
|
|
asset that has a wazuh_agent_id and upserts compliance_results.
|
|
|
|
Cheap operation per asset (one summary GET, no per-check fetch)
|
|
so a 100-asset fleet completes in seconds.
|
|
"""
|
|
from app.services.compliance_service import refresh_all_compliance
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
stats = refresh_all_compliance(db, trigger="scheduled")
|
|
logger.info(
|
|
"Compliance SCA refresh done: %d assets, %d policy results, %d errors",
|
|
stats["assets_synced"], stats["policies_synced"], len(stats["errors"]),
|
|
)
|
|
except Exception as e:
|
|
logger.error("Compliance SCA refresh failed: %s", e)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def refresh_cisa_vulnrichment():
|
|
"""Nightly job — pull CISA Vulnrichment ZIP snapshot + correct CVSS / SSVC.
|
|
|
|
CISA commits to cisagov/vulnrichment several times per day. Running
|
|
this nightly catches CVSS revisions, new SSVC decision points and
|
|
newly-published CVEs that were 404 yesterday. Uses the same
|
|
correct_vulnerability_scores helper as the manual 'Correct CVSS'
|
|
button, so behaviour matches what the operator sees in the UI.
|
|
|
|
Held score corrections (exploitation_source='vulnrichment') are
|
|
preserved across Nessus syncs by the existing override-lock —
|
|
see services/nessus_sync.py run_nessus_sync merge branch.
|
|
"""
|
|
from app.services.vuln_override_service import correct_vulnerability_scores
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
result = correct_vulnerability_scores(db, dry_run=False)
|
|
logger.info(
|
|
"CISA Vulnrichment nightly correction done: "
|
|
"checked=%s, updated=%s, not_found=%s",
|
|
result.get("checked", 0),
|
|
result.get("updated", 0),
|
|
result.get("not_found", 0),
|
|
)
|
|
except Exception as e:
|
|
logger.error("CISA Vulnrichment nightly refresh failed: %s", e)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def sync_schedules():
|
|
"""Synchronizes DB schedules with APScheduler jobs"""
|
|
if not HAS_APSCHEDULER or scheduler is None:
|
|
return
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
schedules = db.query(ScanSchedule).filter(ScanSchedule.enabled == True).all()
|
|
|
|
# Aktuelle Scheduler-Jobs entfernen (nur scan_schedule_ prefixed)
|
|
existing_jobs = {j.id for j in scheduler.get_jobs() if j.id.startswith("scan_schedule_")}
|
|
active_ids = {f"scan_schedule_{s.id}" for s in schedules}
|
|
|
|
# Remove jobs that are no longer active
|
|
for job_id in existing_jobs - active_ids:
|
|
scheduler.remove_job(job_id)
|
|
logger.info(f"Scheduler job removed: {job_id}")
|
|
|
|
# Füge neue/aktualisierte Jobs hinzu
|
|
for schedule in schedules:
|
|
job_id = f"scan_schedule_{schedule.id}"
|
|
interval_kwargs = INTERVAL_MAP.get(schedule.interval, {"days": 1})
|
|
|
|
# Prüfe ob der Job bereits existiert
|
|
existing_job = next((j for j in scheduler.get_jobs() if j.id == job_id), None)
|
|
|
|
if existing_job:
|
|
# Nur Reschedule wenn sich das Intervall geändert hat?
|
|
# Da wir hier schwer das Intervall des bestehenden Triggers prüfen können,
|
|
# vergleichen wir einfach ob der Job-Name (Intervall-Name) noch passt oder
|
|
# ob wir eine Änderung in der DB erzwingen wollen.
|
|
# Wir verzichten auf das generelle Reschedule jede Minute!
|
|
continue
|
|
else:
|
|
# Neuer Job oder nach App-Neustart
|
|
if schedule.interval == ScheduleInterval.CRON and schedule.cron_expression:
|
|
try:
|
|
trigger = CronTrigger.from_crontab(schedule.cron_expression)
|
|
except Exception as e:
|
|
logger.error(f"Invalid cron expression for schedule {schedule.id}: {e}")
|
|
continue
|
|
else:
|
|
# Wenn next_run in der Vergangenheit liegt, sofort ausführen (start_date=now)
|
|
start_date = schedule.next_run if (schedule.next_run and schedule.next_run > datetime.now()) else datetime.now()
|
|
trigger = IntervalTrigger(start_date=start_date, **interval_kwargs)
|
|
|
|
scheduler.add_job(
|
|
execute_scheduled_scan,
|
|
trigger=trigger,
|
|
id=job_id,
|
|
args=[schedule.id],
|
|
name=f"{schedule.name} ({schedule.interval})",
|
|
replace_existing=True
|
|
)
|
|
logger.info(f"Scheduler job added: {job_id} (Trigger: {trigger})")
|
|
|
|
# Update next_run calculation
|
|
if not schedule.next_run:
|
|
if schedule.interval == ScheduleInterval.CRON and schedule.cron_expression:
|
|
schedule.next_run = trigger.get_next_fire_time(None, datetime.now())
|
|
else:
|
|
schedule.next_run = datetime.now() + timedelta(**interval_kwargs)
|
|
|
|
db.commit()
|
|
logger.info(f"Scheduler synchronized: {len(schedules)} active schedules")
|
|
|
|
except Exception as e:
|
|
logger.error(f"Scheduler sync error: {e}")
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def new_vuln_digest_nightly():
|
|
"""Nightly roundup of all new CVEs → one aggregated mail per recipient.
|
|
|
|
Registered hourly and self-gates on the configured hour, so both the
|
|
on/off (notification_schedule) and the send hour (notification_nightly_hour)
|
|
are GUI-configurable without re-registering the job.
|
|
ponytail: 24 cheap no-op checks/day beats re-registration plumbing.
|
|
"""
|
|
from datetime import datetime as _dt
|
|
db = SessionLocal()
|
|
try:
|
|
from app.services.email_service import (
|
|
get_notification_schedule, get_notification_nightly_hour,
|
|
send_nightly_new_vuln_digest,
|
|
)
|
|
if get_notification_schedule(db) != "nightly":
|
|
return
|
|
if _dt.now().hour != get_notification_nightly_hour(db):
|
|
return
|
|
stats = send_nightly_new_vuln_digest(db)
|
|
logger.info("Nightly new-vuln digest sent: %s", stats)
|
|
except Exception as e:
|
|
logger.error("Nightly new-vuln digest failed: %s", e)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def advisory_feeds_refresh():
|
|
"""Refresh the security-advisory RSS feeds (ZDI/CERT-EU/BSI/...) so the
|
|
advisories page serves from cache. Every 6h — these sources publish ahead
|
|
of NVD/cvelistV5, that's their whole value."""
|
|
from app.services.advisory_feed_service import refresh_feeds
|
|
db = SessionLocal()
|
|
try:
|
|
stats = refresh_feeds(db)
|
|
logger.info("Advisory feeds refresh: %s", stats)
|
|
except Exception as e:
|
|
logger.error("Advisory feeds refresh failed: %s", e)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def kev_alert_check():
|
|
"""Mail out actively-exploited CVEs (CISA KEV / ENISA EUVD) that have open
|
|
findings on our own active assets. Hourly, not nightly: known exploitation
|
|
plus confirmed presence is the one case where waiting until tomorrow is the
|
|
wrong call. Idempotent — a CVE is mailed once, and again only if the number
|
|
of affected systems grows."""
|
|
from app.services.kev_alert_service import run_kev_alerts
|
|
db = SessionLocal()
|
|
try:
|
|
stats = run_kev_alerts(db)
|
|
if stats.get("emails_sent") or stats.get("emails_failed"):
|
|
logger.info("KEV alert run: %s", stats)
|
|
except Exception as e:
|
|
logger.error("KEV alert run failed: %s", e)
|
|
db.rollback()
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def start_scheduler():
|
|
"""Starts the background scheduler"""
|
|
if not HAS_APSCHEDULER or scheduler is None:
|
|
logger.warning("Scheduler not available - install apscheduler: pip install apscheduler")
|
|
return
|
|
|
|
try:
|
|
sync_schedules()
|
|
except Exception as e:
|
|
logger.warning(f"Initial scheduler synchronization failed (DB unchecked?): {e}")
|
|
|
|
# Periodic re-sync every 60 seconds to pick up DB changes
|
|
scheduler.add_job(
|
|
sync_schedules,
|
|
trigger=IntervalTrigger(seconds=60),
|
|
id="scheduler_sync",
|
|
name="Scheduler DB Sync",
|
|
replace_existing=True
|
|
)
|
|
|
|
# SLA breach checker every hour
|
|
scheduler.add_job(
|
|
check_sla_breaches,
|
|
trigger=IntervalTrigger(hours=1),
|
|
id="sla_breach_check",
|
|
name="SLA Breach Checker",
|
|
replace_existing=True
|
|
)
|
|
|
|
# Daily threat intel refresh (EPSS scores change daily, KEV updates frequently)
|
|
scheduler.add_job(
|
|
refresh_threat_intel_enrichment,
|
|
trigger=IntervalTrigger(hours=24),
|
|
id="threat_intel_refresh",
|
|
name="Daily Threat Intel Refresh (EPSS + KEV)",
|
|
replace_existing=True,
|
|
next_run_time=datetime.now() + timedelta(minutes=5), # First run 5 min after startup
|
|
)
|
|
|
|
# Nightly CISA Vulnrichment correction at 03:00 — CISA pushes several
|
|
# commits per day so a daily snapshot keeps CVSS + SSVC fields fresh
|
|
# without spamming the GitHub raw API per-CVE.
|
|
scheduler.add_job(
|
|
refresh_cisa_vulnrichment,
|
|
trigger=CronTrigger(hour=4, minute=30),
|
|
id="vulnrichment_nightly",
|
|
name="Nightly CISA Vulnrichment CVSS / SSVC Correction",
|
|
replace_existing=True,
|
|
)
|
|
|
|
# Nightly Wazuh SCA refresh at 02:00 — runs ahead of Vulnrichment so
|
|
# compliance numbers on the dashboard are fresh when admins log in
|
|
# the next morning. Cheap (one summary GET per agent).
|
|
scheduler.add_job(
|
|
refresh_compliance_sca,
|
|
trigger=CronTrigger(hour=2, minute=0),
|
|
id="compliance_sca_nightly",
|
|
name="Nightly Wazuh SCA Compliance Refresh",
|
|
replace_existing=True,
|
|
)
|
|
|
|
# Nightly URS recompute at 04:00 — must run AFTER SCA (02:00) and
|
|
# Vulnrichment (03:00) so AVS/ASS inputs reflect today's data.
|
|
# Also prunes asset_risk_snapshots older than 90 days.
|
|
scheduler.add_job(
|
|
recompute_urs_nightly,
|
|
trigger=CronTrigger(hour=5, minute=10),
|
|
id="urs_nightly",
|
|
name="Nightly URS Recompute + Snapshot Prune",
|
|
replace_existing=True,
|
|
)
|
|
|
|
# Nightly asset lifecycle reconcile at 04:15 — soft-inactivate
|
|
# assets no source has reported within asset_inactive_after_days
|
|
# (default 30), revive recently-seen ones. Runs after URS.
|
|
scheduler.add_job(
|
|
reconcile_assets_nightly,
|
|
trigger=CronTrigger(hour=5, minute=25),
|
|
id="asset_lifecycle_nightly",
|
|
name="Nightly Asset Lifecycle Reconcile",
|
|
replace_existing=True,
|
|
)
|
|
|
|
# Nightly network-exposure refresh at 02:30 — pull syscollector
|
|
# ports, classify risky listeners (VNC/RDP/Telnet/...), store score.
|
|
scheduler.add_job(
|
|
refresh_exposure_nightly,
|
|
trigger=CronTrigger(hour=2, minute=30),
|
|
id="exposure_nightly",
|
|
name="Nightly Network-Exposure Refresh",
|
|
replace_existing=True,
|
|
)
|
|
|
|
# Advisory RSS feeds (ZDI/CERT-EU/BSI/...) every 6h at :20.
|
|
scheduler.add_job(
|
|
advisory_feeds_refresh,
|
|
trigger=CronTrigger(hour="*/6", minute=20),
|
|
id="advisory_feeds_refresh",
|
|
name="Security Advisory Feeds Refresh",
|
|
replace_existing=True,
|
|
)
|
|
|
|
# New-vuln nightly roundup — runs hourly at :05, self-gates on the
|
|
# configured hour + notification_schedule=='nightly' (see the function).
|
|
scheduler.add_job(
|
|
new_vuln_digest_nightly,
|
|
trigger=CronTrigger(minute=5),
|
|
id="new_vuln_digest_nightly",
|
|
name="Nightly New-Vulnerability Roundup",
|
|
replace_existing=True,
|
|
)
|
|
|
|
# Nightly audit-log prune at 03:30 — between Vulnrichment (03:00)
|
|
# and URS (04:00). Retention configurable via setting
|
|
# `audit_log_retention_days` (default 1825 = ~5 years; 0 = keep
|
|
# forever). Covers ISO 27001 / SOX / DSGVO Art.5 windows.
|
|
scheduler.add_job(
|
|
prune_audit_logs_nightly,
|
|
trigger=CronTrigger(hour=5, minute=40),
|
|
id="audit_log_prune_nightly",
|
|
name="Nightly Audit-Log Retention Prune",
|
|
replace_existing=True,
|
|
)
|
|
|
|
# Nightly EOL detection via endoflife.date at 03:15 — closes the
|
|
# gap Wazuh has vs Nessus plugin 64784 (unsupported-version
|
|
# detection). Creates pseudo-CVEs (cve_id starts with "EOL-").
|
|
scheduler.add_job(
|
|
eol_check_nightly,
|
|
trigger=CronTrigger(hour=3, minute=0),
|
|
id="eol_check_nightly",
|
|
name="Nightly endoflife.date EOL Detection",
|
|
replace_existing=True,
|
|
)
|
|
|
|
# Nightly Microsoft 365 Apps CVE detection (Plan P) at 03:20 — parses
|
|
# the MS365 Apps security-updates page and creates real-CVE rows for
|
|
# builds behind the latest channel patch. Closes the gap where M365
|
|
# fixes never reach NVD / Wazuh.
|
|
scheduler.add_job(
|
|
m365_check_nightly,
|
|
trigger=CronTrigger(hour=3, minute=10),
|
|
id="m365_check_nightly",
|
|
name="Nightly Microsoft 365 Apps CVE Detection",
|
|
replace_existing=True,
|
|
)
|
|
|
|
# Nightly exploit-intel refresh (Plan M) at 03:45 — pulls
|
|
# Exploit-DB CSV + PoC-in-GitHub + Metasploit module index, writes
|
|
# per-vuln counts + ref lists.
|
|
scheduler.add_job(
|
|
exploit_intel_nightly,
|
|
trigger=CronTrigger(hour=4, minute=50),
|
|
id="exploit_intel_nightly",
|
|
name="Nightly Public-Exploit Catalog Refresh",
|
|
replace_existing=True,
|
|
)
|
|
|
|
# Nightly Microsoft Intune device/inventory sync (02:10 UTC).
|
|
# Nightly vulnerability index refresh (01:30 UTC) — cvelistV5 (+MFSA),
|
|
# GitHub advisories, TeamViewer bulletins, IGEL ISNs. First job of the
|
|
# scan night on purpose: the Intune/Defender sync at 02:10 decides from
|
|
# these, and it can run for hours on a large estate.
|
|
scheduler.add_job(
|
|
vuln_index_refresh_nightly,
|
|
trigger=CronTrigger(hour=1, minute=30),
|
|
id="vuln_index_refresh_nightly",
|
|
name="Nightly Vulnerability Index Refresh",
|
|
replace_existing=True,
|
|
)
|
|
|
|
scheduler.add_job(
|
|
intune_sync_nightly,
|
|
trigger=CronTrigger(hour=2, minute=10),
|
|
id="intune_sync_nightly",
|
|
name="Nightly Microsoft Intune Inventory Sync",
|
|
replace_existing=True,
|
|
)
|
|
|
|
# Nightly VMware vCenter / ESXi inventory sync (02:20 UTC).
|
|
scheduler.add_job(
|
|
vcenter_sync_nightly,
|
|
trigger=CronTrigger(hour=2, minute=20),
|
|
id="vcenter_sync_nightly",
|
|
name="Nightly VMware vCenter/ESXi Inventory Sync",
|
|
replace_existing=True,
|
|
)
|
|
|
|
# Nightly IGEL UMS endpoint-device inventory sync (02:30 UTC).
|
|
scheduler.add_job(
|
|
igel_sync_nightly,
|
|
trigger=CronTrigger(hour=2, minute=30),
|
|
id="igel_sync_nightly",
|
|
name="Nightly IGEL UMS Endpoint Inventory Sync",
|
|
replace_existing=True,
|
|
)
|
|
|
|
# Nightly Netdisco switch/router inventory sync (02:40 UTC).
|
|
scheduler.add_job(
|
|
netdisco_sync_nightly,
|
|
trigger=CronTrigger(hour=2, minute=40),
|
|
id="netdisco_sync_nightly",
|
|
name="Nightly Netdisco Device Inventory Sync",
|
|
replace_existing=True,
|
|
)
|
|
|
|
# Nightly built-in app→CVE scan (03:20 UTC) — maps installed software
|
|
# (Wazuh packages + Intune detectedApps) to real CVEs via OSV/NVD-CPE;
|
|
# closes the coverage gap for Intune-only / mobile devices.
|
|
scheduler.add_job(
|
|
app_cve_scan_nightly,
|
|
trigger=CronTrigger(hour=3, minute=20),
|
|
id="app_cve_scan_nightly",
|
|
name="Nightly Built-in App CVE Scan",
|
|
replace_existing=True,
|
|
)
|
|
|
|
# Weekly MSRC CVRF ingest (Sun 04:40 UTC) — per-CVE Microsoft fixes
|
|
# (KB + build + link), workarounds, and mitigations into
|
|
# cve_remediations for the Windows/MS-product findings.
|
|
scheduler.add_job(
|
|
msrc_refresh_weekly,
|
|
trigger=CronTrigger(day_of_week="sun", hour=4, minute=40),
|
|
id="msrc_refresh_weekly",
|
|
name="Weekly MSRC Remediation Enrichment",
|
|
replace_existing=True,
|
|
)
|
|
|
|
# Nightly MSRC fixed-build scan (05:10 UTC) — Windows-Server OS CVEs whose
|
|
# FixedBuild is ahead of the host's build. Daily (not weekly) so a Patch
|
|
# Tuesday lands the next morning instead of days later.
|
|
scheduler.add_job(
|
|
msrc_scan_nightly,
|
|
trigger=CronTrigger(hour=3, minute=50),
|
|
id="msrc_scan_nightly",
|
|
name="Nightly MSRC Fixed-Build Scan (Windows OS)",
|
|
replace_existing=True,
|
|
)
|
|
|
|
# Actively-exploited-and-present alerting, hourly. Offset off the hour so
|
|
# it doesn't collide with the SLA checker.
|
|
scheduler.add_job(
|
|
kev_alert_check,
|
|
trigger=CronTrigger(minute=25),
|
|
id="kev_alert_check",
|
|
name="KEV Alert (actively exploited + in inventory)",
|
|
replace_existing=True,
|
|
)
|
|
|
|
# Rows left "running" by a restart mid-sync would show a spinner forever.
|
|
mark_interrupted_runs()
|
|
scheduler.start()
|
|
logger.info(
|
|
"Background scheduler started (SLA Breach Checker + Threat Intel Refresh + KEV Alert Hourly + Vulnrichment Nightly + Compliance SCA Nightly + URS Nightly + Audit-Log Prune)"
|
|
)
|
|
|
|
|
|
def stop_scheduler():
|
|
"""Stops the background scheduler"""
|
|
if not HAS_APSCHEDULER or scheduler is None:
|
|
return
|
|
if scheduler.running:
|
|
scheduler.shutdown(wait=False)
|
|
logger.info("Background scheduler stopped")
|