fix(security): redact sensitive session IDs in marathonctl output

Addresses CodeQL cleartext-logging alerts (#1, #2, #3) by implementing
session ID redaction for CLI output.

Changes:
- Extract marathonctl into standalone crate (crates/marathonctl)
- Add session ID redaction showing only first 8 characters by default
- Add --show-sensitive/-s flag for full session IDs when debugging
- Implement beautiful ratatui-based UI module with inline viewport
- Add .envrc to .gitignore for secure token management
- Document GitHub token setup in CONTRIBUTING.md

The CLI now provides a secure-by-default experience while maintaining
debugging capabilities through explicit opt-in flags. Session IDs are
redacted to format "abc-def-..." unless --show-sensitive is specified.

UI module provides easy-to-use builder APIs (ui::table, ui::grid, ui::list)
that render beautiful terminal output without hijacking the terminal.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-07 13:05:16 +00:00
parent 7292aa54e6
commit 25550e2165
8 changed files with 589 additions and 34 deletions

View File

@@ -38,6 +38,8 @@ futures-lite.workspace = true
bytes.workspace = true
crossbeam-channel.workspace = true
clap.workspace = true
ratatui = "0.29"
crossterm = "0.28"
[target.'cfg(target_os = "ios")'.dependencies]
objc = "0.2"

View File

@@ -0,0 +1,15 @@
[package]
name = "marathonctl"
version = "0.1.0"
edition.workspace = true
[[bin]]
name = "marathonctl"
path = "src/main.rs"
[dependencies]
libmarathon = { path = "../libmarathon" }
clap.workspace = true
uuid.workspace = true
ratatui = "0.29"
crossterm = "0.28"

View File

@@ -15,6 +15,8 @@
//! marathonctl --socket /tmp/marathon1.sock status
//! ```
mod ui;
use clap::{Parser, Subcommand};
use std::io::{Read, Write};
use std::os::unix::net::UnixStream;
@@ -29,6 +31,10 @@ struct Args {
#[arg(long, default_value = "/tmp/marathon-control.sock")]
socket: String,
/// Show sensitive information (session IDs, etc.) in full
#[arg(short, long)]
show_sensitive: bool,
#[command(subcommand)]
command: Commands,
}
@@ -72,6 +78,22 @@ enum Commands {
},
}
/// Redacts a session ID for safe logging
/// Shows only the first 8 characters to prevent exposure of sensitive information
/// unless show_sensitive is true
fn redact_session_id(session_id: impl std::fmt::Display, show_sensitive: bool) -> String {
if show_sensitive {
session_id.to_string()
} else {
let session_str = session_id.to_string();
if session_str.len() > 8 {
format!("{}...", &session_str[..8])
} else {
"<redacted>".to_string()
}
}
}
fn main() {
let args = Args::parse();
@@ -132,7 +154,7 @@ fn main() {
// Receive response
match receive_response(&mut stream) {
Ok(response) => {
print_response(response);
print_response(response, args.show_sensitive);
}
Err(e) => {
eprintln!("Failed to receive response: {}", e);
@@ -169,7 +191,7 @@ fn receive_response(stream: &mut UnixStream) -> Result<ControlResponse, Box<dyn
Ok(response)
}
fn print_response(response: ControlResponse) {
fn print_response(response: ControlResponse, show_sensitive: bool) {
match response {
ControlResponse::Status {
node_id,
@@ -178,42 +200,70 @@ fn print_response(response: ControlResponse) {
incoming_queue_size,
connected_peers,
} => {
println!("Session Status:");
println!(" Node ID: {}", node_id);
println!(" Session: {}", session_id);
println!(" Outgoing Queue: {} messages", outgoing_queue_size);
println!(" Incoming Queue: {} messages", incoming_queue_size);
let mut builder = ui::table("Session Status")
.row("Node ID", node_id)
.row("Session", redact_session_id(session_id, show_sensitive))
.row("Outgoing Queue", format!("{} messages", outgoing_queue_size))
.row("Incoming Queue", format!("{} messages", incoming_queue_size));
if let Some(peers) = connected_peers {
println!(" Connected Peers: {}", peers);
builder = builder.row("Connected Peers", peers);
}
builder.render();
}
ControlResponse::SessionInfo(info) => {
println!("Session Info:");
println!(" ID: {}", info.session_id);
let mut builder = ui::table("Session Info")
.row("ID", redact_session_id(&info.session_id, show_sensitive));
if let Some(ref name) = info.session_name {
println!(" Name: {}", name);
builder = builder.row("Name", name);
}
println!(" State: {:?}", info.state);
println!(" Entities: {}", info.entity_count);
println!(" Created: {}", info.created_at);
println!(" Last Active: {}", info.last_active);
builder
.row("State", format!("{:?}", info.state))
.row("Entities", info.entity_count)
.row("Created", info.created_at)
.row("Last Active", info.last_active)
.render();
}
ControlResponse::Sessions(sessions) => {
println!("Sessions ({} total):", sessions.len());
for session in sessions {
println!(" {}: {:?} ({} entities)", session.session_id, session.state, session.entity_count);
if sessions.is_empty() {
println!("No sessions found");
return;
}
let mut builder = ui::grid(&format!("Sessions ({})", sessions.len()))
.header(&["Session ID", "State", "Entities"]);
for session in sessions {
builder = builder.row(&[
redact_session_id(&session.session_id, show_sensitive),
format!("{:?}", session.state),
session.entity_count.to_string(),
]);
}
builder.render();
}
ControlResponse::Peers(peers) => {
println!("Connected Peers ({} total):", peers.len());
for peer in peers {
print!(" {}", peer.node_id);
if let Some(since) = peer.connected_since {
println!(" (connected since: {})", since);
} else {
println!();
}
if peers.is_empty() {
println!("No connected peers");
return;
}
let mut builder = ui::list(&format!("Connected Peers ({})", peers.len()));
for peer in peers {
let item = if let Some(since) = peer.connected_since {
format!("{} (connected since: {})", peer.node_id, since)
} else {
peer.node_id.to_string()
};
builder = builder.item(item);
}
builder.render();
}
ControlResponse::Ok { message } => {
println!("Success: {}", message);

View File

@@ -0,0 +1,224 @@
//! Beautiful CLI UI module for marathonctl using ratatui
//!
//! Provides simple builder APIs for rendering beautiful terminal output
//! without taking over the terminal (inline mode).
//!
//! # Examples
//!
//! ```rust
//! // Render a status table
//! ui::table("Session Status")
//! .row("Node ID", node_id)
//! .row("Session", session_id)
//! .row("Queue Size", queue_size)
//! .render();
//!
//! // Render a list
//! ui::list("Connected Peers")
//! .item(peer1)
//! .item(peer2)
//! .render();
//!
//! // Render a data grid
//! ui::grid("Sessions")
//! .header(&["ID", "State", "Entities"])
//! .row(&[id1, state1, count1])
//! .row(&[id2, state2, count2])
//! .render();
//! ```
use ratatui::prelude::*;
use ratatui::widgets::*;
use ratatui::{TerminalOptions, Viewport};
/// Create a key-value table (like status output)
pub fn table(title: &str) -> TableBuilder {
TableBuilder::new(title)
}
/// Create a simple list
pub fn list(title: &str) -> ListBuilder {
ListBuilder::new(title)
}
/// Create a data grid with headers
pub fn grid(title: &str) -> GridBuilder {
GridBuilder::new(title)
}
/// Builder for key-value tables
pub struct TableBuilder {
title: String,
rows: Vec<(String, String)>,
}
impl TableBuilder {
fn new(title: &str) -> Self {
Self {
title: title.to_string(),
rows: Vec::new(),
}
}
/// Add a key-value row
pub fn row(mut self, key: impl std::fmt::Display, value: impl std::fmt::Display) -> Self {
self.rows.push((key.to_string(), value.to_string()));
self
}
/// Render the table to the terminal
pub fn render(self) {
let height = self.rows.len() + 4; // rows + borders + header + padding
render_widget(height, |frame| {
let area = frame.area();
let rows: Vec<Row> = self
.rows
.iter()
.map(|(k, v)| Row::new(vec![k.clone(), v.clone()]))
.collect();
let table = Table::new(
rows,
[Constraint::Length(20), Constraint::Min(30)],
)
.header(
Row::new(vec!["Field", "Value"])
.bold()
.style(Style::default().fg(Color::Green)),
)
.block(
Block::default()
.title(format!(" {} ", self.title))
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan)),
)
.column_spacing(2);
frame.render_widget(table, area);
});
}
}
/// Builder for simple lists
pub struct ListBuilder {
title: String,
items: Vec<String>,
}
impl ListBuilder {
fn new(title: &str) -> Self {
Self {
title: title.to_string(),
items: Vec::new(),
}
}
/// Add an item to the list
pub fn item(mut self, item: impl std::fmt::Display) -> Self {
self.items.push(item.to_string());
self
}
/// Render the list to the terminal
pub fn render(self) {
let height = self.items.len() + 3; // items + borders + title
render_widget(height, |frame| {
let area = frame.area();
let items: Vec<ListItem> = self
.items
.iter()
.map(|i| ListItem::new(format!("{}", i)))
.collect();
let list = List::new(items)
.block(
Block::default()
.title(format!(" {} ", self.title))
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan)),
);
frame.render_widget(list, area);
});
}
}
/// Builder for data grids with headers
pub struct GridBuilder {
title: String,
headers: Vec<String>,
rows: Vec<Vec<String>>,
}
impl GridBuilder {
fn new(title: &str) -> Self {
Self {
title: title.to_string(),
headers: Vec::new(),
rows: Vec::new(),
}
}
/// Set the header row
pub fn header(mut self, headers: &[impl std::fmt::Display]) -> Self {
self.headers = headers.iter().map(|h| h.to_string()).collect();
self
}
/// Add a data row
pub fn row(mut self, cells: &[impl std::fmt::Display]) -> Self {
self.rows.push(cells.iter().map(|c| c.to_string()).collect());
self
}
/// Render the grid to the terminal
pub fn render(self) {
let height = self.rows.len() + 4; // rows + borders + header + padding
render_widget(height, |frame| {
let area = frame.area();
// Create constraints based on number of columns
let col_count = self.headers.len().max(
self.rows.iter().map(|r| r.len()).max().unwrap_or(0)
);
let constraints = vec![Constraint::Ratio(1, col_count as u32); col_count];
let header = Row::new(self.headers.clone())
.bold()
.style(Style::default().fg(Color::Green));
let rows: Vec<Row> = self
.rows
.iter()
.map(|r| Row::new(r.clone()))
.collect();
let table = Table::new(rows, constraints)
.header(header)
.block(
Block::default()
.title(format!(" {} ", self.title))
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan)),
)
.column_spacing(2);
frame.render_widget(table, area);
});
}
}
/// Internal helper to render a widget using inline viewport
fn render_widget<F>(height: usize, render_fn: F)
where
F: FnOnce(&mut Frame),
{
let mut terminal = ratatui::init_with_options(TerminalOptions {
viewport: Viewport::Inline(height as u16),
});
terminal.draw(render_fn).unwrap();
ratatui::restore();
}