From 08a67bf35c503c1eee457501a61c02c2c5270c28 Mon Sep 17 00:00:00 2001 From: Thomas Date: Thu, 6 Aug 2026 00:44:52 +0000 Subject: [PATCH 1/3] chore: tidy repository root and correct README badges Presentation-only cleanup. No source, test, or dependency changes. Untrack build-phase prompt/plan files edr-agent-implementation-plan.md and phase2/6/7/8 were tracked in the repository root -- roughly 100KB of the planning prompts used during development. phase9-traffic-context-prompt.md was already gitignored, so this just finishes what that started. The files stay on disk; only the .gitignore entries change. Nothing in the repo linked to them. Move patent paperwork into patent/ PROVISIONAL_PATENT_DRAFT.md/.pdf, USPTO_Specification.md and USPTO_Drawings.html were interleaved with build config in the root (~1MB, the PDF alone is 936KB). Grepped first -- nothing references them by path, so the move is safe. Root goes from 21 tracked files to 12, all of which belong there. Correct two stale README badges - tests: 546 -> 1333 (actual count as of this commit; the badge was undercounting the suite by 60%) - python: 3.13 -> 3.11+ (pyproject declares requires-python >=3.11 and CI runs the matrix on 3.11, 3.12 and 3.13) --- .gitignore | 6 +- README.md | 4 +- edr-agent-implementation-plan.md | 565 ----------------- .../PROVISIONAL_PATENT_DRAFT.md | 0 .../PROVISIONAL_PATENT_DRAFT.pdf | Bin .../USPTO_Drawings.html | 0 .../USPTO_Specification.md | 0 phase2-continuation-prompt.md | 384 ----------- phase6-live-testing-prompt.md | 600 ------------------ phase7-macos-production-hardening.md | 410 ------------ phase8-system-tray-icon-dashboard.md | 528 --------------- 11 files changed, 7 insertions(+), 2490 deletions(-) delete mode 100644 edr-agent-implementation-plan.md rename PROVISIONAL_PATENT_DRAFT.md => patent/PROVISIONAL_PATENT_DRAFT.md (100%) rename PROVISIONAL_PATENT_DRAFT.pdf => patent/PROVISIONAL_PATENT_DRAFT.pdf (100%) rename USPTO_Drawings.html => patent/USPTO_Drawings.html (100%) rename USPTO_Specification.md => patent/USPTO_Specification.md (100%) delete mode 100644 phase2-continuation-prompt.md delete mode 100644 phase6-live-testing-prompt.md delete mode 100644 phase7-macos-production-hardening.md delete mode 100644 phase8-system-tray-icon-dashboard.md diff --git a/.gitignore b/.gitignore index 2ddfe50..c8115a8 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,10 @@ neo4j_data/ config.local.yaml .claude/ test-results/ -phase9-traffic-context-prompt.md +# Build-phase prompt/plan scratch files (kept locally, not published) +edr-agent-implementation-plan.md +phase*-prompt.md +phase7-macos-production-hardening.md +phase8-system-tray-icon-dashboard.md scripts/test_lateral_movement.sh site/ diff --git a/README.md b/README.md index 6b2615d..416081e 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # EDR Graph Agent -![Python 3.13](https://img.shields.io/badge/python-3.13-blue.svg) +![Python 3.11+](https://img.shields.io/badge/python-3.11%2B-blue.svg) ![License](https://img.shields.io/badge/License-AGPLv3-blue.svg) -![Tests](https://img.shields.io/badge/tests-546%20passed-brightgreen.svg) +![Tests](https://img.shields.io/badge/tests-1333%20passed-brightgreen.svg) ![Patent Pending](https://img.shields.io/badge/Patent-Pending-red.svg) > **Disclaimer:** This software is provided for **educational and research purposes only**. It is not a certified or commercially supported security product. Use at your own risk. The authors assume no liability for any damage, data loss, or legal consequences resulting from the use or misuse of this software. By using this software, you agree that you are solely responsible for ensuring compliance with applicable laws and regulations in your jurisdiction. Always obtain proper authorization before deploying monitoring or response tools on any system. diff --git a/edr-agent-implementation-plan.md b/edr-agent-implementation-plan.md deleted file mode 100644 index 5ae113a..0000000 --- a/edr-agent-implementation-plan.md +++ /dev/null @@ -1,565 +0,0 @@ -# Project Vigilance: EDR Agent Implementation Plan - -## System Prompt / Project Context - -You are implementing the evolution of `edr-graph`, a Python-based Host Intrusion Detection System (HIDS) with LLM-powered investigation, into a production-grade Endpoint Detection & Response (EDR) agent. The existing system uses `psutil` for process enumeration, SQLite for event queuing, a graph data model (User → Process → IP), and DeepInfra LLM calls for threat analysis. - -This is a phased implementation. Complete each phase fully before moving to the next. After each phase, run all existing tests and confirm nothing regresses before proceeding. - ---- - -## Phase 0: Instrumentation & Baseline Metrics - -**Goal:** Before changing anything, instrument the current agent so we can measure improvements. - -### Tasks - -1. **Add structured logging throughout the existing codebase.** - - Use Python's `logging` module with `structlog` for JSON-formatted output. - - Every event processed should log: `event_type`, `timestamp`, `processing_latency_ms`, `source` (psutil/etw/auditd). - - Log LLM call latency, token usage, and verdict separately. - -2. **Create a metrics collection module (`agent/metrics.py`).** - - Track and expose: - - `events_processed_total` (counter) - - `events_dropped_total` (counter, for when the queue overflows) - - `event_processing_latency_seconds` (histogram) - - `llm_call_latency_seconds` (histogram) - - `llm_verdicts` (counter by severity: INFO, LOW, MEDIUM, HIGH, CRITICAL) - - `false_positive_rate` (tracked via manual feedback flag in SQLite) - - `agent_uptime_seconds` (gauge) - - Use `prometheus_client` library to expose a `/metrics` endpoint on a local port (default 9100) for scraping. - -3. **Add a health check endpoint (`/health`)** on the same local port. - - Returns JSON: `{"status": "healthy", "uptime": ..., "events_last_minute": ..., "queue_depth": ...}` - -4. **Baseline test:** Run the instrumented agent for 10 minutes on a test host. Record average event processing latency and events/second throughput. Store these numbers in `docs/baseline_metrics.md`. - ---- - -## Phase 1: Real-Time Kernel Event Subscriptions - -**Goal:** Replace `psutil.process_iter()` polling with kernel-pushed event streams. This is the single most important upgrade. - -### 1A: Windows — ETW (Event Tracing for Windows) - -**Create `agent/collectors/etw_collector.py`.** - -- Use the `pywintrace` library as primary. If it proves unreliable, fall back to calling Win32 ETW APIs via `ctypes`. -- Subscribe to the following ETW providers: - -| Provider | GUID | Events | -|----------|------|--------| -| `Microsoft-Windows-Kernel-Process` | `{22FB2CD6-0E7B-422B-A0C7-2FAD1FD0E716}` | Process start/stop | -| `Microsoft-Windows-Kernel-Network` | `{7DD42A49-5329-4832-8DFD-43D979153A88}` | TCP/UDP connections | -| `Microsoft-Windows-DNS-Client` | `{1C95126E-7EEA-49A9-A3FE-A378B03DDB4D}` | DNS resolution | -| `Microsoft-Windows-Kernel-File` | `{EDD08927-9CC4-4E65-B970-C2560FB5C289}` | File I/O | -| `Microsoft-Windows-Kernel-Registry` | `{70EB4F03-C1DE-4F73-A051-33D13D5413BD}` | Registry modifications | - -- Each ETW event must be normalized into a standard `AgentEvent` dataclass: - -```python -@dataclass -class AgentEvent: - event_id: str # UUID - timestamp: datetime # UTC - event_type: str # "process_start", "process_stop", "network_connect", "dns_resolve", "file_modify", "registry_modify" - source: str # "etw", "ebpf", "auditd", "psutil" - pid: int - ppid: Optional[int] - image_name: Optional[str] - command_line: Optional[str] - user: Optional[str] - # Network fields - src_ip: Optional[str] - src_port: Optional[int] - dst_ip: Optional[str] - dst_port: Optional[int] - protocol: Optional[str] - # DNS fields - query_name: Optional[str] - resolved_ips: Optional[List[str]] - # File fields - file_path: Optional[str] - file_operation: Optional[str] # "create", "modify", "delete", "rename" - # Registry fields - registry_key: Optional[str] - registry_value: Optional[str] - registry_operation: Optional[str] # "create", "modify", "delete" - # Raw data for forensics - raw: Optional[dict] = None -``` - -- The ETW consumer MUST run on its own dedicated thread. Events are pushed into an `asyncio.Queue` or `queue.Queue` (thread-safe) that feeds the existing processing pipeline. -- Implement a **ring buffer** (fixed-size deque or `collections.deque(maxlen=N)`) between the ETW consumer and the graph processor. If the processor falls behind, old events are dropped and `events_dropped_total` metric is incremented. Default buffer size: 10,000 events. -- Gracefully handle ETW session teardown on agent shutdown (call `StopTrace`). - -### 1B: Linux — Auditd via Netlink (with eBPF upgrade path) - -**Create `agent/collectors/auditd_collector.py`.** - -- Use `audit` library or raw Netlink socket to consume auditd events. -- Configure audit rules on startup for: - - `execve` syscalls (process execution) - - `connect` syscalls (network connections) - - File watches on critical paths (`/etc/`, `/tmp/`, `/var/www/`) -- Normalize all events into the same `AgentEvent` dataclass. -- Include a `TODO` block and interface stub for future eBPF collector (`agent/collectors/ebpf_collector.py`) that implements the same `Collector` protocol. - -### 1C: Collector Protocol & Platform Abstraction - -**Create `agent/collectors/base.py`.** - -```python -from typing import Protocol, AsyncIterator - -class Collector(Protocol): - async def start(self) -> None: ... - async def stop(self) -> None: ... - async def events(self) -> AsyncIterator[AgentEvent]: ... - def platform(self) -> str: ... # "windows", "linux" -``` - -**Create `agent/collectors/__init__.py`** with a factory: - -```python -def get_collector() -> Collector: - if sys.platform == "win32": - return ETWCollector() - elif sys.platform == "linux": - return AuditdCollector() - else: - raise UnsupportedPlatformError(f"No collector for {sys.platform}") -``` - -### 1D: Retain psutil as Fallback - -- Do NOT delete the existing psutil polling code. Wrap it as `PsutilCollector` implementing the same `Collector` protocol. -- If ETW/Auditd fails to initialize (missing permissions, unsupported OS version), fall back to psutil with a WARNING log. -- Add a config flag: `collector_mode: "auto" | "etw" | "auditd" | "psutil"` - -### 1E: Testing - -- Write unit tests that mock ETW events and verify they produce correct `AgentEvent` objects. -- Write an integration test that starts the ETW collector, spawns `calc.exe` (or `notepad.exe`), and asserts a `process_start` event is received within 1 second. -- Measure and log: events/second throughput and average latency from kernel event to `AgentEvent` creation. Compare against Phase 0 baseline. - ---- - -## Phase 2: Expanded Graph Schema - -**Goal:** Add Domain, File, and RegistryKey node types to the graph. This dramatically improves the LLM's ability to reason about attack chains. - -### 2A: New Node Types - -Extend the graph schema (whatever graph representation you're using — NetworkX, Neo4j, or custom) with these nodes and edges: - -``` -Existing: - (:User {username, sid, domain}) - (:Process {pid, ppid, name, command_line, start_time, end_time}) - (:IP {address, port, protocol, geo_country, geo_city}) - - (:User)-[:LAUNCHED]->(:Process) - (:Process)-[:SPAWNED]->(:Process) - (:Process)-[:CONNECTED_TO]->(:IP) - -New: - (:Domain {name, first_seen, last_seen, is_dga_candidate: bool}) - (:File {path, hash_sha256, size, last_modified}) - (:RegistryKey {path, value_name, value_data, previous_data}) - - (:Process)-[:RESOLVED]->(:Domain) - (:Domain)-[:RESOLVES_TO]->(:IP) - (:Process)-[:MODIFIED]->(:File) - (:Process)-[:READ]->(:File) - (:Process)-[:CREATED]->(:File) - (:Process)-[:DELETED]->(:File) - (:Process)-[:MODIFIED]->(:RegistryKey) - (:Process)-[:CREATED]->(:RegistryKey) - (:Process)-[:DELETED]->(:RegistryKey) -``` - -### 2B: DGA Detection Heuristic - -**Create `agent/analysis/dga_detector.py`.** - -- Implement a lightweight DGA (Domain Generation Algorithm) detector that scores domain names. -- Use entropy calculation + consonant-to-vowel ratio + domain length. -- If `dga_score > threshold`, set `is_dga_candidate = True` on the Domain node. -- This runs synchronously — no LLM call needed. It's a pre-filter that flags suspicious domains for the LLM to investigate. - -### 2C: Registry Persistence Detection - -**Create `agent/analysis/persistence_detector.py`.** - -- Monitor specific registry paths that are commonly abused for persistence: - - `HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Run` - - `HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce` - - `HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Run` - - `HKLM\SYSTEM\CurrentControlSet\Services` - - `HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon\Shell` - - `HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon\Userinit` - - `HKLM\SOFTWARE\Classes\*\shellex\ContextMenuHandlers` - - WMI event subscriptions: `HKLM\SOFTWARE\Microsoft\WBEM\ESS` - - Scheduled tasks: `HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Schedule\TaskCache` -- Any write to these paths automatically escalates to HIGH severity before LLM analysis. - -### 2D: Graph Query Helpers - -**Create `agent/graph/queries.py`.** - -Implement reusable graph traversal functions: - -- `get_process_chain(pid) -> List[Process]` — Walk the SPAWNED edges to build the full parent chain. -- `get_process_network_footprint(pid) -> Dict` — All IPs and Domains a process has touched. -- `get_domain_resolution_history(domain) -> List[IP]` — All IPs a domain has resolved to. -- `get_file_modifiers(file_path) -> List[Process]` — All processes that touched a file. -- `get_persistence_artifacts(pid) -> List[RegistryKey]` — All registry persistence created by a process tree. -- `build_attack_chain(pid) -> Dict` — Comprehensive context object combining all of the above, formatted for LLM consumption. - -The `build_attack_chain()` output is what gets sent to the LLM. It should produce a structured dict that can be serialized to a concise but complete context string. - ---- - -## Phase 3: Response Engine - -**Goal:** Add the ability to take automated response actions. This is the "R" in EDR. - -### 3A: Response Action Framework - -**Create `agent/response/actions.py`.** - -Define a response action enum and execution framework: - -```python -class ResponseAction(Enum): - LOG_ONLY = "log_only" - ALERT = "alert" - SUSPEND_PROCESS = "suspend_process" - TERMINATE_PROCESS = "terminate_process" - ISOLATE_NETWORK = "isolate_network" - QUARANTINE_FILE = "quarantine_file" - -class ResponsePolicy: - """Maps LLM severity verdicts to response actions.""" - - SEVERITY_MAP = { - "INFO": [ResponseAction.LOG_ONLY], - "LOW": [ResponseAction.LOG_ONLY], - "MEDIUM": [ResponseAction.ALERT], - "HIGH": [ResponseAction.ALERT, ResponseAction.ISOLATE_NETWORK], - "CRITICAL": [ResponseAction.ALERT, ResponseAction.SUSPEND_PROCESS, ResponseAction.ISOLATE_NETWORK], - } -``` - -**CRITICAL: Implement a Do-Not-Kill list.** - -```python -PROTECTED_PROCESSES = { - # Windows critical processes — terminating these causes BSOD or system instability - "csrss.exe", "smss.exe", "wininit.exe", "winlogon.exe", "lsass.exe", - "services.exe", "svchost.exe", "dwm.exe", "explorer.exe", - "System", "Registry", "Memory Compression", - # Linux critical processes - "systemd", "init", "kthreadd", "ksoftirqd", "kworker", - # The agent itself - "edr-graph", "edr-watchdog", -} -``` - -### 3B: Process Suspension (Preferred over Termination) - -**Create `agent/response/process_control.py`.** - -- **Windows:** Use `NtSuspendProcess` via ctypes to freeze a process. This preserves forensic state (memory, handles, network connections) and is reversible if the verdict is a false positive. -- **Linux:** Send `SIGSTOP` to the process. -- Only escalate to `TerminateProcess` / `SIGKILL` if: - 1. LLM severity is CRITICAL, AND - 2. The process is NOT in the protected list, AND - 3. A configurable `auto_terminate` flag is True (default: False — requires manual confirmation). - -### 3C: Network Isolation - -**Create `agent/response/network_control.py`.** - -- **Windows:** Use `netsh advfirewall` to add block rules for a specific process/PID. - - Command: `netsh advfirewall firewall add rule name="EDR-BLOCK-{pid}" dir=out action=block program="{exe_path}"` - - Also add inbound rule. - - Track all rules added so they can be reverted: store rule names in SQLite. -- **Linux:** Use `iptables` with cgroup or owner matching. -- Implement `isolate(pid)` and `restore(pid)` methods. -- Network isolation is the preferred first response for HIGH severity — it stops data exfiltration while preserving the process for investigation. - -### 3D: File Quarantine - -**Create `agent/response/file_quarantine.py`.** - -- Move suspicious files to a quarantine directory (`/var/edr-quarantine/` or `C:\ProgramData\edr-graph\quarantine\`). -- Rename with `.quarantined` extension and strip execute permissions. -- Log original path, SHA256 hash, and quarantine timestamp in SQLite. -- Implement `quarantine(file_path)` and `restore(file_path)` methods. - -### 3E: Human-in-the-Loop Confirmation - -**Create `agent/response/approval.py`.** - -- For any destructive action (terminate, quarantine), queue an approval request. -- Approval can come via: - 1. Local CLI prompt (for single-host mode). - 2. Webhook to a central management server (for future multi-host deployment). - 3. Auto-approve if the policy flag `auto_respond` is set and severity is CRITICAL. -- Log all approvals and denials with timestamps and the approver identity. - -### 3F: Response Audit Trail - -- Every response action (including LOG_ONLY) must be recorded in SQLite with: - - `response_id`, `event_id`, `timestamp`, `action_taken`, `target_pid`, `target_path`, `llm_severity`, `llm_confidence`, `approved_by`, `reverted`, `revert_timestamp` -- This is your forensic chain of custody. It must be tamper-evident — append-only, no updates or deletes. - ---- - -## Phase 4: Self-Protection & Persistence - -**Goal:** Make the agent resilient to being killed by users or malware. - -### 4A: Windows Service - -**Create `agent/platform/windows_service.py`.** - -- Use `pywin32` to implement a Windows Service. -- Service name: `EDRGraphAgent` -- Runs as `SYSTEM`. -- Startup type: Automatic. -- Recovery options: Restart on first, second, and subsequent failures (1 second delay). -- Implement proper `SvcDoRun`, `SvcStop` handlers. - -### 4B: Linux systemd Daemon - -**Create deployment files:** - -- `deploy/edr-graph.service` — systemd unit file. - - `Restart=always`, `RestartSec=1` - - `WatchdogSec=30` (systemd will kill and restart if the agent doesn't send heartbeats) - - Run as a dedicated `edr-graph` user with appropriate capabilities (`CAP_NET_ADMIN`, `CAP_SYS_PTRACE`, `CAP_AUDIT_CONTROL`). - -### 4C: Watchdog Process - -**Create `agent/watchdog.py`.** - -- A separate lightweight process that: - 1. Monitors the main agent process via PID and heartbeat file. - 2. Restarts the agent if it dies or stops heartbeating. - 3. The main agent also monitors the watchdog. - 4. Mutual monitoring: if either dies, the other restarts it. -- The watchdog should be as minimal as possible — no imports beyond stdlib. It should be hard to crash. -- Communication via a shared heartbeat file or local socket (not shared memory — that's fragile). - -### 4D: Tamper Detection - -**Create `agent/platform/tamper_detection.py`.** - -- On startup, compute SHA256 of all agent binary/script files. -- Periodically (every 60 seconds) re-verify these hashes. -- If any agent files have been modified, log a CRITICAL alert and notify the central server (when available). -- Monitor the Windows Service registry key for unauthorized modifications. - ---- - -## Phase 5: Configuration & Deployment - -**Goal:** Make the agent configurable without code changes and deployable to new hosts. - -### 5A: Configuration File - -**Create `agent/config.py` and `config.yaml`.** - -```yaml -agent: - name: "edr-graph-agent" - version: "2.0.0" - log_level: "INFO" - log_format: "json" # "json" or "text" - -collector: - mode: "auto" # "auto", "etw", "auditd", "psutil" - buffer_size: 10000 - etw: - providers: - - "Microsoft-Windows-Kernel-Process" - - "Microsoft-Windows-Kernel-Network" - - "Microsoft-Windows-DNS-Client" - - "Microsoft-Windows-Kernel-File" - - "Microsoft-Windows-Kernel-Registry" - auditd: - watched_paths: - - "/etc/" - - "/tmp/" - - "/var/www/" - - "/home/" - -analysis: - llm: - provider: "deepinfra" - model: "meta-llama/Meta-Llama-3.1-70B-Instruct" - api_key_env: "DEEPINFRA_API_KEY" # Read from environment variable - max_tokens: 2048 - temperature: 0.1 - timeout_seconds: 30 - max_concurrent_calls: 3 - rate_limit_per_minute: 30 - dga: - entropy_threshold: 3.5 - min_domain_length: 12 - -response: - auto_respond: false # If true, CRITICAL severity auto-executes response - auto_terminate: false # If true, allows process termination without human approval - quarantine_dir_windows: "C:\\ProgramData\\edr-graph\\quarantine" - quarantine_dir_linux: "/var/edr-graph/quarantine" - protected_processes: - - "csrss.exe" - - "smss.exe" - - "wininit.exe" - - "winlogon.exe" - - "lsass.exe" - - "services.exe" - - "svchost.exe" - - "dwm.exe" - - "explorer.exe" - - "System" - - "systemd" - - "init" - -persistence: - watchdog_enabled: true - heartbeat_interval_seconds: 10 - tamper_check_interval_seconds: 60 - -metrics: - enabled: true - port: 9100 -``` - -- Use `pydantic` for config validation with sensible defaults. -- Config is loaded from (in priority order): CLI args → environment variables → config file → defaults. - -### 5B: Installation Script - -**Create `deploy/install.sh` (Linux) and `deploy/install.ps1` (Windows).** - -- Install Python dependencies from `requirements.txt`. -- Create the service user (Linux) or verify SYSTEM permissions (Windows). -- Install the service/daemon. -- Write initial config. -- Start the agent and verify it's running. - ---- - -## Cross-Cutting Concerns (Apply Throughout All Phases) - -### Error Handling - -- **Never crash the agent on a single bad event.** Wrap all event processing in try/except. Log the error, increment `events_dropped_total`, and continue. -- LLM API failures should fall back to rule-based severity (e.g., if a process writes to a Run key, that's HIGH even without LLM confirmation). -- Network timeouts to the LLM should not block event processing. Use asyncio with timeouts. - -### Security of the Agent Itself - -- The LLM API key must NEVER be stored in the config file. Read from environment variable only. -- All local IPC (metrics endpoint, health check) should bind to `127.0.0.1` only. -- The SQLite database should have restrictive file permissions (owner-only read/write). -- Agent logs should not contain raw command lines in production mode — hash or truncate sensitive arguments. - -### Testing Strategy - -- **Unit tests:** Mock all OS APIs (ETW, auditd, process control). Test event normalization, graph construction, DGA detection, response policy mapping. -- **Integration tests:** Spin up the agent on a test VM, generate known-malicious patterns (e.g., `powershell -encodedCommand ...`, writing to Run keys), verify detection and response. -- **Regression tests:** After each phase, re-run all previous phase tests. - -### Project Structure - -``` -edr-graph/ -├── agent/ -│ ├── __init__.py -│ ├── main.py # Entry point -│ ├── config.py # Pydantic config model -│ ├── metrics.py # Prometheus metrics -│ ├── models.py # AgentEvent dataclass + graph node models -│ ├── collectors/ -│ │ ├── __init__.py # Collector factory -│ │ ├── base.py # Collector protocol -│ │ ├── etw_collector.py # Windows ETW -│ │ ├── auditd_collector.py # Linux Auditd -│ │ ├── ebpf_collector.py # Future: eBPF -│ │ └── psutil_collector.py # Fallback -│ ├── graph/ -│ │ ├── __init__.py -│ │ ├── schema.py # Node/Edge type definitions -│ │ ├── processor.py # Event → Graph updates -│ │ └── queries.py # Graph traversal helpers -│ ├── analysis/ -│ │ ├── __init__.py -│ │ ├── llm_analyzer.py # DeepInfra LLM integration -│ │ ├── dga_detector.py # Domain generation algorithm detection -│ │ ├── persistence_detector.py # Registry persistence rules -│ │ └── rule_engine.py # Fallback rule-based detection -│ ├── response/ -│ │ ├── __init__.py -│ │ ├── actions.py # ResponseAction enum + policy -│ │ ├── process_control.py # Suspend/terminate -│ │ ├── network_control.py # Firewall rules -│ │ ├── file_quarantine.py # File isolation -│ │ └── approval.py # Human-in-the-loop -│ ├── platform/ -│ │ ├── __init__.py -│ │ ├── windows_service.py # pywin32 service wrapper -│ │ └── tamper_detection.py # File integrity monitoring of agent itself -│ └── watchdog.py # Mutual watchdog process -├── deploy/ -│ ├── edr-graph.service # systemd unit file -│ ├── install.sh # Linux installer -│ └── install.ps1 # Windows installer -├── config.yaml # Default configuration -├── requirements.txt -├── tests/ -│ ├── unit/ -│ ├── integration/ -│ └── conftest.py -└── docs/ - ├── baseline_metrics.md - ├── architecture.md - └── response_playbook.md -``` - ---- - -## Implementation Order - -Execute phases in this exact order. Do not skip ahead. - -1. **Phase 0** — Instrumentation (1-2 days) -2. **Phase 1C** — Collector protocol and platform abstraction (half day) -3. **Phase 1D** — Wrap existing psutil as PsutilCollector (half day) -4. **Phase 1A** — ETW collector (2-3 days, this is the hardest part) -5. **Phase 1B** — Auditd collector (1-2 days) -6. **Phase 1E** — Testing and latency comparison (1 day) -7. **Phase 2A** — Graph schema expansion (1 day) -8. **Phase 2B** — DGA detector (half day) -9. **Phase 2C** — Registry persistence detector (half day) -10. **Phase 2D** — Graph query helpers + `build_attack_chain()` (1 day) -11. **Phase 3A-3D** — Response engine (2-3 days) -12. **Phase 3E-3F** — Approval workflow and audit trail (1 day) -13. **Phase 4** — Self-protection and persistence (2 days) -14. **Phase 5** — Configuration and deployment (1 day) - ---- - -## Key Principles - -- **Never crash.** The agent must survive any single bad event, failed API call, or unexpected input. -- **Prefer suspension over termination.** Forensic data is more valuable than a quick kill. -- **The LLM is an advisor, not an executor.** All destructive actions require policy + (optionally) human approval. -- **Measure everything.** If you can't measure whether a change improved detection, you can't justify it. -- **Degrade gracefully.** ETW fails? Fall back to psutil. LLM down? Fall back to rules. Network isolated? Queue events locally. diff --git a/PROVISIONAL_PATENT_DRAFT.md b/patent/PROVISIONAL_PATENT_DRAFT.md similarity index 100% rename from PROVISIONAL_PATENT_DRAFT.md rename to patent/PROVISIONAL_PATENT_DRAFT.md diff --git a/PROVISIONAL_PATENT_DRAFT.pdf b/patent/PROVISIONAL_PATENT_DRAFT.pdf similarity index 100% rename from PROVISIONAL_PATENT_DRAFT.pdf rename to patent/PROVISIONAL_PATENT_DRAFT.pdf diff --git a/USPTO_Drawings.html b/patent/USPTO_Drawings.html similarity index 100% rename from USPTO_Drawings.html rename to patent/USPTO_Drawings.html diff --git a/USPTO_Specification.md b/patent/USPTO_Specification.md similarity index 100% rename from USPTO_Specification.md rename to patent/USPTO_Specification.md diff --git a/phase2-continuation-prompt.md b/phase2-continuation-prompt.md deleted file mode 100644 index 9790c32..0000000 --- a/phase2-continuation-prompt.md +++ /dev/null @@ -1,384 +0,0 @@ -# Phase 2: Graph Schema Expansion & Detection Heuristics - -## Context - -Phases 0 and 1 are complete. The agent now has: -- Structured logging (structlog, JSON/text formats) -- Prometheus metrics on port 9100 with health endpoint -- Pipeline instrumentation (processing latency, event counters, LLM latency/verdicts) -- Platform collectors: ETW (Windows), Auditd (Linux), enhanced unified log (macOS), psutil fallback -- 70 passing tests across 8 commits - -Phase 2 expands the graph data model with new node types and adds lightweight detection heuristics that run before LLM analysis. This improves the LLM's reasoning by giving it richer attack chain context and reduces unnecessary LLM calls by pre-filtering with cheap heuristics. - -**Implementation order matters. Follow the commit sequence below exactly.** - ---- - -## Commit 1: Graph Schema Expansion (2A) - -### New Node Types - -Add these node types to the graph schema alongside the existing User, Process, and IP nodes: - -``` -(:Domain { - name: str, # "evil.example.com" - first_seen: datetime, - last_seen: datetime, - is_dga_candidate: bool, # Set by DGA detector in a later commit - tld: str, # "com" -}) - -(:File { - path: str, # Full normalized path - hash_sha256: Optional[str], # Computed on first observation if file exists - size: Optional[int], - first_seen: datetime, - last_seen: datetime, -}) - -(:RegistryKey { - path: str, # Full registry path - value_name: Optional[str], - value_data: Optional[str], - previous_data: Optional[str], # Captured on modification events - first_seen: datetime, - last_seen: datetime, -}) -``` - -### New Edge Types - -``` -(:Process)-[:RESOLVED]->(:Domain) # DNS query -(:Domain)-[:RESOLVES_TO]->(:IP) # DNS response mapping -(:Process)-[:CREATED]->(:File) # File creation -(:Process)-[:MODIFIED]->(:File) # File write/modify -(:Process)-[:READ]->(:File) # File read (optional, high volume — gate behind config flag) -(:Process)-[:DELETED]->(:File) # File deletion -(:Process)-[:CREATED]->(:RegistryKey) # Registry key/value creation (Windows only) -(:Process)-[:MODIFIED]->(:RegistryKey) # Registry value change (Windows only) -(:Process)-[:DELETED]->(:RegistryKey) # Registry key/value deletion (Windows only) -``` - -### Implementation Details - -- Update `agent/graph/schema.py` (or wherever node/edge types are defined) with the new types. -- Update `agent/graph/processor.py` to handle incoming `AgentEvent` objects with `event_type` of `dns_resolve`, `file_create`, `file_modify`, `file_delete`, `registry_create`, `registry_modify`, `registry_delete` and create the appropriate nodes and edges. -- For DNS events: create both the Domain node and the Domain→IP edge if `resolved_ips` is populated on the event. -- For File events: attempt SHA256 hash computation only if the file still exists at processing time. Don't block on it — if the file is gone (deleted/moved), store `hash_sha256 = None`. -- For RegistryKey nodes: capture `previous_data` by reading the current value before processing a modify event (Windows only, via `winreg`). If the read fails, set `previous_data = None`. -- File READ edges are high-volume. Gate them behind a config flag `collector.file_read_tracking: false` (default off). Everything else is always on. - -### Config Addition - -Add to `config.yaml` under `collector`: - -```yaml -collector: - file_read_tracking: false # Enable (:Process)-[:READ]->(:File) edges. High volume. -``` - -### Tests - -- Test that a `dns_resolve` AgentEvent creates Domain node, IP node (if new), and correct edges. -- Test that a `file_modify` AgentEvent creates File node with hash when file exists, and without hash when file doesn't exist. -- Test that a `registry_modify` AgentEvent creates RegistryKey node with `previous_data` populated. -- Test that duplicate Domain/File/RegistryKey nodes are deduplicated (upserted, not duplicated). -- Test that `file_read_tracking: false` suppresses READ edge creation. - ---- - -## Commit 2: Graph Query Helpers (2D) - -### Create `agent/graph/queries.py` - -Implement these reusable graph traversal functions. Each should work against whatever graph backend is in use (NetworkX, dict-based, etc.): - -```python -def get_process_chain(graph, pid: int) -> List[dict]: - """Walk SPAWNED edges upward to build the full parent process chain. - Returns list from root ancestor down to the given PID. - Example: [systemd, bash, python, malware.py]""" - -def get_process_network_footprint(graph, pid: int) -> dict: - """All network activity for a process. - Returns: { - "domains": [{"name": ..., "first_seen": ..., "is_dga_candidate": ...}], - "ips": [{"address": ..., "port": ..., "protocol": ...}], - "dns_chains": [{"domain": ..., "resolved_to": [...]}] - }""" - -def get_domain_resolution_history(graph, domain_name: str) -> List[dict]: - """All IPs a domain has resolved to over time. - Returns: [{"ip": ..., "first_seen": ..., "last_seen": ...}]""" - -def get_file_activity(graph, file_path: str) -> List[dict]: - """All processes that touched a file and how. - Returns: [{"pid": ..., "process_name": ..., "operation": "CREATED"|"MODIFIED"|"DELETED", "timestamp": ...}]""" - -def get_persistence_artifacts(graph, pid: int) -> List[dict]: - """All registry persistence created by a process or its child tree. - Walks the process tree downward and collects all RegistryKey nodes. - Returns: [{"registry_path": ..., "value_name": ..., "value_data": ..., "created_by_pid": ...}]""" - -def build_attack_chain(graph, pid: int) -> dict: - """Comprehensive context object for LLM consumption. - Combines all of the above into a single structured dict: - { - "target_process": {"pid": ..., "name": ..., "command_line": ..., "user": ...}, - "process_chain": [...], # from get_process_chain - "network_footprint": {...}, # from get_process_network_footprint - "file_activity": [...], # files touched by this process - "persistence_artifacts": [...], # from get_persistence_artifacts - "risk_indicators": [...] # populated by detectors in later commits - } - """ -``` - -### LLM Context Integration - -Update `agent/analysis/llm_analyzer.py` to call `build_attack_chain(pid)` instead of whatever minimal context it currently sends. The attack chain dict should be serialized to a concise string for the LLM prompt. Keep it under 2000 tokens — summarize if the chain is too large (truncate file activity to top 10 most recent, etc.). - -### Tests - -- Test `get_process_chain` with a 3-level deep process tree. -- Test `get_process_network_footprint` with a process that has both DNS and direct IP connections. -- Test `build_attack_chain` produces a complete dict with all sections populated. -- Test that `build_attack_chain` handles a process with zero network/file/registry activity gracefully (empty lists, not errors). -- Test LLM context serialization stays under 2000 tokens for a moderately complex chain. - ---- - -## Commit 3: DGA Detection Heuristic (2B) - -### Create `agent/analysis/dga_detector.py` - -A lightweight, synchronous detector that scores domain names for DGA characteristics. This runs on every DNS event **before** any LLM call. - -#### Scoring Algorithm - -Compute a composite score from these signals: - -1. **Shannon entropy** of the domain name (excluding TLD): Higher entropy = more random. - - Typical legitimate domain entropy: 2.0–3.0 - - Typical DGA domain entropy: 3.5+ - -2. **Consonant-to-vowel ratio**: DGA domains tend to have unusual letter distributions. - - Normal English: ~0.6 vowels per character - - DGA: often < 0.3 or highly irregular - -3. **Domain length**: Longer random strings are more suspicious. - - Flag domains > 15 characters in the second-level domain. - -4. **Bigram frequency**: Compare character bigrams against English language frequency. - - Use a pre-computed bigram frequency table (embed as a dict constant, not a file). - - Low average bigram frequency = likely random/generated. - -5. **Numeric ratio**: High percentage of digits in the domain name. - -#### Interface - -```python -@dataclass -class DGAResult: - domain: str - score: float # 0.0 (definitely legit) to 1.0 (definitely DGA) - entropy: float - consonant_vowel_ratio: float - bigram_score: float - is_dga_candidate: bool # True if score > threshold - reasons: List[str] # Human-readable explanations: ["High entropy: 4.2", "Low bigram freq"] - -def analyze_domain(domain: str, threshold: float = 0.6) -> DGAResult: - """Score a domain name for DGA characteristics.""" -``` - -#### Integration - -- In the graph processor, when a DNS event creates a Domain node, immediately run `analyze_domain()` and set `is_dga_candidate` on the node. -- If `is_dga_candidate is True`, add `"DGA candidate (score: X.XX)"` to the `risk_indicators` list in `build_attack_chain()`. -- The DGA score should be included in the LLM context so the LLM can factor it into its analysis. -- Log DGA detections at WARNING level with the domain name and score. - -#### Allowlist - -Add a config option for known-good domains that should skip DGA analysis: - -```yaml -analysis: - dga: - entropy_threshold: 3.5 - score_threshold: 0.6 - allowlist: - - "googleapis.com" - - "cloudflare.com" - - "amazonaws.com" - - "windows.net" - - "office365.com" - - "microsoftonline.com" -``` - -#### Tests - -- Test that `google.com` scores low (< 0.3). -- Test that a known DGA-style domain like `xjk82mfq3p.xyz` scores high (> 0.6). -- Test that allowlisted domains always return `is_dga_candidate = False` regardless of score. -- Test that the DGA result is correctly attached to the Domain node in the graph. -- Test edge case: single-character domains, punycode domains, IP-literal domains (should not crash). - ---- - -## Commit 4: Persistence Detection (2C) - -### Create `agent/analysis/persistence_detector.py` - -A rule-based detector that monitors registry and filesystem paths commonly abused for persistence. This is platform-aware. - -#### Windows Registry Persistence Paths - -Monitor writes to these registry paths. Any write to these paths automatically sets severity to HIGH in `risk_indicators`: - -```python -WINDOWS_PERSISTENCE_KEYS = { - # Run keys - r"HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Run", - r"HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce", - r"HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Run", - r"HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce", - # Services - r"HKLM\SYSTEM\CurrentControlSet\Services", - # Winlogon - r"HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon\Shell", - r"HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon\Userinit", - # Context menu handlers (COM hijack vector) - r"HKLM\SOFTWARE\Classes\*\shellex\ContextMenuHandlers", - r"HKLM\SOFTWARE\Classes\CLSID", - # WMI persistence - r"HKLM\SOFTWARE\Microsoft\WBEM\ESS", - # Scheduled tasks - r"HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Schedule\TaskCache", - # AppInit DLLs (DLL injection) - r"HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Windows\AppInit_DLLs", - # Image File Execution Options (debugger hijack) - r"HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Image File Execution Options", -} -``` - -Use **prefix matching** — a write to `HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Run\MyMalware` matches the `Run` key. - -#### macOS Persistence Paths - -Monitor file creation/modification in: - -```python -MACOS_PERSISTENCE_PATHS = { - "~/Library/LaunchAgents/", # User-level launch agents - "/Library/LaunchAgents/", # System-wide launch agents - "/Library/LaunchDaemons/", # System-wide launch daemons - "~/Library/Application Support/com.apple.backgroundtaskmanagementagent/", - "/Library/StartupItems/", # Legacy startup items - "/etc/periodic/", # Periodic scripts - "~/Library/Preferences/", # Login items via plist manipulation -} -``` - -#### Linux Persistence Paths - -Monitor file creation/modification in: - -```python -LINUX_PERSISTENCE_PATHS = { - "/etc/cron.d/", - "/etc/cron.daily/", - "/etc/cron.hourly/", - "/etc/cron.weekly/", - "/etc/cron.monthly/", - "/var/spool/cron/", # User crontabs - "/etc/systemd/system/", # systemd unit files - "/usr/lib/systemd/system/", - "~/.config/systemd/user/", # User-level systemd units - "/etc/init.d/", # SysV init scripts - "/etc/rc.local", - "~/.bashrc", # Shell profile persistence - "~/.bash_profile", - "~/.profile", - "/etc/ld.so.preload", # Shared library injection -} -``` - -#### Interface - -```python -@dataclass -class PersistenceResult: - path: str # The registry key or file path that was written - persistence_type: str # "registry_run_key", "launch_agent", "cron_job", "systemd_unit", etc. - platform: str # "windows", "macos", "linux" - severity: str # Always "HIGH" for known persistence paths - mitre_technique: str # ATT&CK ID: "T1547.001", "T1543.001", etc. - description: str # Human-readable: "Process X wrote to Windows Run key" - -def check_persistence(event: AgentEvent) -> Optional[PersistenceResult]: - """Check if an event represents a persistence mechanism installation. - Returns None if the event is not persistence-related.""" -``` - -#### MITRE ATT&CK Mapping - -Map each persistence type to its ATT&CK technique ID: - -| Persistence Type | ATT&CK ID | Name | -|---|---|---| -| Windows Run keys | T1547.001 | Boot/Logon Autostart: Registry Run Keys | -| Windows Services | T1543.003 | Create or Modify System Process: Windows Service | -| Scheduled Tasks | T1053.005 | Scheduled Task | -| WMI Event Sub | T1546.003 | Event Triggered Execution: WMI | -| AppInit DLLs | T1546.010 | Event Triggered Execution: AppInit DLLs | -| IFEO Debugger | T1546.012 | Event Triggered Execution: IFEO | -| macOS LaunchAgent | T1543.001 | Create or Modify System Process: Launch Agent | -| macOS LaunchDaemon | T1543.004 | Create or Modify System Process: Launch Daemon | -| Linux cron | T1053.003 | Scheduled Task: Cron | -| Linux systemd | T1543.002 | Create or Modify System Process: Systemd Service | -| Shell profile | T1546.004 | Event Triggered Execution: Unix Shell Config | -| ld.so.preload | T1574.006 | Hijack Execution Flow: Dynamic Linker Hijacking | - -#### Integration - -- In the graph processor, run `check_persistence()` on every `file_create`, `file_modify`, `registry_create`, and `registry_modify` event. -- If a PersistenceResult is returned, add it to the `risk_indicators` list in `build_attack_chain()`. -- Include the ATT&CK technique ID in the LLM context — this helps the LLM map to known attack patterns. -- Log persistence detections at WARNING level. - -#### Tests - -- Test that writing to `HKLM\...\Run\malware` triggers detection with correct ATT&CK ID. -- Test that writing to `/etc/cron.d/backdoor` triggers detection on Linux. -- Test that writing to `~/Library/LaunchAgents/evil.plist` triggers detection on macOS. -- Test that writing to a non-persistence path (e.g., `/tmp/notes.txt`) returns None. -- Test prefix matching: `HKLM\...\Run\anything` matches the `Run` key pattern. -- Test that `build_attack_chain` includes persistence results in `risk_indicators`. - ---- - -## Cross-Cutting Requirements - -### Error Handling -- No new node type or detector should be able to crash the event processing pipeline. Wrap all new processing in try/except, log errors, increment `events_dropped_total`, and continue. -- Hash computation failure (permission denied, file gone) should log a warning and continue with `hash_sha256 = None`. -- Registry read failure for `previous_data` should not block event processing. - -### Performance -- DGA analysis must complete in < 1ms per domain. It's pure math, no I/O. -- Persistence detection must complete in < 0.1ms per event. It's string prefix matching. -- File hashing (SHA256) should be async or at minimum non-blocking on the main processing thread. For large files (> 100MB), skip hashing and log a warning. - -### Metrics -- Add a new counter: `dga_detections_total` -- Add a new counter: `persistence_detections_total` (labeled by `persistence_type`) -- Add a histogram: `attack_chain_build_latency_seconds` - -### Backward Compatibility -- Existing tests must continue to pass. Events that don't produce Domain/File/RegistryKey nodes should work exactly as before. -- The graph processor must handle events from both old collectors (that don't emit DNS/file/registry events) and new collectors seamlessly. diff --git a/phase6-live-testing-prompt.md b/phase6-live-testing-prompt.md deleted file mode 100644 index 7a8a3a4..0000000 --- a/phase6-live-testing-prompt.md +++ /dev/null @@ -1,600 +0,0 @@ -# Phase 6: Live Testing & Validation - -## Context - -All 5 implementation phases are complete. 19 commits, 326 unit tests passing. The agent has: -- Kernel event collectors: ETW (Windows), Auditd (Linux), unified log (macOS), psutil fallback -- Expanded graph: User, Process, IP, Domain, File, RegistryKey nodes -- DGA detection heuristic + persistence detection with MITRE ATT&CK mapping -- Response engine: suspend, terminate, network isolation, file quarantine, approval workflow, audit trail -- Self-protection: watchdog, tamper detection, Windows Service / systemd daemon -- Config system with YAML + CLI overrides -- Prometheus metrics + health endpoint - -Now we need to validate it works on real hosts. The operator has a MacBook Pro (native macOS) and can spin up a Windows VM. All testing starts in LOG_ONLY / observation mode — no auto-respond. - ---- - -## Step 1: Pre-Flight Checks - -Before touching any VM, verify the agent can start cleanly on each platform. - -### 1A: Create a test runner script - -**Create `tests/live/run_live_tests.py`.** - -This is NOT a unit test. It's a script that: -1. Starts the agent in the foreground with `--no-watchdog --no-tamper-check --log-format text --config config.yaml` -2. Waits 10 seconds for initialization -3. Hits the `/health` endpoint and verifies `{"status": "healthy"}` -4. Hits the `/metrics` endpoint and verifies Prometheus output is parseable -5. Prints a summary: collector type detected, events/second, queue depth, any errors in log output -6. Shuts down cleanly on Ctrl+C - -### 1B: Create a safe test config - -**Create `tests/live/test_config.yaml`.** - -```yaml -agent: - name: "edr-graph-test" - version: "2.0.0" - log_level: "DEBUG" - log_format: "text" - -collector: - mode: "auto" - buffer_size: 10000 - file_read_tracking: false - -analysis: - llm: - provider: "deepinfra" - api_key_env: "DEEPINFRA_API_KEY" - max_tokens: 2048 - temperature: 0.1 - timeout_seconds: 30 - max_concurrent_calls: 1 # Conservative for testing - rate_limit_per_minute: 10 # Conservative for testing - dga: - entropy_threshold: 3.5 - score_threshold: 0.6 - allowlist: - - "googleapis.com" - - "cloudflare.com" - - "amazonaws.com" - - "windows.net" - - "office365.com" - - "microsoftonline.com" - - "apple.com" - - "icloud.com" - -response: - auto_respond: false # LOG_ONLY mode — observe, don't act - auto_terminate: false - -persistence: - watchdog_enabled: false # Disabled for testing - heartbeat_interval_seconds: 10 - tamper_check_interval_seconds: 60 - -metrics: - enabled: true - port: 9100 -``` - ---- - -## Step 2: Simulated Attack Scenarios - -Create a test harness that generates known-malicious patterns the agent should detect. These are SAFE simulations — no actual malware. - -### Create `tests/live/attack_simulations.py` - -This script runs a menu-driven set of simulations. The operator picks which ones to run. Each simulation prints what it's about to do, waits for confirmation, executes, then tells the operator what the agent should have detected. - -**IMPORTANT:** All simulations must be safe and reversible. No actual exploitation. We're testing telemetry and detection, not breaking things. - -``` -=== EDR Agent Live Test Suite === - -Select a test to run: - - [1] Process Chain Test - [2] Suspicious DNS Resolution - [3] File Modification (FIM) Test - [4] Persistence Mechanism Test (platform-specific) - [5] Network Connection Test - [6] Encoded Command Test - [7] Rapid Process Spawning (Ephemeral Execution) - [8] Full Kill Chain Simulation - [0] Run All Tests Sequentially - [q] Quit - ->>> -``` - -#### Test 1: Process Chain Test - -**Purpose:** Verify the agent captures parent-child process relationships. - -```python -# Spawn a chain: python -> sh/cmd -> whoami -# Expected: Agent sees 3-level process chain with correct PPIDs -``` - -- **macOS/Linux:** `subprocess.Popen(["sh", "-c", "whoami && id && uname -a"])` -- **Windows:** `subprocess.Popen(["cmd", "/c", "whoami & hostname & ipconfig"])` -- **Expected detection:** Process chain in graph. `build_attack_chain()` should show the full lineage. -- **Print:** "Agent should show: python (PID X) -> sh/cmd (PID Y) -> whoami (PID Z)" - -#### Test 2: Suspicious DNS Resolution - -**Purpose:** Verify DNS event capture and DGA detection. - -```python -import socket - -# Resolve known-good domains -socket.getaddrinfo("google.com", 80) -socket.getaddrinfo("github.com", 443) - -# Resolve DGA-like domains (these are non-existent, resolution will fail — that's fine) -# The agent should still see the DNS query attempt -try: - socket.getaddrinfo("xjk82mfq3p9a2z.xyz", 80) -except socket.gaierror: - pass - -try: - socket.getaddrinfo("a8f3kq9xm2p7b4.top", 80) -except socket.gaierror: - pass - -# Resolve a domain with high entropy that actually exists (for resolution chain testing) -socket.getaddrinfo("neverssl.com", 80) -``` - -- **Expected detection:** Domain nodes created. DGA candidates flagged with score > 0.6. `risk_indicators` populated. -- **Print:** "Agent should show: Domain 'xjk82mfq3p9a2z.xyz' flagged as DGA candidate. Domain 'google.com' should NOT be flagged." - -#### Test 3: File Modification (FIM) Test - -**Purpose:** Verify file creation/modification events and File nodes in graph. - -```python -import tempfile, os, time - -test_dir = tempfile.mkdtemp(prefix="edr_test_") - -# Create a file -test_file = os.path.join(test_dir, "test_payload.txt") -with open(test_file, "w") as f: - f.write("initial content") - -time.sleep(2) - -# Modify the file -with open(test_file, "a") as f: - f.write("\nmodified content - simulating data staging") - -time.sleep(2) - -# Create a suspicious file extension -suspicious_file = os.path.join(test_dir, "backdoor.php") -with open(suspicious_file, "w") as f: - f.write("") - -time.sleep(2) - -# Cleanup -os.remove(test_file) -os.remove(suspicious_file) -os.rmdir(test_dir) -``` - -- **Expected detection:** File nodes created with paths. CREATED and MODIFIED edges from the python process. SHA256 hashes computed (if files existed at processing time). -- **Print:** "Agent should show: File 'test_payload.txt' CREATED then MODIFIED. File 'backdoor.php' CREATED." - -#### Test 4: Persistence Mechanism Test - -**Purpose:** Verify persistence detection fires on known ATT&CK paths. Platform-specific. - -##### macOS - -```python -import tempfile, os, plistlib - -# Create a fake LaunchAgent plist (in a temp location first, then copy) -plist_data = { - "Label": "com.edr.test.fake", - "ProgramArguments": ["/usr/bin/true"], - "RunAtLoad": True, -} - -# Write to user LaunchAgents directory -launch_agent_path = os.path.expanduser("~/Library/LaunchAgents/com.edr.test.fake.plist") -with open(launch_agent_path, "wb") as f: - plistlib.dump(plist_data, f) - -print(f"Created test LaunchAgent at: {launch_agent_path}") -print("Agent should detect: Persistence (T1543.001 - Launch Agent)") - -time.sleep(5) - -# Cleanup -os.remove(launch_agent_path) -print("Cleaned up test LaunchAgent.") -``` - -##### Windows - -```python -import winreg, time - -# Write a harmless test value to the current user's Run key -key_path = r"SOFTWARE\Microsoft\Windows\CurrentVersion\Run" -value_name = "EDRGraphTest" -value_data = r"C:\Windows\System32\cmd.exe /c echo test" - -try: - key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, key_path, 0, winreg.KEY_SET_VALUE) - winreg.SetValueEx(key, value_name, 0, winreg.REG_SZ, value_data) - winreg.CloseKey(key) - print(f"Created test Run key: HKCU\\{key_path}\\{value_name}") - print("Agent should detect: Persistence (T1547.001 - Registry Run Key)") - - time.sleep(5) - - # Cleanup - key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, key_path, 0, winreg.KEY_SET_VALUE) - winreg.DeleteValue(key, value_name) - winreg.CloseKey(key) - print("Cleaned up test Run key.") -except PermissionError: - print("ERROR: Need to run as Administrator to write Run keys.") -``` - -##### Linux - -```python -import tempfile, os, stat - -# Create a fake cron job -cron_file = "/tmp/edr_test_cron" # Write to /tmp first for safety -with open(cron_file, "w") as f: - f.write("* * * * * /usr/bin/true\n") -print(f"Created test cron file at: {cron_file}") - -# If running as root (in a test VM), copy to actual cron location -if os.geteuid() == 0: - import shutil - actual_cron = "/etc/cron.d/edr_test_fake" - shutil.copy(cron_file, actual_cron) - print(f"Copied to {actual_cron}") - print("Agent should detect: Persistence (T1053.003 - Cron)") - time.sleep(5) - os.remove(actual_cron) - print("Cleaned up.") -else: - print("Not running as root — agent may not detect /tmp writes as persistence.") - print("For full test, run simulation as root in the test VM.") - -os.remove(cron_file) -``` - -- **Expected detection:** PersistenceResult with correct ATT&CK technique ID and HIGH severity. - -#### Test 5: Network Connection Test - -**Purpose:** Verify outbound connection tracking and IP node creation. - -```python -import socket, time - -# Connect to known-good services -targets = [ - ("1.1.1.1", 80, "Cloudflare DNS HTTP"), - ("8.8.8.8", 53, "Google DNS"), - ("93.184.216.34", 80, "example.com"), -] - -for ip, port, desc in targets: - try: - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock.settimeout(5) - sock.connect((ip, port)) - print(f"Connected to {ip}:{port} ({desc})") - sock.close() - except Exception as e: - print(f"Failed to connect to {ip}:{port}: {e}") - time.sleep(1) -``` - -- **Expected detection:** IP nodes created with addresses and ports. CONNECTED_TO edges from the python process. - -#### Test 6: Encoded Command Test - -**Purpose:** Verify the agent captures suspicious command line arguments that the LLM should flag. - -##### macOS/Linux - -```python -import subprocess, base64 - -# Base64 encoded "whoami" — classic attacker technique -encoded = base64.b64encode(b"whoami").decode() -subprocess.run(["sh", "-c", f"echo {encoded} | base64 -d | sh"], capture_output=True) -``` - -##### Windows - -```python -import subprocess, base64 - -# PowerShell encoded command (UTF-16LE base64 of "whoami") -cmd = "whoami" -encoded = base64.b64encode(cmd.encode("utf-16-le")).decode() -subprocess.run(["powershell", "-EncodedCommand", encoded], capture_output=True) -``` - -- **Expected detection:** Process with suspicious command line (`-EncodedCommand`, `base64 -d | sh`). LLM should flag this. - -#### Test 7: Rapid Process Spawning - -**Purpose:** Verify the agent captures ephemeral processes that exist for < 1 second. This is the key improvement over psutil polling. - -```python -import subprocess, time - -print("Spawning 20 short-lived processes in rapid succession...") -start = time.time() - -for i in range(20): - # Each process lives for ~50ms - if sys.platform == "win32": - subprocess.run(["cmd", "/c", f"echo ephemeral_{i}"], capture_output=True) - else: - subprocess.run(["sh", "-c", f"echo ephemeral_{i}"], capture_output=True) - -elapsed = time.time() - start -print(f"Spawned 20 processes in {elapsed:.2f}s") -print(f"Agent should have captured all 20 process_start events.") -print(f"Check metrics: events_processed_total should have increased by >= 20") -``` - -- **Expected detection:** All 20 processes captured with correct image names and command lines. This is the test that proves ETW/auditd is working — psutil polling would miss most of these. - -#### Test 8: Full Kill Chain Simulation - -**Purpose:** Simulate a realistic attack sequence and verify the agent builds a complete attack chain. - -```python -""" -Simulated kill chain: -1. Initial access: Encoded command execution (simulating macro/exploit) -2. Discovery: whoami, ipconfig/ifconfig, net user/id -3. Persistence: Write to Run key (Windows) or LaunchAgent (macOS) -4. C2: DNS resolution of DGA-like domain -5. Staging: Write payload to temp file -6. Exfiltration: Outbound connection - -All actions are safe — no actual exploitation. -""" - -import subprocess, socket, os, sys, time, base64, tempfile - -print("=== Full Kill Chain Simulation ===") -print("This runs all attack stages sequentially.\n") - -# Stage 1: Initial access via encoded command -print("[Stage 1] Encoded command execution...") -if sys.platform == "win32": - encoded = base64.b64encode("whoami".encode("utf-16-le")).decode() - subprocess.run(["powershell", "-EncodedCommand", encoded], capture_output=True) -else: - encoded = base64.b64encode(b"whoami").decode() - subprocess.run(["sh", "-c", f"echo {encoded} | base64 -d | sh"], capture_output=True) -time.sleep(2) - -# Stage 2: Discovery -print("[Stage 2] System discovery...") -if sys.platform == "win32": - subprocess.run(["cmd", "/c", "whoami & hostname & ipconfig & net user"], capture_output=True) -else: - subprocess.run(["sh", "-c", "whoami && hostname && ifconfig && id"], capture_output=True) -time.sleep(2) - -# Stage 3: Persistence -print("[Stage 3] Persistence mechanism...") -if sys.platform == "darwin": - import plistlib - plist_path = os.path.expanduser("~/Library/LaunchAgents/com.edr.killchain.test.plist") - plist_data = {"Label": "com.edr.killchain.test", "ProgramArguments": ["/usr/bin/true"], "RunAtLoad": True} - with open(plist_path, "wb") as f: - plistlib.dump(plist_data, f) - persistence_cleanup = lambda: os.remove(plist_path) -elif sys.platform == "win32": - import winreg - key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, r"SOFTWARE\Microsoft\Windows\CurrentVersion\Run", 0, winreg.KEY_SET_VALUE) - winreg.SetValueEx(key, "EDRKillChainTest", 0, winreg.REG_SZ, r"C:\Windows\System32\cmd.exe /c echo test") - winreg.CloseKey(key) - def persistence_cleanup(): - key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, r"SOFTWARE\Microsoft\Windows\CurrentVersion\Run", 0, winreg.KEY_SET_VALUE) - winreg.DeleteValue(key, "EDRKillChainTest") - winreg.CloseKey(key) -else: - persistence_cleanup = lambda: None - print(" (Skipping persistence on Linux — would need root for /etc/cron.d)") -time.sleep(2) - -# Stage 4: C2 beacon (DGA-like DNS) -print("[Stage 4] C2 DNS beacon...") -dga_domains = ["xjk82mfq3p9a2z.xyz", "q7w2m9f4p8k1.top", "b3x7n2k9m5p1.net"] -for domain in dga_domains: - try: - socket.getaddrinfo(domain, 443) - except socket.gaierror: - pass - time.sleep(0.5) -time.sleep(2) - -# Stage 5: Staging -print("[Stage 5] Data staging...") -staging_dir = tempfile.mkdtemp(prefix="edr_staging_") -staged_file = os.path.join(staging_dir, "exfil_data.enc") -with open(staged_file, "w") as f: - f.write("SIMULATED_SENSITIVE_DATA_" * 100) -time.sleep(2) - -# Stage 6: Exfiltration attempt -print("[Stage 6] Exfiltration connection...") -try: - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock.settimeout(3) - sock.connect(("93.184.216.34", 443)) # example.com - sock.close() -except Exception: - pass -time.sleep(2) - -# Cleanup -print("\n[Cleanup] Removing artifacts...") -persistence_cleanup() -os.remove(staged_file) -os.rmdir(staging_dir) - -print("\n=== Kill Chain Complete ===") -print("Expected agent detections:") -print(" 1. Encoded command execution (suspicious command line)") -print(" 2. Discovery commands (whoami, ipconfig/ifconfig)") -print(" 3. Persistence mechanism (T1547.001 or T1543.001)") -print(" 4. DGA domain resolution (3 candidates, score > 0.6)") -print(" 5. File staging (CREATED edge to temp file)") -print(" 6. Outbound connection (CONNECTED_TO edge)") -print(" 7. Full attack chain should link all 6 stages through process tree") -print("\nCheck: build_attack_chain() for the python PID should show all of the above.") -``` - ---- - -## Step 3: Metrics Validation Script - -### Create `tests/live/check_metrics.py` - -A script that polls the Prometheus metrics endpoint and prints a human-readable dashboard: - -``` -=== EDR Agent Metrics Dashboard === -Uptime: 342s -Events processed: 1,247 -Events dropped: 0 -Event rate: 3.6 events/sec -Queue depth: 12 - -Processing latency (p50/p95/p99): 2.1ms / 8.4ms / 15.2ms -LLM call latency (p50/p95/p99): 420ms / 890ms / 1200ms - -LLM verdicts: INFO=1180 LOW=42 MEDIUM=18 HIGH=5 CRITICAL=2 -DGA detections: 3 -Persistence detections: 1 -Response actions: 0 (auto_respond=false) - -Attack chain build latency (p50/p95): 1.2ms / 4.8ms -``` - -- Poll `/metrics` every 5 seconds. -- Parse Prometheus text format. -- Calculate rates from counter deltas between polls. -- Highlight any concerning values in red (events_dropped > 0, queue_depth > buffer_size * 0.8). - ---- - -## Step 4: Validation Checklist Script - -### Create `tests/live/validate.py` - -After running the simulations, this script queries the agent's graph database and audit trail to verify detections actually occurred. - -```python -""" -Post-simulation validation. Run this AFTER running attack_simulations.py. - -Queries the agent's graph DB and prints pass/fail for each expected detection. -""" - -# For each test, query the graph and verify: - -checks = [ - { - "name": "Process chain captured", - "query": "Check for Process nodes with SPAWNED edges at least 2 levels deep", - "pass_condition": "At least one 3-level process chain exists", - }, - { - "name": "DGA domain detected", - "query": "Check Domain nodes where is_dga_candidate = True", - "pass_condition": "At least 2 DGA candidate domains exist", - }, - { - "name": "Legitimate domain NOT flagged", - "query": "Check Domain node for google.com", - "pass_condition": "is_dga_candidate = False", - }, - { - "name": "File creation tracked", - "query": "Check for File nodes with CREATED edges", - "pass_condition": "At least 1 File node with CREATED edge exists", - }, - { - "name": "Persistence detected", - "query": "Check risk_indicators for any T1547 or T1543 technique IDs", - "pass_condition": "At least 1 persistence detection in audit log", - }, - { - "name": "Network connection tracked", - "query": "Check IP nodes with CONNECTED_TO edges", - "pass_condition": "At least 1 IP node with connection from test process", - }, - { - "name": "Ephemeral processes captured", - "query": "Check for Process nodes matching 'echo ephemeral_*'", - "pass_condition": "At least 15 of 20 ephemeral processes captured", - }, - { - "name": "Attack chain builds successfully", - "query": "Call build_attack_chain() for the simulation PID", - "pass_condition": "Returns dict with non-empty process_chain, network_footprint, and risk_indicators", - }, - { - "name": "Metrics endpoint healthy", - "query": "GET http://localhost:9100/health", - "pass_condition": "Returns status=healthy with events_last_minute > 0", - }, - { - "name": "No dropped events", - "query": "Check events_dropped_total metric", - "pass_condition": "events_dropped_total == 0", - }, -] -``` - -For each check, print: -``` -[PASS] Process chain captured — Found 4 chains, deepest is 3 levels -[PASS] DGA domain detected — 3 DGA candidates found (scores: 0.82, 0.79, 0.71) -[PASS] Legitimate domain NOT flagged — google.com is_dga_candidate=False -[FAIL] Ephemeral processes captured — Only 12 of 20 captured (60%) - ↳ This may indicate the collector is not keeping up. Check buffer_size. -``` - ---- - -## Implementation Notes - -- All test scripts go in `tests/live/` — keep them separate from unit tests. -- Every simulation cleans up after itself. No persistent artifacts left on the test system. -- The validation script should import from the agent's own modules to query the graph — don't reimplement graph queries. -- Print clear, actionable output. The operator is reading terminal output, not a dashboard. -- Handle platform differences with `sys.platform` checks throughout. macOS, Windows, and Linux paths all need to work. -- If a simulation requires elevated privileges (e.g., writing to system cron on Linux, Run keys on Windows), print a clear message and skip gracefully rather than crashing. diff --git a/phase7-macos-production-hardening.md b/phase7-macos-production-hardening.md deleted file mode 100644 index 670e1d8..0000000 --- a/phase7-macos-production-hardening.md +++ /dev/null @@ -1,410 +0,0 @@ -# Phase 7: macOS Production Hardening - -## Context - -The agent runs on macOS with 3 known gaps from live testing (commit 445bc8e): - -1. **No file I/O events** — Endpoint Security framework requires Apple entitlement. No file create/modify/delete tracking. -2. **No persistence detection via file events** — LaunchAgent writes aren't captured, so the persistence detector never fires. -3. **Incomplete process command lines** — unified_log doesn't include full command arguments for most processes. Ephemeral process content can't be verified. - -All three are solvable without Endpoint Security. This phase adds three macOS-specific collectors and integrates them into the existing pipeline. - -**Implementation order matters. Follow the commit sequence below.** - ---- - -## Commit 1: FSEvents File I/O Collector - -### Create `agent/collectors/macos_fsevents_collector.py` - -Use the `fsevents` Python package (PyPI: `fsevents`) to monitor filesystem changes. FSEvents is the macOS-native filesystem notification API — it's what Spotlight and Time Machine use. No entitlement required. - -#### Installation - -```bash -pip install fsevents -``` - -`fsevents` only works on macOS. Guard the import: - -```python -import sys -if sys.platform != "darwin": - raise ImportError("FSEvents collector is macOS-only") - -import fsevents -``` - -#### What FSEvents Provides - -- File/directory: created, modified, deleted, renamed -- The full path of the changed item -- Event flags indicating the type of change -- **Does NOT provide:** the PID that made the change (that requires Endpoint Security) - -#### Implementation - -- Watch these paths by default (configurable via `config.yaml`): - -```yaml -collector: - fsevents: - watched_paths: - - "/Users/" - - "/tmp/" - - "/var/tmp/" - - "/etc/" - - "/Library/LaunchAgents/" - - "/Library/LaunchDaemons/" - - "/Applications/" - excluded_paths: - - "/Users/*/Library/Caches/" - - "/Users/*/Library/Logs/" - - "/Users/*/.Trash/" - - "/tmp/com.apple.*" - latency: 0.5 # seconds — FSEvents coalescing interval -``` - -- For each FSEvents callback, create an `AgentEvent` with: - - `event_type`: map FSEvents flags to `"file_create"`, `"file_modify"`, `"file_delete"`, `"file_rename"` - - `file_path`: the full path from the event - - `file_operation`: same as event_type without the `file_` prefix - - `pid`: `None` (FSEvents doesn't provide this) - - `timestamp`: current UTC time (FSEvents doesn't give precise timestamps per event) - - `source`: `"fsevents"` - -- **PID correlation heuristic:** Since FSEvents doesn't tell us which process made the change, implement a best-effort correlator: - 1. When a file event arrives, check the graph for processes that were running at that timestamp. - 2. If only one process has the file's directory in its `command_line` or `cwd`, attribute it. - 3. If multiple candidates exist or none match, create the File node with a `MODIFIED_BY_UNKNOWN` edge to a sentinel `(:Process {name: "unknown", pid: -1})` node. - 4. This is imperfect. That's fine. Log it at DEBUG level and move on. - -- **Volume filtering:** FSEvents is noisy. Filter out: - - Events in excluded_paths (glob matching) - - `.DS_Store` files - - Files with extensions: `.log`, `.tmp`, `.cache` (configurable) - - Rapid duplicate events for the same path within 1 second (FSEvents can fire multiple callbacks for a single write) - -- Run the FSEvents observer on its own thread. Push events into the shared event queue. - -#### Integration - -- Register this collector in `agent/collectors/__init__.py` for the macOS platform. -- The existing graph processor already handles `file_create`, `file_modify`, `file_delete` event types from Phase 2 — these FSEvents events should flow through the same path and create File nodes with edges. -- The persistence detector from Phase 2 (commit 4, `persistence_detector.py`) should now fire when FSEvents reports writes to `~/Library/LaunchAgents/`, `/Library/LaunchDaemons/`, etc. - -#### Tests - -- Test that FSEvents callback correctly maps flags to `AgentEvent.event_type`. -- Test path exclusion filtering (`.DS_Store`, cache dirs, `.log` files). -- Test deduplication of rapid duplicate events for the same path. -- Test that events with `pid=None` create File nodes with the unknown process sentinel edge. -- Test that writes to LaunchAgents paths trigger the persistence detector. - ---- - -## Commit 2: LaunchAgent/Daemon Directory Polling (Belt and Suspenders) - -### Create `agent/collectors/macos_persistence_poller.py` - -FSEvents should catch LaunchAgent writes, but as a backup, implement a polling-based persistence monitor that snapshots LaunchAgent/LaunchDaemon directories and diffs them. - -This is the "belt and suspenders" approach — if FSEvents misses something (which can happen if the coalescing window swallows a rapid create+modify), the poller catches it. - -#### Implementation - -- On startup, snapshot these directories: - -```python -PERSISTENCE_DIRS = [ - os.path.expanduser("~/Library/LaunchAgents/"), - "/Library/LaunchAgents/", - "/Library/LaunchDaemons/", - "/Library/StartupItems/", -] -``` - -- For each directory, record: `{filename: (mtime, sha256, size)}`. -- Every `poll_interval` seconds (default: 10, configurable), re-scan and diff: - - **New file:** Emit a `file_create` AgentEvent for the path. Also parse the plist and extract `Label`, `ProgramArguments`, and `RunAtLoad` — include these in `AgentEvent.raw`. - - **Modified file (mtime or hash changed):** Emit a `file_modify` AgentEvent. Include old and new hash in `raw`. - - **Deleted file:** Emit a `file_delete` AgentEvent. -- Deduplicate against FSEvents: if the FSEvents collector already emitted an event for this path within the last `poll_interval`, skip it. Use a shared set (thread-safe) of recently-seen paths. - -#### Plist Parsing - -When a new or modified `.plist` file is detected, parse it and add structured data to `AgentEvent.raw`: - -```python -import plistlib - -def parse_launch_plist(path: str) -> Optional[dict]: - try: - with open(path, "rb") as f: - plist = plistlib.load(f) - return { - "label": plist.get("Label"), - "program": plist.get("Program"), - "program_arguments": plist.get("ProgramArguments"), - "run_at_load": plist.get("RunAtLoad", False), - "keep_alive": plist.get("KeepAlive", False), - "watch_paths": plist.get("WatchPaths"), - "start_interval": plist.get("StartInterval"), - } - except Exception: - return None -``` - -This structured plist data is extremely valuable for the LLM — it can reason about what the LaunchAgent actually does rather than just knowing a file was created. - -#### Config - -```yaml -collector: - persistence_poller: - enabled: true - poll_interval_seconds: 10 - directories: - - "~/Library/LaunchAgents/" - - "/Library/LaunchAgents/" - - "/Library/LaunchDaemons/" - - "/Library/StartupItems/" -``` - -#### Tests - -- Test that a new plist file in a watched directory triggers a `file_create` event. -- Test that modifying a plist triggers `file_modify` with old/new hash in raw. -- Test that deleting a plist triggers `file_delete`. -- Test plist parsing extracts Label, ProgramArguments, RunAtLoad correctly. -- Test deduplication: if FSEvents already reported the same path, poller skips it. -- Test that a malformed plist (binary garbage) doesn't crash the poller. - ---- - -## Commit 3: Process Command Line Enrichment - -### Create `agent/collectors/macos_proc_enricher.py` - -The unified log gives us PIDs but not full command lines. This module enriches process events with command line arguments by querying the kernel via `sysctl`. - -#### Implementation - -Use `sysctl` with `CTL_KERN` + `KERN_PROCARGS2` via ctypes to read command line arguments for a given PID: - -```python -import ctypes -import ctypes.util - -libc = ctypes.CDLL(ctypes.util.find_library("c")) - -CTL_KERN = 1 -KERN_PROCARGS2 = 49 - -def get_process_cmdline(pid: int) -> Optional[str]: - """Read full command line for a PID via sysctl KERN_PROCARGS2. - - Returns the full command line string, or None if the process - has exited or we lack permission. - """ - # Buffer size query - size = ctypes.c_size_t(0) - mib = (ctypes.c_int * 3)(CTL_KERN, KERN_PROCARGS2, pid) - - # First call to get buffer size - if libc.sysctl(mib, 3, None, ctypes.byref(size), None, 0) != 0: - return None - - # Allocate buffer and read - buf = ctypes.create_string_buffer(size.value) - if libc.sysctl(mib, 3, buf, ctypes.byref(size), None, 0) != 0: - return None - - # Parse: first 4 bytes = argc, then executable path (null-terminated), - # then padding nulls, then argv strings (null-separated) - raw = buf.raw[:size.value] - argc = int.from_bytes(raw[:4], byteorder="little") - - # Skip argc and executable path - rest = raw[4:] - exe_end = rest.index(b'\x00') - rest = rest[exe_end + 1:] - - # Skip padding nulls - while rest and rest[0:1] == b'\x00': - rest = rest[1:] - - # Extract argc arguments - args = [] - for _ in range(argc): - if not rest: - break - end = rest.index(b'\x00') if b'\x00' in rest else len(rest) - args.append(rest[:end].decode("utf-8", errors="replace")) - rest = rest[end + 1:] - - return " ".join(args) if args else None -``` - -#### Integration as an Enrichment Step - -This is NOT a standalone collector. It's an enrichment pass that runs in the graph processor: - -1. When a `process_start` event arrives from the unified log collector with `command_line` as `None` or incomplete (just the binary name): -2. Immediately call `get_process_cmdline(event.pid)`. -3. If successful, update `event.command_line` with the full command line. -4. If the process has already exited (sysctl returns error), log at DEBUG and continue with whatever we have. - -**Timing is critical.** The enrichment must happen as quickly as possible after the event arrives, before the process exits. For ephemeral processes (< 100ms lifetime), we'll often miss the window — that's acceptable. Log the miss rate as a metric. - -#### Race Condition Handling - -- `get_process_cmdline` will fail for processes that have already exited. This is expected and common for ephemeral processes. -- It will also fail for processes owned by other users if we're not running as root. When running as a LaunchDaemon (root), this isn't an issue. -- Never block the event pipeline waiting for enrichment. If sysctl takes > 10ms (shouldn't happen, it's a kernel call), skip and continue. - -#### Metrics - -- Add counter: `cmdline_enrichment_total` (labeled: `success`, `failed_exited`, `failed_permission`, `failed_timeout`) -- Add histogram: `cmdline_enrichment_latency_seconds` - -#### Config - -```yaml -collector: - proc_enrichment: - enabled: true - timeout_ms: 10 # Max time to spend on sysctl call per PID -``` - -#### Tests - -- Test that `get_process_cmdline(os.getpid())` returns the current process's command line. -- Test that `get_process_cmdline(99999999)` returns None (non-existent PID). -- Test that the enrichment step updates `event.command_line` when successful. -- Test that a failed enrichment doesn't block or crash the pipeline. -- Test that the metric counters increment correctly for success and failure cases. - ---- - -## Commit 4: Integration and Collector Registration - -### Update `agent/collectors/__init__.py` - -On macOS, the full collector stack should now be: - -```python -# macOS collector initialization order: -# 1. UnifiedLogCollector — process events, some network events -# 2. MacOSDnsCollector — DNS queries via tcpdump (added in 445bc8e) -# 3. PsutilCollector — network connections (supplement) -# 4. MacOSFSEventsCollector — file I/O events (NEW) -# 5. MacOSPersistencePoller — LaunchAgent/Daemon directory monitoring (NEW) -# 6. MacOSProcEnricher — command line enrichment pass (NEW, not a collector — runs in processor) -``` - -All collectors run concurrently, feeding into the shared event queue. The proc enricher runs in the graph processor, not as a separate collector. - -### Update Config Defaults - -Add the new macOS sections to `config.yaml` and the config model: - -```yaml -collector: - fsevents: - watched_paths: - - "/Users/" - - "/tmp/" - - "/var/tmp/" - - "/etc/" - - "/Library/LaunchAgents/" - - "/Library/LaunchDaemons/" - - "/Applications/" - excluded_paths: - - "/Users/*/Library/Caches/" - - "/Users/*/Library/Logs/" - - "/Users/*/.Trash/" - - "/tmp/com.apple.*" - excluded_extensions: - - ".log" - - ".tmp" - - ".cache" - - ".DS_Store" - latency: 0.5 - persistence_poller: - enabled: true - poll_interval_seconds: 10 - directories: - - "~/Library/LaunchAgents/" - - "/Library/LaunchAgents/" - - "/Library/LaunchDaemons/" - - "/Library/StartupItems/" - proc_enrichment: - enabled: true - timeout_ms: 10 -``` - -### Update Live Test Simulations - -Update `tests/live/attack_simulations.py`: - -- **Test 3 (File Modification):** Should now produce File nodes on macOS via FSEvents. Update expected output. -- **Test 4 (Persistence):** LaunchAgent plist creation should now be detected by BOTH FSEvents and the persistence poller. Update expected output to confirm persistence detection fires with ATT&CK ID T1543.001. -- **Test 6 (Encoded Command):** With proc enrichment, the base64 encoded command line should now be captured for processes that live long enough. Update expected output. -- **Test 7 (Ephemeral Processes):** Some of the 20 ephemeral processes should now have command lines enriched. Don't expect 100% — log the enrichment success rate. - -### Update `tests/live/validate.py` - -Add macOS-specific validation checks: - -```python -# New macOS checks: -{ - "name": "FSEvents file tracking active", - "query": "Check for File nodes with source=fsevents", - "pass_condition": "At least 1 File node created via FSEvents", -}, -{ - "name": "LaunchAgent persistence detected", - "query": "Check findings for T1543.001", - "pass_condition": "Persistence finding with ATT&CK ID T1543.001 exists", -}, -{ - "name": "Command line enrichment working", - "query": "Check Process nodes for non-null command_line", - "pass_condition": "At least 50% of Process nodes have command_line populated", -}, -{ - "name": "Plist parsing in persistence events", - "query": "Check raw data on persistence file events", - "pass_condition": "At least 1 event has parsed plist data (Label, ProgramArguments)", -}, -``` - -### Run Full Test Suite - -After all 4 commits, run: -1. All 326 existing unit tests — must still pass. -2. New unit tests for FSEvents, persistence poller, and proc enricher. -3. Full live test suite on macOS: `run_live_tests.py` → `attack_simulations.py` (test 8, full kill chain) → `validate.py`. - ---- - -## Cross-Cutting Requirements - -### Error Handling -- FSEvents observer crash must not take down the agent. Wrap in try/except, log, increment `events_dropped_total`. -- Persistence poller encountering a directory it can't read (permission denied) should log a warning and skip that directory, not crash. -- `get_process_cmdline` failures are expected and frequent. Never log above DEBUG for individual failures — only log aggregate stats (enrichment success rate) at INFO on a periodic basis (every 60 seconds). - -### Performance -- FSEvents latency of 0.5s means events are batched. This is fine — we're not doing real-time response on file events without PID attribution anyway. -- Persistence poller at 10s intervals is negligible CPU. Plist parsing is fast. -- `sysctl KERN_PROCARGS2` is a kernel call — should complete in < 1ms. The 10ms timeout is a safety net. -- FSEvents volume filtering is critical. Without it, `/Users/*/Library/Caches/` alone can generate hundreds of events per second during normal browsing. - -### Dependencies -- `fsevents` (PyPI) — macOS only, C extension. Add to requirements with platform marker: `fsevents>=0.3; sys_platform == 'darwin'` -- `plistlib` — stdlib, no additional dependency. -- `ctypes` — stdlib, no additional dependency.` diff --git a/phase8-system-tray-icon-dashboard.md b/phase8-system-tray-icon-dashboard.md deleted file mode 100644 index 49d34c2..0000000 --- a/phase8-system-tray-icon-dashboard.md +++ /dev/null @@ -1,528 +0,0 @@ -# Phase 8: System Tray Icon + Local Web Dashboard - -## Context - -The agent is functionally complete on macOS with 359 tests passing. There is currently no user interface beyond terminal logs, `check_metrics.py`, and the Prometheus endpoint. This phase adds: - -1. A native macOS menu bar (tray) icon for status, controls, and notifications -2. A local web dashboard for investigation, alert review, and graph visualization - -The tray icon is the agent's face — it shows status at a glance and pushes macOS notifications for high-severity alerts. The web dashboard is where investigation happens — alert tables, process trees, graph views, and the response audit trail. - -**Both run inside the agent process** to avoid the Kuzu concurrent reader problem. The web server is a thread inside the agent, not a separate process. - ---- - -## Commit 1: Web Dashboard Backend (FastAPI) - -### Create `agent/dashboard/server.py` - -A FastAPI application that serves the dashboard UI and exposes REST API endpoints for the frontend. - -#### Dependencies - -``` -fastapi>=0.110.0 -uvicorn>=0.29.0 -``` - -Run uvicorn in a thread inside the agent's main process. Bind to `127.0.0.1:9200` (configurable). This is separate from the Prometheus metrics port (9100). - -#### API Endpoints - -``` -GET /api/status - Returns: { - "agent_status": "running", - "uptime_seconds": 1234, - "collector_sources": ["unified_log", "tcpdump_dns", "psutil_network", "fsevents", "persistence_poller"], - "events_processed": 12345, - "events_dropped": 0, - "events_per_second": 3.6, - "queue_depth": 12, - "buffer_size": 10000, - "last_event_timestamp": "2025-02-17T10:30:00Z" - } - -GET /api/findings?severity=HIGH&limit=50&offset=0&sort=timestamp_desc - Returns: { - "findings": [ - { - "id": "...", - "timestamp": "...", - "severity": "HIGH", - "title": "Persistence mechanism detected", - "description": "Process python3 created LaunchAgent com.edr.test...", - "mitre_technique": "T1543.001", - "mitre_name": "Launch Agent", - "source_pid": 1234, - "source_process": "python3", - "risk_indicators": [...], - "llm_analysis": "...", - "response_actions": [...] - } - ], - "total": 42, - "limit": 50, - "offset": 0 - } - -GET /api/findings/:id - Returns: Full finding detail with complete LLM analysis text and response audit trail. - -GET /api/graph/process-tree/:pid - Returns: { - "root": { - "pid": 1, - "name": "launchd", - "children": [ - { - "pid": 500, - "name": "bash", - "command_line": "/bin/bash", - "children": [ - { - "pid": 1234, - "name": "python3", - "command_line": "python3 malware.py", - "children": [] - } - ] - } - ] - } - } - -GET /api/graph/network/:pid - Returns: { - "process": {"pid": 1234, "name": "python3"}, - "domains": [ - {"name": "evil.com", "is_dga": true, "score": 0.82, "resolved_to": ["1.2.3.4"]} - ], - "connections": [ - {"ip": "1.2.3.4", "port": 443, "protocol": "tcp", "timestamp": "..."} - ] - } - -GET /api/graph/attack-chain/:pid - Returns: The full output of build_attack_chain() for this PID. - -GET /api/graph/stats - Returns: { - "nodes": {"Process": 245, "IP": 41, "Domain": 28, "File": 99, "RegistryKey": 0, "User": 3}, - "edges": {"SPAWNED": 41, "CONNECTED_TO": 112, "RESOLVED": 54, "CREATED_FILE": 90, ...}, - "total_nodes": 416, - "total_edges": 450 - } - -GET /api/metrics - Returns: Parsed Prometheus metrics as JSON (reads from the in-process metrics, not HTTP scrape). - -GET /api/audit-trail?limit=50&offset=0 - Returns: Response action audit trail from SQLite. Each entry includes: - action_taken, target_pid, target_path, severity, approved_by, timestamp, reverted. - -GET /api/events/recent?limit=100&source=all - Returns: Most recent raw events from the processing pipeline (keep a circular buffer of last 1000 events in memory for this endpoint). - -POST /api/response/approve/:response_id - Body: {"action": "approve"} or {"action": "deny"} - Approves or denies a pending response action. Only works if auto_respond is false. -``` - -#### Implementation Notes - -- All graph queries go through `agent/graph/queries.py` functions. Do NOT write raw Kuzu queries in the dashboard server. -- SQLite queries for findings and audit trail use the existing DB connection from the agent process (same thread-safety approach as the rest of the agent). -- The recent events buffer is an in-memory `collections.deque(maxlen=1000)` that the graph processor appends to. The dashboard reads from it. -- All endpoints return JSON. No server-side HTML rendering. -- Add CORS headers for `127.0.0.1` only (the frontend is served from the same origin, but add it for development flexibility). - -#### Tests - -- Test each API endpoint returns correct JSON schema. -- Test findings filtering by severity. -- Test process tree endpoint builds correct hierarchy. -- Test that the server binds to localhost only (security). - ---- - -## Commit 2: Dashboard Frontend - -### Create `agent/dashboard/static/` - -A single-page application served by FastAPI's static file handler. **Everything in one `index.html` file** — inline CSS, inline JS, no build step, no npm, no bundler. Use vanilla JS with `fetch()` for API calls. - -#### Design Requirements - -**Color scheme and aesthetic:** -- Dark theme. Background: `#0a0a0f`. Card backgrounds: `#12121a`. -- Accent color for alerts and highlights: `#3b82f6` (blue). -- Severity colors: CRITICAL `#ef4444` (red), HIGH `#f97316` (orange), MEDIUM `#eab308` (yellow), LOW `#22c55e` (green), INFO `#6b7280` (gray). -- Font: system font stack (`-apple-system, BlinkMacSystemFont, "SF Pro Display", sans-serif`). -- Clean, minimal, information-dense. Think security operations center, not marketing page. -- Monospace font for PIDs, command lines, paths, IPs: `"SF Mono", "Menlo", monospace`. -- Subtle borders: `1px solid #1e1e2e`. No heavy shadows. - -**Layout — single page with tab navigation:** - -``` -┌──────────────────────────────────────────────────────────┐ -│ [icon] EDR Graph Agent [●] Running 12.3 evt/s │ ← Header bar -├────────┬────────┬────────┬────────┬────────┬─────────────┤ -│Overview│Findings│ Graph │ Events │ Audit │ Settings │ ← Tab bar -├────────┴────────┴────────┴────────┴────────┴─────────────┤ -│ │ -│ Tab Content │ -│ │ -└──────────────────────────────────────────────────────────┘ -``` - -#### Tab 1: Overview - -Status dashboard with key metrics in card layout: - -``` -┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ -│ Agent │ │ Events │ │ Findings │ │ Graph │ -│ ● Running │ │ 12,345 │ │ 42 total │ │ 416 nodes │ -│ Uptime: 2h │ │ 3.6/sec │ │ 5 HIGH │ │ 450 edges │ -│ 0 dropped │ │ Queue: 12 │ │ 2 CRITICAL │ │ 99 files │ -└─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ - -┌─ Active Collectors ──────────────────────────────────────┐ -│ ✓ unified_log ✓ tcpdump_dns ✓ psutil_network │ -│ ✓ fsevents ✓ persistence_poller │ -└──────────────────────────────────────────────────────────┘ - -┌─ Recent Findings ───────────────────────────────────────┐ -│ 🔴 HIGH T1543.001 LaunchAgent persistence 2m ago │ -│ 🟡 MED — Encoded command exec 5m ago │ -│ 🟢 LOW — Unusual DNS pattern 8m ago │ -└─────────────────────────────────────────────────────────┘ -``` - -- Auto-refresh every 5 seconds via `setInterval` + `fetch("/api/status")`. -- Recent findings: show last 5, clickable to jump to Findings tab with that finding selected. - -#### Tab 2: Findings - -Sortable, filterable table of all findings: - -``` -┌─ Filters: [All Severities ▼] [All Techniques ▼] [Search...] ─┐ -├────────┬──────────┬───────────────────────┬──────────┬────────┤ -│Severity│ Time │ Title │ ATT&CK │ PID │ -├────────┼──────────┼───────────────────────┼──────────┼────────┤ -│ 🔴 HIGH│ 10:30:15 │ Persistence detected │ T1543.001│ 1234 │ -│ 🟡 MED │ 10:28:02 │ Encoded command │ — │ 5678 │ -│ 🟢 LOW │ 10:25:44 │ Unusual DNS │ — │ 9012 │ -└────────┴──────────┴───────────────────────┴──────────┴────────┘ -``` - -Clicking a row expands a detail panel below the table: - -``` -┌─ Finding Detail ─────────────────────────────────────────────┐ -│ │ -│ Severity: HIGH ATT&CK: T1543.001 (Launch Agent) │ -│ Process: python3 (PID 1234) │ -│ Command: python3 tests/live/attack_simulations.py │ -│ User: thomas │ -│ Time: 2025-02-17 10:30:15 UTC │ -│ │ -│ ── LLM Analysis ────────────────────────────────────────── │ -│ The process created a LaunchAgent plist at │ -│ ~/Library/LaunchAgents/com.edr.killchain.test.plist with │ -│ RunAtLoad=True. This is a persistence mechanism... │ -│ │ -│ ── Risk Indicators ─────────────────────────────────────── │ -│ • Persistence: T1543.001 Launch Agent (HIGH) │ -│ • DGA candidate: xjk82mfq3p9a2z.xyz (score: 0.82) │ -│ │ -│ ── Response Actions ────────────────────────────────────── │ -│ [Alert sent] [Network isolation: awaiting_approval] │ -│ │ -│ [View Process Tree] [View Network Graph] [View Chain] │ -└──────────────────────────────────────────────────────────────┘ -``` - -The "View Process Tree", "View Network Graph", and "View Chain" buttons switch to the Graph tab with the relevant PID loaded. - -#### Tab 3: Graph - -Interactive graph visualizations. Three sub-views selectable via toggle buttons: - -**Process Tree View:** -- Render the process tree for a selected PID as an indented tree or a top-down hierarchy. -- Use SVG rendering (no external library — draw it with vanilla JS + SVG elements). -- Each node shows: process name, PID, and a severity badge if there are findings. -- Color-code nodes: red border if associated with HIGH/CRITICAL findings, default border otherwise. -- Clicking a node shows its details in a side panel. - -**Network Graph View:** -- Show a selected process's network footprint: Process → Domain → IP. -- Layout: process node on the left, domain nodes in the middle, IP nodes on the right. -- DGA candidate domains highlighted in orange/red. -- Render with SVG. Edges as lines/arrows between nodes. - -**Attack Chain View:** -- Full `build_attack_chain()` output rendered as a timeline or flow diagram. -- Stages: Process chain → Network activity → File activity → Persistence → Response actions. -- Each stage is a card in a horizontal or vertical flow. - -**Implementation approach for all graph views:** -- Use inline SVG generated by JavaScript. No D3, no external graphing libraries. -- Keep it simple: rectangular nodes with text, lines for edges, color coding for severity. -- The graph doesn't need to be draggable or zoomable for v1. Just readable and clear. -- Add a PID search bar at the top of the Graph tab to look up any process. - -#### Tab 4: Events - -Live event stream showing the most recent events: - -``` -┌─ Event Stream (auto-refresh) ──── [Pause] [Filter: All ▼] ──┐ -│ │ -│ 10:30:15.123 process_start python3 PID:1234 ul │ -│ 10:30:15.456 dns_resolve evil.com PID:1234 dns │ -│ 10:30:15.789 file_create /tmp/payload — fse │ -│ 10:30:16.012 network_connect 1.2.3.4:443 PID:1234 psu │ -│ 10:30:16.234 file_modify backdoor.php — fse │ -│ │ -└───────────────────────────────────────────────────────────────┘ -``` - -- Auto-scrolling feed, newest at top. -- Color-coded by event type. -- Source abbreviation on the right (ul=unified_log, dns=tcpdump, fse=fsevents, psu=psutil, pp=persistence_poller). -- Filterable by event type and source. -- Pause button to freeze the stream for reading. -- Polls `/api/events/recent` every 2 seconds. - -#### Tab 5: Audit Trail - -Table of all response actions taken: - -``` -┌────────┬──────────┬─────────────────┬────────┬──────────┬─────────┐ -│ Time │ Action │ Target │Severity│ Approved │Reverted │ -├────────┼──────────┼─────────────────┼────────┼──────────┼─────────┤ -│10:30:15│ alert │ PID 1234 │ HIGH │ auto │ — │ -│10:30:15│ isolate │ PID 1234 │ HIGH │ pending │ — │ -│10:28:02│ log_only │ PID 5678 │ MEDIUM │ auto │ — │ -└────────┴──────────┴─────────────────┴────────┴──────────┴─────────┘ -``` - -- Pending approvals have an [Approve] [Deny] button that calls `POST /api/response/approve/:id`. -- Reverted actions are shown with strikethrough. - -#### Tab 6: Settings - -Read-only display of the current agent configuration. Shows: -- Collector configuration (which collectors are active, watched paths, etc.) -- Analysis settings (LLM model, DGA thresholds, persistence paths) -- Response policy (auto_respond, auto_terminate, protected processes) -- Dashboard port, metrics port - -No editing — config changes require restarting the agent with a modified config.yaml. This tab is informational. - -#### Frontend Technical Requirements - -- **Single file: `index.html`** — all CSS in a `