Skip to main content

cargo_capsec/
init.rs

1//! `cargo capsec init` — bootstrap capsec for an existing codebase.
2//!
3//! Runs a full audit, then generates a `.capsec.toml` that suppresses all existing
4//! findings. This lets teams adopt capsec incrementally: accept the current state,
5//! then catch regressions in CI.
6
7use crate::authorities::Risk;
8use crate::detector::Finding;
9use crate::{baseline, config, detector, discovery, parser};
10use std::collections::{BTreeMap, HashSet};
11use std::fmt::Write;
12use std::io::BufRead;
13use std::path::{Path, PathBuf};
14
15/// Options for `cargo capsec init`.
16pub struct InitOptions {
17    pub path: PathBuf,
18    pub ci: Option<String>,
19    pub interactive: bool,
20    pub report: bool,
21    pub exclude_tests: bool,
22    pub baseline: bool,
23    pub force: bool,
24}
25
26/// Runs the `cargo capsec init` command.
27pub fn run_init(opts: InitOptions) {
28    let cap_root = capsec_core::root::root();
29    let fs_read = cap_root.grant::<capsec_core::permission::FsRead>();
30    let fs_write = cap_root.grant::<capsec_core::permission::FsWrite>();
31    let spawn_cap = cap_root.grant::<capsec_core::permission::Spawn>();
32
33    let path_arg = opts.path.canonicalize().unwrap_or(opts.path.clone());
34    let config_path = path_arg.join(".capsec.toml");
35
36    // Check for existing config
37    if config_path.exists() && !opts.force {
38        eprintln!(
39            "Error: .capsec.toml already exists. Use --force to overwrite, or edit it manually."
40        );
41        std::process::exit(1);
42    }
43
44    // Interactive mode: prompt for choices
45    let (exclude_tests, save_baseline, ci_type, show_report) = if opts.interactive {
46        run_interactive(&opts)
47    } else {
48        (
49            opts.exclude_tests,
50            opts.baseline,
51            opts.ci.clone(),
52            opts.report,
53        )
54    };
55
56    eprintln!("Running audit...");
57
58    // Discover and scan (workspace only, no deps — init is about YOUR code)
59    let discovery = match discovery::discover_crates(&path_arg, false, &spawn_cap, &fs_read) {
60        Ok(d) => d,
61        Err(e) => {
62            eprintln!("Error: {e}");
63            eprintln!("Hint: Run from a directory containing Cargo.toml, or use --path");
64            std::process::exit(2);
65        }
66    };
67    let workspace_root = discovery.workspace_root;
68
69    let cfg = config::Config::default();
70    let customs = config::custom_authorities(&cfg);
71    let crate_deny = cfg.deny.normalized_categories();
72
73    let mut all_findings = Vec::new();
74
75    for krate in &discovery.crates {
76        if krate.is_dependency {
77            continue;
78        }
79
80        let mut det = detector::Detector::new();
81        det.add_custom_authorities(&customs);
82
83        let source_files = discovery::discover_source_files(&krate.source_dir, &fs_read);
84        for file_path in source_files {
85            match parser::parse_file(&file_path, &fs_read) {
86                Ok(parsed) => {
87                    let findings = det.analyse(&parsed, &krate.name, &krate.version, &crate_deny);
88                    all_findings.extend(findings);
89                }
90                Err(e) => {
91                    eprintln!("  Warning: {e}");
92                }
93            }
94        }
95    }
96
97    // Normalize paths
98    for f in &mut all_findings {
99        f.file = make_relative(&f.file, &workspace_root);
100    }
101
102    let crate_count = discovery.crates.iter().filter(|c| !c.is_dependency).count();
103    eprintln!(
104        "Found {} findings across {} crates.\n",
105        all_findings.len(),
106        crate_count
107    );
108
109    // Generate .capsec.toml
110    let toml_content = generate_capsec_toml(&all_findings, exclude_tests);
111    capsec_std::fs::write(&config_path, &toml_content, &fs_write)
112        .unwrap_or_else(|e| eprintln!("Error writing .capsec.toml: {e}"));
113
114    let allow_count = all_findings
115        .iter()
116        .map(|f| (&f.crate_name, &f.function))
117        .collect::<HashSet<_>>()
118        .len();
119    eprintln!("Written: .capsec.toml ({allow_count} allow rules)");
120
121    // Save baseline
122    if save_baseline {
123        match baseline::save_baseline(&workspace_root, &all_findings, &fs_write) {
124            Ok(()) => eprintln!("Written: .capsec-baseline.json"),
125            Err(e) => eprintln!("Warning: Failed to save baseline: {e}"),
126        }
127    }
128
129    // Generate CI config
130    if let Some(ref ci) = ci_type {
131        write_ci_config(ci, &path_arg, &fs_write);
132    }
133
134    // Migration report
135    if show_report {
136        print_migration_report(&all_findings);
137    }
138
139    eprintln!("\nDone! New findings will be caught by CI.");
140    eprintln!("To see existing findings: cargo capsec audit --diff");
141}
142
143/// Generates `.capsec.toml` content from current audit findings.
144fn generate_capsec_toml(findings: &[Finding], exclude_tests: bool) -> String {
145    let mut toml = String::new();
146    let _ = writeln!(toml, "# Auto-generated by `cargo capsec init`");
147    let _ = writeln!(
148        toml,
149        "# Remove allow rules as you migrate functions to capsec types."
150    );
151    let _ = writeln!(toml);
152
153    if exclude_tests {
154        let _ = writeln!(toml, "[analysis]");
155        let _ = writeln!(
156            toml,
157            "exclude = [\"tests/**\", \"benches/**\", \"examples/**\"]"
158        );
159        let _ = writeln!(toml);
160    }
161
162    // Deduplicate: one allow rule per (crate, function)
163    let mut seen = HashSet::new();
164    let _ = writeln!(
165        toml,
166        "# {} existing findings suppressed:",
167        findings
168            .iter()
169            .map(|f| (&f.crate_name, &f.function))
170            .collect::<HashSet<_>>()
171            .len()
172    );
173    let _ = writeln!(toml);
174
175    for f in findings {
176        let key = (f.crate_name.clone(), f.function.clone());
177        if !seen.insert(key) {
178            continue;
179        }
180
181        let _ = writeln!(toml, "[[allow]]");
182        let _ = writeln!(toml, "crate = \"{}\"", f.crate_name);
183        let _ = writeln!(toml, "function = \"{}\"", f.function);
184        let _ = writeln!(
185            toml,
186            "# {} {} \u{2014} {}:{}",
187            f.category.label(),
188            f.risk.label(),
189            f.file,
190            f.call_line
191        );
192        let _ = writeln!(toml);
193    }
194
195    toml
196}
197
198// ── CI templates ──
199
200const GITHUB_CI_TEMPLATE: &str = r#"name: capsec audit
201on: [push, pull_request]
202jobs:
203  audit:
204    runs-on: ubuntu-latest
205    steps:
206      - uses: actions/checkout@v4
207      - uses: actions-rust-lang/setup-rust-toolchain@v1
208      - run: cargo install cargo-capsec
209      - run: cargo capsec audit --fail-on high --format sarif > capsec.sarif
210      - uses: github/codeql-action/upload-sarif@v3
211        with:
212          sarif_file: capsec.sarif
213        if: always()
214"#;
215
216const GITLAB_CI_TEMPLATE: &str = r#"capsec-audit:
217  stage: test
218  image: rust:latest
219  script:
220    - cargo install cargo-capsec
221    - cargo capsec audit --fail-on high --quiet
222  rules:
223    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
224    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
225"#;
226
227const GENERIC_CI_TEMPLATE: &str = r#"#!/usr/bin/env bash
228set -euo pipefail
229
230# Install capsec
231cargo install cargo-capsec
232
233# Run audit — fails on high-risk or above
234cargo capsec audit --fail-on high --quiet
235
236echo "capsec audit passed."
237"#;
238
239fn write_ci_config(
240    ci_type: &str,
241    workspace_root: &Path,
242    fs_write: &impl capsec_core::cap_provider::CapProvider<capsec_core::permission::FsWrite>,
243) {
244    let (path, content) = match ci_type {
245        "github" => {
246            let dir = workspace_root.join(".github/workflows");
247            let _ = std::fs::create_dir_all(&dir);
248            (dir.join("capsec.yml"), GITHUB_CI_TEMPLATE)
249        }
250        "gitlab" => (
251            workspace_root.join(".gitlab-ci-capsec.yml"),
252            GITLAB_CI_TEMPLATE,
253        ),
254        "generic" => (workspace_root.join("capsec-audit.sh"), GENERIC_CI_TEMPLATE),
255        _ => return,
256    };
257
258    if path.exists() {
259        eprintln!("Warning: {} already exists, skipping", path.display());
260        return;
261    }
262
263    match capsec_std::fs::write(&path, content, fs_write) {
264        Ok(()) => eprintln!("Written: {}", path.display()),
265        Err(e) => eprintln!("Warning: Failed to write {}: {e}", path.display()),
266    }
267}
268
269// ── Interactive mode ──
270
271fn run_interactive(opts: &InitOptions) -> (bool, bool, Option<String>, bool) {
272    let exclude_tests = prompt_yn("Exclude test/bench/example files?", true);
273    let save_baseline = prompt_yn("Save baseline?", true);
274    let ci_type = prompt_choice(
275        "Generate CI config?",
276        &["none", "github", "gitlab", "generic"],
277        "none",
278    );
279    let ci = if ci_type == "none" {
280        None
281    } else {
282        Some(ci_type)
283    };
284    let show_report = opts.report || prompt_yn("Show migration priority report?", false);
285
286    (exclude_tests, save_baseline, ci, show_report)
287}
288
289fn prompt_yn(question: &str, default: bool) -> bool {
290    let hint = if default { "[Y/n]" } else { "[y/N]" };
291    eprint!("? {question} {hint} ");
292    let mut line = String::new();
293    let _ = std::io::stdin().lock().read_line(&mut line);
294    let trimmed = line.trim().to_lowercase();
295    if trimmed.is_empty() {
296        return default;
297    }
298    trimmed.starts_with('y')
299}
300
301fn prompt_choice(question: &str, choices: &[&str], default: &str) -> String {
302    eprint!("? {question} [{}] ", choices.join("/"));
303    let mut line = String::new();
304    let _ = std::io::stdin().lock().read_line(&mut line);
305    let trimmed = line.trim().to_lowercase();
306    if trimmed.is_empty() {
307        return default.to_string();
308    }
309    if choices.contains(&trimmed.as_str()) {
310        trimmed
311    } else {
312        default.to_string()
313    }
314}
315
316// ── Migration priority report ──
317
318fn print_migration_report(findings: &[Finding]) {
319    // Group direct findings by (crate, function) with their risk + location
320    let mut functions: BTreeMap<(String, String), (Risk, String, usize, usize)> = BTreeMap::new();
321    // Count how many transitive findings reference each function
322    let mut transitive_refs: BTreeMap<String, usize> = BTreeMap::new();
323
324    for f in findings {
325        if f.is_transitive {
326            // call_text is the callee function name for transitive findings
327            *transitive_refs.entry(f.call_text.clone()).or_default() += 1;
328        } else {
329            functions
330                .entry((f.crate_name.clone(), f.function.clone()))
331                .or_insert((f.risk, f.file.clone(), f.call_line, 0));
332        }
333    }
334
335    // Merge transitive ref counts into function entries
336    for ((_, func_name), entry) in &mut functions {
337        if let Some(&count) = transitive_refs.get(func_name) {
338            entry.3 = count;
339        }
340    }
341
342    // Sort by risk (desc) then callers (desc)
343    let mut sorted: Vec<_> = functions.into_iter().collect();
344    sorted.sort_by(|a, b| b.1.0.cmp(&a.1.0).then_with(|| b.1.3.cmp(&a.1.3)));
345
346    eprintln!("\nMigration Priority (by risk \u{00d7} frequency)");
347    eprintln!(
348        "\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}"
349    );
350
351    for (i, ((crate_name, func), (risk, file, line, callers))) in sorted.iter().enumerate().take(20)
352    {
353        let caller_str = if *callers > 0 {
354            format!(" \u{2014} called from {callers} functions")
355        } else {
356            String::new()
357        };
358        eprintln!(
359            "  {:<3} {}()  {:<20} {}:{}{caller_str}",
360            format!("{}.", i + 1),
361            func,
362            format!("{crate_name}/{}", risk.label()),
363            file,
364            line,
365        );
366    }
367
368    if sorted.len() > 20 {
369        eprintln!("  ... and {} more", sorted.len() - 20);
370    }
371}
372
373fn make_relative(file_path: &str, workspace_root: &Path) -> String {
374    Path::new(file_path)
375        .strip_prefix(workspace_root)
376        .map(|p| p.to_string_lossy().to_string())
377        .unwrap_or_else(|_| file_path.to_string())
378}