Skip to content

Commit 2ad7349

Browse files
committed
Anchor dedup window at first alert and make prune non-critical
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.
1 parent 00ed6e5 commit 2ad7349

3 files changed

Lines changed: 241 additions & 111 deletions

File tree

integrations/vulnerability_email_dedup/README.md

Lines changed: 104 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -29,13 +29,17 @@ becomes hundreds of near-identical messages.
2929
This integration is a drop-in replacement for a `custom-email` script that
3030
collapses the burst:
3131

32-
- The first alert for a given agent, package and status sends one email.
33-
- Every subsequent CVE for that same agent and package inside the suppression
34-
window is recorded and counted, but no email is sent.
32+
- The first alert for a given agent, package and status sends one email and
33+
opens a 24 hour window.
34+
- Every subsequent CVE for that same agent and package inside the window is
35+
counted, but no email is sent.
36+
- The window is measured from that first alert and never slides. More alerts
37+
arriving inside it do not extend it and do not start a new one.
38+
- Once 24 hours have passed since the first alert, the next detection emails
39+
again and opens a fresh window, so a package that stays vulnerable stays
40+
visible without becoming noise.
3541
- Non-vulnerability alerts are passed through and emailed unchanged, so the
3642
script can also serve as the general email path.
37-
- The window expires (24 hours by default), so a package that is still
38-
vulnerable the next day notifies again and recurring problems stay visible.
3943

4044
How much noise this removes depends on how densely CVEs cluster per package. In
4145
a synthetic load test, 2,000 vulnerability alerts spread across 300 distinct
@@ -64,29 +68,41 @@ concurrent writers.
6468

6569
### How It Works
6670

71+
Each `(agent_id, package_name, status)` gets one row, anchored at `first_seen`:
72+
the moment its first alert arrived. Every alert resolves to one of three cases,
73+
decided entirely at read time:
74+
75+
| State of the row | Decision |
76+
| --- | --- |
77+
| No row exists | Send one email, create the row anchored at now. |
78+
| `now - first_seen >= 24h` | The window has elapsed. Send one email, re-anchor `first_seen` at now. |
79+
| Inside the window | Suppress. Increment the counter and **leave `first_seen` untouched**. |
80+
81+
That last line is the whole de-duplication rule. Because a suppressed alert
82+
never moves the anchor, no volume of alerts can push the window forward, which
83+
is the failure mode of a last-seen window: a package detected every few hours
84+
would slide its own window indefinitely and never notify again.
85+
86+
The package **version** is shown in the email body (read from the triggering
87+
alert, not persisted) but is deliberately kept out of the key, so a version bump
88+
that is still vulnerable does not re-notify. Move it into the key if you want
89+
per-version notifications.
90+
6791
`wazuh-integratord` runs an integration script **once per alert, as a separate
6892
OS process**. A burst of 1,000 alerts is therefore 1,000 short-lived processes,
69-
not one process handling 1,000 items. Two properties make that safe and fast:
93+
not one process handling 1,000 items. Three properties make that safe and cheap:
7094

71-
- **Cheap lookup.** Suppressed alerts hit one indexed SQLite lookup and exit in
72-
under a millisecond, without ever opening an SMTP connection. Only the winning
73-
alert pays the cost of sending mail.
95+
- **Cheap lookup.** Per alert the script does one indexed SQLite lookup and one
96+
small write. Suppressed alerts exit in under a millisecond, without ever
97+
opening an SMTP connection. Only the notifying alert pays for sending mail.
7498
- **Safe concurrency.** The store runs in WAL mode with a busy timeout, and the
7599
check-and-write happens inside a single `BEGIN IMMEDIATE` transaction.
76100
Concurrent processes for the same package serialize on the write lock and
77-
exactly one wins, so a burst produces no `database is locked` errors, no lost
78-
updates, and no duplicate emails.
79-
80-
The de-duplication key is `(agent_id, package_name, status)`. The package
81-
**version** is shown in the email body (read from the triggering alert, not
82-
persisted) but is deliberately kept out of the key, so a version bump that is
83-
still vulnerable does not re-notify. Move it into the key if you want
84-
per-version notifications.
85-
86-
The suppression window is anchored at the **first** notification rather than at
87-
the last alert seen. This matters: anchoring on the last alert would let a
88-
steady drip of CVEs slide the window forward indefinitely and silence a package
89-
forever.
101+
exactly one wins at each window boundary, so a burst produces no
102+
`database is locked` errors, no lost updates, and no duplicate emails.
103+
- **Nothing runs between alerts.** No daemon, no polling, no in-memory state.
104+
The table holds one row per currently active package and agent, so it stays in
105+
the kilobytes.
90106

91107
If the database cannot be opened or the de-duplication check raises, the script
92108
**fails open** and sends the email. Losing a notification is worse than sending
@@ -97,10 +113,10 @@ graph TD
97113
A[Vulnerability alert] --> B{Has package name?}
98114
B -- No --> C[Send email, stock behaviour]
99115
B -- Yes --> D[BEGIN IMMEDIATE on SQLite]
100-
D --> E{Key seen inside window?}
101-
E -- No --> F[Insert or reset row, COMMIT]
116+
D --> E{Row exists and<br/>within 24h of first_seen?}
117+
E -- No --> F[Insert or re-anchor<br/>first_seen = now, COMMIT]
102118
F --> G[Send one email]
103-
E -- Yes --> H[Increment cve_count, append CVE, COMMIT]
119+
E -- Yes --> H[Increment cve_count,<br/>first_seen unchanged, COMMIT]
104120
H --> I[Exit, no email]
105121
```
106122

@@ -110,14 +126,15 @@ graph TD
110126

111127
#### Using the Integration Files
112128

113-
This integration ships two files:
129+
This integration ships three files:
114130

115131
| File | Purpose |
116132
| --- | --- |
117133
| `custom-email.py` | The integration logic. |
118134
| `custom-email` | Standard Wazuh shell wrapper that invokes the script with the embedded Python interpreter. |
135+
| `test_dedup.py` | Self-check for the window logic. Not deployed to the manager. |
119136

120-
Copy both to the manager:
137+
Copy the two runtime files to the manager:
121138

122139
```bash
123140
cp custom-email custom-email.py /var/ossec/integrations/
@@ -146,8 +163,11 @@ redirects mail and storage into a sandbox without editing the file.
146163
| `RECEIVER_EMAIL` | `VULN_RECEIVER` | `soc@example.com` | Destination address. |
147164
| `DB_PATH` | `VULN_DEDUP_DB` | `/var/ossec/logs/vuln_dedup.db` | De-duplication store. |
148165
| `LOG_PATH` | `VULN_DEDUP_LOG` | `/var/ossec/logs/custom-email_integration.log` | Script log file. |
149-
| `DEDUP_TTL_SECONDS` | `VULN_DEDUP_TTL` | `86400` (24h) | Suppression window per agent, package and status. |
150-
| `RETENTION_SECONDS` | `VULN_RETENTION` | `604800` (7d) | Age at which `prune` deletes expired rows. |
166+
| `DEDUP_TTL_SECONDS` | `VULN_DEDUP_TTL` | `86400` (24h) | Window per agent, package and status, measured from the first alert. |
167+
168+
`DEDUP_TTL_SECONDS` is the only behavioural knob. Set it to `43200` for a 12
169+
hour window or `172800` for 48 hours; nothing else in the script needs changing,
170+
and the prune job picks up the new value automatically.
151171

152172
The script does not perform SMTP authentication or STARTTLS. If your relay
153173
requires either, extend `send_email()` with `server.starttls()` and
@@ -186,13 +206,21 @@ Notes on this block:
186206
#### Scheduled Maintenance
187207

188208
Cleanup is an explicit maintenance mode rather than opportunistic work on the
189-
alert hot path. `custom-email prune` deletes rows whose window expired more than
190-
`RETENTION_SECONDS` ago and runs a `VACUUM` to reclaim file space.
191-
192-
Keep this in perspective: the table is bounded by the number of **distinct**
193-
`(agent, package, status)` combinations, not by alert volume. One thousand CVEs
194-
for a single package is still one row. This is housekeeping, not a growth
195-
problem, and once a day is ample.
209+
alert hot path. `custom-email prune` deletes rows whose window has fully elapsed
210+
and runs a `VACUUM` to reclaim file space.
211+
212+
**Prune is not load-bearing.** Every de-duplication decision is made from
213+
`first_seen` when the alert arrives, and the read path already treats an elapsed
214+
row as "notify and re-anchor". So if prune runs late, or skips a day, or never
215+
runs at all, the only consequence is some rows for packages that went quiet
216+
sitting around until the next run. No missed emails, no duplicate emails. That
217+
also means the schedule needs no particular timing: no midnight pin, no
218+
alignment with scan windows.
219+
220+
Keep the growth in perspective: the table is bounded by the number of
221+
**distinct** `(agent, package, status)` combinations seen in the last window, not
222+
by alert volume. One thousand CVEs for a single package is still one row. Prune
223+
is one `DELETE` on an indexed column, so once a day is ample.
196224

197225
Since the script lives on the manager, schedule it with the manager-native
198226
`command` wodle rather than system cron. Add to `/var/ossec/etc/ossec.conf`:
@@ -263,7 +291,29 @@ seen package or records the CVE and exits silently.
263291

264292
### Integration Testing
265293

266-
#### Test 1: First alert for a package sends an email
294+
#### Test 1: Window logic self-check
295+
296+
Run the bundled self-check from the integration directory. It exercises the
297+
window rules against a temporary database, sends no mail, and needs nothing
298+
outside the standard library:
299+
300+
```bash
301+
python3 test_dedup.py
302+
```
303+
304+
Expected:
305+
306+
```
307+
OK: all de-duplication window checks passed
308+
```
309+
310+
It asserts the behaviours that would otherwise take a day of wall-clock time to
311+
observe: that a burst of 1,000 alerts for one package produces exactly one
312+
email with no lost counts, that a flood arriving inside the window neither
313+
notifies nor moves the anchor, that the next alert after the window elapses
314+
notifies and re-anchors, and that prune removes only fully elapsed rows.
315+
316+
#### Test 2: First alert for a package sends an email
267317

268318
Write a sample alert and invoke the script directly:
269319

@@ -293,7 +343,7 @@ Expected:
293343
2026-07-18T10:00:01 INFO Sent vuln alert package=openssl agent=001 cve=CVE-2026-0001
294344
```
295345

296-
#### Test 2: A second CVE for the same package is suppressed
346+
#### Test 3: A second CVE for the same package is suppressed
297347

298348
```bash
299349
sed 's/CVE-2026-0001/CVE-2026-0002/g' /tmp/alert1.json > /tmp/alert2.json
@@ -305,16 +355,19 @@ default `INFO` level. The row instead shows the incremented count:
305355

306356
```bash
307357
sqlite3 /var/ossec/logs/vuln_dedup.db \
308-
"SELECT agent_id, package, status, cve_count FROM dedup;"
358+
"SELECT agent_id, package, status, cve_count,
359+
CAST((strftime('%s','now') - first_seen) / 3600 AS INT) AS window_age_h
360+
FROM dedup;"
309361
```
310362

311-
Expected:
363+
Expected, with the anchor still showing zero hours of age because the second
364+
alert did not move it:
312365

313366
```
314-
001|openssl|Active|2
367+
001|openssl|Active|2|0
315368
```
316369

317-
#### Test 3: Burst behaviour and concurrency
370+
#### Test 4: Burst behaviour and concurrency
318371

319372
Generate a burst of alerts for a handful of packages and confirm that no update
320373
is lost. The invariant to check is that the sum of `cve_count` across all rows
@@ -330,17 +383,17 @@ sqlite3 /var/ossec/logs/vuln_dedup.db \
330383
"SELECT agent_id, package, status, cve_count FROM dedup ORDER BY cve_count DESC LIMIT 10;"
331384
```
332385

333-
#### Test 4: Maintenance mode
386+
#### Test 5: Maintenance mode
334387

335388
```bash
336389
sudo -u wazuh /var/ossec/integrations/custom-email prune
337390
tail -n 1 /var/ossec/logs/custom-email_integration.log
338391
```
339392

340-
Expected:
393+
Expected, since the rows just created are still inside their window:
341394

342395
```
343-
2026-07-18T10:05:00 INFO Prune complete: removed 0 expired row(s).
396+
2026-07-18T10:05:00 INFO Prune complete: removed 0 elapsed row(s).
344397
```
345398

346399
---
@@ -353,7 +406,8 @@ Expected:
353406
| `has write permissions` in `ossec.log` | Permissions are too broad. Re-apply `chmod 750` and `chown root:wazuh`. |
354407
| One email per CVE, no suppression | Alerts are not matching the expected schema. Confirm `data.vulnerability.package.name` is present in the raw alert. |
355408
| `DB open failed ... sending without dedup` | The database path is not writable by the `wazuh` user. Check `DB_PATH` and its parent directory. |
356-
| Emails stop for a package that is still vulnerable | Expected inside the window. Lower `DEDUP_TTL_SECONDS` if you want more frequent reminders. |
409+
| Emails stop for a package that is still vulnerable | Expected inside the window. Check `first_seen` for that row: one fresh email is sent on the first detection after it plus `DEDUP_TTL_SECONDS`. Lower the TTL if you want more frequent reminders. |
410+
| The dedup table is larger than expected | Prune has not run recently. It is housekeeping only, so this affects nothing but disk. Run `custom-email prune` and confirm the wodle is enabled. |
357411
| SMTP errors in the script log | Verify relay reachability with `nc -zv <SMTP_HOST> 25`. |
358412

359413
---
@@ -366,10 +420,10 @@ Expected:
366420
- **Adapted by**: Leon Fuller.
367421
- **Tested versions**: Wazuh manager 4.x with Vulnerability Detection enabled
368422
and the 4.8+ vulnerability alert schema. Python 3 standard library only, no
369-
third-party dependencies. Logic validated on Python 3.11 against a synthetic
370-
2,000-alert burst at 64-way concurrency: 300 distinct keys produced 300
371-
emails, `SUM(cve_count)` matched the 2,000 alerts fed in, and no lock errors
372-
occurred.
423+
third-party dependencies. Logic validated on Python 3.11 by the bundled
424+
`test_dedup.py`, plus a synthetic 2,000-alert burst at 64-way concurrency: 300
425+
distinct keys produced 300 emails, `SUM(cve_count)` matched the 2,000 alerts
426+
fed in, and no lock errors occurred.
373427
- **Maintainer**: Leon Fuller.
374428
- **Support boundary**: Community-maintained and provided as is. Not covered by
375429
Wazuh commercial support.

0 commit comments

Comments
 (0)