agentshield/fix/
deserializer.rs1use once_cell::sync::Lazy;
2use regex::Regex;
3use std::path::Path;
4
5use super::{AppliedFix, FixOutput};
6
7static LOADER_ARG_RE: Lazy<Regex> =
8 Lazy::new(|| Regex::new(r"Loader=[A-Za-z0-9_.]+").expect("valid regex"));
9
10pub fn fix_unsafe_deserializers(content: &str, path: &Path) -> Option<FixOutput> {
12 let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
13 if ext != "py" {
14 return None;
15 }
16
17 let mut modified_lines = Vec::new();
18 let mut fixes = Vec::new();
19 let mut made_changes = false;
20
21 let lines: Vec<&str> = content.lines().collect();
22
23 for (line_idx, line) in lines.iter().enumerate() {
24 let trimmed = line.trim();
25
26 if trimmed.contains("yaml.load(")
28 && !trimmed.contains("yaml.safe_load(")
29 && !trimmed.contains("SafeLoader")
30 && !trimmed.contains("CSafeLoader")
31 && !trimmed.starts_with('#')
32 {
33 let new_line = if line.contains("Loader=") {
34 LOADER_ARG_RE
35 .replace(line, "Loader=yaml.SafeLoader")
36 .to_string()
37 } else if line.contains("yaml.load(") {
38 line.replace("yaml.load(", "yaml.safe_load(")
40 } else {
41 line.to_string()
42 };
43
44 if new_line != *line {
45 fixes.push(AppliedFix {
46 rule_id: "SHIELD-016".into(),
47 description: "Replaced unsafe 'yaml.load' with 'yaml.safe_load'".into(),
48 line_number: line_idx + 1,
49 });
50 modified_lines.push(new_line);
51 made_changes = true;
52 continue;
53 }
54 }
55
56 if trimmed.contains("pickle.loads(") && !trimmed.starts_with('#') {
58 let new_line = line.replace("pickle.loads(", "json.loads(");
59 fixes.push(AppliedFix {
60 rule_id: "SHIELD-016".into(),
61 description: "Replaced insecure 'pickle.loads' with 'json.loads'".into(),
62 line_number: line_idx + 1,
63 });
64 modified_lines.push(new_line);
65 made_changes = true;
66 continue;
67 }
68
69 modified_lines.push(line.to_string());
70 }
71
72 if made_changes
74 && content.contains("import pickle")
75 && !modified_lines.join("\n").contains("pickle.")
76 {
77 for line in &mut modified_lines {
78 if line.trim() == "import pickle" {
79 *line = line.replace("import pickle", "import json");
80 }
81 }
82 }
83
84 if !made_changes {
85 return None;
86 }
87
88 let mut output = modified_lines.join("\n");
89 if content.ends_with('\n') {
90 output.push('\n');
91 }
92
93 Some(FixOutput {
94 content: output,
95 fixes,
96 })
97}
98
99#[cfg(test)]
100mod tests {
101 use super::*;
102
103 #[test]
104 fn test_fix_yaml_load_to_safe_load() {
105 let code = "import yaml\ndata = yaml.load(user_input)\n";
106 let res = fix_unsafe_deserializers(code, Path::new("server.py")).unwrap();
107 assert_eq!(
108 res.content,
109 "import yaml\ndata = yaml.safe_load(user_input)\n"
110 );
111 assert_eq!(res.fixes.len(), 1);
112 assert_eq!(res.fixes[0].rule_id, "SHIELD-016");
113 }
114
115 #[test]
116 fn test_fix_pickle_loads_to_json_loads() {
117 let code = "import pickle\ndata = pickle.loads(payload)\n";
118 let res = fix_unsafe_deserializers(code, Path::new("loader.py")).unwrap();
119 assert_eq!(res.content, "import json\ndata = json.loads(payload)\n");
120 assert_eq!(res.fixes.len(), 1);
121 }
122}