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.
Files changed (79) hide show
  1. iflow_mcp_developermode_korea_reversecore_mcp-1.0.0.dist-info/METADATA +543 -0
  2. iflow_mcp_developermode_korea_reversecore_mcp-1.0.0.dist-info/RECORD +79 -0
  3. iflow_mcp_developermode_korea_reversecore_mcp-1.0.0.dist-info/WHEEL +5 -0
  4. iflow_mcp_developermode_korea_reversecore_mcp-1.0.0.dist-info/entry_points.txt +2 -0
  5. iflow_mcp_developermode_korea_reversecore_mcp-1.0.0.dist-info/licenses/LICENSE +21 -0
  6. iflow_mcp_developermode_korea_reversecore_mcp-1.0.0.dist-info/top_level.txt +1 -0
  7. reversecore_mcp/__init__.py +9 -0
  8. reversecore_mcp/core/__init__.py +78 -0
  9. reversecore_mcp/core/audit.py +101 -0
  10. reversecore_mcp/core/binary_cache.py +138 -0
  11. reversecore_mcp/core/command_spec.py +357 -0
  12. reversecore_mcp/core/config.py +432 -0
  13. reversecore_mcp/core/container.py +288 -0
  14. reversecore_mcp/core/decorators.py +152 -0
  15. reversecore_mcp/core/error_formatting.py +93 -0
  16. reversecore_mcp/core/error_handling.py +142 -0
  17. reversecore_mcp/core/evidence.py +229 -0
  18. reversecore_mcp/core/exceptions.py +296 -0
  19. reversecore_mcp/core/execution.py +240 -0
  20. reversecore_mcp/core/ghidra.py +642 -0
  21. reversecore_mcp/core/ghidra_helper.py +481 -0
  22. reversecore_mcp/core/ghidra_manager.py +234 -0
  23. reversecore_mcp/core/json_utils.py +131 -0
  24. reversecore_mcp/core/loader.py +73 -0
  25. reversecore_mcp/core/logging_config.py +206 -0
  26. reversecore_mcp/core/memory.py +721 -0
  27. reversecore_mcp/core/metrics.py +198 -0
  28. reversecore_mcp/core/mitre_mapper.py +365 -0
  29. reversecore_mcp/core/plugin.py +45 -0
  30. reversecore_mcp/core/r2_helpers.py +404 -0
  31. reversecore_mcp/core/r2_pool.py +403 -0
  32. reversecore_mcp/core/report_generator.py +268 -0
  33. reversecore_mcp/core/resilience.py +252 -0
  34. reversecore_mcp/core/resource_manager.py +169 -0
  35. reversecore_mcp/core/result.py +132 -0
  36. reversecore_mcp/core/security.py +213 -0
  37. reversecore_mcp/core/validators.py +238 -0
  38. reversecore_mcp/dashboard/__init__.py +221 -0
  39. reversecore_mcp/prompts/__init__.py +56 -0
  40. reversecore_mcp/prompts/common.py +24 -0
  41. reversecore_mcp/prompts/game.py +280 -0
  42. reversecore_mcp/prompts/malware.py +1219 -0
  43. reversecore_mcp/prompts/report.py +150 -0
  44. reversecore_mcp/prompts/security.py +136 -0
  45. reversecore_mcp/resources.py +329 -0
  46. reversecore_mcp/server.py +727 -0
  47. reversecore_mcp/tools/__init__.py +49 -0
  48. reversecore_mcp/tools/analysis/__init__.py +74 -0
  49. reversecore_mcp/tools/analysis/capa_tools.py +215 -0
  50. reversecore_mcp/tools/analysis/die_tools.py +180 -0
  51. reversecore_mcp/tools/analysis/diff_tools.py +643 -0
  52. reversecore_mcp/tools/analysis/lief_tools.py +272 -0
  53. reversecore_mcp/tools/analysis/signature_tools.py +591 -0
  54. reversecore_mcp/tools/analysis/static_analysis.py +479 -0
  55. reversecore_mcp/tools/common/__init__.py +58 -0
  56. reversecore_mcp/tools/common/file_operations.py +352 -0
  57. reversecore_mcp/tools/common/memory_tools.py +516 -0
  58. reversecore_mcp/tools/common/patch_explainer.py +230 -0
  59. reversecore_mcp/tools/common/server_tools.py +115 -0
  60. reversecore_mcp/tools/ghidra/__init__.py +19 -0
  61. reversecore_mcp/tools/ghidra/decompilation.py +975 -0
  62. reversecore_mcp/tools/ghidra/ghidra_tools.py +1052 -0
  63. reversecore_mcp/tools/malware/__init__.py +61 -0
  64. reversecore_mcp/tools/malware/adaptive_vaccine.py +579 -0
  65. reversecore_mcp/tools/malware/dormant_detector.py +756 -0
  66. reversecore_mcp/tools/malware/ioc_tools.py +228 -0
  67. reversecore_mcp/tools/malware/vulnerability_hunter.py +519 -0
  68. reversecore_mcp/tools/malware/yara_tools.py +214 -0
  69. reversecore_mcp/tools/patch_explainer.py +19 -0
  70. reversecore_mcp/tools/radare2/__init__.py +13 -0
  71. reversecore_mcp/tools/radare2/r2_analysis.py +972 -0
  72. reversecore_mcp/tools/radare2/r2_session.py +376 -0
  73. reversecore_mcp/tools/radare2/radare2_mcp_tools.py +1183 -0
  74. reversecore_mcp/tools/report/__init__.py +4 -0
  75. reversecore_mcp/tools/report/email.py +82 -0
  76. reversecore_mcp/tools/report/report_mcp_tools.py +344 -0
  77. reversecore_mcp/tools/report/report_tools.py +1076 -0
  78. reversecore_mcp/tools/report/session.py +194 -0
  79. 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