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}