Skip to content

Commit 4adfb34

Browse files
committed
feat(hunt-io): add support for hunt v3 api while keeping v2 (#7678)
The two Hunt.io C2 feed APIs use mutually exclusive authentication: V2 expects a `token` header, V3 expects `Authorization: Bearer` with an `ak_`-prefixed key. Each returns HTTP 401 when sent the other's header. Add `hunt_io.api_version` (HUNT_IO_API_VERSION) to select between them, defaulting to v2 so existing deployments are unaffected. The response payload is identical between versions, so parsing and STIX conversion are unchanged. - Select the auth header by version in HTTPSessionManager.create_session, passing the version at both construction and post-timeout refresh - Reject a non-`ak_` key at startup when v3 is selected; V3 returns the same opaque 401 for a malformed key as for a missing one - Normalize blank and mixed-case values, so an undefined compose passthrough (`${HUNT_IO_API_VERSION}` -> "") falls back to the default - Document both versions and regenerate __metadata__
1 parent e00de1c commit 4adfb34

10 files changed

Lines changed: 358 additions & 8 deletions

File tree

external-import/hunt-io/README.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,10 +62,28 @@ Below are the parameters you'll need to set for the connector:
6262

6363
| Parameter | config.yml | Docker environment variable | Default | Mandatory | Description |
6464
|--------------|--------------|-----------------------------|---------|-----------|---------------------------------------------------------------------------------------------------------------------------|
65+
| API version | api_version | `HUNT_IO_API_VERSION` | v2 | No | Which Hunt.io C2 feed API to target. Valid values: `v2`, `v3`. Must be changed together with `api_base_url` — see below. |
6566
| API base URL | api_base_url | `HUNT_IO_API_BASE_URL` | | Yes | |
6667
| API key | api_key | `HUNT_IO_API_KEY` | | Yes | |
6768
| TLP level | tlp_level | `HUNT_IO_TLP_LEVEL` | amber | No | The Traffic Light Protocol level for data being ingested. Valid values: `white`, `green`, `amber`, `amber+strict`, `red`. |
6869

70+
#### Choosing an API version
71+
72+
The two Hunt.io APIs use mutually exclusive authentication, so `api_version` and `api_base_url` must
73+
always be set together:
74+
75+
| `api_version` | `api_base_url` | Authentication | Key format |
76+
|---------------|------------------------------------|-----------------------------------|----------------|
77+
| `v2` (default)| `https://api.hunt.io/v1/feeds/c2` | `token` header | any |
78+
| `v3` | `https://a.hunt.io/feeds/c2` | `Authorization: Bearer` | `ak_`-prefixed |
79+
80+
> **Changing only the base URL will not work.** Pointing `HUNT_IO_API_BASE_URL` at the V3 endpoint
81+
> while leaving `HUNT_IO_API_VERSION` at `v2` keeps sending the `token` header, and V3 answers with
82+
> an HTTP 401 that is indistinguishable from a missing key. The same applies in reverse.
83+
84+
Existing deployments are unaffected: `api_version` defaults to `v2`, which is the behaviour prior to
85+
this setting being introduced.
86+
6987
## Deployment
7088

7189
### Docker Deployment
@@ -88,6 +106,7 @@ Configure the connector in `docker-compose.yml`:
88106
- CONNECTOR_NAME=Hunt.io
89107
- CONNECTOR_SCOPE=hunt-io
90108
- CONNECTOR_LOG_LEVEL=info
109+
- HUNT_IO_API_VERSION=v2
91110
- HUNT_IO_API_BASE_URL=ChangeMe
92111
- HUNT_IO_API_KEY=ChangeMe
93112
restart: always

external-import/hunt-io/__metadata__/CONNECTOR_CONFIG_DOC.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,13 @@ Below is an exhaustive enumeration of all configurable parameters available, eac
1515
| CONNECTOR_TYPE | `const` | | `EXTERNAL_IMPORT` | | `"EXTERNAL_IMPORT"` | |
1616
| CONNECTOR_DURATION_PERIOD | `string` | | Format: [`duration`](https://json-schema.org/understanding-json-schema/reference/string#built-in-formats) | | `"P1D"` | Time interval between consecutive data imports from Hunt.io. Controls how frequently the connector runs |
1717
| HUNT_IO_API_BASE_URL | `string` | | Format: [`uri`](https://json-schema.org/understanding-json-schema/reference/string#built-in-formats) | | `"https://api.hunt.io/v1/feeds/c2"` | Hunt.io API endpoint URL for the C2 threat intelligence feeds |
18+
| HUNT_IO_API_VERSION | `string` | | `v2` `v3` | | `"v2"` | Which Hunt.io C2 feed API to target. 'v2' authenticates with a 'token' header against https://api.hunt.io/v1/feeds/c2. 'v3' authenticates with 'Authorization: Bearer' against https://a.hunt.io/feeds/c2 and requires an 'ak_'-prefixed key. The two APIs are mutually exclusive: set api_base_url to match the version, as changing one without the other returns HTTP 401 |
1819
| HUNT_IO_TLP_LEVEL | `string` | | `white` `clear` `green` `amber` `amber+strict` `red` | | `"amber"` | Traffic Light Protocol (TLP) marking level to apply to imported data, controlling information sharing restrictions |
1920
| CONNECTOR_HUNT_UI_API_BASE_URL | `string` | | Format: [`uri`](https://json-schema.org/understanding-json-schema/reference/string#built-in-formats) | ⛔️ | `"https://api.hunt.io/v1/feeds/c2"` | Use HUNT_IO_API_BASE_URL instead. |
21+
| CONNECTOR_HUNT_UI_API_VERSION | `string` | | `v2` `v3` | ⛔️ | `"v2"` | Use HUNT_IO_API_VERSION instead. |
2022
| CONNECTOR_HUNT_UI_API_KEY | `string` | | Format: [`password`](https://json-schema.org/understanding-json-schema/reference/string#built-in-formats) | ⛔️ | | Use HUNT_IO_API_KEY instead. |
2123
| CONNECTOR_HUNT_UI_TLP_LEVEL | `string` | | `white` `clear` `green` `amber` `amber+strict` `red` | ⛔️ | `"amber"` | Use HUNT_IO_TLP_LEVEL instead. |
2224
| CONNECTOR_HUNT_IO_API_BASE_URL | `string` | | Format: [`uri`](https://json-schema.org/understanding-json-schema/reference/string#built-in-formats) | ⛔️ | `"https://api.hunt.io/v1/feeds/c2"` | Use HUNT_IO_API_BASE_URL instead. |
25+
| CONNECTOR_HUNT_IO_API_VERSION | `string` | | `v2` `v3` | ⛔️ | `"v2"` | Use HUNT_IO_API_VERSION instead. |
2326
| CONNECTOR_HUNT_IO_API_KEY | `string` | | Format: [`password`](https://json-schema.org/understanding-json-schema/reference/string#built-in-formats) | ⛔️ | | Use HUNT_IO_API_KEY instead. |
2427
| CONNECTOR_HUNT_IO_TLP_LEVEL | `string` | | `white` `clear` `green` `amber` `amber+strict` `red` | ⛔️ | `"amber"` | Use HUNT_IO_TLP_LEVEL instead. |

external-import/hunt-io/__metadata__/connector_config_schema.json

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"$schema": "https://json-schema.org/draft/2020-12/schema",
3-
"$id": "https://www.filigran.io/connectors/hunt-io_config.schema.json",
3+
"$id": "https://www.filigran.io/connectors/._config.schema.json",
44
"type": "object",
55
"properties": {
66
"OPENCTI_URL": {
@@ -63,6 +63,16 @@
6363
"type": "string",
6464
"deprecated": true
6565
},
66+
"CONNECTOR_HUNT_UI_API_VERSION": {
67+
"default": "v2",
68+
"description": "Use HUNT_IO_API_VERSION instead.",
69+
"enum": [
70+
"v2",
71+
"v3"
72+
],
73+
"type": "string",
74+
"deprecated": true
75+
},
6676
"CONNECTOR_HUNT_UI_API_KEY": {
6777
"description": "Use HUNT_IO_API_KEY instead.",
6878
"format": "password",
@@ -93,6 +103,16 @@
93103
"type": "string",
94104
"deprecated": true
95105
},
106+
"CONNECTOR_HUNT_IO_API_VERSION": {
107+
"default": "v2",
108+
"description": "Use HUNT_IO_API_VERSION instead.",
109+
"enum": [
110+
"v2",
111+
"v3"
112+
],
113+
"type": "string",
114+
"deprecated": true
115+
},
96116
"CONNECTOR_HUNT_IO_API_KEY": {
97117
"description": "Use HUNT_IO_API_KEY instead.",
98118
"format": "password",
@@ -122,6 +142,15 @@
122142
"minLength": 1,
123143
"type": "string"
124144
},
145+
"HUNT_IO_API_VERSION": {
146+
"default": "v2",
147+
"description": "Which Hunt.io C2 feed API to target. 'v2' authenticates with a 'token' header against https://api.hunt.io/v1/feeds/c2. 'v3' authenticates with 'Authorization: Bearer' against https://a.hunt.io/feeds/c2 and requires an 'ak_'-prefixed key. The two APIs are mutually exclusive: set api_base_url to match the version, as changing one without the other returns HTTP 401",
148+
"enum": [
149+
"v2",
150+
"v3"
151+
],
152+
"type": "string"
153+
},
125154
"HUNT_IO_API_KEY": {
126155
"description": "Authentication key for accessing the Hunt.io API. Obtain this from your Hunt.io account settings",
127156
"format": "password",

external-import/hunt-io/config.yml.sample

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,5 +19,9 @@ connector:
1919
#send_to_directory_retention: 7
2020

2121
hunt_io:
22+
# 'v2' pairs with https://api.hunt.io/v1/feeds/c2 and a plain API key.
23+
# 'v3' pairs with https://a.hunt.io/feeds/c2 and an 'ak_'-prefixed key.
24+
# api_version and api_base_url must be changed together, or the API returns 401.
25+
api_version: 'v2'
2226
api_base_url: 'https://api.hunt.io/v1/feeds/c2'
2327
api_key: 'ChangeMe'

external-import/hunt-io/docker-compose.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ services:
1111
- CONNECTOR_DURATION_PERIOD=PT24H
1212

1313
# Connector's custom execution parameters
14+
- HUNT_IO_API_VERSION=${HUNT_IO_API_VERSION:-v2}
1415
- HUNT_IO_API_BASE_URL=${CONNECTOR_HUNT_UI_API_BASE_URL}
1516
- HUNT_IO_API_KEY=${CONNECTOR_HUNT_UI_API_KEY}
1617
restart: always

external-import/hunt-io/src/external_import_connector/client_api.py

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,18 @@ class HTTPSessionManager:
3131
def __init__(self, helper: OpenCTIConnectorHelper):
3232
self.helper = helper
3333

34-
def create_session(self, api_key: str) -> requests.Session:
35-
"""Create a new HTTP session with resilience features."""
34+
def create_session(self, api_key: str, api_version: str) -> requests.Session:
35+
"""Create a new HTTP session with resilience features.
36+
37+
The two Hunt.io APIs use mutually exclusive auth schemes: V2 expects a `token`
38+
header and rejects bearer auth, V3 expects `Authorization: Bearer` and rejects
39+
the `token` header. Both return HTTP 401 on a mismatch.
40+
"""
3641
session = requests.Session()
37-
session.headers.update({"token": api_key})
42+
if api_version == "v3":
43+
session.headers.update({"Authorization": f"Bearer {api_key}"})
44+
else:
45+
session.headers.update({"token": api_key})
3846

3947
# Configure retry strategy
4048
retry_strategy = Retry(
@@ -195,7 +203,8 @@ def __init__(self, helper: OpenCTIConnectorHelper, config: "ConnectorSettings"):
195203

196204
# Create HTTP session with resilience features
197205
self.session = self.session_manager.create_session(
198-
self.config.hunt_io.api_key.get_secret_value()
206+
self.config.hunt_io.api_key.get_secret_value(),
207+
self.config.hunt_io.api_version,
199208
)
200209

201210
@property
@@ -322,7 +331,8 @@ def _refresh_session_on_timeout(self) -> None:
322331

323332
# Create new session with same configuration
324333
self.session = self.session_manager.create_session(
325-
self.config.hunt_io.api_key.get_secret_value()
334+
self.config.hunt_io.api_key.get_secret_value(),
335+
self.config.hunt_io.api_version,
326336
)
327337

328338
self.helper.connector_logger.info(

external-import/hunt-io/src/external_import_connector/settings.py

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from datetime import timedelta
2-
from typing import Literal
2+
from typing import Annotated, Any, Literal
33

44
from connectors_sdk import (
55
BaseConfigModel,
@@ -8,14 +8,53 @@
88
DeprecatedField,
99
ListFromString,
1010
)
11-
from pydantic import Field, HttpUrl, SecretStr, SkipValidation
11+
from pydantic import (
12+
BeforeValidator,
13+
Field,
14+
HttpUrl,
15+
SecretStr,
16+
SkipValidation,
17+
model_validator,
18+
)
19+
20+
DEFAULT_API_VERSION = "v2"
21+
22+
23+
def _normalize_api_version(value: Any) -> Any:
24+
"""Normalize an api_version coming from an environment variable.
25+
26+
A compose passthrough such as `HUNT_IO_API_VERSION=${HUNT_IO_API_VERSION}` sets the
27+
variable to an empty string when it is not defined, which would otherwise fail
28+
validation instead of falling back to the default. Case is normalized too, since
29+
environment variables are commonly written in upper case.
30+
"""
31+
if isinstance(value, str):
32+
normalized = value.strip().lower()
33+
return normalized or DEFAULT_API_VERSION
34+
return value
35+
36+
37+
ApiVersion = Annotated[
38+
Literal["v2", "v3"],
39+
BeforeValidator(_normalize_api_version),
40+
]
1241

1342

1443
class HuntIoConfig(BaseConfigModel):
1544
api_base_url: HttpUrl = Field(
1645
description="Hunt.io API endpoint URL for the C2 threat intelligence feeds",
1746
default=HttpUrl("https://api.hunt.io/v1/feeds/c2"),
1847
)
48+
api_version: ApiVersion = Field(
49+
description=(
50+
"Which Hunt.io C2 feed API to target. 'v2' authenticates with a 'token' "
51+
"header against https://api.hunt.io/v1/feeds/c2. 'v3' authenticates with "
52+
"'Authorization: Bearer' against https://a.hunt.io/feeds/c2 and requires an "
53+
"'ak_'-prefixed key. The two APIs are mutually exclusive: set api_base_url "
54+
"to match the version, as changing one without the other returns HTTP 401"
55+
),
56+
default=DEFAULT_API_VERSION,
57+
)
1958
api_key: SecretStr = Field(
2059
description=(
2160
"Authentication key for accessing the Hunt.io API. "
@@ -32,6 +71,25 @@ class HuntIoConfig(BaseConfigModel):
3271
)
3372
)
3473

74+
@model_validator(mode="after")
75+
def _validate_api_key_matches_version(self) -> "HuntIoConfig":
76+
"""Fail fast when a V3 key is malformed.
77+
78+
The V3 API rejects any key without an `ak_` prefix using the same opaque 401 it
79+
returns for a missing key, which makes a typo indistinguishable from an
80+
entitlement problem at runtime. V2 has no documented prefix rule, so this check
81+
is deliberately scoped to V3 only.
82+
"""
83+
if self.api_version == "v3" and not self.api_key.get_secret_value().startswith(
84+
"ak_"
85+
):
86+
raise ValueError(
87+
"api_version 'v3' requires an 'ak_'-prefixed API key; the V3 API "
88+
"rejects other keys with an HTTP 401 indistinguishable from a "
89+
"missing key"
90+
)
91+
return self
92+
3593

3694
class ExternalImportConfig(BaseExternalImportConnectorConfig):
3795
name: str = Field(

external-import/hunt-io/tests/conftest.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,48 @@ def correct_config():
3131
yield
3232

3333

34+
@pytest.fixture
35+
def v3_config():
36+
with patch(
37+
"os.environ",
38+
{
39+
"OPENCTI_URL": "http://url",
40+
"OPENCTI_TOKEN": "token",
41+
"CONNECTOR_ID": "connector_id",
42+
"CONNECTOR_NAME": "connector_name",
43+
"CONNECTOR_TYPE": "EXTERNAL_IMPORT",
44+
"CONNECTOR_LOG_LEVEL": "error",
45+
"CONNECTOR_SCOPE": "scope",
46+
"CONNECTOR_DURATION_PERIOD": "PT5M",
47+
"HUNT_IO_API_VERSION": "v3",
48+
"HUNT_IO_API_BASE_URL": "https://a.hunt.io/feeds/c2",
49+
"HUNT_IO_API_KEY": "ak_api_key_value",
50+
},
51+
):
52+
yield
53+
54+
55+
@pytest.fixture
56+
def deprecated_v3_config():
57+
with patch(
58+
"os.environ",
59+
{
60+
"OPENCTI_URL": "http://url",
61+
"OPENCTI_TOKEN": "token",
62+
"CONNECTOR_ID": "connector_id",
63+
"CONNECTOR_NAME": "connector_name",
64+
"CONNECTOR_TYPE": "EXTERNAL_IMPORT",
65+
"CONNECTOR_LOG_LEVEL": "error",
66+
"CONNECTOR_SCOPE": "scope",
67+
"CONNECTOR_DURATION_PERIOD": "PT5M",
68+
"CONNECTOR_HUNT_IO_API_VERSION": "v3",
69+
"CONNECTOR_HUNT_IO_API_BASE_URL": "https://a.hunt.io/feeds/c2",
70+
"CONNECTOR_HUNT_IO_API_KEY": "ak_api_key_value",
71+
},
72+
):
73+
yield
74+
75+
3476
@pytest.fixture
3577
def deprecated_config():
3678
with patch(
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
from unittest.mock import MagicMock
2+
3+
import pytest
4+
from external_import_connector.client_api import ConnectorClient, HTTPSessionManager
5+
from external_import_connector.settings import ConnectorSettings
6+
7+
V2_KEY = "api_key_value"
8+
V3_KEY = "ak_api_key_value"
9+
10+
11+
@pytest.mark.parametrize(
12+
"api_version, api_key, expected_header, expected_value, absent_header",
13+
[
14+
pytest.param("v2", V2_KEY, "token", V2_KEY, "Authorization", id="v2_token"),
15+
pytest.param(
16+
"v3",
17+
V3_KEY,
18+
"Authorization",
19+
f"Bearer {V3_KEY}",
20+
"token",
21+
id="v3_bearer",
22+
),
23+
],
24+
)
25+
def test_create_session_sets_auth_header_matching_api_version(
26+
api_version, api_key, expected_header, expected_value, absent_header
27+
):
28+
"""
29+
The two Hunt.io APIs use mutually exclusive auth schemes and each answers 401 when
30+
sent the other one's header, so the session must carry exactly one of them.
31+
"""
32+
session_manager = HTTPSessionManager(MagicMock())
33+
34+
session = session_manager.create_session(api_key, api_version)
35+
36+
assert session.headers[expected_header] == expected_value
37+
assert absent_header not in session.headers
38+
39+
40+
def test_api_version_is_accepted_through_deprecated_namespace(deprecated_v3_config):
41+
"""
42+
`api_version` is a new field, so its deprecated spellings exist only because the SDK
43+
namespace shim forwards every key. Guard that, since the naming decision relies on it.
44+
"""
45+
client = ConnectorClient(helper=MagicMock(), config=ConnectorSettings())
46+
47+
assert client.session.headers["Authorization"] == f"Bearer {V3_KEY}"
48+
49+
50+
def test_refresh_session_on_timeout_keeps_v3_bearer_header(v3_config):
51+
"""
52+
`_refresh_session_on_timeout` rebuilds the session independently of `__init__`, so a
53+
missed argument there would silently drop authentication after a connection timeout.
54+
"""
55+
client = ConnectorClient(helper=MagicMock(), config=ConnectorSettings())
56+
assert client.session.headers["Authorization"] == f"Bearer {V3_KEY}"
57+
58+
client._refresh_session_on_timeout()
59+
60+
assert client.session.headers["Authorization"] == f"Bearer {V3_KEY}"
61+
assert "token" not in client.session.headers

0 commit comments

Comments
 (0)