Files
vulncheck/app/scheduler.py
T
vulncheckandClaude Opus 5 bcaa4f02ee feat(netdisco): switch estate as assets, with Aruba firmware CVEs
A switch runs no agent, no MDM enrols it, and a Nessus scan without SNMP
credentials sees an open port and no version — so the devices everything
else is plugged into had no coverage at all. Netdisco already asks them
over SNMP; this reads its deviceinventory report and turns it into assets
and findings.

Aruba detection is family-split on purpose: AOS-CX (10.13.1005), the
ProVision line (16.11.0016) and the Mobility controllers (8.13.1.1) are
three products HPE files under one vendor with overlapping numbers, and a
CX switch matched against a controller's range would be told to install
an image its hardware cannot take. Each family gets its own curated key,
its own anchored product regex and its own NVD CPE, and an index entry
without a floor must additionally share the installed release BRANCH —
HPE writes one range per branch on the same record (CVE-2026-73749
carries 10.18, 10.17, 10.16, 10.13 and 10.10 side by side).

Both CVE sources, as everywhere here: NVD carries usable cpeMatch ranges
for the three 2023 ArubaOS-Switch flaws, while the current AOS-CX batch
(CVE-2026-73749, -44880) sits there unenriched and exists only as HPE's
own CNA records. The switch records state their bounds as prose
("KB/WC/YA/YB/YC.16.11.0015 and below"), which the shared extractor reads
as the number salad (16,11,16,11,12) it looks like — so those are parsed
separately, per branch, and "All versions" is skipped: a finding with no
fix to reach is noise, not a verdict.

The two-letter code line ("WC.", "PL.") names the hardware family, not
the version. It is dropped for comparison and kept in the description,
where it is what an operator matches against HPE's download page.

Also anchors the Apple iOS patterns. They matched "ios" as a substring,
and Netdisco names a Cisco switch's OS exactly that — every Cisco device
in an estate would have been compared against Apple's iPhone ranges,
where IOS 15.2 sits below every bound ever written.

Deliberately not included: HPE's own bulletins (the same data the CNA
records already state structurally) and EOL/EOM (HPE publishes those per
hardware product, not per firmware line, and endoflife.date carries no
ArubaOS at all — an invented date would retire real assets on a guess).
Non-Aruba devices get an asset and no CVE verdict.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 11:44:19 +02:00

1520 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 Aruba 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.
for _mod, _label in ((github_repo_advisory_service, "repo-advisory"),
(teamviewer_bulletin_service, "teamviewer-bulletin"),
(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")