Add vulnerability email de-duplication integration - #103
Conversation
The Vulnerability Detection module emits one alert per (package, CVE) pair. A single package often carries dozens of CVEs, so one scan produces a burst of near-identical alerts for the same package on the same agent, which the stock email integration turns into hundreds of near-identical messages. This integration collapses the burst: the first alert for a given (agent, package, status) sends one email, and every subsequent CVE for that same key inside the suppression window is counted but not mailed. Non-vulnerability alerts pass through unchanged. State is kept in a SQLite store in WAL mode, with the check-and-write done in a single BEGIN IMMEDIATE transaction so the ~1000 concurrent integratord processes of a burst serialize correctly without lost updates or lock errors. Suppressed alerts exit on one indexed lookup and never open an SMTP connection. The suppression window is anchored at the first notification rather than the last alert seen, so a steady drip of CVEs cannot postpone the window indefinitely and silence a package. Cleanup is an explicit prune mode driven by a command wodle, keeping maintenance off the alert hot path.
There was a problem hiding this comment.
Pull request overview
Adds a new Wazuh integration (integrations/vulnerability_email_dedup/) that acts as a drop-in custom-email replacement to suppress duplicate Vulnerability Detection emails by de-duplicating on (agent_id, package, status) within a fixed time window, while still passing non-vulnerability alerts through unchanged.
Changes:
- Introduces a SQLite/WAL-backed de-duplication store to suppress duplicate vulnerability emails during bursts.
- Adds a
prunemaintenance mode intended to be run via a scheduled Wazuhcommandwodle. - Provides installation/configuration/testing documentation and a standard Wazuh shell wrapper.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 6 comments.
| File | Description |
|---|---|
| integrations/vulnerability_email_dedup/README.md | Documents the integration behavior, configuration, ossec.conf wiring, and testing/maintenance steps. |
| integrations/vulnerability_email_dedup/custom-email.py | Implements per-alert de-duplication logic with SQLite WAL and a prune maintenance entry point. |
| integrations/vulnerability_email_dedup/custom-email | Provides the standard Wazuh wrapper to invoke the Python script via the embedded interpreter. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- Fall back to stderr logging when LOG_PATH is not writable, so a logging problem cannot stop the integration from sending mail. - Exit non-zero on prune failure and on email send failure, so failures are visible to the scheduler / integrator instead of appearing successful. - Clarify (docstring + README) that the package version is read from the triggering alert for the email body and is not persisted.
- Strip CR/LF from email header values (the subject is built from alert-derived package/agent names) to prevent header injection and send-time crashes. - Close the SQLite connection if a PRAGMA/DDL fails during open_db, so a failed init does not leak the handle. - Add an explicit no-args usage check for a clearer log message (the IndexError was already caught by the existing handler; this only improves diagnostics).
The suppression window is now decided entirely from first_seen at read time: no row or a fully elapsed row notifies and (re-)anchors the window at now, while an alert inside the window is counted without touching the anchor. A flood of alerts can no longer slide the window forward and silence a package. Because the read path already re-notifies past an elapsed window, prune is pure housekeeping. It drops rows on the same window and needs no particular schedule, so RETENTION_SECONDS goes away and DEDUP_TTL_SECONDS is the only knob left. Also drop the per-row CVE list. It cost a JSON parse and dump on the suppression hot path with no consumer, and the CVEs are already in alerts.json and the indexer. Add test_dedup.py, covering one email per key under a 1000-alert burst, no window slide while alerts keep arriving, a fresh email plus re-anchor once the window elapses, and prune taking only elapsed rows.
Drop test_dedup.py from the integration folder: the deliverable is the script and its documentation. The window rules it asserted are now a documented manual procedure instead, ageing first_seen with sqlite3 so both the no-slide and the rollover behaviour can be confirmed in seconds rather than over a day. Also round the window_age_h helper query, which truncated a 23 hour old anchor to 22.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (3)
integrations/vulnerability_email_dedup/custom-email.py:159
prune_old_rows()starts an IMMEDIATE transaction but doesn’t roll it back on exceptions. If the DELETE or COMMIT fails, the connection can keep a write lock until process exit, which can block alert-path writers unnecessarily.
conn.execute("BEGIN IMMEDIATE")
cur = conn.execute("DELETE FROM dedup WHERE first_seen < ?",
(time.time() - DEDUP_TTL_SECONDS,))
deleted = cur.rowcount
conn.execute("COMMIT")
integrations/vulnerability_email_dedup/custom-email.py:209
- In prune mode, if
open_db()orprune_old_rows()raises, the SQLite connection isn’t closed (becauseconn.close()is inside the happy path). This can leave a write lock held longer than necessary and also leaks the file descriptor until process exit.
if len(sys.argv) > 1 and sys.argv[1] == 'prune':
try:
conn = open_db()
deleted = prune_old_rows(conn, vacuum=True)
conn.close()
logging.info("Prune complete: removed %d elapsed row(s).", deleted)
integrations/vulnerability_email_dedup/custom-email.py:183
smtplib.SMTP()is created without a timeout, so a stalled TCP connect or hung SMTP session can block the per-alert process indefinitely. Sincewazuh-integratordcan spawn many processes during bursts, this can cause process pileups when the relay is down/unresponsive.
with smtplib.SMTP(SMTP_SERVER, SMTP_PORT) as server:
server.send_message(msg)
Summary
Adds
integrations/vulnerability_email_dedup/, a drop-in replacement for acustom-emailintegration that suppresses duplicate vulnerability notifications.The Vulnerability Detection module emits one alert per (package, CVE) pair. A single package often carries dozens of CVEs, so one scan produces a burst of near-identical alerts for the same package on the same agent. Routed to email through the stock integration, that burst becomes hundreds of near-identical messages.
This integration sends one email per affected package per agent instead of one per CVE. Every subsequent CVE for the same key inside the window is counted, but not mailed. Non-vulnerability alerts pass through unchanged.
De-duplication model
Each
(agent_id, package_name, status)gets one row anchored atfirst_seen, the moment its first alert arrived. Every alert resolves to one of three cases, decided entirely at read time:now - first_seen >= 24hfirst_seenat now.first_seenuntouched.That last line is the whole rule. A suppressed alert never moves the anchor, so no volume of alerts can push the window forward. This is the failure mode of a last-seen window, where a package detected every few hours slides its own window indefinitely and never notifies again.
DEDUP_TTL_SECONDSis the only behavioural knob, so a 12h or 48h window is a one-line change.Design notes
wazuh-integratordruns the script once per alert as a separate process, so a 1000-alert burst is 1000 short-lived processes rather than one process handling 1000 items. Correctness under the burst comes from the storage layer, not from concurrency inside the script.smtplibis imported lazily so the suppression path does not pay the import cost. Nothing runs between alerts: no daemon, no polling, no in-memory state. The table holds one row per currently active package and agent, so it stays in the kilobytes.BEGIN IMMEDIATEtransaction, so concurrent processes for the same key serialize on the write lock and exactly one wins at each window boundary.first_seenat read time and an elapsed row is already treated as "notify and re-anchor", prune only reclaims space. If it runs late, skips a day, or never runs, the only consequence is stale rows sitting around until the next run: no missed emails, no duplicates. The wodle therefore needs no particular timing, so no midnight pin and no alignment with scan windows.Files
custom-email.py- integration logic, Python standard library only.custom-email- standard Wazuh shell wrapper for the embedded interpreter.README.md- installation, configuration table,ossec.confblocks for both the integration and the prune wodle, testing steps, troubleshooting, provenance.Testing
The window rules were verified by ageing a row's
first_seenwithsqlite3, which confirms in seconds what would otherwise take a day of wall-clock time. The README documents the procedure:window_age_hat 23. The anchor does not move, so replaying alerts can never push the window forward.Also verified end to end on Python 3.11 as real process invocations: the first vulnerability alert attempts one send, two further CVEs for the same key exit silently with the counter at 3, a non-vulnerability alert is emailed unchanged, invocation with no arguments exits 1 with a usage error, and
prunereports cleanly.Earlier load test of the storage layer, unchanged by this revision: 2,000 alerts across 300 distinct
(agent, package, status)combinations at 64-way concurrency produced 300 emails, one per distinct key, an 85% reduction.SUM(cve_count)equalled 2,000 and no lock errors occurred, at roughly 665 alerts/sec.Not yet exercised against a live vulnerability scan on a production manager; the
ossec.confwiring in the README is the documented path for that.