Skip to main content

lowfat_core/
redact.rs

1//! Redaction ruleset for the `redact-secrets` pipeline processor.
2//!
3//! Patterns come from three layers, applied in order:
4//!   1. built-in defaults — a safe baseline of common secret formats
5//!   2. the global rules file  (`~/.lowfat/redact.conf`)
6//!   3. the project rules file (`redact.conf` beside `.lowfat`)
7//!
8//! Why config, not a plugin: redaction is cross-cutting (any command can
9//! leak), and an lf-filter plugin degrades to passthrough on error — for
10//! redaction that means leaking. The engine here is trusted in-process
11//! Rust; only the *patterns* are data, because compliance needs differ
12//! per organisation (PCI, HIPAA, internal token formats).
13
14use anyhow::{Context, Result, anyhow, bail};
15use regex::Regex;
16use std::path::{Path, PathBuf};
17use std::sync::{LazyLock, OnceLock};
18
19/// One redaction rule: a compiled regex and its replacement template.
20/// The replacement may reference capture groups (`$1`, `${1}`) for
21/// partial masking, e.g. keep a prefix and mask the rest.
22#[derive(Debug)]
23pub struct RedactRule {
24    pub re: Regex,
25    pub replacement: String,
26}
27
28/// The merged, ordered set of redaction rules.
29#[derive(Debug)]
30pub struct RedactRules {
31    rules: Vec<RedactRule>,
32}
33
34/// Process-wide ruleset, installed once by [`init`].
35static RULES: OnceLock<RedactRules> = OnceLock::new();
36
37/// Defaults-only ruleset — used before [`init`] runs (e.g. in tests).
38static DEFAULT_RULES: LazyLock<RedactRules> = LazyLock::new(|| RedactRules {
39    rules: RedactRules::compile_defaults(),
40});
41
42/// Built-in default secret patterns — the baseline that ships with lowfat.
43/// `(regex, replacement)`. Sourced from gitleaks and common secret formats.
44fn defaults() -> &'static [(&'static str, &'static str)] {
45    &[
46        (r"(?i)(AKIA[0-9A-Z]{16})", "[REDACTED:aws-key]"),
47        (
48            r"(?i)(aws_secret_access_key|aws_secret_key)\s*[=:]\s*\S+",
49            "$1=[REDACTED:aws-secret]",
50        ),
51        (
52            r"ghp_[A-Za-z0-9]{36,}|gho_[A-Za-z0-9]{36,}|ghs_[A-Za-z0-9]{36,}|ghr_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{22,}",
53            "[REDACTED:github-token]",
54        ),
55        (r"glpat-[A-Za-z0-9\-_]{20,}", "[REDACTED:gitlab-token]"),
56        (r"xox[bpsar]-[A-Za-z0-9\-]{24,}", "[REDACTED:slack-token]"),
57        (
58            r#"(?i)(api[_-]?key|api[_-]?secret|api[_-]?token|access[_-]?token|secret[_-]?key|auth[_-]?token|private[_-]?key)\s*[=:]\s*['"]?([A-Za-z0-9/+=\-_.]{16,})['"]?"#,
59            "$1=[REDACTED]",
60        ),
61        (r"(?i)(Bearer\s+)[A-Za-z0-9\-_.~+/]+=*", "${1}[REDACTED:bearer]"),
62        (
63            r"eyJ[A-Za-z0-9\-_]+\.eyJ[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_.+/=]+",
64            "[REDACTED:jwt]",
65        ),
66        (
67            r"(?s)-----BEGIN[A-Z ]*PRIVATE KEY-----.*?-----END[A-Z ]*PRIVATE KEY-----",
68            "[REDACTED:private-key]",
69        ),
70        (r"(://[^:]+:)[^@\s]+(@)", "${1}[REDACTED]${2}"),
71        (r"(?i)(HEROKU_API_KEY)\s*[=:]\s*\S+", "$1=[REDACTED:heroku]"),
72        (
73            r#"(?i)(secret|token|password|passwd|credential)\s*[=:]\s*['"]?([0-9a-f]{32,})['"]?"#,
74            "$1=[REDACTED]",
75        ),
76    ]
77}
78
79impl RedactRules {
80    fn compile_defaults() -> Vec<RedactRule> {
81        defaults()
82            .iter()
83            .map(|(p, r)| RedactRule {
84                re: Regex::new(p).expect("built-in redact pattern must compile"),
85                replacement: r.to_string(),
86            })
87            .collect()
88    }
89
90    /// Parse a `redact.conf` file. Each non-comment line is
91    /// `<regex> => <replacement>`. A bare `!no-defaults` line drops the
92    /// built-in baseline. Returns the rules and the no-defaults flag.
93    fn parse_file(path: &Path) -> Result<(Vec<RedactRule>, bool)> {
94        let text = std::fs::read_to_string(path)
95            .with_context(|| format!("reading {}", path.display()))?;
96        let mut rules = Vec::new();
97        let mut no_defaults = false;
98        for (i, raw) in text.lines().enumerate() {
99            let line = raw.trim();
100            if line.is_empty() || line.starts_with('#') {
101                continue;
102            }
103            if line == "!no-defaults" {
104                no_defaults = true;
105                continue;
106            }
107            let Some((pat, repl)) = line.split_once(" => ") else {
108                bail!(
109                    "{}:{}: expected `<regex> => <replacement>`, got `{}`",
110                    path.display(),
111                    i + 1,
112                    line
113                );
114            };
115            let pat = pat.trim();
116            if pat.is_empty() {
117                bail!("{}:{}: empty regex", path.display(), i + 1);
118            }
119            let re = Regex::new(pat).map_err(|e| {
120                anyhow!(
121                    "{}:{}: invalid regex `{}`: {}",
122                    path.display(),
123                    i + 1,
124                    pat,
125                    e
126                )
127            })?;
128            rules.push(RedactRule {
129                re,
130                replacement: repl.trim().to_string(),
131            });
132        }
133        Ok((rules, no_defaults))
134    }
135
136    /// Load the layered ruleset: built-in defaults, then the global file,
137    /// then the project file. A missing file is simply skipped.
138    pub fn load(global: Option<&Path>, project: Option<&Path>) -> Result<Self> {
139        let mut user_rules = Vec::new();
140        let mut no_defaults = false;
141        for path in [global, project].into_iter().flatten() {
142            if path.is_file() {
143                let (mut r, nd) = Self::parse_file(path)?;
144                no_defaults |= nd;
145                user_rules.append(&mut r);
146            }
147        }
148        let mut rules = if no_defaults {
149            Vec::new()
150        } else {
151            Self::compile_defaults()
152        };
153        rules.append(&mut user_rules);
154        Ok(Self { rules })
155    }
156
157    /// Apply every rule, in order, to `text`.
158    pub fn apply(&self, text: &str) -> String {
159        let mut out = text.to_string();
160        for rule in &self.rules {
161            out = rule
162                .re
163                .replace_all(&out, rule.replacement.as_str())
164                .into_owned();
165        }
166        out
167    }
168
169    pub fn len(&self) -> usize {
170        self.rules.len()
171    }
172
173    pub fn is_empty(&self) -> bool {
174        self.rules.is_empty()
175    }
176}
177
178/// Resolve the global + project `redact.conf` paths from the lowfat home
179/// directory and the `.lowfat` config path (if any).
180pub fn paths(home_dir: &Path, config_path: Option<&Path>) -> (PathBuf, Option<PathBuf>) {
181    let global = home_dir.join("redact.conf");
182    let project = config_path
183        .and_then(|p| p.parent())
184        .map(|d| d.join("redact.conf"));
185    (global, project)
186}
187
188/// Install the process-wide ruleset. Call once at startup. A malformed
189/// `redact.conf` is reported loudly to stderr; lowfat then falls back to
190/// the built-in defaults so known secrets are still masked.
191pub fn init(global: Option<&Path>, project: Option<&Path>) {
192    let rules = match RedactRules::load(global, project) {
193        Ok(r) => r,
194        Err(e) => {
195            eprintln!("[lowfat] redact.conf error: {e:#}");
196            eprintln!("[lowfat] falling back to built-in redaction defaults");
197            RedactRules {
198                rules: RedactRules::compile_defaults(),
199            }
200        }
201    };
202    let _ = RULES.set(rules);
203}
204
205/// Redact secrets from `text` using the installed ruleset, or the
206/// built-in defaults if [`init`] has not run.
207pub fn redact(text: &str) -> String {
208    RULES.get().unwrap_or(&DEFAULT_RULES).apply(text)
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214
215    fn write(dir: &Path, name: &str, body: &str) -> PathBuf {
216        let p = dir.join(name);
217        std::fs::write(&p, body).unwrap();
218        p
219    }
220
221    #[test]
222    fn parse_basic_rule() {
223        let dir = tempfile::tempdir().unwrap();
224        let f = write(dir.path(), "redact.conf", "# a comment\nFOO-[0-9]+ => [X]\n");
225        let (rules, nd) = RedactRules::parse_file(&f).unwrap();
226        assert_eq!(rules.len(), 1);
227        assert!(!nd);
228    }
229
230    #[test]
231    fn malformed_regex_errors() {
232        let dir = tempfile::tempdir().unwrap();
233        let f = write(dir.path(), "redact.conf", "FOO( => [X]\n");
234        let err = format!("{:#}", RedactRules::parse_file(&f).unwrap_err());
235        assert!(err.contains("invalid regex"), "got: {err}");
236    }
237
238    #[test]
239    fn missing_separator_errors() {
240        let dir = tempfile::tempdir().unwrap();
241        let f = write(dir.path(), "redact.conf", "no separator here\n");
242        assert!(RedactRules::parse_file(&f).is_err());
243    }
244
245    #[test]
246    fn layering_defaults_plus_custom() {
247        let dir = tempfile::tempdir().unwrap();
248        let g = write(dir.path(), "g.conf", "EMP-[0-9]{3} => [EMP]\n");
249        let rs = RedactRules::load(Some(&g), None).unwrap();
250        let out = rs.apply("key AKIA0000000000000000 staff EMP-123");
251        assert!(out.contains("[REDACTED:aws-key]"), "default applied: {out}");
252        assert!(out.contains("[EMP]"), "custom applied: {out}");
253    }
254
255    #[test]
256    fn no_defaults_directive() {
257        let dir = tempfile::tempdir().unwrap();
258        let g = write(dir.path(), "g.conf", "!no-defaults\nEMP-[0-9]{3} => [EMP]\n");
259        let rs = RedactRules::load(Some(&g), None).unwrap();
260        let out = rs.apply("AKIA0000000000000000 EMP-123");
261        assert!(out.contains("AKIA0000000000000000"), "default dropped: {out}");
262        assert!(out.contains("[EMP]"));
263    }
264
265    #[test]
266    fn project_layers_over_global() {
267        let dir = tempfile::tempdir().unwrap();
268        let g = write(dir.path(), "g.conf", "!no-defaults\nGLOBAL-X => [G]\n");
269        let p = write(dir.path(), "p.conf", "PROJ-Y => [P]\n");
270        let rs = RedactRules::load(Some(&g), Some(&p)).unwrap();
271        let out = rs.apply("GLOBAL-X and PROJ-Y");
272        assert_eq!(out, "[G] and [P]");
273    }
274
275    #[test]
276    fn capture_group_partial_mask() {
277        let dir = tempfile::tempdir().unwrap();
278        let f = write(
279            dir.path(),
280            "c.conf",
281            "!no-defaults\n(TOK_)[A-Z0-9]+ => ${1}[REDACTED]\n",
282        );
283        let rs = RedactRules::load(Some(&f), None).unwrap();
284        assert_eq!(rs.apply("TOK_ABC123"), "TOK_[REDACTED]");
285    }
286
287    #[test]
288    fn missing_file_keeps_defaults() {
289        let rs = RedactRules::load(Some(Path::new("/no/such/redact.conf")), None).unwrap();
290        assert!(!rs.is_empty());
291    }
292}