Skip to main content

cc_audit/
cli.rs

1use crate::rules::Confidence;
2use clap::{Parser, ValueEnum};
3use std::path::PathBuf;
4
5#[derive(Debug, Clone, Copy, ValueEnum, Default, PartialEq, Eq)]
6pub enum OutputFormat {
7    #[default]
8    Terminal,
9    Json,
10    Sarif,
11    Html,
12}
13
14#[derive(Debug, Clone, Copy, ValueEnum, Default, PartialEq, Eq)]
15pub enum ScanType {
16    #[default]
17    Skill,
18    Hook,
19    Mcp,
20    Command,
21    Rules,
22    Docker,
23    Dependency,
24    /// Scan .claude/agents/ subagent definitions
25    Subagent,
26    /// Scan marketplace.json plugin definitions
27    Plugin,
28}
29
30#[derive(Parser, Debug)]
31#[command(
32    name = "cc-audit",
33    version,
34    about = "Security auditor for Claude Code skills, hooks, and MCP servers",
35    long_about = "cc-audit scans Claude Code skills, hooks, and MCP servers for security vulnerabilities before installation."
36)]
37pub struct Cli {
38    /// Paths to scan (files or directories)
39    #[arg(required = true)]
40    pub paths: Vec<PathBuf>,
41
42    /// Output format
43    #[arg(short, long, value_enum, default_value_t = OutputFormat::Terminal)]
44    pub format: OutputFormat,
45
46    /// Strict mode: show medium/low severity findings and treat warnings as errors
47    #[arg(short, long)]
48    pub strict: bool,
49
50    /// Scan type
51    #[arg(short = 't', long = "type", value_enum, default_value_t = ScanType::Skill)]
52    pub scan_type: ScanType,
53
54    /// Recursive scan
55    #[arg(short, long)]
56    pub recursive: bool,
57
58    /// CI mode: non-interactive output
59    #[arg(long)]
60    pub ci: bool,
61
62    /// Verbose output
63    #[arg(short, long)]
64    pub verbose: bool,
65
66    /// Include test directories (tests/, spec/, __tests__, etc.) in scan
67    #[arg(long)]
68    pub include_tests: bool,
69
70    /// Include node_modules directories in scan
71    #[arg(long)]
72    pub include_node_modules: bool,
73
74    /// Include vendor directories (vendor/, third_party/) in scan
75    #[arg(long)]
76    pub include_vendor: bool,
77
78    /// Minimum confidence level for findings to be reported
79    #[arg(long, value_enum, default_value_t = Confidence::Tentative)]
80    pub min_confidence: Confidence,
81
82    /// Skip comment lines when scanning (lines starting with #, //, --, etc.)
83    #[arg(long)]
84    pub skip_comments: bool,
85
86    /// Show fix hints in terminal output
87    #[arg(long)]
88    pub fix_hint: bool,
89
90    /// Watch mode: continuously monitor files for changes and re-scan
91    #[arg(short, long)]
92    pub watch: bool,
93
94    /// Install cc-audit pre-commit hook in the git repository
95    #[arg(long)]
96    pub init_hook: bool,
97
98    /// Remove cc-audit pre-commit hook from the git repository
99    #[arg(long)]
100    pub remove_hook: bool,
101
102    /// Path to a custom malware signatures database (JSON)
103    #[arg(long)]
104    pub malware_db: Option<PathBuf>,
105
106    /// Disable malware signature scanning
107    #[arg(long)]
108    pub no_malware_scan: bool,
109
110    /// Path to a custom rules file (YAML format)
111    #[arg(long)]
112    pub custom_rules: Option<PathBuf>,
113
114    /// Create a baseline snapshot for drift detection (rug pull prevention)
115    #[arg(long)]
116    pub baseline: bool,
117
118    /// Check for drift against saved baseline
119    #[arg(long)]
120    pub check_drift: bool,
121
122    /// Generate a default configuration file template
123    #[arg(long)]
124    pub init: bool,
125
126    /// Output file path (for HTML/JSON output)
127    #[arg(short, long)]
128    pub output: Option<PathBuf>,
129
130    /// Save baseline to specified file
131    #[arg(long, value_name = "FILE")]
132    pub save_baseline: Option<PathBuf>,
133
134    /// Compare against baseline file (show only new findings)
135    #[arg(long, value_name = "FILE")]
136    pub baseline_file: Option<PathBuf>,
137
138    /// Compare two paths and show differences
139    #[arg(long, num_args = 2, value_names = ["PATH1", "PATH2"])]
140    pub compare: Option<Vec<PathBuf>>,
141
142    /// Auto-fix issues (where possible)
143    #[arg(long)]
144    pub fix: bool,
145
146    /// Preview auto-fix changes without applying them
147    #[arg(long)]
148    pub fix_dry_run: bool,
149
150    /// Run as MCP server
151    #[arg(long)]
152    pub mcp_server: bool,
153
154    /// Enable deep scan with deobfuscation
155    #[arg(long)]
156    pub deep_scan: bool,
157
158    /// Load settings from a named profile
159    #[arg(long, value_name = "NAME")]
160    pub profile: Option<String>,
161
162    /// Save current settings as a named profile
163    #[arg(long, value_name = "NAME")]
164    pub save_profile: Option<String>,
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170    use crate::rules::Confidence;
171    use clap::CommandFactory;
172
173    #[test]
174    fn test_cli_valid() {
175        Cli::command().debug_assert();
176    }
177
178    #[test]
179    fn test_parse_basic_args() {
180        let cli = Cli::try_parse_from(["cc-audit", "./skill/"]).unwrap();
181        assert_eq!(cli.paths.len(), 1);
182        assert!(!cli.strict);
183        assert!(!cli.recursive);
184    }
185
186    #[test]
187    fn test_parse_multiple_paths() {
188        let cli = Cli::try_parse_from(["cc-audit", "./skill1/", "./skill2/"]).unwrap();
189        assert_eq!(cli.paths.len(), 2);
190    }
191
192    #[test]
193    fn test_parse_format_json() {
194        let cli = Cli::try_parse_from(["cc-audit", "--format", "json", "./skill/"]).unwrap();
195        assert!(matches!(cli.format, OutputFormat::Json));
196    }
197
198    #[test]
199    fn test_parse_strict_mode() {
200        let cli = Cli::try_parse_from(["cc-audit", "--strict", "./skill/"]).unwrap();
201        assert!(cli.strict);
202    }
203
204    #[test]
205    fn test_parse_recursive() {
206        let cli = Cli::try_parse_from(["cc-audit", "-r", "./skills/"]).unwrap();
207        assert!(cli.recursive);
208    }
209
210    #[test]
211    fn test_parse_format_sarif() {
212        let cli = Cli::try_parse_from(["cc-audit", "--format", "sarif", "./skill/"]).unwrap();
213        assert!(matches!(cli.format, OutputFormat::Sarif));
214    }
215
216    #[test]
217    fn test_parse_type_hook() {
218        let cli = Cli::try_parse_from(["cc-audit", "--type", "hook", "./settings.json"]).unwrap();
219        assert!(matches!(cli.scan_type, ScanType::Hook));
220    }
221
222    #[test]
223    fn test_parse_type_mcp() {
224        let cli = Cli::try_parse_from(["cc-audit", "--type", "mcp", "./mcp.json"]).unwrap();
225        assert!(matches!(cli.scan_type, ScanType::Mcp));
226    }
227
228    #[test]
229    fn test_parse_type_command() {
230        let cli = Cli::try_parse_from(["cc-audit", "--type", "command", "./"]).unwrap();
231        assert!(matches!(cli.scan_type, ScanType::Command));
232    }
233
234    #[test]
235    fn test_parse_type_rules() {
236        let cli = Cli::try_parse_from(["cc-audit", "--type", "rules", "./"]).unwrap();
237        assert!(matches!(cli.scan_type, ScanType::Rules));
238    }
239
240    #[test]
241    fn test_parse_type_docker() {
242        let cli = Cli::try_parse_from(["cc-audit", "--type", "docker", "./"]).unwrap();
243        assert!(matches!(cli.scan_type, ScanType::Docker));
244    }
245
246    #[test]
247    fn test_parse_type_dependency() {
248        let cli = Cli::try_parse_from(["cc-audit", "--type", "dependency", "./"]).unwrap();
249        assert!(matches!(cli.scan_type, ScanType::Dependency));
250    }
251
252    #[test]
253    fn test_parse_ci_mode() {
254        let cli = Cli::try_parse_from(["cc-audit", "--ci", "./skill/"]).unwrap();
255        assert!(cli.ci);
256    }
257
258    #[test]
259    fn test_parse_verbose() {
260        let cli = Cli::try_parse_from(["cc-audit", "-v", "./skill/"]).unwrap();
261        assert!(cli.verbose);
262    }
263
264    #[test]
265    fn test_parse_all_options() {
266        let cli = Cli::try_parse_from([
267            "cc-audit",
268            "--format",
269            "json",
270            "--strict",
271            "--type",
272            "hook",
273            "--recursive",
274            "--ci",
275            "--verbose",
276            "./path/",
277        ])
278        .unwrap();
279        assert!(matches!(cli.format, OutputFormat::Json));
280        assert!(cli.strict);
281        assert!(matches!(cli.scan_type, ScanType::Hook));
282        assert!(cli.recursive);
283        assert!(cli.ci);
284        assert!(cli.verbose);
285    }
286
287    #[test]
288    fn test_default_values() {
289        let cli = Cli::try_parse_from(["cc-audit", "./skill/"]).unwrap();
290        assert!(matches!(cli.format, OutputFormat::Terminal));
291        assert!(matches!(cli.scan_type, ScanType::Skill));
292        assert!(!cli.strict);
293        assert!(!cli.recursive);
294        assert!(!cli.ci);
295        assert!(!cli.verbose);
296        assert!(!cli.include_tests);
297        assert!(!cli.include_node_modules);
298        assert!(!cli.include_vendor);
299        assert!(matches!(cli.min_confidence, Confidence::Tentative));
300    }
301
302    #[test]
303    fn test_parse_include_tests() {
304        let cli = Cli::try_parse_from(["cc-audit", "--include-tests", "./skill/"]).unwrap();
305        assert!(cli.include_tests);
306    }
307
308    #[test]
309    fn test_parse_include_node_modules() {
310        let cli = Cli::try_parse_from(["cc-audit", "--include-node-modules", "./skill/"]).unwrap();
311        assert!(cli.include_node_modules);
312    }
313
314    #[test]
315    fn test_parse_include_vendor() {
316        let cli = Cli::try_parse_from(["cc-audit", "--include-vendor", "./skill/"]).unwrap();
317        assert!(cli.include_vendor);
318    }
319
320    #[test]
321    fn test_parse_all_include_options() {
322        let cli = Cli::try_parse_from([
323            "cc-audit",
324            "--include-tests",
325            "--include-node-modules",
326            "--include-vendor",
327            "./skill/",
328        ])
329        .unwrap();
330        assert!(cli.include_tests);
331        assert!(cli.include_node_modules);
332        assert!(cli.include_vendor);
333    }
334
335    #[test]
336    fn test_parse_min_confidence_tentative() {
337        let cli =
338            Cli::try_parse_from(["cc-audit", "--min-confidence", "tentative", "./skill/"]).unwrap();
339        assert!(matches!(cli.min_confidence, Confidence::Tentative));
340    }
341
342    #[test]
343    fn test_parse_min_confidence_firm() {
344        let cli =
345            Cli::try_parse_from(["cc-audit", "--min-confidence", "firm", "./skill/"]).unwrap();
346        assert!(matches!(cli.min_confidence, Confidence::Firm));
347    }
348
349    #[test]
350    fn test_parse_min_confidence_certain() {
351        let cli =
352            Cli::try_parse_from(["cc-audit", "--min-confidence", "certain", "./skill/"]).unwrap();
353        assert!(matches!(cli.min_confidence, Confidence::Certain));
354    }
355
356    #[test]
357    fn test_parse_skip_comments() {
358        let cli = Cli::try_parse_from(["cc-audit", "--skip-comments", "./skill/"]).unwrap();
359        assert!(cli.skip_comments);
360    }
361
362    #[test]
363    fn test_default_skip_comments_false() {
364        let cli = Cli::try_parse_from(["cc-audit", "./skill/"]).unwrap();
365        assert!(!cli.skip_comments);
366    }
367
368    #[test]
369    fn test_parse_fix_hint() {
370        let cli = Cli::try_parse_from(["cc-audit", "--fix-hint", "./skill/"]).unwrap();
371        assert!(cli.fix_hint);
372    }
373
374    #[test]
375    fn test_default_fix_hint_false() {
376        let cli = Cli::try_parse_from(["cc-audit", "./skill/"]).unwrap();
377        assert!(!cli.fix_hint);
378    }
379
380    #[test]
381    fn test_parse_watch() {
382        let cli = Cli::try_parse_from(["cc-audit", "--watch", "./skill/"]).unwrap();
383        assert!(cli.watch);
384    }
385
386    #[test]
387    fn test_parse_watch_short() {
388        let cli = Cli::try_parse_from(["cc-audit", "-w", "./skill/"]).unwrap();
389        assert!(cli.watch);
390    }
391
392    #[test]
393    fn test_default_watch_false() {
394        let cli = Cli::try_parse_from(["cc-audit", "./skill/"]).unwrap();
395        assert!(!cli.watch);
396    }
397
398    #[test]
399    fn test_parse_init_hook() {
400        let cli = Cli::try_parse_from(["cc-audit", "--init-hook", "./repo/"]).unwrap();
401        assert!(cli.init_hook);
402    }
403
404    #[test]
405    fn test_parse_remove_hook() {
406        let cli = Cli::try_parse_from(["cc-audit", "--remove-hook", "./repo/"]).unwrap();
407        assert!(cli.remove_hook);
408    }
409
410    #[test]
411    fn test_default_init_hook_false() {
412        let cli = Cli::try_parse_from(["cc-audit", "./skill/"]).unwrap();
413        assert!(!cli.init_hook);
414    }
415
416    #[test]
417    fn test_default_remove_hook_false() {
418        let cli = Cli::try_parse_from(["cc-audit", "./skill/"]).unwrap();
419        assert!(!cli.remove_hook);
420    }
421
422    #[test]
423    fn test_parse_malware_db() {
424        let cli =
425            Cli::try_parse_from(["cc-audit", "--malware-db", "./custom.json", "./skill/"]).unwrap();
426        assert!(cli.malware_db.is_some());
427        assert_eq!(cli.malware_db.unwrap().to_str().unwrap(), "./custom.json");
428    }
429
430    #[test]
431    fn test_default_malware_db_none() {
432        let cli = Cli::try_parse_from(["cc-audit", "./skill/"]).unwrap();
433        assert!(cli.malware_db.is_none());
434    }
435
436    #[test]
437    fn test_parse_no_malware_scan() {
438        let cli = Cli::try_parse_from(["cc-audit", "--no-malware-scan", "./skill/"]).unwrap();
439        assert!(cli.no_malware_scan);
440    }
441
442    #[test]
443    fn test_default_no_malware_scan_false() {
444        let cli = Cli::try_parse_from(["cc-audit", "./skill/"]).unwrap();
445        assert!(!cli.no_malware_scan);
446    }
447
448    #[test]
449    fn test_parse_custom_rules() {
450        let cli = Cli::try_parse_from(["cc-audit", "--custom-rules", "./rules.yaml", "./skill/"])
451            .unwrap();
452        assert!(cli.custom_rules.is_some());
453        assert_eq!(cli.custom_rules.unwrap().to_str().unwrap(), "./rules.yaml");
454    }
455
456    #[test]
457    fn test_default_custom_rules_none() {
458        let cli = Cli::try_parse_from(["cc-audit", "./skill/"]).unwrap();
459        assert!(cli.custom_rules.is_none());
460    }
461
462    #[test]
463    fn test_parse_init() {
464        let cli = Cli::try_parse_from(["cc-audit", "--init", "./"]).unwrap();
465        assert!(cli.init);
466    }
467
468    #[test]
469    fn test_default_init_false() {
470        let cli = Cli::try_parse_from(["cc-audit", "./skill/"]).unwrap();
471        assert!(!cli.init);
472    }
473}