-
Notifications
You must be signed in to change notification settings - Fork 1
Notifier
Webhook fan-out for deviations. Per-run-summary semantics: when the Differ writes ≥1 deviation for a run, one webhook fires per configured + enabled target.
Source: internal/orchestrator/notifier/.
Three template kinds:
| Template | Payload shape | HMAC support |
|---|---|---|
slack |
Slack Block Kit card + mobile-friendly text fallback |
URL is the secret (HMAC ignored) |
discord |
Discord webhook embed + color-by-severity | URL is the secret (HMAC ignored) |
generic |
FANGS-native JSON envelope | HMAC opt-in via secret_env
|
// In api.Server.scheduleDiffer, after the Differ writes deviations:
if s.notifier != nil && err == nil && n > 0 {
notifyCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
s.notifier.Trigger(notifyCtx, runID)
}Trigger:
- Loads run + deviations + every enabled notifier.
- Computes the run's max severity across all deviations.
- For each target where
target.MinSeverity == "" || severityRank(maxSev) >= severityRank(target.MinSeverity): spawns a goroutine runningdeliverWithRetry. The goroutine detaches from the request context so a slow retry loop isn't canceled when the calling differ returns.
Per-target retry inside deliverWithRetry:
attempts: 5 max
backoff: 1s × 2^(n-1) with ±25% jitter
classify: 2xx → sent; 4xx (non-408/429) → permanent;
5xx + network/timeout → retry
Each attempt writes a notifications row with:
-
attempt(1..N) -
status(queued|sent|failed|permanent) -
last_attempted_at,next_attempt_at(when retrying) -
response_code,response_body(truncated to 2048 bytes) -
error_msg(network/timeout, never the response body) deviation_count
fangs notifier history -run <id> reads this table.
Only https:// is accepted in production. http:// is rejected
unless the host resolves to 127.0.0.1, localhost, or ::1 —
loopback exemption exists so fangs notifier test works against a
local mock listener during development.
Per-target opt-in via secret_env: ENV_NAME on the target row. When
set on a generic target:
X-FANGS-Signature: sha256=<hex>
Where hex = HMAC-SHA256(os.Getenv(ENV_NAME), body).
The receiver verifies with the same secret + algorithm. Stale-body attacks are mitigated at the receiver layer (timestamp checks); FANGS doesn't embed a timestamp in the signature payload today.
Slack + Discord skip HMAC even when secret_env is set — those
services use the URL itself as the shared secret (a leaked Slack URL
lets anyone post; a leaked Discord URL same). Adding an HMAC over the
body wouldn't help anyone — the receiving service doesn't verify it.
Optional headers: {"X-API-Key": "..."} JSON object on the target.
Headers get merged into every request (lowest priority — Content-Type
and User-Agent are set first and not overridable).
{
"text": "🚨 FANGS deviation: lodash@4.18.9 — 3 findings (max: high)",
"blocks": [
{
"type": "header",
"text": {"type": "plain_text", "text": "🚨 FANGS deviation: lodash@4.18.9 — 3 findings (max: high)", "emoji": true}
},
{
"type": "section",
"text": {"type": "mrkdwn", "text": "Run `18b2089cca…` · package `lodash` · version `4.18.9`"}
},
{
"type": "section",
"text": {"type": "mrkdwn", "text": "• *high* `/root/.ssh/id_rsa` — fs_new_path_read\n• *medium* `1.1.1.1:31337` — net_new_destination\n• *medium* `exfil.example` — net_new_https_host"}
}
]
}Caps body lines at 12; surplus shown as _… and N more_. Slack
messages have a 40k char limit but in practice 10-15 deviations is the
realistic ceiling per run.
{
"username": "FANGS",
"embeds": [
{
"title": "FANGS deviation: lodash@4.18.9",
"description": "**3 findings** (max severity: **high**)\nRun `18b2089cca…`",
"color": 14443613,
"fields": [
{"name": "high · fs_new_path_read", "value": "`/root/.ssh/id_rsa`", "inline": false},
{"name": "medium · net_new_destination", "value": "`1.1.1.1:31337`", "inline": false},
...
]
}
]
}Color picked per max severity:
- critical →
0xE3635D(red) - high →
0xD3A851(amber) - medium →
0xD3A851(amber) - low →
0x6DA3D8(blue) - (other) →
0x808080(grey)
FANGS-native envelope for SIEM / event-bus / Lambda intake:
{
"source": "fangs-orchestrator",
"schema_version": "v1",
"run_id": "18b2089cca6d11860000000000000000",
"package_name": "lodash",
"version": "4.18.9",
"state": "done",
"is_baseline": false,
"max_severity": "high",
"deviation_count": 3,
"deviations": [
{
"id": "ab12cd34...",
"category": "fs_new_path_read",
"value": "/root/.ssh/id_rsa",
"severity": "high",
"evidence_event_id": 47291
},
...
]
}Compact, structured, no special characters. Most operators consume this in their existing detection-content pipeline.
Per-target min_severity value (or empty for "any severity"). The
notifier computes the run's MAX severity across all deviations and
compares:
"min_severity": "high"
run.maxSeverity == "high" or "critical" → fire
run.maxSeverity == "medium" or "low" → skip
Severity ordering:
critical (4) > high (3) > medium (2) > low (1) > unknown (0)
Routing pattern: a min_severity=high Slack channel for paging the
on-call human, and a min_severity="" generic target piping
everything into a SIEM dashboard.
fangs notifier test soc-slackBuilds a synthetic payload per template:
| Template | Test payload |
|---|---|
| slack | {"text":"FANGS notifier test — if you see this..."} |
| discord | embed with title "FANGS notifier test" |
| generic | {"source":"fangs-orchestrator","test":true,"message":"..."} |
No HMAC on the test fire even when secret_env is configured —
that's intentional, you're verifying URL + reachability.
Two paths to configure:
-
CLI —
fangs notifier add/list/remove/test/history. Imperative; stored innotifierstable. -
JSON file —
-notifiers-file=/etc/fangs/notifiers.jsonon the orchestrator. Declarative; upserted into the same table at boot.
Either path can coexist. The file is additive — entries added via CLI aren't removed when the file omits them.
JSON file format:
{
"notifiers": [
{
"name": "soc-slack",
"url": "https://hooks.slack.com/services/T.../B.../...",
"template": "slack",
"enabled": true
},
{
"name": "siem",
"url": "https://intake.internal/fangs",
"template": "generic",
"secret_env": "FANGS_HMAC",
"min_severity": "high",
"headers": {"X-API-Key": "${INTAKE_KEY}"}
}
]
}CREATE TABLE notifiers (
name TEXT PRIMARY KEY,
url TEXT NOT NULL,
template TEXT NOT NULL, -- 'slack' | 'discord' | 'generic'
secret_env TEXT,
headers TEXT, -- JSON-encoded
min_severity TEXT, -- 'low'|'medium'|'high'|'critical'|''
enabled INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE notifications (
id TEXT PRIMARY KEY,
run_id TEXT NOT NULL REFERENCES runs(id) ON DELETE CASCADE,
notifier_name TEXT NOT NULL REFERENCES notifiers(name) ON DELETE CASCADE,
attempt INTEGER NOT NULL,
status TEXT NOT NULL,
last_attempted_at TEXT,
next_attempt_at TEXT,
response_code INTEGER,
response_body TEXT,
error_msg TEXT,
deviation_count INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
);Per-run, per-notifier, per-attempt audit log. notifications rows are
never updated — each retry writes a new row.
- Suppression / dedup across runs. Same deviation appearing on three sequential runs fires three separate notifications. Operators who want dedup add it at the receiver (Slack channel summary bots, PagerDuty incident grouping).
-
Persistent retry across orchestrator restarts. The retry queue
is in-memory. An orchestrator kill mid-retry loses pending
retries. The
notificationsaudit log records the last attempted state so post-mortem analysis still works. - Delivery ordering guarantees. Two runs finishing in quick succession could deliver in either order to the receiver.
-
Templating customization. Built-in templates only; operators
wanting custom shapes use a
generictarget + a transform Lambda / pipeline.