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, Serialize};
34use std::path::Path;
35
36/// Crate classification for capability-based security analysis.
37///
38/// Inspired by Wyvern's resource/pure module distinction (Melicher et al., ECOOP 2017).
39/// A "pure" crate should contain no ambient authority (no I/O, no process spawning, etc.).
40/// A "resource" crate is expected to have ambient authority findings.
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
42#[serde(rename_all = "lowercase")]
43pub enum Classification {
44    /// No I/O, no state, no side effects — safe to import without capability grants.
45    Pure,
46    /// Contains I/O or ambient authority — requires explicit capability grants.
47    Resource,
48}
49
50const CONFIG_FILE: &str = ".capsec.toml";
51
52/// Top-level configuration loaded from `.capsec.toml`.
53///
54/// All fields are optional — a missing config file produces sensible defaults
55/// (no excludes, no custom authorities, no allow rules, no deny rules).
56#[derive(Debug, Deserialize, Default)]
57pub struct Config {
58    #[serde(default)]
59    pub deny: DenyConfig,
60    #[serde(default)]
61    pub analysis: AnalysisConfig,
62    #[serde(default)]
63    pub authority: Vec<AuthorityEntry>,
64    #[serde(default)]
65    pub allow: Vec<AllowEntry>,
66    #[serde(default)]
67    pub classify: Vec<ClassifyEntry>,
68}
69
70/// A crate classification entry from `[[classify]]` in `.capsec.toml`.
71///
72/// Overrides any `[package.metadata.capsec]` classification in the crate's own Cargo.toml.
73#[derive(Debug, Deserialize)]
74pub struct ClassifyEntry {
75    /// Crate name to classify (e.g., `"serde"`, `"my-app"`).
76    #[serde(rename = "crate")]
77    pub crate_name: String,
78    /// Classification: `pure` or `resource`.
79    pub classification: Classification,
80}
81
82/// Crate-level deny configuration from `[deny]` in `.capsec.toml`.
83///
84/// When categories are specified, any ambient authority matching those categories
85/// is promoted to a Critical-risk deny violation — the same behavior as
86/// `#[capsec::deny(...)]` on individual functions, but applied crate-wide.
87///
88/// Valid categories: `all`, `fs`, `net`, `env`, `process`, `ffi`.
89#[derive(Debug, Deserialize, Default)]
90pub struct DenyConfig {
91    #[serde(default)]
92    pub categories: Vec<String>,
93}
94
95/// Settings that control which files are scanned.
96#[derive(Debug, Deserialize, Default)]
97pub struct AnalysisConfig {
98    #[serde(default)]
99    pub exclude: Vec<String>,
100}
101
102/// A custom authority pattern from `[[authority]]` in `.capsec.toml`.
103#[derive(Debug, Deserialize)]
104pub struct AuthorityEntry {
105    pub path: Vec<String>,
106    pub category: String,
107    #[serde(default = "default_risk")]
108    pub risk: String,
109    #[serde(default)]
110    pub description: String,
111}
112
113fn default_risk() -> String {
114    "medium".to_string()
115}
116
117/// A suppression rule from `[[allow]]` in `.capsec.toml`.
118///
119/// At least one of `crate`/`crate_name` or `function` must be set for the rule
120/// to match anything. Both `crate` and `crate_name` keys are supported for ergonomics.
121#[derive(Debug, Deserialize)]
122pub struct AllowEntry {
123    #[serde(default)]
124    pub crate_name: Option<String>,
125    // support both "crate" and "crate_name" keys
126    #[serde(default, rename = "crate")]
127    pub crate_key: Option<String>,
128    #[serde(default)]
129    pub function: Option<String>,
130}
131
132impl AllowEntry {
133    /// Returns the crate name from either `crate` or `crate_name` key.
134    pub fn effective_crate(&self) -> Option<&str> {
135        self.crate_name.as_deref().or(self.crate_key.as_deref())
136    }
137}
138
139const VALID_DENY_CATEGORIES: &[&str] = &["all", "fs", "net", "env", "process", "ffi"];
140
141impl DenyConfig {
142    /// Normalizes category names to lowercase and warns about unknown categories.
143    pub fn normalized_categories(&self) -> Vec<String> {
144        self.categories
145            .iter()
146            .filter_map(|cat| {
147                let lower = cat.to_lowercase();
148                if VALID_DENY_CATEGORIES.contains(&lower.as_str()) {
149                    Some(lower)
150                } else {
151                    eprintln!("Warning: unknown deny category '{cat}', ignoring (valid: all, fs, net, env, process, ffi)");
152                    None
153                }
154            })
155            .collect()
156    }
157}
158
159/// Loads configuration from `.capsec.toml` in the given workspace root.
160///
161/// Returns [`Config::default()`] if the file doesn't exist. Returns an error
162/// if the file exists but contains invalid TOML.
163pub fn load_config(
164    workspace_root: &Path,
165    cap: &impl capsec_core::has::Has<capsec_core::permission::FsRead>,
166) -> Result<Config, String> {
167    let config_path = workspace_root.join(CONFIG_FILE);
168
169    if !config_path.exists() {
170        return Ok(Config::default());
171    }
172
173    let content = capsec_std::fs::read_to_string(&config_path, cap)
174        .map_err(|e| format!("Failed to read {}: {e}", config_path.display()))?;
175
176    let config: Config = toml::from_str(&content)
177        .map_err(|e| format!("Failed to parse {}: {e}", config_path.display()))?;
178
179    Ok(config)
180}
181
182/// Converts `[[authority]]` config entries into [`CustomAuthority`] values
183/// that can be added to the detector.
184pub fn custom_authorities(config: &Config) -> Vec<CustomAuthority> {
185    config
186        .authority
187        .iter()
188        .map(|entry| CustomAuthority {
189            path: entry.path.clone(),
190            category: match entry.category.to_lowercase().as_str() {
191                "fs" => Category::Fs,
192                "net" => Category::Net,
193                "env" => Category::Env,
194                "process" | "proc" => Category::Process,
195                "ffi" => Category::Ffi,
196                _ => Category::Ffi,
197            },
198            risk: Risk::parse(&entry.risk),
199            description: entry.description.clone(),
200        })
201        .collect()
202}
203
204/// Returns `true` if a finding should be suppressed by an `[[allow]]` rule.
205pub fn should_allow(finding: &Finding, config: &Config) -> bool {
206    config.allow.iter().any(|rule| {
207        let crate_match = rule
208            .effective_crate()
209            .is_none_or(|c| c == finding.crate_name);
210        let func_match = rule
211            .function
212            .as_ref()
213            .is_none_or(|f| f == &finding.function);
214        crate_match && func_match && (rule.effective_crate().is_some() || rule.function.is_some())
215    })
216}
217
218/// Result of classification verification for a single crate.
219#[derive(Debug, Clone, Serialize)]
220pub struct ClassificationResult {
221    /// Crate name.
222    pub crate_name: String,
223    /// Crate version.
224    pub crate_version: String,
225    /// Resolved classification (`None` if unclassified).
226    pub classification: Option<Classification>,
227    /// `true` if the classification is valid (no violations).
228    pub valid: bool,
229    /// Number of non-build.rs findings that violate a "pure" classification.
230    pub violation_count: usize,
231}
232
233/// Verifies whether a crate's classification matches its audit findings.
234///
235/// A crate classified as `Pure` that has non-build.rs findings is a violation.
236/// Build.rs findings are excluded (compile-time only, not runtime authority).
237/// Resource and unclassified crates always pass.
238pub fn verify_classification(
239    classification: Option<Classification>,
240    findings: &[Finding],
241    crate_name: &str,
242    crate_version: &str,
243) -> ClassificationResult {
244    let violation_count = match classification {
245        Some(Classification::Pure) => findings
246            .iter()
247            .filter(|f| f.crate_name == crate_name && !f.is_build_script)
248            .count(),
249        _ => 0,
250    };
251
252    ClassificationResult {
253        crate_name: crate_name.to_string(),
254        crate_version: crate_version.to_string(),
255        classification,
256        valid: violation_count == 0,
257        violation_count,
258    }
259}
260
261/// Resolves the final classification for a crate by merging Cargo.toml metadata
262/// with `.capsec.toml` `[[classify]]` overrides.
263///
264/// Precedence: `.capsec.toml` wins over `Cargo.toml` metadata (consumer > author).
265pub fn resolve_classification(
266    crate_name: &str,
267    cargo_toml_classification: Option<Classification>,
268    config: &Config,
269) -> Option<Classification> {
270    // Check .capsec.toml [[classify]] first (consumer override)
271    for entry in &config.classify {
272        if entry.crate_name == crate_name {
273            return Some(entry.classification);
274        }
275    }
276    // Fall back to Cargo.toml metadata
277    cargo_toml_classification
278}
279
280/// Returns `true` if a file path matches any `[analysis].exclude` glob pattern.
281///
282/// Uses the [`globset`] crate for correct glob semantics (supports `**`, `*`,
283/// `?`, and character classes).
284pub fn should_exclude(path: &Path, excludes: &[String]) -> bool {
285    let path_str = path.display().to_string();
286    excludes.iter().any(|pattern| {
287        match globset::Glob::new(pattern) {
288            Ok(glob) => match glob.compile_matcher().is_match(&path_str) {
289                true => true,
290                false => {
291                    // Also try matching against just the file name for simple patterns
292                    path.file_name()
293                        .and_then(|n| n.to_str())
294                        .is_some_and(|name| glob.compile_matcher().is_match(name))
295                }
296            },
297            Err(_) => path_str.contains(pattern),
298        }
299    })
300}
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305
306    #[test]
307    fn parse_config() {
308        let toml = r#"
309            [analysis]
310            exclude = ["tests/**", "benches/**"]
311
312            [[authority]]
313            path = ["my_crate", "secrets", "fetch"]
314            category = "net"
315            risk = "critical"
316            description = "Fetches secrets"
317
318            [[allow]]
319            crate = "tracing"
320            reason = "Logging framework"
321        "#;
322
323        let config: Config = toml::from_str(toml).unwrap();
324        assert_eq!(config.analysis.exclude.len(), 2);
325        assert_eq!(config.authority.len(), 1);
326        assert_eq!(
327            config.authority[0].path,
328            vec!["my_crate", "secrets", "fetch"]
329        );
330        assert_eq!(config.allow.len(), 1);
331        assert_eq!(config.allow[0].effective_crate(), Some("tracing"));
332    }
333
334    #[test]
335    fn missing_config_returns_default() {
336        let root = capsec_core::root::test_root();
337        let cap = root.grant::<capsec_core::permission::FsRead>();
338        let config = load_config(Path::new("/nonexistent/path"), &cap).unwrap();
339        assert!(config.authority.is_empty());
340        assert!(config.allow.is_empty());
341    }
342
343    #[test]
344    fn parse_deny_config() {
345        let toml = r#"
346            [deny]
347            categories = ["all"]
348        "#;
349        let config: Config = toml::from_str(toml).unwrap();
350        assert_eq!(config.deny.categories, vec!["all"]);
351    }
352
353    #[test]
354    fn parse_deny_selective_categories() {
355        let toml = r#"
356            [deny]
357            categories = ["fs", "net"]
358        "#;
359        let config: Config = toml::from_str(toml).unwrap();
360        assert_eq!(config.deny.categories, vec!["fs", "net"]);
361    }
362
363    #[test]
364    fn missing_deny_section_defaults_to_empty() {
365        let toml = r#"
366            [[allow]]
367            crate = "tracing"
368        "#;
369        let config: Config = toml::from_str(toml).unwrap();
370        assert!(config.deny.categories.is_empty());
371    }
372
373    #[test]
374    fn normalized_categories_lowercases_and_filters() {
375        let deny = DenyConfig {
376            categories: vec!["FS".to_string(), "bogus".to_string(), "net".to_string()],
377        };
378        let normalized = deny.normalized_categories();
379        assert_eq!(normalized, vec!["fs", "net"]);
380    }
381
382    #[test]
383    fn parse_classify_entries() {
384        let toml = r#"
385            [[classify]]
386            crate = "serde"
387            classification = "pure"
388
389            [[classify]]
390            crate = "tokio"
391            classification = "resource"
392        "#;
393        let config: Config = toml::from_str(toml).unwrap();
394        assert_eq!(config.classify.len(), 2);
395        assert_eq!(config.classify[0].crate_name, "serde");
396        assert_eq!(config.classify[0].classification, Classification::Pure);
397        assert_eq!(config.classify[1].crate_name, "tokio");
398        assert_eq!(config.classify[1].classification, Classification::Resource);
399    }
400
401    #[test]
402    fn missing_classify_defaults_to_empty() {
403        let toml = r#"
404            [[allow]]
405            crate = "tracing"
406        "#;
407        let config: Config = toml::from_str(toml).unwrap();
408        assert!(config.classify.is_empty());
409    }
410
411    #[test]
412    fn resolve_capsec_toml_overrides_cargo_metadata() {
413        let config = Config {
414            classify: vec![ClassifyEntry {
415                crate_name: "my-lib".to_string(),
416                classification: Classification::Resource,
417            }],
418            ..Config::default()
419        };
420        let result = resolve_classification("my-lib", Some(Classification::Pure), &config);
421        assert_eq!(result, Some(Classification::Resource));
422    }
423
424    #[test]
425    fn resolve_falls_back_to_cargo_metadata() {
426        let config = Config::default();
427        let result = resolve_classification("my-lib", Some(Classification::Pure), &config);
428        assert_eq!(result, Some(Classification::Pure));
429    }
430
431    #[test]
432    fn resolve_unclassified_returns_none() {
433        let config = Config::default();
434        let result = resolve_classification("my-lib", None, &config);
435        assert_eq!(result, None);
436    }
437
438    #[test]
439    fn verify_pure_crate_with_no_findings_passes() {
440        let result = verify_classification(Some(Classification::Pure), &[], "my-lib", "0.1.0");
441        assert!(result.valid);
442        assert_eq!(result.violation_count, 0);
443    }
444
445    #[test]
446    fn verify_pure_crate_with_findings_fails() {
447        let findings = vec![Finding {
448            file: "src/lib.rs".to_string(),
449            function: "do_io".to_string(),
450            function_line: 1,
451            call_line: 2,
452            call_col: 5,
453            call_text: "std::fs::read".to_string(),
454            category: crate::authorities::Category::Fs,
455            subcategory: "read".to_string(),
456            risk: crate::authorities::Risk::Medium,
457            description: "Read file".to_string(),
458            is_build_script: false,
459            crate_name: "my-lib".to_string(),
460            crate_version: "0.1.0".to_string(),
461            is_deny_violation: false,
462            is_transitive: false,
463        }];
464        let result =
465            verify_classification(Some(Classification::Pure), &findings, "my-lib", "0.1.0");
466        assert!(!result.valid);
467        assert_eq!(result.violation_count, 1);
468    }
469
470    #[test]
471    fn verify_pure_crate_excludes_build_script_findings() {
472        let findings = vec![Finding {
473            file: "build.rs".to_string(),
474            function: "main".to_string(),
475            function_line: 1,
476            call_line: 2,
477            call_col: 5,
478            call_text: "std::env::var".to_string(),
479            category: crate::authorities::Category::Env,
480            subcategory: "read".to_string(),
481            risk: crate::authorities::Risk::Low,
482            description: "Read env var".to_string(),
483            is_build_script: true,
484            crate_name: "my-lib".to_string(),
485            crate_version: "0.1.0".to_string(),
486            is_deny_violation: false,
487            is_transitive: false,
488        }];
489        let result =
490            verify_classification(Some(Classification::Pure), &findings, "my-lib", "0.1.0");
491        assert!(result.valid);
492        assert_eq!(result.violation_count, 0);
493    }
494
495    #[test]
496    fn verify_resource_crate_always_passes() {
497        let findings = vec![Finding {
498            file: "src/lib.rs".to_string(),
499            function: "do_io".to_string(),
500            function_line: 1,
501            call_line: 2,
502            call_col: 5,
503            call_text: "std::fs::read".to_string(),
504            category: crate::authorities::Category::Fs,
505            subcategory: "read".to_string(),
506            risk: crate::authorities::Risk::Medium,
507            description: "Read file".to_string(),
508            is_build_script: false,
509            crate_name: "my-lib".to_string(),
510            crate_version: "0.1.0".to_string(),
511            is_deny_violation: false,
512            is_transitive: false,
513        }];
514        let result =
515            verify_classification(Some(Classification::Resource), &findings, "my-lib", "0.1.0");
516        assert!(result.valid);
517    }
518
519    #[test]
520    fn invalid_classification_value_errors() {
521        let toml = r#"
522            [[classify]]
523            crate = "bad"
524            classification = "unknown"
525        "#;
526        let result: Result<Config, _> = toml::from_str(toml);
527        assert!(result.is_err());
528    }
529
530    #[test]
531    fn exclude_pattern_matching() {
532        assert!(should_exclude(
533            Path::new("tests/integration.rs"),
534            &["tests/**".to_string()]
535        ));
536        assert!(!should_exclude(
537            Path::new("src/main.rs"),
538            &["tests/**".to_string()]
539        ));
540    }
541}