diff --git a/fact-ebpf/src/bpf/events.h b/fact-ebpf/src/bpf/events.h index fe521450..48222193 100644 --- a/fact-ebpf/src/bpf/events.h +++ b/fact-ebpf/src/bpf/events.h @@ -143,3 +143,16 @@ __always_inline static void submit_mkdir_event(struct metrics_by_hook_t* m, // d_instantiate doesn't support bpf_d_path, so we use false and rely on the stashed path from path_mkdir __submit_event(event, m, DIR_ACTIVITY_CREATION, filename, inode, parent_inode, false); } + +__always_inline static void submit_rmdir_event(struct metrics_by_hook_t* m, + const char filename[PATH_MAX], + inode_key_t* inode, + inode_key_t* parent_inode) { + struct event_t* event = bpf_ringbuf_reserve(&rb, sizeof(struct event_t), 0); + if (event == NULL) { + m->ringbuffer_full++; + return; + } + + __submit_event(event, m, DIR_ACTIVITY_UNLINK, filename, inode, parent_inode, path_hooks_support_bpf_d_path); +} diff --git a/fact-ebpf/src/bpf/main.c b/fact-ebpf/src/bpf/main.c index d262f2d8..97a6b52f 100644 --- a/fact-ebpf/src/bpf/main.c +++ b/fact-ebpf/src/bpf/main.c @@ -326,3 +326,36 @@ int BPF_PROG(trace_d_instantiate, struct dentry* dentry, struct inode* inode) { bpf_map_delete_elem(&mkdir_context, &pid_tgid); return 0; } + +SEC("lsm/path_rmdir") +int BPF_PROG(trace_path_rmdir, struct path* dir, struct dentry* dentry) { + struct metrics_t* m = get_metrics(); + if (m == NULL) { + return 0; + } + + m->path_rmdir.total++; + + struct bound_path_t* path = path_read_append_d_entry(dir, dentry); + if (path == NULL) { + bpf_printk("Failed to read directory path"); + m->path_rmdir.error++; + return 0; + } + + inode_key_t inode_key = inode_to_key(dentry->d_inode); + inode_key_t* inode_to_submit = &inode_key; + + if (is_monitored(inode_key, path, NULL, &inode_to_submit) == NOT_MONITORED) { + m->path_rmdir.ignored++; + return 0; + } + + inode_remove(&inode_key); + + submit_rmdir_event(&m->path_rmdir, + path->path, + inode_to_submit, + NULL); + return 0; +} diff --git a/fact-ebpf/src/bpf/types.h b/fact-ebpf/src/bpf/types.h index 95c67e86..9a395da4 100644 --- a/fact-ebpf/src/bpf/types.h +++ b/fact-ebpf/src/bpf/types.h @@ -56,6 +56,7 @@ typedef enum file_activity_type_t { FILE_ACTIVITY_CHOWN, FILE_ACTIVITY_RENAME, DIR_ACTIVITY_CREATION, + DIR_ACTIVITY_UNLINK, } file_activity_type_t; struct event_t { @@ -120,4 +121,5 @@ struct metrics_t { struct metrics_by_hook_t path_rename; struct metrics_by_hook_t path_mkdir; struct metrics_by_hook_t d_instantiate; + struct metrics_by_hook_t path_rmdir; }; diff --git a/fact-ebpf/src/lib.rs b/fact-ebpf/src/lib.rs index c4c95ec6..ba84fb01 100644 --- a/fact-ebpf/src/lib.rs +++ b/fact-ebpf/src/lib.rs @@ -126,6 +126,7 @@ impl metrics_t { m.path_chown = m.path_chown.accumulate(&other.path_chown); m.path_rename = m.path_rename.accumulate(&other.path_rename); m.path_mkdir = m.path_mkdir.accumulate(&other.path_mkdir); + m.path_rmdir = m.path_rmdir.accumulate(&other.path_rmdir); m.d_instantiate = m.d_instantiate.accumulate(&other.d_instantiate); m } diff --git a/fact/src/event/mod.rs b/fact/src/event/mod.rs index 1c87a859..1b346548 100644 --- a/fact/src/event/mod.rs +++ b/fact/src/event/mod.rs @@ -134,8 +134,12 @@ impl Event { matches!(self.file, FileData::MkDir(_)) } + pub fn is_rmdir(&self) -> bool { + matches!(self.file, FileData::RmDir(_)) + } + pub fn is_unlink(&self) -> bool { - matches!(self.file, FileData::Unlink(_)) + matches!(self.file, FileData::Unlink(_) | FileData::RmDir(_)) } /// Unwrap the inner FileData and return the inode that triggered @@ -148,6 +152,7 @@ impl Event { FileData::Open(data) => &data.inode, FileData::Creation(data) => &data.inode, FileData::MkDir(data) => &data.inode, + FileData::RmDir(data) => &data.inode, FileData::Unlink(data) => &data.inode, FileData::Chmod(data) => &data.inner.inode, FileData::Chown(data) => &data.inner.inode, @@ -161,6 +166,7 @@ impl Event { FileData::Open(data) => &data.parent_inode, FileData::Creation(data) => &data.parent_inode, FileData::MkDir(data) => &data.parent_inode, + FileData::RmDir(data) => &data.parent_inode, FileData::Unlink(data) => &data.parent_inode, FileData::Chmod(data) => &data.inner.parent_inode, FileData::Chown(data) => &data.inner.parent_inode, @@ -183,6 +189,7 @@ impl Event { FileData::Open(data) => &data.filename, FileData::Creation(data) => &data.filename, FileData::MkDir(data) => &data.filename, + FileData::RmDir(data) => &data.filename, FileData::Unlink(data) => &data.filename, FileData::Chmod(data) => &data.inner.filename, FileData::Chown(data) => &data.inner.filename, @@ -202,6 +209,7 @@ impl Event { FileData::Open(data) => &data.host_file, FileData::Creation(data) => &data.host_file, FileData::MkDir(data) => &data.host_file, + FileData::RmDir(data) => &data.host_file, FileData::Unlink(data) => &data.host_file, FileData::Chmod(data) => &data.inner.host_file, FileData::Chown(data) => &data.inner.host_file, @@ -218,6 +226,7 @@ impl Event { FileData::Open(data) => data.host_file = host_path, FileData::Creation(data) => data.host_file = host_path, FileData::MkDir(data) => data.host_file = host_path, + FileData::RmDir(data) => data.host_file = host_path, FileData::Unlink(data) => data.host_file = host_path, FileData::Chmod(data) => data.inner.host_file = host_path, FileData::Chown(data) => data.inner.host_file = host_path, @@ -303,6 +312,7 @@ pub enum FileData { Open(BaseFileData), Creation(BaseFileData), MkDir(BaseFileData), + RmDir(BaseFileData), Unlink(BaseFileData), Chmod(ChmodFileData), Chown(ChownFileData), @@ -322,6 +332,7 @@ impl FileData { file_activity_type_t::FILE_ACTIVITY_OPEN => FileData::Open(inner), file_activity_type_t::FILE_ACTIVITY_CREATION => FileData::Creation(inner), file_activity_type_t::DIR_ACTIVITY_CREATION => FileData::MkDir(inner), + file_activity_type_t::DIR_ACTIVITY_UNLINK => FileData::RmDir(inner), file_activity_type_t::FILE_ACTIVITY_UNLINK => FileData::Unlink(inner), file_activity_type_t::FILE_ACTIVITY_CHMOD => { let data = ChmodFileData { @@ -373,6 +384,9 @@ impl From for fact_api::file_activity::File { FileData::MkDir(_) => { unreachable!("MkDir event reached protobuf conversion"); } + FileData::RmDir(_) => { + unreachable!("RmDir event reached protobuf conversion"); + } FileData::Unlink(event) => { let activity = Some(fact_api::FileActivityBase::from(event)); let f_act = fact_api::FileUnlink { activity }; @@ -401,6 +415,7 @@ impl PartialEq for FileData { (FileData::Open(this), FileData::Open(other)) => this == other, (FileData::Creation(this), FileData::Creation(other)) => this == other, (FileData::MkDir(this), FileData::MkDir(other)) => this == other, + (FileData::RmDir(this), FileData::RmDir(other)) => this == other, (FileData::Unlink(this), FileData::Unlink(other)) => this == other, (FileData::Chmod(this), FileData::Chmod(other)) => this == other, (FileData::Rename(this), FileData::Rename(other)) => this == other, diff --git a/fact/src/host_scanner.rs b/fact/src/host_scanner.rs index 7c9b5a81..075cc870 100644 --- a/fact/src/host_scanner.rs +++ b/fact/src/host_scanner.rs @@ -295,8 +295,8 @@ impl HostScanner { self.handle_unlink_event(&event); } - // Skip directory creation events - we track them internally but don't send to sensor - if event.is_mkdir() { + // Skip directory creation and deletion events - we track them internally but don't send to sensor + if event.is_mkdir() || event.is_rmdir() { continue; } diff --git a/fact/src/metrics/kernel_metrics.rs b/fact/src/metrics/kernel_metrics.rs index 9caa1ff3..15da3993 100644 --- a/fact/src/metrics/kernel_metrics.rs +++ b/fact/src/metrics/kernel_metrics.rs @@ -14,6 +14,7 @@ pub struct KernelMetrics { path_chown: EventCounter, path_rename: EventCounter, path_mkdir: EventCounter, + path_rmdir: EventCounter, d_instantiate: EventCounter, map: PerCpuArray, } @@ -50,6 +51,11 @@ impl KernelMetrics { "Events processed by the path_mkdir LSM hook", &[], // Labels are not needed since `collect` will add them all ); + let path_rmdir = EventCounter::new( + "kernel_path_rmdir_events", + "Events processed by the path_rmdir LSM hook", + &[], // Labels are not needed since `collect` will add them all + ); let d_instantiate = EventCounter::new( "kernel_d_instantiate_events", "Events processed by the d_instantiate LSM hook", @@ -62,6 +68,7 @@ impl KernelMetrics { path_chown.register(reg); path_rename.register(reg); path_mkdir.register(reg); + path_rmdir.register(reg); d_instantiate.register(reg); KernelMetrics { @@ -71,6 +78,7 @@ impl KernelMetrics { path_chown, path_rename, path_mkdir, + path_rmdir, d_instantiate, map: kernel_metrics, } @@ -122,6 +130,7 @@ impl KernelMetrics { KernelMetrics::refresh_labels(&self.path_chown, &metrics.path_chown); KernelMetrics::refresh_labels(&self.path_rename, &metrics.path_rename); KernelMetrics::refresh_labels(&self.path_mkdir, &metrics.path_mkdir); + KernelMetrics::refresh_labels(&self.path_rmdir, &metrics.path_rmdir); KernelMetrics::refresh_labels(&self.d_instantiate, &metrics.d_instantiate); Ok(()) diff --git a/tests/test_path_rmdir.py b/tests/test_path_rmdir.py new file mode 100644 index 00000000..3fce265f --- /dev/null +++ b/tests/test_path_rmdir.py @@ -0,0 +1,377 @@ +import os +import shutil +import subprocess + +import pytest + +from event import Event, EventType, Process +from utils import get_metric_value + + +def get_inode_removed_count(fact_config): + """ + Query Prometheus metrics to get the count of removed inodes. + + Args: + fact_config: The fact configuration tuple (config dict, config file path). + + Returns: + The current value of host_scanner_scan{label="InodeRemoved"} metric. + """ + value = get_metric_value(fact_config, "host_scanner_scan", {"label": "InodeRemoved"}) + return int(value) if value is not None else 0 + + +def get_kernel_rmdir_processed(fact_config): + """ + Query Prometheus metrics to get the count of processed (non-ignored) rmdir events. + + Args: + fact_config: The fact configuration tuple (config dict, config file path). + + Returns: + The difference between Total and Ignored kernel_path_rmdir_events. + """ + total_str = get_metric_value(fact_config, "kernel_path_rmdir_events", {"label": "Total"}) + ignored_str = get_metric_value(fact_config, "kernel_path_rmdir_events", {"label": "Ignored"}) + + total = int(total_str) if total_str is not None else 0 + ignored = int(ignored_str) if ignored_str is not None else 0 + + return total - ignored + + +@pytest.mark.parametrize("dirname", [ + pytest.param('testdir', id='ASCII'), + pytest.param('café', id='French'), + pytest.param('файл', id='Cyrillic'), + pytest.param('日本語', id='Japanese'), +]) +def test_rmdir_empty(monitored_dir, server, fact_config, dirname): + """ + Tests that removing an empty directory properly cleans up inode tracking. + + Scenario: File is removed first, leaving an empty directory, then rmdir is called. + + We use exact delta matching because: + - Each test has an isolated monitored_dir + - Periodic scans are disabled (scan_interval: 0) + - No background activity should interfere + + Args: + monitored_dir: Temporary directory path for creating the test directory. + server: The server instance to communicate with. + fact_config: The fact configuration. + dirname: Directory name to test (including UTF-8 variants). + """ + process = Process.from_proc() + + # Get baseline metric counts + initial_inode_removed = get_inode_removed_count(fact_config) + initial_kernel_rmdir = get_kernel_rmdir_processed(fact_config) + + # Create a directory + test_dir = os.path.join(monitored_dir, dirname) + os.mkdir(test_dir) + + # Create a file in it + test_file = os.path.join(test_dir, 'file.txt') + with open(test_file, 'w') as f: + f.write('test content') + + # File creation should be tracked + e1 = Event(process=process, event_type=EventType.CREATION, + file=test_file, host_path=test_file) + + server.wait_events([e1]) + + # Remove the file first, leaving an empty directory + os.remove(test_file) + + # File deletion should be tracked + e2 = Event(process=process, event_type=EventType.UNLINK, + file=test_file, host_path=test_file) + + server.wait_events([e2]) + + # Check that file deletion incremented the metric by exactly 1 + count_after_file = get_inode_removed_count(fact_config) + file_delta = count_after_file - initial_inode_removed + assert file_delta == 1, \ + f"Expected exactly 1 inode removed for file deletion, got {file_delta}" + + # Now remove the empty directory with rmdir + os.rmdir(test_dir) + + # Check metrics after directory deletion + final_inode_removed = get_inode_removed_count(fact_config) + final_kernel_rmdir = get_kernel_rmdir_processed(fact_config) + + inode_delta = final_inode_removed - initial_inode_removed + kernel_delta = final_kernel_rmdir - initial_kernel_rmdir + + assert inode_delta == 2, \ + f"Expected exactly 2 inodes removed (1 file + 1 dir), got {inode_delta}" + assert kernel_delta == 1, \ + f"Expected exactly 1 kernel rmdir event processed, got {kernel_delta}" + + +def test_rmdir_recursive_with_rm(monitored_dir, server, fact_config): + """ + Tests that removing a directory tree recursively cleans up all inode tracking. + + Scenario: Directory with nested subdirectories and files is removed recursively + using the rm -rf command via subprocess. + + This tests that all inodes (both files and directories) are properly removed + from tracking when a tree is deleted by an external process. The rm command + deletes directories eagerly (immediately after they become empty), creating + an interleaved deletion pattern. + + Args: + monitored_dir: Temporary directory path for creating test directories. + server: The server instance to communicate with. + fact_config: The fact configuration. + """ + process = Process.from_proc() + + # Get baseline metric counts + initial_inode_removed = get_inode_removed_count(fact_config) + initial_kernel_rmdir = get_kernel_rmdir_processed(fact_config) + + # Create nested directories + level1 = os.path.join(monitored_dir, 'level1') + level2 = os.path.join(level1, 'level2') + level3 = os.path.join(level2, 'level3') + os.makedirs(level3) + + # Create files at different levels + file1 = os.path.join(level1, 'file1.txt') + file2 = os.path.join(level2, 'file2.txt') + file3 = os.path.join(level3, 'file3.txt') + + with open(file1, 'w') as f: + f.write('level1') + with open(file2, 'w') as f: + f.write('level2') + with open(file3, 'w') as f: + f.write('level3') + + # All files should be tracked + creation_events = [ + Event(process=process, event_type=EventType.CREATION, + file=file1, host_path=file1), + Event(process=process, event_type=EventType.CREATION, + file=file2, host_path=file2), + Event(process=process, event_type=EventType.CREATION, + file=file3, host_path=file3), + ] + + server.wait_events(creation_events) + + # Remove the entire tree recursively using subprocess (like running rm -rf) + # This will generate events for all files and directories + # Order: deepest files/dirs first, then work up to the root + proc = subprocess.Popen(["rm", "-rf", level1]) + + # Capture process info while subprocess is running + rm_process = Process.from_proc(proc.pid) + + # Wait for completion + proc.wait() + if proc.returncode != 0: + raise RuntimeError(f"rm command failed with exit code {proc.returncode}") + + # Wait for file deletion events (rm -rf deletes depth-first) + unlink_events = [ + Event(process=rm_process, event_type=EventType.UNLINK, + file=file3, host_path=file3), + Event(process=rm_process, event_type=EventType.UNLINK, + file=file2, host_path=file2), + Event(process=rm_process, event_type=EventType.UNLINK, + file=file1, host_path=file1), + ] + + server.wait_events(unlink_events) + + # Check that all inodes and kernel events were tracked + final_inode_removed = get_inode_removed_count(fact_config) + final_kernel_rmdir = get_kernel_rmdir_processed(fact_config) + + inode_delta = final_inode_removed - initial_inode_removed + kernel_delta = final_kernel_rmdir - initial_kernel_rmdir + + assert inode_delta == 6, \ + f"Expected exactly 6 inodes removed (3 files + 3 dirs), got {inode_delta}" + assert kernel_delta == 3, \ + f"Expected exactly 3 kernel rmdir events processed, got {kernel_delta}" + + +def test_rmdir_ignored(monitored_dir, ignored_dir, server, fact_config): + """ + Tests that directories removed outside monitored paths don't affect tracking. + + Verifies that inode_removed metric only increments for monitored paths. + + Args: + monitored_dir: Temporary directory path that is monitored. + ignored_dir: Temporary directory path that is not monitored. + server: The server instance to communicate with. + fact_config: The fact configuration. + """ + process = Process.from_proc() + + # Get baseline metric counts + initial_inode_removed = get_inode_removed_count(fact_config) + initial_kernel_rmdir = get_kernel_rmdir_processed(fact_config) + + # Create directory in ignored path + ignored_subdir = os.path.join(ignored_dir, 'ignored_subdir') + os.mkdir(ignored_subdir) + ignored_file = os.path.join(ignored_subdir, 'ignored.txt') + with open(ignored_file, 'w') as f: + f.write('ignored') + + # Remove ignored file and directory - should NOT generate events or increment metrics + os.remove(ignored_file) + os.rmdir(ignored_subdir) + + # Metrics should not have changed + inode_after_ignored = get_inode_removed_count(fact_config) + kernel_after_ignored = get_kernel_rmdir_processed(fact_config) + assert inode_after_ignored == initial_inode_removed, \ + f"Ignored path operations should not increment inode_removed metric" + assert kernel_after_ignored == initial_kernel_rmdir, \ + f"Ignored path operations should not increment kernel_rmdir_processed metric" + + # Create and remove directory in monitored path + monitored_subdir = os.path.join(monitored_dir, 'monitored_subdir') + os.mkdir(monitored_subdir) + monitored_file = os.path.join(monitored_subdir, 'monitored.txt') + with open(monitored_file, 'w') as f: + f.write('monitored') + + # Monitored file creation should generate an event + e1 = Event(process=process, event_type=EventType.CREATION, + file=monitored_file, host_path=monitored_file) + + server.wait_events([e1]) + + # Remove monitored file and directory + os.remove(monitored_file) + os.rmdir(monitored_subdir) + + deletion_events = [ + Event(process=process, event_type=EventType.UNLINK, + file=monitored_file, host_path=monitored_file), + ] + + server.wait_events(deletion_events) + + # Metrics should have incremented by exactly 2 inodes and 1 kernel rmdir + final_inode_removed = get_inode_removed_count(fact_config) + final_kernel_rmdir = get_kernel_rmdir_processed(fact_config) + + inode_delta = final_inode_removed - initial_inode_removed + kernel_delta = final_kernel_rmdir - initial_kernel_rmdir + + assert inode_delta == 2, \ + f"Expected exactly 2 inodes removed from monitored path, got {inode_delta}" + assert kernel_delta == 1, \ + f"Expected exactly 1 kernel rmdir event processed, got {kernel_delta}" + + +def test_rmdir_with_parent_inode(monitored_dir, server, fact_config): + """ + Tests that directory deletion properly handles parent inode relationships. + + This is important because after deleting a subdirectory, the parent directory + should still be tracked and able to track new files created in it. + + Args: + monitored_dir: Temporary directory path for creating test directories. + server: The server instance to communicate with. + fact_config: The fact configuration. + """ + process = Process.from_proc() + + # Get baseline metric counts + initial_inode_removed = get_inode_removed_count(fact_config) + initial_kernel_rmdir = get_kernel_rmdir_processed(fact_config) + + # Create a subdirectory + subdir = os.path.join(monitored_dir, 'subdir') + os.mkdir(subdir) + + # Create a file in the subdirectory + test_file = os.path.join(subdir, 'test.txt') + with open(test_file, 'w') as f: + f.write('content') + + # Verify file creation is tracked + e1 = Event(process=process, event_type=EventType.CREATION, + file=test_file, host_path=test_file) + server.wait_events([e1]) + + # Create another file at the root level (parent directory) + root_file = os.path.join(monitored_dir, 'root.txt') + with open(root_file, 'w') as f: + f.write('root content') + + e2 = Event(process=process, event_type=EventType.CREATION, + file=root_file, host_path=root_file) + server.wait_events([e2]) + + # Remove the subdirectory and its contents + os.remove(test_file) + os.rmdir(subdir) + + # Verify file deletion is tracked + deletion_events = [ + Event(process=process, event_type=EventType.UNLINK, + file=test_file, host_path=test_file), + ] + server.wait_events(deletion_events) + + # Check metrics incremented (file + subdir) + inode_after_subdir = get_inode_removed_count(fact_config) + kernel_after_subdir = get_kernel_rmdir_processed(fact_config) + + inode_delta_subdir = inode_after_subdir - initial_inode_removed + kernel_delta_subdir = kernel_after_subdir - initial_kernel_rmdir + + assert inode_delta_subdir == 2, \ + f"Expected 2 inodes removed (file + subdir), got {inode_delta_subdir}" + assert kernel_delta_subdir == 1, \ + f"Expected 1 kernel rmdir event processed, got {kernel_delta_subdir}" + + # Create a NEW file in the parent directory (monitored_dir) + # This tests that removing the subdirectory didn't corrupt + # the parent directory's inode tracking + new_file = os.path.join(monitored_dir, 'new.txt') + with open(new_file, 'w') as f: + f.write('new content') + + e4 = Event(process=process, event_type=EventType.CREATION, + file=new_file, host_path=new_file) + server.wait_events([e4]) + + # Remove the new file to clean up + os.remove(new_file) + + e5 = Event(process=process, event_type=EventType.UNLINK, + file=new_file, host_path=new_file) + server.wait_events([e5]) + + # Final metric check: should be 3 total inodes (test_file, subdir, new_file) + # and 1 total kernel rmdir (subdir) + final_inode_removed = get_inode_removed_count(fact_config) + final_kernel_rmdir = get_kernel_rmdir_processed(fact_config) + + inode_total_delta = final_inode_removed - initial_inode_removed + kernel_total_delta = final_kernel_rmdir - initial_kernel_rmdir + + assert inode_total_delta == 3, \ + f"Expected 3 inodes removed total, got {inode_total_delta}" + assert kernel_total_delta == 1, \ + f"Expected 1 kernel rmdir event total, got {kernel_total_delta}" diff --git a/tests/utils.py b/tests/utils.py index b99254d9..684632fc 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -1,6 +1,8 @@ import os import re +import requests + def join_path_with_filename(directory, filename): """ @@ -66,8 +68,40 @@ def rust_style_join(args): """ Concatenate arguments after quoting them. Each argument is separated by a single space. - + Args: args: The string to quote """ return ' '.join(rust_style_quote(arg) for arg in args) + + +def get_metric_value(fact_config, metric_name, labels=None): + """ + Query Prometheus metrics endpoint to get the value of a metric. + + Args: + fact_config: The fact configuration tuple (config dict, config file path). + metric_name: Name of the metric to query (e.g., "host_scanner_scan"). + labels: Optional dict of label filters (e.g., {"label": "InodeRemoved"}). + + Returns: + The metric value as a string if found, None otherwise. + """ + config, _ = fact_config + response = requests.get(f'http://{config["endpoint"]["address"]}/metrics') + assert response.status_code == 200 + + labels = labels or {} + + for line in response.text.split('\n'): + if metric_name not in line: + continue + + # Check if all label filters match + if all(f'{k}="{v}"' in line for k, v in labels.items()): + # Format: metric_name{label="value"} 42 + parts = line.split() + if len(parts) >= 2: + return parts[-1] + + return None