Skip to main content

max / makenotwork

14.8 KB · 458 lines History Blame Raw
1 //! Layer 4: YARA rule matching via yara-x.
2 //!
3 //! Rules are compiled once at startup from all `.yar` files in the configured
4 //! rules directory. The compiled rules are stored in ScanPipeline and reused.
5
6 use std::path::Path;
7 use std::time::Duration;
8
9 use super::{ErrorPolicy, LayerResult, LayerVerdict};
10
11 /// In-process deterministic layer. A YARA scan error (timeout, rule
12 /// compilation failure at scan time) is structurally suspicious — fail closed.
13 pub const ERROR_POLICY: ErrorPolicy = ErrorPolicy::FailClosed;
14
15 /// Maximum time for a single YARA scan before it is aborted.
16 const YARA_SCAN_TIMEOUT: Duration = Duration::from_secs(30);
17
18 /// Compile all YARA rules from `.yar` files in a directory.
19 /// Returns None if the directory doesn't exist or contains no rules.
20 pub fn compile_rules_from_dir(dir: &str) -> Result<Option<yara_x::Rules>, String> {
21 let path = Path::new(dir);
22 if !path.exists() {
23 tracing::info!(dir = %dir, "YARA rules directory not found, skipping");
24 return Ok(None);
25 }
26
27 let mut compiler = yara_x::Compiler::new();
28 let mut rule_count = 0;
29 let mut skipped_count = 0;
30
31 let entries = std::fs::read_dir(path)
32 .map_err(|e| format!("Failed to read YARA rules directory: {}", e))?;
33
34 for entry in entries {
35 let entry = entry.map_err(|e| format!("Failed to read directory entry: {}", e))?;
36 let file_path = entry.path();
37
38 if file_path.extension().is_some_and(|ext| ext == "yar" || ext == "yara") {
39 let source = match std::fs::read_to_string(&file_path) {
40 Ok(s) => s,
41 Err(e) => {
42 tracing::warn!(file = %file_path.display(), error = %e, "skipping unreadable YARA rule file");
43 skipped_count += 1;
44 continue;
45 }
46 };
47
48 // Per-file fail-open. Third-party rule corpora (e.g. Florian Roth's
49 // signature-base) include rules that exercise built-in identifiers
50 // (`filename`, `filepath`, `extension`, ...) which yara-x's pure-Rust
51 // engine does not yet implement. A single such rule must not take
52 // down the whole scanner — skip the file and log the rule path so
53 // operators can audit coverage gaps.
54 match compiler.add_source(source.as_str()) {
55 Ok(_) => {
56 rule_count += 1;
57 tracing::debug!(file = %file_path.display(), "Loaded YARA rule file");
58 }
59 Err(e) => {
60 tracing::warn!(
61 file = %file_path.display(),
62 error = %e,
63 "skipping YARA rule file that yara-x cannot compile"
64 );
65 skipped_count += 1;
66 }
67 }
68 }
69 }
70
71 if skipped_count > 0 {
72 tracing::info!(skipped_count, "YARA rule files skipped due to unsupported features");
73 }
74
75 if rule_count == 0 {
76 tracing::info!(dir = %dir, "No YARA rule files found");
77 return Ok(None);
78 }
79
80 let rules = compiler
81 .build();
82
83 tracing::info!(rule_count, dir = %dir, "YARA rules compiled");
84 Ok(Some(rules))
85 }
86
87 /// Scan file data against compiled YARA rules.
88 pub fn scan_with_yara(rules: &yara_x::Rules, data: &[u8]) -> LayerResult {
89 let mut scanner = yara_x::Scanner::new(rules);
90 scanner.set_timeout(YARA_SCAN_TIMEOUT);
91
92 let scan_results = match scanner.scan(data) {
93 Ok(results) => results,
94 Err(e) => {
95 return LayerResult {
96 layer: "yara",
97 verdict: LayerVerdict::Error,
98 detail: Some(format!("YARA scan failed: {}", e)),
99 };
100 }
101 };
102
103 let matching_rules: Vec<String> = scan_results
104 .matching_rules()
105 .map(|rule| {
106 let ns = rule.namespace();
107 let name = rule.identifier();
108 if ns == "default" {
109 name.to_string()
110 } else {
111 format!("{}:{}", ns, name)
112 }
113 })
114 .collect();
115
116 if matching_rules.is_empty() {
117 LayerResult {
118 layer: "yara",
119 verdict: LayerVerdict::Pass,
120 detail: None,
121 }
122 } else {
123 LayerResult {
124 layer: "yara",
125 verdict: LayerVerdict::Fail,
126 detail: Some(format!("Matched rules: {}", matching_rules.join(", "))),
127 }
128 }
129 }
130
131 /// Scan a spooled file against YARA rules. Reads the file into memory
132 /// (yara-x's `Scanner::scan` takes a byte slice). Path-based entry exists
133 /// so the streaming code path has a clean call site even though it does
134 /// not yet save on memory; the win comes when yara-x exposes mmap input.
135 pub fn scan_with_yara_path(rules: &yara_x::Rules, path: &std::path::Path) -> LayerResult {
136 match std::fs::read(path) {
137 Ok(data) => scan_with_yara(rules, &data),
138 Err(e) => LayerResult {
139 layer: "yara",
140 verdict: LayerVerdict::Error,
141 detail: Some(format!("read spool {}: {e}", path.display())),
142 },
143 }
144 }
145
146 #[cfg(test)]
147 mod tests {
148 use super::*;
149
150 #[test]
151 fn no_rules_dir_returns_none() {
152 let result = compile_rules_from_dir("/nonexistent/path/to/rules");
153 assert!(result.is_ok());
154 assert!(result.unwrap().is_none());
155 }
156
157 #[test]
158 fn clean_data_passes() {
159 // Compile a simple test rule that looks for "MALWARE_SIGNATURE"
160 let mut compiler = yara_x::Compiler::new();
161 compiler
162 .add_source(
163 r#"
164 rule test_malware {
165 strings:
166 $sig = "MALWARE_SIGNATURE"
167 condition:
168 $sig
169 }
170 "#,
171 )
172 .unwrap();
173 let rules = compiler.build();
174
175 let result = scan_with_yara(&rules, b"this is clean data");
176 assert_eq!(result.verdict, LayerVerdict::Pass);
177 }
178
179 #[test]
180 fn malicious_data_fails() {
181 let mut compiler = yara_x::Compiler::new();
182 compiler
183 .add_source(
184 r#"
185 rule test_malware {
186 strings:
187 $sig = "MALWARE_SIGNATURE"
188 condition:
189 $sig
190 }
191 "#,
192 )
193 .unwrap();
194 let rules = compiler.build();
195
196 let result = scan_with_yara(&rules, b"contains MALWARE_SIGNATURE inside");
197 assert_eq!(result.verdict, LayerVerdict::Fail);
198 assert!(result.detail.unwrap().contains("test_malware"));
199 }
200
201 #[test]
202 fn path_entry_matches_buffered() {
203 let mut compiler = yara_x::Compiler::new();
204 compiler
205 .add_source(
206 r#"
207 rule test_malware {
208 strings:
209 $sig = "MALWARE_SIGNATURE"
210 condition:
211 $sig
212 }
213 "#,
214 )
215 .unwrap();
216 let rules = compiler.build();
217
218 for sample in [&b"clean bytes"[..], &b"hit MALWARE_SIGNATURE here"[..]] {
219 let buffered = scan_with_yara(&rules, sample);
220 let tmp = tempfile::NamedTempFile::new().unwrap();
221 std::fs::write(tmp.path(), sample).unwrap();
222 let path_based = scan_with_yara_path(&rules, tmp.path());
223 assert_eq!(buffered.verdict, path_based.verdict);
224 }
225 }
226
227 // ── Adversarial tests (test-fuzz) ──
228
229 #[test]
230 fn empty_data_passes() {
231 let mut compiler = yara_x::Compiler::new();
232 compiler
233 .add_source(
234 r#"
235 rule test_malware {
236 strings:
237 $sig = "MALWARE_SIGNATURE"
238 condition:
239 $sig
240 }
241 "#,
242 )
243 .unwrap();
244 let rules = compiler.build();
245
246 let result = scan_with_yara(&rules, b"");
247 assert_eq!(result.verdict, LayerVerdict::Pass);
248 }
249
250 #[test]
251 fn multiple_rules_all_reported() {
252 let mut compiler = yara_x::Compiler::new();
253 compiler
254 .add_source(
255 r#"
256 rule rule_alpha {
257 strings:
258 $a = "ALPHA"
259 condition:
260 $a
261 }
262 rule rule_beta {
263 strings:
264 $b = "BETA"
265 condition:
266 $b
267 }
268 "#,
269 )
270 .unwrap();
271 let rules = compiler.build();
272
273 let result = scan_with_yara(&rules, b"ALPHA and BETA are both here");
274 assert_eq!(result.verdict, LayerVerdict::Fail);
275 let detail = result.detail.unwrap();
276 assert!(detail.contains("rule_alpha"), "Missing rule_alpha in: {}", detail);
277 assert!(detail.contains("rule_beta"), "Missing rule_beta in: {}", detail);
278 }
279
280 #[test]
281 fn partial_match_does_not_trigger() {
282 let mut compiler = yara_x::Compiler::new();
283 compiler
284 .add_source(
285 r#"
286 rule test_exact {
287 strings:
288 $sig = "EXACT_MATCH"
289 condition:
290 $sig
291 }
292 "#,
293 )
294 .unwrap();
295 let rules = compiler.build();
296
297 // Partial overlap should not match
298 let result = scan_with_yara(&rules, b"EXACT_MATC");
299 assert_eq!(result.verdict, LayerVerdict::Pass);
300 }
301
302 #[test]
303 fn large_clean_data_passes() {
304 let mut compiler = yara_x::Compiler::new();
305 compiler
306 .add_source(
307 r#"
308 rule test_sig {
309 strings:
310 $sig = "DANGEROUS"
311 condition:
312 $sig
313 }
314 "#,
315 )
316 .unwrap();
317 let rules = compiler.build();
318
319 // 1MB of clean data
320 let data = vec![b'A'; 1_000_000];
321 let result = scan_with_yara(&rules, &data);
322 assert_eq!(result.verdict, LayerVerdict::Pass);
323 }
324
325 #[test]
326 fn signature_at_end_of_data() {
327 let mut compiler = yara_x::Compiler::new();
328 compiler
329 .add_source(
330 r#"
331 rule end_sig {
332 strings:
333 $sig = "TAIL"
334 condition:
335 $sig
336 }
337 "#,
338 )
339 .unwrap();
340 let rules = compiler.build();
341
342 let mut data = vec![b'X'; 10000];
343 data.extend_from_slice(b"TAIL");
344 let result = scan_with_yara(&rules, &data);
345 assert_eq!(result.verdict, LayerVerdict::Fail);
346 assert!(result.detail.unwrap().contains("end_sig"));
347 }
348
349 #[test]
350 fn compile_rules_from_real_temp_dir() {
351 let dir = tempfile::tempdir().unwrap();
352
353 // Write a valid YARA rule file
354 std::fs::write(
355 dir.path().join("test.yar"),
356 r#"
357 rule hello_world {
358 strings:
359 $hw = "Hello, World!"
360 condition:
361 $hw
362 }
363 "#,
364 )
365 .unwrap();
366
367 let result = compile_rules_from_dir(dir.path().to_str().unwrap());
368 assert!(result.is_ok());
369 let rules = result.unwrap();
370 assert!(rules.is_some(), "Should have compiled one rule");
371
372 // Verify the rules work
373 let rules = rules.unwrap();
374 let scan = scan_with_yara(&rules, b"Hello, World!");
375 assert_eq!(scan.verdict, LayerVerdict::Fail);
376 assert!(scan.detail.unwrap().contains("hello_world"));
377 }
378
379 #[test]
380 fn empty_dir_returns_none() {
381 let dir = tempfile::tempdir().unwrap();
382 let result = compile_rules_from_dir(dir.path().to_str().unwrap());
383 assert!(result.is_ok());
384 assert!(result.unwrap().is_none());
385 }
386
387 #[test]
388 fn non_yar_files_ignored() {
389 let dir = tempfile::tempdir().unwrap();
390 std::fs::write(dir.path().join("readme.txt"), "not a rule").unwrap();
391 std::fs::write(dir.path().join("rules.json"), "{}").unwrap();
392
393 let result = compile_rules_from_dir(dir.path().to_str().unwrap());
394 assert!(result.is_ok());
395 assert!(result.unwrap().is_none());
396 }
397
398 #[test]
399 fn invalid_yara_rule_is_skipped_not_fatal() {
400 // Per-file fail-open: a single uncompilable rule (e.g. one using a
401 // yara-x-unsupported built-in identifier from a third-party corpus)
402 // must not abort the entire scanner. The file is logged and skipped.
403 let dir = tempfile::tempdir().unwrap();
404 std::fs::write(dir.path().join("bad.yar"), "this is not valid YARA syntax").unwrap();
405
406 let result = compile_rules_from_dir(dir.path().to_str().unwrap());
407 assert!(result.is_ok(), "skipped-on-error, not aborted");
408 // No valid rules in the dir, so the function returns Ok(None) — the
409 // pipeline interprets None as "yara not configured" → Skip verdict.
410 assert!(result.unwrap().is_none());
411 }
412
413 #[test]
414 fn mixed_valid_and_invalid_rules_keeps_valid() {
415 let dir = tempfile::tempdir().unwrap();
416 std::fs::write(
417 dir.path().join("good.yar"),
418 r#"rule clean_test { strings: $s = "marker" condition: $s }"#,
419 ).unwrap();
420 std::fs::write(dir.path().join("bad.yar"), "not yara at all").unwrap();
421
422 let result = compile_rules_from_dir(dir.path().to_str().unwrap());
423 assert!(result.is_ok());
424 assert!(result.unwrap().is_some(), "valid rules still compile when bad files are present");
425 }
426
427 #[test]
428 fn default_namespace_rule_is_unprefixed() {
429 // Catches the L79 `==` → `!=` mutation. The function emits "rule" for
430 // default-namespace rules but "ns:rule" otherwise. Under the mutant the
431 // prefix logic inverts. Existing tests check `detail.contains("rule_x")`
432 // which is also true for "default:rule_x", so they don't catch the flip.
433 // Pin the exact format.
434 let mut compiler = yara_x::Compiler::new();
435 compiler
436 .add_source(
437 r#"
438 rule plain {
439 strings:
440 $a = "TARGET"
441 condition:
442 $a
443 }
444 "#,
445 )
446 .unwrap();
447 let rules = compiler.build();
448 let result = scan_with_yara(&rules, b"TARGET in data");
449 let detail = result.detail.unwrap();
450 // Default-namespace rule must appear unprefixed.
451 assert!(detail.contains("plain"), "missing rule name: {detail}");
452 assert!(
453 !detail.contains("default:"),
454 "default-namespace rules must not be prefixed; got {detail}"
455 );
456 }
457 }
458