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:
Hmbown
2026-08-02 01:51:57 -07:00
parent 0b3d794934
commit 23e279033d
3 changed files with 110 additions and 0 deletions
+72
View File
@@ -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
+26
View File
@@ -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());
}
+12
View File
@@ -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)",