1use std::path::{Component, Path};
18
19use globset::{Glob, GlobBuilder, 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 any_case(pattern: &str) -> std::result::Result<Glob, globset::Error> {
202 GlobBuilder::new(pattern).case_insensitive(true).build()
203}
204
205fn read_up_to(r: &mut impl std::io::Read, buf: &mut [u8]) -> std::io::Result<usize> {
207 let mut filled = 0;
208 while filled < buf.len() {
209 match r.read(&mut buf[filled..]) {
210 Ok(0) => break,
211 Ok(n) => filled += n,
212 Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
213 Err(e) => return Err(e),
214 }
215 }
216 Ok(filled)
217}
218
219pub struct Guard {
220 names: GlobSet,
221 extra_paths: GlobSet,
222 content_names: Vec<&'static str>,
223 content: RegexSet,
224 extra_content: Vec<Regex>,
225}
226
227impl Guard {
228 pub fn new(cfg: &GuardConfig) -> Result<Self> {
229 let mut names = GlobSetBuilder::new();
232 for n in DENIED_NAMES {
233 names.add(any_case(n).map_err(|e| Error::Config(e.to_string()))?);
234 }
235 let mut extra = GlobSetBuilder::new();
236 for g in &cfg.deny_globs {
237 extra.add(any_case(g).map_err(|e| Error::Config(format!("guard.deny_globs: {e}")))?);
238 }
239 let content = RegexSetBuilder::new(DENIED_CONTENT.iter().map(|(_, r)| *r))
244 .unicode(false)
245 .build()
246 .map_err(|e| Error::Config(e.to_string()))?;
247 let extra_content = cfg
248 .deny_content
249 .iter()
250 .map(|r| Regex::new(r).map_err(|e| Error::Config(format!("guard.deny_content: {e}"))))
251 .collect::<Result<Vec<_>>>()?;
252 Ok(Self {
253 names: names.build().map_err(|e| Error::Config(e.to_string()))?,
254 extra_paths: extra.build().map_err(|e| Error::Config(e.to_string()))?,
255 content_names: DENIED_CONTENT.iter().map(|(n, _)| *n).collect(),
256 content,
257 extra_content,
258 })
259 }
260
261 pub fn check_path(&self, path: &Path) -> Option<Finding> {
263 for comp in path.components() {
264 if let Component::Normal(c) = comp {
265 let s = c.to_string_lossy();
266 if DENIED_DIRS.iter().any(|d| d.eq_ignore_ascii_case(&s)) {
267 return Some(Finding {
268 rule: "denied-directory",
269 detail: format!("path contains `{s}/`"),
270 });
271 }
272 }
273 }
274 let name = path
275 .file_name()
276 .map(|n| n.to_string_lossy())
277 .unwrap_or_default();
278 if self.names.is_match(name.as_ref()) {
279 return Some(Finding {
280 rule: "denied-filename",
281 detail: format!("file name `{name}`"),
282 });
283 }
284 if self.extra_paths.is_match(path) || self.extra_paths.is_match(name.as_ref()) {
285 return Some(Finding {
286 rule: "config-deny-glob",
287 detail: format!("`{}` matches guard.deny_globs", path.display()),
288 });
289 }
290 None
291 }
292
293 pub fn check_download_target(&self, rel: &Path) -> Option<Finding> {
298 if let Some(f) = self.check_path(rel) {
299 return Some(f);
300 }
301 for comp in rel.components() {
302 if let Component::Normal(c) = comp {
303 let s = c.to_string_lossy();
304 if LOCAL_TRUSTED_NAMES
305 .iter()
306 .any(|d| d.eq_ignore_ascii_case(&s))
307 {
308 return Some(Finding {
309 rule: "local-trusted-name",
310 detail: format!(
311 "`{s}` is something an editor or agent would execute or trust locally"
312 ),
313 });
314 }
315 }
316 }
317 None
318 }
319
320 pub fn check_content(&self, bytes: &[u8]) -> Option<Finding> {
322 let hits = self.content.matches(bytes);
323 if let Some(i) = hits.iter().next() {
324 return Some(Finding {
325 rule: self.content_names[i],
326 detail: "content matched".into(),
327 });
328 }
329 for (i, re) in self.extra_content.iter().enumerate() {
330 if re.is_match(bytes) {
331 return Some(Finding {
332 rule: "config-deny-content",
333 detail: format!("guard.deny_content[{i}]"),
334 });
335 }
336 }
337 None
338 }
339
340 pub fn ensure_content(&self, what: &str, bytes: &[u8]) -> Result<()> {
342 match self.check_content(bytes) {
343 Some(f) => Err(f.deny(what)),
344 None => Ok(()),
345 }
346 }
347
348 pub fn ensure_upload(&self, path: &Path, bytes: &[u8]) -> Result<()> {
350 if let Some(f) = self.check_path(path) {
351 return Err(f.deny(&format!("`{}`", path.display())));
352 }
353 self.ensure_content(&format!("the content of `{}`", path.display()), bytes)
354 }
355
356 pub fn ensure_upload_file(&self, path: &Path) -> Result<()> {
360 self.ensure_upload_reader(path, &mut std::fs::File::open(path)?)
361 }
362
363 pub fn ensure_upload_reader(&self, path: &Path, file: &mut impl std::io::Read) -> Result<()> {
365 if let Some(f) = self.check_path(path) {
366 return Err(f.deny(&format!("`{}`", path.display())));
367 }
368 let mut window: Vec<u8> = Vec::with_capacity(SCAN_CHUNK + SCAN_OVERLAP);
369 loop {
370 let keep = window.len().saturating_sub(SCAN_OVERLAP);
371 window.drain(..keep);
372 let start = window.len();
373 window.resize(start + SCAN_CHUNK, 0);
374 let n = read_up_to(file, &mut window[start..])?;
375 window.truncate(start + n);
376 if n == 0 {
377 return Ok(());
378 }
379 self.ensure_content(&format!("the content of `{}`", path.display()), &window)?;
380 }
381 }
382
383 pub fn stream(&self, path: &Path) -> StreamGuard<'_> {
385 StreamGuard {
386 guard: self,
387 what: format!("the content of `{}`", path.display()),
388 held: Vec::new(),
389 }
390 }
391
392 pub fn ensure_download(&self, rel: &Path) -> Result<()> {
395 match self.check_download_target(rel) {
396 Some(f) => Err(Error::Denied(format!(
397 "download refused: `{}` (rule: {}, {}). Downloads never create credential \
398 files or anything a local tool would execute; pick another name or fetch it \
399 yourself.",
400 rel.display(),
401 f.rule,
402 f.detail
403 ))),
404 None => Ok(()),
405 }
406 }
407}
408
409pub struct StreamGuard<'g> {
416 guard: &'g Guard,
417 what: String,
418 held: Vec<u8>,
419}
420
421impl StreamGuard<'_> {
422 pub fn feed(&mut self, data: &[u8]) -> Result<Vec<u8>> {
424 self.held.extend_from_slice(data);
425 self.guard.ensure_content(&self.what, &self.held)?;
426 let release = self.held.len().saturating_sub(SCAN_OVERLAP);
427 Ok(self.held.drain(..release).collect())
428 }
429
430 pub fn finish(self) -> Vec<u8> {
433 self.held
434 }
435}
436
437#[cfg(test)]
438mod tests {
439 use super::*;
440 use std::path::PathBuf;
441
442 fn guard() -> Guard {
443 Guard::new(&GuardConfig::default()).unwrap()
444 }
445
446 #[test]
447 fn stream_releases_nothing_a_later_chunk_could_complete() {
448 let g = guard();
449 let path = Path::new("data.bin");
450 let mut first = vec![b'x'; SCAN_CHUNK];
451 first.extend_from_slice(b" AKIAABCDEFGH");
452 let mut s = g.stream(path);
453 let sent = s.feed(&first).unwrap();
454 assert_eq!(sent.len(), first.len() - SCAN_OVERLAP);
455 assert!(!sent.windows(4).any(|w| w == b"AKIA"));
456 let err = s.feed(b"IJKLMNOP and more").unwrap_err().to_string();
457 assert!(err.contains("aws-access-key"), "{err}");
458
459 let mut s = g.stream(path);
461 let mut out = s.feed(&first[..100]).unwrap();
462 out.extend(s.feed(&vec![b'y'; SCAN_CHUNK]).unwrap());
463 out.extend(s.finish());
464 assert_eq!(out.len(), 100 + SCAN_CHUNK);
465 assert_eq!(&out[..100], &first[..100]);
466 }
467
468 #[test]
469 fn scans_large_files_across_window_boundaries() {
470 let g = guard();
471 let dir = std::env::temp_dir().join(format!("farhand-guard-{}", std::process::id()));
472 std::fs::create_dir_all(&dir).unwrap();
473 let clean = dir.join("clean.bin");
474 std::fs::write(&clean, vec![b'x'; SCAN_CHUNK * 2 + 17]).unwrap();
475 assert!(g.ensure_upload_file(&clean).is_ok());
476 let mut bytes = vec![b'x'; SCAN_CHUNK - 8];
478 bytes.extend_from_slice(b" AKIAABCDEFGHIJKLMNOP ");
479 bytes.extend(std::iter::repeat_n(b'x', SCAN_CHUNK));
480 let dirty = dir.join("dirty.bin");
481 std::fs::write(&dirty, &bytes).unwrap();
482 let err = g.ensure_upload_file(&dirty).unwrap_err().to_string();
483 assert!(err.contains("aws-access-key"), "{err}");
484 std::fs::remove_dir_all(&dir).unwrap();
485 }
486
487 #[test]
488 fn names_match_in_any_case() {
489 let g = Guard::new(&GuardConfig {
490 deny_globs: vec!["*.corp".into()],
491 ..Default::default()
492 })
493 .unwrap();
494 for name in [
497 "OPENCODE.JSONC",
498 "OpenCode.json",
499 ".ENV",
500 ".Env.Local",
501 "ID_RSA",
502 "Server.PEM",
503 ".FARHAND.TOML",
504 ".MCP.json",
505 "Notes.CORP",
506 ] {
507 let rel = PathBuf::from("dl").join(name);
508 assert!(g.check_path(&rel).is_some(), "{name}");
509 assert!(g.check_download_target(&rel).is_some(), "{name}");
510 }
511 assert!(g.check_path(Path::new("dl/README.md")).is_none());
512 }
513
514 #[test]
515 fn download_targets() {
516 let g = guard();
517 for p in [
518 ".claude/settings.json",
519 "proj/.vscode/tasks.json",
520 ".farhand.toml",
521 "x/.git/hooks/pre-commit",
522 "id_rsa",
523 "sub/CLAUDE.md",
524 ] {
525 assert!(g.check_download_target(Path::new(p)).is_some(), "{p}");
526 }
527 for p in ["logs/app.log", "package.json", "src/main.rs", "Makefile"] {
528 assert!(g.check_download_target(Path::new(p)).is_none(), "{p}");
529 }
530 }
531
532 #[test]
533 fn denies_well_known_files() {
534 let g = guard();
535 for p in [
536 "/x/.env",
537 "/x/.env.local",
538 "/x/prod.env",
539 "/x/server.pem",
540 "/x/id_rsa",
541 "/x/id_ed25519.pub",
542 "/home/me/.ssh/config",
543 "/home/me/.config/opencode/opencode.jsonc",
544 "/x/.mcp.json",
545 "/x/credentials.json",
546 "/x/gcp-service-account-1234.json",
547 "/x/terraform.tfstate",
548 "/x/.aws/credentials",
549 ] {
550 assert!(
551 g.check_path(&PathBuf::from(p)).is_some(),
552 "{p} should be denied"
553 );
554 }
555 }
556
557 #[test]
558 fn allows_ordinary_files() {
559 let g = guard();
560 for p in [
561 "/x/main.rs",
562 "/x/README.md",
563 "/x/env.example.md",
564 "/x/src/keyboard.rs",
565 ] {
566 assert!(
567 g.check_path(&PathBuf::from(p)).is_none(),
568 "{p} should be allowed"
569 );
570 }
571 }
572
573 #[test]
574 fn denies_secret_shapes() {
575 let g = guard();
576 let cases: &[(&str, &str)] = &[
577 (
578 "-----BEGIN OPENSSH PRIVATE KEY-----\nabc",
579 "private-key-block",
580 ),
581 (
582 "key: sk-ant-api03-abcdefghijklmnopqrstuvwxyz0123",
583 "openai-or-anthropic-key",
584 ),
585 ("ghp_abcdefghijklmnopqrstuvwxyz0123456789", "github-token"),
586 ("github_pat_11ABCDEFG0123456789abcdefghij", "github-token"),
587 ("AKIAIOSFODNN7EXAMPLE", "aws-access-key"),
588 ("xoxb-1234567890-abcdefghij", "slack-token"),
589 (
590 "Authorization: Bearer abcdefghijklmnopqrstuvwxyz",
591 "bearer-header",
592 ),
593 (
594 "postgres://user:hunter2hunter2@db.internal/app",
595 "url-with-password",
596 ),
597 (
598 "API_KEY = \"a1b2c3d4e5f6g7h8i9j0k1l2m3n4\"",
599 "credential-assignment",
600 ),
601 (
602 "\"apiKey\": \"a1b2c3d4e5f6g7h8i9j0k1l2m3n4\"",
603 "credential-assignment",
604 ),
605 ];
606 for (text, rule) in cases {
607 let f = g
608 .check_content(text.as_bytes())
609 .unwrap_or_else(|| panic!("{text} not caught"));
610 assert_eq!(f.rule, *rule, "{text}");
611 }
612 }
613
614 #[test]
615 fn allows_ordinary_code() {
616 let g = guard();
617 for text in [
618 "fn main() { println!(\"hello\"); }",
619 "let token = parse_token(input);",
620 "password = \"changeme\"",
621 "const API_KEY_HEADER: &str = \"x-api-key\";",
622 "export OPENAI_API_KEY=$(cat ~/.secrets/openai)",
623 "curl -H 'Authorization: Bearer $TOKEN' https://x",
624 "git clone https://github.com/CogFlux/farhand.git",
625 ] {
626 assert!(
627 g.check_content(text.as_bytes()).is_none(),
628 "{text} wrongly denied"
629 );
630 }
631 }
632
633 #[test]
634 fn extra_rules_apply() {
635 let cfg = GuardConfig {
636 deny_globs: vec!["*.internal".into()],
637 deny_content: vec!["ACME-[0-9]{6}".into()],
638 };
639 let g = Guard::new(&cfg).unwrap();
640 assert_eq!(
641 g.check_path(Path::new("/x/notes.internal")).unwrap().rule,
642 "config-deny-glob"
643 );
644 assert_eq!(
645 g.check_content(b"ref ACME-123456").unwrap().rule,
646 "config-deny-content"
647 );
648 }
649}