iflow-mcp_developermode-korea_reversecore-mcp 1.0.0__py3-none-any.whl
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.
- iflow_mcp_developermode_korea_reversecore_mcp-1.0.0.dist-info/METADATA +543 -0
- iflow_mcp_developermode_korea_reversecore_mcp-1.0.0.dist-info/RECORD +79 -0
- iflow_mcp_developermode_korea_reversecore_mcp-1.0.0.dist-info/WHEEL +5 -0
- iflow_mcp_developermode_korea_reversecore_mcp-1.0.0.dist-info/entry_points.txt +2 -0
- iflow_mcp_developermode_korea_reversecore_mcp-1.0.0.dist-info/licenses/LICENSE +21 -0
- iflow_mcp_developermode_korea_reversecore_mcp-1.0.0.dist-info/top_level.txt +1 -0
- reversecore_mcp/__init__.py +9 -0
- reversecore_mcp/core/__init__.py +78 -0
- reversecore_mcp/core/audit.py +101 -0
- reversecore_mcp/core/binary_cache.py +138 -0
- reversecore_mcp/core/command_spec.py +357 -0
- reversecore_mcp/core/config.py +432 -0
- reversecore_mcp/core/container.py +288 -0
- reversecore_mcp/core/decorators.py +152 -0
- reversecore_mcp/core/error_formatting.py +93 -0
- reversecore_mcp/core/error_handling.py +142 -0
- reversecore_mcp/core/evidence.py +229 -0
- reversecore_mcp/core/exceptions.py +296 -0
- reversecore_mcp/core/execution.py +240 -0
- reversecore_mcp/core/ghidra.py +642 -0
- reversecore_mcp/core/ghidra_helper.py +481 -0
- reversecore_mcp/core/ghidra_manager.py +234 -0
- reversecore_mcp/core/json_utils.py +131 -0
- reversecore_mcp/core/loader.py +73 -0
- reversecore_mcp/core/logging_config.py +206 -0
- reversecore_mcp/core/memory.py +721 -0
- reversecore_mcp/core/metrics.py +198 -0
- reversecore_mcp/core/mitre_mapper.py +365 -0
- reversecore_mcp/core/plugin.py +45 -0
- reversecore_mcp/core/r2_helpers.py +404 -0
- reversecore_mcp/core/r2_pool.py +403 -0
- reversecore_mcp/core/report_generator.py +268 -0
- reversecore_mcp/core/resilience.py +252 -0
- reversecore_mcp/core/resource_manager.py +169 -0
- reversecore_mcp/core/result.py +132 -0
- reversecore_mcp/core/security.py +213 -0
- reversecore_mcp/core/validators.py +238 -0
- reversecore_mcp/dashboard/__init__.py +221 -0
- reversecore_mcp/prompts/__init__.py +56 -0
- reversecore_mcp/prompts/common.py +24 -0
- reversecore_mcp/prompts/game.py +280 -0
- reversecore_mcp/prompts/malware.py +1219 -0
- reversecore_mcp/prompts/report.py +150 -0
- reversecore_mcp/prompts/security.py +136 -0
- reversecore_mcp/resources.py +329 -0
- reversecore_mcp/server.py +727 -0
- reversecore_mcp/tools/__init__.py +49 -0
- reversecore_mcp/tools/analysis/__init__.py +74 -0
- reversecore_mcp/tools/analysis/capa_tools.py +215 -0
- reversecore_mcp/tools/analysis/die_tools.py +180 -0
- reversecore_mcp/tools/analysis/diff_tools.py +643 -0
- reversecore_mcp/tools/analysis/lief_tools.py +272 -0
- reversecore_mcp/tools/analysis/signature_tools.py +591 -0
- reversecore_mcp/tools/analysis/static_analysis.py +479 -0
- reversecore_mcp/tools/common/__init__.py +58 -0
- reversecore_mcp/tools/common/file_operations.py +352 -0
- reversecore_mcp/tools/common/memory_tools.py +516 -0
- reversecore_mcp/tools/common/patch_explainer.py +230 -0
- reversecore_mcp/tools/common/server_tools.py +115 -0
- reversecore_mcp/tools/ghidra/__init__.py +19 -0
- reversecore_mcp/tools/ghidra/decompilation.py +975 -0
- reversecore_mcp/tools/ghidra/ghidra_tools.py +1052 -0
- reversecore_mcp/tools/malware/__init__.py +61 -0
- reversecore_mcp/tools/malware/adaptive_vaccine.py +579 -0
- reversecore_mcp/tools/malware/dormant_detector.py +756 -0
- reversecore_mcp/tools/malware/ioc_tools.py +228 -0
- reversecore_mcp/tools/malware/vulnerability_hunter.py +519 -0
- reversecore_mcp/tools/malware/yara_tools.py +214 -0
- reversecore_mcp/tools/patch_explainer.py +19 -0
- reversecore_mcp/tools/radare2/__init__.py +13 -0
- reversecore_mcp/tools/radare2/r2_analysis.py +972 -0
- reversecore_mcp/tools/radare2/r2_session.py +376 -0
- reversecore_mcp/tools/radare2/radare2_mcp_tools.py +1183 -0
- reversecore_mcp/tools/report/__init__.py +4 -0
- reversecore_mcp/tools/report/email.py +82 -0
- reversecore_mcp/tools/report/report_mcp_tools.py +344 -0
- reversecore_mcp/tools/report/report_tools.py +1076 -0
- reversecore_mcp/tools/report/session.py +194 -0
- reversecore_mcp/tools/report_tools.py +11 -0
|
@@ -0,0 +1,1076 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Malware Analysis Report Tools for Reversecore_MCP
|
|
3
|
+
|
|
4
|
+
Features:
|
|
5
|
+
- OS-level timestamp (no AI hallucination)
|
|
6
|
+
- Session tracking (start/end time, duration)
|
|
7
|
+
- Timezone support (UTC, local, custom)
|
|
8
|
+
- IOC collection during analysis
|
|
9
|
+
- Template-based report generation
|
|
10
|
+
- Environment variable support for email configuration
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
import hashlib
|
|
15
|
+
import logging
|
|
16
|
+
import os
|
|
17
|
+
import platform
|
|
18
|
+
import aiosmtplib
|
|
19
|
+
import uuid
|
|
20
|
+
from datetime import datetime, timedelta, timezone
|
|
21
|
+
from email import encoders
|
|
22
|
+
from email.mime.base import MIMEBase
|
|
23
|
+
from email.mime.multipart import MIMEMultipart
|
|
24
|
+
from email.mime.text import MIMEText
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
import aiofiles
|
|
28
|
+
|
|
29
|
+
# Use optimized JSON implementation (3-5x faster than standard json)
|
|
30
|
+
# Import session and email utilities from submodules
|
|
31
|
+
from reversecore_mcp.tools.report.session import (
|
|
32
|
+
AnalysisSession,
|
|
33
|
+
TimezonePreset,
|
|
34
|
+
TIMEZONE_OFFSETS,
|
|
35
|
+
TIMEZONE_ABBRS,
|
|
36
|
+
)
|
|
37
|
+
from reversecore_mcp.tools.report.email import (
|
|
38
|
+
EmailConfig,
|
|
39
|
+
load_quick_contacts_from_env,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
logger = logging.getLogger(__name__)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ReportTools:
|
|
46
|
+
"""
|
|
47
|
+
Malware Analysis Report Generation Tools
|
|
48
|
+
|
|
49
|
+
Features:
|
|
50
|
+
- OS-level accurate timestamps
|
|
51
|
+
- Analysis session tracking
|
|
52
|
+
- Multi-timezone support
|
|
53
|
+
- Auto hash calculation
|
|
54
|
+
- Template-based report generation
|
|
55
|
+
- Email delivery support
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
def __init__(
|
|
59
|
+
self,
|
|
60
|
+
template_dir: Path,
|
|
61
|
+
output_dir: Path,
|
|
62
|
+
default_timezone: str = "UTC",
|
|
63
|
+
email_config: EmailConfig | None = None,
|
|
64
|
+
):
|
|
65
|
+
self.template_dir = Path(template_dir)
|
|
66
|
+
self.output_dir = Path(output_dir)
|
|
67
|
+
self.output_dir.mkdir(parents=True, exist_ok=True)
|
|
68
|
+
|
|
69
|
+
self.default_timezone = default_timezone
|
|
70
|
+
self.timezone_offset = TIMEZONE_OFFSETS.get(default_timezone, 0)
|
|
71
|
+
|
|
72
|
+
# Active session management
|
|
73
|
+
self.sessions: dict[str, AnalysisSession] = {}
|
|
74
|
+
self.current_session_id: str | None = None
|
|
75
|
+
|
|
76
|
+
# Email configuration
|
|
77
|
+
self.email_config = email_config or EmailConfig()
|
|
78
|
+
|
|
79
|
+
# Quick contacts list
|
|
80
|
+
self.quick_contacts: dict[str, dict[str, str]] = {}
|
|
81
|
+
|
|
82
|
+
# =========================================================================
|
|
83
|
+
# Timezone Management
|
|
84
|
+
# =========================================================================
|
|
85
|
+
|
|
86
|
+
def set_timezone(self, tz: str) -> dict:
|
|
87
|
+
"""
|
|
88
|
+
Set default timezone.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
tz: Timezone name (UTC, Asia/Seoul, America/New_York, etc.)
|
|
92
|
+
"""
|
|
93
|
+
if tz not in TIMEZONE_OFFSETS:
|
|
94
|
+
return {
|
|
95
|
+
"success": False,
|
|
96
|
+
"error": f"Unknown timezone: {tz}",
|
|
97
|
+
"available": list(TIMEZONE_OFFSETS.keys()),
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
offset = TIMEZONE_OFFSETS.get(tz, 0)
|
|
101
|
+
return {
|
|
102
|
+
"success": True,
|
|
103
|
+
"timezone": tz,
|
|
104
|
+
"utc_offset": f"UTC{'+' if offset >= 0 else ''}{offset}",
|
|
105
|
+
"abbreviation": TIMEZONE_ABBRS.get(tz, ""),
|
|
106
|
+
"current_time": self._format_time(datetime.now(timezone.utc), tz_name=tz),
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
def get_timezone_info(self) -> dict:
|
|
110
|
+
"""Return current timezone configuration info"""
|
|
111
|
+
return {
|
|
112
|
+
"current_timezone": self.default_timezone,
|
|
113
|
+
"utc_offset": self.timezone_offset,
|
|
114
|
+
"abbreviation": TIMEZONE_ABBRS.get(self.default_timezone, ""),
|
|
115
|
+
"available_timezones": {
|
|
116
|
+
name: {
|
|
117
|
+
"offset": f"UTC{'+' if offset >= 0 else ''}{offset}",
|
|
118
|
+
"abbreviation": TIMEZONE_ABBRS.get(name, ""),
|
|
119
|
+
}
|
|
120
|
+
for name, offset in TIMEZONE_OFFSETS.items()
|
|
121
|
+
},
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
def _get_local_time(self) -> datetime:
|
|
125
|
+
"""Get current time in configured timezone"""
|
|
126
|
+
utc_now = datetime.now(timezone.utc)
|
|
127
|
+
local_tz = timezone(timedelta(hours=self.timezone_offset))
|
|
128
|
+
return utc_now.astimezone(local_tz)
|
|
129
|
+
|
|
130
|
+
def _format_time(
|
|
131
|
+
self, dt: datetime, include_tz: bool = True, tz_name: str | None = None
|
|
132
|
+
) -> str:
|
|
133
|
+
"""Format datetime to configured timezone"""
|
|
134
|
+
target_tz_name = tz_name or self.default_timezone
|
|
135
|
+
offset = TIMEZONE_OFFSETS.get(target_tz_name, 0)
|
|
136
|
+
|
|
137
|
+
local_tz = timezone(timedelta(hours=offset))
|
|
138
|
+
local_dt = dt.astimezone(local_tz)
|
|
139
|
+
|
|
140
|
+
if include_tz:
|
|
141
|
+
abbr = TIMEZONE_ABBRS.get(target_tz_name, f"UTC{'+' if offset >= 0 else ''}{offset}")
|
|
142
|
+
return f"{local_dt.strftime('%Y-%m-%d %H:%M:%S')} ({abbr})"
|
|
143
|
+
return local_dt.strftime("%Y-%m-%d %H:%M:%S")
|
|
144
|
+
|
|
145
|
+
# =========================================================================
|
|
146
|
+
# Timestamp Generation
|
|
147
|
+
# =========================================================================
|
|
148
|
+
|
|
149
|
+
def get_timestamp_data(self, tz_name: str | None = None) -> dict:
|
|
150
|
+
"""
|
|
151
|
+
Generate accurate timestamp data at OS level.
|
|
152
|
+
Provided directly from server to prevent AI from guessing dates.
|
|
153
|
+
"""
|
|
154
|
+
target_tz_name = tz_name or self.default_timezone
|
|
155
|
+
offset = TIMEZONE_OFFSETS.get(target_tz_name, 0)
|
|
156
|
+
|
|
157
|
+
utc_now = datetime.now(timezone.utc)
|
|
158
|
+
local_tz = timezone(timedelta(hours=offset))
|
|
159
|
+
local_now = utc_now.astimezone(local_tz)
|
|
160
|
+
abbr = TIMEZONE_ABBRS.get(target_tz_name, "")
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
# For Report ID generation
|
|
164
|
+
"report_id": f"MAR-{local_now.strftime('%Y%m%d-%H%M%S')}",
|
|
165
|
+
# Date formats (ISO, localized)
|
|
166
|
+
"date": local_now.strftime("%Y-%m-%d"),
|
|
167
|
+
"date_long": local_now.strftime("%B %d, %Y"), # December 05, 2025
|
|
168
|
+
"date_short": local_now.strftime("%d %b %Y"), # 05 Dec 2025
|
|
169
|
+
"date_eu": local_now.strftime("%d/%m/%Y"), # 05/12/2025
|
|
170
|
+
"date_us": local_now.strftime("%m/%d/%Y"), # 12/05/2025
|
|
171
|
+
# Time formats
|
|
172
|
+
"time": local_now.strftime("%H:%M:%S"),
|
|
173
|
+
"time_12h": local_now.strftime("%I:%M:%S %p"), # 02:30:45 PM
|
|
174
|
+
"datetime": local_now.strftime("%Y-%m-%d %H:%M:%S"),
|
|
175
|
+
"datetime_full": self._format_time(utc_now, tz_name=target_tz_name),
|
|
176
|
+
"datetime_iso": local_now.isoformat(),
|
|
177
|
+
# UTC based
|
|
178
|
+
"datetime_utc": utc_now.strftime("%Y-%m-%d %H:%M:%S UTC"),
|
|
179
|
+
"timestamp_unix": int(utc_now.timestamp()),
|
|
180
|
+
# Individual fields
|
|
181
|
+
"year": local_now.strftime("%Y"),
|
|
182
|
+
"month": local_now.strftime("%m"),
|
|
183
|
+
"month_name": local_now.strftime("%B"), # December
|
|
184
|
+
"month_name_short": local_now.strftime("%b"), # Dec
|
|
185
|
+
"day": local_now.strftime("%d"),
|
|
186
|
+
"weekday": local_now.strftime("%A"), # Thursday
|
|
187
|
+
"weekday_short": local_now.strftime("%a"), # Thu
|
|
188
|
+
# Timezone info
|
|
189
|
+
"timezone": target_tz_name,
|
|
190
|
+
"timezone_abbr": abbr,
|
|
191
|
+
"timezone_offset": f"UTC{'+' if offset >= 0 else ''}{offset}",
|
|
192
|
+
# System info
|
|
193
|
+
"hostname": platform.node(),
|
|
194
|
+
"platform": platform.system(),
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async def get_current_time(self) -> dict:
|
|
198
|
+
"""Return current system time info (for MCP Tool)"""
|
|
199
|
+
return self.get_timestamp_data()
|
|
200
|
+
|
|
201
|
+
# =========================================================================
|
|
202
|
+
# Session Management
|
|
203
|
+
# =========================================================================
|
|
204
|
+
|
|
205
|
+
async def start_session(
|
|
206
|
+
self,
|
|
207
|
+
sample_path: str | None = None,
|
|
208
|
+
analyst: str = "Security Researcher",
|
|
209
|
+
severity: str = "medium",
|
|
210
|
+
malware_family: str | None = None,
|
|
211
|
+
tags: list[str] | None = None,
|
|
212
|
+
) -> dict:
|
|
213
|
+
"""
|
|
214
|
+
Start a new analysis session.
|
|
215
|
+
|
|
216
|
+
Args:
|
|
217
|
+
sample_path: Path to the sample file
|
|
218
|
+
analyst: Analyst name
|
|
219
|
+
severity: Severity level (low, medium, high, critical)
|
|
220
|
+
malware_family: Malware family name
|
|
221
|
+
tags: Tag list
|
|
222
|
+
|
|
223
|
+
Returns:
|
|
224
|
+
Session information
|
|
225
|
+
"""
|
|
226
|
+
session_id = f"SES-{uuid.uuid4().hex[:8].upper()}"
|
|
227
|
+
|
|
228
|
+
session = AnalysisSession(
|
|
229
|
+
session_id=session_id,
|
|
230
|
+
sample_path=sample_path,
|
|
231
|
+
sample_name=Path(sample_path).name if sample_path else None,
|
|
232
|
+
analyst=analyst,
|
|
233
|
+
severity=severity,
|
|
234
|
+
malware_family=malware_family,
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
if tags:
|
|
238
|
+
for tag in tags:
|
|
239
|
+
session.add_tag(tag)
|
|
240
|
+
|
|
241
|
+
session.start()
|
|
242
|
+
|
|
243
|
+
# Auto-calculate sample hashes
|
|
244
|
+
if sample_path:
|
|
245
|
+
sample_info = await self._extract_sample_info(sample_path)
|
|
246
|
+
session.findings["sample_info"] = sample_info
|
|
247
|
+
|
|
248
|
+
# Auto-add hashes to IOC
|
|
249
|
+
for hash_type in ["md5", "sha1", "sha256"]:
|
|
250
|
+
if hash_type in sample_info:
|
|
251
|
+
session.add_ioc("hashes", f"{hash_type.upper()}: {sample_info[hash_type]}")
|
|
252
|
+
|
|
253
|
+
self.sessions[session_id] = session
|
|
254
|
+
self.current_session_id = session_id
|
|
255
|
+
|
|
256
|
+
return {
|
|
257
|
+
"success": True,
|
|
258
|
+
"session_id": session_id,
|
|
259
|
+
"started_at": self._format_time(session.started_at),
|
|
260
|
+
"started_at_utc": session.started_at.strftime("%Y-%m-%d %H:%M:%S UTC"),
|
|
261
|
+
"sample": session.sample_name,
|
|
262
|
+
"analyst": analyst,
|
|
263
|
+
"severity": severity,
|
|
264
|
+
"malware_family": malware_family,
|
|
265
|
+
"message": f"Analysis session started. Use session_id '{session_id}' to track.",
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async def end_session(
|
|
269
|
+
self, session_id: str | None = None, status: str = "completed", summary: str | None = None
|
|
270
|
+
) -> dict:
|
|
271
|
+
"""
|
|
272
|
+
End an analysis session.
|
|
273
|
+
|
|
274
|
+
Args:
|
|
275
|
+
session_id: Session ID (uses current session if not provided)
|
|
276
|
+
status: End status (completed, aborted)
|
|
277
|
+
summary: Analysis summary
|
|
278
|
+
"""
|
|
279
|
+
sid = session_id or self.current_session_id
|
|
280
|
+
|
|
281
|
+
if not sid or sid not in self.sessions:
|
|
282
|
+
return {"success": False, "error": "No active session found"}
|
|
283
|
+
|
|
284
|
+
session = self.sessions[sid]
|
|
285
|
+
session.end(status)
|
|
286
|
+
|
|
287
|
+
if summary:
|
|
288
|
+
session.findings["summary"] = summary
|
|
289
|
+
|
|
290
|
+
result = {
|
|
291
|
+
"success": True,
|
|
292
|
+
"session_id": sid,
|
|
293
|
+
"status": status,
|
|
294
|
+
"started_at": self._format_time(session.started_at),
|
|
295
|
+
"ended_at": self._format_time(session.ended_at),
|
|
296
|
+
"duration": session.get_duration_str(),
|
|
297
|
+
"severity": session.severity,
|
|
298
|
+
"malware_family": session.malware_family,
|
|
299
|
+
"iocs_collected": sum(len(v) for v in session.iocs.values()),
|
|
300
|
+
"mitre_techniques": len(session.mitre_techniques),
|
|
301
|
+
"notes": len(session.notes),
|
|
302
|
+
"tags": session.tags,
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if sid == self.current_session_id:
|
|
306
|
+
self.current_session_id = None
|
|
307
|
+
|
|
308
|
+
return result
|
|
309
|
+
|
|
310
|
+
async def get_session_info(self, session_id: str | None = None) -> dict:
|
|
311
|
+
"""Query session status"""
|
|
312
|
+
sid = session_id or self.current_session_id
|
|
313
|
+
|
|
314
|
+
if not sid or sid not in self.sessions:
|
|
315
|
+
return {
|
|
316
|
+
"success": False,
|
|
317
|
+
"error": "No session found",
|
|
318
|
+
"active_sessions": list(self.sessions.keys()),
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
session = self.sessions[sid]
|
|
322
|
+
info = session.to_dict()
|
|
323
|
+
|
|
324
|
+
# 포맷된 시간 추가
|
|
325
|
+
if session.started_at:
|
|
326
|
+
info["started_at_formatted"] = self._format_time(session.started_at)
|
|
327
|
+
if session.ended_at:
|
|
328
|
+
info["ended_at_formatted"] = self._format_time(session.ended_at)
|
|
329
|
+
|
|
330
|
+
info["is_current"] = sid == self.current_session_id
|
|
331
|
+
|
|
332
|
+
return {"success": True, "session": info}
|
|
333
|
+
|
|
334
|
+
async def add_session_ioc(
|
|
335
|
+
self, ioc_type: str, value: str, session_id: str | None = None
|
|
336
|
+
) -> dict:
|
|
337
|
+
"""Add IOC to session"""
|
|
338
|
+
sid = session_id or self.current_session_id
|
|
339
|
+
|
|
340
|
+
if not sid or sid not in self.sessions:
|
|
341
|
+
return {"success": False, "error": "No active session"}
|
|
342
|
+
|
|
343
|
+
session = self.sessions[sid]
|
|
344
|
+
valid_types = list(session.iocs.keys())
|
|
345
|
+
|
|
346
|
+
if ioc_type not in valid_types:
|
|
347
|
+
return {
|
|
348
|
+
"success": False,
|
|
349
|
+
"error": f"Invalid IOC type: {ioc_type}",
|
|
350
|
+
"valid_types": valid_types,
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
added = session.add_ioc(ioc_type, value)
|
|
354
|
+
|
|
355
|
+
return {
|
|
356
|
+
"success": True,
|
|
357
|
+
"added": added,
|
|
358
|
+
"ioc": {"type": ioc_type, "value": value},
|
|
359
|
+
"message": "IOC added" if added else "IOC already exists",
|
|
360
|
+
"total_iocs": sum(len(v) for v in session.iocs.values()),
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
async def add_session_note(
|
|
364
|
+
self, note: str, category: str = "general", session_id: str | None = None
|
|
365
|
+
) -> dict:
|
|
366
|
+
"""Add analysis note to session"""
|
|
367
|
+
sid = session_id or self.current_session_id
|
|
368
|
+
|
|
369
|
+
if not sid or sid not in self.sessions:
|
|
370
|
+
return {"success": False, "error": "No active session"}
|
|
371
|
+
|
|
372
|
+
session = self.sessions[sid]
|
|
373
|
+
session.add_note(note, category)
|
|
374
|
+
|
|
375
|
+
return {
|
|
376
|
+
"success": True,
|
|
377
|
+
"note_added": note[:100] + "..." if len(note) > 100 else note,
|
|
378
|
+
"category": category,
|
|
379
|
+
"timestamp": self._format_time(datetime.now(timezone.utc)),
|
|
380
|
+
"total_notes": len(session.notes),
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
async def add_session_mitre(
|
|
384
|
+
self, technique_id: str, technique_name: str, tactic: str, session_id: str | None = None
|
|
385
|
+
) -> dict:
|
|
386
|
+
"""Add MITRE ATT&CK technique to session"""
|
|
387
|
+
sid = session_id or self.current_session_id
|
|
388
|
+
|
|
389
|
+
if not sid or sid not in self.sessions:
|
|
390
|
+
return {"success": False, "error": "No active session"}
|
|
391
|
+
|
|
392
|
+
session = self.sessions[sid]
|
|
393
|
+
session.add_mitre(technique_id, technique_name, tactic)
|
|
394
|
+
|
|
395
|
+
return {
|
|
396
|
+
"success": True,
|
|
397
|
+
"added": f"{technique_id} - {technique_name}",
|
|
398
|
+
"tactic": tactic,
|
|
399
|
+
"total_techniques": len(session.mitre_techniques),
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
async def add_session_tag(self, tag: str, session_id: str | None = None) -> dict:
|
|
403
|
+
"""Add tag to session"""
|
|
404
|
+
sid = session_id or self.current_session_id
|
|
405
|
+
|
|
406
|
+
if not sid or sid not in self.sessions:
|
|
407
|
+
return {"success": False, "error": "No active session"}
|
|
408
|
+
|
|
409
|
+
session = self.sessions[sid]
|
|
410
|
+
session.add_tag(tag)
|
|
411
|
+
|
|
412
|
+
return {"success": True, "tag_added": tag, "all_tags": session.tags}
|
|
413
|
+
|
|
414
|
+
async def set_session_severity(self, severity: str, session_id: str | None = None) -> dict:
|
|
415
|
+
"""Set session severity"""
|
|
416
|
+
sid = session_id or self.current_session_id
|
|
417
|
+
|
|
418
|
+
if not sid or sid not in self.sessions:
|
|
419
|
+
return {"success": False, "error": "No active session"}
|
|
420
|
+
|
|
421
|
+
valid_severities = ["low", "medium", "high", "critical"]
|
|
422
|
+
if severity.lower() not in valid_severities:
|
|
423
|
+
return {
|
|
424
|
+
"success": False,
|
|
425
|
+
"error": f"Invalid severity: {severity}",
|
|
426
|
+
"valid_severities": valid_severities,
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
session = self.sessions[sid]
|
|
430
|
+
session.severity = severity.lower()
|
|
431
|
+
|
|
432
|
+
return {"success": True, "severity": session.severity, "session_id": sid}
|
|
433
|
+
|
|
434
|
+
async def list_sessions(self) -> dict:
|
|
435
|
+
"""List all sessions"""
|
|
436
|
+
sessions_list = []
|
|
437
|
+
|
|
438
|
+
for sid, session in self.sessions.items():
|
|
439
|
+
sessions_list.append(
|
|
440
|
+
{
|
|
441
|
+
"session_id": sid,
|
|
442
|
+
"sample": session.sample_name,
|
|
443
|
+
"status": session.status,
|
|
444
|
+
"severity": session.severity,
|
|
445
|
+
"malware_family": session.malware_family,
|
|
446
|
+
"started_at": self._format_time(session.started_at)
|
|
447
|
+
if session.started_at
|
|
448
|
+
else None,
|
|
449
|
+
"duration": session.get_duration_str(),
|
|
450
|
+
"iocs_count": sum(len(v) for v in session.iocs.values()),
|
|
451
|
+
"is_current": sid == self.current_session_id,
|
|
452
|
+
}
|
|
453
|
+
)
|
|
454
|
+
|
|
455
|
+
return {
|
|
456
|
+
"total": len(sessions_list),
|
|
457
|
+
"current_session": self.current_session_id,
|
|
458
|
+
"sessions": sessions_list,
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
# =========================================================================
|
|
462
|
+
# Report Generation
|
|
463
|
+
# =========================================================================
|
|
464
|
+
|
|
465
|
+
async def create_report(
|
|
466
|
+
self,
|
|
467
|
+
template_type: str = "full_analysis",
|
|
468
|
+
session_id: str | None = None,
|
|
469
|
+
sample_path: str | None = None,
|
|
470
|
+
analyst: str = "Security Researcher",
|
|
471
|
+
classification: str = "TLP:AMBER",
|
|
472
|
+
custom_fields: dict | None = None,
|
|
473
|
+
output_format: str = "markdown",
|
|
474
|
+
timezone: str | None = None, # Add per-request timezone
|
|
475
|
+
) -> dict:
|
|
476
|
+
"""
|
|
477
|
+
Generate an analysis report.
|
|
478
|
+
If a session exists, session data is automatically included.
|
|
479
|
+
"""
|
|
480
|
+
# 타임스탬프 생성 (서버 시간 기준, 타임존 지정 가능)
|
|
481
|
+
ts = self.get_timestamp_data(tz_name=timezone)
|
|
482
|
+
|
|
483
|
+
# 템플릿 로드
|
|
484
|
+
template_path = self.template_dir / f"{template_type}.md"
|
|
485
|
+
if not template_path.exists():
|
|
486
|
+
available = [f.stem for f in self.template_dir.glob("*.md")]
|
|
487
|
+
return {
|
|
488
|
+
"success": False,
|
|
489
|
+
"error": f"Template not found: {template_type}",
|
|
490
|
+
"available_templates": available,
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
async with aiofiles.open(template_path, encoding="utf-8") as f:
|
|
494
|
+
template = await f.read()
|
|
495
|
+
|
|
496
|
+
# Basic fields
|
|
497
|
+
fields = {
|
|
498
|
+
"REPORT_ID": ts["report_id"],
|
|
499
|
+
"DATE": ts["date"],
|
|
500
|
+
"DATE_LONG": ts["date_long"],
|
|
501
|
+
"DATE_SHORT": ts["date_short"],
|
|
502
|
+
"DATE_EU": ts["date_eu"],
|
|
503
|
+
"DATE_US": ts["date_us"],
|
|
504
|
+
"DATETIME": ts["datetime"],
|
|
505
|
+
"DATETIME_FULL": ts["datetime_full"],
|
|
506
|
+
"DATETIME_UTC": ts["datetime_utc"],
|
|
507
|
+
"TIMESTAMP": str(ts["timestamp_unix"]),
|
|
508
|
+
"YEAR": ts["year"],
|
|
509
|
+
"MONTH": ts["month"],
|
|
510
|
+
"MONTH_NAME": ts["month_name"],
|
|
511
|
+
"MONTH_NAME_SHORT": ts["month_name_short"],
|
|
512
|
+
"DAY": ts["day"],
|
|
513
|
+
"WEEKDAY": ts["weekday"],
|
|
514
|
+
"WEEKDAY_SHORT": ts["weekday_short"],
|
|
515
|
+
"TIME": ts["time"],
|
|
516
|
+
"TIME_12H": ts["time_12h"],
|
|
517
|
+
"TIMEZONE": ts["timezone"],
|
|
518
|
+
"TIMEZONE_ABBR": ts["timezone_abbr"],
|
|
519
|
+
"ANALYST": analyst,
|
|
520
|
+
"CLASSIFICATION": classification,
|
|
521
|
+
"GENERATED_BY": "Reversecore_MCP",
|
|
522
|
+
"HOSTNAME": ts["hostname"],
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
# Merge session data
|
|
526
|
+
sid = session_id or self.current_session_id
|
|
527
|
+
session = self.sessions.get(sid) if sid else None
|
|
528
|
+
|
|
529
|
+
if session:
|
|
530
|
+
fields.update(
|
|
531
|
+
{
|
|
532
|
+
"SESSION_ID": session.session_id,
|
|
533
|
+
"SESSION_STATUS": session.status,
|
|
534
|
+
"SEVERITY": session.severity.upper(),
|
|
535
|
+
"SEVERITY_EMOJI": self._get_severity_emoji(session.severity),
|
|
536
|
+
"MALWARE_FAMILY": session.malware_family or "Unknown",
|
|
537
|
+
"ANALYSIS_START": self._format_time(session.started_at)
|
|
538
|
+
if session.started_at
|
|
539
|
+
else "N/A",
|
|
540
|
+
"ANALYSIS_END": self._format_time(session.ended_at)
|
|
541
|
+
if session.ended_at
|
|
542
|
+
else "In Progress",
|
|
543
|
+
"ANALYSIS_DURATION": session.get_duration_str(),
|
|
544
|
+
"TAGS": ", ".join(session.tags) if session.tags else "None",
|
|
545
|
+
}
|
|
546
|
+
)
|
|
547
|
+
|
|
548
|
+
# 세션에서 샘플 정보 가져오기
|
|
549
|
+
if "sample_info" in session.findings:
|
|
550
|
+
sample_info = session.findings["sample_info"]
|
|
551
|
+
fields.update({k.upper(): str(v) for k, v in sample_info.items()})
|
|
552
|
+
|
|
553
|
+
# IOC 블록 생성
|
|
554
|
+
fields["IOCS_YAML"] = self._format_iocs_yaml(session.iocs)
|
|
555
|
+
fields["IOCS_MARKDOWN"] = self._format_iocs_markdown(session.iocs)
|
|
556
|
+
fields["IOCS_COUNT"] = str(sum(len(v) for v in session.iocs.values()))
|
|
557
|
+
|
|
558
|
+
# MITRE 테이블 생성
|
|
559
|
+
fields["MITRE_TABLE"] = self._format_mitre_table(session.mitre_techniques)
|
|
560
|
+
fields["MITRE_COUNT"] = str(len(session.mitre_techniques))
|
|
561
|
+
|
|
562
|
+
# 노트 섹션
|
|
563
|
+
fields["ANALYSIS_NOTES"] = self._format_notes(session.notes)
|
|
564
|
+
fields["NOTES_COUNT"] = str(len(session.notes))
|
|
565
|
+
|
|
566
|
+
# 요약
|
|
567
|
+
fields["SUMMARY"] = session.findings.get("summary", "_No summary provided._")
|
|
568
|
+
|
|
569
|
+
# 샘플 정보 (세션 없이 직접 지정한 경우)
|
|
570
|
+
elif sample_path:
|
|
571
|
+
sample_info = await self._extract_sample_info(sample_path)
|
|
572
|
+
fields.update({k.upper(): str(v) for k, v in sample_info.items()})
|
|
573
|
+
|
|
574
|
+
# 커스텀 필드
|
|
575
|
+
if custom_fields:
|
|
576
|
+
fields.update({k.upper(): str(v) for k, v in custom_fields.items()})
|
|
577
|
+
|
|
578
|
+
# 기본값 설정 (템플릿 변수가 치환되지 않은 경우)
|
|
579
|
+
default_values = {
|
|
580
|
+
"SEVERITY": "MEDIUM",
|
|
581
|
+
"SEVERITY_EMOJI": "🟡",
|
|
582
|
+
"MALWARE_FAMILY": "Unknown",
|
|
583
|
+
"TAGS": "None",
|
|
584
|
+
"IOCS_YAML": "# No IOCs collected",
|
|
585
|
+
"IOCS_MARKDOWN": "_No IOCs collected._",
|
|
586
|
+
"IOCS_COUNT": "0",
|
|
587
|
+
"MITRE_TABLE": "| - | - | - |",
|
|
588
|
+
"MITRE_COUNT": "0",
|
|
589
|
+
"ANALYSIS_NOTES": "_No notes recorded._",
|
|
590
|
+
"NOTES_COUNT": "0",
|
|
591
|
+
"SUMMARY": "_No summary provided._",
|
|
592
|
+
"SESSION_ID": "N/A",
|
|
593
|
+
"SESSION_STATUS": "N/A",
|
|
594
|
+
"ANALYSIS_START": "N/A",
|
|
595
|
+
"ANALYSIS_END": "N/A",
|
|
596
|
+
"ANALYSIS_DURATION": "N/A",
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
for key, default in default_values.items():
|
|
600
|
+
if key not in fields:
|
|
601
|
+
fields[key] = default
|
|
602
|
+
|
|
603
|
+
# 템플릿 치환
|
|
604
|
+
report = template
|
|
605
|
+
for key, value in fields.items():
|
|
606
|
+
report = report.replace(f"{{{{{key}}}}}", value)
|
|
607
|
+
|
|
608
|
+
# 리포트 저장
|
|
609
|
+
output_path = self.output_dir / f"{ts['report_id']}.md"
|
|
610
|
+
async with aiofiles.open(output_path, mode="w", encoding="utf-8") as f:
|
|
611
|
+
await f.write(report)
|
|
612
|
+
|
|
613
|
+
return {
|
|
614
|
+
"success": True,
|
|
615
|
+
"report_id": ts["report_id"],
|
|
616
|
+
"path": str(output_path),
|
|
617
|
+
"template": template_type,
|
|
618
|
+
"session_id": sid,
|
|
619
|
+
"generated_at": ts["datetime_full"],
|
|
620
|
+
"timezone": ts["timezone"],
|
|
621
|
+
"fields_filled": len(fields),
|
|
622
|
+
"report_content": report, # 미리보기용
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
async def list_templates(self) -> dict:
|
|
626
|
+
"""List available templates"""
|
|
627
|
+
templates = []
|
|
628
|
+
|
|
629
|
+
for f in self.template_dir.glob("*.md"):
|
|
630
|
+
async with aiofiles.open(f, encoding="utf-8") as tf:
|
|
631
|
+
content = await tf.read()
|
|
632
|
+
# 첫 줄에서 설명 추출 (<!-- description --> 형식)
|
|
633
|
+
desc = ""
|
|
634
|
+
if content.startswith("<!--"):
|
|
635
|
+
end = content.find("-->")
|
|
636
|
+
if end > 0:
|
|
637
|
+
desc = content[4:end].strip()
|
|
638
|
+
|
|
639
|
+
templates.append({"name": f.stem, "description": desc, "path": str(f)})
|
|
640
|
+
|
|
641
|
+
return {"total": len(templates), "templates": templates}
|
|
642
|
+
|
|
643
|
+
async def get_report(self, report_id: str) -> dict:
|
|
644
|
+
"""Retrieve a generated report"""
|
|
645
|
+
report_path = self.output_dir / f"{report_id}.md"
|
|
646
|
+
|
|
647
|
+
if not report_path.exists():
|
|
648
|
+
# 리포트 목록 반환
|
|
649
|
+
reports = [f.stem for f in self.output_dir.glob("*.md")]
|
|
650
|
+
return {
|
|
651
|
+
"success": False,
|
|
652
|
+
"error": f"Report not found: {report_id}",
|
|
653
|
+
"available_reports": reports,
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
async with aiofiles.open(report_path, encoding="utf-8") as f:
|
|
657
|
+
content = await f.read()
|
|
658
|
+
|
|
659
|
+
return {
|
|
660
|
+
"success": True,
|
|
661
|
+
"report_id": report_id,
|
|
662
|
+
"path": str(report_path),
|
|
663
|
+
"content": content,
|
|
664
|
+
"size": len(content),
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
async def list_reports(self) -> dict:
|
|
668
|
+
"""List generated reports"""
|
|
669
|
+
reports = []
|
|
670
|
+
|
|
671
|
+
for f in self.output_dir.glob("*.md"):
|
|
672
|
+
stat = f.stat()
|
|
673
|
+
reports.append(
|
|
674
|
+
{
|
|
675
|
+
"report_id": f.stem,
|
|
676
|
+
"path": str(f),
|
|
677
|
+
"size": stat.st_size,
|
|
678
|
+
"created": datetime.fromtimestamp(stat.st_ctime).isoformat(),
|
|
679
|
+
"modified": datetime.fromtimestamp(stat.st_mtime).isoformat(),
|
|
680
|
+
}
|
|
681
|
+
)
|
|
682
|
+
|
|
683
|
+
# 최신순 정렬
|
|
684
|
+
reports.sort(key=lambda x: x["created"], reverse=True)
|
|
685
|
+
|
|
686
|
+
return {"total": len(reports), "reports": reports}
|
|
687
|
+
|
|
688
|
+
# =========================================================================
|
|
689
|
+
# Email / Delivery
|
|
690
|
+
# =========================================================================
|
|
691
|
+
|
|
692
|
+
async def get_email_status(self) -> dict:
|
|
693
|
+
"""Check email configuration status"""
|
|
694
|
+
return {
|
|
695
|
+
"configured": self.email_config.is_configured,
|
|
696
|
+
"smtp_server": self.email_config.smtp_server or "(not set)",
|
|
697
|
+
"smtp_port": self.email_config.smtp_port,
|
|
698
|
+
"username": self.email_config.username or "(not set)",
|
|
699
|
+
"use_tls": self.email_config.use_tls,
|
|
700
|
+
"sender_name": self.email_config.sender_name,
|
|
701
|
+
"quick_contacts_count": len(self.quick_contacts),
|
|
702
|
+
"hint": "Set environment variables or use configure_report_email tool"
|
|
703
|
+
if not self.email_config.is_configured
|
|
704
|
+
else None,
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
async def configure_email(
|
|
708
|
+
self,
|
|
709
|
+
smtp_server: str,
|
|
710
|
+
smtp_port: int = 587,
|
|
711
|
+
username: str = "",
|
|
712
|
+
password: str = "",
|
|
713
|
+
use_tls: bool = True,
|
|
714
|
+
sender_name: str = "Reversecore_MCP",
|
|
715
|
+
) -> dict:
|
|
716
|
+
"""Configure email settings (runtime override, takes precedence over env vars)"""
|
|
717
|
+
self.email_config = EmailConfig(
|
|
718
|
+
smtp_server=smtp_server,
|
|
719
|
+
smtp_port=smtp_port,
|
|
720
|
+
username=username,
|
|
721
|
+
password=password,
|
|
722
|
+
use_tls=use_tls,
|
|
723
|
+
sender_name=sender_name,
|
|
724
|
+
)
|
|
725
|
+
|
|
726
|
+
return {
|
|
727
|
+
"success": True,
|
|
728
|
+
"smtp_server": smtp_server,
|
|
729
|
+
"smtp_port": smtp_port,
|
|
730
|
+
"use_tls": use_tls,
|
|
731
|
+
"sender_name": sender_name,
|
|
732
|
+
"configured": self.email_config.is_configured,
|
|
733
|
+
"message": "Email configuration updated (runtime override)",
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
async def add_quick_contact(
|
|
737
|
+
self, name: str, email: str, role: str = "Security Analyst"
|
|
738
|
+
) -> dict:
|
|
739
|
+
"""Add quick contact"""
|
|
740
|
+
self.quick_contacts[name] = {"email": email, "role": role}
|
|
741
|
+
|
|
742
|
+
return {
|
|
743
|
+
"success": True,
|
|
744
|
+
"contact": {"name": name, "email": email, "role": role},
|
|
745
|
+
"total_contacts": len(self.quick_contacts),
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
async def list_quick_contacts(self) -> dict:
|
|
749
|
+
"""List quick contacts"""
|
|
750
|
+
return {
|
|
751
|
+
"total": len(self.quick_contacts),
|
|
752
|
+
"contacts": [{"name": name, **info} for name, info in self.quick_contacts.items()],
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
async def send_report(
|
|
756
|
+
self,
|
|
757
|
+
report_id: str,
|
|
758
|
+
recipients: list[str],
|
|
759
|
+
subject: str | None = None,
|
|
760
|
+
message: str | None = None,
|
|
761
|
+
include_attachment: bool = True,
|
|
762
|
+
) -> dict:
|
|
763
|
+
"""
|
|
764
|
+
Send a report via email.
|
|
765
|
+
|
|
766
|
+
Args:
|
|
767
|
+
report_id: Report ID to send
|
|
768
|
+
recipients: List of recipient email addresses
|
|
769
|
+
subject: Email subject (auto-generated by default)
|
|
770
|
+
message: Email body
|
|
771
|
+
include_attachment: Whether to attach the report file
|
|
772
|
+
"""
|
|
773
|
+
# 리포트 확인
|
|
774
|
+
report_path = self.output_dir / f"{report_id}.md"
|
|
775
|
+
if not report_path.exists():
|
|
776
|
+
return {"success": False, "error": f"Report not found: {report_id}"}
|
|
777
|
+
|
|
778
|
+
# 이메일 설정 확인
|
|
779
|
+
if not self.email_config.is_configured:
|
|
780
|
+
return {
|
|
781
|
+
"success": False,
|
|
782
|
+
"error": "Email not configured. Set environment variables (REPORT_SMTP_SERVER, REPORT_SMTP_USERNAME, REPORT_SMTP_PASSWORD) or use configure_report_email tool.",
|
|
783
|
+
"hint": "Copy .env.example to .env and fill in your SMTP settings",
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
# 빠른 연락처 이름을 이메일로 변환
|
|
787
|
+
resolved_recipients = []
|
|
788
|
+
for r in recipients:
|
|
789
|
+
if r in self.quick_contacts:
|
|
790
|
+
resolved_recipients.append(self.quick_contacts[r]["email"])
|
|
791
|
+
else:
|
|
792
|
+
resolved_recipients.append(r)
|
|
793
|
+
|
|
794
|
+
async with aiofiles.open(report_path, encoding="utf-8") as f:
|
|
795
|
+
report_content = await f.read()
|
|
796
|
+
|
|
797
|
+
# 기본 제목
|
|
798
|
+
if not subject:
|
|
799
|
+
subject = f"[Malware Analysis Report] {report_id}"
|
|
800
|
+
|
|
801
|
+
# 기본 메시지
|
|
802
|
+
if not message:
|
|
803
|
+
ts = self.get_timestamp_data()
|
|
804
|
+
message = f"""안녕하세요,
|
|
805
|
+
|
|
806
|
+
새로운 악성코드 분석 리포트가 생성되었습니다.
|
|
807
|
+
|
|
808
|
+
리포트 ID: {report_id}
|
|
809
|
+
생성 시간: {ts["datetime_full"]}
|
|
810
|
+
|
|
811
|
+
상세 내용은 첨부 파일 또는 아래 내용을 확인해주세요.
|
|
812
|
+
|
|
813
|
+
---
|
|
814
|
+
|
|
815
|
+
{report_content[:2000]}{"...(truncated)" if len(report_content) > 2000 else ""}
|
|
816
|
+
|
|
817
|
+
---
|
|
818
|
+
|
|
819
|
+
이 리포트는 Reversecore_MCP에 의해 자동 생성되었습니다.
|
|
820
|
+
"""
|
|
821
|
+
|
|
822
|
+
try:
|
|
823
|
+
# 이메일 구성
|
|
824
|
+
msg = MIMEMultipart()
|
|
825
|
+
msg["From"] = self.email_config.username
|
|
826
|
+
msg["To"] = ", ".join(resolved_recipients)
|
|
827
|
+
msg["Subject"] = subject
|
|
828
|
+
|
|
829
|
+
msg.attach(MIMEText(message, "plain", "utf-8"))
|
|
830
|
+
|
|
831
|
+
# 첨부파일
|
|
832
|
+
if include_attachment:
|
|
833
|
+
attachment = MIMEBase("application", "octet-stream")
|
|
834
|
+
attachment.set_payload(report_content.encode("utf-8"))
|
|
835
|
+
encoders.encode_base64(attachment)
|
|
836
|
+
attachment.add_header("Content-Disposition", f"attachment; filename={report_id}.md")
|
|
837
|
+
msg.attach(attachment)
|
|
838
|
+
|
|
839
|
+
# 전송 (Native async SMTP - no thread pool blocking)
|
|
840
|
+
await aiosmtplib.send(
|
|
841
|
+
msg,
|
|
842
|
+
hostname=self.email_config.smtp_server,
|
|
843
|
+
port=self.email_config.smtp_port,
|
|
844
|
+
start_tls=self.email_config.use_tls,
|
|
845
|
+
username=self.email_config.username if self.email_config.username else None,
|
|
846
|
+
password=self.email_config.password if self.email_config.password else None,
|
|
847
|
+
)
|
|
848
|
+
|
|
849
|
+
return {
|
|
850
|
+
"success": True,
|
|
851
|
+
"report_id": report_id,
|
|
852
|
+
"recipients": resolved_recipients,
|
|
853
|
+
"subject": subject,
|
|
854
|
+
"attachment_included": include_attachment,
|
|
855
|
+
"sent_at": self._format_time(datetime.now(timezone.utc)),
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
except Exception as e:
|
|
859
|
+
logger.error(f"Failed to send email: {e}")
|
|
860
|
+
return {
|
|
861
|
+
"success": False,
|
|
862
|
+
"error": str(e),
|
|
863
|
+
"report_id": report_id,
|
|
864
|
+
"recipients": resolved_recipients,
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
# =========================================================================
|
|
868
|
+
# Helper Methods
|
|
869
|
+
# =========================================================================
|
|
870
|
+
|
|
871
|
+
async def _extract_sample_info(self, sample_path: str) -> dict:
|
|
872
|
+
"""Extract metadata from sample file"""
|
|
873
|
+
path = Path(sample_path)
|
|
874
|
+
|
|
875
|
+
if not path.exists():
|
|
876
|
+
return {"filename": path.name, "error": "File not found"}
|
|
877
|
+
|
|
878
|
+
# Stream file read to prevent memory explosion with large files
|
|
879
|
+
md5_hash = hashlib.md5()
|
|
880
|
+
sha1_hash = hashlib.sha1()
|
|
881
|
+
sha256_hash = hashlib.sha256()
|
|
882
|
+
|
|
883
|
+
file_size = 0
|
|
884
|
+
first_chunk = b""
|
|
885
|
+
|
|
886
|
+
async with aiofiles.open(path, mode="rb") as f:
|
|
887
|
+
while chunk := await f.read(64 * 1024): # 64KB chunks
|
|
888
|
+
if not first_chunk:
|
|
889
|
+
first_chunk = chunk
|
|
890
|
+
file_size += len(chunk)
|
|
891
|
+
md5_hash.update(chunk)
|
|
892
|
+
sha1_hash.update(chunk)
|
|
893
|
+
sha256_hash.update(chunk)
|
|
894
|
+
|
|
895
|
+
stat = path.stat()
|
|
896
|
+
|
|
897
|
+
info = {
|
|
898
|
+
"filename": path.name,
|
|
899
|
+
"filepath": str(path.absolute()),
|
|
900
|
+
"filesize": file_size,
|
|
901
|
+
"filesize_hr": self._human_readable_size(file_size),
|
|
902
|
+
"md5": md5_hash.hexdigest(),
|
|
903
|
+
"sha1": sha1_hash.hexdigest(),
|
|
904
|
+
"sha256": sha256_hash.hexdigest(),
|
|
905
|
+
"file_created": datetime.fromtimestamp(stat.st_ctime).isoformat(),
|
|
906
|
+
"file_modified": datetime.fromtimestamp(stat.st_mtime).isoformat(),
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
# 파일 타입 식별
|
|
910
|
+
info["file_type"] = self._identify_file_type(first_chunk)
|
|
911
|
+
|
|
912
|
+
return info
|
|
913
|
+
|
|
914
|
+
@staticmethod
|
|
915
|
+
def _identify_file_type(data: bytes) -> str:
|
|
916
|
+
"""Identify file type"""
|
|
917
|
+
if len(data) < 4:
|
|
918
|
+
return "Unknown (too small)"
|
|
919
|
+
|
|
920
|
+
magic_bytes = {
|
|
921
|
+
b"MZ": "PE Executable (Windows)",
|
|
922
|
+
b"\x7fELF": "ELF Executable (Linux)",
|
|
923
|
+
b"%PDF": "PDF Document",
|
|
924
|
+
b"PK": "ZIP Archive / Office Document",
|
|
925
|
+
b"\xd0\xcf\x11\xe0": "OLE Compound File (Office)",
|
|
926
|
+
b"Rar!": "RAR Archive",
|
|
927
|
+
b"\x1f\x8b": "GZIP Archive",
|
|
928
|
+
b"BZ": "BZIP2 Archive",
|
|
929
|
+
b"\x89PNG": "PNG Image",
|
|
930
|
+
b"\xff\xd8\xff": "JPEG Image",
|
|
931
|
+
b"GIF8": "GIF Image",
|
|
932
|
+
b"<!DO": "HTML Document",
|
|
933
|
+
b"<?xm": "XML Document",
|
|
934
|
+
b"{\n ": "JSON Document",
|
|
935
|
+
b"#!": "Script (Shell/Python)",
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
for magic, file_type in magic_bytes.items():
|
|
939
|
+
if data.startswith(magic):
|
|
940
|
+
return file_type
|
|
941
|
+
|
|
942
|
+
# ASCII 텍스트 체크
|
|
943
|
+
try:
|
|
944
|
+
data[:1000].decode("utf-8")
|
|
945
|
+
return "Text/Script File"
|
|
946
|
+
except UnicodeDecodeError:
|
|
947
|
+
pass
|
|
948
|
+
|
|
949
|
+
return "Unknown Binary"
|
|
950
|
+
|
|
951
|
+
@staticmethod
|
|
952
|
+
def _human_readable_size(size: int) -> str:
|
|
953
|
+
"""Convert bytes to human-readable format"""
|
|
954
|
+
for unit in ["B", "KB", "MB", "GB"]:
|
|
955
|
+
if size < 1024:
|
|
956
|
+
return f"{size:,.1f} {unit}"
|
|
957
|
+
size /= 1024
|
|
958
|
+
return f"{size:,.1f} TB"
|
|
959
|
+
|
|
960
|
+
@staticmethod
|
|
961
|
+
def _get_severity_emoji(severity: str) -> str:
|
|
962
|
+
"""Get severity emoji"""
|
|
963
|
+
emojis = {"low": "🟢", "medium": "🟡", "high": "🟠", "critical": "🔴"}
|
|
964
|
+
return emojis.get(severity.lower(), "⚪")
|
|
965
|
+
|
|
966
|
+
def _format_iocs_yaml(self, iocs: dict[str, list[str]]) -> str:
|
|
967
|
+
"""Format IOCs in YAML format"""
|
|
968
|
+
lines = []
|
|
969
|
+
for ioc_type, values in iocs.items():
|
|
970
|
+
if values:
|
|
971
|
+
lines.append(f"{ioc_type}:")
|
|
972
|
+
for v in values:
|
|
973
|
+
lines.append(f" - {v}")
|
|
974
|
+
return "\n".join(lines) if lines else "# No IOCs collected"
|
|
975
|
+
|
|
976
|
+
def _format_iocs_markdown(self, iocs: dict[str, list[str]]) -> str:
|
|
977
|
+
"""Format IOCs in Markdown format"""
|
|
978
|
+
lines = []
|
|
979
|
+
for ioc_type, values in iocs.items():
|
|
980
|
+
if values:
|
|
981
|
+
lines.append(f"### {ioc_type.title()}")
|
|
982
|
+
for v in values:
|
|
983
|
+
lines.append(f"- `{v}`")
|
|
984
|
+
lines.append("")
|
|
985
|
+
return "\n".join(lines) if lines else "_No IOCs collected._"
|
|
986
|
+
|
|
987
|
+
def _format_mitre_table(self, techniques: list[dict[str, str]]) -> str:
|
|
988
|
+
"""Format MITRE techniques as Markdown table"""
|
|
989
|
+
if not techniques:
|
|
990
|
+
return "| - | - | - |"
|
|
991
|
+
|
|
992
|
+
lines = []
|
|
993
|
+
for t in techniques:
|
|
994
|
+
lines.append(f"| {t['tactic']} | {t['name']} | `{t['id']}` |")
|
|
995
|
+
return "\n".join(lines)
|
|
996
|
+
|
|
997
|
+
def _format_notes(self, notes: list[dict[str, str]]) -> str:
|
|
998
|
+
"""Format analysis notes"""
|
|
999
|
+
if not notes:
|
|
1000
|
+
return "_No notes recorded._"
|
|
1001
|
+
|
|
1002
|
+
lines = []
|
|
1003
|
+
for n in notes:
|
|
1004
|
+
ts = n["timestamp"][:19].replace("T", " ") # ISO to readable
|
|
1005
|
+
category = n.get("category", "general")
|
|
1006
|
+
category_emoji = {
|
|
1007
|
+
"general": "📝",
|
|
1008
|
+
"finding": "🔍",
|
|
1009
|
+
"warning": "⚠️",
|
|
1010
|
+
"important": "❗",
|
|
1011
|
+
"behavior": "🎯",
|
|
1012
|
+
}.get(category, "📝")
|
|
1013
|
+
lines.append(f"- {category_emoji} **[{ts}]** {n['note']}")
|
|
1014
|
+
return "\n".join(lines)
|
|
1015
|
+
|
|
1016
|
+
|
|
1017
|
+
# 싱글톤 인스턴스 (기본 경로)
|
|
1018
|
+
_default_report_tools: ReportTools | None = None
|
|
1019
|
+
|
|
1020
|
+
|
|
1021
|
+
def get_report_tools(
|
|
1022
|
+
template_dir: Path | None = None,
|
|
1023
|
+
output_dir: Path | None = None,
|
|
1024
|
+
default_timezone: str | None = None,
|
|
1025
|
+
) -> ReportTools:
|
|
1026
|
+
"""
|
|
1027
|
+
ReportTools 싱글톤 인스턴스 반환
|
|
1028
|
+
|
|
1029
|
+
환경변수 지원:
|
|
1030
|
+
- REPORT_DEFAULT_TIMEZONE: 기본 타임존 (default: Asia/Seoul)
|
|
1031
|
+
- REPORT_SMTP_SERVER: SMTP 서버 주소
|
|
1032
|
+
- REPORT_SMTP_PORT: SMTP 포트 (default: 587)
|
|
1033
|
+
- REPORT_SMTP_USERNAME: 이메일 계정
|
|
1034
|
+
- REPORT_SMTP_PASSWORD: 이메일 비밀번호
|
|
1035
|
+
- REPORT_SMTP_USE_TLS: TLS 사용 여부 (default: true)
|
|
1036
|
+
- REPORT_SENDER_NAME: 발신자 이름 (default: Reversecore_MCP)
|
|
1037
|
+
- REPORT_QUICK_CONTACTS: 빠른 연락처 (format: name:email:role,...)
|
|
1038
|
+
- REPORT_DEFAULT_CLASSIFICATION: 기본 TLP 분류 (default: TLP:AMBER)
|
|
1039
|
+
- REPORT_DEFAULT_ANALYST: 기본 분석가 이름
|
|
1040
|
+
"""
|
|
1041
|
+
global _default_report_tools
|
|
1042
|
+
|
|
1043
|
+
if _default_report_tools is None:
|
|
1044
|
+
# 환경변수에서 설정 로드
|
|
1045
|
+
env_timezone = os.getenv("REPORT_DEFAULT_TIMEZONE", "Asia/Seoul")
|
|
1046
|
+
|
|
1047
|
+
# 이메일 설정 로드
|
|
1048
|
+
email_config = EmailConfig.from_env()
|
|
1049
|
+
|
|
1050
|
+
# ReportTools 인스턴스 생성
|
|
1051
|
+
_default_report_tools = ReportTools(
|
|
1052
|
+
template_dir=template_dir or Path("templates/reports"),
|
|
1053
|
+
output_dir=output_dir or Path("reports"),
|
|
1054
|
+
default_timezone=default_timezone or env_timezone,
|
|
1055
|
+
email_config=email_config,
|
|
1056
|
+
)
|
|
1057
|
+
|
|
1058
|
+
# 환경변수에서 빠른 연락처 로드
|
|
1059
|
+
env_contacts = load_quick_contacts_from_env()
|
|
1060
|
+
_default_report_tools.quick_contacts.update(env_contacts)
|
|
1061
|
+
|
|
1062
|
+
# 로그 출력
|
|
1063
|
+
logger.info("📋 ReportTools initialized:")
|
|
1064
|
+
logger.info(f" - Timezone: {_default_report_tools.default_timezone}")
|
|
1065
|
+
logger.info(
|
|
1066
|
+
f" - Email: {'✅ Configured' if email_config.is_configured else '❌ Not configured'}"
|
|
1067
|
+
)
|
|
1068
|
+
logger.info(f" - Quick contacts: {len(_default_report_tools.quick_contacts)}")
|
|
1069
|
+
|
|
1070
|
+
return _default_report_tools
|
|
1071
|
+
|
|
1072
|
+
|
|
1073
|
+
def reset_report_tools() -> None:
|
|
1074
|
+
"""ReportTools 싱글톤 인스턴스 리셋 (테스트용)"""
|
|
1075
|
+
global _default_report_tools
|
|
1076
|
+
_default_report_tools = None
|