From 7193a3fdad464c5dad6f98ce8445c1496f95f115 Mon Sep 17 00:00:00 2001 From: Karib0u Date: Sun, 3 May 2026 15:28:56 +0200 Subject: [PATCH 1/2] feat: implement YARA memory scanning with configurable options and background processing --- Cargo.toml | 2 +- docs/configuration.md | 17 ++ docs/detection.md | 12 + docs/troubleshooting.md | 31 +++ src/config.rs | 37 +++ src/main.rs | 267 +++++++++++++++++++++- src/memory.rs | 492 ++++++++++++++++++++++++++++++++++++++++ src/scanner/mod.rs | 167 +++++++++----- 8 files changed, 968 insertions(+), 57 deletions(-) create mode 100644 src/memory.rs diff --git a/Cargo.toml b/Cargo.toml index 772426e..4dcde83 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -65,7 +65,7 @@ libc = "0.2" pelite = "0.10" memmap2 = "0.9" ferrisetw = "1.2" -windows = { version = "0.62.2", features = ["Win32_Foundation", "Win32_System_Diagnostics_Etw", "Win32_Security", "Win32_Security_Authorization", "Win32_System_Threading", "Win32_System_Diagnostics_ToolHelp", "Win32_System_ProcessStatus", "Win32_Storage_FileSystem"] } +windows = { version = "0.62.2", features = ["Win32_Foundation", "Win32_System_Diagnostics_Etw", "Win32_Security", "Win32_Security_Authorization", "Win32_System_Threading", "Win32_System_Diagnostics_ToolHelp", "Win32_System_ProcessStatus", "Win32_Storage_FileSystem", "Win32_System_Memory", "Win32_System_Diagnostics_Debug"] } windows-service = "0.8.0" [dev-dependencies] diff --git a/docs/configuration.md b/docs/configuration.md index 3413441..adc0fc2 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -26,6 +26,15 @@ sigma_rules_path = "rules/sigma" yara_enabled = true yara_rules_path = "rules/yara" +# Memory scanning is off by default. +# yara_memory_enabled = false +# yara_memory_delay_ms = 750 +# yara_memory_max_process_mb = 64 +# yara_memory_max_region_mb = 8 +# yara_memory_include_private = true +# yara_memory_include_image = false +# yara_memory_include_mapped = false + [reload] enabled = true debounce_ms = 2000 @@ -124,6 +133,14 @@ These defaults feed `allowlist.paths`, which then propagate to active response, | `yara_enabled` | `true` | Enable YARA scanning | | `yara_rules_path` | `rules/yara` | YARA rules directory | | `yara_allowlist_paths` | inherits `allowlist.paths` | Prefix paths skipped by YARA queueing and scanning | +| `yara_memory_enabled` | `false` | Enable YARA memory scanning (requires `yara_enabled = true`) | +| `yara_memory_queue_capacity` | `64` | Maximum pending memory scan jobs before new ones are dropped | +| `yara_memory_delay_ms` | `750` | Milliseconds to wait after process start before reading memory | +| `yara_memory_max_process_mb` | `64` | Stop reading a process once this many MB have been accumulated | +| `yara_memory_max_region_mb` | `8` | Clamp each region read to this many MB | +| `yara_memory_include_private` | `true` | Scan private (anonymous) memory regions | +| `yara_memory_include_image` | `false` | Scan image-backed regions (loaded executables/DLLs) | +| `yara_memory_include_mapped` | `false` | Scan file-mapped regions | ### Reload diff --git a/docs/detection.md b/docs/detection.md index a33d16b..8237ee1 100644 --- a/docs/detection.md +++ b/docs/detection.md @@ -138,6 +138,18 @@ YARA scanning is shared across both supported platforms. Every YARA match is emitted as a `critical` alert. +### YARA memory scanning + +YARA memory scanning is optional and disabled by default (`scanner.yara_memory_enabled = false`). + +When enabled, Rustinel queues process IDs from process-start events to a bounded background worker. The worker waits a configurable delay (`yara_memory_delay_ms`, default 750 ms) to allow packers or loaders to finish unpacking, then reads a limited amount of selected process memory and scans it with the active YARA ruleset. + +Default behavior scans private readable memory only and avoids mapped or image-backed regions to reduce overhead and false positives. Each matching YARA rule emits its own `critical` alert. The alert `provider` field is set to `yara-memory` to distinguish memory hits from file hits (`etw` or `ebpf`). + +Memory scanning follows the same allowlist as file YARA: process paths allowlisted via `scanner.yara_allowlist_paths` are not queued for memory scanning either. + +The worker uses `try_send` so a full queue drops jobs rather than blocking the sensor event path. + ## IOC The IOC engine hot reloads indicator files and splits work between inline event checks and a background hash worker. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 3dc7d07..15dc08c 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -170,6 +170,37 @@ Typical symptom in logs: YARA queue full; dropping scan job ``` +### YARA memory scan produces no alerts + +Check these first: + +- `scanner.yara_memory_enabled` must be `true` and `scanner.yara_enabled` must also be `true` +- the process path may be allowlisted via `scanner.yara_allowlist_paths` +- the memory scan queue may have been full and the job dropped (look for `YARA memory queue full; dropping scan job`) +- the process may have exited before the scan ran (the worker waits `yara_memory_delay_ms` first) +- per-region or per-process byte caps may have prevented reading the region containing the match +- insufficient privileges may prevent reading process memory (see below) + +#### Linux memory scanning privileges + +On Linux, reading `/proc//mem` typically requires root or the `CAP_SYS_PTRACE` capability. You may also need to set a permissive ptrace scope: + +```bash +echo 0 | sudo tee /proc/sys/kernel/yama/ptrace_scope +``` + +Without adequate privileges, region reads will fail silently (logged at `trace`). + +#### Windows memory scanning privileges + +On Windows, `OpenProcess` with `PROCESS_VM_READ` may fail for: + +- protected processes (`PROTECTED_PROCESS_LIGHT` or `PROTECTED_PROCESS`) +- system processes with elevated integrity levels +- some anti-tamper or security software + +These failures are logged at `trace` and do not affect other detection paths. + ### IOC hash matching did not fire Hash matching is more selective than inline IOC checks. diff --git a/src/config.rs b/src/config.rs index 5ac7aca..8b47eaf 100644 --- a/src/config.rs +++ b/src/config.rs @@ -64,6 +64,14 @@ pub struct ScannerConfig { pub yara_enabled: bool, pub yara_rules_path: PathBuf, pub yara_allowlist_paths: Vec, + pub yara_memory_enabled: bool, + pub yara_memory_queue_capacity: usize, + pub yara_memory_delay_ms: u64, + pub yara_memory_max_process_mb: u64, + pub yara_memory_max_region_mb: u64, + pub yara_memory_include_private: bool, + pub yara_memory_include_image: bool, + pub yara_memory_include_mapped: bool, } /// Global allowlist configuration shared across modules @@ -145,6 +153,14 @@ impl AppConfig { .set_default("scanner.yara_enabled", true)? .set_default("scanner.yara_rules_path", "rules/yara")? .set_default("scanner.yara_allowlist_paths", Vec::::new())? + .set_default("scanner.yara_memory_enabled", false)? + .set_default("scanner.yara_memory_queue_capacity", 64i64)? + .set_default("scanner.yara_memory_delay_ms", 750i64)? + .set_default("scanner.yara_memory_max_process_mb", 64i64)? + .set_default("scanner.yara_memory_max_region_mb", 8i64)? + .set_default("scanner.yara_memory_include_private", true)? + .set_default("scanner.yara_memory_include_image", false)? + .set_default("scanner.yara_memory_include_mapped", false)? // Logging .set_default("logging.level", "info")? .set_default("logging.directory", "logs")? @@ -215,6 +231,14 @@ impl Default for AppConfig { yara_enabled: true, yara_rules_path: PathBuf::from("rules/yara"), yara_allowlist_paths: Vec::new(), + yara_memory_enabled: false, + yara_memory_queue_capacity: 64, + yara_memory_delay_ms: 750, + yara_memory_max_process_mb: 64, + yara_memory_max_region_mb: 8, + yara_memory_include_private: true, + yara_memory_include_image: false, + yara_memory_include_mapped: false, }, logging: LogConfig { level: "info".to_string(), @@ -307,6 +331,19 @@ mod tests { assert_eq!(cfg.scanner.yara_allowlist_paths, cfg.allowlist.paths); } + #[test] + fn test_yara_memory_defaults_disabled() { + let cfg = AppConfig::default(); + assert!(!cfg.scanner.yara_memory_enabled); + assert_eq!(cfg.scanner.yara_memory_queue_capacity, 64); + assert_eq!(cfg.scanner.yara_memory_max_process_mb, 64); + assert_eq!(cfg.scanner.yara_memory_max_region_mb, 8); + assert_eq!(cfg.scanner.yara_memory_delay_ms, 750); + assert!(cfg.scanner.yara_memory_include_private); + assert!(!cfg.scanner.yara_memory_include_image); + assert!(!cfg.scanner.yara_memory_include_mapped); + } + #[test] fn test_module_specific_allowlist_not_overwritten() { let mut cfg = AppConfig::default(); diff --git a/src/main.rs b/src/main.rs index d3e23bb..d9e3877 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,6 +8,7 @@ mod alerts; mod config; mod engine; mod ioc; +mod memory; mod models; mod normalizer; mod reload; @@ -25,6 +26,8 @@ use engine::{Engine, SigmaDetectionHandler}; #[cfg(any(windows, target_os = "linux"))] use ioc::{HashCache, HashRequirements, IocEngine}; #[cfg(any(windows, target_os = "linux"))] +use memory::MemoryScanConfig; +#[cfg(any(windows, target_os = "linux"))] use models::{ Alert, AlertSeverity, DetectionEngine, EventCategory, EventFields, MatchDebugLevel, MatchDetails, NormalizedEvent, ProcessCreationFields, YaraMatchDetails, YaraRuleMatch, @@ -36,7 +39,7 @@ use reload::DetectorStore; #[cfg(any(windows, target_os = "linux"))] use response::ResponseEngine; #[cfg(any(windows, target_os = "linux"))] -use scanner::YaraEventHandler; +use scanner::{YaraEventHandler, YaraMemoryJob}; #[cfg(any(windows, target_os = "linux"))] use sensor::{Platform, Sensor, SensorEvent, SensorEventRouter}; #[cfg(any(windows, target_os = "linux"))] @@ -648,6 +651,83 @@ fn build_yara_alert( } } +#[cfg(any(windows, target_os = "linux"))] +fn build_yara_memory_match_details( + match_debug: MatchDebugLevel, + rule_match: &YaraRuleMatch, + chunk: &memory::MemoryChunk, +) -> Option { + if matches!(match_debug, MatchDebugLevel::Off) { + return None; + } + + let summary = format!( + "matched YARA rule {} in process memory at 0x{:x} {:?} {}{}{}", + rule_match.rule, + chunk.base, + chunk.region.kind, + if chunk.region.readable { 'r' } else { '-' }, + if chunk.region.writable { 'w' } else { '-' }, + if chunk.region.executable { 'x' } else { '-' }, + ); + + let mut rule = rule_match.clone(); + if !matches!(match_debug, MatchDebugLevel::Full) { + rule.strings.clear(); + } + + Some(MatchDetails { + summary, + sigma: None, + yara: Some(YaraMatchDetails { rules: vec![rule] }), + }) +} + +#[cfg(any(windows, target_os = "linux"))] +fn build_yara_memory_alert( + rule_name: &str, + image: &str, + pid: u32, + match_details: Option, + platform: Platform, + provider: &str, +) -> Alert { + Alert { + severity: AlertSeverity::Critical, + rule_name: rule_name.to_string(), + rule_description: None, + engine: DetectionEngine::Yara, + event: NormalizedEvent { + timestamp: utils::now_timestamp_string(), + platform, + provider: provider.to_string(), + category: EventCategory::Process, + event_id: 1, + event_id_string: "1".to_string(), + opcode: 1, + fields: EventFields::ProcessCreation(ProcessCreationFields { + image: Some(image.to_string()), + original_file_name: None, + product: None, + description: None, + target_image: None, + command_line: None, + process_id: Some(pid.to_string()), + parent_process_id: None, + parent_image: None, + parent_command_line: None, + current_directory: None, + integrity_level: None, + user: None, + logon_id: None, + logon_guid: None, + }), + process_context: None, + }, + match_details, + } +} + // ── Windows ETW EDR ─────────────────────────────────────────────────────────── // Native API FFI structures for NtQuerySystemInformation @@ -1091,6 +1171,16 @@ async fn run_edr( // Buffer = 1000 items. If 1000 processes start instantly, we drop events rather than blocking. let (tx, mut rx) = mpsc::channel::<(String, u32)>(1000); + // Create optional YARA memory scanning channel. + let (yara_memory_tx, yara_memory_rx) = + if cfg.scanner.yara_enabled && cfg.scanner.yara_memory_enabled { + let capacity = cfg.scanner.yara_memory_queue_capacity.max(1); + let (tx, rx) = mpsc::channel::(capacity); + (Some(tx), Some(rx)) + } else { + (None, None) + }; + // Initialize IOC engine let ioc_engine = Arc::new(IocEngine::load(&cfg.ioc)); if ioc_engine.is_enabled() { @@ -1230,6 +1320,83 @@ async fn run_edr( info!(target: "scanner", "YARA worker thread shutting down"); }); + // Spawn optional YARA memory scanning worker. + let yara_memory_worker_handle = if let Some(mut mem_rx) = yara_memory_rx { + let detectors_for_mem = Arc::clone(&detectors); + let alert_sink_for_mem = alert_sink.clone(); + let response_engine_for_mem = response_engine.clone(); + let mem_cfg = MemoryScanConfig { + max_process_bytes: (cfg.scanner.yara_memory_max_process_mb * 1024 * 1024) as usize, + max_region_bytes: (cfg.scanner.yara_memory_max_region_mb * 1024 * 1024) as usize, + include_private: cfg.scanner.yara_memory_include_private, + include_image: cfg.scanner.yara_memory_include_image, + include_mapped: cfg.scanner.yara_memory_include_mapped, + delay_ms: cfg.scanner.yara_memory_delay_ms, + }; + let mem_match_debug = cfg.alerts.match_debug; + Some(tokio::task::spawn_blocking(move || { + info!(target: "scanner", "YARA memory worker started"); + while let Some(job) = mem_rx.blocking_recv() { + std::thread::sleep(Duration::from_millis(mem_cfg.delay_ms)); + let chunks = match memory::read_process_memory_chunks(job.pid, &mem_cfg) { + Ok(c) => c, + Err(err) => { + tracing::trace!( + target: "scanner", + pid = job.pid, + image = %job.image, + error = %err, + "YARA memory scan skipped" + ); + continue; + } + }; + let scanner = detectors_for_mem.yara(); + for chunk in &chunks { + let matches = match scanner.scan_bytes(&chunk.bytes, mem_match_debug) { + Ok(m) => m, + Err(err) => { + tracing::trace!( + target: "scanner", + pid = job.pid, + error = %err, + "YARA memory chunk scan failed" + ); + continue; + } + }; + if !matches.is_empty() { + let rule_names: Vec = + matches.iter().map(|r| r.rule.clone()).collect(); + warn!( + pid = job.pid, + image = %job.image, + rules = ?rule_names, + "YARA memory detection triggered" + ); + for rule_match in &matches { + let details = + build_yara_memory_match_details(mem_match_debug, rule_match, chunk); + let alert = build_yara_memory_alert( + &rule_match.rule, + &job.image, + job.pid, + details, + Platform::Windows, + "yara-memory", + ); + alert_sink_for_mem.write_alert(&alert); + response_engine_for_mem.handle_alert(&alert); + } + } + } + } + info!(target: "scanner", "YARA memory worker shutting down"); + })) + } else { + None + }; + // Create background worker channel for IOC hashing (process start only) // Uses spawn_blocking to avoid starving the tokio async thread pool with // CPU-bound crypto work and synchronous file I/O. @@ -1385,6 +1552,7 @@ async fn run_edr( // Create YARA event handler let yara_handler = YaraEventHandler { tx, + memory_tx: yara_memory_tx, allowlist_paths: yara_allowlist_paths, }; @@ -1489,6 +1657,13 @@ async fn run_edr( Err(e) => error!("Failed to join YARA worker thread: {}", e), } + if let Some(handle) = yara_memory_worker_handle { + match handle.await { + Ok(_) => info!("YARA memory worker thread finished"), + Err(e) => error!("Failed to join YARA memory worker thread: {}", e), + } + } + if let Some(handle) = ioc_hash_worker_handle.take() { info!("Signaling IOC hash worker to shut down..."); match handle.await { @@ -1642,6 +1817,15 @@ async fn run_linux_edr() -> anyhow::Result<()> { // 9. YARA background worker let (yara_tx, mut yara_rx) = mpsc::channel::<(String, u32)>(1000); + + let (yara_memory_tx, yara_memory_rx) = + if cfg.scanner.yara_enabled && cfg.scanner.yara_memory_enabled { + let capacity = cfg.scanner.yara_memory_queue_capacity.max(1); + let (tx, rx) = mpsc::channel::(capacity); + (Some(tx), Some(rx)) + } else { + (None, None) + }; let detectors_for_yara = Arc::clone(&detectors); let yara_allowlist_paths_for_worker = yara_allowlist_paths.clone(); let alert_sink_for_yara = alert_sink.clone(); @@ -1688,6 +1872,83 @@ async fn run_linux_edr() -> anyhow::Result<()> { } }); + // Spawn optional YARA memory scanning worker (Linux). + let yara_memory_worker_handle = if let Some(mut mem_rx) = yara_memory_rx { + let detectors_for_mem = Arc::clone(&detectors); + let alert_sink_for_mem = alert_sink.clone(); + let response_engine_for_mem = response_engine.clone(); + let mem_cfg = MemoryScanConfig { + max_process_bytes: (cfg.scanner.yara_memory_max_process_mb * 1024 * 1024) as usize, + max_region_bytes: (cfg.scanner.yara_memory_max_region_mb * 1024 * 1024) as usize, + include_private: cfg.scanner.yara_memory_include_private, + include_image: cfg.scanner.yara_memory_include_image, + include_mapped: cfg.scanner.yara_memory_include_mapped, + delay_ms: cfg.scanner.yara_memory_delay_ms, + }; + let mem_match_debug = cfg.alerts.match_debug; + Some(tokio::task::spawn_blocking(move || { + info!(target: "scanner", "YARA memory worker started"); + while let Some(job) = mem_rx.blocking_recv() { + std::thread::sleep(Duration::from_millis(mem_cfg.delay_ms)); + let chunks = match memory::read_process_memory_chunks(job.pid, &mem_cfg) { + Ok(c) => c, + Err(err) => { + tracing::trace!( + target: "scanner", + pid = job.pid, + image = %job.image, + error = %err, + "YARA memory scan skipped" + ); + continue; + } + }; + let scanner = detectors_for_mem.yara(); + for chunk in &chunks { + let matches = match scanner.scan_bytes(&chunk.bytes, mem_match_debug) { + Ok(m) => m, + Err(err) => { + tracing::trace!( + target: "scanner", + pid = job.pid, + error = %err, + "YARA memory chunk scan failed" + ); + continue; + } + }; + if !matches.is_empty() { + let rule_names: Vec = + matches.iter().map(|r| r.rule.clone()).collect(); + warn!( + pid = job.pid, + image = %job.image, + rules = ?rule_names, + "YARA memory detection triggered" + ); + for rule_match in &matches { + let details = + build_yara_memory_match_details(mem_match_debug, rule_match, chunk); + let alert = build_yara_memory_alert( + &rule_match.rule, + &job.image, + job.pid, + details, + Platform::Linux, + "yara-memory", + ); + alert_sink_for_mem.write_alert(&alert); + response_engine_for_mem.handle_alert(&alert); + } + } + } + } + info!(target: "scanner", "YARA memory worker shutting down"); + })) + } else { + None + }; + // 10. IOC hash background worker let (ioc_hash_tx, mut ioc_hash_worker_handle) = if ioc_engine.is_enabled() { let (hash_tx, mut hash_rx) = mpsc::channel::<(String, u32)>(1000); @@ -1781,6 +2042,7 @@ async fn run_linux_edr() -> anyhow::Result<()> { }; let yara_handler = YaraEventHandler { tx: yara_tx, + memory_tx: yara_memory_tx, allowlist_paths: yara_allowlist_paths, }; let mut router_inner = SensorEventRouter::new(); @@ -1820,6 +2082,9 @@ async fn run_linux_edr() -> anyhow::Result<()> { drop(response_engine); let _ = sensor_worker_handle.await; let _ = yara_worker_handle.await; + if let Some(h) = yara_memory_worker_handle { + let _ = h.await; + } if let Some(h) = ioc_hash_worker_handle.take() { let _ = h.await; } diff --git a/src/memory.rs b/src/memory.rs new file mode 100644 index 0000000..044a38b --- /dev/null +++ b/src/memory.rs @@ -0,0 +1,492 @@ +//! Process memory reader for YARA memory scanning. +//! +//! Reads selected memory regions from a live process into byte chunks that +//! can then be passed to `Scanner::scan_bytes`. All I/O failures are treated +//! as non-fatal — callers receive whatever chunks were successfully read. + +use anyhow::Result; + +/// Per-scan limits and region-type filters. +#[derive(Debug, Clone)] +pub struct MemoryScanConfig { + /// Stop reading a process once this many bytes have been accumulated. + pub max_process_bytes: usize, + /// Clamp each region read to this many bytes. + pub max_region_bytes: usize, + pub include_private: bool, + pub include_image: bool, + pub include_mapped: bool, + /// Milliseconds to wait before scanning (gives packers time to unpack). + pub delay_ms: u64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MemoryRegionKind { + Private, + Image, + Mapped, + Other, +} + +#[derive(Debug, Clone)] +pub struct MemoryRegion { + pub base: u64, + pub size: usize, + pub readable: bool, + pub writable: bool, + pub executable: bool, + pub kind: MemoryRegionKind, +} + +#[derive(Debug)] +pub struct MemoryChunk { + pub base: u64, + pub bytes: Vec, + pub region: MemoryRegion, +} + +/// Read selected memory regions from `pid` according to `cfg`. +/// Returns whatever chunks could be read; individual region failures are silently skipped. +pub fn read_process_memory_chunks(pid: u32, cfg: &MemoryScanConfig) -> Result> { + platform::read_process_memory_chunks(pid, cfg) +} + +// ── Windows implementation ──────────────────────────────────────────────────── + +#[cfg(windows)] +mod platform { + use super::{MemoryChunk, MemoryRegion, MemoryRegionKind, MemoryScanConfig}; + use anyhow::Result; + use windows::Win32::Foundation::CloseHandle; + use windows::Win32::System::Diagnostics::Debug::ReadProcessMemory; + use windows::Win32::System::Memory::{ + VirtualQueryEx, MEMORY_BASIC_INFORMATION, MEM_COMMIT, MEM_IMAGE, MEM_MAPPED, MEM_PRIVATE, + PAGE_EXECUTE, PAGE_EXECUTE_READ, PAGE_EXECUTE_READWRITE, PAGE_EXECUTE_WRITECOPY, + PAGE_GUARD, PAGE_NOACCESS, PAGE_PROTECTION_FLAGS, PAGE_READONLY, PAGE_READWRITE, + PAGE_WRITECOPY, + }; + use windows::Win32::System::Threading::{ + OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_VM_READ, + }; + + fn is_readable(protect: PAGE_PROTECTION_FLAGS) -> bool { + matches!( + protect, + PAGE_READONLY + | PAGE_READWRITE + | PAGE_WRITECOPY + | PAGE_EXECUTE_READ + | PAGE_EXECUTE_READWRITE + | PAGE_EXECUTE_WRITECOPY + ) + } + + fn is_writable(protect: PAGE_PROTECTION_FLAGS) -> bool { + matches!( + protect, + PAGE_READWRITE | PAGE_WRITECOPY | PAGE_EXECUTE_READWRITE | PAGE_EXECUTE_WRITECOPY + ) + } + + fn is_executable(protect: PAGE_PROTECTION_FLAGS) -> bool { + matches!( + protect, + PAGE_EXECUTE | PAGE_EXECUTE_READ | PAGE_EXECUTE_READWRITE | PAGE_EXECUTE_WRITECOPY + ) + } + + pub fn read_process_memory_chunks( + pid: u32, + cfg: &MemoryScanConfig, + ) -> Result> { + let handle = unsafe { + OpenProcess( + PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_VM_READ, + false, + pid, + ) + }; + + let handle = match handle { + Ok(h) if !h.is_invalid() => h, + Ok(_) | Err(_) => { + tracing::trace!( + target: "scanner", + pid = pid, + "YARA memory: OpenProcess failed (process may have exited)" + ); + return Ok(Vec::new()); + } + }; + + let mut chunks = Vec::new(); + let mut address: usize = 0; + let mut total_bytes: usize = 0; + + loop { + if total_bytes >= cfg.max_process_bytes { + break; + } + + let mut mbi = MEMORY_BASIC_INFORMATION::default(); + let written = unsafe { + VirtualQueryEx( + handle, + Some(address as *const _), + &mut mbi, + std::mem::size_of::(), + ) + }; + + if written == 0 { + break; + } + + let region_base = mbi.BaseAddress as usize; + let region_size = mbi.RegionSize; + + // Advance cursor regardless of whether we read this region. + address = match region_base.checked_add(region_size) { + Some(next) => next, + None => break, + }; + + // Only scan committed pages. + if mbi.State != MEM_COMMIT { + continue; + } + + let protect = mbi.Protect; + + // Skip inaccessible and guard pages. + if protect == PAGE_NOACCESS || protect.contains(PAGE_GUARD) { + continue; + } + + if !is_readable(protect) { + continue; + } + + let kind = if mbi.Type == MEM_PRIVATE { + MemoryRegionKind::Private + } else if mbi.Type == MEM_IMAGE { + MemoryRegionKind::Image + } else if mbi.Type == MEM_MAPPED { + MemoryRegionKind::Mapped + } else { + MemoryRegionKind::Other + }; + + let include = match kind { + MemoryRegionKind::Private => cfg.include_private, + MemoryRegionKind::Image => cfg.include_image, + MemoryRegionKind::Mapped => cfg.include_mapped, + MemoryRegionKind::Other => false, + }; + + if !include { + continue; + } + + let read_size = region_size + .min(cfg.max_region_bytes) + .min(cfg.max_process_bytes - total_bytes); + + let mut buf = vec![0u8; read_size]; + let mut bytes_read: usize = 0; + + let ok = unsafe { + ReadProcessMemory( + handle, + region_base as *const _, + buf.as_mut_ptr() as *mut _, + read_size, + Some(&mut bytes_read), + ) + }; + + if ok.is_err() || bytes_read == 0 { + tracing::trace!( + target: "scanner", + pid = pid, + base = format_args!("0x{:x}", region_base), + "YARA memory: ReadProcessMemory failed (normal for guard/exited process)" + ); + continue; + } + + buf.truncate(bytes_read); + total_bytes += bytes_read; + + let region = MemoryRegion { + base: region_base as u64, + size: region_size, + readable: true, + writable: is_writable(protect), + executable: is_executable(protect), + kind, + }; + + chunks.push(MemoryChunk { + base: region_base as u64, + bytes: buf, + region, + }); + } + + unsafe { + let _ = CloseHandle(handle); + } + + Ok(chunks) + } +} + +// ── Linux implementation ────────────────────────────────────────────────────── + +#[cfg(target_os = "linux")] +mod platform { + use super::{MemoryChunk, MemoryRegion, MemoryRegionKind, MemoryScanConfig}; + use anyhow::Result; + use std::fs::File; + use std::io::{Read, Seek, SeekFrom}; + + struct MapsEntry { + start: u64, + end: u64, + readable: bool, + writable: bool, + executable: bool, + private: bool, + path: Option, + } + + fn parse_maps_line(line: &str) -> Option { + let mut parts = line.splitn(6, ' '); + let addr_range = parts.next()?; + let perms = parts.next()?; + let _offset = parts.next()?; + let _device = parts.next()?; + let _inode = parts.next()?; + let path = parts + .next() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()); + + let (start_str, end_str) = addr_range.split_once('-')?; + let start = u64::from_str_radix(start_str, 16).ok()?; + let end = u64::from_str_radix(end_str, 16).ok()?; + + let readable = perms.starts_with('r'); + let writable = perms.len() > 1 && perms.chars().nth(1) == Some('w'); + let executable = perms.len() > 2 && perms.chars().nth(2) == Some('x'); + let private = perms.len() > 3 && perms.chars().nth(3) == Some('p'); + + Some(MapsEntry { + start, + end, + readable, + writable, + executable, + private, + path, + }) + } + + fn classify_region(path: Option<&str>) -> MemoryRegionKind { + match path { + None | Some("") => MemoryRegionKind::Private, + Some(p) if p.starts_with('[') => MemoryRegionKind::Other, + Some(_) => MemoryRegionKind::Mapped, + } + } + + pub fn read_process_memory_chunks( + pid: u32, + cfg: &MemoryScanConfig, + ) -> Result> { + let maps_path = format!("/proc/{}/maps", pid); + let mem_path = format!("/proc/{}/mem", pid); + + let maps_content = match std::fs::read_to_string(&maps_path) { + Ok(s) => s, + Err(err) => { + tracing::trace!( + target: "scanner", + pid = pid, + error = %err, + "YARA memory: cannot read /proc//maps" + ); + return Ok(Vec::new()); + } + }; + + let mut mem_file = match File::open(&mem_path) { + Ok(f) => f, + Err(err) => { + tracing::trace!( + target: "scanner", + pid = pid, + error = %err, + "YARA memory: cannot open /proc//mem" + ); + return Ok(Vec::new()); + } + }; + + let mut chunks = Vec::new(); + let mut total_bytes: usize = 0; + + for line in maps_content.lines() { + if total_bytes >= cfg.max_process_bytes { + break; + } + + let entry = match parse_maps_line(line) { + Some(e) => e, + None => continue, + }; + + if !entry.readable { + continue; + } + + let path_ref = entry.path.as_deref(); + + // Skip special kernel-mapped regions. + if let Some(p) = path_ref { + if matches!(p, "[vvar]" | "[vdso]" | "[vsyscall]") { + continue; + } + } + + let kind = if entry.private && entry.path.is_none() { + MemoryRegionKind::Private + } else { + classify_region(path_ref) + }; + + let include = match kind { + MemoryRegionKind::Private => cfg.include_private, + MemoryRegionKind::Image => cfg.include_image, + MemoryRegionKind::Mapped => cfg.include_mapped, + MemoryRegionKind::Other => false, + }; + + if !include { + continue; + } + + let region_size = (entry.end - entry.start) as usize; + let read_size = region_size + .min(cfg.max_region_bytes) + .min(cfg.max_process_bytes - total_bytes); + + if read_size == 0 { + break; + } + + if mem_file.seek(SeekFrom::Start(entry.start)).is_err() { + continue; + } + + let mut buf = vec![0u8; read_size]; + let bytes_read = match mem_file.read(&mut buf) { + Ok(n) => n, + Err(err) => { + tracing::trace!( + target: "scanner", + pid = pid, + base = format_args!("0x{:x}", entry.start), + error = %err, + "Unable to read memory region" + ); + continue; + } + }; + + if bytes_read == 0 { + continue; + } + + buf.truncate(bytes_read); + total_bytes += bytes_read; + + let region = MemoryRegion { + base: entry.start, + size: region_size, + readable: true, + writable: entry.writable, + executable: entry.executable, + kind, + }; + + chunks.push(MemoryChunk { + base: entry.start, + bytes: buf, + region, + }); + } + + Ok(chunks) + } +} + +// Stub for unsupported platforms so the crate still compiles. +#[cfg(not(any(windows, target_os = "linux")))] +mod platform { + use super::{MemoryChunk, MemoryScanConfig}; + use anyhow::Result; + + pub fn read_process_memory_chunks( + _pid: u32, + _cfg: &MemoryScanConfig, + ) -> Result> { + Ok(Vec::new()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_memory_scan_config_fields() { + let cfg = MemoryScanConfig { + max_process_bytes: 64 * 1024 * 1024, + max_region_bytes: 8 * 1024 * 1024, + include_private: true, + include_image: false, + include_mapped: false, + delay_ms: 750, + }; + assert_eq!(cfg.max_process_bytes, 64 * 1024 * 1024); + assert_eq!(cfg.max_region_bytes, 8 * 1024 * 1024); + assert!(cfg.include_private); + assert!(!cfg.include_image); + assert!(!cfg.include_mapped); + } + + #[test] + fn test_memory_region_kind_variants() { + assert_ne!(MemoryRegionKind::Private, MemoryRegionKind::Image); + assert_ne!(MemoryRegionKind::Mapped, MemoryRegionKind::Other); + } + + #[cfg(target_os = "linux")] + #[test] + fn test_read_nonexistent_pid_returns_empty() { + let cfg = MemoryScanConfig { + max_process_bytes: 1024, + max_region_bytes: 512, + include_private: true, + include_image: true, + include_mapped: true, + delay_ms: 0, + }; + // PID 99999999 should not exist; expect Ok(empty). + let result = read_process_memory_chunks(99_999_999, &cfg); + assert!(result.is_ok()); + assert!(result.unwrap().is_empty()); + } +} diff --git a/src/scanner/mod.rs b/src/scanner/mod.rs index d2a8f9e..2a93913 100644 --- a/src/scanner/mod.rs +++ b/src/scanner/mod.rs @@ -200,6 +200,13 @@ fn truncate_str(s: &str, max_len: usize) -> String { out } +/// Job queued from a process-start event for background memory scanning. +#[derive(Debug, Clone)] +pub struct YaraMemoryJob { + pub pid: u32, + pub image: String, +} + /// Main Scanner struct holding compiled rules pub struct Scanner { rules: Rules, @@ -290,63 +297,10 @@ impl Scanner { let mut scan_ok = false; let mut scanner = XScanner::new(&self.rules); - // Scan the file match scanner.scan_file(path) { Ok(scan_results) => { scan_ok = true; - for rule in scan_results.matching_rules() { - let rule_name = rule.identifier().to_string(); - let include_meta = !matches!(match_debug, MatchDebugLevel::Off); - let include_strings = matches!(match_debug, MatchDebugLevel::Full); - - let tags = if include_meta { - rule.tags() - .map(|tag| tag.identifier().to_string()) - .collect() - } else { - Vec::new() - }; - - let namespace = if include_meta { - Some(rule.namespace().to_string()) - } else { - None - }; - - let mut strings = Vec::new(); - if include_strings { - let mut count = 0usize; - for pattern in rule.patterns() { - let pattern_id = pattern.identifier().to_string(); - for m in pattern.matches() { - if count >= MAX_YARA_STRINGS_PER_RULE { - break; - } - let offset = m.range().start as u64; - let snippet_raw = String::from_utf8_lossy(m.data()).to_string(); - let snippet = truncate_str(&snippet_raw, MAX_YARA_SNIPPET_LEN); - - strings.push(YaraStringMatch { - id: pattern_id.clone(), - offset: Some(offset), - snippet: Some(snippet), - }); - - count += 1; - } - if count >= MAX_YARA_STRINGS_PER_RULE { - break; - } - } - } - - matches.push(YaraRuleMatch { - rule: rule_name, - tags, - namespace, - strings, - }); - } + matches = collect_yara_matches(scan_results, match_debug); } Err(e) => { // File locking issues are common in EDR; keep these at trace to avoid debug spam. @@ -369,11 +323,93 @@ impl Scanner { Ok(matches) } + + /// Scan a byte slice and return matching rule details. + pub fn scan_bytes( + &self, + data: &[u8], + match_debug: MatchDebugLevel, + ) -> Result> { + let mut scanner = XScanner::new(&self.rules); + match scanner.scan(data) { + Ok(scan_results) => Ok(collect_yara_matches(scan_results, match_debug)), + Err(err) => { + tracing::trace!( + target: "scanner", + error = %err, + "Skipping YARA memory chunk" + ); + Ok(Vec::new()) + } + } + } +} + +fn collect_yara_matches( + scan_results: yara_x::ScanResults, + match_debug: MatchDebugLevel, +) -> Vec { + let mut matches = Vec::new(); + + for rule in scan_results.matching_rules() { + let rule_name = rule.identifier().to_string(); + let include_meta = !matches!(match_debug, MatchDebugLevel::Off); + let include_strings = matches!(match_debug, MatchDebugLevel::Full); + + let tags = if include_meta { + rule.tags() + .map(|tag| tag.identifier().to_string()) + .collect() + } else { + Vec::new() + }; + + let namespace = if include_meta { + Some(rule.namespace().to_string()) + } else { + None + }; + + let mut strings = Vec::new(); + if include_strings { + let mut count = 0usize; + for pattern in rule.patterns() { + let pattern_id = pattern.identifier().to_string(); + for m in pattern.matches() { + if count >= MAX_YARA_STRINGS_PER_RULE { + break; + } + let offset = m.range().start as u64; + let snippet_raw = String::from_utf8_lossy(m.data()).to_string(); + let snippet = truncate_str(&snippet_raw, MAX_YARA_SNIPPET_LEN); + strings.push(YaraStringMatch { + id: pattern_id.clone(), + offset: Some(offset), + snippet: Some(snippet), + }); + count += 1; + } + if count >= MAX_YARA_STRINGS_PER_RULE { + break; + } + } + } + + matches.push(YaraRuleMatch { + rule: rule_name, + tags, + namespace, + strings, + }); + } + + matches } /// Sensor-event handler that sends file paths to the background worker. pub struct YaraEventHandler { - pub tx: Sender<(String, u32)>, // Sends (FilePath, PID) + pub tx: Sender<(String, u32)>, + pub memory_tx: Option>, pub allowlist_paths: Vec, } @@ -428,6 +464,27 @@ impl SensorEventHandler for YaraEventHandler { "YARA queue full; dropping scan job" ), } + + if let Some(memory_tx) = &self.memory_tx { + match memory_tx.try_send(YaraMemoryJob { + pid, + image: path.to_string(), + }) { + Ok(_) => tracing::trace!( + target: "scanner", + pid = pid, + file = path, + "YARA queued process for memory scan" + ), + Err(err) => warn!( + target: "scanner", + pid = pid, + file = path, + error = %err, + "YARA memory queue full; dropping scan job" + ), + } + } } } From c2ad18e438f77da7f916f275e6c93a8b43f01757 Mon Sep 17 00:00:00 2001 From: Karib0u Date: Sun, 3 May 2026 15:41:11 +0200 Subject: [PATCH 2/2] fix: suppress dead_code warnings on public memory scanning API types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MemoryRegionKind::Image is only constructed on Windows; on Linux the variant exists for API completeness. MemoryRegion.base and .size are written but only chunk.base is consumed by the alert builder — the fields are kept for future use. Both suppressed with #[allow(dead_code)] so CI passes with -D warnings. Co-Authored-By: Claude Sonnet 4.6 --- src/memory.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/memory.rs b/src/memory.rs index 044a38b..f89f102 100644 --- a/src/memory.rs +++ b/src/memory.rs @@ -21,6 +21,7 @@ pub struct MemoryScanConfig { } #[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[allow(dead_code)] pub enum MemoryRegionKind { Private, Image, @@ -29,6 +30,7 @@ pub enum MemoryRegionKind { } #[derive(Debug, Clone)] +#[allow(dead_code)] pub struct MemoryRegion { pub base: u64, pub size: usize,