Skip to content

Commit f500b4b

Browse files
jrx-codeclaude
andcommitted
feat: v0.19.0 — CVE watch: periodic vulnerability monitoring for installed deps
- CVE watch: lightweight background task checking OSV.dev for new CVEs - Default interval: 6 hours (configurable) - Tracks alerts across runs: only notifies on NEW vulnerabilities - MQTT status: publishes cve_alert:{count}_new when new CVEs found - API: GET /api/cve-alerts, POST /api/cve-watch (enable/disable) - Settings: cve_watch_enabled, cve_watch_interval_hours Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent a91b9f9 commit f500b4b

4 files changed

Lines changed: 144 additions & 6 deletions

File tree

ha-sandbox/app/main.py

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,10 +50,12 @@ async def lifespan(app: FastAPI):
5050
publish_status("idle")
5151
except Exception as e:
5252
log.warning("MQTT init failed (non-fatal): %s", e)
53-
# Start scheduled scans if enabled
53+
# Start scheduled tasks if enabled
5454
cfg = app_settings.load()
5555
if cfg.get("schedule_enabled"):
5656
scheduler.start(cfg.get("schedule_interval_hours", 24))
57+
if cfg.get("cve_watch_enabled"):
58+
scheduler.start_cve_watch(cfg.get("cve_watch_interval_hours", 6))
5759
yield
5860
scheduler.stop()
5961
storage.close()
@@ -536,6 +538,39 @@ async def api_scheduler_update(request: Request):
536538
return JSONResponse(content={"ok": True, **scheduler.status()})
537539

538540

541+
# --- CVE Watch API ---
542+
543+
@app.get("/api/cve-alerts")
544+
async def api_cve_alerts():
545+
"""Get current CVE alerts from the watch system."""
546+
alerts = scheduler.get_cve_alerts()
547+
return JSONResponse(content={
548+
"alerts": alerts,
549+
"total": sum(len(v) for v in alerts.values()),
550+
"components_affected": len(alerts),
551+
})
552+
553+
554+
@app.post("/api/cve-watch")
555+
async def api_cve_watch_update(request: Request):
556+
"""Enable/disable CVE watch.
557+
558+
Body: {"enabled": true/false, "interval_hours": 6}
559+
"""
560+
data = await request.json()
561+
enabled = data.get("enabled", False)
562+
interval = float(data.get("interval_hours", 6))
563+
564+
app_settings.save({"cve_watch_enabled": enabled, "cve_watch_interval_hours": interval})
565+
566+
if enabled and interval > 0:
567+
scheduler.start_cve_watch(interval)
568+
else:
569+
scheduler.stop()
570+
571+
return JSONResponse(content={"ok": True, **scheduler.status()})
572+
573+
539574
# --- Cross-Component Intelligence API (L.5) ---
540575

541576
@app.get("/api/intelligence")

ha-sandbox/app/scheduler.py

Lines changed: 105 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""Scheduled periodic scans of installed HACS components."""
1+
"""Scheduled periodic scans and CVE watch for installed HACS components."""
22

33
import asyncio
44
import logging
@@ -7,8 +7,12 @@
77
log = logging.getLogger(__name__)
88

99
_task: asyncio.Task | None = None
10+
_cve_task: asyncio.Task | None = None
1011
_interval_hours: float = 0
12+
_cve_interval_hours: float = 6
1113
_enabled: bool = False
14+
_cve_enabled: bool = False
15+
_last_cve_alerts: dict[str, list[str]] = {}
1216

1317

1418
async def _scheduled_loop():
@@ -57,6 +61,70 @@ async def _scheduled_loop():
5761
publish_status("idle")
5862

5963

64+
async def _cve_watch_loop():
65+
"""Lightweight CVE-only check — queries OSV.dev for known deps without full scan."""
66+
from app.scanner.hacs_list import fetch_installed_hacs, repo_to_url
67+
from app.scanner.fetch import fetch_and_parse
68+
from app.scanner.cve_lookup import check_cve, check_cve_repo
69+
from app.report.mqtt import publish_status
70+
from app.models import ScanJob
71+
72+
global _last_cve_alerts
73+
74+
while True:
75+
await asyncio.sleep(_cve_interval_hours * 3600)
76+
if not _cve_enabled:
77+
continue
78+
79+
log.info("CVE watch check starting (interval=%gh)", _cve_interval_hours)
80+
81+
try:
82+
installed = await fetch_installed_hacs()
83+
if not installed:
84+
continue
85+
86+
new_alerts: dict[str, list[str]] = {}
87+
for comp in installed:
88+
url = repo_to_url(comp.get("full_name", ""))
89+
if not url:
90+
continue
91+
name = comp.get("name", comp.get("full_name", ""))
92+
93+
try:
94+
job = ScanJob(id=f"cve:{name[:8]}", repo_url=url, name=name)
95+
repo_path = fetch_and_parse(job)
96+
97+
findings = []
98+
if job.manifest and job.manifest.requirements:
99+
cve_findings = await check_cve(job.manifest)
100+
findings.extend(cve_findings)
101+
repo_cve = await check_cve_repo(repo_path)
102+
findings.extend(repo_cve)
103+
104+
if findings:
105+
cve_ids = [f.description[:80] for f in findings]
106+
prev = _last_cve_alerts.get(name, [])
107+
new_cves = [c for c in cve_ids if c not in prev]
108+
if new_cves:
109+
new_alerts[name] = new_cves
110+
log.warning("CVE watch: %s has %d new vulnerabilities", name, len(new_cves))
111+
_last_cve_alerts[name] = cve_ids
112+
113+
except Exception as e:
114+
log.warning("CVE watch failed for %s: %s", name, e)
115+
116+
if new_alerts:
117+
total = sum(len(v) for v in new_alerts.values())
118+
publish_status(f"cve_alert:{total}_new")
119+
log.warning("CVE watch: %d new vulnerabilities across %d components",
120+
total, len(new_alerts))
121+
else:
122+
log.info("CVE watch: no new vulnerabilities found")
123+
124+
except Exception as e:
125+
log.exception("CVE watch loop error: %s", e)
126+
127+
60128
def start(interval_hours: float = 24):
61129
"""Start the scheduled scan loop."""
62130
global _task, _interval_hours, _enabled
@@ -75,14 +143,36 @@ def start(interval_hours: float = 24):
75143
log.info("Scheduled scans enabled: every %gh", interval_hours)
76144

77145

146+
def start_cve_watch(interval_hours: float = 6):
147+
"""Start the CVE watch loop (lightweight, CVE-only checks)."""
148+
global _cve_task, _cve_interval_hours, _cve_enabled
149+
if interval_hours <= 0:
150+
log.info("CVE watch disabled (interval=0)")
151+
return
152+
153+
_cve_interval_hours = interval_hours
154+
_cve_enabled = True
155+
156+
if _cve_task and not _cve_task.done():
157+
log.info("CVE watch already running, updating interval to %gh", interval_hours)
158+
return
159+
160+
_cve_task = asyncio.create_task(_cve_watch_loop())
161+
log.info("CVE watch enabled: every %gh", interval_hours)
162+
163+
78164
def stop():
79-
"""Stop the scheduled scan loop."""
80-
global _task, _enabled
165+
"""Stop all scheduled tasks."""
166+
global _task, _cve_task, _enabled, _cve_enabled
81167
_enabled = False
168+
_cve_enabled = False
82169
if _task and not _task.done():
83170
_task.cancel()
84171
_task = None
85-
log.info("Scheduled scans disabled")
172+
if _cve_task and not _cve_task.done():
173+
_cve_task.cancel()
174+
_cve_task = None
175+
log.info("All scheduled tasks disabled")
86176

87177

88178
def status() -> dict:
@@ -91,4 +181,15 @@ def status() -> dict:
91181
"enabled": _enabled,
92182
"interval_hours": _interval_hours,
93183
"running": _task is not None and not _task.done() if _task else False,
184+
"cve_watch": {
185+
"enabled": _cve_enabled,
186+
"interval_hours": _cve_interval_hours,
187+
"running": _cve_task is not None and not _cve_task.done() if _cve_task else False,
188+
"active_alerts": len(_last_cve_alerts),
189+
},
94190
}
191+
192+
193+
def get_cve_alerts() -> dict[str, list[str]]:
194+
"""Return current CVE alerts from last watch run."""
195+
return _last_cve_alerts

ha-sandbox/app/settings.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@
3232
"log_level": "info",
3333
"schedule_enabled": False,
3434
"schedule_interval_hours": 24,
35+
"cve_watch_enabled": False,
36+
"cve_watch_interval_hours": 6,
3537
}
3638

3739
# Public API provider presets

ha-sandbox/config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
name: "HA Security Sandbox"
2-
version: "0.18.0"
2+
version: "0.19.0"
33
slug: ha_security_sandbox
44
description: "Security scanner for Home Assistant custom components — static analysis + AI review"
55
url: "https://github.com/jrx-code/ha-security-sandbox"

0 commit comments

Comments
 (0)