Skip to main content

cc_audit/
cli.rs

1use crate::rules::{Confidence, RuleSeverity, Severity};
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    /// Warn-only mode: treat all findings as warnings (exit code 0)
51    #[arg(long)]
52    pub warn_only: bool,
53
54    /// Minimum severity level to include in output (critical, high, medium, low)
55    #[arg(long, value_enum)]
56    pub min_severity: Option<Severity>,
57
58    /// Minimum rule severity to treat as errors (error, warn)
59    #[arg(long, value_enum)]
60    pub min_rule_severity: Option<RuleSeverity>,
61
62    /// Scan type
63    #[arg(short = 't', long = "type", value_enum, default_value_t = ScanType::Skill)]
64    pub scan_type: ScanType,
65
66    /// Recursive scan
67    #[arg(short, long)]
68    pub recursive: bool,
69
70    /// CI mode: non-interactive output
71    #[arg(long)]
72    pub ci: bool,
73
74    /// Verbose output
75    #[arg(short, long)]
76    pub verbose: bool,
77
78    /// Include test directories (tests/, spec/, __tests__, etc.) in scan
79    #[arg(long)]
80    pub include_tests: bool,
81
82    /// Include node_modules directories in scan
83    #[arg(long)]
84    pub include_node_modules: bool,
85
86    /// Include vendor directories (vendor/, third_party/) in scan
87    #[arg(long)]
88    pub include_vendor: bool,
89
90    /// Minimum confidence level for findings to be reported
91    #[arg(long, value_enum, default_value_t = Confidence::Tentative)]
92    pub min_confidence: Confidence,
93
94    /// Skip comment lines when scanning (lines starting with #, //, --, etc.)
95    #[arg(long)]
96    pub skip_comments: bool,
97
98    /// Show fix hints in terminal output
99    #[arg(long)]
100    pub fix_hint: bool,
101
102    /// Watch mode: continuously monitor files for changes and re-scan
103    #[arg(short, long)]
104    pub watch: bool,
105
106    /// Install cc-audit pre-commit hook in the git repository
107    #[arg(long)]
108    pub init_hook: bool,
109
110    /// Remove cc-audit pre-commit hook from the git repository
111    #[arg(long)]
112    pub remove_hook: bool,
113
114    /// Path to a custom malware signatures database (JSON)
115    #[arg(long)]
116    pub malware_db: Option<PathBuf>,
117
118    /// Disable malware signature scanning
119    #[arg(long)]
120    pub no_malware_scan: bool,
121
122    /// Path to a custom rules file (YAML format)
123    #[arg(long)]
124    pub custom_rules: Option<PathBuf>,
125
126    /// Create a baseline snapshot for drift detection (rug pull prevention)
127    #[arg(long)]
128    pub baseline: bool,
129
130    /// Check for drift against saved baseline
131    #[arg(long)]
132    pub check_drift: bool,
133
134    /// Generate a default configuration file template
135    #[arg(long)]
136    pub init: bool,
137
138    /// Output file path (for HTML/JSON output)
139    #[arg(short, long)]
140    pub output: Option<PathBuf>,
141
142    /// Save baseline to specified file
143    #[arg(long, value_name = "FILE")]
144    pub save_baseline: Option<PathBuf>,
145
146    /// Compare against baseline file (show only new findings)
147    #[arg(long, value_name = "FILE")]
148    pub baseline_file: Option<PathBuf>,
149
150    /// Compare two paths and show differences
151    #[arg(long, num_args = 2, value_names = ["PATH1", "PATH2"])]
152    pub compare: Option<Vec<PathBuf>>,
153
154    /// Auto-fix issues (where possible)
155    #[arg(long)]
156    pub fix: bool,
157
158    /// Preview auto-fix changes without applying them
159    #[arg(long)]
160    pub fix_dry_run: bool,
161
162    /// Run as MCP server
163    #[arg(long)]
164    pub mcp_server: bool,
165
166    /// Enable deep scan with deobfuscation
167    #[arg(long)]
168    pub deep_scan: bool,
169
170    /// Load settings from a named profile
171    #[arg(long, value_name = "NAME")]
172    pub profile: Option<String>,
173
174    /// Save current settings as a named profile
175    #[arg(long, value_name = "NAME")]
176    pub save_profile: Option<String>,
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182    use crate::rules::{Confidence, RuleSeverity, Severity};
183    use clap::CommandFactory;
184
185    #[test]
186    fn test_cli_valid() {
187        Cli::command().debug_assert();
188    }
189
190    #[test]
191    fn test_parse_basic_args() {
192        let cli = Cli::try_parse_from(["cc-audit", "./skill/"]).unwrap();
193        assert_eq!(cli.paths.len(), 1);
194        assert!(!cli.strict);
195        assert!(!cli.recursive);
196    }
197
198    #[test]
199    fn test_parse_multiple_paths() {
200        let cli = Cli::try_parse_from(["cc-audit", "./skill1/", "./skill2/"]).unwrap();
201        assert_eq!(cli.paths.len(), 2);
202    }
203
204    #[test]
205    fn test_parse_format_json() {
206        let cli = Cli::try_parse_from(["cc-audit", "--format", "json", "./skill/"]).unwrap();
207        assert!(matches!(cli.format, OutputFormat::Json));
208    }
209
210    #[test]
211    fn test_parse_strict_mode() {
212        let cli = Cli::try_parse_from(["cc-audit", "--strict", "./skill/"]).unwrap();
213        assert!(cli.strict);
214    }
215
216    #[test]
217    fn test_parse_recursive() {
218        let cli = Cli::try_parse_from(["cc-audit", "-r", "./skills/"]).unwrap();
219        assert!(cli.recursive);
220    }
221
222    #[test]
223    fn test_parse_format_sarif() {
224        let cli = Cli::try_parse_from(["cc-audit", "--format", "sarif", "./skill/"]).unwrap();
225        assert!(matches!(cli.format, OutputFormat::Sarif));
226    }
227
228    #[test]
229    fn test_parse_type_hook() {
230        let cli = Cli::try_parse_from(["cc-audit", "--type", "hook", "./settings.json"]).unwrap();
231        assert!(matches!(cli.scan_type, ScanType::Hook));
232    }
233
234    #[test]
235    fn test_parse_type_mcp() {
236        let cli = Cli::try_parse_from(["cc-audit", "--type", "mcp", "./mcp.json"]).unwrap();
237        assert!(matches!(cli.scan_type, ScanType::Mcp));
238    }
239
240    #[test]
241    fn test_parse_type_command() {
242        let cli = Cli::try_parse_from(["cc-audit", "--type", "command", "./"]).unwrap();
243        assert!(matches!(cli.scan_type, ScanType::Command));
244    }
245
246    #[test]
247    fn test_parse_type_rules() {
248        let cli = Cli::try_parse_from(["cc-audit", "--type", "rules", "./"]).unwrap();
249        assert!(matches!(cli.scan_type, ScanType::Rules));
250    }
251
252    #[test]
253    fn test_parse_type_docker() {
254        let cli = Cli::try_parse_from(["cc-audit", "--type", "docker", "./"]).unwrap();
255        assert!(matches!(cli.scan_type, ScanType::Docker));
256    }
257
258    #[test]
259    fn test_parse_type_dependency() {
260        let cli = Cli::try_parse_from(["cc-audit", "--type", "dependency", "./"]).unwrap();
261        assert!(matches!(cli.scan_type, ScanType::Dependency));
262    }
263
264    #[test]
265    fn test_parse_ci_mode() {
266        let cli = Cli::try_parse_from(["cc-audit", "--ci", "./skill/"]).unwrap();
267        assert!(cli.ci);
268    }
269
270    #[test]
271    fn test_parse_verbose() {
272        let cli = Cli::try_parse_from(["cc-audit", "-v", "./skill/"]).unwrap();
273        assert!(cli.verbose);
274    }
275
276    #[test]
277    fn test_parse_all_options() {
278        let cli = Cli::try_parse_from([
279            "cc-audit",
280            "--format",
281            "json",
282            "--strict",
283            "--type",
284            "hook",
285            "--recursive",
286            "--ci",
287            "--verbose",
288            "./path/",
289        ])
290        .unwrap();
291        assert!(matches!(cli.format, OutputFormat::Json));
292        assert!(cli.strict);
293        assert!(matches!(cli.scan_type, ScanType::Hook));
294        assert!(cli.recursive);
295        assert!(cli.ci);
296        assert!(cli.verbose);
297    }
298
299    #[test]
300    fn test_default_values() {
301        let cli = Cli::try_parse_from(["cc-audit", "./skill/"]).unwrap();
302        assert!(matches!(cli.format, OutputFormat::Terminal));
303        assert!(matches!(cli.scan_type, ScanType::Skill));
304        assert!(!cli.strict);
305        assert!(!cli.recursive);
306        assert!(!cli.ci);
307        assert!(!cli.verbose);
308        assert!(!cli.include_tests);
309        assert!(!cli.include_node_modules);
310        assert!(!cli.include_vendor);
311        assert!(matches!(cli.min_confidence, Confidence::Tentative));
312    }
313
314    #[test]
315    fn test_parse_include_tests() {
316        let cli = Cli::try_parse_from(["cc-audit", "--include-tests", "./skill/"]).unwrap();
317        assert!(cli.include_tests);
318    }
319
320    #[test]
321    fn test_parse_include_node_modules() {
322        let cli = Cli::try_parse_from(["cc-audit", "--include-node-modules", "./skill/"]).unwrap();
323        assert!(cli.include_node_modules);
324    }
325
326    #[test]
327    fn test_parse_include_vendor() {
328        let cli = Cli::try_parse_from(["cc-audit", "--include-vendor", "./skill/"]).unwrap();
329        assert!(cli.include_vendor);
330    }
331
332    #[test]
333    fn test_parse_all_include_options() {
334        let cli = Cli::try_parse_from([
335            "cc-audit",
336            "--include-tests",
337            "--include-node-modules",
338            "--include-vendor",
339            "./skill/",
340        ])
341        .unwrap();
342        assert!(cli.include_tests);
343        assert!(cli.include_node_modules);
344        assert!(cli.include_vendor);
345    }
346
347    #[test]
348    fn test_parse_min_confidence_tentative() {
349        let cli =
350            Cli::try_parse_from(["cc-audit", "--min-confidence", "tentative", "./skill/"]).unwrap();
351        assert!(matches!(cli.min_confidence, Confidence::Tentative));
352    }
353
354    #[test]
355    fn test_parse_min_confidence_firm() {
356        let cli =
357            Cli::try_parse_from(["cc-audit", "--min-confidence", "firm", "./skill/"]).unwrap();
358        assert!(matches!(cli.min_confidence, Confidence::Firm));
359    }
360
361    #[test]
362    fn test_parse_min_confidence_certain() {
363        let cli =
364            Cli::try_parse_from(["cc-audit", "--min-confidence", "certain", "./skill/"]).unwrap();
365        assert!(matches!(cli.min_confidence, Confidence::Certain));
366    }
367
368    #[test]
369    fn test_parse_skip_comments() {
370        let cli = Cli::try_parse_from(["cc-audit", "--skip-comments", "./skill/"]).unwrap();
371        assert!(cli.skip_comments);
372    }
373
374    #[test]
375    fn test_default_skip_comments_false() {
376        let cli = Cli::try_parse_from(["cc-audit", "./skill/"]).unwrap();
377        assert!(!cli.skip_comments);
378    }
379
380    #[test]
381    fn test_parse_fix_hint() {
382        let cli = Cli::try_parse_from(["cc-audit", "--fix-hint", "./skill/"]).unwrap();
383        assert!(cli.fix_hint);
384    }
385
386    #[test]
387    fn test_default_fix_hint_false() {
388        let cli = Cli::try_parse_from(["cc-audit", "./skill/"]).unwrap();
389        assert!(!cli.fix_hint);
390    }
391
392    #[test]
393    fn test_parse_watch() {
394        let cli = Cli::try_parse_from(["cc-audit", "--watch", "./skill/"]).unwrap();
395        assert!(cli.watch);
396    }
397
398    #[test]
399    fn test_parse_watch_short() {
400        let cli = Cli::try_parse_from(["cc-audit", "-w", "./skill/"]).unwrap();
401        assert!(cli.watch);
402    }
403
404    #[test]
405    fn test_default_watch_false() {
406        let cli = Cli::try_parse_from(["cc-audit", "./skill/"]).unwrap();
407        assert!(!cli.watch);
408    }
409
410    #[test]
411    fn test_parse_init_hook() {
412        let cli = Cli::try_parse_from(["cc-audit", "--init-hook", "./repo/"]).unwrap();
413        assert!(cli.init_hook);
414    }
415
416    #[test]
417    fn test_parse_remove_hook() {
418        let cli = Cli::try_parse_from(["cc-audit", "--remove-hook", "./repo/"]).unwrap();
419        assert!(cli.remove_hook);
420    }
421
422    #[test]
423    fn test_default_init_hook_false() {
424        let cli = Cli::try_parse_from(["cc-audit", "./skill/"]).unwrap();
425        assert!(!cli.init_hook);
426    }
427
428    #[test]
429    fn test_default_remove_hook_false() {
430        let cli = Cli::try_parse_from(["cc-audit", "./skill/"]).unwrap();
431        assert!(!cli.remove_hook);
432    }
433
434    #[test]
435    fn test_parse_malware_db() {
436        let cli =
437            Cli::try_parse_from(["cc-audit", "--malware-db", "./custom.json", "./skill/"]).unwrap();
438        assert!(cli.malware_db.is_some());
439        assert_eq!(cli.malware_db.unwrap().to_str().unwrap(), "./custom.json");
440    }
441
442    #[test]
443    fn test_default_malware_db_none() {
444        let cli = Cli::try_parse_from(["cc-audit", "./skill/"]).unwrap();
445        assert!(cli.malware_db.is_none());
446    }
447
448    #[test]
449    fn test_parse_no_malware_scan() {
450        let cli = Cli::try_parse_from(["cc-audit", "--no-malware-scan", "./skill/"]).unwrap();
451        assert!(cli.no_malware_scan);
452    }
453
454    #[test]
455    fn test_default_no_malware_scan_false() {
456        let cli = Cli::try_parse_from(["cc-audit", "./skill/"]).unwrap();
457        assert!(!cli.no_malware_scan);
458    }
459
460    #[test]
461    fn test_parse_custom_rules() {
462        let cli = Cli::try_parse_from(["cc-audit", "--custom-rules", "./rules.yaml", "./skill/"])
463            .unwrap();
464        assert!(cli.custom_rules.is_some());
465        assert_eq!(cli.custom_rules.unwrap().to_str().unwrap(), "./rules.yaml");
466    }
467
468    #[test]
469    fn test_default_custom_rules_none() {
470        let cli = Cli::try_parse_from(["cc-audit", "./skill/"]).unwrap();
471        assert!(cli.custom_rules.is_none());
472    }
473
474    #[test]
475    fn test_parse_init() {
476        let cli = Cli::try_parse_from(["cc-audit", "--init", "./"]).unwrap();
477        assert!(cli.init);
478    }
479
480    #[test]
481    fn test_default_init_false() {
482        let cli = Cli::try_parse_from(["cc-audit", "./skill/"]).unwrap();
483        assert!(!cli.init);
484    }
485
486    #[test]
487    fn test_parse_warn_only() {
488        let cli = Cli::try_parse_from(["cc-audit", "--warn-only", "./skill/"]).unwrap();
489        assert!(cli.warn_only);
490    }
491
492    #[test]
493    fn test_default_warn_only_false() {
494        let cli = Cli::try_parse_from(["cc-audit", "./skill/"]).unwrap();
495        assert!(!cli.warn_only);
496    }
497
498    #[test]
499    fn test_parse_min_severity_critical() {
500        let cli =
501            Cli::try_parse_from(["cc-audit", "--min-severity", "critical", "./skill/"]).unwrap();
502        assert_eq!(cli.min_severity, Some(Severity::Critical));
503    }
504
505    #[test]
506    fn test_parse_min_severity_high() {
507        let cli = Cli::try_parse_from(["cc-audit", "--min-severity", "high", "./skill/"]).unwrap();
508        assert_eq!(cli.min_severity, Some(Severity::High));
509    }
510
511    #[test]
512    fn test_parse_min_severity_medium() {
513        let cli =
514            Cli::try_parse_from(["cc-audit", "--min-severity", "medium", "./skill/"]).unwrap();
515        assert_eq!(cli.min_severity, Some(Severity::Medium));
516    }
517
518    #[test]
519    fn test_parse_min_severity_low() {
520        let cli = Cli::try_parse_from(["cc-audit", "--min-severity", "low", "./skill/"]).unwrap();
521        assert_eq!(cli.min_severity, Some(Severity::Low));
522    }
523
524    #[test]
525    fn test_default_min_severity_none() {
526        let cli = Cli::try_parse_from(["cc-audit", "./skill/"]).unwrap();
527        assert!(cli.min_severity.is_none());
528    }
529
530    #[test]
531    fn test_parse_min_rule_severity_error() {
532        let cli =
533            Cli::try_parse_from(["cc-audit", "--min-rule-severity", "error", "./skill/"]).unwrap();
534        assert_eq!(cli.min_rule_severity, Some(RuleSeverity::Error));
535    }
536
537    #[test]
538    fn test_parse_min_rule_severity_warn() {
539        let cli =
540            Cli::try_parse_from(["cc-audit", "--min-rule-severity", "warn", "./skill/"]).unwrap();
541        assert_eq!(cli.min_rule_severity, Some(RuleSeverity::Warn));
542    }
543
544    #[test]
545    fn test_default_min_rule_severity_none() {
546        let cli = Cli::try_parse_from(["cc-audit", "./skill/"]).unwrap();
547        assert!(cli.min_rule_severity.is_none());
548    }
549
550    #[test]
551    fn test_warn_only_with_strict_conflict() {
552        // Both options can be parsed, but logic will determine behavior
553        let cli = Cli::try_parse_from(["cc-audit", "--warn-only", "--strict", "./skill/"]).unwrap();
554        assert!(cli.warn_only);
555        assert!(cli.strict);
556    }
557}