use crate::args::ExplainArgs;
use anyhow::Result;
use keyhog_core::DetectorSpec;
pub fn run(args: ExplainArgs) -> Result<()> {
let detectors = crate::orchestrator_config::load_detectors_or_embedded(&args.detectors)?;
let raw = args.detector_id.to_lowercase();
let (needle, hot_origin) = match canonical_for_hot_id(&raw) {
Some(canon) => (canon.to_string(), Some(raw.clone())),
None => (raw.clone(), None),
};
let detector = detectors
.iter()
.find(|d| d.id.to_lowercase() == needle)
.ok_or_else(|| explain_not_found(&detectors, &args.detector_id, &raw))?;
if let Some(hot) = &hot_origin {
println!(
"\u{2139} '{hot}' is keyhog's SIMD fast-path label; showing the \
canonical detector '{needle}'.\n"
);
}
print_explanation(detector);
Ok(())
}
pub fn canonical_for_hot_id(id: &str) -> Option<&'static str> {
match id {
"hot-github_pat" => Some("github-classic-pat"),
"hot-openai_key" => Some("openai-api-key"),
"hot-aws_key" => Some("aws-access-key"),
"hot-aws_session_key" => Some("aws-session-token"),
"hot-sendgrid_key" => Some("sendgrid-api-key"),
"hot-slack_bot_token" => Some("slack-bot-token"),
"hot-slack_user_token" => Some("slack-user-token"),
_ => None,
}
}
pub fn explain_not_found(
detectors: &[DetectorSpec],
requested: &str,
lowered: &str,
) -> anyhow::Error {
if let Some(stripped) = lowered.strip_prefix("hot-") {
let svc = stripped.split('_').next().unwrap_or(stripped);
let related: Vec<&str> = detectors
.iter()
.filter(|d| d.id.to_lowercase().contains(svc) || d.service.to_lowercase().contains(svc))
.map(|d| d.id.as_str())
.take(8)
.collect();
return if related.is_empty() {
anyhow::anyhow!(
"'{requested}' is a keyhog SIMD fast-path pattern with no standalone \
registry detector yet - it still surfaces in scans, there is just no \
separate spec to explain."
)
} else {
anyhow::anyhow!(
"'{requested}' is a keyhog SIMD fast-path label, not a registry detector id. \
Related detectors you can explain: {}",
related.join(", ")
)
};
}
let suggestions: Vec<&str> = detectors
.iter()
.filter(|d| d.id.to_lowercase().contains(lowered))
.map(|d| d.id.as_str())
.take(8)
.collect();
if suggestions.is_empty() {
anyhow::anyhow!(
"no detector with id '{requested}' (use `keyhog detectors` to list available ids)"
)
} else {
anyhow::anyhow!(
"no detector with id '{requested}'. Did you mean: {}?",
suggestions.join(", ")
)
}
}
fn print_explanation(d: &DetectorSpec) {
println!("\u{1F4D6} {}\n", d.id);
println!(" Name: {}", d.name);
println!(" Service: {}", d.service);
println!(" Severity: {:?}", d.severity);
println!(" Patterns: {}", d.patterns.len());
for (i, p) in d.patterns.iter().enumerate() {
println!(" [{i}] {}", p.regex);
if let Some(group) = p.group {
println!(" capture group: {group}");
}
if let Some(desc) = &p.description {
println!(" description: {desc}");
}
}
if !d.keywords.is_empty() {
println!(" Keywords:");
for kw in &d.keywords {
println!(" - {kw}");
}
}
if !d.companions.is_empty() {
println!(" Companions:");
for c in &d.companions {
let req = if c.required { " (required)" } else { "" };
println!(
" - {}{req}: {} (within {} lines)",
c.name, c.regex, c.within_lines
);
}
}
if let Some(verify) = &d.verify {
println!(" Verification:");
if let Some(url) = verify.url.as_deref() {
println!(" URL: {url}");
}
println!(" Steps: {}", verify.steps.len());
} else {
println!(" Verification: (none; pattern match only)");
}
if let Some(rotation) = rotation_guide(&d.service) {
println!();
println!("\u{1F510} Rotation guide for {}:", d.service);
println!(" {rotation}");
}
println!();
println!("If this finding lands in your scan, the canonical remediation is:");
println!(" 1. Treat the credential as compromised; assume it has been read.");
println!(" 2. Rotate it at the issuer (see rotation-guide URL above).");
println!(" 3. Audit access logs for the old credential's identifier.");
println!(" 4. Replace the leaked value with an env-var reference and add to `.gitignore`.");
println!();
}
fn rotation_guide(service: &str) -> Option<&'static str> {
let lower = service.to_lowercase();
match lower.as_str() {
s if s.contains("aws") => Some("https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_access-keys.html#Using_RotateAccessKey"),
s if s.contains("github") => Some("https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens"),
s if s.contains("gitlab") => Some("https://docs.gitlab.com/ee/user/profile/personal_access_tokens.html#revoke-a-personal-access-token"),
s if s.contains("slack") => Some("https://api.slack.com/legacy/oauth-scopes#auth.revoke"),
s if s.contains("openai") => Some("https://platform.openai.com/api-keys"),
s if s.contains("anthropic") => Some("https://console.anthropic.com/settings/keys"),
s if s.contains("stripe") => Some("https://dashboard.stripe.com/apikeys"),
s if s.contains("twilio") => Some("https://www.twilio.com/docs/iam/access-tokens#rotate-keys"),
s if s.contains("sendgrid") => Some("https://docs.sendgrid.com/ui/account-and-settings/api-keys"),
s if s.contains("google") || s.contains("gcp") => Some("https://cloud.google.com/iam/docs/creating-managing-service-account-keys#rotating"),
s if s.contains("azure") => Some("https://learn.microsoft.com/en-us/azure/active-directory/develop/howto-create-service-principal-portal#authentication-two-options"),
s if s.contains("npm") => Some("https://docs.npmjs.com/revoking-access-tokens"),
s if s.contains("pypi") => Some("https://pypi.org/help/#apitoken"),
s if s.contains("docker") => Some("https://docs.docker.com/security/for-developers/access-tokens/"),
s if s.contains("datadog") => Some("https://docs.datadoghq.com/account_management/api-app-keys/"),
s if s.contains("snowflake") => Some("https://docs.snowflake.com/en/user-guide/key-pair-auth#configuring-key-pair-rotation"),
_ => None,
}
}