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                "examples/".to_string(),
83                "build/".to_string(),
84                "target/".to_string(),
85                "dist/".to_string(),
86                "out/".to_string(),
87                ".next/".to_string(),
88            ],
89        }
90    }
91}
92
93impl SecurityConfig {
94    /// Create configuration with sensible default ignore paths.
95    ///
96    /// This is an alias for `Default::default()`.
97    #[must_use]
98    #[deprecated(since = "0.6.0", note = "Use `SecurityConfig::default()` instead")]
99    pub fn with_defaults() -> Self {
100        Self::default()
101    }
102
103    /// Create an empty configuration with no ignore rules.
104    ///
105    /// Use this when you want to scan all files without any filtering.
106    #[must_use]
107    pub fn empty() -> Self {
108        Self {
109            ignore_patterns: vec![],
110            ignore_paths: vec![],
111        }
112    }
113
114    /// Check if a file path should be ignored based on configuration.
115    ///
116    /// This is a fast check that can be used before scanning to avoid
117    /// running expensive regex patterns on files in ignored directories.
118    ///
119    /// # Arguments
120    ///
121    /// * `file_path` - The file path to check
122    ///
123    /// # Returns
124    ///
125    /// `true` if the path should be ignored, `false` otherwise.
126    #[must_use]
127    pub fn should_ignore_path(&self, file_path: &str) -> bool {
128        self.ignore_paths
129            .iter()
130            .any(|prefix| path_matches_prefix(file_path, prefix))
131    }
132
133    /// Load configuration from `~/.config/aptu/security.toml`.
134    ///
135    /// Returns default configuration if file doesn't exist or parse fails.
136    ///
137    /// # Returns
138    ///
139    /// Loaded configuration or default on error.
140    #[must_use]
141    pub fn load() -> Self {
142        if let Some(path) = Self::config_path() {
143            match Self::load_from_path(&path) {
144                Ok(config) => config,
145                Err(e) => {
146                    tracing::warn!("Failed to load security config: {:#}", e);
147                    Self::default()
148                }
149            }
150        } else {
151            tracing::warn!("Config directory not available, using default security config");
152            Self::default()
153        }
154    }
155
156    /// Get the configuration file path.
157    ///
158    /// Returns `~/.config/aptu/security.toml` or `None` if config directory cannot be determined.
159    #[must_use]
160    pub fn config_path() -> Option<PathBuf> {
161        dirs::config_dir().map(|dir| dir.join("aptu").join("security.toml"))
162    }
163
164    /// Load configuration from a specific path.
165    ///
166    /// # Arguments
167    ///
168    /// * `path` - Path to configuration file
169    ///
170    /// # Returns
171    ///
172    /// Loaded configuration or error if file exists but is invalid.
173    fn load_from_path(path: &PathBuf) -> Result<Self> {
174        if !path.exists() {
175            return Ok(Self::default());
176        }
177
178        let contents = fs::read_to_string(path)
179            .with_context(|| format!("Failed to read config file: {}", path.display()))?;
180
181        toml::from_str(&contents)
182            .with_context(|| format!("Failed to parse config file: {}", path.display()))
183    }
184
185    /// Check if a finding should be ignored based on configuration.
186    ///
187    /// A finding is ignored if:
188    /// - Its pattern ID matches any entry in `ignore_patterns`
189    /// - Its file path starts with any entry in `ignore_paths`
190    ///
191    /// # Arguments
192    ///
193    /// * `finding` - The finding to check
194    ///
195    /// # Returns
196    ///
197    /// `true` if the finding should be ignored, `false` otherwise.
198    #[must_use]
199    pub fn should_ignore(&self, finding: &Finding) -> bool {
200        // Check pattern ID
201        if self.ignore_patterns.contains(&finding.pattern_id) {
202            return true;
203        }
204
205        // Check file path prefixes
206        for prefix in &self.ignore_paths {
207            if path_matches_prefix(&finding.file_path, prefix) {
208                return true;
209            }
210        }
211
212        false
213    }
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219    use crate::security::{Confidence, Severity};
220
221    #[test]
222    fn test_security_config_default_has_sensible_paths() {
223        let config = SecurityConfig::default();
224        assert!(config.ignore_patterns.is_empty());
225        assert_eq!(config.ignore_paths.len(), 13);
226        assert!(config.ignore_paths.contains(&"tests/".to_string()));
227        assert!(config.ignore_paths.contains(&"test/".to_string()));
228        assert!(config.ignore_paths.contains(&"benches/".to_string()));
229        assert!(config.ignore_paths.contains(&"fixtures/".to_string()));
230        assert!(config.ignore_paths.contains(&"vendor/".to_string()));
231        assert!(config.ignore_paths.contains(&"examples/".to_string()));
232        for prefix in [
233            "generated/",
234            "docs/",
235            "examples/",
236            "build/",
237            "target/",
238            "dist/",
239            "out/",
240            ".next/",
241        ] {
242            assert!(config.should_ignore_path(&format!("./{prefix}file.rs")));
243        }
244    }
245
246    #[test]
247    fn test_empty_config() {
248        let config = SecurityConfig::empty();
249        assert!(config.ignore_patterns.is_empty());
250        assert!(config.ignore_paths.is_empty());
251    }
252
253    #[test]
254    #[allow(deprecated)]
255    fn test_with_defaults_deprecated() {
256        // with_defaults is deprecated but should still work
257        let config = SecurityConfig::with_defaults();
258        assert!(config.ignore_patterns.is_empty());
259        assert_eq!(config.ignore_paths.len(), 13);
260    }
261
262    #[test]
263    fn test_should_ignore_path_method() {
264        let config = SecurityConfig::default();
265
266        // Should ignore test paths
267        assert!(config.should_ignore_path("tests/unit/test.rs"));
268        assert!(config.should_ignore_path("test/fixtures/data.rs"));
269        assert!(config.should_ignore_path("vendor/lib.rs"));
270
271        // Should not ignore src paths
272        assert!(!config.should_ignore_path("src/main.rs"));
273        assert!(!config.should_ignore_path("src/test.rs"));
274    }
275
276    #[test]
277    fn test_should_ignore_pattern() {
278        let config = SecurityConfig {
279            ignore_patterns: vec!["test-pattern".to_string(), "another-pattern".to_string()],
280            ignore_paths: vec![],
281        };
282
283        let finding = Finding {
284            pattern_id: "test-pattern".to_string(),
285            description: "Test".to_string(),
286            severity: Severity::Low,
287            confidence: Confidence::Low,
288            file_path: "src/main.rs".to_string(),
289            line_number: 1,
290            matched_text: "test".to_string(),
291            cwe: None,
292        };
293
294        assert!(config.should_ignore(&finding));
295    }
296
297    #[test]
298    fn test_should_ignore_path() {
299        let config = SecurityConfig {
300            ignore_patterns: vec![],
301            ignore_paths: vec!["test/".to_string(), "vendor/".to_string()],
302        };
303
304        let finding = Finding {
305            pattern_id: "pattern".to_string(),
306            description: "Test".to_string(),
307            severity: Severity::Low,
308            confidence: Confidence::Low,
309            file_path: "test/fixtures/data.rs".to_string(),
310            line_number: 1,
311            matched_text: "test".to_string(),
312            cwe: None,
313        };
314
315        assert!(config.should_ignore(&finding));
316    }
317
318    #[test]
319    fn test_should_not_ignore() {
320        let config = SecurityConfig {
321            ignore_patterns: vec!["other-pattern".to_string()],
322            ignore_paths: vec!["vendor/".to_string()],
323        };
324
325        let finding = Finding {
326            pattern_id: "real-pattern".to_string(),
327            description: "Test".to_string(),
328            severity: Severity::High,
329            confidence: Confidence::High,
330            file_path: "src/main.rs".to_string(),
331            line_number: 42,
332            matched_text: "code".to_string(),
333            cwe: Some("CWE-123".to_string()),
334        };
335
336        assert!(!config.should_ignore(&finding));
337    }
338
339    #[test]
340    fn test_should_ignore_path_prefix() {
341        let config = SecurityConfig {
342            ignore_patterns: vec![],
343            ignore_paths: vec!["test/".to_string()],
344        };
345
346        // Should match prefix
347        let finding1 = Finding {
348            pattern_id: "pattern".to_string(),
349            description: "Test".to_string(),
350            severity: Severity::Low,
351            confidence: Confidence::Low,
352            file_path: "test/unit/test.rs".to_string(),
353            line_number: 1,
354            matched_text: "test".to_string(),
355            cwe: None,
356        };
357        assert!(config.should_ignore(&finding1));
358
359        // Should not match if not a prefix
360        let finding2 = Finding {
361            pattern_id: "pattern".to_string(),
362            description: "Test".to_string(),
363            severity: Severity::Low,
364            confidence: Confidence::Low,
365            file_path: "src/test.rs".to_string(),
366            line_number: 1,
367            matched_text: "test".to_string(),
368            cwe: None,
369        };
370        assert!(!config.should_ignore(&finding2));
371    }
372
373    #[test]
374    fn test_path_matches_prefix_component_matching() {
375        // ./ prefix is normalized away (CurDir component skipped)
376        assert!(path_matches_prefix("./tests/foo.rs", "tests/"));
377        // Direct component match
378        assert!(path_matches_prefix("tests/foo.rs", "tests/"));
379        // Absolute path matches prefix at any depth
380        assert!(path_matches_prefix("/absolute/path/tests/foo.rs", "tests/"));
381        // Different first component must not match
382        assert!(!path_matches_prefix("src/main.rs", "tests/"));
383        // Path deeper than prefix still matches
384        assert!(path_matches_prefix("generated/sub/deep.rs", "generated/"));
385        // Nested same-name component in a relative path must not match
386        assert!(!path_matches_prefix("src/tests/foo.rs", "tests/"));
387    }
388
389    #[test]
390    fn test_config_serialization() {
391        let config = SecurityConfig {
392            ignore_patterns: vec!["pattern1".to_string(), "pattern2".to_string()],
393            ignore_paths: vec!["test/".to_string(), "vendor/".to_string()],
394        };
395
396        let toml = toml::to_string(&config).expect("serialize");
397        let deserialized: SecurityConfig = toml::from_str(&toml).expect("deserialize");
398
399        assert_eq!(config.ignore_patterns, deserialized.ignore_patterns);
400        assert_eq!(config.ignore_paths, deserialized.ignore_paths);
401    }
402
403    #[test]
404    fn test_load_nonexistent_file_returns_defaults() {
405        let path = PathBuf::from("/nonexistent/path/security.toml");
406        let config = SecurityConfig::load_from_path(&path).expect("load default");
407        // When file doesn't exist, should return sensible defaults
408        assert!(config.ignore_patterns.is_empty());
409        assert_eq!(config.ignore_paths.len(), 13);
410    }
411
412    #[test]
413    fn test_config_path() {
414        if let Some(path) = SecurityConfig::config_path() {
415            assert!(path.ends_with("aptu/security.toml"));
416        }
417        // If None, test passes (config dir not available in environment)
418    }
419}