keyhog 0.5.37

keyhog: detects leaked credentials in source trees, git history, and cloud storage
Documentation
//! `keyhog explain <detector-id>` - full spec dump for one detector.
//!
//! Prints id, name, service, severity, all patterns, keywords, companions,
//! verification spec presence, and a service-keyed rotation-guide URL when
//! one is known. Tier-B innovation #9 from audits/legendary-2026-04-26.

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();
    // `hot-*` ids are the SIMD fast-path's FINDING labels (see the scanner's
    // simdsieve_prefilter HOT_PATTERN_DETECTOR_IDS), NOT registry detector ids.
    // A user who copies an id straight out of `scan` output (`hot-github_pat`)
    // into `explain` would otherwise hit a bare "no such detector" - the two
    // commands would silently disagree. Resolve to the canonical registry spec
    // and tell them what happened.
    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(())
}

/// Map a `hot-<name>` fast-path finding id to its canonical registry detector.
/// Index-aligned with the scanner's `HOT_PATTERN_DETECTOR_IDS`. Kept here (CLI
/// side, un-feature-gated) so `explain` resolves these ids in every build,
/// including `--no-default-features` portable binaries that compile the hot
/// labels out of the scanner but can still be fed a `hot-*` id by hand or from
/// a baseline produced on a SIMD build.
///
/// `hot-square_secret` (Square `sq0csp-` OAuth) intentionally returns None:
/// there is no standalone Square-payments detector in the registry yet (only
/// `squarespace-api-key`, a different service), so it falls through to the
/// tailored not-found path rather than mis-resolving to the wrong service.
// `pub` so tests/unit/subcommands_explain.rs can assert the hot→canonical
// mapping directly (no_inline_tests_in_src gate).
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,
    }
}

/// Build the "not found" error, with a tailored branch for `hot-*` ids that
/// have no canonical registry detector so the user learns it's a real fast-path
/// pattern rather than chasing a typo.
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(", ")
            )
        };
    }
    // Suggest near-matches by substring so a typo prints something useful
    // instead of "not found".
    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!();
}

/// Service-keyed rotation guide. The map is curated for the most-leaked
/// services per the GitGuardian + Snyk 2025 reports. Unknown services
/// return None and the explainer omits the rotation block.
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,
    }
}