Skip to content

Prepare Tuency bot for new version #2561

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 6 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,11 @@
- `intelmq.bots.experts.securitytxt`:
- Added new bot (PR#2538 by Frank Westers and Sebastian Wagner)
- `intelmq.bots.experts.misp`: Use `PyMISP` class instead of deprecated `ExpandedPyMISP` (PR#2532 by Radek Vyhnal)
- `intelmq.bots.experts.tuency`: (PR#2561 by Kamil Mańkowski)
- Support for querying using `feed.code` and `classification.identifier` (requires Tuency 2.6+),
- Support for customizing fields and the TTL value for suspended sending.
- Support selecting if IP and/or FQDN should be used for querying Tuency.
- Various fixes.
- `intelmq.bots.experts.fake.expert`: New expert to fake data (PR#2567 by Sebastian Wagner).

#### Outputs
Expand Down
53 changes: 47 additions & 6 deletions docs/user/bots.md
Original file line number Diff line number Diff line change
Expand Up @@ -4054,8 +4054,9 @@ addresses and delivery settings for IP objects (addresses, netblocks), Autonomou

- `classification.taxonomy`
- `classification.type`
- `classification.identifier`
- `feed.provider`
- `feed.name`
- `feed.name` or `feed.code`

These fields therefore need to exist, otherwise the message is skipped.

Expand All @@ -4064,17 +4065,20 @@ The API parameter "feed_status" is currently set to "production" constantly, unt
The API answer is processed as following. For the notification interval:

- If *suppress* is true, then `extra.notify` is set to false.
If explicitly configured, a special TTL value can be set.
- Otherwise:
- If the interval is *immediate*, then `extra.ttl` is set to 0.
- Otherwise the interval is converted into seconds and saved in
`extra.ttl`.
- If the interval is *immediate*, then `extra.ttl` is set to 0.
- Otherwise the interval is converted into seconds and saved in
`extra.ttl`.

For the contact lookup: For both fields *ip* and *domain*, the
*destinations* objects are iterated and its *email* fields concatenated to a comma-separated list
in `source.abuse_contact`.

The IntelMQ fields used by this bot may change in the next IntelMQ release, as soon as better suited fields are
available.
For constituency: if provided from Tuency, the list of relvant consitituencies will
be saved comma-separated in the `extra.constituency` field.

The IntelMQ fields used by this bot may be customized by the parameters.

**Module:** `intelmq.bots.experts.tuency.expert`

Expand All @@ -4092,6 +4096,43 @@ available.

(optional, boolean) Whether the existing data in `source.abuse_contact` should be overwritten. Defaults to true.

**`notify_field`**

(optional, string) Name of the field to save information if the message should not be send
(suspension in Tuency). By default `extra.notify`

**`ttl_field`**

(optional, string) Name of the field to save the TTL value (in seconds). By default `extra.ttl`.

**`constituency_field`**

(optional, string) Name of the gield to save information about the consitutuency. By default
`extra.constituency`. If set to empty value, this information won't be saved.

**`query_ip`**

(optional, boolean) Whether the bot should query Tuency based on `source.ip`. By default `true`.

**`query_domain`**

(optional, boolean) Whether the bot should query Tuency based on `source.fqdn`. By default `true`.

**`ttl_on_suspended`**

(optional, integer) Custom value to set as TTL when the sending is suspended. By default
not set - no value will be set at all.

**`query_classification_identifier`**

(optional, boolean) Whether to add `classification.identifier` to the query. Requires
at least Tuency 2.6. By default `False`.

**`query_feed_code`**

(optional, boolean) Whether to query using `feed.code` instead of `feed.name`. Requires
at least Tuency 2.6. By default `False`.

---

### Truncate By Delimiter <div id="intelmq.bots.experts.truncate_by_delimiter.expert" />
Expand Down
135 changes: 108 additions & 27 deletions intelmq/bots/experts/tuency/expert.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""
© 2021 Sebastian Wagner <[email protected]>

SPDX-FileCopyrightText: 2021 Sebastian Wagner <[email protected]>
SPDX-FileCopyrightText: 2025 CERT.at GmbH <https://cert.at/>
SPDX-License-Identifier: AGPL-3.0-or-later

https://gitlab.com/intevation/tuency/tuency/-/blob/master/backend/docs/IntelMQ-API.md
Expand All @@ -26,60 +26,141 @@ class TuencyExpertBot(ExpertBot):
authentication_token: str
overwrite: bool = True

notify_field = "extra.notify"
ttl_field = "extra.ttl"
constituency_field = "extra.constituency"

query_ip = True
query_domain = True

# Allows setting custom TTL for suspended sending
ttl_on_suspended = None

# Non-default values require Tuency v2.6+
query_classification_identifier = False
query_feed_code = False

def init(self):
self.set_request_parameters()
self.session = create_request_session(self)
self.session.headers["Authorization"] = f"Bearer {self.authentication_token}"
self.url = f"{self.url}intelmq/lookup"

if not self.query_ip and not self.query_domain:
self.logger.warning(
"Neither query_ip nor query_domain is set. "
"Bot won't do anything, please ensure it's intended."
)

@staticmethod
def check(parameters):
results = []
if not parameters.get("query_ip", True) and not parameters.get(
"query_domain", True
):
results.append(
[
"warning",
"Neither query_ip nor query_domain is set. "
"Bot won't do anything, please ensure it's intended.",
]
)

return results or None

def process(self):
event = self.receive_message()
if not ("source.ip" in event or "source.fqdn" in event):
self.send_message(event)
self.acknowledge_message()
return

try:
params = {
"classification_taxonomy": event["classification.taxonomy"],
"classification_type": event["classification.type"],
"feed_provider": event["feed.provider"],
"feed_name": event["feed.name"],
"feed_status": "production",
}
if self.query_feed_code:
params["feed_code"] = event["feed.code"]
else:
params["feed_name"] = event["feed.name"]

if self.query_classification_identifier:
params["classification_identifier"] = event["classification.identifier"]
except KeyError as exc:
self.logger.debug('Skipping event because of missing field: %s.', exc)
self.logger.debug("Skipping event because of missing field: %s.", exc)
self.send_message(event)
self.acknowledge_message()
return
try:
params["ip"] = event["source.ip"]
except KeyError:
pass
try:
params["domain"] = event["source.fqdn"]
except KeyError:
pass

response = self.session.get(self.url, params=params).json()
self.logger.debug('Received response %r.', response)
if self.query_ip:
try:
params["ip"] = event["source.ip"]
except KeyError:
pass

if self.query_domain:
try:
params["domain"] = event["source.fqdn"]
except KeyError:
pass

if "ip" not in params and "domain" not in params:
# Nothing to query - skip
self.send_message(event)
self.acknowledge_message()
return

response = self.session.get(self.url, params=params)
self.logger.debug("Received response %r.", response.text)
response = response.json()

destinations = (
response.get("ip", {"destinations": []})["destinations"]
+ response.get("domain", {"destinations": []})["destinations"]
)

if response.get("suppress", False):
event["extra.notify"] = False
event.add(self.notify_field, False, overwrite=self.overwrite)
if self.ttl_on_suspended:
event.add(
self.ttl_field,
self.ttl_on_suspended,
overwrite=self.overwrite,
)
else:
if 'interval' not in response:
if not destinations:
# empty response
self.send_message(event)
self.acknowledge_message()
return
elif response['interval']['unit'] == 'immediate':
event["extra.ttl"] = 0
else:
event["extra.ttl"] = parse_relative(f"{response['interval']['length']} {response['interval']['unit']}") * 60

if "interval" in response:
if response["interval"]["unit"] == "immediate":
event.add(self.ttl_field, 0, overwrite=self.overwrite)
else:
event.add(
self.ttl_field,
(
parse_relative(
f"{response['interval']['length']} {response['interval']['unit']}"
)
* 60
),
overwrite=self.overwrite,
)

contacts = []
for destination in response.get('ip', {'destinations': []})['destinations'] + response.get('domain', {'destinations': []})['destinations']:
contacts.extend(contact['email'] for contact in destination["contacts"])
event.add('source.abuse_contact', ','.join(contacts), overwrite=self.overwrite)
for destination in destinations:
contacts.extend(contact["email"] for contact in destination["contacts"])
event.add("source.abuse_contact", ",".join(contacts), overwrite=self.overwrite)

if self.constituency_field and (
constituencies := response.get("constituencies", [])
):
event.add(
self.constituency_field,
",".join(constituencies),
overwrite=self.overwrite,
)

self.send_message(event)
self.acknowledge_message()
Expand Down
Loading
Loading