Build a YARA-X Malware Detection Engine

Scan Android files for malware using Rust, YARA-X, and Flutter.

Introduction

30 Second Summary

Your phone holds banking credentials, personal photos, and work documents. Malware authors know this, and mobile threats like banking trojans and spyware are growing every year. Security tools that detect these threats rely on pattern matching engines to identify known malicious signatures in files.

In this project, you will build a mobile malware scanner for Android that uses the same pattern matching technology VirusTotal runs in production. You will write a Rust scanning engine, bundle community detection rules, and connect everything to a Flutter interface.

What You'll Build

A Flutter + Rust Android app that scans files for trojans, RATs, ransomware, and spyware using the YARA-X pattern matching engine.

By the end of this project, you'll have:

  • A Rust scanning engine using the yara-x crate with community malware detection rules compiled and evaluated against file bytes.
  • A Flutter UI with file selection, scan progress, and categorized threat results showing severity levels and malware family classification.
  • A bridge layer connecting Rust to Dart via flutter_rust_bridge, proving the full detection pipeline works with standard test patterns.
  • Secret Mission: Add a threat detail screen showing matched rule metadata and implement persistent scan history.

Are there any prerequisites?

This project assumes you have experience with Flutter and Rust individually. You should understand malware concepts like hash-based detection and pattern matching. Basic Android development knowledge is also expected.

Scaffold the Flutter + Rust Project

Your malware scanner needs two engines working together: Flutter for the user interface and Rust for the high-performance scanning logic. flutter_rust_bridge generates the glue code that lets Dart call Rust functions directly, without you managing raw FFI bindings.

In this step, you will scaffold a complete Flutter + Rust project, add the YARA-X scanning crate, and verify the bridge works end-to-end on an Android emulator.

In this step, get ready to:
  • Create the Flutter + Rust project scaffold using flutter_rust_bridge_codegen.
  • Add the yara-x crate and configure Android compilation targets.
  • Run the app on an Android emulator to verify the bridge works.
Create the project scaffold

The flutter_rust_bridge_codegen create command generates a complete Flutter app with a Rust crate wired in. It produces all the platform glue, build scripts, and a sample greeting function so you can verify the bridge immediately.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create the project scaffold by running this command:
flutter_rust_bridge_codegen create malware_detector

What does this command do?

This generates a full Flutter project called malware_detector with a Rust crate at rust/ already wired in. It includes platform-specific build scripts, a rust_builder helper package, and auto-generated bridge glue under lib/src/rust/.

The key starting points are lib/main.dart for Flutter code and rust/src/api/simple.rs for Rust functions exposed to Dart.

  • Move into the new project directory by running:
cd malware_detector
  • Confirm the generated structure includes the Rust crate by running:
ls rust/src/api/
  • You should see simple.rs listed in the output. This file contains the default greeting function that proves the bridge works.

Don't see the rust/ directory?

Make sure you ran the create command from ~/Desktop and that flutter_rust_bridge_codegen is installed globally. Run flutter_rust_bridge_codegen --version to confirm it is available.

help me troubleshoot why flutter_rust_bridge_codegen create did not generate the rust/ directory

Add the yara-x crate and configure Android targets

The yara-x crate is VirusTotal's production YARA pattern matching engine, rewritten entirely in Rust. Its dex-module feature enables parsing Android DEX files during scans, giving your rules access to class names, methods, and strings from APK internals.

  • Open the malware_detector folder in your editor.
  • Open rust/Cargo.toml and replace its entire contents with this:
[package]
name = "rust_lib"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib", "staticlib"]

[dependencies]
flutter_rust_bridge = "2.12.0"
yara-x = { version = "1.17.0", features = ["dex-module"] }

What does this configuration do?

The [lib] section tells Rust to compile as both a C dynamic library (cdylib for Android) and a static library (staticlib for iOS).

The yara-x dependency at version 1.17.0 pulls in the same scanning engine that VirusTotal uses in production. The dex-module feature flag enables Android-specific file format parsing during scans.

  • Save rust/Cargo.toml.

Next, update the Flutter side to include all the dependencies your project will use.

  • Open pubspec.yaml in the project root and replace its entire contents with this:
name: malware_detector
description: YARA-X powered malware detection app

environment:
  sdk: ^3.8.0
  flutter: ^3.32.0

dependencies:
  flutter:
    sdk: flutter
  flutter_rust_bridge: ^2.12.0
  file_picker: ^8.3.7
  rust_lib:
    path: rust_builder

dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^5.0.0

flutter:
  assets:
    - rules/

What did we add here?

  • file_picker lets the user select files from their device for scanning.
  • rust_lib points to the rust_builder directory that flutter_rust_bridge generated. This is how Flutter finds the compiled Rust library.
  • The flutter: assets: - rules/ section registers a directory where you will bundle YARA detection rules later.
  • Save pubspec.yaml.

Now add the Android cross-compilation targets so Rust can compile for Android hardware.

  • Add both Android targets by running this command:
rustup target add aarch64-linux-android x86_64-linux-android

Why two targets?

The aarch64-linux-android target covers physical ARM devices (most phones and tablets). The x86_64-linux-android target covers the Android Studio emulator running on x86 computers.

  • You should see output confirming both targets were installed successfully.

Seeing a download error?

Run rustup update first to ensure your Rust toolchain is current. The yara-x crate requires Rust 1.91.0 or newer.

help me fix rustup target add failing for Android targets

✔️ Awesome, I've got everything!

Great. Make sure both rust/Cargo.toml and pubspec.yaml are saved before moving on.

ⓧ I'd like to double check the full code

Here is the full rust/Cargo.toml:

[package]
name = "rust_lib"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib", "staticlib"]

[dependencies]
flutter_rust_bridge = "2.12.0"
yara-x = { version = "1.17.0", features = ["dex-module"] }

Here is the full pubspec.yaml:

name: malware_detector
description: YARA-X powered malware detection app

environment:
  sdk: ^3.8.0
  flutter: ^3.32.0

dependencies:
  flutter:
    sdk: flutter
  flutter_rust_bridge: ^2.12.0
  file_picker: ^8.3.7
  rust_lib:
    path: rust_builder

dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^5.0.0

flutter:
  assets:
    - rules/
Regenerate bindings and verify the app runs

With the dependencies configured, you need to regenerate the bridge code so Dart knows about the Rust functions. Then you will launch the app to confirm everything compiles and communicates correctly.

  • Regenerate the Dart-Rust bindings by running:
flutter_rust_bridge_codegen generate

What does this command do?

The codegen parses all public Rust functions in rust/src/api/ and generates corresponding async Dart functions under lib/src/rust/. You run this every time you change your Rust API signatures.

  • Make sure your Android emulator is running or a physical device is connected.
  • Create an empty rules/ directory so Flutter does not complain about the missing asset folder:
mkdir -p rules
  • Launch the app on your connected device or emulator by running:
flutter run

What should I see?

The app compiles the Rust crate for your Android target, bundles it into the APK, and launches on the emulator. You should see a default screen displaying a greeting message from Rust (something like a counter or a "Hello" string). This confirms the Flutter-to-Rust bridge is working end-to-end.

  • Confirm you see the greeting from Rust displayed in the Flutter app on your emulator.

Build failing with a compilation error?

The first build downloads and compiles the yara-x crate, which takes several minutes. If you see an error about the Rust toolchain version, run rustup update to get at least version 1.91.0.

If the build fails looking for the Android NDK linker, confirm that Android Studio's NDK is installed. Open Android Studio, go to SDK Manager then SDK Tools, and check that NDK (Side by side) is installed.

help me debug why flutter run fails to compile my Rust code for Android

Your Flutter + Rust scaffold is live and the bridge between Dart and Rust is working. Next up, you will build the Rust scanning engine that uses YARA-X to detect malware patterns in file bytes.

Build the Rust YARA-X Scanning Engine

Your project scaffold is running and the yara-x crate is ready in your Cargo.toml. Now you need a Rust engine that can compile YARA rules and scan file bytes for malware patterns.

In this step, you will build the core scanning module that accepts YARA rule source code, compiles it into an efficient rule set, and evaluates files against those rules. The engine returns structured threat results that flutter_rust_bridge will later expose to Dart.

In this step, get ready to:
  • Define the data types that represent scan results and threat metadata.
  • Implement a rule-loading function that compiles YARA source into a reusable rule set.
  • Implement a file-scanning function that evaluates bytes against compiled rules and extracts match metadata.
Define the scanner data types and global state

Before writing any scanning logic, you need data structures that represent what the engine produces. You also need a place to store compiled rules so they persist across multiple scan calls without recompilation.

  • Create the file rust/src/api/scanner.rs by running this command from your project root:
touch rust/src/api/scanner.rs
  • Open rust/src/api/scanner.rs in your editor and add the following imports, global state, and struct definitions:
use yara_x::{Compiler, Scanner, Rules};
use std::sync::Mutex;
use std::sync::OnceLock;

// Global storage for compiled YARA rules, shared across scan calls
static COMPILED_RULES: OnceLock<Mutex<Rules>> = OnceLock::new();

// Represents a single threat detected by a matching YARA rule
#[derive(Debug, Clone)]
pub struct ThreatResult {
    pub rule_name: String,
    pub description: String,
    pub severity: String,
    pub category: String,
    pub tags: Vec<String>,
}

// The full report returned after scanning a file
#[derive(Debug, Clone)]
pub struct ScanReport {
    pub file_name: String,
    pub threats: Vec<ThreatResult>,
    pub is_clean: bool,
    pub total_rules_checked: u32,
}

What does this code do?

OnceLock is a thread-safe container that can be written to exactly once. Wrapping Rules inside OnceLock<Mutex<Rules>> means you compile your YARA rules a single time on startup, then every subsequent scan reuses them without recompilation.

ThreatResult captures the metadata from a single matched rule: its name, description, severity level, category, and any tags. ScanReport wraps the full list of threats plus a convenience is_clean boolean.

You cannot verify this visually yet. The next chunk adds the first function that uses these types.

Implement the rule loading function

The scanning engine needs a way to receive YARA rule source code, compile it into an efficient internal representation, and store the result for repeated use. Invalid rules should be skipped gracefully so a single bad rule does not block the entire engine.

  • In rust/src/api/scanner.rs, add the load_rules function below the struct definitions:
pub fn load_rules(rule_sources: Vec<String>) -> Result<String, String> {
    let mut compiler = Compiler::new();
    let mut success_count = 0u32;
    let mut error_count = 0u32;

    // Attempt to compile each rule source, skipping failures
    for source in &rule_sources {
        match compiler.add_source(source.as_str()) {
            Ok(_) => success_count += 1,
            Err(e) => {
                error_count += 1;
                eprintln!("Rule compilation warning: {}", e);
            }
        }
    }

    // Build the compiled rule set and store it globally
    let rules = compiler.build();
    COMPILED_RULES
        .set(Mutex::new(rules))
        .map_err(|_| "Rules already loaded".to_string())?;

    Ok(format!(
        "Loaded {} rules successfully, {} skipped",
        success_count, error_count
    ))
}

What does this code do?

  • Compiler::new() creates a fresh YARA-X compiler instance.
  • add_source() feeds one YARA rule file's contents into the compiler. It returns a Result so you can catch syntax errors in individual rules without crashing.
  • compiler.build() consumes the compiler and produces a Rules object. This is the optimized, scan-ready representation of all successfully compiled rules.
  • COMPILED_RULES.set() stores the rules globally. If called a second time it returns an error, preventing accidental double-initialization.
  • Save rust/src/api/scanner.rs.
  • Verify the code compiles by running this from your project root:
cd rust && cargo check && cd ..

You should see no errors. Warnings about unused imports are fine at this stage.

Seeing a compile error?

  • If you see an error about OnceLock not being found, make sure your Rust toolchain is up to date. OnceLock requires Rust 1.70 or newer.
  • If you see an unresolved import for yara_x, confirm that your rust/Cargo.toml has the yara-x dependency with version 1.17.0 and the dex-module feature.

help me fix a cargo check error in my yara-x scanner module

Implement the file scanning function

With rules compiled and stored, you need a function that takes raw file bytes, runs them through the YARA-X scanner, and extracts metadata from any matching rules. Each match produces a ThreatResult with the rule name, severity, category, and tags.

  • In rust/src/api/scanner.rs, add the scan_file_bytes function below load_rules:
pub fn scan_file_bytes(file_name: String, data: Vec<u8>) -> Result<ScanReport, String> {
    // Retrieve the globally stored compiled rules
    let rules_mutex = COMPILED_RULES
        .get()
        .ok_or("Rules not loaded. Call load_rules first.")?;

    let rules = rules_mutex
        .lock()
        .map_err(|e| format!("Lock error: {}", e))?;

    // Create a scanner and run it against the file bytes
    let mut scanner = Scanner::new(&rules);
    let results = scanner
        .scan(data.as_slice())
        .map_err(|e| format!("Scan error: {}", e))?;

    let mut threats: Vec<ThreatResult> = Vec::new();

    // Extract metadata from each matching rule
    for rule in results.matching_rules() {
        let mut description = String::new();
        let mut severity = String::from("Medium");
        let mut category = String::from("Unknown");

        for meta in rule.metadata() {
            match meta.0 {
                "description" => {
                    if let yara_x::MetaValue::String(s) = meta.1 {
                        description = s.to_string();
                    }
                }
                "severity" => {
                    if let yara_x::MetaValue::String(s) = meta.1 {
                        severity = s.to_string();
                    }
                }
                "category" => {
                    if let yara_x::MetaValue::String(s) = meta.1 {
                        category = s.to_string();
                    }
                }
                _ => {}
            }
        }

        let tags: Vec<String> = rule.tags().map(|t| t.to_string()).collect();

        threats.push(ThreatResult {
            rule_name: rule.identifier().to_string(),
            description,
            severity,
            category,
            tags,
        });
    }

    let is_clean = threats.is_empty();

    Ok(ScanReport {
        file_name,
        threats,
        is_clean,
        total_rules_checked: 0,
    })
}

What does this code do?

  • Scanner::new(&rules) creates a scanner bound to the compiled rules. The scanner is reusable for multiple files but each scan() call processes one buffer.
  • results.matching_rules() returns an iterator over every rule that matched the scanned data.
  • rule.metadata() yields (&str, MetaValue) pairs. The code pattern-matches on the key (meta.0) and extracts string values from meta.1 for description, severity, and category.
  • rule.tags() and rule.identifier() pull the rule's tag annotations and its name respectively.
  • Save rust/src/api/scanner.rs.
  • Run cargo check again from the rust/ directory to verify compilation:
cd rust && cargo check && cd ..

You should see a successful check with no errors. You may see warnings about unused functions, which is expected until the bridge generates Dart bindings.

Getting a type mismatch on metadata?

  • Make sure you are matching on yara_x::MetaValue::String(s) with the full path. The MetaValue enum lives in the yara_x namespace.
  • Check that your use statement at the top imports Compiler, Scanner, and Rules from yara_x. Do not import MetaValue directly since you reference it with the full path in the match arms.

help me fix a type error in my scan_file_bytes function

✔️ Awesome, I've got everything!

Great. Make sure you have saved rust/src/api/scanner.rs with both functions in place.

ⓧ I'd like to double check the full code

Here is the complete rust/src/api/scanner.rs file:

use yara_x::{Compiler, Scanner, Rules};
use std::sync::Mutex;
use std::sync::OnceLock;

static COMPILED_RULES: OnceLock<Mutex<Rules>> = OnceLock::new();

#[derive(Debug, Clone)]
pub struct ThreatResult {
    pub rule_name: String,
    pub description: String,
    pub severity: String,
    pub category: String,
    pub tags: Vec<String>,
}

#[derive(Debug, Clone)]
pub struct ScanReport {
    pub file_name: String,
    pub threats: Vec<ThreatResult>,
    pub is_clean: bool,
    pub total_rules_checked: u32,
}

pub fn load_rules(rule_sources: Vec<String>) -> Result<String, String> {
    let mut compiler = Compiler::new();
    let mut success_count = 0u32;
    let mut error_count = 0u32;

    for source in &rule_sources {
        match compiler.add_source(source.as_str()) {
            Ok(_) => success_count += 1,
            Err(e) => {
                error_count += 1;
                eprintln!("Rule compilation warning: {}", e);
            }
        }
    }

    let rules = compiler.build();
    COMPILED_RULES
        .set(Mutex::new(rules))
        .map_err(|_| "Rules already loaded".to_string())?;

    Ok(format!(
        "Loaded {} rules successfully, {} skipped",
        success_count, error_count
    ))
}

pub fn scan_file_bytes(file_name: String, data: Vec<u8>) -> Result<ScanReport, String> {
    let rules_mutex = COMPILED_RULES
        .get()
        .ok_or("Rules not loaded. Call load_rules first.")?;

    let rules = rules_mutex
        .lock()
        .map_err(|e| format!("Lock error: {}", e))?;

    let mut scanner = Scanner::new(&rules);
    let results = scanner
        .scan(data.as_slice())
        .map_err(|e| format!("Scan error: {}", e))?;

    let mut threats: Vec<ThreatResult> = Vec::new();

    for rule in results.matching_rules() {
        let mut description = String::new();
        let mut severity = String::from("Medium");
        let mut category = String::from("Unknown");

        for meta in rule.metadata() {
            match meta.0 {
                "description" => {
                    if let yara_x::MetaValue::String(s) = meta.1 {
                        description = s.to_string();
                    }
                }
                "severity" => {
                    if let yara_x::MetaValue::String(s) = meta.1 {
                        severity = s.to_string();
                    }
                }
                "category" => {
                    if let yara_x::MetaValue::String(s) = meta.1 {
                        category = s.to_string();
                    }
                }
                _ => {}
            }
        }

        let tags: Vec<String> = rule.tags().map(|t| t.to_string()).collect();

        threats.push(ThreatResult {
            rule_name: rule.identifier().to_string(),
            description,
            severity,
            category,
            tags,
        });
    }

    let is_clean = threats.is_empty();

    Ok(ScanReport {
        file_name,
        threats,
        is_clean,
        total_rules_checked: 0,
    })
}
Expose the scanner module and regenerate bindings

Rust needs to know about your new module, and flutter_rust_bridge needs to regenerate Dart bindings so the Flutter side can call your new functions.

  • Open rust/src/api/mod.rs and replace its contents with:
pub mod scanner;
  • Open rust/src/lib.rs and replace its contents with:
mod api;
pub use api::scanner::*;

What do these files do?

mod.rs declares which submodules exist inside rust/src/api/. By listing pub mod scanner, you make the scanner module visible to the rest of the crate.

lib.rs is the crate root. The pub use api::scanner::* line re-exports all public items from your scanner so that flutter_rust_bridge can discover and generate bindings for load_rules and scan_file_bytes.

  • Save both files.
  • Regenerate the Dart bindings by running this command from your project root:
flutter_rust_bridge_codegen generate
  • Confirm the codegen succeeded. You should see output indicating that new Dart files were generated inside lib/src/rust/api/.
  • Verify the full build compiles by running:
cd rust && cargo check && cd ..

A clean check confirms your Rust scanning engine is ready for integration.

Codegen fails with unresolved type?

  • Make sure rust/src/lib.rs has pub use api::scanner::*. The codegen tool looks for public functions and types exported from the crate root.
  • If you see errors about Rules not implementing a bridge trait, that is expected. Only the public functions (load_rules, scan_file_bytes) and the data structs (ThreatResult, ScanReport) are bridged. The COMPILED_RULES static stays internal to Rust.

help me fix flutter_rust_bridge_codegen generate errors

Your Rust scanning engine is complete. Next up, you will write the YARA rules that tell this engine what malware patterns to look for.

Bundle Community YARA Rules

Your Rust scanning engine can compile and evaluate YARA rules, but it has no rules to work with yet. YARA rules are the detection signatures that tell the engine what byte patterns, strings, and conditions indicate malicious software.

In this step, you will create rule files targeting real malware families and register them as Flutter assets. You will also learn the anatomy of a YARA rule by writing one from scratch.

In this step, get ready to:
  • Create community-style YARA rules detecting banking trojans, RATs, ransomware, and spyware.
  • Write an EICAR test rule that demonstrates the meta/strings/condition anatomy.
  • Confirm the Flutter asset registration and plan the Dart-to-Rust loading architecture.
Create the community malware rules

Community YARA rules capture patterns that security researchers have observed in real malware samples. Each rule defines string patterns and conditions that, when matched, flag a file as belonging to a specific malware family.

  • Create a rules/ directory in the root of your Flutter project (the same level as lib/ and rust/) by running this command:
mkdir rules
  • Create a new file called trojan_generic.yar inside the rules/ directory and paste in the following rules:
rule BankingTrojan_Anubis_Generic : banker trojan {
    meta:
        description = "Detects Anubis/BankBot banking trojan patterns"
        severity = "Critical"
        category = "Banking Trojan"
        mitre = "T1417"
    strings:
        $s1 = "ServiceRAT" ascii
        $s2 = "USSD" ascii
        $overlay = "injectActivity" ascii
        $accessibility = "AccessibilityService" ascii
        $sms = "SmsReceiver" ascii
    condition:
        3 of them
}

rule RAT_Metasploit_Android : rat {
    meta:
        description = "Detects Metasploit Android Meterpreter stage"
        severity = "Critical"
        category = "RAT"
        mitre = "T1059"
    strings:
        $stage = "com.metasploit.stage" ascii
        $payload = "com.metasploit.meterpreter" ascii
        $reverse = "ReverseConnection" ascii
    condition:
        any of them
}

rule Ransomware_FileEncryption : ransomware {
    meta:
        description = "Detects file-encrypting ransomware patterns"
        severity = "Critical"
        category = "Ransomware"
    strings:
        $encrypt_service = "EncryptionService" ascii
        $decrypt_service = "DecryptionService" ascii
        $ransom_note = "Your files have been encrypted" ascii nocase
        $bitcoin = /[13][a-km-zA-HJ-NP-Z1-9]{25,34}/ ascii
    condition:
        2 of them
}

rule Spyware_MultiVector : spyware {
    meta:
        description = "Detects multi-vector spyware with call/SMS/network exfiltration"
        severity = "Critical"
        category = "Spyware"
    strings:
        $call_log = "CallLog.Calls" ascii
        $sms_read = "content://sms" ascii
        $location = "getLastKnownLocation" ascii
        $record = "MediaRecorder" ascii
        $upload = "HttpURLConnection" ascii
    condition:
        3 of them
}

What do these rules detect?

Each rule targets a different malware category using string patterns found in real-world samples:

  • BankingTrojan_Anubis_Generic looks for overlay injection and accessibility abuse patterns used by Anubis/BankBot to steal banking credentials.
  • RAT_Metasploit_Android catches the Metasploit Meterpreter payload package names. Any single match triggers the rule.
  • Ransomware_FileEncryption detects encryption service patterns and Bitcoin wallet regex for ransom payment addresses.
  • Spyware_MultiVector identifies apps that access call logs, SMS, location, and upload data to external servers.

The condition section uses threshold logic ("3 of them", "any of them", "2 of them") to reduce false positives. A single matching string is not enough for most rules.

  • Save rules/trojan_generic.yar.
  • Confirm the file exists by running:
ls rules/

You should see trojan_generic.yar listed in the output.

Don't see the file?

  • Make sure you created the rules/ directory at the project root (same level as lib/), not inside lib/ or rust/.
  • Check that the filename ends with .yar and not .yaml or .txt.

Still stuck? Help me figure out why my rules file isn't appearing in the rules/ directory.

✔️ Awesome, I've got everything!

Great. Make sure you have saved rules/trojan_generic.yar before moving on.

ⓧ I'd like to double check the full code

rule BankingTrojan_Anubis_Generic : banker trojan {
    meta:
        description = "Detects Anubis/BankBot banking trojan patterns"
        severity = "Critical"
        category = "Banking Trojan"
        mitre = "T1417"
    strings:
        $s1 = "ServiceRAT" ascii
        $s2 = "USSD" ascii
        $overlay = "injectActivity" ascii
        $accessibility = "AccessibilityService" ascii
        $sms = "SmsReceiver" ascii
    condition:
        3 of them
}

rule RAT_Metasploit_Android : rat {
    meta:
        description = "Detects Metasploit Android Meterpreter stage"
        severity = "Critical"
        category = "RAT"
        mitre = "T1059"
    strings:
        $stage = "com.metasploit.stage" ascii
        $payload = "com.metasploit.meterpreter" ascii
        $reverse = "ReverseConnection" ascii
    condition:
        any of them
}

rule Ransomware_FileEncryption : ransomware {
    meta:
        description = "Detects file-encrypting ransomware patterns"
        severity = "Critical"
        category = "Ransomware"
    strings:
        $encrypt_service = "EncryptionService" ascii
        $decrypt_service = "DecryptionService" ascii
        $ransom_note = "Your files have been encrypted" ascii nocase
        $bitcoin = /[13][a-km-zA-HJ-NP-Z1-9]{25,34}/ ascii
    condition:
        2 of them
}

rule Spyware_MultiVector : spyware {
    meta:
        description = "Detects multi-vector spyware with call/SMS/network exfiltration"
        severity = "Critical"
        category = "Spyware"
    strings:
        $call_log = "CallLog.Calls" ascii
        $sms_read = "content://sms" ascii
        $location = "getLastKnownLocation" ascii
        $record = "MediaRecorder" ascii
        $upload = "HttpURLConnection" ascii
    condition:
        3 of them
}
Write an EICAR test rule

The EICAR test file is a standardized, harmless string developed by the European Institute for Computer Antivirus Research. Every compliant scanner detects it as if it were malware, making it the perfect end-to-end verification tool.

  • Create a new file called eicar_test.yar inside the rules/ directory and paste in this rule:
rule EICAR_Test_File {
    meta:
        description = "Detects EICAR antivirus test file"
        severity = "Low"
        category = "Test"
    strings:
        $eicar = "X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*"
    condition:
        $eicar
}

What is the anatomy of a YARA rule?

This rule demonstrates the three core sections every YARA rule contains:

  • meta stores human-readable metadata. Your Rust scanner extracts description, severity, and category from here to populate ThreatResult fields.
  • strings defines the byte patterns to search for. Patterns can be plain text, hex sequences, or regular expressions (like the Bitcoin address regex in the ransomware rule).
  • condition specifies the boolean logic that must be true for the rule to match. Here, a single string match ($eicar) triggers the detection.

The backslash in the EICAR string is escaped as \\ because YARA uses C-style string escaping.

  • Save rules/eicar_test.yar.
  • Confirm both rule files exist by running:
ls rules/

You should see both eicar_test.yar and trojan_generic.yar in the output.

Seeing only one file?

  • Double check you saved both files inside the same rules/ directory.
  • Make sure the EICAR file is named eicar_test.yar (with an underscore, not a hyphen).

Need more help? Help me check that my YARA rule files are in the correct location.

✔️ Awesome, I've got everything!

Great. Make sure both eicar_test.yar and trojan_generic.yar are saved before continuing.

ⓧ I'd like to double check the full code

rule EICAR_Test_File {
    meta:
        description = "Detects EICAR antivirus test file"
        severity = "Low"
        category = "Test"
    strings:
        $eicar = "X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*"
    condition:
        $eicar
}
Confirm the Flutter asset registration

Flutter apps bundle data files as assets, which are read at runtime using rootBundle. Your pubspec.yaml already registers the rules/ directory as an asset folder from the project scaffolding.

  • Open pubspec.yaml and scroll to the bottom.
  • Verify that the flutter: section includes the rules/ asset entry:
flutter:
  assets:
    - rules/

Why load rules from Dart instead of Rust?

Flutter assets are packaged inside the APK and can only be read through Dart's rootBundle API. Rust code running via FFI cannot access Flutter's bundled assets directly.

The loading architecture works in two steps: Dart reads each .yar file as a string using rootBundle.loadString(), then passes the list of rule strings to Rust's load_rules() function via flutter_rust_bridge. Rust compiles them into a reusable Rules object stored in COMPILED_RULES.

The rules/ entry tells Flutter to include every file in that directory in the final APK. You will wire up the actual Dart loading code in Step 5.

Why community rules instead of writing everything from scratch?

Security researchers publish YARA rules as a way to share threat intelligence. Using community-style rules means your scanner benefits from patterns observed across thousands of real malware samples without needing to reverse-engineer each sample yourself.

Production scanners update their rule sets regularly from sources like YARAify and vendor-published collections. For this project, you bundle a static set to keep things focused on the integration architecture.

Your rule files are bundled and ready. Next up, you will build the Flutter scan interface that lets users pick files and see threat results.

Build the Flutter Scan Interface

Your Rust scanning engine is ready. It can compile YARA rules and scan file bytes for malware patterns.

Now you need a way for users to actually interact with that engine. In this step, you will build the Flutter UI that lets users pick files, triggers the Rust scanner via the bridge, and displays categorized threat results.

In this step, get ready to:
  • Create the app entry point with a dark Material3 theme and Rust bridge initialization.
  • Build the scan screen with file selection, rule loading, and results display.
  • Create a threat card widget with severity badges and category tags.
Create the app entry point and scan screen

The app entry point initializes the flutter_rust_bridge runtime before rendering any widgets. This ensures the Rust functions are available the moment the UI needs them.

  • Create the lib/screens/ directory inside your malware_detector project folder by running:
mkdir -p lib/screens lib/widgets
  • Replace the contents of lib/main.dart with the following code:
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'src/rust/api/scanner.dart';
import 'src/rust/frb_generated.dart';
import 'screens/scan_screen.dart';

Future<void> main() async {
  // Ensure Flutter bindings are ready before calling native code
  WidgetsFlutterBinding.ensureInitialized();
  // Initialize the Rust bridge so scanner functions are available
  await RustLib.init();
  runApp(const MalwareDetectorApp());
}

class MalwareDetectorApp extends StatelessWidget {
  const MalwareDetectorApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'YARA-X Malware Detector',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(
          seedColor: Colors.red,
          brightness: Brightness.dark,
        ),
        useMaterial3: true,
      ),
      home: const ScanScreen(),
    );
  }
}

What does this code do?

The WidgetsFlutterBinding.ensureInitialized() call is required before any plugin or native code runs. Without it, the Rust bridge initialization would crash.

The dark theme with a red seed color fits the security-tool aesthetic. Material3 generates a full color palette from that single seed.

The scan screen is the core of the app. It loads YARA rules on startup, handles file selection, and displays results.

  • Create lib/screens/scan_screen.dart with the following code:
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:file_picker/file_picker.dart';
import 'dart:io';
import '../src/rust/api/scanner.dart';
import '../widgets/threat_card.dart';

class ScanScreen extends StatefulWidget {
  const ScanScreen({super.key});

  @override
  State<ScanScreen> createState() => _ScanScreenState();
}

class _ScanScreenState extends State<ScanScreen> {
  // Track whether YARA rules have been compiled
  bool _rulesLoaded = false;
  // Track whether a scan is in progress
  bool _isScanning = false;
  // Status message shown in the summary card
  String _statusMessage = 'Initializing rules...';
  // Most recent scan report from the Rust engine
  ScanReport? _lastReport;

  @override
  void initState() {
    super.initState();
    _initRules();
  }

  Future<void> _initRules() async {
    try {
      // List of bundled YARA rule files to load
      final ruleFiles = [
        'rules/eicar_test.yar',
        'rules/trojan_generic.yar',
      ];

      // Read each rule file from Flutter assets
      final List<String> ruleSources = [];
      for (final path in ruleFiles) {
        final content = await rootBundle.loadString(path);
        ruleSources.add(content);
      }

      // Pass rule sources to the Rust engine for compilation
      final result = await loadRules(ruleSources: ruleSources);
      setState(() {
        _rulesLoaded = true;
        _statusMessage = result;
      });
    } catch (e) {
      setState(() {
        _statusMessage = 'Error loading rules: $e';
      });
    }
  }

  Future<void> _pickAndScan() async {
    // Open the system file picker
    final result = await FilePicker.platform.pickFiles();
    if (result == null || result.files.single.path == null) return;

    setState(() {
      _isScanning = true;
      _statusMessage = 'Scanning...';
      _lastReport = null;
    });

    try {
      // Read the selected file as bytes
      final file = File(result.files.single.path!);
      final bytes = await file.readAsBytes();

      // Send bytes to the Rust YARA-X scanner
      final report = await scanFileBytes(
        fileName: result.files.single.name,
        data: bytes,
      );

      setState(() {
        _isScanning = false;
        _lastReport = report;
        _statusMessage = report.isClean
            ? 'No threats detected'
            : '${report.threats.length} threat(s) found!';
      });
    } catch (e) {
      setState(() {
        _isScanning = false;
        _statusMessage = 'Scan error: $e';
      });
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('YARA-X Malware Detector'),
        centerTitle: true,
      ),
      body: Padding(
        padding: const EdgeInsets.all(16.0),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            _buildSummaryCard(),
            const SizedBox(height: 16),
            ElevatedButton.icon(
              onPressed: _rulesLoaded && !_isScanning ? _pickAndScan : null,
              icon: const Icon(Icons.search),
              label: const Text('Select File to Scan'),
              style: ElevatedButton.styleFrom(
                padding: const EdgeInsets.symmetric(vertical: 16),
              ),
            ),
            const SizedBox(height: 16),
            if (_isScanning) const Center(child: CircularProgressIndicator()),
            if (_lastReport != null) ...[
              Text(
                'Results',
                style: Theme.of(context).textTheme.titleMedium,
              ),
              const SizedBox(height: 8),
              Expanded(
                child: _lastReport!.isClean
                    ? const Center(
                        child: Column(
                          mainAxisSize: MainAxisSize.min,
                          children: [
                            Icon(Icons.check_circle,
                                color: Colors.green, size: 64),
                            SizedBox(height: 8),
                            Text('File is clean'),
                          ],
                        ),
                      )
                    : ListView.builder(
                        itemCount: _lastReport!.threats.length,
                        itemBuilder: (context, index) {
                          return ThreatCard(
                              threat: _lastReport!.threats[index]);
                        },
                      ),
              ),
            ],
          ],
        ),
      ),
    );
  }

  Widget _buildSummaryCard() {
    return Card(
      child: Padding(
        padding: const EdgeInsets.all(16.0),
        child: Row(
          children: [
            Icon(
              _rulesLoaded ? Icons.shield : Icons.hourglass_empty,
              color: _rulesLoaded ? Colors.green : Colors.orange,
            ),
            const SizedBox(width: 12),
            Expanded(
              child: Text(
                _statusMessage,
                style: Theme.of(context).textTheme.bodyMedium,
              ),
            ),
          ],
        ),
      ),
    );
  }
}

What does this code do?

The screen manages four pieces of state. _rulesLoaded controls whether the scan button is enabled. _isScanning shows a loading spinner during scans.

The _initRules() method reads YARA rule files from Flutter assets using rootBundle.loadString(), then passes the raw rule text to the Rust loadRules() function across the bridge.

The _pickAndScan() method opens the system file picker, reads the selected file as bytes, and passes them to the Rust scanFileBytes() function. The result updates the UI with threat details or a clean status.

  • Save both lib/main.dart and lib/screens/scan_screen.dart.
  • You will not be able to run the app yet because scan_screen.dart imports threat_card.dart which you will create in the next substep.

Seeing import errors in your editor?

The red underline on ../widgets/threat_card.dart is expected. You will create that file next and the error will resolve.

If you see errors on ../src/rust/api/scanner.dart or ../src/rust/frb_generated.dart, make sure you ran flutter_rust_bridge_codegen generate in the previous step.

Help me fix import errors in my scan screen

Build the threat card widget

Each detected threat needs a visual representation. The threat card displays the malware family name, a color-coded severity badge, and the detection category.

  • Create lib/widgets/threat_card.dart with the following code:
import 'package:flutter/material.dart';
import '../src/rust/api/scanner.dart';

class ThreatCard extends StatelessWidget {
  final ThreatResult threat;

  const ThreatCard({super.key, required this.threat});

  // Map severity levels to distinct colors for quick visual scanning
  Color _severityColor() {
    switch (threat.severity.toLowerCase()) {
      case 'critical':
        return Colors.red;
      case 'high':
        return Colors.orange;
      case 'medium':
        return Colors.amber;
      case 'low':
        return Colors.blue;
      default:
        return Colors.grey;
    }
  }

  @override
  Widget build(BuildContext context) {
    return Card(
      margin: const EdgeInsets.only(bottom: 8),
      child: Padding(
        padding: const EdgeInsets.all(12.0),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            // Severity and category badges row
            Row(
              children: [
                Container(
                  padding:
                      const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
                  decoration: BoxDecoration(
                    color: _severityColor(),
                    borderRadius: BorderRadius.circular(4),
                  ),
                  child: Text(
                    threat.severity.toUpperCase(),
                    style: const TextStyle(
                      color: Colors.white,
                      fontSize: 10,
                      fontWeight: FontWeight.bold,
                    ),
                  ),
                ),
                const SizedBox(width: 8),
                Container(
                  padding:
                      const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
                  decoration: BoxDecoration(
                    color: Colors.blueGrey,
                    borderRadius: BorderRadius.circular(4),
                  ),
                  child: Text(
                    threat.category,
                    style: const TextStyle(
                      color: Colors.white,
                      fontSize: 10,
                    ),
                  ),
                ),
              ],
            ),
            const SizedBox(height: 8),
            // Rule name as the primary identifier
            Text(
              threat.ruleName,
              style: Theme.of(context).textTheme.titleSmall?.copyWith(
                    fontWeight: FontWeight.bold,
                  ),
            ),
            // Rule description from YARA metadata
            if (threat.description.isNotEmpty) ...[
              const SizedBox(height: 4),
              Text(
                threat.description,
                style: Theme.of(context).textTheme.bodySmall,
              ),
            ],
            // YARA rule tags as chips
            if (threat.tags.isNotEmpty) ...[
              const SizedBox(height: 8),
              Wrap(
                spacing: 4,
                children: threat.tags
                    .map((tag) => Chip(
                          label: Text(tag, style: const TextStyle(fontSize: 10)),
                          padding: EdgeInsets.zero,
                          materialTapTargetSize:
                              MaterialTapTargetSize.shrinkWrap,
                        ))
                    .toList(),
              ),
            ],
          ],
        ),
      ),
    );
  }
}

What does this code do?

The _severityColor() method maps YARA rule severity strings to colors. Critical threats appear in red, high in orange, medium in amber, and low in blue. This gives users instant visual triage.

The card layout stacks a badge row (severity + category), the rule name, an optional description from the rule's meta section, and YARA tags rendered as Chip widgets. All of this data comes from the ThreatResult struct defined in Rust.

  • Save lib/widgets/threat_card.dart.
  • Confirm the import errors in scan_screen.dart have resolved. Your editor should no longer show a red underline on the threat_card.dart import.

Still seeing import errors?

Make sure the file is saved at exactly lib/widgets/threat_card.dart. The relative import path in scan_screen.dart expects this exact directory structure.

Help me fix my threat_card import path

Verify the summary card and clean state

The scan screen includes two important UI states that run before the user even scans a file. The summary card shows rule loading status, and the clean state provides reassurance when no threats are found.

How does the summary card work?

The _buildSummaryCard() method renders a Card with a shield icon (green when rules are loaded, orange while loading) and the current status message. This gives the user immediate feedback that the scanner is ready.

When a scan completes with no matches, the build method shows a green Icons.check_circle with the text "File is clean" instead of an empty list. This explicit clean state reassures the user that the scan actually ran.

  • Run the app on your Android emulator or device by running:
flutter run
  • You should see a dark-themed screen with a summary card displaying a green shield icon and a status message like "Loaded 6 rules successfully, 0 skipped".
  • The Select File to Scan button should be enabled once the rules finish loading.

App crashes on launch?

If you see an error about rules not loading, check that your pubspec.yaml has the rules/ directory listed under the flutter: assets: section. Flutter must know about asset directories to bundle them.

If the button stays disabled, the _initRules() method may have thrown an exception. Check your terminal output for error messages about rule compilation.

Help me debug my malware scanner app launch issue

✔️ Awesome, I've got everything!

Great. Double check that you have saved all three files before moving on.

ⓧ I'd like to double check the full code

Here is the complete code for all three files.

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'src/rust/api/scanner.dart';
import 'src/rust/frb_generated.dart';
import 'screens/scan_screen.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await RustLib.init();
  runApp(const MalwareDetectorApp());
}

class MalwareDetectorApp extends StatelessWidget {
  const MalwareDetectorApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'YARA-X Malware Detector',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(
          seedColor: Colors.red,
          brightness: Brightness.dark,
        ),
        useMaterial3: true,
      ),
      home: const ScanScreen(),
    );
  }
}
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:file_picker/file_picker.dart';
import 'dart:io';
import '../src/rust/api/scanner.dart';
import '../widgets/threat_card.dart';

class ScanScreen extends StatefulWidget {
  const ScanScreen({super.key});

  @override
  State<ScanScreen> createState() => _ScanScreenState();
}

class _ScanScreenState extends State<ScanScreen> {
  bool _rulesLoaded = false;
  bool _isScanning = false;
  String _statusMessage = 'Initializing rules...';
  ScanReport? _lastReport;

  @override
  void initState() {
    super.initState();
    _initRules();
  }

  Future<void> _initRules() async {
    try {
      final ruleFiles = [
        'rules/eicar_test.yar',
        'rules/trojan_generic.yar',
      ];

      final List<String> ruleSources = [];
      for (final path in ruleFiles) {
        final content = await rootBundle.loadString(path);
        ruleSources.add(content);
      }

      final result = await loadRules(ruleSources: ruleSources);
      setState(() {
        _rulesLoaded = true;
        _statusMessage = result;
      });
    } catch (e) {
      setState(() {
        _statusMessage = 'Error loading rules: $e';
      });
    }
  }

  Future<void> _pickAndScan() async {
    final result = await FilePicker.platform.pickFiles();
    if (result == null || result.files.single.path == null) return;

    setState(() {
      _isScanning = true;
      _statusMessage = 'Scanning...';
      _lastReport = null;
    });

    try {
      final file = File(result.files.single.path!);
      final bytes = await file.readAsBytes();

      final report = await scanFileBytes(
        fileName: result.files.single.name,
        data: bytes,
      );

      setState(() {
        _isScanning = false;
        _lastReport = report;
        _statusMessage = report.isClean
            ? 'No threats detected'
            : '${report.threats.length} threat(s) found!';
      });
    } catch (e) {
      setState(() {
        _isScanning = false;
        _statusMessage = 'Scan error: $e';
      });
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('YARA-X Malware Detector'),
        centerTitle: true,
      ),
      body: Padding(
        padding: const EdgeInsets.all(16.0),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            _buildSummaryCard(),
            const SizedBox(height: 16),
            ElevatedButton.icon(
              onPressed: _rulesLoaded && !_isScanning ? _pickAndScan : null,
              icon: const Icon(Icons.search),
              label: const Text('Select File to Scan'),
              style: ElevatedButton.styleFrom(
                padding: const EdgeInsets.symmetric(vertical: 16),
              ),
            ),
            const SizedBox(height: 16),
            if (_isScanning) const Center(child: CircularProgressIndicator()),
            if (_lastReport != null) ...[
              Text(
                'Results',
                style: Theme.of(context).textTheme.titleMedium,
              ),
              const SizedBox(height: 8),
              Expanded(
                child: _lastReport!.isClean
                    ? const Center(
                        child: Column(
                          mainAxisSize: MainAxisSize.min,
                          children: [
                            Icon(Icons.check_circle,
                                color: Colors.green, size: 64),
                            SizedBox(height: 8),
                            Text('File is clean'),
                          ],
                        ),
                      )
                    : ListView.builder(
                        itemCount: _lastReport!.threats.length,
                        itemBuilder: (context, index) {
                          return ThreatCard(
                              threat: _lastReport!.threats[index]);
                        },
                      ),
              ),
            ],
          ],
        ),
      ),
    );
  }

  Widget _buildSummaryCard() {
    return Card(
      child: Padding(
        padding: const EdgeInsets.all(16.0),
        child: Row(
          children: [
            Icon(
              _rulesLoaded ? Icons.shield : Icons.hourglass_empty,
              color: _rulesLoaded ? Colors.green : Colors.orange,
            ),
            const SizedBox(width: 12),
            Expanded(
              child: Text(
                _statusMessage,
                style: Theme.of(context).textTheme.bodyMedium,
              ),
            ),
          ],
        ),
      ),
    );
  }
}
import 'package:flutter/material.dart';
import '../src/rust/api/scanner.dart';

class ThreatCard extends StatelessWidget {
  final ThreatResult threat;

  const ThreatCard({super.key, required this.threat});

  Color _severityColor() {
    switch (threat.severity.toLowerCase()) {
      case 'critical':
        return Colors.red;
      case 'high':
        return Colors.orange;
      case 'medium':
        return Colors.amber;
      case 'low':
        return Colors.blue;
      default:
        return Colors.grey;
    }
  }

  @override
  Widget build(BuildContext context) {
    return Card(
      margin: const EdgeInsets.only(bottom: 8),
      child: Padding(
        padding: const EdgeInsets.all(12.0),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Row(
              children: [
                Container(
                  padding:
                      const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
                  decoration: BoxDecoration(
                    color: _severityColor(),
                    borderRadius: BorderRadius.circular(4),
                  ),
                  child: Text(
                    threat.severity.toUpperCase(),
                    style: const TextStyle(
                      color: Colors.white,
                      fontSize: 10,
                      fontWeight: FontWeight.bold,
                    ),
                  ),
                ),
                const SizedBox(width: 8),
                Container(
                  padding:
                      const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
                  decoration: BoxDecoration(
                    color: Colors.blueGrey,
                    borderRadius: BorderRadius.circular(4),
                  ),
                  child: Text(
                    threat.category,
                    style: const TextStyle(
                      color: Colors.white,
                      fontSize: 10,
                    ),
                  ),
                ),
              ],
            ),
            const SizedBox(height: 8),
            Text(
              threat.ruleName,
              style: Theme.of(context).textTheme.titleSmall?.copyWith(
                    fontWeight: FontWeight.bold,
                  ),
            ),
            if (threat.description.isNotEmpty) ...[
              const SizedBox(height: 4),
              Text(
                threat.description,
                style: Theme.of(context).textTheme.bodySmall,
              ),
            ],
            if (threat.tags.isNotEmpty) ...[
              const SizedBox(height: 8),
              Wrap(
                spacing: 4,
                children: threat.tags
                    .map((tag) => Chip(
                          label: Text(tag, style: const TextStyle(fontSize: 10)),
                          padding: EdgeInsets.zero,
                          materialTapTargetSize:
                              MaterialTapTargetSize.shrinkWrap,
                        ))
                    .toList(),
              ),
            ],
          ],
        ),
      ),
    );
  }
}

Your Flutter UI is fully wired up. Next, you will connect everything together and run a real scan to verify the full detection pipeline from file selection through YARA-X pattern matching to threat display.

Connect Rust Engine to Flutter and Run a Full Scan

Your scanning engine is built, your YARA rules are bundled, and your Flutter UI is ready to display results.

Now it's time to prove the full pipeline works end-to-end: file selection in Flutter, byte transfer across the bridge, YARA-X pattern matching in Rust, and threat results flowing back to the UI.

In this step, get ready to:
  • Trace how Flutter passes data to the Rust engine via flutter_rust_bridge.
  • Create an EICAR test file on the Android device to trigger detection.
  • Run a full scan and verify the threat card displays correctly.
Trace the data flow from Flutter to Rust

Before you test, it helps to understand exactly how data moves through your app. The bridge between Flutter and Rust handles two async operations: loading rules at startup, and scanning file bytes on demand.

The first operation fires when the app launches. Look at the _initRules() method in scan_screen.dart:

Future<void> _initRules() async {
  try {
    final ruleFiles = [
      'rules/eicar_test.yar',
      'rules/trojan_generic.yar',
    ];

    final List<String> ruleSources = [];
    for (final path in ruleFiles) {
      final content = await rootBundle.loadString(path);
      ruleSources.add(content);
    }

    final result = await loadRules(ruleSources: ruleSources);
    setState(() {
      _rulesLoaded = true;
      _statusMessage = result;
    });
  } catch (e) {
    setState(() {
      _statusMessage = 'Error loading rules: $e';
    });
  }
}

What is happening here?

Flutter reads each .yar file from the bundled assets using rootBundle.loadString(). This returns the raw YARA rule text as a Dart string.

Those strings cross the bridge into Rust via loadRules(). On the Rust side, Compiler::new() compiles them into a reusable Rules object stored in the global OnceLock. This means rules are compiled once and reused for every scan.

The second operation fires when a user picks a file. Look at _pickAndScan():

Future<void> _pickAndScan() async {
  final result = await FilePicker.platform.pickFiles();
  if (result == null || result.files.single.path == null) return;

  setState(() {
    _isScanning = true;
    _statusMessage = 'Scanning...';
    _lastReport = null;
  });

  try {
    final file = File(result.files.single.path!);
    final bytes = await file.readAsBytes();

    final report = await scanFileBytes(
      fileName: result.files.single.name,
      data: bytes,
    );

    setState(() {
      _isScanning = false;
      _lastReport = report;
      _statusMessage = report.isClean
          ? 'No threats detected'
          : '${report.threats.length} threat(s) found!';
    });
  } catch (e) {
    setState(() {
      _isScanning = false;
      _statusMessage = 'Scan error: $e';
    });
  }
}

How does the scan pipeline work?

  • FilePicker.platform.pickFiles() opens the Android file picker and returns the selected file's path.
  • file.readAsBytes() reads the entire file into a Dart Uint8List (byte array).
  • scanFileBytes() sends those bytes across the bridge to Rust, where Scanner::new(&rules).scan(data) runs YARA-X pattern matching against all compiled rules.
  • The Rust function returns a ScanReport struct which flutter_rust_bridge automatically converts into a typed Dart object.

Why pass bytes from Dart instead of a file path?

Flutter assets are bundled inside the APK. They can only be read from the Dart side using rootBundle. Rust code running via FFI does not have access to Flutter's asset system. Passing the rule content as strings and file bytes as Vec<u8> keeps the architecture clean and portable.

Create an EICAR test file on the device

The EICAR test file is a standard 68-byte string that every antivirus engine agrees to detect. It is completely harmless but lets you verify that your detection pipeline works without needing real malware.

  • In your project root, create a file called eicar_test.txt by running this command:
printf '%s' 'X5O!P%@AP[4\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*' > eicar_test.txt

What does this command do?

printf '%s' writes the EICAR string exactly as-is into a file. The single quotes prevent the shell from interpreting special characters like $ and ! in the string.

  • Push the test file to your Android emulator's Download folder by running:
adb push eicar_test.txt /sdcard/Download/eicar_test.txt
  • Verify the file landed on the device by running:
adb shell ls /sdcard/Download/eicar_test.txt
  • You should see the file path echoed back: /sdcard/Download/eicar_test.txt.

Seeing 'No such file or directory'?

  • Make sure your Android emulator is running. You can check connected devices with adb devices.
  • If you have multiple devices connected, specify the emulator with adb -s emulator-5554 push ... (replace with your emulator serial from the devices list).
  • On newer Android versions, the path may be /storage/emulated/0/Download/ instead of /sdcard/Download/. Try the alternative path if the first fails.

Help me push a test file to my Android emulator with adb.

Run the app and verify the full detection pipeline

With the test file on the device, you can now run the complete pipeline: Flutter reads the file, sends bytes to Rust, YARA-X scans them, and threat results appear in the UI.

  • Launch the app on your Android emulator by running:
flutter run
  • Wait for the app to finish building and launching. The summary card at the top should show a green shield icon with a message like Loaded 2 rules successfully, 0 skipped.

What should I see on launch?

The app loads and compiles all YARA rules during initState(). The green shield confirms both eicar_test.yar and trojan_generic.yar compiled without errors. If you see an orange hourglass icon, rules are still loading. If you see an error message, check that your rule files are registered under flutter: assets: in pubspec.yaml.

  • Tap the Select File to Scan button.
  • In the Android file picker that opens, navigate to the Download folder.
  • Select eicar_test.txt.

The scan runs almost instantly. You should see the status message change to 1 threat(s) found! and a threat card appear below.

  • Confirm the threat card shows the following details:
  • A blue LOW severity badge (matching the rule's severity = "Low" metadata).
  • A Test category tag (matching category = "Test").
  • The rule name EICAR_Test_File.
  • The description Detects EICAR antivirus test file.

Why use EICAR for testing?

The EICAR string is a standard test pattern agreed upon by the antivirus industry. It is completely harmless (it is actually a valid DOS executable that prints a message). Every detection engine is expected to flag it, making it the perfect end-to-end test for your scanner.

Your app now uses the same YARA-X engine that VirusTotal runs in production. The only difference is scale. VirusTotal scans billions of files with tens of thousands of rules. Your app starts with a focused rule set for mobile threats.

Scan button stays disabled?

  • The button is disabled until rules finish loading. If the status still shows "Initializing rules...", check the terminal for Rust compilation errors.
  • Make sure you ran flutter_rust_bridge_codegen generate after adding scanner.rs. The Dart bindings for loadRules() and scanFileBytes() are auto-generated from the Rust exports.
  • Verify the rules/ directory is listed under flutter: assets: in pubspec.yaml.

Help me debug why my YARA rules won't load at app startup.

File picker opens but file isn't detected?

  • If the scan shows "No threats detected" after selecting eicar_test.txt, verify the file actually contains the EICAR string. Run adb shell cat /sdcard/Download/eicar_test.txt and confirm the output starts with X5O!P%@AP.
  • If the file is empty, the shell may have expanded special characters. Re-create it using the printf command with single quotes as shown above.

Help me figure out why my EICAR test file isn't being detected.

✔️ Awesome, I've got everything!

Great. Double check you have saved all your files and the scan is working correctly.

ⓧ I'd like to double check the full code

Here is the full scan_screen.dart which handles both the rule loading and scan pipeline:

import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:file_picker/file_picker.dart';
import 'dart:io';
import '../src/rust/api/scanner.dart';
import '../widgets/threat_card.dart';

class ScanScreen extends StatefulWidget {
  const ScanScreen({super.key});

  @override
  State<ScanScreen> createState() => _ScanScreenState();
}

class _ScanScreenState extends State<ScanScreen> {
  bool _rulesLoaded = false;
  bool _isScanning = false;
  String _statusMessage = 'Initializing rules...';
  ScanReport? _lastReport;

  @override
  void initState() {
    super.initState();
    _initRules();
  }

  Future<void> _initRules() async {
    try {
      final ruleFiles = [
        'rules/eicar_test.yar',
        'rules/trojan_generic.yar',
      ];

      final List<String> ruleSources = [];
      for (final path in ruleFiles) {
        final content = await rootBundle.loadString(path);
        ruleSources.add(content);
      }

      final result = await loadRules(ruleSources: ruleSources);
      setState(() {
        _rulesLoaded = true;
        _statusMessage = result;
      });
    } catch (e) {
      setState(() {
        _statusMessage = 'Error loading rules: $e';
      });
    }
  }

  Future<void> _pickAndScan() async {
    final result = await FilePicker.platform.pickFiles();
    if (result == null || result.files.single.path == null) return;

    setState(() {
      _isScanning = true;
      _statusMessage = 'Scanning...';
      _lastReport = null;
    });

    try {
      final file = File(result.files.single.path!);
      final bytes = await file.readAsBytes();

      final report = await scanFileBytes(
        fileName: result.files.single.name,
        data: bytes,
      );

      setState(() {
        _isScanning = false;
        _lastReport = report;
        _statusMessage = report.isClean
            ? 'No threats detected'
            : '${report.threats.length} threat(s) found!';
      });
    } catch (e) {
      setState(() {
        _isScanning = false;
        _statusMessage = 'Scan error: $e';
      });
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('YARA-X Malware Detector'),
        centerTitle: true,
      ),
      body: Padding(
        padding: const EdgeInsets.all(16.0),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            _buildSummaryCard(),
            const SizedBox(height: 16),
            ElevatedButton.icon(
              onPressed: _rulesLoaded && !_isScanning ? _pickAndScan : null,
              icon: const Icon(Icons.search),
              label: const Text('Select File to Scan'),
              style: ElevatedButton.styleFrom(
                padding: const EdgeInsets.symmetric(vertical: 16),
              ),
            ),
            const SizedBox(height: 16),
            if (_isScanning) const Center(child: CircularProgressIndicator()),
            if (_lastReport != null) ...[
              Text(
                'Results',
                style: Theme.of(context).textTheme.titleMedium,
              ),
              const SizedBox(height: 8),
              Expanded(
                child: _lastReport!.isClean
                    ? const Center(
                        child: Column(
                          mainAxisSize: MainAxisSize.min,
                          children: [
                            Icon(Icons.check_circle,
                                color: Colors.green, size: 64),
                            SizedBox(height: 8),
                            Text('File is clean'),
                          ],
                        ),
                      )
                    : ListView.builder(
                        itemCount: _lastReport!.threats.length,
                        itemBuilder: (context, index) {
                          return ThreatCard(
                              threat: _lastReport!.threats[index]);
                        },
                      ),
              ),
            ],
          ],
        ),
      ),
    );
  }

  Widget _buildSummaryCard() {
    return Card(
      child: Padding(
        padding: const EdgeInsets.all(16.0),
        child: Row(
          children: [
            Icon(
              _rulesLoaded ? Icons.shield : Icons.hourglass_empty,
              color: _rulesLoaded ? Colors.green : Colors.orange,
            ),
            const SizedBox(width: 12),
            Expanded(
              child: Text(
                _statusMessage,
                style: Theme.of(context).textTheme.bodyMedium,
              ),
            ),
          ],
        ),
      ),
    );
  }
}

Secret mission

Threat Details and Scan History

Your scanner detects threats, but a real security tool needs deeper investigation capabilities. Add a threat detail screen that reveals the full metadata behind each detection, then implement persistent scan history so you can track every file you have ever analyzed.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing costs.

Resources you used:

  • The malware_detector Flutter + Rust project directory on your Desktop.
  • The flutter_rust_bridge_codegen binary installed via cargo install.
  • Android Rust compilation targets (aarch64-linux-android and x86_64-linux-android).
  • The malware detector app installed on your Android emulator or device.

Keep everything running

No action needed. Choose this if you're still actively building or want to extend the app further.

  • Your project files remain in ~/Desktop/malware_detector.
  • The flutter_rust_bridge_codegen tool stays installed globally for future Flutter + Rust projects.
  • The Android Rust targets remain available for cross-compilation.

Pause - I'll come back to this later

Shut down running processes to free up system memory. Your project files stay intact so you can resume anytime.

  • Stop any running flutter run process by pressing Ctrl+C in the terminal.
  • Close the Android emulator if it is still running.
  • Your project files and installed tools remain ready for when you return.

Delete - I don't want to use this again

Remove all project resources and tools installed during this project.

  • Stop any running flutter run process by pressing Ctrl+C in the terminal.
  • Delete the project directory by running:
rm -rf ~/Desktop/malware_detector
  • Uninstall the flutter_rust_bridge_codegen tool by running:
cargo uninstall flutter_rust_bridge_codegen
  • Remove the Android Rust compilation targets by running:
rustup target remove aarch64-linux-android x86_64-linux-android
  • Uninstall the app from your Android emulator or device. Open Settings on the device, go to Apps, find YARA-X Malware Detector, and tap Uninstall.

Nice Work!

Nice Work!

Nice work! You just built a production-grade mobile malware scanner that uses the same YARA pattern matching engine powering VirusTotal's threat detection infrastructure.

You've learned how to:

  • Build a Rust-based YARA-X scanning engine that compiles community malware rules and evaluates files for trojans, RATs, ransomware, and spyware.
  • Integrate Rust with Flutter using flutter_rust_bridge for high-performance native scanning on Android.
  • Build a complete scan interface with file selection, threat results display, severity classification, and verified the full detection pipeline with the EICAR standard test pattern.
  • Secret Mission: Added a threat detail screen with full rule metadata and persistent scan history using sqflite.

Ready to quiz yourself?