diff --git a/.codecov.yml b/.codecov.yml index d11d93b36..e05b33f01 100644 --- a/.codecov.yml +++ b/.codecov.yml @@ -67,6 +67,10 @@ coverage: target: 75 flags: - grafana + Huntress: + target: 75 + flags: + - huntress JFrog_Platform: target: 75 flags: @@ -469,6 +473,11 @@ flags: paths: - hikaricp/datadog_checks/hikaricp - hikaricp/tests + huntress: + carryforward: true + paths: + - huntress/datadog_checks/huntress + - huntress/tests jfrog_platform_self_hosted: carryforward: true paths: diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index a243f1f45..af210aef4 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1184,6 +1184,8 @@ code-coverage.datadog.yml @DataDog/agent-integr /aerospike_enterprise/ @support-aerospike @DataDog/ecosystems-review /scamalytics/ @ScamalyticsDev @DataDog/ecosystems-review +/huntress/ @kyletaylored kyle.taylor@datadoghq.com + # LEAVE THE FOLLOWING LOG OWNERSHIP LAST IN THE FILE # Make sure logs team is the full owner for all logs related files **/assets/logs/ @DataDog/logs-integrations-reviewers diff --git a/.github/workflows/test-all.yml b/.github/workflows/test-all.yml index 7a5be903a..94b087165 100644 --- a/.github/workflows/test-all.yml +++ b/.github/workflows/test-all.yml @@ -538,6 +538,25 @@ jobs: test-py3: ${{ inputs.test-py3 }} setup-env-vars: "${{ inputs.setup-env-vars }}" secrets: inherit + j8ecbaad: + uses: DataDog/integrations-core/.github/workflows/test-target.yml@574d63ba88365ffbab915280ceddbaa333c63d6a + with: + job-name: Huntress + target: huntress + platform: linux + runner: '["ubuntu-22.04"]' + repo: "${{ inputs.repo }}" + context: ${{ inputs.context }} + python-version: "${{ inputs.python-version }}" + latest: ${{ inputs.latest }} + agent-image: "${{ inputs.agent-image }}" + agent-image-py2: "${{ inputs.agent-image-py2 }}" + agent-image-windows: "${{ inputs.agent-image-windows }}" + agent-image-windows-py2: "${{ inputs.agent-image-windows-py2 }}" + test-py2: ${{ inputs.test-py2 }} + test-py3: ${{ inputs.test-py3 }} + setup-env-vars: "${{ inputs.setup-env-vars }}" + secrets: inherit j43aee0d: uses: DataDog/integrations-core/.github/workflows/test-target.yml@574d63ba88365ffbab915280ceddbaa333c63d6a with: diff --git a/huntress/CHANGELOG.md b/huntress/CHANGELOG.md new file mode 100644 index 000000000..b9a9569a1 --- /dev/null +++ b/huntress/CHANGELOG.md @@ -0,0 +1,9 @@ +# CHANGELOG - Huntress + +## Unreleased + +***Added***: + +* Initial release of the Huntress SIEM integration for Datadog. +* Polls the Huntress Managed SIEM API via ES|QL, enriches logs with organization metadata, and forwards all events to Datadog Logs. +* Emits collection health metrics and a pre-built overview dashboard with monitor templates. diff --git a/huntress/README.md b/huntress/README.md new file mode 100644 index 000000000..41cfcdd58 --- /dev/null +++ b/huntress/README.md @@ -0,0 +1,207 @@ +# Huntress + +## Overview + +[Huntress][5] is a managed security platform that provides endpoint detection and response (EDR), antivirus, security awareness training, and a Managed SIEM product that continuously collects and analyzes endpoint telemetry. + +This integration polls the Huntress Managed SIEM API using ES|QL queries and forwards all security events to Datadog as logs. During each collection run, the integration: + +1. Loads a checkpoint (the timestamp of the last successful collection) +2. Executes a configurable ES|QL query for the elapsed time window +3. Paginates through all result pages +4. Optionally enriches each log with Huntress organization metadata (org name, key, account ID) +5. Forwards logs to Datadog preserving all Elastic Common Schema (ECS) field names +6. Advances the checkpoint only after all pages are successfully sent + +This integration is designed for managed security providers (MSPs) and enterprise teams who want to correlate Huntress threat detections alongside infrastructure and application telemetry in Datadog. + +## Setup + +### Prerequisites + +- Datadog Agent 7.x or later +- A Huntress account with the Managed SIEM feature enabled +- Huntress API credentials (public API key and secret key) from the Huntress Partner Portal under **Settings > API Credentials** + +### Installation + +Install the integration package from the Agent: + +```bash +datadog-agent integration install -t datadog-huntress==1.0.0 +``` + +### Configuration + +1. Copy the [example configuration file][4] to `/etc/datadog-agent/conf.d/huntress/conf.yaml` (Linux/macOS) or `C:\ProgramData\Datadog\conf.d\huntress\conf.yaml` (Windows) and edit it with your credentials: + + ```yaml + init_config: {} + + instances: + - huntress_api_key: "" + huntress_secret_key: "" + log_queries: + - name: "all-logs" + esql_query: "FROM logs | KEEP @timestamp, message, host.hostname, event.category, event.code, event.provider, event.id, user.target.name, user.target.domain, winlog.system.EventID" + tags: + - "source:huntress" + - "service:huntress-siem" + - "env:production" + ``` + +2. Restart the Agent: + + ```bash + # Linux (systemd) + sudo systemctl restart datadog-agent + + # macOS + sudo launchctl stop com.datadoghq.agent && sudo launchctl start com.datadoghq.agent + ``` + +#### Multiple Huntress accounts + +If you manage multiple Huntress accounts, add an additional block under `instances:` for each account. Each block runs independently with its own checkpoint, org metadata cache, and metrics: + +```yaml +instances: + - huntress_api_key: "" + huntress_secret_key: "" + log_queries: + - name: "all-logs" + esql_query: "FROM logs | KEEP @timestamp, message, host.hostname, event.category, event.code, event.provider, event.id, user.target.name, user.target.domain, winlog.system.EventID" + tags: ["source:huntress", "env:production"] + + - huntress_api_key: "" + huntress_secret_key: "" + log_queries: + - name: "all-logs" + esql_query: "FROM logs | KEEP @timestamp, message, host.hostname, event.category, event.code, event.provider, event.id, user.target.name, user.target.domain, winlog.system.EventID" + tags: ["source:huntress", "env:staging"] +``` + +#### Rate limits + +The Huntress API allows 60 requests per minute per API key pair. A typical run with 3 SIEM queries and agent metrics uses roughly 5 to 25 requests, well within the limit. For large accounts with high log volume or thousands of agents, consider distributing the load across two instances using separate API key pairs: + +```yaml +instances: + # Instance 1: SIEM log collection only + - huntress_api_key: "" + huntress_secret_key: "" + log_queries: + - name: "all-logs" + esql_query: "FROM logs | KEEP @timestamp, message, host.hostname, event.category, event.code, event.provider, event.id, user.target.name, user.target.domain, winlog.system.EventID" + + # Instance 2: agent metrics only (isolated rate limit budget) + - huntress_api_key: "" + huntress_secret_key: "" + metrics: + agents: + enabled: true +``` + +#### Configuration reference + +**`init_config` options** (apply to all instances): + +| Field | Required | Default | Description | +| ----------------- | -------- | ------- | ------------------------------------------------- | +| `request_timeout` | No | `30` | HTTP request timeout in seconds for all API calls | + +**`instances` options** (per Huntress account): + +| Field | Required | Default | Description | +| -------------------------- | ----------- | ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `huntress_api_key` | Yes | - | Huntress public API key | +| `huntress_secret_key` | Yes | - | Huntress secret API key | +| `log_queries` | Conditional | - | List of query objects; each has `name` (required), `esql_query` (required, must begin with `FROM logs`), and `tags` (optional). `name` is used as the `query_name` tag on metrics. Always include a `KEEP` clause. Queries without `KEEP` return only `uuid` and `organization_id`. | +| `metrics.agents.enabled` | Conditional | `false` | Collect agent fleet metrics (total, by platform, by Defender/firewall status) | +| `metrics.agents.max_pages` | No | `20` | Max pages of agents to fetch per run (500 agents/page) | +| `enrich_with_org_tags` | No | `true` | Fetch and attach org metadata as log tags | +| `org_cache_ttl_seconds` | No | `3600` | How long to cache org metadata (seconds) | +| `max_pages_per_run` | No | `100` | Page cap per query per run (~20,000 logs maximum) | +| `huntress_base_url` | No | `https://api.huntress.io` | Override for sandbox environments | +| `tags` | No | `[]` | Extra tags on every forwarded metric and log | + +**Note:** At least one of `log_queries` or `metrics.agents.enabled: true` must be configured per instance. + +### Validation + +Run the following command to validate the integration is collecting data: + +```bash +sudo datadog-agent check huntress +``` + +Logs appear in Datadog Log Explorer filtered by `source:huntress`. Allow up to 15 minutes for the first logs to appear. + +## Data Collected + +### Logs + +All logs collected from the Huntress Managed SIEM API are forwarded to Datadog with: + +- `ddsource: huntress`: a reserved log attribute that identifies the log source and triggers automatic pipeline processing +- ECS field names preserved as top-level log attributes (for example, `event.category`, `host.hostname`, `user.name`) +- Organization metadata tags when `enrich_with_org_tags: true` (for example, `huntress_org_name`, `huntress_org_key`, `huntress_account_id`) + +### Metrics + +See [metadata.csv][1] for a list of metrics provided by this integration. + +### Events + +The Huntress integration does not include any events. + +### Service Checks + +See [service_checks.json][3] for a list of service checks provided by this integration. + +## Troubleshooting + +**No logs in Datadog after first run** + +- Run `sudo datadog-agent check huntress` and inspect the output. The summary line reports how many logs were collected and the exact time range queried. +- Verify the API key pair is valid by checking the Huntress Partner Portal. +- Confirm the Managed SIEM feature is enabled on the account. +- Confirm `min_collection_interval: 900` is set in your instance config. Otherwise, the Agent uses its default 15-second interval, and each run queries only a 15-second window with no new events. +- Check that each `log_queries[].esql_query` begins with `FROM logs`. +- The Huntress SIEM API requires an explicit `KEEP` clause to return log fields. Without one, responses contain only `uuid` and `organization_id`. Add `| KEEP @timestamp, message, host.hostname, event.category, event.code, ...` to your query. The Agent logs show a warning if this is detected. + +**`LogsSent: 0` in `datadog-agent status` even though logs are being collected** + +This is expected. The integration sends logs directly to the Datadog Logs Intake API rather than through the Agent's internal log pipeline, so the Logs Agent counters (`LogsProcessed`, `LogsSent`) always read 0. Use the Agent check output or Datadog Log Explorer filtered by `source:huntress` to confirm logs are arriving. + +**`huntress.siem.errors` count is increasing** + +Inspect the `error_type` tag to identify the root cause: + +| `error_type` | Cause | Resolution | +| ------------------ | ---------------------------------- | ------------------------------------------------------------- | +| `auth_failure` | Invalid or rotated API credentials | Update `huntress_api_key` or `huntress_secret_key` | +| `timeout` | ES|QL query too broad | Add a `KEEP` or `WHERE` clause to the query | +| `invalid_query` | Malformed ES|QL | Fix the `esql_query` value in the failing `log_queries` entry | +| `server_error` | Transient Huntress API error | Check [Huntress status page][6] | +| `connection_error` | Network issue | Verify connectivity from the Agent host to `api.huntress.io` | +| `run_failure` | Unexpected error during collection | Check Agent logs for the full stack trace | + +**`huntress.siem.api_call_remaining` is low or zero** + +The Huntress API allows 60 requests per minute. The integration logs a warning when fewer than 10 requests remain in a given minute. If this happens regularly, reduce `max_pages_per_run` or increase `min_collection_interval` to spread out collection runs. + +**Duplicate logs after Agent restart** + +This is expected on the first restart after a failed run. The checkpoint advances only after all pages are successfully sent. + +## Support + +For questions and support, [contact Datadog support][2]. + +[1]: https://github.com/DataDog/integrations-extras/blob/master/huntress/metadata.csv +[2]: https://docs.datadoghq.com/help/ +[3]: https://github.com/DataDog/integrations-extras/blob/master/huntress/assets/service_checks.json +[4]: https://github.com/DataDog/integrations-extras/blob/master/huntress/datadog_checks/huntress/data/conf.yaml.example +[5]: https://www.huntress.com/ +[6]: https://status.huntress.com/ \ No newline at end of file diff --git a/huntress/assets/configuration/spec.yaml b/huntress/assets/configuration/spec.yaml new file mode 100644 index 000000000..06b8ebcb5 --- /dev/null +++ b/huntress/assets/configuration/spec.yaml @@ -0,0 +1,133 @@ +name: Huntress +files: + - name: huntress.yaml + options: + - template: init_config + options: + - template: init_config/default + - name: request_timeout + description: | + HTTP request timeout in seconds applied to all Huntress API calls. + Increase this if the Huntress SIEM API is slow to respond on large queries. + value: + type: number + example: 30 + default: 30 + - template: instances + options: + - template: instances/default + overrides: + min_collection_interval.value.default: 900 + min_collection_interval.value.example: 900 + - name: huntress_api_key + required: true + secret: true + description: | + Huntress public API key. Obtain from the Huntress Partner Portal under + Settings > API Credentials. Each instance uses its own key pair, enabling + multiple Huntress accounts to feed into the same Datadog organization. + value: + type: string + example: + - name: huntress_secret_key + required: true + secret: true + description: | + Huntress private (secret) API key paired with huntress_api_key. + value: + type: string + example: + - name: log_queries + description: | + List of ES|QL queries to execute against the Huntress SIEM API. Each entry + requires an esql_query string (must begin with "FROM logs", case-insensitive) + and an optional tags list attached only to logs produced by that query. + Multiple queries run independently with separate checkpoints, enabling + targeted collection for different log types within the same account. + Do not add time range filters — range_start and range_end are managed + automatically by the check. + + IMPORTANT: Always include a KEEP clause to select the fields you want. + Despite what the Huntress API documentation states, queries without an + explicit KEEP clause return only uuid and organization_id — not all fields. + Example: FROM logs | KEEP @timestamp, message, host.hostname, event.category, + event.code, event.provider, event.id, user.target.name, user.target.domain, + winlog.system.EventID + value: + type: array + items: + type: object + properties: + - name: name + type: string + - name: esql_query + type: string + - name: tags + type: array + items: + type: string + example: + - name: "windows-security-events" + esql_query: "FROM logs | KEEP @timestamp, message, host.hostname, event.category, event.code, event.provider, event.id, user.target.name, user.target.domain, winlog.system.EventID" + tags: + - "log_type:windows" + - name: enrich_with_org_tags + description: | + When true, the check fetches Huntress organization metadata (org name, + org key, account ID) and attaches it as log tags. The org cache is + refreshed according to org_cache_ttl_seconds. + value: + type: boolean + example: true + default: true + - name: org_cache_ttl_seconds + description: | + How long (in seconds) to cache organization metadata before re-fetching + from the Huntress API. Set to 0 to refresh on every run. + value: + type: integer + example: 3600 + default: 3600 + - name: max_pages_per_run + description: | + Maximum number of result pages to fetch per query per run. Each page + contains up to 200 log events. Protects against runaway pagination on + large backlogs. If this cap is hit, the checkpoint does not advance and + remaining pages are fetched on the next run. + value: + type: integer + example: 100 + default: 100 + - name: huntress_base_url + description: | + Huntress API base URL. Override for sandbox or testing environments. + value: + type: string + example: "https://api.huntress.io" + default: "https://api.huntress.io" + - name: metrics + description: | + Controls which Huntress data is collected as Datadog metrics beyond the + standard SIEM run metrics. Omit this section to disable additional metric + collection (logs-only instance). Configure a separate instance with only + this section and different API credentials to isolate the agent metrics + API call budget from SIEM log collection. + value: + type: object + properties: + - name: agents + type: object + properties: + - name: enabled + type: boolean + - name: max_pages + type: integer + example: + agents: + enabled: true + max_pages: 20 + - template: logs + example: + - type: integration + source: huntress + service: huntress diff --git a/huntress/assets/dashboards/huntress_siem_overview.json b/huntress/assets/dashboards/huntress_siem_overview.json new file mode 100644 index 000000000..07dae11a9 --- /dev/null +++ b/huntress/assets/dashboards/huntress_siem_overview.json @@ -0,0 +1,1038 @@ +{ + "title": "Huntress SIEM Overview", + "description": "Monitor Huntress Managed SIEM log collection health and security event activity forwarded to Datadog.", + "widgets": [ + { + "id": 1001, + "definition": { + "type": "note", + "content": "## Huntress SIEM\n\n[Huntress](https://www.huntress.com/) Managed SIEM continuously collects endpoint security telemetry and threat detections. This dashboard shows the health of the Datadog log collection pipeline and a summary of security events forwarded from Huntress.\n\n**Tips**\n- Use the timeframe selector in the upper-right corner to change the observation window.\n- Clone this dashboard to add custom widgets, filters, or team-specific views.", + "background_color": "blue", + "font_size": "14", + "text_align": "left", + "vertical_align": "center", + "show_tick": false, + "has_padding": true + }, + "layout": { + "x": 0, + "y": 0, + "width": 12, + "height": 3 + } + }, + { + "id": 1010, + "definition": { + "title": "Collection Health", + "background_color": "vivid_blue", + "show_title": true, + "type": "group", + "layout_type": "ordered", + "widgets": [ + { + "id": 1011, + "definition": { + "title": "Check Status", + "title_size": "16", + "title_align": "left", + "type": "check_status", + "check": "huntress.siem.check_status", + "grouping": "cluster", + "group_by": [], + "tags": ["*"] + }, + "layout": { + "x": 0, + "y": 0, + "width": 2, + "height": 4 + } + }, + { + "id": 1012, + "definition": { + "title": "Logs Collected (Last Run)", + "title_size": "16", + "title_align": "left", + "type": "query_value", + "requests": [ + { + "response_format": "scalar", + "queries": [ + { + "name": "query1", + "data_source": "metrics", + "query": "sum:huntress.siem.logs_collected{*}", + "aggregator": "last" + } + ], + "formulas": [ + { + "formula": "query1" + } + ] + } + ], + "autoscale": true, + "precision": 0 + }, + "layout": { + "x": 2, + "y": 0, + "width": 2, + "height": 2 + } + }, + { + "id": 1013, + "definition": { + "title": "Pages Fetched (Last Run)", + "title_size": "16", + "title_align": "left", + "type": "query_value", + "requests": [ + { + "response_format": "scalar", + "queries": [ + { + "name": "query1", + "data_source": "metrics", + "query": "sum:huntress.siem.pages_fetched{*}", + "aggregator": "last" + } + ], + "formulas": [ + { + "formula": "query1" + } + ] + } + ], + "autoscale": true, + "precision": 0 + }, + "layout": { + "x": 4, + "y": 0, + "width": 2, + "height": 2 + } + }, + { + "id": 1014, + "definition": { + "title": "Last Run Duration (s)", + "title_size": "16", + "title_align": "left", + "type": "query_value", + "requests": [ + { + "response_format": "scalar", + "queries": [ + { + "name": "query1", + "data_source": "metrics", + "query": "avg:huntress.siem.run_duration_seconds{*}", + "aggregator": "last" + } + ], + "formulas": [ + { + "formula": "query1" + } + ] + } + ], + "autoscale": true, + "precision": 1 + }, + "layout": { + "x": 6, + "y": 0, + "width": 2, + "height": 2 + } + }, + { + "id": 1015, + "definition": { + "title": "Errors (This Window)", + "title_size": "16", + "title_align": "left", + "type": "query_value", + "requests": [ + { + "response_format": "scalar", + "queries": [ + { + "name": "query1", + "data_source": "metrics", + "query": "sum:huntress.siem.errors{*}.as_count()", + "aggregator": "sum" + } + ], + "formulas": [ + { + "formula": "query1" + } + ], + "conditional_formats": [ + { + "comparator": ">", + "value": 0, + "palette": "red_on_white" + }, + { + "comparator": "<=", + "value": 0, + "palette": "green_on_white" + } + ] + } + ], + "autoscale": true, + "precision": 0 + }, + "layout": { + "x": 8, + "y": 0, + "width": 2, + "height": 2 + } + }, + { + "id": 1016, + "definition": { + "title": "Errors by Type", + "title_size": "16", + "title_align": "left", + "type": "toplist", + "requests": [ + { + "response_format": "scalar", + "queries": [ + { + "name": "query1", + "data_source": "metrics", + "query": "sum:huntress.siem.errors{*} by {error_type}.as_count()", + "aggregator": "sum" + } + ], + "formulas": [ + { + "formula": "query1", + "limit": { + "count": 10, + "order": "desc" + } + } + ] + } + ] + }, + "layout": { + "x": 10, + "y": 0, + "width": 2, + "height": 4 + } + }, + { + "id": 1017, + "definition": { + "title": "Logs Collected Per Run", + "title_size": "16", + "title_align": "left", + "show_legend": true, + "legend_layout": "auto", + "type": "timeseries", + "requests": [ + { + "response_format": "timeseries", + "queries": [ + { + "name": "query1", + "data_source": "metrics", + "query": "sum:huntress.siem.logs_collected{*} by {huntress_account_id}" + } + ], + "formulas": [ + { + "formula": "query1" + } + ], + "display_type": "bars", + "style": { + "palette": "datadog16", + "line_type": "solid", + "line_width": "normal" + } + } + ] + }, + "layout": { + "x": 2, + "y": 2, + "width": 4, + "height": 2 + } + }, + { + "id": 1018, + "definition": { + "title": "Collection Errors Over Time", + "title_size": "16", + "title_align": "left", + "show_legend": true, + "legend_layout": "auto", + "type": "timeseries", + "requests": [ + { + "response_format": "timeseries", + "queries": [ + { + "name": "query1", + "data_source": "metrics", + "query": "sum:huntress.siem.errors{*} by {error_type}.as_count()" + } + ], + "formulas": [ + { + "formula": "query1" + } + ], + "display_type": "bars", + "style": { + "palette": "warm", + "line_type": "solid", + "line_width": "normal" + } + } + ] + }, + "layout": { + "x": 6, + "y": 2, + "width": 4, + "height": 2 + } + }, + { + "id": 1019, + "definition": { + "title": "API Requests Remaining (Rate Limit Window)", + "title_size": "16", + "title_align": "left", + "show_legend": false, + "type": "timeseries", + "requests": [ + { + "response_format": "timeseries", + "queries": [ + { + "name": "limit", + "data_source": "metrics", + "query": "avg:huntress.siem.api_call_limit{*}" + }, + { + "name": "remaining", + "data_source": "metrics", + "query": "avg:huntress.siem.api_call_remaining{*}" + } + ], + "formulas": [ + { + "formula": "limit", + "alias": "Limit" + }, + { + "formula": "remaining", + "alias": "Remaining" + } + ], + "display_type": "line", + "style": { + "palette": "cool", + "line_type": "solid", + "line_width": "normal" + } + } + ], + "markers": [ + { + "value": "y = 10", + "display_type": "error dashed", + "label": "Low remaining (< 10)" + } + ] + }, + "layout": { + "x": 10, + "y": 2, + "width": 2, + "height": 2 + } + } + ] + }, + "layout": { + "x": 0, + "y": 3, + "width": 12, + "height": 1 + } + }, + { + "id": 1030, + "definition": { + "title": "Security Events", + "background_color": "vivid_orange", + "show_title": true, + "type": "group", + "layout_type": "ordered", + "widgets": [ + { + "id": 1031, + "definition": { + "title": "Total Events", + "title_size": "16", + "title_align": "left", + "type": "query_value", + "requests": [ + { + "response_format": "scalar", + "queries": [ + { + "name": "query1", + "data_source": "logs", + "search": { + "query": "source:huntress" + }, + "indexes": ["*"], + "group_by": [], + "compute": { + "aggregation": "count" + }, + "storage": "hot" + } + ], + "formulas": [ + { + "formula": "query1" + } + ] + } + ], + "autoscale": true, + "precision": 0 + }, + "layout": { + "x": 0, + "y": 0, + "width": 3, + "height": 4 + } + }, + { + "id": 1032, + "definition": { + "title": "Events by Category", + "title_size": "16", + "title_align": "left", + "type": "toplist", + "requests": [ + { + "response_format": "scalar", + "queries": [ + { + "name": "query1", + "data_source": "logs", + "search": { + "query": "source:huntress" + }, + "indexes": ["*"], + "group_by": [ + { + "facet": "@event.category", + "sort": { + "aggregation": "count", + "order": "desc" + }, + "limit": 10 + } + ], + "compute": { + "aggregation": "count" + }, + "storage": "hot" + } + ], + "formulas": [ + { + "formula": "query1", + "limit": { + "count": 10, + "order": "desc" + } + } + ] + } + ] + }, + "layout": { + "x": 3, + "y": 0, + "width": 3, + "height": 4 + } + }, + { + "id": 1033, + "definition": { + "title": "Events by Outcome", + "title_size": "16", + "title_align": "left", + "type": "toplist", + "requests": [ + { + "response_format": "scalar", + "queries": [ + { + "name": "query1", + "data_source": "logs", + "search": { + "query": "source:huntress" + }, + "indexes": ["*"], + "group_by": [ + { + "facet": "@event.outcome", + "sort": { + "aggregation": "count", + "order": "desc" + }, + "limit": 10 + } + ], + "compute": { + "aggregation": "count" + }, + "storage": "hot" + } + ], + "formulas": [ + { + "formula": "query1", + "limit": { + "count": 10, + "order": "desc" + } + } + ] + } + ] + }, + "layout": { + "x": 6, + "y": 0, + "width": 3, + "height": 4 + } + }, + { + "id": 1034, + "definition": { + "title": "Events by Action", + "title_size": "16", + "title_align": "left", + "type": "toplist", + "requests": [ + { + "response_format": "scalar", + "queries": [ + { + "name": "query1", + "data_source": "logs", + "search": { + "query": "source:huntress" + }, + "indexes": ["*"], + "group_by": [ + { + "facet": "@event.action", + "sort": { + "aggregation": "count", + "order": "desc" + }, + "limit": 10 + } + ], + "compute": { + "aggregation": "count" + }, + "storage": "hot" + } + ], + "formulas": [ + { + "formula": "query1", + "limit": { + "count": 10, + "order": "desc" + } + } + ] + } + ] + }, + "layout": { + "x": 9, + "y": 0, + "width": 3, + "height": 4 + } + }, + { + "id": 1035, + "definition": { + "title": "Events Over Time by Category", + "title_size": "16", + "title_align": "left", + "show_legend": true, + "legend_layout": "auto", + "type": "timeseries", + "requests": [ + { + "response_format": "timeseries", + "queries": [ + { + "name": "query1", + "data_source": "logs", + "search": { + "query": "source:huntress" + }, + "indexes": ["*"], + "group_by": [ + { + "facet": "@event.category", + "sort": { + "aggregation": "count", + "order": "desc" + }, + "limit": 10 + } + ], + "compute": { + "aggregation": "count" + }, + "storage": "hot" + } + ], + "formulas": [ + { + "formula": "query1" + } + ], + "display_type": "bars", + "style": { + "palette": "datadog16", + "line_type": "solid", + "line_width": "normal" + } + } + ] + }, + "layout": { + "x": 0, + "y": 4, + "width": 12, + "height": 3 + } + } + ] + }, + "layout": { + "x": 0, + "y": 12, + "width": 12, + "height": 1 + } + }, + { + "id": 1050, + "definition": { + "title": "Top Activity", + "background_color": "vivid_green", + "show_title": true, + "type": "group", + "layout_type": "ordered", + "widgets": [ + { + "id": 1051, + "definition": { + "title": "Top Hosts", + "title_size": "16", + "title_align": "left", + "type": "toplist", + "requests": [ + { + "response_format": "scalar", + "queries": [ + { + "name": "query1", + "data_source": "logs", + "search": { + "query": "source:huntress" + }, + "indexes": ["*"], + "group_by": [ + { + "facet": "@host.hostname", + "sort": { + "aggregation": "count", + "order": "desc" + }, + "limit": 10 + } + ], + "compute": { + "aggregation": "count" + }, + "storage": "hot" + } + ], + "formulas": [ + { + "formula": "query1", + "limit": { + "count": 10, + "order": "desc" + } + } + ] + } + ] + }, + "layout": { + "x": 0, + "y": 0, + "width": 4, + "height": 4 + } + }, + { + "id": 1052, + "definition": { + "title": "Top Users", + "title_size": "16", + "title_align": "left", + "type": "toplist", + "requests": [ + { + "response_format": "scalar", + "queries": [ + { + "name": "query1", + "data_source": "logs", + "search": { + "query": "source:huntress" + }, + "indexes": ["*"], + "group_by": [ + { + "facet": "@user.name", + "sort": { + "aggregation": "count", + "order": "desc" + }, + "limit": 10 + } + ], + "compute": { + "aggregation": "count" + }, + "storage": "hot" + } + ], + "formulas": [ + { + "formula": "query1", + "limit": { + "count": 10, + "order": "desc" + } + } + ] + } + ] + }, + "layout": { + "x": 4, + "y": 0, + "width": 4, + "height": 4 + } + }, + { + "id": 1053, + "definition": { + "title": "Top Organizations", + "title_size": "16", + "title_align": "left", + "type": "toplist", + "requests": [ + { + "response_format": "scalar", + "queries": [ + { + "name": "query1", + "data_source": "logs", + "search": { + "query": "source:huntress" + }, + "indexes": ["*"], + "group_by": [ + { + "facet": "@huntress_org_name", + "sort": { + "aggregation": "count", + "order": "desc" + }, + "limit": 10 + } + ], + "compute": { + "aggregation": "count" + }, + "storage": "hot" + } + ], + "formulas": [ + { + "formula": "query1", + "limit": { + "count": 10, + "order": "desc" + } + } + ] + } + ] + }, + "layout": { + "x": 8, + "y": 0, + "width": 4, + "height": 4 + } + }, + { + "id": 1054, + "definition": { + "title": "Top Event Providers", + "title_size": "16", + "title_align": "left", + "type": "toplist", + "requests": [ + { + "response_format": "scalar", + "queries": [ + { + "name": "query1", + "data_source": "logs", + "search": { + "query": "source:huntress" + }, + "indexes": ["*"], + "group_by": [ + { + "facet": "@event.provider", + "sort": { + "aggregation": "count", + "order": "desc" + }, + "limit": 10 + } + ], + "compute": { + "aggregation": "count" + }, + "storage": "hot" + } + ], + "formulas": [ + { + "formula": "query1", + "limit": { + "count": 10, + "order": "desc" + } + } + ] + } + ] + }, + "layout": { + "x": 0, + "y": 4, + "width": 4, + "height": 4 + } + }, + { + "id": 1055, + "definition": { + "title": "Events by OS Platform", + "title_size": "16", + "title_align": "left", + "type": "toplist", + "requests": [ + { + "response_format": "scalar", + "queries": [ + { + "name": "query1", + "data_source": "logs", + "search": { + "query": "source:huntress" + }, + "indexes": ["*"], + "group_by": [ + { + "facet": "@host.os.platform", + "sort": { + "aggregation": "count", + "order": "desc" + }, + "limit": 10 + } + ], + "compute": { + "aggregation": "count" + }, + "storage": "hot" + } + ], + "formulas": [ + { + "formula": "query1", + "limit": { + "count": 10, + "order": "desc" + } + } + ] + } + ] + }, + "layout": { + "x": 4, + "y": 4, + "width": 4, + "height": 4 + } + }, + { + "id": 1056, + "definition": { + "title": "Authentication Events — Failed Logins", + "title_size": "16", + "title_align": "left", + "show_legend": false, + "type": "timeseries", + "requests": [ + { + "response_format": "timeseries", + "queries": [ + { + "name": "query1", + "data_source": "logs", + "search": { + "query": "source:huntress @event.category:authentication @event.outcome:failure" + }, + "indexes": ["*"], + "group_by": [ + { + "facet": "@host.hostname", + "sort": { + "aggregation": "count", + "order": "desc" + }, + "limit": 10 + } + ], + "compute": { + "aggregation": "count" + }, + "storage": "hot" + } + ], + "formulas": [ + { + "formula": "query1" + } + ], + "display_type": "bars", + "style": { + "palette": "warm", + "line_type": "solid", + "line_width": "normal" + } + } + ] + }, + "layout": { + "x": 8, + "y": 4, + "width": 4, + "height": 4 + } + } + ] + }, + "layout": { + "x": 0, + "y": 21, + "width": 12, + "height": 1 + } + }, + { + "id": 1070, + "definition": { + "title": "Recent Huntress SIEM Events", + "title_size": "16", + "title_align": "left", + "type": "log_stream", + "indexes": [], + "query": "source:huntress", + "sort": { + "column": "time", + "order": "desc" + }, + "columns": [ + "@event.category", + "@event.action", + "@event.outcome", + "@host.hostname", + "@user.name", + "@huntress_org_name" + ], + "show_date_column": true, + "show_message_column": true, + "message_display": "expanded-md" + }, + "layout": { + "x": 0, + "y": 32, + "width": 12, + "height": 6 + } + } + ], + "template_variables": [], + "layout_type": "ordered", + "notify_list": [], + "reflow_type": "fixed" +} diff --git a/huntress/assets/monitors/huntress_siem_check_failed.json b/huntress/assets/monitors/huntress_siem_check_failed.json new file mode 100644 index 000000000..4575309a6 --- /dev/null +++ b/huntress/assets/monitors/huntress_siem_check_failed.json @@ -0,0 +1,37 @@ +{ + "version": 2, + "created_at": "2026-05-29", + "last_updated_at": "2026-05-29", + "title": "Huntress SIEM collection run failed", + "tags": [ + "integration:huntress" + ], + "description": "The Huntress SIEM Agent check has entered a CRITICAL state, meaning the last collection run failed. Logs may no longer be flowing from Huntress to Datadog.", + "definition": { + "name": "Huntress SIEM collection run failed", + "type": "service check", + "query": "\"huntress.siem.check_status\".over(\"*\").by(\"error_type\").last(2).count_by_status()", + "message": "The Huntress SIEM Agent check on {{host.name}} has entered a CRITICAL state, meaning the last collection run failed. Logs may no longer be flowing from Huntress to Datadog.\n\nCommon causes:\n- Invalid or rotated API credentials (`huntress_api_key` / `huntress_secret_key`)\n- Network connectivity issue between the Agent host and `api.huntress.io`\n- Invalid `esql_query` in the instance configuration\n- Huntress SIEM feature not enabled on this account\n\nCheck the Agent logs for details:\n```\nsudo datadog-agent check huntress\n```", + "tags": [ + "integration:huntress" + ], + "options": { + "thresholds": { + "critical": 1, + "ok": 1 + }, + "notify_audit": false, + "renotify_interval": 60, + "timeout_h": 0, + "include_tags": true, + "renotify_statuses": [ + "alert" + ], + "require_full_window": false, + "escalation_message": "" + }, + "priority": 2, + "draft_status": "published", + "assets": [] + } +} \ No newline at end of file diff --git a/huntress/assets/monitors/huntress_siem_errors_high.json b/huntress/assets/monitors/huntress_siem_errors_high.json new file mode 100644 index 000000000..ce769df1e --- /dev/null +++ b/huntress/assets/monitors/huntress_siem_errors_high.json @@ -0,0 +1,35 @@ +{ + "version": 2, + "created_at": "2026-05-29", + "last_updated_at": "2026-05-29", + "title": "Huntress SIEM collection errors detected", + "tags": [ + "integration:huntress" + ], + "description": "The Huntress SIEM integration has recorded errors in the last 30 minutes. Repeated errors may result in log collection gaps, reducing security event visibility in Datadog.", + "definition": { + "name": "Huntress SIEM collection errors detected", + "type": "query alert", + "query": "sum(last_30m):sum:huntress.siem.errors{*} by {error_type}.as_count() > 5", + "message": "The Huntress SIEM integration has recorded {{value}} errors in the last 30 minutes (threshold: {{threshold}}).\n\nInspect the error type using the `error_type` tag in Datadog Metrics Explorer:\n- `error_type:auth_failure` — check API credentials\n- `error_type:timeout` — query may be too broad; add a KEEP or WHERE clause\n- `error_type:invalid_query` — `esql_query` is malformed\n- `error_type:server_error` — transient Huntress API issue\n- `error_type:connection_error` — network connectivity problem\n\nAgent check logs:\n```\nsudo datadog-agent check huntress\n```", + "tags": [ + "integration:huntress" + ], + "options": { + "thresholds": { + "critical": 5, + "warning": 2 + }, + "notify_audit": false, + "include_tags": true, + "on_missing_data": "show_no_data", + "renotify_interval": 0, + "require_full_window": false, + "escalation_message": "", + "new_group_delay": 0 + }, + "priority": 3, + "draft_status": "published", + "assets": [] + } +} \ No newline at end of file diff --git a/huntress/assets/monitors/huntress_siem_no_logs_collected.json b/huntress/assets/monitors/huntress_siem_no_logs_collected.json new file mode 100644 index 000000000..cd0c51bc0 --- /dev/null +++ b/huntress/assets/monitors/huntress_siem_no_logs_collected.json @@ -0,0 +1,34 @@ +{ + "version": 2, + "created_at": "2026-05-29", + "last_updated_at": "2026-05-29", + "title": "No Huntress SIEM logs collected in 2 hours", + "tags": [ + "integration:huntress" + ], + "description": "The Huntress SIEM integration collected 0 logs in the last 2 hours. This may indicate a collection failure or a disruption in security event visibility from Huntress endpoints.", + "definition": { + "name": "No Huntress SIEM logs collected in 2 hours", + "type": "query alert", + "query": "sum(last_2h):sum:huntress.siem.logs_collected{*} by {query_name}.as_count() < 1", + "message": "The Huntress SIEM integration collected 0 logs in the last 2 hours.\n\nThis may be expected in a brand-new deployment or a quiet environment, but if you expect ongoing log flow, investigate:\n\n1. Verify the Agent check is running: `sudo datadog-agent check huntress`\n2. Confirm `esql_query` returns results in the Huntress Partner Portal\n3. Check that the Huntress account has active endpoints and SIEM log sources\n4. Review `huntress.siem.check_status` — if CRITICAL, the check itself is failing", + "tags": [ + "integration:huntress" + ], + "options": { + "thresholds": { + "critical": 1 + }, + "notify_audit": false, + "include_tags": true, + "on_missing_data": "show_no_data", + "renotify_interval": 0, + "require_full_window": true, + "escalation_message": "", + "new_host_delay": 300 + }, + "priority": 3, + "draft_status": "published", + "assets": [] + } +} \ No newline at end of file diff --git a/huntress/assets/service_checks.json b/huntress/assets/service_checks.json new file mode 100644 index 000000000..e67893ec2 --- /dev/null +++ b/huntress/assets/service_checks.json @@ -0,0 +1,14 @@ +[ + { + "agent_version": "7.0.0", + "integration": "Huntress", + "check": "huntress.siem.check_status", + "statuses": [ + "ok", + "critical" + ], + "groups": [], + "name": "Huntress SIEM Check Status", + "description": "Returns `CRITICAL` if the Huntress SIEM collection run fails for any reason (such as an auth error, network error, or invalid query); returns `OK` otherwise." + } +] \ No newline at end of file diff --git a/huntress/checks.d/huntress.py b/huntress/checks.d/huntress.py new file mode 100644 index 000000000..00006654f --- /dev/null +++ b/huntress/checks.d/huntress.py @@ -0,0 +1,607 @@ +import base64 +import json +import time +import zlib +from datetime import datetime, timedelta, timezone + +import requests + +from datadog_checks.base import AgentCheck, ConfigurationError + +CHECK_NAME = "huntress" + + +class HuntressCheck(AgentCheck): + """Datadog Agent check: polls Huntress SIEM via ES|QL and forwards logs.""" + + HUNTRESS_SIEM_ENDPOINT = "/v1/siem/query" + HUNTRESS_ACCOUNT_ENDPOINT = "/v1/account" + HUNTRESS_ORGS_ENDPOINT = "/v1/accounts/{account_id}/organizations" + HUNTRESS_AGENTS_ENDPOINT = "/v1/agents" + + CHECKPOINT_CACHE_KEY_PREFIX = "huntress_last_collected_at_" + ORG_CACHE_KEY_PREFIX = "huntress_org_cache_" + + MAX_LOGS_PER_BATCH = 1000 + MAX_BATCH_SIZE_BYTES = 4 * 1024 * 1024 # 4 MB headroom under 5 MB limit + + DEFAULT_BASE_URL = "https://api.huntress.io" + DEFAULT_REQUEST_TIMEOUT = 30 + DEFAULT_MIN_COLLECTION_INTERVAL = 900 + DEFAULT_MAX_PAGES_PER_RUN = 100 + DEFAULT_ORG_CACHE_TTL = 3600 + DEFAULT_AGENT_MAX_PAGES = 20 # 20 × 500/page = up to 10k agents + + SERVICE_CHECK_NAME = "huntress.siem.check_status" + + def check(self, instance): + start_time = time.time() + + # Reset per-run rate limit state + self._last_api_call_limit = None + self._last_api_call_remaining = None + + api_key = instance.get("huntress_api_key", "").strip() + secret_key = instance.get("huntress_secret_key", "").strip() + base_url = instance.get("huntress_base_url", self.DEFAULT_BASE_URL).rstrip("/") + max_pages = int(instance.get("max_pages_per_run", self.DEFAULT_MAX_PAGES_PER_RUN)) + min_interval = int(instance.get("min_collection_interval", self.DEFAULT_MIN_COLLECTION_INTERVAL)) + enrich_orgs = instance.get("enrich_with_org_tags", True) + org_ttl = int(instance.get("org_cache_ttl_seconds", self.DEFAULT_ORG_CACHE_TTL)) + extra_tags = list(instance.get("tags", [])) + log_queries = instance.get("log_queries") or [] + + metrics_config = instance.get("metrics") or {} + agents_config = metrics_config.get("agents") or {} + collect_agents = bool(agents_config.get("enabled", False)) + agents_max_pages = int(agents_config.get("max_pages", self.DEFAULT_AGENT_MAX_PAGES)) + + if not api_key: + raise ConfigurationError("huntress_api_key is required") + if not secret_key: + raise ConfigurationError("huntress_secret_key is required") + if not log_queries and not collect_agents: + raise ConfigurationError( + "Configure at least one of: log_queries (for SIEM log collection) " + "or metrics.agents.enabled: true (for agent metrics)" + ) + + instance_hash = self._instance_hash(instance) + headers = self._get_auth_header(api_key, secret_key) + + # Org enrichment cache is shared across all queries for this instance + org_cache = None + if enrich_orgs and log_queries: + org_cache = self._get_or_refresh_org_cache(base_url, headers, instance_hash, org_ttl) + + success = False + + try: + for query_def in log_queries: + query_name = query_def.get("name", "").strip() + esql = query_def.get("esql_query", "").strip() + query_tags = list(query_def.get("tags", [])) + + if not query_name: + raise ConfigurationError("Each entry in log_queries must have a non-empty 'name'") + if not esql: + raise ConfigurationError("Each entry in log_queries must have a non-empty esql_query") + if not esql.lower().lstrip().startswith("from logs"): + raise ConfigurationError(f"esql_query must begin with 'FROM logs' (case-insensitive): {esql!r}") + + # Each query tracks its own checkpoint so queries are independently resumable + checkpoint_key = instance_hash + "_" + self._query_hash(esql) + all_tags = extra_tags + query_tags + query_metric_tags = extra_tags + [f"query_name:{query_name}"] + + logs, pages = self._run_query( + base_url, headers, esql, checkpoint_key, max_pages, min_interval, org_cache, all_tags + ) + + self.gauge("huntress.siem.logs_collected", logs, tags=query_metric_tags) + self.gauge("huntress.siem.pages_fetched", pages, tags=query_metric_tags) + + if collect_agents: + self._collect_agent_metrics(base_url, headers, extra_tags, agents_max_pages) + + success = True + + except Exception as exc: + self.log.error("Huntress check run failed: %s", exc) + self.count("huntress.siem.errors", 1, tags=extra_tags + ["error_type:run_failure"]) + self.service_check(self.SERVICE_CHECK_NAME, self.CRITICAL, tags=extra_tags) + raise + + finally: + duration = time.time() - start_time + self.gauge("huntress.siem.run_duration_seconds", duration, tags=extra_tags) + if self._last_api_call_limit is not None: + self.gauge("huntress.siem.api_call_limit", self._last_api_call_limit, tags=extra_tags) + if self._last_api_call_remaining is not None: + self.gauge("huntress.siem.api_call_remaining", self._last_api_call_remaining, tags=extra_tags) + + if success: + self.service_check(self.SERVICE_CHECK_NAME, self.OK, tags=extra_tags) + + # ------------------------------------------------------------------ # + # Per-query SIEM execution # + # ------------------------------------------------------------------ # + + def _run_query(self, base_url, headers, esql, checkpoint_key, max_pages, min_interval, org_cache, all_tags): + """Paginate a single ES|QL query. Returns (logs_collected, pages_fetched).""" + range_start = self._load_checkpoint(checkpoint_key) + now = datetime.now(timezone.utc) + range_end = now.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z" + + if range_start is None: + default_start = now - timedelta(seconds=min_interval) + range_start = default_start.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z" + else: + # Add 1ms to avoid re-fetching the boundary event from the previous run + try: + dt = datetime.fromisoformat(range_start.replace("Z", "+00:00")) + dt = dt + timedelta(milliseconds=1) + range_start = dt.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z" + except Exception: + pass + + self.log.debug( + "Huntress SIEM query: esql=%r range_start=%s range_end=%s", + esql[:80], + range_start, + range_end, + ) + + service_tag = self._extract_service(all_tags) + page_token = None + total_logs = 0 + pages_fetched = 0 + hit_page_cap = False + + while True: + logs, next_token = self._query_page(base_url, headers, esql, range_start, range_end, page_token) + + if logs: + batch = [] + for raw in logs: + org_tags = self._get_org_tags(raw, org_cache) if org_cache else [] + payload = self._transform_log(raw, all_tags + org_tags, service_tag) + batch.append(payload) + if len(batch) >= self.MAX_LOGS_PER_BATCH: + self._send_logs_batch(batch) + batch = [] + if batch: + self._send_logs_batch(batch) + + total_logs += len(logs) + pages_fetched += 1 + + if next_token and pages_fetched < max_pages: + page_token = next_token + else: + if next_token and pages_fetched >= max_pages: + hit_page_cap = True + self.log.warning( + "Huntress SIEM: hit max_pages_per_run=%d for query %r; remaining pages collected next run", + max_pages, + esql[:60], + ) + break + + if not hit_page_cap: + self._save_checkpoint(checkpoint_key, range_end) + + return total_logs, pages_fetched + + # ------------------------------------------------------------------ # + # Agent metrics # + # ------------------------------------------------------------------ # + + def _collect_agent_metrics(self, base_url, headers, extra_tags, max_pages): + """Fetch all agents and emit platform/status counts as Datadog metrics.""" + agents = [] + page_token = None + pages_fetched = 0 + + while True: + params = {"limit": 500} + if page_token: + params["page_token"] = page_token + + url = base_url + self.HUNTRESS_AGENTS_ENDPOINT + resp = self._request_with_retry("GET", url, headers, params=params) + data = resp.json() + batch = data.get("agents", []) + agents.extend(batch) + pages_fetched += 1 + + pagination = data.get("pagination", {}) + next_token = pagination.get("next_page_token") + if next_token and pages_fetched < max_pages: + page_token = next_token + else: + if next_token and pages_fetched >= max_pages: + self.log.warning( + "Huntress agents: hit max_pages=%d; some agents may be excluded from this run's metrics", + max_pages, + ) + break + + by_platform = {} + by_defender_status = {} + by_firewall_status = {} + + for agent in agents: + platform = (agent.get("platform") or "unknown").lower() + by_platform[platform] = by_platform.get(platform, 0) + 1 + + def_status = (agent.get("defender_status") or "unknown").lower().replace(" ", "_") + by_defender_status[def_status] = by_defender_status.get(def_status, 0) + 1 + + fw_status = (agent.get("firewall_status") or "unknown").lower().replace(" ", "_") + by_firewall_status[fw_status] = by_firewall_status.get(fw_status, 0) + 1 + + self.gauge("huntress.agents.total", len(agents), tags=extra_tags) + self.gauge("huntress.agents.pages_fetched", pages_fetched, tags=extra_tags) + + for platform, count in by_platform.items(): + self.gauge("huntress.agents.count", count, tags=extra_tags + [f"platform:{platform}"]) + + for status, count in by_defender_status.items(): + self.gauge("huntress.agents.defender_status", count, tags=extra_tags + [f"defender_status:{status}"]) + + for status, count in by_firewall_status.items(): + self.gauge("huntress.agents.firewall_status", count, tags=extra_tags + [f"firewall_status:{status}"]) + + # ------------------------------------------------------------------ # + # Auth # + # ------------------------------------------------------------------ # + + def _get_auth_header(self, api_key, secret_key): + token = base64.b64encode(f"{api_key}:{secret_key}".encode()).decode() + return { + "Authorization": f"Basic {token}", + "Content-Type": "application/json", + "Accept": "application/json", + } + + def _parse_rate_limit_headers(self, resp): + try: + limit = resp.headers.get("x-huntress-api-call-limit") + if limit is not None: + self._last_api_call_limit = int(limit) + except (ValueError, TypeError, AttributeError): + pass + try: + remaining = resp.headers.get("x-huntress-api-call-remaining") + if remaining is not None: + self._last_api_call_remaining = int(remaining) + if self._last_api_call_remaining < 10: + self.log.warning( + "Huntress API rate limit nearly exhausted: %d/%d requests remaining this minute", + self._last_api_call_remaining, + self._last_api_call_limit or 60, + ) + except (ValueError, TypeError, AttributeError): + pass + + # ------------------------------------------------------------------ # + # SIEM query # + # ------------------------------------------------------------------ # + + def _query_page(self, base_url, headers, esql, range_start, range_end, page_token=None): + """POST /v1/siem/query; return (logs_list, next_page_token | None).""" + url = base_url + self.HUNTRESS_SIEM_ENDPOINT + body = {"esql": esql, "range_start": range_start, "range_end": range_end} + if page_token: + body["page_token"] = page_token + + response = self._request_with_retry(method="POST", url=url, headers=headers, json_body=body) + data = response.json() + logs = data.get("logs", []) + pagination = data.get("pagination", {}) + next_token = pagination.get("next_page_token") + return logs, next_token + + # ------------------------------------------------------------------ # + # HTTP with retry / error handling # + # ------------------------------------------------------------------ # + + def _request_with_retry(self, method, url, headers, json_body=None, params=None): + """Execute an HTTP request with retry logic per PRD §7.""" + timeout = self.init_config.get("request_timeout", self.DEFAULT_REQUEST_TIMEOUT) + max_retries_5xx = 3 + max_retries_408 = 2 + backoff_5xx = [5, 10, 20] + backoff_408 = [2, 4] + + attempt = 0 + + while True: + try: + resp = requests.request( + method, + url, + headers=headers, + json=json_body, + params=params, + timeout=timeout, + ) + + if resp.status_code == 200: + self._parse_rate_limit_headers(resp) + return resp + + if resp.status_code == 400: + self.count("huntress.siem.errors", 1, tags=["error_type:bad_request"]) + raise Exception(f"Huntress API 400 Bad Request: {resp.text}") + + if resp.status_code == 401: + self.count("huntress.siem.errors", 1, tags=["error_type:auth_failure"]) + raise Exception("Huntress API 401 Unauthorized — check huntress_api_key and huntress_secret_key") + + if resp.status_code == 404: + raise Exception("Huntress API 404 — SIEM feature may not be enabled on this account") + + if resp.status_code == 408: + if attempt < max_retries_408: + wait = backoff_408[attempt] + self.log.warning( + "Huntress API 408 Query Timeout; retrying in %ds (attempt %d/%d)", + wait, + attempt + 1, + max_retries_408, + ) + time.sleep(wait) + attempt += 1 + continue + self.count("huntress.siem.errors", 1, tags=["error_type:timeout"]) + raise Exception("Huntress API 408 Query Timeout — query may be too broad") + + if resp.status_code == 413: + raise Exception("Huntress API 413 Memory Limit — narrow the query with KEEP or WHERE clauses") + + if resp.status_code == 422: + self.count("huntress.siem.errors", 1, tags=["error_type:invalid_query"]) + raise Exception(f"Huntress API 422 Invalid ES|QL query: {resp.text}") + + if resp.status_code == 429: + self.log.warning("Huntress API 429 Rate Limited — sleeping 60s then retrying") + time.sleep(60) + continue + + if 500 <= resp.status_code < 600: + if attempt < max_retries_5xx: + wait = backoff_5xx[attempt] + self.log.warning( + "Huntress API %d Server Error; retrying in %ds (attempt %d/%d)", + resp.status_code, + wait, + attempt + 1, + max_retries_5xx, + ) + time.sleep(wait) + attempt += 1 + continue + self.count("huntress.siem.errors", 1, tags=["error_type:server_error"]) + raise Exception(f"Huntress API {resp.status_code} Server Error after {max_retries_5xx} retries") + + raise Exception(f"Huntress API unexpected status {resp.status_code}: {resp.text}") + + except requests.exceptions.RequestException as exc: + if attempt < max_retries_5xx: + wait = backoff_5xx[attempt] + self.log.warning( + "Huntress API network error (%s); retrying in %ds (attempt %d/%d)", + exc, + wait, + attempt + 1, + max_retries_5xx, + ) + time.sleep(wait) + attempt += 1 + continue + self.count("huntress.siem.errors", 1, tags=["error_type:connection_error"]) + raise Exception(f"Huntress API connection error after {max_retries_5xx} retries: {exc}") from exc + + # ------------------------------------------------------------------ # + # Log transformation # + # ------------------------------------------------------------------ # + + def _extract_service(self, tags): + for tag in tags: + if tag.startswith("service:"): + return tag.split(":", 1)[1] + return "huntress-siem" + + def _transform_log(self, raw_log, tags, service): + message = raw_log.get("log.original") or raw_log.get("message") or json.dumps(raw_log) + + timestamp = raw_log.get("@timestamp") + date_ms = None + if timestamp: + try: + if isinstance(timestamp, (int, float)): + date_ms = int(timestamp) + else: + dt = datetime.fromisoformat(str(timestamp).replace("Z", "+00:00")) + date_ms = int(dt.timestamp() * 1000) + except Exception: + pass + + payload = { + "message": message, + "ddsource": "huntress", + "ddtags": ",".join(tags), + "service": service, + } + if date_ms is not None: + payload["date"] = date_ms + + # Preserve all ECS fields as top-level log attributes + for key, value in raw_log.items(): + if key not in ("log.original", "message", "@timestamp"): + payload[key] = value + + return payload + + # ------------------------------------------------------------------ # + # Log sending # + # ------------------------------------------------------------------ # + + def _send_logs_batch(self, logs_batch): + for log_payload in logs_batch: + self.send_log(log_payload) + + # ------------------------------------------------------------------ # + # Checkpoint # + # ------------------------------------------------------------------ # + + def _load_checkpoint(self, checkpoint_key): + key = self.CHECKPOINT_CACHE_KEY_PREFIX + checkpoint_key + raw = self.read_persistent_cache(key) + if not raw: + return None + try: + data = json.loads(raw) + return data.get("last_collected_at") + except Exception: + return None + + def _save_checkpoint(self, checkpoint_key, timestamp_iso): + key = self.CHECKPOINT_CACHE_KEY_PREFIX + checkpoint_key + payload = json.dumps({"last_collected_at": timestamp_iso, "schema_version": 1}) + self.write_persistent_cache(key, payload) + + # ------------------------------------------------------------------ # + # Org metadata enrichment # + # ------------------------------------------------------------------ # + + def _get_or_refresh_org_cache(self, base_url, headers, instance_hash, ttl_seconds): + """Return the org cache dict, refreshing if stale or absent.""" + cached = self._load_org_cache(instance_hash) + if cached: + try: + fetched_at_str = cached.get("fetched_at", "") + fetched_at = datetime.fromisoformat(fetched_at_str.replace("Z", "+00:00")) + age = (datetime.now(timezone.utc) - fetched_at).total_seconds() + if ttl_seconds == 0 or age < ttl_seconds: + return cached + except Exception: + pass + + try: + account_id = self._get_account_id(base_url, headers) + orgs = self._fetch_org_cache(base_url, headers, account_id) + cache_payload = { + "fetched_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z", + "account_id": account_id, + "orgs": orgs, + } + self._save_org_cache(instance_hash, cache_payload) + return cache_payload + except Exception as exc: + self.log.warning( + "Huntress: failed to fetch org metadata — logs will be collected without org tags: %s", + exc, + ) + return None + + def _get_account_id(self, base_url, headers): + url = base_url + self.HUNTRESS_ACCOUNT_ENDPOINT + resp = self._request_with_retry("GET", url, headers) + data = resp.json() + account = data.get("account", data) + return account["id"] + + def _fetch_org_cache(self, base_url, headers, account_id): + """Paginate /v1/accounts/{id}/organizations; return {org_id_str: {...}} dict.""" + orgs = {} + page = 1 + while True: + url = base_url + self.HUNTRESS_ORGS_ENDPOINT.format(account_id=account_id) + resp = self._request_with_retry("GET", url, headers, params={"limit": 500, "page": page}) + data = resp.json() + org_list = data.get("organizations", []) + for org in org_list: + orgs[str(org["id"])] = { + "name": org.get("name", ""), + "key": org.get("key", ""), + "account_id": account_id, + } + pagination = data.get("pagination", {}) + if pagination.get("next_page_token") or ( + pagination.get("current_page", 1) < pagination.get("total_pages", 1) + ): + page += 1 + else: + break + return orgs + + def _load_org_cache(self, instance_hash): + key = self.ORG_CACHE_KEY_PREFIX + instance_hash + raw = self.read_persistent_cache(key) + if not raw: + return None + try: + return json.loads(raw) + except Exception: + return None + + def _save_org_cache(self, instance_hash, cache_payload): + key = self.ORG_CACHE_KEY_PREFIX + instance_hash + self.write_persistent_cache(key, json.dumps(cache_payload)) + + def _get_org_tags(self, raw_log, org_cache): + """Match a log record to an org in the cache; return list of tag strings.""" + if not org_cache: + return [] + + account_id = org_cache.get("account_id") + orgs = org_cache.get("orgs", {}) + + base_tags = [] + if account_id is not None: + base_tags.append(f"huntress_account_id:{account_id}") + + # Strategy 1: match by organization.id + org_id = raw_log.get("organization.id") or ( + raw_log.get("organization", {}).get("id") if isinstance(raw_log.get("organization"), dict) else None + ) + if org_id is not None: + org = orgs.get(str(org_id)) + if org: + return base_tags + [ + f"huntress_org_id:{org_id}", + f"huntress_org_name:{org['name']}", + f"huntress_org_key:{org['key']}", + ] + + # Strategy 2: match by organization.name (reverse lookup) + org_name = raw_log.get("organization.name") or ( + raw_log.get("organization", {}).get("name") if isinstance(raw_log.get("organization"), dict) else None + ) + if org_name: + for oid, org in orgs.items(): + if org.get("name") == org_name: + return base_tags + [ + f"huntress_org_id:{oid}", + f"huntress_org_name:{org['name']}", + f"huntress_org_key:{org['key']}", + ] + + # Strategy 3: account-level tag only + return base_tags + + # ------------------------------------------------------------------ # + # Hashing # + # ------------------------------------------------------------------ # + + def _instance_hash(self, instance): + """Stable non-secret identifier for this Huntress instance.""" + key = instance.get("instance_id") or instance.get("huntress_base_url", self.DEFAULT_BASE_URL) + return format(zlib.crc32(key.encode("utf-8")) & 0xFFFFFFFF, "08x") + + def _query_hash(self, esql_query): + """Short non-cryptographic checksum for a per-query checkpoint key.""" + return format(zlib.crc32(esql_query.encode("utf-8")) & 0xFFFFFFFF, "08x") diff --git a/huntress/datadog_checks/__init__.py b/huntress/datadog_checks/__init__.py new file mode 100644 index 000000000..69e3be50d --- /dev/null +++ b/huntress/datadog_checks/__init__.py @@ -0,0 +1 @@ +__path__ = __import__('pkgutil').extend_path(__path__, __name__) diff --git a/huntress/datadog_checks/huntress/__about__.py b/huntress/datadog_checks/huntress/__about__.py new file mode 100644 index 000000000..f102a9cad --- /dev/null +++ b/huntress/datadog_checks/huntress/__about__.py @@ -0,0 +1 @@ +__version__ = "0.0.1" diff --git a/huntress/datadog_checks/huntress/__init__.py b/huntress/datadog_checks/huntress/__init__.py new file mode 100644 index 000000000..d4eb6b4b6 --- /dev/null +++ b/huntress/datadog_checks/huntress/__init__.py @@ -0,0 +1,4 @@ +from .__about__ import __version__ +from .huntress import HuntressCheck + +__all__ = ['__version__', 'HuntressCheck'] diff --git a/huntress/datadog_checks/huntress/data/conf.yaml.example b/huntress/datadog_checks/huntress/data/conf.yaml.example new file mode 100644 index 000000000..b720af3cd --- /dev/null +++ b/huntress/datadog_checks/huntress/data/conf.yaml.example @@ -0,0 +1,154 @@ +## 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: + + ## @param request_timeout - number - optional - default: 30 + ## HTTP request timeout in seconds applied to all Huntress API calls. + ## Increase this if the Huntress SIEM API is slow to respond on large queries. + # + # request_timeout: 30 + +## Every instance is scheduled independently of the others. +# +instances: + + - + ## @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: 900 + ## 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: 900 + + ## @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: + # - + + ## @param huntress_api_key - string - required + ## Huntress public API key. Obtain from the Huntress Partner Portal under + ## Settings > API Credentials. Each instance uses its own key pair, enabling + ## multiple Huntress accounts to feed into the same Datadog organization. + # + huntress_api_key: + + ## @param huntress_secret_key - string - required + ## Huntress private (secret) API key paired with huntress_api_key. + # + huntress_secret_key: + + ## @param log_queries - list of mappings - optional + ## List of ES|QL queries to execute against the Huntress SIEM API. Each entry + ## requires an esql_query string (must begin with "FROM logs", case-insensitive) + ## and an optional tags list attached only to logs produced by that query. + ## Multiple queries run independently with separate checkpoints, enabling + ## targeted collection for different log types within the same account. + ## Do not add time range filters — range_start and range_end are managed + ## automatically by the check. + ## + ## IMPORTANT: Always include a KEEP clause to select the fields you want. + ## Despite what the Huntress API documentation states, queries without an + ## explicit KEEP clause return only uuid and organization_id — not all fields. + ## Example: FROM logs | KEEP @timestamp, message, host.hostname, event.category, + ## event.code, event.provider, event.id, user.target.name, user.target.domain, + ## winlog.system.EventID + # + # log_queries: + # - name: windows-security-events + # esql_query: FROM logs | KEEP @timestamp, message, host.hostname, event.category, + # event.code, event.provider, event.id, user.target.name, user.target.domain, winlog.system.EventID + # tags: + # - log_type:windows + + ## @param enrich_with_org_tags - boolean - optional - default: true + ## When true, the check fetches Huntress organization metadata (org name, + ## org key, account ID) and attaches it as log tags. The org cache is + ## refreshed according to org_cache_ttl_seconds. + # + # enrich_with_org_tags: true + + ## @param org_cache_ttl_seconds - integer - optional - default: 3600 + ## How long (in seconds) to cache organization metadata before re-fetching + ## from the Huntress API. Set to 0 to refresh on every run. + # + # org_cache_ttl_seconds: 3600 + + ## @param max_pages_per_run - integer - optional - default: 100 + ## Maximum number of result pages to fetch per query per run. Each page + ## contains up to 200 log events. Protects against runaway pagination on + ## large backlogs. If this cap is hit, the checkpoint does not advance and + ## remaining pages are fetched on the next run. + # + # max_pages_per_run: 100 + + ## @param huntress_base_url - string - optional - default: https://api.huntress.io + ## Huntress API base URL. Override for sandbox or testing environments. + # + # huntress_base_url: https://api.huntress.io + + ## @param metrics - mapping - optional + ## Controls which Huntress data is collected as Datadog metrics beyond the + ## standard SIEM run metrics. Omit this section to disable additional metric + ## collection (logs-only instance). Configure a separate instance with only + ## this section and different API credentials to isolate the agent metrics + ## API call budget from SIEM log collection. + # + # metrics: + # agents: + # enabled: true + # max_pages: 20 + +## 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 +# source: huntress +# service: huntress diff --git a/huntress/datadog_checks/huntress/huntress.py b/huntress/datadog_checks/huntress/huntress.py new file mode 100644 index 000000000..abdd5333a --- /dev/null +++ b/huntress/datadog_checks/huntress/huntress.py @@ -0,0 +1,700 @@ +import base64 +import gzip +import json +import re +import time +import zlib +from datetime import datetime, timedelta, timezone + +import requests + +from datadog_checks.base import AgentCheck, ConfigurationError + +CHECK_NAME = "huntress" + + +class HuntressCheck(AgentCheck): + """Datadog Agent check: polls Huntress SIEM via ES|QL and forwards logs.""" + + HUNTRESS_SIEM_ENDPOINT = "/v1/siem/query" + HUNTRESS_ACCOUNT_ENDPOINT = "/v1/account" + HUNTRESS_ORGS_ENDPOINT = "/v1/accounts/{account_id}/organizations" + HUNTRESS_AGENTS_ENDPOINT = "/v1/agents" + + CHECKPOINT_CACHE_KEY_PREFIX = "huntress_last_collected_at_" + ORG_CACHE_KEY_PREFIX = "huntress_org_cache_" + + MAX_LOGS_PER_BATCH = 1000 + MAX_BATCH_SIZE_BYTES = 4 * 1024 * 1024 # 4 MB headroom under 5 MB limit + + DEFAULT_BASE_URL = "https://api.huntress.io" + DEFAULT_REQUEST_TIMEOUT = 30 + DEFAULT_MIN_COLLECTION_INTERVAL = 900 + DEFAULT_MAX_PAGES_PER_RUN = 100 + DEFAULT_ORG_CACHE_TTL = 3600 + DEFAULT_AGENT_MAX_PAGES = 20 # 20 × 500/page = up to 10k agents + + SERVICE_CHECK_NAME = "huntress.siem.check_status" + + # Fields present in every response regardless of KEEP clause; logs containing + # only these keys have no content because the query is missing a KEEP verb. + _BARE_LOG_KEYS = frozenset({"uuid", "organization_id"}) + + def check(self, instance): + start_time = time.time() + + # Reset per-run rate limit state + self._last_api_call_limit = None + self._last_api_call_remaining = None + + api_key = instance.get("huntress_api_key", "").strip() + secret_key = instance.get("huntress_secret_key", "").strip() + base_url = instance.get("huntress_base_url", self.DEFAULT_BASE_URL).rstrip("/") + max_pages = int(instance.get("max_pages_per_run", self.DEFAULT_MAX_PAGES_PER_RUN)) + min_interval = int(instance.get("min_collection_interval", self.DEFAULT_MIN_COLLECTION_INTERVAL)) + enrich_orgs = instance.get("enrich_with_org_tags", True) + org_ttl = int(instance.get("org_cache_ttl_seconds", self.DEFAULT_ORG_CACHE_TTL)) + extra_tags = list(instance.get("tags", [])) + log_queries = instance.get("log_queries") or [] + + metrics_config = instance.get("metrics") or {} + agents_config = metrics_config.get("agents") or {} + collect_agents = bool(agents_config.get("enabled", False)) + agents_max_pages = int(agents_config.get("max_pages", self.DEFAULT_AGENT_MAX_PAGES)) + + if not api_key: + raise ConfigurationError("huntress_api_key is required") + if not secret_key: + raise ConfigurationError("huntress_secret_key is required") + if not log_queries and not collect_agents: + raise ConfigurationError( + "Configure at least one of: log_queries (for SIEM log collection) " + "or metrics.agents.enabled: true (for agent metrics)" + ) + + instance_hash = self._instance_hash(instance) + headers = self._get_auth_header(api_key, secret_key) + + # Org enrichment cache is shared across all queries for this instance + org_cache = None + if enrich_orgs and log_queries: + org_cache = self._get_or_refresh_org_cache(base_url, headers, instance_hash, org_ttl) + + success = False + run_summary = [] + + try: + for query_def in log_queries: + query_name = query_def.get("name", "").strip() + esql = query_def.get("esql_query", "").strip() + query_tags = list(query_def.get("tags", [])) + + if not query_name: + raise ConfigurationError("Each entry in log_queries must have a non-empty 'name'") + if not esql: + raise ConfigurationError("Each entry in log_queries must have a non-empty esql_query") + if not esql.lower().lstrip().startswith("from logs"): + raise ConfigurationError(f"esql_query must begin with 'FROM logs' (case-insensitive): {esql!r}") + + # Each query tracks its own checkpoint so queries are independently resumable + checkpoint_key = instance_hash + "_" + self._query_hash(esql) + all_tags = extra_tags + query_tags + [f"huntress_log_name:{query_name}"] + query_metric_tags = extra_tags + [f"query_name:{query_name}"] + + logs, pages, q_start, q_end = self._run_query( + base_url, headers, esql, checkpoint_key, max_pages, min_interval, org_cache, all_tags + ) + + self.gauge("huntress.siem.logs_collected", logs, tags=query_metric_tags) + self.gauge("huntress.siem.pages_fetched", pages, tags=query_metric_tags) + run_summary.append( + f"query '{query_name}': {logs} log(s) collected, {pages} page(s) fetched [{q_start} → {q_end}]" + ) + + if collect_agents: + agents_total, agents_pages = self._collect_agent_metrics( + base_url, headers, extra_tags, agents_max_pages + ) + run_summary.append(f"agent metrics: {agents_total} agent(s), {agents_pages} page(s) fetched") + + success = True + self.log.info( + "Huntress check complete — %s", "; ".join(run_summary) if run_summary else "no data collected" + ) + + except Exception as exc: + self.log.error("Huntress check run failed: %s", exc) + self.count("huntress.siem.errors", 1, tags=extra_tags + ["error_type:run_failure"]) + self.service_check(self.SERVICE_CHECK_NAME, self.CRITICAL, tags=extra_tags) + raise + + finally: + duration = time.time() - start_time + self.gauge("huntress.siem.run_duration_seconds", duration, tags=extra_tags) + if self._last_api_call_limit is not None: + self.gauge("huntress.siem.api_call_limit", self._last_api_call_limit, tags=extra_tags) + if self._last_api_call_remaining is not None: + self.gauge("huntress.siem.api_call_remaining", self._last_api_call_remaining, tags=extra_tags) + + if success: + self.service_check(self.SERVICE_CHECK_NAME, self.OK, tags=extra_tags) + + # ------------------------------------------------------------------ # + # Per-query SIEM execution # + # ------------------------------------------------------------------ # + + def _run_query(self, base_url, headers, esql, checkpoint_key, max_pages, min_interval, org_cache, all_tags): + """Paginate a single ES|QL query. Returns (logs_collected, pages_fetched, range_start, range_end).""" + range_start = self._load_checkpoint(checkpoint_key) + now = datetime.now(timezone.utc) + range_end = now.strftime("%Y-%m-%dT%H:%M:%S") + + if range_start is None: + default_start = now - timedelta(seconds=min_interval) + range_start = default_start.strftime("%Y-%m-%dT%H:%M:%S") + else: + # Advance by 1 second so the boundary event isn't re-fetched next run + try: + dt = datetime.fromisoformat(range_start.replace("Z", "+00:00")) + dt = dt + timedelta(seconds=1) + range_start = dt.strftime("%Y-%m-%dT%H:%M:%S") + except Exception: + pass + + self.log.debug( + "Huntress SIEM query: esql=%r range_start=%s range_end=%s", + esql[:80], + range_start, + range_end, + ) + + service_tag = self._extract_service(all_tags) + page_token = None + total_logs = 0 + pages_fetched = 0 + hit_page_cap = False + + while True: + logs, next_token = self._query_page(base_url, headers, esql, range_start, range_end, page_token) + + if logs: + if pages_fetched == 0 and all(set(raw.keys()) <= self._BARE_LOG_KEYS for raw in logs[:3]): + self.log.warning( + "Huntress SIEM query %r returned logs with no content fields " + "(only uuid and organization_id). The Huntress API requires an explicit " + "KEEP clause to return log fields. Add one to your esql_query, e.g.: " + "FROM logs | KEEP @timestamp, message, host.hostname, event.category, event.code, ...", + esql[:80], + ) + + batch = [] + for raw in logs: + org_tags = self._get_org_tags(raw, org_cache) if org_cache else [] + payload = self._transform_log(raw, all_tags + org_tags, service_tag) + batch.append(payload) + if len(batch) >= self.MAX_LOGS_PER_BATCH: + self._send_logs_batch(batch) + batch = [] + if batch: + self._send_logs_batch(batch) + + total_logs += len(logs) + pages_fetched += 1 + + if next_token and pages_fetched < max_pages: + page_token = next_token + else: + if next_token and pages_fetched >= max_pages: + hit_page_cap = True + self.log.warning( + "Huntress SIEM: hit max_pages_per_run=%d for query %r; remaining pages collected next run", + max_pages, + esql[:60], + ) + break + + if not hit_page_cap: + self._save_checkpoint(checkpoint_key, range_end) + + return total_logs, pages_fetched, range_start, range_end + + # ------------------------------------------------------------------ # + # Agent metrics # + # ------------------------------------------------------------------ # + + def _collect_agent_metrics(self, base_url, headers, extra_tags, max_pages): + """Fetch all agents and emit platform/status counts as Datadog metrics.""" + agents = [] + page_token = None + pages_fetched = 0 + + while True: + params = {"limit": 500} + if page_token: + params["page_token"] = page_token + + url = base_url + self.HUNTRESS_AGENTS_ENDPOINT + resp = self._request_with_retry("GET", url, headers, params=params) + data = resp.json() + batch = data.get("agents", []) + agents.extend(batch) + pages_fetched += 1 + + pagination = data.get("pagination", {}) + next_token = pagination.get("next_page_token") + if next_token and pages_fetched < max_pages: + page_token = next_token + else: + if next_token and pages_fetched >= max_pages: + self.log.warning( + "Huntress agents: hit max_pages=%d; some agents may be excluded from this run's metrics", + max_pages, + ) + break + + by_platform = {} + by_defender_status = {} + by_firewall_status = {} + + for agent in agents: + platform = (agent.get("platform") or "unknown").lower() + by_platform[platform] = by_platform.get(platform, 0) + 1 + + def_status = (agent.get("defender_status") or "unknown").lower().replace(" ", "_") + by_defender_status[def_status] = by_defender_status.get(def_status, 0) + 1 + + fw_status = (agent.get("firewall_status") or "unknown").lower().replace(" ", "_") + by_firewall_status[fw_status] = by_firewall_status.get(fw_status, 0) + 1 + + self.gauge("huntress.agents.total", len(agents), tags=extra_tags) + self.gauge("huntress.agents.pages_fetched", pages_fetched, tags=extra_tags) + + for platform, count in by_platform.items(): + self.gauge("huntress.agents.count", count, tags=extra_tags + [f"platform:{platform}"]) + + for status, count in by_defender_status.items(): + self.gauge("huntress.agents.defender_status", count, tags=extra_tags + [f"defender_status:{status}"]) + + for status, count in by_firewall_status.items(): + self.gauge("huntress.agents.firewall_status", count, tags=extra_tags + [f"firewall_status:{status}"]) + + return len(agents), pages_fetched + + # ------------------------------------------------------------------ # + # Auth # + # ------------------------------------------------------------------ # + + def _get_auth_header(self, api_key, secret_key): + token = base64.b64encode(f"{api_key}:{secret_key}".encode()).decode() + return { + "Authorization": f"Basic {token}", + "Content-Type": "application/json", + "Accept": "application/json", + } + + def _parse_rate_limit_headers(self, resp): + try: + limit = resp.headers.get("x-huntress-api-call-limit") + if limit is not None: + self._last_api_call_limit = int(limit) + except (ValueError, TypeError, AttributeError): + pass + try: + remaining = resp.headers.get("x-huntress-api-call-remaining") + if remaining is not None: + self._last_api_call_remaining = int(remaining) + if self._last_api_call_remaining < 10: + self.log.warning( + "Huntress API rate limit nearly exhausted: %d/%d requests remaining this minute", + self._last_api_call_remaining, + self._last_api_call_limit or 60, + ) + except (ValueError, TypeError, AttributeError): + pass + + # ------------------------------------------------------------------ # + # SIEM query # + # ------------------------------------------------------------------ # + + def _query_page(self, base_url, headers, esql, range_start, range_end, page_token=None): + """POST /v1/siem/query; return (logs_list, next_page_token | None).""" + url = base_url + self.HUNTRESS_SIEM_ENDPOINT + body = {"esql": esql, "range_start": range_start, "range_end": range_end} + if page_token: + body["page_token"] = page_token + + response = self._request_with_retry(method="POST", url=url, headers=headers, json_body=body) + data = response.json() + logs = data.get("logs", []) + pagination = data.get("pagination", {}) + next_token = pagination.get("next_page_token") + return logs, next_token + + # ------------------------------------------------------------------ # + # HTTP with retry / error handling # + # ------------------------------------------------------------------ # + + def _request_with_retry(self, method, url, headers, json_body=None, params=None): + """Execute an HTTP request with retry logic per PRD §7.""" + timeout = self.init_config.get("request_timeout", self.DEFAULT_REQUEST_TIMEOUT) + max_retries_5xx = 3 + max_retries_408 = 2 + max_retries_429 = 1 + backoff_5xx = [5, 10, 20] + backoff_408 = [2, 4] + + attempt = 0 + attempt_429 = 0 + + while True: + try: + resp = requests.request( + method, + url, + headers=headers, + json=json_body, + params=params, + timeout=timeout, + ) + + if resp.status_code == 200: + self._parse_rate_limit_headers(resp) + return resp + + if resp.status_code == 400: + self.count("huntress.siem.errors", 1, tags=["error_type:bad_request"]) + raise Exception(f"Huntress API 400 Bad Request: {resp.text}") + + if resp.status_code == 401: + self.count("huntress.siem.errors", 1, tags=["error_type:auth_failure"]) + raise Exception("Huntress API 401 Unauthorized — check huntress_api_key and huntress_secret_key") + + if resp.status_code == 404: + raise Exception("Huntress API 404 — SIEM feature may not be enabled on this account") + + if resp.status_code == 408: + if attempt < max_retries_408: + wait = backoff_408[attempt] + self.log.warning( + "Huntress API 408 Query Timeout; retrying in %ds (attempt %d/%d)", + wait, + attempt + 1, + max_retries_408, + ) + time.sleep(wait) + attempt += 1 + continue + self.count("huntress.siem.errors", 1, tags=["error_type:timeout"]) + raise Exception("Huntress API 408 Query Timeout — query may be too broad") + + if resp.status_code == 413: + raise Exception("Huntress API 413 Memory Limit — narrow the query with KEEP or WHERE clauses") + + if resp.status_code == 422: + self.count("huntress.siem.errors", 1, tags=["error_type:invalid_query"]) + raise Exception(f"Huntress API 422 Invalid ES|QL query: {resp.text}") + + if resp.status_code == 429: + if attempt_429 < max_retries_429: + self.log.warning( + "Huntress API 429 Rate Limited — sleeping 60s then retrying (attempt %d/%d)", + attempt_429 + 1, + max_retries_429, + ) + time.sleep(60) + attempt_429 += 1 + continue + self.count("huntress.siem.errors", 1, tags=["error_type:rate_limited"]) + exceptionText = ( + "Huntress API 429 Rate Limited after retry — " + "reduce max_pages_per_run or increase min_collection_interval" + ) + raise Exception(exceptionText) + + if 500 <= resp.status_code < 600: + if attempt < max_retries_5xx: + wait = backoff_5xx[attempt] + self.log.warning( + "Huntress API %d Server Error; retrying in %ds (attempt %d/%d)", + resp.status_code, + wait, + attempt + 1, + max_retries_5xx, + ) + time.sleep(wait) + attempt += 1 + continue + self.count("huntress.siem.errors", 1, tags=["error_type:server_error"]) + raise Exception(f"Huntress API {resp.status_code} Server Error after {max_retries_5xx} retries") + + raise Exception(f"Huntress API unexpected status {resp.status_code}: {resp.text}") + + except requests.exceptions.RequestException as exc: + if attempt < max_retries_5xx: + wait = backoff_5xx[attempt] + self.log.warning( + "Huntress API network error (%s); retrying in %ds (attempt %d/%d)", + exc, + wait, + attempt + 1, + max_retries_5xx, + ) + time.sleep(wait) + attempt += 1 + continue + self.count("huntress.siem.errors", 1, tags=["error_type:connection_error"]) + raise Exception(f"Huntress API connection error after {max_retries_5xx} retries: {exc}") from exc + + # ------------------------------------------------------------------ # + # Log transformation # + # ------------------------------------------------------------------ # + + def _extract_service(self, tags): + for tag in tags: + if tag.startswith("service:"): + return tag.split(":", 1)[1] + return "huntress-siem" + + def _transform_log(self, raw_log, tags, service): + message = raw_log.get("log.original") or raw_log.get("message") or json.dumps(raw_log) + + timestamp = raw_log.get("@timestamp") + ts_seconds = None + if timestamp: + try: + if isinstance(timestamp, (int, float)): + ts_seconds = float(timestamp) + else: + # Truncate sub-microsecond precision (e.g. nanoseconds) that fromisoformat rejects + ts_str = re.sub(r'(\.\d{6})\d+', r'\1', str(timestamp)).replace("Z", "+00:00") + dt = datetime.fromisoformat(ts_str) + ts_seconds = dt.timestamp() + except Exception: + pass + + payload = { + "message": message, + "ddsource": "huntress", + "ddtags": ",".join(tags), + "service": service, + } + if ts_seconds is not None: + payload["timestamp"] = ts_seconds + + # Preserve all ECS fields as top-level log attributes + for key, value in raw_log.items(): + if key not in ("log.original", "message", "@timestamp"): + payload[key] = value + + return payload + + # ------------------------------------------------------------------ # + # Log sending # + # ------------------------------------------------------------------ # + + def _send_logs_batch(self, logs_batch): + api_key = self.agentConfig.get('api_key') or self.agentConfig.get('dd_api_key') + if not api_key: + for log_payload in logs_batch: + self.send_log(log_payload) + return + + # Split into sub-batches that respect the 5MB uncompressed limit and 1000-entry cap + sub_batch = [] + sub_batch_bytes = 0 + for log in logs_batch: + log_bytes = len(json.dumps(log).encode('utf-8')) + if sub_batch and sub_batch_bytes + log_bytes > self.MAX_BATCH_SIZE_BYTES: + self._post_logs_to_intake(sub_batch, api_key) + sub_batch = [] + sub_batch_bytes = 0 + sub_batch.append(log) + sub_batch_bytes += log_bytes + if sub_batch: + self._post_logs_to_intake(sub_batch, api_key) + + def _post_logs_to_intake(self, logs_batch, api_key): + site = self.agentConfig.get('site') or 'datadoghq.com' + url = f'https://http-intake.logs.{site}/api/v2/logs' + timeout = self.init_config.get('request_timeout', self.DEFAULT_REQUEST_TIMEOUT) + body = gzip.compress(json.dumps(logs_batch).encode('utf-8')) + + for attempt in range(3): + resp = requests.post( + url, + headers={ + 'DD-API-KEY': api_key, + 'Content-Type': 'application/json', + 'Content-Encoding': 'gzip', + }, + data=body, + timeout=timeout, + ) + if resp.status_code in (200, 202): + return + if resp.status_code in (408, 429, 500, 503) and attempt < 2: + wait = (attempt + 1) * 5 + self.log.warning( + "Datadog Logs intake HTTP %d — retrying in %ds (attempt %d/2)", + resp.status_code, + wait, + attempt + 1, + ) + time.sleep(wait) + continue + raise Exception(f"Datadog Logs intake returned HTTP {resp.status_code}: {resp.text[:200]}") + + # ------------------------------------------------------------------ # + # Checkpoint # + # ------------------------------------------------------------------ # + + def _load_checkpoint(self, checkpoint_key): + key = self.CHECKPOINT_CACHE_KEY_PREFIX + checkpoint_key + raw = self.read_persistent_cache(key) + if not raw: + return None + try: + data = json.loads(raw) + return data.get("last_collected_at") + except Exception: + return None + + def _save_checkpoint(self, checkpoint_key, timestamp_iso): + key = self.CHECKPOINT_CACHE_KEY_PREFIX + checkpoint_key + payload = json.dumps({"last_collected_at": timestamp_iso, "schema_version": 1}) + self.write_persistent_cache(key, payload) + + # ------------------------------------------------------------------ # + # Org metadata enrichment # + # ------------------------------------------------------------------ # + + def _get_or_refresh_org_cache(self, base_url, headers, instance_hash, ttl_seconds): + """Return the org cache dict, refreshing if stale or absent.""" + cached = self._load_org_cache(instance_hash) + if cached: + try: + fetched_at_str = cached.get("fetched_at", "") + fetched_at = datetime.fromisoformat(fetched_at_str.replace("Z", "+00:00")) + age = (datetime.now(timezone.utc) - fetched_at).total_seconds() + if ttl_seconds == 0 or age < ttl_seconds: + return cached + except Exception: + pass + + try: + account_id = self._get_account_id(base_url, headers) + orgs = self._fetch_org_cache(base_url, headers, account_id) + cache_payload = { + "fetched_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z", + "account_id": account_id, + "orgs": orgs, + } + self._save_org_cache(instance_hash, cache_payload) + return cache_payload + except Exception as exc: + self.log.warning( + "Huntress: failed to fetch org metadata — logs will be collected without org tags: %s", + exc, + ) + return None + + def _get_account_id(self, base_url, headers): + url = base_url + self.HUNTRESS_ACCOUNT_ENDPOINT + resp = self._request_with_retry("GET", url, headers) + data = resp.json() + account = data.get("account", data) + return account["id"] + + def _fetch_org_cache(self, base_url, headers, account_id): + """Paginate /v1/accounts/{id}/organizations; return {org_id_str: {...}} dict.""" + orgs = {} + page = 1 + while True: + url = base_url + self.HUNTRESS_ORGS_ENDPOINT.format(account_id=account_id) + resp = self._request_with_retry("GET", url, headers, params={"limit": 500, "page": page}) + data = resp.json() + org_list = data.get("organizations", []) + for org in org_list: + orgs[str(org["id"])] = { + "name": org.get("name", ""), + "key": org.get("key", ""), + "account_id": account_id, + } + pagination = data.get("pagination", {}) + if pagination.get("next_page_token") or ( + pagination.get("current_page", 1) < pagination.get("total_pages", 1) + ): + page += 1 + else: + break + return orgs + + def _load_org_cache(self, instance_hash): + key = self.ORG_CACHE_KEY_PREFIX + instance_hash + raw = self.read_persistent_cache(key) + if not raw: + return None + try: + return json.loads(raw) + except Exception: + return None + + def _save_org_cache(self, instance_hash, cache_payload): + key = self.ORG_CACHE_KEY_PREFIX + instance_hash + self.write_persistent_cache(key, json.dumps(cache_payload)) + + def _get_org_tags(self, raw_log, org_cache): + """Match a log record to an org in the cache; return list of tag strings.""" + if not org_cache: + return [] + + account_id = org_cache.get("account_id") + orgs = org_cache.get("orgs", {}) + + base_tags = [] + if account_id is not None: + base_tags.append(f"huntress_account_id:{account_id}") + + # Strategy 1: match by organization id (flat field from API, dot-notation, or nested) + org_id = ( + raw_log.get("organization_id") + or raw_log.get("organization.id") + or (raw_log.get("organization", {}).get("id") if isinstance(raw_log.get("organization"), dict) else None) + ) + if org_id is not None: + org = orgs.get(str(org_id)) + if org: + return base_tags + [ + f"huntress_org_id:{org_id}", + f"huntress_org_name:{org['name']}", + f"huntress_org_key:{org['key']}", + ] + + # Strategy 2: match by organization.name (reverse lookup) + org_name = raw_log.get("organization.name") or ( + raw_log.get("organization", {}).get("name") if isinstance(raw_log.get("organization"), dict) else None + ) + if org_name: + for oid, org in orgs.items(): + if org.get("name") == org_name: + return base_tags + [ + f"huntress_org_id:{oid}", + f"huntress_org_name:{org['name']}", + f"huntress_org_key:{org['key']}", + ] + + # Strategy 3: account-level tag only + return base_tags + + # ------------------------------------------------------------------ # + # Hashing # + # ------------------------------------------------------------------ # + + def _instance_hash(self, instance): + """Stable non-secret identifier for this Huntress instance.""" + key = instance.get("instance_id") or instance.get("huntress_base_url", self.DEFAULT_BASE_URL) + return format(zlib.crc32(key.encode("utf-8")) & 0xFFFFFFFF, "08x") + + def _query_hash(self, esql_query): + """Short non-cryptographic checksum for a per-query checkpoint key.""" + return format(zlib.crc32(esql_query.encode("utf-8")) & 0xFFFFFFFF, "08x") diff --git a/huntress/hatch.toml b/huntress/hatch.toml new file mode 100644 index 000000000..f62423b83 --- /dev/null +++ b/huntress/hatch.toml @@ -0,0 +1,7 @@ +[env.collectors.datadog-checks] + +[[envs.default.matrix]] +python = ["3.12"] + +[envs.default.env-vars] +DDEV_SKIP_GENERIC_TAGS_CHECK = "true" diff --git a/huntress/huntress-logo.png b/huntress/huntress-logo.png new file mode 100644 index 000000000..14d2fa308 Binary files /dev/null and b/huntress/huntress-logo.png differ diff --git a/huntress/manifest.json b/huntress/manifest.json new file mode 100644 index 000000000..3cbb3a695 --- /dev/null +++ b/huntress/manifest.json @@ -0,0 +1,71 @@ +{ + "manifest_version": "2.0.0", + "app_uuid": "a28b1dfe-7e4d-48d6-bc6f-548a2edd5d3f", + "app_id": "huntress", + "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": "Collect and forward Huntress SIEM logs to Datadog for security monitoring.", + "title": "Huntress", + "media": [], + "resources": [ + { + "resource_type": "documentation", + "url": "https://api.huntress.io/docs" + } + ], + "classifier_tags": [ + "Supported OS::Linux", + "Supported OS::macOS", + "Supported OS::Windows", + "Category::Log Collection", + "Category::Security", + "Category::SIEM", + "Offering::Integration", + "Submitted Data Type::Logs", + "Submitted Data Type::Metrics" + ] + }, + "author": { + "support_email": "help@datadoghq.com", + "homepage": "https://www.datadoghq.com", + "sales_email": "info@datadoghq.com", + "name": "Datadog" + }, + "assets": { + "integration": { + "source_type_name": "Huntress", + "configuration": { + "spec": "assets/configuration/spec.yaml" + }, + "events": { + "creates_events": false + }, + "metrics": { + "prefix": "huntress.", + "check": "huntress.siem.logs_collected", + "metadata_path": "metadata.csv" + }, + "service_checks": { + "metadata_path": "assets/service_checks.json" + }, + "source_type_id": 10450, + "auto_install": true + }, + "dashboards": { + "Huntress SIEM Overview": "assets/dashboards/huntress_siem_overview.json" + }, + "monitors": { + "Huntress SIEM collection run failed": "assets/monitors/huntress_siem_check_failed.json", + "Huntress SIEM collection errors detected": "assets/monitors/huntress_siem_errors_high.json", + "Huntress SIEM no logs collected": "assets/monitors/huntress_siem_no_logs_collected.json" + }, + "logs": { + "source": "huntress" + } + } +} \ No newline at end of file diff --git a/huntress/metadata.csv b/huntress/metadata.csv new file mode 100644 index 000000000..cdd5644bc --- /dev/null +++ b/huntress/metadata.csv @@ -0,0 +1,12 @@ +metric_name,metric_type,interval,unit_name,per_unit_name,description,orientation,integration,short_name,curated_metric +huntress.siem.logs_collected,gauge,,,,Number of SIEM log events collected from Huntress in the last run.,0,huntress,siem logs collected, +huntress.siem.pages_fetched,gauge,,,,Number of API result pages fetched from Huntress in the last run.,0,huntress,siem pages fetched, +huntress.siem.run_duration_seconds,gauge,,second,,Wall time of the Huntress SIEM collection run.,0,huntress,siem run duration, +huntress.siem.errors,count,,,,Number of errors encountered during the Huntress SIEM collection run.,0,huntress,siem errors, +huntress.siem.api_call_limit,gauge,,,,Huntress API rate limit (requests per minute) as reported by the x-huntress-api-call-limit response header.,0,huntress,api call limit, +huntress.siem.api_call_remaining,gauge,,,,Remaining Huntress API requests in the current rate limit window as reported by the x-huntress-api-call-remaining response header.,0,huntress,api call remaining, +huntress.agents.total,gauge,,,,Total number of Huntress agents registered in the account.,0,huntress,agents total, +huntress.agents.count,gauge,,,,Number of Huntress agents broken down by platform tag (windows/darwin/linux).,0,huntress,agents count, +huntress.agents.defender_status,gauge,,,,Number of Huntress agents by Defender AV status tagged as defender_status.,0,huntress,agents defender status, +huntress.agents.firewall_status,gauge,,,,Number of Huntress agents by firewall status tagged as firewall_status.,0,huntress,agents firewall status, +huntress.agents.pages_fetched,gauge,,,,Number of API pages fetched to retrieve the full agent list in the last run.,0,huntress,agents pages fetched, diff --git a/huntress/pyproject.toml b/huntress/pyproject.toml new file mode 100644 index 000000000..8723412ce --- /dev/null +++ b/huntress/pyproject.toml @@ -0,0 +1,65 @@ +[build-system] +requires = [ + "hatchling>=0.13.0", +] +build-backend = "hatchling.build" + +[project] +name = "datadog-huntress" +description = "The Huntress SIEM integration for Datadog" +readme = "README.md" +license = "BSD-3-Clause" +requires-python = ">=3.12" +keywords = [ + "datadog", + "datadog agent", + "datadog check", + "huntress", + "siem", + "security", +] +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.12", + "Topic :: System :: Monitoring", +] +dependencies = [ + "datadog-checks-base>=37.11.0", +] +dynamic = [ + "version", +] + +[project.optional-dependencies] +deps = [] + +[project.urls] +Source = "https://github.com/DataDog/integrations-extras" + +[tool.hatch.version] +path = "datadog_checks/huntress/__about__.py" + +[tool.hatch.build.targets.sdist] +include = [ + "/datadog_checks", + "/tests", + "/manifest.json", +] + +[tool.pytest.ini_options] +addopts = "-m 'not integration'" + +[tool.hatch.build.targets.wheel] +include = [ + "/datadog_checks/huntress", +] +dev-mode-dirs = [ + ".", +] diff --git a/huntress/tests/__init__.py b/huntress/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/huntress/tests/conftest.py b/huntress/tests/conftest.py new file mode 100644 index 000000000..e8cc327f9 --- /dev/null +++ b/huntress/tests/conftest.py @@ -0,0 +1,49 @@ +import os + +import pytest + +try: + from datadog_checks.dev import docker_run, get_docker_hostname, get_here + + HERE = get_here() + HOST = get_docker_hostname() + MOCKOON_PORT = 3002 + HAS_DATADOG_DEV = True +except ImportError: + HAS_DATADOG_DEV = False + + +@pytest.fixture(scope='session') +def dd_environment(): + if not HAS_DATADOG_DEV: + yield + return + + compose_file = os.path.join(HERE, "docker", "docker-compose.yml") + with docker_run( + compose_file, + log_patterns=["Server started"], + ): + yield { + "huntress_api_key": "hk_testpublickey", + "huntress_secret_key": "hs_testsecretkey", + "log_queries": [{"name": "all-logs", "esql_query": "FROM logs"}], + "huntress_base_url": f"http://{HOST}:{MOCKOON_PORT}", + "enrich_with_org_tags": True, + "org_cache_ttl_seconds": 3600, + "min_collection_interval": 60, + "max_pages_per_run": 10, + "tags": ["source:huntress", "env:test"], + } + + +@pytest.fixture +def instance(): + return { + "huntress_api_key": "test_api_key", + "huntress_secret_key": "test_secret_key", + "log_queries": [{"name": "all-logs", "esql_query": "FROM logs"}], + "enrich_with_org_tags": False, + "min_collection_interval": 900, + "max_pages_per_run": 100, + } diff --git a/huntress/tests/data/huntress_mockoon.json b/huntress/tests/data/huntress_mockoon.json new file mode 100644 index 000000000..5d5d07a86 --- /dev/null +++ b/huntress/tests/data/huntress_mockoon.json @@ -0,0 +1,549 @@ +{ + "uuid": "d79d60a4-f853-41f1-b1bc-394540ba0418", + "lastMigration": 20, + "name": "Huntress API MOCK", + "endpointPrefix": "", + "latency": 0, + "port": 3002, + "hostname": "0.0.0.0", + "routes": [ + { + "uuid": "6cc05227-ed63-494f-a215-c06fa89640f0", + "documentation": "Account Endpoint", + "method": "get", + "endpoint": "v1/account", + "responses": [ + { + "uuid": "16142531-e3c2-4e50-9cf8-2f2bed17c0bc", + "body": "{\n \"account\": {\n \"id\": 1,\n \"name\": \"Test Account\",\n \"status\": \"active\"\n }\n}", + "latency": 0, + "statusCode": 200, + "label": "Account Endpoint", + "headers": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "x-huntress-api-call-limit", + "value": "60" + }, + { + "key": "x-huntress-api-call-remaining", + "value": "55" + } + ], + "filePath": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false, + "fallbackTo404": false, + "default": true + } + ], + "enabled": true, + "randomResponse": false, + "sequentialResponse": false + }, + { + "uuid": "fe19593d-a249-4077-ab07-b5d2239606d2", + "documentation": "Organizations for a specific account (paginated)", + "method": "get", + "endpoint": "v1/accounts/:id/organizations", + "responses": [ + { + "uuid": "bbd0b95c-7e9a-4446-bb7d-34b2827a05f9", + "body": "{\n \"organizations\": [\n {\n \"id\": 1,\n \"name\": \"Acme Inc.\",\n \"key\": \"acme\",\n \"account_id\": 1\n },\n {\n \"id\": 2,\n \"name\": \"Globex Corp\",\n \"key\": \"globex\",\n \"account_id\": 1\n },\n {\n \"id\": 3,\n \"name\": \"Initech LLC\",\n \"key\": \"initech\",\n \"account_id\": 1\n }\n ],\n \"pagination\": {\n \"next_page_token\": null\n }\n}", + "latency": 0, + "statusCode": 200, + "label": "Account Organizations", + "headers": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "x-huntress-api-call-limit", + "value": "60" + }, + { + "key": "x-huntress-api-call-remaining", + "value": "55" + } + ], + "filePath": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false, + "fallbackTo404": false, + "default": true + } + ], + "enabled": true, + "randomResponse": false, + "sequentialResponse": false + }, + { + "uuid": "95ad0b89-aabe-4e44-95e6-9ad78ca56f1d", + "documentation": "SIEM ES|QL Query Endpoint", + "method": "post", + "endpoint": "v1/siem/query", + "responses": [ + { + "uuid": "d3c533fb-4595-4a19-bebf-1ff2a2daf6ce", + "body": "{\n \"logs\": [\n {\n \"@timestamp\": \"2026-05-27T14:00:00.000Z\",\n \"message\": \"An account was successfully logged on.\",\n \"log.original\": \"An account was successfully logged on.\",\n \"event.provider\": \"Microsoft-Windows-Security-Auditing\",\n \"event.category\": \"authentication\",\n \"event.outcome\": \"success\",\n \"event.action\": \"logged-on\",\n \"host.hostname\": \"DESKTOP-ABC123\",\n \"host.os.platform\": \"windows\",\n \"user.name\": \"alice\",\n \"organization.id\": 1,\n \"organization.name\": \"Acme Inc.\"\n },\n {\n \"@timestamp\": \"2026-05-27T14:01:30.000Z\",\n \"message\": \"A logon was attempted using explicit credentials.\",\n \"log.original\": \"A logon was attempted using explicit credentials.\",\n \"event.provider\": \"Microsoft-Windows-Security-Auditing\",\n \"event.category\": \"authentication\",\n \"event.outcome\": \"unknown\",\n \"event.action\": \"logged-on\",\n \"host.hostname\": \"SERVER-XYZ789\",\n \"host.os.platform\": \"windows\",\n \"user.name\": \"bob\",\n \"organization.id\": 2,\n \"organization.name\": \"Globex Corp\"\n },\n {\n \"@timestamp\": \"2026-05-27T14:02:00.000Z\",\n \"message\": \"An attempt was made to reset an account's password.\",\n \"log.original\": \"An attempt was made to reset an account's password.\",\n \"event.provider\": \"Microsoft-Windows-Security-Auditing\",\n \"event.category\": \"iam\",\n \"event.outcome\": \"success\",\n \"event.action\": \"user-password-reset\",\n \"host.hostname\": \"DC-PRIMARY\",\n \"host.os.platform\": \"windows\",\n \"user.name\": \"charlie\",\n \"organization.id\": 3,\n \"organization.name\": \"Initech LLC\"\n }\n ],\n \"pagination\": {\n \"next_page_token\": null\n }\n}", + "latency": 0, + "statusCode": 200, + "label": "SIEM Query Response", + "headers": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "x-huntress-api-call-limit", + "value": "60" + }, + { + "key": "x-huntress-api-call-remaining", + "value": "55" + } + ], + "filePath": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": true, + "fallbackTo404": false, + "default": true + } + ], + "enabled": true, + "randomResponse": false, + "sequentialResponse": false + }, + { + "uuid": "b3bea0dc-39a2-442d-81de-a11f918c6cd2", + "documentation": "Organizations Endpoint", + "method": "get", + "endpoint": "v1/organizations", + "responses": [ + { + "uuid": "ad456172-d25c-4799-b1d7-245c88fa1879", + "body": "{\n \"organizations\": [\n {{#repeat 9}}\n {\n \"id\": {{int 1 9}},\n \"name\": \"{{company}}\",\n \"created_at\": \"{{date '2022-05-01' '2022-05-30' \"yyyy-MM-dd'T'HH:mm:ss.SSS'Z'\"}}\",\n \"updated_at\": \"{{date '2022-06-01' '2022-06-30' \"yyyy-MM-dd'T'HH:mm:ss.SSS'Z'\"}}\",\n \"account_id\": 1,\n \"key\": \"{{faker 'internet.domainWord'}}\",\n \"notify_emails\": [{{email}}, {{email}}],\n \"incident_reports_count\": {{int 0 8}}\n },\n {{/repeat}}\n {\n \"id\": 10,\n \"name\": \"{{company}}\",\n \"created_at\": \"{{date '2022-05-01' '2022-05-30' \"yyyy-MM-dd'T'HH:mm:ss.SSS'Z'\"}}\",\n \"updated_at\": \"{{date '2022-06-01' '2022-06-30' \"yyyy-MM-dd'T'HH:mm:ss.SSS'Z'\"}}\",\n \"account_id\": 1,\n \"key\": \"{{faker 'word.adjective'}}\",\n \"notify_emails\": [{{email}}, {{email}}],\n \"incident_reports_count\": {{int 0 8}}\n }\n ],\n \"pagination\": {\n \"current_page\": 1,\n \"current_page_count\": 1,\n \"limit\": 10,\n \"total_count\": 20,\n \"next_page\": 2,\n \"next_page_url\": \"https://api.huntress.io/v1/organizations?page=2&limit=10\"\n }\n}", + "latency": 0, + "statusCode": 200, + "label": "Organizations Endpoint", + "headers": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "x-huntress-api-call-limit", + "value": "60" + }, + { + "key": "x-huntress-api-call-remaining", + "value": "55" + } + ], + "filePath": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false, + "fallbackTo404": false, + "default": true + } + ], + "enabled": true, + "randomResponse": false, + "sequentialResponse": false + }, + { + "uuid": "a221cee0-b6ab-4b43-afa8-313188214d38", + "documentation": "Returns data for a specific organization.", + "method": "get", + "endpoint": "v1/organizations/:id", + "responses": [ + { + "uuid": "51e9cab3-7780-4ce2-bfb7-245bf8d8daa7", + "body": "{\n \"organization\": {\n \"id\": {{int 1 10}},\n \"name\": \"{{company}}\",\n \"created_at\": \"{{date '2022-05-01' '2022-05-30' \"yyyy-MM-dd'T'HH:mm:ss.SSS'Z'\"}}\",\n \"updated_at\": \"{{date '2022-06-01' '2022-06-30' \"yyyy-MM-dd'T'HH:mm:ss.SSS'Z'\"}}\",\n \"account_id\": 1,\n \"key\": \"{{faker 'internet.domainWord'}}\",\n \"notify_emails\": [{{email}}, {{email}}],\n \"incident_reports_count\": {{int 0 8}}\n }\n}", + "latency": 0, + "statusCode": 200, + "label": "Returns data for a specific organization.", + "headers": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "x-huntress-api-call-limit", + "value": "60" + }, + { + "key": "x-huntress-api-call-remaining", + "value": "55" + } + ], + "filePath": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false, + "fallbackTo404": false, + "default": true + } + ], + "enabled": true, + "randomResponse": false, + "sequentialResponse": false + }, + { + "uuid": "05f22b2a-81f5-46d1-ac8a-7a171472c216", + "documentation": "Agents Endpoint", + "method": "get", + "endpoint": "v1/agents", + "responses": [ + { + "uuid": "37418ddf-bd35-404d-beea-8add484c2098", + "body": "{\n \"agents\": [\n {{#repeat 9}}\n {\n \"id\": {{int 1 9}},\n \"version\": \"{{oneOf (array '0.13.12' '0.13.10' '0.13.8')}}\",\n \"arch\": \"x86_64\",\n \"win_build_number\": {{oneOf (array '19043' '19044' '22000' '17763' '20348')}},\n \"domain_name\": \"{{faker 'internet.domainName'}}\",\n \"created_at\": \"{{date '2022-05-01' '2022-05-30' \"yyyy-MM-dd'T'HH:mm:ss.SSS'Z'\"}}\",\n \"hostname\": \"{{oneOf (array 'Laptop' 'Workstation' 'Desktop' 'Server')}}-{{faker 'word.adjective'}}\",\n \"ipv4_address\": \"{{ipv4}}\",\n \"external_ip\": \"{{ipv4}}\",\n \"mac_addresses\": [\"{{faker 'internet.mac'}}\", \"{{faker 'internet.mac'}}\"],\n \"updated_at\": \"{{date '2022-06-01' '2022-06-30' \"yyyy-MM-dd'T'HH:mm:ss.SSS'Z'\"}}\",\n \"last_survey_at\": \"{{faker 'date.recent'}}\",\n \"last_callback_at\": \"{{faker 'date.recent'}}\",\n \"account_id\": 1,\n \"organization_id\": {{int 1 10}},\n \"os\": \"{{oneOf (array 'Windows 10 Pro' 'Windows 11 Pro' 'Windows Server 2019' 'Windows Server 2022')}}\",\n \"service_pack_major\": 0,\n \"service_pack_minor\": 0,\n \"tags\": {{{someOf (array 'SQL' 'VIP' 'Critical' 'Excluded' 'temp') 1 2 true}}},\n \"os_major\": 10,\n \"os_minor\": 0,\n \"version_number\": null\n },\n {{/repeat}}\n {\n \"id\": 10,\n \"version\": \"{{oneOf (array '0.13.12' '0.13.10' '0.13.8')}}\",\n \"arch\": \"x86_64\",\n \"win_build_number\": {{oneOf (array '19043' '19044' '22000' '17763' '20348')}},\n \"domain_name\": \"{{faker 'internet.domainName'}}\",\n \"created_at\": \"{{date '2022-05-01' '2022-05-30' \"yyyy-MM-dd'T'HH:mm:ss.SSS'Z'\"}}\",\n \"hostname\": \"{{oneOf (array 'Laptop' 'Workstation' 'Desktop' 'Server')}}-{{faker 'word.adjective'}}\",\n \"ipv4_address\": \"{{ipv4}}\",\n \"external_ip\": \"{{ipv4}}\",\n \"mac_addresses\": [\"{{faker 'internet.mac'}}\", \"{{faker 'internet.mac'}}\"],\n \"updated_at\": \"{{date '2022-06-01' '2022-06-30' \"yyyy-MM-dd'T'HH:mm:ss.SSS'Z'\"}}\",\n \"last_survey_at\": \"{{faker 'date.recent'}}\",\n \"last_callback_at\": \"{{faker 'date.recent'}}\",\n \"account_id\": {{int 1 4}},\n \"organization_id\": {{int 1 20}},\n \"os\": \"{{oneOf (array 'Windows 10 Pro' 'Windows 11 Pro' 'Windows Server 2019' 'Windows Server 2022')}}\",\n \"service_pack_major\": 0,\n \"service_pack_minor\": 0,\n \"tags\": {{{someOf (array 'SQL' 'VIP' 'Critical' 'Excluded' 'temp') 1 2 true}}},\n \"os_major\": 10,\n \"os_minor\": 0,\n \"version_number\": null\n }\n ],\n \"pagination\": {\n \"pagination\": {\n \"current_page\": 1,\n \"current_page_count\": 1,\n \"limit\": 10,\n \"total_count\": 20,\n \"next_page\": 2,\n \"next_page_url\": \"https://api.huntress.io/v1/agents?page=2&limit=10\"\n }\n}", + "latency": 0, + "statusCode": 200, + "label": "Agents Endpoint", + "headers": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "x-huntress-api-call-limit", + "value": "60" + }, + { + "key": "x-huntress-api-call-remaining", + "value": "55" + } + ], + "filePath": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false, + "fallbackTo404": false, + "default": true + } + ], + "enabled": true, + "randomResponse": false, + "sequentialResponse": false + }, + { + "uuid": "296e1e0f-19c0-4b99-9538-587670fdad66", + "documentation": "Returns data for a specific agent.", + "method": "get", + "endpoint": "v1/agents/:id", + "responses": [ + { + "uuid": "1e05ac6c-75f1-4bd8-861e-aa44910cf780", + "body": "{\n \"agent\": {\n \"id\": {{int 1 20}},\n \"version\": \"{{oneOf (array '0.13.12' '0.13.10' '0.13.8')}}\",\n \"arch\": \"x86_64\",\n \"win_build_number\": {{oneOf (array '19043' '19044' '22000' '17763' '20348')}},\n \"domain_name\": \"{{faker 'internet.domainName'}}\",\n \"created_at\": \"{{date '2022-05-01' '2022-05-30' \"yyyy-MM-dd'T'HH:mm:ss.SSS'Z'\"}}\",\n \"hostname\": \"{{oneOf (array 'Laptop' 'Workstation' 'Desktop' 'Server')}}-{{faker 'word.adjective'}}\",\n \"ipv4_address\": \"{{ipv4}}\",\n \"external_ip\": \"{{ipv4}}\",\n \"mac_addresses\": [\"{{faker 'internet.mac'}}\", \"{{faker 'internet.mac'}}\"],\n \"updated_at\": \"{{date '2022-06-01' '2022-06-30' \"yyyy-MM-dd'T'HH:mm:ss.SSS'Z'\"}}\",\n \"last_survey_at\": \"{{faker 'date.recent'}}\",\n \"last_callback_at\": \"{{faker 'date.recent'}}\",\n \"account_id\": 1,\n \"organization_id\": {{int 1 9}},\n \"os\": \"{{oneOf (array 'Windows 10 Pro' 'Windows 11 Pro' 'Windows Server 2019' 'Windows Server 2022')}}\",\n \"service_pack_major\": 0,\n \"service_pack_minor\": 0,\n \"tags\": {{{someOf (array 'SQL' 'VIP' 'Critical' 'Excluded' 'temp') 1 2 true}}},\n \"os_major\": 10,\n \"os_minor\": 0,\n \"version_number\": null\n }\n}", + "latency": 0, + "statusCode": 200, + "label": "Returns data for a specific agent.", + "headers": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "x-huntress-api-call-limit", + "value": "60" + }, + { + "key": "x-huntress-api-call-remaining", + "value": "55" + } + ], + "filePath": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false, + "fallbackTo404": false, + "default": true + } + ], + "enabled": true, + "randomResponse": false, + "sequentialResponse": false + }, + { + "uuid": "3e117dbc-fed1-457d-8eae-22b8885b407e", + "documentation": "Incident Reports Endpoint", + "method": "get", + "endpoint": "v1/incident_reports", + "responses": [ + { + "uuid": "aa51588a-abbc-477b-8c16-f453c12d7f12", + "body": "{\n \"incident_reports\": [\n {{#repeat 9}}\n {\n \"id\": {{int 1 100}},\n \"status\": \"{{oneOf (array 'sent' 'closed' 'dismissed')}}\",\n \"summary\": null,\n \"body\": \"\",\n \"updated_at\": \"{{date '2022-06-01' '2022-06-30' \"yyyy-MM-dd'T'HH:mm:ss.SSS'Z'\"}}\",\n \"agent_id\": {{int 1 20}},\n \"status_updated_at\": \"{{date '2022-06-01' '2022-06-30' \"yyyy-MM-dd'T'HH:mm:ss.SSS'Z'\"}}\",\n \"organization_id\": {{int 1 10}},\n \"sent_at\": \"{{date '2022-05-28' '2022-05-30' \"yyyy-MM-dd'T'HH:mm:ss.SSS'Z'\"}}\",\n \"account_id\": 1,\n \"subject\": \"{{oneOf (array 'LOW' 'HIGH' 'CRITICAL')}} - Incident on {{oneOf (array 'Laptop' 'Workstation' 'Desktop' 'Server')}}-{{faker 'word.adjective'}}\",\n \"created_by_id\": null,\n \"remediation_instructions\": null,\n \"footholds\": \"\",\n \"notes\": \"\",\n \"severity\": \"{{oneOf (array 'low' 'high' 'critical')}}\",\n \"assigned_to_id\": null,\n \"closed_at\": null,\n \"indicator_types\": {{{someOf (array 'footholds' 'monitored_files' 'process_detections' 'ransomware_canaries' 'antivirus_detections') 1 4 true}}},\n \"indicator_counts\": {\n \"footholds\": {{int 0 2}},\n \"monitored_files\": {{int 0 2}},\n \"process_detections\": {{int 0 2}},\n \"ransomware_canaries\": {{int 0 2}},\n \"antivirus_detections\": {{int 0 2}}\n },\n \"details\": {}\n },\n {{/repeat}}\n {\n \"id\": 10,\n \"status\": \"{{oneOf (array 'sent' 'closed' 'dismissed')}}\",\n \"summary\": null,\n \"body\": \"\",\n \"updated_at\": \"{{date '2022-06-01' '2022-06-30' \"yyyy-MM-dd'T'HH:mm:ss.SSS'Z'\"}}\",\n \"agent_id\": {{int 1 20}},\n \"status_updated_at\": \"{{date '2022-06-01' '2022-06-30' \"yyyy-MM-dd'T'HH:mm:ss.SSS'Z'\"}}\",\n \"organization_id\": {{int 1 10}},\n \"sent_at\": \"{{date '2022-05-28' '2022-05-30' \"yyyy-MM-dd'T'HH:mm:ss.SSS'Z'\"}}\",\n \"account_id\": 1,\n \"subject\": \"{{oneOf (array 'LOW' 'HIGH' 'CRITICAL')}} - Incident on {{oneOf (array 'Laptop' 'Workstation' 'Desktop' 'Server')}}-{{faker 'word.adjective'}}\",\n \"created_by_id\": null,\n \"remediation_instructions\": null,\n \"footholds\": \"\",\n \"notes\": \"\",\n \"severity\": \"{{oneOf (array 'low' 'high' 'critical')}}\",\n \"assigned_to_id\": null,\n \"closed_at\": null,\n \"indicator_types\": {{{someOf (array 'footholds' 'monitored_files' 'process_detections' 'ransomware_canaries' 'antivirus_detections') 1 4 true}}},\n \"indicator_counts\": {\n \"footholds\": {{int 0 2}},\n \"monitored_files\": {{int 0 2}},\n \"process_detections\": {{int 0 2}},\n \"ransomware_canaries\": {{int 0 2}},\n \"antivirus_detections\": {{int 0 2}}\n },\n \"details\": {}\n }\n ],\n \"pagination\": {\n \"pagination\": {\n \"current_page\": 1,\n \"current_page_count\": 1,\n \"limit\": 10,\n \"total_count\": 20,\n \"next_page\": 2,\n \"next_page_url\": \"https://api.huntress.io/v1/incident_reports?page=2&limit=10\"\n }\n}", + "latency": 0, + "statusCode": 200, + "label": "Incident Reports Endpoint", + "headers": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "x-huntress-api-call-limit", + "value": "60" + }, + { + "key": "x-huntress-api-call-remaining", + "value": "55" + } + ], + "filePath": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false, + "fallbackTo404": false, + "default": true + } + ], + "enabled": true, + "randomResponse": false, + "sequentialResponse": false + }, + { + "uuid": "7898545a-cd88-4311-9caf-e06d286b0550", + "documentation": "Returns data for a specific incident report.", + "method": "get", + "endpoint": "v1/incident_reports/:id", + "responses": [ + { + "uuid": "253432c2-3a0e-4907-b23c-116a59c79295", + "body": "{\n \"incident_report\": {\n \"id\": {{int 1 100}},\n \"status\": \"{{oneOf (array 'sent' 'closed' 'dismissed')}}\",\n \"summary\": null,\n \"body\": \"\",\n \"updated_at\": \"{{date '2022-06-01' '2022-06-30' \"yyyy-MM-dd'T'HH:mm:ss.SSS'Z'\"}}\",\n \"agent_id\": {{int 1 20}},\n \"status_updated_at\": \"{{date '2022-06-01' '2022-06-30' \"yyyy-MM-dd'T'HH:mm:ss.SSS'Z'\"}}\",\n \"organization_id\": {{int 1 10}},\n \"sent_at\": \"{{date '2022-05-28' '2022-05-30' \"yyyy-MM-dd'T'HH:mm:ss.SSS'Z'\"}}\",\n \"account_id\": 1,\n \"subject\": \"{{oneOf (array 'LOW' 'HIGH' 'CRITICAL')}} - Incident on {{oneOf (array 'Laptop' 'Workstation' 'Desktop' 'Server')}}-{{faker 'word.adjective'}}\",\n \"created_by_id\": null,\n \"remediation_instructions\": null,\n \"footholds\": \"\",\n \"notes\": \"\",\n \"severity\": \"{{oneOf (array 'low' 'high' 'critical')}}\",\n \"assigned_to_id\": null,\n \"closed_at\": null,\n \"{{oneOf (array null 'low' 'high' 'critical')}}\",\n \"indicator_types\": {{{someOf (array 'footholds' 'monitored_files' 'process_detections' 'ransomware_canaries' 'antivirus_detections') 1 4 true}}},\n \"indicator_counts\": {\n \"footholds\": {{int 0 2}},\n \"monitored_files\": {{int 0 2}},\n \"process_detections\": {{int 0 2}},\n \"ransomware_canaries\": {{int 0 2}},\n \"antivirus_detections\": {{int 0 2}}\n },\n \"details\": {}\n }\n}", + "latency": 0, + "statusCode": 200, + "label": "Returns data for a specific incident report.", + "headers": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "x-huntress-api-call-limit", + "value": "60" + }, + { + "key": "x-huntress-api-call-remaining", + "value": "55" + } + ], + "filePath": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false, + "fallbackTo404": false, + "default": true + } + ], + "enabled": true, + "randomResponse": false, + "sequentialResponse": false + }, + { + "uuid": "100d3f08-49ea-42b9-87ca-580ae07a2177", + "documentation": "Reports Endpoint", + "method": "get", + "endpoint": "v1/reports", + "responses": [ + { + "uuid": "64b3e476-7302-45ff-8098-360548da5394", + "body": "{\n \"reports\": [\n {{#repeat 9}}\n {\n \"id\": {{int 1 100}},\n \"type\": \"{{oneOf (array 'monthly_summary' 'quarterly_summary' 'yearly_summary')}}\",\n \"period\": \"{{date '2022-05-28' '2022-05-30' \"yyyy-MM-dd\"}}...{{date '2022-06-28' '2022-06-30' \"yyyy-MM-dd\"}}\",\n \"organization_id\": {{int 1 10}},\n \"created_at\": \"{{date '2022-05-27' '2022-05-28' \"yyyy-MM-dd'T'HH:mm:ss.SSS'Z'\"}}\",\n \"updated_at\": \"{{date '2022-07-01' '2022-07-03' \"yyyy-MM-dd'T'HH:mm:ss.SSS'Z'\"}}\",\n \"url\": \"https://huntress.io/rails/active_storage/blobs/redirect/{{guid}}.pdf?disposition=download\"\n },\n {{/repeat}}\n {\n \"id\": 10,\n \"type\": \"{{oneOf (array 'monthly_summary' 'quarterly_summary' 'yearly_summary')}}\",\n \"period\": \"{{date '2022-05-28' '2022-05-30' \"yyyy-MM-dd\"}}...{{date '2022-06-28' '2022-06-30' \"yyyy-MM-dd\"}}\",\n \"organization_id\": {{int 1 10}},\n \"created_at\": \"{{date '2022-05-27' '2022-05-28' \"yyyy-MM-dd'T'HH:mm:ss.SSS'Z'\"}}\",\n \"updated_at\": \"{{date '2022-07-01' '2022-07-03' \"yyyy-MM-dd'T'HH:mm:ss.SSS'Z'\"}}\",\n \"url\": \"https://huntress.io/rails/active_storage/blobs/redirect/{{guid}}.pdf?disposition=download\"\n }\n ],\n \"pagination\": {\n \"pagination\": {\n \"current_page\": 1,\n \"current_page_count\": 1,\n \"limit\": 10,\n \"total_count\": 20,\n \"next_page\": 2,\n \"next_page_url\": \"https://api.huntress.io/v1/reports?page=2&limit=10\"\n }\n}", + "latency": 0, + "statusCode": 200, + "label": "Reports Endpoint", + "headers": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "x-huntress-api-call-limit", + "value": "60" + }, + { + "key": "x-huntress-api-call-remaining", + "value": "55" + } + ], + "filePath": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false, + "fallbackTo404": false, + "default": true + } + ], + "enabled": true, + "randomResponse": false, + "sequentialResponse": false + }, + { + "uuid": "5e000c10-c3c3-4d34-ace9-4bfea1a3fa5c", + "documentation": "Returns data for a specific report.", + "method": "get", + "endpoint": "v1/reports/:id", + "responses": [ + { + "uuid": "5f7f7dca-ae67-4c06-812e-4db991c26d23", + "body": "{\n \"report\": {\n \"id\": {{int 1 100}},\n \"type\": \"{{oneOf (array 'monthly_summary' 'quarterly_summary' 'yearly_summary')}}\",\n \"period\": \"{{date '2022-05-28' '2022-05-30' \"yyyy-MM-dd\"}}...{{date '2022-06-28' '2022-06-30' \"yyyy-MM-dd\"}}\",\n \"organization_id\": {{int 1 10}},\n \"created_at\": \"{{date '2022-05-27' '2022-05-28' \"yyyy-MM-dd'T'HH:mm:ss.SSS'Z'\"}}\",\n \"updated_at\": \"{{date '2022-07-01' '2022-07-03' \"yyyy-MM-dd'T'HH:mm:ss.SSS'Z'\"}}\",\n \"url\": \"https://huntress.io/rails/active_storage/blobs/redirect/{{guid}}.pdf?disposition=download\"\n }\n}", + "latency": 0, + "statusCode": 200, + "label": "Returns data for a specific report.", + "headers": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "x-huntress-api-call-limit", + "value": "60" + }, + { + "key": "x-huntress-api-call-remaining", + "value": "55" + } + ], + "filePath": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false, + "fallbackTo404": false, + "default": true + } + ], + "enabled": true, + "randomResponse": false, + "sequentialResponse": false + }, + { + "uuid": "05614def-07e9-49b3-aaa9-5f7eff45274e", + "documentation": "Billing Reports Endpoint", + "method": "get", + "endpoint": "v1/billing_reports", + "responses": [ + { + "uuid": "4cf2997a-64eb-4ac2-a2a0-a38149a1dc5a", + "body": "{\n \"billing_reports\": [\n {{#repeat 9}}\n {\n \"id\": {{int 1 9}},\n \"plan\": \"{{oneOf (array 'Huntress Partner Pay As You Go' 'Huntress Partner 100 Agents' 'Huntress Partner 200 Agents' 'Huntress Partner 300 Agents')}}\",\n \"quantity\": {{int 50 400}},\n \"amount\": {{int 150 900}},\n \"currency_type\": \"{{oneOf (array 'USD' 'AUD' 'CAD')}}\",\n \"receipt\": \"https://pay.stripe/com/invoice/invst_{{guid}}\",\n \"status\": \"{{oneOf (array 'open' 'paid' 'failed' 'partial_refund' 'full_refund')}}\",\n \"created_at\": \"{{date '2022-05-27' '2022-05-28' \"yyyy-MM-dd'T'HH:mm:ss.SSS'Z'\"}}\",\n \"updated_at\": \"{{date '2022-05-29' '2022-05-30' \"yyyy-MM-dd'T'HH:mm:ss.SSS'Z'\"}}\"\n },\n {{/repeat}}\n {\n \"id\": 10,\n \"plan\": \"{{oneOf (array 'Huntress Partner Pay As You Go' 'Huntress Partner 100 Agents' 'Huntress Partner 200 Agents' 'Huntress Partner 300 Agents')}}\",\n \"quantity\": {{int 50 400}},\n \"amount\": {{int 150 900}},\n \"currency_type\": \"{{oneOf (array 'USD' 'AUD' 'CAD')}}\",\n \"receipt\": \"https://pay.stripe/com/invoice/invst_{{guid}}\",\n \"status\": \"{{oneOf (array 'open' 'paid' 'failed' 'partial_refund' 'full_refund')}}\",\n \"created_at\": \"{{date '2022-05-27' '2022-05-28' \"yyyy-MM-dd'T'HH:mm:ss.SSS'Z'\"}}\",\n \"updated_at\": \"{{date '2022-05-29' '2022-05-30' \"yyyy-MM-dd'T'HH:mm:ss.SSS'Z'\"}}\"\n }\n ],\n \"pagination\": {\n \"pagination\": {\n \"current_page\": 1,\n \"current_page_count\": 1,\n \"limit\": 10,\n \"total_count\": 20,\n \"next_page\": 2,\n \"next_page_url\": \"https://api.huntress.io/v1/billing_reports?page=2&limit=10\"\n }\n}", + "latency": 0, + "statusCode": 200, + "label": "Billing Reports Endpoint", + "headers": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "x-huntress-api-call-limit", + "value": "60" + }, + { + "key": "x-huntress-api-call-remaining", + "value": "55" + } + ], + "filePath": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false, + "fallbackTo404": false, + "default": true + } + ], + "enabled": true, + "randomResponse": false, + "sequentialResponse": false + }, + { + "uuid": "72142a57-71d5-4a8a-bb17-179c00da4248", + "documentation": "Returns data for a specific billing report.", + "method": "get", + "endpoint": "v1/billing_reports/:id", + "responses": [ + { + "uuid": "2d1c37f2-f441-44a4-a560-f51e1b25baff", + "body": "{\n \"billing_report\": {\n \"id\": {{int 1 9}},\n \"plan\": \"{{oneOf (array 'Huntress Partner Pay As You Go' 'Huntress Partner 100 Agents' 'Huntress Partner 200 Agents' 'Huntress Partner 300 Agents')}}\",\n \"quantity\": {{int 50 400}},\n \"amount\": {{int 150 900}},\n \"currency_type\": \"{{oneOf (array 'USD' 'AUD' 'CAD')}}\",\n \"receipt\": \"https://pay.stripe/com/invoice/invst_{{guid}}\",\n \"status\": \"{{oneOf (array 'open' 'paid' 'failed' 'partial_refund' 'full_refund')}}\",\n \"created_at\": \"{{date '2022-05-27' '2022-05-28' \"yyyy-MM-dd'T'HH:mm:ss.SSS'Z'\"}}\",\n \"updated_at\": \"{{date '2022-05-29' '2022-05-30' \"yyyy-MM-dd'T'HH:mm:ss.SSS'Z'\"}}\"\n }\n}\n", + "latency": 0, + "statusCode": 200, + "label": "Returns data for a specific billing report.", + "headers": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "x-huntress-api-call-limit", + "value": "60" + }, + { + "key": "x-huntress-api-call-remaining", + "value": "55" + } + ], + "filePath": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false, + "fallbackTo404": false, + "default": true + } + ], + "enabled": true, + "randomResponse": false, + "sequentialResponse": false + } + ], + "proxyMode": false, + "proxyHost": "", + "proxyRemovePrefix": false, + "tlsOptions": { + "enabled": false, + "type": "CERT", + "pfxPath": "", + "certPath": "", + "keyPath": "", + "caPath": "", + "passphrase": "" + }, + "cors": true, + "headers": [ + { + "key": "Authorization", + "value": "Basic {{base64 'hk_1kjlkn112lknlk2222lk:hs_4343fffdjvnekjve44444fdvdfvdf222frrf4'}}" + } + ], + "proxyReqHeaders": [ + { + "key": "", + "value": "" + } + ], + "proxyResHeaders": [ + { + "key": "", + "value": "" + } + ] +} \ No newline at end of file diff --git a/huntress/tests/docker/Dockerfile b/huntress/tests/docker/Dockerfile new file mode 100644 index 000000000..d141da6a6 --- /dev/null +++ b/huntress/tests/docker/Dockerfile @@ -0,0 +1,5 @@ +FROM mockoon/cli:latest + +EXPOSE 3002 + +ENTRYPOINT ["mockoon-cli", "start", "--data", "/data/huntress_mockoon.json", "--port", "3002", "--hostname", "0.0.0.0"] diff --git a/huntress/tests/docker/docker-compose.yml b/huntress/tests/docker/docker-compose.yml new file mode 100644 index 000000000..d790e3012 --- /dev/null +++ b/huntress/tests/docker/docker-compose.yml @@ -0,0 +1,10 @@ +services: + mockoon: + build: + context: ../.. + dockerfile: tests/docker/Dockerfile + container_name: dd-test-huntress-mockoon + volumes: + - ../data/huntress_mockoon.json:/data/huntress_mockoon.json:ro + ports: + - "3002:3002" diff --git a/huntress/tests/fixtures/siem_query_empty.json b/huntress/tests/fixtures/siem_query_empty.json new file mode 100644 index 000000000..e190ca924 --- /dev/null +++ b/huntress/tests/fixtures/siem_query_empty.json @@ -0,0 +1,4 @@ +{ + "logs": [], + "pagination": {} +} \ No newline at end of file diff --git a/huntress/tests/fixtures/siem_query_page1.json b/huntress/tests/fixtures/siem_query_page1.json new file mode 100644 index 000000000..90e2dd2b6 --- /dev/null +++ b/huntress/tests/fixtures/siem_query_page1.json @@ -0,0 +1,29 @@ +{ + "logs": [ + { + "@timestamp": "2026-05-27T14:00:00.000Z", + "message": "An account was successfully logged on.", + "log.original": "An account was successfully logged on.", + "event.provider": "Microsoft-Windows-Security-Auditing", + "event.category": "authentication", + "event.outcome": "success", + "host.hostname": "DESKTOP-ABC123", + "organization.id": 101, + "organization.name": "Acme Inc." + }, + { + "@timestamp": "2026-05-27T14:01:00.000Z", + "message": "A logon was attempted using explicit credentials.", + "log.original": "A logon was attempted using explicit credentials.", + "event.provider": "Microsoft-Windows-Security-Auditing", + "event.category": "authentication", + "event.outcome": "unknown", + "host.hostname": "SERVER-XYZ", + "organization.id": 102, + "organization.name": "Globex Corp" + } + ], + "pagination": { + "next_page_token": "page2_token_abc123" + } +} \ No newline at end of file diff --git a/huntress/tests/fixtures/siem_query_page2.json b/huntress/tests/fixtures/siem_query_page2.json new file mode 100644 index 000000000..0349317c5 --- /dev/null +++ b/huntress/tests/fixtures/siem_query_page2.json @@ -0,0 +1,16 @@ +{ + "logs": [ + { + "@timestamp": "2026-05-27T14:02:00.000Z", + "message": "Special privileges assigned to new logon.", + "log.original": "Special privileges assigned to new logon.", + "event.provider": "Microsoft-Windows-Security-Auditing", + "event.category": "iam", + "event.outcome": "success", + "host.hostname": "DESKTOP-ABC123", + "organization.id": 101, + "organization.name": "Acme Inc." + } + ], + "pagination": {} +} \ No newline at end of file diff --git a/huntress/tests/test_docker.py b/huntress/tests/test_docker.py new file mode 100644 index 000000000..e46b2a430 --- /dev/null +++ b/huntress/tests/test_docker.py @@ -0,0 +1,60 @@ +""" +Integration tests for the Huntress SIEM check against a live Mockoon mock server. + +Run with Docker available: + ddev test huntress -m integration +""" + +import pytest + +from datadog_checks.base import AgentCheck +from datadog_checks.huntress import HuntressCheck + +pytestmark = pytest.mark.integration + + +def test_integration_check_runs(dd_environment, aggregator): + """Full check run against the Mockoon server — verifies happy path end-to-end.""" + instance = dd_environment + check = HuntressCheck("huntress", {}, [instance]) + check.check(instance) + + aggregator.assert_metric("huntress.siem.logs_collected", at_least=0) + aggregator.assert_metric("huntress.siem.pages_fetched", at_least=1) + aggregator.assert_metric("huntress.siem.run_duration_seconds", at_least=0) + aggregator.assert_metric("huntress.siem.api_call_limit", value=60) + aggregator.assert_metric("huntress.siem.api_call_remaining", value=55) + aggregator.assert_service_check("huntress.siem.check_status", status=AgentCheck.OK) + aggregator.assert_all_metrics_covered() + + +def test_integration_org_enrichment(dd_environment, aggregator): + """Verify org metadata is fetched from the mock and applied to logs.""" + instance = dict(dd_environment, enrich_with_org_tags=True, org_cache_ttl_seconds=0) + check = HuntressCheck("huntress", {}, [instance]) + + sent_logs = [] + original_send = check._send_logs_batch + + def capture(batch): + sent_logs.extend(batch) + original_send(batch) + + check._send_logs_batch = capture + check.check(instance) + + assert sent_logs, "Expected at least one log to be forwarded" + for log in sent_logs: + assert "huntress_account_id" in log.get("ddtags", ""), ( + f"Missing huntress_account_id in ddtags: {log.get('ddtags')}" + ) + + +def test_integration_rate_limit_metrics(dd_environment, aggregator): + """Verify rate limit headers are parsed and emitted as metrics.""" + instance = dict(dd_environment, enrich_with_org_tags=False) + check = HuntressCheck("huntress", {}, [instance]) + check.check(instance) + + aggregator.assert_metric("huntress.siem.api_call_limit", value=60) + aggregator.assert_metric("huntress.siem.api_call_remaining", value=55) diff --git a/huntress/tests/test_huntress.py b/huntress/tests/test_huntress.py new file mode 100644 index 000000000..cf0ebf31d --- /dev/null +++ b/huntress/tests/test_huntress.py @@ -0,0 +1,892 @@ +""" +Unit tests for the Huntress SIEM → Datadog Logs integration. +Covers all 17 scenarios from PRD §12. +""" + +import json +import os +from datetime import datetime, timezone +from unittest.mock import MagicMock, patch + +import pytest + +from datadog_checks.huntress import HuntressCheck + +FIXTURES = os.path.join(os.path.dirname(__file__), "fixtures") + + +def _load_fixture(name): + with open(os.path.join(FIXTURES, name)) as f: + return json.load(f) + + +def _make_instance(**kwargs): + esql = kwargs.pop("esql_query", "FROM logs") + base = { + "huntress_api_key": "pub_key", + "huntress_secret_key": "secret_key", + "log_queries": [{"name": "test-query", "esql_query": esql}], + "enrich_with_org_tags": False, + "tags": ["source:huntress", "env:test"], + } + base.update(kwargs) + return base + + +def _make_check(**kwargs): + inst = kwargs.pop("instance", None) or _make_instance(**kwargs) + check = HuntressCheck("huntress", {}, [inst]) + check.log = MagicMock() + # Use an in-memory dict to prevent persistent cache from bleeding between tests + _cache = {} + check.read_persistent_cache = lambda key: _cache.get(key) + check.write_persistent_cache = lambda key, value: _cache.__setitem__(key, value) + return check, inst + + +def _mock_response(status_code, body, headers=None): + resp = MagicMock() + resp.status_code = status_code + resp.json.return_value = body + resp.text = json.dumps(body) + # Use a real dict so header parsing in _parse_rate_limit_headers works correctly. + resp.headers = ( + headers + if headers is not None + else { + "x-huntress-api-call-limit": "60", + "x-huntress-api-call-remaining": "55", + } + ) + return resp + + +# =========================================================================== +# 1. Happy path — single page +# =========================================================================== + + +def test_happy_path_single_page(): + check, instance = _make_check() + fixture = _load_fixture("siem_query_empty.json") + fixture["logs"] = _load_fixture("siem_query_page1.json")["logs"][:1] + + with ( + patch.object(check, "_request_with_retry", return_value=_mock_response(200, fixture)), + patch.object(check, "_send_logs_batch") as mock_send, + patch.object(check, "_save_checkpoint") as mock_save, + patch("time.time", side_effect=[1000.0, 1001.0]), + ): + check.check(instance) + + mock_send.assert_called_once() + mock_save.assert_called_once() + batch = mock_send.call_args[0][0] + assert len(batch) == 1 + assert batch[0]["ddsource"] == "huntress" + assert batch[0]["message"] == "An account was successfully logged on." + + +# =========================================================================== +# 2. Happy path — multi-page +# =========================================================================== + + +def test_happy_path_multi_page(): + check, instance = _make_check() + page1 = _load_fixture("siem_query_page1.json") + page2 = _load_fixture("siem_query_page2.json") + + responses = [ + _mock_response(200, page1), + _mock_response(200, page2), + ] + + with ( + patch.object(check, "_request_with_retry", side_effect=responses), + patch.object(check, "_send_logs_batch") as mock_send, + patch.object(check, "_save_checkpoint") as mock_save, + patch("time.time", side_effect=[1000.0, 1002.0]), + ): + check.check(instance) + + assert mock_send.call_count == 2 + all_logs = mock_send.call_args_list[0][0][0] + mock_send.call_args_list[1][0][0] + assert len(all_logs) == 3 + mock_save.assert_called_once() + + +# =========================================================================== +# 3. Auth failure (401) +# =========================================================================== + + +def test_auth_failure_401(): + check, instance = _make_check() + + with ( + patch.object(check, "_request_with_retry", side_effect=Exception("Huntress API 401 Unauthorized")), + patch.object(check, "_save_checkpoint") as mock_save, + patch.object(check, "count"), + patch("time.time", side_effect=[1000.0, 1001.0]), + ): + with pytest.raises(Exception, match="401"): + check.check(instance) + + mock_save.assert_not_called() + + +def test_auth_failure_401_raises_via_retry(): + """Verify _request_with_retry itself raises on 401.""" + check, instance = _make_check() + resp = _mock_response(401, {"error": "unauthorized"}) + + with patch("requests.request", return_value=resp), patch.object(check, "count") as mock_count: + with pytest.raises(Exception, match="401"): + check._request_with_retry("POST", "https://api.huntress.io/v1/siem/query", {}, json_body={}) + + mock_count.assert_called_with("huntress.siem.errors", 1, tags=["error_type:auth_failure"]) + + +# =========================================================================== +# 4. Query timeout (408) — retried twice then aborts +# =========================================================================== + + +def test_query_timeout_408(): + check, instance = _make_check() + resp_408 = _mock_response(408, {"error": "timeout"}) + + with ( + patch("requests.request", return_value=resp_408), + patch("time.sleep") as mock_sleep, + patch.object(check, "count") as mock_count, + ): + with pytest.raises(Exception, match="408"): + check._request_with_retry("POST", "https://x/q", {}, json_body={}) + + # Two retries → sleeps of 2s and 4s + assert mock_sleep.call_count == 2 + sleep_args = [c[0][0] for c in mock_sleep.call_args_list] + assert sleep_args == [2, 4] + mock_count.assert_called_with("huntress.siem.errors", 1, tags=["error_type:timeout"]) + + +# =========================================================================== +# 5. Rate limit (429) — sleeps 60s and retries +# =========================================================================== + + +def test_rate_limit_429_then_success(): + check, instance = _make_check() + resp_429 = _mock_response(429, {"error": "rate_limited"}) + resp_200 = _mock_response(200, _load_fixture("siem_query_empty.json")) + + with patch("requests.request", side_effect=[resp_429, resp_200]), patch("time.sleep") as mock_sleep: + result = check._request_with_retry("POST", "https://x/q", {}, json_body={}) + + mock_sleep.assert_called_once_with(60) + assert result.status_code == 200 + + +# =========================================================================== +# 6. Invalid ES|QL (422) +# =========================================================================== + + +def test_invalid_esql_422(): + check, instance = _make_check() + resp_422 = _mock_response(422, {"error": "invalid query"}) + + with patch("requests.request", return_value=resp_422), patch.object(check, "count") as mock_count: + with pytest.raises(Exception, match="422"): + check._request_with_retry("POST", "https://x/q", {}, json_body={}) + + mock_count.assert_called_with("huntress.siem.errors", 1, tags=["error_type:invalid_query"]) + + +# =========================================================================== +# 7. Checkpoint persistence — second run uses previous range_end as range_start +# =========================================================================== + + +def test_checkpoint_persistence(): + check, instance = _make_check() + saved_ts = "2026-05-27T13:00:00.000Z" + instance_hash = check._instance_hash(instance) + query_hash = check._query_hash("FROM logs") + checkpoint_key = instance_hash + "_" + query_hash + check.write_persistent_cache( + check.CHECKPOINT_CACHE_KEY_PREFIX + checkpoint_key, + json.dumps({"last_collected_at": saved_ts, "schema_version": 1}), + ) + + captured_bodies = [] + + def capture_request(method, url, headers, json_body=None, params=None): + if json_body: + captured_bodies.append(json_body) + return _mock_response(200, _load_fixture("siem_query_empty.json")) + + with ( + patch.object(check, "_request_with_retry", side_effect=capture_request), + patch("time.time", side_effect=[1000.0, 1001.0]), + ): + check.check(instance) + + assert captured_bodies, "No SIEM query was made" + # range_start should be 1 second after saved_ts (API only accepts second-precision timestamps) + assert captured_bodies[0]["range_start"] == "2026-05-27T13:00:01" + + +# =========================================================================== +# 8. No checkpoint (first run) — range_start defaults to now - interval +# =========================================================================== + + +def test_no_checkpoint_first_run(): + check, instance = _make_check(min_collection_interval=900) + captured_bodies = [] + + def capture_request(method, url, headers, json_body=None, params=None): + if json_body: + captured_bodies.append(json_body) + return _mock_response(200, _load_fixture("siem_query_empty.json")) + + fixed_now = datetime(2026, 5, 27, 14, 0, 0, tzinfo=timezone.utc) + with ( + patch.object(check, "_request_with_retry", side_effect=capture_request), + patch("datadog_checks.huntress.huntress.datetime") as mock_dt, + patch("time.time", side_effect=[1000.0, 1001.0]), + ): + mock_dt.now.return_value = fixed_now + mock_dt.fromisoformat = datetime.fromisoformat + check.check(instance) + + assert captured_bodies + body = captured_bodies[0] + range_start = datetime.fromisoformat(body["range_start"].replace("Z", "+00:00")) + range_end = datetime.fromisoformat(body["range_end"].replace("Z", "+00:00")) + diff_seconds = (range_end - range_start).total_seconds() + assert abs(diff_seconds - 900) < 2 + + +# =========================================================================== +# 9. ES|QL validation — query not starting with FROM logs raises ConfigurationError +# =========================================================================== + + +def test_esql_validation_rejects_bad_query(): + check, instance = _make_check(esql_query="SELECT * FROM logs") + with pytest.raises(Exception, match="FROM logs"): + check.check(instance) + + +def test_esql_validation_accepts_from_logs(): + check, instance = _make_check(esql_query="FROM logs | KEEP @timestamp, message") + with ( + patch.object( + check, "_request_with_retry", return_value=_mock_response(200, _load_fixture("siem_query_empty.json")) + ), + patch("time.time", side_effect=[1000.0, 1001.0]), + ): + check.check(instance) # should not raise + + +def test_esql_validation_case_insensitive(): + check, instance = _make_check(esql_query="from logs | limit 100") + with ( + patch.object( + check, "_request_with_retry", return_value=_mock_response(200, _load_fixture("siem_query_empty.json")) + ), + patch("time.time", side_effect=[1000.0, 1001.0]), + ): + check.check(instance) # should not raise + + +# =========================================================================== +# 10. Log transformation — ECS fields correctly mapped to Datadog payload +# =========================================================================== + + +def test_log_transformation(): + check, instance = _make_check() + raw = { + "@timestamp": "2026-05-27T14:00:00.000Z", + "log.original": "An account was successfully logged on.", + "message": "fallback message", + "event.provider": "Microsoft-Windows-Security-Auditing", + "host.hostname": "DESKTOP-ABC123", + "event.category": "authentication", + } + tags = ["source:huntress", "env:test", "huntress_account_id:42"] + payload = check._transform_log(raw, tags, "huntress-siem") + + assert payload["message"] == "An account was successfully logged on." + assert payload["ddsource"] == "huntress" + assert payload["service"] == "huntress-siem" + assert payload["ddtags"] == "source:huntress,env:test,huntress_account_id:42" + assert payload["timestamp"] == 1779890400.0 + assert payload["event.provider"] == "Microsoft-Windows-Security-Auditing" + assert payload["host.hostname"] == "DESKTOP-ABC123" + assert "@timestamp" not in payload + assert "log.original" not in payload + + +def test_log_transformation_fallback_message(): + check, instance = _make_check() + raw = {"message": "fallback used", "@timestamp": "2026-05-27T14:00:00.000Z"} + payload = check._transform_log(raw, [], "huntress-siem") + assert payload["message"] == "fallback used" + + +def test_log_transformation_json_fallback(): + check, instance = _make_check() + raw = {"event.category": "network", "@timestamp": "2026-05-27T14:00:00.000Z"} + payload = check._transform_log(raw, [], "huntress-siem") + assert "event.category" in payload["message"] + + +# =========================================================================== +# 11. Batching — >1,000 logs split into multiple batches +# =========================================================================== + + +def test_batching_over_1000_logs(): + check, instance = _make_check() + + large_log_list = [ + { + "@timestamp": "2026-05-27T14:00:00.000Z", + "message": f"log {i}", + } + for i in range(1500) + ] + fixture = {"logs": large_log_list, "pagination": {}} + + with ( + patch.object(check, "_request_with_retry", return_value=_mock_response(200, fixture)), + patch.object(check, "_send_logs_batch") as mock_send, + patch("time.time", side_effect=[1000.0, 1001.0]), + ): + check.check(instance) + + assert mock_send.call_count == 2 + first_batch = mock_send.call_args_list[0][0][0] + second_batch = mock_send.call_args_list[1][0][0] + assert len(first_batch) == 1000 + assert len(second_batch) == 500 + + +# =========================================================================== +# 12. max_pages_per_run cap — stops after N pages; checkpoint does NOT advance +# =========================================================================== + + +def test_max_pages_per_run_cap(): + check, instance = _make_check(max_pages_per_run=1) + page1 = _load_fixture("siem_query_page1.json") + + with ( + patch.object(check, "_request_with_retry", return_value=_mock_response(200, page1)), + patch.object(check, "_save_checkpoint") as mock_save, + patch("time.time", side_effect=[1000.0, 1001.0]), + ): + check.check(instance) + + mock_save.assert_not_called() + + +# =========================================================================== +# 13. Org enrichment — cache hit (no extra API calls) +# =========================================================================== + + +def test_org_enrichment_cache_hit(): + check, instance = _make_check(enrich_with_org_tags=True) + instance_hash = check._instance_hash(instance) + + # Pre-populate cache (fresh) + from datetime import datetime, timezone + + cache = { + "fetched_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z", + "account_id": 42, + "orgs": { + "101": {"name": "Acme Inc.", "key": "acme", "account_id": 42}, + }, + } + check.write_persistent_cache(check.ORG_CACHE_KEY_PREFIX + instance_hash, json.dumps(cache)) + + page1 = _load_fixture("siem_query_page1.json") + page1["pagination"] = {} + + api_calls = [] + + def side_effect(method, url, headers, json_body=None, params=None): + api_calls.append(url) + return _mock_response(200, page1) + + with ( + patch.object(check, "_request_with_retry", side_effect=side_effect), + patch.object(check, "_send_logs_batch") as mock_send, + patch("time.time", side_effect=[1000.0, 1001.0]), + ): + check.check(instance) + + # Only the SIEM query should have been called — no org API calls + assert all("/siem/query" in u for u in api_calls) + + # Verify org tags applied + batch = mock_send.call_args_list[0][0][0] + first_log_tags = batch[0]["ddtags"] + assert "huntress_org_name:Acme Inc." in first_log_tags + assert "huntress_org_key:acme" in first_log_tags + assert "huntress_account_id:42" in first_log_tags + + +# =========================================================================== +# 14. Org enrichment — cache miss (fetches account + orgs) +# =========================================================================== + + +def test_org_enrichment_cache_miss(): + check, instance = _make_check(enrich_with_org_tags=True) + + account_resp = _mock_response(200, {"account": {"id": 42}}) + orgs_resp = _mock_response( + 200, + { + "organizations": [ + {"id": 101, "name": "Acme Inc.", "key": "acme"}, + ], + "pagination": {}, + }, + ) + siem_resp = _mock_response( + 200, + { + "logs": _load_fixture("siem_query_page1.json")["logs"][:1], + "pagination": {}, + }, + ) + + call_urls = [] + + def side_effect(method, url, headers, json_body=None, params=None): + call_urls.append(url) + if "/account" in url and "organization" not in url: + return account_resp + elif "organizations" in url: + return orgs_resp + else: + return siem_resp + + with ( + patch.object(check, "_request_with_retry", side_effect=side_effect), + patch.object(check, "_send_logs_batch") as mock_send, + patch("time.time", side_effect=[1000.0, 1001.0]), + ): + check.check(instance) + + assert any("/account" in u for u in call_urls) + assert any("organizations" in u for u in call_urls) + + batch = mock_send.call_args_list[0][0][0] + assert "huntress_account_id:42" in batch[0]["ddtags"] + + +# =========================================================================== +# 15. Org enrichment — fetch failure → warning, logs collected without org tags +# =========================================================================== + + +def test_org_enrichment_fetch_failure(): + check, instance = _make_check(enrich_with_org_tags=True) + siem_resp = _mock_response( + 200, + { + "logs": _load_fixture("siem_query_page1.json")["logs"][:1], + "pagination": {}, + }, + ) + + def side_effect(method, url, headers, json_body=None, params=None): + if "/account" in url and "organization" not in url: + raise Exception("Network error fetching account") + return siem_resp + + with ( + patch.object(check, "_request_with_retry", side_effect=side_effect), + patch.object(check, "_send_logs_batch") as mock_send, + patch("time.time", side_effect=[1000.0, 1001.0]), + ): + check.check(instance) # must not raise + + check.log.warning.assert_called() + # Logs should still be sent + mock_send.assert_called() + batch = mock_send.call_args_list[0][0][0] + # No org tags on logs + assert "huntress_org_name" not in batch[0]["ddtags"] + + +# =========================================================================== +# 16. Org enrichment — no org match → only huntress_account_id tag +# =========================================================================== + + +def test_org_enrichment_no_org_match(): + check, instance = _make_check() + org_cache = { + "fetched_at": "2026-05-27T14:00:00.000Z", + "account_id": 42, + "orgs": {"101": {"name": "Acme Inc.", "key": "acme", "account_id": 42}}, + } + raw_log = {"@timestamp": "2026-05-27T14:00:00.000Z", "message": "no org field"} + tags = check._get_org_tags(raw_log, org_cache) + + assert "huntress_account_id:42" in tags + assert not any("org_name" in t or "org_key" in t for t in tags) + + +# =========================================================================== +# 17. Multi-instance isolation — independent checkpoints and org caches +# =========================================================================== + + +def test_multi_instance_isolation(): + instance_a = _make_instance( + instance_id="account_a", + huntress_api_key="key_a", + huntress_secret_key="secret_a", + ) + instance_b = _make_instance( + instance_id="account_b", + huntress_api_key="key_b", + huntress_secret_key="secret_b", + ) + + check_a = HuntressCheck("huntress", {}, [instance_a]) + check_a.log = MagicMock() + _cache_a = {} + check_a.read_persistent_cache = lambda key: _cache_a.get(key) + check_a.write_persistent_cache = lambda key, value: _cache_a.__setitem__(key, value) + + check_b = HuntressCheck("huntress", {}, [instance_b]) + check_b.log = MagicMock() + _cache_b = {} + check_b.read_persistent_cache = lambda key: _cache_b.get(key) + check_b.write_persistent_cache = lambda key, value: _cache_b.__setitem__(key, value) + + hash_a = check_a._instance_hash(instance_a) + hash_b = check_b._instance_hash(instance_b) + query_hash = check_a._query_hash("FROM logs") + + assert hash_a != hash_b + + # Each query gets its own checkpoint key: instance_hash + "_" + query_hash + ck_a = hash_a + "_" + query_hash + ck_b = hash_b + "_" + query_hash + + check_a._save_checkpoint(ck_a, "2026-05-27T14:00:00.000Z") + check_b._save_checkpoint(ck_b, "2026-05-27T15:00:00.000Z") + + assert check_a._load_checkpoint(ck_a) == "2026-05-27T14:00:00.000Z" + assert check_b._load_checkpoint(ck_b) == "2026-05-27T15:00:00.000Z" + # Each check's cache is isolated — A cannot see B's checkpoint + assert check_a._load_checkpoint(ck_b) is None + + +# =========================================================================== +# Bonus: _get_org_tags strategies +# =========================================================================== + + +def test_org_tags_by_id(): + check, _ = _make_check() + org_cache = { + "account_id": 42, + "orgs": {"101": {"name": "Acme Inc.", "key": "acme", "account_id": 42}}, + } + raw = {"organization.id": 101} + tags = check._get_org_tags(raw, org_cache) + assert "huntress_org_id:101" in tags + assert "huntress_org_name:Acme Inc." in tags + assert "huntress_org_key:acme" in tags + assert "huntress_account_id:42" in tags + + +def test_org_tags_by_name_reverse_lookup(): + check, _ = _make_check() + org_cache = { + "account_id": 42, + "orgs": {"101": {"name": "Acme Inc.", "key": "acme", "account_id": 42}}, + } + raw = {"organization.name": "Acme Inc."} + tags = check._get_org_tags(raw, org_cache) + assert "huntress_org_name:Acme Inc." in tags + assert "huntress_org_key:acme" in tags + + +def test_org_tags_nested_org_field(): + """organization field as a nested dict (e.g. from ECS nested structure).""" + check, _ = _make_check() + org_cache = { + "account_id": 42, + "orgs": {"101": {"name": "Acme Inc.", "key": "acme", "account_id": 42}}, + } + raw = {"organization": {"id": 101, "name": "Acme Inc."}} + tags = check._get_org_tags(raw, org_cache) + assert "huntress_org_name:Acme Inc." in tags + + +# =========================================================================== +# Rate limit header parsing +# =========================================================================== + + +def test_rate_limit_headers_parsed_and_stored(): + """_parse_rate_limit_headers populates _last_api_call_* attributes.""" + check, _ = _make_check() + check._last_api_call_limit = None + check._last_api_call_remaining = None + + resp = _mock_response( + 200, + {}, + headers={ + "x-huntress-api-call-limit": "60", + "x-huntress-api-call-remaining": "42", + }, + ) + check._parse_rate_limit_headers(resp) + + assert check._last_api_call_limit == 60 + assert check._last_api_call_remaining == 42 + + +def test_rate_limit_warning_when_low(): + """Warning is logged when remaining drops below 10.""" + check, _ = _make_check() + check._last_api_call_limit = None + check._last_api_call_remaining = None + + resp = _mock_response( + 200, + {}, + headers={ + "x-huntress-api-call-limit": "60", + "x-huntress-api-call-remaining": "3", + }, + ) + check._parse_rate_limit_headers(resp) + + check.log.warning.assert_called() + assert check._last_api_call_remaining == 3 + + +def test_rate_limit_headers_missing_gracefully(): + """Missing rate limit headers do not raise; attributes stay None.""" + check, _ = _make_check() + check._last_api_call_limit = None + check._last_api_call_remaining = None + + resp = _mock_response(200, {}, headers={"Content-Type": "application/json"}) + check._parse_rate_limit_headers(resp) + + assert check._last_api_call_limit is None + assert check._last_api_call_remaining is None + + +def test_rate_limit_metrics_emitted_in_check(): + """huntress.siem.api_call_* gauges are emitted when headers were present.""" + check, instance = _make_check() + fixture = _load_fixture("siem_query_empty.json") + fixture["logs"] = [] + + emitted_gauges = {} + + def capture_gauge(name, value, tags=None): + emitted_gauges[name] = value + + def fake_retry(method, url, headers, json_body=None, params=None): + resp = _mock_response( + 200, + fixture, + headers={ + "x-huntress-api-call-limit": "60", + "x-huntress-api-call-remaining": "48", + }, + ) + check._parse_rate_limit_headers(resp) + return resp + + with ( + patch.object(check, "_request_with_retry", side_effect=fake_retry), + patch.object(check, "gauge", side_effect=capture_gauge), + patch("time.time", side_effect=[1000.0, 1001.0]), + ): + check.check(instance) + + assert emitted_gauges.get("huntress.siem.api_call_limit") == 60 + assert emitted_gauges.get("huntress.siem.api_call_remaining") == 48 + + +# =========================================================================== +# Configuration validation — neither log_queries nor metrics.agents +# =========================================================================== + + +def test_validation_neither_configured_raises(): + instance = { + "huntress_api_key": "pub_key", + "huntress_secret_key": "secret_key", + } + check = HuntressCheck("huntress", {}, [instance]) + check.log = MagicMock() + _cache = {} + check.read_persistent_cache = lambda key: _cache.get(key) + check.write_persistent_cache = lambda key, value: _cache.__setitem__(key, value) + with pytest.raises(Exception, match="at least one"): + check.check(instance) + + +def test_validation_metrics_only_instance(): + """An instance with no log_queries but metrics.agents.enabled=True is valid.""" + instance = { + "huntress_api_key": "pub_key", + "huntress_secret_key": "secret_key", + "metrics": {"agents": {"enabled": True}}, + } + check = HuntressCheck("huntress", {}, [instance]) + check.log = MagicMock() + _cache = {} + check.read_persistent_cache = lambda key: _cache.get(key) + check.write_persistent_cache = lambda key, value: _cache.__setitem__(key, value) + + agents_resp = _mock_response(200, {"agents": [], "pagination": {}}) + with ( + patch.object(check, "_request_with_retry", return_value=agents_resp), + patch("time.time", side_effect=[1000.0, 1001.0]), + ): + check.check(instance) # must not raise + + +# =========================================================================== +# Agent metrics +# =========================================================================== + + +def _make_agents_instance(**kwargs): + base = { + "huntress_api_key": "pub_key", + "huntress_secret_key": "secret_key", + "metrics": {"agents": {"enabled": True}}, + "tags": ["source:huntress"], + } + base.update(kwargs) + return base + + +def test_agent_metrics_emitted(): + """_collect_agent_metrics emits total, per-platform, per-status gauges.""" + instance = _make_agents_instance() + check = HuntressCheck("huntress", {}, [instance]) + check.log = MagicMock() + _cache = {} + check.read_persistent_cache = lambda key: _cache.get(key) + check.write_persistent_cache = lambda key, value: _cache.__setitem__(key, value) + + agents = [ + {"platform": "windows", "defender_status": "Healthy", "firewall_status": "Enabled"}, + {"platform": "windows", "defender_status": "Healthy", "firewall_status": "Disabled"}, + {"platform": "darwin", "defender_status": None, "firewall_status": "Enabled"}, + ] + agents_resp = _mock_response(200, {"agents": agents, "pagination": {}}) + + emitted = {} + + def capture_gauge(name, value, tags=None): + emitted[(name, tuple(sorted(tags or [])))] = value + + with ( + patch.object(check, "_request_with_retry", return_value=agents_resp), + patch.object(check, "gauge", side_effect=capture_gauge), + patch("time.time", side_effect=[1000.0, 1001.0]), + ): + check.check(instance) + + assert emitted[("huntress.agents.total", ("source:huntress",))] == 3 + assert emitted[("huntress.agents.count", ("platform:windows", "source:huntress"))] == 2 + assert emitted[("huntress.agents.count", ("platform:darwin", "source:huntress"))] == 1 + assert emitted[("huntress.agents.defender_status", ("defender_status:healthy", "source:huntress"))] == 2 + assert emitted[("huntress.agents.defender_status", ("defender_status:unknown", "source:huntress"))] == 1 + assert emitted[("huntress.agents.firewall_status", ("firewall_status:enabled", "source:huntress"))] == 2 + assert emitted[("huntress.agents.firewall_status", ("firewall_status:disabled", "source:huntress"))] == 1 + + +def test_agent_metrics_pagination(): + """Agent collection paginates until no next_page_token.""" + instance = _make_agents_instance(metrics={"agents": {"enabled": True, "max_pages": 5}}) + check = HuntressCheck("huntress", {}, [instance]) + check.log = MagicMock() + _cache = {} + check.read_persistent_cache = lambda key: _cache.get(key) + check.write_persistent_cache = lambda key, value: _cache.__setitem__(key, value) + + page1 = { + "agents": [{"platform": "windows", "defender_status": "Healthy", "firewall_status": "Enabled"}], + "pagination": {"next_page_token": "tok2"}, + } + page2 = { + "agents": [{"platform": "linux", "defender_status": "Healthy", "firewall_status": "Enabled"}], + "pagination": {}, + } + + responses = [_mock_response(200, page1), _mock_response(200, page2)] + call_urls = [] + + def side_effect(method, url, headers, json_body=None, params=None): + call_urls.append(url) + return responses.pop(0) + + emitted = {} + + def capture_gauge(name, value, tags=None): + emitted[(name, tuple(sorted(tags or [])))] = value + + with ( + patch.object(check, "_request_with_retry", side_effect=side_effect), + patch.object(check, "gauge", side_effect=capture_gauge), + patch("time.time", side_effect=[1000.0, 1001.0]), + ): + check.check(instance) + + assert emitted[("huntress.agents.total", ("source:huntress",))] == 2 + assert emitted[("huntress.agents.count", ("platform:windows", "source:huntress"))] == 1 + assert emitted[("huntress.agents.count", ("platform:linux", "source:huntress"))] == 1 + assert len(call_urls) == 2 + + +def test_agent_metrics_max_pages_cap(): + """Agent collection stops at max_pages even when more pages exist.""" + instance = _make_agents_instance(metrics={"agents": {"enabled": True, "max_pages": 1}}) + check = HuntressCheck("huntress", {}, [instance]) + check.log = MagicMock() + _cache = {} + check.read_persistent_cache = lambda key: _cache.get(key) + check.write_persistent_cache = lambda key, value: _cache.__setitem__(key, value) + + page_with_more = { + "agents": [{"platform": "windows", "defender_status": "Healthy", "firewall_status": "Enabled"}], + "pagination": {"next_page_token": "there_is_more"}, + } + + with ( + patch.object(check, "_request_with_retry", return_value=_mock_response(200, page_with_more)), + patch("time.time", side_effect=[1000.0, 1001.0]), + ): + check.check(instance) + + check.log.warning.assert_called() diff --git a/huntress/tests/test_unit.py b/huntress/tests/test_unit.py new file mode 100644 index 000000000..2ad322e04 --- /dev/null +++ b/huntress/tests/test_unit.py @@ -0,0 +1,42 @@ +from typing import Any, Callable, Dict # noqa: F401 +from unittest.mock import MagicMock, patch + +import pytest + +from datadog_checks.base import AgentCheck # noqa: F401 +from datadog_checks.base.stubs.aggregator import AggregatorStub # noqa: F401 +from datadog_checks.huntress import HuntressCheck + + +@pytest.fixture +def huntress_instance(): + return { + "huntress_api_key": "test_key", + "huntress_secret_key": "test_secret", + "log_queries": [{"name": "all-logs", "esql_query": "FROM logs"}], + "enrich_with_org_tags": False, + } + + +def test_check_initializes(huntress_instance): + check = HuntressCheck('huntress', {}, [huntress_instance]) + assert check is not None + assert check.SERVICE_CHECK_NAME == 'huntress.siem.check_status' + + +def test_check_runs(dd_run_check, aggregator, huntress_instance): + check = HuntressCheck('huntress', {}, [huntress_instance]) + check.log = MagicMock() + + empty_response = {"logs": [], "pagination": {}} + + with patch.object( + check, + "_request_with_retry", + return_value=MagicMock( + status_code=200, + json=lambda: empty_response, + headers={"x-huntress-api-call-limit": "60", "x-huntress-api-call-remaining": "60"}, + ), + ): + dd_run_check(check)