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)
This commit is contained in:
Shaun Arman 2026-04-19 18:25:53 -05:00
parent 13c4969e31
commit 8b0cbc3ce8
2 changed files with 34 additions and 10 deletions

View File

@ -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<String>,
state: State<'_, AppState>,
) -> Result<TimelineEvent, String> {
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::<serde_json::Value>(&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)
}

View File

@ -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 {