1use std::path::{Component, Path};
19
20use globset::{Glob, GlobBuilder, GlobSet, GlobSetBuilder};
21use regex::bytes::{Regex, RegexBuilder, RegexSet, RegexSetBuilder};
22
23use crate::config::GuardConfig;
24use crate::error::{Error, Result};
25
26const DENIED_NAMES: &[&str] = &[
29 ".env",
30 ".env.*",
31 "*.env",
32 ".envrc",
33 "*.pem",
34 "*.key",
35 "*.p12",
36 "*.pfx",
37 "*.jks",
38 "*.keystore",
39 "*.ppk",
40 "*.ovpn",
41 "*.kdbx",
42 "*.gpg",
43 "*.asc",
44 "*.secret",
45 "*.secrets",
46 "secrets.*",
47 "secret.*",
48 "id_rsa*",
49 "id_dsa*",
50 "id_ecdsa*",
51 "id_ed25519*",
52 "authorized_keys",
53 ".netrc",
54 "_netrc",
55 ".npmrc",
56 ".pypirc",
57 ".yarnrc",
58 ".git-credentials",
59 ".htpasswd",
60 ".vault-token",
61 ".boto",
62 ".s3cfg",
63 ".pgpass",
64 ".my.cnf",
65 "credentials",
66 "credentials.*",
67 "*credentials*.json",
68 "*service-account*.json",
69 "*service_account*.json",
70 "client_secret*.json",
71 "*.tfstate",
72 "*.tfstate.*",
73 "*.tfvars",
74 "terraform.rc",
75 ".terraformrc",
76 "kubeconfig",
77 "opencode.json",
78 "opencode.jsonc",
79 ".farhand.toml",
80 ".mcp.json",
81 "mcp.json",
82 "claude_desktop_config.json",
83 ".claude.json",
84 "auth.json",
85 "hosts.yml",
86 "wallet.dat",
87];
88
89const DENIED_DIRS: &[&str] = &[
91 ".ssh",
92 ".aws",
93 ".azure",
94 ".gcloud",
95 ".gnupg",
96 ".kube",
97 ".docker",
98 ".config",
99 ".claude",
100 ".opencode",
101 ".codex",
102 ".cursor",
103 ".gemini",
104 ".vscode-server",
105 ".password-store",
106 ".secrets",
107 "secrets",
108 ".1password",
109 "Keychains",
110 ".Trash",
111];
112
113const DENIED_CONTENT: &[(&str, &str)] = &[
117 (
118 "private-key-block",
119 r"-----BEGIN [A-Z0-9 ]*PRIVATE KEY( BLOCK)?-----",
120 ),
121 (
122 "openai-or-anthropic-key",
123 r"\bsk-(ant-|proj-|svcacct-)?[A-Za-z0-9_-]{20,}",
124 ),
125 (
126 "github-token",
127 r"\b(gh[pousr]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{22,})",
128 ),
129 ("aws-access-key", r"\b(AKIA|ASIA)[0-9A-Z]{16}\b"),
130 ("google-api-key", r"\bAIza[0-9A-Za-z_-]{35}\b"),
131 ("slack-token", r"\bxox[abprs]-[0-9A-Za-z-]{10,}"),
132 ("stripe-key", r"\b[sr]k_(live|test)_[0-9A-Za-z]{20,}"),
133 (
134 "jwt",
135 r"\beyJ[A-Za-z0-9_-]{15,}\.[A-Za-z0-9_-]{15,}\.[A-Za-z0-9_-]{10,}",
136 ),
137 ("huggingface-token", r"\bhf_[A-Za-z0-9]{30,}"),
138 ("npm-token", r"\bnpm_[A-Za-z0-9]{36}\b"),
139 ("gitlab-token", r"\bglpat-[A-Za-z0-9_-]{20,}"),
140 ("openrouter-key", r"\bsk-or-v1-[0-9a-f]{40,}"),
141 (
142 "bearer-header",
143 r"(?i)\bauthorization\s*[:=]\s*['\x22]?\s*(bearer|basic|token)\s+[A-Za-z0-9._+/=-]{20,}",
144 ),
145 (
146 "url-with-password",
147 r"(?i)\b[a-z][a-z0-9+.-]*://[^\s/:@]+:[^\s/@]{6,}@[^\s/]+",
148 ),
149 ("credential-assignment", CREDENTIAL_ASSIGNMENT),
150];
151
152const CREDENTIAL_ASSIGNMENT: &str = 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,})";
157
158const MIN_SECRET_ENTROPY: f64 = 3.0;
162
163const LOCAL_TRUSTED_NAMES: &[&str] = &[
170 ".farhand.toml",
171 ".vscode",
172 ".idea",
173 ".git",
174 ".husky",
175 ".devcontainer",
176 ".envrc",
177 ".direnv",
178 ".mise.toml",
179 "mise.toml",
180 ".pre-commit-config.yaml",
181 "CLAUDE.md",
182 "AGENTS.md",
183 ".cursorrules",
184 ".windsurfrules",
185];
186
187#[derive(Debug, Clone)]
188pub struct Finding {
189 pub rule: &'static str,
190 pub detail: String,
191}
192
193impl Finding {
194 fn deny(&self, what: &str) -> Error {
195 Error::Denied(format!(
196 "{what} looks like credential material (rule: {}, {}) and will not be sent to the \
197 remote. If this is not a secret, rename it or rewrite the value; FarHand never \
198 uploads credentials.",
199 self.rule, self.detail
200 ))
201 }
202}
203
204const SCAN_CHUNK: usize = 1024 * 1024;
206const SCAN_OVERLAP: usize = 64 * 1024;
209
210fn any_case(pattern: &str) -> std::result::Result<Glob, globset::Error> {
211 GlobBuilder::new(pattern).case_insensitive(true).build()
212}
213
214fn read_up_to(r: &mut impl std::io::Read, buf: &mut [u8]) -> std::io::Result<usize> {
216 let mut filled = 0;
217 while filled < buf.len() {
218 match r.read(&mut buf[filled..]) {
219 Ok(0) => break,
220 Ok(n) => filled += n,
221 Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
222 Err(e) => return Err(e),
223 }
224 }
225 Ok(filled)
226}
227
228pub struct Guard {
229 names: GlobSet,
230 extra_paths: GlobSet,
231 content_names: Vec<&'static str>,
232 content: RegexSet,
233 assignment: Regex,
234 extra_content: Vec<Regex>,
235}
236
237impl Guard {
238 pub fn new(cfg: &GuardConfig) -> Result<Self> {
239 let mut names = GlobSetBuilder::new();
242 for n in DENIED_NAMES {
243 names.add(any_case(n).map_err(|e| Error::Config(e.to_string()))?);
244 }
245 let mut extra = GlobSetBuilder::new();
246 for g in &cfg.deny_globs {
247 extra.add(any_case(g).map_err(|e| Error::Config(format!("guard.deny_globs: {e}")))?);
248 }
249 let content = RegexSetBuilder::new(DENIED_CONTENT.iter().map(|(_, r)| *r))
254 .unicode(false)
255 .build()
256 .map_err(|e| Error::Config(e.to_string()))?;
257 let assignment = RegexBuilder::new(CREDENTIAL_ASSIGNMENT)
258 .unicode(false)
259 .build()
260 .map_err(|e| Error::Config(e.to_string()))?;
261 let extra_content = cfg
262 .deny_content
263 .iter()
264 .map(|r| Regex::new(r).map_err(|e| Error::Config(format!("guard.deny_content: {e}"))))
265 .collect::<Result<Vec<_>>>()?;
266 Ok(Self {
267 names: names.build().map_err(|e| Error::Config(e.to_string()))?,
268 extra_paths: extra.build().map_err(|e| Error::Config(e.to_string()))?,
269 content_names: DENIED_CONTENT.iter().map(|(n, _)| *n).collect(),
270 content,
271 assignment,
272 extra_content,
273 })
274 }
275
276 pub fn check_path(&self, path: &Path) -> Option<Finding> {
278 for comp in path.components() {
279 if let Component::Normal(c) = comp {
280 let s = c.to_string_lossy();
281 if DENIED_DIRS.iter().any(|d| d.eq_ignore_ascii_case(&s)) {
282 return Some(Finding {
283 rule: "denied-directory",
284 detail: format!("path contains `{s}/`"),
285 });
286 }
287 }
288 }
289 let name = path
290 .file_name()
291 .map(|n| n.to_string_lossy())
292 .unwrap_or_default();
293 if self.names.is_match(name.as_ref()) {
294 return Some(Finding {
295 rule: "denied-filename",
296 detail: format!("file name `{name}`"),
297 });
298 }
299 if self.extra_paths.is_match(path) || self.extra_paths.is_match(name.as_ref()) {
300 return Some(Finding {
301 rule: "config-deny-glob",
302 detail: format!("`{}` matches guard.deny_globs", path.display()),
303 });
304 }
305 None
306 }
307
308 pub fn check_download_target(&self, rel: &Path) -> Option<Finding> {
313 if let Some(f) = self.check_path(rel) {
314 return Some(f);
315 }
316 for comp in rel.components() {
317 if let Component::Normal(c) = comp {
318 let s = c.to_string_lossy();
319 if LOCAL_TRUSTED_NAMES
320 .iter()
321 .any(|d| d.eq_ignore_ascii_case(&s))
322 {
323 return Some(Finding {
324 rule: "local-trusted-name",
325 detail: format!(
326 "`{s}` is something an editor or agent would execute or trust locally"
327 ),
328 });
329 }
330 }
331 }
332 None
333 }
334
335 pub fn check_content(&self, bytes: &[u8]) -> Option<Finding> {
337 for i in self.content.matches(bytes).iter() {
338 let rule = self.content_names[i];
339 if rule == "credential-assignment" && !self.has_secret_assignment(bytes) {
340 continue;
341 }
342 return Some(Finding {
343 rule,
344 detail: "content matched".into(),
345 });
346 }
347 for (i, re) in self.extra_content.iter().enumerate() {
348 if re.is_match(bytes) {
349 return Some(Finding {
350 rule: "config-deny-content",
351 detail: format!("guard.deny_content[{i}]"),
352 });
353 }
354 }
355 None
356 }
357
358 fn has_secret_assignment(&self, bytes: &[u8]) -> bool {
360 self.assignment
361 .captures_iter(bytes)
362 .any(|c| assigned_value_is_secret(&c[2], !c[1].is_empty()))
363 }
364
365 pub fn ensure_content(&self, what: &str, bytes: &[u8]) -> Result<()> {
367 match self.check_content(bytes) {
368 Some(f) => Err(f.deny(what)),
369 None => Ok(()),
370 }
371 }
372
373 pub fn ensure_upload(&self, path: &Path, bytes: &[u8]) -> Result<()> {
375 if let Some(f) = self.check_path(path) {
376 return Err(f.deny(&format!("`{}`", path.display())));
377 }
378 self.ensure_content(&format!("the content of `{}`", path.display()), bytes)
379 }
380
381 pub fn ensure_upload_file(&self, path: &Path) -> Result<()> {
385 self.ensure_upload_reader(path, &mut std::fs::File::open(path)?)
386 }
387
388 pub fn ensure_upload_reader(&self, path: &Path, file: &mut impl std::io::Read) -> Result<()> {
390 if let Some(f) = self.check_path(path) {
391 return Err(f.deny(&format!("`{}`", path.display())));
392 }
393 let mut window: Vec<u8> = Vec::with_capacity(SCAN_CHUNK + SCAN_OVERLAP);
394 loop {
395 let keep = window.len().saturating_sub(SCAN_OVERLAP);
396 window.drain(..keep);
397 let start = window.len();
398 window.resize(start + SCAN_CHUNK, 0);
399 let n = read_up_to(file, &mut window[start..])?;
400 window.truncate(start + n);
401 if n == 0 {
402 return Ok(());
403 }
404 self.ensure_content(&format!("the content of `{}`", path.display()), &window)?;
405 }
406 }
407
408 pub fn stream(&self, path: &Path) -> StreamGuard<'_> {
410 StreamGuard {
411 guard: self,
412 what: format!("the content of `{}`", path.display()),
413 held: Vec::new(),
414 }
415 }
416
417 pub fn ensure_download(&self, rel: &Path) -> Result<()> {
420 match self.check_download_target(rel) {
421 Some(f) => Err(Error::Denied(format!(
422 "download refused: `{}` (rule: {}, {}). Downloads never create credential \
423 files or anything a local tool would execute; pick another name or fetch it \
424 yourself.",
425 rel.display(),
426 f.rule,
427 f.detail
428 ))),
429 None => Ok(()),
430 }
431 }
432}
433
434fn assigned_value_is_secret(value: &[u8], quoted: bool) -> bool {
440 if entropy(value) < MIN_SECRET_ENTROPY {
441 return false;
442 }
443 quoted || !reads_as_identifiers(value)
444}
445
446fn entropy(bytes: &[u8]) -> f64 {
448 let mut counts = [0usize; 256];
449 for &b in bytes {
450 counts[b as usize] += 1;
451 }
452 let len = bytes.len() as f64;
453 counts
454 .iter()
455 .filter(|&&c| c > 0)
456 .map(|&c| {
457 let p = c as f64 / len;
458 -p * p.log2()
459 })
460 .sum()
461}
462
463fn reads_as_identifiers(value: &[u8]) -> bool {
468 let mut words = 0;
469 for seg in value.split(|&b| b == b'.' || b == b'_') {
470 if seg.is_empty() {
471 continue;
472 }
473 match word_count(seg) {
474 Some(n) => words += n,
475 None => return false,
476 }
477 }
478 words >= 2
479}
480
481fn word_count(seg: &[u8]) -> Option<usize> {
485 let digits = seg.iter().rev().take_while(|b| b.is_ascii_digit()).count();
486 let body = &seg[..seg.len() - digits];
487 if digits > 3 || body.is_empty() || !body.iter().all(u8::is_ascii_alphabetic) {
488 return None;
489 }
490 let mut i = body.iter().take_while(|b| b.is_ascii_uppercase()).count();
494 i += body[i..]
495 .iter()
496 .take_while(|b| b.is_ascii_lowercase())
497 .count();
498 let mut words = 1;
499 while i < body.len() {
500 let lower = body[i + 1..]
501 .iter()
502 .take_while(|b| b.is_ascii_lowercase())
503 .count();
504 if lower == 0 {
505 return None;
506 }
507 i += 1 + lower;
508 words += 1;
509 }
510 Some(words)
511}
512
513pub struct StreamGuard<'g> {
520 guard: &'g Guard,
521 what: String,
522 held: Vec<u8>,
523}
524
525impl StreamGuard<'_> {
526 pub fn feed(&mut self, data: &[u8]) -> Result<Vec<u8>> {
528 self.held.extend_from_slice(data);
529 self.guard.ensure_content(&self.what, &self.held)?;
530 let release = self.held.len().saturating_sub(SCAN_OVERLAP);
531 Ok(self.held.drain(..release).collect())
532 }
533
534 pub fn finish(self) -> Vec<u8> {
537 self.held
538 }
539}
540
541#[cfg(test)]
542mod tests {
543 use super::*;
544 use std::path::PathBuf;
545
546 fn guard() -> Guard {
547 Guard::new(&GuardConfig::default()).unwrap()
548 }
549
550 #[test]
551 fn stream_releases_nothing_a_later_chunk_could_complete() {
552 let g = guard();
553 let path = Path::new("data.bin");
554 let mut first = vec![b'x'; SCAN_CHUNK];
555 first.extend_from_slice(b" AKIAABCDEFGH");
556 let mut s = g.stream(path);
557 let sent = s.feed(&first).unwrap();
558 assert_eq!(sent.len(), first.len() - SCAN_OVERLAP);
559 assert!(!sent.windows(4).any(|w| w == b"AKIA"));
560 let err = s.feed(b"IJKLMNOP and more").unwrap_err().to_string();
561 assert!(err.contains("aws-access-key"), "{err}");
562
563 let mut s = g.stream(path);
565 let mut out = s.feed(&first[..100]).unwrap();
566 out.extend(s.feed(&vec![b'y'; SCAN_CHUNK]).unwrap());
567 out.extend(s.finish());
568 assert_eq!(out.len(), 100 + SCAN_CHUNK);
569 assert_eq!(&out[..100], &first[..100]);
570 }
571
572 #[test]
573 fn scans_large_files_across_window_boundaries() {
574 let g = guard();
575 let dir = std::env::temp_dir().join(format!("farhand-guard-{}", std::process::id()));
576 std::fs::create_dir_all(&dir).unwrap();
577 let clean = dir.join("clean.bin");
578 std::fs::write(&clean, vec![b'x'; SCAN_CHUNK * 2 + 17]).unwrap();
579 assert!(g.ensure_upload_file(&clean).is_ok());
580 let mut bytes = vec![b'x'; SCAN_CHUNK - 8];
582 bytes.extend_from_slice(b" AKIAABCDEFGHIJKLMNOP ");
583 bytes.extend(std::iter::repeat_n(b'x', SCAN_CHUNK));
584 let dirty = dir.join("dirty.bin");
585 std::fs::write(&dirty, &bytes).unwrap();
586 let err = g.ensure_upload_file(&dirty).unwrap_err().to_string();
587 assert!(err.contains("aws-access-key"), "{err}");
588 std::fs::remove_dir_all(&dir).unwrap();
589 }
590
591 #[test]
592 fn names_match_in_any_case() {
593 let g = Guard::new(&GuardConfig {
594 deny_globs: vec!["*.corp".into()],
595 ..Default::default()
596 })
597 .unwrap();
598 for name in [
601 "OPENCODE.JSONC",
602 "OpenCode.json",
603 ".ENV",
604 ".Env.Local",
605 "ID_RSA",
606 "Server.PEM",
607 ".FARHAND.TOML",
608 ".MCP.json",
609 "Notes.CORP",
610 ] {
611 let rel = PathBuf::from("dl").join(name);
612 assert!(g.check_path(&rel).is_some(), "{name}");
613 assert!(g.check_download_target(&rel).is_some(), "{name}");
614 }
615 assert!(g.check_path(Path::new("dl/README.md")).is_none());
616 }
617
618 #[test]
619 fn download_targets() {
620 let g = guard();
621 for p in [
622 ".claude/settings.json",
623 "proj/.vscode/tasks.json",
624 ".farhand.toml",
625 "x/.git/hooks/pre-commit",
626 "id_rsa",
627 "sub/CLAUDE.md",
628 ] {
629 assert!(g.check_download_target(Path::new(p)).is_some(), "{p}");
630 }
631 for p in ["logs/app.log", "package.json", "src/main.rs", "Makefile"] {
632 assert!(g.check_download_target(Path::new(p)).is_none(), "{p}");
633 }
634 }
635
636 #[test]
637 fn denies_well_known_files() {
638 let g = guard();
639 for p in [
640 "/x/.env",
641 "/x/.env.local",
642 "/x/prod.env",
643 "/x/server.pem",
644 "/x/id_rsa",
645 "/x/id_ed25519.pub",
646 "/home/me/.ssh/config",
647 "/home/me/.config/opencode/opencode.jsonc",
648 "/x/.mcp.json",
649 "/x/credentials.json",
650 "/x/gcp-service-account-1234.json",
651 "/x/terraform.tfstate",
652 "/x/.aws/credentials",
653 ] {
654 assert!(
655 g.check_path(&PathBuf::from(p)).is_some(),
656 "{p} should be denied"
657 );
658 }
659 }
660
661 #[test]
662 fn allows_ordinary_files() {
663 let g = guard();
664 for p in [
665 "/x/main.rs",
666 "/x/README.md",
667 "/x/env.example.md",
668 "/x/src/keyboard.rs",
669 ] {
670 assert!(
671 g.check_path(&PathBuf::from(p)).is_none(),
672 "{p} should be allowed"
673 );
674 }
675 }
676
677 #[test]
678 fn denies_secret_shapes() {
679 let g = guard();
680 let cases: &[(&str, &str)] = &[
681 (
682 "-----BEGIN OPENSSH PRIVATE KEY-----\nabc",
683 "private-key-block",
684 ),
685 (
686 "key: sk-ant-api03-abcdefghijklmnopqrstuvwxyz0123",
687 "openai-or-anthropic-key",
688 ),
689 ("ghp_abcdefghijklmnopqrstuvwxyz0123456789", "github-token"),
690 ("github_pat_11ABCDEFG0123456789abcdefghij", "github-token"),
691 ("AKIAIOSFODNN7EXAMPLE", "aws-access-key"),
692 ("xoxb-1234567890-abcdefghij", "slack-token"),
693 (
694 "Authorization: Bearer abcdefghijklmnopqrstuvwxyz",
695 "bearer-header",
696 ),
697 (
698 "postgres://user:hunter2hunter2@db.internal/app",
699 "url-with-password",
700 ),
701 (
702 "API_KEY = \"a1b2c3d4e5f6g7h8i9j0k1l2m3n4\"",
703 "credential-assignment",
704 ),
705 (
706 "\"apiKey\": \"a1b2c3d4e5f6g7h8i9j0k1l2m3n4\"",
707 "credential-assignment",
708 ),
709 (
711 "password = \"correct_horse_battery_staple\"",
712 "credential-assignment",
713 ),
714 (
715 "client_secret: 'aBcDeFgHiJkLmNoPqRsTuVwXyZ'",
716 "credential-assignment",
717 ),
718 ("TOKEN=Zx9Qm2Lp7Rt4Vb8Nc1Kd6Hf3Jg5", "credential-assignment"),
720 ("PASSWORD=kqzjwhxmvtrplbnfgsdcyeua", "credential-assignment"),
721 ("secret: QWERTYUIOPASDFGHJKLZXCVB", "credential-assignment"),
722 (
723 "access_token: dGhpcyBpcyBub3QgYSByZWFsIHRva2Vu",
724 "credential-assignment",
725 ),
726 (
729 "token = challenge_response_json_token\napi_key = \"a1b2c3d4e5f6g7h8i9j0k1l2m3n4\"",
730 "credential-assignment",
731 ),
732 ];
733 for (text, rule) in cases {
734 let f = g
735 .check_content(text.as_bytes())
736 .unwrap_or_else(|| panic!("{text} not caught"));
737 assert_eq!(f.rule, *rule, "{text}");
738 }
739 }
740
741 #[test]
742 fn allows_ordinary_code() {
743 let g = guard();
744 for text in [
745 "fn main() { println!(\"hello\"); }",
746 "let token = parse_token(input);",
747 "password = \"changeme\"",
748 "const API_KEY_HEADER: &str = \"x-api-key\";",
749 "export OPENAI_API_KEY=$(cat ~/.secrets/openai)",
750 "curl -H 'Authorization: Bearer $TOKEN' https://x",
751 "git clone https://github.com/CogFlux/farhand.git",
752 "token = challenge_response_json_token",
754 "password = args.password_from_command_line",
755 "token: process.env.GITHUB_TOKEN,",
756 "secret_key = settings.SECRET_KEY_BASE_FOR_PROD",
757 "let token = getAccessTokenFromHeader(req);",
758 "password = config.db2.password_hash_sha256",
759 "access_token = self.HTTPServer.currentAccessToken",
760 "\"apiKey\": \"xxxxxxxxxxxxxxxxxxxxxxxxxxxx\"",
762 "API_KEY=000000000000000000000000",
763 ] {
764 assert!(
765 g.check_content(text.as_bytes()).is_none(),
766 "{text} wrongly denied"
767 );
768 }
769 }
770
771 #[test]
772 fn extra_rules_apply() {
773 let cfg = GuardConfig {
774 deny_globs: vec!["*.internal".into()],
775 deny_content: vec!["ACME-[0-9]{6}".into()],
776 };
777 let g = Guard::new(&cfg).unwrap();
778 assert_eq!(
779 g.check_path(Path::new("/x/notes.internal")).unwrap().rule,
780 "config-deny-glob"
781 );
782 assert_eq!(
783 g.check_content(b"ref ACME-123456").unwrap().rule,
784 "config-deny-content"
785 );
786 }
787}