Skip to main content

farhand_core/
guard.rs

1//! The secret guard. Nothing leaves this machine for the remote without
2//! passing through here: command text, file contents written or edited,
3//! and every file an upload would send.
4//!
5//! Two checks:
6//! - **path**: the local file's name or any directory on its path marks it
7//!   as credential material (`.env`, `*.pem`, `~/.ssh/...`, agent configs
8//!   that embed API keys, ...);
9//! - **content**: the bytes contain something shaped like a credential
10//!   (private-key blocks, well-known token prefixes, a `key = "..."`
11//!   assignment whose value is a literal, not a variable name, and not a
12//!   low-entropy placeholder).
13//!
14//! The guard errs toward refusing. A false positive costs one retry with a
15//! clear message; a false negative puts a credential on a machine the user
16//! chose not to trust with it.
17
18use 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
26/// File names and globs that are credential material by convention.
27/// Matched against the basename.
28const 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
89/// Directory components that mark everything beneath them as off-limits.
90const 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
113/// Content shapes that are credentials. Each entry is `(rule name, regex)`;
114/// the name is what the audit log and the refusal message carry, never the
115/// matched text.
116const 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
152/// `key = value` with a credential-like key. A regex hit is only a
153/// candidate: [`assigned_value_is_secret`] decides on the value, so
154/// `token = args.token_from_command_line` is code, not a credential.
155/// Group 1 is the opening quote of the value, if any; group 2 the value.
156const 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
158/// Below this many bits per byte a value is a placeholder (`xxxx…`,
159/// `0000…`), not a credential. Random tokens of this length score above
160/// 4; a human passphrase of 24+ characters still scores above 3.
161const MIN_SECRET_ENTROPY: f64 = 3.0;
162
163/// Names that may never be *created* locally by a download, on top of
164/// [`DENIED_NAMES`] and [`DENIED_DIRS`]: files and directories an editor,
165/// shell or agent executes or trusts when it opens the folder. A remote that
166/// plants one of these inside the allowlist would otherwise get code run
167/// locally, or repoint FarHand itself. Ordinary project files (package.json,
168/// Makefile) are not listed: nothing runs them until the user does.
169const 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
204/// How much of a file `ensure_upload_file` looks at per step.
205const SCAN_CHUNK: usize = 1024 * 1024;
206/// Bytes carried over between steps. Every `DENIED_CONTENT` match, and any
207/// credential a `deny_content` pattern is meant to catch, is far shorter.
208const 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
214/// Fill `buf` from `r` as far as the data goes; short only at EOF.
215fn 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        // Case-insensitive: on the default macOS and Windows volumes
240        // `OPENCODE.JSONC` is the same file as `opencode.jsonc`.
241        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        // ASCII semantics for `\b` and `\s`: every credential shape above is
250        // ASCII, and a Unicode word boundary makes the regex engine abandon
251        // its DFA on each non-ASCII byte, which turns scanning a binary
252        // file into a crawl (4 MB/s against 600 MB/s in practice).
253        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    /// Why a local path may not be uploaded, if it may not.
277    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    /// Why a download may not land at this local path (given relative to
309    /// the allowlisted root), if it may not. Credential-shaped names are
310    /// refused as on upload, plus anything an editor or agent would trust
311    /// or execute when opening the folder.
312    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    /// Why these bytes may not be sent, if they may not.
336    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    /// Whether any `CREDENTIAL_ASSIGNMENT` hit in `bytes` assigns a secret.
359    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    /// Refuse if `bytes` (described as `what` in the message) carry a secret.
366    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    /// Refuse if `path` or its `bytes` may not be uploaded.
374    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    /// `ensure_upload` for a file of any size: the content is scanned in
382    /// windows that overlap by `SCAN_OVERLAP`, so a secret sitting on a
383    /// window boundary is still seen and memory stays flat.
384    pub fn ensure_upload_file(&self, path: &Path) -> Result<()> {
385        self.ensure_upload_reader(path, &mut std::fs::File::open(path)?)
386    }
387
388    /// `ensure_upload_file` for a file already open as `file`.
389    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    /// A scanner for content as it is sent: see [`StreamGuard`].
409    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    /// Refuse if a download may not be written to `rel` (relative to the
418    /// allowlisted root it lands in).
419    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
434/// Whether the value of a credential-like assignment is a credential.
435/// A quoted value is a literal, so only placeholders pass. An unquoted one
436/// may be code (`process.env.GITHUB_TOKEN`, `args.password_from_cli`), so a
437/// value that reads as a dotted chain of identifiers passes too; an
438/// unquoted `.env` or YAML secret is random, not made of words.
439fn 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
446/// Shannon entropy of `bytes`, in bits per byte.
447fn 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
463/// Whether `value` splits on `.` and `_` into words (`snake`, `SHOUT`,
464/// `camelCase`, `PascalCase`, up to three trailing digits as in `sha256`),
465/// with at least two words in all. One long unbroken run of letters is not
466/// an identifier anyone writes; it is more likely a random password.
467fn 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
481/// How many words one identifier segment holds, or `None` if it is not
482/// made of words. Random mixed-case text fails on the first two capitals
483/// in a row after its start, or on digits anywhere but the end.
484fn 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    // A leading run of capitals and/or lowercase (`HTTPServer`, `get`,
491    // `TOKEN`) is the first word; each later word is a capital followed
492    // by lowercase.
493    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
513/// Checks an upload in the form it actually leaves in, chunk by chunk, so
514/// a file that changes after its first scan cannot send what that scan
515/// never saw. The last `SCAN_OVERLAP` bytes are held back until the next
516/// chunk has been scanned with them, so a secret spanning two chunks is
517/// seen whole before any of it is released — the same windows
518/// `ensure_upload_file` uses.
519pub struct StreamGuard<'g> {
520    guard: &'g Guard,
521    what: String,
522    held: Vec<u8>,
523}
524
525impl StreamGuard<'_> {
526    /// Add `data`; returns the bytes that may now be sent.
527    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    /// The bytes still held back at the end of the data; they were
535    /// scanned by the last `feed`.
536    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        // Clean data comes out whole, in order.
564        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        // A key that straddles the first window boundary.
581        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        // None of these exist: the check is on the name a download would
599        // create or an upload would send.
600        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            // Quoted literals are refused even when they read as words.
710            (
711                "password = \"correct_horse_battery_staple\"",
712                "credential-assignment",
713            ),
714            (
715                "client_secret: 'aBcDeFgHiJkLmNoPqRsTuVwXyZ'",
716                "credential-assignment",
717            ),
718            // Unquoted `.env` / YAML values that are not identifiers.
719            ("TOKEN=Zx9Qm2Lp7Rt4Vb8Nc1Kd6Hf3Jg5", "credential-assignment"),
720            ("PASSWORD=kqzjwhxmvtrplbnfgsdcyeua", "credential-assignment"),
721            ("secret: QWERTYUIOPASDFGHJKLZXCVB", "credential-assignment"),
722            (
723                "access_token: dGhpcyBpcyBub3QgYSByZWFsIHRva2Vu",
724                "credential-assignment",
725            ),
726            // A harmless assignment earlier in the text does not hide a
727            // real one after it.
728            (
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            // Variable names and attribute chains, not values.
753            "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            // Placeholders.
761            "\"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}