mirror of
https://github.com/mvt-project/mvt.git
synced 2026-10-29 17:26:33 +01:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d82f01a79 |
No files matched your search
+4
-3
@@ -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.
|
||||||
|
|
||||||
|
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 = []
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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]
|
||||||
Reference in new issue
Block a user