Compare commits

...
Author SHA1 Message Date
Janik Besendorf 5d82f01a79 Parallelize URL indicator checks 2026-07-19 17:28:27 +02:00
9 changed files with 239 additions and 40 deletions

No files matched your search

+4 -3
View File
@@ -37,8 +37,10 @@ export MVT_STIX2="/home/user/IOC1.stix2:/home/user/IOC2.stix2"
## Network Access ## Network Access
When checking URL indicators, MVT follows recognized shortened URLs with an When checking URL indicators, MVT follows recognized shortened URLs with an
HTTP `HEAD` request. The following environment variables control these HTTP `HEAD` request. URL checks are deduplicated and run concurrently, with at
requests: most 20 requests in progress at a time. Redirects within an individual URL
chain are still followed sequentially. The following environment variables
control these requests:
- `MVT_NETWORK_ACCESS_ALLOWED` enables or disables network requests. It defaults - `MVT_NETWORK_ACCESS_ALLOWED` enables or disables network requests. It defaults
to `true`. Set it to `false` to prevent MVT from attempting to resolve to `true`. Set it to `false` to prevent MVT from attempting to resolve
@@ -73,4 +75,3 @@ You can automaticallly download the latest public indicator files with the comma
Please [open an issue](https://github.com/mvt-project/mvt/issues/) to suggest new sources of STIX-formatted IOCs. Please [open an issue](https://github.com/mvt-project/mvt/issues/) to suggest new sources of STIX-formatted IOCs.
+8 -2
View File
@@ -36,6 +36,8 @@ class SMS(BackupModule):
if not self.indicators: if not self.indicators:
return return
messages = []
url_batches = []
for message in self.results: for message in self.results:
if "body" not in message: if "body" not in message:
continue continue
@@ -44,12 +46,16 @@ class SMS(BackupModule):
if message_links == []: if message_links == []:
message_links = check_for_links(message.get("text", "")) message_links = check_for_links(message.get("text", ""))
ioc_match = self.indicators.check_urls(message_links) messages.append(message)
url_batches.append(message_links)
for message, ioc_match in zip(
messages, self.indicators.check_url_batches(url_batches)
):
if ioc_match: if ioc_match:
self.alertstore.critical( self.alertstore.critical(
ioc_match.message, "", message, matched_indicator=ioc_match.ioc ioc_match.message, "", message, matched_indicator=ioc_match.ioc
) )
continue
def run(self) -> None: def run(self) -> None:
sms_path = "apps/com.android.providers.telephony/d_f/*_sms_backup" sms_path = "apps/com.android.providers.telephony/d_f/*_sms_backup"
+38 -6
View File
@@ -7,9 +7,10 @@ import glob
import json import json
import logging import logging
import os import os
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass from dataclasses import dataclass
from functools import lru_cache from functools import lru_cache
from typing import Any, Dict, Iterator, List, Optional from typing import Any, Dict, Iterator, List, Optional, Sequence
import ahocorasick import ahocorasick
from appdirs import user_data_dir from appdirs import user_data_dir
@@ -22,6 +23,8 @@ MVT_INDICATORS_FOLDER = os.path.join(MVT_DATA_FOLDER, "indicators")
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
URL_CHECK_MAX_WORKERS = 20
@dataclass @dataclass
class Indicator: class Indicator:
@@ -490,12 +493,41 @@ class Indicators:
if not urls: if not urls:
return None return None
for url in urls: return self.check_url_batches([urls])[0]
check = self.check_url(url)
if check:
return check
return None def check_url_batches(
self, url_batches: Sequence[Optional[Sequence[str]]]
) -> List[Optional[IndicatorMatch]]:
"""Check batches of URLs concurrently while preserving batch order.
URLs are deduplicated across batches before checking. Each returned item
is the first indicator match from the corresponding input batch, using
the original URL order.
"""
batches = [list(urls) if urls else [] for urls in url_batches]
unique_urls = list(
dict.fromkeys(url for urls in batches for url in urls)
)
if not unique_urls:
return [None] * len(batches)
if settings.NETWORK_ACCESS_ALLOWED and len(unique_urls) > 1:
worker_count = min(URL_CHECK_MAX_WORKERS, len(unique_urls))
with ThreadPoolExecutor(max_workers=worker_count) as executor:
url_matches = dict(
zip(unique_urls, executor.map(self.check_url, unique_urls))
)
else:
url_matches = {url: self.check_url(url) for url in unique_urls}
return [
next(
(url_matches[url] for url in urls if url_matches[url] is not None),
None,
)
for urls in batches
]
def check_process(self, process: str) -> Optional[IndicatorMatch]: def check_process(self, process: str) -> Optional[IndicatorMatch]:
"""Check the provided process name against the list of process """Check the provided process name against the list of process
+4 -2
View File
@@ -76,8 +76,10 @@ class Shortcuts(IOSExtraction):
if not self.indicators: if not self.indicators:
return return
for result in self.results: url_batches = [result["action_urls"] for result in self.results]
ioc_match = self.indicators.check_urls(result["action_urls"]) for result, ioc_match in zip(
self.results, self.indicators.check_url_batches(url_batches)
):
if ioc_match: if ioc_match:
self.alertstore.critical( self.alertstore.critical(
ioc_match.message, "", result, matched_indicator=ioc_match.ioc ioc_match.message, "", result, matched_indicator=ioc_match.ioc
+6 -1
View File
@@ -85,12 +85,17 @@ class SMS(IOSExtraction):
if not self.indicators: if not self.indicators:
return return
url_batches = []
for result in self.results: for result in self.results:
message_links = result.get("links", []) message_links = result.get("links", [])
# Making sure not link was ignored # Making sure not link was ignored
if message_links == []: if message_links == []:
message_links = check_for_links(result.get("text", "")) message_links = check_for_links(result.get("text", ""))
ioc_match = self.indicators.check_urls(message_links) url_batches.append(message_links)
for result, ioc_match in zip(
self.results, self.indicators.check_url_batches(url_batches)
):
if ioc_match: if ioc_match:
self.alertstore.critical( self.alertstore.critical(
ioc_match.message, "", result, matched_indicator=ioc_match.ioc ioc_match.message, "", result, matched_indicator=ioc_match.ioc
@@ -70,6 +70,8 @@ class WebkitSessionResourceLog(IOSExtraction):
if not self.indicators: if not self.indicators:
return return
records = []
url_batches = []
for _, entries in self.results.items(): for _, entries in self.results.items():
for entry in entries: for entry in entries:
source_domains = self._extract_domains(entry["redirect_source"]) source_domains = self._extract_domains(entry["redirect_source"])
@@ -94,36 +96,42 @@ class WebkitSessionResourceLog(IOSExtraction):
) )
) )
ioc_match = self.indicators.check_urls(all_origins) records.append((entry, source_domains, destination_domains))
if ioc_match: url_batches.append(all_origins)
self.alertstore.critical(
ioc_match.message, "", entry, matched_indicator=ioc_match.ioc
)
redirect_path = "" for record, ioc_match in zip(
if len(source_domains) > 0: records, self.indicators.check_url_batches(url_batches)
redirect_path += "SOURCE: " ):
for idx, item in enumerate(source_domains): if ioc_match:
source_domains[idx] = f'"{item}"' entry, source_domains, destination_domains = record
self.alertstore.critical(
ioc_match.message, "", entry, matched_indicator=ioc_match.ioc
)
redirect_path += ", ".join(source_domains) redirect_path = ""
redirect_path += " -> " if len(source_domains) > 0:
redirect_path += "SOURCE: "
for idx, item in enumerate(source_domains):
source_domains[idx] = f'"{item}"'
redirect_path += f'ORIGIN: "{entry["origin"]}"' redirect_path += ", ".join(source_domains)
redirect_path += " -> "
if len(destination_domains) > 0: redirect_path += f'ORIGIN: "{entry["origin"]}"'
redirect_path += " -> "
redirect_path += "DESTINATION: "
for idx, item in enumerate(destination_domains):
destination_domains[idx] = f'"{item}"'
redirect_path += ", ".join(destination_domains) if len(destination_domains) > 0:
redirect_path += " -> "
redirect_path += "DESTINATION: "
for idx, item in enumerate(destination_domains):
destination_domains[idx] = f'"{item}"'
self.alertstore.high( redirect_path += ", ".join(destination_domains)
f"Found HTTP redirect between suspicious domains: {redirect_path}",
"", self.alertstore.high(
entry, f"Found HTTP redirect between suspicious domains: {redirect_path}",
) "",
entry,
)
def _extract_browsing_stats(self, log_path): def _extract_browsing_stats(self, log_path):
items = [] items = []
+4 -2
View File
@@ -61,8 +61,10 @@ class Whatsapp(IOSExtraction):
if not self.indicators: if not self.indicators:
return return
for result in self.results: url_batches = [result.get("links", []) for result in self.results]
ioc_match = self.indicators.check_urls(result.get("links", [])) for result, ioc_match in zip(
self.results, self.indicators.check_url_batches(url_batches)
):
if ioc_match: if ioc_match:
self.alertstore.critical( self.alertstore.critical(
ioc_match.message, "", result, matched_indicator=ioc_match.ioc ioc_match.message, "", result, matched_indicator=ioc_match.ioc
+115
View File
@@ -5,7 +5,9 @@
import logging import logging
import os import os
import threading
import requests
from mvt.common.config import settings from mvt.common.config import settings
from mvt.common.indicators import Indicators from mvt.common.indicators import Indicators
@@ -88,6 +90,119 @@ class TestIndicators:
assert ind.check_url("https://goo.gl/maps/example") is None assert ind.check_url("https://goo.gl/maps/example") is None
head_request.assert_not_called() head_request.assert_not_called()
def test_check_url_batches_preserves_order(self, indicator_file):
ind = Indicators(log=logging)
ind.load_indicators_files([indicator_file], load_default=False)
matches = ind.check_url_batches(
[
[
"https://github.com",
"http://example.com/thisisbad",
"https://www.example.org/foobar",
],
["https://github.com", "https://www.example.org/foobar"],
[],
None,
]
)
assert matches[0]
assert matches[0].ioc.value == "http://example.com/thisisbad"
assert matches[1]
assert matches[1].ioc.value == "example.org"
assert matches[2] is None
assert matches[3] is None
def test_check_url_batches_deduplicates_and_limits_workers(
self, indicator_file, mocker
):
ind = Indicators(log=logging)
ind.load_indicators_files([indicator_file], load_default=False)
mocker.patch("mvt.common.indicators.URL_CHECK_MAX_WORKERS", 2)
barrier = threading.Barrier(2)
lock = threading.Lock()
calls = []
active = 0
max_active = 0
def head_request(url, timeout):
nonlocal active, max_active
with lock:
calls.append(url)
active += 1
max_active = max(max_active, active)
call_number = len(calls)
try:
if call_number <= 2:
barrier.wait(timeout=5)
return mocker.Mock(status_code=200, headers={})
finally:
with lock:
active -= 1
mocker.patch("mvt.common.url.requests.head", side_effect=head_request)
urls = [
"https://bit.ly/one",
"https://tinyurl.com/two",
"https://t.co/three",
]
assert ind.check_url_batches([urls, [urls[0]]]) == [None, None]
assert sorted(calls) == sorted(urls)
assert max_active == 2
def test_check_url_batches_respects_disabled_network(
self, indicator_file, mocker
):
ind = Indicators(log=logging)
ind.load_indicators_files([indicator_file], load_default=False)
mocker.patch("mvt.common.indicators.settings.NETWORK_ACCESS_ALLOWED", False)
head_request = mocker.patch("mvt.common.url.requests.head")
assert ind.check_url_batches([["https://bit.ly/example"]]) == [None]
head_request.assert_not_called()
def test_check_url_batches_handles_nested_redirects_and_request_failures(
self, indicator_file, mocker
):
ind = Indicators(log=logging)
ind.load_indicators_files([indicator_file], load_default=False)
def head_request(url, timeout):
if url == "https://bit.ly/failure":
raise requests.Timeout()
if url == "https://tinyurl.com/nested":
return mocker.Mock(
status_code=301,
headers={"Location": "https://t.co/nested"},
)
if url == "https://t.co/nested":
return mocker.Mock(
status_code=302,
headers={"Location": "https://www.example.org/landing"},
)
raise AssertionError(f"Unexpected URL: {url}")
head = mocker.patch(
"mvt.common.url.requests.head", side_effect=head_request
)
matches = ind.check_url_batches(
[["https://bit.ly/failure"], ["https://tinyurl.com/nested"]]
)
assert matches[0] is None
assert matches[1]
assert matches[1].ioc.value == "example.org"
assert {call.args[0] for call in head.call_args_list} == {
"https://bit.ly/failure",
"https://tinyurl.com/nested",
"https://t.co/nested",
}
def test_check_file_hash(self, indicator_file): def test_check_file_hash(self, indicator_file):
ind = Indicators(log=logging) ind = Indicators(log=logging)
ind.load_indicators_files([indicator_file], load_default=False) ind.load_indicators_files([indicator_file], load_default=False)
+28
View File
@@ -29,3 +29,31 @@ class TestSMSModule:
m.indicators = ind m.indicators = ind
run_module(m) run_module(m)
assert len(m.alertstore.alerts) == 1 assert len(m.alertstore.alerts) == 1
def test_detection_batches_urls_and_preserves_event(self, indicator_file, mocker):
results = [
{
"text": "first",
"links": ["http://example.com/thisisbad"],
},
{
"text": "second",
"links": ["https://github.com"],
},
]
m = SMS(results=results)
ind = Indicators(log=logging.getLogger())
ind.parse_stix2(indicator_file)
batch_check = mocker.spy(ind, "check_url_batches")
m.indicators = ind
m.check_indicators()
batch_check.assert_called_once_with(
[
["http://example.com/thisisbad"],
["https://github.com"],
]
)
assert len(m.alertstore.alerts) == 1
assert m.alertstore.alerts[0].event is results[0]