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//! - **Deny rules** — treat all ambient authority in matching categories as Critical deny violations.
11//!
12//! # Example
13//!
14//! ```toml
15//! [deny]
16//! categories = ["all"]
17//!
18//! [analysis]
19//! exclude = ["tests/**", "benches/**"]
20//!
21//! [[authority]]
22//! path = ["my_crate", "secrets", "fetch"]
23//! category = "net"
24//! risk = "critical"
25//! description = "Fetches secrets from vault"
26//!
27//! [[allow]]
28//! crate = "tracing"
29//! ```
30
31use crate::authorities::{Category, CustomAuthority, Risk};
32use crate::detector::Finding;
33use serde::Deserialize;
34use std::path::Path;
35
36const CONFIG_FILE: &str = ".capsec.toml";
37
38/// Top-level configuration loaded from `.capsec.toml`.
39///
40/// All fields are optional — a missing config file produces sensible defaults
41/// (no excludes, no custom authorities, no allow rules, no deny rules).
42#[derive(Debug, Deserialize, Default)]
43pub struct Config {
44    #[serde(default)]
45    pub deny: DenyConfig,
46    #[serde(default)]
47    pub analysis: AnalysisConfig,
48    #[serde(default)]
49    pub authority: Vec<AuthorityEntry>,
50    #[serde(default)]
51    pub allow: Vec<AllowEntry>,
52}
53
54/// Crate-level deny configuration from `[deny]` in `.capsec.toml`.
55///
56/// When categories are specified, any ambient authority matching those categories
57/// is promoted to a Critical-risk deny violation — the same behavior as
58/// `#[capsec::deny(...)]` on individual functions, but applied crate-wide.
59///
60/// Valid categories: `all`, `fs`, `net`, `env`, `process`, `ffi`.
61#[derive(Debug, Deserialize, Default)]
62pub struct DenyConfig {
63    #[serde(default)]
64    pub categories: Vec<String>,
65}
66
67/// Settings that control which files are scanned.
68#[derive(Debug, Deserialize, Default)]
69pub struct AnalysisConfig {
70    #[serde(default)]
71    pub exclude: Vec<String>,
72}
73
74/// A custom authority pattern from `[[authority]]` in `.capsec.toml`.
75#[derive(Debug, Deserialize)]
76pub struct AuthorityEntry {
77    pub path: Vec<String>,
78    pub category: String,
79    #[serde(default = "default_risk")]
80    pub risk: String,
81    #[serde(default)]
82    pub description: String,
83}
84
85fn default_risk() -> String {
86    "medium".to_string()
87}
88
89/// A suppression rule from `[[allow]]` in `.capsec.toml`.
90///
91/// At least one of `crate`/`crate_name` or `function` must be set for the rule
92/// to match anything. Both `crate` and `crate_name` keys are supported for ergonomics.
93#[derive(Debug, Deserialize)]
94pub struct AllowEntry {
95    #[serde(default)]
96    pub crate_name: Option<String>,
97    // support both "crate" and "crate_name" keys
98    #[serde(default, rename = "crate")]
99    pub crate_key: Option<String>,
100    #[serde(default)]
101    pub function: Option<String>,
102}
103
104impl AllowEntry {
105    /// Returns the crate name from either `crate` or `crate_name` key.
106    pub fn effective_crate(&self) -> Option<&str> {
107        self.crate_name.as_deref().or(self.crate_key.as_deref())
108    }
109}
110
111const VALID_DENY_CATEGORIES: &[&str] = &["all", "fs", "net", "env", "process", "ffi"];
112
113impl DenyConfig {
114    /// Normalizes category names to lowercase and warns about unknown categories.
115    pub fn normalized_categories(&self) -> Vec<String> {
116        self.categories
117            .iter()
118            .filter_map(|cat| {
119                let lower = cat.to_lowercase();
120                if VALID_DENY_CATEGORIES.contains(&lower.as_str()) {
121                    Some(lower)
122                } else {
123                    eprintln!("Warning: unknown deny category '{cat}', ignoring (valid: all, fs, net, env, process, ffi)");
124                    None
125                }
126            })
127            .collect()
128    }
129}
130
131/// Loads configuration from `.capsec.toml` in the given workspace root.
132///
133/// Returns [`Config::default()`] if the file doesn't exist. Returns an error
134/// if the file exists but contains invalid TOML.
135pub fn load_config(
136    workspace_root: &Path,
137    cap: &impl capsec_core::has::Has<capsec_core::permission::FsRead>,
138) -> Result<Config, String> {
139    let config_path = workspace_root.join(CONFIG_FILE);
140
141    if !config_path.exists() {
142        return Ok(Config::default());
143    }
144
145    let content = capsec_std::fs::read_to_string(&config_path, cap)
146        .map_err(|e| format!("Failed to read {}: {e}", config_path.display()))?;
147
148    let config: Config = toml::from_str(&content)
149        .map_err(|e| format!("Failed to parse {}: {e}", config_path.display()))?;
150
151    Ok(config)
152}
153
154/// Converts `[[authority]]` config entries into [`CustomAuthority`] values
155/// that can be added to the detector.
156pub fn custom_authorities(config: &Config) -> Vec<CustomAuthority> {
157    config
158        .authority
159        .iter()
160        .map(|entry| CustomAuthority {
161            path: entry.path.clone(),
162            category: match entry.category.to_lowercase().as_str() {
163                "fs" => Category::Fs,
164                "net" => Category::Net,
165                "env" => Category::Env,
166                "process" | "proc" => Category::Process,
167                "ffi" => Category::Ffi,
168                _ => Category::Ffi,
169            },
170            risk: Risk::parse(&entry.risk),
171            description: entry.description.clone(),
172        })
173        .collect()
174}
175
176/// Returns `true` if a finding should be suppressed by an `[[allow]]` rule.
177pub fn should_allow(finding: &Finding, config: &Config) -> bool {
178    config.allow.iter().any(|rule| {
179        let crate_match = rule
180            .effective_crate()
181            .is_none_or(|c| c == finding.crate_name);
182        let func_match = rule
183            .function
184            .as_ref()
185            .is_none_or(|f| f == &finding.function);
186        crate_match && func_match && (rule.effective_crate().is_some() || rule.function.is_some())
187    })
188}
189
190/// Returns `true` if a file path matches any `[analysis].exclude` glob pattern.
191///
192/// Uses the [`globset`] crate for correct glob semantics (supports `**`, `*`,
193/// `?`, and character classes).
194pub fn should_exclude(path: &Path, excludes: &[String]) -> bool {
195    let path_str = path.display().to_string();
196    excludes.iter().any(|pattern| {
197        match globset::Glob::new(pattern) {
198            Ok(glob) => match glob.compile_matcher().is_match(&path_str) {
199                true => true,
200                false => {
201                    // Also try matching against just the file name for simple patterns
202                    path.file_name()
203                        .and_then(|n| n.to_str())
204                        .is_some_and(|name| glob.compile_matcher().is_match(name))
205                }
206            },
207            Err(_) => path_str.contains(pattern),
208        }
209    })
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215
216    #[test]
217    fn parse_config() {
218        let toml = r#"
219            [analysis]
220            exclude = ["tests/**", "benches/**"]
221
222            [[authority]]
223            path = ["my_crate", "secrets", "fetch"]
224            category = "net"
225            risk = "critical"
226            description = "Fetches secrets"
227
228            [[allow]]
229            crate = "tracing"
230            reason = "Logging framework"
231        "#;
232
233        let config: Config = toml::from_str(toml).unwrap();
234        assert_eq!(config.analysis.exclude.len(), 2);
235        assert_eq!(config.authority.len(), 1);
236        assert_eq!(
237            config.authority[0].path,
238            vec!["my_crate", "secrets", "fetch"]
239        );
240        assert_eq!(config.allow.len(), 1);
241        assert_eq!(config.allow[0].effective_crate(), Some("tracing"));
242    }
243
244    #[test]
245    fn missing_config_returns_default() {
246        let root = capsec_core::root::test_root();
247        let cap = root.grant::<capsec_core::permission::FsRead>();
248        let config = load_config(Path::new("/nonexistent/path"), &cap).unwrap();
249        assert!(config.authority.is_empty());
250        assert!(config.allow.is_empty());
251    }
252
253    #[test]
254    fn parse_deny_config() {
255        let toml = r#"
256            [deny]
257            categories = ["all"]
258        "#;
259        let config: Config = toml::from_str(toml).unwrap();
260        assert_eq!(config.deny.categories, vec!["all"]);
261    }
262
263    #[test]
264    fn parse_deny_selective_categories() {
265        let toml = r#"
266            [deny]
267            categories = ["fs", "net"]
268        "#;
269        let config: Config = toml::from_str(toml).unwrap();
270        assert_eq!(config.deny.categories, vec!["fs", "net"]);
271    }
272
273    #[test]
274    fn missing_deny_section_defaults_to_empty() {
275        let toml = r#"
276            [[allow]]
277            crate = "tracing"
278        "#;
279        let config: Config = toml::from_str(toml).unwrap();
280        assert!(config.deny.categories.is_empty());
281    }
282
283    #[test]
284    fn normalized_categories_lowercases_and_filters() {
285        let deny = DenyConfig {
286            categories: vec!["FS".to_string(), "bogus".to_string(), "net".to_string()],
287        };
288        let normalized = deny.normalized_categories();
289        assert_eq!(normalized, vec!["fs", "net"]);
290    }
291
292    #[test]
293    fn exclude_pattern_matching() {
294        assert!(should_exclude(
295            Path::new("tests/integration.rs"),
296            &["tests/**".to_string()]
297        ));
298        assert!(!should_exclude(
299            Path::new("src/main.rs"),
300            &["tests/**".to_string()]
301        ));
302    }
303}