rsigma_parser/lint/
fix.rs1use std::borrow::Cow;
24
25use yamlpath::{Component, Route};
26
27use super::{FixDisposition, FixPatch, LintWarning};
28
29#[derive(Debug, Clone)]
31pub struct SourceFixOutcome {
32 pub fixed_source: String,
35 pub applied: usize,
37 pub failed: usize,
40}
41
42pub fn json_pointer_to_route(path: &str) -> Route<'static> {
48 let trimmed = path.strip_prefix('/').unwrap_or(path);
49 if trimmed.is_empty() {
50 return Route::default();
51 }
52
53 let components: Vec<Component<'static>> = trimmed
54 .split('/')
55 .map(|segment| {
56 if let Ok(idx) = segment.parse::<usize>() {
57 Component::Index(idx)
58 } else {
59 Component::Key(Cow::Owned(segment.to_string()))
60 }
61 })
62 .collect();
63
64 Route::from(components)
65}
66
67pub fn apply_single_fix_patch(
73 doc: &yamlpath::Document,
74 patch: &FixPatch,
75) -> Result<yamlpath::Document, String> {
76 match patch {
77 FixPatch::ReplaceValue { path, new_value } => {
78 let yp = yamlpatch::Patch {
79 route: json_pointer_to_route(path),
80 operation: yamlpatch::Op::Replace(yaml_serde::Value::String(new_value.clone())),
81 };
82 yamlpatch::apply_yaml_patches(doc, &[yp]).map_err(|e| e.to_string())
83 }
84 FixPatch::ReplaceKey { path, new_key } => apply_rename_key(doc, path, new_key),
85 FixPatch::Remove { path } => {
86 let yp = yamlpatch::Patch {
87 route: json_pointer_to_route(path),
88 operation: yamlpatch::Op::Remove,
89 };
90 yamlpatch::apply_yaml_patches(doc, &[yp]).map_err(|e| e.to_string())
91 }
92 }
93}
94
95pub fn apply_rename_key(
98 doc: &yamlpath::Document,
99 path: &str,
100 new_key: &str,
101) -> Result<yamlpath::Document, String> {
102 let route = json_pointer_to_route(path);
103
104 let key_feature = doc
105 .query_key_only(&route)
106 .map_err(|e| format!("route query failed for key rename: {e}"))?;
107
108 let (start, end) = key_feature.location.byte_span;
109 let mut patched = doc.source().to_string();
110 patched.replace_range(start..end, new_key);
111
112 yamlpath::Document::new(patched).map_err(|e| format!("re-parse after key rename failed: {e}"))
113}
114
115pub fn apply_fixes_to_source(source: &str, warnings: &[&LintWarning]) -> SourceFixOutcome {
125 let safe_fixes = || {
126 warnings.iter().filter(|w| {
127 w.fix
128 .as_ref()
129 .is_some_and(|f| f.disposition == FixDisposition::Safe)
130 })
131 };
132
133 let mut current_doc = match yamlpath::Document::new(source.to_string()) {
134 Ok(d) => d,
135 Err(_) => {
136 return SourceFixOutcome {
137 fixed_source: source.to_string(),
138 applied: 0,
139 failed: safe_fixes().count(),
140 };
141 }
142 };
143
144 let mut applied = 0usize;
145 let mut failed = 0usize;
146
147 for w in safe_fixes() {
148 let fix = w.fix.as_ref().expect("filtered to fixes above");
149 let mut ok = true;
150 for patch in &fix.patches {
151 match apply_single_fix_patch(¤t_doc, patch) {
152 Ok(new_doc) => current_doc = new_doc,
153 Err(_) => {
154 failed += 1;
155 ok = false;
156 break;
157 }
158 }
159 }
160 if ok {
161 applied += 1;
162 }
163 }
164
165 SourceFixOutcome {
166 fixed_source: current_doc.source().to_string(),
167 applied,
168 failed,
169 }
170}
171
172#[cfg(test)]
173mod tests {
174 use super::*;
175 use crate::lint::lint_yaml_str;
176 use insta::assert_snapshot;
177
178 #[test]
179 fn json_pointer_root() {
180 let route = json_pointer_to_route("/");
181 assert!(route.is_empty());
182 }
183
184 #[test]
185 fn json_pointer_empty() {
186 let route = json_pointer_to_route("");
187 assert!(route.is_empty());
188 }
189
190 #[test]
191 fn json_pointer_simple_key() {
192 let route = json_pointer_to_route("/status");
193 assert_snapshot!(format!("{route:?}"), @r#"Route { route: [Key("status")] }"#);
194 }
195
196 #[test]
197 fn json_pointer_nested() {
198 let route = json_pointer_to_route("/logsource/category");
199 assert_snapshot!(format!("{route:?}"), @r#"Route { route: [Key("logsource"), Key("category")] }"#);
200 }
201
202 #[test]
203 fn json_pointer_with_index() {
204 let route = json_pointer_to_route("/tags/2");
205 assert_snapshot!(format!("{route:?}"), @r#"Route { route: [Key("tags"), Index(2)] }"#);
206 }
207
208 #[test]
209 fn json_pointer_detection_path() {
210 let route = json_pointer_to_route("/detection/selection/CommandLine|contains");
211 assert_snapshot!(format!("{route:?}"), @r#"Route { route: [Key("detection"), Key("selection"), Key("CommandLine|contains")] }"#);
212 }
213
214 #[test]
215 fn fix_replace_value_on_file() {
216 let yaml = "title: Test\nstatus: experimetal\nlevel: medium\n";
217 let doc = yamlpath::Document::new(yaml.to_string()).unwrap();
218 let route = json_pointer_to_route("/status");
219 let patch = yamlpatch::Patch {
220 route,
221 operation: yamlpatch::Op::Replace(yaml_serde::Value::String(
222 "experimental".to_string(),
223 )),
224 };
225 let result = yamlpatch::apply_yaml_patches(&doc, &[patch]).unwrap();
226 assert_snapshot!(result.source(), @r"
227 title: Test
228 status: experimental
229 level: medium
230 ");
231 }
232
233 #[test]
234 fn fix_remove_on_file() {
235 let yaml = "title: Test\ntags:\n - attack.execution\n - attack.execution\n - attack.defense_evasion\n";
236 let doc = yamlpath::Document::new(yaml.to_string()).unwrap();
237 let route = json_pointer_to_route("/tags/1");
238 let patch = yamlpatch::Patch {
239 route,
240 operation: yamlpatch::Op::Remove,
241 };
242 let result = yamlpatch::apply_yaml_patches(&doc, &[patch]).unwrap();
243 assert_snapshot!(result.source(), @r"
244 title: Test
245 tags:
246 - attack.execution
247 - attack.defense_evasion
248 ");
249 }
250
251 #[test]
252 fn fix_rename_key_top_level() {
253 let yaml = "title: Test\nStatus: experimental\nlevel: medium\n";
254 let doc = yamlpath::Document::new(yaml.to_string()).unwrap();
255 let result = apply_rename_key(&doc, "/Status", "status").unwrap();
256 assert_snapshot!(result.source(), @r"
257 title: Test
258 status: experimental
259 level: medium
260 ");
261 }
262
263 #[test]
264 fn fix_rename_key_nested() {
265 let yaml = "title: Test\nlogsource:\n Category: test\n product: windows\n";
266 let doc = yamlpath::Document::new(yaml.to_string()).unwrap();
267 let result = apply_rename_key(&doc, "/logsource/Category", "category").unwrap();
268 assert_snapshot!(result.source(), @r"
269 title: Test
270 logsource:
271 category: test
272 product: windows
273 ");
274 }
275
276 #[test]
277 fn fix_rename_detection_key_with_modifiers() {
278 let yaml = "title: Test\nlogsource:\n category: test\ndetection:\n sel:\n Cmd|all|re:\n - foo\n - bar\n condition: sel\n";
279 let doc = yamlpath::Document::new(yaml.to_string()).unwrap();
280 let result = apply_rename_key(&doc, "/detection/sel/Cmd|all|re", "Cmd|re").unwrap();
281 assert_snapshot!(result.source(), @r"
282 title: Test
283 logsource:
284 category: test
285 detection:
286 sel:
287 Cmd|re:
288 - foo
289 - bar
290 condition: sel
291 ");
292 }
293
294 #[test]
295 fn sequential_patches_reparse_correctly() {
296 let yaml = "title: Test\ntags:\n - a\n - a\n - b\n - b\n - c\n";
297 let doc = yamlpath::Document::new(yaml.to_string()).unwrap();
298
299 let patch1 = yamlpatch::Patch {
300 route: json_pointer_to_route("/tags/1"),
301 operation: yamlpatch::Op::Remove,
302 };
303 let doc = yamlpatch::apply_yaml_patches(&doc, &[patch1]).unwrap();
304
305 let patch2 = yamlpatch::Patch {
307 route: json_pointer_to_route("/tags/2"),
308 operation: yamlpatch::Op::Remove,
309 };
310 let doc = yamlpatch::apply_yaml_patches(&doc, &[patch2]).unwrap();
311
312 assert_snapshot!(doc.source(), @r"
313 title: Test
314 tags:
315 - a
316 - b
317 - c
318 ");
319 }
320
321 #[test]
322 fn apply_fixes_to_source_corrects_invalid_status() {
323 let source = "title: Test\nstatus: expreimental\nlogsource:\n category: test\ndetection:\n sel:\n field: value\n condition: sel\n";
324 let warnings = lint_yaml_str(source);
325 let fixable: Vec<&LintWarning> = warnings.iter().filter(|w| w.fix.is_some()).collect();
326 let outcome = apply_fixes_to_source(source, &fixable);
327 assert_eq!(outcome.applied, 1);
328 assert_eq!(outcome.failed, 0);
329 assert!(outcome.fixed_source.contains("status: experimental"));
330 assert!(!outcome.fixed_source.contains("expreimental"));
331 }
332
333 #[test]
334 fn apply_fixes_to_source_no_fixes_returns_input() {
335 let source = "title: Test\nstatus: test\nlogsource:\n category: test\ndetection:\n sel:\n field: value\n condition: sel\n";
336 let outcome = apply_fixes_to_source(source, &[]);
337 assert_eq!(outcome.applied, 0);
338 assert_eq!(outcome.failed, 0);
339 assert_eq!(outcome.fixed_source, source);
340 }
341
342 #[test]
343 fn apply_fixes_to_source_skips_unparseable() {
344 let warning_src = "title: Test\nStatus: test\n";
345 let warnings = lint_yaml_str(warning_src);
346 let fixable: Vec<&LintWarning> = warnings.iter().filter(|w| w.fix.is_some()).collect();
347 let broken = "title: [unterminated\n";
349 let outcome = apply_fixes_to_source(broken, &fixable);
350 assert_eq!(outcome.applied, 0);
351 assert_eq!(outcome.fixed_source, broken);
352 }
353}