1use ratatui::Frame;
5use ratatui::layout::Rect;
6use ratatui::style::{Color, Modifier, Style};
7use ratatui::text::{Line, Span};
8use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph};
9
10use crate::metrics::{MetricsSnapshot, SecurityEventCategory};
11use crate::theme::Theme;
12
13pub fn render(metrics: &MetricsSnapshot, frame: &mut Frame, area: Rect) {
14 let theme = Theme::default();
15 let block = Block::default()
16 .title(" Security ")
17 .borders(Borders::ALL)
18 .style(theme.panel_border);
19
20 let inner = block.inner(area);
21 frame.render_widget(block, area);
22
23 if inner.height == 0 {
24 return;
25 }
26
27 let all_zero = metrics.sanitizer_runs == 0
28 && metrics.sanitizer_injection_flags == 0
29 && metrics.sanitizer_truncations == 0
30 && metrics.quarantine_invocations == 0
31 && metrics.quarantine_failures == 0
32 && metrics.exfiltration_images_blocked == 0
33 && metrics.exfiltration_tool_urls_flagged == 0
34 && metrics.exfiltration_memory_guards == 0
35 && metrics.pre_execution_blocks == 0
36 && metrics.pre_execution_warnings == 0
37 && metrics.egress_requests_total == 0
38 && metrics.egress_blocked_total == 0
39 && metrics.security_events.is_empty();
40
41 if all_zero {
42 let msg = Paragraph::new("No security events.").style(theme.system_message);
43 frame.render_widget(msg, inner);
44 return;
45 }
46
47 let base = theme.system_message;
48 let flag_style = Style::default()
49 .fg(Color::Yellow)
50 .add_modifier(Modifier::BOLD);
51 let block_style = Style::default().fg(Color::Red).add_modifier(Modifier::BOLD);
52
53 let mut items = build_metric_items(metrics, base, flag_style, block_style);
54 append_event_items(metrics, &mut items, base, flag_style, block_style);
55
56 let list = List::new(items);
57 frame.render_widget(list, inner);
58}
59
60fn plain_metric_item(
62 label: &'static str,
63 value: impl std::fmt::Display,
64 base: Style,
65) -> ListItem<'static> {
66 ListItem::new(Line::from(Span::styled(format!("{label}{value}"), base)))
67}
68
69fn styled_counter_item<'a>(
71 label: &'static str,
72 value: u64,
73 base: Style,
74 alert_style: Style,
75) -> ListItem<'a> {
76 ListItem::new(Line::from(vec![
77 Span::styled(label, base),
78 Span::styled(
79 value.to_string(),
80 if value > 0 { alert_style } else { base },
81 ),
82 ]))
83}
84
85fn build_sanitizer_items<'a>(
86 metrics: &MetricsSnapshot,
87 base: Style,
88 flag_style: Style,
89) -> Vec<ListItem<'a>> {
90 vec![
91 plain_metric_item("Sanitizer runs: ", metrics.sanitizer_runs, base),
92 styled_counter_item(
93 "Inj flags: ",
94 metrics.sanitizer_injection_flags,
95 base,
96 flag_style,
97 ),
98 plain_metric_item("Truncations: ", metrics.sanitizer_truncations, base),
99 plain_metric_item("Quarantine calls: ", metrics.quarantine_invocations, base),
100 plain_metric_item("Quarantine fails: ", metrics.quarantine_failures, base),
101 ]
102}
103
104fn build_exfiltration_items<'a>(
105 metrics: &MetricsSnapshot,
106 base: Style,
107 block_style: Style,
108) -> Vec<ListItem<'a>> {
109 vec![
110 styled_counter_item(
111 "Exfil images: ",
112 metrics.exfiltration_images_blocked,
113 base,
114 block_style,
115 ),
116 styled_counter_item(
117 "Exfil URLs: ",
118 metrics.exfiltration_tool_urls_flagged,
119 base,
120 block_style,
121 ),
122 plain_metric_item(
123 "Memory guards: ",
124 metrics.exfiltration_memory_guards,
125 base,
126 ),
127 ]
128}
129
130fn build_pre_execution_items<'a>(
131 metrics: &MetricsSnapshot,
132 base: Style,
133 flag_style: Style,
134 block_style: Style,
135) -> Vec<ListItem<'a>> {
136 vec![
137 styled_counter_item(
138 "Verify blocks: ",
139 metrics.pre_execution_blocks,
140 base,
141 block_style,
142 ),
143 styled_counter_item(
144 "Verify warnings: ",
145 metrics.pre_execution_warnings,
146 base,
147 flag_style,
148 ),
149 ]
150}
151
152fn build_egress_items<'a>(
153 metrics: &MetricsSnapshot,
154 base: Style,
155 flag_style: Style,
156 block_style: Style,
157) -> Vec<ListItem<'a>> {
158 vec![
159 plain_metric_item("Egress requests: ", metrics.egress_requests_total, base),
160 styled_counter_item(
161 "Egress blocked: ",
162 metrics.egress_blocked_total,
163 base,
164 block_style,
165 ),
166 styled_counter_item(
167 "Egress dropped: ",
168 metrics.egress_dropped_total,
169 base,
170 flag_style,
171 ),
172 ]
173}
174
175fn build_metric_items<'a>(
176 metrics: &MetricsSnapshot,
177 base: Style,
178 flag_style: Style,
179 block_style: Style,
180) -> Vec<ListItem<'a>> {
181 let mut items = build_sanitizer_items(metrics, base, flag_style);
182 items.extend(build_exfiltration_items(metrics, base, block_style));
183 items.extend(build_pre_execution_items(
184 metrics,
185 base,
186 flag_style,
187 block_style,
188 ));
189 items.extend(build_egress_items(metrics, base, flag_style, block_style));
190 items
191}
192
193fn append_event_items<'a>(
194 metrics: &'a MetricsSnapshot,
195 items: &mut Vec<ListItem<'a>>,
196 base: Style,
197 flag_style: Style,
198 block_style: Style,
199) {
200 if metrics.security_events.is_empty() {
201 return;
202 }
203 items.push(ListItem::new(Line::from(Span::styled(
204 "Recent events:",
205 Style::default()
206 .fg(Color::DarkGray)
207 .add_modifier(Modifier::UNDERLINED),
208 ))));
209
210 let start = metrics.security_events.len().saturating_sub(5);
212 for ev in metrics.security_events.range(start..) {
213 let (cat_str, cat_style) = match ev.category {
214 SecurityEventCategory::InjectionFlag => ("[inj] ", flag_style),
215 SecurityEventCategory::InjectionBlocked => ("[injb] ", block_style),
216 SecurityEventCategory::ExfiltrationBlock => ("[exfil]", block_style),
217 SecurityEventCategory::Quarantine => ("[quar] ", Style::default().fg(Color::Cyan)),
218 SecurityEventCategory::Truncation => ("[trunc]", Style::default().fg(Color::DarkGray)),
219 SecurityEventCategory::RateLimit => ("[rlim] ", Style::default().fg(Color::Yellow)),
220 SecurityEventCategory::MemoryValidation => {
221 ("[mval] ", Style::default().fg(Color::Magenta))
222 }
223 SecurityEventCategory::PreExecutionBlock => ("[pexb] ", block_style),
224 SecurityEventCategory::PreExecutionWarn => ("[pexw] ", flag_style),
225 SecurityEventCategory::ResponseVerification => ("[rver] ", flag_style),
226 SecurityEventCategory::CausalIpiFlag => ("[cipi] ", flag_style),
227 SecurityEventCategory::CrossBoundaryMcpToAcp => {
228 ("[xbnd] ", Style::default().fg(Color::Red))
229 }
230 SecurityEventCategory::VigilFlag => ("[vigi] ", block_style),
231 };
232 let hm = format_hm(ev.timestamp);
233 items.push(ListItem::new(Line::from(vec![
234 Span::styled(format!("{hm} "), Style::default().fg(Color::DarkGray)),
235 Span::styled(cat_str, cat_style),
236 Span::styled(format!(" {}", ev.source), base),
237 ])));
238 items.push(ListItem::new(Line::from(Span::styled(
239 format!(" {}", ev.detail),
240 Style::default().fg(Color::DarkGray),
241 ))));
242 }
243}
244
245fn format_hm(ts: u64) -> String {
246 #[allow(clippy::cast_possible_wrap)]
247 chrono::DateTime::from_timestamp(ts as i64, 0).map_or_else(
248 || "??:??".to_owned(),
249 |dt| dt.with_timezone(&chrono::Local).format("%H:%M").to_string(),
250 )
251}
252
253#[cfg(test)]
254mod tests {
255 use std::collections::VecDeque;
256
257 use zeph_common::SecurityEventCategory;
258 use zeph_core::metrics::SecurityEvent;
259
260 use super::*;
261 use crate::test_utils::render_to_string;
262
263 #[test]
264 fn renders_no_events_message_when_all_zero() {
265 let metrics = MetricsSnapshot::default();
266 let output = render_to_string(40, 10, |frame, area| {
267 render(&metrics, frame, area);
268 });
269 assert!(output.contains("No security events."));
270 }
271
272 #[test]
273 fn renders_injection_flag_count() {
274 let metrics = MetricsSnapshot {
275 sanitizer_injection_flags: 3,
276 ..MetricsSnapshot::default()
277 };
278 let output = render_to_string(40, 12, |frame, area| {
279 render(&metrics, frame, area);
280 });
281 assert!(output.contains('3'));
282 }
283
284 #[test]
285 fn renders_recent_events() {
286 let mut events = VecDeque::new();
287 events.push_back(SecurityEvent::new(
288 SecurityEventCategory::InjectionFlag,
289 "web_scrape",
290 "Detected pattern: ignore previous",
291 ));
292 let metrics = MetricsSnapshot {
293 sanitizer_injection_flags: 1,
294 security_events: events,
295 ..MetricsSnapshot::default()
296 };
297 let output = render_to_string(50, 25, |frame, area| {
298 render(&metrics, frame, area);
299 });
300 assert!(output.contains("web_scrape") || output.contains("inj"));
301 }
302
303 #[test]
304 fn renders_exfiltration_block_category() {
305 let mut events = VecDeque::new();
306 events.push_back(SecurityEvent::new(
307 SecurityEventCategory::ExfiltrationBlock,
308 "llm_output",
309 "1 markdown image(s) blocked",
310 ));
311 let metrics = MetricsSnapshot {
312 exfiltration_images_blocked: 1,
313 security_events: events,
314 ..MetricsSnapshot::default()
315 };
316 let output = render_to_string(50, 25, |frame, area| {
317 render(&metrics, frame, area);
318 });
319 assert!(
320 output.contains("Exfil") || output.contains("exfil") || output.contains("llm_output")
321 );
322 }
323
324 #[test]
325 fn renders_quarantine_category() {
326 let mut events = VecDeque::new();
327 events.push_back(SecurityEvent::new(
328 SecurityEventCategory::Quarantine,
329 "web_scrape",
330 "Content quarantined, facts extracted",
331 ));
332 let metrics = MetricsSnapshot {
333 quarantine_invocations: 1,
334 security_events: events,
335 ..MetricsSnapshot::default()
336 };
337 let output = render_to_string(50, 25, |frame, area| {
338 render(&metrics, frame, area);
339 });
340 assert!(output.contains("quar") || output.contains("web_scrape"));
341 }
342
343 #[test]
344 fn renders_only_last_5_events_when_more_exist() {
345 let mut metrics = MetricsSnapshot {
346 sanitizer_injection_flags: 8,
347 ..MetricsSnapshot::default()
348 };
349 for i in 0..8u64 {
350 metrics.security_events.push_back(SecurityEvent::new(
351 SecurityEventCategory::InjectionFlag,
352 format!("source_{i}"),
353 format!("detail_{i}"),
354 ));
355 }
356 let output = render_to_string(60, 30, |frame, area| {
357 render(&metrics, frame, area);
358 });
359 assert!(output.contains("source_7"), "last event must be rendered");
361 assert!(
362 output.contains("source_3"),
363 "5th-from-last must be rendered"
364 );
365 assert!(
366 !output.contains("source_2"),
367 "6th-from-last must NOT be rendered"
368 );
369 }
370
371 #[test]
372 fn renders_without_panic_on_zero_height() {
373 let metrics = MetricsSnapshot::default();
374 render_to_string(40, 0, |frame, area| {
376 render(&metrics, frame, area);
377 });
378 }
379}