Skip to content

Commit 953d3dc

Browse files
authored
fix(data): dispatch CVE alerts for newly discovered high/critical vulns (#42)
The ingest_vulnerabilities endpoint was saving CVE findings to the DB but never calling registry.dispatch_vulnerability_found, so the alerts module never fired — new critical/high CVEs were silently stored without any alert. Fix: - After upserting each vuln, track (vuln, host) for newly created rows that are severity=critical or severity=high. - After db.commit(), dispatch on_vulnerability_found for each; errors are logged but don't fail the ingestion response. - Re-scans of already-known CVEs do NOT re-fire (is_new guard) to avoid alert storms. Tests added (3): - test_dispatches_alert_for_new_critical_cve — asserts dispatch called once with the correct vuln/host args for a new critical CVE - test_no_alert_dispatch_for_existing_cve — asserts no dispatch when the CVE row already exists - test_no_alert_dispatch_for_low_severity_cve — asserts no dispatch for low/medium severity new CVEs 534 tests passing (was 531).
1 parent ca91be5 commit 953d3dc

3 files changed

Lines changed: 179 additions & 41 deletions

File tree

.claude/roadmap.md

Lines changed: 25 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -4,67 +4,52 @@ Last updated: 2026-09-06
44

55
## Currently open PR
66

7-
- PR on branch `claude/initial-project-documentation-ogkzS`sync with main, roadmap update
7+
- PR #42 on branch `claude/windows-installer-ogkzS`fix CVE alert dispatch + roadmap sync
88

99
## Open Dependabot alerts
1010

1111
- [LOW] Alert #18 — package unknown (API returns 403) — https://github.com/tunisiano187/Discoverykastle/security/dependabot/18
1212

1313
## Recently merged
1414

15+
- PR #41: feat(ui): Teams page — list, create, delete + member management — merged 2026-09-06
16+
- PR #40: chore: sync with main — browserslist fix merged, roadmap updated — merged 2026-09-06
1517
- PR #39: fix(deps): update browserslist → 4.28.8 (alert #19) — merged 2026-09-04
1618
- PR #38: docs(roadmap): sync state — merged 2026-09-04
1719
- PR #37: chore(deps): Dependabot npm group update — merged 2026-09-04
18-
- PR #36: docs(roadmap): sync recent merges — merged 2026-08-28
19-
- PR #35: fix(deps): replace python-jose with PyJWT, bump aiohttp/cryptography — merged 2026-08-13
20-
- PR #34: feat(ui): CVE host-level drill-down slide-over panel — merged 2026-08-13
21-
- PR #33: feat(ui): team-scoped vuln stats panel — merged 2026-08-13
22-
- PR #32: chore(deps): bump aiohttp 3.14.1→3.14.3 — merged 2026-08-13
23-
- PR #31: feat(agent): Windows WMI collector + CIS Level-1 hardening checks — merged 2026-08-04
24-
- PR #30: feat(inventory): team assignment API — PATCH hosts/{id}/team + networks/{id}/team — merged 2026-08-04
25-
- PR #29: feat(multitenancy): team-scoped data isolation (migration 0005) — merged 2026-07-29
20+
- PR #35: fix(deps): replace python-jose with PyJWT — merged 2026-08-13
21+
- PR #34/#33: CVE drill-down + team-scoped vuln UI — merged 2026-08-13
22+
- PR #31: Windows WMI collector + CIS Level-1 checks — merged 2026-08-04
23+
- PR #30: Team assignment API — merged 2026-08-04
24+
- PR #29: Team-scoped data isolation (migration 0005) — merged 2026-07-29
25+
- PR #24: Multitenancy foundation — Teams + memberships CRUD — merged 2026-07-01
2626

2727
## Todo — prioritized
2828

29-
1. [SECURITY][LOW] Dependabot alert #18details inaccessible (403); review at https://github.com/tunisiano187/Discoverykastle/security/dependabot/18
29+
1. [SECURITY][LOW] Dependabot alert #18 — review at https://github.com/tunisiano187/Discoverykastle/security/dependabot/18
3030

31-
2. [MEDIUM] Page Teams dans le SPA — React UI for team management
32-
- List teams, create/delete, member management, role assignment
33-
- Team-scoped inventory views (hosts/networks filtered by team)
34-
35-
3. [MEDIUM] Windows agent — PowerShell installer script
36-
- install.ps1: set up Windows service (pywin32), write agent.conf, start service
37-
- uninstall.ps1 counterpart
38-
39-
4. [MEDIUM] SNMP collector
31+
2. [MEDIUM] SNMP collector
4032
- agent/collectors/snmp_collector.py using pysnmp
4133
- OID mappings for Cisco/Juniper/generic
34+
- Submit to POST /api/v1/data/discovery
4235

43-
5. [MEDIUM] Alertes automatiques CVE
44-
- Background task comparing installed packages against new CVEs
45-
- Webhook/email notification
46-
47-
6. [LOW] Hosts page: team assignment UI
36+
3. [LOW] Hosts page: team assignment UI
4837
- PATCH /inventory/hosts/{id}/team is wired; add team picker in host detail view
4938

50-
7. [LOW] Hardening TLS — mTLS cert rotation + enforce cert validation on agent connections
39+
4. [LOW] Hardening TLS — mTLS cert rotation + enforce cert validation on agent connections
5140

5241
## Done
5342

54-
- fix(deps): browserslist 4.28.2 → 4.28.8 (alert #19, prototype pollution/DoS) — PR #39
43+
- fix(data): dispatch on_vulnerability_found for new high/critical CVEs (3 new tests) — this PR
44+
- feat(ui): Teams page — list/create/delete + member management (PR #41)
5545
- Windows WMI collector + 14 CIS Level-1 hardening checks (32 tests) — PR #31
46+
- Windows installer: install.ps1, uninstall.ps1, service.py (pywin32)
5647
- Team assignment API: PATCH hosts/{id}/team + networks/{id}/team — PR #30
57-
- Team-scoped data isolation (migration 0005 + model FKs + ?team_id= filtering) — PR #29
58-
- Vuln UI: CVE drill-down slide-over + team-scoped stats panel — PR #33, #34
59-
- fix(deps): python-jose → PyJWT, aiohttp 3.14.3, cryptography, react-router — PR #35
60-
- Dependabot auto-merge workflow + CI permissions fix (issues: write)
61-
- Multitenancy foundation: Teams + memberships CRUD (13 tests) — PR #24
62-
- Integration test suite (auth/vault/inventory flows, live PostgreSQL) — PR #23
63-
- Credential vault (AES-256-GCM), rate limiting, docs generator, agent auto-deploy — PR #19
64-
- RBAC multi-user system + audit log — PR #16
65-
- dkctl admin CLI + agent Docker — PR #15
66-
- Full test suite + GitHub Actions CI — PR #14
67-
- Ansible collector + Netmiko + Devices SPA — PR #13
68-
- CVE scanner + Networks/Topology SPA — PR #12
69-
- nmap + Alembic migrations + LDAP/AD module — PR #11
70-
- React SPA: all pages, server foundation, modules, agent, install scripts
48+
- Team-scoped data isolation (migration 0005) — PR #29
49+
- fix(deps): browserslist 4.28.8, python-jose → PyJWT, aiohttp 3.14.3 — PR #35, #39
50+
- Vuln UI: CVE drill-down + team-scoped stats — PR #34, #33
51+
- Multitenancy: Teams + memberships, CRUD API — PR #24
52+
- Integration tests, credential vault, rate limiting, docs gen, agent auto-deploy — PR #23, #19
53+
- RBAC, audit log, dkctl CLI, Docker agent — PR #16, #15
54+
- Full test suite + CI — PR #14
55+
- All server foundation, modules, agent, SPA pages

server/api/data.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
from server.models.network import Network, NetworkInterface, TopologyEdge, ScanResult
4949
from server.models.device import NetworkDevice
5050
from server.models.vulnerability import Vulnerability
51+
from server.modules.registry import registry
5152

5253
logger = logging.getLogger(__name__)
5354

@@ -494,6 +495,9 @@ async def ingest_vulnerabilities(
494495
upserted = 0
495496
errors = 0
496497

498+
# Collect (vuln, host) pairs for NEW vulns so we can fire alerts after commit.
499+
new_vulns: list[tuple[Vulnerability, Host]] = []
500+
497501
for rec in batch.vulnerabilities:
498502
try:
499503
host = await _resolve_host(db, rec.host_fqdn, rec.host_ip)
@@ -530,8 +534,9 @@ async def ingest_vulnerabilities(
530534
)
531535
)
532536
vuln = result.scalar_one_or_none()
537+
is_new = vuln is None
533538

534-
if vuln is None:
539+
if is_new:
535540
vuln = Vulnerability(
536541
host_id=host.id,
537542
package_id=package_id,
@@ -554,13 +559,28 @@ async def ingest_vulnerabilities(
554559
vuln.package_id = package_id
555560

556561
upserted += 1
562+
563+
# Queue alert dispatch for new high/critical vulns only.
564+
# We only fire for new discoveries to avoid alert storms on re-scans.
565+
if is_new and vuln.severity in ("critical", "high"):
566+
new_vulns.append((vuln, host))
567+
557568
except Exception:
558569
logger.exception("Failed to upsert vuln from agent %s", agent.id)
559570
errors += 1
560571

561572
await db.commit()
562573
logger.info("Vuln batch from agent %s: %d received, %d upserted, %d errors",
563574
agent.id, len(batch.vulnerabilities), upserted, errors)
575+
576+
# Dispatch alerts for newly discovered high/critical CVEs.
577+
# Runs after commit so the vuln rows have stable IDs.
578+
for vuln, host in new_vulns:
579+
try:
580+
await registry.dispatch_vulnerability_found(vuln, host, db)
581+
except Exception:
582+
logger.exception("Alert dispatch failed for %s on host %s", vuln.cve_id, host.id)
583+
564584
return IngestionResult(received=len(batch.vulnerabilities), upserted=upserted, errors=errors)
565585

566586

tests/test_data_ingestion.py

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,139 @@ async def test_inserts_new_cve(self) -> None:
337337
assert result.upserted == 1
338338
mock_db.add.assert_called_once()
339339

340+
@pytest.mark.asyncio
341+
async def test_dispatches_alert_for_new_critical_cve(self) -> None:
342+
"""New critical/high CVEs must trigger dispatch_vulnerability_found."""
343+
from server.api.data import ingest_vulnerabilities
344+
345+
agent = _make_agent()
346+
host = _make_host()
347+
mock_db = AsyncMock(spec=AsyncSession)
348+
349+
agent_result = MagicMock()
350+
agent_result.scalar_one_or_none.return_value = agent
351+
host_result = MagicMock()
352+
host_result.scalar_one_or_none.return_value = host
353+
no_vuln = MagicMock()
354+
no_vuln.scalar_one_or_none.return_value = None
355+
356+
mock_db.execute = AsyncMock(side_effect=[agent_result, host_result, no_vuln])
357+
mock_db.add = MagicMock()
358+
mock_db.commit = AsyncMock()
359+
360+
batch = VulnerabilityBatch(vulnerabilities=[
361+
VulnerabilityRecord(
362+
host_fqdn=host.fqdn,
363+
cve_id="CVE-2024-99999",
364+
severity="critical",
365+
cvss_score=9.9,
366+
)
367+
])
368+
369+
with patch("server.api.data.registry") as mock_registry:
370+
mock_registry.dispatch_vulnerability_found = AsyncMock()
371+
await ingest_vulnerabilities(
372+
batch,
373+
db=mock_db,
374+
x_agent_fingerprint=agent.certificate_fingerprint,
375+
x_agent_id=None,
376+
)
377+
378+
mock_registry.dispatch_vulnerability_found.assert_awaited_once()
379+
call_args = mock_registry.dispatch_vulnerability_found.call_args
380+
vuln_arg, host_arg, db_arg = call_args.args
381+
assert vuln_arg.cve_id == "CVE-2024-99999"
382+
assert host_arg is host
383+
384+
@pytest.mark.asyncio
385+
async def test_no_alert_dispatch_for_existing_cve(self) -> None:
386+
"""Re-scans of an already-known CVE must NOT fire a duplicate alert."""
387+
from server.api.data import ingest_vulnerabilities
388+
from server.models.vulnerability import Vulnerability
389+
390+
agent = _make_agent()
391+
host = _make_host()
392+
mock_db = AsyncMock(spec=AsyncSession)
393+
394+
existing_vuln = Vulnerability(
395+
id=uuid.uuid4(),
396+
host_id=host.id,
397+
cve_id="CVE-2024-11111",
398+
severity="critical",
399+
cvss_score=9.5,
400+
)
401+
402+
agent_result = MagicMock()
403+
agent_result.scalar_one_or_none.return_value = agent
404+
host_result = MagicMock()
405+
host_result.scalar_one_or_none.return_value = host
406+
existing_result = MagicMock()
407+
existing_result.scalar_one_or_none.return_value = existing_vuln
408+
409+
mock_db.execute = AsyncMock(side_effect=[agent_result, host_result, existing_result])
410+
mock_db.add = MagicMock()
411+
mock_db.commit = AsyncMock()
412+
413+
batch = VulnerabilityBatch(vulnerabilities=[
414+
VulnerabilityRecord(
415+
host_fqdn=host.fqdn,
416+
cve_id="CVE-2024-11111",
417+
severity="critical",
418+
cvss_score=9.5,
419+
)
420+
])
421+
422+
with patch("server.api.data.registry") as mock_registry:
423+
mock_registry.dispatch_vulnerability_found = AsyncMock()
424+
await ingest_vulnerabilities(
425+
batch,
426+
db=mock_db,
427+
x_agent_fingerprint=agent.certificate_fingerprint,
428+
x_agent_id=None,
429+
)
430+
431+
mock_registry.dispatch_vulnerability_found.assert_not_awaited()
432+
433+
@pytest.mark.asyncio
434+
async def test_no_alert_dispatch_for_low_severity_cve(self) -> None:
435+
"""Low/medium CVEs must not trigger an alert even when new."""
436+
from server.api.data import ingest_vulnerabilities
437+
438+
agent = _make_agent()
439+
host = _make_host()
440+
mock_db = AsyncMock(spec=AsyncSession)
441+
442+
agent_result = MagicMock()
443+
agent_result.scalar_one_or_none.return_value = agent
444+
host_result = MagicMock()
445+
host_result.scalar_one_or_none.return_value = host
446+
no_vuln = MagicMock()
447+
no_vuln.scalar_one_or_none.return_value = None
448+
449+
mock_db.execute = AsyncMock(side_effect=[agent_result, host_result, no_vuln])
450+
mock_db.add = MagicMock()
451+
mock_db.commit = AsyncMock()
452+
453+
batch = VulnerabilityBatch(vulnerabilities=[
454+
VulnerabilityRecord(
455+
host_fqdn=host.fqdn,
456+
cve_id="CVE-2024-55555",
457+
severity="low",
458+
cvss_score=2.1,
459+
)
460+
])
461+
462+
with patch("server.api.data.registry") as mock_registry:
463+
mock_registry.dispatch_vulnerability_found = AsyncMock()
464+
await ingest_vulnerabilities(
465+
batch,
466+
db=mock_db,
467+
x_agent_fingerprint=agent.certificate_fingerprint,
468+
x_agent_id=None,
469+
)
470+
471+
mock_registry.dispatch_vulnerability_found.assert_not_awaited()
472+
340473

341474
# ---------------------------------------------------------------------------
342475
# Scan results

0 commit comments

Comments
 (0)