Skip to main content

cargo_capsec/
config.rs

1//! `.capsec.toml` configuration parsing.
2//!
3//! Users can place a `.capsec.toml` file in their workspace root to customize
4//! the audit behavior:
5//!
6//! - **Custom authorities** — flag project-specific I/O functions (database queries,
7//!   internal RPC, secret fetchers) that the built-in registry doesn't cover.
8//! - **Allow rules** — suppress known-good findings by crate name and/or function name.
9//! - **Exclude patterns** — skip files matching glob patterns (tests, benches, generated code).
10//!
11//! # Example
12//!
13//! ```toml
14//! [analysis]
15//! exclude = ["tests/**", "benches/**"]
16//!
17//! [[authority]]
18//! path = ["my_crate", "secrets", "fetch"]
19//! category = "net"
20//! risk = "critical"
21//! description = "Fetches secrets from vault"
22//!
23//! [[allow]]
24//! crate = "tracing"
25//! ```
26
27use crate::authorities::{Category, CustomAuthority, Risk};
28use crate::detector::Finding;
29use serde::Deserialize;
30use std::path::Path;
31
32const CONFIG_FILE: &str = ".capsec.toml";
33
34/// Top-level configuration loaded from `.capsec.toml`.
35///
36/// All fields are optional — a missing config file produces sensible defaults
37/// (no excludes, no custom authorities, no allow rules).
38#[derive(Debug, Deserialize, Default)]
39pub struct Config {
40    #[serde(default)]
41    pub analysis: AnalysisConfig,
42    #[serde(default)]
43    pub authority: Vec<AuthorityEntry>,
44    #[serde(default)]
45    pub allow: Vec<AllowEntry>,
46}
47
48/// Settings that control which files are scanned.
49#[derive(Debug, Deserialize, Default)]
50pub struct AnalysisConfig {
51    #[serde(default)]
52    pub exclude: Vec<String>,
53}
54
55/// A custom authority pattern from `[[authority]]` in `.capsec.toml`.
56#[derive(Debug, Deserialize)]
57pub struct AuthorityEntry {
58    pub path: Vec<String>,
59    pub category: String,
60    #[serde(default = "default_risk")]
61    pub risk: String,
62    #[serde(default)]
63    pub description: String,
64}
65
66fn default_risk() -> String {
67    "medium".to_string()
68}
69
70/// A suppression rule from `[[allow]]` in `.capsec.toml`.
71///
72/// At least one of `crate`/`crate_name` or `function` must be set for the rule
73/// to match anything. Both `crate` and `crate_name` keys are supported for ergonomics.
74#[derive(Debug, Deserialize)]
75pub struct AllowEntry {
76    #[serde(default)]
77    pub crate_name: Option<String>,
78    // support both "crate" and "crate_name" keys
79    #[serde(default, rename = "crate")]
80    pub crate_key: Option<String>,
81    #[serde(default)]
82    pub function: Option<String>,
83}
84
85impl AllowEntry {
86    /// Returns the crate name from either `crate` or `crate_name` key.
87    pub fn effective_crate(&self) -> Option<&str> {
88        self.crate_name.as_deref().or(self.crate_key.as_deref())
89    }
90}
91
92/// Loads configuration from `.capsec.toml` in the given workspace root.
93///
94/// Returns [`Config::default()`] if the file doesn't exist. Returns an error
95/// if the file exists but contains invalid TOML.
96pub fn load_config(
97    workspace_root: &Path,
98    cap: &impl capsec_core::has::Has<capsec_core::permission::FsRead>,
99) -> Result<Config, String> {
100    let config_path = workspace_root.join(CONFIG_FILE);
101
102    if !config_path.exists() {
103        return Ok(Config::default());
104    }
105
106    let content = capsec_std::fs::read_to_string(&config_path, cap)
107        .map_err(|e| format!("Failed to read {}: {e}", config_path.display()))?;
108
109    let config: Config = toml::from_str(&content)
110        .map_err(|e| format!("Failed to parse {}: {e}", config_path.display()))?;
111
112    Ok(config)
113}
114
115/// Converts `[[authority]]` config entries into [`CustomAuthority`] values
116/// that can be added to the detector.
117pub fn custom_authorities(config: &Config) -> Vec<CustomAuthority> {
118    config
119        .authority
120        .iter()
121        .map(|entry| CustomAuthority {
122            path: entry.path.clone(),
123            category: match entry.category.to_lowercase().as_str() {
124                "fs" => Category::Fs,
125                "net" => Category::Net,
126                "env" => Category::Env,
127                "process" | "proc" => Category::Process,
128                "ffi" => Category::Ffi,
129                _ => Category::Ffi,
130            },
131            risk: Risk::parse(&entry.risk),
132            description: entry.description.clone(),
133        })
134        .collect()
135}
136
137/// Returns `true` if a finding should be suppressed by an `[[allow]]` rule.
138pub fn should_allow(finding: &Finding, config: &Config) -> bool {
139    config.allow.iter().any(|rule| {
140        let crate_match = rule
141            .effective_crate()
142            .is_none_or(|c| c == finding.crate_name);
143        let func_match = rule
144            .function
145            .as_ref()
146            .is_none_or(|f| f == &finding.function);
147        crate_match && func_match && (rule.effective_crate().is_some() || rule.function.is_some())
148    })
149}
150
151/// Returns `true` if a file path matches any `[analysis].exclude` glob pattern.
152///
153/// Uses the [`globset`] crate for correct glob semantics (supports `**`, `*`,
154/// `?`, and character classes).
155pub fn should_exclude(path: &Path, excludes: &[String]) -> bool {
156    let path_str = path.display().to_string();
157    excludes.iter().any(|pattern| {
158        match globset::Glob::new(pattern) {
159            Ok(glob) => match glob.compile_matcher().is_match(&path_str) {
160                true => true,
161                false => {
162                    // Also try matching against just the file name for simple patterns
163                    path.file_name()
164                        .and_then(|n| n.to_str())
165                        .is_some_and(|name| glob.compile_matcher().is_match(name))
166                }
167            },
168            Err(_) => path_str.contains(pattern),
169        }
170    })
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176
177    #[test]
178    fn parse_config() {
179        let toml = r#"
180            [analysis]
181            exclude = ["tests/**", "benches/**"]
182
183            [[authority]]
184            path = ["my_crate", "secrets", "fetch"]
185            category = "net"
186            risk = "critical"
187            description = "Fetches secrets"
188
189            [[allow]]
190            crate = "tracing"
191            reason = "Logging framework"
192        "#;
193
194        let config: Config = toml::from_str(toml).unwrap();
195        assert_eq!(config.analysis.exclude.len(), 2);
196        assert_eq!(config.authority.len(), 1);
197        assert_eq!(
198            config.authority[0].path,
199            vec!["my_crate", "secrets", "fetch"]
200        );
201        assert_eq!(config.allow.len(), 1);
202        assert_eq!(config.allow[0].effective_crate(), Some("tracing"));
203    }
204
205    #[test]
206    fn missing_config_returns_default() {
207        let root = capsec_core::root::test_root();
208        let cap = root.grant::<capsec_core::permission::FsRead>();
209        let config = load_config(Path::new("/nonexistent/path"), &cap).unwrap();
210        assert!(config.authority.is_empty());
211        assert!(config.allow.is_empty());
212    }
213
214    #[test]
215    fn exclude_pattern_matching() {
216        assert!(should_exclude(
217            Path::new("tests/integration.rs"),
218            &["tests/**".to_string()]
219        ));
220        assert!(!should_exclude(
221            Path::new("src/main.rs"),
222            &["tests/**".to_string()]
223        ));
224    }
225}