1use std::path::{Component, Path};
18
19use globset::{Glob, GlobSet, GlobSetBuilder};
20use regex::bytes::{Regex, RegexSet};
21
22use crate::config::GuardConfig;
23use crate::error::{Error, Result};
24
25const DENIED_NAMES: &[&str] = &[
28 ".env",
29 ".env.*",
30 "*.env",
31 ".envrc",
32 "*.pem",
33 "*.key",
34 "*.p12",
35 "*.pfx",
36 "*.jks",
37 "*.keystore",
38 "*.ppk",
39 "*.ovpn",
40 "*.kdbx",
41 "*.gpg",
42 "*.asc",
43 "*.secret",
44 "*.secrets",
45 "secrets.*",
46 "secret.*",
47 "id_rsa*",
48 "id_dsa*",
49 "id_ecdsa*",
50 "id_ed25519*",
51 "authorized_keys",
52 ".netrc",
53 "_netrc",
54 ".npmrc",
55 ".pypirc",
56 ".yarnrc",
57 ".git-credentials",
58 ".htpasswd",
59 ".vault-token",
60 ".boto",
61 ".s3cfg",
62 ".pgpass",
63 ".my.cnf",
64 "credentials",
65 "credentials.*",
66 "*credentials*.json",
67 "*service-account*.json",
68 "*service_account*.json",
69 "client_secret*.json",
70 "*.tfstate",
71 "*.tfstate.*",
72 "*.tfvars",
73 "terraform.rc",
74 ".terraformrc",
75 "kubeconfig",
76 "opencode.json",
77 "opencode.jsonc",
78 ".farhand.toml",
79 ".mcp.json",
80 "mcp.json",
81 "claude_desktop_config.json",
82 ".claude.json",
83 "auth.json",
84 "hosts.yml",
85 "wallet.dat",
86];
87
88const DENIED_DIRS: &[&str] = &[
90 ".ssh",
91 ".aws",
92 ".azure",
93 ".gcloud",
94 ".gnupg",
95 ".kube",
96 ".docker",
97 ".config",
98 ".claude",
99 ".opencode",
100 ".codex",
101 ".cursor",
102 ".gemini",
103 ".vscode-server",
104 ".password-store",
105 ".secrets",
106 "secrets",
107 ".1password",
108 "Keychains",
109 ".Trash",
110];
111
112const DENIED_CONTENT: &[(&str, &str)] = &[
116 (
117 "private-key-block",
118 r"-----BEGIN [A-Z0-9 ]*PRIVATE KEY( BLOCK)?-----",
119 ),
120 (
121 "openai-or-anthropic-key",
122 r"\bsk-(ant-|proj-|svcacct-)?[A-Za-z0-9_-]{20,}",
123 ),
124 (
125 "github-token",
126 r"\b(gh[pousr]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{22,})",
127 ),
128 ("aws-access-key", r"\b(AKIA|ASIA)[0-9A-Z]{16}\b"),
129 ("google-api-key", r"\bAIza[0-9A-Za-z_-]{35}\b"),
130 ("slack-token", r"\bxox[abprs]-[0-9A-Za-z-]{10,}"),
131 ("stripe-key", r"\b[sr]k_(live|test)_[0-9A-Za-z]{20,}"),
132 (
133 "jwt",
134 r"\beyJ[A-Za-z0-9_-]{15,}\.[A-Za-z0-9_-]{15,}\.[A-Za-z0-9_-]{10,}",
135 ),
136 ("huggingface-token", r"\bhf_[A-Za-z0-9]{30,}"),
137 ("npm-token", r"\bnpm_[A-Za-z0-9]{36}\b"),
138 ("gitlab-token", r"\bglpat-[A-Za-z0-9_-]{20,}"),
139 ("openrouter-key", r"\bsk-or-v1-[0-9a-f]{40,}"),
140 (
141 "bearer-header",
142 r"(?i)\bauthorization\s*[:=]\s*['\x22]?\s*(bearer|basic|token)\s+[A-Za-z0-9._+/=-]{20,}",
143 ),
144 (
145 "url-with-password",
146 r"(?i)\b[a-z][a-z0-9+.-]*://[^\s/:@]+:[^\s/@]{6,}@[^\s/]+",
147 ),
148 (
149 "credential-assignment",
150 r"(?i)\b(api[_-]?key|secret[_-]?key|access[_-]?key|private[_-]?key|auth[_-]?token|access[_-]?token|refresh[_-]?token|client[_-]?secret|password|passwd|token|secret)['\x22]?\s*[:=]\s*['\x22]?[A-Za-z0-9_./+=-]{24,}",
151 ),
152];
153
154const LOCAL_TRUSTED_NAMES: &[&str] = &[
161 ".farhand.toml",
162 ".vscode",
163 ".idea",
164 ".git",
165 ".husky",
166 ".devcontainer",
167 ".envrc",
168 ".direnv",
169 ".mise.toml",
170 "mise.toml",
171 ".pre-commit-config.yaml",
172 "CLAUDE.md",
173 "AGENTS.md",
174 ".cursorrules",
175 ".windsurfrules",
176];
177
178#[derive(Debug, Clone)]
179pub struct Finding {
180 pub rule: &'static str,
181 pub detail: String,
182}
183
184impl Finding {
185 fn deny(&self, what: &str) -> Error {
186 Error::Denied(format!(
187 "{what} looks like credential material (rule: {}, {}) and will not be sent to the \
188 remote. If this is not a secret, rename it or rewrite the value; FarHand never \
189 uploads credentials.",
190 self.rule, self.detail
191 ))
192 }
193}
194
195pub struct Guard {
196 names: GlobSet,
197 extra_paths: GlobSet,
198 content_names: Vec<&'static str>,
199 content: RegexSet,
200 extra_content: Vec<Regex>,
201}
202
203impl Guard {
204 pub fn new(cfg: &GuardConfig) -> Result<Self> {
205 let mut names = GlobSetBuilder::new();
206 for n in DENIED_NAMES {
207 names.add(Glob::new(n).map_err(|e| Error::Config(e.to_string()))?);
208 }
209 let mut extra = GlobSetBuilder::new();
210 for g in &cfg.deny_globs {
211 extra.add(Glob::new(g).map_err(|e| Error::Config(format!("guard.deny_globs: {e}")))?);
212 }
213 let content = RegexSet::new(DENIED_CONTENT.iter().map(|(_, r)| *r))
214 .map_err(|e| Error::Config(e.to_string()))?;
215 let extra_content = cfg
216 .deny_content
217 .iter()
218 .map(|r| Regex::new(r).map_err(|e| Error::Config(format!("guard.deny_content: {e}"))))
219 .collect::<Result<Vec<_>>>()?;
220 Ok(Self {
221 names: names.build().map_err(|e| Error::Config(e.to_string()))?,
222 extra_paths: extra.build().map_err(|e| Error::Config(e.to_string()))?,
223 content_names: DENIED_CONTENT.iter().map(|(n, _)| *n).collect(),
224 content,
225 extra_content,
226 })
227 }
228
229 pub fn check_path(&self, path: &Path) -> Option<Finding> {
231 for comp in path.components() {
232 if let Component::Normal(c) = comp {
233 let s = c.to_string_lossy();
234 if DENIED_DIRS.iter().any(|d| d.eq_ignore_ascii_case(&s)) {
235 return Some(Finding {
236 rule: "denied-directory",
237 detail: format!("path contains `{s}/`"),
238 });
239 }
240 }
241 }
242 let name = path
243 .file_name()
244 .map(|n| n.to_string_lossy())
245 .unwrap_or_default();
246 if self.names.is_match(name.as_ref()) {
247 return Some(Finding {
248 rule: "denied-filename",
249 detail: format!("file name `{name}`"),
250 });
251 }
252 if self.extra_paths.is_match(path) || self.extra_paths.is_match(name.as_ref()) {
253 return Some(Finding {
254 rule: "config-deny-glob",
255 detail: format!("`{}` matches guard.deny_globs", path.display()),
256 });
257 }
258 None
259 }
260
261 pub fn check_download_target(&self, rel: &Path) -> Option<Finding> {
266 if let Some(f) = self.check_path(rel) {
267 return Some(f);
268 }
269 for comp in rel.components() {
270 if let Component::Normal(c) = comp {
271 let s = c.to_string_lossy();
272 if LOCAL_TRUSTED_NAMES
273 .iter()
274 .any(|d| d.eq_ignore_ascii_case(&s))
275 {
276 return Some(Finding {
277 rule: "local-trusted-name",
278 detail: format!(
279 "`{s}` is something an editor or agent would execute or trust locally"
280 ),
281 });
282 }
283 }
284 }
285 None
286 }
287
288 pub fn check_content(&self, bytes: &[u8]) -> Option<Finding> {
290 let hits = self.content.matches(bytes);
291 if let Some(i) = hits.iter().next() {
292 return Some(Finding {
293 rule: self.content_names[i],
294 detail: "content matched".into(),
295 });
296 }
297 for (i, re) in self.extra_content.iter().enumerate() {
298 if re.is_match(bytes) {
299 return Some(Finding {
300 rule: "config-deny-content",
301 detail: format!("guard.deny_content[{i}]"),
302 });
303 }
304 }
305 None
306 }
307
308 pub fn ensure_content(&self, what: &str, bytes: &[u8]) -> Result<()> {
310 match self.check_content(bytes) {
311 Some(f) => Err(f.deny(what)),
312 None => Ok(()),
313 }
314 }
315
316 pub fn ensure_upload(&self, path: &Path, bytes: &[u8]) -> Result<()> {
318 if let Some(f) = self.check_path(path) {
319 return Err(f.deny(&format!("`{}`", path.display())));
320 }
321 self.ensure_content(&format!("the content of `{}`", path.display()), bytes)
322 }
323
324 pub fn ensure_download(&self, rel: &Path) -> Result<()> {
327 match self.check_download_target(rel) {
328 Some(f) => Err(Error::Denied(format!(
329 "download refused: `{}` (rule: {}, {}). Downloads never create credential \
330 files or anything a local tool would execute; pick another name or fetch it \
331 yourself.",
332 rel.display(),
333 f.rule,
334 f.detail
335 ))),
336 None => Ok(()),
337 }
338 }
339}
340
341#[cfg(test)]
342mod tests {
343 use super::*;
344 use std::path::PathBuf;
345
346 fn guard() -> Guard {
347 Guard::new(&GuardConfig::default()).unwrap()
348 }
349
350 #[test]
351 fn download_targets() {
352 let g = guard();
353 for p in [
354 ".claude/settings.json",
355 "proj/.vscode/tasks.json",
356 ".farhand.toml",
357 "x/.git/hooks/pre-commit",
358 "id_rsa",
359 "sub/CLAUDE.md",
360 ] {
361 assert!(g.check_download_target(Path::new(p)).is_some(), "{p}");
362 }
363 for p in ["logs/app.log", "package.json", "src/main.rs", "Makefile"] {
364 assert!(g.check_download_target(Path::new(p)).is_none(), "{p}");
365 }
366 }
367
368 #[test]
369 fn denies_well_known_files() {
370 let g = guard();
371 for p in [
372 "/x/.env",
373 "/x/.env.local",
374 "/x/prod.env",
375 "/x/server.pem",
376 "/x/id_rsa",
377 "/x/id_ed25519.pub",
378 "/home/me/.ssh/config",
379 "/home/me/.config/opencode/opencode.jsonc",
380 "/x/.mcp.json",
381 "/x/credentials.json",
382 "/x/gcp-service-account-1234.json",
383 "/x/terraform.tfstate",
384 "/x/.aws/credentials",
385 ] {
386 assert!(
387 g.check_path(&PathBuf::from(p)).is_some(),
388 "{p} should be denied"
389 );
390 }
391 }
392
393 #[test]
394 fn allows_ordinary_files() {
395 let g = guard();
396 for p in [
397 "/x/main.rs",
398 "/x/README.md",
399 "/x/env.example.md",
400 "/x/src/keyboard.rs",
401 ] {
402 assert!(
403 g.check_path(&PathBuf::from(p)).is_none(),
404 "{p} should be allowed"
405 );
406 }
407 }
408
409 #[test]
410 fn denies_secret_shapes() {
411 let g = guard();
412 let cases: &[(&str, &str)] = &[
413 (
414 "-----BEGIN OPENSSH PRIVATE KEY-----\nabc",
415 "private-key-block",
416 ),
417 (
418 "key: sk-ant-api03-abcdefghijklmnopqrstuvwxyz0123",
419 "openai-or-anthropic-key",
420 ),
421 ("ghp_abcdefghijklmnopqrstuvwxyz0123456789", "github-token"),
422 ("github_pat_11ABCDEFG0123456789abcdefghij", "github-token"),
423 ("AKIAIOSFODNN7EXAMPLE", "aws-access-key"),
424 ("xoxb-1234567890-abcdefghij", "slack-token"),
425 (
426 "Authorization: Bearer abcdefghijklmnopqrstuvwxyz",
427 "bearer-header",
428 ),
429 (
430 "postgres://user:hunter2hunter2@db.internal/app",
431 "url-with-password",
432 ),
433 (
434 "API_KEY = \"a1b2c3d4e5f6g7h8i9j0k1l2m3n4\"",
435 "credential-assignment",
436 ),
437 (
438 "\"apiKey\": \"a1b2c3d4e5f6g7h8i9j0k1l2m3n4\"",
439 "credential-assignment",
440 ),
441 ];
442 for (text, rule) in cases {
443 let f = g
444 .check_content(text.as_bytes())
445 .unwrap_or_else(|| panic!("{text} not caught"));
446 assert_eq!(f.rule, *rule, "{text}");
447 }
448 }
449
450 #[test]
451 fn allows_ordinary_code() {
452 let g = guard();
453 for text in [
454 "fn main() { println!(\"hello\"); }",
455 "let token = parse_token(input);",
456 "password = \"changeme\"",
457 "const API_KEY_HEADER: &str = \"x-api-key\";",
458 "export OPENAI_API_KEY=$(cat ~/.secrets/openai)",
459 "curl -H 'Authorization: Bearer $TOKEN' https://x",
460 "git clone https://github.com/CogFlux/farhand.git",
461 ] {
462 assert!(
463 g.check_content(text.as_bytes()).is_none(),
464 "{text} wrongly denied"
465 );
466 }
467 }
468
469 #[test]
470 fn extra_rules_apply() {
471 let cfg = GuardConfig {
472 deny_globs: vec!["*.internal".into()],
473 deny_content: vec!["ACME-[0-9]{6}".into()],
474 };
475 let g = Guard::new(&cfg).unwrap();
476 assert_eq!(
477 g.check_path(Path::new("/x/notes.internal")).unwrap().rule,
478 "config-deny-glob"
479 );
480 assert_eq!(
481 g.check_content(b"ref ACME-123456").unwrap().rule,
482 "config-deny-content"
483 );
484 }
485}