{"id":366,"date":"2026-05-24T09:00:00","date_gmt":"2026-05-23T23:00:00","guid":{"rendered":"http:\/\/justruss.tech\/index.php\/2026\/05\/24\/automating-memory-analysis-with-volatility-one-script-complete-results\/"},"modified":"2026-05-25T12:45:47","modified_gmt":"2026-05-25T12:45:47","slug":"automating-memory-analysis-with-volatility-one-script-complete-results","status":"publish","type":"post","link":"https:\/\/justruss.tech\/index.php\/2026\/05\/24\/automating-memory-analysis-with-volatility-one-script-complete-results\/","title":{"rendered":"Automating Memory Analysis with Volatility: One Script, Complete Results"},"content":{"rendered":"<p>A memory image taken from a live system is the closest thing to a complete picture of what was happening on that machine at a specific moment in time. Everything that was running, communicating, injected, or hiding is captured in those bytes. The challenge is that extracting useful intelligence from a raw memory image manually is slow. Running Volatility plugins one at a time, copying outputs into notes, correlating findings by hand. A thorough manual analysis of a single 16GB image can take three to four hours for an experienced analyst. When you are hunting across multiple endpoints, or when you need to answer the question &#8220;is anything malicious in this image&#8221; quickly enough to act on the result, manual analysis does not scale.<\/p>\n<p>This post covers building a single script that accepts a memory image path and produces a comprehensive, prioritised threat hunting report without any further analyst input. You drop an image in, you get a report out. The script handles plugin selection, parallel execution, result parsing, anomaly detection, IOC extraction, cross-referencing between plugins, and report generation. It handles both Windows and Linux images, uses Volatility 2 where it provides unique capability and Volatility 3 for everything else, and produces both a shareable HTML report and structured JSON for SIEM ingestion.<\/p>\n<h3>Volatility 2 versus Volatility 3: understanding when to use each<\/h3>\n<p>The Volatility project has two major versions in active use. Volatility 3 is the current version, written from scratch with a cleaner architecture, better Python 3 support, and no dependency on a manually selected profile. Volatility 2 is the older version, maintained for specific use cases where it still provides capabilities or plugin depth that Volatility 3 does not yet match. Understanding the practical differences between them determines which one to call for each analysis task.<\/p>\n<p>Volatility 3 should be your default for almost everything. It automatically identifies the OS version and kernel structures from the image without requiring a manually matched profile, which eliminates the most common source of beginner errors (wrong profile). It runs significantly faster on large images because of improved memory mapping. The output format is consistent JSON across all plugins, which makes automation straightforward. The symbol table system is more robust than Volatility 2&#8217;s profile system for modern Windows versions.<\/p>\n<p>Volatility 2 retains specific advantages that keep it relevant for threat hunters:<\/p>\n<pre># Where Volatility 2 still provides unique value\n\n# 1. Some plugins simply do not exist yet in Volatility 3\n#    - mftparser: deep MFT parsing from memory\n#    - shellbags: user navigation history from registry in memory\n#    - iehistory: Internet Explorer artefacts from process memory\n#    - shimcache: application compatibility cache from memory\n#    - prefetchparser: prefetch execution records from memory\n\n# 2. Certain Linux plugins are more mature in Volatility 2\n#    - linux_psaux: full command line for Linux processes (more reliable)\n#    - linux_netstat: Linux network connections with more detail\n#    - linux_check_syscall: syscall table hook detection\n\n# 3. Legacy Windows support (XP, Vista, 2003, 2008)\n#    Volatility 3 symbol tables may not exist for very old OS versions\n#    Volatility 2 with the correct profile covers these reliably\n\n# 4. Certain rootkit detection plugins\n#    - modscan: raw kernel module scanner (sometimes finds what pslist misses)\n#    - ssdt: SSDT hook detection (useful on older Windows)\n#    - apihooks: API hook detection in process memory\n\n# Check what is available in each version\nvol2 --info | grep \"^[A-Z]\" | sort  # Volatility 2 plugins\nvol3 --help 2&gt;&amp;1 | grep \"windows\\.\" | sort  # Volatility 3 Windows plugins<\/pre>\n<p>The automation script uses both. Volatility 3 for the core analysis pipeline where it is faster and more reliable. Volatility 2 for specific supplementary plugins where it provides unique value. The script detects which tools are available and gracefully skips plugins that cannot run rather than failing entirely.<\/p>\n<h3>Setting up both versions side by side<\/h3>\n<pre>#!\/bin\/bash\n## setup_volatility_dual.sh\n## Install Volatility 2 and 3 side by side\n## Ubuntu 22.04 - run as root or with sudo\n\nset -euo pipefail\n\necho \"[*] Setting up dual Volatility environment\"\n\n## \u2500\u2500 Volatility 3 \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\npython3 -m venv \/opt\/vol3-env\nsource \/opt\/vol3-env\/bin\/activate\npip install --upgrade pip -q\npip install volatility3 yara-python pefile capstone python-magic requests -q\n\n# Symbol tables for Windows analysis\nmkdir -p \/opt\/vol3-symbols\nif [ ! -d \/opt\/vol3-symbols\/windows ]; then\n    echo \"[*] Downloading Windows symbol tables (~500MB)\"\n    wget -q --show-progress \\\n        https:\/\/downloads.volatilityfoundation.org\/volatility3\/symbols\/windows.zip \\\n        -O \/tmp\/windows_syms.zip\n    unzip -q \/tmp\/windows_syms.zip -d \/opt\/vol3-symbols\/\n    rm \/tmp\/windows_syms.zip\nfi\n\n# Link symbols into Volatility 3\nSITE3=$(python3 -c \"import site; print(site.getsitepackages()[0])\")\nln -sf \/opt\/vol3-symbols\/windows $SITE3\/volatility3\/symbols\/windows 2&gt;\/dev\/null || true\n\n# Linux symbol tables (requires ISF files generated from target kernel)\n# These must be generated for each specific kernel version being analysed\n# See: https:\/\/github.com\/volatilityfoundation\/dwarf2json\nmkdir -p \/opt\/vol3-symbols\/linux\n\necho \"[+] Volatility 3: $(python3 - &lt;&lt; &#039;PYEOF&#039;\nimport pkg_resources\nprint(pkg_resources.get_distribution(&quot;volatility3&quot;).version)\nPYEOF)&quot;\ndeactivate\n\n## \u2500\u2500 Volatility 2 \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n# Volatility 2 requires Python 2.7 (use pyenv or system python2)\napt-get install -y -qq python2 python2-dev python-is-python2 2&gt;\/dev\/null || \\\n    apt-get install -y -qq python2.7 python2.7-dev 2&gt;\/dev\/null || true\n\n# If python2 is not available, use Docker instead\nif command -v python2 &amp;&gt;\/dev\/null; then\n    pip2 install --quiet distorm3 pycryptodome 2&gt;\/dev\/null || true\n    git clone --depth 1 https:\/\/github.com\/volatilityfoundation\/volatility.git \\\n        \/opt\/volatility2 2&gt;\/dev\/null || (cd \/opt\/volatility2 &amp;&amp; git pull)\n    echo &quot;[+] Volatility 2: available via python2 \/opt\/volatility2\/vol.py&quot;\nelse\n    echo &quot;[!] Python 2 not available - using Docker for Volatility 2&quot;\n    docker pull remnux\/volatility 2&gt;\/dev\/null || true\n    cat &gt; \/usr\/local\/bin\/vol2 &lt;&lt; &#039;VOL2SCRIPT&#039;\n#!\/bin\/bash\ndocker run --rm -v &quot;$(dirname $(realpath $1)):\/data&quot; \\\n    remnux\/volatility &quot;$@&quot;\nVOL2SCRIPT\n    chmod +x \/usr\/local\/bin\/vol2\nfi\n\n## \u2500\u2500 Supporting tools \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nsource \/opt\/vol3-env\/bin\/activate\npip install -q \\\n    jinja2 \\\n    requests \\\n    psycopg2-binary \\\n    colorama \\\n    tqdm \\\n    tabulate\ndeactivate\n\n# Install yara, foremost, jq from apt\napt-get install -y -qq yara foremost jq\n\n# bulk-extractor must be built from source\napt-get install -y -qq libssl-dev libewf-dev libexpat1-dev build-essential autoconf automake libtool git\ncd \/tmp\ngit clone --recurse-submodules https:\/\/github.com\/simsong\/bulk_extractor.git\ncd bulk_extractor\n.\/bootstrap.sh\n.\/configure\nmake -j$(nproc)\nmake install\ncd \/tmp &amp;&amp; rm -rf bulk_extractor\necho &quot;[+] bulk-extractor: $(bulk_extractor --version 2&gt;&amp;1 | head -1)&quot;\n\necho &quot;&quot;\necho &quot;[+] Setup complete&quot;\necho &quot;    Volatility 3:  vol (in \/opt\/vol3-env\/bin\/)&quot;\necho &quot;    Volatility 2:  python2 \/opt\/volatility2\/vol.py (or vol2 Docker wrapper)&quot;\necho &quot;    Symbol tables: \/opt\/vol3-symbols\/&quot;<\/pre>\n<h3>The automation architecture<\/h3>\n<p>The script runs in five sequential phases. Each phase feeds its results into the next. Phase 1 identifies the image and OS type. Phase 2 runs a broad plugin sweep to collect all available data. Phase 3 applies anomaly detection logic across the collected data to score and prioritise findings. Phase 4 extracts and enriches IOCs. Phase 5 generates the output reports. The whole process runs in parallel where possible, with non-dependent plugins executing concurrently, which reduces total analysis time significantly compared to sequential execution.<\/p>\n<pre>## Directory structure for the automation pipeline\nmkdir -p \/opt\/memory-hunter\/{scripts,templates,yara_rules,reports,logs}\n\n## Full file layout:\n## \/opt\/memory-hunter\/\n##   analyse.py          &lt;- Main entry point (the one script to run)\n##   scripts\/\n##     phase1_identify.py\n##     phase2_collect.py\n##     phase3_detect.py\n##     phase4_iocs.py\n##     phase5_report.py\n##     vol_runner.py     &lt;- Abstraction layer for vol2\/vol3\n##     anomaly_checks.py &lt;- Detection logic\n##   templates\/\n##     report.html.j2    &lt;- Jinja2 HTML report template\n##   yara_rules\/\n##     combined.yar      &lt;- Compiled Yara ruleset\n##   reports\/            &lt;- Output destination\n##   logs\/               &lt;- Analysis logs<\/pre>\n<h3>Phase 1: image identification and OS detection<\/h3>\n<pre>## \/opt\/memory-hunter\/scripts\/phase1_identify.py\n\nimport subprocess\nimport json\nimport logging\nimport hashlib\nfrom pathlib import Path\nfrom typing import Dict, Optional\n\nlog = logging.getLogger(__name__)\n\nVOL3_CMD = '\/opt\/vol3-env\/bin\/vol'\nVOL2_CMD = '\/opt\/volatility2\/vol.py'\n\ndef sha256_file(path: str) -&gt; str:\n    \"\"\"Calculate SHA256 of image file.\"\"\"\n    h = hashlib.sha256()\n    with open(path, 'rb') as f:\n        for chunk in iter(lambda: f.read(65536), b''):\n            h.update(chunk)\n    return h.hexdigest()\n\ndef identify_image(image_path: str) -&gt; Dict:\n    \"\"\"\n    Identify OS type, version, and architecture from memory image.\n    Returns a metadata dict used by all subsequent phases.\n    \"\"\"\n    path = Path(image_path)\n    if not path.exists():\n        raise FileNotFoundError(f\"Image not found: {image_path}\")\n\n    size_gb = path.stat().st_size \/ (1024 ** 3)\n    log.info(f\"Image: {path.name} ({size_gb:.1f} GB)\")\n\n    result = {\n        'path':       str(path.absolute()),\n        'filename':   path.name,\n        'size_gb':    round(size_gb, 2),\n        'sha256':     sha256_file(image_path),\n        'os_type':    None,\n        'os_version': None,\n        'arch':       None,\n        'vol3_ok':    False,\n        'vol2_ok':    False,\n        'vol3_info':  {},\n        'errors':     [],\n    }\n\n    # Try Volatility 3 first\n    log.info(\"Running windows.info to identify OS\")\n    cmd = [VOL3_CMD, '-f', image_path, '--renderer', 'json', 'windows.info']\n    r = subprocess.run(cmd, capture_output=True, text=True, timeout=120)\n\n    if r.returncode == 0 and r.stdout.strip():\n        try:\n            data = json.loads(r.stdout)\n            rows = data.get('rows', data) if isinstance(data, dict) else data\n            info = {row[0]: row[1] for row in rows if isinstance(row, list) and len(row) &gt;= 2}\n\n            result['os_type']    = 'windows'\n            result['vol3_ok']    = True\n            result['vol3_info']  = info\n            result['os_version'] = info.get('NtBuildLab', info.get('Kernel Version', 'Unknown'))\n            result['arch']       = '64-bit' if '64' in str(info.get('Kernel Base', '')) else '32\/64-bit'\n            log.info(f\"Identified: Windows {result['os_version']}\")\n            return result\n        except (json.JSONDecodeError, Exception) as e:\n            result['errors'].append(f\"Vol3 windows.info parse error: {e}\")\n\n    # Try Linux identification\n    log.info(\"Trying linux.bash for Linux identification\")\n    cmd = [VOL3_CMD, '-f', image_path, '--renderer', 'json', 'linux.bash']\n    r = subprocess.run(cmd, capture_output=True, text=True, timeout=60)\n    if r.returncode == 0 and 'bash' in r.stdout.lower():\n        result['os_type']  = 'linux'\n        result['vol3_ok']  = True\n        log.info(\"Identified: Linux image\")\n        return result\n\n    # Fall back to Volatility 2 for profile-based identification\n    log.info(\"Trying Volatility 2 imageinfo\")\n    if Path(VOL2_CMD).exists():\n        cmd = ['python2', VOL2_CMD, '-f', image_path, 'imageinfo']\n        r = subprocess.run(cmd, capture_output=True, text=True, timeout=180)\n        if r.returncode == 0:\n            for line in r.stdout.splitlines():\n                if 'Suggested Profile' in line:\n                    profile = line.split(':')[1].strip().split(',')[0].strip()\n                    result['vol2_ok']    = True\n                    result['os_version'] = profile\n                    result['os_type']    = 'linux' if 'Linux' in profile else 'windows'\n                    log.info(f\"Vol2 identified: {profile}\")\n                    return result\n\n    # Last resort: try vol2 imageinfo directly\n    import shutil\n    if shutil.which('vol2') or Path(VOL2_CMD).exists():\n        log.info('Trying Volatility 2 imageinfo as final fallback')\n        cmd = ['vol2', '-f', image_path, 'imageinfo'] if shutil.which('vol2') \\\n              else ['python2', VOL2_CMD, '-f', image_path, 'imageinfo']\n        try:\n            r = subprocess.run(cmd, capture_output=True, text=True, timeout=300)\n            for line in r.stdout.splitlines():\n                if 'Suggested Profile' in line:\n                    profile = line.split(':')[1].strip().split(',')[0].strip()\n                    result['vol2_ok']      = True\n                    result['vol2_profile'] = profile\n                    result['os_version']   = profile\n                    result['os_type']      = 'linux' if 'Linux' in profile else 'windows'\n                    log.info(f'Vol2 identified: {profile}')\n                    return result\n        except Exception as e:\n            result['errors'].append(f'Vol2 imageinfo failed: {e}')\n\n    result['errors'].append(\"Could not identify OS type from image\")\n    log.error(\"Image identification failed\")\n    return result<\/pre>\n<h3>Phase 2: parallel plugin collection<\/h3>\n<pre>## \/opt\/memory-hunter\/scripts\/vol_runner.py\n## Abstraction layer that handles vol2\/vol3 differences\n\nimport subprocess\nimport json\nimport logging\nimport concurrent.futures\nfrom typing import List, Dict, Optional, Tuple\nfrom pathlib import Path\n\nlog = logging.getLogger(__name__)\n\nVOL3 = '\/opt\/vol3-env\/bin\/vol'\nVOL2 = '\/opt\/volatility2\/vol.py'\n\n# Plugin definitions: (vol3_plugin, vol2_plugin, timeout_seconds, description)\nWINDOWS_PLUGINS = [\n    # Core triage - always run\n    ('windows.pslist',      'pslist',        120, 'Process list'),\n    ('windows.pstree',      'pstree',        120, 'Process tree'),\n    ('windows.cmdline',     'cmdline',       180, 'Command line arguments'),\n    ('windows.netscan',     'netscan',       180, 'Network connections'),\n    ('windows.netstat',     None,            120, 'Network statistics'),\n\n    # Injection detection\n    ('windows.malfind',     'malfind',       900, 'Injection detection'),\n    ('windows.vadinfo',     'vadinfo',       300, 'VAD region analysis'),\n    ('windows.dlllist',     'dlllist',       300, 'Loaded DLL list'),\n\n    # Persistence and privilege\n    ('windows.svcscan',     'svcscan',       180, 'Windows services'),\n    ('windows.scheduled_tasks', None,        120, 'Scheduled tasks'),\n    ('windows.registry.hivelist', 'hivelist',120, 'Registry hives'),\n    ('windows.registry.printkey', None,      120, 'Registry run keys'),\n\n    # Kernel integrity\n    ('windows.callbacks',   None,            180, 'Kernel callbacks'),\n    ('windows.modules',     'modules',       120, 'Loaded kernel modules'),\n    ('windows.driverscan',  'driverscan',    180, 'Driver scan'),\n    ('windows.ssdt',        'ssdt',          120, 'SSDT entries'),\n\n    # Evidence and artefacts\n    ('windows.handles',     'handles',       900, 'Open handles'),\n    ('windows.dumpfiles',   None,            300, 'Mapped files'),\n    ('windows.mftscan',     None,            300, 'MFT entries'),\n    ('windows.envars',      'envars',        120, 'Environment variables'),\n\n    # Credential access indicators\n    ('windows.lsadump',     None,            120, 'LSA credentials'),\n    ('windows.hashdump',    'hashdump',      120, 'Password hashes'),\n]\n\nLINUX_PLUGINS = [\n    ('linux.pslist',        'linux_pslist',  120, 'Process list'),\n    ('linux.pstree',        'linux_pstree',  120, 'Process tree'),\n    ('linux.bash',          'linux_bash',    120, 'Bash history'),\n    ('linux.netstat',       'linux_netstat', 120, 'Network connections'),\n    ('linux.malfind',       None,            600, 'Injection detection'),\n    ('linux.lsmod',         'linux_lsmod',   120, 'Loaded kernel modules'),\n    ('linux.check_syscall', 'linux_check_syscall', 180, 'Syscall table integrity'),\n    ('linux.check_modules', 'linux_check_modules', 120, 'Module integrity'),\n    ('linux.keyboard_notifiers', None,       120, 'Keyboard hooks (rootkit)'),\n    ('linux.envars',        None,            120, 'Environment variables'),\n    ('linux.proc_maps',     None,            300, 'Process memory maps'),\n]\n\n# Vol2-only plugins that add unique value\nVOL2_ONLY_WINDOWS = [\n    ('mftparser',   240, 'MFT parser (Vol2 unique)'),\n    ('shimcache',   120, 'Shimcache (Vol2 unique)'),\n    ('prefetchparser', 120, 'Prefetch (Vol2 unique)'),\n    ('iehistory',   120, 'IE history (Vol2 unique)'),\n]\n\nVOL2_ONLY_LINUX = [\n    ('linux_psaux',         120, 'Full process args (Vol2 unique)'),\n    ('linux_check_afinfo',  120, 'Network hook detection (Vol2 unique)'),\n]\n\ndef run_vol3(image_path: str, plugin: str, timeout: int = 300,\n             extra_args: List[str] = None) -&gt; Tuple[str, List]:\n    \"\"\"Run a Volatility 3 plugin, return (plugin_name, results_list).\"\"\"\n    cmd = [VOL3, '-f', image_path, '--renderer', 'json', plugin]\n    if extra_args:\n        cmd.extend(extra_args)\n\n    try:\n        r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)\n        if r.returncode != 0 or not r.stdout.strip():\n            return plugin, []\n        data = json.loads(r.stdout)\n        if isinstance(data, dict):\n            return plugin, data.get('rows', [])\n        return plugin, data if isinstance(data, list) else []\n    except subprocess.TimeoutExpired:\n        log.warning(f\"TIMEOUT: {plugin} ({timeout}s)\")\n        return plugin, []\n    except Exception as e:\n        log.debug(f\"Error in {plugin}: {e}\")\n        return plugin, []\n\ndef run_vol2(image_path: str, plugin: str, profile: str,\n             timeout: int = 300, extra_args: List[str] = None) -&gt; Tuple[str, str]:\n    \"\"\"Run a Volatility 2 plugin, return (plugin_name, raw_text_output).\"\"\"\n    if not Path(VOL2).exists():\n        return plugin, ''\n\n    cmd = ['python2', VOL2, '-f', image_path,\n           f'--profile={profile}', plugin]\n    if extra_args:\n        cmd.extend(extra_args)\n\n    try:\n        r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)\n        return plugin, r.stdout\n    except subprocess.TimeoutExpired:\n        log.warning(f\"TIMEOUT (vol2): {plugin}\")\n        return plugin, ''\n    except Exception as e:\n        log.debug(f\"Vol2 error in {plugin}: {e}\")\n        return plugin, ''\n\ndef collect_all_plugins(image_path: str, image_info: Dict,\n                         max_workers: int = 8) -&gt; Dict:\n    \"\"\"\n    Run all applicable plugins in parallel.\n    Returns dict of plugin_name -&gt; results.\n    \"\"\"\n    os_type = image_info.get('os_type', 'windows')\n    profile = image_info.get('vol2_profile') or image_info.get('os_version', '')\n\n    plugins = WINDOWS_PLUGINS if os_type == 'windows' else LINUX_PLUGINS\n    vol2_only = VOL2_ONLY_WINDOWS if os_type == 'windows' else VOL2_ONLY_LINUX\n\n    results = {}\n    total = len(plugins) + len(vol2_only)\n    done  = 0\n\n    log.info(f\"Running {total} plugins with {max_workers} parallel workers\")\n\n    with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:\n        # Submit plugins - prefer vol2 when vol3 symbol lookup failed (old images)\n        futures = {}\n        vol3_working = image_info.get('vol3_ok') and bool(image_info.get('vol3_info'))\n        for vol3_plugin, vol2_plugin, timeout, desc in plugins:\n            if vol3_working and vol3_plugin:\n                f = executor.submit(run_vol3, image_path, vol3_plugin, timeout)\n                futures[f] = (vol3_plugin, desc)\n            elif image_info.get('vol2_ok') and vol2_plugin and profile:\n                # Vol3 symbols missing - fall back to vol2\n                f = executor.submit(run_vol2, image_path, vol2_plugin,\n                                    profile, timeout)\n                futures[f] = (vol2_plugin, desc)\n            elif image_info.get('vol3_ok') and vol3_plugin:\n                # Try vol3 anyway even without confirmed symbols\n                f = executor.submit(run_vol3, image_path, vol3_plugin, timeout)\n                futures[f] = (vol3_plugin, desc)\n\n        # Submit vol2-only plugins\n        for vol2_plugin, timeout, desc in vol2_only:\n            if image_info.get('vol2_ok') or Path(VOL2).exists():\n                f = executor.submit(run_vol2, image_path, vol2_plugin,\n                                    profile, timeout)\n                futures[f] = (vol2_plugin, desc)\n\n        # Collect results as they complete\n        for future in concurrent.futures.as_completed(futures):\n            plugin_name, desc = futures[future]\n            try:\n                name, data = future.result()\n                results[name] = data\n                done += 1\n                count = len(data) if isinstance(data, list) else len(data.splitlines())\n                log.info(f\"  [{done}\/{total}] {desc}: {count} records\")\n            except Exception as e:\n                log.error(f\"Plugin {plugin_name} failed: {e}\")\n                results[plugin_name] = []\n                done += 1\n\n    return results<\/pre>\n<h3>Phase 3: anomaly detection and scoring<\/h3>\n<pre>## \/opt\/memory-hunter\/scripts\/anomaly_checks.py\n## Applies detection logic across collected plugin results\n\nimport re\nimport logging\nfrom typing import Dict, List, Tuple\n\nlog = logging.getLogger(__name__)\n\n# Legitimate system processes and their expected parents on Windows\nWINDOWS_PROCESS_RULES = {\n    'system':      {'expected_parents': [''],          'expected_path': ''},\n    'smss.exe':    {'expected_parents': ['System'],    'expected_path': r'windows\\system32'},\n    'csrss.exe':   {'expected_parents': ['smss.exe'],  'expected_path': r'windows\\system32'},\n    'wininit.exe': {'expected_parents': ['smss.exe'],  'expected_path': r'windows\\system32'},\n    'winlogon.exe':{'expected_parents': ['smss.exe'],  'expected_path': r'windows\\system32'},\n    'services.exe':{'expected_parents': ['wininit.exe'],'expected_path': r'windows\\system32'},\n    'lsass.exe':   {'expected_parents': ['wininit.exe'],'expected_path': r'windows\\system32'},\n    'svchost.exe': {'expected_parents': ['services.exe','msiexec.exe'],\n                    'expected_path': r'windows\\system32'},\n    'explorer.exe':{'expected_parents': ['userinit.exe',''],\n                    'expected_path': r'windows'},\n    'taskhost.exe':{'expected_parents': ['services.exe'],'expected_path': r'windows\\system32'},\n    'spoolsv.exe': {'expected_parents': ['services.exe'],'expected_path': r'windows\\system32'},\n}\n\n# Office apps that should never spawn shells\nSUSPICIOUS_PARENT_CHILD = {\n    'winword.exe':  ['cmd.exe', 'powershell.exe', 'wscript.exe', 'cscript.exe',\n                     'mshta.exe', 'regsvr32.exe', 'rundll32.exe'],\n    'excel.exe':    ['cmd.exe', 'powershell.exe', 'wscript.exe', 'cscript.exe'],\n    'powerpnt.exe': ['cmd.exe', 'powershell.exe', 'wscript.exe'],\n    'outlook.exe':  ['cmd.exe', 'powershell.exe', 'wscript.exe'],\n    'acrord32.exe': ['cmd.exe', 'powershell.exe', 'wscript.exe'],\n    'wmiprvse.exe': ['cmd.exe', 'powershell.exe'],\n}\n\n# Paths that are always suspicious for system binaries\nSUSPICIOUS_PATHS = [\n    r'\\\\temp\\\\', r'\\\\tmp\\\\', r'\\\\appdata\\\\',\n    r'\\\\public\\\\', r'\\\\downloads\\\\', r'\\\\desktop\\\\',\n    r'\\\\programdata\\\\', r'\\\\recycle'\n]\n\nSUSPICIOUS_PORTS = {4444, 8080, 8443, 1337, 31337, 9001, 6667, 4545}\n\nclass Finding:\n    def __init__(self, severity: str, category: str, title: str,\n                 detail: str, pid: int = None, process: str = None):\n        self.severity = severity  # CRITICAL \/ HIGH \/ MEDIUM \/ LOW\n        self.category = category\n        self.title    = title\n        self.detail   = detail\n        self.pid      = pid\n        self.process  = process\n        self.score    = {'CRITICAL': 40, 'HIGH': 20, 'MEDIUM': 10, 'LOW': 5}[severity]\n\n    def to_dict(self):\n        return {\n            'severity': self.severity,\n            'category': self.category,\n            'title':    self.title,\n            'detail':   self.detail,\n            'pid':      self.pid,\n            'process':  self.process,\n            'score':    self.score,\n        }\n\ndef check_processes(pslist: List, pstree: List, cmdline: List) -&gt; List[Finding]:\n    \"\"\"Detect process anomalies: masquerading, suspicious parents, unusual paths.\"\"\"\n    findings = []\n\n    # Build lookup dicts\n    pid_to_name = {}\n    pid_to_ppid = {}\n    pid_to_path = {}\n    pid_to_cmd  = {}\n\n    for proc in pslist:\n        if not isinstance(proc, (list, dict)):\n            continue\n        if isinstance(proc, list):\n            # Vol3 returns lists: [PID, PPID, ImageFileName, Offset, Threads, Handles, SessionId, Wow64, CreateTime, ExitTime, File output]\n            pid  = proc[0] if len(proc) &gt; 0 else 0\n            ppid = proc[1] if len(proc) &gt; 1 else 0\n            name = (proc[2] if len(proc) &gt; 2 else '').lower()\n            path = (proc[10] if len(proc) &gt; 10 else '').lower()\n        else:\n            pid  = proc.get('PID', proc.get('pid', 0))\n            ppid = proc.get('PPID', proc.get('ppid', 0))\n            name = proc.get('ImageFileName', proc.get('name', '')).lower()\n            path = proc.get('Path', proc.get('path', '')).lower()\n\n        pid_to_name[pid] = name\n        pid_to_ppid[pid] = ppid\n        pid_to_path[pid] = path\n\n    for cmd_entry in cmdline:\n        if isinstance(cmd_entry, list) and len(cmd_entry) &gt;= 3:\n            pid_to_cmd[cmd_entry[0]] = cmd_entry[2] or ''\n        elif isinstance(cmd_entry, dict):\n            pid_to_cmd[cmd_entry.get('PID', 0)] = cmd_entry.get('Args', '')\n\n    # Check each process\n    for pid, name in pid_to_name.items():\n        path   = pid_to_path.get(pid, '')\n        ppid   = pid_to_ppid.get(pid, 0)\n        parent = pid_to_name.get(ppid, '').lower()\n        cmd    = pid_to_cmd.get(pid, '')\n\n        # 1. Process in suspicious location\n        for sus_path in SUSPICIOUS_PATHS:\n            if sus_path in path.replace('\\\\', '\\\\\\\\'):\n                if name in [k for k in WINDOWS_PROCESS_RULES.keys()]:\n                    findings.append(Finding(\n                        'CRITICAL', 'process_masquerade',\n                        f'System process in suspicious location',\n                        f'{name} (PID {pid}) running from: {path}',\n                        pid, name\n                    ))\n                elif name.endswith('.exe'):\n                    findings.append(Finding(\n                        'HIGH', 'suspicious_path',\n                        f'Executable in staging location',\n                        f'{name} (PID {pid}) at {path}',\n                        pid, name\n                    ))\n\n        # 2. Suspicious parent-child pairs\n        for parent_name, bad_children in SUSPICIOUS_PARENT_CHILD.items():\n            if parent == parent_name and name in bad_children:\n                findings.append(Finding(\n                    'CRITICAL', 'suspicious_spawn',\n                    f'Office\/PDF app spawned shell',\n                    f'{parent} (PPID {ppid}) spawned {name} (PID {pid})\\nCmd: {cmd[:200]}',\n                    pid, name\n                ))\n\n        # 3. Encoded PowerShell\n        if name == 'powershell.exe' and cmd:\n            if any(enc in cmd.lower() for enc in ['-enc', '-encodedcommand', '-e ']):\n                findings.append(Finding(\n                    'HIGH', 'encoded_powershell',\n                    'PowerShell with encoded command',\n                    f'PID {pid}: {cmd[:300]}',\n                    pid, name\n                ))\n            if any(sus in cmd.lower() for sus in [\n                'downloadstring', 'downloadfile', 'webclient',\n                'invoke-expression', 'iex ', 'frombase64'\n            ]):\n                findings.append(Finding(\n                    'HIGH', 'ps_download_cradle',\n                    'PowerShell download cradle detected',\n                    f'PID {pid}: {cmd[:300]}',\n                    pid, name\n                ))\n\n        # 4. WMI execution chain\n        if parent == 'wmiprvse.exe' and name in ['cmd.exe', 'powershell.exe',\n                                                   'wscript.exe', 'cscript.exe']:\n            findings.append(Finding(\n                'HIGH', 'wmi_execution',\n                'WMI spawned command interpreter',\n                f'WmiPrvSE spawned {name} (PID {pid}): {cmd[:200]}',\n                pid, name\n            ))\n\n    return findings\n\ndef check_malfind(malfind_results: List) -&gt; List[Finding]:\n    \"\"\"Score and categorise malfind results.\"\"\"\n    findings = []\n\n    for region in malfind_results:\n        if isinstance(region, list):\n            pid   = region[0] if len(region) &gt; 0 else 0\n            name  = region[1] if len(region) &gt; 1 else ''\n            start = region[3] if len(region) &gt; 3 else 0\n            prot  = region[5] if len(region) &gt; 5 else ''\n            hexd  = str(region[7]) if len(region) &gt; 7 else ''\n        elif isinstance(region, dict):\n            pid   = region.get('PID', region.get('Pid', 0))\n            name  = region.get('Process', region.get('ImageFileName', ''))\n            start = region.get('Start', region.get('VadStart', 0))\n            prot  = region.get('Protection', '')\n            hexd  = str(region.get('Hexdump', region.get('Data', '')))\n        else:\n            continue\n\n        has_pe  = hexd.strip().startswith('4d 5a') or hexd.strip().startswith('MZ')\n        is_rwx  = 'EXECUTE_READ_WRITE' in str(prot)\n\n        if has_pe and is_rwx:\n            findings.append(Finding(\n                'CRITICAL', 'injection',\n                'PE file in RWX anonymous memory (reflective loading)',\n                f'PID {pid} ({name}): addr=0x{start:x} protection={prot}',\n                pid, name\n            ))\n        elif has_pe:\n            findings.append(Finding(\n                'HIGH', 'injection',\n                'PE header in executable anonymous memory',\n                f'PID {pid} ({name}): addr=0x{start:x} protection={prot}',\n                pid, name\n            ))\n        elif is_rwx:\n            findings.append(Finding(\n                'HIGH', 'injection',\n                'RWX anonymous memory region (shellcode staging)',\n                f'PID {pid} ({name}): addr=0x{start:x}',\n                pid, name\n            ))\n\n    return findings\n\ndef check_network(netscan_results: List) -&gt; List[Finding]:\n    \"\"\"Detect suspicious network activity.\"\"\"\n    findings = []\n    internal = ['10.', '172.16.', '172.17.', '172.18.', '172.19.',\n                '172.20.', '172.21.', '172.22.', '172.23.', '172.24.',\n                '172.25.', '172.26.', '172.27.', '172.28.', '172.29.',\n                '172.30.', '172.31.', '192.168.', '127.', '0.0.0.0']\n\n    for conn in netscan_results:\n        if isinstance(conn, list):\n            proto  = str(conn[0]) if len(conn) &gt; 0 else ''\n            local  = str(conn[1]) if len(conn) &gt; 1 else ''\n            remote = str(conn[3]) if len(conn) &gt; 3 else ''\n            rport  = int(conn[4]) if len(conn) &gt; 4 else 0\n            state  = str(conn[5]) if len(conn) &gt; 5 else ''\n            pid    = int(conn[6]) if len(conn) &gt; 6 else 0\n            name   = str(conn[7]) if len(conn) &gt; 7 else ''\n        elif isinstance(conn, dict):\n            remote = str(conn.get('ForeignAddr', conn.get('RemoteAddr', '')))\n            rport  = int(conn.get('ForeignPort', conn.get('RemotePort', 0)))\n            state  = str(conn.get('State', ''))\n            pid    = int(conn.get('PID', conn.get('Pid', 0)))\n            name   = str(conn.get('Owner', conn.get('Process', '')))\n        else:\n            continue\n\n        if 'ESTABLISHED' not in state:\n            continue\n\n        remote_ip = remote.split(':')[0] if ':' in remote else remote\n        is_external = not any(remote_ip.startswith(r) for r in internal)\n\n        if is_external:\n            if rport in SUSPICIOUS_PORTS:\n                findings.append(Finding(\n                    'HIGH', 'suspicious_network',\n                    f'Connection to external IP on known C2 port',\n                    f'{name} (PID {pid}) -&gt; {remote_ip}:{rport}',\n                    pid, name\n                ))\n            elif name.lower() in ['svchost.exe', 'lsass.exe', 'csrss.exe',\n                                   'winlogon.exe', 'services.exe']:\n                findings.append(Finding(\n                    'HIGH', 'suspicious_network',\n                    f'System process with external network connection',\n                    f'{name} (PID {pid}) -&gt; {remote_ip}:{rport}',\n                    pid, name\n                ))\n\n    return findings\n\ndef check_services(svcscan: List) -&gt; List[Finding]:\n    \"\"\"Detect suspicious service configurations.\"\"\"\n    findings = []\n    suspicious_paths = [r'\\temp\\\\', r'\\tmp\\\\', r'\\appdata\\\\',\n                        r'\\public\\\\', r'\\programdata\\\\']\n\n    for svc in svcscan:\n        if isinstance(svc, list):\n            name   = str(svc[0]) if len(svc) &gt; 0 else ''\n            binary = str(svc[4]) if len(svc) &gt; 4 else ''\n            state  = str(svc[2]) if len(svc) &gt; 2 else ''\n        elif isinstance(svc, dict):\n            name   = str(svc.get('ServiceName', svc.get('Name', '')))\n            binary = str(svc.get('Binary', svc.get('Path', '')))\n            state  = str(svc.get('State', ''))\n        else:\n            continue\n\n        binary_lower = binary.lower()\n        for sus in suspicious_paths:\n            if sus in binary_lower:\n                findings.append(Finding(\n                    'HIGH', 'suspicious_service',\n                    'Service binary in staging location',\n                    f'Service: {name} | Binary: {binary}',\n                    None, name\n                ))\n        if 'powershell' in binary_lower or 'cmd.exe \/c' in binary_lower:\n            findings.append(Finding(\n                'HIGH', 'suspicious_service',\n                'Service using interpreter as binary',\n                f'Service: {name} | Binary: {binary}',\n                None, name\n            ))\n\n    return findings\n\ndef check_kernel_integrity(callbacks: List, modules: List,\n                            driverscan: List) -&gt; List[Finding]:\n    \"\"\"Detect kernel-level tampering indicators.\"\"\"\n    findings = []\n\n    # Known legitimate callback registrants\n    known_callbacks = [\n        'ntoskrnl.exe', 'nt', 'win32k.sys', 'ndis.sys',\n        'tcpip.sys', 'fltmgr.sys', 'ci.dll',\n    ]\n\n    for cb in callbacks:\n        if isinstance(cb, list):\n            callback_type = str(cb[0]) if len(cb) &gt; 0 else ''\n            module        = str(cb[2]) if len(cb) &gt; 2 else ''\n        elif isinstance(cb, dict):\n            callback_type = str(cb.get('Type', ''))\n            module        = str(cb.get('Module', ''))\n        else:\n            continue\n\n        module_lower = module.lower()\n        is_known = any(known in module_lower for known in known_callbacks)\n        if not is_known and module:\n            findings.append(Finding(\n                'MEDIUM', 'kernel_callback',\n                f'Unknown kernel callback registration',\n                f'Type: {callback_type} | Module: {module}',\n            ))\n\n    # Check for hidden modules (in driverscan but not modules)\n    module_bases = set()\n    for mod in modules:\n        if isinstance(mod, list) and len(mod) &gt; 1:\n            module_bases.add(str(mod[1]))\n        elif isinstance(mod, dict):\n            module_bases.add(str(mod.get('Base', '')))\n\n    for drv in driverscan:\n        if isinstance(drv, list) and len(drv) &gt; 1:\n            base = str(drv[1])\n            name = str(drv[0]) if len(drv) &gt; 0 else ''\n        elif isinstance(drv, dict):\n            base = str(drv.get('Offset', ''))\n            name = str(drv.get('Name', ''))\n        else:\n            continue\n\n        if base and base not in module_bases:\n            findings.append(Finding(\n                'HIGH', 'hidden_driver',\n                'Driver found by scan not in module list (possible rootkit)',\n                f'Name: {name} | Base: {base}',\n            ))\n\n    return findings\n\ndef run_all_checks(plugin_results: Dict) -&gt; Tuple[List[Finding], int]:\n    \"\"\"Run all anomaly checks and return findings with total risk score.\"\"\"\n    all_findings = []\n\n    all_findings.extend(check_processes(\n        plugin_results.get('windows.pslist', []),\n        plugin_results.get('windows.pstree', []),\n        plugin_results.get('windows.cmdline', []),\n    ))\n\n    all_findings.extend(check_malfind(\n        plugin_results.get('windows.malfind', []) +\n        plugin_results.get('linux.malfind', [])\n    ))\n\n    all_findings.extend(check_network(\n        plugin_results.get('windows.netscan', []) +\n        plugin_results.get('linux.netstat', [])\n    ))\n\n    all_findings.extend(check_services(\n        plugin_results.get('windows.svcscan', [])\n    ))\n\n    all_findings.extend(check_kernel_integrity(\n        plugin_results.get('windows.callbacks', []),\n        plugin_results.get('windows.modules', []),\n        plugin_results.get('windows.driverscan', []),\n    ))\n\n    total_score = sum(f.score for f in all_findings)\n    all_findings.sort(key=lambda f: f.score, reverse=True)\n\n    return all_findings, total_score<\/pre>\n<h3>Phase 4: IOC extraction and Yara scanning<\/h3>\n<pre>## \/opt\/memory-hunter\/scripts\/phase4_iocs.py\n\nimport re\nimport subprocess\nimport json\nimport logging\nfrom typing import Dict, List, Set\nfrom pathlib import Path\n\nlog = logging.getLogger(__name__)\n\nVOL3       = '\/opt\/vol3-env\/bin\/vol'\nYARA_RULES = '\/opt\/memory-hunter\/yara_rules\/combined.yar'\n\n# Patterns for IOC extraction\nPATTERNS = {\n    'ipv4':    re.compile(r'\\b(?:(?:25[0-5]|2[0-4]\\d|[01]?\\d\\d?)\\.){3}(?:25[0-5]|2[0-4]\\d|[01]?\\d\\d?)\\b'),\n    'url':     re.compile(r'https?:\/\/[a-zA-Z0-9._\/?=&amp;%+-]{10,300}'),\n    'domain':  re.compile(r'\\b(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\\.){1,10}(?:com|net|org|io|co|xyz|top|tk|ru|cn|de|info|biz)\\b', re.I),\n    'email':   re.compile(r'\\b[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}\\b'),\n    'hash_md5':    re.compile(r'\\b[a-fA-F0-9]{32}\\b'),\n    'hash_sha256': re.compile(r'\\b[a-fA-F0-9]{64}\\b'),\n    'registry_run':re.compile(r'SOFTWARE\\\\(?:Microsoft\\\\Windows\\\\CurrentVersion\\\\Run|Wow6432Node)[^\"\\'\\\\]+', re.I),\n    'pipe':        re.compile(r'\\\\\\\\\\.\\\\pipe\\\\[a-zA-Z0-9_-]{4,}'),\n    'base64_large':re.compile(r'[A-Za-z0-9+\/]{100,}={0,2}'),\n}\n\nINTERNAL_IPS = ['10.', '172.16.', '192.168.', '127.', '169.254.']\nLEGIT_DOMAINS = ['microsoft.com', 'windows.com', 'windowsupdate.com',\n                 'google.com', 'akamai.com', 'cloudflare.com',\n                 'amazon.com', 'amazonaws.com']\n\ndef extract_strings_from_memory(image_path: str,\n                                  suspicious_pids: List[int]) -&gt; str:\n    \"\"\"Extract strings from suspicious process memory regions.\"\"\"\n    if not suspicious_pids:\n        return ''\n\n    pid_args = ['--pid', ','.join(str(p) for p in suspicious_pids[:10])]\n\n    cmd = [VOL3, '-f', image_path, '--renderer', 'json',\n           'windows.strings'] + pid_args\n    r = subprocess.run(cmd, capture_output=True, text=True, timeout=300)\n    return r.stdout if r.returncode == 0 else ''\n\ndef run_yara_scan(image_path: str) -&gt; List[Dict]:\n    \"\"\"Run Yara rules against process memory via Volatility.\"\"\"\n    if not Path(YARA_RULES).exists():\n        log.warning(\"Yara rules not found - skipping Yara scan\")\n        return []\n\n    cmd = [VOL3, '-f', image_path, '--renderer', 'json',\n           'windows.vadyarascan', '--yara-file', YARA_RULES]\n    r = subprocess.run(cmd, capture_output=True, text=True, timeout=600)\n\n    if r.returncode != 0:\n        return []\n\n    try:\n        data = json.loads(r.stdout)\n        rows = data.get('rows', data) if isinstance(data, dict) else data\n        return rows\n    except:\n        return []\n\ndef extract_iocs(plugin_results: Dict, image_path: str,\n                  suspicious_pids: List[int]) -&gt; Dict:\n    \"\"\"Extract all IOC types from plugin results and process memory strings.\"\"\"\n    iocs: Dict[str, Set] = {k: set() for k in PATTERNS}\n    iocs['yara_hits'] = set()\n\n    # Extract from network connections\n    for conn in plugin_results.get('windows.netscan', []):\n        remote = ''\n        if isinstance(conn, list):\n            remote = str(conn[3]) if len(conn) &gt; 3 else ''\n        elif isinstance(conn, dict):\n            remote = str(conn.get('ForeignAddr', ''))\n        remote_ip = remote.split(':')[0] if ':' in remote else remote\n        if remote_ip and not any(remote_ip.startswith(r) for r in INTERNAL_IPS):\n            iocs['ipv4'].add(remote_ip)\n\n    # Extract from command lines\n    for cmd_entry in plugin_results.get('windows.cmdline', []):\n        cmd_text = ''\n        if isinstance(cmd_entry, list) and len(cmd_entry) &gt; 2:\n            cmd_text = str(cmd_entry[2])\n        elif isinstance(cmd_entry, dict):\n            cmd_text = str(cmd_entry.get('Args', ''))\n\n        for ioc_type, pattern in PATTERNS.items():\n            for match in pattern.findall(cmd_text):\n                if ioc_type == 'ipv4' and not any(match.startswith(r) for r in INTERNAL_IPS):\n                    iocs[ioc_type].add(match)\n                elif ioc_type == 'url' and not any(d in match for d in LEGIT_DOMAINS):\n                    iocs[ioc_type].add(match[:200])\n                elif ioc_type not in ('ipv4', 'url'):\n                    iocs[ioc_type].add(match[:200])\n\n    # Extract from process memory strings\n    if suspicious_pids:\n        strings_output = extract_strings_from_memory(image_path, suspicious_pids)\n        for ioc_type, pattern in PATTERNS.items():\n            for match in pattern.findall(strings_output):\n                if ioc_type == 'url' and not any(d in match for d in LEGIT_DOMAINS):\n                    iocs[ioc_type].add(match[:200])\n                elif ioc_type == 'ipv4' and not any(match.startswith(r) for r in INTERNAL_IPS):\n                    iocs[ioc_type].add(match)\n                elif ioc_type == 'pipe' and 'pipe\\\\' in match.lower():\n                    iocs[ioc_type].add(match)\n\n    # Yara scan\n    yara_hits = run_yara_scan(image_path)\n    for hit in yara_hits:\n        if isinstance(hit, list):\n            rule = str(hit[2]) if len(hit) &gt; 2 else ''\n            pid  = str(hit[0]) if len(hit) &gt; 0 else ''\n            proc = str(hit[1]) if len(hit) &gt; 1 else ''\n        elif isinstance(hit, dict):\n            rule = str(hit.get('Rule', ''))\n            pid  = str(hit.get('PID', ''))\n            proc = str(hit.get('Process', ''))\n        else:\n            continue\n        if rule:\n            iocs['yara_hits'].add(f\"{rule} (PID {pid} \/ {proc})\")\n            log.warning(f\"YARA HIT: {rule} in PID {pid} ({proc})\")\n\n    return {k: sorted(list(v)) for k, v in iocs.items()}<\/pre>\n<h3>Phase 5: report generation (HTML and JSON)<\/h3>\n<pre>## \/opt\/memory-hunter\/scripts\/phase5_report.py\n\nimport json\nimport logging\nfrom datetime import datetime\nfrom pathlib import Path\nfrom typing import Dict, List\nfrom jinja2 import Template\n\nlog = logging.getLogger(__name__)\n\nREPORT_DIR = Path('\/opt\/memory-hunter\/reports')\n\nHTML_TEMPLATE = \"\"\"\n\n\n\n\n<title>Memory Analysis: {{ meta.filename }}<\/title>\n\n  * { box-sizing: border-box; margin: 0; padding: 0; }\n  body { font-family: 'Segoe UI', system-ui, sans-serif; background: #f1f5f9;\n         color: #0f172a; font-size: 14px; }\n  .header { background: #0f172a; color: #e2e8f0; padding: 24px 32px; }\n  .header h1 { font-size: 20px; font-weight: 700; }\n  .header .meta { font-family: monospace; font-size: 11px; color: #64748b;\n                  margin-top: 6px; }\n  .risk-banner { padding: 14px 32px; font-weight: 700; font-size: 15px; }\n  .risk-CRITICAL { background: #450a0a; color: #fca5a5; }\n  .risk-HIGH     { background: #431407; color: #fed7aa; }\n  .risk-MEDIUM   { background: #422006; color: #fde68a; }\n  .risk-LOW      { background: #052e16; color: #86efac; }\n  .container { padding: 24px 32px; max-width: 1400px; }\n  .section { background: white; border-radius: 10px; margin-bottom: 20px;\n             border: 1px solid #e2e8f0; overflow: hidden; }\n  .section-header { padding: 14px 18px; background: #f8fafc;\n                    border-bottom: 1px solid #e2e8f0; font-weight: 600;\n                    font-size: 13px; display: flex; align-items: center; gap: 10px; }\n  .count-badge { background: #1e293b; color: #94a3b8; padding: 2px 8px;\n                 border-radius: 4px; font-family: monospace; font-size: 10px; }\n  .finding { padding: 14px 18px; border-bottom: 1px solid #f1f5f9; }\n  .finding:last-child { border-bottom: none; }\n  .finding-header { display: flex; align-items: center; gap: 10px;\n                    margin-bottom: 6px; }\n  .sev { padding: 2px 8px; border-radius: 4px; font-size: 10px;\n         font-weight: 700; font-family: monospace; }\n  .sev-CRITICAL { background: #450a0a; color: #fca5a5; border: 1px solid #ef4444; }\n  .sev-HIGH     { background: #431407; color: #fed7aa; border: 1px solid #f97316; }\n  .sev-MEDIUM   { background: #422006; color: #fde68a; border: 1px solid #eab308; }\n  .sev-LOW      { background: #052e16; color: #86efac; border: 1px solid #22c55e; }\n  .finding-title { font-weight: 600; font-size: 13px; }\n  .finding-detail { font-family: monospace; font-size: 11px; color: #475569;\n                    background: #f8fafc; padding: 8px 10px; border-radius: 4px;\n                    margin-top: 6px; white-space: pre-wrap; word-break: break-all; }\n  .ioc-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px,1fr));\n              gap: 12px; padding: 16px 18px; }\n  .ioc-group { }\n  .ioc-label { font-size: 9px; font-family: monospace; text-transform: uppercase;\n               letter-spacing: 0.1em; color: #94a3b8; margin-bottom: 6px; }\n  .ioc-value { font-family: monospace; font-size: 11px; color: #0369a1;\n               background: #e0f2fe; padding: 3px 8px; border-radius: 3px;\n               margin-bottom: 3px; word-break: break-all; }\n  .stats { display: grid; grid-template-columns: repeat(5, 1fr);\n           gap: 12px; margin-bottom: 20px; }\n  .stat-card { background: white; border-radius: 8px; padding: 16px;\n               border: 1px solid #e2e8f0; text-align: center; }\n  .stat-val { font-size: 28px; font-weight: 800; }\n  .stat-lbl { font-size: 10px; color: #94a3b8; text-transform: uppercase;\n              letter-spacing: 0.1em; margin-top: 4px; }\n  .val-critical { color: #ef4444; } .val-high { color: #f97316; }\n  .val-medium { color: #eab308; } .val-low { color: #22c55e; }\n  table { width: 100%; border-collapse: collapse; }\n  th { text-align: left; padding: 8px 12px; font-size: 10px; color: #64748b;\n       text-transform: uppercase; letter-spacing: 0.08em;\n       border-bottom: 1px solid #e2e8f0; background: #f8fafc; }\n  td { padding: 8px 12px; border-bottom: 1px solid #f1f5f9;\n       font-family: monospace; font-size: 11px; }\n  tr:last-child td { border-bottom: none; }\n  .tag { display: inline-block; padding: 1px 6px; border-radius: 3px;\n         font-size: 9px; font-family: monospace; }\n  .tag-ext { background: #fee2e2; color: #991b1b; }\n  .tag-sus { background: #fef3c7; color: #92400e; }\n\n\n\n<div class=\"header\">\n  <h1>Memory Analysis Report: {{ meta.filename }}<\/h1>\n  <div class=\"meta\">\n    SHA256: {{ meta.sha256 }} &nbsp;|&nbsp;\n    Size: {{ meta.size_gb }} GB &nbsp;|&nbsp;\n    OS: {{ meta.os_version or 'Unknown' }} &nbsp;|&nbsp;\n    Analysed: {{ generated_at }}\n  <\/div>\n<\/div>\n\n<div class=\"risk-banner risk-{{ risk_label }}\">\n  Overall Risk: {{ risk_label }} (Score: {{ risk_score }}) &nbsp;|&nbsp;\n  {{ findings|length }} finding(s) &nbsp;|&nbsp;\n  {{ iocs.yara_hits|length }} Yara hit(s)\n<\/div>\n\n<div class=\"container\">\n\n<div class=\"stats\">\n  <div class=\"stat-card\">\n    <div class=\"stat-val val-critical\">{{ findings|selectattr('severity','eq','CRITICAL')|list|length }}<\/div>\n    <div class=\"stat-lbl\">Critical<\/div>\n  <\/div>\n  <div class=\"stat-card\">\n    <div class=\"stat-val val-high\">{{ findings|selectattr('severity','eq','HIGH')|list|length }}<\/div>\n    <div class=\"stat-lbl\">High<\/div>\n  <\/div>\n  <div class=\"stat-card\">\n    <div class=\"stat-val val-medium\">{{ findings|selectattr('severity','eq','MEDIUM')|list|length }}<\/div>\n    <div class=\"stat-lbl\">Medium<\/div>\n  <\/div>\n  <div class=\"stat-card\">\n    <div class=\"stat-val\">{{ iocs.yara_hits|length }}<\/div>\n    <div class=\"stat-lbl\">Yara Hits<\/div>\n  <\/div>\n  <div class=\"stat-card\">\n    <div class=\"stat-val\">{{ (iocs.ipv4|length) + (iocs.url|length) }}<\/div>\n    <div class=\"stat-lbl\">Network IOCs<\/div>\n  <\/div>\n<\/div>\n\n{% if findings %}\n<div class=\"section\">\n  <div class=\"section-header\">\n    Threat Findings\n    <span class=\"count-badge\">{{ findings|length }}<\/span>\n  <\/div>\n  {% for f in findings %}\n  <div class=\"finding\">\n    <div class=\"finding-header\">\n      <span class=\"sev sev-{{ f.severity }}\">{{ f.severity }}<\/span>\n      <span class=\"finding-title\">{{ f.title }}<\/span>\n      {% if f.process %}<span style=\"color:#64748b;font-size:11px\">{{ f.process }}\n        {% if f.pid %}(PID {{ f.pid }}){% endif %}<\/span>{% endif %}\n    <\/div>\n    <div class=\"finding-detail\">{{ f.detail }}<\/div>\n  <\/div>\n  {% endfor %}\n<\/div>\n{% endif %}\n\n{% if iocs.yara_hits %}\n<div class=\"section\">\n  <div class=\"section-header\">Yara Rule Matches <span class=\"count-badge\">{{ iocs.yara_hits|length }}<\/span><\/div>\n  <div style=\"padding:14px 18px\">\n    {% for hit in iocs.yara_hits %}\n    <div style=\"font-family:monospace;font-size:12px;color:#ef4444;margin-bottom:4px\">{{ hit }}<\/div>\n    {% endfor %}\n  <\/div>\n<\/div>\n{% endif %}\n\n<div class=\"section\">\n  <div class=\"section-header\">Extracted IOCs<\/div>\n  <div class=\"ioc-grid\">\n    {% for ioc_type, values in iocs.items() %}\n    {% if values and ioc_type != 'yara_hits' %}\n    <div class=\"ioc-group\">\n      <div class=\"ioc-label\">{{ ioc_type.replace('_',' ') }} ({{ values|length }})<\/div>\n      {% for v in values[:20] %}\n      <div class=\"ioc-value\">{{ v }}<\/div>\n      {% endfor %}\n      {% if values|length &gt; 20 %}<div style=\"font-size:10px;color:#94a3b8\">...{{ values|length - 20 }} more<\/div>{% endif %}\n    <\/div>\n    {% endif %}\n    {% endfor %}\n  <\/div>\n<\/div>\n\n<div class=\"section\">\n  <div class=\"section-header\">Network Connections <span class=\"count-badge\">{{ network|length }}<\/span><\/div>\n  <table>\n    <tr><th>PID<\/th><th>Process<\/th><th>Local<\/th><th>Remote<\/th><th>Port<\/th><th>State<\/th><\/tr>\n    {% for conn in network[:50] %}\n    <tr>\n      <td>{{ conn[6] if conn|length &gt; 6 else '' }}<\/td>\n      <td>{{ conn[7] if conn|length &gt; 7 else '' }}<\/td>\n      <td>{{ conn[1] if conn|length &gt; 1 else '' }}<\/td>\n      <td>{% set r = conn[3] if conn|length &gt; 3 else '' %}\n          {{ r }}\n          {% if r and not r.startswith(('10.','192.168.','172.','127.')) %}\n          <span class=\"tag tag-ext\">EXT<\/span>{% endif %}<\/td>\n      <td>{{ conn[4] if conn|length &gt; 4 else '' }}<\/td>\n      <td>{{ conn[5] if conn|length &gt; 5 else '' }}<\/td>\n    <\/tr>\n    {% endfor %}\n  <\/table>\n<\/div>\n\n<div class=\"section\">\n  <div class=\"section-header\">Process List <span class=\"count-badge\">{{ processes|length }}<\/span><\/div>\n  <table>\n    <tr><th>PID<\/th><th>PPID<\/th><th>Name<\/th><th>Path<\/th><\/tr>\n    {% for proc in processes[:100] %}\n    <tr>\n      <td>{{ proc[0] if proc|length &gt; 0 else '' }}<\/td>\n      <td>{{ proc[1] if proc|length &gt; 1 else '' }}<\/td>\n      <td>{{ proc[2] if proc|length &gt; 2 else '' }}<\/td>\n      <td style=\"max-width:400px;overflow:hidden\">\n        {{ proc[10] if proc|length &gt; 10 else '' }}<\/td>\n    <\/tr>\n    {% endfor %}\n  <\/table>\n<\/div>\n\n<\/div>\n\n\n\"\"\"\n\ndef generate_reports(meta: Dict, findings: List, iocs: Dict,\n                      plugin_results: Dict) -&gt; Dict:\n    \"\"\"Generate HTML and JSON reports. Returns paths to both files.\"\"\"\n\n    risk_score = sum(f.score for f in findings)\n    risk_label = (\n        'CRITICAL' if risk_score &gt;= 60 else\n        'HIGH'     if risk_score &gt;= 30 else\n        'MEDIUM'   if risk_score &gt;= 10 else\n        'LOW'\n    )\n\n    hostname    = meta['filename'].split('_')[0]\n    timestamp   = datetime.now().strftime('%Y%m%d_%H%M%S')\n    report_dir  = REPORT_DIR \/ f\"{hostname}_{timestamp}\"\n    report_dir.mkdir(parents=True, exist_ok=True)\n\n    findings_dicts = [f.to_dict() for f in findings]\n\n    # \u2500\u2500 JSON report \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n    json_data = {\n        'meta':          meta,\n        'generated_at':  datetime.now().isoformat(),\n        'risk_label':    risk_label,\n        'risk_score':    risk_score,\n        'findings':      findings_dicts,\n        'iocs':          iocs,\n        'summary': {\n            'critical': sum(1 for f in findings if f.severity == 'CRITICAL'),\n            'high':     sum(1 for f in findings if f.severity == 'HIGH'),\n            'medium':   sum(1 for f in findings if f.severity == 'MEDIUM'),\n            'yara_hits':len(iocs.get('yara_hits', [])),\n            'ioc_count': sum(len(v) for v in iocs.values()),\n        }\n    }\n\n    json_path = report_dir \/ 'report.json'\n    with open(json_path, 'w') as f:\n        json.dump(json_data, f, indent=2, default=str)\n\n    # \u2500\u2500 HTML report \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n    tmpl = Template(HTML_TEMPLATE)\n    html = tmpl.render(\n        meta=meta,\n        generated_at=datetime.now().strftime('%Y-%m-%d %H:%M:%S UTC'),\n        risk_label=risk_label,\n        risk_score=risk_score,\n        findings=findings_dicts,\n        iocs=iocs,\n        network=plugin_results.get('windows.netscan',\n                plugin_results.get('netscan',\n                plugin_results.get('linux.netstat', []))),\n        processes=plugin_results.get('windows.pslist',\n                  plugin_results.get('pslist',\n                  plugin_results.get('linux.pslist', []))),\n    )\n\n    html_path = report_dir \/ 'report.html'\n    with open(html_path, 'w') as f:\n        f.write(html)\n\n    log.info(f\"Reports saved to {report_dir}\")\n    return {\n        'json': str(json_path),\n        'html': str(html_path),\n        'risk_label':  risk_label,\n        'risk_score':  risk_score,\n        'finding_count': len(findings),\n        'report_dir':  str(report_dir),\n    }<\/pre>\n<h3>The main entry point: analyse.py<\/h3>\n<pre>#!\/usr\/bin\/env python3\n## \/opt\/memory-hunter\/analyse.py\n## THE ONE SCRIPT TO RUN\n## Usage: python3 analyse.py \/path\/to\/memory.raw\n## Usage: python3 analyse.py \/path\/to\/memory.raw --workers 16 --no-vol2\n\nimport sys\nimport argparse\nimport logging\nimport time\nimport json\nfrom datetime import datetime\nfrom pathlib import Path\n\n# Add scripts directory to path\nsys.path.insert(0, str(Path(__file__).parent \/ 'scripts'))\n\nfrom phase1_identify import identify_image\nfrom vol_runner       import collect_all_plugins\nfrom anomaly_checks   import run_all_checks\nfrom phase4_iocs      import extract_iocs\nfrom phase5_report    import generate_reports\n\ndef setup_logging(log_file: str = None) -&gt; None:\n    handlers = [logging.StreamHandler(sys.stdout)]\n    if log_file:\n        handlers.append(logging.FileHandler(log_file))\n    logging.basicConfig(\n        level=logging.INFO,\n        format='%(asctime)s [%(levelname)s] %(message)s',\n        handlers=handlers\n    )\n\ndef print_banner():\n    print(\"\"\"\n\u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557\n\u2551         Memory Hunter - Automated Analysis           \u2551\n\u2551   Volatility 2 + 3 | Windows + Linux | HTML + JSON  \u2551\n\u255a\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u255d\n\"\"\")\n\ndef print_summary(report_info: dict, elapsed: float):\n    risk = report_info['risk_label']\n    colour = {\n        'CRITICAL': '\\033[91m', 'HIGH': '\\033[93m',\n        'MEDIUM': '\\033[33m',   'LOW': '\\033[92m'\n    }.get(risk, '')\n    reset = '\\033[0m'\n\n    print(f\"\"\"\n{'='*60}\n ANALYSIS COMPLETE ({elapsed:.1f} seconds)\n{'='*60}\n Risk Level:  {colour}{risk}{reset}\n Risk Score:  {report_info['risk_score']}\n Findings:    {report_info['finding_count']}\n HTML Report: {report_info['html']}\n JSON Report: {report_info['json']}\n{'='*60}\n\"\"\")\n\ndef main():\n    print_banner()\n\n    parser = argparse.ArgumentParser(\n        description='Automated memory image analysis for threat hunters'\n    )\n    parser.add_argument('image', help='Path to memory image file')\n    parser.add_argument('--workers', type=int, default=8,\n                        help='Parallel plugin workers (default: 8)')\n    parser.add_argument('--no-vol2', action='store_true',\n                        help='Skip Volatility 2 plugins')\n    parser.add_argument('--output-dir', default='\/opt\/memory-hunter\/reports',\n                        help='Report output directory')\n    parser.add_argument('--yara-rules', default='\/opt\/memory-hunter\/yara_rules\/combined.yar',\n                        help='Path to compiled Yara rules')\n    parser.add_argument('--log-file', help='Write log to file')\n    parser.add_argument('--quiet', action='store_true',\n                        help='Reduce output verbosity')\n    args = parser.parse_args()\n\n    setup_logging(args.log_file)\n    log = logging.getLogger(__name__)\n\n    start_time = time.time()\n    image_path = str(Path(args.image).absolute())\n\n    print(f\"[*] Image: {image_path}\")\n    print(f\"[*] Workers: {args.workers}\")\n    print()\n\n    # \u2500\u2500 Phase 1: Identify \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n    print(\"[Phase 1\/5] Identifying image...\")\n    meta = identify_image(image_path)\n    print(f\"  OS Type:    {meta.get('os_type', 'unknown')}\")\n    print(f\"  OS Version: {meta.get('os_version', 'unknown')}\")\n    print(f\"  Size:       {meta.get('size_gb', 0):.1f} GB\")\n    print(f\"  SHA256:     {meta.get('sha256', '')[:32]}...\")\n    print()\n\n    if not meta.get('os_type'):\n        print(\"[!] Could not identify OS type - check symbol tables\")\n        print(\"    Windows: ensure \/opt\/vol3-symbols\/windows\/ is populated\")\n        print(\"    Linux:   ensure ISF file exists for this kernel version\")\n        sys.exit(1)\n\n    # \u2500\u2500 Phase 2: Collect \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n    print(f\"[Phase 2\/5] Running plugin collection ({args.workers} workers)...\")\n    plugin_results = collect_all_plugins(image_path, meta, args.workers)\n    print(f\"  Plugins completed: {len(plugin_results)}\")\n    print()\n\n    # \u2500\u2500 Phase 3: Detect \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n    print(\"[Phase 3\/5] Running anomaly detection...\")\n    findings, risk_score = run_all_checks(plugin_results)\n    critical = sum(1 for f in findings if f.severity == 'CRITICAL')\n    high     = sum(1 for f in findings if f.severity == 'HIGH')\n    print(f\"  Findings: {len(findings)} total ({critical} critical, {high} high)\")\n    if findings:\n        print(\"  Top findings:\")\n        for f in findings[:5]:\n            print(f\"    [{f.severity}] {f.title}\")\n    print()\n\n    # \u2500\u2500 Phase 4: IOCs \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n    print(\"[Phase 4\/5] Extracting IOCs...\")\n    suspicious_pids = [f.pid for f in findings if f.pid]\n    iocs = extract_iocs(plugin_results, image_path, suspicious_pids)\n    ioc_count = sum(len(v) for v in iocs.values())\n    print(f\"  IOCs extracted: {ioc_count}\")\n    if iocs.get('yara_hits'):\n        print(f\"  YARA hits: {len(iocs['yara_hits'])}\")\n        for hit in iocs['yara_hits'][:3]:\n            print(f\"    -&gt; {hit}\")\n    print()\n\n    # \u2500\u2500 Phase 5: Report \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n    print(\"[Phase 5\/5] Generating reports...\")\n    report_info = generate_reports(meta, findings, iocs, plugin_results)\n    elapsed = time.time() - start_time\n\n    print_summary(report_info, elapsed)\n\n    # Exit code reflects risk level for CI\/CD integration\n    exit_codes = {'CRITICAL': 3, 'HIGH': 2, 'MEDIUM': 1, 'LOW': 0}\n    sys.exit(exit_codes.get(report_info['risk_label'], 0))\n\nif __name__ == '__main__':\n    main()<\/pre>\n<h3>Making it truly one command to run<\/h3>\n<pre>## Install the script system-wide\nsudo ln -sf \/opt\/memory-hunter\/analyse.py \/usr\/local\/bin\/memory-hunt\nsudo chmod +x \/opt\/memory-hunter\/analyse.py\n\n## Now you can run from anywhere:\nmemory-hunt \/path\/to\/image.raw\n\n## With options:\nmemory-hunt \/path\/to\/image.raw --workers 16 --log-file \/tmp\/analysis.log\n\n## In a pipeline (exit code reflects risk):\nmemory-hunt suspicious.raw &amp;&amp; echo \"CLEAN\" || echo \"THREATS FOUND\"\n\n## Process multiple images in parallel\nls \/srv\/memory\/landing\/*.raw | \\\n    parallel -j 4 memory-hunt {} --log-file \/srv\/memory\/logs\/{\/.}.log\n\n## Quick check with reduced scope (faster for initial triage)\nmemory-hunt image.raw --workers 4 --no-vol2<\/pre>\n<h3>Linux image support and ISF generation<\/h3>\n<pre>## Linux memory analysis requires ISF (Intermediate Symbol Format) files\n## These must be generated for each specific kernel version being analysed\n\n## Method 1: Generate ISF from a running system (same kernel as the image)\n## Install dwarf2json\nwget https:\/\/github.com\/volatilityfoundation\/dwarf2json\/releases\/latest\/download\/dwarf2json-linux-amd64\nchmod +x dwarf2json-linux-amd64\n\n## Generate ISF from the running kernel (on the target or identical system)\nsudo .\/dwarf2json-linux-amd64 linux \\\n    --elf \/usr\/lib\/debug\/boot\/vmlinux-$(uname -r) \\\n    &gt; \/opt\/vol3-symbols\/linux\/$(uname -r).json\n\n## Method 2: Generate from vmlinux debug symbols package\n## On Ubuntu\/Debian:\nsudo apt install linux-image-$(uname -r)-dbgsym 2&gt;\/dev\/null || \\\n    sudo apt install linux-image-$(uname -r)-dbg\nsudo .\/dwarf2json-linux-amd64 linux \\\n    --elf \/usr\/lib\/debug\/boot\/vmlinux-$(uname -r) \\\n    --system-map \/boot\/System.map-$(uname -r) \\\n    &gt; \/opt\/vol3-symbols\/linux\/$(uname -r).json\n\n## Verify Volatility can use the ISF\nvol -f linux_memory.lime linux.pslist\n\n## Automating ISF generation for a fleet\n## Run this on each unique kernel version in your environment\npython3 &lt;&lt; &#039;EOF&#039;\nimport subprocess, os, sys\nfrom pathlib import Path\n\nISF_DIR = Path(&#039;\/opt\/vol3-symbols\/linux&#039;)\nISF_DIR.mkdir(parents=True, exist_ok=True)\n\nkernel_version = subprocess.run([&#039;uname&#039;, &#039;-r&#039;],\n    capture_output=True, text=True).stdout.strip()\nisf_path = ISF_DIR \/ f&quot;{kernel_version}.json&quot;\n\nif isf_path.exists():\n    print(f&quot;ISF already exists for {kernel_version}&quot;)\n    sys.exit(0)\n\n# Try to find vmlinux debug symbols\nvmlinux_paths = [\n    f&#039;\/usr\/lib\/debug\/boot\/vmlinux-{kernel_version}&#039;,\n    f&#039;\/usr\/lib\/debug\/lib\/modules\/{kernel_version}\/vmlinux&#039;,\n    f&#039;\/boot\/vmlinux-{kernel_version}&#039;,\n]\n\nvmlinux = next((p for p in vmlinux_paths if os.path.exists(p)), None)\nif not vmlinux:\n    print(f&quot;No debug symbols found for {kernel_version}&quot;)\n    print(f&quot;Install: apt install linux-image-{kernel_version}-dbgsym&quot;)\n    sys.exit(1)\n\nresult = subprocess.run([\n    &#039;\/opt\/dwarf2json-linux-amd64&#039;, &#039;linux&#039;,\n    &#039;--elf&#039;, vmlinux,\n], capture_output=True, timeout=300)\n\nif result.returncode == 0:\n    isf_path.write_bytes(result.stdout)\n    print(f&quot;ISF generated: {isf_path} ({len(result.stdout)\/\/1024}KB)&quot;)\nelse:\n    print(f&quot;dwarf2json failed: {result.stderr}&quot;)\n    sys.exit(1)\nEOF<\/pre>\n<h3>Troubleshooting the automation pipeline<\/h3>\n<pre>## Common issues and their fixes\n\n## Issue 1: \"Unsatisfied requirement\" errors from Volatility 3\n## This means symbol tables are missing or mislinked\nsource \/opt\/vol3-env\/bin\/activate\npython3 -c \"\nimport volatility3.symbols as sym\nimport os\nsym_path = os.path.dirname(sym.__file__)\nwin_path  = os.path.join(sym_path, 'windows')\nprint(f'Symbol path: {sym_path}')\nprint(f'Windows syms: {os.path.isdir(win_path)}')\nif os.path.isdir(win_path):\n    files = os.listdir(win_path)\n    print(f'Files: {len(files)} (sample: {files[:2]})')\n\"\n\n## Fix: re-link symbols\nSITE=$(python3 -c \"import site; print(site.getsitepackages()[0])\")\nln -sf \/opt\/vol3-symbols\/windows $SITE\/volatility3\/symbols\/windows\n\n## Issue 2: Plugin times out on large images\n## Increase timeout values in vol_runner.py or limit to core plugins only\n## The --workers flag does not help here - timeouts are per-plugin\n\n## Issue 3: Yara scan returns no results despite known malware in image\n## Check compiled ruleset is not corrupted\npython3 -c \"\nimport yara\ntry:\n    rules = yara.load('\/opt\/memory-hunter\/yara_rules\/combined.yar')\n    print('Ruleset loaded OK')\nexcept Exception as e:\n    print(f'Error: {e} - recompile the ruleset')\n\"\n\n## Recompile from source rules:\npython3 -c \"\nimport yara, glob\nrule_files = {}\nfor f in glob.glob('\/opt\/memory-hunter\/yara_rules\/rules\/*.yar'):\n    rule_files[f.split('\/')[-1].replace('.yar','')] = f\ncombined = yara.compile(filepaths=rule_files)\ncombined.save('\/opt\/memory-hunter\/yara_rules\/combined.yar')\nprint(f'Compiled {len(rule_files)} rule files')\n\"\n\n## Issue 4: HTML report renders but shows no data\n## The template assumes Vol3 list format [pid, ppid, name, ...path at index 10]\n## Different Vol3 versions may change column ordering\n## Debug by checking raw plugin output:\nvol -f image.raw --renderer json windows.pslist | \\\n    python3 -c \"import json,sys; d=json.load(sys.stdin); print(d['columns'])\"\n\n## Issue 5: analyse.py runs but risk score is always 0\n## Check anomaly_checks.py is receiving data by printing plugin result sizes:\npython3 -c \"\nimport sys; sys.path.insert(0,'\/opt\/memory-hunter\/scripts')\nfrom vol_runner import collect_all_plugins\nfrom phase1_identify import identify_image\nmeta = identify_image(sys.argv[1])\nresults = collect_all_plugins(sys.argv[1], meta, 4)\nfor name, data in sorted(results.items()):\n    count = len(data) if isinstance(data, list) else len(data.splitlines())\n    print(f'{name}: {count} records')\n\" \/path\/to\/image.raw<\/pre>\n<h3>Integrating with the fleet pipeline<\/h3>\n<p>The single-image automation script integrates cleanly with the fleet collection pipeline described in the companion post. When the image watcher detects a new image it can call the analyse.py script directly instead of the Celery task chain, which is useful for simpler deployments that do not need the full distributed pipeline.<\/p>\n<pre>## Simple integration with the image watcher\n## Replace the validate_image.delay() call with a direct script invocation\n\n## In image_watcher.py, replace:\n## validate_image.delay(image_id)\n\n## With:\nimport subprocess\nsubprocess.Popen([\n    '\/opt\/vol3-env\/bin\/python3',\n    '\/opt\/memory-hunter\/analyse.py',\n    str(path),\n    '--workers', '8',\n    '--log-file', f'\/srv\/memory\/logs\/{hostname}_{timestamp}.log',\n    '--output-dir', f'\/srv\/memory\/reports\/{hostname}',\n])\n\n## The exit code from analyse.py maps to risk level:\n## 0 = LOW (no concerning findings)\n## 1 = MEDIUM\n## 2 = HIGH\n## 3 = CRITICAL\n## Use this in automation to trigger different response actions\n\n## Example: auto-isolate a host if CRITICAL findings\nresult = subprocess.run([\n    '\/opt\/vol3-env\/bin\/python3',\n    '\/opt\/memory-hunter\/analyse.py',\n    image_path,\n], capture_output=True)\n\nif result.returncode == 3:\n    log.warning(f\"CRITICAL findings in {hostname} - triggering isolation workflow\")\n    # Call your EDR\/firewall API to isolate the host\n    isolate_host(hostname)\nelif result.returncode == 2:\n    log.warning(f\"HIGH findings in {hostname} - notifying SOC\")\n    notify_soc(hostname)<\/pre>\n<h3>The HTML report template in full<\/h3>\n<p>The report template referenced in phase5_report.py is a Jinja2 template that lives at <code>\/opt\/memory-hunter\/templates\/report.html.j2<\/code>. The template code was included inline in the phase5_report.py listing above as the <code>HTML_TEMPLATE<\/code> string. To use it as a standalone file instead, replace the inline string with a file load:<\/p>\n<pre>## In phase5_report.py, replace the HTML_TEMPLATE string with:\nfrom jinja2 import Environment, FileSystemLoader\n\nenv  = Environment(loader=FileSystemLoader('\/opt\/memory-hunter\/templates'))\ntmpl = env.get_template('report.html.j2')\nhtml = tmpl.render(...)<\/pre>\n<p>Save the template content from the <code>HTML_TEMPLATE<\/code> variable in phase5_report.py to <code>\/opt\/memory-hunter\/templates\/report.html.j2<\/code>. The template uses standard Jinja2 syntax throughout: <code>{{ variable }}<\/code> for output, <code>{% for item in list %}<\/code> for loops, <code>{% if condition %}<\/code> for conditionals. No additional template dependencies are needed beyond Jinja2 itself.<\/p>\n<h3>Yara rules: complete content for all four rule files<\/h3>\n<p>These are the four rule files that belong in <code>\/opt\/memory-hunter\/yara_rules\/rules\/<\/code>. Each focuses on a different threat category relevant to Windows endpoint memory analysis.<\/p>\n<pre>## File: \/opt\/memory-hunter\/yara_rules\/rules\/cobalt_strike.yar\n## Detects Cobalt Strike beacon variants in process memory\n\nrule CobaltStrike_Beacon_Config_Decoded {\n    meta:\n        description = \"Detects decoded Cobalt Strike beacon configuration in process memory\"\n        author      = \"justruss\"\n        date        = \"2026-05-24\"\n        confidence  = \"high\"\n        reference   = \"https:\/\/blog.cobaltstrike.com\/2021\/02\/09\/learn-pipe-fitting-for-all-of-your-offense-projects\/\"\n    strings:\n        \/\/ Beacon config block header pattern (appears in decoded config)\n        $cfg_header  = { 00 01 00 01 00 00 00 ?? 00 02 00 01 }\n        \/\/ Default HTTP GET URI patterns in decoded beacon memory\n        $uri_check   = \"\/updates\/check\" ascii wide\n        $uri_submit  = \"\/submit.php\" ascii wide\n        $uri_cdn     = \"\/CDN\/\" ascii wide\n        \/\/ Sleep mask instruction sequence common across CS versions\n        $sleep_mask  = { C7 44 24 ?? 01 00 00 00 EB ?? }\n        \/\/ Reflective loader export (present in memory even without file on disk)\n        $ref_loader  = \"ReflectiveLoader\" ascii fullword\n        \/\/ Named pipe patterns for SMB beacon\n        $pipe_msse   = \"\\\\.\\pipe\\MSSE-\" wide ascii\n        $pipe_postex = \"\\\\.\\pipe\\postex_\" wide ascii\n        $pipe_status = \"\\\\.\\pipe\\status_\" wide ascii\n        \/\/ Watermark field offset in beacon config\n        $watermark   = { 00 27 00 01 }\n    condition:\n        \/\/ PE in memory or raw shellcode context\n        (($ref_loader or $sleep_mask) and 1 of ($uri_check, $uri_submit, $uri_cdn, $cfg_header))\n        or 2 of ($pipe_msse, $pipe_postex, $pipe_status)\n}\n\nrule CobaltStrike_Shellcode_Stager {\n    meta:\n        description = \"Detects Cobalt Strike shellcode stager in memory\"\n        confidence  = \"medium\"\n    strings:\n        \/\/ Common x64 CS stager prologue\n        $stager_x64  = { FC 48 83 E4 F0 E8 C? 00 00 00 }\n        \/\/ Common x86 CS stager\n        $stager_x86  = { FC E8 8? 00 00 00 60 89 E5 }\n        \/\/ DNS stager pattern\n        $dns_stager  = { 64 A1 30 00 00 00 8B 40 0C 8B 40 1C }\n    condition:\n        any of them\n\n}\n\nrule CobaltStrike_MalleableC2_Indicators {\n    meta:\n        description = \"Detects indicators of Cobalt Strike Malleable C2 profiles\"\n        confidence  = \"low\"\n    strings:\n        \/\/ Common Malleable C2 Amazon profile indicators\n        $amz_host    = \"s3.amazonaws.com\" ascii wide\n        $amz_sdk     = \"aws-sdk-go\" ascii wide\n        \/\/ Common Malleable C2 Office 365 profile\n        $o365        = \"outlook.office365.com\" ascii wide\n        \/\/ Common fake user agents in profiles\n        $ua_excel    = \"Microsoft Excel\" ascii wide\n        $ua_teams    = \"Teams\/1.\" ascii wide\n    condition:\n        \/\/ Only meaningful in context of other CS indicators\n        ($ref_loader or $sleep_mask) and\n        (1 of ($amz_host, $amz_sdk, $o365, $ua_excel, $ua_teams))\n}\n<\/pre>\n<pre>## File: \/opt\/memory-hunter\/yara_rules\/rules\/meterpreter.yar\n## Detects Meterpreter variants in process memory\n\nrule Meterpreter_Reflective_DLL_x64 {\n    meta:\n        description = \"Detects Meterpreter x64 reflective DLL loaded in process memory\"\n        author      = \"justruss\"\n        date        = \"2026-05-24\"\n        confidence  = \"high\"\n    strings:\n        $mz           = { 4D 5A }\n        $ref_loader   = \"ReflectiveLoader\" ascii fullword\n        \/\/ Meterpreter core extension names\n        $stdapi       = \"stdapi_\" ascii\n        $priv         = \"priv_elevate\" ascii\n        $incognito    = \"incognito_\" ascii\n        $kiwi         = \"kiwi_cmd\" ascii\n        \/\/ Meterpreter transport strings\n        $transport    = \"METERPRETER_TRANSPORT_\" ascii\n        $pivot        = \"pivot_\" ascii\n        \/\/ Session GUID pattern in Meterpreter memory\n        $session_guid = { [0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12} }\n    condition:\n        $mz at 0\n        and $ref_loader\n        and 2 of ($stdapi, $priv, $incognito, $kiwi, $transport, $pivot)\n}\n\nrule Meterpreter_Shellcode_Reverse_TCP {\n    meta:\n        description = \"Detects Meterpreter reverse TCP shellcode in memory\"\n        confidence  = \"high\"\n    strings:\n        \/\/ Common Meterpreter reverse_tcp x64 shellcode pattern\n        $rev_tcp_x64  = { 49 BE ?? ?? ?? ?? ?? ?? ?? ?? 41 FF E6 }\n        \/\/ Reverse HTTPS connect sequence\n        $rev_https    = { 68 ?? ?? ?? ?? 68 02 00 }\n        \/\/ LoadLibrary + GetProcAddress resolution pattern\n        $lib_resolve  = { 48 31 C9 48 81 EC D0 00 00 00 }\n    condition:\n        any of them\n}\n\nrule Meterpreter_Python_Stage {\n    meta:\n        description = \"Detects Python Meterpreter stage in memory\"\n        confidence  = \"medium\"\n    strings:\n        $py_met1   = \"met_api\" ascii\n        $py_met2   = \"meterpreter.core\" ascii\n        $py_met3   = \"MeterpreterSession\" ascii\n        $py_met4   = \"from metasploit\" ascii nocase\n    condition:\n        2 of them\n}\n<\/pre>\n<pre>## File: \/opt\/memory-hunter\/yara_rules\/rules\/credential_tools.yar\n## Detects credential access tools in process memory\n\nrule Mimikatz_In_Memory {\n    meta:\n        description = \"Detects Mimikatz and variants loaded in process memory\"\n        author      = \"justruss\"\n        date        = \"2026-05-24\"\n        confidence  = \"high\"\n    strings:\n        \/\/ Core Mimikatz module names\n        $sekurlsa    = \"sekurlsa::\" ascii wide nocase\n        $lsadump     = \"lsadump::\" ascii wide nocase\n        $kerberos    = \"kerberos::\" ascii wide nocase\n        $crypto      = \"crypto::\" ascii wide nocase\n        $dpapi       = \"dpapi::\" ascii wide nocase\n        \/\/ Mimikatz output strings\n        $mimikatz_id = \"mimikatz\" ascii wide nocase\n        $priv_debug  = \"privilege::debug\" ascii wide nocase\n        $logonpw     = \"logonPasswords\" ascii wide\n        \/\/ WDigest provider targeting\n        $wdigest     = \"wdigest.dll\" ascii wide\n        $lsasrv      = \"lsasrv.dll\" ascii wide\n        \/\/ Common obfuscated variants still contain these\n        $ntlm_hash   = \"NTLM hash\" ascii wide nocase\n        $aes256_key  = \"AES256 HMAC\" ascii wide\n    condition:\n        2 of ($sekurlsa, $lsadump, $kerberos, $crypto, $dpapi, $mimikatz_id, $priv_debug)\n        or ($wdigest and $lsasrv and 1 of ($sekurlsa, $lsadump, $logonpw))\n        or ($ntlm_hash and $aes256_key and $wdigest)\n}\n\nrule Rubeus_Kerberos_Toolkit {\n    meta:\n        description = \"Detects Rubeus .NET Kerberos attack toolkit in memory\"\n        confidence  = \"high\"\n    strings:\n        $rubeus_id   = \"Rubeus\" ascii wide\n        $asktgt      = \"asktgt\" ascii wide nocase\n        $kerberoast  = \"kerberoast\" ascii wide nocase\n        $asreproast  = \"asreproast\" ascii wide nocase\n        $s4u         = \" s4u \" ascii wide nocase\n        $ptt         = \"ptt\" ascii wide\n        $harvest     = \"harvest\" ascii wide nocase\n        $monitor     = \"monitor\" ascii wide nocase\n        $dump        = \"dump\" ascii wide\n        \/\/ .NET assembly marker\n        $dotnet      = { 4D 5A 90 00 03 00 00 00 04 00 00 00 FF FF 00 00 }\n    condition:\n        $rubeus_id and 3 of ($asktgt, $kerberoast, $asreproast, $s4u,\n                              $ptt, $harvest, $monitor, $dump)\n}\n\nrule SharpHound_BloodHound_Collector {\n    meta:\n        description = \"Detects SharpHound\/BloodHound AD enumeration tool in memory\"\n        confidence  = \"high\"\n    strings:\n        $sh1    = \"SharpHound\" ascii wide nocase\n        $sh2    = \"BloodHound\" ascii wide nocase\n        $sh3    = \"Invoke-BloodHound\" ascii wide nocase\n        $ldap1  = \"GetAllDomainTrusts\" ascii wide\n        $ldap2  = \"GetDomainControllers\" ascii wide\n        $ldap3  = \"GetDomainComputers\" ascii wide\n        $ldap4  = \"LdapSearcher\" ascii wide\n        $zip    = \"BloodHound.zip\" ascii wide\n        $json1  = \"computers.json\" ascii wide\n        $json2  = \"users.json\" ascii wide\n        $json3  = \"groups.json\" ascii wide\n    condition:\n        1 of ($sh1, $sh2, $sh3)\n        or (3 of ($ldap1, $ldap2, $ldap3, $ldap4, $zip, $json1, $json2, $json3))\n}\n\nrule NanoDump_LSASS_Dumper {\n    meta:\n        description = \"Detects NanoDump or similar LSASS dumping tools in memory\"\n        confidence  = \"high\"\n    strings:\n        $nano1  = \"nanodump\" ascii wide nocase\n        $nano2  = \"NanoDump\" ascii wide\n        \/\/ Direct syscall patterns used by NanoDump\n        $syscall_pattern = { 4C 8B D1 B8 ?? 00 00 00 0F 05 C3 }\n        \/\/ MiniDump callback function name\n        $minidump = \"MiniDumpWriteDump\" ascii wide\n        \/\/ LSASS targeting strings common to multiple dumpers\n        $lsass_name = \"lsass.exe\" ascii wide nocase\n        $lsass_pid  = \"lsass\" ascii wide nocase\n    condition:\n        $nano1 or $nano2\n        or ($syscall_pattern and $lsass_name)\n        or ($minidump and $lsass_pid and $syscall_pattern)\n}\n\nrule Seatbelt_Recon_Tool {\n    meta:\n        description = \"Detects Seatbelt post-exploitation recon tool in memory\"\n        confidence  = \"high\"\n    strings:\n        $sb1   = \"Seatbelt\" ascii wide\n        $sb2   = \"WindowsCredentialFiles\" ascii wide\n        $sb3   = \"DpapiMasterKeys\" ascii wide\n        $sb4   = \"RDPSavedConnections\" ascii wide\n        $sb5   = \"NetworkProfiles\" ascii wide\n        $sb6   = \"TokenPrivileges\" ascii wide\n        $sb7   = \"PowerShellHistory\" ascii wide\n    condition:\n        $sb1 or 4 of ($sb2, $sb3, $sb4, $sb5, $sb6, $sb7)\n}\n<\/pre>\n<pre>## File: \/opt\/memory-hunter\/yara_rules\/rules\/generic_suspicious.yar\n## Generic patterns for suspicious code patterns regardless of tool\n\nrule RWX_PE_In_Anonymous_Memory {\n    meta:\n        description = \"PE file in executable anonymous memory - possible reflective loading\"\n        author      = \"justruss\"\n        date        = \"2026-05-24\"\n        confidence  = \"medium\"\n        note        = \"May fire on legitimate .NET JIT or browser JIT - tune per environment\"\n    strings:\n        $mz_header   = { 4D 5A 90 00 }\n        $pe_sig      = { 50 45 00 00 }\n    condition:\n        $mz_header at 0 and $pe_sig\n}\n\nrule Shellcode_Common_x64_Preambles {\n    meta:\n        description = \"Common x64 shellcode entry patterns in executable memory\"\n        confidence  = \"medium\"\n    strings:\n        \/\/ Stack alignment + call setup common in x64 shellcode\n        $preamble1   = { FC 48 83 E4 F0 E8 }\n        \/\/ GetPC (get program counter) techniques\n        $getpc1      = { E8 00 00 00 00 59 }\n        $getpc2      = { E8 00 00 00 00 5B }\n        \/\/ PEB walking to find kernel32\n        $peb_walk    = { 64 48 8B 04 25 60 00 00 00 }\n        \/\/ NOP sled into shellcode\n        $nop_sled    = { 90 90 90 90 90 90 90 90 FC 48 }\n    condition:\n        any of them\n}\n\nrule Suspicious_Named_Pipe {\n    meta:\n        description = \"Named pipe patterns associated with common C2 frameworks\"\n        confidence  = \"high\"\n    strings:\n        \/\/ Cobalt Strike defaults\n        $cs_msse     = \"\\\\.\\pipe\\MSSE-\" ascii wide\n        $cs_postex   = \"\\\\.\\pipe\\postex_\" ascii wide\n        $cs_msagent  = \"\\\\.\\pipe\\msagent_\" ascii wide\n        $cs_status   = \"\\\\.\\pipe\\status_\" ascii wide\n        \/\/ Metasploit defaults\n        $msf_pipe    = \"\\\\.\\pipe\\metsrv\" ascii wide\n        \/\/ Empire defaults\n        $empire_pipe = \"\\\\.\\pipe\\empire\" ascii wide nocase\n        \/\/ Generic random-looking pipe names (4+ hex chars)\n        $hex_pipe    = \/\\\\\\.\\\\pipe\\\\[0-9a-f]{8,}\/ ascii wide\n    condition:\n        any of ($cs_msse, $cs_postex, $cs_msagent, $cs_status,\n                $msf_pipe, $empire_pipe)\n        or $hex_pipe\n}\n\nrule AMSI_Bypass_Patterns {\n    meta:\n        description = \"Detects common AMSI bypass technique byte patterns in memory\"\n        confidence  = \"high\"\n    strings:\n        \/\/ AmsiScanBuffer patch (ret instruction at function entry)\n        $amsi_patch1 = { B8 57 00 07 80 C3 }\n        \/\/ Common AmsiScanBuffer null return patch\n        $amsi_patch2 = { 31 C0 C3 }\n        \/\/ AmsiInitialize hook\n        $amsi_str    = \"amsi.dll\" ascii wide nocase\n        $amsi_func   = \"AmsiScanBuffer\" ascii wide\n        $amsi_init   = \"AmsiInitialize\" ascii wide\n    condition:\n        ($amsi_patch1 or $amsi_patch2) and ($amsi_str or $amsi_func)\n}\n\nrule ETW_Tamper_Patterns {\n    meta:\n        description = \"Detects ETW patching techniques in process memory\"\n        confidence  = \"high\"\n    strings:\n        \/\/ EtwEventWrite ret patch (most common ETW bypass)\n        $etw_patch   = { C2 14 00 }\n        \/\/ ntdll EtwEventWrite function beginning before patch\n        $etw_func    = \"EtwEventWrite\" ascii wide\n        \/\/ Common ETW provider disable strings\n        $etw_disable = \"EtwEventUnregister\" ascii wide\n    condition:\n        $etw_patch and ($etw_func or $etw_disable)\n}\n\nrule PowerShell_Encoded_Payload_Decoded {\n    meta:\n        description = \"Detects decoded PowerShell payloads in process memory\"\n        confidence  = \"medium\"\n    strings:\n        \/\/ Download cradle variants after decoding\n        $dl_string   = \"DownloadString\" ascii wide nocase\n        $dl_file     = \"DownloadFile\" ascii wide nocase\n        $dl_data     = \"DownloadData\" ascii wide nocase\n        $webclient   = \"Net.WebClient\" ascii wide nocase\n        $iex         = \"IEX\" ascii wide\n        $invoke_exp  = \"Invoke-Expression\" ascii wide nocase\n        \/\/ Reflection loading\n        $ref_load    = \"[Reflection.Assembly]::Load\" ascii wide\n        $ref_load2   = \"Assembly::LoadWithPartialName\" ascii wide\n        \/\/ Credential theft\n        $get_cred    = \"Get-Credential\" ascii wide\n        $sec_string  = \"ConvertTo-SecureString\" ascii wide\n        $marshal     = \"SecureStringToGlobalAllocUnicode\" ascii wide\n    condition:\n        2 of ($dl_string, $dl_file, $dl_data, $webclient, $iex, $invoke_exp,\n              $ref_load, $ref_load2)\n        or ($get_cred and $sec_string and $marshal)\n}\n\nrule Possible_Beacon_Sleep_Obfuscation {\n    meta:\n        description = \"Detects sleep obfuscation techniques used by beacons to evade memory scanners\"\n        confidence  = \"medium\"\n        note        = \"Beacons encrypt themselves during sleep to evade memory scanning\"\n    strings:\n        \/\/ Ekko sleep obfuscation ROP chain marker\n        $ekko1       = { 48 89 5C 24 08 48 89 74 24 10 57 48 83 EC 20 }\n        \/\/ Foliage sleep obfuscation pattern\n        $foliage     = { 48 8D 05 ?? ?? ?? ?? 48 89 44 24 ?? 48 8D 0D }\n        \/\/ Sleepmask XOR key schedule pattern\n        $sleepmask   = { 48 31 C0 48 FF C0 48 3D ?? 00 00 00 }\n    condition:\n        any of them\n}\n<\/pre>\n<h3>Compiling the combined Yara ruleset<\/h3>\n<p>After placing all four files in the rules directory, compile them into a single binary ruleset that Volatility&#8217;s vadyarascan plugin can load. The compiled format is faster to load than re-parsing individual text files on every scan.<\/p>\n<pre>## Compile all rules into combined.yar\nsource \/opt\/vol3-env\/bin\/activate\n\npython3 &lt; {OUTPUT}\")\nprint(f\"Ruleset size: {Path(OUTPUT).stat().st_size \/ 1024:.1f} KB\")\nEOF\n\n## Verify the compiled ruleset loads correctly\npython3 -c \"\nimport yara\nrules = yara.load('\/opt\/memory-hunter\/yara_rules\/combined.yar')\nprint('Ruleset loaded OK')\nprint(f'Rules available for memory scanning')\n\"\n\n## Test against a known-clean binary to check false positive rate\npython3 -c \"\nimport yara\nrules = yara.load('\/opt\/memory-hunter\/yara_rules\/combined.yar')\nimport os\ntest_files = ['\/bin\/ls', '\/bin\/cat', '\/usr\/bin\/python3']\nfor f in test_files:\n    if os.path.exists(f):\n        matches = rules.match(f)\n        if matches:\n            print(f'FP WARNING: {f} matched {[m.rule for m in matches]}')\n        else:\n            print(f'Clean: {f}')\n\"<\/pre>\n<h3>Adding your own Yara rules<\/h3>\n<p>The four files above are a starting point covering the most commonly encountered C2 frameworks and credential theft tools. As you encounter new malware families or build rules from your own analysis, add new <code>.yar<\/code> files to the rules directory and recompile. A few practical notes on writing rules that work well in a memory scanning context.<\/p>\n<p>Avoid filesize conditions entirely since Yara scanning process memory regions does not have a meaningful file size. Avoid conditions that depend on PE structure offsets like <code>pe.entry_point<\/code> unless you are certain the region you are scanning is a complete PE and not a fragment or raw shellcode. String conditions that use <code>fullword<\/code> are more reliable in memory than substring matches because memory contains a lot of incidental short strings. For strings that appear in both legitimate software and malware (like &#8220;cmd.exe&#8221; or &#8220;powershell&#8221;), always pair them with at least one other more unique indicator before the rule can match. A condition that requires three or more strings is almost always more reliable than one requiring a single string, even if the single string seems highly distinctive on the initial sample.<\/p>\n<p>The <code>clean_samples<\/code> directory at <code>\/opt\/memory-hunter\/yara_rules\/clean_samples\/<\/code> is intended for known-clean Windows executables that you run new rules against before deploying them. A useful set to maintain there: a clean copy of common system binaries (ntdll.dll, kernel32.dll, powershell.exe), clean copies of legitimate admin tools (PsExec, Process Explorer), and a small set of benign .NET assemblies. Running every new rule against these before adding it to the compiled ruleset catches false positives before they generate noise in production scans.<\/p>\n<h3>The complete repository structure<\/h3>\n<pre>## Clone-ready repository layout\n## All scripts from this post organised for immediate use\n\n\/opt\/memory-hunter\/\n\u251c\u2500\u2500 analyse.py                    # Main entry point - run this\n\u251c\u2500\u2500 setup_volatility_dual.sh      # One-shot environment setup\n\u251c\u2500\u2500 requirements.txt              # Python dependencies\n\u251c\u2500\u2500 README.md                     # Quick start guide\n\u2502\n\u251c\u2500\u2500 scripts\/\n\u2502   \u251c\u2500\u2500 phase1_identify.py        # OS detection and image metadata\n\u2502   \u251c\u2500\u2500 vol_runner.py             # Vol2\/Vol3 abstraction + parallel collection\n\u2502   \u251c\u2500\u2500 anomaly_checks.py         # Detection logic and Finding class\n\u2502   \u251c\u2500\u2500 phase4_iocs.py            # IOC extraction and Yara scanning\n\u2502   \u2514\u2500\u2500 phase5_report.py          # HTML and JSON report generation\n\u2502\n\u251c\u2500\u2500 templates\/\n\u2502   \u2514\u2500\u2500 report.html.j2            # Jinja2 HTML report template\n\u2502\n\u251c\u2500\u2500 yara_rules\/\n\u2502   \u251c\u2500\u2500 rules\/                    # Individual .yar files (add yours here)\n\u2502   \u2502   \u251c\u2500\u2500 cobalt_strike.yar\n\u2502   \u2502   \u251c\u2500\u2500 meterpreter.yar\n\u2502   \u2502   \u251c\u2500\u2500 credential_tools.yar\n\u2502   \u2502   \u2514\u2500\u2500 generic_suspicious.yar\n\u2502   \u251c\u2500\u2500 combined.yar              # Compiled ruleset (auto-generated)\n\u2502   \u2514\u2500\u2500 clean_samples\/            # Known-clean files for FP testing\n\u2502\n\u251c\u2500\u2500 reports\/                      # Analysis output (gitignored)\n\u2514\u2500\u2500 logs\/                         # Run logs (gitignored)\n\n## requirements.txt\nvolatility3\nyara-python\npefile\ncapstone\npython-magic\nrequests\njinja2\ntqdm\ntabulate\ncolorama\n\n## Quick start\ngit clone https:\/\/github.com\/yourrepo\/memory-hunter \/opt\/memory-hunter\ncd \/opt\/memory-hunter\nbash setup_volatility_dual.sh\nln -sf \/opt\/memory-hunter\/analyse.py \/usr\/local\/bin\/memory-hunt\n\n## Compile Yara rules\npython3 -c \"\nimport yara, glob\nrule_files = {f.split('\/')[-1].replace('.yar',''): f\n              for f in glob.glob('\/opt\/memory-hunter\/yara_rules\/rules\/*.yar')}\nyara.compile(filepaths=rule_files).save('\/opt\/memory-hunter\/yara_rules\/combined.yar')\nprint(f'Compiled {len(rule_files)} rule files')\n\"\n\n## Run your first analysis\nmemory-hunt \/path\/to\/memory.raw<\/pre>\n<p>The pipeline produces two output files per image: a structured JSON file suitable for SIEM ingestion, scripted comparison across multiple images, or feeding into a correlation pipeline, and an HTML report that opens in any browser with colour-coded severity, IOC tables, full process list, and network connections. The HTML report is designed to be shared with stakeholders who need to understand the findings without running any tools themselves.<\/p>\n<p>The most important design decision in the whole pipeline is the exit code contract. By mapping risk levels to exit codes (0 for clean, 3 for critical), the script integrates cleanly into any automation that knows how to act on a process exit code. Shell scripts, CI\/CD pipelines, the image watcher daemon, and orchestration frameworks all speak exit codes natively. A threat hunter who wants to process fifty images and immediately see which ones need attention can run a single parallel command and act on the results without reading any output until something non-zero comes back.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>A complete guide to automating single memory image analysis with Volatility 2 and 3. Covers when to use each version, parallel plugin collection, anomaly detection logic, IOC extraction, Yara scanning, and a full pipeline that produces an HTML report and structured JSON from a single command. Supports both Windows and Linux images.<\/p>\n","protected":false},"author":1,"featured_media":0,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[4,13],"tags":[],"class_list":["post-366","post","type-post","status-publish","format-standard","hentry","category-dfir","category-threat-hunting"],"_links":{"self":[{"href":"https:\/\/justruss.tech\/index.php\/wp-json\/wp\/v2\/posts\/366","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/justruss.tech\/index.php\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/justruss.tech\/index.php\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/justruss.tech\/index.php\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/justruss.tech\/index.php\/wp-json\/wp\/v2\/comments?post=366"}],"version-history":[{"count":5,"href":"https:\/\/justruss.tech\/index.php\/wp-json\/wp\/v2\/posts\/366\/revisions"}],"predecessor-version":[{"id":371,"href":"https:\/\/justruss.tech\/index.php\/wp-json\/wp\/v2\/posts\/366\/revisions\/371"}],"wp:attachment":[{"href":"https:\/\/justruss.tech\/index.php\/wp-json\/wp\/v2\/media?parent=366"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/justruss.tech\/index.php\/wp-json\/wp\/v2\/categories?post=366"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/justruss.tech\/index.php\/wp-json\/wp\/v2\/tags?post=366"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}