Skip to content

Notifier

cyb3rjerry edited this page May 23, 2026 · 1 revision

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/.

Targets

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

Trigger flow

// 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:

  1. Loads run + deviations + every enabled notifier.
  2. Computes the run's max severity across all deviations.
  3. For each target where target.MinSeverity == "" || severityRank(maxSev) >= severityRank(target.MinSeverity): spawns a goroutine running deliverWithRetry. The goroutine detaches from the request context so a slow retry loop isn't canceled when the calling differ returns.

Retry policy

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.

Security

Scheme

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.

HMAC

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.

Headers

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).

Templates

Slack

{
  "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.

Discord

{
  "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)

Generic

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.

min_severity filter

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.

Test fire

fangs notifier test soc-slack

Builds 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.

Configuration sources

Two paths to configure:

  1. CLIfangs notifier add/list/remove/test/history. Imperative; stored in notifiers table.
  2. JSON file-notifiers-file=/etc/fangs/notifiers.json on 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}"}
    }
  ]
}

Storage schema

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.

What it doesn't do

  • 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 notifications audit 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 generic target + a transform Lambda / pipeline.

Clone this wiki locally