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 0000000000..d69ddbcfab Binary files /dev/null and b/keep-ui/public/icons/snmp-icon.png differ 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