Skip to content
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
96 changes: 96 additions & 0 deletions timesketch/lib/analyzers/domain_test.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,16 @@
# Copyright 2019 Google Inc. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Tests for DomainPlugin."""

from unittest import mock
Expand All @@ -19,3 +32,86 @@ def test_domain_analyzer_class(self):
analyzer = domain.DomainSketchPlugin(index_name, sketch_id)
self.assertEqual(analyzer.index_name, index_name)
self.assertEqual(analyzer.sketch.id, sketch_id)

@mock.patch("timesketch.lib.analyzers.interface.OpenSearchDataStore", MockDataStore)
def test_domain_analyzer_no_events(self):
"""Test that the analyzer handles an empty event stream gracefully."""
analyzer = domain.DomainSketchPlugin("test", 1, timeline_id=1)
analyzer.datastore.client = mock.Mock()

result = analyzer.run()
self.assertIn("No domains to analyze.", result)

@mock.patch("timesketch.lib.analyzers.interface.OpenSearchDataStore", MockDataStore)
def test_domain_extracted_from_url(self):
"""Test that domains are extracted from the url field when domain is absent."""
analyzer = domain.DomainSketchPlugin("test", 1, timeline_id=1)
analyzer.datastore.client = mock.Mock()

# MockDataStore.search_stream iterates with str(i) keys.
analyzer.datastore.event_store["0"] = {
"_id": "0",
"_index": "test",
"_source": {
"__ts_timeline_id": 1,
"timestamp": 1410593222543942,
"url": "https://www.example.com/some/path?q=1",
},
}

result = analyzer.run()
self.assertIn("1 domains discovered", result)

@mock.patch("timesketch.lib.analyzers.interface.OpenSearchDataStore", MockDataStore)
def test_rare_domain_tagged(self):
"""Test that domains appearing infrequently are counted in the result."""
analyzer = domain.DomainSketchPlugin("test", 1, timeline_id=1)
analyzer.datastore.client = mock.Mock()

# Populate the datastore: one common domain (many hits) and one
# rare domain (a single hit so it falls below the 20th percentile).
# Keys must be sequential str(i) for MockDataStore.search_stream.
for i in range(10):
analyzer.datastore.event_store[str(i)] = {
"_id": str(i),
"_index": "test",
"_source": {
"__ts_timeline_id": 1,
"timestamp": 1410593222543942 + i,
"domain": "common.example.com",
},
}

analyzer.datastore.event_store["10"] = {
"_id": "10",
"_index": "test",
"_source": {
"__ts_timeline_id": 1,
"timestamp": 1410593222543942,
"domain": "rare.example.net",
},
}

result = analyzer.run()
self.assertIn("2 domains discovered", result)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great job setting up MockDataStore and running analyzer.run().

Currently, the assertions only check the high-level summary string returned by run() (e.g., "2 domains discovered"). To ensure that the analyzer is actually modifying the events in the datastore as expected (and to prevent future code changes from silently breaking this), we should assert the actual event tags and attributes too.


@mock.patch("timesketch.lib.analyzers.interface.OpenSearchDataStore", MockDataStore)
def test_cdn_domain_tagged(self):
"""Test that events whose domain is a known CDN are reported."""
analyzer = domain.DomainSketchPlugin("test", 1, timeline_id=1)
analyzer.datastore.client = mock.Mock()

# Keys must be sequential str(i) for MockDataStore.search_stream.
analyzer.datastore.event_store["0"] = {
"_id": "0",
"_index": "test",
"_source": {
"__ts_timeline_id": 1,
"timestamp": 1410593222543942,
# .cloudfront.net is listed in KNOWN_CDN_DOMAINS as Amazon CloudFront.
"domain": "assets.example.cloudfront.net",
},
}

result = analyzer.run()
self.assertIn("1 known CDN networks found.", result)
31 changes: 30 additions & 1 deletion timesketch/lib/analyzers/feature_extraction_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,36 @@ def _config_validation(self, config):
if aggregate:
self.assertIsInstance(aggregate, bool)

# TODO: Add tests for the feature extraction.
# Mock the OpenSearch datastore.
@mock.patch("timesketch.lib.analyzers.interface.OpenSearchDataStore", MockDataStore)
def test_analyzer_properties(self):
"""Test getters and setters of FeatureExtractionSketchPlugin."""
analyzer = FeatureExtractionSketchPlugin("test_index", 1, 1)
analyzer.plugin_name = "test_plugin"
analyzer.feature_name = "test_feature"
analyzer.feature_config = {"test": "config"}

self.assertEqual(analyzer.plugin_name, "test_plugin")
self.assertEqual(analyzer.feature_name, "test_feature")
self.assertEqual(analyzer.feature_config, {"test": "config"})

@mock.patch("timesketch.lib.analyzers.interface.OpenSearchDataStore", MockDataStore)
def test_run_empty_plugin_name(self):
"""Test analyzer run method with empty plugin name."""
analyzer = FeatureExtractionSketchPlugin("test_index", 1, 1)
# plugin_name is None by default
result = analyzer.run()
self.assertEqual(result, "Feature extraction plugin name is empty")

@mock.patch("timesketch.lib.analyzers.interface.OpenSearchDataStore", MockDataStore)
def test_run_unregistered_plugin(self):
"""Test analyzer run method with an unregistered plugin."""
analyzer = FeatureExtractionSketchPlugin("test_index", 1, 1)
analyzer.plugin_name = "unregistered_plugin"
# Since it's unregistered, the PluginManager will return None
result = analyzer.run()
self.assertTrue(result.startswith("Error: Feature extraction plugin"))

def test_config(self):
"""Tests that the config file is valid."""
config_file = os.path.join("data", "regex_features.yaml")
Expand Down
86 changes: 81 additions & 5 deletions timesketch/lib/analyzers/ntfs_timestomp_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,9 +108,85 @@ def test_is_suspicious(self):
self.assertEqual(std_diffs, tc.expected_si_diffs)
self.assertEqual(fn_diffs, tc.expected_fn_diffs)

# Mock the OpenSearch datastore.
@mock.patch("timesketch.lib.analyzers.interface.OpenSearchDataStore", MockDataStore)
def test_analyzer(self):
"""Test analyzer."""
# TODO: Write actual tests here.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The original TODO was sitting under a placeholder test_analyzer(self). The goal of that TODO was to test the analyzer's main run() method (which queries the datastore, groups events, and creates the Timesketch view), rather than just the is_suspicious() helper.

Currently, the run() method is completely untested. Would you be open to helping us write an integration test for it? It would look something like this:

  • Populating the MockDataStore with a couple of mock events (one with attribute_type: 16 and one with 48).
  • Running analyzer.run().
  • Verifying that the events are updated/committed and the NtfsTimestomp view is added.

self.assertEqual(True, True)
def test_is_suspicious_no_std_info(self):
"""Test that FileInfo without a std_info_event is never suspicious."""
analyzer = ntfs_timestomp.NtfsTimestompSketchPlugin("test", 1)
file_info = ntfs_timestomp.FileInfo(
file_reference=1,
timestamp_desc="Content Modification Time",
std_info_event=None,
std_info_timestamp=0,
file_names=[(MockEvent(), 9000000000)],
)
self.assertFalse(analyzer.is_suspicious(file_info))

@mock.patch("timesketch.lib.analyzers.interface.OpenSearchDataStore", MockDataStore)
def test_is_suspicious_no_file_names(self):
"""Test that FileInfo with no file_names is never suspicious."""
analyzer = ntfs_timestomp.NtfsTimestompSketchPlugin("test", 1)
file_info = ntfs_timestomp.FileInfo(
file_reference=1,
timestamp_desc="Content Modification Time",
std_info_event=MockEvent(),
std_info_timestamp=0,
file_names=[],
)
self.assertFalse(analyzer.is_suspicious(file_info))

@mock.patch("timesketch.lib.analyzers.interface.OpenSearchDataStore", MockDataStore)
def test_is_suspicious_within_threshold(self):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't this already covered by the existing test_is_suspicious test? (Case 1, 4, and 5 - The existing tests already thoroughly prove that any analyzer.threshold (which includes analyzer.threshold - 1) is correctly ignored.)

"""Test that file_names within the threshold are not flagged."""
analyzer = ntfs_timestomp.NtfsTimestompSketchPlugin("test", 1)
# threshold is 10 * 60000000 = 600000000 microseconds by default.
# A diff smaller than the threshold must not trigger detection.
fn_event = MockEvent()
file_info = ntfs_timestomp.FileInfo(
file_reference=1,
timestamp_desc="Content Modification Time",
std_info_event=MockEvent(),
std_info_timestamp=0,
file_names=[(fn_event, analyzer.threshold - 1)],
)
self.assertFalse(analyzer.is_suspicious(file_info))
# The time_delta attribute must NOT have been set on the file_name event.
self.assertIsNone(fn_event.source.get("time_delta"))

@mock.patch("timesketch.lib.analyzers.interface.OpenSearchDataStore", MockDataStore)
def test_is_suspicious_timestomped(self):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here, this is also already covered by the test_is_suspicious if I'm not mistaken. (case 2 - It tests the exact same success path and attribute-setting behavior, just with different numbers.)

"""Test that file_names with differences above the threshold are flagged."""
analyzer = ntfs_timestomp.NtfsTimestompSketchPlugin("test", 1)
fn_event = MockEvent()
si_event = MockEvent()
large_diff = analyzer.threshold + 1
file_info = ntfs_timestomp.FileInfo(
file_reference=1,
timestamp_desc="Content Modification Time",
std_info_event=si_event,
std_info_timestamp=0,
file_names=[(fn_event, large_diff)],
)
self.assertTrue(analyzer.is_suspicious(file_info))
# The time_delta attribute must be set on every suspicious file_name.
self.assertEqual(fn_event.source.get("time_delta"), large_diff)
# The accumulated time_deltas must be stored on the STD_INFO event.
self.assertEqual(si_event.source.get("time_deltas"), [large_diff])

@mock.patch("timesketch.lib.analyzers.interface.OpenSearchDataStore", MockDataStore)
def test_is_suspicious_mixed_file_names(self):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here, this seems also to be already covered by test_is_suspicious. (case 3 & 4 - This is the exact same "mixed list with early exit" scenario that the new test is trying to cover.)

"""Test that a single within-threshold file_name prevents detection."""
analyzer = ntfs_timestomp.NtfsTimestompSketchPlugin("test", 1)
fn_event_large = MockEvent()
fn_event_small = MockEvent()
file_info = ntfs_timestomp.FileInfo(
file_reference=1,
timestamp_desc="Content Modification Time",
std_info_event=MockEvent(),
std_info_timestamp=0,
file_names=[
(fn_event_large, analyzer.threshold + 1),
(fn_event_small, analyzer.threshold - 1),
],
)
# One file_name is within threshold β€” detection must be suppressed.
self.assertFalse(analyzer.is_suspicious(file_info))
35 changes: 31 additions & 4 deletions timesketch/lib/datafinder.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,30 @@
"""The class definitions for the data finder, or the data analyzer."""

import logging
from datetime import datetime

from timesketch.lib.analyzers import utils
from timesketch.lib.datastores.opensearch import OpenSearchDataStore

logger = logging.getLogger("timesketch.data_finder")


def _is_valid_iso_date(date_string):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That should work well. Would you mind adding a timesketch/lib/datafinder_test.py file with some unit tests that verify set_start_date() and set_and_date() accept valid ISO dates and correctly log warnings for invalid dates?

"""Check whether a string is a valid ISO 8601-formatted date.

Args:
date_string (str): The string to validate.

Returns:
bool: True if the string is a valid ISO 8601 date, False otherwise.
"""
try:
datetime.fromisoformat(date_string)
return True
except (ValueError, TypeError):
return False


class DataFinder:
"""The data finder class."""

Expand Down Expand Up @@ -69,8 +86,13 @@ def can_run(self):
return True

def set_end_date(self, end_date):
"""Sets the end date of the time period the data finder uses."""
# TODO: Implement a check if this is a valid ISO formatted date.
"""Sets the end date of the time period the data finder uses.

Args:
end_date (str): An ISO 8601-formatted date string.
"""
if not _is_valid_iso_date(end_date):
logger.warning("end_date [%s] is not a valid ISO 8601 date.", end_date)
self._end_date = end_date

def set_indices(self, indices):
Expand Down Expand Up @@ -106,8 +128,13 @@ def set_rule(self, rule_dict):
self._rule = rule_dict

def set_start_date(self, start_date):
"""Sets the start date of the time period the data finder uses."""
# TODO: Implement a check if this is a valid ISO formatted date.
"""Sets the start date of the time period the data finder uses.

Args:
start_date (str): An ISO 8601-formatted date string.
"""
if not _is_valid_iso_date(start_date):
logger.warning("start_date [%s] is not a valid ISO 8601 date.", start_date)
self._start_date = start_date

def set_timeline_ids(self, timeline_ids):
Expand Down