From 420e67978c21fd4cd58b4b81d2805ca1966b7076 Mon Sep 17 00:00:00 2001 From: junaiddshaukat Date: Thu, 23 Apr 2026 03:37:40 +0500 Subject: [PATCH] feat(providers): add SNMP provider for ingesting traps as alerts Passive SNMP provider that receives traps via a small snmptrapd exec hook forwarding parsed traps as JSON to Keep's webhook endpoint. - _format_alert converts trap payload into AlertDto - Built-in severity mapping for standard RFC 1907 / RFC 3418 trap OIDs (coldStart, warmStart, linkDown, linkUp, authenticationFailure, egpNeighborLoss) with a user-configurable default_severity fallback - Varbinds exposed as labels["var:"] - Fingerprint derived from (trap_oid, source_address) for deduplication - Docs include snmptrapd.conf snippet and minimal bash bridge - simulate_alert fixture for UI testing - Unit tests for naming, severity resolution, fingerprinting, labels Closes #2112 --- .../providers/documentation/snmp-provider.mdx | 147 +++++++++++ keep-ui/public/icons/snmp-icon.png | Bin 0 -> 5765 bytes keep/providers/snmp_provider/__init__.py | 6 + keep/providers/snmp_provider/alerts_mock.py | 42 +++ keep/providers/snmp_provider/snmp_provider.py | 248 ++++++++++++++++++ tests/test_snmp_provider.py | 109 ++++++++ 6 files changed, 552 insertions(+) create mode 100644 docs/providers/documentation/snmp-provider.mdx create mode 100644 keep-ui/public/icons/snmp-icon.png create mode 100644 keep/providers/snmp_provider/__init__.py create mode 100644 keep/providers/snmp_provider/alerts_mock.py create mode 100644 keep/providers/snmp_provider/snmp_provider.py create mode 100644 tests/test_snmp_provider.py diff --git a/docs/providers/documentation/snmp-provider.mdx b/docs/providers/documentation/snmp-provider.mdx new file mode 100644 index 0000000000..5d56749aca --- /dev/null +++ b/docs/providers/documentation/snmp-provider.mdx @@ -0,0 +1,147 @@ +--- +title: "SNMP" +sidebarTitle: "SNMP Provider" +description: "Ingest SNMP traps into Keep as alerts via a lightweight `snmptrapd` bridge." +--- +import AutoGeneratedSnippet from '/snippets/providers/snmp-snippet-autogenerated.mdx'; + + + +## How it works + +The SNMP provider is passive. It does not bind to UDP port 162 itself (which +would require root and conflict across tenants). Instead, traps are received +by the standard `snmptrapd` daemon on a host you already operate, and a small +exec hook forwards each parsed trap to Keep's webhook endpoint as JSON. + +``` +network device ──(SNMP trap UDP:162)──> snmptrapd ──(HTTPS JSON)──> Keep webhook +``` + +## Connecting with the Provider + +1. Install `net-snmp` (ships `snmptrapd`) on a host that can receive traps + from your network devices. Linux: `sudo apt install snmp snmptrapd` or + `sudo yum install net-snmp`. macOS: `brew install net-snmp`. +2. In Keep, add the SNMP provider. Copy the generated webhook URL and API key + shown after install. +3. Configure `snmptrapd` to forward each trap to Keep (see below). + +## `snmptrapd` bridge + +Add this to `/etc/snmp/snmptrapd.conf` (create it if missing): + +```conf +# Accept v2c traps with community "public" — adjust for your environment. +authCommunity log,execute,net public + +# Forward every trap to the Keep webhook. +traphandle default /usr/local/bin/keep-snmp-bridge +``` + +Create `/usr/local/bin/keep-snmp-bridge` and make it executable +(`chmod +x /usr/local/bin/keep-snmp-bridge`): + +```bash +#!/usr/bin/env bash +# Minimal snmptrapd → Keep webhook bridge. +# snmptrapd invokes this script with the raw trap on stdin. + +KEEP_WEBHOOK_URL="${KEEP_WEBHOOK_URL:?KEEP_WEBHOOK_URL is required}" +KEEP_API_KEY="${KEEP_API_KEY:?KEEP_API_KEY is required}" + +read -r source_address +read -r _source_hostname +trap_oid="" +declare -A varbinds +while read -r line; do + # Each varbind line is: OIDVALUE + oid="${line%% *}" + val="${line#* }" + if [ -z "$trap_oid" ] && [ "$oid" = "1.3.6.1.6.3.1.1.4.1.0" ]; then + trap_oid="$val" + else + varbinds["$oid"]="$val" + fi +done + +# Build JSON payload. +vars_json="{" +sep="" +for k in "${!varbinds[@]}"; do + vars_json+="$sep\"$k\":\"${varbinds[$k]//\"/\\\"}\"" + sep="," +done +vars_json+="}" + +payload=$(cat </dev/null +``` + +Export `KEEP_WEBHOOK_URL` and `KEEP_API_KEY` in the service environment for +`snmptrapd`, then restart it: + +```bash +sudo systemctl restart snmptrapd # or: sudo /usr/sbin/snmptrapd -Lsd +``` + +## Trap payload shape + +The bridge posts a JSON object with these fields; any can be omitted: + +| Field | Type | Description | +|---|---|---| +| `trap_oid` | string | The trap OID (e.g. `1.3.6.1.6.3.1.1.5.3` for `linkDown`). | +| `trap_name` | string | Optional human name used as the alert name. | +| `source_address` | string | IP/host of the device that emitted the trap. | +| `variables` | object | Varbinds as `{ "oid": "value" }`. Exposed on the alert as `labels["var:"]`. | +| `severity` | string | Optional override: `critical`, `high`, `warning`, `info`, `low`. | +| `description` | string | Optional human-readable description. | +| `community` | string | SNMP community string (v1/v2c). | +| `version` | string | SNMP version: `1`, `2c` or `3`. | + +If `severity` is not provided, the provider uses the following built-in +mapping for standard trap OIDs (RFC 1907 / RFC 3418): + +| OID | Name | Severity | +|---|---|---| +| `1.3.6.1.6.3.1.1.5.1` | `coldStart` | info | +| `1.3.6.1.6.3.1.1.5.2` | `warmStart` | info | +| `1.3.6.1.6.3.1.1.5.3` | `linkDown` | high | +| `1.3.6.1.6.3.1.1.5.4` | `linkUp` | info | +| `1.3.6.1.6.3.1.1.5.5` | `authenticationFailure` | warning | +| `1.3.6.1.6.3.1.1.5.6` | `egpNeighborLoss` | warning | + +Anything else falls back to the `default_severity` you configured on the +provider. + +## Testing + +Send a synthetic trap with `snmptrap` from the CLI: + +```bash +snmptrap -v 2c -c public localhost '' 1.3.6.1.6.3.1.1.5.3 \ + 1.3.6.1.2.1.2.2.1.1 i 2 \ + 1.3.6.1.2.1.2.2.1.7 i 2 +``` + +You should see a new alert in Keep within a second or two. + +## Useful links + +- [net-snmp documentation](http://www.net-snmp.org/docs/) +- [`snmptrapd.conf` reference](http://net-snmp.sourceforge.net/docs/man/snmptrapd.conf.html) +- [RFC 1907 — Standard SNMPv2 traps](https://www.rfc-editor.org/rfc/rfc1907) diff --git a/keep-ui/public/icons/snmp-icon.png b/keep-ui/public/icons/snmp-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..d69ddbcfab39fb146a5f9bcb79fe3de7610d6626 GIT binary patch literal 5765 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 literal 0 HcmV?d00001 diff --git a/keep/providers/snmp_provider/__init__.py b/keep/providers/snmp_provider/__init__.py new file mode 100644 index 0000000000..f55f0fc07e --- /dev/null +++ b/keep/providers/snmp_provider/__init__.py @@ -0,0 +1,6 @@ +from keep.providers.snmp_provider.snmp_provider import ( + SnmpProvider, + SnmpProviderAuthConfig, +) + +__all__ = ["SnmpProvider", "SnmpProviderAuthConfig"] diff --git a/keep/providers/snmp_provider/alerts_mock.py b/keep/providers/snmp_provider/alerts_mock.py new file mode 100644 index 0000000000..a458e994c2 --- /dev/null +++ b/keep/providers/snmp_provider/alerts_mock.py @@ -0,0 +1,42 @@ +"""Sample SNMP trap payloads used by `SnmpProvider.simulate_alert`.""" + +TRAPS = { + "linkDown": { + "trap_oid": "1.3.6.1.6.3.1.1.5.3", + "trap_name": "linkDown", + "source_address": "192.0.2.10", + "community": "public", + "version": "2c", + "variables": { + "1.3.6.1.2.1.2.2.1.1": "2", + "1.3.6.1.2.1.2.2.1.7": "2", + }, + "description": "Interface eth1 went down", + "severity": "high", + }, + "linkUp": { + "trap_oid": "1.3.6.1.6.3.1.1.5.4", + "trap_name": "linkUp", + "source_address": "192.0.2.10", + "community": "public", + "version": "2c", + "variables": {"1.3.6.1.2.1.2.2.1.1": "2"}, + "description": "Interface eth1 came back up", + }, + "coldStart": { + "trap_oid": "1.3.6.1.6.3.1.1.5.1", + "trap_name": "coldStart", + "source_address": "192.0.2.20", + "community": "public", + "version": "2c", + "description": "Device rebooted and reinitialised", + }, + "authenticationFailure": { + "trap_oid": "1.3.6.1.6.3.1.1.5.5", + "trap_name": "authenticationFailure", + "source_address": "192.0.2.30", + "community": "public", + "version": "2c", + "description": "SNMP authentication failure from 10.0.0.5", + }, +} diff --git a/keep/providers/snmp_provider/snmp_provider.py b/keep/providers/snmp_provider/snmp_provider.py new file mode 100644 index 0000000000..f7dfb6885b --- /dev/null +++ b/keep/providers/snmp_provider/snmp_provider.py @@ -0,0 +1,248 @@ +""" +SnmpProvider ingests SNMP traps into Keep as alerts. + +SNMP traps arrive via a small `snmptrapd` exec hook that POSTs the parsed +trap as JSON to Keep's generic webhook endpoint. See +`docs/providers/documentation/snmp-provider.mdx` for the bridge setup. +""" + +import dataclasses +import datetime +import hashlib + +import pydantic + +from keep.api.models.alert import AlertDto, AlertSeverity, AlertStatus +from keep.contextmanager.contextmanager import ContextManager +from keep.providers.base.base_provider import BaseProvider +from keep.providers.models.provider_config import ProviderConfig + + +@pydantic.dataclasses.dataclass +class SnmpProviderAuthConfig: + """SNMP provider configuration. + + The provider itself is passive (receives JSON-over-HTTPS posted by the + trap bridge), so no credentials are required. These fields tune how + incoming traps are interpreted. + """ + + default_severity: str = dataclasses.field( + default="info", + metadata={ + "required": False, + "description": ( + "Severity assigned to a trap when the payload does not specify one " + "and the trap OID is not in the built-in mapping." + ), + "type": "select", + "options": ["critical", "high", "warning", "info", "low"], + "sensitive": False, + }, + ) + community_string: str = dataclasses.field( + default="public", + metadata={ + "required": False, + "description": ( + "Expected SNMP community string. Documented for the trap " + "bridge; the provider itself does not enforce it." + ), + "sensitive": False, + }, + ) + + +# Well-known SNMP trap OIDs from RFC 1907 / RFC 3418 with reasonable +# default severities. Users can override any of these in the bridge. +_STANDARD_TRAP_OIDS = { + "1.3.6.1.6.3.1.1.5.1": ("coldStart", AlertSeverity.INFO), + "1.3.6.1.6.3.1.1.5.2": ("warmStart", AlertSeverity.INFO), + "1.3.6.1.6.3.1.1.5.3": ("linkDown", AlertSeverity.HIGH), + "1.3.6.1.6.3.1.1.5.4": ("linkUp", AlertSeverity.INFO), + "1.3.6.1.6.3.1.1.5.5": ("authenticationFailure", AlertSeverity.WARNING), + "1.3.6.1.6.3.1.1.5.6": ("egpNeighborLoss", AlertSeverity.WARNING), +} + + +class SnmpProvider(BaseProvider): + """Ingest SNMP traps into Keep as alerts.""" + + PROVIDER_DISPLAY_NAME = "SNMP" + PROVIDER_CATEGORY = ["Monitoring"] + PROVIDER_TAGS = ["alert"] + + FINGERPRINT_FIELDS = ["trap_oid", "source_address"] + + webhook_description = ( + "This provider ingests SNMP traps via a small `snmptrapd` exec hook " + "that POSTs each trap as JSON to the Keep webhook URL below. " + "See the SNMP provider docs for the bridge script and " + "`snmptrapd.conf` snippet." + ) + webhook_template = ( + "POST {keep_webhook_api_url}\n" + "Headers:\n" + " X-API-KEY: {api_key}\n" + " Content-Type: application/json\n" + "Body (example):\n" + "{{\n" + ' "trap_oid": "1.3.6.1.6.3.1.1.5.3",\n' + ' "trap_name": "linkDown",\n' + ' "source_address": "192.0.2.10",\n' + ' "variables": {{"1.3.6.1.2.1.2.2.1.1": "2"}},\n' + ' "community": "public",\n' + ' "version": "2c"\n' + "}}" + ) + + SEVERITIES_MAP = { + "critical": AlertSeverity.CRITICAL, + "high": AlertSeverity.HIGH, + "error": AlertSeverity.HIGH, + "warning": AlertSeverity.WARNING, + "medium": AlertSeverity.WARNING, + "info": AlertSeverity.INFO, + "low": AlertSeverity.LOW, + } + + def __init__( + self, + context_manager: ContextManager, + provider_id: str, + config: ProviderConfig, + ): + super().__init__(context_manager, provider_id, config) + + def validate_config(self): + """Validate the provider configuration.""" + self.authentication_config = SnmpProviderAuthConfig( + **(self.config.authentication or {}) + ) + + def dispose(self): + """Nothing to clean up; the provider holds no connections.""" + return + + @staticmethod + def _resolve_name(event: dict) -> str: + """Pick a human-friendly alert name for a trap event.""" + if event.get("trap_name"): + return str(event["trap_name"]) + trap_oid = event.get("trap_oid") or "" + if trap_oid in _STANDARD_TRAP_OIDS: + return _STANDARD_TRAP_OIDS[trap_oid][0] + if trap_oid: + # Use the last numeric segment as a last resort name. + return f"snmp_trap_{trap_oid.rsplit('.', 1)[-1]}" + return "snmp_trap" + + @staticmethod + def _resolve_severity(event: dict, default_severity: str) -> AlertSeverity: + """Resolve severity: explicit field > well-known OID map > default.""" + raw = event.get("severity") + if isinstance(raw, str) and raw.strip(): + mapped = SnmpProvider.SEVERITIES_MAP.get(raw.strip().lower()) + if mapped is not None: + return mapped + trap_oid = event.get("trap_oid") or "" + if trap_oid in _STANDARD_TRAP_OIDS: + return _STANDARD_TRAP_OIDS[trap_oid][1] + return SnmpProvider.SEVERITIES_MAP.get( + (default_severity or "info").lower(), AlertSeverity.INFO + ) + + @staticmethod + def _build_fingerprint(event: dict) -> str: + """Deterministic fingerprint from (trap_oid, source_address). + + Falls back to a hash of the full event if neither is present. + """ + trap_oid = event.get("trap_oid") or "" + source = event.get("source_address") or "" + if trap_oid or source: + return hashlib.sha256(f"{trap_oid}|{source}".encode()).hexdigest() + raw = repr(sorted(event.items())) + return hashlib.sha256(raw.encode()).hexdigest() + + @staticmethod + def _format_alert( + event: dict, + provider_instance: "BaseProvider" = None, + ) -> AlertDto: + default_severity = "info" + if provider_instance is not None and getattr( + provider_instance, "authentication_config", None + ): + default_severity = ( + provider_instance.authentication_config.default_severity or "info" + ) + + name = SnmpProvider._resolve_name(event) + severity = SnmpProvider._resolve_severity(event, default_severity) + description = event.get("description") or ( + f"SNMP trap {event.get('trap_oid', '')} " + f"from {event.get('source_address', 'unknown host')}".strip() + ) + last_received = ( + event.get("last_received") + or datetime.datetime.now(tz=datetime.timezone.utc).isoformat() + ) + + labels = { + "trap_oid": event.get("trap_oid", ""), + "trap_name": event.get("trap_name", ""), + "source_address": event.get("source_address", ""), + "community": event.get("community", ""), + "version": event.get("version", ""), + } + # Expose varbinds as labels too, prefixed so they can't clobber + # existing keys. + variables = event.get("variables") or {} + if isinstance(variables, dict): + for oid, value in variables.items(): + labels[f"var:{oid}"] = str(value) + + return AlertDto( + id=event.get("id"), + name=name, + description=description, + severity=severity, + status=AlertStatus.FIRING, + lastReceived=last_received, + source=["snmp"], + host=event.get("source_address"), + fingerprint=event.get("fingerprint") + or SnmpProvider._build_fingerprint(event), + labels={k: v for k, v in labels.items() if v not in (None, "")}, + pushed=True, + ) + + @classmethod + def simulate_alert(cls, **kwargs) -> dict: + """Return a representative SNMP trap payload for UI testing.""" + import random + + from keep.providers.snmp_provider.alerts_mock import TRAPS + + trap_key = kwargs.get("alert_type") or random.choice(list(TRAPS.keys())) + payload = dict(TRAPS[trap_key]) + payload.setdefault( + "last_received", datetime.datetime.now(tz=datetime.timezone.utc).isoformat() + ) + return payload + + +if __name__ == "__main__": + import logging + + logging.basicConfig(level=logging.DEBUG, handlers=[logging.StreamHandler()]) + context_manager = ContextManager(tenant_id="singletenant", workflow_id="test") + provider = SnmpProvider( + context_manager=context_manager, + provider_id="snmp-test", + config=ProviderConfig(authentication={"default_severity": "info"}), + ) + sample = SnmpProvider.simulate_alert() + alert = SnmpProvider._format_alert(sample, provider_instance=provider) + print(alert.json(indent=2)) diff --git a/tests/test_snmp_provider.py b/tests/test_snmp_provider.py new file mode 100644 index 0000000000..0e055eea28 --- /dev/null +++ b/tests/test_snmp_provider.py @@ -0,0 +1,109 @@ +"""Tests for the SNMP provider's trap → AlertDto translation.""" + +import pytest + +from keep.api.models.alert import AlertSeverity, AlertStatus +from keep.contextmanager.contextmanager import ContextManager +from keep.providers.models.provider_config import ProviderConfig +from keep.providers.snmp_provider.snmp_provider import SnmpProvider + + +class TestSnmpProvider: + @pytest.fixture + def context_manager(self): + return ContextManager(tenant_id="test_tenant", workflow_id="test_workflow") + + @pytest.fixture + def provider(self, context_manager): + return SnmpProvider( + context_manager=context_manager, + provider_id="snmp-test", + config=ProviderConfig(authentication={"default_severity": "info"}), + ) + + def test_validate_config_defaults_when_empty(self, context_manager): + provider = SnmpProvider( + context_manager=context_manager, + provider_id="snmp-no-auth", + config=ProviderConfig(authentication={}), + ) + assert provider.authentication_config.default_severity == "info" + assert provider.authentication_config.community_string == "public" + + def test_standard_trap_oid_maps_to_known_name_and_severity(self, provider): + event = { + "trap_oid": "1.3.6.1.6.3.1.1.5.3", + "source_address": "192.0.2.10", + } + alert = SnmpProvider._format_alert(event, provider_instance=provider) + assert alert.name == "linkDown" + assert alert.severity == AlertSeverity.HIGH.value + assert alert.status == AlertStatus.FIRING.value + assert alert.source == ["snmp"] + assert alert.labels["trap_oid"] == "1.3.6.1.6.3.1.1.5.3" + + def test_explicit_severity_overrides_trap_oid_default(self, provider): + event = { + "trap_oid": "1.3.6.1.6.3.1.1.5.3", + "source_address": "192.0.2.10", + "severity": "critical", + } + alert = SnmpProvider._format_alert(event, provider_instance=provider) + assert alert.severity == AlertSeverity.CRITICAL.value + + def test_unknown_trap_falls_back_to_default_severity(self, context_manager): + provider = SnmpProvider( + context_manager=context_manager, + provider_id="snmp-warn-default", + config=ProviderConfig(authentication={"default_severity": "warning"}), + ) + event = { + "trap_oid": "1.3.6.1.4.1.99999.1", + "source_address": "192.0.2.50", + "trap_name": "vendorSpecificTrap", + } + alert = SnmpProvider._format_alert(event, provider_instance=provider) + assert alert.name == "vendorSpecificTrap" + assert alert.severity == AlertSeverity.WARNING.value + + def test_fingerprint_is_stable_for_same_oid_and_source(self, provider): + event_a = {"trap_oid": "1.2.3", "source_address": "192.0.2.1"} + event_b = { + "trap_oid": "1.2.3", + "source_address": "192.0.2.1", + "variables": {"1.2.3.4": "different varbind"}, + } + alert_a = SnmpProvider._format_alert(event_a, provider_instance=provider) + alert_b = SnmpProvider._format_alert(event_b, provider_instance=provider) + assert alert_a.fingerprint == alert_b.fingerprint + + def test_fingerprint_differs_for_different_source(self, provider): + alert_a = SnmpProvider._format_alert( + {"trap_oid": "1.2.3", "source_address": "192.0.2.1"}, + provider_instance=provider, + ) + alert_b = SnmpProvider._format_alert( + {"trap_oid": "1.2.3", "source_address": "192.0.2.2"}, + provider_instance=provider, + ) + assert alert_a.fingerprint != alert_b.fingerprint + + def test_variables_are_exposed_as_prefixed_labels(self, provider): + event = { + "trap_oid": "1.3.6.1.6.3.1.1.5.3", + "source_address": "192.0.2.10", + "variables": {"1.3.6.1.2.1.2.2.1.1": "2"}, + } + alert = SnmpProvider._format_alert(event, provider_instance=provider) + assert alert.labels.get("var:1.3.6.1.2.1.2.2.1.1") == "2" + + def test_simulate_alert_returns_well_formed_payload(self): + payload = SnmpProvider.simulate_alert(alert_type="linkDown") + assert payload["trap_oid"] == "1.3.6.1.6.3.1.1.5.3" + assert payload["trap_name"] == "linkDown" + + def test_provider_type_is_snmp(self, provider): + assert provider.provider_type == "snmp" + + def test_dispose_is_noop(self, provider): + assert provider.dispose() is None