Adding a Slack integration to FAVE: notes from the rebuild
Summary
I pulled FAVE off the shelf last month. The 2021 introduction post described what the tool does: take a target’s detected software-version stack and return a prioritised list of applicable CVEs by querying NVD and adjacent sources. The output layer in the 2021 build was a CLI table and a JSON dump. That worked for a single-target run, but it never fit how I use the tool now, which is unattended across a small queue of assessments with results that need to land somewhere a teammate can read at the same time.
This post documents the Slack integration I added during the rebuild. I am writing it as a tooling note rather than a tutorial. If you maintain a recon tool with a similar output gap, the design decisions and the failure modes are likely the same shape as the ones I ran into.
Why I rebuilt this part now
Two reasons. The first is that the assessment workload has changed. In 2021 I ran FAVE manually, read the output table on the terminal where I had launched it, and pasted the relevant lines into whatever scratchpad held the engagement notes. In 2026 the queue runs unattended on a small VPS overnight and surfaces results into the morning. A terminal dump is no longer useful.
The second reason is downstream. As I wrote in the May cadence retrospective, the value of a CVE record in 2026 is increasingly tied to whether it lands in front of someone who can act on it within the same week. The bottleneck for me was not the CVE lookup itself, it was the gap between the lookup completing and the operator seeing the result. The Slack integration closes that gap.
What I wanted from the integration
Five requirements, in priority order:
- One channel per engagement. Each FAVE run is parameterised with an engagement tag. The integration must post into the channel for that tag, not into a single firehose.
- No duplicate alerts. If a CVE was already posted for a given target during the engagement, do not post it again on the next nightly run.
- Severity-aware formatting. A CVSS 9.8 record needs to look different from a CVSS 4.3 record at a glance.
- Linkbacks for everything. Every CVE in the post must link to its NVD record. Every host must link to the source FAVE scan summary file.
- Rate-limit-aware. A run with 200 new findings should not get the integration banned by Slack’s tier-1 rate limiter on the first attempt.
These map directly onto the implementation choices below.
Architecture
FAVE’s output layer used to be a single Reporter class that wrote to stdout and to a JSON file. The rebuild keeps that class for the JSON-file path but adds two new classes: SlackPublisher and AlertState. The flow is:
[ FAVE scan core ]
|
v
[ Reporter (json) ] --> /var/lib/fave/runs/<run-id>/results.json
|
v
[ SlackPublisher.from_run(run-id) ]
|
| reads: writes:
| AlertState AlertState
| results.json Slack webhook
v
[ Per-target Slack message batch ]
AlertState is a small SQLite file at /var/lib/fave/state/alerts.sqlite with one table:
CREATE TABLE posted_alerts (
engagement TEXT NOT NULL,
target TEXT NOT NULL,
cve TEXT NOT NULL,
first_posted TEXT NOT NULL,
PRIMARY KEY (engagement, target, cve)
);
That is the entire deduplication mechanism. A row is inserted the first time a (engagement, target, cve) triple gets posted, and subsequent runs that produce the same triple skip the post. The decision to make this engagement-scoped rather than global is intentional: across engagements, the same CVE is a fresh signal, because the operator who reads the message has different context.
Implementation
FAVE is Python 3.11. I considered using the official slack-sdk package but ended up with a thin requests-based wrapper, because the integration only needs one Slack endpoint: an incoming webhook. The advantages of slack-sdk (token rotation, Block Kit type hints, OAuth helpers) do not apply when you are using a webhook URL.
The webhook config lives in /etc/fave/slack.toml:
[engagement.acme-q2-2026]
webhook_url = "https://hooks.slack.com/services/T0XXX/B0YYY/abc123..."
channel_name = "#sec-acme-q2"
severity_threshold = 7.0
[engagement.internal-recon]
webhook_url = "https://hooks.slack.com/services/T0XXX/B0ZZZ/def456..."
channel_name = "#sec-internal-recon"
severity_threshold = 4.0
The severity_threshold is a per-engagement floor on the CVSS base score below which findings are written to the JSON file but not posted to Slack. For the internal-recon engagement, the operator wants medium-and-up. For acme-q2-2026, the operator wants high-and-critical only and reviews the JSON output separately for the rest.
The publisher itself is short. The relevant body is:
class SlackPublisher:
def __init__(self, engagement_cfg, state: AlertState):
self.cfg = engagement_cfg
self.state = state
def publish_findings(self, run_id: str, findings: list[Finding]):
new_findings = [
f for f in findings
if f.cvss_base >= self.cfg.severity_threshold
and not self.state.already_posted(
self.cfg.engagement, f.target, f.cve
)
]
if not new_findings:
return
for batch in chunked(new_findings, 25):
payload = self._build_block_kit(batch, run_id)
self._post_with_retry(payload)
for f in batch:
self.state.mark_posted(
self.cfg.engagement, f.target, f.cve
)
Three things in this code are non-obvious and worth calling out.
Batching
The Slack message-size limit for an incoming webhook is 40,000 characters of payload. A single Block Kit message can carry roughly 50 sections before it gets unwieldy. I batch in chunks of 25 to leave headroom and keep each posted message readable. The chunking is a chunked() helper that yields fixed-size slices of an iterable.
State write only on success
The state.mark_posted call happens after the post returns 2xx. If the post fails, the state is not written, and the next FAVE run will retry the same finding. This is the correct posture for an alerting integration: a finding that has not actually surfaced to the operator should be retried, not silently marked as delivered.
Retry with backoff
_post_with_retry uses an exponential backoff with jitter (0.5s, 1s, 2s, 4s, 8s) for transient errors (HTTP 5xx, connection-reset). On a 429 Retry-After, it sleeps for the duration the server requests and then continues. On any 4xx other than 429, it gives up and raises, because a 400 from Slack means the payload is malformed and retrying will not help.
Message formatting
I use Slack Block Kit rather than plain text. The relevant blocks per finding are a Header (target hostname), a Section with severity-coloured indicator, a divider, and a context block with the source file link.
A rendered finding looks like:
─────────────────────────────────────────
acme-app01.internal
─────────────────────────────────────────
CVE-2024-39882 CRITICAL CVSS 9.8
Authentication bypass in Acme Web v3.1.2
→ NVD record
─────────────────────────────────────────
Run: fave-2026-05-16-acme-q2 · results
The severity tag is a unicode emoji-free indicator. Slack messages do not need emojis to be readable, and operator feedback on the first pass was that emoji-heavy alerts produce inconsistent rendering across desktop and mobile clients. Critical findings get a leading ■ and [CRITICAL] text label. The colour comes from the Block Kit attachments.color field on the surrounding container, which Slack renders as a left-edge bar. Red for critical (CVSS >= 9.0), orange for high (>= 7.0), grey otherwise.
The “results” link goes to a per-run static HTML report that FAVE writes alongside the JSON file. This is the same change the cadence post argued for at the institutional level: an alert is more useful when there is a primary-source artefact one click away.
Rate-limit handling
Slack’s rate-limit documentation is precise: webhook integrations are tier-1, which is “one message per second per webhook, with short bursts allowed”. The Retry-After header on a 429 is the authoritative source for how long to back off.
I tested this with an artificial 200-finding run against a private webhook. With batching at 25 per message, that is 8 messages, and the integration completes in well under 30 seconds without ever hitting 429. The retry path exists for unexpected upstream issues, not for the well-behaved case.
For larger runs, batching at 25 with the 1s pacing gives a clean linear scale. A 1,000-finding run would take ~40 messages, ~40 seconds. I have not yet had a run that produces 1,000 unique findings post-deduplication, so the upper bound has not been stress-tested in production conditions.
What did not work on the first pass
Three things I tried that did not survive review.
Posting findings as threaded replies
The first design used a parent “run summary” message and posted each finding as a threaded reply. The intent was a tidy Slack channel where each FAVE run was one thread the operator could collapse.
The problem is that incoming webhooks cannot post to a thread. They can only post into a channel. Threaded replies require either a bot token (chat.postMessage with thread_ts) or a Slack app. Neither is appropriate for this use case, since the webhook URL is the only artefact the engagement-channel admin gives the integration.
I went back to flat-channel posts, which is what works with a webhook.
Embedding CVSS vector strings as full blocks
I tried rendering the CVSS vector string (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H) as a stand-alone Block Kit section per finding, with each component labelled. This was visually correct but doubled the message length and pushed the 25-per-batch chunking down to 12. Operators told me they did not read past the base score on most findings and would click through to NVD for the vector when they needed it.
The vector is now in a single line in the context block at the bottom of each finding, not a stand-alone block. Operators get the score prominently and the vector available without claiming the visual budget.
Detecting “channel paused” status
I wanted the integration to detect when the engagement channel was archived or the webhook had been revoked, and degrade gracefully (write a warning to a log and stop trying for that engagement). Slack webhooks return a 404 when the URL is no longer valid, but they also return a 404 in some transient cases, and I did not find a reliable signal that distinguished permanent revocation from a transient issue.
The current behaviour is: three consecutive 404s for a given webhook causes the integration to log a CHANNEL_PAUSED event for that engagement, but FAVE keeps trying on the next run. The decision to actually pause needs an operator. Automating it would have produced false-negative pauses that cause findings to silently stop reaching the channel.
Status
The Slack integration is in the FAVE main branch. It is enabled by default if /etc/fave/slack.toml exists. The 2021 FAVE introduction post is still the canonical reference for what the tool does at the scan-and-correlate level; this post is the canonical reference for the output layer in 2026.
If you run a similar tool and want to compare notes on the deduplication state shape or the Block Kit chunking decisions, the FAVE repo issues page is the right place.
What is next
Two follow-ups are already in scope.
The first is a Block Kit version of the per-run static HTML report that the “results” link currently goes to. The HTML report is a server-rendered page; the next iteration will let the operator open the same report inside a Slack canvas, which removes a context switch that several operators have flagged.
The second is a webhook-signature-verification path so FAVE can accept inbound Slack events (a slash command, for example, that re-queries a host from inside the channel). That is a larger change because it requires FAVE to expose an HTTP endpoint to Slack, and the threat model on that endpoint is not the same as the threat model on an outbound webhook. The current scope of this post is the outbound side only.
The cadence between this rebuild and the next post will be roughly four weeks, matching the cadence in the publishing notes.