Skip to main content

sidecheck_core/
report.rs

1//! Перевод статистики в понятный человеку вердикт.
2//! Никаких сырых t-значений наружу — только то, что нужно для решения.
3
4use crate::stats::BoxTestResult;
5
6pub struct DetectionReport {
7    pub target: String,
8    pub field: String,
9    pub samples_per_class: usize,
10    pub result: BoxTestResult,
11    pub jitter_seconds: f64,
12    pub failures: usize,
13    pub seed: u64,
14    pub sidecheck_version: String,
15}
16
17/// Автоматически выбирает единицу измерения (ns/μs/ms/s), чтобы не выводить
18/// "16264.9 μs" там, где человеку читается проще "16.3 ms".
19fn format_duration(seconds: f64) -> String {
20    let abs = seconds.abs();
21    if abs >= 1.0 {
22        format!("{seconds:.3} s")
23    } else if abs >= 0.001 {
24        format!("{:.2} ms", seconds * 1_000.0)
25    } else if abs >= 0.000_001 {
26        format!("{:.1} μs", seconds * 1_000_000.0)
27    } else {
28        format!("{:.0} ns", seconds * 1_000_000_000.0)
29    }
30}
31
32impl DetectionReport {
33    pub fn render(&self) -> String {
34        let jitter = format_duration(self.jitter_seconds);
35        let significant = self.result.is_significant();
36
37        let mut out = String::new();
38        out.push_str(&"─".repeat(48));
39        out.push_str("\nsidecheck timing report\n");
40        out.push_str(&"─".repeat(48));
41        out.push_str(&format!("\n\ntarget          {}\n", self.target));
42        out.push_str(&format!("field            {}\n", self.field));
43        out.push_str(&format!("samples/class    {}\n", self.samples_per_class));
44        out.push_str(&format!("network jitter   {jitter}\n"));
45        if self.failures > 0 {
46            out.push_str(&format!(
47                "failed requests  {} (excluded from analysis)\n",
48                self.failures
49            ));
50        }
51        out.push('\n');
52
53        if significant {
54            out.push_str("⚠ timing leak detected\n");
55            out.push_str(&format!(
56                "  estimated leak         {}\n",
57                format_duration(self.result.estimated_leak.abs())
58            ));
59            // "confidence" сама по себе неоднозначна — может читаться как
60            // "вероятность, что сервер уязвим". Уточняем явно, что это
61            // bootstrap-доверительный интервал разницы, а не вероятность
62            // уязвимости и не p-value.
63            out.push_str(&format!(
64                "  bootstrap confidence   {:.1}% (of the measured difference being non-zero)\n",
65                self.result.confidence * 100.0
66            ));
67            out.push_str("\n  this endpoint responds measurably differently depending on\n");
68            out.push_str("  input correctness. an attacker can exploit this to recover\n");
69            out.push_str("  secrets character-by-character instead of brute-forcing them.\n\n");
70            out.push_str("  fix: use a constant-time comparison instead of == on secret\n");
71            out.push_str("  bytes (e.g. the `subtle` crate in Rust, `crypto/subtle` in Go,\n");
72            out.push_str("  `hmac.compare_digest` in Python).\n");
73        } else {
74            out.push_str("✓ no statistically significant timing difference detected\n");
75            out.push_str(&format!(
76                "  (bootstrap {:.0}% CI of the difference: [{}, {}], includes zero)\n",
77                self.result.confidence * 100.0,
78                format_duration(self.result.ci_low),
79                format_duration(self.result.ci_high)
80            ));
81        }
82
83        out.push_str(&"─".repeat(48));
84        out.push_str("\nsidecheck cannot prove the absence of a timing leak — only detect a\n");
85        out.push_str("statistically significant one under the tested conditions. A clean\n");
86        out.push_str("result here is not a safety guarantee.\n");
87        out.push_str(&format!(
88            "generated by sidecheck {} · seed {} (rerun with --seed {} to reproduce request order)\n",
89            self.sidecheck_version, self.seed, self.seed
90        ));
91
92        out
93    }
94}