Skip to main content

keyhog_core/
auto_fix.rs

1//! Auto-fix suggestions: turn each finding into "replace this credential
2//! with `${ENV_VAR_NAME}`" advice.
3//!
4//! Tier-B moat innovation #15 + #17 from audits/legendary-2026-04-26:
5//! moves keyhog from "find" to "fix." We surface the suggestion in SARIF
6//! `result.fixes[]` per the v2.2.0 spec; CLI consumers can apply the edit
7//! interactively or in a pre-commit hook.
8//!
9//! This module provides only the SUGGESTION step (deterministic env-var
10//! name from service + the `${VAR}` replacement string). Actually rewriting
11//! files belongs in the CLI, where we can prompt the user before clobbering
12//! their working tree.
13
14/// Map a detector's `service` string to a conventional environment-variable
15/// name. Falls back to `<UPPER_SERVICE>_KEY` when the service isn't in the
16/// curated map.
17///
18/// The curated mappings follow community conventions (12-factor, common
19/// SDKs):
20///   aws            → AWS_ACCESS_KEY_ID
21///   github / gh-*  → GITHUB_TOKEN
22///   gitlab         → GITLAB_TOKEN
23///   slack          → SLACK_BOT_TOKEN
24///   openai         → OPENAI_API_KEY
25///   anthropic      → ANTHROPIC_API_KEY
26///   stripe         → STRIPE_SECRET_KEY
27///   twilio         → TWILIO_AUTH_TOKEN
28///   sendgrid       → SENDGRID_API_KEY
29///   google / gcp   → GOOGLE_API_KEY
30///   azure          → AZURE_CLIENT_SECRET
31///   npm            → NPM_TOKEN
32///   pypi           → PYPI_TOKEN
33///   docker         → DOCKER_PASSWORD
34///   datadog        → DATADOG_API_KEY
35///   snowflake      → SNOWFLAKE_PASSWORD
36/// Derive a canonical environment variable name for a service (e.g., "stripe" -> "STRIPE_KEY").
37pub fn env_var_name_for_service(service: &str) -> String {
38    let lower = service.to_lowercase();
39    let curated = match lower.as_str() {
40        s if s.contains("aws") => Some("AWS_ACCESS_KEY_ID"),
41        s if s.contains("github") || s.starts_with("gh-") || s.starts_with("ghp_") => {
42            Some("GITHUB_TOKEN")
43        }
44        s if s.contains("gitlab") => Some("GITLAB_TOKEN"),
45        s if s.contains("slack") => Some("SLACK_BOT_TOKEN"),
46        s if s.contains("openai") => Some("OPENAI_API_KEY"),
47        s if s.contains("anthropic") => Some("ANTHROPIC_API_KEY"),
48        s if s.contains("stripe") => Some("STRIPE_SECRET_KEY"),
49        s if s.contains("twilio") => Some("TWILIO_AUTH_TOKEN"),
50        s if s.contains("sendgrid") => Some("SENDGRID_API_KEY"),
51        s if s.contains("google") || s.contains("gcp") => Some("GOOGLE_API_KEY"),
52        s if s.contains("azure") => Some("AZURE_CLIENT_SECRET"),
53        s if s.contains("npm") => Some("NPM_TOKEN"),
54        s if s.contains("pypi") => Some("PYPI_TOKEN"),
55        s if s.contains("docker") => Some("DOCKER_PASSWORD"),
56        s if s.contains("datadog") => Some("DATADOG_API_KEY"),
57        s if s.contains("snowflake") => Some("SNOWFLAKE_PASSWORD"),
58        _ => None,
59    };
60    curated
61        .map(|s| s.to_string())
62        .unwrap_or_else(|| service_to_screaming_snake(service))
63}
64
65fn service_to_screaming_snake(service: &str) -> String {
66    let mut out = String::with_capacity(service.len() + 4);
67    for ch in service.chars() {
68        if ch.is_ascii_alphanumeric() {
69            out.push(ch.to_ascii_uppercase());
70        } else if !out.ends_with('_') {
71            out.push('_');
72        }
73    }
74    out.trim_matches('_').to_string() + "_KEY"
75}
76
77/// Render the `${ENV_VAR_NAME}` shell-interpolation replacement string for
78/// a detector. Reporters embed this in their `fixes[]` output.
79/// Return the recommended replacement text for a leaked credential (e.g., "${STRIPE_KEY}").
80pub fn fix_replacement_text(service: &str) -> String {
81    format!("${{{}}}", env_var_name_for_service(service))
82}
83
84#[cfg(test)]
85mod tests {
86    use super::*;
87
88    #[test]
89    fn curated_services_map_correctly() {
90        assert_eq!(env_var_name_for_service("aws"), "AWS_ACCESS_KEY_ID");
91        assert_eq!(env_var_name_for_service("aws-iam"), "AWS_ACCESS_KEY_ID");
92        assert_eq!(env_var_name_for_service("github"), "GITHUB_TOKEN");
93        assert_eq!(env_var_name_for_service("openai"), "OPENAI_API_KEY");
94        assert_eq!(env_var_name_for_service("anthropic"), "ANTHROPIC_API_KEY");
95        assert_eq!(env_var_name_for_service("stripe"), "STRIPE_SECRET_KEY");
96        assert_eq!(env_var_name_for_service("snowflake"), "SNOWFLAKE_PASSWORD");
97    }
98
99    #[test]
100    fn unknown_service_falls_back_to_screaming_snake() {
101        assert_eq!(
102            env_var_name_for_service("acme-widget-api"),
103            "ACME_WIDGET_API_KEY"
104        );
105        assert_eq!(env_var_name_for_service("RevenueCat"), "REVENUECAT_KEY");
106    }
107
108    #[test]
109    fn fix_replacement_text_wraps_in_dollar_braces() {
110        assert_eq!(fix_replacement_text("aws"), "${AWS_ACCESS_KEY_ID}");
111        assert_eq!(fix_replacement_text("acme-x"), "${ACME_X_KEY}");
112    }
113
114    #[test]
115    fn empty_service_does_not_panic() {
116        // "" → trim_matches('_') yields "" → "" + "_KEY" = "_KEY"
117        assert_eq!(env_var_name_for_service(""), "_KEY");
118    }
119}