Skip to main content

sidecheck_core/
report.rs

1//! Turns the statistics into a human-readable verdict.
2//! No raw t-values exposed — only what's needed to make a decision.
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/// Automatically picks the display unit (ns/μs/ms/s) so we don't print
18/// "16264.9 μs" where "16.3 ms" reads easier for a human.
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" on its own is ambiguous — it can read as "the
60            // probability that the server is vulnerable". Spell out
61            // explicitly that this is the bootstrap confidence interval of
62            // the difference, not a probability of vulnerability and not a
63            // p-value.
64            out.push_str(&format!(
65                "  bootstrap confidence   {:.1}% (of the measured difference being non-zero)\n",
66                self.result.confidence * 100.0
67            ));
68            out.push_str("\n  this endpoint responds measurably differently depending on\n");
69            out.push_str("  input correctness. an attacker can exploit this to recover\n");
70            out.push_str("  secrets character-by-character instead of brute-forcing them.\n\n");
71            out.push_str("  fix: use a constant-time comparison instead of == on secret\n");
72            out.push_str("  bytes (e.g. the `subtle` crate in Rust, `crypto/subtle` in Go,\n");
73            out.push_str("  `hmac.compare_digest` in Python).\n");
74        } else {
75            out.push_str("✓ no statistically significant timing difference detected\n");
76            out.push_str(&format!(
77                "  (bootstrap {:.0}% CI of the difference: [{}, {}], includes zero)\n",
78                self.result.confidence * 100.0,
79                format_duration(self.result.ci_low),
80                format_duration(self.result.ci_high)
81            ));
82        }
83
84        out.push_str(&"─".repeat(48));
85        out.push_str("\nsidecheck cannot prove the absence of a timing leak — only detect a\n");
86        out.push_str("statistically significant one under the tested conditions. A clean\n");
87        out.push_str("result here is not a safety guarantee.\n");
88        out.push_str(&format!(
89            "generated by sidecheck {} · seed {} (rerun with --seed {} to reproduce request order)\n",
90            self.sidecheck_version, self.seed, self.seed
91        ));
92
93        out
94    }
95}