| 8 |
8 |
|
use crate::db::{DnsCheckRow, IncidentRow, PruneResult, RouteCheckRow, TlsCheckRow, WhoisCheckRow};
|
| 9 |
9 |
|
use crate::types::{DnsCheckResult, HealthSnapshot, LatencyStats, TestRun, TestStaleness, WhoisResult};
|
| 10 |
10 |
|
|
|
11 |
+ |
/// Strip terminal control characters from an untrusted remote string before it
|
|
12 |
+ |
/// is written to the operator's terminal.
|
|
13 |
+ |
///
|
|
14 |
+ |
/// A monitored host's health `version`/`uptime`/`error`, a whois/DNS error, a
|
|
15 |
+ |
/// mesh peer's fields, or *any* TCP service on an ssh_banner target can embed raw
|
|
16 |
+ |
/// ANSI escape sequences (ESC/CSI) that clear or rewrite the screen — e.g. a fake
|
|
17 |
+ |
/// all-clear — the moment they're printed on `pom status`/`mesh` (fuzz-2026-07-06
|
|
18 |
+ |
/// ANSI/terminal injection). Every such value passes through here at the display
|
|
19 |
+ |
/// sink. Keeps printable UTF-8; drops C0/C1 controls (incl. ESC, CR, LF) and DEL.
|
|
20 |
+ |
fn scrub(s: &str) -> String {
|
|
21 |
+ |
s.chars().filter(|c| !c.is_control()).collect()
|
|
22 |
+ |
}
|
|
23 |
+ |
|
| 11 |
24 |
|
/// Format a single health snapshot as a human-readable line.
|
| 12 |
25 |
|
pub fn format_health_snapshot(s: &HealthSnapshot) -> String {
|
| 13 |
26 |
|
let mut out = String::new();
|
| 15 |
28 |
|
write!(out, " ({}ms)", s.response_time_ms).unwrap();
|
| 16 |
29 |
|
if let Some(details) = &s.details {
|
| 17 |
30 |
|
if let Some(v) = &details.version {
|
| 18 |
|
- |
write!(out, " v{v}").unwrap();
|
|
31 |
+ |
write!(out, " v{}", scrub(v)).unwrap();
|
| 19 |
32 |
|
}
|
| 20 |
33 |
|
if let Some(u) = &details.uptime {
|
| 21 |
|
- |
write!(out, " up {u}").unwrap();
|
|
34 |
+ |
write!(out, " up {}", scrub(u)).unwrap();
|
| 22 |
35 |
|
}
|
| 23 |
36 |
|
}
|
| 24 |
37 |
|
writeln!(out).unwrap();
|
| 25 |
38 |
|
if let Some(err) = &s.error {
|
| 26 |
|
- |
writeln!(out, " {err}").unwrap();
|
|
39 |
+ |
writeln!(out, " {}", scrub(err)).unwrap();
|
| 27 |
40 |
|
}
|
| 28 |
41 |
|
out
|
| 29 |
42 |
|
}
|
| 82 |
95 |
|
if let Some(d) = &h.details
|
| 83 |
96 |
|
&& let Some(v) = &d.version
|
| 84 |
97 |
|
{
|
| 85 |
|
- |
write!(out, " v{v}").unwrap();
|
|
98 |
+ |
write!(out, " v{}", scrub(v)).unwrap();
|
| 86 |
99 |
|
}
|
| 87 |
100 |
|
writeln!(out).unwrap();
|
| 88 |
101 |
|
} else {
|
| 100 |
113 |
|
|
| 101 |
114 |
|
if let Some(t) = tls {
|
| 102 |
115 |
|
if let Some(ref err) = t.error {
|
| 103 |
|
- |
writeln!(out, " TLS: [ERR] {} \u{2014} {err}", t.host).unwrap();
|
|
116 |
+ |
writeln!(out, " TLS: [ERR] {} \u{2014} {}", t.host, scrub(err)).unwrap();
|
| 104 |
117 |
|
} else if t.days_remaining <= 0 {
|
| 105 |
118 |
|
writeln!(out, " TLS: [ERR] {} \u{2014} EXPIRED (expired {})", t.host, t.not_after).unwrap();
|
| 106 |
119 |
|
} else if t.days_remaining <= 14 {
|
| 138 |
151 |
|
|
| 139 |
152 |
|
if let Some(w) = whois {
|
| 140 |
153 |
|
if let Some(ref err) = w.error {
|
| 141 |
|
- |
writeln!(out, " WHOIS: [ERR] {} \u{2014} {err}", w.domain).unwrap();
|
|
154 |
+ |
writeln!(out, " WHOIS: [ERR] {} \u{2014} {}", w.domain, scrub(err)).unwrap();
|
| 142 |
155 |
|
} else if let Some(days) = w.days_remaining {
|
| 143 |
156 |
|
if days <= 0 {
|
| 144 |
157 |
|
writeln!(out, " WHOIS: [ERR] {} \u{2014} EXPIRED", w.domain).unwrap();
|
| 168 |
181 |
|
&& s.stale
|
| 169 |
182 |
|
&& let Some(reason) = &s.reason
|
| 170 |
183 |
|
{
|
| 171 |
|
- |
writeln!(out, " Tests: STALE \u{2014} {reason}").unwrap();
|
|
184 |
+ |
writeln!(out, " Tests: STALE \u{2014} {}", scrub(reason)).unwrap();
|
| 172 |
185 |
|
}
|
| 173 |
186 |
|
|
| 174 |
187 |
|
if let Some(inc) = incident {
|
| 253 |
266 |
|
writeln!(out, "DNS Records:").unwrap();
|
| 254 |
267 |
|
for r in dns_results {
|
| 255 |
268 |
|
if let Some(ref err) = r.error {
|
| 256 |
|
- |
writeln!(out, " [ERR] {} {} \u{2014} {err}", r.name, r.record_type).unwrap();
|
|
269 |
+ |
writeln!(out, " [ERR] {} {} \u{2014} {}", r.name, r.record_type, scrub(err)).unwrap();
|
| 257 |
270 |
|
} else if r.matches {
|
| 258 |
271 |
|
writeln!(out, " [OK] {} {} \u{2014} {:?}", r.name, r.record_type, r.actual).unwrap();
|
| 259 |
272 |
|
} else {
|
| 269 |
282 |
|
writeln!(out, "WHOIS:").unwrap();
|
| 270 |
283 |
|
for w in whois_results {
|
| 271 |
284 |
|
if let Some(ref err) = w.error {
|
| 272 |
|
- |
writeln!(out, " [ERR] {} \u{2014} {err}", w.domain).unwrap();
|
|
285 |
+ |
writeln!(out, " [ERR] {} \u{2014} {}", w.domain, scrub(err)).unwrap();
|
| 273 |
286 |
|
} else {
|
| 274 |
287 |
|
let days_str = w.days_remaining
|
| 275 |
288 |
|
.map(|d| format!("{d}d remaining"))
|
| 310 |
323 |
|
.and_then(|v| v.as_str())
|
| 311 |
324 |
|
.unwrap_or("?");
|
| 312 |
325 |
|
|
| 313 |
|
- |
writeln!(out, "=== {name} ===").unwrap();
|
| 314 |
|
- |
writeln!(out, " ID: {id}").unwrap();
|
| 315 |
|
- |
writeln!(out, " Version: {version}").unwrap();
|
|
326 |
+ |
writeln!(out, "=== {} ===", scrub(name)).unwrap();
|
|
327 |
+ |
writeln!(out, " ID: {}", scrub(id)).unwrap();
|
|
328 |
+ |
writeln!(out, " Version: {}", scrub(version)).unwrap();
|
| 316 |
329 |
|
|
| 317 |
330 |
|
// Targets
|
| 318 |
331 |
|
if let Some(targets) = instance_data.get("targets").and_then(|v| v.as_object()) {
|
| 325 |
338 |
|
.get("response_time_ms")
|
| 326 |
339 |
|
.and_then(|v| v.as_i64());
|
| 327 |
340 |
|
let ms_str = ms.map(|m| format!(" ({m}ms)")).unwrap_or_default();
|
| 328 |
|
- |
writeln!(out, " Target {target_name}: {status}{ms_str}").unwrap();
|
|
341 |
+ |
writeln!(out, " Target {}: {}{ms_str}", scrub(target_name), scrub(status)).unwrap();
|
| 329 |
342 |
|
}
|
| 330 |
343 |
|
}
|
| 331 |
344 |
|
|
| 341 |
354 |
|
.and_then(|v| v.as_u64())
|
| 342 |
355 |
|
.map(|ms| format!(" ({ms}ms)"))
|
| 343 |
356 |
|
.unwrap_or_default();
|
| 344 |
|
- |
writeln!(out, " Peer {peer_name}: {status}{latency}").unwrap();
|
|
357 |
+ |
writeln!(out, " Peer {}: {}{latency}", scrub(peer_name), scrub(status)).unwrap();
|
| 345 |
358 |
|
}
|
| 346 |
359 |
|
}
|
| 347 |
360 |
|
|
| 348 |
361 |
|
// Error fallback
|
| 349 |
362 |
|
if let Some(err) = instance_data.get("error").and_then(|v| v.as_str()) {
|
| 350 |
|
- |
writeln!(out, " ({err})").unwrap();
|
|
363 |
+ |
writeln!(out, " ({})", scrub(err)).unwrap();
|
| 351 |
364 |
|
}
|
| 352 |
365 |
|
|
| 353 |
366 |
|
writeln!(out).unwrap();
|
| 360 |
373 |
|
use super::*;
|
| 361 |
374 |
|
use crate::types::*;
|
| 362 |
375 |
|
|
|
376 |
+ |
#[test]
|
|
377 |
+ |
fn scrub_strips_ansi_and_control_chars() {
|
|
378 |
+ |
// A monitored host trying to clear/rewrite the operator's terminal.
|
|
379 |
+ |
let hostile = "1.0\u{1b}[2J\u{1b}[1;1H FAKE ALL-CLEAR\r\n";
|
|
380 |
+ |
let cleaned = scrub(hostile);
|
|
381 |
+ |
assert!(!cleaned.contains('\u{1b}'), "ESC must be stripped");
|
|
382 |
+ |
assert!(!cleaned.contains('\r') && !cleaned.contains('\n'));
|
|
383 |
+ |
assert_eq!(cleaned, "1.0[2J[1;1H FAKE ALL-CLEAR");
|
|
384 |
+ |
// Printable Unicode (e.g. the em-dash the display uses) is preserved.
|
|
385 |
+ |
assert_eq!(scrub("v1.2 \u{2014} ok"), "v1.2 \u{2014} ok");
|
|
386 |
+ |
}
|
|
387 |
+ |
|
|
388 |
+ |
#[test]
|
|
389 |
+ |
fn health_snapshot_scrubs_hostile_error() {
|
|
390 |
+ |
let s = HealthSnapshot {
|
|
391 |
+ |
id: None,
|
|
392 |
+ |
target: "mnw".to_string(),
|
|
393 |
+ |
status: HealthStatus::Error,
|
|
394 |
+ |
checked_at: "2026-07-07T00:00:00+00:00".to_string(),
|
|
395 |
+ |
response_time_ms: 5,
|
|
396 |
+ |
details: None,
|
|
397 |
+ |
error: Some("boom\u{1b}[2Jcleared".to_string()),
|
|
398 |
+ |
};
|
|
399 |
+ |
let out = format_health_snapshot(&s);
|
|
400 |
+ |
assert!(!out.contains('\u{1b}'), "ESC from a remote error must not reach the terminal");
|
|
401 |
+ |
}
|
|
402 |
+ |
|
| 363 |
403 |
|
// --- format_health_snapshot ---
|
| 364 |
404 |
|
|
| 365 |
405 |
|
#[test]
|