Skip to main content

zeph_tui/widgets/
security.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use 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
60/// Build a `ListItem` with a plain styled label and value, using `base` style for both.
61fn 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
69/// Build a `ListItem` whose value span switches to `alert_style` when the value is non-zero.
70fn 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    // Show last 5 events (most recent last).
211    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        // Last 5: sources 3..7 should appear, first 3 should not.
360        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        // height=0 means inner area is zero — must not panic
375        render_to_string(40, 0, |frame, area| {
376            render(&metrics, frame, area);
377        });
378    }
379}