1use std::path::{Component, Path};
18
19use globset::{Glob, GlobSet, GlobSetBuilder};
20use regex::bytes::{Regex, RegexSet, RegexSetBuilder};
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
195const SCAN_CHUNK: usize = 1024 * 1024;
197const SCAN_OVERLAP: usize = 64 * 1024;
200
201fn read_up_to(r: &mut impl std::io::Read, buf: &mut [u8]) -> std::io::Result<usize> {
203 let mut filled = 0;
204 while filled < buf.len() {
205 match r.read(&mut buf[filled..]) {
206 Ok(0) => break,
207 Ok(n) => filled += n,
208 Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
209 Err(e) => return Err(e),
210 }
211 }
212 Ok(filled)
213}
214
215pub struct Guard {
216 names: GlobSet,
217 extra_paths: GlobSet,
218 content_names: Vec<&'static str>,
219 content: RegexSet,
220 extra_content: Vec<Regex>,
221}
222
223impl Guard {
224 pub fn new(cfg: &GuardConfig) -> Result<Self> {
225 let mut names = GlobSetBuilder::new();
226 for n in DENIED_NAMES {
227 names.add(Glob::new(n).map_err(|e| Error::Config(e.to_string()))?);
228 }
229 let mut extra = GlobSetBuilder::new();
230 for g in &cfg.deny_globs {
231 extra.add(Glob::new(g).map_err(|e| Error::Config(format!("guard.deny_globs: {e}")))?);
232 }
233 let content = RegexSetBuilder::new(DENIED_CONTENT.iter().map(|(_, r)| *r))
238 .unicode(false)
239 .build()
240 .map_err(|e| Error::Config(e.to_string()))?;
241 let extra_content = cfg
242 .deny_content
243 .iter()
244 .map(|r| Regex::new(r).map_err(|e| Error::Config(format!("guard.deny_content: {e}"))))
245 .collect::<Result<Vec<_>>>()?;
246 Ok(Self {
247 names: names.build().map_err(|e| Error::Config(e.to_string()))?,
248 extra_paths: extra.build().map_err(|e| Error::Config(e.to_string()))?,
249 content_names: DENIED_CONTENT.iter().map(|(n, _)| *n).collect(),
250 content,
251 extra_content,
252 })
253 }
254
255 pub fn check_path(&self, path: &Path) -> Option<Finding> {
257 for comp in path.components() {
258 if let Component::Normal(c) = comp {
259 let s = c.to_string_lossy();
260 if DENIED_DIRS.iter().any(|d| d.eq_ignore_ascii_case(&s)) {
261 return Some(Finding {
262 rule: "denied-directory",
263 detail: format!("path contains `{s}/`"),
264 });
265 }
266 }
267 }
268 let name = path
269 .file_name()
270 .map(|n| n.to_string_lossy())
271 .unwrap_or_default();
272 if self.names.is_match(name.as_ref()) {
273 return Some(Finding {
274 rule: "denied-filename",
275 detail: format!("file name `{name}`"),
276 });
277 }
278 if self.extra_paths.is_match(path) || self.extra_paths.is_match(name.as_ref()) {
279 return Some(Finding {
280 rule: "config-deny-glob",
281 detail: format!("`{}` matches guard.deny_globs", path.display()),
282 });
283 }
284 None
285 }
286
287 pub fn check_download_target(&self, rel: &Path) -> Option<Finding> {
292 if let Some(f) = self.check_path(rel) {
293 return Some(f);
294 }
295 for comp in rel.components() {
296 if let Component::Normal(c) = comp {
297 let s = c.to_string_lossy();
298 if LOCAL_TRUSTED_NAMES
299 .iter()
300 .any(|d| d.eq_ignore_ascii_case(&s))
301 {
302 return Some(Finding {
303 rule: "local-trusted-name",
304 detail: format!(
305 "`{s}` is something an editor or agent would execute or trust locally"
306 ),
307 });
308 }
309 }
310 }
311 None
312 }
313
314 pub fn check_content(&self, bytes: &[u8]) -> Option<Finding> {
316 let hits = self.content.matches(bytes);
317 if let Some(i) = hits.iter().next() {
318 return Some(Finding {
319 rule: self.content_names[i],
320 detail: "content matched".into(),
321 });
322 }
323 for (i, re) in self.extra_content.iter().enumerate() {
324 if re.is_match(bytes) {
325 return Some(Finding {
326 rule: "config-deny-content",
327 detail: format!("guard.deny_content[{i}]"),
328 });
329 }
330 }
331 None
332 }
333
334 pub fn ensure_content(&self, what: &str, bytes: &[u8]) -> Result<()> {
336 match self.check_content(bytes) {
337 Some(f) => Err(f.deny(what)),
338 None => Ok(()),
339 }
340 }
341
342 pub fn ensure_upload(&self, path: &Path, bytes: &[u8]) -> Result<()> {
344 if let Some(f) = self.check_path(path) {
345 return Err(f.deny(&format!("`{}`", path.display())));
346 }
347 self.ensure_content(&format!("the content of `{}`", path.display()), bytes)
348 }
349
350 pub fn ensure_upload_file(&self, path: &Path) -> Result<()> {
354 if let Some(f) = self.check_path(path) {
355 return Err(f.deny(&format!("`{}`", path.display())));
356 }
357 let mut file = std::fs::File::open(path)?;
358 let mut window: Vec<u8> = Vec::with_capacity(SCAN_CHUNK + SCAN_OVERLAP);
359 loop {
360 let keep = window.len().saturating_sub(SCAN_OVERLAP);
361 window.drain(..keep);
362 let start = window.len();
363 window.resize(start + SCAN_CHUNK, 0);
364 let n = read_up_to(&mut file, &mut window[start..])?;
365 window.truncate(start + n);
366 if n == 0 {
367 return Ok(());
368 }
369 self.ensure_content(&format!("the content of `{}`", path.display()), &window)?;
370 }
371 }
372
373 pub fn ensure_download(&self, rel: &Path) -> Result<()> {
376 match self.check_download_target(rel) {
377 Some(f) => Err(Error::Denied(format!(
378 "download refused: `{}` (rule: {}, {}). Downloads never create credential \
379 files or anything a local tool would execute; pick another name or fetch it \
380 yourself.",
381 rel.display(),
382 f.rule,
383 f.detail
384 ))),
385 None => Ok(()),
386 }
387 }
388}
389
390#[cfg(test)]
391mod tests {
392 use super::*;
393 use std::path::PathBuf;
394
395 fn guard() -> Guard {
396 Guard::new(&GuardConfig::default()).unwrap()
397 }
398
399 #[test]
400 fn scans_large_files_across_window_boundaries() {
401 let g = guard();
402 let dir = std::env::temp_dir().join(format!("farhand-guard-{}", std::process::id()));
403 std::fs::create_dir_all(&dir).unwrap();
404 let clean = dir.join("clean.bin");
405 std::fs::write(&clean, vec![b'x'; SCAN_CHUNK * 2 + 17]).unwrap();
406 assert!(g.ensure_upload_file(&clean).is_ok());
407 let mut bytes = vec![b'x'; SCAN_CHUNK - 8];
409 bytes.extend_from_slice(b" AKIAABCDEFGHIJKLMNOP ");
410 bytes.extend(std::iter::repeat_n(b'x', SCAN_CHUNK));
411 let dirty = dir.join("dirty.bin");
412 std::fs::write(&dirty, &bytes).unwrap();
413 let err = g.ensure_upload_file(&dirty).unwrap_err().to_string();
414 assert!(err.contains("aws-access-key"), "{err}");
415 std::fs::remove_dir_all(&dir).unwrap();
416 }
417
418 #[test]
419 fn download_targets() {
420 let g = guard();
421 for p in [
422 ".claude/settings.json",
423 "proj/.vscode/tasks.json",
424 ".farhand.toml",
425 "x/.git/hooks/pre-commit",
426 "id_rsa",
427 "sub/CLAUDE.md",
428 ] {
429 assert!(g.check_download_target(Path::new(p)).is_some(), "{p}");
430 }
431 for p in ["logs/app.log", "package.json", "src/main.rs", "Makefile"] {
432 assert!(g.check_download_target(Path::new(p)).is_none(), "{p}");
433 }
434 }
435
436 #[test]
437 fn denies_well_known_files() {
438 let g = guard();
439 for p in [
440 "/x/.env",
441 "/x/.env.local",
442 "/x/prod.env",
443 "/x/server.pem",
444 "/x/id_rsa",
445 "/x/id_ed25519.pub",
446 "/home/me/.ssh/config",
447 "/home/me/.config/opencode/opencode.jsonc",
448 "/x/.mcp.json",
449 "/x/credentials.json",
450 "/x/gcp-service-account-1234.json",
451 "/x/terraform.tfstate",
452 "/x/.aws/credentials",
453 ] {
454 assert!(
455 g.check_path(&PathBuf::from(p)).is_some(),
456 "{p} should be denied"
457 );
458 }
459 }
460
461 #[test]
462 fn allows_ordinary_files() {
463 let g = guard();
464 for p in [
465 "/x/main.rs",
466 "/x/README.md",
467 "/x/env.example.md",
468 "/x/src/keyboard.rs",
469 ] {
470 assert!(
471 g.check_path(&PathBuf::from(p)).is_none(),
472 "{p} should be allowed"
473 );
474 }
475 }
476
477 #[test]
478 fn denies_secret_shapes() {
479 let g = guard();
480 let cases: &[(&str, &str)] = &[
481 (
482 "-----BEGIN OPENSSH PRIVATE KEY-----\nabc",
483 "private-key-block",
484 ),
485 (
486 "key: sk-ant-api03-abcdefghijklmnopqrstuvwxyz0123",
487 "openai-or-anthropic-key",
488 ),
489 ("ghp_abcdefghijklmnopqrstuvwxyz0123456789", "github-token"),
490 ("github_pat_11ABCDEFG0123456789abcdefghij", "github-token"),
491 ("AKIAIOSFODNN7EXAMPLE", "aws-access-key"),
492 ("xoxb-1234567890-abcdefghij", "slack-token"),
493 (
494 "Authorization: Bearer abcdefghijklmnopqrstuvwxyz",
495 "bearer-header",
496 ),
497 (
498 "postgres://user:hunter2hunter2@db.internal/app",
499 "url-with-password",
500 ),
501 (
502 "API_KEY = \"a1b2c3d4e5f6g7h8i9j0k1l2m3n4\"",
503 "credential-assignment",
504 ),
505 (
506 "\"apiKey\": \"a1b2c3d4e5f6g7h8i9j0k1l2m3n4\"",
507 "credential-assignment",
508 ),
509 ];
510 for (text, rule) in cases {
511 let f = g
512 .check_content(text.as_bytes())
513 .unwrap_or_else(|| panic!("{text} not caught"));
514 assert_eq!(f.rule, *rule, "{text}");
515 }
516 }
517
518 #[test]
519 fn allows_ordinary_code() {
520 let g = guard();
521 for text in [
522 "fn main() { println!(\"hello\"); }",
523 "let token = parse_token(input);",
524 "password = \"changeme\"",
525 "const API_KEY_HEADER: &str = \"x-api-key\";",
526 "export OPENAI_API_KEY=$(cat ~/.secrets/openai)",
527 "curl -H 'Authorization: Bearer $TOKEN' https://x",
528 "git clone https://github.com/CogFlux/farhand.git",
529 ] {
530 assert!(
531 g.check_content(text.as_bytes()).is_none(),
532 "{text} wrongly denied"
533 );
534 }
535 }
536
537 #[test]
538 fn extra_rules_apply() {
539 let cfg = GuardConfig {
540 deny_globs: vec!["*.internal".into()],
541 deny_content: vec!["ACME-[0-9]{6}".into()],
542 };
543 let g = Guard::new(&cfg).unwrap();
544 assert_eq!(
545 g.check_path(Path::new("/x/notes.internal")).unwrap().rule,
546 "config-deny-glob"
547 );
548 assert_eq!(
549 g.check_content(b"ref ACME-123456").unwrap().rule,
550 "config-deny-content"
551 );
552 }
553}