taco-alert
Alerting service. Consumes enriched findings from RabbitMQ, evaluates rule-based alert conditions, and dispatches notifications to configured channels.
Alerting service. Consumes enriched findings from RabbitMQ, evaluates rule-based alert conditions, and dispatches notifications to configured channels.
Overview
| Property | Value |
|---|---|
| Language | Go |
| Framework | Standard library HTTP |
| Port | 8083 (health check + API) |
| Exposed | Internal only (127.0.0.1:8083) |
| Dependencies | MySQL, RabbitMQ |
| Repository | tacosec/taco-alert |
Architecture
taco-enricher taco-alert
+------------------+ +---------------------------+
| enrichment | | consumer/ |
| worker | | RabbitMQ consumer |
| | | (reconnect, DLX, QoS) |
| publisher/ | +----------+----------------+
| PublishEnrich | |
| mentComplete() | --[RabbitMQ]---> v
+------------------+ exchange: +-----+-----+
taco.findings | alerter/ |
routing key: | |
enrichment | - ProcessEvent()
.complete | - Rule evaluation
| - Severity filter
| - Dedup check
| - Dispatch
+-----+-----+
|
+-------+--------+--------+--------+
| | | | |
Email Slack Discord Webhook PagerDuty
(Mailgun)
Data Flow
- taco-enricher enriches findings with EPSS, CVSS, CISA KEV, and risk scores.
- After enrichment,
publisher.PublishEnrichmentComplete()groups findings by project and publishes oneEnrichmentEventper project totaco.findingsexchange with routing keyenrichment.complete. - taco-alert consumes from
taco.findings.alertqueue (bound tofinding.newandenrichment.complete). - Each event goes through
alerter.ProcessEvent(): rate limiting, config lookup, severity filtering, deduplication, rule evaluation, dispatch, and history recording.
RabbitMQ Topology
| Component | Value |
|---|---|
| Exchange | taco.findings (topic) |
| Queue | taco.findings.alert (durable) |
| Routing keys bound | finding.new, enrichment.complete |
| Dead-letter exchange | taco.findings.dlx (fanout) |
| Prefetch | 1 message |
| Max retries | 3 (then dead-lettered) |
Key Packages
| Package | Purpose |
|---|---|
alerter/ | Core alert logic: rule evaluation, severity filtering, dedup, dispatch, rate limiting, history |
alerter/rules.go | Alert rule definitions and evaluation engine |
alerter/config.go | Alert configuration model and MySQL queries |
alerter/history.go | Alert history persistence and dedup checks |
consumer/ | RabbitMQ consumer with reconnection, DLX, and retry logic |
handler/ | HTTP API: alert config CRUD, history, stats, test alerts |
notify/ | Channel implementations: Email, Slack, Discord, Webhook, PagerDuty |
config/ | Environment variable loading |
migrations/ | MySQL schema migrations |
Rule Engine
Built-in Rules
Defined in alerter/rules.go. Each rule has an ID, name, severity, and Evaluate(EnrichedFinding) bool.
| Rule ID | Name | Severity | Condition |
|---|---|---|---|
kev_critical | Known Exploited Vulnerability | critical | KnownExploited == true |
critical_risk_score | Critical Risk Score | critical | RiskScore >= 9.5 |
high_epss | High Exploit Probability | high | EpssScore >= 0.20 |
no_fix_available | No Fix Available | medium | FixedVersion == "" AND severity >= high |
sla_breach | SLA Breach | high | Age exceeds SLA (critical: 3d, high: 7d, medium: 30d) |
regression | Vulnerability Regression | high | IsRegression == true |
Evaluation Flow
gotriggered := EvaluateRules(a.rules, finding)
if len(triggered) == 0 {
continue // no rules matched, skip
}
EvaluateRules() runs every rule against the finding and returns matched rules. HighestRuleSeverity() determines overall alert severity.
How to Add a New Rule
- Add entry to
DefaultRules()inalerter/rules.go. - If needed, add fields to
EnrichedFindinginalerter/alerter.go. - Run
go build ./...andgo vet ./.... - Add test in
alerter/alerter_test.go.
Notification Channels
| Channel | File | Transport | Key Details |
|---|---|---|---|
notify/email.go | Mailgun API | HTML + plain-text, recipient list from JSON | |
| Slack | notify/slack.go | Incoming webhook | Block Kit, severity emoji, max 10 findings |
| Discord | notify/discord.go | Webhook | Rich embeds, color-coded severity |
| Webhook | notify/webhook.go | HTTP POST | HMAC-SHA256 signing via X-TACO-Signature |
| PagerDuty | notify/pagerduty.go | Events API v2 | Dedup key: taco-{project_id}-{event_id} |
How to Add a New Channel
- Create sender in
notify/(e.g.,notify/teams.go). - Add to
Notifierstruct innotify/notifier.go. - Add interface method in
alerter/alerter.go. - Add config fields in
alerter/config.goandhandler/handler.go. - Add migration for
alert_configscolumns. - Add dispatch logic in
alerter.dispatch(). - Update test handler in
handler/handler.go.
Alert Processing Pipeline
- Unmarshal -- Deserialize RabbitMQ message into
EnrichmentEvent. - Rate limit -- Max one alert per project per 5 minutes (requeue if limited).
- Config lookup -- Query
alert_configsfor project, fall back to default (project_id IS NULL). - Per-finding loop:
- Skip
acceptedorsuppressedfindings. - Skip below
cfg.MinSeverity(low=1, medium=2, high=3, critical=4). - Dedup via
{project_id}:{cve_id}:{component_name}. - Check regression (was CVE previously resolved?).
- Evaluate all 6 rules. Skip if none trigger.
- Skip
- Dispatch -- Send to all enabled channels. Failures logged, not blocking.
- Record -- Insert per-finding rows into
alert_history. - Update rate limit -- Record alert time for project.
Database Schema
alert_configs
Per-project configuration. project_id IS NULL is the default fallback.
sqlCREATE TABLE alert_configs (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
project_id BIGINT UNSIGNED NULL,
min_severity ENUM('low','medium','high','critical') NOT NULL DEFAULT 'high',
email_enabled BOOLEAN NOT NULL DEFAULT FALSE,
email_to JSON NULL,
slack_enabled BOOLEAN NOT NULL DEFAULT FALSE,
slack_webhook VARCHAR(512) NULL,
webhook_enabled BOOLEAN NOT NULL DEFAULT FALSE,
webhook_url VARCHAR(512) NULL,
webhook_secret VARCHAR(255) NULL,
pagerduty_enabled BOOLEAN NOT NULL DEFAULT FALSE,
pagerduty_routing_key VARCHAR(255) NULL,
discord_enabled BOOLEAN NOT NULL DEFAULT FALSE,
discord_webhook VARCHAR(512) NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE INDEX idx_project_id (project_id)
);
alert_history
Deduplication (via alert_key) and audit trail.
sqlCREATE TABLE alert_history (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
alert_key VARCHAR(255) NULL,
event_id VARCHAR(36) NOT NULL,
project_id BIGINT UNSIGNED NOT NULL,
project_name VARCHAR(255) NOT NULL,
cve_id VARCHAR(50) NULL,
component_name VARCHAR(255) NULL,
severity VARCHAR(20) NULL,
rules_triggered JSON NULL,
finding_count INT UNSIGNED NOT NULL,
channels_used JSON NOT NULL,
sent_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_project_id (project_id),
INDEX idx_event_id (event_id),
INDEX idx_alert_key (alert_key),
INDEX idx_cve_id (cve_id)
);
Migrations
| # | File | Description |
|---|---|---|
| 001 | 001_create_alert_configs.sql | Creates alert_configs with email and Slack |
| 002 | 002_create_alert_history.sql | Creates alert_history with event-level tracking |
| 003 | 003_add_alert_channels.sql | Adds webhook, PagerDuty, Discord to alert_configs |
| 004 | 004_enhance_alert_history.sql | Adds alert_key, cve_id, component_name, severity, rules_triggered |
HTTP API (taco-alert, port 8083)
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/v1/projects/{id}/alert-config | Get config (?fallback=true for default) |
| PUT | /api/v1/projects/{id}/alert-config | Upsert config |
| DELETE | /api/v1/projects/{id}/alert-config | Delete (revert to default) |
| POST | /api/v1/projects/{id}/alert-config/test | Test all enabled channels |
| GET | /api/v1/projects/{id}/alerts | Paginated history (?page=&per_page=&severity=&cve_id=) |
| GET | /api/v1/alerts/stats | Global stats (by_severity, last_24h/7d/30d) |
| GET | /healthz | Health check (MySQL + RabbitMQ) |
taco-api Alert Endpoints
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/v1/projects/{id}/alerts/config | Get config (frontend) |
| PUT | /api/v1/projects/{id}/alerts/config | Update config (frontend) |
| GET | /api/v1/projects/{id}/alerts/webhook | Effective Slack webhook (project > subscription > account) |
Enrichment Event Format
json{
"event_id": "uuid",
"event_type": "enrichment.complete",
"timestamp": "2025-01-15T10:00:00Z",
"project_id": 123,
"project_name": "",
"findings": [{
"cve_id": "CVE-2024-1234",
"severity": "critical",
"component_name": "lodash",
"component_version": "4.17.20",
"component_purl": "pkg:npm/lodash@4.17.20",
"fixed_version": "4.17.21",
"cvss_score": 9.8,
"epss_score": 0.45,
"risk_score": 9.8,
"known_exploited": true,
"first_detected_at": "2025-01-10T08:00:00Z",
"resolved_at": null,
"project_status": "open"
}]
}
Note: project_name is empty because the enricher does not have project names.
Docker Compose Configuration
yamltaco-alert:
image: ghcr.io/tacosec/taco-alert:latest
ports: ["127.0.0.1:8083:8083"]
environment:
RABBITMQ_URL: amqp://${RABBITMQ_USER:-taco}:${RABBITMQ_PASS:-taco-dev-mq}@rabbitmq:5672/
MYSQL_DSN: ${MYSQL_USER:-taco}:${MYSQL_PASSWORD:-taco}@tcp(mysql:3306)/taco
MAILGUN_DOMAIN: ${MAILGUN_DOMAIN}
MAILGUN_API_KEY: ${MAILGUN_API_KEY}
MAILGUN_FROM: ${MAILGUN_FROM}
HEALTH_PORT: "8083"
taco-enricher:
image: ghcr.io/tacosec/taco-enricher:latest
environment:
MYSQL_DSN: "${MYSQL_USER:-taco}:${MYSQL_PASSWORD:-taco}@tcp(mysql:3306)/taco?parseTime=true"
TACO_NVD_API_KEY: ${TACO_NVD_API_KEY:-}
RABBITMQ_URL: amqp://${RABBITMQ_USER:-taco}:${RABBITMQ_PASS:-taco-dev-mq}@rabbitmq:5672/
Important: The enricher's RABBITMQ_URL must be set for alert integration. If missing, enrichment works but no events are published to taco-alert.
Environment Variables
| Variable | Required | Default | Description |
|---|---|---|---|
RABBITMQ_URL | Yes | -- | RabbitMQ AMQP URL |
MYSQL_DSN | Yes | -- | MySQL connection string |
MAILGUN_DOMAIN | No | -- | Mailgun sending domain |
MAILGUN_API_KEY | No | -- | Mailgun API key |
MAILGUN_FROM | No | alerts@taco.dev | Mailgun sender address |
HEALTH_PORT | No | 8083 | Health check and API HTTP port |
Health Check
GET /healthz -- Returns 200 OK when MySQL and RabbitMQ are reachable. Returns 503 with detail if either is down.