Skip to main content

ebman/
report_bug.rs

1//! Bug-report payload builder.
2//!
3//! `:report-bug` shows the operator a scrubbed report containing
4//! version / OS / recent-log-lines / recent activity, and offers two
5//! ways to actually file it:
6//!
7//!   - `y` — copy to clipboard (paste into a GitHub issue manually)
8//!   - `b` — open a new GitHub issue in the browser, body pre-filled
9//!     via URL query params
10//!   - `esc` — cancel
11//!
12//! Ebman itself never sends the payload anywhere. The friction
13//! (operator pastes into an issue) is the feature: it keeps the
14//! tool defensible to operators running against regulated workloads.
15//!
16//! The scrubber runs over the assembled payload to redact the
17//! obvious leaks: account IDs, ARNs, env names from the live env
18//! list, profile / role names from the active context, and CNAMEs
19//! / FQDNs. It's not bulletproof — a freeform error message could
20//! still embed a customer name — which is why the operator sees
21//! the exact payload before it leaves their machine.
22
23use std::collections::BTreeSet;
24
25/// Sensitive tokens supplied by the caller — pulled from the live App
26/// state at report-build time. `account_id` is the operator's own
27/// account, but it's still PII for "this is the org running ebman" so
28/// gets the same treatment as the others.
29#[derive(Debug, Clone, Default)]
30pub struct ScrubContext {
31    /// Captured but currently unused — the 12-digit-number pass
32    /// already redacts the account ID from any payload text. Kept
33    /// on the struct because future scrubbing rules (e.g. exact
34    /// match against a `friendly_account_name`) will want it.
35    #[allow(dead_code)]
36    pub account_id: Option<String>,
37    pub profile: Option<String>,
38    pub region: Option<String>,
39    /// Every env name currently in the in-memory table. Replaced
40    /// with `[env]` so a stack trace that happened to format an env
41    /// name doesn't leak it.
42    pub env_names: BTreeSet<String>,
43    /// Every application name currently in the in-memory table.
44    pub app_names: BTreeSet<String>,
45    /// CNAMEs / FQDNs from the env list — these are operator-domain
46    /// and leak company names ("api.foo-corp.com").
47    pub cnames: BTreeSet<String>,
48}
49
50/// Markdown-ish report payload, ready to be displayed in the
51/// overlay or pasted into a GitHub issue. Caller passes pre-collected
52/// pieces of context so this module stays free of `App` /
53/// `tokio` dependencies — pure string assembly, testable in
54/// isolation.
55pub struct ReportInput<'a> {
56    pub ebman_version: &'a str,
57    pub os: &'a str,
58    pub os_release: &'a str,
59    pub icons: &'a str,
60    pub theme: &'a str,
61    pub refresh_interval_secs: u64,
62    /// Last ~30 lines of `~/.cache/ebman/ebman.log`. Caller reads
63    /// the file; this module assembles + scrubs.
64    pub recent_log_lines: Vec<String>,
65    /// Last ~10 operator-visible status / error messages. Picked
66    /// from `App.message_log` so we mirror what the operator just
67    /// saw on screen, not whatever internal tracing fired.
68    pub recent_messages: Vec<String>,
69    /// Most recent crash backtrace, if any. Pulled from
70    /// `~/.cache/ebman/crash-*.log` by `latest_crash_log()`.
71    pub recent_crash: Option<String>,
72    /// `(tier, env_count, app_count, multi_regions)` summary —
73    /// abstract numbers, not identifiers.
74    pub env_count: usize,
75    pub app_count: usize,
76    pub multi_regions_count: usize,
77    pub multi_account_enabled: bool,
78}
79
80/// Build the full report. Returns the assembled + scrubbed payload.
81/// Two passes:
82///   1. Format the structured sections into a markdown body.
83///   2. Run `scrub` over the result so any payload-side
84///      identifiers (e.g. ARNs in log lines) get redacted.
85///
86/// Caller decides what to do with the payload — render in an
87/// overlay, copy to clipboard, or hand to a browser URL.
88pub fn build_report(input: &ReportInput<'_>, ctx: &ScrubContext) -> String {
89    let mut body = String::new();
90    body.push_str("## ebman bug report\n\n");
91    body.push_str("(Account IDs / ARNs / env names / profiles / CNAMEs are scrubbed.\n");
92    body.push_str("Review before pasting; some freeform errors may still embed identifiers.)\n\n");
93
94    body.push_str("### Environment\n");
95    body.push_str(&format!("ebman: {}\n", input.ebman_version));
96    body.push_str(&format!("os: {} / {}\n", input.os, input.os_release));
97    body.push_str(&format!("icons: {}\n", input.icons));
98    body.push_str(&format!("theme: {}\n", input.theme));
99    body.push_str(&format!(
100        "refresh_interval: {}s\n",
101        input.refresh_interval_secs
102    ));
103    body.push_str(&format!(
104        "scope: envs={}, apps={}, multi_regions={}, multi_account={}\n",
105        input.env_count, input.app_count, input.multi_regions_count, input.multi_account_enabled
106    ));
107    body.push('\n');
108
109    if !input.recent_messages.is_empty() {
110        body.push_str("### Recent on-screen messages\n");
111        body.push_str("```\n");
112        for msg in &input.recent_messages {
113            body.push_str(msg);
114            body.push('\n');
115        }
116        body.push_str("```\n\n");
117    }
118
119    if !input.recent_log_lines.is_empty() {
120        body.push_str("### Last 30 lines of ebman.log\n");
121        body.push_str("```\n");
122        for line in &input.recent_log_lines {
123            body.push_str(line);
124            body.push('\n');
125        }
126        body.push_str("```\n\n");
127    }
128
129    if let Some(crash) = &input.recent_crash {
130        body.push_str("### Most recent panic backtrace\n");
131        body.push_str("```\n");
132        body.push_str(crash);
133        if !crash.ends_with('\n') {
134            body.push('\n');
135        }
136        body.push_str("```\n\n");
137    }
138
139    body.push_str("### What were you doing?\n");
140    body.push_str("<!-- Describe the action that triggered the bug — what command,\n");
141    body.push_str("     what env, what was the expected result. -->\n\n");
142
143    scrub(&body, ctx)
144}
145
146/// Apply identifier scrubbing to `text`. Order matters: longer /
147/// more-specific patterns first so the shorter ones don't eat
148/// substrings of them. Pure; tested below.
149pub fn scrub(text: &str, ctx: &ScrubContext) -> String {
150    let mut out = text.to_string();
151
152    // 1. ARNs — `arn:aws:<service>:<region>:<account>:<resource>`.
153    // Catch-all regex would be cleaner but adding a regex pass for
154    // one shape is overkill; iterate char-by-char.
155    out = scrub_pattern(&out, "arn:aws:", "[arn]");
156    out = scrub_pattern(&out, "arn:aws-us-gov:", "[arn]");
157    out = scrub_pattern(&out, "arn:aws-cn:", "[arn]");
158
159    // 2. Specific env names from the live list. Reverse-length-sort
160    // so `prod-api-canary` doesn't get half-replaced by a shorter
161    // `prod-api` match.
162    let mut env_names: Vec<&String> = ctx.env_names.iter().collect();
163    env_names.sort_by_key(|n| std::cmp::Reverse(n.len()));
164    for name in &env_names {
165        if !name.is_empty() {
166            out = out.replace(name.as_str(), "[env]");
167        }
168    }
169
170    // 3. Application names — same treatment.
171    let mut app_names: Vec<&String> = ctx.app_names.iter().collect();
172    app_names.sort_by_key(|n| std::cmp::Reverse(n.len()));
173    for name in &app_names {
174        if !name.is_empty() {
175            out = out.replace(name.as_str(), "[app]");
176        }
177    }
178
179    // 4. CNAMEs — typically `*.elb.amazonaws.com` patterns, but
180    // EB also lets operators set arbitrary CNAMEs.
181    let mut cnames: Vec<&String> = ctx.cnames.iter().collect();
182    cnames.sort_by_key(|n| std::cmp::Reverse(n.len()));
183    for cname in &cnames {
184        if !cname.is_empty() {
185            out = out.replace(cname.as_str(), "[cname]");
186        }
187    }
188
189    // 5. Account ID — 12 consecutive ASCII digits. Iterate byte-by-byte
190    // so we don't pull in regex-machinery for one numeric pattern.
191    out = scrub_12_digit_numbers(&out);
192
193    // 6. The operator's own account / profile from context. The
194    // 12-digit pass already caught the account, but the profile
195    // name needs a literal replace.
196    if let Some(p) = ctx.profile.as_ref() {
197        if !p.is_empty() && p != "default" {
198            // Skip "default" — replacing the literal word would
199            // mangle every default-* token in unrelated output.
200            out = out.replace(p, "[profile]");
201        }
202    }
203
204    // 7. Region — informational only; replacing identifies country
205    // code more crudely than the operator probably wants. Leave it.
206    // (Reasoning: a bug report that says "us-east-1" doesn't tell
207    // an attacker much; replacing it costs more useful context
208    // for debugging than it buys in privacy.)
209    let _ = &ctx.region;
210
211    out
212}
213
214/// Replace any 12-digit ASCII number with `[account]`. Linear pass;
215/// matches `\d{12}` style without pulling in the `regex` crate's
216/// machinery for this single pattern.
217fn scrub_12_digit_numbers(text: &str) -> String {
218    let bytes = text.as_bytes();
219    let mut out = String::with_capacity(text.len());
220    let mut i = 0;
221    while i < bytes.len() {
222        let c = bytes[i];
223        if c.is_ascii_digit() {
224            // Lookahead: 12 consecutive digits?
225            let mut j = i;
226            while j < bytes.len() && bytes[j].is_ascii_digit() {
227                j += 1;
228            }
229            let run = j - i;
230            if run == 12 {
231                // Replace. Only when the run is exactly 12 — longer
232                // numeric strings (timestamps, sizes) are different
233                // shape; shorter ones aren't account IDs.
234                out.push_str("[account]");
235                i = j;
236                continue;
237            } else {
238                // Copy the digit run verbatim.
239                out.push_str(&text[i..j]);
240                i = j;
241                continue;
242            }
243        }
244        // SAFETY: text is &str, byte boundary aligned at i because
245        // we only advance over ASCII digits above.
246        out.push(c as char);
247        i += 1;
248    }
249    out
250}
251
252/// Replace anything matching `prefix<continuation-until-whitespace>`
253/// with `replacement`. The continuation gobbles non-whitespace,
254/// non-quote, non-comma characters — sufficient for ARN-style tokens
255/// that end at the next space / quote / comma in a log line.
256fn scrub_pattern(text: &str, prefix: &str, replacement: &str) -> String {
257    let mut out = String::with_capacity(text.len());
258    let mut rest = text;
259    while let Some(pos) = rest.find(prefix) {
260        out.push_str(&rest[..pos]);
261        // Walk until a terminator.
262        let after = &rest[pos..];
263        let mut end = 0;
264        for (i, c) in after.char_indices() {
265            if matches!(c, ' ' | '\t' | '\n' | '\r' | '"' | '\'' | ',' | ')' | ']') {
266                end = i;
267                break;
268            }
269            end = i + c.len_utf8();
270        }
271        out.push_str(replacement);
272        rest = &after[end..];
273    }
274    out.push_str(rest);
275    out
276}
277
278/// URL-encode a body for GitHub's `issues/new?body=` URL. Lighter
279/// than pulling in `urlencoding` for one call site; only encodes
280/// the characters GitHub's URL parser is sensitive to.
281pub fn url_encode(s: &str) -> String {
282    let mut out = String::with_capacity(s.len() * 3);
283    for byte in s.bytes() {
284        match byte {
285            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
286                out.push(byte as char);
287            }
288            _ => {
289                out.push_str(&format!("%{byte:02X}"));
290            }
291        }
292    }
293    out
294}
295
296/// Build the GitHub `issues/new` URL with the report pre-filled.
297/// GitHub caps URL length at ~8192 chars; truncate the body if it
298/// would push us past 7900 so the title + URL params still fit.
299pub fn github_issue_url(repo: &str, title: &str, body: &str) -> String {
300    let max_body = 7900_usize.saturating_sub(title.len());
301    let truncated = if body.len() > max_body {
302        let truncated_at = body
303            .char_indices()
304            .take_while(|(i, _)| *i < max_body.saturating_sub(64))
305            .last()
306            .map(|(i, c)| i + c.len_utf8())
307            .unwrap_or(0);
308        let mut s = body[..truncated_at].to_string();
309        s.push_str("\n\n…[body truncated for URL length; paste the full payload from the overlay]");
310        s
311    } else {
312        body.to_string()
313    };
314    format!(
315        "https://github.com/{repo}/issues/new?title={t}&body={b}",
316        t = url_encode(title),
317        b = url_encode(&truncated)
318    )
319}
320
321/// Read the most recent crash log written by the panic hook. Returns
322/// `None` when no crash logs exist. Helper so the report builder can
323/// stay synchronous + pure.
324pub fn latest_crash_log() -> Option<String> {
325    let dir = crate::util::cache_dir();
326    let mut crashes: Vec<std::fs::DirEntry> = std::fs::read_dir(&dir)
327        .ok()?
328        .filter_map(|e| e.ok())
329        .filter(|e| e.file_name().to_string_lossy().starts_with("crash-"))
330        .collect();
331    crashes.sort_by_key(|e| e.metadata().ok().and_then(|m| m.modified().ok()));
332    let latest = crashes.last()?;
333    std::fs::read_to_string(latest.path()).ok()
334}
335
336/// Read the tail of `~/.cache/ebman/ebman.log` — up to `n` lines.
337/// Errors silently to an empty Vec; the report still ships, just
338/// without log context.
339pub fn tail_ebman_log(n: usize) -> Vec<String> {
340    let mut path = crate::util::cache_dir();
341    path.push("ebman.log");
342    let Ok(text) = std::fs::read_to_string(&path) else {
343        return Vec::new();
344    };
345    let lines: Vec<&str> = text.lines().collect();
346    let start = lines.len().saturating_sub(n);
347    lines[start..].iter().map(|s| s.to_string()).collect()
348}
349
350#[cfg(test)]
351mod tests {
352    use super::*;
353
354    fn ctx_with(env_names: &[&str], app_names: &[&str], profile: Option<&str>) -> ScrubContext {
355        ScrubContext {
356            account_id: None,
357            profile: profile.map(String::from),
358            region: Some("eu-west-2".into()),
359            env_names: env_names.iter().map(|s| (*s).to_string()).collect(),
360            app_names: app_names.iter().map(|s| (*s).to_string()).collect(),
361            cnames: BTreeSet::new(),
362        }
363    }
364
365    #[test]
366    fn scrub_redacts_12_digit_account_ids() {
367        let ctx = ctx_with(&[], &[], None);
368        let scrubbed = scrub("account 123456789012 hit a limit", &ctx);
369        assert_eq!(scrubbed, "account [account] hit a limit");
370    }
371
372    #[test]
373    fn scrub_leaves_short_numbers_alone() {
374        let ctx = ctx_with(&[], &[], None);
375        let scrubbed = scrub("port 8080 / 11 digits 12345678901", &ctx);
376        assert!(scrubbed.contains("8080"));
377        assert!(scrubbed.contains("12345678901"));
378        assert!(!scrubbed.contains("[account]"));
379    }
380
381    #[test]
382    fn scrub_redacts_arns() {
383        let ctx = ctx_with(&[], &[], None);
384        let scrubbed = scrub(
385            "Role arn:aws:iam::123456789012:role/EbmanReadOnly does not have perms",
386            &ctx,
387        );
388        assert!(scrubbed.contains("[arn]"));
389        assert!(!scrubbed.contains("arn:aws"));
390        assert!(!scrubbed.contains("EbmanReadOnly"));
391    }
392
393    #[test]
394    fn scrub_redacts_env_names_longest_first() {
395        let ctx = ctx_with(&["prod-api", "prod-api-canary"], &[], None);
396        let scrubbed = scrub("prod-api-canary went red, prod-api is yellow", &ctx);
397        // `prod-api-canary` matches first because it's longer —
398        // ensures the canary substring doesn't get half-replaced.
399        assert_eq!(scrubbed, "[env] went red, [env] is yellow");
400    }
401
402    #[test]
403    fn scrub_redacts_profile_name_but_skips_default() {
404        let ctx = ctx_with(&[], &[], Some("prod-aws"));
405        let scrubbed = scrub("profile=prod-aws region=eu-west-2", &ctx);
406        assert!(scrubbed.contains("[profile]"));
407        assert!(!scrubbed.contains("prod-aws"));
408
409        let ctx_default = ctx_with(&[], &[], Some("default"));
410        let scrubbed = scrub("profile=default region=eu-west-2", &ctx_default);
411        // 'default' is too generic to redact — leaving it alone.
412        assert!(scrubbed.contains("default"));
413    }
414
415    #[test]
416    fn url_encode_handles_special_chars() {
417        assert_eq!(url_encode("hello world"), "hello%20world");
418        assert_eq!(url_encode("a&b=c"), "a%26b%3Dc");
419        assert_eq!(url_encode("ARN: arn:aws"), "ARN%3A%20arn%3Aaws");
420    }
421
422    #[test]
423    fn github_issue_url_pre_fills_title_and_body() {
424        let url = github_issue_url("tombaldwin/ebman", "Crash on :why", "stack trace here");
425        assert!(url.starts_with("https://github.com/tombaldwin/ebman/issues/new?"));
426        assert!(url.contains("title=Crash"));
427        assert!(url.contains("body=stack"));
428    }
429
430    #[test]
431    fn github_issue_url_truncates_long_body() {
432        let long_body = "x".repeat(20_000);
433        let url = github_issue_url("tombaldwin/ebman", "Bug", &long_body);
434        assert!(url.len() < 8500, "URL must stay under GitHub's ~8k limit");
435        // Decode it back conceptually: truncated marker should be there.
436        assert!(url.contains("body%20truncated"));
437    }
438}