Skip to main content

safe_shell_scanner/
path_rules.rs

1/// A pattern for detecting sensitive file paths.
2#[derive(Debug, Clone)]
3pub struct PathPattern {
4    pub pattern: String,
5    pub description: String,
6}
7
8/// Returns built-in sensitive path patterns.
9pub fn get_sensitive_patterns() -> Vec<PathPattern> {
10    vec![
11        PathPattern {
12            pattern: "~/.ssh".into(),
13            description: "SSH keys and config".into(),
14        },
15        PathPattern {
16            pattern: "~/.aws".into(),
17            description: "AWS credentials".into(),
18        },
19        PathPattern {
20            pattern: "~/.gnupg".into(),
21            description: "GPG keys".into(),
22        },
23        PathPattern {
24            pattern: "~/.config/gcloud".into(),
25            description: "Google Cloud credentials".into(),
26        },
27        PathPattern {
28            pattern: "~/.azure".into(),
29            description: "Azure credentials".into(),
30        },
31        PathPattern {
32            pattern: "~/.docker".into(),
33            description: "Docker config (may contain registry auth)".into(),
34        },
35        PathPattern {
36            pattern: "~/.kube".into(),
37            description: "Kubernetes config".into(),
38        },
39        PathPattern {
40            pattern: "~/.npmrc".into(),
41            description: "npm auth tokens".into(),
42        },
43        PathPattern {
44            pattern: ".env".into(),
45            description: "Environment file".into(),
46        },
47        PathPattern {
48            pattern: ".env.*".into(),
49            description: "Environment file variants".into(),
50        },
51        PathPattern {
52            pattern: "*.pem".into(),
53            description: "PEM certificate/key".into(),
54        },
55        PathPattern {
56            pattern: "*.key".into(),
57            description: "Private key file".into(),
58        },
59        PathPattern {
60            pattern: "*.p12".into(),
61            description: "PKCS#12 certificate".into(),
62        },
63        PathPattern {
64            pattern: "*.pfx".into(),
65            description: "PFX certificate".into(),
66        },
67        PathPattern {
68            pattern: "*.jks".into(),
69            description: "Java keystore".into(),
70        },
71        PathPattern {
72            pattern: "*.keystore".into(),
73            description: "Keystore file".into(),
74        },
75        PathPattern {
76            pattern: "*.tfvars".into(),
77            description: "Terraform variables (may contain secrets)".into(),
78        },
79        PathPattern {
80            pattern: "*.tfstate".into(),
81            description: "Terraform state (contains resource details)".into(),
82        },
83        PathPattern {
84            pattern: "credentials.json".into(),
85            description: "Credentials file".into(),
86        },
87        PathPattern {
88            pattern: "secrets.*".into(),
89            description: "Secrets file".into(),
90        },
91        PathPattern {
92            pattern: "service-account*.json".into(),
93            description: "GCP service account key".into(),
94        },
95        PathPattern {
96            pattern: "*.kdbx".into(),
97            description: "KeePass database".into(),
98        },
99        PathPattern {
100            pattern: "*.kdb".into(),
101            description: "KeePass database (legacy)".into(),
102        },
103    ]
104}
105
106/// Check if a given path matches any built-in sensitive path pattern.
107pub fn is_sensitive_path(path: &str) -> bool {
108    let home = dirs::home_dir()
109        .map(|h| h.to_string_lossy().to_string())
110        .unwrap_or_default();
111
112    let patterns = get_sensitive_patterns();
113    for pat in &patterns {
114        // Home directory paths (e.g. ~/.ssh)
115        if pat.pattern.starts_with("~/") {
116            let resolved = pat.pattern.replacen('~', &home, 1);
117            if path == resolved || path.starts_with(&format!("{resolved}/")) {
118                return true;
119            }
120            continue;
121        }
122
123        // Extension patterns (e.g. *.pem)
124        if let Some(ext) = pat.pattern.strip_prefix("*.") {
125            if path.ends_with(&format!(".{ext}")) {
126                return true;
127            }
128            continue;
129        }
130
131        // Prefix glob patterns (e.g. .env.*, secrets.*, service-account*.json)
132        if pat.pattern.contains('*') {
133            let parts: Vec<&str> = pat.pattern.splitn(2, '*').collect();
134            if parts.len() == 2 {
135                let prefix = parts[0];
136                let suffix = parts[1];
137                if let Some(filename) = std::path::Path::new(path).file_name() {
138                    let name = filename.to_string_lossy();
139                    if name.starts_with(prefix)
140                        && name.ends_with(suffix)
141                        && name.len() >= prefix.len() + suffix.len()
142                    {
143                        return true;
144                    }
145                }
146            }
147            continue;
148        }
149
150        // Exact filename match (e.g. .env, credentials.json)
151        if let Some(filename) = std::path::Path::new(path).file_name() {
152            if filename.to_string_lossy() == pat.pattern {
153                return true;
154            }
155        }
156    }
157
158    false
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164
165    #[test]
166    fn has_enough_patterns() {
167        assert!(get_sensitive_patterns().len() >= 23);
168    }
169
170    #[test]
171    fn detects_ssh_dir() {
172        let home = dirs::home_dir().unwrap();
173        assert!(is_sensitive_path(&format!(
174            "{}/.ssh/id_rsa",
175            home.display()
176        )));
177        assert!(is_sensitive_path(&format!("{}/.ssh", home.display())));
178    }
179
180    #[test]
181    fn detects_aws_dir() {
182        let home = dirs::home_dir().unwrap();
183        assert!(is_sensitive_path(&format!(
184            "{}/.aws/credentials",
185            home.display()
186        )));
187    }
188
189    #[test]
190    fn detects_gnupg() {
191        let home = dirs::home_dir().unwrap();
192        assert!(is_sensitive_path(&format!(
193            "{}/.gnupg/pubring.kbx",
194            home.display()
195        )));
196    }
197
198    #[test]
199    fn detects_env_file() {
200        assert!(is_sensitive_path(".env"));
201        assert!(is_sensitive_path("/project/.env"));
202    }
203
204    #[test]
205    fn detects_env_variants() {
206        assert!(is_sensitive_path(".env.local"));
207        assert!(is_sensitive_path(".env.production"));
208        assert!(is_sensitive_path("/app/.env.staging"));
209    }
210
211    #[test]
212    fn detects_pem_file() {
213        assert!(is_sensitive_path("server.pem"));
214        assert!(is_sensitive_path("/etc/ssl/private/server.pem"));
215    }
216
217    #[test]
218    fn detects_key_file() {
219        assert!(is_sensitive_path("private.key"));
220        assert!(is_sensitive_path("/etc/ssl/server.key"));
221    }
222
223    #[test]
224    fn detects_tfvars() {
225        assert!(is_sensitive_path("terraform.tfvars"));
226        assert!(is_sensitive_path("prod.tfvars"));
227    }
228
229    #[test]
230    fn detects_credentials_json() {
231        assert!(is_sensitive_path("credentials.json"));
232        assert!(is_sensitive_path("/app/credentials.json"));
233    }
234
235    #[test]
236    fn detects_secrets_file() {
237        assert!(is_sensitive_path("secrets.yaml"));
238        assert!(is_sensitive_path("secrets.json"));
239    }
240
241    #[test]
242    fn detects_service_account() {
243        assert!(is_sensitive_path("service-account.json"));
244        assert!(is_sensitive_path("service-account-prod.json"));
245    }
246
247    #[test]
248    fn detects_keepass() {
249        assert!(is_sensitive_path("passwords.kdbx"));
250        assert!(is_sensitive_path("legacy.kdb"));
251    }
252
253    #[test]
254    fn allows_normal_files() {
255        assert!(!is_sensitive_path("/usr/bin/ls"));
256        assert!(!is_sensitive_path("src/main.rs"));
257        assert!(!is_sensitive_path("package.json"));
258        assert!(!is_sensitive_path("README.md"));
259        assert!(!is_sensitive_path("Cargo.toml"));
260    }
261}