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::cap_provider::CapProvider<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/// Pre-compiled exclude patterns for efficient repeated matching.
281#[allow(dead_code)]
282pub struct CompiledExcludes {
283    set: globset::GlobSet,
284}
285
286#[allow(dead_code)]
287impl CompiledExcludes {
288    /// Compiles exclude patterns once. Invalid patterns are silently skipped.
289    pub fn new(patterns: &[String]) -> Self {
290        let mut builder = globset::GlobSetBuilder::new();
291        for p in patterns {
292            if let Ok(glob) = globset::Glob::new(p) {
293                builder.add(glob);
294            }
295        }
296        Self {
297            set: builder
298                .build()
299                .unwrap_or_else(|_| globset::GlobSetBuilder::new().build().unwrap()),
300        }
301    }
302
303    /// Returns `true` if a file path matches any compiled exclude pattern.
304    pub fn is_excluded(&self, path: &Path) -> bool {
305        let path_str = path.display().to_string();
306        self.set.is_match(&path_str)
307            || path
308                .file_name()
309                .and_then(|n| n.to_str())
310                .is_some_and(|name| self.set.is_match(name))
311    }
312}
313
314/// Returns `true` if a file path matches any `[analysis].exclude` glob pattern.
315///
316/// Uses the [`globset`] crate for correct glob semantics (supports `**`, `*`,
317/// `?`, and character classes).
318pub fn should_exclude(path: &Path, excludes: &[String]) -> bool {
319    let path_str = path.display().to_string();
320    excludes
321        .iter()
322        .any(|pattern| match globset::Glob::new(pattern) {
323            Ok(glob) => match glob.compile_matcher().is_match(&path_str) {
324                true => true,
325                false => path
326                    .file_name()
327                    .and_then(|n| n.to_str())
328                    .is_some_and(|name| glob.compile_matcher().is_match(name)),
329            },
330            Err(_) => path_str.contains(pattern),
331        })
332}
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337
338    #[test]
339    fn parse_config() {
340        let toml = r#"
341            [analysis]
342            exclude = ["tests/**", "benches/**"]
343
344            [[authority]]
345            path = ["my_crate", "secrets", "fetch"]
346            category = "net"
347            risk = "critical"
348            description = "Fetches secrets"
349
350            [[allow]]
351            crate = "tracing"
352            reason = "Logging framework"
353        "#;
354
355        let config: Config = toml::from_str(toml).unwrap();
356        assert_eq!(config.analysis.exclude.len(), 2);
357        assert_eq!(config.authority.len(), 1);
358        assert_eq!(
359            config.authority[0].path,
360            vec!["my_crate", "secrets", "fetch"]
361        );
362        assert_eq!(config.allow.len(), 1);
363        assert_eq!(config.allow[0].effective_crate(), Some("tracing"));
364    }
365
366    #[test]
367    fn missing_config_returns_default() {
368        let root = capsec_core::root::test_root();
369        let cap = root.grant::<capsec_core::permission::FsRead>();
370        let config = load_config(Path::new("/nonexistent/path"), &cap).unwrap();
371        assert!(config.authority.is_empty());
372        assert!(config.allow.is_empty());
373    }
374
375    #[test]
376    fn parse_deny_config() {
377        let toml = r#"
378            [deny]
379            categories = ["all"]
380        "#;
381        let config: Config = toml::from_str(toml).unwrap();
382        assert_eq!(config.deny.categories, vec!["all"]);
383    }
384
385    #[test]
386    fn parse_deny_selective_categories() {
387        let toml = r#"
388            [deny]
389            categories = ["fs", "net"]
390        "#;
391        let config: Config = toml::from_str(toml).unwrap();
392        assert_eq!(config.deny.categories, vec!["fs", "net"]);
393    }
394
395    #[test]
396    fn missing_deny_section_defaults_to_empty() {
397        let toml = r#"
398            [[allow]]
399            crate = "tracing"
400        "#;
401        let config: Config = toml::from_str(toml).unwrap();
402        assert!(config.deny.categories.is_empty());
403    }
404
405    #[test]
406    fn normalized_categories_lowercases_and_filters() {
407        let deny = DenyConfig {
408            categories: vec!["FS".to_string(), "bogus".to_string(), "net".to_string()],
409        };
410        let normalized = deny.normalized_categories();
411        assert_eq!(normalized, vec!["fs", "net"]);
412    }
413
414    #[test]
415    fn parse_classify_entries() {
416        let toml = r#"
417            [[classify]]
418            crate = "serde"
419            classification = "pure"
420
421            [[classify]]
422            crate = "tokio"
423            classification = "resource"
424        "#;
425        let config: Config = toml::from_str(toml).unwrap();
426        assert_eq!(config.classify.len(), 2);
427        assert_eq!(config.classify[0].crate_name, "serde");
428        assert_eq!(config.classify[0].classification, Classification::Pure);
429        assert_eq!(config.classify[1].crate_name, "tokio");
430        assert_eq!(config.classify[1].classification, Classification::Resource);
431    }
432
433    #[test]
434    fn missing_classify_defaults_to_empty() {
435        let toml = r#"
436            [[allow]]
437            crate = "tracing"
438        "#;
439        let config: Config = toml::from_str(toml).unwrap();
440        assert!(config.classify.is_empty());
441    }
442
443    #[test]
444    fn resolve_capsec_toml_overrides_cargo_metadata() {
445        let config = Config {
446            classify: vec![ClassifyEntry {
447                crate_name: "my-lib".to_string(),
448                classification: Classification::Resource,
449            }],
450            ..Config::default()
451        };
452        let result = resolve_classification("my-lib", Some(Classification::Pure), &config);
453        assert_eq!(result, Some(Classification::Resource));
454    }
455
456    #[test]
457    fn resolve_falls_back_to_cargo_metadata() {
458        let config = Config::default();
459        let result = resolve_classification("my-lib", Some(Classification::Pure), &config);
460        assert_eq!(result, Some(Classification::Pure));
461    }
462
463    #[test]
464    fn resolve_unclassified_returns_none() {
465        let config = Config::default();
466        let result = resolve_classification("my-lib", None, &config);
467        assert_eq!(result, None);
468    }
469
470    #[test]
471    fn verify_pure_crate_with_no_findings_passes() {
472        let result = verify_classification(Some(Classification::Pure), &[], "my-lib", "0.1.0");
473        assert!(result.valid);
474        assert_eq!(result.violation_count, 0);
475    }
476
477    #[test]
478    fn verify_pure_crate_with_findings_fails() {
479        let findings = vec![Finding {
480            file: "src/lib.rs".to_string(),
481            function: "do_io".to_string(),
482            function_line: 1,
483            call_line: 2,
484            call_col: 5,
485            call_text: "std::fs::read".to_string(),
486            category: crate::authorities::Category::Fs,
487            subcategory: "read".to_string(),
488            risk: crate::authorities::Risk::Medium,
489            description: "Read file".to_string(),
490            is_build_script: false,
491            crate_name: "my-lib".to_string(),
492            crate_version: "0.1.0".to_string(),
493            is_deny_violation: false,
494            is_transitive: false,
495        }];
496        let result =
497            verify_classification(Some(Classification::Pure), &findings, "my-lib", "0.1.0");
498        assert!(!result.valid);
499        assert_eq!(result.violation_count, 1);
500    }
501
502    #[test]
503    fn verify_pure_crate_excludes_build_script_findings() {
504        let findings = vec![Finding {
505            file: "build.rs".to_string(),
506            function: "main".to_string(),
507            function_line: 1,
508            call_line: 2,
509            call_col: 5,
510            call_text: "std::env::var".to_string(),
511            category: crate::authorities::Category::Env,
512            subcategory: "read".to_string(),
513            risk: crate::authorities::Risk::Low,
514            description: "Read env var".to_string(),
515            is_build_script: true,
516            crate_name: "my-lib".to_string(),
517            crate_version: "0.1.0".to_string(),
518            is_deny_violation: false,
519            is_transitive: false,
520        }];
521        let result =
522            verify_classification(Some(Classification::Pure), &findings, "my-lib", "0.1.0");
523        assert!(result.valid);
524        assert_eq!(result.violation_count, 0);
525    }
526
527    #[test]
528    fn verify_resource_crate_always_passes() {
529        let findings = vec![Finding {
530            file: "src/lib.rs".to_string(),
531            function: "do_io".to_string(),
532            function_line: 1,
533            call_line: 2,
534            call_col: 5,
535            call_text: "std::fs::read".to_string(),
536            category: crate::authorities::Category::Fs,
537            subcategory: "read".to_string(),
538            risk: crate::authorities::Risk::Medium,
539            description: "Read file".to_string(),
540            is_build_script: false,
541            crate_name: "my-lib".to_string(),
542            crate_version: "0.1.0".to_string(),
543            is_deny_violation: false,
544            is_transitive: false,
545        }];
546        let result =
547            verify_classification(Some(Classification::Resource), &findings, "my-lib", "0.1.0");
548        assert!(result.valid);
549    }
550
551    #[test]
552    fn invalid_classification_value_errors() {
553        let toml = r#"
554            [[classify]]
555            crate = "bad"
556            classification = "unknown"
557        "#;
558        let result: Result<Config, _> = toml::from_str(toml);
559        assert!(result.is_err());
560    }
561
562    #[test]
563    fn exclude_pattern_matching() {
564        assert!(should_exclude(
565            Path::new("tests/integration.rs"),
566            &["tests/**".to_string()]
567        ));
568        assert!(!should_exclude(
569            Path::new("src/main.rs"),
570            &["tests/**".to_string()]
571        ));
572    }
573}