feat(tui): doctor flags credential-shaped values in plain-text config
config.toml carried a plaintext OAuth-shaped token while a sibling entry was properly [redacted] — mixed hygiene the doctor never mentioned. The configuration section now scans the raw file for bearer-shaped values (known credential prefixes, or long random strings under token/secret/ key-named entries) and warns with the key names only; values are never echoed. Models, URLs, hex ids, and redacted entries stay quiet, proven by unit tests. Verified: doctor suite green; cargo fmt clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -154,6 +154,78 @@ pub(crate) fn secret_backend_human_lines(
|
||||
lines
|
||||
}
|
||||
|
||||
/// Report key names — never values — for config entries whose value is
|
||||
/// shaped like a bearer credential. `config.toml` is plain text, not a
|
||||
/// secret store; doctor warns so tokens migrate to the secret backend
|
||||
/// (morning-report issue: a plaintext OAuth token sat beside a `[redacted]`
|
||||
/// sibling entry).
|
||||
pub(crate) fn config_credential_shaped_keys(raw: &str) -> Vec<String> {
|
||||
fn strong_shape(value: &str) -> bool {
|
||||
const PREFIXES: [&str; 9] = [
|
||||
"sk-",
|
||||
"sk_",
|
||||
"xai-",
|
||||
"ghp_",
|
||||
"gho_",
|
||||
"github_pat_",
|
||||
"xoxb-",
|
||||
"xoxp-",
|
||||
"eyJ",
|
||||
];
|
||||
value.len() >= 20 && PREFIXES.iter().any(|prefix| value.starts_with(prefix))
|
||||
}
|
||||
fn suspect_key(key: &str) -> bool {
|
||||
let key = key.to_ascii_lowercase();
|
||||
[
|
||||
"token",
|
||||
"secret",
|
||||
"password",
|
||||
"credential",
|
||||
"api_key",
|
||||
"apikey",
|
||||
"access_key",
|
||||
]
|
||||
.iter()
|
||||
.any(|needle| key.contains(needle))
|
||||
}
|
||||
fn random_shape(value: &str) -> bool {
|
||||
value.len() >= 24
|
||||
&& value
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.'))
|
||||
&& value.chars().any(|ch| ch.is_ascii_digit())
|
||||
&& value.chars().any(|ch| ch.is_ascii_alphabetic())
|
||||
}
|
||||
|
||||
let mut flagged: Vec<String> = Vec::new();
|
||||
for line in raw.lines() {
|
||||
let line = line.trim();
|
||||
if line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
let Some((key, value)) = line.split_once('=') else {
|
||||
continue;
|
||||
};
|
||||
let key = key.trim().trim_matches('"');
|
||||
let value = value.trim();
|
||||
let Some(value) = value
|
||||
.strip_prefix('"')
|
||||
.and_then(|value| value.strip_suffix('"'))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if value.is_empty() || value.eq_ignore_ascii_case("[redacted]") {
|
||||
continue;
|
||||
}
|
||||
if (strong_shape(value) || (suspect_key(key) && random_shape(value)))
|
||||
&& !flagged.iter().any(|existing| existing == key)
|
||||
{
|
||||
flagged.push(key.to_string());
|
||||
}
|
||||
}
|
||||
flagged
|
||||
}
|
||||
|
||||
/// Return only the non-secret network authority of a configured URL.
|
||||
///
|
||||
/// Userinfo, path, query keys and values, and fragments are all omitted because
|
||||
|
||||
@@ -233,3 +233,29 @@ fn structural_url_authority_omits_every_secret_capable_component() {
|
||||
assert!(!authority.contains(sentinel));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn credential_shaped_config_values_are_flagged_by_key_name_only() {
|
||||
let raw = r#"
|
||||
# comment with sk-not-a-real-line
|
||||
model = "deepseek-v4-flash"
|
||||
base_url = "https://api.moonshot.ai/kimi-code/v1"
|
||||
chatgpt_access_token = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.payload.signature"
|
||||
moonshot_api_key = "[redacted]"
|
||||
provider_api_key = "sk-abc123def456ghi789jkl012"
|
||||
workspace_token_note = "short"
|
||||
random_id = "0123456789abcdef0123456789abcdef"
|
||||
"#;
|
||||
let flagged = super::config_credential_shaped_keys(raw);
|
||||
assert_eq!(flagged, vec!["chatgpt_access_token", "provider_api_key"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn credential_scan_ignores_urls_models_and_redacted_entries() {
|
||||
let raw = r#"
|
||||
model = "kimi-k3-instruct-preview-2026"
|
||||
endpoint = "https://example.com/v1?key=nope"
|
||||
api_key = "[redacted]"
|
||||
"#;
|
||||
assert!(super::config_credential_shaped_keys(raw).is_empty());
|
||||
}
|
||||
|
||||
@@ -3715,6 +3715,18 @@ async fn run_doctor(
|
||||
"✓".truecolor(aqua_r, aqua_g, aqua_b),
|
||||
crate::utils::display_path(config_path)
|
||||
);
|
||||
// Secret hygiene: name the keys, never the values. Plain-text config
|
||||
// is not a secret store.
|
||||
if let Ok(raw) = std::fs::read_to_string(config_path) {
|
||||
let flagged = crate::doctor::config_credential_shaped_keys(&raw);
|
||||
if !flagged.is_empty() {
|
||||
println!(
|
||||
" {} credential-shaped value(s) in config.toml ({}): move them to the secret backend, then scrub the file — config.toml is plain text",
|
||||
"!".truecolor(sky_r, sky_g, sky_b),
|
||||
flagged.join(", ")
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
println!(
|
||||
" {} config.toml not found at {} (using defaults/env)",
|
||||
|
||||
Reference in New Issue
Block a user