diff --git a/.claude/worktrees/agent-a03c04a2 b/.claude/worktrees/agent-a03c04a2 new file mode 160000 index 000000000..3e64ee531 --- /dev/null +++ b/.claude/worktrees/agent-a03c04a2 @@ -0,0 +1 @@ +Subproject commit 3e64ee5318b091b825c3aace1615eac41ce4f922 diff --git a/.claude/worktrees/agent-a439ef87 b/.claude/worktrees/agent-a439ef87 new file mode 160000 index 000000000..183cf6124 --- /dev/null +++ b/.claude/worktrees/agent-a439ef87 @@ -0,0 +1 @@ +Subproject commit 183cf6124bcc97673a2433bfb104d085f0dfd68d diff --git a/CLAUDE.md b/CLAUDE.md index 55295f0ed..16c473492 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -69,6 +69,7 @@ Communication is through Tauri's `invoke()` (frontend→backend) and `emit()` (b | `models/` | Shared types: `LogEntry`, `ParseResult`, `FilterCriteria` | | `state/` | `AppState` (Mutex-wrapped) — tracks open files, tail sessions | | `watcher/` | File watching and real-time tailing via `notify` crate | +| `sysmon/` | Sysmon event log analysis: EVTX parsing, event models | | `menu.rs` | Native application menu | ### Frontend Module Map (`src/`) @@ -80,7 +81,8 @@ Communication is through Tauri's `invoke()` (frontend→backend) and `emit()` (b | `components/dialogs/` | Modal dialogs (find, filter, error lookup) | | `components/intune/` | Intune analysis workspace | | `components/dsregcmd/` | DSRegCmd troubleshooting workspace | -| `stores/` | 5 Zustand stores: log, filter, intune, dsregcmd, ui | +| `components/sysmon/` | Sysmon event log analysis workspace | +| `stores/` | 6 Zustand stores: log, filter, intune, dsregcmd, sysmon, ui | | `hooks/` | Custom hooks for drag-drop, menus, file association | | `types/` | TypeScript type definitions | @@ -102,6 +104,7 @@ Format detection (`detect.rs`) samples the first lines of a file to auto-select - **Encoding fallback**: UTF-8 → Windows-1252 (via `encoding_rs`) - **Parallelism**: Rayon for batch log line processing, Tokio for async file I/O - **Windows-specific code** is gated with `#[cfg(target_os = "windows")]` and the `windows`/`winreg` crates +- **Windows-only workspaces** (Sysmon, parts of Intune) need platform gating in Rust commands and conditional handling in frontend tests ## Testing @@ -116,3 +119,27 @@ Format detection (`detect.rs`) samples the first lines of a file to auto-select - Rust 1.77.2+ (MSVC toolchain on Windows) - Windows: Visual Studio Build Tools with C++ workload + Windows SDK + WebView2 Runtime - Automated Windows setup: `powershell -ExecutionPolicy Bypass -File .\scripts\Install-CMTraceOpenBuildPrereqs.ps1` + +## Agent Directives: Mechanical Overrides + +### Pre-Work + +1. **Step 0 Rule**: Before ANY structural refactor on a file >300 LOC, first remove all dead props, unused exports, unused imports, and debug logs. Commit this cleanup separately before starting the real work. +2. **Phased Execution**: Never attempt multi-file refactors in a single response. Break work into explicit phases. Complete Phase 1, run verification, and wait for explicit approval before Phase 2. Each phase must touch no more than 5 files. + +### Code Quality + +3. **Senior Dev Override**: If architecture is flawed, state is duplicated, or patterns are inconsistent — propose and implement structural fixes. Ask: "What would a senior, experienced, perfectionist dev reject in code review?" Fix all of it. +4. **Forced Verification**: You are FORBIDDEN from reporting a task as complete until you have run `npx tsc --noEmit` (and `npx eslint . --quiet` if configured) and fixed ALL resulting errors. + +### Context Management + +5. **Sub-Agent Swarming**: For tasks touching >5 independent files, launch parallel sub-agents (5–8 files per agent). Sequential processing of large tasks guarantees context decay. +6. **Context Decay Awareness**: After 10+ messages in a conversation, re-read any file before editing it. Do not trust memory of file contents — auto-compaction may have silently destroyed that context. +7. **File Read Budget**: Each file read is capped at 2,000 lines. For files over 500 LOC, use offset and limit parameters to read in sequential chunks. Never assume you have seen a complete file from a single read. +8. **Tool Result Blindness**: Tool results over 50,000 characters are silently truncated. If any search or command returns suspiciously few results, re-run with narrower scope. State when you suspect truncation occurred. + +### Edit Safety + +9. **Edit Integrity**: Before EVERY file edit, re-read the file. After editing, read it again to confirm the change applied correctly. Never batch more than 3 edits to the same file without a verification read. +10. **No Semantic Search**: When renaming or changing any function/type/variable, search separately for: direct calls, type-level references, string literals containing the name, dynamic imports/require() calls, re-exports/barrel file entries, and test files/mocks. diff --git a/docs/superpowers/plans/2026-03-31-sysmon-dashboard.md b/docs/superpowers/plans/2026-03-31-sysmon-dashboard.md new file mode 100644 index 000000000..c47842f37 --- /dev/null +++ b/docs/superpowers/plans/2026-03-31-sysmon-dashboard.md @@ -0,0 +1,1510 @@ +# Sysmon Dashboard View Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a Dashboard tab to the Sysmon workspace with 9 widgets (metric cards, timeline chart, event type donut, security alerts, top processes, network activity, DNS queries, file activity, registry activity) powered by backend-computed aggregations and `@fluentui/react-charts`. + +**Architecture:** Backend computes all dashboard aggregations in a new `build_dashboard_data()` function alongside existing `build_summary()`. A new `SysmonDashboardData` struct is added to `SysmonAnalysisResult`. Frontend renders 6 new components using Fluent UI v9 charts in a scrollable 2-column grid layout. Dashboard becomes the default tab. + +**Tech Stack:** Rust (serde, chrono, HashMap aggregations), TypeScript/React 19, Zustand, `@fluentui/react-charts` v9, `@fluentui/react-components` v9 + +**Spec:** `docs/superpowers/specs/2026-03-31-sysmon-dashboard-design.md` + +--- + +## File Structure + +### Backend (Rust) +| File | Action | Responsibility | +|------|--------|---------------| +| `src-tauri/src/sysmon/models.rs` | Modify | Add `TimeBucket`, `RankedItem`, `SecuritySummary`, `SysmonDashboardData` structs; add `dashboard` field to `SysmonAnalysisResult` | +| `src-tauri/src/sysmon/evtx_parser.rs` | Modify | Add `build_dashboard_data()` function | +| `src-tauri/src/commands/sysmon.rs` | Modify | Call `build_dashboard_data()` and wire into result | +| `src-tauri/tests/sysmon_parser.rs` | Modify | Add tests for `build_dashboard_data()` | + +### Frontend (TypeScript) +| File | Action | Responsibility | +|------|--------|---------------| +| `package.json` | Modify | Add `@fluentui/react-charts` dependency | +| `src/types/sysmon.ts` | Modify | Add `TimeBucket`, `RankedItem`, `SecuritySummary`, `SysmonDashboardData` interfaces; update `SysmonAnalysisResult` | +| `src/stores/sysmon-store.ts` | Modify | Add `dashboard` state, update `activeTab` type, update actions | +| `src/components/sysmon/SysmonWorkspace.tsx` | Modify | Add Dashboard tab as default | +| `src/components/sysmon/SysmonDashboardView.tsx` | Create | Main scrollable dashboard container | +| `src/components/sysmon/DashboardMetricCards.tsx` | Create | Hero metric row (5 cards) | +| `src/components/sysmon/DashboardTimeline.tsx` | Create | VerticalBarChart + granularity picker | +| `src/components/sysmon/DashboardEventTypeChart.tsx` | Create | DonutChart of event types | +| `src/components/sysmon/DashboardSecurityAlerts.tsx` | Create | Warning/error summary widget | +| `src/components/sysmon/DashboardTopList.tsx` | Create | Reusable HorizontalBarChart for top-N lists | + +--- + +## Task 1: Add Backend Data Model Structs + +**Files:** +- Modify: `src-tauri/src/sysmon/models.rs:288-300` + +- [ ] **Step 1: Add new structs before `SysmonAnalysisResult`** + +Insert the following after line 286 (closing `}` of `SysmonConfig`) in `src-tauri/src/sysmon/models.rs`: + +```rust +/// A time-bucketed event count for timeline charts. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TimeBucket { + /// ISO 8601 timestamp for the bucket start. + pub timestamp: String, + /// Unix ms timestamp for the bucket start. + pub timestamp_ms: i64, + /// Number of events in this bucket. + pub count: u64, +} + +/// A named item with a count, used for top-N rankings. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RankedItem { + pub name: String, + pub count: u64, +} + +/// Aggregated security alert statistics. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SecuritySummary { + pub total_warnings: u64, + pub total_errors: u64, + pub events_by_type: Vec, +} + +/// Pre-computed dashboard aggregations. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SysmonDashboardData { + pub timeline_minute: Vec, + pub timeline_hourly: Vec, + pub timeline_daily: Vec, + pub top_processes: Vec, + pub top_destinations: Vec, + pub top_ports: Vec, + pub top_dns_queries: Vec, + pub security_events: SecuritySummary, + pub top_target_files: Vec, + pub top_registry_keys: Vec, +} +``` + +- [ ] **Step 2: Add `dashboard` field to `SysmonAnalysisResult`** + +In the same file, add a field to `SysmonAnalysisResult` (after `config` field, before `source_path`): + +```rust +/// Pre-computed dashboard aggregations. +pub dashboard: SysmonDashboardData, +``` + +- [ ] **Step 3: Run cargo check** + +Run: `cd src-tauri && cargo check 2>&1` +Expected: Compilation errors in `commands/sysmon.rs` because `dashboard` field is now required but not provided. This is expected — we'll fix it in Task 3. + +- [ ] **Step 4: Commit** + +```bash +git add src-tauri/src/sysmon/models.rs +git commit -m "feat(sysmon): add dashboard data model structs" +``` + +--- + +## Task 2: Implement `build_dashboard_data()` in Rust + +**Files:** +- Modify: `src-tauri/src/sysmon/evtx_parser.rs` + +- [ ] **Step 1: Add the `build_dashboard_data` function** + +Add the following function after `build_summary()` (after line 322) in `src-tauri/src/sysmon/evtx_parser.rs`: + +```rust +/// Builds pre-computed dashboard aggregations from parsed events. +pub fn build_dashboard_data(events: &[SysmonEvent]) -> SysmonDashboardData { + use chrono::{DateTime, Utc, Timelike, Datelike}; + + const TOP_N: usize = 20; + + // --- Timeline bucketing --- + let mut minute_buckets: HashMap = HashMap::new(); + let mut hourly_buckets: HashMap = HashMap::new(); + let mut daily_buckets: HashMap = HashMap::new(); + + // --- Top-N counters --- + let mut process_counts: HashMap = HashMap::new(); + let mut dest_counts: HashMap = HashMap::new(); + let mut port_counts: HashMap = HashMap::new(); + let mut dns_counts: HashMap = HashMap::new(); + let mut file_counts: HashMap = HashMap::new(); + let mut registry_counts: HashMap = HashMap::new(); + + // --- Security --- + let mut total_warnings: u64 = 0; + let mut total_errors: u64 = 0; + let mut security_type_counts: HashMap = HashMap::new(); + + for event in events { + // Timeline: bucket by timestamp_ms + if let Some(ms) = event.timestamp_ms { + let minute_key = (ms / 60_000) * 60_000; + let hourly_key = (ms / 3_600_000) * 3_600_000; + let daily_key = (ms / 86_400_000) * 86_400_000; + *minute_buckets.entry(minute_key).or_insert(0) += 1; + *hourly_buckets.entry(hourly_key).or_insert(0) += 1; + *daily_buckets.entry(daily_key).or_insert(0) += 1; + } + + // Top processes + if let Some(ref image) = event.image { + if !image.is_empty() { + *process_counts.entry(image.clone()).or_insert(0) += 1; + } + } + + // Network: destinations and ports (NetworkConnect = EventID 3) + if event.event_id == 3 { + // Prefer hostname over IP + let dest = event + .destination_hostname + .as_deref() + .filter(|s| !s.is_empty()) + .or(event.destination_ip.as_deref().filter(|s| !s.is_empty())); + if let Some(d) = dest { + *dest_counts.entry(d.to_string()).or_insert(0) += 1; + } + if let Some(port) = event.destination_port { + *port_counts.entry(port.to_string()).or_insert(0) += 1; + } + } + + // DNS queries (DnsQuery = EventID 22) + if event.event_id == 22 { + if let Some(ref qname) = event.query_name { + if !qname.is_empty() { + *dns_counts.entry(qname.clone()).or_insert(0) += 1; + } + } + } + + // File activity (EventIDs: 2, 11, 15, 23, 24, 26, 27, 28, 29) + match event.event_id { + 2 | 11 | 15 | 23 | 24 | 26 | 27 | 28 | 29 => { + if let Some(ref tf) = event.target_filename { + if !tf.is_empty() { + *file_counts.entry(tf.clone()).or_insert(0) += 1; + } + } + } + _ => {} + } + + // Registry activity (EventIDs: 12, 13, 14) + match event.event_id { + 12 | 13 | 14 => { + if let Some(ref to) = event.target_object { + if !to.is_empty() { + *registry_counts.entry(to.clone()).or_insert(0) += 1; + } + } + } + _ => {} + } + + // Security: warning and error severity events + match event.severity { + SysmonSeverity::Warning => { + total_warnings += 1; + *security_type_counts + .entry(event.event_type.display_name().to_string()) + .or_insert(0) += 1; + } + SysmonSeverity::Error => { + total_errors += 1; + *security_type_counts + .entry(event.event_type.display_name().to_string()) + .or_insert(0) += 1; + } + SysmonSeverity::Info => {} + } + } + + // --- Helper: convert bucket map to sorted Vec --- + let buckets_to_vec = |map: HashMap| -> Vec { + let mut vec: Vec = map + .into_iter() + .map(|(ms, count)| { + let ts = DateTime::::from_timestamp_millis(ms) + .map(|dt| dt.to_rfc3339()) + .unwrap_or_default(); + TimeBucket { + timestamp: ts, + timestamp_ms: ms, + count, + } + }) + .collect(); + vec.sort_by_key(|b| b.timestamp_ms); + vec + }; + + // --- Helper: convert count map to top-N Vec --- + let top_n = |map: HashMap| -> Vec { + let mut vec: Vec = map + .into_iter() + .map(|(name, count)| RankedItem { name, count }) + .collect(); + vec.sort_by(|a, b| b.count.cmp(&a.count)); + vec.truncate(TOP_N); + vec + }; + + let mut security_by_type: Vec = security_type_counts + .into_iter() + .map(|(name, count)| RankedItem { name, count }) + .collect(); + security_by_type.sort_by(|a, b| b.count.cmp(&a.count)); + + SysmonDashboardData { + timeline_minute: buckets_to_vec(minute_buckets), + timeline_hourly: buckets_to_vec(hourly_buckets), + timeline_daily: buckets_to_vec(daily_buckets), + top_processes: top_n(process_counts), + top_destinations: top_n(dest_counts), + top_ports: top_n(port_counts), + top_dns_queries: top_n(dns_counts), + security_events: SecuritySummary { + total_warnings, + total_errors, + events_by_type: security_by_type, + }, + top_target_files: top_n(file_counts), + top_registry_keys: top_n(registry_counts), + } +} +``` + +- [ ] **Step 2: Add required imports at top of file** + +Ensure these are imported at the top of `evtx_parser.rs` (add if missing): + +```rust +use super::models::{SysmonDashboardData, TimeBucket, RankedItem, SecuritySummary}; +``` + +Also ensure `chrono` import includes `DateTime` and `Utc` (may already exist — check existing imports). + +- [ ] **Step 3: Run cargo check** + +Run: `cd src-tauri && cargo check 2>&1` +Expected: Still fails because `commands/sysmon.rs` doesn't provide `dashboard` field yet. The new function itself should compile. + +- [ ] **Step 4: Commit** + +```bash +git add src-tauri/src/sysmon/evtx_parser.rs +git commit -m "feat(sysmon): implement build_dashboard_data aggregation" +``` + +--- + +## Task 3: Wire Dashboard Data Into Command + +**Files:** +- Modify: `src-tauri/src/commands/sysmon.rs:259-283` + +- [ ] **Step 1: Add dashboard computation after build_summary** + +In `src-tauri/src/commands/sysmon.rs`, after line 262 (`let config = ...`), add: + +```rust + // Build dashboard aggregations + let dashboard = evtx_parser::build_dashboard_data(&all_events); +``` + +- [ ] **Step 2: Add `dashboard` to the result struct** + +Update the `Ok(SysmonAnalysisResult { ... })` block (around line 278) to include the `dashboard` field: + +```rust + Ok(SysmonAnalysisResult { + events: all_events, + summary, + config, + dashboard, + source_path: path, + }) +``` + +- [ ] **Step 3: Run cargo check** + +Run: `cd src-tauri && cargo check 2>&1` +Expected: PASS — all Rust code compiles. + +- [ ] **Step 4: Run cargo clippy** + +Run: `cd src-tauri && cargo clippy -- -D warnings 2>&1` +Expected: PASS with zero warnings. + +- [ ] **Step 5: Commit** + +```bash +git add src-tauri/src/commands/sysmon.rs +git commit -m "feat(sysmon): wire dashboard data into analyze command" +``` + +--- + +## Task 4: Add Backend Tests for `build_dashboard_data` + +**Files:** +- Modify: `src-tauri/tests/sysmon_parser.rs` + +- [ ] **Step 1: Add import for `build_dashboard_data`** + +At the top of `src-tauri/tests/sysmon_parser.rs`, update line 1 to: + +```rust +use app_lib::sysmon::evtx_parser::{build_dashboard_data, build_summary}; +``` + +- [ ] **Step 2: Add test for empty events** + +Append after the last test: + +```rust +#[test] +fn dashboard_data_empty_events() { + let data = build_dashboard_data(&[]); + assert!(data.timeline_minute.is_empty()); + assert!(data.timeline_hourly.is_empty()); + assert!(data.timeline_daily.is_empty()); + assert!(data.top_processes.is_empty()); + assert!(data.top_destinations.is_empty()); + assert!(data.top_ports.is_empty()); + assert!(data.top_dns_queries.is_empty()); + assert!(data.top_target_files.is_empty()); + assert!(data.top_registry_keys.is_empty()); + assert_eq!(data.security_events.total_warnings, 0); + assert_eq!(data.security_events.total_errors, 0); +} +``` + +- [ ] **Step 3: Add test for timeline bucketing** + +```rust +#[test] +fn dashboard_data_timeline_bucketing() { + let events = vec![ + // Two events in same minute, same hour + make_event(0, "2024-04-28T10:00:00Z", Some(1714298400000), 1), + make_event(1, "2024-04-28T10:00:30Z", Some(1714298430000), 1), + // One event in different hour + make_event(2, "2024-04-28T11:00:00Z", Some(1714302000000), 1), + ]; + let data = build_dashboard_data(&events); + + // Minute buckets: 2 at 10:00, 1 at 10:00:30 rounds to same minute? No — 10:00 and 10:00 are same minute key + // 1714298400000 / 60000 * 60000 = 1714298400000 (10:00:00) + // 1714298430000 / 60000 * 60000 = 1714298400000 (10:00:00) + // So 2 events in minute bucket at 10:00, 1 at 11:00 + assert_eq!(data.timeline_minute.len(), 2); + assert_eq!(data.timeline_minute[0].count, 2); // 10:00 bucket + assert_eq!(data.timeline_minute[1].count, 1); // 11:00 bucket + + // Hourly: 2 at 10:00 hour, 1 at 11:00 hour + assert_eq!(data.timeline_hourly.len(), 2); + + // Daily: all same day + assert_eq!(data.timeline_daily.len(), 1); + assert_eq!(data.timeline_daily[0].count, 3); +} +``` + +- [ ] **Step 4: Add test for top processes** + +```rust +#[test] +fn dashboard_data_top_processes() { + let mut e1 = make_event(0, "2024-04-28T10:00:00Z", Some(1714298400000), 1); + e1.image = Some("C:\\Windows\\svchost.exe".to_string()); + let mut e2 = make_event(1, "2024-04-28T10:00:01Z", Some(1714298401000), 1); + e2.image = Some("C:\\Windows\\svchost.exe".to_string()); + let mut e3 = make_event(2, "2024-04-28T10:00:02Z", Some(1714298402000), 1); + e3.image = Some("C:\\Windows\\explorer.exe".to_string()); + + let data = build_dashboard_data(&[e1, e2, e3]); + assert_eq!(data.top_processes.len(), 2); + assert_eq!(data.top_processes[0].name, "C:\\Windows\\svchost.exe"); + assert_eq!(data.top_processes[0].count, 2); + assert_eq!(data.top_processes[1].name, "C:\\Windows\\explorer.exe"); + assert_eq!(data.top_processes[1].count, 1); +} +``` + +- [ ] **Step 5: Add test for network and DNS aggregation** + +```rust +#[test] +fn dashboard_data_network_and_dns() { + // NetworkConnect event (EventID 3) + let mut net1 = make_event(0, "2024-04-28T10:00:00Z", Some(1714298400000), 3); + net1.destination_ip = Some("10.0.0.1".to_string()); + net1.destination_port = Some(443); + net1.destination_hostname = Some("example.com".to_string()); + + let mut net2 = make_event(1, "2024-04-28T10:00:01Z", Some(1714298401000), 3); + net2.destination_ip = Some("10.0.0.1".to_string()); + net2.destination_port = Some(80); + + // DnsQuery event (EventID 22) + let mut dns1 = make_event(2, "2024-04-28T10:00:02Z", Some(1714298402000), 22); + dns1.query_name = Some("google.com".to_string()); + + let mut dns2 = make_event(3, "2024-04-28T10:00:03Z", Some(1714298403000), 22); + dns2.query_name = Some("google.com".to_string()); + + let data = build_dashboard_data(&[net1, net2, dns1, dns2]); + + // Destinations: "example.com" (hostname preferred), "10.0.0.1" (IP fallback) + assert_eq!(data.top_destinations.len(), 2); + assert_eq!(data.top_destinations[0].count, 1); // each destination appears once + + // Ports: 443 and 80 + assert_eq!(data.top_ports.len(), 2); + + // DNS: google.com x2 + assert_eq!(data.top_dns_queries.len(), 1); + assert_eq!(data.top_dns_queries[0].name, "google.com"); + assert_eq!(data.top_dns_queries[0].count, 2); +} +``` + +- [ ] **Step 6: Add test for security events** + +```rust +#[test] +fn dashboard_data_security_events() { + // CreateRemoteThread (EventID 8) → Warning severity + let mut e1 = make_event(0, "2024-04-28T10:00:00Z", Some(1714298400000), 8); + e1.severity = SysmonSeverity::Warning; + // Error event (EventID 255) + let mut e2 = make_event(1, "2024-04-28T10:00:01Z", Some(1714298401000), 255); + e2.severity = SysmonSeverity::Error; + // Normal info event + let e3 = make_event(2, "2024-04-28T10:00:02Z", Some(1714298402000), 1); + + let data = build_dashboard_data(&[e1, e2, e3]); + assert_eq!(data.security_events.total_warnings, 1); + assert_eq!(data.security_events.total_errors, 1); + assert_eq!(data.security_events.events_by_type.len(), 2); +} +``` + +- [ ] **Step 7: Run all tests** + +Run: `cd src-tauri && cargo test 2>&1` +Expected: All tests pass including new dashboard tests. + +- [ ] **Step 8: Commit** + +```bash +git add src-tauri/tests/sysmon_parser.rs +git commit -m "test(sysmon): add unit tests for build_dashboard_data" +``` + +--- + +## Task 5: Install `@fluentui/react-charts` + +**Files:** +- Modify: `package.json` + +- [ ] **Step 1: Install the package** + +Run: `npm install @fluentui/react-charts` + +- [ ] **Step 2: Verify installation** + +Run: `node -e "const p = require('./node_modules/@fluentui/react-charts/package.json'); console.log(p.name, p.version)"` +Expected: `@fluentui/react-charts 9.x.x` + +- [ ] **Step 3: Run TypeScript check** + +Run: `npx tsc --noEmit 2>&1` +Expected: PASS (no type errors from new dependency) + +- [ ] **Step 4: Commit** + +```bash +git add package.json package-lock.json +git commit -m "chore: add @fluentui/react-charts dependency" +``` + +--- + +## Task 6: Add TypeScript Types + +**Files:** +- Modify: `src/types/sysmon.ts` + +- [ ] **Step 1: Add new interfaces before `SysmonAnalysisResult`** + +Insert the following before the `SysmonAnalysisResult` interface (before line 116) in `src/types/sysmon.ts`: + +```typescript +export interface TimeBucket { + timestamp: string; + timestampMs: number; + count: number; +} + +export interface RankedItem { + name: string; + count: number; +} + +export interface SecuritySummary { + totalWarnings: number; + totalErrors: number; + eventsByType: RankedItem[]; +} + +export interface SysmonDashboardData { + timelineMinute: TimeBucket[]; + timelineHourly: TimeBucket[]; + timelineDaily: TimeBucket[]; + topProcesses: RankedItem[]; + topDestinations: RankedItem[]; + topPorts: RankedItem[]; + topDnsQueries: RankedItem[]; + securityEvents: SecuritySummary; + topTargetFiles: RankedItem[]; + topRegistryKeys: RankedItem[]; +} +``` + +- [ ] **Step 2: Add `dashboard` field to `SysmonAnalysisResult`** + +Update the `SysmonAnalysisResult` interface to include: + +```typescript +export interface SysmonAnalysisResult { + events: SysmonEvent[]; + summary: SysmonSummary; + config: SysmonConfig; + dashboard: SysmonDashboardData; + sourcePath: string; +} +``` + +- [ ] **Step 3: Run TypeScript check** + +Run: `npx tsc --noEmit 2>&1` +Expected: Errors in `sysmon-store.ts` because `setResults` doesn't handle `dashboard` yet. Expected — fixed in next task. + +- [ ] **Step 4: Commit** + +```bash +git add src/types/sysmon.ts +git commit -m "feat(sysmon): add dashboard TypeScript types" +``` + +--- + +## Task 7: Update Sysmon Store + +**Files:** +- Modify: `src/stores/sysmon-store.ts` + +- [ ] **Step 1: Update imports** + +Update the import from `../types/sysmon` to include `SysmonDashboardData`: + +```typescript +import type { + SysmonAnalysisResult, + SysmonDashboardData, + SysmonEvent, + SysmonEventType, + SysmonSeverity, +} from "../types/sysmon"; +``` + +- [ ] **Step 2: Update `SysmonWorkspaceTab` type** + +Change line 11 from: +```typescript +type SysmonWorkspaceTab = "events" | "summary" | "config"; +``` +to: +```typescript +type SysmonWorkspaceTab = "dashboard" | "events" | "summary" | "config"; +``` + +- [ ] **Step 3: Add `dashboard` to state interface** + +In `SysmonState` interface, add after `config: SysmonConfig | null;`: + +```typescript + dashboard: SysmonDashboardData | null; +``` + +- [ ] **Step 4: Update initial state** + +In the `create()` call, add to initial state (after `config: null,`): + +```typescript + dashboard: null, +``` + +- [ ] **Step 5: Update `beginAnalysis` action** + +In `beginAnalysis`, add `dashboard: null,` to the reset state object. + +- [ ] **Step 6: Update `setResults` action** + +In `setResults`, change `activeTab: "events"` to `activeTab: "dashboard"` and add `dashboard: result.dashboard,`: + +```typescript + setResults: (result) => + set({ + events: result.events, + summary: result.summary, + config: result.config, + dashboard: result.dashboard, + sourcePath: result.sourcePath, + isAnalyzing: false, + analysisError: null, + progressMessage: null, + activeTab: "dashboard", + }), +``` + +- [ ] **Step 7: Update `clear` action** + +In `clear`, add `dashboard: null,` to the reset state object. + +- [ ] **Step 8: Run TypeScript check** + +Run: `npx tsc --noEmit 2>&1` +Expected: PASS + +- [ ] **Step 9: Commit** + +```bash +git add src/stores/sysmon-store.ts +git commit -m "feat(sysmon): add dashboard state to store" +``` + +--- + +## Task 8: Create `DashboardTopList` Component (Reusable) + +**Files:** +- Create: `src/components/sysmon/DashboardTopList.tsx` + +- [ ] **Step 1: Create the reusable top-N chart component** + +Create `src/components/sysmon/DashboardTopList.tsx`: + +```tsx +import { tokens, makeStyles } from "@fluentui/react-components"; +import { HorizontalBarChart, HorizontalBarChartVariant } from "@fluentui/react-charts"; +import type { RankedItem } from "../../types/sysmon"; + +const useStyles = makeStyles({ + container: { + backgroundColor: tokens.colorNeutralBackground3, + borderRadius: tokens.borderRadiusMedium, + border: `1px solid ${tokens.colorNeutralStroke2}`, + padding: "16px", + }, + title: { + fontSize: "14px", + fontWeight: 600, + marginBottom: "12px", + color: tokens.colorNeutralForeground1, + }, + empty: { + fontSize: "12px", + color: tokens.colorNeutralForeground3, + fontStyle: "italic", + }, +}); + +interface DashboardTopListProps { + title: string; + items: RankedItem[]; + emptyMessage?: string; + color?: string; +} + +export function DashboardTopList({ + title, + items, + emptyMessage = "No data", + color, +}: DashboardTopListProps) { + const styles = useStyles(); + + if (items.length === 0) { + return ( +
+
{title}
+
{emptyMessage}
+
+ ); + } + + const maxCount = items[0].count; + const chartData = items.map((item, i) => ({ + chartTitle: item.name, + chartData: [ + { + legend: item.name, + horizontalBarChartdata: { x: item.count, y: maxCount }, + color: color || tokens.colorBrandBackground, + }, + ], + })); + + return ( +
+
{title}
+ +
+ ); +} +``` + +- [ ] **Step 2: Run TypeScript check** + +Run: `npx tsc --noEmit 2>&1` +Expected: PASS or may have minor import issues to fix depending on exact `@fluentui/react-charts` export names. + +- [ ] **Step 3: Commit** + +```bash +git add src/components/sysmon/DashboardTopList.tsx +git commit -m "feat(sysmon): add reusable DashboardTopList component" +``` + +--- + +## Task 9: Create `DashboardMetricCards` Component + +**Files:** +- Create: `src/components/sysmon/DashboardMetricCards.tsx` + +- [ ] **Step 1: Create the metric cards component** + +Create `src/components/sysmon/DashboardMetricCards.tsx`: + +```tsx +import { tokens, makeStyles } from "@fluentui/react-components"; +import type { SysmonSummary } from "../../types/sysmon"; + +const useStyles = makeStyles({ + row: { + display: "grid", + gridTemplateColumns: "repeat(auto-fit, minmax(160px, 1fr))", + gap: "12px", + marginBottom: "16px", + }, + card: { + backgroundColor: tokens.colorNeutralBackground3, + borderRadius: tokens.borderRadiusMedium, + border: `1px solid ${tokens.colorNeutralStroke2}`, + padding: "12px 16px", + }, + label: { + fontSize: "12px", + color: tokens.colorNeutralForeground3, + marginBottom: "4px", + }, + value: { + fontSize: "20px", + fontWeight: 600, + color: tokens.colorNeutralForeground1, + }, + errorValue: { + fontSize: "20px", + fontWeight: 600, + color: tokens.colorPaletteRedForeground1, + }, + subtext: { + fontSize: "11px", + color: tokens.colorNeutralForeground3, + marginTop: "2px", + }, +}); + +interface DashboardMetricCardsProps { + summary: SysmonSummary; +} + +export function DashboardMetricCards({ summary }: DashboardMetricCardsProps) { + const styles = useStyles(); + + const formatNumber = (n: number) => n.toLocaleString(); + + return ( +
+
+
Total Events
+
{formatNumber(summary.totalEvents)}
+
+
+
Unique Processes
+
+ {formatNumber(summary.uniqueProcesses)} +
+
+
+
Unique Computers
+
+ {formatNumber(summary.uniqueComputers)} +
+
+
+
Time Range
+
+ {summary.earliestTimestamp + ? `${summary.earliestTimestamp} — ${summary.latestTimestamp}` + : "N/A"} +
+
+ {summary.parseErrors > 0 && ( +
+
Parse Errors
+
+ {formatNumber(summary.parseErrors)} +
+
+ )} +
+ ); +} +``` + +- [ ] **Step 2: Run TypeScript check** + +Run: `npx tsc --noEmit 2>&1` +Expected: PASS + +- [ ] **Step 3: Commit** + +```bash +git add src/components/sysmon/DashboardMetricCards.tsx +git commit -m "feat(sysmon): add DashboardMetricCards component" +``` + +--- + +## Task 10: Create `DashboardTimeline` Component + +**Files:** +- Create: `src/components/sysmon/DashboardTimeline.tsx` + +- [ ] **Step 1: Create the timeline chart component** + +Create `src/components/sysmon/DashboardTimeline.tsx`: + +```tsx +import { useState } from "react"; +import { + tokens, + makeStyles, + Dropdown, + Option, +} from "@fluentui/react-components"; +import { VerticalBarChart } from "@fluentui/react-charts"; +import type { TimeBucket, SysmonDashboardData } from "../../types/sysmon"; + +const useStyles = makeStyles({ + container: { + backgroundColor: tokens.colorNeutralBackground3, + borderRadius: tokens.borderRadiusMedium, + border: `1px solid ${tokens.colorNeutralStroke2}`, + padding: "16px", + gridColumn: "1 / -1", + }, + header: { + display: "flex", + justifyContent: "space-between", + alignItems: "center", + marginBottom: "12px", + }, + title: { + fontSize: "14px", + fontWeight: 600, + color: tokens.colorNeutralForeground1, + }, + empty: { + fontSize: "12px", + color: tokens.colorNeutralForeground3, + fontStyle: "italic", + textAlign: "center" as const, + padding: "40px 0", + }, +}); + +type Granularity = "minute" | "hourly" | "daily"; + +const GRANULARITY_LABELS: Record = { + minute: "Per Minute", + hourly: "Per Hour", + daily: "Per Day", +}; + +interface DashboardTimelineProps { + dashboard: SysmonDashboardData; +} + +export function DashboardTimeline({ dashboard }: DashboardTimelineProps) { + const styles = useStyles(); + const [granularity, setGranularity] = useState("hourly"); + + const dataMap: Record = { + minute: dashboard.timelineMinute, + hourly: dashboard.timelineHourly, + daily: dashboard.timelineDaily, + }; + + const buckets = dataMap[granularity]; + + if (buckets.length === 0) { + return ( +
+
+
Event Volume Timeline
+
+
No timeline data available
+
+ ); + } + + const chartPoints = buckets.map((b) => ({ + x: new Date(b.timestamp), + y: b.count, + })); + + const chartData = [ + { + chartTitle: "Events", + data: chartPoints, + color: tokens.colorBrandBackground, + }, + ]; + + return ( +
+
+
Event Volume Timeline
+ { + if (data.optionValue) { + setGranularity(data.optionValue as Granularity); + } + }} + style={{ minWidth: "120px" }} + > + + + + +
+
+ +
+
+ ); +} +``` + +- [ ] **Step 2: Run TypeScript check** + +Run: `npx tsc --noEmit 2>&1` +Expected: PASS (may need adjustments to chart prop names based on actual `@fluentui/react-charts` API) + +- [ ] **Step 3: Commit** + +```bash +git add src/components/sysmon/DashboardTimeline.tsx +git commit -m "feat(sysmon): add DashboardTimeline component" +``` + +--- + +## Task 11: Create `DashboardEventTypeChart` Component + +**Files:** +- Create: `src/components/sysmon/DashboardEventTypeChart.tsx` + +- [ ] **Step 1: Create the donut chart component** + +Create `src/components/sysmon/DashboardEventTypeChart.tsx`: + +```tsx +import { tokens, makeStyles } from "@fluentui/react-components"; +import { DonutChart } from "@fluentui/react-charts"; +import type { SysmonSummary } from "../../types/sysmon"; + +const useStyles = makeStyles({ + container: { + backgroundColor: tokens.colorNeutralBackground3, + borderRadius: tokens.borderRadiusMedium, + border: `1px solid ${tokens.colorNeutralStroke2}`, + padding: "16px", + }, + title: { + fontSize: "14px", + fontWeight: 600, + marginBottom: "12px", + color: tokens.colorNeutralForeground1, + }, + empty: { + fontSize: "12px", + color: tokens.colorNeutralForeground3, + fontStyle: "italic", + }, +}); + +// Fluent UI brand-adjacent palette for up to 12 slices; overflow uses gray +const SLICE_COLORS = [ + tokens.colorBrandBackground, + tokens.colorPaletteBlueBorderActive, + tokens.colorPaletteTealBorderActive, + tokens.colorPaletteGreenBorderActive, + tokens.colorPaletteMarigoldBorderActive, + tokens.colorPalettePeachBorderActive, + tokens.colorPalettePurpleBorderActive, + tokens.colorPalettePinkBorderActive, + tokens.colorPaletteLilacBorderActive, + tokens.colorPaletteLavenderBorderActive, + tokens.colorPaletteRedBorderActive, + tokens.colorPaletteDarkOrangeBorderActive, +]; + +interface DashboardEventTypeChartProps { + summary: SysmonSummary; +} + +export function DashboardEventTypeChart({ + summary, +}: DashboardEventTypeChartProps) { + const styles = useStyles(); + + if (summary.eventTypeCounts.length === 0) { + return ( +
+
Event Type Breakdown
+
No events
+
+ ); + } + + const chartData = { + chartTitle: "Event Types", + chartData: summary.eventTypeCounts.map((tc, i) => ({ + legend: `${tc.displayName} (${tc.count})`, + data: tc.count, + color: SLICE_COLORS[i % SLICE_COLORS.length], + })), + }; + + return ( +
+
Event Type Breakdown
+ +
+ ); +} +``` + +- [ ] **Step 2: Run TypeScript check** + +Run: `npx tsc --noEmit 2>&1` +Expected: PASS + +- [ ] **Step 3: Commit** + +```bash +git add src/components/sysmon/DashboardEventTypeChart.tsx +git commit -m "feat(sysmon): add DashboardEventTypeChart component" +``` + +--- + +## Task 12: Create `DashboardSecurityAlerts` Component + +**Files:** +- Create: `src/components/sysmon/DashboardSecurityAlerts.tsx` + +- [ ] **Step 1: Create the security alerts component** + +Create `src/components/sysmon/DashboardSecurityAlerts.tsx`: + +```tsx +import { tokens, makeStyles, Badge } from "@fluentui/react-components"; +import type { SecuritySummary } from "../../types/sysmon"; + +const useStyles = makeStyles({ + container: { + backgroundColor: tokens.colorNeutralBackground3, + borderRadius: tokens.borderRadiusMedium, + border: `1px solid ${tokens.colorNeutralStroke2}`, + padding: "16px", + }, + title: { + fontSize: "14px", + fontWeight: 600, + marginBottom: "12px", + color: tokens.colorNeutralForeground1, + }, + metricsRow: { + display: "flex", + gap: "16px", + marginBottom: "12px", + }, + metric: { + display: "flex", + alignItems: "center", + gap: "6px", + }, + metricCount: { + fontSize: "18px", + fontWeight: 600, + }, + metricLabel: { + fontSize: "12px", + color: tokens.colorNeutralForeground3, + }, + table: { + width: "100%", + borderCollapse: "collapse" as const, + fontSize: "12px", + }, + th: { + textAlign: "left" as const, + padding: "4px 8px", + borderBottom: `1px solid ${tokens.colorNeutralStroke2}`, + color: tokens.colorNeutralForeground3, + fontWeight: 600, + }, + td: { + padding: "4px 8px", + borderBottom: `1px solid ${tokens.colorNeutralStroke3}`, + color: tokens.colorNeutralForeground1, + }, + tdRight: { + padding: "4px 8px", + borderBottom: `1px solid ${tokens.colorNeutralStroke3}`, + color: tokens.colorNeutralForeground1, + textAlign: "right" as const, + }, + empty: { + fontSize: "12px", + color: tokens.colorNeutralForeground3, + fontStyle: "italic", + }, +}); + +interface DashboardSecurityAlertsProps { + securityEvents: SecuritySummary; +} + +export function DashboardSecurityAlerts({ + securityEvents, +}: DashboardSecurityAlertsProps) { + const styles = useStyles(); + const total = securityEvents.totalWarnings + securityEvents.totalErrors; + + return ( +
+
Security Alerts
+ + {total === 0 ? ( +
No warning or error events detected
+ ) : ( + <> +
+
+ + {securityEvents.totalWarnings} + + Warnings +
+
+ + {securityEvents.totalErrors} + + Errors +
+
+ + {securityEvents.eventsByType.length > 0 && ( + + + + + + + + + {securityEvents.eventsByType.map((item) => ( + + + + + ))} + +
Event Type + Count +
{item.name}{item.count}
+ )} + + )} +
+ ); +} +``` + +- [ ] **Step 2: Run TypeScript check** + +Run: `npx tsc --noEmit 2>&1` +Expected: PASS + +- [ ] **Step 3: Commit** + +```bash +git add src/components/sysmon/DashboardSecurityAlerts.tsx +git commit -m "feat(sysmon): add DashboardSecurityAlerts component" +``` + +--- + +## Task 13: Create `SysmonDashboardView` Container Component + +**Files:** +- Create: `src/components/sysmon/SysmonDashboardView.tsx` + +- [ ] **Step 1: Create the main dashboard view** + +Create `src/components/sysmon/SysmonDashboardView.tsx`: + +```tsx +import { tokens, makeStyles } from "@fluentui/react-components"; +import { useSysmonStore } from "../../stores/sysmon-store"; +import { DashboardMetricCards } from "./DashboardMetricCards"; +import { DashboardTimeline } from "./DashboardTimeline"; +import { DashboardEventTypeChart } from "./DashboardEventTypeChart"; +import { DashboardSecurityAlerts } from "./DashboardSecurityAlerts"; +import { DashboardTopList } from "./DashboardTopList"; + +const useStyles = makeStyles({ + container: { + padding: "16px 24px", + overflowY: "auto", + height: "100%", + }, + grid: { + display: "grid", + gridTemplateColumns: "repeat(auto-fit, minmax(400px, 1fr))", + gap: "16px", + }, + fullWidth: { + gridColumn: "1 / -1", + }, + empty: { + fontSize: "12px", + color: tokens.colorNeutralForeground3, + fontStyle: "italic", + padding: "40px", + textAlign: "center" as const, + }, +}); + +export function SysmonDashboardView() { + const styles = useStyles(); + const summary = useSysmonStore((s) => s.summary); + const dashboard = useSysmonStore((s) => s.dashboard); + + if (!summary || !dashboard) { + return
No dashboard data available
; + } + + return ( +
+ + +
+ + + + + + + + + + + + + +
+
+ ); +} +``` + +- [ ] **Step 2: Run TypeScript check** + +Run: `npx tsc --noEmit 2>&1` +Expected: PASS + +- [ ] **Step 3: Commit** + +```bash +git add src/components/sysmon/SysmonDashboardView.tsx +git commit -m "feat(sysmon): add SysmonDashboardView container component" +``` + +--- + +## Task 14: Integrate Dashboard Tab Into `SysmonWorkspace` + +**Files:** +- Modify: `src/components/sysmon/SysmonWorkspace.tsx` + +- [ ] **Step 1: Add import for SysmonDashboardView** + +Add to the imports section of `src/components/sysmon/SysmonWorkspace.tsx`: + +```typescript +import { SysmonDashboardView } from "./SysmonDashboardView"; +``` + +- [ ] **Step 2: Add Dashboard tab to TabList** + +In the `` component (around line 88), add the Dashboard tab as the first tab: + +```tsx + + setActiveTab(data.value as SysmonWorkspaceTab) + } + size="small" +> + Dashboard + Events ({events.length.toLocaleString()}) + Summary + Configuration + +``` + +- [ ] **Step 3: Add Dashboard tab content rendering** + +In the content area (around line 113-117), add the dashboard case: + +```tsx +{activeTab === "dashboard" && } +{activeTab === "events" && } +{activeTab === "summary" && } +{activeTab === "config" && } +``` + +- [ ] **Step 4: Run TypeScript check** + +Run: `npx tsc --noEmit 2>&1` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/components/sysmon/SysmonWorkspace.tsx +git commit -m "feat(sysmon): integrate dashboard tab into workspace" +``` + +--- + +## Task 15: Final Verification + +**Files:** None (verification only) + +- [ ] **Step 1: Run full Rust verification** + +Run: `cd src-tauri && cargo check && cargo test && cargo clippy -- -D warnings 2>&1` +Expected: All pass with zero warnings. + +- [ ] **Step 2: Run full TypeScript verification** + +Run: `npx tsc --noEmit 2>&1` +Expected: PASS + +- [ ] **Step 3: Run existing frontend tests** + +Run: `npx vitest run 2>&1` +Expected: All existing tests pass (ui-store.test.ts etc.) + +- [ ] **Step 4: Fix any issues found** + +If any checks fail, fix the issues and re-run verification. + +- [ ] **Step 5: Final commit (if fixes were needed)** + +```bash +git add -A +git commit -m "fix(sysmon): address dashboard verification issues" +``` diff --git a/docs/superpowers/specs/2026-03-31-sysmon-dashboard-design.md b/docs/superpowers/specs/2026-03-31-sysmon-dashboard-design.md new file mode 100644 index 000000000..2d30523a9 --- /dev/null +++ b/docs/superpowers/specs/2026-03-31-sysmon-dashboard-design.md @@ -0,0 +1,254 @@ +# Sysmon Dashboard View Design + +**Date:** 2026-03-31 +**Status:** Approved + +## Context + +The Sysmon workspace currently has 3 tabs: Events (virtualized list), Summary (basic metric cards + event type table), and Config (metadata viewer). The Summary tab provides only raw numbers with no visualizations. Users analyzing Sysmon EVTX data need an at-a-glance dashboard with charts and ranked lists to quickly understand event patterns, identify top processes, network activity, DNS queries, security alerts, file activity, and registry changes. + +## Decision Summary + +- **Layout:** Single scrollable dashboard as a new tab (default when data loads) +- **Charts:** `@fluentui/react-charts` v9 (React 19 compatible, consistent with Fluent UI v9 design system) +- **Aggregation:** Backend (Rust) computes all dashboard data — no frontend-side aggregation +- **Timeline granularity:** User-selectable (minute/hour/day) with all 3 pre-computed by backend + +## 1. Backend Data Model + +### New Structs (`src-tauri/src/sysmon/models.rs`) + +```rust +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TimeBucket { + pub timestamp: String, + pub timestamp_ms: i64, + pub count: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RankedItem { + pub name: String, + pub count: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SecuritySummary { + pub total_warnings: usize, + pub total_errors: usize, + pub events_by_type: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SysmonDashboardData { + pub timeline_minute: Vec, + pub timeline_hourly: Vec, + pub timeline_daily: Vec, + pub top_processes: Vec, + pub top_destinations: Vec, + pub top_ports: Vec, + pub top_dns_queries: Vec, + pub security_events: SecuritySummary, + pub top_target_files: Vec, + pub top_registry_keys: Vec, +} +``` + +### Updated Result + +`SysmonAnalysisResult` gains a `dashboard: SysmonDashboardData` field alongside existing `summary` and `config`. + +## 2. Backend Aggregation + +### New Function (`src-tauri/src/sysmon/evtx_parser.rs`) + +`build_dashboard_data(events: &[SysmonEvent]) -> SysmonDashboardData` + +**Timeline buckets:** +- Iterate events once, bucket `timestamp_ms` into minute/hour/day maps using integer division (ms / 60000, ms / 3600000, ms / 86400000) +- Convert each map to sorted `Vec` +- Generate ISO 8601 timestamp strings for each bucket start (e.g., `2024-01-15T10:00:00Z` for hourly, `2024-01-15T00:00:00Z` for daily) + +**Top-N lists (all capped at 20):** +- **Processes:** Count `image` field across all events, sort desc +- **Destinations:** Filter `NetworkConnect` events, count `destination_ip` (prefer `destination_hostname` if present) +- **Ports:** Filter `NetworkConnect` events, count `destination_port` +- **DNS:** Filter `DnsQuery` events, count `query_name` +- **Files:** Filter file-related event types (`FileCreate`, `FileCreateTime`, `FileDelete`, `FileDeleteDetected`, `FileBlockExecutable`, `FileBlockShredding`, `FileExecutableDetected`, `FileCreateStreamHash`), count `target_filename` +- **Registry:** Filter registry event types (`RegistryAddOrDelete`, `RegistryValueSet`, `RegistryRename`), count `target_object` + +**Security summary:** +- Filter events where severity is Warning or Error +- Count totals for each severity +- Group by event type, sort desc + +### Integration + +Called from `analyze_sysmon_logs` command in `src-tauri/src/commands/sysmon.rs` after events are sorted, alongside existing `build_summary()` and `extract_config()`. + +## 3. TypeScript Types + +### New Types (`src/types/sysmon.ts`) + +```typescript +export interface TimeBucket { + timestamp: string; + timestampMs: number; + count: number; +} + +export interface RankedItem { + name: string; + count: number; +} + +export interface SecuritySummary { + totalWarnings: number; + totalErrors: number; + eventsByType: RankedItem[]; +} + +export interface SysmonDashboardData { + timelineMinute: TimeBucket[]; + timelineHourly: TimeBucket[]; + timelineDaily: TimeBucket[]; + topProcesses: RankedItem[]; + topDestinations: RankedItem[]; + topPorts: RankedItem[]; + topDnsQueries: RankedItem[]; + securityEvents: SecuritySummary; + topTargetFiles: RankedItem[]; + topRegistryKeys: RankedItem[]; +} +``` + +`SysmonAnalysisResult` updated to include `dashboard: SysmonDashboardData`. + +## 4. Store Changes + +### `src/stores/sysmon-store.ts` + +- Add `dashboard: SysmonDashboardData | null` to state (initial: `null`) +- `setResults()` populates `dashboard` from `result.dashboard` +- `clear()` resets `dashboard` to `null` +- Update `activeTab` union type to include `"dashboard"` +- Default `activeTab` set to `"dashboard"` in `setResults()` + +## 5. New Dependency + +```bash +npm install @fluentui/react-charts +``` + +`@fluentui/react-charts` v9.3.2 — Fluent UI v9 charting library, React 19 compatible. + +Chart components used: +- `VerticalBarChart` — event volume timeline +- `DonutChart` — event type breakdown +- `HorizontalBarChart` — top-N ranked lists +- All respect Fluent UI theming (dark/light mode) + +## 6. Frontend Components + +### Component Architecture + +All new components in `src/components/sysmon/`: + +| Component | Purpose | +|-----------|---------| +| `SysmonDashboardView.tsx` | Main scrollable container with responsive 2-column grid | +| `DashboardMetricCards.tsx` | Hero row: total events, unique processes, unique computers, time range, parse errors | +| `DashboardTimeline.tsx` | VerticalBarChart + minute/hour/day dropdown | +| `DashboardEventTypeChart.tsx` | DonutChart of event type distribution | +| `DashboardSecurityAlerts.tsx` | Warning/error totals + breakdown by event type | +| `DashboardTopList.tsx` | Reusable HorizontalBarChart for top-N lists (used 6 times) | + +### Layout + +``` +┌─────────────────────────────────────────────────────┐ +│ METRIC CARDS ROW (full width) │ +│ [Total Events] [Processes] [Computers] [Range] [E] │ +├─────────────────────────────────────────────────────┤ +│ EVENT VOLUME TIMELINE (full width) [Min|Hr|Day] │ +│ VerticalBarChart │ +├──────────────────────────┬──────────────────────────┤ +│ EVENT TYPE BREAKDOWN │ SECURITY ALERTS │ +│ DonutChart │ Counts + table │ +├──────────────────────────┬──────────────────────────┤ +│ TOP PROCESSES │ NETWORK ACTIVITY │ +│ HorizontalBarChart │ Destinations + Ports │ +├──────────────────────────┬──────────────────────────┤ +│ DNS QUERIES │ FILE ACTIVITY │ +│ HorizontalBarChart │ HorizontalBarChart │ +├─────────────────────────────────────────────────────┤ +│ REGISTRY ACTIVITY (full width) │ +│ HorizontalBarChart │ +└─────────────────────────────────────────────────────┘ +``` + +- Responsive grid: `grid-template-columns: repeat(auto-fit, minmax(400px, 1fr))` +- Each widget in a card container with Fluent UI tokens for background/border +- Gap: 16px between cards +- Full-width widgets span the grid with `grid-column: 1 / -1` + +### `DashboardTopList.tsx` (Reusable) + +Props: `title: string`, `items: RankedItem[]`, `emptyMessage?: string` + +Used for: top processes, destinations, ports, DNS queries, files, registry keys. Shows "No data" message when items array is empty. + +## 7. Tab Integration + +### `SysmonWorkspace.tsx` + +- Add `"dashboard"` to tab list as first tab +- Tab order: **Dashboard** | Events (n) | Summary | Config +- Dashboard is the default active tab when results load +- No count badge on Dashboard tab + +## 8. Styling + +- All styles via Fluent UI `makeStyles` and design tokens +- Metric cards: horizontal flex row, `tokens.colorNeutralBackground3` background +- Widget cards: consistent padding (16px), border radius, subtle border +- Charts inherit theme automatically from `FluentProvider` +- Dark/light theme support via Fluent UI's built-in theming + +## 9. Files Changed + +### Backend (3 files modified) + +| File | Change | +|------|--------| +| `src-tauri/src/sysmon/models.rs` | Add `TimeBucket`, `RankedItem`, `SecuritySummary`, `SysmonDashboardData`; add `dashboard` to `SysmonAnalysisResult` | +| `src-tauri/src/sysmon/evtx_parser.rs` | Add `build_dashboard_data()` function | +| `src-tauri/src/commands/sysmon.rs` | Call `build_dashboard_data()`, include in result | + +### Frontend (4 modified, 6 new) + +| File | Change | +|------|--------| +| `package.json` | Add `@fluentui/react-charts` | +| `src/types/sysmon.ts` | Add dashboard types, update `SysmonAnalysisResult` | +| `src/stores/sysmon-store.ts` | Add `dashboard` state, update `activeTab` | +| `src/components/sysmon/SysmonWorkspace.tsx` | Add Dashboard tab as default | +| `src/components/sysmon/SysmonDashboardView.tsx` | **New** | +| `src/components/sysmon/DashboardMetricCards.tsx` | **New** | +| `src/components/sysmon/DashboardTimeline.tsx` | **New** | +| `src/components/sysmon/DashboardEventTypeChart.tsx` | **New** | +| `src/components/sysmon/DashboardSecurityAlerts.tsx` | **New** | +| `src/components/sysmon/DashboardTopList.tsx` | **New** | + +## 10. Verification + +1. `cargo check` from `src-tauri/` — Rust types compile +2. `cargo test` from `src-tauri/` — existing tests pass + new unit tests for `build_dashboard_data()` +3. `cargo clippy -- -D warnings` — zero warnings +4. `npx tsc --noEmit` — TypeScript compiles +5. Manual: open Sysmon EVTX file, Dashboard tab appears as default, all 9 widgets render +6. Manual: switch timeline granularity (minute/hour/day), chart updates +7. Manual: empty data edge cases (no network events = "No data" message) +8. Manual: dark/light theme toggle, charts respect theme diff --git a/package-lock.json b/package-lock.json index e70c304c2..ab9668c69 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,15 @@ { "name": "cmtrace-open", - "version": "1.0.3", + "version": "1.0.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cmtrace-open", - "version": "1.0.3", + "version": "1.0.2", "license": "MIT", "dependencies": { + "@fluentui/react-charts": "^9.3.16", "@fluentui/react-components": "^9.73.3", "@tanstack/react-virtual": "^3.13.21", "@tauri-apps/api": "^2.10.1", @@ -1067,6 +1068,16 @@ "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", "license": "MIT" }, + "node_modules/@fluentui/chart-utilities": { + "version": "1.1.38", + "resolved": "https://registry.npmjs.org/@fluentui/chart-utilities/-/chart-utilities-1.1.38.tgz", + "integrity": "sha512-MK2jwYjlZPr7/Y6T+Du445bito+6+RK/H3WTtBaIcUC2WTqOqaVDmV0aUCMl01kIMlWl3epz9DK/Wj/h3ZJMvQ==", + "license": "MIT", + "dependencies": { + "@fluentui/set-version": "^8.2.24", + "tslib": "^2.1.0" + } + }, "node_modules/@fluentui/keyboard-keys": { "version": "9.0.8", "resolved": "https://registry.npmjs.org/@fluentui/keyboard-keys/-/keyboard-keys-9.0.8.tgz", @@ -1227,9 +1238,9 @@ } }, "node_modules/@fluentui/react-button": { - "version": "9.8.2", - "resolved": "https://registry.npmjs.org/@fluentui/react-button/-/react-button-9.8.2.tgz", - "integrity": "sha512-T2xBn6s6DRNH17Y+kLO+uEOaRe89Q20WP1Rs6OzC45cSpOGc+q9ogbPbYBqU7Tr1fur+Xd8LRHxdQJ3j5ufbdw==", + "version": "9.9.0", + "resolved": "https://registry.npmjs.org/@fluentui/react-button/-/react-button-9.9.0.tgz", + "integrity": "sha512-aH3aSjKyxIiNb9jJOUaaIq47w7jP5ESFSRzvMjcWOETvlWo4QgNqEOOsYqpcltM1OrQZ0sTy/isxppRcyMDlcQ==", "license": "MIT", "dependencies": { "@fluentui/keyboard-keys": "^9.0.8", @@ -1302,6 +1313,54 @@ "react-dom": ">=16.14.0 <20.0.0" } }, + "node_modules/@fluentui/react-charts": { + "version": "9.3.16", + "resolved": "https://registry.npmjs.org/@fluentui/react-charts/-/react-charts-9.3.16.tgz", + "integrity": "sha512-FK2z6r+e6VVpqHXAgn6uF8rqGxnyYOA4PewEnYDbJxCmbdKM/WsF2zZHR6HcTtgaOL4JbkZcXauf8tIIZ5Jvxw==", + "license": "MIT", + "dependencies": { + "@fluentui/chart-utilities": "^1.1.38", + "@fluentui/react-button": "^9.9.0", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-overflow": "^9.7.1", + "@fluentui/react-popover": "^9.14.1", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.13", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-tooltip": "^9.9.3", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1", + "@types/d3-array": "^3.0.0", + "@types/d3-axis": "^3.0.0", + "@types/d3-color": "^3.0.0", + "@types/d3-format": "^3.0.0", + "@types/d3-hierarchy": "^3.0.0", + "@types/d3-sankey": "^0.12.3", + "@types/d3-scale": "^4.0.0", + "@types/d3-selection": "^3.0.0", + "@types/d3-shape": "^3.0.0", + "@types/d3-time": "^3.0.0", + "@types/d3-time-format": "^3.0.0", + "d3-array": "^3.0.0", + "d3-axis": "^3.0.0", + "d3-color": "^3.0.0", + "d3-format": "^3.0.0", + "d3-hierarchy": "^3.0.0", + "d3-sankey": "^0.12.3", + "d3-scale": "^4.0.0", + "d3-selection": "^3.0.0", + "d3-shape": "^3.0.0", + "d3-time": "^3.0.0", + "d3-time-format": "^3.0.0" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, "node_modules/@fluentui/react-checkbox": { "version": "9.5.16", "resolved": "https://registry.npmjs.org/@fluentui/react-checkbox/-/react-checkbox-9.5.16.tgz", @@ -1802,9 +1861,9 @@ } }, "node_modules/@fluentui/react-motion": { - "version": "9.13.0", - "resolved": "https://registry.npmjs.org/@fluentui/react-motion/-/react-motion-9.13.0.tgz", - "integrity": "sha512-YdOpW6e7qfvzoWKcqh8hReCqwYEoiEmNBcCprGaupKjWOi9jBbF/JESM1AHI9nOjPd8aY90WUG2+ahvrqfL9LA==", + "version": "9.14.0", + "resolved": "https://registry.npmjs.org/@fluentui/react-motion/-/react-motion-9.14.0.tgz", + "integrity": "sha512-gOy8+fUP1KQRM/J6mRhioCMmUrHW9jbLF0DZ9T8nKPQsLrLaSXHxnnI8DcKZjlYc2fKuZitBnbpximgff6HajQ==", "license": "MIT", "dependencies": { "@fluentui/react-shared-contexts": "^9.26.2", @@ -1819,9 +1878,9 @@ } }, "node_modules/@fluentui/react-motion-components-preview": { - "version": "0.15.2", - "resolved": "https://registry.npmjs.org/@fluentui/react-motion-components-preview/-/react-motion-components-preview-0.15.2.tgz", - "integrity": "sha512-KqHRV8lLmVwOWiHBdpUFA+TwMbuYu9cyzNvmhbMFLVKzZyr3MPgN+97Tf+6QYPf22o99SMT0BPySDv/HiNYanA==", + "version": "0.15.3", + "resolved": "https://registry.npmjs.org/@fluentui/react-motion-components-preview/-/react-motion-components-preview-0.15.3.tgz", + "integrity": "sha512-dUH2+GmEWX9q2ojx70VfFLRqzA9fR4YISC6daXkz3iPx4PtesTDn7jwsuXXquaAhltJeBptJ8+K4jbtBrwCMYQ==", "license": "MIT", "dependencies": { "@fluentui/react-motion": "*", @@ -1908,17 +1967,17 @@ } }, "node_modules/@fluentui/react-popover": { - "version": "9.14.0", - "resolved": "https://registry.npmjs.org/@fluentui/react-popover/-/react-popover-9.14.0.tgz", - "integrity": "sha512-XrZlSfSYhA12j5bna4Sq8N/If2vul7gl8woVrN8U3iQUjdaHB6OAMZ/WMNUdMm35Z+4e4rHClAZxU2dUsbHrmw==", + "version": "9.14.1", + "resolved": "https://registry.npmjs.org/@fluentui/react-popover/-/react-popover-9.14.1.tgz", + "integrity": "sha512-EODa5yWSfDLPDurjWoZXfkf2ccnbQQbk3s1XYRzxA6RDfdVqUI5W64RJzHWBiNhOLzQEhd6Qb4e6Mshj4FSbdQ==", "license": "MIT", "dependencies": { "@fluentui/keyboard-keys": "^9.0.8", "@fluentui/react-aria": "^9.17.10", "@fluentui/react-context-selector": "^9.2.15", "@fluentui/react-jsx-runtime": "^9.4.1", - "@fluentui/react-motion": "^9.13.0", - "@fluentui/react-motion-components-preview": "^0.15.2", + "@fluentui/react-motion": "^9.14.0", + "@fluentui/react-motion-components-preview": "^0.15.3", "@fluentui/react-portal": "^9.8.11", "@fluentui/react-positioning": "^9.22.0", "@fluentui/react-shared-contexts": "^9.26.2", @@ -2603,6 +2662,15 @@ "react-dom": ">=16.14.0 <20.0.0" } }, + "node_modules/@fluentui/set-version": { + "version": "8.2.24", + "resolved": "https://registry.npmjs.org/@fluentui/set-version/-/set-version-8.2.24.tgz", + "integrity": "sha512-8uNi2ThvNgF+6d3q2luFVVdk/wZV0AbRfJ85kkvf2+oSRY+f6QVK0w13vMorNhA5puumKcZniZoAfUF02w7NSg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.1.0" + } + }, "node_modules/@fluentui/tokens": { "version": "1.0.0-alpha.23", "resolved": "https://registry.npmjs.org/@fluentui/tokens/-/tokens-1.0.0-alpha.23.tgz", @@ -3519,6 +3587,105 @@ "assertion-error": "^2.0.1" } }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-axis": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz", + "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-format": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz", + "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==", + "license": "MIT" + }, + "node_modules/@types/d3-hierarchy": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz", + "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==", + "license": "MIT" + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-sankey": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/@types/d3-sankey/-/d3-sankey-0.12.5.tgz", + "integrity": "sha512-/3RZSew0cLAtzGQ+C89hq/Rp3H20QJuVRSqFy6RKLe7E0B8kd2iOS1oBsodrgds4PcNVpqWhdUEng/SHvBcJ6Q==", + "license": "MIT", + "dependencies": { + "@types/d3-shape": "^1" + } + }, + "node_modules/@types/d3-sankey/node_modules/@types/d3-path": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-1.0.11.tgz", + "integrity": "sha512-4pQMp8ldf7UaB/gR8Fvvy69psNHkTpD/pVw3vmEi8iZAB9EPMBruB1JvHO4BIq9QkUUd2lV1F5YXpMNj7JPBpw==", + "license": "MIT" + }, + "node_modules/@types/d3-sankey/node_modules/@types/d3-shape": { + "version": "1.3.12", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-1.3.12.tgz", + "integrity": "sha512-8oMzcd4+poSLGgV0R1Q1rOlx/xdmozS4Xab7np0eamFFUYq71AU9pOCJEFnkXW2aI/oXdVYJzw6pssbSut7Z9Q==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "^1" + } + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-time-format": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-3.0.4.tgz", + "integrity": "sha512-or9DiDnYI1h38J9hxKEsw513+KVuFbEVhl7qdxcaudoiqWWepapUen+2vAriFGexr6W5+P4l9+HJrB39GG+oRg==", + "license": "MIT" + }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -3902,6 +4069,197 @@ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-axis": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz", + "integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-hierarchy": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", + "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-sankey": { + "version": "0.12.3", + "resolved": "https://registry.npmjs.org/d3-sankey/-/d3-sankey-0.12.3.tgz", + "integrity": "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-array": "1 - 2", + "d3-shape": "^1.2.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-array": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz", + "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==", + "license": "BSD-3-Clause", + "dependencies": { + "internmap": "^1.0.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-path": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", + "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-sankey/node_modules/d3-shape": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", + "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-path": "1" + } + }, + "node_modules/d3-sankey/node_modules/internmap": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz", + "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==", + "license": "ISC" + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-3.0.0.tgz", + "integrity": "sha512-UXJh6EKsHBTjopVqZBhFysQcoXSv/5yLONZvkQ5Kk3qbwiUYkdX17Xa1PT6U1ZWXGGfB1ey5L8dKMlFq2DO0Ag==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-time": "1 - 2" + } + }, + "node_modules/d3-time-format/node_modules/d3-array": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz", + "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==", + "license": "BSD-3-Clause", + "dependencies": { + "internmap": "^1.0.0" + } + }, + "node_modules/d3-time-format/node_modules/d3-time": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-2.1.1.tgz", + "integrity": "sha512-/eIQe/eR4kCQwq7yxi7z4c6qEXf2IYGcjoWB5OOQy4Tq9Uv39/947qlDcN2TLkiTzQWzvnsuYPB9TrWaNfipKQ==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-array": "2" + } + }, + "node_modules/d3-time-format/node_modules/internmap": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz", + "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==", + "license": "ISC" + }, "node_modules/data-urls": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", @@ -4165,6 +4523,15 @@ "node": ">=8" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/is-potential-custom-element-name": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", diff --git a/package.json b/package.json index 59bce970c..ba34555b6 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "license": "MIT", "type": "module", "dependencies": { + "@fluentui/react-charts": "^9.3.16", "@fluentui/react-components": "^9.73.3", "@tanstack/react-virtual": "^3.13.21", "@tauri-apps/api": "^2.10.1", diff --git a/src-tauri/src/commands/known_sources.rs b/src-tauri/src/commands/known_sources.rs index 45468f277..798ded26d 100644 --- a/src-tauri/src/commands/known_sources.rs +++ b/src-tauri/src/commands/known_sources.rs @@ -505,6 +505,24 @@ fn windows_known_log_sources() -> Vec { }, None, ), + // --- Sysmon live event log --- + windows_known_source( + "windows-sysmon-live-events", + "Sysmon Live Event Log", + "Query the live Microsoft-Windows-Sysmon/Operational event log channel for real-time Sysmon events.", + KnownSourcePathKind::Folder, + "live-event-log", + &[], + KnownSourceGroupingMetadata { + family_id: "windows-sysmon".to_string(), + family_label: "Windows Sysmon".to_string(), + group_id: "sysmon-events".to_string(), + group_label: "Sysmon Events".to_string(), + group_order: 60, + source_order: 10, + }, + None, + ), ] } diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 2093f8ad0..813a2e88e 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -23,4 +23,5 @@ pub mod macos_diag; pub mod parsing; pub mod registry_ops; pub mod reveal; +pub mod sysmon; pub mod system_preferences; diff --git a/src-tauri/src/commands/sysmon.rs b/src-tauri/src/commands/sysmon.rs new file mode 100644 index 000000000..f39ff4c81 --- /dev/null +++ b/src-tauri/src/commands/sysmon.rs @@ -0,0 +1,288 @@ +use std::path::Path; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use rayon::prelude::*; +use serde::Serialize; +use tauri::{async_runtime, AppHandle, Emitter}; + +use crate::sysmon::evtx_parser; +use crate::sysmon::models::SysmonAnalysisResult; + +const SYSMON_ANALYSIS_PROGRESS_EVENT: &str = "sysmon-analysis-progress"; + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct SysmonAnalysisProgress { + request_id: String, + stage: &'static str, + message: String, + completed_files: usize, + total_files: usize, +} + +/// Analyze Sysmon EVTX files and return structured results. +/// +/// Accepts either: +/// - A single .evtx file path +/// - A directory containing .evtx files +/// - When `include_live_event_logs` is true, also queries the live Windows Event Log +#[tauri::command] +pub async fn analyze_sysmon_logs( + path: String, + request_id: String, + include_live_event_logs: bool, + app: AppHandle, +) -> Result { + async_runtime::spawn_blocking(move || { + analyze_sysmon_blocking(path, request_id, include_live_event_logs, app) + }) + .await + .map_err(|error| { + crate::error::AppError::Internal(format!("Sysmon analysis task failed: {}", error)) + })? +} + +/// Sentinel path value used by known-source presets to indicate "query the live +/// Windows Event Log rather than reading files from disk". +const LIVE_EVENT_LOG_SENTINEL: &str = "live-event-log"; + +fn is_live_only_source(path: &str) -> bool { + path == LIVE_EVENT_LOG_SENTINEL || path.is_empty() +} + +fn analyze_sysmon_blocking( + path: String, + request_id: String, + include_live_event_logs: bool, + app: AppHandle, +) -> Result { + let live_only = is_live_only_source(&path); + + // --- File-based events (skip when live-only) --- + let mut all_events = Vec::new(); + let mut total_errors = 0u64; + let mut source_files: Vec = Vec::new(); + let mut total_files = 0usize; + + if !live_only { + let source_path = Path::new(&path); + + // Emit: discovery stage + let _ = app.emit( + SYSMON_ANALYSIS_PROGRESS_EVENT, + SysmonAnalysisProgress { + request_id: request_id.clone(), + stage: "discovery", + message: "Discovering EVTX files...".to_string(), + completed_files: 0, + total_files: 0, + }, + ); + + // Discover files + let evtx_files = if source_path.is_file() { + vec![source_path.to_path_buf()] + } else if source_path.is_dir() { + evtx_parser::discover_sysmon_evtx_files(source_path) + } else { + return Err(crate::error::AppError::InvalidInput(format!( + "Path does not exist: {}", + path + ))); + }; + + if evtx_files.is_empty() && !include_live_event_logs { + return Err(crate::error::AppError::InvalidInput( + "No .evtx files found at the specified path".to_string(), + )); + } + + // Filter to only Sysmon EVTX files (if directory, validate provider) + let sysmon_files: Vec<_> = if source_path.is_file() { + // Single file — trust the user + evtx_files + } else { + evtx_files + .into_iter() + .filter(|f| evtx_parser::is_sysmon_evtx(f)) + .collect() + }; + + if sysmon_files.is_empty() && !include_live_event_logs { + return Err(crate::error::AppError::InvalidInput( + "No Sysmon EVTX files found. Ensure the file contains Microsoft-Windows-Sysmon events." + .to_string(), + )); + } + + total_files = sysmon_files.len(); + + if total_files > 0 { + // Emit: parsing stage + let _ = app.emit( + SYSMON_ANALYSIS_PROGRESS_EVENT, + SysmonAnalysisProgress { + request_id: request_id.clone(), + stage: "parsing", + message: format!("Parsing {} Sysmon EVTX file(s)...", total_files), + completed_files: 0, + total_files, + }, + ); + + // Parse files in parallel + let completed = AtomicUsize::new(0); + let parse_results: Vec<_> = sysmon_files + .par_iter() + .enumerate() + .filter_map(|(idx, file_path)| { + let id_offset = (idx as u64) * 100_000; + match evtx_parser::parse_sysmon_evtx(file_path, id_offset) { + Ok(events) => { + let done = completed.fetch_add(1, Ordering::Relaxed) + 1; + let _ = app.emit( + SYSMON_ANALYSIS_PROGRESS_EVENT, + SysmonAnalysisProgress { + request_id: request_id.clone(), + stage: "parsing", + message: format!( + "Parsed {} ({}/{})", + file_path.file_name().unwrap_or_default().to_string_lossy(), + done, + total_files + ), + completed_files: done, + total_files, + }, + ); + Some((events, 0u64)) + } + Err(e) => { + log::warn!("event=sysmon_file_error file=\"{}\" error=\"{}\"", file_path.display(), e); + let done = completed.fetch_add(1, Ordering::Relaxed) + 1; + let _ = app.emit( + SYSMON_ANALYSIS_PROGRESS_EVENT, + SysmonAnalysisProgress { + request_id: request_id.clone(), + stage: "parsing", + message: format!( + "Error parsing {} ({}/{})", + file_path.file_name().unwrap_or_default().to_string_lossy(), + done, + total_files + ), + completed_files: done, + total_files, + }, + ); + Some((Vec::new(), 1u64)) + } + } + }) + .collect(); + + for (events, errors) in parse_results { + all_events.extend(events); + total_errors += errors; + } + + source_files = sysmon_files + .iter() + .map(|f| f.to_string_lossy().to_string()) + .collect(); + } + } + + // --- Live event log events --- + if include_live_event_logs { + let _ = app.emit( + SYSMON_ANALYSIS_PROGRESS_EVENT, + SysmonAnalysisProgress { + request_id: request_id.clone(), + stage: "live-query", + message: "Querying live Sysmon event log...".to_string(), + completed_files: total_files, + total_files, + }, + ); + + match evtx_parser::parse_sysmon_live_events() { + Ok(live_events) => { + log::info!( + "event=sysmon_live_query_success count={}", + live_events.len() + ); + if !live_events.is_empty() { + if let Some(first) = live_events.first() { + source_files.push(first.source_file.clone()); + } + } + all_events.extend(live_events); + } + Err(e) => { + log::warn!("event=sysmon_live_query_failed error=\"{}\"", e); + if live_only { + return Err(crate::error::AppError::Internal(e)); + } + // Non-fatal when combined with file-based events + total_errors += 1; + } + } + } + + if all_events.is_empty() { + return Err(crate::error::AppError::InvalidInput( + "No Sysmon events found from files or live event log.".to_string(), + )); + } + + // Sort by timestamp_ms when available, falling back to timestamp and then record_id for stable ordering + all_events.sort_by(|a, b| { + match (a.timestamp_ms, b.timestamp_ms) { + (Some(ta), Some(tb)) => ta + .cmp(&tb) + .then_with(|| a.record_id.cmp(&b.record_id)), + (Some(_), None) => std::cmp::Ordering::Less, // nulls last + (None, Some(_)) => std::cmp::Ordering::Greater, // nulls last + (None, None) => a.timestamp + .cmp(&b.timestamp) + .then_with(|| a.record_id.cmp(&b.record_id)), + } + }); + + // Reassign sequential IDs after sorting + for (i, event) in all_events.iter_mut().enumerate() { + event.id = i as u64; + } + + // Build summary + let summary = evtx_parser::build_summary(&all_events, source_files, total_errors); + + // Extract configuration + let config = evtx_parser::extract_config(&all_events, &summary); + + // Build dashboard aggregations + let dashboard = evtx_parser::build_dashboard_data(&all_events); + + let total_source_count = if include_live_event_logs { total_files + 1 } else { total_files }; + + // Emit: complete + let _ = app.emit( + SYSMON_ANALYSIS_PROGRESS_EVENT, + SysmonAnalysisProgress { + request_id: request_id.clone(), + stage: "complete", + message: format!("Analysis complete: {} events from {} source(s)", all_events.len(), total_source_count), + completed_files: total_source_count, + total_files: total_source_count, + }, + ); + + Ok(SysmonAnalysisResult { + events: all_events, + summary, + config, + dashboard, + source_path: path, + }) +} diff --git a/src-tauri/src/intune/eventlog_win32.rs b/src-tauri/src/intune/eventlog_win32.rs index f6e2bec7f..874d63260 100644 --- a/src-tauri/src/intune/eventlog_win32.rs +++ b/src-tauri/src/intune/eventlog_win32.rs @@ -133,7 +133,9 @@ mod windows_impl { fn render_event_xml(event_handle: EVT_HANDLE) -> Result { let mut buffer_used = 0u32; let mut property_count = 0u32; - let mut buffer = vec![0u16; 4096]; + // 16 KB initial buffer — Sysmon events with long command lines and + // hashes can easily exceed the previous 4 KB default. + let mut buffer = vec![0u16; 8192]; loop { match unsafe { @@ -239,20 +241,33 @@ mod windows_impl { } } + /// Check if an error matches a Win32 error code. + /// Handles both raw Win32 codes and HRESULT-wrapped forms + /// (the Windows crate may return either depending on the API). + fn is_win32_error(error: &Error, win32_code: u32) -> bool { + let raw = error.code().0 as u32; + // Direct Win32 code comparison + if raw == win32_code { + return true; + } + // HRESULT_FROM_WIN32: 0x80070000 | win32_code + raw == (0x8007_0000 | win32_code) + } + fn is_insufficient_buffer(error: &Error) -> bool { - error.code().0 as u32 == 122 + is_win32_error(error, 122) // ERROR_INSUFFICIENT_BUFFER } fn is_no_more_items(error: &Error) -> bool { - error.code().0 as u32 == 259 + is_win32_error(error, 259) // ERROR_NO_MORE_ITEMS } fn is_not_found(error: &Error) -> bool { - error.code().0 as u32 == 1168 + is_win32_error(error, 1168) // ERROR_NOT_FOUND } fn is_message_not_found(error: &Error) -> bool { - error.code().0 as u32 == 15027 + is_win32_error(error, 15027) // ERROR_EVT_MESSAGE_NOT_FOUND } } diff --git a/src-tauri/src/intune/evtx_parser.rs b/src-tauri/src/intune/evtx_parser.rs index 91cf60fc4..22514cffb 100644 --- a/src-tauri/src/intune/evtx_parser.rs +++ b/src-tauri/src/intune/evtx_parser.rs @@ -482,24 +482,24 @@ pub(crate) fn parse_live_event_record( id: u64, fallback_channel: &str, ) -> Option { - let channel_raw = extract_regex_value(xml, &channel_re()) + let channel_raw = extract_regex_value(xml, channel_re()) .unwrap_or_else(|| fallback_channel.to_string()); let channel = EventLogChannel::from_channel_string(&channel_raw); - let timestamp = extract_regex_value(xml, &time_re())?; + let timestamp = extract_regex_value(xml, time_re())?; - let provider = extract_regex_value(xml, &provider_re()).unwrap_or_default(); - let event_id = extract_regex_value(xml, &event_id_re()) + let provider = extract_regex_value(xml, provider_re()).unwrap_or_default(); + let event_id = extract_regex_value(xml, event_id_re()) .and_then(|value| value.parse::().ok()) .unwrap_or(0); - let level = extract_regex_value(xml, &level_re()) + let level = extract_regex_value(xml, level_re()) .and_then(|value| value.parse::().ok()) .unwrap_or(0); - let computer = extract_regex_value(xml, &computer_re()); - let correlation_activity_id = extract_regex_value(xml, &activity_re()); + let computer = extract_regex_value(xml, computer_re()); + let correlation_activity_id = extract_regex_value(xml, activity_re()); let message = rendered_message .filter(|value| !value.trim().is_empty()) .unwrap_or_else(|| { - extract_regex_value(xml, &message_re()) + extract_regex_value(xml, message_re()) .map(|value| decode_xml_text(&value)) .unwrap_or_default() }); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d069fbfdd..e44e15cba 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -14,6 +14,7 @@ pub mod macos_diag; mod menu; mod models; pub mod parser; +pub mod sysmon; mod state; mod watcher; @@ -118,6 +119,7 @@ pub fn run() { event_log::commands::evtx_enumerate_channels, #[cfg(feature = "event-log")] event_log::commands::evtx_query_channels, + commands::sysmon::analyze_sysmon_logs, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/sysmon/evtx_parser.rs b/src-tauri/src/sysmon/evtx_parser.rs new file mode 100644 index 000000000..ce2cdb57d --- /dev/null +++ b/src-tauri/src/sysmon/evtx_parser.rs @@ -0,0 +1,1140 @@ +use std::collections::{HashMap, HashSet}; +use std::fs; +use std::path::{Path, PathBuf}; + +use evtx::EvtxParser; +#[cfg(target_os = "windows")] +use regex::Regex; +use serde_json::Value; +#[cfg(target_os = "windows")] +use std::sync::OnceLock; + +#[cfg(target_os = "windows")] +use crate::intune::eventlog_win32; +use super::models::{ + RankedItem, SecuritySummary, SysmonConfig, SysmonDashboardData, SysmonEvent, SysmonEventType, + SysmonEventTypeCount, SysmonSeverity, SysmonSummary, TimeBucket, +}; + +/// Maximum entries to pull from the live Windows Event Log. +#[cfg(target_os = "windows")] +const MAX_LIVE_ENTRIES: usize = 10_000; + +/// The Sysmon ETW provider name. +const SYSMON_PROVIDER: &str = "Microsoft-Windows-Sysmon"; + +/// The Sysmon Operational event log channel. +#[cfg(target_os = "windows")] +const SYSMON_CHANNEL: &str = "Microsoft-Windows-Sysmon/Operational"; + +// --------------------------------------------------------------------------- +// File discovery +// --------------------------------------------------------------------------- + +/// Discovers Sysmon .evtx files in a directory. +/// Checks the root and the following common subdirectories: +/// "evidence", "event-logs", "evidence/event-logs". +/// Deduplicates results by sorting and deduplicating raw PathBufs. +pub fn discover_sysmon_evtx_files(root: &Path) -> Vec { + let mut files = Vec::new(); + + // Direct .evtx files in root + collect_evtx_files(root, &mut files); + + // Check common subdirectories + for subdir in &["evidence", "event-logs", "evidence/event-logs"] { + let dir = root.join(subdir); + if dir.is_dir() { + collect_evtx_files(&dir, &mut files); + } + } + + // Deduplicate by canonical path + files.sort(); + files.dedup(); + files +} + +fn collect_evtx_files(dir: &Path, out: &mut Vec) { + let entries = match fs::read_dir(dir) { + Ok(e) => e, + Err(_) => return, + }; + + for entry in entries.flatten() { + let path = entry.path(); + if path.is_file() { + if let Some(ext) = path.extension() { + if ext.eq_ignore_ascii_case("evtx") { + out.push(path); + } + } + } + } +} + +/// Returns true if the EVTX file contains Sysmon events (checks first few records). +pub fn is_sysmon_evtx(path: &Path) -> bool { + let mut parser = match EvtxParser::from_path(path) { + Ok(p) => p, + Err(_) => return false, + }; + + // Sample first 5 records to check provider + for record in parser.records_json().take(5).flatten() { + if let Ok(json) = serde_json::from_str::(&record.data) { + let provider = json["Event"]["System"]["Provider"]["#attributes"]["Name"] + .as_str() + .unwrap_or(""); + if provider == SYSMON_PROVIDER { + return true; + } + } + } + false +} + +// --------------------------------------------------------------------------- +// Single-file parser +// --------------------------------------------------------------------------- + +/// Parses a single Sysmon EVTX file into `SysmonEvent` records. +pub fn parse_sysmon_evtx(path: &Path, id_offset: u64) -> Result, String> { + let mut parser = EvtxParser::from_path(path) + .map_err(|e| format!("Failed to open EVTX file {}: {}", path.display(), e))?; + + let source_file = path.to_string_lossy().to_string(); + let mut events = Vec::new(); + let mut current_id = id_offset; + + for record_result in parser.records_json() { + let record = match record_result { + Ok(r) => r, + Err(e) => { + log::warn!( + "event=sysmon_record_skip file=\"{}\" error=\"{}\"", + source_file, + e + ); + continue; + } + }; + + let json: Value = match serde_json::from_str(&record.data) { + Ok(v) => v, + Err(_) => continue, + }; + + let system = &json["Event"]["System"]; + + // Only process Sysmon events + let provider = system["Provider"]["#attributes"]["Name"] + .as_str() + .unwrap_or(""); + if provider != SYSMON_PROVIDER { + continue; + } + + let event_id = extract_event_id(system); + let event_type = SysmonEventType::from_event_id(event_id); + + let timestamp = system["TimeCreated"]["#attributes"]["SystemTime"] + .as_str() + .unwrap_or("") + .to_string(); + + let timestamp_ms = parse_timestamp_ms(×tamp); + + let computer = system["Computer"].as_str().map(|s| s.to_string()); + + let record_id = record.event_record_id; + + let event_data = &json["Event"]["EventData"]; + + let severity = derive_severity(event_id); + + // Extract common and event-specific fields from EventData + let rule_name = get_data_str(event_data, "RuleName"); + let utc_time = get_data_str(event_data, "UtcTime"); + let process_guid = get_data_str(event_data, "ProcessGuid"); + let process_id = get_data_u32(event_data, "ProcessId"); + let image = get_data_str(event_data, "Image"); + let command_line = get_data_str(event_data, "CommandLine"); + let user = get_data_str(event_data, "User"); + let hashes = get_data_str(event_data, "Hashes"); + let parent_image = get_data_str(event_data, "ParentImage"); + let parent_command_line = get_data_str(event_data, "ParentCommandLine"); + let parent_process_id = get_data_u32(event_data, "ParentProcessId"); + let target_filename = get_data_str(event_data, "TargetFilename"); + let protocol = get_data_str(event_data, "Protocol"); + let source_ip = get_data_str(event_data, "SourceIp"); + let source_port = get_data_u16(event_data, "SourcePort"); + let destination_ip = get_data_str(event_data, "DestinationIp"); + let destination_port = get_data_u16(event_data, "DestinationPort"); + let destination_hostname = get_data_str(event_data, "DestinationHostname"); + let target_object = get_data_str(event_data, "TargetObject"); + let details = get_data_str(event_data, "Details"); + let query_name = get_data_str(event_data, "QueryName"); + let query_results = get_data_str(event_data, "QueryResults"); + let source_image = get_data_str(event_data, "SourceImage"); + let target_image = get_data_str(event_data, "TargetImage"); + let granted_access = get_data_str(event_data, "GrantedAccess"); + + let message = build_message(event_id, &event_type, event_data); + + events.push(SysmonEvent { + id: current_id, + event_id, + event_type, + event_type_display: event_type.display_name().to_string(), + severity, + timestamp, + timestamp_ms, + computer, + record_id, + rule_name, + utc_time, + process_guid, + process_id, + image, + command_line, + user, + hashes, + parent_image, + parent_command_line, + parent_process_id, + target_filename, + protocol, + source_ip, + source_port, + destination_ip, + destination_port, + destination_hostname, + target_object, + details, + query_name, + query_results, + source_image, + target_image, + granted_access, + message, + source_file: source_file.clone(), + }); + + current_id += 1; + } + + Ok(events) +} + +// --------------------------------------------------------------------------- +// Summary builder +// --------------------------------------------------------------------------- + +/// Builds a summary from a slice of parsed Sysmon events. +pub fn build_summary( + events: &[SysmonEvent], + source_files: Vec, + parse_errors: u64, +) -> SysmonSummary { + let mut type_counts: HashMap = HashMap::new(); + let mut unique_processes: HashSet = HashSet::new(); + let mut unique_computers: HashSet = HashSet::new(); + let mut earliest_ms: Option = None; + let mut latest_ms: Option = None; + let mut earliest_ts: Option = None; + let mut latest_ts: Option = None; + + for event in events { + *type_counts.entry(event.event_id).or_insert(0) += 1; + + if let Some(ref guid) = event.process_guid { + if guid != "-" { + unique_processes.insert(guid.clone()); + } + } + + if let Some(ref computer) = event.computer { + unique_computers.insert(computer.clone()); + } + + if !event.timestamp.is_empty() { + if let Some(ms) = event.timestamp_ms { + if earliest_ms.map_or(true, |existing| ms < existing) { + earliest_ms = Some(ms); + earliest_ts = Some(event.timestamp.clone()); + } + if latest_ms.map_or(true, |existing| ms > existing) { + latest_ms = Some(ms); + latest_ts = Some(event.timestamp.clone()); + } + } else { + // Fallback: use string comparison when no numeric ms is available + // for this event. String-only events can still update earliest/latest + // even when other events had numeric timestamps. + let ts = event.timestamp.as_str(); + if earliest_ts.as_deref().map_or(true, |existing| ts < existing) { + earliest_ts = Some(event.timestamp.clone()); + } + if latest_ts.as_deref().map_or(true, |existing| ts > existing) { + latest_ts = Some(event.timestamp.clone()); + } + } + } + } + + let mut event_type_counts: Vec = type_counts + .into_iter() + .map(|(eid, count)| { + let et = SysmonEventType::from_event_id(eid); + SysmonEventTypeCount { + event_id: eid, + event_type: et, + display_name: et.display_name().to_string(), + count, + } + }) + .collect(); + event_type_counts.sort_by(|a, b| b.count.cmp(&a.count)); + + SysmonSummary { + total_events: events.len() as u64, + event_type_counts, + unique_processes: unique_processes.len() as u64, + unique_computers: unique_computers.len() as u64, + earliest_timestamp: earliest_ts, + latest_timestamp: latest_ts, + source_files, + parse_errors, + } +} + +// --------------------------------------------------------------------------- +// Configuration extraction +// --------------------------------------------------------------------------- + +/// Extracts Sysmon configuration metadata from parsed events. +pub fn extract_config(events: &[SysmonEvent], summary: &SysmonSummary) -> SysmonConfig { + let mut schema_version: Option = None; + let mut hash_algorithms: Option = None; + let mut last_config_change: Option = None; + let configuration_xml: Option = None; + let mut sysmon_version: Option = None; + + // Look for ConfigChange events (ID 16) — they contain the config hash and sometimes XML + // Look for ServiceStateChange events (ID 4) — they contain version info + for event in events { + match event.event_id { + 16 => { + // ConfigChange: contains Configuration, ConfigurationFileHash + if last_config_change.is_none() + || event.timestamp.as_str() > last_config_change.as_deref().unwrap_or("") + { + last_config_change = Some(event.timestamp.clone()); + } + // NOTE: Do not populate configuration_xml from event.message. + // The Message field is a human-readable summary and does not reliably + // contain the raw configuration XML. If configuration XML display is + // needed, it should be extracted from the EventData "Configuration" + // field during parsing and exposed via SysmonEvent. + } + 4 => { + // ServiceStateChange: may contain version + if sysmon_version.is_none() { + if let Some(ref msg) = event.details { + if msg.contains("version") || msg.contains("Version") { + sysmon_version = Some(msg.clone()); + } + } + // Also check the message field + if sysmon_version.is_none() && event.message.contains("version") { + sysmon_version = Some(event.message.clone()); + } + } + } + _ => {} + } + } + + // Infer hash algorithms from the first event with Hashes field + for event in events { + if let Some(ref h) = event.hashes { + // Hashes format: "SHA256=abc,MD5=def" or "SHA1=abc" + let algos: Vec<&str> = h + .split(',') + .filter_map(|part| part.split('=').next()) + .collect(); + if !algos.is_empty() { + hash_algorithms = Some(algos.join(",")); + break; + } + } + } + + // Infer schema version from RuleName if it contains schema info (rare) + // This is typically only available from the config itself + for event in events.iter().take(100) { + if let Some(ref rule) = event.rule_name { + if rule.contains("schema") { + schema_version = Some(rule.clone()); + break; + } + } + } + + let found = last_config_change.is_some() || hash_algorithms.is_some() || sysmon_version.is_some(); + + SysmonConfig { + schema_version, + hash_algorithms, + found, + last_config_change, + configuration_xml, + sysmon_version, + active_event_types: summary.event_type_counts.clone(), + } +} + +// --------------------------------------------------------------------------- +// Dashboard aggregations +// --------------------------------------------------------------------------- + +/// Builds pre-computed dashboard aggregations from parsed events. +pub fn build_dashboard_data(events: &[SysmonEvent]) -> SysmonDashboardData { + use chrono::{DateTime, Utc}; + + const TOP_N: usize = 20; + + let estimated_unique = (events.len() / 10).max(64); + + let mut minute_buckets: HashMap = HashMap::with_capacity(estimated_unique); + let mut hourly_buckets: HashMap = HashMap::with_capacity(estimated_unique); + let mut daily_buckets: HashMap = HashMap::with_capacity(estimated_unique); + + let mut process_counts: HashMap = HashMap::with_capacity(estimated_unique); + let mut dest_counts: HashMap = HashMap::with_capacity(estimated_unique); + let mut port_counts: HashMap = HashMap::with_capacity(estimated_unique); + let mut dns_counts: HashMap = HashMap::with_capacity(estimated_unique); + let mut file_counts: HashMap = HashMap::with_capacity(estimated_unique); + let mut registry_counts: HashMap = HashMap::with_capacity(estimated_unique); + + let mut total_warnings: u64 = 0; + let mut total_errors: u64 = 0; + let mut security_type_counts: HashMap = HashMap::with_capacity(estimated_unique); + + for event in events { + if let Some(ms) = event.timestamp_ms { + let minute_key = (ms / 60_000) * 60_000; + let hourly_key = (ms / 3_600_000) * 3_600_000; + let daily_key = (ms / 86_400_000) * 86_400_000; + *minute_buckets.entry(minute_key).or_insert(0) += 1; + *hourly_buckets.entry(hourly_key).or_insert(0) += 1; + *daily_buckets.entry(daily_key).or_insert(0) += 1; + } + + if let Some(ref image) = event.image { + if !image.is_empty() { + *process_counts.entry(image.clone()).or_insert(0) += 1; + } + } + + if event.event_id == 3 { + let dest = event + .destination_hostname + .as_deref() + .filter(|s| !s.is_empty()) + .or(event.destination_ip.as_deref().filter(|s| !s.is_empty())); + if let Some(d) = dest { + *dest_counts.entry(d.to_string()).or_insert(0) += 1; + } + if let Some(port) = event.destination_port { + *port_counts.entry(port.to_string()).or_insert(0) += 1; + } + } + + if event.event_id == 22 { + if let Some(ref qname) = event.query_name { + if !qname.is_empty() { + *dns_counts.entry(qname.clone()).or_insert(0) += 1; + } + } + } + + if matches!(event.event_id, 2 | 11 | 15 | 23 | 24 | 26 | 27 | 28 | 29) { + if let Some(ref tf) = event.target_filename { + if !tf.is_empty() { + *file_counts.entry(tf.clone()).or_insert(0) += 1; + } + } + } + + if let 12..=14 = event.event_id { + if let Some(ref to) = event.target_object { + if !to.is_empty() { + *registry_counts.entry(to.clone()).or_insert(0) += 1; + } + } + } + + match event.severity { + SysmonSeverity::Warning => { + total_warnings += 1; + *security_type_counts + .entry(event.event_type.display_name().to_string()) + .or_insert(0) += 1; + } + SysmonSeverity::Error => { + total_errors += 1; + *security_type_counts + .entry(event.event_type.display_name().to_string()) + .or_insert(0) += 1; + } + SysmonSeverity::Info => {} + } + } + + let buckets_to_vec = |map: HashMap| -> Vec { + let mut vec: Vec = map + .into_iter() + .map(|(ms, count)| { + let ts = DateTime::::from_timestamp_millis(ms) + .map(|dt| dt.to_rfc3339()) + .unwrap_or_default(); + TimeBucket { + timestamp: ts, + timestamp_ms: ms, + count, + } + }) + .collect(); + vec.sort_by_key(|b| b.timestamp_ms); + vec + }; + + // Auto-aggregate timeline_minute based on time span to cap at ~1500 buckets. + // Under 2h -> minute, 2-24h -> 5-min, 1-7d -> hourly, >7d -> daily. + let auto_timeline = { + let min_ms = minute_buckets.keys().copied().min(); + let max_ms = minute_buckets.keys().copied().max(); + match (min_ms, max_ms) { + (Some(lo), Some(hi)) => { + let span_ms = hi - lo; + let two_hours = 2 * 3_600_000_i64; + let twenty_four_hours = 24 * 3_600_000_i64; + let seven_days = 7 * 86_400_000_i64; + + if span_ms < two_hours { + buckets_to_vec(minute_buckets) + } else if span_ms < twenty_four_hours { + // Re-bucket into 5-minute intervals + let five_min_ms = 5 * 60_000_i64; + let mut rebucketed: HashMap = + HashMap::with_capacity(minute_buckets.len() / 5 + 1); + for (ms, count) in minute_buckets { + let key = (ms / five_min_ms) * five_min_ms; + *rebucketed.entry(key).or_insert(0) += count; + } + buckets_to_vec(rebucketed) + } else if span_ms < seven_days { + buckets_to_vec(hourly_buckets.clone()) + } else { + buckets_to_vec(daily_buckets.clone()) + } + } + _ => Vec::new(), + } + }; + + let timeline_hourly_vec = buckets_to_vec(hourly_buckets); + let timeline_daily_vec = buckets_to_vec(daily_buckets); + + let top_n = |map: HashMap| -> Vec { + let mut vec: Vec = map + .into_iter() + .map(|(name, count)| RankedItem { name, count }) + .collect(); + vec.sort_by(|a, b| b.count.cmp(&a.count)); + vec.truncate(TOP_N); + vec + }; + + let mut security_by_type: Vec = security_type_counts + .into_iter() + .map(|(name, count)| RankedItem { name, count }) + .collect(); + security_by_type.sort_by(|a, b| b.count.cmp(&a.count)); + + SysmonDashboardData { + timeline_minute: auto_timeline, + timeline_hourly: timeline_hourly_vec, + timeline_daily: timeline_daily_vec, + top_processes: top_n(process_counts), + top_destinations: top_n(dest_counts), + top_ports: top_n(port_counts), + top_dns_queries: top_n(dns_counts), + security_events: SecuritySummary { + total_warnings, + total_errors, + events_by_type: security_by_type, + }, + top_target_files: top_n(file_counts), + top_registry_keys: top_n(registry_counts), + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// Extract EventID which can appear as `{"#text": N}` or just `N`. +fn extract_event_id(system: &Value) -> u32 { + if let Some(id) = system["EventID"].as_u64() { + return id as u32; + } + if let Some(id) = system["EventID"]["#text"].as_u64() { + return id as u32; + } + if let Some(s) = system["EventID"]["#text"].as_str() { + return s.parse().unwrap_or(0); + } + 0 +} + +/// Parse ISO 8601 timestamp to unix millis. +fn parse_timestamp_ms(ts: &str) -> Option { + chrono::DateTime::parse_from_rfc3339(ts) + .ok() + .or_else(|| { + // Handle timestamps like "2024-04-28T22:08:22.025812200Z" that may have + // extra precision beyond what RFC 3339 strictly allows + chrono::NaiveDateTime::parse_from_str( + ts.trim_end_matches('Z'), + "%Y-%m-%dT%H:%M:%S%.f", + ) + .ok() + .map(|ndt| { + ndt.and_utc().fixed_offset() + }) + }) + .map(|dt| dt.timestamp_millis()) +} + +fn get_data_str(event_data: &Value, key: &str) -> Option { + match &event_data[key] { + Value::String(s) if !s.is_empty() && s != "-" => Some(s.clone()), + Value::Number(n) => Some(n.to_string()), + _ => None, + } +} + +fn get_data_u32(event_data: &Value, key: &str) -> Option { + event_data[key] + .as_u64() + .map(|n| n as u32) + .or_else(|| { + event_data[key] + .as_str() + .and_then(|s| s.parse().ok()) + }) +} + +fn get_data_u16(event_data: &Value, key: &str) -> Option { + event_data[key] + .as_u64() + .map(|n| n as u16) + .or_else(|| { + event_data[key] + .as_str() + .and_then(|s| s.parse().ok()) + }) +} + +/// Derive severity from event ID. +fn derive_severity(event_id: u32) -> SysmonSeverity { + match event_id { + 255 => SysmonSeverity::Error, + 8 | 10 | 23 | 25 | 26 | 27 | 28 => SysmonSeverity::Warning, + _ => SysmonSeverity::Info, + } +} + +/// Build a human-readable message from the event's key fields. +fn build_message(event_id: u32, event_type: &SysmonEventType, event_data: &Value) -> String { + let type_label = event_type.display_name(); + + match event_id { + 1 => { + // ProcessCreate + let image = event_data["Image"].as_str().unwrap_or("?"); + let cmd = event_data["CommandLine"].as_str().unwrap_or(""); + let user = event_data["User"].as_str().unwrap_or(""); + if cmd.is_empty() { + format!("{image} (User: {user})") + } else { + format!("{image} | {cmd} (User: {user})") + } + } + 3 => { + // NetworkConnect + let image = event_data["Image"].as_str().unwrap_or("?"); + let dst_ip = event_data["DestinationIp"].as_str().unwrap_or("?"); + let dst_port = event_data["DestinationPort"] + .as_u64() + .map(|p| p.to_string()) + .or_else(|| event_data["DestinationPort"].as_str().map(|s| s.to_string())) + .unwrap_or_else(|| "?".to_string()); + let proto = event_data["Protocol"].as_str().unwrap_or("?"); + format!("{image} → {dst_ip}:{dst_port} ({proto})") + } + 5 => { + // ProcessTerminate + let image = event_data["Image"].as_str().unwrap_or("?"); + format!("{image} terminated") + } + 10 => { + // ProcessAccess + let src = event_data["SourceImage"].as_str().unwrap_or("?"); + let tgt = event_data["TargetImage"].as_str().unwrap_or("?"); + let access = event_data["GrantedAccess"].as_str().unwrap_or("?"); + format!("{src} → {tgt} (Access: {access})") + } + 11 => { + // FileCreate + let image = event_data["Image"].as_str().unwrap_or("?"); + let target = event_data["TargetFilename"].as_str().unwrap_or("?"); + format!("{image} created {target}") + } + 12..=14 => { + // Registry events + let image = event_data["Image"].as_str().unwrap_or("?"); + let target = event_data["TargetObject"].as_str().unwrap_or("?"); + format!("{image} | {target}") + } + 22 => { + // DNSQuery + let image = event_data["Image"].as_str().unwrap_or("?"); + let query = event_data["QueryName"].as_str().unwrap_or("?"); + let results = event_data["QueryResults"].as_str().unwrap_or(""); + if results.is_empty() { + format!("{image} queried {query}") + } else { + format!("{image} queried {query} → {results}") + } + } + 23 | 26 => { + // FileDelete / FileDeleteDetected + let image = event_data["Image"].as_str().unwrap_or("?"); + let target = event_data["TargetFilename"].as_str().unwrap_or("?"); + format!("{image} deleted {target}") + } + _ => { + // Generic: show Image if available, else first few data fields + if let Some(image) = event_data["Image"].as_str() { + format!("[{type_label}] {image}") + } else { + build_generic_message(type_label, event_data) + } + } + } +} + +/// Build a generic message from up to 3 key EventData fields. +fn build_generic_message(type_label: &str, event_data: &Value) -> String { + if let Some(obj) = event_data.as_object() { + let parts: Vec = obj + .iter() + .filter(|(k, _)| *k != "#attributes") + .take(3) + .filter_map(|(k, v)| { + let val = match v { + Value::String(s) if !s.is_empty() => s.clone(), + Value::Number(n) => n.to_string(), + _ => return None, + }; + Some(format!("{k}={val}")) + }) + .collect(); + + if parts.is_empty() { + format!("[{type_label}]") + } else { + format!("[{type_label}] {}", parts.join(", ")) + } + } else { + format!("[{type_label}]") + } +} + +// --------------------------------------------------------------------------- +// Live Windows Event Log support +// --------------------------------------------------------------------------- + +#[cfg(target_os = "windows")] +fn live_provider_re() -> &'static Regex { + static CELL: OnceLock = OnceLock::new(); + CELL.get_or_init(|| { + Regex::new(r#"]*Name=['\"]([^'\"]+)['\"]"#) + .expect("provider regex must compile") + }) +} +#[cfg(target_os = "windows")] +fn live_event_id_re() -> &'static Regex { + static CELL: OnceLock = OnceLock::new(); + CELL.get_or_init(|| { + Regex::new(r"]*)?>(\d+)") + .expect("event id regex must compile") + }) +} +#[cfg(target_os = "windows")] +fn live_time_re() -> &'static Regex { + static CELL: OnceLock = OnceLock::new(); + CELL.get_or_init(|| { + Regex::new(r#"]*SystemTime=['\"]([^'\"]+)['\"]"#) + .expect("time regex must compile") + }) +} +#[cfg(target_os = "windows")] +fn live_computer_re() -> &'static Regex { + static CELL: OnceLock = OnceLock::new(); + CELL.get_or_init(|| { + Regex::new(r"(.*?)").expect("computer regex must compile") + }) +} +#[cfg(target_os = "windows")] +fn live_record_id_re() -> &'static Regex { + static CELL: OnceLock = OnceLock::new(); + CELL.get_or_init(|| { + Regex::new(r"(\d+)") + .expect("record id regex must compile") + }) +} + +#[cfg(target_os = "windows")] +fn extract_xml_value(text: &str, regex: &Regex) -> Option { + regex + .captures(text) + .and_then(|captures| captures.get(1).map(|value| value.as_str().to_string())) +} + +/// Extract a named Data element value from Sysmon XML EventData. +/// Pattern: `value` +#[cfg(target_os = "windows")] +fn extract_event_data_field(xml: &str, field_name: &str) -> Option { + // Build pattern: ... + let pattern = format!( + r#"(.*?)"#, + regex::escape(field_name) + ); + let re = Regex::new(&pattern).ok()?; + re.captures(xml) + .and_then(|captures| captures.get(1)) + .map(|value| decode_xml_text(value.as_str())) + .filter(|value| !value.is_empty() && value != "-") +} + +#[cfg(target_os = "windows")] +fn decode_xml_text(value: &str) -> String { + value + .replace(" ", "\r") + .replace(" ", "\n") + .replace("<", "<") + .replace(">", ">") + .replace(""", "\"") + .replace("'", "'") + .replace("&", "&") +} + +/// Query the live Windows Event Log for Sysmon events. +/// +/// Returns parsed `SysmonEvent` records from the +/// `Microsoft-Windows-Sysmon/Operational` channel. +#[cfg(target_os = "windows")] +pub fn parse_sysmon_live_events() -> Result, String> { + let result = eventlog_win32::query_live_channel(SYSMON_CHANNEL, MAX_LIVE_ENTRIES) + .map_err(|e| format!("Failed to query live Sysmon event log: {}", e))?; + + let source_file = result.source_file; + let mut events = Vec::new(); + + for record in result.records { + let xml = &record.xml; + + // Verify this is a Sysmon event + let provider = match extract_xml_value(xml, live_provider_re()) { + Some(p) if p == SYSMON_PROVIDER => p, + _ => continue, + }; + let _ = provider; + + let event_id = extract_xml_value(xml, live_event_id_re()) + .and_then(|v| v.parse::().ok()) + .unwrap_or(0); + + let timestamp = match extract_xml_value(xml, live_time_re()) { + Some(ts) => ts, + None => continue, + }; + let timestamp_ms = parse_timestamp_ms(×tamp); + + let computer = extract_xml_value(xml, live_computer_re()) + .map(|v| decode_xml_text(&v)); + + let record_id = extract_xml_value(xml, live_record_id_re()) + .and_then(|v| v.parse::().ok()) + .unwrap_or(0); + + let event_type = SysmonEventType::from_event_id(event_id); + let severity = derive_severity(event_id); + + // Extract EventData fields from XML + let rule_name = extract_event_data_field(xml, "RuleName"); + let utc_time = extract_event_data_field(xml, "UtcTime"); + let process_guid = extract_event_data_field(xml, "ProcessGuid"); + let process_id = extract_event_data_field(xml, "ProcessId") + .and_then(|v| v.parse().ok()); + let image = extract_event_data_field(xml, "Image"); + let command_line = extract_event_data_field(xml, "CommandLine"); + let user = extract_event_data_field(xml, "User"); + let hashes = extract_event_data_field(xml, "Hashes"); + let parent_image = extract_event_data_field(xml, "ParentImage"); + let parent_command_line = extract_event_data_field(xml, "ParentCommandLine"); + let parent_process_id = extract_event_data_field(xml, "ParentProcessId") + .and_then(|v| v.parse().ok()); + let target_filename = extract_event_data_field(xml, "TargetFilename"); + let protocol = extract_event_data_field(xml, "Protocol"); + let source_ip = extract_event_data_field(xml, "SourceIp"); + let source_port = extract_event_data_field(xml, "SourcePort") + .and_then(|v| v.parse().ok()); + let destination_ip = extract_event_data_field(xml, "DestinationIp"); + let destination_port = extract_event_data_field(xml, "DestinationPort") + .and_then(|v| v.parse().ok()); + let destination_hostname = extract_event_data_field(xml, "DestinationHostname"); + let target_object = extract_event_data_field(xml, "TargetObject"); + let details = extract_event_data_field(xml, "Details"); + let query_name = extract_event_data_field(xml, "QueryName"); + let query_results = extract_event_data_field(xml, "QueryResults"); + let source_image = extract_event_data_field(xml, "SourceImage"); + let target_image = extract_event_data_field(xml, "TargetImage"); + let granted_access = extract_event_data_field(xml, "GrantedAccess"); + + // Build message from rendered message or from fields + let message = record + .rendered_message + .filter(|m| !m.trim().is_empty()) + .unwrap_or_else(|| { + build_message_from_fields(&MessageFields { + event_id, + event_type: &event_type, + image: image.as_deref(), + command_line: command_line.as_deref(), + user: user.as_deref(), + destination_ip: destination_ip.as_deref(), + destination_port, + protocol: protocol.as_deref(), + target_filename: target_filename.as_deref(), + source_image: source_image.as_deref(), + target_image: target_image.as_deref(), + granted_access: granted_access.as_deref(), + query_name: query_name.as_deref(), + query_results: query_results.as_deref(), + target_object: target_object.as_deref(), + }) + }); + + events.push(SysmonEvent { + id: events.len() as u64, + event_id, + event_type, + event_type_display: event_type.display_name().to_string(), + severity, + timestamp, + timestamp_ms, + computer, + record_id, + rule_name, + utc_time, + process_guid, + process_id, + image, + command_line, + user, + hashes, + parent_image, + parent_command_line, + parent_process_id, + target_filename, + protocol, + source_ip, + source_port, + destination_ip, + destination_port, + destination_hostname, + target_object, + details, + query_name, + query_results, + source_image, + target_image, + granted_access, + message, + source_file: source_file.clone(), + }); + } + + Ok(events) +} + +/// Non-Windows stub for live event log queries. +#[cfg(not(target_os = "windows"))] +pub fn parse_sysmon_live_events() -> Result, String> { + Err("Live Sysmon event log queries are only supported on Windows".to_string()) +} + +/// Holds the fields needed to build a human-readable message for live events. +#[cfg(target_os = "windows")] +struct MessageFields<'a> { + event_id: u32, + event_type: &'a SysmonEventType, + image: Option<&'a str>, + command_line: Option<&'a str>, + user: Option<&'a str>, + destination_ip: Option<&'a str>, + destination_port: Option, + protocol: Option<&'a str>, + target_filename: Option<&'a str>, + source_image: Option<&'a str>, + target_image: Option<&'a str>, + granted_access: Option<&'a str>, + query_name: Option<&'a str>, + query_results: Option<&'a str>, + target_object: Option<&'a str>, +} + +/// Build a human-readable message from extracted field values (for live events). +#[cfg(target_os = "windows")] +fn build_message_from_fields(fields: &MessageFields<'_>) -> String { + let type_label = fields.event_type.display_name(); + let event_id = fields.event_id; + + match event_id { + 1 => { + let img = fields.image.unwrap_or("?"); + let usr = fields.user.unwrap_or(""); + match fields.command_line { + Some(cmd) if !cmd.is_empty() => format!("{img} | {cmd} (User: {usr})"), + _ => format!("{img} (User: {usr})"), + } + } + 3 => { + let img = fields.image.unwrap_or("?"); + let dst_ip = fields.destination_ip.unwrap_or("?"); + let dst_port = fields + .destination_port + .map(|p| p.to_string()) + .unwrap_or_else(|| "?".to_string()); + let proto = fields.protocol.unwrap_or("?"); + format!("{img} → {dst_ip}:{dst_port} ({proto})") + } + 5 => { + let img = fields.image.unwrap_or("?"); + format!("{img} terminated") + } + 10 => { + let src = fields.source_image.unwrap_or("?"); + let tgt = fields.target_image.unwrap_or("?"); + let access = fields.granted_access.unwrap_or("?"); + format!("{src} → {tgt} (Access: {access})") + } + 11 => { + let img = fields.image.unwrap_or("?"); + let target = fields.target_filename.unwrap_or("?"); + format!("{img} created {target}") + } + 12..=14 => { + let img = fields.image.unwrap_or("?"); + let target = fields.target_object.unwrap_or("?"); + format!("{img} | {target}") + } + 22 => { + let img = fields.image.unwrap_or("?"); + let query = fields.query_name.unwrap_or("?"); + match fields.query_results { + Some(results) if !results.is_empty() => { + format!("{img} queried {query} → {results}") + } + _ => format!("{img} queried {query}"), + } + } + 23 | 26 => { + let img = fields.image.unwrap_or("?"); + let target = fields.target_filename.unwrap_or("?"); + format!("{img} deleted {target}") + } + _ => { + if let Some(img) = fields.image { + format!("[{type_label}] {img}") + } else { + format!("[{type_label}]") + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_event_type_mapping() { + assert_eq!(SysmonEventType::from_event_id(1), SysmonEventType::ProcessCreate); + assert_eq!(SysmonEventType::from_event_id(3), SysmonEventType::NetworkConnect); + assert_eq!(SysmonEventType::from_event_id(22), SysmonEventType::DnsQuery); + assert_eq!(SysmonEventType::from_event_id(255), SysmonEventType::Error); + assert_eq!(SysmonEventType::from_event_id(999), SysmonEventType::Unknown); + } + + #[test] + fn test_severity_mapping() { + assert_eq!(derive_severity(1), SysmonSeverity::Info); + assert_eq!(derive_severity(8), SysmonSeverity::Warning); + assert_eq!(derive_severity(255), SysmonSeverity::Error); + } + + #[test] + fn test_parse_timestamp_ms() { + // Standard RFC 3339 + let ts = "2024-04-28T22:08:22.025Z"; + assert!(parse_timestamp_ms(ts).is_some()); + + // Extended precision (7+ fractional digits) + let ts2 = "2024-04-28T22:08:22.025812200Z"; + assert!(parse_timestamp_ms(ts2).is_some()); + } + + #[test] + fn test_get_data_str_skips_dash() { + let data: Value = serde_json::json!({"RuleName": "-", "Image": "cmd.exe"}); + assert_eq!(get_data_str(&data, "RuleName"), None); + assert_eq!(get_data_str(&data, "Image"), Some("cmd.exe".to_string())); + } + + #[test] + fn test_build_summary_empty() { + let summary = build_summary(&[], vec![], 0); + assert_eq!(summary.total_events, 0); + assert_eq!(summary.unique_processes, 0); + assert!(summary.earliest_timestamp.is_none()); + } + + #[test] + fn test_extract_event_id_variants() { + let direct: Value = serde_json::json!({"EventID": 1}); + assert_eq!(extract_event_id(&direct), 1); + + let nested: Value = serde_json::json!({"EventID": {"#text": 22}}); + assert_eq!(extract_event_id(&nested), 22); + + let string_nested: Value = serde_json::json!({"EventID": {"#text": "10"}}); + assert_eq!(extract_event_id(&string_nested), 10); + } +} diff --git a/src-tauri/src/sysmon/mod.rs b/src-tauri/src/sysmon/mod.rs new file mode 100644 index 000000000..707cf317a --- /dev/null +++ b/src-tauri/src/sysmon/mod.rs @@ -0,0 +1,2 @@ +pub mod evtx_parser; +pub mod models; diff --git a/src-tauri/src/sysmon/models.rs b/src-tauri/src/sysmon/models.rs new file mode 100644 index 000000000..3a5b05316 --- /dev/null +++ b/src-tauri/src/sysmon/models.rs @@ -0,0 +1,346 @@ +use serde::{Deserialize, Serialize}; + +/// Sysmon event type mapped from EventID (1–29, 255). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum SysmonEventType { + ProcessCreate, + FileCreateTime, + NetworkConnect, + ServiceStateChange, + ProcessTerminate, + DriverLoad, + ImageLoad, + CreateRemoteThread, + RawAccessRead, + ProcessAccess, + FileCreate, + RegistryAddOrDelete, + RegistryValueSet, + RegistryRename, + FileCreateStreamHash, + ConfigChange, + PipeCreated, + PipeConnected, + WmiFilter, + WmiConsumer, + WmiBinding, + DnsQuery, + FileDelete, + ClipboardChange, + ProcessTampering, + FileDeleteDetected, + FileBlockExecutable, + FileBlockShredding, + FileExecutableDetected, + Error, + Unknown, +} + +impl SysmonEventType { + pub fn from_event_id(id: u32) -> Self { + match id { + 1 => Self::ProcessCreate, + 2 => Self::FileCreateTime, + 3 => Self::NetworkConnect, + 4 => Self::ServiceStateChange, + 5 => Self::ProcessTerminate, + 6 => Self::DriverLoad, + 7 => Self::ImageLoad, + 8 => Self::CreateRemoteThread, + 9 => Self::RawAccessRead, + 10 => Self::ProcessAccess, + 11 => Self::FileCreate, + 12 => Self::RegistryAddOrDelete, + 13 => Self::RegistryValueSet, + 14 => Self::RegistryRename, + 15 => Self::FileCreateStreamHash, + 16 => Self::ConfigChange, + 17 => Self::PipeCreated, + 18 => Self::PipeConnected, + 19 => Self::WmiFilter, + 20 => Self::WmiConsumer, + 21 => Self::WmiBinding, + 22 => Self::DnsQuery, + 23 => Self::FileDelete, + 24 => Self::ClipboardChange, + 25 => Self::ProcessTampering, + 26 => Self::FileDeleteDetected, + 27 => Self::FileBlockExecutable, + 28 => Self::FileBlockShredding, + 29 => Self::FileExecutableDetected, + 255 => Self::Error, + _ => Self::Unknown, + } + } + + pub fn display_name(&self) -> &'static str { + match self { + Self::ProcessCreate => "Process Create", + Self::FileCreateTime => "File Create Time", + Self::NetworkConnect => "Network Connect", + Self::ServiceStateChange => "Service State Change", + Self::ProcessTerminate => "Process Terminate", + Self::DriverLoad => "Driver Load", + Self::ImageLoad => "Image Load", + Self::CreateRemoteThread => "Create Remote Thread", + Self::RawAccessRead => "Raw Access Read", + Self::ProcessAccess => "Process Access", + Self::FileCreate => "File Create", + Self::RegistryAddOrDelete => "Registry Add/Delete", + Self::RegistryValueSet => "Registry Value Set", + Self::RegistryRename => "Registry Rename", + Self::FileCreateStreamHash => "File Stream Hash", + Self::ConfigChange => "Config Change", + Self::PipeCreated => "Pipe Created", + Self::PipeConnected => "Pipe Connected", + Self::WmiFilter => "WMI Filter", + Self::WmiConsumer => "WMI Consumer", + Self::WmiBinding => "WMI Binding", + Self::DnsQuery => "DNS Query", + Self::FileDelete => "File Delete (Archived)", + Self::ClipboardChange => "Clipboard Change", + Self::ProcessTampering => "Process Tampering", + Self::FileDeleteDetected => "File Delete Detected", + Self::FileBlockExecutable => "File Block Executable", + Self::FileBlockShredding => "File Block Shredding", + Self::FileExecutableDetected => "File Executable Detected", + Self::Error => "Sysmon Error", + Self::Unknown => "Unknown", + } + } +} + +/// Severity derived from the Sysmon event type. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum SysmonSeverity { + Info, + Warning, + Error, +} + +/// A single parsed Sysmon event. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SysmonEvent { + /// Sequential ID for stable row identity. + pub id: u64, + /// Sysmon EventID (1–29, 255). + pub event_id: u32, + /// Typed event category. + pub event_type: SysmonEventType, + /// Display name for the event type. + pub event_type_display: String, + /// Severity level. + pub severity: SysmonSeverity, + /// ISO 8601 UTC timestamp from System.TimeCreated. + pub timestamp: String, + /// Unix timestamp in milliseconds for sorting. + pub timestamp_ms: Option, + /// Computer name from System.Computer. + pub computer: Option, + /// EventRecordID from the EVTX record. + pub record_id: u64, + + // --- Common Sysmon fields (populated per event type) --- + + /// RuleName from configuration match. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub rule_name: Option, + /// UtcTime from EventData (millisecond precision). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub utc_time: Option, + /// ProcessGuid — globally unique process identifier. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub process_guid: Option, + /// ProcessId from EventData. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub process_id: Option, + /// Image path (executable) for the process. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub image: Option, + /// Command line used to start the process. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub command_line: Option, + /// User account. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub user: Option, + /// Hashes (e.g. "SHA256=abc,MD5=def"). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub hashes: Option, + /// Parent image path. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub parent_image: Option, + /// Parent command line. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub parent_command_line: Option, + /// Parent ProcessId. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub parent_process_id: Option, + + // --- File events --- + + /// Target file path (FileCreate, FileDelete, etc.). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub target_filename: Option, + + // --- Network events --- + + /// Protocol (tcp/udp). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub protocol: Option, + /// Source IP address. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_ip: Option, + /// Source port. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_port: Option, + /// Destination IP address. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub destination_ip: Option, + /// Destination port. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub destination_port: Option, + /// Destination hostname. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub destination_hostname: Option, + + // --- Registry events --- + + /// Registry target object path. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub target_object: Option, + /// Registry value details. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub details: Option, + + // --- DNS events --- + + /// DNS query name. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub query_name: Option, + /// DNS query results (semicolon-delimited IPs). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub query_results: Option, + + // --- Process access --- + + /// Source image for ProcessAccess. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_image: Option, + /// Target image for ProcessAccess. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub target_image: Option, + /// Granted access mask. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub granted_access: Option, + + /// Human-readable message built from key fields. + pub message: String, + + /// Source .evtx file path. + pub source_file: String, +} + +/// Per-event-type count for the summary. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SysmonEventTypeCount { + pub event_id: u32, + pub event_type: SysmonEventType, + pub display_name: String, + pub count: u64, +} + +/// Summary statistics for a Sysmon analysis. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SysmonSummary { + pub total_events: u64, + pub event_type_counts: Vec, + pub unique_processes: u64, + pub unique_computers: u64, + pub earliest_timestamp: Option, + pub latest_timestamp: Option, + pub source_files: Vec, + pub parse_errors: u64, +} + +/// Extracted Sysmon configuration metadata. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SysmonConfig { + /// Schema version (e.g. "4.82"). + pub schema_version: Option, + /// Hash algorithms configured (e.g. "SHA256,MD5"). + pub hash_algorithms: Option, + /// Whether the configuration was found. + pub found: bool, + /// Timestamp of the most recent config change event (EventID 16). + pub last_config_change: Option, + /// Raw configuration XML if available from ConfigChange events. + pub configuration_xml: Option, + /// Sysmon binary version if available from service state events (EventID 4). + pub sysmon_version: Option, + /// Which event types are actively generating events (observed in data). + pub active_event_types: Vec, +} + +/// A time-bucketed event count for timeline charts. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TimeBucket { + /// ISO 8601 timestamp for the bucket start. + pub timestamp: String, + /// Unix ms timestamp for the bucket start. + pub timestamp_ms: i64, + /// Number of events in this bucket. + pub count: u64, +} + +/// A named item with a count, used for top-N rankings. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RankedItem { + pub name: String, + pub count: u64, +} + +/// Aggregated security alert statistics. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SecuritySummary { + pub total_warnings: u64, + pub total_errors: u64, + pub events_by_type: Vec, +} + +/// Pre-computed dashboard aggregations. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SysmonDashboardData { + pub timeline_minute: Vec, + pub timeline_hourly: Vec, + pub timeline_daily: Vec, + pub top_processes: Vec, + pub top_destinations: Vec, + pub top_ports: Vec, + pub top_dns_queries: Vec, + pub security_events: SecuritySummary, + pub top_target_files: Vec, + pub top_registry_keys: Vec, +} + +/// Top-level result returned from the Sysmon analysis command. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SysmonAnalysisResult { + /// All parsed Sysmon events, sorted by timestamp. + pub events: Vec, + /// Summary statistics. + pub summary: SysmonSummary, + /// Extracted Sysmon configuration metadata. + pub config: SysmonConfig, + /// Pre-computed dashboard aggregations. + pub dashboard: SysmonDashboardData, + /// Source path that was analyzed. + pub source_path: String, +} diff --git a/src-tauri/tests/sysmon_parser.rs b/src-tauri/tests/sysmon_parser.rs new file mode 100644 index 000000000..0a082594d --- /dev/null +++ b/src-tauri/tests/sysmon_parser.rs @@ -0,0 +1,266 @@ +use app_lib::sysmon::evtx_parser::{build_dashboard_data, build_summary, extract_config}; +use app_lib::sysmon::models::{SysmonEvent, SysmonEventType, SysmonSeverity}; + +fn make_event(id: u64, timestamp: &str, timestamp_ms: Option, event_id: u32) -> SysmonEvent { + SysmonEvent { + id, + record_id: id, + timestamp: timestamp.to_string(), + timestamp_ms, + event_id, + event_type: SysmonEventType::from_event_id(event_id), + event_type_display: SysmonEventType::from_event_id(event_id) + .display_name() + .to_string(), + severity: SysmonSeverity::Info, + message: String::new(), + computer: Some("DESKTOP-TEST".to_string()), + utc_time: None, + user: None, + process_guid: Some("{00000000-0000-0000-0000-000000000001}".to_string()), + process_id: None, + image: None, + command_line: None, + parent_image: None, + parent_command_line: None, + parent_process_id: None, + target_filename: None, + target_object: None, + details: None, + hashes: None, + protocol: None, + source_ip: None, + source_port: None, + destination_ip: None, + destination_port: None, + destination_hostname: None, + query_name: None, + query_results: None, + source_image: None, + target_image: None, + granted_access: None, + source_file: "Sysmon.evtx".to_string(), + rule_name: None, + } +} + +#[test] +fn build_summary_empty_events() { + let summary = build_summary(&[], vec![], 0); + assert_eq!(summary.total_events, 0); + assert_eq!(summary.unique_processes, 0); + assert!(summary.earliest_timestamp.is_none()); + assert!(summary.latest_timestamp.is_none()); +} + +#[test] +fn build_summary_counts_event_types() { + let events = vec![ + make_event(0, "2024-04-28T10:00:00Z", Some(1714298400000), 1), + make_event(1, "2024-04-28T10:00:01Z", Some(1714298401000), 1), + make_event(2, "2024-04-28T10:00:02Z", Some(1714298402000), 3), + ]; + let summary = build_summary(&events, vec!["test.evtx".to_string()], 0); + assert_eq!(summary.total_events, 3); + assert_eq!(summary.unique_computers, 1); + + // Two ProcessCreate (id=1) events and one NetworkConnect (id=3) + let process_create_count = summary + .event_type_counts + .iter() + .find(|c| c.event_id == 1) + .map(|c| c.count) + .unwrap_or(0); + assert_eq!(process_create_count, 2); +} + +#[test] +fn build_summary_tracks_earliest_latest_with_numeric_timestamps() { + let events = vec![ + make_event(0, "2024-04-28T10:00:00Z", Some(1714298400000), 1), + make_event(1, "2024-04-28T09:00:00Z", Some(1714294800000), 1), + make_event(2, "2024-04-28T11:00:00Z", Some(1714302000000), 1), + ]; + let summary = build_summary(&events, vec![], 0); + assert_eq!( + summary.earliest_timestamp.as_deref(), + Some("2024-04-28T09:00:00Z") + ); + assert_eq!( + summary.latest_timestamp.as_deref(), + Some("2024-04-28T11:00:00Z") + ); +} + +#[test] +fn build_summary_string_only_events_still_tracked_after_numeric() { + // Issue 10: string-only events must still update earliest/latest + // even when numeric timestamps have been seen + let events = vec![ + make_event(0, "2024-04-28T10:00:00Z", Some(1714298400000), 1), + make_event(1, "2024-04-28T08:00:00Z", None, 1), // earlier, but no ms + make_event(2, "2024-04-28T12:00:00Z", None, 1), // later, but no ms + ]; + let summary = build_summary(&events, vec![], 0); + // The string-only event at 08:00 should be tracked as earliest + assert_eq!( + summary.earliest_timestamp.as_deref(), + Some("2024-04-28T08:00:00Z") + ); + // The string-only event at 12:00 should be tracked as latest + assert_eq!( + summary.latest_timestamp.as_deref(), + Some("2024-04-28T12:00:00Z") + ); +} + +#[test] +fn build_summary_parse_errors_propagated() { + let summary = build_summary(&[], vec!["a.evtx".to_string()], 42); + assert_eq!(summary.parse_errors, 42); + assert_eq!(summary.source_files.len(), 1); +} + +#[test] +fn dashboard_data_empty_events() { + let data = build_dashboard_data(&[]); + assert!(data.timeline_minute.is_empty()); + assert!(data.timeline_hourly.is_empty()); + assert!(data.timeline_daily.is_empty()); + assert!(data.top_processes.is_empty()); + assert!(data.top_destinations.is_empty()); + assert!(data.top_ports.is_empty()); + assert!(data.top_dns_queries.is_empty()); + assert!(data.top_target_files.is_empty()); + assert!(data.top_registry_keys.is_empty()); + assert_eq!(data.security_events.total_warnings, 0); + assert_eq!(data.security_events.total_errors, 0); +} + +#[test] +fn dashboard_data_timeline_bucketing() { + let events = vec![ + make_event(0, "2024-04-28T10:00:00Z", Some(1714298400000), 1), + make_event(1, "2024-04-28T10:00:30Z", Some(1714298430000), 1), + make_event(2, "2024-04-28T11:00:00Z", Some(1714302000000), 1), + ]; + let data = build_dashboard_data(&events); + assert_eq!(data.timeline_minute.len(), 2); + assert_eq!(data.timeline_minute[0].count, 2); + assert_eq!(data.timeline_minute[1].count, 1); + assert_eq!(data.timeline_hourly.len(), 2); + assert_eq!(data.timeline_daily.len(), 1); + assert_eq!(data.timeline_daily[0].count, 3); +} + +#[test] +fn dashboard_data_top_processes() { + let mut e1 = make_event(0, "2024-04-28T10:00:00Z", Some(1714298400000), 1); + e1.image = Some("C:\\Windows\\svchost.exe".to_string()); + let mut e2 = make_event(1, "2024-04-28T10:00:01Z", Some(1714298401000), 1); + e2.image = Some("C:\\Windows\\svchost.exe".to_string()); + let mut e3 = make_event(2, "2024-04-28T10:00:02Z", Some(1714298402000), 1); + e3.image = Some("C:\\Windows\\explorer.exe".to_string()); + + let data = build_dashboard_data(&[e1, e2, e3]); + assert_eq!(data.top_processes.len(), 2); + assert_eq!(data.top_processes[0].name, "C:\\Windows\\svchost.exe"); + assert_eq!(data.top_processes[0].count, 2); + assert_eq!(data.top_processes[1].name, "C:\\Windows\\explorer.exe"); + assert_eq!(data.top_processes[1].count, 1); +} + +#[test] +fn dashboard_data_network_and_dns() { + let mut net1 = make_event(0, "2024-04-28T10:00:00Z", Some(1714298400000), 3); + net1.destination_ip = Some("10.0.0.1".to_string()); + net1.destination_port = Some(443); + net1.destination_hostname = Some("example.com".to_string()); + + let mut net2 = make_event(1, "2024-04-28T10:00:01Z", Some(1714298401000), 3); + net2.destination_ip = Some("10.0.0.1".to_string()); + net2.destination_port = Some(80); + + let mut dns1 = make_event(2, "2024-04-28T10:00:02Z", Some(1714298402000), 22); + dns1.query_name = Some("google.com".to_string()); + + let mut dns2 = make_event(3, "2024-04-28T10:00:03Z", Some(1714298403000), 22); + dns2.query_name = Some("google.com".to_string()); + + let data = build_dashboard_data(&[net1, net2, dns1, dns2]); + assert_eq!(data.top_destinations.len(), 2); + assert_eq!(data.top_destinations[0].count, 1); + assert_eq!(data.top_ports.len(), 2); + assert_eq!(data.top_dns_queries.len(), 1); + assert_eq!(data.top_dns_queries[0].name, "google.com"); + assert_eq!(data.top_dns_queries[0].count, 2); +} + +#[test] +fn dashboard_data_security_events() { + let mut e1 = make_event(0, "2024-04-28T10:00:00Z", Some(1714298400000), 8); + e1.severity = SysmonSeverity::Warning; + let mut e2 = make_event(1, "2024-04-28T10:00:01Z", Some(1714298401000), 255); + e2.severity = SysmonSeverity::Error; + let e3 = make_event(2, "2024-04-28T10:00:02Z", Some(1714298402000), 1); + + let data = build_dashboard_data(&[e1, e2, e3]); + assert_eq!(data.security_events.total_warnings, 1); + assert_eq!(data.security_events.total_errors, 1); + assert_eq!(data.security_events.events_by_type.len(), 2); +} + +#[test] +fn extract_config_empty_events_returns_default() { + let summary = build_summary(&[], vec![], 0); + let config = extract_config(&[], &summary); + assert!(!config.found); + assert!(config.schema_version.is_none()); + assert!(config.hash_algorithms.is_none()); + assert!(config.last_config_change.is_none()); + assert!(config.sysmon_version.is_none()); + assert!(config.configuration_xml.is_none()); + assert!(config.active_event_types.is_empty()); +} + +#[test] +fn extract_config_with_service_state_change() { + let mut e1 = make_event(0, "2024-04-28T10:00:00Z", Some(1714298400000), 4); + e1.details = Some("Sysmon version 15.0".to_string()); + + let events = vec![e1]; + let summary = build_summary(&events, vec![], 0); + let config = extract_config(&events, &summary); + + assert!(config.found); + assert_eq!(config.sysmon_version.as_deref(), Some("Sysmon version 15.0")); +} + +#[test] +fn extract_config_hash_algorithm_inference() { + let mut e1 = make_event(0, "2024-04-28T10:00:00Z", Some(1714298400000), 1); + e1.hashes = Some("SHA256=abc123,MD5=def456".to_string()); + + let events = vec![e1]; + let summary = build_summary(&events, vec![], 0); + let config = extract_config(&events, &summary); + + assert!(config.found); + assert_eq!(config.hash_algorithms.as_deref(), Some("SHA256,MD5")); +} + +#[test] +fn extract_config_config_change_tracks_timestamp() { + let e1 = make_event(0, "2024-04-28T10:00:00Z", Some(1714298400000), 16); + let e2 = make_event(1, "2024-04-28T12:00:00Z", Some(1714305600000), 16); + + let events = vec![e1, e2]; + let summary = build_summary(&events, vec![], 0); + let config = extract_config(&events, &summary); + + assert!(config.found); + assert_eq!( + config.last_config_change.as_deref(), + Some("2024-04-28T12:00:00Z") + ); +} diff --git a/src/components/layout/AppShell.tsx b/src/components/layout/AppShell.tsx index d6dcd0d4a..951c7212a 100644 --- a/src/components/layout/AppShell.tsx +++ b/src/components/layout/AppShell.tsx @@ -24,6 +24,7 @@ import { DsregcmdWorkspace } from "../dsregcmd/DsregcmdWorkspace"; import { MacosDiagWorkspace } from "../macos-diag/MacosDiagWorkspace"; import { DeploymentWorkspace } from "../deployment/DeploymentWorkspace"; import { EventLogWorkspace } from "../event-log-workspace/EventLogWorkspace"; +import { SysmonWorkspace } from "../sysmon/SysmonWorkspace"; import { RegistryViewer } from "../registry-view/RegistryViewer"; import type { FilterClause } from "../dialogs/FilterDialog"; import type { LogEntry } from "../../types/log"; @@ -33,6 +34,7 @@ import { useFilterStore } from "../../stores/filter-store"; import { switchToTab } from "../../lib/log-source"; import { useFileWatcher } from "../../hooks/use-file-watcher"; import { useIntuneAnalysisProgress } from "../../hooks/use-intune-analysis-progress"; +import { useSysmonAnalysisProgress } from "../../hooks/use-sysmon-analysis-progress"; import { useKeyboard } from "../../hooks/use-keyboard"; import { useDragDrop } from "../../hooks/use-drag-drop"; import { useFileAssociation } from "../../hooks/use-file-association"; @@ -242,6 +244,7 @@ export function AppShell() { useFileWatcher(); useIntuneAnalysisProgress(); + useSysmonAnalysisProgress(); useKeyboard(); useDragDrop(); // Handle file path passed via OS file association at startup @@ -409,6 +412,14 @@ export function AppShell() { ); } + if (activeView === "sysmon") { + return ( +
+ +
+ ); + } + if (activeView === "deployment") { return (
diff --git a/src/components/layout/FileSidebar.tsx b/src/components/layout/FileSidebar.tsx index b9d1b34ba..d27a1b085 100644 --- a/src/components/layout/FileSidebar.tsx +++ b/src/components/layout/FileSidebar.tsx @@ -12,6 +12,7 @@ import { loadLogSource, loadSelectedLogFile } from "../../lib/log-source"; import { useFilterStore } from "../../stores/filter-store"; import { useIntuneStore } from "../../stores/intune-store"; import { useDsregcmdStore } from "../../stores/dsregcmd-store"; +import { useSysmonStore } from "../../stores/sysmon-store"; import { getActiveSourceLabel, getActiveSourcePath, @@ -784,6 +785,46 @@ function IntuneSidebar() { ); } +function SysmonSidebar() { + const summary = useSysmonStore((s) => s.summary); + const sourcePath = useSysmonStore((s) => s.sourcePath); + const isAnalyzing = useSysmonStore((s) => s.isAnalyzing); + const analysisError = useSysmonStore((s) => s.analysisError); + const progressMessage = useSysmonStore((s) => s.progressMessage); + + const title = sourcePath ? getBaseName(sourcePath) : "Sysmon"; + const subtitle = sourcePath ?? "Open a folder containing Sysmon EVTX files to begin."; + + return ( + <> + + {isAnalyzing &&
{progressMessage ?? "Analyzing..."}
} + {analysisError &&
{analysisError}
} + {summary && ( + <> +
Events: {summary.totalEvents.toLocaleString()}
+
Processes: {summary.uniqueProcesses.toLocaleString()}
+
Files: {summary.sourceFiles.length}
+ {summary.parseErrors > 0 && ( +
+ Parse errors: {summary.parseErrors} +
+ )} + + )} + {!isAnalyzing && !analysisError && !summary &&
Ready
} +
+ } + /> + + ); +} + function DsregcmdSidebar() { const result = useDsregcmdStore((s) => s.result); const sourceContext = useDsregcmdStore((s) => s.sourceContext); @@ -990,7 +1031,9 @@ export function FileSidebar({ width = FILE_SIDEBAR_RECOMMENDED_WIDTH, activeView ? : isIntuneWorkspace(activeView) ? - : } + : activeView === "sysmon" + ? + : } {(activeView === "log" || activeView === "deployment") && } ); diff --git a/src/components/layout/StatusBar.tsx b/src/components/layout/StatusBar.tsx index 28a0ad141..695c64aec 100644 --- a/src/components/layout/StatusBar.tsx +++ b/src/components/layout/StatusBar.tsx @@ -21,6 +21,7 @@ import { import { useIntuneStore } from "../../stores/intune-store"; import { useDsregcmdStore } from "../../stores/dsregcmd-store"; import { useDeploymentStore } from "../../stores/deployment-store"; +import { useSysmonStore } from "../../stores/sysmon-store"; interface SeverityCounts { errors: number; @@ -75,6 +76,11 @@ export function StatusBar() { const deploymentPhase = useDeploymentStore((s) => s.phase); const deploymentResult = useDeploymentStore((s) => s.result); + const sysmonIsAnalyzing = useSysmonStore((s) => s.isAnalyzing); + const sysmonSummary = useSysmonStore((s) => s.summary); + const sysmonError = useSysmonStore((s) => s.analysisError); + const sysmonSourcePath = useSysmonStore((s) => s.sourcePath); + const filterClauseCount = useFilterStore((s) => s.clauses.length); const filteredIds = useFilterStore((s) => s.filteredIds); const isFiltering = useFilterStore((s) => s.isFiltering); @@ -277,6 +283,33 @@ export function StatusBar() { } else { rightStatusText = intuneAnalysisState.message; } + } else if (activeView === "sysmon") { + leftParts = [ + "Sysmon", + sysmonIsAnalyzing + ? "Analyzing" + : sysmonError + ? "Analysis failed" + : sysmonSummary + ? `${sysmonSummary.totalEvents.toLocaleString()} events` + : "Ready", + ]; + if (sysmonSourcePath) { + leftParts.push(`Source ${getBaseName(sysmonSourcePath)}`); + } + if (sysmonIsAnalyzing) { + rightStatusText = "Analyzing Sysmon EVTX files..."; + rightTone = tokens.colorPaletteBlueForeground2; + } else if (sysmonError) { + rightStatusText = sysmonError; + rightTone = tokens.colorPaletteRedForeground2; + } else if (sysmonSummary) { + rightStatusText = [ + `${sysmonSummary.totalEvents.toLocaleString()} events`, + `${sysmonSummary.uniqueProcesses.toLocaleString()} processes`, + `${sysmonSummary.sourceFiles.length} files`, + ].join(" | "); + } } else if (activeView === "deployment") { leftParts = [ "Software Deployment", @@ -344,7 +377,13 @@ export function StatusBar() { ? "Intune" : activeView === "new-intune" ? "New Intune" - : "dsregcmd"; + : activeView === "sysmon" + ? "Sysmon Analysis" + : activeView === "deployment" + ? "Software Deployment" + : activeView === "macos-diag" + ? "macOS Diagnostics" + : "dsregcmd"; return (
= { log: "Log Explorer", @@ -99,8 +102,14 @@ const WORKSPACE_LABELS: Record = { "macos-diag": "macOS Diagnostics", deployment: "Software Deployment", "event-log": "Event Log Viewer", + sysmon: "Sysmon", }; +const SYSMON_FILE_DIALOG_FILTERS = [ + { name: "EVTX Files", extensions: ["evtx"] }, + { name: "All Files", extensions: ["*"] }, +]; + function getOpenFileDialogFilters(workspace: WorkspaceId) { if (isIntuneWorkspace(workspace)) { return INTUNE_FILE_DIALOG_FILTERS; @@ -110,6 +119,10 @@ function getOpenFileDialogFilters(workspace: WorkspaceId) { return DSREGCMD_FILE_DIALOG_FILTERS; } + if (workspace === "sysmon") { + return SYSMON_FILE_DIALOG_FILTERS; + } + return LOG_FILE_DIALOG_FILTERS; } @@ -130,6 +143,14 @@ function getOpenActionLabels(workspace: WorkspaceId) { }; } + if (workspace === "sysmon") { + return { + file: "Open EVTX File", + folder: "Open EVTX Folder", + openPlaceholder: "Open Sysmon Source...", + }; + } + return { file: "Open File", folder: "Open Folder", @@ -161,6 +182,10 @@ function shouldIncludeLiveEventLogs(source: LogSource): boolean { return source.kind === "known" && source.sourceId === LIVE_INTUNE_SOURCE_ID; } +function shouldIncludeSysmonLiveEventLogs(source: LogSource): boolean { + return source.kind === "known" && source.sourceId === LIVE_SYSMON_SOURCE_ID; +} + export interface OpenKnownSourceCatalogAction extends KnownSourceCatalogActionIds { trigger: string; @@ -232,6 +257,11 @@ export function useAppActions(): AppActionHandlers { const dsregcmdIsAnalyzing = useDsregcmdStore((s) => s.isAnalyzing); const dsregcmdSource = useDsregcmdStore((s) => s.sourceContext.source); const dsregcmdBundlePath = useDsregcmdStore((s) => s.sourceContext.bundlePath); + const sysmonIsAnalyzing = useSysmonStore((s) => s.isAnalyzing); + const sysmonSourcePath = useSysmonStore((s) => s.sourcePath); + const beginSysmonAnalysis = useSysmonStore((s) => s.beginAnalysis); + const setSysmonResults = useSysmonStore((s) => s.setResults); + const failSysmonAnalysis = useSysmonStore((s) => s.failAnalysis); const activeWorkspace = useUiStore((s) => s.activeWorkspace); const activeView = useUiStore((s) => s.activeView); @@ -265,7 +295,7 @@ export function useAppActions(): AppActionHandlers { () => resolveRefreshSource(activeSource, openFilePath), [activeSource, openFilePath] ); - const isSourceCommandBusy = isLoading || intuneIsAnalyzing || dsregcmdIsAnalyzing; + const isSourceCommandBusy = isLoading || intuneIsAnalyzing || dsregcmdIsAnalyzing || sysmonIsAnalyzing; const commandState = useMemo( () => ({ @@ -281,7 +311,9 @@ export function useAppActions(): AppActionHandlers { !isSourceCommandBusy && (activeWorkspace === "dsregcmd" ? dsregcmdSource !== null - : refreshSource !== null), + : activeWorkspace === "sysmon" + ? sysmonSourcePath !== null + : refreshSource !== null), canToggleDetailsPane: activeView === "log", canToggleInfoPane: activeView === "log", canShowEvidenceBundle: @@ -295,7 +327,9 @@ export function useAppActions(): AppActionHandlers { hasActiveSource: activeWorkspace === "dsregcmd" ? dsregcmdSource !== null - : refreshSource !== null, + : activeWorkspace === "sysmon" + ? sysmonSourcePath !== null + : refreshSource !== null, isDetailsVisible: showDetails, isInfoPaneVisible: showInfoPane, activeFilterCount, @@ -321,6 +355,7 @@ export function useAppActions(): AppActionHandlers { refreshSource, showDetails, showInfoPane, + sysmonSourcePath, ] ); @@ -415,6 +450,32 @@ export function useAppActions(): AppActionHandlers { [] ); + const analyzeSysmonWorkspaceSource = useCallback( + async (source: LogSource, trigger: string) => { + useUiStore.getState().ensureWorkspaceVisible("sysmon", trigger); + const sourcePath = getLogSourcePath(source); + const requestId = `sysmon-${Date.now()}`; + beginSysmonAnalysis(sourcePath, requestId); + + try { + const result = await analyzeSysmonLogs(sourcePath, requestId, { + includeLiveEventLogs: shouldIncludeSysmonLiveEventLogs(source), + }); + startTransition(() => { + setSysmonResults(result); + }); + } catch (error) { + console.error("[app-actions] failed to analyze Sysmon source", { + source, + trigger, + error, + }); + failSysmonAnalysis(error instanceof Error ? error.message : String(error)); + } + }, + [beginSysmonAnalysis, setSysmonResults, failSysmonAnalysis] + ); + const openSourceForWorkspace = useCallback( async (source: LogSource, trigger: string, workspace: WorkspaceId) => { if (isIntuneWorkspace(workspace)) { @@ -427,6 +488,11 @@ export function useAppActions(): AppActionHandlers { return; } + if (workspace === "sysmon") { + await analyzeSysmonWorkspaceSource(source, trigger); + return; + } + if (workspace === "deployment") { // Extract folder path from source const folderPath = @@ -447,6 +513,7 @@ export function useAppActions(): AppActionHandlers { [ analyzeDsregcmdWorkspaceSource, analyzeIntuneWorkspaceSource, + analyzeSysmonWorkspaceSource, loadLogWorkspaceSource, ] ); @@ -690,6 +757,19 @@ export function useAppActions(): AppActionHandlers { return; } + if (activeWorkspace === "sysmon") { + if (sysmonSourcePath) { + const isLiveSource = sysmonSourcePath === "live-event-log"; + await analyzeSysmonWorkspaceSource( + isLiveSource + ? { kind: "known", sourceId: LIVE_SYSMON_SOURCE_ID, defaultPath: sysmonSourcePath, pathKind: "folder" } + : { kind: "file", path: sysmonSourcePath }, + "app-actions.refresh" + ); + } + return; + } + if (!refreshSource) { return; } @@ -708,9 +788,11 @@ export function useAppActions(): AppActionHandlers { }, [ activeWorkspace, analyzeIntuneWorkspaceSource, + analyzeSysmonWorkspaceSource, commandState.canRefresh, refreshSource, selectedSourceFilePath, + sysmonSourcePath, ]); const toggleDetailsPane = useCallback(() => { diff --git a/src/components/sysmon/DashboardEventTypeChart.tsx b/src/components/sysmon/DashboardEventTypeChart.tsx new file mode 100644 index 000000000..8f59bf920 --- /dev/null +++ b/src/components/sysmon/DashboardEventTypeChart.tsx @@ -0,0 +1,55 @@ +import { tokens } from "@fluentui/react-components"; +import { DonutChart } from "@fluentui/react-charts"; +import type { SysmonSummary } from "../../types/sysmon"; + +interface DashboardEventTypeChartProps { + summary: SysmonSummary; +} + +// Theme-aware palette using Fluent UI tokens for chart colors +const CHART_COLORS = [ + tokens.colorPaletteBlueForeground2, + tokens.colorPaletteRedForeground1, + tokens.colorPaletteGreenForeground1, + tokens.colorPalettePurpleForeground2, + tokens.colorPaletteMarigoldForeground1, + tokens.colorPaletteTealForeground2, + tokens.colorPalettePinkForeground2, + tokens.colorPaletteBerryForeground1, +]; + +export function DashboardEventTypeChart({ summary }: DashboardEventTypeChartProps) { + const chartData = summary.eventTypeCounts.map((etc, i) => ({ + legend: etc.displayName, + data: etc.count, + color: CHART_COLORS[i % CHART_COLORS.length], + })); + + return ( +
+

+ Event Type Distribution +

+ {chartData.length === 0 ? ( +
+ No event type data available. +
+ ) : ( + + )} +
+ ); +} diff --git a/src/components/sysmon/DashboardMetricCards.tsx b/src/components/sysmon/DashboardMetricCards.tsx new file mode 100644 index 000000000..6c8a12a17 --- /dev/null +++ b/src/components/sysmon/DashboardMetricCards.tsx @@ -0,0 +1,92 @@ +import { tokens } from "@fluentui/react-components"; +import type { SysmonSummary } from "../../types/sysmon"; + +interface DashboardMetricCardsProps { + summary: SysmonSummary; +} + +export function DashboardMetricCards({ summary }: DashboardMetricCardsProps) { + const timeRange = + summary.earliestTimestamp && summary.latestTimestamp + ? `${fmtTs(summary.earliestTimestamp)} – ${fmtTs(summary.latestTimestamp)}` + : summary.earliestTimestamp + ? fmtTs(summary.earliestTimestamp) + : "—"; + + return ( +
+ + + + + {summary.parseErrors > 0 && ( + + )} +
+ ); +} + +function fmtTs(ts: string): string { + try { + return new Date(ts).toLocaleString(); + } catch { + return ts; + } +} + +function MetricCard({ + label, + value, + valueColor, + smallValue, +}: { + label: string; + value: string; + valueColor?: string; + smallValue?: boolean; +}) { + return ( +
+
+ {label} +
+
+ {value} +
+
+ ); +} diff --git a/src/components/sysmon/DashboardSecurityAlerts.tsx b/src/components/sysmon/DashboardSecurityAlerts.tsx new file mode 100644 index 000000000..de30e0faa --- /dev/null +++ b/src/components/sysmon/DashboardSecurityAlerts.tsx @@ -0,0 +1,97 @@ +import { tokens, Badge } from "@fluentui/react-components"; +import type { SecuritySummary } from "../../types/sysmon"; + +interface DashboardSecurityAlertsProps { + securityEvents: SecuritySummary; +} + +export function DashboardSecurityAlerts({ securityEvents }: DashboardSecurityAlertsProps) { + const total = securityEvents.totalWarnings + securityEvents.totalErrors; + + return ( +
+

+ Security Alerts +

+ + {total === 0 ? ( +
+ No warning or error events detected. +
+ ) : ( + <> + {/* Summary badges */} +
+ {securityEvents.totalErrors > 0 && ( +
+ + {securityEvents.totalErrors.toLocaleString()} + + + Error{securityEvents.totalErrors !== 1 ? "s" : ""} + +
+ )} + {securityEvents.totalWarnings > 0 && ( +
+ + {securityEvents.totalWarnings.toLocaleString()} + + + Warning{securityEvents.totalWarnings !== 1 ? "s" : ""} + +
+ )} +
+ + {/* Events by type table */} + {securityEvents.eventsByType.length > 0 && ( + + + + + + + + + {securityEvents.eventsByType.map((item) => ( + + + + + ))} + +
Event TypeCount
{item.name}{item.count.toLocaleString()}
+ )} + + )} +
+ ); +} + +const thStyle: React.CSSProperties = { + textAlign: "left", + padding: "4px 8px", + borderBottom: `1px solid ${tokens.colorNeutralStroke2}`, + fontWeight: 600, + color: tokens.colorNeutralForeground2, +}; + +const tdStyle: React.CSSProperties = { + padding: "4px 8px", + borderBottom: `1px solid ${tokens.colorNeutralStroke3}`, + color: tokens.colorNeutralForeground1, +}; diff --git a/src/components/sysmon/DashboardTimeline.tsx b/src/components/sysmon/DashboardTimeline.tsx new file mode 100644 index 000000000..e890a936d --- /dev/null +++ b/src/components/sysmon/DashboardTimeline.tsx @@ -0,0 +1,125 @@ +import { useState } from "react"; +import { tokens, Dropdown, Option } from "@fluentui/react-components"; +import { VerticalBarChart } from "@fluentui/react-charts"; +import type { SysmonDashboardData, TimeBucket } from "../../types/sysmon"; + +interface DashboardTimelineProps { + dashboard: SysmonDashboardData; +} + +type Granularity = "minute" | "hour" | "day"; + +const GRANULARITY_OPTIONS: { value: Granularity; label: string }[] = [ + { value: "minute", label: "Per Minute" }, + { value: "hour", label: "Per Hour" }, + { value: "day", label: "Per Day" }, +]; + +function fmtLabel(ts: string, granularity: Granularity): string { + try { + const d = new Date(ts); + if (granularity === "day") { + return d.toLocaleDateString(undefined, { month: "short", day: "numeric" }); + } + if (granularity === "hour") { + return d.toLocaleString(undefined, { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }); + } + // minute + return d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" }); + } catch { + return ts; + } +} + +const MAX_BARS = 100; + +function downsampleBuckets(buckets: TimeBucket[]): TimeBucket[] { + if (buckets.length <= MAX_BARS) return buckets; + // Merge adjacent buckets to fit within MAX_BARS + const factor = Math.ceil(buckets.length / MAX_BARS); + const result: TimeBucket[] = []; + for (let i = 0; i < buckets.length; i += factor) { + const slice = buckets.slice(i, i + factor); + const merged: TimeBucket = { + timestamp: slice[0].timestamp, + timestampMs: slice[0].timestampMs, + count: slice.reduce((sum, b) => sum + b.count, 0), + }; + result.push(merged); + } + return result; +} + +function bucketsToChartData(buckets: TimeBucket[], granularity: Granularity) { + const sampled = downsampleBuckets(buckets); + return sampled.map((b) => ({ + x: fmtLabel(b.timestamp, granularity), + y: b.count, + legend: fmtLabel(b.timestamp, granularity), + color: tokens.colorBrandBackground, + })); +} + +export function DashboardTimeline({ dashboard }: DashboardTimelineProps) { + const [granularity, setGranularity] = useState("hour"); + + const buckets = + granularity === "minute" + ? dashboard.timelineMinute + : granularity === "hour" + ? dashboard.timelineHourly + : dashboard.timelineDaily; + + const data = bucketsToChartData(buckets, granularity); + + const selectedLabel = + GRANULARITY_OPTIONS.find((o) => o.value === granularity)?.label ?? "Per Hour"; + + return ( +
+
+

+ Event Timeline +

+ setGranularity(d.optionValue as Granularity)} + size="small" + style={{ minWidth: "130px" }} + > + {GRANULARITY_OPTIONS.map((o) => ( + + ))} + +
+ + {data.length === 0 ? ( +
+ No timeline data available. +
+ ) : ( +
+ +
+ )} +
+ ); +} diff --git a/src/components/sysmon/DashboardTopList.tsx b/src/components/sysmon/DashboardTopList.tsx new file mode 100644 index 000000000..4e4c99e7e --- /dev/null +++ b/src/components/sysmon/DashboardTopList.tsx @@ -0,0 +1,68 @@ +import { tokens } from "@fluentui/react-components"; +import { HorizontalBarChart } from "@fluentui/react-charts"; +import type { RankedItem } from "../../types/sysmon"; + +interface DashboardTopListProps { + title: string; + items: RankedItem[]; + emptyMessage?: string; + color?: string; +} + +export function DashboardTopList({ + title, + items, + emptyMessage = "No data available.", + color, +}: DashboardTopListProps) { + if (items.length === 0) { + return ( +
+

{title}

+
+ {emptyMessage} +
+
+ ); + } + + const maxCount = Math.max(...items.map((i) => i.count), 1); + const barColor = color ?? tokens.colorBrandBackground; + + // HorizontalBarChart expects ChartProps[] where each ChartProps has chartTitle and chartData + const chartData = items.map((item) => ({ + chartTitle: item.name, + chartData: [ + { + legend: item.name, + horizontalBarChartdata: { x: item.count, y: maxCount }, + color: barColor, + }, + ], + })); + + return ( +
+

{title}

+ true)} + barHeight={14} + /> +
+ ); +} + +const containerStyle: React.CSSProperties = { + padding: "16px", + backgroundColor: tokens.colorNeutralBackground1, + borderRadius: "6px", + border: `1px solid ${tokens.colorNeutralStroke2}`, +}; + +const titleStyle: React.CSSProperties = { + margin: "0 0 12px 0", + fontSize: "13px", + fontWeight: 600, + color: tokens.colorNeutralForeground1, +}; diff --git a/src/components/sysmon/SysmonConfigView.tsx b/src/components/sysmon/SysmonConfigView.tsx new file mode 100644 index 000000000..feb628863 --- /dev/null +++ b/src/components/sysmon/SysmonConfigView.tsx @@ -0,0 +1,126 @@ +import { tokens } from "@fluentui/react-components"; +import { useSysmonStore } from "../../stores/sysmon-store"; +import { LOG_MONOSPACE_FONT_FAMILY } from "../../lib/log-accessibility"; + +export function SysmonConfigView() { + const config = useSysmonStore((s) => s.config); + + if (!config || !config.found) { + return ( +
+ No Sysmon configuration data found in the analyzed events. +
+ ); + } + + return ( +
+

+ Sysmon Configuration +

+ + {/* Config metadata */} +
+ {config.schemaVersion && ( + <> + Schema Version + {config.schemaVersion} + + )} + {config.sysmonVersion && ( + <> + Sysmon Version + {config.sysmonVersion} + + )} + {config.hashAlgorithms && ( + <> + Hash Algorithms + {config.hashAlgorithms} + + )} + {config.lastConfigChange && ( + <> + Last Config Change + {config.lastConfigChange} + + )} +
+ + {/* Active event types */} + {config.activeEventTypes.length > 0 && ( +
+

+ Active Event Types (observed in data) +

+
+

+ These event types were found in the analyzed EVTX files, indicating they are enabled + in the Sysmon configuration. +

+
+ {config.activeEventTypes.map((et) => ( + + ID {et.eventId}: {et.displayName} ({et.count.toLocaleString()}) + + ))} +
+
+
+ )} + + {/* Configuration XML if available */} + {config.configurationXml && ( +
+

+ Configuration Details (from Event ID 16) +

+
+            {config.configurationXml}
+          
+
+ )} +
+ ); +} + +const labelStyle: React.CSSProperties = { + fontWeight: 600, + color: tokens.colorNeutralForeground3, +}; diff --git a/src/components/sysmon/SysmonDashboardView.tsx b/src/components/sysmon/SysmonDashboardView.tsx new file mode 100644 index 000000000..f009e5922 --- /dev/null +++ b/src/components/sysmon/SysmonDashboardView.tsx @@ -0,0 +1,107 @@ +import { tokens } from "@fluentui/react-components"; +import { useSysmonStore } from "../../stores/sysmon-store"; +import { DashboardMetricCards } from "./DashboardMetricCards"; +import { DashboardTimeline } from "./DashboardTimeline"; +import { DashboardEventTypeChart } from "./DashboardEventTypeChart"; +import { DashboardSecurityAlerts } from "./DashboardSecurityAlerts"; +import { DashboardTopList } from "./DashboardTopList"; + +export function SysmonDashboardView() { + const summary = useSysmonStore((s) => s.summary); + const dashboard = useSysmonStore((s) => s.dashboard); + + if (!summary || !dashboard) { + return ( +
+ No dashboard data available. +
+ ); + } + + return ( +
+ {/* Hero metric cards — full width above grid */} + + + {/* Main grid */} +
+ {/* Timeline spans full width */} + + + {/* Donut + Security stacked left, Top Processes right */} +
+
+ + +
+ +
+ {/* Network, DNS, Ports on same row */} +
+ + + +
+
+ +
+
+ +
+
+
+ ); +} diff --git a/src/components/sysmon/SysmonEventTable.tsx b/src/components/sysmon/SysmonEventTable.tsx new file mode 100644 index 000000000..82335c9d6 --- /dev/null +++ b/src/components/sysmon/SysmonEventTable.tsx @@ -0,0 +1,368 @@ +import { useMemo, useRef } from "react"; +import { useVirtualizer } from "@tanstack/react-virtual"; +import { tokens } from "@fluentui/react-components"; +import { getLogListMetrics, LOG_MONOSPACE_FONT_FAMILY } from "../../lib/log-accessibility"; +import { useSysmonStore } from "../../stores/sysmon-store"; +import { useUiStore } from "../../stores/ui-store"; +import { getThemeById } from "../../lib/themes"; +import type { SysmonEvent } from "../../types/sysmon"; + +const DETAIL_HEIGHT = 200; + +function formatTimestamp(ts: string): string { + if (!ts) return ""; + try { + const d = new Date(ts); + return d.toISOString().replace("T", " ").replace("Z", ""); + } catch { + return ts; + } +} + +export function SysmonEventTable() { + const events = useSysmonStore((s) => s.events); + const selectedEventId = useSysmonStore((s) => s.selectedEventId); + const selectEvent = useSysmonStore((s) => s.selectEvent); + const filterEventType = useSysmonStore((s) => s.filterEventType); + const filterSeverity = useSysmonStore((s) => s.filterSeverity); + const searchQuery = useSysmonStore((s) => s.searchQuery); + const setFilterEventType = useSysmonStore((s) => s.setFilterEventType); + const setFilterSeverity = useSysmonStore((s) => s.setFilterSeverity); + const setSearchQuery = useSysmonStore((s) => s.setSearchQuery); + const summary = useSysmonStore((s) => s.summary); + const themeId = useUiStore((s) => s.themeId); + const logListFontSize = useUiStore((s) => s.logListFontSize); + const metrics = useMemo(() => getLogListMetrics(logListFontSize), [logListFontSize]); + const severityPalette = useMemo(() => getThemeById(themeId).severityPalette, [themeId]); + + const filteredEvents = useMemo(() => { + let result = events; + if (filterEventType !== "All") { + result = result.filter((e) => e.eventType === filterEventType); + } + if (filterSeverity !== "All") { + result = result.filter((e) => e.severity === filterSeverity); + } + if (searchQuery) { + const q = searchQuery.toLowerCase(); + result = result.filter( + (e) => + e.message.toLowerCase().includes(q) || + (e.image && e.image.toLowerCase().includes(q)) || + (e.commandLine && e.commandLine.toLowerCase().includes(q)) || + (e.targetFilename && e.targetFilename.toLowerCase().includes(q)) || + (e.queryName && e.queryName.toLowerCase().includes(q)) || + (e.targetObject && e.targetObject.toLowerCase().includes(q)) || + (e.destinationIp && e.destinationIp.includes(q)) || + (e.sourceIp && e.sourceIp.includes(q)) + ); + } + return result; + }, [events, filterEventType, filterSeverity, searchQuery]); + + const parentRef = useRef(null); + + const virtualizer = useVirtualizer({ + count: filteredEvents.length, + getScrollElement: () => parentRef.current, + estimateSize: (index) => + filteredEvents[index]?.id === selectedEventId ? DETAIL_HEIGHT : metrics.rowHeight, + overscan: 20, + }); + + const eventTypes = useMemo(() => { + if (!summary) return []; + return summary.eventTypeCounts.map((c) => ({ + value: c.eventType, + label: `${c.displayName} (${c.count})`, + })); + }, [summary]); + + return ( +
+ {/* Filter bar */} +
+ + + setSearchQuery(e.target.value)} + style={{ + fontSize: `${metrics.fontSize}px`, + backgroundColor: tokens.colorNeutralBackground1, + color: tokens.colorNeutralForeground1, + border: `1px solid ${tokens.colorNeutralStroke1}`, + borderRadius: "3px", + padding: "2px 8px", + minWidth: "200px", + flex: 1, + maxWidth: "400px", + }} + /> + + {filteredEvents.length.toLocaleString()} events + +
+ + {/* Header row */} +
+ Timestamp + Event Type + Severity + Message +
+ + {/* Virtual list */} +
+
+ {virtualizer.getVirtualItems().map((virtualRow) => { + const event = filteredEvents[virtualRow.index]; + const isSelected = event.id === selectedEventId; + + return ( +
+ selectEvent(isSelected ? null : event.id)} + severityPalette={severityPalette} + rowHeight={metrics.rowHeight} + fontSize={metrics.fontSize} + /> + {isSelected && } +
+ ); + })} +
+
+
+ ); +} + +function EventRow({ + event, + isSelected, + onClick, + severityPalette, + rowHeight, + fontSize, +}: { + event: SysmonEvent; + isSelected: boolean; + onClick: () => void; + severityPalette: import("../../lib/constants").LogSeverityPalette; + rowHeight: number; + fontSize: number; +}) { + const severityColor = + event.severity === "Error" + ? severityPalette.error.text + : event.severity === "Warning" + ? severityPalette.warning.text + : severityPalette.info.text; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onClick(); + } + }; + + return ( +
+ + {formatTimestamp(event.timestamp)} + + + {event.eventTypeDisplay} + + + {event.severity} + + + {event.message} + +
+ ); +} + +function EventDetail({ event }: { event: SysmonEvent }) { + const fields: [string, string | number | null | undefined][] = [ + ["Event ID", event.eventId], + ["Record ID", event.recordId], + ["Computer", event.computer], + ["User", event.user], + ["Image", event.image], + ["Command Line", event.commandLine], + ["Process ID", event.processId], + ["Process GUID", event.processGuid], + ["Parent Image", event.parentImage], + ["Parent Command Line", event.parentCommandLine], + ["Parent PID", event.parentProcessId], + ["Target Filename", event.targetFilename], + ["Target Object", event.targetObject], + ["Details", event.details], + ["Protocol", event.protocol], + ["Source IP", event.sourceIp], + ["Source Port", event.sourcePort], + ["Destination IP", event.destinationIp], + ["Destination Port", event.destinationPort], + ["Destination Host", event.destinationHostname], + ["DNS Query", event.queryName], + ["DNS Results", event.queryResults], + ["Source Image", event.sourceImage], + ["Target Image", event.targetImage], + ["Granted Access", event.grantedAccess], + ["Hashes", event.hashes], + ["Rule Name", event.ruleName], + ["Source File", event.sourceFile], + ]; + + const populated = fields.filter( + ([, v]) => v != null && v !== "" && v !== undefined + ); + + return ( +
+
+ {populated.map(([label, value]) => ( +
+ + {label} + + + {String(value)} + +
+ ))} +
+
+ ); +} diff --git a/src/components/sysmon/SysmonSummaryView.tsx b/src/components/sysmon/SysmonSummaryView.tsx new file mode 100644 index 000000000..064ee9a3f --- /dev/null +++ b/src/components/sysmon/SysmonSummaryView.tsx @@ -0,0 +1,87 @@ +import { tokens } from "@fluentui/react-components"; +import { useSysmonStore } from "../../stores/sysmon-store"; + +export function SysmonSummaryView() { + const summary = useSysmonStore((s) => s.summary); + + if (!summary) { + return ( +
+ No summary available. +
+ ); + } + + return ( +
+

+ Sysmon Analysis Summary +

+ + {/* Event type breakdown */} +
+

+ Event Type Breakdown +

+ + + + + + + + + + + {summary.eventTypeCounts.map((tc) => ( + + + + + + + ))} + +
Event IDTypeCount%
{tc.eventId}{tc.displayName}{tc.count.toLocaleString()} + {summary.totalEvents > 0 + ? ((tc.count / summary.totalEvents) * 100).toFixed(1) + : "0"} + % +
+
+ + {/* Source files */} + {summary.sourceFiles.length > 0 && ( +
+

Source Files

+
    + {summary.sourceFiles.map((f) => ( +
  • + {f} +
  • + ))} +
+
+ )} +
+ ); +} + +const thStyle: React.CSSProperties = { + textAlign: "left", + padding: "6px 12px", + borderBottom: `1px solid ${tokens.colorNeutralStroke2}`, + fontWeight: 600, +}; + +const tdStyle: React.CSSProperties = { + padding: "4px 12px", + borderBottom: `1px solid ${tokens.colorNeutralStroke3}`, +}; diff --git a/src/components/sysmon/SysmonWorkspace.tsx b/src/components/sysmon/SysmonWorkspace.tsx new file mode 100644 index 000000000..26e1848c8 --- /dev/null +++ b/src/components/sysmon/SysmonWorkspace.tsx @@ -0,0 +1,123 @@ +import { tokens, Button, Spinner, Tab, TabList } from "@fluentui/react-components"; +import { useSysmonStore, type SysmonWorkspaceTab } from "../../stores/sysmon-store"; +import { useAppActions } from "../layout/Toolbar"; +import { SysmonEventTable } from "./SysmonEventTable"; +import { SysmonSummaryView } from "./SysmonSummaryView"; +import { SysmonConfigView } from "./SysmonConfigView"; +import { SysmonDashboardView } from "./SysmonDashboardView"; + +export function SysmonWorkspace() { + const isAnalyzing = useSysmonStore((s) => s.isAnalyzing); + const analysisError = useSysmonStore((s) => s.analysisError); + const progressMessage = useSysmonStore((s) => s.progressMessage); + const events = useSysmonStore((s) => s.events); + const activeTab = useSysmonStore((s) => s.activeTab); + const setActiveTab = useSysmonStore((s) => s.setActiveTab); + const sourcePath = useSysmonStore((s) => s.sourcePath); + const { commandState, refreshActiveSource } = useAppActions(); + + if (isAnalyzing) { + return ( +
+ + + {progressMessage || "Analyzing Sysmon logs..."} + +
+ ); + } + + if (analysisError) { + return ( +
+ + {analysisError} + +
+ ); + } + + if (events.length === 0) { + return ( +
+ Sysmon Log Viewer + + Open a Sysmon .evtx file or folder to analyze events. + +
+ ); + } + + return ( +
+
+ setActiveTab(data.value as SysmonWorkspaceTab)} + size="small" + style={{ flex: 1 }} + > + Dashboard + Events ({events.length.toLocaleString()}) + Summary + Configuration + + +
+ +
+ {activeTab === "dashboard" && } + {activeTab === "events" && } + {activeTab === "summary" && } + {activeTab === "config" && } +
+
+ ); +} diff --git a/src/hooks/use-sysmon-analysis-progress.ts b/src/hooks/use-sysmon-analysis-progress.ts new file mode 100644 index 000000000..1e11b355e --- /dev/null +++ b/src/hooks/use-sysmon-analysis-progress.ts @@ -0,0 +1,22 @@ +import { useEffect } from "react"; +import { listen } from "@tauri-apps/api/event"; +import { useSysmonStore, type SysmonAnalysisProgress } from "../stores/sysmon-store"; + +const SYSMON_ANALYSIS_PROGRESS_EVENT = "sysmon-analysis-progress"; + +export function useSysmonAnalysisProgress() { + const updateProgress = useSysmonStore((s) => s.updateProgress); + + useEffect(() => { + const unlisten = listen( + SYSMON_ANALYSIS_PROGRESS_EVENT, + (event) => { + updateProgress(event.payload); + } + ); + + return () => { + unlisten.then((dispose) => dispose()); + }; + }, [updateProgress]); +} diff --git a/src/lib/commands.ts b/src/lib/commands.ts index 130fb5f7d..7089aa2ba 100644 --- a/src/lib/commands.ts +++ b/src/lib/commands.ts @@ -11,6 +11,7 @@ import type { import type { EvidenceArtifactPreview, EvidenceBundleDetails, EvidenceArtifactIntakeKind } from "../types/evidence"; import type { RegistryParseResult } from "../types/registry"; import type { IntuneAnalysisResult } from "../types/intune"; +import type { SysmonAnalysisResult } from "../types/sysmon"; import type { DsregcmdAnalysisResult, DsregcmdCaptureResult, @@ -189,6 +190,18 @@ export async function analyzeIntuneLogs( }); } +export async function analyzeSysmonLogs( + path: string, + requestId: string, + options?: { includeLiveEventLogs?: boolean } +): Promise { + return invokeCommand("analyze_sysmon_logs", { + path, + requestId, + includeLiveEventLogs: options?.includeLiveEventLogs ?? false, + }); +} + export async function analyzeDsregcmd( input: string, bundlePath?: string | null diff --git a/src/stores/sysmon-store.ts b/src/stores/sysmon-store.ts new file mode 100644 index 000000000..f26b44a27 --- /dev/null +++ b/src/stores/sysmon-store.ts @@ -0,0 +1,145 @@ +import { create } from "zustand"; +import type { + SysmonAnalysisResult, + SysmonConfig, + SysmonDashboardData, + SysmonEvent, + SysmonEventType, + SysmonSeverity, + SysmonSummary, +} from "../types/sysmon"; + +export type SysmonWorkspaceTab = "dashboard" | "events" | "summary" | "config"; + +export interface SysmonAnalysisProgress { + requestId: string; + stage: string; + message: string; + completedFiles: number; + totalFiles: number; +} + +interface SysmonState { + events: SysmonEvent[]; + summary: SysmonSummary | null; + config: SysmonConfig | null; + dashboard: SysmonDashboardData | null; + sourcePath: string | null; + isAnalyzing: boolean; + analysisError: string | null; + progressMessage: string | null; + /** requestId of the active analysis — used to discard stale progress events. */ + currentRequestId: string | null; + + // Interaction state + selectedEventId: number | null; + activeTab: SysmonWorkspaceTab; + filterEventType: SysmonEventType | "All"; + filterSeverity: SysmonSeverity | "All"; + searchQuery: string; + + // Actions + beginAnalysis: (path: string, requestId: string) => void; + setResults: (result: SysmonAnalysisResult) => void; + failAnalysis: (error: string) => void; + updateProgress: (progress: SysmonAnalysisProgress) => void; + selectEvent: (id: number | null) => void; + setActiveTab: (tab: SysmonWorkspaceTab) => void; + setFilterEventType: (type_: SysmonEventType | "All") => void; + setFilterSeverity: (severity: SysmonSeverity | "All") => void; + setSearchQuery: (query: string) => void; + clear: () => void; +} + +export const useSysmonStore = create((set) => ({ + events: [], + summary: null, + config: null, + dashboard: null, + sourcePath: null, + isAnalyzing: false, + analysisError: null, + progressMessage: null, + currentRequestId: null, + selectedEventId: null, + activeTab: "events", + filterEventType: "All", + filterSeverity: "All", + searchQuery: "", + + beginAnalysis: (path, requestId) => + set({ + events: [], + summary: null, + config: null, + dashboard: null, + sourcePath: path, + isAnalyzing: true, + analysisError: null, + progressMessage: "Starting Sysmon analysis...", + currentRequestId: requestId, + selectedEventId: null, + filterEventType: "All", + filterSeverity: "All", + searchQuery: "", + }), + + setResults: (result) => + set({ + events: result.events, + summary: result.summary, + config: result.config, + dashboard: result.dashboard, + sourcePath: result.sourcePath, + isAnalyzing: false, + analysisError: null, + progressMessage: null, + activeTab: "dashboard", + }), + + failAnalysis: (error) => + set({ + isAnalyzing: false, + analysisError: error, + progressMessage: null, + }), + + updateProgress: (progress) => + set((state) => { + if (!state.isAnalyzing) return state; + if (state.currentRequestId !== null && state.currentRequestId !== progress.requestId) { + return state; + } + return { progressMessage: progress.message }; + }), + + selectEvent: (id) => set({ selectedEventId: id }), + + setActiveTab: (tab) => set({ activeTab: tab }), + + setFilterEventType: (type_) => + set({ filterEventType: type_, selectedEventId: null }), + + setFilterSeverity: (severity) => + set({ filterSeverity: severity, selectedEventId: null }), + + setSearchQuery: (query) => set({ searchQuery: query }), + + clear: () => + set({ + events: [], + summary: null, + config: null, + dashboard: null, + sourcePath: null, + isAnalyzing: false, + analysisError: null, + progressMessage: null, + currentRequestId: null, + selectedEventId: null, + activeTab: "events", + filterEventType: "All", + filterSeverity: "All", + searchQuery: "", + }), +})); diff --git a/src/stores/ui-store.test.ts b/src/stores/ui-store.test.ts index 9d148979a..1a88f3bce 100644 --- a/src/stores/ui-store.test.ts +++ b/src/stores/ui-store.test.ts @@ -175,6 +175,7 @@ describe("getAvailableWorkspaces", () => { expect(workspaces).toContain("intune"); expect(workspaces).toContain("dsregcmd"); expect(workspaces).toContain("deployment"); + expect(workspaces).toContain("sysmon"); expect(workspaces).not.toContain("macos-diag"); }); @@ -182,6 +183,7 @@ describe("getAvailableWorkspaces", () => { const workspaces = getAvailableWorkspaces("macos"); expect(workspaces).toContain("log"); expect(workspaces).toContain("macos-diag"); + expect(workspaces).not.toContain("sysmon"); expect(workspaces).not.toContain("dsregcmd"); expect(workspaces).not.toContain("deployment"); }); @@ -189,6 +191,7 @@ describe("getAvailableWorkspaces", () => { it("returns linux workspaces for linux", () => { const workspaces = getAvailableWorkspaces("linux"); expect(workspaces).toContain("log"); + expect(workspaces).not.toContain("sysmon"); expect(workspaces).not.toContain("dsregcmd"); expect(workspaces).not.toContain("macos-diag"); }); diff --git a/src/stores/ui-store.ts b/src/stores/ui-store.ts index 3b1cf323d..23391bf57 100644 --- a/src/stores/ui-store.ts +++ b/src/stores/ui-store.ts @@ -47,6 +47,7 @@ const WORKSPACE_PLATFORM_MAP: Record = { "macos-diag": ["macos"], deployment: ["windows"], "event-log": "all", + sysmon: ["windows"], }; export function getAvailableWorkspaces( @@ -120,6 +121,14 @@ export function getUiChromeStatus( }; } + if (activeView === "sysmon") { + return { + viewLabel: "Sysmon workspace", + detailsLabel: "Details hidden in Sysmon workspace", + infoLabel: "Info hidden in Sysmon workspace", + }; + } + if (activeView === "dsregcmd") { return { viewLabel: "dsregcmd workspace", diff --git a/src/types/log.ts b/src/types/log.ts index af334ce37..d410390ac 100644 --- a/src/types/log.ts +++ b/src/types/log.ts @@ -47,7 +47,8 @@ export type WorkspaceId = | "dsregcmd" | "macos-diag" | "deployment" - | "event-log"; + | "event-log" + | "sysmon"; export type KnownSourceDefaultFileSelectionBehavior = | "none" | "preferFileName" diff --git a/src/types/sysmon.ts b/src/types/sysmon.ts new file mode 100644 index 000000000..54f03a3fd --- /dev/null +++ b/src/types/sysmon.ts @@ -0,0 +1,152 @@ +export type SysmonEventType = + | "ProcessCreate" + | "FileCreateTime" + | "NetworkConnect" + | "ServiceStateChange" + | "ProcessTerminate" + | "DriverLoad" + | "ImageLoad" + | "CreateRemoteThread" + | "RawAccessRead" + | "ProcessAccess" + | "FileCreate" + | "RegistryAddOrDelete" + | "RegistryValueSet" + | "RegistryRename" + | "FileCreateStreamHash" + | "ConfigChange" + | "PipeCreated" + | "PipeConnected" + | "WmiFilter" + | "WmiConsumer" + | "WmiBinding" + | "DnsQuery" + | "FileDelete" + | "ClipboardChange" + | "ProcessTampering" + | "FileDeleteDetected" + | "FileBlockExecutable" + | "FileBlockShredding" + | "FileExecutableDetected" + | "Error" + | "Unknown"; + +export type SysmonSeverity = "Info" | "Warning" | "Error"; + +export interface SysmonEvent { + id: number; + eventId: number; + eventType: SysmonEventType; + eventTypeDisplay: string; + severity: SysmonSeverity; + timestamp: string; + timestampMs: number | null; + computer: string | null; + recordId: number; + + // Common fields + ruleName?: string | null; + utcTime?: string | null; + processGuid?: string | null; + processId?: number | null; + image?: string | null; + commandLine?: string | null; + user?: string | null; + hashes?: string | null; + parentImage?: string | null; + parentCommandLine?: string | null; + parentProcessId?: number | null; + + // File events + targetFilename?: string | null; + + // Network events + protocol?: string | null; + sourceIp?: string | null; + sourcePort?: number | null; + destinationIp?: string | null; + destinationPort?: number | null; + destinationHostname?: string | null; + + // Registry events + targetObject?: string | null; + details?: string | null; + + // DNS events + queryName?: string | null; + queryResults?: string | null; + + // Process access + sourceImage?: string | null; + targetImage?: string | null; + grantedAccess?: string | null; + + message: string; + sourceFile: string; +} + +export interface SysmonEventTypeCount { + eventId: number; + eventType: SysmonEventType; + displayName: string; + count: number; +} + +export interface SysmonSummary { + totalEvents: number; + eventTypeCounts: SysmonEventTypeCount[]; + uniqueProcesses: number; + uniqueComputers: number; + earliestTimestamp: string | null; + latestTimestamp: string | null; + sourceFiles: string[]; + parseErrors: number; +} + +export interface SysmonConfig { + schemaVersion: string | null; + hashAlgorithms: string | null; + found: boolean; + lastConfigChange: string | null; + configurationXml: string | null; + sysmonVersion: string | null; + activeEventTypes: SysmonEventTypeCount[]; +} + +export interface TimeBucket { + timestamp: string; + timestampMs: number; + count: number; +} + +export interface RankedItem { + name: string; + count: number; +} + +export interface SecuritySummary { + totalWarnings: number; + totalErrors: number; + eventsByType: RankedItem[]; +} + +export interface SysmonDashboardData { + timelineMinute: TimeBucket[]; + timelineHourly: TimeBucket[]; + timelineDaily: TimeBucket[]; + topProcesses: RankedItem[]; + topDestinations: RankedItem[]; + topPorts: RankedItem[]; + topDnsQueries: RankedItem[]; + securityEvents: SecuritySummary; + topTargetFiles: RankedItem[]; + topRegistryKeys: RankedItem[]; +} + +export interface SysmonAnalysisResult { + events: SysmonEvent[]; + summary: SysmonSummary; + config: SysmonConfig; + dashboard: SysmonDashboardData; + sourcePath: string; +}