diff --git a/threat_intel/CHANGELOG.md b/threat_intel/CHANGELOG.md new file mode 100644 index 0000000000000..02b989d4c4c14 --- /dev/null +++ b/threat_intel/CHANGELOG.md @@ -0,0 +1,3 @@ +# CHANGELOG - Threat Intel + + diff --git a/threat_intel/README.md b/threat_intel/README.md new file mode 100644 index 0000000000000..8d3fa322c4294 --- /dev/null +++ b/threat_intel/README.md @@ -0,0 +1,56 @@ +# Threat Intel + +## Overview + +This integration queries the [AbuseIPDB](https://www.abuseipdb.com/) threat intelligence API to check IP addresses for known malicious activity and sends the results as logs to Datadog. + +The check periodically queries the configured IP addresses against the AbuseIPDB database, reporting abuse confidence scores, ISP information, country of origin, and report counts. + +## Setup + +### Prerequisites + +You need an AbuseIPDB API key. You can sign up for a free account at [AbuseIPDB](https://www.abuseipdb.com/). + +### Installation + +The Threat Intel check is included in the [Datadog Agent][1] package. No additional installation is needed on your server. + +### Configuration + +1. Edit the `threat_intel.d/conf.yaml` file in the `conf.d/` folder at the root of your Agent's configuration directory to start collecting threat intelligence data. See the [sample threat_intel.d/conf.yaml][2] for all available configuration options. + +2. [Restart the Agent][3]. + +### Validation + +[Run the Agent's status subcommand][4] and look for `threat_intel` under the Checks section. + +## Data Collected + +### Logs + +The Threat Intel check sends log events containing threat intelligence data for each queried IP address, including: + +- IP address +- Abuse confidence score +- Country code +- ISP +- Domain +- Total reports +- Whitelist status +- Last reported timestamp + +### Service Checks + +**threat_intel.can_connect**: Returns `CRITICAL` if the check fails to query the AbuseIPDB API. Returns `OK` otherwise. + +## Support + +Need help? Contact [Datadog support][5]. + +[1]: https://app.datadoghq.com/account/settings/agent/latest +[2]: https://github.com/DataDog/integrations-core/blob/master/threat_intel/datadog_checks/threat_intel/data/conf.yaml.example +[3]: https://docs.datadoghq.com/agent/guide/agent-commands/#start-stop-and-restart-the-agent +[4]: https://docs.datadoghq.com/agent/guide/agent-commands/#agent-status-and-information +[5]: https://docs.datadoghq.com/help/ diff --git a/threat_intel/assets/configuration/spec.yaml b/threat_intel/assets/configuration/spec.yaml new file mode 100644 index 0000000000000..e79894c767170 --- /dev/null +++ b/threat_intel/assets/configuration/spec.yaml @@ -0,0 +1,46 @@ +name: Threat Intel +fleet_configurable: true +files: +- name: threat_intel.yaml + options: + - template: logs + example: + - type: integration + service: threat_intel + source: threat_intel + - template: init_config + options: + - template: init_config/default + - template: instances + options: + - name: api_key + fleet_configurable: true + required: true + secret: true + description: "AbuseIPDB API key for querying IP threat intelligence." + value: + type: string + example: + - name: ip_addresses + fleet_configurable: true + required: true + description: "List of IP addresses to check for threat intelligence." + value: + type: array + items: + type: string + example: + - 192.168.1.1 + - name: max_age_in_days + fleet_configurable: true + description: "Maximum age in days for abuse reports." + value: + type: integer + example: 90 + minimum: 1 + maximum: 365 + + - template: instances/default + overrides: + min_collection_interval.value.example: 3600 + min_collection_interval.value.minimum: 60 diff --git a/threat_intel/assets/service_checks.json b/threat_intel/assets/service_checks.json new file mode 100644 index 0000000000000..2e32c9e45e84f --- /dev/null +++ b/threat_intel/assets/service_checks.json @@ -0,0 +1,11 @@ +[ + { + "agent_version": "7.0.0", + "integration": "threat_intel", + "check": "threat_intel.can_connect", + "statuses": ["ok", "critical"], + "groups": [], + "name": "threat_intel.can_connect", + "description": "Returns CRITICAL if the check cannot query the AbuseIPDB API. Returns OK otherwise." + } +] diff --git a/threat_intel/changelog.d/22626.added b/threat_intel/changelog.d/22626.added new file mode 100644 index 0000000000000..cc7498e240e19 --- /dev/null +++ b/threat_intel/changelog.d/22626.added @@ -0,0 +1 @@ +Initial Release diff --git a/threat_intel/datadog_checks/__init__.py b/threat_intel/datadog_checks/__init__.py new file mode 100644 index 0000000000000..a77b3f5ff63ac --- /dev/null +++ b/threat_intel/datadog_checks/__init__.py @@ -0,0 +1,4 @@ +# (C) Datadog, Inc. 2025-present +# All rights reserved +# Licensed under a 3-clause BSD style license (see LICENSE) +__path__ = __import__('pkgutil').extend_path(__path__, __name__) # type: ignore diff --git a/threat_intel/datadog_checks/threat_intel/__about__.py b/threat_intel/datadog_checks/threat_intel/__about__.py new file mode 100644 index 0000000000000..1bde5986a04b2 --- /dev/null +++ b/threat_intel/datadog_checks/threat_intel/__about__.py @@ -0,0 +1,4 @@ +# (C) Datadog, Inc. 2025-present +# All rights reserved +# Licensed under a 3-clause BSD style license (see LICENSE) +__version__ = '0.0.1' diff --git a/threat_intel/datadog_checks/threat_intel/__init__.py b/threat_intel/datadog_checks/threat_intel/__init__.py new file mode 100644 index 0000000000000..cdbc499e9ef89 --- /dev/null +++ b/threat_intel/datadog_checks/threat_intel/__init__.py @@ -0,0 +1,7 @@ +# (C) Datadog, Inc. 2025-present +# All rights reserved +# Licensed under a 3-clause BSD style license (see LICENSE) +from .__about__ import __version__ +from .check import ThreatIntelCheck + +__all__ = ['__version__', 'ThreatIntelCheck'] diff --git a/threat_intel/datadog_checks/threat_intel/check.py b/threat_intel/datadog_checks/threat_intel/check.py new file mode 100644 index 0000000000000..279052601c294 --- /dev/null +++ b/threat_intel/datadog_checks/threat_intel/check.py @@ -0,0 +1,79 @@ +# (C) Datadog, Inc. 2025-present +# All rights reserved +# Licensed under a 3-clause BSD style license (see LICENSE) +import json + +from datadog_checks.base import AgentCheck, ConfigurationError +from datadog_checks.base.utils.time import get_current_datetime, get_timestamp + +from . import constants + + +class ThreatIntelCheck(AgentCheck): + __NAMESPACE__ = "threat_intel" + + def __init__(self, name, init_config, instances): + super(ThreatIntelCheck, self).__init__(name, init_config, instances) + self.api_key = self.instance.get("api_key") + self.ip_addresses = self.instance.get("ip_addresses", []) + self.max_age_in_days = self.instance.get("max_age_in_days", 90) + self.check_initializations.append(self.validate_config) + + def validate_config(self) -> None: + if not self.api_key: + raise ConfigurationError("AbuseIPDB API key is required.") + if not self.ip_addresses: + raise ConfigurationError("At least one IP address must be configured.") + + def query_ip(self, ip_address: str) -> dict | None: + """Query the AbuseIPDB API for threat intelligence on an IP address.""" + url = constants.ABUSEIPDB_API_URL + headers = {"Key": self.api_key, "Accept": "application/json"} + params = { + "ipAddress": ip_address, + "maxAgeInDays": str(self.max_age_in_days), + } + try: + response = self.http.get(url, headers=headers, params=params) + response.raise_for_status() + return response.json() + except Exception: + self.log.error("Failed to query AbuseIPDB for IP %s", ip_address) + raise + + def check(self, _): + current_time = get_current_datetime() + has_error = False + for ip_address in self.ip_addresses: + try: + result = self.query_ip(ip_address) + if result and "data" in result: + data = result["data"] + log_data = { + "timestamp": get_timestamp(current_time), + "message": json.dumps( + { + "ip_address": data.get("ipAddress"), + "abuse_confidence_score": data.get("abuseConfidenceScore"), + "country_code": data.get("countryCode"), + "isp": data.get("isp"), + "domain": data.get("domain"), + "total_reports": data.get("totalReports"), + "is_whitelisted": data.get("isWhitelisted"), + "last_reported_at": data.get("lastReportedAt"), + } + ), + "ddsource": "threat_intel", + } + self.send_log(log_data) + except Exception: + has_error = True + + if has_error: + self.service_check( + constants.SERVICE_CHECK_NAME, + AgentCheck.CRITICAL, + message="Failed to query one or more IP addresses.", + ) + else: + self.service_check(constants.SERVICE_CHECK_NAME, AgentCheck.OK) diff --git a/threat_intel/datadog_checks/threat_intel/config_models/__init__.py b/threat_intel/datadog_checks/threat_intel/config_models/__init__.py new file mode 100644 index 0000000000000..f678b7e73d91a --- /dev/null +++ b/threat_intel/datadog_checks/threat_intel/config_models/__init__.py @@ -0,0 +1,24 @@ +# (C) Datadog, Inc. 2026-present +# All rights reserved +# Licensed under a 3-clause BSD style license (see LICENSE) + +# This file is autogenerated. +# To change this file you should edit assets/configuration/spec.yaml and then run the following commands: +# ddev -x validate config -s +# ddev -x validate models -s + +from .instance import InstanceConfig +from .shared import SharedConfig + + +class ConfigMixin: + _config_model_instance: InstanceConfig + _config_model_shared: SharedConfig + + @property + def config(self) -> InstanceConfig: + return self._config_model_instance + + @property + def shared_config(self) -> SharedConfig: + return self._config_model_shared diff --git a/threat_intel/datadog_checks/threat_intel/config_models/defaults.py b/threat_intel/datadog_checks/threat_intel/config_models/defaults.py new file mode 100644 index 0000000000000..2d52b60c79bc6 --- /dev/null +++ b/threat_intel/datadog_checks/threat_intel/config_models/defaults.py @@ -0,0 +1,24 @@ +# (C) Datadog, Inc. 2026-present +# All rights reserved +# Licensed under a 3-clause BSD style license (see LICENSE) + +# This file is autogenerated. +# To change this file you should edit assets/configuration/spec.yaml and then run the following commands: +# ddev -x validate config -s +# ddev -x validate models -s + + +def instance_disable_generic_tags(): + return False + + +def instance_empty_default_hostname(): + return False + + +def instance_max_age_in_days(): + return 90 + + +def instance_min_collection_interval(): + return 3600 diff --git a/threat_intel/datadog_checks/threat_intel/config_models/instance.py b/threat_intel/datadog_checks/threat_intel/config_models/instance.py new file mode 100644 index 0000000000000..6dba9724b6ed8 --- /dev/null +++ b/threat_intel/datadog_checks/threat_intel/config_models/instance.py @@ -0,0 +1,64 @@ +# (C) Datadog, Inc. 2026-present +# All rights reserved +# Licensed under a 3-clause BSD style license (see LICENSE) + +# This file is autogenerated. +# To change this file you should edit assets/configuration/spec.yaml and then run the following commands: +# ddev -x validate config -s +# ddev -x validate models -s + +from __future__ import annotations + +from typing import Optional + +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator + +from datadog_checks.base.utils.functions import identity +from datadog_checks.base.utils.models import validation + +from . import defaults, validators + + +class MetricPatterns(BaseModel): + model_config = ConfigDict( + arbitrary_types_allowed=True, + frozen=True, + ) + exclude: Optional[tuple[str, ...]] = None + include: Optional[tuple[str, ...]] = None + + +class InstanceConfig(BaseModel): + model_config = ConfigDict( + validate_default=True, + arbitrary_types_allowed=True, + frozen=True, + ) + api_key: str + disable_generic_tags: Optional[bool] = None + empty_default_hostname: Optional[bool] = None + ip_addresses: tuple[str, ...] + max_age_in_days: Optional[int] = Field(None, ge=1, le=365) + metric_patterns: Optional[MetricPatterns] = None + min_collection_interval: Optional[float] = Field(None, ge=60.0) + service: Optional[str] = None + tags: Optional[tuple[str, ...]] = None + + @model_validator(mode='before') + def _initial_validation(cls, values): + return validation.core.initialize_config(getattr(validators, 'initialize_instance', identity)(values)) + + @field_validator('*', mode='before') + def _validate(cls, value, info): + field = cls.model_fields[info.field_name] + field_name = field.alias or info.field_name + if field_name in info.context['configured_fields']: + value = getattr(validators, f'instance_{info.field_name}', identity)(value, field=field) + else: + value = getattr(defaults, f'instance_{info.field_name}', lambda: value)() + + return validation.utils.make_immutable(value) + + @model_validator(mode='after') + def _final_validation(cls, model): + return validation.core.check_model(getattr(validators, 'check_instance', identity)(model)) diff --git a/threat_intel/datadog_checks/threat_intel/config_models/shared.py b/threat_intel/datadog_checks/threat_intel/config_models/shared.py new file mode 100644 index 0000000000000..10cab800f6c1e --- /dev/null +++ b/threat_intel/datadog_checks/threat_intel/config_models/shared.py @@ -0,0 +1,45 @@ +# (C) Datadog, Inc. 2026-present +# All rights reserved +# Licensed under a 3-clause BSD style license (see LICENSE) + +# This file is autogenerated. +# To change this file you should edit assets/configuration/spec.yaml and then run the following commands: +# ddev -x validate config -s +# ddev -x validate models -s + +from __future__ import annotations + +from typing import Optional + +from pydantic import BaseModel, ConfigDict, field_validator, model_validator + +from datadog_checks.base.utils.functions import identity +from datadog_checks.base.utils.models import validation + +from . import validators + + +class SharedConfig(BaseModel): + model_config = ConfigDict( + validate_default=True, + arbitrary_types_allowed=True, + frozen=True, + ) + service: Optional[str] = None + + @model_validator(mode='before') + def _initial_validation(cls, values): + return validation.core.initialize_config(getattr(validators, 'initialize_shared', identity)(values)) + + @field_validator('*', mode='before') + def _validate(cls, value, info): + field = cls.model_fields[info.field_name] + field_name = field.alias or info.field_name + if field_name in info.context['configured_fields']: + value = getattr(validators, f'shared_{info.field_name}', identity)(value, field=field) + + return validation.utils.make_immutable(value) + + @model_validator(mode='after') + def _final_validation(cls, model): + return validation.core.check_model(getattr(validators, 'check_shared', identity)(model)) diff --git a/threat_intel/datadog_checks/threat_intel/config_models/validators.py b/threat_intel/datadog_checks/threat_intel/config_models/validators.py new file mode 100644 index 0000000000000..5e48f02a73da4 --- /dev/null +++ b/threat_intel/datadog_checks/threat_intel/config_models/validators.py @@ -0,0 +1,13 @@ +# (C) Datadog, Inc. 2026-present +# All rights reserved +# Licensed under a 3-clause BSD style license (see LICENSE) + +# Here you can include additional config validators or transformers +# +# def initialize_instance(values, **kwargs): +# if 'my_option' not in values and 'my_legacy_option' in values: +# values['my_option'] = values['my_legacy_option'] +# if values.get('my_number') > 10: +# raise ValueError('my_number max value is 10, got %s' % str(values.get('my_number'))) +# +# return values diff --git a/threat_intel/datadog_checks/threat_intel/constants.py b/threat_intel/datadog_checks/threat_intel/constants.py new file mode 100644 index 0000000000000..1adbe082c0c7a --- /dev/null +++ b/threat_intel/datadog_checks/threat_intel/constants.py @@ -0,0 +1,5 @@ +# (C) Datadog, Inc. 2025-present +# All rights reserved +# Licensed under a 3-clause BSD style license (see LICENSE) +ABUSEIPDB_API_URL = "https://api.abuseipdb.com/api/v2/check" +SERVICE_CHECK_NAME = "can_connect" diff --git a/threat_intel/datadog_checks/threat_intel/data/conf.yaml.example b/threat_intel/datadog_checks/threat_intel/data/conf.yaml.example new file mode 100644 index 0000000000000..a18be95e3074c --- /dev/null +++ b/threat_intel/datadog_checks/threat_intel/data/conf.yaml.example @@ -0,0 +1,90 @@ +## Log Section +## +## type - required - Type of log input source (tcp / udp / file / windows_event). +## port / path / channel_path - required - Set port if type is tcp or udp. +## Set path if type is file. +## Set channel_path if type is windows_event. +## source - required - Attribute that defines which integration sent the logs. +## encoding - optional - For file specifies the file encoding. Default is utf-8. Other +## possible values are utf-16-le and utf-16-be. +## service - optional - The name of the service that generates the log. +## Overrides any `service` defined in the `init_config` section. +## tags - optional - Add tags to the collected logs. +## +## Discover Datadog log collection: https://docs.datadoghq.com/logs/log_collection/ +# +# logs: +# - type: integration +# service: threat_intel +# source: threat_intel + +## All options defined here are available to all instances. +# +init_config: + + ## @param service - string - optional + ## Attach the tag `service:` to every metric, event, and service check emitted by this integration. + ## + ## Additionally, this sets the default `service` for every log source. + # + # service: + +## Every instance is scheduled independently of the others. +# +instances: + + ## @param api_key - string - required + ## AbuseIPDB API key for querying IP threat intelligence. + # + - api_key: + + ## @param ip_addresses - list of strings - required + ## List of IP addresses to check for threat intelligence. + # + ip_addresses: + - 192.168.1.1 + + ## @param max_age_in_days - integer - optional - default: 90 + ## Maximum age in days for abuse reports. + # + # max_age_in_days: 90 + + ## @param tags - list of strings - optional + ## A list of tags to attach to every metric and service check emitted by this instance. + ## + ## Learn more about tagging at https://docs.datadoghq.com/tagging + # + # tags: + # - : + # - : + + ## @param service - string - optional + ## Attach the tag `service:` to every metric, event, and service check emitted by this integration. + ## + ## Overrides any `service` defined in the `init_config` section. + # + # service: + + ## @param min_collection_interval - number - optional - default: 3600 + ## This changes the collection interval of the check. For more information, see: + ## https://docs.datadoghq.com/developers/write_agent_check/#collection-interval + # + # min_collection_interval: 3600 + + ## @param empty_default_hostname - boolean - optional - default: false + ## This forces the check to send metrics with no hostname. + ## + ## This is useful for cluster-level checks. + # + # empty_default_hostname: false + + ## @param metric_patterns - mapping - optional + ## A mapping of metrics to include or exclude, with each entry being a regular expression. + ## + ## Metrics defined in `exclude` will take precedence in case of overlap. + # + # metric_patterns: + # include: + # - + # exclude: + # - diff --git a/threat_intel/hatch.toml b/threat_intel/hatch.toml new file mode 100644 index 0000000000000..fee2455000258 --- /dev/null +++ b/threat_intel/hatch.toml @@ -0,0 +1,4 @@ +[env.collectors.datadog-checks] + +[[envs.default.matrix]] +python = ["3.13"] diff --git a/threat_intel/manifest.json b/threat_intel/manifest.json new file mode 100644 index 0000000000000..5ff25344f2959 --- /dev/null +++ b/threat_intel/manifest.json @@ -0,0 +1,50 @@ +{ + "manifest_version": "2.0.0", + "app_uuid": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "app_id": "threat-intel", + "owner": "agent-integrations", + "display_on_public_website": true, + "tile": { + "overview": "README.md#Overview", + "configuration": "README.md#Setup", + "support": "README.md#Support", + "changelog": "CHANGELOG.md", + "description": "Query AbuseIPDB for IP threat intelligence and send results as logs.", + "title": "Threat Intel", + "media": [], + "classifier_tags": [ + "Supported OS::Linux", + "Supported OS::macOS", + "Supported OS::Windows", + "Category::Log Collection", + "Category::Security", + "Offering::Integration", + "Submitted Data Type::Logs" + ] + }, + "assets": { + "integration": { + "auto_install": true, + "source_type_id": 57376999, + "source_type_name": "Threat Intel", + "configuration": { + "spec": "assets/configuration/spec.yaml" + }, + "events": { + "creates_events": false + }, + "service_checks": { + "metadata_path": "assets/service_checks.json" + } + }, + "logs": { + "source": "threat_intel" + } + }, + "author": { + "support_email": "help@datadoghq.com", + "name": "Datadog", + "homepage": "https://www.datadoghq.com", + "sales_email": "info@datadoghq.com" + } +} diff --git a/threat_intel/pyproject.toml b/threat_intel/pyproject.toml new file mode 100644 index 0000000000000..49869ddd50fb4 --- /dev/null +++ b/threat_intel/pyproject.toml @@ -0,0 +1,60 @@ +[build-system] +requires = [ + "hatchling>=0.13.0", +] +build-backend = "hatchling.build" + +[project] +name = "datadog-threat-intel" +description = "The Threat Intel check" +readme = "README.md" +license = "BSD-3-Clause" +requires-python = ">=3.12" +keywords = [ + "datadog", + "datadog agent", + "datadog check", + "threat_intel", +] +authors = [ + { name = "Datadog", email = "packages@datadoghq.com" }, +] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "Intended Audience :: System Administrators", + "License :: OSI Approved :: BSD License", + "Private :: Do Not Upload", + "Programming Language :: Python :: 3.13", + "Topic :: System :: Monitoring", +] +dependencies = [ + "datadog-checks-base>=37.21.0", +] +dynamic = [ + "version", +] + +[project.optional-dependencies] +deps = [] + +[project.urls] +Source = "https://github.com/DataDog/integrations-core" + +[tool.hatch.version] +path = "datadog_checks/threat_intel/__about__.py" + +[tool.hatch.build.targets.sdist] +include = [ + "/datadog_checks", + "/tests", + "/manifest.json", +] + +[tool.hatch.build.targets.wheel] +include = [ + "/datadog_checks/threat_intel", +] +dev-mode-dirs = [ + ".", +] diff --git a/threat_intel/tests/__init__.py b/threat_intel/tests/__init__.py new file mode 100644 index 0000000000000..c9f1f2a9882c7 --- /dev/null +++ b/threat_intel/tests/__init__.py @@ -0,0 +1,3 @@ +# (C) Datadog, Inc. 2025-present +# All rights reserved +# Licensed under a 3-clause BSD style license (see LICENSE) diff --git a/threat_intel/tests/conftest.py b/threat_intel/tests/conftest.py new file mode 100644 index 0000000000000..655a63fe2ae6a --- /dev/null +++ b/threat_intel/tests/conftest.py @@ -0,0 +1,30 @@ +# (C) Datadog, Inc. 2025-present +# All rights reserved +# Licensed under a 3-clause BSD style license (see LICENSE) +from copy import deepcopy + +import pytest + +INSTANCE = { + "api_key": "test_api_key_12345", + "ip_addresses": ["192.168.1.1", "10.0.0.1"], + "max_age_in_days": 90, + "min_collection_interval": 3600, +} + +CONFIG = {"instances": [INSTANCE], "init_config": {}} + + +@pytest.fixture(scope='session') +def dd_environment(): + yield + + +@pytest.fixture +def instance(): + return deepcopy(INSTANCE) + + +@pytest.fixture +def config(): + return deepcopy(CONFIG) diff --git a/threat_intel/tests/test_unit.py b/threat_intel/tests/test_unit.py new file mode 100644 index 0000000000000..4afeba24c1f81 --- /dev/null +++ b/threat_intel/tests/test_unit.py @@ -0,0 +1,151 @@ +# (C) Datadog, Inc. 2025-present +# All rights reserved +# Licensed under a 3-clause BSD style license (see LICENSE) + +import json +from unittest.mock import MagicMock, patch + +import pytest + +from datadog_checks.base import AgentCheck, ConfigurationError +from datadog_checks.threat_intel import ThreatIntelCheck + +MOCK_API_RESPONSE = { + "data": { + "ipAddress": "192.168.1.1", + "isPublic": True, + "ipVersion": 4, + "isWhitelisted": False, + "abuseConfidenceScore": 75, + "countryCode": "US", + "usageType": "Data Center/Web Hosting/Transit", + "isp": "Example ISP", + "domain": "example.com", + "hostnames": [], + "totalReports": 42, + "numDistinctUsers": 10, + "lastReportedAt": "2025-01-15T10:30:00+00:00", + } +} + + +def _mock_response(json_data=None, status_code=200, raise_for_status=None): + mock_resp = MagicMock() + mock_resp.json.return_value = json_data + mock_resp.status_code = status_code + mock_resp.raise_for_status.return_value = raise_for_status + return mock_resp + + +def test_instance_check(config, instance): + check = ThreatIntelCheck("threat_intel", config['init_config'], [instance]) + assert isinstance(check, AgentCheck) + + +@pytest.mark.unit +def test_validate_config_missing_api_key(config, instance): + instance.pop("api_key") + check = ThreatIntelCheck("threat_intel", config['init_config'], [instance]) + with pytest.raises(ConfigurationError, match="AbuseIPDB API key is required"): + check.api_key = None + check.validate_config() + + +@pytest.mark.unit +def test_validate_config_missing_ip_addresses(config, instance): + instance.pop("ip_addresses") + check = ThreatIntelCheck("threat_intel", config['init_config'], [instance]) + with pytest.raises(ConfigurationError, match="At least one IP address must be configured"): + check.ip_addresses = [] + check.validate_config() + + +@pytest.mark.unit +def test_validate_config_success(config, instance): + check = ThreatIntelCheck("threat_intel", config['init_config'], [instance]) + assert check.validate_config() is None + + +@pytest.mark.unit +def test_query_ip_success(config, instance): + check = ThreatIntelCheck("threat_intel", config['init_config'], [instance]) + mock_resp = _mock_response(json_data=MOCK_API_RESPONSE) + + with patch('requests.Session.get', return_value=mock_resp): + result = check.query_ip("192.168.1.1") + assert result == MOCK_API_RESPONSE + assert result["data"]["abuseConfidenceScore"] == 75 + + +@pytest.mark.unit +def test_query_ip_failure(config, instance): + check = ThreatIntelCheck("threat_intel", config['init_config'], [instance]) + + with patch('requests.Session.get', side_effect=Exception("API error")): + with pytest.raises(Exception, match="API error"): + check.query_ip("192.168.1.1") + + +@pytest.mark.unit +def test_check_successful(config, datadog_agent, instance): + check = ThreatIntelCheck("threat_intel", config['init_config'], [instance]) + mock_resp = _mock_response(json_data=MOCK_API_RESPONSE) + + with patch('requests.Session.get', return_value=mock_resp): + check.check(None) + + logs = datadog_agent._sent_logs[check.check_id] + assert len(logs) == 2 + + for log_entry in logs: + message = json.loads(log_entry["message"]) + assert message["ip_address"] == "192.168.1.1" + assert message["abuse_confidence_score"] == 75 + assert message["country_code"] == "US" + assert message["isp"] == "Example ISP" + assert message["domain"] == "example.com" + assert message["total_reports"] == 42 + assert log_entry["ddsource"] == "threat_intel" + + +@pytest.mark.unit +def test_check_with_api_error(config, instance, aggregator): + check = ThreatIntelCheck("threat_intel", config['init_config'], [instance]) + + with patch('requests.Session.get', side_effect=Exception("API error")): + check.check(None) + + aggregator.assert_service_check( + "threat_intel.can_connect", + AgentCheck.CRITICAL, + message="Failed to query one or more IP addresses.", + ) + + +@pytest.mark.unit +def test_check_service_check_ok(config, instance, aggregator): + check = ThreatIntelCheck("threat_intel", config['init_config'], [instance]) + mock_resp = _mock_response(json_data=MOCK_API_RESPONSE) + + with patch('requests.Session.get', return_value=mock_resp): + check.check(None) + + aggregator.assert_service_check("threat_intel.can_connect", AgentCheck.OK) + + +@pytest.mark.unit +def test_check_partial_failure(config, instance, aggregator): + check = ThreatIntelCheck("threat_intel", config['init_config'], [instance]) + mock_resp = _mock_response(json_data=MOCK_API_RESPONSE) + + with patch( + 'requests.Session.get', + side_effect=[mock_resp, Exception("API error")], + ): + check.check(None) + + aggregator.assert_service_check( + "threat_intel.can_connect", + AgentCheck.CRITICAL, + message="Failed to query one or more IP addresses.", + )