Skip to main content

nosecrets_filter/
lib.rs

1use globset::{Glob, GlobMatcher, GlobSet, GlobSetBuilder};
2use regex::Regex;
3use serde::Deserialize;
4use std::collections::HashSet;
5use std::fs;
6use std::path::{Path, PathBuf};
7use thiserror::Error;
8
9const DEFAULT_IGNORE_PATHS: &[&str] = &[
10    "**/.git/**",
11    "**/node_modules/**",
12    "**/.pnpm-store/**",
13    "**/.yarn/**",
14    "**/.astro/**",
15    "**/.wrangler/**",
16    "**/dist/**",
17    "**/build/**",
18    "**/.next/**",
19    "**/.svelte-kit/**",
20    "**/coverage/**",
21    "**/target/**",
22    "**/playwright-report/**",
23    "**/test-results/**",
24];
25
26#[derive(Debug, Deserialize, Default, Clone)]
27pub struct Config {
28    #[serde(default)]
29    pub ignore: IgnoreConfig,
30    #[serde(default)]
31    pub allow: AllowConfig,
32    #[serde(default)]
33    pub entropy: EntropyConfig,
34}
35
36#[derive(Debug, Deserialize, Default, Clone)]
37pub struct IgnoreConfig {
38    #[serde(default)]
39    pub paths: Vec<String>,
40}
41
42#[derive(Debug, Deserialize, Default, Clone)]
43pub struct AllowConfig {
44    #[serde(default)]
45    pub patterns: Vec<String>,
46    #[serde(default)]
47    pub values: Vec<String>,
48}
49
50#[derive(Debug, Deserialize, Clone)]
51pub struct EntropyConfig {
52    #[serde(default = "entropy_default_enabled")]
53    pub enabled: bool,
54    #[serde(default = "entropy_default_min_length")]
55    pub min_length: usize,
56    #[serde(default = "entropy_default_threshold")]
57    pub threshold: f64,
58    #[serde(default = "entropy_default_require_context")]
59    pub require_context: bool,
60    #[serde(default)]
61    pub allow: EntropyAllowConfig,
62}
63
64impl Default for EntropyConfig {
65    fn default() -> Self {
66        Self {
67            enabled: entropy_default_enabled(),
68            min_length: entropy_default_min_length(),
69            threshold: entropy_default_threshold(),
70            require_context: entropy_default_require_context(),
71            allow: EntropyAllowConfig::default(),
72        }
73    }
74}
75
76fn entropy_default_enabled() -> bool {
77    true
78}
79
80fn entropy_default_min_length() -> usize {
81    20
82}
83
84fn entropy_default_threshold() -> f64 {
85    4.2
86}
87
88fn entropy_default_require_context() -> bool {
89    true
90}
91
92#[derive(Debug, Deserialize, Default, Clone)]
93pub struct EntropyAllowConfig {
94    #[serde(default)]
95    pub patterns: Vec<String>,
96}
97
98#[derive(Debug)]
99pub struct IgnoreEntry {
100    pub fingerprint: String,
101    pub matcher: Option<GlobMatcher>,
102}
103
104#[derive(Debug)]
105pub struct Filter {
106    ignore_paths: Option<GlobSet>,
107    allow_patterns: Vec<Regex>,
108    allow_values: HashSet<String>,
109    ignore_entries: Vec<IgnoreEntry>,
110}
111
112#[derive(Debug, Error)]
113pub enum FilterError {
114    #[error("failed to read {path}: {error}")]
115    Read {
116        path: PathBuf,
117        #[source]
118        error: std::io::Error,
119    },
120    #[error("failed to parse {path}: {error}")]
121    Parse {
122        path: PathBuf,
123        #[source]
124        error: toml::de::Error,
125    },
126    #[error("invalid glob pattern {pattern}: {error}")]
127    Glob {
128        pattern: String,
129        #[source]
130        error: globset::Error,
131    },
132    #[error("invalid regex pattern {pattern}: {error}")]
133    Regex {
134        pattern: String,
135        #[source]
136        error: regex::Error,
137    },
138}
139
140impl Config {
141    pub fn load_from_dir(dir: &Path) -> Result<Option<Self>, FilterError> {
142        let path = dir.join(".nosecrets.toml");
143        if !path.exists() {
144            return Ok(None);
145        }
146        let content = fs::read_to_string(&path).map_err(|error| FilterError::Read {
147            path: path.clone(),
148            error,
149        })?;
150        let config = toml::from_str(&content).map_err(|error| FilterError::Parse {
151            path: path.clone(),
152            error,
153        })?;
154        Ok(Some(config))
155    }
156}
157
158pub fn load_ignore_file(path: &Path) -> Result<Vec<IgnoreEntry>, FilterError> {
159    if !path.exists() {
160        return Ok(Vec::new());
161    }
162    let content = fs::read_to_string(path).map_err(|error| FilterError::Read {
163        path: path.to_path_buf(),
164        error,
165    })?;
166    let mut entries = Vec::new();
167    for line in content.lines() {
168        let trimmed = line.trim();
169        if trimmed.is_empty() || trimmed.starts_with('#') {
170            continue;
171        }
172        let mut parts = trimmed.splitn(2, ':');
173        let fingerprint = parts.next().unwrap().trim().to_string();
174        let matcher = parts
175            .next()
176            .map(|glob| glob.trim())
177            .filter(|glob| !glob.is_empty())
178            .map(|glob| {
179                let normalized = normalize_glob_pattern(glob);
180                Glob::new(&normalized)
181                    .map(|g| g.compile_matcher())
182                    .map_err(|error| FilterError::Glob {
183                        pattern: normalized.clone(),
184                        error,
185                    })
186            })
187            .transpose()?;
188        entries.push(IgnoreEntry {
189            fingerprint,
190            matcher,
191        });
192    }
193    Ok(entries)
194}
195
196impl Filter {
197    pub fn from_config(
198        config: Option<Config>,
199        ignore_entries: Vec<IgnoreEntry>,
200    ) -> Result<Self, FilterError> {
201        let config = config.unwrap_or_default();
202        let mut all_ignore_paths: Vec<String> = DEFAULT_IGNORE_PATHS
203            .iter()
204            .map(|pattern| (*pattern).to_string())
205            .collect();
206        all_ignore_paths.extend(config.ignore.paths.iter().cloned());
207
208        let ignore_paths = if all_ignore_paths.is_empty() {
209            None
210        } else {
211            let mut builder = GlobSetBuilder::new();
212            for pattern in &all_ignore_paths {
213                let normalized = normalize_glob_pattern(pattern);
214                let glob = Glob::new(&normalized).map_err(|error| FilterError::Glob {
215                    pattern: normalized.clone(),
216                    error,
217                })?;
218                builder.add(glob);
219            }
220            Some(builder.build().map_err(|error| FilterError::Glob {
221                pattern: "<globset>".to_string(),
222                error,
223            })?)
224        };
225
226        let mut allow_patterns = Vec::new();
227        for pattern in &config.allow.patterns {
228            let regex = Regex::new(pattern).map_err(|error| FilterError::Regex {
229                pattern: pattern.to_string(),
230                error,
231            })?;
232            allow_patterns.push(regex);
233        }
234        let allow_values = config.allow.values.into_iter().collect();
235
236        Ok(Self {
237            ignore_paths,
238            allow_patterns,
239            allow_values,
240            ignore_entries,
241        })
242    }
243
244    pub fn is_path_ignored(&self, path: &Path) -> bool {
245        let Some(globset) = &self.ignore_paths else {
246            return false;
247        };
248        let normalized = normalize_path(path);
249        globset.is_match(normalized)
250    }
251
252    pub fn is_value_allowed(&self, value: &str) -> bool {
253        if self.allow_values.contains(value) {
254            return true;
255        }
256        self.allow_patterns
257            .iter()
258            .any(|regex| regex.is_match(value))
259    }
260
261    pub fn is_fingerprint_ignored(&self, fingerprint: &str, path: &Path) -> bool {
262        let normalized = normalize_path(path);
263        self.ignore_entries.iter().any(|entry| {
264            if entry.fingerprint != fingerprint {
265                return false;
266            }
267            match &entry.matcher {
268                Some(matcher) => matcher.is_match(&normalized),
269                None => true,
270            }
271        })
272    }
273
274    pub fn is_inline_ignored(line: &str) -> bool {
275        line.contains("@nosecrets-ignore") || line.contains("@nsi")
276    }
277}
278
279pub fn normalize_path(path: &Path) -> String {
280    let raw = path.to_string_lossy().replace('\\', "/");
281    raw.trim_start_matches("./").to_string()
282}
283
284fn normalize_glob_pattern(pattern: &str) -> String {
285    let mut normalized = pattern.replace('\\', "/");
286    if normalized.ends_with('/') {
287        normalized.push_str("**");
288    }
289    normalized
290}
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295    use std::path::Path;
296    use tempfile::tempdir;
297
298    #[test]
299    fn ignore_paths_match_trailing_slash() {
300        let mut config = Config::default();
301        config.ignore.paths = vec!["vendor/".to_string()];
302        let filter = Filter::from_config(Some(config), Vec::new()).expect("build filter");
303        assert!(filter.is_path_ignored(Path::new("vendor/lib.rs")));
304        assert!(!filter.is_path_ignored(Path::new("src/lib.rs")));
305    }
306
307    #[test]
308    fn allow_values_and_patterns() {
309        let mut config = Config::default();
310        config.allow.values = vec!["ALLOW_ME".to_string()];
311        config.allow.patterns = vec!["^test_.*$".to_string()];
312        let filter = Filter::from_config(Some(config), Vec::new()).expect("build filter");
313        assert!(filter.is_value_allowed("ALLOW_ME"));
314        assert!(filter.is_value_allowed("test_value"));
315        assert!(!filter.is_value_allowed("deny"));
316    }
317
318    #[test]
319    fn ignore_file_with_path_matcher() {
320        let dir = tempdir().expect("tempdir");
321        let path = dir.path().join(".nosecretsignore");
322        fs::write(&path, "nsi_123:src/**\n").expect("write ignore");
323        let entries = load_ignore_file(&path).expect("load ignore");
324        let filter = Filter::from_config(None, entries).expect("build filter");
325        assert!(filter.is_fingerprint_ignored("nsi_123", Path::new("src/main.rs")));
326        assert!(!filter.is_fingerprint_ignored("nsi_123", Path::new("tests/main.rs")));
327    }
328
329    #[test]
330    fn inline_ignore_detection() {
331        assert!(Filter::is_inline_ignored(
332            "key = \"secret\" # @nosecrets-ignore"
333        ));
334        assert!(Filter::is_inline_ignored("// @nsi test"));
335        assert!(!Filter::is_inline_ignored("no ignore here"));
336    }
337
338    #[test]
339    fn default_ignore_paths_skip_common_build_and_dependency_dirs() {
340        let filter = Filter::from_config(None, Vec::new()).expect("build filter");
341        assert!(filter.is_path_ignored(Path::new("node_modules/pkg/index.js")));
342        assert!(filter.is_path_ignored(Path::new(".wrangler/tmp/worker.js")));
343        assert!(filter.is_path_ignored(Path::new("dist/client/index.html")));
344        assert!(filter.is_path_ignored(Path::new(".astro/types.d.ts")));
345        assert!(filter.is_path_ignored(Path::new("playwright-report/index.html")));
346        assert!(filter.is_path_ignored(Path::new("test-results/run/output.json")));
347        assert!(!filter.is_path_ignored(Path::new("src/pages/index.astro")));
348    }
349}