From 8b0cbc3ce8ba9037325a9e9e590ceb98c8a44d7b Mon Sep 17 00:00:00 2001 From: Shaun Arman Date: Sun, 19 Apr 2026 18:25:53 -0500 Subject: [PATCH] fix: harden timeline event input validation and atomic writes Address security review findings: - Validate event_type against whitelist of 7 known types (M-3) - Validate metadata is valid JSON and under 10KB (M-2, M-4) - Include metadata in audit log details (M-2) - Wrap timeline insert + audit write + timestamp update in a SQLite transaction for atomicity (M-5) - Fix TypeScript TimelineEvent interface: add issue_id, metadata fields and correct created_at type to string (L-3) - Add timeline_events to IssueDetail TypeScript interface (L-4) --- src-tauri/src/commands/db.rs | 39 +++++++++++++++++++++++++++--------- src/lib/tauriCommands.ts | 5 ++++- 2 files changed, 34 insertions(+), 10 deletions(-) diff --git a/src-tauri/src/commands/db.rs b/src-tauri/src/commands/db.rs index 033feca0..73550f94 100644 --- a/src-tauri/src/commands/db.rs +++ b/src-tauri/src/commands/db.rs @@ -533,6 +533,16 @@ pub async fn update_five_why( Ok(()) } +const VALID_EVENT_TYPES: &[&str] = &[ + "triage_started", + "log_uploaded", + "why_level_advanced", + "root_cause_identified", + "rca_generated", + "postmortem_generated", + "document_exported", +]; + #[tauri::command] pub async fn add_timeline_event( issue_id: String, @@ -541,17 +551,28 @@ pub async fn add_timeline_event( metadata: Option, state: State<'_, AppState>, ) -> Result { + if !VALID_EVENT_TYPES.contains(&event_type.as_str()) { + return Err(format!("Invalid event_type: {event_type}")); + } + + let meta = metadata.unwrap_or_else(|| "{}".to_string()); + if meta.len() > 10240 { + return Err("metadata exceeds maximum size of 10KB".to_string()); + } + serde_json::from_str::(&meta) + .map_err(|_| "metadata must be valid JSON".to_string())?; + let event = TimelineEvent::new( issue_id.clone(), event_type.clone(), description.clone(), - metadata.unwrap_or_else(|| "{}".to_string()), + meta, ); - let db = state.db.lock().map_err(|e| e.to_string())?; + let mut db = state.db.lock().map_err(|e| e.to_string())?; + let tx = db.transaction().map_err(|e| e.to_string())?; - // Write to timeline_events table - db.execute( + tx.execute( "INSERT INTO timeline_events (id, issue_id, event_type, description, metadata, created_at) \ VALUES (?1, ?2, ?3, ?4, ?5, ?6)", rusqlite::params![ @@ -565,24 +586,24 @@ pub async fn add_timeline_event( ) .map_err(|e| e.to_string())?; - // Dual-write to audit_log for security hash chain crate::audit::log::write_audit_event( - &db, + &tx, &event_type, "issue", &issue_id, - &serde_json::json!({ "description": description }).to_string(), + &serde_json::json!({ "description": description, "metadata": event.metadata }).to_string(), ) .map_err(|_| "Failed to write security audit entry".to_string())?; - // Update issue timestamp let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(); - db.execute( + tx.execute( "UPDATE issues SET updated_at = ?1 WHERE id = ?2", rusqlite::params![now, issue_id], ) .map_err(|e| e.to_string())?; + tx.commit().map_err(|e| e.to_string())?; + Ok(event) } diff --git a/src/lib/tauriCommands.ts b/src/lib/tauriCommands.ts index a1fce80e..c78ede46 100644 --- a/src/lib/tauriCommands.ts +++ b/src/lib/tauriCommands.ts @@ -74,9 +74,11 @@ export interface FiveWhyEntry { export interface TimelineEvent { id: string; + issue_id: string; event_type: string; description: string; - created_at: number; + metadata: string; + created_at: string; } export interface AiConversation { @@ -104,6 +106,7 @@ export interface IssueDetail { image_attachments: ImageAttachment[]; resolution_steps: ResolutionStep[]; conversations: AiConversation[]; + timeline_events: TimelineEvent[]; } export interface IssueSummary {