diff --git a/CHANGELOG.md b/CHANGELOG.md index bd92f906c..94da408e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## Unreleased + +* [Added] Add Cloud SIEM rule management and security signals retrieval. +* [Added] Add dogshell command for security monitoring rule and signal management. + ## v0.51.0 / 2025-01-27 * [Added] Add hosts endpoint. See [#884](https://github.com/DataDog/datadogpy/pull/884). diff --git a/datadog/api/__init__.py b/datadog/api/__init__.py index eb477c97d..f4b65bf38 100644 --- a/datadog/api/__init__.py +++ b/datadog/api/__init__.py @@ -50,3 +50,5 @@ from datadog.api.service_level_objectives import ServiceLevelObjective from datadog.api.synthetics import Synthetics from datadog.api.logs import Logs +from datadog.api.security_monitoring_rules import SecurityMonitoringRule +from datadog.api.security_monitoring_signals import SecurityMonitoringSignal diff --git a/datadog/api/security_monitoring_rules.py b/datadog/api/security_monitoring_rules.py new file mode 100644 index 000000000..eb5a1e280 --- /dev/null +++ b/datadog/api/security_monitoring_rules.py @@ -0,0 +1,93 @@ +# Unless explicitly stated otherwise all files in this repository are licensed under the BSD-3-Clause License. +# This product includes software developed at Datadog (https://www.datadoghq.com/). +# Copyright 2015-Present Datadog, Inc +""" +Security Monitoring Rule API. +""" + +from datadog.api.resources import ( + GetableAPIResource, + CreateableAPIResource, + ListableAPIResource, + UpdatableAPIResource, + DeletableAPIResource, + ActionAPIResource, +) + + +class SecurityMonitoringRule( + GetableAPIResource, + CreateableAPIResource, + ListableAPIResource, + UpdatableAPIResource, + DeletableAPIResource, + ActionAPIResource, +): + """ + A wrapper around Security Monitoring Rule API. + """ + + _resource_name = "security_monitoring/rules" + _api_version = "v2" + + @classmethod + def get_all(cls, **params): + """ + Get all security monitoring rules. + + :param params: additional parameters to filter security monitoring rules + :type params: dict + + :returns: Dictionary representing the API's JSON response + """ + return super(SecurityMonitoringRule, cls).get_all(**params) + + @classmethod + def get(cls, rule_id, **params): + """ + Get a security monitoring rule's details. + + :param rule_id: ID of the security monitoring rule + :type rule_id: str + + :returns: Dictionary representing the API's JSON response + """ + return super(SecurityMonitoringRule, cls).get(rule_id, **params) + + @classmethod + def create(cls, **params): + """ + Create a security monitoring rule. + + :param params: Parameters to create the security monitoring rule with + :type params: dict + + :returns: Dictionary representing the API's JSON response + """ + return super(SecurityMonitoringRule, cls).create(**params) + + @classmethod + def update(cls, rule_id, **params): + """ + Update a security monitoring rule. + + :param rule_id: ID of the security monitoring rule to update + :type rule_id: str + :param params: Parameters to update the security monitoring rule with + :type params: dict + + :returns: Dictionary representing the API's JSON response + """ + return super(SecurityMonitoringRule, cls).update(rule_id, **params) + + @classmethod + def delete(cls, rule_id, **params): + """ + Delete a security monitoring rule. + + :param rule_id: ID of the security monitoring rule to delete + :type rule_id: str + + :returns: Dictionary representing the API's JSON response + """ + return super(SecurityMonitoringRule, cls).delete(rule_id, **params) diff --git a/datadog/api/security_monitoring_signals.py b/datadog/api/security_monitoring_signals.py new file mode 100644 index 000000000..97d9d264f --- /dev/null +++ b/datadog/api/security_monitoring_signals.py @@ -0,0 +1,84 @@ +# Unless explicitly stated otherwise all files in this repository are licensed under the BSD-3-Clause License. +# This product includes software developed at Datadog (https://www.datadoghq.com/). +# Copyright 2015-Present Datadog, Inc +""" +Security Monitoring Signals API. +""" + +from datadog.api.resources import ( + GetableAPIResource, + ListableAPIResource, + SearchableAPIResource, + ActionAPIResource, +) + + +class SecurityMonitoringSignal( + GetableAPIResource, + ListableAPIResource, + SearchableAPIResource, + ActionAPIResource, +): + """ + A wrapper around Security Monitoring Signal API. + """ + + _resource_name = "security_monitoring/signals" + _api_version = "v2" + + @classmethod + def get(cls, signal_id, **params): + """ + Get a security signal's details. + + :param signal_id: ID of the security signal + :type signal_id: str + + :returns: Dictionary representing the API's JSON response + """ + return super(SecurityMonitoringSignal, cls).get(signal_id, **params) + + @classmethod + def get_all(cls, **params): + """ + Get all security signals. + + :param params: additional parameters to filter security signals + Valid options are: + - filter[query]: search query to filter security signals + - filter[from]: minimum timestamp for returned security signals + - filter[to]: maximum timestamp for returned security signals + - sort: sort order, can be 'timestamp', '-timestamp', etc. + - page[size]: number of signals to return per page + - page[cursor]: cursor to use for pagination + :type params: dict + + :returns: Dictionary representing the API's JSON response + """ + return super(SecurityMonitoringSignal, cls).get_all(**params) + + @classmethod + def change_triage_state(cls, signal_id, state, **params): + """ + Change the triage state of security signals. + + :param signal_id: signal ID to update + :type signal_id: str + :param state: new triage state ('open', 'archived', 'under_review') + :type state: str + :param params: additional parameters + :type params: dict + + :returns: Dictionary representing the API's JSON response + """ + body = { + "data": { + "attributes": { + "state": state, + }, + "id": signal_id, + "type": "signal_metadata", + } + } + + return cls._trigger_class_action("PATCH", "state", id=signal_id, **body) diff --git a/datadog/dogshell/__init__.py b/datadog/dogshell/__init__.py index e9e4d3423..9284d414a 100644 --- a/datadog/dogshell/__init__.py +++ b/datadog/dogshell/__init__.py @@ -27,6 +27,7 @@ from datadog.dogshell.tag import TagClient from datadog.dogshell.timeboard import TimeboardClient from datadog.dogshell.dashboard import DashboardClient +from datadog.dogshell.security_monitoring import SecurityMonitoringClient def main(): @@ -100,6 +101,7 @@ def main(): DowntimeClient.setup_parser(subparsers) ServiceCheckClient.setup_parser(subparsers) ServiceLevelObjectiveClient.setup_parser(subparsers) + SecurityMonitoringClient.setup_parser(subparsers) args = parser.parse_args() diff --git a/datadog/dogshell/security_monitoring.py b/datadog/dogshell/security_monitoring.py new file mode 100644 index 000000000..691f73818 --- /dev/null +++ b/datadog/dogshell/security_monitoring.py @@ -0,0 +1,264 @@ +# Unless explicitly stated otherwise all files in this repository are licensed under the BSD-3-Clause License. +# This product includes software developed at Datadog (https://www.datadoghq.com/). +# Copyright 2015-Present Datadog, Inc +""" +Security Monitoring client - dogshell implementation. +""" +from __future__ import print_function + +import json +import sys +from functools import wraps + +from datadog.dogshell.common import report_errors, report_warnings, print_err +from datadog.api.security_monitoring_rules import SecurityMonitoringRule +from datadog.api.security_monitoring_signals import SecurityMonitoringSignal +from datadog.util.format import pretty_json +from datadog import api + + +def api_cmd(f): + """ + Decorator for security monitoring commands. + """ + @wraps(f) + def wrapper(args): + """ + A decorator that reports errors and warnings. + """ + api._timeout = args.timeout + format = args.format + try: + res = f(args) + if res is None: + return 0 + if report_errors(res) or report_warnings(res): + return 1 + if format == "pretty": + print(pretty_json(res)) + else: + print(json.dumps(res)) + return 0 + except Exception as e: + print_err("ERROR: {}".format(str(e))) + return 1 + return wrapper + + +class SecurityMonitoringClient(object): + """ + SecurityMonitoring client implementing the dogshell interface. + """ + + @classmethod + def setup_parser(cls, subparsers): + """ + Set up the command line parser for security monitoring commands. + """ + parser = subparsers.add_parser( + "security-monitoring", help="Manage security monitoring rules and signals" + ) + parser.add_argument( + "--timeout", + type=int, + default=None, + help="Timeout in seconds", + ) + + sub_parsers = parser.add_subparsers(title="Commands", dest="sub_command") + sub_parsers.required = True + + # Rules commands + rule_parser = sub_parsers.add_parser("rules", help="Manage security monitoring rules") + rule_sub_parsers = rule_parser.add_subparsers(title="Commands", dest="rule_command") + rule_sub_parsers.required = True + + # Rules list + rule_list_parser = rule_sub_parsers.add_parser("list", help="List all security monitoring rules") + rule_list_parser.add_argument( + "--page-size", dest="page_size", type=int, help="Size for a given page. The maximum allowed value is 100" + ) + rule_list_parser.add_argument( + "--page-number", dest="page_number", help="Specific page number to return" + ) + rule_list_parser.set_defaults(func=cls._show_all_rules) + + # Rules get + rule_get_parser = rule_sub_parsers.add_parser("get", help="Get a security monitoring rule") + rule_get_parser.add_argument("rule_id", help="Rule ID") + rule_get_parser.set_defaults(func=cls._show_rule) + + # Rules create + rule_create_parser = rule_sub_parsers.add_parser("create", help="Create a security monitoring rule") + rule_create_parser.add_argument( + "--file", "-f", dest="file", required=True, help="JSON file with rule definition" + ) + rule_create_parser.set_defaults(func=cls._create_rule) + + # Rules update + rule_update_parser = rule_sub_parsers.add_parser("update", help="Update a security monitoring rule") + rule_update_parser.add_argument("rule_id", help="Rule ID") + rule_update_parser.add_argument( + "--file", "-f", dest="file", required=True, help="JSON file with rule definition" + ) + rule_update_parser.set_defaults(func=cls._update_rule) + + # Rules delete + rule_delete_parser = rule_sub_parsers.add_parser("delete", help="Delete a security monitoring rule") + rule_delete_parser.add_argument("rule_id", help="Rule ID") + rule_delete_parser.set_defaults(func=cls._delete_rule) + + # Signals commands + signal_parser = sub_parsers.add_parser("signals", help="Manage security monitoring signals") + signal_sub_parsers = signal_parser.add_subparsers(title="Commands", dest="signal_command") + signal_sub_parsers.required = True + + # Signals list + signal_list_parser = signal_sub_parsers.add_parser("list", help="List security monitoring signals") + signal_list_parser.add_argument( + "--query", dest="query", help="Query to filter signals" + ) + signal_list_parser.add_argument( + "--from", dest="from_time", help="From timestamp (e.g., 'now-1h', timestamp)" + ) + signal_list_parser.add_argument( + "--to", dest="to_time", help="To timestamp (e.g., 'now', timestamp)" + ) + signal_list_parser.add_argument( + "--sort", dest="sort", help="Sort order (e.g., '-timestamp')" + ) + signal_list_parser.add_argument( + "--page-size", dest="page_size", type=int, help="Number of results per page" + ) + signal_list_parser.add_argument( + "--page-cursor", dest="page_cursor", help="Cursor for pagination" + ) + signal_list_parser.set_defaults(func=cls._list_signals) + + # Signals get + signal_get_parser = signal_sub_parsers.add_parser("get", help="Get a security monitoring signal") + signal_get_parser.add_argument("signal_id", help="Signal ID") + signal_get_parser.set_defaults(func=cls._get_signal) + + # Signals change triage state + signal_triage_parser = signal_sub_parsers.add_parser( + "triage", help="Change triage state of security signals" + ) + signal_triage_parser.add_argument( + "signal_id", help="Signal ID" + ) + signal_triage_parser.add_argument( + "--state", dest="state", required=True, choices=["open", "archived", "under_review"], + help="New triage state (open, archived, under_review)" + ) + signal_triage_parser.set_defaults(func=cls._change_triage_state) + + @classmethod + def _show_rule(cls, args): + @api_cmd + def show_rule_cmd(args): + return SecurityMonitoringRule.get(args.rule_id) + return show_rule_cmd(args) + + @classmethod + def _show_all_rules(cls, args): + @api_cmd + def show_all_rules_cmd(args): + params = {} + + if args.page_size: + params["page[size]"] = args.page_size + if args.page_number: + params["page[number]"] = args.page_number + + return SecurityMonitoringRule.get_all(**params) + return show_all_rules_cmd(args) + + @classmethod + def _create_rule(cls, args): + """ + Create a security monitoring rule. + """ + @api_cmd + def create_rule_cmd(args): + try: + with open(args.file, "r") as f: + rule_data = json.load(f) + except Exception as e: + print("Error reading rule file: {}".format(str(e)), file=sys.stderr) + return {} + + return SecurityMonitoringRule.create(**rule_data) + return create_rule_cmd(args) + + @classmethod + def _update_rule(cls, args): + """ + Update a security monitoring rule. + """ + @api_cmd + def update_rule_cmd(args): + try: + with open(args.file, "r") as f: + rule_data = json.load(f) + except Exception as e: + print("Error reading rule file: {}".format(str(e)), file=sys.stderr) + return {} + + return SecurityMonitoringRule.update(args.rule_id, **rule_data) + return update_rule_cmd(args) + + @classmethod + def _delete_rule(cls, args): + """ + Delete a security monitoring rule. + """ + @api_cmd + def delete_rule_cmd(args): + return SecurityMonitoringRule.delete(args.rule_id) + return delete_rule_cmd(args) + + @classmethod + def _list_signals(cls, args): + """ + List security monitoring signals. + """ + @api_cmd + def list_signals_cmd(args): + params = {} + + if args.query: + params["filter[query]"] = args.query + if args.from_time: + params["filter[from]"] = args.from_time + if args.to_time: + params["filter[to]"] = args.to_time + if args.sort: + params["sort"] = args.sort + if args.page_size: + params["page[size]"] = args.page_size + if args.page_cursor: + params["page[cursor]"] = args.page_cursor + + return SecurityMonitoringSignal.get_all(**params) + return list_signals_cmd(args) + + @classmethod + def _get_signal(cls, args): + """ + Get a security monitoring signal. + """ + @api_cmd + def get_signal_cmd(args): + return SecurityMonitoringSignal.get(args.signal_id) + return get_signal_cmd(args) + + @classmethod + def _change_triage_state(cls, args): + """ + Change triage state of security signals. + """ + @api_cmd + def change_triage_state_cmd(args): + return SecurityMonitoringSignal.change_triage_state(args.signal_id, args.state) + return change_triage_state_cmd(args) diff --git a/tests/integration/api/cassettes/TestDatadog.test_security_monitoring_rules.frozen b/tests/integration/api/cassettes/TestDatadog.test_security_monitoring_rules.frozen new file mode 100644 index 000000000..f41eed70f --- /dev/null +++ b/tests/integration/api/cassettes/TestDatadog.test_security_monitoring_rules.frozen @@ -0,0 +1 @@ +2025-03-28T10:10:35.967938+01:00 \ No newline at end of file diff --git a/tests/integration/api/cassettes/TestDatadog.test_security_monitoring_rules.yaml b/tests/integration/api/cassettes/TestDatadog.test_security_monitoring_rules.yaml new file mode 100644 index 000000000..978f6a9f8 --- /dev/null +++ b/tests/integration/api/cassettes/TestDatadog.test_security_monitoring_rules.yaml @@ -0,0 +1,263 @@ +interactions: +- request: + body: '{"cases": [{"condition": "a > 0", "name": "test case", "status": "info"}], + "isEnabled": true, "message": "Test rule generated via API", "name": "Test Rule + 1743153035", "options": {"evaluationWindow": 3600, "keepAlive": 3600, "maxSignalDuration": + 86400}, "queries": [{"distinctFields": [], "groupByFields": [], "name": "a", + "query": "source:test"}], "tags": ["test:true", "env:test"], "type": "log_detection"}' + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + Content-Length: + - '408' + Content-Type: + - application/json + User-Agent: + - datadogpy/0.51.1-dev (python 3.10.6; os darwin; arch arm64) + method: POST + uri: https://api.datadoghq.com/api/v2/security_monitoring/rules + response: + body: + string: '{"name":"Test Rule 1743153035","createdAt":1743156728662,"isDefault":false,"isPartner":false,"isEnabled":true,"isBeta":false,"isDeleted":false,"isDeprecated":false,"queries":[{"query":"source:test","groupByFields":[],"hasOptionalGroupByFields":false,"distinctFields":[],"aggregation":"count","name":"a","dataSource":"logs"}],"options":{"evaluationWindow":3600,"detectionMethod":"threshold","maxSignalDuration":86400,"keepAlive":3600},"cases":[{"name":"test + case","status":"info","notifications":[],"condition":"a \u003e 0"}],"message":"Test + rule generated via API","tags":["env:test","test:true"],"hasExtendedTitle":false,"type":"log_detection","filters":[],"version":1,"id":"ns0-4ha-0kz","blocking":false,"metadata":{"entities":null,"sources":null},"creationAuthorId":1445416,"creator":{"handle":"frog@datadoghq.com","name":"frog"},"updater":{"handle":"","name":""}}' + headers: + Connection: + - keep-alive + Content-Length: + - '867' + Content-Type: + - application/json + Date: + - Fri, 28 Mar 2025 10:12:08 GMT + content-security-policy: + - frame-ancestors 'self'; report-uri https://logs.browser-intake-datadoghq.com/api/v2/logs?dd-api-key=pube4f163c23bbf91c16b8f57f56af9fc58&dd-evp-origin=content-security-policy&ddsource=csp-report&ddtags=site%3Adatadoghq.com + strict-transport-security: + - max-age=31536000; includeSubDomains; preload + vary: + - Accept-Encoding + x-content-type-options: + - nosniff + x-frame-options: + - SAMEORIGIN + x-ratelimit-limit: + - '1000' + x-ratelimit-name: + - security_analytics + x-ratelimit-period: + - '10' + x-ratelimit-remaining: + - '999' + x-ratelimit-reset: + - '2' + status: + code: 200 + message: OK +- request: + body: null + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + User-Agent: + - datadogpy/0.51.1-dev (python 3.10.6; os darwin; arch arm64) + method: GET + uri: https://api.datadoghq.com/api/v2/security_monitoring/rules/ns0-4ha-0kz + response: + body: + string: '{"name":"Test Rule 1743153035","createdAt":1743156728662,"isDefault":false,"isPartner":false,"isEnabled":true,"isBeta":false,"isDeleted":false,"isDeprecated":false,"queries":[{"query":"source:test","groupByFields":[],"hasOptionalGroupByFields":false,"distinctFields":[],"aggregation":"count","name":"a","dataSource":"logs"}],"options":{"evaluationWindow":3600,"detectionMethod":"threshold","maxSignalDuration":86400,"keepAlive":3600},"cases":[{"name":"test + case","status":"info","notifications":[],"condition":"a \u003e 0"}],"message":"Test + rule generated via API","tags":["env:test","test:true"],"hasExtendedTitle":false,"type":"log_detection","filters":[],"version":1,"id":"ns0-4ha-0kz","blocking":false,"metadata":{"entities":null,"sources":null},"creationAuthorId":1445416,"creator":{"handle":"frog@datadoghq.com","name":"frog"},"updater":{"handle":"","name":""}}' + headers: + Connection: + - keep-alive + Content-Length: + - '867' + Content-Type: + - application/json + Date: + - Fri, 28 Mar 2025 10:12:09 GMT + content-security-policy: + - frame-ancestors 'self'; report-uri https://logs.browser-intake-datadoghq.com/api/v2/logs?dd-api-key=pube4f163c23bbf91c16b8f57f56af9fc58&dd-evp-origin=content-security-policy&ddsource=csp-report&ddtags=site%3Adatadoghq.com + strict-transport-security: + - max-age=31536000; includeSubDomains; preload + vary: + - Accept-Encoding + x-content-type-options: + - nosniff + x-frame-options: + - SAMEORIGIN + x-ratelimit-limit: + - '1000' + x-ratelimit-name: + - security_analytics + x-ratelimit-period: + - '10' + x-ratelimit-remaining: + - '998' + x-ratelimit-reset: + - '1' + status: + code: 200 + message: OK +- request: + body: '{"cases": [{"condition": "a > 0", "name": "test case", "status": "info"}], + "isEnabled": true, "message": "Test rule generated via API", "name": "Updated + Rule 1743153035", "options": {"evaluationWindow": 3600, "keepAlive": 3600, "maxSignalDuration": + 86400}, "queries": [{"distinctFields": [], "groupByFields": [], "name": "a", + "query": "source:test"}], "tags": ["test:true", "env:test"], "type": "log_detection"}' + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + Content-Length: + - '411' + Content-Type: + - application/json + User-Agent: + - datadogpy/0.51.1-dev (python 3.10.6; os darwin; arch arm64) + method: PUT + uri: https://api.datadoghq.com/api/v2/security_monitoring/rules/ns0-4ha-0kz + response: + body: + string: '{"name":"Updated Rule 1743153035","isEnabled":true,"queries":[{"query":"source:test","groupByFields":[],"hasOptionalGroupByFields":false,"distinctFields":[],"aggregation":"count","name":"a","dataSource":"logs"}],"options":{"evaluationWindow":3600,"detectionMethod":"threshold","maxSignalDuration":86400,"keepAlive":3600},"cases":[{"name":"test + case","status":"info","notifications":[],"condition":"a \u003e 0"}],"message":"Test + rule generated via API","tags":["env:test","test:true"],"hasExtendedTitle":false,"type":"log_detection","filters":[],"id":"ns0-4ha-0kz","version":2,"createdAt":1743156728662,"creationAuthorId":1445416,"updateAuthorId":1445416,"updatedAt":1743156729592,"isDefault":false,"blocking":false,"isBeta":false,"isDeleted":false,"isDeprecated":false,"metadata":{"entities":null,"sources":null}}' + headers: + Connection: + - keep-alive + Content-Length: + - '813' + Content-Type: + - application/json + Date: + - Fri, 28 Mar 2025 10:12:09 GMT + content-security-policy: + - frame-ancestors 'self'; report-uri https://logs.browser-intake-datadoghq.com/api/v2/logs?dd-api-key=pube4f163c23bbf91c16b8f57f56af9fc58&dd-evp-origin=content-security-policy&ddsource=csp-report&ddtags=site%3Adatadoghq.com + strict-transport-security: + - max-age=31536000; includeSubDomains; preload + vary: + - Accept-Encoding + x-content-type-options: + - nosniff + x-frame-options: + - SAMEORIGIN + x-ratelimit-limit: + - '1000' + x-ratelimit-name: + - security_analytics + x-ratelimit-period: + - '10' + x-ratelimit-remaining: + - '997' + x-ratelimit-reset: + - '1' + status: + code: 200 + message: OK +- request: + body: null + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + Content-Length: + - '0' + User-Agent: + - datadogpy/0.51.1-dev (python 3.10.6; os darwin; arch arm64) + method: DELETE + uri: https://api.datadoghq.com/api/v2/security_monitoring/rules/ns0-4ha-0kz + response: + body: + string: '' + headers: + Connection: + - keep-alive + Date: + - Fri, 28 Mar 2025 10:12:10 GMT + content-security-policy: + - frame-ancestors 'self'; report-uri https://logs.browser-intake-datadoghq.com/api/v2/logs?dd-api-key=pube4f163c23bbf91c16b8f57f56af9fc58&dd-evp-origin=content-security-policy&ddsource=csp-report&ddtags=site%3Adatadoghq.com + strict-transport-security: + - max-age=31536000; includeSubDomains; preload + vary: + - Accept-Encoding + x-content-type-options: + - nosniff + x-frame-options: + - SAMEORIGIN + x-ratelimit-limit: + - '1000' + x-ratelimit-name: + - security_analytics + x-ratelimit-period: + - '10' + x-ratelimit-remaining: + - '999' + x-ratelimit-reset: + - '10' + status: + code: 204 + message: No Content +- request: + body: null + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + User-Agent: + - datadogpy/0.51.1-dev (python 3.10.6; os darwin; arch arm64) + method: GET + uri: https://api.datadoghq.com/api/v2/security_monitoring/rules/ns0-4ha-0kz + response: + body: + string: '{"error":{"code":"NotFound","message":"Threat detection rule not found: + ns0-4ha-0kz"}}' + headers: + Connection: + - keep-alive + Content-Length: + - '86' + Content-Type: + - application/json + Date: + - Fri, 28 Mar 2025 10:12:10 GMT + content-security-policy: + - frame-ancestors 'self'; report-uri https://logs.browser-intake-datadoghq.com/api/v2/logs?dd-api-key=pube4f163c23bbf91c16b8f57f56af9fc58&dd-evp-origin=content-security-policy&ddsource=csp-report&ddtags=site%3Adatadoghq.com + strict-transport-security: + - max-age=31536000; includeSubDomains; preload + vary: + - Accept-Encoding + x-content-type-options: + - nosniff + x-frame-options: + - SAMEORIGIN + x-ratelimit-limit: + - '1000' + x-ratelimit-name: + - security_analytics + x-ratelimit-period: + - '10' + x-ratelimit-remaining: + - '998' + x-ratelimit-reset: + - '10' + status: + code: 404 + message: Not Found +version: 1 diff --git a/tests/integration/api/cassettes/TestDatadog.test_security_monitoring_signals.frozen b/tests/integration/api/cassettes/TestDatadog.test_security_monitoring_signals.frozen new file mode 100644 index 000000000..7bab8a350 --- /dev/null +++ b/tests/integration/api/cassettes/TestDatadog.test_security_monitoring_signals.frozen @@ -0,0 +1 @@ +2025-03-28T11:48:05.750956+01:00 \ No newline at end of file diff --git a/tests/integration/api/cassettes/TestDatadog.test_security_monitoring_signals.yaml b/tests/integration/api/cassettes/TestDatadog.test_security_monitoring_signals.yaml new file mode 100644 index 000000000..5c60b61f6 --- /dev/null +++ b/tests/integration/api/cassettes/TestDatadog.test_security_monitoring_signals.yaml @@ -0,0 +1,52 @@ +interactions: +- request: + body: null + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + User-Agent: + - datadogpy/0.51.1-dev (python 3.10.6; os darwin; arch arm64) + method: GET + uri: https://api.datadoghq.com/api/v2/security_monitoring/signals?filter%5Bfrom%5D=2025-03-28T09%3A48%3A05%2B00%3A00&filter%5Bquery%5D=%2A&filter%5Bto%5D=2025-03-28T10%3A48%3A05%2B00%3A00&page%5Bsize%5D=10&sort=-timestamp + response: + body: + string: '{"data":[],"meta":{"elapsed":40,"request_id":"pddv1ChZmcTZ6UC00OVR2eV9mUFpzdnlTaW5RIisKG6GoW4kkIgv_EF8ff1Kd7QSfjHsxhGew-iMxZxIMSR0sPZ1zHds6RZgE","status":"done"}} + + ' + headers: + Connection: + - keep-alive + Content-Type: + - application/json + Date: + - Fri, 28 Mar 2025 10:48:06 GMT + Transfer-Encoding: + - chunked + content-security-policy: + - frame-ancestors 'self'; report-uri https://logs.browser-intake-datadoghq.com/api/v2/logs?dd-api-key=pube4f163c23bbf91c16b8f57f56af9fc58&dd-evp-origin=content-security-policy&ddsource=csp-report&ddtags=site%3Adatadoghq.com + strict-transport-security: + - max-age=31536000; includeSubDomains; preload + vary: + - Accept-Encoding + x-content-type-options: + - nosniff + x-frame-options: + - SAMEORIGIN + x-ratelimit-limit: + - '1000' + x-ratelimit-name: + - security_analytics + x-ratelimit-period: + - '10' + x-ratelimit-remaining: + - '999' + x-ratelimit-reset: + - '4' + status: + code: 200 + message: OK +version: 1 diff --git a/tests/integration/api/test_api.py b/tests/integration/api/test_api.py index 3c8d87249..ee2d9caac 100644 --- a/tests/integration/api/test_api.py +++ b/tests/integration/api/test_api.py @@ -989,6 +989,97 @@ def test_roles_crud(self, dog): res = dog.Roles.get(role_uuid) assert "errors" in res + def test_security_monitoring_rules(self, dog, get_with_retry, freezer): + """ + Test security monitoring rules CRUD operations. + """ + with freezer: + timestamp = int(time.time()) + + # Create a security monitoring rule + rule_name = "Test Rule {}".format(timestamp) + rule_data = { + "name": rule_name, + "queries": [ + { + "query": "source:test", + "groupByFields": [], + "distinctFields": [], + "name": "a" + } + ], + "cases": [ + { + "name": "test case", + "condition": "a > 0", + "status": "info" + } + ], + "options": { + "evaluationWindow": 3600, + "keepAlive": 3600, + "maxSignalDuration": 86400 + }, + "message": "Test rule generated via API", + "tags": ["test:true", "env:test"], + "isEnabled": True, + "type": "log_detection" + } + + rule = dog.SecurityMonitoringRule.create(**rule_data) + assert rule["name"] == rule_name + assert rule["message"] == "Test rule generated via API" + assert rule["isEnabled"] is True + assert "id" in rule + + rule_id = rule["id"] + + # Get the rule + retrieved_rule = get_with_retry("SecurityMonitoringRule", rule_id) + assert retrieved_rule["id"] == rule_id + assert retrieved_rule["name"] == rule_name + + # Update the rule + updated_name = "Updated Rule {}".format(timestamp) + update_data = dict(rule_data) + update_data["name"] = updated_name + + updated_rule = dog.SecurityMonitoringRule.update(rule_id, **update_data) + assert updated_rule["id"] == rule_id + assert updated_rule["name"] == updated_name + + # Skip the list rule since there is not filtering + + # Delete the rule + dog.SecurityMonitoringRule.delete(rule_id) + retrieved_rule = get_with_retry("SecurityMonitoringRule", rule_id) + assert retrieved_rule["error"] is not None + assert retrieved_rule["error"]["code"] == "NotFound" + assert retrieved_rule["error"]["message"] == "Threat detection rule not found: {}".format(rule_id) + + def test_security_monitoring_signals(self, dog, freezer): + """ + Test security monitoring signals API. + Note: This test might be limited as signals cannot be created directly via API. + We'll test the list functionality with filters. + """ + with freezer: + now_time = datetime.datetime.now(datetime.timezone.utc) + now = now_time.replace(microsecond=0).isoformat() + one_hour_ago = (now_time - datetime.timedelta(hours=1)).replace(microsecond=0).isoformat() + + # List signals with various filters + signals = dog.SecurityMonitoringSignal.get_all( + **{ + "filter[query]": "*", + "filter[from]": one_hour_ago, + "filter[to]": now, + "sort": "-timestamp", + "page[size]": 10 + } + ) + assert signals["data"] is not None + @mock.patch('datadog.api._return_raw_response', True) def test_user_agent(self, dog): _, resp = dog.api_client.APIClient.submit('GET', 'validate') diff --git a/tests/integration/dogshell/cassettes/TestDogshell.test_security_monitoring_rules.frozen b/tests/integration/dogshell/cassettes/TestDogshell.test_security_monitoring_rules.frozen new file mode 100644 index 000000000..496d45bbe --- /dev/null +++ b/tests/integration/dogshell/cassettes/TestDogshell.test_security_monitoring_rules.frozen @@ -0,0 +1 @@ +2025-03-28T10:10:36.003922+01:00 \ No newline at end of file diff --git a/tests/integration/dogshell/cassettes/TestDogshell.test_security_monitoring_rules.seed b/tests/integration/dogshell/cassettes/TestDogshell.test_security_monitoring_rules.seed new file mode 100644 index 000000000..158d0ab7a --- /dev/null +++ b/tests/integration/dogshell/cassettes/TestDogshell.test_security_monitoring_rules.seed @@ -0,0 +1 @@ +64483 \ No newline at end of file diff --git a/tests/integration/dogshell/cassettes/TestDogshell.test_security_monitoring_rules.yaml b/tests/integration/dogshell/cassettes/TestDogshell.test_security_monitoring_rules.yaml new file mode 100644 index 000000000..4cbb05d22 --- /dev/null +++ b/tests/integration/dogshell/cassettes/TestDogshell.test_security_monitoring_rules.yaml @@ -0,0 +1,216 @@ +interactions: +- request: + body: '{"cases": [{"condition": "a > 1", "name": "test case", "status": "info"}], + "isEnabled": true, "message": "Test rule generated via dogshell", "name": "Test + Rule 93e6bea490920eb10a6cede03c221fc4", "options": {"evaluationWindow": 3600, + "keepAlive": 3600, "maxSignalDuration": 86400}, "queries": [{"distinctFields": + [], "groupByFields": [], "name": "a", "query": "source:test"}], "tags": ["test:true", + "env:frog"], "type": "log_detection"}' + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + Content-Length: + - '435' + Content-Type: + - application/json + User-Agent: + - datadogpy/0.51.1-dev (python 3.10.6; os darwin; arch arm64) + method: POST + uri: https://api.datadoghq.com/api/v2/security_monitoring/rules + response: + body: + string: '{"name":"Test Rule 93e6bea490920eb10a6cede03c221fc4","createdAt":1743153036588,"isDefault":false,"isPartner":false,"isEnabled":true,"isBeta":false,"isDeleted":false,"isDeprecated":false,"queries":[{"query":"source:test","groupByFields":[],"hasOptionalGroupByFields":false,"distinctFields":[],"aggregation":"count","name":"a","dataSource":"logs"}],"options":{"evaluationWindow":3600,"detectionMethod":"threshold","maxSignalDuration":86400,"keepAlive":3600},"cases":[{"name":"test + case","status":"info","notifications":[],"condition":"a \u003e 1"}],"message":"Test + rule generated via dogshell","tags":["env:frog","test:true"],"hasExtendedTitle":false,"type":"log_detection","filters":[],"version":1,"id":"oag-yse-dt1","blocking":false,"metadata":{"entities":null,"sources":null},"creationAuthorId":1445416,"creator":{"handle":"frog@datadoghq.com","name":"frog"},"updater":{"handle":"","name":""}}' + headers: + Connection: + - keep-alive + Content-Length: + - '894' + Content-Type: + - application/json + Date: + - Fri, 28 Mar 2025 09:10:36 GMT + content-security-policy: + - frame-ancestors 'self'; report-uri https://logs.browser-intake-datadoghq.com/api/v2/logs?dd-api-key=pube4f163c23bbf91c16b8f57f56af9fc58&dd-evp-origin=content-security-policy&ddsource=csp-report&ddtags=site%3Adatadoghq.com + strict-transport-security: + - max-age=31536000; includeSubDomains; preload + vary: + - Accept-Encoding + x-content-type-options: + - nosniff + x-frame-options: + - SAMEORIGIN + x-ratelimit-limit: + - '1000' + x-ratelimit-name: + - security_analytics + x-ratelimit-period: + - '10' + x-ratelimit-remaining: + - '999' + x-ratelimit-reset: + - '4' + status: + code: 200 + message: OK +- request: + body: null + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + User-Agent: + - datadogpy/0.51.1-dev (python 3.10.6; os darwin; arch arm64) + method: GET + uri: https://api.datadoghq.com/api/v2/security_monitoring/rules/oag-yse-dt1 + response: + body: + string: '{"name":"Test Rule 93e6bea490920eb10a6cede03c221fc4","createdAt":1743153036588,"isDefault":false,"isPartner":false,"isEnabled":true,"isBeta":false,"isDeleted":false,"isDeprecated":false,"queries":[{"query":"source:test","groupByFields":[],"hasOptionalGroupByFields":false,"distinctFields":[],"aggregation":"count","name":"a","dataSource":"logs"}],"options":{"evaluationWindow":3600,"detectionMethod":"threshold","maxSignalDuration":86400,"keepAlive":3600},"cases":[{"name":"test + case","status":"info","notifications":[],"condition":"a \u003e 1"}],"message":"Test + rule generated via dogshell","tags":["env:frog","test:true"],"hasExtendedTitle":false,"type":"log_detection","filters":[],"version":1,"id":"oag-yse-dt1","blocking":false,"metadata":{"entities":null,"sources":null},"creationAuthorId":1445416,"creator":{"handle":"frog@datadoghq.com","name":"frog"},"updater":{"handle":"","name":""}}' + headers: + Connection: + - keep-alive + Content-Length: + - '894' + Content-Type: + - application/json + Date: + - Fri, 28 Mar 2025 09:10:37 GMT + content-security-policy: + - frame-ancestors 'self'; report-uri https://logs.browser-intake-datadoghq.com/api/v2/logs?dd-api-key=pube4f163c23bbf91c16b8f57f56af9fc58&dd-evp-origin=content-security-policy&ddsource=csp-report&ddtags=site%3Adatadoghq.com + strict-transport-security: + - max-age=31536000; includeSubDomains; preload + vary: + - Accept-Encoding + x-content-type-options: + - nosniff + x-frame-options: + - SAMEORIGIN + x-ratelimit-limit: + - '1000' + x-ratelimit-name: + - security_analytics + x-ratelimit-period: + - '10' + x-ratelimit-remaining: + - '998' + x-ratelimit-reset: + - '3' + status: + code: 200 + message: OK +- request: + body: '{"cases": [{"condition": "a > 1", "name": "test case", "status": "info"}], + "isEnabled": true, "message": "Test rule generated via dogshell", "name": "Updated + Rule 93e6bea490920eb10a6cede03c221fc4", "options": {"evaluationWindow": 3600, + "keepAlive": 3600, "maxSignalDuration": 86400}, "queries": [{"distinctFields": + [], "groupByFields": [], "name": "a", "query": "source:test"}], "tags": ["test:true", + "env:frog"], "type": "log_detection"}' + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + Content-Length: + - '438' + Content-Type: + - application/json + User-Agent: + - datadogpy/0.51.1-dev (python 3.10.6; os darwin; arch arm64) + method: PUT + uri: https://api.datadoghq.com/api/v2/security_monitoring/rules/oag-yse-dt1 + response: + body: + string: '{"name":"Updated Rule 93e6bea490920eb10a6cede03c221fc4","isEnabled":true,"queries":[{"query":"source:test","groupByFields":[],"hasOptionalGroupByFields":false,"distinctFields":[],"aggregation":"count","name":"a","dataSource":"logs"}],"options":{"evaluationWindow":3600,"detectionMethod":"threshold","maxSignalDuration":86400,"keepAlive":3600},"cases":[{"name":"test + case","status":"info","notifications":[],"condition":"a \u003e 1"}],"message":"Test + rule generated via dogshell","tags":["env:frog","test:true"],"hasExtendedTitle":false,"type":"log_detection","filters":[],"id":"oag-yse-dt1","version":2,"createdAt":1743153036588,"creationAuthorId":1445416,"updateAuthorId":1445416,"updatedAt":1743153037779,"isDefault":false,"blocking":false,"isBeta":false,"isDeleted":false,"isDeprecated":false,"metadata":{"entities":null,"sources":null}}' + headers: + Connection: + - keep-alive + Content-Length: + - '840' + Content-Type: + - application/json + Date: + - Fri, 28 Mar 2025 09:10:37 GMT + content-security-policy: + - frame-ancestors 'self'; report-uri https://logs.browser-intake-datadoghq.com/api/v2/logs?dd-api-key=pube4f163c23bbf91c16b8f57f56af9fc58&dd-evp-origin=content-security-policy&ddsource=csp-report&ddtags=site%3Adatadoghq.com + strict-transport-security: + - max-age=31536000; includeSubDomains; preload + vary: + - Accept-Encoding + x-content-type-options: + - nosniff + x-frame-options: + - SAMEORIGIN + x-ratelimit-limit: + - '1000' + x-ratelimit-name: + - security_analytics + x-ratelimit-period: + - '10' + x-ratelimit-remaining: + - '997' + x-ratelimit-reset: + - '3' + status: + code: 200 + message: OK +- request: + body: null + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + Content-Length: + - '0' + User-Agent: + - datadogpy/0.51.1-dev (python 3.10.6; os darwin; arch arm64) + method: DELETE + uri: https://api.datadoghq.com/api/v2/security_monitoring/rules/oag-yse-dt1 + response: + body: + string: '' + headers: + Connection: + - keep-alive + Date: + - Fri, 28 Mar 2025 09:10:38 GMT + content-security-policy: + - frame-ancestors 'self'; report-uri https://logs.browser-intake-datadoghq.com/api/v2/logs?dd-api-key=pube4f163c23bbf91c16b8f57f56af9fc58&dd-evp-origin=content-security-policy&ddsource=csp-report&ddtags=site%3Adatadoghq.com + strict-transport-security: + - max-age=31536000; includeSubDomains; preload + vary: + - Accept-Encoding + x-content-type-options: + - nosniff + x-frame-options: + - SAMEORIGIN + x-ratelimit-limit: + - '1000' + x-ratelimit-name: + - security_analytics + x-ratelimit-period: + - '10' + x-ratelimit-remaining: + - '996' + x-ratelimit-reset: + - '2' + status: + code: 204 + message: No Content +version: 1 diff --git a/tests/integration/dogshell/cassettes/TestDogshell.test_security_monitoring_signals.frozen b/tests/integration/dogshell/cassettes/TestDogshell.test_security_monitoring_signals.frozen new file mode 100644 index 000000000..df35edf12 --- /dev/null +++ b/tests/integration/dogshell/cassettes/TestDogshell.test_security_monitoring_signals.frozen @@ -0,0 +1 @@ +2025-03-28T11:48:06.436084+01:00 \ No newline at end of file diff --git a/tests/integration/dogshell/cassettes/TestDogshell.test_security_monitoring_signals.yaml b/tests/integration/dogshell/cassettes/TestDogshell.test_security_monitoring_signals.yaml new file mode 100644 index 000000000..e214f544a --- /dev/null +++ b/tests/integration/dogshell/cassettes/TestDogshell.test_security_monitoring_signals.yaml @@ -0,0 +1,212 @@ +interactions: +- request: + body: null + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + User-Agent: + - datadogpy/0.51.1-dev (python 3.10.6; os darwin; arch arm64) + method: GET + uri: https://api.datadoghq.com/api/v2/security_monitoring/signals/abcd1234-abcd-1234-abcd-1234abcd1234 + response: + body: + string: '{"errors":["This endpoint has encountered an internal server error"]}' + headers: + Connection: + - keep-alive + Content-Type: + - application/json + Date: + - Fri, 28 Mar 2025 10:47:32 GMT + Transfer-Encoding: + - chunked + content-encoding: + - gzip + content-security-policy: + - frame-ancestors 'self'; report-uri https://logs.browser-intake-datadoghq.com/api/v2/logs?dd-api-key=pube4f163c23bbf91c16b8f57f56af9fc58&dd-evp-origin=content-security-policy&ddsource=csp-report&ddtags=site%3Adatadoghq.com + strict-transport-security: + - max-age=31536000; includeSubDomains; preload + vary: + - Accept-Encoding + x-content-type-options: + - nosniff + x-frame-options: + - SAMEORIGIN + x-ratelimit-limit: + - '1000' + x-ratelimit-name: + - security_analytics + x-ratelimit-period: + - '10' + x-ratelimit-remaining: + - '998' + x-ratelimit-reset: + - '8' + status: + code: 400 + message: Bad Request +- request: + body: '{"data": {"attributes": {"state": "archived"}, "id": "abcd1234-abcd-1234-abcd-1234abcd1234", + "type": "signal_metadata"}}' + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + Content-Length: + - '120' + Content-Type: + - application/json + User-Agent: + - datadogpy/0.51.1-dev (python 3.10.6; os darwin; arch arm64) + method: PATCH + uri: https://api.datadoghq.com/api/v2/security_monitoring/signals/abcd1234-abcd-1234-abcd-1234abcd1234/state + response: + body: + string: '{"errors":["This endpoint has encountered an internal server error"]}' + headers: + Connection: + - keep-alive + Content-Type: + - application/json + Date: + - Fri, 28 Mar 2025 10:47:32 GMT + Transfer-Encoding: + - chunked + content-encoding: + - gzip + content-security-policy: + - frame-ancestors 'self'; report-uri https://logs.browser-intake-datadoghq.com/api/v2/logs?dd-api-key=pube4f163c23bbf91c16b8f57f56af9fc58&dd-evp-origin=content-security-policy&ddsource=csp-report&ddtags=site%3Adatadoghq.com + strict-transport-security: + - max-age=31536000; includeSubDomains; preload + vary: + - Accept-Encoding + x-content-type-options: + - nosniff + x-frame-options: + - SAMEORIGIN + x-ratelimit-limit: + - '1000' + x-ratelimit-name: + - security_analytics + x-ratelimit-period: + - '10' + x-ratelimit-remaining: + - '997' + x-ratelimit-reset: + - '8' + status: + code: 400 + message: Bad Request +- request: + body: null + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + User-Agent: + - datadogpy/0.51.1-dev (python 3.10.6; os darwin; arch arm64) + method: GET + uri: https://api.datadoghq.com/api/v2/security_monitoring/signals/abcd1234-abcd-1234-abcd-1234abcd1234 + response: + body: + string: '{"errors":["This endpoint has encountered an internal server error"]}' + headers: + Connection: + - keep-alive + Content-Type: + - application/json + Date: + - Fri, 28 Mar 2025 10:48:06 GMT + Transfer-Encoding: + - chunked + content-encoding: + - gzip + content-security-policy: + - frame-ancestors 'self'; report-uri https://logs.browser-intake-datadoghq.com/api/v2/logs?dd-api-key=pube4f163c23bbf91c16b8f57f56af9fc58&dd-evp-origin=content-security-policy&ddsource=csp-report&ddtags=site%3Adatadoghq.com + strict-transport-security: + - max-age=31536000; includeSubDomains; preload + vary: + - Accept-Encoding + x-content-type-options: + - nosniff + x-frame-options: + - SAMEORIGIN + x-ratelimit-limit: + - '1000' + x-ratelimit-name: + - security_analytics + x-ratelimit-period: + - '10' + x-ratelimit-remaining: + - '998' + x-ratelimit-reset: + - '4' + status: + code: 400 + message: Bad Request +- request: + body: '{"data": {"attributes": {"state": "archived"}, "id": "abcd1234-abcd-1234-abcd-1234abcd1234", + "type": "signal_metadata"}}' + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + Content-Length: + - '120' + Content-Type: + - application/json + User-Agent: + - datadogpy/0.51.1-dev (python 3.10.6; os darwin; arch arm64) + method: PATCH + uri: https://api.datadoghq.com/api/v2/security_monitoring/signals/abcd1234-abcd-1234-abcd-1234abcd1234/state + response: + body: + string: '{"errors":["This endpoint has encountered an internal server error"]}' + headers: + Connection: + - keep-alive + Content-Type: + - application/json + Date: + - Fri, 28 Mar 2025 10:48:07 GMT + Transfer-Encoding: + - chunked + content-encoding: + - gzip + content-security-policy: + - frame-ancestors 'self'; report-uri https://logs.browser-intake-datadoghq.com/api/v2/logs?dd-api-key=pube4f163c23bbf91c16b8f57f56af9fc58&dd-evp-origin=content-security-policy&ddsource=csp-report&ddtags=site%3Adatadoghq.com + strict-transport-security: + - max-age=31536000; includeSubDomains; preload + vary: + - Accept-Encoding + x-content-type-options: + - nosniff + x-frame-options: + - SAMEORIGIN + x-ratelimit-limit: + - '1000' + x-ratelimit-name: + - security_analytics + x-ratelimit-period: + - '10' + x-ratelimit-remaining: + - '997' + x-ratelimit-reset: + - '3' + status: + code: 400 + message: Bad Request +version: 1 diff --git a/tests/integration/dogshell/test_dogshell.py b/tests/integration/dogshell/test_dogshell.py index 4d9c7a608..a28afee4c 100644 --- a/tests/integration/dogshell/test_dogshell.py +++ b/tests/integration/dogshell/test_dogshell.py @@ -1,6 +1,7 @@ # Unless explicitly stated otherwise all files in this repository are licensed under the BSD-3-Clause License. # This product includes software developed at Datadog (https://www.datadoghq.com/). # Copyright 2015-Present Datadog, Inc +import datetime from hashlib import md5 import json import os @@ -710,6 +711,123 @@ def test_service_check(self, dogshell): out, _, _ = dogshell(["service_check", "check", "check_pg", "host0", "1"]) out = json.loads(out) assert out["status"], "ok" + + def test_security_monitoring_rules(self, dogshell, get_unique, tmp_path): + """ + Test dogshell security-monitoring rules commands. + """ + # Generate a unique rule name to avoid conflicts + unique = get_unique() + rule_name = "Test Rule {}".format(unique) + + # Create a rule file + rule_file = tmp_path / "rule.json" + rule_data = { + "name": rule_name, + "queries": [ + { + "query": "source:test", + "groupByFields": [], + "distinctFields": [], + "name": "a" + } + ], + "cases": [ + { + "name": "test case", + "condition": "a > 1", + "status": "info" + } + ], + "options": { + "evaluationWindow": 3600, + "keepAlive": 3600, + "maxSignalDuration": 86400 + }, + "message": "Test rule generated via dogshell", + "tags": ["test:true", "env:frog"], + "isEnabled": True, + "type": "log_detection" + } + + with open(rule_file, "w") as f: + json.dump(rule_data, f) + + # Create rule + out, _, _ = dogshell(["security-monitoring", "rules", "create", "--file", str(rule_file)]) + created_rule = json.loads(out) + assert created_rule["name"] == rule_name + assert "id" in created_rule + rule_id = created_rule["id"] + + # Get rule + out, _, _ = dogshell(["security-monitoring", "rules", "get", rule_id]) + retrieved_rule = json.loads(out) + assert retrieved_rule["id"] == rule_id + assert retrieved_rule["name"] == rule_name + + # Skip the list rule since there is not filtering + + # Update rule + updated_name = "Updated Rule {}".format(unique) + rule_data["name"] = updated_name + + with open(rule_file, "w") as f: + json.dump(rule_data, f) + + out, _, _ = dogshell(["security-monitoring", "rules", "update", rule_id, "--file", str(rule_file)]) + updated_rule = json.loads(out) + assert updated_rule["id"] == rule_id + assert updated_rule["name"] == updated_name + + # Delete rule + out, _, _ = dogshell(["security-monitoring", "rules", "delete", rule_id]) + # Successfully deleted returns a valid response + assert out == "" + + def test_security_monitoring_signals(self, freezer, dogshell): + """ + Test dogshell security-monitoring signals commands. + Note: Since signals cannot be created directly, we'll primarily test command structure. + """ + with freezer: + now_time = datetime.datetime.now(datetime.timezone.utc) + now = now_time.replace(microsecond=0).isoformat() + one_hour_ago = (now_time - datetime.timedelta(hours=1)).replace(microsecond=0).isoformat() + + # List signals + out, err, return_code = dogshell( + [ + "security-monitoring", + "signals", + "list", + "--query", "security:attack", + "--from", one_hour_ago, + "--to", now, + "--sort", "-timestamp", + "--page-size", "5" + ], + check_return_code=False + ) + + # Even if no signals are found, the command should execute successfully + # If it's a 401/403, that's okay since test account might not have permissions + if return_code == 0: + signals = json.loads(out) + assert "data" in signals or "signals" in signals + + # Try get signal with a fake ID (will likely fail, but tests command structure) + fake_signal_id = "abcd1234-abcd-1234-abcd-1234abcd1234" + _, _, return_code = dogshell( + ["security-monitoring", "signals", "get", fake_signal_id], + check_return_code=False + ) + + # Try the triage command with a fake ID (will likely fail, but tests command structure) + _, _, return_code = dogshell( + ["security-monitoring", "signals", "triage", fake_signal_id, "--state", "archived"], + check_return_code=False + ) def parse_response(self, out): data = {} diff --git a/tests/unit/api/test_security_monitoring.py b/tests/unit/api/test_security_monitoring.py new file mode 100644 index 000000000..edcc92f4e --- /dev/null +++ b/tests/unit/api/test_security_monitoring.py @@ -0,0 +1,29 @@ +# Unless explicitly stated otherwise all files in this repository are licensed under the BSD-3-Clause License. +# This product includes software developed at Datadog (https://www.datadoghq.com/). +# Copyright 2015-Present Datadog, Inc +""" +Tests for Security Monitoring API endpoints. +""" + +import unittest + +from datadog.api.security_monitoring_rules import SecurityMonitoringRule +from datadog.api.security_monitoring_signals import SecurityMonitoringSignal + +# We'll implement proper testing in a follow-up PR +class TestSecurityMonitoring(unittest.TestCase): + """ + Simple tests that classes exist + """ + + def test_classes_exist(self): + """Test that our classes are defined correctly""" + self.assertEqual(SecurityMonitoringRule._resource_name, "security_monitoring/rules") + self.assertEqual(SecurityMonitoringRule._api_version, "v2") + + self.assertEqual(SecurityMonitoringSignal._resource_name, "security_monitoring/signals") + self.assertEqual(SecurityMonitoringSignal._api_version, "v2") + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/tests/unit/dogshell/test_security_monitoring.py b/tests/unit/dogshell/test_security_monitoring.py new file mode 100644 index 000000000..b2e08342c --- /dev/null +++ b/tests/unit/dogshell/test_security_monitoring.py @@ -0,0 +1,230 @@ +# Unless explicitly stated otherwise all files in this repository are licensed under the BSD-3-Clause License. +# This product includes software developed at Datadog (https://www.datadoghq.com/). +# Copyright 2015-Present Datadog, Inc +""" +Tests for Security Monitoring dogshell commands. +""" + +import json +import os +import tempfile +import unittest + +import mock + +from datadog.dogshell.security_monitoring import SecurityMonitoringClient + +class TestSecurityMonitoringCommands(unittest.TestCase): + """ + Test class for Security Monitoring dogshell commands. + """ + + def setUp(self): + """ + Setup common mocks. + """ + self.rule_get_all_mock = mock.patch('datadog.api.security_monitoring_rules.SecurityMonitoringRule.get_all') + self.rule_get_mock = mock.patch('datadog.api.security_monitoring_rules.SecurityMonitoringRule.get') + self.rule_create_mock = mock.patch('datadog.api.security_monitoring_rules.SecurityMonitoringRule.create') + self.rule_update_mock = mock.patch('datadog.api.security_monitoring_rules.SecurityMonitoringRule.update') + self.rule_delete_mock = mock.patch('datadog.api.security_monitoring_rules.SecurityMonitoringRule.delete') + + self.signal_get_all_mock = mock.patch('datadog.api.security_monitoring_signals.SecurityMonitoringSignal.get_all') + self.signal_get_mock = mock.patch('datadog.api.security_monitoring_signals.SecurityMonitoringSignal.get') + self.signal_change_triage_state_mock = mock.patch('datadog.api.security_monitoring_signals.SecurityMonitoringSignal.change_triage_state') + + self.rule_get_all = self.rule_get_all_mock.start() + self.rule_get = self.rule_get_mock.start() + self.rule_create = self.rule_create_mock.start() + self.rule_update = self.rule_update_mock.start() + self.rule_delete = self.rule_delete_mock.start() + + self.signal_get_all = self.signal_get_all_mock.start() + self.signal_get = self.signal_get_mock.start() + self.signal_change_triage_state = self.signal_change_triage_state_mock.start() + + def tearDown(self): + """ + Reset mocks. + """ + self.rule_get_all_mock.stop() + self.rule_get_mock.stop() + self.rule_create_mock.stop() + self.rule_update_mock.stop() + self.rule_delete_mock.stop() + + self.signal_get_all_mock.stop() + self.signal_get_mock.stop() + self.signal_change_triage_state_mock.stop() + + def test_rules_list(self): + """ + Test 'security-monitoring rules list' command. + """ + expected_response = {'rules': [{'id': 'abc-123'}]} + self.rule_get_all.return_value = expected_response + + args = mock.MagicMock() + args.page_size = 10 + args.page_number = 1 + args.timeout = None + + response = SecurityMonitoringClient._show_all_rules(args) + self.rule_get_all.assert_called_once_with( + **{ + 'page[size]': 10, + 'page[number]': 1 + }) + self.assertEqual(response, 0) # Expect success code + + def test_rules_get(self): + """ + Test 'security-monitoring rules get' command. + """ + rule_id = 'abc-123' + expected_response = {'id': rule_id} + self.rule_get.return_value = expected_response + + args = mock.MagicMock() + args.rule_id = rule_id + args.timeout = None + + response = SecurityMonitoringClient._show_rule(args) + self.rule_get.assert_called_once_with(rule_id) + self.assertEqual(response, 0) # Expect success code + + def test_rules_create(self): + """ + Test 'security-monitoring rules create' command. + """ + temp_file = tempfile.NamedTemporaryFile(delete=False, mode='w+') + try: + rule_data = { + 'name': 'Test rule', + 'is_enabled': True + } + json.dump(rule_data, temp_file) + temp_file.close() + + expected_response = {'id': 'new-rule-id'} + self.rule_create.return_value = expected_response + + args = mock.MagicMock() + args.file = temp_file.name + args.timeout = None + + response = SecurityMonitoringClient._create_rule(args) + self.rule_create.assert_called_once_with(**rule_data) + self.assertEqual(response, 0) # Expect success code + finally: + os.unlink(temp_file.name) + + def test_rules_update(self): + """ + Test 'security-monitoring rules update' command. + """ + rule_id = 'abc-123' + temp_file = tempfile.NamedTemporaryFile(delete=False, mode='w+') + try: + rule_data = { + 'name': 'Updated rule', + 'is_enabled': False + } + json.dump(rule_data, temp_file) + temp_file.close() + + expected_response = {'id': rule_id} + self.rule_update.return_value = expected_response + + args = mock.MagicMock() + args.rule_id = rule_id + args.file = temp_file.name + args.timeout = None + + response = SecurityMonitoringClient._update_rule(args) + self.rule_update.assert_called_once_with(rule_id, **rule_data) + self.assertEqual(response, 0) # Expect success code + finally: + os.unlink(temp_file.name) + + def test_rules_delete(self): + """ + Test 'security-monitoring rules delete' command. + """ + rule_id = 'abc-123' + expected_response = {'deleted': rule_id} + self.rule_delete.return_value = expected_response + + args = mock.MagicMock() + args.rule_id = rule_id + args.timeout = None + + response = SecurityMonitoringClient._delete_rule(args) + self.rule_delete.assert_called_once_with(rule_id) + self.assertEqual(response, 0) # Expect success code + + def test_signals_list(self): + """ + Test 'security-monitoring signals list' command. + """ + expected_response = {'signals': [{'id': 'sig-123'}]} + self.signal_get_all.return_value = expected_response + + args = mock.MagicMock() + args.query = 'security:attack' + args.from_time = 'now-1h' + args.to_time = 'now' + args.sort = '-timestamp' + args.page_size = 10 + args.page_cursor = None + args.timeout = None + + response = SecurityMonitoringClient._list_signals(args) + self.signal_get_all.assert_called_once_with( + **{ + 'filter[query]': 'security:attack', + 'filter[from]': 'now-1h', + 'filter[to]': 'now', + 'sort': '-timestamp', + 'page[size]': 10 + } + ) + self.assertEqual(response, 0) # Expect success code + + def test_signals_get(self): + """ + Test 'security-monitoring signals get' command. + """ + signal_id = 'sig-123' + expected_response = {'id': signal_id} + self.signal_get.return_value = expected_response + + args = mock.MagicMock() + args.signal_id = signal_id + args.timeout = None + + response = SecurityMonitoringClient._get_signal(args) + self.signal_get.assert_called_once_with(signal_id) + self.assertEqual(response, 0) # Expect success code + + def test_signals_change_triage_state(self): + """ + Test 'security-monitoring signals triage' command. + """ + signal_id = 'sig-123' + state = 'archived' + expected_response = {'status': 'success'} + self.signal_change_triage_state.return_value = expected_response + + args = mock.MagicMock() + args.signal_id = 'sig-123' + args.state = state + args.timeout = None + + response = SecurityMonitoringClient._change_triage_state(args) + self.signal_change_triage_state.assert_called_once_with(signal_id, state) + self.assertEqual(response, 0) # Expect success code + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file