Skip to content

Commit 0d59d28

Browse files
authored
Merge branch 'develop' into makefile-codestyle
2 parents e1f43de + 791b376 commit 0d59d28

File tree

7 files changed

+267
-0
lines changed

7 files changed

+267
-0
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@
3030
- Fix to avoid schema download if not configured #2530.
3131

3232
#### Experts
33+
- `intelmq.bots.experts.securitytxt`:
34+
- Added new bot (PR#2538 by Frank Westers and Sebastian Wagner)
3335

3436
#### Outputs
3537
- `intelmq.bots.outputs.cif3.output`:

docs/user/bots.md

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3524,6 +3524,56 @@ to true.
35243524
(optional, boolean) Query for IPs at `https://stat.ripe.net/data/maxmind-geo-lite/data.json?resource=%s`. Defaults to
35253525
true.
35263526

3527+
---
3528+
3529+
### SecurityTXT <div id="intelmq.bots.experts.securitytxt.expert" />
3530+
3531+
SecurityTXT is an initiative to standardize how websites publish their abuse contact information.
3532+
It is standardized in [RFC 9116 "A File Format to Aid in Security Vulnerability Disclosure"](https://datatracker.ietf.org/doc/rfc9116/).
3533+
Refer to the linked document RFC for more information on `security.txt`.
3534+
This bot looks for `security.txt` files on a URL or IP, retrieves the primary contact information out of it and adds this to the event.
3535+
3536+
**Requirements**
3537+
3538+
To use this bot, you need to install the required dependencies:
3539+
3540+
```bash
3541+
pip3 install -r intelmq/bots/experts/securitytxt/REQUIREMENTS.txt
3542+
```
3543+
3544+
**Module:** `intelmq.bots.experts.securitytxt.expert`
3545+
3546+
**Parameters**
3547+
3548+
**`url_field`**
3549+
3550+
The field in the event that contains the URL/IP on which to look for the the security.txt file. Default: `source.reverse_dns`
3551+
3552+
**`contact_field`**
3553+
3554+
The field in the event in which to put the found contact details. Default: `source.abuse_contact`
3555+
3556+
**`only_email_address`** (bool)
3557+
3558+
Contact details can be web URLs or email addresses. When this value is set to True, it only selects email addresses as contact information.
3559+
Default: `true`
3560+
3561+
**`overwrite`** (bool)
3562+
3563+
Boolean indicating whether to override existing data in contact_field.
3564+
Default: `true`
3565+
3566+
**`check_expired`** (bool)
3567+
3568+
Boolean indicating whether to check if the security.txt has expired according to its own expiry date.
3569+
Default: `false`
3570+
3571+
**`check_canonical`** (bool)
3572+
3573+
Boolean indicating whether to check if the url is contained in the list of canonical urls.
3574+
Default: `false`
3575+
3576+
35273577
---
35283578

35293579
### Sieve <div id="intelmq.bots.experts.sieve.expert" />
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# SPDX-FileCopyrightText: 2022 Frank Westers, 2024 Institute for Common Good Technology
2+
# SPDX-License-Identifier: AGPL-3.0-or-later
3+
4+
wellknown-securitytxt

intelmq/bots/experts/securitytxt/__init__.py

Whitespace-only changes.
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
# SPDX-FileCopyrightText: 2022 Frank Westers, 2024 Institute for Common Good Technology
2+
#
3+
# SPDX-License-Identifier: AGPL-3.0-or-later
4+
5+
from typing import Optional
6+
7+
import requests
8+
9+
from intelmq.lib.bot import ExpertBot
10+
from intelmq.lib.exceptions import MissingDependencyError
11+
12+
try:
13+
from securitytxt import SecurityTXT
14+
except (ImportError, ModuleNotFoundError):
15+
SecurityTXT = None
16+
17+
18+
class SecurityTXTExpertBot(ExpertBot):
19+
"""
20+
A bot for retrieving contact details from a security.txt
21+
"""
22+
"""
23+
url_field: The field where to find the url which should be searched
24+
contact_field: Field in which to place the found contact details
25+
26+
only_email_address: whether to select only email addresses as contact detail (no web urls)
27+
overwrite: whether to override existing data
28+
check_expired / check_canonical: whether to perform checks on expiry date / canonical urls.
29+
"""
30+
url_field: str = "source.reverse_dns"
31+
contact_field: str = "source.abuse_contact"
32+
33+
only_email_address: bool = True
34+
overwrite: bool = True
35+
check_expired: bool = False
36+
check_canonical: bool = False
37+
38+
def init(self):
39+
if SecurityTXT is None:
40+
raise MissingDependencyError('wellknown-securitytxt')
41+
42+
def process(self):
43+
event = self.receive_message()
44+
45+
try:
46+
self.check_prerequisites(event)
47+
primary_contact = self.get_primary_contact(event.get(self.url_field))
48+
event.add(self.contact_field, primary_contact, overwrite=self.overwrite)
49+
except NotMeetsRequirementsError as e:
50+
self.logger.debug("Skipping event (%s).", e)
51+
except ContactNotFoundError as e:
52+
self.logger.debug("No contact found: %s Continue.", e)
53+
54+
self.send_message(event)
55+
self.acknowledge_message()
56+
57+
def check_prerequisites(self, event) -> None:
58+
"""
59+
Check whether this event should be processed by this bot, or can be skipped.
60+
:param event: The event to evaluate.
61+
"""
62+
if not event.get(self.url_field, False):
63+
raise NotMeetsRequirementsError("The URL field is empty.")
64+
if event.get(self.contact_field, False) and not self.overwrite:
65+
raise NotMeetsRequirementsError("All replace values already set.")
66+
67+
def get_primary_contact(self, url: str) -> Optional[str]:
68+
"""
69+
Given a url, get the file, check it's validity and look for contact details. The primary contact details are
70+
returned. If only_email_address is set to True, it will only return email addresses (no urls).
71+
:param url: The URL on which to look for a security.txt file
72+
:return: The contact information
73+
:raises ContactNotFoundError: if contact cannot be found
74+
"""
75+
try:
76+
securitytxt = SecurityTXT.from_url(url)
77+
if not self.security_txt_is_valid(securitytxt):
78+
raise ContactNotFoundError("SecurityTXT File not valid.")
79+
for contact in securitytxt.contact:
80+
if not self.only_email_address or SecurityTXTExpertBot.is_email_address(contact):
81+
return contact
82+
raise ContactNotFoundError("No contact details found in SecurityTXT.")
83+
except (FileNotFoundError, AttributeError, requests.exceptions.RequestException):
84+
raise ContactNotFoundError("SecurityTXT file could not be found or parsed.")
85+
86+
def security_txt_is_valid(self, securitytxt: SecurityTXT):
87+
"""
88+
Determine whether a security.txt file is valid according to parameters of the bot.
89+
:param securitytxt: The securityTXT object
90+
:return: Whether the securitytxt is valid.
91+
"""
92+
return (not self.check_expired or not securitytxt.expired) and \
93+
(not self.check_canonical or securitytxt.canonical_url())
94+
95+
@staticmethod
96+
def is_email_address(contact: str):
97+
"""
98+
Determine whether the argument is an email address
99+
:param contact: the contact
100+
:return: whether contact is email address
101+
"""
102+
return 'mailto:' in contact or '@' in contact
103+
104+
105+
class NotMeetsRequirementsError(Exception):
106+
pass
107+
108+
109+
class ContactNotFoundError(Exception):
110+
pass
111+
112+
113+
BOT = SecurityTXTExpertBot

intelmq/tests/bots/experts/securitytxt/__init__.py

Whitespace-only changes.
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
# SPDX-FileCopyrightText: 2022 Frank Westers
2+
#
3+
# SPDX-License-Identifier: AGPL-3.0-or-later
4+
5+
# -*- coding: utf-8 -*-
6+
"""
7+
Testing the SecurityTXT Expert Bot
8+
"""
9+
10+
import unittest
11+
12+
import requests_mock
13+
14+
import intelmq.lib.test as test
15+
from intelmq.bots.experts.securitytxt.expert import SecurityTXTExpertBot
16+
17+
EXAMPLE_INPUT_IP = {"__type": "Event",
18+
"source.ip": "192.168.123.123"}
19+
20+
EXPECTED_OUTPUT_IP = {"__type": "Event",
21+
"source.ip": "192.168.123.123",
22+
"source.account": 'test@test.local'}
23+
24+
EXAMPLE_INPUT_FQDN = {"__type": "Event",
25+
"source.fqdn": "test.local"}
26+
27+
EXPECTED_OUTPUT_FQDN = {"__type": "Event",
28+
"source.fqdn": "test.local",
29+
"source.abuse_contact": 'test.local/whitehat'}
30+
31+
EXPECTED_OUTPUT_FQDN_NO_CONTACT = {"__type": "Event",
32+
"source.fqdn": "test.local"}
33+
34+
@requests_mock.Mocker()
35+
@test.skip_exotic()
36+
class TestSecurityTXTExpertBot(test.BotTestCase, unittest.TestCase):
37+
"""
38+
A TestCase for the SecurityTXT Expert Bot
39+
"""
40+
41+
@classmethod
42+
def set_bot(cls):
43+
cls.bot_reference = SecurityTXTExpertBot
44+
45+
def test_ip(self, m: requests_mock.Mocker):
46+
self._run_generic_test(securitytxt_url=f"https://{EXAMPLE_INPUT_IP['source.ip']}/.well-known/security.txt",
47+
securitytxt=f"Contact: {EXPECTED_OUTPUT_IP['source.account']}",
48+
input_message=EXAMPLE_INPUT_IP,
49+
output_message=EXPECTED_OUTPUT_IP,
50+
config={'url_field': 'source.ip', 'contact_field': 'source.account',
51+
'only_email_address': False},
52+
m=m)
53+
54+
def test_fqdn(self, m: requests_mock.Mocker):
55+
self._run_generic_test(securitytxt_url=f"https://{EXAMPLE_INPUT_FQDN['source.fqdn']}/.well-known/security.txt",
56+
securitytxt=f"Contact: {EXPECTED_OUTPUT_FQDN['source.abuse_contact']}",
57+
input_message=EXAMPLE_INPUT_FQDN,
58+
output_message=EXPECTED_OUTPUT_FQDN,
59+
config={'url_field': 'source.fqdn', 'contact_field': 'source.abuse_contact',
60+
'only_email_address': False},
61+
m=m)
62+
63+
def test_only_email_address_true(self, m: requests_mock.Mocker):
64+
self._run_generic_test(securitytxt_url=f"https://{EXAMPLE_INPUT_FQDN['source.fqdn']}/.well-known/security.txt",
65+
securitytxt=f"Contact: {EXPECTED_OUTPUT_FQDN['source.abuse_contact']}",
66+
input_message=EXAMPLE_INPUT_FQDN,
67+
output_message=EXPECTED_OUTPUT_FQDN_NO_CONTACT,
68+
config={'url_field': 'source.fqdn', 'contact_field': 'source.abuse_contact',
69+
'only_email_address': True},
70+
m=m)
71+
72+
def test_expired(self, m: requests_mock.Mocker):
73+
self._run_generic_test(securitytxt_url=f"https://{EXAMPLE_INPUT_FQDN['source.fqdn']}/.well-known/security.txt",
74+
securitytxt=f"Contact: {EXPECTED_OUTPUT_FQDN['source.abuse_contact']}\nExpires: 1900-12-31T18:37:07.000Z",
75+
input_message=EXAMPLE_INPUT_FQDN,
76+
output_message=EXPECTED_OUTPUT_FQDN_NO_CONTACT,
77+
config={'url_field': 'source.fqdn', 'contact_field': 'source.abuse_contact',
78+
'only_email_address': False, 'check_expired': True},
79+
m=m)
80+
81+
def test_not_expired(self, m: requests_mock.Mocker):
82+
self._run_generic_test(securitytxt_url=f"https://{EXAMPLE_INPUT_FQDN['source.fqdn']}/.well-known/security.txt",
83+
securitytxt=f"Contact: {EXPECTED_OUTPUT_FQDN['source.abuse_contact']}\nExpires: 3000-12-31T18:37:07.000Z",
84+
input_message=EXAMPLE_INPUT_FQDN,
85+
output_message=EXPECTED_OUTPUT_FQDN,
86+
config={'url_field': 'source.fqdn', 'contact_field': 'source.abuse_contact',
87+
'only_email_address': False, 'check_expired': True},
88+
m=m)
89+
90+
def _run_generic_test(self, m: requests_mock.Mocker, config: dict, securitytxt_url: str, securitytxt: str,
91+
input_message: dict, output_message: dict):
92+
self.sysconfig = config
93+
self.prepare_bot()
94+
m.get(requests_mock.ANY, status_code=404)
95+
m.get(securitytxt_url, text=securitytxt)
96+
self.input_message = input_message
97+
self.run_bot()
98+
self.assertMessageEqual(0, output_message)

0 commit comments

Comments
 (0)