Maximize RedEdr telemetry extraction
This commit is contained in:
@@ -8,6 +8,8 @@ All notable changes to this project will be documented in this file.
|
||||
- `/api/results/<target>/risk` endpoint and matching `grumpycat.get_risk_assessment()` client method
|
||||
- `GrumpyCats/install_mcp.py` — installer for six MCP clients with auto-detected venv Python
|
||||
- Command-line arguments input on the dynamic-analysis warning modal (pre-populated from last run)
|
||||
- RedEdr now captures Microsoft-Windows-Kernel-File / -Network / -Audit-API-Calls / Antimalware-Engine ETW events; new tabs surface File Ops / Network / Audit API / Defender with Process Tree panel and ETW Provider Diagnostics
|
||||
- Defender threat verdicts at runtime contribute +50 to the Detection Score (only verdicts; scan activity stays descriptive)
|
||||
|
||||
### Changed
|
||||
- Backend split into Flask blueprints, services, and a `utils/` package; subprocess analyzers consolidated under `BaseSubprocessAnalyzer`
|
||||
@@ -21,6 +23,11 @@ All notable changes to this project will be documented in this file.
|
||||
- UI terminology reframed for operator-first reading: Detection Score, Triggering Indicators, Sensitive Imports, Signature triggered, Critical Imports, Payload Analysis
|
||||
- Color palette softened across the app — severity tokens shifted -500 → -400, summary risk badges converted from solid bg to outlined chips, heavy rgba alphas tightened
|
||||
- Analysis-type cards now show explicit `Run X Scan →` CTAs with stronger hover state
|
||||
- RedEdr launch line is now `--etw --show --with-antimalwareengine --with-defendertrace --trace ...` (replaces broken `-e --trace` which RedEdr's cxxopts schema didn't recognize)
|
||||
- Payload now fires as soon as RedEdr signals ETW-providers-attached (1-3s typical) instead of a fixed 15s sleep
|
||||
- Module-load timeline deduplicates PEB-snapshot DLLs against ETW image_loads; kernel device paths stripped to basenames
|
||||
- ETW timestamps shown as `HH:MM:SS.mmm` (FILETIME → local time) instead of raw 64-bit values
|
||||
- Defender events split into threat / scan / internal categories; the noise table is collapsed by default with a verdict line summarizing what Defender did
|
||||
|
||||
### Fixed
|
||||
- XSS hardening at user-data interpolation sites in results-page renderers
|
||||
@@ -28,11 +35,15 @@ All notable changes to this project will be documented in this file.
|
||||
- Per-tool render failures no longer suppress the rest of the rendering
|
||||
- Office macro upload no longer throws on missing `macroDetectionNotes` element (upstream issue)
|
||||
- `LitterBoxMCP.py` startup crash — broken import, removed `mcp.serve(...)` API, and stdout-corrupting logging all fixed
|
||||
- RedEdr parser was reading PascalCase ETW field names (ProcessID, ImageName, ThreadID, etc.) but RedEdr lowercases all field names; Threads / Images / Child Processes / CPU Priority tabs now populate with real data instead of nulls
|
||||
- Audit-API events now show `OpenProcess` / `OpenThread` (mapped from `etw_event_id`) instead of the placeholder task name `Info`
|
||||
- RedEdr is now always cleaned up on dynamic-analysis failure paths (early termination, payload crash, analyzer exception); previously left orphaned subprocesses
|
||||
|
||||
### Removed
|
||||
- Pre-redesign Tailwind utility chains and inline cyber-themed `<style>` blocks
|
||||
- Tailwind CDN runtime dependency from `report.html`
|
||||
- Dead code in `grumpycat.py` and `LitterBoxMCP.py` (cache, unused imports, exception envelope, lazy client wrapper)
|
||||
- `etw_wait_time` config key (replaced by event-driven readiness signal)
|
||||
|
||||
### Notes
|
||||
- No new dependencies; setup unchanged
|
||||
|
||||
+5
-2
@@ -103,9 +103,12 @@ analysis:
|
||||
|
||||
rededr:
|
||||
enabled: true
|
||||
etw_wait_time: 15 # Time in seconds to wait for ETW setup
|
||||
tool_path: ".\\Scanners\\RedEdr\\RedEdr.exe"
|
||||
command: "{tool_path} -e --trace {process_name}"
|
||||
# --etw: consume Kernel-Process / -File / -Network / -Audit-API ETW providers
|
||||
# --show: emit JSON events to stdout (otherwise only the web/file output is on)
|
||||
# --with-antimalwareengine: tap Microsoft-Antimalware-Engine ETW (Defender scan verdicts on our payload)
|
||||
# --with-defendertrace: also track msmpeng.exe events touching the target process
|
||||
command: "{tool_path} --etw --show --with-antimalwareengine --with-defendertrace --trace {process_name}"
|
||||
timeout: 120
|
||||
|
||||
|
||||
|
||||
Binary file not shown.
@@ -5,7 +5,81 @@ import logging
|
||||
import traceback
|
||||
from .base import DynamicAnalyzer
|
||||
|
||||
|
||||
# Microsoft-Windows-Kernel-Audit-API-Calls — the task name in the JSON is just
|
||||
# "Info" so we map etw_event_id → API name. RedEdr subscribes to ids {3,4,5,6}
|
||||
# (RedEdr/etwreader.cpp:320).
|
||||
_AUDIT_API_BY_ID = {
|
||||
3: 'CreateSymbolicLink',
|
||||
4: 'SetContextThread',
|
||||
5: 'OpenProcess',
|
||||
6: 'OpenThread',
|
||||
}
|
||||
|
||||
|
||||
# Microsoft-Antimalware-Engine emits "Behavior Monitoring Bm*" events.
|
||||
# Three categories with different operator value:
|
||||
# - Bm* "scan activity" (BmModuleLoad, BmNotificationHandleStart/Stop,
|
||||
# BmOpenProcess) — Defender's behavior monitor actively engaging with
|
||||
# our process. Count is signal: "Defender scanned the binary N times."
|
||||
# - Bm* "internal state" (BmInternal, BmEtw) — Defender's own telemetry
|
||||
# plumbing; not signal, hide by default.
|
||||
# - Threat verdict events (ThreatFound, MalwareFound, etc.) or any event
|
||||
# carrying a non-empty verdict field — actual detection.
|
||||
_DEFENDER_INTERNAL_SUBSTRINGS = (
|
||||
'bminternal',
|
||||
'bmetw',
|
||||
)
|
||||
|
||||
_DEFENDER_SCAN_SUBSTRINGS = (
|
||||
'bmmoduleload',
|
||||
'bmnotificationhandle',
|
||||
'bmopenprocess',
|
||||
)
|
||||
|
||||
|
||||
# Substrings that indicate a real Defender detection (not just monitoring).
|
||||
_DEFENDER_THREAT_SUBSTRINGS = (
|
||||
'threatfound',
|
||||
'threatdetect',
|
||||
'detectionadded',
|
||||
'malwarefound',
|
||||
'protectionalert',
|
||||
'detected',
|
||||
)
|
||||
|
||||
|
||||
def _classify_defender_event(event_name, verdict):
|
||||
"""Return one of 'threat' / 'scan' / 'internal' / 'other'."""
|
||||
if _is_defender_threat(event_name, verdict):
|
||||
return 'threat'
|
||||
lowered = (event_name or '').lower()
|
||||
if any(s in lowered for s in _DEFENDER_INTERNAL_SUBSTRINGS):
|
||||
return 'internal'
|
||||
if any(s in lowered for s in _DEFENDER_SCAN_SUBSTRINGS):
|
||||
return 'scan'
|
||||
return 'other'
|
||||
|
||||
|
||||
def _is_defender_threat(event_name, verdict):
|
||||
if verdict and isinstance(verdict, str) and verdict.strip():
|
||||
return True
|
||||
if event_name:
|
||||
lowered = event_name.lower()
|
||||
if any(s in lowered for s in _DEFENDER_THREAT_SUBSTRINGS):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class RedEdrAnalyzer(DynamicAnalyzer):
|
||||
# Readiness substring emitted by RedEdr.exe via loguru on stderr (which we
|
||||
# merge into stdout) once all ETW providers are attached. See
|
||||
# RedEdr/etwreader.cpp:431 — "ETW: All providers configured, ready to start
|
||||
# collecting". Fires immediately before the threadReadynessEtw event is
|
||||
# signaled, so when we observe this line ManagerStart has effectively
|
||||
# returned and RedEdr is collecting events.
|
||||
_READY_MARKER = 'All providers configured'
|
||||
|
||||
def __init__(self, config):
|
||||
super().__init__(config)
|
||||
self.tool_process = None
|
||||
@@ -16,22 +90,58 @@ class RedEdrAnalyzer(DynamicAnalyzer):
|
||||
self.logger = logging.getLogger(__name__)
|
||||
self.output_thread = None
|
||||
self._stop_reading = threading.Event()
|
||||
|
||||
self._ready_event = threading.Event()
|
||||
|
||||
def _reader_thread(self):
|
||||
"""Thread to read RedEdr output without blocking"""
|
||||
"""Thread to read RedEdr output without blocking.
|
||||
|
||||
Watches every line for the ETW-ready marker so callers can fire the
|
||||
payload as soon as RedEdr is actually collecting. The same `_ready_event`
|
||||
is also set on EOF (RedEdr exited) so `wait_for_ready()` never hangs
|
||||
forever — callers distinguish real readiness from process-death by
|
||||
checking `is_ready()`."""
|
||||
try:
|
||||
while not self._stop_reading.is_set():
|
||||
line = self.tool_process.stdout.readline()
|
||||
if not line:
|
||||
break
|
||||
|
||||
break # EOF — process closed stdout (likely exited)
|
||||
|
||||
line = line.strip()
|
||||
if line:
|
||||
with self._output_lock:
|
||||
self.collected_output.append(line)
|
||||
|
||||
if not self._ready_event.is_set() and self._READY_MARKER in line:
|
||||
self._ready_event.set()
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error in reader thread: {e}")
|
||||
finally:
|
||||
# Unblock wait_for_ready() on any reader exit (EOF, exception,
|
||||
# stop request) so callers never hang waiting on a dead process.
|
||||
self._ready_event.set()
|
||||
|
||||
def wait_for_ready(self):
|
||||
"""Block until RedEdr signals ETW-providers-attached, or until the
|
||||
reader thread exits (process died / pipe closed / stop requested).
|
||||
|
||||
No timeout — RedEdr's normal startup is bounded by ETW provider
|
||||
attachment (typically 1-3s) and any failure surfaces as a quick exit
|
||||
which trips the EOF path in the reader thread.
|
||||
|
||||
Use `is_ready()` after returning to distinguish real readiness from
|
||||
a dead-process unblock."""
|
||||
self._ready_event.wait()
|
||||
|
||||
def is_ready(self):
|
||||
"""True only if the readiness marker was actually seen on stdout."""
|
||||
if not self._ready_event.is_set():
|
||||
return False
|
||||
# Distinguish real readiness from a dead-process unblock by checking
|
||||
# the live process. If the subprocess exited before the marker fired,
|
||||
# is_ready() must return False so callers can error out cleanly.
|
||||
if self.tool_process is None:
|
||||
return False
|
||||
return self.tool_process.poll() is None
|
||||
|
||||
def start_tool(self, target_name):
|
||||
"""Start the RedEdr tool in monitoring mode"""
|
||||
@@ -75,8 +185,8 @@ class RedEdrAnalyzer(DynamicAnalyzer):
|
||||
def _parse_output(self, output):
|
||||
"""Parse RedEdr JSON output into structured data"""
|
||||
findings = {
|
||||
'events': [],
|
||||
'process_info': {
|
||||
'events': [],
|
||||
'process_info': {
|
||||
'commandline': None,
|
||||
'image_path': None,
|
||||
'working_dir': None,
|
||||
@@ -85,13 +195,20 @@ class RedEdrAnalyzer(DynamicAnalyzer):
|
||||
'is_protected_process': False,
|
||||
'pid': None,
|
||||
'start_time': None # Add start time field
|
||||
},
|
||||
'loaded_dlls': [],
|
||||
'child_processes': [],
|
||||
'threads': [],
|
||||
'image_loads': [],
|
||||
'image_unloads': [],
|
||||
'cpu_priority_changes': []
|
||||
},
|
||||
'loaded_dlls': [],
|
||||
'child_processes': [],
|
||||
'threads': [],
|
||||
'image_loads': [],
|
||||
'image_unloads': [],
|
||||
'cpu_priority_changes': [],
|
||||
# Categories sourced from additional ETW providers RedEdr taps
|
||||
# when launched with --etw / --with-antimalwareengine / --with-defendertrace.
|
||||
# ETW field names are lowercased by RedEdr's KrabsEtwEventToJsonStr.
|
||||
'file_operations': [], # Microsoft-Windows-Kernel-File
|
||||
'network_activity': [], # Microsoft-Windows-Kernel-Network
|
||||
'audit_api_calls': [], # Microsoft-Windows-Kernel-Audit-API-Calls
|
||||
'defender_events': [], # Microsoft-Antimalware-Engine + msmpeng track
|
||||
}
|
||||
|
||||
try:
|
||||
@@ -158,53 +275,134 @@ class RedEdrAnalyzer(DynamicAnalyzer):
|
||||
findings['loaded_dlls'].append(dlls)
|
||||
|
||||
# ETW Events
|
||||
# Field names emitted by RedEdr are lowercased by
|
||||
# KrabsEtwEventToJsonStr (RedEdrShared/etw_krabs.cpp:55).
|
||||
# Standard fields (etw_pid, etw_tid, etw_time,
|
||||
# etw_provider_name, etw_event_id, event, type, stack_trace)
|
||||
# keep their casing; everything else is lowercased.
|
||||
elif event_type == 'etw':
|
||||
if event.get('ProcessID') and not findings['process_info']['pid']:
|
||||
findings['process_info']['pid'] = event.get('ProcessID')
|
||||
if event.get('etw_pid') and not findings['process_info']['pid']:
|
||||
findings['process_info']['pid'] = event.get('etw_pid')
|
||||
|
||||
if event_name == 'ProcessStartStart':
|
||||
findings['child_processes'].append({
|
||||
'pid': event.get('ProcessID'),
|
||||
'parent_pid': event.get('ParentProcessID'),
|
||||
'image_name': event.get('ImageName'),
|
||||
'create_time': event.get('CreateTime')
|
||||
'pid': event.get('processid'),
|
||||
'parent_pid': event.get('parentprocessid'),
|
||||
'image_name': event.get('imagename'),
|
||||
'create_time': event.get('createtime') or event.get('etw_time'),
|
||||
})
|
||||
|
||||
elif event_name == 'ThreadStartStart':
|
||||
findings['threads'].append({
|
||||
'thread_id': event.get('ThreadID'),
|
||||
'process_id': event.get('ProcessID'),
|
||||
'start_addr': event.get('StartAddr'),
|
||||
'stack_base': event.get('StackBase')
|
||||
'thread_id': event.get('threadid'),
|
||||
'process_id': event.get('processid'),
|
||||
'start_addr': event.get('startaddr'),
|
||||
'stack_base': event.get('stackbase'),
|
||||
})
|
||||
|
||||
elif event_name == 'ImageLoadInfo':
|
||||
findings['image_loads'].append({
|
||||
'pid': event.get('ProcessID'),
|
||||
'image_name': event.get('ImageName'),
|
||||
'base': event.get('ImageBase'),
|
||||
'size': event.get('ImageSize'),
|
||||
'time_stamp': event.get('time'), # Use ETW time
|
||||
'pid': event.get('processid'),
|
||||
'image_name': event.get('imagename'),
|
||||
'base': event.get('imagebase'),
|
||||
'size': event.get('imagesize'),
|
||||
'time_stamp': event.get('etw_time') or event.get('time'),
|
||||
'stack_trace': event.get('stack_trace', []),
|
||||
})
|
||||
|
||||
elif event_name == 'ImageUnloadInfo':
|
||||
findings['image_unloads'].append({
|
||||
'pid': event.get('ProcessID'),
|
||||
'image_name': event.get('ImageName'),
|
||||
'base': event.get('ImageBase'),
|
||||
'size': event.get('ImageSize'),
|
||||
'time_stamp': event.get('time'),
|
||||
'pid': event.get('processid'),
|
||||
'image_name': event.get('imagename'),
|
||||
'base': event.get('imagebase'),
|
||||
'size': event.get('imagesize'),
|
||||
'time_stamp': event.get('etw_time') or event.get('time'),
|
||||
'stack_trace': event.get('stack_trace', []),
|
||||
})
|
||||
|
||||
|
||||
elif event_name in ['CpuBasePriorityChangeInfo', 'CpuPriorityChangeInfo']:
|
||||
findings['cpu_priority_changes'].append({
|
||||
'pid': event.get('ProcessID'),
|
||||
'thread_id': event.get('ThreadID'),
|
||||
'old_priority': event.get('OldPriority'),
|
||||
'new_priority': event.get('NewPriority'),
|
||||
'time': event.get('time')
|
||||
'pid': event.get('processid'),
|
||||
'thread_id': event.get('threadid'),
|
||||
'old_priority': event.get('oldpriority'),
|
||||
'new_priority': event.get('newpriority'),
|
||||
'time': event.get('etw_time') or event.get('time'),
|
||||
})
|
||||
|
||||
# ETW provider-based dispatch for the categories the
|
||||
# parser used to ignore. Routes by provider name; field
|
||||
# names are lowercased by RedEdr — try a couple of
|
||||
# likely keys per slot since the ETW manifest varies.
|
||||
provider = event.get('etw_provider_name', '')
|
||||
if provider == 'Microsoft-Windows-Kernel-File':
|
||||
findings['file_operations'].append({
|
||||
'path': event.get('filename') or event.get('filepath') or event.get('name'),
|
||||
'operation': event_name,
|
||||
'time': event.get('etw_time') or event.get('time'),
|
||||
'thread_id': event.get('etw_tid'),
|
||||
'pid': event.get('etw_pid'),
|
||||
'stack_trace': event.get('stack_trace', []),
|
||||
})
|
||||
elif provider == 'Microsoft-Windows-Kernel-Network':
|
||||
findings['network_activity'].append({
|
||||
'proto': 'tcp' if 'tcp' in event_name.lower() else ('udp' if 'udp' in event_name.lower() else 'unknown'),
|
||||
'operation': event_name,
|
||||
'local_addr': event.get('saddr'),
|
||||
'local_port': event.get('sport'),
|
||||
'remote_addr': event.get('daddr'),
|
||||
'remote_port': event.get('dport'),
|
||||
'size': event.get('size'),
|
||||
'time': event.get('etw_time') or event.get('time'),
|
||||
'pid': event.get('etw_pid') or event.get('pid'),
|
||||
'stack_trace': event.get('stack_trace', []),
|
||||
})
|
||||
elif provider == 'Microsoft-Windows-Kernel-Audit-API-Calls':
|
||||
# The provider's task name is just "Info" — the
|
||||
# actual API is identified by etw_event_id.
|
||||
# See RedEdr/etwreader.cpp:320 (events 3,4,5,6).
|
||||
api_name = _AUDIT_API_BY_ID.get(
|
||||
event.get('etw_event_id'),
|
||||
event_name or 'Unknown',
|
||||
)
|
||||
findings['audit_api_calls'].append({
|
||||
'api': api_name,
|
||||
'event_id': event.get('etw_event_id'),
|
||||
'target_pid': event.get('targetprocessid'),
|
||||
'target_tid': event.get('targetthreadid'),
|
||||
'desired_access': event.get('desiredaccess'),
|
||||
'return_code': event.get('returncode'),
|
||||
'time': event.get('etw_time') or event.get('time'),
|
||||
'caller_pid': event.get('etw_pid'),
|
||||
'caller_tid': event.get('etw_tid'),
|
||||
'stack_trace': event.get('stack_trace', []),
|
||||
})
|
||||
elif provider == 'Microsoft-Antimalware-Engine':
|
||||
verdict = event.get('threatname') or event.get('threatid') or event.get('result')
|
||||
category = _classify_defender_event(event_name, verdict)
|
||||
findings['defender_events'].append({
|
||||
'provider': 'antimalware_engine',
|
||||
'event': event_name,
|
||||
'event_id': event.get('etw_event_id'),
|
||||
'scan_target': event.get('filename') or event.get('name') or event.get('path'),
|
||||
'verdict': verdict,
|
||||
'severity': event.get('severityid') or event.get('severity'),
|
||||
'time': event.get('etw_time') or event.get('time'),
|
||||
'category': category,
|
||||
'is_threat': category == 'threat',
|
||||
})
|
||||
elif event.get('etw_process', '').lower() == 'msmpeng.exe':
|
||||
# Captured via --with-defendertrace: msmpeng activity touching our payload.
|
||||
verdict = event.get('threatname') or event.get('threatid') or event.get('result')
|
||||
category = _classify_defender_event(event_name, verdict)
|
||||
findings['defender_events'].append({
|
||||
'provider': 'defender_trace',
|
||||
'event': event_name,
|
||||
'event_id': event.get('etw_event_id'),
|
||||
'scan_target': event.get('filename') or event.get('name') or event.get('path'),
|
||||
'verdict': verdict,
|
||||
'time': event.get('etw_time') or event.get('time'),
|
||||
'category': category,
|
||||
'is_threat': category == 'threat',
|
||||
})
|
||||
|
||||
# Store all valid JSON events
|
||||
@@ -243,13 +441,21 @@ class RedEdrAnalyzer(DynamicAnalyzer):
|
||||
'image_loads': [],
|
||||
'image_unloads': [],
|
||||
'cpu_priority_changes': [],
|
||||
'file_operations': [],
|
||||
'network_activity': [],
|
||||
'audit_api_calls': [],
|
||||
'defender_events': [],
|
||||
'summary': {
|
||||
'total_events': 0,
|
||||
'total_dlls': 0,
|
||||
'total_child_processes': 0,
|
||||
'total_threads': 0,
|
||||
'total_image_loads': 0,
|
||||
'total_image_unloads': 0
|
||||
'total_image_unloads': 0,
|
||||
'total_file_operations': 0,
|
||||
'total_network_activity': 0,
|
||||
'total_audit_api_calls': 0,
|
||||
'total_defender_events': 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -280,18 +486,25 @@ class RedEdrAnalyzer(DynamicAnalyzer):
|
||||
})
|
||||
|
||||
# Update lists with actual data if available
|
||||
if 'loaded_dlls' in parsed_data:
|
||||
findings['loaded_dlls'] = parsed_data['loaded_dlls']
|
||||
if 'child_processes' in parsed_data:
|
||||
findings['child_processes'] = parsed_data['child_processes']
|
||||
if 'threads' in parsed_data:
|
||||
findings['threads'] = parsed_data['threads']
|
||||
if 'image_loads' in parsed_data:
|
||||
findings['image_loads'] = parsed_data['image_loads']
|
||||
if 'image_unloads' in parsed_data:
|
||||
findings['image_unloads'] = parsed_data['image_unloads']
|
||||
if 'cpu_priority_changes' in parsed_data:
|
||||
findings['cpu_priority_changes'] = parsed_data['cpu_priority_changes']
|
||||
for key in ('loaded_dlls', 'child_processes', 'threads',
|
||||
'image_loads', 'image_unloads', 'cpu_priority_changes',
|
||||
'file_operations', 'network_activity',
|
||||
'audit_api_calls', 'defender_events'):
|
||||
if key in parsed_data:
|
||||
findings[key] = parsed_data[key]
|
||||
|
||||
# Per-provider event counts (diagnostic). Surfaces whether ETW
|
||||
# actually delivered events from each provider RedEdr subscribes
|
||||
# to. A 0 count for Microsoft-Windows-Kernel-Network when the
|
||||
# payload made TCP connections almost always means the events
|
||||
# fired but were attributed to System(4) / svchost rather than
|
||||
# the payload PID — which RedEdr's event_callback_process drops.
|
||||
# Reliable network capture would require RedEdr's --hook mode
|
||||
# (kernel driver + DLL injection).
|
||||
events_by_provider = {}
|
||||
for ev in parsed_data.get('events', []):
|
||||
provider = ev.get('etw_provider_name') or ev.get('type') or 'unknown'
|
||||
events_by_provider[provider] = events_by_provider.get(provider, 0) + 1
|
||||
|
||||
# Update summary
|
||||
findings['summary'] = {
|
||||
@@ -300,7 +513,12 @@ class RedEdrAnalyzer(DynamicAnalyzer):
|
||||
'total_child_processes': len(findings['child_processes']),
|
||||
'total_threads': len(findings['threads']),
|
||||
'total_image_loads': len(findings['image_loads']),
|
||||
'total_image_unloads': len(findings['image_unloads'])
|
||||
'total_image_unloads': len(findings['image_unloads']),
|
||||
'total_file_operations': len(findings['file_operations']),
|
||||
'total_network_activity': len(findings['network_activity']),
|
||||
'total_audit_api_calls': len(findings['audit_api_calls']),
|
||||
'total_defender_events': len(findings['defender_events']),
|
||||
'events_by_provider': events_by_provider,
|
||||
}
|
||||
|
||||
findings['timeline'] = self._generate_timeline(findings)
|
||||
@@ -323,13 +541,31 @@ class RedEdrAnalyzer(DynamicAnalyzer):
|
||||
'findings': default_findings
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _module_basename(path):
|
||||
"""Strip kernel device prefixes / directories down to a basename.
|
||||
e.g. '\\Device\\HarddiskVolume3\\Windows\\System32\\cryptsp.dll' -> 'cryptsp.dll'."""
|
||||
if not path:
|
||||
return ''
|
||||
return path.replace('/', '\\').rsplit('\\', 1)[-1]
|
||||
|
||||
def _generate_timeline(self, parsed_data):
|
||||
"""
|
||||
Generate a chronological timeline of significant events.
|
||||
Each event must have a timestamp in a consistent format.
|
||||
|
||||
Module loads are de-duplicated across two sources that report the same
|
||||
thing differently:
|
||||
- ETW Microsoft-Windows-Kernel-Process ImageLoad events (one per
|
||||
actual load, with real per-event timestamps and full kernel paths)
|
||||
- The PEB-walk snapshot RedEdr emits when it first augments the
|
||||
target (one event with all currently-loaded modules)
|
||||
|
||||
We trust ETW first (better timing) and only fall back to PEB entries
|
||||
whose basename ETW didn't see. That covers the rare case where RedEdr
|
||||
attaches AFTER the target has already loaded some modules.
|
||||
"""
|
||||
timeline = []
|
||||
|
||||
|
||||
# Add process start if available
|
||||
if parsed_data.get('process_info', {}).get('pid'):
|
||||
timeline.append({
|
||||
@@ -337,7 +573,7 @@ class RedEdrAnalyzer(DynamicAnalyzer):
|
||||
'type': 'Process Start',
|
||||
'details': f"Process started: PID {parsed_data['process_info']['pid']}"
|
||||
})
|
||||
|
||||
|
||||
# Add child process creations
|
||||
for child in parsed_data.get('child_processes', []):
|
||||
timeline.append({
|
||||
@@ -346,20 +582,31 @@ class RedEdrAnalyzer(DynamicAnalyzer):
|
||||
'details': f"Created child process: {child.get('image_name', 'Unknown')} (PID: {child.get('pid', 'Unknown')})"
|
||||
})
|
||||
|
||||
# Add DLL loads
|
||||
for dll in parsed_data.get('loaded_dlls', []):
|
||||
timeline.append({
|
||||
'time': dll.get('time', None),
|
||||
'type': 'DLL Load',
|
||||
'details': f"Loaded DLL: {dll.get('name', 'Unknown')}"
|
||||
})
|
||||
|
||||
# Add image loads
|
||||
# ETW image loads — primary source for module-load timeline entries.
|
||||
seen_basenames = set()
|
||||
for img in parsed_data.get('image_loads', []):
|
||||
raw = img.get('image_name') or 'Unknown'
|
||||
basename = self._module_basename(raw) or raw
|
||||
seen_basenames.add(basename.lower())
|
||||
timeline.append({
|
||||
'time': img.get('time_stamp', None),
|
||||
'type': 'Image Load',
|
||||
'details': f"Loaded image: {img.get('image_name', 'Unknown')}"
|
||||
'details': f"Loaded image: {basename}",
|
||||
})
|
||||
|
||||
# PEB snapshot DLLs — only add ones ETW didn't already see, since the
|
||||
# snapshot is largely redundant with ETW for any process that started
|
||||
# under RedEdr's watch.
|
||||
for dll in parsed_data.get('loaded_dlls', []):
|
||||
name = dll.get('name') or ''
|
||||
basename = self._module_basename(name) or name
|
||||
if basename.lower() in seen_basenames:
|
||||
continue
|
||||
seen_basenames.add(basename.lower())
|
||||
timeline.append({
|
||||
'time': dll.get('time', None),
|
||||
'type': 'DLL Load',
|
||||
'details': f"Loaded DLL: {basename} (initial)",
|
||||
})
|
||||
|
||||
# Sort timeline by timestamp if available, otherwise keep original order
|
||||
@@ -376,23 +623,36 @@ class RedEdrAnalyzer(DynamicAnalyzer):
|
||||
return sorted_timeline
|
||||
|
||||
def cleanup(self):
|
||||
"""Stop the RedEdr process if it's still running"""
|
||||
"""Stop the RedEdr process if it's still running.
|
||||
|
||||
Idempotent — safe to call multiple times. Manager calls this in a
|
||||
try/finally so it can fire after the happy-path cleanup or after a
|
||||
crashed payload's exception path."""
|
||||
# Signal reader thread to stop
|
||||
self._stop_reading.set()
|
||||
|
||||
if self.tool_process:
|
||||
|
||||
if self.tool_process is None:
|
||||
return
|
||||
|
||||
proc = self.tool_process
|
||||
self.tool_process = None # Mark cleaned up before any I/O — second
|
||||
# call sees None and returns immediately.
|
||||
try:
|
||||
proc.terminate()
|
||||
proc.wait(timeout=5)
|
||||
|
||||
# Wait for reader thread to finish
|
||||
if self.output_thread and self.output_thread.is_alive():
|
||||
self.output_thread.join(timeout=2)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
except Exception as e:
|
||||
self.logger.warning(f"RedEdr cleanup: error while stopping subprocess: {e}")
|
||||
finally:
|
||||
try:
|
||||
self.tool_process.terminate()
|
||||
self.tool_process.wait(timeout=5)
|
||||
|
||||
# Wait for reader thread to finish
|
||||
if self.output_thread and self.output_thread.is_alive():
|
||||
self.output_thread.join(timeout=2)
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
self.tool_process.kill()
|
||||
finally:
|
||||
if self.tool_process.stdout:
|
||||
self.tool_process.stdout.close()
|
||||
if self.tool_process.stderr:
|
||||
self.tool_process.stderr.close()
|
||||
if proc.stdout:
|
||||
proc.stdout.close()
|
||||
if proc.stderr:
|
||||
proc.stderr.close()
|
||||
except Exception:
|
||||
pass
|
||||
+79
-25
@@ -169,66 +169,120 @@ class AnalysisManager:
|
||||
return self._create_error_result(start_time, str(e))
|
||||
|
||||
def _run_file_analysis(self, target: str, cmd_args: list, start_time: float) -> dict:
|
||||
"""Handle file-based analysis with RedEdr integration"""
|
||||
"""Handle file-based analysis with RedEdr integration.
|
||||
|
||||
RedEdr cleanup is in `finally` so an orphaned RedEdr can never outlive
|
||||
a crashed/early-terminated payload. Whatever telemetry RedEdr managed
|
||||
to collect is also attached to the response on failure paths, so the
|
||||
user sees partial events instead of nothing.
|
||||
"""
|
||||
results = {}
|
||||
process = None
|
||||
rededr = None
|
||||
|
||||
response = None
|
||||
|
||||
try:
|
||||
# 1. Start RedEdr if enabled
|
||||
rededr = self._initialize_rededr(target, results)
|
||||
|
||||
|
||||
# 2. Validate and start process
|
||||
try:
|
||||
process, pid = self._validate_process(target, False, cmd_args)
|
||||
except Exception as e:
|
||||
return self._handle_process_startup_error(e, start_time, cmd_args)
|
||||
|
||||
response = self._handle_process_startup_error(e, start_time, cmd_args)
|
||||
return response
|
||||
|
||||
# 3. Run regular analyzers (excluding RedEdr)
|
||||
regular_analyzers = {k: v for k, v in self.dynamic_analyzers.items() if k != 'rededr'}
|
||||
other_results = self._run_analyzers(regular_analyzers, pid, 'dynamic')
|
||||
results.update(other_results)
|
||||
|
||||
|
||||
# 4. Capture process output
|
||||
results['process_output'] = self._capture_process_output(process)
|
||||
|
||||
# 5. Get RedEdr results and cleanup
|
||||
|
||||
# 5. Get RedEdr results — cleanup is unconditional, in finally.
|
||||
if rededr:
|
||||
self.logger.debug("Getting RedEdr events")
|
||||
results['rededr'] = rededr.get_results()
|
||||
self._cleanup_rededr(rededr)
|
||||
|
||||
|
||||
results['analysis_metadata'] = self._create_metadata(
|
||||
start_time,
|
||||
early_termination=False,
|
||||
analysis_started=True,
|
||||
start_time,
|
||||
early_termination=False,
|
||||
analysis_started=True,
|
||||
cmd_args=cmd_args or []
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
response = results
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
return self._create_error_result(start_time, str(e), cmd_args)
|
||||
response = self._create_error_result(start_time, str(e), cmd_args)
|
||||
return response
|
||||
|
||||
finally:
|
||||
# Always tear down RedEdr — even on early return / exception —
|
||||
# so a crashed payload never leaves an orphaned RedEdr process.
|
||||
# Cleanup is idempotent, so calling it after the happy-path
|
||||
# get_results() is safe.
|
||||
if rededr is not None:
|
||||
# Attach partial RedEdr telemetry to the response on failure
|
||||
# paths (early termination, generic exception). The happy
|
||||
# path already populated results['rededr'] in step 5.
|
||||
if isinstance(response, dict) and 'rededr' not in response:
|
||||
try:
|
||||
response['rededr'] = rededr.get_results()
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to capture partial RedEdr telemetry: {e}")
|
||||
try:
|
||||
self._cleanup_rededr(rededr)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error during RedEdr cleanup: {e}")
|
||||
|
||||
def _initialize_rededr(self, target: str, results: dict):
|
||||
"""Initialize RedEdr if enabled"""
|
||||
"""Initialize RedEdr if enabled.
|
||||
|
||||
Blocks until RedEdr logs that all ETW providers are attached
|
||||
(typically 1-3s). No timeout — failure surfaces as a quick subprocess
|
||||
exit, which the reader thread also unblocks on. Callers that want a
|
||||
hard deadline get it from the surrounding analysis-pipeline timeout.
|
||||
"""
|
||||
rededr_config = self.config['analysis']['dynamic'].get('rededr', {})
|
||||
if not rededr_config.get('enabled'):
|
||||
return None
|
||||
|
||||
|
||||
self.logger.debug("Initializing RedEdr analyzer")
|
||||
try:
|
||||
target_name = target.split('\\')[-1]
|
||||
rededr = RedEdrAnalyzer(self.config)
|
||||
if rededr.start_tool(target_name):
|
||||
etw_wait_time = rededr_config.get('etw_wait_time', 5)
|
||||
self.logger.debug(f"RedEdr initialized, waiting {etw_wait_time} seconds for ETW setup")
|
||||
time.sleep(etw_wait_time)
|
||||
return rededr
|
||||
else:
|
||||
if not rededr.start_tool(target_name):
|
||||
self.logger.error("Failed to start RedEdr")
|
||||
results['rededr'] = {'status': 'error', 'error': 'Failed to start tool'}
|
||||
return None
|
||||
|
||||
ready_start = time.monotonic()
|
||||
rededr.wait_for_ready()
|
||||
elapsed = time.monotonic() - ready_start
|
||||
if rededr.is_ready():
|
||||
self.logger.debug(
|
||||
f"RedEdr ready in {elapsed:.2f}s (ETW providers attached)"
|
||||
)
|
||||
return rededr
|
||||
|
||||
# Reader thread unblocked because RedEdr exited before signaling
|
||||
# readiness. Capture whatever output we collected for diagnostics.
|
||||
self.logger.error(
|
||||
f"RedEdr exited after {elapsed:.2f}s without signaling readiness"
|
||||
)
|
||||
try:
|
||||
rededr.cleanup()
|
||||
except Exception:
|
||||
pass
|
||||
tail = '\n'.join(rededr.collected_output[-20:]) if rededr.collected_output else ''
|
||||
results['rededr'] = {
|
||||
'status': 'error',
|
||||
'error': 'RedEdr exited before ETW providers attached',
|
||||
'last_output': tail,
|
||||
}
|
||||
return None
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error initializing RedEdr: {e}")
|
||||
results['rededr'] = {'status': 'error', 'error': str(e)}
|
||||
|
||||
@@ -2,6 +2,45 @@
|
||||
import { errorPanel, cleanState, threatState, statRow, panel, kvGrid, tag, escapeHtml } from './_shared.js';
|
||||
import { formatBytes } from '../renderers.js';
|
||||
|
||||
// Windows FILETIME → ISO-ish local time. RedEdr emits
|
||||
// record.EventHeader.TimeStamp.QuadPart which is 100-ns intervals since
|
||||
// 1601-01-01 UTC. Unix-epoch (1970) is 116444736000000000 of those.
|
||||
// Values are too large for JS Number (1.34e17 > 9.0e15 MAX_SAFE_INTEGER),
|
||||
// so use BigInt for the math.
|
||||
const FILETIME_UNIX_OFFSET_100NS = 116444736000000000n;
|
||||
|
||||
function formatEtwTime(value) {
|
||||
if (value === null || value === undefined || value === '' || value === 'N/A') {
|
||||
return 'N/A';
|
||||
}
|
||||
let big;
|
||||
try {
|
||||
big = typeof value === 'bigint' ? value : BigInt(value);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
// Heuristic: FILETIME for the current era is ~1.3e17. Anything below
|
||||
// 1e15 is more likely unix-epoch milliseconds (RedEdr's get_time()).
|
||||
let unixMs;
|
||||
if (big > 1000000000000000n) {
|
||||
unixMs = Number((big - FILETIME_UNIX_OFFSET_100NS) / 10000n);
|
||||
} else if (big > 1000000000000n) {
|
||||
unixMs = Number(big); // already unix ms
|
||||
} else if (big > 1000000000n) {
|
||||
unixMs = Number(big) * 1000; // unix seconds
|
||||
} else {
|
||||
return String(value);
|
||||
}
|
||||
if (!Number.isFinite(unixMs) || unixMs <= 0) return String(value);
|
||||
const d = new Date(unixMs);
|
||||
if (Number.isNaN(d.getTime())) return String(value);
|
||||
const hh = String(d.getHours()).padStart(2, '0');
|
||||
const mm = String(d.getMinutes()).padStart(2, '0');
|
||||
const ss = String(d.getSeconds()).padStart(2, '0');
|
||||
const ms = String(d.getMilliseconds()).padStart(3, '0');
|
||||
return `${hh}:${mm}:${ss}.${ms}`;
|
||||
}
|
||||
|
||||
export default {
|
||||
id: 'rededr',
|
||||
elementId: 'redEdrResults',
|
||||
@@ -37,12 +76,26 @@ export default {
|
||||
const cpuChanges = findings.cpu_priority_changes || [];
|
||||
const timeline = findings.timeline || [];
|
||||
const summary = findings.summary || {};
|
||||
const childProcesses = findings.child_processes || [];
|
||||
const fileOps = findings.file_operations || [];
|
||||
const networkActivity = findings.network_activity || [];
|
||||
const auditApi = findings.audit_api_calls || [];
|
||||
const defenderEvents = findings.defender_events || [];
|
||||
const defenderThreats = defenderEvents.filter(e => e.category === 'threat');
|
||||
const defenderScans = defenderEvents.filter(e => e.category === 'scan');
|
||||
const defenderInternal = defenderEvents.filter(e => e.category === 'internal');
|
||||
const defenderOther = defenderEvents.filter(e => e.category === 'other' || !e.category);
|
||||
const hasThreatVerdict = defenderThreats.length > 0;
|
||||
|
||||
ctx.statsElement.innerHTML = statRow([
|
||||
{ label: 'Total Events', value: summary.total_events || 0, severity: 'info' },
|
||||
{ label: 'DLLs Loaded', value: summary.total_dlls || 0, severity: 'info' },
|
||||
{ label: 'Child Processes', value: summary.total_child_processes || 0, severity: 'info' },
|
||||
{ label: 'Active Threads', value: summary.total_threads || 0, severity: 'info' },
|
||||
{ label: 'Total Events', value: summary.total_events || 0, severity: 'info' },
|
||||
{ label: 'DLLs Loaded', value: summary.total_dlls || 0, severity: 'info' },
|
||||
{ label: 'Child Processes', value: summary.total_child_processes || 0, severity: 'info' },
|
||||
{ label: 'Threads', value: summary.total_threads || 0, severity: 'info' },
|
||||
{ label: 'Network', value: summary.total_network_activity || 0, severity: networkActivity.length > 0 ? 'medium' : 'info' },
|
||||
{ label: 'File Ops', value: summary.total_file_operations || 0, severity: 'info' },
|
||||
{ label: 'Audit API', value: summary.total_audit_api_calls || 0, severity: auditApi.length > 0 ? 'medium' : 'info' },
|
||||
{ label: 'Defender', value: summary.total_defender_events || 0, severity: hasThreatVerdict ? 'critical' : (defenderScans.length > 0 ? 'medium' : 'info') },
|
||||
]);
|
||||
|
||||
let html = '';
|
||||
@@ -69,13 +122,117 @@ export default {
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Process Tree (parent + spawned children)
|
||||
if (childProcesses.length) {
|
||||
const escapedRoot = escapeHtml(proc.image_path?.split('\\').pop() || proc.commandline || 'Target');
|
||||
html += panel('Process Tree', `
|
||||
<div style="display: flex; flex-direction: column; gap: 4px; font-size: 12px;">
|
||||
<div>
|
||||
<span class="lb-mono lb-strong">${escapedRoot}</span>
|
||||
<span class="lb-muted" style="margin-left: 8px;">PID ${escapeHtml(String(proc.pid ?? '?'))}</span>
|
||||
</div>
|
||||
<ul style="list-style: none; margin: 0; padding-left: 18px; display: flex; flex-direction: column; gap: 3px;">
|
||||
${childProcesses.map(c => `
|
||||
<li style="font-size: 11px;">
|
||||
<span class="lb-muted">└─</span>
|
||||
<span class="lb-mono lb-strong" style="margin-left: 4px;">${escapeHtml((c.image_name || '').split('\\').pop() || 'Unknown')}</span>
|
||||
<span class="lb-muted" style="margin-left: 6px;">PID ${escapeHtml(String(c.pid ?? '?'))}</span>
|
||||
${c.parent_pid ? `<span class="lb-muted" style="margin-left: 6px;">parent ${escapeHtml(String(c.parent_pid))}</span>` : ''}
|
||||
</li>
|
||||
`).join('')}
|
||||
</ul>
|
||||
</div>
|
||||
`, `${childProcesses.length} child${childProcesses.length === 1 ? '' : 'ren'}`);
|
||||
}
|
||||
|
||||
// Defender events — verdict line up top, then a table only when there's
|
||||
// actually something to show. Three buckets:
|
||||
// threat = real detection (the loud signal)
|
||||
// scan = Defender behavior monitor engaged with our process
|
||||
// (BmModuleLoad / BmNotificationHandle* / BmOpenProcess)
|
||||
// internal = Defender's own state plumbing (BmInternal / BmEtw)
|
||||
// For most operator-flavored runs the answer is: lots of scans, no
|
||||
// threats — meaning Defender looked at the binary and didn't flag it.
|
||||
if (defenderEvents.length) {
|
||||
const renderDefenderRows = (events) => events.map(e => `
|
||||
<tr ${e.is_threat ? 'style="background: rgba(248, 113, 113, 0.04);"' : ''}>
|
||||
<td class="lb-mono">${escapeHtml(e.provider || '')}</td>
|
||||
<td class="lb-mono">${escapeHtml(e.event || '')}</td>
|
||||
<td class="lb-mono lb-muted">${escapeHtml(e.scan_target || '—')}</td>
|
||||
<td>${e.verdict ? tag(e.is_threat ? 'critical' : 'medium', String(e.verdict)) : '<span class="lb-muted">—</span>'}</td>
|
||||
<td class="lb-mono lb-muted">${escapeHtml(formatEtwTime(e.time))}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
|
||||
// Headline verdict — the operator's bottom-line answer.
|
||||
let verdictLine, verdictColor, headerBadge;
|
||||
if (hasThreatVerdict) {
|
||||
verdictLine = `Defender flagged the binary — ${defenderThreats.length} threat verdict${defenderThreats.length === 1 ? '' : 's'}.`;
|
||||
verdictColor = 'var(--lb-accent-soft)';
|
||||
headerBadge = `${defenderThreats.length} threat${defenderThreats.length === 1 ? '' : 's'}`;
|
||||
} else if (defenderScans.length > 0) {
|
||||
verdictLine = `Defender scanned the binary ${defenderScans.length} time${defenderScans.length === 1 ? '' : 's'} — no threat verdict.`;
|
||||
verdictColor = 'var(--lb-sev-low)';
|
||||
headerBadge = `${defenderScans.length} scan${defenderScans.length === 1 ? '' : 's'}, no verdict`;
|
||||
} else {
|
||||
verdictLine = `Defender did not actively scan the binary (${defenderInternal.length + defenderOther.length} internal event${(defenderInternal.length + defenderOther.length) === 1 ? '' : 's'} only).`;
|
||||
verdictColor = 'var(--lb-text-dim)';
|
||||
headerBadge = `${defenderEvents.length} internal`;
|
||||
}
|
||||
|
||||
const breakdownLine = `
|
||||
<div class="lb-muted" style="font-size: 11px; margin-top: 4px;">
|
||||
Threats ${defenderThreats.length} · Scan activity ${defenderScans.length} · Internal ${defenderInternal.length}${defenderOther.length > 0 ? ' · Other ' + defenderOther.length : ''}
|
||||
</div>`;
|
||||
|
||||
const verdictBlock = `
|
||||
<div style="margin-bottom: 12px; padding: 10px 12px; border-left: 2px solid ${verdictColor}; background: var(--lb-bg);">
|
||||
<div class="lb-strong" style="color: ${verdictColor}; font-size: 12px;">${escapeHtml(verdictLine)}</div>
|
||||
${breakdownLine}
|
||||
</div>`;
|
||||
|
||||
// Show actual rows only when there's something interesting (threat
|
||||
// verdicts, or scan events with details). Internal Bm* state is
|
||||
// stashed behind a toggle.
|
||||
const interestingEvents = [...defenderThreats, ...defenderScans];
|
||||
const interestingTable = interestingEvents.length > 0
|
||||
? `<table class="lb-table">
|
||||
<thead><tr><th>Provider</th><th>Event</th><th>Scan Target</th><th>Verdict</th><th>Time</th></tr></thead>
|
||||
<tbody>${renderDefenderRows(interestingEvents.slice(0, 50))}</tbody>
|
||||
</table>
|
||||
${interestingEvents.length > 50 ? `<div class="lb-muted" style="font-size: 11px; padding: 6px 0; font-style: italic;">… and ${interestingEvents.length - 50} more</div>` : ''}`
|
||||
: '';
|
||||
|
||||
const internalEvents = [...defenderInternal, ...defenderOther];
|
||||
const internalToggle = internalEvents.length > 0
|
||||
? `<div style="margin-top: 12px; border-top: 1px dashed var(--lb-border); padding-top: 10px;">
|
||||
<span id="defenderNoiseToggle" style="cursor: pointer; text-decoration: underline; color: var(--lb-text-dim); font-size: 11px;">
|
||||
Show ${internalEvents.length} internal Defender event${internalEvents.length === 1 ? '' : 's'} (Bm* state plumbing)
|
||||
</span>
|
||||
<div id="defenderNoiseTable" class="hidden" style="margin-top: 8px;">
|
||||
<table class="lb-table">
|
||||
<thead><tr><th>Provider</th><th>Event</th><th>Scan Target</th><th>Verdict</th><th>Time</th></tr></thead>
|
||||
<tbody>${renderDefenderRows(internalEvents.slice(0, 50))}</tbody>
|
||||
</table>
|
||||
${internalEvents.length > 50 ? `<div class="lb-muted" style="font-size: 11px; padding: 6px 0; font-style: italic;">… and ${internalEvents.length - 50} more</div>` : ''}
|
||||
</div>
|
||||
</div>`
|
||||
: '';
|
||||
|
||||
html += panel(
|
||||
hasThreatVerdict ? 'Defender — Threat Verdicts' : 'Defender',
|
||||
verdictBlock + interestingTable + internalToggle,
|
||||
headerBadge
|
||||
);
|
||||
}
|
||||
|
||||
// Timeline
|
||||
if (timeline.length) {
|
||||
html += panel('Event Timeline', `
|
||||
<div style="display: flex; flex-direction: column;">
|
||||
${timeline.map(event => `
|
||||
<div style="display: grid; grid-template-columns: 110px 130px 1fr; gap: 12px; padding: 6px 0; border-bottom: 1px dashed var(--lb-border); font-size: 11px;">
|
||||
<span class="lb-mono lb-muted">${escapeHtml(event.time || 'N/A')}</span>
|
||||
<span class="lb-mono lb-muted">${escapeHtml(formatEtwTime(event.time))}</span>
|
||||
<span class="lb-strong">${escapeHtml(event.type || '')}</span>
|
||||
<span class="lb-dim">${escapeHtml(event.details || '')}</span>
|
||||
</div>
|
||||
@@ -95,7 +252,7 @@ export default {
|
||||
<td class="lb-mono">${escapeHtml(String(c.thread_id))}</td>
|
||||
<td class="lb-mono">${escapeHtml(String(c.old_priority))}</td>
|
||||
<td class="lb-mono">${escapeHtml(String(c.new_priority))}</td>
|
||||
<td class="lb-mono lb-muted">${escapeHtml(c.time || 'N/A')}</td>
|
||||
<td class="lb-mono lb-muted">${escapeHtml(formatEtwTime(c.time))}</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
@@ -103,13 +260,77 @@ export default {
|
||||
`, String(cpuChanges.length));
|
||||
}
|
||||
|
||||
// DLLs / Images / Threads sub-tabs
|
||||
// ETW Provider Diagnostics — collapsible, surfaces per-provider event
|
||||
// counts so it's obvious whether a 0 in (e.g.) network_activity means
|
||||
// "ETW didn't deliver any Kernel-Network events" or "events arrived
|
||||
// but my parser missed them". User-mode ETW often attributes outbound
|
||||
// TCP to System(4) or svchost, which RedEdr's filter drops — that
|
||||
// would show as Microsoft-Windows-Kernel-Network: 0 here even when
|
||||
// the payload made real network calls. Reliable capture needs
|
||||
// RedEdr's --hook mode (kernel driver path).
|
||||
const providerCounts = summary.events_by_provider || {};
|
||||
const providerEntries = Object.entries(providerCounts).sort((a, b) => b[1] - a[1]);
|
||||
if (providerEntries.length > 0) {
|
||||
const knownProviders = new Set([
|
||||
'Microsoft-Windows-Kernel-Process',
|
||||
'Microsoft-Windows-Kernel-File',
|
||||
'Microsoft-Windows-Kernel-Network',
|
||||
'Microsoft-Windows-Kernel-Audit-API-Calls',
|
||||
'Microsoft-Antimalware-Engine',
|
||||
]);
|
||||
const missingProviders = [...knownProviders].filter(p => !(p in providerCounts));
|
||||
html += `
|
||||
<div class="lb-panel">
|
||||
<div class="lb-panel-hdr">
|
||||
<span class="lb-glyph">▸</span>ETW Provider Diagnostics
|
||||
<span class="lb-panel-badge">${providerEntries.length} provider${providerEntries.length === 1 ? '' : 's'}</span>
|
||||
<button id="rededrDiagToggle" class="lb-btn lb-btn-ghost" style="margin-left: auto; padding: 2px 10px; font-size: 11px;">Show</button>
|
||||
</div>
|
||||
<div id="rededrDiagBody" class="lb-panel-body hidden">
|
||||
<p class="lb-muted" style="font-size: 11px; margin-bottom: 10px;">
|
||||
Per-provider event counts. A subscribed provider with <code>0</code>
|
||||
events usually means ETW delivered events but RedEdr filtered them
|
||||
out (e.g. Kernel-Network often attributes outbound TCP to System
|
||||
or svchost, not the payload PID). Reliable capture for those
|
||||
categories requires RedEdr's <code>--hook</code> kernel-driver path.
|
||||
</p>
|
||||
<table class="lb-table">
|
||||
<thead><tr><th>Provider</th><th>Events delivered</th></tr></thead>
|
||||
<tbody>
|
||||
${providerEntries.map(([prov, count]) => `
|
||||
<tr>
|
||||
<td class="lb-mono">${escapeHtml(prov)}</td>
|
||||
<td class="lb-mono">${count}</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
${missingProviders.map(prov => `
|
||||
<tr>
|
||||
<td class="lb-mono lb-muted">${escapeHtml(prov)}</td>
|
||||
<td class="lb-mono lb-muted">0 (no events received)</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// DLLs / Images / Threads / Network / File Ops / Audit API sub-tabs
|
||||
const tabBtn = (name, label, count, active) => {
|
||||
const baseStyle = 'padding: 8px 14px; background: transparent; border: 0; font-family: inherit; font-size: 12px; cursor: pointer;';
|
||||
const activeStyle = 'color: var(--lb-text); border-bottom: 2px solid var(--lb-accent-soft);';
|
||||
const inactiveStyle = 'color: var(--lb-text-dim); border-bottom: 2px solid transparent;';
|
||||
return `<button onclick="switchInnerTab('${name}')" class="tab-button${active ? ' active' : ''}" style="${baseStyle} ${active ? activeStyle : inactiveStyle}">${label} (${count})</button>`;
|
||||
};
|
||||
html += `
|
||||
<div class="lb-panel">
|
||||
<div style="display: flex; border-bottom: 1px solid var(--lb-border);">
|
||||
<button onclick="switchInnerTab('dlls')" class="tab-button active" style="padding: 8px 14px; background: transparent; color: var(--lb-text); border: 0; border-bottom: 2px solid var(--lb-accent-soft); font-family: inherit; font-size: 12px; cursor: pointer;">DLLs (${loadedDlls.length})</button>
|
||||
<button onclick="switchInnerTab('images')" class="tab-button" style="padding: 8px 14px; background: transparent; color: var(--lb-text-dim); border: 0; border-bottom: 2px solid transparent; font-family: inherit; font-size: 12px; cursor: pointer;">Images (${imageLoads.length + imageUnloads.length})</button>
|
||||
<button onclick="switchInnerTab('threads')" class="tab-button" style="padding: 8px 14px; background: transparent; color: var(--lb-text-dim); border: 0; border-bottom: 2px solid transparent; font-family: inherit; font-size: 12px; cursor: pointer;">Threads (${threads.length})</button>
|
||||
<div style="display: flex; border-bottom: 1px solid var(--lb-border); flex-wrap: wrap;">
|
||||
${tabBtn('dlls', 'DLLs', loadedDlls.length, true)}
|
||||
${tabBtn('images', 'Images', imageLoads.length + imageUnloads.length, false)}
|
||||
${tabBtn('threads', 'Threads', threads.length, false)}
|
||||
${tabBtn('network', 'Network', networkActivity.length, false)}
|
||||
${tabBtn('fileops', 'File Ops', fileOps.length, false)}
|
||||
${tabBtn('auditapi', 'Audit API', auditApi.length, false)}
|
||||
</div>
|
||||
|
||||
<div id="dlls-tab" class="tab-content">
|
||||
@@ -183,6 +404,69 @@ export default {
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="network-tab" class="tab-content hidden">
|
||||
<div class="lb-panel-body">
|
||||
${networkActivity.length === 0
|
||||
? '<div class="lb-muted" style="padding: 8px 0; font-size: 11px;">No network activity observed.</div>'
|
||||
: `<table class="lb-table">
|
||||
<thead><tr><th>Proto</th><th>Local</th><th>Remote</th><th>Op</th><th>Size</th><th>Time</th></tr></thead>
|
||||
<tbody>
|
||||
${networkActivity.map(n => `
|
||||
<tr>
|
||||
<td class="lb-mono">${escapeHtml(n.proto || '?')}</td>
|
||||
<td class="lb-mono lb-muted">${escapeHtml(String(n.local_addr || '—'))}${n.local_port ? ':' + escapeHtml(String(n.local_port)) : ''}</td>
|
||||
<td class="lb-mono">${escapeHtml(String(n.remote_addr || '—'))}${n.remote_port ? ':' + escapeHtml(String(n.remote_port)) : ''}</td>
|
||||
<td class="lb-mono lb-muted">${escapeHtml(n.operation || '—')}</td>
|
||||
<td class="lb-mono lb-muted">${n.size != null ? formatBytes(Number(n.size) || 0) : '—'}</td>
|
||||
<td class="lb-mono lb-muted">${escapeHtml(formatEtwTime(n.time))}</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="fileops-tab" class="tab-content hidden">
|
||||
<div class="lb-panel-body">
|
||||
${fileOps.length === 0
|
||||
? '<div class="lb-muted" style="padding: 8px 0; font-size: 11px;">No file operations observed.</div>'
|
||||
: `<table class="lb-table">
|
||||
<thead><tr><th>Path</th><th>Operation</th><th>Thread</th><th>Time</th></tr></thead>
|
||||
<tbody>
|
||||
${fileOps.map(f => `
|
||||
<tr>
|
||||
<td class="lb-mono" style="word-break: break-all;">${escapeHtml(String(f.path || '—'))}</td>
|
||||
<td class="lb-mono lb-muted">${escapeHtml(f.operation || '—')}</td>
|
||||
<td class="lb-mono lb-muted">${escapeHtml(String(f.thread_id ?? '—'))}</td>
|
||||
<td class="lb-mono lb-muted">${escapeHtml(formatEtwTime(f.time))}</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="auditapi-tab" class="tab-content hidden">
|
||||
<div class="lb-panel-body">
|
||||
${auditApi.length === 0
|
||||
? '<div class="lb-muted" style="padding: 8px 0; font-size: 11px;">No audit-API calls observed.</div>'
|
||||
: `<table class="lb-table">
|
||||
<thead><tr><th>API</th><th>Target PID</th><th>Target TID</th><th>Caller PID/TID</th><th>Time</th></tr></thead>
|
||||
<tbody>
|
||||
${auditApi.map(a => `
|
||||
<tr>
|
||||
<td class="lb-mono">${escapeHtml(a.api || '—')}</td>
|
||||
<td class="lb-mono">${escapeHtml(String(a.target_pid ?? '—'))}</td>
|
||||
<td class="lb-mono lb-muted">${escapeHtml(String(a.target_tid ?? '—'))}</td>
|
||||
<td class="lb-mono lb-muted">${escapeHtml(String(a.caller_pid ?? '—'))} / ${escapeHtml(String(a.caller_tid ?? '—'))}</td>
|
||||
<td class="lb-mono lb-muted">${escapeHtml(formatEtwTime(a.time))}</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -218,5 +502,28 @@ export default {
|
||||
else { loads.classList.add('hidden'); unloads.classList.remove('hidden'); }
|
||||
};
|
||||
}
|
||||
|
||||
// Defender internal-events toggle — show/hide Bm* state plumbing.
|
||||
const noiseToggle = document.getElementById('defenderNoiseToggle');
|
||||
const noiseTable = document.getElementById('defenderNoiseTable');
|
||||
if (noiseToggle && noiseTable) {
|
||||
const internalCount = defenderInternal.length + defenderOther.length;
|
||||
noiseToggle.addEventListener('click', () => {
|
||||
const hidden = noiseTable.classList.toggle('hidden');
|
||||
noiseToggle.textContent = hidden
|
||||
? `Show ${internalCount} internal Defender event${internalCount === 1 ? '' : 's'} (Bm* state plumbing)`
|
||||
: `Hide ${internalCount} internal Defender event${internalCount === 1 ? '' : 's'}`;
|
||||
});
|
||||
}
|
||||
|
||||
// ETW provider diagnostic toggle.
|
||||
const diagToggle = document.getElementById('rededrDiagToggle');
|
||||
const diagBody = document.getElementById('rededrDiagBody');
|
||||
if (diagToggle && diagBody) {
|
||||
diagToggle.addEventListener('click', () => {
|
||||
const hidden = diagBody.classList.toggle('hidden');
|
||||
diagToggle.textContent = hidden ? 'Show' : 'Hide';
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
+112
-2
@@ -1149,21 +1149,131 @@
|
||||
|
||||
{# RedEdr #}
|
||||
{% set re_ = dynamic_results.rededr %}
|
||||
{% set re_findings = re_.findings if re_ and re_.findings else None %}
|
||||
{% set re_summary = re_findings.summary if re_findings else None %}
|
||||
{% set re_proc = re_findings.process_info if re_findings else None %}
|
||||
{% set re_children = re_findings.child_processes if re_findings else [] %}
|
||||
{% set re_defender = re_findings.defender_events if re_findings else [] %}
|
||||
{% set re_net = re_findings.network_activity if re_findings else [] %}
|
||||
{% set re_files = re_findings.file_operations if re_findings else [] %}
|
||||
{% set re_audit = re_findings.audit_api_calls if re_findings else [] %}
|
||||
{% set threat_signals = ['threatfound','threatdetect','detectionadded','malwarefound','protectionalert','detected'] %}
|
||||
{% set ns = namespace(threat_hit=false) %}
|
||||
{% for d in re_defender %}{% if d.verdict or d.event %}{% set evt = (d.event or '')|lower %}{% for s in threat_signals %}{% if s in evt %}{% set ns.threat_hit = true %}{% endif %}{% endfor %}{% if d.verdict %}{% set ns.threat_hit = true %}{% endif %}{% endif %}{% endfor %}
|
||||
|
||||
<div class="scanner">
|
||||
<div class="scanner-name">RedEdr</div>
|
||||
<div class="scanner-body">
|
||||
{% set re_summary = re_.findings.summary if re_ and re_.findings and re_.findings.summary else None %}
|
||||
<div class="scanner-headline">
|
||||
{% if ns.threat_hit %}
|
||||
<span class="threat-state">
|
||||
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M8 1l7 14H1L8 1zm0 5v4m0 2v.5"/></svg>
|
||||
Defender flagged the binary at runtime
|
||||
</span>
|
||||
{% endif %}
|
||||
<span class="scanner-meta">
|
||||
{% if re_summary %}
|
||||
{{ re_summary.total_events or 0 }} events ·
|
||||
{{ re_summary.total_dlls or 0 }} DLL loads ·
|
||||
{{ re_summary.total_image_loads or 0 }} image loads
|
||||
{{ re_summary.total_image_loads or 0 }} image loads ·
|
||||
{{ re_summary.total_network_activity or 0 }} net ·
|
||||
{{ re_summary.total_file_operations or 0 }} files ·
|
||||
{{ re_summary.total_audit_api_calls or 0 }} audit-API
|
||||
{% else %}
|
||||
No telemetry recorded
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{% if re_defender %}
|
||||
<h4 class="subsection-title mt-12">Defender Events ({{ re_defender|length }})</h4>
|
||||
<table class="report-table">
|
||||
<thead><tr><th>Provider</th><th>Event</th><th>Scan Target</th><th>Verdict</th></tr></thead>
|
||||
<tbody>
|
||||
{% for d in re_defender[:15] %}
|
||||
{% set evt = (d.event or '')|lower %}
|
||||
{% set is_threat = (d.verdict and (d.verdict|string)|trim) %}
|
||||
{% if not is_threat %}{% for s in threat_signals %}{% if s in evt %}{% set is_threat = true %}{% endif %}{% endfor %}{% endif %}
|
||||
<tr>
|
||||
<td class="mono">{{ d.provider or '—' }}</td>
|
||||
<td class="mono">{{ d.event or '—' }}</td>
|
||||
<td class="mono muted">{{ (d.scan_target or '—')|truncate(80, true) }}</td>
|
||||
<td>{% if d.verdict %}<span class="pill {% if is_threat %}critical{% else %}medium{% endif %}">{{ d.verdict }}</span>{% else %}<span class="muted">—</span>{% endif %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% if re_defender|length > 15 %}
|
||||
<tr><td colspan="4" class="center">… and {{ re_defender|length - 15 }} more</td></tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
|
||||
{% if re_proc and (re_proc.pid or re_children) %}
|
||||
<h4 class="subsection-title mt-12">Process Tree</h4>
|
||||
<pre class="code">{{ (re_proc.image_path or re_proc.commandline or 'Target')|trim }} (PID {{ re_proc.pid or '?' }})
|
||||
{% for c in re_children %} └─ {{ c.image_name or 'Unknown' }} (PID {{ c.pid or '?' }}{% if c.parent_pid %}, parent {{ c.parent_pid }}{% endif %})
|
||||
{% endfor %}</pre>
|
||||
{% endif %}
|
||||
|
||||
{% if re_net %}
|
||||
<h4 class="subsection-title mt-12">Network Activity ({{ re_net|length }})</h4>
|
||||
<table class="report-table">
|
||||
<thead><tr><th>Proto</th><th>Local</th><th>Remote</th><th>Op</th><th>Size</th></tr></thead>
|
||||
<tbody>
|
||||
{% for n in re_net[:25] %}
|
||||
<tr>
|
||||
<td class="mono">{{ n.proto or '?' }}</td>
|
||||
<td class="mono muted">{{ n.local_addr or '—' }}{% if n.local_port %}:{{ n.local_port }}{% endif %}</td>
|
||||
<td class="mono">{{ n.remote_addr or '—' }}{% if n.remote_port %}:{{ n.remote_port }}{% endif %}</td>
|
||||
<td class="mono muted">{{ n.operation or '—' }}</td>
|
||||
<td class="mono muted">{{ n.size if n.size is not none else '—' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% if re_net|length > 25 %}
|
||||
<tr><td colspan="5" class="center">… and {{ re_net|length - 25 }} more</td></tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
|
||||
{% if re_files %}
|
||||
<h4 class="subsection-title mt-12">File Operations ({{ re_files|length }})</h4>
|
||||
<table class="report-table">
|
||||
<thead><tr><th>Path</th><th>Operation</th><th>Thread</th></tr></thead>
|
||||
<tbody>
|
||||
{% for f in re_files[:25] %}
|
||||
<tr>
|
||||
<td class="mono" style="word-break: break-all;">{{ (f.path or '—')|truncate(120, true) }}</td>
|
||||
<td class="mono muted">{{ f.operation or '—' }}</td>
|
||||
<td class="mono muted">{{ f.thread_id if f.thread_id is not none else '—' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% if re_files|length > 25 %}
|
||||
<tr><td colspan="3" class="center">… and {{ re_files|length - 25 }} more</td></tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
|
||||
{% if re_audit %}
|
||||
<h4 class="subsection-title mt-12">Audit-API Calls ({{ re_audit|length }})</h4>
|
||||
<table class="report-table">
|
||||
<thead><tr><th>API</th><th>Target PID</th><th>Target TID</th><th>Caller PID/TID</th></tr></thead>
|
||||
<tbody>
|
||||
{% for a in re_audit[:25] %}
|
||||
<tr>
|
||||
<td class="mono">{{ a.api or '—' }}</td>
|
||||
<td class="mono">{{ a.target_pid if a.target_pid is not none else '—' }}</td>
|
||||
<td class="mono muted">{{ a.target_tid if a.target_tid is not none else '—' }}</td>
|
||||
<td class="mono muted">{{ a.caller_pid if a.caller_pid is not none else '—' }} / {{ a.caller_tid if a.caller_tid is not none else '—' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% if re_audit|length > 25 %}
|
||||
<tr><td colspan="4" class="center">… and {{ re_audit|length - 25 }} more</td></tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -328,10 +328,43 @@ def _calculate_dynamic_risk(dynamic_results, analysis_type):
|
||||
dynamic_risk += _calculate_memory_anomaly_risk(dynamic_results, analysis_type, risk_factors)
|
||||
dynamic_risk += _calculate_behavior_risk(dynamic_results, analysis_type, risk_factors)
|
||||
dynamic_risk += _calculate_hsb_risk(dynamic_results, analysis_type, risk_factors)
|
||||
dynamic_risk += _calculate_rededr_risk(dynamic_results, analysis_type, risk_factors)
|
||||
|
||||
return dynamic_risk, risk_factors
|
||||
|
||||
|
||||
def _calculate_rededr_risk(dynamic_results, analysis_type, risk_factors):
|
||||
"""Defender-only contribution from RedEdr telemetry.
|
||||
|
||||
The analyzer classifies every defender_events entry as one of:
|
||||
threat — real detection (ThreatFound, non-empty verdict, etc.)
|
||||
scan — Defender behavior monitor actively engaged with our process
|
||||
(BmModuleLoad / BmNotificationHandle* / BmOpenProcess)
|
||||
internal — Defender's own state plumbing (BmInternal / BmEtw)
|
||||
other — anything else (e.g., msmpeng ThreadStop)
|
||||
|
||||
Only `threat` events bump the score. `scan` is descriptive (operator
|
||||
knows Defender engaged but didn't flag — typically the win state). The
|
||||
other RedEdr signals (network, audit-API, file ops, child processes)
|
||||
stay descriptive too, per the design decision.
|
||||
"""
|
||||
rededr = dynamic_results.get('rededr', {}).get('findings', {})
|
||||
defender = rededr.get('defender_events') or []
|
||||
if not defender:
|
||||
return 0
|
||||
|
||||
threat_hits = [e for e in defender if e.get('category') == 'threat']
|
||||
|
||||
if threat_hits:
|
||||
# ThreatFound-class verdict at runtime is the strongest possible signal.
|
||||
risk_factors.append(
|
||||
f"Critical: Microsoft Defender flagged the binary at runtime "
|
||||
f"({len(threat_hits)} threat verdict{'s' if len(threat_hits) != 1 else ''})"
|
||||
)
|
||||
return 50
|
||||
return 0
|
||||
|
||||
|
||||
def _calculate_memory_anomaly_risk(dynamic_results, analysis_type, risk_factors):
|
||||
moneta_findings = dynamic_results.get('moneta', {}).get('findings', {})
|
||||
if not moneta_findings:
|
||||
|
||||
Reference in New Issue
Block a user