Skip to main content

aptu_core/security/
ignore.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Global ignore list for security findings.
4//!
5//! Allows users to configure patterns and paths to skip before LLM validation,
6//! reducing API costs and noise from known false positives.
7
8use std::fs;
9use std::path::{Component, Path, PathBuf};
10
11use anyhow::{Context, Result};
12use serde::{Deserialize, Serialize};
13
14use super::Finding;
15
16fn path_matches_prefix(file_path: &str, prefix: &str) -> bool {
17    let file_components: Vec<Component<'_>> = Path::new(file_path)
18        .components()
19        .filter(|component| !matches!(component, Component::CurDir))
20        .collect();
21    let prefix_components: Vec<Component<'_>> = Path::new(prefix).components().collect();
22
23    if prefix_components.is_empty() || file_components.is_empty() {
24        return false;
25    }
26
27    // Relative paths must start with the prefix.
28    if file_components.len() >= prefix_components.len()
29        && file_components[..prefix_components.len()]
30            .iter()
31            .zip(&prefix_components)
32            .all(|(file_component, prefix_component)| file_component == prefix_component)
33    {
34        return true;
35    }
36
37    // Absolute paths may contain the prefix at any depth.
38    matches!(file_components.first(), Some(Component::RootDir))
39        && file_components
40            .windows(prefix_components.len())
41            .any(|window| {
42                window
43                    .iter()
44                    .zip(&prefix_components)
45                    .all(|(file_component, prefix_component)| file_component == prefix_component)
46            })
47}
48
49/// Security configuration for ignore rules.
50///
51/// Loaded from `~/.config/aptu/security.toml` with fallback to defaults.
52///
53/// By default, includes sensible ignore paths for common test and vendor directories.
54/// Use `SecurityConfig::empty()` for a configuration with no ignore rules.
55#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct SecurityConfig {
57    /// Pattern IDs to ignore (e.g., `["hardcoded-secret", "sql-injection"]`).
58    #[serde(default)]
59    pub ignore_patterns: Vec<String>,
60
61    /// File path prefixes to ignore (e.g., `["test/", "vendor/"]`).
62    #[serde(default)]
63    pub ignore_paths: Vec<String>,
64}
65
66impl Default for SecurityConfig {
67    /// Returns configuration with sensible default ignore paths.
68    ///
69    /// Includes common test and vendor directories that typically contain
70    /// test fixtures or third-party code that should not be scanned.
71    fn default() -> Self {
72        Self {
73            ignore_patterns: vec![],
74            ignore_paths: vec![
75                "tests/".to_string(),
76                "test/".to_string(),
77                "benches/".to_string(),
78                "fixtures/".to_string(),
79                "vendor/".to_string(),
80                "generated/".to_string(),
81                "docs/".to_string(),
82                "build/".to_string(),
83                "target/".to_string(),
84                "dist/".to_string(),
85                "out/".to_string(),
86                ".next/".to_string(),
87            ],
88        }
89    }
90}
91
92impl SecurityConfig {
93    /// Create configuration with sensible default ignore paths.
94    ///
95    /// This is an alias for `Default::default()`.
96    #[must_use]
97    #[deprecated(since = "0.6.0", note = "Use `SecurityConfig::default()` instead")]
98    pub fn with_defaults() -> Self {
99        Self::default()
100    }
101
102    /// Create an empty configuration with no ignore rules.
103    ///
104    /// Use this when you want to scan all files without any filtering.
105    #[must_use]
106    pub fn empty() -> Self {
107        Self {
108            ignore_patterns: vec![],
109            ignore_paths: vec![],
110        }
111    }
112
113    /// Check if a file path should be ignored based on configuration.
114    ///
115    /// This is a fast check that can be used before scanning to avoid
116    /// running expensive regex patterns on files in ignored directories.
117    ///
118    /// # Arguments
119    ///
120    /// * `file_path` - The file path to check
121    ///
122    /// # Returns
123    ///
124    /// `true` if the path should be ignored, `false` otherwise.
125    #[must_use]
126    pub fn should_ignore_path(&self, file_path: &str) -> bool {
127        self.ignore_paths
128            .iter()
129            .any(|prefix| path_matches_prefix(file_path, prefix))
130    }
131
132    /// Load configuration from `~/.config/aptu/security.toml`.
133    ///
134    /// Returns default configuration if file doesn't exist or parse fails.
135    ///
136    /// # Returns
137    ///
138    /// Loaded configuration or default on error.
139    #[must_use]
140    pub fn load() -> Self {
141        if let Some(path) = Self::config_path() {
142            match Self::load_from_path(&path) {
143                Ok(config) => config,
144                Err(e) => {
145                    tracing::warn!("Failed to load security config: {:#}", e);
146                    Self::default()
147                }
148            }
149        } else {
150            tracing::warn!("Config directory not available, using default security config");
151            Self::default()
152        }
153    }
154
155    /// Get the configuration file path.
156    ///
157    /// Returns `~/.config/aptu/security.toml` or `None` if config directory cannot be determined.
158    #[must_use]
159    pub fn config_path() -> Option<PathBuf> {
160        dirs::config_dir().map(|dir| dir.join("aptu").join("security.toml"))
161    }
162
163    /// Load configuration from a specific path.
164    ///
165    /// # Arguments
166    ///
167    /// * `path` - Path to configuration file
168    ///
169    /// # Returns
170    ///
171    /// Loaded configuration or error if file exists but is invalid.
172    fn load_from_path(path: &PathBuf) -> Result<Self> {
173        if !path.exists() {
174            return Ok(Self::default());
175        }
176
177        let contents = fs::read_to_string(path)
178            .with_context(|| format!("Failed to read config file: {}", path.display()))?;
179
180        toml::from_str(&contents)
181            .with_context(|| format!("Failed to parse config file: {}", path.display()))
182    }
183
184    /// Check if a finding should be ignored based on configuration.
185    ///
186    /// A finding is ignored if:
187    /// - Its pattern ID matches any entry in `ignore_patterns`
188    /// - Its file path starts with any entry in `ignore_paths`
189    ///
190    /// # Arguments
191    ///
192    /// * `finding` - The finding to check
193    ///
194    /// # Returns
195    ///
196    /// `true` if the finding should be ignored, `false` otherwise.
197    #[must_use]
198    pub fn should_ignore(&self, finding: &Finding) -> bool {
199        // Check pattern ID
200        if self.ignore_patterns.contains(&finding.pattern_id) {
201            return true;
202        }
203
204        // Check file path prefixes
205        for prefix in &self.ignore_paths {
206            if path_matches_prefix(&finding.file_path, prefix) {
207                return true;
208            }
209        }
210
211        false
212    }
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218    use crate::security::{Confidence, Severity};
219
220    #[test]
221    fn test_security_config_default_has_sensible_paths() {
222        let config = SecurityConfig::default();
223        assert!(config.ignore_patterns.is_empty());
224        assert_eq!(config.ignore_paths.len(), 12);
225        assert!(config.ignore_paths.contains(&"tests/".to_string()));
226        assert!(config.ignore_paths.contains(&"test/".to_string()));
227        assert!(config.ignore_paths.contains(&"benches/".to_string()));
228        assert!(config.ignore_paths.contains(&"fixtures/".to_string()));
229        assert!(config.ignore_paths.contains(&"vendor/".to_string()));
230        for prefix in [
231            "generated/",
232            "docs/",
233            "build/",
234            "target/",
235            "dist/",
236            "out/",
237            ".next/",
238        ] {
239            assert!(config.should_ignore_path(&format!("./{prefix}file.rs")));
240        }
241    }
242
243    #[test]
244    fn test_empty_config() {
245        let config = SecurityConfig::empty();
246        assert!(config.ignore_patterns.is_empty());
247        assert!(config.ignore_paths.is_empty());
248    }
249
250    #[test]
251    #[allow(deprecated)]
252    fn test_with_defaults_deprecated() {
253        // with_defaults is deprecated but should still work
254        let config = SecurityConfig::with_defaults();
255        assert!(config.ignore_patterns.is_empty());
256        assert_eq!(config.ignore_paths.len(), 12);
257    }
258
259    #[test]
260    fn test_should_ignore_path_method() {
261        let config = SecurityConfig::default();
262
263        // Should ignore test paths
264        assert!(config.should_ignore_path("tests/unit/test.rs"));
265        assert!(config.should_ignore_path("test/fixtures/data.rs"));
266        assert!(config.should_ignore_path("vendor/lib.rs"));
267
268        // Should not ignore src paths
269        assert!(!config.should_ignore_path("src/main.rs"));
270        assert!(!config.should_ignore_path("src/test.rs"));
271    }
272
273    #[test]
274    fn test_should_ignore_pattern() {
275        let config = SecurityConfig {
276            ignore_patterns: vec!["test-pattern".to_string(), "another-pattern".to_string()],
277            ignore_paths: vec![],
278        };
279
280        let finding = Finding {
281            pattern_id: "test-pattern".to_string(),
282            description: "Test".to_string(),
283            severity: Severity::Low,
284            confidence: Confidence::Low,
285            file_path: "src/main.rs".to_string(),
286            line_number: 1,
287            matched_text: "test".to_string(),
288            cwe: None,
289        };
290
291        assert!(config.should_ignore(&finding));
292    }
293
294    #[test]
295    fn test_should_ignore_path() {
296        let config = SecurityConfig {
297            ignore_patterns: vec![],
298            ignore_paths: vec!["test/".to_string(), "vendor/".to_string()],
299        };
300
301        let finding = Finding {
302            pattern_id: "pattern".to_string(),
303            description: "Test".to_string(),
304            severity: Severity::Low,
305            confidence: Confidence::Low,
306            file_path: "test/fixtures/data.rs".to_string(),
307            line_number: 1,
308            matched_text: "test".to_string(),
309            cwe: None,
310        };
311
312        assert!(config.should_ignore(&finding));
313    }
314
315    #[test]
316    fn test_should_not_ignore() {
317        let config = SecurityConfig {
318            ignore_patterns: vec!["other-pattern".to_string()],
319            ignore_paths: vec!["vendor/".to_string()],
320        };
321
322        let finding = Finding {
323            pattern_id: "real-pattern".to_string(),
324            description: "Test".to_string(),
325            severity: Severity::High,
326            confidence: Confidence::High,
327            file_path: "src/main.rs".to_string(),
328            line_number: 42,
329            matched_text: "code".to_string(),
330            cwe: Some("CWE-123".to_string()),
331        };
332
333        assert!(!config.should_ignore(&finding));
334    }
335
336    #[test]
337    fn test_should_ignore_path_prefix() {
338        let config = SecurityConfig {
339            ignore_patterns: vec![],
340            ignore_paths: vec!["test/".to_string()],
341        };
342
343        // Should match prefix
344        let finding1 = Finding {
345            pattern_id: "pattern".to_string(),
346            description: "Test".to_string(),
347            severity: Severity::Low,
348            confidence: Confidence::Low,
349            file_path: "test/unit/test.rs".to_string(),
350            line_number: 1,
351            matched_text: "test".to_string(),
352            cwe: None,
353        };
354        assert!(config.should_ignore(&finding1));
355
356        // Should not match if not a prefix
357        let finding2 = Finding {
358            pattern_id: "pattern".to_string(),
359            description: "Test".to_string(),
360            severity: Severity::Low,
361            confidence: Confidence::Low,
362            file_path: "src/test.rs".to_string(),
363            line_number: 1,
364            matched_text: "test".to_string(),
365            cwe: None,
366        };
367        assert!(!config.should_ignore(&finding2));
368    }
369
370    #[test]
371    fn test_path_matches_prefix_component_matching() {
372        // ./ prefix is normalized away (CurDir component skipped)
373        assert!(path_matches_prefix("./tests/foo.rs", "tests/"));
374        // Direct component match
375        assert!(path_matches_prefix("tests/foo.rs", "tests/"));
376        // Absolute path matches prefix at any depth
377        assert!(path_matches_prefix("/absolute/path/tests/foo.rs", "tests/"));
378        // Different first component must not match
379        assert!(!path_matches_prefix("src/main.rs", "tests/"));
380        // Path deeper than prefix still matches
381        assert!(path_matches_prefix("generated/sub/deep.rs", "generated/"));
382        // Nested same-name component in a relative path must not match
383        assert!(!path_matches_prefix("src/tests/foo.rs", "tests/"));
384    }
385
386    #[test]
387    fn test_config_serialization() {
388        let config = SecurityConfig {
389            ignore_patterns: vec!["pattern1".to_string(), "pattern2".to_string()],
390            ignore_paths: vec!["test/".to_string(), "vendor/".to_string()],
391        };
392
393        let toml = toml::to_string(&config).expect("serialize");
394        let deserialized: SecurityConfig = toml::from_str(&toml).expect("deserialize");
395
396        assert_eq!(config.ignore_patterns, deserialized.ignore_patterns);
397        assert_eq!(config.ignore_paths, deserialized.ignore_paths);
398    }
399
400    #[test]
401    fn test_load_nonexistent_file_returns_defaults() {
402        let path = PathBuf::from("/nonexistent/path/security.toml");
403        let config = SecurityConfig::load_from_path(&path).expect("load default");
404        // When file doesn't exist, should return sensible defaults
405        assert!(config.ignore_patterns.is_empty());
406        assert_eq!(config.ignore_paths.len(), 12);
407    }
408
409    #[test]
410    fn test_config_path() {
411        if let Some(path) = SecurityConfig::config_path() {
412            assert!(path.ends_with("aptu/security.toml"));
413        }
414        // If None, test passes (config dir not available in environment)
415    }
416}