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