mcp-dfir 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mcp_dfir-0.1.0/PKG-INFO +17 -0
- mcp_dfir-0.1.0/README.md +111 -0
- mcp_dfir-0.1.0/mcp_dfir/__init__.py +0 -0
- mcp_dfir-0.1.0/mcp_dfir/config.py +110 -0
- mcp_dfir-0.1.0/mcp_dfir/constants.py +11 -0
- mcp_dfir-0.1.0/mcp_dfir/data_manager.py +117 -0
- mcp_dfir-0.1.0/mcp_dfir/docker_manager.py +91 -0
- mcp_dfir-0.1.0/mcp_dfir/runner.py +67 -0
- mcp_dfir-0.1.0/mcp_dfir/server.py +75 -0
- mcp_dfir-0.1.0/mcp_dfir/tools/__init__.py +0 -0
- mcp_dfir-0.1.0/mcp_dfir/tools/file_search.py +59 -0
- mcp_dfir-0.1.0/mcp_dfir/tools/file_tools.py +57 -0
- mcp_dfir-0.1.0/mcp_dfir/tools/hash.py +30 -0
- mcp_dfir-0.1.0/mcp_dfir/tools/history.py +70 -0
- mcp_dfir-0.1.0/mcp_dfir/tools/models.py +69 -0
- mcp_dfir-0.1.0/mcp_dfir/tools/notes.py +21 -0
- mcp_dfir-0.1.0/mcp_dfir/tools/sleuthkit.py +118 -0
- mcp_dfir-0.1.0/mcp_dfir/tools/volatility.py +162 -0
- mcp_dfir-0.1.0/mcp_dfir.egg-info/PKG-INFO +17 -0
- mcp_dfir-0.1.0/mcp_dfir.egg-info/SOURCES.txt +25 -0
- mcp_dfir-0.1.0/mcp_dfir.egg-info/dependency_links.txt +1 -0
- mcp_dfir-0.1.0/mcp_dfir.egg-info/entry_points.txt +2 -0
- mcp_dfir-0.1.0/mcp_dfir.egg-info/requires.txt +13 -0
- mcp_dfir-0.1.0/mcp_dfir.egg-info/top_level.txt +1 -0
- mcp_dfir-0.1.0/pyproject.toml +46 -0
- mcp_dfir-0.1.0/setup.cfg +4 -0
- mcp_dfir-0.1.0/tests/test_formatting.py +14 -0
mcp_dfir-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mcp-dfir
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MCP Server for Performing Memory and Disk Forensics
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Requires-Dist: attrs
|
|
7
|
+
Requires-Dist: loguru
|
|
8
|
+
Requires-Dist: mcp
|
|
9
|
+
Requires-Dist: python_on_whales
|
|
10
|
+
Requires-Dist: requests
|
|
11
|
+
Provides-Extra: dev
|
|
12
|
+
Requires-Dist: build; extra == "dev"
|
|
13
|
+
Requires-Dist: mcp[cli]; extra == "dev"
|
|
14
|
+
Requires-Dist: pytest; extra == "dev"
|
|
15
|
+
Requires-Dist: ruff; extra == "dev"
|
|
16
|
+
Requires-Dist: twine; extra == "dev"
|
|
17
|
+
Requires-Dist: uv; extra == "dev"
|
mcp_dfir-0.1.0/README.md
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# MCP DFIR
|
|
2
|
+
|
|
3
|
+
MCP DFIR (Digital Forensics Incident Response) is an MCP server that gives AI agents the tools to perform memory, disk, and artifact forensics. It runs forensics tools inside an isolated Docker container and exposes them over the MCP stdio transport.
|
|
4
|
+
|
|
5
|
+
[Example report](/examples/tryhackme_volatility/analysis/analyst_notes/Case%20002%20-%20Complete%20Incident%20Analysis%20Report.md) based on the TryHackMe Volatility Essentials room.
|
|
6
|
+
|
|
7
|
+
# Features
|
|
8
|
+
|
|
9
|
+
- Memory forensics via [Volatility3](https://github.com/volatilityfoundation/volatility3)
|
|
10
|
+
- Disk forensics via [The Sleuth Kit](https://www.sleuthkit.org/)
|
|
11
|
+
- File search with `strings`, `grep`, and `find`
|
|
12
|
+
- Archive extraction with `unzip` and `tar`
|
|
13
|
+
- Automatic Windows symbol downloading and conversion
|
|
14
|
+
- Linux symbol search and downloading from [Abyss-W4tcher/volatility3-symbols](https://github.com/Abyss-W4tcher/volatility3-symbols)
|
|
15
|
+
- Persistent command history — the agent never re-runs commands already completed
|
|
16
|
+
- Analyst notes — the agent records findings in structured markdown notes
|
|
17
|
+
|
|
18
|
+
# Requirements
|
|
19
|
+
|
|
20
|
+
- [Docker](https://www.docker.com/) must be installed and running
|
|
21
|
+
|
|
22
|
+
# Installation
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install mcp-dfir
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
# Setup
|
|
29
|
+
|
|
30
|
+
## Claude Desktop config
|
|
31
|
+
|
|
32
|
+
Add the following to your Claude Desktop `claude_desktop_config.json`:
|
|
33
|
+
|
|
34
|
+
```json
|
|
35
|
+
{
|
|
36
|
+
"mcpServers": {
|
|
37
|
+
"mcp-dfir": {
|
|
38
|
+
"command": "mcp-dfir"
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Case directory layout
|
|
45
|
+
|
|
46
|
+
The MCP server expects evidence and artifacts to be organized under a case directory:
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
my_case/
|
|
50
|
+
├── evidence/ # Place memory dumps, disk images, etc. here (read-only)
|
|
51
|
+
├── artifacts/ # Output location for extracted files (read-write)
|
|
52
|
+
├── symbols/ # Volatility symbol files (auto-populated on download)
|
|
53
|
+
└── analysis/ # Command history and analyst notes (auto-generated)
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Place all evidence files (`.mem`, `.img`, `.e01`, etc.) inside the `evidence/` directory before starting a session.
|
|
57
|
+
|
|
58
|
+
# Args
|
|
59
|
+
|
|
60
|
+
```
|
|
61
|
+
usage: mcp-dfir [-h] [--version] [-d CASE_DIR]
|
|
62
|
+
|
|
63
|
+
options:
|
|
64
|
+
-h, --help show this help message and exit
|
|
65
|
+
--version show program's version number and exit
|
|
66
|
+
-d, --case-dir CASE_DIR
|
|
67
|
+
Working directory for the case, default: current directory
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
# MCP Tools
|
|
71
|
+
|
|
72
|
+
| Tool | Description |
|
|
73
|
+
| ---------------------------- | -------------------------------------------------------------------- |
|
|
74
|
+
| `run_volatility_command` | Runs Volatility3 (`vol`) for memory forensics |
|
|
75
|
+
| `download_windows_symbol` | Downloads and converts Windows PDB symbols for Volatility3 |
|
|
76
|
+
| `download_linux_symbol` | Downloads a Linux symbol file from Abyss-W4tcher/volatility3-symbols |
|
|
77
|
+
| `search_linux_symbols` | Searches the Linux symbol map by regex to find matching symbol files |
|
|
78
|
+
| `run_sleuthkit_command` | Runs any Sleuth Kit tool for disk forensics |
|
|
79
|
+
| `run_strings` | Runs `strings` on a file |
|
|
80
|
+
| `run_grep` | Runs `grep` on files |
|
|
81
|
+
| `run_find` | Runs `find` to search for files |
|
|
82
|
+
| `list_files` | Lists files in `evidence`, `artifacts`, or `symbols` |
|
|
83
|
+
| `unzip_file` | Extracts a zip archive into `/artifacts` |
|
|
84
|
+
| `untar_file` | Extracts a tar archive into `/artifacts` |
|
|
85
|
+
| `get_hash` | Computes a hash of a file |
|
|
86
|
+
| `get_command_history` | Returns all previously run commands |
|
|
87
|
+
| `get_command_result` | Returns the output of a specific past command |
|
|
88
|
+
| `show_analyst_notes_summary` | Lists all analyst notes |
|
|
89
|
+
| `show_analyst_note` | Retrieves the content of a specific analyst note |
|
|
90
|
+
| `add_analyst_note` | Creates a new analyst note |
|
|
91
|
+
| `update_analyst_note` | Updates an existing analyst note |
|
|
92
|
+
|
|
93
|
+
# Build from source
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
# Create virtual environment
|
|
97
|
+
python3 -m venv .venv
|
|
98
|
+
|
|
99
|
+
# Activate (Linux/macOS)
|
|
100
|
+
source .venv/bin/activate
|
|
101
|
+
# Activate (Windows)
|
|
102
|
+
.venv/Scripts/activate.ps1
|
|
103
|
+
|
|
104
|
+
# Install dependencies
|
|
105
|
+
pip install .[dev]
|
|
106
|
+
|
|
107
|
+
# Run
|
|
108
|
+
mcp-dfir --case-dir /path/to/your/case
|
|
109
|
+
# or
|
|
110
|
+
python3 -m mcp_dfir --case-dir /path/to/your/case
|
|
111
|
+
```
|
|
File without changes
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Defines the config object"""
|
|
2
|
+
|
|
3
|
+
# Standard libraries
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
# Third-party libraries
|
|
7
|
+
from attrs import define, field, validators
|
|
8
|
+
|
|
9
|
+
# Project libraries
|
|
10
|
+
from mcp_dfir.constants import VERSION
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@define
|
|
14
|
+
class DockerVolume:
|
|
15
|
+
name: str = field(validator=validators.instance_of(str))
|
|
16
|
+
internal_path: str = field(validator=validators.instance_of(str))
|
|
17
|
+
external_path: Path = field(validator=validators.instance_of(Path))
|
|
18
|
+
permissions: str = field(validator=validators.instance_of(str))
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@define
|
|
22
|
+
class DockerConfig:
|
|
23
|
+
container_name: str = field(validator=validators.instance_of(str))
|
|
24
|
+
image_name: str = field(validator=validators.instance_of(str))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@define
|
|
28
|
+
class Config:
|
|
29
|
+
working_directory: Path = field(default=Path.cwd(), validator=validators.instance_of(Path))
|
|
30
|
+
|
|
31
|
+
# Directories
|
|
32
|
+
analysis_directory: Path = field(init=False, validator=validators.instance_of(Path))
|
|
33
|
+
artifact_directory: Path = field(init=False, validator=validators.instance_of(Path))
|
|
34
|
+
symbols_directory: Path = field(init=False, validator=validators.instance_of(Path))
|
|
35
|
+
evidence_directory: Path = field(init=False, validator=validators.instance_of(Path))
|
|
36
|
+
|
|
37
|
+
# Command history
|
|
38
|
+
command_history_json: Path = field(init=False, validator=validators.instance_of(Path))
|
|
39
|
+
command_record_directory: Path = field(init=False, validator=validators.instance_of(Path))
|
|
40
|
+
|
|
41
|
+
# Analyst notes
|
|
42
|
+
analyst_notes_list_json: Path = field(init=False, validator=validators.instance_of(Path))
|
|
43
|
+
analyst_notes_directory: Path = field(init=False, validator=validators.instance_of(Path))
|
|
44
|
+
|
|
45
|
+
# Symbols
|
|
46
|
+
windows_symbols_directory: Path = field(init=False, validator=validators.instance_of(Path))
|
|
47
|
+
linux_symbols_directory: Path = field(init=False, validator=validators.instance_of(Path))
|
|
48
|
+
linux_symbol_map_json: Path = field(init=False, validator=validators.instance_of(Path))
|
|
49
|
+
|
|
50
|
+
# Docker
|
|
51
|
+
docker_volumes: list[DockerVolume] = field(
|
|
52
|
+
init=False,
|
|
53
|
+
validator=validators.deep_iterable(
|
|
54
|
+
member_validator=validators.instance_of(DockerVolume), iterable_validator=validators.instance_of(list)
|
|
55
|
+
),
|
|
56
|
+
)
|
|
57
|
+
docker_config: DockerConfig = field(init=False, validator=validators.instance_of(DockerConfig))
|
|
58
|
+
|
|
59
|
+
def load(self, working_directory: Path):
|
|
60
|
+
|
|
61
|
+
# Directories
|
|
62
|
+
self.working_directory = working_directory
|
|
63
|
+
|
|
64
|
+
self.analysis_directory = self.working_directory / "analysis"
|
|
65
|
+
self.analysis_directory.mkdir(mode=500, parents=False, exist_ok=True)
|
|
66
|
+
|
|
67
|
+
self.artifact_directory = self.working_directory / "artifacts"
|
|
68
|
+
self.artifact_directory.mkdir(mode=500, parents=False, exist_ok=True)
|
|
69
|
+
|
|
70
|
+
self.symbols_directory = self.working_directory / "symbols"
|
|
71
|
+
self.symbols_directory.mkdir(mode=500, parents=False, exist_ok=True)
|
|
72
|
+
|
|
73
|
+
self.evidence_directory = self.working_directory / "evidence"
|
|
74
|
+
self.evidence_directory.mkdir(mode=500, parents=False, exist_ok=True)
|
|
75
|
+
|
|
76
|
+
# Command history
|
|
77
|
+
self.command_history_json = self.analysis_directory / "command_history.json"
|
|
78
|
+
self.command_record_directory = self.analysis_directory / "command_history"
|
|
79
|
+
|
|
80
|
+
# Analyst notes
|
|
81
|
+
self.analyst_notes_list_json = self.analysis_directory / "analyst_notes.json"
|
|
82
|
+
self.analyst_notes_directory = self.analysis_directory / "analyst_notes"
|
|
83
|
+
|
|
84
|
+
# Symbols
|
|
85
|
+
self.windows_symbols_directory = self.symbols_directory / "windows"
|
|
86
|
+
self.linux_symbols_directory = self.symbols_directory / "linux"
|
|
87
|
+
self.linux_symbol_map_json = self.linux_symbols_directory / "symbol_map.json"
|
|
88
|
+
|
|
89
|
+
# Docker
|
|
90
|
+
self.docker_volumes = [
|
|
91
|
+
DockerVolume(
|
|
92
|
+
name="evidence", internal_path="/evidence", external_path=self.evidence_directory, permissions="ro"
|
|
93
|
+
),
|
|
94
|
+
DockerVolume(
|
|
95
|
+
name="analysis", internal_path="/analysis", external_path=self.analysis_directory, permissions="ro"
|
|
96
|
+
),
|
|
97
|
+
DockerVolume(
|
|
98
|
+
name="symbols", internal_path="/symbols", external_path=self.symbols_directory, permissions="rw"
|
|
99
|
+
),
|
|
100
|
+
DockerVolume(
|
|
101
|
+
name="artifacts", internal_path="/artifacts", external_path=self.artifact_directory, permissions="rw"
|
|
102
|
+
),
|
|
103
|
+
]
|
|
104
|
+
self.docker_config = DockerConfig(
|
|
105
|
+
container_name=f"{self.working_directory.name.replace(' ', '_')}-forensics",
|
|
106
|
+
image_name=f"androsh7/forensics-docker:{VERSION}",
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
config = Config()
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Defines constants"""
|
|
2
|
+
|
|
3
|
+
VERSION = "0.1.0"
|
|
4
|
+
|
|
5
|
+
# Volatility symbols
|
|
6
|
+
WINDOWS_SYMBOL_SERVER = "https://msdl.microsoft.com/download/symbols"
|
|
7
|
+
LINUX_SYMBOL_REPO_BASE_URL = "https://raw.githubusercontent.com/Abyss-W4tcher/volatility3-symbols/refs/heads/master"
|
|
8
|
+
LINUX_SYMBOL_MAP_SOURCE = f"{LINUX_SYMBOL_REPO_BASE_URL}/banners/banners_plain.json"
|
|
9
|
+
|
|
10
|
+
# Command output limit
|
|
11
|
+
COMMAND_MAX_OUTPUT_BYTES = 25 * 1024 # 2 KB
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Defines the data manager class"""
|
|
2
|
+
|
|
3
|
+
# Standard libraries
|
|
4
|
+
import json
|
|
5
|
+
|
|
6
|
+
# Third-party libraries
|
|
7
|
+
from pydantic import BaseModel, Field
|
|
8
|
+
|
|
9
|
+
# Project libraries
|
|
10
|
+
from mcp_dfir.config import config
|
|
11
|
+
from mcp_dfir.tools.models import AnalystNote, AnalystNoteSummary, CommandRecord, CommandRecordSummary
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class CommandHistoryManager(BaseModel):
|
|
15
|
+
command_summary_list: list[CommandRecordSummary] = Field(default_factory=list, init=False)
|
|
16
|
+
|
|
17
|
+
def model_post_init(self, __context):
|
|
18
|
+
# Load commands
|
|
19
|
+
if config.command_history_json.exists():
|
|
20
|
+
self.load()
|
|
21
|
+
config.command_record_directory.mkdir(mode=500, parents=True, exist_ok=True)
|
|
22
|
+
|
|
23
|
+
def dump(self, command: CommandRecord | None, command_number: int | None = None):
|
|
24
|
+
# Write to command history
|
|
25
|
+
with open(file=config.command_history_json, mode="w", encoding="utf-8") as command_summary_file:
|
|
26
|
+
json.dump(
|
|
27
|
+
[record.model_dump() for record in self.command_summary_list],
|
|
28
|
+
command_summary_file,
|
|
29
|
+
indent=4,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
# Write to command file
|
|
33
|
+
if command is not None and command_number is not None:
|
|
34
|
+
with open(
|
|
35
|
+
file=config.command_record_directory / f"{command_number}.txt", mode="w", encoding="utf-8"
|
|
36
|
+
) as command_file:
|
|
37
|
+
command_file.write(command.result)
|
|
38
|
+
|
|
39
|
+
def load(self):
|
|
40
|
+
with open(file=config.command_history_json, encoding="utf-8") as command_file:
|
|
41
|
+
for command_summary_dict in json.load(command_file):
|
|
42
|
+
self.command_summary_list.append(CommandRecordSummary.model_validate(command_summary_dict))
|
|
43
|
+
|
|
44
|
+
def add_command(self, command_record: CommandRecord) -> CommandRecordSummary:
|
|
45
|
+
number = len(self.command_summary_list) + 1
|
|
46
|
+
summary = command_record.summary(number=number)
|
|
47
|
+
self.command_summary_list.append(summary)
|
|
48
|
+
self.dump(command_record, number)
|
|
49
|
+
return summary
|
|
50
|
+
|
|
51
|
+
def get_command(self, command_number: int) -> CommandRecord:
|
|
52
|
+
for command in self.command_summary_list:
|
|
53
|
+
if command.number == command_number:
|
|
54
|
+
with open(file=config.command_record_directory / f"{command_number}.txt", encoding="utf-8") as file:
|
|
55
|
+
return CommandRecord(
|
|
56
|
+
command=command.command,
|
|
57
|
+
date_run=command.date_run,
|
|
58
|
+
result=file.read(),
|
|
59
|
+
exit_code=command.exit_code,
|
|
60
|
+
)
|
|
61
|
+
raise KeyError(f"Could not find command with number {command_number}")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class AnalystNoteManager(BaseModel):
|
|
65
|
+
note_list: list[AnalystNoteSummary] = Field(default_factory=list, init=False)
|
|
66
|
+
|
|
67
|
+
def model_post_init(self, __context):
|
|
68
|
+
if config.analyst_notes_list_json.exists():
|
|
69
|
+
self.load()
|
|
70
|
+
config.analyst_notes_directory.mkdir(mode=500, exist_ok=True)
|
|
71
|
+
|
|
72
|
+
def dump(self, note: AnalystNote | None):
|
|
73
|
+
# Update note summary
|
|
74
|
+
with open(file=config.analyst_notes_list_json, mode="w", encoding="utf-8") as note_summary_file:
|
|
75
|
+
json.dump(
|
|
76
|
+
[note.model_dump() for note in self.note_list],
|
|
77
|
+
note_summary_file,
|
|
78
|
+
indent=4,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
# Write to note file
|
|
82
|
+
if note is not None:
|
|
83
|
+
with open(
|
|
84
|
+
file=config.analyst_notes_directory / f"{note.title}.md", mode="w", encoding="utf-8"
|
|
85
|
+
) as note_file:
|
|
86
|
+
note_file.write(note.description)
|
|
87
|
+
|
|
88
|
+
def load(self):
|
|
89
|
+
with open(file=config.analyst_notes_list_json, encoding="utf-8") as note_file:
|
|
90
|
+
for note_dict in json.load(note_file):
|
|
91
|
+
self.note_list.append(AnalystNote.model_validate(note_dict))
|
|
92
|
+
|
|
93
|
+
def add_note(self, note: AnalystNote) -> AnalystNote:
|
|
94
|
+
self.note_list.append(note.summary())
|
|
95
|
+
self.dump(note)
|
|
96
|
+
return note
|
|
97
|
+
|
|
98
|
+
def update_note(self, title: str, note: AnalystNote) -> AnalystNote:
|
|
99
|
+
for index, note in enumerate(self.note_list):
|
|
100
|
+
if note.title == title:
|
|
101
|
+
self.note_list[index] = note.summary()
|
|
102
|
+
self.dump(note)
|
|
103
|
+
return note
|
|
104
|
+
raise KeyError(f"No note with title '{title}'")
|
|
105
|
+
|
|
106
|
+
def get_note(self, title: str) -> AnalystNote:
|
|
107
|
+
for note in self.note_list:
|
|
108
|
+
if note.title == title:
|
|
109
|
+
with open(file=config.analyst_notes_directory / f"{title}.md", encoding="utf-8") as note_file:
|
|
110
|
+
return AnalystNote(
|
|
111
|
+
title=note.title, status=note.status, tags=note.tags, description=note_file.read()
|
|
112
|
+
)
|
|
113
|
+
raise KeyError(f"No note with title '{title}'")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
command_history_manager = CommandHistoryManager()
|
|
117
|
+
analyst_note_manager = AnalystNoteManager()
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Defines all functions to run commands on the docker container"""
|
|
2
|
+
|
|
3
|
+
# Third-party libraries
|
|
4
|
+
from attrs import define, field, validators
|
|
5
|
+
from loguru import logger
|
|
6
|
+
from mcp.server.fastmcp import Context
|
|
7
|
+
from python_on_whales import Container, DockerClient
|
|
8
|
+
from python_on_whales.exceptions import DockerException, NoSuchContainer
|
|
9
|
+
|
|
10
|
+
# Project libraries
|
|
11
|
+
from mcp_dfir.config import config
|
|
12
|
+
from mcp_dfir.data_manager import command_history_manager
|
|
13
|
+
from mcp_dfir.tools.models import CommandRecord, CommandRecordSummary
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@define
|
|
17
|
+
class DockerManager:
|
|
18
|
+
docker: DockerClient = field(validator=validators.instance_of(DockerClient), init=False)
|
|
19
|
+
container: Container = field(validator=validators.optional(validators.instance_of(Container)), init=False)
|
|
20
|
+
|
|
21
|
+
def __attrs_post_init__(self):
|
|
22
|
+
self.docker = DockerClient()
|
|
23
|
+
self.container = None
|
|
24
|
+
|
|
25
|
+
def start_forensic_container(self):
|
|
26
|
+
logger.info("Starting forensics docker container")
|
|
27
|
+
|
|
28
|
+
try:
|
|
29
|
+
self.container = self.docker.container.inspect(config.docker_config.container_name)
|
|
30
|
+
|
|
31
|
+
if self.container.state.running:
|
|
32
|
+
logger.info("Forensics container already running")
|
|
33
|
+
return
|
|
34
|
+
|
|
35
|
+
logger.info("Forensics container exists but is stopped, starting it")
|
|
36
|
+
self.docker.container.start(config.docker_config.container_name)
|
|
37
|
+
return
|
|
38
|
+
|
|
39
|
+
except NoSuchContainer:
|
|
40
|
+
logger.info("Forensics container does not exist, creating one")
|
|
41
|
+
|
|
42
|
+
volumes = [
|
|
43
|
+
(volume_mount.external_path, volume_mount.internal_path, volume_mount.permissions)
|
|
44
|
+
for volume_mount in list(config.docker_volumes)
|
|
45
|
+
]
|
|
46
|
+
self.container = self.docker.run(
|
|
47
|
+
config.docker_config.image_name,
|
|
48
|
+
name=config.docker_config.container_name,
|
|
49
|
+
detach=True,
|
|
50
|
+
remove=False,
|
|
51
|
+
volumes=volumes,
|
|
52
|
+
networks=["none"],
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
def stop_forensic_container(self):
|
|
56
|
+
if self.container is not None:
|
|
57
|
+
logger.info("Stopping forensics docker container")
|
|
58
|
+
self.container.stop()
|
|
59
|
+
|
|
60
|
+
def clear_forensic_container(self):
|
|
61
|
+
if self.container is not None:
|
|
62
|
+
logger.info("Clearing forensics docker container")
|
|
63
|
+
self.container.stop()
|
|
64
|
+
self.container.remove()
|
|
65
|
+
self.container = None
|
|
66
|
+
|
|
67
|
+
def exec_stream(self, ctx: Context, command_list: list[str]) -> CommandRecordSummary:
|
|
68
|
+
if self.container is None:
|
|
69
|
+
raise RuntimeError("No docker container is currently running")
|
|
70
|
+
|
|
71
|
+
ctx.report_progress(f"Running command {' '.join(command_list)}")
|
|
72
|
+
output = []
|
|
73
|
+
exit_code = 0
|
|
74
|
+
try:
|
|
75
|
+
for stream, data in self.docker.execute(self.container, command_list, stream=True):
|
|
76
|
+
if stream == "stdout":
|
|
77
|
+
output.append(data.decode())
|
|
78
|
+
except DockerException as ex:
|
|
79
|
+
exit_code = ex.return_code if ex.return_code is not None else 1
|
|
80
|
+
output.append(str(ex))
|
|
81
|
+
result = "".join(output)
|
|
82
|
+
return command_history_manager.add_command(
|
|
83
|
+
CommandRecord(
|
|
84
|
+
command=" ".join(command_list),
|
|
85
|
+
result=result,
|
|
86
|
+
exit_code=exit_code,
|
|
87
|
+
)
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
docker_manager = DockerManager()
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Runner for llm_forensics"""
|
|
2
|
+
|
|
3
|
+
# Standard libraries
|
|
4
|
+
import argparse
|
|
5
|
+
import shutil
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
# Project libraries
|
|
10
|
+
from mcp_dfir.config import config
|
|
11
|
+
from mcp_dfir.constants import VERSION
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def main():
|
|
15
|
+
parser = argparse.ArgumentParser(prog="mcp-dfir", description="MCP Server for Performing Memory and Disk Forensics")
|
|
16
|
+
parser.add_argument("--version", action="version", version=f"mcp-dfir v{VERSION}")
|
|
17
|
+
parser.add_argument(
|
|
18
|
+
"-d", "--case-dir", type=Path, default=Path.cwd(), help=f"Working directory for the case, default: {Path.cwd()}"
|
|
19
|
+
)
|
|
20
|
+
parser.add_argument("--init", action="store_true", help="Create case directories then exit")
|
|
21
|
+
parser.add_argument(
|
|
22
|
+
"--clear-case-dir", action="store_true", help="Clear the case directory before starting the server"
|
|
23
|
+
)
|
|
24
|
+
parser.add_argument(
|
|
25
|
+
"--clear-docker", action="store_true", help="Clear the forensics docker container before starting the server"
|
|
26
|
+
)
|
|
27
|
+
args = parser.parse_args()
|
|
28
|
+
|
|
29
|
+
if args.clear_case_dir:
|
|
30
|
+
user_input = input(
|
|
31
|
+
f"Are you sure you want to clear the case directory {args.case_dir}? This action cannot be undone. Type 'yes' to confirm: "
|
|
32
|
+
)
|
|
33
|
+
if user_input != "yes":
|
|
34
|
+
parser.exit(status=1, message="Case directory not cleared")
|
|
35
|
+
for path in "artifacts", "analysis", "evidence", "symbols":
|
|
36
|
+
shutil.rmtree(args.case_dir / path, ignore_errors=True)
|
|
37
|
+
print("Case directory cleared")
|
|
38
|
+
sys.exit(0)
|
|
39
|
+
|
|
40
|
+
# Load config
|
|
41
|
+
config.load(working_directory=args.case_dir)
|
|
42
|
+
|
|
43
|
+
if args.init:
|
|
44
|
+
sys.exit(0)
|
|
45
|
+
|
|
46
|
+
if args.clear_docker:
|
|
47
|
+
from mcp_dfir.docker_manager import docker_manager
|
|
48
|
+
|
|
49
|
+
docker_manager.clear_forensic_container()
|
|
50
|
+
sys.exit(0)
|
|
51
|
+
|
|
52
|
+
# Load MCP server
|
|
53
|
+
from mcp_dfir.docker_manager import docker_manager
|
|
54
|
+
from mcp_dfir.server import mcp
|
|
55
|
+
|
|
56
|
+
# Start the mcp server
|
|
57
|
+
docker_manager.start_forensic_container()
|
|
58
|
+
try:
|
|
59
|
+
mcp.run(transport="stdio")
|
|
60
|
+
except KeyboardInterrupt:
|
|
61
|
+
pass
|
|
62
|
+
finally:
|
|
63
|
+
docker_manager.stop_forensic_container()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
if __name__ == "__main__":
|
|
67
|
+
main()
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Defines the MCP server"""
|
|
2
|
+
|
|
3
|
+
# Third-party libraries
|
|
4
|
+
from mcp.server.fastmcp import FastMCP
|
|
5
|
+
|
|
6
|
+
# Project libraries
|
|
7
|
+
from mcp_dfir.tools.file_search import list_files, run_find, run_grep, run_strings
|
|
8
|
+
from mcp_dfir.tools.file_tools import untar_file, unzip_file
|
|
9
|
+
from mcp_dfir.tools.hash import get_hash
|
|
10
|
+
from mcp_dfir.tools.history import get_command_history, get_command_result
|
|
11
|
+
from mcp_dfir.tools.notes import (
|
|
12
|
+
add_analyst_note,
|
|
13
|
+
show_analyst_note,
|
|
14
|
+
show_analyst_notes_summary,
|
|
15
|
+
update_analyst_note,
|
|
16
|
+
)
|
|
17
|
+
from mcp_dfir.tools.sleuthkit import run_sleuthkit_command
|
|
18
|
+
from mcp_dfir.tools.volatility import (
|
|
19
|
+
download_linux_symbol,
|
|
20
|
+
download_windows_symbol,
|
|
21
|
+
run_volatility_command,
|
|
22
|
+
search_linux_symbols,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
# Create MCP server
|
|
26
|
+
mcp = FastMCP(
|
|
27
|
+
"llm-forensics",
|
|
28
|
+
auth=None,
|
|
29
|
+
instructions="""\
|
|
30
|
+
You are a host forensics agent tasked with performing memory, disk, and artifact forensics.
|
|
31
|
+
|
|
32
|
+
Before beginning analysis always perform the following tasks:
|
|
33
|
+
1. Perform an inventory of all evidence, ask the user for specific details on how, when, and where the evidence was acquired
|
|
34
|
+
2. Review all previous commands that have already been run, commands should never be re-run unless the evidence has been updated or the commands were run incorrectly
|
|
35
|
+
3. Review all analyst notes to not perform duplicate analysis
|
|
36
|
+
|
|
37
|
+
While performing analysis follow these rules:
|
|
38
|
+
1. Create an analyst note (markdown format) after every finding whether benign, malicious, or if additional research is needed
|
|
39
|
+
2. Never make any assumptions, if a conclusion cannot be reached on whether a finding is benign or malicious explicitly mark it as needing additional research
|
|
40
|
+
3. Never use custom bash or python scripts to parse files, only use the built-in head/tail/regex/truncate options when going through command results
|
|
41
|
+
4. All artifacts should be saved under /artifacts
|
|
42
|
+
""",
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
# File search
|
|
46
|
+
mcp.add_tool(list_files)
|
|
47
|
+
mcp.add_tool(run_strings)
|
|
48
|
+
mcp.add_tool(run_grep)
|
|
49
|
+
mcp.add_tool(run_find)
|
|
50
|
+
|
|
51
|
+
# File tools
|
|
52
|
+
mcp.add_tool(unzip_file)
|
|
53
|
+
mcp.add_tool(untar_file)
|
|
54
|
+
|
|
55
|
+
# Hash
|
|
56
|
+
mcp.add_tool(get_hash)
|
|
57
|
+
|
|
58
|
+
# Records
|
|
59
|
+
mcp.add_tool(get_command_history)
|
|
60
|
+
mcp.add_tool(get_command_result)
|
|
61
|
+
|
|
62
|
+
# Volatility
|
|
63
|
+
mcp.add_tool(run_volatility_command)
|
|
64
|
+
mcp.add_tool(download_windows_symbol)
|
|
65
|
+
mcp.add_tool(download_linux_symbol)
|
|
66
|
+
mcp.add_tool(search_linux_symbols)
|
|
67
|
+
|
|
68
|
+
# Sleuthkit
|
|
69
|
+
mcp.add_tool(run_sleuthkit_command)
|
|
70
|
+
|
|
71
|
+
# Notes
|
|
72
|
+
mcp.add_tool(show_analyst_notes_summary)
|
|
73
|
+
mcp.add_tool(show_analyst_note)
|
|
74
|
+
mcp.add_tool(add_analyst_note)
|
|
75
|
+
mcp.add_tool(update_analyst_note)
|
|
File without changes
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""File search mcp functions"""
|
|
2
|
+
|
|
3
|
+
# Standard libraries
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Literal
|
|
6
|
+
|
|
7
|
+
# Third-party libraries
|
|
8
|
+
from mcp.server.fastmcp import Context
|
|
9
|
+
|
|
10
|
+
# Project libraries
|
|
11
|
+
from mcp_dfir.config import config
|
|
12
|
+
from mcp_dfir.docker_manager import docker_manager
|
|
13
|
+
from mcp_dfir.tools.models import CommandRecordSummary, FileDetails
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def list_files(path: Literal["artifacts", "symbols", "evidence"]) -> list[FileDetails]:
|
|
17
|
+
valid_paths = [volume.name for volume in config.docker_volumes]
|
|
18
|
+
|
|
19
|
+
# Select volume
|
|
20
|
+
selected_volume = None
|
|
21
|
+
for volume in config.docker_volumes:
|
|
22
|
+
if volume.name == path:
|
|
23
|
+
selected_volume = volume
|
|
24
|
+
break
|
|
25
|
+
if selected_volume is None:
|
|
26
|
+
raise RuntimeError(f"Invalid path {path}, valid paths are {valid_paths}")
|
|
27
|
+
|
|
28
|
+
out_list = []
|
|
29
|
+
for dir_path, _, file_name_list in selected_volume.external_path.walk():
|
|
30
|
+
for file_name in file_name_list:
|
|
31
|
+
external_file_path = Path(dir_path / file_name)
|
|
32
|
+
internal_file_path = (
|
|
33
|
+
f"/{path}/{str(external_file_path.relative_to(selected_volume.external_path)).replace('\\', '/')}"
|
|
34
|
+
)
|
|
35
|
+
out_list.append(
|
|
36
|
+
FileDetails(
|
|
37
|
+
name=file_name,
|
|
38
|
+
path=internal_file_path,
|
|
39
|
+
size=external_file_path.stat().st_size,
|
|
40
|
+
)
|
|
41
|
+
)
|
|
42
|
+
return out_list
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def run_strings(ctx: Context, arguments: list[str]) -> CommandRecordSummary:
|
|
46
|
+
"""Runs: strings <arguments>"""
|
|
47
|
+
return docker_manager.exec_stream(ctx=ctx, command_list=["strings", *arguments])
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def run_grep(ctx: Context, arguments: list[str]) -> CommandRecordSummary:
|
|
51
|
+
"""Runs: grep <arguments>"""
|
|
52
|
+
return docker_manager.exec_stream(ctx=ctx, command_list=["grep", *arguments])
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def run_find(ctx: Context, arguments: list[str]) -> CommandRecordSummary:
|
|
56
|
+
"""Runs: find <arguments>"""
|
|
57
|
+
if " ".join(arguments).find("-exec") != -1:
|
|
58
|
+
raise RuntimeError("Cannot run exec in find")
|
|
59
|
+
return docker_manager.exec_stream(ctx=ctx, command_list=["find", *arguments])
|