feat: log censoring (#925)

This commit is contained in:
LynithDev 2026-10-03 16:00:44 +02:00 • committed by GitHub
parent c43272683b
commit b2384afb4e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 106 additions and 9 deletions

View file

@ -0,0 +1,5 @@
---
default: minor
---
# Game logs hide account tokens, and the game output starts with the launch command ready to paste into a terminal

1
Cargo.lock generated
View file

@ -4995,6 +4995,7 @@ dependencies = [
"oneclient_db",
"oneclient_net",
"polyio",
"regex",
"reqwest 0.13.5",
"serde",
"serde_json",

View file

@ -25,6 +25,7 @@ bytes.workspace = true
chrono.workspace = true
image.workspace = true
reqwest.workspace = true
regex.workspace = true
serde.workspace = true
serde_repr.workspace = true
strum.workspace = true

View file

@ -109,7 +109,7 @@ pub(super) fn lines_from(content: &str, opts: &ReadOptions) -> Vec<LogLine> {
lines.push(LogLine {
number: idx + 1,
level,
text: raw.to_string(),
text: super::censor(raw).into_owned(),
});
}

View file

@ -32,7 +32,7 @@ pub async fn upload_log_at(
path: &Path,
) -> ClusterResult<MclogsUploadResponse> {
let path = ensure_allowed(path)?;
let mut content = read_file_string(&path).await?;
let mut content = super::censor(&read_file_string(&path).await?).into_owned();
let line_count = content.lines().count();
if line_count > MAX_LINES {

View file

@ -10,7 +10,7 @@ use chrono::{DateTime, Utc};
pub use error::LogsError;
pub use manage::{cluster_output_log, delete_log_at, list_cluster_logs, read_log_at};
pub use mclogs::upload_log_at;
pub use parse::parse_level;
pub use parse::{censor, parse_level};
/// No launcher variant on purpose the launcher's own logs live in the log console
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]

View file

@ -1,5 +1,21 @@
use std::borrow::Cow;
use std::sync::LazyLock;
use regex::Regex;
use super::LogLevel;
static SECRET: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r#"(--accessToken|--session(?:Id)?)([\s,=]+)[^\s,\[\]'"]+|(token:)[^:\s]+|eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]*"#,
)
.expect("secret pattern")
});
pub fn censor(line: &str) -> Cow<'_, str> {
SECRET.replace_all(line, "${1}${2}${3}[censored]")
}
pub fn parse_level(line: &str) -> Option<LogLevel> {
let bytes = line.as_bytes();
let mut i = 0;

View file

@ -1,3 +1,4 @@
use std::fmt::Write as _;
use std::path::Path;
use std::process::Stdio;
use std::sync::Arc;
@ -5,7 +6,7 @@ use std::time::Duration;
use chrono::{DateTime, Utc};
use interfrost::api::minecraft::ArgumentType;
use tokio::io::AsyncReadExt;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::process::Command;
use crate::ClusterStage;
@ -395,6 +396,14 @@ async fn start(
.args(mc_args)
.current_dir(&cwd);
let header = format!(
"OneClient {} · {} · {version_name} · Java {}\n\n{}\n\n",
env!("CARGO_PKG_VERSION"),
cluster.name,
java.major,
command_line(command.as_std(), &version_info.main_class),
);
let log_path = oneclient_cluster::logs::cluster_output_log(&cluster)?;
if let Some(parent) = log_path.parent() {
polyio::create_dir_all(parent).await.ok();
@ -405,10 +414,15 @@ async fn start(
// The cloned handle shares the file offset
// so stdout and stderr interleave instead of overwriting
let handles = match tokio::fs::File::create(&log_path).await {
Ok(out) => match out.try_clone().await {
Ok(mut out) => {
if let Err(err) = out.write_all(header.as_bytes()).await {
tracing::warn!(cluster_id, error = %err, "failed to write the launch header");
}
match out.try_clone().await {
Ok(err) => Ok((out.into_std().await, err.into_std().await)),
Err(err) => Err(err),
},
}
}
Err(err) => Err(err),
};
@ -752,6 +766,66 @@ fn base_command(profile: &GameSettingsProfile, java_path: &str) -> (Command, Opt
(command, Some(program.to_string()))
}
fn command_line(command: &std::process::Command, main_class: &str) -> String {
#[cfg(not(windows))]
const CONTINUATION: &str = " \\\n ";
#[cfg(windows)]
const CONTINUATION: &str = " `\n ";
let mut out = String::new();
if let Some(dir) = command.get_current_dir() {
let _ = writeln!(out, "cd {}", shell_quote(&dir.to_string_lossy()));
}
for (key, value) in command.get_envs() {
let (key, Some(value)) = (key.to_string_lossy(), value) else {
continue;
};
let value = shell_quote(&value.to_string_lossy());
#[cfg(not(windows))]
let _ = write!(out, "{key}={value} ");
#[cfg(windows)]
let _ = writeln!(out, "$env:{key} = {value}");
}
#[cfg(windows)]
{
out += "& ";
}
out += &shell_quote(&command.get_program().to_string_lossy());
for arg in command.get_args() {
let arg = arg.to_string_lossy();
let arg = oneclient_cluster::logs::censor(&arg);
out += if arg.starts_with('-') || arg == main_class {
CONTINUATION
} else {
" "
};
out += &shell_quote(&arg);
}
out
}
#[cfg(not(windows))]
fn shell_quote(arg: &str) -> String {
let plain = !arg.is_empty()
&& arg
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b"_-./=:,+@%".contains(&b));
if plain {
arg.to_string()
} else {
format!("'{}'", arg.replace('\'', r"'\''"))
}
}
#[cfg(windows)]
fn shell_quote(arg: &str) -> String {
format!("'{}'", arg.replace('\'', "''"))
}
fn apply_env(command: &mut Command, profile: &GameSettingsProfile) {
command.env_remove("_JAVA_OPTIONS");

View file

@ -129,7 +129,7 @@ async fn emit(
crash_watch: &CrashWatch,
) {
// Blank lines are kept so the console reads the way the log does
let text = line.trim_end_matches(['\n', '\r']).to_string();
let text = oneclient_cluster::logs::censor(line.trim_end_matches(['\n', '\r'])).into_owned();
if let Some(recorder) = recorder {
recorder.observe(&text).await;
}